@prisma-next/migration-tools 0.5.0-dev.67 → 0.5.0-dev.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{errors-5KVuWV_5.mjs → errors-EPL_9p9f.mjs} +12 -6
- package/dist/errors-EPL_9p9f.mjs.map +1 -0
- package/dist/exports/aggregate.d.mts +534 -0
- package/dist/exports/aggregate.d.mts.map +1 -0
- package/dist/exports/aggregate.mjs +598 -0
- package/dist/exports/aggregate.mjs.map +1 -0
- package/dist/exports/errors.d.mts +6 -1
- package/dist/exports/errors.d.mts.map +1 -1
- package/dist/exports/errors.mjs +2 -2
- package/dist/exports/graph.d.mts +1 -1
- package/dist/exports/hash.d.mts +1 -1
- package/dist/exports/invariants.d.mts +13 -2
- package/dist/exports/invariants.d.mts.map +1 -1
- package/dist/exports/invariants.mjs +1 -1
- package/dist/exports/io.d.mts +25 -1
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +2 -2
- package/dist/exports/metadata.d.mts +1 -1
- package/dist/exports/migration-graph.d.mts +1 -1
- package/dist/exports/migration-graph.mjs +1 -522
- package/dist/exports/migration.d.mts +1 -1
- package/dist/exports/migration.mjs +2 -2
- package/dist/exports/refs.mjs +1 -1
- package/dist/exports/spaces.d.mts +341 -237
- package/dist/exports/spaces.d.mts.map +1 -1
- package/dist/exports/spaces.mjs +137 -339
- package/dist/exports/spaces.mjs.map +1 -1
- package/dist/{graph-4dIUm90i.d.mts → graph-HMWAldoR.d.mts} +1 -1
- package/dist/{graph-4dIUm90i.d.mts.map → graph-HMWAldoR.d.mts.map} +1 -1
- package/dist/{invariants-CkLSBcMu.mjs → invariants-Duc8f9NM.mjs} +16 -5
- package/dist/invariants-Duc8f9NM.mjs.map +1 -0
- package/dist/{io-TX8RPDeh.mjs → io-D13dLvUh.mjs} +38 -4
- package/dist/io-D13dLvUh.mjs.map +1 -0
- package/dist/migration-graph-DGNnKDY5.mjs +523 -0
- package/dist/{exports/migration-graph.mjs.map → migration-graph-DGNnKDY5.mjs.map} +1 -1
- package/dist/read-contract-space-contract-C3-1eyaI.mjs +298 -0
- package/dist/read-contract-space-contract-C3-1eyaI.mjs.map +1 -0
- package/package.json +10 -6
- package/src/aggregate/loader.ts +409 -0
- package/src/aggregate/marker-types.ts +16 -0
- package/src/aggregate/planner-types.ts +137 -0
- package/src/aggregate/planner.ts +158 -0
- package/src/aggregate/project-schema-to-space.ts +64 -0
- package/src/aggregate/strategies/graph-walk.ts +92 -0
- package/src/aggregate/strategies/synth.ts +122 -0
- package/src/aggregate/types.ts +89 -0
- package/src/aggregate/verifier.ts +230 -0
- package/src/assert-descriptor-self-consistency.ts +70 -0
- package/src/compute-extension-space-apply-path.ts +152 -0
- package/src/concatenate-space-apply-inputs.ts +2 -2
- package/src/detect-space-contract-drift.ts +22 -26
- package/src/{emit-pinned-space-artefacts.ts → emit-contract-space-artefacts.ts} +14 -33
- package/src/errors.ts +11 -5
- package/src/exports/aggregate.ts +37 -0
- package/src/exports/errors.ts +1 -0
- package/src/exports/io.ts +1 -0
- package/src/exports/spaces.ts +23 -10
- package/src/gather-disk-contract-space-state.ts +62 -0
- package/src/invariants.ts +14 -3
- package/src/io.ts +42 -0
- package/src/plan-all-spaces.ts +3 -7
- package/src/read-contract-space-contract.ts +44 -0
- package/src/read-contract-space-head-ref.ts +63 -0
- package/src/space-layout.ts +4 -11
- package/src/verify-contract-spaces.ts +45 -49
- package/dist/errors-5KVuWV_5.mjs.map +0 -1
- package/dist/invariants-CkLSBcMu.mjs.map +0 -1
- package/dist/io-TX8RPDeh.mjs.map +0 -1
- package/src/read-pinned-contract-hash.ts +0 -77
- /package/dist/{metadata-th_MvOTT.d.mts → metadata-BnLFiI6B.d.mts} +0 -0
package/src/errors.ts
CHANGED
|
@@ -160,13 +160,19 @@ export function errorInvalidSpaceId(spaceId: string): MigrationToolsError {
|
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
export function
|
|
163
|
+
export function errorDescriptorHeadHashMismatch(args: {
|
|
164
|
+
readonly extensionId: string;
|
|
165
|
+
readonly recomputedHash: string;
|
|
166
|
+
readonly headRefHash: string;
|
|
167
|
+
}): MigrationToolsError {
|
|
168
|
+
const { extensionId, recomputedHash, headRefHash } = args;
|
|
164
169
|
return new MigrationToolsError(
|
|
165
|
-
'MIGRATION.
|
|
166
|
-
'
|
|
170
|
+
'MIGRATION.DESCRIPTOR_HEAD_HASH_MISMATCH',
|
|
171
|
+
"Extension descriptor's headRef.hash does not match its contractJson",
|
|
167
172
|
{
|
|
168
|
-
why:
|
|
169
|
-
fix: '
|
|
173
|
+
why: `Extension "${extensionId}" publishes a \`contractSpace\` whose \`headRef.hash\` (${headRefHash}) does not match the canonical hash recomputed from \`contractSpace.contractJson\` (${recomputedHash}). This means the extension descriptor was published with stale \`headRef.hash\` — typically because the contract was bumped without rerunning the extension's emit pipeline.`,
|
|
174
|
+
fix: 'Re-run the extension authoring pipeline so `contractJson.storage.storageHash` and `headRef.hash` agree, then republish the extension. If you are the extension author and you intentionally bumped `contractJson`, recompute and update `headRef.hash` (and refresh any on-disk migration metadata that derives from it).',
|
|
175
|
+
details: { extensionId, recomputedHash, headRefHash },
|
|
170
176
|
},
|
|
171
177
|
);
|
|
172
178
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type AggregateContractHasher,
|
|
3
|
+
type DeclaredExtensionEntry,
|
|
4
|
+
type LayoutViolation,
|
|
5
|
+
type LoadAggregateError,
|
|
6
|
+
type LoadAggregateInput,
|
|
7
|
+
type LoadAggregateOutput,
|
|
8
|
+
loadContractSpaceAggregate,
|
|
9
|
+
} from '../aggregate/loader';
|
|
10
|
+
export type { ContractMarkerRecordLike } from '../aggregate/marker-types';
|
|
11
|
+
export {
|
|
12
|
+
type AggregateCurrentDBState,
|
|
13
|
+
type AggregatePerSpacePlan,
|
|
14
|
+
type AggregatePlannerError,
|
|
15
|
+
type AggregatePlannerInput,
|
|
16
|
+
type AggregatePlannerOutput,
|
|
17
|
+
type AggregatePlannerSuccess,
|
|
18
|
+
type CallerPolicy,
|
|
19
|
+
planAggregate,
|
|
20
|
+
} from '../aggregate/planner';
|
|
21
|
+
export { projectSchemaToSpace } from '../aggregate/project-schema-to-space';
|
|
22
|
+
export type {
|
|
23
|
+
ContractSpaceAggregate,
|
|
24
|
+
ContractSpaceMember,
|
|
25
|
+
HydratedMigrationGraph,
|
|
26
|
+
} from '../aggregate/types';
|
|
27
|
+
export {
|
|
28
|
+
type AggregateVerifierError,
|
|
29
|
+
type AggregateVerifierInput,
|
|
30
|
+
type AggregateVerifierOutput,
|
|
31
|
+
type AggregateVerifierSuccess,
|
|
32
|
+
type MarkerCheckResult,
|
|
33
|
+
type MarkerCheckSection,
|
|
34
|
+
type OrphanElement,
|
|
35
|
+
type SchemaCheckSection,
|
|
36
|
+
verifyAggregate,
|
|
37
|
+
} from '../aggregate/verifier';
|
package/src/exports/errors.ts
CHANGED
package/src/exports/io.ts
CHANGED
package/src/exports/spaces.ts
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
export {
|
|
2
|
-
|
|
3
|
-
type
|
|
4
|
-
} from '../
|
|
2
|
+
assertDescriptorSelfConsistency,
|
|
3
|
+
type DescriptorSelfConsistencyInputs,
|
|
4
|
+
} from '../assert-descriptor-self-consistency';
|
|
5
|
+
export {
|
|
6
|
+
type ComputeExtensionSpaceApplyPathInputs,
|
|
7
|
+
computeExtensionSpaceApplyPath,
|
|
8
|
+
type ExtensionSpaceApplyPathOutcome,
|
|
9
|
+
} from '../compute-extension-space-apply-path';
|
|
10
|
+
export type { SpaceApplyInput } from '../concatenate-space-apply-inputs';
|
|
5
11
|
export {
|
|
6
12
|
type DetectSpaceContractDriftInputs,
|
|
7
13
|
detectSpaceContractDrift,
|
|
8
14
|
type SpaceContractDriftResult,
|
|
9
15
|
} from '../detect-space-contract-drift';
|
|
10
16
|
export {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
type ContractSpaceArtefactInputs,
|
|
18
|
+
emitContractSpaceArtefacts,
|
|
19
|
+
} from '../emit-contract-space-artefacts';
|
|
20
|
+
export {
|
|
21
|
+
type DiskContractSpaceState,
|
|
22
|
+
gatherDiskContractSpaceState,
|
|
23
|
+
} from '../gather-disk-contract-space-state';
|
|
15
24
|
export {
|
|
16
25
|
planAllSpaces,
|
|
17
26
|
type SpacePlanInput,
|
|
18
27
|
type SpacePlanOutput,
|
|
19
28
|
} from '../plan-all-spaces';
|
|
20
|
-
export {
|
|
29
|
+
export { readContractSpaceContract } from '../read-contract-space-contract';
|
|
30
|
+
export {
|
|
31
|
+
type ContractSpaceHeadRef,
|
|
32
|
+
readContractSpaceHeadRef,
|
|
33
|
+
} from '../read-contract-space-head-ref';
|
|
21
34
|
export {
|
|
22
35
|
APP_SPACE_ID,
|
|
23
36
|
assertValidSpaceId,
|
|
@@ -26,9 +39,9 @@ export {
|
|
|
26
39
|
type ValidSpaceId,
|
|
27
40
|
} from '../space-layout';
|
|
28
41
|
export {
|
|
29
|
-
|
|
42
|
+
type ContractSpaceHeadRecord,
|
|
43
|
+
listContractSpaceDirectories,
|
|
30
44
|
type SpaceMarkerRecord,
|
|
31
|
-
type SpacePinnedHashRecord,
|
|
32
45
|
type SpaceVerifierViolation,
|
|
33
46
|
type VerifyContractSpacesInputs,
|
|
34
47
|
type VerifyContractSpacesResult,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readContractSpaceHeadRef } from './read-contract-space-head-ref';
|
|
2
|
+
import { APP_SPACE_ID } from './space-layout';
|
|
3
|
+
import {
|
|
4
|
+
type ContractSpaceHeadRecord,
|
|
5
|
+
listContractSpaceDirectories,
|
|
6
|
+
} from './verify-contract-spaces';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Disk-side inputs to {@link import('./verify-contract-spaces').verifyContractSpaces}
|
|
10
|
+
* — gathered without touching the live database. The caller composes
|
|
11
|
+
* this with the marker rows it reads from the runtime to invoke the
|
|
12
|
+
* verifier.
|
|
13
|
+
*/
|
|
14
|
+
export interface DiskContractSpaceState {
|
|
15
|
+
/** Contract-space directory names observed under `<projectMigrationsDir>/`. */
|
|
16
|
+
readonly spaceDirsOnDisk: readonly string[];
|
|
17
|
+
/** Head-ref `(hash, invariants)` per extension space. */
|
|
18
|
+
readonly headRefsBySpace: ReadonlyMap<string, ContractSpaceHeadRecord>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read the on-disk state the per-space verifier needs:
|
|
23
|
+
*
|
|
24
|
+
* - The list of contract-space directories under
|
|
25
|
+
* `<projectMigrationsDir>/` (via
|
|
26
|
+
* {@link import('./verify-contract-spaces').listContractSpaceDirectories}).
|
|
27
|
+
* - The on-disk head ref `(hash, invariants)` for each declared extension space
|
|
28
|
+
* (via {@link readContractSpaceHeadRef}; missing on-disk artefacts are simply
|
|
29
|
+
* omitted — the verifier reports them as `declaredButUnmigrated`).
|
|
30
|
+
*
|
|
31
|
+
* Synchronous in spirit but async due to filesystem reads. Reads only
|
|
32
|
+
* the user's repo. **Does not import any extension descriptor module.**
|
|
33
|
+
*
|
|
34
|
+
* Composition convention: pure target-agnostic primitive in
|
|
35
|
+
* `1-framework`; the SQL family (and any future target family) wires
|
|
36
|
+
* it into its `dbInit` / `verify` flows alongside its own marker-row
|
|
37
|
+
* read before invoking `verifyContractSpaces`.
|
|
38
|
+
*/
|
|
39
|
+
export async function gatherDiskContractSpaceState(args: {
|
|
40
|
+
readonly projectMigrationsDir: string;
|
|
41
|
+
/**
|
|
42
|
+
* Set of space ids the project declares: `'app'` plus each entry in
|
|
43
|
+
* `extensionPacks` whose descriptor exposes a `contractSpace`. The
|
|
44
|
+
* helper reads on-disk head data only for the extension members.
|
|
45
|
+
*/
|
|
46
|
+
readonly loadedSpaceIds: ReadonlySet<string>;
|
|
47
|
+
}): Promise<DiskContractSpaceState> {
|
|
48
|
+
const { projectMigrationsDir, loadedSpaceIds } = args;
|
|
49
|
+
|
|
50
|
+
const spaceDirsOnDisk = await listContractSpaceDirectories(projectMigrationsDir);
|
|
51
|
+
|
|
52
|
+
const headRefsBySpace = new Map<string, ContractSpaceHeadRecord>();
|
|
53
|
+
for (const spaceId of loadedSpaceIds) {
|
|
54
|
+
if (spaceId === APP_SPACE_ID) continue;
|
|
55
|
+
const head = await readContractSpaceHeadRef(projectMigrationsDir, spaceId);
|
|
56
|
+
if (head !== null) {
|
|
57
|
+
headRefsBySpace.set(spaceId, head);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { spaceDirsOnDisk, headRefsBySpace };
|
|
62
|
+
}
|
package/src/invariants.ts
CHANGED
|
@@ -16,8 +16,19 @@ export function validateInvariantId(invariantId: string): boolean {
|
|
|
16
16
|
/**
|
|
17
17
|
* Walk a migration's operations and produce its `providedInvariants`
|
|
18
18
|
* aggregate: the sorted, deduplicated list of `invariantId`s declared
|
|
19
|
-
* by
|
|
20
|
-
*
|
|
19
|
+
* by ops in the migration. Ops without an `invariantId` are skipped.
|
|
20
|
+
*
|
|
21
|
+
* Both `data`-class ops (data-transforms, e.g. backfills) and
|
|
22
|
+
* `additive`-class opaque DDL (e.g. cipherstash's vendored EQL bundle
|
|
23
|
+
* via `installEqlBundleOp`) may declare invariantIds: the
|
|
24
|
+
* `operationClass` axis classifies *policy gating* (which kinds of ops
|
|
25
|
+
* a `db init` / `db update` policy permits), while `invariantId`
|
|
26
|
+
* classifies *marker bookkeeping* (which named bundles of work a
|
|
27
|
+
* future regeneration knows to skip). The two concerns are
|
|
28
|
+
* intentionally orthogonal — an extension can ship additive
|
|
29
|
+
* non-IR-derivable DDL (the only way the planner can know the bundle
|
|
30
|
+
* is already applied is via the invariantId on the marker) without
|
|
31
|
+
* needing to mis-classify it as `data`-class.
|
|
21
32
|
*
|
|
22
33
|
* Throws `MIGRATION.INVALID_INVARIANT_ID` on a malformed id and
|
|
23
34
|
* `MIGRATION.DUPLICATE_INVARIANT_IN_EDGE` on duplicates.
|
|
@@ -39,7 +50,7 @@ export function deriveProvidedInvariants(ops: MigrationOps): readonly string[] {
|
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
function readInvariantId(op: MigrationPlanOperation): string | undefined {
|
|
42
|
-
if (op
|
|
53
|
+
if (!Object.hasOwn(op, 'invariantId')) return undefined;
|
|
43
54
|
const candidate = (op as { invariantId?: unknown }).invariantId;
|
|
44
55
|
return typeof candidate === 'string' ? candidate : undefined;
|
|
45
56
|
}
|
package/src/io.ts
CHANGED
|
@@ -124,6 +124,48 @@ export async function materialiseMigrationPackage(
|
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Idempotent variant of {@link materialiseMigrationPackage}: writes the
|
|
129
|
+
* package only if `<targetDir>/<pkg.dirName>/` does not already exist on
|
|
130
|
+
* disk as a directory; returns `{ written: false }` when the package
|
|
131
|
+
* directory is present (no rewrite, no comparison — by-existence skip).
|
|
132
|
+
*
|
|
133
|
+
* Concretely:
|
|
134
|
+
* - existing directory → skip silently, return `{ written: false }`.
|
|
135
|
+
* - missing path → write three files via {@link materialiseMigrationPackage},
|
|
136
|
+
* return `{ written: true }`.
|
|
137
|
+
* - path exists but is not a directory (file/symlink) → treated as
|
|
138
|
+
* missing; {@link materialiseMigrationPackage} will attempt creation
|
|
139
|
+
* and fail with an appropriate OS error.
|
|
140
|
+
* - any other I/O error from `stat` → propagated unchanged.
|
|
141
|
+
*
|
|
142
|
+
* Used by the CLI's `runContractSpaceExtensionMigrationsPass` to
|
|
143
|
+
* materialise extension migration packages into a project's
|
|
144
|
+
* `migrations/<spaceId>/` directory, and by extension-package tests
|
|
145
|
+
* that mirror the same idempotent-rematerialise property locally
|
|
146
|
+
* without taking a CLI dependency.
|
|
147
|
+
*/
|
|
148
|
+
export async function materialiseExtensionMigrationPackageIfMissing(
|
|
149
|
+
targetDir: string,
|
|
150
|
+
pkg: MigrationPackage,
|
|
151
|
+
): Promise<{ readonly written: boolean }> {
|
|
152
|
+
const pkgDir = join(targetDir, pkg.dirName);
|
|
153
|
+
if (await directoryExists(pkgDir)) {
|
|
154
|
+
return { written: false };
|
|
155
|
+
}
|
|
156
|
+
await materialiseMigrationPackage(targetDir, pkg);
|
|
157
|
+
return { written: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function directoryExists(p: string): Promise<boolean> {
|
|
161
|
+
try {
|
|
162
|
+
return (await stat(p)).isDirectory();
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (hasErrnoCode(error, 'ENOENT')) return false;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
127
169
|
/**
|
|
128
170
|
* Copy a list of files into `destDir`, optionally renaming each one.
|
|
129
171
|
*
|
package/src/plan-all-spaces.ts
CHANGED
|
@@ -7,13 +7,11 @@ import { errorDuplicateSpaceId } from './errors';
|
|
|
7
7
|
*
|
|
8
8
|
* - `priorContract` is `null` for a space that has never been emitted
|
|
9
9
|
* (no `migrations/<space-id>/contract.json` on disk yet); otherwise it
|
|
10
|
-
* is the canonical contract value
|
|
10
|
+
* is the canonical contract value emitted for that space.
|
|
11
11
|
* - `newContract` is the canonical contract value the planner is about
|
|
12
12
|
* to emit for that space — for app-space, the just-emitted root
|
|
13
13
|
* `contract.json`; for an extension space, the descriptor's
|
|
14
14
|
* `contractSpace.contractJson`.
|
|
15
|
-
*
|
|
16
|
-
* @see specs/framework-mechanism.spec.md § 3.
|
|
17
15
|
*/
|
|
18
16
|
export interface SpacePlanInput<TContract> {
|
|
19
17
|
readonly spaceId: string;
|
|
@@ -32,7 +30,7 @@ export interface SpacePlanOutput<TPackage> {
|
|
|
32
30
|
*
|
|
33
31
|
* Behaviour:
|
|
34
32
|
*
|
|
35
|
-
* - The output is sorted alphabetically by `spaceId
|
|
33
|
+
* - The output is sorted alphabetically by `spaceId`. Two callers
|
|
36
34
|
* passing the same set of inputs in different orders observe
|
|
37
35
|
* byte-identical outputs.
|
|
38
36
|
* - The per-space planner (`planSpace`) is called exactly once per
|
|
@@ -49,11 +47,9 @@ export interface SpacePlanOutput<TPackage> {
|
|
|
49
47
|
*
|
|
50
48
|
* Synchronous: the underlying per-space planner (target's
|
|
51
49
|
* `MigrationPlanner.plan(...)`) is synchronous; callers that need to
|
|
52
|
-
* resolve async I/O (e.g. reading
|
|
50
|
+
* resolve async I/O (e.g. reading on-disk `contract.json` from disk)
|
|
53
51
|
* resolve it before calling `planAllSpaces` and pass the materialised
|
|
54
52
|
* inputs through.
|
|
55
|
-
*
|
|
56
|
-
* @see specs/framework-mechanism.spec.md § 3 — Per-space planner (T1.3).
|
|
57
53
|
*/
|
|
58
54
|
export function planAllSpaces<TContract, TPackage>(
|
|
59
55
|
inputs: readonly SpacePlanInput<TContract>[],
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'pathe';
|
|
3
|
+
import { errorInvalidJson, errorMissingFile } from './errors';
|
|
4
|
+
import { assertValidSpaceId } from './space-layout';
|
|
5
|
+
|
|
6
|
+
function hasErrnoCode(error: unknown, code: string): boolean {
|
|
7
|
+
return error instanceof Error && (error as { code?: string }).code === code;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read the on-disk contract value for a contract space
|
|
12
|
+
* (`<projectMigrationsDir>/<spaceId>/contract.json`). Returns the parsed
|
|
13
|
+
* JSON value as `unknown` — callers that need a typed contract validate
|
|
14
|
+
* via their family's `validateContract` to surface schema issues.
|
|
15
|
+
*
|
|
16
|
+
* Companion to {@link import('./read-contract-space-head-ref').readContractSpaceHeadRef}
|
|
17
|
+
* — same ENOENT-throws / corrupt-file-error semantics. Returns the
|
|
18
|
+
* canonical-JSON value the framework wrote during emit, so re-running
|
|
19
|
+
* this helper across machines / runs yields a byte-identical value.
|
|
20
|
+
*/
|
|
21
|
+
export async function readContractSpaceContract(
|
|
22
|
+
projectMigrationsDir: string,
|
|
23
|
+
spaceId: string,
|
|
24
|
+
): Promise<unknown> {
|
|
25
|
+
assertValidSpaceId(spaceId);
|
|
26
|
+
|
|
27
|
+
const filePath = join(projectMigrationsDir, spaceId, 'contract.json');
|
|
28
|
+
|
|
29
|
+
let raw: string;
|
|
30
|
+
try {
|
|
31
|
+
raw = await readFile(filePath, 'utf-8');
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (hasErrnoCode(error, 'ENOENT')) {
|
|
34
|
+
throw errorMissingFile('contract.json', join(projectMigrationsDir, spaceId));
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
throw errorInvalidJson(filePath, e instanceof Error ? e.message : String(e));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import type { ContractSpaceHeadRef } from '@prisma-next/framework-components/control';
|
|
3
|
+
import { join } from 'pathe';
|
|
4
|
+
import { errorInvalidJson, errorInvalidRefFile } from './errors';
|
|
5
|
+
import { assertValidSpaceId } from './space-layout';
|
|
6
|
+
|
|
7
|
+
export type { ContractSpaceHeadRef };
|
|
8
|
+
|
|
9
|
+
function hasErrnoCode(error: unknown, code: string): boolean {
|
|
10
|
+
return error instanceof Error && (error as { code?: string }).code === code;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read the head ref (`hash` + `invariants`) for a contract space from
|
|
15
|
+
* `<projectMigrationsDir>/<spaceId>/refs/head.json`.
|
|
16
|
+
*
|
|
17
|
+
* Returns `null` when the file does not exist (first emit). Surfaces
|
|
18
|
+
* `MIGRATION.INVALID_JSON` / `MIGRATION.INVALID_REF_FILE` on a corrupt
|
|
19
|
+
* `refs/head.json` so callers can distinguish "no head ref on disk"
|
|
20
|
+
* (returns `null`) from "head ref present but unreadable" (throws).
|
|
21
|
+
*
|
|
22
|
+
* Validates the space id against `[a-z][a-z0-9_-]{0,63}` for the same
|
|
23
|
+
* filesystem-safety reasons as the rest of the per-space helpers. The
|
|
24
|
+
* helper is uniform across the app and extension spaces.
|
|
25
|
+
*/
|
|
26
|
+
export async function readContractSpaceHeadRef(
|
|
27
|
+
projectMigrationsDir: string,
|
|
28
|
+
spaceId: string,
|
|
29
|
+
): Promise<ContractSpaceHeadRef | null> {
|
|
30
|
+
assertValidSpaceId(spaceId);
|
|
31
|
+
|
|
32
|
+
const filePath = join(projectMigrationsDir, spaceId, 'refs', 'head.json');
|
|
33
|
+
|
|
34
|
+
let raw: string;
|
|
35
|
+
try {
|
|
36
|
+
raw = await readFile(filePath, 'utf-8');
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (hasErrnoCode(error, 'ENOENT')) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let parsed: unknown;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(raw);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
throw errorInvalidJson(filePath, e instanceof Error ? e.message : String(e));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
52
|
+
throw errorInvalidRefFile(filePath, 'expected an object');
|
|
53
|
+
}
|
|
54
|
+
const obj = parsed as { hash?: unknown; invariants?: unknown };
|
|
55
|
+
if (typeof obj.hash !== 'string') {
|
|
56
|
+
throw errorInvalidRefFile(filePath, 'expected an object with a string `hash` field');
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(obj.invariants) || obj.invariants.some((value) => typeof value !== 'string')) {
|
|
59
|
+
throw errorInvalidRefFile(filePath, 'expected an object with an `invariants` array of strings');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { hash: obj.hash, invariants: obj.invariants as readonly string[] };
|
|
63
|
+
}
|
package/src/space-layout.ts
CHANGED
|
@@ -17,8 +17,6 @@ export type ValidSpaceId = string & { readonly __brand: 'ValidSpaceId' };
|
|
|
17
17
|
* Pattern a contract-space identifier must match. The constraint is
|
|
18
18
|
* filesystem-friendly: lowercase letters / digits / hyphen / underscore,
|
|
19
19
|
* starts with a letter, max 64 characters.
|
|
20
|
-
*
|
|
21
|
-
* @see specs/framework-mechanism.spec.md § 3.
|
|
22
20
|
*/
|
|
23
21
|
const SPACE_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
24
22
|
|
|
@@ -35,21 +33,16 @@ export function assertValidSpaceId(spaceId: string): asserts spaceId is ValidSpa
|
|
|
35
33
|
/**
|
|
36
34
|
* Resolve the migrations subdirectory for a given contract space.
|
|
37
35
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* The space id is validated against {@link SPACE_ID_PATTERN} because
|
|
43
|
-
* it becomes a filesystem directory name verbatim.
|
|
36
|
+
* Every contract space — including the app space (default `'app'`) —
|
|
37
|
+
* lands under `<projectMigrationsDir>/<spaceId>/`. The space id is
|
|
38
|
+
* validated against {@link SPACE_ID_PATTERN} because it becomes a
|
|
39
|
+
* filesystem directory name verbatim.
|
|
44
40
|
*
|
|
45
41
|
* `projectMigrationsDir` is the project's top-level `migrations/`
|
|
46
42
|
* directory; the helper does not assume anything about its absolute /
|
|
47
43
|
* relative shape and is symmetric with `pathe.join`.
|
|
48
44
|
*/
|
|
49
45
|
export function spaceMigrationDirectory(projectMigrationsDir: string, spaceId: string): string {
|
|
50
|
-
if (spaceId === APP_SPACE_ID) {
|
|
51
|
-
return projectMigrationsDir;
|
|
52
|
-
}
|
|
53
46
|
assertValidSpaceId(spaceId);
|
|
54
47
|
return join(projectMigrationsDir, spaceId);
|
|
55
48
|
}
|