Skip to content

Commit 3e039e0

Browse files
fix(service-datasource): a pool rebuild syncs the managed objects bound to the datasource (#27750) (#27874)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D8TexLjwJ2KtVSbiAujiwfF)_ Fixes #27750 ## 维护者速读 - 改了什么:管理员修改一个 Setup 创建的托管数据源(换库、换凭据、改 schemaMode 或重新启用)导致连接池重建时,绑定到它的代码对象会在新库上建表。 - 为什么改:#27701 只修了"创建"和"重启"两条路;"修改后重建"仍不建表,换库后该对象每次读取都失败,只能手工建表。 - 风险与回滚:只对 `schemaMode: managed` 生效,`external` / `validate-only` 依旧零 DDL;重建失败保留旧池时不同步。回滚 = revert 本 PR。 - 先看:`datasource-admin-plugin.ts` 新的共用 helper `managedObjectsBoundTo`,以及 `reconnect` 新增的 `objects` 参数。 - 两分钟验证:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/runtime-datasource-bound-object-storage.test.ts`(新增 3 例;回退连接服务的改动后换库那例变红)。 - 需要你做的:无(Tier S,按本期落地授权由席位落地)。 ## What changes - `DatasourceConnectionService.reconnect` takes an optional `objects` list and forwards it to both of its inner `connect` calls (the swap rebuild and the nothing-to-swap plain connect), so the connect's sync loop gives those objects their tables on the pool the new record opens. The kept-old-pool path syncs nothing. - `DatasourceAdminServicePlugin`: the managed-only gate and bound-name list that `registerPool` computed inline (#27701) moved into one helper, `managedObjectsBoundTo`, which both `registerPool` and `reregisterPool` call. `reregisterPool` passes the objects bound to the NEW record. - ⛔ `schemaMode: external` / `validate-only` still pass no objects, so they never get DDL. ## Why #27701 synced bound managed objects when a runtime datasource's pool opens. An admin update that rebuilds the pool (`PATCH /api/v1/datasources/:name` → `updateDatasource` → `reregisterPool` → `reconnect`) still passed none, so moving `warehouse` to a new database left `wh_note` without a table there and every read of it failed. ## Verification - Pinned red on `main` first: in `runtime-datasource-bound-object-storage.test.ts` (real plugin, real `ObjectQL`, SQLite), create `warehouse` on `a.db`, update `config` to `b.db` → `hasTable('wh_note')` was `false` on `b.db`. Green with the fix; ablating the connection-service change turns it red again, restoring turns it green. - New ⛔ cases: `external` and `validate-only` datasources updated to a new database sync nothing and create no table. - `@objectstack/service-datasource`: 63 files / 1031 tests pass; `tsc --noEmit` clean. - `dispatch-gates --ran`: 60 derived families accounted for: 59 run green, 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3, needs a whole-tree build; CI runs it). `pnpm lint` exit 0. `check:commit-card-trailers` green. Clause-②: no Metadata-protocol: none ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. Serial note: draft PR #27819 (#27746) edits both files; this PR keeps to `reconnect`, `registerPool` / `reregisterPool` and the shared helper. The later lander merges `main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1ZEsJ6Xne4sn5EXtT Co-authored-by: Claude <noreply@anthropic.com>
1 parent e320e36 commit 3e039e0

4 files changed

Lines changed: 86 additions & 14 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
#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.
11+
12+
- **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.
13+
- **One gate.** The open (`registerPool`) and the rebuild (`reregisterPool`) read the bound objects through one helper.
14+
- ⛔ **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.
15+
- **Not changed.** When the rebuild fails and the previous pool is kept, nothing is synced: that pool already served those objects.

‎packages/services/service-datasource/src/__tests__/runtime-datasource-bound-object-storage.test.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@
2020
* - on create, the bound managed object's table exists and the object reads;
2121
* - on the restart's rehydration, a table that is missing (the state an
2222
* install created before this fix is in) is created, and the object reads;
23+
* - [#27750] on an update that rebuilds the pool (a changed `config`), the
24+
* table exists on the NEW database, and the object reads;
2325
* - ⛔ an `external` or `validate-only` datasource gets no schema sync at all:
24-
* its pool opens, and nothing is synced for the objects bound to it.
26+
* its pool opens (or is rebuilt), and nothing is synced for the objects
27+
* bound to it.
2528
*
2629
* The composition is the real one: the real `DatasourceAdminServicePlugin`
2730
* 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', (
173176
});
174177
});
175178

