Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,10 @@ A `migration check` finding, carried as an `error` diagnostic on a completed run

A `migration check` finding, carried as an `error` diagnostic on a completed run that exits `4`: a ref file in a space's `refs/` directory cannot be read or parsed. Repair or remove the corrupt ref file.

### MIGRATION.CHECK_SNAPSHOT_CONTENT_MISMATCH

A `migration check` finding, carried as an `error` diagnostic on a completed run that exits `4`: a contract snapshot's declared `storage.storageHash` agrees with the migration's `to` hash, but the snapshot's content recomputes to a different storage hash — the file under `migrations/snapshots/<hash>/` has been edited (or corrupted) since it was written. Restore `migrations/snapshots/` from version control, or re-run the command that produced the migration to regenerate its snapshot.

### MIGRATION.CHECK_SNAPSHOT_HASH_MISMATCH

A `migration check` finding, carried as an `error` diagnostic on a completed run that exits `4`: a migration declares a destination hash `to` but the contract snapshot stored for that hash has a different inner `storage.storageHash`. Re-emit the package so `migration.json` and its snapshot agree.
Expand Down Expand Up @@ -869,6 +873,10 @@ An apply carrying consent was refused because the plan recomputed for it is not

A contract JSON on disk failed to deserialize into a valid contract: either a snapshot-store entry read while migration tooling resolved a contract at a ref or hash, or the emitted `contract.json` read as the fallback source by `db sign` / `db update --to` (invalid JSON, or a value that is not a JSON object). Re-emit the owning migration package (or re-run `prisma contract emit` for the emitted contract), or restore the file from version control. Meta: `filePath`, `message`. Also raised by `migration new` when the emitted `contract.json` fails to deserialize; that site has no meta and attaches the deserialization failure as `cause`.

### MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH

A contract snapshot loaded from `migrations/snapshots/<hash>/contract.json` does not reproduce the storage hash it is addressed by: the store is content-addressed, and the file has been edited (or corrupted) since it was written. Raised at the snapshot-store load seam, so every command that resolves a contract from the store (`migration plan`, `ref set`, `db sign` / `db update --to`, aggregate contract resolution) refuses instead of treating the edited content as the recorded contract. The envelope names the file and both hashes (meta: `storageHash`, `computedHash`, `jsonPath`). Restore `migrations/snapshots/` from version control, or re-run the command that authored the referencing migration to regenerate the snapshot.

### MIGRATION.CONTRACT_SNAPSHOT_HASH_MISMATCH

While writing a contract snapshot, the contract JSON's inner `storage.storageHash` does not equal the storage hash the snapshot is being filed under — the two must agree by construction. Primarily an authoring/tooling invariant rather than something a user causes directly. Meta: `storageHash`, `actualHash`, `dir`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,20 @@ export interface ContractSerializer<TContract> {
* arrays (e.g. SQL `indexes`/`uniques`) supply this hook.
*/
readonly sortStorage?: StorageSort;

/**
* The canonicalization hooks the family's emit pipeline computes storage
* hashes with. Distinct from {@link shouldPreserveEmpty} /
* {@link sortStorage}: those govern on-disk serialization and may be
* broader per target (Postgres preserves required entity-kind fields at
* default values so the persisted contract re-deserializes), while a hash
* recompute must reproduce the exact canonical form the published
* `storageHash` was derived from. Integrity checks that recompute storage
* hashes (snapshot content verification, descriptor self-consistency)
* must use these hooks, never the serialization pair.
*/
readonly hashCanonicalizationHooks?: {
readonly shouldPreserveEmpty?: PreserveEmptyPredicate;
readonly sortStorage?: StorageSort;
};
}
10 changes: 9 additions & 1 deletion packages/1-framework/3-tooling/cli/src/control-api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ import {
hasSchemaView,
} from '@internal/framework-components/control';
import type { PslDocumentAst } from '@internal/framework-components/psl-ast';
import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store';
import { ifDefined } from '@internal/utils/defined';
import { InternalError } from '@internal/utils/internal-error';
import { notOk, ok } from '@internal/utils/result';
import { structuredError } from '@internal/utils/structured-error';

