@prisma-next/migration-tools 0.11.0-dev.5 → 0.11.0-dev.51

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 (133) hide show
  1. package/README.md +4 -4
  2. package/dist/{errors-DGYwcwXs.mjs → errors-4YabujxZ.mjs} +15 -21
  3. package/dist/errors-4YabujxZ.mjs.map +1 -0
  4. package/dist/exports/aggregate.d.mts +275 -179
  5. package/dist/exports/aggregate.d.mts.map +1 -1
  6. package/dist/exports/aggregate.mjs +363 -184
  7. package/dist/exports/aggregate.mjs.map +1 -1
  8. package/dist/exports/enumerate-migration-spaces.d.mts +53 -0
  9. package/dist/exports/enumerate-migration-spaces.d.mts.map +1 -0
  10. package/dist/exports/enumerate-migration-spaces.mjs +107 -0
  11. package/dist/exports/enumerate-migration-spaces.mjs.map +1 -0
  12. package/dist/exports/errors.d.mts +2 -2
  13. package/dist/exports/errors.d.mts.map +1 -1
  14. package/dist/exports/errors.mjs +1 -1
  15. package/dist/exports/graph.d.mts +1 -1
  16. package/dist/exports/hash.d.mts +8 -9
  17. package/dist/exports/hash.d.mts.map +1 -1
  18. package/dist/exports/hash.mjs +1 -1
  19. package/dist/exports/invariants.d.mts +1 -1
  20. package/dist/exports/invariants.d.mts.map +1 -1
  21. package/dist/exports/invariants.mjs +1 -1
  22. package/dist/exports/io.d.mts +2 -83
  23. package/dist/exports/io.mjs +1 -1
  24. package/dist/exports/metadata.d.mts +2 -2
  25. package/dist/exports/migration-graph.d.mts +9 -2
  26. package/dist/exports/migration-graph.d.mts.map +1 -0
  27. package/dist/exports/migration-graph.mjs +16 -2
  28. package/dist/exports/migration-graph.mjs.map +1 -0
  29. package/dist/exports/migration-list-graph-topology.d.mts +13 -0
  30. package/dist/exports/migration-list-graph-topology.d.mts.map +1 -0
  31. package/dist/exports/migration-list-graph-topology.mjs +105 -0
  32. package/dist/exports/migration-list-graph-topology.mjs.map +1 -0
  33. package/dist/exports/migration-list-types.d.mts +2 -0
  34. package/dist/exports/migration-list-types.mjs +1 -0
  35. package/dist/exports/migration-ts.d.mts.map +1 -1
  36. package/dist/exports/migration-ts.mjs.map +1 -1
  37. package/dist/exports/migration.d.mts +5 -6
  38. package/dist/exports/migration.d.mts.map +1 -1
  39. package/dist/exports/migration.mjs +14 -32
  40. package/dist/exports/migration.mjs.map +1 -1
  41. package/dist/exports/package.d.mts +1 -1
  42. package/dist/exports/ref-resolution.d.mts +2 -2
  43. package/dist/exports/ref-resolution.d.mts.map +1 -1
  44. package/dist/exports/ref-resolution.mjs +1 -1
  45. package/dist/exports/ref-resolution.mjs.map +1 -1
  46. package/dist/exports/refs.d.mts +15 -2
  47. package/dist/exports/refs.d.mts.map +1 -0
  48. package/dist/exports/refs.mjs +137 -2
  49. package/dist/exports/refs.mjs.map +1 -0
  50. package/dist/exports/spaces.d.mts +31 -132
  51. package/dist/exports/spaces.d.mts.map +1 -1
  52. package/dist/exports/spaces.mjs +14 -9
  53. package/dist/exports/spaces.mjs.map +1 -1
  54. package/dist/{graph-BrLXqoUc.d.mts → graph-BUZuUeBC.d.mts} +1 -2
  55. package/dist/graph-BUZuUeBC.d.mts.map +1 -0
  56. package/dist/{hash-Cr4WIr4Z.mjs → hash--Y7vCpN3.mjs} +8 -9
  57. package/dist/hash--Y7vCpN3.mjs.map +1 -0
  58. package/dist/{invariants-0daYEzyo.mjs → invariants-CCOAyg6c.mjs} +2 -2
  59. package/dist/{invariants-0daYEzyo.mjs.map → invariants-CCOAyg6c.mjs.map} +1 -1
  60. package/dist/{io-BPLfzvZe.mjs → io-BHl0amF0.mjs} +100 -13
  61. package/dist/io-BHl0amF0.mjs.map +1 -0
  62. package/dist/io-nqFXSSTN.d.mts +124 -0
  63. package/dist/io-nqFXSSTN.d.mts.map +1 -0
  64. package/dist/metadata-Bp9X04gM.d.mts +2 -0
  65. package/dist/{migration-graph-De0dUZoC.d.mts → migration-graph-DtNT-cqc.d.mts} +6 -6
  66. package/dist/migration-graph-DtNT-cqc.d.mts.map +1 -0
  67. package/dist/{migration-graph-nlS4TRpn.mjs → migration-graph-kGBkIZDa.mjs} +6 -26
  68. package/dist/migration-graph-kGBkIZDa.mjs.map +1 -0
  69. package/dist/migration-list-types-BRTuXR8i.d.mts +23 -0
  70. package/dist/migration-list-types-BRTuXR8i.d.mts.map +1 -0
  71. package/dist/op-schema-D5qkXfEf.mjs.map +1 -1
  72. package/dist/{package-DZj8YvD0.d.mts → package-DIttKL7X.d.mts} +1 -1
  73. package/dist/package-DIttKL7X.d.mts.map +1 -0
  74. package/dist/read-contract-space-contract-BS5Oxbgw.mjs +82 -0
  75. package/dist/read-contract-space-contract-BS5Oxbgw.mjs.map +1 -0
  76. package/dist/{refs-BDHo5l_g.mjs → refs-BBKNL45K.mjs} +76 -4
  77. package/dist/refs-BBKNL45K.mjs.map +1 -0
  78. package/dist/{refs-CDaNerhT.d.mts → refs-C8f2IGM8.d.mts} +12 -2
  79. package/dist/refs-C8f2IGM8.d.mts.map +1 -0
  80. package/dist/{read-contract-space-contract-DRueB4Aa.mjs → verify-contract-spaces-BJX5gqtD.mjs} +32 -80
  81. package/dist/verify-contract-spaces-BJX5gqtD.mjs.map +1 -0
  82. package/dist/verify-contract-spaces-T0aiJlBS.d.mts +132 -0
  83. package/dist/verify-contract-spaces-T0aiJlBS.d.mts.map +1 -0
  84. package/package.json +18 -6
  85. package/src/aggregate/aggregate.ts +90 -0
  86. package/src/aggregate/check-integrity.ts +243 -0
  87. package/src/aggregate/loader.ts +156 -334
  88. package/src/aggregate/planner.ts +8 -6
  89. package/src/aggregate/project-schema-to-space.ts +1 -1
  90. package/src/aggregate/strategies/graph-walk.ts +12 -7
  91. package/src/aggregate/strategies/synth.ts +2 -2
  92. package/src/aggregate/types.ts +56 -64
  93. package/src/aggregate/verifier.ts +6 -4
  94. package/src/assert-descriptor-self-consistency.ts +6 -0
  95. package/src/compute-extension-space-apply-path.ts +1 -1
  96. package/src/emit-contract-space-artefacts.ts +4 -3
  97. package/src/enumerate-migration-spaces.ts +127 -0
  98. package/src/errors.ts +17 -2
  99. package/src/exports/aggregate.ts +17 -12
  100. package/src/exports/enumerate-migration-spaces.ts +4 -0
  101. package/src/exports/io.ts +2 -0
  102. package/src/exports/metadata.ts +1 -1
  103. package/src/exports/migration-graph.ts +1 -0
  104. package/src/exports/migration-list-graph-topology.ts +5 -0
  105. package/src/exports/migration-list-types.ts +5 -0
  106. package/src/exports/refs.ts +8 -0
  107. package/src/exports/spaces.ts +3 -0
  108. package/src/graph-membership.ts +17 -0
  109. package/src/graph.ts +0 -1
  110. package/src/hash.ts +7 -8
  111. package/src/integrity-violation.ts +114 -0
  112. package/src/io.ts +139 -14
  113. package/src/metadata.ts +1 -1
  114. package/src/migration-base.ts +10 -30
  115. package/src/migration-graph.ts +7 -35
  116. package/src/migration-list-graph-topology.ts +158 -0
  117. package/src/migration-list-types.ts +21 -0
  118. package/src/read-contract-space-head-ref.ts +5 -2
  119. package/src/refs/snapshot.ts +197 -0
  120. package/src/refs.ts +97 -1
  121. package/src/space-layout.ts +30 -0
  122. package/dist/errors-DGYwcwXs.mjs.map +0 -1
  123. package/dist/exports/io.d.mts.map +0 -1
  124. package/dist/graph-BrLXqoUc.d.mts.map +0 -1
  125. package/dist/hash-Cr4WIr4Z.mjs.map +0 -1
  126. package/dist/io-BPLfzvZe.mjs.map +0 -1
  127. package/dist/metadata-BFX0xdz8.d.mts +0 -2
  128. package/dist/migration-graph-De0dUZoC.d.mts.map +0 -1
  129. package/dist/migration-graph-nlS4TRpn.mjs.map +0 -1
  130. package/dist/package-DZj8YvD0.d.mts.map +0 -1
  131. package/dist/read-contract-space-contract-DRueB4Aa.mjs.map +0 -1
  132. package/dist/refs-BDHo5l_g.mjs.map +0 -1
  133. package/dist/refs-CDaNerhT.d.mts.map +0 -1
