@prisma-next/migration-tools 0.3.0-dev.84 → 0.3.0-dev.86
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/dist/{errors-DdSjGRqx.mjs → errors-CqLiJwqA.mjs} +47 -9
- package/dist/errors-CqLiJwqA.mjs.map +1 -0
- package/dist/exports/attestation.d.mts +1 -1
- package/dist/exports/attestation.mjs +1 -1
- package/dist/exports/dag.d.mts +36 -17
- package/dist/exports/dag.d.mts.map +1 -1
- package/dist/exports/dag.mjs +206 -76
- package/dist/exports/dag.mjs.map +1 -1
- package/dist/exports/io.d.mts +3 -3
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +1 -1
- package/dist/exports/refs.d.mts +10 -0
- package/dist/exports/refs.d.mts.map +1 -0
- package/dist/exports/refs.mjs +73 -0
- package/dist/exports/refs.mjs.map +1 -0
- package/dist/exports/types.d.mts +2 -2
- package/dist/exports/types.mjs +13 -2
- package/dist/exports/types.mjs.map +1 -0
- package/dist/{io-Dx98-h0p.mjs → io-afog-e8J.mjs} +2 -3
- package/dist/io-afog-e8J.mjs.map +1 -0
- package/dist/types-9YQfIg6N.d.mts +96 -0
- package/dist/types-9YQfIg6N.d.mts.map +1 -0
- package/package.json +8 -4
- package/src/dag.ts +267 -107
- package/src/errors.ts +58 -7
- package/src/exports/dag.ts +3 -0
- package/src/exports/refs.ts +2 -0
- package/src/exports/types.ts +6 -1
- package/src/io.ts +4 -5
- package/src/refs.ts +102 -0
- package/src/types.ts +54 -7
- package/dist/errors-DdSjGRqx.mjs.map +0 -1
- package/dist/io-Dx98-h0p.mjs.map +0 -1
- package/dist/types-CUnzoaLY.d.mts +0 -56
- package/dist/types-CUnzoaLY.d.mts.map +0 -1
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
|
|
10
|
+
import type { AttestedMigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
|
|
9
11
|
|
|
10
|
-
export function reconstructGraph(packages: readonly
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
123
|
+
const outgoing = graph.forwardChain.get(current);
|
|
124
|
+
if (!outgoing) continue;
|
|
105
125
|
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
*
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
*
|
|
123
|
-
*
|
|
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
|
-
|
|
209
|
+
function findDivergencePoint(
|
|
131
210
|
graph: MigrationGraph,
|
|
132
211
|
fromHash: string,
|
|
133
|
-
|
|
134
|
-
):
|
|
135
|
-
|
|
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
|
|
138
|
-
|
|
231
|
+
const commonAncestors = [...(ancestorSets[0] ?? [])].filter((node) =>
|
|
232
|
+
ancestorSets.every((s) => s.has(node)),
|
|
233
|
+
);
|
|
139
234
|
|
|
140
|
-
let
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
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 (
|
|
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
|
-
|
|
315
|
+
const leaf = leaves[0];
|
|
316
|
+
return leaf !== undefined ? leaf : EMPTY_CONTRACT_HASH;
|
|
165
317
|
}
|
|
166
318
|
|
|
167
319
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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(
|
|
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: '
|
|
99
|
-
details: {
|
|
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(', ')}).
|
|
106
|
-
fix: 'Inspect the migrations directory for corrupted migration.json files.
|
|
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}".
|
|
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
|
},
|
package/src/exports/dag.ts
CHANGED
package/src/exports/types.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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
|
|
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:
|
|
156
|
+
const packages: MigrationBundle[] = [];
|
|
158
157
|
|
|
159
158
|
for (const entry of entries.sort()) {
|
|
160
159
|
const entryPath = join(migrationsRoot, entry);
|