@prisma-next/migration-tools 0.5.0-dev.61 → 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.
Files changed (67) hide show
  1. package/dist/{constants-BQEHsaEx.mjs → constants-B87kJAGj.mjs} +1 -1
  2. package/dist/{constants-BQEHsaEx.mjs.map → constants-B87kJAGj.mjs.map} +1 -1
  3. package/dist/{errors-CfmjBeK0.mjs → errors-DQsXvidG.mjs} +22 -2
  4. package/dist/errors-DQsXvidG.mjs.map +1 -0
  5. package/dist/exports/constants.mjs +1 -1
  6. package/dist/exports/errors.d.mts.map +1 -1
  7. package/dist/exports/errors.mjs +1 -1
  8. package/dist/exports/graph.d.mts +1 -1
  9. package/dist/exports/hash.d.mts +3 -3
  10. package/dist/exports/hash.d.mts.map +1 -1
  11. package/dist/exports/hash.mjs +1 -1
  12. package/dist/exports/invariants.d.mts +1 -1
  13. package/dist/exports/invariants.mjs +2 -2
  14. package/dist/exports/io.d.mts +40 -5
  15. package/dist/exports/io.d.mts.map +1 -1
  16. package/dist/exports/io.mjs +4 -162
  17. package/dist/exports/metadata.d.mts +1 -1
  18. package/dist/exports/migration-graph.d.mts +3 -3
  19. package/dist/exports/migration-graph.d.mts.map +1 -1
  20. package/dist/exports/migration-graph.mjs +2 -2
  21. package/dist/exports/migration-graph.mjs.map +1 -1
  22. package/dist/exports/migration.d.mts +3 -3
  23. package/dist/exports/migration.d.mts.map +1 -1
  24. package/dist/exports/migration.mjs +4 -4
  25. package/dist/exports/package.d.mts +3 -2
  26. package/dist/exports/refs.mjs +1 -1
  27. package/dist/exports/spaces.d.mts +447 -0
  28. package/dist/exports/spaces.d.mts.map +1 -0
  29. package/dist/exports/spaces.mjs +433 -0
  30. package/dist/exports/spaces.mjs.map +1 -0
  31. package/dist/{graph-BHPv-9Gl.d.mts → graph-Czaj8O2q.d.mts} +1 -1
  32. package/dist/{graph-BHPv-9Gl.d.mts.map → graph-Czaj8O2q.d.mts.map} +1 -1
  33. package/dist/{hash-BARZdVgW.mjs → hash-G0bAfIGh.mjs} +2 -2
  34. package/dist/hash-G0bAfIGh.mjs.map +1 -0
  35. package/dist/{invariants-30VA65sB.mjs → invariants-4Avb_Yhy.mjs} +2 -2
  36. package/dist/{invariants-30VA65sB.mjs.map → invariants-4Avb_Yhy.mjs.map} +1 -1
  37. package/dist/io-CDJaWGbt.mjs +207 -0
  38. package/dist/io-CDJaWGbt.mjs.map +1 -0
  39. package/dist/metadata-CSjwljJx.d.mts +2 -0
  40. package/dist/{op-schema-DZKFua46.mjs → op-schema-BiF1ZYqH.mjs} +1 -1
  41. package/dist/{op-schema-DZKFua46.mjs.map → op-schema-BiF1ZYqH.mjs.map} +1 -1
  42. package/dist/package-B3Yl6DTr.d.mts +21 -0
  43. package/dist/package-B3Yl6DTr.d.mts.map +1 -0
  44. package/package.json +8 -4
  45. package/src/concatenate-space-apply-inputs.ts +90 -0
  46. package/src/detect-space-contract-drift.ts +95 -0
  47. package/src/emit-pinned-space-artefacts.ts +89 -0
  48. package/src/errors.ts +35 -0
  49. package/src/exports/io.ts +1 -0
  50. package/src/exports/package.ts +2 -1
  51. package/src/exports/spaces.ts +36 -0
  52. package/src/hash.ts +2 -2
  53. package/src/io.ts +71 -16
  54. package/src/metadata.ts +1 -41
  55. package/src/migration-graph.ts +2 -2
  56. package/src/package.ts +14 -11
  57. package/src/plan-all-spaces.ts +80 -0
  58. package/src/read-pinned-contract-hash.ts +77 -0
  59. package/src/space-layout.ts +55 -0
  60. package/src/verify-contract-spaces.ts +276 -0
  61. package/dist/errors-CfmjBeK0.mjs.map +0 -1
  62. package/dist/exports/io.mjs.map +0 -1
  63. package/dist/hash-BARZdVgW.mjs.map +0 -1
  64. package/dist/metadata-BP1cmU7Z.d.mts +0 -50
  65. package/dist/metadata-BP1cmU7Z.d.mts.map +0 -1
  66. package/dist/package-5HCCg0z-.d.mts +0 -21
  67. 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, MigrationPackage } from './package';
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: MigrationPackage): VerifyResult {
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, MigrationPackage } from './package';
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<MigrationPackage> {
113
- const manifestPath = join(dir, MANIFEST_FILE);
114
- const opsPath = join(dir, OPS_FILE);
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, dir);
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, dir);
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: MigrationPackage = {
165
- dirName: basename(dir),
166
- dirPath: dir,
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(dir, verification.storedHash, verification.computedHash);
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 MigrationPackage[]> {
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: MigrationPackage[] = [];
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
- 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 | 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';
@@ -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 { MigrationPackage } from './package';
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 MigrationPackage[]): MigrationGraph {
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 { MigrationPlanOperation } from '@prisma-next/framework-components/control';
2
- import type { MigrationMetadata } from './metadata';
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
- * 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.
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
+ }