@prisma-next/cli 0.4.0-dev.9 → 0.4.2

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 (157) hide show
  1. package/README.md +26 -18
  2. package/dist/agent-skill-mongo.md +63 -31
  3. package/dist/agent-skill-postgres.md +1 -1
  4. package/dist/cli-errors-BFYgBH3L.d.mts +4 -0
  5. package/dist/cli-errors-Cd79vmTH.mjs +5 -0
  6. package/dist/cli.mjs +127 -25
  7. package/dist/cli.mjs.map +1 -1
  8. package/dist/{client-CJxHfhze.mjs → client-CrsnY58k.mjs} +9 -8
  9. package/dist/{client-CJxHfhze.mjs.map → client-CrsnY58k.mjs.map} +1 -1
  10. package/dist/commands/contract-emit.d.mts.map +1 -1
  11. package/dist/commands/contract-emit.mjs +7 -7
  12. package/dist/commands/contract-infer.mjs +8 -8
  13. package/dist/commands/db-init.mjs +8 -8
  14. package/dist/commands/db-schema.mjs +8 -8
  15. package/dist/commands/db-sign.mjs +8 -8
  16. package/dist/commands/db-update.mjs +8 -8
  17. package/dist/commands/db-verify.mjs +9 -9
  18. package/dist/commands/migration-apply.d.mts +1 -1
  19. package/dist/commands/migration-apply.d.mts.map +1 -1
  20. package/dist/commands/migration-apply.mjs +37 -28
  21. package/dist/commands/migration-apply.mjs.map +1 -1
  22. package/dist/commands/migration-new.d.mts.map +1 -1
  23. package/dist/commands/migration-new.mjs +48 -23
  24. package/dist/commands/migration-new.mjs.map +1 -1
  25. package/dist/commands/migration-plan.d.mts +6 -1
  26. package/dist/commands/migration-plan.d.mts.map +1 -1
  27. package/dist/commands/migration-plan.mjs +94 -71
  28. package/dist/commands/migration-plan.mjs.map +1 -1
  29. package/dist/commands/migration-ref.d.mts +6 -4
  30. package/dist/commands/migration-ref.d.mts.map +1 -1
  31. package/dist/commands/migration-ref.mjs +29 -34
  32. package/dist/commands/migration-ref.mjs.map +1 -1
  33. package/dist/commands/migration-show.d.mts +2 -2
  34. package/dist/commands/migration-show.d.mts.map +1 -1
  35. package/dist/commands/migration-show.mjs +11 -16
  36. package/dist/commands/migration-show.mjs.map +1 -1
  37. package/dist/commands/migration-status.d.mts +4 -5
  38. package/dist/commands/migration-status.d.mts.map +1 -1
  39. package/dist/commands/migration-status.mjs +7 -7
  40. package/dist/config-loader-C25b63rJ.mjs +90 -0
  41. package/dist/config-loader-C25b63rJ.mjs.map +1 -0
  42. package/dist/config-loader.d.mts.map +1 -1
  43. package/dist/config-loader.mjs +1 -1
  44. package/dist/contract-emit-DxgyXrqV.mjs +6 -0
  45. package/dist/{contract-emit-gpJNLGs7.mjs → contract-emit-NJ01hiiv.mjs} +20 -16
  46. package/dist/contract-emit-NJ01hiiv.mjs.map +1 -0
  47. package/dist/{contract-emit-CKig_Lra.mjs → contract-emit-V5SSitUT.mjs} +25 -21
  48. package/dist/contract-emit-V5SSitUT.mjs.map +1 -0
  49. package/dist/{contract-enrichment-CGW6mm-E.mjs → contract-enrichment-CAOELa-H.mjs} +1 -1
  50. package/dist/{contract-enrichment-CGW6mm-E.mjs.map → contract-enrichment-CAOELa-H.mjs.map} +1 -1
  51. package/dist/{contract-infer-BDJgg7Xb.mjs → contract-infer-D9cC3rJm.mjs} +4 -4
  52. package/dist/{contract-infer-BDJgg7Xb.mjs.map → contract-infer-D9cC3rJm.mjs.map} +1 -1
  53. package/dist/exports/control-api.d.mts +2 -2
  54. package/dist/exports/control-api.d.mts.map +1 -1
  55. package/dist/exports/control-api.mjs +6 -6
  56. package/dist/exports/index.mjs +7 -7
  57. package/dist/exports/init-output.d.mts +39 -0
  58. package/dist/exports/init-output.d.mts.map +1 -0
  59. package/dist/exports/init-output.mjs +3 -0
  60. package/dist/{extract-operation-statements-DZUJNmL3.mjs → extract-operation-statements-DsFfxXVZ.mjs} +2 -2
  61. package/dist/{extract-operation-statements-DZUJNmL3.mjs.map → extract-operation-statements-DsFfxXVZ.mjs.map} +1 -1
  62. package/dist/{extract-sql-ddl-DDMX-9mz.mjs → extract-sql-ddl-D9UbZDyz.mjs} +1 -1
  63. package/dist/{extract-sql-ddl-DDMX-9mz.mjs.map → extract-sql-ddl-D9UbZDyz.mjs.map} +1 -1
  64. package/dist/{framework-components-Bsr1GaIj.mjs → framework-components-Cr--XBKy.mjs} +2 -2
  65. package/dist/{framework-components-Bsr1GaIj.mjs.map → framework-components-Cr--XBKy.mjs.map} +1 -1
  66. package/dist/init-m8x0UoPY.mjs +2062 -0
  67. package/dist/init-m8x0UoPY.mjs.map +1 -0
  68. package/dist/{inspect-live-schema-ChqrALmw.mjs → inspect-live-schema-yrHAvG71.mjs} +6 -6
  69. package/dist/{inspect-live-schema-ChqrALmw.mjs.map → inspect-live-schema-yrHAvG71.mjs.map} +1 -1
  70. package/dist/migration-cli.d.mts +50 -0
  71. package/dist/migration-cli.d.mts.map +1 -0
  72. package/dist/migration-cli.mjs +184 -0
  73. package/dist/migration-cli.mjs.map +1 -0
  74. package/dist/{migration-command-scaffold-B0oH_hyB.mjs → migration-command-scaffold-B3B09et6.mjs} +7 -7
  75. package/dist/{migration-command-scaffold-B0oH_hyB.mjs.map → migration-command-scaffold-B3B09et6.mjs.map} +1 -1
  76. package/dist/{migration-status-CPamfEPj.mjs → migration-status-DUMiH8_G.mjs} +25 -43
  77. package/dist/migration-status-DUMiH8_G.mjs.map +1 -0
  78. package/dist/{migrations-BIsjFjSV.mjs → migrations-Bo5WtTla.mjs} +4 -15
  79. package/dist/migrations-Bo5WtTla.mjs.map +1 -0
  80. package/dist/output-BpcQrnnq.mjs +103 -0
  81. package/dist/output-BpcQrnnq.mjs.map +1 -0
  82. package/dist/{progress-adapter-B-YvmcDu.mjs → progress-adapter-DvQWB1nK.mjs} +1 -1
  83. package/dist/{progress-adapter-B-YvmcDu.mjs.map → progress-adapter-DvQWB1nK.mjs.map} +1 -1
  84. package/dist/quick-reference-mongo.md +34 -13
  85. package/dist/quick-reference-postgres.md +11 -9
  86. package/dist/{result-handler-AFK4hxyX.mjs → result-handler-Ba3zWQsI.mjs} +26 -88
  87. package/dist/result-handler-Ba3zWQsI.mjs.map +1 -0
  88. package/dist/{terminal-ui-C5k88MmW.mjs → terminal-ui-C3ZLwQxK.mjs} +76 -2
  89. package/dist/terminal-ui-C3ZLwQxK.mjs.map +1 -0
  90. package/dist/{validate-contract-deps-DBH6iTAD.mjs → validate-contract-deps-B_Cs29TL.mjs} +1 -1
  91. package/dist/{validate-contract-deps-DBH6iTAD.mjs.map → validate-contract-deps-B_Cs29TL.mjs.map} +1 -1
  92. package/dist/{verify-C56CuQc7.mjs → verify-Bkycc-Tf.mjs} +2 -2
  93. package/dist/{verify-C56CuQc7.mjs.map → verify-Bkycc-Tf.mjs.map} +1 -1
  94. package/package.json +24 -19
  95. package/src/cli.ts +1 -5
  96. package/src/commands/contract-emit.ts +9 -10
  97. package/src/commands/init/detect-pnpm-catalog.ts +141 -0
  98. package/src/commands/init/errors.ts +254 -0
  99. package/src/commands/init/exit-codes.ts +62 -0
  100. package/src/commands/init/hygiene-gitattributes.ts +97 -0
  101. package/src/commands/init/hygiene-gitignore.ts +48 -0
  102. package/src/commands/init/hygiene-package-scripts.ts +91 -0
  103. package/src/commands/init/index.ts +112 -7
  104. package/src/commands/init/init.ts +766 -144
  105. package/src/commands/init/inputs.ts +421 -0
  106. package/src/commands/init/output.ts +147 -0
  107. package/src/commands/init/probe-db.ts +308 -0
  108. package/src/commands/init/reinit-cleanup.ts +83 -0
  109. package/src/commands/init/templates/agent-skill-mongo.md +63 -31
  110. package/src/commands/init/templates/agent-skill-postgres.md +1 -1
  111. package/src/commands/init/templates/agent-skill.ts +25 -3
  112. package/src/commands/init/templates/code-templates.ts +125 -32
  113. package/src/commands/init/templates/env.ts +80 -0
  114. package/src/commands/init/templates/quick-reference-mongo.md +34 -13
  115. package/src/commands/init/templates/quick-reference-postgres.md +11 -9
  116. package/src/commands/init/templates/quick-reference.ts +42 -3
  117. package/src/commands/init/templates/tsconfig.ts +167 -5
  118. package/src/commands/migration-apply.ts +37 -26
  119. package/src/commands/migration-new.ts +39 -17
  120. package/src/commands/migration-plan.ts +119 -104
  121. package/src/commands/migration-ref.ts +32 -47
  122. package/src/commands/migration-show.ts +6 -16
  123. package/src/commands/migration-status.ts +30 -55
  124. package/src/config-loader.ts +35 -29
  125. package/src/config-path-validation.ts +75 -0
  126. package/src/control-api/client.ts +2 -1
  127. package/src/control-api/operations/contract-emit.ts +24 -23
  128. package/src/control-api/types.ts +1 -1
  129. package/src/exports/init-output.ts +10 -0
  130. package/src/migration-cli.ts +254 -0
  131. package/src/utils/cli-errors.ts +1 -0
  132. package/src/utils/command-helpers.ts +18 -22
  133. package/src/utils/formatters/graph-migration-mapper.ts +5 -14
  134. package/src/utils/formatters/help.ts +0 -1
  135. package/src/utils/formatters/migrations.ts +2 -29
  136. package/dist/cli-errors-BUuJr6py.mjs +0 -5
  137. package/dist/cli-errors-Dic2eADK.d.mts +0 -4
  138. package/dist/commands/migration-emit.d.mts +0 -38
  139. package/dist/commands/migration-emit.d.mts.map +0 -1
  140. package/dist/commands/migration-emit.mjs +0 -81
  141. package/dist/commands/migration-emit.mjs.map +0 -1
  142. package/dist/config-loader-C4VXKl8f.mjs +0 -43
  143. package/dist/config-loader-C4VXKl8f.mjs.map +0 -1
  144. package/dist/contract-emit-CKig_Lra.mjs.map +0 -1
  145. package/dist/contract-emit-CU-SYNe4.mjs +0 -6
  146. package/dist/contract-emit-gpJNLGs7.mjs.map +0 -1
  147. package/dist/init-DZWvhEP0.mjs +0 -430
  148. package/dist/init-DZWvhEP0.mjs.map +0 -1
  149. package/dist/migration-emit-Du4DBMqz.mjs +0 -125
  150. package/dist/migration-emit-Du4DBMqz.mjs.map +0 -1
  151. package/dist/migration-status-CPamfEPj.mjs.map +0 -1
  152. package/dist/migrations-BIsjFjSV.mjs.map +0 -1
  153. package/dist/result-handler-AFK4hxyX.mjs.map +0 -1
  154. package/dist/terminal-ui-C5k88MmW.mjs.map +0 -1
  155. package/src/commands/migration-emit.ts +0 -134
  156. package/src/lib/migration-emit.ts +0 -125
  157. package/src/lib/migration-strategy.ts +0 -49
