@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
@@ -3,23 +3,25 @@ import type { Contract } from '@prisma-next/contract/types';
3
3
  import { getEmittedArtifactPaths } from '@prisma-next/emitter';
4
4
  import {
5
5
  createControlStack,
6
+ hasOperationPreview,
6
7
  type MigrationPlanOperation,
8
+ type OperationPreview,
7
9
  } from '@prisma-next/framework-components/control';
8
- import { computeMigrationId } from '@prisma-next/migration-tools/attestation';
9
- import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
10
- import { findLatestMigration } from '@prisma-next/migration-tools/dag';
10
+ import { MigrationToolsError } from '@prisma-next/migration-tools/errors';
11
+ import { computeMigrationHash } from '@prisma-next/migration-tools/hash';
12
+ import { deriveProvidedInvariants } from '@prisma-next/migration-tools/invariants';
11
13
  import {
12
14
  copyFilesWithRename,
13
15
  formatMigrationDirName,
14
16
  writeMigrationPackage,
15
17
  } from '@prisma-next/migration-tools/io';
18
+ import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata';
19
+ import { findLatestMigration } from '@prisma-next/migration-tools/migration-graph';
16
20
  import { writeMigrationTs } from '@prisma-next/migration-tools/migration-ts';
17
- import { type MigrationManifest, MigrationToolsError } from '@prisma-next/migration-tools/types';
18
21
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
19
22
  import { Command } from 'commander';
20
23
  import { join, relative } from 'pathe';
21
24
  import { loadConfig } from '../config-loader';
