@prisma-next/migration-tools 0.11.0 → 0.12.0

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 (118) hide show
  1. package/README.md +4 -4
  2. package/dist/{errors-DGYwcwXs.mjs → errors-vFROOhCR.mjs} +46 -21
  3. package/dist/errors-vFROOhCR.mjs.map +1 -0
  4. package/dist/exports/aggregate.d.mts +328 -204
  5. package/dist/exports/aggregate.d.mts.map +1 -1
  6. package/dist/exports/aggregate.mjs +480 -243
  7. package/dist/exports/aggregate.mjs.map +1 -1
  8. package/dist/exports/errors.d.mts +2 -2
  9. package/dist/exports/errors.d.mts.map +1 -1
  10. package/dist/exports/errors.mjs +1 -1
  11. package/dist/exports/graph.d.mts +1 -1
  12. package/dist/exports/hash.d.mts +8 -9
  13. package/dist/exports/hash.d.mts.map +1 -1
  14. package/dist/exports/hash.mjs +1 -1
  15. package/dist/exports/invariants.d.mts +1 -1
  16. package/dist/exports/invariants.d.mts.map +1 -1
  17. package/dist/exports/invariants.mjs +1 -1
  18. package/dist/exports/io.d.mts +2 -83
  19. package/dist/exports/io.mjs +1 -1
  20. package/dist/exports/metadata.d.mts +2 -2
  21. package/dist/exports/migration-graph.d.mts +9 -2
  22. package/dist/exports/migration-graph.d.mts.map +1 -0
  23. package/dist/exports/migration-graph.mjs +3 -2
  24. package/dist/exports/migration-ts.d.mts.map +1 -1
  25. package/dist/exports/migration-ts.mjs.map +1 -1
  26. package/dist/exports/migration.d.mts +5 -6
  27. package/dist/exports/migration.d.mts.map +1 -1
  28. package/dist/exports/migration.mjs +14 -32
  29. package/dist/exports/migration.mjs.map +1 -1
  30. package/dist/exports/package.d.mts +1 -1
  31. package/dist/exports/ref-resolution.d.mts +2 -2
  32. package/dist/exports/ref-resolution.d.mts.map +1 -1
  33. package/dist/exports/ref-resolution.mjs +1 -1
  34. package/dist/exports/ref-resolution.mjs.map +1 -1
  35. package/dist/exports/refs.d.mts +15 -2
  36. package/dist/exports/refs.d.mts.map +1 -0
  37. package/dist/exports/refs.mjs +3 -2
  38. package/dist/exports/spaces.d.mts +31 -132
  39. package/dist/exports/spaces.d.mts.map +1 -1
  40. package/dist/exports/spaces.mjs +13 -9
  41. package/dist/exports/spaces.mjs.map +1 -1
  42. package/dist/{graph-BrLXqoUc.d.mts → graph-3dLMZp5l.d.mts} +1 -2
  43. package/dist/graph-3dLMZp5l.d.mts.map +1 -0
  44. package/dist/graph-membership-BV23F1IV.mjs +15 -0
  45. package/dist/graph-membership-BV23F1IV.mjs.map +1 -0
  46. package/dist/{hash-Cr4WIr4Z.mjs → hash--Y7vCpN3.mjs} +8 -9
  47. package/dist/hash--Y7vCpN3.mjs.map +1 -0
  48. package/dist/{invariants-0daYEzyo.mjs → invariants-C23nXy1c.mjs} +2 -2
  49. package/dist/{invariants-0daYEzyo.mjs.map → invariants-C23nXy1c.mjs.map} +1 -1
  50. package/dist/{io-BPLfzvZe.mjs → io-BGlPOt9b.mjs} +100 -13
  51. package/dist/io-BGlPOt9b.mjs.map +1 -0
  52. package/dist/io-BH4G3F-i.d.mts +124 -0
  53. package/dist/io-BH4G3F-i.d.mts.map +1 -0
  54. package/dist/metadata-Bp9X04gM.d.mts +2 -0
  55. package/dist/{migration-graph-nlS4TRpn.mjs → migration-graph-BMAqSfv9.mjs} +6 -26
  56. package/dist/migration-graph-BMAqSfv9.mjs.map +1 -0
  57. package/dist/{migration-graph-De0dUZoC.d.mts → migration-graph-CWEM2SLR.d.mts} +6 -6
  58. package/dist/migration-graph-CWEM2SLR.d.mts.map +1 -0
  59. package/dist/op-schema-D5qkXfEf.mjs.map +1 -1
  60. package/dist/{package-DZj8YvD0.d.mts → package-Ca-J_z_0.d.mts} +1 -1
  61. package/dist/package-Ca-J_z_0.d.mts.map +1 -0
  62. package/dist/{read-contract-space-contract-DRueB4Aa.mjs → read-contract-space-contract-TbeXuJXL.mjs} +32 -5
  63. package/dist/read-contract-space-contract-TbeXuJXL.mjs.map +1 -0
  64. package/dist/{refs-BDHo5l_g.mjs → refs-C-_WUrPw.mjs} +97 -4
  65. package/dist/refs-C-_WUrPw.mjs.map +1 -0
  66. package/dist/refs-C7wuYFqZ.d.mts +42 -0
  67. package/dist/refs-C7wuYFqZ.d.mts.map +1 -0
  68. package/dist/snapshot-Bazwo13S.mjs +137 -0
  69. package/dist/snapshot-Bazwo13S.mjs.map +1 -0
  70. package/dist/verify-contract-spaces-BdysZdQk.d.mts +132 -0
  71. package/dist/verify-contract-spaces-BdysZdQk.d.mts.map +1 -0
  72. package/package.json +18 -9
  73. package/src/aggregate/aggregate.ts +266 -0
  74. package/src/aggregate/check-integrity.ts +243 -0
  75. package/src/aggregate/loader.ts +161 -334
  76. package/src/aggregate/planner-types.ts +14 -14
  77. package/src/aggregate/planner.ts +20 -23
  78. package/src/aggregate/project-schema-to-space.ts +3 -8
  79. package/src/aggregate/strategies/graph-walk.ts +15 -10
  80. package/src/aggregate/strategies/synth.ts +4 -4
  81. package/src/aggregate/types.ts +81 -62
  82. package/src/aggregate/verifier.ts +23 -23
  83. package/src/assert-descriptor-self-consistency.ts +6 -0
  84. package/src/compute-extension-space-apply-path.ts +1 -1
  85. package/src/emit-contract-space-artefacts.ts +4 -3
  86. package/src/errors.ts +58 -2
  87. package/src/exports/aggregate.ts +29 -19
  88. package/src/exports/io.ts +2 -0
  89. package/src/exports/metadata.ts +1 -1
  90. package/src/exports/migration-graph.ts +1 -0
  91. package/src/exports/refs.ts +11 -0
  92. package/src/exports/spaces.ts +3 -0
  93. package/src/graph-membership.ts +17 -0
  94. package/src/graph.ts +0 -1
  95. package/src/hash.ts +7 -8
  96. package/src/integrity-violation.ts +114 -0
  97. package/src/io.ts +139 -14
  98. package/src/metadata.ts +1 -1
  99. package/src/migration-base.ts +10 -30
  100. package/src/migration-graph.ts +7 -35
  101. package/src/read-contract-space-head-ref.ts +5 -2
  102. package/src/refs/snapshot.ts +199 -0
  103. package/src/refs.ts +124 -1
  104. package/src/space-layout.ts +30 -0
  105. package/dist/errors-DGYwcwXs.mjs.map +0 -1
  106. package/dist/exports/io.d.mts.map +0 -1
  107. package/dist/graph-BrLXqoUc.d.mts.map +0 -1
  108. package/dist/hash-Cr4WIr4Z.mjs.map +0 -1
  109. package/dist/io-BPLfzvZe.mjs.map +0 -1
  110. package/dist/metadata-BFX0xdz8.d.mts +0 -2
  111. package/dist/migration-graph-De0dUZoC.d.mts.map +0 -1
  112. package/dist/migration-graph-nlS4TRpn.mjs.map +0 -1
  113. package/dist/package-DZj8YvD0.d.mts.map +0 -1
  114. package/dist/read-contract-space-contract-DRueB4Aa.mjs.map +0 -1
  115. package/dist/refs-BDHo5l_g.mjs.map +0 -1
  116. package/dist/refs-CDaNerhT.d.mts +0 -16
  117. package/dist/refs-CDaNerhT.d.mts.map +0 -1
  118. package/src/aggregate/extract-storage-element-names.ts +0 -75
