@prisma-next/migration-tools 0.5.0-dev.66 → 0.5.0-dev.68

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 (98) hide show
  1. package/dist/{constants-B87kJAGj.mjs → constants-DWV9_o2Z.mjs} +2 -2
  2. package/dist/{constants-B87kJAGj.mjs.map → constants-DWV9_o2Z.mjs.map} +1 -1
  3. package/dist/{errors-DQsXvidG.mjs → errors-EPL_9p9f.mjs} +13 -8
  4. package/dist/errors-EPL_9p9f.mjs.map +1 -0
  5. package/dist/exports/aggregate.d.mts +534 -0
  6. package/dist/exports/aggregate.d.mts.map +1 -0
  7. package/dist/exports/aggregate.mjs +598 -0
  8. package/dist/exports/aggregate.mjs.map +1 -0
  9. package/dist/exports/constants.d.mts.map +1 -1
  10. package/dist/exports/constants.mjs +2 -3
  11. package/dist/exports/errors.d.mts +6 -1
  12. package/dist/exports/errors.d.mts.map +1 -1
  13. package/dist/exports/errors.mjs +2 -3
  14. package/dist/exports/graph.d.mts +1 -1
  15. package/dist/exports/graph.mjs +1 -1
  16. package/dist/exports/hash.d.mts +2 -2
  17. package/dist/exports/hash.d.mts.map +1 -1
  18. package/dist/exports/hash.mjs +2 -3
  19. package/dist/exports/invariants.d.mts +14 -4
  20. package/dist/exports/invariants.d.mts.map +1 -1
  21. package/dist/exports/invariants.mjs +2 -4
  22. package/dist/exports/io.d.mts +26 -2
  23. package/dist/exports/io.d.mts.map +1 -1
  24. package/dist/exports/io.mjs +2 -5
  25. package/dist/exports/metadata.d.mts +1 -1
  26. package/dist/exports/metadata.mjs +1 -1
  27. package/dist/exports/migration-graph.d.mts +2 -2
  28. package/dist/exports/migration-graph.d.mts.map +1 -1
  29. package/dist/exports/migration-graph.mjs +1 -525
  30. package/dist/exports/migration-ts.d.mts.map +1 -1
  31. package/dist/exports/migration-ts.mjs +1 -3
  32. package/dist/exports/migration-ts.mjs.map +1 -1
  33. package/dist/exports/migration.d.mts +1 -1
  34. package/dist/exports/migration.d.mts.map +1 -1
  35. package/dist/exports/migration.mjs +5 -6
  36. package/dist/exports/migration.mjs.map +1 -1
  37. package/dist/exports/package.d.mts +1 -1
  38. package/dist/exports/package.mjs +1 -1
  39. package/dist/exports/refs.d.mts.map +1 -1
  40. package/dist/exports/refs.mjs +2 -3
  41. package/dist/exports/refs.mjs.map +1 -1
  42. package/dist/exports/spaces.d.mts +341 -238
  43. package/dist/exports/spaces.d.mts.map +1 -1
  44. package/dist/exports/spaces.mjs +138 -348
  45. package/dist/exports/spaces.mjs.map +1 -1
  46. package/dist/{graph-Czaj8O2q.d.mts → graph-HMWAldoR.d.mts} +1 -1
  47. package/dist/graph-HMWAldoR.d.mts.map +1 -0
  48. package/dist/{hash-G0bAfIGh.mjs → hash-By50zM_E.mjs} +2 -4
  49. package/dist/hash-By50zM_E.mjs.map +1 -0
  50. package/dist/{invariants-4Avb_Yhy.mjs → invariants-Duc8f9NM.mjs} +17 -7
  51. package/dist/invariants-Duc8f9NM.mjs.map +1 -0
  52. package/dist/{io-CDJaWGbt.mjs → io-D13dLvUh.mjs} +46 -14
  53. package/dist/io-D13dLvUh.mjs.map +1 -0
  54. package/dist/migration-graph-DGNnKDY5.mjs +523 -0
  55. package/dist/migration-graph-DGNnKDY5.mjs.map +1 -0
  56. package/dist/{op-schema-BiF1ZYqH.mjs → op-schema-D5qkXfEf.mjs} +2 -3
  57. package/dist/{op-schema-BiF1ZYqH.mjs.map → op-schema-D5qkXfEf.mjs.map} +1 -1
  58. package/dist/{package-B3Yl6DTr.d.mts → package-BjiZ7KDy.d.mts} +1 -1
  59. package/dist/package-BjiZ7KDy.d.mts.map +1 -0
  60. package/dist/read-contract-space-contract-C3-1eyaI.mjs +298 -0
  61. package/dist/read-contract-space-contract-C3-1eyaI.mjs.map +1 -0
  62. package/package.json +13 -9
  63. package/src/aggregate/loader.ts +409 -0
  64. package/src/aggregate/marker-types.ts +16 -0
  65. package/src/aggregate/planner-types.ts +137 -0
  66. package/src/aggregate/planner.ts +158 -0
  67. package/src/aggregate/project-schema-to-space.ts +64 -0
  68. package/src/aggregate/strategies/graph-walk.ts +92 -0
  69. package/src/aggregate/strategies/synth.ts +122 -0
  70. package/src/aggregate/types.ts +89 -0
  71. package/src/aggregate/verifier.ts +230 -0
  72. package/src/assert-descriptor-self-consistency.ts +70 -0
  73. package/src/compute-extension-space-apply-path.ts +152 -0
  74. package/src/concatenate-space-apply-inputs.ts +2 -2
  75. package/src/detect-space-contract-drift.ts +22 -26
  76. package/src/{emit-pinned-space-artefacts.ts → emit-contract-space-artefacts.ts} +14 -33
  77. package/src/errors.ts +11 -5
  78. package/src/exports/aggregate.ts +37 -0
  79. package/src/exports/errors.ts +1 -0
  80. package/src/exports/io.ts +1 -0
  81. package/src/exports/spaces.ts +23 -10
  82. package/src/gather-disk-contract-space-state.ts +62 -0
  83. package/src/invariants.ts +14 -3
  84. package/src/io.ts +42 -0
  85. package/src/plan-all-spaces.ts +3 -7
  86. package/src/read-contract-space-contract.ts +44 -0
  87. package/src/read-contract-space-head-ref.ts +63 -0
  88. package/src/space-layout.ts +4 -11
  89. package/src/verify-contract-spaces.ts +45 -49
  90. package/dist/errors-DQsXvidG.mjs.map +0 -1
  91. package/dist/exports/migration-graph.mjs.map +0 -1
  92. package/dist/graph-Czaj8O2q.d.mts.map +0 -1
  93. package/dist/hash-G0bAfIGh.mjs.map +0 -1
  94. package/dist/invariants-4Avb_Yhy.mjs.map +0 -1
  95. package/dist/io-CDJaWGbt.mjs.map +0 -1
  96. package/dist/package-B3Yl6DTr.d.mts.map +0 -1
  97. package/src/read-pinned-contract-hash.ts +0 -77
  98. /package/dist/{metadata-CSjwljJx.d.mts → metadata-BnLFiI6B.d.mts} +0 -0
