Skip to content

Commit d3d0644

Browse files
fix(cli): os migrate plan no longer lists sys_packages as unmanaged after os migrate apply created it (#24226)
Fixes #24059 Clause-②: no ## What changes `os migrate plan` no longer lists `sys_packages` under "declared by no object in this plan" after `os migrate apply` has created it. A genuinely stranded `sys_*` table is still listed. `sys_packages` is the package registry's table. It is not an object. `PackageServicePlugin.start()` creates it with raw DDL, and since #23022 `os migrate apply` creates it from the registry's `package.storage` declaration. The plan's unmanaged-table sweep built its known set from the planned drivers' managed tables and the declared objects only. `sys_packages` is neither, so every plan after an apply reported the live registry table as if it were stranded. - `packages/cli/src/utils/schema-migrate.ts`: `findPackageStorage(kernel)` is now the one reader of the `package.storage` declaration (the lookup and the shape check that used to open `planPackageTable`). `bootSchemaStack` reads it once per boot. `planPackageTable` plans the table from that object as before, on a deferring boot only. The same object's `table` is exposed as `SchemaStack.nonObjectTables`. It is filled on every boot, whether or not the table exists and whether or not anything is planned for it, and it is `[]` when the boot composed no package registry. - `packages/cli/src/utils/unmanaged-tables.ts`: `buildKnownTableNames(managed, declaredObjects, nonObjectTables)` adds those names verbatim. `collectUnmanagedTables` takes a required `nonObjectTables` option and passes it through. The module header's list of legitimate platform-prefixed families gains this one as item 4. No table name is spelled in the sweep. - `packages/cli/src/commands/migrate/plan.ts`: passes `stack.nonObjectTables` to the sweep. The sweep reads the declaration from the same boot and kernel the plan runs in. ## Measured before changing anything (PM hypotheses) - **H1, confirmed** at `aa31569b2`: `buildKnownTableNames` returned the managed set plus each declared object's `name` and resolved table name, and `selectUnmanagedTables` reported every platform-prefixed table outside it. - **H2, confirmed**: `planPackageTable` returns `null` when the table is present. Door reading below: after the apply, the plan's `pending` is `[]`, so the planned work names nothing. The fix therefore reads the declaration, not the planned work. The sweep runs in the same boot as the plan: `bootSchemaStack` reads the declaration from the kernel it booted, and `plan.ts` hands it to the sweep from the returned stack. - **H3, pinned as a decision**: when no package registry is composed, `nonObjectTables` is `[]`, and a `sys_packages` table left in that database is still reported. Through the door this case does not arise on a mirrored boot. The sweep runs only when the composition mirrors the served boot, and that composition always mounts `PackageServicePlugin` through the always-on `package-registry` slate (`composeServedPlatform` in `schema-migration-plugins.ts`, and the base reading's composition note lists it). So the decision is pinned at unit level ("DECISION: a boot that composed no package registry …"). - **H4, re-measured at base `aa31569b2`** on `examples/app-crm`. Setup: a fresh sqlite file with `sys_legacy_widget` created up front, fresh `OS_HOME`, DB env unset, run through `packages/cli/bin/run-dev.js` with `--database-url file:DB`. Then `migrate apply --yes`, then `migrate plan`, then `migrate plan --json`: - base: apply printed `+ sys_packages [create_table, 7 column(s)]` and `Created/extended 80 table(s)`. The plan printed `2 table(s) … declared by no object in this plan:` with `• sys_legacy_widget` and `• sys_packages`. `--json`: `unmanagedTables.tables` = `sys_legacy_widget`, `sys_packages`, `physicalTables` 81, `pending` `[]`. - head `04c0d95ec`: same apply. The plan printed `1 table(s) …:` with `• sys_legacy_widget` only. `--json`: `tables` = `sys_legacy_widget`, `physicalTables` 81, `pending` `[]`. Both tables are physically present after each run. ## Pins - `unmanaged-tables.test.ts`, new describe `[#24059] a table the composition declares outside the object set`. It has three cases: the known set holds the name only when it is declared; after an apply the registry table is not reported and `sys_legacy_widget` still is; and the H3 decision. Every existing `buildKnownTableNames` / `collectUnmanagedTables` call states `nonObjectTables` explicitly. - `unmanaged-tables.integration.test.ts` uses a real boot with the composition `plan` uses. `sys_packages` joins the negative controls, and the premise case asserts that `stack.nonObjectTables` equals the `table` of the kernel's own `package.storage` service while nothing is pending for it. - `schema-migrate.package-table.integration.test.ts`: `nonObjectTables` is `['sys_packages']` on the plan of a never-started project and on the plan after the apply, when `pending` holds nothing for the table. - Two door-level pins encoded the defect, and they flip with this change. `plan.boot-parity.integration.test.ts` expected the sweep to name exactly `sys_packages`, and now expects it to name nothing. `plan.telemetry-sibling.integration.test.ts` expected `sys_packages` plus the sibling's retired table, and now expects only the retired table. Both pins were green at base with the old expectation, so the new one fails against the old sweep. The H4 base reading shows the same thing. ## Ablation (fix committed first, at `04c0d95ec`) `node scripts/ablation-replace.mjs --file packages/cli/src/utils/unmanaged-tables.ts --anchor 'for (const table of nonObjectTables) known.add(table);' --delete -- pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/utils/unmanaged-tables.test.ts`, under the verify lock: - the anchor went 1 to 0, and the blob went `99e653e8d5d0` to `33c383315c85`; - `Tests 2 failed | 43 passed (45)`. The two red cases are the "known set" case and the "after an apply" case. The DECISION case stayed green, as expected, because it asserts the direction without a declaration; - restore: the blob equals HEAD (`99e653e8d5d0`), `git diff HEAD` is empty, and `git status --porcelain` is empty. The unit suite imports `./unmanaged-tables.js` relatively, so it reads `src/` and no `dist/` rebuild applies. ## Tests and gates All readings are at head `7a3803bd3` unless another commit is named. `04c0d95ec` is the fix commit. `7a3803bd3` merges `origin/main` `b9ac35cbf`, which touches CI workflows and only one file in `packages/cli`: `test/published-entry-node-env-source-reroute.test.ts`. - `pnpm --filter @objectstack/cli typecheck` at `04c0d95ec`: exit 0. That is `tsc --noEmit`, then `check:test-typecheck`, which held 3 files, 28 errors and 6 signatures. `tsc -p packages/cli/tsconfig.json --listFilesOnly` puts all 5 touched test files in its 1611-file program. - Unit tier at `04c0d95ec`, `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 2 failed | 308 passed (310)`, `Tests 4573 passed | 29 skipped`. The two files, `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts`, were refused before their bodies ran ("packages/cli is not built"). After `pnpm --filter @objectstack/cli build`, both pass: `Tests 29 passed (29)`. - Integration tier at `04c0d95ec`, the four touched files with `--project integration`: `unmanaged-tables.integration` and `schema-migrate.package-table.integration` 4 passed, `plan.boot-parity.integration` 3 passed, `plan.telemetry-sibling.integration` 2 passed. - After the merge: `test/published-entry-node-env-source-reroute.test.ts` and `src/utils/unmanaged-tables.test.ts`, 50 passed. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `7a3803bd3`: 64 commands, merge base `b9ac35cbf`, 9 paths, 232 changed lines. All 64 ran, with each exit code captured before any pipe. 62 exited 0 on the first run. `check:dual-build-cjs-loads` and `check:i18n-coverage` exited 3 (PREREQUISITE NOT MET); both exited 0 after `turbo run build --filter='!@objectstack/docs' --concurrency=2` (76 tasks, 75 cached). `--ran`: `64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0. - `pnpm exec eslint --no-inline-config` over the 8 changed `.ts` files: exit 0. Repo-wide `pnpm lint` is left to CI. ## Acceptance notes - **One more refusal path, stated.** `findPackageStorage` now runs on every boot, not only a deferring one. A `package.storage` service of the wrong shape (a version skew between `@objectstack/cli` and `@objectstack/service-package`) now also fails the skip-schema-sync boot of `plan`, `apply` and `organization-ownership` (the Turso remote transport). It fails with the release-mismatch error those commands already gave on every deferring boot. Only those three commands, plus `unmapped-columns`, compose the registry. The changeset says so. - The section's sentence still reads "declared by no object in this plan". The rows it lists are still declared by nothing, so the wording is unchanged. - `nonObjectTables` is a union across every planned database, like the managed set beside it. The registry writes to the default driver, so a `sys_packages` table in the `telemetry` sibling would also go unreported. Over-excluding is the module's stated safe direction. Not measured; no carrier. --- _Generated by [Claude Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9040945 commit d3d0644

9 files changed

Lines changed: 189 additions & 43 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate plan` no longer lists `sys_packages` among the tables "declared by no object in this plan" after `os migrate apply` has created it
6+
7+
Clause-②: no
8+
9+
`sys_packages` is the package registry's table. It is not an object: `PackageServicePlugin` creates it with its own DDL, and `os migrate apply` creates it from the registry's `package.storage` declaration. The plan's unmanaged-table sweep built its known set from the planned drivers' managed tables and the declared objects only, so on every applied deployment the plan reported the live registry table as if it were stranded.
10+
11+
- **Now:** the sweep also counts the tables the boot's composition declares outside the object set. It reads that name from the same `package.storage` declaration the plan creates the table from. It reads it on every boot, whether or not the table exists yet, the skip-schema-sync boot of a Turso remote primary included. On `examples/app-crm` after `os migrate apply --yes`, the plan lists only a genuinely stranded `sys_*` table.
12+
- **Still reported:** a `sys_packages` table in the database of a deployment that composes no package registry. Nothing there declares it.
13+
- **Refused in one more case:** a `package.storage` service whose shape this CLI does not read now fails `plan`, `apply` and `organization-ownership` on a skip-schema-sync boot too, with the error those commands already gave on every other boot: `@objectstack/cli` and `@objectstack/service-package` must come from the same release.
14+
- **Unchanged:** the `--json` keys, the section's wording, and what `apply` creates.

‎packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ import Database from 'better-sqlite3';
4040
* included) — with `pending` empty, equal counts are equal sets;
4141
* - the unmanaged-tables sweep RUNS on every shape — the composition says it
4242
* mirrors the served boot, the compiled-artifact project with no config
43-
* included (#22580) — and names nothing but `sys_packages`.
43+
* included (#22580) — and names nothing: [#24059] not even `sys_packages`,
44+
* which the composed package registry declares outside the object set.
4445
*
4546
* `sys_packages` is the one table outside the object set, by construction:
4647
* `PackageServicePlugin.start()` creates it with raw DDL (`package-table.ts`),
47-
* and no object declares it. A new raw-DDL table fails this pin and has to be
48-
* named here with the same reason.
48+
* and no object declares it — its `package.storage` declaration does, and the
49+
* sweep reads that declaration. A new raw-DDL table fails this pin and has to
50+
* be named here with the same reason.
4951
*
5052
* Both sides run with `OS_TELEMETRY_DB=0`, so the pin compares object sets on
5153
* one database. A development boot's `telemetry` sibling (ADR-0057 §3.6) — a
@@ -145,10 +147,11 @@ function expectParity(r: Reading): void {
145147
// … and platform tables by name: [#22580] the unmanaged sweep runs on every
146148
// shape, because the composition records that it mirrors the served boot —
147149
// the compiled artifact with no config included, which it used to withhold
148-
// with a reason that no longer held.
150+
// with a reason that no longer held. [#24059] It reports nothing: the
151+
// raw-DDL table above exists, and its declaration accounts for it.
149152
expect(r.plan.unmanagedTables?.status, JSON.stringify(r.plan.unmanagedTables)).toBe('read');
150153
const unmanaged = (r.plan.unmanagedTables.tables ?? []).map((t: any) => t.table);
151-
expect(unmanaged).toEqual(NOT_AN_OBJECT);
154+
expect(unmanaged).toEqual([]);
152155
}
153156

154157
describe('os migrate plan examines exactly what os serve registers, per app shape (#22506)', () => {

‎packages/cli/src/commands/migrate/plan.telemetry-sibling.integration.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,10 @@ describe('os migrate plan / apply plan each object against the database it lives
194194
// Both databases' objects examined: their tables, less the raw-DDL one.
195195
expect(plan.payload.managedTables).toBe(primaryTables.length - NOT_AN_OBJECT.length + siblingTables.length);
196196
// [#22580] Both databases' catalogs swept: the sibling's stranded table is
197-
// reported beside the primary's raw-DDL one.
197+
// reported — and [#24059] the primary's raw-DDL one is not, its
198+
// declaration accounts for it.
198199
expect(plan.payload.unmanagedTables?.status, JSON.stringify(plan.payload.unmanagedTables)).toBe('read');
199-
expect(plan.payload.unmanagedTables.tables.map((t: any) => t.table)).toEqual([...NOT_AN_OBJECT, retired].sort());
200+
expect(plan.payload.unmanagedTables.tables.map((t: any) => t.table)).toEqual([retired]);
200201
// A plan writes nothing, to either database.
201202
expect({ primary: state(f.primary), sibling: state(f.sibling) }).toEqual(before);
202203

‎packages/cli/src/commands/migrate/plan.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ export default class MigratePlan extends Command {
232232
// resolved from it are two connections to one file.
233233
drivers: distinctDatabaseDrivers(stack.drivers),
234234
declaredObjects: stack.allObjects(),
235+
// [#24059] …and the tables the composition declares outside the object
236+
// set — `sys_packages`, which `os migrate apply` creates.
237+
nonObjectTables: stack.nonObjectTables,
235238
composition: stack.composition,
236239
normalize: normalizeRows,
237240
});

‎packages/cli/src/utils/schema-migrate.package-table.integration.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
* still absent while the plan is on screen), the flush creates it and
3333
* reports it, and the table is the one the boot's own DDL creates —
3434
* `PRIMARY KEY (id, version)`, `TEXT` timestamps, `idx_packages_latest`;
35-
* 3. a plan over the migrated database lists nothing for it.
35+
* 3. a plan over the migrated database lists nothing for it — and [#24059]
36+
* still names it among the tables the composition declares outside the
37+
* object set (`nonObjectTables`), which is what keeps `os migrate plan`'s
38+
* unmanaged-table sweep from reporting the table this apply created.
3639
*
3740
* The flag is SET for every boot here: the documented path must not depend on
3841
* it. That the boot itself creates nothing under the flag is
@@ -125,6 +128,7 @@ describe('os migrate plan / apply cover sys_packages, flag set (#23022)', () =>
125128
stack = await boot('plan');
126129
expect(stack.composition.servedBoot).toEqual({ mirrored: true });
127130
expect(packageWork(stack)).toEqual([{ table: 'sys_packages', kind: 'create_table', columns: COLUMNS }]);
131+
expect(stack.nonObjectTables).toEqual(['sys_packages']);
128132
await stack.shutdown();
129133
stack = null;
130134
expect(existsSync(dbFile), 'a dry run brought the database into existence').toBe(false);
@@ -150,8 +154,10 @@ describe('os migrate plan / apply cover sys_packages, flag set (#23022)', () =>
150154
index: [{ name: 'id' }, { name: 'created_at' }],
151155
});
152156

153-
// 3. plan again — the table is there, so nothing is pending for it.
157+
// 3. plan again — the table is there, so nothing is pending for it…
154158
stack = await boot('plan');
155159
expect(packageWork(stack)).toEqual([]);
160+
// …and [#24059] the declaration still names it, from the same service.
161+
expect(stack.nonObjectTables).toEqual(['sys_packages']);
156162
}, 300_000);
157163
});

‎packages/cli/src/utils/schema-migrate.ts‎

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ export interface SchemaStack {
198198
* (#3917). Always `[]` unless the stack was booted with `deferSchemaDdl`.
199199
*/
200200
pendingSchemaWork: PendingSchemaWork[];
201+
/**
202+
* [#24059] The physical tables this boot's composition declares OUTSIDE the
203+
* object set: the package registry's `sys_packages`, named by the same
204+
* `package.storage` declaration {@link pendingSchemaWork} plans it from
205+
* (#23022) — or `[]` when the boot composed no package registry.
206+
*
207+
* Named whether or not the table exists, and on every boot: the plan lists the
208+
* table as `create_table` only while it is absent, so once `os migrate apply`
209+
* has created it the pending work names nothing. `os migrate plan`'s
210+
* unmanaged-table sweep reads this, so the table the apply created is not
211+
* reported as declared by nothing.
212+
*/
213+
nonObjectTables: readonly string[];
201214
/**
202215
* [#21529] Did this boot MEASURE `objectName`'s table as absent? `true` only
203216
* when the held-back sync above would CREATE it (`create_table`), which the
@@ -331,29 +344,21 @@ interface PackageStorageDeclaration {
331344
}
332345

333346
/**
334-
* [#23022] `sys_packages` as pending schema work, when it is absent.
347+
* [#23022] The package registry's declaration of `sys_packages`, as this boot
348+
* composed it — or `null` when the boot composed no package registry.
335349
*
336-
* ## Why the plan needs this
350+
* [#24059] The ONE reader of that declaration. {@link planPackageTable} plans
351+
* the table from what this returns, and {@link SchemaStack.nonObjectTables}
352+
* names the table from the same object, so `os migrate plan`'s unmanaged-table
353+
* sweep knows the table by the source the plan creates it from — and knows it
354+
* whether or not the table exists, which the plan's pending work cannot say:
355+
* that lists the table only while it is absent.
337356
*
338-
* `PackageServicePlugin.start()` creates `sys_packages` with raw DDL, because
339-
* the table cannot be a declared object (`service-package`'s
340-
* `package-table.ts` records the measurement: the driver's managed tables key
341-
* on `id` alone, and this one keys on `(id, version)`). So no driver deferral
342-
* ever listed it, and a deployment that sets `OS_SKIP_SCHEMA_SYNC=1` — whose
343-
* boot now creates nothing — was left with no way to get it from the documented
344-
* path. The plugin declares the table in `init()`, which a migrate boot runs,
345-
* and this lists it as `create_table` beside the objects' tables; `apply`'s
346-
* flush then runs the plugin's own statements, so `apply` creates exactly what
347-
* the boot creates.
348-
*
349-
* `null` when there is nothing to plan: the plugin is not composed, or the
350-
* table exists. When its presence could not be READ, that is not absence: a
351-
* note says the table was not examined, and nothing is planned.
357+
* Throws when the service answers in a shape this CLI does not read: the two
358+
* packages come from different releases, and neither the plan nor the sweep can
359+
* name the table.
352360
*/
353-
async function planPackageTable(
354-
kernel: unknown,
355-
notes: string[],
356-
): Promise<{ storage: PackageStorageDeclaration; work: PendingSchemaWork } | null> {
361+
function findPackageStorage(kernel: unknown): PackageStorageDeclaration | null {
357362
let service: unknown;
358363
try {
359364
service = (kernel as { getService?: (name: string) => unknown }).getService?.(PACKAGE_STORAGE_SERVICE);
@@ -372,7 +377,33 @@ async function planPackageTable(
372377
+ 'from the same release.',
373378
);
374379
}
375-
const declared = storage as PackageStorageDeclaration;
380+
return storage as PackageStorageDeclaration;
381+
}
382+
383+
/**
384+
* [#23022] `sys_packages` as pending schema work, when it is absent.
385+
*
386+
* ## Why the plan needs this
387+
*
388+
* `PackageServicePlugin.start()` creates `sys_packages` with raw DDL, because
389+
* the table cannot be a declared object (`service-package`'s
390+
* `package-table.ts` records the measurement: the driver's managed tables key
391+
* on `id` alone, and this one keys on `(id, version)`). So no driver deferral
392+
* ever listed it, and a deployment that sets `OS_SKIP_SCHEMA_SYNC=1` — whose
393+
* boot now creates nothing — was left with no way to get it from the documented
394+
* path. The plugin declares the table in `init()`, which a migrate boot runs,
395+
* and this lists it as `create_table` beside the objects' tables; `apply`'s
396+
* flush then runs the plugin's own statements, so `apply` creates exactly what
397+
* the boot creates.
398+
*
399+
* `null` when there is nothing to plan: the table exists. When its presence
400+
* could not be READ, that is not absence: a note says the table was not
401+
* examined, and nothing is planned.
402+
*/
403+
async function planPackageTable(
404+
declared: PackageStorageDeclaration,
405+
notes: string[],
406+
): Promise<{ storage: PackageStorageDeclaration; work: PendingSchemaWork } | null> {
376407
const present = await declared.present();
377408
if (present === true) return null;
378409
if (present === undefined) {
@@ -1386,7 +1417,11 @@ export async function bootSchemaStack(
13861417
// [#23022] …and the one table outside the object set, `sys_packages`, which
13871418
// no driver deferral can see. Read-only here; `flushSchemaDdl` creates it.
13881419
// Not on a skip-sync boot: the table lives on the primary, which held nothing back.
1389-
let packageTable = defer ? await planPackageTable(kernel, composition.notes) : null;
1420+
// [#24059] The declaration itself is read on every boot, a skip-sync one
1421+
// included: `nonObjectTables` below names the table from it whether or not
1422+
// anything is planned for it.
1423+
const packageStorage = findPackageStorage(kernel);
1424+
let packageTable = defer && packageStorage ? await planPackageTable(packageStorage, composition.notes) : null;
13901425
if (packageTable) pendingSchemaWork.push(packageTable.work);
13911426
sortPendingSchemaWork(pendingSchemaWork);
13921427
// [#21529] The tables the held-back sync would create: measured absent.
@@ -1454,6 +1489,7 @@ export async function bootSchemaStack(
14541489
managedTableCount,
14551490
kernel,
14561491
pendingSchemaWork,
1492+
nonObjectTables: packageStorage ? [packageStorage.table] : [],
14571493
// The driver keys its deferred work by physical table, so ask in the same
14581494
// name (`StorageNameMapping`, the mapping the driver itself applies).
14591495
tableAbsent: (objectName: string): boolean =>

‎packages/cli/src/utils/unmanaged-tables.integration.test.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
* seam, against tables that really exist.
2424
*
2525
* ⛔ The NEGATIVE control is the point. `sys_user_permission_set` (from the composed
26-
* plugin) and `sys_secret` (from the platform floor) are put in the database
27-
* BEFORE the boot and must NOT be reported, alongside `_objectstack_sequences`
28-
* and an application table. A sweep observed only in the presence of an orphan
29-
* would be indistinguishable from one that reports everything.
26+
* plugin), `sys_secret` (from the platform floor) and [#24059] `sys_packages`
27+
* (the package registry's, declared outside the object set) are put in the
28+
* database BEFORE the boot and must NOT be reported, alongside
29+
* `_objectstack_sequences` and an application table. A sweep observed only in
30+
* the presence of an orphan would be indistinguishable from one that reports
31+
* everything.
3032
*
3133
* The positive control is the card's own measured case: a `sys_`-prefixed table
3234
* left behind by a retired object, which no plan can mention today.
@@ -81,6 +83,9 @@ const NEGATIVE_CONTROLS = [
8183
// declared by nothing, so the sweep is RIGHT to report it.
8284
'sys_user_permission_set', // declared by the composed host plugin
8385
'sys_secret', // declared by the platform floor
86+
// [#24059] Not an object: the composed package registry declares it through
87+
// `package.storage`, and `os migrate apply` creates it from that declaration.
88+
'sys_packages',
8489
'_objectstack_sequences', // the driver's own autonumber ledger
8590
'app_leftovers', // no reserved prefix at all
8691
];
@@ -143,6 +148,7 @@ describe('os migrate plan — unmanaged tables, against a real database (#13204)
143148
return collectUnmanagedTables({
144149
drivers: stack!.drivers,
145150
declaredObjects: stack!.allObjects(),
151+
nonObjectTables: stack!.nonObjectTables,
146152
composition: stack!.composition,
147153
normalize: normalizeRows,
148154
});
@@ -209,6 +215,12 @@ describe('os migrate plan — unmanaged tables, against a real database (#13204)
209215
const declared = (stack!.allObjects() as Array<{ name?: string }>).map((o) => o?.name);
210216
expect(declared).toContain('sys_user_permission_set');
211217
expect(declared).toContain('sys_secret');
218+
// [#24059] The table outside the object set is named by the declaration the
219+
// plan creates it from — the composed registry's own `package.storage` —
220+
// although the table exists here, so the plan has nothing pending for it.
221+
const storage = stack!.kernel.getService('package.storage') as { table: string };
222+
expect(stack!.nonObjectTables).toEqual([storage.table]);
223+
expect(stack!.pendingSchemaWork.filter((w) => w.table === storage.table)).toEqual([]);
212224
});
213225

214226
it('REPORTS the stranded table, and reports ONLY it', async () => {

0 commit comments

Comments
 (0)