@prisma-next/migration-tools 0.8.0 → 0.9.0-dev.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/{errors-EPL_9p9f.mjs → errors-DGYwcwXs.mjs} +3 -15
- package/dist/errors-DGYwcwXs.mjs.map +1 -0
- package/dist/exports/aggregate.d.mts +10 -10
- package/dist/exports/aggregate.d.mts.map +1 -1
- package/dist/exports/aggregate.mjs +5 -5
- package/dist/exports/aggregate.mjs.map +1 -1
- package/dist/exports/errors.d.mts.map +1 -1
- package/dist/exports/errors.mjs +1 -1
- package/dist/exports/graph.d.mts +1 -1
- package/dist/exports/hash.d.mts +9 -7
- package/dist/exports/hash.d.mts.map +1 -1
- package/dist/exports/hash.mjs +1 -1
- package/dist/exports/invariants.d.mts +1 -1
- package/dist/exports/invariants.mjs +1 -1
- package/dist/exports/io.d.mts +13 -17
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +1 -1
- package/dist/exports/metadata.d.mts +1 -1
- package/dist/exports/migration-graph.d.mts +1 -1
- package/dist/exports/migration-graph.mjs +1 -1
- package/dist/exports/migration.d.mts +1 -1
- package/dist/exports/migration.d.mts.map +1 -1
- package/dist/exports/migration.mjs +3 -41
- package/dist/exports/migration.mjs.map +1 -1
- package/dist/exports/package.d.mts +1 -1
- package/dist/exports/ref-resolution.d.mts +100 -0
- package/dist/exports/ref-resolution.d.mts.map +1 -0
- package/dist/exports/ref-resolution.mjs +239 -0
- package/dist/exports/ref-resolution.mjs.map +1 -0
- package/dist/exports/refs.d.mts +2 -16
- package/dist/exports/refs.mjs +1 -147
- package/dist/exports/spaces.d.mts +5 -5
- package/dist/exports/spaces.mjs +7 -7
- package/dist/exports/spaces.mjs.map +1 -1
- package/dist/{graph-HMWAldoR.d.mts → graph-BrLXqoUc.d.mts} +1 -1
- package/dist/{graph-HMWAldoR.d.mts.map → graph-BrLXqoUc.d.mts.map} +1 -1
- package/dist/{hash-C6bpZczT.mjs → hash-Cr4WIr4Z.mjs} +10 -8
- package/dist/hash-Cr4WIr4Z.mjs.map +1 -0
- package/dist/{invariants-qgQGlsrV.mjs → invariants-0daYEzyo.mjs} +2 -2
- package/dist/{invariants-qgQGlsrV.mjs.map → invariants-0daYEzyo.mjs.map} +1 -1
- package/dist/{io-Dw620b51.mjs → io-BPLfzvZe.mjs} +16 -24
- package/dist/io-BPLfzvZe.mjs.map +1 -0
- package/dist/{migration-graph-DulOITvG.d.mts → migration-graph-De0dUZoC.d.mts} +3 -3
- package/dist/{migration-graph-DulOITvG.d.mts.map → migration-graph-De0dUZoC.d.mts.map} +1 -1
- package/dist/{migration-graph-DGNnKDY5.mjs → migration-graph-nlS4TRpn.mjs} +2 -2
- package/dist/{migration-graph-DGNnKDY5.mjs.map → migration-graph-nlS4TRpn.mjs.map} +1 -1
- package/dist/{package-BjiZ7KDy.d.mts → package-DZj8YvD0.d.mts} +1 -1
- package/dist/package-DZj8YvD0.d.mts.map +1 -0
- package/dist/{read-contract-space-contract-COyz4tZn.mjs → read-contract-space-contract-DRueB4Aa.mjs} +4 -4
- package/dist/{read-contract-space-contract-COyz4tZn.mjs.map → read-contract-space-contract-DRueB4Aa.mjs.map} +1 -1
- package/dist/refs-BDHo5l_g.mjs +148 -0
- package/dist/refs-BDHo5l_g.mjs.map +1 -0
- package/dist/refs-CDaNerhT.d.mts +16 -0
- package/dist/refs-CDaNerhT.d.mts.map +1 -0
- package/package.json +10 -6
- package/src/aggregate/loader.ts +3 -3
- package/src/aggregate/planner-types.ts +2 -2
- package/src/aggregate/strategies/graph-walk.ts +2 -3
- package/src/aggregate/strategies/synth.ts +1 -2
- package/src/aggregate/types.ts +1 -1
- package/src/compute-extension-space-apply-path.ts +1 -1
- package/src/contract-space-from-json.ts +3 -3
- package/src/errors.ts +1 -22
- package/src/exports/ref-resolution.ts +15 -0
- package/src/hash.ts +8 -12
- package/src/io.ts +12 -22
- package/src/migration-base.ts +1 -54
- package/src/read-contract-space-contract.ts +1 -1
- package/src/refs/contract-ref.ts +103 -0
- package/src/refs/migration-ref.ts +121 -0
- package/src/refs/types.ts +93 -0
- package/src/refs.ts +3 -3
- package/dist/errors-EPL_9p9f.mjs.map +0 -1
- package/dist/exports/refs.d.mts.map +0 -1
- package/dist/exports/refs.mjs.map +0 -1
- package/dist/hash-C6bpZczT.mjs.map +0 -1
- package/dist/io-Dw620b51.mjs.map +0 -1
- package/dist/package-BjiZ7KDy.d.mts.map +0 -1
- /package/dist/{metadata-CFvm3ayn.d.mts → metadata-BFX0xdz8.d.mts} +0 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Contract } from '@prisma-next/contract/types';
|
|
2
1
|
import type { MigrationPlan } from '@prisma-next/framework-components/control';
|
|
3
2
|
import { EMPTY_CONTRACT_HASH } from '../../constants';
|
|
4
3
|
import { findPathWithDecision } from '../../migration-graph';
|
|
@@ -26,7 +25,7 @@ export interface GraphWalkStrategyInputs {
|
|
|
26
25
|
readonly currentMarker: ContractMarkerRecordLike | null;
|
|
27
26
|
/**
|
|
28
27
|
* Optional ref name to decorate the resulting `PathDecision`. Used by
|
|
29
|
-
* `
|
|
28
|
+
* `migrate` to surface the user-supplied `--to <name>` in
|
|
30
29
|
* structured-progress events and invariant-path error envelopes. The
|
|
31
30
|
* strategy itself does not interpret it.
|
|
32
31
|
*/
|
|
@@ -109,7 +108,7 @@ export function graphWalkStrategy(input: GraphWalkStrategyInputs): GraphWalkOutc
|
|
|
109
108
|
result: {
|
|
110
109
|
plan,
|
|
111
110
|
displayOps: pathOps,
|
|
112
|
-
destinationContract: member.contract
|
|
111
|
+
destinationContract: member.contract,
|
|
113
112
|
strategy: 'graph-walk',
|
|
114
113
|
migrationEdges: edgeRefs,
|
|
115
114
|
pathDecision: outcome.decision,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Contract } from '@prisma-next/contract/types';
|
|
2
1
|
import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
|
|
3
2
|
import type {
|
|
4
3
|
ControlFamilyInstance,
|
|
@@ -115,7 +114,7 @@ export async function synthStrategy<TFamilyId extends string, TTargetId extends
|
|
|
115
114
|
result: {
|
|
116
115
|
plan,
|
|
117
116
|
displayOps: synthedPlan.operations,
|
|
118
|
-
destinationContract: input.member.contract
|
|
117
|
+
destinationContract: input.member.contract,
|
|
119
118
|
strategy: 'synth',
|
|
120
119
|
},
|
|
121
120
|
};
|
package/src/aggregate/types.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface HydratedMigrationGraph {
|
|
|
30
30
|
* - `contract`: the validated contract value for this member. For the
|
|
31
31
|
* app, the user's authored contract; for an extension, the on-disk
|
|
32
32
|
* `migrations/<spaceId>/contract.json`. Both have already passed the
|
|
33
|
-
* family's `
|
|
33
|
+
* family's `deserializeContract` at the loader boundary.
|
|
34
34
|
* - `headRef.hash`: the storage hash this member is targeting. For the
|
|
35
35
|
* app, equals `contract.storage.storageHash`. For extensions, the
|
|
36
36
|
* on-disk `refs/head.json.hash`.
|
|
@@ -101,7 +101,7 @@ export async function computeExtensionSpaceApplyPath(
|
|
|
101
101
|
const graph = reconstructGraph(packages);
|
|
102
102
|
|
|
103
103
|
// Live-marker layer encodes "no prior state" as EMPTY_CONTRACT_HASH;
|
|
104
|
-
// mirror the `
|
|
104
|
+
// mirror the `migrate` flow so a fresh-marker initial walk
|
|
105
105
|
// hits the baseline migration whose `from` is EMPTY_CONTRACT_HASH.
|
|
106
106
|
const fromHash = currentMarkerHash ?? EMPTY_CONTRACT_HASH;
|
|
107
107
|
const required = new Set(
|
|
@@ -29,9 +29,9 @@ import type { MigrationMetadata } from './metadata';
|
|
|
29
29
|
* canonical emit pipeline".
|
|
30
30
|
*
|
|
31
31
|
* The helper does not introspect or schema-validate the inputs; runtime
|
|
32
|
-
* validation is the responsibility of `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* validation is the responsibility of `family.deserializeContract`
|
|
33
|
+
* (codec-aware, invoked at control-stack construction) and the
|
|
34
|
+
* per-migration `readMigrationPackage` reader used when loading
|
|
35
35
|
* from disk. JSON-imported packages flow through the descriptor without
|
|
36
36
|
* a disk read, so the equivalent runtime guarantee comes from the emit
|
|
37
37
|
* pipeline that produced the JSON in the first place.
|
package/src/errors.ts
CHANGED
|
@@ -111,27 +111,6 @@ export function errorInvalidOperationEntry(index: number, reason: string): Migra
|
|
|
111
111
|
);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
export function errorStaleContractBookends(args: {
|
|
115
|
-
readonly side: 'from' | 'to';
|
|
116
|
-
readonly metaHash: string | null;
|
|
117
|
-
readonly contractHash: string;
|
|
118
|
-
}): MigrationToolsError {
|
|
119
|
-
const { side, metaHash, contractHash } = args;
|
|
120
|
-
// `meta.from` is `string | null` (null = baseline). Render `null` as a
|
|
121
|
-
// human-readable token in the diagnostic so the message stays clear when
|
|
122
|
-
// the mismatch is a baseline-vs-non-baseline disagreement.
|
|
123
|
-
const renderedMetaHash = metaHash === null ? 'null (baseline)' : `"${metaHash}"`;
|
|
124
|
-
return new MigrationToolsError(
|
|
125
|
-
'MIGRATION.STALE_CONTRACT_BOOKENDS',
|
|
126
|
-
'Migration manifest contract bookends disagree with describe()',
|
|
127
|
-
{
|
|
128
|
-
why: `migration.json stores ${side}Contract.storage.storageHash "${contractHash}", but describe() returned meta.${side} = ${renderedMetaHash}. The bookend is stale — most likely the migration's describe() was edited after the package was scaffolded by \`migration plan\`.`,
|
|
129
|
-
fix: 'Re-run `migration plan` to regenerate the package with fresh contract bookends, or restore the directory from version control.',
|
|
130
|
-
details: { side, metaHash, contractHash },
|
|
131
|
-
},
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
114
|
export function errorInvalidSlug(slug: string): MigrationToolsError {
|
|
136
115
|
return new MigrationToolsError('MIGRATION.INVALID_NAME', 'Invalid migration name', {
|
|
137
116
|
why: `The slug "${slug}" contains no valid characters after sanitization (only a-z, 0-9 are kept).`,
|
|
@@ -220,7 +199,7 @@ export function errorAmbiguousTarget(
|
|
|
220
199
|
: '';
|
|
221
200
|
return new MigrationToolsError('MIGRATION.AMBIGUOUS_TARGET', 'Ambiguous migration target', {
|
|
222
201
|
why: `The migration history has diverged into multiple branches: ${branchTips.join(', ')}. This typically happens when two developers plan migrations from the same starting point.${divergenceInfo}`,
|
|
223
|
-
fix: 'Use `
|
|
202
|
+
fix: 'Use `ref set <name> <hash>` to target a specific branch, delete one of the conflicting migration directories and re-run `migration plan`, or use --from <hash> to explicitly select a starting point.',
|
|
224
203
|
details: {
|
|
225
204
|
branchTips,
|
|
226
205
|
...(context ? { divergencePoint: context.divergencePoint, branches: context.branches } : {}),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { parseContractRef } from '../refs/contract-ref';
|
|
2
|
+
export { parseMigrationRef } from '../refs/migration-ref';
|
|
3
|
+
export type {
|
|
4
|
+
ContractRef,
|
|
5
|
+
ContractRefProvenance,
|
|
6
|
+
MigrationRef,
|
|
7
|
+
MigrationRefProvenance,
|
|
8
|
+
RefResolutionAmbiguous,
|
|
9
|
+
RefResolutionContext,
|
|
10
|
+
RefResolutionError,
|
|
11
|
+
RefResolutionInvalidFormat,
|
|
12
|
+
RefResolutionNotFound,
|
|
13
|
+
RefResolutionWrongGrammar,
|
|
14
|
+
} from '../refs/types';
|
|
15
|
+
export { findEdgeByDirName } from '../refs/types';
|
package/src/hash.ts
CHANGED
|
@@ -15,11 +15,13 @@ function sha256Hex(input: string): string {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Content-addressed migration hash over (metadata envelope sans
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* and must not affect identity.
|
|
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.
|
|
23
25
|
*
|
|
24
26
|
* The integrity check is purely structural, not semantic. The function
|
|
25
27
|
* canonicalizes its inputs via `sortKeys` (recursive) + `JSON.stringify`
|
|
@@ -44,13 +46,7 @@ export function computeMigrationHash(
|
|
|
44
46
|
metadata: Omit<MigrationMetadata, 'migrationHash'> & { readonly migrationHash?: string },
|
|
45
47
|
ops: MigrationOps,
|
|
46
48
|
): string {
|
|
47
|
-
const {
|
|
48
|
-
migrationHash: _migrationHash,
|
|
49
|
-
fromContract: _fromContract,
|
|
50
|
-
toContract: _toContract,
|
|
51
|
-
hints: _hints,
|
|
52
|
-
...strippedMeta
|
|
53
|
-
} = metadata;
|
|
49
|
+
const { migrationHash: _migrationHash, hints: _hints, ...strippedMeta } = metadata;
|
|
54
50
|
|
|
55
51
|
const canonicalMetadata = canonicalizeJson(strippedMeta);
|
|
56
52
|
const canonicalOps = canonicalizeJson(ops);
|
package/src/io.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type {
|
|
|
3
3
|
MigrationMetadata,
|
|
4
4
|
MigrationPackage,
|
|
5
5
|
} from '@prisma-next/framework-components/control';
|
|
6
|
-
import { canonicalizeJson } from '@prisma-next/framework-components/utils';
|
|
7
6
|
import { type } from 'arktype';
|
|
8
7
|
import { basename, dirname, join, resolve } from 'pathe';
|
|
9
8
|
import {
|
|
@@ -40,8 +39,6 @@ const MigrationMetadataSchema = type({
|
|
|
40
39
|
from: 'string > 0 | null',
|
|
41
40
|
to: 'string',
|
|
42
41
|
migrationHash: 'string',
|
|
43
|
-
fromContract: 'object | null',
|
|
44
|
-
toContract: 'object',
|
|
45
42
|
hints: MigrationHintsSchema,
|
|
46
43
|
labels: 'string[]',
|
|
47
44
|
providedInvariants: 'string[]',
|
|
@@ -74,35 +71,31 @@ export async function writeMigrationPackage(
|
|
|
74
71
|
* Materialise an in-memory {@link MigrationPackage} to a per-space
|
|
75
72
|
* directory on disk.
|
|
76
73
|
*
|
|
77
|
-
* Writes
|
|
74
|
+
* Writes two files under `<targetDir>/<pkg.dirName>/`:
|
|
78
75
|
*
|
|
79
76
|
* - `migration.json` — the manifest (pretty-printed, matches
|
|
80
77
|
* {@link writeMigrationPackage}'s output for byte-for-byte parity with
|
|
81
78
|
* app-space migrations).
|
|
82
79
|
* - `ops.json` — the operation list (pretty-printed).
|
|
83
|
-
* - `contract.json` — the canonical-JSON serialisation of
|
|
84
|
-
* `metadata.toContract`. This is the per-package post-state contract
|
|
85
|
-
* snapshot; the canonicalisation pass guarantees byte-determinism so
|
|
86
|
-
* re-emitting the same package across machines / runs produces an
|
|
87
|
-
* identical file.
|
|
88
80
|
*
|
|
89
81
|
* Distinct verb from the lower-level {@link writeMigrationPackage}
|
|
90
82
|
* (which takes constituent `(metadata, ops)`): callers reading
|
|
91
|
-
* `materialise…` know they are persisting a struct-typed package
|
|
92
|
-
* including its contract-snapshot side car.
|
|
83
|
+
* `materialise…` know they are persisting a struct-typed package.
|
|
93
84
|
*
|
|
94
85
|
* Overwrite-idempotent: the per-package directory is cleared before
|
|
95
86
|
* each emit, so re-running against the same `targetDir` produces
|
|
96
87
|
* byte-identical contents and never leaves stale files behind. The
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* authored migration; this helper is the re-emit path that is
|
|
103
|
-
* supposed to converge on a single canonical on-disk shape.
|
|
88
|
+
* lower-level {@link writeMigrationPackage} stays strict because the
|
|
89
|
+
* CLI authoring path (`migration plan` / `migration new`) deliberately
|
|
90
|
+
* refuses to clobber an existing authored migration; this helper is
|
|
91
|
+
* the re-emit path that is supposed to converge on a single canonical
|
|
92
|
+
* on-disk shape.
|
|
104
93
|
*
|
|
105
|
-
*
|
|
94
|
+
* The per-space head contract lives at
|
|
95
|
+
* `<projectMigrationsDir>/<spaceId>/contract.json` (written by
|
|
96
|
+
* {@link import('./emit-contract-space-artefacts').emitContractSpaceArtefacts}),
|
|
97
|
+
* not inside the per-package directory. The runner reads only
|
|
98
|
+
* `migration.json` + `ops.json` from each package.
|
|
106
99
|
*/
|
|
107
100
|
export async function materialiseMigrationPackage(
|
|
108
101
|
targetDir: string,
|
|
@@ -111,9 +104,6 @@ export async function materialiseMigrationPackage(
|
|
|
111
104
|
const dir = join(targetDir, pkg.dirName);
|
|
112
105
|
await rm(dir, { recursive: true, force: true });
|
|
113
106
|
await writeMigrationPackage(dir, pkg.metadata, pkg.ops);
|
|
114
|
-
await writeFile(join(dir, 'contract.json'), `${canonicalizeJson(pkg.metadata.toContract)}\n`, {
|
|
115
|
-
flag: 'wx',
|
|
116
|
-
});
|
|
117
107
|
}
|
|
118
108
|
|
|
119
109
|
/**
|
package/src/migration-base.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import type { Contract } from '@prisma-next/contract/types';
|
|
4
3
|
import type {
|
|
5
4
|
ControlStack,
|
|
6
5
|
MigrationPlan,
|
|
7
6
|
MigrationPlanOperation,
|
|
8
7
|
} from '@prisma-next/framework-components/control';
|
|
9
8
|
import { type } from 'arktype';
|
|
10
|
-
import { errorInvalidOperationEntry
|
|
9
|
+
import { errorInvalidOperationEntry } from './errors';
|
|
11
10
|
import { computeMigrationHash } from './hash';
|
|
12
11
|
import { deriveProvidedInvariants } from './invariants';
|
|
13
12
|
import type { MigrationHints, MigrationMetadata } from './metadata';
|
|
@@ -145,21 +144,12 @@ function buildAttestedMetadata(
|
|
|
145
144
|
ops: MigrationOps,
|
|
146
145
|
existing: Partial<MigrationMetadata> | null,
|
|
147
146
|
): MigrationMetadata {
|
|
148
|
-
assertBookendsMatchMeta(meta, existing);
|
|
149
|
-
|
|
150
147
|
const baseMetadata: Omit<MigrationMetadata, 'migrationHash'> = {
|
|
151
148
|
from: meta.from,
|
|
152
149
|
to: meta.to,
|
|
153
150
|
labels: meta.labels ?? existing?.labels ?? [],
|
|
154
151
|
providedInvariants: deriveProvidedInvariants(ops),
|
|
155
152
|
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
156
|
-
fromContract: existing?.fromContract ?? null,
|
|
157
|
-
// When no scaffolded metadata exists we synthesize a minimal contract
|
|
158
|
-
// stub so the package is still readable end-to-end. The cast is
|
|
159
|
-
// intentional: only the storage bookend matters for hash computation
|
|
160
|
-
// (everything else is stripped by `computeMigrationHash`), and a real
|
|
161
|
-
// contract bookend would only be available after `migration plan`.
|
|
162
|
-
toContract: existing?.toContract ?? ({ storage: { storageHash: meta.to } } as Contract),
|
|
163
153
|
hints: normalizeHints(existing?.hints),
|
|
164
154
|
};
|
|
165
155
|
|
|
@@ -167,49 +157,6 @@ function buildAttestedMetadata(
|
|
|
167
157
|
return { ...baseMetadata, migrationHash };
|
|
168
158
|
}
|
|
169
159
|
|
|
170
|
-
/**
|
|
171
|
-
* Verify each preserved contract bookend in `existing` agrees with the
|
|
172
|
-
* corresponding side of `describe()`'s output. A mismatch indicates the
|
|
173
|
-
* migration's `describe()` was edited after `migration plan` scaffolded
|
|
174
|
-
* the package, leaving a self-inconsistent manifest. Failing fast at
|
|
175
|
-
* write-time turns a silent foot-gun into an actionable diagnostic.
|
|
176
|
-
*
|
|
177
|
-
* Skipped when a side's `existing.<side>Contract` is null/absent (the
|
|
178
|
-
* synthesis path stays open for origin-less initial migrations and for
|
|
179
|
-
* bare `migration.ts` runs from scratch). When a bookend is *present*
|
|
180
|
-
* but its `storage.storageHash` is missing, that's treated as a
|
|
181
|
-
* mismatch — a malformed bookend is not equivalent to "no bookend".
|
|
182
|
-
*
|
|
183
|
-
* This check is paired with TML-2274, which removes `fromContract` /
|
|
184
|
-
* `toContract` from the manifest entirely; once that lands, this
|
|
185
|
-
* function and its error code are deleted.
|
|
186
|
-
*/
|
|
187
|
-
function assertBookendsMatchMeta(
|
|
188
|
-
meta: MigrationMeta,
|
|
189
|
-
existing: Partial<MigrationMetadata> | null,
|
|
190
|
-
): void {
|
|
191
|
-
if (existing?.fromContract != null) {
|
|
192
|
-
const contractHash = existing.fromContract.storage?.storageHash ?? '';
|
|
193
|
-
if (contractHash !== meta.from) {
|
|
194
|
-
throw errorStaleContractBookends({
|
|
195
|
-
side: 'from',
|
|
196
|
-
metaHash: meta.from,
|
|
197
|
-
contractHash,
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
if (existing?.toContract != null) {
|
|
202
|
-
const contractHash = existing.toContract.storage?.storageHash ?? '';
|
|
203
|
-
if (contractHash !== meta.to) {
|
|
204
|
-
throw errorStaleContractBookends({
|
|
205
|
-
side: 'to',
|
|
206
|
-
metaHash: meta.to,
|
|
207
|
-
contractHash,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
160
|
/**
|
|
214
161
|
* Project `existing.hints` down to the known `MigrationHints` shape, dropping
|
|
215
162
|
* any legacy keys that may linger in metadata scaffolded by older CLI
|
|
@@ -11,7 +11,7 @@ function hasErrnoCode(error: unknown, code: string): boolean {
|
|
|
11
11
|
* Read the on-disk contract value for a contract space
|
|
12
12
|
* (`<projectMigrationsDir>/<spaceId>/contract.json`). Returns the parsed
|
|
13
13
|
* JSON value as `unknown` — callers that need a typed contract validate
|
|
14
|
-
* via their family's `
|
|
14
|
+
* via their family's `deserializeContract` to surface schema issues.
|
|
15
15
|
*
|
|
16
16
|
* Companion to {@link import('./read-contract-space-head-ref').readContractSpaceHeadRef}
|
|
17
17
|
* — same ENOENT-throws / corrupt-file-error semantics. Returns the
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Result } from '@prisma-next/utils/result';
|
|
2
|
+
import { notOk, ok } from '@prisma-next/utils/result';
|
|
3
|
+
import { validateRefName } from '../refs';
|
|
4
|
+
import type {
|
|
5
|
+
ContractRef,
|
|
6
|
+
ContractRefProvenance,
|
|
7
|
+
RefResolutionContext,
|
|
8
|
+
RefResolutionError,
|
|
9
|
+
} from './types';
|
|
10
|
+
import { findEdgeByDirName, isFullHash, isHexPrefix, normalizeHashPrefix } from './types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a user-supplied string to a contract hash using the unified
|
|
14
|
+
* contract-reference grammar.
|
|
15
|
+
*
|
|
16
|
+
* Accepted forms:
|
|
17
|
+
* - Full storage hash (`sha256:<64 hex>` or `sha256:empty`)
|
|
18
|
+
* - Hex prefix (6+ hex chars, must uniquely identify one contract)
|
|
19
|
+
* - Ref name (looked up in the refs index)
|
|
20
|
+
* - Migration directory name (resolves to the migration's `to`-contract)
|
|
21
|
+
* - `<dir>^` (resolves to the migration's `from`-contract)
|
|
22
|
+
*/
|
|
23
|
+
export function parseContractRef(
|
|
24
|
+
input: string,
|
|
25
|
+
ctx: RefResolutionContext,
|
|
26
|
+
): Result<ContractRef, RefResolutionError> {
|
|
27
|
+
if (!input) {
|
|
28
|
+
return notOk({ kind: 'invalid-format', input, reason: 'Reference cannot be empty' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (isFullHash(input)) {
|
|
32
|
+
if (ctx.graph.nodes.has(input)) {
|
|
33
|
+
return ok({ hash: input, provenance: { kind: 'hash', input } });
|
|
34
|
+
}
|
|
35
|
+
return notOk({ kind: 'not-found', input, grammar: 'contract' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (input.endsWith('^')) {
|
|
39
|
+
const dirName = input.slice(0, -1);
|
|
40
|
+
if (!dirName) {
|
|
41
|
+
return notOk({ kind: 'invalid-format', input, reason: 'Missing directory name before ^' });
|
|
42
|
+
}
|
|
43
|
+
const edge = findEdgeByDirName(ctx.graph, dirName);
|
|
44
|
+
if (edge) {
|
|
45
|
+
return ok({ hash: edge.from, provenance: { kind: 'migration-from', dirName } });
|
|
46
|
+
}
|
|
47
|
+
return notOk({ kind: 'not-found', input, grammar: 'contract' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Candidate = { hash: string; provenance: ContractRefProvenance; label: string };
|
|
51
|
+
const candidates: Candidate[] = [];
|
|
52
|
+
|
|
53
|
+
if (validateRefName(input) && Object.hasOwn(ctx.refs, input)) {
|
|
54
|
+
const ref = ctx.refs[input];
|
|
55
|
+
if (ref) {
|
|
56
|
+
candidates.push({
|
|
57
|
+
hash: ref.hash,
|
|
58
|
+
provenance: { kind: 'ref', refName: input },
|
|
59
|
+
label: `ref "${input}"`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const edge = findEdgeByDirName(ctx.graph, input);
|
|
65
|
+
if (edge) {
|
|
66
|
+
candidates.push({
|
|
67
|
+
hash: edge.to,
|
|
68
|
+
provenance: { kind: 'migration-to', dirName: input },
|
|
69
|
+
label: `migration directory "${input}"`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isHexPrefix(input)) {
|
|
74
|
+
const prefix = normalizeHashPrefix(input);
|
|
75
|
+
const matches = [...ctx.graph.nodes].filter((n) => n.startsWith(prefix));
|
|
76
|
+
const [firstMatch] = matches;
|
|
77
|
+
if (matches.length === 1 && firstMatch !== undefined) {
|
|
78
|
+
candidates.push({
|
|
79
|
+
hash: firstMatch,
|
|
80
|
+
provenance: { kind: 'hash', input },
|
|
81
|
+
label: `hash prefix "${input}"`,
|
|
82
|
+
});
|
|
83
|
+
} else if (matches.length > 1) {
|
|
84
|
+
return notOk({ kind: 'ambiguous', input, candidates: matches, grammar: 'contract' });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const [firstCandidate] = candidates;
|
|
89
|
+
if (candidates.length === 1 && firstCandidate !== undefined) {
|
|
90
|
+
return ok({ hash: firstCandidate.hash, provenance: firstCandidate.provenance });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (candidates.length > 1) {
|
|
94
|
+
return notOk({
|
|
95
|
+
kind: 'ambiguous',
|
|
96
|
+
input,
|
|
97
|
+
candidates: candidates.map((c) => c.label),
|
|
98
|
+
grammar: 'contract',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return notOk({ kind: 'not-found', input, grammar: 'contract' });
|
|
103
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Result } from '@prisma-next/utils/result';
|
|
2
|
+
import { notOk, ok } from '@prisma-next/utils/result';
|
|
3
|
+
import { validateRefName } from '../refs';
|
|
4
|
+
import type { MigrationRef, RefResolutionContext, RefResolutionError } from './types';
|
|
5
|
+
import { findEdgeByDirName, isFullHash, isHexPrefix, normalizeHashPrefix } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a user-supplied string to a migration using the migration-reference
|
|
9
|
+
* grammar.
|
|
10
|
+
*
|
|
11
|
+
* Accepted forms:
|
|
12
|
+
* - Migration directory name (e.g. `20260101-add-users`)
|
|
13
|
+
* - Migration hash (full or 6+ hex prefix)
|
|
14
|
+
*
|
|
15
|
+
* Wrong-grammar diagnostics are produced when the input matches a
|
|
16
|
+
* contract-grammar form (ref name, `<dir>^`, contract-only hash) so the
|
|
17
|
+
* user gets a targeted hint rather than a generic "not found".
|
|
18
|
+
*/
|
|
19
|
+
export function parseMigrationRef(
|
|
20
|
+
input: string,
|
|
21
|
+
ctx: RefResolutionContext,
|
|
22
|
+
): Result<MigrationRef, RefResolutionError> {
|
|
23
|
+
if (!input) {
|
|
24
|
+
return notOk({ kind: 'invalid-format', input, reason: 'Reference cannot be empty' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (input.endsWith('^')) {
|
|
28
|
+
return notOk({
|
|
29
|
+
kind: 'wrong-grammar',
|
|
30
|
+
input,
|
|
31
|
+
expectedGrammar: 'migration',
|
|
32
|
+
message: '`^` syntax addresses contracts, not migrations',
|
|
33
|
+
fix: 'Pass the migration directory name without `^`, or use a contract-accepting flag like `--to` or `--from`.',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (validateRefName(input) && Object.hasOwn(ctx.refs, input)) {
|
|
38
|
+
return notOk({
|
|
39
|
+
kind: 'wrong-grammar',
|
|
40
|
+
input,
|
|
41
|
+
expectedGrammar: 'migration',
|
|
42
|
+
message: `"${input}" is a ref name, not a migration`,
|
|
43
|
+
fix: 'Refs point at contracts, not migrations. Use a migration directory name or migration hash.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const edge = findEdgeByDirName(ctx.graph, input);
|
|
48
|
+
if (edge) {
|
|
49
|
+
return ok({
|
|
50
|
+
dirName: edge.dirName,
|
|
51
|
+
migrationHash: edge.migrationHash,
|
|
52
|
+
from: edge.from,
|
|
53
|
+
to: edge.to,
|
|
54
|
+
provenance: { kind: 'dir-name', dirName: input },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isFullHash(input)) {
|
|
59
|
+
const migEdge = ctx.graph.migrationByHash.get(input);
|
|
60
|
+
if (migEdge) {
|
|
61
|
+
return ok({
|
|
62
|
+
dirName: migEdge.dirName,
|
|
63
|
+
migrationHash: migEdge.migrationHash,
|
|
64
|
+
from: migEdge.from,
|
|
65
|
+
to: migEdge.to,
|
|
66
|
+
provenance: { kind: 'hash', input },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (ctx.graph.nodes.has(input)) {
|
|
70
|
+
return notOk({
|
|
71
|
+
kind: 'wrong-grammar',
|
|
72
|
+
input,
|
|
73
|
+
expectedGrammar: 'migration',
|
|
74
|
+
message: 'Hash matched a contract but not a migration',
|
|
75
|
+
fix: 'Use a contract-accepting flag like `--to` or `--from` to reference contracts by hash. Pass `migration show <dir>` for a specific migration.',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return notOk({ kind: 'not-found', input, grammar: 'migration' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isHexPrefix(input)) {
|
|
82
|
+
const prefix = normalizeHashPrefix(input);
|
|
83
|
+
const migMatches = [...ctx.graph.migrationByHash.entries()].filter(([hash]) =>
|
|
84
|
+
hash.startsWith(prefix),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const [firstMigMatch] = migMatches;
|
|
88
|
+
if (migMatches.length === 1 && firstMigMatch !== undefined) {
|
|
89
|
+
const [, matchedEdge] = firstMigMatch;
|
|
90
|
+
return ok({
|
|
91
|
+
dirName: matchedEdge.dirName,
|
|
92
|
+
migrationHash: matchedEdge.migrationHash,
|
|
93
|
+
from: matchedEdge.from,
|
|
94
|
+
to: matchedEdge.to,
|
|
95
|
+
provenance: { kind: 'hash', input },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (migMatches.length > 1) {
|
|
100
|
+
return notOk({
|
|
101
|
+
kind: 'ambiguous',
|
|
102
|
+
input,
|
|
103
|
+
candidates: migMatches.map(([hash]) => hash),
|
|
104
|
+
grammar: 'migration',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const contractMatches = [...ctx.graph.nodes].filter((n) => n.startsWith(prefix));
|
|
109
|
+
if (contractMatches.length > 0) {
|
|
110
|
+
return notOk({
|
|
111
|
+
kind: 'wrong-grammar',
|
|
112
|
+
input,
|
|
113
|
+
expectedGrammar: 'migration',
|
|
114
|
+
message: 'Hash matched a contract but not a migration',
|
|
115
|
+
fix: 'Use a contract-accepting flag like `--to` or `--from` to reference contracts by hash. Pass `migration show <dir>` for a specific migration.',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return notOk({ kind: 'not-found', input, grammar: 'migration' });
|
|
121
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { MigrationEdge, MigrationGraph } from '../graph';
|
|
2
|
+
import type { Refs } from '../refs';
|
|
3
|
+
|
|
4
|
+
/** Context required to resolve a contract or migration reference. */
|
|
5
|
+
export interface RefResolutionContext {
|
|
6
|
+
readonly graph: MigrationGraph;
|
|
7
|
+
readonly refs: Refs;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ContractRefProvenance =
|
|
11
|
+
| { readonly kind: 'hash'; readonly input: string }
|
|
12
|
+
| { readonly kind: 'ref'; readonly refName: string }
|
|
13
|
+
| { readonly kind: 'migration-to'; readonly dirName: string }
|
|
14
|
+
| { readonly kind: 'migration-from'; readonly dirName: string };
|
|
15
|
+
|
|
16
|
+
/** A resolved contract reference: the target hash and how it was derived. */
|
|
17
|
+
export interface ContractRef {
|
|
18
|
+
readonly hash: string;
|
|
19
|
+
readonly provenance: ContractRefProvenance;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MigrationRefProvenance =
|
|
23
|
+
| { readonly kind: 'dir-name'; readonly dirName: string }
|
|
24
|
+
| { readonly kind: 'hash'; readonly input: string };
|
|
25
|
+
|
|
26
|
+
/** A resolved migration reference. */
|
|
27
|
+
export interface MigrationRef {
|
|
28
|
+
readonly dirName: string;
|
|
29
|
+
readonly migrationHash: string;
|
|
30
|
+
readonly from: string;
|
|
31
|
+
readonly to: string;
|
|
32
|
+
readonly provenance: MigrationRefProvenance;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RefResolutionNotFound {
|
|
36
|
+
readonly kind: 'not-found';
|
|
37
|
+
readonly input: string;
|
|
38
|
+
readonly grammar: 'contract' | 'migration';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RefResolutionAmbiguous {
|
|
42
|
+
readonly kind: 'ambiguous';
|
|
43
|
+
readonly input: string;
|
|
44
|
+
readonly candidates: readonly string[];
|
|
45
|
+
readonly grammar: 'contract' | 'migration';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RefResolutionWrongGrammar {
|
|
49
|
+
readonly kind: 'wrong-grammar';
|
|
50
|
+
readonly input: string;
|
|
51
|
+
readonly expectedGrammar: 'contract' | 'migration';
|
|
52
|
+
readonly message: string;
|
|
53
|
+
readonly fix: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RefResolutionInvalidFormat {
|
|
57
|
+
readonly kind: 'invalid-format';
|
|
58
|
+
readonly input: string;
|
|
59
|
+
readonly reason: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type RefResolutionError =
|
|
63
|
+
| RefResolutionNotFound
|
|
64
|
+
| RefResolutionAmbiguous
|
|
65
|
+
| RefResolutionWrongGrammar
|
|
66
|
+
| RefResolutionInvalidFormat;
|
|
67
|
+
|
|
68
|
+
const FULL_HASH_PATTERN = /^sha256:([0-9a-f]{64}|empty)$/;
|
|
69
|
+
const HEX_PREFIX_PATTERN = /^(sha256:)?[0-9a-f]{6,}$/;
|
|
70
|
+
|
|
71
|
+
export function isFullHash(input: string): boolean {
|
|
72
|
+
return FULL_HASH_PATTERN.test(input);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isHexPrefix(input: string): boolean {
|
|
76
|
+
return HEX_PREFIX_PATTERN.test(input);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function normalizeHashPrefix(input: string): string {
|
|
80
|
+
return input.startsWith('sha256:') ? input : `sha256:${input}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function findEdgeByDirName(
|
|
84
|
+
graph: MigrationGraph,
|
|
85
|
+
dirName: string,
|
|
86
|
+
): MigrationEdge | undefined {
|
|
87
|
+
for (const edges of graph.forwardChain.values()) {
|
|
88
|
+
for (const edge of edges) {
|
|
89
|
+
if (edge.dirName === dirName) return edge;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|