@prisma-next/migration-tools 0.4.0-dev.9 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/dist/{attestation-DnebS4XZ.mjs → attestation-DtF8tEOM.mjs} +24 -23
  3. package/dist/attestation-DtF8tEOM.mjs.map +1 -0
  4. package/dist/{errors-C_XuSbX7.mjs → errors-BKbRGCJM.mjs} +9 -2
  5. package/dist/errors-BKbRGCJM.mjs.map +1 -0
  6. package/dist/exports/attestation.d.mts +20 -6
  7. package/dist/exports/attestation.d.mts.map +1 -1
  8. package/dist/exports/attestation.mjs +3 -3
  9. package/dist/exports/dag.d.mts +8 -6
  10. package/dist/exports/dag.d.mts.map +1 -1
  11. package/dist/exports/dag.mjs +181 -107
  12. package/dist/exports/dag.mjs.map +1 -1
  13. package/dist/exports/io.d.mts +16 -13
  14. package/dist/exports/io.d.mts.map +1 -1
  15. package/dist/exports/io.mjs +2 -2
  16. package/dist/exports/migration-ts.d.mts +10 -20
  17. package/dist/exports/migration-ts.d.mts.map +1 -1
  18. package/dist/exports/migration-ts.mjs +23 -35
  19. package/dist/exports/migration-ts.mjs.map +1 -1
  20. package/dist/exports/migration.d.mts +1 -1
  21. package/dist/exports/migration.mjs +20 -13
  22. package/dist/exports/migration.mjs.map +1 -1
  23. package/dist/exports/refs.mjs +1 -1
  24. package/dist/exports/types.d.mts +2 -2
  25. package/dist/exports/types.mjs +2 -16
  26. package/dist/{io-Cun81AIZ.mjs → io-CCnYsUHU.mjs} +18 -22
  27. package/dist/io-CCnYsUHU.mjs.map +1 -0
  28. package/dist/types-DyGXcWWp.d.mts +71 -0
  29. package/dist/types-DyGXcWWp.d.mts.map +1 -0
  30. package/package.json +5 -4
  31. package/src/attestation.ts +34 -26
  32. package/src/dag.ts +140 -154
  33. package/src/errors.ts +8 -0
  34. package/src/exports/attestation.ts +2 -1
  35. package/src/exports/io.ts +1 -1
  36. package/src/exports/migration-ts.ts +1 -1
  37. package/src/exports/types.ts +2 -8
  38. package/src/graph-ops.ts +65 -0
  39. package/src/io.ts +23 -24
  40. package/src/migration-base.ts +21 -13
  41. package/src/migration-ts.ts +23 -49
  42. package/src/queue.ts +37 -0
  43. package/src/types.ts +15 -55
  44. package/dist/attestation-DnebS4XZ.mjs.map +0 -1
  45. package/dist/errors-C_XuSbX7.mjs.map +0 -1
  46. package/dist/exports/types.mjs.map +0 -1
  47. package/dist/io-Cun81AIZ.mjs.map +0 -1
  48. package/dist/types-D2uX4ql7.d.mts +0 -100
  49. package/dist/types-D2uX4ql7.d.mts.map +0 -1
