Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/27750-datasource-rebuild-bound-object-storage.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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'`
Expand Down Expand Up @@ -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',
Expand Down
33 changes: 22 additions & 11 deletions packages/services/service-datasource/src/datasource-admin-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down Expand Up @@ -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' },
});
},
Expand All @@ -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' },
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectableDatasource, 'driver' | 'schemaMode' | 'external'>;
},
Expand All @@ -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;

Expand All @@ -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
Expand Down
Loading