@prisma-next/cli 0.6.0-dev.3 → 0.6.0-dev.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/cli.mjs +4 -4
  2. package/dist/{client-qVH-rEgd.mjs → client-BCnP7cHo.mjs} +9 -119
  3. package/dist/client-BCnP7cHo.mjs.map +1 -0
  4. package/dist/commands/contract-infer.mjs +1 -1
  5. package/dist/commands/db-init.mjs +3 -3
  6. package/dist/commands/db-schema.mjs +1 -1
  7. package/dist/commands/db-sign.mjs +1 -1
  8. package/dist/commands/db-update.mjs +3 -3
  9. package/dist/commands/db-verify.mjs +1 -1
  10. package/dist/commands/migration-apply.d.mts +1 -1
  11. package/dist/commands/migration-apply.mjs +2 -2
  12. package/dist/commands/migration-plan.d.mts.map +1 -1
  13. package/dist/commands/migration-plan.mjs +1 -1
  14. package/dist/commands/migration-show.d.mts +55 -7
  15. package/dist/commands/migration-show.d.mts.map +1 -1
  16. package/dist/commands/migration-show.mjs +153 -46
  17. package/dist/commands/migration-show.mjs.map +1 -1
  18. package/dist/commands/migration-status.d.mts.map +1 -1
  19. package/dist/commands/migration-status.mjs +1 -1
  20. package/dist/{contract-infer-BK9YFGEG.mjs → contract-infer-ByxhPjpW.mjs} +2 -2
  21. package/dist/{contract-infer-BK9YFGEG.mjs.map → contract-infer-ByxhPjpW.mjs.map} +1 -1
  22. package/dist/contract-space-aggregate-loader-BrwKK6Q6.mjs +160 -0
  23. package/dist/contract-space-aggregate-loader-BrwKK6Q6.mjs.map +1 -0
  24. package/dist/{db-verify-C0y1PCO2.mjs → db-verify-Czm5T-J4.mjs} +2 -2
  25. package/dist/{db-verify-C0y1PCO2.mjs.map → db-verify-Czm5T-J4.mjs.map} +1 -1
  26. package/dist/exports/control-api.d.mts +1 -1
  27. package/dist/exports/control-api.mjs +1 -1
  28. package/dist/{inspect-live-schema-CWYxGKlb.mjs → inspect-live-schema-DxdBd4Er.mjs} +2 -2
  29. package/dist/{inspect-live-schema-CWYxGKlb.mjs.map → inspect-live-schema-DxdBd4Er.mjs.map} +1 -1
  30. package/dist/{migration-command-scaffold-B5dORFEv.mjs → migration-command-scaffold-BdV8JYXV.mjs} +2 -2
  31. package/dist/{migration-command-scaffold-B5dORFEv.mjs.map → migration-command-scaffold-BdV8JYXV.mjs.map} +1 -1
  32. package/dist/{migration-plan-C6lVaHsO.mjs → migration-plan-mRu5K81L.mjs} +89 -149
  33. package/dist/migration-plan-mRu5K81L.mjs.map +1 -0
  34. package/dist/{migration-status-CZ-D5k7k.mjs → migration-status-By9G5p2H.mjs} +6 -8
  35. package/dist/{migration-status-CZ-D5k7k.mjs.map → migration-status-By9G5p2H.mjs.map} +1 -1
  36. package/dist/{migrations-D_UJnpuW.mjs → migrations-CTsyBXCA.mjs} +42 -29
  37. package/dist/migrations-CTsyBXCA.mjs.map +1 -0
  38. package/dist/{types-D7x-IFLO.d.mts → types-LItU7E4l.d.mts} +7 -9
  39. package/dist/{types-D7x-IFLO.d.mts.map → types-LItU7E4l.d.mts.map} +1 -1
  40. package/package.json +14 -14
  41. package/src/commands/migration-plan.ts +45 -47
  42. package/src/commands/migration-show.ts +245 -60
  43. package/src/commands/migration-status.ts +17 -9
  44. package/src/control-api/operations/db-apply-aggregate.ts +12 -10
  45. package/src/control-api/operations/migration-apply.ts +7 -1
  46. package/src/control-api/types.ts +6 -8
  47. package/src/utils/contract-space-aggregate-loader.ts +7 -34
  48. package/src/utils/contract-space-seed-phase.ts +201 -0
  49. package/src/utils/extension-pack-inputs.ts +47 -55
  50. package/src/utils/formatters/migrations.ts +80 -38
  51. package/dist/client-qVH-rEgd.mjs.map +0 -1
  52. package/dist/extension-pack-inputs-C7xgE-vv.mjs +0 -74
  53. package/dist/extension-pack-inputs-C7xgE-vv.mjs.map +0 -1
  54. package/dist/migration-plan-C6lVaHsO.mjs.map +0 -1
  55. package/dist/migrations-D_UJnpuW.mjs.map +0 -1
  56. package/src/utils/contract-space-extension-migrations-pass.ts +0 -120
  57. package/src/utils/contract-space-migrate-pass.ts +0 -156
