@prisma-next/migration-tools 0.11.0-dev.45 → 0.11.0-dev.47
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/dist/{errors-CoEN114u.mjs → errors-4YabujxZ.mjs} +3 -21
- package/dist/{errors-CoEN114u.mjs.map → errors-4YabujxZ.mjs.map} +1 -1
- package/dist/exports/aggregate.d.mts +273 -177
- package/dist/exports/aggregate.d.mts.map +1 -1
- package/dist/exports/aggregate.mjs +363 -185
- package/dist/exports/aggregate.mjs.map +1 -1
- package/dist/exports/enumerate-migration-spaces.d.mts +1 -1
- package/dist/exports/enumerate-migration-spaces.mjs +4 -4
- package/dist/exports/enumerate-migration-spaces.mjs.map +1 -1
- package/dist/exports/errors.mjs +1 -1
- package/dist/exports/graph.d.mts +1 -1
- package/dist/exports/hash.d.mts +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 +1 -1
- package/dist/exports/migration-graph.d.mts +2 -2
- package/dist/exports/migration-graph.mjs +2 -2
- package/dist/exports/migration-list-graph-layout.d.mts +2 -2
- package/dist/exports/migration-list-graph-topology.d.mts +1 -1
- package/dist/exports/migration-list-types.d.mts +1 -1
- package/dist/exports/migration.d.mts +1 -1
- package/dist/exports/migration.mjs +2 -2
- package/dist/exports/ref-resolution.d.mts +2 -2
- package/dist/exports/ref-resolution.mjs +1 -1
- package/dist/exports/refs.d.mts +1 -1
- package/dist/exports/refs.mjs +2 -2
- package/dist/exports/spaces.d.mts +1 -130
- package/dist/exports/spaces.d.mts.map +1 -1
- package/dist/exports/spaces.mjs +6 -6
- package/dist/exports/spaces.mjs.map +1 -1
- package/dist/{graph-B0LIIjIu.d.mts → graph-3dLMZp5l.d.mts} +1 -1
- package/dist/{graph-B0LIIjIu.d.mts.map → graph-3dLMZp5l.d.mts.map} +1 -1
- package/dist/{invariants-lbJddL-S.mjs → invariants-CCOAyg6c.mjs} +2 -2
- package/dist/{invariants-lbJddL-S.mjs.map → invariants-CCOAyg6c.mjs.map} +1 -1
- package/dist/io-BH4G3F-i.d.mts +124 -0
- package/dist/io-BH4G3F-i.d.mts.map +1 -0
- package/dist/{io-Dc64lvaL.mjs → io-BHl0amF0.mjs} +99 -6
- package/dist/io-BHl0amF0.mjs.map +1 -0
- package/dist/{migration-graph-fl5ChjXE.d.mts → migration-graph-CWEM2SLR.d.mts} +2 -2
- package/dist/{migration-graph-fl5ChjXE.d.mts.map → migration-graph-CWEM2SLR.d.mts.map} +1 -1
- package/dist/{migration-graph-D5JeadSE.mjs → migration-graph-kGBkIZDa.mjs} +3 -7
- package/dist/migration-graph-kGBkIZDa.mjs.map +1 -0
- package/dist/{migration-list-graph-topology-CafEnhPT.d.mts → migration-list-graph-topology-Be1d8Y89.d.mts} +2 -2
- package/dist/{migration-list-graph-topology-CafEnhPT.d.mts.map → migration-list-graph-topology-Be1d8Y89.d.mts.map} +1 -1
- package/dist/{migration-list-types-wLyb3E-p.d.mts → migration-list-types-0YjFETIv.d.mts} +1 -1
- package/dist/{migration-list-types-wLyb3E-p.d.mts.map → migration-list-types-0YjFETIv.d.mts.map} +1 -1
- package/dist/{read-contract-space-contract-C4fEdoXO.mjs → read-contract-space-contract-7-OB-ykY.mjs} +3 -3
- package/dist/{read-contract-space-contract-C4fEdoXO.mjs.map → read-contract-space-contract-7-OB-ykY.mjs.map} +1 -1
- package/dist/{refs-D8xBNqs7.d.mts → refs-B33AsTjk.d.mts} +12 -2
- package/dist/refs-B33AsTjk.d.mts.map +1 -0
- package/dist/{refs-HhOkD8BT.mjs → refs-BBKNL45K.mjs} +75 -3
- package/dist/refs-BBKNL45K.mjs.map +1 -0
- package/dist/{verify-contract-spaces-DIdQLGo7.mjs → verify-contract-spaces-BJX5gqtD.mjs} +3 -3
- package/dist/{verify-contract-spaces-DIdQLGo7.mjs.map → verify-contract-spaces-BJX5gqtD.mjs.map} +1 -1
- 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 +90 -0
- package/src/aggregate/check-integrity.ts +243 -0
- package/src/aggregate/loader.ts +156 -334
- package/src/aggregate/planner.ts +8 -6
- package/src/aggregate/project-schema-to-space.ts +1 -1
- package/src/aggregate/strategies/graph-walk.ts +12 -7
- package/src/aggregate/strategies/synth.ts +2 -2
- package/src/aggregate/types.ts +56 -64
- package/src/aggregate/verifier.ts +6 -4
- package/src/compute-extension-space-apply-path.ts +1 -1
- package/src/enumerate-migration-spaces.ts +1 -1
- package/src/exports/aggregate.ts +17 -12
- package/src/exports/io.ts +2 -0
- package/src/integrity-violation.ts +114 -0
- package/src/io.ts +139 -6
- package/src/migration-graph.ts +3 -17
- package/src/refs.ts +94 -0
- package/dist/exports/io.d.mts.map +0 -1
- package/dist/io-Dc64lvaL.mjs.map +0 -1
- package/dist/migration-graph-D5JeadSE.mjs.map +0 -1
- package/dist/refs-D8xBNqs7.d.mts.map +0 -1
- package/dist/refs-HhOkD8BT.mjs.map +0 -1
- /package/dist/{metadata-COhIQCiH.d.mts → metadata-CGkJF4L6.d.mts} +0 -0
|
@@ -73,7 +73,7 @@ export async function synthStrategy<TFamilyId extends string, TTargetId extends
|
|
|
73
73
|
|
|
74
74
|
const planner = input.migrations.createPlanner(input.familyInstance);
|
|
75
75
|
const plannerResult: MigrationPlannerResult = await (planner.plan({
|
|
76
|
-
contract: input.member.contract,
|
|
76
|
+
contract: input.member.contract(),
|
|
77
77
|
schema: projectedSchema,
|
|
78
78
|
policy: input.operationPolicy,
|
|
79
79
|
fromContract: null,
|
|
@@ -114,7 +114,7 @@ export async function synthStrategy<TFamilyId extends string, TTargetId extends
|
|
|
114
114
|
result: {
|
|
115
115
|
plan,
|
|
116
116
|
displayOps: synthedPlan.operations,
|
|
117
|
-
destinationContract: input.member.contract,
|
|
117
|
+
destinationContract: input.member.contract(),
|
|
118
118
|
strategy: 'synth',
|
|
119
119
|
},
|
|
120
120
|
};
|
package/src/aggregate/types.ts
CHANGED
|
@@ -1,89 +1,81 @@
|
|
|
1
1
|
import type { Contract } from '@prisma-next/contract/types';
|
|
2
2
|
import type { MigrationGraph } from '../graph';
|
|
3
|
+
import type { IntegrityQueryOptions, IntegrityViolation } from '../integrity-violation';
|
|
3
4
|
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
|
-
}
|
|
5
|
+
import type { Refs } from '../refs';
|
|
6
|
+
import type { ContractSpaceHeadRecord } from '../verify-contract-spaces';
|
|
23
7
|
|
|
24
8
|
/**
|
|
25
9
|
* One contract space — app or extension — as a member of a
|
|
26
10
|
* {@link ContractSpaceAggregate}. Every member has the same shape.
|
|
27
11
|
*
|
|
12
|
+
* A member is a tolerant snapshot of one space's on-disk state, not a
|
|
13
|
+
* validated value: `packages` is the raw migration-package list as read
|
|
14
|
+
* from disk (a hash- or invariants-mismatched package is retained here;
|
|
15
|
+
* a genuinely unparseable one is omitted), and integrity is judged
|
|
16
|
+
* separately by {@link ContractSpaceAggregate.checkIntegrity}.
|
|
17
|
+
*
|
|
28
18
|
* - `spaceId`: `'app'` for the application, otherwise the extension's
|
|
29
19
|
* id (validated against `[a-z][a-z0-9_-]{0,63}`).
|
|
30
|
-
* - `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
20
|
+
* - `packages`: raw on-disk migration packages, as read; never
|
|
21
|
+
* integrity-validated at load.
|
|
22
|
+
* - `refs`: the user-authored refs under `migrations/<spaceId>/refs/*.json`.
|
|
23
|
+
* - `headRef`: the system head ref read from
|
|
24
|
+
* `migrations/<spaceId>/refs/head.json`, or `null` when absent
|
|
25
|
+
* (represented as a `headRefMissing` violation, never fatal). The app
|
|
26
|
+
* member's head ref is always synthesised from its live contract's
|
|
27
|
+
* storage hash, so it is never `null`.
|
|
28
|
+
* - `graph()`: the migration graph this space's packages induce —
|
|
29
|
+
* lazily reconstructed on first call and memoised. Pure structure: a
|
|
30
|
+
* `from === to` self-edge is represented, not rejected.
|
|
31
|
+
* - `contract()`: the deserialized contract for this member — lazily
|
|
32
|
+
* produced on first call and memoised. For the app it is the live
|
|
33
|
+
* contract the caller supplied; for an extension it is the on-disk
|
|
34
|
+
* `migrations/<spaceId>/contract.json` run through the family's
|
|
35
|
+
* `deserializeContract`. Throws if the on-disk contract is missing or
|
|
36
|
+
* undeserializable (surfaced as `contractUnreadable` by `checkIntegrity`
|
|
37
|
+
* under `checkContracts`); callers gate before querying it.
|
|
44
38
|
*/
|
|
45
39
|
export interface ContractSpaceMember {
|
|
46
40
|
readonly spaceId: string;
|
|
47
|
-
readonly
|
|
48
|
-
readonly
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
readonly migrations: HydratedMigrationGraph;
|
|
41
|
+
readonly packages: readonly OnDiskMigrationPackage[];
|
|
42
|
+
readonly refs: Refs;
|
|
43
|
+
readonly headRef: ContractSpaceHeadRecord | null;
|
|
44
|
+
graph(): MigrationGraph;
|
|
45
|
+
contract(): Contract;
|
|
53
46
|
}
|
|
54
47
|
|
|
55
48
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
49
|
+
* Tolerant, queryable snapshot of a project's on-disk migration state:
|
|
50
|
+
* the app contract space plus every extension contract space, each a
|
|
51
|
+
* {@link ContractSpaceMember}.
|
|
58
52
|
*
|
|
59
53
|
* Produced once per CLI invocation by `loadContractSpaceAggregate`.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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).
|
|
54
|
+
* Building the aggregate never throws on disk content; every consumer
|
|
55
|
+
* obtains spaces / packages / refs / graphs from this one value rather
|
|
56
|
+
* than re-deriving them from disk.
|
|
80
57
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
58
|
+
* - `targetId`: the app contract's target; every member is expected to
|
|
59
|
+
* share it (a mismatch surfaces as a `targetMismatch` violation under
|
|
60
|
+
* `checkContracts`).
|
|
61
|
+
* - `app` / `extensions`: retained as fields for the existing planner /
|
|
62
|
+
* verifier / runner consumers. `extensions` is sorted alphabetically
|
|
63
|
+
* by `spaceId` (the apply-ordering convention).
|
|
64
|
+
* - `listSpaces()` / `hasSpace()` / `space()` / `spaces()`: the query
|
|
65
|
+
* surface the read commands consume — `app` first, then extension ids
|
|
66
|
+
* lex-ascending.
|
|
67
|
+
* - `checkIntegrity()`: judges the loaded model and returns every
|
|
68
|
+
* violation (never bailing at the first). Config/contract-dependent
|
|
69
|
+
* checks run only when the matching {@link IntegrityQueryOptions} opt
|
|
70
|
+
* is set.
|
|
84
71
|
*/
|
|
85
72
|
export interface ContractSpaceAggregate {
|
|
86
73
|
readonly targetId: string;
|
|
87
74
|
readonly app: ContractSpaceMember;
|
|
88
75
|
readonly extensions: readonly ContractSpaceMember[];
|
|
76
|
+
listSpaces(): readonly string[];
|
|
77
|
+
hasSpace(id: string): boolean;
|
|
78
|
+
space(id: string): ContractSpaceMember | undefined;
|
|
79
|
+
spaces(): readonly ContractSpaceMember[];
|
|
80
|
+
checkIntegrity(opts?: IntegrityQueryOptions): readonly IntegrityViolation[];
|
|
89
81
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Result } from '@prisma-next/utils/result';
|
|
2
2
|
import { notOk, ok } from '@prisma-next/utils/result';
|
|
3
|
+
import { requireHeadRef } from './aggregate';
|
|
3
4
|
import { extractStorageElementNames } from './extract-storage-element-names';
|
|
4
5
|
import type { ContractMarkerRecordLike } from './marker-types';
|
|
5
6
|
import { projectSchemaToSpace } from './project-schema-to-space';
|
|
@@ -145,16 +146,17 @@ function runVerifyAggregate<TSchemaResult>(
|
|
|
145
146
|
markerPerSpace.set(member.spaceId, { kind: 'absent' });
|
|
146
147
|
continue;
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
+
const headRef = requireHeadRef(member);
|
|
150
|
+
if (marker.storageHash !== headRef.hash) {
|
|
149
151
|
markerPerSpace.set(member.spaceId, {
|
|
150
152
|
kind: 'hashMismatch',
|
|
151
153
|
markerHash: marker.storageHash,
|
|
152
|
-
expected:
|
|
154
|
+
expected: headRef.hash,
|
|
153
155
|
});
|
|
154
156
|
continue;
|
|
155
157
|
}
|
|
156
158
|
const markerInvariants = new Set(marker.invariants);
|
|
157
|
-
const missing =
|
|
159
|
+
const missing = headRef.invariants.filter((id) => !markerInvariants.has(id));
|
|
158
160
|
if (missing.length > 0) {
|
|
159
161
|
markerPerSpace.set(member.spaceId, {
|
|
160
162
|
kind: 'missingInvariants',
|
|
@@ -211,7 +213,7 @@ function detectOrphanElements(
|
|
|
211
213
|
|
|
212
214
|
const claimedTables = new Set<string>();
|
|
213
215
|
for (const member of members) {
|
|
214
|
-
for (const name of extractStorageElementNames(member.contract)) {
|
|
216
|
+
for (const name of extractStorageElementNames(member.contract())) {
|
|
215
217
|
claimedTables.add(name);
|
|
216
218
|
}
|
|
217
219
|
}
|
|
@@ -97,7 +97,7 @@ export async function computeExtensionSpaceApplyPath(
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const spaceDir = spaceMigrationDirectory(projectMigrationsDir, spaceId);
|
|
100
|
-
const packages = await readMigrationsDir(spaceDir);
|
|
100
|
+
const { packages } = await readMigrationsDir(spaceDir);
|
|
101
101
|
const graph = reconstructGraph(packages);
|
|
102
102
|
|
|
103
103
|
// Live-marker layer encodes "no prior state" as EMPTY_CONTRACT_HASH;
|
|
@@ -104,7 +104,7 @@ export async function enumerateMigrationSpaces(args: {
|
|
|
104
104
|
const spaces: MigrationSpaceListEntry[] = [];
|
|
105
105
|
for (const spaceId of spaceIds) {
|
|
106
106
|
const spaceDir = spaceMigrationDirectory(projectMigrationsDir, spaceId);
|
|
107
|
-
const packages = await readMigrationsDir(spaceDir);
|
|
107
|
+
const { packages } = await readMigrationsDir(spaceDir);
|
|
108
108
|
const refsByHash = await resolveRefsByContractHash(spaceRefsDirectory(spaceDir));
|
|
109
109
|
|
|
110
110
|
const migrations: MigrationListEntry[] = packages
|
package/src/exports/aggregate.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
export {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
createContractSpaceAggregate,
|
|
3
|
+
createContractSpaceMember,
|
|
4
|
+
requireHeadRef,
|
|
5
|
+
} from '../aggregate/aggregate';
|
|
6
|
+
export {
|
|
7
|
+
computeIntegrityViolations,
|
|
8
|
+
type IntegrityComputationInput,
|
|
9
|
+
type IntegritySpaceState,
|
|
10
|
+
loadProblemToViolation,
|
|
11
|
+
} from '../aggregate/check-integrity';
|
|
12
|
+
export { type LoadAggregateInput, loadContractSpaceAggregate } from '../aggregate/loader';
|
|
9
13
|
export type { ContractMarkerRecordLike } from '../aggregate/marker-types';
|
|
10
14
|
export {
|
|
11
15
|
type AggregateCurrentDBState,
|
|
@@ -24,11 +28,7 @@ export {
|
|
|
24
28
|
type GraphWalkStrategyInputs,
|
|
25
29
|
graphWalkStrategy,
|
|
26
30
|
} from '../aggregate/strategies/graph-walk';
|
|
27
|
-
export type {
|
|
28
|
-
ContractSpaceAggregate,
|
|
29
|
-
ContractSpaceMember,
|
|
30
|
-
HydratedMigrationGraph,
|
|
31
|
-
} from '../aggregate/types';
|
|
31
|
+
export type { ContractSpaceAggregate, ContractSpaceMember } from '../aggregate/types';
|
|
32
32
|
export {
|
|
33
33
|
type AggregateVerifierError,
|
|
34
34
|
type AggregateVerifierInput,
|
|
@@ -40,3 +40,8 @@ export {
|
|
|
40
40
|
type SchemaCheckSection,
|
|
41
41
|
verifyAggregate,
|
|
42
42
|
} from '../aggregate/verifier';
|
|
43
|
+
export type {
|
|
44
|
+
DeclaredExtensionEntry,
|
|
45
|
+
IntegrityQueryOptions,
|
|
46
|
+
IntegrityViolation,
|
|
47
|
+
} from '../integrity-violation';
|
package/src/exports/io.ts
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every structural problem the migration model can carry.
|
|
3
|
+
*
|
|
4
|
+
* Violations come in three groups:
|
|
5
|
+
*
|
|
6
|
+
* - **Recoverable**: the package or space is retained in the model;
|
|
7
|
+
* the violation is surfaced for policy (report, refuse, or ignore
|
|
8
|
+
* depending on the command).
|
|
9
|
+
* - **Config/contract-dependent**: produced only when the matching
|
|
10
|
+
* `IntegrityQueryOptions` opt is set (declaredExtensions /
|
|
11
|
+
* checkContracts). The model is built without them; they surface
|
|
12
|
+
* when the caller explicitly asks for the broader integrity view.
|
|
13
|
+
* - **Unloadable**: the package is omitted from the model entirely
|
|
14
|
+
* (its on-disk content cannot be parsed into an `OnDiskMigrationPackage`).
|
|
15
|
+
*
|
|
16
|
+
* `checkIntegrity()` on `ContractSpaceAggregate` returns the full set —
|
|
17
|
+
* all violations across all spaces — never bailing at the first hit.
|
|
18
|
+
*/
|
|
19
|
+
export type IntegrityViolation =
|
|
20
|
+
// recoverable — package/space retained, surfaced for policy
|
|
21
|
+
| {
|
|
22
|
+
readonly kind: 'sameSourceAndTarget';
|
|
23
|
+
readonly spaceId: string;
|
|
24
|
+
readonly dirName: string;
|
|
25
|
+
readonly hash: string;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
readonly kind: 'hashMismatch';
|
|
29
|
+
readonly spaceId: string;
|
|
30
|
+
readonly dirName: string;
|
|
31
|
+
readonly stored: string;
|
|
32
|
+
readonly computed: string;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
readonly kind: 'providedInvariantsMismatch';
|
|
36
|
+
readonly spaceId: string;
|
|
37
|
+
readonly dirName: string;
|
|
38
|
+
}
|
|
39
|
+
| { readonly kind: 'headRefMissing'; readonly spaceId: string }
|
|
40
|
+
| { readonly kind: 'headRefNotInGraph'; readonly spaceId: string; readonly hash: string }
|
|
41
|
+
| {
|
|
42
|
+
readonly kind: 'duplicateMigrationHash';
|
|
43
|
+
readonly spaceId: string;
|
|
44
|
+
readonly migrationHash: string;
|
|
45
|
+
readonly dirNames: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
readonly kind: 'refUnreadable';
|
|
49
|
+
readonly spaceId: string;
|
|
50
|
+
readonly refName: string;
|
|
51
|
+
readonly detail: string;
|
|
52
|
+
}
|
|
53
|
+
// config/contract-dependent — produced only when the matching opt is set
|
|
54
|
+
| { readonly kind: 'orphanSpaceDir'; readonly spaceId: string }
|
|
55
|
+
| { readonly kind: 'declaredButUnmigrated'; readonly spaceId: string }
|
|
56
|
+
| {
|
|
57
|
+
readonly kind: 'targetMismatch';
|
|
58
|
+
readonly spaceId: string;
|
|
59
|
+
readonly expected: string;
|
|
60
|
+
readonly actual: string;
|
|
61
|
+
}
|
|
62
|
+
| {
|
|
63
|
+
readonly kind: 'disjointness';
|
|
64
|
+
readonly element: string;
|
|
65
|
+
readonly claimedBy: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
| { readonly kind: 'contractUnreadable'; readonly spaceId: string; readonly detail: string }
|
|
68
|
+
// genuinely unloadable — package omitted from member.packages
|
|
69
|
+
| {
|
|
70
|
+
readonly kind: 'packageUnloadable';
|
|
71
|
+
readonly spaceId: string;
|
|
72
|
+
readonly dirName: string;
|
|
73
|
+
readonly detail: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One declared extension entry, drawn from `Config.extensionPacks`.
|
|
78
|
+
*
|
|
79
|
+
* The integrity layer needs only:
|
|
80
|
+
*
|
|
81
|
+
* - `id` — the space id (also the directory name under `migrations/`),
|
|
82
|
+
* used for the layout-drift checks (`orphanSpaceDir` /
|
|
83
|
+
* `declaredButUnmigrated`).
|
|
84
|
+
* - `targetId` — the target the declaring extension was configured for.
|
|
85
|
+
*
|
|
86
|
+
* Typed structurally so the migration-tools layer stays framework-neutral.
|
|
87
|
+
*/
|
|
88
|
+
export interface DeclaredExtensionEntry {
|
|
89
|
+
readonly id: string;
|
|
90
|
+
readonly targetId: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Options controlling which config/contract-dependent violation checks
|
|
95
|
+
* `checkIntegrity()` runs.
|
|
96
|
+
*
|
|
97
|
+
* Both opts default to disabled: a caller without the app contract or
|
|
98
|
+
* declared extensions still gets the structurally-derivable violations
|
|
99
|
+
* (hashMismatch, providedInvariantsMismatch, headRefMissing,
|
|
100
|
+
* headRefNotInGraph, refUnreadable, sameSourceAndTarget, packageUnloadable).
|
|
101
|
+
*/
|
|
102
|
+
export interface IntegrityQueryOptions {
|
|
103
|
+
/**
|
|
104
|
+
* When provided, enables layout-drift checks: `orphanSpaceDir`
|
|
105
|
+
* (a directory exists on disk for an extension not in the list) and
|
|
106
|
+
* `declaredButUnmigrated` (an extension in the list has no on-disk dir).
|
|
107
|
+
*/
|
|
108
|
+
readonly declaredExtensions?: readonly DeclaredExtensionEntry[];
|
|
109
|
+
/**
|
|
110
|
+
* When true, enables contract/disjointness/target checks:
|
|
111
|
+
* `contractUnreadable`, `targetMismatch`, `disjointness`.
|
|
112
|
+
*/
|
|
113
|
+
readonly checkContracts?: boolean;
|
|
114
|
+
}
|
package/src/io.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
errorMigrationHashMismatch,
|
|
15
15
|
errorMissingFile,
|
|
16
16
|
errorProvidedInvariantsMismatch,
|
|
17
|
+
MigrationToolsError,
|
|
17
18
|
} from './errors';
|
|
18
19
|
import { verifyMigrationHash } from './hash';
|
|
19
20
|
import { deriveProvidedInvariants } from './invariants';
|
|
@@ -247,6 +248,60 @@ export async function readMigrationPackage(dir: string): Promise<OnDiskMigration
|
|
|
247
248
|
return pkg;
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Reads a migration package's manifest and ops without running hash or
|
|
253
|
+
* invariants verification. Returns `null` when the files cannot be read or
|
|
254
|
+
* parsed (i.e. when the package is genuinely unloadable).
|
|
255
|
+
*
|
|
256
|
+
* Used by {@link readMigrationsDir} to retain a package whose hash or
|
|
257
|
+
* invariants diverge from what is stored on disk — the raw content is still
|
|
258
|
+
* useful for display / querying; only integrity is in question.
|
|
259
|
+
*/
|
|
260
|
+
async function readMigrationPackageRaw(dir: string): Promise<OnDiskMigrationPackage | null> {
|
|
261
|
+
const absoluteDir = resolve(dir);
|
|
262
|
+
const manifestPath = join(absoluteDir, MANIFEST_FILE);
|
|
263
|
+
const opsPath = join(absoluteDir, OPS_FILE);
|
|
264
|
+
|
|
265
|
+
let manifestRaw: string;
|
|
266
|
+
try {
|
|
267
|
+
manifestRaw = await readFile(manifestPath, 'utf-8');
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
let opsRaw: string;
|
|
272
|
+
try {
|
|
273
|
+
opsRaw = await readFile(opsPath, 'utf-8');
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let metadata: MigrationMetadata;
|
|
279
|
+
try {
|
|
280
|
+
metadata = JSON.parse(manifestRaw);
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
let ops: MigrationOps;
|
|
285
|
+
try {
|
|
286
|
+
ops = JSON.parse(opsRaw);
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const result = MigrationMetadataSchema(metadata);
|
|
292
|
+
if (result instanceof type.errors) return null;
|
|
293
|
+
|
|
294
|
+
const opsResult = MigrationOpsSchema(ops);
|
|
295
|
+
if (opsResult instanceof type.errors) return null;
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
dirName: basename(absoluteDir),
|
|
299
|
+
dirPath: absoluteDir,
|
|
300
|
+
metadata,
|
|
301
|
+
ops,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
250
305
|
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
251
306
|
if (a.length !== b.length) return false;
|
|
252
307
|
for (let i = 0; i < a.length; i++) {
|
|
@@ -272,20 +327,64 @@ function validateOps(ops: unknown, filePath: string): asserts ops is MigrationOp
|
|
|
272
327
|
}
|
|
273
328
|
}
|
|
274
329
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
330
|
+
/**
|
|
331
|
+
* A per-package load-time problem returned by {@link readMigrationsDir}.
|
|
332
|
+
*
|
|
333
|
+
* Three variants, matching the relocated throws from the load path:
|
|
334
|
+
*
|
|
335
|
+
* - `hashMismatch` — stored `migrationHash` differs from the recomputed value.
|
|
336
|
+
* The package is **retained** in the returned `packages` array.
|
|
337
|
+
* - `providedInvariantsMismatch` — `migration.json` declares different
|
|
338
|
+
* `providedInvariants` than `ops.json` implies. The package is **retained**.
|
|
339
|
+
* - `packageUnloadable` — the manifest is missing, unparseable, or schema-
|
|
340
|
+
* invalid. The package is **omitted** from `packages`.
|
|
341
|
+
*
|
|
342
|
+
* Callers that need the `spaceId` context (e.g. the aggregate loader) attach
|
|
343
|
+
* it when converting to {@link import('./integrity-violation').IntegrityViolation}.
|
|
344
|
+
*/
|
|
345
|
+
export type PackageLoadProblem =
|
|
346
|
+
| {
|
|
347
|
+
readonly kind: 'hashMismatch';
|
|
348
|
+
readonly dirName: string;
|
|
349
|
+
readonly stored: string;
|
|
350
|
+
readonly computed: string;
|
|
351
|
+
}
|
|
352
|
+
| { readonly kind: 'providedInvariantsMismatch'; readonly dirName: string }
|
|
353
|
+
| { readonly kind: 'packageUnloadable'; readonly dirName: string; readonly detail: string };
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Result returned by {@link readMigrationsDir}.
|
|
357
|
+
*
|
|
358
|
+
* - `packages` — every package that could be read; hash-mismatched and
|
|
359
|
+
* invariants-mismatched packages are included here (the problem is
|
|
360
|
+
* represented rather than fatal).
|
|
361
|
+
* - `problems` — one entry per package that had a load-time issue.
|
|
362
|
+
* `packageUnloadable` entries are **not** in `packages`.
|
|
363
|
+
*/
|
|
364
|
+
export interface ReadMigrationsDirResult {
|
|
365
|
+
readonly packages: readonly OnDiskMigrationPackage[];
|
|
366
|
+
readonly problems: readonly PackageLoadProblem[];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function packageLoadProblemDetailFromError(error: unknown): string {
|
|
370
|
+
if (MigrationToolsError.is(error)) return error.why;
|
|
371
|
+
if (error instanceof Error) return error.message;
|
|
372
|
+
return String(error);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function readMigrationsDir(migrationsRoot: string): Promise<ReadMigrationsDirResult> {
|
|
278
376
|
let entries: string[];
|
|
279
377
|
try {
|
|
280
378
|
entries = await readdir(migrationsRoot);
|
|
281
379
|
} catch (error) {
|
|
282
380
|
if (hasErrnoCode(error, 'ENOENT')) {
|
|
283
|
-
return [];
|
|
381
|
+
return { packages: [], problems: [] };
|
|
284
382
|
}
|
|
285
383
|
throw error;
|
|
286
384
|
}
|
|
287
385
|
|
|
288
386
|
const packages: OnDiskMigrationPackage[] = [];
|
|
387
|
+
const problems: PackageLoadProblem[] = [];
|
|
289
388
|
|
|
290
389
|
for (const entry of entries.sort()) {
|
|
291
390
|
const entryPath = join(migrationsRoot, entry);
|
|
@@ -299,10 +398,44 @@ export async function readMigrationsDir(
|
|
|
299
398
|
continue; // skip non-migration directories
|
|
300
399
|
}
|
|
301
400
|
|
|
302
|
-
|
|
401
|
+
let pkg: OnDiskMigrationPackage;
|
|
402
|
+
try {
|
|
403
|
+
pkg = await readMigrationPackage(entryPath);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
const dirName = entry;
|
|
406
|
+
if (MigrationToolsError.is(error)) {
|
|
407
|
+
if (error.code === 'MIGRATION.HASH_MISMATCH') {
|
|
408
|
+
const details = error.details;
|
|
409
|
+
const rawPkg = await readMigrationPackageRaw(entryPath);
|
|
410
|
+
if (rawPkg !== null) packages.push(rawPkg);
|
|
411
|
+
problems.push({
|
|
412
|
+
kind: 'hashMismatch',
|
|
413
|
+
dirName,
|
|
414
|
+
stored: typeof details?.['storedHash'] === 'string' ? details['storedHash'] : '',
|
|
415
|
+
computed: typeof details?.['computedHash'] === 'string' ? details['computedHash'] : '',
|
|
416
|
+
});
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (error.code === 'MIGRATION.PROVIDED_INVARIANTS_MISMATCH') {
|
|
420
|
+
const rawPkg = await readMigrationPackageRaw(entryPath);
|
|
421
|
+
if (rawPkg !== null) packages.push(rawPkg);
|
|
422
|
+
problems.push({ kind: 'providedInvariantsMismatch', dirName });
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Any other error (missing file, invalid JSON, invalid manifest schema) →
|
|
427
|
+
// package unloadable; omit from packages.
|
|
428
|
+
problems.push({
|
|
429
|
+
kind: 'packageUnloadable',
|
|
430
|
+
dirName,
|
|
431
|
+
detail: packageLoadProblemDetailFromError(error),
|
|
432
|
+
});
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
packages.push(pkg);
|
|
303
436
|
}
|
|
304
437
|
|
|
305
|
-
return packages;
|
|
438
|
+
return { packages, problems };
|
|
306
439
|
}
|
|
307
440
|
|
|
308
441
|
export function formatMigrationDirName(timestamp: Date, slug: string): string {
|
package/src/migration-graph.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
2
2
|
import { EMPTY_CONTRACT_HASH } from './constants';
|
|
3
|
-
import {
|
|
4
|
-
errorAmbiguousTarget,
|
|
5
|
-
errorDuplicateMigrationHash,
|
|
6
|
-
errorNoInitialMigration,
|
|
7
|
-
errorNoTarget,
|
|
8
|
-
errorSameSourceAndTarget,
|
|
9
|
-
} from './errors';
|
|
3
|
+
import { errorAmbiguousTarget, errorNoInitialMigration, errorNoTarget } from './errors';
|
|
10
4
|
import type { MigrationEdge, MigrationGraph } from './graph';
|
|
11
5
|
import { bfs } from './graph-ops';
|
|
12
6
|
import type { OnDiskMigrationPackage } from './package';
|
|
@@ -50,13 +44,6 @@ export function reconstructGraph(packages: readonly OnDiskMigrationPackage[]): M
|
|
|
50
44
|
const from = pkg.metadata.from ?? EMPTY_CONTRACT_HASH;
|
|
51
45
|
const { to } = pkg.metadata;
|
|
52
46
|
|
|
53
|
-
if (from === to) {
|
|
54
|
-
const hasDataOp = pkg.ops.some((op) => op.operationClass === 'data');
|
|
55
|
-
if (!hasDataOp) {
|
|
56
|
-
throw errorSameSourceAndTarget(pkg.dirPath, from);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
47
|
nodes.add(from);
|
|
61
48
|
nodes.add(to);
|
|
62
49
|
|
|
@@ -69,10 +56,9 @@ export function reconstructGraph(packages: readonly OnDiskMigrationPackage[]): M
|
|
|
69
56
|
invariants: pkg.metadata.providedInvariants,
|
|
70
57
|
};
|
|
71
58
|
|
|
72
|
-
if (migrationByHash.has(migration.migrationHash)) {
|
|
73
|
-
|
|
59
|
+
if (!migrationByHash.has(migration.migrationHash)) {
|
|
60
|
+
migrationByHash.set(migration.migrationHash, migration);
|
|
74
61
|
}
|
|
75
|
-
migrationByHash.set(migration.migrationHash, migration);
|
|
76
62
|
|
|
77
63
|
appendEdge(forwardChain, from, migration);
|
|
78
64
|
appendEdge(reverseChain, to, migration);
|