@prisma-next/migration-tools 0.11.0-dev.6 → 0.11.0-dev.60
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 +4 -4
- package/dist/{errors-DGYwcwXs.mjs → errors-vFROOhCR.mjs} +46 -21
- package/dist/errors-vFROOhCR.mjs.map +1 -0
- package/dist/exports/aggregate.d.mts +302 -178
- package/dist/exports/aggregate.d.mts.map +1 -1
- package/dist/exports/aggregate.mjs +475 -238
- package/dist/exports/aggregate.mjs.map +1 -1
- package/dist/exports/errors.d.mts +2 -2
- package/dist/exports/errors.d.mts.map +1 -1
- package/dist/exports/errors.mjs +1 -1
- package/dist/exports/graph.d.mts +1 -1
- package/dist/exports/hash.d.mts +8 -9
- package/dist/exports/hash.d.mts.map +1 -1
- package/dist/exports/hash.mjs +1 -1
- package/dist/exports/invariants.d.mts +1 -1
- package/dist/exports/invariants.d.mts.map +1 -1
- package/dist/exports/invariants.mjs +1 -1
- package/dist/exports/io.d.mts +2 -83
- package/dist/exports/io.mjs +1 -1
- package/dist/exports/metadata.d.mts +2 -2
- package/dist/exports/migration-graph.d.mts +9 -2
- package/dist/exports/migration-graph.d.mts.map +1 -0
- package/dist/exports/migration-graph.mjs +3 -2
- package/dist/exports/migration-ts.d.mts.map +1 -1
- package/dist/exports/migration-ts.mjs.map +1 -1
- package/dist/exports/migration.d.mts +5 -6
- package/dist/exports/migration.d.mts.map +1 -1
- package/dist/exports/migration.mjs +14 -32
- package/dist/exports/migration.mjs.map +1 -1
- package/dist/exports/package.d.mts +1 -1
- package/dist/exports/ref-resolution.d.mts +2 -2
- package/dist/exports/ref-resolution.d.mts.map +1 -1
- package/dist/exports/ref-resolution.mjs +1 -1
- package/dist/exports/ref-resolution.mjs.map +1 -1
- package/dist/exports/refs.d.mts +15 -2
- package/dist/exports/refs.d.mts.map +1 -0
- package/dist/exports/refs.mjs +3 -2
- package/dist/exports/spaces.d.mts +31 -132
- package/dist/exports/spaces.d.mts.map +1 -1
- package/dist/exports/spaces.mjs +13 -9
- package/dist/exports/spaces.mjs.map +1 -1
- package/dist/{graph-BrLXqoUc.d.mts → graph-3dLMZp5l.d.mts} +1 -2
- package/dist/graph-3dLMZp5l.d.mts.map +1 -0
- package/dist/graph-membership-BV23F1IV.mjs +15 -0
- package/dist/graph-membership-BV23F1IV.mjs.map +1 -0
- package/dist/{hash-Cr4WIr4Z.mjs → hash--Y7vCpN3.mjs} +8 -9
- package/dist/hash--Y7vCpN3.mjs.map +1 -0
- package/dist/{invariants-0daYEzyo.mjs → invariants-C23nXy1c.mjs} +2 -2
- package/dist/{invariants-0daYEzyo.mjs.map → invariants-C23nXy1c.mjs.map} +1 -1
- package/dist/{io-BPLfzvZe.mjs → io-BGlPOt9b.mjs} +100 -13
- package/dist/io-BGlPOt9b.mjs.map +1 -0
- package/dist/io-BH4G3F-i.d.mts +124 -0
- package/dist/io-BH4G3F-i.d.mts.map +1 -0
- package/dist/metadata-Bp9X04gM.d.mts +2 -0
- package/dist/{migration-graph-nlS4TRpn.mjs → migration-graph-BMAqSfv9.mjs} +6 -26
- package/dist/migration-graph-BMAqSfv9.mjs.map +1 -0
- package/dist/{migration-graph-De0dUZoC.d.mts → migration-graph-CWEM2SLR.d.mts} +6 -6
- package/dist/migration-graph-CWEM2SLR.d.mts.map +1 -0
- package/dist/op-schema-D5qkXfEf.mjs.map +1 -1
- package/dist/{package-DZj8YvD0.d.mts → package-Ca-J_z_0.d.mts} +1 -1
- package/dist/package-Ca-J_z_0.d.mts.map +1 -0
- package/dist/{read-contract-space-contract-DRueB4Aa.mjs → read-contract-space-contract-TbeXuJXL.mjs} +32 -5
- package/dist/read-contract-space-contract-TbeXuJXL.mjs.map +1 -0
- package/dist/{refs-BDHo5l_g.mjs → refs-C-_WUrPw.mjs} +97 -4
- package/dist/refs-C-_WUrPw.mjs.map +1 -0
- package/dist/refs-C7wuYFqZ.d.mts +42 -0
- package/dist/refs-C7wuYFqZ.d.mts.map +1 -0
- package/dist/snapshot-38tKJ9o9.mjs +137 -0
- package/dist/snapshot-38tKJ9o9.mjs.map +1 -0
- package/dist/verify-contract-spaces-BdysZdQk.d.mts +132 -0
- package/dist/verify-contract-spaces-BdysZdQk.d.mts.map +1 -0
- package/package.json +6 -6
- package/src/aggregate/aggregate.ts +266 -0
- package/src/aggregate/check-integrity.ts +243 -0
- package/src/aggregate/loader.ts +161 -334
- package/src/aggregate/planner.ts +8 -6
- package/src/aggregate/project-schema-to-space.ts +3 -8
- package/src/aggregate/strategies/graph-walk.ts +12 -7
- package/src/aggregate/strategies/synth.ts +2 -2
- package/src/aggregate/types.ts +81 -62
- package/src/aggregate/verifier.ts +9 -6
- package/src/assert-descriptor-self-consistency.ts +6 -0
- package/src/compute-extension-space-apply-path.ts +1 -1
- package/src/emit-contract-space-artefacts.ts +4 -3
- package/src/errors.ts +58 -2
- package/src/exports/aggregate.ts +18 -8
- package/src/exports/io.ts +2 -0
- package/src/exports/metadata.ts +1 -1
- package/src/exports/migration-graph.ts +1 -0
- package/src/exports/refs.ts +11 -0
- package/src/exports/spaces.ts +3 -0
- package/src/graph-membership.ts +17 -0
- package/src/graph.ts +0 -1
- package/src/hash.ts +7 -8
- package/src/integrity-violation.ts +114 -0
- package/src/io.ts +139 -14
- package/src/metadata.ts +1 -1
- package/src/migration-base.ts +10 -30
- package/src/migration-graph.ts +7 -35
- package/src/read-contract-space-head-ref.ts +5 -2
- package/src/refs/snapshot.ts +197 -0
- package/src/refs.ts +124 -1
- package/src/space-layout.ts +30 -0
- package/dist/errors-DGYwcwXs.mjs.map +0 -1
- package/dist/exports/io.d.mts.map +0 -1
- package/dist/graph-BrLXqoUc.d.mts.map +0 -1
- package/dist/hash-Cr4WIr4Z.mjs.map +0 -1
- package/dist/io-BPLfzvZe.mjs.map +0 -1
- package/dist/metadata-BFX0xdz8.d.mts +0 -2
- package/dist/migration-graph-De0dUZoC.d.mts.map +0 -1
- package/dist/migration-graph-nlS4TRpn.mjs.map +0 -1
- package/dist/package-DZj8YvD0.d.mts.map +0 -1
- package/dist/read-contract-space-contract-DRueB4Aa.mjs.map +0 -1
- package/dist/refs-BDHo5l_g.mjs.map +0 -1
- package/dist/refs-CDaNerhT.d.mts +0 -16
- package/dist/refs-CDaNerhT.d.mts.map +0 -1
- package/src/aggregate/extract-storage-element-names.ts +0 -75
package/src/aggregate/loader.ts
CHANGED
|
@@ -1,376 +1,203 @@
|
|
|
1
1
|
import type { Contract } from '@prisma-next/contract/types';
|
|
2
|
-
import { notOk, ok, type Result } from '@prisma-next/utils/result';
|
|
3
|
-
import { EMPTY_CONTRACT_HASH } from '../constants';
|
|
4
2
|
import { MigrationToolsError } from '../errors';
|
|
5
3
|
import { readMigrationsDir } from '../io';
|
|
6
|
-
import { reconstructGraph } from '../migration-graph';
|
|
7
|
-
import type { OnDiskMigrationPackage } from '../package';
|
|
8
4
|
import { readContractSpaceContract } from '../read-contract-space-contract';
|
|
9
5
|
import { readContractSpaceHeadRef } from '../read-contract-space-head-ref';
|
|
10
|
-
import {
|
|
6
|
+
import { HEAD_REF_NAME, type RefLoadProblem, readRefsTolerant } from '../refs';
|
|
7
|
+
import {
|
|
8
|
+
APP_SPACE_ID,
|
|
9
|
+
isValidSpaceId,
|
|
10
|
+
RESERVED_SPACE_SUBDIR_NAMES,
|
|
11
|
+
spaceMigrationDirectory,
|
|
12
|
+
spaceRefsDirectory,
|
|
13
|
+
} from '../space-layout';
|
|
11
14
|
import { listContractSpaceDirectories } from '../verify-contract-spaces';
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
15
|
+
import { createContractSpaceAggregate, createContractSpaceMember } from './aggregate';
|
|
16
|
+
import { computeIntegrityViolations, type IntegritySpaceState } from './check-integrity';
|
|
17
|
+
import type { ContractSpaceAggregate } from './types';
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
if (MigrationToolsError.is(error)) {
|
|
17
|
-
return error.why;
|
|
18
|
-
}
|
|
19
|
-
if (error instanceof Error) {
|
|
20
|
-
return error.message;
|
|
21
|
-
}
|
|
22
|
-
return String(error);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Single declared extension entry the loader needs from `Config.extensionPacks`.
|
|
27
|
-
*
|
|
28
|
-
* Only the subset of fields the loader operates on:
|
|
29
|
-
*
|
|
30
|
-
* - `id` — the space id (also the directory name under `migrations/`).
|
|
31
|
-
* - `targetId` — the configured `Config.adapter.targetId` value the
|
|
32
|
-
* declaring extension declared. The loader rejects mismatches against
|
|
33
|
-
* the aggregate's `targetId` with `targetMismatch`.
|
|
34
|
-
*
|
|
35
|
-
* Whether the descriptor declares a contract space is decided by whether
|
|
36
|
-
* its corresponding `migrations/<id>/` directory exists on disk
|
|
37
|
-
* (materialised by the seed phase before the loader runs); the loader
|
|
38
|
-
* never reads the descriptor's `contractJson` itself. That makes the
|
|
39
|
-
* aggregate's apply / verify paths byte-for-byte independent of the
|
|
40
|
-
* descriptor module — `db verify` succeeds even if the descriptor's
|
|
41
|
-
* `contractJson` is a throwing getter.
|
|
42
|
-
*
|
|
43
|
-
* Typed structurally so the migration-tools layer stays framework-neutral.
|
|
44
|
-
*/
|
|
45
|
-
export interface DeclaredExtensionEntry {
|
|
46
|
-
readonly id: string;
|
|
47
|
-
readonly targetId: string;
|
|
48
|
-
}
|
|
19
|
+
export type { DeclaredExtensionEntry } from '../integrity-violation';
|
|
49
20
|
|
|
50
21
|
/**
|
|
51
22
|
* Inputs for {@link loadContractSpaceAggregate}.
|
|
52
23
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
24
|
+
* Construction reads migration **state** from disk (`migrations/<space>/`
|
|
25
|
+
* packages + refs + head refs). The app's *live* contract is not a disk
|
|
26
|
+
* artefact — in Prisma Next it is always compiled from the project's
|
|
27
|
+
* central contract, so the caller always has it and threads it in as
|
|
28
|
+
* `appContract`. `deserializeContract` is held and called lazily only for
|
|
29
|
+
* the on-disk extension contracts (`migrations/<ext>/contract.json`).
|
|
58
30
|
*/
|
|
59
31
|
export interface LoadAggregateInput {
|
|
60
|
-
readonly targetId: string;
|
|
61
32
|
readonly migrationsDir: string;
|
|
33
|
+
readonly deserializeContract: (raw: unknown) => Contract;
|
|
62
34
|
readonly appContract: Contract;
|
|
63
|
-
readonly declaredExtensions: ReadonlyArray<DeclaredExtensionEntry>;
|
|
64
|
-
readonly deserializeContract: (contractJson: unknown) => Contract;
|
|
65
|
-
/**
|
|
66
|
-
* Hydrated migration graph for the **app member**.
|
|
67
|
-
*
|
|
68
|
-
* The framework-neutral migration-tools layer doesn't know how to read
|
|
69
|
-
* the user's authored `migrations/` directory (the app member's
|
|
70
|
-
* migration-package layout is family-aware: ops.json shape, manifest
|
|
71
|
-
* keys, etc.). Callers — the SQL family today — read the user's
|
|
72
|
-
* `migrations/` and hand the resulting `OnDiskMigrationPackage[]` through.
|
|
73
|
-
*
|
|
74
|
-
* Passing `[]` is valid (greenfield project, no authored migrations).
|
|
75
|
-
* Equivalent to `migrations/` not existing or being empty.
|
|
76
|
-
*/
|
|
77
|
-
readonly appMigrationPackages: ReadonlyArray<OnDiskMigrationPackage>;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Discriminated failure variants the loader emits.
|
|
82
|
-
*
|
|
83
|
-
* Every variant short-circuits at first hit; the loader does not keep
|
|
84
|
-
* collecting after the first violation in any phase except for layout
|
|
85
|
-
* (where every layout offence is bundled into one `layoutViolation`).
|
|
86
|
-
*/
|
|
87
|
-
export type LoadAggregateError =
|
|
88
|
-
| { readonly kind: 'layoutViolation'; readonly violations: readonly LayoutViolation[] }
|
|
89
|
-
| { readonly kind: 'integrityFailure'; readonly spaceId: string; readonly detail: string }
|
|
90
|
-
| { readonly kind: 'validationFailure'; readonly spaceId: string; readonly detail: string }
|
|
91
|
-
| {
|
|
92
|
-
readonly kind: 'disjointnessViolation';
|
|
93
|
-
readonly element: string;
|
|
94
|
-
readonly claimedBy: readonly string[];
|
|
95
|
-
}
|
|
96
|
-
| {
|
|
97
|
-
readonly kind: 'targetMismatch';
|
|
98
|
-
readonly spaceId: string;
|
|
99
|
-
readonly expected: string;
|
|
100
|
-
readonly actual: string;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Single layout violation; bundled into a `layoutViolation` error so
|
|
105
|
-
* users see every layout offence at once rather than fixing them one
|
|
106
|
-
* at a time across re-runs.
|
|
107
|
-
*
|
|
108
|
-
* - `declaredButUnmigrated`: extension declared in `extensionPacks` with
|
|
109
|
-
* a `contractSpace` but no contract-space dir on disk. Remediation:
|
|
110
|
-
* `prisma-next migrate`.
|
|
111
|
-
* - `orphanSpaceDir`: contract-space dir under `migrations/` for an extension
|
|
112
|
-
* not in `extensionPacks`. Remediation: remove the directory, or
|
|
113
|
-
* re-add the extension to `extensionPacks`.
|
|
114
|
-
*/
|
|
115
|
-
export type LayoutViolation =
|
|
116
|
-
| { readonly kind: 'declaredButUnmigrated'; readonly spaceId: string }
|
|
117
|
-
| { readonly kind: 'orphanSpaceDir'; readonly spaceId: string };
|
|
118
|
-
|
|
119
|
-
export type LoadAggregateOutput = Result<
|
|
120
|
-
{ readonly aggregate: ContractSpaceAggregate },
|
|
121
|
-
LoadAggregateError
|
|
122
|
-
>;
|
|
123
|
-
|
|
124
|
-
interface LoadedExtensionState {
|
|
125
|
-
readonly entry: DeclaredExtensionEntry;
|
|
126
|
-
readonly contract: Contract;
|
|
127
|
-
readonly headRefHash: string;
|
|
128
|
-
readonly headRefInvariants: readonly string[];
|
|
129
|
-
readonly migrations: HydratedMigrationGraph;
|
|
130
35
|
}
|
|
131
36
|
|
|
132
37
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
38
|
+
* Build a tolerant, queryable {@link ContractSpaceAggregate} from on-disk
|
|
39
|
+
* migration state plus the caller's live app contract.
|
|
135
40
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* {@link readMigrationsDir} / {@link readContractSpaceHeadRef} /
|
|
145
|
-
* {@link readContractSpaceContract} / `deserializeContract`), and
|
|
146
|
-
* disjointness — into a single typed value.
|
|
41
|
+
* Building **never throws on disk content**: a hash- or
|
|
42
|
+
* invariants-mismatched package is retained, an unparseable package is
|
|
43
|
+
* omitted, a missing extension head ref leaves `headRef: null`, and an
|
|
44
|
+
* unreadable on-disk contract defers its failure to `member.contract()`.
|
|
45
|
+
* Every such problem is judged by {@link ContractSpaceAggregate.checkIntegrity}
|
|
46
|
+
* rather than aborting the load. The only rejections are catastrophic I/O
|
|
47
|
+
* (a `migrations/` that exists but is unreadable for reasons other than
|
|
48
|
+
* absence).
|
|
147
49
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
50
|
+
* The app space's head ref is synthesised from the live contract's
|
|
51
|
+
* storage hash (the app contract is authored independently of the
|
|
52
|
+
* migration graph), and `app.contract()` returns the supplied contract.
|
|
53
|
+
* Extension spaces read their contract, refs, and head ref from disk.
|
|
150
54
|
*/
|
|
151
55
|
export async function loadContractSpaceAggregate(
|
|
152
56
|
input: LoadAggregateInput,
|
|
153
|
-
): Promise<
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
if (appContractTarget !== input.targetId) {
|
|
157
|
-
return notOk({
|
|
158
|
-
kind: 'targetMismatch',
|
|
159
|
-
spaceId: APP_SPACE_ID,
|
|
160
|
-
expected: input.targetId,
|
|
161
|
-
actual: appContractTarget,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
57
|
+
): Promise<ContractSpaceAggregate> {
|
|
58
|
+
const { migrationsDir, deserializeContract, appContract } = input;
|
|
59
|
+
const targetId = appContract.target;
|
|
164
60
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return notOk({
|
|
168
|
-
kind: 'targetMismatch',
|
|
169
|
-
spaceId: entry.id,
|
|
170
|
-
expected: input.targetId,
|
|
171
|
-
actual: entry.targetId,
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
}
|
|
61
|
+
const appState = await loadAppSpace(migrationsDir, appContract, deserializeContract);
|
|
62
|
+
const extensionStates = await loadExtensionSpaces(migrationsDir, deserializeContract);
|
|
175
63
|
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
// Every declared extension contributes an entry to the aggregate when
|
|
179
|
-
// a corresponding `migrations/<id>/` directory exists on disk. The
|
|
180
|
-
// loader treats the directory's presence as the membership signal —
|
|
181
|
-
// the descriptor itself is not read — so codec-only extensions (no
|
|
182
|
-
// on-disk dir) and contract-space extensions (dir present) are
|
|
183
|
-
// distinguished structurally.
|
|
184
|
-
const declaredSpaceIds = new Set(input.declaredExtensions.map((e) => e.id));
|
|
185
|
-
const allDirs = await listContractSpaceDirectories(input.migrationsDir);
|
|
186
|
-
// The app member is implicitly declared (it is always part of the
|
|
187
|
-
// aggregate); its `migrations/<APP_SPACE_ID>/` directory may exist or
|
|
188
|
-
// not (greenfield projects start with neither). Filter it out of the
|
|
189
|
-
// orphan / declared-but-unmigrated checks so the layout precheck is
|
|
190
|
-
// about extensions only.
|
|
191
|
-
const extensionDirsOnDisk = allDirs.filter((d) => d !== APP_SPACE_ID);
|
|
192
|
-
const spaceDirSet = new Set(extensionDirsOnDisk);
|
|
64
|
+
const spaces: readonly IntegritySpaceState[] = [appState, ...extensionStates];
|
|
193
65
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (!spaceDirSet.has(id)) {
|
|
202
|
-
layoutViolations.push({ kind: 'declaredButUnmigrated', spaceId: id });
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (layoutViolations.length > 0) {
|
|
206
|
-
return notOk({ kind: 'layoutViolation', violations: layoutViolations });
|
|
207
|
-
}
|
|
66
|
+
return createContractSpaceAggregate({
|
|
67
|
+
targetId,
|
|
68
|
+
app: appState.member,
|
|
69
|
+
extensions: extensionStates.map((state) => state.member),
|
|
70
|
+
checkIntegrity: (opts) => computeIntegrityViolations({ targetId, spaces }, opts),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
208
73
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
detail: `Head ref \`refs/head.json\` is missing for extension space "${entry.id}".`,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
74
|
+
async function loadAppSpace(
|
|
75
|
+
migrationsDir: string,
|
|
76
|
+
appContract: Contract,
|
|
77
|
+
deserializeContract: (raw: unknown) => Contract,
|
|
78
|
+
): Promise<IntegritySpaceState> {
|
|
79
|
+
const spaceDir = spaceMigrationDirectory(migrationsDir, APP_SPACE_ID);
|
|
80
|
+
const { packages, problems } = await readMigrationsDir(spaceDir);
|
|
81
|
+
const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir));
|
|
220
82
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
let spaceContract: Contract;
|
|
233
|
-
try {
|
|
234
|
-
spaceContract = input.deserializeContract(spaceContractRaw);
|
|
235
|
-
} catch (error) {
|
|
236
|
-
return notOk({
|
|
237
|
-
kind: 'validationFailure',
|
|
238
|
-
spaceId: entry.id,
|
|
239
|
-
detail: integrityDetail(error),
|
|
240
|
-
});
|
|
241
|
-
}
|
|
83
|
+
const member = createContractSpaceMember({
|
|
84
|
+
spaceId: APP_SPACE_ID,
|
|
85
|
+
packages,
|
|
86
|
+
refs,
|
|
87
|
+
headRef: { hash: appContract.storage.storageHash, invariants: [] },
|
|
88
|
+
refsDir: spaceRefsDirectory(spaceDir),
|
|
89
|
+
resolveContract: () => appContract,
|
|
90
|
+
deserializeContract,
|
|
91
|
+
});
|
|
242
92
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
expected: input.targetId,
|
|
248
|
-
actual: spaceContract.target,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
93
|
+
// The app head ref is synthesised from the live contract, so there is
|
|
94
|
+
// no on-disk head.json to be missing or corrupt for it.
|
|
95
|
+
return { member, problems, refProblems, headRefProblem: null, isApp: true };
|
|
96
|
+
}
|
|
251
97
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
98
|
+
async function loadExtensionSpaces(
|
|
99
|
+
migrationsDir: string,
|
|
100
|
+
deserializeContract: (raw: unknown) => Contract,
|
|
101
|
+
): Promise<readonly IntegritySpaceState[]> {
|
|
102
|
+
const candidateDirs = await listContractSpaceDirectories(migrationsDir);
|
|
103
|
+
const extensionIds = candidateDirs
|
|
104
|
+
.filter((name) => name !== APP_SPACE_ID)
|
|
105
|
+
.filter((name) => !RESERVED_SPACE_SUBDIR_NAMES.has(name))
|
|
106
|
+
.filter(isValidSpaceId)
|
|
107
|
+
.sort();
|
|
108
|
+
|
|
109
|
+
const states: IntegritySpaceState[] = [];
|
|
110
|
+
for (const spaceId of extensionIds) {
|
|
111
|
+
states.push(await loadExtensionSpace(migrationsDir, spaceId, deserializeContract));
|
|
112
|
+
}
|
|
113
|
+
return states;
|
|
114
|
+
}
|
|
265
115
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
116
|
+
async function loadExtensionSpace(
|
|
117
|
+
migrationsDir: string,
|
|
118
|
+
spaceId: string,
|
|
119
|
+
deserializeContract: (raw: unknown) => Contract,
|
|
120
|
+
): Promise<IntegritySpaceState> {
|
|
121
|
+
const spaceDir = spaceMigrationDirectory(migrationsDir, spaceId);
|
|
122
|
+
const { packages, problems } = await readMigrationsDir(spaceDir);
|
|
123
|
+
const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir));
|
|
124
|
+
const { headRef, problem: headRefProblem } = await readHeadRefTolerant(migrationsDir, spaceId);
|
|
125
|
+
const rawContract = await readRawContractDeferred(migrationsDir, spaceId);
|
|
126
|
+
|
|
127
|
+
const member = createContractSpaceMember({
|
|
128
|
+
spaceId,
|
|
129
|
+
packages,
|
|
130
|
+
refs,
|
|
131
|
+
headRef,
|
|
132
|
+
refsDir: spaceRefsDirectory(spaceDir),
|
|
133
|
+
resolveContract: () => deserializeContract(rawContract()),
|
|
134
|
+
deserializeContract,
|
|
135
|
+
});
|
|
276
136
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
// sentinel (a never-emitted extension space; not a typical scenario
|
|
280
|
-
// because the layout precheck would have flagged the missing
|
|
281
|
-
// dir, but defensible).
|
|
282
|
-
if (graph.nodes.size === 0) {
|
|
283
|
-
if (headRef.hash !== EMPTY_CONTRACT_HASH) {
|
|
284
|
-
return notOk({
|
|
285
|
-
kind: 'integrityFailure',
|
|
286
|
-
spaceId: entry.id,
|
|
287
|
-
detail: `Head ref "${headRef.hash}" is not present in the (empty) on-disk migration graph.`,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
} else if (!graph.nodes.has(headRef.hash)) {
|
|
291
|
-
return notOk({
|
|
292
|
-
kind: 'integrityFailure',
|
|
293
|
-
spaceId: entry.id,
|
|
294
|
-
detail: `Head ref "${headRef.hash}" is not present in the on-disk migration graph.`,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
137
|
+
return { member, problems, refProblems, headRefProblem, isApp: false };
|
|
138
|
+
}
|
|
297
139
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
140
|
+
/**
|
|
141
|
+
* The result of resolving an extension's `refs/head.json`: the parsed
|
|
142
|
+
* head ref (or `null` when the file is absent or corrupt) plus a problem
|
|
143
|
+
* when the file exists but cannot be parsed.
|
|
144
|
+
*/
|
|
145
|
+
interface HeadRefReadResult {
|
|
146
|
+
readonly headRef: Awaited<ReturnType<typeof readContractSpaceHeadRef>>;
|
|
147
|
+
readonly problem: RefLoadProblem | null;
|
|
148
|
+
}
|
|
301
149
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Read an extension's head ref, distinguishing a *genuinely absent*
|
|
152
|
+
* `head.json` (`headRef: null`, no problem — judged `headRefMissing`)
|
|
153
|
+
* from one that *exists but cannot be parsed* (`headRef: null` plus a
|
|
154
|
+
* problem — judged `refUnreadable`, not `headRefMissing`).
|
|
155
|
+
* `readContractSpaceHeadRef` already returns `null` only for ENOENT and
|
|
156
|
+
* throws for unparseable / schema-invalid content, so the throw is the
|
|
157
|
+
* corruption signal. Construction never throws on disk content.
|
|
158
|
+
*/
|
|
159
|
+
function isToleratedRefHeadReadError(error: unknown): boolean {
|
|
160
|
+
if (MigrationToolsError.is(error)) return true;
|
|
161
|
+
if (!(error instanceof Error)) return false;
|
|
162
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
163
|
+
return code === 'ENOENT' || code === 'EISDIR';
|
|
164
|
+
}
|
|
310
165
|
|
|
311
|
-
|
|
312
|
-
|
|
166
|
+
async function readHeadRefTolerant(
|
|
167
|
+
migrationsDir: string,
|
|
168
|
+
spaceId: string,
|
|
169
|
+
): Promise<HeadRefReadResult> {
|
|
313
170
|
try {
|
|
314
|
-
|
|
171
|
+
const headRef = await readContractSpaceHeadRef(migrationsDir, spaceId);
|
|
172
|
+
return { headRef, problem: null };
|
|
315
173
|
} catch (error) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
});
|
|
174
|
+
if (!isToleratedRefHeadReadError(error)) {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
return { headRef: null, problem: { refName: HEAD_REF_NAME, detail: detailOf(error) } };
|
|
321
178
|
}
|
|
322
|
-
|
|
323
|
-
input.appMigrationPackages.map((p) => [p.metadata.migrationHash, p]),
|
|
324
|
-
);
|
|
325
|
-
|
|
326
|
-
const appMember: ContractSpaceMember = {
|
|
327
|
-
spaceId: APP_SPACE_ID,
|
|
328
|
-
contract: input.appContract,
|
|
329
|
-
headRef: {
|
|
330
|
-
hash: input.appContract.storage.storageHash,
|
|
331
|
-
invariants: [],
|
|
332
|
-
},
|
|
333
|
-
migrations: {
|
|
334
|
-
graph: appGraph,
|
|
335
|
-
packagesByMigrationHash: appPackagesByMigrationHash,
|
|
336
|
-
},
|
|
337
|
-
};
|
|
179
|
+
}
|
|
338
180
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
headRef: {
|
|
343
|
-
hash: s.headRefHash,
|
|
344
|
-
invariants: s.headRefInvariants,
|
|
345
|
-
},
|
|
346
|
-
migrations: s.migrations,
|
|
347
|
-
}));
|
|
181
|
+
function detailOf(error: unknown): string {
|
|
182
|
+
return error instanceof Error ? error.message : String(error);
|
|
183
|
+
}
|
|
348
184
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
185
|
+
/**
|
|
186
|
+
* Read the raw on-disk contract eagerly (cheap I/O) but defer its
|
|
187
|
+
* (throwing) failure to call time, so a missing or unparseable
|
|
188
|
+
* `contract.json` becomes a `contract()` throw — surfaced as
|
|
189
|
+
* `contractUnreadable` — rather than a construction failure.
|
|
190
|
+
*/
|
|
191
|
+
async function readRawContractDeferred(
|
|
192
|
+
migrationsDir: string,
|
|
193
|
+
spaceId: string,
|
|
194
|
+
): Promise<() => unknown> {
|
|
195
|
+
try {
|
|
196
|
+
const raw = await readContractSpaceContract(migrationsDir, spaceId);
|
|
197
|
+
return () => raw;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return () => {
|
|
200
|
+
throw error;
|
|
201
|
+
};
|
|
367
202
|
}
|
|
368
|
-
|
|
369
|
-
return ok({
|
|
370
|
-
aggregate: {
|
|
371
|
-
targetId: input.targetId,
|
|
372
|
-
app: appMember,
|
|
373
|
-
extensions: extensionMembers,
|
|
374
|
-
},
|
|
375
|
-
});
|
|
376
203
|
}
|
package/src/aggregate/planner.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { notOk, ok } from '@prisma-next/utils/result';
|
|
2
|
+
import { requireHeadRef } from './aggregate';
|
|
2
3
|
import type {
|
|
3
4
|
AggregatePerSpacePlan,
|
|
4
5
|
AggregatePlannerError,
|
|
@@ -28,7 +29,7 @@ export type {
|
|
|
28
29
|
* 1. If `callerPolicy.ignoreGraphFor.has(member.spaceId)`:
|
|
29
30
|
* - If `member.headRef.invariants` is empty → synth.
|
|
30
31
|
* - Else → `policyConflict` (synth cannot satisfy authored invariants).
|
|
31
|
-
* 2. Else if `member.
|
|
32
|
+
* 2. Else if `member.graph()` is non-empty AND graph-walk
|
|
32
33
|
* succeeds → graph-walk.
|
|
33
34
|
* 3. Else if `member.headRef.invariants` is empty → synth.
|
|
34
35
|
* 4. Else → graph-walk failure → `extensionPathUnreachable` /
|
|
@@ -60,15 +61,16 @@ export async function planAggregate<TFamilyId extends string, TTargetId extends
|
|
|
60
61
|
for (const member of orderedMembers) {
|
|
61
62
|
const otherMembers = allMembers.filter((m) => m.spaceId !== member.spaceId);
|
|
62
63
|
const currentMarker = currentDBState.markersBySpaceId.get(member.spaceId) ?? null;
|
|
64
|
+
const headRef = requireHeadRef(member);
|
|
63
65
|
|
|
64
66
|
const ignoreGraph = callerPolicy.ignoreGraphFor.has(member.spaceId);
|
|
65
|
-
const invariantsRequired =
|
|
67
|
+
const invariantsRequired = headRef.invariants.length > 0;
|
|
66
68
|
|
|
67
69
|
if (ignoreGraph && invariantsRequired) {
|
|
68
70
|
const conflict: AggregatePlannerError = {
|
|
69
71
|
kind: 'policyConflict',
|
|
70
72
|
spaceId: member.spaceId,
|
|
71
|
-
detail: `\`callerPolicy.ignoreGraphFor\` requested for space "${member.spaceId}", but the member declares non-empty head-ref invariants (${
|
|
73
|
+
detail: `\`callerPolicy.ignoreGraphFor\` requested for space "${member.spaceId}", but the member declares non-empty head-ref invariants (${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.`,
|
|
72
74
|
};
|
|
73
75
|
return notOk(conflict);
|
|
74
76
|
}
|
|
@@ -97,7 +99,7 @@ export async function planAggregate<TFamilyId extends string, TTargetId extends
|
|
|
97
99
|
|
|
98
100
|
// Try graph-walk first when the graph has nodes; fall back to synth
|
|
99
101
|
// when the graph is empty AND no invariants are required.
|
|
100
|
-
if (member.
|
|
102
|
+
if (member.graph().nodes.size > 0) {
|
|
101
103
|
const walked = graphWalkStrategy({
|
|
102
104
|
aggregateTargetId: aggregate.targetId,
|
|
103
105
|
member,
|
|
@@ -111,7 +113,7 @@ export async function planAggregate<TFamilyId extends string, TTargetId extends
|
|
|
111
113
|
return notOk({
|
|
112
114
|
kind: 'extensionPathUnreachable',
|
|
113
115
|
spaceId: member.spaceId,
|
|
114
|
-
target:
|
|
116
|
+
target: headRef.hash,
|
|
115
117
|
});
|
|
116
118
|
}
|
|
117
119
|
// unsatisfiable — surface
|
|
@@ -128,7 +130,7 @@ export async function planAggregate<TFamilyId extends string, TTargetId extends
|
|
|
128
130
|
return notOk({
|
|
129
131
|
kind: 'extensionPathUnsatisfiable',
|
|
130
132
|
spaceId: member.spaceId,
|
|
131
|
-
missingInvariants: [...
|
|
133
|
+
missingInvariants: [...headRef.invariants].sort(),
|
|
132
134
|
});
|
|
133
135
|
}
|
|
134
136
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { elementCoordinates } from '@prisma-next/framework-components/ir';
|
|
2
2
|
import type { ContractSpaceMember } from './types';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -90,11 +90,6 @@ export function projectSchemaToSpace(
|
|
|
90
90
|
return schema;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
* Collect the set of storage element names claimed by other members.
|
|
95
|
-
* Reuses the loader's `extractStorageElementNames` helper so the
|
|
96
|
-
* tables/collections walk lives in exactly one place.
|
|
97
|
-
*/
|
|
98
93
|
function collectOwnedNames(
|
|
99
94
|
member: ContractSpaceMember,
|
|
100
95
|
otherMembers: ReadonlyArray<ContractSpaceMember>,
|
|
@@ -102,8 +97,8 @@ function collectOwnedNames(
|
|
|
102
97
|
const owned = new Set<string>();
|
|
103
98
|
for (const other of otherMembers) {
|
|
104
99
|
if (other.spaceId === member.spaceId) continue;
|
|
105
|
-
for (const
|
|
106
|
-
owned.add(
|
|
100
|
+
for (const { entityName } of elementCoordinates(other.contract().storage)) {
|
|
101
|
+
owned.add(entityName);
|
|
107
102
|
}
|
|
108
103
|
}
|
|
109
104
|
return owned;
|