@prisma-next/migration-tools 0.5.0-dev.66 → 0.5.0-dev.68

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 (98) hide show
  1. package/dist/{constants-B87kJAGj.mjs → constants-DWV9_o2Z.mjs} +2 -2
  2. package/dist/{constants-B87kJAGj.mjs.map → constants-DWV9_o2Z.mjs.map} +1 -1
  3. package/dist/{errors-DQsXvidG.mjs → errors-EPL_9p9f.mjs} +13 -8
  4. package/dist/errors-EPL_9p9f.mjs.map +1 -0
  5. package/dist/exports/aggregate.d.mts +534 -0
  6. package/dist/exports/aggregate.d.mts.map +1 -0
  7. package/dist/exports/aggregate.mjs +598 -0
  8. package/dist/exports/aggregate.mjs.map +1 -0
  9. package/dist/exports/constants.d.mts.map +1 -1
  10. package/dist/exports/constants.mjs +2 -3
  11. package/dist/exports/errors.d.mts +6 -1
  12. package/dist/exports/errors.d.mts.map +1 -1
  13. package/dist/exports/errors.mjs +2 -3
  14. package/dist/exports/graph.d.mts +1 -1
  15. package/dist/exports/graph.mjs +1 -1
  16. package/dist/exports/hash.d.mts +2 -2
  17. package/dist/exports/hash.d.mts.map +1 -1
  18. package/dist/exports/hash.mjs +2 -3
  19. package/dist/exports/invariants.d.mts +14 -4
  20. package/dist/exports/invariants.d.mts.map +1 -1
  21. package/dist/exports/invariants.mjs +2 -4
  22. package/dist/exports/io.d.mts +26 -2
  23. package/dist/exports/io.d.mts.map +1 -1
  24. package/dist/exports/io.mjs +2 -5
  25. package/dist/exports/metadata.d.mts +1 -1
  26. package/dist/exports/metadata.mjs +1 -1
  27. package/dist/exports/migration-graph.d.mts +2 -2
  28. package/dist/exports/migration-graph.d.mts.map +1 -1
  29. package/dist/exports/migration-graph.mjs +1 -525
  30. package/dist/exports/migration-ts.d.mts.map +1 -1
  31. package/dist/exports/migration-ts.mjs +1 -3
  32. package/dist/exports/migration-ts.mjs.map +1 -1
  33. package/dist/exports/migration.d.mts +1 -1
  34. package/dist/exports/migration.d.mts.map +1 -1
  35. package/dist/exports/migration.mjs +5 -6
  36. package/dist/exports/migration.mjs.map +1 -1
  37. package/dist/exports/package.d.mts +1 -1
  38. package/dist/exports/package.mjs +1 -1
  39. package/dist/exports/refs.d.mts.map +1 -1
  40. package/dist/exports/refs.mjs +2 -3
  41. package/dist/exports/refs.mjs.map +1 -1
  42. package/dist/exports/spaces.d.mts +341 -238
  43. package/dist/exports/spaces.d.mts.map +1 -1
  44. package/dist/exports/spaces.mjs +138 -348
  45. package/dist/exports/spaces.mjs.map +1 -1
  46. package/dist/{graph-Czaj8O2q.d.mts → graph-HMWAldoR.d.mts} +1 -1
  47. package/dist/graph-HMWAldoR.d.mts.map +1 -0
  48. package/dist/{hash-G0bAfIGh.mjs → hash-By50zM_E.mjs} +2 -4
  49. package/dist/hash-By50zM_E.mjs.map +1 -0
  50. package/dist/{invariants-4Avb_Yhy.mjs → invariants-Duc8f9NM.mjs} +17 -7
  51. package/dist/invariants-Duc8f9NM.mjs.map +1 -0
  52. package/dist/{io-CDJaWGbt.mjs → io-D13dLvUh.mjs} +46 -14
  53. package/dist/io-D13dLvUh.mjs.map +1 -0
  54. package/dist/migration-graph-DGNnKDY5.mjs +523 -0
  55. package/dist/migration-graph-DGNnKDY5.mjs.map +1 -0
  56. package/dist/{op-schema-BiF1ZYqH.mjs → op-schema-D5qkXfEf.mjs} +2 -3
  57. package/dist/{op-schema-BiF1ZYqH.mjs.map → op-schema-D5qkXfEf.mjs.map} +1 -1
  58. package/dist/{package-B3Yl6DTr.d.mts → package-BjiZ7KDy.d.mts} +1 -1
  59. package/dist/package-BjiZ7KDy.d.mts.map +1 -0
  60. package/dist/read-contract-space-contract-C3-1eyaI.mjs +298 -0
  61. package/dist/read-contract-space-contract-C3-1eyaI.mjs.map +1 -0
  62. package/package.json +13 -9
  63. package/src/aggregate/loader.ts +409 -0
  64. package/src/aggregate/marker-types.ts +16 -0
  65. package/src/aggregate/planner-types.ts +137 -0
  66. package/src/aggregate/planner.ts +158 -0
  67. package/src/aggregate/project-schema-to-space.ts +64 -0
  68. package/src/aggregate/strategies/graph-walk.ts +92 -0
  69. package/src/aggregate/strategies/synth.ts +122 -0
  70. package/src/aggregate/types.ts +89 -0
  71. package/src/aggregate/verifier.ts +230 -0
  72. package/src/assert-descriptor-self-consistency.ts +70 -0
  73. package/src/compute-extension-space-apply-path.ts +152 -0
  74. package/src/concatenate-space-apply-inputs.ts +2 -2
  75. package/src/detect-space-contract-drift.ts +22 -26
  76. package/src/{emit-pinned-space-artefacts.ts → emit-contract-space-artefacts.ts} +14 -33
  77. package/src/errors.ts +11 -5
  78. package/src/exports/aggregate.ts +37 -0
  79. package/src/exports/errors.ts +1 -0
  80. package/src/exports/io.ts +1 -0
  81. package/src/exports/spaces.ts +23 -10
  82. package/src/gather-disk-contract-space-state.ts +62 -0
  83. package/src/invariants.ts +14 -3
  84. package/src/io.ts +42 -0
  85. package/src/plan-all-spaces.ts +3 -7
  86. package/src/read-contract-space-contract.ts +44 -0
  87. package/src/read-contract-space-head-ref.ts +63 -0
  88. package/src/space-layout.ts +4 -11
  89. package/src/verify-contract-spaces.ts +45 -49
  90. package/dist/errors-DQsXvidG.mjs.map +0 -1
  91. package/dist/exports/migration-graph.mjs.map +0 -1
  92. package/dist/graph-Czaj8O2q.d.mts.map +0 -1
  93. package/dist/hash-G0bAfIGh.mjs.map +0 -1
  94. package/dist/invariants-4Avb_Yhy.mjs.map +0 -1
  95. package/dist/io-CDJaWGbt.mjs.map +0 -1
  96. package/dist/package-B3Yl6DTr.d.mts.map +0 -1
  97. package/src/read-pinned-contract-hash.ts +0 -77
  98. /package/dist/{metadata-CSjwljJx.d.mts → metadata-BnLFiI6B.d.mts} +0 -0
