@prisma-next/migration-tools 0.11.0-dev.5 → 0.11.0-dev.50

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 (133) hide show
  1. package/README.md +4 -4
  2. package/dist/{errors-DGYwcwXs.mjs → errors-4YabujxZ.mjs} +15 -21
  3. package/dist/errors-4YabujxZ.mjs.map +1 -0
  4. package/dist/exports/aggregate.d.mts +275 -179
  5. package/dist/exports/aggregate.d.mts.map +1 -1
  6. package/dist/exports/aggregate.mjs +363 -184
  7. package/dist/exports/aggregate.mjs.map +1 -1
  8. package/dist/exports/enumerate-migration-spaces.d.mts +53 -0
  9. package/dist/exports/enumerate-migration-spaces.d.mts.map +1 -0
  10. package/dist/exports/enumerate-migration-spaces.mjs +107 -0
  11. package/dist/exports/enumerate-migration-spaces.mjs.map +1 -0
  12. package/dist/exports/errors.d.mts +2 -2
  13. package/dist/exports/errors.d.mts.map +1 -1
  14. package/dist/exports/errors.mjs +1 -1
  15. package/dist/exports/graph.d.mts +1 -1
  16. package/dist/exports/hash.d.mts +8 -9
  17. package/dist/exports/hash.d.mts.map +1 -1
  18. package/dist/exports/hash.mjs +1 -1
  19. package/dist/exports/invariants.d.mts +1 -1
  20. package/dist/exports/invariants.d.mts.map +1 -1
  21. package/dist/exports/invariants.mjs +1 -1
  22. package/dist/exports/io.d.mts +2 -83
  23. package/dist/exports/io.mjs +1 -1
  24. package/dist/exports/metadata.d.mts +2 -2
  25. package/dist/exports/migration-graph.d.mts +9 -2
  26. package/dist/exports/migration-graph.d.mts.map +1 -0
  27. package/dist/exports/migration-graph.mjs +16 -2
  28. package/dist/exports/migration-graph.mjs.map +1 -0
  29. package/dist/exports/migration-list-graph-topology.d.mts +13 -0
  30. package/dist/exports/migration-list-graph-topology.d.mts.map +1 -0
  31. package/dist/exports/migration-list-graph-topology.mjs +105 -0
  32. package/dist/exports/migration-list-graph-topology.mjs.map +1 -0
  33. package/dist/exports/migration-list-types.d.mts +2 -0
  34. package/dist/exports/migration-list-types.mjs +1 -0
  35. package/dist/exports/migration-ts.d.mts.map +1 -1
  36. package/dist/exports/migration-ts.mjs.map +1 -1
  37. package/dist/exports/migration.d.mts +5 -6
  38. package/dist/exports/migration.d.mts.map +1 -1
  39. package/dist/exports/migration.mjs +14 -32
  40. package/dist/exports/migration.mjs.map +1 -1
  41. package/dist/exports/package.d.mts +1 -1
  42. package/dist/exports/ref-resolution.d.mts +2 -2
  43. package/dist/exports/ref-resolution.d.mts.map +1 -1
  44. package/dist/exports/ref-resolution.mjs +1 -1
  45. package/dist/exports/ref-resolution.mjs.map +1 -1
  46. package/dist/exports/refs.d.mts +15 -2
  47. package/dist/exports/refs.d.mts.map +1 -0
  48. package/dist/exports/refs.mjs +137 -2
  49. package/dist/exports/refs.mjs.map +1 -0
  50. package/dist/exports/spaces.d.mts +31 -132
  51. package/dist/exports/spaces.d.mts.map +1 -1
  52. package/dist/exports/spaces.mjs +14 -9
  53. package/dist/exports/spaces.mjs.map +1 -1
  54. package/dist/{graph-BrLXqoUc.d.mts → graph-BUZuUeBC.d.mts} +1 -2
  55. package/dist/graph-BUZuUeBC.d.mts.map +1 -0
  56. package/dist/{hash-Cr4WIr4Z.mjs → hash--Y7vCpN3.mjs} +8 -9
  57. package/dist/hash--Y7vCpN3.mjs.map +1 -0
  58. package/dist/{invariants-0daYEzyo.mjs → invariants-CCOAyg6c.mjs} +2 -2
  59. package/dist/{invariants-0daYEzyo.mjs.map → invariants-CCOAyg6c.mjs.map} +1 -1
  60. package/dist/{io-BPLfzvZe.mjs → io-BHl0amF0.mjs} +100 -13
  61. package/dist/io-BHl0amF0.mjs.map +1 -0
  62. package/dist/io-nqFXSSTN.d.mts +124 -0
  63. package/dist/io-nqFXSSTN.d.mts.map +1 -0
  64. package/dist/metadata-Bp9X04gM.d.mts +2 -0
  65. package/dist/{migration-graph-De0dUZoC.d.mts → migration-graph-DtNT-cqc.d.mts} +6 -6
  66. package/dist/migration-graph-DtNT-cqc.d.mts.map +1 -0
  67. package/dist/{migration-graph-nlS4TRpn.mjs → migration-graph-kGBkIZDa.mjs} +6 -26
  68. package/dist/migration-graph-kGBkIZDa.mjs.map +1 -0
  69. package/dist/migration-list-types-BRTuXR8i.d.mts +23 -0
  70. package/dist/migration-list-types-BRTuXR8i.d.mts.map +1 -0
  71. package/dist/op-schema-D5qkXfEf.mjs.map +1 -1
  72. package/dist/{package-DZj8YvD0.d.mts → package-DIttKL7X.d.mts} +1 -1
  73. package/dist/package-DIttKL7X.d.mts.map +1 -0
  74. package/dist/read-contract-space-contract-BS5Oxbgw.mjs +82 -0
  75. package/dist/read-contract-space-contract-BS5Oxbgw.mjs.map +1 -0
  76. package/dist/{refs-BDHo5l_g.mjs → refs-BBKNL45K.mjs} +76 -4
  77. package/dist/refs-BBKNL45K.mjs.map +1 -0
  78. package/dist/{refs-CDaNerhT.d.mts → refs-C8f2IGM8.d.mts} +12 -2
  79. package/dist/refs-C8f2IGM8.d.mts.map +1 -0
  80. package/dist/{read-contract-space-contract-DRueB4Aa.mjs → verify-contract-spaces-BJX5gqtD.mjs} +32 -80
  81. package/dist/verify-contract-spaces-BJX5gqtD.mjs.map +1 -0
  82. package/dist/verify-contract-spaces-T0aiJlBS.d.mts +132 -0
  83. package/dist/verify-contract-spaces-T0aiJlBS.d.mts.map +1 -0
  84. package/package.json +18 -6
  85. package/src/aggregate/aggregate.ts +90 -0
  86. package/src/aggregate/check-integrity.ts +243 -0
  87. package/src/aggregate/loader.ts +156 -334
  88. package/src/aggregate/planner.ts +8 -6
  89. package/src/aggregate/project-schema-to-space.ts +1 -1
  90. package/src/aggregate/strategies/graph-walk.ts +12 -7
  91. package/src/aggregate/strategies/synth.ts +2 -2
  92. package/src/aggregate/types.ts +56 -64
  93. package/src/aggregate/verifier.ts +6 -4
  94. package/src/assert-descriptor-self-consistency.ts +6 -0
  95. package/src/compute-extension-space-apply-path.ts +1 -1
  96. package/src/emit-contract-space-artefacts.ts +4 -3
  97. package/src/enumerate-migration-spaces.ts +127 -0
  98. package/src/errors.ts +17 -2
  99. package/src/exports/aggregate.ts +17 -12
  100. package/src/exports/enumerate-migration-spaces.ts +4 -0
  101. package/src/exports/io.ts +2 -0
  102. package/src/exports/metadata.ts +1 -1
  103. package/src/exports/migration-graph.ts +1 -0
  104. package/src/exports/migration-list-graph-topology.ts +5 -0
  105. package/src/exports/migration-list-types.ts +5 -0
  106. package/src/exports/refs.ts +8 -0
  107. package/src/exports/spaces.ts +3 -0
  108. package/src/graph-membership.ts +17 -0
  109. package/src/graph.ts +0 -1
  110. package/src/hash.ts +7 -8
  111. package/src/integrity-violation.ts +114 -0
  112. package/src/io.ts +139 -14
  113. package/src/metadata.ts +1 -1
  114. package/src/migration-base.ts +10 -30
  115. package/src/migration-graph.ts +7 -35
  116. package/src/migration-list-graph-topology.ts +158 -0
  117. package/src/migration-list-types.ts +21 -0
  118. package/src/read-contract-space-head-ref.ts +5 -2
  119. package/src/refs/snapshot.ts +197 -0
  120. package/src/refs.ts +97 -1
  121. package/src/space-layout.ts +30 -0
  122. package/dist/errors-DGYwcwXs.mjs.map +0 -1
  123. package/dist/exports/io.d.mts.map +0 -1
  124. package/dist/graph-BrLXqoUc.d.mts.map +0 -1
  125. package/dist/hash-Cr4WIr4Z.mjs.map +0 -1
  126. package/dist/io-BPLfzvZe.mjs.map +0 -1
  127. package/dist/metadata-BFX0xdz8.d.mts +0 -2
  128. package/dist/migration-graph-De0dUZoC.d.mts.map +0 -1
  129. package/dist/migration-graph-nlS4TRpn.mjs.map +0 -1
  130. package/dist/package-DZj8YvD0.d.mts.map +0 -1
  131. package/dist/read-contract-space-contract-DRueB4Aa.mjs.map +0 -1
  132. package/dist/refs-BDHo5l_g.mjs.map +0 -1
  133. package/dist/refs-CDaNerhT.d.mts.map +0 -1