@@ -0,0 +1,71 @@
1
+ import { Contract } from "@prisma-next/contract/types";
2
+ import { MigrationPlanOperation } from "@prisma-next/framework-components/control";
3
+
4
+ //#region src/types.d.ts
5
+ interface MigrationHints {
6
+ readonly used: readonly string[];
7
+ readonly applied: readonly string[];
8
+ readonly plannerVersion: string;
9
+ }
10
+ /**
11
+ * On-disk migration manifest. Every migration is content-addressed: the
12
+ * `migrationId` is a hash over the manifest envelope plus the operations
13
+ * list, computed at write time. There is no draft state — a migration
14
+ * directory either exists with a fully attested manifest or it does not.
15
+ *
16
+ * When the planner cannot lower an operation because of an unfilled
17
+ * `placeholder(...)` slot, the migration is still written with
18
+ * `migrationId` hashed over `ops: []`. Re-running self-emit after the
19
+ * user fills the placeholder produces a *different* `migrationId`
20
+ * (committed to the real ops); this is intentional.
21
+ */
22
+ interface MigrationManifest {
23
+ readonly migrationId: string;
24
+ readonly from: string;
25
+ readonly to: string;
26
+ readonly kind: 'regular' | 'baseline';
27
+ readonly fromContract: Contract | null;
28
+ readonly toContract: Contract;
29
+ readonly hints: MigrationHints;
30
+ readonly labels: readonly string[];
31
+ readonly authorship?: {
32
+ readonly author?: string;
33
+ readonly email?: string;
34
+ };
35
+ readonly signature?: {
36
+ readonly keyId: string;
37
+ readonly value: string;
38
+ } | null;
39
+ readonly createdAt: string;
40
+ }
41
+ type MigrationOps = readonly MigrationPlanOperation[];
42
+ /**
43
+ * An on-disk migration directory containing a manifest and operations.
44
+ */
45
+ interface MigrationBundle {
46
+ readonly dirName: string;
47
+ readonly dirPath: string;
48
+ readonly manifest: MigrationManifest;
49
+ readonly ops: MigrationOps;
50
+ }
51
+ /**
52
+ * An entry in the migration graph. All on-disk migrations are attested,
53
+ * so `migrationId` is always a string.
54
+ */
55
+ interface MigrationChainEntry {
56
+ readonly from: string;
57
+ readonly to: string;
58
+ readonly migrationId: string;
59
+ readonly dirName: string;
60
+ readonly createdAt: string;
61
+ readonly labels: readonly string[];
62
+ }
63
+ interface MigrationGraph {
64
+ readonly nodes: ReadonlySet<string>;
65
+ readonly forwardChain: ReadonlyMap<string, readonly MigrationChainEntry[]>;
66
+ readonly reverseChain: ReadonlyMap<string, readonly MigrationChainEntry[]>;
67
+ readonly migrationById: ReadonlyMap<string, MigrationChainEntry>;
68
+ }
69
+ //#endregion
70
+ export { MigrationManifest as a, MigrationHints as i, MigrationChainEntry as n, MigrationOps as o, MigrationGraph as r, MigrationBundle as t };
71
+ //# sourceMappingURL=types-DyGXcWWp.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-DyGXcWWp.d.mts","names":[],"sources":["../src/types.ts"],"sourcesContent":[],"mappings":";;;;UAGiB,cAAA;;EAAA,SAAA,OAAA,EAAc,SAAA,MAAA,EAAA;EAkBd,SAAA,cAAiB,EAAA,MAAA;;;;;AAclC;AAKA;AAWA;AASA;;;;;;AAI8C,UA3C7B,iBAAA,CA2C6B;EAApB,SAAA,WAAA,EAAA,MAAA;EAAW,SAAA,IAAA,EAAA,MAAA;;;yBAtCZ;uBACF;kBACL;;;;;;;;;;;;KAON,YAAA,YAAwB;;;;UAKnB,eAAA;;;qBAGI;gBACL;;;;;;UAOC,mBAAA;;;;;;;;UASA,cAAA;kBACC;yBACO,6BAA6B;yBAC7B,6BAA6B;0BAC5B,oBAAoB"}
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@prisma-next/migration-tools",
3
- "version": "0.4.0-dev.9",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "On-disk migration persistence, attestation, and chain reconstruction for Prisma Next",
7
7
  "dependencies": {
8
8
  "arktype": "^2.1.29",
9
9
  "pathe": "^2.0.3",
10
- "@prisma-next/contract": "0.4.0-dev.9",
11
- "@prisma-next/framework-components": "0.4.0-dev.9",
12
- "@prisma-next/utils": "0.4.0-dev.9"
10
+ "prettier": "^3.6.2",
11
+ "@prisma-next/contract": "0.4.1",
12
+ "@prisma-next/framework-components": "0.4.1",
13
+ "@prisma-next/utils": "0.4.1"
13
14
  },
