From 94e3056d6248d86ceba4f27ad7e577d69850e948 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:08:43 +0000 Subject: [PATCH 1/3] fix(service-datasource): external validate reads the live object registry The federation service's listObjects and getObject read the engine's object registry (objectql service), resolved when validation runs, instead of the metadata service's boot-time copy. An object saved at runtime through PUT /meta/object or the import is listed and judged with no restart. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../21842-validate-reads-live-registry.md | 11 + ...validate-sees-runtime-save.dogfood.test.ts | 121 +++++++++++ ...ernal-validate-reads-live-registry.test.ts | 188 ++++++++++++++++++ .../services/service-datasource/src/plugin.ts | 51 +++-- 4 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 .changeset/21842-validate-reads-live-registry.md create mode 100644 packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts create mode 100644 packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts diff --git a/.changeset/21842-validate-reads-live-registry.md b/.changeset/21842-validate-reads-live-registry.md new file mode 100644 index 00000000000..2cf16191693 --- /dev/null +++ b/.changeset/21842-validate-reads-live-registry.md @@ -0,0 +1,11 @@ +--- +'@objectstack/service-datasource': patch +--- + +fix(service-datasource): `POST /api/v1/datasources/:name/external/validate` sees a federated object saved at runtime, with no restart (#21842) + +Clause-②: no + +- **What was wrong.** The federation service read its objects from the `metadata` service. That service holds a copy of the engine's object registry taken once at boot. `PUT /api/v1/meta/object/:name`, and the external-table import that saves through it, write `sys_metadata` and the engine registry, but never that copy. So after a federated object was saved at runtime, validate answered the code-defined objects only, and listed the saved one after a restart. An object re-saved at runtime was judged on its definition as it stood at boot. +- **What it reads now.** `ExternalDatasourceServicePlugin` reads objects (`listObjects` and `getObject`) from the engine's object registry on the `objectql` service, which is the registry the save writes through to. The registry is looked up when validation runs, not when the plugin starts. A saved or imported object is listed and judged on what was saved, the moment the save answers. +- **What does not move.** Validation judges the same things: the same federation predicate, the same comparison and the same datasource definitions. At boot the registry holds the objects the old copy was taken from, so the boot validation gate sweeps the same objects. No route's request or response shape changes, and no export is added. diff --git a/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts b/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts new file mode 100644 index 00000000000..cd67fe7061e --- /dev/null +++ b/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21842, ADR-0015 §6] `POST /datasources/:name/external/validate` lists a +// federated object the moment it is saved at runtime, with no restart, over +// the real showcase composition. +// +// ## What was broken +// +// The federation service read its objects from the `metadata` service, which +// holds a copy of the engine's object registry taken once at boot. +// `PUT /meta/object/:name`, and the external-table import that saves through +// it, write `sys_metadata` and the engine registry, never that copy. Measured +// on the showcase (`objectstack dev`): after a federated object was saved on +// `showcase_external`, validate still answered the two code-defined objects +// only, and listed the saved one after a restart. The service now reads the +// engine registry, resolved when validation runs. +// +// ## What this file pins, and what it leaves to the seam pins +// +// The listing, through the doors an operator uses: the code-defined objects, +// an object saved through `PUT /meta/object/:name`, and an object imported +// through `…/external/tables/:remote/import`. Under this harness the +// federation service is mounted as an extra plugin and finds no `metadata` +// service at its `init()`, so it holds no datasource definition and every row +// it answers is `ok` with no comparison made. The VERDICT on a runtime-saved +// object is therefore pinned beside the plugin +// (`packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts`), +// and was measured on `objectstack dev`, where the definition is read. +// +// The working directory is a temporary one because the showcase's external +// datasource and its fixture both name a cwd-relative SQLite file: this +// file's remote database is its own, not one a parallel file writes. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** The showcase's external datasource (`showcase-external.datasource.ts`). */ +const DATASOURCE = 'showcase_external'; +/** The two objects the showcase binds to it in code (`data/objects/external/`). */ +const CODE_DEFINED = ['showcase_ext_customer', 'showcase_ext_order']; +/** Saved at runtime through the metadata door, bound to the remote `customers` table. */ +const SAVED = 'dogfood_ext_cust_21842'; +/** Imported at runtime from the remote `orders` table. */ +const IMPORTED = 'dogfood_ext_ord_21842'; + +const validatePath = `/datasources/${DATASOURCE}/external/validate`; + +function listedObjects(json: unknown): string[] { + const body = json as { data?: { results?: Array<{ object?: unknown }> } } | undefined; + return (body?.data?.results ?? []).map((r) => String(r.object)).sort(); +} + +describe('external validate lists a federated object saved at runtime, with no restart (showcase)', () => { + let stack: VerifyStack; + let token: string; + let prevCwd: string; + let dir: string; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack.apiAs(token, method, path, body); + const json: unknown = await res.json().catch(() => ({})); + return { status: res.status, json }; + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21842-')); + process.chdir(dir); + // Provision the remote tables, exactly as `os dev` does at boot (the + // harness imports only the stack's default export, so `onEnable` never + // runs on its own). + await onEnable({ logger: { info() {}, warn() {} } } as never); + stack = await bootStack(showcaseStack, { + databaseFile: join(dir, 'showcase.db'), + extraPlugins: [new ExternalDatasourceServicePlugin()], + }); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('lists the code-defined objects bound to the datasource', async () => { + const validated = await call('POST', validatePath); + expect(validated.status, JSON.stringify(validated.json)).toBe(200); + expect(listedObjects(validated.json)).toEqual(CODE_DEFINED); + }); + + it('lists an object saved through PUT /meta/object/:name, and still the code-defined ones', async () => { + const saved = await call('PUT', `/meta/object/${SAVED}`, { + name: SAVED, + label: 'Dogfood External Customer', + sharingModel: 'public_read_write', + datasource: DATASOURCE, + external: { remoteName: 'customers' }, + fields: { name: { type: 'text', label: 'Name' }, email: { type: 'text', label: 'Email' } }, + }); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + + const validated = await call('POST', validatePath); + expect(validated.status, JSON.stringify(validated.json)).toBe(200); + expect(listedObjects(validated.json)).toEqual([...CODE_DEFINED, SAVED].sort()); + }); + + it('lists an object imported from a remote table, beside the saved and code-defined ones', async () => { + const imported = await call('POST', `/datasources/${DATASOURCE}/external/tables/orders/import`, { name: IMPORTED }); + expect(imported.status, JSON.stringify(imported.json)).toBe(201); + + const validated = await call('POST', validatePath); + expect(validated.status, JSON.stringify(validated.json)).toBe(200); + expect(listedObjects(validated.json)).toEqual([...CODE_DEFINED, IMPORTED, SAVED].sort()); + }); +}); diff --git a/packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts b/packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts new file mode 100644 index 00000000000..9c50a6d1a9c --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21842] Federated validation reads its objects from the engine's object + * registry, the one a runtime save writes through to. + * + * ## The defect this pins closed + * + * `ExternalDatasourceServicePlugin` wired the service's `listObjects` and + * `getObject` to the `'metadata'` service. That service holds a copy of the + * engine's object registry taken once at boot (`ObjectQLPlugin`'s startup + * bridge). `PUT /api/v1/meta/object/:name`, and the external-table import that + * saves through it, write `sys_metadata` and the engine registry, never that + * copy. Measured on the showcase (`objectstack dev`): after a federated object + * was saved at runtime on `showcase_external`, `POST …/external/validate` + * still answered the two code-defined objects only, and listed the saved one + * after a restart. + * + * ## What each case pins + * + * - an object saved at runtime is listed AND judged on its live definition by + * the per-datasource validate and by the boot gate's sweep, with no restart; + * the code-defined object is still listed; + * - an object re-saved at runtime is judged on what was saved, not on the + * copy taken at boot; + * - the registry is asked for when validation runs, not at `init()`; + * - the metadata service's boot copy is never read for objects, so no second + * source can answer. + * + * The fakes model the measured mechanism: `metadata` holds the boot copy, the + * `objectql` registry is live, and a "runtime save" writes only the registry. + * The plugin is imported through a RELATIVE specifier, so these cases measure + * `src/` and need no build. The booted-stack half is the dogfood pin's + * (`packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts`). + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import type { IntrospectedSchema } from '@objectstack/spec/contracts'; +import { ExternalDatasourceServicePlugin } from '../plugin.js'; +import type { ExternalDatasourceService, ObjectLike } from '../external-datasource-service.js'; + +const remoteSchema = (): IntrospectedSchema => + ({ + dialect: 'sqlite', + tables: { + customers: { + name: 'customers', + indexes: [], + columns: [ + { name: 'id', type: 'text', nullable: false, primaryKey: true }, + { name: 'name', type: 'text', nullable: true, primaryKey: false }, + { name: 'email', type: 'text', nullable: true, primaryKey: false }, + ], + }, + }, + }) as unknown as IntrospectedSchema; + +const federated = (name: string, fields: string[]): ObjectLike => ({ + name, + datasource: 'warehouse', + external: { remoteName: 'customers' }, + fields: Object.fromEntries(fields.map((f) => [f, { type: 'text' }])), +}); + +/** Defined in code: in the registry and in the boot copy alike. */ +const CODE = federated('code_cust', ['name', 'email']); +/** Saved at runtime: the remote has no `loyalty_tier`, so a real comparison reports it. */ +const SAVED = federated('saved_cust', ['name', 'loyalty_tier']); +const LOYALTY_MISSING = { kind: 'missing_column', remoteName: 'customers', column: 'loyalty_tier', severity: 'error' }; + +interface Harness { + ctx: PluginContext; + services: Map; + /** The engine's object registry — what a runtime save writes through to. */ + live: Map; + /** The metadata service's object reads, over the copy it took at boot. */ + bootCopyReads: { getObject: ReturnType; listObjects: ReturnType; list: ReturnType }; +} + +/** A kernel context whose `getService` throws on an unregistered name, as the kernel's does. */ +function harness(opts: { registry?: boolean } = {}): Harness { + const services = new Map(); + const live = new Map([[CODE.name, CODE]]); + const bootCopy = [CODE]; + const bootCopyReads = { + getObject: vi.fn(async (n: string) => bootCopy.find((o) => o.name === n)), + listObjects: vi.fn(async () => bootCopy), + list: vi.fn(async () => bootCopy), + }; + services.set('data', { introspectDatasource: async () => remoteSchema() }); + services.set('metadata', { + get: async (type: string, name: string) => + type === 'datasource' && name === 'warehouse' ? { name, schemaMode: 'external' } : undefined, + ...bootCopyReads, + }); + if (opts.registry !== false) services.set('objectql', engineOver(live)); + const ctx = { + getService: (name: string) => { + if (!services.has(name)) throw new Error(`Service '${name}' not found`); + return services.get(name); + }, + registerService: (name: string, service: unknown) => { + services.set(name, service); + }, + trigger: async () => undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + } as unknown as PluginContext; + return { ctx, services, live, bootCopyReads }; +} + +function engineOver(live: Map) { + return { + registry: { + getObject: (n: string) => live.get(n), + getAllObjects: () => [...live.values()], + }, + }; +} + +async function federation(h: Harness): Promise { + await new ExternalDatasourceServicePlugin().init(h.ctx); + return h.services.get('external-datasource') as ExternalDatasourceService; +} + +const byObject = (a: { object: string }, b: { object: string }) => a.object.localeCompare(b.object); + +describe('federated validation reads the live object registry (#21842)', () => { + it('an object saved at runtime is listed and judged on its definition, with no restart; the code-defined object is still listed', async () => { + const h = harness(); + const svc = await federation(h); + + h.live.set(SAVED.name, SAVED); // the save's write-through; the boot copy is untouched + + const expected = [ + { ok: true, datasource: 'warehouse', object: 'code_cust', diffs: [] }, + { ok: false, datasource: 'warehouse', object: 'saved_cust', diffs: [LOYALTY_MISSING] }, + ]; + const scoped = await svc.validateDatasource('warehouse'); + expect(scoped.ok).toBe(false); + expect([...scoped.results].sort(byObject)).toEqual(expected); + // The boot gate's sweep reads the same population. + const sweep = await svc.validateAll(); + expect([...sweep.results].sort(byObject)).toEqual(expected); + }); + + it('an object re-saved at runtime is judged on what was saved, not on the copy taken at boot', async () => { + const h = harness(); + const svc = await federation(h); + + h.live.set(CODE.name, federated(CODE.name, ['name', 'email', 'loyalty_tier'])); + + expect(await svc.validateObject(CODE.name)).toEqual({ + ok: false, + datasource: 'warehouse', + object: 'code_cust', + diffs: [LOYALTY_MISSING], + }); + expect((await svc.validateDatasource('warehouse')).results).toEqual([ + { ok: false, datasource: 'warehouse', object: 'code_cust', diffs: [LOYALTY_MISSING] }, + ]); + }); + + it('asks for the registry when validation runs, so an engine registered after init is read', async () => { + const h = harness({ registry: false }); + const svc = await federation(h); + h.live.set(SAVED.name, SAVED); + h.services.set('objectql', engineOver(h.live)); + + const report = await svc.validateDatasource('warehouse'); + + expect(report.results.map((r) => r.object).sort()).toEqual(['code_cust', 'saved_cust']); + }); + + it('never reads objects from the metadata service\'s boot copy', async () => { + const h = harness(); + const svc = await federation(h); + h.live.set(SAVED.name, SAVED); + + await svc.validateAll(); + await svc.validateDatasource('warehouse'); + await svc.validateObject(SAVED.name); + + expect(h.bootCopyReads.listObjects).not.toHaveBeenCalled(); + expect(h.bootCopyReads.list).not.toHaveBeenCalled(); + expect(h.bootCopyReads.getObject).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/services/service-datasource/src/plugin.ts b/packages/services/service-datasource/src/plugin.ts index d1d6a114886..5fd0285237c 100644 --- a/packages/services/service-datasource/src/plugin.ts +++ b/packages/services/service-datasource/src/plugin.ts @@ -2,7 +2,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import type { MetadataProtocol } from '@objectstack/spec/api'; -import type { IDataEngine, IntrospectedSchema } from '@objectstack/spec/contracts'; +import type { IDataEngine, IObjectQLEngine, IntrospectedSchema } from '@objectstack/spec/contracts'; import { ExternalDatasourceService, type ExternalDatasourceServiceConfig, @@ -23,17 +23,22 @@ import { // (`getDriverByName?`, [#4251]), which makes the degradation reachable for // the first time instead of silently dead. +/** + * The `'metadata'` service members this plugin reads: datasource and package + * definitions, and the catalog write. Objects are not read here — see + * `objectRegistry` in {@link ExternalDatasourceServicePlugin.init}. + */ interface MetadataServiceLike { get: (type: string, name: string) => Promise; - getObject?: (name: string) => Promise; - listObjects?: () => Promise; - list?: (type: string) => Promise; register?: (type: string, name: string, data: unknown) => Promise | void; } /** The metadata door's save, as the `'protocol'` service declares it. */ type MetadataSaveDoor = Pick; +/** The two reads the federation service makes of the engine's object registry. */ +type ObjectRegistryReads = Pick; + export interface ExternalDatasourceServicePluginOptions { /** Override the introspection function (mainly for tests). */ introspect?: (datasource: string) => Promise; @@ -45,8 +50,8 @@ export interface ExternalDatasourceServicePluginOptions { * into the kernel as the `'external-datasource'` service (ADR-0015 §6.1). * * It bridges the decoupled {@link ExternalDatasourceService} to the live - * `IDataEngine` (for driver introspection) and `IMetadataService` (for object - * + datasource reads). + * `IDataEngine` (for driver introspection), the engine's object registry (for + * object reads) and `IMetadataService` (for datasource reads). */ export class ExternalDatasourceServicePlugin implements Plugin { name = 'com.objectstack.service-external-datasource'; @@ -95,15 +100,37 @@ export class ExternalDatasourceServicePlugin implements Plugin { return typeof protocol?.saveMetaItem === 'function' ? (protocol as MetadataSaveDoor) : undefined; }; + /** + * [#21842] Where the federation service reads objects: the engine's object + * registry, on the `'objectql'` service (`IObjectQLEngine.registry`). + * + * It is the registry that save writes through to (`applyRegistryWriteThrough` + * in `@objectstack/metadata-protocol`), so an object saved at runtime through + * `PUT /api/v1/meta/object/:name`, or by the import (`persistObject` below), + * is validated the moment it is saved. The objects used to come from the `'metadata'` + * service, which holds a copy of this registry taken once at boot + * (`ObjectQLPlugin`'s startup bridge): a runtime save never reached that + * copy, so `POST …/external/validate` did not see the object until the next + * restart. ⛔ Never a second copy kept here and refreshed by hand: this + * registry is the one source. + * + * Resolved where it is used, never at `init()`, for the reason + * `metadataSaveDoor` is (AGENTS.md, "Startup registry reads"). + */ + const objectRegistry = (): ObjectRegistryReads | undefined => { + const registry = safeGetService>>(ctx, 'objectql')?.registry; + return typeof registry?.getObject === 'function' && typeof registry.getAllObjects === 'function' + ? registry + : undefined; + }; + const config: ExternalDatasourceServiceConfig = { introspect, getDatasource: async (n) => (await metadata?.get('datasource', n)) as DatasourceLike | undefined, - getObject: async (n) => - (metadata?.getObject ? await metadata.getObject(n) : await metadata?.get('object', n)) as ObjectLike | undefined, - listObjects: async () => - ((metadata?.listObjects - ? await metadata.listObjects() - : await metadata?.list?.('object')) ?? []) as ObjectLike[], + // [#21842] Both object reads come from the one registry, so the sweep + // judges the very definitions it listed. See `objectRegistry`. + getObject: async (n) => objectRegistry()?.getObject(n) as ObjectLike | undefined, + listObjects: async () => (objectRegistry()?.getAllObjects() ?? []) as ObjectLike[], // Persist the refreshed snapshot as an `external_catalog` metadata record // so the boot gate + Studio's schema browser can read it without // re-introspecting. No-op when the metadata service can't write. From c68487ae6cd00deb934a342d329d039f3d2d2827 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:33:38 +0000 Subject: [PATCH 2/3] docs(changeset): state the boot-gate reading as measured on objectstack dev Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .changeset/21842-validate-reads-live-registry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21842-validate-reads-live-registry.md b/.changeset/21842-validate-reads-live-registry.md index 2cf16191693..ae9a5cb897a 100644 --- a/.changeset/21842-validate-reads-live-registry.md +++ b/.changeset/21842-validate-reads-live-registry.md @@ -8,4 +8,4 @@ Clause-②: no - **What was wrong.** The federation service read its objects from the `metadata` service. That service holds a copy of the engine's object registry taken once at boot. `PUT /api/v1/meta/object/:name`, and the external-table import that saves through it, write `sys_metadata` and the engine registry, but never that copy. So after a federated object was saved at runtime, validate answered the code-defined objects only, and listed the saved one after a restart. An object re-saved at runtime was judged on its definition as it stood at boot. - **What it reads now.** `ExternalDatasourceServicePlugin` reads objects (`listObjects` and `getObject`) from the engine's object registry on the `objectql` service, which is the registry the save writes through to. The registry is looked up when validation runs, not when the plugin starts. A saved or imported object is listed and judged on what was saved, the moment the save answers. -- **What does not move.** Validation judges the same things: the same federation predicate, the same comparison and the same datasource definitions. At boot the registry holds the objects the old copy was taken from, so the boot validation gate sweeps the same objects. No route's request or response shape changes, and no export is added. +- **What does not move.** The comparison is unchanged: the same federation predicate, the same column and type checks, and datasource definitions read from the same place. On `objectstack dev` the boot validation gate sweeps the same objects with the same verdicts as before. No route's request or response shape changes, and no export is added. From 9489265ae06f63ef13ac63fb2dd177f6a2b727eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:40:24 +0000 Subject: [PATCH 3/3] test(service-datasource): start-ordering pins read objects from the engine registry; door pin asserts the verdict After merging main, the federation service reads objects from the engine registry and datasource definitions from the metadata service resolved at use. The start-ordering unit file's harness now serves its objects from an objectql registry fake, its re-ask case is told apart by the datasource definition, and its no-metadata case has no registry either. The door pin now asserts the runtime-saved object's real verdict. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- ...validate-sees-runtime-save.dogfood.test.ts | 57 ++++++++++++++----- .../external-metadata-read-at-use.test.ts | 42 +++++++++----- 2 files changed, 71 insertions(+), 28 deletions(-) diff --git a/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts b/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts index cd67fe7061e..113b176dc2b 100644 --- a/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts @@ -15,17 +15,18 @@ // only, and listed the saved one after a restart. The service now reads the // engine registry, resolved when validation runs. // -// ## What this file pins, and what it leaves to the seam pins +// ## What this file pins // -// The listing, through the doors an operator uses: the code-defined objects, -// an object saved through `PUT /meta/object/:name`, and an object imported -// through `…/external/tables/:remote/import`. Under this harness the -// federation service is mounted as an extra plugin and finds no `metadata` -// service at its `init()`, so it holds no datasource definition and every row -// it answers is `ok` with no comparison made. The VERDICT on a runtime-saved -// object is therefore pinned beside the plugin -// (`packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts`), -// and was measured on `objectstack dev`, where the definition is read. +// Through the doors an operator uses: the code-defined objects are listed; +// an object saved through `PUT /meta/object/:name` is listed with a real +// verdict (it declares a column the remote lacks, and exactly that column is +// reported); an object imported through `…/external/tables/:remote/import` is +// listed beside them. The verify harness composes no metadata plugin, so its +// `metadata` service is the kernel's fallback registered after the +// federation service's `init()`, as on `objectstack start`; the service reads +// it when validation runs ([#21876]), so each row here is a real comparison. +// The seam pins sit beside the plugin +// (`packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts`). // // The working directory is a temporary one because the showcase's external // datasource and its fixture both name a cwd-relative SQLite file: this @@ -45,16 +46,30 @@ const DATASOURCE = 'showcase_external'; const CODE_DEFINED = ['showcase_ext_customer', 'showcase_ext_order']; /** Saved at runtime through the metadata door, bound to the remote `customers` table. */ const SAVED = 'dogfood_ext_cust_21842'; +/** A column the saved object declares and the remote `customers` table does not have. */ +const MISSING = 'loyalty_tier'; /** Imported at runtime from the remote `orders` table. */ const IMPORTED = 'dogfood_ext_ord_21842'; const validatePath = `/datasources/${DATASOURCE}/external/validate`; +interface Row { + object: string; + ok: boolean; + diffs: Array<{ kind: string; column?: string; severity: string }>; +} + +function rowsOf(json: unknown): Row[] { + const body = json as { data?: { results?: Row[] } } | undefined; + return body?.data?.results ?? []; +} + function listedObjects(json: unknown): string[] { - const body = json as { data?: { results?: Array<{ object?: unknown }> } } | undefined; - return (body?.data?.results ?? []).map((r) => String(r.object)).sort(); + return rowsOf(json).map((r) => String(r.object)).sort(); } +const rowOf = (json: unknown, object: string) => rowsOf(json).find((r) => r.object === object); + describe('external validate lists a federated object saved at runtime, with no restart (showcase)', () => { let stack: VerifyStack; let token: string; @@ -94,20 +109,30 @@ describe('external validate lists a federated object saved at runtime, with no r expect(listedObjects(validated.json)).toEqual(CODE_DEFINED); }); - it('lists an object saved through PUT /meta/object/:name, and still the code-defined ones', async () => { + it('lists an object saved through PUT /meta/object/:name with a real verdict, and still the code-defined ones', async () => { const saved = await call('PUT', `/meta/object/${SAVED}`, { name: SAVED, label: 'Dogfood External Customer', sharingModel: 'public_read_write', datasource: DATASOURCE, external: { remoteName: 'customers' }, - fields: { name: { type: 'text', label: 'Name' }, email: { type: 'text', label: 'Email' } }, + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + [MISSING]: { type: 'text', label: 'Loyalty Tier' }, + }, }); expect(saved.status, JSON.stringify(saved.json)).toBe(200); const validated = await call('POST', validatePath); expect(validated.status, JSON.stringify(validated.json)).toBe(200); expect(listedObjects(validated.json)).toEqual([...CODE_DEFINED, SAVED].sort()); + // Compared, not waved through: exactly the declared column the remote lacks. + expect(rowOf(validated.json, SAVED)).toMatchObject({ + ok: false, + diffs: [{ kind: 'missing_column', remoteName: 'customers', column: MISSING, severity: 'error' }], + }); + for (const name of CODE_DEFINED) expect(rowOf(validated.json, name)?.ok, name).toBe(true); }); it('lists an object imported from a remote table, beside the saved and code-defined ones', async () => { @@ -117,5 +142,9 @@ describe('external validate lists a federated object saved at runtime, with no r const validated = await call('POST', validatePath); expect(validated.status, JSON.stringify(validated.json)).toBe(200); expect(listedObjects(validated.json)).toEqual([...CODE_DEFINED, IMPORTED, SAVED].sort()); + expect(rowOf(validated.json, IMPORTED), JSON.stringify(rowOf(validated.json, IMPORTED))).toMatchObject({ + ok: true, + diffs: [], + }); }); }); diff --git a/packages/services/service-datasource/src/__tests__/external-metadata-read-at-use.test.ts b/packages/services/service-datasource/src/__tests__/external-metadata-read-at-use.test.ts index f55451eab1a..2f7ce50f93b 100644 --- a/packages/services/service-datasource/src/__tests__/external-metadata-read-at-use.test.ts +++ b/packages/services/service-datasource/src/__tests__/external-metadata-read-at-use.test.ts @@ -136,10 +136,24 @@ interface Harness { services: Map; } -/** A kernel context whose `getService` throws on an unregistered name, as the kernel's does. */ -function harness(): Harness { +/** + * A kernel context whose `getService` throws on an unregistered name, as the kernel's does. + * + * [#21842] The federated objects live in the engine's object registry on the + * `objectql` service, which is where the federation service reads objects + * (the registry a runtime save writes through to). The `metadata` fake still + * holds its own copy, as the kernel's fallback does after the startup bridge; + * the readers this file pins are the ones that stay on the metadata service. + */ +function harness(opts: { registry?: boolean } = {}): Harness { const services = new Map(); services.set('data', { introspectDatasource: vi.fn(async () => remoteSchema()) }); + if (opts.registry !== false) { + const objects = new Map([[CUSTOMER.name, CUSTOMER], [ORDER.name, ORDER]]); + services.set('objectql', { + registry: { getObject: (n: string) => objects.get(n), getAllObjects: () => [...objects.values()] }, + }); + } const ctx = { getService: (name: string) => { if (!services.has(name)) throw new Error(`Service '${name}' not found`); @@ -257,18 +271,16 @@ describe('the federation service reads a metadata service registered after init it('asks for the service again at each use, never remembering the first answer', async () => { const { h, service } = await startOrdering(); - expect(objectsOf(await service.validateAll())).toEqual(['wh_customer', 'wh_order']); - - const replacement = metadataFake(); - await replacement.register('object', 'wh_extra', { - name: 'wh_extra', - datasource: 'warehouse', - external: { remoteName: 'orders' }, - fields: {}, - }); - h.services.set('metadata', replacement.service); + const customerDiffs = async () => + (await service.validateAll()).results.find((r) => r.object === 'wh_customer')?.diffs; + expect(await customerDiffs()).toEqual([expect.objectContaining({ kind: 'missing_column', column: 'email' })]); + + // [#21842] Objects come from the engine registry, so the replacement is + // told apart by the datasource definition it answers: a `managed` + // datasource is not compared against its remote at all. + h.services.set('metadata', metadataFake({ name: 'warehouse', schemaMode: 'managed' }).service); - expect(objectsOf(await service.validateAll())).toEqual(['wh_customer', 'wh_extra', 'wh_order']); + expect(await customerDiffs()).toEqual([]); }); }); @@ -292,7 +304,9 @@ describe('control: a metadata service registered before init (the dev ordering) describe('with no metadata service at all, every reader keeps its fallback', () => { it('validate and the sweep answer an empty report, the draft a bare name, the catalog an unpersisted snapshot', async () => { - const service = await federation(harness()); + // [#21842] No engine registry either: that is where validate's objects + // come from, so with neither service the report has nothing to list. + const service = await federation(harness({ registry: false })); expect(await service.validateAll()).toEqual({ ok: true, results: [] }); expect((await service.generateObjectDraft('warehouse', 'customers')).name).toBe('customers');