@prisma-next/cli 0.5.0-dev.9 → 0.5.1

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 (185) hide show
  1. package/README.md +61 -26
  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-BCnP7cHo.mjs +1485 -0
  8. package/dist/client-BCnP7cHo.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 +64 -10
  45. package/dist/commands/migration-show.d.mts.map +1 -1
  46. package/dist/commands/migration-show.mjs +166 -60
  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-ByxhPjpW.mjs} +13 -22
  63. package/dist/contract-infer-ByxhPjpW.mjs.map +1 -0
  64. package/dist/contract-space-aggregate-loader-BrwKK6Q6.mjs +160 -0
  65. package/dist/contract-space-aggregate-loader-BrwKK6Q6.mjs.map +1 -0
  66. package/dist/db-verify-Czm5T-J4.mjs +404 -0
  67. package/dist/db-verify-Czm5T-J4.mjs.map +1 -0
  68. package/dist/exports/config-types.mjs +1 -2
  69. package/dist/exports/control-api.d.mts +101 -586
  70. package/dist/exports/control-api.d.mts.map +1 -1
  71. package/dist/exports/control-api.mjs +4 -6
  72. package/dist/exports/index.d.mts.map +1 -1
  73. package/dist/exports/index.mjs +28 -30
  74. package/dist/exports/index.mjs.map +1 -1
  75. package/dist/exports/init-output.d.mts +2 -4
  76. package/dist/exports/init-output.d.mts.map +1 -1
  77. package/dist/exports/init-output.mjs +2 -3
  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-DETSgw3h.mjs} +40 -49
  85. package/dist/init-DETSgw3h.mjs.map +1 -0
  86. package/dist/{inspect-live-schema-yrHAvG71.mjs → inspect-live-schema-DxdBd4Er.mjs} +10 -11
  87. package/dist/inspect-live-schema-DxdBd4Er.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-BdV8JYXV.mjs} +8 -9
  93. package/dist/migration-command-scaffold-BdV8JYXV.mjs.map +1 -0
  94. package/dist/migration-plan-mRu5K81L.mjs +494 -0
  95. package/dist/migration-plan-mRu5K81L.mjs.map +1 -0
  96. package/dist/{migration-status-DUMiH8_G.mjs → migration-status-By9G5p2H.mjs} +270 -65
  97. package/dist/migration-status-By9G5p2H.mjs.map +1 -0
  98. package/dist/migrations-CTsyBXCA.mjs +229 -0
  99. package/dist/migrations-CTsyBXCA.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-LItU7E4l.d.mts +856 -0
  110. package/dist/types-LItU7E4l.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 +28 -26
  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 +26 -18
  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 +213 -75
  127. package/src/commands/migration-ref.ts +8 -7
  128. package/src/commands/migration-show.ts +274 -70
  129. package/src/commands/migration-status.ts +491 -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 +399 -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 +430 -131
  140. package/src/control-api/types.ts +278 -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 +177 -0
  148. package/src/utils/contract-space-seed-phase.ts +201 -0
  149. package/src/utils/emit-queue.ts +26 -0
  150. package/src/utils/extension-pack-inputs.ts +162 -0
  151. package/src/utils/formatters/graph-migration-mapper.ts +7 -3
  152. package/src/utils/formatters/migrations.ts +255 -77
  153. package/src/utils/publish-contract-artifact-pair.ts +134 -0
  154. package/dist/cli-errors-BFYgBH3L.d.mts +0 -4
  155. package/dist/cli-errors-Cd79vmTH.mjs +0 -5
  156. package/dist/client-CrsnY58k.mjs +0 -997
  157. package/dist/client-CrsnY58k.mjs.map +0 -1
  158. package/dist/commands/db-verify.mjs.map +0 -1
  159. package/dist/commands/migration-plan.mjs.map +0 -1
  160. package/dist/config-loader-C25b63rJ.mjs.map +0 -1
  161. package/dist/contract-emit--feXyNd7.mjs +0 -4
  162. package/dist/contract-emit-NJ01hiiv.mjs +0 -195
  163. package/dist/contract-emit-NJ01hiiv.mjs.map +0 -1
  164. package/dist/contract-emit-V5SSitUT.mjs +0 -122
  165. package/dist/contract-emit-V5SSitUT.mjs.map +0 -1
  166. package/dist/contract-enrichment-CAOELa-H.mjs.map +0 -1
  167. package/dist/contract-infer-D9cC3rJm.mjs.map +0 -1
  168. package/dist/extract-operation-statements-DsFfxXVZ.mjs +0 -13
  169. package/dist/extract-operation-statements-DsFfxXVZ.mjs.map +0 -1
  170. package/dist/extract-sql-ddl-D9UbZDyz.mjs +0 -26
  171. package/dist/extract-sql-ddl-D9UbZDyz.mjs.map +0 -1
  172. package/dist/init-C5220SY9.mjs.map +0 -1
  173. package/dist/inspect-live-schema-yrHAvG71.mjs.map +0 -1
  174. package/dist/migration-command-scaffold-B3B09et6.mjs.map +0 -1
  175. package/dist/migration-status-DUMiH8_G.mjs.map +0 -1
  176. package/dist/migrations-Bo5WtTla.mjs +0 -153
  177. package/dist/migrations-Bo5WtTla.mjs.map +0 -1
  178. package/dist/output-BpcQrnnq.mjs.map +0 -1
  179. package/dist/result-handler-Ba3zWQsI.mjs.map +0 -1
  180. package/dist/terminal-ui-C3ZLwQxK.mjs.map +0 -1
  181. package/dist/validate-contract-deps-B_Cs29TL.mjs +0 -37
  182. package/dist/validate-contract-deps-B_Cs29TL.mjs.map +0 -1
  183. package/dist/verify-Bkycc-Tf.mjs.map +0 -1
  184. package/src/control-api/operations/extract-operation-statements.ts +0 -14
  185. 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,146 @@ 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
