From f0ef0a93476dc0a7c1f197d21c4b93a1d7ed56a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:00:42 +0000 Subject: [PATCH] fix(service-datasource): a pool rebuild syncs the managed objects bound to the datasource An admin update that rebuilds a runtime managed datasource's pool (a changed config, credential, schemaMode or active) went through reconnect, whose inner connect carried no objects, so the objects bound to it got no table on the new database. reconnect now takes the bound objects and forwards them to the rebuild's connect; registerPool and reregisterPool read them through one managed-only helper, so an external or validate-only datasource still never gets DDL. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4d1ZEsJ6Xne4sn5EXtT --- ...datasource-rebuild-bound-object-storage.md | 15 +++++++ ...me-datasource-bound-object-storage.test.ts | 40 ++++++++++++++++++- .../src/datasource-admin-plugin.ts | 33 ++++++++++----- .../src/datasource-connection-service.ts | 12 +++++- 4 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 .changeset/27750-datasource-rebuild-bound-object-storage.md diff --git a/.changeset/27750-datasource-rebuild-bound-object-storage.md b/.changeset/27750-datasource-rebuild-bound-object-storage.md new file mode 100644 index 00000000000..5a9ce7218e0 --- /dev/null +++ b/.changeset/27750-datasource-rebuild-bound-object-storage.md @@ -0,0 +1,15 @@ +--- +'@objectstack/service-datasource': patch +--- + +fix(service-datasource): an edit that rebuilds a Setup-created managed datasource's pool gives the objects bound to it their tables on the new pool (#27750) + +Clause-②: no +Metadata-protocol: none + +#27701 made a runtime managed datasource's pool sync the objects bound to it when the pool opens. An edit that rebuilds the pool still synced nothing: an admin who moved `warehouse` to a new database (a changed `config`) left every code object bound to it without a table there, and every read of it failed until the tables were recreated by hand. + +- **Now.** The rebuild (`PATCH /api/v1/datasources/:name` → `updateDatasource` → `reregisterPool` → `DatasourceConnectionService.reconnect`) carries the objects bound to the NEW record into the rebuild's connect, so its sync loop gives them their tables, as on create. The same holds when an edit re-activates an inactive datasource. `reconnect` takes them as a new optional `objects` option. +- **One gate.** The open (`registerPool`) and the rebuild (`reregisterPool`) read the bound objects through one helper. +- ⛔ **Only a managed datasource.** A datasource whose `schemaMode` is `external` or `validate-only` syncs nothing for its objects on the rebuild either, so it never gets DDL. +- **Not changed.** When the rebuild fails and the previous pool is kept, nothing is synced: that pool already served those objects. diff --git a/packages/services/service-datasource/src/__tests__/runtime-datasource-bound-object-storage.test.ts b/packages/services/service-datasource/src/__tests__/runtime-datasource-bound-object-storage.test.ts index f394b844b04..1f3e7e24dd7 100644 --- a/packages/services/service-datasource/src/__tests__/runtime-datasource-bound-object-storage.test.ts +++ b/packages/services/service-datasource/src/__tests__/runtime-datasource-bound-object-storage.test.ts @@ -20,8 +20,11 @@ * - on create, the bound managed object's table exists and the object reads; * - on the restart's rehydration, a table that is missing (the state an * install created before this fix is in) is created, and the object reads; + * - [#27750] on an update that rebuilds the pool (a changed `config`), the + * table exists on the NEW database, and the object reads; * - ⛔ an `external` or `validate-only` datasource gets no schema sync at all: - * its pool opens, and nothing is synced for the objects bound to it. + * its pool opens (or is rebuilt), and nothing is synced for the objects + * bound to it. * * The composition is the real one: the real `DatasourceAdminServicePlugin` * through `init()` and `start()`, a real `ObjectQL` engine on the `'objectql'` @@ -173,6 +176,41 @@ describe('a code object bound to a runtime managed datasource gets its table', ( }); }); +describe('[#27750] an update that rebuilds the pool gives the bound managed object its table', () => { + it('a changed config: the table exists on the NEW database, and the object reads', async () => { + const dir = scratch(); + const { admin, knexOf, read } = await boot({ objects: [boundTo('wh_note', 'warehouse')] }); + await admin.createDatasource({ name: 'warehouse', driver: 'sqlite', config: { filename: join(dir, 'a.db') } }); + + const updated = await admin.updateDatasource('warehouse', { config: { filename: join(dir, 'b.db') } }); + + expect(updated.status).toBe('ok'); + expect(await knexOf('warehouse').schema.hasTable('wh_note')).toBe(true); + await expect(read('wh_note')).resolves.toEqual([]); + }); + + it.each(['external', 'validate-only'] as const)( + '⛔ schemaMode %s: the rebuilt pool opens, and no object bound to it is synced', + async (schemaMode) => { + const dir = scratch(); + const { admin, synced, knexOf } = await boot({ objects: [boundTo('lake_note', 'lake')] }); + await admin.createDatasource({ + name: 'lake', + driver: 'sqlite', + schemaMode, + external: { validation: { onMismatch: 'warn' } }, + config: { filename: join(dir, 'a.db') }, + } as never); + + const updated = await admin.updateDatasource('lake', { config: { filename: join(dir, 'b.db') } }); + + expect(updated.status).toBe('ok'); + expect(synced).not.toHaveBeenCalled(); + expect(await knexOf('lake').schema.hasTable('lake_note')).toBe(false); + }, + ); +}); + describe('⛔ an external or federated datasource never gets DDL', () => { it.each(['external', 'validate-only'] as const)( 'schemaMode %s: the pool opens, and no object bound to it is synced', diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts index eb4b01ee78e..1a1873a683f 100644 --- a/packages/services/service-datasource/src/datasource-admin-plugin.ts +++ b/packages/services/service-datasource/src/datasource-admin-plugin.ts @@ -423,6 +423,20 @@ export class DatasourceAdminServicePlugin implements Plugin { .map((o) => ({ name: o.name, external: o.external })); }; + // [#27701 · #27750] The objects a pool's connect syncs: the ones bound to + // a MANAGED datasource, so they get their tables on the database the pool + // opens. ⛔ A datasource whose schema ObjectStack does not own (`external`, + // `validate-only`) gets none: it never gets DDL. Its mode is read the way + // the driver factory reads it, so the gate and the driver agree on what it + // is. One helper for both pool doors: the open (`registerPool`) and the + // rebuild (`reregisterPool`). + const managedObjectsBoundTo = (record: StoredDatasource): string[] => + (resolveDatasourceSchemaMode({ ...record, config: record.config ?? {} }) ?? 'managed') === 'managed' + ? objectsBoundTo(record.name) + .map((o) => o.name) + .filter((n): n is string => typeof n === 'string') + : []; + const config: DatasourceAdminServiceConfig = { probe: (input) => this.probe(factory(), input), @@ -541,20 +555,12 @@ export class DatasourceAdminServicePlugin implements Plugin { // `connectDeclared` does for a declared datasource. Boot's schema sync // ran before this driver existed and skipped them; without this a code // object bound to a Setup-created datasource had no table, on create and - // after every restart, and every read of it was a 500. ⛔ A datasource - // whose schema ObjectStack does not own (`external`, `validate-only`) - // passes none: it never gets DDL. Its mode is read the way the driver - // factory reads it, so the gate and the driver agree on what it is. + // after every restart, and every read of it was a 500. The gate is + // `managedObjectsBoundTo`'s: never DDL for an unmanaged datasource. registerPool: async (record) => { this.livePoolRecords.set(record.name, record); - const managed = (resolveDatasourceSchemaMode({ ...record, config: record.config ?? {} }) ?? 'managed') === 'managed'; - const objects = managed - ? objectsBoundTo(record.name) - .map((o) => o.name) - .filter((n): n is string => typeof n === 'string') - : []; await this.connection?.connect(record, { - objects, + objects: managedObjectsBoundTo(record), context: { origin: record.origin ?? 'runtime', trigger: 'runtime-admin' }, }); }, @@ -570,9 +576,14 @@ export class DatasourceAdminServicePlugin implements Plugin { // `previous` rides along because the engine's eviction door removes the // datasource def with the driver, and the keep-old-pool path must put // back the def the old pool was serving under. + // + // [#27750] The rebuild carries the objects bound to the NEW record, the + // way `registerPool` does on create: a changed `config` can open a new, + // empty database, and without them the bound objects had no table there. reregisterPool: async (previous, next) => { this.livePoolRecords.set(next.name, next); await this.connection?.reconnect(next, { + objects: managedObjectsBoundTo(next), previous, context: { origin: next.origin ?? 'runtime', trigger: 'runtime-admin' }, }); diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts index 163483cb7b8..9564d9d589e 100644 --- a/packages/services/service-datasource/src/datasource-connection-service.ts +++ b/packages/services/service-datasource/src/datasource-connection-service.ts @@ -834,10 +834,18 @@ export class DatasourceConnectionService { * Not for the DEFAULT datasource: runtime-admin records can never be named * `default` (the name is host-reserved), and the default driver's natural- * name registration (#3826) would make a name-keyed swap evict nothing. + * + * [#27750] `objects` are the objects bound to the NEW record, passed to the + * rebuild's {@link connect} so its sync loop gives them their tables on the + * pool the new configuration opens (a changed `config` can point at a new, + * empty database). The caller passes them only for a MANAGED datasource, the + * same gate `registerPool` applies on create. The kept-old-pool path syncs + * nothing: that pool already served those objects. */ async reconnect( record: ConnectableDatasource, opts: { + objects?: readonly string[]; context?: DatasourceConnectContext; previous: Pick; }, @@ -859,7 +867,7 @@ export class DatasourceConnectionService { // plain connect is the whole job: it builds when the name is free, and // on a door-less engine it degrades to the pre-#13804 idempotent no-op — // the safe direction for a host that cannot express a swap. - return this.connect(record, { context: opts.context }); + return this.connect(record, { objects: opts.objects, context: opts.context }); } const oldOwnership = this.states.get(name)?.ownership; @@ -882,7 +890,7 @@ export class DatasourceConnectionService { engine.unregisterDriver(name); let result: ConnectResult; try { - result = await this.connect(record, { context: opts.context }); + result = await this.connect(record, { objects: opts.objects, context: opts.context }); } catch (err) { // `connect` re-throws authoring verdicts (an unhonourable `pool` block, // #5714) after recording a failed state. Same settlement as a returned