179+
describe('[#27750] an update that rebuilds the pool gives the bound managed object its table', () => {
180+
it('a changed config: the table exists on the NEW database, and the object reads', async () => {
181+
const dir = scratch();
182+
const { admin, knexOf, read } = await boot({ objects: [boundTo('wh_note', 'warehouse')] });
183+
await admin.createDatasource({ name: 'warehouse', driver: 'sqlite', config: { filename: join(dir, 'a.db') } });
184+
185+
const updated = await admin.updateDatasource('warehouse', { config: { filename: join(dir, 'b.db') } });
186+
187+
expect(updated.status).toBe('ok');
188+
expect(await knexOf('warehouse').schema.hasTable('wh_note')).toBe(true);
189+
await expect(read('wh_note')).resolves.toEqual([]);
190+
});
191+
192+
it.each(['external', 'validate-only'] as const)(
193+
'⛔ schemaMode %s: the rebuilt pool opens, and no object bound to it is synced',
194+
async (schemaMode) => {
195+
const dir = scratch();
196+
const { admin, synced, knexOf } = await boot({ objects: [boundTo('lake_note', 'lake')] });
197+
await admin.createDatasource({
198+
name: 'lake',
199+
driver: 'sqlite',
200+
schemaMode,
201+
external: { validation: { onMismatch: 'warn' } },
202+
config: { filename: join(dir, 'a.db') },
203+
} as never);
204+
205+
const updated = await admin.updateDatasource('lake', { config: { filename: join(dir, 'b.db') } });
206+
207+
expect(updated.status).toBe('ok');
208+
expect(synced).not.toHaveBeenCalled();
209+
expect(await knexOf('lake').schema.hasTable('lake_note')).toBe(false);
210+
},
211+
);
212+
});
213+
176214
describe('⛔ an external or federated datasource never gets DDL', () => {
177215
it.each(['external', 'validate-only'] as const)(
178216
'schemaMode %s: the pool opens, and no object bound to it is synced',

‎packages/services/service-datasource/src/datasource-admin-plugin.ts‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,20 @@ export class DatasourceAdminServicePlugin implements Plugin {
423423
.map((o) => ({ name: o.name, external: o.external }));
424424
};
425425

426+
// [#27701 · #27750] The objects a pool's connect syncs: the ones bound to
427+
// a MANAGED datasource, so they get their tables on the database the pool
428+
// opens. ⛔ A datasource whose schema ObjectStack does not own (`external`,
429+
// `validate-only`) gets none: it never gets DDL. Its mode is read the way
430+
// the driver factory reads it, so the gate and the driver agree on what it
431+
// is. One helper for both pool doors: the open (`registerPool`) and the
432+
// rebuild (`reregisterPool`).
433+
const managedObjectsBoundTo = (record: StoredDatasource): string[] =>
434+
(resolveDatasourceSchemaMode({ ...record, config: record.config ?? {} }) ?? 'managed') === 'managed'
435+
? objectsBoundTo(record.name)
436+
.map((o) => o.name)
437+
.filter((n): n is string => typeof n === 'string')
438+
: [];
439+
426440
const config: DatasourceAdminServiceConfig = {
427441
probe: (input) => this.probe(factory(), input),
428442

@@ -541,20 +555,12 @@ export class DatasourceAdminServicePlugin implements Plugin {
541555
// `connectDeclared` does for a declared datasource. Boot's schema sync
542556
// ran before this driver existed and skipped them; without this a code
543557
// object bound to a Setup-created datasource had no table, on create and
544-
// after every restart, and every read of it was a 500. ⛔ A datasource
545-
// whose schema ObjectStack does not own (`external`, `validate-only`)
546-
// passes none: it never gets DDL. Its mode is read the way the driver
547-
// factory reads it, so the gate and the driver agree on what it is.
558+
// after every restart, and every read of it was a 500. The gate is
559+
// `managedObjectsBoundTo`'s: never DDL for an unmanaged datasource.
548560
registerPool: async (record) => {
549561
this.livePoolRecords.set(record.name, record);
550-
const managed = (resolveDatasourceSchemaMode({ ...record, config: record.config ?? {} }) ?? 'managed') === 'managed';
551-
const objects = managed
552-
? objectsBoundTo(record.name)
553-
.map((o) => o.name)
554-
.filter((n): n is string => typeof n === 'string')
555-
: [];
556562
await this.connection?.connect(record, {
557-
objects,
563+
objects: managedObjectsBoundTo(record),
558564
context: { origin: record.origin ?? 'runtime', trigger: 'runtime-admin' },
559565
});
560566
},
@@ -570,9 +576,14 @@ export class DatasourceAdminServicePlugin implements Plugin {
570576
// `previous` rides along because the engine's eviction door removes the
571577
// datasource def with the driver, and the keep-old-pool path must put
572578
// back the def the old pool was serving under.
579+
//
580+
// [#27750] The rebuild carries the objects bound to the NEW record, the
581+
// way `registerPool` does on create: a changed `config` can open a new,
582+
// empty database, and without them the bound objects had no table there.
573583
reregisterPool: async (previous, next) => {
574584
this.livePoolRecords.set(next.name, next);
575585
await this.connection?.reconnect(next, {
586+
objects: managedObjectsBoundTo(next),
576587
previous,
577588
context: { origin: next.origin ?? 'runtime', trigger: 'runtime-admin' },
578589
});

‎packages/services/service-datasource/src/datasource-connection-service.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -834,10 +834,18 @@ export class DatasourceConnectionService {
834834
* Not for the DEFAULT datasource: runtime-admin records can never be named
835835
* `default` (the name is host-reserved), and the default driver's natural-
836836
* name registration (#3826) would make a name-keyed swap evict nothing.
837+
*
838+
* [#27750] `objects` are the objects bound to the NEW record, passed to the
839+
* rebuild's {@link connect} so its sync loop gives them their tables on the
840+
* pool the new configuration opens (a changed `config` can point at a new,
841+
* empty database). The caller passes them only for a MANAGED datasource, the
842+
* same gate `registerPool` applies on create. The kept-old-pool path syncs
843+
* nothing: that pool already served those objects.
837844
*/
838845
async reconnect(
839846
record: ConnectableDatasource,
840847
opts: {
848+
objects?: readonly string[];
841849
context?: DatasourceConnectContext;
842850
previous: Pick<ConnectableDatasource, 'driver' | 'schemaMode' | 'external'>;
843851
},
@@ -859,7 +867,7 @@ export class DatasourceConnectionService {
859867
// plain connect is the whole job: it builds when the name is free, and
860868
// on a door-less engine it degrades to the pre-#13804 idempotent no-op —
861869
// the safe direction for a host that cannot express a swap.
862-
return this.connect(record, { context: opts.context });
870+
return this.connect(record, { objects: opts.objects, context: opts.context });
863871
}
864872
const oldOwnership = this.states.get(name)?.ownership;
865873

@@ -882,7 +890,7 @@ export class DatasourceConnectionService {
882890
engine.unregisterDriver(name);
883891
let result: ConnectResult;
884892
try {
885-
result = await this.connect(record, { context: opts.context });
893+
result = await this.connect(record, { objects: opts.objects, context: opts.context });
886894
} catch (err) {
887895
// `connect` re-throws authoring verdicts (an unhonourable `pool` block,
888896
// #5714) after recording a failed state. Same settlement as a returned

0 commit comments

Comments
 (0)