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
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,65 @@ import type { TargetBoundComponentDescriptor } from './framework-components';
* - 'additive': Adds new structures without modifying existing ones (safe)
* - 'widening': Relaxes constraints or expands types (generally safe)
* - 'destructive': Removes or alters existing structures (potentially unsafe)
* - 'data': Data transformation operation (e.g., backfill, type conversion)
*/
export type MigrationOperationClass = 'additive' | 'widening' | 'destructive';
export type MigrationOperationClass = 'additive' | 'widening' | 'destructive' | 'data';

// ============================================================================
// Data Transform Operation
// ============================================================================

/**
* A lowered query statement as stored in ops.json.
* Contains the SQL string and parameter values — ready for execution.
* Lowering from query builder AST to SQL happens at verify time.
*/
export interface SerializedQueryPlan {
readonly sql: string;
readonly params: readonly unknown[];
}

/**
* A data transform operation within a migration edge.
*
* Data transforms are authored in TypeScript using the query builder,
* serialized to JSON ASTs at verification time, and rendered to SQL
* by the target adapter at apply time.
*
* The `name` serves as the invariant identity — it's recorded in the
* ledger and used for invariant-aware routing via environment refs.
*
* In draft state (before verification), `check` and `run` are null.
* After verification, they contain the serialized query ASTs.
*/
export interface DataTransformOperation extends MigrationPlanOperation {
readonly operationClass: 'data';
/**
* The invariant name for this data transform.
* Recorded in the ledger on successful edge completion.
* Used by environment refs to declare required invariants.
*/
readonly name: string;
/**
* Path to the TypeScript source file that produced this operation.
* Not part of edgeId computation — for traceability only.
*/
readonly source: string;
/**
* Serialized check query plan, or a boolean literal.
* - SerializedQueryPlan: describes violations; empty result = already applied.
* - false: always run (no check).
* - true: always skip.
* - null: not yet serialized (draft state).
*/
readonly check: SerializedQueryPlan | boolean | null;
/**
* Serialized run query plans.
* - Array of serialized query plans to execute sequentially.
* - null: not yet serialized (draft state).
*/
readonly run: readonly SerializedQueryPlan[] | null;
}

/**
* Policy defining which operation classes are allowed during a migration.
Expand All @@ -37,6 +94,16 @@ export interface MigrationOperationPolicy {
// Plan Types (Display-Oriented)
// ============================================================================

/**
* Minimal shape for operation descriptors at the framework level.
* Targets produce richer types; this captures just enough for the
* framework to scaffold migration.ts files and pass descriptors through.
*/
export interface OperationDescriptor {
readonly kind: string;
readonly [key: string]: unknown;
}

/**
* A single migration operation for display purposes.
* Contains only the fields needed for CLI output (tree view, JSON envelope).
Expand Down Expand Up @@ -270,4 +337,43 @@ export interface TargetMigrationsCapability<
contract: Contract | null,
frameworkComponents?: ReadonlyArray<TargetBoundComponentDescriptor<TFamilyId, TTargetId>>,
): unknown;

/**
* Plans a migration using the descriptor-based planner.
* Returns operation descriptors and whether data migration is needed.
* The caller decides whether to resolve immediately or scaffold migration.ts.
*/
planWithDescriptors?(context: {
readonly fromContract: Contract | null;
readonly toContract: Contract;
readonly frameworkComponents?: ReadonlyArray<
TargetBoundComponentDescriptor<TFamilyId, TTargetId>
>;
}):
| {
readonly ok: true;
readonly descriptors: readonly OperationDescriptor[];
readonly needsDataMigration: boolean;
}
| {
readonly ok: false;
readonly conflicts: readonly MigrationPlannerConflict[];
};

/**
* Resolves operation descriptors into target-specific migration plan operations
* with SQL/DDL, prechecks, and postchecks. Called by `migration verify` to
* serialize migration.ts into ops.json.
*/
resolveDescriptors?(
descriptors: readonly OperationDescriptor[],
context: {
readonly fromContract: Contract | null;
readonly toContract: Contract;
readonly schemaName?: string;
readonly frameworkComponents?: ReadonlyArray<
TargetBoundComponentDescriptor<TFamilyId, TTargetId>
>;
},
): readonly MigrationPlanOperation[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface VerifyDatabaseResult {
};
}