@@ -1,376 +1,198 @@
1
1
  import type { Contract } from '@prisma-next/contract/types';
2
- import { notOk, ok, type Result } from '@prisma-next/utils/result';
3
- import { EMPTY_CONTRACT_HASH } from '../constants';
4
2
  import { MigrationToolsError } from '../errors';
5
3
  import { readMigrationsDir } from '../io';
6
- import { reconstructGraph } from '../migration-graph';
7
- import type { OnDiskMigrationPackage } from '../package';
8
4
  import { readContractSpaceContract } from '../read-contract-space-contract';
9
5
  import { readContractSpaceHeadRef } from '../read-contract-space-head-ref';
10
- import { APP_SPACE_ID, spaceMigrationDirectory } from '../space-layout';
6
+ import { HEAD_REF_NAME, type RefLoadProblem, readRefsTolerant } from '../refs';
7
+ import {
8
+ APP_SPACE_ID,
9
+ isValidSpaceId,
10
+ RESERVED_SPACE_SUBDIR_NAMES,
11
+ spaceMigrationDirectory,
12
+ spaceRefsDirectory,
13
+ } from '../space-layout';
11
14
  import { listContractSpaceDirectories } from '../verify-contract-spaces';
12
- import { extractStorageElementNames } from './extract-storage-element-names';
13
- import type { ContractSpaceAggregate, ContractSpaceMember, HydratedMigrationGraph } from './types';
15
+ import { createContractSpaceAggregate, createContractSpaceMember } from './aggregate';
16
+ import { computeIntegrityViolations, type IntegritySpaceState } from './check-integrity';
17
+ import type { ContractSpaceAggregate } from './types';
14
18
 
