@prisma-next/migration-tools 0.3.0-dev.85 → 0.3.0-dev.87

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.
package/src/dag.ts CHANGED
@@ -1,18 +1,19 @@
1
1
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/core-control-plane/constants';
2
+ import { ifDefined } from '@prisma-next/utils/defined';
2
3
  import {
3
4
  errorAmbiguousLeaf,
4
5
  errorDuplicateMigrationId,
6
+ errorNoResolvableLeaf,
5
7
  errorNoRoot,
6
8
  errorSelfLoop,
7
9
  } from './errors';
8
- import type { MigrationChainEntry, MigrationGraph, MigrationPackage } from './types';
10
+ import type { AttestedMigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
9
11
 
10
- export function reconstructGraph(packages: readonly MigrationPackage[]): MigrationGraph {
12
+ export function reconstructGraph(packages: readonly AttestedMigrationBundle[]): MigrationGraph {
11
13
  const nodes = new Set<string>();
12
14
  const forwardChain = new Map<string, MigrationChainEntry[]>();
13
15
  const reverseChain = new Map<string, MigrationChainEntry[]>();
14
16
  const migrationById = new Map<string, MigrationChainEntry>();
15
- const childrenByParentId = new Map<string | null, MigrationChainEntry[]>();
16
17
 
17
18
  for (const pkg of packages) {
18
19
  const { from, to } = pkg.manifest;
@@ -28,7 +29,6 @@ export function reconstructGraph(packages: readonly MigrationPackage[]): Migrati
28
29
  from,
29
30
  to,
30
31
  migrationId: pkg.manifest.migrationId,
31
- parentMigrationId: pkg.manifest.parentMigrationId,
32
32
  dirName: pkg.dirName,
33
33
  createdAt: pkg.manifest.createdAt,
34
34
  labels: pkg.manifest.labels,
@@ -41,14 +41,6 @@ export function reconstructGraph(packages: readonly MigrationPackage[]): Migrati
41
41
  migrationById.set(migration.migrationId, migration);
42
42
  }
43
43
 
44
- const parentId = migration.parentMigrationId;
45
- const siblings = childrenByParentId.get(parentId);
46
- if (siblings) {
47
- siblings.push(migration);
48
- } else {
49
- childrenByParentId.set(parentId, [migration]);
50
- }
51
-
52
44
  const fwd = forwardChain.get(from);
53
45
  if (fwd) {
54
46
  fwd.push(migration);
@@ -64,128 +56,287 @@ export function reconstructGraph(packages: readonly MigrationPackage[]): Migrati
64
56
  }
65
57
  }
66
58
 
67
- return { nodes, forwardChain, reverseChain, migrationById, childrenByParentId };
59
+ return { nodes, forwardChain, reverseChain, migrationById };
68
60
  }
69
61
 
70
- /**
71
- * Walk the parent-migration chain to find the latest migration.
72
- * Returns the migration with no children, or null for an empty graph.
73
- * Throws AMBIGUOUS_LEAF if the chain branches.
74
- */
75
- export function findLatestMigration(graph: MigrationGraph): MigrationChainEntry | null {
76
- if (graph.nodes.size === 0) {
77
- return null;
78
- }
62
+ const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
79
63
 
80
- const roots = graph.childrenByParentId.get(null);
81
- if (!roots || roots.length === 0) {
82
- throw errorNoRoot([...graph.nodes].sort());
64
+ function labelPriority(labels: readonly string[]): number {
65
+ let best = 3;
66
+ for (const l of labels) {
67
+ const p = LABEL_PRIORITY[l];
68
+ if (p !== undefined && p < best) best = p;
83
69
  }
70
+ return best;
71
+ }
84
72
 
85
- if (roots.length > 1) {
86
- throw errorAmbiguousLeaf(roots.map((e) => e.to));
87
- }
73
+ function sortedNeighbors(edges: readonly MigrationChainEntry[]): readonly MigrationChainEntry[] {
74
+ return [...edges].sort((a, b) => {
75
+ const lp = labelPriority(a.labels) - labelPriority(b.labels);
76
+ if (lp !== 0) return lp;
77
+ const ca = a.createdAt.localeCompare(b.createdAt);
78
+ if (ca !== 0) return ca;
79
+ const tc = a.to.localeCompare(b.to);
80
+ if (tc !== 0) return tc;
81
+ return (a.migrationId ?? '').localeCompare(b.migrationId ?? '');
82
+ });
83
+ }
88
84
 
89
- let current = roots[0];
90
- if (!current) {
91
- throw errorNoRoot([...graph.nodes].sort());
92
- }
85
+ /**
86
+ * Find the shortest path from `fromHash` to `toHash` using BFS over the
87
+ * contract-hash graph. Returns the ordered list of edges, or null if no path
88
+ * exists. Returns an empty array when `fromHash === toHash` (no-op).
89
+ *
90
+ * Neighbor ordering is deterministic via the tie-break sort key:
91
+ * label priority → createdAt → to → migrationId.
92
+ */
93
+ export function findPath(
94
+ graph: MigrationGraph,
95
+ fromHash: string,
96
+ toHash: string,
97
+ ): readonly MigrationChainEntry[] | null {
98
+ if (fromHash === toHash) return [];
93
99
 
94
- for (let depth = 0; depth < graph.migrationById.size + 1 && current; depth++) {
95
- const children: readonly MigrationChainEntry[] | undefined =
96
- current.migrationId !== null ? graph.childrenByParentId.get(current.migrationId) : undefined;
100
+ const visited = new Set<string>();
101
+ const parent = new Map<string, { node: string; edge: MigrationChainEntry }>();
102
+ const queue: string[] = [fromHash];
103
+ visited.add(fromHash);
97
104
 
98
- if (!children || children.length === 0) {
99
- return current;
105
+ while (queue.length > 0) {
106
+ const current = queue.shift();
107
+ if (current === undefined) break;
108
+
109
+ if (current === toHash) {
110
+ const path: MigrationChainEntry[] = [];
111
+ let node = toHash;
112
+ let entry = parent.get(node);
113
+ while (entry) {
114
+ const { node: prev, edge } = entry;
115
+ path.push(edge);
116
+ node = prev;
117
+ entry = parent.get(node);
118
+ }
119
+ path.reverse();
120
+ return path;
100
121
  }
101
122
 
102
- if (children.length > 1) {
103
- throw errorAmbiguousLeaf(children.map((e) => e.to));
104
- }
123
+ const outgoing = graph.forwardChain.get(current);
124
+ if (!outgoing) continue;
105
125
 
106
- current = children[0];
126
+ for (const edge of sortedNeighbors(outgoing)) {
127
+ if (!visited.has(edge.to)) {
128
+ visited.add(edge.to);
129
+ parent.set(edge.to, { node: current, edge });
130
+ queue.push(edge.to);
131
+ }
132
+ }
107
133
  }
108
134
 
109
- throw errorNoRoot([...graph.nodes].sort());
135
+ return null;
136
+ }
137
+
138
+ export interface PathDecision {
139
+ readonly selectedPath: readonly MigrationChainEntry[];
140
+ readonly fromHash: string;
141
+ readonly toHash: string;
142
+ readonly alternativeCount: number;
143
+ readonly tieBreakReasons: readonly string[];
144
+ readonly refName?: string;
110
145
  }
111
146
 
112
147
  /**
113
- * Find the leaf contract hash of the migration chain.
114
- * Convenience wrapper around findLatestMigration.
148
+ * Find the shortest path from `fromHash` to `toHash` and return structured
149
+ * path-decision metadata for machine-readable output.
115
150
  */
116
- export function findLeaf(graph: MigrationGraph): string {
117
- const migration = findLatestMigration(graph);
118
- return migration ? migration.to : EMPTY_CONTRACT_HASH;
151
+ export function findPathWithDecision(
152
+ graph: MigrationGraph,
153
+ fromHash: string,
154
+ toHash: string,
155
+ refName?: string,
156
+ ): PathDecision | null {
157
+ if (fromHash === toHash) {
158
+ return {
159
+ selectedPath: [],
160
+ fromHash,
161
+ toHash,
162
+ alternativeCount: 0,
163
+ tieBreakReasons: [],
164
+ ...ifDefined('refName', refName),
165
+ };
166
+ }
167
+
168
+ const path = findPath(graph, fromHash, toHash);
169
+ if (!path) return null;
170
+
171
+ const tieBreakReasons: string[] = [];
172
+ let alternativeCount = 0;
173
+
174
+ for (const edge of path) {
175
+ const outgoing = graph.forwardChain.get(edge.from);
176
+ if (outgoing && outgoing.length > 1) {
177
+ const reachable = outgoing.filter((e) => {
178
+ const pathFromE = findPath(graph, e.to, toHash);
179
+ return pathFromE !== null || e.to === toHash;
180
+ });
181
+ if (reachable.length > 1) {
182
+ alternativeCount += reachable.length - 1;
183
+ const sorted = sortedNeighbors(reachable);
184
+ if (sorted[0] && sorted[0].migrationId === edge.migrationId) {
185
+ if (reachable.some((e) => e.migrationId !== edge.migrationId)) {
186
+ tieBreakReasons.push(
187
+ `at ${edge.from}: ${reachable.length} candidates, selected by tie-break`,
188
+ );
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ return {
196
+ selectedPath: path,
197
+ fromHash,
198
+ toHash,
199
+ alternativeCount,
200
+ tieBreakReasons,
201
+ ...ifDefined('refName', refName),
202
+ };
119
203
  }
120
204
 
121
205
  /**
122
- * Find the ordered chain of migrations from `fromHash` to `toHash` by walking the
123
- * parent-migration chain. Returns the sub-sequence of migrations whose cumulative path
124
- * goes from `fromHash` to `toHash`.
125
- *
126
- * This reconstructs the full chain from root to leaf via parent pointers, then
127
- * extracts the segment between the two hashes. This correctly handles revisited
128
- * contract hashes (e.g. A→B→A) because it operates on migrations, not nodes.
206
+ * Walk ancestors of each leaf back from the leaves to find the last node
207
+ * that appears on all paths. Returns `fromHash` if no shared ancestor is found.
129
208
  */
130
- export function findPath(
209
+ function findDivergencePoint(
131
210
  graph: MigrationGraph,
132
211
  fromHash: string,
133
- toHash: string,
134
- ): readonly MigrationChainEntry[] | null {
135
- if (fromHash === toHash) return [];
212
+ leaves: readonly string[],
213
+ ): string {
214
+ const ancestorSets = leaves.map((leaf) => {
215
+ const ancestors = new Set<string>();
216
+ const queue = [leaf];
217
+ while (queue.length > 0) {
218
+ const current = queue.shift() as string;
219
+ if (ancestors.has(current)) continue;
220
+ ancestors.add(current);
221
+ const incoming = graph.reverseChain.get(current);
222
+ if (incoming) {
223
+ for (const edge of incoming) {
224
+ queue.push(edge.from);
225
+ }
226
+ }
227
+ }
228
+ return ancestors;
229
+ });
136
230
 
137
- const chain = buildChain(graph);
138
- if (!chain) return null;
231
+ const commonAncestors = [...(ancestorSets[0] ?? [])].filter((node) =>
232
+ ancestorSets.every((s) => s.has(node)),
233
+ );
139
234
 
140
- let startIdx = -1;
141
- if (chain.length > 0 && chain[0]?.from === fromHash) {
142
- startIdx = 0;
143
- } else {
144
- for (let i = chain.length - 1; i >= 0; i--) {
145
- if (chain[i]?.to === fromHash) {
146
- startIdx = i + 1;
147
- break;
235
+ let deepest = fromHash;
236
+ let deepestDepth = -1;
237
+ for (const ancestor of commonAncestors) {
238
+ const path = findPath(graph, fromHash, ancestor);
239
+ const depth = path ? path.length : 0;
240
+ if (depth > deepestDepth) {
241
+ deepestDepth = depth;
242
+ deepest = ancestor;
243
+ }
244
+ }
245
+ return deepest;
246
+ }
247
+
248
+ /**
249
+ * Find all leaf nodes reachable from `fromHash` via forward edges.
250
+ * A leaf is a node with no outgoing edges in the graph.
251
+ */
252
+ export function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[] {
253
+ const visited = new Set<string>();
254
+ const queue: string[] = [fromHash];
255
+ visited.add(fromHash);
256
+ const leaves: string[] = [];
257
+
258
+ while (queue.length > 0) {
259
+ const current = queue.shift();
260
+ if (current === undefined) break;
261
+ const outgoing = graph.forwardChain.get(current);
262
+
263
+ if (!outgoing || outgoing.length === 0) {
264
+ leaves.push(current);
265
+ } else {
266
+ for (const edge of outgoing) {
267
+ if (!visited.has(edge.to)) {
268
+ visited.add(edge.to);
269
+ queue.push(edge.to);
270
+ }
148
271
  }
149
272
  }
150
273
  }
151
274
 
152
- if (startIdx === -1) return null;
275
+ return leaves;
276
+ }
277
+
278
+ /**
279
+ * Find the leaf contract hash of the migration graph reachable from
280
+ * EMPTY_CONTRACT_HASH. Throws NO_ROOT if the graph has nodes but none
281
+ * originate from the empty hash (e.g. root migration was deleted).
282
+ * Throws AMBIGUOUS_LEAF if multiple leaves exist.
283
+ */
284
+ export function findLeaf(graph: MigrationGraph): string {
285
+ if (graph.nodes.size === 0) {
286
+ return EMPTY_CONTRACT_HASH;
287
+ }
288
+
289
+ if (!graph.nodes.has(EMPTY_CONTRACT_HASH)) {
290
+ throw errorNoRoot([...graph.nodes]);
291
+ }
292
+
293
+ const leaves = findReachableLeaves(graph, EMPTY_CONTRACT_HASH);
153
294
 
154
- let endIdx = -1;
155
- for (let i = chain.length - 1; i >= startIdx; i--) {
156
- if (chain[i]?.to === toHash) {
157
- endIdx = i + 1;
158
- break;
295
+ if (leaves.length === 0) {
296
+ const reachable = [...graph.nodes].filter((n) => n !== EMPTY_CONTRACT_HASH);
297
+ if (reachable.length > 0) {
298
+ throw errorNoResolvableLeaf(reachable);
159
299
  }
300
+ return EMPTY_CONTRACT_HASH;
160
301
  }
161
302
 
162
- if (endIdx === -1) return null;
303
+ if (leaves.length > 1) {
304
+ const divergencePoint = findDivergencePoint(graph, EMPTY_CONTRACT_HASH, leaves);
305
+ const branches = leaves.map((leaf) => {
306
+ const path = findPath(graph, divergencePoint, leaf);
307
+ return {
308
+ leaf,
309
+ edges: (path ?? []).map((e) => ({ dirName: e.dirName, from: e.from, to: e.to })),
310
+ };
311
+ });
312
+ throw errorAmbiguousLeaf(leaves, { divergencePoint, branches });
313
+ }
163
314
 
164
- return chain.slice(startIdx, endIdx);
315
+ const leaf = leaves[0];
316
+ return leaf !== undefined ? leaf : EMPTY_CONTRACT_HASH;
165
317
  }
166
318
 
167
319
  /**
168
- * Build the full ordered chain of migrations from root to leaf by following
169
- * parent pointers. Returns null if the chain cannot be reconstructed
170
- * (e.g. missing root, branches).
320
+ * Find the latest migration entry by traversing from EMPTY_CONTRACT_HASH
321
+ * to the single leaf. Returns null for an empty graph.
322
+ * Throws AMBIGUOUS_LEAF if the graph has multiple leaves.
171
323
  */
172
- function buildChain(graph: MigrationGraph): readonly MigrationChainEntry[] | null {
173
- const roots = graph.childrenByParentId.get(null);
174
- if (!roots || roots.length !== 1) return null;
175
-
176
- const chain: MigrationChainEntry[] = [];
177
- let current: MigrationChainEntry | undefined = roots[0];
178
-
179
- for (let depth = 0; depth < graph.migrationById.size + 1 && current; depth++) {
180
- chain.push(current);
181
- const children =
182
- current.migrationId !== null ? graph.childrenByParentId.get(current.migrationId) : undefined;
183
- if (!children || children.length === 0) break;
184
- if (children.length > 1) return null;
185
- current = children[0];
324
+ export function findLatestMigration(graph: MigrationGraph): MigrationChainEntry | null {
325
+ if (graph.nodes.size === 0) {
326
+ return null;
327
+ }
328
+
329
+ const leafHash = findLeaf(graph);
330
+ if (leafHash === EMPTY_CONTRACT_HASH) {
331
+ return null;
186
332
  }
187
333
 
188
- return chain;
334
+ const path = findPath(graph, EMPTY_CONTRACT_HASH, leafHash);
335
+ if (!path || path.length === 0) {
336
+ return null;
337
+ }
338
+
339
+ return path[path.length - 1] ?? null;
189
340
  }
190
341
 
191
342
  export function detectCycles(graph: MigrationGraph): readonly string[][] {
@@ -194,7 +345,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
194
345
  const BLACK = 2;
195
346
 
196
347
  const color = new Map<string, number>();
197
- const parent = new Map<string, string | null>();
348
+ const parentMap = new Map<string, string | null>();
198
349
  const cycles: string[][] = [];
199
350
 
200
351
  for (const node of graph.nodes) {
@@ -209,17 +360,16 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
209
360
  for (const edge of outgoing) {
210
361
  const v = edge.to;
211
362
  if (color.get(v) === GRAY) {
212
- // Back edge found — reconstruct cycle
213
363
  const cycle: string[] = [v];
214
364
  let cur = u;
215
365
  while (cur !== v) {
216
366
  cycle.push(cur);
217
- cur = parent.get(cur) ?? v;
367
+ cur = parentMap.get(cur) ?? v;
218
368
  }
219
369
  cycle.reverse();
220
370
  cycles.push(cycle);
221
371
  } else if (color.get(v) === WHITE) {
222
- parent.set(v, u);
372
+ parentMap.set(v, u);
223
373
  dfs(v);
224
374
  }
225
375
  }
@@ -230,7 +380,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
230
380
 
231
381
  for (const node of graph.nodes) {
232
382
  if (color.get(node) === WHITE) {
233
- parent.set(node, null);
383
+ parentMap.set(node, null);
234
384
  dfs(node);
235
385
  }
236
386
  }
@@ -242,15 +392,25 @@ export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEnt
242
392
  if (graph.nodes.size === 0) return [];
243
393
 
244
394
  const reachable = new Set<string>();
245
- const rootMigrations = graph.childrenByParentId.get(null) ?? [];
246
- const emptyRootExists = rootMigrations.some(
247
- (migration) => migration.from === EMPTY_CONTRACT_HASH,
248
- );
249
- const rootHashes = emptyRootExists
250
- ? [EMPTY_CONTRACT_HASH]
251
- : [...new Set(rootMigrations.map((migration) => migration.from))];
252
- const queue: string[] = rootHashes.length > 0 ? rootHashes : [EMPTY_CONTRACT_HASH];
395
+ const startNodes: string[] = [];
396
+
397
+ if (graph.forwardChain.has(EMPTY_CONTRACT_HASH)) {
398
+ startNodes.push(EMPTY_CONTRACT_HASH);
399
+ } else {
400
+ const allTargets = new Set<string>();
401
+ for (const edges of graph.forwardChain.values()) {
402
+ for (const edge of edges) {
403
+ allTargets.add(edge.to);
404
+ }
405
+ }
406
+ for (const node of graph.nodes) {
407
+ if (!allTargets.has(node)) {
408
+ startNodes.push(node);
409
+ }
410
+ }
411
+ }
253
412
 
413
+ const queue = [...startNodes];
254
414
  for (const hash of queue) {
255
415
  reachable.add(hash);
256
416
  }
package/src/errors.ts CHANGED
@@ -92,28 +92,79 @@ export function errorSelfLoop(dirName: string, hash: string): MigrationToolsErro
92
92
  });
93
93
  }
94
94
 
95
- export function errorAmbiguousLeaf(leaves: readonly string[]): MigrationToolsError {
95
+ export function errorAmbiguousLeaf(
96
+ leaves: readonly string[],
97
+ context?: {
98
+ divergencePoint: string;
99
+ branches: readonly {
100
+ leaf: string;
101
+ edges: readonly { dirName: string; from: string; to: string }[];
102
+ }[];
103
+ },
104
+ ): MigrationToolsError {
105
+ const divergenceInfo = context
106
+ ? `\nDivergence point: ${context.divergencePoint}\nBranches:\n${context.branches.map((b) => ` → ${b.leaf} (${b.edges.length} edge(s): ${b.edges.map((e) => e.dirName).join(' → ') || 'direct'})`).join('\n')}`
107
+ : '';
96
108
  return new MigrationToolsError('MIGRATION.AMBIGUOUS_LEAF', 'Ambiguous migration graph', {
97
- why: `Multiple leaf nodes found: ${leaves.join(', ')}. The migration graph has diverged — this typically happens when two developers plan migrations from the same starting point.`,
98
- fix: 'Delete one of the conflicting migration directories, then re-run `migration plan` to re-plan it from the remaining branch. Or use --from <hash> to explicitly select a starting point.',
99
- details: { leaves },
109
+ why: `Multiple leaf nodes found: ${leaves.join(', ')}. The migration graph has diverged — this typically happens when two developers plan migrations from the same starting point.${divergenceInfo}`,
110
+ fix: 'Use `migration ref set <name> <hash>` to target a specific branch, delete one of the conflicting migration directories and re-run `migration plan`, or use --from <hash> to explicitly select a starting point.',
111
+ details: {
112
+ leaves,
113
+ ...(context ? { divergencePoint: context.divergencePoint, branches: context.branches } : {}),
114
+ },
100
115
  });
101
116
  }
102
117
 
103
118
  export function errorNoRoot(nodes: readonly string[]): MigrationToolsError {
104
119
  return new MigrationToolsError('MIGRATION.NO_ROOT', 'Migration graph has no root', {
105
- why: `No root migration found in the migration graph (nodes: ${nodes.join(', ')}). Every migration references a parentMigrationId that does not exist, or the graph contains a cycle in parent pointers.`,
106
- fix: 'Inspect the migrations directory for corrupted migration.json files. Exactly one migration must have parentMigrationId set to null (the first migration).',
120
+ why: `No root migration found in the migration graph (nodes: ${nodes.join(', ')}). No migration starts from the empty contract hash, or all edges form a disconnected subgraph.`,
121
+ fix: 'Inspect the migrations directory for corrupted migration.json files. At least one migration must start from the empty contract hash.',
107
122
  details: { nodes },
108
123
  });
109
124
  }
110
125
 
126
+ export function errorInvalidRefs(refsPath: string, reason: string): MigrationToolsError {
127
+ return new MigrationToolsError('MIGRATION.INVALID_REFS', 'Invalid refs.json', {
128
+ why: `refs.json at "${refsPath}" is invalid: ${reason}`,
129
+ fix: 'Ensure refs.json is a flat object mapping valid ref names to contract hash strings.',
130
+ details: { path: refsPath, reason },
131
+ });
132
+ }
133
+
134
+ export function errorInvalidRefName(refName: string): MigrationToolsError {
135
+ return new MigrationToolsError('MIGRATION.INVALID_REF_NAME', 'Invalid ref name', {
136
+ why: `Ref name "${refName}" is invalid. Names must be lowercase alphanumeric with hyphens or forward slashes (no "." or ".." segments).`,
137
+ fix: `Use a valid ref name (e.g., "staging", "envs/production").`,
138
+ details: { refName },
139
+ });
140
+ }
141
+
142
+ export function errorNoResolvableLeaf(reachableNodes: readonly string[]): MigrationToolsError {
143
+ return new MigrationToolsError(
144
+ 'MIGRATION.NO_RESOLVABLE_LEAF',
145
+ 'Migration graph has no resolvable leaf',
146
+ {
147
+ why: `The migration graph contains cycles and no node has zero outgoing edges (reachable nodes: ${reachableNodes.join(', ')}). This typically happens after rollback migrations (e.g., C1→C2→C1).`,
148
+ fix: 'Use --from <hash> to specify the planning origin explicitly.',
149
+ details: { reachableNodes },
150
+ },
151
+ );
152
+ }
153
+
154
+ export function errorInvalidRefValue(value: string): MigrationToolsError {
155
+ return new MigrationToolsError('MIGRATION.INVALID_REF_VALUE', 'Invalid ref value', {
156
+ why: `Ref value "${value}" is not a valid contract hash. Values must be in the format "sha256:<64 hex chars>" or "sha256:empty".`,
157
+ fix: 'Use a valid storage hash from `prisma-next contract emit` output or an existing migration.',
158
+ details: { value },
159
+ });
160
+ }
161
+
111
162
  export function errorDuplicateMigrationId(migrationId: string): MigrationToolsError {
112
163
  return new MigrationToolsError(
113
164
  'MIGRATION.DUPLICATE_MIGRATION_ID',
114
165
  'Duplicate migrationId in migration graph',
115
166
  {
116
- why: `Multiple migrations share migrationId "${migrationId}". This makes parent-chain reconstruction ambiguous and unsafe.`,
167
+ why: `Multiple migrations share migrationId "${migrationId}". Each migration must have a unique content-addressed identity.`,
117
168
  fix: 'Regenerate one of the conflicting migrations so each migrationId is unique, then re-run migration commands.',
118
169
  details: { migrationId },
119
170
  },
@@ -1,8 +1,11 @@
1
+ export type { PathDecision } from '../dag';
1
2
  export {
2
3
  detectCycles,
3
4
  detectOrphans,
4
5
  findLatestMigration,
5
6
  findLeaf,
6
7
  findPath,
8
+ findPathWithDecision,
9
+ findReachableLeaves,
7
10
  reconstructGraph,
8
11
  } from '../dag';
@@ -0,0 +1,2 @@
1
+ export type { Refs } from '../refs';
2
+ export { readRefs, resolveRef, validateRefName, validateRefValue, writeRefs } from '../refs';
@@ -1,9 +1,14 @@
1
1
  export { MigrationToolsError } from '../errors';
2
2
  export type {
3
+ AttestedMigrationBundle,
4
+ AttestedMigrationManifest,
5
+ DraftMigrationManifest,
6
+ MigrationBundle,
7
+ MigrationBundle as MigrationPackage,
3
8
  MigrationChainEntry,
4
9
  MigrationGraph,
5
10
  MigrationHints,
6
11
  MigrationManifest,
7
12
  MigrationOps,
8
- MigrationPackage,
9
13
  } from '../types';
14
+ export { isAttested } from '../types';
package/src/io.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  errorInvalidSlug,
9
9
  errorMissingFile,
10
10
  } from './errors';
11
- import type { MigrationManifest, MigrationOps, MigrationPackage } from './types';
11
+ import type { MigrationBundle, MigrationManifest, MigrationOps } from './types';
12
12
 
13
13
  const MANIFEST_FILE = 'migration.json';
14
14
  const OPS_FILE = 'ops.json';
@@ -29,7 +29,6 @@ const MigrationManifestSchema = type({
29
29
  from: 'string',
30
30
  to: 'string',
31
31
  migrationId: 'string | null',
32
- parentMigrationId: 'string | null',
33
32
  kind: "'regular' | 'baseline'",
34
33
  fromContract: 'object | null',
35
34
  toContract: 'object',
@@ -75,7 +74,7 @@ export async function writeMigrationPackage(
75
74
  await writeFile(join(dir, OPS_FILE), JSON.stringify(ops, null, 2), { flag: 'wx' });
76
75
  }
77
76
 
78
- export async function readMigrationPackage(dir: string): Promise<MigrationPackage> {
77
+ export async function readMigrationPackage(dir: string): Promise<MigrationBundle> {
79
78
  const manifestPath = join(dir, MANIFEST_FILE);
80
79
  const opsPath = join(dir, OPS_FILE);
81
80
 
@@ -143,7 +142,7 @@ function validateOps(ops: unknown, filePath: string): asserts ops is MigrationOp
143
142
 
144
143
  export async function readMigrationsDir(
145
144
  migrationsRoot: string,
146
- ): Promise<readonly MigrationPackage[]> {
145
+ ): Promise<readonly MigrationBundle[]> {
147
146
  let entries: string[];
148
147
  try {
149
148
  entries = await readdir(migrationsRoot);
@@ -154,7 +153,7 @@ export async function readMigrationsDir(
154
153
  throw error;
155
154
  }
156
155
 
157
- const packages: MigrationPackage[] = [];
156
+ const packages: MigrationBundle[] = [];
158
157
 
159
158
  for (const entry of entries.sort()) {
160
159
  const entryPath = join(migrationsRoot, entry);