+ // Count pending *migrations* (graph edges), not operations: a
517
+ // single authored migration that lowers to N ops or zero ops
518
+ // both count as exactly one pending unit of work for the user.
519
+ pendingCount = walked.result.migrationEdges?.length ?? 0;
520
+ if (liveMarker === null) {
521
+ status = pendingCount === 0 ? 'no-marker' : 'pending';
522
+ } else {
523
+ status = pendingCount === 0 ? 'up-to-date' : 'pending';
524
+ }
525
+ } else {
526
+ status = 'unreachable';
527
+ }
528
+
529
+ rows.push({
530
+ spaceId: member.spaceId,
531
+ kind: isApp ? 'app' : 'extension',
532
+ headHash: member.headRef.hash,
533
+ markerHash: liveMarker?.storageHash ?? null,
534
+ pendingCount,
535
+ ...(status ? { status } : {}),
536
+ });
537
+ }
538
+ return rows;
539
+ }
540
+
541
+ /**
542
+ * Read the raw contract.json bytes from disk for the aggregate
543
+ * loader. Returns `null` if the file is missing or unparseable —
544
+ * the existing `readContractEnvelope` path will report the same
545
+ * problem via a status diagnostic, no need to double-surface.
546
+ */
547
+ async function loadContractRawSafely(config: {
548
+ contract?: { output?: string };
549
+ }): Promise<unknown | null> {
550
+ try {
551
+ const path = (await import('../utils/command-helpers')).resolveContractPath(config);
552
+ const raw = await (await import('node:fs/promises')).readFile(path, 'utf-8');
553
+ return JSON.parse(raw) as unknown;
554
+ } catch {
555
+ return null;
556
+ }
557
+ }
558
+
342
559
  async function executeMigrationStatusCommand(
343
560
  options: MigrationStatusOptions,
344
561
  flags: GlobalFlags,
345
562
  ui: TerminalUI,
346
563
  ): Promise<Result<MigrationStatusResult, CliStructuredError>> {
347
564
  const config = await loadConfig(options.config);
348
- const { configPath, migrationsDir, migrationsRelative, refsDir } = resolveMigrationPaths(
349
- options.config,
350
- config,
351
- );
565
+ const { configPath, appMigrationsDir, appMigrationsRelative, migrationsDir, refsDir } =
566
+ resolveMigrationPaths(options.config, config);
352
567
 
353
568
  const dbConnection = options.db ?? config.db?.connection;
354
569
  const hasDriver = !!config.driver;
355
570
 
356
571
  let activeRefName: string | undefined;
357
572
  let activeRefHash: string | undefined;
573
+ let activeRefEntry: RefEntry | undefined;
358
574
  let allRefs: Refs = {};
359
575
  try {
360
576
  allRefs = await readRefs(refsDir);
361
577
  } catch (error) {
362
578
  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
- );
579
+ return notOk(mapMigrationToolsError(error));
370
580
  }
