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