@@ -1,376 +1,203 @@
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, deserializeContract);
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
+ deserializeContract: (raw: unknown) => Contract,
78
+ ): Promise<IntegritySpaceState> {
79
+ const spaceDir = spaceMigrationDirectory(migrationsDir, APP_SPACE_ID);
80
+ const { packages, problems } = await readMigrationsDir(spaceDir);
81
+ const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir));
220
82
 
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
- }
83
+ const member = createContractSpaceMember({
84
+ spaceId: APP_SPACE_ID,
85
+ packages,
86
+ refs,
87
+ headRef: { hash: appContract.storage.storageHash, invariants: [] },
88
+ refsDir: spaceRefsDirectory(spaceDir),
89
+ resolveContract: () => appContract,
90
+ deserializeContract,
91
+ });
242
92
 
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
- }
93
+ // The app head ref is synthesised from the live contract, so there is
94
+ // no on-disk head.json to be missing or corrupt for it.
95
+ return { member, problems, refProblems, headRefProblem: null, isApp: true };
96
+ }
251
97
 
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
- }
98
+ async function loadExtensionSpaces(
99
+ migrationsDir: string,
100
+ deserializeContract: (raw: unknown) => Contract,
101
+ ): Promise<readonly IntegritySpaceState[]> {
102
+ const candidateDirs = await listContractSpaceDirectories(migrationsDir);
103
+ const extensionIds = candidateDirs
104
+ .filter((name) => name !== APP_SPACE_ID)
105
+ .filter((name) => !RESERVED_SPACE_SUBDIR_NAMES.has(name))
106
+ .filter(isValidSpaceId)
107
+ .sort();
108
+
109
+ const states: IntegritySpaceState[] = [];
110
+ for (const spaceId of extensionIds) {
111
+ states.push(await loadExtensionSpace(migrationsDir, spaceId, deserializeContract));
112
+ }
113
+ return states;
114
+ }
265
115
 
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
- }
116
+ async function loadExtensionSpace(
117
+ migrationsDir: string,
118
+ spaceId: string,
119
+ deserializeContract: (raw: unknown) => Contract,
120
+ ): Promise<IntegritySpaceState> {
121
+ const spaceDir = spaceMigrationDirectory(migrationsDir, spaceId);
122
+ const { packages, problems } = await readMigrationsDir(spaceDir);
123
+ const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir));
124
+ const { headRef, problem: headRefProblem } = await readHeadRefTolerant(migrationsDir, spaceId);
125
+ const rawContract = await readRawContractDeferred(migrationsDir, spaceId);
126
+
127
+ const member = createContractSpaceMember({
128
+ spaceId,
129
+ packages,
130
+ refs,
131
+ headRef,
132
+ refsDir: spaceRefsDirectory(spaceDir),
133
+ resolveContract: () => deserializeContract(rawContract()),
134
+ deserializeContract,
135
+ });
276
136
 
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
- }
137
+ return { member, problems, refProblems, headRefProblem, isApp: false };
138
+ }
297
139
 
