Skip to content

Commit 199ec47

Browse files
os-zhuangclaude
andauthored
fix(objectql,service-datasource): bind federated objects whatever the boot order, and report what could not be bound (#7737) (#7788)
`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. Claude-Session: https://claude.ai/code/session_01K5pui68hQhfFRR1fB1iuvh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4fb347e commit 199ec47

4 files changed

Lines changed: 451 additions & 1 deletion

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/service-datasource": patch
4+
---
5+
6+
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)
7+
8+
`driver.registerExternalObject(obj)` is the only thing that installs an
9+
ADR-0015 federated object's read metadata — the object -> remote-table mapping
10+
(`external.remoteName` / `remoteSchema`), the `external.columnMap` translation
11+
and the coercion maps. An external object that never gets it resolves to a
12+
table named after the OBJECT rather than the remote table it declares, so every
13+
read against it fails with `no such table`, or answers from the wrong table.
14+
15+
`ObjectQLPlugin`'s boot schema-sync calls it, but that call runs inside the
16+
engine plugin's `start()`, while the declared datasource that owns the remote
17+
database is auto-connected in `AppPlugin.start()` — a later `start()`. So on a
18+
perfectly healthy boot `getDriverForObject()` answers `undefined` for every
19+
federated object at that moment and the call is skipped; whether the object ends
20+
up bound depended on some other component re-driving it afterwards. Two cases
21+
where nothing did:
22+
23+
- an object routed to the datasource by a **`datasourceMapping` rule** (#4462)
24+
rather than an explicit `object.datasource` — `DatasourceConnectionService`
25+
re-drove only the explicitly-bound list;
26+
- any deployment running with **`OS_SKIP_SCHEMA_SYNC`** — that flag is about DDL
27+
managed out of band, and this binding is DDL-free, but it took both
28+
`syncRegisteredSchemas()` calls (and the only in-plugin binding site) with it.
29+
30+
**What changed**
31+
32+
- `ObjectQLPlugin` now runs a federated-binding reconciliation on
33+
`kernel:ready`, after every plugin's `start()` has completed: it re-drives the
34+
binding for every registered external object (idempotent) regardless of which
35+
plugin connected the datasource, in which slot, or whether DDL was skipped.
36+
Boot order no longer decides whether federation works.
37+
- The same pass **reports** what it could not bind, at `error`, naming the
38+
objects, their datasources, the consequence and the fix. Previously the entire
39+
diagnosis of a broken federation was one `debug` line reading
40+
`No driver available for object, skipping schema sync` — invisible at any
41+
normal log level, and emitted on healthy boots too. A boot with nothing to
42+
report stays silent.
43+
- `DatasourceConnectionService.connect()` now re-drives `mappedObjects`
44+
alongside `objects` when a datasource comes up, so a mapping-routed federated
45+
object is also bound by a **runtime** (UI-created) datasource connect, not
46+
only at boot.
47+
48+
No authoring surface changes; a deployment whose federated objects already
49+
worked behaves identically.

‎packages/objectql/src/plugin.ts‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,12 @@ export class ObjectQLPlugin implements Plugin {
398398
// Idempotent: the bind fully replaces the 'metadata-service' package
399399
// set, so edited hooks re-bind and deleted hooks tear down.
400400
ctx.hook('kernel:ready', async () => {
401+
// #7737 — FIRST, before anything that might read data: bind every
402+
// declared federated object to its remote table now that every
403+
// plugin's `start()` (including the declared-datasource auto-connect
404+
// in `AppPlugin.start()`) has run. See
405+
// {@link reconcileFederatedBindings}.
406+
await this.reconcileFederatedBindings(ctx);
401407
await this.resyncAuthoredHooks(ctx);
402408
await this.resyncAuthoredActions(ctx);
403409
// [ADR-0110 D5] Governance inventory — AFTER the authored-action
@@ -1058,6 +1064,107 @@ export class ObjectQLPlugin implements Plugin {
10581064
* instead of all rows.
10591065
*/
10601066

1067+
/**
1068+
* Bind every declared FEDERATED (external) object to its remote table —
1069+
* once, at `kernel:ready`, when the boot has finished moving (#7737).
1070+
*
1071+
* ## What the binding is, and why it has to happen twice
1072+
*
1073+
* `driver.registerExternalObject(obj)` is what installs an external
1074+
* object's read metadata: the object -> remote-table mapping
1075+
* (`external.remoteName` / `remoteSchema`), the `external.columnMap`
1076+
* translation, and the type-coercion maps. Nothing else installs it. An
1077+
* external object without it resolves to a table named after the OBJECT
1078+
* rather than the remote table it declares, so every read against it either
1079+
* fails with "no such table" or silently answers from the wrong table.
1080+
*
1081+
* {@link syncRegisteredSchemas} already calls it — but it runs inside THIS
1082+
* plugin's `start()`, and the declared datasource that owns the remote
1083+
* database is auto-connected in `AppPlugin.start()` (ADR-0062 D1), a later
1084+
* `start()`. So at boot schema-sync time `getDriverForObject()` legitimately
1085+
* answers `undefined` for a federated object on a healthy boot, and that
1086+
* call is skipped. Whether the object ends up bound then depends on some
1087+
* OTHER component re-driving it afterwards — today
1088+
* `DatasourceConnectionService` does, but only for objects the datasource
1089+
* knew to name (an explicit `object.datasource`), never for objects a
1090+
* `datasourceMapping` rule routes to it, and not at all when
1091+
* `OS_SKIP_SCHEMA_SYNC` is set (that flag is about DDL, and this binding is
1092+
* DDL-free).
1093+
*
1094+
* This pass removes that dependence on boot ORDER: it runs after every
1095+
* `start()` has completed, re-drives the binding for every registered
1096+
* external object (idempotent — `registerExternalObject` is pure metadata
1097+
* assignment), and is therefore correct no matter which plugin connected
1098+
* the datasource, in which slot, or whether DDL was skipped.
1099+
*
1100+
* ## …and it REPORTS what it could not bind
1101+
*
1102+
* A federated object that reaches the end of boot with no driver is
1103+
* declared-but-unreadable while the object stays registered, keeps its REST
1104+
* routes and keeps rendering in the UI. That is the shape #7737 was filed
1105+
* for, and the reason its ruling is that the skip must stop being silent:
1106+
* `debug` at the skip site was the whole diagnosis of a broken federation.
1107+
* Reported at `error` per the AGENTS.md degradation-log-level rule — from
1108+
* the outside the deployment looks healthy while declared data is simply
1109+
* not reachable — naming the objects, their datasources, the consequence
1110+
* and the fix. A boot with nothing to report says nothing.
1111+
*/
1112+
private async reconcileFederatedBindings(ctx: PluginContext): Promise<void> {
1113+
if (!this.ql) return;
1114+
1115+
const allObjects = this.ql.registry?.getAllObjects?.() ?? [];
1116+
const federated = allObjects.filter((o: any) => o?.external != null);
1117+
if (federated.length === 0) return;
1118+
1119+
let bound = 0;
1120+
const unbound: string[] = [];
1121+
const unsupported: string[] = [];
1122+
const failed: string[] = [];
1123+
const datasourceOf = (name: string): string =>
1124+
this.ql?.resolveEffectiveDatasource?.(name) ?? '(default)';
1125+
1126+
for (const obj of federated) {
1127+
const driver: any = this.ql.getDriverForObject(obj.name);
1128+
if (!driver) {
1129+
unbound.push(`${obj.name} -> datasource '${datasourceOf(obj.name)}'`);
1130+
continue;
1131+
}
1132+
if (typeof driver.registerExternalObject !== 'function') {
1133+
unsupported.push(`${obj.name} -> driver '${driver.name}'`);
1134+
continue;
1135+
}
1136+
try {
1137+
await driver.registerExternalObject(obj);
1138+
bound++;
1139+
} catch (e: unknown) {
1140+
failed.push(`${obj.name}: ${e instanceof Error ? e.message : String(e)}`);
1141+
}
1142+
}
1143+
1144+
if (unbound.length === 0 && unsupported.length === 0 && failed.length === 0) {
1145+
ctx.logger.debug('Federated objects bound to their remote tables', { bound });
1146+
return;
1147+
}
1148+
1149+
ctx.logger.error(
1150+
`${unbound.length + unsupported.length + failed.length} federated (external) object(s) are NOT bound to their remote ` +
1151+
`table, yet stay registered and served: they keep their REST routes and keep rendering in the UI, while every read ` +
1152+
`against them resolves to a table named after the OBJECT instead of the remote table it declares — so those reads ` +
1153+
`fail with "no such table", or answer from the wrong table. ` +
1154+
(unbound.length
1155+
? `No driver for the declared datasource (never declared, or its connection was refused/failed — see that ` +
1156+
`datasource's own connect verdict earlier in this boot): ${unbound.join(', ')}. `
1157+
: '') +
1158+
(unsupported.length
1159+
? `Driver does not implement external-object registration (ADR-0015 federation): ${unsupported.join(', ')}. `
1160+
: '') +
1161+
(failed.length ? `Registration threw: ${failed.join(', ')}. ` : '') +
1162+
`Fix the datasource/driver named above and restart (or trigger a metadata reload) to re-run this binding.`,
1163+
undefined,
1164+
{ bound, unbound: unbound.length, unsupported: unsupported.length, failed: failed.length },
1165+
);
1166+
}
1167+
10611168
/**
10621169
* Synchronize all registered object schemas to the database.
10631170
*
@@ -1123,6 +1230,33 @@ export class ObjectQLPlugin implements Plugin {
11231230
for (const obj of allObjects) {
11241231
const driver = this.ql.getDriverForObject(obj.name);
11251232
if (!driver) {
1233+
// #7737 — for a FEDERATED object this skip is not a schema-sync
1234+
// detail. `registerExternalObject` (just below) is the ONLY thing
1235+
// that installs the object -> remote-table mapping, and it lives past
1236+
// this guard: skip it and every read of that object resolves against
1237+
// a table named after the object instead of the remote table it
1238+
// declares.
1239+
//
1240+
// It is also NOT, by itself, a defect. Boot schema-sync runs inside
1241+
// this plugin's `start()`, while a declared datasource is
1242+
// auto-connected in `AppPlugin.start()` — a later `start()` on every
1243+
// composition that has one — so on a perfectly healthy boot the
1244+
// driver genuinely does not exist yet at this line.
1245+
//
1246+
// Hence the split: quiet HERE, because the deferral is expected, and
1247+
// reconciled + REPORTED at `kernel:ready` by
1248+
// {@link reconcileFederatedBindings}, after every `start()` has run.
1249+
// That is the point at which "still no driver" is final and is a real
1250+
// defect, and it is reported as one — this skip is no longer the last
1251+
// word on a declared external object.
1252+
if (obj.external != null) {
1253+
ctx.logger.debug(
1254+
'No driver yet for federated object — deferring its remote-table binding to the kernel:ready reconciliation',
1255+
{ object: obj.name, datasource: this.ql.resolveEffectiveDatasource?.(obj.name) },
1256+
);
1257+
skipped++;
1258+
continue;
1259+
}
11261260
ctx.logger.debug('No driver available for object, skipping schema sync', {
11271261
object: obj.name,
11281262
});

0 commit comments

Comments
 (0)