@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/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,79 @@ export function errorInvalidRefValue(value: string): MigrationToolsError {
|
|
|
175
201
|
});
|
|
176
202
|
}
|
|
177
203
|
|
|
178
|
-
export function
|
|
204
|
+
export function errorDuplicateMigrationHash(migrationHash: string): MigrationToolsError {
|
|
205
|
+
return new MigrationToolsError(
|
|
206
|
+
'MIGRATION.DUPLICATE_MIGRATION_HASH',
|
|
207
|
+
'Duplicate migrationHash in migration graph',
|
|
208
|
+
{
|
|
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 },
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function errorInvalidInvariantId(invariantId: string): MigrationToolsError {
|
|
217
|
+
return new MigrationToolsError('MIGRATION.INVALID_INVARIANT_ID', 'Invalid invariantId', {
|
|
218
|
+
why: `invariantId ${JSON.stringify(invariantId)} is invalid. Ids must be non-empty and contain no whitespace or control characters (including Unicode whitespace like NBSP); other content (kebab-case, camelCase, namespaced, Unicode letters) is allowed.`,
|
|
219
|
+
fix: 'Pick an invariantId without spaces, tabs, newlines, or control characters — e.g. "backfill-user-phone", "users/backfill-phone", or "BackfillUserPhone".',
|
|
220
|
+
details: { invariantId },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function errorDuplicateInvariantInEdge(invariantId: string): MigrationToolsError {
|
|
179
225
|
return new MigrationToolsError(
|
|
180
|
-
'MIGRATION.
|
|
181
|
-
'Duplicate
|
|
226
|
+
'MIGRATION.DUPLICATE_INVARIANT_IN_EDGE',
|
|
227
|
+
'Duplicate invariantId on a single migration',
|
|
182
228
|
{
|
|
183
|
-
why: `
|
|
184
|
-
fix: '
|
|
185
|
-
details: {
|
|
229
|
+
why: `invariantId "${invariantId}" is declared by more than one dataTransform on the same migration. The marker stores invariants as a set and the routing layer treats them as edge-level, so two ops cannot share a routing identity.`,
|
|
230
|
+
fix: 'Rename one of the conflicting dataTransform invariantIds, or drop invariantId on the op that does not need to be routing-visible.',
|
|
231
|
+
details: { invariantId },
|
|
186
232
|
},
|
|
187
233
|
);
|
|
188
234
|
}
|
|
235
|
+
|
|
236
|
+
export function errorProvidedInvariantsMismatch(
|
|
237
|
+
filePath: string,
|
|
238
|
+
stored: readonly string[],
|
|
239
|
+
derived: readonly string[],
|
|
240
|
+
): MigrationToolsError {
|
|
241
|
+
const storedSet = new Set(stored);
|
|
242
|
+
const derivedSet = new Set(derived);
|
|
243
|
+
const missing = [...derivedSet].filter((id) => !storedSet.has(id));
|
|
244
|
+
const extra = [...storedSet].filter((id) => !derivedSet.has(id));
|
|
245
|
+
// When sets agree but arrays don't, the only difference is ordering — call
|
|
246
|
+
// it out so the reader doesn't stare at two visually-identical arrays.
|
|
247
|
+
// Canonical providedInvariants is sorted ascending; a manifest with the
|
|
248
|
+
// same ids in a different order is still a mismatch (the hash check would
|
|
249
|
+
// also fail), but the human-readable diagnostic is otherwise unhelpful.
|
|
250
|
+
const orderingOnly = missing.length === 0 && extra.length === 0;
|
|
251
|
+
const why = orderingOnly
|
|
252
|
+
? `migration.json at "${filePath}" stores providedInvariants ${JSON.stringify(stored)}, but the canonical value derived from ops.json is ${JSON.stringify(derived)} — same ids, different order. Canonical providedInvariants is sorted ascending.`
|
|
253
|
+
: `migration.json at "${filePath}" stores providedInvariants ${JSON.stringify(stored)}, but the value derived from ops.json is ${JSON.stringify(derived)}. The manifest copy was likely hand-edited without re-emitting.`;
|
|
254
|
+
return new MigrationToolsError(
|
|
255
|
+
'MIGRATION.PROVIDED_INVARIANTS_MISMATCH',
|
|
256
|
+
'providedInvariants on migration.json disagrees with ops.json',
|
|
257
|
+
{
|
|
258
|
+
why,
|
|
259
|
+
fix: reemitHint(dirname(filePath), 'or restore the directory from version control.'),
|
|
260
|
+
details: { filePath, stored, derived, difference: { missing, extra } },
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function errorMigrationHashMismatch(
|
|
266
|
+
dir: string,
|
|
267
|
+
storedHash: string,
|
|
268
|
+
computedHash: string,
|
|
269
|
+
): MigrationToolsError {
|
|
270
|
+
// Render a cwd-relative path in the human-readable diagnostic so users
|
|
271
|
+
// running CLI commands from the project root see a familiar short path.
|
|
272
|
+
// Keep the absolute path in `details.dir` for machine consumers.
|
|
273
|
+
const relativeDir = relative(process.cwd(), dir);
|
|
274
|
+
return new MigrationToolsError('MIGRATION.HASH_MISMATCH', 'Migration package is corrupt', {
|
|
275
|
+
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.`,
|
|
276
|
+
fix: reemitHint(dir, 'or restore the directory from version control.'),
|
|
277
|
+
details: { dir, storedHash, computedHash },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MigrationToolsError } from '../errors';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { MigrationEdge, MigrationGraph } from '../graph';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { deriveProvidedInvariants, validateInvariantId } from '../invariants';
|
package/src/exports/io.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { MigrationHints, MigrationMetadata } from '../metadata';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { PathDecision } from '../
|
|
1
|
+
export type { PathDecision } from '../migration-graph';
|
|
2
2
|
export {
|
|
3
3
|
detectCycles,
|
|
4
4
|
detectOrphans,
|
|
@@ -8,4 +8,4 @@ export {
|
|
|
8
8
|
findPathWithDecision,
|
|
9
9
|
findReachableLeaves,
|
|
10
10
|
reconstructGraph,
|
|
11
|
-
} from '../
|
|
11
|
+
} from '../migration-graph';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { MigrationOps, MigrationPackage } from '../package';
|
package/src/exports/refs.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
|
-
export type { Refs } from '../refs';
|
|
2
|
-
export {
|
|
1
|
+
export type { RefEntry, Refs } from '../refs';
|
|
2
|
+
export {
|
|
3
|
+
deleteRef,
|
|
4
|
+
readRef,
|
|
5
|
+
readRefs,
|
|
6
|
+
resolveRef,
|
|
7
|
+
validateRefName,
|
|
8
|
+
validateRefValue,
|
|
9
|
+
writeRef,
|
|
10
|
+
} from '../refs';
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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 MigrationEdge {
|
|
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
|
+
* Sorted, deduplicated list of `invariantId`s this edge provides.
|
|
14
|
+
* An empty array means the migration declares no routing-visible
|
|
15
|
+
* data transforms.
|
|
16
|
+
*/
|
|
17
|
+
readonly invariants: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MigrationGraph {
|
|
21
|
+
readonly nodes: ReadonlySet<string>;
|
|
22
|
+
readonly forwardChain: ReadonlyMap<string, readonly MigrationEdge[]>;
|
|
23
|
+
readonly reverseChain: ReadonlyMap<string, readonly MigrationEdge[]>;
|
|
24
|
+
readonly migrationByHash: ReadonlyMap<string, MigrationEdge>;
|
|
25
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MigrationPlanOperation } from '@prisma-next/framework-components/control';
|
|
2
|
+
import { errorDuplicateInvariantInEdge, errorInvalidInvariantId } from './errors';
|
|
3
|
+
import type { MigrationOps } from './package';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hygiene check for `invariantId`. Rejects empty values plus any
|
|
7
|
+
* whitespace or control character (including Unicode whitespace like
|
|
8
|
+
* NBSP and em space, which are visually identical to ASCII space and
|
|
9
|
+
* routinely sneak in via paste).
|
|
10
|
+
*/
|
|
11
|
+
export function validateInvariantId(invariantId: string): boolean {
|
|
12
|
+
if (invariantId.length === 0) return false;
|
|
13
|
+
return !/[\p{Cc}\p{White_Space}]/u.test(invariantId);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Walk a migration's operations and produce its `providedInvariants`
|
|
18
|
+
* aggregate: the sorted, deduplicated list of `invariantId`s declared
|
|
19
|
+
* by data-transform ops. Ops without `operationClass === 'data'` are
|
|
20
|
+
* skipped; data ops without an `invariantId` are skipped.
|
|
21
|
+
*
|
|
22
|
+
* Throws `MIGRATION.INVALID_INVARIANT_ID` on a malformed id and
|
|
23
|
+
* `MIGRATION.DUPLICATE_INVARIANT_IN_EDGE` on duplicates.
|
|
24
|
+
*/
|
|
25
|
+
export function deriveProvidedInvariants(ops: MigrationOps): readonly string[] {
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
for (const op of ops) {
|
|
28
|
+
const invariantId = readInvariantId(op);
|
|
29
|
+
if (invariantId === undefined) continue;
|
|
30
|
+
if (!validateInvariantId(invariantId)) {
|
|
31
|
+
throw errorInvalidInvariantId(invariantId);
|
|
32
|
+
}
|
|
33
|
+
if (seen.has(invariantId)) {
|
|
34
|
+
throw errorDuplicateInvariantInEdge(invariantId);
|
|
35
|
+
}
|
|
36
|
+
seen.add(invariantId);
|
|
37
|
+
}
|
|
38
|
+
return [...seen].sort();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readInvariantId(op: MigrationPlanOperation): string | undefined {
|
|
42
|
+
if (op.operationClass !== 'data') return undefined;
|
|
43
|
+
const candidate = (op as { invariantId?: unknown }).invariantId;
|
|
44
|
+
return typeof candidate === 'string' ? candidate : undefined;
|
|
45
|
+
}
|
package/src/io.ts
CHANGED
|
@@ -7,9 +7,14 @@ import {
|
|
|
7
7
|
errorInvalidJson,
|
|
8
8
|
errorInvalidManifest,
|
|
9
9
|
errorInvalidSlug,
|
|
10
|
+
errorMigrationHashMismatch,
|
|
10
11
|
errorMissingFile,
|
|
12
|
+
errorProvidedInvariantsMismatch,
|
|
11
13
|
} from './errors';
|
|
12
|
-
import
|
|
14
|
+
import { verifyMigrationHash } from './hash';
|
|
15
|
+
import { deriveProvidedInvariants } from './invariants';
|
|
16
|
+
import type { MigrationMetadata } from './metadata';
|
|
17
|
+
import type { MigrationOps, MigrationPackage } from './package';
|
|
13
18
|
|
|
14
19
|
const MANIFEST_FILE = 'migration.json';
|
|
15
20
|
const OPS_FILE = 'ops.json';
|
|
@@ -25,15 +30,16 @@ const MigrationHintsSchema = type({
|
|
|
25
30
|
plannerVersion: 'string',
|
|
26
31
|
});
|
|
27
32
|
|
|
28
|
-
const
|
|
33
|
+
const MigrationMetadataSchema = type({
|
|
29
34
|
from: 'string',
|
|
30
35
|
to: 'string',
|
|
31
|
-
|
|
36
|
+
migrationHash: 'string',
|
|
32
37
|
kind: "'regular' | 'baseline'",
|
|
33
38
|
fromContract: 'object | null',
|
|
34
39
|
toContract: 'object',
|
|
35
40
|
hints: MigrationHintsSchema,
|
|
36
41
|
labels: 'string[]',
|
|
42
|
+
providedInvariants: 'string[]',
|
|
37
43
|
'authorship?': type({
|
|
38
44
|
'author?': 'string',
|
|
39
45
|
'email?': 'string',
|
|
@@ -49,6 +55,7 @@ const MigrationOpSchema = type({
|
|
|
49
55
|
id: 'string',
|
|
50
56
|
label: 'string',
|
|
51
57
|
operationClass: "'additive' | 'widening' | 'destructive' | 'data'",
|
|
58
|
+
'invariantId?': 'string',
|
|
52
59
|
});
|
|
53
60
|
|
|
54
61
|
// Intentionally shallow: operation-specific payload validation is owned by planner/runner layers.
|
|
@@ -56,7 +63,7 @@ const MigrationOpsSchema = MigrationOpSchema.array();
|
|
|
56
63
|
|
|
57
64
|
export async function writeMigrationPackage(
|
|
58
65
|
dir: string,
|
|
59
|
-
|
|
66
|
+
metadata: MigrationMetadata,
|
|
60
67
|
ops: MigrationOps,
|
|
61
68
|
): Promise<void> {
|
|
62
69
|
await mkdir(dirname(dir), { recursive: true });
|
|
@@ -70,7 +77,9 @@ export async function writeMigrationPackage(
|
|
|
70
77
|
throw error;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
await writeFile(join(dir, MANIFEST_FILE), JSON.stringify(
|
|
80
|
+
await writeFile(join(dir, MANIFEST_FILE), JSON.stringify(metadata, null, 2), {
|
|
81
|
+
flag: 'wx',
|
|
82
|
+
});
|
|
74
83
|
await writeFile(join(dir, OPS_FILE), JSON.stringify(ops, null, 2), { flag: 'wx' });
|
|
75
84
|
}
|
|
76
85
|
|
|
@@ -98,18 +107,18 @@ export async function copyFilesWithRename(
|
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
export async function
|
|
110
|
+
export async function writeMigrationMetadata(
|
|
102
111
|
dir: string,
|
|
103
|
-
|
|
112
|
+
metadata: MigrationMetadata,
|
|
104
113
|
): Promise<void> {
|
|
105
|
-
await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(
|
|
114
|
+
await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(metadata, null, 2)}\n`);
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
export async function writeMigrationOps(dir: string, ops: MigrationOps): Promise<void> {
|
|
109
118
|
await writeFile(join(dir, OPS_FILE), `${JSON.stringify(ops, null, 2)}\n`);
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
export async function readMigrationPackage(dir: string): Promise<
|
|
121
|
+
export async function readMigrationPackage(dir: string): Promise<MigrationPackage> {
|
|
113
122
|
const manifestPath = join(dir, MANIFEST_FILE);
|
|
114
123
|
const opsPath = join(dir, OPS_FILE);
|
|
115
124
|
|
|
@@ -133,9 +142,9 @@ export async function readMigrationPackage(dir: string): Promise<MigrationBundle
|
|
|
133
142
|
throw error;
|
|
134
143
|
}
|
|
135
144
|
|
|
136
|
-
let
|
|
145
|
+
let metadata: MigrationMetadata;
|
|
137
146
|
try {
|
|
138
|
-
|
|
147
|
+
metadata = JSON.parse(manifestRaw);
|
|
139
148
|
} catch (e) {
|
|
140
149
|
throw errorInvalidJson(manifestPath, e instanceof Error ? e.message : String(e));
|
|
141
150
|
}
|
|
@@ -147,22 +156,48 @@ export async function readMigrationPackage(dir: string): Promise<MigrationBundle
|
|
|
147
156
|
throw errorInvalidJson(opsPath, e instanceof Error ? e.message : String(e));
|
|
148
157
|
}
|
|
149
158
|
|
|
150
|
-
|
|
159
|
+
validateMetadata(metadata, manifestPath);
|
|
151
160
|
validateOps(ops, opsPath);
|
|
152
161
|
|
|
153
|
-
|
|
162
|
+
// Re-derive before the hash check so format/duplicate diagnostics
|
|
163
|
+
// fire with their dedicated codes rather than as a generic hash mismatch.
|
|
164
|
+
const derivedInvariants = deriveProvidedInvariants(ops);
|
|
165
|
+
if (!arraysEqual(metadata.providedInvariants, derivedInvariants)) {
|
|
166
|
+
throw errorProvidedInvariantsMismatch(
|
|
167
|
+
manifestPath,
|
|
168
|
+
metadata.providedInvariants,
|
|
169
|
+
derivedInvariants,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const pkg: MigrationPackage = {
|
|
154
174
|
dirName: basename(dir),
|
|
155
175
|
dirPath: dir,
|
|
156
|
-
|
|
176
|
+
metadata,
|
|
157
177
|
ops,
|
|
158
178
|
};
|
|
179
|
+
|
|
180
|
+
const verification = verifyMigrationHash(pkg);
|
|
181
|
+
if (!verification.ok) {
|
|
182
|
+
throw errorMigrationHashMismatch(dir, verification.storedHash, verification.computedHash);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return pkg;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
189
|
+
if (a.length !== b.length) return false;
|
|
190
|
+
for (let i = 0; i < a.length; i++) {
|
|
191
|
+
if (a[i] !== b[i]) return false;
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
159
194
|
}
|
|
160
195
|
|
|
161
|
-
function
|
|
162
|
-
|
|
196
|
+
function validateMetadata(
|
|
197
|
+
metadata: unknown,
|
|
163
198
|
filePath: string,
|
|
164
|
-
): asserts
|
|
165
|
-
const result =
|
|
199
|
+
): asserts metadata is MigrationMetadata {
|
|
200
|
+
const result = MigrationMetadataSchema(metadata);
|
|
166
201
|
if (result instanceof type.errors) {
|
|
167
202
|
throw errorInvalidManifest(filePath, result.summary);
|
|
168
203
|
}
|
|
@@ -177,7 +212,7 @@ function validateOps(ops: unknown, filePath: string): asserts ops is MigrationOp
|
|
|
177
212
|
|
|
178
213
|
export async function readMigrationsDir(
|
|
179
214
|
migrationsRoot: string,
|
|
180
|
-
): Promise<readonly
|
|
215
|
+
): Promise<readonly MigrationPackage[]> {
|
|
181
216
|
let entries: string[];
|
|
182
217
|
try {
|
|
183
218
|
entries = await readdir(migrationsRoot);
|
|
@@ -188,7 +223,7 @@ export async function readMigrationsDir(
|
|
|
188
223
|
throw error;
|
|
189
224
|
}
|
|
190
225
|
|
|
191
|
-
const packages:
|
|
226
|
+
const packages: MigrationPackage[] = [];
|
|
192
227
|
|
|
193
228
|
for (const entry of entries.sort()) {
|
|
194
229
|
const entryPath = join(migrationsRoot, entry);
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
/**
|
|
34
|
+
* Sorted, deduplicated list of `invariantId`s declared by the
|
|
35
|
+
* migration's data-transform ops. Always present; an empty array
|
|
36
|
+
* means the migration has no routing-visible data transforms.
|
|
37
|
+
*/
|
|
38
|
+
readonly providedInvariants: readonly string[];
|
|
39
|
+
readonly authorship?: { readonly author?: string; readonly email?: string };
|
|
40
|
+
readonly signature?: { readonly keyId: string; readonly value: string } | null;
|
|
41
|
+
readonly createdAt: string;
|
|
42
|
+
}
|