@@ -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
  }
@@ -1,4 +1,6 @@
1
+ import type { PreserveEmptyPredicate, StorageSort } from '@prisma-next/contract/hashing';
1
2
  import { computeStorageHash } from '@prisma-next/contract/hashing';
3
+ import { ifDefined } from '@prisma-next/utils/defined';
2
4
  import { errorDescriptorHeadHashMismatch } from './errors';
3
5
 
4
6
  function stripNamespaceKinds(storage: Record<string, unknown>): Record<string, unknown> {
@@ -35,6 +37,8 @@ export interface DescriptorSelfConsistencyInputs {
35
37
  */
36
38
  readonly storage: unknown;
37
39
  readonly headRefHash: string;
40
+ readonly shouldPreserveEmpty?: PreserveEmptyPredicate;
41
+ readonly sortStorage?: StorageSort;
38
42
  }
39
43
 
40
44
  /**
@@ -82,6 +86,8 @@ export function assertDescriptorSelfConsistency(inputs: DescriptorSelfConsistenc
82
86
  target: inputs.target,
83
87
  targetFamily: inputs.targetFamily,
84
88
  storage: normalizedStorage,
89
+ ...ifDefined('shouldPreserveEmpty', inputs.shouldPreserveEmpty),
90
+ ...ifDefined('sortStorage', inputs.sortStorage),
85
91
  });
86
92
  if (recomputed !== inputs.headRefHash) {
87
93
  throw errorDescriptorHeadHashMismatch({
@@ -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;
@@ -2,7 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { canonicalizeJson } from '@prisma-next/framework-components/utils';
3
3
  import { join } from 'pathe';
4
4
  import type { ContractSpaceHeadRef } from './read-contract-space-head-ref';
5
- import { assertValidSpaceId } from './space-layout';
5
+ import { assertValidSpaceId, spaceRefsDirectory } from './space-layout';
6
6
 
7
7
  /**
8
8
  * Inputs for {@link emitContractSpaceArtefacts}.
@@ -56,7 +56,8 @@ export async function emitContractSpaceArtefacts(
56
56
  assertValidSpaceId(spaceId);
57
57
 
58
58
  const dir = join(projectMigrationsDir, spaceId);
59
- await mkdir(join(dir, 'refs'), { recursive: true });
59
+ const refsDir = spaceRefsDirectory(dir);
60
+ await mkdir(refsDir, { recursive: true });
60
61
 
61
62
  await writeFile(join(dir, 'contract.json'), `${canonicalizeJson(inputs.contract)}\n`);
62
63
  await writeFile(join(dir, 'contract.d.ts'), inputs.contractDts);
@@ -66,5 +67,5 @@ export async function emitContractSpaceArtefacts(
66
67
  hash: inputs.headRef.hash,
67
68
  invariants: sortedInvariants,
68
69
  });
69
- await writeFile(join(dir, 'refs', 'head.json'), `${headJson}\n`);
70
+ await writeFile(join(refsDir, 'head.json'), `${headJson}\n`);
70
71
  }
@@ -0,0 +1,127 @@
1
+ import { readMigrationsDir } from './io';
2
+ import type { MigrationListEntry, MigrationSpaceListEntry } from './migration-list-types';
3
+ import { readRefs } from './refs';
4
+ import {
5
+ APP_SPACE_ID,
6
+ isValidSpaceId,
7
+ RESERVED_SPACE_SUBDIR_NAMES,
8
+ spaceMigrationDirectory,
9
+ spaceRefsDirectory,
10
+ } from './space-layout';
11
+ import { listContractSpaceDirectories } from './verify-contract-spaces';
12
+
13
+ /**
14
+ * Index `migrations/<space>/refs/*.json` by the contract hash each ref
15
+ * points at, so callers can attach `(ref names)` decorations to every
16
+ * row whose destination contract hash matches.
17
+ *
18
+ * Each bucket is sorted lex-asc to keep rendered output deterministic
19
+ * (adjacent rows pointing at the same hash render their ref decorations
20
+ * in the same order).
21
+ *
22
+ * Refs whose hash matches no migration on disk are still indexed; the
23
+ * caller decides whether to surface them. Migration rows only carry
24
+ * `(refs)` decorations when a matching destination contract hash exists
25
+ * on disk — orphan refs are not rendered on any row.
26
+ *
27
+ * Returns an empty map when the refs directory does not exist
28
+ * ({@link readRefs} treats `ENOENT` as "no refs").
29
+ */
30
+ export async function resolveRefsByContractHash(
31
+ refsDir: string,
32
+ ): Promise<ReadonlyMap<string, readonly string[]>> {
33
+ const refs = await readRefs(refsDir);
34
+ const byHash = new Map<string, string[]>();
35
+ for (const [name, entry] of Object.entries(refs)) {
36
+ const bucket = byHash.get(entry.hash);
37
+ if (bucket) bucket.push(name);
38
+ else byHash.set(entry.hash, [name]);
39
+ }
40
+ for (const bucket of byHash.values()) {
41
+ bucket.sort();
42
+ }
43
+ return byHash;
44
+ }
45
+
46
+ /**
47
+ * Compare two contract-space IDs for the inter-space ordering rule:
48
+ * {@link APP_SPACE_ID} first if present, then lex-asc on the rest.
49
+ */
50
+ function compareSpaceIds(a: string, b: string): number {
51
+ if (a === APP_SPACE_ID) return b === APP_SPACE_ID ? 0 : -1;
52
+ if (b === APP_SPACE_ID) return 1;
53
+ if (a < b) return -1;
54
+ if (a > b) return 1;
55
+ return 0;
56
+ }
57
+
58
+ /**
59
+ * Sort `dirName` descending so the rendered output reads latest-first,
60
+ * matching the `git log` latest-first convention.
61
+ */
62
+ function compareDirNamesDescending(a: MigrationListEntry, b: MigrationListEntry): number {
63
+ if (a.dirName < b.dirName) return 1;
64
+ if (a.dirName > b.dirName) return -1;
65
+ return 0;
66
+ }
67
+
68
+ /**
69
+ * Enumerate every contract space's on-disk migrations under
70
+ * `<projectMigrationsDir>/`. For each valid space directory:
71
+ *
72
+ * - Loads on-disk packages via {@link readMigrationsDir}.
73
+ * - Attaches ref decorations: each migration's `refs[]` lists every ref
74
+ * name from `migrations/<spaceId>/refs/*.json` whose hash equals the
75
+ * migration's destination contract hash.
76
+ * - Sorts migrations within the space by `dirName` descending
77
+ * (reverse-filename, latest first).
78
+ *
79
+ * Contract spaces are returned with {@link APP_SPACE_ID} first when
80
+ * present, then the remaining ids lex-asc. A contract-space directory
81
+ * that contains no migrations becomes `{ spaceId, migrations: [] }` so
82
+ * the renderer's empty-state path can surface it.
83
+ *
84
+ * Directory entries that are not valid {@link isValidSpaceId} names are
85
+ * skipped (a stray non-space directory under `migrations/` does not
86
+ * spawn a phantom space entry). Entries whose name appears in
87
+ * {@link RESERVED_SPACE_SUBDIR_NAMES} are also skipped — the per-space
88
+ * `refs/` subdirectory name shape would otherwise satisfy
89
+ * {@link isValidSpaceId} and surface as a phantom contract space.
90
+ *
91
+ * Returns `[]` when `<projectMigrationsDir>` does not exist — a fresh
92
+ * project that has not authored any migration yet.
93
+ */
94
+ export async function enumerateMigrationSpaces(args: {
95
+ readonly projectMigrationsDir: string;
96
+ }): Promise<readonly MigrationSpaceListEntry[]> {
97
+ const { projectMigrationsDir } = args;
98
+ const candidateDirs = await listContractSpaceDirectories(projectMigrationsDir);
99
+ const spaceIds = candidateDirs
100
+ .filter((name) => !RESERVED_SPACE_SUBDIR_NAMES.has(name))
101
+ .filter(isValidSpaceId)
102
+ .sort(compareSpaceIds);
103
+
104
+ const spaces: MigrationSpaceListEntry[] = [];
105
+ for (const spaceId of spaceIds) {
106
+ const spaceDir = spaceMigrationDirectory(projectMigrationsDir, spaceId);
107
+ const { packages } = await readMigrationsDir(spaceDir);
108
+ const refsByHash = await resolveRefsByContractHash(spaceRefsDirectory(spaceDir));
109
+
110
+ const migrations: MigrationListEntry[] = packages
111
+ .map((pkg) => ({
112
+ dirName: pkg.dirName,
113
+ from: pkg.metadata.from,
114
+ to: pkg.metadata.to,
115
+ migrationHash: pkg.metadata.migrationHash,
116
+ operationCount: pkg.ops.length,
117
+ createdAt: pkg.metadata.createdAt,
118
+ refs: refsByHash.get(pkg.metadata.to) ?? [],
119
+ providedInvariants: pkg.metadata.providedInvariants,
120
+ }))
121
+ .sort(compareDirNamesDescending);
122
+
123
+ spaces.push({ spaceId, migrations });
124
+ }
125
+
126
+ return spaces;
127
+ }
package/src/errors.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ifDefined } from '@prisma-next/utils/defined';
2
2
  import { basename, dirname, relative } from 'pathe';
