@prisma-next/migration-tools 0.3.0-dev.99 → 0.4.0-dev.1
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 +10 -8
- package/dist/constants-DARNL_LD.mjs +10 -0
- package/dist/constants-DARNL_LD.mjs.map +1 -0
- package/dist/{errors-CqLiJwqA.mjs → errors-C_XuSbX7.mjs} +17 -17
- package/dist/errors-C_XuSbX7.mjs.map +1 -0
- package/dist/exports/attestation.d.mts +1 -1
- package/dist/exports/attestation.d.mts.map +1 -1
- package/dist/exports/attestation.mjs +4 -7
- package/dist/exports/attestation.mjs.map +1 -1
- package/dist/exports/constants.d.mts +9 -0
- package/dist/exports/constants.d.mts.map +1 -0
- package/dist/exports/constants.mjs +3 -0
- package/dist/exports/dag.d.mts +9 -9
- package/dist/exports/dag.mjs +18 -18
- package/dist/exports/dag.mjs.map +1 -1
- package/dist/exports/io.d.mts +6 -4
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +2 -2
- package/dist/exports/migration-ts.d.mts +34 -0
- package/dist/exports/migration-ts.d.mts.map +1 -0
- package/dist/exports/migration-ts.mjs +125 -0
- package/dist/exports/migration-ts.mjs.map +1 -0
- package/dist/exports/migration.d.mts +28 -0
- package/dist/exports/migration.d.mts.map +1 -0
- package/dist/exports/migration.mjs +105 -0
- package/dist/exports/migration.mjs.map +1 -0
- package/dist/exports/refs.mjs +1 -1
- package/dist/exports/types.d.mts +3 -3
- package/dist/exports/types.mjs +5 -2
- package/dist/exports/types.mjs.map +1 -1
- package/dist/{io-afog-e8J.mjs → io-BO18-Evu.mjs} +10 -4
- package/dist/io-BO18-Evu.mjs.map +1 -0
- package/dist/{types-9YQfIg6N.d.mts → types-DXjq7Fum.d.mts} +13 -9
- package/dist/types-DXjq7Fum.d.mts.map +1 -0
- package/package.json +18 -6
- package/src/attestation.ts +3 -5
- package/src/constants.ts +5 -0
- package/src/dag.ts +21 -21
- package/src/errors.ts +35 -27
- package/src/exports/constants.ts +1 -0
- package/src/exports/io.ts +2 -0
- package/src/exports/migration-ts.ts +6 -0
- package/src/exports/migration.ts +1 -0
- package/src/exports/types.ts +5 -3
- package/src/io.ts +16 -5
- package/src/migration-base.ts +141 -0
- package/src/migration-ts.ts +199 -0
- package/src/types.ts +15 -7
- package/dist/errors-CqLiJwqA.mjs.map +0 -1
- package/dist/io-afog-e8J.mjs.map +0 -1
- package/dist/types-9YQfIg6N.d.mts.map +0 -1
package/src/dag.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { EMPTY_CONTRACT_HASH } from '@prisma-next/core-control-plane/constants';
|
|
2
1
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
2
|
+
import { EMPTY_CONTRACT_HASH } from './constants';
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
errorAmbiguousTarget,
|
|
5
5
|
errorDuplicateMigrationId,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
errorNoInitialMigration,
|
|
7
|
+
errorNoTarget,
|
|
8
|
+
errorSameSourceAndTarget,
|
|
9
9
|
} from './errors';
|
|
10
10
|
import type { AttestedMigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
|
|
11
11
|
|
|
@@ -19,7 +19,7 @@ export function reconstructGraph(packages: readonly AttestedMigrationBundle[]):
|
|
|
19
19
|
const { from, to } = pkg.manifest;
|
|
20
20
|
|
|
21
21
|
if (from === to) {
|
|
22
|
-
throw
|
|
22
|
+
throw errorSameSourceAndTarget(pkg.dirName, from);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
nodes.add(from);
|
|
@@ -203,7 +203,7 @@ export function findPathWithDecision(
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
/**
|
|
206
|
-
* Walk ancestors of each
|
|
206
|
+
* Walk ancestors of each branch tip back to find the last node
|
|
207
207
|
* that appears on all paths. Returns `fromHash` if no shared ancestor is found.
|
|
208
208
|
*/
|
|
209
209
|
function findDivergencePoint(
|
|
@@ -246,8 +246,8 @@ function findDivergencePoint(
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/**
|
|
249
|
-
* Find all
|
|
250
|
-
*
|
|
249
|
+
* Find all branch tips (nodes with no outgoing edges) reachable from
|
|
250
|
+
* `fromHash` via forward edges.
|
|
251
251
|
*/
|
|
252
252
|
export function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[] {
|
|
253
253
|
const visited = new Set<string>();
|
|
@@ -276,10 +276,10 @@ export function findReachableLeaves(graph: MigrationGraph, fromHash: string): re
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
* Find the
|
|
280
|
-
* EMPTY_CONTRACT_HASH. Throws
|
|
281
|
-
* originate from the empty hash
|
|
282
|
-
* Throws
|
|
279
|
+
* Find the target contract hash of the migration graph reachable from
|
|
280
|
+
* EMPTY_CONTRACT_HASH. Throws NO_INITIAL_MIGRATION if the graph has
|
|
281
|
+
* nodes but none originate from the empty hash.
|
|
282
|
+
* Throws AMBIGUOUS_TARGET if multiple branch tips exist.
|
|
283
283
|
*/
|
|
284
284
|
export function findLeaf(graph: MigrationGraph): string {
|
|
285
285
|
if (graph.nodes.size === 0) {
|
|
@@ -287,7 +287,7 @@ export function findLeaf(graph: MigrationGraph): string {
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
if (!graph.nodes.has(EMPTY_CONTRACT_HASH)) {
|
|
290
|
-
throw
|
|
290
|
+
throw errorNoInitialMigration([...graph.nodes]);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
const leaves = findReachableLeaves(graph, EMPTY_CONTRACT_HASH);
|
|
@@ -295,21 +295,21 @@ export function findLeaf(graph: MigrationGraph): string {
|
|
|
295
295
|
if (leaves.length === 0) {
|
|
296
296
|
const reachable = [...graph.nodes].filter((n) => n !== EMPTY_CONTRACT_HASH);
|
|
297
297
|
if (reachable.length > 0) {
|
|
298
|
-
throw
|
|
298
|
+
throw errorNoTarget(reachable);
|
|
299
299
|
}
|
|
300
300
|
return EMPTY_CONTRACT_HASH;
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
if (leaves.length > 1) {
|
|
304
304
|
const divergencePoint = findDivergencePoint(graph, EMPTY_CONTRACT_HASH, leaves);
|
|
305
|
-
const branches = leaves.map((
|
|
306
|
-
const path = findPath(graph, divergencePoint,
|
|
305
|
+
const branches = leaves.map((tip) => {
|
|
306
|
+
const path = findPath(graph, divergencePoint, tip);
|
|
307
307
|
return {
|
|
308
|
-
|
|
308
|
+
tip,
|
|
309
309
|
edges: (path ?? []).map((e) => ({ dirName: e.dirName, from: e.from, to: e.to })),
|
|
310
310
|
};
|
|
311
311
|
});
|
|
312
|
-
throw
|
|
312
|
+
throw errorAmbiguousTarget(leaves, { divergencePoint, branches });
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
const leaf = leaves[0];
|
|
@@ -318,8 +318,8 @@ export function findLeaf(graph: MigrationGraph): string {
|
|
|
318
318
|
|
|
319
319
|
/**
|
|
320
320
|
* Find the latest migration entry by traversing from EMPTY_CONTRACT_HASH
|
|
321
|
-
* to the single
|
|
322
|
-
* Throws
|
|
321
|
+
* to the single target. Returns null for an empty graph.
|
|
322
|
+
* Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
|
|
323
323
|
*/
|
|
324
324
|
export function findLatestMigration(graph: MigrationGraph): MigrationChainEntry | null {
|
|
325
325
|
if (graph.nodes.size === 0) {
|
package/src/errors.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Follows the NAMESPACE.SUBCODE convention from ADR 027. All codes live under
|
|
5
5
|
* the MIGRATION namespace. These are tooling-time errors (file I/O, attestation,
|
|
6
|
-
* migration
|
|
6
|
+
* migration history reconstruction), distinct from the runtime MIGRATION.* codes for apply-time
|
|
7
7
|
* failures (PRECHECK_FAILED, POSTCHECK_FAILED, etc.).
|
|
8
8
|
*
|
|
9
9
|
* Fields:
|
|
@@ -84,40 +84,44 @@ export function errorInvalidSlug(slug: string): MigrationToolsError {
|
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export function
|
|
88
|
-
return new MigrationToolsError(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
export function errorSameSourceAndTarget(dirName: string, hash: string): MigrationToolsError {
|
|
88
|
+
return new MigrationToolsError(
|
|
89
|
+
'MIGRATION.SAME_SOURCE_AND_TARGET',
|
|
90
|
+
'Migration has same source and target',
|
|
91
|
+
{
|
|
92
|
+
why: `Migration "${dirName}" has from === to === "${hash}". A migration must transition between two different contract states.`,
|
|
93
|
+
fix: 'Delete the invalid migration directory and re-run migration plan.',
|
|
94
|
+
details: { dirName, hash },
|
|
95
|
+
},
|
|
96
|
+
);
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
export function
|
|
96
|
-
|
|
99
|
+
export function errorAmbiguousTarget(
|
|
100
|
+
branchTips: readonly string[],
|
|
97
101
|
context?: {
|
|
98
102
|
divergencePoint: string;
|
|
99
103
|
branches: readonly {
|
|
100
|
-
|
|
104
|
+
tip: string;
|
|
101
105
|
edges: readonly { dirName: string; from: string; to: string }[];
|
|
102
106
|
}[];
|
|
103
107
|
},
|
|
104
108
|
): MigrationToolsError {
|
|
105
109
|
const divergenceInfo = context
|
|
106
|
-
? `\nDivergence point: ${context.divergencePoint}\nBranches:\n${context.branches.map((b) => ` → ${b.
|
|
110
|
+
? `\nDivergence point: ${context.divergencePoint}\nBranches:\n${context.branches.map((b) => ` → ${b.tip} (${b.edges.length} edge(s): ${b.edges.map((e) => e.dirName).join(' → ') || 'direct'})`).join('\n')}`
|
|
107
111
|
: '';
|
|
108
|
-
return new MigrationToolsError('MIGRATION.
|
|
109
|
-
why: `
|
|
112
|
+
return new MigrationToolsError('MIGRATION.AMBIGUOUS_TARGET', 'Ambiguous migration target', {
|
|
113
|
+
why: `The migration history has diverged into multiple branches: ${branchTips.join(', ')}. This typically happens when two developers plan migrations from the same starting point.${divergenceInfo}`,
|
|
110
114
|
fix: 'Use `migration ref set <name> <hash>` to target a specific branch, delete one of the conflicting migration directories and re-run `migration plan`, or use --from <hash> to explicitly select a starting point.',
|
|
111
115
|
details: {
|
|
112
|
-
|
|
116
|
+
branchTips,
|
|
113
117
|
...(context ? { divergencePoint: context.divergencePoint, branches: context.branches } : {}),
|
|
114
118
|
},
|
|
115
119
|
});
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
export function
|
|
119
|
-
return new MigrationToolsError('MIGRATION.
|
|
120
|
-
why: `No
|
|
122
|
+
export function errorNoInitialMigration(nodes: readonly string[]): MigrationToolsError {
|
|
123
|
+
return new MigrationToolsError('MIGRATION.NO_INITIAL_MIGRATION', 'No initial migration found', {
|
|
124
|
+
why: `No migration starts from the empty contract state (known hashes: ${nodes.join(', ')}). At least one migration must originate from the empty state.`,
|
|
121
125
|
fix: 'Inspect the migrations directory for corrupted migration.json files. At least one migration must start from the empty contract hash.',
|
|
122
126
|
details: { nodes },
|
|
123
127
|
});
|
|
@@ -131,6 +135,14 @@ export function errorInvalidRefs(refsPath: string, reason: string): MigrationToo
|
|
|
131
135
|
});
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
export function errorInvalidRefFile(filePath: string, reason: string): MigrationToolsError {
|
|
139
|
+
return new MigrationToolsError('MIGRATION.INVALID_REF_FILE', 'Invalid ref file', {
|
|
140
|
+
why: `Ref file at "${filePath}" is invalid: ${reason}`,
|
|
141
|
+
fix: 'Ensure the ref file contains valid JSON with { "hash": "sha256:<64 hex chars>", "invariants": ["..."] }.',
|
|
142
|
+
details: { path: filePath, reason },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
export function errorInvalidRefName(refName: string): MigrationToolsError {
|
|
135
147
|
return new MigrationToolsError('MIGRATION.INVALID_REF_NAME', 'Invalid ref name', {
|
|
136
148
|
why: `Ref name "${refName}" is invalid. Names must be lowercase alphanumeric with hyphens or forward slashes (no "." or ".." segments).`,
|
|
@@ -139,16 +151,12 @@ export function errorInvalidRefName(refName: string): MigrationToolsError {
|
|
|
139
151
|
});
|
|
140
152
|
}
|
|
141
153
|
|
|
142
|
-
export function
|
|
143
|
-
return new MigrationToolsError(
|
|
144
|
-
|
|
145
|
-
'
|
|
146
|
-
{
|
|
147
|
-
|
|
148
|
-
fix: 'Use --from <hash> to specify the planning origin explicitly.',
|
|
149
|
-
details: { reachableNodes },
|
|
150
|
-
},
|
|
151
|
-
);
|
|
154
|
+
export function errorNoTarget(reachableHashes: readonly string[]): MigrationToolsError {
|
|
155
|
+
return new MigrationToolsError('MIGRATION.NO_TARGET', 'No migration target could be resolved', {
|
|
156
|
+
why: `The migration history contains cycles and no target can be resolved automatically (reachable hashes: ${reachableHashes.join(', ')}). This typically happens after rollback migrations (e.g., C1→C2→C1).`,
|
|
157
|
+
fix: 'Use --from <hash> to specify the planning origin explicitly.',
|
|
158
|
+
details: { reachableHashes },
|
|
159
|
+
});
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
export function errorInvalidRefValue(value: string): MigrationToolsError {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { EMPTY_CONTRACT_HASH } from '../constants';
|
package/src/exports/io.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Migration, type MigrationMeta } from '../migration-base';
|
package/src/exports/types.ts
CHANGED
|
@@ -2,13 +2,15 @@ export { MigrationToolsError } from '../errors';
|
|
|
2
2
|
export type {
|
|
3
3
|
AttestedMigrationBundle,
|
|
4
4
|
AttestedMigrationManifest,
|
|
5
|
+
BaseMigrationBundle,
|
|
6
|
+
BaseMigrationBundle as MigrationBundle,
|
|
7
|
+
BaseMigrationBundle as MigrationPackage,
|
|
8
|
+
DraftMigrationBundle,
|
|
5
9
|
DraftMigrationManifest,
|
|
6
|
-
MigrationBundle,
|
|
7
|
-
MigrationBundle as MigrationPackage,
|
|
8
10
|
MigrationChainEntry,
|
|
9
11
|
MigrationGraph,
|
|
10
12
|
MigrationHints,
|
|
11
13
|
MigrationManifest,
|
|
12
14
|
MigrationOps,
|
|
13
15
|
} from '../types';
|
|
14
|
-
export { isAttested } from '../types';
|
|
16
|
+
export { isAttested, isDraft } from '../types';
|
package/src/io.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
errorInvalidSlug,
|
|
9
9
|
errorMissingFile,
|
|
10
10
|
} from './errors';
|
|
11
|
-
import type {
|
|
11
|
+
import type { BaseMigrationBundle, MigrationManifest, MigrationOps } from './types';
|
|
12
12
|
|
|
13
13
|
const MANIFEST_FILE = 'migration.json';
|
|
14
14
|
const OPS_FILE = 'ops.json';
|
|
@@ -48,7 +48,7 @@ const MigrationManifestSchema = type({
|
|
|
48
48
|
const MigrationOpSchema = type({
|
|
49
49
|
id: 'string',
|
|
50
50
|
label: 'string',
|
|
51
|
-
operationClass: "'additive' | 'widening' | 'destructive'",
|
|
51
|
+
operationClass: "'additive' | 'widening' | 'destructive' | 'data'",
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
// Intentionally shallow: operation-specific payload validation is owned by planner/runner layers.
|
|
@@ -74,7 +74,18 @@ export async function writeMigrationPackage(
|
|
|
74
74
|
await writeFile(join(dir, OPS_FILE), JSON.stringify(ops, null, 2), { flag: 'wx' });
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export async function
|
|
77
|
+
export async function writeMigrationManifest(
|
|
78
|
+
dir: string,
|
|
79
|
+
manifest: MigrationManifest,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function writeMigrationOps(dir: string, ops: MigrationOps): Promise<void> {
|
|
85
|
+
await writeFile(join(dir, OPS_FILE), `${JSON.stringify(ops, null, 2)}\n`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function readMigrationPackage(dir: string): Promise<BaseMigrationBundle> {
|
|
78
89
|
const manifestPath = join(dir, MANIFEST_FILE);
|
|
79
90
|
const opsPath = join(dir, OPS_FILE);
|
|
80
91
|
|
|
@@ -142,7 +153,7 @@ function validateOps(ops: unknown, filePath: string): asserts ops is MigrationOp
|
|
|
142
153
|
|
|
143
154
|
export async function readMigrationsDir(
|
|
144
155
|
migrationsRoot: string,
|
|
145
|
-
): Promise<readonly
|
|
156
|
+
): Promise<readonly BaseMigrationBundle[]> {
|
|
146
157
|
let entries: string[];
|
|
147
158
|
try {
|
|
148
159
|
entries = await readdir(migrationsRoot);
|
|
@@ -153,7 +164,7 @@ export async function readMigrationsDir(
|
|
|
153
164
|
throw error;
|
|
154
165
|
}
|
|
155
166
|
|
|
156
|
-
const packages:
|
|
167
|
+
const packages: BaseMigrationBundle[] = [];
|
|
157
168
|
|
|
158
169
|
for (const entry of entries.sort()) {
|
|
159
170
|
const entryPath = join(migrationsRoot, entry);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { type } from 'arktype';
|
|
4
|
+
import { dirname, join } from 'pathe';
|
|
5
|
+
|
|
6
|
+
export interface MigrationMeta {
|
|
7
|
+
readonly from: string;
|
|
8
|
+
readonly to: string;
|
|
9
|
+
readonly kind?: 'regular' | 'baseline';
|
|
10
|
+
readonly labels?: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MigrationMetaSchema = type({
|
|
14
|
+
from: 'string',
|
|
15
|
+
to: 'string',
|
|
16
|
+
'kind?': "'regular' | 'baseline'",
|
|
17
|
+
'labels?': 'string[]',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export abstract class Migration<TOperation = unknown> {
|
|
21
|
+
abstract plan(): TOperation[];
|
|
22
|
+
|
|
23
|
+
describe(): MigrationMeta | undefined {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Entrypoint guard for migration files. When called at module scope,
|
|
29
|
+
* detects whether the file is being run directly (e.g. `tsx migration.ts`)
|
|
30
|
+
* and if so, serializes the migration plan to `ops.json` (and optionally
|
|
31
|
+
* `migration.json`) in the same directory. When the file is imported by
|
|
32
|
+
* another module, this is a no-op.
|
|
33
|
+
*
|
|
34
|
+
* Usage (at module scope, after the class definition):
|
|
35
|
+
*
|
|
36
|
+
* class MyMigration extends Migration { ... }
|
|
37
|
+
* export default MyMigration;
|
|
38
|
+
* Migration.run(import.meta.url, MyMigration);
|
|
39
|
+
*/
|
|
40
|
+
static run(importMetaUrl: string, MigrationClass: new () => Migration): void {
|
|
41
|
+
if (!importMetaUrl) return;
|
|
42
|
+
|
|
43
|
+
const metaFilename = fileURLToPath(importMetaUrl);
|
|
44
|
+
const argv1 = process.argv[1];
|
|
45
|
+
if (!argv1) return;
|
|
46
|
+
|
|
47
|
+
let isEntrypoint: boolean;
|
|
48
|
+
try {
|
|
49
|
+
isEntrypoint = realpathSync(metaFilename) === realpathSync(argv1);
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!isEntrypoint) return;
|
|
54
|
+
|
|
55
|
+
const args = process.argv.slice(2);
|
|
56
|
+
|
|
57
|
+
if (args.includes('--help')) {
|
|
58
|
+
printHelp();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const dryRun = args.includes('--dry-run');
|
|
63
|
+
const migrationDir = dirname(metaFilename);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
serializeMigration(MigrationClass, migrationDir, dryRun);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printHelp(): void {
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
[
|
|
77
|
+
'Usage: tsx <migration-file> [options]',
|
|
78
|
+
'',
|
|
79
|
+
'Options:',
|
|
80
|
+
' --dry-run Print operations to stdout without writing files',
|
|
81
|
+
' --help Show this help message',
|
|
82
|
+
'',
|
|
83
|
+
].join('\n'),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildManifest(meta: MigrationMeta): Record<string, unknown> {
|
|
88
|
+
return {
|
|
89
|
+
migrationId: null,
|
|
90
|
+
from: meta.from,
|
|
91
|
+
to: meta.to,
|
|
92
|
+
kind: meta.kind ?? 'regular',
|
|
93
|
+
labels: meta.labels ?? [],
|
|
94
|
+
createdAt: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serializeMigration(
|
|
99
|
+
MigrationClass: new () => Migration,
|
|
100
|
+
migrationDir: string,
|
|
101
|
+
dryRun: boolean,
|
|
102
|
+
): void {
|
|
103
|
+
const instance = new MigrationClass();
|
|
104
|
+
|
|
105
|
+
const ops = instance.plan();
|
|
106
|
+
|
|
107
|
+
if (!Array.isArray(ops)) {
|
|
108
|
+
throw new Error('plan() must return an array of operations');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const serializedOps = JSON.stringify(ops, null, 2);
|
|
112
|
+
|
|
113
|
+
let manifest: Record<string, unknown> | undefined;
|
|
114
|
+
if (typeof instance.describe === 'function') {
|
|
115
|
+
const rawMeta: unknown = instance.describe();
|
|
116
|
+
if (rawMeta !== undefined) {
|
|
117
|
+
const parsed = MigrationMetaSchema(rawMeta);
|
|
118
|
+
if (parsed instanceof type.errors) {
|
|
119
|
+
throw new Error(`describe() returned invalid metadata: ${parsed.summary}`);
|
|
120
|
+
}
|
|
121
|
+
manifest = buildManifest(parsed);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (dryRun) {
|
|
126
|
+
if (manifest) {
|
|
127
|
+
process.stdout.write(`--- migration.json ---\n${JSON.stringify(manifest, null, 2)}\n`);
|
|
128
|
+
process.stdout.write('--- ops.json ---\n');
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write(`${serializedOps}\n`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
writeFileSync(join(migrationDir, 'ops.json'), serializedOps);
|
|
135
|
+
if (manifest) {
|
|
136
|
+
writeFileSync(join(migrationDir, 'migration.json'), JSON.stringify(manifest, null, 2));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const files = manifest ? 'ops.json + migration.json' : 'ops.json';
|
|
140
|
+
process.stdout.write(`Wrote ${files} to ${migrationDir}\n`);
|
|
141
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for scaffolding and evaluating migration.ts files.
|
|
3
|
+
*
|
|
4
|
+
* - scaffoldMigrationTs: writes a migration.ts file with boilerplate
|
|
5
|
+
* - evaluateMigrationTs: loads migration.ts via native Node import, returns descriptors
|
|
6
|
+
*
|
|
7
|
+
* Shared by migration plan (scaffold), migration new (scaffold), and
|
|
8
|
+
* migration verify (evaluate).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import type { OperationDescriptor } from '@prisma-next/framework-components/control';
|
|
13
|
+
import { join, relative, resolve } from 'pathe';
|
|
14
|
+
|
|
15
|
+
const MIGRATION_TS_FILE = 'migration.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for scaffolding a migration.ts file.
|
|
19
|
+
*/
|
|
20
|
+
export interface ScaffoldOptions {
|
|
21
|
+
/** Operation descriptors to serialize as builder calls. */
|
|
22
|
+
readonly descriptors?: readonly OperationDescriptor[];
|
|
23
|
+
/** Absolute path to contract.json — used to derive contract.d.ts import for typed builders. */
|
|
24
|
+
readonly contractJsonPath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function serializeQueryInput(input: unknown): string {
|
|
28
|
+
if (typeof input === 'boolean') return String(input);
|
|
29
|
+
if (typeof input === 'symbol') return 'TODO /* fill in using db.sql.from(...) */';
|
|
30
|
+
if (input === null || input === undefined) return 'null';
|
|
31
|
+
if (Array.isArray(input)) {
|
|
32
|
+
if (input.length === 0) return '[]';
|
|
33
|
+
if (input.every((item) => typeof item === 'symbol'))
|
|
34
|
+
return '[TODO /* fill in using db.sql.from(...) */]';
|
|
35
|
+
return `[${input.map(serializeQueryInput).join(', ')}]`;
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify(input);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function descriptorToBuilderCall(desc: OperationDescriptor): string {
|
|
41
|
+
switch (desc.kind) {
|
|
42
|
+
case 'createTable':
|
|
43
|
+
return `createTable(${JSON.stringify(desc['table'])})`;
|
|
44
|
+
case 'dropTable':
|
|
45
|
+
return `dropTable(${JSON.stringify(desc['table'])})`;
|
|
46
|
+
case 'addColumn': {
|
|
47
|
+
const args = [JSON.stringify(desc['table']), JSON.stringify(desc['column'])];
|
|
48
|
+
if (desc['overrides']) {
|
|
49
|
+
args.push(JSON.stringify(desc['overrides']));
|
|
50
|
+
}
|
|
51
|
+
return `addColumn(${args.join(', ')})`;
|
|
52
|
+
}
|
|
53
|
+
case 'dropColumn':
|
|
54
|
+
return `dropColumn(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['column'])})`;
|
|
55
|
+
case 'alterColumnType': {
|
|
56
|
+
const opts: Record<string, unknown> = {};
|
|
57
|
+
if (desc['using']) opts['using'] = desc['using'];
|
|
58
|
+
if (desc['toType']) opts['toType'] = desc['toType'];
|
|
59
|
+
const hasOpts = Object.keys(opts).length > 0;
|
|
60
|
+
return hasOpts
|
|
61
|
+
? `alterColumnType(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['column'])}, ${JSON.stringify(opts)})`
|
|
62
|
+
: `alterColumnType(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['column'])})`;
|
|
63
|
+
}
|
|
64
|
+
case 'setNotNull':
|
|
65
|
+
return `setNotNull(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['column'])})`;
|
|
66
|
+
case 'dropNotNull':
|
|
67
|
+
return `dropNotNull(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['column'])})`;
|
|
68
|
+
case 'setDefault':
|
|
69
|
+
return `setDefault(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['column'])})`;
|
|
70
|
+
case 'dropDefault':
|
|
71
|
+
return `dropDefault(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['column'])})`;
|
|
72
|
+
case 'addPrimaryKey':
|
|
73
|
+
return `addPrimaryKey(${JSON.stringify(desc['table'])})`;
|
|
74
|
+
case 'addUnique':
|
|
75
|
+
return `addUnique(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['columns'])})`;
|
|
76
|
+
case 'addForeignKey':
|
|
77
|
+
return `addForeignKey(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['columns'])})`;
|
|
78
|
+
case 'dropConstraint':
|
|
79
|
+
return `dropConstraint(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['constraintName'])})`;
|
|
80
|
+
case 'createIndex':
|
|
81
|
+
return `createIndex(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['columns'])})`;
|
|
82
|
+
case 'dropIndex':
|
|
83
|
+
return `dropIndex(${JSON.stringify(desc['table'])}, ${JSON.stringify(desc['indexName'])})`;
|
|
84
|
+
case 'createEnumType':
|
|
85
|
+
return desc['values']
|
|
86
|
+
? `createEnumType(${JSON.stringify(desc['typeName'])}, ${JSON.stringify(desc['values'])})`
|
|
87
|
+
: `createEnumType(${JSON.stringify(desc['typeName'])})`;
|
|
88
|
+
case 'addEnumValues':
|
|
89
|
+
return `addEnumValues(${JSON.stringify(desc['typeName'])}, ${JSON.stringify(desc['values'])})`;
|
|
90
|
+
case 'dropEnumType':
|
|
91
|
+
return `dropEnumType(${JSON.stringify(desc['typeName'])})`;
|
|
92
|
+
case 'renameType':
|
|
93
|
+
return `renameType(${JSON.stringify(desc['fromName'])}, ${JSON.stringify(desc['toName'])})`;
|
|
94
|
+
case 'createDependency':
|
|
95
|
+
return `createDependency(${JSON.stringify(desc['dependencyId'])})`;
|
|
96
|
+
case 'dataTransform':
|
|
97
|
+
return `dataTransform(${JSON.stringify(desc['name'])}, {\n check: ${serializeQueryInput(desc['check'])},\n run: ${serializeQueryInput(desc['run'])},\n })`;
|
|
98
|
+
default:
|
|
99
|
+
throw new Error(`Unknown descriptor kind: ${desc.kind}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Scaffolds a migration.ts file in the given package directory.
|
|
105
|
+
* Serializes operation descriptors as builder calls that the user can edit.
|
|
106
|
+
* On verify, this file is re-evaluated to produce the final ops.
|
|
107
|
+
*/
|
|
108
|
+
export async function scaffoldMigrationTs(
|
|
109
|
+
packageDir: string,
|
|
110
|
+
options: ScaffoldOptions = {},
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const filePath = join(packageDir, MIGRATION_TS_FILE);
|
|
113
|
+
|
|
114
|
+
const descriptors = options.descriptors ?? [];
|
|
115
|
+
const hasDataTransform = descriptors.some((d) => d.kind === 'dataTransform');
|
|
116
|
+
|
|
117
|
+
const lines: string[] = [];
|
|
118
|
+
|
|
119
|
+
if (hasDataTransform && options.contractJsonPath) {
|
|
120
|
+
const relativeContractDts = relative(packageDir, options.contractJsonPath).replace(
|
|
121
|
+
/\.json$/,
|
|
122
|
+
'.d',
|
|
123
|
+
);
|
|
124
|
+
lines.push(`import type { Contract } from "${relativeContractDts}"`);
|
|
125
|
+
lines.push(`import { createBuilders } from "@prisma-next/target-postgres/migration-builders"`);
|
|
126
|
+
lines.push('');
|
|
127
|
+
const importList = [...new Set(descriptors.map((d) => d.kind))];
|
|
128
|
+
importList.push('TODO');
|
|
129
|
+
lines.push(`const { ${importList.join(', ')} } = createBuilders<Contract>()`);
|
|
130
|
+
} else {
|
|
131
|
+
const importList = [...new Set(descriptors.map((d) => d.kind))];
|
|
132
|
+
if (importList.length === 0) {
|
|
133
|
+
importList.push('createTable');
|
|
134
|
+
}
|
|
135
|
+
if (hasDataTransform) {
|
|
136
|
+
importList.push('TODO');
|
|
137
|
+
}
|
|
138
|
+
lines.push(
|
|
139
|
+
`import { ${importList.join(', ')} } from "@prisma-next/target-postgres/migration-builders"`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const calls = descriptors.map((d) => ` ${descriptorToBuilderCall(d)},`).join('\n');
|
|
144
|
+
const body = calls.length > 0 ? `\n${calls}\n` : '';
|
|
145
|
+
|
|
146
|
+
lines.push('');
|
|
147
|
+
lines.push(`export default () => [${body}]`);
|
|
148
|
+
lines.push('');
|
|
149
|
+
|
|
150
|
+
await writeFile(filePath, lines.join('\n'));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Checks whether a migration.ts file exists in the package directory.
|
|
155
|
+
*/
|
|
156
|
+
export async function hasMigrationTs(packageDir: string): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
const s = await stat(join(packageDir, MIGRATION_TS_FILE));
|
|
159
|
+
return s.isFile();
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Evaluates a migration.ts file by loading it via native Node import.
|
|
167
|
+
* Returns the result of calling the default export (expected to be a
|
|
168
|
+
* function returning an array of operation descriptors).
|
|
169
|
+
*
|
|
170
|
+
* Requires Node ≥24 for native TypeScript support.
|
|
171
|
+
*/
|
|
172
|
+
export async function evaluateMigrationTs(packageDir: string): Promise<readonly unknown[]> {
|
|
173
|
+
const filePath = resolve(join(packageDir, MIGRATION_TS_FILE));
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
await stat(filePath);
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error(`migration.ts not found at "${filePath}"`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Use native Node TS import (Node ≥24, stable type stripping)
|
|
182
|
+
const mod = (await import(filePath)) as { default?: unknown };
|
|
183
|
+
|
|
184
|
+
if (typeof mod.default !== 'function') {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`migration.ts must export a default function returning an operation list. Got: ${typeof mod.default}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const result: unknown = mod.default();
|
|
191
|
+
|
|
192
|
+
if (!Array.isArray(result)) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`migration.ts default export must return an array of operations. Got: ${typeof result}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return result;
|
|
199
|
+
}
|