@@ -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
+ }
@@ -10,8 +10,8 @@ import { APP_SPACE_ID } from './space-layout';
10
10
  * and the helper preserves it through the concatenation.
11
11
  *
12
12
  * - `migrationDirectory` is the on-disk migration directory for the
13
- * space — `<projectRoot>/migrations` for `'app'` and
14
- * `<projectRoot>/migrations/<space-id>` for an extension space.
13
+ * space — `<projectRoot>/migrations/<space-id>` (uniform; the app
14
+ * subspaces under its own `<APP_SPACE_ID>/` directory).
15
15
  * - `currentMarkerHash` and `currentMarkerInvariants` are the values
16
16
  * read from the `prisma_contract.marker` row keyed by `space = <space-id>`
17
17
  * (T1.1). `null` hash = no marker row yet.
@@ -8,43 +8,41 @@
8
8
  * family speaks its own contract type, and both reduce to a hash string
9
9
  * before drift detection runs.
10
10
  *
11
- * `pinnedHash` is `null` when no pinned `contract.json` exists yet for
11
+ * `priorHeadHash` is `null` when no `contract.json` exists yet on disk for
12
12
  * the space (the descriptor declares an extension that has never been
13
13
  * emitted into the user's repo). That's the "first emit" case — no
14
- * drift to surface; the migrate emit will create the pinned files.
15
- *
16
- * @see specs/framework-mechanism.spec.md § 3 — Drift detection (T1.9).
14
+ * drift to surface; the migrate emit will create the on-disk artefacts.
17
15
  */
18
16
  export interface DetectSpaceContractDriftInputs {
19
17
  readonly descriptorHash: string;
20
- readonly pinnedHash: string | null;
18
+ readonly priorHeadHash: string | null;
21
19
  }
22
20
 
23
21
  /**
24
22
  * Result discriminant for {@link detectSpaceContractDrift}.
25
23
  *
26
- * - `noDrift`: descriptor hash and pinned hash agree byte-for-byte.
24
+ * - `noDrift`: descriptor hash and on-disk head hash agree byte-for-byte.
27
25
  * The migrate emit can proceed with no warning.
28
- * - `firstEmit`: no pinned `contract.json` on disk yet. The extension
26
+ * - `firstEmit`: no on-disk `contract.json` on disk yet. The extension
29
27
  * was just added to `extensionPacks`; this run will create the
30
- * pinned files. No warning either — the user's intent is to install
31
- * the extension, not to "drift" from a state they haven't pinned.
32
- * - `drift`: descriptor hash differs from pinned hash. The caller
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
33
31
  * surfaces a non-fatal warning naming the extension and the
34
- * diff direction (descriptor → pinned). The migrate emit proceeds
32
+ * diff direction (descriptor → on-disk head). The migrate emit proceeds
35
33
  * normally so the bump is materialised this run; the warning just
36
34
  * confirms the bump is being captured.
37
35
  *
38
- * `spaceId`, `descriptorHash`, and `pinnedHash` are threaded through
36
+ * `spaceId`, `descriptorHash`, and `priorHeadHash` are threaded through
39
37
  * verbatim so the caller (logger / TerminalUI / strict-mode envelope)
40
38
  * has everything it needs to format the warning message without
41
- * re-reading the descriptor or the pinned file.
39
+ * re-reading the descriptor or the on-disk artefact.
42
40
  */
43
41
  export type SpaceContractDriftResult = {
44
42
  readonly kind: 'noDrift' | 'firstEmit' | 'drift';
45
43
  readonly spaceId: string;
46
44
  readonly descriptorHash: string;
47
- readonly pinnedHash: string | null;
45
+ readonly priorHeadHash: string | null;
48
46
  };
49
47
 
50
48
  /**
@@ -56,40 +54,38 @@ export type SpaceContractDriftResult = {
56
54
  * already canonical hashes produced by the same pipeline, so any
57
55
  * difference is meaningful drift.
58
56
  *
59
- * Synchronous, pure, no I/O. The caller (SQL family in M2 R1) reads
60
- * the pinned `contract.json` and computes its hash, then invokes this
61
- * helper alongside the descriptor's `headRef.hash`. Composes naturally
62
- * with {@link import('./read-pinned-contract-hash').readPinnedContractHash}
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}
63
61
  * which provides the read-side primitive.
64
62
  *
65
- * @see specs/framework-mechanism.spec.md § 3 Drift detection (T1.9).
66
- * @see specs/framework-mechanism.spec.md AM7 — drift warning surfaces
67
- * the extension name and the diff direction.
63
+ * The drift warning surfaces the extension name and the diff direction.
68
64
  */
69
65
  export function detectSpaceContractDrift(
70
66
  spaceId: string,
71
67
  inputs: DetectSpaceContractDriftInputs,
72
68
  ): SpaceContractDriftResult {
73
- if (inputs.pinnedHash === null) {
69
+ if (inputs.priorHeadHash === null) {
74
70
  return {
75
71
  kind: 'firstEmit',
76
72
  spaceId,
77
73
  descriptorHash: inputs.descriptorHash,
78
- pinnedHash: null,
74
+ priorHeadHash: null,
79
75
  };
80
76
  }
81
- if (inputs.descriptorHash === inputs.pinnedHash) {
77
+ if (inputs.descriptorHash === inputs.priorHeadHash) {
82
78
  return {
83
79
  kind: 'noDrift',
84
80
  spaceId,
85
81
  descriptorHash: inputs.descriptorHash,
86
- pinnedHash: inputs.pinnedHash,
82
+ priorHeadHash: inputs.priorHeadHash,
87
83
  };
88
84
  }
89
85
  return {
90
86
  kind: 'drift',
91
87
  spaceId,
92
88
  descriptorHash: inputs.descriptorHash,
93
- pinnedHash: inputs.pinnedHash,
89
+ priorHeadHash: inputs.priorHeadHash,
94
90
  };
95
91
  }
@@ -1,21 +1,11 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'pathe';
3
3
  import { canonicalizeJson } from './canonicalize-json';
4
- import { errorPinnedArtefactsAppSpace } from './errors';
5
- import { APP_SPACE_ID, assertValidSpaceId } from './space-layout';
4
+ import type { ContractSpaceHeadRef } from './read-contract-space-head-ref';
5
+ import { assertValidSpaceId } from './space-layout';
6
6
 
7
7
  /**
8
- * Pinned head reference for a contract space — `(hash, invariants)`.
9
- * Mirrors {@link import('./refs').RefEntry} but is redeclared locally so
10
- * callers can construct the input without depending on the refs module.
11
- */
12
- export interface PinnedSpaceHeadRef {
13
- readonly hash: string;
14
- readonly invariants: readonly string[];
15
- }
16
-
17
- /**
18
- * Inputs for {@link emitPinnedSpaceArtefacts}.
8
+ * Inputs for {@link emitContractSpaceArtefacts}.
19
9
  *
20
10
  * - `contract` is the canonical contract value the framework just emitted
21
11
  * for the space; it is serialised through {@link canonicalizeJson}, so
@@ -29,19 +19,19 @@ export interface PinnedSpaceHeadRef {
29
19
  * needs), so this helper accepts the text verbatim and writes it out
30
20
  * without further transformation.
31
21
  *
32
- * - `headRef` is the pinned head reference for the space.
22
+ * - `headRef` is the head reference for the space.
33
23
  * `invariants` are sorted alphabetically before serialisation so two
34
24
  * callers passing the same set in different orders produce
35
25
  * byte-identical `refs/head.json`.
36
26
  */
37
- export interface PinnedSpaceArtefactInputs {
27
+ export interface ContractSpaceArtefactInputs {
38
28
  readonly contract: unknown;
39
29
  readonly contractDts: string;
40
- readonly headRef: PinnedSpaceHeadRef;
30
+ readonly headRef: ContractSpaceHeadRef;
41
31
  }
42
32
 
43
33
  /**
44
- * Emit the pinned per-space artefacts (`contract.json`, `contract.d.ts`,
34
+ * Emit the per-space artefacts (`contract.json`, `contract.d.ts`,
45
35
  * `refs/head.json`) under `<projectMigrationsDir>/<spaceId>/`.
46
36
  *
47
37
  * Always-overwrite: the framework owns these files; running `migrate`
@@ -49,29 +39,20 @@ export interface PinnedSpaceArtefactInputs {
49
39
  * helper does not check pre-existing contents — re-emit always wins.
50
40
  *
51
41
  * Path layout matches the convention in
52
- * [`spaceMigrationDirectory`](./space-layout.ts), with two restrictions
53
- * specific to pinned artefacts:
54
- *
55
- * - Rejects the app space (`spaceId === APP_SPACE_ID`): the app space's
56
- * canonical `contract.json` lives at the project root, not under
57
- * `migrations/`. Callers that want to emit it use the app-space
58
- * contract emit pipeline.
59
- * - Validates `spaceId` against `[a-z][a-z0-9_-]{0,63}` via
60
- * {@link assertValidSpaceId} for the same filesystem-safety reasons.
42
+ * [`spaceMigrationDirectory`](./space-layout.ts). The space id is
43
+ * validated against `[a-z][a-z0-9_-]{0,63}` via
44
+ * {@link assertValidSpaceId} for filesystem-safety reasons; the helper
45
+ * accepts every space uniformly (including the app space, default
46
+ * `'app'`).
61
47
  *
62
48
  * The migrations directory and space subdirectory are created if they
63
49
  * do not yet exist (`mkdir { recursive: true }`).
64
- *
65
- * @see specs/framework-mechanism.spec.md § 3 — Pinned artefact emission (T1.8).
66
50
  */
67
- export async function emitPinnedSpaceArtefacts(
51
+ export async function emitContractSpaceArtefacts(
68
52
  projectMigrationsDir: string,
69
53
  spaceId: string,
70
- inputs: PinnedSpaceArtefactInputs,
54
+ inputs: ContractSpaceArtefactInputs,
71
55
  ): Promise<void> {
72
- if (spaceId === APP_SPACE_ID) {
73
- throw errorPinnedArtefactsAppSpace();
74
- }
75
56
  assertValidSpaceId(spaceId);
76
57
 
77
58
  const dir = join(projectMigrationsDir, spaceId);