@@ -0,0 +1,201 @@
1
+ import { materialiseExtensionMigrationPackageIfMissing } from '@prisma-next/migration-tools/io';
2
+ import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata';
3
+ import type { MigrationOps } from '@prisma-next/migration-tools/package';
4
+ import {
5
+ emitContractSpaceArtefacts,
6
+ planAllSpaces,
7
+ readContractSpaceHeadRef,
8
+ type SpacePlanOutput,
9
+ spaceMigrationDirectory,
10
+ } from '@prisma-next/migration-tools/spaces';
11
+
12
+ /**
13
+ * In-memory authored migration package shipped by an extension descriptor.
14
+ * Mirrors `MigrationPackage` from `@prisma-next/migration-tools/io` (the
15
+ * on-disk shape minus `dirPath`); redeclared structurally here so the
16
+ * CLI helper does not couple to any family's `ExtensionMigrationPackage`
17
+ * type — any family that ships pre-built migration packages can pass
18
+ * them through unchanged.
19
+ */
20
+ export interface DescriptorMigrationPackage {
21
+ readonly dirName: string;
22
+ readonly metadata: MigrationMetadata;
23
+ readonly ops: MigrationOps;
24
+ }
25
+
26
+ /**
27
+ * Minimal descriptor view consumed by the seed phase. Mirrors the shape
28
+ * the SQL family ships on each declared extension entry; only the fields
29
+ * the seed phase needs are surfaced.
30
+ */
31
+ export interface SeedPhaseExtensionInput {
32
+ readonly id: string;
33
+ readonly contractSpace?: {
34
+ readonly contractJson: unknown;
35
+ readonly headRef: { readonly hash: string; readonly invariants: readonly string[] };
36
+ readonly migrations: readonly DescriptorMigrationPackage[];
37
+ };
38
+ }
39
+
40
+ export interface ContractSpaceSeedPhaseInputs {
41
+ readonly migrationsDir: string;
42
+ readonly extensionPacks: ReadonlyArray<SeedPhaseExtensionInput>;
43
+ }
44
+
45
+ /**
46
+ * One per-space record describing what the seed phase did for an
47
+ * extension contract space. Surfaced verbatim by the caller (typically
48
+ * `migration plan`) so users see a single line per touched extension.
49
+ *
50
+ * - `action: 'updated'` — either the on-disk head pointer changed, or
51
+ * one or more new descriptor-shipped migration packages were
52
+ * materialised into `migrations/<spaceId>/<dirName>/`.
53
+ * - `action: 'unchanged'` — the on-disk head already matched the
54
+ * descriptor and no new migration packages needed to be written.
55
+ *
56
+ * Either way, the artefacts (`contract.json`, `contract.d.ts`,
57
+ * `refs/head.json`) are re-emitted: the framework owns those files and
58
+ * makes the re-emit observably idempotent at the byte level.
59
+ */
60
+ export interface ContractSpaceSeedPhaseRecord {
61
+ readonly spaceId: string;
62
+ readonly action: 'updated' | 'unchanged';
63
+ readonly priorHash: string | null;
64
+ readonly newHash: string;
65
+ readonly newMigrationDirs: readonly string[];
66
+ }
67
+
68
+ export interface ContractSpaceSeedPhaseResult {
69
+ readonly seeded: readonly ContractSpaceSeedPhaseRecord[];
70
+ }
71
+
72
+ /**
73
+ * Phase-1 of the two-phase `migration plan` pipeline (sub-spec § 4).
74
+ *
75
+ * For every extension that exposes a `contractSpace`:
76
+ *
77
+ * 1. Read the on-disk head ref (returns `null` on first emit).
78
+ * 2. Re-emit `contract.json` / `contract.d.ts` / `refs/head.json`
79
+ * unconditionally via {@link emitContractSpaceArtefacts}. The
80
+ * framework owns these files; re-emit is the contract.
81
+ * 3. Materialise any descriptor-shipped migration packages not yet on
82
+ * disk via {@link materialiseExtensionMigrationPackageIfMissing}.
83
+ * Existing packages are left untouched (by-existence skip).
84
+ *
85
+ * The return value lets the caller render a per-space status line and
86
+ * lets the phase-2 aggregate loader run on a now-consistent disk state
87
+ * (every loaded extension is guaranteed to have its head ref pinned
88
+ * to the descriptor's hash and to ship every package the descriptor
89
+ * declares).
90
+ *
91
+ * Output ordering is deterministic and alphabetical by spaceId (via
92
+ * {@link planAllSpaces}, which also detects duplicate spaceIds). This
93
+ * matches the canonical sort order used by every other aggregate
94
+ * surface (`migration apply`, `migration status`, the runner).
95
+ */
96
+ export async function runContractSpaceSeedPhase(
97
+ inputs: ContractSpaceSeedPhaseInputs,
98
+ ): Promise<ContractSpaceSeedPhaseResult> {
99
+ const planInputs = inputs.extensionPacks
100
+ .filter(
101
+ (
102
+ pack,
103
+ ): pack is SeedPhaseExtensionInput & {
104
+ contractSpace: NonNullable<SeedPhaseExtensionInput['contractSpace']>;
105
+ } => pack.contractSpace !== undefined,
106
+ )
107
+ .map((pack) => ({
108
+ spaceId: pack.id,
109
+ priorContract: null,
110
+ newContract: pack.contractSpace.contractJson,
111
+ __pack: pack.contractSpace,
112
+ }));
113
+
114
+ // `planAllSpaces` brings deterministic alphabetical ordering and
115
+ // duplicate-spaceId detection. The "planner" callback is a no-op
116
+ // pass-through that simply returns the descriptor's pre-built
117
+ // migration packages.
118
+ const planned: readonly SpacePlanOutput<DescriptorMigrationPackage>[] = planAllSpaces(
119
+ planInputs,
120
+ (input) =>
121
+ (
122
+ input as typeof input & {
123
+ readonly __pack: NonNullable<SeedPhaseExtensionInput['contractSpace']>;
124
+ }
125
+ ).__pack.migrations,
126
+ );
127
+
128
+ // Reassemble a spaceId → descriptor lookup so the loop below can read
129
+ // the contractJson / headRef without leaking the typed-cast back into
130
+ // `planAllSpaces`'s output shape.
131
+ const descriptorBySpace = new Map<
132
+ string,
133
+ NonNullable<SeedPhaseExtensionInput['contractSpace']>
134
+ >();
135
+ for (const pack of inputs.extensionPacks) {
136
+ if (pack.contractSpace !== undefined) descriptorBySpace.set(pack.id, pack.contractSpace);
137
+ }
138
+
139
+ const seeded: ContractSpaceSeedPhaseRecord[] = [];
140
+ for (const space of planned) {
141
+ const descriptor = descriptorBySpace.get(space.spaceId);
142
+ if (descriptor === undefined) continue;
143
+
144
+ const onDiskHeadRef = await readContractSpaceHeadRef(inputs.migrationsDir, space.spaceId);
145
+ const priorHash = onDiskHeadRef?.hash ?? null;
146
+
147
+ await emitContractSpaceArtefacts(inputs.migrationsDir, space.spaceId, {
148
+ contract: descriptor.contractJson,
149
+ contractDts: buildPlaceholderContractDts(space.spaceId),
150
+ headRef: { hash: descriptor.headRef.hash, invariants: descriptor.headRef.invariants },
151
+ });
152
+
153
+ const spaceDir = spaceMigrationDirectory(inputs.migrationsDir, space.spaceId);
154
+ const newMigrationDirs: string[] = [];
155
+ for (const pkg of space.migrationPackages) {
156
+ const { written } = await materialiseExtensionMigrationPackageIfMissing(spaceDir, pkg);
157
+ if (written) newMigrationDirs.push(pkg.dirName);
158
+ }
159
+
160
+ const action: ContractSpaceSeedPhaseRecord['action'] =
161
+ priorHash !== descriptor.headRef.hash || newMigrationDirs.length > 0
162
+ ? 'updated'
163
+ : 'unchanged';
164
+
165
+ seeded.push({
166
+ spaceId: space.spaceId,
167
+ action,
168
+ priorHash,
169
+ newHash: descriptor.headRef.hash,
170
+ newMigrationDirs,
171
+ });
172
+ }
173
+
174
+ return { seeded };
175
+ }
176
+
177
+ /**
178
+ * Placeholder `.d.ts` content for an extension space's on-disk mirror.
179
+ *
180
+ * Rendering a fully-typed `.d.ts` for an extension contract requires
181
+ * the SQL-family renderer with the codec / typemap registry threaded
182
+ * through; until that integration ships, the on-disk `.d.ts` is a
183
+ * stub `export {};` module that documents how consumers should
184
+ * validate the sibling `contract.json`. The stub typechecks on its
185
+ * own and does not need any TypeScript suppressions.
186
+ */
187
+ function buildPlaceholderContractDts(spaceId: string): string {
188
+ return [
189
+ '/**',
190
+ ` * Placeholder \`.d.ts\` for extension space "${spaceId}".`,
191
+ ' *',
192
+ ' * The framework re-emits this file on every `migration plan` run',
193
+ ' * alongside `contract.json` and `refs/head.json`. A typed `.d.ts`',
194
+ ' * rendering pass for extension contracts is tracked separately;',
195
+ ' * until that ships, consumers should import `contract.json`',
196
+ ' * directly with `validateContract<…>(…)`.',
197
+ ' */',
198
+ 'export {};',
199
+ '',
200
+ ].join('\n');
201
+ }
@@ -16,8 +16,6 @@
16
16
  import type { DeclaredExtensionEntry } from '@prisma-next/migration-tools/aggregate';