15
- function integrityDetail(error: unknown): string {
16
- if (MigrationToolsError.is(error)) {
17
- return error.why;
18
- }
19
- if (error instanceof Error) {
20
- return error.message;
21
- }
22
- return String(error);
23
- }
24
-
25
- /**
26
- * Single declared extension entry the loader needs from `Config.extensionPacks`.
27
- *
28
- * Only the subset of fields the loader operates on:
29
- *
30
- * - `id` — the space id (also the directory name under `migrations/`).
31
- * - `targetId` — the configured `Config.adapter.targetId` value the
32
- * declaring extension declared. The loader rejects mismatches against
33
- * the aggregate's `targetId` with `targetMismatch`.
34
- *
35
- * Whether the descriptor declares a contract space is decided by whether
36
- * its corresponding `migrations/<id>/` directory exists on disk
37
- * (materialised by the seed phase before the loader runs); the loader
38
- * never reads the descriptor's `contractJson` itself. That makes the
39
- * aggregate's apply / verify paths byte-for-byte independent of the
40
- * descriptor module — `db verify` succeeds even if the descriptor's
41
- * `contractJson` is a throwing getter.
42
- *
43
- * Typed structurally so the migration-tools layer stays framework-neutral.
44
- */
45
- export interface DeclaredExtensionEntry {
46
- readonly id: string;
47
- readonly targetId: string;
48
- }
19
+ export type { DeclaredExtensionEntry } from '../integrity-violation';
49
20
 
50
21
  /**
51
22
  * Inputs for {@link loadContractSpaceAggregate}.
52
23
  *
53
- * The loader is the **sole** descriptor-import boundary in the M2.5
54
- * pipeline: callers gather the descriptor data (already-validated app
55
- * contract, declared extension entries) and pass it through. Once the
56
- * loader returns, no descriptor module is imported again for this
57
- * aggregate's lifetime.
24
+ * Construction reads migration **state** from disk (`migrations/<space>/`
25
+ * packages + refs + head refs). The app's *live* contract is not a disk
26
+ * artefact in Prisma Next it is always compiled from the project's
27
+ * central contract, so the caller always has it and threads it in as
28
+ * `appContract`. `deserializeContract` is held and called lazily only for
29
+ * the on-disk extension contracts (`migrations/<ext>/contract.json`).
58
30
  */
59
31
  export interface LoadAggregateInput {
60
- readonly targetId: string;
61
32
  readonly migrationsDir: string;
33
+ readonly deserializeContract: (raw: unknown) => Contract;
62
34
  readonly appContract: Contract;
63
- readonly declaredExtensions: ReadonlyArray<DeclaredExtensionEntry>;
64
- readonly deserializeContract: (contractJson: unknown) => Contract;
65
- /**
66
- * Hydrated migration graph for the **app member**.
67
- *
68
- * The framework-neutral migration-tools layer doesn't know how to read
69
- * the user's authored `migrations/` directory (the app member's
70
- * migration-package layout is family-aware: ops.json shape, manifest
71
- * keys, etc.). Callers — the SQL family today — read the user's
72
- * `migrations/` and hand the resulting `OnDiskMigrationPackage[]` through.
73
- *
74
- * Passing `[]` is valid (greenfield project, no authored migrations).
75
- * Equivalent to `migrations/` not existing or being empty.
76
- */
77
- readonly appMigrationPackages: ReadonlyArray<OnDiskMigrationPackage>;
78
- }
79
-
80
- /**
81
- * Discriminated failure variants the loader emits.
82
- *
83
- * Every variant short-circuits at first hit; the loader does not keep
84
- * collecting after the first violation in any phase except for layout
85
- * (where every layout offence is bundled into one `layoutViolation`).
86
- */
87
- export type LoadAggregateError =
88
- | { readonly kind: 'layoutViolation'; readonly violations: readonly LayoutViolation[] }
89
- | { readonly kind: 'integrityFailure'; readonly spaceId: string; readonly detail: string }
90
- | { readonly kind: 'validationFailure'; readonly spaceId: string; readonly detail: string }
91
- | {
92
- readonly kind: 'disjointnessViolation';
93
- readonly element: string;
94
- readonly claimedBy: readonly string[];
95
- }
96
- | {
97
- readonly kind: 'targetMismatch';
98
- readonly spaceId: string;
99
- readonly expected: string;
100
- readonly actual: string;
101
- };
102
-
103
- /**
104
- * Single layout violation; bundled into a `layoutViolation` error so
105
- * users see every layout offence at once rather than fixing them one
106
- * at a time across re-runs.
107
- *
108
- * - `declaredButUnmigrated`: extension declared in `extensionPacks` with
109
- * a `contractSpace` but no contract-space dir on disk. Remediation:
110
- * `prisma-next migrate`.
111
- * - `orphanSpaceDir`: contract-space dir under `migrations/` for an extension
112
- * not in `extensionPacks`. Remediation: remove the directory, or
113
- * re-add the extension to `extensionPacks`.
114
- */
115
- export type LayoutViolation =
116
- | { readonly kind: 'declaredButUnmigrated'; readonly spaceId: string }
117
- | { readonly kind: 'orphanSpaceDir'; readonly spaceId: string };
118
-
119
- export type LoadAggregateOutput = Result<
120
- { readonly aggregate: ContractSpaceAggregate },
121
- LoadAggregateError
122
- >;
123
-
124
- interface LoadedExtensionState {
125
- readonly entry: DeclaredExtensionEntry;
126
- readonly contract: Contract;
127
- readonly headRefHash: string;
128
- readonly headRefInvariants: readonly string[];
129
- readonly migrations: HydratedMigrationGraph;
130
35
  }
