import type { SqlType } from "./sql-type.js"; // --------------------------------------------------------------------------- // Snapshot descriptors — same shape for expected (from metadata) and actual // (from introspection). diff() compares two SchemaSnapshots symmetrically. // --------------------------------------------------------------------------- export interface SchemaSnapshot { tables: TableDescriptor[]; /** Always empty in v0.1; populated by introspect for v0.3 future-proofing. */ views: ViewDescriptor[]; /** * Dialect-specific metadata captured at introspect time. Used by emit * (e.g., SQLite version → choose native ALTER vs recreate-and-copy fallback). */ meta?: SnapshotMeta; } export interface SnapshotMeta { sqliteVersion?: string; // e.g., "3.44.2"; only set for SQLite snapshots } export interface TableDescriptor { name: string; // resolved db name (snake_case, plural) /** * DB schema this table lives in. Undefined for SQLite (no schema concept). * For Postgres, undefined is normalized to "public" at SnapshotMeta boundaries; * the diff and emit layers treat undefined === "public" as equivalent. */ schema?: string; columns: ColumnDescriptor[]; indexes: IndexDescriptor[]; foreignKeys: FkDescriptor[]; checks: CheckDescriptor[]; primaryKey: string[]; // column names; [] if none /** * Human-readable description threaded from entity `@description`. * Postgres: emitted as `COMMENT ON TABLE … IS '…';` after CREATE TABLE. * SQLite: silently ignored (no native COMMENT support). * Introspect side: not read back in v1 (no change-description variant yet). */ description?: string; } export interface ColumnDescriptor { name: string; sqlType: SqlType; nullable: boolean; default?: ColumnDefault; identity?: "increment" | "uuid"; /** * Human-readable description threaded from field `@description`. * Postgres: emitted as `COMMENT ON COLUMN … IS '…';` after CREATE TABLE or ADD COLUMN. * SQLite: silently ignored (no native COMMENT support). * Introspect side: not read back in v1 (no change-description variant yet). */ description?: string; } export interface ColumnDefault { kind: "literal" | "expr"; value: string; } export interface IndexDescriptor { name: string; columns: string[]; unique: boolean; /** * Per-column sort direction, positional to `columns`. Omitted (or "asc") = the * default ascending order, which Postgres does not render in an index def. A * "desc" entry emits ` DESC` and is compared against the introspected * `pg_index.indoption` DESC bit. Absent ⇒ all-ascending. */ orders?: ("asc" | "desc")[]; /** * Partial-index predicate (raw SQL, e.g. `delivered_at IS NULL`). Emitted as a * trailing `WHERE ()`; compared against the introspected * `pg_get_expr(indpred, …)` after expression normalization. Absent ⇒ full index. */ where?: string; /** * Raw key EXPRESSION for a functional/expression index (e.g. `lower((email)::text)`). * When present, the index key is this expression and `columns` is empty — * emitted as `()` and compared against the introspected `pg_get_indexdef` * key after normalization. */ expr?: string; /** * Index access method (`gin`, `gist`, `hash`, …). Absent / `btree` is the * default and not rendered. Emitted as `USING ` before the key list. */ using?: string; /** * INTROSPECTION-ONLY, Postgres. Set when the LIVE index is owned by a table * constraint (`pg_constraint.conindid`), i.e. it was created as a side effect of * `UNIQUE` / `PRIMARY KEY` / `EXCLUDE` rather than by `CREATE INDEX`. Postgres * refuses `DROP INDEX` on such an index ("cannot drop index X because constraint * X on table Y requires it"), so the emitter must drop the CONSTRAINT instead. * * Never authored in metadata and never present on the expected side — it describes * how the database happens to hold an index, not what the model asks for. It is * therefore deliberately NOT compared by `indexEquals`: a unique index and a unique * constraint over the same columns are the same model-level thing. * * This matters far beyond an edge case: Drizzle's `unique()` produces constraints, * so every adopter migrating from a Drizzle-managed schema has constraint-backed * unique indexes (#285). */ constraint?: "unique" | "primary" | "exclude"; } export interface CheckDescriptor { /** Constraint name, e.g. `__chk`. Diff/identity key. */ name: string; /** The boolean SQL expression, e.g. `status IN ('OPEN','CLOSED')`. */ expression: string; } export interface FkDescriptor { name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: FkAction; onUpdate?: FkAction; } export type FkAction = "cascade" | "set-null" | "restrict" | "no-action"; /** One output column of a view, in SELECT order. */ export interface ViewColumnDescriptor { name: string; sqlType: SqlType; } /** A relation that depends on a view — i.e. what a `DROP ... CASCADE` would destroy. */ export interface DependentRelation { schema: string; name: string; /** 'v' = view, 'm' = materialized view. */ relkind: "v" | "m"; /** * True when this dependent is a view MetaObjects manages (it carries our * fingerprint). A managed dependent that the same migration recreates is * harmless; an UNMANAGED one is somebody else's object and a CASCADE destroys * it irrecoverably. */ managed: boolean; } export interface ViewDescriptor { name: string; /** Same semantics as TableDescriptor.schema. */ schema?: string; /** * View definition SQL. * * On the EXPECTED side (`buildExpectedSchema` / `buildExpectedViews`) this is * the view body — the SELECT clause through the FROM/WHERE/GROUP-BY tail. It is * the input to `viewFingerprint()`. * * On the ACTUAL side (`introspect`) this is whatever the DB catalog stores. * Its role is DIALECT-DEPENDENT, and this is load-bearing: * * - SQLite/D1 store `sqlite_master.sql` VERBATIM — the exact text we wrote — * so there the body is a sound basis for comparison (`viewSqlEquals`). * * - Postgres does NOT store view SQL. It stores the parse tree, and * `pg_get_viewdef()` DEPARSES it back into Postgres's own canonical style * (lowercased functions, `LEFT OUTER JOIN` → `LEFT JOIN`, parenthesized FROM * items and ON predicates, dropped redundant aliases). It can never equal * what we emitted, so on Postgres the body is NOT used for comparison — * `fingerprint` is. The deparsed body is still carried, as the RESTORE * payload for a down migration (it is valid SQL that reproduces the view). */ sql?: string; /** * Content hash of the generated body — `metaobjects:v1:sha256:` — stamped * into the view's `COMMENT ON VIEW` at emit time and read back at introspect * time. This, not the body text, is how Postgres decides whether a view is * up to date (see view-fingerprint.ts). * * Absent on the actual side means the view carries NO MetaObjects stamp: either * it is hand-written, or it predates fingerprinting. The two are * indistinguishable on Postgres, so the diff fails CLOSED and blocks pending * `allow.adoptView`. */ fingerprint?: string; /** * The view's output columns, in SELECT order. Drives the replace-vs-drop * decision: Postgres permits `CREATE OR REPLACE VIEW` only when the existing * columns are a PREFIX of the new ones (same names, same types, same order, * additions at the end only). Undefined = unknown → fail safe to drop+create. */ columns?: readonly ViewColumnDescriptor[]; /** * Relations that depend on this view (transitively). Populated on the ACTUAL * side by introspection. A `DROP VIEW` with dependents needs CASCADE, which * destroys every one of them — including views owned by other applications. */ dependents?: readonly DependentRelation[]; /** * Physical tables this view reads (base + joined tables). Populated on the * EXPECTED side only (the view producer knows the join graph; introspection * does not resolve it). The diff uses it to drop + recreate the view around a * column-altering change to one of its source tables — postgres blocks ALTER on * a column a view depends on, and sqlite's recreate-and-copy rebuilds the table. */ dependsOn?: readonly string[]; } // --------------------------------------------------------------------------- // Change union — produced by diff(), consumed by emit(). // --------------------------------------------------------------------------- /** * Every variant with a `table: string` (or `table: TableDescriptor`) field also * carries an optional `schema?: string`. For the descriptor-bearing variants * (create-table, drop-table source is just a name) the schema is redundant with * `table.schema` but kept in parallel so the emit layer has a single, uniform * place to read schema regardless of variant. * * For Postgres, undefined is treated as equivalent to "public" by both diff * (table identity normalization) and emit (qualified-name suppression). * SQLite has no schema concept; the field is always undefined there. */ export type Change = | { kind: "create-table"; table: TableDescriptor; schema?: string; status: ChangeStatus } | { kind: "drop-table"; table: string; schema?: string; restore?: TableDescriptor; status: ChangeStatus } | { kind: "rename-table"; from: string; to: string; schema?: string; status: ChangeStatus } | { kind: "add-column"; table: string; schema?: string; column: ColumnDescriptor; status: ChangeStatus } | { kind: "drop-column"; table: string; schema?: string; column: string; restore?: ColumnDescriptor; status: ChangeStatus } | { kind: "rename-column"; table: string; schema?: string; from: string; to: string; status: ChangeStatus } | { kind: "change-column-type"; table: string; schema?: string; column: string; from: SqlType; to: SqlType; status: ChangeStatus } | { kind: "change-column-nullable"; table: string; schema?: string; column: string; from: boolean; to: boolean; status: ChangeStatus } | { kind: "change-column-default"; table: string; schema?: string; column: string; from?: ColumnDefault; to?: ColumnDefault; status: ChangeStatus } | { kind: "add-index"; table: string; schema?: string; index: IndexDescriptor; status: ChangeStatus } | { kind: "drop-index"; table: string; schema?: string; index: string; restore?: IndexDescriptor; status: ChangeStatus } | { kind: "add-fk"; table: string; schema?: string; fk: FkDescriptor; status: ChangeStatus } | { kind: "drop-fk"; table: string; schema?: string; fk: string; restore?: FkDescriptor; status: ChangeStatus } | { kind: "add-check"; table: string; schema?: string; check: CheckDescriptor; status: ChangeStatus } | { kind: "drop-check"; table: string; schema?: string; check: string; restore?: CheckDescriptor; status: ChangeStatus } // Declared for v0.3, never produced in v0.1: | { kind: "create-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus } | { kind: "drop-view"; view: string; schema?: string; status: ChangeStatus; /** The view as it exists in the DB — lets the down migration recreate it. */ restore?: ViewDescriptor; /** * Relations a CASCADE would destroy. EXTERNAL dependents only: a managed view * this same migration recreates is filtered out (it comes back). Non-empty ⇒ * a plain DROP VIEW would fail at apply, and CASCADE would destroy objects we * do not manage — so this is blocked pending `allow.dropViewCascade`. */ dependents?: readonly DependentRelation[]; /** * This drop is the drop half of adopting an UNMANAGED view (no fingerprint) * whose shape changed too much for a legal `CREATE OR REPLACE` (#239). It is * paired with a `create-view`, so the recreate-pair rule would normally * auto-allow it — but clobbering (possibly hand-written) SQL still needs * explicit consent, so status.ts gates it on `allow.adoptView`, exactly like * the `replace-view` adopt path. */ unmanagedActual?: boolean; } | { kind: "replace-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus; /** The view as it exists in the DB — lets the down migration restore the old body. */ restore?: ViewDescriptor; /** * The DB view carries no MetaObjects fingerprint — hand-written, or created * before fingerprinting existed. We cannot tell which (Postgres deparses away * the text evidence), and overwriting a hand-written view destroys SQL no down * migration can recover. Blocked pending `allow.adoptView`. */ unmanagedActual?: boolean; }; export type ChangeKind = Change["kind"]; export interface ChangeStatus { state: "allowed" | "blocked"; blockedReason?: string; } // --------------------------------------------------------------------------- // diff() options // --------------------------------------------------------------------------- export interface AllowOptions { dropColumn?: boolean; dropTable?: boolean; /** Narrowing/lossy types only; widening always allowed regardless of this flag. */ typeChange?: boolean; dropIndex?: boolean; dropFk?: boolean; dropCheck?: boolean; /** * Gates a REAL view removal (present in the DB, absent from the model) — like * every other drop. The internal drop/create recreate pair the diff emits * around a column-altering change to a view's source table is NOT gated (the * view is re-created in the same migration). */ dropView?: boolean; /** * Gates `DROP VIEW ... CASCADE`. A view with dependents cannot be dropped plainly * (Postgres refuses), and CASCADE destroys every dependent — including views and * materialized views owned by OTHER applications, which this tool does not manage * and cannot restore. Strictly ADDITIONAL to `dropView`: `--allow drop-view` alone * never cascades, and a plain non-cascading DROP VIEW stays the emitted form * whenever there are no dependents, so Postgres itself backstops a stale * dependents snapshot rather than silently cascading. */ dropViewCascade?: boolean; /** * Gates overwriting an existing view that carries NO MetaObjects fingerprint — * i.e. taking ownership of it. It is either hand-written or predates * fingerprinting, and on Postgres those are indistinguishable. Every environment * upgrading from an older toolchain needs exactly one `--allow adopt-view` run to * stamp its existing views; after that they are fingerprinted and silent. */ adoptView?: boolean; /** Existing data must satisfy NOT NULL; diff cannot verify this. */ nullableToNotNull?: boolean; /** * Gates dropping a live Postgres auto-sequence DEFAULT (the `nextval(...)` * shape a legacy `serial`/`bigserial` column carries — see * pg-identity-default.ts) when the expected side declares NO identity at * all, i.e. `@generation` was never set. That silence is genuinely * ambiguous: it reads identically whether the author simply never got * around to declaring `@generation: increment`, or deliberately dropped it * to move the column off auto-increment (e.g. onto app-assigned ULIDs). * The diff cannot tell those apart, so it refuses instead of guessing — * this flag is how the author confirms the second reading and lets the * DROP DEFAULT through. (An expected side that DOES declare * `@generation: increment` never reaches this gate at all — diff/index.ts * skips the default-diff for a live auto-sequence default entirely.) */ dropIdentityDefault?: boolean; /** * Permits dropping an object the COMMITTED SNAPSHOT never contained — i.e. one * this toolchain never managed. Without it, such a drop is refused at generation * time, because the migration it would write cannot replay against a database * where that object never existed (#313). `classify.ts` already states the * doctrine: objects present in the DB but not the snapshot "must never be treated * as actionable drift or auto-dropped". * * The ONE field here read by the CLI's generation-time provenance guard rather * than by `diff()`'s status pass — `diff` compares metadata against introspection * and never sees the snapshot, which is precisely why the doctrine was not * enforced where it mattered. It lives in `AllowOptions` anyway so `--allow` keeps * ONE token list and ONE grant map (`ALLOW_TOKENS` / `ALLOW_TOKEN_MAP`, pinned * together by `cli/test/unit/allow-tokens-pinned.test.ts`): a second parallel * validation path for a single token is the exact drift that pin exists to catch. */ dropUnmanaged?: boolean; } export type AmbiguousChange = | { kind: "possible-column-rename"; table: string; from: { name: string; sqlType: SqlType }; to: { name: string; sqlType: SqlType }; } | { kind: "possible-table-rename"; from: { name: string; columnCount: number }; to: { name: string; columnCount: number }; columnOverlap: number; // 0..1 fraction }; export type AmbiguousResolution = "rename" | "drop+add" | "abort"; export type AmbiguousCallback = (q: AmbiguousChange) => Promise; export interface DiffResult { changes: Change[]; /** Subset of `changes` where status.state === "blocked"; convenience for CLI error messaging. */ blocked: Change[]; } // --------------------------------------------------------------------------- // emit() result // --------------------------------------------------------------------------- export interface EmitResult { up: string; down: string; /** * Tables rebuilt via the SQLite recreate-and-copy pattern. Empty for * postgres (in-place ALTER). The CLI uses this to pre-drop only the * views whose source tables are being recreated — SQLite's RENAME re- * parses dependent view definitions and errors if any reference the * mid-recreate source table. */ recreatedTables: ReadonlySet; } export type Dialect = "postgres" | "sqlite" | "d1";