@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,158 @@
|
|
|
1
|
+
import { notOk, ok } from '@prisma-next/utils/result';
|
|
2
|
+
import type {
|
|
3
|
+
AggregatePerSpacePlan,
|
|
4
|
+
AggregatePlannerError,
|
|
5
|
+
AggregatePlannerInput,
|
|
6
|
+
AggregatePlannerOutput,
|
|
7
|
+
} from './planner-types';
|
|
8
|
+
import { graphWalkStrategy } from './strategies/graph-walk';
|
|
9
|
+
import { synthStrategy } from './strategies/synth';
|
|
10
|
+
import type { ContractSpaceMember } from './types';
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
AggregateCurrentDBState,
|
|
14
|
+
AggregatePerSpacePlan,
|
|
15
|
+
AggregatePlannerError,
|
|
16
|
+
AggregatePlannerInput,
|
|
17
|
+
AggregatePlannerOutput,
|
|
18
|
+
AggregatePlannerSuccess,
|
|
19
|
+
CallerPolicy,
|
|
20
|
+
} from './planner-types';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Plan a migration across every member of a {@link ContractSpaceAggregate}.
|
|
24
|
+
*
|
|
25
|
+
* Strategy selection per member, in order; first match wins:
|
|
26
|
+
*
|
|
27
|
+
* 1. If `callerPolicy.ignoreGraphFor.has(member.spaceId)`:
|
|
28
|
+
* - If `member.headRef.invariants` is empty → synth.
|
|
29
|
+
* - Else → `policyConflict` (synth cannot satisfy authored invariants).
|
|
30
|
+
* 2. Else if `member.migrations.graph` is non-empty AND graph-walk
|
|
31
|
+
* succeeds → graph-walk.
|
|
32
|
+
* 3. Else if `member.headRef.invariants` is empty → synth.
|
|
33
|
+
* 4. Else → graph-walk failure → `extensionPathUnreachable` /
|
|
34
|
+
* `extensionPathUnsatisfiable`.
|
|
35
|
+
*
|
|
36
|
+
* Output `applyOrder` is `[...aggregate.extensions.map(spaceId), aggregate.app.spaceId]`
|
|
37
|
+
* — extensions alphabetical, then app — matching today's
|
|
38
|
+
* `concatenateSpaceApplyInputs` ordering. This preserves
|
|
39
|
+
* `MultiSpaceRunnerFailure.failingSpace` attribution byte-for-byte.
|
|
40
|
+
*
|
|
41
|
+
* Every emitted `MigrationPlan` has `targetId = aggregate.targetId`.
|
|
42
|
+
* No placeholder cast; no patch step.
|
|
43
|
+
*/
|
|
44
|
+
export async function planAggregate<TFamilyId extends string, TTargetId extends string>(
|
|
45
|
+
input: AggregatePlannerInput<TFamilyId, TTargetId>,
|
|
46
|
+
): Promise<AggregatePlannerOutput> {
|
|
47
|
+
const { aggregate, currentDBState, callerPolicy } = input;
|
|
48
|
+
const allMembers: ReadonlyArray<ContractSpaceMember> = [aggregate.app, ...aggregate.extensions];
|
|
49
|
+
|
|
50
|
+
const perSpace = new Map<string, AggregatePerSpacePlan>();
|
|
51
|
+
|
|
52
|
+
// Iterate in apply order so a per-member error short-circuits the
|
|
53
|
+
// walk in the same order the runner would walk inputs.
|
|
54
|
+
const orderedMembers: ReadonlyArray<ContractSpaceMember> = [
|
|
55
|
+
...aggregate.extensions,
|
|
56
|
+
aggregate.app,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const member of orderedMembers) {
|
|
60
|
+
const otherMembers = allMembers.filter((m) => m.spaceId !== member.spaceId);
|
|
61
|
+
const currentMarker = currentDBState.markersBySpaceId.get(member.spaceId) ?? null;
|
|
62
|
+
|
|
63
|
+
const ignoreGraph = callerPolicy.ignoreGraphFor.has(member.spaceId);
|
|
64
|
+
const invariantsRequired = member.headRef.invariants.length > 0;
|
|
65
|
+
|
|
66
|
+
if (ignoreGraph && invariantsRequired) {
|
|
67
|
+
const conflict: AggregatePlannerError = {
|
|
68
|
+
kind: 'policyConflict',
|
|
69
|
+
spaceId: member.spaceId,
|
|
70
|
+
detail: `\`callerPolicy.ignoreGraphFor\` requested for space "${member.spaceId}", but the member declares non-empty head-ref invariants (${member.headRef.invariants.join(', ')}). Synthesising a plan from the contract IR cannot satisfy authored invariants — the graph must be walked. Either remove "${member.spaceId}" from \`ignoreGraphFor\` or amend the on-disk head ref to declare zero invariants.`,
|
|
71
|
+
};
|
|
72
|
+
return notOk(conflict);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (ignoreGraph) {
|
|
76
|
+
const synthOutcome = await synthStrategy({
|
|
77
|
+
aggregateTargetId: aggregate.targetId,
|
|
78
|
+
member,
|
|
79
|
+
otherMembers,
|
|
80
|
+
schemaIntrospection: currentDBState.schemaIntrospection,
|
|
81
|
+
familyInstance: input.familyInstance,
|
|
82
|
+
migrations: input.migrations,
|
|
83
|
+
frameworkComponents: input.frameworkComponents,
|
|
84
|
+
operationPolicy: input.operationPolicy,
|
|
85
|
+
});
|
|
86
|
+
if (synthOutcome.kind === 'failure') {
|
|
87
|
+
return notOk({
|
|
88
|
+
kind: 'appSynthFailure',
|
|
89
|
+
spaceId: member.spaceId,
|
|
90
|
+
conflicts: synthOutcome.conflicts,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
perSpace.set(member.spaceId, synthOutcome.result);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Try graph-walk first when the graph has nodes; fall back to synth
|
|
98
|
+
// when the graph is empty AND no invariants are required.
|
|
99
|
+
if (member.migrations.graph.nodes.size > 0) {
|
|
100
|
+
const walked = graphWalkStrategy({
|
|
101
|
+
aggregateTargetId: aggregate.targetId,
|
|
102
|
+
member,
|
|
103
|
+
currentMarker,
|
|
104
|
+
});
|
|
105
|
+
if (walked.kind === 'ok') {
|
|
106
|
+
perSpace.set(member.spaceId, walked.result);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (walked.kind === 'unreachable') {
|
|
110
|
+
return notOk({
|
|
111
|
+
kind: 'extensionPathUnreachable',
|
|
112
|
+
spaceId: member.spaceId,
|
|
113
|
+
target: member.headRef.hash,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// unsatisfiable — surface
|
|
117
|
+
return notOk({
|
|
118
|
+
kind: 'extensionPathUnsatisfiable',
|
|
119
|
+
spaceId: member.spaceId,
|
|
120
|
+
missingInvariants: walked.missing,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Empty graph: synth is the only option, and it can only satisfy
|
|
125
|
+
// empty-invariant members.
|
|
126
|
+
if (invariantsRequired) {
|
|
127
|
+
return notOk({
|
|
128
|
+
kind: 'extensionPathUnsatisfiable',
|
|
129
|
+
spaceId: member.spaceId,
|
|
130
|
+
missingInvariants: [...member.headRef.invariants].sort(),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const synthOutcome = await synthStrategy({
|
|
135
|
+
aggregateTargetId: aggregate.targetId,
|
|
136
|
+
member,
|
|
137
|
+
otherMembers,
|
|
138
|
+
schemaIntrospection: currentDBState.schemaIntrospection,
|
|
139
|
+
familyInstance: input.familyInstance,
|
|
140
|
+
migrations: input.migrations,
|
|
141
|
+
frameworkComponents: input.frameworkComponents,
|
|
142
|
+
operationPolicy: input.operationPolicy,
|
|
143
|
+
});
|
|
144
|
+
if (synthOutcome.kind === 'failure') {
|
|
145
|
+
return notOk({
|
|
146
|
+
kind: 'appSynthFailure',
|
|
147
|
+
spaceId: member.spaceId,
|
|
148
|
+
conflicts: synthOutcome.conflicts,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
perSpace.set(member.spaceId, synthOutcome.result);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return ok({
|
|
155
|
+
perSpace,
|
|
156
|
+
applyOrder: [...aggregate.extensions.map((m) => m.spaceId), aggregate.app.spaceId],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ContractSpaceMember } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project the introspected live schema to the slice claimed by a single
|
|
5
|
+
* contract-space member.
|
|
6
|
+
*
|
|
7
|
+
* Returns the same `schema` value with every top-level table claimed by
|
|
8
|
+
* **other** members of the aggregate removed. Tables not claimed by any
|
|
9
|
+
* member flow through unchanged — the planner / verifier sees them as
|
|
10
|
+
* orphans (extras in strict mode).
|
|
11
|
+
*
|
|
12
|
+
* Used by:
|
|
13
|
+
*
|
|
14
|
+
* - The aggregate planner's **synth strategy**: when synthesising a
|
|
15
|
+
* plan against a member's contract, the live schema must be projected
|
|
16
|
+
* to that member's slice so the planner doesn't treat tables claimed
|
|
17
|
+
* by other members as "extras" and emit destructive ops to drop
|
|
18
|
+
* them.
|
|
19
|
+
* - The aggregate verifier's **schemaCheck**: projects per member so the
|
|
20
|
+
* single-contract `verifySqlSchema` only sees the slice claimed by
|
|
21
|
+
* the member it is checking. Closes the F23 architectural concern
|
|
22
|
+
* (multi-member deployments where each member's tables look like
|
|
23
|
+
* extras to every other member's verify pass).
|
|
24
|
+
*
|
|
25
|
+
* **Duck-typing semantics**: the helper operates on `unknown` for the
|
|
26
|
+
* schema and falls through structurally if the shape doesn't match.
|
|
27
|
+
* Every family today exposes `storage.tables: Record<string, ...>` and
|
|
28
|
+
* the introspected schema mirrors the same shape; a future family with
|
|
29
|
+
* a different storage shape gets the schema returned unchanged rather
|
|
30
|
+
* than blowing up the aggregate planner.
|
|
31
|
+
*/
|
|
32
|
+
export function projectSchemaToSpace(
|
|
33
|
+
schema: unknown,
|
|
34
|
+
member: ContractSpaceMember,
|
|
35
|
+
otherMembers: ReadonlyArray<ContractSpaceMember>,
|
|
36
|
+
): unknown {
|
|
37
|
+
if (typeof schema !== 'object' || schema === null) return schema;
|
|
38
|
+
const schemaObj = schema as { readonly tables?: unknown };
|
|
39
|
+
if (typeof schemaObj.tables !== 'object' || schemaObj.tables === null) return schema;
|
|
40
|
+
const schemaTables = schemaObj.tables as Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
const ownedByOthers = new Set<string>();
|
|
43
|
+
for (const other of otherMembers) {
|
|
44
|
+
if (other.spaceId === member.spaceId) continue;
|
|
45
|
+
const storage = (other.contract as { readonly storage?: unknown }).storage;
|
|
46
|
+
if (typeof storage !== 'object' || storage === null) continue;
|
|
47
|
+
const tables = (storage as { readonly tables?: unknown }).tables;
|
|
48
|
+
if (typeof tables !== 'object' || tables === null) continue;
|
|
49
|
+
for (const tableName of Object.keys(tables as Record<string, unknown>)) {
|
|
50
|
+
ownedByOthers.add(tableName);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (ownedByOthers.size === 0) return schema;
|
|
55
|
+
|
|
56
|
+
const prunedTables: Record<string, unknown> = {};
|
|
57
|
+
for (const [name, table] of Object.entries(schemaTables)) {
|
|
58
|
+
if (!ownedByOthers.has(name)) {
|
|
59
|
+
prunedTables[name] = table;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { ...schemaObj, tables: prunedTables };
|
|
64
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
2
|
+
import type { MigrationPlan } from '@prisma-next/framework-components/control';
|
|
3
|
+
import { EMPTY_CONTRACT_HASH } from '../../constants';
|
|
4
|
+
import { findPathWithDecision } from '../../migration-graph';
|
|
5
|
+
import type { MigrationOps } from '../../package';
|
|
6
|
+
import type { ContractMarkerRecordLike } from '../marker-types';
|
|
7
|
+
import type { AggregatePerSpacePlan } from '../planner-types';
|
|
8
|
+
import type { ContractSpaceMember } from '../types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Outcome variants for the graph-walk strategy. Mirrors
|
|
12
|
+
* {@link import('../../compute-extension-space-apply-path').ExtensionSpaceApplyPathOutcome}
|
|
13
|
+
* but operates against the **already-hydrated** `member.migrations.graph`
|
|
14
|
+
* instead of re-reading from disk. The aggregate planner converts
|
|
15
|
+
* these into {@link import('../planner-types').AggregatePlannerError}
|
|
16
|
+
* variants.
|
|
17
|
+
*/
|
|
18
|
+
export type GraphWalkOutcome =
|
|
19
|
+
| { readonly kind: 'ok'; readonly result: AggregatePerSpacePlan }
|
|
20
|
+
| { readonly kind: 'unreachable' }
|
|
21
|
+
| { readonly kind: 'unsatisfiable'; readonly missing: readonly string[] };
|
|
22
|
+
|
|
23
|
+
export interface GraphWalkStrategyInputs {
|
|
24
|
+
readonly aggregateTargetId: string;
|
|
25
|
+
readonly member: ContractSpaceMember;
|
|
26
|
+
readonly currentMarker: ContractMarkerRecordLike | null;
|
|
27
|
+
/**
|
|
28
|
+
* Optional ref name to decorate the resulting `PathDecision`. Used by
|
|
29
|
+
* `migration apply` to surface the user-supplied `--ref <name>` in
|
|
30
|
+
* structured-progress events and invariant-path error envelopes. The
|
|
31
|
+
* strategy itself does not interpret it.
|
|
32
|
+
*/
|
|
33
|
+
readonly refName?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Walk a member's hydrated migration graph from the live marker to
|
|
38
|
+
* `member.headRef.hash`, covering every required invariant.
|
|
39
|
+
*
|
|
40
|
+
* Pure synchronous function — no I/O. The aggregate's loader has
|
|
41
|
+
* already integrity-checked every package and reconstructed the graph;
|
|
42
|
+
* this strategy just looks up ops by `migrationHash` and assembles a
|
|
43
|
+
* `MigrationPlan` with `targetId` set from the aggregate (no
|
|
44
|
+
* placeholder cast).
|
|
45
|
+
*
|
|
46
|
+
* Required invariants are computed as `headRef.invariants \ marker.invariants`
|
|
47
|
+
* — the marker already declares some invariants satisfied; the path
|
|
48
|
+
* only needs to provide the remainder. Mirrors today's
|
|
49
|
+
* `computeExtensionSpaceApplyPath` semantics.
|
|
50
|
+
*/
|
|
51
|
+
export function graphWalkStrategy(input: GraphWalkStrategyInputs): GraphWalkOutcome {
|
|
52
|
+
const { aggregateTargetId, member, currentMarker, refName } = input;
|
|
53
|
+
const { graph, packagesByMigrationHash } = member.migrations;
|
|
54
|
+
|
|
55
|
+
const fromHash = currentMarker?.storageHash ?? EMPTY_CONTRACT_HASH;
|
|
56
|
+
const markerInvariants = new Set(currentMarker?.invariants ?? []);
|
|
57
|
+
const required = new Set(member.headRef.invariants.filter((id) => !markerInvariants.has(id)));
|
|
58
|
+
|
|
59
|
+
const outcome = findPathWithDecision(graph, fromHash, member.headRef.hash, {
|
|
60
|
+
required,
|
|
61
|
+
...(refName !== undefined ? { refName } : {}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (outcome.kind === 'unreachable') {
|
|
65
|
+
return { kind: 'unreachable' };
|
|
66
|
+
}
|
|
67
|
+
if (outcome.kind === 'unsatisfiable') {
|
|
68
|
+
return { kind: 'unsatisfiable', missing: outcome.missing };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pathOps: MigrationOps[number][] = [];
|
|
72
|
+
const providedInvariantsSet = new Set<string>();
|
|
73
|
+
const edgeRefs: Array<{
|
|
74
|
+
migrationHash: string;
|
|
75
|
+
dirName: string;
|
|
76
|
+
from: string;
|
|
77
|
+
to: string;
|
|
78
|
+
operationCount: number;
|
|
79
|
+
}> = [];
|
|
80
|
+
for (const edge of outcome.decision.selectedPath) {
|
|
81
|
+
const pkg = packagesByMigrationHash.get(edge.migrationHash);
|
|
82
|
+
if (!pkg) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Migration package missing for edge ${edge.migrationHash} in space "${member.spaceId}". The hydrated migration graph and packagesByMigrationHash map are out of sync — this should be unreachable; report.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
for (const op of pkg.ops) pathOps.push(op);
|
|
88
|
+
for (const invariant of pkg.metadata.providedInvariants) providedInvariantsSet.add(invariant);
|
|
89
|
+
edgeRefs.push({
|
|
90
|
+
migrationHash: edge.migrationHash,
|
|
91
|
+
dirName: edge.dirName,
|
|
92
|
+
from: edge.from,
|
|
93
|
+
to: edge.to,
|
|
94
|
+
operationCount: pkg.ops.length,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const plan: MigrationPlan = {
|
|
99
|
+
targetId: aggregateTargetId,
|
|
100
|
+
spaceId: member.spaceId,
|
|
101
|
+
origin: currentMarker === null ? null : { storageHash: currentMarker.storageHash },
|
|
102
|
+
destination: { storageHash: member.headRef.hash },
|
|
103
|
+
operations: pathOps,
|
|
104
|
+
providedInvariants: [...providedInvariantsSet].sort(),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
kind: 'ok',
|
|
109
|
+
result: {
|
|
110
|
+
plan,
|
|
111
|
+
displayOps: pathOps,
|
|
112
|
+
destinationContract: member.contract as Contract,
|
|
113
|
+
strategy: 'graph-walk',
|
|
114
|
+
migrationEdges: edgeRefs,
|
|
115
|
+
pathDecision: outcome.decision,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
MigrationPlannerResult,
|
|
9
|
+
TargetMigrationsCapability,
|
|
10
|
+
} from '@prisma-next/framework-components/control';
|
|
11
|
+
import type { AggregatePerSpacePlan } from '../planner-types';
|
|
12
|
+
import { projectSchemaToSpace } from '../project-schema-to-space';
|
|
13
|
+
import type { ContractSpaceMember } from '../types';
|
|
14
|
+
|
|
15
|
+
export interface SynthStrategyInputs<TFamilyId extends string, TTargetId extends string> {
|
|
16
|
+
readonly aggregateTargetId: string;
|
|
17
|
+
readonly member: ContractSpaceMember;
|
|
18
|
+
readonly otherMembers: ReadonlyArray<ContractSpaceMember>;
|
|
19
|
+
readonly schemaIntrospection: unknown;
|
|
20
|
+
readonly familyInstance: ControlFamilyInstance<TFamilyId, unknown>;
|
|
21
|
+
readonly migrations: TargetMigrationsCapability<
|
|
22
|
+
TFamilyId,
|
|
23
|
+
TTargetId,
|
|
24
|
+
ControlFamilyInstance<TFamilyId, unknown>
|
|
25
|
+
>;
|
|
26
|
+
readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<TFamilyId, TTargetId>>;
|
|
27
|
+
readonly operationPolicy: MigrationOperationPolicy;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SynthStrategyOutcome =
|
|
31
|
+
| { readonly kind: 'ok'; readonly result: AggregatePerSpacePlan }
|
|
32
|
+
| { readonly kind: 'failure'; readonly conflicts: readonly MigrationPlannerConflict[] };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The {@link MigrationPlanner.plan} interface is declared as synchronous,
|
|
36
|
+
* but historical and test fixture call sites have always invoked it
|
|
37
|
+
* with `await` (see prior `db-apply-per-space.ts`). Tolerating a
|
|
38
|
+
* Promise here keeps existing test mocks working without changing the
|
|
39
|
+
* declared family SPI.
|
|
40
|
+
*/
|
|
41
|
+
type MaybeAsyncPlannerResult = MigrationPlannerResult | Promise<MigrationPlannerResult>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Synthesise a migration plan for a single member by projecting the
|
|
45
|
+
* live schema down to that member's claimed slice and delegating to
|
|
46
|
+
* the family's `createPlanner(...).plan(...)`.
|
|
47
|
+
*
|
|
48
|
+
* Pre-projection (via {@link projectSchemaToSpace}) closes the F23
|
|
49
|
+
* concern: without it, the family's planner sees other members'
|
|
50
|
+
* tables as "extras" and emits destructive ops to drop them. With it,
|
|
51
|
+
* the planner only sees the slice this member claims.
|
|
52
|
+
*
|
|
53
|
+
* The synthesised plan's `targetId` is set from `aggregateTargetId`
|
|
54
|
+
* (the aggregate's ambient target). The family's planner does not
|
|
55
|
+
* stamp `targetId` on the produced plan; the aggregate planner is
|
|
56
|
+
* the single point that knows the target.
|
|
57
|
+
*
|
|
58
|
+
* Used by:
|
|
59
|
+
*
|
|
60
|
+
* - The app member by default (CLI policy
|
|
61
|
+
* `ignoreGraphFor: { app.spaceId }`).
|
|
62
|
+
* - Any extension member whose `headRef.invariants` is empty (the
|
|
63
|
+
* strategy selector falls back to synth when graph-walk isn't
|
|
64
|
+
* required).
|
|
65
|
+
*/
|
|
66
|
+
export async function synthStrategy<TFamilyId extends string, TTargetId extends string>(
|
|
67
|
+
input: SynthStrategyInputs<TFamilyId, TTargetId>,
|
|
68
|
+
): Promise<SynthStrategyOutcome> {
|
|
69
|
+
const projectedSchema = projectSchemaToSpace(
|
|
70
|
+
input.schemaIntrospection,
|
|
71
|
+
input.member,
|
|
72
|
+
input.otherMembers,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const planner = input.migrations.createPlanner(input.familyInstance);
|
|
76
|
+
const plannerResult: MigrationPlannerResult = await (planner.plan({
|
|
77
|
+
contract: input.member.contract,
|
|
78
|
+
schema: projectedSchema,
|
|
79
|
+
policy: input.operationPolicy,
|
|
80
|
+
fromContract: null,
|
|
81
|
+
frameworkComponents: input.frameworkComponents,
|
|
82
|
+
spaceId: input.member.spaceId,
|
|
83
|
+
}) as MaybeAsyncPlannerResult);
|
|
84
|
+
|
|
85
|
+
if (plannerResult.kind === 'failure') {
|
|
86
|
+
return { kind: 'failure', conflicts: plannerResult.conflicts };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const synthedPlan = plannerResult.plan;
|
|
90
|
+
// The family planner returns a class-instance-shaped plan whose
|
|
91
|
+
// `destination` / `operations` are accessors on the prototype, often
|
|
92
|
+
// backed by private fields. A naive spread (`{ ...synthedPlan }`)
|
|
93
|
+
// would lose those accessors and produce a plan with
|
|
94
|
+
// `destination: undefined`; rebinding the prototype on a plain
|
|
95
|
+
// object would break private-field access. We instead wrap the plan
|
|
96
|
+
// in a Proxy that forwards every read except `targetId`, which is
|
|
97
|
+
// stamped from the aggregate's ambient target. This preserves the
|
|
98
|
+
// planner's class semantics while keeping the aggregate the single
|
|
99
|
+
// source of truth for `targetId`.
|
|
100
|
+
const plan: MigrationPlan = new Proxy(synthedPlan, {
|
|
101
|
+
get(target, prop) {
|
|
102
|
+
if (prop === 'targetId') return input.aggregateTargetId;
|
|
103
|
+
// Forward `this` as the original target so prototype-bound
|
|
104
|
+
// private fields (#destination, #operations, …) resolve.
|
|
105
|
+
return Reflect.get(target, prop, target);
|
|
106
|
+
},
|
|
107
|
+
has(target, prop) {
|
|
108
|
+
if (prop === 'targetId') return true;
|
|
109
|
+
return Reflect.has(target, prop);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
kind: 'ok',
|
|
115
|
+
result: {
|
|
116
|
+
plan,
|
|
117
|
+
displayOps: synthedPlan.operations,
|
|
118
|
+
destinationContract: input.member.contract as Contract,
|
|
119
|
+
strategy: 'synth',
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
2
|
+
import type { MigrationGraph } from '../graph';
|
|
3
|
+
import type { OnDiskMigrationPackage } from '../package';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hydrated migration graph for a single contract space.
|
|
7
|
+
*
|
|
8
|
+
* `graph` is the structural shortest-path graph (forward / reverse chain,
|
|
9
|
+
* deterministic tie-break order) reconstructed from a set of on-disk
|
|
10
|
+
* migration packages. `packagesByMigrationHash` is the lookup table the
|
|
11
|
+
* graph-walk strategy uses to resolve a path's edge sequence back to the
|
|
12
|
+
* concrete `OnDiskMigrationPackage` (and therefore the operation list) for
|
|
13
|
+
* apply.
|
|
14
|
+
*
|
|
15
|
+
* Eagerly hydrated by the loader. Once a `ContractSpaceAggregate` exists,
|
|
16
|
+
* downstream consumers do **not** touch the filesystem to walk graphs or
|
|
17
|
+
* resolve packages — the aggregate is the boundary.
|
|
18
|
+
*/
|
|
19
|
+
export interface HydratedMigrationGraph {
|
|
20
|
+
readonly graph: MigrationGraph;
|
|
21
|
+
readonly packagesByMigrationHash: ReadonlyMap<string, OnDiskMigrationPackage>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One contract space — app or extension — as a member of a
|
|
26
|
+
* {@link ContractSpaceAggregate}. Every member has the same shape.
|
|
27
|
+
*
|
|
28
|
+
* - `spaceId`: `'app'` for the application, otherwise the extension's
|
|
29
|
+
* id (validated against `[a-z][a-z0-9_-]{0,63}`).
|
|
30
|
+
* - `contract`: the validated contract value for this member. For the
|
|
31
|
+
* app, the user's authored contract; for an extension, the on-disk
|
|
32
|
+
* `migrations/<spaceId>/contract.json`. Both have already passed the
|
|
33
|
+
* family's `validateContract` at the loader boundary.
|
|
34
|
+
* - `headRef.hash`: the storage hash this member is targeting. For the
|
|
35
|
+
* app, equals `contract.storage.storageHash`. For extensions, the
|
|
36
|
+
* on-disk `refs/head.json.hash`.
|
|
37
|
+
* - `headRef.invariants`: alphabetically sorted, deduplicated invariant
|
|
38
|
+
* ids declared on the head ref. Empty for the app member (the app's
|
|
39
|
+
* plan is synthesised from the contract IR, no invariants required).
|
|
40
|
+
* - `migrations`: the hydrated migration graph for this space. Possibly
|
|
41
|
+
* empty (an extension whose on-disk head ref points at the
|
|
42
|
+
* empty-contract sentinel and ships no migrations yet, or the app
|
|
43
|
+
* when the user hasn't authored any).
|
|
44
|
+
*/
|
|
45
|
+
export interface ContractSpaceMember {
|
|
46
|
+
readonly spaceId: string;
|
|
47
|
+
readonly contract: Contract;
|
|
48
|
+
readonly headRef: {
|
|
49
|
+
readonly hash: string;
|
|
50
|
+
readonly invariants: readonly string[];
|
|
51
|
+
};
|
|
52
|
+
readonly migrations: HydratedMigrationGraph;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Typed value carrying the user's app contract plus every loaded
|
|
57
|
+
* extension contract space, fully hydrated and internally consistent.
|
|
58
|
+
*
|
|
59
|
+
* Produced once per CLI invocation by `loadContractSpaceAggregate`.
|
|
60
|
+
* Every downstream component (planner, verifier, runner adapter)
|
|
61
|
+
* consumes this value rather than rebuilding state from disk.
|
|
62
|
+
*
|
|
63
|
+
* Invariants the loader enforces at construction:
|
|
64
|
+
*
|
|
65
|
+
* 1. `targetId` is consistent across every member (`contract.target`
|
|
66
|
+
* matches `aggregate.targetId`). The aggregate's `targetId` is the
|
|
67
|
+
* `Config.adapter.targetId` value the loader was told to use.
|
|
68
|
+
* 2. `aggregate.extensions` is sorted alphabetically by `spaceId`.
|
|
69
|
+
* Mirrors {@link import('../concatenate-space-apply-inputs').concatenateSpaceApplyInputs}'s
|
|
70
|
+
* extension ordering convention so downstream apply order matches
|
|
71
|
+
* today's behaviour byte-for-byte.
|
|
72
|
+
* 3. No two members claim the same storage element (table / type / etc.).
|
|
73
|
+
* 4. For each extension member: `member.headRef.hash` is reachable from
|
|
74
|
+
* the empty-contract sentinel in `member.migrations.graph` (or the
|
|
75
|
+
* graph is empty and `member.headRef.hash === EMPTY_CONTRACT_HASH`).
|
|
76
|
+
* 5. For the app member: `member.headRef.hash` equals
|
|
77
|
+
* `member.contract.storage.storageHash`. The app's `migrations`
|
|
78
|
+
* is hydrated from the user's authored `migrations/` (or empty if
|
|
79
|
+
* none).
|
|
80
|
+
*
|
|
81
|
+
* The aggregate is **type-uniform** post-construction: app/extension
|
|
82
|
+
* distinguishability survives only at the caller-policy layer
|
|
83
|
+
* (`ignoreGraphFor: new Set([appSpaceId])`), not on member shape.
|
|
84
|
+
*/
|
|
85
|
+
export interface ContractSpaceAggregate {
|
|
86
|
+
readonly targetId: string;
|
|
87
|
+
readonly app: ContractSpaceMember;
|
|
88
|
+
readonly extensions: readonly ContractSpaceMember[];
|
|
89
|
+
}
|