@prisma-next/migration-tools 0.5.0-dev.9 → 0.6.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 +34 -22
- package/dist/{constants-BRi0X7B_.mjs → constants-DWV9_o2Z.mjs} +2 -2
- package/dist/{constants-BRi0X7B_.mjs.map → constants-DWV9_o2Z.mjs.map} +1 -1
- package/dist/errors-EPL_9p9f.mjs +297 -0
- package/dist/errors-EPL_9p9f.mjs.map +1 -0
- package/dist/exports/aggregate.d.mts +614 -0
- package/dist/exports/aggregate.d.mts.map +1 -0
- package/dist/exports/aggregate.mjs +611 -0
- package/dist/exports/aggregate.mjs.map +1 -0
- package/dist/exports/constants.d.mts.map +1 -1
- package/dist/exports/constants.mjs +2 -3
- package/dist/exports/errors.d.mts +68 -0
- package/dist/exports/errors.d.mts.map +1 -0
- package/dist/exports/errors.mjs +2 -0
- package/dist/exports/graph.d.mts +2 -0
- package/dist/exports/graph.mjs +1 -0
- package/dist/exports/hash.d.mts +52 -0
- package/dist/exports/hash.d.mts.map +1 -0
- package/dist/exports/hash.mjs +2 -0
- package/dist/exports/invariants.d.mts +39 -0
- package/dist/exports/invariants.d.mts.map +1 -0
- package/dist/exports/invariants.mjs +2 -0
- package/dist/exports/io.d.mts +66 -6
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +2 -3
- package/dist/exports/metadata.d.mts +2 -0
- package/dist/exports/metadata.mjs +1 -0
- package/dist/exports/migration-graph.d.mts +2 -0
- package/dist/exports/migration-graph.mjs +2 -0
- package/dist/exports/migration-ts.d.mts.map +1 -1
- package/dist/exports/migration-ts.mjs +2 -4
- package/dist/exports/migration-ts.mjs.map +1 -1
- package/dist/exports/migration.d.mts +15 -14
- package/dist/exports/migration.d.mts.map +1 -1
- package/dist/exports/migration.mjs +70 -43
- package/dist/exports/migration.mjs.map +1 -1
- package/dist/exports/package.d.mts +3 -0
- package/dist/exports/package.mjs +1 -0
- package/dist/exports/refs.d.mts.map +1 -1
- package/dist/exports/refs.mjs +3 -4
- package/dist/exports/refs.mjs.map +1 -1
- package/dist/exports/spaces.d.mts +591 -0
- package/dist/exports/spaces.d.mts.map +1 -0
- package/dist/exports/spaces.mjs +266 -0
- package/dist/exports/spaces.mjs.map +1 -0
- package/dist/graph-HMWAldoR.d.mts +28 -0
- package/dist/graph-HMWAldoR.d.mts.map +1 -0
- package/dist/hash-By50zM_E.mjs +74 -0
- package/dist/hash-By50zM_E.mjs.map +1 -0
- package/dist/invariants-qgQGlsrV.mjs +57 -0
- package/dist/invariants-qgQGlsrV.mjs.map +1 -0
- package/dist/io-D5YYptRO.mjs +239 -0
- package/dist/io-D5YYptRO.mjs.map +1 -0
- package/dist/metadata-CFvm3ayn.d.mts +2 -0
- package/dist/migration-graph-DGNnKDY5.mjs +523 -0
- package/dist/migration-graph-DGNnKDY5.mjs.map +1 -0
- package/dist/migration-graph-DulOITvG.d.mts +124 -0
- package/dist/migration-graph-DulOITvG.d.mts.map +1 -0
- package/dist/op-schema-D5qkXfEf.mjs +13 -0
- package/dist/op-schema-D5qkXfEf.mjs.map +1 -0
- package/dist/package-BjiZ7KDy.d.mts +21 -0
- package/dist/package-BjiZ7KDy.d.mts.map +1 -0
- package/dist/read-contract-space-contract-Bj_EMYSC.mjs +298 -0
- package/dist/read-contract-space-contract-Bj_EMYSC.mjs.map +1 -0
- package/package.json +42 -17
- package/src/aggregate/loader.ts +409 -0
- package/src/aggregate/marker-types.ts +16 -0
- package/src/aggregate/planner-types.ts +171 -0
- package/src/aggregate/planner.ts +158 -0
- package/src/aggregate/project-schema-to-space.ts +64 -0
- package/src/aggregate/strategies/graph-walk.ts +118 -0
- package/src/aggregate/strategies/synth.ts +122 -0
- package/src/aggregate/types.ts +89 -0
- package/src/aggregate/verifier.ts +230 -0
- package/src/assert-descriptor-self-consistency.ts +70 -0
- package/src/compute-extension-space-apply-path.ts +152 -0
- package/src/concatenate-space-apply-inputs.ts +90 -0
- package/src/contract-space-from-json.ts +63 -0
- package/src/detect-space-contract-drift.ts +91 -0
- package/src/emit-contract-space-artefacts.ts +70 -0
- package/src/errors.ts +251 -17
- package/src/exports/aggregate.ts +42 -0
- package/src/exports/errors.ts +8 -0
- package/src/exports/graph.ts +1 -0
- package/src/exports/hash.ts +2 -0
- package/src/exports/invariants.ts +1 -0
- package/src/exports/io.ts +3 -1
- package/src/exports/metadata.ts +1 -0
- package/src/exports/{dag.ts → migration-graph.ts} +3 -2
- package/src/exports/migration.ts +0 -1
- package/src/exports/package.ts +2 -0
- package/src/exports/spaces.ts +50 -0
- package/src/gather-disk-contract-space-state.ts +62 -0
- package/src/graph-ops.ts +57 -30
- package/src/graph.ts +25 -0
- package/src/hash.ts +91 -0
- package/src/invariants.ts +61 -0
- package/src/io.ts +163 -40
- package/src/metadata.ts +1 -0
- package/src/migration-base.ts +97 -56
- package/src/migration-graph.ts +676 -0
- package/src/op-schema.ts +11 -0
- package/src/package.ts +21 -0
- package/src/plan-all-spaces.ts +76 -0
- package/src/read-contract-space-contract.ts +44 -0
- package/src/read-contract-space-head-ref.ts +63 -0
- package/src/space-layout.ts +48 -0
- package/src/verify-contract-spaces.ts +272 -0
- package/dist/attestation-BnzTb0Qp.mjs +0 -65
- package/dist/attestation-BnzTb0Qp.mjs.map +0 -1
- package/dist/errors-BmiSgz1j.mjs +0 -160
- package/dist/errors-BmiSgz1j.mjs.map +0 -1
- package/dist/exports/attestation.d.mts +0 -37
- package/dist/exports/attestation.d.mts.map +0 -1
- package/dist/exports/attestation.mjs +0 -4
- package/dist/exports/dag.d.mts +0 -51
- package/dist/exports/dag.d.mts.map +0 -1
- package/dist/exports/dag.mjs +0 -386
- package/dist/exports/dag.mjs.map +0 -1
- package/dist/exports/types.d.mts +0 -35
- package/dist/exports/types.d.mts.map +0 -1
- package/dist/exports/types.mjs +0 -3
- package/dist/io-Cd6GLyjK.mjs +0 -153
- package/dist/io-Cd6GLyjK.mjs.map +0 -1
- package/dist/types-DyGXcWWp.d.mts +0 -71
- package/dist/types-DyGXcWWp.d.mts.map +0 -1
- package/src/attestation.ts +0 -81
- package/src/dag.ts +0 -426
- package/src/exports/attestation.ts +0 -2
- package/src/exports/types.ts +0 -10
- package/src/types.ts +0 -66
|
@@ -0,0 +1,409 @@
|
|
|
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
|
+
import { detectSpaceContractDrift } from '../detect-space-contract-drift';
|
|
5
|
+
import { readMigrationsDir } from '../io';
|
|
6
|
+
import { reconstructGraph } from '../migration-graph';
|
|
7
|
+
import type { OnDiskMigrationPackage } from '../package';
|
|
8
|
+
import { readContractSpaceContract } from '../read-contract-space-contract';
|
|
9
|
+
import { readContractSpaceHeadRef } from '../read-contract-space-head-ref';
|
|
10
|
+
import { APP_SPACE_ID, spaceMigrationDirectory } from '../space-layout';
|
|
11
|
+
import { listContractSpaceDirectories } from '../verify-contract-spaces';
|
|
12
|
+
import type { ContractSpaceAggregate, ContractSpaceMember, HydratedMigrationGraph } from './types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Hash function used by drift detection. Defaults to a canonical-JSON +
|
|
16
|
+
* SHA-256 pipeline that mirrors the framework's contract-hash convention,
|
|
17
|
+
* but the loader accepts a callback so SQL-family callers can pass their
|
|
18
|
+
* `coreHash` / `storageHash` derivation through unchanged.
|
|
19
|
+
*
|
|
20
|
+
* The contract value passed in is the framework-neutral `unknown` form;
|
|
21
|
+
* callers that have already validated typed contracts can simply hand
|
|
22
|
+
* the validated value back through.
|
|
23
|
+
*/
|
|
24
|
+
export type AggregateContractHasher = (contract: unknown) => string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Single declared extension entry the loader needs from `Config.extensionPacks`.
|
|
28
|
+
*
|
|
29
|
+
* Only the subset of fields the loader operates on:
|
|
30
|
+
*
|
|
31
|
+
* - `id` — the space id (also the directory name under `migrations/`).
|
|
32
|
+
* - `targetId` — the configured `Config.adapter.targetId` value the
|
|
33
|
+
* declaring extension declared. The loader rejects mismatches against
|
|
34
|
+
* the aggregate's `targetId` with `targetMismatch`.
|
|
35
|
+
* - `contractSpace` — present iff the descriptor declares a contract
|
|
36
|
+
* space (extensions can ship without one and remain runtime-only /
|
|
37
|
+
* codec-only). Drift detection compares the descriptor's
|
|
38
|
+
* `contractJson` hash against the on-disk on-disk head hash; the loader
|
|
39
|
+
* rejects drift fatally.
|
|
40
|
+
*
|
|
41
|
+
* Typed structurally so the migration-tools layer stays framework-neutral.
|
|
42
|
+
*/
|
|
43
|
+
export interface DeclaredExtensionEntry {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly targetId: string;
|
|
46
|
+
readonly contractSpace?: {
|
|
47
|
+
readonly contractJson: unknown;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Inputs for {@link loadContractSpaceAggregate}.
|
|
53
|
+
*
|
|
54
|
+
* The loader is the **sole** descriptor-import boundary in the M2.5
|
|
55
|
+
* pipeline: callers gather the descriptor data (already-validated app
|
|
56
|
+
* contract, declared extension entries) and pass it through. Once the
|
|
57
|
+
* loader returns, no descriptor module is imported again for this
|
|
58
|
+
* aggregate's lifetime.
|
|
59
|
+
*/
|
|
60
|
+
export interface LoadAggregateInput {
|
|
61
|
+
readonly targetId: string;
|
|
62
|
+
readonly migrationsDir: string;
|
|
63
|
+
readonly appContract: Contract;
|
|
64
|
+
readonly declaredExtensions: ReadonlyArray<DeclaredExtensionEntry>;
|
|
65
|
+
readonly validateContract: (contractJson: unknown) => Contract;
|
|
66
|
+
readonly hashContract: AggregateContractHasher;
|
|
67
|
+
/**
|
|
68
|
+
* Hydrated migration graph for the **app member**.
|
|
69
|
+
*
|
|
70
|
+
* The framework-neutral migration-tools layer doesn't know how to read
|
|
71
|
+
* the user's authored `migrations/` directory (the app member's
|
|
72
|
+
* migration-package layout is family-aware: ops.json shape, manifest
|
|
73
|
+
* keys, etc.). Callers — the SQL family today — read the user's
|
|
74
|
+
* `migrations/` and hand the resulting `OnDiskMigrationPackage[]` through.
|
|
75
|
+
*
|
|
76
|
+
* Passing `[]` is valid (greenfield project, no authored migrations).
|
|
77
|
+
* Equivalent to `migrations/` not existing or being empty.
|
|
78
|
+
*/
|
|
79
|
+
readonly appMigrationPackages: ReadonlyArray<OnDiskMigrationPackage>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Discriminated failure variants the loader emits.
|
|
84
|
+
*
|
|
85
|
+
* Every variant short-circuits at first hit; the loader does not keep
|
|
86
|
+
* collecting after the first violation in any phase except for layout
|
|
87
|
+
* (where every layout offence is bundled into one `layoutViolation`).
|
|
88
|
+
*/
|
|
89
|
+
export type LoadAggregateError =
|
|
90
|
+
| { readonly kind: 'layoutViolation'; readonly violations: readonly LayoutViolation[] }
|
|
91
|
+
| { readonly kind: 'integrityFailure'; readonly spaceId: string; readonly detail: string }
|
|
92
|
+
| { readonly kind: 'validationFailure'; readonly spaceId: string; readonly detail: string }
|
|
93
|
+
| {
|
|
94
|
+
readonly kind: 'driftViolation';
|
|
95
|
+
readonly spaceId: string;
|
|
96
|
+
readonly priorHeadHash: string;
|
|
97
|
+
readonly liveHash: string;
|
|
98
|
+
}
|
|
99
|
+
| {
|
|
100
|
+
readonly kind: 'disjointnessViolation';
|
|
101
|
+
readonly element: string;
|
|
102
|
+
readonly claimedBy: readonly string[];
|
|
103
|
+
}
|
|
104
|
+
| {
|
|
105
|
+
readonly kind: 'targetMismatch';
|
|
106
|
+
readonly spaceId: string;
|
|
107
|
+
readonly expected: string;
|
|
108
|
+
readonly actual: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Single layout violation; bundled into a `layoutViolation` error so
|
|
113
|
+
* users see every layout offence at once rather than fixing them one
|
|
114
|
+
* at a time across re-runs.
|
|
115
|
+
*
|
|
116
|
+
* - `declaredButUnmigrated`: extension declared in `extensionPacks` with
|
|
117
|
+
* a `contractSpace` but no contract-space dir on disk. Remediation:
|
|
118
|
+
* `prisma-next migrate`.
|
|
119
|
+
* - `orphanSpaceDir`: contract-space dir under `migrations/` for an extension
|
|
120
|
+
* not in `extensionPacks`. Remediation: remove the directory, or
|
|
121
|
+
* re-add the extension to `extensionPacks`.
|
|
122
|
+
*/
|
|
123
|
+
export type LayoutViolation =
|
|
124
|
+
| { readonly kind: 'declaredButUnmigrated'; readonly spaceId: string }
|
|
125
|
+
| { readonly kind: 'orphanSpaceDir'; readonly spaceId: string };
|
|
126
|
+
|
|
127
|
+
export type LoadAggregateOutput = Result<
|
|
128
|
+
{ readonly aggregate: ContractSpaceAggregate },
|
|
129
|
+
LoadAggregateError
|
|
130
|
+
>;
|
|
131
|
+
|
|
132
|
+
interface LoadedExtensionState {
|
|
133
|
+
readonly entry: DeclaredExtensionEntry;
|
|
134
|
+
readonly contract: Contract;
|
|
135
|
+
readonly headRefHash: string;
|
|
136
|
+
readonly headRefInvariants: readonly string[];
|
|
137
|
+
readonly migrations: HydratedMigrationGraph;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Hydrate a {@link ContractSpaceAggregate} from on-disk state and
|
|
142
|
+
* caller-provided descriptor data.
|
|
143
|
+
*
|
|
144
|
+
* This is the **only** descriptor-import boundary in the post-M2.5
|
|
145
|
+
* pipeline: callers read `extensionPacks` from `Config`, validate the
|
|
146
|
+
* app contract, and pass everything through. The loader composes
|
|
147
|
+
* existing migration-tools primitives — layout precheck (via
|
|
148
|
+
* {@link listContractSpaceDirectories}), integrity checks (via
|
|
149
|
+
* {@link readMigrationsDir} / {@link readContractSpaceHeadRef} /
|
|
150
|
+
* {@link readContractSpaceContract} / `validateContract`), drift detection
|
|
151
|
+
* (via {@link detectSpaceContractDrift}), and disjointness — into a
|
|
152
|
+
* single typed value.
|
|
153
|
+
*
|
|
154
|
+
* Failure semantics: every failure variant in {@link LoadAggregateError}
|
|
155
|
+
* short-circuits the load. Drift is fatal (M2.5 spec § Loader, step 5).
|
|
156
|
+
*/
|
|
157
|
+
export async function loadContractSpaceAggregate(
|
|
158
|
+
input: LoadAggregateInput,
|
|
159
|
+
): Promise<LoadAggregateOutput> {
|
|
160
|
+
// 1. Validate target consistency on the app contract.
|
|
161
|
+
const appContractTarget = input.appContract.target;
|
|
162
|
+
if (appContractTarget !== input.targetId) {
|
|
163
|
+
return notOk({
|
|
164
|
+
kind: 'targetMismatch',
|
|
165
|
+
spaceId: APP_SPACE_ID,
|
|
166
|
+
expected: input.targetId,
|
|
167
|
+
actual: appContractTarget,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const entry of input.declaredExtensions) {
|
|
172
|
+
if (entry.targetId !== input.targetId) {
|
|
173
|
+
return notOk({
|
|
174
|
+
kind: 'targetMismatch',
|
|
175
|
+
spaceId: entry.id,
|
|
176
|
+
expected: input.targetId,
|
|
177
|
+
actual: entry.targetId,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 2. Layout precheck: bundle every layout offence at once.
|
|
183
|
+
const declaredWithSpace = input.declaredExtensions.filter((e) => e.contractSpace !== undefined);
|
|
184
|
+
const declaredSpaceIds = new Set(declaredWithSpace.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);
|
|
193
|
+
|
|
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
|
+
}
|
|
208
|
+
|
|
209
|
+
// 3-5. Per-extension: read + validate + integrity-check + drift.
|
|
210
|
+
const loadedExtensions: LoadedExtensionState[] = [];
|
|
211
|
+
for (const entry of [...declaredWithSpace].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
|
+
}
|
|
220
|
+
|
|
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: error instanceof Error ? error.message : String(error),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let spaceContract: Contract;
|
|
233
|
+
try {
|
|
234
|
+
spaceContract = input.validateContract(spaceContractRaw);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return notOk({
|
|
237
|
+
kind: 'validationFailure',
|
|
238
|
+
spaceId: entry.id,
|
|
239
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
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
|
+
}
|
|
251
|
+
|
|
252
|
+
// Drift: compare descriptor's live `contractJson` to on-disk
|
|
253
|
+
// `refs/head.json.hash`.
|
|
254
|
+
if (entry.contractSpace) {
|
|
255
|
+
const liveHash = input.hashContract(entry.contractSpace.contractJson);
|
|
256
|
+
const drift = detectSpaceContractDrift(entry.id, {
|
|
257
|
+
descriptorHash: liveHash,
|
|
258
|
+
priorHeadHash: headRef.hash,
|
|
259
|
+
});
|
|
260
|
+
if (drift.kind === 'drift') {
|
|
261
|
+
return notOk({
|
|
262
|
+
kind: 'driftViolation',
|
|
263
|
+
spaceId: entry.id,
|
|
264
|
+
priorHeadHash: drift.priorHeadHash ?? '',
|
|
265
|
+
liveHash: drift.descriptorHash,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Read + integrity-check the migration packages. `readMigrationsDir`
|
|
271
|
+
// re-derives `providedInvariants` and verifies migrationHash for
|
|
272
|
+
// every package.
|
|
273
|
+
let packages: readonly OnDiskMigrationPackage[];
|
|
274
|
+
try {
|
|
275
|
+
packages = await readMigrationsDir(spaceMigrationDirectory(input.migrationsDir, entry.id));
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return notOk({
|
|
278
|
+
kind: 'integrityFailure',
|
|
279
|
+
spaceId: entry.id,
|
|
280
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let graph: ReturnType<typeof reconstructGraph>;
|
|
285
|
+
try {
|
|
286
|
+
graph = reconstructGraph(packages);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return notOk({
|
|
289
|
+
kind: 'integrityFailure',
|
|
290
|
+
spaceId: entry.id,
|
|
291
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The on-disk head ref must be reachable in the graph. Empty graphs
|
|
296
|
+
// are tolerated only when the head ref points at the empty-contract
|
|
297
|
+
// sentinel (a never-emitted extension space; not a typical scenario
|
|
298
|
+
// because the layout precheck would have flagged the missing
|
|
299
|
+
// dir, but defensible).
|
|
300
|
+
if (graph.nodes.size === 0) {
|
|
301
|
+
if (headRef.hash !== EMPTY_CONTRACT_HASH) {
|
|
302
|
+
return notOk({
|
|
303
|
+
kind: 'integrityFailure',
|
|
304
|
+
spaceId: entry.id,
|
|
305
|
+
detail: `Head ref "${headRef.hash}" is not present in the (empty) on-disk migration graph.`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
} else if (!graph.nodes.has(headRef.hash)) {
|
|
309
|
+
return notOk({
|
|
310
|
+
kind: 'integrityFailure',
|
|
311
|
+
spaceId: entry.id,
|
|
312
|
+
detail: `Head ref "${headRef.hash}" is not present in the on-disk migration graph.`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const packagesByMigrationHash = new Map<string, OnDiskMigrationPackage>(
|
|
317
|
+
packages.map((p) => [p.metadata.migrationHash, p]),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
loadedExtensions.push({
|
|
321
|
+
entry,
|
|
322
|
+
contract: spaceContract,
|
|
323
|
+
headRefHash: headRef.hash,
|
|
324
|
+
headRefInvariants: [...headRef.invariants].sort(),
|
|
325
|
+
migrations: { graph, packagesByMigrationHash },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 6. Build app member with hydrated graph from caller-supplied packages.
|
|
330
|
+
let appGraph: ReturnType<typeof reconstructGraph>;
|
|
331
|
+
try {
|
|
332
|
+
appGraph = reconstructGraph(input.appMigrationPackages);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return notOk({
|
|
335
|
+
kind: 'integrityFailure',
|
|
336
|
+
spaceId: APP_SPACE_ID,
|
|
337
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const appPackagesByMigrationHash = new Map<string, OnDiskMigrationPackage>(
|
|
341
|
+
input.appMigrationPackages.map((p) => [p.metadata.migrationHash, p]),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const appMember: ContractSpaceMember = {
|
|
345
|
+
spaceId: APP_SPACE_ID,
|
|
346
|
+
contract: input.appContract,
|
|
347
|
+
headRef: {
|
|
348
|
+
hash: input.appContract.storage.storageHash,
|
|
349
|
+
invariants: [],
|
|
350
|
+
},
|
|
351
|
+
migrations: {
|
|
352
|
+
graph: appGraph,
|
|
353
|
+
packagesByMigrationHash: appPackagesByMigrationHash,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const extensionMembers: ContractSpaceMember[] = loadedExtensions.map((s) => ({
|
|
358
|
+
spaceId: s.entry.id,
|
|
359
|
+
contract: s.contract,
|
|
360
|
+
headRef: {
|
|
361
|
+
hash: s.headRefHash,
|
|
362
|
+
invariants: s.headRefInvariants,
|
|
363
|
+
},
|
|
364
|
+
migrations: s.migrations,
|
|
365
|
+
}));
|
|
366
|
+
|
|
367
|
+
// 7. Disjointness: no two members claim the same storage element.
|
|
368
|
+
const elementClaimedBy = new Map<string, string[]>();
|
|
369
|
+
for (const member of [appMember, ...extensionMembers]) {
|
|
370
|
+
const tables = extractTableNames(member.contract);
|
|
371
|
+
for (const tableName of tables) {
|
|
372
|
+
const claimers = elementClaimedBy.get(tableName);
|
|
373
|
+
if (claimers) claimers.push(member.spaceId);
|
|
374
|
+
else elementClaimedBy.set(tableName, [member.spaceId]);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const [element, claimedBy] of elementClaimedBy) {
|
|
378
|
+
if (claimedBy.length > 1) {
|
|
379
|
+
return notOk({
|
|
380
|
+
kind: 'disjointnessViolation',
|
|
381
|
+
element,
|
|
382
|
+
claimedBy: [...claimedBy].sort(),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return ok({
|
|
388
|
+
aggregate: {
|
|
389
|
+
targetId: input.targetId,
|
|
390
|
+
app: appMember,
|
|
391
|
+
extensions: extensionMembers,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Extract the set of top-level storage table names from a contract.
|
|
398
|
+
* Duck-typed: returns `[]` if the contract's storage shape doesn't
|
|
399
|
+
* match the canonical `storage.tables: Record<string, ...>` form. A
|
|
400
|
+
* future family with a different storage shape gets disjointness
|
|
401
|
+
* effectively disabled (not enforced) rather than a hard failure.
|
|
402
|
+
*/
|
|
403
|
+
function extractTableNames(contract: Contract): readonly string[] {
|
|
404
|
+
const storage = (contract as { readonly storage?: unknown }).storage;
|
|
405
|
+
if (typeof storage !== 'object' || storage === null) return [];
|
|
406
|
+
const tables = (storage as { readonly tables?: unknown }).tables;
|
|
407
|
+
if (typeof tables !== 'object' || tables === null) return [];
|
|
408
|
+
return Object.keys(tables as Record<string, unknown>);
|
|
409
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural shape the aggregate planner / verifier accept for marker
|
|
3
|
+
* rows. Mirrors `family.readAllMarkers(...)` outputs across SQL and
|
|
4
|
+
* Mongo families: a `(storageHash, invariants)` pair plus an optional
|
|
5
|
+
* `profileHash` the verifier uses to align the marker with the
|
|
6
|
+
* destination contract's profile envelope.
|
|
7
|
+
*
|
|
8
|
+
* Typed structurally so `migration-tools` stays framework-neutral; SQL
|
|
9
|
+
* and Mongo families pass their typed `ContractMarkerRecord` through
|
|
10
|
+
* unchanged.
|
|
11
|
+
*/
|
|
12
|
+
export interface ContractMarkerRecordLike {
|
|
13
|
+
readonly storageHash: string;
|
|
14
|
+
readonly invariants: readonly string[];
|
|
15
|
+
readonly profileHash?: string;
|
|
16
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
2
|
+
import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
|
|
3
|
+
import type {
|
|
4
|
+
ControlFamilyInstance,
|
|
5
|
+
MigrationOperationPolicy,
|
|
6
|
+
MigrationPlan,
|
|
7
|
+
MigrationPlannerConflict,
|
|
8
|
+
MigrationPlanOperation,
|
|
9
|
+
TargetMigrationsCapability,
|
|
10
|
+
} from '@prisma-next/framework-components/control';
|
|
11
|
+
import type { Result } from '@prisma-next/utils/result';
|
|
12
|
+
import type { PathDecision } from '../migration-graph';
|
|
13
|
+
import type { ContractMarkerRecordLike } from './marker-types';
|
|
14
|
+
import type { ContractSpaceAggregate } from './types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Caller-provided policy for {@link planAggregate}. Today this carries
|
|
18
|
+
* just one knob:
|
|
19
|
+
*
|
|
20
|
+
* - `ignoreGraphFor`: `Set<spaceId>`. For listed members, the planner
|
|
21
|
+
* forces the **synth** strategy (synthesise a plan from the contract
|
|
22
|
+
* IR via `familyInstance.createPlanner(...).plan(...)`) regardless of
|
|
23
|
+
* whether a graph is available. The CLI's daily-driver `db init` /
|
|
24
|
+
* `db update` pipelines pass `new Set([aggregate.app.spaceId])` to
|
|
25
|
+
* keep today's app-space behaviour: the user's authored
|
|
26
|
+
* `migrations/` directory is **not** walked for the app member, the
|
|
27
|
+
* plan is synthesised on the fly. Extension members are walked.
|
|
28
|
+
*
|
|
29
|
+
* Listing a member here whose `headRef.invariants` is non-empty is
|
|
30
|
+
* a `policyConflict` — synth cannot satisfy authored invariants.
|
|
31
|
+
*/
|
|
32
|
+
export interface CallerPolicy {
|
|
33
|
+
readonly ignoreGraphFor: ReadonlySet<string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Snapshot of the live database state the planner needs to drive
|
|
38
|
+
* strategy selection.
|
|
39
|
+
*
|
|
40
|
+
* - `markersBySpaceId`: per-space marker rows. Absent entry = no
|
|
41
|
+
* marker yet (greenfield space). The planner treats the marker's
|
|
42
|
+
* `storageHash` as the graph-walk's `from` node, falling back to
|
|
43
|
+
* {@link import('../constants').EMPTY_CONTRACT_HASH} when absent.
|
|
44
|
+
* - `schemaIntrospection`: the family's full live schema IR. Fed into
|
|
45
|
+
* the synth strategy after per-space pre-projection via
|
|
46
|
+
* {@link import('./project-schema-to-space').projectSchemaToSpace}.
|
|
47
|
+
*
|
|
48
|
+
* Callers (CLI commands) gather this via the family's
|
|
49
|
+
* `readAllMarkers` + `introspect` calls before invoking the planner.
|
|
50
|
+
* The planner itself does not touch the database.
|
|
51
|
+
*/
|
|
52
|
+
export interface AggregateCurrentDBState {
|
|
53
|
+
readonly markersBySpaceId: ReadonlyMap<string, ContractMarkerRecordLike | null>;
|
|
54
|
+
readonly schemaIntrospection: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Inputs to {@link planAggregate}.
|
|
59
|
+
*
|
|
60
|
+
* The planner is target-agnostic but family-aware: per-member synth
|
|
61
|
+
* delegates to the family's `createPlanner(familyInstance).plan(...)`,
|
|
62
|
+
* which is why `familyInstance`, `migrations` (the
|
|
63
|
+
* `TargetMigrationsCapability`), and `frameworkComponents` are all
|
|
64
|
+
* threaded through. (`frameworkComponents` is passed verbatim into
|
|
65
|
+
* `planner.plan(...)` per ADR 212; the planner does not interpret it.)
|
|
66
|
+
*
|
|
67
|
+
* The aggregate planner does **not** receive a `targetId` separately —
|
|
68
|
+
* it reads `aggregate.targetId` and stamps it onto every emitted
|
|
69
|
+
* `MigrationPlan` from construction. No placeholder, no patch step.
|
|
70
|
+
*/
|
|
71
|
+
export interface AggregatePlannerInput<TFamilyId extends string, TTargetId extends string> {
|
|
72
|
+
readonly aggregate: ContractSpaceAggregate;
|
|
73
|
+
readonly currentDBState: AggregateCurrentDBState;
|
|
74
|
+
readonly familyInstance: ControlFamilyInstance<TFamilyId, unknown>;
|
|
75
|
+
readonly migrations: TargetMigrationsCapability<
|
|
76
|
+
TFamilyId,
|
|
77
|
+
TTargetId,
|
|
78
|
+
ControlFamilyInstance<TFamilyId, unknown>
|
|
79
|
+
>;
|
|
80
|
+
readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<TFamilyId, TTargetId>>;
|
|
81
|
+
readonly callerPolicy: CallerPolicy;
|
|
82
|
+
readonly operationPolicy: MigrationOperationPolicy;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Per-member output of the aggregate planner. The runner ingests this
|
|
87
|
+
* shape directly via a thin `toRunnerInput` adapter at the CLI.
|
|
88
|
+
*
|
|
89
|
+
* - `plan`: ready-to-execute `MigrationPlan` with `targetId` already
|
|
90
|
+
* set from `aggregate.targetId`.
|
|
91
|
+
* - `displayOps`: same operation list, surfaced separately so plan-mode
|
|
92
|
+
* output can render without touching the runner-bound `plan`.
|
|
93
|
+
* - `destinationContract`: the typed contract value the runner uses
|
|
94
|
+
* for post-apply verification. For the app member, the user's
|
|
95
|
+
* contract; for extension members, the on-disk `contract.json`.
|
|
96
|
+
* - `strategy`: which strategy produced this plan (`'graph-walk'` or
|
|
97
|
+
* `'synth'`). Surfaced for diagnostics; not consumed by the runner.
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* Per-edge metadata for the chain assembled by the graph-walk
|
|
101
|
+
* strategy. Lets `migration apply` surface a per-migration `applied[]`
|
|
102
|
+
* entry (preserving the single-space `migrationsApplied` count
|
|
103
|
+
* semantics) without re-walking the graph.
|
|
104
|
+
*
|
|
105
|
+
* `synth`-produced plans leave this absent — synthesised plans don't
|
|
106
|
+
* have authored edges to surface.
|
|
107
|
+
*/
|
|
108
|
+
export interface AggregateMigrationEdgeRef {
|
|
109
|
+
readonly migrationHash: string;
|
|
110
|
+
readonly dirName: string;
|
|
111
|
+
readonly from: string;
|
|
112
|
+
readonly to: string;
|
|
113
|
+
readonly operationCount: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface AggregatePerSpacePlan {
|
|
117
|
+
readonly plan: MigrationPlan;
|
|
118
|
+
readonly displayOps: readonly MigrationPlanOperation[];
|
|
119
|
+
readonly destinationContract: Contract;
|
|
120
|
+
readonly strategy: 'graph-walk' | 'synth';
|
|
121
|
+
/**
|
|
122
|
+
* Per-edge breakdown of the chain. Populated by the graph-walk
|
|
123
|
+
* strategy; absent for synth-produced plans.
|
|
124
|
+
*/
|
|
125
|
+
readonly migrationEdges?: readonly AggregateMigrationEdgeRef[];
|
|
126
|
+
/**
|
|
127
|
+
* Path decision data the strategy used to select the chain
|
|
128
|
+
* (alternative count, tie-break reasons, required/satisfied
|
|
129
|
+
* invariants, per-edge invariants). Populated by the graph-walk
|
|
130
|
+
* strategy; absent for synth-produced plans.
|
|
131
|
+
*
|
|
132
|
+
* `migration apply` surfaces this for the app member as
|
|
133
|
+
* `MigrationApplySuccess.pathDecision` (back-compat with single-
|
|
134
|
+
* space callers).
|
|
135
|
+
*/
|
|
136
|
+
readonly pathDecision?: PathDecision;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface AggregatePlannerSuccess {
|
|
140
|
+
readonly perSpace: ReadonlyMap<string, AggregatePerSpacePlan>;
|
|
141
|
+
/**
|
|
142
|
+
* `applyOrder` is the order the runner must walk per-space inputs.
|
|
143
|
+
* Mirrors the existing `concatenateSpaceApplyInputs` convention:
|
|
144
|
+
* extensions alphabetically by `spaceId`, then the app. Tests assert
|
|
145
|
+
* on `MultiSpaceRunnerFailure.failingSpace`, which is positional in
|
|
146
|
+
* the runner's input array — preserving the literal ordering keeps
|
|
147
|
+
* `failingSpace` attribution byte-for-byte.
|
|
148
|
+
*/
|
|
149
|
+
readonly applyOrder: readonly string[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Discriminated failure variants for {@link planAggregate}. Each
|
|
154
|
+
* variant short-circuits the plan; per-member errors carry the
|
|
155
|
+
* `spaceId` so the CLI can surface a precise envelope.
|
|
156
|
+
*/
|
|
157
|
+
export type AggregatePlannerError =
|
|
158
|
+
| { readonly kind: 'extensionPathUnreachable'; readonly spaceId: string; readonly target: string }
|
|
159
|
+
| {
|
|
160
|
+
readonly kind: 'extensionPathUnsatisfiable';
|
|
161
|
+
readonly spaceId: string;
|
|
162
|
+
readonly missingInvariants: readonly string[];
|
|
163
|
+
}
|
|
164
|
+
| {
|
|
165
|
+
readonly kind: 'appSynthFailure';
|
|
166
|
+
readonly spaceId: string;
|
|
167
|
+
readonly conflicts: readonly MigrationPlannerConflict[];
|
|
168
|
+
}
|
|
169
|
+
| { readonly kind: 'policyConflict'; readonly spaceId: string; readonly detail: string };
|
|
170
|
+
|
|
171
|
+
export type AggregatePlannerOutput = Result<AggregatePlannerSuccess, AggregatePlannerError>;
|