@prisma-next/cli 0.11.0-dev.56 → 0.11.0-dev.58

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 (90) hide show
  1. package/README.md +13 -9
  2. package/dist/{cli-errors-DFF1LlfU.mjs → cli-errors-DQY629C7.mjs} +16 -11
  3. package/dist/{cli-errors-DFF1LlfU.mjs.map → cli-errors-DQY629C7.mjs.map} +1 -1
  4. package/dist/cli.mjs +9 -9
  5. package/dist/{client-5uvDppD8.mjs → client-Ls2SAhrZ.mjs} +24 -6
  6. package/dist/{client-5uvDppD8.mjs.map → client-Ls2SAhrZ.mjs.map} +1 -1
  7. package/dist/{command-helpers-4UNsRRc4.mjs → command-helpers-DTpEJCgI.mjs} +2 -2
  8. package/dist/{command-helpers-4UNsRRc4.mjs.map → command-helpers-DTpEJCgI.mjs.map} +1 -1
  9. package/dist/commands/contract-emit.mjs +1 -1
  10. package/dist/commands/contract-infer.mjs +1 -1
  11. package/dist/commands/db-init.mjs +5 -5
  12. package/dist/commands/db-schema.mjs +3 -3
  13. package/dist/commands/db-sign.mjs +4 -4
  14. package/dist/commands/db-update.mjs +5 -5
  15. package/dist/commands/db-verify.mjs +1 -1
  16. package/dist/commands/migrate.d.mts +1 -1
  17. package/dist/commands/migrate.d.mts.map +1 -1
  18. package/dist/commands/migrate.mjs +34 -38
  19. package/dist/commands/migrate.mjs.map +1 -1
  20. package/dist/commands/migration-check.mjs +1 -1
  21. package/dist/commands/migration-graph.d.mts +1 -1
  22. package/dist/commands/migration-graph.mjs +3 -3
  23. package/dist/commands/migration-list.d.mts +2 -2
  24. package/dist/commands/migration-list.mjs +1 -1
  25. package/dist/commands/migration-log.mjs +3 -3
  26. package/dist/commands/migration-new.mjs +4 -4
  27. package/dist/commands/migration-plan.d.mts +1 -1
  28. package/dist/commands/migration-plan.d.mts.map +1 -1
  29. package/dist/commands/migration-plan.mjs +1 -1
  30. package/dist/commands/migration-show.d.mts +1 -1
  31. package/dist/commands/migration-show.mjs +4 -4
  32. package/dist/commands/migration-status.d.mts +1 -1
  33. package/dist/commands/migration-status.mjs +6 -6
  34. package/dist/commands/ref.d.mts +1 -1
  35. package/dist/commands/ref.mjs +2 -2
  36. package/dist/contract-at-errors-B98TC1wK.mjs +42 -0
  37. package/dist/contract-at-errors-B98TC1wK.mjs.map +1 -0
  38. package/dist/{contract-emit-C-CFGZsI.mjs → contract-emit-BWLCn2PH.mjs} +3 -3
  39. package/dist/{contract-emit-C-CFGZsI.mjs.map → contract-emit-BWLCn2PH.mjs.map} +1 -1
  40. package/dist/{contract-emit-CuUzzM46.mjs → contract-emit-CS3vF-w9.mjs} +4 -4
  41. package/dist/{contract-emit-CuUzzM46.mjs.map → contract-emit-CS3vF-w9.mjs.map} +1 -1
  42. package/dist/{contract-infer-C98ZaRhp.mjs → contract-infer-BtefFYF-.mjs} +3 -3
  43. package/dist/{contract-infer-C98ZaRhp.mjs.map → contract-infer-BtefFYF-.mjs.map} +1 -1
  44. package/dist/{contract-space-aggregate-loader-CVHGuA35.mjs → contract-space-aggregate-loader-DX_1n2SA.mjs} +2 -2
  45. package/dist/{contract-space-aggregate-loader-CVHGuA35.mjs.map → contract-space-aggregate-loader-DX_1n2SA.mjs.map} +1 -1
  46. package/dist/{db-verify-BWl1Yxi-.mjs → db-verify-aHw2nzH2.mjs} +5 -5
  47. package/dist/{db-verify-BWl1Yxi-.mjs.map → db-verify-aHw2nzH2.mjs.map} +1 -1
  48. package/dist/exports/control-api.d.mts +1 -1
  49. package/dist/exports/control-api.mjs +2 -2
  50. package/dist/exports/index.mjs +1 -1
  51. package/dist/exports/init-output.mjs +1 -1
  52. package/dist/{framework-components-DTcjouhS.mjs → framework-components-BwuEBcyk.mjs} +2 -2
  53. package/dist/{framework-components-DTcjouhS.mjs.map → framework-components-BwuEBcyk.mjs.map} +1 -1
  54. package/dist/{global-flags-DWsQ6SSI.d.mts → global-flags-Bo6nCRUS.d.mts} +1 -1
  55. package/dist/{global-flags-DWsQ6SSI.d.mts.map → global-flags-Bo6nCRUS.d.mts.map} +1 -1
  56. package/dist/{glyph-mode-CBB4emzO.d.mts → glyph-mode-VIjULGFF.d.mts} +1 -1
  57. package/dist/glyph-mode-VIjULGFF.d.mts.map +1 -0
  58. package/dist/{graph-render-D2FnLpuK.mjs → graph-render-eJDcLWny.mjs} +1 -1
  59. package/dist/{graph-render-D2FnLpuK.mjs.map → graph-render-eJDcLWny.mjs.map} +1 -1
  60. package/dist/{init-C7PvN163.mjs → init-DOE4Q9YK.mjs} +5 -5
  61. package/dist/{init-C7PvN163.mjs.map → init-DOE4Q9YK.mjs.map} +1 -1
  62. package/dist/{inspect-live-schema-BRCWQ-Sr.mjs → inspect-live-schema-IS8jWaJy.mjs} +4 -4
  63. package/dist/{inspect-live-schema-BRCWQ-Sr.mjs.map → inspect-live-schema-IS8jWaJy.mjs.map} +1 -1
  64. package/dist/{migration-check-DoskM1nB.mjs → migration-check-BFdael8w.mjs} +2 -2
  65. package/dist/{migration-check-DoskM1nB.mjs.map → migration-check-BFdael8w.mjs.map} +1 -1
  66. package/dist/{migration-command-scaffold-CXLkoIJx.mjs → migration-command-scaffold-DojkenVv.mjs} +4 -4
  67. package/dist/{migration-command-scaffold-CXLkoIJx.mjs.map → migration-command-scaffold-DojkenVv.mjs.map} +1 -1
  68. package/dist/{migration-list-B2-iQ5Jd.mjs → migration-list-hj86sCtZ.mjs} +3 -3
  69. package/dist/{migration-list-B2-iQ5Jd.mjs.map → migration-list-hj86sCtZ.mjs.map} +1 -1
  70. package/dist/{migration-plan-BqmIKQpZ.mjs → migration-plan-Bt6wxUIv.mjs} +165 -178
  71. package/dist/migration-plan-Bt6wxUIv.mjs.map +1 -0
  72. package/dist/{migration-types-q64xAI_J.d.mts → migration-types-D2FW63pr.d.mts} +1 -1
  73. package/dist/{migration-types-q64xAI_J.d.mts.map → migration-types-D2FW63pr.d.mts.map} +1 -1
  74. package/dist/{migrations-BcVTutso.mjs → migrations-CVLh0Kv4.mjs} +2 -2
  75. package/dist/{migrations-BcVTutso.mjs.map → migrations-CVLh0Kv4.mjs.map} +1 -1
  76. package/dist/{output-B60Gw5fu.mjs → output-CF_hqzI-.mjs} +1 -1
  77. package/dist/{output-B60Gw5fu.mjs.map → output-CF_hqzI-.mjs.map} +1 -1
  78. package/dist/{types-CEtm6v6a.d.mts → types-BuatV9YW.d.mts} +1 -1
  79. package/dist/{types-CEtm6v6a.d.mts.map → types-BuatV9YW.d.mts.map} +1 -1
  80. package/dist/{verify-DOHbbrub.mjs → verify-BiWm4XwD.mjs} +2 -2
  81. package/dist/{verify-DOHbbrub.mjs.map → verify-BiWm4XwD.mjs.map} +1 -1
  82. package/package.json +18 -18
  83. package/src/commands/migrate.ts +37 -36
  84. package/src/commands/migration-plan.ts +104 -120
  85. package/src/control-api/operations/migration-apply.ts +25 -4
  86. package/src/utils/cli-errors.ts +22 -12
  87. package/src/utils/contract-at-errors.ts +96 -0
  88. package/src/utils/plan-resolution.ts +134 -133
  89. package/dist/glyph-mode-CBB4emzO.d.mts.map +0 -1
  90. package/dist/migration-plan-BqmIKQpZ.mjs.map +0 -1