131
36
 
132
37
  /**
133
- * Hydrate a {@link ContractSpaceAggregate} from on-disk state and
134
- * the app contract value the caller supplies.
38
+ * Build a tolerant, queryable {@link ContractSpaceAggregate} from on-disk
39
+ * migration state plus the caller's live app contract.
135
40
  *
136
- * The loader is the **only** descriptor-import boundary at apply /
137
- * verify time, but it intentionally does **not** read the extension
138
- * descriptor's `contractJson` value. Each extension space's contract
139
- * is read from its on-disk `migrations/<id>/contract.json` mirror; the
140
- * descriptor's role is exhausted by the seed phase that wrote that
141
- * mirror in the first place. The loader composes existing
142
- * migration-tools primitives layout precheck (via
143
- * {@link listContractSpaceDirectories}), integrity checks (via
144
- * {@link readMigrationsDir} / {@link readContractSpaceHeadRef} /
145
- * {@link readContractSpaceContract} / `deserializeContract`), and
146
- * disjointness — into a single typed value.
41
+ * Building **never throws on disk content**: a hash- or
42
+ * invariants-mismatched package is retained, an unparseable package is
43
+ * omitted, a missing extension head ref leaves `headRef: null`, and an
44
+ * unreadable on-disk contract defers its failure to `member.contract()`.
45
+ * Every such problem is judged by {@link ContractSpaceAggregate.checkIntegrity}
46
+ * rather than aborting the load. The only rejections are catastrophic I/O
47
+ * (a `migrations/` that exists but is unreadable for reasons other than
48
+ * absence).
147
49
  *
148
- * Failure semantics: every failure variant in {@link LoadAggregateError}
149
- * short-circuits the load.
50
+ * The app space's head ref is synthesised from the live contract's
51
+ * storage hash (the app contract is authored independently of the
52
+ * migration graph), and `app.contract()` returns the supplied contract.
53
+ * Extension spaces read their contract, refs, and head ref from disk.
150
54
  */
151
55
  export async function loadContractSpaceAggregate(
152
56
  input: LoadAggregateInput,
153
- ): Promise<LoadAggregateOutput> {
154
- // 1. Validate target consistency on the app contract.
155
- const appContractTarget = input.appContract.target;
156
- if (appContractTarget !== input.targetId) {
157
- return notOk({
158
- kind: 'targetMismatch',
159
- spaceId: APP_SPACE_ID,
160
- expected: input.targetId,
161
- actual: appContractTarget,
162
- });
163
- }
57
+ ): Promise<ContractSpaceAggregate> {
58
+ const { migrationsDir, deserializeContract, appContract } = input;
59
+ const targetId = appContract.target;
164
60
 
165
- for (const entry of input.declaredExtensions) {
166
- if (entry.targetId !== input.targetId) {
167
- return notOk({
168
- kind: 'targetMismatch',
169
- spaceId: entry.id,
170
- expected: input.targetId,
171
- actual: entry.targetId,
172
- });
173
- }
174
- }
61
+ const appState = await loadAppSpace(migrationsDir, appContract);
62
+ const extensionStates = await loadExtensionSpaces(migrationsDir, deserializeContract);
175
63
 
176
- // 2. Layout precheck: bundle every layout offence at once.
177
- //
178
- // Every declared extension contributes an entry to the aggregate when
179
- // a corresponding `migrations/<id>/` directory exists on disk. The
180
- // loader treats the directory's presence as the membership signal —
181
- // the descriptor itself is not read — so codec-only extensions (no
182
- // on-disk dir) and contract-space extensions (dir present) are
183
- // distinguished structurally.
184
- const declaredSpaceIds = new Set(input.declaredExtensions.map((e) => e.id));
185
- const allDirs = await listContractSpaceDirectories(input.migrationsDir);
186
- // The app member is implicitly declared (it is always part of the
187
- // aggregate); its `migrations/<APP_SPACE_ID>/` directory may exist or
188
- // not (greenfield projects start with neither). Filter it out of the
189
- // orphan / declared-but-unmigrated checks so the layout precheck is
190
- // about extensions only.
191
- const extensionDirsOnDisk = allDirs.filter((d) => d !== APP_SPACE_ID);
192
- const spaceDirSet = new Set(extensionDirsOnDisk);
64
+ const spaces: readonly IntegritySpaceState[] = [appState, ...extensionStates];
193
65
 
194
- const layoutViolations: LayoutViolation[] = [];
195
- for (const dir of extensionDirsOnDisk) {
196
- if (!declaredSpaceIds.has(dir)) {
197
- layoutViolations.push({ kind: 'orphanSpaceDir', spaceId: dir });
198
- }
199
- }
200
- for (const id of [...declaredSpaceIds].sort()) {
201
- if (!spaceDirSet.has(id)) {
202
- layoutViolations.push({ kind: 'declaredButUnmigrated', spaceId: id });
203
- }
204
- }
205
- if (layoutViolations.length > 0) {
206
- return notOk({ kind: 'layoutViolation', violations: layoutViolations });
207
- }
66
+ return createContractSpaceAggregate({
67
+ targetId,
68
+ app: appState.member,
69
+ extensions: extensionStates.map((state) => state.member),
70
+ checkIntegrity: (opts) => computeIntegrityViolations({ targetId, spaces }, opts),
71
+ });
72
+ }
208
73
 
