@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.
- package/README.md +1 -1
- package/dist/{attestation-DnebS4XZ.mjs → attestation-DtF8tEOM.mjs} +24 -23
- package/dist/attestation-DtF8tEOM.mjs.map +1 -0
- package/dist/{errors-C_XuSbX7.mjs → errors-BKbRGCJM.mjs} +9 -2
- package/dist/errors-BKbRGCJM.mjs.map +1 -0
- package/dist/exports/attestation.d.mts +20 -6
- package/dist/exports/attestation.d.mts.map +1 -1
- package/dist/exports/attestation.mjs +3 -3
- package/dist/exports/dag.d.mts +8 -6
- package/dist/exports/dag.d.mts.map +1 -1
- package/dist/exports/dag.mjs +181 -107
- package/dist/exports/dag.mjs.map +1 -1
- package/dist/exports/io.d.mts +16 -13
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +2 -2
- package/dist/exports/migration-ts.d.mts +10 -20
- package/dist/exports/migration-ts.d.mts.map +1 -1
- package/dist/exports/migration-ts.mjs +23 -35
- package/dist/exports/migration-ts.mjs.map +1 -1
- package/dist/exports/migration.d.mts +1 -1
- package/dist/exports/migration.mjs +20 -13
- package/dist/exports/migration.mjs.map +1 -1
- package/dist/exports/refs.mjs +1 -1
- package/dist/exports/types.d.mts +2 -2
- package/dist/exports/types.mjs +2 -16
- package/dist/{io-Cun81AIZ.mjs → io-CCnYsUHU.mjs} +18 -22
- package/dist/io-CCnYsUHU.mjs.map +1 -0
- package/dist/types-DyGXcWWp.d.mts +71 -0
- package/dist/types-DyGXcWWp.d.mts.map +1 -0
- package/package.json +5 -4
- package/src/attestation.ts +34 -26
- package/src/dag.ts +140 -154
- package/src/errors.ts +8 -0
- package/src/exports/attestation.ts +2 -1
- package/src/exports/io.ts +1 -1
- package/src/exports/migration-ts.ts +1 -1
- package/src/exports/types.ts +2 -8
- package/src/graph-ops.ts +65 -0
- package/src/io.ts +23 -24
- package/src/migration-base.ts +21 -13
- package/src/migration-ts.ts +23 -49
- package/src/queue.ts +37 -0
- package/src/types.ts +15 -55
- package/dist/attestation-DnebS4XZ.mjs.map +0 -1
- package/dist/errors-C_XuSbX7.mjs.map +0 -1
- package/dist/exports/types.mjs.map +0 -1
- package/dist/io-Cun81AIZ.mjs.map +0 -1
- package/dist/types-D2uX4ql7.d.mts +0 -100
- 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.
|
|
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
|
-
"
|
|
11
|
-
"@prisma-next/
|
|
12
|
-
"@prisma-next/
|
|
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",
|
package/src/attestation.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { canonicalizeJson } from './canonicalize-json';
|
|
3
|
-
import { readMigrationPackage
|
|
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?: '
|
|
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(
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 (
|
|
64
|
-
return {
|
|
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:
|
|
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
|
|
10
|
+
import { bfs } from './graph-ops';
|
|
11
|
+
import type { MigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
|
|
11
12
|
|
|
12
|
-
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
112
|
-
let
|
|
113
|
-
while (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
217
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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.
|
|
281
|
-
*
|
|
282
|
-
* Throws
|
|
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
|
|
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
|
|
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
|
-
|
|
316
|
-
return
|
|
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 ===
|
|
331
|
-
return null;
|
|
332
|
-
}
|
|
324
|
+
if (leafHash === null) return null;
|
|
333
325
|
|
|
334
326
|
const path = findPath(graph, EMPTY_CONTRACT_HASH, leafHash);
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
414
|
-
|
|
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 {
|
|
1
|
+
export type { VerifyResult } from '../attestation';
|
|
2
|
+
export { computeMigrationId, verifyMigration, verifyMigrationBundle } from '../attestation';
|
package/src/exports/io.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { hasMigrationTs, writeMigrationTs } from '../migration-ts';
|
|
2
2
|
export type { ScaffoldRuntime } from '../runtime-detection';
|
|
3
3
|
export { detectScaffoldRuntime, shebangLineFor } from '../runtime-detection';
|
package/src/exports/types.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
export { MigrationToolsError } from '../errors';
|
|
2
2
|
export type {
|
|
3
|
-
|
|
4
|
-
|
|
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';
|