package/src/errors.ts CHANGED
@@ -160,13 +160,19 @@ export function errorInvalidSpaceId(spaceId: string): MigrationToolsError {
160
160
  );
161
161
  }
162
162
 
163
- export function errorPinnedArtefactsAppSpace(): MigrationToolsError {
163
+ export function errorDescriptorHeadHashMismatch(args: {
164
+ readonly extensionId: string;
165
+ readonly recomputedHash: string;
166
+ readonly headRefHash: string;
167
+ }): MigrationToolsError {
168
+ const { extensionId, recomputedHash, headRefHash } = args;
164
169
  return new MigrationToolsError(
165
- 'MIGRATION.PINNED_ARTEFACTS_APP_SPACE',
166
- 'Pinned per-space artefacts do not apply to the app space',
170
+ 'MIGRATION.DESCRIPTOR_HEAD_HASH_MISMATCH',
171
+ "Extension descriptor's headRef.hash does not match its contractJson",
167
172
  {
168
- why: "Pinned `contract.json`/`contract.d.ts`/`refs/head.json` files only exist for extension spaces under `migrations/<space-id>/`. The app space's canonical contract lives at the project root (`contract.json`)`emitPinnedSpaceArtefacts` is the wrong helper for it.",
169
- fix: 'Pass an extension space id, or use the app-space contract emit pipeline for the project-root `contract.json` / `contract.d.ts`.',
173
+ why: `Extension "${extensionId}" publishes a \`contractSpace\` whose \`headRef.hash\` (${headRefHash}) does not match the canonical hash recomputed from \`contractSpace.contractJson\` (${recomputedHash}). This means the extension descriptor was published with stale \`headRef.hash\`typically because the contract was bumped without rerunning the extension's emit pipeline.`,
174
+ fix: 'Re-run the extension authoring pipeline so `contractJson.storage.storageHash` and `headRef.hash` agree, then republish the extension. If you are the extension author and you intentionally bumped `contractJson`, recompute and update `headRef.hash` (and refresh any on-disk migration metadata that derives from it).',
175
+ details: { extensionId, recomputedHash, headRefHash },
170
176
  },
171
177
  );
172
178
  }
@@ -0,0 +1,37 @@
1
+ export {
2
+ type AggregateContractHasher,
3
+ type DeclaredExtensionEntry,
4
+ type LayoutViolation,
5
+ type LoadAggregateError,
6
+ type LoadAggregateInput,
7
+ type LoadAggregateOutput,
8
+ loadContractSpaceAggregate,
9
+ } from '../aggregate/loader';
10
+ export type { ContractMarkerRecordLike } from '../aggregate/marker-types';
11
+ export {
12
+ type AggregateCurrentDBState,
13
+ type AggregatePerSpacePlan,
14
+ type AggregatePlannerError,
15
+ type AggregatePlannerInput,
16
+ type AggregatePlannerOutput,
17
+ type AggregatePlannerSuccess,
18
+ type CallerPolicy,
19
+ planAggregate,
20
+ } from '../aggregate/planner';
21
+ export { projectSchemaToSpace } from '../aggregate/project-schema-to-space';
22
+ export type {
23
+ ContractSpaceAggregate,
24
+ ContractSpaceMember,
25
+ HydratedMigrationGraph,
26
+ } from '../aggregate/types';
27
+ export {
28
+ type AggregateVerifierError,
29
+ type AggregateVerifierInput,
30
+ type AggregateVerifierOutput,
31
+ type AggregateVerifierSuccess,
32
+ type MarkerCheckResult,
33
+ type MarkerCheckSection,
34
+ type OrphanElement,
35
+ type SchemaCheckSection,
36
+ verifyAggregate,
37
+ } from '../aggregate/verifier';
@@ -1,4 +1,5 @@
1
1
  export {
2
+ errorDescriptorHeadHashMismatch,
2
3
  errorInvalidJson,
3
4
  errorNoInvariantPath,
4
5
  errorUnknownInvariant,
package/src/exports/io.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  copyFilesWithRename,
3
3
  formatMigrationDirName,
4
+ materialiseExtensionMigrationPackageIfMissing,
4
5
  materialiseMigrationPackage,
5
6
  readMigrationPackage,
6
7
  readMigrationsDir,
@@ -1,23 +1,36 @@
1
1
  export {
2
- concatenateSpaceApplyInputs,
3
- type SpaceApplyInput,
4
- } from '../concatenate-space-apply-inputs';
2
+ assertDescriptorSelfConsistency,
3
+ type DescriptorSelfConsistencyInputs,
4
+ } from '../assert-descriptor-self-consistency';
5
+ export {
6
+ type ComputeExtensionSpaceApplyPathInputs,
7
+ computeExtensionSpaceApplyPath,
8
+ type ExtensionSpaceApplyPathOutcome,
9
+ } from '../compute-extension-space-apply-path';
10
+ export type { SpaceApplyInput } from '../concatenate-space-apply-inputs';
5
11
  export {
6
12
  type DetectSpaceContractDriftInputs,
7
13
  detectSpaceContractDrift,
8
14
  type SpaceContractDriftResult,
9
15
  } from '../detect-space-contract-drift';
10
16
  export {
11
- emitPinnedSpaceArtefacts,
12
- type PinnedSpaceArtefactInputs,
13
- type PinnedSpaceHeadRef,
14
- } from '../emit-pinned-space-artefacts';
17
+ type ContractSpaceArtefactInputs,
18
+ emitContractSpaceArtefacts,
19
+ } from '../emit-contract-space-artefacts';
20
+ export {
21
+ type DiskContractSpaceState,
22
+ gatherDiskContractSpaceState,
23
+ } from '../gather-disk-contract-space-state';
15
24
  export {
16
25
  planAllSpaces,
17
26
  type SpacePlanInput,
18
27
  type SpacePlanOutput,
19
28
  } from '../plan-all-spaces';