209
- // 3-5. Per-extension: read + validate + integrity-check.
210
- const loadedExtensions: LoadedExtensionState[] = [];
211
- for (const entry of [...input.declaredExtensions].sort((a, b) => a.id.localeCompare(b.id))) {
212
- const headRef = await readContractSpaceHeadRef(input.migrationsDir, entry.id);
213
- if (headRef === null) {
214
- return notOk({
215
- kind: 'integrityFailure',
216
- spaceId: entry.id,
217
- detail: `Head ref \`refs/head.json\` is missing for extension space "${entry.id}".`,
218
- });
219
- }
74
+ async function loadAppSpace(
75
+ migrationsDir: string,
76
+ appContract: Contract,
77
+ ): Promise<IntegritySpaceState> {
78
+ const spaceDir = spaceMigrationDirectory(migrationsDir, APP_SPACE_ID);
79
+ const { packages, problems } = await readMigrationsDir(spaceDir);
80
+ const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir));
220
81
 
221
- let spaceContractRaw: unknown;
222
- try {
223
- spaceContractRaw = await readContractSpaceContract(input.migrationsDir, entry.id);
224
- } catch (error) {
225
- return notOk({
226
- kind: 'integrityFailure',
227
- spaceId: entry.id,
228
- detail: integrityDetail(error),
229
- });
230
- }
231
-
232
- let spaceContract: Contract;
233
- try {
234
- spaceContract = input.deserializeContract(spaceContractRaw);
235
- } catch (error) {
236
- return notOk({
237
- kind: 'validationFailure',
238
- spaceId: entry.id,
239
- detail: integrityDetail(error),
240
- });
241
- }
82
+ const member = createContractSpaceMember({
83
+ spaceId: APP_SPACE_ID,
84
+ packages,
85
+ refs,
86
+ headRef: { hash: appContract.storage.storageHash, invariants: [] },
87
+ resolveContract: () => appContract,
88
+ });
242
89
 
243
- if (spaceContract.target !== input.targetId) {
244
- return notOk({
245
- kind: 'targetMismatch',
246
- spaceId: entry.id,
247
- expected: input.targetId,
248
- actual: spaceContract.target,
249
- });
250
- }
90
+ // The app head ref is synthesised from the live contract, so there is
91
+ // no on-disk head.json to be missing or corrupt for it.
92
+ return { member, problems, refProblems, headRefProblem: null, isApp: true };
93
+ }
251
94
 
252
- // Read + integrity-check the migration packages. `readMigrationsDir`
253
- // re-derives `providedInvariants` and verifies migrationHash for
254
- // every package.
255
- let packages: readonly OnDiskMigrationPackage[];
256
- try {
257
- packages = await readMigrationsDir(spaceMigrationDirectory(input.migrationsDir, entry.id));
258
- } catch (error) {
259
- return notOk({
260
- kind: 'integrityFailure',
261
- spaceId: entry.id,
262
- detail: integrityDetail(error),
263
- });
264
- }
95
+ async function loadExtensionSpaces(
96
+ migrationsDir: string,
97
+ deserializeContract: (raw: unknown) => Contract,
98
+ ): Promise<readonly IntegritySpaceState[]> {
99
+ const candidateDirs = await listContractSpaceDirectories(migrationsDir);
100
+ const extensionIds = candidateDirs
101
+ .filter((name) => name !== APP_SPACE_ID)
102
+ .filter((name) => !RESERVED_SPACE_SUBDIR_NAMES.has(name))
103
+ .filter(isValidSpaceId)
104
+ .sort();
105
+
106
+ const states: IntegritySpaceState[] = [];
107
+ for (const spaceId of extensionIds) {
108
+ states.push(await loadExtensionSpace(migrationsDir, spaceId, deserializeContract));
109
+ }
110
+ return states;
111
+ }
265
112
 