import { assertFrameworkComponentsCompatible } from '../utils/framework-components';
import { snapshotVerifierFor } from '../utils/snapshot-content-verification';
import { enrichContract } from './contract-enrichment';
import { executeDbInit } from './operations/db-init';
import { executeDbUpdate } from './operations/db-update';
Expand Down Expand Up @@ -84,10 +85,13 @@ class ControlClientImpl implements ControlClient {
> | null = null;
private initialized = false;
private readonly defaultConnection: unknown;
/** One per client so the verified-hash memo spans operations (e.g. db update's pre-plan + consented apply). */
private readonly snapshotVerifier: SnapshotContentVerifier | undefined;

constructor(options: ControlClientOptions) {
this.options = options;
this.defaultConnection = options.connection;
this.snapshotVerifier = snapshotVerifierFor(options);
}

init(): void {
Expand Down Expand Up @@ -414,6 +418,7 @@ class ControlClientImpl implements ControlClient {
migrationsDir: options.migrationsDir,
targetId: this.options.target.targetId,
extensions: this.options.extensions ?? [],
...ifDefined('verifySnapshotContent', this.snapshotVerifier),
...ifDefined('onProgress', onProgress),
});
}
Expand Down Expand Up @@ -453,6 +458,7 @@ class ControlClientImpl implements ControlClient {
extensions: this.options.extensions ?? [],
...ifDefined('acceptDataLoss', options.acceptDataLoss),
...ifDefined('consent', options.consent),
...ifDefined('verifySnapshotContent', this.snapshotVerifier),
...ifDefined('onProgress', onProgress),
});
}
Expand All @@ -473,6 +479,7 @@ class ControlClientImpl implements ControlClient {
mode: options.strict ? 'strict' : 'lenient',
skipSchema: options.skipSchema,
skipMarker: options.skipMarker,
...ifDefined('verifySnapshotContent', this.snapshotVerifier),
...ifDefined('onProgress', onProgress),
});
}
Expand Down Expand Up @@ -529,6 +536,7 @@ class ControlClientImpl implements ControlClient {
...ifDefined('refHash', options.refHash),
...ifDefined('refInvariants', options.refInvariants),
...ifDefined('refName', options.refName),
...ifDefined('verifySnapshotContent', this.snapshotVerifier),
...ifDefined('onProgress', onProgress),
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
errorUnexpected,
mapRefResolutionError,
} from '../../utils/cli-errors';
import { snapshotVerifierFor } from '../../utils/snapshot-content-verification';
import { buildReadAggregate } from './contract-space-aggregate-loader';