14
15
  "devDependencies": {
15
16
  "tsdown": "0.18.4",
@@ -1,11 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { canonicalizeJson } from './canonicalize-json';
3
- import { readMigrationPackage, writeMigrationManifest } from './io';
4
- import type { MigrationManifest, MigrationOps } from './types';
3
+ import { readMigrationPackage } from './io';
4
+ import type { MigrationBundle, MigrationManifest, MigrationOps } from './types';
5
5
 
6
6
  export interface VerifyResult {
7
7
  readonly ok: boolean;
8
- readonly reason?: 'draft' | 'mismatch';
8
+ readonly reason?: 'mismatch';
9
9
  readonly storedMigrationId?: string;
10
10
  readonly computedMigrationId?: string;
11
11
  }
@@ -20,8 +20,15 @@ function sha256Hex(input: string): string {
20
20
  * for the rationale: contracts are anchored separately by the
21
21
  * storage-hash bookends inside the envelope; planner hints are advisory
22
22
  * and must not affect identity.
23
+ *
24
+ * The `migrationId` field on the manifest is stripped before hashing so
25
+ * the function can be used both at write time (when no id exists yet)
26
+ * and at verify time (rehashing an already-attested manifest).
23
27
  */
24
- export function computeMigrationId(manifest: MigrationManifest, ops: MigrationOps): string {
28
+ export function computeMigrationId(
29
+ manifest: Omit<MigrationManifest, 'migrationId'> & { readonly migrationId?: string },
30
+ ops: MigrationOps,
31
+ ): string {
25
32
  const {
26
33
  migrationId: _migrationId,
27
34
  signature: _signature,
@@ -40,34 +47,35 @@ export function computeMigrationId(manifest: MigrationManifest, ops: MigrationOp
40
47
  return `sha256:${hash}`;
41
48
  }
42
49
 
43
- /** Compute and persist `migrationId` to `manifest.json`. */
44
- export async function attestMigration(dir: string): Promise<string> {
45
- const pkg = await readMigrationPackage(dir);
46
- const migrationId = computeMigrationId(pkg.manifest, pkg.ops);
47
-
48
- const updated = { ...pkg.manifest, migrationId };
49
- await writeMigrationManifest(dir, updated);
50
-
51
- return migrationId;
52
- }
53
-
54
- export async function verifyMigration(dir: string): Promise<VerifyResult> {
55
- const pkg = await readMigrationPackage(dir);
56
-
57
- if (pkg.manifest.migrationId === null) {
58
- return { ok: false, reason: 'draft' };
59
- }
60
-
61
- const computed = computeMigrationId(pkg.manifest, pkg.ops);
50
+ /**
51
+ * Re-hash an on-disk migration bundle and compare against the stored
52
+ * `migrationId`. Returns `{ ok: true }` when the package is internally
53
+ * consistent (manifest + ops still produce the recorded id), or
54
+ * `{ ok: false, reason: 'mismatch', stored, computed }` when they do
55
+ * not typically a sign of FS corruption, partial writes, or a
56
+ * post-emit hand edit.
57
+ */
58
+ export function verifyMigrationBundle(bundle: MigrationBundle): VerifyResult {
59
+ const computed = computeMigrationId(bundle.manifest, bundle.ops);
62
60
 
63
- if (pkg.manifest.migrationId === computed) {
64
- return { ok: true, storedMigrationId: pkg.manifest.migrationId, computedMigrationId: computed };
61
+ if (bundle.manifest.migrationId === computed) {
62
+ return {
63
+ ok: true,
64
+ storedMigrationId: bundle.manifest.migrationId,
65
+ computedMigrationId: computed,
66
+ };
65
67
  }
66
68
 
67
69
  return {
68
70
  ok: false,
69
71
  reason: 'mismatch',
70
- storedMigrationId: pkg.manifest.migrationId,
72
+ storedMigrationId: bundle.manifest.migrationId,
71
73
  computedMigrationId: computed,
72
74
  };
73
75
  }
76
+
77
+ /** Convenience wrapper: read the package from disk then verify it. */
78
+ export async function verifyMigration(dir: string): Promise<VerifyResult> {
79
+ const pkg = await readMigrationPackage(dir);
80
+ return verifyMigrationBundle(pkg);
81
+ }
package/src/dag.ts CHANGED
@@ -7,9 +7,30 @@ import {
7
7
  errorNoTarget,
8
8
  errorSameSourceAndTarget,
9
9
  } from './errors';
10
- import type { AttestedMigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
10
+ import { bfs } from './graph-ops';
11
+ import type { MigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
11
12
 
12
- export function reconstructGraph(packages: readonly AttestedMigrationBundle[]): MigrationGraph {
13
+ /** Forward-edge neighbours for BFS: edge `e` from `n` visits `e.to` next. */
14
+ function forwardNeighbours(graph: MigrationGraph, node: string) {
15
+ return (graph.forwardChain.get(node) ?? []).map((edge) => ({ next: edge.to, edge }));
16
+ }
17
+
18
+ /** Reverse-edge neighbours for BFS: edge `e` from `n` visits `e.from` next. */
19
+ function reverseNeighbours(graph: MigrationGraph, node: string) {
20
+ return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));
21
+ }
22
+
23
+ function appendEdge(
24
+ map: Map<string, MigrationChainEntry[]>,
25
+ key: string,
26
+ entry: MigrationChainEntry,
27
+ ): void {
28
+ const bucket = map.get(key);
29
+ if (bucket) bucket.push(entry);
30
+ else map.set(key, [entry]);
31
+ }
32
+
33
+ export function reconstructGraph(packages: readonly MigrationBundle[]): MigrationGraph {
13
34
  const nodes = new Set<string>();
14
35
  const forwardChain = new Map<string, MigrationChainEntry[]>();
15
36
  const reverseChain = new Map<string, MigrationChainEntry[]>();
@@ -34,31 +55,24 @@ export function reconstructGraph(packages: readonly AttestedMigrationBundle[]):
34
55
  labels: pkg.manifest.labels,
35
56
  };
36
57
 
37
- if (migration.migrationId !== null) {
38
- if (migrationById.has(migration.migrationId)) {
39
- throw errorDuplicateMigrationId(migration.migrationId);
40
- }
41
- migrationById.set(migration.migrationId, migration);
42
- }
43
-
44
- const fwd = forwardChain.get(from);
45
- if (fwd) {
46
- fwd.push(migration);
47
- } else {
48
- forwardChain.set(from, [migration]);
58
+ if (migrationById.has(migration.migrationId)) {
59
+ throw errorDuplicateMigrationId(migration.migrationId);
49
60
  }
61
+ migrationById.set(migration.migrationId, migration);
50
62
 
51
- const rev = reverseChain.get(to);
52
- if (rev) {
53
- rev.push(migration);
54
- } else {
55
- reverseChain.set(to, [migration]);
56
- }
63
+ appendEdge(forwardChain, from, migration);
64
+ appendEdge(reverseChain, to, migration);
57
65
  }
58
66
 
59
67
  return { nodes, forwardChain, reverseChain, migrationById };
60
68
  }
61
69
 
70
+ // ---------------------------------------------------------------------------
71
+ // Deterministic tie-breaking for BFS neighbour order.
72
+ // Used by `findPath` and `findPathWithDecision` only; not a general-purpose
73
+ // utility. Ordering: label priority → createdAt → to → migrationId.
74
+ // ---------------------------------------------------------------------------
75
+
62
76
  const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
63
77
 
64
78
  function labelPriority(labels: readonly string[]): number {
@@ -70,16 +84,25 @@ function labelPriority(labels: readonly string[]): number {
70
84
  return best;
71
85
  }
72
86
 
87
+ function compareTieBreak(a: MigrationChainEntry, b: MigrationChainEntry): number {
88
+ const lp = labelPriority(a.labels) - labelPriority(b.labels);
89
+ if (lp !== 0) return lp;
90
+ const ca = a.createdAt.localeCompare(b.createdAt);
91
+ if (ca !== 0) return ca;
92
+ const tc = a.to.localeCompare(b.to);
93
+ if (tc !== 0) return tc;
94
+ return a.migrationId.localeCompare(b.migrationId);
95
+ }
96
+
73
97
  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
- });
98
+ return [...edges].sort(compareTieBreak);
99
+ }
100
+
101
+ /** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */
102
+ function bfsOrdering(
103
+ items: readonly { next: string; edge: MigrationChainEntry }[],
104
+ ): readonly { next: string; edge: MigrationChainEntry }[] {
105
+ return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));
83
106
  }
84
107
 
85
108
  /**
@@ -97,44 +120,40 @@ export function findPath(
97
120
  ): readonly MigrationChainEntry[] | null {
98
121
  if (fromHash === toHash) return [];
99
122
 
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);
104
-
105
- while (queue.length > 0) {
106
- const current = queue.shift();
107
- if (current === undefined) break;
108
-
109
- if (current === toHash) {
123
+ const parents = new Map<string, { parent: string; edge: MigrationChainEntry }>();
124
+ for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n), bfsOrdering)) {
125
+ if (step.parent !== null && step.incomingEdge !== null) {
126
+ parents.set(step.node, { parent: step.parent, edge: step.incomingEdge });
127
+ }
128
+ if (step.node === toHash) {
110
129
  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);
130
+ let cur = toHash;
131
+ let p = parents.get(cur);
132
+ while (p) {
133
+ path.push(p.edge);
134
+ cur = p.parent;
135
+ p = parents.get(cur);
118
136
  }
119
137
  path.reverse();
120
138
  return path;
121
139
  }
122
-
123
- const outgoing = graph.forwardChain.get(current);
124
- if (!outgoing) continue;
125
-
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
- }
133
140
  }
134
141
 
135
142
  return null;
136
143
  }
137
144
 
145
+ /**
146
+ * Reverse-BFS from `toHash` over `reverseChain` to collect every node from
147
+ * which `toHash` is reachable (inclusive of `toHash` itself).
148
+ */
149
+ function collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<string> {
150
+ const reached = new Set<string>();
151
+ for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) {
152
+ reached.add(step.node);
153
+ }
154
+ return reached;
155
+ }
156
+
138
157
  export interface PathDecision {
139
158
  readonly selectedPath: readonly MigrationChainEntry[];
140
159
  readonly fromHash: string;
@@ -168,16 +187,18 @@ export function findPathWithDecision(
168
187
  const path = findPath(graph, fromHash, toHash);
169
188
  if (!path) return null;
170
189
 
190
+ // Single reverse BFS marks every node from which `toHash` is reachable.
191
+ // Replaces a per-edge `findPath(e.to, toHash)` call inside the loop below,
192
+ // which made the whole function O(|path| · (V + E)) instead of O(V + E).
193
+ const reachesTarget = collectNodesReachingTarget(graph, toHash);
194
+
171
195
  const tieBreakReasons: string[] = [];
172
196
  let alternativeCount = 0;
173
197
 
174
198
  for (const edge of path) {
175
199
  const outgoing = graph.forwardChain.get(edge.from);
176
200
  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
- });
201
+ const reachable = outgoing.filter((e) => reachesTarget.has(e.to));
181
202
  if (reachable.length > 1) {
182
203
  alternativeCount += reachable.length - 1;
183
204
  const sorted = sortedNeighbors(reachable);
@@ -213,17 +234,8 @@ function findDivergencePoint(
213
234
  ): string {
214
235
  const ancestorSets = leaves.map((leaf) => {
215
236
  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
- }
237
+ for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) {
238
+ ancestors.add(step.node);
227
239
  }
228
240
  return ancestors;
229
241
  });
@@ -250,40 +262,26 @@ function findDivergencePoint(
250
262
  * `fromHash` via forward edges.
251
263
  */
252
264
  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
265
  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
- }
271
- }
266
+ for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) {
267
+ if (!graph.forwardChain.get(step.node)?.length) {
268
+ leaves.push(step.node);
272
269
  }
273
270
  }
274
-
275
271
  return leaves;
276
272
  }