17
17
  import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata';
18
18
  import type { MigrationOps } from '@prisma-next/migration-tools/package';
19
- import type { ExtensionMigrationsExtensionInput } from './contract-space-extension-migrations-pass';
20
- import type { MigrateExtensionInput } from './contract-space-migrate-pass';
21
19
 
22
20
  /**
23
21
  * In-memory authored migration package shipped by an extension descriptor.
@@ -108,63 +106,57 @@ export function toExtensionInputs(
108
106
  // ---------------------------------------------------------------------------
109
107
 
110
108
  /**
111
- * Aggregate-loader projection: surfaces `targetId` + `contractSpace.contractJson`
112
- * to {@link import('./contract-space-aggregate-loader').buildContractSpaceAggregate}
113
- * and a `hashByContractJson` map keyed by the same `contractJson` reference
114
- * the loader hands to its hash callback.
109
+ * Aggregate-loader projection. Surfaces `id` + `targetId` per
110
+ * contract-space-bearing extension to
111
+ * {@link import('./contract-space-aggregate-loader').buildContractSpaceAggregate}.
112
+ *
113
+ * Codec-only extensions (no `contractSpace` declaration) are filtered
114
+ * out: they are not contract-space members, so the aggregate loader
115
+ * has nothing to do with them. Filtering happens at this descriptor-
116
+ * import boundary so the loader stays oblivious to that distinction —
117
+ * every entry it sees expects an on-disk `migrations/<id>/` directory.
115
118
  */
