@prisma-next/cli 0.5.0-dev.8 → 0.5.0-dev.81

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 (186) hide show
  1. package/README.md +56 -21
  2. package/dist/cli-errors-B9OBbled.d.mts +3 -0
  3. package/dist/cli-errors-D3_sMh2K.mjs +33 -0
  4. package/dist/cli-errors-D3_sMh2K.mjs.map +1 -0
  5. package/dist/cli.mjs +16 -78
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/client-qVH-rEgd.mjs +1595 -0
  8. package/dist/client-qVH-rEgd.mjs.map +1 -0
  9. package/dist/{result-handler-Ba3zWQsI.mjs → command-helpers-BeZHkxV8.mjs} +70 -47
  10. package/dist/command-helpers-BeZHkxV8.mjs.map +1 -0
  11. package/dist/commands/contract-emit.d.mts.map +1 -1
  12. package/dist/commands/contract-emit.mjs +2 -4
  13. package/dist/commands/contract-infer.d.mts.map +1 -1
  14. package/dist/commands/contract-infer.mjs +2 -4
  15. package/dist/commands/db-init.d.mts.map +1 -1
  16. package/dist/commands/db-init.mjs +16 -13
  17. package/dist/commands/db-init.mjs.map +1 -1
  18. package/dist/commands/db-schema.d.mts.map +1 -1
  19. package/dist/commands/db-schema.mjs +6 -7
  20. package/dist/commands/db-schema.mjs.map +1 -1
  21. package/dist/commands/db-sign.d.mts.map +1 -1
  22. package/dist/commands/db-sign.mjs +9 -9
  23. package/dist/commands/db-sign.mjs.map +1 -1
  24. package/dist/commands/db-update.d.mts.map +1 -1
  25. package/dist/commands/db-update.mjs +15 -13
  26. package/dist/commands/db-update.mjs.map +1 -1
  27. package/dist/commands/db-verify.d.mts.map +1 -1
  28. package/dist/commands/db-verify.mjs +1 -321
  29. package/dist/commands/migration-apply.d.mts +28 -13
  30. package/dist/commands/migration-apply.d.mts.map +1 -1
  31. package/dist/commands/migration-apply.mjs +55 -151
  32. package/dist/commands/migration-apply.mjs.map +1 -1
  33. package/dist/commands/migration-new.d.mts +0 -1
  34. package/dist/commands/migration-new.d.mts.map +1 -1
  35. package/dist/commands/migration-new.mjs +34 -40
  36. package/dist/commands/migration-new.mjs.map +1 -1
  37. package/dist/commands/migration-plan.d.mts +33 -6
  38. package/dist/commands/migration-plan.d.mts.map +1 -1
  39. package/dist/commands/migration-plan.mjs +2 -348
  40. package/dist/commands/migration-ref.d.mts +1 -1
  41. package/dist/commands/migration-ref.d.mts.map +1 -1
  42. package/dist/commands/migration-ref.mjs +8 -12
  43. package/dist/commands/migration-ref.mjs.map +1 -1
  44. package/dist/commands/migration-show.d.mts +13 -7
  45. package/dist/commands/migration-show.d.mts.map +1 -1
  46. package/dist/commands/migration-show.mjs +35 -36
  47. package/dist/commands/migration-show.mjs.map +1 -1
  48. package/dist/commands/migration-status.d.mts +126 -5
  49. package/dist/commands/migration-status.d.mts.map +1 -1
  50. package/dist/commands/migration-status.mjs +2 -4
  51. package/dist/{config-loader-C25b63rJ.mjs → config-loader-B6sJjXTv.mjs} +3 -5
  52. package/dist/config-loader-B6sJjXTv.mjs.map +1 -0
  53. package/dist/config-loader.d.mts +0 -1
  54. package/dist/config-loader.d.mts.map +1 -1
  55. package/dist/config-loader.mjs +2 -3
  56. package/dist/contract-emit-9DBda5Ou.mjs +150 -0
  57. package/dist/contract-emit-9DBda5Ou.mjs.map +1 -0
  58. package/dist/contract-emit-B77TsJqf.mjs +327 -0
  59. package/dist/contract-emit-B77TsJqf.mjs.map +1 -0
  60. package/dist/{contract-enrichment-CAOELa-H.mjs → contract-enrichment-Dani0mMW.mjs} +4 -6
  61. package/dist/contract-enrichment-Dani0mMW.mjs.map +1 -0
  62. package/dist/{contract-infer-D9cC3rJm.mjs → contract-infer-BK9YFGEG.mjs} +13 -22
  63. package/dist/contract-infer-BK9YFGEG.mjs.map +1 -0
  64. package/dist/db-verify-C0y1PCO2.mjs +404 -0
  65. package/dist/db-verify-C0y1PCO2.mjs.map +1 -0
  66. package/dist/exports/config-types.mjs +1 -2
  67. package/dist/exports/control-api.d.mts +101 -586
  68. package/dist/exports/control-api.d.mts.map +1 -1
  69. package/dist/exports/control-api.mjs +4 -6
  70. package/dist/exports/index.d.mts.map +1 -1
  71. package/dist/exports/index.mjs +28 -30
  72. package/dist/exports/index.mjs.map +1 -1
  73. package/dist/exports/init-output.d.mts +2 -4
  74. package/dist/exports/init-output.d.mts.map +1 -1
  75. package/dist/exports/init-output.mjs +2 -3
  76. package/dist/extension-pack-inputs-C7xgE-vv.mjs +74 -0
  77. package/dist/extension-pack-inputs-C7xgE-vv.mjs.map +1 -0
  78. package/dist/{framework-components-Cr--XBKy.mjs → framework-components-ChqVUxR-.mjs} +3 -4
  79. package/dist/{framework-components-Cr--XBKy.mjs.map → framework-components-ChqVUxR-.mjs.map} +1 -1
  80. package/dist/global-flags-Icqpxk23.d.mts +12 -0
  81. package/dist/global-flags-Icqpxk23.d.mts.map +1 -0
  82. package/dist/helpers-eqdN8tH6.mjs +25 -0
  83. package/dist/helpers-eqdN8tH6.mjs.map +1 -0
  84. package/dist/{init-C5220SY9.mjs → init-CoDVPvQ4.mjs} +26 -35
  85. package/dist/init-CoDVPvQ4.mjs.map +1 -0
  86. package/dist/{inspect-live-schema-yrHAvG71.mjs → inspect-live-schema-CWYxGKlb.mjs} +10 -11
  87. package/dist/inspect-live-schema-CWYxGKlb.mjs.map +1 -0
  88. package/dist/migration-cli.d.mts +41 -12
  89. package/dist/migration-cli.d.mts.map +1 -1
  90. package/dist/migration-cli.mjs +309 -86
  91. package/dist/migration-cli.mjs.map +1 -1
  92. package/dist/{migration-command-scaffold-B3B09et6.mjs → migration-command-scaffold-B5dORFEv.mjs} +8 -9
  93. package/dist/migration-command-scaffold-B5dORFEv.mjs.map +1 -0
  94. package/dist/migration-plan-C6lVaHsO.mjs +554 -0
  95. package/dist/migration-plan-C6lVaHsO.mjs.map +1 -0
  96. package/dist/{migration-status-DUMiH8_G.mjs → migration-status-CZ-D5k7k.mjs} +272 -65
  97. package/dist/migration-status-CZ-D5k7k.mjs.map +1 -0
  98. package/dist/migrations-D_UJnpuW.mjs +216 -0
  99. package/dist/migrations-D_UJnpuW.mjs.map +1 -0
  100. package/dist/{output-BpcQrnnq.mjs → output-B16Kefzx.mjs} +9 -3
  101. package/dist/output-B16Kefzx.mjs.map +1 -0
  102. package/dist/{progress-adapter-DvQWB1nK.mjs → progress-adapter-DFfvZcYL.mjs} +2 -2
  103. package/dist/{progress-adapter-DvQWB1nK.mjs.map → progress-adapter-DFfvZcYL.mjs.map} +1 -1
  104. package/dist/result-handler-rmPVKIP2.mjs +25 -0
  105. package/dist/result-handler-rmPVKIP2.mjs.map +1 -0
  106. package/dist/rolldown-runtime-twds-ZHy.mjs +14 -0
  107. package/dist/{terminal-ui-C3ZLwQxK.mjs → terminal-ui-C_hFNbAn.mjs} +4 -28
  108. package/dist/terminal-ui-C_hFNbAn.mjs.map +1 -0
  109. package/dist/types-D7x-IFLO.d.mts +858 -0
  110. package/dist/types-D7x-IFLO.d.mts.map +1 -0
  111. package/dist/{verify-Bkycc-Tf.mjs → verify-CiwNWM9N.mjs} +3 -4
  112. package/dist/verify-CiwNWM9N.mjs.map +1 -0
  113. package/package.json +26 -24
  114. package/src/cli.ts +32 -6
  115. package/src/commands/contract-emit.ts +67 -163
  116. package/src/commands/contract-infer.ts +7 -20
  117. package/src/commands/db-init.ts +15 -3
  118. package/src/commands/db-update.ts +9 -4
  119. package/src/commands/db-verify.ts +47 -15
  120. package/src/commands/init/index.ts +1 -1
  121. package/src/commands/init/init.ts +2 -2
  122. package/src/commands/init/templates/code-templates.ts +12 -4
  123. package/src/commands/inspect-live-schema.ts +10 -5
  124. package/src/commands/migration-apply.ts +114 -212
  125. package/src/commands/migration-new.ts +42 -45
  126. package/src/commands/migration-plan.ts +212 -72
  127. package/src/commands/migration-ref.ts +8 -7
  128. package/src/commands/migration-show.ts +60 -41
  129. package/src/commands/migration-status.ts +483 -64
  130. package/src/config-path-validation.ts +0 -1
  131. package/src/control-api/client.ts +85 -5
  132. package/src/control-api/contract-enrichment.ts +6 -4
  133. package/src/control-api/operations/apply-aggregate.ts +290 -0
  134. package/src/control-api/operations/contract-emit.ts +198 -115
  135. package/src/control-api/operations/db-apply-aggregate.ts +397 -0
  136. package/src/control-api/operations/db-init.ts +51 -253
  137. package/src/control-api/operations/db-update.ts +66 -183
  138. package/src/control-api/operations/db-verify.ts +342 -0
  139. package/src/control-api/operations/migration-apply.ts +424 -131
  140. package/src/control-api/types.ts +280 -29
  141. package/src/exports/control-api.ts +15 -3
  142. package/src/load-ts-contract.ts +28 -26
  143. package/src/migration-cli.ts +445 -122
  144. package/src/utils/cli-errors.ts +49 -2
  145. package/src/utils/combine-schema-results.ts +84 -0
  146. package/src/utils/command-helpers.ts +69 -25
  147. package/src/utils/contract-space-aggregate-loader.ts +204 -0
  148. package/src/utils/contract-space-extension-migrations-pass.ts +120 -0
  149. package/src/utils/contract-space-migrate-pass.ts +156 -0
  150. package/src/utils/emit-queue.ts +26 -0
  151. package/src/utils/extension-pack-inputs.ts +170 -0
  152. package/src/utils/formatters/graph-migration-mapper.ts +7 -3
  153. package/src/utils/formatters/migrations.ts +197 -61
  154. package/src/utils/publish-contract-artifact-pair.ts +134 -0
  155. package/dist/cli-errors-BFYgBH3L.d.mts +0 -4
  156. package/dist/cli-errors-Cd79vmTH.mjs +0 -5
  157. package/dist/client-CrsnY58k.mjs +0 -997
  158. package/dist/client-CrsnY58k.mjs.map +0 -1
  159. package/dist/commands/db-verify.mjs.map +0 -1
  160. package/dist/commands/migration-plan.mjs.map +0 -1
  161. package/dist/config-loader-C25b63rJ.mjs.map +0 -1
  162. package/dist/contract-emit--feXyNd7.mjs +0 -4
  163. package/dist/contract-emit-NJ01hiiv.mjs +0 -195
  164. package/dist/contract-emit-NJ01hiiv.mjs.map +0 -1
  165. package/dist/contract-emit-V5SSitUT.mjs +0 -122
  166. package/dist/contract-emit-V5SSitUT.mjs.map +0 -1
  167. package/dist/contract-enrichment-CAOELa-H.mjs.map +0 -1
  168. package/dist/contract-infer-D9cC3rJm.mjs.map +0 -1
  169. package/dist/extract-operation-statements-DsFfxXVZ.mjs +0 -13
  170. package/dist/extract-operation-statements-DsFfxXVZ.mjs.map +0 -1
  171. package/dist/extract-sql-ddl-D9UbZDyz.mjs +0 -26
  172. package/dist/extract-sql-ddl-D9UbZDyz.mjs.map +0 -1
  173. package/dist/init-C5220SY9.mjs.map +0 -1
  174. package/dist/inspect-live-schema-yrHAvG71.mjs.map +0 -1
  175. package/dist/migration-command-scaffold-B3B09et6.mjs.map +0 -1
  176. package/dist/migration-status-DUMiH8_G.mjs.map +0 -1
  177. package/dist/migrations-Bo5WtTla.mjs +0 -153
  178. package/dist/migrations-Bo5WtTla.mjs.map +0 -1
  179. package/dist/output-BpcQrnnq.mjs.map +0 -1
  180. package/dist/result-handler-Ba3zWQsI.mjs.map +0 -1
  181. package/dist/terminal-ui-C3ZLwQxK.mjs.map +0 -1
  182. package/dist/validate-contract-deps-B_Cs29TL.mjs +0 -37
  183. package/dist/validate-contract-deps-B_Cs29TL.mjs.map +0 -1
  184. package/dist/verify-Bkycc-Tf.mjs.map +0 -1
  185. package/src/control-api/operations/extract-operation-statements.ts +0 -14
  186. package/src/control-api/operations/extract-sql-ddl.ts +0 -47