@@ -1,7 +1,8 @@
1
+ import { verifyMigrationBundle } from '@prisma-next/migration-tools/attestation';
1
2
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
2
3
  import { findPathWithDecision } from '@prisma-next/migration-tools/dag';
3
4
  import { readRefs, resolveRef } from '@prisma-next/migration-tools/refs';
4
- import type { AttestedMigrationBundle } from '@prisma-next/migration-tools/types';
5
+ import type { MigrationBundle } from '@prisma-next/migration-tools/types';
5
6
  import { MigrationToolsError } from '@prisma-next/migration-tools/types';
6
7
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
7
8
  import { Command } from 'commander';
@@ -63,7 +64,7 @@ export interface MigrationApplyResult {
63
64
  readonly refName?: string;
64
65
  readonly selectedPath: readonly {
65
66
  readonly dirName: string;
66
- readonly migrationId: string | null;
67
+ readonly migrationId: string;
67
68
  readonly from: string;
68
69
  readonly to: string;
69
70
  }[];
@@ -94,7 +95,7 @@ function mapApplyFailure(failure: MigrationApplyFailure): CliStructuredErrorType
94
95
  });
95
96
  }
96
97
 
97
- function packageToStep(pkg: AttestedMigrationBundle): MigrationApplyStep {
98
+ function packageToStep(pkg: MigrationBundle): MigrationApplyStep {
98
99
  return {
99
100
  dirName: pkg.dirName,
100
101
  from: pkg.manifest.from,
@@ -111,7 +112,7 @@ async function executeMigrationApplyCommand(
111
112
  startTime: number,
112
113
  ): Promise<Result<MigrationApplyResult, CliStructuredErrorType>> {
113
114
  const config = await loadConfig(options.config);
114
- const { configPath, migrationsDir, migrationsRelative, refsPath } = resolveMigrationPaths(
115
+ const { configPath, migrationsDir, migrationsRelative, refsDir } = resolveMigrationPaths(
115
116
  options.config,
116
117
  config,
117
118
  );
@@ -148,8 +149,8 @@ async function executeMigrationApplyCommand(
148
149
  if (options.ref) {
149
150
  refName = options.ref;
150
151
  try {
151
- const refs = await readRefs(refsPath);
152
- destinationHash = resolveRef(refs, refName);
152
+ const refs = await readRefs(refsDir);
153
+ destinationHash = resolveRef(refs, refName).hash;
153
154
  } catch (error) {
154
155
  if (MigrationToolsError.is(error)) {
155
156
  return notOk(mapMigrationToolsError(error));
@@ -198,12 +199,6 @@ async function executeMigrationApplyCommand(
198
199
  let migrations: MigrationBundleSet;
199
200
  try {
200
201
  migrations = await loadAllBundles(migrationsDir);
201
- if (migrations.drafts.length > 0 && !flags.quiet) {
202
- ui.warn(
203
- `${migrations.drafts.length} draft migration(s) found: ${migrations.drafts.map((d) => d.dirName).join(', ')}. ` +
204
- "Run 'prisma-next migration emit --dir <path>' to attest before applying.",
205
- );
206
- }
207
202
  } catch (error) {
208
203
  if (MigrationToolsError.is(error)) {
209
204
  return notOk(mapMigrationToolsError(error));
@@ -211,6 +206,27 @@ async function executeMigrationApplyCommand(
211
206
  throw error;
212
207
  }
213
208
 
209
+ // Defense in depth: re-hash every bundle and confirm the recorded
210
+ // `migrationId` matches the on-disk `(manifest, ops)`. Catches FS
211
+ // corruption, partial writes, and post-emit hand edits before we
212
+ // start touching the database.
213
+ for (const bundle of migrations.bundles) {
214
+ const verified = verifyMigrationBundle(bundle);
215
+ if (!verified.ok) {
216
+ return notOk(
217
+ errorRuntime(`Migration package is corrupt: ${bundle.dirName}`, {
218
+ why: `Stored migrationId "${verified.storedMigrationId}" does not match the recomputed hash "${verified.computedMigrationId}" for ${migrationsRelative}/${bundle.dirName}. The migration.json or ops.json has been edited or partially written since emit.`,
219
+ fix: `Re-emit the package by running \`node "${migrationsRelative}/${bundle.dirName}/migration.ts"\`, or restore the directory from version control.`,
220
+ meta: {
221
+ dirName: bundle.dirName,
222
+ storedMigrationId: verified.storedMigrationId,
223
+ computedMigrationId: verified.computedMigrationId,
224
+ },
225
+ }),
226
+ );
227
+ }
228
+ }
229
+
214
230
  const client = createControlClient({
215
231
  family: config.family,
216
232
  target: config.target,
@@ -223,12 +239,12 @@ async function executeMigrationApplyCommand(
223
239
  await client.connect(dbConnection);
224
240
  const marker = await client.readMarker();
225
241
 
226
- // --- No attested migrations on disk ---
227
- if (migrations.attested.length === 0) {
242
+ // --- No migrations on disk ---
243
+ if (migrations.bundles.length === 0) {
228
244
  if (marker?.storageHash) {
229
245
  return notOk(
230
246
  errorRuntime('Database has state but no migrations exist', {
231
- why: `The database marker hash "${marker.storageHash}" exists but no attested migrations were found in ${migrationsRelative}`,
247
+ why: `The database marker hash "${marker.storageHash}" exists but no migrations were found in ${migrationsRelative}`,
232
248
  fix: 'Ensure the migrations directory is correct. If the database was managed with `db init` or `db update`, run `prisma-next db sign` to update the marker.',
233
249
  meta: { markerHash: marker.storageHash, migrationsDir: migrationsRelative },
234
250
  }),
@@ -238,8 +254,8 @@ async function executeMigrationApplyCommand(
238
254
  if (destinationHash !== EMPTY_CONTRACT_HASH) {
239
255
  return notOk(
240
256
  errorRuntime('Current contract has no planned migrations', {
241
- why: `No attested migrations were found in ${migrationsRelative}, but current contract hash is "${destinationHash}"`,
242
- fix: 'Run `prisma-next migration plan` to create an attested migration for the current contract.',
257
+ why: `No migrations were found in ${migrationsRelative}, but current contract hash is "${destinationHash}"`,
258
+ fix: 'Run `prisma-next migration plan` to create a migration for the current contract.',
243
259
  meta: { destinationHash, migrationsDir: migrationsRelative },
244
260
  }),
245
261
  );
@@ -251,7 +267,7 @@ async function executeMigrationApplyCommand(
251
267
  migrationsTotal: 0,
252
268
  markerHash: EMPTY_CONTRACT_HASH,
253
269
  applied: [],
254
- summary: 'No attested migrations found',
270
+ summary: 'No migrations found',
255
271
  timings: { total: Date.now() - startTime },
256
272
  });
257
273
  }
@@ -283,15 +299,10 @@ async function executeMigrationApplyCommand(
283
299
  }
284
300
 
285
301
  if (!migrations.graph.nodes.has(destinationHash)) {
286
- const matchingDraft = migrations.drafts.find((d) => d.manifest.to === destinationHash);
287
302
  return notOk(
288
303
  errorRuntime('Current contract has no planned migration path', {
289
- why: matchingDraft
290
- ? `A draft migration exists at "${matchingDraft.dirName}" but has not been attested`
291
- : `Current contract hash "${destinationHash}" is not present in the migration history at ${migrationsRelative}`,
292
- fix: matchingDraft
293
- ? `Run 'prisma-next migration emit --dir "${migrationsRelative}/${matchingDraft.dirName}"' to attest, then re-run apply.`
294
- : 'Run `prisma-next migration plan` to create a migration for the current contract, then re-run apply.',
304
+ why: `Current contract hash "${destinationHash}" is not present in the migration history at ${migrationsRelative}`,
305
+ fix: 'Run `prisma-next migration plan` to create a migration for the current contract, then re-run apply.',
295
306
  meta: { destinationHash, knownNodes: [...migrations.graph.nodes] },
296
307
  }),
297
308
  );
@@ -329,7 +340,7 @@ async function executeMigrationApplyCommand(
329
340
  });
330
341
  }
331
342
 
332
- const bundleByDir = new Map(migrations.attested.map((b) => [b.dirName, b]));
343
+ const bundleByDir = new Map(migrations.bundles.map((b) => [b.dirName, b]));
333
344
  const pendingMigrations: MigrationApplyStep[] = [];
334
345
  for (const migration of pendingPath) {
335
346
  const pkg = bundleByDir.get(migration.dirName);
@@ -2,8 +2,7 @@
2
2
  * `migration new` — scaffolds a migration package with a `migration.ts` file
3
3
  * for manual authoring.
4
4
  *
5
- * Both descriptor-flow (Postgres) and class-flow (Mongo) targets go through
6
- * the same path here: the planner's `emptyMigration(context)` returns a
5
+ * The planner's `emptyMigration(context)` returns a
7
6
  * `MigrationPlanWithAuthoringSurface`, whose `renderTypeScript()` produces
8
7
  * the target-appropriate empty stub. The CLI writes the returned source
9
8
  * verbatim.
@@ -11,18 +10,20 @@
11
10
 
12
11
  import { readFileSync } from 'node:fs';
13
12
  import type { Contract } from '@prisma-next/contract/types';
13
+ import { getEmittedArtifactPaths } from '@prisma-next/emitter';
14
14
  import { createControlStack } from '@prisma-next/framework-components/control';
15
+ import { computeMigrationId } from '@prisma-next/migration-tools/attestation';
15
16
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
16
17
  import { findLatestMigration, reconstructGraph } from '@prisma-next/migration-tools/dag';
17
18
  import {
18
- copyContractToMigrationDir,
19
+ copyFilesWithRename,
19
20
  formatMigrationDirName,
20
21
  readMigrationsDir,
21
22
  writeMigrationPackage,
22
23
  } from '@prisma-next/migration-tools/io';
23
24
  import { writeMigrationTs } from '@prisma-next/migration-tools/migration-ts';
24
25
  import type { MigrationManifest } from '@prisma-next/migration-tools/types';
25
- import { isAttested, MigrationToolsError } from '@prisma-next/migration-tools/types';
26
+ import { MigrationToolsError } from '@prisma-next/migration-tools/types';
26
27
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
27
28
  import { Command } from 'commander';
28
29
  import { join, relative, resolve } from 'pathe';
@@ -116,16 +117,16 @@ async function executeMigrationNewCommand(
116
117
 
117
118
  let fromContract: Contract | null = null;
118
119
  let fromHash: string = EMPTY_CONTRACT_HASH;
120
+ let fromContractSourceDir: string | null = null;
119
121
 
120
122
  try {
121
123
  const packages = await readMigrationsDir(migrationsDir);
122
- const attested = packages.filter(isAttested);
123
124
 
124
- if (attested.length > 0) {
125
- const graph = reconstructGraph(attested);
125
+ if (packages.length > 0) {
126
+ const graph = reconstructGraph(packages);
126
127
 
127
128
  if (options.from) {
128
- const match = attested.find((p) => p.manifest.to.startsWith(options.from!));
129
+ const match = packages.find((p) => p.manifest.to.startsWith(options.from!));
129
130
  if (!match) {
130
131
  return notOk(
131
132
  errorRuntime('Starting contract not found', {
@@ -136,15 +137,17 @@ async function executeMigrationNewCommand(
136
137
  }
137
138
  fromHash = match.manifest.to;
138
139
  fromContract = match.manifest.toContract;
140
+ fromContractSourceDir = match.dirPath;
139
141
  } else {
140
142
  const latestMigration = findLatestMigration(graph);
141
143
  if (latestMigration) {
142
144
  fromHash = latestMigration.to;
143
- const leafPkg = attested.find(
145
+ const leafPkg = packages.find(
144
146
  (p) => p.manifest.migrationId === latestMigration.migrationId,
145
147
  );
146
148
  if (leafPkg) {
147
149
  fromContract = leafPkg.manifest.toContract;
150
+ fromContractSourceDir = leafPkg.dirPath;
148
151
  }
149
152
  }
150
153
  }
@@ -176,10 +179,13 @@ async function executeMigrationNewCommand(
176
179
  const dirName = formatMigrationDirName(timestamp, slug);
177
180
  const packageDir = join(migrationsDir, dirName);
178
181
 
179
- const manifest: MigrationManifest = {
182
+ // `migration new` scaffolds an empty `migration.ts` for the user to
183
+ // fill, so we attest over `ops: []`. Re-running self-emit after the
184
+ // user adds operations will produce a different `migrationId` (over
185
+ // the real ops). This is intentional — there is no on-disk draft.
186
+ const baseManifest: Omit<MigrationManifest, 'migrationId'> = {
180
187
  from: fromHash,
181
188
  to: toStorageHash,
182
- migrationId: null,
183
189
  kind: 'regular',
184
190
  fromContract,
185
191
  toContract: toContractJson,
@@ -187,11 +193,14 @@ async function executeMigrationNewCommand(
187
193
  used: [],
188
194
  applied: [],
189
195
  plannerVersion: '1.0.0',
190
- planningStrategy: 'manual',
191
196
  },
192
197
  labels: [],
193
198
  createdAt: timestamp.toISOString(),
194
199
  };
200
+ const manifest: MigrationManifest = {
201
+ ...baseManifest,
202
+ migrationId: computeMigrationId(baseManifest, []),
203
+ };
195
204
 
196
205
  const migrations = getTargetMigrations(config.target);
197
206
  if (!migrations) {
@@ -210,14 +219,27 @@ async function executeMigrationNewCommand(
210
219
  ]);
211
220
 
212
221
  await writeMigrationPackage(packageDir, manifest, []);
213
- await copyContractToMigrationDir(packageDir, contractPathAbsolute);
222
+ const destinationArtifacts = getEmittedArtifactPaths(contractPathAbsolute);
223
+ await copyFilesWithRename(packageDir, [
224
+ { sourcePath: destinationArtifacts.jsonPath, destName: 'end-contract.json' },
225
+ { sourcePath: destinationArtifacts.dtsPath, destName: 'end-contract.d.ts' },
226
+ ]);
227
+ if (fromContractSourceDir !== null) {
228
+ const sourceArtifacts = getEmittedArtifactPaths(
229
+ join(fromContractSourceDir, 'end-contract.json'),
230
+ );
231
+ await copyFilesWithRename(packageDir, [
232
+ { sourcePath: sourceArtifacts.jsonPath, destName: 'start-contract.json' },
233
+ { sourcePath: sourceArtifacts.dtsPath, destName: 'start-contract.d.ts' },
234
+ ]);
235
+ }
214
236
 
215
237
  const stack = createControlStack(config);
216
238
  const familyInstance = config.family.create(stack);
217
239
  const planner = migrations.createPlanner(familyInstance);
218
240
  const emptyPlan = planner.emptyMigration({
219
241
  packageDir,
220
- contractJsonPath: join(packageDir, 'contract.json'),
242
+ contractJsonPath: join(packageDir, 'end-contract.json'),
221
243
  fromHash,
222
244
  toHash: toStorageHash,
223
245
  });
@@ -248,8 +270,8 @@ export function createMigrationNewCommand(): Command {
248
270
  command,
249
271
  'Scaffold a new migration for manual authoring',
250
272
  'Creates a migration package with a migration.ts file for manual authoring.\n' +
251
- 'Write operation descriptors and data transforms in migration.ts, then run\n' +
252
- '`migration emit` to resolve and attest the package.',
273
+ 'Write the migration body in migration.ts, then run the file with Node\n' +
274
+ '(`node migration.ts`) to self-emit ops.json and attest the package.',
253
275
  );
254
276
  setCommandExamples(command, [
255
277
  'prisma-next migration new --name split-name',
@@ -283,7 +305,7 @@ export function createMigrationNewCommand(): Command {
283
305
  ui.output(` from: ${value.from}`);
284
306
  ui.output(` to: ${value.to}`);
285
307
  ui.output(
286
- `\nEdit migration.ts, then run \`prisma-next migration emit --dir "${value.dir}"\` to attest.`,
308
+ `\nEdit migration.ts, then run it directly (\`node "${value.dir}/migration.ts"\`) to self-emit and attest.`,
287
309
  );
288
310
  }
289
311
  });
@@ -1,10 +1,15 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import type { Contract } from '@prisma-next/contract/types';
3
- import { createControlStack } from '@prisma-next/framework-components/control';
3
+ import { getEmittedArtifactPaths } from '@prisma-next/emitter';
4
+ import {
5
+ createControlStack,
6
+ type MigrationPlanOperation,
7
+ } from '@prisma-next/framework-components/control';
8
+ import { computeMigrationId } from '@prisma-next/migration-tools/attestation';
4
9
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
5
10
  import { findLatestMigration } from '@prisma-next/migration-tools/dag';
6
11
  import {
7
- copyContractToMigrationDir,
12
+ copyFilesWithRename,
8
13
  formatMigrationDirName,
9
14
  writeMigrationPackage,
10
15
  } from '@prisma-next/migration-tools/io';
@@ -15,8 +20,6 @@ import { Command } from 'commander';
15
20
  import { join, relative } from 'pathe';
16
21
  import { loadConfig } from '../config-loader';
17
22
  import { extractSqlDdl } from '../control-api/operations/extract-sql-ddl';
18
- import { emitMigration } from '../lib/migration-emit';
19
- import { migrationStrategy } from '../lib/migration-strategy';
20
23
  import {
21
24
  type CliErrorConflict,
22
25
  CliStructuredError,
@@ -54,7 +57,6 @@ export interface MigrationPlanResult {
54
57
  readonly noOp: boolean;
55
58
  readonly from: string;
56
59
  readonly to: string;
57
- readonly migrationId?: string;
58
60
  readonly dir?: string;
59
61
  readonly operations: readonly {
60
62
  readonly id: string;
@@ -63,6 +65,12 @@ export interface MigrationPlanResult {
63
65
  }[];
64
66
  readonly sql?: readonly string[];
65
67
  readonly summary: string;
68
+ /**
69
+ * When true, `migration.ts` was written but contains unfilled
70
+ * `placeholder(...)` calls. The user must edit the file and then run
71
+ * `node migration.ts` to self-emit `ops.json` / `migration.json`.
72
+ */
73
+ readonly pendingPlaceholders?: boolean;
66
74
  readonly timings: {
67
75
  readonly total: number;
68
76
  };
@@ -166,20 +174,10 @@ async function executeMigrationPlanCommand(
166
174
  // Read existing migrations and determine "from" contract
167
175
  let fromContract: Contract | null = null;
168
176
  let fromHash: string = EMPTY_CONTRACT_HASH;
177
+ let fromContractSourceDir: string | null = null;
169
178
 
170
179
  try {
171
- const { attested: bundles, drafts, graph } = await loadAllBundles(migrationsDir);
172
-
173
- // Check if a draft migration already targets this contract
174
- const existingDraft = drafts.find((d) => d.manifest.to === toStorageHash);
175
- if (existingDraft) {
176
- return notOk(
177
- errorRuntime('A draft migration to this contract already exists', {
178
- why: `Draft migration at "${existingDraft.dirName}" already targets ${toStorageHash}`,
179
- fix: `Run 'prisma-next migration emit --dir ${migrationsRelative}/${existingDraft.dirName}' to attest it, or delete it and re-plan.`,
180
- }),
181
- );
182
- }
180
+ const { bundles, graph } = await loadAllBundles(migrationsDir);
183
181
 
184
182
  if (options.from) {
185
183
  const resolved = resolveBundleByPrefix(bundles, options.from);
@@ -199,6 +197,7 @@ async function executeMigrationPlanCommand(
199
197
  }
200
198
  fromHash = resolved.value.manifest.to;
201
199
  fromContract = resolved.value.manifest.toContract;
200
+ fromContractSourceDir = resolved.value.dirPath;
202
201
  } else {
203
202
  const latestMigration = findLatestMigration(graph);
204
203
  if (latestMigration) {
@@ -206,6 +205,7 @@ async function executeMigrationPlanCommand(
206
205
  const leafPkg = bundles.find((p) => p.manifest.migrationId === latestMigration.migrationId);
207
206
  if (leafPkg) {
208
207
  fromContract = leafPkg.manifest.toContract;
208
+ fromContractSourceDir = leafPkg.dirPath;
209
209
  }
210
210
  }
211
211
  }
@@ -245,18 +245,15 @@ async function executeMigrationPlanCommand(
245
245
  [config.target, config.adapter, ...(config.extensionPacks ?? [])],
246
246
  );
247
247
 
248
- const strategy = migrationStrategy(migrations, config.target.targetId);
249
-
250
248
  // Build manifest and write migration package
251
249
  const timestamp = new Date();
252
250
  const slug = options.name ?? 'migration';
253
251
  const dirName = formatMigrationDirName(timestamp, slug);
254
252
  const packageDir = join(migrationsDir, dirName);
255
253
 
256
- const manifest: MigrationManifest = {
254
+ const baseManifest: Omit<MigrationManifest, 'migrationId'> = {
257
255
  from: fromHash,
258
256
  to: toStorageHash,
259
- migrationId: null,
260
257
  kind: 'regular',
261
258
  fromContract,
262
259
  toContract: toContractJson,
@@ -264,41 +261,42 @@ async function executeMigrationPlanCommand(
264
261
  used: [],
265
262
  applied: [],
266
263
  plannerVersion: '2.0.0',
267
- planningStrategy: strategy === 'descriptor' ? 'descriptors' : 'class-based',
268
264
  },
269
265
  labels: [],
270
266
  createdAt: timestamp.toISOString(),
271
267
  };
272
268
 
273
- const scaffoldContext = {
274
- packageDir,
275
- contractJsonPath: contractPathAbsolute,
276
- fromHash,
277
- toHash: toStorageHash,
278
- };
279
-
280
269
  try {
281
- let migrationTsContent: string;
270
+ const stack = createControlStack(config);
271
+ const familyInstance = config.family.create(stack);
272
+ const planner = migrations.createPlanner(familyInstance);
273
+ const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents);
274
+ const plannerResult = planner.plan({
275
+ contract: toContractJson,
276
+ schema: fromSchema,
277
+ policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] },
278
+ fromHash,
279
+ fromContract,
280
+ frameworkComponents,
281
+ });
282
+ if (plannerResult.kind === 'failure') {
283
+ return notOk(
284
+ errorMigrationPlanningFailed({
285
+ conflicts: plannerResult.conflicts as readonly CliErrorConflict[],
286
+ }),
287
+ );
288
+ }
282
289
 
283
- if (strategy === 'descriptor') {
284
- if (!migrations.planWithDescriptors || !migrations.renderDescriptorTypeScript) {
285
- throw errorTargetMigrationNotSupported({
286
- why: `Target "${config.target.targetId}" advertises descriptor flow but is missing required hooks`,
287
- });
288
- }
289
- const descriptorResult = migrations.planWithDescriptors({
290
- fromContract,
291
- toContract: toContractJson,
292
- frameworkComponents,
293
- });
294
- if (!descriptorResult.ok) {
295
- return notOk(
296
- errorMigrationPlanningFailed({
297
- conflicts: descriptorResult.conflicts as readonly CliErrorConflict[],
298
- }),
299
- );
300
- }
301
- if (descriptorResult.descriptors.length === 0) {
290
+ // Accessing .operations triggers toOp() on each call. If any call
291
+ // is a DataTransformCall with an unfilled placeholder stub, toOp()
292
+ // throws PN-MIG-2001. We catch that here so the migration can still
293
+ // be scaffolded with `ops: []`; the user fills the placeholder, then
294
+ // re-runs `node migration.ts` to attest with the real ops.
295
+ let plannedOps: readonly MigrationPlanOperation[] = [];
296
+ let hasPlaceholders = false;
297
+ try {
298
+ plannedOps = plannerResult.plan.operations;
299
+ if (plannedOps.length === 0) {
302
300
  return notOk(
303
301
  errorMigrationPlanningFailed({
304
302
  conflicts: [
@@ -312,75 +310,74 @@ async function executeMigrationPlanCommand(
312
310
  }),
313
311
  );
314
312
  }
315
- migrationTsContent = migrations.renderDescriptorTypeScript(
316
- descriptorResult.descriptors,
317
- scaffoldContext,
318
- );
319
- } else {
320
- const stack = createControlStack(config);
321
- const familyInstance = config.family.create(stack);
322
- const planner = migrations.createPlanner(familyInstance);
323
- const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents);
324
- const plannerResult = planner.plan({
325
- contract: toContractJson,
326
- schema: fromSchema,
327
- policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] },
328
- fromHash,
329
- frameworkComponents,
330
- });
331
- if (plannerResult.kind === 'failure') {
332
- return notOk(
333
- errorMigrationPlanningFailed({
334
- conflicts: plannerResult.conflicts as readonly CliErrorConflict[],
335
- }),
336
- );
337
- }
338
- if (plannerResult.plan.operations.length === 0) {
339
- return notOk(
340
- errorMigrationPlanningFailed({
341
- conflicts: [
342
- {
343
- kind: 'unsupportedChange',
344
- summary:
345
- 'Contract changed but planner produced no operations. ' +
346
- 'This indicates unsupported or ignored changes.',
347
- },
348
- ],
349
- }),
350
- );
313
+ } catch (e) {
314
+ if (CliStructuredError.is(e) && e.domain === 'MIG' && e.code === '2001') {
315
+ hasPlaceholders = true;
316
+ } else {
317
+ throw e;
351
318
  }
352
- migrationTsContent = plannerResult.plan.renderTypeScript();
353
319
  }
354
320
 
355
- await writeMigrationPackage(packageDir, manifest, []);
356
- await copyContractToMigrationDir(packageDir, contractPathAbsolute);
321
+ const migrationTsContent = plannerResult.plan.renderTypeScript();
322
+
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;
327
+ // there is no on-disk "draft" state.
328
+ const opsForWrite = hasPlaceholders ? [] : plannedOps;
329
+ const manifest: MigrationManifest = {
330
+ ...baseManifest,
331
+ migrationId: computeMigrationId(baseManifest, opsForWrite),
332
+ };
333
+
334
+ await writeMigrationPackage(packageDir, manifest, opsForWrite);
335
+ const destinationArtifacts = getEmittedArtifactPaths(contractPathAbsolute);
336
+ await copyFilesWithRename(packageDir, [
337
+ { sourcePath: destinationArtifacts.jsonPath, destName: 'end-contract.json' },
338
+ { sourcePath: destinationArtifacts.dtsPath, destName: 'end-contract.d.ts' },
339
+ ]);
340
+ if (fromContractSourceDir !== null) {
341
+ const sourceArtifacts = getEmittedArtifactPaths(
342
+ join(fromContractSourceDir, 'end-contract.json'),
343
+ );
344
+ await copyFilesWithRename(packageDir, [
345
+ { sourcePath: sourceArtifacts.jsonPath, destName: 'start-contract.json' },
346
+ { sourcePath: sourceArtifacts.dtsPath, destName: 'start-contract.d.ts' },
347
+ ]);
348
+ }
357
349
  await writeMigrationTs(packageDir, migrationTsContent);
358
350
 
359
- // Always run emit inline. If migration.ts contains unfilled
360
- // placeholders (e.g. user must hand-author a dataTransform body),
361
- // emitMigration throws errorUnfilledPlaceholder (PN-MIG-2001) and
362
- // we propagate that structured error to the user.
363
- const { operations, migrationId } = await emitMigration(packageDir, {
364
- targetId: config.target.targetId,
365
- migrations,
366
- frameworkComponents,
367
- });
351
+ if (hasPlaceholders) {
352
+ const result: MigrationPlanResult = {
353
+ ok: true,
354
+ noOp: false,
355
+ from: fromHash,
356
+ to: toStorageHash,
357
+ dir: relative(process.cwd(), packageDir),
358
+ operations: [],
359
+ pendingPlaceholders: true,
360
+ summary:
361
+ 'Planned migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit',
362
+ timings: { total: Date.now() - startTime },
363
+ };
364
+ return ok(result);
365
+ }
368
366
 
369
- const sql = extractSqlDdl(operations);
367
+ const sql = extractSqlDdl(plannedOps);
370
368
  const result: MigrationPlanResult = {
371
369
  ok: true,
372
370
  noOp: false,
373
371
  from: fromHash,
374
372
  to: toStorageHash,
375
- migrationId,
376
373
  dir: relative(process.cwd(), packageDir),
377
- operations: operations.map((op) => ({
374
+ operations: plannedOps.map((op) => ({
378
375
  id: op.id,
379
376
  label: op.label,
380
377
  operationClass: op.operationClass,
381
378
  })),
382
379
  sql,
383
- summary: `Planned ${operations.length} operation(s)`,
380
+ summary: `Planned ${plannedOps.length} operation(s)`,
384
381
  timings: { total: Date.now() - startTime },
385
382
  };
386
383
  return ok(result);
@@ -442,6 +439,22 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
442
439
  return lines.join('\n');
443
440
  }
444
441
 
442
+ if (result.pendingPlaceholders) {
443
+ lines.push(`${yellow_('⚠')} ${result.summary}`);
444
+ lines.push('');
445
+ lines.push(dim_(`from: ${result.from}`));
446
+ lines.push(dim_(`to: ${result.to}`));
447
+ if (result.dir) {
448
+ lines.push(dim_(`dir: ${result.dir}`));
449
+ }
450
+ lines.push('');
451
+ lines.push(
452
+ 'Open migration.ts and replace each `placeholder(...)` call with your actual query.',
453
+ );
454
+ lines.push(`Then run: ${green_(`node ${result.dir ?? '<dir>'}/migration.ts`)}`);
455
+ return lines.join('\n');
456
+ }
457
+
445
458
  lines.push(`${green_('✔')} ${result.summary}`);
446
459
  lines.push('');
447
460
 
@@ -470,13 +483,15 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
470
483
 
471
484
  lines.push(dim_(`from: ${result.from}`));
472
485
  lines.push(dim_(`to: ${result.to}`));
473
- if (result.migrationId) {
474
- lines.push(dim_(`migrationId: ${result.migrationId}`));
475
- }
476
486
  if (result.dir) {
477
487
  lines.push(dim_(`dir: ${result.dir}`));
478
488
  }
479
489
 
490
+ lines.push('');
491
+ 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')}.`,
493
+ );
494
+
480
495
  if (result.sql && result.sql.length > 0) {
481
496
  lines.push('');
482
497
  lines.push(dim_('DDL preview'));