266
- let graph: ReturnType<typeof reconstructGraph>;
267
- try {
268
- graph = reconstructGraph(packages);
269
- } catch (error) {
270
- return notOk({
271
- kind: 'integrityFailure',
272
- spaceId: entry.id,
273
- detail: integrityDetail(error),
274
- });
275
- }
113
+ async function loadExtensionSpace(
114
+ migrationsDir: string,
115
+ spaceId: string,
116
+ deserializeContract: (raw: unknown) => Contract,
117
+ ): Promise<IntegritySpaceState> {
118
+ const spaceDir = spaceMigrationDirectory(migrationsDir, spaceId);
119
+ const { packages, problems } = await readMigrationsDir(spaceDir);
120
+ const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir));
121
+ const { headRef, problem: headRefProblem } = await readHeadRefTolerant(migrationsDir, spaceId);
122
+ const rawContract = await readRawContractDeferred(migrationsDir, spaceId);
123
+
124
+ const member = createContractSpaceMember({
125
+ spaceId,
126
+ packages,
127
+ refs,
128
+ headRef,
129
+ resolveContract: () => deserializeContract(rawContract()),
130
+ });
276
131
 
277
- // The on-disk head ref must be reachable in the graph. Empty graphs
278
- // are tolerated only when the head ref points at the empty-contract
279
- // sentinel (a never-emitted extension space; not a typical scenario
280
- // because the layout precheck would have flagged the missing
281
- // dir, but defensible).
282
- if (graph.nodes.size === 0) {
283
- if (headRef.hash !== EMPTY_CONTRACT_HASH) {
284
- return notOk({
285
- kind: 'integrityFailure',
286
- spaceId: entry.id,
287
- detail: `Head ref "${headRef.hash}" is not present in the (empty) on-disk migration graph.`,
288
- });
289
- }
290
- } else if (!graph.nodes.has(headRef.hash)) {
291
- return notOk({
292
- kind: 'integrityFailure',
293
- spaceId: entry.id,
294
- detail: `Head ref "${headRef.hash}" is not present in the on-disk migration graph.`,
295
- });
296
- }
132
+ return { member, problems, refProblems, headRefProblem, isApp: false };
133
+ }
297
134
 
298
- const packagesByMigrationHash = new Map<string, OnDiskMigrationPackage>(
299
- packages.map((p) => [p.metadata.migrationHash, p]),
300
- );
135
+ /**
136
+ * The result of resolving an extension's `refs/head.json`: the parsed
137
+ * head ref (or `null` when the file is absent or corrupt) plus a problem
138
+ * when the file exists but cannot be parsed.
139
+ */
140
+ interface HeadRefReadResult {
141
+ readonly headRef: Awaited<ReturnType<typeof readContractSpaceHeadRef>>;
142
+ readonly problem: RefLoadProblem | null;
143
+ }
301
144
 
302
- loadedExtensions.push({
303
- entry,
304
- contract: spaceContract,
305
- headRefHash: headRef.hash,
306
- headRefInvariants: [...headRef.invariants].sort(),
307
- migrations: { graph, packagesByMigrationHash },
308
- });
309
- }
145
+ /**
146
+ * Read an extension's head ref, distinguishing a *genuinely absent*
147
+ * `head.json` (`headRef: null`, no problem — judged `headRefMissing`)
148
+ * from one that *exists but cannot be parsed* (`headRef: null` plus a
149
+ * problem — judged `refUnreadable`, not `headRefMissing`).
150
+ * `readContractSpaceHeadRef` already returns `null` only for ENOENT and
151
+ * throws for unparseable / schema-invalid content, so the throw is the
152
+ * corruption signal. Construction never throws on disk content.
153
+ */
154
+ function isToleratedRefHeadReadError(error: unknown): boolean {
155
+ if (MigrationToolsError.is(error)) return true;
156
+ if (!(error instanceof Error)) return false;
157
+ const code = (error as NodeJS.ErrnoException).code;
158
+ return code === 'ENOENT' || code === 'EISDIR';
159
+ }
310
160
 
311
- // 6. Build app member with hydrated graph from caller-supplied packages.
312
- let appGraph: ReturnType<typeof reconstructGraph>;
161
+ async function readHeadRefTolerant(
162
+ migrationsDir: string,
163
+ spaceId: string,
164
+ ): Promise<HeadRefReadResult> {
313
165
  try {
314
- appGraph = reconstructGraph(input.appMigrationPackages);
166
+ const headRef = await readContractSpaceHeadRef(migrationsDir, spaceId);
167
+ return { headRef, problem: null };
315
168
  } catch (error) {
316
- return notOk({
317
- kind: 'integrityFailure',
318
- spaceId: APP_SPACE_ID,
319
- detail: error instanceof Error ? error.message : String(error),
320
- });
169
+ if (!isToleratedRefHeadReadError(error)) {
170
+ throw error;
171
+ }
172
+ return { headRef: null, problem: { refName: HEAD_REF_NAME, detail: detailOf(error) } };
321
173
  }
322
- const appPackagesByMigrationHash = new Map<string, OnDiskMigrationPackage>(
323
- input.appMigrationPackages.map((p) => [p.metadata.migrationHash, p]),
324
- );
325
-
326
- const appMember: ContractSpaceMember = {
327
- spaceId: APP_SPACE_ID,
328
- contract: input.appContract,
329
- headRef: {
330
- hash: input.appContract.storage.storageHash,
331
- invariants: [],
332
- },
333
- migrations: {
334
- graph: appGraph,
335
- packagesByMigrationHash: appPackagesByMigrationHash,
336
- },
337
- };
174
+ }
338
175
 