@@ -1,18 +1,26 @@
1
- import type { MigrationPlanOperation } from '@prisma-next/framework-components/control';
1
+ import {
2
+ createControlStack,
3
+ type MigrationPlanOperation,
4
+ } from '@prisma-next/framework-components/control';
5
+ import {
6
+ type ContractMarkerRecordLike,
7
+ graphWalkStrategy,
8
+ } from '@prisma-next/migration-tools/aggregate';
2
9
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
10
+ import {
11
+ errorNoInvariantPath,
12
+ errorUnknownInvariant,
13
+ MigrationToolsError,
14
+ } from '@prisma-next/migration-tools/errors';
15
+ import type { MigrationEdge, MigrationGraph } from '@prisma-next/migration-tools/graph';
3
16
  import {
4
17
  findPath,
5
18
  findPathWithDecision,
6
19
  findReachableLeaves,
7
- } from '@prisma-next/migration-tools/dag';
8
- import type { Refs } from '@prisma-next/migration-tools/refs';
20
+ } from '@prisma-next/migration-tools/migration-graph';
21
+ import type { OnDiskMigrationPackage } from '@prisma-next/migration-tools/package';
22
+ import type { RefEntry, Refs } from '@prisma-next/migration-tools/refs';
9
23
  import { readRefs, resolveRef } from '@prisma-next/migration-tools/refs';