22
- import { extractSqlDdl } from '../control-api/operations/extract-sql-ddl';
23
25
  import {
24
26
  type CliErrorConflict,
25
27
  CliStructuredError,
@@ -29,16 +31,20 @@ import {
29
31
  errorRuntime,
30
32
  errorTargetMigrationNotSupported,
31
33
  errorUnexpected,
34
+ mapMigrationToolsError,
32
35
  } from '../utils/cli-errors';
33
36
  import {
34
37
  addGlobalOptions,
35
38
  getTargetMigrations,
36
- loadAllBundles,
39
+ loadMigrationPackages,
37
40
  resolveContractPath,
38
41
  resolveMigrationPaths,
39
42
  setCommandDescriptions,
40
43
  setCommandExamples,
41
44
  } from '../utils/command-helpers';
45
+ import { buildContractSpaceAggregate } from '../utils/contract-space-aggregate-loader';
46
+ import { runContractSpaceSeedPhase } from '../utils/contract-space-seed-phase';
47
+ import { toExtensionInputs } from '../utils/extension-pack-inputs';
42
48
  import { formatStyledHeader } from '../utils/formatters/styled';
43
49
  import { assertFrameworkComponentsCompatible } from '../utils/framework-components';
44
50
  import type { CommonCommandOptions } from '../utils/global-flags';
@@ -55,15 +61,33 @@ interface MigrationPlanOptions extends CommonCommandOptions {
55
61
  export interface MigrationPlanResult {
56
62
  readonly ok: boolean;
57
63
  readonly noOp: boolean;
58
- readonly from: string;
64
+ readonly from: string | null;
59
65
  readonly to: string;
60
66
  readonly dir?: string;
67
+ /**
68
+ * Extension-space migration packages materialised onto disk during this
69
+ * `plan` run. Each entry names a `migrations/<spaceId>/<dirName>/`
70
+ * tree the framework wrote alongside the app-space migration directory.
71
+ * Empty when the project has no extension packs declaring a contract
72
+ * space, or when every extension-space package is already on disk.
73
+ *
74
+ * Surfacing these in the result (rather than only via `ui.step` log
75
+ * lines) makes the cross-space side effect explicit to JSON consumers
76
+ * and the success-summary renderer — the same multi-space side effect
77
+ * that `migration apply` will replay.
78
+ */
79
+ readonly emittedExtensionDirs: readonly { readonly spaceId: string; readonly dirName: string }[];
61
80
  readonly operations: readonly {
62
81
  readonly id: string;
63
82
  readonly label: string;
64
83
  readonly operationClass: string;
65
84
  }[];
66
- readonly sql?: readonly string[];
85
+ /**
86
+ * Family-agnostic textual preview of the migration plan operations.
87
+ * Replaces the previous `sql?: readonly string[]` field; consumers should
88
+ * read `result.preview?.statements`.
89
+ */
90
+ readonly preview?: OperationPreview;
67
91
  readonly summary: string;
68
92
  /**
69
93
  * When true, `migration.ts` was written but contains unfilled
@@ -76,22 +100,6 @@ export interface MigrationPlanResult {
76
100
  };
77
101
  }
78
102
 
79
- function mapMigrationToolsError(error: unknown): CliStructuredError {
80
- if (CliStructuredError.is(error)) {
81
- return error;
82
- }
83
- if (MigrationToolsError.is(error)) {
84
- return errorRuntime(error.message, {
85
- why: error.why,
86
- fix: error.fix,
87
- meta: { code: error.code, ...(error.details ?? {}) },
88
- });
89
- }
90
- return errorUnexpected(error instanceof Error ? error.message : String(error), {
91
- why: `Unexpected error during migration plan: ${error instanceof Error ? error.message : String(error)}`,
92
- });
93
- }
94
-
95
103
  async function executeMigrationPlanCommand(
96
104
  options: MigrationPlanOptions,
97
105
  flags: GlobalFlags,
@@ -99,10 +107,8 @@ async function executeMigrationPlanCommand(
99
107
  startTime: number,
100
108
  ): Promise<Result<MigrationPlanResult, CliStructuredError>> {
101
109
  const config = await loadConfig(options.config);
102
- const { configPath, migrationsDir, migrationsRelative } = resolveMigrationPaths(
103
- options.config,
104
- config,
105
- );
110
+ const { configPath, migrationsDir, appMigrationsDir, appMigrationsRelative } =
111
+ resolveMigrationPaths(options.config, config);
106
112
 
107
113
  const contractPathAbsolute = resolveContractPath(config);
108
114
  const contractPath = relative(process.cwd(), contractPathAbsolute);
@@ -111,7 +117,7 @@ async function executeMigrationPlanCommand(
111
117
  const details: Array<{ label: string; value: string }> = [
112
118
  { label: 'config', value: configPath },
113
119
  { label: 'contract', value: contractPath },
114
- { label: 'migrations', value: migrationsRelative },
120
+ { label: 'migrations', value: appMigrationsRelative },
115
121
  ];
116
122
  if (options.from) {
117
123
  details.push({ label: 'from', value: options.from });
@@ -173,11 +179,11 @@ async function executeMigrationPlanCommand(
173
179
 
174
180
  // Read existing migrations and determine "from" contract
175
181
  let fromContract: Contract | null = null;
176
- let fromHash: string = EMPTY_CONTRACT_HASH;
182
+ let fromHash: string | null = null;
177
183
  let fromContractSourceDir: string | null = null;
178
184
 
179
185
  try {
180
- const { bundles, graph } = await loadAllBundles(migrationsDir);
186
+ const { bundles, graph } = await loadMigrationPackages(appMigrationsDir);
181
187
 
182
188
  if (options.from) {
183
189
  const resolved = resolveBundleByPrefix(bundles, options.from);
@@ -186,25 +192,27 @@ async function executeMigrationPlanCommand(
186
192
  return notOk(
187
193
  f.reason === 'ambiguous'
188
194
  ? errorRuntime('Multiple matching migrations found', {
189
- why: `Prefix "${options.from}" matches ${f.count} migrations in ${migrationsRelative}`,
195
+ why: `Prefix "${options.from}" matches ${f.count} migrations in ${appMigrationsRelative}`,
190
196
  fix: 'Provide a longer prefix to disambiguate, or omit --from to use the latest migration target.',
191
197
  })
192
198
  : errorRuntime('Starting contract not found', {
193
- why: `No migration with to hash matching "${options.from}" exists in ${migrationsRelative}`,
199
+ why: `No migration with to hash matching "${options.from}" exists in ${appMigrationsRelative}`,
194
200
  fix: 'Check that the --from hash matches a known migration target hash, or omit --from to use the latest migration target.',
195
201
  }),
196
202
  );
197
203
  }
198
- fromHash = resolved.value.manifest.to;
199
- fromContract = resolved.value.manifest.toContract;
204
+ fromHash = resolved.value.metadata.to;
205
+ fromContract = resolved.value.metadata.toContract;
200
206
  fromContractSourceDir = resolved.value.dirPath;
201
207
  } else {
202
208
  const latestMigration = findLatestMigration(graph);
203
209
  if (latestMigration) {
204
210
  fromHash = latestMigration.to;
205
- const leafPkg = bundles.find((p) => p.manifest.migrationId === latestMigration.migrationId);
211
+ const leafPkg = bundles.find(
212
+ (p) => p.metadata.migrationHash === latestMigration.migrationHash,
213
+ );
206
214
  if (leafPkg) {
207
- fromContract = leafPkg.manifest.toContract;
215
+ fromContract = leafPkg.metadata.toContract;
208
216
  fromContractSourceDir = leafPkg.dirPath;
209
217
  }
210
218
  }
@@ -213,8 +221,42 @@ async function executeMigrationPlanCommand(
213
221
  if (MigrationToolsError.is(error)) {
214
222
  return notOk(mapMigrationToolsError(error));
215
223
  }
216
- throw error;
224
+ // Wrap unexpected (non-MigrationToolsError) failures from the migration
225
+ // load phase in a structured CLI envelope. Letting them throw would
226
+ // bypass `handleResult()` and crash the command — see CLI structured-
227
+ // errors guideline (CliStructuredError + Result pattern).
228
+ const message = error instanceof Error ? error.message : String(error);
229
+ return notOk(
230
+ errorUnexpected(message, {
231
+ why: `Unexpected error while loading migrations: ${message}`,
232
+ }),
233
+ );
234
+ }
235
+
236
+ // Phase 1 — seed: unconditionally re-emit per-space pinned artefacts
237
+ // (contract.json / contract.d.ts / refs/head.json) and materialise any
238
+ // descriptor-shipped migration packages not yet on disk. Runs before
239
+ // the no-op check so that an extension bump alone (with no structural
240
+ // app-space change) still re-pins extension artefacts on disk.
241
+ const canonicalExtensionInputs = toExtensionInputs(config.extensionPacks ?? []);
242
+ const seedResult = await runContractSpaceSeedPhase({
243
+ migrationsDir,
244
+ extensionPacks: canonicalExtensionInputs,
245
+ });
246
+ if (!flags.json && !flags.quiet) {
247
+ for (const record of seedResult.seeded) {
248
+ if (record.action === 'updated') {
249
+ const pkgSuffix =
250
+ record.newMigrationDirs.length > 0
251
+ ? `; ${record.newMigrationDirs.length} new migration package(s) materialised`
252
+ : '';
253
+ ui.step(`Updated ${record.spaceId} to ${record.newHash}${pkgSuffix}`);
254
+ }
255
+ }
217
256
  }
257
+ const emittedExtensionDirs = seedResult.seeded.flatMap((r) =>
258
+ r.newMigrationDirs.map((dirName) => ({ spaceId: r.spaceId, dirName })),
259
+ );
218
260
 
219
261
  // Check for no-op (same hash means no changes)
220
262
  if (fromHash === toStorageHash) {
@@ -224,6 +266,7 @@ async function executeMigrationPlanCommand(
224
266
  from: fromHash,
225
267
  to: toStorageHash,
226
268
  operations: [],
269
+ emittedExtensionDirs,
227
270
  summary: 'No changes detected between contracts',
228
271
  timings: { total: Date.now() - startTime },
229
272
  };
@@ -239,6 +282,25 @@ async function executeMigrationPlanCommand(
239
282
  }),
240
283
  );
241
284
  }
285
+
286
+ // Phase 2 — load: build the aggregate against the now-consistent disk
287
+ // state that phase 1 just seeded. The seed phase guarantees every
288
+ // declared extension has its head ref pinned, so the loader's
289
+ // declaredButUnmigrated precheck always passes here.
290
+ const stack = createControlStack(config);
291
+ const familyInstance = config.family.create(stack);
292
+ const aggregateResult = await buildContractSpaceAggregate({
293
+ targetId: config.target.targetId,
294
+ migrationsDir,
295
+ appContract: toContractJson,
296
+ extensionPacks: config.extensionPacks ?? [],
297
+ validateContract: (json: unknown) => familyInstance.validateContract(json),
298
+ });
299
+ if (!aggregateResult.ok) {
300
+ return notOk(aggregateResult.failure);
301
+ }
302
+ const aggregate = aggregateResult.value;
303
+
242
304
  const frameworkComponents = assertFrameworkComponentsCompatible(
243
305
  config.family.familyId,
244
306
  config.target.targetId,
@@ -249,12 +311,11 @@ async function executeMigrationPlanCommand(
249
311
  const timestamp = new Date();
250
312
  const slug = options.name ?? 'migration';
251
313
  const dirName = formatMigrationDirName(timestamp, slug);
252
- const packageDir = join(migrationsDir, dirName);
314
+ const packageDir = join(appMigrationsDir, dirName);
253
315
 
254
- const baseManifest: Omit<MigrationManifest, 'migrationId'> = {
316
+ const baseMetadata: Omit<MigrationMetadata, 'migrationHash' | 'providedInvariants'> = {
255
317
  from: fromHash,
256
318
  to: toStorageHash,
257
- kind: 'regular',
258
319
  fromContract,
259
320
  toContract: toContractJson,
260
321
  hints: {
@@ -267,17 +328,15 @@ async function executeMigrationPlanCommand(
267
328
  };
268
329
 
269
330
  try {
270
- const stack = createControlStack(config);
271
- const familyInstance = config.family.create(stack);
272
331
  const planner = migrations.createPlanner(familyInstance);
273
332
  const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents);
274
333
  const plannerResult = planner.plan({
275
- contract: toContractJson,
334
+ contract: aggregate.app.contract,
276
335
  schema: fromSchema,
277
336
  policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] },
278
- fromHash,
279
337
  fromContract,
280
338
  frameworkComponents,
339
+ spaceId: aggregate.app.spaceId,
281
340
  });
282
341
  if (plannerResult.kind === 'failure') {
283
342
  return notOk(
@@ -320,18 +379,22 @@ async function executeMigrationPlanCommand(
320
379
 
321
380
  const migrationTsContent = plannerResult.plan.renderTypeScript();
322
381
 
323
- // Always-attest: compute migrationId over (manifest, ops). When
324
- // placeholders blocked lowering, ops is `[]` and the id hashes over
325
- // the empty list — re-emitting after the user fills the placeholder
326
- // produces a different id (over the real ops). This is intentional;
382
+ // Always-attest: compute migrationHash over (metadata, ops). When
383
+ // placeholders blocked lowering, ops is `[]` and the hash is computed
384
+ // over the empty list — re-emitting after the user fills the placeholder
385
+ // produces a different hash (over the real ops). This is intentional;
327
386
  // there is no on-disk "draft" state.
328
387
  const opsForWrite = hasPlaceholders ? [] : plannedOps;
329
- const manifest: MigrationManifest = {
330
- ...baseManifest,
331
- migrationId: computeMigrationId(baseManifest, opsForWrite),
388
+ const metadataWithInvariants: Omit<MigrationMetadata, 'migrationHash'> = {
389
+ ...baseMetadata,
390
+ providedInvariants: deriveProvidedInvariants(opsForWrite),
391
+ };
392
+ const metadata: MigrationMetadata = {
393
+ ...metadataWithInvariants,
394
+ migrationHash: computeMigrationHash(metadataWithInvariants, opsForWrite),
332
395
  };
333
396
 
334
- await writeMigrationPackage(packageDir, manifest, opsForWrite);
397
+ await writeMigrationPackage(packageDir, metadata, opsForWrite);
335
398
  const destinationArtifacts = getEmittedArtifactPaths(contractPathAbsolute);
336
399
  await copyFilesWithRename(packageDir, [
337
400
  { sourcePath: destinationArtifacts.jsonPath, destName: 'end-contract.json' },
@@ -356,6 +419,7 @@ async function executeMigrationPlanCommand(
356
419
  to: toStorageHash,
357
420
  dir: relative(process.cwd(), packageDir),
358
421
  operations: [],
422
+ emittedExtensionDirs,
359
423
  pendingPlaceholders: true,
360
424
  summary:
361
425
  'Planned migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit',
@@ -364,7 +428,9 @@ async function executeMigrationPlanCommand(
364
428
  return ok(result);
365
429
  }
366
430
 
367
- const sql = extractSqlDdl(plannedOps);
431
+ const preview = hasOperationPreview(familyInstance)
432
+ ? familyInstance.toOperationPreview(plannedOps)
433
+ : undefined;
368
434
  const result: MigrationPlanResult = {
369
435
  ok: true,
370
436
  noOp: false,
@@ -376,13 +442,25 @@ async function executeMigrationPlanCommand(
376
442
  label: op.label,
377
443
  operationClass: op.operationClass,
378
444
  })),
379
- sql,
380
- summary: `Planned ${plannedOps.length} operation(s)`,
445
+ emittedExtensionDirs,
446
+ ...(preview !== undefined ? { preview } : {}),
447
+ summary: buildPlanSummary(plannedOps.length, emittedExtensionDirs.length),
381
448
  timings: { total: Date.now() - startTime },
382
449
  };
383
450
  return ok(result);
384
451
  } catch (error) {
385
- return notOk(mapMigrationToolsError(error));
452
+ if (CliStructuredError.is(error)) {
453
+ return notOk(error);
454
+ }
455
+ if (MigrationToolsError.is(error)) {
456
+ return notOk(mapMigrationToolsError(error));
457
+ }
458
+ const message = error instanceof Error ? error.message : String(error);
459
+ return notOk(
460
+ errorUnexpected(message, {
461
+ why: `Unexpected error during migration plan: ${message}`,
462
+ }),
463
+ );
386
464
  }
387
465
  }
388
466
 
@@ -424,7 +502,29 @@ export function createMigrationPlanCommand(): Command {
424
502
  return command;
425
503
  }
426
504
 
427
- function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFlags): string {
505
+ /**
506
+ * Compose the success-line summary so the cross-space side effect
507
+ * (extension-space migration packages materialised on disk during
508
+ * this `plan` run) is visible in the top line — not just in the
509
+ * step log above it.
510
+ *
511
+ * Example outputs:
512
+ * - `Planned 3 operation(s)` (app-space-only project)
513
+ * - `Planned 3 operation(s); materialised 1 extension-space migration` (one extension)
514
+ * - `Planned 3 operation(s); materialised 2 extension-space migrations` (two extensions)
515
+ *
516
+ * Locks AC3 at the summary-line level: a reader of the success line
517
+ * can tell that something happened beyond the app space.
518
+ */
519
+ function buildPlanSummary(plannedOpsCount: number, emittedExtensionDirsCount: number): string {
520
+ const base = `Planned ${plannedOpsCount} operation(s)`;
521
+ if (emittedExtensionDirsCount === 0) return base;
522
+ const noun =
523
+ emittedExtensionDirsCount === 1 ? 'extension-space migration' : 'extension-space migrations';
524
+ return `${base}; materialised ${emittedExtensionDirsCount} ${noun}`;
525
+ }
526
+
527
+ export function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFlags): string {
428
528
  const lines: string[] = [];
429
529
  const useColor = flags.color !== false;
430
530
 
@@ -432,10 +532,29 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
432
532
  const yellow_ = useColor ? (s: string) => `\x1b[33m${s}\x1b[0m` : (s: string) => s;
433
533
  const dim_ = useColor ? (s: string) => `\x1b[2m${s}\x1b[0m` : (s: string) => s;
434
534
 
535
+ // Renders the extension-space materialisation block + canonical apply-step
536
+ // hint shared by the no-op, placeholder, and full-plan branches. The app
537
+ // space short-circuits do not skip it: an extension-only bump emits new
538
+ // `migrations/<spaceId>/<dirName>/` directories on disk that the user
539
+ // still has to apply, so the success line must surface them.
540
+ function appendEmittedExtensions(): void {
541
+ if (result.emittedExtensionDirs.length === 0) return;
542
+ lines.push('');
543
+ lines.push(dim_('Emitted extension migrations:'));
544
+ for (const entry of result.emittedExtensionDirs) {
545
+ lines.push(dim_(` ${entry.spaceId} → migrations/${entry.spaceId}/${entry.dirName}`));
546
+ }
547
+ lines.push('');
548
+ lines.push(
549
+ `Next: review the extension migrations above, then run ${green_('prisma-next migration apply')}.`,
550
+ );
551
+ }
552
+
435
553
  if (result.noOp) {
436
554
  lines.push(`${green_('✔')} No changes detected`);
437
555
  lines.push(dim_(` from: ${result.from}`));
438
556
  lines.push(dim_(` to: ${result.to}`));
557
+ appendEmittedExtensions();
439
558
  return lines.join('\n');
440
559
  }
441
560
 
@@ -452,6 +571,7 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
452
571
  'Open migration.ts and replace each `placeholder(...)` call with your actual query.',
453
572
  );
454
573
  lines.push(`Then run: ${green_(`node ${result.dir ?? '<dir>'}/migration.ts`)}`);
574
+ appendEmittedExtensions();
455
575
  return lines.join('\n');
456
576
  }
457
577
 
@@ -464,11 +584,11 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
464
584
  const op = result.operations[i]!;
465
585
  const isLast = i === result.operations.length - 1;
466
586
  const treeChar = isLast ? '└' : '├';
467
- const opClassLabel =
468
- op.operationClass === 'destructive'
469
- ? yellow_(`[${op.operationClass}]`)
470
- : dim_(`[${op.operationClass}]`);
471
- lines.push(`${dim_(treeChar)}─ ${op.label} ${opClassLabel}`);
587
+ // operationClass tag is intentionally NOT inlined per spec:
588
+ // a destructive footer warning still surfaces below this list.
589
+ const destructiveMarker =
590
+ op.operationClass === 'destructive' ? ` ${yellow_('(destructive)')}` : '';
591
+ lines.push(`${dim_(treeChar)}─ ${op.label}${destructiveMarker}`);
472
592
  }
