@prisma-next/migration-tools 0.11.0-dev.46 → 0.11.0-dev.48

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 (81) hide show
  1. package/dist/{errors-CoEN114u.mjs → errors-4YabujxZ.mjs} +3 -21
  2. package/dist/{errors-CoEN114u.mjs.map → errors-4YabujxZ.mjs.map} +1 -1
  3. package/dist/exports/aggregate.d.mts +273 -177
  4. package/dist/exports/aggregate.d.mts.map +1 -1
  5. package/dist/exports/aggregate.mjs +363 -185
  6. package/dist/exports/aggregate.mjs.map +1 -1
  7. package/dist/exports/enumerate-migration-spaces.d.mts +1 -1
  8. package/dist/exports/enumerate-migration-spaces.mjs +4 -4
  9. package/dist/exports/enumerate-migration-spaces.mjs.map +1 -1
  10. package/dist/exports/errors.mjs +1 -1
  11. package/dist/exports/graph.d.mts +1 -1
  12. package/dist/exports/hash.d.mts +1 -1
  13. package/dist/exports/invariants.mjs +1 -1
  14. package/dist/exports/io.d.mts +2 -83
  15. package/dist/exports/io.mjs +1 -1
  16. package/dist/exports/metadata.d.mts +1 -1
  17. package/dist/exports/migration-graph.d.mts +2 -2
  18. package/dist/exports/migration-graph.mjs +2 -2
  19. package/dist/exports/migration-list-graph-layout.d.mts +2 -2
  20. package/dist/exports/migration-list-graph-topology.d.mts +1 -1
  21. package/dist/exports/migration-list-types.d.mts +1 -1
  22. package/dist/exports/migration.d.mts +1 -1
  23. package/dist/exports/migration.mjs +2 -2
  24. package/dist/exports/ref-resolution.d.mts +2 -2
  25. package/dist/exports/ref-resolution.mjs +1 -1
  26. package/dist/exports/refs.d.mts +1 -1
  27. package/dist/exports/refs.mjs +2 -2
  28. package/dist/exports/spaces.d.mts +1 -130
  29. package/dist/exports/spaces.d.mts.map +1 -1
  30. package/dist/exports/spaces.mjs +6 -6
  31. package/dist/exports/spaces.mjs.map +1 -1
  32. package/dist/{graph-B0LIIjIu.d.mts → graph-3dLMZp5l.d.mts} +1 -1
  33. package/dist/{graph-B0LIIjIu.d.mts.map → graph-3dLMZp5l.d.mts.map} +1 -1
  34. package/dist/{invariants-lbJddL-S.mjs → invariants-CCOAyg6c.mjs} +2 -2
  35. package/dist/{invariants-lbJddL-S.mjs.map → invariants-CCOAyg6c.mjs.map} +1 -1
  36. package/dist/io-BH4G3F-i.d.mts +124 -0
  37. package/dist/io-BH4G3F-i.d.mts.map +1 -0
  38. package/dist/{io-Dc64lvaL.mjs → io-BHl0amF0.mjs} +99 -6
  39. package/dist/io-BHl0amF0.mjs.map +1 -0
  40. package/dist/{migration-graph-fl5ChjXE.d.mts → migration-graph-CWEM2SLR.d.mts} +2 -2
  41. package/dist/{migration-graph-fl5ChjXE.d.mts.map → migration-graph-CWEM2SLR.d.mts.map} +1 -1
  42. package/dist/{migration-graph-D5JeadSE.mjs → migration-graph-kGBkIZDa.mjs} +3 -7
  43. package/dist/migration-graph-kGBkIZDa.mjs.map +1 -0
  44. package/dist/{migration-list-graph-topology-CafEnhPT.d.mts → migration-list-graph-topology-Be1d8Y89.d.mts} +2 -2
  45. package/dist/{migration-list-graph-topology-CafEnhPT.d.mts.map → migration-list-graph-topology-Be1d8Y89.d.mts.map} +1 -1
  46. package/dist/{migration-list-types-wLyb3E-p.d.mts → migration-list-types-0YjFETIv.d.mts} +1 -1
  47. package/dist/{migration-list-types-wLyb3E-p.d.mts.map → migration-list-types-0YjFETIv.d.mts.map} +1 -1
  48. package/dist/{read-contract-space-contract-C4fEdoXO.mjs → read-contract-space-contract-7-OB-ykY.mjs} +3 -3
  49. package/dist/{read-contract-space-contract-C4fEdoXO.mjs.map → read-contract-space-contract-7-OB-ykY.mjs.map} +1 -1
  50. package/dist/{refs-D8xBNqs7.d.mts → refs-B33AsTjk.d.mts} +12 -2
  51. package/dist/refs-B33AsTjk.d.mts.map +1 -0
  52. package/dist/{refs-HhOkD8BT.mjs → refs-BBKNL45K.mjs} +75 -3
  53. package/dist/refs-BBKNL45K.mjs.map +1 -0
  54. package/dist/{verify-contract-spaces-DIdQLGo7.mjs → verify-contract-spaces-BJX5gqtD.mjs} +3 -3
  55. package/dist/{verify-contract-spaces-DIdQLGo7.mjs.map → verify-contract-spaces-BJX5gqtD.mjs.map} +1 -1
  56. package/dist/verify-contract-spaces-BdysZdQk.d.mts +132 -0
  57. package/dist/verify-contract-spaces-BdysZdQk.d.mts.map +1 -0
  58. package/package.json +6 -6
  59. package/src/aggregate/aggregate.ts +90 -0
  60. package/src/aggregate/check-integrity.ts +243 -0
  61. package/src/aggregate/loader.ts +156 -334
  62. package/src/aggregate/planner.ts +8 -6
  63. package/src/aggregate/project-schema-to-space.ts +1 -1
  64. package/src/aggregate/strategies/graph-walk.ts +12 -7
  65. package/src/aggregate/strategies/synth.ts +2 -2
  66. package/src/aggregate/types.ts +56 -64
  67. package/src/aggregate/verifier.ts +6 -4
  68. package/src/compute-extension-space-apply-path.ts +1 -1
  69. package/src/enumerate-migration-spaces.ts +1 -1
  70. package/src/exports/aggregate.ts +17 -12
  71. package/src/exports/io.ts +2 -0
  72. package/src/integrity-violation.ts +114 -0
  73. package/src/io.ts +139 -6
  74. package/src/migration-graph.ts +3 -17
  75. package/src/refs.ts +94 -0
  76. package/dist/exports/io.d.mts.map +0 -1
  77. package/dist/io-Dc64lvaL.mjs.map +0 -1
  78. package/dist/migration-graph-D5JeadSE.mjs.map +0 -1
  79. package/dist/refs-D8xBNqs7.d.mts.map +0 -1
  80. package/dist/refs-HhOkD8BT.mjs.map +0 -1
  81. /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
  };
