Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

**Accepted** — closes the dev → ship transition trap tracked in [TML-2629](https://linear.app/prisma-company/issue/TML-2629/dev-ship-transition-broken-first-migration-plan-after-db-update).

The **paired contract snapshot** part of this decision (a ref carrying its own `<name>.contract.json` / `<name>.contract.d.ts` copy) was folded into the shared content-addressed store introduced by [ADR 240](ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md) (TML-3072). A ref is now only its pointer file (`{hash, invariants}`); the contract bytes it names resolve through `migrations/snapshots/<hex>/contract.{json,d.ts}` by that hash, the same store every graph node resolves through. The **universal graph-node invariant** and **asymmetric ref-advancement** decisions below are unaffected and remain current.

## Context

Prisma Next's migration workflow is deliberately Git-shaped: named **refs** point at contract hashes, the on-disk **migration graph** records committed edges between hashes, and the live database **marker** records which hash the database was last brought up to. In healthy operation those three views agree. In the dev-shaped loop — iterate locally with `db init` / `db update`, then publish with `migration plan` and `migrate` — they can drift apart with no precise diagnostic.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ One planner caller never renders a `migration.ts` at all: the `db init` / `db up

This store is a distinct concept from the ref-paired snapshot [ADR 218](ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) introduced: `refs/<name>.contract.json` / `.contract.d.ts` are mutable working state paired to a live ref (`db`, `production`, …), rewritten whenever the ref advances. The content-addressed store this ADR describes holds immutable migration-chain history, written once per distinct contract and never rewritten. Both are called "snapshot" today — a boundary worth naming precisely because it is not meant to be permanent.

The two are folding into one. Ref-paired snapshots are the immediate next piece of work: `refs/<name>.json` becomes a pure `{ hash, invariants }` pointer, and the ref's contract resolves through this same store instead of through paired sibling files. Every ref-advance path already resolves the contract bytes it would pair with the ref, so writing them into the store instead changes no call site's inputs — only where the bytes land. Once that lands, `migrations/` holds exactly one snapshot concept: the content-addressed store, with every consumer — a migration bookend, a ref, an extension head — reaching it by hash.
The two have folded into one, in this same release (TML-3072): `refs/<name>.json` is now a pure `{ hash, invariants }` pointer, and a ref's contract resolves through this same store instead of through paired sibling files. Every ref-advance path already resolved the contract bytes it would pair with the ref, so writing them into the store instead changed no call site's inputs — only where the bytes land. `migrations/` now holds exactly one snapshot concept: the content-addressed store, with every consumer — a migration bookend, a ref, an extension head — reaching it by hash.

## Alternatives considered

Expand Down
46 changes: 19 additions & 27 deletions docs/architecture docs/subsystems/7. Migration System.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ The directed graph of contracts (nodes) connected by migrations (edges). Built b

### Ref (Contract Ref)

A named pointer at a contract, stored as `migrations/<space>/refs/<name>.json`, optionally with a paired contract snapshot (`<name>.contract.json`). Refs are environment-named (`production`, `staging`) and describe *where CD will `migrate --to` next* in that environment. The default `db` ref records dev-database checkpoint state for offline planning. Managed via `prisma-next ref set|list|delete`. See [ADR 218 — Refs with paired contract snapshots](architecture%20docs/adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md).
A named pointer at a contract, stored as `migrations/<space>/refs/<name>.json` (`{hash, invariants}`); the contract it names resolves through the shared content-addressed store at `migrations/snapshots/<hex>/contract.json` by that hash. Refs are environment-named (`production`, `staging`) and describe *where CD will `migrate --to` next* in that environment. The default `db` ref records dev-database checkpoint state for offline planning. Managed via `prisma-next ref set|list|delete`. See [ADR 218 — Refs with paired contract snapshots](architecture%20docs/adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (paired-snapshot part superseded — see its Status note) and [ADR 240 — Contract snapshots live in a content-addressed store](architecture%20docs/adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md).

A ref is a specific kind of [contract reference](#contract-reference) — the named, file-backed, persistent kind.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ async function executeDbInitCommand(
...ifDefined('advanceRef', options.advanceRef),
...ifDefined('db', options.db),
refsDir,
migrationsDir,
contractIR,
mode: result.value.mode,
hash: advancementHash,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ async function executeDbUpdateCommand(
...ifDefined('advanceRef', options.advanceRef),
...ifDefined('db', options.db),
refsDir,
migrationsDir,
contractIR,
mode: result.value.mode,
hash: advancementHash,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@ async function executeMigrateCommand(
: await readContractIR(snapshotContractJson, contractPathAbsolute);
advancedRef = await executeRefAdvancement(
refsDir,
migrationsDir,
options.advanceRef,
value.markerHash,
contractIR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ async function executeMigrationPlanCommand(
fromHash = resolutionResult.value.fromHash;
fromContract = resolutionResult.value.fromContract;
break;
case 'snapshot':
case 'ref':
fromHash = resolutionResult.value.fromHash;
fromContract = resolutionResult.value.fromContract;
snapshotStartContract = {
Expand Down
16 changes: 5 additions & 11 deletions packages/1-framework/3-tooling/cli/src/commands/ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import { findLatestMigration, isGraphNode } from '@prisma-next/migration-tools/m
import { parseContractRef } from '@prisma-next/migration-tools/ref-resolution';
import type { RefEntry } from '@prisma-next/migration-tools/refs';
import {
deleteRefPaired,
deleteRef,
readRefs,
validateRefName,
validateRefValue,
writeRefPaired,
writeRef,
} from '@prisma-next/migration-tools/refs';
import { notOk, ok, type Result } from '@prisma-next/utils/result';
import { Command } from 'commander';
Expand All @@ -37,7 +37,6 @@ import {
import { buildReadAggregate } from '../utils/contract-space-aggregate-loader';
import { formatCommandHelp } from '../utils/formatters/help';
import { parseGlobalFlags, parseGlobalFlagsOrExit } from '../utils/global-flags';
import { readContractIR } from '../utils/ref-advancement';
import { handleResult } from '../utils/result-handler';
import { createTerminalUI } from '../utils/terminal-ui';

Expand Down Expand Up @@ -121,12 +120,8 @@ export async function executeRefSetCommand(
contractSnapshotDir(migrationsDir, resolvedHash),
'contract.json',
);
let contractJson: Record<string, unknown>;
try {
contractJson = (await readContractSnapshotJson(migrationsDir, resolvedHash)) as Record<
string,
unknown
>;
await readContractSnapshotJson(migrationsDir, resolvedHash);
} catch (readError) {
if (
MigrationToolsError.is(readError) &&
Expand All @@ -142,9 +137,8 @@ export async function executeRefSetCommand(
throw readError;
}

const contractIR = await readContractIR(contractJson, contractJsonPath);
const entry: RefEntry = { hash: resolvedHash, invariants: [] };
await writeRefPaired(refsDir, name, entry, contractIR);
await writeRef(refsDir, name, entry);
return ok({ ok: true as const, ref: name, hash: resolvedHash, invariants: [] });
} catch (error) {
if (error instanceof CliStructuredError) return notOk(error);
Expand All @@ -159,7 +153,7 @@ export async function executeRefDeleteCommand(
try {
const config = await loadConfig(options.config);
const { refsDir } = resolveMigrationPaths(options.config, config);
await deleteRefPaired(refsDir, name);
await deleteRef(refsDir, name);
return ok({ ok: true as const, ref: name, deleted: true as const });
} catch (error) {
if (error instanceof CliStructuredError) return notOk(error);
Expand Down
18 changes: 13 additions & 5 deletions packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,22 +215,30 @@ export function errorPlanForgotTheFlag(
});
}

/**
* `viaRef: true` (the default) mirrors migration-tools' `errorRefNotResolvable`:
* a ref name with no pointer file, where the fallback hash isn't a graph
* node either — there's nothing to materialize a contract from.
* `viaRef: false` is a distinct, ref-independent case: an explicit `--from
* <hash>` that doesn't name a ref, on an empty migration graph, so there is
* no graph node and no ref to resolve a contract through.
*/
export function errorSnapshotMissing(
identifier: string,
options?: { readonly viaRef?: boolean },
): CliStructuredError {
const viaRef = options?.viaRef !== false;
const fix = viaRef
? `Run "prisma-next db update --advance-ref ${identifier}" to repopulate the snapshot, or "prisma-next ref delete ${identifier}" to clear the orphan pointer.`
: `No contract source exists for hash "${identifier}" on an empty migration graph. Use --from with a ref name that has a paired snapshot, or run db update first.`;
? `Create the ref with "prisma-next ref set ${identifier} <hash>" (or advance it via "prisma-next db update --advance-ref ${identifier}"), or pass a hash that is a node in the migration graph.`
: `No contract source exists for hash "${identifier}" on an empty migration graph. Use --from with a ref name (its contract resolves through the snapshot store), or run db update first.`;
return errorRuntime(
viaRef
? `Ref "${identifier}" has no paired contract snapshot`
? `Ref "${identifier}" is not resolvable`
: `No contract source for from-hash "${identifier}"`,
{
why: viaRef
? `Ref "${identifier}" exists but its paired snapshot files are missing.`
: `Hash "${identifier}" is not a graph node and no paired ref snapshot supplies a contract.`,
? `Ref "${identifier}" has no pointer file, and the hash being resolved is not a node in the migration graph either.`
: `Hash "${identifier}" is not a node in the migration graph (the graph is empty), and it does not name a ref either.`,
fix,
meta: {
code: 'MIGRATION.SNAPSHOT_MISSING',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function mapContractAtError(
): Result<never, CliStructuredError> {
if (MigrationToolsError.is(error)) {
switch (error.code) {
case 'MIGRATION.SNAPSHOT_MISSING': {
case 'MIGRATION.REF_NOT_RESOLVABLE': {
const refName =
typeof error.details?.['refName'] === 'string'
? error.details['refName']
Expand All @@ -26,18 +26,13 @@ export function mapContractAtError(
}
case 'MIGRATION.CONTRACT_DESERIALIZATION_FAILED': {
const filePath =
typeof error.details?.['filePath'] === 'string'
? error.details['filePath']
: 'ref-snapshot';
typeof error.details?.['filePath'] === 'string' ? error.details['filePath'] : 'unknown';
const message =
typeof error.details?.['message'] === 'string' ? error.details['message'] : error.message;
const isRefSnapshot = filePath.endsWith('.contract.json');
return notOk(
errorContractValidationFailed(
isRefSnapshot
? `Ref snapshot contract failed to deserialize: ${message}`
: `Predecessor contract at ${filePath} failed to deserialize: ${message}`,
{ where: { path: isRefSnapshot ? 'ref-snapshot' : filePath } },
`Predecessor contract at ${filePath} failed to deserialize: ${message}`,
{ where: { path: filePath } },
),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type FromResolution =
| { kind: 'greenfield'; fromHash: null; fromContract: null }
| { kind: 'graph-node'; fromHash: string; fromContract: Contract }
| {
kind: 'snapshot';
kind: 'ref';
fromHash: string;
fromContract: Contract;
contractDts: string;
Expand Down Expand Up @@ -81,7 +81,7 @@ export function assertFromIsGraphNode(

type RefContractResolution =
| {
kind: 'snapshot';
kind: 'ref';
hash: string;
contract: Contract;
contractJson: unknown;
Expand All @@ -106,9 +106,9 @@ async function resolveContractRef(
try {
const at = await space.contractAt(hash, refName !== undefined ? { refName } : undefined);

if (at.provenance === 'snapshot') {
if (at.provenance === 'ref') {
return ok({
kind: 'snapshot',
kind: 'ref',
hash: at.hash,
contract: at.contract,
contractJson: at.contractJson,
Expand Down Expand Up @@ -175,7 +175,7 @@ async function resolveFromPolicy(
throw error;
}
return ok({
kind: 'snapshot',
kind: 'ref',
fromHash: hash,
fromContract: contract,
contractDts,
Expand Down
25 changes: 22 additions & 3 deletions packages/1-framework/3-tooling/cli/src/utils/ref-advancement.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { readFile } from 'node:fs/promises';
import type { ContractIR } from '@prisma-next/migration-tools/refs';
import { writeRefPaired } from '@prisma-next/migration-tools/refs';
import { writeContractSnapshot } from '@prisma-next/migration-tools/contract-snapshot-store';
import { errorInvalidRefName } from '@prisma-next/migration-tools/errors';
import { validateRefName, writeRef } from '@prisma-next/migration-tools/refs';
import { ifDefined } from '@prisma-next/utils/defined';

export interface ContractIR {
readonly contract: unknown;
readonly contractDts: string;
}

export interface RefAdvancementFields {
readonly advancedRef: { readonly name: string; readonly hash: string } | null;
readonly plannedAdvanceRef: { readonly name: string; readonly hash: string } | null;
Expand Down Expand Up @@ -32,18 +38,30 @@ export async function readContractIR(

export async function executeRefAdvancement(
refsDir: string,
migrationsDir: string,
name: string,
hash: string,
contractIR: ContractIR,
): Promise<{ name: string; hash: string }> {
await writeRefPaired(refsDir, name, { hash, invariants: [] }, contractIR);
// Validate the ref name before writing anything: writeRef validates it too,
// but only after the store write below, which would otherwise leave a
// (harmless, but pointless) orphan store entry on an invalid name.
if (!validateRefName(name)) {
throw errorInvalidRefName(name);
}
await writeContractSnapshot(migrationsDir, hash, {
contractJson: contractIR.contract,
contractDts: contractIR.contractDts,
});
await writeRef(refsDir, name, { hash, invariants: [] });
return { name, hash };
}

export async function buildRefAdvancementFields(options: {
readonly advanceRef?: string;
readonly db?: string;
readonly refsDir: string;
readonly migrationsDir: string;
readonly contractIR: ContractIR;
readonly mode: 'plan' | 'apply';
readonly hash: string;
Expand All @@ -60,6 +78,7 @@ export async function buildRefAdvancementFields(options: {
}
const advancedRef = await executeRefAdvancement(
options.refsDir,
options.migrationsDir,
name,
options.hash,
options.contractIR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ const mocks = vi.hoisted(() => ({
mkdir: vi.fn(),
writeFile: vi.fn(),
readRefs: vi.fn(),
readRefSnapshot: vi.fn(),
writeMigrationPackage: vi.fn(),
writeContractSnapshot: vi.fn(),
writeMigrationTs: vi.fn(),
Expand Down Expand Up @@ -67,7 +66,7 @@ vi.mock('@prisma-next/migration-tools/refs', async () => {
const actual = await vi.importActual<typeof import('@prisma-next/migration-tools/refs')>(
'@prisma-next/migration-tools/refs',
);
return { ...actual, readRefs: mocks.readRefs, readRefSnapshot: mocks.readRefSnapshot };
return { ...actual, readRefs: mocks.readRefs };
});

vi.mock('@prisma-next/migration-tools/io', async () => {
Expand Down Expand Up @@ -150,7 +149,7 @@ function buildResolutionSpace(
contract: snap.contract as Contract,
contractJson: snap.contract,
contractDts: snap.contractDts,
provenance: 'snapshot',
provenance: 'ref',
};
}

Expand Down Expand Up @@ -678,7 +677,7 @@ describe('migration plan command', () => {
expect(result).not.toHaveProperty('migrationHash');

// With no --from flag, resolution goes through the named 'db' ref
// (snapshot provenance), which carries its own contract snapshot —
// (ref provenance), which carries its own contract snapshot —
// migration plan writes both the destination and that start snapshot.
expect(mocks.writeContractSnapshot).toHaveBeenCalledTimes(2);
expect(mocks.writeContractSnapshot).toHaveBeenCalledWith(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { createTerminalUI } from '../../src/utils/terminal-ui';
import { executeCommand, setupCommandMocks } from '../utils/test-helpers';

const mocks = vi.hoisted(() => ({
writeRefPaired: vi.fn(),
writeRef: vi.fn(),
readMarker: vi.fn(),
readLedger: vi.fn(),
connect: vi.fn(),
Expand All @@ -38,7 +38,7 @@ vi.mock('@prisma-next/config-loader', { spy: true });

vi.mock('@prisma-next/migration-tools/refs', async (importOriginal) => {
const actual = await importOriginal<typeof import('@prisma-next/migration-tools/refs')>();
return { ...actual, writeRefPaired: mocks.writeRefPaired };
return { ...actual, writeRef: mocks.writeRef };
});

vi.mock('../../src/control-api/client', () => ({
Expand Down Expand Up @@ -189,7 +189,7 @@ describe('read commands --json golden', () => {
vi.clearAllMocks();
mocks.connect.mockResolvedValue(undefined);
mocks.close.mockResolvedValue(undefined);
mocks.writeRefPaired.mockResolvedValue(undefined);
mocks.writeRef.mockResolvedValue(undefined);
mocks.schemaVerify.mockResolvedValue({ ok: true, summary: 'Schema matches contract' });
mocks.sign.mockResolvedValue({
ok: true,
Expand Down
Loading
Loading