@prisma-next/migration-tools 0.5.0-dev.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +34 -22
  2. package/dist/{constants-BRi0X7B_.mjs → constants-DWV9_o2Z.mjs} +2 -2
  3. package/dist/{constants-BRi0X7B_.mjs.map → constants-DWV9_o2Z.mjs.map} +1 -1
  4. package/dist/errors-EPL_9p9f.mjs +297 -0
  5. package/dist/errors-EPL_9p9f.mjs.map +1 -0
  6. package/dist/exports/aggregate.d.mts +614 -0
  7. package/dist/exports/aggregate.d.mts.map +1 -0
  8. package/dist/exports/aggregate.mjs +611 -0
  9. package/dist/exports/aggregate.mjs.map +1 -0
  10. package/dist/exports/constants.d.mts.map +1 -1
  11. package/dist/exports/constants.mjs +2 -3
  12. package/dist/exports/errors.d.mts +68 -0
  13. package/dist/exports/errors.d.mts.map +1 -0
  14. package/dist/exports/errors.mjs +2 -0
  15. package/dist/exports/graph.d.mts +2 -0
  16. package/dist/exports/graph.mjs +1 -0
  17. package/dist/exports/hash.d.mts +52 -0
  18. package/dist/exports/hash.d.mts.map +1 -0
  19. package/dist/exports/hash.mjs +2 -0
  20. package/dist/exports/invariants.d.mts +34 -0
  21. package/dist/exports/invariants.d.mts.map +1 -0
  22. package/dist/exports/invariants.mjs +2 -0
  23. package/dist/exports/io.d.mts +66 -6
  24. package/dist/exports/io.d.mts.map +1 -1
  25. package/dist/exports/io.mjs +2 -3
  26. package/dist/exports/metadata.d.mts +2 -0
  27. package/dist/exports/metadata.mjs +1 -0
  28. package/dist/exports/migration-graph.d.mts +2 -0
  29. package/dist/exports/migration-graph.mjs +2 -0
  30. package/dist/exports/migration-ts.d.mts.map +1 -1
  31. package/dist/exports/migration-ts.mjs +2 -4
  32. package/dist/exports/migration-ts.mjs.map +1 -1
  33. package/dist/exports/migration.d.mts +15 -14
  34. package/dist/exports/migration.d.mts.map +1 -1
  35. package/dist/exports/migration.mjs +70 -43
  36. package/dist/exports/migration.mjs.map +1 -1
  37. package/dist/exports/package.d.mts +3 -0
  38. package/dist/exports/package.mjs +1 -0
  39. package/dist/exports/refs.d.mts.map +1 -1
  40. package/dist/exports/refs.mjs +3 -4
  41. package/dist/exports/refs.mjs.map +1 -1
  42. package/dist/exports/spaces.d.mts +550 -0
  43. package/dist/exports/spaces.d.mts.map +1 -0
  44. package/dist/exports/spaces.mjs +223 -0
  45. package/dist/exports/spaces.mjs.map +1 -0
  46. package/dist/graph-HMWAldoR.d.mts +28 -0
  47. package/dist/graph-HMWAldoR.d.mts.map +1 -0
  48. package/dist/hash-By50zM_E.mjs +74 -0
  49. package/dist/hash-By50zM_E.mjs.map +1 -0
  50. package/dist/invariants-Duc8f9NM.mjs +52 -0
  51. package/dist/invariants-Duc8f9NM.mjs.map +1 -0
  52. package/dist/io-D13dLvUh.mjs +239 -0
  53. package/dist/io-D13dLvUh.mjs.map +1 -0
  54. package/dist/metadata-CFvm3ayn.d.mts +2 -0
  55. package/dist/migration-graph-DGNnKDY5.mjs +523 -0
  56. package/dist/migration-graph-DGNnKDY5.mjs.map +1 -0
  57. package/dist/migration-graph-DulOITvG.d.mts +124 -0
  58. package/dist/migration-graph-DulOITvG.d.mts.map +1 -0
  59. package/dist/op-schema-D5qkXfEf.mjs +13 -0
  60. package/dist/op-schema-D5qkXfEf.mjs.map +1 -0
  61. package/dist/package-BjiZ7KDy.d.mts +21 -0
  62. package/dist/package-BjiZ7KDy.d.mts.map +1 -0
  63. package/dist/read-contract-space-contract-C3-1eyaI.mjs +298 -0
  64. package/dist/read-contract-space-contract-C3-1eyaI.mjs.map +1 -0
  65. package/package.json +44 -19
  66. package/src/aggregate/loader.ts +409 -0
  67. package/src/aggregate/marker-types.ts +16 -0
  68. package/src/aggregate/planner-types.ts +171 -0
  69. package/src/aggregate/planner.ts +158 -0
  70. package/src/aggregate/project-schema-to-space.ts +64 -0
  71. package/src/aggregate/strategies/graph-walk.ts +118 -0
  72. package/src/aggregate/strategies/synth.ts +122 -0
  73. package/src/aggregate/types.ts +89 -0
  74. package/src/aggregate/verifier.ts +230 -0
  75. package/src/assert-descriptor-self-consistency.ts +70 -0
  76. package/src/compute-extension-space-apply-path.ts +152 -0
  77. package/src/concatenate-space-apply-inputs.ts +90 -0
  78. package/src/detect-space-contract-drift.ts +91 -0
  79. package/src/emit-contract-space-artefacts.ts +70 -0
  80. package/src/errors.ts +251 -17
  81. package/src/exports/aggregate.ts +42 -0
  82. package/src/exports/errors.ts +8 -0
  83. package/src/exports/graph.ts +1 -0
  84. package/src/exports/hash.ts +2 -0
  85. package/src/exports/invariants.ts +1 -0
  86. package/src/exports/io.ts +3 -1
  87. package/src/exports/metadata.ts +1 -0
  88. package/src/exports/{dag.ts → migration-graph.ts} +3 -2
  89. package/src/exports/migration.ts +0 -1
  90. package/src/exports/package.ts +2 -0
  91. package/src/exports/spaces.ts +49 -0
  92. package/src/gather-disk-contract-space-state.ts +62 -0
  93. package/src/graph-ops.ts +57 -30
  94. package/src/graph.ts +25 -0
  95. package/src/hash.ts +91 -0
  96. package/src/invariants.ts +56 -0
  97. package/src/io.ts +163 -40
  98. package/src/metadata.ts +1 -0
  99. package/src/migration-base.ts +97 -56
  100. package/src/migration-graph.ts +676 -0
  101. package/src/op-schema.ts +11 -0
  102. package/src/package.ts +21 -0
  103. package/src/plan-all-spaces.ts +76 -0
  104. package/src/read-contract-space-contract.ts +44 -0
  105. package/src/read-contract-space-head-ref.ts +63 -0
  106. package/src/space-layout.ts +48 -0
  107. package/src/verify-contract-spaces.ts +272 -0
  108. package/dist/attestation-BnzTb0Qp.mjs +0 -65
  109. package/dist/attestation-BnzTb0Qp.mjs.map +0 -1
  110. package/dist/errors-BmiSgz1j.mjs +0 -160
  111. package/dist/errors-BmiSgz1j.mjs.map +0 -1
  112. package/dist/exports/attestation.d.mts +0 -37
  113. package/dist/exports/attestation.d.mts.map +0 -1
  114. package/dist/exports/attestation.mjs +0 -4
  115. package/dist/exports/dag.d.mts +0 -51
  116. package/dist/exports/dag.d.mts.map +0 -1
  117. package/dist/exports/dag.mjs +0 -386
  118. package/dist/exports/dag.mjs.map +0 -1
  119. package/dist/exports/types.d.mts +0 -35
  120. package/dist/exports/types.d.mts.map +0 -1
  121. package/dist/exports/types.mjs +0 -3
  122. package/dist/io-Cd6GLyjK.mjs +0 -153
  123. package/dist/io-Cd6GLyjK.mjs.map +0 -1
  124. package/dist/types-DyGXcWWp.d.mts +0 -71
  125. package/dist/types-DyGXcWWp.d.mts.map +0 -1
  126. package/src/attestation.ts +0 -81
  127. package/src/dag.ts +0 -426
  128. package/src/exports/attestation.ts +0 -2
  129. package/src/exports/types.ts +0 -10
  130. 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
+ }