@@ -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
- * - `contract`: the validated contract value for this member. For the
31
- * app, the user's authored contract; for an extension, the on-disk
32
- * `migrations/<spaceId>/contract.json`. Both have already passed the
33
- * family's `deserializeContract` at the loader boundary.
34
- * - `headRef.hash`: the storage hash this member is targeting. For the
35
- * app, equals `contract.storage.storageHash`. For extensions, the
36
- * on-disk `refs/head.json.hash`.
37
- * - `headRef.invariants`: alphabetically sorted, deduplicated invariant
38
- * ids declared on the head ref. Empty for the app member (the app's
39
- * plan is synthesised from the contract IR, no invariants required).
40
- * - `migrations`: the hydrated migration graph for this space. Possibly
41
- * empty (an extension whose on-disk head ref points at the
42
- * empty-contract sentinel and ships no migrations yet, or the app
43
- * when the user hasn't authored any).
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 contract: Contract;
48
- readonly headRef: {
49
- readonly hash: string;
50
- readonly invariants: readonly string[];
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
- * Typed value carrying the user's app contract plus every loaded
57
- * extension contract space, fully hydrated and internally consistent.
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
- * Every downstream component (planner, verifier, runner adapter)
61
- * consumes this value rather than rebuilding state from disk.
62
- *
63
- * Invariants the loader enforces at construction:
64
- *
65
- * 1. `targetId` is consistent across every member (`contract.target`
66
- * matches `aggregate.targetId`). The aggregate's `targetId` is the
67
- * `Config.adapter.targetId` value the loader was told to use.
68
- * 2. `aggregate.extensions` is sorted alphabetically by `spaceId`.
69
- * Mirrors {@link import('../concatenate-space-apply-inputs').concatenateSpaceApplyInputs}'s
70
- * extension ordering convention so downstream apply order matches
71
- * today's behaviour byte-for-byte.
72
- * 3. No two members claim the same storage element (table / type / etc.).
73
- * 4. For each extension member: `member.headRef.hash` is reachable from
74
- * the empty-contract sentinel in `member.migrations.graph` (or the
75
- * graph is empty and `member.headRef.hash === EMPTY_CONTRACT_HASH`).
76
- * 5. For the app member: `member.headRef.hash` equals
77
- * `member.contract.storage.storageHash`. The app's `migrations`
78
- * is hydrated from the user's authored `migrations/` (or empty if
79
- * none).
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
- * The aggregate is **type-uniform** post-construction: app/extension
82
- * distinguishability survives only at the caller-policy layer
83
- * (`ignoreGraphFor: new Set([appSpaceId])`), not on member shape.
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
- if (marker.storageHash !== member.headRef.hash) {
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: member.headRef.hash,
154
+ expected: headRef.hash,
153
155
  });
154
156
  continue;
155
157
  }
156
158
  const markerInvariants = new Set(marker.invariants);
157
- const missing = member.headRef.invariants.filter((id) => !markerInvariants.has(id));
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
@@ -1,11 +1,15 @@
1
1
  export {
2
- type DeclaredExtensionEntry,
3
- type LayoutViolation,
4
- type LoadAggregateError,
5
- type LoadAggregateInput,
6
- type LoadAggregateOutput,
7
- loadContractSpaceAggregate,
8
- } from '../aggregate/loader';
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
@@ -3,6 +3,8 @@ export {
3
3
  formatMigrationDirName,
4
4
  materialiseExtensionMigrationPackageIfMissing,
5
5
  materialiseMigrationPackage,
6
+ type PackageLoadProblem,
7
+ type ReadMigrationsDirResult,
6
8
  readMigrationPackage,
7
9
  readMigrationsDir,
8
10
  writeMigrationMetadata,
@@ -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
- export async function readMigrationsDir(
276
- migrationsRoot: string,
277
- ): Promise<readonly OnDiskMigrationPackage[]> {
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
- packages.push(await readMigrationPackage(entryPath));
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 {
@@ -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
- throw errorDuplicateMigrationHash(migration.migrationHash);
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);