@prisma-next/migration-tools 0.5.0-dev.8 → 0.5.0-dev.81
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 +34 -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 +550 -0
- package/dist/exports/spaces.d.mts.map +1 -0
- package/dist/exports/spaces.mjs +223 -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-Duc8f9NM.mjs +52 -0
- package/dist/invariants-Duc8f9NM.mjs.map +1 -0
- package/dist/io-D13dLvUh.mjs +239 -0
- package/dist/io-D13dLvUh.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-C3-1eyaI.mjs +298 -0
- package/dist/read-contract-space-contract-C3-1eyaI.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/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 +49 -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 +56 -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,230 @@
|
|
|
1
|
+
import type { Result } from '@prisma-next/utils/result';
|
|
2
|
+
import { notOk, ok } from '@prisma-next/utils/result';
|
|
3
|
+
import type { ContractMarkerRecordLike } from './marker-types';
|
|
4
|
+
import { projectSchemaToSpace } from './project-schema-to-space';
|
|
5
|
+
import type { ContractSpaceAggregate, ContractSpaceMember } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Caller policy for the aggregate verifier. Today's only knob is
|
|
9
|
+
* `mode`: `strict` treats orphan elements (live tables not claimed by
|
|
10
|
+
* any aggregate member) as errors; `lenient` treats them as
|
|
11
|
+
* informational. Maps directly to `db verify --strict`.
|
|
12
|
+
*/
|
|
13
|
+
export interface AggregateVerifierInput<TSchemaResult> {
|
|
14
|
+
readonly aggregate: ContractSpaceAggregate;
|
|
15
|
+
readonly markersBySpaceId: ReadonlyMap<string, ContractMarkerRecordLike | null>;
|
|
16
|
+
readonly schemaIntrospection: unknown;
|
|
17
|
+
readonly mode: 'strict' | 'lenient';
|
|
18
|
+
/**
|
|
19
|
+
* Caller-supplied per-space schema verifier. The CLI wires this to
|
|
20
|
+
* the family's `verifySqlSchema` (SQL) / equivalent (other
|
|
21
|
+
* families). The aggregate verifier projects the schema to the
|
|
22
|
+
* member's slice via {@link projectSchemaToSpace} before invoking
|
|
23
|
+
* the callback, so single-contract semantics are preserved.
|
|
24
|
+
*
|
|
25
|
+
* Typed structurally with a generic `TSchemaResult` so the
|
|
26
|
+
* migration-tools layer doesn't depend on the SQL family's
|
|
27
|
+
* `VerifySqlSchemaResult`. CLI callers pass the family's type
|
|
28
|
+
* through unchanged.
|
|
29
|
+
*/
|
|
30
|
+
readonly verifySchemaForMember: (
|
|
31
|
+
projectedSchema: unknown,
|
|
32
|
+
member: ContractSpaceMember,
|
|
33
|
+
mode: 'strict' | 'lenient',
|
|
34
|
+
) => TSchemaResult;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Marker-check result per member. Mirrors the four cases the
|
|
39
|
+
* `verifyContractSpaces` primitive surfaces today, plus an `'absent'`
|
|
40
|
+
* case for greenfield spaces (no marker row written yet — `db init`
|
|
41
|
+
* not run).
|
|
42
|
+
*/
|
|
43
|
+
export type MarkerCheckResult =
|
|
44
|
+
| { readonly kind: 'ok' }
|
|
45
|
+
| { readonly kind: 'absent' }
|
|
46
|
+
| {
|
|
47
|
+
readonly kind: 'hashMismatch';
|
|
48
|
+
readonly markerHash: string;
|
|
49
|
+
readonly expected: string;
|
|
50
|
+
}
|
|
51
|
+
| { readonly kind: 'missingInvariants'; readonly missing: readonly string[] };
|
|
52
|
+
|
|
53
|
+
export interface MarkerCheckSection {
|
|
54
|
+
readonly perSpace: ReadonlyMap<string, MarkerCheckResult>;
|
|
55
|
+
readonly orphanMarkers: readonly {
|
|
56
|
+
readonly spaceId: string;
|
|
57
|
+
readonly row: ContractMarkerRecordLike;
|
|
58
|
+
}[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A live storage element (today: a top-level table) not claimed by any
|
|
63
|
+
* member of the aggregate. The aggregate verifier always reports these;
|
|
64
|
+
* the caller decides what to do — `db verify --strict` treats them as
|
|
65
|
+
* errors, the lenient default treats them as informational.
|
|
66
|
+
*
|
|
67
|
+
* Today only `kind: 'table'` exists. The discriminated shape leaves
|
|
68
|
+
* room for orphan columns / indexes / sequences in the future without
|
|
69
|
+
* breaking the type contract.
|
|
70
|
+
*/
|
|
71
|
+
export type OrphanElement = { readonly kind: 'table'; readonly name: string };
|
|
72
|
+
|
|
73
|
+
export interface SchemaCheckSection<TSchemaResult> {
|
|
74
|
+
readonly perSpace: ReadonlyMap<string, TSchemaResult>;
|
|
75
|
+
/**
|
|
76
|
+
* Live elements present in the introspected schema that are not
|
|
77
|
+
* claimed by **any** aggregate member. Sorted alphabetically by name.
|
|
78
|
+
*/
|
|
79
|
+
readonly orphanElements: readonly OrphanElement[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AggregateVerifierSuccess<TSchemaResult> {
|
|
83
|
+
readonly markerCheck: MarkerCheckSection;
|
|
84
|
+
readonly schemaCheck: SchemaCheckSection<TSchemaResult>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type AggregateVerifierError = {
|
|
88
|
+
readonly kind: 'introspectionFailure';
|
|
89
|
+
readonly detail: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type AggregateVerifierOutput<TSchemaResult> = Result<
|
|
93
|
+
AggregateVerifierSuccess<TSchemaResult>,
|
|
94
|
+
AggregateVerifierError
|
|
95
|
+
>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Verify a {@link ContractSpaceAggregate} against the live database
|
|
99
|
+
* state. Bundles two checks:
|
|
100
|
+
*
|
|
101
|
+
* - `markerCheck` per member: compare the live marker row against the
|
|
102
|
+
* member's `headRef.hash` + `headRef.invariants`. Absence is a
|
|
103
|
+
* distinct kind, not an error (callers — `db verify` strict vs
|
|
104
|
+
* `db init` precondition — choose how to interpret it).
|
|
105
|
+
* - `schemaCheck` per member: project the live schema to the slice
|
|
106
|
+
* the member claims via {@link projectSchemaToSpace}, then delegate
|
|
107
|
+
* to the caller-supplied `verifySchemaForMember`. The pre-projection
|
|
108
|
+
* means the family's single-contract verifier no longer sees other
|
|
109
|
+
* members' tables as `extras`, so a multi-member deployment never
|
|
110
|
+
* surfaces cross-member tables as orphaned schema elements.
|
|
111
|
+
*
|
|
112
|
+
* `markerCheck.orphanMarkers` lists every marker row whose `space` is
|
|
113
|
+
* not a member of the aggregate. `db verify` callers reject orphans;
|
|
114
|
+
* future tooling may not.
|
|
115
|
+
*
|
|
116
|
+
* Pure synchronous function; no I/O. The caller (CLI) gathers
|
|
117
|
+
* `markersBySpaceId` and `schemaIntrospection` ahead of the call.
|
|
118
|
+
*/
|
|
119
|
+
export function verifyAggregate<TSchemaResult>(
|
|
120
|
+
input: AggregateVerifierInput<TSchemaResult>,
|
|
121
|
+
): AggregateVerifierOutput<TSchemaResult> {
|
|
122
|
+
try {
|
|
123
|
+
return runVerifyAggregate(input);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return notOk({
|
|
126
|
+
kind: 'introspectionFailure',
|
|
127
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function runVerifyAggregate<TSchemaResult>(
|
|
133
|
+
input: AggregateVerifierInput<TSchemaResult>,
|
|
134
|
+
): AggregateVerifierOutput<TSchemaResult> {
|
|
135
|
+
const { aggregate, markersBySpaceId, schemaIntrospection, mode, verifySchemaForMember } = input;
|
|
136
|
+
const allMembers: ReadonlyArray<ContractSpaceMember> = [aggregate.app, ...aggregate.extensions];
|
|
137
|
+
const memberSpaceIds = new Set(allMembers.map((m) => m.spaceId));
|
|
138
|
+
|
|
139
|
+
// Marker check per member.
|
|
140
|
+
const markerPerSpace = new Map<string, MarkerCheckResult>();
|
|
141
|
+
for (const member of allMembers) {
|
|
142
|
+
const marker = markersBySpaceId.get(member.spaceId) ?? null;
|
|
143
|
+
if (marker === null) {
|
|
144
|
+
markerPerSpace.set(member.spaceId, { kind: 'absent' });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (marker.storageHash !== member.headRef.hash) {
|
|
148
|
+
markerPerSpace.set(member.spaceId, {
|
|
149
|
+
kind: 'hashMismatch',
|
|
150
|
+
markerHash: marker.storageHash,
|
|
151
|
+
expected: member.headRef.hash,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const markerInvariants = new Set(marker.invariants);
|
|
156
|
+
const missing = member.headRef.invariants.filter((id) => !markerInvariants.has(id));
|
|
157
|
+
if (missing.length > 0) {
|
|
158
|
+
markerPerSpace.set(member.spaceId, {
|
|
159
|
+
kind: 'missingInvariants',
|
|
160
|
+
missing: [...missing].sort(),
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
markerPerSpace.set(member.spaceId, { kind: 'ok' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Orphan markers: entries in markersBySpaceId whose spaceId is not a
|
|
168
|
+
// member of the aggregate.
|
|
169
|
+
const orphanMarkers: { spaceId: string; row: ContractMarkerRecordLike }[] = [];
|
|
170
|
+
for (const [spaceId, row] of markersBySpaceId) {
|
|
171
|
+
if (row !== null && !memberSpaceIds.has(spaceId)) {
|
|
172
|
+
orphanMarkers.push({ spaceId, row });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
orphanMarkers.sort((a, b) => a.spaceId.localeCompare(b.spaceId));
|
|
176
|
+
|
|
177
|
+
// Schema check per member (with per-space pre-projection).
|
|
178
|
+
const schemaPerSpace = new Map<string, TSchemaResult>();
|
|
179
|
+
for (const member of allMembers) {
|
|
180
|
+
const others = allMembers.filter((m) => m.spaceId !== member.spaceId);
|
|
181
|
+
const projected = projectSchemaToSpace(schemaIntrospection, member, others);
|
|
182
|
+
schemaPerSpace.set(member.spaceId, verifySchemaForMember(projected, member, mode));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return ok({
|
|
186
|
+
markerCheck: {
|
|
187
|
+
perSpace: markerPerSpace,
|
|
188
|
+
orphanMarkers,
|
|
189
|
+
},
|
|
190
|
+
schemaCheck: {
|
|
191
|
+
perSpace: schemaPerSpace,
|
|
192
|
+
orphanElements: detectOrphanElements(schemaIntrospection, allMembers),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Live tables not claimed by any aggregate member. Duck-typed against
|
|
199
|
+
* the introspected schema's `tables` map; schemas whose shape doesn't
|
|
200
|
+
* match return an empty list (consistent with
|
|
201
|
+
* {@link projectSchemaToSpace}'s fall-through).
|
|
202
|
+
*/
|
|
203
|
+
function detectOrphanElements(
|
|
204
|
+
schemaIntrospection: unknown,
|
|
205
|
+
members: ReadonlyArray<ContractSpaceMember>,
|
|
206
|
+
): readonly OrphanElement[] {
|
|
207
|
+
if (typeof schemaIntrospection !== 'object' || schemaIntrospection === null) return [];
|
|
208
|
+
const liveTables = (schemaIntrospection as { readonly tables?: unknown }).tables;
|
|
209
|
+
if (typeof liveTables !== 'object' || liveTables === null) return [];
|
|
210
|
+
|
|
211
|
+
const claimedTables = new Set<string>();
|
|
212
|
+
for (const member of members) {
|
|
213
|
+
const storage = (member.contract as { readonly storage?: unknown }).storage;
|
|
214
|
+
if (typeof storage !== 'object' || storage === null) continue;
|
|
215
|
+
const tables = (storage as { readonly tables?: unknown }).tables;
|
|
216
|
+
if (typeof tables !== 'object' || tables === null) continue;
|
|
217
|
+
for (const tableName of Object.keys(tables as Record<string, unknown>)) {
|
|
218
|
+
claimedTables.add(tableName);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const orphans: OrphanElement[] = [];
|
|
223
|
+
for (const tableName of Object.keys(liveTables as Record<string, unknown>)) {
|
|
224
|
+
if (!claimedTables.has(tableName)) {
|
|
225
|
+
orphans.push({ kind: 'table', name: tableName });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
orphans.sort((a, b) => a.name.localeCompare(b.name));
|
|
229
|
+
return orphans;
|
|
230
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { computeStorageHash } from '@prisma-next/contract/hashing';
|
|
2
|
+
import { errorDescriptorHeadHashMismatch } from './errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inputs the helper needs to recompute the descriptor's storage hash and
|
|
6
|
+
* compare it to the published `headRef.hash`. Kept structural so the SQL
|
|
7
|
+
* family (and any future target family) can compose the check without
|
|
8
|
+
* coupling to its own descriptor types.
|
|
9
|
+
*/
|
|
10
|
+
export interface DescriptorSelfConsistencyInputs {
|
|
11
|
+
readonly extensionId: string;
|
|
12
|
+
readonly target: string;
|
|
13
|
+
readonly targetFamily: string;
|
|
14
|
+
/**
|
|
15
|
+
* Family-specific storage object. Typed as `unknown` so callers can
|
|
16
|
+
* pass their own narrow storage shape (e.g. `SqlStorage`) without an
|
|
17
|
+
* inline cast — the helper canonicalises through `JSON.stringify`
|
|
18
|
+
* inside {@link computeStorageHash} and only requires a plain
|
|
19
|
+
* record-shaped value at runtime.
|
|
20
|
+
*/
|
|
21
|
+
readonly storage: unknown;
|
|
22
|
+
readonly headRefHash: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Assert that an extension descriptor is self-consistent: the
|
|
27
|
+
* `headRef.hash` it publishes must match the canonical hash recomputed
|
|
28
|
+
* from its `contractSpace.contractJson`.
|
|
29
|
+
*
|
|
30
|
+
* Recomputes via {@link computeStorageHash} — the same canonical-JSON
|
|
31
|
+
* pipeline the descriptor's own emit pipeline produced the hash with —
|
|
32
|
+
* over `(target, targetFamily, storage)`. Mismatch indicates the
|
|
33
|
+
* extension author bumped `contractJson` without rerunning emit, leaving
|
|
34
|
+
* the descriptor's `headRef.hash` stale; the consumer-side helpers
|
|
35
|
+
* (drift detection, on-disk artefact emission, runner marker writes) all
|
|
36
|
+
* trust `headRef.hash` as the canonical identity, so a stale value would
|
|
37
|
+
* silently corrupt every downstream boundary.
|
|
38
|
+
*
|
|
39
|
+
* Synchronous, pure, no I/O. Throws
|
|
40
|
+
* `MIGRATION.DESCRIPTOR_HEAD_HASH_MISMATCH` on failure with both the
|
|
41
|
+
* recomputed and published hashes in `details` so callers can surface a
|
|
42
|
+
* clear remediation hint without re-deriving them.
|
|
43
|
+
*/
|
|
44
|
+
export function assertDescriptorSelfConsistency(inputs: DescriptorSelfConsistencyInputs): void {
|
|
45
|
+
// The published `storage.storageHash` is the *output* of the production
|
|
46
|
+
// emit pipeline's `computeStorageHash` call, computed over a storage
|
|
47
|
+
// object that did not yet carry `storageHash`. Recomputing against the
|
|
48
|
+
// published storage as-is would feed the result back into its own input
|
|
49
|
+
// and produce a different digest. Strip `storageHash` before
|
|
50
|
+
// recomputing so the helper sees the same canonical shape the
|
|
51
|
+
// descriptor's authoring pipeline saw.
|
|
52
|
+
// The helper requires only a plain record-shaped storage value at
|
|
53
|
+
// runtime; a single cast here keeps the public input type
|
|
54
|
+
// family-agnostic (`unknown`) while still letting us strip the
|
|
55
|
+
// descriptor-published `storageHash` before re-canonicalising.
|
|
56
|
+
const storageRecord = inputs.storage as Record<string, unknown>;
|
|
57
|
+
const { storageHash: _stripped, ...storageWithoutHash } = storageRecord;
|
|
58
|
+
const recomputed = computeStorageHash({
|
|
59
|
+
target: inputs.target,
|
|
60
|
+
targetFamily: inputs.targetFamily,
|
|
61
|
+
storage: storageWithoutHash,
|
|
62
|
+
});
|
|
63
|
+
if (recomputed !== inputs.headRefHash) {
|
|
64
|
+
throw errorDescriptorHeadHashMismatch({
|
|
65
|
+
extensionId: inputs.extensionId,
|
|
66
|
+
recomputedHash: recomputed,
|
|
67
|
+
headRefHash: inputs.headRefHash,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { EMPTY_CONTRACT_HASH } from './constants';
|
|
2
|
+
import { readMigrationsDir } from './io';
|
|
3
|
+
import { findPathWithDecision, reconstructGraph } from './migration-graph';
|
|
4
|
+
import type { MigrationOps } from './package';
|
|
5
|
+
import {
|
|
6
|
+
type ContractSpaceHeadRef,
|
|
7
|
+
readContractSpaceHeadRef,
|
|
8
|
+
} from './read-contract-space-head-ref';
|
|
9
|
+
import { spaceMigrationDirectory } from './space-layout';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Outcome of {@link computeExtensionSpaceApplyPath} — a discriminated union
|
|
13
|
+
* mirroring {@link import('./migration-graph').FindPathOutcome} so callers
|
|
14
|
+
* can map structural / invariant failures to their preferred CLI envelope
|
|
15
|
+
* without re-running pathfinding.
|
|
16
|
+
*/
|
|
17
|
+
export type ExtensionSpaceApplyPathOutcome =
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'ok';
|
|
20
|
+
readonly contractSpaceHeadRef: ContractSpaceHeadRef;
|
|
21
|
+
/**
|
|
22
|
+
* Sorted, deduplicated invariant ids covered by the walked path.
|
|
23
|
+
* Mirrors the on-disk `providedInvariants` summed across edges and
|
|
24
|
+
* canonicalised — what the runner stamps on the marker after apply.
|
|
25
|
+
*/
|
|
26
|
+
readonly providedInvariants: readonly string[];
|
|
27
|
+
/**
|
|
28
|
+
* Path operations in apply order. Empty when the marker is already
|
|
29
|
+
* at the recorded head (no-op).
|
|
30
|
+
*/
|
|
31
|
+
readonly pathOps: MigrationOps;
|
|
32
|
+
/**
|
|
33
|
+
* Migration directory names walked, in order. Mirrors `pathOps`'s
|
|
34
|
+
* structure but at the package granularity — useful for surfacing
|
|
35
|
+
* "applied N migration(s)" messages.
|
|
36
|
+
*/
|
|
37
|
+
readonly walkedMigrationDirs: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
| { readonly kind: 'unreachable'; readonly contractSpaceHeadRef: ContractSpaceHeadRef }
|
|
40
|
+
| {
|
|
41
|
+
readonly kind: 'unsatisfiable';
|
|
42
|
+
readonly contractSpaceHeadRef: ContractSpaceHeadRef;
|
|
43
|
+
readonly missing: readonly string[];
|
|
44
|
+
readonly structuralPath: readonly { readonly dirName: string; readonly to: string }[];
|
|
45
|
+
}
|
|
46
|
+
| { readonly kind: 'contractSpaceHeadRefMissing' };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Inputs to {@link computeExtensionSpaceApplyPath}. The helper is
|
|
50
|
+
* deliberately framework-neutral and consumes only on-disk state:
|
|
51
|
+
*
|
|
52
|
+
* - `projectMigrationsDir` is the project's top-level `migrations/` dir.
|
|
53
|
+
* - `spaceId` selects the per-space subdirectory under it.
|
|
54
|
+
* - `currentMarkerHash` / `currentMarkerInvariants` come from the live
|
|
55
|
+
* marker row keyed by `space = <spaceId>`. `null` hash = no marker yet
|
|
56
|
+
* (the pathfinder treats this as the empty-contract sentinel per ADR
|
|
57
|
+
* 208).
|
|
58
|
+
*/
|
|
59
|
+
export interface ComputeExtensionSpaceApplyPathInputs {
|
|
60
|
+
readonly projectMigrationsDir: string;
|
|
61
|
+
readonly spaceId: string;
|
|
62
|
+
readonly currentMarkerHash: string | null;
|
|
63
|
+
readonly currentMarkerInvariants: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute the apply path for an extension contract space — the shortest
|
|
68
|
+
* sequence of on-disk migration packages that walks the live marker
|
|
69
|
+
* forward to the on-disk head ref hash, covering every required
|
|
70
|
+
* invariant.
|
|
71
|
+
*
|
|
72
|
+
* Reads only on-disk artefacts (`migrations/<spaceId>/refs/head.json`
|
|
73
|
+
* and the per-space migration packages). **Does not import any
|
|
74
|
+
* extension descriptor module** — `db init` / `db update` must remain
|
|
75
|
+
* runnable without the descriptor source on disk.
|
|
76
|
+
*
|
|
77
|
+
* Behaviour:
|
|
78
|
+
* - Returns `{ kind: 'ok', pathOps: [], … }` when the marker is already
|
|
79
|
+
* at the recorded head and no required invariants are missing.
|
|
80
|
+
* - Returns `{ kind: 'unreachable' }` when the marker hash is not
|
|
81
|
+
* structurally connected to the recorded head in the graph.
|
|
82
|
+
* - Returns `{ kind: 'unsatisfiable', missing, … }` when the marker is
|
|
83
|
+
* reachable but no path covers the required invariants.
|
|
84
|
+
* - Returns `{ kind: 'contractSpaceHeadRefMissing' }` when the per-space
|
|
85
|
+
* `refs/head.json` is absent — the precheck verifier should already
|
|
86
|
+
* have rejected this case, but the helper is defensive so callers can
|
|
87
|
+
* surface a coherent error rather than throw.
|
|
88
|
+
*/
|
|
89
|
+
export async function computeExtensionSpaceApplyPath(
|
|
90
|
+
inputs: ComputeExtensionSpaceApplyPathInputs,
|
|
91
|
+
): Promise<ExtensionSpaceApplyPathOutcome> {
|
|
92
|
+
const { projectMigrationsDir, spaceId, currentMarkerHash, currentMarkerInvariants } = inputs;
|
|
93
|
+
|
|
94
|
+
const contractSpaceHeadRef = await readContractSpaceHeadRef(projectMigrationsDir, spaceId);
|
|
95
|
+
if (contractSpaceHeadRef === null) {
|
|
96
|
+
return { kind: 'contractSpaceHeadRefMissing' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const spaceDir = spaceMigrationDirectory(projectMigrationsDir, spaceId);
|
|
100
|
+
const packages = await readMigrationsDir(spaceDir);
|
|
101
|
+
const graph = reconstructGraph(packages);
|
|
102
|
+
|
|
103
|
+
// Live-marker layer encodes "no prior state" as EMPTY_CONTRACT_HASH;
|
|
104
|
+
// mirror the `migration apply` flow so a fresh-marker initial walk
|
|
105
|
+
// hits the baseline migration whose `from` is EMPTY_CONTRACT_HASH.
|
|
106
|
+
const fromHash = currentMarkerHash ?? EMPTY_CONTRACT_HASH;
|
|
107
|
+
const required = new Set(
|
|
108
|
+
contractSpaceHeadRef.invariants.filter((id) => !currentMarkerInvariants.includes(id)),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const outcome = findPathWithDecision(graph, fromHash, contractSpaceHeadRef.hash, { required });
|
|
112
|
+
|
|
113
|
+
if (outcome.kind === 'unreachable') {
|
|
114
|
+
return { kind: 'unreachable', contractSpaceHeadRef };
|
|
115
|
+
}
|
|
116
|
+
if (outcome.kind === 'unsatisfiable') {
|
|
117
|
+
return {
|
|
118
|
+
kind: 'unsatisfiable',
|
|
119
|
+
contractSpaceHeadRef,
|
|
120
|
+
missing: outcome.missing,
|
|
121
|
+
structuralPath: outcome.structuralPath.map(({ dirName, to }) => ({ dirName, to })),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const packagesByHash = new Map(packages.map((pkg) => [pkg.metadata.migrationHash, pkg]));
|
|
126
|
+
|
|
127
|
+
const pathOps: MigrationOps[number][] = [];
|
|
128
|
+
const walkedMigrationDirs: string[] = [];
|
|
129
|
+
const providedInvariantsSet = new Set<string>();
|
|
130
|
+
for (const edge of outcome.decision.selectedPath) {
|
|
131
|
+
const pkg = packagesByHash.get(edge.migrationHash);
|
|
132
|
+
if (!pkg) {
|
|
133
|
+
// Path edges always come from the same `packages` array, so this
|
|
134
|
+
// is only reachable when the graph is internally inconsistent —
|
|
135
|
+
// surface it loudly rather than silently truncating the path.
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Migration package missing for edge ${edge.migrationHash} in space "${spaceId}"`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
walkedMigrationDirs.push(pkg.dirName);
|
|
141
|
+
for (const op of pkg.ops) pathOps.push(op);
|
|
142
|
+
for (const invariant of pkg.metadata.providedInvariants) providedInvariantsSet.add(invariant);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
kind: 'ok',
|
|
147
|
+
contractSpaceHeadRef,
|
|
148
|
+
providedInvariants: [...providedInvariantsSet].sort(),
|
|
149
|
+
pathOps,
|
|
150
|
+
walkedMigrationDirs,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { errorDuplicateSpaceId } from './errors';
|
|
2
|
+
import { APP_SPACE_ID } from './space-layout';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-space input the runner consumes when applying a migration.
|
|
6
|
+
*
|
|
7
|
+
* The shape is target-agnostic: callers (today the SQL family; later
|
|
8
|
+
* any other family) bind `TOp` to their own per-target operation type
|
|
9
|
+
* (e.g. `SqlMigrationPlanOperation<TTargetDetails>` for the SQL family)
|
|
10
|
+
* and the helper preserves it through the concatenation.
|
|
11
|
+
*
|
|
12
|
+
* - `migrationDirectory` is the on-disk migration directory for the
|
|
13
|
+
* space — `<projectRoot>/migrations/<space-id>` (uniform; the app
|
|
14
|
+
* subspaces under its own `<APP_SPACE_ID>/` directory).
|
|
15
|
+
* - `currentMarkerHash` and `currentMarkerInvariants` are the values
|
|
16
|
+
* read from the `prisma_contract.marker` row keyed by `space = <space-id>`
|
|
17
|
+
* (T1.1). `null` hash = no marker row yet.
|
|
18
|
+
* - `path` is the per-space operation list resolved from
|
|
19
|
+
* `findPathWithDecision(currentMarker, ref.hash, effectiveRequired)`
|
|
20
|
+
* per ADR 208, materialised against the on-disk migration packages.
|
|
21
|
+
*
|
|
22
|
+
* @see specs/framework-mechanism.spec.md § 4 — Runner.
|
|
23
|
+
*/
|
|
24
|
+
export interface SpaceApplyInput<TOp> {
|
|
25
|
+
readonly spaceId: string;
|
|
26
|
+
readonly migrationDirectory: string;
|
|
27
|
+
readonly currentMarkerHash: string | null;
|
|
28
|
+
readonly currentMarkerInvariants: readonly string[];
|
|
29
|
+
readonly path: readonly TOp[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Order a set of per-space apply inputs into the canonical cross-space
|
|
34
|
+
* sequence the runner applies under a single transaction.
|
|
35
|
+
*
|
|
36
|
+
* Cross-space ordering convention (sub-spec § 4):
|
|
37
|
+
*
|
|
38
|
+
* 1. **Extension spaces first**, alphabetically by `spaceId`.
|
|
39
|
+
* 2. **App space last** — only one `'app'` entry expected, at most.
|
|
40
|
+
*
|
|
41
|
+
* Rationale: extensions install their own structural objects (types,
|
|
42
|
+
* functions, helper tables) before the app's structural ops reference
|
|
43
|
+
* them. Putting app-space last lets app-space ops freely depend on any
|
|
44
|
+
* extension-space declaration in the same transaction.
|
|
45
|
+
*
|
|
46
|
+
* Determinism (NFR6): the output order is independent of the input
|
|
47
|
+
* order, so two callers with the same set of `extensionPacks` produce
|
|
48
|
+
* identical apply sequences.
|
|
49
|
+
*
|
|
50
|
+
* Atomicity: rejects duplicate `spaceId`s with
|
|
51
|
+
* `MIGRATION.DUPLICATE_SPACE_ID` before producing any output. This
|
|
52
|
+
* mirrors {@link import('./plan-all-spaces').planAllSpaces} so the
|
|
53
|
+
* planner-side and runner-side helpers reject malformed inputs the same
|
|
54
|
+
* way (callers don't need a separate dedup pass).
|
|
55
|
+
*
|
|
56
|
+
* Synchronous, pure, no I/O: callers resolve marker rows and `path`
|
|
57
|
+
* before invoking this helper. The actual DB application — driving the
|
|
58
|
+
* transaction, committing marker writes, recording the per-space marker
|
|
59
|
+
* rows — happens at the SQL-family consumption site (per the
|
|
60
|
+
* helper-location convention from R3).
|
|
61
|
+
*/
|
|
62
|
+
export function concatenateSpaceApplyInputs<TOp>(
|
|
63
|
+
inputs: readonly SpaceApplyInput<TOp>[],
|
|
64
|
+
): readonly SpaceApplyInput<TOp>[] {
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
for (const input of inputs) {
|
|
67
|
+
if (seen.has(input.spaceId)) {
|
|
68
|
+
throw errorDuplicateSpaceId(input.spaceId);
|
|
69
|
+
}
|
|
70
|
+
seen.add(input.spaceId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const extensions: SpaceApplyInput<TOp>[] = [];
|
|
74
|
+
let appSpace: SpaceApplyInput<TOp> | undefined;
|
|
75
|
+
for (const input of inputs) {
|
|
76
|
+
if (input.spaceId === APP_SPACE_ID) {
|
|
77
|
+
appSpace = input;
|
|
78
|
+
} else {
|
|
79
|
+
extensions.push(input);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
extensions.sort((a, b) => {
|
|
84
|
+
if (a.spaceId < b.spaceId) return -1;
|
|
85
|
+
if (a.spaceId > b.spaceId) return 1;
|
|
86
|
+
return 0;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return appSpace ? [...extensions, appSpace] : extensions;
|
|
90
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inputs for {@link detectSpaceContractDrift}.
|
|
3
|
+
*
|
|
4
|
+
* Both hashes are produced by the caller (the SQL-family wiring at the
|
|
5
|
+
* consumption site) using the canonical contract hashing pipeline.
|
|
6
|
+
* Keeping the helper pure lets `migration-tools` stay framework-neutral
|
|
7
|
+
* — the SQL family already speaks `Contract<SqlStorage>`, the Mongo
|
|
8
|
+
* family speaks its own contract type, and both reduce to a hash string
|
|
9
|
+
* before drift detection runs.
|
|
10
|
+
*
|
|
11
|
+
* `priorHeadHash` is `null` when no `contract.json` exists yet on disk for
|
|
12
|
+
* the space (the descriptor declares an extension that has never been
|
|
13
|
+
* emitted into the user's repo). That's the "first emit" case — no
|
|
14
|
+
* drift to surface; the migrate emit will create the on-disk artefacts.
|
|
15
|
+
*/
|
|
16
|
+
export interface DetectSpaceContractDriftInputs {
|
|
17
|
+
readonly descriptorHash: string;
|
|
18
|
+
readonly priorHeadHash: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Result discriminant for {@link detectSpaceContractDrift}.
|
|
23
|
+
*
|
|
24
|
+
* - `noDrift`: descriptor hash and on-disk head hash agree byte-for-byte.
|
|
25
|
+
* The migrate emit can proceed with no warning.
|
|
26
|
+
* - `firstEmit`: no on-disk `contract.json` on disk yet. The extension
|
|
27
|
+
* was just added to `extensionPacks`; this run will create the
|
|
28
|
+
* on-disk artefacts. No warning either — the user's intent is to install
|
|
29
|
+
* the extension, not to "drift" from a state they haven't recorded.
|
|
30
|
+
* - `drift`: descriptor hash differs from on-disk head hash. The caller
|
|
31
|
+
* surfaces a non-fatal warning naming the extension and the
|
|
32
|
+
* diff direction (descriptor → on-disk head). The migrate emit proceeds
|
|
33
|
+
* normally so the bump is materialised this run; the warning just
|
|
34
|
+
* confirms the bump is being captured.
|
|
35
|
+
*
|
|
36
|
+
* `spaceId`, `descriptorHash`, and `priorHeadHash` are threaded through
|
|
37
|
+
* verbatim so the caller (logger / TerminalUI / strict-mode envelope)
|
|
38
|
+
* has everything it needs to format the warning message without
|
|
39
|
+
* re-reading the descriptor or the on-disk artefact.
|
|
40
|
+
*/
|
|
41
|
+
export type SpaceContractDriftResult = {
|
|
42
|
+
readonly kind: 'noDrift' | 'firstEmit' | 'drift';
|
|
43
|
+
readonly spaceId: string;
|
|
44
|
+
readonly descriptorHash: string;
|
|
45
|
+
readonly priorHeadHash: string | null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pure drift-detection primitive for a single contract space.
|
|
50
|
+
*
|
|
51
|
+
* Runs once per loaded extension space, just before computing the
|
|
52
|
+
* `priorContract` that feeds {@link import('./plan-all-spaces').planAllSpaces}.
|
|
53
|
+
* Hash equality is byte-for-byte (no normalisation) — both sides are
|
|
54
|
+
* already canonical hashes produced by the same pipeline, so any
|
|
55
|
+
* difference is meaningful drift.
|
|
56
|
+
*
|
|
57
|
+
* Synchronous, pure, no I/O. The caller (SQL family) reads the on-disk
|
|
58
|
+
* `contract.json` and computes its hash, then invokes this helper
|
|
59
|
+
* alongside the descriptor's `headRef.hash`. Composes naturally with
|
|
60
|
+
* {@link import('./read-contract-space-head-ref').readContractSpaceHeadRef}
|
|
61
|
+
* which provides the read-side primitive.
|
|
62
|
+
*
|
|
63
|
+
* The drift warning surfaces the extension name and the diff direction.
|
|
64
|
+
*/
|
|
65
|
+
export function detectSpaceContractDrift(
|
|
66
|
+
spaceId: string,
|
|
67
|
+
inputs: DetectSpaceContractDriftInputs,
|
|
68
|
+
): SpaceContractDriftResult {
|
|
69
|
+
if (inputs.priorHeadHash === null) {
|
|
70
|
+
return {
|
|
71
|
+
kind: 'firstEmit',
|
|
72
|
+
spaceId,
|
|
73
|
+
descriptorHash: inputs.descriptorHash,
|
|
74
|
+
priorHeadHash: null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (inputs.descriptorHash === inputs.priorHeadHash) {
|
|
78
|
+
return {
|
|
79
|
+
kind: 'noDrift',
|
|
80
|
+
spaceId,
|
|
81
|
+
descriptorHash: inputs.descriptorHash,
|
|
82
|
+
priorHeadHash: inputs.priorHeadHash,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
kind: 'drift',
|
|
87
|
+
spaceId,
|
|
88
|
+
descriptorHash: inputs.descriptorHash,
|
|
89
|
+
priorHeadHash: inputs.priorHeadHash,
|
|
90
|
+
};
|
|
91
|
+
}
|