277
273
 
278
274
  /**
279
275
  * Find the target contract hash of the migration graph reachable from
280
- * EMPTY_CONTRACT_HASH. Throws NO_INITIAL_MIGRATION if the graph has
281
- * nodes but none originate from the empty hash.
282
- * Throws AMBIGUOUS_TARGET if multiple branch tips exist.
276
+ * EMPTY_CONTRACT_HASH. Returns `null` for a graph that has no target
277
+ * state (either empty, or containing only the root with no outgoing
278
+ * edges). Throws NO_INITIAL_MIGRATION if the graph has nodes but none
279
+ * originate from the empty hash, and AMBIGUOUS_TARGET if multiple
280
+ * branch tips exist.
283
281
  */
284
- export function findLeaf(graph: MigrationGraph): string {
282
+ export function findLeaf(graph: MigrationGraph): string | null {
285
283
  if (graph.nodes.size === 0) {
286
- return EMPTY_CONTRACT_HASH;
284
+ return null;
287
285
  }
288
286
 
289
287
  if (!graph.nodes.has(EMPTY_CONTRACT_HASH)) {
@@ -297,7 +295,7 @@ export function findLeaf(graph: MigrationGraph): string {
297
295
  if (reachable.length > 0) {
298
296
  throw errorNoTarget(reachable);
299
297
  }
300
- return EMPTY_CONTRACT_HASH;
298
+ return null;
301
299
  }
302
300
 
303
301
  if (leaves.length > 1) {
@@ -312,8 +310,8 @@ export function findLeaf(graph: MigrationGraph): string {
312
310
  throw errorAmbiguousTarget(leaves, { divergencePoint, branches });
313
311
  }
314
312
 
315
- const leaf = leaves[0];
316
- return leaf !== undefined ? leaf : EMPTY_CONTRACT_HASH;
313
+ // biome-ignore lint/style/noNonNullAssertion: leaves.length is neither 0 nor >1 per the branches above, so exactly one leaf remains
314
+ return leaves[0]!;
317
315
  }
318
316
 
319
317
  /**
@@ -322,21 +320,11 @@ export function findLeaf(graph: MigrationGraph): string {
322
320
  * Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
323
321
  */
324
322
  export function findLatestMigration(graph: MigrationGraph): MigrationChainEntry | null {
325
- if (graph.nodes.size === 0) {
326
- return null;
327
- }
328
-
329
323
  const leafHash = findLeaf(graph);
330
- if (leafHash === EMPTY_CONTRACT_HASH) {
331
- return null;
332
- }
324
+ if (leafHash === null) return null;
333
325
 
334
326
  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;
327
+ return path?.at(-1) ?? null;
340
328
  }
341
329
 
342
330
  export function detectCycles(graph: MigrationGraph): readonly string[][] {
@@ -352,37 +340,50 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
352
340
  color.set(node, WHITE);
353
341
  }
354
342
 
355
- function dfs(u: string): void {
343
+ // Iterative three-color DFS. A frame is (node, outgoing edges, next-index).
344
+ interface Frame {
345
+ node: string;
346
+ outgoing: readonly MigrationChainEntry[];
347
+ index: number;
348
+ }
349
+ const stack: Frame[] = [];
350
+
351
+ function pushFrame(u: string): void {
356
352
  color.set(u, GRAY);
353
+ stack.push({ node: u, outgoing: graph.forwardChain.get(u) ?? [], index: 0 });
354
+ }
357
355
 
358
- const outgoing = graph.forwardChain.get(u);
359
- if (outgoing) {
360
- for (const edge of outgoing) {
361
- const v = edge.to;
362
- if (color.get(v) === GRAY) {
363
- const cycle: string[] = [v];
364
- let cur = u;
365
- while (cur !== v) {
366
- cycle.push(cur);
367
- cur = parentMap.get(cur) ?? v;
368
- }
369
- cycle.reverse();
370
- cycles.push(cycle);
371
- } else if (color.get(v) === WHITE) {
372
- parentMap.set(v, u);
373
- dfs(v);
356
+ for (const root of graph.nodes) {
357
+ if (color.get(root) !== WHITE) continue;
358
+ parentMap.set(root, null);
359
+ pushFrame(root);
360
+
361
+ while (stack.length > 0) {
362
+ // biome-ignore lint/style/noNonNullAssertion: stack.length > 0 should guarantee that this cannot be undefined
363
+ const frame = stack[stack.length - 1]!;
364
+ if (frame.index >= frame.outgoing.length) {
365
+ color.set(frame.node, BLACK);
366
+ stack.pop();
367
+ continue;
368
+ }
369
+ // biome-ignore lint/style/noNonNullAssertion: the early-continue above guarantees frame.index < frame.outgoing.length here, so this is defined
370
+ const edge = frame.outgoing[frame.index++]!;
371
+ const v = edge.to;
372
+ const vColor = color.get(v);
373
+ if (vColor === GRAY) {
374
+ const cycle: string[] = [v];
375
+ let cur = frame.node;
376
+ while (cur !== v) {
377
+ cycle.push(cur);
378
+ cur = parentMap.get(cur) ?? v;
374
379
  }
380
+ cycle.reverse();
381
+ cycles.push(cycle);
382
+ } else if (vColor === WHITE) {
383
+ parentMap.set(v, frame.node);
384
+ pushFrame(v);
375
385
  }
376
386
  }
377
-
378
- color.set(u, BLACK);
379
- }
380
-
381
- for (const node of graph.nodes) {
382
- if (color.get(node) === WHITE) {
383
- parentMap.set(node, null);
384
- dfs(node);
385
- }
386
387
  }
387
388
 
388
389
  return cycles;
@@ -410,23 +411,8 @@ export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEnt
410
411
  }
411
412
  }
412
413
 
413
- const queue = [...startNodes];
414
- for (const hash of queue) {
415
- reachable.add(hash);
416
- }
417
-
418
- while (queue.length > 0) {
419
- const node = queue.shift();
420
- if (node === undefined) break;
421
- const outgoing = graph.forwardChain.get(node);
422
- if (!outgoing) continue;
423
-
424
- for (const migration of outgoing) {
425
- if (!reachable.has(migration.to)) {
426
- reachable.add(migration.to);
427
- queue.push(migration.to);
428
- }
429
- }
414
+ for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) {
415
+ reachable.add(step.node);
430
416
  }
431
417
 
432
418
  const orphans: MigrationChainEntry[] = [];
package/src/errors.ts CHANGED
@@ -84,6 +84,14 @@ export function errorInvalidSlug(slug: string): MigrationToolsError {
84
84
  });
85
85
  }
86
86
 
87
+ export function errorInvalidDestName(destName: string): MigrationToolsError {
88
+ return new MigrationToolsError('MIGRATION.INVALID_DEST_NAME', 'Invalid copy destination name', {
89
+ why: `The destination name "${destName}" must be a single path segment (no ".." or directory separators).`,
90
+ fix: 'Use a simple file name such as "contract.json" for each destination in the copy list.',
91
+ details: { destName },
92
+ });
93
+ }
94
+
87
95
  export function errorSameSourceAndTarget(dirName: string, hash: string): MigrationToolsError {
88
96
  return new MigrationToolsError(
89
97
  'MIGRATION.SAME_SOURCE_AND_TARGET',
@@ -1 +1,2 @@
1
- export { attestMigration, computeMigrationId, verifyMigration } from '../attestation';
1
+ export type { VerifyResult } from '../attestation';
2
+ export { computeMigrationId, verifyMigration, verifyMigrationBundle } from '../attestation';
package/src/exports/io.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export {
2
- copyContractToMigrationDir,
2
+ copyFilesWithRename,
3
3
  formatMigrationDirName,
4
4
  readMigrationPackage,
5
5
  readMigrationsDir,
@@ -1,3 +1,3 @@
1
- export { evaluateMigrationTs, hasMigrationTs, writeMigrationTs } from '../migration-ts';
1
+ export { hasMigrationTs, writeMigrationTs } from '../migration-ts';
2
2
  export type { ScaffoldRuntime } from '../runtime-detection';
3
3
  export { detectScaffoldRuntime, shebangLineFor } from '../runtime-detection';
@@ -1,16 +1,10 @@
1
1
  export { MigrationToolsError } from '../errors';
2
2
  export type {
3
- AttestedMigrationBundle,
4
- AttestedMigrationManifest,
5
- BaseMigrationBundle,
6
- BaseMigrationBundle as MigrationBundle,
7
- BaseMigrationBundle as MigrationPackage,
8
- DraftMigrationBundle,
9
- DraftMigrationManifest,
3
+ MigrationBundle,
4
+ MigrationBundle as MigrationPackage,
10
5
  MigrationChainEntry,
11
6
  MigrationGraph,
12
7
  MigrationHints,
13
8
  MigrationManifest,
14
9
  MigrationOps,
15
10
  } from '../types';
16
- export { isAttested, isDraft } from '../types';