473
593
 
474
594
  const hasDestructive = result.operations.some((op) => op.operationClass === 'destructive');
@@ -484,22 +604,37 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
484
604
  lines.push(dim_(`from: ${result.from}`));
485
605
  lines.push(dim_(`to: ${result.to}`));
486
606
  if (result.dir) {
487
- lines.push(dim_(`dir: ${result.dir}`));
607
+ lines.push(dim_(`App space → ${result.dir}`));
608
+ }
609
+ // Per-space block: surface the extension-space directories materialised
610
+ // alongside the app-space migration. Without this block the cross-space
611
+ // side effect is invisible in the success summary (e2e finding F1).
612
+ for (const entry of result.emittedExtensionDirs) {
613
+ lines.push(
614
+ dim_(`Extension space ${entry.spaceId} → migrations/${entry.spaceId}/${entry.dirName}`),
615
+ );
488
616
  }
489
617
 
490
618
  lines.push('');
619
+ // The "Next:" hint always points at the canonical apply path
620
+ // (`prisma-next migration apply`) regardless of how many spaces
621
+ // were materialised — `db update` is a dev-time convenience, not
622
+ // the canonical replay step.
491
623
  lines.push(
492
- `Next: ${green_(`node ${result.dir ?? '<dir>'}/migration.ts`)} to emit ops.json and attest migrationId before running ${green_('prisma-next migration apply')}.`,
624
+ `Next: review ${green_(result.dir ?? '<dir>')} if needed, then run ${green_('prisma-next migration apply')}.`,
493
625
  );