20
- export { readPinnedContractHash } from '../read-pinned-contract-hash';
29
+ export { readContractSpaceContract } from '../read-contract-space-contract';
30
+ export {
31
+ type ContractSpaceHeadRef,
32
+ readContractSpaceHeadRef,
33
+ } from '../read-contract-space-head-ref';
21
34
  export {
22
35
  APP_SPACE_ID,
23
36
  assertValidSpaceId,
@@ -26,9 +39,9 @@ export {
26
39
  type ValidSpaceId,
27
40
  } from '../space-layout';
28
41
  export {
29
- listPinnedSpaceDirectories,
42
+ type ContractSpaceHeadRecord,
43
+ listContractSpaceDirectories,
30
44
  type SpaceMarkerRecord,
31
- type SpacePinnedHashRecord,
32
45
  type SpaceVerifierViolation,
33
46
  type VerifyContractSpacesInputs,
34
47
  type VerifyContractSpacesResult,
@@ -0,0 +1,62 @@
1
+ import { readContractSpaceHeadRef } from './read-contract-space-head-ref';
2
+ import { APP_SPACE_ID } from './space-layout';
3
+ import {
4
+ type ContractSpaceHeadRecord,
5
+ listContractSpaceDirectories,
6
+ } from './verify-contract-spaces';
7
+
8
+ /**
9
+ * Disk-side inputs to {@link import('./verify-contract-spaces').verifyContractSpaces}
10
+ * — gathered without touching the live database. The caller composes
11
+ * this with the marker rows it reads from the runtime to invoke the
12
+ * verifier.
13
+ */
14
+ export interface DiskContractSpaceState {
15
+ /** Contract-space directory names observed under `<projectMigrationsDir>/`. */
16
+ readonly spaceDirsOnDisk: readonly string[];
17
+ /** Head-ref `(hash, invariants)` per extension space. */
18
+ readonly headRefsBySpace: ReadonlyMap<string, ContractSpaceHeadRecord>;
19
+ }
20
+
21
+ /**
22
+ * Read the on-disk state the per-space verifier needs:
23
+ *
24
+ * - The list of contract-space directories under
25
+ * `<projectMigrationsDir>/` (via
26
+ * {@link import('./verify-contract-spaces').listContractSpaceDirectories}).
27
+ * - The on-disk head ref `(hash, invariants)` for each declared extension space
28
+ * (via {@link readContractSpaceHeadRef}; missing on-disk artefacts are simply
29
+ * omitted — the verifier reports them as `declaredButUnmigrated`).
30
+ *
31
+ * Synchronous in spirit but async due to filesystem reads. Reads only
32
+ * the user's repo. **Does not import any extension descriptor module.**
33
+ *
34
+ * Composition convention: pure target-agnostic primitive in
35
+ * `1-framework`; the SQL family (and any future target family) wires
36
+ * it into its `dbInit` / `verify` flows alongside its own marker-row
37
+ * read before invoking `verifyContractSpaces`.
38
+ */
39
+ export async function gatherDiskContractSpaceState(args: {
40
+ readonly projectMigrationsDir: string;
41
+ /**
42
+ * Set of space ids the project declares: `'app'` plus each entry in
43
+ * `extensionPacks` whose descriptor exposes a `contractSpace`. The
44
+ * helper reads on-disk head data only for the extension members.
45
+ */
46
+ readonly loadedSpaceIds: ReadonlySet<string>;
47
+ }): Promise<DiskContractSpaceState> {
48
+ const { projectMigrationsDir, loadedSpaceIds } = args;
49
+
50
+ const spaceDirsOnDisk = await listContractSpaceDirectories(projectMigrationsDir);
51
+
52
+ const headRefsBySpace = new Map<string, ContractSpaceHeadRecord>();
53
+ for (const spaceId of loadedSpaceIds) {
54
+ if (spaceId === APP_SPACE_ID) continue;
55
+ const head = await readContractSpaceHeadRef(projectMigrationsDir, spaceId);
56
+ if (head !== null) {
57
+ headRefsBySpace.set(spaceId, head);
58
+ }
59
+ }
60
+
61
+ return { spaceDirsOnDisk, headRefsBySpace };
62
+ }
package/src/invariants.ts CHANGED
@@ -16,8 +16,19 @@ export function validateInvariantId(invariantId: string): boolean {
16
16
  /**
17
17
  * Walk a migration's operations and produce its `providedInvariants`
18
18
  * aggregate: the sorted, deduplicated list of `invariantId`s declared
19
- * by data-transform ops. Ops without `operationClass === 'data'` are
20
- * skipped; data ops without an `invariantId` are skipped.
19
+ * by ops in the migration. Ops without an `invariantId` are skipped.
20
+ *
21
+ * Both `data`-class ops (data-transforms, e.g. backfills) and
22
+ * `additive`-class opaque DDL (e.g. cipherstash's vendored EQL bundle
23
+ * via `installEqlBundleOp`) may declare invariantIds: the
24
+ * `operationClass` axis classifies *policy gating* (which kinds of ops
25
+ * a `db init` / `db update` policy permits), while `invariantId`
26
+ * classifies *marker bookkeeping* (which named bundles of work a
27
+ * future regeneration knows to skip). The two concerns are
28
+ * intentionally orthogonal — an extension can ship additive
29
+ * non-IR-derivable DDL (the only way the planner can know the bundle
30
+ * is already applied is via the invariantId on the marker) without
31
+ * needing to mis-classify it as `data`-class.
21
32
  *
22
33
  * Throws `MIGRATION.INVALID_INVARIANT_ID` on a malformed id and
23
34
  * `MIGRATION.DUPLICATE_INVARIANT_IN_EDGE` on duplicates.
@@ -39,7 +50,7 @@ export function deriveProvidedInvariants(ops: MigrationOps): readonly string[] {
39
50
  }
40
51
 
41
52
  function readInvariantId(op: MigrationPlanOperation): string | undefined {
42
- if (op.operationClass !== 'data') return undefined;
53
+ if (!Object.hasOwn(op, 'invariantId')) return undefined;
43
54
  const candidate = (op as { invariantId?: unknown }).invariantId;
44
55
  return typeof candidate === 'string' ? candidate : undefined;
45
56
  }
package/src/io.ts CHANGED
@@ -124,6 +124,48 @@ export async function materialiseMigrationPackage(
124
124
  });
125
125
  }
126
126
 
127
+ /**
128
+ * Idempotent variant of {@link materialiseMigrationPackage}: writes the
129
+ * package only if `<targetDir>/<pkg.dirName>/` does not already exist on
130
+ * disk as a directory; returns `{ written: false }` when the package
131
+ * directory is present (no rewrite, no comparison — by-existence skip).
132
+ *
133
+ * Concretely:
134
+ * - existing directory → skip silently, return `{ written: false }`.
135
+ * - missing path → write three files via {@link materialiseMigrationPackage},
136
+ * return `{ written: true }`.
137
+ * - path exists but is not a directory (file/symlink) → treated as
138
+ * missing; {@link materialiseMigrationPackage} will attempt creation
139
+ * and fail with an appropriate OS error.
140
+ * - any other I/O error from `stat` → propagated unchanged.
141
+ *
142
+ * Used by the CLI's `runContractSpaceExtensionMigrationsPass` to
143
+ * materialise extension migration packages into a project's
144
+ * `migrations/<spaceId>/` directory, and by extension-package tests
145
+ * that mirror the same idempotent-rematerialise property locally
146
+ * without taking a CLI dependency.
147
+ */
148
+ export async function materialiseExtensionMigrationPackageIfMissing(
149
+ targetDir: string,
150
+ pkg: MigrationPackage,
151
+ ): Promise<{ readonly written: boolean }> {
152
+ const pkgDir = join(targetDir, pkg.dirName);
153
+ if (await directoryExists(pkgDir)) {
154
+ return { written: false };
155
+ }
156
+ await materialiseMigrationPackage(targetDir, pkg);
157
+ return { written: true };
158
+ }
159
+
160
+ async function directoryExists(p: string): Promise<boolean> {
161
+ try {
162
+ return (await stat(p)).isDirectory();
163
+ } catch (error) {
164
+ if (hasErrnoCode(error, 'ENOENT')) return false;
165
+ throw error;
166
+ }
167
+ }
168
+
127
169
  /**
128
170
  * Copy a list of files into `destDir`, optionally renaming each one.
129
171
  *
@@ -7,13 +7,11 @@ import { errorDuplicateSpaceId } from './errors';
7
7
  *
8
8
  * - `priorContract` is `null` for a space that has never been emitted
9
9
  * (no `migrations/<space-id>/contract.json` on disk yet); otherwise it
10
- * is the canonical contract value pinned for that space.
10
+ * is the canonical contract value emitted for that space.
11
11
  * - `newContract` is the canonical contract value the planner is about
12
12
  * to emit for that space — for app-space, the just-emitted root
13
13
  * `contract.json`; for an extension space, the descriptor's
14
14
  * `contractSpace.contractJson`.
15
- *
16
- * @see specs/framework-mechanism.spec.md § 3.
17
15
  */
18
16
  export interface SpacePlanInput<TContract> {
19
17
  readonly spaceId: string;
@@ -32,7 +30,7 @@ export interface SpacePlanOutput<TPackage> {
32
30
  *
33
31
  * Behaviour:
34
32
  *
35
- * - The output is sorted alphabetically by `spaceId` (AM3). Two callers
33
+ * - The output is sorted alphabetically by `spaceId`. Two callers
36
34
  * passing the same set of inputs in different orders observe
37
35
  * byte-identical outputs.
38
36
  * - The per-space planner (`planSpace`) is called exactly once per
@@ -49,11 +47,9 @@ export interface SpacePlanOutput<TPackage> {
49
47
  *
50
48
  * Synchronous: the underlying per-space planner (target's
51
49
  * `MigrationPlanner.plan(...)`) is synchronous; callers that need to
52
- * resolve async I/O (e.g. reading pinned `contract.json` from disk)
50
+ * resolve async I/O (e.g. reading on-disk `contract.json` from disk)
53
51
  * resolve it before calling `planAllSpaces` and pass the materialised
54
52
  * inputs through.
55
- *
56
- * @see specs/framework-mechanism.spec.md § 3 — Per-space planner (T1.3).
57
53
  */
58
54
  export function planAllSpaces<TContract, TPackage>(
59
55
  inputs: readonly SpacePlanInput<TContract>[],
@@ -0,0 +1,44 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'pathe';
3
+ import { errorInvalidJson, errorMissingFile } from './errors';
4
+ import { 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 on-disk contract value for a contract space
12
+ * (`<projectMigrationsDir>/<spaceId>/contract.json`). Returns the parsed
13
+ * JSON value as `unknown` — callers that need a typed contract validate
14
+ * via their family's `validateContract` to surface schema issues.
15
+ *
16
+ * Companion to {@link import('./read-contract-space-head-ref').readContractSpaceHeadRef}
17
+ * — same ENOENT-throws / corrupt-file-error semantics. Returns the
18
+ * canonical-JSON value the framework wrote during emit, so re-running
19
+ * this helper across machines / runs yields a byte-identical value.
20
+ */
21
+ export async function readContractSpaceContract(
22
+ projectMigrationsDir: string,
23
+ spaceId: string,
24
+ ): Promise<unknown> {
25
+ assertValidSpaceId(spaceId);
26
+
27
+ const filePath = join(projectMigrationsDir, spaceId, 'contract.json');
28
+
29
+ let raw: string;
30
+ try {
31
+ raw = await readFile(filePath, 'utf-8');
32
+ } catch (error) {
33
+ if (hasErrnoCode(error, 'ENOENT')) {
34
+ throw errorMissingFile('contract.json', join(projectMigrationsDir, spaceId));
35
+ }
36
+ throw error;
37
+ }
38
+
39
+ try {
40
+ return JSON.parse(raw);
41
+ } catch (e) {
42
+ throw errorInvalidJson(filePath, e instanceof Error ? e.message : String(e));
43
+ }
44
+ }
@@ -0,0 +1,63 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import type { ContractSpaceHeadRef } from '@prisma-next/framework-components/control';
3
+ import { join } from 'pathe';
4
+ import { errorInvalidJson, errorInvalidRefFile } from './errors';
5
+ import { assertValidSpaceId } from './space-layout';
6
+
7
+ export type { ContractSpaceHeadRef };
8
+
9
+ function hasErrnoCode(error: unknown, code: string): boolean {
10
+ return error instanceof Error && (error as { code?: string }).code === code;
11
+ }
12
+
13
+ /**
14
+ * Read the head ref (`hash` + `invariants`) for a contract space from
15
+ * `<projectMigrationsDir>/<spaceId>/refs/head.json`.
16
+ *
17
+ * Returns `null` when the file does not exist (first emit). Surfaces
18
+ * `MIGRATION.INVALID_JSON` / `MIGRATION.INVALID_REF_FILE` on a corrupt
19
+ * `refs/head.json` so callers can distinguish "no head ref on disk"
20
+ * (returns `null`) from "head ref present but unreadable" (throws).
21
+ *
22
+ * Validates the space id against `[a-z][a-z0-9_-]{0,63}` for the same
23
+ * filesystem-safety reasons as the rest of the per-space helpers. The
24
+ * helper is uniform across the app and extension spaces.
25
+ */
26
+ export async function readContractSpaceHeadRef(
27
+ projectMigrationsDir: string,
28
+ spaceId: string,
29
+ ): Promise<ContractSpaceHeadRef | null> {
30
+ assertValidSpaceId(spaceId);
31
+
32
+ const filePath = join(projectMigrationsDir, spaceId, 'refs', 'head.json');
33
+
34
+ let raw: string;
35
+ try {
36
+ raw = await readFile(filePath, 'utf-8');
37
+ } catch (error) {
38
+ if (hasErrnoCode(error, 'ENOENT')) {
39
+ return null;
40
+ }
41
+ throw error;
42
+ }
43
+
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ } catch (e) {
48
+ throw errorInvalidJson(filePath, e instanceof Error ? e.message : String(e));
49
+ }
50
+
51
+ if (typeof parsed !== 'object' || parsed === null) {
52
+ throw errorInvalidRefFile(filePath, 'expected an object');
53
+ }
54
+ const obj = parsed as { hash?: unknown; invariants?: unknown };
55
+ if (typeof obj.hash !== 'string') {
56
+ throw errorInvalidRefFile(filePath, 'expected an object with a string `hash` field');
57
+ }
58
+ if (!Array.isArray(obj.invariants) || obj.invariants.some((value) => typeof value !== 'string')) {
59
+ throw errorInvalidRefFile(filePath, 'expected an object with an `invariants` array of strings');
60
+ }
61
+
62
+ return { hash: obj.hash, invariants: obj.invariants as readonly string[] };
63
+ }
@@ -17,8 +17,6 @@ export type ValidSpaceId = string & { readonly __brand: 'ValidSpaceId' };
17
17
  * Pattern a contract-space identifier must match. The constraint is
18
18
  * filesystem-friendly: lowercase letters / digits / hyphen / underscore,
19
19
  * starts with a letter, max 64 characters.
20
- *
21
- * @see specs/framework-mechanism.spec.md § 3.
22
20
  */
23
21
  const SPACE_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
24
22
 
@@ -35,21 +33,16 @@ export function assertValidSpaceId(spaceId: string): asserts spaceId is ValidSpa
35
33
  /**
36
34
  * Resolve the migrations subdirectory for a given contract space.
37
35
  *
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.
36
+ * Every contract space including the app space (default `'app'`)
37
+ * lands under `<projectMigrationsDir>/<spaceId>/`. The space id is
38
+ * validated against {@link SPACE_ID_PATTERN} because it becomes a
39
+ * filesystem directory name verbatim.
44
40
  *
45
41
  * `projectMigrationsDir` is the project's top-level `migrations/`
46
42
  * directory; the helper does not assume anything about its absolute /
47
43
  * relative shape and is symmetric with `pathe.join`.
48
44
  */
49
45
  export function spaceMigrationDirectory(projectMigrationsDir: string, spaceId: string): string {
50
- if (spaceId === APP_SPACE_ID) {
51
- return projectMigrationsDir;
52
- }
53
46
  assertValidSpaceId(spaceId);
54
47
  return join(projectMigrationsDir, spaceId);
55
48
  }