@prisma-next/migration-tools 0.4.2 → 0.5.0-dev.10
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 +32 -20
- package/dist/{constants-BRi0X7B_.mjs → constants-WVGVMOdu.mjs} +1 -1
- package/dist/{constants-BRi0X7B_.mjs.map → constants-WVGVMOdu.mjs.map} +1 -1
- package/dist/{errors-BmiSgz1j.mjs → errors-CZ9JD4sd.mjs} +45 -16
- package/dist/errors-CZ9JD4sd.mjs.map +1 -0
- package/dist/exports/constants.mjs +1 -1
- package/dist/exports/dag.d.mts +4 -3
- package/dist/exports/dag.d.mts.map +1 -1
- package/dist/exports/dag.mjs +15 -15
- package/dist/exports/dag.mjs.map +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/io.d.mts +7 -6
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +156 -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/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 +20 -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.mjs +2 -2
- package/dist/graph-HiqjZROg.d.mts +22 -0
- package/dist/graph-HiqjZROg.d.mts.map +1 -0
- package/dist/hash-BNWumjn7.mjs +76 -0
- package/dist/hash-BNWumjn7.mjs.map +1 -0
- package/dist/metadata-DDa5L-uD.d.mts +45 -0
- package/dist/metadata-DDa5L-uD.d.mts.map +1 -0
- package/dist/package-BJ5KAEcD.d.mts +21 -0
- package/dist/package-BJ5KAEcD.d.mts.map +1 -0
- package/package.json +23 -11
- package/src/dag.ts +19 -18
- package/src/errors.ts +57 -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/io.ts +1 -1
- package/src/exports/metadata.ts +1 -0
- package/src/exports/package.ts +1 -0
- package/src/graph.ts +19 -0
- package/src/hash.ts +91 -0
- package/src/io.ts +32 -20
- package/src/metadata.ts +36 -0
- package/src/migration-base.ts +32 -28
- package/src/package.ts +18 -0
- package/dist/attestation-BnzTb0Qp.mjs +0 -65
- package/dist/attestation-BnzTb0Qp.mjs.map +0 -1
- package/dist/errors-BmiSgz1j.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/types.d.mts.map +0 -1
- package/dist/exports/types.mjs +0 -3
- package/dist/io-Cd6GLyjK.mjs +0 -153
- package/dist/io-Cd6GLyjK.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/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prisma-next/migration-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-dev.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
|
-
"description": "On-disk migration persistence,
|
|
6
|
+
"description": "On-disk migration persistence, hash verification, and chain reconstruction for Prisma Next",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"arktype": "^2.1.29",
|
|
9
9
|
"pathe": "^2.0.3",
|
|
10
10
|
"prettier": "^3.6.2",
|
|
11
|
-
"@prisma-next/contract": "0.
|
|
12
|
-
"@prisma-next/framework-components": "0.
|
|
13
|
-
"@prisma-next/utils": "0.
|
|
11
|
+
"@prisma-next/contract": "0.5.0-dev.10",
|
|
12
|
+
"@prisma-next/framework-components": "0.5.0-dev.10",
|
|
13
|
+
"@prisma-next/utils": "0.5.0-dev.10"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"tsdown": "0.18.4",
|
|
@@ -27,17 +27,29 @@
|
|
|
27
27
|
"node": ">=20"
|
|
28
28
|
},
|
|
29
29
|
"exports": {
|
|
30
|
-
"./
|
|
31
|
-
"types": "./dist/exports/
|
|
32
|
-
"import": "./dist/exports/
|
|
30
|
+
"./metadata": {
|
|
31
|
+
"types": "./dist/exports/metadata.d.mts",
|
|
32
|
+
"import": "./dist/exports/metadata.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./package": {
|
|
35
|
+
"types": "./dist/exports/package.d.mts",
|
|
36
|
+
"import": "./dist/exports/package.mjs"
|
|
37
|
+
},
|
|
38
|
+
"./graph": {
|
|
39
|
+
"types": "./dist/exports/graph.d.mts",
|
|
40
|
+
"import": "./dist/exports/graph.mjs"
|
|
41
|
+
},
|
|
42
|
+
"./errors": {
|
|
43
|
+
"types": "./dist/exports/errors.d.mts",
|
|
44
|
+
"import": "./dist/exports/errors.mjs"
|
|
33
45
|
},
|
|
34
46
|
"./io": {
|
|
35
47
|
"types": "./dist/exports/io.d.mts",
|
|
36
48
|
"import": "./dist/exports/io.mjs"
|
|
37
49
|
},
|
|
38
|
-
"./
|
|
39
|
-
"types": "./dist/exports/
|
|
40
|
-
"import": "./dist/exports/
|
|
50
|
+
"./hash": {
|
|
51
|
+
"types": "./dist/exports/hash.d.mts",
|
|
52
|
+
"import": "./dist/exports/hash.mjs"
|
|
41
53
|
},
|
|
42
54
|
"./dag": {
|
|
43
55
|
"types": "./dist/exports/dag.d.mts",
|
package/src/dag.ts
CHANGED
|
@@ -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 { MigrationChainEntry, 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) {
|
|
@@ -30,17 +31,17 @@ function appendEdge(
|
|
|
30
31
|
else map.set(key, [entry]);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export function reconstructGraph(packages: readonly
|
|
34
|
+
export function reconstructGraph(packages: readonly MigrationPackage[]): MigrationGraph {
|
|
34
35
|
const nodes = new Set<string>();
|
|
35
36
|
const forwardChain = new Map<string, MigrationChainEntry[]>();
|
|
36
37
|
const reverseChain = new Map<string, MigrationChainEntry[]>();
|
|
37
|
-
const
|
|
38
|
+
const migrationByHash = new Map<string, MigrationChainEntry>();
|
|
38
39
|
|
|
39
40
|
for (const pkg of packages) {
|
|
40
|
-
const { from, to } = pkg.
|
|
41
|
+
const { from, to } = pkg.metadata;
|
|
41
42
|
|
|
42
43
|
if (from === to) {
|
|
43
|
-
throw errorSameSourceAndTarget(pkg.
|
|
44
|
+
throw errorSameSourceAndTarget(pkg.dirPath, from);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
nodes.add(from);
|
|
@@ -49,28 +50,28 @@ export function reconstructGraph(packages: readonly MigrationBundle[]): Migratio
|
|
|
49
50
|
const migration: MigrationChainEntry = {
|
|
50
51
|
from,
|
|
51
52
|
to,
|
|
52
|
-
|
|
53
|
+
migrationHash: pkg.metadata.migrationHash,
|
|
53
54
|
dirName: pkg.dirName,
|
|
54
|
-
createdAt: pkg.
|
|
55
|
-
labels: pkg.
|
|
55
|
+
createdAt: pkg.metadata.createdAt,
|
|
56
|
+
labels: pkg.metadata.labels,
|
|
56
57
|
};
|
|
57
58
|
|
|
58
|
-
if (
|
|
59
|
-
throw
|
|
59
|
+
if (migrationByHash.has(migration.migrationHash)) {
|
|
60
|
+
throw errorDuplicateMigrationHash(migration.migrationHash);
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
+
migrationByHash.set(migration.migrationHash, migration);
|
|
62
63
|
|
|
63
64
|
appendEdge(forwardChain, from, migration);
|
|
64
65
|
appendEdge(reverseChain, to, migration);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
return { nodes, forwardChain, reverseChain,
|
|
68
|
+
return { nodes, forwardChain, reverseChain, migrationByHash };
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// ---------------------------------------------------------------------------
|
|
71
72
|
// Deterministic tie-breaking for BFS neighbour order.
|
|
72
73
|
// Used by `findPath` and `findPathWithDecision` only; not a general-purpose
|
|
73
|
-
// utility. Ordering: label priority → createdAt → to →
|
|
74
|
+
// utility. Ordering: label priority → createdAt → to → migrationHash.
|
|
74
75
|
// ---------------------------------------------------------------------------
|
|
75
76
|
|
|
76
77
|
const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
|
|
@@ -91,7 +92,7 @@ function compareTieBreak(a: MigrationChainEntry, b: MigrationChainEntry): number
|
|
|
91
92
|
if (ca !== 0) return ca;
|
|
92
93
|
const tc = a.to.localeCompare(b.to);
|
|
93
94
|
if (tc !== 0) return tc;
|
|
94
|
-
return a.
|
|
95
|
+
return a.migrationHash.localeCompare(b.migrationHash);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
function sortedNeighbors(edges: readonly MigrationChainEntry[]): readonly MigrationChainEntry[] {
|
|
@@ -111,7 +112,7 @@ function bfsOrdering(
|
|
|
111
112
|
* exists. Returns an empty array when `fromHash === toHash` (no-op).
|
|
112
113
|
*
|
|
113
114
|
* Neighbor ordering is deterministic via the tie-break sort key:
|
|
114
|
-
* label priority → createdAt → to →
|
|
115
|
+
* label priority → createdAt → to → migrationHash.
|
|
115
116
|
*/
|
|
116
117
|
export function findPath(
|
|
117
118
|
graph: MigrationGraph,
|
|
@@ -202,8 +203,8 @@ export function findPathWithDecision(
|
|
|
202
203
|
if (reachable.length > 1) {
|
|
203
204
|
alternativeCount += reachable.length - 1;
|
|
204
205
|
const sorted = sortedNeighbors(reachable);
|
|
205
|
-
if (sorted[0] && sorted[0].
|
|
206
|
-
if (reachable.some((e) => e.
|
|
206
|
+
if (sorted[0] && sorted[0].migrationHash === edge.migrationHash) {
|
|
207
|
+
if (reachable.some((e) => e.migrationHash !== edge.migrationHash)) {
|
|
207
208
|
tieBreakReasons.push(
|
|
208
209
|
`at ${edge.from}: ${reachable.length} candidates, selected by tie-break`,
|
|
209
210
|
);
|
package/src/errors.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
|
+
import { basename, dirname, relative } from 'pathe';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the canonical "re-emit this package" remediation hint.
|
|
5
|
+
*
|
|
6
|
+
* Every on-disk migration package ships its own `migration.ts` author-time
|
|
7
|
+
* file. Running it regenerates `migration.json` and `ops.json` with the
|
|
8
|
+
* correct hash + metadata, so it is the right primitive whenever a single
|
|
9
|
+
* package's on-disk artifacts are missing, malformed, or otherwise corrupt.
|
|
10
|
+
* Pointing users at `migration plan` would emit a *new* package rather than
|
|
11
|
+
* heal the broken one.
|
|
12
|
+
*/
|
|
13
|
+
function reemitHint(dir: string, fallback?: string): string {
|
|
14
|
+
const relativeDir = relative(process.cwd(), dir);
|
|
15
|
+
const reemit = `Re-emit the package by running \`node "${relativeDir}/migration.ts"\``;
|
|
16
|
+
return fallback ? `${reemit}, ${fallback}` : `${reemit}.`;
|
|
17
|
+
}
|
|
18
|
+
|
|
1
19
|
/**
|
|
2
20
|
* Structured error for migration tooling operations.
|
|
3
21
|
*
|
|
4
22
|
* Follows the NAMESPACE.SUBCODE convention from ADR 027. All codes live under
|
|
5
|
-
* the MIGRATION namespace. These are tooling-time errors (file I/O,
|
|
6
|
-
* migration history reconstruction), distinct from the runtime
|
|
7
|
-
* failures (PRECHECK_FAILED, POSTCHECK_FAILED,
|
|
23
|
+
* the MIGRATION namespace. These are tooling-time errors (file I/O, hash
|
|
24
|
+
* verification, migration history reconstruction), distinct from the runtime
|
|
25
|
+
* MIGRATION.* codes for apply-time failures (PRECHECK_FAILED, POSTCHECK_FAILED,
|
|
26
|
+
* etc.).
|
|
8
27
|
*
|
|
9
28
|
* Fields:
|
|
10
29
|
* - code: Stable machine-readable code (MIGRATION.SUBCODE)
|
|
@@ -55,7 +74,10 @@ export function errorDirectoryExists(dir: string): MigrationToolsError {
|
|
|
55
74
|
export function errorMissingFile(file: string, dir: string): MigrationToolsError {
|
|
56
75
|
return new MigrationToolsError('MIGRATION.FILE_MISSING', `Missing ${file}`, {
|
|
57
76
|
why: `Expected "${file}" in "${dir}" but the file does not exist.`,
|
|
58
|
-
fix:
|
|
77
|
+
fix: reemitHint(
|
|
78
|
+
dir,
|
|
79
|
+
'or delete the directory if the migration is unwanted and the source TypeScript is gone.',
|
|
80
|
+
),
|
|
59
81
|
details: { file, dir },
|
|
60
82
|
});
|
|
61
83
|
}
|
|
@@ -63,15 +85,15 @@ export function errorMissingFile(file: string, dir: string): MigrationToolsError
|
|
|
63
85
|
export function errorInvalidJson(filePath: string, parseError: string): MigrationToolsError {
|
|
64
86
|
return new MigrationToolsError('MIGRATION.INVALID_JSON', 'Invalid JSON in migration file', {
|
|
65
87
|
why: `Failed to parse "${filePath}": ${parseError}`,
|
|
66
|
-
fix:
|
|
88
|
+
fix: reemitHint(dirname(filePath), 'or restore the directory from version control.'),
|
|
67
89
|
details: { filePath, parseError },
|
|
68
90
|
});
|
|
69
91
|
}
|
|
70
92
|
|
|
71
93
|
export function errorInvalidManifest(filePath: string, reason: string): MigrationToolsError {
|
|
72
94
|
return new MigrationToolsError('MIGRATION.INVALID_MANIFEST', 'Invalid migration manifest', {
|
|
73
|
-
why: `
|
|
74
|
-
fix:
|
|
95
|
+
why: `Migration manifest at "${filePath}" is invalid: ${reason}`,
|
|
96
|
+
fix: reemitHint(dirname(filePath), 'or restore the directory from version control.'),
|
|
75
97
|
details: { filePath, reason },
|
|
76
98
|
});
|
|
77
99
|
}
|
|
@@ -92,13 +114,17 @@ export function errorInvalidDestName(destName: string): MigrationToolsError {
|
|
|
92
114
|
});
|
|
93
115
|
}
|
|
94
116
|
|
|
95
|
-
export function errorSameSourceAndTarget(
|
|
117
|
+
export function errorSameSourceAndTarget(dir: string, hash: string): MigrationToolsError {
|
|
118
|
+
const dirName = basename(dir);
|
|
96
119
|
return new MigrationToolsError(
|
|
97
120
|
'MIGRATION.SAME_SOURCE_AND_TARGET',
|
|
98
121
|
'Migration has same source and target',
|
|
99
122
|
{
|
|
100
123
|
why: `Migration "${dirName}" has from === to === "${hash}". A migration must transition between two different contract states.`,
|
|
101
|
-
fix:
|
|
124
|
+
fix: reemitHint(
|
|
125
|
+
dir,
|
|
126
|
+
'or delete the directory if the migration is unwanted and the source TypeScript is gone.',
|
|
127
|
+
),
|
|
102
128
|
details: { dirName, hash },
|
|
103
129
|
},
|
|
104
130
|
);
|
|
@@ -175,14 +201,30 @@ export function errorInvalidRefValue(value: string): MigrationToolsError {
|
|
|
175
201
|
});
|
|
176
202
|
}
|
|
177
203
|
|
|
178
|
-
export function
|
|
204
|
+
export function errorDuplicateMigrationHash(migrationHash: string): MigrationToolsError {
|
|
179
205
|
return new MigrationToolsError(
|
|
180
|
-
'MIGRATION.
|
|
181
|
-
'Duplicate
|
|
206
|
+
'MIGRATION.DUPLICATE_MIGRATION_HASH',
|
|
207
|
+
'Duplicate migrationHash in migration graph',
|
|
182
208
|
{
|
|
183
|
-
why: `Multiple migrations share
|
|
184
|
-
fix: 'Regenerate one of the conflicting migrations so each
|
|
185
|
-
details: {
|
|
209
|
+
why: `Multiple migrations share migrationHash "${migrationHash}". Each migration must have a unique content-addressed identity.`,
|
|
210
|
+
fix: 'Regenerate one of the conflicting migrations so each migrationHash is unique, then re-run migration commands.',
|
|
211
|
+
details: { migrationHash },
|
|
186
212
|
},
|
|
187
213
|
);
|
|
188
214
|
}
|
|
215
|
+
|
|
216
|
+
export function errorMigrationHashMismatch(
|
|
217
|
+
dir: string,
|
|
218
|
+
storedHash: string,
|
|
219
|
+
computedHash: string,
|
|
220
|
+
): MigrationToolsError {
|
|
221
|
+
// Render a cwd-relative path in the human-readable diagnostic so users
|
|
222
|
+
// running CLI commands from the project root see a familiar short path.
|
|
223
|
+
// Keep the absolute path in `details.dir` for machine consumers.
|
|
224
|
+
const relativeDir = relative(process.cwd(), dir);
|
|
225
|
+
return new MigrationToolsError('MIGRATION.HASH_MISMATCH', 'Migration package is corrupt', {
|
|
226
|
+
why: `Stored migrationHash "${storedHash}" does not match the recomputed hash "${computedHash}" for "${relativeDir}". The migration.json or ops.json has been edited or partially written since emit.`,
|
|
227
|
+
fix: reemitHint(dir, 'or restore the directory from version control.'),
|
|
228
|
+
details: { dir, storedHash, computedHash },
|
|
229
|
+
});
|
|
230
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MigrationToolsError } from '../errors';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { MigrationChainEntry, MigrationGraph } from '../graph';
|
package/src/exports/io.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { MigrationHints, MigrationMetadata } from '../metadata';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { MigrationOps, MigrationPackage } from '../package';
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An entry in the migration graph. All on-disk migrations are attested,
|
|
3
|
+
* so `migrationHash` is always a string.
|
|
4
|
+
*/
|
|
5
|
+
export interface MigrationChainEntry {
|
|
6
|
+
readonly from: string;
|
|
7
|
+
readonly to: string;
|
|
8
|
+
readonly migrationHash: string;
|
|
9
|
+
readonly dirName: string;
|
|
10
|
+
readonly createdAt: string;
|
|
11
|
+
readonly labels: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MigrationGraph {
|
|
15
|
+
readonly nodes: ReadonlySet<string>;
|
|
16
|
+
readonly forwardChain: ReadonlyMap<string, readonly MigrationChainEntry[]>;
|
|
17
|
+
readonly reverseChain: ReadonlyMap<string, readonly MigrationChainEntry[]>;
|
|
18
|
+
readonly migrationByHash: ReadonlyMap<string, MigrationChainEntry>;
|
|
19
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalizeJson } from './canonicalize-json';
|
|
3
|
+
import type { MigrationMetadata } from './metadata';
|
|
4
|
+
import type { MigrationOps, MigrationPackage } from './package';
|
|
5
|
+
|
|
6
|
+
export interface VerifyResult {
|
|
7
|
+
readonly ok: boolean;
|
|
8
|
+
readonly reason?: 'mismatch';
|
|
9
|
+
readonly storedHash: string;
|
|
10
|
+
readonly computedHash: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sha256Hex(input: string): string {
|
|
14
|
+
return createHash('sha256').update(input).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Content-addressed migration hash over (metadata envelope sans
|
|
19
|
+
* contracts/hints/signature, ops). See ADR 199 — Storage-only migration
|
|
20
|
+
* identity for the rationale: contracts are anchored separately by the
|
|
21
|
+
* storage-hash bookends inside the envelope; planner hints are advisory
|
|
22
|
+
* and must not affect identity.
|
|
23
|
+
*
|
|
24
|
+
* The integrity check is purely structural, not semantic. The function
|
|
25
|
+
* canonicalizes its inputs via `sortKeys` (recursive) + `JSON.stringify`
|
|
26
|
+
* and hashes the result. Target-specific operation payloads (`step.sql`,
|
|
27
|
+
* Mongo's pipeline AST, …) are hashed verbatim — no per-target
|
|
28
|
+
* normalization is required, because what's being verified is "do the
|
|
29
|
+
* on-disk bytes still produce their recorded hash", not "do two
|
|
30
|
+
* semantically-equivalent migrations hash the same". The latter is an
|
|
31
|
+
* emit-drift concern (ADR 192 step 2).
|
|
32
|
+
*
|
|
33
|
+
* The symmetry across write and read holds because `JSON.parse(
|
|
34
|
+
* JSON.stringify(x))` round-trips JSON-safe values losslessly and
|
|
35
|
+
* `sortKeys` is idempotent and deterministic — write-time and read-time
|
|
36
|
+
* canonicalization produce the same canonical bytes regardless of
|
|
37
|
+
* source-side key ordering or whitespace.
|
|
38
|
+
*
|
|
39
|
+
* The `migrationHash` field on the metadata is stripped before hashing
|
|
40
|
+
* so the function can be used both at write time (when no hash exists
|
|
41
|
+
* yet) and at verify time (rehashing an already-attested record).
|
|
42
|
+
*/
|
|
43
|
+
export function computeMigrationHash(
|
|
44
|
+
metadata: Omit<MigrationMetadata, 'migrationHash'> & { readonly migrationHash?: string },
|
|
45
|
+
ops: MigrationOps,
|
|
46
|
+
): string {
|
|
47
|
+
const {
|
|
48
|
+
migrationHash: _migrationHash,
|
|
49
|
+
signature: _signature,
|
|
50
|
+
fromContract: _fromContract,
|
|
51
|
+
toContract: _toContract,
|
|
52
|
+
hints: _hints,
|
|
53
|
+
...strippedMeta
|
|
54
|
+
} = metadata;
|
|
55
|
+
|
|
56
|
+
const canonicalMetadata = canonicalizeJson(strippedMeta);
|
|
57
|
+
const canonicalOps = canonicalizeJson(ops);
|
|
58
|
+
|
|
59
|
+
const partHashes = [canonicalMetadata, canonicalOps].map(sha256Hex);
|
|
60
|
+
const hash = sha256Hex(canonicalizeJson(partHashes));
|
|
61
|
+
|
|
62
|
+
return `sha256:${hash}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Re-hash an in-memory migration package and compare against the stored
|
|
67
|
+
* `migrationHash`. See `computeMigrationHash` for the canonicalization rules.
|
|
68
|
+
*
|
|
69
|
+
* Returns `{ ok: true }` when the package is internally consistent, or
|
|
70
|
+
* `{ ok: false, reason: 'mismatch', storedHash, computedHash }` when it is
|
|
71
|
+
* not — typically a sign of FS corruption, partial writes, or a post-emit
|
|
72
|
+
* hand edit.
|
|
73
|
+
*/
|
|
74
|
+
export function verifyMigrationHash(pkg: MigrationPackage): VerifyResult {
|
|
75
|
+
const computed = computeMigrationHash(pkg.metadata, pkg.ops);
|
|
76
|
+
|
|
77
|
+
if (pkg.metadata.migrationHash === computed) {
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
storedHash: pkg.metadata.migrationHash,
|
|
81
|
+
computedHash: computed,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: 'mismatch',
|
|
88
|
+
storedHash: pkg.metadata.migrationHash,
|
|
89
|
+
computedHash: computed,
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/io.ts
CHANGED
|
@@ -7,9 +7,12 @@ import {
|
|
|
7
7
|
errorInvalidJson,
|
|
8
8
|
errorInvalidManifest,
|
|
9
9
|
errorInvalidSlug,
|
|
10
|
+
errorMigrationHashMismatch,
|
|
10
11
|
errorMissingFile,
|
|
11
12
|
} from './errors';
|
|
12
|
-
import
|
|
13
|
+
import { verifyMigrationHash } from './hash';
|
|
14
|
+
import type { MigrationMetadata } from './metadata';
|
|
15
|
+
import type { MigrationOps, MigrationPackage } from './package';
|
|
13
16
|
|
|
14
17
|
const MANIFEST_FILE = 'migration.json';
|
|
15
18
|
const OPS_FILE = 'ops.json';
|
|
@@ -25,10 +28,10 @@ const MigrationHintsSchema = type({
|
|
|
25
28
|
plannerVersion: 'string',
|
|
26
29
|
});
|
|
27
30
|
|
|
28
|
-
const
|
|
31
|
+
const MigrationMetadataSchema = type({
|
|
29
32
|
from: 'string',
|
|
30
33
|
to: 'string',
|
|
31
|
-
|
|
34
|
+
migrationHash: 'string',
|
|
32
35
|
kind: "'regular' | 'baseline'",
|
|
33
36
|
fromContract: 'object | null',
|
|
34
37
|
toContract: 'object',
|
|
@@ -56,7 +59,7 @@ const MigrationOpsSchema = MigrationOpSchema.array();
|
|
|
56
59
|
|
|
57
60
|
export async function writeMigrationPackage(
|
|
58
61
|
dir: string,
|
|
59
|
-
|
|
62
|
+
metadata: MigrationMetadata,
|
|
60
63
|
ops: MigrationOps,
|
|
61
64
|
): Promise<void> {
|
|
62
65
|
await mkdir(dirname(dir), { recursive: true });
|
|
@@ -70,7 +73,9 @@ export async function writeMigrationPackage(
|
|
|
70
73
|
throw error;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
await writeFile(join(dir, MANIFEST_FILE), JSON.stringify(
|
|
76
|
+
await writeFile(join(dir, MANIFEST_FILE), JSON.stringify(metadata, null, 2), {
|
|
77
|
+
flag: 'wx',
|
|
78
|
+
});
|
|
74
79
|
await writeFile(join(dir, OPS_FILE), JSON.stringify(ops, null, 2), { flag: 'wx' });
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -98,18 +103,18 @@ export async function copyFilesWithRename(
|
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
export async function
|
|
106
|
+
export async function writeMigrationMetadata(
|
|
102
107
|
dir: string,
|
|
103
|
-
|
|
108
|
+
metadata: MigrationMetadata,
|
|
104
109
|
): Promise<void> {
|
|
105
|
-
await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(
|
|
110
|
+
await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(metadata, null, 2)}\n`);
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
export async function writeMigrationOps(dir: string, ops: MigrationOps): Promise<void> {
|
|
109
114
|
await writeFile(join(dir, OPS_FILE), `${JSON.stringify(ops, null, 2)}\n`);
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
export async function readMigrationPackage(dir: string): Promise<
|
|
117
|
+
export async function readMigrationPackage(dir: string): Promise<MigrationPackage> {
|
|
113
118
|
const manifestPath = join(dir, MANIFEST_FILE);
|
|
114
119
|
const opsPath = join(dir, OPS_FILE);
|
|
115
120
|
|
|
@@ -133,9 +138,9 @@ export async function readMigrationPackage(dir: string): Promise<MigrationBundle
|
|
|
133
138
|
throw error;
|
|
134
139
|
}
|
|
135
140
|
|
|
136
|
-
let
|
|
141
|
+
let metadata: MigrationMetadata;
|
|
137
142
|
try {
|
|
138
|
-
|
|
143
|
+
metadata = JSON.parse(manifestRaw);
|
|
139
144
|
} catch (e) {
|
|
140
145
|
throw errorInvalidJson(manifestPath, e instanceof Error ? e.message : String(e));
|
|
141
146
|
}
|
|
@@ -147,22 +152,29 @@ export async function readMigrationPackage(dir: string): Promise<MigrationBundle
|
|
|
147
152
|
throw errorInvalidJson(opsPath, e instanceof Error ? e.message : String(e));
|
|
148
153
|
}
|
|
149
154
|
|
|
150
|
-
|
|
155
|
+
validateMetadata(metadata, manifestPath);
|
|
151
156
|
validateOps(ops, opsPath);
|
|
152
157
|
|
|
153
|
-
|
|
158
|
+
const pkg: MigrationPackage = {
|
|
154
159
|
dirName: basename(dir),
|
|
155
160
|
dirPath: dir,
|
|
156
|
-
|
|
161
|
+
metadata,
|
|
157
162
|
ops,
|
|
158
163
|
};
|
|
164
|
+
|
|
165
|
+
const verification = verifyMigrationHash(pkg);
|
|
166
|
+
if (!verification.ok) {
|
|
167
|
+
throw errorMigrationHashMismatch(dir, verification.storedHash, verification.computedHash);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return pkg;
|
|
159
171
|
}
|
|
160
172
|
|
|
161
|
-
function
|
|
162
|
-
|
|
173
|
+
function validateMetadata(
|
|
174
|
+
metadata: unknown,
|
|
163
175
|
filePath: string,
|
|
164
|
-
): asserts
|
|
165
|
-
const result =
|
|
176
|
+
): asserts metadata is MigrationMetadata {
|
|
177
|
+
const result = MigrationMetadataSchema(metadata);
|
|
166
178
|
if (result instanceof type.errors) {
|
|
167
179
|
throw errorInvalidManifest(filePath, result.summary);
|
|
168
180
|
}
|
|
@@ -177,7 +189,7 @@ function validateOps(ops: unknown, filePath: string): asserts ops is MigrationOp
|
|
|
177
189
|
|
|
178
190
|
export async function readMigrationsDir(
|
|
179
191
|
migrationsRoot: string,
|
|
180
|
-
): Promise<readonly
|
|
192
|
+
): Promise<readonly MigrationPackage[]> {
|
|
181
193
|
let entries: string[];
|
|
182
194
|
try {
|
|
183
195
|
entries = await readdir(migrationsRoot);
|
|
@@ -188,7 +200,7 @@ export async function readMigrationsDir(
|
|
|
188
200
|
throw error;
|
|
189
201
|
}
|
|
190
202
|
|
|
191
|
-
const packages:
|
|
203
|
+
const packages: MigrationPackage[] = [];
|
|
192
204
|
|
|
193
205
|
for (const entry of entries.sort()) {
|
|
194
206
|
const entryPath = join(migrationsRoot, entry);
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
2
|
+
|
|
3
|
+
export interface MigrationHints {
|
|
4
|
+
readonly used: readonly string[];
|
|
5
|
+
readonly applied: readonly string[];
|
|
6
|
+
readonly plannerVersion: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* In-memory migration metadata envelope. Every migration is content-addressed:
|
|
11
|
+
* the `migrationHash` is a hash over the metadata envelope plus the operations
|
|
12
|
+
* list, computed at write time. There is no draft state — a migration
|
|
13
|
+
* directory either exists with fully attested metadata or it does not.
|
|
14
|
+
*
|
|
15
|
+
* When the planner cannot lower an operation because of an unfilled
|
|
16
|
+
* `placeholder(...)` slot, the migration is still written with `migrationHash`
|
|
17
|
+
* hashed over `ops: []`. Re-running self-emit after the user fills the
|
|
18
|
+
* placeholder produces a *different* `migrationHash` (committed to the real
|
|
19
|
+
* ops); this is intentional.
|
|
20
|
+
*
|
|
21
|
+
* The on-disk JSON shape in `migration.json` matches this type field-for-field
|
|
22
|
+
* — `JSON.stringify(metadata, null, 2)` is the canonical writer output.
|
|
23
|
+
*/
|
|
24
|
+
export interface MigrationMetadata {
|
|
25
|
+
readonly migrationHash: string;
|
|
26
|
+
readonly from: string;
|
|
27
|
+
readonly to: string;
|
|
28
|
+
readonly kind: 'regular' | 'baseline';
|
|
29
|
+
readonly fromContract: Contract | null;
|
|
30
|
+
readonly toContract: Contract;
|
|
31
|
+
readonly hints: MigrationHints;
|
|
32
|
+
readonly labels: readonly string[];
|
|
33
|
+
readonly authorship?: { readonly author?: string; readonly email?: string };
|
|
34
|
+
readonly signature?: { readonly keyId: string; readonly value: string } | null;
|
|
35
|
+
readonly createdAt: string;
|
|
36
|
+
}
|