3
+ import type { MigrationGraph } from './graph';
3
4
 
4
5
  /**
5
6
  * Build the canonical "re-emit this package" remediation hint.
@@ -319,8 +320,8 @@ export function errorProvidedInvariantsMismatch(
319
320
  /**
320
321
  * Wire-shape edge surfaced through the JSON envelope's
321
322
  * `meta.structuralPath` of `MIGRATION.NO_INVARIANT_PATH`. Slim by design —
322
- * authoring metadata (`createdAt`, `labels`) lives on `MigrationEdge` but
323
- * is intentionally dropped here so the envelope stays stable across
323
+ * authoring metadata (`createdAt`) lives on `MigrationEdge` but is
324
+ * intentionally dropped here so the envelope stays stable across
324
325
  * graph-internal refactors.
325
326
  *
326
327
  * Stability: any field added here is part of the public CLI JSON contract.
@@ -399,3 +400,17 @@ export function errorMigrationHashMismatch(
399
400
  details: { dir, storedHash, computedHash },
400
401
  });
401
402
  }
403
+
404
+ export function errorHashNotInGraph(hash: string, graph: MigrationGraph): MigrationToolsError {
405
+ const reachableHashes = [...graph.nodes].sort();
406
+ const reachableList = reachableHashes.length > 0 ? reachableHashes.join(', ') : '(none)';
407
+ return new MigrationToolsError(
408
+ 'MIGRATION.HASH_NOT_IN_GRAPH',
409
+ `Hash "${hash}" is not a node in the migration graph`,
410
+ {
411
+ why: `The migration graph contains nodes ${reachableList}; "${hash}" isn't one of them.`,
412
+ fix: `Pass a hash that's the from-or-to of an on-disk migration bundle, use --from with a graph-node hash, or run "prisma-next migration plan" to introduce it.`,
413
+ details: { hash, reachableHashes },
414
+ },
415
+ );
416
+ }
@@ -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';
@@ -0,0 +1,4 @@
1
+ export {
2
+ enumerateMigrationSpaces,
3
+ resolveRefsByContractHash,
4
+ } from '../enumerate-migration-spaces';
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,
@@ -1 +1 @@
1
- export type { MigrationHints, MigrationMetadata } from '../metadata';
1
+ export type { MigrationMetadata } from '../metadata';
@@ -1,3 +1,4 @@
1
+ export { assertHashIsGraphNode, isGraphNode } from '../graph-membership';
1
2
  export type { PathDecision } from '../migration-graph';
2
3
  export {
3
4
  detectCycles,
@@ -0,0 +1,5 @@
1
+ export type {
2
+ MigrationEdgeKind,
3
+ MigrationListGraphTopology,
4
+ } from '../migration-list-graph-topology';
5
+ export { classifyMigrationListGraphTopology } from '../migration-list-graph-topology';
@@ -0,0 +1,5 @@
1
+ export type {
2
+ MigrationListEntry,
3
+ MigrationListResult,
4
+ MigrationSpaceListEntry,
5
+ } from '../migration-list-types';
@@ -8,3 +8,11 @@ export {
8
8
  validateRefValue,
9
9
  writeRef,
10
10
  } from '../refs';
11
+ export type { ContractIR } from '../refs/snapshot';
12
+ export {
13
+ deleteRefPaired,
14
+ deleteRefSnapshot,
15
+ readRefSnapshot,
16
+ writeRefPaired,
17
+ writeRefSnapshot,
18
+ } from '../refs/snapshot';
@@ -31,7 +31,10 @@ export {
31
31
  APP_SPACE_ID,
32
32
  assertValidSpaceId,
33
33
  isValidSpaceId,
34
+ RESERVED_SPACE_SUBDIR_NAMES,
35
+ SPACE_REFS_DIRNAME,
34
36
  spaceMigrationDirectory,
37
+ spaceRefsDirectory,
35
38
  type ValidSpaceId,
36
39
  } from '../space-layout';
37
40
  export {
@@ -0,0 +1,17 @@
1
+ import { EMPTY_CONTRACT_HASH } from './constants';
2
+ import { errorHashNotInGraph } from './errors';
3
+ import type { MigrationGraph } from './graph';
4
+
5
+ export function isGraphNode(hash: string, graph: MigrationGraph): boolean {
6
+ if (hash === EMPTY_CONTRACT_HASH) {
7
+ return true;
8
+ }
9
+ return graph.nodes.has(hash);
10
+ }
11
+
12
+ export function assertHashIsGraphNode(hash: string, graph: MigrationGraph): asserts hash is string {
13
+ if (isGraphNode(hash, graph)) {
14
+ return;
15
+ }
16
+ throw errorHashNotInGraph(hash, graph);
17
+ }
package/src/graph.ts CHANGED
@@ -8,7 +8,6 @@ export interface MigrationEdge {
8
8
  readonly migrationHash: string;
9
9
  readonly dirName: string;
10
10
  readonly createdAt: string;
11
- readonly labels: readonly string[];
12
11
  /**
13
12
  * Sorted, deduplicated list of `invariantId`s this edge provides.
14
13
  * An empty array means the migration declares no routing-visible
package/src/hash.ts CHANGED
@@ -15,13 +15,12 @@ function sha256Hex(input: string): string {
15
15
  }
16
16
 
17
17
  /**
18
- * Content-addressed migration hash over (metadata envelope sans hints,
19
- * ops). See ADR 199 — Storage-only migration identity for the
20
- * rationale: the storage-hash bookends (`from`, `to`) inside the
21
- * envelope anchor the contract identity by hash, and planner hints are
22
- * advisory and must not affect identity. The full contract IRs are not
23
- * part of the manifest they live in sibling `*-contract.json` files
24
- * authored alongside the migration, never inlined here.
18
+ * Content-addressed migration hash over (metadata envelope, ops). See
19
+ * ADR 199 — Storage-only migration identity for the rationale: the
20
+ * storage-hash bookends (`from`, `to`) inside the envelope anchor the
21
+ * contract identity by hash. The full contract IRs are not part of the
22
+ * manifest they live in sibling `*-contract.json` files authored
23
+ * alongside the migration, never inlined here.
25
24
  *
26
25
  * The integrity check is purely structural, not semantic. The function
27
26
  * canonicalizes its inputs via `sortKeys` (recursive) + `JSON.stringify`
@@ -46,7 +45,7 @@ export function computeMigrationHash(
46
45
  metadata: Omit<MigrationMetadata, 'migrationHash'> & { readonly migrationHash?: string },
47
46
  ops: MigrationOps,
48
47
  ): string {
49
- const { migrationHash: _migrationHash, hints: _hints, ...strippedMeta } = metadata;
48
+ const { migrationHash: _migrationHash, ...strippedMeta } = metadata;
50
49
 
51
50
  const canonicalMetadata = canonicalizeJson(strippedMeta);
52
51
  const canonicalOps = canonicalizeJson(ops);