//! SQLite database access via an async worker.
//!
//! The application talks to SQLite through a single
//! request/response channel. A dedicated OS thread owns the
//! `rusqlite::Connection` (which is `Send` but `!Sync` and uses
//! a synchronous API), receives `Request` messages, and replies
//! on per-request `oneshot` channels.
//!
//! This shape was chosen up front in Phase 2 of the parser/DB
//! iteration so that B3 (query timeout/cancellation) and U1
//! (snapshot capture) drop in without an architectural refactor.
//!
//! ## STRICT and foreign keys
//!
//! Per ADR-0002, every table is created with the `STRICT`
//! keyword and the connection-level `PRAGMA foreign_keys` is
//! enabled at open time.
//!
//! ## Error handling
//!
//! Database errors flow through `DbError`, which carries a
//! coarse `kind` to support the future friendly-error layer
//! (H1). For now `friendly_message()` is a passthrough; when H1
//! lands the body of that method becomes the translation table.

use std::path::Path;
use std::thread;

use rusqlite::Connection;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};

use crate::dsl::ColumnSpec;
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
    ChangeColumnMode, Command, CompareOp, Constraint, ConstraintKind, Expr, IndexSelector, Operand,
    Predicate, RelationshipSelector, RowFilter, SeedOverride, SeedOverrideKind, SqlForeignKey,
};
use crate::dsl::shortid;
use crate::dsl::types::Type;
use crate::dsl::value::{Bound, Value, ValueError};
use crate::mode::Mode;
use crate::output_render::{Alignment, render_diagnostic_table};
use crate::persistence::{
    CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema,
    SchemaSnapshot, TableCheck, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
};
use crate::project::{DATA_DIR, PROJECT_YAML};
use crate::type_change;
use crate::undo::{DEFAULT_RING_CAPACITY, SnapshotError, SnapshotMeta, SnapshotStore, Staged};

/// Inbox capacity. The worker is fast enough that this rarely
/// matters; `64` is a generous head-room for bursts.
const REQUEST_CHANNEL_CAPACITY: usize = 64;

/// In-process handle for the database. Cheap to clone.
#[derive(Debug, Clone)]
pub struct Database {
    inbox: mpsc::Sender<Request>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableDescription {
    pub name: String,
    pub columns: Vec<ColumnDescription>,
    /// Relationships where *this* table is the child (holds the
    /// FK column referencing another table).
    pub outbound_relationships: Vec<RelationshipEnd>,
    /// Relationships where *this* table is the parent (some
    /// other table's column references one of ours).
    pub inbound_relationships: Vec<RelationshipEnd>,
    /// User-created indexes on this table (ADR-0025).
    pub indexes: Vec<IndexInfo>,
    /// Table-level composite `UNIQUE (a, b, …)` constraints (ADR-0035
    /// §4a.2). Single-column UNIQUE rides on the column itself
    /// (`ColumnDescription::unique`); these are the multi-column ones.
    pub unique_constraints: Vec<Vec<String>>,
    /// Table-level `CHECK (…)` constraints with their optional name
    /// (ADR-0035 §4a.3 / §4g), in declaration order.
    pub check_constraints: Vec<crate::persistence::TableCheck>,
}

/// Structured payload for rendering one relationship's diagram.
///
/// ADR-0044: the relationship plus both endpoint table structures.
/// Built worker-side; rendered **App-side** (like `QueryPlan`) so the
/// diagram can be width-aware and styled.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelationshipDiagramData {
    /// The relationship itself (endpoints + referential actions).
    pub rel: crate::persistence::RelationshipSchema,
    /// FK-holder (the `n` side), drawn on the left.
    pub child: TableDescription,
    /// Referenced table (the `1` side), drawn on the right.
    pub parent: TableDescription,
}

/// One user-created index on a table (ADR-0025).
///
/// Read live from the engine's native catalog
/// (`pragma_index_list` / `pragma_index_info`); the playground
/// keeps no separate index metadata table. Only indexes with
/// origin `c` (a `CREATE INDEX` statement) are surfaced — the
/// automatic indexes backing primary keys and UNIQUE
/// constraints are not user indexes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexInfo {
    pub name: String,
    /// Indexed columns, in index order.
    pub columns: Vec<String>,
    pub unique: bool,
}

/// One end of a relationship as seen from the table being
/// described.
///
/// Used for both outbound (this table is the child, holding the
/// FK column) and inbound (this table is the parent being
/// referenced) sides; the field meanings flip per side.
/// `(outbound, inbound)` pair returned by
/// [`Database::read_relationships`].
pub type RelationshipsReply = Result<(Vec<RelationshipEnd>, Vec<RelationshipEnd>), DbError>;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelationshipEnd {
    /// User-facing name of the relationship (auto-generated or
    /// user-supplied at creation time).
    pub name: String,
    /// The other table involved.
    pub other_table: String,
    /// The column(s) on the other table (ordered, paired with
    /// `local_columns`; one element for single-column — ADR-0043).
    pub other_columns: Vec<String>,
    /// The column(s) on *this* table.
    pub local_columns: Vec<String>,
    pub on_delete: ReferentialAction,
    pub on_update: ReferentialAction,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnDescription {
    pub name: String,
    /// The user-facing type the column was declared as, recovered
    /// from our internal column-metadata table. Always populated
    /// for tables created through the DSL. `None` only for the
    /// edge case of a foreign-attached database whose tables we
    /// did not create — not achievable in the current flow.
    pub user_type: Option<Type>,
    /// The SQLite-side type as reported by `PRAGMA table_info`
    /// (e.g. `INTEGER`, `TEXT`). Kept for diagnostics and as a
    /// fall-back when `user_type` is not available; the UI
    /// prefers `user_type` when rendering.
    pub sqlite_type: String,
    pub notnull: bool,
    pub primary_key: bool,
    /// Carries a single-column `UNIQUE` constraint (ADR-0029).
    /// A PK column is not flagged here — the `primary_key`
    /// flag already conveys its implicit uniqueness.
    pub unique: bool,
    /// The column's `DEFAULT` expression as SQLite reports it,
    /// or `None` (ADR-0029).
    pub default: Option<String>,
    /// The column's `CHECK` constraint in compiled-SQL form,
    /// or `None` (ADR-0029).
    pub check: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DbError {
    Sqlite {
        message: String,
        kind: SqliteErrorKind,
    },
    Unsupported(String),
    InvalidValue(String),
    PersistenceFatal {
        operation: &'static str,
        path: std::path::PathBuf,
        message: String,
    },
    RebuildRowFailed {
        table: String,
        csv_path: std::path::PathBuf,
        row_number: usize,
        detail: String,
    },
    WorkerGone,
    Io(String),
}

impl std::fmt::Display for DbError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Sqlite { message, .. } => {
                f.write_str(&crate::t!("db.error.sqlite", message = message,))
            }
            Self::Unsupported(detail) => {
                f.write_str(&crate::t!("db.error.unsupported", detail = detail,))
            }
            Self::InvalidValue(detail) => {
                f.write_str(&crate::t!("db.error.invalid_value", detail = detail,))
            }
            Self::PersistenceFatal {
                operation,
                path,
                message,
            } => f.write_str(&crate::t!(
                "db.error.persistence_fatal",
                operation = operation,
                path = path.display(),
                message = message,
            )),
            Self::RebuildRowFailed {
                table,
                csv_path,
                row_number,
                detail,
            } => f.write_str(&crate::t!(
                "db.error.rebuild_row_failed",
                row_number = row_number,
                csv_path = csv_path.display(),
                table = table,
                detail = detail,
            )),
            Self::WorkerGone => f.write_str(&crate::t!("db.error.worker_gone")),
            Self::Io(detail) => f.write_str(&crate::t!("db.error.io", detail = detail,)),
        }
    }
}

impl std::error::Error for DbError {}

/// Result of a query / show-data call.
///
/// `None` cells render as NULL; `Some(s)` renders as the
/// string. `column_types` carries the user-facing type per
/// column (per ADR-0016 §2): the renderer uses it for
/// alignment, and future work uses it for type-aware cell
/// styling. `None` only for the edge case of a
/// foreign-attached database we did not create — not
/// achievable in normal use.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataResult {
    pub table_name: String,
    pub columns: Vec<String>,
    pub column_types: Vec<Option<Type>>,
    pub rows: Vec<Vec<Option<String>>>,
}

/// One row of an `EXPLAIN QUERY PLAN` result (ADR-0028 §2).
///
/// `id` / `parent` form the plan tree (`parent == 0` is a
/// top-level node); `detail` is the engine's verbatim step
/// description (`SCAN Customers`, `SEARCH … USING INDEX …`).
/// The `notused` column the engine also returns is dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExplainRow {
    pub id: i64,
    pub parent: i64,
    pub detail: String,
}

/// A captured query plan (ADR-0028 §2/§3).
///
/// `display_sql` is the standard-SQL form of the explained
/// statement — shown above the plan tree as part of the
/// simple → advanced bridge. `rows` are the plan-tree nodes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryPlan {
    pub display_sql: String,
    pub rows: Vec<ExplainRow>,
}

/// Outcome of a successful INSERT — a count plus the new row(s)
/// fetched immediately after so the user can see what landed
/// (auto-filled IDs, generated shortids, etc.).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InsertResult {
    pub rows_affected: usize,
    pub data: DataResult,
}

/// Outcome of a successful `seed` (ADR-0048).
///
/// `produced` is below `requested` when the unique-value space ran out
/// (D14 cap). `data` is a **capped preview** of the seeded rows (D18,
/// not the whole batch). `advisory_columns` names columns that were
/// filled with generic text but look like fixed value sets — enum-ish
/// names or un-derivable CHECKs (D12/D13) — so the render can nudge the
/// user toward choosing those values deliberately.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeedResult {
    pub table: String,
    pub requested: u64,
    pub produced: u64,
    pub data: DataResult,
    pub advisory_columns: Vec<String>,
}

/// Outcome of a successful `add column …`.
///
/// Carries the post-add structure (used for the auto-show that
/// follows DDL) plus zero or one pre-rendered `[client-side]`
/// note lines (ADR-0018 §9). Only the auto-fill paths
/// (`add column T: x (serial|shortid)` on a non-empty table)
/// produce notes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddColumnResult {
    pub description: TableDescription,
    pub client_side_notes: Vec<String>,
}

/// Outcome of a successful `drop column …` (ADR-0025).
///
/// `dropped_indexes` names any index removed by `--cascade`
/// because it covered the dropped column. Empty in the common
/// case (no covering index, or none to cascade); the runtime
/// renders one note line per entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DropColumnResult {
    pub description: TableDescription,
    pub dropped_indexes: Vec<String>,
}

/// Outcome of a successful `change column …` (ADR-0017 §6).
///
/// `description` is the post-rebuild table structure (used for
/// the auto-show that follows DDL). `client_side` carries the
/// pedagogical note that fires whenever the playground rewrote
/// any cell value before storing it — the moment that tells the
/// learner "the tool did this for you; raw SQL would need a
/// `CAST` or application code." `None` when the change was
/// pure metadata (storage class unchanged for every row).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeColumnTypeResult {
    pub description: TableDescription,
    pub client_side: Option<ClientSideNote>,
}

/// Counts feeding the `[client-side]` success line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientSideNote {
    /// Cells whose stored value differs from what the user
    /// originally inserted (any non-identity transformation,
    /// including pure storage-class changes like
    /// `Text("42")` → `Integer(42)`). Auto-filled cells (per
    /// ADR-0018) are NOT counted here — they're tracked in
    /// `auto_filled` separately so the success summary can
    /// distinguish "the tool transformed your data" from "the
    /// tool generated values where you had nulls."
    pub transformed: usize,
    /// Subset of `transformed` where information was discarded
    /// (only non-zero when `--force-conversion` was used).
    pub lossy: usize,
    /// Null cells filled with auto-generated values when the
    /// target type carries an auto-generation contract
    /// (`serial` / `shortid`). ADR-0018 §3 / §7.
    pub auto_filled: usize,
    /// What kind of value was auto-generated; drives the note
    /// wording (per ADR-0018 §9). `None` when `auto_filled` is
    /// zero.
    pub auto_fill_kind: Option<AutoFillKind>,
}

/// Whether an auto-fill emitted serial sequence values or
/// generated shortids.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoFillKind {
    Serial,
    ShortId,
}

/// Outcome of a successful UPDATE — a count plus the rows that
/// matched (and were updated). Captured by rowid so that even an
/// UPDATE which changes the WHERE-target column still finds the
/// post-update rows.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateResult {
    pub rows_affected: usize,
    pub data: DataResult,
}

/// Outcome of a successful DELETE — the directly-deleted-row
/// count plus any cascade effects observed in child tables.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteResult {
    pub rows_affected: usize,
    pub cascade: Vec<CascadeEffect>,
    /// Rows produced by a `RETURNING` clause (ADR-0033 §5, 3g).
    /// Empty (no columns, no rows) when the DELETE had no
    /// `RETURNING` — the renderer skips a column-less result, so the
    /// non-RETURNING path is unaffected. For SQL `DELETE … RETURNING`
    /// these are the rows as they were *before* deletion; the
    /// cascade summary surfaces alongside.
    pub data: DataResult,
}

/// One observed change in a child table caused by referential
/// action on a parent-side DELETE. Detected by row-count diffing
/// the child table immediately before and after the delete.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CascadeEffect {
    pub relationship_name: String,
    pub child_table: String,
    pub rows_changed: i64,
    pub action: ReferentialAction,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqliteErrorKind {
    /// `UNIQUE` constraint, including duplicate primary key.
    UniqueViolation,
    /// Referenced or operated-on table does not exist.
    NoSuchTable,
    /// Operated-on column does not exist.
    NoSuchColumn,
    /// Object (table, index, etc.) already exists.
    AlreadyExists,
    /// Catch-all.
    Other,
}

impl DbError {
    /// User-visible rendering of this error.
    ///
    /// Routes through the H1 friendly-error layer
    /// ([`crate::friendly::translate_error`], ADR-0019). With
    /// no context the translator falls back to abstract wording
    /// — for operation-tailored output, callers should
    /// construct a [`crate::friendly::TranslateContext`] and
    /// call the translator directly. This method exists for the
    /// many callsites where context isn't readily available
    /// (fatal-banner paths, fallback render paths) and the
    /// abstract wording is acceptable.
    #[must_use]
    pub fn friendly_message(&self) -> String {
        let ctx = crate::friendly::TranslateContext::default();
        crate::friendly::translate_error(self, &ctx).render()
    }

    fn from_rusqlite(err: rusqlite::Error) -> Self {
        let message = err.to_string();
        let kind = classify_sqlite_error(&err, &message);
        Self::Sqlite { message, kind }
    }

    fn from_persistence(err: PersistenceError) -> Self {
        Self::PersistenceFatal {
            operation: err.operation(),
            path: err.path().to_path_buf(),
            message: err.to_string(),
        }
    }

    /// Whether this error means the application cannot
    /// continue and must quit (per ADR-0015 §8). The runtime
    /// surfaces these as fatal banners.
    #[must_use]
    pub const fn is_fatal(&self) -> bool {
        matches!(
            self,
            Self::PersistenceFatal { .. } | Self::RebuildRowFailed { .. }
        )
    }
}

fn classify_sqlite_error(err: &rusqlite::Error, message: &str) -> SqliteErrorKind {
    use rusqlite::ErrorCode;
    if let rusqlite::Error::SqliteFailure(code, _) = err
        && code.code == ErrorCode::ConstraintViolation
    {
        return SqliteErrorKind::UniqueViolation;
    }
    let lowered = message.to_ascii_lowercase();
    if lowered.contains("no such table") {
        SqliteErrorKind::NoSuchTable
    } else if lowered.contains("no such column") {
        SqliteErrorKind::NoSuchColumn
    } else if lowered.contains("already exists") {
        SqliteErrorKind::AlreadyExists
    } else {
        SqliteErrorKind::Other
    }
}

/// Internal request type — kept private so the channel protocol
/// is not part of the public API.
#[derive(Debug)]
enum Request {
    CreateTable {
        name: String,
        columns: Vec<ColumnSpec>,
        primary_key: Vec<String>,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    /// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes
    /// structurally through `do_create_table`; `if_not_exists` turns
    /// an existing table into a no-op (`CreateOutcome::Skipped`, no
    /// snapshot) instead of an error (ADR-0035 §4).
    SqlCreateTable {
        name: String,
        columns: Vec<ColumnSpec>,
        primary_key: Vec<String>,
        unique_constraints: Vec<Vec<String>>,
        check_constraints: Vec<String>,
        foreign_keys: Vec<SqlForeignKey>,
        if_not_exists: bool,
        source: Option<String>,
        reply: oneshot::Sender<Result<CreateOutcome, DbError>>,
    },
    DropTable {
        name: String,
        source: Option<String>,
        reply: oneshot::Sender<Result<(), DbError>>,
    },
    /// Advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c).
    /// Executes through `do_drop_table`; `if_exists` turns an absent
    /// table into a no-op (`DropOutcome::Skipped`, no snapshot).
    SqlDropTable {
        name: String,
        if_exists: bool,
        source: Option<String>,
        reply: oneshot::Sender<Result<DropOutcome, DbError>>,
    },
    /// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name>` (ADR-0035 §4d).
    /// Executes through `do_drop_index`; `if_exists` turns an absent
    /// index into a no-op (`DropIndexOutcome::Skipped`, no snapshot).
    SqlDropIndex {
        name: String,
        if_exists: bool,
        source: Option<String>,
        reply: oneshot::Sender<Result<DropIndexOutcome, DbError>>,
    },
    /// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
    /// (ADR-0035 §4d). Executes through `do_add_index` (with `unique`);
    /// `if_not_exists` turns an existing index name into a no-op
    /// (`CreateIndexOutcome::Skipped`, no snapshot).
    SqlCreateIndex {
        name: Option<String>,
        table: String,
        columns: Vec<String>,
        unique: bool,
        if_not_exists: bool,
        source: Option<String>,
        reply: oneshot::Sender<Result<CreateIndexOutcome, DbError>>,
    },
    AddColumn {
        table: String,
        column: ColumnSpec,
        source: Option<String>,
        reply: oneshot::Sender<Result<AddColumnResult, DbError>>,
    },
    DropColumn {
        table: String,
        column: String,
        cascade: bool,
        source: Option<String>,
        reply: oneshot::Sender<Result<DropColumnResult, DbError>>,
    },
    RenameColumn {
        table: String,
        old: String,
        new: String,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    /// `ALTER TABLE <table> RENAME TO <new>` (ADR-0035 §6, 4h).
    RenameTable {
        table: String,
        new: String,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    ChangeColumnType {
        table: String,
        column: String,
        ty: Type,
        mode: ChangeColumnMode,
        source: Option<String>,
        reply: oneshot::Sender<Result<ChangeColumnTypeResult, DbError>>,
    },
    ListTables {
        reply: oneshot::Sender<Result<Vec<String>, DbError>>,
    },
    /// List every item of a schema kind (tables / relationships /
    /// indexes) as pre-formatted display lines for the `show
    /// <kind>` commands (V5). Read-only; formats in the worker
    /// from the same helpers the items panel and describe view use.
    ShowList {
        kind: crate::dsl::command::ShowListKind,
        /// `None` lists all items of the kind; `Some(name)` shows
        /// one named relationship/index's detail (V5a).
        name: Option<String>,
        reply: oneshot::Sender<Result<Vec<String>, DbError>>,
    },
    /// Structured data to render one relationship's diagram (ADR-0044
    /// §6): the relationship + both endpoint table structures, or
    /// `None` if no relationship by that name exists.
    ShowRelationship {
        name: String,
        reply: oneshot::Sender<Result<Option<RelationshipDiagramData>, DbError>>,
    },
    DescribeTable {
        name: String,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    AddRelationship {
        name: Option<String>,
        parent_table: String,
        parent_columns: Vec<String>,
        child_table: String,
        child_columns: Vec<String>,
        on_delete: ReferentialAction,
        on_update: ReferentialAction,
        create_fk: bool,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    CreateM2nRelationship {
        t1: String,
        t2: String,
        name: Option<String>,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    DropRelationship {
        selector: RelationshipSelector,
        source: Option<String>,
        reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
    },
    /// Create an index on a table (ADR-0025).
    AddIndex {
        name: Option<String>,
        table: String,
        columns: Vec<String>,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    /// Drop an index by name or by table + column set (ADR-0025).
    DropIndex {
        selector: IndexSelector,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    /// Add a column-level constraint to an existing column
    /// (ADR-0029 §2.2). The post-rebuild `TableDescription`
    /// flows back through the standard auto-show path.
    AddConstraint {
        table: String,
        column: String,
        constraint: Constraint,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    /// Remove a column-level constraint from an existing column
    /// (ADR-0029 §2.2).
    DropConstraint {
        table: String,
        column: String,
        kind: ConstraintKind,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    /// `ALTER TABLE … ALTER COLUMN <col> SET DEFAULT <expr>` — set a
    /// column's default to raw SQL text (ADR-0035 Amendment 2). Distinct
    /// from `AddConstraint(Default(Value))` because the advanced default
    /// is raw `sql_expr` text with no typed `Value`.
    SetColumnDefault {
        table: String,
        column: String,
        default_sql: String,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    /// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
    /// table-level CHECK, named or unnamed (ADR-0035 §4g).
    AlterAddTableCheck {
        table: String,
        name: Option<String>,
        expr_sql: String,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    /// `ALTER TABLE … ADD UNIQUE (<col>, …)` — a composite UNIQUE
    /// constraint (ADR-0035 §4g).
    AlterAddUnique {
        table: String,
        columns: Vec<String>,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    /// `ALTER TABLE … DROP CONSTRAINT <name>` — drop a named table-level
    /// CHECK or a named FK (ADR-0035 §4g).
    AlterDropConstraint {
        table: String,
        name: String,
        source: Option<String>,
        reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
    },
    /// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (…)
    /// REFERENCES …` — a relationship on an existing table (ADR-0035 §4g).
    AlterAddForeignKey {
        child_table: String,
        name: Option<String>,
        fk: Box<SqlForeignKey>,
        source: Option<String>,
        reply: oneshot::Sender<Result<TableDescription, DbError>>,
    },
    Insert {
        table: String,
        columns: Option<Vec<String>>,
        values: Vec<Value>,
        source: Option<String>,
        reply: oneshot::Sender<Result<InsertResult, DbError>>,
    },
    /// Populate a table with generated fake data (ADR-0048). One undo
    /// snapshot wraps the whole seed via `snapshot_then`.
    Seed {
        table: String,
        target_column: Option<String>,
        count: Option<u64>,
        overrides: Vec<SeedOverride>,
        rng_seed: Option<u64>,
        source: Option<String>,
        reply: oneshot::Sender<Result<SeedResult, DbError>>,
    },
    Update {
        table: String,
        assignments: Vec<(String, Value)>,
        filter: RowFilter,
        source: Option<String>,
        reply: oneshot::Sender<Result<UpdateResult, DbError>>,
    },
    Delete {
        table: String,
        filter: RowFilter,
        source: Option<String>,
        reply: oneshot::Sender<Result<DeleteResult, DbError>>,
    },
    QueryData {
        table: String,
        filter: Option<Expr>,
        limit: Option<u64>,
        reply: oneshot::Sender<Result<DataResult, DbError>>,
    },
    /// Run a SQL `SELECT` typed by the user in advanced mode
    /// (ADR-0030 §6, ADR-0031). The grammar walker has already
    /// validated `sql` is in the supported subset; the worker
    /// prepares and runs the statement and returns the rows as
    /// a [`DataResult`] (with no playground type information per
    /// ADR-0030 §6 — computed columns render with neutral
    /// alignment). The literal submitted line is journalled to
    /// `history.log` at the dispatch layer for replay (ADR-0030 §11,
    /// ADR-0052).
    RunSelect {
        sql: String,
        reply: oneshot::Sender<Result<DataResult, DbError>>,
    },
    /// Run a validated SQL `INSERT` typed in advanced mode
    /// (ADR-0033 §1, sub-phase 3b). The grammar walker has
    /// validated `sql` is in the supported subset; the worker
    /// executes it as text, re-persists the target table's CSV
    /// (ADR-0030 §11), and appends the literal line to
    /// `history.log`. `target_table` comes from the parse so the
    /// worker re-persists the right CSV without re-parsing.
    RunSqlInsert {
        sql: String,
        source: Option<String>,
        target_table: String,
        listed_columns: Vec<String>,
        row_source: String,
        returning: bool,
        /// Captured literal `VALUES` (per row, per position; `None` =
        /// expression) for app-level type validation before the verbatim
        /// insert (ADR-0036 Phase 1).
        literal_rows: Vec<Vec<Option<Value>>>,
        reply: oneshot::Sender<Result<InsertResult, DbError>>,
    },
    /// Run a grammar-validated SQL `UPDATE` (ADR-0033 §2). The
    /// worker executes `sql` as text, re-persists `target_table`'s
    /// CSV (ADR-0030 §11), and appends the literal line to
    /// `history.log`.
    RunSqlUpdate {
        sql: String,
        source: Option<String>,
        target_table: String,
        returning: bool,
        /// Captured literal `SET col = <literal>` values (`(col, None)` =
        /// expression RHS) for app-level type validation before the
        /// verbatim update (ADR-0036 Phase 2).
        set_literals: Vec<(String, Option<Value>)>,
        reply: oneshot::Sender<Result<UpdateResult, DbError>>,
    },
    /// Run a grammar-validated SQL `DELETE` (ADR-0033 §1/§7). The
    /// worker executes `sql` as text, detects FK cascade by
    /// row-count diffing the inbound children (Amendment 2),
    /// re-persists `target_table`'s CSV plus every cascade-affected
    /// child (ADR-0030 §11), and appends the literal line to
    /// `history.log`.
    RunSqlDelete {
        sql: String,
        source: Option<String>,
        target_table: String,
        returning: bool,
        reply: oneshot::Sender<Result<DeleteResult, DbError>>,
    },
    /// Capture the query plan for an explainable command via
    /// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
    /// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
    /// never executes it, so this is read-only even for the
    /// destructive variants.
    ExplainPlan {
        query: Command,
        reply: oneshot::Sender<Result<QueryPlan, DbError>>,
    },
    /// Rebuild the database from `project.yaml` + `data/`
    /// (ADR-0015 §7). Used by the runtime when the `.db` file
    /// is missing on project open and by the explicit
    /// `rebuild` app-level command (Iteration 4).
    RebuildFromText {
        project_path: std::path::PathBuf,
        source: Option<String>,
        reply: oneshot::Sender<Result<(), DbError>>,
    },
    /// Read both directions of FK relationships for `table`.
    /// Returns `(outbound, inbound)` — outbound = rows where
    /// `table` is the child (has FK columns pointing elsewhere);
    /// inbound = rows where `table` is the parent (referenced
    /// by other tables). Used by the friendly-error layer's
    /// runtime enrichment (ADR-0019 §6).
    ReadRelationships {
        table: String,
        reply: oneshot::Sender<RelationshipsReply>,
    },
    /// Find rows in `table` where `column` matches `value`.
    /// Capped at `limit` rows. Used by the friendly-error
    /// layer's row-pinpoint diagnostic (ADR-0019 §6, ADR-0017 §7).
    /// Best-effort: returns empty rows on any failure (no row
    /// matched, schema gone, type mismatch on bind, etc.).
    FindRowsMatching {
        table: String,
        column: String,
        value: Value,
        limit: usize,
        reply: oneshot::Sender<Result<DataResult, DbError>>,
    },
    /// List schema entity names for a given identifier slot
    /// (ADR-0022 §9). Used by the completion engine to offer
    /// candidates for `TableName` / `Column` /
    /// `RelationshipName` slots. `NewName` is rejected at
    /// the caller — schema has nothing to offer for new
    /// names — and never reaches the worker.
    ///
    /// Returns names in stable (alphabetical) order, no
    /// duplicates. The reply is small even for projects with
    /// hundreds of tables/columns.
    ListNamesFor {
        source: crate::dsl::grammar::IdentSource,
        reply: oneshot::Sender<Result<Vec<String>, DbError>>,
    },
    /// All relationships as full schema records (name, parent/child
    /// tables + columns, referential actions). Feeds the sidebar
    /// relationships panel (ADR-0046 DB2); the walker only needs the
    /// names, which `ListNamesFor` already provides.
    ReadAllRelationships {
        reply: oneshot::Sender<Result<Vec<RelationshipSchema>, DbError>>,
    },
    /// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
    /// Replies with the metadata of the command that was undone, or
    /// `None` if there is nothing to undo (or undo is disabled).
    Undo {
        reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
    },
    /// Re-apply the most recently undone snapshot. `None` if there is
    /// nothing to redo.
    Redo {
        reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
    },
    /// Read — without restoring — the snapshot `undo` would restore.
    /// Used to build the confirmation prompt. `None` if the ring is
    /// empty or undo is disabled.
    PeekUndo {
        reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
    },
    /// Read — without restoring — the snapshot `redo` would restore.
    PeekRedo {
        reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
    },
    /// Open a batch (ADR-0006 Amendment 1): take one boundary
    /// snapshot for the whole batch and suppress per-command
    /// snapshots until `EndBatch`. Used by `replay` so a multi-command
    /// replay is a single undo step. `source` is the batch command.
    BeginBatch {
        source: Option<String>,
        reply: oneshot::Sender<()>,
    },
    /// Close a batch: finalise the boundary snapshot into the ring if
    /// any mutation committed during the batch, else discard it.
    EndBatch { reply: oneshot::Sender<()> },
    /// Record the current input mode and persist it to
    /// `project.yaml` (ADR-0015 mode-restore amendment, issue
    /// #14). Sent at boot / project-switch to seed the mode and
    /// whenever the user changes mode mid-session.
    SetMode {
        mode: Mode,
        reply: oneshot::Sender<Result<(), DbError>>,
    },
}

impl Database {
    /// Open a database without per-command persistence.
    ///
    /// The path may be a filesystem location or `":memory:"`
    /// for an ephemeral in-memory database. The connection is
    /// moved onto a dedicated worker thread. With no
    /// persistence handle, the YAML/CSV/`history.log` writes
    /// are skipped — useful for unit tests that exercise the
    /// SQLite layer in isolation.
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, DbError> {
        Self::open_inner(path, None, false)
    }

    /// Open a database with per-command persistence wired in
    /// (ADR-0015 §6). Every successful user-issued mutation
    /// writes through to `project.yaml`, the affected
    /// `data/<table>.csv` files, and `history.log` *before*
    /// the SQLite tx commits.
    pub fn open_with_persistence<P: AsRef<Path>>(
        path: P,
        persistence: Persistence,
    ) -> Result<Self, DbError> {
        Self::open_inner(path, Some(persistence), false)
    }

    /// Open with per-command persistence *and* the undo/snapshot ring
    /// (ADR-0006 Amendment 1). `undo_enabled` is `false` under the
    /// `--no-undo` CLI flag, in which case no snapshots are taken.
    pub fn open_with_persistence_and_undo<P: AsRef<Path>>(
        path: P,
        persistence: Persistence,
        undo_enabled: bool,
    ) -> Result<Self, DbError> {
        Self::open_inner(path, Some(persistence), undo_enabled)
    }

    fn open_inner<P: AsRef<Path>>(
        path: P,
        persistence: Option<Persistence>,
        undo_enabled: bool,
    ) -> Result<Self, DbError> {
        let path_display = path.as_ref().to_string_lossy().into_owned();
        let conn = match path.as_ref().to_str() {
            Some(":memory:") => Connection::open_in_memory(),
            _ => Connection::open(path.as_ref()),
        }
        .map_err(DbError::from_rusqlite)?;
        info!(path = %path_display, "opened database");
        configure_connection(&conn).map_err(DbError::from_rusqlite)?;

        // The undo ring needs the project directory; it is only
        // available when persistence is wired and undo is enabled.
        let snapshots = if undo_enabled {
            persistence
                .as_ref()
                .map(|p| SnapshotStore::new(p.project_path(), DEFAULT_RING_CAPACITY))
        } else {
            None
        };
        if let Some(store) = &snapshots {
            // Sweep crash leftovers (`.staging/`, orphan payloads).
            if let Err(e) = store.cleanup() {
                warn!(error = %e, "undo snapshot cleanup on open failed");
            }
            info!("undo snapshots enabled");
        }

        let (tx, rx) = mpsc::channel::<Request>(REQUEST_CHANNEL_CAPACITY);
        thread::Builder::new()
            .name("rdbms-db-worker".to_string())
            .spawn(move || worker_loop(conn, persistence, snapshots, rx))
            .map_err(|e| DbError::Io(e.to_string()))?;

        Ok(Self { inbox: tx })
    }

    pub async fn create_table(
        &self,
        name: String,
        columns: Vec<ColumnSpec>,
        primary_key: Vec<String>,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::CreateTable {
            name,
            columns,
            primary_key,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes
    /// structurally; returns whether the table was created or skipped
    /// (the `IF NOT EXISTS` no-op, ADR-0035 §4).
    #[allow(clippy::too_many_arguments)]
    pub async fn sql_create_table(
        &self,
        name: String,
        columns: Vec<ColumnSpec>,
        primary_key: Vec<String>,
        unique_constraints: Vec<Vec<String>>,
        check_constraints: Vec<String>,
        foreign_keys: Vec<SqlForeignKey>,
        if_not_exists: bool,
        source: Option<String>,
    ) -> Result<CreateOutcome, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::SqlCreateTable {
            name,
            columns,
            primary_key,
            unique_constraints,
            check_constraints,
            foreign_keys,
            if_not_exists,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn drop_table(&self, name: String, source: Option<String>) -> Result<(), DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::DropTable {
            name,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c).
    /// Returns whether the table was dropped or skipped (the `IF EXISTS`
    /// no-op on an absent table).
    pub async fn sql_drop_table(
        &self,
        name: String,
        if_exists: bool,
        source: Option<String>,
    ) -> Result<DropOutcome, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::SqlDropTable {
            name,
            if_exists,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Advanced-mode SQL `DROP INDEX [IF EXISTS]` (ADR-0035 §4d).
    /// Returns whether the index was dropped (with the affected table's
    /// structure) or skipped (the `IF EXISTS` no-op on an absent index).
    pub async fn sql_drop_index(
        &self,
        name: String,
        if_exists: bool,
        source: Option<String>,
    ) -> Result<DropIndexOutcome, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::SqlDropIndex {
            name,
            if_exists,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
    /// (ADR-0035 §4d). Returns whether the index was created (with the
    /// affected table's structure) or skipped (the `IF NOT EXISTS` no-op
    /// on an existing index name, carrying the resolved name).
    pub async fn sql_create_index(
        &self,
        name: Option<String>,
        table: String,
        columns: Vec<String>,
        unique: bool,
        if_not_exists: bool,
        source: Option<String>,
    ) -> Result<CreateIndexOutcome, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::SqlCreateIndex {
            name,
            table,
            columns,
            unique,
            if_not_exists,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn add_column(
        &self,
        table: String,
        column: ColumnSpec,
        source: Option<String>,
    ) -> Result<AddColumnResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::AddColumn {
            table,
            column,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn drop_column(
        &self,
        table: String,
        column: String,
        cascade: bool,
        source: Option<String>,
    ) -> Result<DropColumnResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::DropColumn {
            table,
            column,
            cascade,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn add_index(
        &self,
        name: Option<String>,
        table: String,
        columns: Vec<String>,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::AddIndex {
            name,
            table,
            columns,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn drop_index(
        &self,
        selector: IndexSelector,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::DropIndex {
            selector,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Add a column-level constraint to an existing column
    /// (ADR-0029 §2.2).
    pub async fn add_constraint(
        &self,
        table: String,
        column: String,
        constraint: Constraint,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::AddConstraint {
            table,
            column,
            constraint,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Remove a column-level constraint from an existing column
    /// (ADR-0029 §2.2).
    pub async fn drop_constraint(
        &self,
        table: String,
        column: String,
        kind: ConstraintKind,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::DropConstraint {
            table,
            column,
            kind,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// `ALTER TABLE … ALTER COLUMN <col> SET DEFAULT <expr>` — set the
    /// column's default to raw SQL text (ADR-0035 Amendment 2).
    pub async fn set_column_default(
        &self,
        table: String,
        column: String,
        default_sql: String,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::SetColumnDefault {
            table,
            column,
            default_sql,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
    /// table-level CHECK (ADR-0035 §4g).
    pub async fn alter_add_table_check(
        &self,
        table: String,
        name: Option<String>,
        expr_sql: String,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::AlterAddTableCheck {
            table,
            name,
            expr_sql,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// `ALTER TABLE … ADD UNIQUE (<col>, …)` — a composite UNIQUE
    /// constraint (ADR-0035 §4g).
    pub async fn alter_add_unique(
        &self,
        table: String,
        columns: Vec<String>,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::AlterAddUnique {
            table,
            columns,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// `ALTER TABLE … DROP CONSTRAINT <name>` — drop a named table-level
    /// CHECK or a named FK (ADR-0035 §4g).
    pub async fn alter_drop_constraint(
        &self,
        table: String,
        name: String,
        source: Option<String>,
    ) -> Result<Option<TableDescription>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::AlterDropConstraint {
            table,
            name,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (…)
    /// REFERENCES …` — add a relationship to an existing table (ADR-0035
    /// §4g).
    pub async fn alter_add_foreign_key(
        &self,
        child_table: String,
        name: Option<String>,
        fk: SqlForeignKey,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::AlterAddForeignKey {
            child_table,
            name,
            fk: Box::new(fk),
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn rename_column(
        &self,
        table: String,
        old: String,
        new: String,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::RenameColumn {
            table,
            old,
            new,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// `ALTER TABLE <table> RENAME TO <new>` (ADR-0035 §6, 4h).
    pub async fn rename_table(
        &self,
        table: String,
        new: String,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::RenameTable {
            table,
            new,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn change_column_type(
        &self,
        table: String,
        column: String,
        ty: Type,
        mode: ChangeColumnMode,
        source: Option<String>,
    ) -> Result<ChangeColumnTypeResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::ChangeColumnType {
            table,
            column,
            mode,
            ty,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn list_tables(&self) -> Result<Vec<String>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::ListTables { reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Pre-formatted display lines for `show tables` /
    /// `show relationships` / `show indexes` (V5). Read-only.
    pub async fn show_list(
        &self,
        kind: crate::dsl::command::ShowListKind,
        name: Option<String>,
    ) -> Result<Vec<String>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::ShowList { kind, name, reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Structured data to render one relationship's diagram (ADR-0044):
    /// the relationship + both endpoint table structures, or `None` if
    /// no relationship by that name exists.
    pub async fn show_relationship(
        &self,
        name: String,
    ) -> Result<Option<RelationshipDiagramData>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::ShowRelationship { name, reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn describe_table(&self, name: String) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::DescribeTable { name, reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    #[allow(clippy::too_many_arguments)]
    pub async fn add_relationship(
        &self,
        name: Option<String>,
        parent_table: String,
        parent_columns: Vec<String>,
        child_table: String,
        child_columns: Vec<String>,
        on_delete: ReferentialAction,
        on_update: ReferentialAction,
        create_fk: bool,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::AddRelationship {
            name,
            parent_table,
            parent_columns,
            child_table,
            child_columns,
            on_delete,
            on_update,
            create_fk,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Generate a junction table for an m:n relationship between
    /// `t1` and `t2` (ADR-0045 / C4). One worker request = one undo
    /// step (the junction + both relationships are built in a single
    /// `do_create_table`).
    pub async fn create_m2n_relationship(
        &self,
        t1: String,
        t2: String,
        name: Option<String>,
        source: Option<String>,
    ) -> Result<TableDescription, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::CreateM2nRelationship {
            t1,
            t2,
            name,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn drop_relationship(
        &self,
        selector: RelationshipSelector,
        source: Option<String>,
    ) -> Result<Option<TableDescription>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::DropRelationship {
            selector,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn insert(
        &self,
        table: String,
        columns: Option<Vec<String>>,
        values: Vec<Value>,
        source: Option<String>,
    ) -> Result<InsertResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::Insert {
            table,
            columns,
            values,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Populate a table with generated fake data (ADR-0048, SD1/SD2).
    pub async fn seed(
        &self,
        table: String,
        target_column: Option<String>,
        count: Option<u64>,
        overrides: Vec<SeedOverride>,
        rng_seed: Option<u64>,
        source: Option<String>,
    ) -> Result<SeedResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::Seed {
            table,
            target_column,
            count,
            overrides,
            rng_seed,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn update(
        &self,
        table: String,
        assignments: Vec<(String, Value)>,
        filter: RowFilter,
        source: Option<String>,
    ) -> Result<UpdateResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::Update {
            table,
            assignments,
            filter,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn delete(
        &self,
        table: String,
        filter: RowFilter,
        source: Option<String>,
    ) -> Result<DeleteResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::Delete {
            table,
            filter,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Rebuild the database from `project.yaml` + `data/`
    /// (ADR-0015 §7).
    ///
    /// Called by the runtime on a missing `.db` at startup
    /// (with `source = None`, no history entry) and by the
    /// explicit `rebuild` app-level command (with
    /// `source = Some("rebuild")`, which appends to
    /// `history.log` on success).
    pub async fn rebuild_from_text(
        &self,
        project_path: std::path::PathBuf,
        source: Option<String>,
    ) -> Result<(), DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::RebuildFromText {
            project_path,
            source,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    pub async fn query_data(
        &self,
        table: String,
        filter: Option<Expr>,
        limit: Option<u64>,
    ) -> Result<DataResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::QueryData {
            table,
            filter,
            limit,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Run a validated SQL `SELECT` and return the rows
    /// (ADR-0030 §6, ADR-0031). `sql` is the grammar-validated
    /// statement text; the literal submitted line is journalled
    /// at the dispatch layer (ADR-0052).
    pub async fn run_select(&self, sql: String) -> Result<DataResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::RunSelect { sql, reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Run a validated SQL `INSERT` and return the affected-row
    /// count plus the inserted rows (ADR-0033 §1, sub-phase 3b).
    /// `sql` is the grammar-validated statement text; `source` is
    /// the literal submitted line for `history.log`; `target_table`
    /// is the parsed target whose CSV is re-persisted.
    /// Run a grammar-validated SQL `INSERT` with **no** captured literals
    /// (no app-level value validation — the verbatim ADR-0033 path). Used
    /// by worker-level callers that build the statement directly. The
    /// runtime, which has a parsed command, uses
    /// [`Self::run_sql_insert_with_literals`] instead so the literals are
    /// validated (ADR-0036 Phase 1).
    pub async fn run_sql_insert(
        &self,
        sql: String,
        source: Option<String>,
        target_table: String,
        listed_columns: Vec<String>,
        row_source: String,
        returning: bool,
    ) -> Result<InsertResult, DbError> {
        self.run_sql_insert_with_literals(
            sql,
            source,
            target_table,
            listed_columns,
            row_source,
            returning,
            Vec::new(),
        )
        .await
    }

    /// As [`Self::run_sql_insert`], plus the literal `VALUES` captured at
    /// parse (per row, per position; `None` = expression) so the worker
    /// can validate each literal against its column type before the
    /// (still verbatim) insert and the error layer can name the offending
    /// value (ADR-0036 Phase 1).
    #[allow(clippy::too_many_arguments)]
    pub async fn run_sql_insert_with_literals(
        &self,
        sql: String,
        source: Option<String>,
        target_table: String,
        listed_columns: Vec<String>,
        row_source: String,
        returning: bool,
        literal_rows: Vec<Vec<Option<Value>>>,
    ) -> Result<InsertResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::RunSqlInsert {
            sql,
            source,
            target_table,
            listed_columns,
            row_source,
            returning,
            literal_rows,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Run a validated SQL `UPDATE` with **no** captured literals (no
    /// app-level value validation — the verbatim ADR-0033 path). Used by
    /// worker-level callers that build the statement directly. The
    /// runtime, which has a parsed command, uses
    /// [`Self::run_sql_update_with_literals`] instead so the `SET`
    /// literals are validated (ADR-0036 Phase 2). `sql` is the
    /// grammar-validated statement text; `source` is the literal
    /// submitted line for `history.log`; `target_table` is the parsed
    /// target whose CSV is re-persisted.
    pub async fn run_sql_update(
        &self,
        sql: String,
        source: Option<String>,
        target_table: String,
        returning: bool,
    ) -> Result<UpdateResult, DbError> {
        self.run_sql_update_with_literals(sql, source, target_table, returning, Vec::new())
            .await
    }

    /// As [`Self::run_sql_update`], plus the literal `SET` values captured
    /// at parse (`(col, None)` = expression RHS) so the worker can
    /// validate each literal against its column type before the (still
    /// verbatim) update and the error layer can name the offending value
    /// (ADR-0036 Phase 2).
    pub async fn run_sql_update_with_literals(
        &self,
        sql: String,
        source: Option<String>,
        target_table: String,
        returning: bool,
        set_literals: Vec<(String, Option<Value>)>,
    ) -> Result<UpdateResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::RunSqlUpdate {
            sql,
            source,
            target_table,
            returning,
            set_literals,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Run a validated SQL `DELETE` and return the affected-row
    /// count plus any cascade effects (ADR-0033 §1/§7, sub-phase
    /// 3f). `sql` is the grammar-validated statement text; `source`
    /// is the literal submitted line for `history.log`;
    /// `target_table` is the parsed target whose CSV (and whose
    /// cascade-affected children's CSVs) are re-persisted.
    pub async fn run_sql_delete(
        &self,
        sql: String,
        source: Option<String>,
        target_table: String,
        returning: bool,
    ) -> Result<DeleteResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::RunSqlDelete {
            sql,
            source,
            target_table,
            returning,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Capture the query plan for an explainable command
    /// (ADR-0028 §2). The wrapped command is not executed —
    /// `EXPLAIN QUERY PLAN` only inspects how the engine would
    /// locate the rows — so this is safe even for `update` /
    /// `delete`.
    pub async fn explain_query_plan(&self, query: Command) -> Result<QueryPlan, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::ExplainPlan { query, reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Read both directions of FK relationships for `table`.
    /// Used by the runtime's friendly-error enrichment to
    /// resolve parent / child table names (ADR-0019 §6).
    pub async fn read_relationships(&self, table: String) -> RelationshipsReply {
        let (reply, recv) = oneshot::channel();
        self.send(Request::ReadRelationships { table, reply })
            .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Pinpoint rows in `table` where `column` matches `value`.
    /// Used by the runtime's friendly-error enrichment to
    /// surface offending rows after a UNIQUE / FK violation
    /// (ADR-0019 §6, ADR-0017 §7). Capped at `limit`.
    pub async fn find_rows_matching(
        &self,
        table: String,
        column: String,
        value: Value,
        limit: usize,
    ) -> Result<DataResult, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::FindRowsMatching {
            table,
            column,
            value,
            limit,
            reply,
        })
        .await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// List schema entity names for an identifier source
    /// (ADR-0022 §9, ADR-0024 §architecture).
    ///
    /// Returns alphabetised, deduplicated names suitable for
    /// the completion menu:
    ///   - `IdentSource::Tables` → user tables (filters
    ///     `__rdbms_*` internal tables);
    ///   - `IdentSource::Columns` → distinct column names
    ///     across all user tables (v1 simplification — no
    ///     table-context binding);
    ///   - `IdentSource::Relationships` → relationship names
    ///     from the metadata table;
    ///   - `IdentSource::NewName`, `Types`, `Free` → returns
    ///     `Ok(vec![])` immediately without a worker round-trip
    ///     (the user invents these names, or the source is
    ///     synthetic).
    pub async fn list_names_for(
        &self,
        source: crate::dsl::grammar::IdentSource,
    ) -> Result<Vec<String>, DbError> {
        if !source.completes_from_schema() {
            return Ok(Vec::new());
        }
        let (reply, recv) = oneshot::channel();
        self.send(Request::ListNamesFor { source, reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// All relationships as full schema records, for the sidebar
    /// relationships panel (ADR-0046 DB2).
    pub async fn read_all_relationships(&self) -> Result<Vec<RelationshipSchema>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::ReadAllRelationships { reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
    /// `Ok(Some(meta))` reports the command that was undone;
    /// `Ok(None)` means nothing to undo (or undo is disabled).
    pub async fn undo(&self) -> Result<Option<SnapshotMeta>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::Undo { reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Re-apply the most recently undone snapshot. `Ok(None)` means
    /// nothing to redo.
    pub async fn redo(&self) -> Result<Option<SnapshotMeta>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::Redo { reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Metadata of the snapshot `undo` would restore, without
    /// restoring it — for the confirmation prompt.
    pub async fn peek_undo(&self) -> Result<Option<SnapshotMeta>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::PeekUndo { reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Metadata of the snapshot `redo` would restore.
    pub async fn peek_redo(&self) -> Result<Option<SnapshotMeta>, DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::PeekRedo { reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    /// Open a batch so a multi-command operation (`replay`, future
    /// batch commands) records a single undo step (ADR-0006
    /// Amendment 1). Pair with [`Database::end_batch`]. `source` is
    /// the batch command text recorded on the boundary snapshot.
    pub async fn begin_batch(&self, source: Option<String>) -> Result<(), DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::BeginBatch { source, reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)
    }

    /// Close a batch opened with [`Database::begin_batch`]. The
    /// boundary snapshot is kept iff a mutation committed during the
    /// batch.
    pub async fn end_batch(&self) -> Result<(), DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::EndBatch { reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)
    }

    /// Record the current input mode and persist it to
    /// `project.yaml` (ADR-0015 mode-restore amendment, issue
    /// #14). Idempotent and cheap; a no-op for databases opened
    /// without persistence.
    pub async fn set_mode(&self, mode: Mode) -> Result<(), DbError> {
        let (reply, recv) = oneshot::channel();
        self.send(Request::SetMode { mode, reply }).await?;
        recv.await.map_err(|_| DbError::WorkerGone)?
    }

    async fn send(&self, req: Request) -> Result<(), DbError> {
        self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
    }
}

/// Internal table tracking the user-facing type each column was
/// declared with. The structure view consults this so users see
/// the names they typed (`serial`, `date`) rather than SQLite's
/// erased forms (`INTEGER`, `TEXT`) — closing a Q3 / ADR-0005
/// promise. Track 2's project file format is the long-term home
/// for this metadata; this table is the in-database mirror that
/// makes round-trip rendering work today.
const META_TABLE: &str = "__rdbms_playground_columns";
const REL_TABLE: &str = "__rdbms_playground_relationships";
/// Single-row key/value table that carries project metadata
/// the YAML serializer needs to round-trip — currently
/// `created_at`. Created on first connect and only ever
/// written by us; the user never touches it directly.
const META_PROJECT_TABLE: &str = "__rdbms_playground_meta";
/// Table-level `CHECK (<expr>)` constraints (ADR-0035 §4a.3). The
/// engine exposes no PRAGMA for CHECK constraints, so — unlike UNIQUE /
/// PK / FK, which are read back from PRAGMA — a table-level CHECK has no
/// engine-readable home and this table is its source of truth. One row
/// per CHECK, ordered by `seq` (declaration order).
const CHECK_TABLE: &str = "__rdbms_playground_table_checks";

fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
    conn.execute_batch(&format!(
        "PRAGMA foreign_keys = ON;\n\
         CREATE TABLE IF NOT EXISTS {META_TABLE} (\n\
             table_name TEXT NOT NULL,\n\
             column_name TEXT NOT NULL,\n\
             user_type TEXT NOT NULL,\n\
             check_expr TEXT,\n\
             PRIMARY KEY (table_name, column_name)\n\
         ) STRICT;\n\
         CREATE TABLE IF NOT EXISTS {REL_TABLE} (\n\
             name TEXT NOT NULL UNIQUE,\n\
             parent_table TEXT NOT NULL,\n\
             parent_column TEXT NOT NULL,\n\
             child_table TEXT NOT NULL,\n\
             child_column TEXT NOT NULL,\n\
             on_delete TEXT NOT NULL,\n\
             on_update TEXT NOT NULL,\n\
             PRIMARY KEY (child_table, child_column)\n\
         ) STRICT;\n\
         CREATE TABLE IF NOT EXISTS {CHECK_TABLE} (\n\
             table_name TEXT NOT NULL,\n\
             seq INTEGER NOT NULL,\n\
             check_expr TEXT NOT NULL,\n\
             name TEXT,\n\
             PRIMARY KEY (table_name, seq)\n\
         ) STRICT;\n\
         CREATE TABLE IF NOT EXISTS {META_PROJECT_TABLE} (\n\
             key TEXT NOT NULL PRIMARY KEY,\n\
             value TEXT NOT NULL\n\
         ) STRICT;"
    ))?;
    // Seed `created_at` once. INSERT OR IGNORE keeps the value
    // stable across re-opens of the same database.
    conn.execute(
        &format!(
            "INSERT OR IGNORE INTO {META_PROJECT_TABLE} (key, value) VALUES ('created_at', ?1)"
        ),
        [iso8601_now()],
    )?;
    Ok(())
}

/// Current UTC time as ISO-8601 with second precision.
/// Duplicates the helper in `persistence::history` rather
/// than depending on it, since this code path runs at
/// connection setup before the rest of the worker is up.
fn iso8601_now() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let day_secs = secs.rem_euclid(86_400);
    let h = day_secs / 3600;
    let m = (day_secs % 3600) / 60;
    let s = day_secs % 60;
    let days = secs.div_euclid(86_400);
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m_cal = if mp < 10 { mp + 3 } else { mp - 9 };
    let y_cal = if m_cal <= 2 { y + 1 } else { y };
    format!("{y_cal:04}-{m_cal:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}

fn worker_loop(
    conn: Connection,
    persistence: Option<Persistence>,
    snapshots: Option<SnapshotStore>,
    mut rx: mpsc::Receiver<Request>,
) {
    info!("db worker started");
    // `conn` must be mutable: restoring a snapshot (undo/redo) writes
    // into the live connection via the backup API (`&mut`).
    let mut conn = conn;
    let snap = snapshots.as_ref();
    let mut batch = BatchState::default();
    while let Some(req) = rx.blocking_recv() {
        // Undo/redo/peek/batch are handled here: undo/redo need
        // `&mut conn` for the restore, and batch state lives across
        // requests. Everything else goes to `handle_request`, which
        // brackets mutations with a pre-op snapshot.
        match req {
            Request::Undo { reply } => {
                let _ = reply.send(do_undo(snap, &mut conn));
            }
            Request::Redo { reply } => {
                let _ = reply.send(do_redo(snap, &mut conn));
            }
            Request::PeekUndo { reply } => {
                let _ = reply.send(peek_undo_op(snap));
            }
            Request::PeekRedo { reply } => {
                let _ = reply.send(peek_redo_op(snap));
            }
            Request::BeginBatch { source, reply } => {
                begin_batch(snap, &conn, &mut batch, source.as_deref());
                let _ = reply.send(());
            }
            Request::EndBatch { reply } => {
                end_batch(snap, &mut batch);
                let _ = reply.send(());
            }
            // ADR-0015 mode-restore amendment (issue #14): record the
            // current input mode so `project.yaml` reflects it. We
            // persist immediately (not just update the in-memory
            // value) so a mode change followed by quit — with no
            // intervening command — is still saved.
            Request::SetMode { mode, reply } => {
                let result = persistence.as_ref().map_or(Ok(()), |p| {
                    p.set_mode(mode);
                    persist_current_mode(&conn, p)
                });
                let _ = reply.send(result);
            }
            other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other),
        }
    }
    info!("db worker exiting");
}

/// Worker-side undo bracketing state for the request stream.
/// `active` is set between `BeginBatch`/`EndBatch` so per-command
/// snapshots are suppressed in favour of one boundary snapshot for
/// the whole batch (ADR-0006 Amendment 1).
#[derive(Default)]
struct BatchState {
    active: bool,
    dirty: bool,
    staged: Option<Staged>,
}

fn snapshot_to_db_error(e: SnapshotError) -> DbError {
    DbError::Io(e.to_string())
}

/// Stage a pre-mutation snapshot, never failing the user's command if
/// the snapshot itself can't be taken (the real persistence is the
/// durable state — the snapshot is a best-effort safety net). Returns
/// `None` when undo is off, the command has no user `source` (an
/// internal op, e.g. open-time rebuild — not undoable), or staging
/// failed.
fn stage_pre_mutation(
    snap: Option<&SnapshotStore>,
    conn: &Connection,
    source: Option<&str>,
) -> Option<Staged> {
    let store = snap?;
    let src = source?;
    match store.stage(conn, src) {
        Ok(staged) => Some(staged),
        Err(e) => {
            warn!(error = %e, "could not stage undo snapshot; command proceeds without undo");
            None
        }
    }
}

/// Run a mutating handler with undo bracketing: stage before, then
/// finalise on success / discard on failure — or, inside a batch,
/// just mark the batch dirty so its single boundary snapshot is kept.
/// The command result is always sent; snapshot bookkeeping never
/// fails the user's actual work.
fn snapshot_then<T>(
    snap: Option<&SnapshotStore>,
    batch: &mut BatchState,
    conn: &Connection,
    source: Option<&str>,
    reply: oneshot::Sender<Result<T, DbError>>,
    run: impl FnOnce() -> Result<T, DbError>,
) {
    let staged = if batch.active {
        None
    } else {
        stage_pre_mutation(snap, conn, source)
    };
    let result = run();
    let committed = result.is_ok();
    if batch.active {
        if committed {
            batch.dirty = true;
        }
    } else if let Some(store) = snap {
        let outcome = match staged {
            Some(st) if committed => store.finalize(st).map(|_| ()),
            Some(st) => store.discard(st),
            // No snapshot was staged. If this is a committed user
            // mutation (source present → staging FAILED, not just an
            // internal op), the redo stack is now stale and must be
            // cleared, or a later `redo` would silently discard this
            // work. `finalize` would have done this; it didn't run.
            None if committed && source.is_some() => store.clear_redo(),
            None => Ok(()),
        };
        if let Err(e) = outcome {
            warn!(error = %e, "undo snapshot bookkeeping failed (command already applied)");
        }
    }
    let _ = reply.send(result);
}

/// Open a batch: one boundary snapshot, then suppress per-command
/// snapshots until `end_batch`.
fn begin_batch(
    snap: Option<&SnapshotStore>,
    conn: &Connection,
    batch: &mut BatchState,
    source: Option<&str>,
) {
    if batch.active {
        warn!("BeginBatch while a batch is active; ignoring (no nested batches)");
        return;
    }
    batch.staged = stage_pre_mutation(snap, conn, source);
    batch.active = true;
    batch.dirty = false;
}

/// Close a batch: keep the boundary snapshot iff a mutation committed
/// during it, else discard it (an all-skips batch leaves no undo step).
fn end_batch(snap: Option<&SnapshotStore>, batch: &mut BatchState) {
    if !batch.active {
        warn!("EndBatch with no active batch; ignoring");
        return;
    }
    if let Some(store) = snap {
        let outcome = match batch.staged.take() {
            Some(st) if batch.dirty => store.finalize(st).map(|_| ()),
            Some(st) => store.discard(st),
            // Boundary snapshot failed to stage but mutations
            // committed: clear the now-stale redo stack (same
            // data-loss guard as the per-command path).
            None if batch.dirty => store.clear_redo(),
            None => Ok(()),
        };
        if let Err(e) = outcome {
            warn!(error = %e, "batch undo snapshot bookkeeping failed");
        }
    }
    batch.active = false;
    batch.dirty = false;
}

fn do_undo(
    snap: Option<&SnapshotStore>,
    conn: &mut Connection,
) -> Result<Option<SnapshotMeta>, DbError> {
    snap.map_or(Ok(None), |store| {
        store.undo(conn).map_err(snapshot_to_db_error)
    })
}

fn do_redo(
    snap: Option<&SnapshotStore>,
    conn: &mut Connection,
) -> Result<Option<SnapshotMeta>, DbError> {
    snap.map_or(Ok(None), |store| {
        store.redo(conn).map_err(snapshot_to_db_error)
    })
}

fn peek_undo_op(snap: Option<&SnapshotStore>) -> Result<Option<SnapshotMeta>, DbError> {
    snap.map_or(Ok(None), |s| s.peek_undo().map_err(snapshot_to_db_error))
}

fn peek_redo_op(snap: Option<&SnapshotStore>) -> Result<Option<SnapshotMeta>, DbError> {
    snap.map_or(Ok(None), |s| s.peek_redo().map_err(snapshot_to_db_error))
}

fn handle_request(
    conn: &Connection,
    persistence: Option<&Persistence>,
    snap: Option<&SnapshotStore>,
    batch: &mut BatchState,
    req: Request,
) {
    match req {
        Request::CreateTable {
            name,
            columns,
            primary_key,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_create_table(
                    conn,
                    persistence,
                    &name,
                    &columns,
                    &primary_key,
                    &[],
                    &[],
                    &[],
                )
            });
        }
        Request::SqlCreateTable {
            name,
            columns,
            primary_key,
            unique_constraints,
            check_constraints,
            foreign_keys,
            if_not_exists,
            source,
            reply,
        } => {
            // `IF NOT EXISTS` on an existing table is a no-op: reply
            // `Skipped` with the existing structure and take **no**
            // snapshot (there is nothing to undo). The submitted line is
            // still journalled — like other read-only / no-op commands
            // (`show table`), it belongs in the complete journal
            // (ADR-0034). ADR-0035 §4.
            if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) {
                // ADR-0052: journaling moved to the dispatch layer; this
                // no-op skip is an `Ok` outcome there and is journalled by
                // the spawn like any other.
                let result = do_describe_table(conn, &name).map(CreateOutcome::Skipped);
                let _ = reply.send(result);
            } else {
                snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                    do_create_table(
                        conn,
                        persistence,
                        &name,
                        &columns,
                        &primary_key,
                        &unique_constraints,
                        &check_constraints,
                        &foreign_keys,
                    )
                    .map(CreateOutcome::Created)
                });
            }
        }
        Request::DropTable {
            name,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_drop_table(conn, persistence, &name)
            });
        }
        Request::SqlDropTable {
            name,
            if_exists,
            source,
            reply,
        } => {
            // `IF EXISTS` on an absent table is a no-op: reply `Skipped`
            // and take **no** snapshot (nothing to undo). The submitted
            // line is still journalled — like the `CREATE TABLE IF NOT
            // EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4.
            if if_exists && !user_table_exists(conn, &name).unwrap_or(false) {
                // ADR-0052: journaling moved to the dispatch layer.
                let result: Result<DropOutcome, DbError> = Ok(DropOutcome::Skipped);
                let _ = reply.send(result);
            } else {
                snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                    do_drop_table(conn, persistence, &name).map(|()| DropOutcome::Dropped)
                });
            }
        }
        Request::AddColumn {
            table,
            column,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_add_column(conn, persistence, &table, &column)
            });
        }
        Request::DropColumn {
            table,
            column,
            cascade,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_drop_column(conn, persistence, &table, &column, cascade)
            });
        }
        Request::RenameColumn {
            table,
            old,
            new,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_rename_column(conn, persistence, &table, &old, &new)
            });
        }
        Request::RenameTable {
            table,
            new,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_rename_table(conn, persistence, &table, &new)
            });
        }
        Request::ChangeColumnType {
            table,
            column,
            ty,
            mode,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_change_column_type(conn, persistence, &table, &column, ty, mode)
            });
        }
        Request::ListTables { reply } => {
            let _ = reply.send(do_list_tables(conn));
        }
        Request::ShowList { kind, name, reply } => {
            let _ = reply.send(do_show_list(conn, kind, name.as_deref()));
        }
        Request::ShowRelationship { name, reply } => {
            let _ = reply.send(do_show_relationship(conn, &name));
        }
        Request::DescribeTable { name, reply } => {
            let _ = reply.send(do_describe_table(conn, &name));
        }
        Request::AddRelationship {
            name,
            parent_table,
            parent_columns,
            child_table,
            child_columns,
            on_delete,
            on_update,
            create_fk,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_add_relationship(
                    conn,
                    persistence,
                    name.as_deref(),
                    &parent_table,
                    &parent_columns,
                    &child_table,
                    &child_columns,
                    on_delete,
                    on_update,
                    create_fk,
                )
            });
        }
        Request::CreateM2nRelationship {
            t1,
            t2,
            name,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_create_m2n_relationship(conn, persistence, &t1, &t2, name.as_deref())
            });
        }
        Request::DropRelationship {
            selector,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_drop_relationship(conn, persistence, &selector)
            });
        }
        Request::AddIndex {
            name,
            table,
            columns,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_add_index(
                    conn,
                    persistence,
                    name.as_deref(),
                    &table,
                    &columns,
                    // Simple-mode `add index` is always non-unique
                    // (ADR-0025); `add unique index` stays deferred. The SQL
                    // `CREATE UNIQUE INDEX` path passes `true` (ADR-0035 §4d).
                    false,
                )
            });
        }
        Request::DropIndex {
            selector,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_drop_index(conn, persistence, &selector)
            });
        }
        Request::SqlDropIndex {
            name,
            if_exists,
            source,
            reply,
        } => {
            // `IF EXISTS` on an absent index is a no-op: reply `Skipped`
            // and take **no** snapshot (nothing to undo). The submitted
            // line is still journalled (the 4c skip pattern, ADR-0034 /
            // ADR-0035 §4). Existence uses the same user-index lookup as
            // `do_drop_index` (`sql IS NOT NULL`).
            if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
                // ADR-0052: journaling moved to the dispatch layer.
                let result: Result<DropIndexOutcome, DbError> = Ok(DropIndexOutcome::Skipped);
                let _ = reply.send(result);
            } else {
                snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                    do_drop_index(
                        conn,
                        persistence,
                        &IndexSelector::Named { name: name.clone() },
                    )
                    .map(DropIndexOutcome::Dropped)
                });
            }
        }
        Request::SqlCreateIndex {
            name,
            table,
            columns,
            unique,
            if_not_exists,
            source,
            reply,
        } => {
            // `IF NOT EXISTS` short-circuits only a *name* collision into
            // a no-op (reply `Skipped`, no snapshot, line journalled — the
            // 4c skip pattern). The name uses the broad lookup (any index
            // of that name), matching `do_add_index`'s collision guard.
            // A *different*-named but redundant-column-set create still
            // hits `do_add_index`'s redundant-set refusal (ADR-0025).
            let resolved = resolve_index_name(name.as_deref(), &table, &columns);
            if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
                // ADR-0052: journaling moved to the dispatch layer.
                let result: Result<CreateIndexOutcome, DbError> =
                    Ok(CreateIndexOutcome::Skipped(resolved));
                let _ = reply.send(result);
            } else {
                snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                    do_add_index(conn, persistence, name.as_deref(), &table, &columns, unique)
                        .map(CreateIndexOutcome::Created)
                });
            }
        }
        Request::AddConstraint {
            table,
            column,
            constraint,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_add_constraint(conn, persistence, &table, &column, &constraint)
            });
        }
        Request::DropConstraint {
            table,
            column,
            kind,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_drop_constraint(conn, persistence, &table, &column, kind)
            });
        }
        Request::SetColumnDefault {
            table,
            column,
            default_sql,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_set_column_default(conn, persistence, &table, &column, &default_sql)
            });
        }
        Request::AlterAddTableCheck {
            table,
            name,
            expr_sql,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_alter_add_table_check(conn, persistence, &table, name.as_deref(), &expr_sql)
            });
        }
        Request::AlterAddUnique {
            table,
            columns,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_alter_add_unique(conn, persistence, &table, &columns)
            });
        }
        Request::AlterDropConstraint {
            table,
            name,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_drop_constraint_by_name(conn, persistence, &table, &name)
            });
        }
        Request::AlterAddForeignKey {
            child_table,
            name,
            fk,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_alter_add_foreign_key(conn, persistence, &child_table, name.as_deref(), &fk)
            });
        }
        Request::Insert {
            table,
            columns,
            values,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_insert(conn, persistence, &table, columns.as_deref(), &values)
            });
        }
        Request::Seed {
            table,
            target_column,
            count,
            overrides,
            rng_seed,
            source,
            reply,
        } => {
            // One snapshot wraps the whole seed (ADR-0048 D15 — one undo
            // step), exactly like a single insert.
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_seed(
                    conn,
                    persistence,
                    &table,
                    target_column.as_deref(),
                    count,
                    &overrides,
                    rng_seed,
                )
            });
        }
        Request::Update {
            table,
            assignments,
            filter,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_update(conn, persistence, &table, &assignments, &filter)
            });
        }
        Request::Delete {
            table,
            filter,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_delete(conn, persistence, &table, &filter)
            });
        }
        Request::QueryData {
            table,
            filter,
            limit,
            reply,
        } => {
            let _ = reply.send(do_query_data(conn, &table, filter.as_ref(), limit));
        }
        Request::RunSelect { sql, reply } => {
            let _ = reply.send(do_run_select(conn, &sql));
        }
        Request::RunSqlInsert {
            sql,
            source,
            target_table,
            listed_columns,
            row_source,
            returning,
            literal_rows,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_sql_insert(
                    conn,
                    persistence,
                    &sql,
                    &target_table,
                    &listed_columns,
                    &row_source,
                    returning,
                    &literal_rows,
                )
            });
        }
        Request::RunSqlUpdate {
            sql,
            source,
            target_table,
            returning,
            set_literals,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_sql_update(
                    conn,
                    persistence,
                    &sql,
                    &target_table,
                    returning,
                    &set_literals,
                )
            });
        }
        Request::RunSqlDelete {
            sql,
            source,
            target_table,
            returning,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_sql_delete(conn, persistence, &sql, &target_table, returning)
            });
        }
        Request::RebuildFromText {
            project_path,
            source,
            reply,
        } => {
            snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
                do_rebuild_from_text(conn, &project_path)
            });
        }
        Request::ExplainPlan { query, reply } => {
            let _ = reply.send(do_explain_plan(conn, &query));
        }
        Request::ReadRelationships { table, reply } => {
            let result = do_read_relationships(conn, &table);
            let _ = reply.send(result);
        }
        Request::FindRowsMatching {
            table,
            column,
            value,
            limit,
            reply,
        } => {
            let result = do_find_rows_matching(conn, &table, &column, &value, limit);
            let _ = reply.send(result);
        }
        Request::ListNamesFor { source, reply } => {
            let result = do_list_names_for(conn, source);
            let _ = reply.send(result);
        }
        Request::ReadAllRelationships { reply } => {
            let _ = reply.send(read_all_relationships(conn));
        }
        // Undo/redo/peek/batch are intercepted in `worker_loop` (they
        // need `&mut conn` or persistent batch state) and never reach
        // here. Listed explicitly so a new variant still forces a
        // decision at compile time.
        Request::Undo { .. }
        | Request::Redo { .. }
        | Request::PeekUndo { .. }
        | Request::PeekRedo { .. }
        | Request::BeginBatch { .. }
        | Request::EndBatch { .. }
        | Request::SetMode { .. } => {
            unreachable!("undo/redo/peek/batch/set-mode are handled in worker_loop")
        }
    }
}

/// Schema-name lookup for the completion engine
/// (ADR-0022 §9). Non-schema sources (`NewName`, `Types`, `Free`)
/// never reach here — the public `list_names_for` short-circuits.
fn do_list_names_for(
    conn: &Connection,
    source: crate::dsl::grammar::IdentSource,
) -> Result<Vec<String>, DbError> {
    use crate::dsl::grammar::IdentSource;
    match source {
        IdentSource::Tables => do_list_tables(conn),
        IdentSource::Columns => {
            // Distinct column names across all user tables.
            // v1 simplification: no table-context binding
            // (ADR-0022 stage 6 note).
            let mut stmt = conn
                .prepare(&format!(
                    "SELECT DISTINCT column_name \
                     FROM {META_TABLE} \
                     ORDER BY column_name;"
                ))
                .map_err(DbError::from_rusqlite)?;
            let rows = stmt
                .query_map([], |row| row.get::<_, String>(0))
                .map_err(DbError::from_rusqlite)?;
            let mut out = Vec::new();
            for row in rows {
                out.push(row.map_err(DbError::from_rusqlite)?);
            }
            Ok(out)
        }
        IdentSource::Relationships => {
            let mut stmt = conn
                .prepare(&format!("SELECT name FROM {REL_TABLE} ORDER BY name;"))
                .map_err(DbError::from_rusqlite)?;
            let rows = stmt
                .query_map([], |row| row.get::<_, String>(0))
                .map_err(DbError::from_rusqlite)?;
            let mut out = Vec::new();
            for row in rows {
                out.push(row.map_err(DbError::from_rusqlite)?);
            }
            Ok(out)
        }
        IdentSource::Indexes => {
            // User indexes only: a `CREATE INDEX` statement
            // leaves a non-null `sql`, whereas the automatic
            // indexes backing PKs / UNIQUE constraints have a
            // null `sql`.
            let mut stmt = conn
                .prepare(
                    "SELECT name FROM sqlite_master \
                     WHERE type = 'index' AND sql IS NOT NULL \
                     ORDER BY name;",
                )
                .map_err(DbError::from_rusqlite)?;
            let rows = stmt
                .query_map([], |row| row.get::<_, String>(0))
                .map_err(DbError::from_rusqlite)?;
            let mut out = Vec::new();
            for row in rows {
                out.push(row.map_err(DbError::from_rusqlite)?);
            }
            Ok(out)
        }
        IdentSource::NewName | IdentSource::Types | IdentSource::Generators | IdentSource::Free => {
            Ok(Vec::new())
        }
    }
}

/// Read both directions of FK relationships for `table`. Used
/// by `Request::ReadRelationships` (ADR-0019 §6 enrichment).
fn do_read_relationships(conn: &Connection, table: &str) -> RelationshipsReply {
    let outbound = read_relationships_outbound(conn, table)?;
    let inbound = read_relationships_inbound(conn, table)?;
    Ok((outbound, inbound))
}

/// `SELECT * FROM <table> WHERE <column> = <value> LIMIT <n>`.
/// Used by the runtime to pinpoint rows after a UNIQUE / FK
/// violation (ADR-0019 §6, ADR-0017 §7). Returns
/// `DbError::Sqlite` on bind failure, missing column, etc. —
/// callers treat any error as "no diagnostic table available"
/// and fall back to the headline-only wording.
fn do_find_rows_matching(
    conn: &Connection,
    table: &str,
    column: &str,
    value: &Value,
    limit: usize,
) -> Result<DataResult, DbError> {
    let schema = read_schema(conn, table)?;
    let col_info = schema
        .columns
        .iter()
        .find(|c| c.name == column)
        .ok_or_else(|| DbError::Sqlite {
            message: format!("no such column: {table}.{column}"),
            kind: SqliteErrorKind::NoSuchColumn,
        })?;
    let ty = col_info.user_type.ok_or_else(|| {
        DbError::Unsupported(format!(
            "column `{column}` has no user-type metadata; cannot pinpoint"
        ))
    })?;
    let bound = value
        .bind_for_column(column, ty)
        .map_err(|e| DbError::InvalidValue(e.to_string()))?;

    let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
    let column_types: Vec<Option<Type>> = schema.columns.iter().map(|c| c.user_type).collect();
    let cols_csv = column_names
        .iter()
        .map(|c| quote_ident(c))
        .collect::<Vec<_>>()
        .join(", ");
    let sql = format!(
        "SELECT {cols} FROM {tbl} WHERE {col} = ?1 LIMIT {n};",
        cols = cols_csv,
        tbl = quote_ident(table),
        col = quote_ident(column),
        n = limit,
    );

    let bound_value = bound_to_sqlite_value(&bound);
    let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
    let rows_iter = stmt
        .query_map(rusqlite::params![bound_value], |row| {
            let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(column_names.len());
            for i in 0..column_names.len() {
                cells.push(row.get(i)?);
            }
            Ok(cells)
        })
        .map_err(DbError::from_rusqlite)?;
    let mut rows: Vec<Vec<Option<String>>> = Vec::new();
    for r in rows_iter {
        let cells = r.map_err(DbError::from_rusqlite)?;
        let formatted: Vec<Option<String>> = cells
            .into_iter()
            .zip(column_types.iter())
            .map(|(v, ty)| format_cell(v, *ty))
            .collect();
        rows.push(formatted);
    }
    Ok(DataResult {
        table_name: table.to_string(),
        columns: column_names,
        column_types,
        rows,
    })
}

/// Set of changes a mutation made, used by the post-mutation
/// persistence phase to know which text targets need refreshing
/// (ADR-0015 §6).
#[derive(Debug, Default)]
struct Changes {
    /// Schema (tables / columns / relationships) was modified —
    /// `project.yaml` needs to be rewritten.
    schema_dirty: bool,
    /// Tables whose row data changed and whose CSVs need to be
    /// re-emitted from the post-mutation state.
    rewritten_tables: Vec<String>,
    /// Tables that were dropped — their CSVs should be removed.
    deleted_tables: Vec<String>,
}

/// Drive the post-mutation persistence phase: write the YAML
/// schema, rewrite affected CSVs, append `history.log`. Called
/// before `tx.commit()` so a failure here causes the SQLite
/// transaction to roll back automatically (the `Drop` impl on
/// `Transaction` rolls back on drop).
///
/// Read-only requests (no schema change, no row writes, no
/// drops) still use this to append `history.log` if `source`
/// is set; they pass an empty `Changes`.
// Persist the **state** sources (project.yaml + data/*.csv) for a
// committed mutation, inside the worker transaction (ADR-0015 §6
// commit-db-last). `history.log` is NOT written here — ADR-0052 moved
// journaling to the dispatch layer (runtime), so the command's mode is
// available without plumbing it through the worker, and a journal-write
// failure no longer rolls back a committed command (it is best-effort,
// like the failure path).
fn finalize_persistence(
    conn: &Connection,
    persistence: Option<&Persistence>,
    changes: &Changes,
) -> Result<(), DbError> {
    let Some(p) = persistence else {
        return Ok(());
    };
    if changes.schema_dirty {
        let mut schema = read_schema_snapshot(conn)?;
        // Stamp the live input mode (ADR-0015 mode-restore
        // amendment, issue #14). Mode is not stored in the db, so
        // `read_schema_snapshot` leaves a placeholder; the
        // persister is authoritative and writes the current value.
        schema.mode = p.current_mode();
        p.write_schema(&schema).map_err(DbError::from_persistence)?;
    }
    for table in &changes.rewritten_tables {
        if let Some(snapshot) = read_table_snapshot(conn, table)? {
            p.write_table_data(&snapshot)
                .map_err(DbError::from_persistence)?;
        }
    }
    for table in &changes.deleted_tables {
        p.delete_table_data(table)
            .map_err(DbError::from_persistence)?;
    }
    Ok(())
}

/// Read the full user-facing schema (tables, columns,
/// relationships, project metadata) from the database. Reads
/// committed *or* in-tx state because SQLite presents the
/// same connection's writes back through the same connection.
fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
    let table_names = do_list_tables(conn)?;
    let mut tables: Vec<TableSchema> = Vec::with_capacity(table_names.len());
    for name in &table_names {
        let read = read_schema(conn, name)?;
        let columns: Vec<ColumnSchema> = read
            .columns
            .iter()
            .map(|c| ColumnSchema {
                name: c.name.clone(),
                unique: c.unique,
                not_null: c.notnull,
                default: c.default_sql.clone(),
                check: c.check.clone(),
                // user_type is always populated for tables we
                // created; the fallback is defensive.
                user_type: c.user_type.unwrap_or(Type::Text),
            })
            .collect();
        tables.push(TableSchema {
            name: name.clone(),
            primary_key: read.primary_key.clone(),
            columns,
            unique_constraints: read.unique_constraints.clone(),
            check_constraints: read.check_constraints.clone(),
        });
    }

    let relationships = read_all_relationships(conn)?;
    let mut indexes: Vec<IndexSchema> = Vec::new();
    for name in &table_names {
        for idx in read_table_indexes(conn, name)? {
            indexes.push(IndexSchema {
                name: idx.name,
                table: name.clone(),
                columns: idx.columns,
                unique: idx.unique,
            });
        }
    }
    let created_at = read_project_created_at(conn)?;
    Ok(SchemaSnapshot {
        created_at,
        // Mode is live UI state, not stored in the database
        // (ADR-0015 mode-restore amendment, issue #14). This is a
        // placeholder the persister overwrites with the current
        // mode; the field exists for the read/restore path, where
        // `parse_schema` fills it from `project.yaml`.
        mode: Mode::default(),
        tables,
        relationships,
        indexes,
    })
}

/// Write `project.yaml` now with the current schema and the
/// persister's current input mode (ADR-0015 mode-restore
/// amendment, issue #14). Used by the `SetMode` request so a mode
/// change is saved immediately, without waiting for the next
/// schema-mutating command. A no-op when persistence is absent
/// (in-memory test databases) or the schema is otherwise clean.
fn persist_current_mode(conn: &Connection, p: &Persistence) -> Result<(), DbError> {
    let mut schema = read_schema_snapshot(conn)?;
    schema.mode = p.current_mode();
    p.write_schema(&schema).map_err(DbError::from_persistence)
}

fn read_all_relationships(conn: &Connection) -> Result<Vec<RelationshipSchema>, DbError> {
    let mut stmt = conn
        .prepare(&format!(
            "SELECT name, parent_table, parent_column, child_table, child_column, \
                    on_delete, on_update \
             FROM {REL_TABLE} \
             ORDER BY rowid"
        ))
        .map_err(DbError::from_rusqlite)?;
    let rows = stmt
        .query_map([], |row| {
            Ok(RelationshipSchema {
                name: row.get(0)?,
                parent_table: row.get(1)?,
                parent_columns: decode_rel_columns(row.get::<_, String>(2)?.as_str()),
                child_table: row.get(3)?,
                child_columns: decode_rel_columns(row.get::<_, String>(4)?.as_str()),
                on_delete: parse_action_from_sqlite(row.get::<_, String>(5)?.as_str()),
                on_update: parse_action_from_sqlite(row.get::<_, String>(6)?.as_str()),
            })
        })
        .map_err(DbError::from_rusqlite)?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(DbError::from_rusqlite)?);
    }
    Ok(out)
}

fn read_project_created_at(conn: &Connection) -> Result<String, DbError> {
    let value: Option<String> = conn
        .query_row(
            &format!("SELECT value FROM {META_PROJECT_TABLE} WHERE key = 'created_at'"),
            [],
            |row| row.get(0),
        )
        .or_else(|e| match e {
            rusqlite::Error::QueryReturnedNoRows => Ok(None),
            other => Err(other),
        })
        .map_err(DbError::from_rusqlite)?;
    Ok(value.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()))
}

/// Read a single table's full row data, returning `None` if
/// the table no longer exists (e.g. a recent `drop_table`).
fn read_table_snapshot(conn: &Connection, table: &str) -> Result<Option<TableSnapshot>, DbError> {
    if !user_table_exists(conn, table)? {
        return Ok(None);
    }
    let read = read_schema(conn, table)?;
    let columns: Vec<ColumnSchema> = read
        .columns
        .iter()
        .map(|c| ColumnSchema {
            name: c.name.clone(),
            user_type: c.user_type.unwrap_or(Type::Text),
            unique: c.unique,
            not_null: c.notnull,
            default: c.default_sql.clone(),
            check: c.check.clone(),
        })
        .collect();
    let column_idents: Vec<String> = read.columns.iter().map(|c| quote_ident(&c.name)).collect();
    let sql = format!(
        "SELECT {} FROM {} ORDER BY rowid",
        column_idents.join(", "),
        quote_ident(table),
    );
    let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
    let column_count = read.columns.len();
    let mut rows: Vec<Vec<CellValue>> = Vec::new();
    let mut iter = stmt.query([]).map_err(DbError::from_rusqlite)?;
    while let Some(row) = iter.next().map_err(DbError::from_rusqlite)? {
        let mut record: Vec<CellValue> = Vec::with_capacity(column_count);
        for i in 0..column_count {
            record.push(row_value_to_cell(row, i)?);
        }
        rows.push(record);
    }
    Ok(Some(TableSnapshot {
        name: table.to_string(),
        columns,
        rows,
    }))
}

fn user_table_exists(conn: &Connection, table: &str) -> Result<bool, DbError> {
    let count: i64 = conn
        .query_row(
            "SELECT COUNT(*) FROM sqlite_schema \
             WHERE type = 'table' AND name = ?1 \
               AND substr(name, 1, 8) != '__rdbms_'",
            [table],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    Ok(count > 0)
}

/// An engine-neutral "no such table" error for `name`.
fn no_such_table(name: &str) -> DbError {
    DbError::Sqlite {
        message: format!("no such table: {name}"),
        kind: SqliteErrorKind::NoSuchTable,
    }
}

/// Resolve a user-supplied table name to its **stored (canonical) case**,
/// or `None` if no such user table exists.
///
/// SQL identifiers are case-insensitive, so a user may type a table name
/// in any capitalization and the engine resolves it. But our metadata
/// tables (keyed by `table_name` / `parent_table` / `child_table`) and the
/// `data/<table>.csv` files are keyed by the *stored* case, and TEXT `=`
/// is case-sensitive — so an executor that used the as-typed name would
/// drift the metadata/CSV out of step with the live schema. Every executor
/// that names a table canonicalizes first and then operates on the
/// canonical name, keeping the live schema, the metadata, and the CSV in
/// step regardless of how the user capitalised the name.
///
/// Internal `__rdbms_*` tables are excluded (treated as non-existent),
/// folding the [`reject_internal_table_name`] guard into the same lookup.
fn canonical_table_name(conn: &Connection, name: &str) -> Result<Option<String>, DbError> {
    let mut stmt = conn
        .prepare(
            "SELECT name FROM sqlite_schema \
             WHERE type = 'table' AND name = ?1 COLLATE NOCASE \
               AND name NOT LIKE 'sqlite_%' \
               AND substr(name, 1, 8) != '__rdbms_'",
        )
        .map_err(DbError::from_rusqlite)?;
    let mut rows = stmt.query([name]).map_err(DbError::from_rusqlite)?;
    match rows.next().map_err(DbError::from_rusqlite)? {
        Some(row) => Ok(Some(
            row.get::<_, String>(0).map_err(DbError::from_rusqlite)?,
        )),
        None => Ok(None),
    }
}

/// Resolve a table name to its canonical stored case, erroring with a
/// "no such table" if it does not exist (the common executor entry guard).
fn require_canonical_table(conn: &Connection, name: &str) -> Result<String, DbError> {
    canonical_table_name(conn, name)?.ok_or_else(|| no_such_table(name))
}

fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result<CellValue, DbError> {
    use rusqlite::types::ValueRef;
    let v = row.get_ref(idx).map_err(DbError::from_rusqlite)?;
    Ok(match v {
        ValueRef::Null => CellValue::Null,
        ValueRef::Integer(n) => CellValue::Integer(n),
        ValueRef::Real(f) => CellValue::Real(f),
        ValueRef::Text(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()),
        // `blob` was removed from the vocabulary (ADR-0005 Amendment 2), so no
        // column is BLOB-typed any more and a blob value-ref is not expected.
        // Surface any stray bytes as lossy text rather than panicking.
        ValueRef::Blob(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()),
    })
}

/// Quote an identifier for safe inclusion in DDL. Doubles any
/// embedded double-quotes per SQL convention.
/// Quote + comma-join a column list for a FK clause (ADR-0043):
/// `"a", "b"`. A single-column FK is the one-element case.
fn quote_cols(cols: &[String]) -> String {
    cols.iter()
        .map(|c| quote_ident(c))
        .collect::<Vec<_>>()
        .join(", ")
}

fn quote_ident(name: &str) -> String {
    let mut out = String::with_capacity(name.len() + 2);
    out.push('"');
    for c in name.chars() {
        if c == '"' {
            out.push_str("\"\"");
        } else {
            out.push(c);
        }
    }
    out.push('"');
    out
}

/// Render a column's constraint-DDL suffix (ADR-0029) — the
/// ` NOT NULL` / ` UNIQUE` / ` DEFAULT <literal>` fragment that
/// follows the column's type in a `CREATE TABLE` or `ALTER
/// TABLE ADD COLUMN`. The default literal is bound against the
/// column's user-facing type, so `default 18` on an `int`
/// column emits `DEFAULT 18` and `default 'x'` on a `text`
/// column emits `DEFAULT 'x'`. (`CHECK` joins this in a later
/// ADR-0029 step.)
fn column_constraints_sql(spec: &ColumnSpec) -> Result<String, DbError> {
    let mut sql = String::new();
    if spec.not_null {
        sql.push_str(" NOT NULL");
    }
    if spec.unique {
        sql.push_str(" UNIQUE");
    }
    // Advanced-mode raw `DEFAULT <expr>` (ADR-0035 §4a.2) takes
    // precedence over a simple-mode typed default; SQLite stores the
    // literal text and `PRAGMA table_info` reports it back for the
    // round-trip (no metadata needed for DEFAULT).
    if let Some(raw) = &spec.default_sql {
        sql.push_str(" DEFAULT ");
        sql.push_str(raw);
    } else if let Some(literal) = default_sql_literal(spec)? {
        sql.push_str(" DEFAULT ");
        sql.push_str(&literal);
    }
    Ok(sql)
}

/// The SQL literal for a column's `DEFAULT` value, bound
/// against the column's user-facing type (ADR-0029). `None`
/// when the column carries no default.
fn default_sql_literal(spec: &ColumnSpec) -> Result<Option<String>, DbError> {
    match &spec.default {
        Some(value) => Ok(Some(value_to_default_sql(value, &spec.name, spec.ty)?)),
        None => Ok(None),
    }
}

/// The SQL literal for a `DEFAULT` `value` on a `column` of
/// user-facing type `ty` (ADR-0029) — the value-bound,
/// type-checked rendering shared by `default_sql_literal` (the
/// create-table / add-column path) and `do_add_constraint`
/// (the `add constraint default` path).
fn value_to_default_sql(value: &Value, column: &str, ty: Type) -> Result<String, DbError> {
    let bound = value
        .bind_for_column(column, ty)
        .map_err(|e| DbError::InvalidValue(e.to_string()))?;
    Ok(sql_literal(&bound_to_sqlite_value(&bound)))
}

/// Compile a `CHECK` expression to inline SQL (ADR-0029 §4 /
/// §7) — the form stored in the `check_expr` metadata column
/// and emitted into column DDL. `compile_expr` produces
/// `?N`-parameterised SQL; `inline_params_for_display`
/// (ADR-0028) folds the literals back in, since DDL admits no
/// parameters.
fn compile_check_sql(expr: &Expr, schema: &ReadSchema) -> String {
    let mut params: Vec<rusqlite::types::Value> = Vec::new();
    let sql = compile_expr(expr, schema, &mut params);
    inline_params_for_display(&sql, &params)
}

/// A minimal `ReadSchema` built from column specs — enough for
/// `compile_expr` to resolve column types when compiling a
/// `CHECK` at create-table time, before the table exists.
fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> ReadSchema {
    ReadSchema {
        columns: columns
            .iter()
            .map(|c| ReadColumn {
                name: c.name.clone(),
                sqlite_type: c.ty.sqlite_strict_type().to_string(),
                notnull: c.not_null,
                primary_key: primary_key.contains(&c.name),
                unique: c.unique,
                default_sql: None,
                check: None,
                user_type: Some(c.ty),
            })
            .collect(),
        primary_key: primary_key.to_vec(),
        foreign_keys: Vec::new(),
        unique_constraints: Vec::new(),
        check_constraints: Vec::new(),
    }
}

/// Insert a column's row into the metadata table — the user
/// type, plus the compiled `CHECK` SQL when present
/// (ADR-0029 §7).
fn insert_column_metadata(
    conn: &Connection,
    table: &str,
    column: &str,
    user_type: Type,
    check_sql: Option<&str>,
) -> Result<(), DbError> {
    conn.execute(
        &format!(
            "INSERT INTO {META_TABLE} \
                 (table_name, column_name, user_type, check_expr) \
             VALUES (?1, ?2, ?3, ?4);"
        ),
        rusqlite::params![table, column, user_type.keyword(), check_sql],
    )
    .map_err(DbError::from_rusqlite)?;
    Ok(())
}

/// The result of an advanced-mode SQL `CREATE TABLE` (ADR-0035 §4).
///
/// Either the table was created, or `IF NOT EXISTS` matched an
/// existing table and the statement was a no-op. Both carry the
/// table's structure so the runtime can render it; `Skipped` also
/// drives the "already exists — skipped" note.
#[derive(Debug)]
pub enum CreateOutcome {
    Created(TableDescription),
    Skipped(TableDescription),
}

/// The result of an advanced-mode SQL `DROP TABLE` (ADR-0035 §4, 4c).
///
/// Either the table was dropped, or `IF EXISTS` matched no table and
/// the statement was a no-op that drives the "doesn't exist — skipped"
/// note. Carries no payload — the runtime renders the note from the
/// command's table name.
#[derive(Debug)]
pub enum DropOutcome {
    Dropped,
    Skipped,
}

/// The result of an advanced-mode SQL `DROP INDEX` (ADR-0035 §4d).
///
/// Either the index was dropped — `Dropped` carries the affected
/// table's structure so the runtime auto-shows the now de-indexed table
/// (ADR-0014), unlike `DROP TABLE` whose table is gone — or `IF EXISTS`
/// matched no index and the statement was a no-op driving the "doesn't
/// exist — skipped" note (the index name comes from the command).
#[derive(Debug)]
pub enum DropIndexOutcome {
    Dropped(TableDescription),
    Skipped,
}

/// The result of an advanced-mode SQL `CREATE [UNIQUE] INDEX`
/// (ADR-0035 §4d).
///
/// Either the index was created — `Created` carries the affected table's
/// structure for the auto-show (ADR-0014) — or `IF NOT EXISTS` matched
/// an existing index name and the statement was a no-op. `Skipped`
/// carries the **resolved** index name (the auto-name is unknown to the
/// command for the unnamed form) to drive the "already exists — skipped"
/// note.
#[derive(Debug)]
pub enum CreateIndexOutcome {
    Created(TableDescription),
    Skipped(String),
}

#[allow(clippy::too_many_arguments)]
fn do_create_table(
    conn: &Connection,
    persistence: Option<&Persistence>,
    name: &str,
    columns: &[ColumnSpec],
    primary_key: &[String],
    unique_constraints: &[Vec<String>],
    check_constraints: &[String],
    foreign_keys: &[SqlForeignKey],
) -> Result<TableDescription, DbError> {
    debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table");
    // A new table may not take an internal `__rdbms_*` name (it would be
    // filtered out of `list_tables` — a hidden orphan). The advanced-SQL
    // create path rejects this at parse, but the simple-mode DSL
    // `TABLE_NAME_NEW` slot has no validator, and `create m:n … as
    // <name>` (ADR-0045) reaches here too — so the shared executor is the
    // single place that closes every path (issue raised by the ADR-0045
    // /runda pass).
    reject_internal_table_name(name)?;
    if columns.is_empty() {
        // SQLite requires at least one column. The DSL grammar
        // already prevents this, but defending here too keeps
        // the executor honest if anyone synthesises a Command
        // directly (tests, future scripting).
        return Err(DbError::Unsupported(
            "tables need at least one column".to_string(),
        ));
    }
    // Resolve + validate any foreign keys before building the DDL, so
    // an invalid reference aborts before the table is created (ADR-0035
    // §5, sub-phase 4b). Self-references validate against the columns
    // being defined; other parents must already exist.
    let resolved_fks = resolve_create_table_fks(conn, name, columns, primary_key, foreign_keys)?;
    if !resolved_fks.is_empty() {
        debug!(table = %name, fks = resolved_fks.len(), "create_table: foreign keys resolved + validated");
    }

    // Inline `PRIMARY KEY` on the column when the table has a single
    // primary-key column and it is the **first** column — the exact
    // rule [`schema_to_ddl`] uses on rebuild, so a table's DDL is
    // identical whether freshly created or reconstructed (no
    // round-trip drift). SQLite grants an inline single-column PK
    // rowid-alias semantics; a compound PK, or a single PK that is not
    // the first column, gets a table-level constraint. `serial`
    // autoincrement does **not** depend on this — the insert path
    // computes the next value itself (verified by the multi-column /
    // rebuild round-trip tests) — so the choice is purely about
    // matching the rebuild generator (ADR-0035 §6.4).
    let inline_pk_col: Option<&str> =
        (primary_key.len() == 1 && !columns.is_empty() && primary_key[0] == columns[0].name)
            .then(|| primary_key[0].as_str());

    // Compile each column's CHECK once (ADR-0029 §4) — reused
    // by the DDL clause and the metadata insert below. The
    // minimal schema gives `compile_expr` the column types.
    let check_schema = read_schema_for_specs(columns, primary_key);
    // Advanced-mode raw `CHECK` text (ADR-0035 §4a.2) wins over a
    // compiled simple-mode `Expr`; both are stored verbatim in the
    // column metadata and echoed by `schema_to_ddl` on rebuild.
    let check_sqls: Vec<Option<String>> = columns
        .iter()
        .map(|c| {
            c.check_sql.clone().or_else(|| {
                c.check
                    .as_ref()
                    .map(|e| compile_check_sql(e, &check_schema))
            })
        })
        .collect();

    let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
    for (col, check_sql) in columns.iter().zip(&check_sqls) {
        let mut clause = format!(
            "{ident} {sqlite_type}",
            ident = quote_ident(&col.name),
            sqlite_type = col.ty.sqlite_strict_type(),
        );
        if inline_pk_col == Some(col.name.as_str()) {
            clause.push_str(" PRIMARY KEY");
        }
        // ADR-0029 column constraints. A single-column PK is
        // already NOT NULL + UNIQUE; the simple-mode grammar
        // rejects redundant declarations (ADR-0029 §9) and the
        // SQL builder de-dups them (ADR-0035 §6.5), so an
        // inline-PK column never carries them here.
        clause.push_str(&column_constraints_sql(col)?);
        if let Some(cs) = check_sql {
            clause.push_str(&format!(" CHECK ({cs})"));
        }
        column_clauses.push(clause);
    }

    let mut ddl = format!(
        "CREATE TABLE {ident} ({columns}",
        ident = quote_ident(name),
        columns = column_clauses.join(", "),
    );

    if inline_pk_col.is_none() && !primary_key.is_empty() {
        let pk_idents: Vec<String> = primary_key.iter().map(|n| quote_ident(n)).collect();
        ddl.push_str(", PRIMARY KEY (");
        ddl.push_str(&pk_idents.join(", "));
        ddl.push(')');
    }

    // Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2).
    // Single-column UNIQUE rides on the column's inline `UNIQUE`; these
    // are the multi-column table-level constraints.
    for cols in unique_constraints {
        let idents: Vec<String> = cols.iter().map(|n| quote_ident(n)).collect();
        ddl.push_str(", UNIQUE (");
        ddl.push_str(&idents.join(", "));
        ddl.push(')');
    }

    // Table-level CHECK constraints (ADR-0035 §4a.3), emitted verbatim
    // from the captured raw SQL text. Must stay identical to the
    // `schema_to_ddl` rebuild path (the §6.1 two-generators rule).
    // The engine has no PRAGMA to report these back, so they are also
    // recorded in `CHECK_TABLE` (below) as their source of truth.
    for expr in check_constraints {
        ddl.push_str(", CHECK (");
        ddl.push_str(expr);
        ddl.push(')');
    }

    // Foreign keys (ADR-0035 §5, sub-phase 4b) — emitted identically to
    // `schema_to_ddl` (the §6.1 two-generators rule): always the
    // explicit resolved parent column + both actions, so the create DDL
    // and the rebuild DDL match byte-for-byte.
    for fk in &resolved_fks {
        ddl.push_str(&format!(
            ", FOREIGN KEY ({child}) REFERENCES {parent}({pcol}) ON DELETE {od} ON UPDATE {ou}",
            child = quote_cols(&fk.child_columns),
            parent = quote_ident(&fk.parent_table),
            pcol = quote_cols(&fk.parent_columns),
            od = fk.on_delete.sql_clause(),
            ou = fk.on_update.sql_clause(),
        ));
    }

    ddl.push_str(") STRICT;");
    debug!(ddl = %ddl, "create_table");

    // Wrap the table-creation DDL and the metadata inserts in a
    // single transaction so they commit atomically — if either
    // step fails, neither side persists.
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
    for (col, check_sql) in columns.iter().zip(&check_sqls) {
        insert_column_metadata(&tx, name, &col.name, col.ty, check_sql.as_deref())?;
    }
    // Record table-level CHECKs in their metadata table (the engine
    // reports no CHECK constraints, ADR-0035 §4a.3). `seq` preserves
    // declaration order so read-back / rebuild re-emit them in order.
    for (seq, expr) in check_constraints.iter().enumerate() {
        tx.execute(
            &format!(
                "INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr) VALUES (?1, ?2, ?3);"
            ),
            rusqlite::params![name, seq as i64, expr],
        )
        .map_err(DbError::from_rusqlite)?;
    }
    // Foreign-key relationships (ADR-0035 §5): one metadata row per FK,
    // in the same transaction as the table — so the whole statement is
    // one undo step.
    for fk in &resolved_fks {
        insert_relationship_metadata(
            &tx,
            &fk.name,
            &fk.parent_table,
            &fk.parent_columns,
            name,
            &fk.child_columns,
            fk.on_delete,
            fk.on_update,
        )?;
    }
    let description = do_describe_table(conn, name)?;
    let changes = Changes {
        schema_dirty: true,
        rewritten_tables: vec![name.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(description)
}

fn do_drop_table(
    conn: &Connection,
    persistence: Option<&Persistence>,
    name: &str,
) -> Result<(), DbError> {
    debug!(table = %name, "drop_table");
    // Canonicalize the user-typed name to its stored case (and refuse a
    // non-existent / internal table), so the metadata DELETEs and the CSV
    // removal target the right name regardless of capitalization.
    let canonical_name = require_canonical_table(conn, name)?;
    let name = canonical_name.as_str();
    // Refuse the drop while any *other* table still has a
    // relationship pointing at this one — dropping the parent
    // would leave dangling FK constraints in the children. The
    // user is told which relationships to drop first.
    let inbound = read_relationships_inbound(conn, name)?;
    if !inbound.is_empty() {
        let names: Vec<&str> = inbound.iter().map(|r| r.name.as_str()).collect();
        return Err(DbError::Unsupported(format!(
            "cannot drop `{name}`: it is referenced by relationship(s) [{}]. \
             Drop those relationships first.",
            names.join(", ")
        )));
    }

    let ddl = format!("DROP TABLE {ident};", ident = quote_ident(name));
    debug!(ddl = %ddl, "drop_table");
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
    tx.execute(
        &format!("DELETE FROM {META_TABLE} WHERE table_name = ?1;"),
        [name],
    )
    .map_err(DbError::from_rusqlite)?;
    // Outbound relationships are gone with the table — clean
    // their metadata too.
    tx.execute(
        &format!("DELETE FROM {REL_TABLE} WHERE child_table = ?1;"),
        [name],
    )
    .map_err(DbError::from_rusqlite)?;
    // Table-level CHECK metadata goes with the table (ADR-0035 §4a.3).
    tx.execute(
        &format!("DELETE FROM {CHECK_TABLE} WHERE table_name = ?1;"),
        [name],
    )
    .map_err(DbError::from_rusqlite)?;
    let changes = Changes {
        schema_dirty: true,
        rewritten_tables: Vec::new(),
        deleted_tables: vec![name.to_string()],
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(())
}

/// Add a column to an existing table.
///
/// Two execution paths per ADR-0018 §6:
///
/// - **Plain types** (`text`, `int`, `real`, `decimal`, `bool`,
///   `date`, `datetime`): `ALTER TABLE ADD COLUMN`. Existing
///   rows get NULL in the new column — that's the SQL standard
///   behaviour and acceptable for non-auto-generated types.
/// - **Auto-generated types** (`serial`, `shortid`): route
///   through the rebuild-table primitive so we can populate
///   the new column atomically with table creation. Existing
///   rows receive a generated value (1..N for serial, fresh
///   shortids for shortid) and the column gains UNIQUE +
///   NOT NULL.
fn do_add_column(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    column: &ColumnSpec,
) -> Result<AddColumnResult, DbError> {
    debug!(table = %table, column = %column.name, "add_column");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    if matches!(column.ty, Type::Serial | Type::ShortId) {
        // ADR-0029 §6: a `serial` / `shortid` column auto-fills
        // its own values, so a separate `default` is ambiguous.
        // (`default_sql` is the advanced-mode raw form — ADR-0035 §4e.)
        if column.default.is_some() || column.default_sql.is_some() {
            return Err(DbError::Unsupported(format!(
                "`{name}` is a {ty} column — it auto-fills its own values, \
                 so it cannot also carry a `default`.",
                name = column.name,
                ty = column.ty.keyword(),
            )));
        }
        // A CHECK on an auto-generated column is supported at
        // `create table` time; adding one to a `serial` /
        // `shortid` column afterwards is not (the auto-fill
        // rebuild path does not thread it). `check_sql` is the
        // advanced-mode raw form.
        if column.check.is_some() || column.check_sql.is_some() {
            return Err(DbError::Unsupported(format!(
                "a `check` constraint on the auto-generated column `{}` \
                 can only be set when the table is created.",
                column.name,
            )));
        }
        return do_add_auto_generated_column(conn, persistence, table, column);
    }
    // SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`
    // or `CHECK`, and a `NOT NULL` column added that way must
    // carry a default — all route through the rebuild primitive
    // instead (ADR-0029 §6). The advanced-mode raw forms
    // (`check_sql` / `default_sql`, ADR-0035 §4e) count alongside
    // the typed AST forms.
    if column.unique
        || column.check.is_some()
        || column.check_sql.is_some()
        || (column.not_null && column.default.is_none() && column.default_sql.is_none())
    {
        do_add_constrained_column_via_rebuild(conn, persistence, table, column)
    } else {
        do_add_plain_column(conn, persistence, table, column)
    }
}

/// Plain ALTER-TABLE path for non-auto-generated types.
fn do_add_plain_column(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    spec: &ColumnSpec,
) -> Result<AddColumnResult, DbError> {
    debug!(table = %table, column = %spec.name, "add_plain_column");
    // The plain `ALTER TABLE ADD COLUMN` path. `do_add_column`
    // only routes here when the constraints are ALTER-expressible
    // (no UNIQUE; NOT NULL only alongside a default), so the
    // ADR-0029 suffix appends cleanly.
    let ty = spec.ty;
    let column = spec.name.as_str();
    let ddl = format!(
        "ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type}{constraints};",
        tbl = quote_ident(table),
        col = quote_ident(column),
        sqlite_type = ty.sqlite_strict_type(),
        constraints = column_constraints_sql(spec)?,
    );
    debug!(ddl = %ddl, "add_column");
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
    tx.execute(
        &format!(
            "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
             VALUES (?1, ?2, ?3);"
        ),
        [table, column, ty.keyword()],
    )
    .map_err(DbError::from_rusqlite)?;
    let description = do_describe_table(conn, table)?;
    let changes = Changes {
        schema_dirty: true,
        rewritten_tables: vec![table.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(AddColumnResult {
        description,
        client_side_notes: Vec::new(),
    })
}

/// Auto-fill path for `serial` / `shortid` (ADR-0018 §6 + §9).
///
/// Empty table: the new column is added through the rebuild
/// primitive too, but no auto-fill / `[client-side]` note is
/// produced — there are no rows to populate.
fn do_add_auto_generated_column(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    spec: &ColumnSpec,
) -> Result<AddColumnResult, DbError> {
    debug!(table = %table, column = %spec.name, "add_auto_generated_column");
    use rusqlite::types::Value as RV;

    let ty = spec.ty;
    let column = spec.name.as_str();
    let old_schema = read_schema(conn, table)?;
    if old_schema.columns.iter().any(|c| c.name == column) {
        return Err(DbError::Unsupported(format!(
            "column `{table}.{column}` already exists."
        )));
    }
    let row_count = count_rows(conn, table)? as usize;

    // Build the new schema with the auto-generated column
    // appended. UNIQUE + NOT NULL emit per ADR-0018 §4.
    let mut new_schema = old_schema.clone();
    new_schema.columns.push(ReadColumn {
        name: column.to_string(),
        sqlite_type: ty.sqlite_strict_type().to_string(),
        notnull: true,
        primary_key: false,
        unique: true,
        default_sql: None,
        check: None,
        user_type: Some(ty),
    });

    // Generate auto-fill values for every existing row.
    let auto_fill_values: Vec<RV> = match ty {
        Type::Serial => (1..=row_count as i64).map(RV::Integer).collect(),
        Type::ShortId => generate_shortid_batch(row_count, &[])?,
        _ => unreachable!("guarded by `auto_generated` flag"),
    };

    let old_columns: Vec<String> = old_schema.columns.iter().map(|c| c.name.clone()).collect();
    let new_columns: Vec<String> = new_schema.columns.iter().map(|c| c.name.clone()).collect();

    let copy_data =
        |tx: &rusqlite::Transaction<'_>, temp_name: &str, orig: &str| -> Result<(), DbError> {
            if row_count == 0 {
                return Ok(());
            }
            // Read all rows from old, append the auto-fill value,
            // INSERT into temp.
            let select_cols = old_columns
                .iter()
                .map(|c| quote_ident(c))
                .collect::<Vec<_>>()
                .join(", ");
            let select_sql = format!(
                "SELECT {select_cols} FROM {orig};",
                orig = quote_ident(orig),
            );
            let cols_csv = new_columns
                .iter()
                .map(|c| quote_ident(c))
                .collect::<Vec<_>>()
                .join(", ");
            let placeholders = (1..=new_columns.len())
                .map(|i| format!("?{i}"))
                .collect::<Vec<_>>()
                .join(", ");
            let insert_sql = format!(
                "INSERT INTO {temp} ({cols_csv}) VALUES ({placeholders});",
                temp = quote_ident(temp_name),
            );

            let mut select_stmt = tx.prepare(&select_sql).map_err(DbError::from_rusqlite)?;
            let mut rows = select_stmt.query([]).map_err(DbError::from_rusqlite)?;
            let mut insert_stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?;
            let mut row_idx = 0usize;
            while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? {
                let mut values: Vec<RV> = Vec::with_capacity(new_columns.len());
                for i in 0..old_columns.len() {
                    values.push(r.get(i).map_err(DbError::from_rusqlite)?);
                }
                values.push(auto_fill_values[row_idx].clone());
                let params: Vec<&dyn rusqlite::ToSql> =
                    values.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
                insert_stmt
                    .execute(rusqlite::params_from_iter(params))
                    .map_err(DbError::from_rusqlite)?;
                row_idx += 1;
            }
            Ok(())
        };

    let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
        tx.execute(
            &format!(
                "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
                 VALUES (?1, ?2, ?3);"
            ),
            [table, column, ty.keyword()],
        )
        .map_err(DbError::from_rusqlite)?;
        let changes = Changes {
            schema_dirty: true,
            rewritten_tables: vec![table.to_string()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    };

    rebuild_table_with_copy(conn, table, &new_schema, copy_data, metadata_updates)?;

    let description = do_describe_table(conn, table)?;
    let mut client_side_notes = Vec::new();
    if row_count > 0 {
        client_side_notes.push(format_auto_fill_add_note(ty, row_count));
    }
    Ok(AddColumnResult {
        description,
        client_side_notes,
    })
}

/// Add a plain column whose constraints `ALTER TABLE ADD
/// COLUMN` cannot express — `UNIQUE`, or `NOT NULL` with no
/// default — through the rebuild primitive (ADR-0029 §6). A
/// pre-flight check refuses, with a friendly message, the
/// cases the table's existing rows would violate.
fn do_add_constrained_column_via_rebuild(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    spec: &ColumnSpec,
) -> Result<AddColumnResult, DbError> {
    debug!(table = %table, column = %spec.name, "add_constrained_column_via_rebuild");
    let old_schema = read_schema(conn, table)?;
    if old_schema.columns.iter().any(|c| c.name == spec.name) {
        return Err(DbError::Unsupported(format!(
            "column `{table}.{}` already exists.",
            spec.name,
        )));
    }
    let row_count = count_rows(conn, table)?;

    // ADR-0029 §6 pre-flight refusals — caught before any SQL
    // write, surfaced as friendly messages.
    // A default may come from the typed AST (`default`, simple mode) or
    // the raw advanced-mode text (`default_sql`, ADR-0035 §4e); either
    // satisfies the NOT-NULL backfill and triggers the UNIQUE collision
    // guard.
    let has_default = spec.default.is_some() || spec.default_sql.is_some();
    if spec.not_null && !has_default && row_count > 0 {
        return Err(DbError::Unsupported(format!(
            "adding the NOT NULL column `{}` to `{table}`, which already \
             has {row_count} row(s), needs a `default` — every existing \
             row would otherwise be null.",
            spec.name,
        )));
    }
    if spec.unique && has_default && row_count > 1 {
        return Err(DbError::Unsupported(format!(
            "adding the UNIQUE column `{}` with a default to `{table}` \
             would give all {row_count} existing rows the same value, \
             breaking uniqueness.",
            spec.name,
        )));
    }

    // The DEFAULT literal: the raw advanced-mode text wins over the typed
    // form (ADR-0035 §4e, mirroring `column_constraints_sql`).
    let default_sql = match &spec.default_sql {
        Some(raw) => Some(raw.clone()),
        None => default_sql_literal(spec)?,
    };
    // Append the new column to the schema; the rebuild's
    // column-by-name copy leaves it at its DEFAULT (or NULL).
    let mut new_schema = old_schema.clone();
    new_schema.columns.push(ReadColumn {
        name: spec.name.clone(),
        sqlite_type: spec.ty.sqlite_strict_type().to_string(),
        notnull: spec.not_null,
        primary_key: false,
        unique: spec.unique,
        default_sql,
        check: None,
        user_type: Some(spec.ty),
    });
    // The CHECK: the raw advanced-mode text (`check_sql`) wins; otherwise
    // the typed `check` AST is compiled against the post-add schema (so
    // it may reference the new column itself).
    let check_sql = spec.check_sql.clone().or_else(|| {
        spec.check
            .as_ref()
            .map(|e| compile_check_sql(e, &new_schema))
    });
    if let Some(last) = new_schema.columns.last_mut() {
        last.check.clone_from(&check_sql);
    }

    let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
        insert_column_metadata(tx, table, &spec.name, spec.ty, check_sql.as_deref())?;
        let changes = Changes {
            schema_dirty: true,
            rewritten_tables: vec![table.to_string()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    };

    rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;

    Ok(AddColumnResult {
        description: do_describe_table(conn, table)?,
        client_side_notes: Vec::new(),
    })
}

// =================================================================
// add constraint / drop constraint (ADR-0029 §2.2 / §5 / §9)
// =================================================================

/// Add a column-level constraint to an existing column
/// (ADR-0029 §2.2). Steps: resolve the column; apply the §9
/// redundant-on-PK and §6 default-on-auto-generated refusals;
/// run the §5 dry-run for the constraints existing data can
/// violate (`not null` / `unique` / `check`); then apply the
/// constraint through the rebuild-table primitive — SQLite's
/// `ALTER TABLE` cannot add these to an existing column.
fn do_add_constraint(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    column: &str,
    constraint: &Constraint,
) -> Result<TableDescription, DbError> {
    debug!(table = %table, column = %column, "add_constraint");
    // Canonicalize to the stored case (and refuse a non-existent /
    // internal `__rdbms_*` table as "no such table"), like the sibling
    // schema-mutation executors. Closes the simple `add constraint`
    // exposure and the SQL `ALTER TABLE … ADD CONSTRAINT` decomposition
    // target (ADR-0035 §4g).
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let old_schema = read_schema(conn, table)?;
    let (col_is_pk, col_user_type) = {
        let col = old_schema
            .columns
            .iter()
            .find(|c| c.name == column)
            .ok_or_else(|| DbError::Sqlite {
                message: format!("no such column: {table}.{column}"),
                kind: SqliteErrorKind::NoSuchColumn,
            })?;
        (col.primary_key, col.user_type)
    };
    let single_column_pk = old_schema.primary_key.len() == 1;

    // ADR-0029 §9 — a constraint the primary key already
    // implies is a friendly refusal, not a silent no-op.
    match constraint {
        Constraint::NotNull if col_is_pk => {
            return Err(DbError::Unsupported(format!(
                "`{table}.{column}` is a primary-key column, so it is already \
                 NOT NULL — there is no constraint to add."
            )));
        }
        Constraint::Unique if col_is_pk && single_column_pk => {
            return Err(DbError::Unsupported(format!(
                "`{table}.{column}` is a single-column primary key, so it is \
                 already UNIQUE — there is no constraint to add."
            )));
        }
        // ADR-0029 §6 — an auto-generated column fills its own
        // values, so a `default` would be a second, ambiguous
        // source of "the value when none is given".
        Constraint::Default(_) if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) => {
            return Err(DbError::Unsupported(format!(
                "`{table}.{column}` is a {ty} column — it auto-fills its own \
                 values, so it cannot also carry a `default`.",
                ty = col_user_type.expect("matched Some above").keyword(),
            )));
        }
        _ => {}
    }

    // Compile the CHECK once — reused by the dry-run predicate
    // and the column DDL / metadata write.
    let check_sql: Option<String> = match constraint {
        Constraint::Check(expr) => Some(compile_check_sql(expr, &old_schema)),
        _ => None,
    };

    // ADR-0029 §5 — refuse, before any SQL write, when the
    // existing rows violate the constraint. `default` never
    // touches existing rows, so it skips the dry-run.
    let refusal = match constraint {
        Constraint::NotNull => dry_run_not_null(conn, &old_schema, table, column)?,
        Constraint::Unique => dry_run_unique(conn, &old_schema, table, column)?,
        Constraint::Check(_) => dry_run_check(
            conn,
            &old_schema,
            table,
            column,
            check_sql
                .as_deref()
                .expect("check_sql set for a Check constraint"),
        )?,
        Constraint::Default(_) => None,
    };
    if let Some(message) = refusal {
        return Err(DbError::Unsupported(message));
    }

    // Build the post-add schema: the target column gains the
    // constraint; every other column carries over unchanged.
    let mut new_schema = old_schema.clone();
    {
        let target = new_schema
            .columns
            .iter_mut()
            .find(|c| c.name == column)
            .expect("column existence checked above");
        match constraint {
            Constraint::NotNull => target.notnull = true,
            Constraint::Unique => target.unique = true,
            Constraint::Default(value) => {
                let ty = target.user_type.ok_or_else(|| {
                    DbError::Unsupported(format!(
                        "`{table}.{column}` has no user-type metadata; \
                         cannot bind a default value."
                    ))
                })?;
                target.default_sql = Some(value_to_default_sql(value, column, ty)?);
            }
            Constraint::Check(_) => target.check.clone_from(&check_sql),
        }
    }

    let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
        // Only CHECK needs a metadata write — NOT NULL / UNIQUE /
        // DEFAULT are recoverable from the engine's own catalog
        // (ADR-0029 §7).
        if matches!(constraint, Constraint::Check(_)) {
            tx.execute(
                &format!(
                    "UPDATE {META_TABLE} SET check_expr = ?1 \
                     WHERE table_name = ?2 AND column_name = ?3;"
                ),
                rusqlite::params![check_sql, table, column],
            )
            .map_err(DbError::from_rusqlite)?;
        }
        let changes = Changes {
            schema_dirty: true,
            rewritten_tables: vec![table.to_string()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    };

    rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
    do_describe_table(conn, table)
}

/// Remove a column-level constraint from an existing column
/// (ADR-0029 §2.2). Removing a constraint cannot violate the
/// data, so there is no dry-run; the §9 PK-implied refusals
/// still apply, and dropping a constraint the column does not
/// carry is itself a friendly refusal.
fn do_drop_constraint(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    column: &str,
    kind: ConstraintKind,
) -> Result<TableDescription, DbError> {
    debug!(table = %table, column = %column, "drop_constraint");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let old_schema = read_schema(conn, table)?;
    let (col_is_pk, present) = {
        let col = old_schema
            .columns
            .iter()
            .find(|c| c.name == column)
            .ok_or_else(|| DbError::Sqlite {
                message: format!("no such column: {table}.{column}"),
                kind: SqliteErrorKind::NoSuchColumn,
            })?;
        let present = match kind {
            ConstraintKind::NotNull => col.notnull,
            ConstraintKind::Unique => col.unique,
            ConstraintKind::Default => col.default_sql.is_some(),
            ConstraintKind::Check => col.check.is_some(),
        };
        (col.primary_key, present)
    };
    let single_column_pk = old_schema.primary_key.len() == 1;

    // ADR-0029 §9 — the primary key still enforces these, so
    // there is nothing for `drop constraint` to remove.
    match kind {
        ConstraintKind::NotNull if col_is_pk => {
            return Err(DbError::Unsupported(format!(
                "`{table}.{column}` is a primary-key column — its NOT NULL is \
                 enforced by the primary key and cannot be dropped."
            )));
        }
        ConstraintKind::Unique if col_is_pk && single_column_pk => {
            return Err(DbError::Unsupported(format!(
                "`{table}.{column}` is a single-column primary key — its \
                 UNIQUE is enforced by the primary key and cannot be dropped."
            )));
        }
        _ => {}
    }

    if !present {
        return Err(DbError::Unsupported(format!(
            "`{table}.{column}` has no {kind} constraint to drop.",
            kind = kind.label(),
        )));
    }

    let mut new_schema = old_schema.clone();
    {
        let target = new_schema
            .columns
            .iter_mut()
            .find(|c| c.name == column)
            .expect("column existence checked above");
        match kind {
            ConstraintKind::NotNull => target.notnull = false,
            ConstraintKind::Unique => target.unique = false,
            ConstraintKind::Default => target.default_sql = None,
            ConstraintKind::Check => target.check = None,
        }
    }

    let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
        if kind == ConstraintKind::Check {
            tx.execute(
                &format!(
                    "UPDATE {META_TABLE} SET check_expr = NULL \
                     WHERE table_name = ?1 AND column_name = ?2;"
                ),
                rusqlite::params![table, column],
            )
            .map_err(DbError::from_rusqlite)?;
        }
        let changes = Changes {
            schema_dirty: true,
            rewritten_tables: vec![table.to_string()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    };

    rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
    do_describe_table(conn, table)
}

/// `ALTER TABLE … ALTER COLUMN <col> SET DEFAULT <expr>` — set the
/// column's default to raw SQL text (ADR-0035 Amendment 2). Mirrors
/// `do_add_constraint`'s `Default` branch (the §6 `serial`/`shortid`
/// refusal; no dry-run — a default never touches existing rows) but
/// takes raw `sql_expr` text instead of a typed `Value` (advanced
/// `SET DEFAULT` has no AST). DEFAULT is recoverable from the engine
/// catalog, so there is no metadata write — the rebuilt table's DDL
/// carries it (ADR-0029 §7).
fn do_set_column_default(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    column: &str,
    default_sql: &str,
) -> Result<TableDescription, DbError> {
    debug!(table = %table, column = %column, "set_column_default");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let old_schema = read_schema(conn, table)?;
    let col_user_type = {
        let col = old_schema
            .columns
            .iter()
            .find(|c| c.name == column)
            .ok_or_else(|| DbError::Sqlite {
                message: format!("no such column: {table}.{column}"),
                kind: SqliteErrorKind::NoSuchColumn,
            })?;
        col.user_type
    };

    // ADR-0029 §6 — an auto-generated column fills its own values, so a
    // `default` would be a second, ambiguous source of "the value when
    // none is given" (mirrors do_add_constraint).
    if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) {
        return Err(DbError::Unsupported(format!(
            "`{table}.{column}` is a {ty} column — it auto-fills its own \
             values, so it cannot also carry a `default`.",
            ty = col_user_type.expect("matched Some above").keyword(),
        )));
    }

    let mut new_schema = old_schema.clone();
    {
        let target = new_schema
            .columns
            .iter_mut()
            .find(|c| c.name == column)
            .expect("column existence checked above");
        target.default_sql = Some(default_sql.to_string());
    }

    let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
        let changes = Changes {
            schema_dirty: true,
            rewritten_tables: vec![table.to_string()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    };

    rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
    do_describe_table(conn, table)
}

/// A row's primary-key cell values paired with its value in
/// the column under test — the unit an ADR-0029 §5 dry-run
/// scans.
type DryRunRow = (Vec<rusqlite::types::Value>, rusqlite::types::Value);

/// Read the primary-key cell values of every row of `table`
/// matching `where_sql`, paired with the row's value in
/// `column`. The shared read behind the three ADR-0029 §5
/// dry-runs; `where_sql` is built from `quote_ident`-quoted
/// names and `compile_check_sql` output, never raw user text.
fn read_constraint_dry_run_rows(
    conn: &Connection,
    schema: &ReadSchema,
    table: &str,
    column: &str,
    where_sql: &str,
) -> Result<Vec<DryRunRow>, DbError> {
    use rusqlite::types::Value as RV;
    let pk_columns = &schema.primary_key;
    let mut select_idents: Vec<String> = pk_columns.iter().map(|c| quote_ident(c)).collect();
    select_idents.push(quote_ident(column));
    let sql = format!(
        "SELECT {cols} FROM {tbl} WHERE {pred};",
        cols = select_idents.join(", "),
        tbl = quote_ident(table),
        pred = where_sql,
    );
    let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
    let pk_count = pk_columns.len();
    let mut out = Vec::new();
    let mut rows = stmt.query([]).map_err(DbError::from_rusqlite)?;
    while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? {
        let mut pk_values: Vec<RV> = Vec::with_capacity(pk_count);
        for i in 0..pk_count {
            pk_values.push(r.get(i).map_err(DbError::from_rusqlite)?);
        }
        let target: RV = r.get(pk_count).map_err(DbError::from_rusqlite)?;
        out.push((pk_values, target));
    }
    Ok(out)
}

/// Header cells identifying an offending row in a §5 dry-run
/// table — the primary-key columns. The DSL always creates a
/// primary key, so the no-PK fallback (the constrained column
/// itself) is defensive only.
fn dry_run_id_headers(schema: &ReadSchema, column: &str) -> Vec<String> {
    if schema.primary_key.is_empty() {
        vec![column.to_string()]
    } else {
        pk_header_cells(&schema.primary_key)
    }
}

fn dry_run_id_alignments(schema: &ReadSchema) -> Vec<Alignment> {
    if schema.primary_key.is_empty() {
        vec![Alignment::Left]
    } else {
        pk_header_alignments(&schema.primary_key, schema)
    }
}

fn dry_run_id_cells(
    schema: &ReadSchema,
    pk_values: &[rusqlite::types::Value],
    target: &rusqlite::types::Value,
) -> Vec<String> {
    if schema.primary_key.is_empty() {
        vec![render_value(target)]
    } else {
        pk_value_cells(pk_values)
    }
}

/// Assemble a §5 dry-run refusal: a summary line above the
/// pretty-printed table of offending rows.
fn render_constraint_dry_run(
    summary: String,
    headers: &[String],
    alignments: &[Alignment],
    rows: Vec<Vec<String>>,
) -> String {
    let mut out = format!("{summary}\n\n");
    for line in render_diagnostic_table(headers, &rows, alignments) {
        out.push_str(&line);
        out.push('\n');
    }
    out
}

/// ADR-0029 §5 dry-run for `add constraint not null`: any row
/// whose target column holds `NULL` violates the constraint.
fn dry_run_not_null(
    conn: &Connection,
    schema: &ReadSchema,
    table: &str,
    column: &str,
) -> Result<Option<String>, DbError> {
    let rows = read_constraint_dry_run_rows(
        conn,
        schema,
        table,
        column,
        &format!("{col} IS NULL", col = quote_ident(column)),
    )?;
    if rows.is_empty() {
        return Ok(None);
    }
    let total = rows.len();
    let headers = dry_run_id_headers(schema, column);
    let alignments = dry_run_id_alignments(schema);
    let visible = total.min(DIAGNOSTIC_ROW_CAP);
    let mut out_rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
    for (pk, target) in rows.iter().take(visible) {
        out_rows.push(dry_run_id_cells(schema, pk, target));
    }
    if total > visible {
        out_rows.push(more_row(headers.len(), total - visible));
    }
    Ok(Some(render_constraint_dry_run(
        crate::t!(
            "db.diagnostic.add_not_null_summary",
            table = table,
            column = column,
            total = total,
        ),
        &headers,
        &alignments,
        out_rows,
    )))
}

/// ADR-0029 §5 dry-run for `add constraint unique`: any
/// non-`NULL` value shared by two or more rows collides
/// (SQL's "NULLs are distinct" rule means `NULL`s never do).
fn dry_run_unique(
    conn: &Connection,
    schema: &ReadSchema,
    table: &str,
    column: &str,
) -> Result<Option<String>, DbError> {
    use std::collections::BTreeMap;

    let rows = read_constraint_dry_run_rows(
        conn,
        schema,
        table,
        column,
        &format!("{col} IS NOT NULL", col = quote_ident(column)),
    )?;
    let mut groups: BTreeMap<String, Vec<DryRunRow>> = BTreeMap::new();
    for (pk, target) in rows {
        groups
            .entry(render_value(&target))
            .or_default()
            .push((pk, target));
    }
    let collisions: Vec<(String, Vec<DryRunRow>)> = groups
        .into_iter()
        .filter(|(_, members)| members.len() >= 2)
        .collect();
    if collisions.is_empty() {
        return Ok(None);
    }

    let total = collisions.len();
    let pk_label = if schema.primary_key.is_empty() {
        column.to_string()
    } else {
        schema.primary_key.join(", ")
    };
    let headers = vec![
        crate::t!("db.diagnostic.header_value"),
        crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label),
    ];
    let alignments = vec![Alignment::Left, Alignment::Left];
    let visible = total.min(DIAGNOSTIC_ROW_CAP);
    let mut out_rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
    for (value, members) in collisions.iter().take(visible) {
        let ids = members
            .iter()
            .take(5)
            .map(|(pk, target)| {
                if schema.primary_key.is_empty() {
                    render_value(target)
                } else {
                    pk_value_cells_inline(pk)
                }
            })
            .collect::<Vec<_>>()
            .join(", ");
        let ids = if members.len() > 5 {
            format!("{ids}, …")
        } else {
            ids
        };
        out_rows.push(vec![value.clone(), ids]);
    }
    if total > visible {
        out_rows.push(more_row(headers.len(), total - visible));
    }
    Ok(Some(render_constraint_dry_run(
        crate::t!(
            "db.diagnostic.add_unique_summary",
            table = table,
            column = column,
            total = total,
        ),
        &headers,
        &alignments,
        out_rows,
    )))
}

/// ADR-0029 §5 dry-run for `add constraint check`: any row for
/// which the compiled `check_sql` is definitively false
/// violates the constraint. (`NOT (expr)` is true only when
/// `expr` is false — a `NULL` result, which the engine's CHECK
/// also tolerates, does not select the row.)
fn dry_run_check(
    conn: &Connection,
    schema: &ReadSchema,
    table: &str,
    column: &str,
    check_sql: &str,
) -> Result<Option<String>, DbError> {
    let rows =
        read_constraint_dry_run_rows(conn, schema, table, column, &format!("NOT ({check_sql})"))?;
    if rows.is_empty() {
        return Ok(None);
    }
    let total = rows.len();
    let mut headers = dry_run_id_headers(schema, column);
    headers.push(crate::t!("db.diagnostic.header_value"));
    let mut alignments = dry_run_id_alignments(schema);
    alignments.push(Alignment::Left);
    let visible = total.min(DIAGNOSTIC_ROW_CAP);
    let mut out_rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
    for (pk, target) in rows.iter().take(visible) {
        let mut cells = dry_run_id_cells(schema, pk, target);
        cells.push(render_value(target));
        out_rows.push(cells);
    }
    if total > visible {
        out_rows.push(more_row(headers.len(), total - visible));
    }
    Ok(Some(render_constraint_dry_run(
        crate::t!(
            "db.diagnostic.add_check_summary",
            table = table,
            column = column,
            total = total,
            rule = check_sql,
        ),
        &headers,
        &alignments,
        out_rows,
    )))
}

/// Generate `count` shortid values that don't collide with each
/// other or with `existing` (a slice of currently-stored
/// shortid values, used during change-column-to-shortid). Up to
/// 5 retries per cell per ADR-0018 §3.
fn generate_shortid_batch(
    count: usize,
    existing: &[String],
) -> Result<Vec<rusqlite::types::Value>, DbError> {
    use std::collections::HashSet;
    let mut taken: HashSet<String> = existing.iter().cloned().collect();
    let mut out: Vec<rusqlite::types::Value> = Vec::with_capacity(count);
    for _ in 0..count {
        let mut generated: Option<String> = None;
        for _ in 0..5 {
            let candidate = shortid::generate();
            if !taken.contains(&candidate) {
                taken.insert(candidate.clone());
                generated = Some(candidate);
                break;
            }
        }
        match generated {
            Some(v) => out.push(rusqlite::types::Value::Text(v)),
            None => {
                return Err(DbError::Unsupported(
                    "could not generate a unique shortid after 5 attempts; \
                     this typically indicates a generator-state issue, not \
                     a recoverable user error."
                        .to_string(),
                ));
            }
        }
    }
    Ok(out)
}

/// `[client-side]` note line for `add column T: x (serial|shortid)`
/// on a non-empty table per ADR-0018 §9.
fn format_auto_fill_add_note(ty: Type, row_count: usize) -> String {
    match ty {
        Type::Serial => {
            crate::t!("client_side.auto_fill_add_serial", count = row_count)
        }
        Type::ShortId => {
            crate::t!("client_side.auto_fill_add_shortid", count = row_count)
        }
        _ => unreachable!("called only for serial/shortid"),
    }
}

/// Drop a column from a table.
///
/// Uses SQLite's native `ALTER TABLE … DROP COLUMN`
/// (available since SQLite 3.35) so we get the engine's
/// constraint checks for free; SQLite refuses if the column
/// is part of the PK, has a UNIQUE constraint, is referenced
/// in a CHECK, or is used in an FK. In addition we run two
/// up-front checks so the user gets friendly messages
/// before SQLite refuses:
///
/// - Refuse PK columns (the dominant case the user might
///   try).
/// - Refuse columns involved in a declared relationship
///   (per `__rdbms_playground_relationships`). Drop the
///   relationship first.
fn do_drop_column(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    column: &str,
    cascade: bool,
) -> Result<DropColumnResult, DbError> {
    debug!(table = %table, column = %column, cascade, "drop_column");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let schema = read_schema(conn, table)?;
    let col_info = schema
        .columns
        .iter()
        .find(|c| c.name == column)
        .ok_or_else(|| DbError::Sqlite {
            message: format!("no such column: {table}.{column}"),
            kind: SqliteErrorKind::NoSuchColumn,
        })?;
    if col_info.primary_key {
        return Err(DbError::Unsupported(format!(
            "cannot drop primary-key column `{table}.{column}`. \
             Drop the table or change the primary key first."
        )));
    }
    let rel_count: i64 = conn
        .query_row(
            &format!(
                "SELECT COUNT(*) FROM {REL_TABLE} \
                 WHERE (parent_table = ?1 AND parent_column = ?2) \
                    OR (child_table = ?1 AND child_column = ?2);"
            ),
            rusqlite::params![table, column],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    if rel_count > 0 {
        return Err(DbError::Unsupported(format!(
            "cannot drop `{table}.{column}` while a relationship \
             references it; drop the relationship first."
        )));
    }

    // Indexes covering this column (ADR-0025). Without
    // `--cascade` a covered column is refused; with it, the
    // covering indexes are dropped alongside the column.
    let covering: Vec<IndexInfo> = read_table_indexes(conn, table)?
        .into_iter()
        .filter(|i| i.columns.iter().any(|c| c == column))
        .collect();
    if !covering.is_empty() && !cascade {
        let names = covering
            .iter()
            .map(|i| format!("`{}`", i.name))
            .collect::<Vec<_>>()
            .join(", ");
        return Err(DbError::Unsupported(format!(
            "cannot drop `{table}.{column}` while an index covers \
             it ({names}); drop the index first, or pass `--cascade` \
             to drop the covering indexes too."
        )));
    }

    // A composite UNIQUE covering this column (ADR-0035 Amendment 1): the
    // engine refuses to drop a column a UNIQUE constraint spans, so refuse
    // up-front with the constraint's derived name and the actionable drop
    // command. Single-column UNIQUEs ride on the column `unique` flag (the
    // engine drops their auto-index with the column), not
    // `unique_constraints`, so they do not reach here.
    if let Some(cols) = schema
        .unique_constraints
        .iter()
        .find(|cols| cols.iter().any(|c| c == column))
    {
        let cname = unique_constraint_name(cols);
        return Err(DbError::Unsupported(format!(
            "cannot drop `{table}.{column}` — it is part of the UNIQUE \
             constraint `{cname}` ({}); drop that constraint first \
             (`alter table {table} drop constraint {cname}`).",
            cols.join(", "),
        )));
    }

    // A single-column UNIQUE on this column (ADR-0029): the engine refuses
    // to drop a column carrying a UNIQUE constraint. Unlike a composite
    // UNIQUE (handled above), a single-column UNIQUE is removed by the
    // column-level `drop constraint` — point there (ADR-0035 Amendment 1,
    // gap B).
    if col_info.unique {
        return Err(DbError::Unsupported(format!(
            "cannot drop `{table}.{column}` — it has a UNIQUE constraint; \
             remove the constraint first (`drop constraint unique from \
             {table}.{column}`), then drop the column."
        )));
    }

    // A CHECK (table-level, or a *different* column's column-level CHECK)
    // that references this column (ADR-0035 §4e, the 4a.3 deferral): a
    // deliberate up-front refusal — dropping the column would break that
    // CHECK and the rebuilt DDL would name a missing column. The column's
    // own column-level CHECK drops with it, so it does not block.
    // Friendly wording is H1. Guards both surfaces.
    if column_referenced_by_check(conn, table, &schema, column, false)? {
        return Err(DbError::Unsupported(format!(
            "cannot drop `{table}.{column}` — a CHECK constraint refers to \
             it, and dropping the column would leave that rule pointing at \
             a column that no longer exists. Drop or change the CHECK \
             constraint first, then drop the column."
        )));
    }

    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    // Drop covering indexes first — the engine refuses
    // DROP COLUMN on an indexed column otherwise. `covering`
    // is empty unless `--cascade` was given (the refusal above).
    for index in &covering {
        tx.execute_batch(&format!(
            "DROP INDEX {ident};",
            ident = quote_ident(&index.name)
        ))
        .map_err(DbError::from_rusqlite)?;
    }
    let ddl = format!(
        "ALTER TABLE {tbl} DROP COLUMN {col};",
        tbl = quote_ident(table),
        col = quote_ident(column),
    );
    debug!(ddl = %ddl, "drop_column");
    tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
    tx.execute(
        &format!("DELETE FROM {META_TABLE} WHERE table_name = ?1 AND column_name = ?2;"),
        [table, column],
    )
    .map_err(DbError::from_rusqlite)?;
    let description = do_describe_table(conn, table)?;
    let changes = Changes {
        schema_dirty: true,
        rewritten_tables: vec![table.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(DropColumnResult {
        description,
        dropped_indexes: covering.into_iter().map(|i| i.name).collect(),
    })
}

/// Rename a column.
///
/// Uses SQLite's native `ALTER TABLE … RENAME COLUMN`
/// (available since SQLite 3.25), which automatically
/// updates references in views, triggers, and FK
/// declarations on other tables. We mirror the rename into
/// our two metadata tables so they don't drift.
fn do_rename_column(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    old: &str,
    new: &str,
) -> Result<TableDescription, DbError> {
    debug!(table = %table, old = %old, new = %new, "rename_column");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let schema = read_schema(conn, table)?;
    if !schema.columns.iter().any(|c| c.name == old) {
        return Err(DbError::Sqlite {
            message: format!("no such column: {table}.{old}"),
            kind: SqliteErrorKind::NoSuchColumn,
        });
    }
    // A CHECK that references this column (ADR-0035 §4e): refuse — a
    // native RENAME COLUMN rewrites the live CHECK but the stored CHECK
    // text (table-level in `__rdbms_playground_table_checks`, or
    // column-level in `__rdbms_playground_columns`) keeps the old name,
    // drifting the metadata and breaking a later rebuild. A column's
    // *own* column-level CHECK drifts too (`include_self = true`).
    // Deliberate refusal (friendly wording is H1); guards both surfaces.
    if column_referenced_by_check(conn, table, &schema, old, true)? {
        return Err(DbError::Unsupported(format!(
            "cannot rename `{table}.{old}` — a CHECK constraint refers to \
             it by name, and the rename would leave that rule pointing at \
             the old name. Drop or change the CHECK constraint first, then \
             rename the column."
        )));
    }
    if old == new {
        // Nothing to do; refusing keeps behaviour
        // predictable rather than appearing to "succeed"
        // with no effect.
        return Err(DbError::Unsupported(format!(
            "rename: new name is identical to the existing one (`{old}`)."
        )));
    }
    if schema.columns.iter().any(|c| c.name == new) {
        return Err(DbError::Unsupported(format!(
            "column `{table}.{new}` already exists; pick a different name."
        )));
    }

    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    let ddl = format!(
        "ALTER TABLE {tbl} RENAME COLUMN {old_c} TO {new_c};",
        tbl = quote_ident(table),
        old_c = quote_ident(old),
        new_c = quote_ident(new),
    );
    debug!(ddl = %ddl, "rename_column");
    tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;

    // Mirror the rename into __rdbms_playground_columns.
    tx.execute(
        &format!(
            "UPDATE {META_TABLE} SET column_name = ?1 \
             WHERE table_name = ?2 AND column_name = ?3;"
        ),
        [new, table, old],
    )
    .map_err(DbError::from_rusqlite)?;
    // Mirror into __rdbms_playground_relationships on
    // BOTH sides — a column may be the parent endpoint or
    // the child endpoint (or, for self-referencing tables,
    // both).
    tx.execute(
        &format!(
            "UPDATE {REL_TABLE} SET parent_column = ?1 \
             WHERE parent_table = ?2 AND parent_column = ?3;"
        ),
        [new, table, old],
    )
    .map_err(DbError::from_rusqlite)?;
    tx.execute(
        &format!(
            "UPDATE {REL_TABLE} SET child_column = ?1 \
             WHERE child_table = ?2 AND child_column = ?3;"
        ),
        [new, table, old],
    )
    .map_err(DbError::from_rusqlite)?;

    let description = do_describe_table(conn, table)?;
    let changes = Changes {
        schema_dirty: true,
        rewritten_tables: vec![table.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(description)
}

/// Rename a table (ADR-0035 §6, sub-phase 4h) — the one genuinely new
/// low-level op in Phase 4.
///
/// Uses SQLite's native `ALTER TABLE … RENAME TO` (structure-preserving,
/// so no rebuild), which also rewrites references to the old name in
/// other tables' FK declarations and inside the renamed table's own
/// CHECK / self-FK definitions (the engine's modern, non-legacy
/// behaviour). We then reconcile everything the engine does *not* know
/// about, all in one transaction (commit-db-last, ADR-0015 §6):
///
/// 1. every metadata row that names the table — `__rdbms_playground_columns`
///    (`table_name`), **both ends** of `__rdbms_playground_relationships`
///    (`parent_table` *and* `child_table`, covering a self-referential
///    table), and `__rdbms_playground_table_checks` (`table_name`);
/// 2. CHECK *text* that qualifies a column with the old table name
///    (`T.age` → `U.age`), in both metadata tables — the live schema was
///    already rewritten, so the stored text must match or a rebuild fails;
/// 3. the CSV file, via the existing persistence rewrite+delete path
///    (`rewritten_tables = [new]`, `deleted_tables = [old]`).
///
/// Auto-named indexes and relationships keep their (now stale but
/// functional) names — only the table-name *columns* update (ADR-0035 §6
/// scope; user-confirmed). One undo step (the worker's whole-project
/// snapshot).
fn do_rename_table(
    conn: &Connection,
    persistence: Option<&Persistence>,
    old: &str,
    new: &str,
) -> Result<TableDescription, DbError> {
    debug!(old = %old, new = %new, "rename_table");
    reject_internal_table_name(new)?;
    // Canonicalize the source to its stored case (and refuse a
    // non-existent / internal source as "no such table") — so a
    // case-variant source name still resolves to the real table and the
    // metadata UPDATEs below match the stored case.
    let canonical_old = require_canonical_table(conn, old)?;
    let old = canonical_old.as_str();

    if old == new {
        return Err(DbError::Unsupported(format!(
            "rename: new name is identical to the existing one (`{old}`)."
        )));
    }
    // The database matches table names case-insensitively, so collision
    // checks must be case-insensitive too — otherwise the native rename
    // surfaces a raw engine collision error (ADR-0035 §9). A target that
    // differs from the source only in capitalization is a no-op the engine
    // cannot perform; a target colliding with a *different* table is the
    // ordinary "already exists" refusal.
    if old.eq_ignore_ascii_case(new) {
        return Err(DbError::Unsupported(format!(
            "`{old}` and `{new}` differ only in capitalization; the database \
             treats them as the same table, so there is nothing to rename."
        )));
    }
    let tables = do_list_tables(conn)?;
    if tables.iter().any(|t| t.eq_ignore_ascii_case(new)) {
        return Err(DbError::Unsupported(format!(
            "table `{new}` already exists; pick a different name."
        )));
    }

    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    let ddl = format!(
        "ALTER TABLE {old_t} RENAME TO {new_t};",
        old_t = quote_ident(old),
        new_t = quote_ident(new),
    );
    debug!(ddl = %ddl, "rename_table");
    tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;

    // (1) Rename the table name in every metadata row that names it.
    tx.execute(
        &format!("UPDATE {META_TABLE} SET table_name = ?1 WHERE table_name = ?2;"),
        [new, old],
    )
    .map_err(DbError::from_rusqlite)?;
    // Both relationship ends — parent and child — so an FK parent, an FK
    // child, and a self-referencing table are all covered. The
    // relationship `name` is left as-is (auto-names embed the old table
    // name; stale-but-functional per the user decision, like index names).
    tx.execute(
        &format!("UPDATE {REL_TABLE} SET parent_table = ?1 WHERE parent_table = ?2;"),
        [new, old],
    )
    .map_err(DbError::from_rusqlite)?;
    tx.execute(
        &format!("UPDATE {REL_TABLE} SET child_table = ?1 WHERE child_table = ?2;"),
        [new, old],
    )
    .map_err(DbError::from_rusqlite)?;
    tx.execute(
        &format!("UPDATE {CHECK_TABLE} SET table_name = ?1 WHERE table_name = ?2;"),
        [new, old],
    )
    .map_err(DbError::from_rusqlite)?;

    // (2) Reconcile CHECK *text* that qualifies a reference with the old
    // table name. Read the rows (now keyed by `new`), rewrite, write back
    // only when changed — the common unqualified CHECK is a no-op.
    let col_checks: Vec<(String, String)> = {
        let mut stmt = tx
            .prepare(&format!(
                "SELECT column_name, check_expr FROM {META_TABLE} \
                 WHERE table_name = ?1 AND check_expr IS NOT NULL;"
            ))
            .map_err(DbError::from_rusqlite)?;
        let rows = stmt
            .query_map([new], |r| {
                Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
            })
            .map_err(DbError::from_rusqlite)?;
        let mut v = Vec::new();
        for row in rows {
            v.push(row.map_err(DbError::from_rusqlite)?);
        }
        v
    };
    for (column_name, expr) in col_checks {
        let rewritten = rewrite_check_table_qualifier(&expr, old, new);
        if rewritten != expr {
            tx.execute(
                &format!(
                    "UPDATE {META_TABLE} SET check_expr = ?1 \
                     WHERE table_name = ?2 AND column_name = ?3;"
                ),
                rusqlite::params![rewritten, new, column_name],
            )
            .map_err(DbError::from_rusqlite)?;
        }
    }
    let table_checks: Vec<(i64, String)> = {
        let mut stmt = tx
            .prepare(&format!(
                "SELECT seq, check_expr FROM {CHECK_TABLE} WHERE table_name = ?1;"
            ))
            .map_err(DbError::from_rusqlite)?;
        let rows = stmt
            .query_map([new], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
            .map_err(DbError::from_rusqlite)?;
        let mut v = Vec::new();
        for row in rows {
            v.push(row.map_err(DbError::from_rusqlite)?);
        }
        v
    };
    for (seq, expr) in table_checks {
        let rewritten = rewrite_check_table_qualifier(&expr, old, new);
        if rewritten != expr {
            tx.execute(
                &format!(
                    "UPDATE {CHECK_TABLE} SET check_expr = ?1 \
                     WHERE table_name = ?2 AND seq = ?3;"
                ),
                rusqlite::params![rewritten, new, seq],
            )
            .map_err(DbError::from_rusqlite)?;
        }
    }

    let description = do_describe_table(conn, new)?;
    // (3) CSV follows the table: write `data/<new>.csv` from the renamed
    // table (read in-tx by its new name) and delete `data/<old>.csv` — the
    // existing rewrite+delete path; an empty table writes no CSV on either
    // side. `schema_dirty` rewrites `project.yaml`, which reflects the new
    // name automatically.
    let changes = Changes {
        schema_dirty: true,
        rewritten_tables: vec![new.to_string()],
        deleted_tables: vec![old.to_string()],
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(description)
}

/// Change a column's type.
///
/// Change a column's user-facing type, per ADR-0017's per-cell
/// transformer model.
///
/// SQLite's `ALTER TABLE` cannot change column types, so this
/// routes through the rebuild-table primitive (ADR-0013) with a
/// row-by-row transformation step inserted between read-out and
/// write-back.
///
/// Refusal preconditions (in order):
///
/// 1. Target is `serial` (auto-increment is create-table-only).
/// 2. Source ↔ target is statically refused per the matrix
///    (same-type, date↔datetime, undefined cross-domain).
/// 3. Column is the *child* side of any relationship (outbound
///    FK) — drop the relationship first.
/// 4. Column has any inbound FK (parent side) and the new type
///    would change `fk_target_type()` (ADR-0017 §4.1) — which
///    means referencing FK columns in other tables would need a
///    cascading type change v1 doesn't perform.
///
/// Then per the mode:
///
/// - `Default`: dry-run; refuse on any incompatible cell;
///   refuse on any lossy cell (with a hint about
///   `--force-conversion`); enforce uniqueness for PK / shortid
///   columns; rebuild with transformed values.
/// - `ForceConversion`: same as Default except lossy cells are
///   accepted (incompatible cells still refuse).
/// - `DontConvert`: skip the entire client-side layer, identity
///   copy via `INSERT…SELECT`; engine-level errors are wrapped
///   in friendly form (ADR-0002 user-facing posture).
fn do_change_column_type(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    column: &str,
    ty: Type,
    mode: ChangeColumnMode,
) -> Result<ChangeColumnTypeResult, DbError> {
    debug!(table = %table, column = %column, ty = %ty, mode = ?mode, "change_column_type");
    // Canonicalize to the stored case (and refuse a non-existent /
    // internal `__rdbms_*` table as "no such table"), like the sibling
    // column executors. Closes the simple `change column` exposure and
    // the SQL `ALTER COLUMN TYPE` decomposition target (ADR-0035 §4f);
    // user-confirmed 2026-05-25.
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let old_schema = read_schema(conn, table)?;
    let col_info = old_schema
        .columns
        .iter()
        .find(|c| c.name == column)
        .ok_or_else(|| DbError::Sqlite {
            message: format!("no such column: {table}.{column}"),
            kind: SqliteErrorKind::NoSuchColumn,
        })?;
    let src_ty = col_info.user_type.ok_or_else(|| {
        DbError::Unsupported(format!(
            "cannot change type of `{table}.{column}`: column has no \
             user-facing type metadata."
        ))
    })?;

    // (1) + (2): static refusals via the matrix.
    if let Some(reason) = type_change::static_refusal(src_ty, ty) {
        return Err(DbError::Unsupported(reason));
    }

    // (3): outbound FK (column is child side of any relationship).
    let outbound_count: i64 = conn
        .query_row(
            &format!(
                "SELECT COUNT(*) FROM {REL_TABLE} \
                 WHERE child_table = ?1 AND child_column = ?2;"
            ),
            rusqlite::params![table, column],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    if outbound_count > 0 {
        return Err(DbError::Unsupported(format!(
            "cannot change the type of `{table}.{column}` — a relationship \
             uses it as a foreign key, and changing its type could break \
             the link to the table it references. Drop the relationship \
             first, then change the type."
        )));
    }

    // (4): inbound FKs (column is parent / target of any FK).
    let inbound_count: i64 = conn
        .query_row(
            &format!(
                "SELECT COUNT(*) FROM {REL_TABLE} \
                 WHERE parent_table = ?1 AND parent_column = ?2;"
            ),
            rusqlite::params![table, column],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    if inbound_count > 0 && src_ty.fk_target_type() != ty.fk_target_type() {
        return Err(DbError::Unsupported(format!(
            "`{table}.{column}` is referenced by {inbound_count} relationship(s); \
             changing its type to `{ty}` would change the type that \
             referencing columns require — drop those relationships first \
             or pick a target type whose foreign-key shape matches the \
             current one."
        )));
    }

    // Build new_schema: same as old, but the target column's
    // user_type / sqlite_type are updated. PK / notnull flags
    // carry over unchanged. UNIQUE handling per ADR-0018 §4:
    //
    //   - Non-PK serial / shortid target: gain UNIQUE.
    //   - Reverse direction (e.g. serial → int): keep whatever
    //     UNIQUE flag was already present (ADR-0018 §4 "leaves
    //     UNIQUE in place"), which the clone() above already
    //     does.
    let mut new_schema = old_schema.clone();
    {
        let target = new_schema
            .columns
            .iter_mut()
            .find(|c| c.name == column)
            .expect("column existence checked above");
        target.user_type = Some(ty);
        target.sqlite_type = ty.sqlite_strict_type().to_string();
        if matches!(ty, Type::Serial | Type::ShortId) && !target.primary_key {
            target.unique = true;
            // Auto-generated columns are non-null by contract
            // (ADR-0018 §3). Set the flag here so schema_to_ddl
            // emits NOT NULL during the rebuild.
            target.notnull = true;
        }
    }

    let ty_keyword = ty.keyword();
    let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
        tx.execute(
            &format!(
                "UPDATE {META_TABLE} SET user_type = ?1 \
                 WHERE table_name = ?2 AND column_name = ?3;"
            ),
            [ty_keyword, table, column],
        )
        .map_err(DbError::from_rusqlite)?;
        let changes = Changes {
            schema_dirty: true,
            rewritten_tables: vec![table.to_string()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    };

    let client_side = match mode {
        ChangeColumnMode::DontConvert => {
            // Skip the client-side layer entirely. The engine's
            // STRICT typing will accept or refuse cells; we wrap
            // any error in a friendly message (ADR-0017 §5,
            // ADR-0002 user-facing posture).
            rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates).map_err(
                |e| {
                    let ctx = crate::friendly::TranslateContext {
                        operation: Some(crate::friendly::Operation::ChangeColumnType),
                        table: Some(table.to_string()),
                        column: Some(column.to_string()),
                        src_type: Some(src_ty),
                        target_type: Some(ty),
                        ..crate::friendly::TranslateContext::default()
                    };
                    let rendered = crate::friendly::translate_error(&e, &ctx).render();
                    DbError::Unsupported(rendered)
                },
            )?;
            None
        }
        ChangeColumnMode::Default | ChangeColumnMode::ForceConversion => {
            Some(run_change_column_with_dry_run(
                conn,
                table,
                column,
                src_ty,
                ty,
                mode,
                &old_schema,
                &new_schema,
                metadata_updates,
            )?)
        }
    };
    // Client-side note fires when at least one cell was
    // materially transformed (ADR-0017 §6) OR at least one
    // null cell was auto-filled (ADR-0018 §9). A pure metadata
    // change (zero non-identity outcomes, no auto-fill) emits
    // no note.
    let client_side = client_side.filter(|note| note.transformed > 0 || note.auto_filled > 0);

    let description = do_describe_table(conn, table)?;
    Ok(ChangeColumnTypeResult {
        description,
        client_side,
    })
}

/// Read every row from the source table, classify the target
/// column's cell via the transformer matrix, refuse on
/// incompatibles or lossy (per `mode`), check uniqueness for
/// PK / shortid, and finally rebuild the table with the
/// transformed values bound row-by-row.
///
/// Returns the per-row counts feeding the `[client-side]`
/// success note (ADR-0017 §6).
#[allow(clippy::too_many_arguments)]
fn run_change_column_with_dry_run<M>(
    conn: &Connection,
    table: &str,
    column: &str,
    src_ty: Type,
    target_ty: Type,
    mode: ChangeColumnMode,
    old_schema: &ReadSchema,
    new_schema: &ReadSchema,
    metadata_updates: M,
) -> Result<ClientSideNote, DbError>
where
    M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>,
{
    use rusqlite::types::Value as RV;
    use type_change::CellOutcome;

    let pk_columns: Vec<String> = old_schema
        .columns
        .iter()
        .filter(|c| c.primary_key)
        .map(|c| c.name.clone())
        .collect();
    if pk_columns.is_empty() {
        return Err(DbError::Unsupported(format!(
            "cannot run change-column dry run on `{table}` — table has no \
             primary key; use `--dont-convert` to bypass the client-side \
             layer."
        )));
    }

    // Read every row's data: the target column's value, plus the
    // PK column values (used for diagnostic identifiers and for
    // re-binding the row during rebuild).
    let all_columns: Vec<String> = old_schema.columns.iter().map(|c| c.name.clone()).collect();
    let target_idx = all_columns
        .iter()
        .position(|c| c == column)
        .expect("column existence checked above");
    let pk_indices: Vec<usize> = pk_columns
        .iter()
        .map(|pk| {
            all_columns
                .iter()
                .position(|c| c == pk)
                .expect("pk in cols")
        })
        .collect();

    let select_cols = all_columns
        .iter()
        .map(|c| quote_ident(c))
        .collect::<Vec<_>>()
        .join(", ");
    let order_by = pk_columns
        .iter()
        .map(|c| quote_ident(c))
        .collect::<Vec<_>>()
        .join(", ");
    let select_sql = format!(
        "SELECT {select_cols} FROM {tbl} ORDER BY {order_by};",
        tbl = quote_ident(table),
    );

    let mut rows: Vec<Vec<RV>> = Vec::new();
    {
        let mut stmt = conn.prepare(&select_sql).map_err(DbError::from_rusqlite)?;
        let mut sql_rows = stmt.query([]).map_err(DbError::from_rusqlite)?;
        while let Some(r) = sql_rows.next().map_err(DbError::from_rusqlite)? {
            let mut row: Vec<RV> = Vec::with_capacity(all_columns.len());
            for i in 0..all_columns.len() {
                let v: RV = r.get(i).map_err(DbError::from_rusqlite)?;
                row.push(v);
            }
            rows.push(row);
        }
    }

    // Classify every row's target cell. Null cells targeting
    // an auto-generated type get a placeholder outcome here
    // and are filled in below once the full set of non-null
    // values is known (so serial sequencing can start at
    // `MAX + 1`, and shortid generation can avoid collisions
    // against existing values).
    let auto_filling = matches!(target_ty, Type::Serial | Type::ShortId);
    let mut outcomes: Vec<Outcome> = Vec::with_capacity(rows.len());
    for row in &rows {
        let pk_values: Vec<RV> = pk_indices.iter().map(|i| row[*i].clone()).collect();
        let original = row[target_idx].clone();
        let (outcome, is_auto_fill) = if auto_filling && matches!(original, RV::Null) {
            // Placeholder; replaced after the loop.
            (CellOutcome::Clean(RV::Null), true)
        } else {
            (
                type_change::transform_cell(src_ty, target_ty, &original),
                false,
            )
        };
        outcomes.push(Outcome {
            pk_values,
            original,
            outcome,
            is_auto_fill,
        });
    }

    // Auto-fill pass for serial / shortid targets per ADR-0018
    // §3 / §7. Runs before the incompatible / lossy / collision
    // checks so the filled values participate in those checks
    // as if they were ordinary classified outcomes.
    if auto_filling {
        fill_auto_generated_cells(target_ty, &mut outcomes)?;
    }

    // Refuse on incompatibles.
    let incompatibles: Vec<&Outcome> = outcomes
        .iter()
        .filter(|o| matches!(o.outcome, CellOutcome::Incompatible { .. }))
        .collect();
    if !incompatibles.is_empty() {
        return Err(DbError::Unsupported(render_incompatible_diagnostic(
            table,
            column,
            src_ty,
            target_ty,
            &pk_columns,
            old_schema,
            &incompatibles,
        )));
    }

    // Refuse on lossy unless --force-conversion.
    let lossies: Vec<&Outcome> = outcomes
        .iter()
        .filter(|o| matches!(o.outcome, CellOutcome::Lossy { .. }))
        .collect();
    if !lossies.is_empty() && mode != ChangeColumnMode::ForceConversion {
        return Err(DbError::Unsupported(render_lossy_diagnostic(
            table,
            column,
            src_ty,
            target_ty,
            &pk_columns,
            old_schema,
            &lossies,
        )));
    }

    // Uniqueness check for any target column that will carry
    // a uniqueness constraint in the new schema. ADR-0017 §4.3
    // covered PK + shortid; ADR-0018 §4.3-amendment extends
    // this to "any column that gains a UNIQUE constraint as
    // part of the operation" — i.e., the new schema's target
    // column has primary_key OR unique set.
    let new_target = new_schema
        .columns
        .iter()
        .find(|c| c.name == column)
        .expect("target column exists in new schema");
    let uniqueness_required = new_target.primary_key || new_target.unique;
    if uniqueness_required
        && let Some(error_msg) = check_uniqueness_collisions(
            table,
            column,
            src_ty,
            target_ty,
            &pk_columns,
            old_schema,
            &outcomes,
        )
    {
        return Err(DbError::Unsupported(error_msg));
    }

    // All cells classified Clean or Lossy-with-force. Build the
    // transformed values list, indexed by row, and rebuild the
    // table with row-by-row binding.
    let transformed_values: Vec<RV> = outcomes
        .iter()
        .map(|o| match &o.outcome {
            CellOutcome::Clean(v) | CellOutcome::Lossy { new: v, .. } => v.clone(),
            CellOutcome::Incompatible { .. } => {
                unreachable!("incompatibles refused above")
            }
        })
        .collect();

    let copy_data =
        |tx: &rusqlite::Transaction<'_>, temp_name: &str, _orig: &str| -> Result<(), DbError> {
            if rows.is_empty() {
                return Ok(());
            }
            let cols_csv = all_columns
                .iter()
                .map(|c| quote_ident(c))
                .collect::<Vec<_>>()
                .join(", ");
            let placeholders = (0..all_columns.len())
                .map(|i| format!("?{}", i + 1))
                .collect::<Vec<_>>()
                .join(", ");
            let insert_sql = format!(
                "INSERT INTO {temp} ({cols}) VALUES ({ph});",
                temp = quote_ident(temp_name),
                cols = cols_csv,
                ph = placeholders,
            );
            let mut stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?;
            for (row_idx, row) in rows.iter().enumerate() {
                let mut bound: Vec<RV> = row.clone();
                bound[target_idx] = transformed_values[row_idx].clone();
                let params: Vec<&dyn rusqlite::ToSql> =
                    bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
                stmt.execute(rusqlite::params_from_iter(params))
                    .map_err(DbError::from_rusqlite)?;
            }
            Ok(())
        };

    rebuild_table_with_copy(conn, table, new_schema, copy_data, metadata_updates)?;

    // Tally counts for the [client-side] note. Three kinds of
    // cells of interest (ADR-0017 §6 + ADR-0018 §9):
    //
    //   - auto-filled: original was NULL, target type
    //     auto-generates. Counted under `auto_filled`, NOT
    //     `transformed` (different user-facing wording).
    //   - transformed: original was non-null and the stored
    //     value materially differs (storage class change
    //     counts).
    //   - lossy: subset of transformed; only when
    //     --force-conversion was used.
    let mut transformed = 0usize;
    let mut lossy = 0usize;
    let mut auto_filled = 0usize;
    for (idx, o) in outcomes.iter().enumerate() {
        let new = &transformed_values[idx];
        if o.is_auto_fill {
            auto_filled += 1;
        } else if type_change::is_non_identity(&o.original, new) {
            transformed += 1;
        }
        if matches!(o.outcome, CellOutcome::Lossy { .. }) {
            lossy += 1;
        }
    }
    let auto_fill_kind = if auto_filled > 0 {
        Some(match target_ty {
            Type::Serial => AutoFillKind::Serial,
            Type::ShortId => AutoFillKind::ShortId,
            _ => unreachable!("auto-fill only fires for Serial / ShortId targets"),
        })
    } else {
        None
    };
    Ok(ClientSideNote {
        transformed,
        lossy,
        auto_filled,
        auto_fill_kind,
    })
}

/// Replace the placeholder `Clean(Null)` outcomes for null
/// source cells with auto-generated values.
///
/// For `Serial` targets: continue the integer sequence from
/// `MAX(non-null value) + 1`. For `ShortId` targets: generate
/// fresh shortids that don't collide with existing values or
/// with one another (5-retry budget per cell, ADR-0018 §3).
fn fill_auto_generated_cells(target_ty: Type, outcomes: &mut [Outcome]) -> Result<(), DbError> {
    use rusqlite::types::Value as RV;
    use type_change::CellOutcome;

    match target_ty {
        Type::Serial => {
            let max_existing: i64 = outcomes
                .iter()
                .filter_map(|o| {
                    if o.is_auto_fill {
                        return None;
                    }
                    match &o.outcome {
                        CellOutcome::Clean(RV::Integer(i)) => Some(*i),
                        _ => None,
                    }
                })
                .max()
                .unwrap_or(0);
            let mut next = max_existing.saturating_add(1);
            for o in outcomes.iter_mut().filter(|o| o.is_auto_fill) {
                o.outcome = CellOutcome::Clean(RV::Integer(next));
                next = next.saturating_add(1);
            }
        }
        Type::ShortId => {
            let existing: Vec<String> = outcomes
                .iter()
                .filter_map(|o| {
                    if o.is_auto_fill {
                        return None;
                    }
                    match &o.outcome {
                        CellOutcome::Clean(RV::Text(s)) => Some(s.clone()),
                        _ => None,
                    }
                })
                .collect();
            let auto_fill_count = outcomes.iter().filter(|o| o.is_auto_fill).count();
            let new_values = generate_shortid_batch(auto_fill_count, &existing)?;
            for (idx, o) in outcomes.iter_mut().filter(|o| o.is_auto_fill).enumerate() {
                o.outcome = CellOutcome::Clean(new_values[idx].clone());
            }
        }
        _ => {
            // Caller filters to Serial / ShortId; the
            // unreachable branch is defensive.
            unreachable!("fill_auto_generated_cells called with non-auto-gen target");
        }
    }
    Ok(())
}

/// Maximum diagnostic rows rendered per refusal (ADR-0017 §7).
/// Rows beyond this collapse into a trailing `… and N more` row.
const DIAGNOSTIC_ROW_CAP: usize = 100;

#[allow(clippy::too_many_arguments)]
fn render_lossy_diagnostic(
    table: &str,
    column: &str,
    src_ty: Type,
    target_ty: Type,
    pk_columns: &[String],
    old_schema: &ReadSchema,
    lossies: &[&Outcome],
) -> String {
    let mut headers = pk_header_cells(pk_columns);
    headers.extend([
        crate::t!("db.diagnostic.header_from"),
        crate::t!("db.diagnostic.header_to"),
        crate::t!("db.diagnostic.header_reason"),
    ]);

    let mut alignments = pk_header_alignments(pk_columns, old_schema);
    alignments.extend([
        type_change::is_in_matrix_alignment(src_ty),
        type_change::is_in_matrix_alignment(target_ty),
        Alignment::Left,
    ]);

    let total = lossies.len();
    let visible = total.min(DIAGNOSTIC_ROW_CAP);
    let mut rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
    for o in lossies.iter().take(visible) {
        let mut cells = pk_value_cells(&o.pk_values);
        let (new_str, reason) = match &o.outcome {
            type_change::CellOutcome::Lossy { new, reason } => (render_value(new), reason.clone()),
            _ => unreachable!("filtered to Lossy"),
        };
        cells.push(render_value(&o.original));
        cells.push(new_str);
        cells.push(reason);
        rows.push(cells);
    }
    if total > visible {
        rows.push(more_row(headers.len(), total - visible));
    }

    let mut out = format!(
        "{}\n\n",
        crate::t!(
            "db.diagnostic.lossy_summary",
            table = table,
            column = column,
            src_ty = src_ty,
            target_ty = target_ty,
            total = total,
        ),
    );
    for line in render_diagnostic_table(&headers, &rows, &alignments) {
        out.push_str(&line);
        out.push('\n');
    }
    out.push('\n');
    out.push_str(&crate::t!("db.diagnostic.force_conversion_hint"));
    out
}

#[allow(clippy::too_many_arguments)]
fn render_incompatible_diagnostic(
    table: &str,
    column: &str,
    src_ty: Type,
    target_ty: Type,
    pk_columns: &[String],
    old_schema: &ReadSchema,
    incompatibles: &[&Outcome],
) -> String {
    let mut headers = pk_header_cells(pk_columns);
    headers.extend([
        crate::t!("db.diagnostic.header_value"),
        crate::t!("db.diagnostic.header_reason"),
    ]);

    let mut alignments = pk_header_alignments(pk_columns, old_schema);
    alignments.extend([type_change::is_in_matrix_alignment(src_ty), Alignment::Left]);

    let total = incompatibles.len();
    let visible = total.min(DIAGNOSTIC_ROW_CAP);
    let mut rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
    for o in incompatibles.iter().take(visible) {
        let reason = match &o.outcome {
            type_change::CellOutcome::Incompatible { reason } => reason.clone(),
            _ => unreachable!("filtered to Incompatible"),
        };
        let mut cells = pk_value_cells(&o.pk_values);
        cells.push(render_value(&o.original));
        cells.push(reason);
        rows.push(cells);
    }
    if total > visible {
        rows.push(more_row(headers.len(), total - visible));
    }

    let mut out = format!(
        "{}\n\n",
        crate::t!(
            "db.diagnostic.incompatible_summary",
            table = table,
            column = column,
            src_ty = src_ty,
            target_ty = target_ty,
            total = total,
        ),
    );
    for line in render_diagnostic_table(&headers, &rows, &alignments) {
        out.push_str(&line);
        out.push('\n');
    }
    out
}

/// Detect post-transformation duplicates among `outcomes` and
/// produce a refusal diagnostic if any exist. Returns `None` if
/// uniqueness is preserved. Per ADR-0017 §4.3 collisions are
/// classified incompatible — no `--force-conversion` override.
fn check_uniqueness_collisions(
    table: &str,
    column: &str,
    src_ty: Type,
    target_ty: Type,
    pk_columns: &[String],
    old_schema: &ReadSchema,
    outcomes: &[Outcome],
) -> Option<String> {
    use std::collections::BTreeMap;

    // Keyed by the canonical string form of the transformed
    // value; NULL is excluded from uniqueness collision checks
    // (the new column may carry NULL until C3 lands NOT NULL
    // constraints; treat NULLs as not-colliding).
    let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
    for (i, o) in outcomes.iter().enumerate() {
        let new = match &o.outcome {
            type_change::CellOutcome::Clean(v) => v,
            type_change::CellOutcome::Lossy { new, .. } => new,
            type_change::CellOutcome::Incompatible { .. } => continue,
        };
        if matches!(new, rusqlite::types::Value::Null) {
            continue;
        }
        groups.entry(render_value(new)).or_default().push(i);
    }

    let collisions: Vec<(String, Vec<usize>)> = groups
        .into_iter()
        .filter(|(_, idxs)| idxs.len() >= 2)
        .collect();
    if collisions.is_empty() {
        return None;
    }

    let pk_label = pk_columns.join(", ");
    let headers = vec![
        crate::t!("db.diagnostic.header_becomes"),
        crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label),
        crate::t!("db.diagnostic.header_source_values"),
    ];

    let alignments = vec![
        type_change::is_in_matrix_alignment(target_ty),
        Alignment::Left,
        Alignment::Left,
    ];

    let total = collisions.len();
    let visible = total.min(DIAGNOSTIC_ROW_CAP);
    let mut rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
    for (becomes, idxs) in collisions.iter().take(visible) {
        let pks_csv = idxs
            .iter()
            .take(5)
            .map(|i| pk_value_cells_inline(&outcomes[*i].pk_values))
            .collect::<Vec<_>>()
            .join(", ");
        let pks_str = if idxs.len() > 5 {
            format!("{pks_csv}, …")
        } else {
            pks_csv
        };
        let sources_csv = idxs
            .iter()
            .take(5)
            .map(|i| render_value(&outcomes[*i].original))
            .collect::<Vec<_>>()
            .join(", ");
        let sources_str = if idxs.len() > 5 {
            format!("{sources_csv}, …")
        } else {
            sources_csv
        };
        rows.push(vec![becomes.clone(), pks_str, sources_str]);
    }
    if total > visible {
        rows.push(more_row(headers.len(), total - visible));
    }

    // ColumnDescription guarantees old_schema known; not used
    // beyond the §4.3 wording so silence the warning.
    let _ = old_schema;

    let mut out = format!(
        "{}\n\n",
        crate::t!(
            "db.diagnostic.uniqueness_summary",
            table = table,
            column = column,
            src_ty = src_ty,
            target_ty = target_ty,
            total = total,
        ),
    );
    for line in render_diagnostic_table(&headers, &rows, &alignments) {
        out.push_str(&line);
        out.push('\n');
    }
    Some(out)
}

/// Outcome record for the dry-run pass. Mirrors the inner type
/// inside `run_change_column_with_dry_run` so the diagnostic
/// helpers can take it as a slice.
struct Outcome {
    pk_values: Vec<rusqlite::types::Value>,
    original: rusqlite::types::Value,
    outcome: type_change::CellOutcome,
    /// `true` when the original cell was NULL and the target
    /// type carries an auto-generation contract; the `outcome`
    /// gets filled in with the generated value during the
    /// post-classification auto-fill pass.
    is_auto_fill: bool,
}

fn pk_header_cells(pk_columns: &[String]) -> Vec<String> {
    pk_columns.iter().map(|c| format!("{c} (PK)")).collect()
}

fn pk_header_alignments(pk_columns: &[String], schema: &ReadSchema) -> Vec<Alignment> {
    pk_columns
        .iter()
        .map(|name| {
            schema
                .columns
                .iter()
                .find(|c| &c.name == name)
                .and_then(|c| c.user_type)
                .map_or(Alignment::Left, type_change::is_in_matrix_alignment)
        })
        .collect()
}

fn pk_value_cells(values: &[rusqlite::types::Value]) -> Vec<String> {
    values.iter().map(render_value).collect()
}

/// Single-cell inline rendering of a row's PK values, used in
/// uniqueness-collision diagnostics where one cell lists many
/// PK identifiers. Single-PK forms render as bare values (`5`);
/// compound-PK forms render as tuples (`(1, 2)`).
fn pk_value_cells_inline(values: &[rusqlite::types::Value]) -> String {
    if values.len() == 1 {
        render_value(&values[0])
    } else {
        let parts: Vec<String> = values.iter().map(render_value).collect();
        format!("({})", parts.join(", "))
    }
}

fn render_value(v: &rusqlite::types::Value) -> String {
    use rusqlite::types::Value as RV;
    match v {
        RV::Null => "(null)".to_string(),
        RV::Integer(i) => i.to_string(),
        // Full-precision, shortest round-trip — NOT the issue-#32
        // display rounding. `render_value` is reused as a *canonical
        // identity key* by the uniqueness dry-runs (`dry_run_unique`,
        // `check_uniqueness_collisions`, ADR-0017 §4.3 / ADR-0029 §5):
        // they group rows by this string to detect duplicates the
        // engine would reject. Rounding here would merge two distinct
        // doubles into one key and report a collision the engine —
        // which compares exact values — would not. Display rounding
        // lives in `format_cell` (query / `show data` cells) only,
        // where no value is ever used as a key.
        RV::Real(r) => format!("{r}"),
        RV::Text(s) => s.clone(),
        RV::Blob(_) => "<blob>".to_string(),
    }
}

fn more_row(width: usize, more: usize) -> Vec<String> {
    let mut row = vec!["…".to_string(); width];
    if let Some(last) = row.last_mut() {
        *last = format!("… and {more} more");
    }
    row
}

fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
    debug!("list_tables");
    let mut stmt = conn
        .prepare(
            "SELECT name FROM sqlite_schema \
             WHERE type = 'table' \
               AND name NOT LIKE 'sqlite_%' \
               AND substr(name, 1, 8) != '__rdbms_' \
             ORDER BY name;",
        )
        .map_err(DbError::from_rusqlite)?;
    let rows = stmt
        .query_map([], |row| row.get::<_, String>(0))
        .map_err(DbError::from_rusqlite)?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(DbError::from_rusqlite)?);
    }
    Ok(out)
}

/// Structured data to render one relationship's diagram (ADR-0044):
/// find the named relationship, then describe both endpoint tables.
/// `Ok(None)` when no relationship by that name exists (the App shows
/// a friendly not-found line).
fn do_show_relationship(
    conn: &Connection,
    name: &str,
) -> Result<Option<RelationshipDiagramData>, DbError> {
    debug!(name = %name, "show_relationship");
    let Some(rel) = read_all_relationships(conn)?
        .into_iter()
        .find(|r| r.name == name)
    else {
        return Ok(None);
    };
    let child = do_describe_table(conn, &rel.child_table)?;
    let parent = do_describe_table(conn, &rel.parent_table)?;
    Ok(Some(RelationshipDiagramData { rel, child, parent }))
}

/// Pre-formatted display lines for the `show <kind>` list commands
/// (V5). A count header followed by one indented item per line, or a
/// single friendly "none yet" line for an empty collection. Reuses
/// the same helpers the items panel / describe view read from, so the
/// list never drifts from those views. Engine-neutral wording per the
/// ADR-0002 user-facing posture.
fn do_show_list(
    conn: &Connection,
    kind: crate::dsl::command::ShowListKind,
    name: Option<&str>,
) -> Result<Vec<String>, DbError> {
    debug!(kind = ?kind, name = ?name, "show_list");
    use crate::dsl::command::ShowListKind;
    // V5a: a named item shows one relationship/index's detail.
    if let Some(name) = name {
        return do_show_one(conn, kind, name);
    }
    let mut lines = Vec::new();
    match kind {
        ShowListKind::Tables => {
            let tables = do_list_tables(conn)?;
            if tables.is_empty() {
                lines.push("No tables in this project yet.".to_string());
            } else {
                lines.push(format!("Tables ({}):", tables.len()));
                for name in tables {
                    lines.push(format!("  {name}"));
                }
            }
        }
        ShowListKind::Relationships => {
            let rels = read_all_relationships(conn)?;
            if rels.is_empty() {
                lines.push("No relationships in this project yet.".to_string());
            } else {
                lines.push(format!("Relationships ({}):", rels.len()));
                for r in rels {
                    let mut line = format!(
                        "  {}: {} → {}",
                        r.name,
                        fmt_rel_endpoint(&r.parent_table, &r.parent_columns),
                        fmt_rel_endpoint(&r.child_table, &r.child_columns),
                    );
                    if r.on_delete != ReferentialAction::default_action() {
                        line.push_str(&format!(" on delete {}", r.on_delete.keyword()));
                    }
                    if r.on_update != ReferentialAction::default_action() {
                        line.push_str(&format!(" on update {}", r.on_update.keyword()));
                    }
                    lines.push(line);
                }
            }
        }
        ShowListKind::Indexes => {
            // Each table's user-created indexes (origin "c"), the
            // same set the items panel shows. Ordered by table, then
            // by index name (read_table_indexes orders by name).
            let tables = do_list_tables(conn)?;
            let mut entries: Vec<String> = Vec::new();
            for table in &tables {
                for ix in read_table_indexes(conn, table)? {
                    let unique = if ix.unique { " [unique]" } else { "" };
                    entries.push(format!(
                        "  {}.{} ({}){unique}",
                        table,
                        ix.name,
                        ix.columns.join(", ")
                    ));
                }
            }
            if entries.is_empty() {
                lines.push("No indexes in this project yet.".to_string());
            } else {
                lines.push(format!("Indexes ({}):", entries.len()));
                lines.extend(entries);
            }
        }
    }
    Ok(lines)
}

/// Detail lines for one named relationship or index (V5a): a
/// labelled block, or a friendly "no such item" line. `Tables` is
/// never routed here (the table singular is `ShowTable`); the
/// defensive arm keeps the match total without a panic.
///
/// **The `Relationships` arm is superseded for the app by
/// `do_show_relationship` (ADR-0044): the runtime reroutes a named
/// `show relationship` to the structured diagram path, so this prose
/// form is no longer shown to users.** It is retained — reachable via
/// the `Database::show_list` worker API and covered by a worker test —
/// as a text fallback that could back a future non-visual display
/// option (cf. ADR-0044 OOS-7's relationship-display setting). The
/// `Indexes` arm remains live (`show index <name>` still routes here).
fn do_show_one(
    conn: &Connection,
    kind: crate::dsl::command::ShowListKind,
    name: &str,
) -> Result<Vec<String>, DbError> {
    debug!(kind = ?kind, name = %name, "show_one");
    use crate::dsl::command::ShowListKind;
    let mut lines = Vec::new();
    match kind {
        ShowListKind::Relationships => match read_all_relationships(conn)?
            .into_iter()
            .find(|r| r.name == name)
        {
            None => lines.push(format!("No relationship named `{name}`.")),
            Some(r) => {
                lines.push(format!("Relationship `{}`:", r.name));
                lines.push(format!(
                    "  {} → {}",
                    fmt_rel_endpoint(&r.parent_table, &r.parent_columns),
                    fmt_rel_endpoint(&r.child_table, &r.child_columns),
                ));
                lines.push(format!("  on delete {}", r.on_delete.keyword()));
                lines.push(format!("  on update {}", r.on_update.keyword()));
            }
        },
        ShowListKind::Indexes => {
            // Find the user-created index by name across all tables.
            let mut found = None;
            for table in do_list_tables(conn)? {
                if let Some(ix) = read_table_indexes(conn, &table)?
                    .into_iter()
                    .find(|ix| ix.name == name)
                {
                    found = Some((table, ix));
                    break;
                }
            }
            match found {
                None => lines.push(format!("No index named `{name}`.")),
                Some((table, ix)) => {
                    lines.push(format!("Index `{}` on {table}:", ix.name));
                    lines.push(format!("  columns: {}", ix.columns.join(", ")));
                    lines.push(format!(
                        "  unique: {}",
                        if ix.unique { "yes" } else { "no" }
                    ));
                }
            }
        }
        ShowListKind::Tables => {
            lines.push(format!("No relationship or index named `{name}`."));
        }
    }
    Ok(lines)
}

/// Internal full schema of a table, sufficient to regenerate
/// its `CREATE TABLE` statement during the rebuild dance.
#[derive(Debug, Clone)]
struct ReadSchema {
    columns: Vec<ReadColumn>,
    primary_key: Vec<String>,
    foreign_keys: Vec<ReadForeignKey>,
    /// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2),
    /// read from the UNIQUE-constraint indexes (`origin = 'u'`).
    /// Single-column UNIQUE rides on `ReadColumn::unique` instead.
    unique_constraints: Vec<Vec<String>>,
    /// Table-level CHECK constraints as raw SQL text with an optional
    /// name, in declaration order (ADR-0035 §4a.3, named in §4g). The
    /// engine reports no CHECK constraints, so these are read from
    /// `__rdbms_playground_table_checks` rather than PRAGMA, and echoed
    /// verbatim by `schema_to_ddl` on rebuild (`CONSTRAINT <name>` when
    /// named).
    check_constraints: Vec<TableCheck>,
}

#[derive(Debug, Clone)]
struct ReadColumn {
    name: String,
    sqlite_type: String,
    notnull: bool,
    primary_key: bool,
    /// `true` when this column carries a single-column UNIQUE
    /// constraint detected via `pragma_index_list` /
    /// `pragma_index_info` (origin = "u"). PK columns are not
    /// marked unique here even though PK implies UNIQUE — the
    /// `primary_key` flag covers that, and `schema_to_ddl`
    /// avoids double-emitting. Compound UNIQUE is out of scope
    /// for v1 (ADR-0018 OOS-6 / future C3 work).
    unique: bool,
    /// The column's `DEFAULT` expression as SQLite reports it
    /// (`pragma_table_info.dflt_value`) — already a SQL
    /// literal, echoed verbatim by `schema_to_ddl` so the
    /// rebuild dance preserves it (ADR-0029).
    default_sql: Option<String>,
    /// The column's `CHECK` constraint in compiled-SQL form
    /// (ADR-0029 §7), read from the `check_expr` metadata
    /// column — `pragma_table_info` does not expose CHECK.
    /// Echoed verbatim by `schema_to_ddl`.
    check: Option<String>,
    user_type: Option<Type>,
}

#[derive(Debug, Clone)]
struct ReadForeignKey {
    parent_table: String,
    /// Parent + child column lists, positionally paired; one
    /// element for single-column, ordered list for a compound FK
    /// (ADR-0043).
    parent_columns: Vec<String>,
    child_columns: Vec<String>,
    on_delete: ReferentialAction,
    on_update: ReferentialAction,
}

fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
    // Columns + PK from pragma_table_info, joined with our user-type metadata.
    let mut col_stmt = conn
        .prepare(&format!(
            "SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type, \
                    pti.dflt_value, m.check_expr \
             FROM pragma_table_info(?1) AS pti \
             LEFT JOIN {META_TABLE} AS m \
               ON m.table_name = ?1 AND m.column_name = pti.name \
             ORDER BY pti.cid;"
        ))
        .map_err(DbError::from_rusqlite)?;
    let rows = col_stmt
        .query_map([table], |row| {
            let user_type_kw: Option<String> = row.get(4)?;
            let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().ok());
            Ok(ReadColumn {
                name: row.get(0)?,
                sqlite_type: row.get(1)?,
                notnull: row.get::<_, i64>(2)? != 0,
                primary_key: row.get::<_, i64>(3)? != 0,
                unique: false, // filled in below from pragma_index_list
                default_sql: row.get(5)?,
                check: row.get(6)?,
                user_type,
            })
        })
        .map_err(DbError::from_rusqlite)?;
    let mut columns = Vec::new();
    for row in rows {
        columns.push(row.map_err(DbError::from_rusqlite)?);
    }
    if columns.is_empty() {
        return Err(DbError::Sqlite {
            message: format!("no such table: {table}"),
            kind: SqliteErrorKind::NoSuchTable,
        });
    }
    let primary_key: Vec<String> = columns
        .iter()
        .filter(|c| c.primary_key)
        .map(|c| c.name.clone())
        .collect();

    // Detect UNIQUE constraints (ADR-0018 §4, ADR-0035 §4a.2).
    // pragma_index_list returns one row per index; we filter to
    // unique indexes whose origin is "u" (a UNIQUE constraint, as
    // opposed to "pk" or "c"). Single-column → the column's `unique`
    // flag; multi-column → a composite `unique_constraints` entry.
    let (unique_columns, unique_constraints) = read_unique_constraints(conn, table)?;
    for col in &mut columns {
        if unique_columns.contains(&col.name) {
            col.unique = true;
        }
    }

    // Foreign keys from pragma_foreign_key_list. A *compound* FK
    // (ADR-0043) is reported as several rows sharing one `id`, in
    // `seq` order — so we group consecutive same-`id` rows into one
    // `ReadForeignKey` with positionally-ordered column lists.
    let mut fk_stmt = conn
        .prepare(
            "SELECT id, \"table\", \"from\", \"to\", on_delete, on_update \
             FROM pragma_foreign_key_list(?1) \
             ORDER BY id, seq;",
        )
        .map_err(DbError::from_rusqlite)?;
    let fk_rows = fk_stmt
        .query_map([table], |row| {
            Ok((
                row.get::<_, i64>(0)?,    // id — groups a compound FK's columns
                row.get::<_, String>(1)?, // parent table
                row.get::<_, String>(2)?, // child column (`from`)
                row.get::<_, String>(3)?, // parent column (`to`)
                parse_action_from_sqlite(&row.get::<_, String>(4)?),
                parse_action_from_sqlite(&row.get::<_, String>(5)?),
            ))
        })
        .map_err(DbError::from_rusqlite)?;
    let mut foreign_keys: Vec<ReadForeignKey> = Vec::new();
    let mut current_id: Option<i64> = None;
    for row in fk_rows {
        let (id, parent_table, child_col, parent_col, on_delete, on_update) =
            row.map_err(DbError::from_rusqlite)?;
        if current_id == Some(id) {
            let fk = foreign_keys
                .last_mut()
                .expect("a same-id group always follows its first row");
            fk.child_columns.push(child_col);
            fk.parent_columns.push(parent_col);
        } else {
            current_id = Some(id);
            foreign_keys.push(ReadForeignKey {
                parent_table,
                child_columns: vec![child_col],
                parent_columns: vec![parent_col],
                on_delete,
                on_update,
            });
        }
    }

    // Table-level CHECK constraints (ADR-0035 §4a.3) come from their
    // metadata table, not PRAGMA — the engine reports no CHECKs.
    let check_constraints = read_table_checks(conn, table)?;

    Ok(ReadSchema {
        columns,
        primary_key,
        foreign_keys,
        unique_constraints,
        check_constraints,
    })
}

/// Read a table's table-level CHECK constraints (ADR-0035 §4a.3, named
/// in §4g) from `CHECK_TABLE`, in declaration order (`seq`). The engine
/// exposes no PRAGMA for CHECK constraints, so this metadata table is
/// their only source of truth. Tolerates a pre-4g project whose table
/// predates the `name` column (rebuild-only migration) by reading the
/// name as `None`.
fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<TableCheck>, DbError> {
    let has_name = check_table_has_name_column(conn)?;
    let sql = if has_name {
        format!(
            "SELECT check_expr, name FROM {CHECK_TABLE} \
             WHERE table_name = ?1 ORDER BY seq;"
        )
    } else {
        format!(
            "SELECT check_expr FROM {CHECK_TABLE} \
             WHERE table_name = ?1 ORDER BY seq;"
        )
    };
    let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
    let rows = stmt
        .query_map([table], |row| {
            let expr: String = row.get(0)?;
            let name: Option<String> = if has_name { row.get(1)? } else { None };
            Ok(TableCheck { name, expr })
        })
        .map_err(DbError::from_rusqlite)?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(DbError::from_rusqlite)?);
    }
    Ok(out)
}

/// Whether `CHECK_TABLE` carries the `name` column (ADR-0035 §4g). A
/// pre-4g project's metadata table predates it — the column arrives on
/// `rebuild` (the rebuild-only migration, user-confirmed 2026-05-25).
/// Used to read names tolerantly and to refuse a *named* CHECK add on an
/// un-upgraded project with a friendly "rebuild first" message rather
/// than a raw engine error.
fn check_table_has_name_column(conn: &Connection) -> Result<bool, DbError> {
    let count: i64 = conn
        .query_row(
            &format!(
                "SELECT COUNT(*) FROM pragma_table_info('{CHECK_TABLE}') WHERE name = 'name';"
            ),
            [],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    Ok(count > 0)
}

/// Whether the raw CHECK expression `check_expr` references the column
/// `column` (ADR-0035 §4e — the 4a.3-deferred drop/rename guard).
///
/// Tokenizes the expression with the shared lex helpers, **skipping
/// single-quoted string literals** so an identifier that only appears
/// inside a literal does not false-match, and compares each bare
/// identifier case-insensitively. Playground column names are always
/// valid bare identifiers (`validate_user_name` rejects the characters
/// that would force quoting), so bare-identifier scanning is sufficient;
/// the engine's native `DROP`/`RENAME COLUMN` remains the backstop for
/// any miss.
fn check_references_column(check_expr: &str, column: &str) -> bool {
    use crate::dsl::walker::lex_helpers::{consume_ident, consume_string_literal, skip_whitespace};
    let mut i = 0;
    while i < check_expr.len() {
        i = skip_whitespace(check_expr, i);
        if i >= check_expr.len() {
            break;
        }
        if let Some(((_, end), _)) = consume_string_literal(check_expr, i) {
            i = end;
        } else if let Some((start, end)) = consume_ident(check_expr, i) {
            if check_expr[start..end].eq_ignore_ascii_case(column) {
                return true;
            }
            i = end;
        } else {
            // Operator / paren / number / punctuation — advance one char.
            i += check_expr[i..].chars().next().map_or(1, char::len_utf8);
        }
    }
    false
}

#[cfg(test)]
mod check_references_column_tests {
    use super::check_references_column;

    #[test]
    fn detects_a_bare_identifier() {
        assert!(check_references_column("price > 0", "price"));
        assert!(check_references_column("a < b AND b < c", "b"));
    }

    #[test]
    fn is_case_insensitive() {
        assert!(check_references_column("Price > 0", "price"));
        assert!(check_references_column("price > 0", "PRICE"));
    }

    #[test]
    fn ignores_identifier_inside_a_string_literal() {
        // `status` appears only inside a literal → not a reference.
        assert!(!check_references_column("kind <> 'status'", "status"));
        // A genuine reference alongside a literal is still found.
        assert!(check_references_column("status <> 'archived'", "status"));
    }

    #[test]
    fn ignores_a_longer_identifier_that_merely_contains_the_name() {
        // `price` is a substring of these idents but not a whole match.
        assert!(!check_references_column("price_cents > 0", "price"));
        assert!(!check_references_column("unit_price > 0", "price"));
    }
}

/// Rewrite the old table name where it is used as a **qualifier** in a
/// CHECK expression (the identifier immediately followed by `.`) to the
/// new name — both the bare (`old.col`) and double-quoted (`"old".col`)
/// forms, case-insensitively — leaving everything else byte-for-byte
/// intact (ADR-0035 §6, sub-phase 4h).
///
/// **Why this is needed.** A native `ALTER TABLE old RENAME TO new`
/// rewrites table-qualified column references inside the renamed table's
/// *live* CHECK (`CHECK (T.age>0)` → `CHECK ("U".age>0)`), but our
/// *stored* CHECK text would keep the old name and break a later rebuild
/// (`schema_to_ddl` would emit `CHECK (T.age>0)` for a table now named
/// `U` → "no such table T"). This keeps the stored text in step with the
/// live schema. **Bounded problem:** a CHECK may reference only its own
/// table's columns (SQLite forbids subqueries / other tables in a CHECK),
/// so the only table qualifier that can appear is the renamed table's
/// name — the rewrite target is unambiguous.
///
/// Extends the [`check_references_column`] tokenizer: skips single-quoted
/// string literals (a literal containing the old name is untouched), and
/// a bare identifier equal to the old name but **not** used as a qualifier
/// (not followed by `.` — e.g. a column literally named like the table)
/// is left alone, so the common unqualified CHECK (`age > 0`) is a no-op.
fn rewrite_check_table_qualifier(check_expr: &str, old: &str, new: &str) -> String {
    use crate::dsl::walker::lex_helpers::{consume_ident, consume_string_literal};
    let bytes = check_expr.as_bytes();
    // Whether the next byte at `pos` begins a `.` qualifier separator.
    let dot_follows = |pos: usize| bytes.get(pos) == Some(&b'.');
    let mut out = String::with_capacity(check_expr.len());
    let mut i = 0;
    while i < check_expr.len() {
        // Single-quoted string literal — copy verbatim, never rewrite.
        if let Some(((start, end), _)) = consume_string_literal(check_expr, i) {
            out.push_str(&check_expr[start..end]);
            i = end;
            continue;
        }
        // Double-quoted identifier — scan to the closing quote (`""` is an
        // escaped quote). Rewrite when it is the old name used as a
        // qualifier; table names never contain quotes, so the raw inner
        // text suffices for comparison.
        if bytes[i] == b'"' {
            let mut j = i + 1;
            while j < check_expr.len() {
                if bytes[j] == b'"' {
                    if bytes.get(j + 1) == Some(&b'"') {
                        j += 2; // escaped quote inside the identifier
                        continue;
                    }
                    break; // closing quote
                }
                j += 1;
            }
            let end = (j + 1).min(check_expr.len()); // byte after closing `"`
            let inner = &check_expr[i + 1..j.min(check_expr.len())];
            if inner.eq_ignore_ascii_case(old) && dot_follows(end) {
                out.push('"');
                out.push_str(new);
                out.push('"');
            } else {
                out.push_str(&check_expr[i..end]);
            }
            i = end;
            continue;
        }
        // Bare identifier.
        if let Some((start, end)) = consume_ident(check_expr, i) {
            if check_expr[start..end].eq_ignore_ascii_case(old) && dot_follows(end) {
                out.push_str(new);
            } else {
                out.push_str(&check_expr[start..end]);
            }
            i = end;
            continue;
        }
        // Operator / paren / number / punctuation — copy one char.
        let ch_len = check_expr[i..].chars().next().map_or(1, char::len_utf8);
        out.push_str(&check_expr[i..i + ch_len]);
        i += ch_len;
    }
    out
}

#[cfg(test)]
mod rewrite_check_table_qualifier_tests {
    use super::rewrite_check_table_qualifier;

    #[test]
    fn rewrites_a_bare_qualifier() {
        assert_eq!(
            rewrite_check_table_qualifier("T.age > 0", "T", "U"),
            "U.age > 0"
        );
        // multiple occurrences, table-level CHECK shape
        assert_eq!(
            rewrite_check_table_qualifier("T.a <> T.b", "T", "U"),
            "U.a <> U.b"
        );
    }

    #[test]
    fn is_case_insensitive_on_the_qualifier() {
        assert_eq!(
            rewrite_check_table_qualifier("t.age > 0", "T", "U"),
            "U.age > 0"
        );
    }

    #[test]
    fn rewrites_a_quoted_qualifier() {
        assert_eq!(
            rewrite_check_table_qualifier("\"T\".age > 0", "T", "U"),
            "\"U\".age > 0"
        );
    }

    #[test]
    fn leaves_string_literals_untouched() {
        assert_eq!(
            rewrite_check_table_qualifier("note <> 'T.x' AND T.age > 0", "T", "U"),
            "note <> 'T.x' AND U.age > 0"
        );
    }

    #[test]
    fn leaves_a_bare_name_that_is_not_a_qualifier() {
        // A column literally named like the table, not followed by `.`.
        assert_eq!(rewrite_check_table_qualifier("T > 0", "T", "U"), "T > 0");
    }

    #[test]
    fn unqualified_check_is_a_no_op() {
        assert_eq!(
            rewrite_check_table_qualifier("age > 0", "T", "U"),
            "age > 0"
        );
    }

    #[test]
    fn does_not_match_a_longer_identifier() {
        // `Total` merely starts with `T`; not the qualifier `T`.
        assert_eq!(
            rewrite_check_table_qualifier("Total.x > 0", "T", "U"),
            "Total.x > 0"
        );
    }
}

/// Whether any CHECK constraint on `table` references `column` — both the
/// table-level CHECKs (`read_table_checks`) and the column-level CHECKs
/// (`schema.columns[].check`), the guard for drop/rename column
/// (ADR-0035 §4e).
///
/// `include_self` controls whether the column's *own* column-level CHECK
/// counts: a RENAME rewrites the live CHECK but leaves the stored text
/// stale (drift) even for a self-check, so it must be included; a DROP
/// removes the column's own CHECK alongside it, so it is excluded (only a
/// *cross-referencing* CHECK blocks the drop).
fn column_referenced_by_check(
    conn: &Connection,
    table: &str,
    schema: &ReadSchema,
    column: &str,
    include_self: bool,
) -> Result<bool, DbError> {
    for check in read_table_checks(conn, table)? {
        if check_references_column(&check.expr, column) {
            return Ok(true);
        }
    }
    for col in &schema.columns {
        if !include_self && col.name == column {
            continue;
        }
        if let Some(check) = &col.check
            && check_references_column(check, column)
        {
            return Ok(true);
        }
    }
    Ok(false)
}

/// Read the user-created indexes on `table` (ADR-0025).
///
/// `pragma_index_list` reports every index; we keep only those
/// with origin `c` (a `CREATE INDEX` statement) and skip partial
/// indexes — the playground never creates partial indexes, and
/// surfacing the automatic PK / UNIQUE indexes as user indexes
/// would be misleading. Results are ordered by index name for
/// stable rendering.
fn read_table_indexes(conn: &Connection, table: &str) -> Result<Vec<IndexInfo>, DbError> {
    let mut list_stmt = conn
        .prepare(
            "SELECT name, \"unique\", origin, partial \
             FROM pragma_index_list(?1) \
             ORDER BY name;",
        )
        .map_err(DbError::from_rusqlite)?;
    let metas = list_stmt
        .query_map([table], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, i64>(1)? != 0,
                row.get::<_, String>(2)?,
                row.get::<_, i64>(3)? != 0,
            ))
        })
        .map_err(DbError::from_rusqlite)?;
    let mut keep: Vec<(String, bool)> = Vec::new();
    for meta in metas {
        let (name, unique, origin, partial) = meta.map_err(DbError::from_rusqlite)?;
        if origin == "c" && !partial {
            keep.push((name, unique));
        }
    }
    let mut out = Vec::with_capacity(keep.len());
    for (name, unique) in keep {
        let columns = read_index_columns(conn, &name)?;
        out.push(IndexInfo {
            name,
            columns,
            unique,
        });
    }
    Ok(out)
}

/// The indexed columns of `index`, in index order.
fn read_index_columns(conn: &Connection, index: &str) -> Result<Vec<String>, DbError> {
    let mut stmt = conn
        .prepare("SELECT name FROM pragma_index_info(?1) ORDER BY seqno;")
        .map_err(DbError::from_rusqlite)?;
    let rows = stmt
        .query_map([index], |row| row.get::<_, String>(0))
        .map_err(DbError::from_rusqlite)?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(DbError::from_rusqlite)?);
    }
    Ok(out)
}

fn parse_action_from_sqlite(s: &str) -> ReferentialAction {
    // SQLite stores the action keywords in upper-case form
    // ("CASCADE", "SET NULL", "NO ACTION", "RESTRICT").
    s.parse::<ReferentialAction>()
        .unwrap_or(ReferentialAction::NoAction)
}

/// Read the set of column names carrying a single-column UNIQUE
/// constraint on `table`, via `pragma_index_list` +
/// `pragma_index_info`. Filters to indexes whose `origin` is
/// `"u"` (a UNIQUE constraint, not a PK-implied or CHECK
/// auto-index) and which cover exactly one column. Compound
/// UNIQUE is deferred to a future ADR (out of scope for ADR-0018).
/// Read the table's `UNIQUE` constraints (ADR-0018 §4, ADR-0035
/// §4a.2) from the constraint-backing indexes (`origin = 'u'`).
/// Returns `(single_column_names, composite_constraints)`: a
/// single-column UNIQUE rides on the column's `unique` flag, while a
/// multi-column UNIQUE becomes a `Vec<String>` of its columns (in
/// index order).
fn read_unique_constraints(
    conn: &Connection,
    table: &str,
) -> Result<(std::collections::HashSet<String>, Vec<Vec<String>>), DbError> {
    let mut single: std::collections::HashSet<String> = std::collections::HashSet::new();
    let mut composite: Vec<Vec<String>> = Vec::new();
    let mut idx_stmt = conn
        .prepare(
            "SELECT name, \"unique\", origin \
             FROM pragma_index_list(?1);",
        )
        .map_err(DbError::from_rusqlite)?;
    let idx_rows = idx_stmt
        .query_map([table], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, i64>(1)? != 0,
                row.get::<_, String>(2)?,
            ))
        })
        .map_err(DbError::from_rusqlite)?;
    let indexes: Vec<(String, bool, String)> = idx_rows
        .collect::<Result<Vec<_>, _>>()
        .map_err(DbError::from_rusqlite)?;
    for (idx_name, is_unique, origin) in indexes {
        if !is_unique || origin != "u" {
            continue;
        }
        let mut info_stmt = conn
            .prepare("SELECT name FROM pragma_index_info(?1) ORDER BY seqno;")
            .map_err(DbError::from_rusqlite)?;
        let cols: Vec<String> = info_stmt
            .query_map([&idx_name], |row| row.get::<_, String>(0))
            .map_err(DbError::from_rusqlite)?
            .collect::<Result<Vec<_>, _>>()
            .map_err(DbError::from_rusqlite)?;
        match cols.len() {
            0 => {}
            1 => {
                single.insert(cols.into_iter().next().expect("len 1"));
            }
            _ => composite.push(cols),
        }
    }
    Ok((single, composite))
}

/// Engine-neutral display/address name for an anonymous composite UNIQUE
/// constraint (ADR-0035 Amendment 1): `unique_<col1>_<col2>…`. A pure
/// function of the column list — recomputed wherever the name is shown
/// (`describe`) or matched (`ALTER TABLE … DROP CONSTRAINT <name>`), so
/// nothing is persisted; the constraint stays a bare column-list in our
/// model and the §4g anonymity decision is intact.
pub(crate) fn unique_constraint_name(cols: &[String]) -> String {
    format!("unique_{}", cols.join("_"))
}

/// Generate the CREATE TABLE DDL from a `ReadSchema`. Used during
/// the rebuild dance.
fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
    let mut clauses: Vec<String> = Vec::new();

    // The single-column-INTEGER-PK case must be inline (`PRIMARY
    // KEY` on the column itself) so SQLite gives it rowid-alias
    // semantics. Compound or non-INTEGER PKs go to a table-level
    // constraint.
    let single_inline_pk = schema.primary_key.len() == 1
        && !schema.columns.is_empty()
        && schema.columns[0].primary_key
        && schema.primary_key[0] == schema.columns[0].name;

    for col in &schema.columns {
        let mut clause = format!(
            "{ident} {sqlite_type}",
            ident = quote_ident(&col.name),
            sqlite_type = col.sqlite_type,
        );
        if col.notnull {
            clause.push_str(" NOT NULL");
        }
        if single_inline_pk && col.primary_key {
            clause.push_str(" PRIMARY KEY");
        }
        // Inline UNIQUE for columns flagged unique (ADR-0018 §4,
        // ADR-0029 §9). A *single-column* PK is already UNIQUE
        // via PRIMARY KEY, so suppress a redundant index there;
        // a *compound*-PK member is not individually unique, so
        // an explicit UNIQUE on it is a real, distinct rule.
        let single_column_pk = schema.primary_key.len() == 1;
        if col.unique && !(col.primary_key && single_column_pk) {
            clause.push_str(" UNIQUE");
        }
        // ADR-0029 DEFAULT — echoed verbatim from the value
        // SQLite reported, so the rebuild dance preserves it.
        if let Some(default_sql) = &col.default_sql {
            clause.push_str(" DEFAULT ");
            clause.push_str(default_sql);
        }
        // ADR-0029 CHECK — echoed verbatim from the compiled
        // SQL stored in the `check_expr` metadata column.
        if let Some(check) = &col.check {
            clause.push_str(" CHECK (");
            clause.push_str(check);
            clause.push(')');
        }
        clauses.push(clause);
    }

    if !single_inline_pk && !schema.primary_key.is_empty() {
        let pk_idents: Vec<String> = schema.primary_key.iter().map(|n| quote_ident(n)).collect();
        clauses.push(format!("PRIMARY KEY ({})", pk_idents.join(", ")));
    }

    // Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2) —
    // emitted identically to `do_create_table` so a created table and
    // its rebuilt form match.
    for cols in &schema.unique_constraints {
        let idents: Vec<String> = cols.iter().map(|n| quote_ident(n)).collect();
        clauses.push(format!("UNIQUE ({})", idents.join(", ")));
    }

    // Table-level CHECK constraints (ADR-0035 §4a.3) — echoed verbatim
    // from the raw SQL stored in the metadata table, emitted identically
    // to `do_create_table` (the §6.1 two-generators rule). A named CHECK
    // (ADR-0035 §4g) re-emits its `CONSTRAINT <name>` prefix so the name
    // round-trips through a rebuild.
    for check in &schema.check_constraints {
        match &check.name {
            Some(name) => clauses.push(format!(
                "CONSTRAINT {ident} CHECK ({expr})",
                ident = quote_ident(name),
                expr = check.expr,
            )),
            None => clauses.push(format!("CHECK ({expr})", expr = check.expr)),
        }
    }

    for fk in &schema.foreign_keys {
        // Multi-column FK (ADR-0043): emit the positionally-paired
        // column lists `FOREIGN KEY (a, b) REFERENCES P(x, y)`. A
        // single-column FK is the one-element case.
        clauses.push(format!(
            "FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \
             ON DELETE {od} ON UPDATE {ou}",
            child = quote_cols(&fk.child_columns),
            parent_table = quote_ident(&fk.parent_table),
            parent_col = quote_cols(&fk.parent_columns),
            od = fk.on_delete.sql_clause(),
            ou = fk.on_update.sql_clause(),
        ));
    }

    format!(
        "CREATE TABLE {ident} ({clauses}) STRICT;",
        ident = quote_ident(table),
        clauses = clauses.join(", "),
    )
}

/// The rebuild-table dance. Replaces `table` with a fresh table
/// constructed from `new_schema`, then runs a caller-provided
/// `copy_data` step to populate it from the old table. The
/// `metadata_updates` closure runs inside the same transaction
/// after the data copy.
///
/// Most callers want the default INSERT…SELECT copy — they
/// should use [`rebuild_table`] which wraps this with that copy
/// strategy. The custom-copy form is reserved for `change column
/// type` (ADR-0017), which transforms one column's values
/// row-by-row before binding them.
///
/// Per the official SQLite ALTER-via-rebuild recipe: foreign-key
/// enforcement must be temporarily disabled at session level,
/// not just deferred, because the connection-level pragma is a
/// no-op inside transactions.
fn rebuild_table_with_copy<C, M>(
    conn: &Connection,
    table: &str,
    new_schema: &ReadSchema,
    copy_data: C,
    metadata_updates: M,
) -> Result<(), DbError>
where
    C: FnOnce(&rusqlite::Transaction<'_>, &str, &str) -> Result<(), DbError>,
    M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>,
{
    debug!(table = %table, cols = new_schema.columns.len(), "rebuild_table: begin (foreign_keys OFF, temp-copy primitive)");
    // foreign_keys=OFF must be set *outside* a transaction.
    conn.execute_batch("PRAGMA foreign_keys = OFF;")
        .map_err(DbError::from_rusqlite)?;

    let result = (|| -> Result<(), DbError> {
        let tx = conn
            .unchecked_transaction()
            .map_err(DbError::from_rusqlite)?;

        let temp_name = format!("__rdbms_rebuild_{table}");
        // Defensive: drop any leftover rebuild table from a
        // previous failed attempt.
        tx.execute_batch(&format!(
            "DROP TABLE IF EXISTS {ident};",
            ident = quote_ident(&temp_name)
        ))
        .map_err(DbError::from_rusqlite)?;

        let create_temp = schema_to_ddl(&temp_name, new_schema);
        tx.execute_batch(&create_temp)
            .map_err(DbError::from_rusqlite)?;

        copy_data(&tx, &temp_name, table)?;

        // Capture the table's user indexes before the drop —
        // `DROP TABLE` discards them (ADR-0025). They are
        // recreated verbatim after the rename: every caller of
        // this primitive preserves the column set, so the index
        // column references stay valid.
        let captured_indexes = read_table_indexes(&tx, table)?;

        tx.execute_batch(&format!("DROP TABLE {ident};", ident = quote_ident(table)))
            .map_err(DbError::from_rusqlite)?;
        tx.execute_batch(&format!(
            "ALTER TABLE {temp} RENAME TO {final_name};",
            temp = quote_ident(&temp_name),
            final_name = quote_ident(table),
        ))
        .map_err(DbError::from_rusqlite)?;

        for index in &captured_indexes {
            let cols = index
                .columns
                .iter()
                .map(|c| quote_ident(c))
                .collect::<Vec<_>>()
                .join(", ");
            let unique_kw = if index.unique { "UNIQUE " } else { "" };
            tx.execute_batch(&format!(
                "CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
                idx = quote_ident(&index.name),
                tbl = quote_ident(table),
            ))
            .map_err(DbError::from_rusqlite)?;
        }

        metadata_updates(&tx)?;

        // Verify referential integrity before committing. Any
        // returned rows mean a FK violation persisted.
        let mut check = tx
            .prepare("PRAGMA foreign_key_check;")
            .map_err(DbError::from_rusqlite)?;
        let mut rows = check.query([]).map_err(DbError::from_rusqlite)?;
        if let Some(_row) = rows.next().map_err(DbError::from_rusqlite)? {
            warn!(table = %table, "rebuild_table: foreign_key_check failed; existing data violates new constraint, rolling back");
            return Err(DbError::Sqlite {
                message: format!(
                    "foreign-key check failed after rebuild of `{table}`; \
                     existing data violates the new constraint"
                ),
                kind: SqliteErrorKind::Other,
            });
        }
        drop(rows);
        drop(check);

        tx.commit().map_err(DbError::from_rusqlite)?;
        debug!(table = %table, indexes = captured_indexes.len(), "rebuild_table: committed (indexes recreated)");
        Ok(())
    })();

    // Always re-enable foreign_keys, even on error.
    let pragma_result = conn
        .execute_batch("PRAGMA foreign_keys = ON;")
        .map_err(DbError::from_rusqlite);
    if let Err(e) = &pragma_result {
        warn!(table = %table, error = %e, "rebuild_table: failed to re-enable foreign_keys after rebuild");
    }

    result.and(pragma_result)
}

/// The default rebuild-table strategy: column-by-name copy via
/// `INSERT INTO new (cols) SELECT cols FROM old`, where `cols`
/// is the intersection of old and new column names.
fn rebuild_table<F>(
    conn: &Connection,
    table: &str,
    old_schema: &ReadSchema,
    new_schema: &ReadSchema,
    metadata_updates: F,
) -> Result<(), DbError>
where
    F: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>,
{
    let copy_cols: Vec<String> = new_schema
        .columns
        .iter()
        .filter(|c| old_schema.columns.iter().any(|oc| oc.name == c.name))
        .map(|c| c.name.clone())
        .collect();

    rebuild_table_with_copy(
        conn,
        table,
        new_schema,
        |tx, temp_name, orig| {
            if copy_cols.is_empty() {
                return Ok(());
            }
            let cols_csv = copy_cols
                .iter()
                .map(|c| quote_ident(c))
                .collect::<Vec<_>>()
                .join(", ");
            let copy_sql = format!(
                "INSERT INTO {temp} ({cols}) SELECT {cols} FROM {orig};",
                temp = quote_ident(temp_name),
                cols = cols_csv,
                orig = quote_ident(orig),
            );
            tx.execute_batch(&copy_sql).map_err(DbError::from_rusqlite)
        },
        metadata_updates,
    )
}

/// Auto-name a relationship per ADR-0013
/// (`<Parent>_<pcol>_to_<Child>_<ccol>`, reading in the declared
/// direction) when `name` is `None`; otherwise use the supplied name.
/// Shared by the DSL `add relationship` path and advanced-mode SQL
/// `CREATE TABLE` foreign keys (ADR-0035 §5).
fn resolve_relationship_name(
    name: Option<&str>,
    parent_table: &str,
    parent_columns: &[String],
    child_table: &str,
    child_columns: &[String],
) -> String {
    // Auto-name joins each side's column list with `_` (ADR-0043):
    // a compound FK becomes `Parent_a_b_to_Child_x_y`; a
    // single-column FK is unchanged (`Parent_a_to_Child_x`).
    name.map_or_else(
        || {
            format!(
                "{parent_table}_{p}_to_{child_table}_{c}",
                p = parent_columns.join("_"),
                c = child_columns.join("_"),
            )
        },
        ToString::to_string,
    )
}

/// Reject a relationship name that collides with an existing one (the
/// `name` column is UNIQUE, ADR-0013). Engine-neutral message.
fn ensure_relationship_name_unique(conn: &Connection, name: &str) -> Result<(), DbError> {
    let collision: i64 = conn
        .query_row(
            &format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"),
            [name],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    if collision > 0 {
        return Err(DbError::Unsupported(format!(
            "a relationship named `{name}` already exists. \
             Pick a different name or drop the existing one first."
        )));
    }
    Ok(())
}

/// Insert one relationship metadata row into `REL_TABLE` (ADR-0013).
/// Shared by `add relationship` (inside its rebuild transaction) and
/// SQL `CREATE TABLE` foreign keys (inside the create transaction);
/// `conn` may be a `&Transaction` (deref-coerces to `&Connection`).
#[allow(clippy::too_many_arguments)]
fn insert_relationship_metadata(
    conn: &Connection,
    name: &str,
    parent_table: &str,
    parent_columns: &[String],
    child_table: &str,
    child_columns: &[String],
    on_delete: ReferentialAction,
    on_update: ReferentialAction,
) -> Result<(), DbError> {
    conn.execute(
        &format!(
            "INSERT INTO {REL_TABLE} \
               (name, parent_table, parent_column, child_table, child_column, on_delete, on_update) \
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"
        ),
        [
            name,
            parent_table,
            &encode_rel_columns(parent_columns),
            child_table,
            &encode_rel_columns(child_columns),
            on_delete.keyword(),
            on_update.keyword(),
        ],
    )
    .map_err(DbError::from_rusqlite)?;
    Ok(())
}

/// Encode a relationship's column list into a metadata `TEXT` cell
/// (ADR-0043 D5): comma-joined. Safe because column identifiers are
/// `[A-Za-z0-9_]+` (no commas — parser.rs); a single-column FK
/// stores just the bare name, a compound one stores `a,b`.
fn encode_rel_columns(cols: &[String]) -> String {
    cols.join(",")
}

/// Inverse of [`encode_rel_columns`]: split a metadata cell back
/// into the ordered column list (one element for single-column).
fn decode_rel_columns(s: &str) -> Vec<String> {
    s.split(',').map(str::to_string).collect()
}

/// Format a relationship endpoint for display (ADR-0043): `T.col`
/// for a single column, `T.(a, b)` for a compound one — mirroring
/// the DSL `from P.(a, b)` syntax.
fn fmt_rel_endpoint(table: &str, cols: &[String]) -> String {
    if cols.len() == 1 {
        format!("{table}.{}", cols[0])
    } else {
        format!("{table}.({})", cols.join(", "))
    }
}

/// Validate that an FK child column's type is compatible with the
/// referenced parent column's type — it must equal the parent type's
/// `fk_target_type()` (ADR-0011). Engine-neutral mismatch error.
fn check_fk_type_compat(
    parent_table: &str,
    parent_column: &str,
    parent_type: Type,
    child_table: &str,
    child_column: &str,
    child_type: Type,
) -> Result<(), DbError> {
    let expected = parent_type.fk_target_type();
    if child_type != expected {
        return Err(DbError::Unsupported(format!(
            "type mismatch: `{child_table}.{child_column}` is `{child_type}` but \
             a foreign key referencing `{parent_table}.{parent_column}` \
             (`{parent_type}`) requires `{expected}`. \
             Either change the column type, or pick a different FK column."
        )));
    }
    Ok(())
}

/// Resolve a foreign key's parent (referenced) column list and check
/// it pairs with the child column list (ADR-0043).
///
/// - **F-A (full PK):** an *explicit* `REFERENCES P(...)` list must be
///   exactly the parent's primary-key column **set** (any order;
///   paired positionally with the child list). A subset, super-set,
///   or non-PK column is refused (UNIQUE-target / subset FKs are OOS).
/// - **F-D (auto-expand):** a *bare* `REFERENCES P` (`explicit ==
///   None`) expands to the parent's full PK in PK order, requiring the
///   child arity to match.
/// - Arity: the returned parent list and the child list must be
///   equal, non-zero length.
fn resolve_fk_parent_columns(
    parent_table: &str,
    parent_pk: &[String],
    explicit: Option<&[String]>,
    child_arity: usize,
    inline: bool,
) -> Result<Vec<String>, DbError> {
    if child_arity == 0 {
        return Err(DbError::Unsupported(
            "a foreign key needs at least one column.".to_string(),
        ));
    }
    let parent_columns = match explicit {
        None => {
            if parent_pk.is_empty() {
                return Err(DbError::Unsupported(format!(
                    "`{parent_table}` has no primary key to reference."
                )));
            }
            parent_pk.to_vec()
        }
        Some(cols) => {
            use std::collections::BTreeSet;
            let pk: BTreeSet<&String> = parent_pk.iter().collect();
            let given: BTreeSet<&String> = cols.iter().collect();
            if pk != given {
                return Err(DbError::Unsupported(format!(
                    "a foreign key must reference `{parent_table}`'s full primary \
                     key ({pk_list}); got ({given_list}). Referencing a subset, or \
                     a non-primary-key column, is not supported.",
                    pk_list = parent_pk.join(", "),
                    given_list = cols.join(", "),
                )));
            }
            cols.to_vec()
        }
    };
    if parent_columns.len() != child_arity {
        // An inline column-level FK (`<col> REFERENCES …`) can only carry
        // the one column it sits on, so it can never satisfy a compound
        // key — point the user at the table-level form rather than the
        // generic arity message (ADR-0043 D4).
        if inline && parent_columns.len() > 1 {
            return Err(DbError::Unsupported(format!(
                "an inline column reference can only name one column, but \
                 `{parent_table}`'s key has {n}. Use the table-level form \
                 instead: `FOREIGN KEY (<columns>) REFERENCES \
                 {parent_table} ({pk})`.",
                n = parent_columns.len(),
                pk = parent_columns.join(", "),
            )));
        }
        return Err(DbError::Unsupported(format!(
            "{child_arity} foreign-key column(s) on the child side, but \
             `{parent_table}`'s key has {n}. A foreign key references every \
             column of the key, paired in order.",
            n = parent_columns.len(),
        )));
    }
    Ok(parent_columns)
}

/// A `CREATE TABLE` foreign key after resolution + validation
/// (ADR-0035 §5, sub-phase 4b): the bare-`REFERENCES` parent column is
/// resolved, the relationship name is decided, and PK-target /
/// type-compat are checked.
struct ResolvedFk {
    name: String,
    child_columns: Vec<String>,
    parent_table: String,
    parent_columns: Vec<String>,
    on_delete: ReferentialAction,
    on_update: ReferentialAction,
}

/// Resolve + validate every foreign key declared in a `CREATE TABLE`
/// (ADR-0035 §5, sub-phase 4b) **before** the table is built, so an
/// invalid reference aborts cleanly. A self-referencing FK (parent is
/// the table being created) is validated against the columns/PK being
/// defined; any other parent must already exist. The bare
/// `REFERENCES <parent>` form resolves to the parent's single-column
/// PK (composite → error). Reuses the relationship validation/naming
/// helpers shared with `do_add_relationship`.
fn resolve_create_table_fks(
    conn: &Connection,
    table_name: &str,
    columns: &[ColumnSpec],
    primary_key: &[String],
    foreign_keys: &[SqlForeignKey],
) -> Result<Vec<ResolvedFk>, DbError> {
    let mut out = Vec::with_capacity(foreign_keys.len());
    for fk in foreign_keys {
        // The parent's PK column list + a (name -> user type) lookup.
        // A self-reference reads the in-statement specs (the table does
        // not exist yet); any other parent must already exist.
        let (parent_pk, parent_cols): (Vec<String>, Vec<(String, Option<Type>)>) =
            if fk.parent_table == table_name {
                (
                    primary_key.to_vec(),
                    columns
                        .iter()
                        .map(|c| (c.name.clone(), Some(c.ty)))
                        .collect(),
                )
            } else {
                let ps = read_schema(conn, &fk.parent_table)?;
                (
                    ps.primary_key.clone(),
                    ps.columns
                        .iter()
                        .map(|c| (c.name.clone(), c.user_type))
                        .collect(),
                )
            };

        // Resolve the parent column list: explicit must be the full PK
        // set (F-A); bare auto-expands to the PK (F-D). Arity-checked
        // against the child column count (ADR-0043).
        let parent_columns = resolve_fk_parent_columns(
            &fk.parent_table,
            &parent_pk,
            fk.parent_columns.as_deref(),
            fk.child_columns.len(),
            fk.inline,
        )?;

        // Each child column must be one of the columns being defined,
        // and each pair must be type-compatible (ADR-0011, per pair).
        for (child_col, parent_col) in fk.child_columns.iter().zip(&parent_columns) {
            let child = columns
                .iter()
                .find(|c| &c.name == child_col)
                .ok_or_else(|| DbError::Sqlite {
                    message: format!("no such column: {table_name}.{child_col}"),
                    kind: SqliteErrorKind::NoSuchColumn,
                })?;
            let parent_type = parent_cols
                .iter()
                .find(|(n, _)| n == parent_col)
                .and_then(|(_, t)| *t)
                .ok_or_else(|| DbError::Sqlite {
                    message: format!("no such column: {}.{parent_col}", fk.parent_table),
                    kind: SqliteErrorKind::NoSuchColumn,
                })?;
            check_fk_type_compat(
                &fk.parent_table,
                parent_col,
                parent_type,
                table_name,
                child_col,
                child.ty,
            )?;
        }

        let resolved_name = resolve_relationship_name(
            fk.name.as_deref(),
            &fk.parent_table,
            &parent_columns,
            table_name,
            &fk.child_columns,
        );
        ensure_relationship_name_unique(conn, &resolved_name)?;

        out.push(ResolvedFk {
            name: resolved_name,
            child_columns: fk.child_columns.clone(),
            parent_table: fk.parent_table.clone(),
            parent_columns,
            on_delete: fk.on_delete,
            on_update: fk.on_update,
        });
    }
    Ok(out)
}

/// Generate a junction table for an m:n relationship between `t1` and
/// `t2` (ADR-0045 / C4). Builds one FK column per parent PK column
/// (`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a
/// compound PK over all of them, and two `CASCADE` foreign keys, then
/// hands the whole thing to [`do_create_table`] — so the junction table
/// and both relationships are created in one transaction = one undo
/// step. Self-referential m:n is refused (column-name collision); a
/// PK-less parent is refused (nothing to reference).
fn do_create_m2n_relationship(
    conn: &Connection,
    persistence: Option<&Persistence>,
    t1: &str,
    t2: &str,
    name: Option<&str>,
) -> Result<TableDescription, DbError> {
    debug!(t1 = %t1, t2 = %t2, name = ?name, "create_m2n_relationship");
    // Canonicalize both parents (refuse non-existent / internal tables).
    let canon_t1 = require_canonical_table(conn, t1)?;
    let t1 = canon_t1.as_str();
    let canon_t2 = require_canonical_table(conn, t2)?;
    let t2 = canon_t2.as_str();

    // Self-referential m:n is OOS (ADR-0045): the two FK column sets
    // would collide on `{T}_{pkcol}`, needing directional names this
    // beginner convenience deliberately avoids.
    if t1.eq_ignore_ascii_case(t2) {
        return Err(DbError::Unsupported(format!(
            "an m:n relationship needs two different tables (got `{t1}` twice). \
             To link a table to itself, build the junction table by hand."
        )));
    }

    let schema1 = read_schema(conn, t1)?;
    let schema2 = read_schema(conn, t2)?;

    // Build one FK column per parent PK column (compound parents
    // contribute one each, ADR-0043) + the compound PK + the two FKs.
    let mut columns: Vec<ColumnSpec> = Vec::new();
    let mut primary_key: Vec<String> = Vec::new();
    let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
    for (tbl, schema) in [(t1, &schema1), (t2, &schema2)] {
        // D7 parent-PK guard: advanced-mode SQL can create a PK-less
        // table; it cannot anchor an m:n relationship.
        if schema.primary_key.is_empty() {
            return Err(DbError::Unsupported(format!(
                "`{tbl}` has no primary key, so it cannot anchor an m:n relationship."
            )));
        }
        let mut child_columns: Vec<String> = Vec::new();
        for pkcol in &schema.primary_key {
            let pcol = schema
                .columns
                .iter()
                .find(|c| &c.name == pkcol)
                .ok_or_else(|| DbError::Sqlite {
                    message: format!("no such column: {tbl}.{pkcol}"),
                    kind: SqliteErrorKind::NoSuchColumn,
                })?;
            let pty = pcol.user_type.ok_or_else(|| {
                DbError::Unsupported("primary-key column has no user type metadata".to_string())
            })?;
            let col_name = format!("{tbl}_{pkcol}");
            columns.push(ColumnSpec::new(col_name.clone(), pty.fk_target_type()));
            primary_key.push(col_name.clone());
            child_columns.push(col_name);
        }
        foreign_keys.push(SqlForeignKey {
            name: None,
            child_columns,
            parent_table: tbl.to_string(),
            parent_columns: Some(schema.primary_key.clone()),
            on_delete: ReferentialAction::Cascade,
            on_update: ReferentialAction::Cascade,
            inline: false,
        });
    }

    // Junction name: explicit `as <name>` or the auto-name `{t1}_{t2}`.
    let junction = name.map_or_else(|| format!("{t1}_{t2}"), str::to_string);
    debug!(junction = %junction, cols = columns.len(), "create_m2n_relationship: building junction table");

    do_create_table(
        conn,
        persistence,
        &junction,
        &columns,
        &primary_key,
        &[],
        &[],
        &foreign_keys,
    )
}

#[allow(clippy::too_many_arguments)]
fn do_add_relationship(
    conn: &Connection,
    persistence: Option<&Persistence>,
    name: Option<&str>,
    parent_table: &str,
    parent_columns: &[String],
    child_table: &str,
    child_columns: &[String],
    on_delete: ReferentialAction,
    on_update: ReferentialAction,
    create_fk: bool,
) -> Result<TableDescription, DbError> {
    debug!(name = ?name, parent = %parent_table, child = %child_table, "add_relationship");
    // Canonicalize both endpoints to their stored case (and refuse a
    // non-existent / internal `__rdbms_*` table as "no such table"), like
    // the sibling schema-mutation executors — so the relationship metadata
    // stores the stored-case names and `describe` / rebuild match them.
    // Closes the simple `add 1:n relationship` exposure and the SQL `ALTER
    // TABLE … ADD FOREIGN KEY` decomposition target (ADR-0035 §4g).
    let canonical_parent = require_canonical_table(conn, parent_table)?;
    let parent_table = canonical_parent.as_str();
    let canonical_child = require_canonical_table(conn, child_table)?;
    let child_table = canonical_child.as_str();
    // 1. Read parent schema; resolve+validate the referenced columns
    //    are the parent's full PK (F-A), arity-matched to the child
    //    list (ADR-0043). The DSL always supplies explicit columns.
    let parent_schema = read_schema(conn, parent_table)?;
    let parent_columns = resolve_fk_parent_columns(
        parent_table,
        &parent_schema.primary_key,
        Some(parent_columns),
        child_columns.len(),
        false, // DSL `add relationship` is never an inline column FK
    )?;

    // 2. Read child schema; refuse missing columns unless --create-fk.
    let mut child_schema = read_schema(conn, child_table)?;
    let missing: Vec<&String> = child_columns
        .iter()
        .filter(|c| child_schema.columns.iter().all(|col| &col.name != *c))
        .collect();
    if !missing.is_empty() && !create_fk {
        let list = missing
            .iter()
            .map(|c| format!("`{child_table}.{c}`"))
            .collect::<Vec<_>>()
            .join(", ");
        return Err(DbError::Unsupported(format!(
            "column(s) {list} do not exist. Add them first, or use \
             `--create-fk` to create them automatically."
        )));
    }

    // 3. Per pair: resolve the parent column's user type, then either
    //    validate the existing child column's type (ADR-0011 per pair)
    //    or synthesise a new child column typed to the parent's
    //    fk_target_type (--create-fk, one per missing column).
    let mut created: Vec<(String, Type)> = Vec::new();
    for (child_col, parent_col) in child_columns.iter().zip(&parent_columns) {
        let pcol = parent_schema
            .columns
            .iter()
            .find(|c| &c.name == parent_col)
            .ok_or_else(|| DbError::Sqlite {
                message: format!("no such column: {parent_table}.{parent_col}"),
                kind: SqliteErrorKind::NoSuchColumn,
            })?;
        let parent_user_type = pcol.user_type.ok_or_else(|| {
            DbError::Unsupported("parent column has no user type metadata".to_string())
        })?;
        match child_schema.columns.iter().find(|c| &c.name == child_col) {
            Some(child_col_row) => {
                let actual = child_col_row.user_type.ok_or_else(|| {
                    DbError::Unsupported("child column has no user type metadata".to_string())
                })?;
                check_fk_type_compat(
                    parent_table,
                    parent_col,
                    parent_user_type,
                    child_table,
                    child_col,
                    actual,
                )?;
            }
            None => created.push((child_col.clone(), parent_user_type.fk_target_type())),
        }
    }

    // Synthesise the new child columns into the old schema (so rebuild
    // builds the new table with them), mirroring the single-column path.
    for (col, ty) in &created {
        child_schema.columns.push(ReadColumn {
            name: col.clone(),
            sqlite_type: ty.sqlite_strict_type().to_string(),
            notnull: false,
            primary_key: false,
            unique: false,
            default_sql: None,
            check: None,
            user_type: Some(*ty),
        });
    }

    // 4. Determine relationship name (auto-gen or supplied) and
    //    check uniqueness against the metadata table.
    let resolved_name = resolve_relationship_name(
        name,
        parent_table,
        &parent_columns,
        child_table,
        child_columns,
    );
    ensure_relationship_name_unique(conn, &resolved_name)?;

    // 5. Build the new schema with the (single, multi-column) FK appended.
    let mut new_schema = child_schema.clone();
    new_schema.foreign_keys.push(ReadForeignKey {
        parent_table: parent_table.to_string(),
        parent_columns: parent_columns.clone(),
        child_columns: child_columns.to_vec(),
        on_delete,
        on_update,
    });

    // 6. Rebuild, with metadata updates inside the transaction.
    rebuild_table(conn, child_table, &child_schema, &new_schema, |tx| {
        for (col, ty) in &created {
            tx.execute(
                &format!(
                    "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
                     VALUES (?1, ?2, ?3);"
                ),
                [child_table, col.as_str(), ty.keyword()],
            )
            .map_err(DbError::from_rusqlite)?;
        }
        insert_relationship_metadata(
            tx,
            &resolved_name,
            parent_table,
            &parent_columns,
            child_table,
            child_columns,
            on_delete,
            on_update,
        )?;
        // Persistence runs inside the same tx so a write
        // failure rolls back both the schema and the metadata
        // (commit-db-last per ADR-0015 §6).
        let changes = Changes {
            schema_dirty: true,
            // The child table was rebuilt — its CSV needs to
            // be re-emitted from the new state too, in case
            // the FK column was newly created.
            rewritten_tables: vec![child_table.to_string()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    })?;

    // Return the parent (1-side) description so the user sees
    // the new relationship via the inbound section — that's the
    // perspective that matches the `from <Parent>.col to ...`
    // direction of the command.
    do_describe_table(conn, parent_table)
}

fn do_drop_relationship(
    conn: &Connection,
    persistence: Option<&Persistence>,
    selector: &RelationshipSelector,
) -> Result<Option<TableDescription>, DbError> {
    debug!(selector = ?selector, "drop_relationship");
    // Resolve to a single relationship row.
    let resolved: Option<(String, String, String, String, String)> = match selector {
        RelationshipSelector::Named { name } => conn
            .query_row(
                &format!(
                    "SELECT name, parent_table, parent_column, child_table, child_column \
                     FROM {REL_TABLE} WHERE name = ?1;"
                ),
                [name],
                |row| {
                    Ok((
                        row.get(0)?,
                        row.get(1)?,
                        row.get(2)?,
                        row.get(3)?,
                        row.get(4)?,
                    ))
                },
            )
            .ok(),
        RelationshipSelector::Endpoints {
            parent_table,
            parent_column,
            child_table,
            child_column,
        } => conn
            .query_row(
                &format!(
                    "SELECT name, parent_table, parent_column, child_table, child_column \
                     FROM {REL_TABLE} \
                     WHERE parent_table = ?1 AND parent_column = ?2 \
                       AND child_table = ?3 AND child_column = ?4;"
                ),
                [parent_table, parent_column, child_table, child_column],
                |row| {
                    Ok((
                        row.get(0)?,
                        row.get(1)?,
                        row.get(2)?,
                        row.get(3)?,
                        row.get(4)?,
                    ))
                },
            )
            .ok(),
    };
    let (rel_name, parent_table, _parent_column, child_table, child_column) =
        resolved.ok_or_else(|| DbError::Sqlite {
            message: format!("no such relationship: {selector}"),
            kind: SqliteErrorKind::Other,
        })?;

    // Read child schema; build new schema without the FK.
    let old_schema = read_schema(conn, &child_table)?;
    let mut new_schema = old_schema.clone();
    new_schema
        .foreign_keys
        .retain(|fk| fk.child_columns != decode_rel_columns(&child_column));

    let child_table_for_persist = child_table.clone();
    rebuild_table(conn, &child_table, &old_schema, &new_schema, |tx| {
        tx.execute(
            &format!("DELETE FROM {REL_TABLE} WHERE name = ?1;"),
            [rel_name.as_str()],
        )
        .map_err(DbError::from_rusqlite)?;
        let changes = Changes {
            schema_dirty: true,
            rewritten_tables: vec![child_table_for_persist.clone()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    })?;

    // Show the parent (1-side) afterwards — same direction as
    // the command's `from <Parent> to ...` reading.
    Ok(Some(do_describe_table(conn, &parent_table)?))
}

/// `ALTER TABLE <T> ADD [CONSTRAINT <name>] CHECK (<expr>)` (ADR-0035
/// §4g). A dry-run refuses the add if any existing row fails the
/// predicate; the rebuild then re-emits the table with the new CHECK in
/// its DDL and records it in `CHECK_TABLE`. A *named* CHECK on a pre-4g
/// project (whose metadata table predates the `name` column — the
/// rebuild-only migration) is refused with a friendly "rebuild first"
/// message rather than a raw engine error.
fn do_alter_add_table_check(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    name: Option<&str>,
    expr_sql: &str,
) -> Result<TableDescription, DbError> {
    debug!(table = %table, name = ?name, "alter_add_table_check");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let old_schema = read_schema(conn, table)?;

    if name.is_some() && !check_table_has_name_column(conn)? {
        return Err(DbError::Unsupported(
            "this project predates named constraints; run `rebuild` to \
             upgrade it, then add the named constraint again."
                .to_string(),
        ));
    }

    // A named CHECK must not collide with an existing CHECK name on this
    // table NOR with a relationship name (FKs are also `DROP CONSTRAINT`
    // targets) — keeps `drop constraint <name>` unambiguous.
    if let Some(n) = name {
        let collides_check = old_schema
            .check_constraints
            .iter()
            .any(|c| c.name.as_deref() == Some(n));
        let collides_rel: i64 = conn
            .query_row(
                &format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"),
                [n],
                |row| row.get(0),
            )
            .map_err(DbError::from_rusqlite)?;
        if collides_check || collides_rel > 0 {
            return Err(DbError::Unsupported(format!(
                "a constraint named `{n}` already exists on `{table}`."
            )));
        }
    }

    // Dry-run: a CHECK passes on TRUE or NULL; only FALSE fails, so
    // `WHERE NOT (expr)` counts the genuine violations.
    let violating: i64 = conn
        .query_row(
            &format!(
                "SELECT COUNT(*) FROM {tbl} WHERE NOT ({expr_sql});",
                tbl = quote_ident(table),
            ),
            [],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    if violating > 0 {
        return Err(DbError::Unsupported(format!(
            "cannot add CHECK ({expr_sql}) to `{table}`: {violating} existing \
             row(s) do not satisfy it."
        )));
    }

    let mut new_schema = old_schema.clone();
    new_schema.check_constraints.push(TableCheck {
        name: name.map(ToString::to_string),
        expr: expr_sql.to_string(),
    });

    let table_owned = table.to_string();
    let name_owned = name.map(ToString::to_string);
    let expr_owned = expr_sql.to_string();
    rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
        // MAX(seq)+1 avoids colliding with a gap a prior DROP left.
        let next_seq: i64 = tx
            .query_row(
                &format!(
                    "SELECT COALESCE(MAX(seq), -1) + 1 FROM {CHECK_TABLE} \
                     WHERE table_name = ?1;"
                ),
                [table_owned.as_str()],
                |row| row.get(0),
            )
            .map_err(DbError::from_rusqlite)?;
        tx.execute(
            &format!(
                "INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr, name) \
                 VALUES (?1, ?2, ?3, ?4);"
            ),
            rusqlite::params![table_owned, next_seq, expr_owned, name_owned],
        )
        .map_err(DbError::from_rusqlite)?;
        let changes = Changes {
            schema_dirty: true,
            rewritten_tables: vec![table_owned.clone()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    })?;
    do_describe_table(conn, table)
}

/// `ALTER TABLE <T> ADD UNIQUE (<col>, …)` (ADR-0035 §4g) — a composite
/// UNIQUE constraint (anonymous: composite UNIQUE is PRAGMA-detected on
/// read, ADR-0035 §4a.2, so it carries no name). A dry-run refuses the
/// add if existing rows already contain a duplicate non-NULL tuple
/// (NULLs are distinct under SQL's UNIQUE semantics).
fn do_alter_add_unique(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    columns: &[String],
) -> Result<TableDescription, DbError> {
    debug!(table = %table, cols = ?columns, "alter_add_unique");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let old_schema = read_schema(conn, table)?;
    for c in columns {
        if !old_schema.columns.iter().any(|oc| &oc.name == c) {
            return Err(DbError::Sqlite {
                message: format!("no such column: {table}.{c}"),
                kind: SqliteErrorKind::NoSuchColumn,
            });
        }
    }

    let non_null = columns
        .iter()
        .map(|c| format!("{} IS NOT NULL", quote_ident(c)))
        .collect::<Vec<_>>()
        .join(" AND ");
    let group = columns
        .iter()
        .map(|c| quote_ident(c))
        .collect::<Vec<_>>()
        .join(", ");
    let dup_groups: i64 = conn
        .query_row(
            &format!(
                "SELECT COUNT(*) FROM (SELECT 1 FROM {tbl} WHERE {non_null} \
                 GROUP BY {group} HAVING COUNT(*) > 1);",
                tbl = quote_ident(table),
            ),
            [],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    if dup_groups > 0 {
        return Err(DbError::Unsupported(format!(
            "cannot add UNIQUE ({}) to `{table}`: existing rows contain \
             duplicate values.",
            columns.join(", "),
        )));
    }

    let mut new_schema = old_schema.clone();
    new_schema.unique_constraints.push(columns.to_vec());
    let table_owned = table.to_string();
    rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
        let changes = Changes {
            schema_dirty: true,
            rewritten_tables: vec![table_owned.clone()],
            ..Changes::default()
        };
        finalize_persistence(tx, persistence, &changes)?;
        Ok(())
    })?;
    do_describe_table(conn, table)
}

/// `ALTER TABLE <T> DROP CONSTRAINT <name>` (ADR-0035 §4g). Resolves
/// `name` to a named table-level CHECK on `T` (rebuild without it +
/// delete the metadata row), else to a named relationship (FK) whose
/// child is `T` (via `do_drop_relationship`), else refuses.
fn do_drop_constraint_by_name(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    name: &str,
) -> Result<Option<TableDescription>, DbError> {
    debug!(table = %table, name = %name, "drop_constraint_by_name");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();

    // 1. A named table-level CHECK on this table?
    if check_table_has_name_column(conn)? {
        let check_count: i64 = conn
            .query_row(
                &format!(
                    "SELECT COUNT(*) FROM {CHECK_TABLE} \
                     WHERE table_name = ?1 AND name = ?2;"
                ),
                [table, name],
                |row| row.get(0),
            )
            .map_err(DbError::from_rusqlite)?;
        if check_count > 0 {
            let old_schema = read_schema(conn, table)?;
            let mut new_schema = old_schema.clone();
            new_schema
                .check_constraints
                .retain(|c| c.name.as_deref() != Some(name));
            let (t, n) = (table.to_string(), name.to_string());
            rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
                tx.execute(
                    &format!("DELETE FROM {CHECK_TABLE} WHERE table_name = ?1 AND name = ?2;"),
                    [t.as_str(), n.as_str()],
                )
                .map_err(DbError::from_rusqlite)?;
                let changes = Changes {
                    schema_dirty: true,
                    rewritten_tables: vec![t.clone()],
                    ..Changes::default()
                };
                finalize_persistence(tx, persistence, &changes)?;
                Ok(())
            })?;
            return Ok(Some(do_describe_table(conn, table)?));
        }
    }

    // 2. A named relationship (FK) whose child is this table?
    let rel_count: i64 = conn
        .query_row(
            &format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1 AND child_table = ?2;"),
            [name, table],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    if rel_count > 0 {
        return do_drop_relationship(
            conn,
            persistence,
            &RelationshipSelector::Named {
                name: name.to_string(),
            },
        );
    }

    // 3. A composite UNIQUE whose derived name (ADR-0035 Amendment 1,
    //    `unique_<cols>`) matches? The constraint is anonymous in our
    //    model, so we recompute each composite UNIQUE's name and match.
    //    Order matters: a named CHECK/FK above shadows a derived UNIQUE
    //    name (the distinctive `unique_` prefix makes a clash unlikely).
    let schema = read_schema(conn, table)?;
    let matched_cols: Vec<Vec<String>> = schema
        .unique_constraints
        .iter()
        .filter(|cols| unique_constraint_name(cols) == name)
        .cloned()
        .collect();
    if matched_cols.len() > 1 {
        // Two distinct UNIQUEs can derive the same name (e.g. a column
        // literally named `b_c` vs `UNIQUE (b, c)`). Refuse rather than
        // guess which to drop.
        return Err(DbError::Unsupported(format!(
            "the constraint name `{name}` is ambiguous on `{table}` — it \
             matches more than one UNIQUE constraint; recreate the table \
             to change them."
        )));
    }
    if let Some(cols) = matched_cols.first() {
        let old_schema = schema.clone();
        let mut new_schema = schema;
        new_schema.unique_constraints.retain(|c| c != cols);
        let table_owned = table.to_string();
        rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
            let changes = Changes {
                schema_dirty: true,
                rewritten_tables: vec![table_owned.clone()],
                ..Changes::default()
            };
            finalize_persistence(tx, persistence, &changes)?;
            Ok(())
        })?;
        return Ok(Some(do_describe_table(conn, table)?));
    }

    // 4. Not a known named constraint on this table.
    Err(DbError::Sqlite {
        message: format!("no such constraint: {name} on {table}"),
        kind: SqliteErrorKind::Other,
    })
}

/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (<col>)
/// REFERENCES <P>[(<col>)] [ON …]` (ADR-0035 §4g). Resolves a bare
/// `REFERENCES <P>` to the parent's single-column PK, then delegates to
/// `do_add_relationship` (the same machinery `add 1:n relationship`
/// uses) with `create_fk = false` — the child column must already exist
/// (an `ALTER … ADD FOREIGN KEY` references an existing column).
fn do_alter_add_foreign_key(
    conn: &Connection,
    persistence: Option<&Persistence>,
    child_table: &str,
    name: Option<&str>,
    fk: &SqlForeignKey,
) -> Result<TableDescription, DbError> {
    debug!(child = %child_table, name = ?name, "alter_add_foreign_key");
    reject_internal_table_name(child_table)?;
    reject_internal_table_name(&fk.parent_table)?;
    // Resolve the parent columns: explicit must be the full PK (F-A);
    // a bare `REFERENCES P` auto-expands to the full PK in PK order
    // (F-D), arity-matched to the child list (ADR-0043).
    let parent_pk = read_schema(conn, &fk.parent_table)?.primary_key;
    let parent_columns = resolve_fk_parent_columns(
        &fk.parent_table,
        &parent_pk,
        fk.parent_columns.as_deref(),
        fk.child_columns.len(),
        fk.inline, // false for `ALTER … ADD FOREIGN KEY` (table-level)
    )?;
    // Every child column must already exist for `ALTER … ADD FOREIGN
    // KEY` — there is no SQL spelling to auto-create one (`--create-fk`
    // is the simple-mode `add relationship` surface only). Pre-check
    // here so the refusal speaks SQL, not the DSL flag (ADR-0035
    // Amendment 1, gap C). A missing child *table* is left to
    // `do_add_relationship`'s own "no such table".
    if let Ok(child_schema) = read_schema(conn, child_table) {
        let missing: Vec<&String> = fk
            .child_columns
            .iter()
            .filter(|c| child_schema.columns.iter().all(|col| &col.name != *c))
            .collect();
        if let Some(child) = missing.first() {
            return Err(DbError::Unsupported(format!(
                "column `{child_table}.{child}` does not exist — add it first \
                 (`alter table {child_table} add column {child} <type>`), then \
                 add the foreign key."
            )));
        }
    }
    do_add_relationship(
        conn,
        persistence,
        name,
        &fk.parent_table,
        &parent_columns,
        child_table,
        &fk.child_columns,
        fk.on_delete,
        fk.on_update,
        false,
    )
}

/// Create an index on `table` over `columns` (ADR-0025).
///
/// Refuses a redundant index on an already-indexed column set
/// and a name collision. The index name is auto-generated as
/// `<table>_<col…>_idx` when not supplied.
/// Resolve an index name: the user-given name, or the ADR-0025
/// auto-name `<table>_<col…>_idx`. Shared by `do_add_index` and the
/// `CREATE INDEX IF NOT EXISTS` skip pre-check (ADR-0035 §4d) so both
/// compute the same name.
fn resolve_index_name(name: Option<&str>, table: &str, columns: &[String]) -> String {
    name.map_or_else(
        || format!("{table}_{}_idx", columns.join("_")),
        ToString::to_string,
    )
}

/// Refuse an internal `__rdbms_*` table as "no such table" — the same
/// opacity the rest of the app presents (internal tables are filtered
/// from `list_tables` and never offered in completion). Guards the
/// user-facing schema-mutation executors so a deliberately-typed
/// internal name cannot index or alter the metadata tables (ADR-0035
/// §4d/§4e; the grammar's `reject_internal_table` covers only the typed
/// SQL family, not the simple DSL nodes).
fn reject_internal_table_name(table: &str) -> Result<(), DbError> {
    if table.to_ascii_lowercase().starts_with("__rdbms_") {
        return Err(DbError::Sqlite {
            message: format!("no such table: {table}"),
            kind: SqliteErrorKind::NoSuchTable,
        });
    }
    Ok(())
}

/// Whether an index named `name` exists (ADR-0035 §4d skip checks).
///
/// `user_only = true` counts only explicit `CREATE INDEX` objects
/// (`sql IS NOT NULL`), matching `do_drop_index`'s named lookup — used
/// by the `DROP INDEX IF EXISTS` skip. `user_only = false` counts any
/// index of that name (incl. the automatic PK / UNIQUE-constraint
/// indexes), matching `do_add_index`'s name-collision guard — used by
/// the `CREATE INDEX IF NOT EXISTS` skip.
fn index_exists(conn: &Connection, name: &str, user_only: bool) -> Result<bool, DbError> {
    let sql = if user_only {
        "SELECT COUNT(*) FROM sqlite_master \
         WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;"
    } else {
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1;"
    };
    let count: i64 = conn
        .query_row(sql, [name], |row| row.get(0))
        .map_err(DbError::from_rusqlite)?;
    Ok(count > 0)
}

fn do_add_index(
    conn: &Connection,
    persistence: Option<&Persistence>,
    name: Option<&str>,
    table: &str,
    columns: &[String],
    unique: bool,
) -> Result<TableDescription, DbError> {
    debug!(name = ?name, table = %table, cols = ?columns, unique, "add_index");
    // 0. Canonicalize to the stored case (and refuse a non-existent /
    //    internal `__rdbms_*` table) — both the simple `add index` and SQL
    //    `CREATE INDEX` surfaces reach here, and the auto-index name embeds
    //    the table name, so it must use the stored case.
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    // 1. Table must exist; gather its columns.
    let schema = read_schema(conn, table)?;
    // 2. Every indexed column must exist on the table.
    for col in columns {
        if !schema.columns.iter().any(|c| &c.name == col) {
            return Err(DbError::Sqlite {
                message: format!("no such column: {table}.{col}"),
                kind: SqliteErrorKind::NoSuchColumn,
            });
        }
    }
    // 3. Refuse a redundant index over an identical column set *of the
    //    same kind*. A plain and a unique index over the same columns are
    //    NOT redundant (the unique one enforces a constraint the plain one
    //    does not), so the guard keys on `(columns, unique)` (ADR-0035
    //    §4d). To hold both, the user must name them distinctly — the
    //    auto-name is identical, so the name guard (step 5) would
    //    otherwise collide.
    let existing = read_table_indexes(conn, table)?;
    if let Some(dup) = existing
        .iter()
        .find(|i| i.columns.as_slice() == columns && i.unique == unique)
    {
        return Err(DbError::Unsupported(format!(
            "the columns ({}) of `{table}` are already indexed by `{}`.",
            columns.join(", "),
            dup.name,
        )));
    }
    // 4. Resolve the index name (auto-generate when omitted).
    let resolved = resolve_index_name(name, table, columns);
    // 5. Refuse a name collision.
    let name_taken: i64 = conn
        .query_row(
            "SELECT COUNT(*) FROM sqlite_master \
             WHERE type = 'index' AND name = ?1;",
            [&resolved],
            |row| row.get(0),
        )
        .map_err(DbError::from_rusqlite)?;
    if name_taken > 0 {
        return Err(DbError::Unsupported(format!(
            "an index named `{resolved}` already exists. \
             Pick a different name or drop the existing one first."
        )));
    }
    // 6. Create the index and persist.
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    let cols_csv = columns
        .iter()
        .map(|c| quote_ident(c))
        .collect::<Vec<_>>()
        .join(", ");
    let unique_kw = if unique { "UNIQUE " } else { "" };
    let ddl = format!(
        "CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
        idx = quote_ident(&resolved),
        tbl = quote_ident(table),
        cols = cols_csv,
    );
    debug!(ddl = %ddl, unique, "add_index");
    tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
    let description = do_describe_table(conn, table)?;
    let changes = Changes {
        schema_dirty: true,
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(description)
}

/// Drop an index identified by name or by table + column set
/// (ADR-0025). Returns the affected table's description.
fn do_drop_index(
    conn: &Connection,
    persistence: Option<&Persistence>,
    selector: &IndexSelector,
) -> Result<TableDescription, DbError> {
    debug!(selector = ?selector, "drop_index");
    let (index_name, table_name) = match selector {
        IndexSelector::Named { name } => {
            let lookup = conn.query_row(
                "SELECT tbl_name FROM sqlite_master \
                 WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;",
                [name],
                |row| row.get::<_, String>(0),
            );
            match lookup {
                Ok(table) => (name.clone(), table),
                Err(rusqlite::Error::QueryReturnedNoRows) => {
                    return Err(DbError::Sqlite {
                        message: format!("no such index: {name}"),
                        kind: SqliteErrorKind::Other,
                    });
                }
                Err(e) => return Err(DbError::from_rusqlite(e)),
            }
        }
        IndexSelector::Columns { table, columns } => {
            // Surface a missing table as such, not as "no index".
            read_schema(conn, table)?;
            let matches: Vec<IndexInfo> = read_table_indexes(conn, table)?
                .into_iter()
                .filter(|i| i.columns.as_slice() == columns.as_slice())
                .collect();
            match matches.as_slice() {
                [] => {
                    return Err(DbError::Sqlite {
                        message: format!("no index on {table} ({}) exists", columns.join(", ")),
                        kind: SqliteErrorKind::Other,
                    });
                }
                [one] => (one.name.clone(), table.clone()),
                many => {
                    let names = many
                        .iter()
                        .map(|i| format!("`{}`", i.name))
                        .collect::<Vec<_>>()
                        .join(", ");
                    return Err(DbError::Unsupported(format!(
                        "more than one index on {table} ({}) matches \
                         ({names}); drop it by name instead.",
                        columns.join(", ")
                    )));
                }
            }
        }
    };
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    tx.execute_batch(&format!(
        "DROP INDEX {ident};",
        ident = quote_ident(&index_name)
    ))
    .map_err(DbError::from_rusqlite)?;
    let description = do_describe_table(conn, &table_name)?;
    let changes = Changes {
        schema_dirty: true,
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(description)
}

/// Reads a table's user-facing structure (`show table` / `describe`).
/// ADR-0052: journaling moved to the dispatch layer, so this read does
/// not touch `history.log` — the spawn journals the `Ok` outcome.
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
    debug!(name = %name, "describe_table");
    // Column info — including the ADR-0029 constraints — comes
    // from `read_schema`, the single source of per-column truth
    // (it joins `pragma_table_info` with our type metadata and
    // detects single-column UNIQUE via `pragma_index_list`). A
    // missing table surfaces as `read_schema`'s NoSuchTable.
    let schema = read_schema(conn, name)?;
    let columns: Vec<ColumnDescription> = schema
        .columns
        .iter()
        .map(|c| ColumnDescription {
            name: c.name.clone(),
            user_type: c.user_type,
            sqlite_type: c.sqlite_type.clone(),
            notnull: c.notnull,
            primary_key: c.primary_key,
            unique: c.unique,
            default: c.default_sql.clone(),
            check: c.check.clone(),
        })
        .collect();

    let outbound_relationships = read_relationships_outbound(conn, name)?;
    let inbound_relationships = read_relationships_inbound(conn, name)?;
    let indexes = read_table_indexes(conn, name)?;

    Ok(TableDescription {
        name: name.to_string(),
        columns,
        outbound_relationships,
        inbound_relationships,
        indexes,
        unique_constraints: schema.unique_constraints.clone(),
        check_constraints: schema.check_constraints,
    })
}

// --- Data operations (C5) -----------------------------------

fn impl_value_for(schema: &ReadSchema, column: &str, value: &Value) -> Result<Bound, DbError> {
    let col = schema
        .columns
        .iter()
        .find(|c| c.name == column)
        .ok_or_else(|| DbError::Sqlite {
            message: format!("no such column: {column}"),
            kind: SqliteErrorKind::NoSuchColumn,
        })?;
    let ty = col.user_type.ok_or_else(|| {
        DbError::Unsupported(format!(
            "column `{column}` has no user-type metadata; cannot validate value"
        ))
    })?;
    value
        .bind_for_column(column, ty)
        .map_err(|e: ValueError| DbError::InvalidValue(e.to_string()))
}

fn bound_to_sqlite_value(b: &Bound) -> rusqlite::types::Value {
    use rusqlite::types::Value as V;
    match b {
        Bound::Integer(i) => V::Integer(*i),
        Bound::Real(r) => V::Real(*r),
        Bound::Text(s) => V::Text(s.clone()),
        Bound::Null => V::Null,
    }
}

// =================================================================
// WHERE-expression → parameterised SQL (ADR-0026 §6)
// =================================================================

/// Compile an `Expr` to a parameterised SQL boolean expression.
///
/// Every literal becomes a `?` placeholder pushed onto `params`
/// (1-based, continuing from the current `params.len()` — so
/// the caller can pre-load SET-clause params); identifiers are
/// `quote_ident`-quoted. The raw user text is never spliced
/// into the SQL. Connectives, `NOT`, and parentheses come from
/// the tree structure; the database re-derives precedence from
/// the emitted operators.
fn compile_expr(
    expr: &Expr,
    schema: &ReadSchema,
    params: &mut Vec<rusqlite::types::Value>,
) -> String {
    match expr {
        Expr::Or(terms) => join_expr(terms, "OR", schema, params),
        Expr::And(terms) => join_expr(terms, "AND", schema, params),
        Expr::Not(inner) => {
            format!("(NOT {})", compile_expr(inner, schema, params))
        }
        Expr::Predicate(predicate) => compile_predicate(predicate, schema, params),
    }
}

fn join_expr(
    terms: &[Expr],
    op: &str,
    schema: &ReadSchema,
    params: &mut Vec<rusqlite::types::Value>,
) -> String {
    let parts: Vec<String> = terms
        .iter()
        .map(|t| compile_expr(t, schema, params))
        .collect();
    format!("({})", parts.join(&format!(" {op} ")))
}

fn compile_predicate(
    predicate: &Predicate,
    schema: &ReadSchema,
    params: &mut Vec<rusqlite::types::Value>,
) -> String {
    match predicate {
        Predicate::Compare { left, op, right } => {
            // A literal on one side binds against the column on
            // the other side, where there is one.
            let left_ty = operand_column_type(left, schema);
            let right_ty = operand_column_type(right, schema);
            let lhs = compile_operand(left, right_ty, params);
            let rhs = compile_operand(right, left_ty, params);
            format!("{lhs} {} {rhs}", compare_op_sql(*op))
        }
        Predicate::Like {
            target,
            pattern,
            negated,
        } => {
            let t = compile_operand(target, None, params);
            let p = compile_operand(pattern, None, params);
            let not = if *negated { "NOT " } else { "" };
            format!("{t} {not}LIKE {p}")
        }
        Predicate::Between {
            target,
            low,
            high,
            negated,
        } => {
            let ty = operand_column_type(target, schema);
            let t = compile_operand(target, None, params);
            let lo = compile_operand(low, ty, params);
            let hi = compile_operand(high, ty, params);
            let not = if *negated { "NOT " } else { "" };
            format!("{t} {not}BETWEEN {lo} AND {hi}")
        }
        Predicate::In {
            target,
            items,
            negated,
        } => {
            let ty = operand_column_type(target, schema);
            let t = compile_operand(target, None, params);
            let rendered: Vec<String> = items
                .iter()
                .map(|item| compile_operand(item, ty, params))
                .collect();
            let not = if *negated { "NOT " } else { "" };
            format!("{t} {not}IN ({})", rendered.join(", "))
        }
        Predicate::IsNull { target, negated } => {
            let t = compile_operand(target, None, params);
            let not = if *negated { "NOT " } else { "" };
            format!("{t} IS {not}NULL")
        }
    }
}

/// Render an operand. A column becomes a quoted identifier; a
/// literal becomes a `?` placeholder bound against `against`'s
/// type when one is supplied.
fn compile_operand(
    operand: &Operand,
    against: Option<Type>,
    params: &mut Vec<rusqlite::types::Value>,
) -> String {
    match operand {
        Operand::Column { name, .. } => quote_ident(name),
        Operand::Literal { value, .. } => {
            params.push(bind_where_literal(value, against));
            format!("?{}", params.len())
        }
    }
}

/// The user-facing type of a column operand, if the operand is
/// a column the schema knows. Literals and unknown columns
/// yield `None`.
fn operand_column_type(operand: &Operand, schema: &ReadSchema) -> Option<Type> {
    match operand {
        Operand::Column { name, .. } => schema
            .columns
            .iter()
            .find(|c| c.name.eq_ignore_ascii_case(name))
            .and_then(|c| c.user_type),
        Operand::Literal { .. } => None,
    }
}

/// Bind a WHERE-clause literal. When a target column type is
/// known and the literal converts cleanly, bind through it;
/// otherwise bind by the literal's own syntactic shape — a
/// type-mismatched comparison is flagged in the editor but
/// still runs (ADR-0026 §7).
fn bind_where_literal(value: &Value, against: Option<Type>) -> rusqlite::types::Value {
    if let Some(ty) = against
        && let Ok(bound) = value.bind_for_column("", ty)
    {
        return bound_to_sqlite_value(&bound);
    }
    bound_to_sqlite_value(&syntactic_bound(value))
}

/// Bind a literal by the shape it was written in, ignoring any
/// column type — the permissive fallback for type-mismatched
/// comparisons and the literal-vs-literal case.
fn syntactic_bound(value: &Value) -> Bound {
    match value {
        Value::Null => Bound::Null,
        Value::Bool(b) => Bound::Integer(i64::from(*b)),
        Value::Text(s) => Bound::Text(s.clone()),
        Value::Number(s) => s.parse::<i64>().map_or_else(
            |_| {
                s.parse::<f64>()
                    .map_or_else(|_| Bound::Text(s.clone()), Bound::Real)
            },
            Bound::Integer,
        ),
    }
}

const fn compare_op_sql(op: CompareOp) -> &'static str {
    match op {
        CompareOp::Eq => "=",
        // `<>` is standard SQL for inequality (ADR-0026 §6).
        CompareOp::NotEq => "<>",
        CompareOp::Lt => "<",
        CompareOp::LtEq => "<=",
        CompareOp::Gt => ">",
        CompareOp::GtEq => ">=",
    }
}

/// Execute an INSERT/UPDATE/DELETE and convert any rusqlite
/// failure into a `DbError`. Wraps the raw `conn.execute` so the
/// three callers (insert, update, delete) have a single hook for
/// future row-pinpointing per ADR-0019 §6 — when re-query lands,
/// the runtime-side wiring will pass the table identity into the
/// translator from here.
///
/// Today this is a thin wrapper. The `table` argument is
/// retained as the future re-query hook needs it. (The previous
/// `enrich_fk_message` helper that lived here, listing all the
/// outbound/inbound FKs in the error message, was absorbed into
/// the friendly-error layer's catalog wording per ADR-0019;
/// re-introducing the per-FK detail belongs to the re-query
/// follow-on, not as freeform plain-text appended to engine
/// errors.)
fn execute_with_fk_enrichment(
    conn: &Connection,
    _table: &str,
    sql: &str,
    params: &[rusqlite::types::Value],
) -> Result<usize, DbError> {
    conn.execute(sql, rusqlite::params_from_iter(params.iter()))
        .map_err(DbError::from_rusqlite)
}

/// Fetch a small `DataResult` containing only the rows whose
/// rowids appear in `rowids`. Used so writes can show only the
/// rows they touched rather than the whole table.
fn query_rows_by_rowid(
    conn: &Connection,
    table: &str,
    rowids: &[i64],
) -> Result<DataResult, DbError> {
    let schema = read_schema(conn, table)?;
    let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
    let column_types: Vec<Option<Type>> = schema.columns.iter().map(|c| c.user_type).collect();

    if rowids.is_empty() {
        return Ok(DataResult {
            table_name: table.to_string(),
            columns: column_names,
            column_types,
            rows: Vec::new(),
        });
    }

    let cols_csv = column_names
        .iter()
        .map(|c| quote_ident(c))
        .collect::<Vec<_>>()
        .join(", ");
    let placeholders = (1..=rowids.len())
        .map(|i| format!("?{i}"))
        .collect::<Vec<_>>()
        .join(", ");
    let sql = format!(
        "SELECT {cols} FROM {ident} WHERE rowid IN ({placeholders});",
        cols = cols_csv,
        ident = quote_ident(table),
    );
    let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
    let params: Vec<rusqlite::types::Value> = rowids
        .iter()
        .map(|id| rusqlite::types::Value::Integer(*id))
        .collect();
    let rows_iter = stmt
        .query_map(rusqlite::params_from_iter(params.iter()), |row| {
            let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(column_names.len());
            for i in 0..column_names.len() {
                cells.push(row.get(i)?);
            }
            Ok(cells)
        })
        .map_err(DbError::from_rusqlite)?;
    let mut rows: Vec<Vec<Option<String>>> = Vec::new();
    for r in rows_iter {
        let cells = r.map_err(DbError::from_rusqlite)?;
        let formatted: Vec<Option<String>> = cells
            .into_iter()
            .zip(column_types.iter())
            .map(|(v, ty)| format_cell(v, *ty))
            .collect();
        rows.push(formatted);
    }
    Ok(DataResult {
        table_name: table.to_string(),
        columns: column_names,
        column_types,
        rows,
    })
}

fn count_rows(conn: &Connection, table: &str) -> Result<i64, DbError> {
    let sql = format!("SELECT COUNT(*) FROM {ident};", ident = quote_ident(table));
    conn.query_row(&sql, [], |row| row.get::<_, i64>(0))
        .map_err(DbError::from_rusqlite)
}

/// Default row count when `seed <T>` omits the count (ADR-0048 D6).
const DEFAULT_SEED_COUNT: u64 = 20;

/// Upper bound on a single `seed` (ADR-0048 D6) — a typo like
/// `seed t 1000000` is refused rather than left to hang the app.
const MAX_SEED_COUNT: u64 = 10_000;

/// Cap on rows shown in the post-seed auto-show preview (ADR-0048 D18).
/// The full count is always reported; only the rendered table is capped.
const SEED_PREVIEW_CAP: usize = 20;

/// How a single column's value is produced for each seeded row.
enum SeedColPlan {
    /// Generated from the seed library (the generator is chosen once;
    /// `generate_value` runs per row).
    Generated {
        generator: crate::seed::Generator,
        ty: Type,
    },
    /// A foreign-key child column: sampled from an existing parent row
    /// (ADR-0048 D14). `fk_idx` selects the FK; `pos` selects this
    /// column's slot within the parent key tuple (so a compound FK's
    /// child columns all read from the *same* sampled parent row).
    ForeignKey { fk_idx: usize, pos: usize },
    /// A `shortid` column: a base58 id from seed's *seeded* RNG so it
    /// reproduces under `--seed` (ADR-0048 D4). Always forced — a
    /// `shortid` column needs an id, never a name-heuristic value.
    ShortId,
}

/// Collision key for a positional list of seeded values, used to keep
/// uniqueness groups (PK tuple, UNIQUE columns) distinct (ADR-0048 D10).
/// `\u{1}` separates fields; `\u{0}` marks NULL.
fn seed_value_list_key(values: &[Value]) -> String {
    let mut key = String::new();
    for v in values {
        match v {
            Value::Number(s) | Value::Text(s) => key.push_str(s),
            Value::Bool(b) => key.push(if *b { 'T' } else { 'F' }),
            Value::Null => key.push('\u{0}'),
        }
        key.push('\u{1}');
    }
    key
}

/// `COALESCE(MAX(col), 0)` — the base for sequencing identifier-int
/// columns (ADR-0048 D10) so generated ids continue past existing rows.
fn seed_max_int(conn: &Connection, table: &str, column: &str) -> Result<i64, DbError> {
    let sql = format!(
        "SELECT COALESCE(MAX(\"{}\"), 0) FROM \"{}\"",
        column.replace('"', "\"\""),
        table.replace('"', "\"\"")
    );
    conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
        .map_err(DbError::from_rusqlite)
}

/// Sample existing parent-key tuples for FK generation (ADR-0048 D14).
///
/// Returns one `Value` tuple per distinct parent row in
/// `parent_columns` order, so a compound FK's children can be filled
/// from one consistent parent row. Empty when the parent has no rows
/// (the caller turns that into the friendly "seed the parent first"
/// error).
fn sample_parent_key_tuples(
    conn: &Connection,
    parent_table: &str,
    parent_columns: &[String],
) -> Result<Vec<Vec<Value>>, DbError> {
    let cols = parent_columns
        .iter()
        .map(|c| format!("\"{}\"", c.replace('"', "\"\"")))
        .collect::<Vec<_>>()
        .join(", ");
    // `ORDER BY` the key columns so the sampled order is deterministic
    // (ADR-0048 D4): `--seed` reproducibility must not depend on
    // SQLite's unspecified `DISTINCT` row order.
    let sql = format!(
        "SELECT DISTINCT {cols} FROM \"{}\" ORDER BY {cols}",
        parent_table.replace('"', "\"\"")
    );
    let n = parent_columns.len();
    let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
    let tuples = stmt
        .query_map([], |row| {
            let mut tuple = Vec::with_capacity(n);
            for i in 0..n {
                let v = match row.get_ref(i)? {
                    rusqlite::types::ValueRef::Null => Value::Null,
                    rusqlite::types::ValueRef::Integer(x) => Value::Number(x.to_string()),
                    rusqlite::types::ValueRef::Real(x) => Value::Number(x.to_string()),
                    rusqlite::types::ValueRef::Text(t) => {
                        Value::Text(String::from_utf8_lossy(t).into_owned())
                    }
                    // FK keys are never blobs in this app; treat as NULL.
                    rusqlite::types::ValueRef::Blob(_) => Value::Null,
                };
                tuple.push(v);
            }
            Ok(tuple)
        })
        .map_err(DbError::from_rusqlite)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(DbError::from_rusqlite)?;
    Ok(tuples)
}

/// Populate a table with generated fake data (ADR-0048, SD1/SD2).
///
/// Generates whole rows and inserts them in one transaction, reusing the
/// per-value validation, autogen autofill, FK-error enrichment and
/// persistence machinery via [`insert_one_row`]. The whole seed is a
/// single undo step (the worker wraps the call in one `snapshot_then`)
/// and writes exactly one `history.log` line.
///
/// Foreign-key columns are filled by sampling existing parent rows
/// (D14); a compound FK reads all its child columns from one sampled
/// parent row. An empty parent is refused with a friendly error.
///
/// **Phase 2 (SD2):** when `target_column` is `Some`, this delegates to
/// [`do_seed_column_fill`] (fill one column across existing rows, D1
/// form 2). `overrides` carries the `set <col> …` clause (D2): per-column
/// pins that replace the heuristic generator and drop the column from the
/// generic-fill advisory (D13).
fn do_seed(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    target_column: Option<&str>,
    count: Option<u64>,
    overrides: &[SeedOverride],
    rng_seed: Option<u64>,
) -> Result<SeedResult, DbError> {
    use crate::seed;
    use rand::RngExt;

    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();

    // Column-fill (D1 form 2) is a distinct UPDATE path.
    if let Some(col) = target_column {
        return do_seed_column_fill(conn, persistence, table, col, count, overrides, rng_seed);
    }

    let n = count.unwrap_or(DEFAULT_SEED_COUNT);
    debug!(table = %table, count = n, "seed");
    if n > MAX_SEED_COUNT {
        return Err(DbError::Unsupported(format!(
            "cannot seed {n} rows at once: the maximum is {MAX_SEED_COUNT}. \
             Seed in smaller batches."
        )));
    }

    let schema = read_schema(conn, table)?;

    // Pre-sample each FK's parent key tuples (D14); refuse if a parent
    // is empty (no valid reference can be fabricated).
    let mut fk_samples: Vec<Vec<Vec<Value>>> = Vec::with_capacity(schema.foreign_keys.len());
    for fk in &schema.foreign_keys {
        let tuples = sample_parent_key_tuples(conn, &fk.parent_table, &fk.parent_columns)?;
        if tuples.is_empty() {
            return Err(DbError::Unsupported(format!(
                "cannot seed `{table}`: parent table `{}` (referenced by `{}`) has no rows. \
                 Seed or insert into `{}` first.",
                fk.parent_table,
                fk.child_columns.join(", "),
                fk.parent_table,
            )));
        }
        fk_samples.push(tuples);
    }
    // child column → (fk index, position within the FK's column list).
    let mut fk_child_pos: std::collections::HashMap<&str, (usize, usize)> =
        std::collections::HashMap::new();
    for (fk_idx, fk) in schema.foreign_keys.iter().enumerate() {
        for (pos, child) in fk.child_columns.iter().enumerate() {
            fk_child_pos.insert(child.as_str(), (fk_idx, pos));
        }
    }

    // Build the per-column generation plan, skipping autogen and
    // un-generatable columns. `advisory_columns` collects columns
    // filled with generic text that look like fixed value sets (D12/D13).
    let mut col_names: Vec<String> = Vec::new();
    let mut plans: Vec<SeedColPlan> = Vec::new();
    let mut advisory_columns: Vec<String> = Vec::new();
    for c in &schema.columns {
        let ty = c.user_type.unwrap_or(Type::Text);
        // serial auto-fills deterministically in `do_insert` (rowid /
        // MAX+1) — omit it. shortid is handled below from the seeded RNG.
        if matches!(ty, Type::Serial) {
            continue;
        }
        col_names.push(c.name.clone());
        if let Some(&(fk_idx, pos)) = fk_child_pos.get(c.name.as_str()) {
            plans.push(SeedColPlan::ForeignKey { fk_idx, pos });
        } else if matches!(ty, Type::ShortId) {
            // Always the shortid generator (never a name heuristic — a
            // shortid column needs a base58 id, not e.g. an email).
            plans.push(SeedColPlan::ShortId);
        } else {
            // A simple `col IN ('a','b')` CHECK becomes the value source
            // (D17) so the enum-as-CHECK pattern just works.
            let check_in_values = c
                .check
                .as_deref()
                .and_then(|chk| seed::parse_in_check_values(chk, &c.name));
            let spec = seed::ColumnSpec {
                name: c.name.clone(),
                ty,
                not_null: c.notnull,
                primary_key: c.primary_key,
                unique: c.unique,
                is_foreign_key: false,
                check_in_values,
            };
            let generator = seed::choose_generator(table, &spec);
            // Flag columns that fell through to generic text but look
            // like a fixed value set (enum-ish name, or a CHECK we
            // could not derive values from) — D12/D13.
            if matches!(generator, crate::seed::Generator::Generic)
                && (seed::is_enum_ish(&c.name)
                    || (c.check.is_some() && spec.check_in_values.is_none()))
            {
                advisory_columns.push(c.name.clone());
            }
            plans.push(SeedColPlan::Generated { generator, ty });
        }
    }

    // Apply the `set <col> …` overrides (D2): each replaces the named
    // column's plan with the pinned generator and removes it from the
    // generic-fill advisory (the user chose its values deliberately,
    // D13). An override that names a non-fillable column is a friendly
    // error; a bounded value source (fixed / pick-list) that can't supply
    // enough distinct values for a single-column-UNIQUE target is refused
    // up front rather than silently capped (DA finding). FK / type binding
    // still apply — a value that violates a constraint surfaces through the
    // existing FK-error guard.
    apply_seed_overrides(
        &schema,
        overrides,
        n,
        &col_names,
        &mut plans,
        &mut advisory_columns,
    )?;

    // Uniqueness groups (ADR-0048 D10): value tuples that must stay
    // distinct across the batch and against existing rows — the
    // user-fillable PK (so junction distinct-combos fall out of this),
    // each compound UNIQUE constraint, and each single-column UNIQUE or
    // identifier-named column. Each group is a list of indices into
    // `col_names` / `plans`.
    let col_index: std::collections::HashMap<&str, usize> = col_names
        .iter()
        .enumerate()
        .map(|(i, name)| (name.as_str(), i))
        .collect();
    let project_group = |cols: &[String]| -> Vec<usize> {
        cols.iter()
            .filter_map(|c| col_index.get(c.as_str()).copied())
            .collect()
    };
    let mut unique_groups: Vec<Vec<usize>> = Vec::new();
    let pk_group = project_group(&schema.primary_key);
    if !pk_group.is_empty() {
        unique_groups.push(pk_group);
    }
    for uc in &schema.unique_constraints {
        let g = project_group(uc);
        if !g.is_empty() {
            unique_groups.push(g);
        }
    }
    for (i, name) in col_names.iter().enumerate() {
        let unique_col = schema
            .columns
            .iter()
            .find(|c| &c.name == name)
            .is_some_and(|c| c.unique);
        let is_identifier = matches!(
            &plans[i],
            SeedColPlan::Generated {
                generator: crate::seed::Generator::IdentitySequential,
                ..
            }
        );
        if unique_col || is_identifier {
            unique_groups.push(vec![i]);
        }
    }

    // Sequence base for identifier-int columns (D10): start past the
    // current MAX so generated ids continue cleanly.
    let mut seq_base: std::collections::HashMap<usize, i64> = std::collections::HashMap::new();
    for (i, plan) in plans.iter().enumerate() {
        if let SeedColPlan::Generated { generator, ty } = plan
            && matches!(generator, crate::seed::Generator::IdentitySequential)
            && matches!(ty, Type::Int)
        {
            seq_base.insert(i, seed_max_int(conn, table, &col_names[i])?);
        }
    }

    // Pre-load each group's existing tuples so generation never
    // collides with rows already present.
    let mut used: Vec<std::collections::HashSet<String>> =
        vec![std::collections::HashSet::new(); unique_groups.len()];
    for (gi, group) in unique_groups.iter().enumerate() {
        let cols: Vec<String> = group.iter().map(|&i| col_names[i].clone()).collect();
        for tuple in sample_parent_key_tuples(conn, table, &cols)? {
            used[gi].insert(seed_value_list_key(&tuple));
        }
    }

    // Retry cap per row: when the unique space is exhausted (e.g. a
    // junction requested more rows than there are parent combinations),
    // stop and cap rather than spin (D14).
    const MAX_ATTEMPTS: u32 = 200;

    let mut rng = seed::make_rng(rng_seed);
    let mut preview_rowids: Vec<i64> = Vec::new();
    let mut accepted: u64 = 0;
    let mut capped = false;

    // All rows insert in a single transaction; persistence (the CSV and
    // the one history line) is written once, before the single commit —
    // preserving ADR-0015 §6 commit-db-last while staying O(N) instead
    // of the O(N^2) of per-row CSV rewrites. A mid-batch failure rolls
    // the whole seed back (atomic).
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;

    while accepted < n {
        let mut attempt = 0u32;
        let rowid = loop {
            // One sampled parent row per FK for this attempt, so a
            // compound FK's children stay consistent.
            let fk_choice: Vec<usize> = fk_samples
                .iter()
                .map(|tuples| rng.random_range(0..tuples.len()))
                .collect();
            let values: Vec<Value> = plans
                .iter()
                .enumerate()
                .map(|(i, plan)| match plan {
                    SeedColPlan::ForeignKey { fk_idx, pos } => {
                        fk_samples[*fk_idx][fk_choice[*fk_idx]][*pos].clone()
                    }
                    // Seeded base58 id → reproducible under `--seed` (D4).
                    SeedColPlan::ShortId => {
                        Value::Text(crate::dsl::shortid::generate_with_rng(&mut rng))
                    }
                    SeedColPlan::Generated { generator, ty }
                        if matches!(generator, crate::seed::Generator::IdentitySequential)
                            && matches!(ty, Type::Int) =>
                    {
                        // Monotonic past existing rows → inherently unique.
                        Value::Number((seq_base[&i] + accepted as i64 + 1).to_string())
                    }
                    SeedColPlan::Generated { generator, ty } => {
                        seed::generate_value(generator, *ty, &mut rng)
                    }
                })
                .collect();

            let keys: Vec<String> = unique_groups
                .iter()
                .map(|group| {
                    let projected: Vec<Value> = group.iter().map(|&i| values[i].clone()).collect();
                    seed_value_list_key(&projected)
                })
                .collect();
            if keys.iter().enumerate().any(|(gi, k)| used[gi].contains(k)) {
                attempt += 1;
                if attempt >= MAX_ATTEMPTS {
                    capped = true;
                    break None;
                }
                continue;
            }
            for (gi, k) in keys.into_iter().enumerate() {
                used[gi].insert(k);
            }
            let (_rows, rowid) = insert_one_row(conn, table, &schema, Some(&col_names), &values)?;
            break Some(rowid);
        };
        match rowid {
            Some(rowid) => {
                // Keep the first `SEED_PREVIEW_CAP` rowids for the
                // capped auto-show (D18).
                if preview_rowids.len() < SEED_PREVIEW_CAP {
                    preview_rowids.push(rowid);
                }
                accepted += 1;
            }
            None => break,
        }
    }

    if capped {
        warn!(
            table = %table,
            requested = n,
            produced = accepted,
            "seed capped: ran out of distinct unique-value combinations before the requested count"
        );
    }

    // Persist once (CSV + the single history line), then commit (db-last).
    let changes = Changes {
        schema_dirty: false,
        rewritten_tables: vec![table.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;

    let data = if preview_rowids.is_empty() {
        DataResult {
            table_name: table.to_string(),
            columns: Vec::new(),
            column_types: Vec::new(),
            rows: Vec::new(),
        }
    } else {
        query_rows_by_rowid(conn, table, &preview_rowids)?
    };

    Ok(SeedResult {
        table: table.to_string(),
        requested: n,
        produced: accepted,
        data,
        advisory_columns,
    })
}

/// Apply the `set <col> …` overrides (ADR-0048 D2) to the per-column
/// generation plan. Each override replaces the named column's plan and
/// drops it from the generic-fill advisory (D13 — the user chose those
/// values). An override naming a column that is not in the fillable set
/// (unknown, or an auto-generated `serial`) is a friendly error.
fn apply_seed_overrides(
    schema: &ReadSchema,
    overrides: &[SeedOverride],
    row_count: u64,
    col_names: &[String],
    plans: &mut [SeedColPlan],
    advisory_columns: &mut Vec<String>,
) -> Result<(), DbError> {
    for ov in overrides {
        let Some(idx) = col_names
            .iter()
            .position(|c| c.eq_ignore_ascii_case(&ov.column))
        else {
            return Err(DbError::Unsupported(format!(
                "cannot apply `set {col} …`: `{col}` is not a fillable column of this \
                 table (it is unknown, or an auto-generated column).",
                col = ov.column,
            )));
        };
        let ty = schema
            .columns
            .iter()
            .find(|c| c.name.eq_ignore_ascii_case(&ov.column))
            .and_then(|c| c.user_type)
            .unwrap_or(Type::Text);
        seed_override_capacity_guard(schema, &ov.column, &ov.kind, row_count)?;
        plans[idx] = seed_override_plan(&ov.kind, ty, &ov.column)?;
        advisory_columns.retain(|c| !c.eq_ignore_ascii_case(&ov.column));
    }
    Ok(())
}

/// Refuse up front when a **bounded** override (a fixed value or a
/// pick-list) cannot supply enough *distinct* values to fill a
/// single-column-UNIQUE target across `row_count` rows — otherwise the
/// uniqueness machinery would silently cap the run to the achievable
/// count (DA finding; the ADR left this interaction open and the user
/// chose a friendly error). Generators and ranges are treated as
/// effectively unbounded sources here; if one does exhaust, the existing
/// distinct-combination cap (D14) still applies.
fn seed_override_capacity_guard(
    schema: &ReadSchema,
    column: &str,
    kind: &SeedOverrideKind,
    row_count: u64,
) -> Result<(), DbError> {
    let distinct = match kind {
        SeedOverrideKind::Fixed(_) => 1,
        SeedOverrideKind::PickList(values) => {
            let mut set = std::collections::HashSet::new();
            for v in values {
                set.insert(seed_override_literal(v, column)?);
            }
            set.len()
        }
        // Unbounded-enough sources — leave to the cap if they exhaust.
        SeedOverrideKind::Generator(_) | SeedOverrideKind::Range { .. } => return Ok(()),
    };
    if distinct as u64 >= row_count.max(1) {
        return Ok(());
    }
    // Single-column uniqueness only: a compound UNIQUE / compound PK can
    // still be satisfied by varying the *other* columns, so a pinned
    // value there does not force a cap.
    let single_unique = schema
        .columns
        .iter()
        .find(|c| c.name.eq_ignore_ascii_case(column))
        .is_some_and(|c| c.unique)
        || (schema.primary_key.len() == 1 && schema.primary_key[0].eq_ignore_ascii_case(column));
    if single_unique {
        return Err(DbError::Unsupported(format!(
            "cannot fill {row_count} rows: `set {column} …` offers only {distinct} distinct \
             value(s), but `{column}` is UNIQUE. Use a generator (e.g. `as email`) or a list \
             of at least {row_count} values."
        )));
    }
    Ok(())
}

/// Turn one `set` override into the `SeedColPlan` that produces its
/// values (ADR-0048 D2). `Fixed`/`PickList` become a `PickFrom` over the
/// literal(s); `Generator` resolves the curated name (unknown → friendly
/// error); `Range` validates its bounds against the column type *before*
/// generation (an incompatible bound → friendly error).
fn seed_override_plan(
    kind: &SeedOverrideKind,
    ty: Type,
    column: &str,
) -> Result<SeedColPlan, DbError> {
    use crate::seed::Generator;
    let generator = match kind {
        SeedOverrideKind::Fixed(v) => Generator::PickFrom(vec![seed_override_literal(v, column)?]),
        SeedOverrideKind::PickList(vs) => {
            let lits = vs
                .iter()
                .map(|v| seed_override_literal(v, column))
                .collect::<Result<Vec<_>, _>>()?;
            Generator::PickFrom(lits)
        }
        SeedOverrideKind::Generator(name) => {
            crate::seed::generator_for_name(name).ok_or_else(|| {
                DbError::Unsupported(format!(
                    "unknown generator `{name}` in `set {column} as {name}`. \
                     Known generators: {}.",
                    crate::seed::KNOWN_GENERATORS.join(", "),
                ))
            })?
        }
        SeedOverrideKind::Range { low, high } => {
            let lo = seed_override_literal(low, column)?;
            let hi = seed_override_literal(high, column)?;
            if let Some(reason) = crate::seed::range_bounds_reason(ty, &lo, &hi) {
                return Err(DbError::Unsupported(format!(
                    "cannot apply `set {column} between …`: {reason}."
                )));
            }
            Generator::Range { low: lo, high: hi }
        }
    };
    Ok(SeedColPlan::Generated { generator, ty })
}

/// Extract the literal string an override value contributes to a
/// `PickFrom` / `Range` (re-typed per column by `generate_value`). A
/// `null` override is refused — seed always fills a value (NULL
/// injection is out of scope, ADR-0048 Out-of-scope).
fn seed_override_literal(value: &Value, column: &str) -> Result<String, DbError> {
    match value {
        Value::Number(s) | Value::Text(s) => Ok(s.clone()),
        Value::Bool(b) => Ok(if *b { "true" } else { "false" }.to_string()),
        Value::Null => Err(DbError::Unsupported(format!(
            "`set {column} = null` is not supported — seed always fills a value."
        ))),
    }
}

/// Column-fill (ADR-0048 D1 form 2): fill one column across the table's
/// **existing** rows (an UPDATE), the natural follow-up to `add column`.
///
/// Refuses PK and auto-generated (`serial`/`shortid`) targets;
/// an empty table is a friendly no-op. The `set` clause may only adjust
/// the column being filled (the rest of the per-column heuristics do not
/// apply — there is exactly one column). A UNIQUE / identifier target
/// gets collision-free values (generated distinct from *every* existing
/// value in the column, so no row-by-row UPDATE can transiently collide);
/// an FK target samples an existing parent key (D14). The whole fill is
/// one transaction → one undo step (D15), persisted once (commit-db-last).
#[allow(clippy::too_many_arguments)]
fn do_seed_column_fill(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    column: &str,
    count: Option<u64>,
    overrides: &[SeedOverride],
    rng_seed: Option<u64>,
) -> Result<SeedResult, DbError> {
    use crate::seed;
    use rand::RngExt;

    debug!(table = %table, column = %column, "seed column-fill");

    // A row count is meaningless when filling existing rows (D1 form 2).
    if count.is_some() {
        return Err(DbError::Unsupported(format!(
            "`seed {table}.{column}` fills existing rows, so it takes no row count \
             (drop the number)."
        )));
    }

    let schema = read_schema(conn, table)?;
    let col = schema
        .columns
        .iter()
        .find(|c| c.name.eq_ignore_ascii_case(column))
        .ok_or_else(|| {
            DbError::Unsupported(format!("cannot fill `{table}.{column}`: no such column."))
        })?;
    let canonical_col = col.name.clone();
    let ty = col.user_type.unwrap_or(Type::Text);

    // Refuse identity / auto-generated / un-generatable targets (D1).
    if col.primary_key {
        return Err(DbError::Unsupported(format!(
            "cannot fill `{table}.{canonical_col}`: it is part of the primary key — \
             you don't fill an identity column."
        )));
    }
    if matches!(ty, Type::Serial | Type::ShortId) {
        return Err(DbError::Unsupported(format!(
            "cannot fill `{table}.{canonical_col}`: `{}` columns generate their own \
             values automatically.",
            ty.keyword(),
        )));
    }
    // The `set` clause may only adjust the filled column (user decision).
    for ov in overrides {
        if !ov.column.eq_ignore_ascii_case(&canonical_col) {
            return Err(DbError::Unsupported(format!(
                "in `seed {table}.{canonical_col}`, `set` can only adjust \
                 `{canonical_col}` (the column being filled), not `{}`.",
                ov.column,
            )));
        }
    }

    // Existing rowids in a deterministic order (D4 reproducibility).
    let rowids: Vec<i64> = {
        let sql = format!(
            "SELECT rowid FROM \"{}\" ORDER BY rowid",
            table.replace('"', "\"\"")
        );
        let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
        stmt.query_map([], |r| r.get::<_, i64>(0))
            .map_err(DbError::from_rusqlite)?
            .collect::<Result<Vec<_>, _>>()
            .map_err(DbError::from_rusqlite)?
    };

    // Empty table → friendly no-op (D1).
    if rowids.is_empty() {
        return Ok(SeedResult {
            table: table.to_string(),
            requested: 0,
            produced: 0,
            data: DataResult {
                table_name: table.to_string(),
                columns: Vec::new(),
                column_types: Vec::new(),
                rows: Vec::new(),
            },
            advisory_columns: Vec::new(),
        });
    }

    // FK target → sample an existing parent key column (D14).
    let fk_sample: Option<Vec<Value>> = {
        let fk = schema.foreign_keys.iter().find(|fk| {
            fk.child_columns
                .iter()
                .any(|c| c.eq_ignore_ascii_case(&canonical_col))
        });
        match fk {
            Some(fk) => {
                // Single-column position within the FK (column-fill targets
                // one column; a compound FK filled one column at a time is
                // unusual but we sample that column's parent values).
                let pos = fk
                    .child_columns
                    .iter()
                    .position(|c| c.eq_ignore_ascii_case(&canonical_col))
                    .unwrap_or(0);
                let parent_col = fk.parent_columns.get(pos).cloned().unwrap_or_default();
                let tuples = sample_parent_key_tuples(conn, &fk.parent_table, &[parent_col])?;
                if tuples.is_empty() {
                    return Err(DbError::Unsupported(format!(
                        "cannot fill `{table}.{canonical_col}`: parent table `{}` has no \
                         rows to reference. Seed or insert into `{}` first.",
                        fk.parent_table, fk.parent_table,
                    )));
                }
                Some(tuples.into_iter().map(|mut t| t.remove(0)).collect())
            }
            None => None,
        }
    };

    // The value source: an override (if present) else the heuristic.
    let mut advisory_columns: Vec<String> = Vec::new();
    let plan: SeedColPlan = if let Some(ov) = overrides
        .iter()
        .find(|o| o.column.eq_ignore_ascii_case(&canonical_col))
    {
        // Same capacity guard as whole-row: a bounded override that can't
        // give enough distinct values for a UNIQUE column across the
        // existing rows is refused up front, not silently capped.
        seed_override_capacity_guard(&schema, &canonical_col, &ov.kind, rowids.len() as u64)?;
        seed_override_plan(&ov.kind, ty, &canonical_col)?
    } else if fk_sample.is_some() {
        SeedColPlan::ForeignKey { fk_idx: 0, pos: 0 }
    } else if matches!(ty, Type::ShortId) {
        SeedColPlan::ShortId // unreachable (refused above), kept for totality
    } else {
        let check_in_values = col
            .check
            .as_deref()
            .and_then(|chk| seed::parse_in_check_values(chk, &canonical_col));
        let spec = seed::ColumnSpec {
            name: canonical_col.clone(),
            ty,
            not_null: col.notnull,
            primary_key: col.primary_key,
            unique: col.unique,
            is_foreign_key: false,
            check_in_values,
        };
        let generator = seed::choose_generator(table, &spec);
        if matches!(generator, crate::seed::Generator::Generic)
            && (seed::is_enum_ish(&canonical_col)
                || (col.check.is_some() && spec.check_in_values.is_none()))
        {
            advisory_columns.push(canonical_col.clone());
        }
        SeedColPlan::Generated { generator, ty }
    };

    // Collision-free generation for UNIQUE / identifier targets: seed the
    // used-set with EVERY existing value of the column so a generated
    // value never matches a not-yet-updated row (no transient UNIQUE
    // violation) nor a value already assigned this batch (ADR-0048 D10).
    let enforce_unique = col.unique
        || matches!(
            &plan,
            SeedColPlan::Generated {
                generator: crate::seed::Generator::IdentitySequential,
                ..
            }
        );
    let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
    if enforce_unique {
        for tuple in sample_parent_key_tuples(conn, table, std::slice::from_ref(&canonical_col))? {
            used.insert(seed_value_list_key(&tuple));
        }
    }
    let seq_base = if matches!(
        &plan,
        SeedColPlan::Generated {
            generator: crate::seed::Generator::IdentitySequential,
            ..
        }
    ) && matches!(ty, Type::Int)
    {
        Some(seed_max_int(conn, table, &canonical_col)?)
    } else {
        None
    };

    const MAX_ATTEMPTS: u32 = 200;
    let mut rng = seed::make_rng(rng_seed);
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;

    let update_sql = format!(
        "UPDATE \"{}\" SET \"{}\" = ?1 WHERE rowid = ?2",
        table.replace('"', "\"\""),
        canonical_col.replace('"', "\"\""),
    );
    let mut produced: u64 = 0;
    for (offset, rowid) in rowids.iter().enumerate() {
        let mut attempt = 0u32;
        let value = loop {
            let v = match &plan {
                SeedColPlan::ForeignKey { .. } => {
                    let samples = fk_sample.as_ref().expect("fk plan implies samples");
                    samples[rng.random_range(0..samples.len())].clone()
                }
                SeedColPlan::ShortId => {
                    Value::Text(crate::dsl::shortid::generate_with_rng(&mut rng))
                }
                SeedColPlan::Generated { generator, ty }
                    if matches!(generator, crate::seed::Generator::IdentitySequential)
                        && matches!(ty, Type::Int) =>
                {
                    Value::Number((seq_base.unwrap_or(0) + produced as i64 + 1).to_string())
                }
                SeedColPlan::Generated { generator, ty } => {
                    seed::generate_value(generator, *ty, &mut rng)
                }
            };
            if enforce_unique {
                let key = seed_value_list_key(std::slice::from_ref(&v));
                if used.contains(&key) {
                    attempt += 1;
                    if attempt >= MAX_ATTEMPTS {
                        break v; // give up on distinctness; DB may reject
                    }
                    continue;
                }
                used.insert(key);
            }
            break v;
        };
        let bound = impl_value_for(&schema, &canonical_col, &value)?;
        let params: Vec<rusqlite::types::Value> = vec![
            bound_to_sqlite_value(&bound),
            rusqlite::types::Value::Integer(*rowid),
        ];
        execute_with_fk_enrichment(conn, table, &update_sql, &params)?;
        produced += 1;
        let _ = offset;
    }

    let changes = Changes {
        schema_dirty: false,
        rewritten_tables: vec![table.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;

    // Preview the first capped rows (D18).
    let preview: Vec<i64> = rowids.iter().take(SEED_PREVIEW_CAP).copied().collect();
    let data = query_rows_by_rowid(conn, table, &preview)?;

    Ok(SeedResult {
        table: table.to_string(),
        requested: produced,
        produced,
        data,
        advisory_columns,
    })
}

/// Build and execute a single-row `INSERT` — column resolution, value
/// binding, `serial`/`shortid` autofill, and the FK-enriched execute —
/// returning `(rows_affected, new rowid)`.
///
/// It does **not** manage the transaction or persistence: the caller
/// owns those. This lets `do_insert` run one row in its own
/// transaction while `do_seed` runs N rows in a single transaction and
/// persists once (preserving ADR-0015 §6 commit-db-last while staying
/// O(N)). **The caller must hold an open transaction.** `table` must
/// already be canonical and `schema` already read.
fn insert_one_row(
    conn: &Connection,
    table: &str,
    schema: &ReadSchema,
    user_columns: Option<&[String]>,
    user_values: &[Value],
) -> Result<(usize, i64), DbError> {
    // Resolve which columns the user is providing values for. The short
    // form (None) is every non-auto-generated column in schema
    // declaration order; serial and shortid get auto-filled below.
    let user_cols: Vec<String> = user_columns.map_or_else(
        || {
            schema
                .columns
                .iter()
                .filter(|c| !matches!(c.user_type, Some(Type::Serial) | Some(Type::ShortId)))
                .map(|c| c.name.clone())
                .collect()
        },
        <[String]>::to_vec,
    );

    if user_cols.len() != user_values.len() {
        return Err(DbError::InvalidValue(format!(
            "expected {} value(s) for ({}), got {}",
            user_cols.len(),
            user_cols.join(", "),
            user_values.len()
        )));
    }

    let mut bindings: Vec<(String, Bound)> = Vec::with_capacity(user_cols.len());
    for (col_name, value) in user_cols.iter().zip(user_values.iter()) {
        let bound = impl_value_for(schema, col_name, value)?;
        bindings.push((col_name.clone(), bound));
    }

    // Auto-fill any shortid columns the user didn't list.
    let provided: std::collections::HashSet<String> =
        bindings.iter().map(|(c, _)| c.clone()).collect();
    for c in &schema.columns {
        if c.user_type == Some(Type::ShortId) && !provided.contains(&c.name) {
            bindings.push((c.name.clone(), Bound::Text(shortid::generate())));
        }
    }

    // Auto-fill any non-PK serial columns the user didn't list
    // (ADR-0018 §5). PK serial columns rely on SQLite's rowid
    // alias (omitting the column from the INSERT yields the
    // next rowid for free); non-PK serial needs explicit
    // MAX(col)+1 application-side because there's no engine-
    // level auto-increment for non-PK columns. The worker-
    // thread serialisation (ADR-0010) makes this read-then-
    // write sequence safe without explicit locking.
    for c in &schema.columns {
        if c.user_type == Some(Type::Serial) && !c.primary_key && !provided.contains(&c.name) {
            let next: i64 = conn
                .query_row(
                    &format!(
                        "SELECT COALESCE(MAX({col}), 0) + 1 FROM {tbl};",
                        col = quote_ident(&c.name),
                        tbl = quote_ident(table),
                    ),
                    [],
                    |row| row.get(0),
                )
                .map_err(DbError::from_rusqlite)?;
            bindings.push((c.name.clone(), Bound::Integer(next)));
        }
    }

    if bindings.is_empty() {
        return Err(DbError::InvalidValue(
            "INSERT requires at least one column value".to_string(),
        ));
    }

    debug!(
        table = %table,
        user_cols = user_cols.len(),
        total_cols = bindings.len(),
        autofilled = bindings.len() - user_cols.len(),
        "insert: column bindings resolved (serial/shortid auto-fill applied)"
    );

    let cols_csv = bindings
        .iter()
        .map(|(c, _)| quote_ident(c))
        .collect::<Vec<_>>()
        .join(", ");
    let placeholders = (1..=bindings.len())
        .map(|i| format!("?{i}"))
        .collect::<Vec<_>>()
        .join(", ");
    let sql = format!(
        "INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});",
        ident = quote_ident(table),
    );
    debug!(sql = %sql, "insert");
    let params: Vec<rusqlite::types::Value> = bindings
        .iter()
        .map(|(_, b)| bound_to_sqlite_value(b))
        .collect();
    let rows_affected = execute_with_fk_enrichment(conn, table, &sql, &params)?;
    let new_rowid = conn.last_insert_rowid();
    Ok((rows_affected, new_rowid))
}

fn do_insert(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    user_columns: Option<&[String]>,
    user_values: &[Value],
) -> Result<InsertResult, DbError> {
    debug!(table = %table, "insert");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let schema = read_schema(conn, table)?;
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    let (rows_affected, new_rowid) =
        insert_one_row(conn, table, &schema, user_columns, user_values)?;
    let data = query_rows_by_rowid(conn, table, &[new_rowid])?;
    let changes = Changes {
        schema_dirty: false,
        rewritten_tables: vec![table.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(InsertResult {
        rows_affected,
        data,
    })
}

/// Build the parameterised `UPDATE … SET … WHERE …` statement.
/// Separated from `do_update` so the `explain` path runs
/// `EXPLAIN QUERY PLAN` against the exact same statement
/// (ADR-0028 §2). `compile_expr` continues the `?N` numbering
/// from the SET-clause params already pushed.
fn build_update_sql(
    schema: &ReadSchema,
    table: &str,
    assignments: &[(String, Value)],
    filter: &RowFilter,
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
    let mut params: Vec<rusqlite::types::Value> = Vec::new();
    let mut set_clauses: Vec<String> = Vec::with_capacity(assignments.len());
    for (col, value) in assignments {
        let bound = impl_value_for(schema, col, value)?;
        set_clauses.push(format!(
            "{col_id} = ?{n}",
            col_id = quote_ident(col),
            n = params.len() + 1
        ));
        params.push(bound_to_sqlite_value(&bound));
    }
    let where_sql = match filter {
        RowFilter::AllRows => String::new(),
        RowFilter::Where(expr) => {
            format!(" WHERE {}", compile_expr(expr, schema, &mut params))
        }
    };
    let sql = format!(
        "UPDATE {ident} SET {sets}{where_sql};",
        ident = quote_ident(table),
        sets = set_clauses.join(", "),
    );
    Ok((sql, params))
}

fn do_update(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    assignments: &[(String, Value)],
    filter: &RowFilter,
) -> Result<UpdateResult, DbError> {
    debug!(table = %table, assignments = assignments.len(), "update");
    if assignments.is_empty() {
        return Err(DbError::InvalidValue(
            "UPDATE requires at least one assignment".to_string(),
        ));
    }
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let schema = read_schema(conn, table)?;

    // Capture rowids of matching rows up front so we can fetch
    // the updated rows even if the UPDATE changed the WHERE column.
    let rowids = match filter {
        RowFilter::AllRows => select_all_rowids(conn, table)?,
        RowFilter::Where(expr) => {
            let mut where_params: Vec<rusqlite::types::Value> = Vec::new();
            let clause = compile_expr(expr, &schema, &mut where_params);
            let mut stmt = conn
                .prepare(&format!(
                    "SELECT rowid FROM {ident} WHERE {clause};",
                    ident = quote_ident(table),
                ))
                .map_err(DbError::from_rusqlite)?;
            let rows = stmt
                .query_map(rusqlite::params_from_iter(where_params.iter()), |row| {
                    row.get::<_, i64>(0)
                })
                .map_err(DbError::from_rusqlite)?;
            let mut ids = Vec::new();
            for r in rows {
                ids.push(r.map_err(DbError::from_rusqlite)?);
            }
            ids
        }
    };

    let (sql, params) = build_update_sql(&schema, table, assignments, filter)?;
    debug!(sql = %sql, "update");
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    let rows_affected = execute_with_fk_enrichment(conn, table, &sql, &params)?;
    let data = query_rows_by_rowid(conn, table, &rowids)?;
    let changes = Changes {
        schema_dirty: false,
        rewritten_tables: vec![table.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(UpdateResult {
        rows_affected,
        data,
    })
}

fn select_all_rowids(conn: &Connection, table: &str) -> Result<Vec<i64>, DbError> {
    let mut stmt = conn
        .prepare(&format!(
            "SELECT rowid FROM {ident};",
            ident = quote_ident(table)
        ))
        .map_err(DbError::from_rusqlite)?;
    let rows = stmt
        .query_map([], |row| row.get::<_, i64>(0))
        .map_err(DbError::from_rusqlite)?;
    let mut ids = Vec::new();
    for r in rows {
        ids.push(r.map_err(DbError::from_rusqlite)?);
    }
    Ok(ids)
}

/// Build the parameterised `DELETE FROM … WHERE …` statement.
/// Separated from `do_delete` so the `explain` path runs
/// `EXPLAIN QUERY PLAN` against the exact same statement
/// (ADR-0028 §2).
fn build_delete_sql(
    schema: &ReadSchema,
    table: &str,
    filter: &RowFilter,
) -> (String, Vec<rusqlite::types::Value>) {
    let mut params: Vec<rusqlite::types::Value> = Vec::new();
    let where_sql = match filter {
        RowFilter::AllRows => String::new(),
        RowFilter::Where(expr) => {
            format!(" WHERE {}", compile_expr(expr, schema, &mut params))
        }
    };
    let sql = format!(
        "DELETE FROM {ident}{where_sql};",
        ident = quote_ident(table)
    );
    (sql, params)
}

fn do_delete(
    conn: &Connection,
    persistence: Option<&Persistence>,
    table: &str,
    filter: &RowFilter,
) -> Result<DeleteResult, DbError> {
    debug!(table = %table, "delete");
    let canonical_table = require_canonical_table(conn, table)?;
    let table = canonical_table.as_str();
    let schema = read_schema(conn, table)?;

    // Snapshot child-table row counts before the delete so we
    // can detect cascade effects via diffing afterwards. ON
    // UPDATE CASCADE does not change row counts and so is not
    // detected here — it would need value-level diffing, which
    // a future iteration can add.
    let inbound = read_relationships_inbound(conn, table)?;
    let mut before_counts: Vec<(String, i64)> = Vec::with_capacity(inbound.len());
    for r in &inbound {
        before_counts.push((r.other_table.clone(), count_rows(conn, &r.other_table)?));
    }

    let (sql, params) = build_delete_sql(&schema, table, filter);
    debug!(sql = %sql, "delete");
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    let rows_affected = execute_with_fk_enrichment(conn, table, &sql, &params)?;

    // Compare child-table counts after the delete; non-zero
    // diffs are cascade effects. We also collect cascaded
    // tables so the persistence phase rewrites their CSVs too.
    let mut cascade: Vec<CascadeEffect> = Vec::new();
    let mut rewritten_tables: Vec<String> = vec![table.to_string()];
    for (rel, (_child_table, before_count)) in inbound.iter().zip(before_counts.iter()) {
        let after_count = count_rows(conn, &rel.other_table)?;
        let mut rows_changed = before_count - after_count;
        // A self-referential FK (child == target): the before/after
        // diff also covers the directly-deleted rows, which are
        // already reported in `rows_affected` and are not cascade
        // effects. Subtract them so the summary reports only the rows
        // removed *via* the self-reference. Shared with `do_sql_delete`.
        let self_referential = rel.other_table == table;
        if self_referential {
            rows_changed -= rows_affected as i64;
        }
        if rows_changed > 0 {
            cascade.push(CascadeEffect {
                relationship_name: rel.name.clone(),
                child_table: rel.other_table.clone(),
                rows_changed,
                action: rel.on_delete,
            });
            // The target's CSV is already queued; only add a distinct
            // child table (a self-ref child is the target itself).
            if !self_referential {
                rewritten_tables.push(rel.other_table.clone());
            }
        }
    }

    debug!(
        table = %table,
        rows_affected,
        cascaded_relationships = cascade.len(),
        rewritten_tables = rewritten_tables.len(),
        "delete: complete (cascade effects detected by child-count diff)"
    );

    let changes = Changes {
        schema_dirty: false,
        rewritten_tables,
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;

    Ok(DeleteResult {
        rows_affected,
        cascade,
        // The DSL `delete` has no RETURNING (a SQL-only clause); the
        // empty result is skipped by the renderer (ADR-0033 §5, 3g).
        data: DataResult {
            table_name: table.to_string(),
            columns: Vec::new(),
            column_types: Vec::new(),
            rows: Vec::new(),
        },
    })
}

/// Currently-stored non-NULL values of one column, for shortid
/// collision-avoidance (passed to `generate_shortid_batch`).
fn existing_shortids(conn: &Connection, table: &str, column: &str) -> Result<Vec<String>, DbError> {
    let mut stmt = conn
        .prepare(&format!(
            "SELECT {col} FROM {tbl} WHERE {col} IS NOT NULL;",
            col = quote_ident(column),
            tbl = quote_ident(table),
        ))
        .map_err(DbError::from_rusqlite)?;
    let rows = stmt
        .query_map([], |r| r.get::<_, String>(0))
        .map_err(DbError::from_rusqlite)?;
    let mut out = Vec::new();
    for r in rows {
        out.push(r.map_err(DbError::from_rusqlite)?);
    }
    Ok(out)
}

/// Plan `shortid` auto-fill for a SQL `INSERT` (ADR-0033 §6,
/// sub-phase 3d).
///
/// Returns the SQL the worker should execute plus its bound
/// params. When the user's `(column_list)` omits one or more
/// `shortid` columns, this materialises the row source (Option B:
/// run it as a query), synthesises a fresh distinct id per row via
/// `generate_shortid_batch`, and reconstructs a parameterised
/// multi-row `INSERT` over the listed columns plus the omitted
/// shortid columns. Otherwise it returns the original `sql`
/// verbatim with no params (the 3b path):
///
/// - no explicit column list → the row source supplies every
///   column positionally (a listed shortid is the user's value);
/// - no omitted shortid column → nothing to fill;
/// - the row source yields zero rows → nothing to fill (the
///   verbatim INSERT inserts nothing without a NOT-NULL violation).
///
/// `serial` columns are not handled here — an omitted `serial`
/// primary key is filled by the engine's rowid (ADR-0033 §6).
/// Auto-fill the omitted **auto-generated** columns of an advanced-mode
/// Form-A `INSERT` (a `(col, …)` list is present) by reconstructing a
/// parameterised statement, honouring ADR-0018 §1's "auto-generated on
/// every path" contract — the same contract simple-mode `do_insert`
/// honours:
///   - **`shortid`** columns omitted from the list get a fresh distinct
///     id per row (ADR-0018 §3).
///   - **non-PK `serial`** columns omitted from the list get `MAX(col)+1`,
///     `MAX+2`, … per row (ADR-0018 §5; the X4 fix — advanced mode used
///     to leave them NULL). PK serial relies on the engine's rowid alias
///     and is never in this set.
///
/// Returns the original `sql` + an empty param vec when there is nothing
/// to fill (Form B / no omitted auto-gen column / zero rows), so the
/// caller executes the verbatim statement unchanged.
fn plan_autogen_autofill(
    conn: &Connection,
    target_table: &str,
    sql: &str,
    listed_columns: &[String],
    row_source: &str,
    trailing_tail: &str,
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
    if listed_columns.is_empty() {
        return Ok((sql.to_string(), Vec::new()));
    }
    let schema = read_schema(conn, target_table)?;
    // Identifiers are case-preserving but matched case-insensitively
    // (ADR-0009): a column counts as omitted unless the user listed a
    // name equal to it ignoring ASCII case.
    let listed_ci: Vec<String> = listed_columns
        .iter()
        .map(|c| c.to_ascii_lowercase())
        .collect();
    let is_omitted = |c: &&ReadColumn| !listed_ci.contains(&c.name.to_ascii_lowercase());
    let omitted_shortids: Vec<String> = schema
        .columns
        .iter()
        .filter(|c| c.user_type == Some(Type::ShortId))
        .filter(is_omitted)
        .map(|c| c.name.clone())
        .collect();
    // ADR-0018 §5 + X4: a non-PK serial omitted from the list is
    // auto-filled MAX+1 per row (PK serial uses the rowid alias and is
    // excluded). Mirrors simple-mode `do_insert`.
    let omitted_serials: Vec<String> = schema
        .columns
        .iter()
        .filter(|c| c.user_type == Some(Type::Serial) && !c.primary_key)
        .filter(is_omitted)
        .map(|c| c.name.clone())
        .collect();
    if omitted_shortids.is_empty() && omitted_serials.is_empty() {
        return Ok((sql.to_string(), Vec::new()));
    }

    // Materialise the row source (VALUES / SELECT / WITH … SELECT)
    // as concrete rows for the listed columns.
    let listed_count = listed_columns.len();
    let mut stmt = conn.prepare(row_source).map_err(DbError::from_rusqlite)?;
    // Arity guard: if the row source's column count disagrees with
    // the user's column list, do NOT auto-fill — reading
    // `listed_count` cells would silently drop extra columns (or
    // error opaquely on too few). Defer to the verbatim statement
    // so the engine reports the mismatch as it does on the
    // non-auto-fill path (a friendly pre-flight lands in 3i).
    if stmt.column_count() != listed_count {
        return Ok((sql.to_string(), Vec::new()));
    }
    let mut rows: Vec<Vec<rusqlite::types::Value>> = Vec::new();
    {
        let mut q = stmt.query([]).map_err(DbError::from_rusqlite)?;
        while let Some(r) = q.next().map_err(DbError::from_rusqlite)? {
            let mut cells = Vec::with_capacity(listed_count);
            for i in 0..listed_count {
                cells.push(
                    r.get::<_, rusqlite::types::Value>(i)
                        .map_err(DbError::from_rusqlite)?,
                );
            }
            rows.push(cells);
        }
    }
    let n = rows.len();
    if n == 0 {
        // Nothing to insert — the verbatim statement inserts zero
        // rows without touching the omitted shortid column.
        return Ok((sql.to_string(), Vec::new()));
    }

    // A fresh, distinct shortid per row for each omitted column,
    // avoiding collision with values already stored in that column.
    let mut id_batches: Vec<Vec<rusqlite::types::Value>> =
        Vec::with_capacity(omitted_shortids.len());
    for col in &omitted_shortids {
        let existing = existing_shortids(conn, target_table, col)?;
        id_batches.push(generate_shortid_batch(n, &existing)?);
    }

    // A serial sequence per omitted non-PK serial column: MAX(col)+1 …
    // MAX(col)+n. MAX is read once (current table state); the worker-
    // thread serialisation (ADR-0010) makes the read-then-insert safe.
    // All values exceed the current MAX, so they collide with neither
    // existing rows nor each other (the column's UNIQUE holds). MAX+1
    // (gaps jumped, not back-filled) matches `do_insert` and the rowid
    // PK case (ADR-0018 §5 / Resolution 2).
    let mut serial_batches: Vec<Vec<rusqlite::types::Value>> =
        Vec::with_capacity(omitted_serials.len());
    for col in &omitted_serials {
        let max: i64 = conn
            .query_row(
                &format!(
                    "SELECT COALESCE(MAX({col}), 0) FROM {tbl};",
                    col = quote_ident(col),
                    tbl = quote_ident(target_table),
                ),
                [],
                |row| row.get(0),
            )
            .map_err(DbError::from_rusqlite)?;
        serial_batches.push(
            (1..=n as i64)
                .map(|i| rusqlite::types::Value::Integer(max + i))
                .collect(),
        );
    }

    // Reconstruct: listed columns, then the omitted shortid columns,
    // then the omitted non-PK serial columns; one parameterised tuple
    // per materialised row. The param push order below matches this.
    let all_cols: Vec<&String> = listed_columns
        .iter()
        .chain(omitted_shortids.iter())
        .chain(omitted_serials.iter())
        .collect();
    let cols_csv = all_cols
        .iter()
        .map(|c| quote_ident(c))
        .collect::<Vec<_>>()
        .join(", ");
    let per_tuple = all_cols.len();
    let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(n * per_tuple);
    let mut tuples: Vec<String> = Vec::with_capacity(n);
    let mut ph = 1;
    for (row_idx, row) in rows.into_iter().enumerate() {
        for cell in row {
            params.push(cell);
        }
        for batch in &id_batches {
            params.push(batch[row_idx].clone());
        }
        for batch in &serial_batches {
            params.push(batch[row_idx].clone());
        }
        let placeholders = (0..per_tuple)
            .map(|_| {
                let s = format!("?{ph}");
                ph += 1;
                s
            })
            .collect::<Vec<_>>()
            .join(", ");
        tuples.push(format!("({placeholders})"));
    }
    // Preserve any trailing clause — `ON CONFLICT …` (3h) and/or
    // `RETURNING …` (3g). The reconstruction rebuilds only INSERT …
    // VALUES …, so without this the UPSERT action would be lost and
    // `RETURNING *` would yield no rows. `trailing_tail` is "" when
    // the statement has neither.
    let trailing_suffix = if trailing_tail.is_empty() {
        String::new()
    } else {
        format!(" {trailing_tail}")
    };
    let exec_sql = format!(
        "INSERT INTO {tbl} ({cols_csv}) VALUES {vals}{trailing_suffix};",
        tbl = quote_ident(target_table),
        vals = tuples.join(", "),
    );
    Ok((exec_sql, params))
}

/// Worker handler for `Request::RunSqlInsert` (ADR-0033 §1,
/// sub-phase 3b). Mirrors `do_insert`'s persistence discipline:
/// run the validated SQL inside a transaction, re-persist the
/// target table's CSV + append `history.log` via
/// `finalize_persistence` *before* `tx.commit()` (so a
/// persistence failure rolls the insert back), then commit.
///
/// Grammar-as-text (ADR-0030 §4): normally the values are literals
/// in `sql` and no parameters are bound. The auto-fill path (an
/// omitted `shortid` or non-PK `serial` column) is the exception — it
/// reconstructs a parameterised `INSERT` (see `plan_autogen_autofill`);
/// either way `history.log` records the original `source`, never the
/// rewritten statement (ADR-0030 §11). FK / UNIQUE / NOT NULL
/// engine errors surface enriched via `execute_with_fk_enrichment`
/// + the friendly-error layer.
///
/// Auto-show is best-effort: the inserted rows are the last
/// `rows_affected` rowids ending at `last_insert_rowid()`. For the
/// common case (sequential / engine-assigned rowids) this is
/// exactly the inserted rows; an INSERT that sets explicit
/// non-contiguous rowid/INTEGER-PK values may surface a partial
/// view. `RETURNING` (sub-phase 3g) is the precise tool.
#[allow(clippy::too_many_arguments)]
fn do_sql_insert(
    conn: &Connection,
    persistence: Option<&Persistence>,
    sql: &str,
    target_table: &str,
    listed_columns: &[String],
    row_source: &str,
    returning: bool,
    literal_rows: &[Vec<Option<Value>>],
) -> Result<InsertResult, DbError> {
    debug!(table = %target_table, returning, "sql_insert");
    debug!(sql = %sql, table = %target_table, returning, "sql_insert");
    let canonical_table = require_canonical_table(conn, target_table)?;
    let target_table = canonical_table.as_str();

    // ADR-0036 Phase 1: validate captured literal VALUES against their
    // column types BEFORE the (still verbatim) insert runs — sharing the
    // DSL's per-type validators (`impl_value_for`) for identical wording.
    // Only literal positions are checked; expression positions (`None`)
    // are left to the engine. Column mapping mirrors the engine's: an
    // explicit column list maps by position; natural order maps to the
    // schema's columns in definition order. An out-of-range position
    // (arity mismatch) is left for the engine / parse-time diagnostic.
    // Execution below is unchanged (no binding, no auto-fill change).
    if literal_rows.iter().any(|r| r.iter().any(Option::is_some)) {
        let schema = read_schema(conn, target_table)?;
        let columns: Vec<&str> = if listed_columns.is_empty() {
            schema.columns.iter().map(|c| c.name.as_str()).collect()
        } else {
            listed_columns.iter().map(String::as_str).collect()
        };
        for row in literal_rows {
            for (idx, slot) in row.iter().enumerate() {
                if let Some(value) = slot
                    && let Some(col) = columns.get(idx)
                {
                    impl_value_for(&schema, col, value)?;
                }
            }
        }
    }
    // The `shortid` auto-fill rewrite reconstructs only `INSERT …
    // VALUES …` and would drop any trailing clause — `ON CONFLICT …`
    // (3h) and/or `RETURNING …` (3g). `row_source` is the clean
    // VALUES/SELECT text (`build_sql_insert` stops the slice at the
    // first trailing clause), so whatever follows it in the full
    // `sql` is exactly that tail; extract it here so the rewrite can
    // re-append it verbatim. On the verbatim (no auto-fill) path the
    // original `sql` already carries the tail, so it is consumed only
    // by the rewrite.
    let trailing_tail: String = if row_source.is_empty() {
        String::new()
    } else {
        sql.find(row_source)
            .map(|i| {
                sql[i + row_source.len()..]
                    .trim()
                    .trim_end_matches(';')
                    .trim()
                    .to_string()
            })
            .unwrap_or_default()
    };
    // Sub-phase 3d: when the user's column list omits one or more
    // `shortid` columns, the worker materialises the row source,
    // synthesises fresh distinct ids, and reinserts the augmented
    // rows. Returns the executable SQL + bound params; an empty
    // params vec with the original `sql` means "no auto-fill —
    // execute verbatim" (the 3b path).
    let (exec_sql, params) = plan_autogen_autofill(
        conn,
        target_table,
        sql,
        listed_columns,
        row_source,
        &trailing_tail,
    )?;
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    // RETURNING (3g): one pass inserts and yields the inserted rows
    // (incl. any auto-filled shortid), so the returned set is the
    // precise auto-show and rows_affected is its length. Without
    // RETURNING, fall back to the best-effort rowid auto-show.
    let (rows_affected, data) = if returning {
        let data = run_returning(conn, &exec_sql, &params, target_table)?;
        (data.rows.len(), data)
    } else {
        let n = execute_with_fk_enrichment(conn, target_table, &exec_sql, &params)?;
        let last = conn.last_insert_rowid();
        let rowids: Vec<i64> = if n == 0 {
            Vec::new()
        } else {
            let count = n as i64;
            ((last - count + 1)..=last).collect()
        };
        let data = query_rows_by_rowid(conn, target_table, &rowids)?;
        (n, data)
    };
    let changes = Changes {
        schema_dirty: false,
        rewritten_tables: vec![target_table.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(InsertResult {
        rows_affected,
        data,
    })
}

/// Worker handler for `Request::RunSqlUpdate` (ADR-0033 §2,
/// sub-phase 3e). Mirrors `do_sql_insert`'s persistence
/// discipline: run the validated SQL inside a transaction,
/// re-persist the target table's CSV + append `history.log` via
/// `finalize_persistence` *before* `tx.commit()`, then commit.
///
/// Grammar-as-text (ADR-0030 §4): the assignment and predicate
/// values are literals in `sql`, so no parameters are bound. A SQL
/// `UPDATE` without `WHERE` runs across all rows as written
/// (ADR-0030 §12 — no `--all-rows` rail). An update matching zero
/// rows is a success (`rows_affected == 0`); the persistence
/// write-through still runs (re-persisting the unchanged CSV is a
/// no-op-equivalent and keeps the path uniform).
///
/// Auto-show: 3e returns an empty [`DataResult`] — the affected
/// rows can't be shown precisely without `RETURNING` (sub-phase
/// 3g, which is the precise tool). The summary surfaces the
/// affected-row count; the renderer skips the (column-less) table.
fn do_sql_update(
    conn: &Connection,
    persistence: Option<&Persistence>,
    sql: &str,
    target_table: &str,
    returning: bool,
    set_literals: &[(String, Option<Value>)],
) -> Result<UpdateResult, DbError> {
    debug!(table = %target_table, returning, "sql_update");
    debug!(sql = %sql, table = %target_table, returning, "sql_update");
    let canonical_table = require_canonical_table(conn, target_table)?;
    let target_table = canonical_table.as_str();

    // ADR-0036 Phase 2: validate each captured `SET col = <literal>`
    // against its column type BEFORE the (still verbatim) update runs —
    // sharing the DSL's per-type validators (`impl_value_for`, the same
    // helper `build_update_sql` uses) for identical wording. Only literal
    // assignments are checked; expression positions (`None`) and the
    // `WHERE` predicate are left to the engine (ADR-0036 §2). Execution
    // below is unchanged (no binding).
    if set_literals.iter().any(|(_, v)| v.is_some()) {
        let schema = read_schema(conn, target_table)?;
        for (col, slot) in set_literals {
            if let Some(value) = slot {
                impl_value_for(&schema, col, value)?;
            }
        }
    }
    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    // RETURNING (3g): one pass performs the update and yields the
    // modified rows; rows_affected is the row count. Without
    // RETURNING the affected-row count surfaces and the (column-less)
    // DataResult is skipped by the renderer (3e behaviour).
    let (rows_affected, data) = if returning {
        let data = run_returning(conn, sql, &[], target_table)?;
        (data.rows.len(), data)
    } else {
        let n = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
        (
            n,
            DataResult {
                table_name: target_table.to_string(),
                columns: Vec::new(),
                column_types: Vec::new(),
                rows: Vec::new(),
            },
        )
    };
    let changes = Changes {
        schema_dirty: false,
        rewritten_tables: vec![target_table.to_string()],
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;
    Ok(UpdateResult {
        rows_affected,
        data,
    })
}

/// Worker handler for `Request::RunSqlDelete` (ADR-0033 §1/§7,
/// sub-phase 3f). Mirrors the DSL `do_delete` exactly, differing
/// only in that it executes the verbatim grammar-validated `sql`
/// rather than building the statement from a typed filter.
///
/// Cascade detection (ADR-0033 Amendment 2): the worker snapshots
/// each inbound child table's row count *before* the DELETE, runs
/// the statement inside a transaction (the engine applies any
/// `ON DELETE CASCADE`), counts the children again *after*, and
/// reports the positive difference as a [`CascadeEffect`]. This is
/// the identical mechanism the DSL path uses, so the SQL and DSL
/// DELETE produce the same per-relationship summary on the same
/// schema/data — and since both return a [`DeleteResult`] routed
/// through `CommandOutcome::Delete`, the render-layer formatter is
/// shared with no duplication. `ON DELETE SET NULL` leaves row
/// counts unchanged and so is not reported on either path (a
/// deferred enhancement for both).
///
/// Because the diff observes the result of executing the whole
/// statement, the WHERE clause is never inspected — a WHERE that
/// itself contains a subquery (the R2 invariant) is correct by
/// construction and carries no extra per-child query cost.
///
/// Persistence discipline matches `do_delete` / `do_sql_update`:
/// re-persist the target's CSV *and every cascade-affected child's*
/// CSV via `finalize_persistence` (which also appends `source` to
/// `history.log`) *before* `tx.commit()`, so a persistence failure
/// rolls the delete back. A DELETE matching zero rows is a success
/// (`rows_affected == 0`, empty cascade); the target's CSV is still
/// re-persisted, keeping the path uniform.
fn do_sql_delete(
    conn: &Connection,
    persistence: Option<&Persistence>,
    sql: &str,
    target_table: &str,
    returning: bool,
) -> Result<DeleteResult, DbError> {
    debug!(sql = %sql, table = %target_table, returning, "sql_delete");
    let canonical_table = require_canonical_table(conn, target_table)?;
    let target_table = canonical_table.as_str();

    // Snapshot child-table row counts before the delete so cascade
    // effects can be detected by diffing afterwards (Amendment 2;
    // identical to `do_delete`). ON UPDATE CASCADE / ON DELETE SET
    // NULL do not change row counts and so are not detected here.
    let inbound = read_relationships_inbound(conn, target_table)?;
    let mut before_counts: Vec<i64> = Vec::with_capacity(inbound.len());
    for r in &inbound {
        before_counts.push(count_rows(conn, &r.other_table)?);
    }

    let tx = conn
        .unchecked_transaction()
        .map_err(DbError::from_rusqlite)?;
    // RETURNING (3g): one pass deletes and yields the rows as they
    // were *before* deletion. `rows_affected` is the count of
    // directly-deleted rows either way (RETURNING does not yield
    // cascade-deleted child rows, so data.rows.len() == direct
    // deletes), which keeps the self-ref cascade correction below
    // valid. The cascade pre-count was already captured above.
    let (rows_affected, data) = if returning {
        let data = run_returning(conn, sql, &[], target_table)?;
        (data.rows.len(), data)
    } else {
        let n = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
        (
            n,
            DataResult {
                table_name: target_table.to_string(),
                columns: Vec::new(),
                column_types: Vec::new(),
                rows: Vec::new(),
            },
        )
    };

    // Compare child-table counts after the delete; positive diffs
    // are cascade effects. Collect the cascaded tables so the
    // persistence phase rewrites their CSVs too.
    let mut cascade: Vec<CascadeEffect> = Vec::new();
    let mut rewritten_tables: Vec<String> = vec![target_table.to_string()];
    for (rel, before_count) in inbound.iter().zip(before_counts.iter()) {
        let after_count = count_rows(conn, &rel.other_table)?;
        let mut rows_changed = before_count - after_count;
        // A self-referential FK (child == target): the before/after
        // diff also covers the directly-deleted rows, which are
        // already reported in `rows_affected` and are not cascade
        // effects. Subtract them so the summary reports only the rows
        // removed *via* the self-reference.
        let self_referential = rel.other_table == target_table;
        if self_referential {
            rows_changed -= rows_affected as i64;
        }
        if rows_changed > 0 {
            cascade.push(CascadeEffect {
                relationship_name: rel.name.clone(),
                child_table: rel.other_table.clone(),
                rows_changed,
                action: rel.on_delete,
            });
            // The target's CSV is already queued; only add a distinct
            // child table (a self-ref child is the target itself).
            if !self_referential {
                rewritten_tables.push(rel.other_table.clone());
            }
        }
    }

    let changes = Changes {
        schema_dirty: false,
        rewritten_tables,
        ..Changes::default()
    };
    finalize_persistence(conn, persistence, &changes)?;
    tx.commit().map_err(DbError::from_rusqlite)?;

    Ok(DeleteResult {
        rows_affected,
        cascade,
        data,
    })
}

/// Execute a grammar-validated SQL `SELECT` and collect its
/// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 +
/// Amendment 1).
///
/// Per-column playground types are recovered from the engine's
/// column-origin metadata (`column_table_name` /
/// `column_origin_name`, surfaced by rusqlite's
/// `columns_with_metadata`). The Amendment-1 empirical probe
/// confirmed the metadata follows through non-recursive CTEs,
/// scalar subqueries, derived tables, set ops, and JOINs; only
/// computed projections and recursive-CTE result columns return
/// `None`. The renderer (ADR-0016) handles typed columns
/// (bool → true/false, etc.) and falls back to neutral
/// alignment for `None`.
/// Execute a grammar-validated SQL DML statement carrying a
/// `RETURNING` clause and collect its returned rows into a
/// [`DataResult`] (ADR-0033 §5, sub-phase 3g).
///
/// A `RETURNING` DML, when stepped, *both* performs the mutation
/// *and* yields one result row per affected row — so `query_map`
/// does the write and the read in one pass. Result-column
/// playground types are recovered via the same column-origin path
/// SELECT uses (`resolve_select_column_types`), so a bare-column
/// `RETURNING` ref renders with its playground type; computed
/// projections stay typeless. `params` carries the bound values for
/// the `shortid` auto-fill rewrite (empty on the verbatim path).
///
/// `table_name` labels the result for the renderer; the columns are
/// the RETURNING projection, which may not be the table's columns
/// (aliases, expressions), exactly as for a SELECT.
fn run_returning(
    conn: &Connection,
    sql: &str,
    params: &[rusqlite::types::Value],
    table_name: &str,
) -> Result<DataResult, DbError> {
    let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
    let column_names: Vec<String> = stmt.column_names().into_iter().map(String::from).collect();
    let col_count = column_names.len();
    let column_types = resolve_select_column_types(conn, &stmt);
    let rows_iter = stmt
        .query_map(rusqlite::params_from_iter(params.iter()), |row| {
            let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(col_count);
            for i in 0..col_count {
                cells.push(row.get(i)?);
            }
            Ok(cells)
        })
        .map_err(DbError::from_rusqlite)?;
    let mut rows: Vec<Vec<Option<String>>> = Vec::new();
    for r in rows_iter {
        let cells = r.map_err(DbError::from_rusqlite)?;
        rows.push(
            cells
                .into_iter()
                .enumerate()
                .map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten()))
                .collect(),
        );
    }
    Ok(DataResult {
        table_name: table_name.to_string(),
        columns: column_names,
        column_types,
        rows,
    })
}

fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
    debug!(sql = %sql, "run_select");
    let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
    let column_names: Vec<String> = stmt.column_names().into_iter().map(String::from).collect();
    let col_count = column_names.len();
    let column_types = resolve_select_column_types(conn, &stmt);
    let rows_iter = stmt
        .query_map([], |row| {
            let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(col_count);
            for i in 0..col_count {
                cells.push(row.get(i)?);
            }
            Ok(cells)
        })
        .map_err(DbError::from_rusqlite)?;
    let mut rows: Vec<Vec<Option<String>>> = Vec::new();
    for r in rows_iter {
        let cells = r.map_err(DbError::from_rusqlite)?;
        rows.push(
            cells
                .into_iter()
                .enumerate()
                .map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten()))
                .collect(),
        );
    }
    Ok(DataResult {
        table_name: String::new(),
        columns: column_names,
        column_types,
        rows,
    })
}

/// Resolve playground types for each result column of a
/// prepared SELECT statement (ADR-0032 §12 + Amendment 1).
///
/// For each result column, query the engine's column-origin
/// metadata. If both `table_name` and `origin_name` are
/// populated (the result column traces back to a base-table
/// column), look up the playground type in
/// `__rdbms_playground_columns`. Otherwise the slot stays
/// `None` — Amendment 1 documents that recursive-CTE result
/// columns and computed projections are the only structural
/// classes that don't follow through.
fn resolve_select_column_types(conn: &Connection, stmt: &rusqlite::Statement) -> Vec<Option<Type>> {
    let metas = stmt.columns_with_metadata();
    if metas.is_empty() {
        return Vec::new();
    }
    // Prepare the lookup once; reuse across columns.
    let mut lookup = match conn.prepare(&format!(
        "SELECT user_type FROM {META_TABLE} \
         WHERE table_name = ?1 COLLATE NOCASE \
           AND column_name = ?2 COLLATE NOCASE"
    )) {
        Ok(s) => s,
        Err(_) => return vec![None; metas.len()],
    };
    metas
        .iter()
        .map(|m| {
            let table = m.table_name()?;
            let origin = m.origin_name()?;
            lookup
                .query_row(rusqlite::params![table, origin], |row| {
                    row.get::<_, String>(0)
                })
                .ok()
                .and_then(|kw| kw.parse::<Type>().ok())
        })
        .collect()
}

/// Build the parameterised `SELECT … FROM …` statement for a
/// `show data` query (ADR-0026 §5–§6). Separated from
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
/// PLAN` against the *exact* same statement (ADR-0028 §2).
///
/// A `limit` implies a stable primary-key `ORDER BY` so
/// `limit n` is "first n by primary key" rather than an
/// arbitrary subset.
fn build_query_data_sql(
    schema: &ReadSchema,
    table: &str,
    filter: Option<&Expr>,
    limit: Option<u64>,
) -> (String, Vec<rusqlite::types::Value>) {
    let cols_csv = schema
        .columns
        .iter()
        .map(|c| quote_ident(&c.name))
        .collect::<Vec<_>>()
        .join(", ");

    let mut params: Vec<rusqlite::types::Value> = Vec::new();
    let where_sql = filter.map_or_else(String::new, |expr| {
        format!(" WHERE {}", compile_expr(expr, schema, &mut params))
    });
    let (order_sql, limit_sql) = limit.map_or_else(
        || (String::new(), String::new()),
        |n| {
            let order = if schema.primary_key.is_empty() {
                String::new()
            } else {
                let pk = schema
                    .primary_key
                    .iter()
                    .map(|c| quote_ident(c))
                    .collect::<Vec<_>>()
                    .join(", ");
                format!(" ORDER BY {pk}")
            };
            params.push(rusqlite::types::Value::Integer(
                i64::try_from(n).unwrap_or(i64::MAX),
            ));
            (order, format!(" LIMIT ?{}", params.len()))
        },
    );
    let sql = format!(
        "SELECT {cols_csv} FROM {ident}{where_sql}{order_sql}{limit_sql};",
        ident = quote_ident(table),
    );
    (sql, params)
}

fn do_query_data(
    conn: &Connection,
    table: &str,
    filter: Option<&Expr>,
    limit: Option<u64>,
) -> Result<DataResult, DbError> {
    debug!(table = %table, limit = ?limit, "query_data");
    let schema = read_schema(conn, table)?;
    let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
    let column_types: Vec<Option<Type>> = schema.columns.iter().map(|c| c.user_type).collect();

    let (sql, params) = build_query_data_sql(&schema, table, filter, limit);
    debug!(sql = %sql, "query_data");
    let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
    let rows_iter = stmt
        .query_map(rusqlite::params_from_iter(params.iter()), |row| {
            let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(column_names.len());
            for i in 0..column_names.len() {
                let v: rusqlite::types::Value = row.get(i)?;
                cells.push(v);
            }
            Ok(cells)
        })
        .map_err(DbError::from_rusqlite)?;
    let mut rows: Vec<Vec<Option<String>>> = Vec::new();
    for r in rows_iter {
        let cells = r.map_err(DbError::from_rusqlite)?;
        let formatted: Vec<Option<String>> = cells
            .into_iter()
            .zip(column_types.iter())
            .map(|(v, ty)| format_cell(v, *ty))
            .collect();
        rows.push(formatted);
    }
    Ok(DataResult {
        table_name: table.to_string(),
        columns: column_names,
        column_types,
        rows,
    })
}

fn format_cell(value: rusqlite::types::Value, ty: Option<Type>) -> Option<String> {
    use rusqlite::types::Value as V;
    match value {
        V::Null => None,
        V::Integer(i) => Some(if matches!(ty, Some(Type::Bool)) {
            (if i == 0 { "false" } else { "true" }).to_string()
        } else {
            i.to_string()
        }),
        V::Real(r) => Some(format_real_display(r)),
        V::Text(s) => Some(s),
        V::Blob(b) => Some(format!("<blob {} bytes>", b.len())),
    }
}

/// Render a floating-point value for **display**, trimming the
/// IEEE-754 noise that surfaces when the engine coerces a
/// `decimal` (stored as exact TEXT, ADR-0005) to a double for
/// arithmetic or aggregation (issue #32): `sum(price * qty)`
/// would otherwise show `298.59999999999997` for `298.60`.
///
/// A double carries ~15–17 significant decimal digits and the
/// noise lives in the last one or two, so we round to 15
/// significant figures and then take the *shortest* round-tripping
/// form of the rounded value. That collapses
/// `298.59999999999997` → `298.6` and `0.30000000000000004` →
/// `0.3`. A clean value rounds to itself, so the result is never
/// longer than the previous `format!("{r}")` — the magnitude and
/// whatever digit expansion `Display` already chose are preserved
/// (Rust's `Display` for `f64` never uses scientific notation, so
/// a very large value still expands to full digits, exactly as
/// before). Non-finite values pass through unchanged.
///
/// DISPLAY ONLY, and only for genuine display cells. The CSV
/// encoder (`persistence::csv_io::format_real`) keeps the exact
/// shortest round-trip so a stored `real` survives save/load
/// byte-for-byte; the uniqueness dry-runs key on the full-precision
/// `render_value`; FK-key matching and EXPLAIN-SQL literals keep
/// full precision too. Rounding any of those would change behaviour,
/// not just looks. This is wired only into `format_cell`.
fn format_real_display(r: f64) -> String {
    if !r.is_finite() {
        return format!("{r}");
    }
    // `{:.14e}` is a 15-significant-figure scientific form (one
    // mantissa digit + 14 after the point); parsing it back and
    // letting `Display` pick the shortest exact form drops the
    // trailing IEEE-754 noise.
    let rounded: f64 = format!("{r:.14e}").parse().unwrap_or(r);
    format!("{rounded}")
}

/// Capture the query plan for an explainable command
/// (ADR-0028 §2). Matches the inner command, builds the exact
/// SQL it would otherwise run via the shared `build_*_sql`
/// helpers, runs `EXPLAIN QUERY PLAN` against it (which never
/// executes the statement), and pairs the plan rows with a
/// standard-SQL display form of the statement.
fn do_explain_plan(conn: &Connection, query: &Command) -> Result<QueryPlan, DbError> {
    debug!("explain_plan");
    let (exec_sql, params) = match query {
        Command::ShowData {
            name,
            filter,
            limit,
        } => {
            let schema = read_schema(conn, name)?;
            build_query_data_sql(&schema, name, filter.as_ref(), *limit)
        }
        Command::Update {
            table,
            assignments,
            filter,
        } => {
            let schema = read_schema(conn, table)?;
            build_update_sql(&schema, table, assignments, filter)?
        }
        Command::Delete { table, filter } => {
            let schema = read_schema(conn, table)?;
            build_delete_sql(&schema, table, filter)
        }
        // ADR-0039: advanced-mode SQL commands carry their validated
        // SQL text verbatim (grammar-as-text), so there is nothing to
        // synthesise — run EXPLAIN QUERY PLAN over the text directly,
        // with no bound parameters. `EXPLAIN QUERY PLAN` never
        // executes the statement, so this is safe for the destructive
        // verbs too.
        Command::Select { sql }
        | Command::SqlInsert { sql, .. }
        | Command::SqlUpdate { sql, .. }
        | Command::SqlDelete { sql, .. } => (sql.clone(), Vec::new()),
        other => {
            // The grammar only ever wraps the three explainable
            // commands; a different inner command means a
            // synthesised `Command::Explain` (tests, scripting).
            return Err(DbError::Unsupported(format!(
                "cannot explain `{}`",
                other.verb()
            )));
        }
    };
    let rows = run_explain_query_plan(conn, &exec_sql, &params)?;
    let display_sql = inline_params_for_display(&exec_sql, &params);
    debug!(sql = %display_sql, rows = rows.len(), "explain_plan");
    Ok(QueryPlan { display_sql, rows })
}

/// Prepare `EXPLAIN QUERY PLAN <sql>` and read back its tree
/// rows. The inner statement's parameters are bound so the
/// statement prepares cleanly; `EXPLAIN QUERY PLAN` determines
/// the plan from structure, not parameter values (ADR-0028 §2).
fn run_explain_query_plan(
    conn: &Connection,
    sql: &str,
    params: &[rusqlite::types::Value],
) -> Result<Vec<ExplainRow>, DbError> {
    let explain_sql = format!("EXPLAIN QUERY PLAN {sql}");
    let mut stmt = conn.prepare(&explain_sql).map_err(DbError::from_rusqlite)?;
    // `EXPLAIN QUERY PLAN` yields `(id, parent, notused, detail)`
    // — the `notused` column at index 2 is dropped.
    let rows_iter = stmt
        .query_map(rusqlite::params_from_iter(params.iter()), |row| {
            Ok(ExplainRow {
                id: row.get(0)?,
                parent: row.get(1)?,
                detail: row.get(3)?,
            })
        })
        .map_err(DbError::from_rusqlite)?;
    let mut out = Vec::new();
    for r in rows_iter {
        out.push(r.map_err(DbError::from_rusqlite)?);
    }
    Ok(out)
}

/// Render execution SQL as human-facing display SQL by inlining
/// each `?N` placeholder as a standard-SQL literal (ADR-0028
/// §3). The scan is quote-aware — a `?N`-shaped run inside a
/// double-quoted identifier is left untouched — and the
/// trailing `;` is dropped so the result reads as a query.
fn inline_params_for_display(sql: &str, params: &[rusqlite::types::Value]) -> String {
    let mut out = String::with_capacity(sql.len());
    let mut chars = sql.chars().peekable();
    let mut in_quote = false;
    while let Some(c) = chars.next() {
        match c {
            '"' => {
                in_quote = !in_quote;
                out.push('"');
            }
            '?' if !in_quote && chars.peek().is_some_and(char::is_ascii_digit) => {
                let mut digits = String::new();
                while let Some(d) = chars.peek() {
                    if d.is_ascii_digit() {
                        digits.push(*d);
                        chars.next();
                    } else {
                        break;
                    }
                }
                match digits
                    .parse::<usize>()
                    .ok()
                    .and_then(|n| n.checked_sub(1))
                    .and_then(|idx| params.get(idx))
                {
                    Some(value) => out.push_str(&sql_literal(value)),
                    // Out of range — leave the placeholder verbatim.
                    None => {
                        out.push('?');
                        out.push_str(&digits);
                    }
                }
            }
            other => out.push(other),
        }
    }
    out.trim_end().trim_end_matches(';').trim_end().to_string()
}

/// Render a bound parameter as a standard-SQL literal for the
/// display SQL (ADR-0028 §3). Text is single-quoted with
/// embedded quotes doubled; a real with no fractional part
/// keeps a `.0` so it still reads as a real.
fn sql_literal(value: &rusqlite::types::Value) -> String {
    use rusqlite::types::Value as V;
    match value {
        V::Null => "NULL".to_string(),
        V::Integer(i) => i.to_string(),
        V::Real(r) => {
            let s = r.to_string();
            if s.contains(['.', 'e', 'E']) {
                s
            } else {
                format!("{s}.0")
            }
        }
        V::Text(s) => format!("'{}'", s.replace('\'', "''")),
        V::Blob(bytes) => {
            let mut hex = String::with_capacity(bytes.len() * 2 + 3);
            hex.push_str("x'");
            for b in bytes {
                hex.push_str(&format!("{b:02x}"));
            }
            hex.push('\'');
            hex
        }
    }
}

fn read_relationships_outbound(
    conn: &Connection,
    table: &str,
) -> Result<Vec<RelationshipEnd>, DbError> {
    let mut stmt = conn
        .prepare(&format!(
            "SELECT name, parent_table, parent_column, child_column, on_delete, on_update \
             FROM {REL_TABLE} \
             WHERE child_table = ?1 \
             ORDER BY name;"
        ))
        .map_err(DbError::from_rusqlite)?;
    let rows = stmt
        .query_map([table], |row| {
            let on_delete: String = row.get(4)?;
            let on_update: String = row.get(5)?;
            Ok(RelationshipEnd {
                name: row.get(0)?,
                other_table: row.get(1)?,
                other_columns: decode_rel_columns(row.get::<_, String>(2)?.as_str()),
                local_columns: decode_rel_columns(row.get::<_, String>(3)?.as_str()),
                on_delete: on_delete
                    .parse::<ReferentialAction>()
                    .unwrap_or(ReferentialAction::NoAction),
                on_update: on_update
                    .parse::<ReferentialAction>()
                    .unwrap_or(ReferentialAction::NoAction),
            })
        })
        .map_err(DbError::from_rusqlite)?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(DbError::from_rusqlite)?);
    }
    Ok(out)
}

fn read_relationships_inbound(
    conn: &Connection,
    table: &str,
) -> Result<Vec<RelationshipEnd>, DbError> {
    let mut stmt = conn
        .prepare(&format!(
            "SELECT name, child_table, child_column, parent_column, on_delete, on_update \
             FROM {REL_TABLE} \
             WHERE parent_table = ?1 \
             ORDER BY name;"
        ))
        .map_err(DbError::from_rusqlite)?;
    let rows = stmt
        .query_map([table], |row| {
            let on_delete: String = row.get(4)?;
            let on_update: String = row.get(5)?;
            Ok(RelationshipEnd {
                name: row.get(0)?,
                other_table: row.get(1)?,
                other_columns: decode_rel_columns(row.get::<_, String>(2)?.as_str()),
                local_columns: decode_rel_columns(row.get::<_, String>(3)?.as_str()),
                on_delete: on_delete
                    .parse::<ReferentialAction>()
                    .unwrap_or(ReferentialAction::NoAction),
                on_update: on_update
                    .parse::<ReferentialAction>()
                    .unwrap_or(ReferentialAction::NoAction),
            })
        })
        .map_err(DbError::from_rusqlite)?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(DbError::from_rusqlite)?);
    }
    Ok(out)
}

/// Rebuild the database from `project.yaml` + `data/<table>.csv`
/// (ADR-0015 §7).
///
/// The on-disk text is the authoritative source: this function
/// recreates schema, metadata, and rows so the resulting `.db`
/// reflects them exactly. Persistence callbacks are NOT invoked
/// for the schema/data writes; we're loading, not changing
/// user-visible state. The exception is `history.log`: when
/// `source` is `Some`, the rebuild was user-initiated (via the
/// `rebuild` app-level command) and is appended as a successful
/// command per ADR-0015 §5.
///
/// Existing user tables and metadata rows are wiped at the
/// start of the rebuild so this function works on both fresh
/// and populated databases — the silent on-load case (empty
/// db) sees a no-op wipe; the explicit `rebuild` command
/// replaces whatever was there.
///
/// FK enforcement is disabled for the load and re-enabled at
/// the end (regardless of success). A `foreign_key_check`
/// before commit verifies the loaded data is consistent — any
/// violation aborts with a fatal error.
fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), DbError> {
    debug!(path = %project_path.display(), "rebuild_from_text");
    let yaml_path = project_path.join(PROJECT_YAML);
    let data_dir = project_path.join(DATA_DIR);

    let yaml_body = std::fs::read_to_string(&yaml_path).map_err(|e| DbError::PersistenceFatal {
        operation: "read",
        path: yaml_path.clone(),
        message: e.to_string(),
    })?;
    let snapshot = parse_schema(&yaml_body).map_err(|e| DbError::PersistenceFatal {
        operation: "parse",
        path: yaml_path.clone(),
        message: e.to_string(),
    })?;

    conn.execute_batch("PRAGMA foreign_keys = OFF;")
        .map_err(DbError::from_rusqlite)?;

    let result = (|| -> Result<(), DbError> {
        let tx = conn
            .unchecked_transaction()
            .map_err(DbError::from_rusqlite)?;

        // 0. Wipe any existing user tables + metadata so the
        //    rebuild can start from a clean slate. This step
        //    is a no-op on a freshly-created database (the
        //    silent rebuild on missing-`.db`); on the explicit
        //    `rebuild` command it replaces the live state with
        //    the text-source state per ADR-0015 §7.
        let existing_tables = do_list_tables(&tx)?;
        for name in &existing_tables {
            tx.execute_batch(&format!("DROP TABLE {ident};", ident = quote_ident(name)))
                .map_err(DbError::from_rusqlite)?;
        }
        tx.execute_batch(&format!(
            "DELETE FROM {META_TABLE}; DELETE FROM {REL_TABLE};"
        ))
        .map_err(DbError::from_rusqlite)?;

        // 0b. The table-level CHECK metadata is the source of truth that
        //     the engine cannot report (ADR-0035 §4a.3), so — like
        //     META/REL — it is wiped and repopulated from the YAML
        //     snapshot here (step 3b). Without this a fresh rebuild
        //     (missing `.db`) would enforce the CHECK via the recreated
        //     DDL but leave `CHECK_TABLE` empty, so `describe` / `DROP
        //     CONSTRAINT` / a later save would lose it. The rebuild also
        //     **migrates** a pre-§4g table that predates the `name`
        //     column (the rebuild-only migration, ADR-0035 §4g): add it
        //     if absent before repopulating with names.
        if !check_table_has_name_column(&tx)? {
            tx.execute_batch(&format!("ALTER TABLE {CHECK_TABLE} ADD COLUMN name TEXT;"))
                .map_err(DbError::from_rusqlite)?;
        }
        tx.execute_batch(&format!("DELETE FROM {CHECK_TABLE};"))
            .map_err(DbError::from_rusqlite)?;

        // 1. Recreate user tables with FK constraints inline.
        for table in &snapshot.tables {
            let read_schema = build_read_schema(table, &snapshot.relationships);
            let ddl = schema_to_ddl(&table.name, &read_schema);
            tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
        }

        // 2. Column-type metadata.
        {
            let mut stmt = tx
                .prepare(&format!(
                    "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
                     VALUES (?1, ?2, ?3);"
                ))
                .map_err(DbError::from_rusqlite)?;
            for table in &snapshot.tables {
                for col in &table.columns {
                    stmt.execute([
                        table.name.as_str(),
                        col.name.as_str(),
                        col.user_type.keyword(),
                    ])
                    .map_err(DbError::from_rusqlite)?;
                }
            }
        }

        // 3. Relationship metadata.
        {
            let mut stmt = tx
                .prepare(&format!(
                    "INSERT INTO {REL_TABLE} \
                       (name, parent_table, parent_column, child_table, child_column, \
                        on_delete, on_update) \
                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"
                ))
                .map_err(DbError::from_rusqlite)?;
            for rel in &snapshot.relationships {
                let parent_cols = encode_rel_columns(&rel.parent_columns);
                let child_cols = encode_rel_columns(&rel.child_columns);
                stmt.execute([
                    rel.name.as_str(),
                    rel.parent_table.as_str(),
                    parent_cols.as_str(),
                    rel.child_table.as_str(),
                    child_cols.as_str(),
                    rel.on_delete.keyword(),
                    rel.on_update.keyword(),
                ])
                .map_err(DbError::from_rusqlite)?;
            }
        }

        // 3b. Table-level CHECK metadata (ADR-0035 §4a.3 / §4g) — in
        //     declaration order (`seq`), carrying the optional name so a
        //     named CHECK round-trips through the rebuild.
        {
            let mut stmt = tx
                .prepare(&format!(
                    "INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr, name) \
                     VALUES (?1, ?2, ?3, ?4);"
                ))
                .map_err(DbError::from_rusqlite)?;
            for table in &snapshot.tables {
                for (seq, check) in table.check_constraints.iter().enumerate() {
                    stmt.execute(rusqlite::params![
                        table.name, seq as i64, check.expr, check.name,
                    ])
                    .map_err(DbError::from_rusqlite)?;
                }
            }
        }

        // 4. Project metadata: overwrite the configure-time
        //    `created_at` with the YAML's authoritative value.
        tx.execute(
            &format!(
                "INSERT INTO {META_PROJECT_TABLE} (key, value) VALUES ('created_at', ?1) \
                 ON CONFLICT(key) DO UPDATE SET value = excluded.value;"
            ),
            [snapshot.created_at.as_str()],
        )
        .map_err(DbError::from_rusqlite)?;

        // 5. Load each table's rows (if a CSV is present).
        for table in &snapshot.tables {
            let csv_path = data_dir.join(format!("{}.csv", table.name));
            if !csv_path.exists() {
                continue;
            }
            load_table_csv(&tx, table, &csv_path)?;
        }

        // 5b. Recreate indexes (ADR-0025). Done after the data
        //     load — the result is identical either way, and
        //     this keeps the structural steps (tables, FKs,
        //     data) ahead of the derived index objects.
        for index in &snapshot.indexes {
            let cols = index
                .columns
                .iter()
                .map(|c| quote_ident(c))
                .collect::<Vec<_>>()
                .join(", ");
            // ADR-0035 §4d: a UNIQUE index round-trips its uniqueness, so
            // re-emit `CREATE UNIQUE INDEX` — otherwise a rebuild would
            // silently demote it to a plain index.
            let unique_kw = if index.unique { "UNIQUE " } else { "" };
            tx.execute_batch(&format!(
                "CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
                idx = quote_ident(&index.name),
                tbl = quote_ident(&index.table),
            ))
            .map_err(DbError::from_rusqlite)?;
        }

        // 6. Verify FK consistency before committing.
        {
            let mut check = tx
                .prepare("PRAGMA foreign_key_check;")
                .map_err(DbError::from_rusqlite)?;
            let mut rows = check.query([]).map_err(DbError::from_rusqlite)?;
            if rows.next().map_err(DbError::from_rusqlite)?.is_some() {
                return Err(DbError::PersistenceFatal {
                    operation: "rebuild",
                    path: yaml_path.clone(),
                    message: "rebuilt data violates foreign-key constraints".to_string(),
                });
            }
        }

        // 7. Append `history.log` if this rebuild was
        //    user-initiated (the silent on-load case has
        //    `source = None`).
        // ADR-0052: `rebuild` is journalled at the dispatch layer
        // (`spawn_rebuild`), not here — journaling left the worker.

        tx.commit().map_err(DbError::from_rusqlite)?;
        Ok(())
    })();

    let pragma_result = conn
        .execute_batch("PRAGMA foreign_keys = ON;")
        .map_err(DbError::from_rusqlite);
    result.and(pragma_result)
}

/// Build a `ReadSchema` for `table` that includes any
/// relationships from the snapshot in which `table` is the
/// child. The output drives `schema_to_ddl` so the resulting
/// CREATE TABLE has the FKs inline.
fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema]) -> ReadSchema {
    let columns: Vec<ReadColumn> = table
        .columns
        .iter()
        .map(|c| ReadColumn {
            name: c.name.clone(),
            sqlite_type: c.user_type.sqlite_strict_type().to_string(),
            notnull: c.not_null,
            primary_key: table.primary_key.contains(&c.name),
            unique: c.unique,
            default_sql: c.default.clone(),
            check: c.check.clone(),
            user_type: Some(c.user_type),
        })
        .collect();
    let foreign_keys: Vec<ReadForeignKey> = relationships
        .iter()
        .filter(|r| r.child_table == table.name)
        .map(|r| ReadForeignKey {
            parent_table: r.parent_table.clone(),
            parent_columns: r.parent_columns.clone(),
            child_columns: r.child_columns.clone(),
            on_delete: r.on_delete,
            on_update: r.on_update,
        })
        .collect();
    ReadSchema {
        columns,
        primary_key: table.primary_key.clone(),
        foreign_keys,
        unique_constraints: table.unique_constraints.clone(),
        check_constraints: table.check_constraints.clone(),
    }
}

/// Read `csv_path` and INSERT each row into `table.name`.
/// Failures are wrapped in `DbError::RebuildRowFailed` with
/// row number and table name per ADR-0015 §7.
fn load_table_csv(
    tx: &rusqlite::Transaction<'_>,
    table: &TableSchema,
    csv_path: &Path,
) -> Result<(), DbError> {
    let body = std::fs::read_to_string(csv_path).map_err(|e| DbError::PersistenceFatal {
        operation: "read",
        path: csv_path.to_path_buf(),
        message: e.to_string(),
    })?;
    let parsed = parse_csv(&body).map_err(|e| DbError::PersistenceFatal {
        operation: "parse",
        path: csv_path.to_path_buf(),
        message: e.to_string(),
    })?;

    if parsed.rows.is_empty() {
        return Ok(());
    }

    // Header sanity check: column names must match the YAML
    // schema's column order. A mismatch is a hand-edit hazard;
    // surfacing it as a fatal error is better than silently
    // mis-aligning columns.
    let expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
    let header_strs: Vec<&str> = parsed.header.iter().map(String::as_str).collect();
    if header_strs != expected {
        return Err(DbError::PersistenceFatal {
            operation: "validate",
            path: csv_path.to_path_buf(),
            message: format!(
                "CSV header {:?} does not match table columns {:?}",
                parsed.header, expected,
            ),
        });
    }

    let cols_csv = table
        .columns
        .iter()
        .map(|c| quote_ident(&c.name))
        .collect::<Vec<_>>()
        .join(", ");
    let placeholders = (1..=table.columns.len())
        .map(|i| format!("?{i}"))
        .collect::<Vec<_>>()
        .join(", ");
    let sql = format!(
        "INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});",
        ident = quote_ident(&table.name),
    );
    let mut stmt = tx.prepare(&sql).map_err(DbError::from_rusqlite)?;

    for (idx, raw_row) in parsed.rows.iter().enumerate() {
        // Row number reported as a 1-based file line: header
        // is line 1, so the first data row is line 2.
        let row_number = idx + 2;
        if raw_row.len() != table.columns.len() {
            return Err(DbError::RebuildRowFailed {
                table: table.name.clone(),
                csv_path: csv_path.to_path_buf(),
                row_number,
                detail: format!(
                    "row has {} field(s) but table has {} column(s)",
                    raw_row.len(),
                    table.columns.len(),
                ),
            });
        }
        let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(raw_row.len());
        for (col, raw_cell) in table.columns.iter().zip(raw_row.iter()) {
            let cell = decode_cell(col.user_type, raw_cell).map_err(|detail| {
                DbError::RebuildRowFailed {
                    table: table.name.clone(),
                    csv_path: csv_path.to_path_buf(),
                    row_number,
                    detail: format!("column `{}`: {detail}", col.name),
                }
            })?;
            params.push(cell_value_to_sqlite(&cell));
        }
        stmt.execute(rusqlite::params_from_iter(params.iter()))
            .map_err(|e| DbError::RebuildRowFailed {
                table: table.name.clone(),
                csv_path: csv_path.to_path_buf(),
                row_number,
                detail: e.to_string(),
            })?;
    }
    Ok(())
}

fn cell_value_to_sqlite(cell: &CellValue) -> rusqlite::types::Value {
    use rusqlite::types::Value;
    match cell {
        CellValue::Null => Value::Null,
        CellValue::Integer(n) => Value::Integer(*n),
        CellValue::Real(f) => Value::Real(*f),
        CellValue::Text(s) => Value::Text(s.clone()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    fn db() -> Database {
        Database::open(":memory:").expect("open in-memory")
    }

    // ---- Issue #32 — display rounding of coerced doubles ----

    #[test]
    fn format_real_display_trims_ieee754_noise() {
        // The reported case and the classic float artifacts.
        assert_eq!(format_real_display(298.599_999_999_999_97), "298.6");
        assert_eq!(format_real_display(0.1 + 0.2), "0.3");
        assert_eq!(format_real_display(59.97), "59.97");
    }

    #[test]
    fn format_real_display_keeps_whole_numbers_and_sign() {
        assert_eq!(format_real_display(5.0), "5");
        assert_eq!(format_real_display(0.0), "0");
        assert_eq!(format_real_display(-298.599_999_999_999_97), "-298.6");
        assert_eq!(format_real_display(1.5e-12), "0.0000000000015");
    }

    #[test]
    fn format_real_display_preserves_clean_values_no_regression() {
        // A value with no IEEE-754 noise rounds to itself, so the
        // helper is never *worse* (or longer) than the previous
        // `format!("{r}")` — including a very large magnitude, which
        // `Display` expands to full digits both before and after.
        for v in [1e300_f64, 1.25_f64, 1.5e-12_f64, 42.0_f64, -0.5_f64] {
            assert_eq!(
                format_real_display(v),
                format!("{v}"),
                "clean value {v} must format exactly as before",
            );
        }
    }

    #[test]
    fn render_value_keeps_full_precision_for_identity_grouping() {
        // DA guard (issue #32): `render_value` must NOT adopt the
        // display rounding — the uniqueness dry-runs key on its output
        // to detect duplicates the engine (which compares exact
        // values) would reject. Two adjacent doubles that agree to 15
        // significant figures must still render to *distinct* strings,
        // or `dry_run_unique` / `check_uniqueness_collisions` would
        // report a false collision.
        use rusqlite::types::Value as V;
        let a = 1.0_f64;
        let b = 1.000_000_000_000_000_2_f64; // 1.0.next_up()
        assert_ne!(a, b, "the two doubles are genuinely distinct");
        assert_eq!(render_value(&V::Real(a)), "1");
        assert_eq!(render_value(&V::Real(b)), "1.0000000000000002");
        assert_ne!(
            render_value(&V::Real(a)),
            render_value(&V::Real(b)),
            "render_value must keep distinct doubles distinct (identity key)",
        );
        // And the display formatter *does* round both to the same
        // string — which is exactly why it must not be used as a key.
        assert_eq!(format_real_display(a), format_real_display(b));
    }

    #[test]
    fn format_real_display_passes_through_non_finite() {
        assert_eq!(format_real_display(f64::INFINITY), "inf");
        assert_eq!(format_real_display(f64::NEG_INFINITY), "-inf");
        assert_eq!(format_real_display(f64::NAN), "NaN");
    }

    #[test]
    fn format_real_display_rounds_to_fifteen_significant_figures() {
        // 1/3 collapses to 15 sig figs (the noise past that is the
        // double's representation limit, not real precision).
        assert_eq!(format_real_display(1.0 / 3.0), "0.333333333333333");
    }

    fn col(name: &str, ty: Type) -> ColumnSpec {
        ColumnSpec::new(name, ty)
    }

    /// Convenience: a `serial`-PK table with a single `id` column.
    async fn make_id_table(db: &Database, name: &str) -> TableDescription {
        db.create_table(
            name.to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create table")
    }

    #[tokio::test]
    async fn open_in_memory_succeeds() {
        let _ = db();
    }

    #[tokio::test]
    async fn create_table_with_serial_pk_appears_in_list() {
        let db = db();
        make_id_table(&db, "Customers").await;
        let tables = db.list_tables().await.unwrap();
        assert_eq!(tables, vec!["Customers".to_string()]);
    }

    #[tokio::test]
    async fn create_table_with_serial_pk_describes_correctly() {
        let db = db();
        let desc = make_id_table(&db, "Customers").await;
        assert_eq!(desc.name, "Customers");
        assert_eq!(desc.columns.len(), 1);
        let id = &desc.columns[0];
        assert_eq!(id.name, "id");
        assert!(id.primary_key);
        assert_eq!(id.user_type, Some(Type::Serial));
        assert_eq!(id.sqlite_type.to_uppercase(), "INTEGER");
    }

    #[tokio::test]
    async fn create_table_with_text_pk_works() {
        let db = db();
        let desc = db
            .create_table(
                "Customers".to_string(),
                vec![col("email", Type::Text)],
                vec!["email".to_string()],
                None,
            )
            .await
            .unwrap();
        assert_eq!(desc.columns.len(), 1);
        assert_eq!(desc.columns[0].name, "email");
        assert_eq!(desc.columns[0].user_type, Some(Type::Text));
        assert_eq!(desc.columns[0].sqlite_type.to_uppercase(), "TEXT");
        assert!(desc.columns[0].primary_key);
    }

    #[tokio::test]
    async fn create_table_with_compound_pk_works() {
        let db = db();
        let desc = db
            .create_table(
                "OrderLines".to_string(),
                vec![col("order_id", Type::Int), col("product_id", Type::Int)],
                vec!["order_id".to_string(), "product_id".to_string()],
                None,
            )
            .await
            .unwrap();
        assert_eq!(desc.columns.len(), 2);
        assert!(desc.columns.iter().all(|c| c.primary_key));
    }

    #[tokio::test]
    async fn create_table_with_pedagogically_unusual_pk_type_still_works() {
        // The grammar lets users try anything; the DB layer just
        // does what they ask.
        let db = db();
        let desc = db
            .create_table(
                "T".to_string(),
                vec![col("flag", Type::Bool)],
                vec!["flag".to_string()],
                None,
            )
            .await
            .unwrap();
        assert!(desc.columns[0].primary_key);
    }

    #[tokio::test]
    async fn create_table_rejects_zero_columns() {
        let db = db();
        let err = db
            .create_table("T".to_string(), Vec::new(), Vec::new(), None)
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn create_table_rejects_an_internal_name() {
        // A new table may not take an internal `__rdbms_*` name — it would
        // be hidden from `list_tables`. The advanced-SQL path rejects this
        // at parse; the shared executor guards every other path (the
        // simple-mode DSL slot and `create m:n … as`, ADR-0045).
        let db = db();
        let err = db
            .create_table(
                "__rdbms_sneaky".to_string(),
                vec![col("id", Type::Int)],
                vec!["id".to_string()],
                None,
            )
            .await
            .unwrap_err();
        assert!(
            matches!(
                err,
                DbError::Sqlite {
                    kind: SqliteErrorKind::NoSuchTable,
                    ..
                }
            ),
            "got {err:?}"
        );
        assert!(db.list_tables().await.unwrap().is_empty());
    }

    #[tokio::test]
    async fn drop_table_removes_it_from_list() {
        let db = db();
        make_id_table(&db, "T").await;
        db.drop_table("T".to_string(), None).await.unwrap();
        let tables = db.list_tables().await.unwrap();
        assert!(tables.is_empty());
    }

    #[tokio::test]
    async fn add_column_appends_to_existing_table() {
        let db = db();
        make_id_table(&db, "Customers").await;
        let result = db
            .add_column(
                "Customers".to_string(),
                ColumnSpec::new("Name".to_string(), Type::Text),
                None,
            )
            .await
            .unwrap();
        let desc = &result.description;
        let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
        assert_eq!(names, vec!["id", "Name"]);
        let name_col = desc.columns.iter().find(|c| c.name == "Name").unwrap();
        assert_eq!(name_col.user_type, Some(Type::Text));
        assert_eq!(name_col.sqlite_type.to_uppercase(), "TEXT");
        assert!(
            result.client_side_notes.is_empty(),
            "no auto-fill for plain types"
        );
    }

    #[tokio::test]
    async fn user_facing_types_round_trip_through_metadata() {
        let db = db();
        // Create with a serial PK and add columns of every type
        // that would otherwise be erased by SQLite (date,
        // datetime, decimal — all backed by TEXT).
        make_id_table(&db, "T").await;
        for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
            db.add_column(
                "T".to_string(),
                ColumnSpec::new(format!("c_{ty}"), ty),
                None,
            )
            .await
            .unwrap();
        }
        let desc = db.describe_table("T".to_string()).await.unwrap();
        let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap();
        assert_eq!(id_col.user_type, Some(Type::Serial));
        for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
            let col_name = format!("c_{ty}");
            let c = desc.columns.iter().find(|c| c.name == col_name).unwrap();
            assert_eq!(c.user_type, Some(ty), "mismatch for {col_name}");
        }
    }

    #[tokio::test]
    async fn list_tables_excludes_internal_metadata_table() {
        let db = db();
        make_id_table(&db, "Visible").await;
        let tables = db.list_tables().await.unwrap();
        assert_eq!(tables, vec!["Visible".to_string()]);
        // Metadata table is present in the underlying schema but
        // hidden from list_tables.
    }

    #[tokio::test]
    async fn drop_table_clears_metadata_so_recreate_starts_fresh() {
        let db = db();
        // Create with a date column.
        db.create_table(
            "T".to_string(),
            vec![col("when", Type::Date)],
            vec!["when".to_string()],
            None,
        )
        .await
        .unwrap();
        let before = db.describe_table("T".to_string()).await.unwrap();
        assert_eq!(before.columns[0].user_type, Some(Type::Date));

        // Drop it.
        db.drop_table("T".to_string(), None).await.unwrap();

        // Recreate with a different type for the same-named column;
        // the metadata for the new table must reflect the new type
        // (i.e. metadata from the previous incarnation must not
        // bleed through).
        db.create_table(
            "T".to_string(),
            vec![col("when", Type::DateTime)],
            vec!["when".to_string()],
            None,
        )
        .await
        .unwrap();
        let after = db.describe_table("T".to_string()).await.unwrap();
        assert_eq!(after.columns[0].user_type, Some(Type::DateTime));
    }

    #[tokio::test]
    async fn add_column_for_each_value_type() {
        let db = db();
        make_id_table(&db, "T").await;
        for ty in [Type::Text, Type::Int, Type::Real, Type::Bool, Type::ShortId] {
            let col_name = format!("c_{ty}");
            db.add_column("T".to_string(), ColumnSpec::new(col_name.clone(), ty), None)
                .await
                .unwrap_or_else(|e| panic!("type {ty} failed: {e}"));
        }
        let desc = db.describe_table("T".to_string()).await.unwrap();
        // 5 user columns + the id PK column.
        assert_eq!(desc.columns.len(), 6);
    }

    #[tokio::test]
    async fn add_column_serial_to_empty_table_succeeds() {
        // ADR-0018 §6: serial / shortid via add_column are now
        // allowed. Empty-table case: column added, no auto-fill
        // [client-side] note (nothing to populate).
        let db = db();
        make_id_table(&db, "T").await;
        let result = db
            .add_column(
                "T".to_string(),
                ColumnSpec::new("code".to_string(), Type::Serial),
                None,
            )
            .await
            .unwrap();
        let code = result
            .description
            .columns
            .iter()
            .find(|c| c.name == "code")
            .expect("code column added");
        assert_eq!(code.user_type, Some(Type::Serial));
        assert!(
            !code.primary_key,
            "non-PK serial: column is not the PK (id remains the PK)"
        );
        assert!(
            result.client_side_notes.is_empty(),
            "empty table: no auto-fill note"
        );
    }

    /// Helper: build a table with `id` (serial PK) + `Name`
    /// (text) and insert N rows, populating just `Name`.
    async fn make_table_with_n_rows(db: &Database, table: &str, count: usize) {
        make_id_table(db, table).await;
        db.add_column(
            table.to_string(),
            ColumnSpec::new("Name".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        for i in 0..count {
            db.insert(
                table.to_string(),
                Some(vec!["Name".to_string()]),
                vec![Value::Text(format!("row{i}"))],
                None,
            )
            .await
            .unwrap();
        }
    }

    #[tokio::test]
    async fn add_column_serial_to_non_empty_table_auto_fills() {
        let db = db();
        make_table_with_n_rows(&db, "T", 3).await;
        let result = db
            .add_column(
                "T".to_string(),
                ColumnSpec::new("seq".to_string(), Type::Serial),
                None,
            )
            .await
            .unwrap();
        let seq = result
            .description
            .columns
            .iter()
            .find(|c| c.name == "seq")
            .unwrap();
        assert_eq!(seq.user_type, Some(Type::Serial));
        assert!(
            !result.client_side_notes.is_empty(),
            "auto-fill note expected"
        );
        assert!(
            result.client_side_notes[0].contains("3 row(s) given auto-generated serial"),
            "unexpected note: {:?}",
            result.client_side_notes
        );
        // Verify the column is populated 1..3.
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap();
        let mut filled: Vec<i64> = data
            .rows
            .iter()
            .filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
            .collect();
        filled.sort();
        assert_eq!(filled, vec![1, 2, 3]);
    }

    #[tokio::test]
    async fn add_column_shortid_to_non_empty_table_auto_fills() {
        let db = db();
        make_table_with_n_rows(&db, "T", 3).await;
        let result = db
            .add_column(
                "T".to_string(),
                ColumnSpec::new("tag".to_string(), Type::ShortId),
                None,
            )
            .await
            .unwrap();
        let tag = result
            .description
            .columns
            .iter()
            .find(|c| c.name == "tag")
            .unwrap();
        assert_eq!(tag.user_type, Some(Type::ShortId));
        assert!(
            !result.client_side_notes.is_empty(),
            "auto-fill note expected"
        );
        assert!(
            result.client_side_notes[0].contains("3 row(s) given auto-generated shortid"),
            "unexpected note: {:?}",
            result.client_side_notes
        );
        // Verify each row has a non-null shortid value.
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap();
        for row in &data.rows {
            let v = row[tag_idx].as_ref().expect("non-null shortid auto-filled");
            assert!(
                v.len() >= 10 && v.len() <= 12,
                "shortid length out of range: {v}"
            );
        }
    }

    #[tokio::test]
    async fn insert_auto_fills_non_pk_serial_column_sequentially() {
        // ADR-0018 §5: non-PK serial columns get MAX(col)+1
        // automatically when the user omits them. PK serial
        // columns continue to use SQLite's rowid alias.
        let db = db();
        make_table_with_n_rows(&db, "T", 0).await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("seq".to_string(), Type::Serial),
            None,
        )
        .await
        .unwrap();
        // Insert three rows providing only `Name`. The seq
        // column should auto-fill 1, 2, 3.
        for n in ["a", "b", "c"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Name".to_string()]),
                vec![Value::Text(n.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap();
        let mut values: Vec<i64> = data
            .rows
            .iter()
            .filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
            .collect();
        values.sort();
        assert_eq!(values, vec![1, 2, 3]);
    }

    #[tokio::test]
    async fn insert_non_pk_serial_continues_past_explicit_value() {
        // If the user explicitly inserts a high value, MAX+1
        // sequencing jumps past it. Gappy sequences are
        // accepted (ADR-0018 Resolution 2).
        let db = db();
        make_table_with_n_rows(&db, "T", 0).await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("seq".to_string(), Type::Serial),
            None,
        )
        .await
        .unwrap();
        // Insert with explicit seq=100.
        db.insert(
            "T".to_string(),
            Some(vec!["Name".to_string(), "seq".to_string()]),
            vec![
                Value::Text("a".to_string()),
                Value::Number("100".to_string()),
            ],
            None,
        )
        .await
        .unwrap();
        // Next omitted-seq insert should auto-fill 101.
        db.insert(
            "T".to_string(),
            Some(vec!["Name".to_string()]),
            vec![Value::Text("b".to_string())],
            None,
        )
        .await
        .unwrap();
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap();
        let mut values: Vec<i64> = data
            .rows
            .iter()
            .filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
            .collect();
        values.sort();
        assert_eq!(values, vec![100, 101]);
    }

    #[tokio::test]
    async fn add_column_serial_emits_unique_constraint() {
        // Non-PK serial gains UNIQUE per ADR-0018 §4. Verify by
        // attempting to UPDATE all rows to the same value and
        // confirming the engine refuses.
        let db = db();
        make_table_with_n_rows(&db, "T", 2).await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("seq".to_string(), Type::Serial),
            None,
        )
        .await
        .unwrap();
        // Attempt to UPDATE one row to have the same `seq` value
        // as the other — should violate UNIQUE.
        let err = db
            .update(
                "T".to_string(),
                vec![("seq".to_string(), Value::Number("1".to_string()))],
                RowFilter::AllRows,
                None,
            )
            .await
            .unwrap_err();
        // SQLite reports as a constraint violation; classified
        // as UniqueViolation.
        match err {
            DbError::Sqlite { kind, .. } => {
                assert_eq!(kind, SqliteErrorKind::UniqueViolation);
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[tokio::test]
    async fn create_table_duplicate_returns_already_exists() {
        let db = db();
        make_id_table(&db, "T").await;
        let err = db
            .create_table(
                "T".to_string(),
                vec![col("id", Type::Serial)],
                vec!["id".to_string()],
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::AlreadyExists),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[tokio::test]
    async fn drop_nonexistent_table_returns_no_such_table() {
        let db = db();
        let err = db.drop_table("Ghost".to_string(), None).await.unwrap_err();
        match err {
            DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[tokio::test]
    async fn add_column_to_missing_table_returns_no_such_table() {
        let db = db();
        let err = db
            .add_column(
                "Ghost".to_string(),
                ColumnSpec::new("x".to_string(), Type::Text),
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    // --- drop_column / rename_column / change_column_type ---

    #[tokio::test]
    async fn drop_column_removes_column_and_data() {
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Score".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.insert(
            "T".to_string(),
            None,
            vec![Value::Number("42".to_string())],
            None,
        )
        .await
        .unwrap();

        let result = db
            .drop_column("T".to_string(), "Score".to_string(), false, None)
            .await
            .unwrap();
        let names: Vec<_> = result
            .description
            .columns
            .iter()
            .map(|c| c.name.as_str())
            .collect();
        assert_eq!(names, vec!["id"]);

        // Row data still accessible (id was preserved); the
        // dropped column is gone from the projection.
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        assert_eq!(data.columns, vec!["id".to_string()]);
        assert_eq!(data.rows.len(), 1);
    }

    #[tokio::test]
    async fn drop_column_refuses_primary_key() {
        let db = db();
        make_id_table(&db, "T").await;
        let err = db
            .drop_column("T".to_string(), "id".to_string(), false, None)
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
        let msg = format!("{err}");
        assert!(msg.to_lowercase().contains("primary"), "{msg}");
    }

    #[tokio::test]
    async fn drop_column_refuses_column_in_a_relationship() {
        let db = db();
        // Customers(id PK) ← Orders(cust_id FK)
        make_id_table(&db, "Customers").await;
        make_id_table(&db, "Orders").await;
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("cust_id".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["cust_id".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        // Try to drop the FK column on the child side.
        let err = db
            .drop_column("Orders".to_string(), "cust_id".to_string(), false, None)
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
        assert!(format!("{err}").contains("relationship"));
    }

    #[tokio::test]
    async fn drop_column_for_missing_column_errors() {
        let db = db();
        make_id_table(&db, "T").await;
        let err = db
            .drop_column("T".to_string(), "Ghost".to_string(), false, None)
            .await
            .unwrap_err();
        match err {
            DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchColumn),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    // --- indexes (ADR-0025) -----------------------------------

    /// A `serial`-PK table with one extra text `Email` column —
    /// something indexable.
    async fn make_indexable_table(db: &Database, name: &str) {
        make_id_table(db, name).await;
        db.add_column(
            name.to_string(),
            ColumnSpec::new("Email".to_string(), Type::Text),
            None,
        )
        .await
        .expect("add Email column");
    }

    #[tokio::test]
    async fn add_index_appears_in_description() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        let desc = db
            .add_index(
                Some("idx_email".to_string()),
                "Customers".to_string(),
                vec!["Email".to_string()],
                None,
            )
            .await
            .expect("add index");
        assert_eq!(desc.indexes.len(), 1);
        assert_eq!(desc.indexes[0].name, "idx_email");
        assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]);
    }

    #[tokio::test]
    async fn add_index_auto_generates_name() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        let desc = db
            .add_index(
                None,
                "Customers".to_string(),
                vec!["Email".to_string()],
                None,
            )
            .await
            .expect("add index");
        assert_eq!(desc.indexes[0].name, "Customers_Email_idx");
    }

    #[tokio::test]
    async fn add_index_composite_auto_name_joins_columns() {
        let db = db();
        make_id_table(&db, "Orders").await;
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("CustId".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("Day".to_string(), Type::Date),
            None,
        )
        .await
        .unwrap();
        let desc = db
            .add_index(
                None,
                "Orders".to_string(),
                vec!["CustId".to_string(), "Day".to_string()],
                None,
            )
            .await
            .expect("add index");
        assert_eq!(desc.indexes[0].name, "Orders_CustId_Day_idx");
        assert_eq!(
            desc.indexes[0].columns,
            vec!["CustId".to_string(), "Day".to_string()]
        );
    }

    #[tokio::test]
    async fn add_index_rejects_duplicate_name() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        db.add_column(
            "Customers".to_string(),
            ColumnSpec::new("Nick".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        db.add_index(
            Some("idx".to_string()),
            "Customers".to_string(),
            vec!["Email".to_string()],
            None,
        )
        .await
        .unwrap();
        let err = db
            .add_index(
                Some("idx".to_string()),
                "Customers".to_string(),
                vec!["Nick".to_string()],
                None,
            )
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn add_index_rejects_redundant_column_set() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        db.add_index(
            Some("a".to_string()),
            "Customers".to_string(),
            vec!["Email".to_string()],
            None,
        )
        .await
        .unwrap();
        let err = db
            .add_index(
                Some("b".to_string()),
                "Customers".to_string(),
                vec!["Email".to_string()],
                None,
            )
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn add_index_rejects_missing_column() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        let err = db
            .add_index(
                None,
                "Customers".to_string(),
                vec!["Ghost".to_string()],
                None,
            )
            .await
            .unwrap_err();
        assert!(
            matches!(
                err,
                DbError::Sqlite {
                    kind: SqliteErrorKind::NoSuchColumn,
                    ..
                }
            ),
            "got {err:?}"
        );
    }

    #[tokio::test]
    async fn add_index_rejects_missing_table() {
        let db = db();
        let err = db
            .add_index(None, "Ghost".to_string(), vec!["x".to_string()], None)
            .await
            .unwrap_err();
        assert!(
            matches!(
                err,
                DbError::Sqlite {
                    kind: SqliteErrorKind::NoSuchTable,
                    ..
                }
            ),
            "got {err:?}"
        );
    }

    #[tokio::test]
    async fn drop_index_by_name_removes_it() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        db.add_index(
            Some("idx_email".to_string()),
            "Customers".to_string(),
            vec!["Email".to_string()],
            None,
        )
        .await
        .unwrap();
        let desc = db
            .drop_index(
                IndexSelector::Named {
                    name: "idx_email".to_string(),
                },
                None,
            )
            .await
            .expect("drop index");
        assert!(desc.indexes.is_empty());
    }

    #[tokio::test]
    async fn drop_index_by_columns_removes_it() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        db.add_index(
            None,
            "Customers".to_string(),
            vec!["Email".to_string()],
            None,
        )
        .await
        .unwrap();
        let desc = db
            .drop_index(
                IndexSelector::Columns {
                    table: "Customers".to_string(),
                    columns: vec!["Email".to_string()],
                },
                None,
            )
            .await
            .expect("drop index");
        assert!(desc.indexes.is_empty());
    }

    #[tokio::test]
    async fn drop_index_unknown_name_errors() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        let err = db
            .drop_index(
                IndexSelector::Named {
                    name: "nope".to_string(),
                },
                None,
            )
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Sqlite { .. }), "got {err:?}");
    }

    #[tokio::test]
    async fn drop_column_refuses_indexed_column_without_cascade() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        db.add_index(
            Some("idx_email".to_string()),
            "Customers".to_string(),
            vec!["Email".to_string()],
            None,
        )
        .await
        .unwrap();
        let err = db
            .drop_column("Customers".to_string(), "Email".to_string(), false, None)
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
        assert!(format!("{err}").contains("idx_email"), "got {err}");
    }

    #[tokio::test]
    async fn drop_column_cascade_drops_covering_index() {
        let db = db();
        make_indexable_table(&db, "Customers").await;
        db.add_index(
            Some("idx_email".to_string()),
            "Customers".to_string(),
            vec!["Email".to_string()],
            None,
        )
        .await
        .unwrap();
        let result = db
            .drop_column("Customers".to_string(), "Email".to_string(), true, None)
            .await
            .expect("drop column --cascade");
        assert_eq!(result.dropped_indexes, vec!["idx_email".to_string()]);
        assert!(result.description.indexes.is_empty());
        assert!(result.description.columns.iter().all(|c| c.name != "Email"),);
    }

    #[tokio::test]
    async fn rebuild_table_preserves_indexes() {
        // `change column` rebuilds the table; an index on an
        // unrelated column must survive the rebuild (ADR-0025).
        let db = db();
        make_indexable_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Score".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.add_index(
            Some("idx_email".to_string()),
            "T".to_string(),
            vec!["Email".to_string()],
            None,
        )
        .await
        .unwrap();
        let result = db
            .change_column_type(
                "T".to_string(),
                "Score".to_string(),
                Type::Real,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .expect("change column type");
        assert_eq!(result.description.indexes.len(), 1);
        assert_eq!(result.description.indexes[0].name, "idx_email");
        assert_eq!(
            result.description.indexes[0].columns,
            vec!["Email".to_string()]
        );
    }

    #[tokio::test]
    async fn rename_column_updates_schema_and_metadata() {
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Old".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        let desc = db
            .rename_column("T".to_string(), "Old".to_string(), "New".to_string(), None)
            .await
            .unwrap();
        let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
        assert_eq!(names, vec!["id", "New"]);
        // user_type metadata was preserved through the rename.
        let new_col = desc.columns.iter().find(|c| c.name == "New").unwrap();
        assert_eq!(new_col.user_type, Some(Type::Text));
    }

    #[tokio::test]
    async fn rename_column_propagates_to_relationship_metadata() {
        let db = db();
        make_id_table(&db, "Customers").await;
        make_id_table(&db, "Orders").await;
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("cust_id".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["cust_id".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        // Rename on the child side; SQLite cascades the FK
        // declaration, and we mirror that into our metadata.
        db.rename_column(
            "Orders".to_string(),
            "cust_id".to_string(),
            "buyer_id".to_string(),
            None,
        )
        .await
        .unwrap();
        let orders = db.describe_table("Orders".to_string()).await.unwrap();
        let outbound = orders
            .outbound_relationships
            .iter()
            .find(|r| r.local_columns == vec!["buyer_id".to_string()]);
        assert!(
            outbound.is_some(),
            "expected outbound rel on `buyer_id`, got {:?}",
            orders.outbound_relationships,
        );

        // Same from the parent perspective via inbound.
        let customers = db.describe_table("Customers".to_string()).await.unwrap();
        let inbound = customers
            .inbound_relationships
            .iter()
            .find(|r| r.other_columns == vec!["buyer_id".to_string()]);
        assert!(
            inbound.is_some(),
            "expected inbound rel referencing `buyer_id`, got {:?}",
            customers.inbound_relationships,
        );
    }

    #[tokio::test]
    async fn rename_column_refuses_collision() {
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("A".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("B".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        let err = db
            .rename_column("T".to_string(), "A".to_string(), "B".to_string(), None)
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn rename_column_refuses_identity_rename() {
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("A".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        let err = db
            .rename_column("T".to_string(), "A".to_string(), "A".to_string(), None)
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn change_column_type_works_for_compatible_data() {
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Score".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        // Insert numeric-looking strings.
        for v in ["1", "2", "3"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Score".to_string()]),
                vec![Value::Text(v.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .change_column_type(
                "T".to_string(),
                "Score".to_string(),
                Type::Int,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .unwrap();
        let score = result
            .description
            .columns
            .iter()
            .find(|c| c.name == "Score")
            .unwrap();
        assert_eq!(score.user_type, Some(Type::Int));
        // Per ADR-0017 §6, the [client-side] note fires when
        // any cell was rewritten (storage class change counts).
        let note = result
            .client_side
            .expect("text -> int rewrites every cell; expected client-side note");
        assert_eq!(note.transformed, 3);
        assert_eq!(note.lossy, 0);
        // Data preserved via the per-cell transformer.
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        assert_eq!(data.rows.len(), 3);
    }

    #[tokio::test]
    async fn change_column_type_pk_without_inbound_fk_allowed() {
        // ADR-0017 §4.1: a PK without inbound FKs may have its
        // type changed freely (no cascade to worry about).
        let db = db();
        make_id_table(&db, "T").await;
        // serial -> int (canonical "drop auto-increment" case).
        db.change_column_type(
            "T".to_string(),
            "id".to_string(),
            Type::Int,
            ChangeColumnMode::Default,
            None,
        )
        .await
        .expect("serial -> int on PK without inbound FK should succeed");
    }

    #[tokio::test]
    async fn change_column_type_pk_with_inbound_fk_refused_when_target_type_changes() {
        // PK referenced by another table's FK; new type would
        // change `fk_target_type` (serial -> text means
        // fk_target_type goes Int -> Text). Refused.
        let db = db();
        make_id_table(&db, "Customers").await;
        make_id_table(&db, "Orders").await;
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("cust_id".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["cust_id".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        let err = db
            .change_column_type(
                "Customers".to_string(),
                "id".to_string(),
                Type::Text,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(message) => {
                assert!(
                    message.contains("referenced") || message.contains("relationship"),
                    "expected FK-cascade language: {message}"
                );
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[tokio::test]
    async fn change_column_type_pk_with_inbound_fk_allowed_when_target_type_preserved() {
        // PK referenced by another table's FK; serial -> int
        // preserves `fk_target_type` (both Int). Allowed.
        let db = db();
        make_id_table(&db, "Customers").await;
        make_id_table(&db, "Orders").await;
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("cust_id".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["cust_id".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        db.change_column_type(
            "Customers".to_string(),
            "id".to_string(),
            Type::Int,
            ChangeColumnMode::Default,
            None,
        )
        .await
        .expect("serial -> int on FK-referenced PK should succeed");
    }

    // ---- ADR-0017 dry-run / mode / uniqueness coverage ----

    #[tokio::test]
    async fn change_column_type_refuses_lossy_by_default() {
        // real -> int with fractional values: every cell is
        // Lossy. Default mode refuses with a friendly diagnostic
        // table.
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Score".to_string(), Type::Real),
            None,
        )
        .await
        .unwrap();
        for v in ["3.14", "2.71"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Score".to_string()]),
                vec![Value::Number(v.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let err = db
            .change_column_type(
                "T".to_string(),
                "Score".to_string(),
                Type::Int,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(message) => {
                assert!(
                    message.contains("discard information"),
                    "expected lossy header: {message}"
                );
                assert!(
                    message.contains("--force-conversion"),
                    "expected hint about --force-conversion: {message}"
                );
                // Bordered diagnostic table is present.
                assert!(message.contains('┌'), "expected bordered table: {message}");
                assert!(
                    message.contains("(PK)"),
                    "expected PK header marker: {message}"
                );
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[tokio::test]
    async fn change_column_type_force_conversion_accepts_lossy() {
        // Same setup as above, but with --force-conversion the
        // change succeeds and the [client-side] note carries
        // the lossy count.
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Score".to_string(), Type::Real),
            None,
        )
        .await
        .unwrap();
        for v in ["3.14", "2.71", "5.0"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Score".to_string()]),
                vec![Value::Number(v.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .change_column_type(
                "T".to_string(),
                "Score".to_string(),
                Type::Int,
                ChangeColumnMode::ForceConversion,
                None,
            )
            .await
            .expect("--force-conversion should accept lossy rows");
        let note = result.client_side.expect("transformations happened");
        assert_eq!(note.transformed, 3);
        // Two of three rows are fractional (lossy); 5.0 is clean.
        assert_eq!(note.lossy, 2);
    }

    #[tokio::test]
    async fn change_column_type_refuses_incompatible_even_with_force() {
        // text "abc" -> int: incompatible. --force-conversion
        // does NOT help (per ADR-0017 §5 / §2 step 3).
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Note".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        for v in ["abc", "123", "xyz"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Note".to_string()]),
                vec![Value::Text(v.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] {
            let err = db
                .change_column_type("T".to_string(), "Note".to_string(), Type::Int, mode, None)
                .await
                .unwrap_err();
            match err {
                DbError::Unsupported(message) => {
                    assert!(
                        message.contains("cannot be converted"),
                        "expected incompatible header for {mode:?}: {message}"
                    );
                    assert!(
                        !message.contains("--force-conversion"),
                        "incompatible refusal must NOT mention --force-conversion: {message}"
                    );
                }
                other => panic!("unexpected ({mode:?}): {other:?}"),
            }
        }
    }

    #[tokio::test]
    async fn change_column_type_int_to_bool_with_zero_one_succeeds() {
        // ADR-0017 §3 matrix: (Int, Bool) is per-cell-classified.
        // Values 0/1 are Clean (storage class doesn't change); the
        // transformer returns Value::Integer(0)/(1) unchanged, so
        // is_non_identity is false for every cell. No
        // [client-side] note is expected.
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Flag".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        for v in ["0", "1", "0"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Flag".to_string()]),
                vec![Value::Number(v.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .change_column_type(
                "T".to_string(),
                "Flag".to_string(),
                Type::Bool,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .expect("int -> bool with all 0/1 values should succeed");
        let flag = result
            .description
            .columns
            .iter()
            .find(|c| c.name == "Flag")
            .unwrap();
        assert_eq!(flag.user_type, Some(Type::Bool));
        assert!(
            result.client_side.is_none(),
            "int -> bool with values that map identity should not fire a client-side note: {:?}",
            result.client_side
        );
        // Data preserved.
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        assert_eq!(data.rows.len(), 3);
    }

    #[tokio::test]
    async fn change_column_type_int_to_bool_refuses_other_values() {
        // ADR-0017 §3 matrix: (Int, Bool) classifies any value
        // other than 0/1 as Incompatible. A single offending row
        // triggers a refusal, and --force-conversion does not
        // help (incompatible is not lossy).
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Flag".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        for v in ["0", "1", "2"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Flag".to_string()]),
                vec![Value::Number(v.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] {
            let err = db
                .change_column_type("T".to_string(), "Flag".to_string(), Type::Bool, mode, None)
                .await
                .unwrap_err();
            match err {
                DbError::Unsupported(message) => {
                    assert!(
                        message.contains("cannot be converted"),
                        "expected incompatible header for {mode:?}: {message}"
                    );
                    assert!(
                        message.contains("not 0 or 1"),
                        "expected per-cell reason naming the offending value for {mode:?}: {message}"
                    );
                    assert!(
                        !message.contains("--force-conversion"),
                        "incompatible refusal must NOT advertise --force-conversion for {mode:?}: {message}"
                    );
                }
                other => panic!("unexpected ({mode:?}): {other:?}"),
            }
        }
    }

    #[tokio::test]
    async fn change_column_type_dont_convert_skips_client_side() {
        // text -> int: under the per-cell matrix, "1"/"2"/"3"
        // would all classify Clean and fire the [client-side]
        // note. With --dont-convert we bypass that and rely on
        // engine coercion; the note is suppressed.
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Score".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        for v in ["1", "2", "3"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Score".to_string()]),
                vec![Value::Text(v.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .change_column_type(
                "T".to_string(),
                "Score".to_string(),
                Type::Int,
                ChangeColumnMode::DontConvert,
                None,
            )
            .await
            .expect("text->int with all-clean cells should succeed under --dont-convert");
        assert!(
            result.client_side.is_none(),
            "--dont-convert must NOT emit a client-side note"
        );
    }

    #[tokio::test]
    async fn change_column_type_uniqueness_collision_on_pk_refused() {
        // PK column with values 3.14 and 3.7: real -> int with
        // --force-conversion would collapse both to 3, violating
        // PK uniqueness. ADR-0017 §4.3: this is incompatible
        // even under --force-conversion.
        let db = db();
        db.create_table(
            "T".to_string(),
            vec![col("id", Type::Real)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        for v in ["3.14", "3.7"] {
            db.insert(
                "T".to_string(),
                Some(vec!["id".to_string()]),
                vec![Value::Number(v.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let err = db
            .change_column_type(
                "T".to_string(),
                "id".to_string(),
                Type::Int,
                ChangeColumnMode::ForceConversion,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(message) => {
                assert!(
                    message.contains("collision") && message.contains("uniqueness"),
                    "expected collision diagnostic: {message}"
                );
                assert!(message.contains('┌'), "expected bordered table: {message}");
                // Source rows column with PK column name in the header.
                assert!(
                    message.contains("Source rows (id)"),
                    "expected PK-aware Source rows header: {message}"
                );
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[tokio::test]
    async fn change_column_type_outbound_fk_refused() {
        // Child-side FK column: §4.2 says always refuse,
        // regardless of target type.
        let db = db();
        make_id_table(&db, "Customers").await;
        make_id_table(&db, "Orders").await;
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("cust_id".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["cust_id".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        let err = db
            .change_column_type(
                "Orders".to_string(),
                "cust_id".to_string(),
                Type::Real,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(message) => {
                assert!(
                    message.contains("foreign key") || message.contains("relationship"),
                    "expected outbound-FK refusal: {message}"
                );
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[tokio::test]
    async fn change_column_type_no_op_when_no_rows() {
        // Empty table: no cells to transform, no [client-side]
        // note, and the structural change goes through.
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("Note".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        let result = db
            .change_column_type(
                "T".to_string(),
                "Note".to_string(),
                Type::Int,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .expect("empty table should still allow the type change");
        assert!(
            result.client_side.is_none(),
            "no rows means no client-side note"
        );
        let note_col = result
            .description
            .columns
            .iter()
            .find(|c| c.name == "Note")
            .unwrap();
        assert_eq!(note_col.user_type, Some(Type::Int));
    }

    #[tokio::test]
    async fn change_column_type_int_to_serial_succeeds() {
        // ADR-0018 §8: int → serial joins the matrix. The
        // column gains UNIQUE (since it's non-PK) and the
        // existing values are preserved.
        let db = db();
        make_table_with_n_rows(&db, "T", 0).await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("code".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        // Insert a few rows with explicit code values.
        for (i, code) in [(1, 10), (2, 20), (3, 30)] {
            db.insert(
                "T".to_string(),
                Some(vec!["Name".to_string(), "code".to_string()]),
                vec![
                    Value::Text(format!("row{i}")),
                    Value::Number(code.to_string()),
                ],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .change_column_type(
                "T".to_string(),
                "code".to_string(),
                Type::Serial,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .expect("int -> serial should succeed");
        let code = result
            .description
            .columns
            .iter()
            .find(|c| c.name == "code")
            .unwrap();
        assert_eq!(code.user_type, Some(Type::Serial));
        // No null cells, no transformation: no [client-side]
        // note (storage-class change for int -> serial is
        // identity at the value level).
        assert!(
            result.client_side.is_none(),
            "int -> serial with all non-null values is a metadata-only change"
        );
    }

    #[tokio::test]
    async fn change_column_type_int_to_serial_refuses_duplicate_values() {
        // Duplicate non-null values would violate the UNIQUE
        // contract serial gains. Refused via the existing
        // uniqueness-collision diagnostic.
        let db = db();
        make_table_with_n_rows(&db, "T", 0).await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("code".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        // Two rows with the same code.
        for i in 0..2 {
            db.insert(
                "T".to_string(),
                Some(vec!["Name".to_string(), "code".to_string()]),
                vec![
                    Value::Text(format!("row{i}")),
                    Value::Number("7".to_string()),
                ],
                None,
            )
            .await
            .unwrap();
        }
        let err = db
            .change_column_type(
                "T".to_string(),
                "code".to_string(),
                Type::Serial,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(message) => {
                assert!(
                    message.contains("collision") || message.contains("uniqueness"),
                    "expected uniqueness diagnostic: {message}"
                );
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[tokio::test]
    async fn change_column_type_text_to_serial_routes_via_int_hint() {
        // Per ADR-0018 §8, only int → serial is supported
        // directly. Other source types are refused with a hint
        // to route through int.
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("A".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        let err = db
            .change_column_type(
                "T".to_string(),
                "A".to_string(),
                Type::Serial,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(message) => {
                assert!(
                    message.contains("int"),
                    "expected hint about routing via int: {message}"
                );
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[tokio::test]
    async fn change_column_type_int_to_serial_with_nulls_auto_fills() {
        // Per ADR-0018 §3 / §7, null cells get sequence values
        // continuing from MAX of non-null values. The
        // [client-side] note reports the auto-fill count.
        let db = db();
        make_table_with_n_rows(&db, "T", 0).await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("code".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        // Three rows: one with code=5, two with NULL.
        db.insert(
            "T".to_string(),
            Some(vec!["Name".to_string(), "code".to_string()]),
            vec![Value::Text("a".to_string()), Value::Number("5".to_string())],
            None,
        )
        .await
        .unwrap();
        for n in ["b", "c"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Name".to_string()]),
                vec![Value::Text(n.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .change_column_type(
                "T".to_string(),
                "code".to_string(),
                Type::Serial,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .expect("int -> serial with auto-fill should succeed");
        let note = result
            .client_side
            .expect("auto-fill should produce a client-side note");
        assert_eq!(note.auto_filled, 2);
        assert_eq!(note.auto_fill_kind, Some(AutoFillKind::Serial));
        // Confirm the filled values: existing 5, fills are 6
        // and 7 (continue sequence from MAX+1).
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        let code_idx = data.columns.iter().position(|c| c == "code").unwrap();
        let mut values: Vec<i64> = data
            .rows
            .iter()
            .filter_map(|r| r[code_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
            .collect();
        values.sort();
        assert_eq!(values, vec![5, 6, 7]);
    }

    #[tokio::test]
    async fn change_column_type_text_to_shortid_with_nulls_auto_fills() {
        // text → shortid is in the matrix; null cells in the
        // text column get fresh shortids (ADR-0018 §3).
        let db = db();
        make_table_with_n_rows(&db, "T", 0).await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("tag".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        // One row with a valid shortid value, two with NULL.
        db.insert(
            "T".to_string(),
            Some(vec!["Name".to_string(), "tag".to_string()]),
            vec![
                Value::Text("a".to_string()),
                Value::Text("23456789Ab".to_string()),
            ],
            None,
        )
        .await
        .unwrap();
        for n in ["b", "c"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Name".to_string()]),
                vec![Value::Text(n.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .change_column_type(
                "T".to_string(),
                "tag".to_string(),
                Type::ShortId,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .expect("text -> shortid with auto-fill should succeed");
        let note = result
            .client_side
            .expect("auto-fill should produce a client-side note");
        assert_eq!(note.auto_filled, 2);
        assert_eq!(note.auto_fill_kind, Some(AutoFillKind::ShortId));
        // All three rows now have valid shortids.
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap();
        for row in &data.rows {
            let v = row[tag_idx].as_ref().expect("non-null shortid after fill");
            assert!(v.len() >= 10 && v.len() <= 12, "len out of range: {v}");
        }
    }

    #[tokio::test]
    async fn change_column_type_refuses_relationship_column() {
        let db = db();
        make_id_table(&db, "Customers").await;
        make_id_table(&db, "Orders").await;
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("cust_id".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["cust_id".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        let err = db
            .change_column_type(
                "Orders".to_string(),
                "cust_id".to_string(),
                Type::Text,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .unwrap_err();
        let DbError::Unsupported(msg) = &err else {
            panic!("expected Unsupported, got {err:?}");
        };
        // The refusal explains the FK link, not just that it failed
        // (ADR-0035 Amendment 1, gap D).
        assert!(
            msg.contains("uses it as a foreign key"),
            "explains the FK link; got: {msg}"
        );
    }

    #[tokio::test]
    async fn change_column_type_no_op_to_same_type_errors() {
        let db = db();
        make_id_table(&db, "T").await;
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("A".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        let err = db
            .change_column_type(
                "T".to_string(),
                "A".to_string(),
                Type::Int,
                ChangeColumnMode::Default,
                None,
            )
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn describe_missing_table_returns_no_such_table() {
        let db = db();
        let err = db.describe_table("Ghost".to_string()).await.unwrap_err();
        match err {
            DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    // --- Relationship tests ---

    async fn customers_orders_setup(db: &Database) {
        db.create_table(
            "Customers".to_string(),
            vec![col("id", Type::Serial), col("Name", Type::Text)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.create_table(
            "Orders".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("CustId".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn add_relationship_appears_outbound_on_child() {
        let db = db();
        customers_orders_setup(&db).await;
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        let orders = db.describe_table("Orders".to_string()).await.unwrap();
        assert_eq!(orders.outbound_relationships.len(), 1);
        let rel = &orders.outbound_relationships[0];
        assert_eq!(rel.local_columns, vec!["CustId".to_string()]);
        assert_eq!(rel.other_table, "Customers");
        assert_eq!(rel.other_columns, vec!["id".to_string()]);
        assert_eq!(rel.name, "Customers_id_to_Orders_CustId");
    }

    #[tokio::test]
    async fn add_relationship_appears_inbound_on_parent() {
        let db = db();
        customers_orders_setup(&db).await;
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        let customers = db.describe_table("Customers".to_string()).await.unwrap();
        assert_eq!(customers.inbound_relationships.len(), 1);
        let rel = &customers.inbound_relationships[0];
        assert_eq!(rel.local_columns, vec!["id".to_string()]);
        assert_eq!(rel.other_table, "Orders");
        assert_eq!(rel.other_columns, vec!["CustId".to_string()]);
    }

    #[tokio::test]
    async fn add_relationship_with_user_supplied_name() {
        let db = db();
        customers_orders_setup(&db).await;
        db.add_relationship(
            Some("cust_orders".to_string()),
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::Cascade,
            ReferentialAction::SetNull,
            false,
            None,
        )
        .await
        .unwrap();
        let orders = db.describe_table("Orders".to_string()).await.unwrap();
        let rel = &orders.outbound_relationships[0];
        assert_eq!(rel.name, "cust_orders");
        assert_eq!(rel.on_delete, ReferentialAction::Cascade);
        assert_eq!(rel.on_update, ReferentialAction::SetNull);
    }

    #[tokio::test]
    async fn add_relationship_with_create_fk_creates_the_column() {
        let db = db();
        // Create child *without* the FK column.
        db.create_table(
            "Customers".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.create_table(
            "Orders".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();

        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            true, // --create-fk
            None,
        )
        .await
        .unwrap();
        let orders = db.describe_table("Orders".to_string()).await.unwrap();
        // The auto-created FK column has user_type Int (Serial's
        // fk_target_type), not Serial.
        let cust = orders
            .columns
            .iter()
            .find(|c| c.name == "CustId")
            .expect("FK column auto-created");
        assert_eq!(cust.user_type, Some(Type::Int));
        assert_eq!(orders.outbound_relationships.len(), 1);
    }

    #[tokio::test]
    async fn add_relationship_without_create_fk_errors_when_column_missing() {
        let db = db();
        db.create_table(
            "Customers".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.create_table(
            "Orders".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        let err = db
            .add_relationship(
                None,
                "Customers".to_string(),
                vec!["id".to_string()],
                "Orders".to_string(),
                vec!["CustId".to_string()],
                ReferentialAction::NoAction,
                ReferentialAction::NoAction,
                false,
                None,
            )
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn add_relationship_with_type_mismatch_errors_with_advice() {
        let db = db();
        db.create_table(
            "Customers".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.create_table(
            "Orders".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        // Wrong type — text instead of int.
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("CustId".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();

        let err = db
            .add_relationship(
                None,
                "Customers".to_string(),
                vec!["id".to_string()],
                "Orders".to_string(),
                vec!["CustId".to_string()],
                ReferentialAction::NoAction,
                ReferentialAction::NoAction,
                false,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(msg) => {
                assert!(msg.contains("type mismatch"), "{msg}");
                assert!(msg.contains("text") && msg.contains("int"), "{msg}");
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[tokio::test]
    async fn add_relationship_against_non_pk_errors() {
        let db = db();
        // Customers has a non-PK column we'll try to point at.
        db.create_table(
            "Customers".to_string(),
            vec![col("id", Type::Serial), col("Name", Type::Text)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.create_table(
            "Orders".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("CustName".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        let err = db
            .add_relationship(
                None,
                "Customers".to_string(),
                vec!["Name".to_string()],
                "Orders".to_string(),
                vec!["CustName".to_string()],
                ReferentialAction::NoAction,
                ReferentialAction::NoAction,
                false,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(msg) => assert!(msg.contains("primary key"), "{msg}"),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[tokio::test]
    async fn drop_relationship_by_name_clears_both_sides() {
        let db = db();
        customers_orders_setup(&db).await;
        db.add_relationship(
            Some("cust_orders".to_string()),
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        db.drop_relationship(
            RelationshipSelector::Named {
                name: "cust_orders".to_string(),
            },
            None,
        )
        .await
        .unwrap();
        let orders = db.describe_table("Orders".to_string()).await.unwrap();
        let customers = db.describe_table("Customers".to_string()).await.unwrap();
        assert!(orders.outbound_relationships.is_empty());
        assert!(customers.inbound_relationships.is_empty());
    }

    #[tokio::test]
    async fn drop_relationship_by_endpoints_works() {
        let db = db();
        customers_orders_setup(&db).await;
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        db.drop_relationship(
            RelationshipSelector::Endpoints {
                parent_table: "Customers".to_string(),
                parent_column: "id".to_string(),
                child_table: "Orders".to_string(),
                child_column: "CustId".to_string(),
            },
            None,
        )
        .await
        .unwrap();
        let orders = db.describe_table("Orders".to_string()).await.unwrap();
        assert!(orders.outbound_relationships.is_empty());
    }

    #[tokio::test]
    async fn drop_table_with_inbound_relationship_errors() {
        let db = db();
        customers_orders_setup(&db).await;
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        let err = db
            .drop_table("Customers".to_string(), None)
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(msg) => {
                assert!(msg.contains("referenced by"), "{msg}");
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[tokio::test]
    async fn drop_child_table_cleans_relationship_metadata() {
        let db = db();
        customers_orders_setup(&db).await;
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        // Dropping the child is allowed (no inbound relationships
        // on Orders) and cleans the metadata.
        db.drop_table("Orders".to_string(), None).await.unwrap();
        let customers = db.describe_table("Customers".to_string()).await.unwrap();
        assert!(customers.inbound_relationships.is_empty());
    }

    #[tokio::test]
    async fn add_relationship_with_duplicate_name_errors() {
        let db = db();
        customers_orders_setup(&db).await;
        db.add_column(
            "Orders".to_string(),
            ColumnSpec::new("OtherCust".to_string(), Type::Int),
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            Some("dup".to_string()),
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        let err = db
            .add_relationship(
                Some("dup".to_string()),
                "Customers".to_string(),
                vec!["id".to_string()],
                "Orders".to_string(),
                vec!["OtherCust".to_string()],
                ReferentialAction::NoAction,
                ReferentialAction::NoAction,
                false,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Unsupported(msg) => assert!(msg.contains("already exists"), "{msg}"),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[tokio::test]
    async fn rebuild_preserves_existing_data_through_relationship_change() {
        let db = db();
        customers_orders_setup(&db).await;
        // Direct INSERT through the underlying connection isn't
        // exposed via the public API yet (C5). The point of this
        // test is to verify the rebuild itself works on populated
        // tables — we add a column with a default-able operation
        // and then add a relationship to ensure the table survives.
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::Cascade,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        // After the rebuild, the original columns are still
        // present with the right user types, and any extra
        // metadata (Name on Customers) survives.
        let customers = db.describe_table("Customers".to_string()).await.unwrap();
        let names: Vec<&str> = customers.columns.iter().map(|c| c.name.as_str()).collect();
        assert_eq!(names, vec!["id", "Name"]);
        let name_col = customers.columns.iter().find(|c| c.name == "Name").unwrap();
        assert_eq!(name_col.user_type, Some(Type::Text));
    }

    // --- Data operations (C5) ---

    async fn customers_table(db: &Database) {
        db.create_table(
            "Customers".to_string(),
            vec![col("id", Type::Serial), col("Name", Type::Text)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn insert_short_form_skips_serial_column() {
        let db = db();
        customers_table(&db).await;
        let result = db
            .insert(
                "Customers".to_string(),
                None,
                vec![Value::Text("Alice".to_string())],
                None,
            )
            .await
            .unwrap();
        assert_eq!(result.rows_affected, 1);
        // The InsertResult itself carries the just-inserted row.
        assert_eq!(result.data.rows.len(), 1);
        assert_eq!(result.data.rows[0][1], Some("Alice".to_string()));
        let data = db
            .query_data("Customers".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(data.columns, vec!["id".to_string(), "Name".to_string()]);
        assert_eq!(data.rows.len(), 1);
        assert_eq!(data.rows[0][1], Some("Alice".to_string()));
        // id was auto-filled by SQLite.
        assert_eq!(data.rows[0][0], Some("1".to_string()));
    }

    #[tokio::test]
    async fn insert_short_form_auto_generates_shortid() {
        let db = db();
        db.create_table(
            "Tags".to_string(),
            vec![col("id", Type::ShortId), col("Label", Type::Text)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.insert(
            "Tags".to_string(),
            None,
            vec![Value::Text("database".to_string())],
            None,
        )
        .await
        .unwrap();
        let data = db.query_data("Tags".to_string(), None, None).await.unwrap();
        let id = data.rows[0][0].as_ref().expect("auto-generated id");
        assert!(
            id.len() >= 10 && id.len() <= 12,
            "expected a base58 shortid, got {id}"
        );
    }

    #[tokio::test]
    async fn insert_explicit_columns_uses_user_values() {
        let db = db();
        customers_table(&db).await;
        db.insert(
            "Customers".to_string(),
            Some(vec!["id".to_string(), "Name".to_string()]),
            vec![
                Value::Number("99".to_string()),
                Value::Text("Bob".to_string()),
            ],
            None,
        )
        .await
        .unwrap();
        let data = db
            .query_data("Customers".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(data.rows[0][0], Some("99".to_string()));
        assert_eq!(data.rows[0][1], Some("Bob".to_string()));
    }

    #[tokio::test]
    async fn insert_with_type_mismatch_returns_invalid_value() {
        let db = db();
        customers_table(&db).await;
        // 42 is a number, but Name expects text.
        let err = db
            .insert(
                "Customers".to_string(),
                Some(vec!["Name".to_string()]),
                vec![Value::Number("42".to_string())],
                None,
            )
            .await
            .unwrap_err();
        assert!(matches!(err, DbError::InvalidValue(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn update_with_where_affects_only_matching_rows() {
        let db = db();
        customers_table(&db).await;
        for name in ["Alice", "Bob"] {
            db.insert(
                "Customers".to_string(),
                None,
                vec![Value::Text(name.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .update(
                "Customers".to_string(),
                vec![("Name".to_string(), Value::Text("Alicia".to_string()))],
                RowFilter::eq("id", Value::Number("1".to_string())),
                None,
            )
            .await
            .unwrap();
        assert_eq!(result.rows_affected, 1);
        // The UpdateResult contains only the updated rows.
        assert_eq!(result.data.rows.len(), 1);
        assert_eq!(result.data.rows[0][1], Some("Alicia".to_string()));
        let data = db
            .query_data("Customers".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(data.rows[0][1], Some("Alicia".to_string()));
        assert_eq!(data.rows[1][1], Some("Bob".to_string()));
    }

    // ---- Complex WHERE expressions (ADR-0026) ----------------

    /// `People(id serial pk, Name text, Age int, Active bool)`
    /// seeded with four rows: Alice/25/true, Bob/35/false,
    /// Carol/45/true, Dave/35/true (ids 1..4).
    async fn people_table(db: &Database) {
        db.create_table(
            "People".to_string(),
            vec![
                col("id", Type::Serial),
                col("Name", Type::Text),
                col("Age", Type::Int),
                col("Active", Type::Bool),
            ],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        for (name, age, active) in [
            ("Alice", 25, true),
            ("Bob", 35, false),
            ("Carol", 45, true),
            ("Dave", 35, true),
        ] {
            db.insert(
                "People".to_string(),
                None,
                vec![
                    Value::Text(name.to_string()),
                    Value::Number(age.to_string()),
                    Value::Bool(active),
                ],
                None,
            )
            .await
            .unwrap();
        }
    }

    /// Pull the `RowFilter` out of an `update` / `delete` parsed
    /// from DSL — the readable way to build a complex `Expr`.
    /// Parses in Simple mode: `update`/`delete` are shared entry
    /// words since sub-phase 3j (ADR-0033 Amendment 3), so the DSL
    /// `Command::Update`/`Delete` is only produced in Simple mode.
    fn parse_filter(dsl: &str) -> RowFilter {
        match crate::dsl::parser::parse_command_in_mode(dsl, crate::mode::Mode::Simple)
            .expect("filter parse")
        {
            crate::dsl::command::Command::Update { filter, .. }
            | crate::dsl::command::Command::Delete { filter, .. } => filter,
            other => panic!("expected update/delete, got {other:?}"),
        }
    }

    /// Pull the optional filter + limit out of a parsed
    /// `show data` command.
    fn parse_show(dsl: &str) -> (Option<Expr>, Option<u64>) {
        match crate::dsl::parser::parse_command(dsl).expect("show parse") {
            crate::dsl::command::Command::ShowData { filter, limit, .. } => (filter, limit),
            other => panic!("expected show data, got {other:?}"),
        }
    }

    /// The `Name` column of every remaining row, in row order.
    fn names(data: &DataResult) -> Vec<String> {
        data.rows
            .iter()
            .map(|r| r[1].clone().unwrap_or_default())
            .collect()
    }

    #[tokio::test]
    async fn delete_with_and_expression_filters_rows() {
        let db = db();
        people_table(&db).await;
        let result = db
            .delete(
                "People".to_string(),
                parse_filter("delete from People where Age >= 35 and Active = true"),
                None,
            )
            .await
            .unwrap();
        // Carol (45/true) and Dave (35/true) match; Bob (35) is
        // inactive, Alice (25) is too young.
        assert_eq!(result.rows_affected, 2);
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(names(&data), vec!["Alice", "Bob"]);
    }

    #[tokio::test]
    async fn delete_with_or_expression_filters_rows() {
        let db = db();
        people_table(&db).await;
        let result = db
            .delete(
                "People".to_string(),
                parse_filter("delete from People where id = 1 or id = 3"),
                None,
            )
            .await
            .unwrap();
        assert_eq!(result.rows_affected, 2);
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(names(&data), vec!["Bob", "Dave"]);
    }

    #[tokio::test]
    async fn delete_with_not_expression_filters_rows() {
        let db = db();
        people_table(&db).await;
        // `not Age = 35` keeps Bob and Dave (both 35).
        let result = db
            .delete(
                "People".to_string(),
                parse_filter("delete from People where not Age = 35"),
                None,
            )
            .await
            .unwrap();
        assert_eq!(result.rows_affected, 2);
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(names(&data), vec!["Bob", "Dave"]);
    }

    #[tokio::test]
    async fn delete_with_between_filters_rows() {
        let db = db();
        people_table(&db).await;
        let result = db
            .delete(
                "People".to_string(),
                parse_filter("delete from People where Age between 30 and 40"),
                None,
            )
            .await
            .unwrap();
        // Bob (35) and Dave (35) are in range.
        assert_eq!(result.rows_affected, 2);
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(names(&data), vec!["Alice", "Carol"]);
    }

    #[tokio::test]
    async fn delete_with_in_filters_rows() {
        let db = db();
        people_table(&db).await;
        let result = db
            .delete(
                "People".to_string(),
                parse_filter("delete from People where Name in ('Alice', 'Carol')"),
                None,
            )
            .await
            .unwrap();
        assert_eq!(result.rows_affected, 2);
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(names(&data), vec!["Bob", "Dave"]);
    }

    #[tokio::test]
    async fn delete_with_like_filters_rows() {
        let db = db();
        people_table(&db).await;
        let result = db
            .delete(
                "People".to_string(),
                parse_filter("delete from People where Name like 'A%'"),
                None,
            )
            .await
            .unwrap();
        assert_eq!(result.rows_affected, 1, "only Alice matches `A%`");
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(names(&data), vec!["Bob", "Carol", "Dave"]);
    }

    #[tokio::test]
    async fn update_with_complex_where_updates_only_matching_rows() {
        let db = db();
        people_table(&db).await;
        // Deactivate everyone over 30 who is still active.
        let result = db
            .update(
                "People".to_string(),
                vec![("Active".to_string(), Value::Bool(false))],
                parse_filter(
                    "update People set Active=false \
                     where Age > 30 and Active = true",
                ),
                None,
            )
            .await
            .unwrap();
        // Carol (45) and Dave (35) — Bob is already inactive.
        assert_eq!(result.rows_affected, 2);
    }

    #[tokio::test]
    async fn query_data_with_where_filters_rows() {
        let db = db();
        people_table(&db).await;
        let (filter, limit) = parse_show("show data People where Active = true");
        let data = db
            .query_data("People".to_string(), filter, limit)
            .await
            .unwrap();
        assert_eq!(names(&data), vec!["Alice", "Carol", "Dave"]);
    }

    #[tokio::test]
    async fn query_data_with_limit_caps_rows_by_primary_key() {
        let db = db();
        people_table(&db).await;
        let (filter, limit) = parse_show("show data People limit 2");
        let data = db
            .query_data("People".to_string(), filter, limit)
            .await
            .unwrap();
        // `limit` implies an ORDER BY the primary key, so this
        // is a stable "first 2 by id".
        assert_eq!(names(&data), vec!["Alice", "Bob"]);
    }

    #[tokio::test]
    async fn query_data_with_where_and_limit_combines_both() {
        let db = db();
        people_table(&db).await;
        let (filter, limit) = parse_show("show data People where Age >= 35 limit 1");
        let data = db
            .query_data("People".to_string(), filter, limit)
            .await
            .unwrap();
        // Three rows match `Age >= 35` (Bob, Carol, Dave); the
        // limit keeps the first by primary key — Bob (id 2).
        assert_eq!(names(&data), vec!["Bob"]);
    }

    // --- explain / query plans (ADR-0028) -------------------

    /// Parse a non-`explain` query command for use as the inner
    /// command of `explain_query_plan`. Simple mode: `explain`
    /// wraps the DSL `show data` / `update` / `delete` commands
    /// (ADR-0028; SQL DML is not explainable, ADR-0030 §13 OOS-2),
    /// and `update`/`delete` only yield the DSL variant in Simple
    /// mode (shared entry words since sub-phase 3j).
    fn parse_inner(dsl: &str) -> Command {
        crate::dsl::parser::parse_command_in_mode(dsl, crate::mode::Mode::Simple)
            .expect("inner command parse")
    }

    /// Advanced-mode counterpart of `parse_inner` (ADR-0039): parses
    /// a bare SQL command (the inner of an `explain`).
    fn parse_inner_adv(sql: &str) -> Command {
        crate::dsl::parser::parse_command_in_mode(sql, crate::mode::Mode::Advanced)
            .expect("inner SQL command parse")
    }

    #[tokio::test]
    async fn explain_show_data_returns_a_scan_plan() {
        let db = db();
        people_table(&db).await;
        let plan = db
            .explain_query_plan(parse_inner("show data People where Active = true"))
            .await
            .unwrap();
        assert!(!plan.rows.is_empty(), "a plan has at least one node");
        // No index on `Active`, so the filtered query is a full
        // table scan.
        assert!(
            plan.rows.iter().any(|r| r.detail.contains("SCAN")),
            "expected a SCAN node, got {:?}",
            plan.rows,
        );
    }

    #[tokio::test]
    async fn explain_show_data_uses_an_index_when_one_exists() {
        let db = db();
        people_table(&db).await;
        db.add_index(None, "People".to_string(), vec!["Age".to_string()], None)
            .await
            .unwrap();
        let plan = db
            .explain_query_plan(parse_inner("show data People where Age = 35"))
            .await
            .unwrap();
        // The teaching payoff: the plan flips from a scan to an
        // index search once an index covers the WHERE column.
        assert!(
            plan.rows.iter().any(|r| r.detail.contains("USING INDEX")),
            "expected an index search, got {:?}",
            plan.rows,
        );
    }

    #[tokio::test]
    async fn explain_delete_does_not_remove_any_rows() {
        let db = db();
        people_table(&db).await;
        let plan = db
            .explain_query_plan(parse_inner("delete from People where Active = true"))
            .await
            .unwrap();
        assert!(!plan.rows.is_empty());
        // ADR-0028 §1: EXPLAIN QUERY PLAN never executes.
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(data.rows.len(), 4, "explain delete must not delete");
    }

    #[tokio::test]
    async fn explain_update_does_not_change_any_data() {
        let db = db();
        people_table(&db).await;
        db.explain_query_plan(parse_inner(
            "update People set Active = false where Active = true",
        ))
        .await
        .unwrap();
        let (filter, _) = parse_show("show data People where Active = true");
        let data = db
            .query_data("People".to_string(), filter, None)
            .await
            .unwrap();
        // Alice, Carol, Dave are still active — nothing ran.
        assert_eq!(data.rows.len(), 3, "explain update must not write");
    }

    #[tokio::test]
    async fn explain_display_sql_inlines_literals_and_quotes_idents() {
        let db = db();
        people_table(&db).await;
        let plan = db
            .explain_query_plan(parse_inner("show data People where Name = 'Alice'"))
            .await
            .unwrap();
        assert!(
            plan.display_sql.contains("\"People\""),
            "table identifier should be double-quoted: {}",
            plan.display_sql,
        );
        assert!(
            plan.display_sql.contains("'Alice'"),
            "WHERE literal should be inlined: {}",
            plan.display_sql,
        );
        assert!(
            !plan.display_sql.contains('?'),
            "display SQL must carry no `?` placeholders: {}",
            plan.display_sql,
        );
    }

    #[tokio::test]
    async fn explain_display_sql_shows_the_implicit_order_by_for_limit() {
        let db = db();
        people_table(&db).await;
        let plan = db
            .explain_query_plan(parse_inner("show data People limit 2"))
            .await
            .unwrap();
        // `limit` adds an implicit ORDER BY the primary key
        // (ADR-0026 §5) — the display SQL surfaces it.
        assert!(
            plan.display_sql.contains("ORDER BY"),
            "limit implies ORDER BY pk: {}",
            plan.display_sql,
        );
        assert!(
            plan.display_sql.contains("LIMIT 2"),
            "limit count inlined: {}",
            plan.display_sql,
        );
    }

    #[tokio::test]
    async fn explain_display_sql_writes_inequality_as_angle_brackets() {
        let db = db();
        people_table(&db).await;
        let plan = db
            .explain_query_plan(parse_inner("show data People where Age != 35"))
            .await
            .unwrap();
        // ADR-0028 §3: inequality is shown as standard SQL `<>`
        // even when the user typed `!=`.
        assert!(
            plan.display_sql.contains("<>"),
            "inequality should render as `<>`: {}",
            plan.display_sql,
        );
        assert!(
            !plan.display_sql.contains("!="),
            "the `!=` spelling should not survive: {}",
            plan.display_sql,
        );
    }

    #[tokio::test]
    async fn explain_of_a_missing_table_is_an_error() {
        let db = db();
        let result = db
            .explain_query_plan(parse_inner("show data NoSuchTable"))
            .await;
        assert!(result.is_err(), "explaining a missing table should fail");
    }

    // --- ADR-0039: explain over advanced-mode SQL -------------

    #[tokio::test]
    async fn explain_sql_select_returns_a_scan_plan() {
        let db = db();
        people_table(&db).await;
        let plan = db
            .explain_query_plan(parse_inner_adv("select * from People where Name = 'Bob'"))
            .await
            .unwrap();
        assert!(!plan.rows.is_empty(), "a plan has at least one node");
        assert!(
            plan.rows.iter().any(|r| r.detail.contains("SCAN")),
            "expected a SCAN node, got {:?}",
            plan.rows,
        );
    }

    #[tokio::test]
    async fn explain_sql_select_uses_an_index_when_one_exists() {
        let db = db();
        people_table(&db).await;
        db.add_index(None, "People".to_string(), vec!["Age".to_string()], None)
            .await
            .unwrap();
        let plan = db
            .explain_query_plan(parse_inner_adv("select * from People where Age = 35"))
            .await
            .unwrap();
        assert!(
            plan.rows.iter().any(|r| r.detail.contains("USING INDEX")),
            "expected an index search, got {:?}",
            plan.rows,
        );
    }

    #[tokio::test]
    async fn explain_sql_delete_does_not_remove_any_rows() {
        let db = db();
        people_table(&db).await;
        db.explain_query_plan(parse_inner_adv("delete from People where Age = 35"))
            .await
            .unwrap();
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(data.rows.len(), 4, "explain delete must not delete");
    }

    #[tokio::test]
    async fn explain_sql_update_does_not_change_any_data() {
        let db = db();
        people_table(&db).await;
        db.explain_query_plan(parse_inner_adv(
            "update People set Name = 'Zed' where Age = 35",
        ))
        .await
        .unwrap();
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert!(
            !data
                .rows
                .iter()
                .flatten()
                .any(|c| c.as_deref() == Some("Zed")),
            "explain update must not modify rows",
        );
    }

    #[tokio::test]
    async fn explain_sql_with_cte_returns_a_plan() {
        let db = db();
        people_table(&db).await;
        let plan = db
            .explain_query_plan(parse_inner_adv(
                "with adults as (select * from People where Age = 35) select * from adults",
            ))
            .await
            .unwrap();
        assert!(!plan.rows.is_empty(), "a WITH query has a plan");
    }

    #[tokio::test]
    async fn explain_sql_insert_does_not_add_a_row() {
        // EXPLAIN QUERY PLAN over an INSERT must not execute it
        // (ADR-0039). A VALUES insert has a trivial plan (it may be
        // empty), so we assert the call succeeds and the table is
        // unchanged rather than asserting a node count.
        let db = db();
        people_table(&db).await;
        db.explain_query_plan(parse_inner_adv(
            "insert into People (Name, Age, Active) values ('Zed', 1, true)",
        ))
        .await
        .unwrap();
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(data.rows.len(), 4, "explain insert must not insert");
    }

    #[tokio::test]
    async fn explain_sql_insert_from_select_returns_a_plan() {
        // INSERT … SELECT has a real query plan (the SELECT source),
        // so this exercises the non-trivial insert-plan path.
        let db = db();
        people_table(&db).await;
        let plan = db
            .explain_query_plan(parse_inner_adv(
                "insert into People (Name, Age, Active) select Name, Age, Active from People where Age = 35",
            ))
            .await
            .unwrap();
        assert!(
            plan.rows.iter().any(|r| r.detail.contains("SCAN")),
            "insert-from-select plans the SELECT source, got {:?}",
            plan.rows,
        );
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(data.rows.len(), 4, "explain insert-select must not insert");
    }

    #[tokio::test]
    async fn explain_sql_display_sql_is_the_verbatim_query() {
        // The SQL path carries the user's text grammar-as-text, so
        // the display SQL is verbatim (no canonicalisation), unlike
        // the DSL path which synthesises canonical SQL.
        let db = db();
        people_table(&db).await;
        let plan = db
            .explain_query_plan(parse_inner_adv("select * from People where Age = 35"))
            .await
            .unwrap();
        assert_eq!(plan.display_sql, "select * from People where Age = 35");
    }

    #[tokio::test]
    async fn explain_sql_of_a_missing_table_is_an_error() {
        let db = db();
        let result = db
            .explain_query_plan(parse_inner_adv("select * from NoSuchTable"))
            .await;
        assert!(
            result.is_err(),
            "explaining a missing SQL table should fail"
        );
    }

    // --- column constraints at create-table (ADR-0029) ------

    /// A `ColumnSpec` carrying the four constraint slots.
    fn col_c(
        name: &str,
        ty: Type,
        not_null: bool,
        unique: bool,
        default: Option<Value>,
    ) -> ColumnSpec {
        ColumnSpec {
            name: name.to_string(),
            ty,
            not_null,
            unique,
            default,
            check: None,
            default_sql: None,
            check_sql: None,
        }
    }

    /// Create `T(id serial pk, <extra>)`.
    async fn table_with(db: &Database, extra: ColumnSpec) -> TableDescription {
        db.create_table(
            "T".to_string(),
            vec![col("id", Type::Serial), extra],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create table")
    }

    #[tokio::test]
    async fn create_table_not_null_column_rejects_a_null_insert() {
        let db = db();
        table_with(&db, col_c("name", Type::Text, true, false, None)).await;
        let ok = db
            .insert(
                "T".to_string(),
                Some(vec!["name".to_string()]),
                vec![Value::Text("Alice".to_string())],
                None,
            )
            .await;
        assert!(ok.is_ok(), "a non-null value is accepted");
        let bad = db
            .insert(
                "T".to_string(),
                Some(vec!["name".to_string()]),
                vec![Value::Null],
                None,
            )
            .await;
        assert!(bad.is_err(), "NULL into a NOT NULL column is refused");
    }

    #[tokio::test]
    async fn create_table_unique_column_rejects_a_duplicate_insert() {
        let db = db();
        table_with(&db, col_c("email", Type::Text, false, true, None)).await;
        let insert_email = |v: &str| {
            db.insert(
                "T".to_string(),
                Some(vec!["email".to_string()]),
                vec![Value::Text(v.to_string())],
                None,
            )
        };
        assert!(insert_email("a@x.io").await.is_ok());
        assert!(
            insert_email("a@x.io").await.is_err(),
            "a duplicate value violates UNIQUE",
        );
        assert!(
            insert_email("b@x.io").await.is_ok(),
            "a distinct value is still accepted",
        );
    }

    #[tokio::test]
    async fn create_table_default_applies_when_the_column_is_omitted() {
        let db = db();
        db.create_table(
            "T".to_string(),
            vec![
                col("id", Type::Serial),
                col("name", Type::Text),
                col_c(
                    "tier",
                    Type::Int,
                    false,
                    false,
                    Some(Value::Number("3".to_string())),
                ),
            ],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        // Insert names only `name` — `tier` is omitted and so
        // takes its DEFAULT; `id` auto-fills as a serial.
        db.insert(
            "T".to_string(),
            Some(vec!["name".to_string()]),
            vec![Value::Text("Alice".to_string())],
            None,
        )
        .await
        .unwrap();
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        let tier_idx = data.columns.iter().position(|c| c == "tier").unwrap();
        assert_eq!(
            data.rows[0][tier_idx].as_deref(),
            Some("3"),
            "the omitted column took its DEFAULT",
        );
    }

    #[tokio::test]
    async fn describe_surfaces_the_column_constraints() {
        let db = db();
        let desc = table_with(
            &db,
            col_c(
                "email",
                Type::Text,
                true,
                true,
                Some(Value::Text("none".to_string())),
            ),
        )
        .await;
        let email = desc.columns.iter().find(|c| c.name == "email").unwrap();
        assert!(email.notnull, "NOT NULL is described");
        assert!(email.unique, "UNIQUE is described");
        assert_eq!(
            email.default.as_deref(),
            Some("'none'"),
            "the DEFAULT literal is described",
        );
    }

    #[tokio::test]
    async fn rebuild_preserves_column_constraints() {
        // A type change on one column rebuilds the whole table
        // through `schema_to_ddl`; the constraints on the other
        // columns must survive that round-trip.
        let db = db();
        db.create_table(
            "T".to_string(),
            vec![
                col("id", Type::Serial),
                col_c("email", Type::Text, true, true, None),
                col_c(
                    "tier",
                    Type::Int,
                    false,
                    false,
                    Some(Value::Number("1".to_string())),
                ),
            ],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        // Change `tier`'s type — int -> decimal — forcing a rebuild.
        db.change_column_type(
            "T".to_string(),
            "tier".to_string(),
            Type::Decimal,
            ChangeColumnMode::Default,
            None,
        )
        .await
        .unwrap();
        let desc = db.describe_table("T".to_string()).await.unwrap();
        let email = desc.columns.iter().find(|c| c.name == "email").unwrap();
        assert!(
            email.notnull && email.unique,
            "email keeps NOT NULL + UNIQUE"
        );
        let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap();
        assert_eq!(
            tier.default.as_deref(),
            Some("1"),
            "tier keeps its DEFAULT across the rebuild",
        );
    }

    // --- column constraints at add-column (ADR-0029 §6) -----

    #[tokio::test]
    async fn add_column_with_default_fills_existing_rows() {
        let db = db();
        people_table(&db).await; // 4 rows
        db.add_column(
            "People".to_string(),
            col_c(
                "tier",
                Type::Int,
                false,
                false,
                Some(Value::Number("1".to_string())),
            ),
            None,
        )
        .await
        .unwrap();
        let data = db
            .query_data("People".to_string(), None, None)
            .await
            .unwrap();
        let idx = data.columns.iter().position(|c| c == "tier").unwrap();
        assert!(
            data.rows.iter().all(|r| r[idx].as_deref() == Some("1")),
            "every existing row took the new column's DEFAULT",
        );
    }

    #[tokio::test]
    async fn add_not_null_column_without_default_to_populated_table_is_refused() {
        let db = db();
        people_table(&db).await;
        let result = db
            .add_column(
                "People".to_string(),
                col_c("x", Type::Int, true, false, None),
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "a NOT NULL column with no default cannot be added to a table with rows",
        );
    }

    #[tokio::test]
    async fn add_not_null_column_without_default_to_empty_table_succeeds() {
        let db = db();
        db.create_table(
            "T".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.add_column(
            "T".to_string(),
            col_c("x", Type::Int, true, false, None),
            None,
        )
        .await
        .expect("NOT NULL with no default is fine on an empty table");
        let desc = db.describe_table("T".to_string()).await.unwrap();
        assert!(desc.columns.iter().find(|c| c.name == "x").unwrap().notnull);
    }

    #[tokio::test]
    async fn add_not_null_column_with_a_default_succeeds_on_a_populated_table() {
        let db = db();
        people_table(&db).await;
        db.add_column(
            "People".to_string(),
            col_c(
                "tier",
                Type::Int,
                true,
                false,
                Some(Value::Number("0".to_string())),
            ),
            None,
        )
        .await
        .unwrap();
        let desc = db.describe_table("People".to_string()).await.unwrap();
        let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap();
        assert!(tier.notnull);
        assert_eq!(tier.default.as_deref(), Some("0"));
    }

    #[tokio::test]
    async fn add_unique_column_applies_the_constraint_via_rebuild() {
        let db = db();
        people_table(&db).await; // 4 rows; the new column is all-NULL
        db.add_column(
            "People".to_string(),
            col_c("badge", Type::Text, false, true, None),
            None,
        )
        .await
        .expect("a UNIQUE column with no default is fine — NULLs do not collide");
        let desc = db.describe_table("People".to_string()).await.unwrap();
        assert!(
            desc.columns
                .iter()
                .find(|c| c.name == "badge")
                .unwrap()
                .unique
        );
    }

    #[tokio::test]
    async fn add_unique_column_with_default_to_a_multi_row_table_is_refused() {
        let db = db();
        people_table(&db).await; // 4 rows
        let result = db
            .add_column(
                "People".to_string(),
                col_c(
                    "badge",
                    Type::Text,
                    false,
                    true,
                    Some(Value::Text("X".to_string())),
                ),
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "a UNIQUE column with a default would give every row the same value",
        );
    }

    #[tokio::test]
    async fn add_serial_column_with_a_default_is_refused() {
        let db = db();
        people_table(&db).await;
        let result = db
            .add_column(
                "People".to_string(),
                col_c(
                    "seq",
                    Type::Serial,
                    false,
                    false,
                    Some(Value::Number("1".to_string())),
                ),
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "a serial column auto-fills its own values — a default is ambiguous",
        );
    }

    // --- CHECK constraints (ADR-0029 §4) --------------------

    /// Parse a `create table` DSL string into its db-call parts
    /// — the way to get a real `Expr` into a `ColumnSpec.check`
    /// without hand-building the AST.
    fn parse_create(dsl: &str) -> (String, Vec<ColumnSpec>, Vec<String>) {
        match crate::dsl::parser::parse_command(dsl).expect("create table parse") {
            Command::CreateTable {
                name,
                columns,
                primary_key,
            } => (name, columns, primary_key),
            other => panic!("expected CreateTable, got {other:?}"),
        }
    }

    /// A `ColumnSpec` carrying a `CHECK`, parsed from DSL.
    fn col_c_check(name: &str, ty: Type, check_dsl: &str) -> ColumnSpec {
        let (_, columns, _) = parse_create(&format!(
            "create table __probe with pk {name}({}) check ({check_dsl})",
            ty.keyword(),
        ));
        columns.into_iter().next().expect("one column")
    }

    #[tokio::test]
    async fn create_table_check_constraint_is_enforced() {
        let db = db();
        let (n, c, pk) = parse_create(
            "create table Grades with pk grade(text) check (grade in ('A', 'B', 'C'))",
        );
        db.create_table(n, c, pk, None).await.unwrap();
        let insert_grade = |g: &str| {
            db.insert(
                "Grades".to_string(),
                Some(vec!["grade".to_string()]),
                vec![Value::Text(g.to_string())],
                None,
            )
        };
        assert!(insert_grade("A").await.is_ok(), "a value the check allows");
        assert!(
            insert_grade("Z").await.is_err(),
            "a value the check forbids is refused",
        );
    }

    #[tokio::test]
    async fn describe_surfaces_the_check_constraint() {
        let db = db();
        let (n, c, pk) = parse_create("create table T with pk age(int) check (age >= 0)");
        db.create_table(n, c, pk, None).await.unwrap();
        let desc = db.describe_table("T".to_string()).await.unwrap();
        let age = desc.columns.iter().find(|c| c.name == "age").unwrap();
        let check = age.check.as_deref().expect("age carries a CHECK");
        assert!(
            check.contains(">="),
            "the compiled check SQL is surfaced: {check}",
        );
    }

    #[tokio::test]
    async fn add_column_check_constraint_is_enforced() {
        let db = db();
        people_table(&db).await;
        db.add_column(
            "People".to_string(),
            col_c_check("score", Type::Int, "score >= 0"),
            None,
        )
        .await
        .expect("a CHECK column adds via the rebuild path");
        let desc = db.describe_table("People".to_string()).await.unwrap();
        assert!(
            desc.columns
                .iter()
                .find(|c| c.name == "score")
                .unwrap()
                .check
                .is_some()
        );
        // An update that violates the check is refused.
        let bad = db
            .update(
                "People".to_string(),
                vec![("score".to_string(), Value::Number("-1".to_string()))],
                parse_filter("update People set score=-1 where id = 1"),
                None,
            )
            .await;
        assert!(bad.is_err(), "an update violating the CHECK is refused");
    }

    #[tokio::test]
    async fn rebuild_preserves_a_check_constraint() {
        let db = db();
        let (n, c, pk) = parse_create("create table T with pk code(text) check (code like 'X%')");
        db.create_table(n, c, pk, None).await.unwrap();
        db.add_column("T".to_string(), col("note", Type::Int), None)
            .await
            .unwrap();
        // A type change on `note` rebuilds the table; `code`'s
        // CHECK must survive the round-trip through schema_to_ddl.
        db.change_column_type(
            "T".to_string(),
            "note".to_string(),
            Type::Decimal,
            ChangeColumnMode::Default,
            None,
        )
        .await
        .unwrap();
        let desc = db.describe_table("T".to_string()).await.unwrap();
        assert!(
            desc.columns
                .iter()
                .find(|c| c.name == "code")
                .unwrap()
                .check
                .is_some(),
            "code keeps its CHECK across the rebuild",
        );
    }

    #[tokio::test]
    async fn add_serial_column_with_a_check_is_refused() {
        let db = db();
        people_table(&db).await;
        let result = db
            .add_column(
                "People".to_string(),
                col_c_check("seq", Type::Serial, "seq > 0"),
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "a CHECK on an auto-generated column is a create-table-only feature",
        );
    }

    #[tokio::test]
    async fn update_with_all_rows_affects_everything() {
        let db = db();
        customers_table(&db).await;
        for name in ["Alice", "Bob", "Carol"] {
            db.insert(
                "Customers".to_string(),
                None,
                vec![Value::Text(name.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .update(
                "Customers".to_string(),
                vec![("Name".to_string(), Value::Text("X".to_string()))],
                RowFilter::AllRows,
                None,
            )
            .await
            .unwrap();
        assert_eq!(result.rows_affected, 3);
        assert_eq!(result.data.rows.len(), 3);
    }

    #[tokio::test]
    async fn delete_with_where_removes_matching_rows() {
        let db = db();
        customers_table(&db).await;
        for name in ["Alice", "Bob"] {
            db.insert(
                "Customers".to_string(),
                None,
                vec![Value::Text(name.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let result = db
            .delete(
                "Customers".to_string(),
                RowFilter::eq("id", Value::Number("1".to_string())),
                None,
            )
            .await
            .unwrap();
        assert_eq!(result.rows_affected, 1);
        assert!(result.cascade.is_empty(), "no children to cascade to");
        let data = db
            .query_data("Customers".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(data.rows.len(), 1);
        assert_eq!(data.rows[0][1], Some("Bob".to_string()));
    }

    #[tokio::test]
    async fn fk_violation_returns_engine_classified_constraint_error() {
        // Pre-H1 (ADR-0019), this test asserted that the
        // engine's `FOREIGN KEY constraint failed` text was
        // enriched in-band with a list of every relevant FK on
        // the offending table. That enrichment moved into the
        // friendly-error layer's catalog wording (see
        // `friendly::translate::tests::fk_with_*_op_renders_*`)
        // and out of the raw `DbError::Sqlite { message }`
        // payload. What stays in `message` is the engine's
        // un-enriched text — useful for the translator's own
        // classification, but no longer the user-facing
        // surface.
        //
        // This test now just confirms the engine error reaches
        // us classified as a constraint violation; user-facing
        // wording is exercised in the friendly module.
        let db = db();
        customers_table(&db).await;
        db.create_table(
            "Orders".to_string(),
            vec![col("id", Type::Serial), col("CustId", Type::Int)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();

        let err = db
            .insert(
                "Orders".to_string(),
                Some(vec!["CustId".to_string()]),
                vec![Value::Number("999".to_string())],
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Sqlite { message, .. } => {
                assert!(
                    message.to_ascii_lowercase().contains("foreign key"),
                    "expected engine-classified FK message, got: {message}"
                );
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[tokio::test]
    async fn cascade_delete_propagates_to_children() {
        let db = db();
        customers_table(&db).await;
        db.create_table(
            "Orders".to_string(),
            vec![col("id", Type::Serial), col("CustId", Type::Int)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            None,
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            ReferentialAction::Cascade,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        db.insert(
            "Customers".to_string(),
            None,
            vec![Value::Text("Alice".to_string())],
            None,
        )
        .await
        .unwrap();
        db.insert(
            "Orders".to_string(),
            Some(vec!["CustId".to_string()]),
            vec![Value::Number("1".to_string())],
            None,
        )
        .await
        .unwrap();
        // Delete Alice — cascades to Orders.
        db.delete(
            "Customers".to_string(),
            RowFilter::eq("id", Value::Number("1".to_string())),
            None,
        )
        .await
        .unwrap();
        let orders = db
            .query_data("Orders".to_string(), None, None)
            .await
            .unwrap();
        assert!(orders.rows.is_empty(), "child rows should be cascaded");
    }

    #[tokio::test]
    async fn self_referential_cascade_counts_only_cascaded_rows() {
        // A self-referential ON DELETE CASCADE FK (T.ParentId -> T.id):
        // deleting the root of a chain cascades down within T. The
        // directly-deleted root is reported in `rows_affected`, so the
        // cascade summary must report only the *additional* rows
        // removed by the self-reference — the raw before/after diff
        // would double-count the direct delete. Parity fix shared with
        // `do_sql_delete` (ADR-0033 Amendment 2 mechanism).
        let db = db();
        db.create_table(
            "T".to_string(),
            vec![col("id", Type::Int), col("ParentId", Type::Int)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            Some("parent_of".to_string()),
            "T".to_string(),
            vec!["id".to_string()],
            "T".to_string(),
            vec!["ParentId".to_string()],
            ReferentialAction::Cascade,
            ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        // Chain: 1 (root) <- 2 <- 3.
        db.insert(
            "T".to_string(),
            Some(vec!["id".to_string(), "ParentId".to_string()]),
            vec![Value::Number("1".to_string()), Value::Null],
            None,
        )
        .await
        .unwrap();
        db.insert(
            "T".to_string(),
            Some(vec!["id".to_string(), "ParentId".to_string()]),
            vec![
                Value::Number("2".to_string()),
                Value::Number("1".to_string()),
            ],
            None,
        )
        .await
        .unwrap();
        db.insert(
            "T".to_string(),
            Some(vec!["id".to_string(), "ParentId".to_string()]),
            vec![
                Value::Number("3".to_string()),
                Value::Number("2".to_string()),
            ],
            None,
        )
        .await
        .unwrap();
        let result = db
            .delete(
                "T".to_string(),
                RowFilter::eq("id", Value::Number("1".to_string())),
                None,
            )
            .await
            .unwrap();
        assert_eq!(
            result.rows_affected, 1,
            "one row matched the filter directly"
        );
        assert_eq!(
            result.cascade.len(),
            1,
            "self-ref relationship reported once"
        );
        assert_eq!(
            result.cascade[0].rows_changed, 2,
            "only the 2 cascaded rows, not the directly-deleted root too"
        );
    }

    #[tokio::test]
    async fn query_data_renders_bools_as_words() {
        let db = db();
        db.create_table(
            "Flags".to_string(),
            vec![col("id", Type::Serial), col("Active", Type::Bool)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.insert("Flags".to_string(), None, vec![Value::Bool(true)], None)
            .await
            .unwrap();
        db.insert("Flags".to_string(), None, vec![Value::Bool(false)], None)
            .await
            .unwrap();
        let data = db
            .query_data("Flags".to_string(), None, None)
            .await
            .unwrap();
        assert_eq!(data.rows[0][1], Some("true".to_string()));
        assert_eq!(data.rows[1][1], Some("false".to_string()));
    }

    #[tokio::test]
    async fn query_data_renders_null_as_none() {
        let db = db();
        db.create_table(
            "T".to_string(),
            vec![col("id", Type::Serial), col("note", Type::Text)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.insert("T".to_string(), None, vec![Value::Null], None)
            .await
            .unwrap();
        let data = db.query_data("T".to_string(), None, None).await.unwrap();
        assert_eq!(data.rows[0][1], None);
    }

    // ---- ADR-0018 UNIQUE infrastructure ----

    #[test]
    fn read_schema_detects_single_column_unique() {
        // Create a raw connection (no Database wrapper) so we
        // can drop arbitrary DDL containing UNIQUE — the DSL
        // doesn't yet expose UNIQUE as a user-controlled
        // constraint (C3-track).
        let conn = Connection::open_in_memory().unwrap();
        configure_connection(&conn).unwrap();
        conn.execute_batch("CREATE TABLE T (id INTEGER PRIMARY KEY, code TEXT UNIQUE) STRICT;")
            .unwrap();
        let schema = read_schema(&conn, "T").unwrap();
        let id = schema.columns.iter().find(|c| c.name == "id").unwrap();
        let code = schema.columns.iter().find(|c| c.name == "code").unwrap();
        assert!(id.primary_key, "id should still be PK");
        assert!(
            !id.unique,
            "PK columns are not separately marked unique \
             (PK already implies it; double-marking would lead to \
             redundant DDL)"
        );
        assert!(code.unique, "code should be detected as unique");
    }

    #[test]
    fn read_schema_ignores_compound_unique_constraints() {
        // Multi-column UNIQUE is out of scope for ADR-0018; the
        // detection helper filters it out so schema_to_ddl does
        // not accidentally emit a column-level UNIQUE for one
        // half of a compound constraint.
        let conn = Connection::open_in_memory().unwrap();
        configure_connection(&conn).unwrap();
        conn.execute_batch(
            "CREATE TABLE T (id INTEGER PRIMARY KEY, a TEXT, b TEXT, UNIQUE(a, b)) STRICT;",
        )
        .unwrap();
        let schema = read_schema(&conn, "T").unwrap();
        for col in &schema.columns {
            assert!(
                !col.unique,
                "column {} should not be marked unique under compound UNIQUE",
                col.name
            );
        }
    }

    #[test]
    fn schema_to_ddl_emits_unique_for_marked_column() {
        // Construct a ReadSchema with a unique non-PK column and
        // confirm the round-trip through schema_to_ddl produces
        // valid DDL containing UNIQUE.
        let schema = ReadSchema {
            columns: vec![
                ReadColumn {
                    name: "id".to_string(),
                    sqlite_type: "INTEGER".to_string(),
                    notnull: false,
                    primary_key: true,
                    unique: false,
                    default_sql: None,
                    check: None,
                    user_type: Some(Type::Serial),
                },
                ReadColumn {
                    name: "code".to_string(),
                    sqlite_type: "TEXT".to_string(),
                    notnull: false,
                    primary_key: false,
                    unique: true,
                    default_sql: None,
                    check: None,
                    user_type: Some(Type::Text),
                },
            ],
            primary_key: vec!["id".to_string()],
            foreign_keys: vec![],
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        };
        let ddl = schema_to_ddl("T", &schema);
        assert!(
            ddl.contains("\"id\" INTEGER PRIMARY KEY"),
            "PK emission preserved: {ddl}"
        );
        assert!(
            ddl.contains("\"code\" TEXT UNIQUE"),
            "UNIQUE emitted on non-PK column: {ddl}"
        );
        assert!(
            !ddl.contains("\"id\" INTEGER PRIMARY KEY UNIQUE"),
            "PK column should not double-emit UNIQUE: {ddl}"
        );
        // The DDL is valid SQL — apply it and read it back.
        let conn = Connection::open_in_memory().unwrap();
        configure_connection(&conn).unwrap();
        conn.execute_batch(&ddl).unwrap();
        let round = read_schema(&conn, "T").unwrap();
        let code = round.columns.iter().find(|c| c.name == "code").unwrap();
        assert!(code.unique, "round-tripped column retains unique flag");
    }

    #[tokio::test]
    async fn unique_flag_persisted_in_yaml_for_non_pk_serial() {
        // ADR-0018 §4: a non-PK serial column gains UNIQUE,
        // and the flag must be recorded in `project.yaml` so
        // that a rebuild-from-text reconstructs the constraint
        // faithfully (preserves the "serial -> int leaves
        // UNIQUE in place" promise across save/load cycles).
        use crate::persistence::Persistence;
        let dir = tempfile::tempdir().unwrap();
        let persistence = Persistence::new(dir.path().to_path_buf());
        let db_path = dir.path().join("playground.db");
        let db = Database::open_with_persistence(&db_path, persistence).unwrap();
        db.create_table(
            "T".to_string(),
            vec![col("id", Type::Serial), col("Name", Type::Text)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("seq".to_string(), Type::Serial),
            None,
        )
        .await
        .unwrap();
        // Read the persisted YAML straight from disk.
        let yaml = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
        assert!(
            yaml.contains("seq") && yaml.contains("unique: true"),
            "yaml should record the unique flag for `seq`: {yaml}"
        );
        // PK column not separately marked unique — PK already
        // implies UNIQUE, double-marking would emit redundant
        // DDL on rebuild.
        let lines_with_id: Vec<&str> = yaml.lines().filter(|l| l.contains("name: id")).collect();
        for line in &lines_with_id {
            assert!(
                !line.contains("unique: true"),
                "PK column should not be marked unique in YAML: {line}"
            );
        }
    }

    #[tokio::test]
    async fn set_mode_persists_and_survives_a_later_ddl_command() {
        // ADR-0015 mode-restore amendment (issue #14): the worker
        // stamps the *current* input mode into `project.yaml` on
        // every write, so a mode change is saved immediately AND a
        // later schema-mutating command re-writes the same live
        // mode rather than clobbering it back to the default. This
        // is the core guarantee the whole feature rests on.
        use crate::persistence::Persistence;
        let dir = tempfile::tempdir().unwrap();
        let persistence = Persistence::new(dir.path().to_path_buf());
        let db_path = dir.path().join("playground.db");
        let db = Database::open_with_persistence(&db_path, persistence).unwrap();

        // A table exists first so there is a schema to rewrite.
        db.create_table(
            "T".to_string(),
            vec![col("id", Type::Serial), col("Name", Type::Text)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();

        // Switch to advanced and confirm it lands in the file.
        db.set_mode(Mode::Advanced).await.unwrap();
        let yaml = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
        assert!(
            yaml.contains("mode: advanced"),
            "set_mode should record the mode in project.yaml: {yaml}"
        );

        // A later DDL command rewrites the whole project.yaml —
        // the mode must NOT regress to the default.
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("extra".to_string(), Type::Text),
            None,
        )
        .await
        .unwrap();
        let yaml_after = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
        assert!(
            yaml_after.contains("mode: advanced"),
            "a later DDL command must preserve the live mode, not clobber it: {yaml_after}"
        );
        assert_eq!(
            Persistence::read_stored_mode(dir.path()),
            Some(Mode::Advanced),
            "the stored mode reads back as advanced"
        );
    }

    #[tokio::test]
    async fn set_mode_persists_even_with_no_prior_command() {
        // ADR-0015 mode-restore amendment (issue #14), persist on
        // unload: leaving a project must record the mode even if the
        // session ran NO command (e.g. a bare `--mode advanced` plus
        // read-only browsing). The unload calls `set_mode`, which
        // writes project.yaml from the empty schema + the live mode.
        use crate::persistence::Persistence;
        let dir = tempfile::tempdir().unwrap();
        let persistence = Persistence::new(dir.path().to_path_buf()).with_mode(Mode::Advanced);
        let db_path = dir.path().join("playground.db");
        let db = Database::open_with_persistence(&db_path, persistence).unwrap();

        // No command runs — straight to the unload-style persist.
        db.set_mode(Mode::Advanced).await.unwrap();

        assert_eq!(
            Persistence::read_stored_mode(dir.path()),
            Some(Mode::Advanced),
            "an unload persists the mode with no prior command",
        );
    }

    #[tokio::test]
    async fn unique_flag_round_trips_through_rebuild() {
        // End-to-end: create a table with a non-PK serial,
        // then rebuild from text. The rebuild should re-emit
        // UNIQUE on the column, restoring the constraint
        // exactly as the original.
        use crate::persistence::Persistence;
        let dir = tempfile::tempdir().unwrap();
        let persistence = Persistence::new(dir.path().to_path_buf());
        let db_path = dir.path().join("playground.db");
        let db = Database::open_with_persistence(&db_path, persistence).unwrap();
        db.create_table(
            "T".to_string(),
            vec![col("id", Type::Serial), col("Name", Type::Text)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.add_column(
            "T".to_string(),
            ColumnSpec::new("seq".to_string(), Type::Serial),
            None,
        )
        .await
        .unwrap();
        // Tear down the .db file and rebuild from yaml + csvs.
        drop(db);
        std::fs::remove_file(&db_path).unwrap();
        let persistence2 = Persistence::new(dir.path().to_path_buf());
        let db = Database::open_with_persistence(&db_path, persistence2).unwrap();
        db.rebuild_from_text(dir.path().to_path_buf(), None)
            .await
            .unwrap();
        // Read the schema and confirm `seq` is still unique.
        let desc = db.describe_table("T".to_string()).await.unwrap();
        let seq = desc
            .columns
            .iter()
            .find(|c| c.name == "seq")
            .expect("seq column rebuilt");
        assert_eq!(seq.user_type, Some(Type::Serial));
        // Confirm the UNIQUE constraint is enforced post-
        // rebuild by attempting to UPDATE all rows to the same
        // value... but the table is empty. Instead, insert two
        // rows and then UPDATE.
        for n in ["a", "b"] {
            db.insert(
                "T".to_string(),
                Some(vec!["Name".to_string()]),
                vec![Value::Text(n.to_string())],
                None,
            )
            .await
            .unwrap();
        }
        let err = db
            .update(
                "T".to_string(),
                vec![("seq".to_string(), Value::Number("1".to_string()))],
                RowFilter::AllRows,
                None,
            )
            .await
            .unwrap_err();
        match err {
            DbError::Sqlite { kind, .. } => {
                assert_eq!(kind, SqliteErrorKind::UniqueViolation);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[tokio::test]
    async fn quoted_table_names_round_trip() {
        let db = db();
        // Identifier with internal whitespace would not parse via the DSL
        // today, but the DB layer should still handle it correctly.
        db.create_table(
            "Order Lines".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        let tables = db.list_tables().await.unwrap();
        assert_eq!(tables, vec!["Order Lines".to_string()]);
        let desc = db.describe_table("Order Lines".to_string()).await.unwrap();
        assert_eq!(desc.name, "Order Lines");
    }

    // ---- list_names_for (ADR-0022 §9, stage 7) ----

    #[tokio::test]
    async fn list_names_for_new_name_short_circuits_to_empty() {
        // The user invents new names; schema has nothing to
        // offer. The public method short-circuits before
        // touching the worker.
        let db = db();
        let names = db
            .list_names_for(crate::dsl::grammar::IdentSource::NewName)
            .await
            .unwrap();
        assert!(names.is_empty());
    }

    #[tokio::test]
    async fn list_names_for_table_returns_user_tables_alphabetised() {
        let db = db();
        make_id_table(&db, "Customers").await;
        make_id_table(&db, "Orders").await;
        let names = db
            .list_names_for(crate::dsl::grammar::IdentSource::Tables)
            .await
            .unwrap();
        assert_eq!(names, vec!["Customers".to_string(), "Orders".to_string()]);
    }

    #[tokio::test]
    async fn list_names_for_table_filters_internal_metadata_tables() {
        // The internal __rdbms_* tables must never appear in
        // the completion menu (ADR-0002 user-facing posture).
        let db = db();
        make_id_table(&db, "Customers").await;
        let names = db
            .list_names_for(crate::dsl::grammar::IdentSource::Tables)
            .await
            .unwrap();
        assert_eq!(names, vec!["Customers".to_string()]);
        for n in &names {
            assert!(
                !n.starts_with("__rdbms_"),
                "internal table leaked into completion: {n}",
            );
        }
    }

    #[tokio::test]
    async fn list_names_for_column_returns_distinct_columns_alphabetised() {
        let db = db();
        // Two tables sharing a column name `id`, with different
        // additional columns.
        db.create_table(
            "Customers".to_string(),
            vec![col("id", Type::Serial), col("name", Type::Text)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.create_table(
            "Orders".to_string(),
            vec![col("id", Type::Serial), col("total", Type::Real)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        let names = db
            .list_names_for(crate::dsl::grammar::IdentSource::Columns)
            .await
            .unwrap();
        // `id` appears once despite being in both tables (DISTINCT).
        assert_eq!(
            names,
            vec!["id".to_string(), "name".to_string(), "total".to_string()],
        );
    }

    #[tokio::test]
    async fn list_names_for_relationship_returns_named_relationships() {
        let db = db();
        db.create_table(
            "Customers".to_string(),
            vec![col("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.create_table(
            "Orders".to_string(),
            vec![col("id", Type::Serial), col("CustId", Type::Int)],
            vec!["id".to_string()],
            None,
        )
        .await
        .unwrap();
        db.add_relationship(
            Some("cust_orders".to_string()),
            "Customers".to_string(),
            vec!["id".to_string()],
            "Orders".to_string(),
            vec!["CustId".to_string()],
            crate::dsl::ReferentialAction::NoAction,
            crate::dsl::ReferentialAction::NoAction,
            false,
            None,
        )
        .await
        .unwrap();
        let names = db
            .list_names_for(crate::dsl::grammar::IdentSource::Relationships)
            .await
            .unwrap();
        assert_eq!(names, vec!["cust_orders".to_string()]);
    }

    // --- add constraint / drop constraint (ADR-0029 §2.2) -----

    /// A `Constraint::Check` whose expression references
    /// `column` of type `ty`, parsed from `check_dsl`.
    fn check_constraint(column: &str, ty: Type, check_dsl: &str) -> Constraint {
        Constraint::Check(
            col_c_check(column, ty, check_dsl)
                .check
                .expect("the CHECK expression parses"),
        )
    }

    /// `People` plus an all-NULL plain `int` column `x`.
    async fn people_with_null_column(db: &Database) {
        people_table(db).await;
        db.add_column("People".to_string(), col("x", Type::Int), None)
            .await
            .expect("a plain nullable column adds");
    }

    #[tokio::test]
    async fn add_constraint_not_null_succeeds_on_clean_column() {
        let db = db();
        people_table(&db).await; // every row has a non-null Name
        let desc = db
            .add_constraint(
                "People".to_string(),
                "Name".to_string(),
                Constraint::NotNull,
                None,
            )
            .await
            .expect("NOT NULL applies — no row holds a null");
        assert!(
            desc.columns
                .iter()
                .find(|c| c.name == "Name")
                .unwrap()
                .notnull,
            "the column is now NOT NULL",
        );
    }

    #[tokio::test]
    async fn add_constraint_not_null_refused_when_rows_are_null() {
        let db = db();
        people_with_null_column(&db).await; // `x` is null in every row
        let result = db
            .add_constraint(
                "People".to_string(),
                "x".to_string(),
                Constraint::NotNull,
                None,
            )
            .await;
        let err = result.expect_err("NOT NULL is refused — rows hold null");
        assert!(
            format!("{err}").contains("null"),
            "the refusal explains the null rows: {err}",
        );
    }

    #[tokio::test]
    async fn add_constraint_unique_succeeds_on_distinct_values() {
        let db = db();
        people_table(&db).await; // Names are all distinct
        let desc = db
            .add_constraint(
                "People".to_string(),
                "Name".to_string(),
                Constraint::Unique,
                None,
            )
            .await
            .expect("UNIQUE applies — every Name is distinct");
        assert!(
            desc.columns
                .iter()
                .find(|c| c.name == "Name")
                .unwrap()
                .unique
        );
    }

    #[tokio::test]
    async fn add_constraint_unique_refused_on_duplicate_values() {
        let db = db();
        people_table(&db).await; // Age 35 appears for Bob and Dave
        let result = db
            .add_constraint(
                "People".to_string(),
                "Age".to_string(),
                Constraint::Unique,
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "UNIQUE is refused — the value 35 appears in two rows",
        );
    }

    #[tokio::test]
    async fn add_constraint_check_succeeds_when_data_satisfies_it() {
        let db = db();
        people_table(&db).await; // every Age is >= 0
        let desc = db
            .add_constraint(
                "People".to_string(),
                "Age".to_string(),
                check_constraint("Age", Type::Int, "Age >= 0"),
                None,
            )
            .await
            .expect("the CHECK applies — all rows satisfy it");
        assert!(
            desc.columns
                .iter()
                .find(|c| c.name == "Age")
                .unwrap()
                .check
                .is_some()
        );
    }

    #[tokio::test]
    async fn add_constraint_check_refused_when_rows_violate_it() {
        let db = db();
        people_table(&db).await; // Alice is 25, Bob/Dave are 35
        let result = db
            .add_constraint(
                "People".to_string(),
                "Age".to_string(),
                check_constraint("Age", Type::Int, "Age >= 40"),
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "the CHECK is refused — three rows are under 40"
        );
    }

    #[tokio::test]
    async fn add_constraint_default_succeeds() {
        let db = db();
        people_table(&db).await;
        let desc = db
            .add_constraint(
                "People".to_string(),
                "Age".to_string(),
                Constraint::Default(Value::Number("0".to_string())),
                None,
            )
            .await
            .expect("a DEFAULT applies — it never touches existing rows");
        assert_eq!(
            desc.columns
                .iter()
                .find(|c| c.name == "Age")
                .unwrap()
                .default
                .as_deref(),
            Some("0"),
        );
    }

    #[tokio::test]
    async fn add_constraint_check_is_enforced_after_being_added() {
        let db = db();
        people_table(&db).await;
        db.add_constraint(
            "People".to_string(),
            "Age".to_string(),
            check_constraint("Age", Type::Int, "Age >= 0"),
            None,
        )
        .await
        .unwrap();
        let bad = db
            .insert(
                "People".to_string(),
                None,
                vec![
                    Value::Text("Eve".to_string()),
                    Value::Number("-1".to_string()),
                    Value::Bool(true),
                ],
                None,
            )
            .await;
        assert!(bad.is_err(), "an insert violating the new CHECK is refused");
    }

    #[tokio::test]
    async fn add_constraint_not_null_on_pk_column_is_refused() {
        let db = db();
        people_table(&db).await;
        let result = db
            .add_constraint(
                "People".to_string(),
                "id".to_string(),
                Constraint::NotNull,
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "a PK column is already NOT NULL (ADR-0029 §9)"
        );
    }

    #[tokio::test]
    async fn add_constraint_unique_on_single_column_pk_is_refused() {
        let db = db();
        people_table(&db).await;
        let result = db
            .add_constraint(
                "People".to_string(),
                "id".to_string(),
                Constraint::Unique,
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "a single-column PK is already UNIQUE (ADR-0029 §9)",
        );
    }

    #[tokio::test]
    async fn add_constraint_unique_on_compound_pk_member_is_allowed() {
        // A compound PK does not make its members individually
        // unique, so `unique` on one of them is meaningful.
        let db = db();
        db.create_table(
            "T".to_string(),
            vec![col("a", Type::Int), col("b", Type::Int)],
            vec!["a".to_string(), "b".to_string()],
            None,
        )
        .await
        .unwrap();
        let desc = db
            .add_constraint("T".to_string(), "a".to_string(), Constraint::Unique, None)
            .await
            .expect("UNIQUE on a compound-PK member is allowed");
        assert!(desc.columns.iter().find(|c| c.name == "a").unwrap().unique);
    }

    #[tokio::test]
    async fn add_constraint_default_on_serial_column_is_refused() {
        let db = db();
        people_table(&db).await;
        let result = db
            .add_constraint(
                "People".to_string(),
                "id".to_string(),
                Constraint::Default(Value::Number("5".to_string())),
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "a serial column auto-fills its own values (ADR-0029 §6)",
        );
    }

    #[tokio::test]
    async fn add_constraint_to_missing_column_errors() {
        let db = db();
        people_table(&db).await;
        let result = db
            .add_constraint(
                "People".to_string(),
                "ghost".to_string(),
                Constraint::Unique,
                None,
            )
            .await;
        assert!(result.is_err(), "no such column");
    }

    #[tokio::test]
    async fn drop_constraint_removes_not_null() {
        let db = db();
        people_table(&db).await;
        db.add_constraint(
            "People".to_string(),
            "Name".to_string(),
            Constraint::NotNull,
            None,
        )
        .await
        .unwrap();
        let desc = db
            .drop_constraint(
                "People".to_string(),
                "Name".to_string(),
                ConstraintKind::NotNull,
                None,
            )
            .await
            .expect("the NOT NULL is dropped");
        assert!(
            !desc
                .columns
                .iter()
                .find(|c| c.name == "Name")
                .unwrap()
                .notnull,
            "the column is nullable again",
        );
    }

    #[tokio::test]
    async fn drop_constraint_check_clears_the_constraint() {
        let db = db();
        people_table(&db).await;
        db.add_constraint(
            "People".to_string(),
            "Age".to_string(),
            check_constraint("Age", Type::Int, "Age >= 0"),
            None,
        )
        .await
        .unwrap();
        let desc = db
            .drop_constraint(
                "People".to_string(),
                "Age".to_string(),
                ConstraintKind::Check,
                None,
            )
            .await
            .expect("the CHECK is dropped");
        assert!(
            desc.columns
                .iter()
                .find(|c| c.name == "Age")
                .unwrap()
                .check
                .is_none(),
            "the CHECK is gone from the structure view",
        );
        // With the CHECK gone, a previously-forbidden value inserts.
        db.insert(
            "People".to_string(),
            None,
            vec![
                Value::Text("Eve".to_string()),
                Value::Number("-1".to_string()),
                Value::Bool(true),
            ],
            None,
        )
        .await
        .expect("the CHECK no longer applies");
    }

    #[tokio::test]
    async fn drop_constraint_not_null_on_pk_is_refused() {
        let db = db();
        people_table(&db).await;
        let result = db
            .drop_constraint(
                "People".to_string(),
                "id".to_string(),
                ConstraintKind::NotNull,
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "the PK still enforces NOT NULL — nothing to drop (ADR-0029 §9)",
        );
    }

    #[tokio::test]
    async fn drop_constraint_absent_constraint_is_refused() {
        let db = db();
        people_table(&db).await; // `Name` carries no UNIQUE
        let result = db
            .drop_constraint(
                "People".to_string(),
                "Name".to_string(),
                ConstraintKind::Unique,
                None,
            )
            .await;
        assert!(
            result.is_err(),
            "dropping a constraint the column never had is a friendly refusal",
        );
    }
}