298
- const packagesByMigrationHash = new Map<string, OnDiskMigrationPackage>(
299
- packages.map((p) => [p.metadata.migrationHash, p]),
300
- );
140
+ /**
141
+ * The result of resolving an extension's `refs/head.json`: the parsed
142
+ * head ref (or `null` when the file is absent or corrupt) plus a problem
143
+ * when the file exists but cannot be parsed.
144
+ */
145
+ interface HeadRefReadResult {
146
+ readonly headRef: Awaited<ReturnType<typeof readContractSpaceHeadRef>>;
147
+ readonly problem: RefLoadProblem | null;
148
+ }
301
149
 
302
- loadedExtensions.push({
303
- entry,
304
- contract: spaceContract,
305
- headRefHash: headRef.hash,
306
- headRefInvariants: [...headRef.invariants].sort(),
307
- migrations: { graph, packagesByMigrationHash },
308
- });
309
- }
150
+ /**
151
+ * Read an extension's head ref, distinguishing a *genuinely absent*
152
+ * `head.json` (`headRef: null`, no problem — judged `headRefMissing`)
153
+ * from one that *exists but cannot be parsed* (`headRef: null` plus a
154
+ * problem — judged `refUnreadable`, not `headRefMissing`).
155
+ * `readContractSpaceHeadRef` already returns `null` only for ENOENT and
156
+ * throws for unparseable / schema-invalid content, so the throw is the
157
+ * corruption signal. Construction never throws on disk content.
158
+ */
159
+ function isToleratedRefHeadReadError(error: unknown): boolean {
160
+ if (MigrationToolsError.is(error)) return true;
161
+ if (!(error instanceof Error)) return false;
162
+ const code = (error as NodeJS.ErrnoException).code;
163
+ return code === 'ENOENT' || code === 'EISDIR';
164
+ }
310
165
 