@@ -2,7 +2,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import type { Contract } from '@prisma-next/contract/types';
3
3
  import { getEmittedArtifactPaths } from '@prisma-next/emitter';
4
4
  import {
5
- type ControlFamilyInstance,
6
5
  createControlStack,
7
6
  hasOperationPreview,
8
7
  type MigrationPlanOperation,
@@ -36,20 +35,22 @@ import {
36
35
  import {
37
36
  addGlobalOptions,
38
37
  getTargetMigrations,
39
- loadMigrationPackages,
40
38
  resolveContractPath,
41
39
  resolveMigrationPaths,
42
40
  setCommandDescriptions,
43
41
  setCommandExamples,
44
42
  } from '../utils/command-helpers';
45
- import { buildContractSpaceAggregate } from '../utils/contract-space-aggregate-loader';
43
+ import {
44
+ buildContractSpaceAggregate,
45
+ loadContractSpaceAggregateForCli,
46
+ } from '../utils/contract-space-aggregate-loader';
46
47
  import { runContractSpaceSeedPhase } from '../utils/contract-space-seed-phase';
47
48
  import { toExtensionInputs } from '../utils/extension-pack-inputs';
48
49
  import { formatStyledHeader } from '../utils/formatters/styled';
49
50
  import { assertFrameworkComponentsCompatible } from '../utils/framework-components';
50
51
  import type { CommonCommandOptions } from '../utils/global-flags';
51
52
  import { type GlobalFlags, parseGlobalFlagsOrExit } from '../utils/global-flags';
52
- import { resolveFromForPlan } from '../utils/plan-resolution';
53
+ import { resolveFromForPlan, resolveToForPlan } from '../utils/plan-resolution';
53
54
  import { handleResult } from '../utils/result-handler';
54
55
  import { createTerminalUI, type TerminalUI } from '../utils/terminal-ui';
55
56
 
@@ -57,55 +58,7 @@ interface MigrationPlanOptions extends CommonCommandOptions {
57
58
  readonly config?: string;
58
59
  readonly name?: string;
59
60
  readonly from?: string;
60
- }
61
-
62
- /**
63
- * Load a predecessor migration's destination contract from its sibling
64
- * `end-contract.json` on disk and route it through the family's
65
- * `ContractSerializer` (via `deserializeContract`) so the in-memory shape
66
- * is the hydrated `Contract` every other caller sees. Bypassing this
67
- * seam was the root cause of TML-2536: a raw `JSON.parse(...) as Contract`
68
- * here let polymorphic `storage.types` entries reach the planner without
69
- * the `kind` discriminator the planner dispatches on.
70
- *
71
- * Throws `CliStructuredError` with:
72
- * - `errorFileNotFound` when the sibling file is missing — the user
73
- * has likely deleted or never authored the snapshot, and the
74
- * message names the file and points them at re-emitting from the
75
- * source.
76
- * - `errorContractValidationFailed` when the JSON parses but the
77
- * family deserializer rejects it (legacy untagged shape, structural
78
- * mismatch, etc.) — the message names the predecessor's path so
79
- * the operator can locate the bad snapshot.
80
- */
81
- async function readPredecessorEndContract(
82
- migrationDir: string,
83
- familyInstance: ControlFamilyInstance<string, unknown>,
84
- ): Promise<Contract> {
85
- const path = join(migrationDir, 'end-contract.json');
86
- let raw: string;
87
- try {
88
- raw = await readFile(path, 'utf-8');
89
- } catch (error) {
90
- if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
91
- throw errorFileNotFound(path, {
92
- why: `Predecessor migration is missing its destination contract snapshot at ${path}`,
93
- fix: 'Re-emit the predecessor migration (`prisma-next migration plan` from its source) so its sibling `end-contract.json` is restored, then re-run this command.',
94
- });
95
- }
96
- throw error;
97
- }
98
- try {
99
- return familyInstance.deserializeContract(JSON.parse(raw) as unknown);
100
- } catch (error) {
101
- if (CliStructuredError.is(error)) {
102
- throw error;
103
- }
104
- throw errorContractValidationFailed(
105
- `Predecessor contract at ${path} failed to deserialize: ${error instanceof Error ? error.message : String(error)}`,
106
- { where: { path } },
107
- );
108
- }
61
+ readonly to?: string;
109
62
  }
110
63
 
111
64
  async function writeSnapshotContractArtifacts(
@@ -282,6 +235,9 @@ async function executeMigrationPlanCommand(
282
235
  if (options.from) {
283
236
  details.push({ label: 'from', value: options.from });
284
237
  }
238
+ if (options.to) {
239
+ details.push({ label: 'to', value: options.to });
240
+ }
285
241
  if (options.name) {
286
242
  details.push({ label: 'name', value: options.name });
287
243
  }
@@ -315,8 +271,7 @@ async function executeMigrationPlanCommand(
315
271
  );
316
272
  }
317
273
 
318
- // Construct the family instance up-front so on-disk reads (the app
319
- // contract here + every `readPredecessorEndContract` below) cross the
274
+ // Construct the family instance up-front so on-disk contract reads cross the
320
275
  // serializer seam at the read site, not after the planner has already
321
276
  // started dispatching on raw shapes. See TML-2536.
322
277
  const stack = createControlStack(config);
@@ -342,9 +297,12 @@ async function executeMigrationPlanCommand(
342
297
  }),
343
298
  );
