From 9637c83d10ca10daf2b143e5c0eb3660c45f7dd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:57:55 +0000 Subject: [PATCH] fix(objectql,service-datasource): bind federated objects whatever the boot order, and report what could not be bound (#7737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `driver.registerExternalObject(obj)` is the only thing that installs an ADR-0015 federated object's read metadata (object -> remote-table mapping, columnMap translation, coercion maps). Without it a read resolves to a table named after the OBJECT instead of the remote table it declares. `ObjectQLPlugin`'s boot schema-sync calls it from `start()`, but the declared datasource that owns the remote database is auto-connected in `AppPlugin.start()` — a later `start()` — so on a healthy boot the driver does not exist yet at that point and the call is skipped. Whether an object ended up bound therefore depended on some other component re-driving it. Two cases where nothing did: an object routed by a `datasourceMapping` rule (#4462), and any deployment running with `OS_SKIP_SCHEMA_SYNC` (a DDL flag, while this binding is DDL-free). - ObjectQLPlugin now reconciles federated bindings on `kernel:ready`, after every `start()` has run: idempotent re-drive for every registered external object, independent of boot order. - The same pass reports what it could not bind at `error`, naming the objects, their datasources, the consequence and the fix. The previous diagnosis was one `debug` line ("No driver available for object, skipping schema sync"). Silent on a boot with nothing to report. - DatasourceConnectionService re-drives `mappedObjects` alongside `objects`, so a mapping-routed federated object is bound by a runtime datasource connect too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K5pui68hQhfFRR1fB1iuvh --- .../federated-boot-binding-reconciliation.md | 49 ++++ packages/objectql/src/plugin.ts | 134 +++++++++ .../src/federated-boot-binding.test.ts | 257 ++++++++++++++++++ .../src/datasource-connection-service.ts | 12 +- 4 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 .changeset/federated-boot-binding-reconciliation.md create mode 100644 packages/runtime/src/federated-boot-binding.test.ts diff --git a/.changeset/federated-boot-binding-reconciliation.md b/.changeset/federated-boot-binding-reconciliation.md new file mode 100644 index 0000000000..ceb5d7ca7e --- /dev/null +++ b/.changeset/federated-boot-binding-reconciliation.md @@ -0,0 +1,49 @@ +--- +"@objectstack/objectql": patch +"@objectstack/service-datasource": patch +--- + +fix(objectql,service-datasource): bind federated objects to their remote tables whatever the boot order, and report the ones that could not be bound (#7737) + +`driver.registerExternalObject(obj)` is the only thing that installs an +ADR-0015 federated object's read metadata — the object -> remote-table mapping +(`external.remoteName` / `remoteSchema`), the `external.columnMap` translation +and the coercion maps. An external object that never gets it resolves to a +table named after the OBJECT rather than the remote table it declares, so every +read against it fails with `no such table`, or answers from the wrong table. + +`ObjectQLPlugin`'s boot schema-sync calls it, but that call runs inside the +engine plugin's `start()`, while the declared datasource that owns the remote +database is auto-connected in `AppPlugin.start()` — a later `start()`. So on a +perfectly healthy boot `getDriverForObject()` answers `undefined` for every +federated object at that moment and the call is skipped; whether the object ends +up bound depended on some other component re-driving it afterwards. Two cases +where nothing did: + +- an object routed to the datasource by a **`datasourceMapping` rule** (#4462) + rather than an explicit `object.datasource` — `DatasourceConnectionService` + re-drove only the explicitly-bound list; +- any deployment running with **`OS_SKIP_SCHEMA_SYNC`** — that flag is about DDL + managed out of band, and this binding is DDL-free, but it took both + `syncRegisteredSchemas()` calls (and the only in-plugin binding site) with it. + +**What changed** + +- `ObjectQLPlugin` now runs a federated-binding reconciliation on + `kernel:ready`, after every plugin's `start()` has completed: it re-drives the + binding for every registered external object (idempotent) regardless of which + plugin connected the datasource, in which slot, or whether DDL was skipped. + Boot order no longer decides whether federation works. +- The same pass **reports** what it could not bind, at `error`, naming the + objects, their datasources, the consequence and the fix. Previously the entire + diagnosis of a broken federation was one `debug` line reading + `No driver available for object, skipping schema sync` — invisible at any + normal log level, and emitted on healthy boots too. A boot with nothing to + report stays silent. +- `DatasourceConnectionService.connect()` now re-drives `mappedObjects` + alongside `objects` when a datasource comes up, so a mapping-routed federated + object is also bound by a **runtime** (UI-created) datasource connect, not + only at boot. + +No authoring surface changes; a deployment whose federated objects already +worked behaves identically. diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 771f29f089..966b48d7d2 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -398,6 +398,12 @@ export class ObjectQLPlugin implements Plugin { // Idempotent: the bind fully replaces the 'metadata-service' package // set, so edited hooks re-bind and deleted hooks tear down. ctx.hook('kernel:ready', async () => { + // #7737 — FIRST, before anything that might read data: bind every + // declared federated object to its remote table now that every + // plugin's `start()` (including the declared-datasource auto-connect + // in `AppPlugin.start()`) has run. See + // {@link reconcileFederatedBindings}. + await this.reconcileFederatedBindings(ctx); await this.resyncAuthoredHooks(ctx); await this.resyncAuthoredActions(ctx); // [ADR-0110 D5] Governance inventory — AFTER the authored-action @@ -1058,6 +1064,107 @@ export class ObjectQLPlugin implements Plugin { * instead of all rows. */ + /** + * Bind every declared FEDERATED (external) object to its remote table — + * once, at `kernel:ready`, when the boot has finished moving (#7737). + * + * ## What the binding is, and why it has to happen twice + * + * `driver.registerExternalObject(obj)` is what installs an external + * object's read metadata: the object -> remote-table mapping + * (`external.remoteName` / `remoteSchema`), the `external.columnMap` + * translation, and the type-coercion maps. Nothing else installs it. An + * external object without it resolves to a table named after the OBJECT + * rather than the remote table it declares, so every read against it either + * fails with "no such table" or silently answers from the wrong table. + * + * {@link syncRegisteredSchemas} already calls it — but it runs inside THIS + * plugin's `start()`, and the declared datasource that owns the remote + * database is auto-connected in `AppPlugin.start()` (ADR-0062 D1), a later + * `start()`. So at boot schema-sync time `getDriverForObject()` legitimately + * answers `undefined` for a federated object on a healthy boot, and that + * call is skipped. Whether the object ends up bound then depends on some + * OTHER component re-driving it afterwards — today + * `DatasourceConnectionService` does, but only for objects the datasource + * knew to name (an explicit `object.datasource`), never for objects a + * `datasourceMapping` rule routes to it, and not at all when + * `OS_SKIP_SCHEMA_SYNC` is set (that flag is about DDL, and this binding is + * DDL-free). + * + * This pass removes that dependence on boot ORDER: it runs after every + * `start()` has completed, re-drives the binding for every registered + * external object (idempotent — `registerExternalObject` is pure metadata + * assignment), and is therefore correct no matter which plugin connected + * the datasource, in which slot, or whether DDL was skipped. + * + * ## …and it REPORTS what it could not bind + * + * A federated object that reaches the end of boot with no driver is + * declared-but-unreadable while the object stays registered, keeps its REST + * routes and keeps rendering in the UI. That is the shape #7737 was filed + * for, and the reason its ruling is that the skip must stop being silent: + * `debug` at the skip site was the whole diagnosis of a broken federation. + * Reported at `error` per the AGENTS.md degradation-log-level rule — from + * the outside the deployment looks healthy while declared data is simply + * not reachable — naming the objects, their datasources, the consequence + * and the fix. A boot with nothing to report says nothing. + */ + private async reconcileFederatedBindings(ctx: PluginContext): Promise { + if (!this.ql) return; + + const allObjects = this.ql.registry?.getAllObjects?.() ?? []; + const federated = allObjects.filter((o: any) => o?.external != null); + if (federated.length === 0) return; + + let bound = 0; + const unbound: string[] = []; + const unsupported: string[] = []; + const failed: string[] = []; + const datasourceOf = (name: string): string => + this.ql?.resolveEffectiveDatasource?.(name) ?? '(default)'; + + for (const obj of federated) { + const driver: any = this.ql.getDriverForObject(obj.name); + if (!driver) { + unbound.push(`${obj.name} -> datasource '${datasourceOf(obj.name)}'`); + continue; + } + if (typeof driver.registerExternalObject !== 'function') { + unsupported.push(`${obj.name} -> driver '${driver.name}'`); + continue; + } + try { + await driver.registerExternalObject(obj); + bound++; + } catch (e: unknown) { + failed.push(`${obj.name}: ${e instanceof Error ? e.message : String(e)}`); + } + } + + if (unbound.length === 0 && unsupported.length === 0 && failed.length === 0) { + ctx.logger.debug('Federated objects bound to their remote tables', { bound }); + return; + } + + ctx.logger.error( + `${unbound.length + unsupported.length + failed.length} federated (external) object(s) are NOT bound to their remote ` + + `table, yet stay registered and served: they keep their REST routes and keep rendering in the UI, while every read ` + + `against them resolves to a table named after the OBJECT instead of the remote table it declares — so those reads ` + + `fail with "no such table", or answer from the wrong table. ` + + (unbound.length + ? `No driver for the declared datasource (never declared, or its connection was refused/failed — see that ` + + `datasource's own connect verdict earlier in this boot): ${unbound.join(', ')}. ` + : '') + + (unsupported.length + ? `Driver does not implement external-object registration (ADR-0015 federation): ${unsupported.join(', ')}. ` + : '') + + (failed.length ? `Registration threw: ${failed.join(', ')}. ` : '') + + `Fix the datasource/driver named above and restart (or trigger a metadata reload) to re-run this binding.`, + undefined, + { bound, unbound: unbound.length, unsupported: unsupported.length, failed: failed.length }, + ); + } + /** * Synchronize all registered object schemas to the database. * @@ -1123,6 +1230,33 @@ export class ObjectQLPlugin implements Plugin { for (const obj of allObjects) { const driver = this.ql.getDriverForObject(obj.name); if (!driver) { + // #7737 — for a FEDERATED object this skip is not a schema-sync + // detail. `registerExternalObject` (just below) is the ONLY thing + // that installs the object -> remote-table mapping, and it lives past + // this guard: skip it and every read of that object resolves against + // a table named after the object instead of the remote table it + // declares. + // + // It is also NOT, by itself, a defect. Boot schema-sync runs inside + // this plugin's `start()`, while a declared datasource is + // auto-connected in `AppPlugin.start()` — a later `start()` on every + // composition that has one — so on a perfectly healthy boot the + // driver genuinely does not exist yet at this line. + // + // Hence the split: quiet HERE, because the deferral is expected, and + // reconciled + REPORTED at `kernel:ready` by + // {@link reconcileFederatedBindings}, after every `start()` has run. + // That is the point at which "still no driver" is final and is a real + // defect, and it is reported as one — this skip is no longer the last + // word on a declared external object. + if (obj.external != null) { + ctx.logger.debug( + 'No driver yet for federated object — deferring its remote-table binding to the kernel:ready reconciliation', + { object: obj.name, datasource: this.ql.resolveEffectiveDatasource?.(obj.name) }, + ); + skipped++; + continue; + } ctx.logger.debug('No driver available for object, skipping schema sync', { object: obj.name, }); diff --git a/packages/runtime/src/federated-boot-binding.test.ts b/packages/runtime/src/federated-boot-binding.test.ts new file mode 100644 index 0000000000..26f72b614b --- /dev/null +++ b/packages/runtime/src/federated-boot-binding.test.ts @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7737 — a declared FEDERATED (external) object must end the boot bound to +// its remote table, whatever ORDER the boot happened to run in. +// +// ## What is actually being pinned +// +// `driver.registerExternalObject(obj)` is the only thing that installs an +// external object's object -> remote-table mapping (`external.remoteName`), +// its `columnMap` translation and its coercion maps. Without it the query path +// resolves to a table named after the OBJECT, so a read either fails with +// "no such table" or answers from the wrong table. +// +// `ObjectQLPlugin.start()` calls it from boot schema-sync — but that runs +// BEFORE `AppPlugin.start()` auto-connects the declared datasource, so at that +// point `getDriverForObject()` answers `undefined` for every federated object +// and the call is skipped. Whether the object ends up bound therefore depends +// on some later component re-driving it, which is the ordering dependence +// #7737 was filed against. +// +// So these are BOOT-SEQUENCE tests on purpose. A test that calls +// `registerExternalObject` directly passes with or without the fix and pins +// nothing: the defect is not in that method, it is in whether the boot ever +// reaches it. +// +// Two binding routes are exercised, and they were NOT equivalent before the +// fix: +// • explicit `object.datasource` — was already re-driven at connect time by +// `DatasourceConnectionService`, so it worked; it is here as the guard +// that the fix does not regress the path that did work. +// • a `datasourceMapping` rule (#4462) — the connect-time re-drive iterated +// only the explicitly-bound list, so this object was never bound and its +// read hit `no such table: fed_invoice`. This is the case that fails on +// unfixed code. +// +// sqlite `:memory:` keeps it hermetic (the database lives and dies inside the +// process). The remote tables are created through the LIVE auto-connected +// driver after boot — `registerExternalObject` records a name and never +// introspects, so "the remote table exists" is independent of when it appeared. + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { Runtime } from './runtime.js'; +import { DriverPlugin } from './driver-plugin.js'; +import { AppPlugin } from './app-plugin.js'; + +const BOOT_TIMEOUT = 60_000; + +async function makeDefaultDriver() { + const { SqlDriver } = await import('@objectstack/driver-sql'); + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); +} + +const EXTERNAL_DATASOURCE = { + name: 'fed_ext', + label: 'Federated external (sqlite :memory:)', + driver: 'sqlite', + schemaMode: 'external', + origin: 'code', + config: { filename: ':memory:' }, + external: { allowWrites: false, validation: { onMismatch: 'warn', checkOnBoot: false } }, + active: true, +}; + +/** The showcase shape: object name deliberately differs from the remote table. */ +function artifact() { + return { + manifest: { id: 'com.test.fed-binding', name: 'Federated Binding', version: '1.0.0' }, + objects: [ + // Route (1): explicit binding. + { + name: 'fed_customer', + label: 'Federated Customer', + datasource: 'fed_ext', + external: { remoteName: 'remote_customers' }, + fields: { id: { type: 'text' }, name: { type: 'text' } }, + }, + // Route (2): NO `datasource` — a datasourceMapping rule routes it. + { + name: 'fed_invoice', + label: 'Federated Invoice', + external: { remoteName: 'remote_invoices' }, + fields: { id: { type: 'text' }, amount: { type: 'number' } }, + }, + // A managed object on the host default, so the mapping rule below is + // demonstrably selective rather than catching everything. + { name: 'local_note', label: 'Local Note', fields: { title: { type: 'text' } } }, + ], + datasourceMapping: [{ objectPattern: 'fed_invoice', datasource: 'fed_ext' }], + datasources: [EXTERNAL_DATASOURCE], + }; +} + +/** An external object whose declared datasource is not declared anywhere. */ +function orphanArtifact() { + return { + manifest: { id: 'com.test.fed-orphan', name: 'Federated Orphan', version: '1.0.0' }, + objects: [ + { + name: 'fed_orphan', + label: 'Federated Orphan', + datasource: 'fed_missing', + external: { remoteName: 'remote_orphans' }, + fields: { id: { type: 'text' } }, + }, + ], + datasources: [], + }; +} + +async function boot(bundle: Record) { + const { ObjectQLPlugin } = await import('@objectstack/objectql'); + const { DatasourceAdminServicePlugin, createDefaultDatasourceDriverFactory } = await import( + '@objectstack/service-datasource' + ); + + const runtime = new Runtime({ cluster: false }); + const kernel = runtime.getKernel(); + await kernel.use(new DriverPlugin(await makeDefaultDriver())); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AppPlugin(bundle as never)); + await kernel.use( + new DatasourceAdminServicePlugin({ driverFactory: createDefaultDatasourceDriverFactory() }), + ); + await kernel.bootstrap(); + return kernel; +} + +type Engine = { + getDriverByName(n: string): any; + find(object: string, query?: Record): Promise; +}; + +describe('#7737 federated boot binding — declared external objects are bound whatever the boot order', () => { + let kernel: Awaited> | undefined; + + afterEach(async () => { + try { await (kernel as any)?.stop?.(); } catch { /* noop */ } + kernel = undefined; + }); + + it('binds BOTH binding routes to their remote tables and serves the remote rows', async () => { + kernel = await boot(artifact()); + const engine = kernel.getService('data'); + + const driver = engine.getDriverByName('fed_ext'); + expect(driver, 'the declared external datasource must auto-connect (ADR-0062 D1)').toBeDefined(); + + // Stand up the "remote" database out of band — nothing in this + // composition may run DDL on an `external` datasource. + await driver.execute('CREATE TABLE remote_customers (id text primary key, name text)'); + await driver.execute('CREATE TABLE remote_invoices (id text primary key, amount numeric)'); + await driver.execute("INSERT INTO remote_customers (id, name) VALUES ('c1','Ada'), ('c2','Grace')"); + await driver.execute("INSERT INTO remote_invoices (id, amount) VALUES ('i1', 100)"); + + // Route (1) — explicit `object.datasource`. Worked before the fix; here + // as the no-regression guard. + const customers = await engine.find('fed_customer'); + expect(customers.map((r) => r.name).sort()).toEqual(['Ada', 'Grace']); + + // Route (2) — routed by a `datasourceMapping` rule. THIS is the one that + // fails on unfixed code: with no binding installed, the read targets a + // table called `fed_invoice`, which does not exist in the remote + // database, and the call rejects with `no such table: fed_invoice`. + const invoices = await engine.find('fed_invoice'); + expect(invoices.map((r) => r.id)).toEqual(['i1']); + }, BOOT_TIMEOUT); + + // The binding must not depend on the DDL opt-out: `registerExternalObject` + // runs no DDL, and `OS_SKIP_SCHEMA_SYNC` is about DDL managed out of band. + // Before the fix this flag skipped BOTH `syncRegisteredSchemas()` calls and + // took the only in-plugin binding site with them. + it('binds federated objects even when boot schema sync is skipped (OS_SKIP_SCHEMA_SYNC)', async () => { + const previous = process.env.OS_SKIP_SCHEMA_SYNC; + process.env.OS_SKIP_SCHEMA_SYNC = '1'; + try { + kernel = await boot(artifact()); + const engine = kernel.getService('data'); + const driver = engine.getDriverByName('fed_ext'); + await driver.execute('CREATE TABLE remote_customers (id text primary key, name text)'); + await driver.execute('CREATE TABLE remote_invoices (id text primary key, amount numeric)'); + await driver.execute("INSERT INTO remote_customers (id, name) VALUES ('c1','Ada')"); + await driver.execute("INSERT INTO remote_invoices (id, amount) VALUES ('i1', 100)"); + expect((await engine.find('fed_customer')).map((r) => r.name)).toEqual(['Ada']); + expect((await engine.find('fed_invoice')).map((r) => r.id)).toEqual(['i1']); + } finally { + if (previous === undefined) delete process.env.OS_SKIP_SCHEMA_SYNC; + else process.env.OS_SKIP_SCHEMA_SYNC = previous; + } + }, BOOT_TIMEOUT); +}); + +// #7737's ruling: the `if (!driver)` skip must stop being SILENT for a +// declared external object. Before the fix the entire diagnosis of a broken +// federation was one `debug` line reading "No driver available for object, +// skipping schema sync" — invisible at any normal log level, and emitted on +// healthy boots too (the driver simply had not connected yet at that point). +// +// This is a LOG assertion, not an ADR-0112 refusal assertion, and deliberately +// so: an unbound federated object does not throw at boot. It is reported, and +// the read that follows refuses on its own with the engine's existing +// "Datasource 'fed_missing' … is not registered" error — asserted below so the +// pair (boot says so / read refuses) is pinned together. +describe('#7737 an external object that cannot be bound is REPORTED, not skipped in silence', () => { + let kernel: Awaited> | undefined; + + afterEach(async () => { + try { await (kernel as any)?.stop?.(); } catch { /* noop */ } + kernel = undefined; + vi.restoreAllMocks(); + }); + + it('names the object, its datasource, the consequence and the fix at error level', async () => { + const lines: string[] = []; + const spy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(((chunk: string | Uint8Array) => { + lines.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return true; + }) as never); + + kernel = await boot(orphanArtifact()); + spy.mockRestore(); + + const report = lines.find((l) => l.includes('fed_orphan') && l.includes('ERROR')); + expect(report, 'boot must report the unbound federated object at ERROR level').toBeDefined(); + // The object, the datasource that could not be resolved, the consequence + // and the remedy — a diagnosis, not a bare "skipping". + expect(report).toContain('fed_orphan'); + expect(report).toContain('fed_missing'); + expect(report).toContain('NOT bound'); + expect(report).toContain('no such table'); + expect(report).toContain('Fix'); + + // …and the read itself still refuses rather than answering empty. + const engine = kernel.getService('data'); + await expect(engine.find('fed_orphan')).rejects.toThrow(/fed_missing/); + }, BOOT_TIMEOUT); + + it('says nothing when every federated object bound (no false alarm on a healthy boot)', async () => { + const lines: string[] = []; + const spy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(((chunk: string | Uint8Array) => { + lines.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return true; + }) as never); + + kernel = await boot(artifact()); + spy.mockRestore(); + + expect(lines.filter((l) => l.includes('NOT bound to their remote'))).toEqual([]); + }, BOOT_TIMEOUT); +}); diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts index e0a8016c97..74b7548d11 100644 --- a/packages/services/service-datasource/src/datasource-connection-service.ts +++ b/packages/services/service-datasource/src/datasource-connection-service.ts @@ -605,7 +605,17 @@ export class DatasourceConnectionService { // Register read metadata for bound federated objects (DDL-free). Boot // schema-sync ran before this driver existed, so do it on-demand now. - for (const objectName of opts.objects ?? []) { + // + // #7737 — `mappedObjects` belongs in this loop too. An object a + // `datasourceMapping` rule routes here (#4462) has exactly the problem + // an explicitly-bound one has: boot schema-sync skipped it because this + // driver did not exist yet, so without a re-drive its object -> + // remote-table mapping is never installed and every read resolves to a + // table named after the object. The two lists are already treated as + // equals by the fail-fast policy below (both mean "no fallback + // driver"); they were unequal only here. `syncObjectSchema` is + // idempotent, so an object in both lists is harmless. + for (const objectName of [...(opts.objects ?? []), ...(opts.mappedObjects ?? [])]) { try { await engine.syncObjectSchema?.(objectName); } catch (err) {