Skip to content

Commit 5bdfba6

Browse files
committed
fix(cli): os migrate unmapped-columns boots the plan's object set, served platform included
Its documented contract is the plan's own unmapped_column findings over the plan's object set; with composeServedPlatform on plan/apply only, a platform object the plan reports on (sys_account) was OBJECT_NOT_FOUND here. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4782330 commit 5bdfba6

5 files changed

Lines changed: 52 additions & 10 deletions

File tree

‎.changeset/22506-migrate-composes-boot.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@
33
'@objectstack/driver-sql': patch
44
---
55

6-
fix(cli): `os migrate plan` / `apply` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506)
6+
fix(cli): `os migrate plan` / `apply` / `unmapped-columns` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506)
77

88
`os migrate plan` and `os migrate apply` only examine the objects their own boot registers, and that boot stopped at the stack: its config, its metadata and the platform floor. Nothing `os serve` mounts around a stack was there. On an app declaring `requires: ['auth']`, the plan examined 9 of the 68 tables the serving boot had created. `sys_account` read as an undeclared platform table, so its retired `issuer` column (and that column's unique index) was never a destructive drop. `os migrate apply --allow-destructive` dropped nothing, while the boot's drift line and `os migrate account-issuer` kept prescribing exactly that command.
99

10-
The two commands now also compose, each through the rule `os serve` itself reads:
10+
The two commands now also compose, each through the rule `os serve` itself reads, and so does `os migrate unmapped-columns`, which reads the plan's own `unmapped_column` findings and therefore needs the plan's object set:
1111

1212
- the auth family behind `serve`'s auth gate: plugin-auth's identity objects, the security plugin and the audit plugin, when the stack mounts no `AuthPlugin` of its own, the `auth` tier is on, and an auth secret resolves;
1313
- the provider of every capability `serve` mounts: the stack's `requires` and the always-on slate, as `os serve` composes it with no `--preset`;
1414
- the REST API plugin;
1515
- every `plugins` entry by `serve`'s rule for one: a package name is loaded from the app, and a plain bundle is wrapped as an app;
1616
- the pinyin-search decision from the config's locales, so the `__search` companion columns a boot provisions are no longer listed as destructive drops on a config-only project.
1717

18+
`os migrate unmapped-columns --object sys_account` (or any platform object the plan now declares) resolves the object and reads its unmapped columns, where it used to answer `OBJECT_NOT_FOUND`.
19+
1820
Each piece is composed for its declarations only. Its `init()` runs, and its `start()` and lifecycle hooks do not, so no dispatcher, scheduler or seed runs inside a dry run.
1921

2022
**What an operator sees.** On the shape that looped, `os migrate plan` lists `sys_account.issuer` and `uniq_sys_account_issuer_account_id` as destructive drops. `os migrate apply --allow-destructive` drops both, `os migrate account-issuer` reads zero, and the next boot prints no drift line for them. When no auth secret is set and the boot is not a development one, the plan says it did not compose the auth family, and how to compose it: run with the deployment's `OS_AUTH_SECRET` exported. `os migrate` reads no `.env` file.

‎packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@
3030
* type): refused in both faces, exit 1, naming the column and the record
3131
* id, no record emitted;
3232
* 7. the door writes nothing: the schema and every row are byte-identical
33-
* after it ran.
33+
* after it ran;
34+
* 8. [#22506] a PLATFORM object the plan declares — `sys_account`, behind
35+
* `os serve`'s auth gate, carrying the retired `issuer` column — is
36+
* resolved and read, the same column set the plan reports for its table,
37+
* not refused as `OBJECT_NOT_FOUND`. The plan composes what `os serve`
38+
* mounts around the stack, so this door has to boot that object set too.
3439
*
3540
* The runtime half, that the engine's data door never serves these columns, is
3641
* the read narrowing's own pin
@@ -130,6 +135,13 @@ await raw.knex.raw('ALTER TABLE um_contact ADD COLUMN ${HASH_SHADOW} text');
130135
await raw.knex('um_contact').update({ ${HASH_SHADOW}: 'shadow' });
131136
await raw.knex.raw('ALTER TABLE um_blob ADD COLUMN legacy_bytes blob');
132137
await raw.knex('um_blob').update({ legacy_bytes: Buffer.from([1, 2, 255]) });
138+
// [#22506] A platform table the auth family declares, in the shape a
139+
// pre-retirement deployment left it: today's definition plus the retired
140+
// \`issuer\` column, one account row carrying a value in it.
141+
const { SysAccount } = await import('@objectstack/platform-objects/identity');
142+
await raw.initObjects([SysAccount]);
143+
await raw.knex.raw('ALTER TABLE sys_account ADD COLUMN issuer text');
144+
await raw.knex('sys_account').insert({ id: 'acc_os22506', provider_id: 'credential', account_id: 'u1', user_id: 'u1', issuer: 'local:credential' });
133145
await raw.disconnect();
134146
process.stderr.write('[fixture] seeded\\n');
135147
process.exit(0);
@@ -185,6 +197,9 @@ function runCli(argv: string[]): Promise<Run> {
185197
NO_COLOR: '1',
186198
OS_ARTIFACT_PATH: join(dir, 'dist', 'objectstack.json'),
187199
OS_SECRET_KEY: '0e2e'.repeat(16),
200+
// [#22506] Auth-enabled, as a serving deployment is: `os serve`'s auth
201+
// gate composes the auth family when a secret resolves.
202+
OS_AUTH_SECRET: 'os22506-unmapped-secret-at-least-32-chars',
188203
}),
189204
stdio: ['ignore', 'pipe', 'pipe'],
190205
});
@@ -217,6 +232,7 @@ let unknownJson: Run;
217232
let cappedJson: Run;
218233
let bytesJson: Run;
219234
let bytesHuman: Run;
235+
let platformJson: Run;
220236