344
299
  }
345
- const toStorageHash = rawStorageHash;
300
+ let toStorageHash: string = rawStorageHash;
346
301
 
347
- const { refsDir } = resolveMigrationPaths(options.config, config);
302
+ // When `--to <ref>` resolves a non-default destination, these carry its raw
303
+ // artifacts so the planned package's `end-contract.*` is written from the
304
+ // resolved target rather than copied from the emitted `contract.json`.
305
+ let toArtifacts: { contractJson: unknown; contractDts: string } | null = null;
348
306
 
349
307
  let fromContract: Contract | null = null;
350
308
  let fromHash: string | null = null;
@@ -352,62 +310,71 @@ async function executeMigrationPlanCommand(
352
310
  let snapshotStartContract: { contractJson: unknown; contractDts: string } | null = null;
353
311
  let isAutoBaseline = false;
354
312
 
355
- try {
356
- const { bundles, graph } = await loadMigrationPackages(appMigrationsDir);
357
-
358
- const resolutionResult = await resolveFromForPlan({
359
- optionsFrom: options.from,
360
- refsDir,
361
- bundles,
362
- graph,
363
- familyInstance,
364
- readBundleEndContract: (migrationDir) =>
365
- readPredecessorEndContract(migrationDir, familyInstance),
366
- });
313
+ const tolerantAggregateResult = await loadContractSpaceAggregateForCli({
314
+ targetId: config.target.targetId,
315
+ migrationsDir,
316
+ appContract: toContract,
317
+ extensionPacks: config.extensionPacks ?? [],
318
+ deserializeContract: (json: unknown) => familyInstance.deserializeContract(json),
319
+ });
320
+ if (!tolerantAggregateResult.ok) {
321
+ return notOk(tolerantAggregateResult.failure);
322
+ }
323
+ const resolutionMember = tolerantAggregateResult.value.app;
367
324
 
368
- if (!resolutionResult.ok) {
369
- return notOk(resolutionResult.failure);
370
- }
325
+ const resolutionResult = await resolveFromForPlan({
326
+ optionsFrom: options.from,
327
+ member: resolutionMember,
328
+ });
371
329
 
372
- switch (resolutionResult.value.kind) {
373
- case 'greenfield':
374
- break;
375
- case 'graph-node':
376
- fromHash = resolutionResult.value.fromHash;
377
- fromContract = resolutionResult.value.fromContract;
378
- fromContractSourceDir = resolutionResult.value.sourceDir;
379
- break;
380
- case 'snapshot':
381
- fromHash = resolutionResult.value.fromHash;
382
- fromContract = resolutionResult.value.fromContract;
383
- snapshotStartContract = {
384
- contractJson: resolutionResult.value.contractJson,
385
- contractDts: resolutionResult.value.contractDts,
386
- };
387
- break;
388
- case 'auto-baseline':
389
- fromHash = resolutionResult.value.fromHash;
390
- fromContract = resolutionResult.value.fromContract;
391
- snapshotStartContract = {
392
- contractJson: resolutionResult.value.contractJson,
393
- contractDts: resolutionResult.value.contractDts,
394
- };
395
- isAutoBaseline = true;
396
- break;
397
- }
398
- } catch (error) {
399
- if (MigrationToolsError.is(error)) {
400
- return notOk(mapMigrationToolsError(error));
401
- }
402
- if (CliStructuredError.is(error)) {
403
- return notOk(error);
330
+ if (!resolutionResult.ok) {
331
+ return notOk(resolutionResult.failure);
332
+ }
333
+
334
+ switch (resolutionResult.value.kind) {
335
+ case 'greenfield':
336
+ break;
337
+ case 'graph-node':
338
+ fromHash = resolutionResult.value.fromHash;
339
+ fromContract = resolutionResult.value.fromContract;
340
+ fromContractSourceDir = resolutionResult.value.sourceDir;
341
+ break;
342
+ case 'snapshot':
343
+ fromHash = resolutionResult.value.fromHash;
344
+ fromContract = resolutionResult.value.fromContract;
345
+ snapshotStartContract = {
346
+ contractJson: resolutionResult.value.contractJson,
347
+ contractDts: resolutionResult.value.contractDts,
348
+ };
349
+ break;
350
+ case 'auto-baseline':
351
+ fromHash = resolutionResult.value.fromHash;
352
+ fromContract = resolutionResult.value.fromContract;
353
+ snapshotStartContract = {
354
+ contractJson: resolutionResult.value.contractJson,
355
+ contractDts: resolutionResult.value.contractDts,
356
+ };
357
+ isAutoBaseline = true;
358
+ break;
359
+ }
360
+
361
+ // `--to <ref>` swaps the planner destination to an arbitrary resolved
362
+ // contract (e.g. an ancestor / rollback target). The from-side resolution
363
+ // above is untouched; only the destination + its emitted `end-contract.*`
364
+ // change.
365
+ if (options.to !== undefined) {
366
+ const toResolution = await resolveToForPlan(options.to, {
367
+ member: resolutionMember,
368
+ });
369
+ if (!toResolution.ok) {
370
+ return notOk(toResolution.failure);
404
371
  }
405
- const message = error instanceof Error ? error.message : String(error);
406
- return notOk(
407
- errorUnexpected(message, {
408
- why: `Unexpected error while loading migrations: ${message}`,
409
- }),
410
- );
372
+ toContract = toResolution.value.contract;
373
+ toStorageHash = toResolution.value.hash;
374
+ toArtifacts = {
375
+ contractJson: toResolution.value.contractJson,
376
+ contractDts: toResolution.value.contractDts,
377
+ };
411
378
  }
412
379
 
413
380
  // Phase 1 — seed: unconditionally re-emit per-space pinned artefacts
@@ -487,6 +454,26 @@ async function executeMigrationPlanCommand(
487
454
  [config.target, config.adapter, ...(config.extensionPacks ?? [])],
488
455
  );
489
456
 
457
+ // Write the planned package's destination `end-contract.*`. With `--to`, the
458
+ // resolved target's raw artifacts are written; otherwise the emitted
459
+ // `contract.json` / `contract.d.ts` are copied verbatim (today's behaviour).
460
+ async function writeDestinationEndContract(packageDir: string): Promise<void> {
461
+ if (toArtifacts !== null) {
462
+ await writeSnapshotContractArtifacts(
463
+ packageDir,
464
+ toArtifacts.contractJson,
465
+ toArtifacts.contractDts,
466
+ 'end-contract',
467
+ );
468
+ return;
469
+ }
470
+ const destinationArtifacts = getEmittedArtifactPaths(contractPathAbsolute);
471
+ await copyFilesWithRename(packageDir, [
472
+ { sourcePath: destinationArtifacts.jsonPath, destName: 'end-contract.json' },
473
+ { sourcePath: destinationArtifacts.dtsPath, destName: 'end-contract.d.ts' },
474
+ ]);
475
+ }
476
+
490
477
  try {
491
478
  const planner = migrations.createPlanner(familyInstance);
492
479
 
@@ -591,11 +578,7 @@ async function executeMigrationPlanCommand(
591
578
  deltaTimestamp,
592
579
  deltaLeg.value,
593
580
  );
594
- const destinationArtifacts = getEmittedArtifactPaths(contractPathAbsolute);
595
- await copyFilesWithRename(deltaPackageDir, [
596
- { sourcePath: destinationArtifacts.jsonPath, destName: 'end-contract.json' },
597
- { sourcePath: destinationArtifacts.dtsPath, destName: 'end-contract.d.ts' },
598
- ]);
581
+ await writeDestinationEndContract(deltaPackageDir);
599
582
  await writeSnapshotStartContract(
600
583
  deltaPackageDir,
601
584
  snapshotStartContract.contractJson,
@@ -668,11 +651,7 @@ async function executeMigrationPlanCommand(
668
651
  timestamp,
669
652
  deltaLeg.value,
670
653
  );
671
- const destinationArtifacts = getEmittedArtifactPaths(contractPathAbsolute);
672
- await copyFilesWithRename(packageDir, [
673
- { sourcePath: destinationArtifacts.jsonPath, destName: 'end-contract.json' },
674
- { sourcePath: destinationArtifacts.dtsPath, destName: 'end-contract.d.ts' },
675
- ]);
654
+ await writeDestinationEndContract(packageDir);
676
655
  if (fromContractSourceDir !== null) {
677
656
  const sourceArtifacts = getEmittedArtifactPaths(
678
657
  join(fromContractSourceDir, 'end-contract.json'),
@@ -755,6 +734,7 @@ export function createMigrationPlanCommand(): Command {
755
734
  setCommandExamples(command, [
756
735
  'prisma-next migration plan',
757
736
  'prisma-next migration plan --name add-users-table',
737
+ 'prisma-next migration plan --to <migration-dir>^ --name rollback',
758
738
  ]);
759
739
  addGlobalOptions(command)
760
740
  .option('--config <path>', 'Path to prisma-next.config.ts')
@@ -763,6 +743,10 @@ export function createMigrationPlanCommand(): Command {
763
743
  '--from <contract>',
764
744
  'Starting contract reference (hash, prefix, ref name, migration dir name, <dir>^, or ./path)',
765
745
  )
746
+ .option(
747
+ '--to <contract>',
748
+ 'Destination contract reference (hash, prefix, ref name, migration dir name, <dir>^, or ./path); defaults to the emitted contract',
749
+ )
766
750
  .action(async (options: MigrationPlanOptions) => {
767
751
  const flags = parseGlobalFlagsOrExit(options);
768
752
  const startTime = Date.now();
@@ -450,16 +450,37 @@ function buildSuccess(args: BuildSuccessArgs): MigrationApplySuccess {
450
450
  };
451
451
  }
452
452
 
453
- function buildNeverPlannedFailure(spaceId: string, targetHash: string): MigrationApplyFailure {
453
+ /**
454
+ * Build the `neverPlanned` failure raised when a contract space has no on-disk
455
+ * migration graph but migrate was asked to reach a target hash. The `why`
456
+ * states only the condition; the recovery sequence is composed by
457
+ * `errorPathUnreachable`'s `fix`.
458
+ *
459
+ * @internal Exported for testing only.
460
+ */
461
+ export function buildNeverPlannedFailure(
462
+ spaceId: string,
463
+ targetHash: string,
464
+ ): MigrationApplyFailure {
454
465
  return {
455
466
  code: 'MIGRATION_PATH_NOT_FOUND',
456
467
  summary: `No on-disk migrations for contract space "${spaceId}"`,
457
- why: `migrate is replay-only: every contract space must have an authored migration graph on disk. Space "${spaceId}" has no migrations under \`migrations/${spaceId}/\` but its head ref targets "${targetHash}". Run \`prisma-next migration plan\` first to materialise the path.`,
468
+ why: `migrate is replay-only: every contract space must have an authored migration graph on disk. Space "${spaceId}" has no migrations under \`migrations/${spaceId}/\` but its head ref targets "${targetHash}".`,
458
469
  meta: { spaceId, target: targetHash, kind: 'neverPlanned' },
459
470
  };
460
471
  }
461
472
 
462
- function buildPathNotFoundFailure(
473
+ /**
474
+ * Build the `pathUnreachable` failure raised when an emitted contract has no
475
+ * on-disk migration edge from the current marker to the target. The `why`
476
+ * states only the condition (no edge between the two named states, and migrate
477
+ * replays edges rather than inventing them); the recovery sequence — plan the
478
+ * edge, then re-apply — is composed by `errorPathUnreachable`'s `fix`, so the
479
+ * two read as one non-redundant plan-then-apply story.
480
+ *
481
+ * @internal Exported for testing only.
482
+ */
483
+ export function buildPathNotFoundFailure(
463
484
  spaceId: string,
464
485
  marker: ContractMarkerRecordLike | null,
465
486
  targetHash: string,
@@ -477,7 +498,7 @@ function buildPathNotFoundFailure(
477
498
  return {
478
499
  code: 'MIGRATION_PATH_NOT_FOUND',
479
500
  summary,
480
- why: `Cannot reach target "${targetHash}" from current marker "${fromHash}" in space "${spaceId}". The on-disk migration graph for this space does not connect the two states. Run \`prisma-next migration plan\` to materialise the path.`,
501
+ why: `No migration edge connects the current state "${fromHash}" to the target "${targetHash}" in contract space "${spaceId}". The on-disk migration graph does not join the two, and migrate replays existing edges it never invents one.`,
481
502
  meta: { spaceId, fromHash, targetHash, kind: 'pathUnreachable' },
482
503
  };
483
504
  }
@@ -252,7 +252,9 @@ export function errorMarkerMismatch(
252
252
 
253
253
  export function errorPathUnreachable(failure: MigrationApplyFailure): CliStructuredError {
254
254
  const meta = failure.meta ?? {};
255
- const fromHash = typeof meta['fromHash'] === 'string' ? meta['fromHash'] : null;
255
+ const fromHashMeta = typeof meta['fromHash'] === 'string' ? meta['fromHash'] : null;
256
+ // `buildPathNotFoundFailure` uses this sentinel in meta when the live marker is null.
257
+ const planFromHash = fromHashMeta === '<empty>' ? null : fromHashMeta;
256
258
  const targetHash =
257
259
  typeof meta['targetHash'] === 'string'
258
260
  ? meta['targetHash']
@@ -264,26 +266,34 @@ export function errorPathUnreachable(failure: MigrationApplyFailure): CliStructu
264
266
  Array.isArray(deadEnds) && deadEnds.length > 0
265
267
  ? ` Dead-ends: ${deadEnds.map(String).join(', ')}.`
266
268
  : '';
267
- const planFix = (() => {
268
- if (fromHash !== null && targetHash !== null) {
269
- return `Run \`prisma-next migration plan --from ${fromHash} --to ${targetHash}\` to introduce the missing path.`;
269
+ // Plan-then-apply recovery. The planner destination is the missing edge's
270
+ // target; `migration plan --to` (built for arbitrary targets) makes this a
271
+ // real command, so the diagnostic that sends you here is now honest.
272
+ const planCommand = (() => {
273
+ if (planFromHash !== null && targetHash !== null) {
274
+ return `prisma-next migration plan --from ${planFromHash} --to ${targetHash} --name <slug>`;
270
275
  }
271
276
  if (targetHash !== null) {
272
- return `Run \`prisma-next migration plan --to ${targetHash}\` to introduce the missing path.`;
277
+ return `prisma-next migration plan --to ${targetHash} --name <slug>`;
273
278
  }
274
- if (fromHash !== null) {
275
- return `Run \`prisma-next migration plan --from ${fromHash}\` to introduce the missing path.`;
279
+ if (planFromHash !== null) {
280
+ return `prisma-next migration plan --from ${planFromHash} --name <slug>`;
276
281
  }
277
- return 'Run `prisma-next migration plan` to introduce the missing path.';
282
+ return 'prisma-next migration plan';
278
283
  })();
284
+ const applyCommand =
285
+ targetHash !== null ? `prisma-next migrate --to ${targetHash}` : 'prisma-next migrate';
279
286
  return errorRuntime(failure.summary, {
280
287
  why:
281
288
  failure.why ??
282
- `Cannot reach target "${targetHash ?? '<unknown>'}" from current marker "${fromHash ?? '<unknown>'}".${deadEndsSuffix}`,
289
+ `Cannot reach target "${targetHash ?? '<unknown>'}" from current marker "${fromHashMeta ?? '<unknown>'}".${deadEndsSuffix}`,
283
290
  fix: [
284
- 'Run `prisma-next migration list` to see the on-disk graph.',
285
- planFix,
286
- 'Run `prisma-next migration show <bundle>` for any bundle in the path you expected.',
291
+ 'Plan the missing edge, then apply it:',
292
+ ` 1. ${planCommand}`,
293
+ ` 2. ${applyCommand}`,
294
+ 'A rollback (reverse) plan is expected to contain destructive (DROP) operations — review them before applying.',
295
+ 'Narrower cases (rename inference, re-adding a NOT NULL column without a safe default, or a type change that needs data) may additionally need a hint in the planned migration.',
296
+ 'Inspect the on-disk graph with `prisma-next migration list`, or `prisma-next migration show <bundle>` for any bundle in the path you expected.',
287
297
  ].join('\n'),
288
298
  meta: {
289
299
  ...meta,
@@ -0,0 +1,96 @@
1
+ import { MigrationToolsError } from '@prisma-next/migration-tools/errors';
2
+ import { notOk, type Result } from '@prisma-next/utils/result';
3
+ import { join } from 'pathe';
4
+ import {
5
+ CliStructuredError,
6
+ errorContractValidationFailed,
7
+ errorFileNotFound,
8
+ errorSnapshotMissing,
9
+ errorUnexpected,
10
+ mapMigrationToolsError,
11
+ } from './cli-errors';
12
+
13
+ export function mapContractAtError(
14
+ error: unknown,
15
+ options?: { readonly artifactRole?: 'from' | 'to' },
16
+ ): Result<never, CliStructuredError> {
17
+ if (MigrationToolsError.is(error)) {
18
+ switch (error.code) {
19
+ case 'MIGRATION.SNAPSHOT_MISSING': {
20
+ const refName =
21
+ typeof error.details?.['refName'] === 'string'
22
+ ? error.details['refName']
23
+ : typeof error.details?.['identifier'] === 'string'
24
+ ? error.details['identifier']
25
+ : 'unknown';
26
+ return notOk(errorSnapshotMissing(refName));
27
+ }
28
+ case 'MIGRATION.CONTRACT_DESERIALIZATION_FAILED': {
29
+ const filePath =
30
+ typeof error.details?.['filePath'] === 'string'
31
+ ? error.details['filePath']
32
+ : 'ref-snapshot';
33
+ const message =
34
+ typeof error.details?.['message'] === 'string' ? error.details['message'] : error.message;
35
+ const isRefSnapshot = filePath.endsWith('.contract.json');
36
+ return notOk(
37
+ errorContractValidationFailed(
38
+ isRefSnapshot
39
+ ? `Ref snapshot contract failed to deserialize: ${message}`
40
+ : `Predecessor contract at ${filePath} failed to deserialize: ${message}`,
41
+ { where: { path: isRefSnapshot ? 'ref-snapshot' : filePath } },
42
+ ),
43
+ );
44
+ }
45
+ case 'MIGRATION.INVALID_JSON': {
46
+ const filePath =
47
+ typeof error.details?.['filePath'] === 'string' ? error.details['filePath'] : 'unknown';
48
+ const message =
49
+ typeof error.details?.['parseError'] === 'string'
50
+ ? error.details['parseError']
51
+ : error.message;
52
+ const role = options?.artifactRole ?? 'from';
53
+ return notOk(
54
+ errorContractValidationFailed(
55
+ role === 'to'
56
+ ? `Target contract at ${filePath} failed to deserialize: ${message}`
57
+ : `Predecessor contract at ${filePath} failed to deserialize: ${message}`,
58
+ { where: { path: filePath } },
59
+ ),
60
+ );
61
+ }
62
+ case 'MIGRATION.BUNDLE_NOT_FOUND_FOR_GRAPH_NODE':
63
+ return notOk(
64
+ errorUnexpected(error.message, {
65
+ why: error.why,
66
+ fix: error.fix,
67
+ }),
68
+ );
69
+ case 'MIGRATION.FILE_MISSING': {
70
+ const file =
71
+ typeof error.details?.['file'] === 'string' ? error.details['file'] : 'end-contract.json';
72
+ const dir = typeof error.details?.['dir'] === 'string' ? error.details['dir'] : '';
73
+ const jsonPath = dir ? join(dir, 'end-contract.json') : file;
74
+ const role = options?.artifactRole ?? 'from';
75
+ return notOk(
76
+ errorFileNotFound(jsonPath, {
77
+ why:
78
+ role === 'to'
79
+ ? `Target migration is missing its destination contract snapshot at ${jsonPath}`
80
+ : `Predecessor migration is missing its destination contract snapshot at ${jsonPath}`,
81
+ fix:
82
+ role === 'to'
83
+ ? 'Re-emit the target migration so its sibling `end-contract.json` / `end-contract.d.ts` are restored, then re-run this command.'
84
+ : 'Re-emit the predecessor migration (`prisma-next migration plan` from its source) so its sibling `end-contract.json` is restored, then re-run this command.',
85
+ }),
86
+ );
87
+ }
88
+ default:
89
+ return notOk(mapMigrationToolsError(error));
90
+ }
91
+ }
92
+ if (CliStructuredError.is(error)) {
93
+ return notOk(error);
94
+ }
95
+ throw error;
96
+ }