@prisma-next/migration-tools 0.5.0-dev.62 → 0.5.0-dev.63
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/dist/{constants-BQEHsaEx.mjs → constants-B87kJAGj.mjs} +1 -1
- package/dist/{constants-BQEHsaEx.mjs.map → constants-B87kJAGj.mjs.map} +1 -1
- package/dist/{errors-CfmjBeK0.mjs → errors-DQsXvidG.mjs} +22 -2
- package/dist/errors-DQsXvidG.mjs.map +1 -0
- package/dist/exports/constants.mjs +1 -1
- package/dist/exports/errors.d.mts.map +1 -1
- package/dist/exports/errors.mjs +1 -1
- package/dist/exports/graph.d.mts +1 -1
- package/dist/exports/hash.d.mts +3 -3
- package/dist/exports/hash.d.mts.map +1 -1
- package/dist/exports/hash.mjs +1 -1
- package/dist/exports/invariants.d.mts +1 -1
- package/dist/exports/invariants.mjs +2 -2
- package/dist/exports/io.d.mts +40 -5
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +4 -162
- package/dist/exports/metadata.d.mts +1 -1
- package/dist/exports/migration-graph.d.mts +3 -3
- package/dist/exports/migration-graph.d.mts.map +1 -1
- package/dist/exports/migration-graph.mjs +2 -2
- package/dist/exports/migration-graph.mjs.map +1 -1
- package/dist/exports/migration.d.mts +3 -3
- package/dist/exports/migration.d.mts.map +1 -1
- package/dist/exports/migration.mjs +4 -4
- package/dist/exports/package.d.mts +3 -2
- package/dist/exports/refs.mjs +1 -1
- package/dist/exports/spaces.d.mts +447 -0
- package/dist/exports/spaces.d.mts.map +1 -0
- package/dist/exports/spaces.mjs +433 -0
- package/dist/exports/spaces.mjs.map +1 -0
- package/dist/{graph-BHPv-9Gl.d.mts → graph-Czaj8O2q.d.mts} +1 -1
- package/dist/{graph-BHPv-9Gl.d.mts.map → graph-Czaj8O2q.d.mts.map} +1 -1
- package/dist/{hash-BARZdVgW.mjs → hash-G0bAfIGh.mjs} +2 -2
- package/dist/hash-G0bAfIGh.mjs.map +1 -0
- package/dist/{invariants-30VA65sB.mjs → invariants-4Avb_Yhy.mjs} +2 -2
- package/dist/{invariants-30VA65sB.mjs.map → invariants-4Avb_Yhy.mjs.map} +1 -1
- package/dist/io-CDJaWGbt.mjs +207 -0
- package/dist/io-CDJaWGbt.mjs.map +1 -0
- package/dist/metadata-CSjwljJx.d.mts +2 -0
- package/dist/{op-schema-DZKFua46.mjs → op-schema-BiF1ZYqH.mjs} +1 -1
- package/dist/{op-schema-DZKFua46.mjs.map → op-schema-BiF1ZYqH.mjs.map} +1 -1
- package/dist/package-B3Yl6DTr.d.mts +21 -0
- package/dist/package-B3Yl6DTr.d.mts.map +1 -0
- package/package.json +8 -4
- package/src/concatenate-space-apply-inputs.ts +90 -0
- package/src/detect-space-contract-drift.ts +95 -0
- package/src/emit-pinned-space-artefacts.ts +89 -0
- package/src/errors.ts +35 -0
- package/src/exports/io.ts +1 -0
- package/src/exports/package.ts +2 -1
- package/src/exports/spaces.ts +36 -0
- package/src/hash.ts +2 -2
- package/src/io.ts +71 -16
- package/src/metadata.ts +1 -41
- package/src/migration-graph.ts +2 -2
- package/src/package.ts +14 -11
- package/src/plan-all-spaces.ts +80 -0
- package/src/read-pinned-contract-hash.ts +77 -0
- package/src/space-layout.ts +55 -0
- package/src/verify-contract-spaces.ts +276 -0
- package/dist/errors-CfmjBeK0.mjs.map +0 -1
- package/dist/exports/io.mjs.map +0 -1
- package/dist/hash-BARZdVgW.mjs.map +0 -1
- package/dist/metadata-BP1cmU7Z.d.mts +0 -50
- package/dist/metadata-BP1cmU7Z.d.mts.map +0 -1
- package/dist/package-5HCCg0z-.d.mts +0 -21
- package/dist/package-5HCCg0z-.d.mts.map +0 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export {
|
|
2
|
+
concatenateSpaceApplyInputs,
|
|
3
|
+
type SpaceApplyInput,
|
|
4
|
+
} from '../concatenate-space-apply-inputs';
|
|
5
|
+
export {
|
|
6
|
+
type DetectSpaceContractDriftInputs,
|
|
7
|
+
detectSpaceContractDrift,
|
|
8
|
+
type SpaceContractDriftResult,
|
|
9
|
+
} from '../detect-space-contract-drift';
|
|
10
|
+
export {
|
|
11
|
+
emitPinnedSpaceArtefacts,
|
|
12
|
+
type PinnedSpaceArtefactInputs,
|
|
13
|
+
type PinnedSpaceHeadRef,
|
|
14
|
+
} from '../emit-pinned-space-artefacts';
|
|
15
|
+
export {
|
|
16
|
+
planAllSpaces,
|
|
17
|
+
type SpacePlanInput,
|
|
18
|
+
type SpacePlanOutput,
|
|
19
|
+
} from '../plan-all-spaces';
|
|
20
|
+
export { readPinnedContractHash } from '../read-pinned-contract-hash';
|
|
21
|
+
export {
|
|
22
|
+
APP_SPACE_ID,
|
|
23
|
+
assertValidSpaceId,
|
|
24
|
+
isValidSpaceId,
|
|
25
|
+
spaceMigrationDirectory,
|
|
26
|
+
type ValidSpaceId,
|
|
27
|
+
} from '../space-layout';
|
|
28
|
+
export {
|
|
29
|
+
listPinnedSpaceDirectories,
|
|
30
|
+
type SpaceMarkerRecord,
|
|
31
|
+
type SpacePinnedHashRecord,
|
|
32
|
+
type SpaceVerifierViolation,
|
|
33
|
+
type VerifyContractSpacesInputs,
|
|
34
|
+
type VerifyContractSpacesResult,
|
|
35
|
+
verifyContractSpaces,
|
|
36
|
+
} from '../verify-contract-spaces';
|
package/src/hash.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { canonicalizeJson } from './canonicalize-json';
|
|
3
3
|
import type { MigrationMetadata } from './metadata';
|
|
4
|
-
import type { MigrationOps,
|
|
4
|
+
import type { MigrationOps, OnDiskMigrationPackage } from './package';
|
|
5
5
|
|
|
6
6
|
export interface VerifyResult {
|
|
7
7
|
readonly ok: boolean;
|
|
@@ -71,7 +71,7 @@ export function computeMigrationHash(
|
|
|
71
71
|
* not — typically a sign of FS corruption, partial writes, or a post-emit
|
|
72
72
|
* hand edit.
|
|
73
73
|
*/
|
|
74
|
-
export function verifyMigrationHash(pkg:
|
|
74
|
+
export function verifyMigrationHash(pkg: OnDiskMigrationPackage): VerifyResult {
|
|
75
75
|
const computed = computeMigrationHash(pkg.metadata, pkg.ops);
|
|
76
76
|
|
|
77
77
|
if (pkg.metadata.migrationHash === computed) {
|
package/src/io.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { copyFile, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import type {
|
|
3
|
+
MigrationMetadata,
|
|
4
|
+
MigrationPackage,
|
|
5
|
+
} from '@prisma-next/framework-components/control';
|
|
2
6
|
import { type } from 'arktype';
|
|
3
|
-
import { basename, dirname, join } from 'pathe';
|
|
7
|
+
import { basename, dirname, join, resolve } from 'pathe';
|
|
8
|
+
import { canonicalizeJson } from './canonicalize-json';
|
|
4
9
|
import {
|
|
5
10
|
errorDirectoryExists,
|
|
6
11
|
errorInvalidDestName,
|
|
@@ -13,11 +18,10 @@ import {
|
|
|
13
18
|
} from './errors';
|
|
14
19
|
import { verifyMigrationHash } from './hash';
|
|
15
20
|
import { deriveProvidedInvariants } from './invariants';
|
|
16
|
-
import type { MigrationMetadata } from './metadata';
|
|
17
21
|
import { MigrationOpsSchema } from './op-schema';
|
|
18
|
-
import type { MigrationOps,
|
|
22
|
+
import type { MigrationOps, OnDiskMigrationPackage } from './package';
|
|
19
23
|
|
|
20
|
-
const MANIFEST_FILE = 'migration.json';
|
|
24
|
+
export const MANIFEST_FILE = 'migration.json';
|
|
21
25
|
const OPS_FILE = 'ops.json';
|
|
22
26
|
const MAX_SLUG_LENGTH = 64;
|
|
23
27
|
|
|
@@ -74,6 +78,52 @@ export async function writeMigrationPackage(
|
|
|
74
78
|
await writeFile(join(dir, OPS_FILE), JSON.stringify(ops, null, 2), { flag: 'wx' });
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Materialise an in-memory {@link MigrationPackage} to a per-space
|
|
83
|
+
* directory on disk.
|
|
84
|
+
*
|
|
85
|
+
* Writes three files under `<targetDir>/<pkg.dirName>/`:
|
|
86
|
+
*
|
|
87
|
+
* - `migration.json` — the manifest (pretty-printed, matches
|
|
88
|
+
* {@link writeMigrationPackage}'s output for byte-for-byte parity with
|
|
89
|
+
* app-space migrations).
|
|
90
|
+
* - `ops.json` — the operation list (pretty-printed).
|
|
91
|
+
* - `contract.json` — the canonical-JSON serialisation of
|
|
92
|
+
* `metadata.toContract`. This is the per-package post-state contract
|
|
93
|
+
* snapshot; the canonicalisation pass guarantees byte-determinism so
|
|
94
|
+
* re-emitting the same package across machines / runs produces an
|
|
95
|
+
* identical file.
|
|
96
|
+
*
|
|
97
|
+
* Distinct verb from the lower-level {@link writeMigrationPackage}
|
|
98
|
+
* (which takes constituent `(metadata, ops)`): callers reading
|
|
99
|
+
* `materialise…` know they are persisting a struct-typed package
|
|
100
|
+
* including its contract-snapshot side car.
|
|
101
|
+
*
|
|
102
|
+
* Overwrite-idempotent: the per-package directory is cleared before
|
|
103
|
+
* each emit, so re-running against the same `targetDir` produces
|
|
104
|
+
* byte-identical contents and never leaves stale files behind. The
|
|
105
|
+
* spec's "re-emitting the same package across runs / machines produces
|
|
106
|
+
* byte-identical files" guarantee (§ 3) covers both same-dir and
|
|
107
|
+
* fresh-dir re-emits. The lower-level {@link writeMigrationPackage}
|
|
108
|
+
* stays strict because the CLI authoring path (`migration plan` /
|
|
109
|
+
* `migration new`) deliberately refuses to clobber an existing
|
|
110
|
+
* authored migration; this helper is the re-emit path that is
|
|
111
|
+
* supposed to converge on a single canonical on-disk shape.
|
|
112
|
+
*
|
|
113
|
+
* @see specs/framework-mechanism.spec.md § 3 — Emission helper (T1.7).
|
|
114
|
+
*/
|
|
115
|
+
export async function materialiseMigrationPackage(
|
|
116
|
+
targetDir: string,
|
|
117
|
+
pkg: MigrationPackage,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
const dir = join(targetDir, pkg.dirName);
|
|
120
|
+
await rm(dir, { recursive: true, force: true });
|
|
121
|
+
await writeMigrationPackage(dir, pkg.metadata, pkg.ops);
|
|
122
|
+
await writeFile(join(dir, 'contract.json'), `${canonicalizeJson(pkg.metadata.toContract)}\n`, {
|
|
123
|
+
flag: 'wx',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
77
127
|
/**
|
|
78
128
|
* Copy a list of files into `destDir`, optionally renaming each one.
|
|
79
129
|
*
|
|
@@ -109,16 +159,17 @@ export async function writeMigrationOps(dir: string, ops: MigrationOps): Promise
|
|
|
109
159
|
await writeFile(join(dir, OPS_FILE), `${JSON.stringify(ops, null, 2)}\n`);
|
|
110
160
|
}
|
|
111
161
|
|
|
112
|
-
export async function readMigrationPackage(dir: string): Promise<
|
|
113
|
-
const
|
|
114
|
-
const
|
|
162
|
+
export async function readMigrationPackage(dir: string): Promise<OnDiskMigrationPackage> {
|
|
163
|
+
const absoluteDir = resolve(dir);
|
|
164
|
+
const manifestPath = join(absoluteDir, MANIFEST_FILE);
|
|
165
|
+
const opsPath = join(absoluteDir, OPS_FILE);
|
|
115
166
|
|
|
116
167
|
let manifestRaw: string;
|
|
117
168
|
try {
|
|
118
169
|
manifestRaw = await readFile(manifestPath, 'utf-8');
|
|
119
170
|
} catch (error) {
|
|
120
171
|
if (hasErrnoCode(error, 'ENOENT')) {
|
|
121
|
-
throw errorMissingFile(MANIFEST_FILE,
|
|
172
|
+
throw errorMissingFile(MANIFEST_FILE, absoluteDir);
|
|
122
173
|
}
|
|
123
174
|
throw error;
|
|
124
175
|
}
|
|
@@ -128,7 +179,7 @@ export async function readMigrationPackage(dir: string): Promise<MigrationPackag
|
|
|
128
179
|
opsRaw = await readFile(opsPath, 'utf-8');
|
|
129
180
|
} catch (error) {
|
|
130
181
|
if (hasErrnoCode(error, 'ENOENT')) {
|
|
131
|
-
throw errorMissingFile(OPS_FILE,
|
|
182
|
+
throw errorMissingFile(OPS_FILE, absoluteDir);
|
|
132
183
|
}
|
|
133
184
|
throw error;
|
|
134
185
|
}
|
|
@@ -161,16 +212,20 @@ export async function readMigrationPackage(dir: string): Promise<MigrationPackag
|
|
|
161
212
|
);
|
|
162
213
|
}
|
|
163
214
|
|
|
164
|
-
const pkg:
|
|
165
|
-
dirName: basename(
|
|
166
|
-
dirPath:
|
|
215
|
+
const pkg: OnDiskMigrationPackage = {
|
|
216
|
+
dirName: basename(absoluteDir),
|
|
217
|
+
dirPath: absoluteDir,
|
|
167
218
|
metadata,
|
|
168
219
|
ops,
|
|
169
220
|
};
|
|
170
221
|
|
|
171
222
|
const verification = verifyMigrationHash(pkg);
|
|
172
223
|
if (!verification.ok) {
|
|
173
|
-
throw errorMigrationHashMismatch(
|
|
224
|
+
throw errorMigrationHashMismatch(
|
|
225
|
+
absoluteDir,
|
|
226
|
+
verification.storedHash,
|
|
227
|
+
verification.computedHash,
|
|
228
|
+
);
|
|
174
229
|
}
|
|
175
230
|
|
|
176
231
|
return pkg;
|
|
@@ -203,7 +258,7 @@ function validateOps(ops: unknown, filePath: string): asserts ops is MigrationOp
|
|
|
203
258
|
|
|
204
259
|
export async function readMigrationsDir(
|
|
205
260
|
migrationsRoot: string,
|
|
206
|
-
): Promise<readonly
|
|
261
|
+
): Promise<readonly OnDiskMigrationPackage[]> {
|
|
207
262
|
let entries: string[];
|
|
208
263
|
try {
|
|
209
264
|
entries = await readdir(migrationsRoot);
|
|
@@ -214,7 +269,7 @@ export async function readMigrationsDir(
|
|
|
214
269
|
throw error;
|
|
215
270
|
}
|
|
216
271
|
|
|
217
|
-
const packages:
|
|
272
|
+
const packages: OnDiskMigrationPackage[] = [];
|
|
218
273
|
|
|
219
274
|
for (const entry of entries.sort()) {
|
|
220
275
|
const entryPath = join(migrationsRoot, entry);
|
package/src/metadata.ts
CHANGED
|
@@ -1,41 +1 @@
|
|
|
1
|
-
|
|
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 | null;
|
|
27
|
-
readonly to: string;
|
|
28
|
-
readonly fromContract: Contract | null;
|
|
29
|
-
readonly toContract: Contract;
|
|
30
|
-
readonly hints: MigrationHints;
|
|
31
|
-
readonly labels: readonly string[];
|
|
32
|
-
/**
|
|
33
|
-
* Sorted, deduplicated list of `invariantId`s declared by the
|
|
34
|
-
* migration's data-transform ops. Always present; an empty array
|
|
35
|
-
* means the migration has no routing-visible data transforms.
|
|
36
|
-
*/
|
|
37
|
-
readonly providedInvariants: readonly string[];
|
|
38
|
-
readonly authorship?: { readonly author?: string; readonly email?: string };
|
|
39
|
-
readonly signature?: { readonly keyId: string; readonly value: string } | null;
|
|
40
|
-
readonly createdAt: string;
|
|
41
|
-
}
|
|
1
|
+
export type { MigrationHints, MigrationMetadata } from '@prisma-next/framework-components/control';
|
package/src/migration-graph.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from './errors';
|
|
10
10
|
import type { MigrationEdge, MigrationGraph } from './graph';
|
|
11
11
|
import { bfs } from './graph-ops';
|
|
12
|
-
import type {
|
|
12
|
+
import type { OnDiskMigrationPackage } from './package';
|
|
13
13
|
|
|
14
14
|
/** Forward-edge neighbours: edge `e` from `n` visits `e.to` next. */
|
|
15
15
|
function forwardNeighbours(graph: MigrationGraph, node: string) {
|
|
@@ -36,7 +36,7 @@ function appendEdge(map: Map<string, MigrationEdge[]>, key: string, entry: Migra
|
|
|
36
36
|
else map.set(key, [entry]);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function reconstructGraph(packages: readonly
|
|
39
|
+
export function reconstructGraph(packages: readonly OnDiskMigrationPackage[]): MigrationGraph {
|
|
40
40
|
const nodes = new Set<string>();
|
|
41
41
|
const forwardChain = new Map<string, MigrationEdge[]>();
|
|
42
42
|
const reverseChain = new Map<string, MigrationEdge[]>();
|
package/src/package.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
MigrationPackage,
|
|
3
|
+
MigrationPlanOperation,
|
|
4
|
+
} from '@prisma-next/framework-components/control';
|
|
3
5
|
|
|
4
6
|
export type MigrationOps = readonly MigrationPlanOperation[];
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Augmented form of the canonical {@link MigrationPackage} returned by
|
|
10
|
+
* the on-disk readers (`readMigrationPackage`, `readMigrationsDir`).
|
|
11
|
+
* Adds `dirPath` — the absolute path the package was loaded from — so
|
|
12
|
+
* downstream diagnostics can point operators at a concrete directory.
|
|
13
|
+
*
|
|
14
|
+
* Holding an `OnDiskMigrationPackage` value implies the loader verified
|
|
15
|
+
* the package's integrity (hash recomputation against the stored
|
|
16
|
+
* `migrationHash`); the canonical structural shape carries no such
|
|
17
|
+
* guarantee on its own.
|
|
12
18
|
*/
|
|
13
|
-
export interface MigrationPackage {
|
|
14
|
-
readonly dirName: string;
|
|
19
|
+
export interface OnDiskMigrationPackage extends MigrationPackage {
|
|
15
20
|
readonly dirPath: string;
|
|
16
|
-
readonly metadata: MigrationMetadata;
|
|
17
|
-
readonly ops: MigrationOps;
|
|
18
21
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { errorDuplicateSpaceId } from './errors';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-space input for {@link planAllSpaces}. One entry per loaded
|
|
5
|
+
* contract space (the application's `'app'` plus each extension that
|
|
6
|
+
* exposes a `contractSpace`).
|
|
7
|
+
*
|
|
8
|
+
* - `priorContract` is `null` for a space that has never been emitted
|
|
9
|
+
* (no `migrations/<space-id>/contract.json` on disk yet); otherwise it
|
|
10
|
+
* is the canonical contract value pinned for that space.
|
|
11
|
+
* - `newContract` is the canonical contract value the planner is about
|
|
12
|
+
* to emit for that space — for app-space, the just-emitted root
|
|
13
|
+
* `contract.json`; for an extension space, the descriptor's
|
|
14
|
+
* `contractSpace.contractJson`.
|
|
15
|
+
*
|
|
16
|
+
* @see specs/framework-mechanism.spec.md § 3.
|
|
17
|
+
*/
|
|
18
|
+
export interface SpacePlanInput<TContract> {
|
|
19
|
+
readonly spaceId: string;
|
|
20
|
+
readonly priorContract: TContract | null;
|
|
21
|
+
readonly newContract: TContract;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SpacePlanOutput<TPackage> {
|
|
25
|
+
readonly spaceId: string;
|
|
26
|
+
readonly migrationPackages: readonly TPackage[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Iterate the per-space planner across a set of loaded contract spaces
|
|
31
|
+
* and return a deterministic shape regardless of declaration order.
|
|
32
|
+
*
|
|
33
|
+
* Behaviour:
|
|
34
|
+
*
|
|
35
|
+
* - The output is sorted alphabetically by `spaceId` (AM3). Two callers
|
|
36
|
+
* passing the same set of inputs in different orders observe
|
|
37
|
+
* byte-identical outputs.
|
|
38
|
+
* - The per-space planner (`planSpace`) is called exactly once per
|
|
39
|
+
* input, in alphabetical-by-spaceId order. Its return value is
|
|
40
|
+
* attached to the corresponding output entry verbatim.
|
|
41
|
+
* - Duplicate `spaceId`s in the input array throw
|
|
42
|
+
* `MIGRATION.DUPLICATE_SPACE_ID` before any `planSpace` call runs,
|
|
43
|
+
* keeping the planner pure when the input is malformed.
|
|
44
|
+
*
|
|
45
|
+
* The signature is generic over `TContract` and `TPackage` because the
|
|
46
|
+
* shape is framework-neutral (SQL family today, Mongo family
|
|
47
|
+
* eventually). Callers wire in whatever contract value and migration
|
|
48
|
+
* package shape their family already speaks.
|
|
49
|
+
*
|
|
50
|
+
* Synchronous: the underlying per-space planner (target's
|
|
51
|
+
* `MigrationPlanner.plan(...)`) is synchronous; callers that need to
|
|
52
|
+
* resolve async I/O (e.g. reading pinned `contract.json` from disk)
|
|
53
|
+
* resolve it before calling `planAllSpaces` and pass the materialised
|
|
54
|
+
* inputs through.
|
|
55
|
+
*
|
|
56
|
+
* @see specs/framework-mechanism.spec.md § 3 — Per-space planner (T1.3).
|
|
57
|
+
*/
|
|
58
|
+
export function planAllSpaces<TContract, TPackage>(
|
|
59
|
+
inputs: readonly SpacePlanInput<TContract>[],
|
|
60
|
+
planSpace: (input: SpacePlanInput<TContract>) => readonly TPackage[],
|
|
61
|
+
): readonly SpacePlanOutput<TPackage>[] {
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
for (const input of inputs) {
|
|
64
|
+
if (seen.has(input.spaceId)) {
|
|
65
|
+
throw errorDuplicateSpaceId(input.spaceId);
|
|
66
|
+
}
|
|
67
|
+
seen.add(input.spaceId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sorted = [...inputs].sort((a, b) => {
|
|
71
|
+
if (a.spaceId < b.spaceId) return -1;
|
|
72
|
+
if (a.spaceId > b.spaceId) return 1;
|
|
73
|
+
return 0;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return sorted.map((input) => ({
|
|
77
|
+
spaceId: input.spaceId,
|
|
78
|
+
migrationPackages: planSpace(input),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'pathe';
|
|
3
|
+
import { errorInvalidJson, errorInvalidRefFile, errorPinnedArtefactsAppSpace } from './errors';
|
|
4
|
+
import { APP_SPACE_ID, assertValidSpaceId } from './space-layout';
|
|
5
|
+
|
|
6
|
+
function hasErrnoCode(error: unknown, code: string): boolean {
|
|
7
|
+
return error instanceof Error && (error as { code?: string }).code === code;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read the pinned head hash for an extension space.
|
|
12
|
+
*
|
|
13
|
+
* Returns the `hash` field of `<projectMigrationsDir>/<spaceId>/refs/head.json`
|
|
14
|
+
* — i.e. the canonical contract hash the framework wrote on the last
|
|
15
|
+
* `migrate` for this space. Returns `null` when the file does not exist
|
|
16
|
+
* (or the migrations directory is missing entirely), which is the
|
|
17
|
+
* "first emit" signal {@link import('./detect-space-contract-drift').detectSpaceContractDrift}
|
|
18
|
+
* uses to distinguish a brand-new extension from drift.
|
|
19
|
+
*
|
|
20
|
+
* Pure I/O (read + parse). The "comparison hash" is stored on disk by
|
|
21
|
+
* {@link import('./emit-pinned-space-artefacts').emitPinnedSpaceArtefacts}
|
|
22
|
+
* via the descriptor's `headRef.hash`, so reading it back here matches
|
|
23
|
+
* the descriptor's hashing pipeline by construction — neither side
|
|
24
|
+
* recomputes anything.
|
|
25
|
+
*
|
|
26
|
+
* Validation:
|
|
27
|
+
*
|
|
28
|
+
* - Rejects the app space — pinned head refs are an extension-space
|
|
29
|
+
* concept; the app space's contract-of-record lives at the project
|
|
30
|
+
* root, not under `migrations/`.
|
|
31
|
+
* - Validates the space id against the same `[a-z][a-z0-9_-]{0,63}`
|
|
32
|
+
* pattern as the rest of the per-space helpers.
|
|
33
|
+
* - Surfaces `MIGRATION.INVALID_JSON` / `MIGRATION.INVALID_REF_FILE`
|
|
34
|
+
* on a corrupt `refs/head.json` so callers can distinguish "no
|
|
35
|
+
* pinned file" (returns `null`) from "pinned file but unreadable"
|
|
36
|
+
* (throws).
|
|
37
|
+
*
|
|
38
|
+
* @see specs/framework-mechanism.spec.md § 3 — Drift detection (T1.9).
|
|
39
|
+
*/
|
|
40
|
+
export async function readPinnedContractHash(
|
|
41
|
+
projectMigrationsDir: string,
|
|
42
|
+
spaceId: string,
|
|
43
|
+
): Promise<string | null> {
|
|
44
|
+
if (spaceId === APP_SPACE_ID) {
|
|
45
|
+
throw errorPinnedArtefactsAppSpace();
|
|
46
|
+
}
|
|
47
|
+
assertValidSpaceId(spaceId);
|
|
48
|
+
|
|
49
|
+
const filePath = join(projectMigrationsDir, spaceId, 'refs', 'head.json');
|
|
50
|
+
|
|
51
|
+
let raw: string;
|
|
52
|
+
try {
|
|
53
|
+
raw = await readFile(filePath, 'utf-8');
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (hasErrnoCode(error, 'ENOENT')) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let parsed: unknown;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
throw errorInvalidJson(filePath, e instanceof Error ? e.message : String(e));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
typeof parsed !== 'object' ||
|
|
70
|
+
parsed === null ||
|
|
71
|
+
typeof (parsed as { hash?: unknown }).hash !== 'string'
|
|
72
|
+
) {
|
|
73
|
+
throw errorInvalidRefFile(filePath, 'expected an object with a string `hash` field');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (parsed as { hash: string }).hash;
|
|
77
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { APP_SPACE_ID } from '@prisma-next/framework-components/control';
|
|
2
|
+
import { join } from 'pathe';
|
|
3
|
+
import { errorInvalidSpaceId } from './errors';
|
|
4
|
+
|
|
5
|
+
export { APP_SPACE_ID };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Branded string carrying a compile-time guarantee that the value has
|
|
9
|
+
* been validated by {@link assertValidSpaceId}. Downstream filesystem
|
|
10
|
+
* helpers (e.g. {@link spaceMigrationDirectory}) accept this type to
|
|
11
|
+
* make "validated" tracking visible at the type level rather than
|
|
12
|
+
* relying purely on a runtime check.
|
|
13
|
+
*/
|
|
14
|
+
export type ValidSpaceId = string & { readonly __brand: 'ValidSpaceId' };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pattern a contract-space identifier must match. The constraint is
|
|
18
|
+
* filesystem-friendly: lowercase letters / digits / hyphen / underscore,
|
|
19
|
+
* starts with a letter, max 64 characters.
|
|
20
|
+
*
|
|
21
|
+
* @see specs/framework-mechanism.spec.md § 3.
|
|
22
|
+
*/
|
|
23
|
+
const SPACE_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
24
|
+
|
|
25
|
+
export function isValidSpaceId(spaceId: string): spaceId is ValidSpaceId {
|
|
26
|
+
return SPACE_ID_PATTERN.test(spaceId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertValidSpaceId(spaceId: string): asserts spaceId is ValidSpaceId {
|
|
30
|
+
if (!isValidSpaceId(spaceId)) {
|
|
31
|
+
throw errorInvalidSpaceId(spaceId);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the migrations subdirectory for a given contract space.
|
|
37
|
+
*
|
|
38
|
+
* - **App space** (`spaceId === APP_SPACE_ID`) keeps today's layout: the
|
|
39
|
+
* project's `migrations/` directory is the migrations directory, no
|
|
40
|
+
* subdirectory.
|
|
41
|
+
* - **Extension space** lands under `<projectMigrationsDir>/<spaceId>/`.
|
|
42
|
+
* The space id is validated against {@link SPACE_ID_PATTERN} because
|
|
43
|
+
* it becomes a filesystem directory name verbatim.
|
|
44
|
+
*
|
|
45
|
+
* `projectMigrationsDir` is the project's top-level `migrations/`
|
|
46
|
+
* directory; the helper does not assume anything about its absolute /
|
|
47
|
+
* relative shape and is symmetric with `pathe.join`.
|
|
48
|
+
*/
|
|
49
|
+
export function spaceMigrationDirectory(projectMigrationsDir: string, spaceId: string): string {
|
|
50
|
+
if (spaceId === APP_SPACE_ID) {
|
|
51
|
+
return projectMigrationsDir;
|
|
52
|
+
}
|
|
53
|
+
assertValidSpaceId(spaceId);
|
|
54
|
+
return join(projectMigrationsDir, spaceId);
|
|
55
|
+
}
|