311
- // 6. Build app member with hydrated graph from caller-supplied packages.
312
- let appGraph: ReturnType<typeof reconstructGraph>;
166
+ async function readHeadRefTolerant(
167
+ migrationsDir: string,
168
+ spaceId: string,
169
+ ): Promise<HeadRefReadResult> {
313
170
  try {
314
- appGraph = reconstructGraph(input.appMigrationPackages);
171
+ const headRef = await readContractSpaceHeadRef(migrationsDir, spaceId);
172
+ return { headRef, problem: null };
315
173
  } catch (error) {
316
- return notOk({
317
- kind: 'integrityFailure',
318
- spaceId: APP_SPACE_ID,
319
- detail: error instanceof Error ? error.message : String(error),
320
- });
174
+ if (!isToleratedRefHeadReadError(error)) {
175
+ throw error;
176
+ }
177
+ return { headRef: null, problem: { refName: HEAD_REF_NAME, detail: detailOf(error) } };
321
178
  }
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
- };
179
+ }
338
180
 
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
- }));
181
+ function detailOf(error: unknown): string {
182
+ return error instanceof Error ? error.message : String(error);
183
+ }
348
184
 
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
- }
185
+ /**
186
+ * Read the raw on-disk contract eagerly (cheap I/O) but defer its
187
+ * (throwing) failure to call time, so a missing or unparseable
188
+ * `contract.json` becomes a `contract()` throw — surfaced as
189
+ * `contractUnreadable` rather than a construction failure.
190
+ */
191
+ async function readRawContractDeferred(
192
+ migrationsDir: string,
193
+ spaceId: string,
194
+ ): Promise<() => unknown> {
195
+ try {
196
+ const raw = await readContractSpaceContract(migrationsDir, spaceId);
197
+ return () => raw;
198
+ } catch (error) {
199
+ return () => {
200
+ throw error;
201
+ };
367
202
  }
368
-
369
- return ok({
370
- aggregate: {
371
- targetId: input.targetId,
372
- app: appMember,
373
- extensions: extensionMembers,
374
- },
375
- });
376
203
  }
@@ -14,7 +14,7 @@ import type { ContractMarkerRecordLike } from './marker-types';
14
14
  import type { ContractSpaceAggregate } from './types';
15
15
 
16
16
  /**
17
- * Caller-provided policy for {@link planAggregate}. Today this carries
17
+ * Caller-provided policy for {@link planMigration}. Today this carries
18
18
  * just one knob:
19
19
  *
20
20
  * - `ignoreGraphFor`: `Set<spaceId>`. For listed members, the planner
@@ -55,7 +55,7 @@ export interface AggregateCurrentDBState {
55
55
  }
56
56
 
57
57
  /**
58
- * Inputs to {@link planAggregate}.
58
+ * Inputs to {@link planMigration}.
59
59
  *
60
60
  * The planner is target-agnostic but family-aware: per-member synth
61
61
  * delegates to the family's `createPlanner(familyInstance).plan(...)`,
@@ -64,11 +64,11 @@ export interface AggregateCurrentDBState {
64
64
  * threaded through. (`frameworkComponents` is passed verbatim into
65
65
  * `planner.plan(...)` per ADR 212; the planner does not interpret it.)
66
66
  *
67
- * The aggregate planner does **not** receive a `targetId` separately —
67
+ * The planner does **not** receive a `targetId` separately —
68
68
  * it reads `aggregate.targetId` and stamps it onto every emitted
69
69
  * `MigrationPlan` from construction. No placeholder, no patch step.
70
70
  */
