@prisma-next/migration-tools 0.11.0-dev.9 → 0.12.0-dev.1
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-CoEN114u.mjs → errors-vFROOhCR.mjs} +34 -21
- package/dist/{errors-CoEN114u.mjs.map → errors-vFROOhCR.mjs.map} +1 -1
- 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 +2 -2
- package/dist/exports/migration-graph.d.mts.map +1 -1
- package/dist/exports/migration-graph.mjs +2 -15
- 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 +2 -2
- package/dist/exports/refs.d.mts.map +1 -1
- package/dist/exports/refs.mjs +3 -137
- 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-lbJddL-S.mjs → invariants-C23nXy1c.mjs} +2 -2
- package/dist/{invariants-lbJddL-S.mjs.map → invariants-C23nXy1c.mjs.map} +1 -1
- package/dist/{io-gHmDrSjQ.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-C2iNX8dk.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-Cglige7P.mjs → read-contract-space-contract-TbeXuJXL.mjs} +32 -5
- package/dist/read-contract-space-contract-TbeXuJXL.mjs.map +1 -0
- package/dist/{refs-HhOkD8BT.mjs → refs-C-_WUrPw.mjs} +96 -3
- 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 +43 -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/refs.ts +3 -0
- package/src/exports/spaces.ts +3 -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 +3 -1
- package/src/refs.ts +121 -0
- package/src/space-layout.ts +30 -0
- package/dist/exports/io.d.mts.map +0 -1
- package/dist/exports/migration-graph.mjs.map +0 -1
- package/dist/exports/refs.mjs.map +0 -1
- package/dist/graph-BrLXqoUc.d.mts.map +0 -1
- package/dist/hash-Cr4WIr4Z.mjs.map +0 -1
- package/dist/io-gHmDrSjQ.mjs.map +0 -1
- package/dist/metadata-BFX0xdz8.d.mts +0 -2
- package/dist/migration-graph-C2iNX8dk.mjs.map +0 -1
- package/dist/migration-graph-De0dUZoC.d.mts.map +0 -1
- package/dist/package-DZj8YvD0.d.mts.map +0 -1
- package/dist/read-contract-space-contract-Cglige7P.mjs.map +0 -1
- package/dist/refs-CDaNerhT.d.mts +0 -16
- package/dist/refs-CDaNerhT.d.mts.map +0 -1
- package/dist/refs-HhOkD8BT.mjs.map +0 -1
- package/src/aggregate/extract-storage-element-names.ts +0 -75
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
3
|
+
import { join } from 'pathe';
|
|
4
|
+
import {
|
|
5
|
+
errorBundleNotFoundForGraphNode,
|
|
6
|
+
errorContractDeserializationFailed,
|
|
7
|
+
errorHashNotInGraph,
|
|
8
|
+
errorInvalidJson,
|
|
9
|
+
errorMissingFile,
|
|
10
|
+
errorSnapshotMissing,
|
|
11
|
+
MigrationToolsError,
|
|
12
|
+
} from '../errors';
|
|
13
|
+
import type { MigrationGraph } from '../graph';
|
|
14
|
+
import { isGraphNode } from '../graph-membership';
|
|
15
|
+
import type { IntegrityQueryOptions, IntegrityViolation } from '../integrity-violation';
|
|
16
|
+
import { reconstructGraph } from '../migration-graph';
|
|
17
|
+
import type { OnDiskMigrationPackage } from '../package';
|
|
18
|
+
import type { Refs } from '../refs';
|
|
19
|
+
import { readRefSnapshot } from '../refs/snapshot';
|
|
20
|
+
import type { ContractSpaceHeadRecord } from '../verify-contract-spaces';
|
|
21
|
+
import type {
|
|
22
|
+
ContractAtOptions,
|
|
23
|
+
ContractAtResult,
|
|
24
|
+
ContractSpaceAggregate,
|
|
25
|
+
ContractSpaceMember,
|
|
26
|
+
} from './types';
|
|
27
|
+
|
|
28
|
+
function hasErrnoCode(error: unknown, code: string): boolean {
|
|
29
|
+
return error instanceof Error && (error as { code?: string }).code === code;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function contractAtMemoKey(hash: string, refName: string | undefined): string {
|
|
33
|
+
return `${hash}\0${refName ?? ''}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function deserializeContractAtPath(
|
|
37
|
+
filePath: string,
|
|
38
|
+
contractJson: unknown,
|
|
39
|
+
deserializeContract: (raw: unknown) => Contract,
|
|
40
|
+
): Contract {
|
|
41
|
+
try {
|
|
42
|
+
return deserializeContract(contractJson);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (MigrationToolsError.is(error)) {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
throw errorContractDeserializationFailed(filePath, message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readGraphNodeEndContract(
|
|
53
|
+
packageDir: string,
|
|
54
|
+
deserializeContract: (raw: unknown) => Contract,
|
|
55
|
+
): Promise<{ contractJson: unknown; contractDts: string; contract: Contract }> {
|
|
56
|
+
const jsonPath = join(packageDir, 'end-contract.json');
|
|
57
|
+
const dtsPath = join(packageDir, 'end-contract.d.ts');
|
|
58
|
+
|
|
59
|
+
let rawJson: string;
|
|
60
|
+
try {
|
|
61
|
+
rawJson = await readFile(jsonPath, 'utf-8');
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (hasErrnoCode(error, 'ENOENT')) {
|
|
64
|
+
throw errorMissingFile('end-contract.json', packageDir);
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let contractJson: unknown;
|
|
70
|
+
try {
|
|
71
|
+
contractJson = JSON.parse(rawJson);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw errorInvalidJson(jsonPath, error instanceof Error ? error.message : String(error));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let contractDts: string;
|
|
77
|
+
try {
|
|
78
|
+
contractDts = await readFile(dtsPath, 'utf-8');
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (hasErrnoCode(error, 'ENOENT')) {
|
|
81
|
+
throw errorMissingFile('end-contract.d.ts', packageDir);
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const contract = deserializeContractAtPath(jsonPath, contractJson, deserializeContract);
|
|
87
|
+
return { contractJson, contractDts, contract };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function resolveContractAt(args: {
|
|
91
|
+
readonly hash: string;
|
|
92
|
+
readonly opts: ContractAtOptions | undefined;
|
|
93
|
+
readonly refsDir: string;
|
|
94
|
+
readonly packages: readonly OnDiskMigrationPackage[];
|
|
95
|
+
readonly graph: MigrationGraph;
|
|
96
|
+
readonly deserializeContract: (raw: unknown) => Contract;
|
|
97
|
+
}): Promise<ContractAtResult> {
|
|
98
|
+
const { hash, opts, refsDir, packages, graph, deserializeContract } = args;
|
|
99
|
+
const refName = opts?.refName;
|
|
100
|
+
|
|
101
|
+
if (refName !== undefined) {
|
|
102
|
+
const snapshot = await readRefSnapshot(refsDir, refName);
|
|
103
|
+
if (snapshot) {
|
|
104
|
+
const jsonPath = join(refsDir, `${refName}.contract.json`);
|
|
105
|
+
return {
|
|
106
|
+
hash,
|
|
107
|
+
contractJson: snapshot.contract,
|
|
108
|
+
contractDts: snapshot.contractDts,
|
|
109
|
+
contract: deserializeContractAtPath(jsonPath, snapshot.contract, deserializeContract),
|
|
110
|
+
provenance: 'snapshot',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (isGraphNode(hash, graph)) {
|
|
115
|
+
return resolveGraphNodeContractAt({
|
|
116
|
+
hash,
|
|
117
|
+
packages,
|
|
118
|
+
deserializeContract,
|
|
119
|
+
explicitLabel: refName,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw errorSnapshotMissing(refName);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (isGraphNode(hash, graph)) {
|
|
127
|
+
return resolveGraphNodeContractAt({ hash, packages, deserializeContract });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
throw errorHashNotInGraph(hash, graph);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function resolveGraphNodeContractAt(args: {
|
|
134
|
+
readonly hash: string;
|
|
135
|
+
readonly packages: readonly OnDiskMigrationPackage[];
|
|
136
|
+
readonly deserializeContract: (raw: unknown) => Contract;
|
|
137
|
+
readonly explicitLabel?: string;
|
|
138
|
+
}): Promise<ContractAtResult> {
|
|
139
|
+
const { hash, packages, deserializeContract, explicitLabel } = args;
|
|
140
|
+
const matchingBundle = packages.find((pkg) => pkg.metadata.to === hash);
|
|
141
|
+
if (!matchingBundle) {
|
|
142
|
+
throw errorBundleNotFoundForGraphNode(hash, explicitLabel);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { contractJson, contractDts, contract } = await readGraphNodeEndContract(
|
|
146
|
+
matchingBundle.dirPath,
|
|
147
|
+
deserializeContract,
|
|
148
|
+
);
|
|
149
|
+
return {
|
|
150
|
+
hash,
|
|
151
|
+
contractJson,
|
|
152
|
+
contractDts,
|
|
153
|
+
contract,
|
|
154
|
+
provenance: 'graph-node',
|
|
155
|
+
sourceDir: matchingBundle.dirPath,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Resolve a member's head ref, asserting it is present. The apply/verify
|
|
161
|
+
* engine only runs after `checkIntegrity` has refused on `headRefMissing`,
|
|
162
|
+
* so a member reaching the planner / verifier without a head ref is a
|
|
163
|
+
* programming error (the integrity gate was skipped), not a user-facing
|
|
164
|
+
* state. The app member's head ref is always synthesised, so this only
|
|
165
|
+
* ever guards an ungated extension space.
|
|
166
|
+
*/
|
|
167
|
+
export function requireHeadRef(member: ContractSpaceMember): ContractSpaceHeadRecord {
|
|
168
|
+
if (member.headRef === null) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Contract space "${member.spaceId}" has no head ref; the integrity gate must refuse a missing head ref before planning or verifying.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return member.headRef;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build a {@link ContractSpaceMember} with lazily-memoised `graph()`,
|
|
178
|
+
* `contract()`, and `contractAt()` facets.
|
|
179
|
+
*
|
|
180
|
+
* `graph()` reconstructs the migration graph from `packages` on first
|
|
181
|
+
* call and caches it. `contract()` calls `resolveContract` on first call
|
|
182
|
+
* and caches the result; a throwing `resolveContract` (e.g. a missing or
|
|
183
|
+
* undeserializable on-disk contract) re-throws on each call rather than
|
|
184
|
+
* caching a value — `checkIntegrity` surfaces that as `contractUnreadable`.
|
|
185
|
+
* `contractAt()` materializes the contract at an arbitrary graph node with
|
|
186
|
+
* the same resolution order as plan-time ref resolution: ref snapshot first
|
|
187
|
+
* (when `opts.refName` is set), else the matching package's `end-contract.*`.
|
|
188
|
+
*/
|
|
189
|
+
export function createContractSpaceMember(args: {
|
|
190
|
+
readonly spaceId: string;
|
|
191
|
+
readonly packages: readonly OnDiskMigrationPackage[];
|
|
192
|
+
readonly refs: Refs;
|
|
193
|
+
readonly headRef: ContractSpaceHeadRecord | null;
|
|
194
|
+
readonly refsDir: string;
|
|
195
|
+
readonly resolveContract: () => Contract;
|
|
196
|
+
readonly deserializeContract: (raw: unknown) => Contract;
|
|
197
|
+
}): ContractSpaceMember {
|
|
198
|
+
const { spaceId, packages, refs, headRef, refsDir, resolveContract, deserializeContract } = args;
|
|
199
|
+
let graphMemo: MigrationGraph | undefined;
|
|
200
|
+
let contractMemo: Contract | undefined;
|
|
201
|
+
const contractAtMemo = new Map<string, ContractAtResult>();
|
|
202
|
+
|
|
203
|
+
function memberGraph(): MigrationGraph {
|
|
204
|
+
graphMemo ??= reconstructGraph(packages);
|
|
205
|
+
return graphMemo;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
spaceId,
|
|
210
|
+
packages,
|
|
211
|
+
refs,
|
|
212
|
+
headRef,
|
|
213
|
+
graph: memberGraph,
|
|
214
|
+
contract() {
|
|
215
|
+
contractMemo ??= resolveContract();
|
|
216
|
+
return contractMemo;
|
|
217
|
+
},
|
|
218
|
+
async contractAt(hash, opts) {
|
|
219
|
+
const key = contractAtMemoKey(hash, opts?.refName);
|
|
220
|
+
const cached = contractAtMemo.get(key);
|
|
221
|
+
if (cached) {
|
|
222
|
+
return cached;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const result = await resolveContractAt({
|
|
226
|
+
hash,
|
|
227
|
+
opts,
|
|
228
|
+
refsDir,
|
|
229
|
+
packages,
|
|
230
|
+
graph: memberGraph(),
|
|
231
|
+
deserializeContract,
|
|
232
|
+
});
|
|
233
|
+
contractAtMemo.set(key, result);
|
|
234
|
+
return result;
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Assemble a {@link ContractSpaceAggregate} value from its members and a
|
|
241
|
+
* `checkIntegrity` implementation. The query methods (`listSpaces` /
|
|
242
|
+
* `hasSpace` / `space` / `spaces`) are derived here so every aggregate —
|
|
243
|
+
* loader-built or test-built — shares one query surface: `app` first,
|
|
244
|
+
* then `extensions` in the order supplied (the loader sorts them
|
|
245
|
+
* lex-ascending by `spaceId`).
|
|
246
|
+
*/
|
|
247
|
+
export function createContractSpaceAggregate(args: {
|
|
248
|
+
readonly targetId: string;
|
|
249
|
+
readonly app: ContractSpaceMember;
|
|
250
|
+
readonly extensions: readonly ContractSpaceMember[];
|
|
251
|
+
readonly checkIntegrity: (opts?: IntegrityQueryOptions) => readonly IntegrityViolation[];
|
|
252
|
+
}): ContractSpaceAggregate {
|
|
253
|
+
const { targetId, app, extensions, checkIntegrity } = args;
|
|
254
|
+
const ordered: readonly ContractSpaceMember[] = [app, ...extensions];
|
|
255
|
+
const byId = new Map(ordered.map((m) => [m.spaceId, m]));
|
|
256
|
+
return {
|
|
257
|
+
targetId,
|
|
258
|
+
app,
|
|
259
|
+
extensions,
|
|
260
|
+
listSpaces: () => ordered.map((m) => m.spaceId),
|
|
261
|
+
hasSpace: (id) => byId.has(id),
|
|
262
|
+
space: (id) => byId.get(id),
|
|
263
|
+
spaces: () => ordered,
|
|
264
|
+
checkIntegrity,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { elementCoordinates } from '@prisma-next/framework-components/ir';
|
|
2
|
+
import { EMPTY_CONTRACT_HASH } from '../constants';
|
|
3
|
+
import { MigrationToolsError } from '../errors';
|
|
4
|
+
import type {
|
|
5
|
+
DeclaredExtensionEntry,
|
|
6
|
+
IntegrityQueryOptions,
|
|
7
|
+
IntegrityViolation,
|
|
8
|
+
} from '../integrity-violation';
|
|
9
|
+
import type { PackageLoadProblem } from '../io';
|
|
10
|
+
import type { OnDiskMigrationPackage } from '../package';
|
|
11
|
+
import type { RefLoadProblem } from '../refs';
|
|
12
|
+
import type { ContractSpaceMember } from './types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One space's load-time facts that `checkIntegrity` judges: the loaded
|
|
16
|
+
* member, the load-time problems `readMigrationsDir` surfaced for it, and
|
|
17
|
+
* whether it is the app space (the app head ref is synthesised, so the
|
|
18
|
+
* head-ref checks are skipped for it).
|
|
19
|
+
*/
|
|
20
|
+
export interface IntegritySpaceState {
|
|
21
|
+
readonly member: ContractSpaceMember;
|
|
22
|
+
readonly problems: readonly PackageLoadProblem[];
|
|
23
|
+
/** Per-ref problems: a user ref `*.json` that exists but is unparseable. */
|
|
24
|
+
readonly refProblems: readonly RefLoadProblem[];
|
|
25
|
+
/**
|
|
26
|
+
* The space's `refs/head.json` problem when it exists but is unparseable.
|
|
27
|
+
* `null` means the head ref was read cleanly or is genuinely absent —
|
|
28
|
+
* the absent case is judged `headRefMissing`, the corrupt case here is
|
|
29
|
+
* judged `refUnreadable` (and suppresses `headRefMissing`).
|
|
30
|
+
*/
|
|
31
|
+
readonly headRefProblem: RefLoadProblem | null;
|
|
32
|
+
readonly isApp: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IntegrityComputationInput {
|
|
36
|
+
readonly targetId: string;
|
|
37
|
+
readonly spaces: readonly IntegritySpaceState[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Walk the loaded model and return **every** integrity violation — never
|
|
42
|
+
* bailing at the first. Structurally-derivable violations (load-time
|
|
43
|
+
* problems, self-edges, missing / unreachable head refs) are always
|
|
44
|
+
* produced; layout-drift checks require `declaredExtensions`, and
|
|
45
|
+
* contract / target / disjointness checks require `checkContracts`.
|
|
46
|
+
*/
|
|
47
|
+
export function computeIntegrityViolations(
|
|
48
|
+
input: IntegrityComputationInput,
|
|
49
|
+
opts?: IntegrityQueryOptions,
|
|
50
|
+
): readonly IntegrityViolation[] {
|
|
51
|
+
const violations: IntegrityViolation[] = [];
|
|
52
|
+
|
|
53
|
+
for (const { member, problems, refProblems, headRefProblem, isApp } of input.spaces) {
|
|
54
|
+
const { spaceId } = member;
|
|
55
|
+
|
|
56
|
+
for (const problem of problems) {
|
|
57
|
+
violations.push(loadProblemToViolation(spaceId, problem));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const refProblem of refProblems) {
|
|
61
|
+
violations.push({
|
|
62
|
+
kind: 'refUnreadable',
|
|
63
|
+
spaceId,
|
|
64
|
+
refName: refProblem.refName,
|
|
65
|
+
detail: refProblem.detail,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (headRefProblem !== null) {
|
|
69
|
+
violations.push({
|
|
70
|
+
kind: 'refUnreadable',
|
|
71
|
+
spaceId,
|
|
72
|
+
refName: headRefProblem.refName,
|
|
73
|
+
detail: headRefProblem.detail,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const pkg of member.packages) {
|
|
78
|
+
const from = pkg.metadata.from ?? EMPTY_CONTRACT_HASH;
|
|
79
|
+
const isSelfEdge = from === pkg.metadata.to;
|
|
80
|
+
const hasDataOp = pkg.ops.some((op) => op.operationClass === 'data');
|
|
81
|
+
if (isSelfEdge && !hasDataOp) {
|
|
82
|
+
violations.push({ kind: 'sameSourceAndTarget', spaceId, dirName: pkg.dirName, hash: from });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
violations.push(...duplicateMigrationHashViolations(spaceId, member.packages));
|
|
87
|
+
|
|
88
|
+
// The app head ref is synthesised from the live contract, so it is
|
|
89
|
+
// always present and reachable; only extension spaces read their head
|
|
90
|
+
// ref from disk and can be missing or point outside the graph. A head
|
|
91
|
+
// ref that exists but is unparseable is already surfaced above as
|
|
92
|
+
// `refUnreadable`, so it is not also reported as `headRefMissing`.
|
|
93
|
+
if (!isApp && headRefProblem === null) {
|
|
94
|
+
if (member.headRef === null) {
|
|
95
|
+
violations.push({ kind: 'headRefMissing', spaceId });
|
|
96
|
+
} else if (!headRefPresentInGraph(member, member.headRef.hash)) {
|
|
97
|
+
violations.push({ kind: 'headRefNotInGraph', spaceId, hash: member.headRef.hash });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (opts?.declaredExtensions !== undefined) {
|
|
103
|
+
violations.push(...layoutViolations(input.spaces, opts.declaredExtensions));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (opts?.checkContracts === true) {
|
|
107
|
+
violations.push(...contractViolations(input));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return violations;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function loadProblemToViolation(
|
|
114
|
+
spaceId: string,
|
|
115
|
+
problem: PackageLoadProblem,
|
|
116
|
+
): IntegrityViolation {
|
|
117
|
+
switch (problem.kind) {
|
|
118
|
+
case 'hashMismatch':
|
|
119
|
+
return {
|
|
120
|
+
kind: 'hashMismatch',
|
|
121
|
+
spaceId,
|
|
122
|
+
dirName: problem.dirName,
|
|
123
|
+
stored: problem.stored,
|
|
124
|
+
computed: problem.computed,
|
|
125
|
+
};
|
|
126
|
+
case 'providedInvariantsMismatch':
|
|
127
|
+
return { kind: 'providedInvariantsMismatch', spaceId, dirName: problem.dirName };
|
|
128
|
+
case 'packageUnloadable':
|
|
129
|
+
return {
|
|
130
|
+
kind: 'packageUnloadable',
|
|
131
|
+
spaceId,
|
|
132
|
+
dirName: problem.dirName,
|
|
133
|
+
detail: problem.detail,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function duplicateMigrationHashViolations(
|
|
139
|
+
spaceId: string,
|
|
140
|
+
packages: readonly OnDiskMigrationPackage[],
|
|
141
|
+
): readonly IntegrityViolation[] {
|
|
142
|
+
const dirNamesByHash = new Map<string, string[]>();
|
|
143
|
+
for (const pkg of packages) {
|
|
144
|
+
const hash = pkg.metadata.migrationHash;
|
|
145
|
+
const dirNames = dirNamesByHash.get(hash);
|
|
146
|
+
if (dirNames) dirNames.push(pkg.dirName);
|
|
147
|
+
else dirNamesByHash.set(hash, [pkg.dirName]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const out: IntegrityViolation[] = [];
|
|
151
|
+
for (const [migrationHash, dirNames] of dirNamesByHash) {
|
|
152
|
+
if (dirNames.length > 1) {
|
|
153
|
+
out.push({
|
|
154
|
+
kind: 'duplicateMigrationHash',
|
|
155
|
+
spaceId,
|
|
156
|
+
migrationHash,
|
|
157
|
+
dirNames: [...dirNames].sort(),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Whether a space's head-ref hash is present in its reconstructed graph.
|
|
166
|
+
* An empty graph is reachable only by the empty-contract sentinel.
|
|
167
|
+
*/
|
|
168
|
+
function headRefPresentInGraph(member: ContractSpaceMember, headHash: string): boolean {
|
|
169
|
+
const graph = member.graph();
|
|
170
|
+
if (graph.nodes.size === 0) {
|
|
171
|
+
return headHash === EMPTY_CONTRACT_HASH;
|
|
172
|
+
}
|
|
173
|
+
return graph.nodes.has(headHash);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function layoutViolations(
|
|
177
|
+
spaces: readonly IntegritySpaceState[],
|
|
178
|
+
declaredExtensions: readonly DeclaredExtensionEntry[],
|
|
179
|
+
): readonly IntegrityViolation[] {
|
|
180
|
+
const out: IntegrityViolation[] = [];
|
|
181
|
+
const extensionSpaceIds = new Set(spaces.filter((s) => !s.isApp).map((s) => s.member.spaceId));
|
|
182
|
+
const declaredIds = new Set(declaredExtensions.map((d) => d.id));
|
|
183
|
+
|
|
184
|
+
for (const id of [...extensionSpaceIds].sort()) {
|
|
185
|
+
if (!declaredIds.has(id)) {
|
|
186
|
+
out.push({ kind: 'orphanSpaceDir', spaceId: id });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const id of [...declaredIds].sort()) {
|
|
190
|
+
if (!extensionSpaceIds.has(id)) {
|
|
191
|
+
out.push({ kind: 'declaredButUnmigrated', spaceId: id });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function contractViolations(input: IntegrityComputationInput): readonly IntegrityViolation[] {
|
|
198
|
+
const out: IntegrityViolation[] = [];
|
|
199
|
+
const elementClaimedBy = new Map<string, string[]>();
|
|
200
|
+
|
|
201
|
+
for (const { member } of input.spaces) {
|
|
202
|
+
let contract: ReturnType<ContractSpaceMember['contract']>;
|
|
203
|
+
try {
|
|
204
|
+
contract = member.contract();
|
|
205
|
+
} catch (error) {
|
|
206
|
+
out.push({ kind: 'contractUnreadable', spaceId: member.spaceId, detail: detailOf(error) });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (contract.target !== input.targetId) {
|
|
211
|
+
out.push({
|
|
212
|
+
kind: 'targetMismatch',
|
|
213
|
+
spaceId: member.spaceId,
|
|
214
|
+
expected: input.targetId,
|
|
215
|
+
actual: contract.target,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const { entityName: elementName } of elementCoordinates(contract.storage)) {
|
|
220
|
+
const claimers = elementClaimedBy.get(elementName);
|
|
221
|
+
if (claimers) claimers.push(member.spaceId);
|
|
222
|
+
else elementClaimedBy.set(elementName, [member.spaceId]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const disjointness: IntegrityViolation[] = [];
|
|
227
|
+
for (const [element, claimedBy] of elementClaimedBy) {
|
|
228
|
+
if (claimedBy.length > 1) {
|
|
229
|
+
disjointness.push({ kind: 'disjointness', element, claimedBy: [...claimedBy].sort() });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
disjointness.sort((a, b) =>
|
|
233
|
+
a.kind === 'disjointness' && b.kind === 'disjointness' ? a.element.localeCompare(b.element) : 0,
|
|
234
|
+
);
|
|
235
|
+
out.push(...disjointness);
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function detailOf(error: unknown): string {
|
|
240
|
+
if (MigrationToolsError.is(error)) return error.why;
|
|
241
|
+
if (error instanceof Error) return error.message;
|
|
242
|
+
return String(error);
|
|
243
|
+
}
|