import { realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import type { ControlStack, MigrationPlan, MigrationPlanOperation, } from '@prisma-next/framework-components/control'; import { type } from 'arktype'; import { errorInvalidOperationEntry } from './errors'; import { computeMigrationHash } from './hash'; import { deriveProvidedInvariants } from './invariants'; import type { MigrationMetadata } from './metadata'; import { MigrationOpSchema } from './op-schema'; import type { MigrationOps } from './package'; export interface MigrationMeta { readonly from: string | null; readonly to: string; } // `from` rejects empty strings to mirror `MigrationMetadataSchema` in // `./io.ts`. Without this match, an authored migration could `describe()` with // `from: ''` and pass `buildMigrationArtifacts`'s validation, only to have // `readMigrationPackage` reject the resulting `migration.json` later — the // two validators must agree on the legal value space. const MigrationMetaSchema = type({ from: 'string > 0 | null', to: 'string', }); /** * Base class for migrations. * * A `Migration` subclass is itself a `MigrationPlan`: CLI commands and the * runner can consume it directly via `targetId`, `operations`, `origin`, and * `destination`. The metadata-shaped inputs come from `describe()`, which * every migration must implement — `migration.json` is required for a * migration to be valid. */ export abstract class Migration< _TOperation extends MigrationPlanOperation = MigrationPlanOperation, TFamilyId extends string = string, TTargetId extends string = string, > implements MigrationPlan { abstract readonly targetId: string; /** * Assembled `ControlStack` injected by the orchestrator (`runMigration`). * * Subclasses (e.g. `PostgresMigration`) read the stack to materialize their * adapter once per instance. Optional at the abstract level so unit tests can * construct `Migration` instances purely for `operations` / `describe` * assertions without needing a real stack; concrete subclasses that need the * stack at runtime should narrow the parameter to required. */ protected readonly stack: ControlStack | undefined; constructor(stack?: ControlStack) { this.stack = stack; } /** * Ordered list of operations this migration performs. * * Implemented as a getter so that subclasses can either precompute the list * in their constructor or build it lazily per access. Entries may be Promises * when the target requires async codec resolution (e.g. DDL literal defaults). */ abstract get operations(): readonly (MigrationPlanOperation | Promise)[]; /** * Metadata inputs used to build `migration.json` and to derive the plan's * origin/destination identities. Every migration must provide this — * omitting it would produce an invalid on-disk migration package. */ abstract describe(): MigrationMeta; get origin(): { readonly storageHash: string } | null { const from = this.describe().from; return from === null ? null : { storageHash: from }; } get destination(): { readonly storageHash: string } { return { storageHash: this.describe().to }; } } /** * Returns true when `import.meta.url` resolves to the same file that was * invoked as the node entrypoint (`process.argv[1]`). Used by * `MigrationCLI.run` (in `@prisma-next/cli/migration-cli`) to no-op when * the migration module is being imported (e.g. by another script) rather * than executed directly. */ export function isDirectEntrypoint(importMetaUrl: string): boolean { const metaFilename = fileURLToPath(importMetaUrl); const argv1 = process.argv[1]; if (!argv1) return false; try { return realpathSync(metaFilename) === realpathSync(argv1); } catch { return false; } } /** * In-memory artifacts produced from a `Migration` instance: the * serialized `ops.json` body, the `migration.json` metadata object, and * its serialized form. Returned by `buildMigrationArtifacts` so callers * (today: `MigrationCLI.run` in `@prisma-next/cli/migration-cli`) can * decide how to persist them — write to disk, print in dry-run, ship * over the wire — without coupling artifact construction to file I/O. * * `metadataJson` is `JSON.stringify(metadata, null, 2)` — the canonical * on-disk shape that the arktype loader-schema in `./io` validates. */ export interface MigrationArtifacts { readonly opsJson: string; readonly metadata: MigrationMetadata; readonly metadataJson: string; } /** * Build the attested metadata from `describe()`-derived metadata, the * operations list, and the previously-scaffolded metadata (if any). * * When a `migration.json` already exists for this package (the common * case: it was scaffolded by `migration plan`), preserve `createdAt` * set there — that field is owned by the CLI scaffolder, not the authored * class. Only the `describe()`-derived fields (`from`, `to`) and the * operations change as the author iterates. When no metadata exists yet * (a bare `migration.ts` run from scratch), synthesize a minimal but * schema-conformant record so the resulting package can still be read, * verified, and applied. * * The `migrationHash` is recomputed against the current metadata + ops so * the on-disk artifacts are always fully attested. */ function buildAttestedMetadata( meta: MigrationMeta, ops: MigrationOps, existing: Partial | null, ): MigrationMetadata { const baseMetadata: Omit = { from: meta.from, to: meta.to, providedInvariants: deriveProvidedInvariants(ops), createdAt: existing?.createdAt ?? new Date().toISOString(), }; const migrationHash = computeMigrationHash(baseMetadata, ops); return { ...baseMetadata, migrationHash }; } /** * Pure conversion from a `Migration` instance (plus the previously * scaffolded metadata, when one exists on disk) to the in-memory * artifacts that downstream tooling persists. Owns metadata validation, * metadata synthesis/preservation, and the content-addressed * `migrationHash` computation, but performs no file I/O — callers handle * reads (to source `existing`) and writes (to persist `opsJson` / * `metadataJson`). */ export async function buildMigrationArtifacts( instance: Migration, existing: Partial | null, ): Promise { const rawOps = instance.operations; if (!Array.isArray(rawOps)) { throw new Error('operations must be an array'); } const ops = await Promise.all(rawOps); for (let index = 0; index < ops.length; index++) { const result = MigrationOpSchema(ops[index]); if (result instanceof type.errors) { throw errorInvalidOperationEntry(index, result.summary); } } const rawMeta: unknown = instance.describe(); const parsed = MigrationMetaSchema(rawMeta); if (parsed instanceof type.errors) { throw new Error(`describe() returned invalid metadata: ${parsed.summary}`); } const metadata = buildAttestedMetadata(parsed, ops, existing); return { opsJson: JSON.stringify(ops, null, 2), metadata, metadataJson: JSON.stringify(metadata, null, 2), }; }