339
- const extensionMembers: ContractSpaceMember[] = loadedExtensions.map((s) => ({
340
- spaceId: s.entry.id,
341
- contract: s.contract,
342
- headRef: {
343
- hash: s.headRefHash,
344
- invariants: s.headRefInvariants,
345
- },
346
- migrations: s.migrations,
347
- }));
176
+ function detailOf(error: unknown): string {
177
+ return error instanceof Error ? error.message : String(error);
178
+ }
348
179
 
349
- // 7. Disjointness: no two members claim the same storage element.
350
- const elementClaimedBy = new Map<string, string[]>();
351
- for (const member of [appMember, ...extensionMembers]) {
352
- const elements = extractStorageElementNames(member.contract);
353
- for (const elementName of elements) {
354
- const claimers = elementClaimedBy.get(elementName);
355
- if (claimers) claimers.push(member.spaceId);
356
- else elementClaimedBy.set(elementName, [member.spaceId]);
357
- }
358
- }
359
- for (const [element, claimedBy] of elementClaimedBy) {
360
- if (claimedBy.length > 1) {
361
- return notOk({
362
- kind: 'disjointnessViolation',
363
- element,
364
- claimedBy: [...claimedBy].sort(),
365
- });
366
- }
180
+ /**
181
+ * Read the raw on-disk contract eagerly (cheap I/O) but defer its
182
+ * (throwing) failure to call time, so a missing or unparseable
183
+ * `contract.json` becomes a `contract()` throw — surfaced as
184
+ * `contractUnreadable` rather than a construction failure.
185
+ */
186
+ async function readRawContractDeferred(
187
+ migrationsDir: string,
188
+ spaceId: string,
189
+ ): Promise<() => unknown> {
190
+ try {
191
+ const raw = await readContractSpaceContract(migrationsDir, spaceId);
192
+ return () => raw;
193
+ } catch (error) {
194
+ return () => {
195
+ throw error;
196
+ };
367
197
  }
368
-
369
- return ok({
370
- aggregate: {
371
- targetId: input.targetId,
372
- app: appMember,
373
- extensions: extensionMembers,
374
- },
375
- });
376
198
  }
@@ -1,4 +1,5 @@
1
1
  import { notOk, ok } from '@prisma-next/utils/result';
2
+ import { requireHeadRef } from './aggregate';
2
3
  import type {
3
4
  AggregatePerSpacePlan,
4
5
  AggregatePlannerError,
@@ -28,7 +29,7 @@ export type {
28
29
  * 1. If `callerPolicy.ignoreGraphFor.has(member.spaceId)`:
29
30
  * - If `member.headRef.invariants` is empty → synth.
30
31
  * - Else → `policyConflict` (synth cannot satisfy authored invariants).
31
- * 2. Else if `member.migrations.graph` is non-empty AND graph-walk
32
+ * 2. Else if `member.graph()` is non-empty AND graph-walk
32
33
  * succeeds → graph-walk.
33
34
  * 3. Else if `member.headRef.invariants` is empty → synth.
34
35
  * 4. Else → graph-walk failure → `extensionPathUnreachable` /
@@ -60,15 +61,16 @@ export async function planAggregate<TFamilyId extends string, TTargetId extends
60
61
  for (const member of orderedMembers) {
61
62
  const otherMembers = allMembers.filter((m) => m.spaceId !== member.spaceId);
62
63
  const currentMarker = currentDBState.markersBySpaceId.get(member.spaceId) ?? null;
64
+ const headRef = requireHeadRef(member);
63
65
 
64
66
  const ignoreGraph = callerPolicy.ignoreGraphFor.has(member.spaceId);
65
- const invariantsRequired = member.headRef.invariants.length > 0;
67
+ const invariantsRequired = headRef.invariants.length > 0;
66
68
 
67
69
  if (ignoreGraph && invariantsRequired) {
68
70
  const conflict: AggregatePlannerError = {
69
71
  kind: 'policyConflict',
70
72
  spaceId: member.spaceId,
71
- detail: `\`callerPolicy.ignoreGraphFor\` requested for space "${member.spaceId}", but the member declares non-empty head-ref invariants (${member.headRef.invariants.join(', ')}). Synthesising a plan from the contract IR cannot satisfy authored invariants — the graph must be walked. Either remove "${member.spaceId}" from \`ignoreGraphFor\` or amend the on-disk head ref to declare zero invariants.`,
73
+ detail: `\`callerPolicy.ignoreGraphFor\` requested for space "${member.spaceId}", but the member declares non-empty head-ref invariants (${headRef.invariants.join(', ')}). Synthesising a plan from the contract IR cannot satisfy authored invariants — the graph must be walked. Either remove "${member.spaceId}" from \`ignoreGraphFor\` or amend the on-disk head ref to declare zero invariants.`,
72
74
  };
73
75
  return notOk(conflict);
74
76
  }
@@ -97,7 +99,7 @@ export async function planAggregate<TFamilyId extends string, TTargetId extends
97
99
 
98
100
  // Try graph-walk first when the graph has nodes; fall back to synth
99
101
  // when the graph is empty AND no invariants are required.
100
- if (member.migrations.graph.nodes.size > 0) {
102
+ if (member.graph().nodes.size > 0) {
101
103
  const walked = graphWalkStrategy({
102
104
  aggregateTargetId: aggregate.targetId,
103
105
  member,
@@ -111,7 +113,7 @@ export async function planAggregate<TFamilyId extends string, TTargetId extends
111
113
  return notOk({
112
114
  kind: 'extensionPathUnreachable',
113
115
  spaceId: member.spaceId,
114
- target: member.headRef.hash,
116
+ target: headRef.hash,
115
117
  });
116
118
  }
117
119
  // unsatisfiable — surface
@@ -128,7 +130,7 @@ export async function planAggregate<TFamilyId extends string, TTargetId extends
128
130
  return notOk({
129
131
  kind: 'extensionPathUnsatisfiable',
130
132
  spaceId: member.spaceId,
131
- missingInvariants: [...member.headRef.invariants].sort(),
133
+ missingInvariants: [...headRef.invariants].sort(),
132
134
  });
133
135
  }
134
136
 
@@ -102,7 +102,7 @@ function collectOwnedNames(
102
102
  const owned = new Set<string>();
103
103
  for (const other of otherMembers) {
104
104
  if (other.spaceId === member.spaceId) continue;
105
- for (const name of extractStorageElementNames(other.contract)) {
105
+ for (const name of extractStorageElementNames(other.contract())) {
106
106
  owned.add(name);
107
107
  }
108
108
  }
@@ -1,7 +1,8 @@
1
1
  import type { MigrationPlan } from '@prisma-next/framework-components/control';
2
2
  import { EMPTY_CONTRACT_HASH } from '../../constants';
3
3
  import { findPathWithDecision } from '../../migration-graph';
4
- import type { MigrationOps } from '../../package';
4
+ import type { MigrationOps, OnDiskMigrationPackage } from '../../package';
5
+ import { requireHeadRef } from '../aggregate';
5
6
  import type { ContractMarkerRecordLike } from '../marker-types';
6
7
  import type { AggregatePerSpacePlan } from '../planner-types';
7
8
  import type { ContractSpaceMember } from '../types';
@@ -9,7 +10,7 @@ import type { ContractSpaceMember } from '../types';
9
10
  /**
10
11
  * Outcome variants for the graph-walk strategy. Mirrors
11
12
  * {@link import('../../compute-extension-space-apply-path').ExtensionSpaceApplyPathOutcome}
12
- * but operates against the **already-hydrated** `member.migrations.graph`
13
+ * but operates against the member's lazily-reconstructed `graph()`
13
14
  * instead of re-reading from disk. The aggregate planner converts
14
15
  * these into {@link import('../planner-types').AggregatePlannerError}
15
16
  * variants.
@@ -49,13 +50,17 @@ export interface GraphWalkStrategyInputs {
49
50
  */
50
51
  export function graphWalkStrategy(input: GraphWalkStrategyInputs): GraphWalkOutcome {
51
52
  const { aggregateTargetId, member, currentMarker, refName } = input;
52
- const { graph, packagesByMigrationHash } = member.migrations;
53
+ const headRef = requireHeadRef(member);
54
+ const graph = member.graph();
55
+ const packagesByMigrationHash = new Map<string, OnDiskMigrationPackage>(
56
+ member.packages.map((pkg) => [pkg.metadata.migrationHash, pkg]),
57
+ );
53
58
 
54
59
  const fromHash = currentMarker?.storageHash ?? EMPTY_CONTRACT_HASH;
55
60
  const markerInvariants = new Set(currentMarker?.invariants ?? []);
56
- const required = new Set(member.headRef.invariants.filter((id) => !markerInvariants.has(id)));
61
+ const required = new Set(headRef.invariants.filter((id) => !markerInvariants.has(id)));
57
62
 
58
- const outcome = findPathWithDecision(graph, fromHash, member.headRef.hash, {
63
+ const outcome = findPathWithDecision(graph, fromHash, headRef.hash, {
59
64
  required,
60
65
  ...(refName !== undefined ? { refName } : {}),
61
66
  });
@@ -98,7 +103,7 @@ export function graphWalkStrategy(input: GraphWalkStrategyInputs): GraphWalkOutc
98
103
  targetId: aggregateTargetId,
99
104
  spaceId: member.spaceId,
100
105
  origin: currentMarker === null ? null : { storageHash: currentMarker.storageHash },
101
- destination: { storageHash: member.headRef.hash },
106
+ destination: { storageHash: headRef.hash },
102
107
  operations: pathOps,
103
108
  providedInvariants: [...providedInvariantsSet].sort(),
104
109
  };
@@ -108,7 +113,7 @@ export function graphWalkStrategy(input: GraphWalkStrategyInputs): GraphWalkOutc
108
113
  result: {
109
114
  plan,
110
115
  displayOps: pathOps,
111
- destinationContract: member.contract,
116
+ destinationContract: member.contract(),
112
117
  strategy: 'graph-walk',
113
118
  migrationEdges: edgeRefs,
114
119
  pathDecision: outcome.decision,