71
- export interface AggregatePlannerInput<TFamilyId extends string, TTargetId extends string> {
71
+ export interface PlannerInput<TFamilyId extends string, TTargetId extends string> {
72
72
  readonly aggregate: ContractSpaceAggregate;
73
73
  readonly currentDBState: AggregateCurrentDBState;
74
74
  readonly familyInstance: ControlFamilyInstance<TFamilyId, unknown>;
@@ -83,7 +83,7 @@ export interface AggregatePlannerInput<TFamilyId extends string, TTargetId exten
83
83
  }
84
84
 
85
85
  /**
86
- * Per-member output of the aggregate planner. The runner ingests this
86
+ * Per-member output of the planner. The runner ingests this
87
87
  * shape directly via a thin `toRunnerInput` adapter at the CLI.
88
88
  *
89
89
  * - `plan`: ready-to-execute `MigrationPlan` with `targetId` already
@@ -99,8 +99,8 @@ export interface AggregatePlannerInput<TFamilyId extends string, TTargetId exten
99
99
  /**
100
100
  * Per-edge metadata for the chain assembled by the graph-walk
101
101
  * strategy. Lets `migrate` surface a per-migration `applied[]`
102
- * entry (preserving the single-space `migrationsApplied` count
103
- * semantics) without re-walking the graph.
102
+ * entry (preserving the `migrationsApplied` count semantics) without
103
+ * re-walking the graph.
104
104
  *
105
105
  * `synth`-produced plans leave this absent — synthesised plans don't
106
106
  * have authored edges to surface.
@@ -113,7 +113,7 @@ export interface AggregateMigrationEdgeRef {
113
113
  readonly operationCount: number;
114
114
  }
115
115
 
116
- export interface AggregatePerSpacePlan {
116
+ export interface PerSpacePlan {
117
117
  readonly plan: MigrationPlan;
118
118
  readonly displayOps: readonly MigrationPlanOperation[];
119
119
  readonly destinationContract: Contract;
@@ -136,13 +136,13 @@ export interface AggregatePerSpacePlan {
136
136
  readonly pathDecision?: PathDecision;
137
137
  }
138
138
 
139
- export interface AggregatePlannerSuccess {
140
- readonly perSpace: ReadonlyMap<string, AggregatePerSpacePlan>;
139
+ export interface PlannerSuccess {
140
+ readonly perSpace: ReadonlyMap<string, PerSpacePlan>;
141
141
  /**
142
142
  * `applyOrder` is the order the runner must walk per-space inputs.
143
143
  * Mirrors the existing `concatenateSpaceApplyInputs` convention:
144
144
  * extensions alphabetically by `spaceId`, then the app. Tests assert
145
- * on `MultiSpaceRunnerFailure.failingSpace`, which is positional in
145
+ * on `MigrationRunnerFailure.failingSpace`, which is positional in
146
146
  * the runner's input array — preserving the literal ordering keeps
147
147
  * `failingSpace` attribution byte-for-byte.
148
148
  */
@@ -150,11 +150,11 @@ export interface AggregatePlannerSuccess {
150
150
  }
151
151
 
152
152
  /**
153
- * Discriminated failure variants for {@link planAggregate}. Each
153
+ * Discriminated failure variants for {@link planMigration}. Each
154
154
  * variant short-circuits the plan; per-member errors carry the
155
155
  * `spaceId` so the CLI can surface a precise envelope.
156
156
  */
157
- export type AggregatePlannerError =
157
+ export type PlannerError =
158
158
  | { readonly kind: 'extensionPathUnreachable'; readonly spaceId: string; readonly target: string }
159
159
  | {
160
160
  readonly kind: 'extensionPathUnsatisfiable';
@@ -168,4 +168,4 @@ export type AggregatePlannerError =
168
168
  }
169
169
  | { readonly kind: 'policyConflict'; readonly spaceId: string; readonly detail: string };
170
170
 
171
- export type AggregatePlannerOutput = Result<AggregatePlannerSuccess, AggregatePlannerError>;
171
+ export type PlannerOutput = Result<PlannerSuccess, PlannerError>;