10
- import type {
11
- MigrationBundle,
12
- MigrationChainEntry,
13
- MigrationGraph,
14
- } from '@prisma-next/migration-tools/types';
15
- import { MigrationToolsError } from '@prisma-next/migration-tools/types';
16
24
  import { ifDefined } from '@prisma-next/utils/defined';
17
25
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
18
26
  import { cyan, dim, magenta, yellow } from 'colorette';
@@ -20,17 +28,28 @@ import { Command } from 'commander';
20
28
 
21
29
  import { loadConfig } from '../config-loader';
22
30
  import { createControlClient } from '../control-api/client';
23
- import { type CliStructuredError, errorRuntime, errorUnexpected } from '../utils/cli-errors';
31
+ import {
32
+ type CliStructuredError,
33
+ errorRuntime,
34
+ errorUnexpected,
35
+ mapMigrationToolsError,
36
+ } from '../utils/cli-errors';
24
37
  import {
25
38
  addGlobalOptions,
26
- loadAllBundles,
39
+ collectDeclaredInvariants,
40
+ loadMigrationPackages,
27
41
  maskConnectionUrl,
28
42
  readContractEnvelope,
29
43
  resolveMigrationPaths,
30
44
  setCommandDescriptions,
31
45
  setCommandExamples,
32
46
  toPathDecisionResult,
47
+ toStructuralEdge,
33
48
  } from '../utils/command-helpers';