function isEnoent(error: unknown): boolean {
Expand Down Expand Up @@ -83,7 +84,13 @@ export async function resolveContractRefToSnapshot(
const contractJson = blindCast<
Record<string, unknown>,
'contract snapshot store entries are JSON objects written by writeContractSnapshot'
>(await readContractSnapshotJson(options.migrationsDir, targetHash));
>(
await readContractSnapshotJson(
options.migrationsDir,
targetHash,
snapshotVerifierFor(options.config),
),
);
return ok({
hash: targetHash,
contractJson,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import type {
} from '@internal/migration-tools/aggregate';
import { loadContractSpaceAggregate } from '@internal/migration-tools/aggregate';
import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants';
import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store';
import { MigrationToolsError } from '@internal/migration-tools/errors';
import { blindCast } from '@internal/utils/casts';
import { ifDefined } from '@internal/utils/defined';
import { notOk, ok, type Result } from '@internal/utils/result';
import { CliStructuredError, errorUnexpected } from '../../utils/cli-errors';
import { readContractEnvelope, resolveContractPath } from '../../utils/command-helpers';
import { toDeclaredExtensionsFromRaw } from '../../utils/extension-pack-inputs';
import { snapshotVerifierFor } from '../../utils/snapshot-content-verification';

const CONTRACT_SPACES_DOCS_URL = 'https://pris.ly/contract-spaces';

Expand Down Expand Up @@ -180,6 +183,12 @@ export interface BuildAggregateInputs<TFamilyId extends string, TTargetId extend
readonly appContract: Contract;
readonly extensions: ReadonlyArray<ControlExtensionDescriptor<TFamilyId, TTargetId>>;
readonly deserializeContract: (contractJson: unknown) => Contract;
/**
* Content check for contract snapshots resolved through the aggregate;
* build it with `snapshotVerifierFor(config)` so the recompute uses the
* target's canonicalization hooks.
*/
readonly verifySnapshotContent?: SnapshotContentVerifier;
}

function declaredExtensionsFromInputs(
Expand Down Expand Up @@ -225,6 +234,7 @@ export async function loadContractSpaceAggregateForCli<
migrationsDir: inputs.migrationsDir,
deserializeContract: inputs.deserializeContract,
appContract: inputs.appContract,
...ifDefined('verifySnapshotContent', inputs.verifySnapshotContent),
});
return ok(aggregate);
}
Expand Down Expand Up @@ -344,7 +354,11 @@ export async function loadContractRawSafely(config: {
*/
export async function buildReadAggregate(
config: PrismaNextConfig,
options: { readonly migrationsDir: string },
options: {
readonly migrationsDir: string;
/** Command-scoped verifier to share the verified-hash memo; defaults to one built from `config`. */
readonly verifySnapshotContent?: SnapshotContentVerifier;
},
): Promise<
Result<
{ readonly aggregate: ContractSpaceAggregate; readonly contractHash: string },
Expand Down Expand Up @@ -384,6 +398,10 @@ export async function buildReadAggregate(
appContract: appContractForLoad,
extensions: config.extensions ?? [],
deserializeContract,
...ifDefined(
'verifySnapshotContent',
options.verifySnapshotContent ?? snapshotVerifierFor(config),
),
});
if (!loaded.ok) {
return loaded;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ControlFamilyInstance,
TargetMigrationsCapability,
} from '@internal/framework-components/control';
import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store';
import { ifDefined } from '@internal/utils/defined';
import type { DbInitResult, OnControlProgress } from '../types';
import { executeRun } from './db-run';
Expand Down Expand Up @@ -56,6 +57,8 @@ export interface ExecuteDbInitOptions<TFamilyId extends string, TTargetId extend
* extension spaces in the aggregate.
*/
readonly extensions?: ReadonlyArray<ControlExtensionDescriptor<TFamilyId, TTargetId>>;
/** Content check for contract snapshots the aggregate loader resolves. */
readonly verifySnapshotContent?: SnapshotContentVerifier;
/** Optional progress callback for observing operation progress */
readonly onProgress?: OnControlProgress;
}
Expand Down Expand Up @@ -83,6 +86,7 @@ export async function executeDbInit<TFamilyId extends string, TTargetId extends
extensions: options.extensions ?? [],
policy: { allowedOperationClasses: ['additive'] },
action: 'dbInit',
...ifDefined('verifySnapshotContent', options.verifySnapshotContent),
...ifDefined('onProgress', options.onProgress),
});
return result as DbInitResult;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type PlannerError,
planMigration,
} from '@internal/migration-tools/aggregate';
import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store';
import { blindCast } from '@internal/utils/casts';
import { ifDefined } from '@internal/utils/defined';
import { InternalError } from '@internal/utils/internal-error';
Expand Down Expand Up @@ -93,6 +94,8 @@ export interface ExecuteRunOptions<TFamilyId extends string, TTargetId extends s
* general.
*/
readonly consentedPlanHash?: string;
/** Content check for contract snapshots the aggregate loader resolves. */
readonly verifySnapshotContent?: SnapshotContentVerifier;
readonly onProgress?: OnControlProgress;
}

