Skip to content

Commit d7b741e

Browse files
committed
test(cli): pin the served-boot fact per project shape, the sweep on an artifact project, its reason on the controls, and the sibling sweep
Claude-Session: https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55527f8 commit d7b741e

7 files changed

Lines changed: 206 additions & 37 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate plan`'s unmanaged-tables sweep runs whenever the plan's object set mirrors what `os serve` registers, covers every database the plan diffs, and states only a reason that holds when it does not run (#22580)
6+
7+
On a project with a compiled artifact and no `objectstack.config.ts`, `os migrate plan` composes what `os serve` registers, platform included. The sweep for platform-prefixed tables that no object declares was still withheld there (`unmanagedTables.status: "unreadable"` in `--json`), with a reason claiming the plan's object set was only the artifact plus the platform floor. On a project with neither a config nor a compiled artifact, the same untrue reason was given.
8+
9+
The plan's composition now records whether its object set mirrors the served boot, and why when it does not. The sweep runs exactly when it does, the compiled-artifact project included. When it does not run, the `detail` names the composition's own reason: the host config that did not load, or a project with neither a config nor a compiled artifact.
10+
11+
The sweep also reads every database the plan diffs. Where the serving boot keeps lifecycle-classed objects in the `telemetry` sibling database, a table stranded there is now reported too, where before the sweep answered `read` over the primary database alone. A sibling whose catalog cannot be read makes the whole sweep `unreadable`, and the `detail` names that database. The `--json` shape of `unmanagedTables` is unchanged. `physicalTables` counts the tables of every database swept, and the human line says "in the database(s) this plan covers".

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ import Database from 'better-sqlite3';
3838
* - the boot's tables, a rotation shard read as its object, number exactly
3939
* the plan's examined objects plus `sys_packages` (boot ⊆ plan, app tables
4040
* included) — with `pending` empty, equal counts are equal sets;
41-
* - where the unmanaged-tables sweep runs (a project with a host config), it
42-
* names nothing but `sys_packages`.
41+
* - the unmanaged-tables sweep RUNS on every shape — the composition says it
42+
* mirrors the served boot, the compiled-artifact project with no config
43+
* included (#22580) — and names nothing but `sys_packages`.
4344
*
4445
* `sys_packages` is the one table outside the object set, by construction:
4546
* `PackageServicePlugin.start()` creates it with raw DDL (`package-table.ts`),
@@ -141,12 +142,13 @@ function expectParity(r: Reading): void {
141142
const rawDdl = r.tables.filter((t) => NOT_AN_OBJECT.includes(t));
142143
expect(rawDdl).toEqual(NOT_AN_OBJECT);
143144
expect(r.tables.length).toBe(r.plan.managedTables + rawDdl.length);
144-
// … and platform tables by name, where the unmanaged sweep runs (it reports
145-
// itself `unreadable` on a project with no host config).
146-
if (r.plan.unmanagedTables?.status === 'read') {
147-
const unmanaged = (r.plan.unmanagedTables.tables ?? []).map((t: any) => (typeof t === 'string' ? t : t.table ?? t.name));
148-
expect(unmanaged.filter((t: string) => !NOT_AN_OBJECT.includes(t))).toEqual([]);
149-
}
145+
// … and platform tables by name: [#22580] the unmanaged sweep runs on every
146+
// shape, because the composition records that it mirrors the served boot —
147+
// the compiled artifact with no config included, which it used to withhold
148+
// with a reason that no longer held.
149+
expect(r.plan.unmanagedTables?.status, JSON.stringify(r.plan.unmanagedTables)).toBe('read');
150+
const unmanaged = (r.plan.unmanagedTables.tables ?? []).map((t: any) => t.table);
151+
expect(unmanaged).toEqual(NOT_AN_OBJECT);
150152
}
151153

152154
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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import Database from 'better-sqlite3';
2929
* - after a development boot, the plan names the sibling, lists no
3030
* lifecycle-classed object for the primary, and examines both databases'
3131
* objects; `apply` creates nothing, and neither database moves;
32+
* - [#22580] its unmanaged-tables sweep reads the sibling's catalog too: a
33+
* retired lifecycle-classed object strands its table there, and a sweep of
34+
* the primary alone answered `read` without ever looking;
3235
* - the CONTROL: a deployment with no sibling (`OS_TELEMETRY_DB=0`) plans as
3336
* it always did — no sibling named, nothing pending;
3437
* - a plan with the sibling on over a deployment that has none yet plans the
@@ -159,6 +162,11 @@ describe('os migrate plan / apply plan each object against the database it lives
159162
const siblingTables = tables(f.sibling);
160163
expect(siblingTables).toEqual(expect.arrayContaining(['sys_audit_log', 'sys_metadata_audit']));
161164
expect(primaryTables.filter((t) => siblingTables.includes(t)), 'one table in both databases').toEqual([]);
165+
// [#22580] What a retired lifecycle-classed object leaves behind: its table,
166+
// in the sibling, declared by nothing.
167+
const retired = 'sys_os22580_retired_event';
168+
const db = new Database(f.sibling);
169+
try { db.exec(`CREATE TABLE ${retired} (id TEXT PRIMARY KEY)`); } finally { db.close(); }
162170
const before = { primary: state(f.primary), sibling: state(f.sibling) };
163171

164172
const plan = command(f, ['plan', '--json']);
@@ -170,6 +178,10 @@ describe('os migrate plan / apply plan each object against the database it lives
170178
expect(plan.payload.composition?.coverage?.unexaminedObjects).toBe(0);
171179
// Both databases' objects examined: their tables, less the raw-DDL one.
172180
expect(plan.payload.managedTables).toBe(primaryTables.length - NOT_AN_OBJECT.length + siblingTables.length);
181+
// [#22580] Both databases' catalogs swept: the sibling's stranded table is
182+
// reported beside the primary's raw-DDL one.
183+
expect(plan.payload.unmanagedTables?.status, JSON.stringify(plan.payload.unmanagedTables)).toBe('read');
184+
expect(plan.payload.unmanagedTables.tables.map((t: any) => t.table)).toEqual([...NOT_AN_OBJECT, retired].sort());
173185
// A plan writes nothing, to either database.
174186
expect({ primary: state(f.primary), sibling: state(f.sibling) }).toEqual(before);
175187

‎packages/cli/src/utils/schema-migration-plugins.test.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ describe('buildSchemaMigrationPlugins', () => {
185185
expect(out.notes).toEqual([]);
186186
expect(out.hostConfigPath).toBeNull();
187187
expect(out.hostConfigLoaded).toBe(false);
188+
// [#22580] …and it says so: there is no deployment here to mirror.
189+
expect(out.servedBoot).toEqual({ mirrored: false, reason: 'nothing-to-compose' });
188190
});
189191

190192
it('composes the platform floor once an artifact app is present, and not twice', async () => {
@@ -468,7 +470,7 @@ describe('describeUnloadableHostConfig (#12953)', () => {
468470
function composition(over: Partial<SchemaMigrationComposition>): SchemaMigrationComposition {
469471
return {
470472
plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null,
471-
notes: [], coverage: null, ...over,
473+
servedBoot: { mirrored: false, reason: 'nothing-to-compose' }, notes: [], coverage: null, ...over,
472474
};
473475
}
474476

@@ -537,7 +539,7 @@ describe('describeUnloadableHostConfig — the no-DDL notice (#13118)', () => {
537539
function composition(over: Partial<SchemaMigrationComposition>): SchemaMigrationComposition {
538540
return {
539541
plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null,
540-
notes: [], coverage: null, ...over,
542+
servedBoot: { mirrored: false, reason: 'nothing-to-compose' }, notes: [], coverage: null, ...over,
541543
};
542544
}
543545

@@ -917,6 +919,36 @@ describe('servedPlatform (#22506)', () => {
917919
'com.objectstack.platform-objects',
918920
]);
919921
}, 60_000);
922+
923+
/**
924+
* [#22580] The composition records, where it decides it, whether its set
925+
* mirrors the served boot — the fact `os migrate plan`'s unmanaged-tables
926+
* sweep gates on. Every shape a project can be in, each answer its own.
927+
*/
928+
it('[#22580] records whether the set mirrors the served boot — per project shape, never from hostConfigLoaded', async () => {
929+
// A compiled artifact and NO config: `hostConfigLoaded` is false, and the
930+
// set IS the served boot's once the served platform is composed.
931+
const artifactApp = { type: 'app', name: 'plugin.app.demo' };
932+
const artifact = await buildSchemaMigrationPlugins({ basePlugins: [artifactApp], cwd: tempProject(), servedPlatform: {} });
933+
expect(artifact.hostConfigLoaded).toBe(false);
934+
expect(artifact.servedBoot).toEqual({ mirrored: true });
935+
936+
const config = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: project(APP), servedPlatform: {} });
937+
expect(config.servedBoot).toEqual({ mirrored: true });
938+
939+
// The controls: each set that does NOT mirror the served boot, with its reason.
940+
const stackOnly = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: project(APP) });
941+
expect(stackOnly.servedBoot).toEqual({ mirrored: false, reason: 'stack-only' });
942+
943+
const unloadable = await buildSchemaMigrationPlugins({
944+
basePlugins: [], cwd: project("throw new Error('fixture: os22580 unloadable');\n"), servedPlatform: {},
945+
});
946+
expect(unloadable.hostConfigLoaded).toBe(false);
947+
expect(unloadable.servedBoot).toEqual({ mirrored: false, reason: 'config-unloadable' });
948+
949+
const nothing = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: tempProject(), servedPlatform: {} });
950+
expect(nothing.servedBoot).toEqual({ mirrored: false, reason: 'nothing-to-compose' });
951+
}, 120_000);
920952
});
921953

922954
describe('composeProviderForDeclarations (#22506)', () => {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,15 @@ describe('os migrate plan — unmanaged tables, against a real database (#13204)
130130
deferSchemaDdl: true,
131131
readOnlyProbe: true,
132132
composeHostStack: true,
133+
// [#22506] `plan` composes what `os serve` mounts around the stack too —
134+
// and [#22580] the sweep runs only over a set that mirrors the served boot.
135+
composeServedPlatform: true,
133136
});
134137

135138
async function sweep(): Promise<UnmanagedTablesReport> {
136139
const { normalizeRows } = await import('@objectstack/metadata-protocol');
137140
return collectUnmanagedTables({
138-
driver: stack!.driver,
141+
drivers: stack!.drivers,
139142
declaredObjects: stack!.allObjects(),
140143
composition: stack!.composition,
141144
normalize: normalizeRows,
@@ -198,6 +201,7 @@ describe('os migrate plan — unmanaged tables, against a real database (#13204)
198201
// assertion below pass for the wrong reason.
199202
expect(stack!.driver).not.toBeNull();
200203
expect(stack!.composition.hostConfigLoaded).toBe(true);
204+
expect(stack!.composition.servedBoot).toEqual({ mirrored: true });
201205
expect(stack!.managedTableCount).toBeGreaterThan(0);
202206
const declared = (stack!.allObjects() as Array<{ name?: string }>).map((o) => o?.name);
203207
expect(declared).toContain('sys_permission_set');

0 commit comments

Comments
 (0)