494
626
 
495
- if (result.sql && result.sql.length > 0) {
627
+ if (result.preview && result.preview.statements.length > 0) {
628
+ // The non-empty length is already guaranteed by the surrounding check, so
629
+ // a plain `every` here is equivalent to the helper in formatters/migrations.ts.
630
+ const allSql = result.preview.statements.every((s) => s.language === 'sql');
496
631
  lines.push('');
497
- lines.push(dim_('DDL preview'));
632
+ lines.push(dim_(allSql ? 'DDL preview' : 'Operation preview'));
498
633
  lines.push('');
499
- for (const statement of result.sql) {
500
- const trimmed = statement.trim();
634
+ for (const statement of result.preview.statements) {
635
+ const trimmed = statement.text.trim();
501
636
  if (!trimmed) continue;
502
- const line = trimmed.endsWith(';') ? trimmed : `${trimmed};`;
637
+ const line = statement.language === 'sql' && !trimmed.endsWith(';') ? `${trimmed};` : trimmed;
503
638
  lines.push(line);
504
639
  }
505
640
  }
@@ -517,24 +652,27 @@ export type PrefixResolutionFailure =
517
652
  | { reason: 'not-found' };
518
653
 
519
654
  /**
520
- * Resolve a migration bundle by exact hash or prefix match.
655
+ * Resolve a migration package by **target contract hash** (`metadata.to`)
656
+ * using exact match or prefix match.
521
657
  *
658
+ * Note: matches `metadata.to` (the contract hash this migration produces),
659
+ * not `metadata.migrationHash` (the package's content-addressed identity).
522
660
  * Tries exact match first, then prefix match (auto-prepending `sha256:` when
523
- * the needle omits the scheme). Returns the matched bundle on success, or a
661
+ * the needle omits the scheme). Returns the matched package on success, or a
524
662
  * discriminated failure indicating whether the prefix was ambiguous or simply
525
663
  * not found.
526
664
  *
527
665
  * @internal Exported for testing only.
528
666
  */
529
- export function resolveBundleByPrefix<T extends { manifest: { to: string } }>(
667
+ export function resolveBundleByPrefix<T extends { metadata: { to: string } }>(
530
668
  bundles: readonly T[],
531
669
  needle: string,
532
670
  ): Result<T, PrefixResolutionFailure> {
533
- const exact = bundles.find((p) => p.manifest.to === needle);
671
+ const exact = bundles.find((p) => p.metadata.to === needle);
534
672
  if (exact) return ok(exact);
535
673
 
536
674
  const prefixWithScheme = needle.startsWith('sha256:') ? needle : `sha256:${needle}`;
537
- const candidates = bundles.filter((p) => p.manifest.to.startsWith(prefixWithScheme));
675
+ const candidates = bundles.filter((p) => p.metadata.to.startsWith(prefixWithScheme));
538
676
 
539
677
  if (candidates.length === 1) return ok(candidates[0]!);
540
678
  if (candidates.length > 1) return notOk({ reason: 'ambiguous', count: candidates.length });
@@ -1,3 +1,4 @@
1
+ import { MigrationToolsError } from '@prisma-next/migration-tools/errors';
1
2
  import type { RefEntry } from '@prisma-next/migration-tools/refs';
2
3
  import {
3
4
  deleteRef,
@@ -7,11 +8,15 @@ import {
7
8
  validateRefValue,
8
9
  writeRef,
9
10
  } from '@prisma-next/migration-tools/refs';
10
- import { MigrationToolsError } from '@prisma-next/migration-tools/types';
11
11
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
12
12
  import { Command } from 'commander';
13
13
  import { loadConfig } from '../config-loader';
14
- import { CliStructuredError, errorRuntime, errorUnexpected } from '../utils/cli-errors';
14
+ import {
15
+ CliStructuredError,
16
+ errorRuntime,
17
+ errorUnexpected,
18
+ mapMigrationToolsError,
19
+ } from '../utils/cli-errors';
15
20
  import {
16
21
  addGlobalOptions,
17
22
  resolveMigrationPaths,
@@ -49,11 +54,7 @@ interface RefListResult {
49
54
 
50
55
  function mapError(error: unknown): CliStructuredError {
51
56
  if (MigrationToolsError.is(error)) {
52
- return errorRuntime(error.message, {
53
- why: error.why,
54
- fix: error.fix,
55
- meta: { code: error.code },
56
- });
57
+ return mapMigrationToolsError(error);
57
58
  }
58
59
  return errorUnexpected(error instanceof Error ? error.message : String(error));
59
60
  }