@prisma-next/migration-tools 0.5.0-dev.2 → 0.5.0-dev.20
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 +34 -22
- package/dist/{constants-BRi0X7B_.mjs → constants-DOzBI2EP.mjs} +1 -1
- package/dist/{constants-BRi0X7B_.mjs.map → constants-DOzBI2EP.mjs.map} +1 -1
- package/dist/{errors-BKbRGCJM.mjs → errors-BS_Kq8GF.mjs} +83 -21
- package/dist/errors-BS_Kq8GF.mjs.map +1 -0
- package/dist/exports/constants.mjs +1 -1
- package/dist/exports/{types.d.mts → errors.d.mts} +6 -8
- package/dist/exports/errors.d.mts.map +1 -0
- package/dist/exports/errors.mjs +3 -0
- package/dist/exports/graph.d.mts +2 -0
- package/dist/exports/graph.mjs +1 -0
- package/dist/exports/hash.d.mts +52 -0
- package/dist/exports/hash.d.mts.map +1 -0
- package/dist/exports/hash.mjs +3 -0
- package/dist/exports/invariants.d.mts +24 -0
- package/dist/exports/invariants.d.mts.map +1 -0
- package/dist/exports/invariants.mjs +4 -0
- package/dist/exports/io.d.mts +7 -6
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +166 -2
- package/dist/exports/io.mjs.map +1 -0
- package/dist/exports/metadata.d.mts +2 -0
- package/dist/exports/metadata.mjs +1 -0
- package/dist/exports/{dag.d.mts → migration-graph.d.mts} +10 -9
- package/dist/exports/migration-graph.d.mts.map +1 -0
- package/dist/exports/{dag.mjs → migration-graph.mjs} +18 -17
- package/dist/exports/migration-graph.mjs.map +1 -0
- package/dist/exports/migration-ts.mjs +1 -1
- package/dist/exports/migration.d.mts +13 -10
- package/dist/exports/migration.d.mts.map +1 -1
- package/dist/exports/migration.mjs +23 -21
- package/dist/exports/migration.mjs.map +1 -1
- package/dist/exports/package.d.mts +2 -0
- package/dist/exports/package.mjs +1 -0
- package/dist/exports/refs.d.mts +11 -5
- package/dist/exports/refs.d.mts.map +1 -1
- package/dist/exports/refs.mjs +106 -30
- package/dist/exports/refs.mjs.map +1 -1
- package/dist/graph-coc0V7k2.d.mts +28 -0
- package/dist/graph-coc0V7k2.d.mts.map +1 -0
- package/dist/hash-BARZdVgW.mjs +76 -0
- package/dist/hash-BARZdVgW.mjs.map +1 -0
- package/dist/invariants-jlMTqh_Q.mjs +42 -0
- package/dist/invariants-jlMTqh_Q.mjs.map +1 -0
- package/dist/metadata-CdSwaQ2k.d.mts +51 -0
- package/dist/metadata-CdSwaQ2k.d.mts.map +1 -0
- package/dist/package-DFjGigEm.d.mts +21 -0
- package/dist/package-DFjGigEm.d.mts.map +1 -0
- package/package.json +30 -14
- package/src/errors.ts +106 -15
- package/src/exports/errors.ts +1 -0
- package/src/exports/graph.ts +1 -0
- package/src/exports/hash.ts +2 -0
- package/src/exports/invariants.ts +1 -0
- package/src/exports/io.ts +1 -1
- package/src/exports/metadata.ts +1 -0
- package/src/exports/{dag.ts → migration-graph.ts} +2 -2
- package/src/exports/package.ts +1 -0
- package/src/exports/refs.ts +10 -2
- package/src/graph.ts +25 -0
- package/src/hash.ts +91 -0
- package/src/invariants.ts +45 -0
- package/src/io.ts +55 -20
- package/src/metadata.ts +42 -0
- package/src/migration-base.ts +34 -28
- package/src/{dag.ts → migration-graph.ts} +36 -38
- package/src/package.ts +18 -0
- package/src/refs.ts +148 -37
- package/dist/attestation-DtF8tEOM.mjs +0 -65
- package/dist/attestation-DtF8tEOM.mjs.map +0 -1
- package/dist/errors-BKbRGCJM.mjs.map +0 -1
- package/dist/exports/attestation.d.mts +0 -37
- package/dist/exports/attestation.d.mts.map +0 -1
- package/dist/exports/attestation.mjs +0 -4
- package/dist/exports/dag.d.mts.map +0 -1
- package/dist/exports/dag.mjs.map +0 -1
- package/dist/exports/types.d.mts.map +0 -1
- package/dist/exports/types.mjs +0 -3
- package/dist/io-CCnYsUHU.mjs +0 -153
- package/dist/io-CCnYsUHU.mjs.map +0 -1
- package/dist/types-DyGXcWWp.d.mts +0 -71
- package/dist/types-DyGXcWWp.d.mts.map +0 -1
- package/src/attestation.ts +0 -81
- package/src/exports/attestation.ts +0 -2
- package/src/exports/types.ts +0 -10
- package/src/types.ts +0 -66
package/src/migration-base.ts
CHANGED
|
@@ -8,8 +8,10 @@ import type {
|
|
|
8
8
|
} from '@prisma-next/framework-components/control';
|
|
9
9
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
10
10
|
import { type } from 'arktype';
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
11
|
+
import { computeMigrationHash } from './hash';
|
|
12
|
+
import { deriveProvidedInvariants } from './invariants';
|
|
13
|
+
import type { MigrationHints, MigrationMetadata } from './metadata';
|
|
14
|
+
import type { MigrationOps } from './package';
|
|
13
15
|
|
|
14
16
|
export interface MigrationMeta {
|
|
15
17
|
readonly from: string;
|
|
@@ -30,7 +32,7 @@ const MigrationMetaSchema = type({
|
|
|
30
32
|
*
|
|
31
33
|
* A `Migration` subclass is itself a `MigrationPlan`: CLI commands and the
|
|
32
34
|
* runner can consume it directly via `targetId`, `operations`, `origin`, and
|
|
33
|
-
* `destination`. The
|
|
35
|
+
* `destination`. The metadata-shaped inputs come from `describe()`, which
|
|
34
36
|
* every migration must implement — `migration.json` is required for a
|
|
35
37
|
* migration to be valid.
|
|
36
38
|
*/
|
|
@@ -123,64 +125,68 @@ function printHelp(): void {
|
|
|
123
125
|
|
|
124
126
|
/**
|
|
125
127
|
* In-memory artifacts produced from a `Migration` instance: the
|
|
126
|
-
* serialized `ops.json` body, the `migration.json`
|
|
128
|
+
* serialized `ops.json` body, the `migration.json` metadata object, and
|
|
127
129
|
* its serialized form. Returned by `buildMigrationArtifacts` so callers
|
|
128
130
|
* (today: `MigrationCLI.run` in `@prisma-next/cli/migration-cli`) can
|
|
129
131
|
* decide how to persist them — write to disk, print in dry-run, ship
|
|
130
132
|
* over the wire — without coupling artifact construction to file I/O.
|
|
133
|
+
*
|
|
134
|
+
* `metadataJson` is `JSON.stringify(metadata, null, 2)` — the canonical
|
|
135
|
+
* on-disk shape that the arktype loader-schema in `./io` validates.
|
|
131
136
|
*/
|
|
132
137
|
export interface MigrationArtifacts {
|
|
133
138
|
readonly opsJson: string;
|
|
134
|
-
readonly
|
|
135
|
-
readonly
|
|
139
|
+
readonly metadata: MigrationMetadata;
|
|
140
|
+
readonly metadataJson: string;
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
/**
|
|
139
|
-
* Build the attested
|
|
140
|
-
* operations list, and the previously-scaffolded
|
|
144
|
+
* Build the attested metadata from `describe()`-derived metadata, the
|
|
145
|
+
* operations list, and the previously-scaffolded metadata (if any).
|
|
141
146
|
*
|
|
142
147
|
* When a `migration.json` already exists for this package (the common
|
|
143
148
|
* case: it was scaffolded by `migration plan`), preserve the contract
|
|
144
149
|
* bookends, hints, labels, and `createdAt` set there — those fields are
|
|
145
150
|
* owned by the CLI scaffolder, not the authored class. Only the
|
|
146
151
|
* `describe()`-derived fields (`from`, `to`, `kind`) and the operations
|
|
147
|
-
* change as the author iterates. When no
|
|
152
|
+
* change as the author iterates. When no metadata exists yet (a bare
|
|
148
153
|
* `migration.ts` run from scratch), synthesize a minimal but
|
|
149
|
-
* schema-conformant
|
|
154
|
+
* schema-conformant record so the resulting package can still be read,
|
|
150
155
|
* verified, and applied.
|
|
151
156
|
*
|
|
152
|
-
* The `
|
|
157
|
+
* The `migrationHash` is recomputed against the current metadata + ops so
|
|
153
158
|
* the on-disk artifacts are always fully attested.
|
|
154
159
|
*/
|
|
155
|
-
function
|
|
160
|
+
function buildAttestedMetadata(
|
|
156
161
|
meta: MigrationMeta,
|
|
157
162
|
ops: MigrationOps,
|
|
158
|
-
existing: Partial<
|
|
159
|
-
):
|
|
160
|
-
const
|
|
163
|
+
existing: Partial<MigrationMetadata> | null,
|
|
164
|
+
): MigrationMetadata {
|
|
165
|
+
const baseMetadata: Omit<MigrationMetadata, 'migrationHash'> = {
|
|
161
166
|
from: meta.from,
|
|
162
167
|
to: meta.to,
|
|
163
168
|
kind: meta.kind ?? 'regular',
|
|
164
169
|
labels: meta.labels ?? existing?.labels ?? [],
|
|
170
|
+
providedInvariants: deriveProvidedInvariants(ops),
|
|
165
171
|
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
166
172
|
fromContract: existing?.fromContract ?? null,
|
|
167
|
-
// When no scaffolded
|
|
173
|
+
// When no scaffolded metadata exists we synthesize a minimal contract
|
|
168
174
|
// stub so the package is still readable end-to-end. The cast is
|
|
169
175
|
// intentional: only the storage bookend matters for hash computation
|
|
170
|
-
// (everything else is stripped by `
|
|
176
|
+
// (everything else is stripped by `computeMigrationHash`), and a real
|
|
171
177
|
// contract bookend would only be available after `migration plan`.
|
|
172
178
|
toContract: existing?.toContract ?? ({ storage: { storageHash: meta.to } } as Contract),
|
|
173
179
|
hints: normalizeHints(existing?.hints),
|
|
174
180
|
...ifDefined('authorship', existing?.authorship),
|
|
175
181
|
};
|
|
176
182
|
|
|
177
|
-
const
|
|
178
|
-
return { ...
|
|
183
|
+
const migrationHash = computeMigrationHash(baseMetadata, ops);
|
|
184
|
+
return { ...baseMetadata, migrationHash };
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
/**
|
|
182
188
|
* Project `existing.hints` down to the known `MigrationHints` shape, dropping
|
|
183
|
-
* any legacy keys that may linger in
|
|
189
|
+
* any legacy keys that may linger in metadata scaffolded by older CLI
|
|
184
190
|
* versions (e.g. `planningStrategy`). Picking fields explicitly instead of
|
|
185
191
|
* spreading keeps refreshed `migration.json` files schema-clean regardless
|
|
186
192
|
* of what was on disk before.
|
|
@@ -195,16 +201,16 @@ function normalizeHints(existing: MigrationHints | undefined): MigrationHints {
|
|
|
195
201
|
|
|
196
202
|
/**
|
|
197
203
|
* Pure conversion from a `Migration` instance (plus the previously
|
|
198
|
-
* scaffolded
|
|
204
|
+
* scaffolded metadata, when one exists on disk) to the in-memory
|
|
199
205
|
* artifacts that downstream tooling persists. Owns metadata validation,
|
|
200
|
-
*
|
|
201
|
-
* content-addressed `
|
|
206
|
+
* metadata synthesis/preservation, hint normalization, and the
|
|
207
|
+
* content-addressed `migrationHash` computation, but performs no file I/O
|
|
202
208
|
* — callers handle reads (to source `existing`) and writes (to persist
|
|
203
|
-
* `opsJson` / `
|
|
209
|
+
* `opsJson` / `metadataJson`).
|
|
204
210
|
*/
|
|
205
211
|
export function buildMigrationArtifacts(
|
|
206
212
|
instance: Migration,
|
|
207
|
-
existing: Partial<
|
|
213
|
+
existing: Partial<MigrationMetadata> | null,
|
|
208
214
|
): MigrationArtifacts {
|
|
209
215
|
const ops = instance.operations;
|
|
210
216
|
if (!Array.isArray(ops)) {
|
|
@@ -217,11 +223,11 @@ export function buildMigrationArtifacts(
|
|
|
217
223
|
throw new Error(`describe() returned invalid metadata: ${parsed.summary}`);
|
|
218
224
|
}
|
|
219
225
|
|
|
220
|
-
const
|
|
226
|
+
const metadata = buildAttestedMetadata(parsed, ops, existing);
|
|
221
227
|
|
|
222
228
|
return {
|
|
223
229
|
opsJson: JSON.stringify(ops, null, 2),
|
|
224
|
-
|
|
225
|
-
|
|
230
|
+
metadata,
|
|
231
|
+
metadataJson: JSON.stringify(metadata, null, 2),
|
|
226
232
|
};
|
|
227
233
|
}
|
|
@@ -2,13 +2,14 @@ import { ifDefined } from '@prisma-next/utils/defined';
|
|
|
2
2
|
import { EMPTY_CONTRACT_HASH } from './constants';
|
|
3
3
|
import {
|
|
4
4
|
errorAmbiguousTarget,
|
|
5
|
-
|
|
5
|
+
errorDuplicateMigrationHash,
|
|
6
6
|
errorNoInitialMigration,
|
|
7
7
|
errorNoTarget,
|
|
8
8
|
errorSameSourceAndTarget,
|
|
9
9
|
} from './errors';
|
|
10
|
+
import type { MigrationEdge, MigrationGraph } from './graph';
|
|
10
11
|
import { bfs } from './graph-ops';
|
|
11
|
-
import type {
|
|
12
|
+
import type { MigrationPackage } from './package';
|
|
12
13
|
|
|
13
14
|
/** Forward-edge neighbours for BFS: edge `e` from `n` visits `e.to` next. */
|
|
14
15
|
function forwardNeighbours(graph: MigrationGraph, node: string) {
|
|
@@ -20,57 +21,54 @@ function reverseNeighbours(graph: MigrationGraph, node: string) {
|
|
|
20
21
|
return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function appendEdge(
|
|
24
|
-
map: Map<string, MigrationChainEntry[]>,
|
|
25
|
-
key: string,
|
|
26
|
-
entry: MigrationChainEntry,
|
|
27
|
-
): void {
|
|
24
|
+
function appendEdge(map: Map<string, MigrationEdge[]>, key: string, entry: MigrationEdge): void {
|
|
28
25
|
const bucket = map.get(key);
|
|
29
26
|
if (bucket) bucket.push(entry);
|
|
30
27
|
else map.set(key, [entry]);
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
export function reconstructGraph(packages: readonly
|
|
30
|
+
export function reconstructGraph(packages: readonly MigrationPackage[]): MigrationGraph {
|
|
34
31
|
const nodes = new Set<string>();
|
|
35
|
-
const forwardChain = new Map<string,
|
|
36
|
-
const reverseChain = new Map<string,
|
|
37
|
-
const
|
|
32
|
+
const forwardChain = new Map<string, MigrationEdge[]>();
|
|
33
|
+
const reverseChain = new Map<string, MigrationEdge[]>();
|
|
34
|
+
const migrationByHash = new Map<string, MigrationEdge>();
|
|
38
35
|
|
|
39
36
|
for (const pkg of packages) {
|
|
40
|
-
const { from, to } = pkg.
|
|
37
|
+
const { from, to } = pkg.metadata;
|
|
41
38
|
|
|
42
39
|
if (from === to) {
|
|
43
|
-
throw errorSameSourceAndTarget(pkg.
|
|
40
|
+
throw errorSameSourceAndTarget(pkg.dirPath, from);
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
nodes.add(from);
|
|
47
44
|
nodes.add(to);
|
|
48
45
|
|
|
49
|
-
const migration:
|
|
46
|
+
const migration: MigrationEdge = {
|
|
50
47
|
from,
|
|
51
48
|
to,
|
|
52
|
-
|
|
49
|
+
migrationHash: pkg.metadata.migrationHash,
|
|
53
50
|
dirName: pkg.dirName,
|
|
54
|
-
createdAt: pkg.
|
|
55
|
-
labels: pkg.
|
|
51
|
+
createdAt: pkg.metadata.createdAt,
|
|
52
|
+
labels: pkg.metadata.labels,
|
|
53
|
+
invariants: pkg.metadata.providedInvariants,
|
|
56
54
|
};
|
|
57
55
|
|
|
58
|
-
if (
|
|
59
|
-
throw
|
|
56
|
+
if (migrationByHash.has(migration.migrationHash)) {
|
|
57
|
+
throw errorDuplicateMigrationHash(migration.migrationHash);
|
|
60
58
|
}
|
|
61
|
-
|
|
59
|
+
migrationByHash.set(migration.migrationHash, migration);
|
|
62
60
|
|
|
63
61
|
appendEdge(forwardChain, from, migration);
|
|
64
62
|
appendEdge(reverseChain, to, migration);
|
|
65
63
|
}
|
|
66
64
|
|
|
67
|
-
return { nodes, forwardChain, reverseChain,
|
|
65
|
+
return { nodes, forwardChain, reverseChain, migrationByHash };
|
|
68
66
|
}
|
|
69
67
|
|
|
70
68
|
// ---------------------------------------------------------------------------
|
|
71
69
|
// Deterministic tie-breaking for BFS neighbour order.
|
|
72
70
|
// Used by `findPath` and `findPathWithDecision` only; not a general-purpose
|
|
73
|
-
// utility. Ordering: label priority → createdAt → to →
|
|
71
|
+
// utility. Ordering: label priority → createdAt → to → migrationHash.
|
|
74
72
|
// ---------------------------------------------------------------------------
|
|
75
73
|
|
|
76
74
|
const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
|
|
@@ -84,24 +82,24 @@ function labelPriority(labels: readonly string[]): number {
|
|
|
84
82
|
return best;
|
|
85
83
|
}
|
|
86
84
|
|
|
87
|
-
function compareTieBreak(a:
|
|
85
|
+
function compareTieBreak(a: MigrationEdge, b: MigrationEdge): number {
|
|
88
86
|
const lp = labelPriority(a.labels) - labelPriority(b.labels);
|
|
89
87
|
if (lp !== 0) return lp;
|
|
90
88
|
const ca = a.createdAt.localeCompare(b.createdAt);
|
|
91
89
|
if (ca !== 0) return ca;
|
|
92
90
|
const tc = a.to.localeCompare(b.to);
|
|
93
91
|
if (tc !== 0) return tc;
|
|
94
|
-
return a.
|
|
92
|
+
return a.migrationHash.localeCompare(b.migrationHash);
|
|
95
93
|
}
|
|
96
94
|
|
|
97
|
-
function sortedNeighbors(edges: readonly
|
|
95
|
+
function sortedNeighbors(edges: readonly MigrationEdge[]): readonly MigrationEdge[] {
|
|
98
96
|
return [...edges].sort(compareTieBreak);
|
|
99
97
|
}
|
|
100
98
|
|
|
101
99
|
/** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */
|
|
102
100
|
function bfsOrdering(
|
|
103
|
-
items: readonly { next: string; edge:
|
|
104
|
-
): readonly { next: string; edge:
|
|
101
|
+
items: readonly { next: string; edge: MigrationEdge }[],
|
|
102
|
+
): readonly { next: string; edge: MigrationEdge }[] {
|
|
105
103
|
return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));
|
|
106
104
|
}
|
|
107
105
|
|
|
@@ -111,22 +109,22 @@ function bfsOrdering(
|
|
|
111
109
|
* exists. Returns an empty array when `fromHash === toHash` (no-op).
|
|
112
110
|
*
|
|
113
111
|
* Neighbor ordering is deterministic via the tie-break sort key:
|
|
114
|
-
* label priority → createdAt → to →
|
|
112
|
+
* label priority → createdAt → to → migrationHash.
|
|
115
113
|
*/
|
|
116
114
|
export function findPath(
|
|
117
115
|
graph: MigrationGraph,
|
|
118
116
|
fromHash: string,
|
|
119
117
|
toHash: string,
|
|
120
|
-
): readonly
|
|
118
|
+
): readonly MigrationEdge[] | null {
|
|
121
119
|
if (fromHash === toHash) return [];
|
|
122
120
|
|
|
123
|
-
const parents = new Map<string, { parent: string; edge:
|
|
121
|
+
const parents = new Map<string, { parent: string; edge: MigrationEdge }>();
|
|
124
122
|
for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n), bfsOrdering)) {
|
|
125
123
|
if (step.parent !== null && step.incomingEdge !== null) {
|
|
126
124
|
parents.set(step.node, { parent: step.parent, edge: step.incomingEdge });
|
|
127
125
|
}
|
|
128
126
|
if (step.node === toHash) {
|
|
129
|
-
const path:
|
|
127
|
+
const path: MigrationEdge[] = [];
|
|
130
128
|
let cur = toHash;
|
|
131
129
|
let p = parents.get(cur);
|
|
132
130
|
while (p) {
|
|
@@ -155,7 +153,7 @@ function collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<
|
|
|
155
153
|
}
|
|
156
154
|
|
|
157
155
|
export interface PathDecision {
|
|
158
|
-
readonly selectedPath: readonly
|
|
156
|
+
readonly selectedPath: readonly MigrationEdge[];
|
|
159
157
|
readonly fromHash: string;
|
|
160
158
|
readonly toHash: string;
|
|
161
159
|
readonly alternativeCount: number;
|
|
@@ -202,8 +200,8 @@ export function findPathWithDecision(
|
|
|
202
200
|
if (reachable.length > 1) {
|
|
203
201
|
alternativeCount += reachable.length - 1;
|
|
204
202
|
const sorted = sortedNeighbors(reachable);
|
|
205
|
-
if (sorted[0] && sorted[0].
|
|
206
|
-
if (reachable.some((e) => e.
|
|
203
|
+
if (sorted[0] && sorted[0].migrationHash === edge.migrationHash) {
|
|
204
|
+
if (reachable.some((e) => e.migrationHash !== edge.migrationHash)) {
|
|
207
205
|
tieBreakReasons.push(
|
|
208
206
|
`at ${edge.from}: ${reachable.length} candidates, selected by tie-break`,
|
|
209
207
|
);
|
|
@@ -319,7 +317,7 @@ export function findLeaf(graph: MigrationGraph): string | null {
|
|
|
319
317
|
* to the single target. Returns null for an empty graph.
|
|
320
318
|
* Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
|
|
321
319
|
*/
|
|
322
|
-
export function findLatestMigration(graph: MigrationGraph):
|
|
320
|
+
export function findLatestMigration(graph: MigrationGraph): MigrationEdge | null {
|
|
323
321
|
const leafHash = findLeaf(graph);
|
|
324
322
|
if (leafHash === null) return null;
|
|
325
323
|
|
|
@@ -343,7 +341,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
|
|
|
343
341
|
// Iterative three-color DFS. A frame is (node, outgoing edges, next-index).
|
|
344
342
|
interface Frame {
|
|
345
343
|
node: string;
|
|
346
|
-
outgoing: readonly
|
|
344
|
+
outgoing: readonly MigrationEdge[];
|
|
347
345
|
index: number;
|
|
348
346
|
}
|
|
349
347
|
const stack: Frame[] = [];
|
|
@@ -389,7 +387,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
|
|
|
389
387
|
return cycles;
|
|
390
388
|
}
|
|
391
389
|
|
|
392
|
-
export function detectOrphans(graph: MigrationGraph): readonly
|
|
390
|
+
export function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[] {
|
|
393
391
|
if (graph.nodes.size === 0) return [];
|
|
394
392
|
|
|
395
393
|
const reachable = new Set<string>();
|
|
@@ -415,7 +413,7 @@ export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEnt
|
|
|
415
413
|
reachable.add(step.node);
|
|
416
414
|
}
|
|
417
415
|
|
|
418
|
-
const orphans:
|
|
416
|
+
const orphans: MigrationEdge[] = [];
|
|
419
417
|
for (const [from, migrations] of graph.forwardChain) {
|
|
420
418
|
if (!reachable.has(from)) {
|
|
421
419
|
orphans.push(...migrations);
|
package/src/package.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MigrationPlanOperation } from '@prisma-next/framework-components/control';
|
|
2
|
+
import type { MigrationMetadata } from './metadata';
|
|
3
|
+
|
|
4
|
+
export type MigrationOps = readonly MigrationPlanOperation[];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An on-disk migration directory (a "package") with its parsed metadata and
|
|
8
|
+
* operations. Returned from `readMigrationPackage` / `readMigrationsDir` only
|
|
9
|
+
* after the loader has verified the package's integrity (hash recomputation
|
|
10
|
+
* against the stored `migrationHash`); holding a `MigrationPackage` value
|
|
11
|
+
* therefore implies the package is internally consistent.
|
|
12
|
+
*/
|
|
13
|
+
export interface MigrationPackage {
|
|
14
|
+
readonly dirName: string;
|
|
15
|
+
readonly dirPath: string;
|
|
16
|
+
readonly metadata: MigrationMetadata;
|
|
17
|
+
readonly ops: MigrationOps;
|
|
18
|
+
}
|
package/src/refs.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readdir, readFile, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { type } from 'arktype';
|
|
3
|
-
import { dirname, join } from 'pathe';
|
|
3
|
+
import { dirname, join, relative } from 'pathe';
|
|
4
4
|
import {
|
|
5
|
+
errorInvalidRefFile,
|
|
5
6
|
errorInvalidRefName,
|
|
6
|
-
errorInvalidRefs,
|
|
7
7
|
errorInvalidRefValue,
|
|
8
8
|
MigrationToolsError,
|
|
9
9
|
} from './errors';
|
|
10
10
|
|
|
11
|
-
export
|
|
11
|
+
export interface RefEntry {
|
|
12
|
+
readonly hash: string;
|
|
13
|
+
readonly invariants: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Refs = Readonly<Record<string, RefEntry>>;
|
|
12
17
|
|
|
13
18
|
const REF_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\/[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
|
14
19
|
const REF_VALUE_PATTERN = /^sha256:(empty|[0-9a-f]{64})$/;
|
|
@@ -25,22 +30,40 @@ export function validateRefValue(value: string): boolean {
|
|
|
25
30
|
return REF_VALUE_PATTERN.test(value);
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const RefEntrySchema = type({
|
|
34
|
+
hash: 'string',
|
|
35
|
+
invariants: 'string[]',
|
|
36
|
+
}).narrow((entry, ctx) => {
|
|
37
|
+
if (!validateRefValue(entry.hash))
|
|
38
|
+
return ctx.mustBe(`a valid contract hash (got "${entry.hash}")`);
|
|
34
39
|
return true;
|
|
35
40
|
});
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
function refFilePath(refsDir: string, name: string): string {
|
|
43
|
+
return join(refsDir, `${name}.json`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function refNameFromPath(refsDir: string, filePath: string): string {
|
|
47
|
+
const rel = relative(refsDir, filePath);
|
|
48
|
+
return rel.replace(/\.json$/, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readRef(refsDir: string, name: string): Promise<RefEntry> {
|
|
52
|
+
if (!validateRefName(name)) {
|
|
53
|
+
throw errorInvalidRefName(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const filePath = refFilePath(refsDir, name);
|
|
38
57
|
let raw: string;
|
|
39
58
|
try {
|
|
40
|
-
raw = await readFile(
|
|
59
|
+
raw = await readFile(filePath, 'utf-8');
|
|
41
60
|
} catch (error) {
|
|
42
61
|
if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
|
|
43
|
-
|
|
62
|
+
throw new MigrationToolsError('MIGRATION.UNKNOWN_REF', `Unknown ref "${name}"`, {
|
|
63
|
+
why: `No ref file found at "${filePath}".`,
|
|
64
|
+
fix: `Create the ref with: prisma-next migration ref set ${name} <hash>`,
|
|
65
|
+
details: { refName: name, filePath },
|
|
66
|
+
});
|
|
44
67
|
}
|
|
45
68
|
throw error;
|
|
46
69
|
}
|
|
@@ -49,54 +72,142 @@ export async function readRefs(refsPath: string): Promise<Refs> {
|
|
|
49
72
|
try {
|
|
50
73
|
parsed = JSON.parse(raw);
|
|
51
74
|
} catch {
|
|
52
|
-
throw
|
|
75
|
+
throw errorInvalidRefFile(filePath, 'Failed to parse as JSON');
|
|
53
76
|
}
|
|
54
77
|
|
|
55
|
-
const result =
|
|
78
|
+
const result = RefEntrySchema(parsed);
|
|
56
79
|
if (result instanceof type.errors) {
|
|
57
|
-
throw
|
|
80
|
+
throw errorInvalidRefFile(filePath, result.summary);
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
return result;
|
|
61
84
|
}
|
|
62
85
|
|
|
63
|
-
export async function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
export async function readRefs(refsDir: string): Promise<Refs> {
|
|
87
|
+
let entries: string[];
|
|
88
|
+
try {
|
|
89
|
+
entries = await readdir(refsDir, { recursive: true, encoding: 'utf-8' });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const jsonFiles = entries.filter((entry) => entry.endsWith('.json'));
|
|
98
|
+
const refs: Record<string, RefEntry> = {};
|
|
99
|
+
|
|
100
|
+
for (const jsonFile of jsonFiles) {
|
|
101
|
+
const filePath = join(refsDir, jsonFile);
|
|
102
|
+
const name = refNameFromPath(refsDir, filePath);
|
|
103
|
+
|
|
104
|
+
let raw: string;
|
|
105
|
+
try {
|
|
106
|
+
raw = await readFile(filePath, 'utf-8');
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// Tolerate the TOCTOU race between `readdir` and `readFile` (ENOENT) and
|
|
109
|
+
// benign EISDIR if a directory happens to end in `.json`. Anything else
|
|
110
|
+
// (EACCES, EIO, EMFILE, …) is a real failure and propagates so the CLI
|
|
111
|
+
// surfaces it rather than silently dropping the ref.
|
|
112
|
+
const code = error instanceof Error ? (error as { code?: string }).code : undefined;
|
|
113
|
+
if (code === 'ENOENT' || code === 'EISDIR') {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let parsed: unknown;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(raw);
|
|
122
|
+
} catch {
|
|
123
|
+
throw errorInvalidRefFile(filePath, 'Failed to parse as JSON');
|
|
67
124
|
}
|
|
68
|
-
|
|
69
|
-
|
|
125
|
+
|
|
126
|
+
const result = RefEntrySchema(parsed);
|
|
127
|
+
if (result instanceof type.errors) {
|
|
128
|
+
throw errorInvalidRefFile(filePath, result.summary);
|
|
70
129
|
}
|
|
130
|
+
|
|
131
|
+
refs[name] = result;
|
|
71
132
|
}
|
|
72
133
|
|
|
73
|
-
|
|
134
|
+
return refs;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function writeRef(refsDir: string, name: string, entry: RefEntry): Promise<void> {
|
|
138
|
+
if (!validateRefName(name)) {
|
|
139
|
+
throw errorInvalidRefName(name);
|
|
140
|
+
}
|
|
141
|
+
if (!validateRefValue(entry.hash)) {
|
|
142
|
+
throw errorInvalidRefValue(entry.hash);
|
|
143
|
+
}
|
|
74
144
|
|
|
75
|
-
const
|
|
145
|
+
const filePath = refFilePath(refsDir, name);
|
|
146
|
+
const dir = dirname(filePath);
|
|
76
147
|
await mkdir(dir, { recursive: true });
|
|
77
148
|
|
|
78
|
-
const tmpPath = join(dir,
|
|
79
|
-
await writeFile(
|
|
80
|
-
|
|
149
|
+
const tmpPath = join(dir, `.${name.split('/').pop()}.json.${Date.now()}.tmp`);
|
|
150
|
+
await writeFile(
|
|
151
|
+
tmpPath,
|
|
152
|
+
`${JSON.stringify({ hash: entry.hash, invariants: [...entry.invariants] }, null, 2)}\n`,
|
|
153
|
+
);
|
|
154
|
+
await rename(tmpPath, filePath);
|
|
81
155
|
}
|
|
82
156
|
|
|
83
|
-
export function
|
|
157
|
+
export async function deleteRef(refsDir: string, name: string): Promise<void> {
|
|
84
158
|
if (!validateRefName(name)) {
|
|
85
159
|
throw errorInvalidRefName(name);
|
|
86
160
|
}
|
|
87
161
|
|
|
88
|
-
const
|
|
89
|
-
|
|
162
|
+
const filePath = refFilePath(refsDir, name);
|
|
163
|
+
try {
|
|
164
|
+
await unlink(filePath);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
|
|
167
|
+
throw new MigrationToolsError('MIGRATION.UNKNOWN_REF', `Unknown ref "${name}"`, {
|
|
168
|
+
why: `No ref file found at "${filePath}".`,
|
|
169
|
+
fix: 'Run `prisma-next migration ref list` to see available refs.',
|
|
170
|
+
details: { refName: name, filePath },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Clean empty parent directories up to refsDir. Stop walking on the expected
|
|
177
|
+
// "directory has siblings" signal (ENOTEMPTY on Linux, EEXIST on some BSDs)
|
|
178
|
+
// and on ENOENT (concurrent removal). Anything else (EACCES, EIO, …) is a
|
|
179
|
+
// real failure and propagates.
|
|
180
|
+
let dir = dirname(filePath);
|
|
181
|
+
while (dir !== refsDir && dir.startsWith(refsDir)) {
|
|
182
|
+
try {
|
|
183
|
+
await rmdir(dir);
|
|
184
|
+
dir = dirname(dir);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const code = error instanceof Error ? (error as { code?: string }).code : undefined;
|
|
187
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST' || code === 'ENOENT') {
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function resolveRef(refs: Refs, name: string): RefEntry {
|
|
196
|
+
if (!validateRefName(name)) {
|
|
197
|
+
throw errorInvalidRefName(name);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Object.hasOwn gate: plain-object `refs` would otherwise let
|
|
201
|
+
// `refs['constructor']` return Object.prototype.constructor and bypass the
|
|
202
|
+
// UNKNOWN_REF throw. validateRefName accepts `"constructor"` as a name shape.
|
|
203
|
+
if (!Object.hasOwn(refs, name)) {
|
|
90
204
|
throw new MigrationToolsError('MIGRATION.UNKNOWN_REF', `Unknown ref "${name}"`, {
|
|
91
|
-
why: `No ref named "${name}" exists
|
|
92
|
-
fix: `Available refs: ${Object.keys(refs).join(', ') || '(none)'}. Create a ref with: set
|
|
205
|
+
why: `No ref named "${name}" exists.`,
|
|
206
|
+
fix: `Available refs: ${Object.keys(refs).join(', ') || '(none)'}. Create a ref with: prisma-next migration ref set ${name} <hash>`,
|
|
93
207
|
details: { refName: name, availableRefs: Object.keys(refs) },
|
|
94
208
|
});
|
|
95
209
|
}
|
|
96
210
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return hash;
|
|
211
|
+
// biome-ignore lint/style/noNonNullAssertion: Object.hasOwn gate above guarantees this is defined
|
|
212
|
+
return refs[name]!;
|
|
102
213
|
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { r as readMigrationPackage } from "./io-CCnYsUHU.mjs";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
|
|
4
|
-
//#region src/canonicalize-json.ts
|
|
5
|
-
function sortKeys(value) {
|
|
6
|
-
if (value === null || typeof value !== "object") return value;
|
|
7
|
-
if (Array.isArray(value)) return value.map(sortKeys);
|
|
8
|
-
const sorted = {};
|
|
9
|
-
for (const key of Object.keys(value).sort()) sorted[key] = sortKeys(value[key]);
|
|
10
|
-
return sorted;
|
|
11
|
-
}
|
|
12
|
-
function canonicalizeJson(value) {
|
|
13
|
-
return JSON.stringify(sortKeys(value));
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
//#endregion
|
|
17
|
-
//#region src/attestation.ts
|
|
18
|
-
function sha256Hex(input) {
|
|
19
|
-
return createHash("sha256").update(input).digest("hex");
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Content-addressed migration identity over (manifest envelope sans
|
|
23
|
-
* contracts/hints, ops). See ADR 199 "Storage-only migration identity"
|
|
24
|
-
* for the rationale: contracts are anchored separately by the
|
|
25
|
-
* storage-hash bookends inside the envelope; planner hints are advisory
|
|
26
|
-
* and must not affect identity.
|
|
27
|
-
*
|
|
28
|
-
* The `migrationId` field on the manifest is stripped before hashing so
|
|
29
|
-
* the function can be used both at write time (when no id exists yet)
|
|
30
|
-
* and at verify time (rehashing an already-attested manifest).
|
|
31
|
-
*/
|
|
32
|
-
function computeMigrationId(manifest, ops) {
|
|
33
|
-
const { migrationId: _migrationId, signature: _signature, fromContract: _fromContract, toContract: _toContract, hints: _hints, ...strippedMeta } = manifest;
|
|
34
|
-
return `sha256:${sha256Hex(canonicalizeJson([canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex)))}`;
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Re-hash an on-disk migration bundle and compare against the stored
|
|
38
|
-
* `migrationId`. Returns `{ ok: true }` when the package is internally
|
|
39
|
-
* consistent (manifest + ops still produce the recorded id), or
|
|
40
|
-
* `{ ok: false, reason: 'mismatch', stored, computed }` when they do
|
|
41
|
-
* not — typically a sign of FS corruption, partial writes, or a
|
|
42
|
-
* post-emit hand edit.
|
|
43
|
-
*/
|
|
44
|
-
function verifyMigrationBundle(bundle) {
|
|
45
|
-
const computed = computeMigrationId(bundle.manifest, bundle.ops);
|
|
46
|
-
if (bundle.manifest.migrationId === computed) return {
|
|
47
|
-
ok: true,
|
|
48
|
-
storedMigrationId: bundle.manifest.migrationId,
|
|
49
|
-
computedMigrationId: computed
|
|
50
|
-
};
|
|
51
|
-
return {
|
|
52
|
-
ok: false,
|
|
53
|
-
reason: "mismatch",
|
|
54
|
-
storedMigrationId: bundle.manifest.migrationId,
|
|
55
|
-
computedMigrationId: computed
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
/** Convenience wrapper: read the package from disk then verify it. */
|
|
59
|
-
async function verifyMigration(dir) {
|
|
60
|
-
return verifyMigrationBundle(await readMigrationPackage(dir));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
//#endregion
|
|
64
|
-
export { verifyMigration as n, verifyMigrationBundle as r, computeMigrationId as t };
|
|
65
|
-
//# sourceMappingURL=attestation-DtF8tEOM.mjs.map
|