Skip to content

Commit 1e9d5ba

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22727-formula-currency-result
2 parents 2cfbea7 + 7c7e750 commit 1e9d5ba

11 files changed

Lines changed: 397 additions & 114 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/commands/migrate/plan.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ export default class MigratePlan extends Command {
195195
// indistinguishable from "never looked at". See `../../utils/unmanaged-tables.ts`.
196196
const { normalizeRows } = await import('@objectstack/metadata-protocol');
197197
const unmanagedTables: UnmanagedTablesReport = await collectUnmanagedTables({
198-
driver: stack.driver,
198+
// [#22580] Every database the drift above covers, the sibling included.
199+
drivers: stack.drivers,
199200
declaredObjects: stack.allObjects(),
200201
composition: stack.composition,
201202
normalize: normalizeRows,

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ export interface SchemaStack {
174174
/**
175175
* What the host composition added, and anything it could not (#12938) — the
176176
* host config it composed, whether that config actually loaded, the platform
177-
* floor it added. `notes` is always `[]` unless the boot asked for
177+
* floor it added, and whether the set mirrors what `os serve` registers
178+
* (`servedBoot`, #22580). `notes` is always `[]` unless the boot asked for
178179
* `composeHostStack`, and `[]` even then when there was nothing to compose,
179180
* so a project with neither a config nor a compiled artifact renders
180181
* byte-identically to before this existed.
@@ -863,6 +864,8 @@ export async function bootSchemaStack(
863864
})
864865
: {
865866
plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null,
867+
// [#22580] The data stack alone: this boot was not asked for the deployment's set.
868+
servedBoot: { mirrored: false, reason: 'not-composed' },
866869
notes: [], coverage: null,
867870
} satisfies SchemaMigrationComposition;
868871
for (const plugin of composition.plugins) {

‎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/schema-migration-plugins.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1286,6 +1286,36 @@ export interface SchemaMigrationComposition {
12861286
* `null` on every other shape, including a config that loaded.
12871287
*/
12881288
hostConfigError: string | null;
1289+
/**
1290+
* [#22580] Does the composed object set MIRROR what this deployment's
1291+
* `os serve` boot registers? Recorded once, by the code that decides it, so
1292+
* a consumer that needs the answer reads it here rather than re-deriving it.
1293+
*
1294+
* `mirrored: true` exactly when {@link composeServedPlatform} composed what
1295+
* `serve` mounts around a stack this boot could read: a host config that
1296+
* loaded, or a compiled artifact with no config. That is the set
1297+
* `commands/migrate/plan.boot-parity.integration.test.ts` holds equal to a
1298+
* real `os serve` boot's, per app shape. Otherwise `reason` says why not:
1299+
*
1300+
* - `not-composed`: the boot did not ask for the host composition
1301+
* (`composeHostStack` off), so the set is the data stack alone;
1302+
* - `nothing-to-compose`: neither a host config nor a compiled artifact, so
1303+
* there is no deployment here to mirror;
1304+
* - `config-unloadable`: the host config exists and did not load (#12953);
1305+
* - `stack-only`: the caller did not ask for what `serve` mounts around the
1306+
* stack (`servedPlatform`), so its auth family and capability providers
1307+
* are not in the set.
1308+
*
1309+
* ⛔ Not a second reading of {@link hostConfigLoaded}: that is `false` on a
1310+
* project with a compiled artifact and no config too, which mirrors the
1311+
* served boot once the served platform is composed (#22506).
1312+
*/
1313+
servedBoot:
1314+
| { readonly mirrored: true }
1315+
| {
1316+
readonly mirrored: false;
1317+
readonly reason: 'not-composed' | 'nothing-to-compose' | 'config-unloadable' | 'stack-only';
1318+
};
12891319
/**
12901320
* One line per thing this composition did or could not do, for the command to
12911321
* print. Empty when nothing was composed, so a project with neither a config
@@ -1333,6 +1363,7 @@ const NOTHING_COMPOSED: SchemaMigrationComposition = Object.freeze({
13331363
hostConfigPath: null,
13341364
hostConfigLoaded: false,
13351365
hostConfigError: null,
1366+
servedBoot: { mirrored: false, reason: 'nothing-to-compose' },
13361367
notes: [],
13371368
coverage: null,
13381369
}) as SchemaMigrationComposition;
@@ -1564,6 +1595,12 @@ export async function buildSchemaMigrationPlugins(opts: {
15641595
const notes: string[] = [];
15651596
let hostConfigLoaded = false;
15661597
let hostConfigError: string | null = null;
1598+
// [#22580] Whether this set mirrors the served boot — set below where each
1599+
// answer is decided: the catch that finds the config unloadable, and the
1600+
// branch that composes what `serve` mounts around the stack.
1601+
let servedBoot: SchemaMigrationComposition['servedBoot'] = !hostConfigPath && !hasArtifactApp
1602+
? { mirrored: false, reason: 'nothing-to-compose' }
1603+
: { mirrored: false, reason: 'stack-only' };
15671604
// #21732 — the config's capability declarations, read off the same loaded
15681605
// config the plugins came from. See {@link resolveRequiredProviders}.
15691606
let loadedRequires: unknown;
@@ -1678,6 +1715,7 @@ export async function buildSchemaMigrationPlugins(opts: {
16781715
console.warn(`[migrate] ⚠ ${line}`);
16791716
notes.push(line);
16801717
hostConfigError = message;
1718+
servedBoot = { mirrored: false, reason: 'config-unloadable' };
16811719
}
16821720
}
16831721

@@ -1713,6 +1751,9 @@ export async function buildSchemaMigrationPlugins(opts: {
17131751
plugins.splice(1, 0, ...served.authFamily);
17141752
plugins.push(...served.plugins);
17151753
notes.push(...served.notes);
1754+
// [#22580] The stack this boot read, and what `serve` mounts around it:
1755+
// the set the boot-parity pin holds equal to `serve`'s.
1756+
if (hostConfigPath || hasArtifactApp) servedBoot = { mirrored: true };
17161757
}
17171758
// #21732 — `serve` step 5, narrowed to what this boot cannot start without:
17181759
// a provider the config's `requires` supplies, that a composed plugin
@@ -1751,7 +1792,8 @@ export async function buildSchemaMigrationPlugins(opts: {
17511792
}
17521793

17531794
return {
1754-
plugins, hostConfigPath, hostConfigLoaded, hostConfigError, notes, coverage: null, writeGuard, lifecycle,
1795+
plugins, hostConfigPath, hostConfigLoaded, hostConfigError, servedBoot,
1796+
notes, coverage: null, writeGuard, lifecycle,
17551797
...(securityPlugin ? { securityPlugin } : {}),
17561798
};
17571799
}

‎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)