export interface SchemaIssue {
export interface BaseSchemaIssue {
readonly kind:
| 'missing_table'
| 'missing_column'
Expand All @@ -57,11 +57,22 @@ export interface SchemaIssue {
readonly column?: string;
readonly indexOrConstraint?: string;
readonly typeName?: string;
readonly dependencyId?: string;
readonly expected?: string;
readonly actual?: string;
readonly message: string;
}

export interface EnumValuesChangedIssue {
readonly kind: 'enum_values_changed';
readonly typeName: string;
readonly addedValues: readonly string[];
readonly removedValues: readonly string[];
readonly message: string;
}

export type SchemaIssue = BaseSchemaIssue | EnumValuesChangedIssue;

export interface SchemaVerificationNode {
readonly status: 'pass' | 'warn' | 'fail';
readonly kind: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type {
ControlTargetInstance,
} from '../control-instances';
export type {
DataTransformOperation,
MigrationOperationClass,
MigrationOperationPolicy,
MigrationPlan,
Expand All @@ -29,10 +30,14 @@ export type {
MigrationRunnerFailure,
MigrationRunnerResult,
MigrationRunnerSuccessValue,
OperationDescriptor,
SerializedQueryPlan,
TargetMigrationsCapability,
} from '../control-migration-types';
export type {
BaseSchemaIssue,
EmitContractResult,
EnumValuesChangedIssue,
IntrospectSchemaResult,
OperationContext,
SchemaIssue,
Expand Down
5 changes: 5 additions & 0 deletions packages/1-framework/3-tooling/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@prisma-next/tsconfig": "workspace:*",
"@prisma-next/tsdown": "workspace:*",
"@types/node": "catalog:",
"@vitest/ui": "4.0.17",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
Expand Down Expand Up @@ -94,6 +95,10 @@
"types": "./dist/commands/contract-infer.d.mts",
"import": "./dist/commands/contract-infer.mjs"
},
"./commands/migration-new": {
"types": "./dist/commands/migration-new.d.mts",
"import": "./dist/commands/migration-new.mjs"
},
"./commands/migration-plan": {
"types": "./dist/commands/migration-plan.d.mts",
"import": "./dist/commands/migration-plan.mjs"
Expand Down
4 changes: 4 additions & 0 deletions packages/1-framework/3-tooling/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createDbSignCommand } from './commands/db-sign';
import { createDbUpdateCommand } from './commands/db-update';
import { createDbVerifyCommand } from './commands/db-verify';
import { createMigrationApplyCommand } from './commands/migration-apply';
import { createMigrationNewCommand } from './commands/migration-new';
import { createMigrationPlanCommand } from './commands/migration-plan';
import { createMigrationRefCommand } from './commands/migration-ref';
import { createMigrationShowCommand } from './commands/migration-show';
Expand Down Expand Up @@ -227,6 +228,9 @@ migrationCommand.configureHelp({
const migrationPlanCommand = createMigrationPlanCommand();
migrationCommand.addCommand(migrationPlanCommand);

const migrationNewCommand = createMigrationNewCommand();
migrationCommand.addCommand(migrationNewCommand);

const migrationShowCommand = createMigrationShowCommand();
migrationCommand.addCommand(migrationShowCommand);

Expand Down
41 changes: 26 additions & 15 deletions packages/1-framework/3-tooling/cli/src/commands/migration-apply.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
import { findPathWithDecision } from '@prisma-next/migration-tools/dag';
import { readRefs, resolveRef } from '@prisma-next/migration-tools/refs';
import type { AttestedMigrationBundle, MigrationGraph } from '@prisma-next/migration-tools/types';
import type { AttestedMigrationBundle } from '@prisma-next/migration-tools/types';
import { MigrationToolsError } from '@prisma-next/migration-tools/types';
import { notOk, ok, type Result } from '@prisma-next/utils/result';
import { Command } from 'commander';
Expand All @@ -20,7 +20,8 @@ import {
} from '../utils/cli-errors';
import {
addGlobalOptions,
loadMigrationBundles,
loadAllBundles,
type MigrationBundleSet,
maskConnectionUrl,
readContractEnvelope,
resolveMigrationPaths,
Expand Down Expand Up @@ -194,10 +195,15 @@ async function executeMigrationApplyCommand(
}

// Read migrations and build migration chain model (offline — no DB needed)
let bundles: readonly AttestedMigrationBundle[];
let graph: MigrationGraph;
let migrations: MigrationBundleSet;
try {
({ bundles, graph } = await loadMigrationBundles(migrationsDir));
migrations = await loadAllBundles(migrationsDir);
if (migrations.drafts.length > 0 && !flags.quiet) {
ui.warn(
`${migrations.drafts.length} draft migration(s) found: ${migrations.drafts.map((d) => d.dirName).join(', ')}. ` +
"Run 'prisma-next migration verify --dir <path>' to attest before applying.",
);
}
} catch (error) {
if (MigrationToolsError.is(error)) {
return notOk(mapMigrationToolsError(error));
Expand All @@ -218,7 +224,7 @@ async function executeMigrationApplyCommand(
const marker = await client.readMarker();

// --- No attested migrations on disk ---
if (bundles.length === 0) {
if (migrations.attested.length === 0) {
if (marker?.storageHash) {
return notOk(
errorRuntime('Database has state but no migrations exist', {
Expand Down Expand Up @@ -266,22 +272,27 @@ async function executeMigrationApplyCommand(

const markerHash = marker?.storageHash;

if (markerHash !== undefined && !graph.nodes.has(markerHash)) {
if (markerHash !== undefined && !migrations.graph.nodes.has(markerHash)) {
return notOk(
errorRuntime('Database marker does not match any known migration', {
why: `The database marker hash "${markerHash}" is not found in the migration history at ${migrationsRelative}`,
fix: 'Ensure the migrations directory matches this database. If the database was managed with `db init` or `db update`, run `prisma-next db sign` to update the marker.',
meta: { markerHash, knownNodes: [...graph.nodes] },
meta: { markerHash, knownNodes: [...migrations.graph.nodes] },
}),
);
}

if (!graph.nodes.has(destinationHash)) {
if (!migrations.graph.nodes.has(destinationHash)) {
const matchingDraft = migrations.drafts.find((d) => d.manifest.to === destinationHash);
return notOk(
errorRuntime('Current contract has no planned migration path', {
why: `Current contract hash "${destinationHash}" is not present in the migration history at ${migrationsRelative}`,
fix: 'Run `prisma-next migration plan` to create a migration for the current contract, then re-run apply.',
meta: { destinationHash, knownNodes: [...graph.nodes] },
why: matchingDraft
? `A draft migration exists at "${matchingDraft.dirName}" but has not been attested`
: `Current contract hash "${destinationHash}" is not present in the migration history at ${migrationsRelative}`,
fix: matchingDraft
? `Run 'prisma-next migration verify --dir ${migrationsRelative}/${matchingDraft.dirName}' to attest, then re-run apply.`
: 'Run `prisma-next migration plan` to create a migration for the current contract, then re-run apply.',
meta: { destinationHash, knownNodes: [...migrations.graph.nodes] },
}),
);
}
Expand All @@ -291,7 +302,7 @@ async function executeMigrationApplyCommand(
// "No marker" means the database is fresh — start from the empty contract hash.
const originHash = markerHash ?? EMPTY_CONTRACT_HASH;

const decision = findPathWithDecision(graph, originHash, destinationHash, refName);
const decision = findPathWithDecision(migrations.graph, originHash, destinationHash, refName);
if (!decision) {
return notOk(
errorRuntime('No migration path from current state to target', {
Expand All @@ -318,7 +329,7 @@ async function executeMigrationApplyCommand(
});
}

const bundleByDir = new Map(bundles.map((b) => [b.dirName, b]));
const bundleByDir = new Map(migrations.attested.map((b) => [b.dirName, b]));
const pendingMigrations: MigrationApplyStep[] = [];
for (const migration of pendingPath) {
const pkg = bundleByDir.get(migration.dirName);
Expand Down Expand Up @@ -389,7 +400,7 @@ export function createMigrationApplyCommand(): Command {
addGlobalOptions(command)
.option('--db <url>', 'Database connection string')
.option('--config <path>', 'Path to prisma-next.config.ts')
.option('--ref <name>', 'Target ref name from migrations/refs.json')
.option('--ref <name>', 'Target ref name from migrations/refs/')
.action(async (options: MigrationApplyCommandOptions) => {
const flags = parseGlobalFlags(options);
const startTime = Date.now();
Expand Down
Loading
Loading