371
581
  throw error;
372
582
  }
@@ -374,21 +584,18 @@ async function executeMigrationStatusCommand(
374
584
  if (options.ref) {
375
585
  activeRefName = options.ref;
376
586
  try {
377
- activeRefHash = resolveRef(allRefs, activeRefName).hash;
587
+ activeRefEntry = resolveRef(allRefs, activeRefName);
588
+ activeRefHash = activeRefEntry.hash;
378
589
  } catch (error) {
379
590
  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
- );
591
+ return notOk(mapMigrationToolsError(error));
387
592
  }
388
593
  throw error;
389
594
  }
390
595
  }
391
596
 
597
+ const requiredInvariants: readonly string[] = [...(activeRefEntry?.invariants ?? [])].sort();
598
+
392
599
  const statusRefs: StatusRef[] = Object.entries(allRefs).map(([name, entry]) => ({
393
600
  name,
394
601
  hash: entry.hash,
@@ -398,7 +605,7 @@ async function executeMigrationStatusCommand(
398
605
  if (!flags.json && !flags.quiet) {
399
606
  const details: Array<{ label: string; value: string }> = [
400
607
  { label: 'config', value: configPath },
401
- { label: 'migrations', value: migrationsRelative },
608
+ { label: 'migrations', value: appMigrationsRelative },
402
609
  ];
403
610
  if (dbConnection && hasDriver) {
404
611
  details.push({ label: 'database', value: maskConnectionUrl(String(dbConnection)) });
@@ -406,6 +613,12 @@ async function executeMigrationStatusCommand(
406
613
  if (activeRefName) {
407
614
  details.push({ label: 'ref', value: activeRefName });
408
615
  }
616
+ if (activeRefEntry && activeRefEntry.invariants.length > 0) {
617
+ details.push({
618
+ label: 'required',
619
+ value: formatInvariantList(activeRefEntry.invariants),
620
+ });
621
+ }
409
622
  const header = formatStyledHeader({
410
623
  command: 'migration status',
411
624
  description: 'Show migration history and applied status',
@@ -429,15 +642,13 @@ async function executeMigrationStatusCommand(
429
642
  });
430
643
  }
431
644
 
432
- let bundles: readonly MigrationBundle[];
645
+ let bundles: readonly OnDiskMigrationPackage[];
433
646
  let graph: MigrationGraph;
434
647
  try {
435
- ({ bundles, graph } = await loadAllBundles(migrationsDir));
648
+ ({ bundles, graph } = await loadMigrationPackages(appMigrationsDir));
436
649
  } catch (error) {
437
650
  if (MigrationToolsError.is(error)) {
438
- return notOk(
439
- errorRuntime(error.message, { why: error.why, fix: error.fix, meta: { code: error.code } }),
440
- );
651
+ return notOk(mapMigrationToolsError(error));
441
652
  }
442
653
  return notOk(
443
654
  errorUnexpected(error instanceof Error ? error.message : String(error), {
@@ -465,6 +676,7 @@ async function executeMigrationStatusCommand(
465
676
  contractHash,
466
677
  summary: 'No migrations found',
467
678
  diagnostics,
679
+ requiredInvariants,
468
680
  });
469
681
  }
470
682
 
@@ -492,7 +704,9 @@ async function executeMigrationStatusCommand(
492
704
  }
493
705
 
494
706
  let markerHash: string | undefined;
707
+ let markerInvariants: readonly string[] = [];
495
708
  let mode: 'online' | 'offline' = 'offline';
709
+ let allMarkers: ReadonlyMap<string, ContractMarkerRecordLike> | null = null;
496
710
 
497
711
  if (dbConnection && hasDriver) {
498
712
  const client = createControlClient({
@@ -504,8 +718,30 @@ async function executeMigrationStatusCommand(
504
718
  });
505
719
  try {
506
720
  await client.connect(dbConnection);
507
- markerHash = (await client.readMarker())?.storageHash;
721
+ const marker = await client.readMarker();
722
+ markerHash = marker?.storageHash;
723
+ markerInvariants = marker?.invariants ?? [];
508
724
  mode = 'online';
725
+ // Read every space's marker so the aggregate enumeration can
726
+ // surface per-space marker state. `readAllMarkers` mirrors what
727
+ // `db init` / `db update` already use to drive the multi-space
728
+ // planner; here it powers the aggregate status output.
729
+ //
730
+ // Probe for the method first so we only swallow the
731
+ // unsupported-method case: older family instances may not
732
+ // implement `readAllMarkers` (per-space enumeration then falls
733
+ // back to "marker unknown"). Real query / runtime errors from
734
+ // an instance that *does* expose the method must propagate up
735
+ // — otherwise transient DB failures would silently degrade
736
+ // status to "markers unknown".
737
+ if (typeof client.readAllMarkers === 'function') {
738
+ allMarkers = await client.readAllMarkers();
739
+ } else {
740
+ // Leaving `allMarkers` as `null` signals "unknown" to the
741
+ // aggregate loader (an empty `Map` would instead mean "every
742
+ // space has no marker", which is a different condition).
743
+ allMarkers = null;
744
+ }
509
745
  } catch {
510
746
  if (!flags.json && !flags.quiet) {
511
747
  ui.warn('Could not connect to database — showing offline status');
@@ -515,6 +751,63 @@ async function executeMigrationStatusCommand(
515
751
  }
516
752
  }
517
753
 
754
+ // Build the aggregate enumeration of contract spaces. Lossy on
755
+ // failure (extensions are simply omitted) so the existing
756
+ // single-space app pipeline below still runs even if extensions
757
+ // can't be loaded — a strict failure here would degrade the
758
+ // load-bearing app-space output for unrelated reasons.
759
+ const contractRawForAggregate = await loadContractRawSafely(config);
760
+ let aggregateSpaces: readonly MigrationStatusSpaceEntry[] = [];
761
+ if (contractRawForAggregate !== null) {
762
+ // The aggregate loader needs a typed-Contract producer. Build a
763
+ // real control stack so `validateContract` runs against a fully
764
+ // composed family instance — descriptors that read stack members
765
+ // during construction (e.g. codec lookups) get a consistent view.
766
+ const stack = createControlStack(config);
767
+ const familyInstance = config.family.create(stack);
768
+ try {
769
+ aggregateSpaces = await loadAggregateStatusSpaces({
770
+ targetId: config.target.targetId,
771
+ migrationsDir,
772
+ appContractRaw: contractRawForAggregate,
773
+ extensionPacks: config.extensionPacks ?? [],
774
+ validateContract: (json: unknown) => familyInstance.validateContract(json),
775
+ markersBySpace: allMarkers,
776
+ });
777
+ } catch {
778
+ // Loader failure short-circuits silently — the existing
779
+ // single-space app pipeline below still runs.
780
+ aggregateSpaces = [];
781
+ }
782
+ }
783
+ const totalPendingAcrossSpaces = computeTotalPendingAcrossSpaces(aggregateSpaces);
784
+
785
+ // Pre-check unknown invariants. Online: union the graph's declared
786
+ // invariants with the marker's recorded set so a retired-but-applied
787
+ // invariant doesn't surface as MIGRATION.UNKNOWN_INVARIANT — apply would
788
+ // route fine because marker subtraction empties `effectiveRequired`.
789
+ // Offline: keep the check graph-strict (the marker is unknown, and a
790
+ // missing declarer is the dominant signal we can offer).
791
+ if (activeRefEntry && activeRefEntry.invariants.length > 0) {
792
+ const declared = collectDeclaredInvariants(graph);
793
+ const known = new Set<string>(declared);
794
+ if (mode === 'online') {
795
+ for (const id of markerInvariants) known.add(id);
796
+ }
797
+ const unknown = activeRefEntry.invariants.filter((id) => !known.has(id));
798
+ if (unknown.length > 0) {
799
+ return notOk(
800
+ mapMigrationToolsError(
801
+ errorUnknownInvariant({
802
+ ...ifDefined('refName', activeRefName),
803
+ unknown,
804
+ declared: [...declared].sort(),
805
+ }),
806
+ ),
807
+ );
808
+ }
809
+ }
810
+
518
811
  // Marker exists but is not in the migration graph and doesn't match the
519
812
  // contract hash. The DB is at an unknown state relative to the graph.
520
813
  // Bail out early with a clear diagnostic instead of rendering a confusing
@@ -560,6 +853,7 @@ async function executeMigrationStatusCommand(
560
853
  summary: `${bundles.length} migration(s) on disk`,
561
854
  diagnostics,
562
855
  markerHash,
856
+ requiredInvariants,
563
857
  ...(statusRefs.length > 0 ? { refs: statusRefs } : {}),
564
858
  });
565
859
  }
@@ -600,6 +894,7 @@ async function executeMigrationStatusCommand(
600
894
  summary: `${bundles.length} migration(s) on disk`,
601
895
  diagnostics,
602
896
  ...ifDefined('markerHash', markerHash),
897
+ requiredInvariants,
603
898
  ...(statusRefs.length > 0 ? { refs: statusRefs } : {}),
604
899
  graph,
605
900
  bundles,
@@ -624,14 +919,39 @@ async function executeMigrationStatusCommand(
624
919
  const pendingCount = edgeStatuses.filter((e) => e.status === 'pending').length;
625
920
  const appliedCount = edgeStatuses.filter((e) => e.status === 'applied').length;
626
921
 
922
+ let appliedInvariants: readonly string[] | undefined;
923
+ let missingInvariants: readonly string[] | undefined;
924
+ let effectiveRequired = new Set<string>();
925
+ if (mode === 'online') {
926
+ // Mirrors `migration-apply.ts`: compute `effectiveRequired = required −
927
+ // marker.invariants` directly, then derive the display fields from it.
928
+ // `appliedInvariants` is the intersection (`required ∩ marker`), which
929
+ // is what JSON consumers see for the active ref; the unfiltered set
930
+ // lives on `marker.invariants`.
931
+ const markerSet = new Set(markerInvariants);
932
+ effectiveRequired = new Set(requiredInvariants.filter((id) => !markerSet.has(id)));
933
+ appliedInvariants = requiredInvariants.filter((id) => markerSet.has(id));
934
+ missingInvariants = [...effectiveRequired].sort();
935
+ }
936
+
937
+ // The marker can match the structural target while still missing required
938
+ // invariants — for example, a self-edge that provides X, applied via a ref
939
+ // declaring X. `pendingCount` (structural) says zero in that case but
940
+ // `effectiveRequired` is non-empty, so up-to-date messaging would mislead.
941
+ const hasInvariantWork = effectiveRequired.size > 0;
942
+ const missingList = [...effectiveRequired].sort().join(', ');
943
+
627
944
  let summary: string;
628
945
  if (mode === 'online') {
629
946
  if (markerHash !== undefined && !graph.nodes.has(markerHash) && markerHash === contractHash) {
630
947
  summary = `${bundles.length} migration(s) on disk`;
631
948
  } else if (activeRefHash && markerHash !== undefined) {
632
- summary = summarizeRefDistance(graph, markerHash, activeRefHash, activeRefName!);
633
- } else if (pendingCount === 0) {
949
+ const distance = summarizeRefDistance(graph, markerHash, activeRefHash, activeRefName!);
950
+ summary = hasInvariantWork ? `${distance} missing invariant(s): ${missingList}` : distance;
951
+ } else if (pendingCount === 0 && !hasInvariantWork) {
634
952
  summary = `Database is up to date (${appliedCount} migration${appliedCount !== 1 ? 's' : ''} applied)`;
953
+ } else if (pendingCount === 0 && hasInvariantWork) {
954
+ summary = `Missing invariant(s): ${missingList} — run 'prisma-next migration apply --ref ${activeRefName ?? '<ref>'}' to apply`;
635
955
  } else if (markerHash === undefined) {
636
956
  summary = `${pendingCount} pending migration(s) — database has no marker`;
637
957
  } else {
@@ -641,6 +961,37 @@ async function executeMigrationStatusCommand(
641
961
  summary = `${entries.length} migration(s) on disk`;
642
962
  }
643
963
 
964
+ let pathDecision: MigrationStatusResult['pathDecision'];
965
+ let routingUnreachable = false;
966
+ if (mode === 'online') {
967
+ const originHash = markerHash ?? EMPTY_CONTRACT_HASH;
968
+ const outcome = findPathWithDecision(graph, originHash, targetHash, {
969
+ ...ifDefined('refName', activeRefName),
970
+ required: effectiveRequired,
971
+ });
972
+ if (outcome.kind === 'ok') {
973
+ pathDecision = toPathDecisionResult(outcome.decision);
974
+ } else if (outcome.kind === 'unsatisfiable') {
975
+ return notOk(
976
+ mapMigrationToolsError(
977
+ errorNoInvariantPath({
978
+ ...ifDefined('refName', activeRefName),
979
+ required: [...effectiveRequired].sort(),
980
+ missing: outcome.missing,
981
+ structuralPath: outcome.structuralPath.map(toStructuralEdge),
982
+ }),
983
+ ),
984
+ );
985
+ } else {
986
+ // outcome.kind === 'unreachable' — origin (marker) has no structural
987
+ // path to the active target. `pendingCount` and `hasInvariantWork`
988
+ // both report zero in this case, but emitting MIGRATION.UP_TO_DATE
989
+ // would be wrong: the database simply cannot reach the requested
990
+ // ref/contract from its current state. Suppress UP_TO_DATE below.
991
+ routingUnreachable = true;
992
+ }
993
+ }
994
+
644
995
  if (mode === 'online') {
645
996
  if (markerHash !== undefined && !graph.nodes.has(markerHash) && markerHash === contractHash) {
646
997
  diagnostics.push({
@@ -657,7 +1008,16 @@ async function executeMigrationStatusCommand(
657
1008
  message: `${pendingCount} migration(s) pending`,
658
1009
  hints: ["Run 'prisma-next migration apply' to apply pending migrations"],
659
1010
  });
660
- } else {
1011
+ } else if (hasInvariantWork) {
1012
+ diagnostics.push({
1013
+ code: 'MIGRATION.INVARIANTS_PENDING',
1014
+ severity: 'info',
1015
+ message: `Missing required invariant(s): ${missingList}`,
1016
+ hints: [
1017
+ `Run 'prisma-next migration apply --ref ${activeRefName ?? '<ref>'}' to apply a path that covers the required invariants`,
1018
+ ],
1019
+ });
1020
+ } else if (!routingUnreachable) {
661
1021
  diagnostics.push({
662
1022
  code: 'MIGRATION.UP_TO_DATE',
663
1023
  severity: 'info',
@@ -667,14 +1027,6 @@ async function executeMigrationStatusCommand(
667
1027
  }
668
1028
  }
669
1029
 
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
1030
  const result: MigrationStatusResult = {
679
1031
  ok: true,
680
1032
  mode,
@@ -684,6 +1036,9 @@ async function executeMigrationStatusCommand(
684
1036
  summary,
685
1037
  diagnostics,
686
1038
  ...ifDefined('markerHash', markerHash),
1039
+ requiredInvariants,
1040
+ ...ifDefined('appliedInvariants', appliedInvariants),
1041
+ ...ifDefined('missingInvariants', missingInvariants),
687
1042
  ...(statusRefs.length > 0 ? { refs: statusRefs } : {}),
688
1043
  ...ifDefined('pathDecision', pathDecision),
689
1044
  graph,
@@ -691,6 +1046,8 @@ async function executeMigrationStatusCommand(
691
1046
  edgeStatuses,
692
1047
  ...ifDefined('activeRefHash', activeRefHash),
693
1048
  ...ifDefined('activeRefName', activeRefName),
1049
+ spaces: aggregateSpaces,
1050
+ ...ifDefined('totalPendingAcrossSpaces', totalPendingAcrossSpaces),
694
1051
  };
695
1052
  return ok(result);
696
1053
  }
@@ -724,13 +1081,19 @@ export function createMigrationStatusCommand(): Command {
724
1081
 
725
1082
  const exitCode = handleResult(result, flags, ui, (statusResult) => {
726
1083
  if (flags.json) {
1084
+ // Strip non-JSON-shape fields before emitting. These belong to
1085
+ // the in-memory result so the human renderer can avoid
1086
+ // recomputing them, but they would either bloat the wire format
1087
+ // (graph, bundles, edgeStatuses) or expose internals
1088
+ // (activeRefHash, activeRefName, diverged) that consumers should
1089
+ // read off `pathDecision` / `refs` instead.
727
1090
  const {
728
- graph: _g,
729
- bundles: _b,
730
- edgeStatuses: _es,
731
- activeRefHash: _arh,
732
- activeRefName: _arn,
733
- diverged: _d,
1091
+ graph: _graph,
1092
+ bundles: _bundles,
1093
+ edgeStatuses: _edgeStatuses,
1094
+ activeRefHash: _activeRefHash,
1095
+ activeRefName: _activeRefName,
1096
+ diverged: _diverged,
734
1097
  ...jsonResult
735
1098
  } = statusResult;
736
1099
  ui.output(JSON.stringify(jsonResult, null, 2));
@@ -789,7 +1152,7 @@ function formatLegend(colorize: boolean): string {
789
1152
  return c(dim, parts.join(' '));
790
1153
  }
791
1154
 
792
- function formatStatusSummary(result: MigrationStatusResult, colorize: boolean): string {
1155
+ export function formatStatusSummary(result: MigrationStatusResult, colorize: boolean): string {
793
1156
  const c = (fn: (s: string) => string, s: string) => (colorize ? fn(s) : s);
794
1157
  const lines: string[] = [];
795
1158
 
@@ -797,11 +1160,16 @@ function formatStatusSummary(result: MigrationStatusResult, colorize: boolean):
797
1160
  const pendingCount = result.migrations.filter((e) => e.status === 'pending').length;
798
1161
 
799
1162
  const hasWarnings = result.diagnostics?.some((d) => d.severity === 'warn') ?? false;
1163
+ // INVARIANTS_PENDING is filed at severity 'info' (per ADR 208) so the
1164
+ // warn-severity check above doesn't see it. It still represents pending
1165
+ // work, so it must promote the summary off the success icon.
1166
+ const hasInvariantPending =
1167
+ result.diagnostics?.some((d) => d.code === 'MIGRATION.INVARIANTS_PENDING') ?? false;
800
1168
 
801
1169
  if (result.mode === 'online') {
802
1170
  if (hasUnknown || hasWarnings) {
803
1171
  lines.push(`${c(yellow, '⚠')} ${result.summary}`);
804
- } else if (pendingCount === 0) {
1172
+ } else if (pendingCount === 0 && !hasInvariantPending) {
805
1173
  lines.push(`${c(cyan, '✔')} ${result.summary}`);
806
1174
  } else {
807
1175
  lines.push(`${c(yellow, '⧗')} ${result.summary}`);
@@ -810,6 +1178,15 @@ function formatStatusSummary(result: MigrationStatusResult, colorize: boolean):
810
1178
  lines.push(result.summary);
811
1179
  }
812
1180
 
1181
+ if (result.requiredInvariants.length > 0) {
1182
+ if (result.appliedInvariants !== undefined && result.missingInvariants !== undefined) {
1183
+ lines.push(`${c(dim, 'applied ')}${formatInvariantList(result.appliedInvariants)}`);
1184
+ lines.push(`${c(dim, 'missing ')}${formatInvariantList(result.missingInvariants)}`);
1185
+ } else {
1186
+ lines.push(`${c(dim, 'applied ')}(unknown — connect a database to evaluate)`);
1187
+ }
1188
+ }
1189
+
813
1190
  const warnings = result.diagnostics?.filter((d) => d.severity === 'warn') ?? [];
814
1191
  for (const diag of warnings) {
815
1192
  lines.push(`${c(yellow, '⚠')} ${diag.message}`);
@@ -818,9 +1195,59 @@ function formatStatusSummary(result: MigrationStatusResult, colorize: boolean):
818
1195
  }
819
1196
  }
820
1197
 
1198
+ // Per-space section. Suppressed when there's no extension space —
1199
+ // the legacy single-space output already covers the app member.
1200
+ // When extensions exist, render every space (including the app)
1201
+ // for consistency, plus a cross-space pending total + apply hint.
1202
+ if (result.spaces?.some((s) => s.kind === 'extension')) {
1203
+ const total = result.totalPendingAcrossSpaces ?? 0;
1204
+ lines.push('');
1205
+ lines.push(c(dim, 'spaces'));
1206
+ for (const space of result.spaces) {
1207
+ lines.push(formatSpaceLine(space, c));
1208
+ }
1209
+ if (total > 0) {
1210
+ lines.push('');
1211
+ lines.push(
1212
+ `${c(yellow, '⧗')} ${total} pending migration(s) across ${result.spaces.length} space(s) — run 'prisma-next migration apply' to apply`,
1213
+ );
1214
+ }
1215
+ }
1216
+
821
1217
  return lines.join('\n');
822
1218
  }
823
1219
 
1220
+ function formatSpaceLine(
1221
+ space: MigrationStatusSpaceEntry,
1222
+ c: (fn: (s: string) => string, s: string) => string,
1223
+ ): string {
1224
+ const glyph = (() => {
1225
+ if (space.status === 'up-to-date' || space.status === 'no-marker') return c(cyan, '✓');
1226
+ if (space.status === 'pending') return c(yellow, '⧗');
1227
+ if (space.status === 'unreachable' || space.status === 'never-planned') return c(magenta, '✗');
1228
+ return ' ';
1229
+ })();
1230
+ const tag = space.kind === 'app' ? '[app]' : '[ext]';
1231
+ const head = space.headHash.slice(0, 8);
1232
+ const marker =
1233
+ space.markerHash === undefined
1234
+ ? '(unknown)'
1235
+ : space.markerHash === null
1236
+ ? '(no marker)'
1237
+ : space.markerHash.slice(0, 8);
1238
+ const pending =
1239
+ space.pendingCount === undefined
1240
+ ? ''
1241
+ : space.pendingCount === 0
1242
+ ? c(dim, ' (up to date)')
1243
+ : c(yellow, ` (${space.pendingCount} pending)`);
1244
+ return ` ${glyph} ${c(dim, tag)} ${space.spaceId} → head ${c(dim, head)}, marker ${c(dim, marker)}${pending}`;
1245
+ }
1246
+
1247
+ function formatInvariantList(ids: readonly string[]): string {
1248
+ return ids.length === 0 ? '(none)' : ids.join(', ');
1249
+ }
1250
+
824
1251
  function summarizeRefDistance(
825
1252
  graph: MigrationGraph,
826
1253
  markerHash: string,