Expand Down Expand Up @@ -142,6 +145,7 @@ export async function executeRun<TFamilyId extends string, TTargetId extends str
appContract: contract,
extensions,
deserializeContract: (json) => familyInstance.deserializeContract(json),
...ifDefined('verifySnapshotContent', options.verifySnapshotContent),
};
const loaded = await buildContractSpaceAggregate(loadInputs);
if (!loaded.ok) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ControlFamilyInstance,
TargetMigrationsCapability,
} from '@internal/framework-components/control';
import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store';
import { ifDefined } from '@internal/utils/defined';
import { notOk } from '@internal/utils/result';
import type { DbUpdateResult, OnControlProgress } from '../types';
Expand Down Expand Up @@ -48,6 +49,8 @@ export interface ExecuteDbUpdateOptions<TFamilyId extends string, TTargetId exte
readonly migrationsDir: string;
readonly targetId: TTargetId;
readonly extensions?: ReadonlyArray<ControlExtensionDescriptor<TFamilyId, TTargetId>>;
/** Content check for contract snapshots the aggregate loader resolves. */
readonly verifySnapshotContent?: SnapshotContentVerifier;
readonly onProgress?: OnControlProgress;
}

Expand Down Expand Up @@ -75,6 +78,7 @@ export async function executeDbUpdate<TFamilyId extends string, TTargetId extend
extensions: options.extensions ?? [],
policy: DB_UPDATE_POLICY,
action: 'dbUpdate' as const,
...ifDefined('verifySnapshotContent', options.verifySnapshotContent),
...ifDefined('onProgress', options.onProgress),
};
if (options.mode === 'apply' && !options.acceptDataLoss && options.consent === undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type VerifierOutput,
verifyMigration,
} from '@internal/migration-tools/aggregate';
import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store';
import { castAs } from '@internal/utils/casts';
import { ifDefined } from '@internal/utils/defined';
import { notOk, ok, type Result } from '@internal/utils/result';
Expand Down Expand Up @@ -55,6 +56,8 @@ export interface ExecuteDbVerifyOptions<TFamilyId extends string, TTargetId exte
readonly mode: 'strict' | 'lenient';
readonly skipSchema: boolean;
readonly skipMarker: boolean;
/** Content check for contract snapshots the aggregate loader resolves. */
readonly verifySnapshotContent?: SnapshotContentVerifier;
readonly onProgress?: OnControlProgress;
}

Expand Down Expand Up @@ -165,6 +168,7 @@ function buildLoadInputs<TFamilyId extends string, TTargetId extends string>(
appContract: options.contract,
extensions: options.extensions,
deserializeContract: (json) => options.familyInstance.deserializeContract(json),
...ifDefined('verifySnapshotContent', options.verifySnapshotContent),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
resolveRecordedPath,
} from '@internal/migration-tools/aggregate';
import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants';
import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store';
import { errorNoInvariantPath } from '@internal/migration-tools/errors';
import { findPathWithDecision } from '@internal/migration-tools/migration-graph';
import { ifDefined } from '@internal/utils/defined';
Expand Down Expand Up @@ -63,6 +64,8 @@ export interface ExecuteMigrateOptions<TFamilyId extends string, TTargetId exten
readonly migrationsDir: string;
readonly extensions: ReadonlyArray<ControlExtensionDescriptor<TFamilyId, TTargetId>>;
readonly targetId: TTargetId;
/** Content check for contract snapshots the aggregate loader resolves. */
readonly verifySnapshotContent?: SnapshotContentVerifier;
/**
* Optional app-space ref override. When provided, the app space's
* graph-walk targets this hash instead of `space.headRef.hash`.
Expand Down Expand Up @@ -137,6 +140,7 @@ export async function executeMigrate<TFamilyId extends string, TTargetId extends
appContract: contract,
extensions,
deserializeContract: (json) => familyInstance.deserializeContract(json),
...ifDefined('verifySnapshotContent', options.verifySnapshotContent),
};
const loaded = await buildContractSpaceAggregate(loadInputs);
if (!loaded.ok) {
Expand Down
Loading
Loading