@prisma-next/migration-tools 0.5.0-dev.3 → 0.5.0-dev.31
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-BQEHsaEx.mjs} +1 -1
- package/dist/{constants-BRi0X7B_.mjs.map → constants-BQEHsaEx.mjs.map} +1 -1
- package/dist/errors-Bl3cKiM8.mjs +244 -0
- package/dist/errors-Bl3cKiM8.mjs.map +1 -0
- package/dist/exports/constants.mjs +1 -1
- package/dist/exports/{types.d.mts → errors.d.mts} +7 -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 +162 -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} +31 -10
- package/dist/exports/migration-graph.d.mts.map +1 -0
- package/dist/exports/{dag.mjs → migration-graph.mjs} +143 -63
- package/dist/exports/migration-graph.mjs.map +1 -0
- package/dist/exports/migration-ts.mjs +1 -1
- package/dist/exports/migration.d.mts +15 -14
- package/dist/exports/migration.d.mts.map +1 -1
- package/dist/exports/migration.mjs +68 -40
- 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-BHPv-9Gl.d.mts +28 -0
- package/dist/graph-BHPv-9Gl.d.mts.map +1 -0
- package/dist/hash-BARZdVgW.mjs +76 -0
- package/dist/hash-BARZdVgW.mjs.map +1 -0
- package/dist/invariants-BmrTBQ0A.mjs +42 -0
- package/dist/invariants-BmrTBQ0A.mjs.map +1 -0
- package/dist/metadata-BP1cmU7Z.d.mts +50 -0
- package/dist/metadata-BP1cmU7Z.d.mts.map +1 -0
- package/dist/op-schema-DZKFua46.mjs +14 -0
- package/dist/op-schema-DZKFua46.mjs.map +1 -0
- package/dist/package-5HCCg0z-.d.mts +21 -0
- package/dist/package-5HCCg0z-.d.mts.map +1 -0
- package/package.json +32 -16
- package/src/errors.ts +139 -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} +3 -2
- package/src/exports/migration.ts +0 -1
- package/src/exports/package.ts +1 -0
- package/src/exports/refs.ts +10 -2
- package/src/graph-ops.ts +57 -30
- package/src/graph.ts +25 -0
- package/src/hash.ts +91 -0
- package/src/invariants.ts +45 -0
- package/src/io.ts +57 -31
- package/src/metadata.ts +41 -0
- package/src/migration-base.ts +97 -56
- package/src/{dag.ts → migration-graph.ts} +156 -54
- package/src/op-schema.ts +11 -0
- 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 +0 -160
- 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,20 +8,27 @@ 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 { errorInvalidOperationEntry, errorStaleContractBookends } from './errors';
|
|
12
|
+
import { computeMigrationHash } from './hash';
|
|
13
|
+
import { deriveProvidedInvariants } from './invariants';
|
|
14
|
+
import type { MigrationHints, MigrationMetadata } from './metadata';
|
|
15
|
+
import { MigrationOpSchema } from './op-schema';
|
|
16
|
+
import type { MigrationOps } from './package';
|
|
13
17
|
|
|
14
18
|
export interface MigrationMeta {
|
|
15
|
-
readonly from: string;
|
|
19
|
+
readonly from: string | null;
|
|
16
20
|
readonly to: string;
|
|
17
|
-
readonly kind?: 'regular' | 'baseline';
|
|
18
21
|
readonly labels?: readonly string[];
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
// `from` rejects empty strings to mirror `MigrationMetadataSchema` in
|
|
25
|
+
// `./io.ts`. Without this match, an authored migration could `describe()` with
|
|
26
|
+
// `from: ''` and pass `buildMigrationArtifacts`'s validation, only to have
|
|
27
|
+
// `readMigrationPackage` reject the resulting `migration.json` later — the
|
|
28
|
+
// two validators must agree on the legal value space.
|
|
21
29
|
const MigrationMetaSchema = type({
|
|
22
|
-
from: 'string',
|
|
30
|
+
from: 'string > 0 | null',
|
|
23
31
|
to: 'string',
|
|
24
|
-
'kind?': "'regular' | 'baseline'",
|
|
25
32
|
'labels?': type('string').array(),
|
|
26
33
|
});
|
|
27
34
|
|
|
@@ -30,7 +37,7 @@ const MigrationMetaSchema = type({
|
|
|
30
37
|
*
|
|
31
38
|
* A `Migration` subclass is itself a `MigrationPlan`: CLI commands and the
|
|
32
39
|
* runner can consume it directly via `targetId`, `operations`, `origin`, and
|
|
33
|
-
* `destination`. The
|
|
40
|
+
* `destination`. The metadata-shaped inputs come from `describe()`, which
|
|
34
41
|
* every migration must implement — `migration.json` is required for a
|
|
35
42
|
* migration to be valid.
|
|
36
43
|
*/
|
|
@@ -74,11 +81,7 @@ export abstract class Migration<
|
|
|
74
81
|
|
|
75
82
|
get origin(): { readonly storageHash: string } | null {
|
|
76
83
|
const from = this.describe().from;
|
|
77
|
-
|
|
78
|
-
// initial baseline, or an in-process plan that was never persisted).
|
|
79
|
-
// Surface that as a null origin so runners treat the plan as
|
|
80
|
-
// origin-less rather than matching against an empty storage hash.
|
|
81
|
-
return from === '' ? null : { storageHash: from };
|
|
84
|
+
return from === null ? null : { storageHash: from };
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
get destination(): { readonly storageHash: string } {
|
|
@@ -104,83 +107,114 @@ export function isDirectEntrypoint(importMetaUrl: string): boolean {
|
|
|
104
107
|
}
|
|
105
108
|
}
|
|
106
109
|
|
|
107
|
-
export function printMigrationHelp(): void {
|
|
108
|
-
printHelp();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function printHelp(): void {
|
|
112
|
-
process.stdout.write(
|
|
113
|
-
[
|
|
114
|
-
'Usage: node <migration-file> [options]',
|
|
115
|
-
'',
|
|
116
|
-
'Options:',
|
|
117
|
-
' --dry-run Print operations to stdout without writing files',
|
|
118
|
-
' --help Show this help message',
|
|
119
|
-
'',
|
|
120
|
-
].join('\n'),
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
110
|
/**
|
|
125
111
|
* In-memory artifacts produced from a `Migration` instance: the
|
|
126
|
-
* serialized `ops.json` body, the `migration.json`
|
|
112
|
+
* serialized `ops.json` body, the `migration.json` metadata object, and
|
|
127
113
|
* its serialized form. Returned by `buildMigrationArtifacts` so callers
|
|
128
114
|
* (today: `MigrationCLI.run` in `@prisma-next/cli/migration-cli`) can
|
|
129
115
|
* decide how to persist them — write to disk, print in dry-run, ship
|
|
130
116
|
* over the wire — without coupling artifact construction to file I/O.
|
|
117
|
+
*
|
|
118
|
+
* `metadataJson` is `JSON.stringify(metadata, null, 2)` — the canonical
|
|
119
|
+
* on-disk shape that the arktype loader-schema in `./io` validates.
|
|
131
120
|
*/
|
|
132
121
|
export interface MigrationArtifacts {
|
|
133
122
|
readonly opsJson: string;
|
|
134
|
-
readonly
|
|
135
|
-
readonly
|
|
123
|
+
readonly metadata: MigrationMetadata;
|
|
124
|
+
readonly metadataJson: string;
|
|
136
125
|
}
|
|
137
126
|
|
|
138
127
|
/**
|
|
139
|
-
* Build the attested
|
|
140
|
-
* operations list, and the previously-scaffolded
|
|
128
|
+
* Build the attested metadata from `describe()`-derived metadata, the
|
|
129
|
+
* operations list, and the previously-scaffolded metadata (if any).
|
|
141
130
|
*
|
|
142
131
|
* When a `migration.json` already exists for this package (the common
|
|
143
132
|
* case: it was scaffolded by `migration plan`), preserve the contract
|
|
144
133
|
* bookends, hints, labels, and `createdAt` set there — those fields are
|
|
145
134
|
* owned by the CLI scaffolder, not the authored class. Only the
|
|
146
|
-
* `describe()`-derived fields (`from`, `to
|
|
147
|
-
* change as the author iterates. When no
|
|
135
|
+
* `describe()`-derived fields (`from`, `to`) and the operations
|
|
136
|
+
* change as the author iterates. When no metadata exists yet (a bare
|
|
148
137
|
* `migration.ts` run from scratch), synthesize a minimal but
|
|
149
|
-
* schema-conformant
|
|
138
|
+
* schema-conformant record so the resulting package can still be read,
|
|
150
139
|
* verified, and applied.
|
|
151
140
|
*
|
|
152
|
-
* The `
|
|
141
|
+
* The `migrationHash` is recomputed against the current metadata + ops so
|
|
153
142
|
* the on-disk artifacts are always fully attested.
|
|
154
143
|
*/
|
|
155
|
-
function
|
|
144
|
+
function buildAttestedMetadata(
|
|
156
145
|
meta: MigrationMeta,
|
|
157
146
|
ops: MigrationOps,
|
|
158
|
-
existing: Partial<
|
|
159
|
-
):
|
|
160
|
-
|
|
147
|
+
existing: Partial<MigrationMetadata> | null,
|
|
148
|
+
): MigrationMetadata {
|
|
149
|
+
assertBookendsMatchMeta(meta, existing);
|
|
150
|
+
|
|
151
|
+
const baseMetadata: Omit<MigrationMetadata, 'migrationHash'> = {
|
|
161
152
|
from: meta.from,
|
|
162
153
|
to: meta.to,
|
|
163
|
-
kind: meta.kind ?? 'regular',
|
|
164
154
|
labels: meta.labels ?? existing?.labels ?? [],
|
|
155
|
+
providedInvariants: deriveProvidedInvariants(ops),
|
|
165
156
|
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
166
157
|
fromContract: existing?.fromContract ?? null,
|
|
167
|
-
// When no scaffolded
|
|
158
|
+
// When no scaffolded metadata exists we synthesize a minimal contract
|
|
168
159
|
// stub so the package is still readable end-to-end. The cast is
|
|
169
160
|
// intentional: only the storage bookend matters for hash computation
|
|
170
|
-
// (everything else is stripped by `
|
|
161
|
+
// (everything else is stripped by `computeMigrationHash`), and a real
|
|
171
162
|
// contract bookend would only be available after `migration plan`.
|
|
172
163
|
toContract: existing?.toContract ?? ({ storage: { storageHash: meta.to } } as Contract),
|
|
173
164
|
hints: normalizeHints(existing?.hints),
|
|
174
165
|
...ifDefined('authorship', existing?.authorship),
|
|
175
166
|
};
|
|
176
167
|
|
|
177
|
-
const
|
|
178
|
-
return { ...
|
|
168
|
+
const migrationHash = computeMigrationHash(baseMetadata, ops);
|
|
169
|
+
return { ...baseMetadata, migrationHash };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Verify each preserved contract bookend in `existing` agrees with the
|
|
174
|
+
* corresponding side of `describe()`'s output. A mismatch indicates the
|
|
175
|
+
* migration's `describe()` was edited after `migration plan` scaffolded
|
|
176
|
+
* the package, leaving a self-inconsistent manifest. Failing fast at
|
|
177
|
+
* write-time turns a silent foot-gun into an actionable diagnostic.
|
|
178
|
+
*
|
|
179
|
+
* Skipped when a side's `existing.<side>Contract` is null/absent (the
|
|
180
|
+
* synthesis path stays open for origin-less initial migrations and for
|
|
181
|
+
* bare `migration.ts` runs from scratch). When a bookend is *present*
|
|
182
|
+
* but its `storage.storageHash` is missing, that's treated as a
|
|
183
|
+
* mismatch — a malformed bookend is not equivalent to "no bookend".
|
|
184
|
+
*
|
|
185
|
+
* This check is paired with TML-2274, which removes `fromContract` /
|
|
186
|
+
* `toContract` from the manifest entirely; once that lands, this
|
|
187
|
+
* function and its error code are deleted.
|
|
188
|
+
*/
|
|
189
|
+
function assertBookendsMatchMeta(
|
|
190
|
+
meta: MigrationMeta,
|
|
191
|
+
existing: Partial<MigrationMetadata> | null,
|
|
192
|
+
): void {
|
|
193
|
+
if (existing?.fromContract != null) {
|
|
194
|
+
const contractHash = existing.fromContract.storage?.storageHash ?? '';
|
|
195
|
+
if (contractHash !== meta.from) {
|
|
196
|
+
throw errorStaleContractBookends({
|
|
197
|
+
side: 'from',
|
|
198
|
+
metaHash: meta.from,
|
|
199
|
+
contractHash,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (existing?.toContract != null) {
|
|
204
|
+
const contractHash = existing.toContract.storage?.storageHash ?? '';
|
|
205
|
+
if (contractHash !== meta.to) {
|
|
206
|
+
throw errorStaleContractBookends({
|
|
207
|
+
side: 'to',
|
|
208
|
+
metaHash: meta.to,
|
|
209
|
+
contractHash,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
179
213
|
}
|
|
180
214
|
|
|
181
215
|
/**
|
|
182
216
|
* Project `existing.hints` down to the known `MigrationHints` shape, dropping
|
|
183
|
-
* any legacy keys that may linger in
|
|
217
|
+
* any legacy keys that may linger in metadata scaffolded by older CLI
|
|
184
218
|
* versions (e.g. `planningStrategy`). Picking fields explicitly instead of
|
|
185
219
|
* spreading keeps refreshed `migration.json` files schema-clean regardless
|
|
186
220
|
* of what was on disk before.
|
|
@@ -195,33 +229,40 @@ function normalizeHints(existing: MigrationHints | undefined): MigrationHints {
|
|
|
195
229
|
|
|
196
230
|
/**
|
|
197
231
|
* Pure conversion from a `Migration` instance (plus the previously
|
|
198
|
-
* scaffolded
|
|
232
|
+
* scaffolded metadata, when one exists on disk) to the in-memory
|
|
199
233
|
* artifacts that downstream tooling persists. Owns metadata validation,
|
|
200
|
-
*
|
|
201
|
-
* content-addressed `
|
|
234
|
+
* metadata synthesis/preservation, hint normalization, and the
|
|
235
|
+
* content-addressed `migrationHash` computation, but performs no file I/O
|
|
202
236
|
* — callers handle reads (to source `existing`) and writes (to persist
|
|
203
|
-
* `opsJson` / `
|
|
237
|
+
* `opsJson` / `metadataJson`).
|
|
204
238
|
*/
|
|
205
239
|
export function buildMigrationArtifacts(
|
|
206
240
|
instance: Migration,
|
|
207
|
-
existing: Partial<
|
|
241
|
+
existing: Partial<MigrationMetadata> | null,
|
|
208
242
|
): MigrationArtifacts {
|
|
209
243
|
const ops = instance.operations;
|
|
210
244
|
if (!Array.isArray(ops)) {
|
|
211
245
|
throw new Error('operations must be an array');
|
|
212
246
|
}
|
|
213
247
|
|
|
248
|
+
for (let index = 0; index < ops.length; index++) {
|
|
249
|
+
const result = MigrationOpSchema(ops[index]);
|
|
250
|
+
if (result instanceof type.errors) {
|
|
251
|
+
throw errorInvalidOperationEntry(index, result.summary);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
214
255
|
const rawMeta: unknown = instance.describe();
|
|
215
256
|
const parsed = MigrationMetaSchema(rawMeta);
|
|
216
257
|
if (parsed instanceof type.errors) {
|
|
217
258
|
throw new Error(`describe() returned invalid metadata: ${parsed.summary}`);
|
|
218
259
|
}
|
|
219
260
|
|
|
220
|
-
const
|
|
261
|
+
const metadata = buildAttestedMetadata(parsed, ops, existing);
|
|
221
262
|
|
|
222
263
|
return {
|
|
223
264
|
opsJson: JSON.stringify(ops, null, 2),
|
|
224
|
-
|
|
225
|
-
|
|
265
|
+
metadata,
|
|
266
|
+
metadataJson: JSON.stringify(metadata, null, 2),
|
|
226
267
|
};
|
|
227
268
|
}
|
|
@@ -2,75 +2,87 @@ 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
|
-
/** Forward-edge neighbours
|
|
14
|
+
/** Forward-edge neighbours: edge `e` from `n` visits `e.to` next. */
|
|
14
15
|
function forwardNeighbours(graph: MigrationGraph, node: string) {
|
|
15
16
|
return (graph.forwardChain.get(node) ?? []).map((edge) => ({ next: edge.to, edge }));
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Forward-edge neighbours, sorted by the deterministic tie-break.
|
|
21
|
+
* Used by path-finding so the resulting shortest path is stable across runs.
|
|
22
|
+
*/
|
|
23
|
+
function sortedForwardNeighbours(graph: MigrationGraph, node: string) {
|
|
24
|
+
const edges = graph.forwardChain.get(node) ?? [];
|
|
25
|
+
return [...edges].sort(compareTieBreak).map((edge) => ({ next: edge.to, edge }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reverse-edge neighbours: edge `e` from `n` visits `e.from` next. */
|
|
19
29
|
function reverseNeighbours(graph: MigrationGraph, node: string) {
|
|
20
30
|
return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
function appendEdge(
|
|
24
|
-
map: Map<string, MigrationChainEntry[]>,
|
|
25
|
-
key: string,
|
|
26
|
-
entry: MigrationChainEntry,
|
|
27
|
-
): void {
|
|
33
|
+
function appendEdge(map: Map<string, MigrationEdge[]>, key: string, entry: MigrationEdge): void {
|
|
28
34
|
const bucket = map.get(key);
|
|
29
35
|
if (bucket) bucket.push(entry);
|
|
30
36
|
else map.set(key, [entry]);
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
export function reconstructGraph(packages: readonly
|
|
39
|
+
export function reconstructGraph(packages: readonly MigrationPackage[]): MigrationGraph {
|
|
34
40
|
const nodes = new Set<string>();
|
|
35
|
-
const forwardChain = new Map<string,
|
|
36
|
-
const reverseChain = new Map<string,
|
|
37
|
-
const
|
|
41
|
+
const forwardChain = new Map<string, MigrationEdge[]>();
|
|
42
|
+
const reverseChain = new Map<string, MigrationEdge[]>();
|
|
43
|
+
const migrationByHash = new Map<string, MigrationEdge>();
|
|
38
44
|
|
|
39
45
|
for (const pkg of packages) {
|
|
40
|
-
|
|
46
|
+
// Manifest `from` is `string | null` (null = baseline). The graph layer
|
|
47
|
+
// is the marker/path layer where "no prior state" is encoded as the
|
|
48
|
+
// EMPTY_CONTRACT_HASH sentinel; bridge here so pathfinding stays string-
|
|
49
|
+
// keyed.
|
|
50
|
+
const from = pkg.metadata.from ?? EMPTY_CONTRACT_HASH;
|
|
51
|
+
const { to } = pkg.metadata;
|
|
41
52
|
|
|
42
53
|
if (from === to) {
|
|
43
|
-
throw errorSameSourceAndTarget(pkg.
|
|
54
|
+
throw errorSameSourceAndTarget(pkg.dirPath, from);
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
nodes.add(from);
|
|
47
58
|
nodes.add(to);
|
|
48
59
|
|
|
49
|
-
const migration:
|
|
60
|
+
const migration: MigrationEdge = {
|
|
50
61
|
from,
|
|
51
62
|
to,
|
|
52
|
-
|
|
63
|
+
migrationHash: pkg.metadata.migrationHash,
|
|
53
64
|
dirName: pkg.dirName,
|
|
54
|
-
createdAt: pkg.
|
|
55
|
-
labels: pkg.
|
|
65
|
+
createdAt: pkg.metadata.createdAt,
|
|
66
|
+
labels: pkg.metadata.labels,
|
|
67
|
+
invariants: pkg.metadata.providedInvariants,
|
|
56
68
|
};
|
|
57
69
|
|
|
58
|
-
if (
|
|
59
|
-
throw
|
|
70
|
+
if (migrationByHash.has(migration.migrationHash)) {
|
|
71
|
+
throw errorDuplicateMigrationHash(migration.migrationHash);
|
|
60
72
|
}
|
|
61
|
-
|
|
73
|
+
migrationByHash.set(migration.migrationHash, migration);
|
|
62
74
|
|
|
63
75
|
appendEdge(forwardChain, from, migration);
|
|
64
76
|
appendEdge(reverseChain, to, migration);
|
|
65
77
|
}
|
|
66
78
|
|
|
67
|
-
return { nodes, forwardChain, reverseChain,
|
|
79
|
+
return { nodes, forwardChain, reverseChain, migrationByHash };
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
// ---------------------------------------------------------------------------
|
|
71
83
|
// Deterministic tie-breaking for BFS neighbour order.
|
|
72
|
-
// Used by
|
|
73
|
-
//
|
|
84
|
+
// Used by path-finders only; not a general-purpose utility.
|
|
85
|
+
// Ordering: label priority → createdAt → to → migrationHash.
|
|
74
86
|
// ---------------------------------------------------------------------------
|
|
75
87
|
|
|
76
88
|
const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
|
|
@@ -84,49 +96,42 @@ function labelPriority(labels: readonly string[]): number {
|
|
|
84
96
|
return best;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
function compareTieBreak(a:
|
|
99
|
+
function compareTieBreak(a: MigrationEdge, b: MigrationEdge): number {
|
|
88
100
|
const lp = labelPriority(a.labels) - labelPriority(b.labels);
|
|
89
101
|
if (lp !== 0) return lp;
|
|
90
102
|
const ca = a.createdAt.localeCompare(b.createdAt);
|
|
91
103
|
if (ca !== 0) return ca;
|
|
92
104
|
const tc = a.to.localeCompare(b.to);
|
|
93
105
|
if (tc !== 0) return tc;
|
|
94
|
-
return a.
|
|
106
|
+
return a.migrationHash.localeCompare(b.migrationHash);
|
|
95
107
|
}
|
|
96
108
|
|
|
97
|
-
function sortedNeighbors(edges: readonly
|
|
109
|
+
function sortedNeighbors(edges: readonly MigrationEdge[]): readonly MigrationEdge[] {
|
|
98
110
|
return [...edges].sort(compareTieBreak);
|
|
99
111
|
}
|
|
100
112
|
|
|
101
|
-
/** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */
|
|
102
|
-
function bfsOrdering(
|
|
103
|
-
items: readonly { next: string; edge: MigrationChainEntry }[],
|
|
104
|
-
): readonly { next: string; edge: MigrationChainEntry }[] {
|
|
105
|
-
return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
113
|
/**
|
|
109
114
|
* Find the shortest path from `fromHash` to `toHash` using BFS over the
|
|
110
115
|
* contract-hash graph. Returns the ordered list of edges, or null if no path
|
|
111
116
|
* exists. Returns an empty array when `fromHash === toHash` (no-op).
|
|
112
117
|
*
|
|
113
118
|
* Neighbor ordering is deterministic via the tie-break sort key:
|
|
114
|
-
* label priority → createdAt → to →
|
|
119
|
+
* label priority → createdAt → to → migrationHash.
|
|
115
120
|
*/
|
|
116
121
|
export function findPath(
|
|
117
122
|
graph: MigrationGraph,
|
|
118
123
|
fromHash: string,
|
|
119
124
|
toHash: string,
|
|
120
|
-
): readonly
|
|
125
|
+
): readonly MigrationEdge[] | null {
|
|
121
126
|
if (fromHash === toHash) return [];
|
|
122
127
|
|
|
123
|
-
const parents = new Map<string, { parent: string; edge:
|
|
124
|
-
for (const step of bfs([fromHash], (n) =>
|
|
128
|
+
const parents = new Map<string, { parent: string; edge: MigrationEdge }>();
|
|
129
|
+
for (const step of bfs([fromHash], (n) => sortedForwardNeighbours(graph, n))) {
|
|
125
130
|
if (step.parent !== null && step.incomingEdge !== null) {
|
|
126
|
-
parents.set(step.
|
|
131
|
+
parents.set(step.state, { parent: step.parent, edge: step.incomingEdge });
|
|
127
132
|
}
|
|
128
|
-
if (step.
|
|
129
|
-
const path:
|
|
133
|
+
if (step.state === toHash) {
|
|
134
|
+
const path: MigrationEdge[] = [];
|
|
130
135
|
let cur = toHash;
|
|
131
136
|
let p = parents.get(cur);
|
|
132
137
|
while (p) {
|
|
@@ -142,6 +147,103 @@ export function findPath(
|
|
|
142
147
|
return null;
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Find the shortest path from `fromHash` to `toHash` whose edges collectively
|
|
152
|
+
* cover every invariant in `required`. Returns `null` when no such path exists
|
|
153
|
+
* (either `fromHash`→`toHash` is structurally unreachable, or every reachable
|
|
154
|
+
* path leaves at least one required invariant uncovered). When `required` is
|
|
155
|
+
* empty, delegates to `findPath` so the result is byte-identical for that case.
|
|
156
|
+
*
|
|
157
|
+
* Algorithm: BFS over `(node, coveredSubset)` states with state-level dedup.
|
|
158
|
+
* The covered subset is a `Set<string>` of invariant ids; the state's dedup
|
|
159
|
+
* key is `${node}\0${[...covered].sort().join('\0')}`. State keys distinguish
|
|
160
|
+
* distinct `(node, covered)` tuples regardless of node-name length because
|
|
161
|
+
* `\0` cannot appear in any invariant id (validation rejects whitespace and
|
|
162
|
+
* control chars at authoring time).
|
|
163
|
+
*
|
|
164
|
+
* Neighbour ordering when `required ≠ ∅`: edges covering ≥1 still-needed
|
|
165
|
+
* invariant come first, with `labelPriority → createdAt → to → migrationHash`
|
|
166
|
+
* as the secondary key. The heuristic steers BFS toward the satisfying path;
|
|
167
|
+
* correctness (shortest, deterministic) does not depend on it.
|
|
168
|
+
*/
|
|
169
|
+
export function findPathWithInvariants(
|
|
170
|
+
graph: MigrationGraph,
|
|
171
|
+
fromHash: string,
|
|
172
|
+
toHash: string,
|
|
173
|
+
required: ReadonlySet<string>,
|
|
174
|
+
): readonly MigrationEdge[] | null {
|
|
175
|
+
if (required.size === 0) {
|
|
176
|
+
return findPath(graph, fromHash, toHash);
|
|
177
|
+
}
|
|
178
|
+
if (fromHash === toHash) {
|
|
179
|
+
// Empty path covers no invariants; required is non-empty ⇒ unsatisfiable.
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface InvState {
|
|
184
|
+
readonly node: string;
|
|
185
|
+
readonly covered: ReadonlySet<string>;
|
|
186
|
+
}
|
|
187
|
+
const stateKey = (s: InvState): string => {
|
|
188
|
+
if (s.covered.size === 0) return `${s.node}\0`;
|
|
189
|
+
return `${s.node}\0${[...s.covered].sort().join('\0')}`;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const neighbours = (s: InvState): Iterable<{ next: InvState; edge: MigrationEdge }> => {
|
|
193
|
+
const outgoing = graph.forwardChain.get(s.node) ?? [];
|
|
194
|
+
if (outgoing.length === 0) return [];
|
|
195
|
+
return [...outgoing]
|
|
196
|
+
.map((edge) => {
|
|
197
|
+
let useful = false;
|
|
198
|
+
let next: Set<string> | null = null;
|
|
199
|
+
for (const inv of edge.invariants) {
|
|
200
|
+
if (required.has(inv) && !s.covered.has(inv)) {
|
|
201
|
+
if (next === null) next = new Set(s.covered);
|
|
202
|
+
next.add(inv);
|
|
203
|
+
useful = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { edge, useful, nextCovered: next ?? s.covered };
|
|
207
|
+
})
|
|
208
|
+
.sort((a, b) => {
|
|
209
|
+
if (a.useful !== b.useful) return a.useful ? -1 : 1;
|
|
210
|
+
return compareTieBreak(a.edge, b.edge);
|
|
211
|
+
})
|
|
212
|
+
.map(({ edge, nextCovered }) => ({
|
|
213
|
+
next: { node: edge.to, covered: nextCovered },
|
|
214
|
+
edge,
|
|
215
|
+
}));
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Path reconstruction is consumer-side, keyed on stateKey, same shape as
|
|
219
|
+
// findPath's parents map.
|
|
220
|
+
const parents = new Map<string, { parentKey: string; edge: MigrationEdge }>();
|
|
221
|
+
for (const step of bfs<InvState, MigrationEdge>(
|
|
222
|
+
[{ node: fromHash, covered: new Set() }],
|
|
223
|
+
neighbours,
|
|
224
|
+
stateKey,
|
|
225
|
+
)) {
|
|
226
|
+
const curKey = stateKey(step.state);
|
|
227
|
+
if (step.parent !== null && step.incomingEdge !== null) {
|
|
228
|
+
parents.set(curKey, { parentKey: stateKey(step.parent), edge: step.incomingEdge });
|
|
229
|
+
}
|
|
230
|
+
if (step.state.node === toHash && step.state.covered.size === required.size) {
|
|
231
|
+
const path: MigrationEdge[] = [];
|
|
232
|
+
let cur: string | undefined = curKey;
|
|
233
|
+
while (cur !== undefined) {
|
|
234
|
+
const p = parents.get(cur);
|
|
235
|
+
if (!p) break;
|
|
236
|
+
path.push(p.edge);
|
|
237
|
+
cur = p.parentKey;
|
|
238
|
+
}
|
|
239
|
+
path.reverse();
|
|
240
|
+
return path;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
145
247
|
/**
|
|
146
248
|
* Reverse-BFS from `toHash` over `reverseChain` to collect every node from
|
|
147
249
|
* which `toHash` is reachable (inclusive of `toHash` itself).
|
|
@@ -149,13 +251,13 @@ export function findPath(
|
|
|
149
251
|
function collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<string> {
|
|
150
252
|
const reached = new Set<string>();
|
|
151
253
|
for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) {
|
|
152
|
-
reached.add(step.
|
|
254
|
+
reached.add(step.state);
|
|
153
255
|
}
|
|
154
256
|
return reached;
|
|
155
257
|
}
|
|
156
258
|
|
|
157
259
|
export interface PathDecision {
|
|
158
|
-
readonly selectedPath: readonly
|
|
260
|
+
readonly selectedPath: readonly MigrationEdge[];
|
|
159
261
|
readonly fromHash: string;
|
|
160
262
|
readonly toHash: string;
|
|
161
263
|
readonly alternativeCount: number;
|
|
@@ -202,8 +304,8 @@ export function findPathWithDecision(
|
|
|
202
304
|
if (reachable.length > 1) {
|
|
203
305
|
alternativeCount += reachable.length - 1;
|
|
204
306
|
const sorted = sortedNeighbors(reachable);
|
|
205
|
-
if (sorted[0] && sorted[0].
|
|
206
|
-
if (reachable.some((e) => e.
|
|
307
|
+
if (sorted[0] && sorted[0].migrationHash === edge.migrationHash) {
|
|
308
|
+
if (reachable.some((e) => e.migrationHash !== edge.migrationHash)) {
|
|
207
309
|
tieBreakReasons.push(
|
|
208
310
|
`at ${edge.from}: ${reachable.length} candidates, selected by tie-break`,
|
|
209
311
|
);
|
|
@@ -235,7 +337,7 @@ function findDivergencePoint(
|
|
|
235
337
|
const ancestorSets = leaves.map((leaf) => {
|
|
236
338
|
const ancestors = new Set<string>();
|
|
237
339
|
for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) {
|
|
238
|
-
ancestors.add(step.
|
|
340
|
+
ancestors.add(step.state);
|
|
239
341
|
}
|
|
240
342
|
return ancestors;
|
|
241
343
|
});
|
|
@@ -264,8 +366,8 @@ function findDivergencePoint(
|
|
|
264
366
|
export function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[] {
|
|
265
367
|
const leaves: string[] = [];
|
|
266
368
|
for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) {
|
|
267
|
-
if (!graph.forwardChain.get(step.
|
|
268
|
-
leaves.push(step.
|
|
369
|
+
if (!graph.forwardChain.get(step.state)?.length) {
|
|
370
|
+
leaves.push(step.state);
|
|
269
371
|
}
|
|
270
372
|
}
|
|
271
373
|
return leaves;
|
|
@@ -319,7 +421,7 @@ export function findLeaf(graph: MigrationGraph): string | null {
|
|
|
319
421
|
* to the single target. Returns null for an empty graph.
|
|
320
422
|
* Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
|
|
321
423
|
*/
|
|
322
|
-
export function findLatestMigration(graph: MigrationGraph):
|
|
424
|
+
export function findLatestMigration(graph: MigrationGraph): MigrationEdge | null {
|
|
323
425
|
const leafHash = findLeaf(graph);
|
|
324
426
|
if (leafHash === null) return null;
|
|
325
427
|
|
|
@@ -343,7 +445,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
|
|
|
343
445
|
// Iterative three-color DFS. A frame is (node, outgoing edges, next-index).
|
|
344
446
|
interface Frame {
|
|
345
447
|
node: string;
|
|
346
|
-
outgoing: readonly
|
|
448
|
+
outgoing: readonly MigrationEdge[];
|
|
347
449
|
index: number;
|
|
348
450
|
}
|
|
349
451
|
const stack: Frame[] = [];
|
|
@@ -389,7 +491,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
|
|
|
389
491
|
return cycles;
|
|
390
492
|
}
|
|
391
493
|
|
|
392
|
-
export function detectOrphans(graph: MigrationGraph): readonly
|
|
494
|
+
export function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[] {
|
|
393
495
|
if (graph.nodes.size === 0) return [];
|
|
394
496
|
|
|
395
497
|
const reachable = new Set<string>();
|
|
@@ -412,10 +514,10 @@ export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEnt
|
|
|
412
514
|
}
|
|
413
515
|
|
|
414
516
|
for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) {
|
|
415
|
-
reachable.add(step.
|
|
517
|
+
reachable.add(step.state);
|
|
416
518
|
}
|
|
417
519
|
|
|
418
|
-
const orphans:
|
|
520
|
+
const orphans: MigrationEdge[] = [];
|
|
419
521
|
for (const [from, migrations] of graph.forwardChain) {
|
|
420
522
|
if (!reachable.has(from)) {
|
|
421
523
|
orphans.push(...migrations);
|
package/src/op-schema.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type } from 'arktype';
|
|
2
|
+
|
|
3
|
+
export const MigrationOpSchema = type({
|
|
4
|
+
id: 'string',
|
|
5
|
+
label: 'string',
|
|
6
|
+
operationClass: "'additive' | 'widening' | 'destructive' | 'data'",
|
|
7
|
+
'invariantId?': 'string',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Intentionally shallow: operation-specific payload validation is owned by planner/runner layers.
|
|
11
|
+
export const MigrationOpsSchema = MigrationOpSchema.array();
|
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
|
+
}
|