@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.
- package/README.md +4 -4
- package/dist/{errors-DGYwcwXs.mjs → errors-vFROOhCR.mjs} +46 -21
- package/dist/errors-vFROOhCR.mjs.map +1 -0
- package/dist/exports/aggregate.d.mts +328 -204
- package/dist/exports/aggregate.d.mts.map +1 -1
- package/dist/exports/aggregate.mjs +480 -243
- package/dist/exports/aggregate.mjs.map +1 -1
- package/dist/exports/errors.d.mts +2 -2
- package/dist/exports/errors.d.mts.map +1 -1
- package/dist/exports/errors.mjs +1 -1
- package/dist/exports/graph.d.mts +1 -1
- package/dist/exports/hash.d.mts +8 -9
- package/dist/exports/hash.d.mts.map +1 -1
- package/dist/exports/hash.mjs +1 -1
- package/dist/exports/invariants.d.mts +1 -1
- package/dist/exports/invariants.d.mts.map +1 -1
- package/dist/exports/invariants.mjs +1 -1
- package/dist/exports/io.d.mts +2 -83
- package/dist/exports/io.mjs +1 -1
- package/dist/exports/metadata.d.mts +2 -2
- package/dist/exports/migration-graph.d.mts +9 -2
- package/dist/exports/migration-graph.d.mts.map +1 -0
- package/dist/exports/migration-graph.mjs +3 -2
- package/dist/exports/migration-ts.d.mts.map +1 -1
- package/dist/exports/migration-ts.mjs.map +1 -1
- package/dist/exports/migration.d.mts +5 -6
- package/dist/exports/migration.d.mts.map +1 -1
- package/dist/exports/migration.mjs +14 -32
- package/dist/exports/migration.mjs.map +1 -1
- package/dist/exports/package.d.mts +1 -1
- package/dist/exports/ref-resolution.d.mts +2 -2
- package/dist/exports/ref-resolution.d.mts.map +1 -1
- package/dist/exports/ref-resolution.mjs +1 -1
- package/dist/exports/ref-resolution.mjs.map +1 -1
- package/dist/exports/refs.d.mts +15 -2
- package/dist/exports/refs.d.mts.map +1 -0
- package/dist/exports/refs.mjs +3 -2
- package/dist/exports/spaces.d.mts +31 -132
- package/dist/exports/spaces.d.mts.map +1 -1
- package/dist/exports/spaces.mjs +13 -9
- package/dist/exports/spaces.mjs.map +1 -1
- package/dist/{graph-BrLXqoUc.d.mts → graph-3dLMZp5l.d.mts} +1 -2
- package/dist/graph-3dLMZp5l.d.mts.map +1 -0
- package/dist/graph-membership-BV23F1IV.mjs +15 -0
- package/dist/graph-membership-BV23F1IV.mjs.map +1 -0
- package/dist/{hash-Cr4WIr4Z.mjs → hash--Y7vCpN3.mjs} +8 -9
- package/dist/hash--Y7vCpN3.mjs.map +1 -0
- package/dist/{invariants-0daYEzyo.mjs → invariants-C23nXy1c.mjs} +2 -2
- package/dist/{invariants-0daYEzyo.mjs.map → invariants-C23nXy1c.mjs.map} +1 -1
- package/dist/{io-BPLfzvZe.mjs → io-BGlPOt9b.mjs} +100 -13
- package/dist/io-BGlPOt9b.mjs.map +1 -0
- package/dist/io-BH4G3F-i.d.mts +124 -0
- package/dist/io-BH4G3F-i.d.mts.map +1 -0
- package/dist/metadata-Bp9X04gM.d.mts +2 -0
- package/dist/{migration-graph-nlS4TRpn.mjs → migration-graph-BMAqSfv9.mjs} +6 -26
- package/dist/migration-graph-BMAqSfv9.mjs.map +1 -0
- package/dist/{migration-graph-De0dUZoC.d.mts → migration-graph-CWEM2SLR.d.mts} +6 -6
- package/dist/migration-graph-CWEM2SLR.d.mts.map +1 -0
- package/dist/op-schema-D5qkXfEf.mjs.map +1 -1
- package/dist/{package-DZj8YvD0.d.mts → package-Ca-J_z_0.d.mts} +1 -1
- package/dist/package-Ca-J_z_0.d.mts.map +1 -0
- package/dist/{read-contract-space-contract-DRueB4Aa.mjs → read-contract-space-contract-TbeXuJXL.mjs} +32 -5
- package/dist/read-contract-space-contract-TbeXuJXL.mjs.map +1 -0
- package/dist/{refs-BDHo5l_g.mjs → refs-C-_WUrPw.mjs} +97 -4
- package/dist/refs-C-_WUrPw.mjs.map +1 -0
- package/dist/refs-C7wuYFqZ.d.mts +42 -0
- package/dist/refs-C7wuYFqZ.d.mts.map +1 -0
- package/dist/snapshot-Bazwo13S.mjs +137 -0
- package/dist/snapshot-Bazwo13S.mjs.map +1 -0
- package/dist/verify-contract-spaces-BdysZdQk.d.mts +132 -0
- package/dist/verify-contract-spaces-BdysZdQk.d.mts.map +1 -0
- package/package.json +18 -9
- package/src/aggregate/aggregate.ts +266 -0
- package/src/aggregate/check-integrity.ts +243 -0
- package/src/aggregate/loader.ts +161 -334
- package/src/aggregate/planner-types.ts +14 -14
- package/src/aggregate/planner.ts +20 -23
- package/src/aggregate/project-schema-to-space.ts +3 -8
- package/src/aggregate/strategies/graph-walk.ts +15 -10
- package/src/aggregate/strategies/synth.ts +4 -4
- package/src/aggregate/types.ts +81 -62
- package/src/aggregate/verifier.ts +23 -23
- package/src/assert-descriptor-self-consistency.ts +6 -0
- package/src/compute-extension-space-apply-path.ts +1 -1
- package/src/emit-contract-space-artefacts.ts +4 -3
- package/src/errors.ts +58 -2
- package/src/exports/aggregate.ts +29 -19
- package/src/exports/io.ts +2 -0
- package/src/exports/metadata.ts +1 -1
- package/src/exports/migration-graph.ts +1 -0
- package/src/exports/refs.ts +11 -0
- package/src/exports/spaces.ts +3 -0
- package/src/graph-membership.ts +17 -0
- package/src/graph.ts +0 -1
- package/src/hash.ts +7 -8
- package/src/integrity-violation.ts +114 -0
- package/src/io.ts +139 -14
- package/src/metadata.ts +1 -1
- package/src/migration-base.ts +10 -30
- package/src/migration-graph.ts +7 -35
- package/src/read-contract-space-head-ref.ts +5 -2
- package/src/refs/snapshot.ts +199 -0
- package/src/refs.ts +124 -1
- package/src/space-layout.ts +30 -0
- package/dist/errors-DGYwcwXs.mjs.map +0 -1
- package/dist/exports/io.d.mts.map +0 -1
- package/dist/graph-BrLXqoUc.d.mts.map +0 -1
- package/dist/hash-Cr4WIr4Z.mjs.map +0 -1
- package/dist/io-BPLfzvZe.mjs.map +0 -1
- package/dist/metadata-BFX0xdz8.d.mts +0 -2
- package/dist/migration-graph-De0dUZoC.d.mts.map +0 -1
- package/dist/migration-graph-nlS4TRpn.mjs.map +0 -1
- package/dist/package-DZj8YvD0.d.mts.map +0 -1
- package/dist/read-contract-space-contract-DRueB4Aa.mjs.map +0 -1
- package/dist/refs-BDHo5l_g.mjs.map +0 -1
- package/dist/refs-CDaNerhT.d.mts +0 -16
- package/dist/refs-CDaNerhT.d.mts.map +0 -1
- package/src/aggregate/extract-storage-element-names.ts +0 -75
package/src/aggregate/loader.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
13
|
-
import
|
|
15
|
+
import { createContractSpaceAggregate, createContractSpaceMember } from './aggregate';
|
|
16
|
+
import { computeIntegrityViolations, type IntegritySpaceState } from './check-integrity';
|
|
17
|
+
import type { ContractSpaceAggregate } from './types';
|
|
14
18
|
|
|
15
|
-
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
*
|
|
134
|
-
*
|
|
38
|
+
* Build a tolerant, queryable {@link ContractSpaceAggregate} from on-disk
|
|
39
|
+
* migration state plus the caller's live app contract.
|
|
135
40
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
-
*
|
|
149
|
-
*
|
|
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<
|
|
154
|
-
|
|
155
|
-
const
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
312
|
-
|
|
166
|
+
async function readHeadRefTolerant(
|
|
167
|
+
migrationsDir: string,
|
|
168
|
+
spaceId: string,
|
|
169
|
+
): Promise<HeadRefReadResult> {
|
|
313
170
|
try {
|
|
314
|
-
|
|
171
|
+
const headRef = await readContractSpaceHeadRef(migrationsDir, spaceId);
|
|
172
|
+
return { headRef, problem: null };
|
|
315
173
|
} catch (error) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
});
|
|
174
|
+
if (!isToleratedRefHeadReadError(error)) {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
return { headRef: null, problem: { refName: HEAD_REF_NAME, detail: detailOf(error) } };
|
|
321
178
|
}
|
|
322
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
103
|
-
*
|
|
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
|
|
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
|
|
140
|
-
readonly perSpace: ReadonlyMap<string,
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
171
|
+
export type PlannerOutput = Result<PlannerSuccess, PlannerError>;
|