@prisma-next/migration-tools 0.5.0-dev.1 → 0.5.0-dev.11
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-WVGVMOdu.mjs} +1 -1
- package/dist/{constants-BRi0X7B_.mjs.map → constants-WVGVMOdu.mjs.map} +1 -1
- package/dist/{errors-BKbRGCJM.mjs → errors-CZ9JD4sd.mjs} +50 -21
- package/dist/errors-CZ9JD4sd.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/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/{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} +17 -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 +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.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-B5wbCSna.d.mts +22 -0
- package/dist/graph-B5wbCSna.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 +26 -14
- 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/{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 +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/{dag.ts → migration-graph.ts} +35 -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/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
|
+
}
|
package/src/migration-base.ts
CHANGED
|
@@ -8,8 +8,9 @@ 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 type { MigrationHints,
|
|
11
|
+
import { computeMigrationHash } from './hash';
|
|
12
|
+
import type { MigrationHints, MigrationMetadata } from './metadata';
|
|
13
|
+
import type { MigrationOps } from './package';
|
|
13
14
|
|
|
14
15
|
export interface MigrationMeta {
|
|
15
16
|
readonly from: string;
|
|
@@ -30,7 +31,7 @@ const MigrationMetaSchema = type({
|
|
|
30
31
|
*
|
|
31
32
|
* A `Migration` subclass is itself a `MigrationPlan`: CLI commands and the
|
|
32
33
|
* runner can consume it directly via `targetId`, `operations`, `origin`, and
|
|
33
|
-
* `destination`. The
|
|
34
|
+
* `destination`. The metadata-shaped inputs come from `describe()`, which
|
|
34
35
|
* every migration must implement — `migration.json` is required for a
|
|
35
36
|
* migration to be valid.
|
|
36
37
|
*/
|
|
@@ -123,64 +124,67 @@ function printHelp(): void {
|
|
|
123
124
|
|
|
124
125
|
/**
|
|
125
126
|
* In-memory artifacts produced from a `Migration` instance: the
|
|
126
|
-
* serialized `ops.json` body, the `migration.json`
|
|
127
|
+
* serialized `ops.json` body, the `migration.json` metadata object, and
|
|
127
128
|
* its serialized form. Returned by `buildMigrationArtifacts` so callers
|
|
128
129
|
* (today: `MigrationCLI.run` in `@prisma-next/cli/migration-cli`) can
|
|
129
130
|
* decide how to persist them — write to disk, print in dry-run, ship
|
|
130
131
|
* over the wire — without coupling artifact construction to file I/O.
|
|
132
|
+
*
|
|
133
|
+
* `metadataJson` is `JSON.stringify(metadata, null, 2)` — the canonical
|
|
134
|
+
* on-disk shape that the arktype loader-schema in `./io` validates.
|
|
131
135
|
*/
|
|
132
136
|
export interface MigrationArtifacts {
|
|
133
137
|
readonly opsJson: string;
|
|
134
|
-
readonly
|
|
135
|
-
readonly
|
|
138
|
+
readonly metadata: MigrationMetadata;
|
|
139
|
+
readonly metadataJson: string;
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
/**
|
|
139
|
-
* Build the attested
|
|
140
|
-
* operations list, and the previously-scaffolded
|
|
143
|
+
* Build the attested metadata from `describe()`-derived metadata, the
|
|
144
|
+
* operations list, and the previously-scaffolded metadata (if any).
|
|
141
145
|
*
|
|
142
146
|
* When a `migration.json` already exists for this package (the common
|
|
143
147
|
* case: it was scaffolded by `migration plan`), preserve the contract
|
|
144
148
|
* bookends, hints, labels, and `createdAt` set there — those fields are
|
|
145
149
|
* owned by the CLI scaffolder, not the authored class. Only the
|
|
146
150
|
* `describe()`-derived fields (`from`, `to`, `kind`) and the operations
|
|
147
|
-
* change as the author iterates. When no
|
|
151
|
+
* change as the author iterates. When no metadata exists yet (a bare
|
|
148
152
|
* `migration.ts` run from scratch), synthesize a minimal but
|
|
149
|
-
* schema-conformant
|
|
153
|
+
* schema-conformant record so the resulting package can still be read,
|
|
150
154
|
* verified, and applied.
|
|
151
155
|
*
|
|
152
|
-
* The `
|
|
156
|
+
* The `migrationHash` is recomputed against the current metadata + ops so
|
|
153
157
|
* the on-disk artifacts are always fully attested.
|
|
154
158
|
*/
|
|
155
|
-
function
|
|
159
|
+
function buildAttestedMetadata(
|
|
156
160
|
meta: MigrationMeta,
|
|
157
161
|
ops: MigrationOps,
|
|
158
|
-
existing: Partial<
|
|
159
|
-
):
|
|
160
|
-
const
|
|
162
|
+
existing: Partial<MigrationMetadata> | null,
|
|
163
|
+
): MigrationMetadata {
|
|
164
|
+
const baseMetadata: Omit<MigrationMetadata, 'migrationHash'> = {
|
|
161
165
|
from: meta.from,
|
|
162
166
|
to: meta.to,
|
|
163
167
|
kind: meta.kind ?? 'regular',
|
|
164
168
|
labels: meta.labels ?? existing?.labels ?? [],
|
|
165
169
|
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
166
170
|
fromContract: existing?.fromContract ?? null,
|
|
167
|
-
// When no scaffolded
|
|
171
|
+
// When no scaffolded metadata exists we synthesize a minimal contract
|
|
168
172
|
// stub so the package is still readable end-to-end. The cast is
|
|
169
173
|
// intentional: only the storage bookend matters for hash computation
|
|
170
|
-
// (everything else is stripped by `
|
|
174
|
+
// (everything else is stripped by `computeMigrationHash`), and a real
|
|
171
175
|
// contract bookend would only be available after `migration plan`.
|
|
172
176
|
toContract: existing?.toContract ?? ({ storage: { storageHash: meta.to } } as Contract),
|
|
173
177
|
hints: normalizeHints(existing?.hints),
|
|
174
178
|
...ifDefined('authorship', existing?.authorship),
|
|
175
179
|
};
|
|
176
180
|
|
|
177
|
-
const
|
|
178
|
-
return { ...
|
|
181
|
+
const migrationHash = computeMigrationHash(baseMetadata, ops);
|
|
182
|
+
return { ...baseMetadata, migrationHash };
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
/**
|
|
182
186
|
* Project `existing.hints` down to the known `MigrationHints` shape, dropping
|
|
183
|
-
* any legacy keys that may linger in
|
|
187
|
+
* any legacy keys that may linger in metadata scaffolded by older CLI
|
|
184
188
|
* versions (e.g. `planningStrategy`). Picking fields explicitly instead of
|
|
185
189
|
* spreading keeps refreshed `migration.json` files schema-clean regardless
|
|
186
190
|
* of what was on disk before.
|
|
@@ -195,16 +199,16 @@ function normalizeHints(existing: MigrationHints | undefined): MigrationHints {
|
|
|
195
199
|
|
|
196
200
|
/**
|
|
197
201
|
* Pure conversion from a `Migration` instance (plus the previously
|
|
198
|
-
* scaffolded
|
|
202
|
+
* scaffolded metadata, when one exists on disk) to the in-memory
|
|
199
203
|
* artifacts that downstream tooling persists. Owns metadata validation,
|
|
200
|
-
*
|
|
201
|
-
* content-addressed `
|
|
204
|
+
* metadata synthesis/preservation, hint normalization, and the
|
|
205
|
+
* content-addressed `migrationHash` computation, but performs no file I/O
|
|
202
206
|
* — callers handle reads (to source `existing`) and writes (to persist
|
|
203
|
-
* `opsJson` / `
|
|
207
|
+
* `opsJson` / `metadataJson`).
|
|
204
208
|
*/
|
|
205
209
|
export function buildMigrationArtifacts(
|
|
206
210
|
instance: Migration,
|
|
207
|
-
existing: Partial<
|
|
211
|
+
existing: Partial<MigrationMetadata> | null,
|
|
208
212
|
): MigrationArtifacts {
|
|
209
213
|
const ops = instance.operations;
|
|
210
214
|
if (!Array.isArray(ops)) {
|
|
@@ -217,11 +221,11 @@ export function buildMigrationArtifacts(
|
|
|
217
221
|
throw new Error(`describe() returned invalid metadata: ${parsed.summary}`);
|
|
218
222
|
}
|
|
219
223
|
|
|
220
|
-
const
|
|
224
|
+
const metadata = buildAttestedMetadata(parsed, ops, existing);
|
|
221
225
|
|
|
222
226
|
return {
|
|
223
227
|
opsJson: JSON.stringify(ops, null, 2),
|
|
224
|
-
|
|
225
|
-
|
|
228
|
+
metadata,
|
|
229
|
+
metadataJson: JSON.stringify(metadata, null, 2),
|
|
226
230
|
};
|
|
227
231
|
}
|
|
@@ -2,13 +2,14 @@ import { ifDefined } from '@prisma-next/utils/defined';
|
|
|
2
2
|
import { EMPTY_CONTRACT_HASH } from './constants';
|
|
3
3
|
import {
|
|
4
4
|
errorAmbiguousTarget,
|
|
5
|
-
|
|
5
|
+
errorDuplicateMigrationHash,
|
|
6
6
|
errorNoInitialMigration,
|
|
7
7
|
errorNoTarget,
|
|
8
8
|
errorSameSourceAndTarget,
|
|
9
9
|
} from './errors';
|
|
10
|
+
import type { MigrationEdge, MigrationGraph } from './graph';
|
|
10
11
|
import { bfs } from './graph-ops';
|
|
11
|
-
import type {
|
|
12
|
+
import type { MigrationPackage } from './package';
|
|
12
13
|
|
|
13
14
|
/** Forward-edge neighbours for BFS: edge `e` from `n` visits `e.to` next. */
|
|
14
15
|
function forwardNeighbours(graph: MigrationGraph, node: string) {
|
|
@@ -20,57 +21,53 @@ function reverseNeighbours(graph: MigrationGraph, node: string) {
|
|
|
20
21
|
return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function appendEdge(
|
|
24
|
-
map: Map<string, MigrationChainEntry[]>,
|
|
25
|
-
key: string,
|
|
26
|
-
entry: MigrationChainEntry,
|
|
27
|
-
): void {
|
|
24
|
+
function appendEdge(map: Map<string, MigrationEdge[]>, key: string, entry: MigrationEdge): void {
|
|
28
25
|
const bucket = map.get(key);
|
|
29
26
|
if (bucket) bucket.push(entry);
|
|
30
27
|
else map.set(key, [entry]);
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
export function reconstructGraph(packages: readonly
|
|
30
|
+
export function reconstructGraph(packages: readonly MigrationPackage[]): MigrationGraph {
|
|
34
31
|
const nodes = new Set<string>();
|
|
35
|
-
const forwardChain = new Map<string,
|
|
36
|
-
const reverseChain = new Map<string,
|
|
37
|
-
const
|
|
32
|
+
const forwardChain = new Map<string, MigrationEdge[]>();
|
|
33
|
+
const reverseChain = new Map<string, MigrationEdge[]>();
|
|
34
|
+
const migrationByHash = new Map<string, MigrationEdge>();
|
|
38
35
|
|
|
39
36
|
for (const pkg of packages) {
|
|
40
|
-
const { from, to } = pkg.
|
|
37
|
+
const { from, to } = pkg.metadata;
|
|
41
38
|
|
|
42
39
|
if (from === to) {
|
|
43
|
-
throw errorSameSourceAndTarget(pkg.
|
|
40
|
+
throw errorSameSourceAndTarget(pkg.dirPath, from);
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
nodes.add(from);
|
|
47
44
|
nodes.add(to);
|
|
48
45
|
|
|
49
|
-
const migration:
|
|
46
|
+
const migration: MigrationEdge = {
|
|
50
47
|
from,
|
|
51
48
|
to,
|
|
52
|
-
|
|
49
|
+
migrationHash: pkg.metadata.migrationHash,
|
|
53
50
|
dirName: pkg.dirName,
|
|
54
|
-
createdAt: pkg.
|
|
55
|
-
labels: pkg.
|
|
51
|
+
createdAt: pkg.metadata.createdAt,
|
|
52
|
+
labels: pkg.metadata.labels,
|
|
56
53
|
};
|
|
57
54
|
|
|
58
|
-
if (
|
|
59
|
-
throw
|
|
55
|
+
if (migrationByHash.has(migration.migrationHash)) {
|
|
56
|
+
throw errorDuplicateMigrationHash(migration.migrationHash);
|
|
60
57
|
}
|
|
61
|
-
|
|
58
|
+
migrationByHash.set(migration.migrationHash, migration);
|
|
62
59
|
|
|
63
60
|
appendEdge(forwardChain, from, migration);
|
|
64
61
|
appendEdge(reverseChain, to, migration);
|
|
65
62
|
}
|
|
66
63
|
|
|
67
|
-
return { nodes, forwardChain, reverseChain,
|
|
64
|
+
return { nodes, forwardChain, reverseChain, migrationByHash };
|
|
68
65
|
}
|
|
69
66
|
|
|
70
67
|
// ---------------------------------------------------------------------------
|
|
71
68
|
// Deterministic tie-breaking for BFS neighbour order.
|
|
72
69
|
// Used by `findPath` and `findPathWithDecision` only; not a general-purpose
|
|
73
|
-
// utility. Ordering: label priority → createdAt → to →
|
|
70
|
+
// utility. Ordering: label priority → createdAt → to → migrationHash.
|
|
74
71
|
// ---------------------------------------------------------------------------
|
|
75
72
|
|
|
76
73
|
const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
|
|
@@ -84,24 +81,24 @@ function labelPriority(labels: readonly string[]): number {
|
|
|
84
81
|
return best;
|
|
85
82
|
}
|
|
86
83
|
|
|
87
|
-
function compareTieBreak(a:
|
|
84
|
+
function compareTieBreak(a: MigrationEdge, b: MigrationEdge): number {
|
|
88
85
|
const lp = labelPriority(a.labels) - labelPriority(b.labels);
|
|
89
86
|
if (lp !== 0) return lp;
|
|
90
87
|
const ca = a.createdAt.localeCompare(b.createdAt);
|
|
91
88
|
if (ca !== 0) return ca;
|
|
92
89
|
const tc = a.to.localeCompare(b.to);
|
|
93
90
|
if (tc !== 0) return tc;
|
|
94
|
-
return a.
|
|
91
|
+
return a.migrationHash.localeCompare(b.migrationHash);
|
|
95
92
|
}
|
|
96
93
|
|
|
97
|
-
function sortedNeighbors(edges: readonly
|
|
94
|
+
function sortedNeighbors(edges: readonly MigrationEdge[]): readonly MigrationEdge[] {
|
|
98
95
|
return [...edges].sort(compareTieBreak);
|
|
99
96
|
}
|
|
100
97
|
|
|
101
98
|
/** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */
|
|
102
99
|
function bfsOrdering(
|
|
103
|
-
items: readonly { next: string; edge:
|
|
104
|
-
): readonly { next: string; edge:
|
|
100
|
+
items: readonly { next: string; edge: MigrationEdge }[],
|
|
101
|
+
): readonly { next: string; edge: MigrationEdge }[] {
|
|
105
102
|
return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));
|
|
106
103
|
}
|
|
107
104
|
|
|
@@ -111,22 +108,22 @@ function bfsOrdering(
|
|
|
111
108
|
* exists. Returns an empty array when `fromHash === toHash` (no-op).
|
|
112
109
|
*
|
|
113
110
|
* Neighbor ordering is deterministic via the tie-break sort key:
|
|
114
|
-
* label priority → createdAt → to →
|
|
111
|
+
* label priority → createdAt → to → migrationHash.
|
|
115
112
|
*/
|
|
116
113
|
export function findPath(
|
|
117
114
|
graph: MigrationGraph,
|
|
118
115
|
fromHash: string,
|
|
119
116
|
toHash: string,
|
|
120
|
-
): readonly
|
|
117
|
+
): readonly MigrationEdge[] | null {
|
|
121
118
|
if (fromHash === toHash) return [];
|
|
122
119
|
|
|
123
|
-
const parents = new Map<string, { parent: string; edge:
|
|
120
|
+
const parents = new Map<string, { parent: string; edge: MigrationEdge }>();
|
|
124
121
|
for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n), bfsOrdering)) {
|
|
125
122
|
if (step.parent !== null && step.incomingEdge !== null) {
|
|
126
123
|
parents.set(step.node, { parent: step.parent, edge: step.incomingEdge });
|
|
127
124
|
}
|
|
128
125
|
if (step.node === toHash) {
|
|
129
|
-
const path:
|
|
126
|
+
const path: MigrationEdge[] = [];
|
|
130
127
|
let cur = toHash;
|
|
131
128
|
let p = parents.get(cur);
|
|
132
129
|
while (p) {
|
|
@@ -155,7 +152,7 @@ function collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<
|
|
|
155
152
|
}
|
|
156
153
|
|
|
157
154
|
export interface PathDecision {
|
|
158
|
-
readonly selectedPath: readonly
|
|
155
|
+
readonly selectedPath: readonly MigrationEdge[];
|
|
159
156
|
readonly fromHash: string;
|
|
160
157
|
readonly toHash: string;
|
|
161
158
|
readonly alternativeCount: number;
|
|
@@ -202,8 +199,8 @@ export function findPathWithDecision(
|
|
|
202
199
|
if (reachable.length > 1) {
|
|
203
200
|
alternativeCount += reachable.length - 1;
|
|
204
201
|
const sorted = sortedNeighbors(reachable);
|
|
205
|
-
if (sorted[0] && sorted[0].
|
|
206
|
-
if (reachable.some((e) => e.
|
|
202
|
+
if (sorted[0] && sorted[0].migrationHash === edge.migrationHash) {
|
|
203
|
+
if (reachable.some((e) => e.migrationHash !== edge.migrationHash)) {
|
|
207
204
|
tieBreakReasons.push(
|
|
208
205
|
`at ${edge.from}: ${reachable.length} candidates, selected by tie-break`,
|
|
209
206
|
);
|
|
@@ -319,7 +316,7 @@ export function findLeaf(graph: MigrationGraph): string | null {
|
|
|
319
316
|
* to the single target. Returns null for an empty graph.
|
|
320
317
|
* Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
|
|
321
318
|
*/
|
|
322
|
-
export function findLatestMigration(graph: MigrationGraph):
|
|
319
|
+
export function findLatestMigration(graph: MigrationGraph): MigrationEdge | null {
|
|
323
320
|
const leafHash = findLeaf(graph);
|
|
324
321
|
if (leafHash === null) return null;
|
|
325
322
|
|
|
@@ -343,7 +340,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
|
|
|
343
340
|
// Iterative three-color DFS. A frame is (node, outgoing edges, next-index).
|
|
344
341
|
interface Frame {
|
|
345
342
|
node: string;
|
|
346
|
-
outgoing: readonly
|
|
343
|
+
outgoing: readonly MigrationEdge[];
|
|
347
344
|
index: number;
|
|
348
345
|
}
|
|
349
346
|
const stack: Frame[] = [];
|
|
@@ -389,7 +386,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
|
|
|
389
386
|
return cycles;
|
|
390
387
|
}
|
|
391
388
|
|
|
392
|
-
export function detectOrphans(graph: MigrationGraph): readonly
|
|
389
|
+
export function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[] {
|
|
393
390
|
if (graph.nodes.size === 0) return [];
|
|
394
391
|
|
|
395
392
|
const reachable = new Set<string>();
|
|
@@ -415,7 +412,7 @@ export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEnt
|
|
|
415
412
|
reachable.add(step.node);
|
|
416
413
|
}
|
|
417
414
|
|
|
418
|
-
const orphans:
|
|
415
|
+
const orphans: MigrationEdge[] = [];
|
|
419
416
|
for (const [from, migrations] of graph.forwardChain) {
|
|
420
417
|
if (!reachable.has(from)) {
|
|
421
418
|
orphans.push(...migrations);
|
package/src/package.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MigrationPlanOperation } from '@prisma-next/framework-components/control';
|
|
2
|
+
import type { MigrationMetadata } from './metadata';
|
|
3
|
+
|
|
4
|
+
export type MigrationOps = readonly MigrationPlanOperation[];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An on-disk migration directory (a "package") with its parsed metadata and
|
|
8
|
+
* operations. Returned from `readMigrationPackage` / `readMigrationsDir` only
|
|
9
|
+
* after the loader has verified the package's integrity (hash recomputation
|
|
10
|
+
* against the stored `migrationHash`); holding a `MigrationPackage` value
|
|
11
|
+
* therefore implies the package is internally consistent.
|
|
12
|
+
*/
|
|
13
|
+
export interface MigrationPackage {
|
|
14
|
+
readonly dirName: string;
|
|
15
|
+
readonly dirPath: string;
|
|
16
|
+
readonly metadata: MigrationMetadata;
|
|
17
|
+
readonly ops: MigrationOps;
|
|
18
|
+
}
|