221237
beforeAll(async () => {
222238
dir = mkdtempSync(join(tmpdir(), 'os-21573-'));
@@ -241,6 +257,7 @@ beforeAll(async () => {
241257
cappedJson = await runCli(door('um_contact', '--max-records', '2', '--json'));
242258
bytesJson = await runCli(door('um_blob', '--json'));
243259
bytesHuman = await runCli(door('um_blob'));
260+
platformJson = await runCli(door('sys_account', '--json'));
244261
after = readState();
245262
planJson = await runCli(['migrate', 'plan', '--json']);
246263
}, HOOK_TIMEOUT_MS);
@@ -329,3 +346,19 @@ describe('os migrate unmapped-columns: the other answers', () => {
329346
for (const out of [bytesJson.stdout, bytesHuman.stdout]) expect(out).not.toContain('"type":"Buffer"');
330347
});
331348
});
349+
350+
describe('os migrate unmapped-columns: a platform object the plan declares (#22506)', () => {
351+
it('resolves sys_account on an auth-enabled stack and reads the column the plan reports, not OBJECT_NOT_FOUND', () => {
352+
expect(platformJson.code, `${platformJson.stdout}\n${platformJson.stderr}`).toBe(0);
353+
const doc = JSON.parse(platformJson.stdout);
354+
expect(doc).toMatchObject({ object: 'sys_account', table: 'sys_account', count: 1 });
355+
expect(doc.records).toEqual([{ id: 'acc_os22506', values: { issuer: 'local:credential' } }]);
356+
// ONE column set with the plan, on this platform table too.
357+
const plan = JSON.parse(planJson.stdout);
358+
const planned = (plan.changes as Array<{ kind: string; table: string; column: string; actual: string }>)
359+
.filter((c) => c.kind === 'unmapped_column' && c.table === 'sys_account')
360+
.map((c) => ({ column: c.column, actual: c.actual }));
361+
expect(planned.map((c) => c.column)).toEqual(['issuer']);
362+
expect(doc.columns).toEqual(planned);
363+
});
364+
});

‎packages/cli/src/commands/migrate/unmapped-columns.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,14 +285,18 @@ export default class MigrateUnmappedColumns extends Command {
285285
try {
286286
// The `os migrate plan` boot, so the differ runs over the plan's object
287287
// set: schema DDL deferred and no seed (`deferSchemaDdl`), no database
288-
// file brought into existence (`readOnlyProbe`), and the deployment's
289-
// own composition (`composeHostStack`).
288+
// file brought into existence (`readOnlyProbe`), the deployment's own
289+
// composition (`composeHostStack`), and what `os serve` mounts around it
290+
// (`composeServedPlatform`, #22506) — or a platform object the plan
291+
// reports an `unmapped_column` on, such as `sys_account`, is one this
292+
// command cannot resolve.
290293
stack = await bootSchemaStack({
291294
jsonOutput: flags.json,
292295
...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}),
293296
deferSchemaDdl: true,
294297
readOnlyProbe: true,
295298
composeHostStack: true,
299+
composeServedPlatform: true,
296300
});
297301
} catch (error: any) {
298302
if (flags.json) {

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,8 +472,10 @@ export async function bootSchemaStack(
472472
* mounts AROUND the stack, each piece for its declarations only: the auth
473473
* family behind its auth gate, the provider of every capability its
474474
* resolver mounts (the stack's `requires` and the always-on slate), and
475-
* the REST API plugin. Set by `os migrate plan` and `os migrate apply`, and
476-
* by nothing else — their subject is the deployment's whole object set.
475+
* the REST API plugin. Set by `os migrate plan` and `os migrate apply`,
476+
* whose subject is the deployment's whole object set, and by `os migrate
477+
* unmapped-columns`, which reads the plan's own findings over that set —
478+
* and by nothing else.
477479
* See `buildSchemaMigrationPlugins`'s `servedPlatform`.
478480
*/
479481
composeServedPlatform?: boolean;

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ import {
6868
* companion columns its boot provisions are in the schema view too.
6969
* - **`PlatformObjectsPlugin`**, when absent — `serve` step 5c, composed on
7070
* every served boot.
71-
* - **What `os serve` mounts AROUND the stack — `os migrate plan` and `apply`
72-
* only** (`servedPlatform`, #22506, {@link composeServedPlatform}): the auth
71+
* - **What `os serve` mounts AROUND the stack — `os migrate plan`, `apply` and
72+
* `unmapped-columns` only** (`servedPlatform`, #22506, {@link composeServedPlatform}): the auth
7373
* family behind `serve`'s auth gate, the provider of every capability its
7474
* resolver mounts (the stack's `requires` and the always-on slate), and the
7575
* REST API plugin — each through the rule `serve` reads, each for its
@@ -1531,7 +1531,8 @@ export async function buildSchemaMigrationPlugins(opts: {
15311531
* stack surfaced it.
15321532
*
15331533
* Set by `os migrate plan` and `os migrate apply`, whose subject is the
1534-
* deployment's whole object set. Unset (`os migrate
1534+
* deployment's whole object set, and by `os migrate unmapped-columns`, which
1535+
* reads the plan's own `unmapped_column` findings over that set. Unset (`os migrate
15351536
* security-catalog-overlays`, which also boots NON-deferred under `--apply`):
15361537
* a provider is composed only when a composed plugin hard-depends on it
15371538
* (#21732), as before.

0 commit comments

Comments
 (0)