116
- export function toDeclaredExtensions(inputs: ReadonlyArray<ExtensionPackInput>): {
117
- readonly entries: ReadonlyArray<DeclaredExtensionEntry>;
118
- readonly hashByContractJson: Map<unknown, string>;
119
- } {
119
+ export function toDeclaredExtensions(
120
+ inputs: ReadonlyArray<ExtensionPackInput>,
121
+ ): readonly DeclaredExtensionEntry[] {
120
122
  const entries: DeclaredExtensionEntry[] = [];
121
- const hashByContractJson = new Map<unknown, string>();
122
123
  for (const pack of inputs) {
123
- if (pack.contractSpace) {
124
- entries.push({
125
- id: pack.id,
126
- targetId: pack.targetId,
127
- contractSpace: { contractJson: pack.contractSpace.contractJson },
128
- });
129
- hashByContractJson.set(pack.contractSpace.contractJson, pack.contractSpace.headRef.hash);
130
- } else {
131
- entries.push({ id: pack.id, targetId: pack.targetId });
132
- }
124
+ if (pack.contractSpace === undefined) continue;
125
+ entries.push({ id: pack.id, targetId: pack.targetId });
133
126
  }
134
- return { entries, hashByContractJson };
127
+ return entries;
135
128
  }
136
129
 
137
- /** Migrate-time per-space pass projection. */
138
- export function toMigratePassInputs(
139
- inputs: ReadonlyArray<ExtensionPackInput>,
140
- ): readonly MigrateExtensionInput[] {
141
- return inputs.map((pack) =>
142
- pack.contractSpace
143
- ? {
144
- id: pack.id,
145
- contractSpace: {
146
- contractJson: pack.contractSpace.contractJson,
147
- headRef: pack.contractSpace.headRef,
148
- },
149
- }
150
- : { id: pack.id },
151
- );
152
- }
153
-
154
- /** Extension-migrations materialisation pass projection. */
155
- export function toExtensionMigrationsInputs(
156
- inputs: ReadonlyArray<ExtensionPackInput>,
157
- ): readonly ExtensionMigrationsExtensionInput[] {
158
- return inputs.map((pack) =>
159
- pack.contractSpace
160
- ? {
161
- id: pack.id,
162
- contractSpace: {
163
- contractJson: pack.contractSpace.contractJson,
164
- headRef: pack.contractSpace.headRef,
165
- migrations: pack.contractSpace.migrations,
166
- },
167
- }
168
- : { id: pack.id },
169
- );
130
+ /**
131
+ * Minimal aggregate-loader projection that extracts `id` + `targetId`
132
+ * from raw extension pack descriptors **without invoking any
133
+ * `contractSpace` accessor**. Inspects the own-property descriptor so
134
+ * that getter-backed `contractSpace` declarations are detected but
135
+ * never called.
136
+ *
137
+ * Inclusion semantics match {@link toDeclaredExtensions}: a data
138
+ * property whose value is explicitly `undefined` is treated as "no
139
+ * contract-space declaration" and skipped, mirroring the
140
+ * `pack.contractSpace === undefined` check used on canonicalised
141
+ * inputs. Prototype-chain `contractSpace` properties (no own
142
+ * descriptor) are also skipped.
143
+ *
144
+ * This variant must be used by `buildContractSpaceAggregate` so that
145
+ * the aggregate path (including `db verify`) never reads
146
+ * `contractSpace.contractJson` from extension descriptors — the loader
147
+ * always reads the contract from on-disk artefacts instead.
148
+ */
149
+ export function toDeclaredExtensionsFromRaw(
150
+ extensionPacks: ReadonlyArray<unknown>,
151
+ ): readonly DeclaredExtensionEntry[] {
152
+ const entries: DeclaredExtensionEntry[] = [];
153
+ for (const raw of extensionPacks) {
154
+ if (typeof raw !== 'object' || raw === null) continue;
155
+ const descriptor = Object.getOwnPropertyDescriptor(raw, 'contractSpace');
156
+ if (descriptor === undefined) continue;
157
+ if ('value' in descriptor && descriptor.value === undefined) continue;
158
+ const pack = raw as { readonly id: string; readonly targetId: string };
159
+ entries.push({ id: pack.id, targetId: pack.targetId });
160
+ }
161
+ return entries;
170
162
  }
@@ -7,9 +7,9 @@ import { createColorFormatter, formatDim, isVerbose } from './helpers';
7
7
 
8
8
  /**
9
9
  * Render a single statement of an `OperationPreview` for the human-readable
10
- * preview block. SQL statements get a trailing `;` if missing matches the
11
- * legacy `string[]`-based renderer byte-for-byte (per spec OQ-4). Other
12
- * languages (`'mongodb-shell'`) render verbatim.
10
+ * preview block. SQL statements get a trailing `;` if missing so the rendered
11
+ * preview is byte-identical to the legacy `string[]`-based renderer for SQL
12
+ * targets. Other languages (`'mongodb-shell'`) render verbatim.
13
13
  */
14
14
  function renderPreviewStatement(text: string, language: string): string | undefined {
15
15
  const trimmed = text.trim();
@@ -22,9 +22,10 @@ function renderPreviewStatement(text: string, language: string): string | undefi
22
22
 
23
23
  /**
24
24
  * Choose the header label for a preview block. SQL-only previews keep the
25
- * legacy `DDL preview` label (preserves CLI byte-identity for SQL targets per
26
- * spec OQ-4); previews from any other family — or a mix that includes any
27
- * non-SQL language — use the family-agnostic `Operation preview` label.
25
+ * legacy `DDL preview` label so the rendered output is byte-identical to the
26
+ * pre-aggregate SQL CLI; previews from any other family — or a mix that
27
+ * includes any non-SQL language — use the family-agnostic `Operation preview`
28
+ * label.
28
29
  *
29
30
  * An empty `statements` array deliberately renders as `Operation preview`
30
31
  * rather than `DDL preview`: `Array.prototype.every` is vacuously true for
@@ -76,8 +77,9 @@ export interface MigrationCommandResult {
76
77
  /**
77
78
  * Per-space execution breakdown in canonical schedule order
78
79
  * (extensions alphabetically, then app). Surfaces per-space markers
79
- * + ops grouped by space; closes F1 / F4 / F7 from the M6
80
- * verification doc. See {@link AggregatePerSpaceExecutionEntry}.
80
+ * and the ops grouped by space, so the CLI summary can name which
81
+ * space each op and marker belongs to instead of flattening them
82
+ * into a single ambiguous list. See {@link AggregatePerSpaceExecutionEntry}.
81
83
  */
82
84
  readonly perSpace?: ReadonlyArray<AggregatePerSpaceExecutionEntry>;
83
85
  readonly summary: string;
@@ -88,10 +90,9 @@ export interface MigrationCommandResult {
88
90
 
89
91
  /**
90
92
  * Render the shared per-space execution block consumed by the `db init`
91
- * / `db update` / `migration apply` summaries (M6 sub-spec § Output
92
- * shape contract). Always shows: space label (`Extension space: <id>`
93
- * or `App space`) → per-op lines under each space → per-space marker
94
- * hash (when known).
93
+ * / `db update` / `migration apply` summaries. Always shows: space
94
+ * label (`Extension space: <id>` or `App space`) → per-op lines under
95
+ * each space → per-space marker hash (when known).
95
96
  *
96
97
  * `mode` controls the marker label phrasing — `'apply'` shows
97
98
  * `marker → <hash>` (post-apply), `'plan'` omits the marker line
@@ -165,7 +166,7 @@ export function formatMigrationPlanOutput(
165
166
  const formatYellow = createColorFormatter(useColor, yellow);
166
167
 
167
168
  // Per-space breakdown takes precedence over the flat ops tree when
168
- // the aggregate flow surfaced one (M6 sub-spec § Output shape contract).
169
+ // the aggregate flow surfaced one.
169
170
  if (result.perSpace && result.perSpace.length > 0) {
170
171
  lines.push('');
171
172
  lines.push(...formatPerSpaceBlock(result.perSpace, 'plan', useColor));
@@ -294,7 +295,9 @@ export function formatMigrationApplyCommandOutput(
294
295
  return lines.join('\n');
295
296
  }
296
297
 
297
- interface MigrationShowResult {
298
+ interface MigrationShowSpacePresent {
299
+ readonly kind: 'present';
300
+ readonly spaceId: string;
298
301
  readonly dirName: string;
299
302
  readonly dirPath: string;
300
303
  readonly from: string | null;
@@ -310,39 +313,48 @@ interface MigrationShowResult {
310
313
  readonly summary: string;
311
314
  }
312
315
 
313
- export function formatMigrationShowOutput(result: MigrationShowResult, flags: GlobalFlags): string {
314
- if (flags.quiet) {
315
- return '';
316
- }
316
+ interface MigrationShowSpaceMissing {
317
+ readonly kind: 'missing';
318
+ readonly spaceId: string;
319
+ readonly summary: string;
320
+ }
317
321
 
318
- const lines: string[] = [];
322
+ type MigrationShowSpaceResult = MigrationShowSpacePresent | MigrationShowSpaceMissing;
319
323
 
320
- const useColor = flags.color !== false;
324
+ interface MigrationShowResult {
325
+ readonly spaces: readonly MigrationShowSpaceResult[];
326
+ }
327
+
328
+ function formatSpaceShowBlock(
329
+ space: MigrationShowSpacePresent,
330
+ useColor: boolean,
331
+ ): readonly string[] {
321
332
  const formatGreen = createColorFormatter(useColor, green);
322
333
  const formatYellow = createColorFormatter(useColor, yellow);
323
334
  const formatDimText = (text: string) => formatDim(useColor, text);
324
335
 
325
- lines.push(`${formatGreen('✔')} ${result.dirName}`);
326
- lines.push(`${formatDimText(` from: ${result.from ?? '(baseline)'}`)}`);
327
- lines.push(`${formatDimText(` to: ${result.to}`)}`);
328
- lines.push(`${formatDimText(` migrationHash: ${result.migrationHash}`)}`);
329
- lines.push(`${formatDimText(` created: ${result.createdAt}`)}`);
336
+ const lines: string[] = [];
337
+ lines.push(`${formatGreen('✔')} ${space.dirName}`);
338
+ lines.push(`${formatDimText(` from: ${space.from ?? '(baseline)'}`)}`);
339
+ lines.push(`${formatDimText(` to: ${space.to}`)}`);
340
+ lines.push(`${formatDimText(` migrationHash: ${space.migrationHash}`)}`);
341
+ lines.push(`${formatDimText(` created: ${space.createdAt}`)}`);
330
342
 
331
343
  lines.push('');
332
- lines.push(`${result.operations.length} operation(s)`);
344
+ lines.push(`${space.operations.length} operation(s)`);
333
345
 
334
- if (result.operations.length > 0) {
346
+ if (space.operations.length > 0) {
335
347
  lines.push(`${formatDimText('│')}`);
336
- for (let i = 0; i < result.operations.length; i++) {
337
- const op = result.operations[i]!;
338
- const isLast = i === result.operations.length - 1;
348
+ for (let i = 0; i < space.operations.length; i++) {
349
+ const op = space.operations[i]!;
350
+ const isLast = i === space.operations.length - 1;
339
351
  const treeChar = isLast ? '└' : '├';
340
352
  const destructiveMarker =
341
353
  op.operationClass === 'destructive' ? ` ${formatYellow('(destructive)')}` : '';
342
354
  lines.push(`${formatDimText(treeChar)}─ ${op.label}${destructiveMarker}`);
343
355
  }
344
356
 
345
- const hasDestructive = result.operations.some((op) => op.operationClass === 'destructive');
357
+ const hasDestructive = space.operations.some((op) => op.operationClass === 'destructive');
346
358
  if (hasDestructive) {
347
359
  lines.push('');
348
360
  lines.push(
@@ -351,11 +363,11 @@ export function formatMigrationShowOutput(result: MigrationShowResult, flags: Gl
351
363
  }
352
364
  }
353
365
 
354
- if (result.preview.statements.length > 0) {
366
+ if (space.preview.statements.length > 0) {
355
367
  lines.push('');
356
- lines.push(`${formatDimText(previewBlockHeader(result.preview))}`);
368
+ lines.push(`${formatDimText(previewBlockHeader(space.preview))}`);
357
369
  lines.push('');
358
- for (const statement of result.preview.statements) {
370
+ for (const statement of space.preview.statements) {
359
371
  const rendered = renderPreviewStatement(statement.text, statement.language);
360
372
  if (rendered) {
361
373
  lines.push(rendered);
@@ -363,6 +375,36 @@ export function formatMigrationShowOutput(result: MigrationShowResult, flags: Gl
363
375
  }
364
376
  }
365
377
 
378
+ return lines;
379
+ }
380
+
381
+ export function formatMigrationShowOutput(result: MigrationShowResult, flags: GlobalFlags): string {
382
+ if (flags.quiet) {
383
+ return '';
384
+ }
385
+
386
+ const useColor = flags.color !== false;
387
+ const formatDimText = (text: string) => formatDim(useColor, text);
388
+ const multipleSpaces = result.spaces.length > 1;
389
+ const lines: string[] = [];
390
+
391
+ for (let i = 0; i < result.spaces.length; i++) {
392
+ const space = result.spaces[i]!;
393
+ if (multipleSpaces) {
394
+ lines.push(formatDimText(`── ${space.spaceId} ──`));
395
+ }
396
+ if (space.kind === 'missing') {
397
+ lines.push(formatDimText(` ${space.summary}`));
398
+ } else {
399
+ for (const line of formatSpaceShowBlock(space, useColor)) {
400
+ lines.push(line);
401
+ }
402
+ }
403
+ if (i < result.spaces.length - 1) {
404
+ lines.push('');
405
+ }
406
+ }
407
+
366
408
  return lines.join('\n');
367
409
  }
368
410
 
@@ -401,7 +443,7 @@ export function formatMigrationApplyOutput(
401
443
  }
402
444
 
403
445
  // Per-space breakdown — replaces the single ambiguous `Signature:`
404
- // line per M6 sub-spec § Output shape contract / AC4 / AC5.
446
+ // line with a per-space marker + ops listing.
405
447
  if (result.perSpace && result.perSpace.length > 0) {
406
448
  lines.push('');
407
449
  lines.push(...formatPerSpaceBlock(result.perSpace, 'apply', useColor));
@@ -415,9 +457,9 @@ export function formatMigrationApplyOutput(
415
457
  );
416
458
  } else if (result.marker) {
417
459
  // Single-space fallback (no aggregate breakdown surfaced — e.g.
418
- // older callers / non-aggregate code paths). Renamed from
419
- // `Signature` to `App-space marker` per AC4 when only one
420
- // marker is observable, name what it covers explicitly.
460
+ // older callers / non-aggregate code paths). The label is
461
+ // `App-space marker` (not `Signature`) so that when only one
462
+ // marker is observable we still name what it covers explicitly.
421
463
  lines.push(`${formatDimText(` App-space marker: ${result.marker.storageHash}`)}`);
422
464
  if (result.marker.profileHash) {
423
465
  lines.push(`${formatDimText(` Profile hash: ${result.marker.profileHash}`)}`);