49
+ import {
50
+ type BuildAggregateInputs,
51
+ buildContractSpaceAggregate,
52
+ } from '../utils/contract-space-aggregate-loader';
34
53
  import {
35
54
  type EdgeStatus,
36
55
  type EdgeStatusKind,
@@ -61,13 +80,60 @@ export interface MigrationStatusEntry {
61
80
  readonly dirName: string;
62
81
  readonly from: string;
63
82
  readonly to: string;
64
- readonly migrationId: string;
83
+ readonly migrationHash: string;
65
84
  readonly operationCount: number;
66
85
  readonly operationSummary: string;
67
86
  readonly hasDestructive: boolean;
68
87
  readonly status: EdgeStatusKind | 'unknown';
69
88
  }
70
89
 
90
+ /**
91
+ * Per-space status row in the aggregate-shaped status output.
92
+ *
93
+ * Surfaces, for each contract space:
94
+ *
95
+ * - `headHash`: the on-disk head ref's hash (where the space is going).
96
+ * - `markerHash`: the live marker hash for the space, or null if no
97
+ * marker has been written yet (greenfield, or pre-`migration apply`).
98
+ * - `pendingCount`: number of migration edges between marker and head.
99
+ * Computed via {@link graphWalkStrategy}; 0 means the space is
100
+ * already at head.
101
+ * - `status`: convenience tag the formatter uses to pick a glyph.
102
+ * `'never-planned'` is reserved for spaces with non-empty head but
103
+ * no on-disk migrations — which shouldn't happen if the loader's
104
+ * integrity check passes.
105
+ *
106
+ * Online-only fields (`markerHash`, `status`) are absent when the
107
+ * command runs without a database connection.
108
+ */
109
+ export interface MigrationStatusSpaceEntry {
110
+ readonly spaceId: string;
111
+ readonly kind: 'app' | 'extension';
112
+ readonly headHash: string;
113
+ readonly markerHash?: string | null;
114
+ readonly pendingCount?: number;
115
+ readonly status?: 'up-to-date' | 'pending' | 'no-marker' | 'never-planned' | 'unreachable';
116
+ }
117
+
118
+ /**
119
+ * Sum per-space `pendingCount` into a cross-space total, but only when
120
+ * every loaded space reports a defined `pendingCount`. Returns
121
+ * `undefined` if any space is on the marker-unknown / offline path
122
+ * (where `pendingCount` is intentionally absent), so JSON consumers can
123
+ * distinguish "no pending" from "unknown".
124
+ */
125
+ export function computeTotalPendingAcrossSpaces(
126
+ spaces: readonly MigrationStatusSpaceEntry[],
127
+ ): number | undefined {
128
+ if (spaces.length === 0) return undefined;
129
+ let total = 0;
130
+ for (const s of spaces) {
131
+ if (s.pendingCount === undefined) return undefined;
132
+ total += s.pendingCount;
133
+ }
134
+ return total;
135
+ }
136
+
71
137
  export type { StatusDiagnostic, StatusRef } from '../utils/migration-types';
72
138
 
73
139
  export interface MigrationStatusResult {
@@ -78,23 +144,55 @@ export interface MigrationStatusResult {
78
144
  readonly targetHash: string;
79
145
  readonly contractHash: string;
80
146
  readonly refs?: readonly StatusRef[];
147
+ /** Required invariants from the active ref, sorted ascending. Always present (`[]` when no `--ref` or the ref declares none) — knowable offline. */
148
+ readonly requiredInvariants: readonly string[];
149
+ /**
150
+ * Invariants the marker has applied at least once, intersected with
151
+ * `requiredInvariants` for display relevance. JSON consumers see only the
152
+ * subset overlapping the active ref's required set — the full unfiltered
153
+ * marker invariant list lives on `marker.invariants` (control plane) and
154
+ * is not surfaced here. Present only in `mode === 'online'`; absent when
155
+ * offline (the marker is unknown, not empty).
156
+ */
157
+ readonly appliedInvariants?: readonly string[];
158
+ /** required − applied. Present only in `mode === 'online'`; absent when offline. */
159
+ readonly missingInvariants?: readonly string[];
81
160
  readonly pathDecision?: {
82
161
  readonly fromHash: string;
83
162
  readonly toHash: string;
84
163
  readonly alternativeCount: number;
85
164
  readonly tieBreakReasons: readonly string[];
86
165
  readonly refName?: string;
166
+ readonly requiredInvariants: readonly string[];
167
+ readonly satisfiedInvariants: readonly string[];
87
168
  readonly selectedPath: readonly {
88
169
  readonly dirName: string;
89
- readonly migrationId: string;
170
+ readonly migrationHash: string;
90
171
  readonly from: string;
91
172
  readonly to: string;
173
+ readonly invariants: readonly string[];
92
174
  }[];
93
175
  };
94
176
  readonly summary: string;
95
177
  readonly diagnostics: readonly StatusDiagnostic[];
178
+ /**
179
+ * Aggregate enumeration of every on-disk contract space (app +
180
+ * extensions), in canonical schedule order (extensions
181
+ * alphabetically, then app). Present whenever the aggregate loader
182
+ * succeeded; absent in early-error returns (e.g. unreadable
183
+ * migrations directory) where the existing diagnostics already
184
+ * surface the failure.
185
+ *
186
+ * The legacy top-level fields (`migrations`, `markerHash`,
187
+ * `targetHash`, `pathDecision`, …) describe the **app member**
188
+ * specifically — back-compat with single-space callers. Per-space
189
+ * detail for extension members lives only on this list.
190
+ */
191
+ readonly spaces?: readonly MigrationStatusSpaceEntry[];
192
+ /** Cross-space pending-migration total (sum of `spaces[].pendingCount`). Present when `spaces` is. */
193
+ readonly totalPendingAcrossSpaces?: number;
96
194
  readonly graph?: MigrationGraph;
97
- readonly bundles?: readonly MigrationBundle[];
195
+ readonly bundles?: readonly OnDiskMigrationPackage[];
98
196
  readonly edgeStatuses?: readonly EdgeStatus[];
99
197
  readonly activeRefHash?: string;
100
198
  readonly activeRefName?: string;
@@ -154,7 +252,7 @@ export function deriveEdgeStatuses(
154
252
  ): EdgeStatus[] {
155
253
  if (mode === 'offline') return [];
156
254
 
157
- const edgeKey = (e: MigrationChainEntry) => `${e.from}\0${e.to}`;
255
+ const edgeKey = (e: MigrationEdge) => `${e.from}\0${e.to}`;
158
256
 
159
257
  // No marker = empty DB — treat root as the marker (nothing applied, everything pending)
160
258
  const effectiveMarker = markerHash ?? EMPTY_CONTRACT_HASH;
@@ -224,8 +322,8 @@ export function deriveEdgeStatuses(
224
322
  * @param markerHash — the marker hash from the database, or undefined if no marker row / offline
225
323
  */
226
324
  function buildMigrationEntries(
227
- chain: readonly MigrationChainEntry[],
228
- packages: readonly MigrationBundle[],
325
+ chain: readonly MigrationEdge[],
326
+ packages: readonly OnDiskMigrationPackage[],
229
327
  mode: 'online' | 'offline',
230
328
  markerHash: string | undefined,
231
329
  edgeStatuses?: readonly EdgeStatus[],
@@ -261,7 +359,7 @@ function buildMigrationEntries(
261
359
  dirName: migration.dirName,
262
360
  from: migration.from,
263
361
  to: migration.to,
264
- migrationId: migration.migrationId,
362
+ migrationHash: migration.migrationHash,
265
363
  operationCount: ops.length,
266
364
  operationSummary: summary,
267
365
  hasDestructive,
@@ -294,7 +392,7 @@ function resolveDisplayChain(
294
392
  graph: MigrationGraph,
295
393
  targetHash: string,
296
394
  markerHash: string | undefined,
297
- ): readonly MigrationChainEntry[] | null {
395
+ ): readonly MigrationEdge[] | null {
298
396
  if (markerHash === undefined) {
299
397
  return findPath(graph, EMPTY_CONTRACT_HASH, targetHash);
300
398
  }
@@ -339,34 +437,143 @@ function determineLimit(opts: MigrationStatusOptions) {
339
437
  return parsed;
340
438
  }
341
439
 
440
+ /**
441
+ * Build the aggregate enumeration of contract spaces for the status
442
+ * output. Loads the aggregate from disk (lossy on failure — extension
443
+ * spaces are simply omitted, the existing single-space app behaviour
444
+ * keeps working), reads per-space marker rows when online, and uses
445
+ * {@link graphWalkStrategy} to compute each space's pending count.
446
+ *
447
+ * Sub-spec § `migration status` semantics — the aggregate-walking
448
+ * version reports per-space marker + pending state alongside the
449
+ * cross-space totals.
450
+ */
451
+ export async function loadAggregateStatusSpaces(args: {
452
+ readonly targetId: string;
453
+ readonly migrationsDir: string;
454
+ readonly appContractRaw: unknown;
455
+ readonly extensionPacks: BuildAggregateInputs<string, string>['extensionPacks'];
456
+ readonly validateContract: BuildAggregateInputs<string, string>['validateContract'];
457
+ readonly markersBySpace: ReadonlyMap<string, ContractMarkerRecordLike> | null;
458
+ }): Promise<readonly MigrationStatusSpaceEntry[]> {
459
+ const loadInputs: BuildAggregateInputs<string, string> = {
460
+ targetId: args.targetId,
461
+ migrationsDir: args.migrationsDir,
462
+ appContract: args.validateContract(args.appContractRaw),
463
+ extensionPacks: args.extensionPacks,
464
+ validateContract: args.validateContract,
465
+ };
466
+
467
+ const loaded = await buildContractSpaceAggregate(loadInputs);
468
+ if (!loaded.ok) {
469
+ // Loader failure (drift, layout violation, etc.) — surfacing it
470
+ // as a status diagnostic would duplicate `migration plan`'s job.
471
+ // The single-space app pipeline still runs; extensions are simply
472
+ // not enumerated.
473
+ return [];
474
+ }
475
+ const aggregate = loaded.value;
476
+
477
+ const orderedMembers = [...aggregate.extensions, aggregate.app];
478
+ const rows: MigrationStatusSpaceEntry[] = [];
479
+ for (const member of orderedMembers) {
480
+ const liveMarker = args.markersBySpace?.get(member.spaceId) ?? null;
481
+ const isApp = member.spaceId === aggregate.app.spaceId;
482
+
483
+ if (member.migrations.graph.nodes.size === 0) {
484
+ rows.push({
485
+ spaceId: member.spaceId,
486
+ kind: isApp ? 'app' : 'extension',
487
+ headHash: member.headRef.hash,
488
+ ...(args.markersBySpace !== null
489
+ ? {
490
+ markerHash: liveMarker?.storageHash ?? null,
491
+ status: member.headRef.hash === EMPTY_CONTRACT_HASH ? 'up-to-date' : 'never-planned',
492
+ pendingCount: 0,
493
+ }
494
+ : {}),
495
+ });
496
+ continue;
497
+ }
498
+
499
+ if (args.markersBySpace === null) {
500
+ rows.push({
501
+ spaceId: member.spaceId,
502
+ kind: isApp ? 'app' : 'extension',
503
+ headHash: member.headRef.hash,
504
+ });
505
+ continue;
506
+ }
507
+
508
+ const walked = graphWalkStrategy({
509
+ aggregateTargetId: aggregate.targetId,
510
+ member,
511
+ currentMarker: liveMarker,
512
+ });
513
+ let pendingCount = 0;
514
+ let status: MigrationStatusSpaceEntry['status'];
515
+ if (walked.kind === 'ok') {
516
+ pendingCount = walked.result.plan.operations.length;
517
+ if (liveMarker === null) {
518
+ status = pendingCount === 0 ? 'no-marker' : 'pending';
519
+ } else {
520
+ status = pendingCount === 0 ? 'up-to-date' : 'pending';
521
+ }
522
+ } else {
523
+ status = 'unreachable';
524
+ }
525
+
526
+ rows.push({
527
+ spaceId: member.spaceId,
528
+ kind: isApp ? 'app' : 'extension',
529
+ headHash: member.headRef.hash,
530
+ markerHash: liveMarker?.storageHash ?? null,
531
+ pendingCount,
532
+ ...(status ? { status } : {}),
533
+ });
534
+ }
535
+ return rows;
536
+ }
537
+
538
+ /**
539
+ * Read the raw contract.json bytes from disk for the aggregate
540
+ * loader. Returns `null` if the file is missing or unparseable —
541
+ * the existing `readContractEnvelope` path will report the same
542
+ * problem via a status diagnostic, no need to double-surface.
543
+ */
544
+ async function loadContractRawSafely(config: {
545
+ contract?: { output?: string };
546
+ }): Promise<unknown | null> {
547
+ try {
548
+ const path = (await import('../utils/command-helpers')).resolveContractPath(config);
549
+ const raw = await (await import('node:fs/promises')).readFile(path, 'utf-8');
550
+ return JSON.parse(raw) as unknown;
551
+ } catch {
552
+ return null;
553
+ }
554
+ }
555
+
342
556
  async function executeMigrationStatusCommand(
343
557
  options: MigrationStatusOptions,
344
558
  flags: GlobalFlags,
345
559
  ui: TerminalUI,
346
560
  ): Promise<Result<MigrationStatusResult, CliStructuredError>> {
347
561
  const config = await loadConfig(options.config);
348
- const { configPath, migrationsDir, migrationsRelative, refsDir } = resolveMigrationPaths(
349
- options.config,
350
- config,
351
- );
562
+ const { configPath, appMigrationsDir, appMigrationsRelative, migrationsDir, refsDir } =
563
+ resolveMigrationPaths(options.config, config);
352
564
 
353
565
  const dbConnection = options.db ?? config.db?.connection;
354
566
  const hasDriver = !!config.driver;
355
567
 
356
568
  let activeRefName: string | undefined;
357
569
  let activeRefHash: string | undefined;
570
+ let activeRefEntry: RefEntry | undefined;
358
571
  let allRefs: Refs = {};
359
572
  try {
360
573
  allRefs = await readRefs(refsDir);
361
574
  } catch (error) {
362
575
  if (MigrationToolsError.is(error)) {
363
- return notOk(
364
- errorRuntime(error.message, {
365
- why: error.why,
366
- fix: error.fix,
367
- meta: { code: error.code },
368
- }),
369
- );
576
+ return notOk(mapMigrationToolsError(error));
370
577
  }
371
578
  throw error;
372
579
  }
@@ -374,21 +581,18 @@ async function executeMigrationStatusCommand(
374
581
  if (options.ref) {
375
582
  activeRefName = options.ref;
376
583
  try {
377
- activeRefHash = resolveRef(allRefs, activeRefName).hash;
584
+ activeRefEntry = resolveRef(allRefs, activeRefName);
585
+ activeRefHash = activeRefEntry.hash;
378
586
  } catch (error) {
379
587
  if (MigrationToolsError.is(error)) {
380
- return notOk(
381
- errorRuntime(error.message, {
382
- why: error.why,
383
- fix: error.fix,
384
- meta: { code: error.code },
385
- }),
386
- );
588
+ return notOk(mapMigrationToolsError(error));
387
589
  }
388
590
  throw error;
389
591
  }
390
592
  }
391
593
 
594
+ const requiredInvariants: readonly string[] = [...(activeRefEntry?.invariants ?? [])].sort();
595
+
392
596
  const statusRefs: StatusRef[] = Object.entries(allRefs).map(([name, entry]) => ({
393
597
  name,
394
598
  hash: entry.hash,
@@ -398,7 +602,7 @@ async function executeMigrationStatusCommand(
398
602
  if (!flags.json && !flags.quiet) {
399
603
  const details: Array<{ label: string; value: string }> = [
400
604
  { label: 'config', value: configPath },
401
- { label: 'migrations', value: migrationsRelative },
605
+ { label: 'migrations', value: appMigrationsRelative },
402
606
  ];
403
607
  if (dbConnection && hasDriver) {
404
608
  details.push({ label: 'database', value: maskConnectionUrl(String(dbConnection)) });
@@ -406,6 +610,12 @@ async function executeMigrationStatusCommand(
406
610
  if (activeRefName) {
407
611
  details.push({ label: 'ref', value: activeRefName });
408
612
  }
613
+ if (activeRefEntry && activeRefEntry.invariants.length > 0) {
614
+ details.push({
615
+ label: 'required',
616
+ value: formatInvariantList(activeRefEntry.invariants),
617
+ });
618
+ }
409
619
  const header = formatStyledHeader({
410
620
  command: 'migration status',
411
621
  description: 'Show migration history and applied status',
@@ -429,15 +639,13 @@ async function executeMigrationStatusCommand(
429
639
  });
430
640
  }
431
641
 
432
- let bundles: readonly MigrationBundle[];
642
+ let bundles: readonly OnDiskMigrationPackage[];
433
643
  let graph: MigrationGraph;
434
644
  try {
435
- ({ bundles, graph } = await loadAllBundles(migrationsDir));
645
+ ({ bundles, graph } = await loadMigrationPackages(appMigrationsDir));
436
646
  } catch (error) {
437
647
  if (MigrationToolsError.is(error)) {
438
- return notOk(
439
- errorRuntime(error.message, { why: error.why, fix: error.fix, meta: { code: error.code } }),
440
- );
648
+ return notOk(mapMigrationToolsError(error));
441
649
  }
442
650
  return notOk(
443
651
  errorUnexpected(error instanceof Error ? error.message : String(error), {
@@ -465,6 +673,7 @@ async function executeMigrationStatusCommand(
465
673
  contractHash,
466
674
  summary: 'No migrations found',
467
675
  diagnostics,
676
+ requiredInvariants,
468
677
  });
469
678
  }
470
679
 
@@ -492,7 +701,9 @@ async function executeMigrationStatusCommand(
492
701
  }
493
702
 
494
703
  let markerHash: string | undefined;
704
+ let markerInvariants: readonly string[] = [];
495
705
  let mode: 'online' | 'offline' = 'offline';
706
+ let allMarkers: ReadonlyMap<string, ContractMarkerRecordLike> | null = null;
496
707
 
497
708
  if (dbConnection && hasDriver) {
498
709
  const client = createControlClient({
@@ -504,8 +715,25 @@ async function executeMigrationStatusCommand(
504
715
  });
505
716
  try {
506
717
  await client.connect(dbConnection);
507
- markerHash = (await client.readMarker())?.storageHash;
718
+ const marker = await client.readMarker();
719
+ markerHash = marker?.storageHash;
720
+ markerInvariants = marker?.invariants ?? [];
508
721
  mode = 'online';
722
+ // Read every space's marker so the aggregate enumeration can
723
+ // surface per-space marker state. `readAllMarkers` mirrors what
724
+ // `db init` / `db update` already use to drive the multi-space
725
+ // planner; here it powers the aggregate status output.
726
+ try {
727
+ allMarkers = await client.readAllMarkers();
728
+ } catch {
729
+ // Older family instances may not implement `readAllMarkers`.
730
+ // Per-space enumeration falls back to "marker unknown" rather
731
+ // than failing the whole status command — leaving
732
+ // `allMarkers` as `null` signals "unknown" to the aggregate
733
+ // loader (an empty `Map` would instead mean "every space has
734
+ // no marker", which is a different condition).
735
+ allMarkers = null;
736
+ }
509
737
  } catch {
510
738
  if (!flags.json && !flags.quiet) {
511
739
  ui.warn('Could not connect to database — showing offline status');
@@ -515,6 +743,63 @@ async function executeMigrationStatusCommand(
515
743
  }
516
744
  }
517
745
 
746
+ // Build the aggregate enumeration of contract spaces. Lossy on
747
+ // failure (extensions are simply omitted) so the existing
748
+ // single-space app pipeline below still runs even if extensions
749
+ // can't be loaded — a strict failure here would degrade the
750
+ // load-bearing app-space output for unrelated reasons.
751
+ const contractRawForAggregate = await loadContractRawSafely(config);
752
+ let aggregateSpaces: readonly MigrationStatusSpaceEntry[] = [];
753
+ if (contractRawForAggregate !== null) {
754
+ // The aggregate loader needs a typed-Contract producer. Build a
755
+ // real control stack so `validateContract` runs against a fully
756
+ // composed family instance — descriptors that read stack members
757
+ // during construction (e.g. codec lookups) get a consistent view.
758
+ const stack = createControlStack(config);
759
+ const familyInstance = config.family.create(stack);
760
+ try {
761
+ aggregateSpaces = await loadAggregateStatusSpaces({
762
+ targetId: config.target.targetId,
763
+ migrationsDir,
764
+ appContractRaw: contractRawForAggregate,
765
+ extensionPacks: config.extensionPacks ?? [],
766
+ validateContract: (json: unknown) => familyInstance.validateContract(json),
767
+ markersBySpace: allMarkers,
768
+ });
769
+ } catch {
770
+ // Loader failure short-circuits silently — the existing
771
+ // single-space app pipeline below still runs.
772
+ aggregateSpaces = [];
773
+ }
774
+ }
775
+ const totalPendingAcrossSpaces = computeTotalPendingAcrossSpaces(aggregateSpaces);
776
+
777
+ // Pre-check unknown invariants. Online: union the graph's declared
778
+ // invariants with the marker's recorded set so a retired-but-applied
779
+ // invariant doesn't surface as MIGRATION.UNKNOWN_INVARIANT — apply would
780
+ // route fine because marker subtraction empties `effectiveRequired`.
781
+ // Offline: keep the check graph-strict (the marker is unknown, and a
782
+ // missing declarer is the dominant signal we can offer).
783
+ if (activeRefEntry && activeRefEntry.invariants.length > 0) {
784
+ const declared = collectDeclaredInvariants(graph);
785
+ const known = new Set<string>(declared);
786
+ if (mode === 'online') {
787
+ for (const id of markerInvariants) known.add(id);
788
+ }
789
+ const unknown = activeRefEntry.invariants.filter((id) => !known.has(id));
790
+ if (unknown.length > 0) {
791
+ return notOk(
792
+ mapMigrationToolsError(
793
+ errorUnknownInvariant({
794
+ ...ifDefined('refName', activeRefName),
795
+ unknown,
796
+ declared: [...declared].sort(),
797
+ }),
798
+ ),
799
+ );
800
+ }
801
+ }
802
+
518
803
  // Marker exists but is not in the migration graph and doesn't match the
519
804
  // contract hash. The DB is at an unknown state relative to the graph.
520
805
  // Bail out early with a clear diagnostic instead of rendering a confusing
@@ -560,6 +845,7 @@ async function executeMigrationStatusCommand(
560
845
  summary: `${bundles.length} migration(s) on disk`,
561
846
  diagnostics,
562
847
  markerHash,
848
+ requiredInvariants,
563
849
  ...(statusRefs.length > 0 ? { refs: statusRefs } : {}),
564
850
  });
565
851
  }
@@ -600,6 +886,7 @@ async function executeMigrationStatusCommand(
600
886
  summary: `${bundles.length} migration(s) on disk`,
601
887
  diagnostics,
602
888
  ...ifDefined('markerHash', markerHash),
889
+ requiredInvariants,
603
890
  ...(statusRefs.length > 0 ? { refs: statusRefs } : {}),
604
891
  graph,
605
892
  bundles,
@@ -624,14 +911,39 @@ async function executeMigrationStatusCommand(
624
911
  const pendingCount = edgeStatuses.filter((e) => e.status === 'pending').length;
625
912
  const appliedCount = edgeStatuses.filter((e) => e.status === 'applied').length;
626
913
 
914
+ let appliedInvariants: readonly string[] | undefined;
915
+ let missingInvariants: readonly string[] | undefined;
916
+ let effectiveRequired = new Set<string>();
917
+ if (mode === 'online') {
918
+ // Mirrors `migration-apply.ts`: compute `effectiveRequired = required −
919
+ // marker.invariants` directly, then derive the display fields from it.
920
+ // `appliedInvariants` is the intersection (`required ∩ marker`), which
921
+ // is what JSON consumers see for the active ref; the unfiltered set
922
+ // lives on `marker.invariants`.
923
+ const markerSet = new Set(markerInvariants);
924
+ effectiveRequired = new Set(requiredInvariants.filter((id) => !markerSet.has(id)));
925
+ appliedInvariants = requiredInvariants.filter((id) => markerSet.has(id));
926
+ missingInvariants = [...effectiveRequired].sort();
927
+ }
928
+
929
+ // The marker can match the structural target while still missing required
930
+ // invariants — for example, a self-edge that provides X, applied via a ref
931
+ // declaring X. `pendingCount` (structural) says zero in that case but
932
+ // `effectiveRequired` is non-empty, so up-to-date messaging would mislead.
933
+ const hasInvariantWork = effectiveRequired.size > 0;
934
+ const missingList = [...effectiveRequired].sort().join(', ');
935
+
627
936
  let summary: string;
628
937
  if (mode === 'online') {
629
938
  if (markerHash !== undefined && !graph.nodes.has(markerHash) && markerHash === contractHash) {
630
939
  summary = `${bundles.length} migration(s) on disk`;
631
940
  } else if (activeRefHash && markerHash !== undefined) {
632
- summary = summarizeRefDistance(graph, markerHash, activeRefHash, activeRefName!);
633
- } else if (pendingCount === 0) {
941
+ const distance = summarizeRefDistance(graph, markerHash, activeRefHash, activeRefName!);
942
+ summary = hasInvariantWork ? `${distance} missing invariant(s): ${missingList}` : distance;
943
+ } else if (pendingCount === 0 && !hasInvariantWork) {
634
944
  summary = `Database is up to date (${appliedCount} migration${appliedCount !== 1 ? 's' : ''} applied)`;
945
+ } else if (pendingCount === 0 && hasInvariantWork) {
946
+ summary = `Missing invariant(s): ${missingList} — run 'prisma-next migration apply --ref ${activeRefName ?? '<ref>'}' to apply`;
635
947
  } else if (markerHash === undefined) {
636
948
  summary = `${pendingCount} pending migration(s) — database has no marker`;
637
949
  } else {
@@ -641,6 +953,37 @@ async function executeMigrationStatusCommand(
641
953
  summary = `${entries.length} migration(s) on disk`;
642
954
  }
643
955
 
956
+ let pathDecision: MigrationStatusResult['pathDecision'];
957
+ let routingUnreachable = false;
958
+ if (mode === 'online') {
959
+ const originHash = markerHash ?? EMPTY_CONTRACT_HASH;
960
+ const outcome = findPathWithDecision(graph, originHash, targetHash, {
961
+ ...ifDefined('refName', activeRefName),
962
+ required: effectiveRequired,
963
+ });
964
+ if (outcome.kind === 'ok') {
965
+ pathDecision = toPathDecisionResult(outcome.decision);
966
+ } else if (outcome.kind === 'unsatisfiable') {
967
+ return notOk(
968
+ mapMigrationToolsError(
969
+ errorNoInvariantPath({
970
+ ...ifDefined('refName', activeRefName),
971
+ required: [...effectiveRequired].sort(),
972
+ missing: outcome.missing,
973
+ structuralPath: outcome.structuralPath.map(toStructuralEdge),
974
+ }),
975
+ ),
976
+ );
977
+ } else {
978
+ // outcome.kind === 'unreachable' — origin (marker) has no structural
979
+ // path to the active target. `pendingCount` and `hasInvariantWork`
980
+ // both report zero in this case, but emitting MIGRATION.UP_TO_DATE
981
+ // would be wrong: the database simply cannot reach the requested
982
+ // ref/contract from its current state. Suppress UP_TO_DATE below.
983
+ routingUnreachable = true;
984
+ }
985
+ }
986
+
644
987
  if (mode === 'online') {
645
988
  if (markerHash !== undefined && !graph.nodes.has(markerHash) && markerHash === contractHash) {
646
989
  diagnostics.push({
@@ -657,7 +1000,16 @@ async function executeMigrationStatusCommand(
657
1000
  message: `${pendingCount} migration(s) pending`,
658
1001
  hints: ["Run 'prisma-next migration apply' to apply pending migrations"],
659
1002
  });
660
- } else {
1003
+ } else if (hasInvariantWork) {
1004
+ diagnostics.push({
1005
+ code: 'MIGRATION.INVARIANTS_PENDING',
1006
+ severity: 'info',
1007
+ message: `Missing required invariant(s): ${missingList}`,
1008
+ hints: [
1009
+ `Run 'prisma-next migration apply --ref ${activeRefName ?? '<ref>'}' to apply a path that covers the required invariants`,
1010
+ ],
1011
+ });
1012
+ } else if (!routingUnreachable) {
661
1013
  diagnostics.push({
662
1014
  code: 'MIGRATION.UP_TO_DATE',
663
1015
  severity: 'info',
@@ -667,14 +1019,6 @@ async function executeMigrationStatusCommand(
667
1019
  }
668
1020
  }
669
1021
 
670
- let pathDecision: MigrationStatusResult['pathDecision'];
671
- if (mode === 'online' && markerHash !== undefined) {
672
- const decision = findPathWithDecision(graph, markerHash, targetHash, activeRefName);
673
- if (decision) {
674
- pathDecision = toPathDecisionResult(decision);
675
- }
676
- }
677
-
678
1022
  const result: MigrationStatusResult = {
679
1023
  ok: true,
680
1024
  mode,
@@ -684,6 +1028,9 @@ async function executeMigrationStatusCommand(
684
1028
  summary,
685
1029
  diagnostics,
686
1030
  ...ifDefined('markerHash', markerHash),
1031
+ requiredInvariants,
1032
+ ...ifDefined('appliedInvariants', appliedInvariants),
1033
+ ...ifDefined('missingInvariants', missingInvariants),
687
1034
  ...(statusRefs.length > 0 ? { refs: statusRefs } : {}),
688
1035
  ...ifDefined('pathDecision', pathDecision),
689
1036
  graph,
@@ -691,6 +1038,8 @@ async function executeMigrationStatusCommand(
691
1038
  edgeStatuses,
692
1039
  ...ifDefined('activeRefHash', activeRefHash),
693
1040
  ...ifDefined('activeRefName', activeRefName),
1041
+ spaces: aggregateSpaces,
1042
+ ...ifDefined('totalPendingAcrossSpaces', totalPendingAcrossSpaces),
694
1043
  };
695
1044
  return ok(result);
696
1045
  }
@@ -724,13 +1073,19 @@ export function createMigrationStatusCommand(): Command {
724
1073
 
725
1074
  const exitCode = handleResult(result, flags, ui, (statusResult) => {
726
1075
  if (flags.json) {
1076
+ // Strip non-JSON-shape fields before emitting. These belong to
1077
+ // the in-memory result so the human renderer can avoid
1078
+ // recomputing them, but they would either bloat the wire format
1079
+ // (graph, bundles, edgeStatuses) or expose internals
1080
+ // (activeRefHash, activeRefName, diverged) that consumers should
1081
+ // read off `pathDecision` / `refs` instead.
727
1082
  const {
728
- graph: _g,
729
- bundles: _b,
730
- edgeStatuses: _es,
731
- activeRefHash: _arh,
732
- activeRefName: _arn,
733
- diverged: _d,
1083
+ graph: _graph,
1084
+ bundles: _bundles,
1085
+ edgeStatuses: _edgeStatuses,
1086
+ activeRefHash: _activeRefHash,
1087
+ activeRefName: _activeRefName,
1088
+ diverged: _diverged,
734
1089
  ...jsonResult
735
1090
  } = statusResult;
736
1091
  ui.output(JSON.stringify(jsonResult, null, 2));
@@ -789,7 +1144,7 @@ function formatLegend(colorize: boolean): string {
789
1144
  return c(dim, parts.join(' '));
790
1145
  }
791
1146
 
792
- function formatStatusSummary(result: MigrationStatusResult, colorize: boolean): string {
1147
+ export function formatStatusSummary(result: MigrationStatusResult, colorize: boolean): string {
793
1148
  const c = (fn: (s: string) => string, s: string) => (colorize ? fn(s) : s);
794
1149
  const lines: string[] = [];
795
1150
 
@@ -797,11 +1152,16 @@ function formatStatusSummary(result: MigrationStatusResult, colorize: boolean):
797
1152
  const pendingCount = result.migrations.filter((e) => e.status === 'pending').length;
798
1153
 
799
1154
  const hasWarnings = result.diagnostics?.some((d) => d.severity === 'warn') ?? false;
1155
+ // INVARIANTS_PENDING is filed at severity 'info' (per ADR 208) so the
1156
+ // warn-severity check above doesn't see it. It still represents pending
1157
+ // work, so it must promote the summary off the success icon.
1158
+ const hasInvariantPending =
1159
+ result.diagnostics?.some((d) => d.code === 'MIGRATION.INVARIANTS_PENDING') ?? false;
800
1160
 
801
1161
  if (result.mode === 'online') {
802
1162
  if (hasUnknown || hasWarnings) {
803
1163
  lines.push(`${c(yellow, '⚠')} ${result.summary}`);
804
- } else if (pendingCount === 0) {
1164
+ } else if (pendingCount === 0 && !hasInvariantPending) {
805
1165
  lines.push(`${c(cyan, '✔')} ${result.summary}`);
806
1166
  } else {
807
1167
  lines.push(`${c(yellow, '⧗')} ${result.summary}`);
@@ -810,6 +1170,15 @@ function formatStatusSummary(result: MigrationStatusResult, colorize: boolean):
810
1170
  lines.push(result.summary);
811
1171
  }
812
1172
 
1173
+ if (result.requiredInvariants.length > 0) {
1174
+ if (result.appliedInvariants !== undefined && result.missingInvariants !== undefined) {
1175
+ lines.push(`${c(dim, 'applied ')}${formatInvariantList(result.appliedInvariants)}`);
1176
+ lines.push(`${c(dim, 'missing ')}${formatInvariantList(result.missingInvariants)}`);
1177
+ } else {
1178
+ lines.push(`${c(dim, 'applied ')}(unknown — connect a database to evaluate)`);
1179
+ }
1180
+ }
1181
+
813
1182
  const warnings = result.diagnostics?.filter((d) => d.severity === 'warn') ?? [];
814
1183
  for (const diag of warnings) {
815
1184
  lines.push(`${c(yellow, '⚠')} ${diag.message}`);
@@ -818,9 +1187,59 @@ function formatStatusSummary(result: MigrationStatusResult, colorize: boolean):
818
1187
  }
819
1188
  }
820
1189
 
1190
+ // Per-space section. Suppressed when there's no extension space —
1191
+ // the legacy single-space output already covers the app member.
1192
+ // When extensions exist, render every space (including the app)
1193
+ // for consistency, plus a cross-space pending total + apply hint.
1194
+ if (result.spaces?.some((s) => s.kind === 'extension')) {
1195
+ const total = result.totalPendingAcrossSpaces ?? 0;
1196
+ lines.push('');
1197
+ lines.push(c(dim, 'spaces'));
1198
+ for (const space of result.spaces) {
1199
+ lines.push(formatSpaceLine(space, c));
1200
+ }
1201
+ if (total > 0) {
1202
+ lines.push('');
1203
+ lines.push(
1204
+ `${c(yellow, '⧗')} ${total} pending migration(s) across ${result.spaces.length} space(s) — run 'prisma-next migration apply' to apply`,
1205
+ );
1206
+ }
1207
+ }
1208
+
821
1209
  return lines.join('\n');
822
1210
  }
823
1211
 
1212
+ function formatSpaceLine(
1213
+ space: MigrationStatusSpaceEntry,
1214
+ c: (fn: (s: string) => string, s: string) => string,
1215
+ ): string {
1216
+ const glyph = (() => {
1217
+ if (space.status === 'up-to-date' || space.status === 'no-marker') return c(cyan, '✓');
1218
+ if (space.status === 'pending') return c(yellow, '⧗');
1219
+ if (space.status === 'unreachable' || space.status === 'never-planned') return c(magenta, '✗');
1220
+ return ' ';
1221
+ })();
1222
+ const tag = space.kind === 'app' ? '[app]' : '[ext]';
1223
+ const head = space.headHash.slice(0, 8);
1224
+ const marker =
1225
+ space.markerHash === undefined
1226
+ ? '(unknown)'
1227
+ : space.markerHash === null
1228
+ ? '(no marker)'
1229
+ : space.markerHash.slice(0, 8);
1230
+ const pending =
1231
+ space.pendingCount === undefined
1232
+ ? ''
1233
+ : space.pendingCount === 0
1234
+ ? c(dim, ' (up to date)')
1235
+ : c(yellow, ` (${space.pendingCount} pending)`);
1236
+ return ` ${glyph} ${c(dim, tag)} ${space.spaceId} → head ${c(dim, head)}, marker ${c(dim, marker)}${pending}`;
1237
+ }
1238
+
1239
+ function formatInvariantList(ids: readonly string[]): string {
1240
+ return ids.length === 0 ? '(none)' : ids.join(', ');
1241
+ }
1242
+
824
1243
  function summarizeRefDistance(
825
1244
  graph: MigrationGraph,
826
1245
  markerHash: string,