//! Running a `querylog.db` schema migration, and proving one landed.
//!
//! `querylog_versions.zig` says which versions exist and which SQL gets from
//! one to the next. This file executes that chain against a real file, and
//! `schemaEquivalent` is the oracle a test uses to assert the migrated file is
//! indistinguishable from a freshly created one.
//!
//! The contract the whole design rests on: a migration either lands completely
//! or leaves the file exactly as it was. There is no third state and no
//! salvage path — a failure refuses the startup rather than recreating
//! anything, because the operator's query history is the thing being protected.

const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;

const db = @import("db.zig");

const log = std.log.scoped(.querylog_migrations);

pub const Error = error{
    /// The migration did not happen. The file is logically what it was before.
    MigrationFailed,
    /// The pre-migration backup could not be made, so the migration was never
    /// attempted. The file is untouched.
    MigrationBackupFailed,
    /// Internal to this module and `querylog_schema.open`: the migration
    /// COMMITTED, and something after the commit failed. The file is at the
    /// target version and the backup is kept; the connection is no longer
    /// trustworthy, so the open path closes it and refuses this one startup.
    MigrationCommittedButUnclean,
    /// Internal, the same route as the one above for a different reason: the
    /// migration did NOT happen — the file is logically what it was and this
    /// run's backup is gone — but restoring the connection's pragmas failed, so
    /// it may still carry `foreign_keys = OFF` or `legacy_alter_table = ON`.
    /// `MigrationFailed` promises a usable connection and this cannot keep that
    /// promise, so it travels as its own error and the open path closes the
    /// handle rather than handing a half-configured connection to the server.
    MigrationFailedUnclean,
    NameTooLong,
} || db.Error;

/// Reports a condition that refuses a startup or abandons a migration.
///
/// `err`, except while a test has said it is deliberately causing one. The test
/// runner fails a test that logs at `err`, and every refusal path in this
/// milestone is a path some test has to drive on purpose. Capture is opt-in, so
/// an unexpected refusal in an unrelated test still fails it — the same
/// reasoning as `db.read_tx_faults`.
fn fail(comptime fmt: []const u8, args: anytype) void {
    if (expected_failures.capturing()) {
        log.warn(fmt, args);
    } else {
        log.err(fmt, args);
    }
}

const failure_seam = if (builtin.is_test) struct {
    var capturing: bool = false;
} else struct {};

/// Shared by `querylog_schema.zig`, whose refusal and restamp-failure lines are
/// the same class of deliberate fault.
pub const expected_failures = struct {
    pub fn capturing() bool {
        if (!builtin.is_test) return false;
        return failure_seam.capturing;
    }

    pub fn begin() void {
        if (builtin.is_test) failure_seam.capturing = true;
    }

    pub fn end() void {
        if (builtin.is_test) failure_seam.capturing = false;
    }
};

/// Long enough for any path this program will be handed, plus a backup suffix.
const path_buf_len = 4096 + 64;

const backup_infix = ".pre-migrate-";

// ---------------------------------------------------------------------------
// the step runner
// ---------------------------------------------------------------------------

/// Executes `sql` and stamps `target`, inside the transaction the CALLER has
/// already opened. It never opens or closes one.
///
/// SLICING CONTRACT: `sql` is exactly the `[from, target)` suffix of the chain
/// — `sql[0]` migrates `from` to `from + 1`. Production callers slice
/// `step_sql[from - minimum_supported_version ..]`; tests pass synthetic
/// chains. The assert below is the whole guard against an off-by-one that would
/// stamp a version the file does not have.
///
/// While the steps run, SQLite's authorizer denies every transaction statement.
/// A post-step `sqlite3_get_autocommit` check alone would not do: a step
/// containing `COMMIT; BEGIN IMMEDIATE;` leaves autocommit off and looks fine,
/// having silently split the migration into two transactions. Denying at
/// prepare time means such a step cannot run at all. The autocommit check stays
/// as a belt against anything that ends a transaction without a statement the
/// authorizer sees.
pub fn migrateSteps(
    database: *db.Db,
    sql: []const [:0]const u8,
    from: i32,
    target: i32,
) (db.Error || error{TransactionViolation})!void {
    assert(target > from);
    assert(sql.len == @as(usize, @intCast(target - from)));
    assert(database.inTransaction());

    var guard: db.TransactionGuard = .{};
    try database.denyTransactions(&guard);
    defer database.clearAuthorizer();

    for (sql, 0..) |step, index| {
        database.exec(step) catch |e| {
            if (guard.denied) {
                fail("migration step {d} ({d} -> {d}) contains a transaction statement", .{
                    index,
                    from + @as(i32, @intCast(index)),
                    from + @as(i32, @intCast(index)) + 1,
                });
                return error.TransactionViolation;
            }
            var buf: [256]u8 = undefined;
            fail("migration step {d} ({d} -> {d}) failed: {s}", .{
                index,
                from + @as(i32, @intCast(index)),
                from + @as(i32, @intCast(index)) + 1,
                database.lastError(&buf),
            });
            return e;
        };
        if (!database.inTransaction()) {
            fail("migration step {d} ended the migration transaction", .{index});
            return error.TransactionViolation;
        }
    }

    var stamp_buf: [64]u8 = undefined;
    const stamp = std.fmt.bufPrintZ(&stamp_buf, "PRAGMA user_version = {d};", .{target}) catch
        unreachable; // an i32 and a fixed prefix cannot overrun 64 bytes
    try database.exec(stamp);
}

// ---------------------------------------------------------------------------
// the orchestration
// ---------------------------------------------------------------------------

/// Backs the file up, migrates it from `from` to `target` in one transaction,
/// and keeps exactly this run's backup.
///
/// `database` is borrowed and this function never closes it: the caller owns the
/// handle and already has the one error-path close. What the caller gets back
/// decides what that handle is worth.
///
/// - Success and `error.MigrationFailed`, `error.MigrationBackupFailed`: the
///   connection is USABLE. Its pragmas are what `applyPragmas` guarantees.
/// - `error.MigrationCommittedButUnclean` (the commit succeeded, the restore did
///   not) and `error.MigrationFailedUnclean` (the migration did not happen and
///   the restore did not either): the connection MUST NOT be used, because it
///   may still hold `foreign_keys = OFF` or `legacy_alter_table = ON`. Both are
///   internal to this module and `querylog_schema.openVersioned`, which closes
///   the handle on each and surfaces `error.MigrationFailed`.
///
/// A failed restore is therefore never swallowed. Reporting "the migration
/// failed, carry on with this connection" while foreign keys are off would let
/// the server write rows no constraint ever checked.
///
/// `dir` and `path` follow `querylog_schema.open`'s resolution rule: either an
/// absolute path with `dir` on its parent, or `std.Io.Dir.cwd()` with a
/// cwd-relative path. The backup is written beside `path`.
pub fn runMigration(
    io: std.Io,
    dir: std.Io.Dir,
    path: [:0]const u8,
    database: *db.Db,
    sql: []const [:0]const u8,
    from: i32,
    target: i32,
) Error!void {
    assert(target > from);
    assert(sql.len == @as(usize, @intCast(target - from)));

    var backup_buf: [path_buf_len]u8 = undefined;
    const backup = try makeBackup(io, dir, path, database, &backup_buf);

    // Both pragmas are connection-global and non-transactional, so they are set
    // before BEGIN and restored on every way out.
    //
    // `foreign_keys = OFF` is not a convenience. `applyPragmas` turns foreign
    // keys ON, and with them on, renaming a table that another table REFERENCES
    // rewrites the child's stored FK text to point at `<t>_old` — which then
    // dangles the moment `<t>_old` drops. `legacy_alter_table = ON` alone does
    // not prevent that; it governs views and the renamed table's own text.
    enterMigrationMode(database) catch {
        var buf: [256]u8 = undefined;
        fail("cannot prepare the connection for migration {d} -> {d}: {s}", .{
            from, target, database.lastError(&buf),
        });
        deleteQuietly(io, dir, backup);
        return restoreAfterFailure(database, from, target);
    };

    migrateInTransaction(database, sql, from, target) catch |e| {
        var buf: [256]u8 = undefined;
        fail("querylog migration {d} -> {d} failed before commit ({t}): {s}; " ++
            "the database is unchanged", .{ from, target, e, database.lastError(&buf) });
        deleteQuietly(io, dir, backup);
        return restoreAfterFailure(database, from, target);
    };

    leaveMigrationMode(database) catch {
        var buf: [256]u8 = undefined;
        fail("querylog migration {d} -> {d} COMMITTED and the database IS at version {d}, " ++
            "but the connection could not be restored: {s}; the backup '{s}' is kept and " ++
            "the next start will open the migrated file normally", .{
            from, target, target, database.lastError(&buf), backup,
        });
        return error.MigrationCommittedButUnclean;
    };

    // Authoritative retention: this run knows the exact name to keep, so every
    // other backup goes now. `querylog_schema`'s every-open pass only retries
    // what fails here.
    pruneBackupsExcept(io, dir, path, std.fs.path.basename(backup));
    log.info("migrated querylog database '{s}' from version {d} to {d}; " ++
        "pre-migration backup kept as '{s}'", .{ path, from, target, backup });
}

/// BEGIN IMMEDIATE, re-check the stamp under the lock, run the chain, verify
/// referential integrity, COMMIT. Any error leaves the transaction rolled back.
fn migrateInTransaction(
    database: *db.Db,
    sql: []const [:0]const u8,
    from: i32,
    target: i32,
) !void {
    var tx = try db.Tx.begin(database);
    errdefer tx.rollback();

    // nxdns owns this file exclusively, so a stamp that moved between the
    // classification and the lock means another process is writing to it.
    // Migrating on top of that would be guessing.
    const stamped = try database.queryInt("PRAGMA user_version");
    if (stamped != from) {
        fail("querylog user_version changed from {d} to {d} while the migration was starting; " ++
            "another process is using the file", .{ from, stamped });
        return error.MigrationFailed;
    }

    try migrateSteps(database, sql, from, target);

    var check = try database.prepare("PRAGMA foreign_key_check");
    defer check.deinit();
    if (try check.step()) {
        fail("querylog migration {d} -> {d} left a foreign key violation in table '{s}'", .{
            from, target, check.columnText(0),
        });
        return error.MigrationFailed;
    }

    try tx.commit();
}

/// Restores the connection after a migration that did not happen, and says which
/// error the caller is looking at.
///
/// The caller has already logged WHY the migration failed and deleted this run's
/// backup; the file is logically untouched either way. The only question left is
/// whether the connection came back, and the answer changes the error rather
/// than being dropped.
fn restoreAfterFailure(database: *db.Db, from: i32, target: i32) Error {
    leaveMigrationMode(database) catch {
        var buf: [256]u8 = undefined;
        fail("querylog migration {d} -> {d} did NOT happen and the database is unchanged, " ++
            "but the connection's pragmas could not be restored: {s}; the connection is " ++
            "closed and the server does not start", .{ from, target, database.lastError(&buf) });
        return error.MigrationFailedUnclean;
    };
    return error.MigrationFailed;
}

fn enterMigrationMode(database: *db.Db) db.Error!void {
    try database.exec("PRAGMA foreign_keys = OFF;");
    try database.exec("PRAGMA legacy_alter_table = ON;");
}

/// Restores what `applyPragmas` guarantees every connection to this file has.
fn leaveMigrationMode(database: *db.Db) db.Error!void {
    try database.exec("PRAGMA legacy_alter_table = OFF;");
    try database.exec("PRAGMA foreign_keys = ON;");
}

// ---------------------------------------------------------------------------
// the backup
// ---------------------------------------------------------------------------

/// `VACUUM INTO` on the live connection with no transaction open: it produces a
/// complete, consistent copy of the committed database including whatever is
/// still only in the write-ahead log, which a byte copy of the main file would
/// miss.
///
/// Returns the backup path, borrowed from `buf`.
fn makeBackup(
    io: std.Io,
    dir: std.Io.Dir,
    path: [:0]const u8,
    database: *db.Db,
    buf: *[path_buf_len]u8,
) Error![]const u8 {
    assert(!database.inTransaction());
    const seconds = std.Io.Clock.real.now(io).toSeconds();

    var attempt: u32 = 1;
    while (attempt < 100) : (attempt += 1) {
        const backup = (if (attempt == 1)
            std.fmt.bufPrint(buf, "{s}{s}{d}", .{ path, backup_infix, seconds })
        else
            std.fmt.bufPrint(buf, "{s}{s}{d}-{d}", .{ path, backup_infix, seconds, attempt })) catch
            return error.NameTooLong;

        // `VACUUM INTO` refuses an existing destination itself; probing first is
        // what turns that refusal into a retry under a different name instead of
        // a failed migration.
        dir.access(io, backup, .{}) catch |e| switch (e) {
            error.FileNotFound => {
                try vacuumInto(database, backup, io, dir);
                return backup;
            },
            else => {
                fail("cannot probe backup destination '{s}': {t}", .{ backup, e });
                return error.MigrationBackupFailed;
            },
        };
    }
    fail("cannot find an unused backup name beside '{s}'", .{path});
    return error.MigrationBackupFailed;
}

fn vacuumInto(database: *db.Db, backup: []const u8, io: std.Io, dir: std.Io.Dir) Error!void {
    var sql_buf: [path_buf_len * 2 + 32]u8 = undefined;
    var quoted_buf: [path_buf_len * 2]u8 = undefined;
    const quoted = quoteLiteral(&quoted_buf, backup) catch return error.NameTooLong;
    const sql = std.fmt.bufPrintZ(&sql_buf, "VACUUM INTO '{s}';", .{quoted}) catch
        return error.NameTooLong;

    database.exec(sql) catch {
        var buf: [256]u8 = undefined;
        fail("cannot back up the querylog database to '{s}': {s}", .{ backup, database.lastError(&buf) });
        // Only the destination this call just named: an older, valid backup
        // beside it is the operator's last copy and must survive a failure here.
        deleteQuietly(io, dir, backup);
        return error.MigrationBackupFailed;
    };

    // SQLite creates the destination at `0644 & ~umask`, as it does the live
    // file, and the backup holds the same query history. A backup this process
    // cannot lock down may be world-readable, so it is a failed backup: drop it
    // and refuse, exactly as a failed `VACUUM INTO` does.
    dir.setFilePermissions(io, backup, .fromMode(0o600), .{}) catch |e| {
        fail("cannot restrict the querylog backup '{s}' to 0600: {t}", .{ backup, e });
        deleteQuietly(io, dir, backup);
        return error.MigrationBackupFailed;
    };
}

/// Doubles every `'` so `text` can sit inside a single-quoted SQL literal.
/// `VACUUM INTO` takes an expression, not a bindable parameter, so the path has
/// to enter the statement as text — and a data directory is operator input.
fn quoteLiteral(buf: []u8, text: []const u8) error{NoSpaceLeft}![]const u8 {
    var out: usize = 0;
    for (text) |ch| {
        const width: usize = if (ch == '\'') 2 else 1;
        if (out + width > buf.len) return error.NoSpaceLeft;
        if (ch == '\'') {
            buf[out] = '\'';
            out += 1;
        }
        buf[out] = ch;
        out += 1;
    }
    return buf[0..out];
}

fn deleteQuietly(io: std.Io, dir: std.Io.Dir, target: []const u8) void {
    dir.deleteFile(io, target) catch |e| switch (e) {
        error.FileNotFound => {},
        else => log.warn("cannot delete '{s}': {t}", .{ target, e }),
    };
}

// ---------------------------------------------------------------------------
// backup retention
// ---------------------------------------------------------------------------

/// The authoritative pass: this run made `keep`, so everything else beside it
/// is a previous migration's backup and the contract keeps only the newest.
///
/// Best effort by design. A backup that will not delete is disk to reclaim, not
/// a reason to refuse a startup whose migration already committed.
pub fn pruneBackupsExcept(io: std.Io, dir: std.Io.Dir, path: []const u8, keep: []const u8) void {
    sweep(io, dir, path, .{ .keep_name = keep });
}

/// The retry pass, run on every plain successful open: deletes backups from
/// migrations whose own step-4 cleanup failed.
///
/// Deliberately weaker than the pass above, because it does not know which name
/// this run made. It deletes only names whose epoch is STRICTLY below the
/// maximum epoch present, so every file tied at the newest epoch survives —
/// including collision suffixes — and a name that does not parse is never
/// touched.
///
/// **This assumes a forward-moving wall clock between migrations.** Under a
/// clock rollback an older backup could carry the higher epoch and outrank a
/// genuinely newer one. That is why the exact-name pass above is the primary
/// mechanism and this is only its retry.
pub fn pruneBackupsConservative(io: std.Io, dir: std.Io.Dir, path: []const u8) void {
    var prefix_buf: [path_buf_len]u8 = undefined;
    const prefix = backupPrefix(&prefix_buf, path) orelse return;

    var newest: ?i64 = null;
    {
        var parent = openParent(io, dir, path) orelse return;
        defer parent.close(io);
        var it = parent.iterate();
        while (it.next(io) catch |e| {
            log.warn("cannot scan for querylog backups: {t}", .{e});
            return;
        }) |entry| {
            if (entry.kind != .file) continue;
            const epoch = backupEpoch(entry.name, prefix) orelse continue;
            if (newest == null or epoch > newest.?) newest = epoch;
        }
    }
    const max = newest orelse return;
    sweep(io, dir, path, .{ .below_epoch = max });
}

const SweepRule = union(enum) {
    /// Delete every backup whose name is not this one.
    keep_name: []const u8,
    /// Delete every backup whose epoch is strictly below this.
    below_epoch: i64,
};

/// One pass over the directory, collecting victims before deleting any of them:
/// removing entries from under a live directory iterator is not something the
/// `std.Io.Dir` iterator promises to survive.
fn sweep(io: std.Io, dir: std.Io.Dir, path: []const u8, rule: SweepRule) void {
    var prefix_buf: [path_buf_len]u8 = undefined;
    const prefix = backupPrefix(&prefix_buf, path) orelse return;

    var rounds: u32 = 0;
    while (rounds < 64) : (rounds += 1) {
        var victims: Victims = .{};
        var parent = openParent(io, dir, path) orelse return;
        defer parent.close(io);
        {
            var it = parent.iterate();
            while (it.next(io) catch |e| {
                log.warn("cannot scan for querylog backups: {t}", .{e});
                return;
            }) |entry| {
                if (entry.kind != .file) continue;
                const epoch = backupEpoch(entry.name, prefix) orelse continue;
                const doomed = switch (rule) {
                    .keep_name => |keep| !std.mem.eql(u8, entry.name, keep),
                    .below_epoch => |max| epoch < max,
                };
                if (!doomed) continue;
                if (!victims.push(entry.name)) break;
            }
        }
        for (0..victims.count) |i| {
            deleteQuietly(io, parent, victims.names[i][0..victims.lengths[i]]);
        }
        if (!victims.overflowed) return;
    }
    log.warn("gave up sweeping querylog backups beside '{s}'", .{path});
}

/// A bounded batch of names to delete. The list is short in every real
/// deployment — one migration leaves one backup — so overflow means "sweep
/// again" rather than "allocate".
const Victims = struct {
    const capacity = 16;
    const name_len = 256;

    names: [capacity][name_len]u8 = undefined,
    lengths: [capacity]usize = undefined,
    count: usize = 0,
    overflowed: bool = false,

    fn push(self: *Victims, name: []const u8) bool {
        if (name.len > name_len) {
            log.warn("querylog backup name is too long to delete: '{s}'", .{name});
            return true;
        }
        if (self.count == capacity) {
            self.overflowed = true;
            return false;
        }
        @memcpy(self.names[self.count][0..name.len], name);
        self.lengths[self.count] = name.len;
        self.count += 1;
        return true;
    }
};

fn openParent(io: std.Io, dir: std.Io.Dir, path: []const u8) ?std.Io.Dir {
    const parent_path = std.fs.path.dirname(path) orelse ".";
    return dir.openDir(io, parent_path, .{ .iterate = true }) catch |e| {
        log.warn("cannot open '{s}' to manage querylog backups: {t}", .{ parent_path, e });
        return null;
    };
}

/// `<basename><infix>`: what every backup beside `path` starts with.
fn backupPrefix(buf: []u8, path: []const u8) ?[]const u8 {
    return std.fmt.bufPrint(buf, "{s}{s}", .{ std.fs.path.basename(path), backup_infix }) catch {
        log.warn("querylog path is too long to manage backups for: '{s}'", .{path});
        return null;
    };
}

/// The epoch in `<basename>.pre-migrate-<epoch>` or
/// `<basename>.pre-migrate-<epoch>-<n>`, or null when `name` is not a backup
/// this code wrote. Anything that does not parse is somebody else's file.
fn backupEpoch(name: []const u8, prefix: []const u8) ?i64 {
    if (!std.mem.startsWith(u8, name, prefix)) return null;
    const rest = name[prefix.len..];
    if (rest.len == 0) return null;
    const digits = if (std.mem.indexOfScalar(u8, rest, '-')) |dash| blk: {
        const suffix = rest[dash + 1 ..];
        if (suffix.len == 0) return null;
        _ = std.fmt.parseInt(u32, suffix, 10) catch return null;
        break :blk rest[0..dash];
    } else rest;
    if (digits.len == 0) return null;
    return std.fmt.parseInt(i64, digits, 10) catch null;
}

// ---------------------------------------------------------------------------
// the equivalence oracle
// ---------------------------------------------------------------------------

/// True when `a` and `b` carry the same schema, in the sense that no SQL
/// statement could tell them apart.
///
/// Two layers, both of which must agree:
///
/// 1. **Exact text.** Every non-`sqlite_` object's `(type, name, tbl_name,
///    sql)`, with `sql` compared byte for byte. No normalization is needed or
///    wanted: the rebuild rule in `querylog_versions.step_sql` requires a
///    migrated table to carry the verbatim fresh CREATE text, and a fresh file
///    trivially does. This layer is what sees CHECK constraints, foreign key
///    clauses, WITHOUT ROWID, partial-index predicates and trigger bodies.
/// 2. **Structural belt.** Per table, `table_xinfo` plus the `table_list`
///    `wr`/`strict` flags and `foreign_key_list`; per index, `index_list`
///    flags plus `index_xinfo`. This is what stops a change SQLite records
///    somewhere other than the CREATE text from passing.
///
/// Both layers land in one canonical text per database, which is then compared
/// whole — the sort orders come from the queries themselves.
pub fn schemaEquivalent(
    gpa: std.mem.Allocator,
    a: *db.Db,
    b: *db.Db,
) (db.Error || std.mem.Allocator.Error)!bool {
    var text_a: std.ArrayList(u8) = .empty;
    defer text_a.deinit(gpa);
    var text_b: std.ArrayList(u8) = .empty;
    defer text_b.deinit(gpa);

    try describeSchema(gpa, a, &text_a);
    try describeSchema(gpa, b, &text_b);
    return std.mem.eql(u8, text_a.items, text_b.items);
}

const DescribeError = db.Error || std.mem.Allocator.Error;

fn describeSchema(gpa: std.mem.Allocator, database: *db.Db, out: *std.ArrayList(u8)) DescribeError!void {
    {
        var stmt = try database.prepare(
            \\SELECT type, name, tbl_name, coalesce(sql, '')
            \\FROM sqlite_schema WHERE name NOT LIKE 'sqlite\_%' ESCAPE '\'
            \\ORDER BY type, name, tbl_name
        );
        defer stmt.deinit();
        while (try stmt.step()) {
            try out.print(gpa, "O|{s}|{s}|{s}|{s}\n", .{
                stmt.columnText(0), stmt.columnText(1), stmt.columnText(2), stmt.columnText(3),
            });
        }
    }

    const table_names_sql =
        \\SELECT name FROM sqlite_schema
        \\WHERE type = 'table' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'
        \\ORDER BY name
    ;
    var tables = try collectNames(gpa, database, table_names_sql, null);
    defer freeNames(gpa, &tables);

    for (tables.items) |table| {
        try describeRows(gpa, database, out, "T", table,
            \\SELECT wr, strict FROM pragma_table_list
            \\WHERE schema = 'main' AND name = ?1
        );
        try describeRows(gpa, database, out, "X", table,
            \\SELECT cid, name, type, "notnull", coalesce(dflt_value, '<null>'), pk, hidden
            \\FROM pragma_table_xinfo(?1) ORDER BY cid
        );
        try describeRows(gpa, database, out, "F", table,
            \\SELECT id, seq, "table", "from", coalesce("to", '<null>'), on_update, on_delete, match
            \\FROM pragma_foreign_key_list(?1) ORDER BY id, seq
        );
        // Autoindexes are included on purpose: a UNIQUE constraint's index is
        // part of what the file enforces, and both sides list them the same way.
        try describeRows(gpa, database, out, "L", table,
            \\SELECT name, "unique", origin, partial
            \\FROM pragma_index_list(?1) ORDER BY name
        );

        var indexes = try collectNames(
            gpa,
            database,
            "SELECT name FROM pragma_index_list(?1) ORDER BY name",
            table,
        );
        defer freeNames(gpa, &indexes);
        for (indexes.items) |index| {
            try describeRows(gpa, database, out, "I", index,
                \\SELECT seqno, cid, coalesce(name, '<null>'), desc, coll, key
                \\FROM pragma_index_xinfo(?1) ORDER BY seqno
            );
        }
    }
}

/// One line per row, tagged and prefixed with the object the pragma was asked
/// about, so a difference names what differs rather than just where.
fn describeRows(
    gpa: std.mem.Allocator,
    database: *db.Db,
    out: *std.ArrayList(u8),
    comptime tag: []const u8,
    subject: []const u8,
    sql: []const u8,
) DescribeError!void {
    var stmt = try database.prepare(sql);
    defer stmt.deinit();
    try stmt.bindText(1, subject);
    while (try stmt.step()) {
        try out.print(gpa, tag ++ "|{s}", .{subject});
        var col: c_int = 0;
        const columns = std.math.cast(c_int, columnCount(&stmt)) orelse unreachable;
        while (col < columns) : (col += 1) {
            try out.print(gpa, "|{s}", .{stmt.columnTextOrNull(col) orelse "<null>"});
        }
        try out.append(gpa, '\n');
    }
}

fn columnCount(stmt: *db.Stmt) usize {
    return @intCast(db.c.sqlite3_column_count(stmt.handle));
}

/// Names from a query with one text column, owned by `gpa`. `subject`, when
/// present, is bound as `?1` — the table a pragma function is asked about.
///
/// The names are copied out before anything else runs against the connection:
/// the pragma statements below step while this list is alive, and a borrowed
/// `columnText` would not survive that.
fn collectNames(
    gpa: std.mem.Allocator,
    database: *db.Db,
    sql: []const u8,
    subject: ?[]const u8,
) DescribeError!std.ArrayList([]u8) {
    var names: std.ArrayList([]u8) = .empty;
    errdefer freeNames(gpa, &names);

    var stmt = try database.prepare(sql);
    defer stmt.deinit();
    if (subject) |t| try stmt.bindText(1, t);
    while (try stmt.step()) {
        try names.append(gpa, try stmt.columnTextAlloc(gpa, 0));
    }
    return names;
}

fn freeNames(gpa: std.mem.Allocator, names: *std.ArrayList([]u8)) void {
    for (names.items) |name| gpa.free(name);
    names.deinit(gpa);
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const testing = std.testing;

/// A synthetic schema, not `querylog_schema.ddl`. The chains below are test
/// fixtures for the runner, and pinning them to the real schema would make
/// every future schema edit rewrite tests that are not about the schema. The
/// two table names are the real ones only because the parent/child rebuild
/// hazard is what several of these tests are about.
const syn_domains =
    \\CREATE TABLE domains (
    \\    id INTEGER PRIMARY KEY,
    \\    domain TEXT NOT NULL UNIQUE
    \\)
;

const syn_query_log_v1 =
    \\CREATE TABLE query_log (
    \\    id INTEGER PRIMARY KEY,
    \\    domain_id INTEGER NOT NULL REFERENCES domains(id),
    \\    client_ip TEXT NOT NULL
    \\)
;

/// The verbatim text a v3 file must end up storing, whether it was created
/// fresh or rebuilt by step 2. That equality is the rebuild rule.
const syn_query_log_v3 =
    \\CREATE TABLE query_log (
    \\    id INTEGER PRIMARY KEY,
    \\    domain_id INTEGER NOT NULL REFERENCES domains(id),
    \\    client_ip TEXT NOT NULL,
    \\    qtype INTEGER,
    \\    CHECK (qtype IS NULL OR qtype BETWEEN 0 AND 65535)
    \\)
;

const syn_index = "CREATE INDEX idx_query_log_domain ON query_log(domain_id)";
const syn_notes = "CREATE TABLE notes (id INTEGER PRIMARY KEY, note TEXT NOT NULL)";

const syn_ddl_v1: [:0]const u8 = syn_domains ++ ";\n" ++ syn_query_log_v1 ++ ";\n" ++ syn_index ++ ";\n";
const syn_ddl_v3: [:0]const u8 =
    syn_domains ++ ";\n" ++ syn_query_log_v3 ++ ";\n" ++ syn_index ++ ";\n" ++ syn_notes ++ ";\n";

const syn_step_1_to_2: [:0]const u8 = syn_notes ++ ";\nINSERT INTO notes (id, note) VALUES (1, 'from step 1');\n";

/// The full A.2 rebuild sequence: rename, create verbatim, copy, drop, recreate
/// the dependent index.
const syn_step_2_to_3: [:0]const u8 =
    \\ALTER TABLE query_log RENAME TO query_log_old;
++ "\n" ++ syn_query_log_v3 ++ ";\n" ++
    \\INSERT INTO query_log (id, domain_id, client_ip)
    \\SELECT id, domain_id, client_ip FROM query_log_old;
    \\DROP TABLE query_log_old;
++ "\n" ++ syn_index ++ ";\n";

const syn_chain: []const [:0]const u8 = &.{ syn_step_1_to_2, syn_step_2_to_3 };

/// A step that rebuilds the table the other one REFERENCES. With
/// `foreign_keys` left on, the rename would rewrite `query_log`'s stored FK
/// text to `domains_old`, and dropping `domains_old` would leave it dangling.
const syn_step_rebuild_parent: [:0]const u8 =
    \\ALTER TABLE domains RENAME TO domains_old;
++ "\n" ++ syn_domains ++ ";\n" ++
    \\INSERT INTO domains (id, domain) SELECT id, domain FROM domains_old;
    \\DROP TABLE domains_old;
    \\
;

const Harness = struct {
    threaded: std.Io.Threaded,
    tmp: std.testing.TmpDir,
    buf: [256]u8 = undefined,

    fn init() Harness {
        return .{
            .threaded = .init(testing.allocator, .{}),
            .tmp = testing.tmpDir(.{ .iterate = true }),
        };
    }

    fn deinit(self: *Harness) void {
        self.tmp.cleanup();
        self.threaded.deinit();
    }

    fn io(self: *Harness) std.Io {
        return self.threaded.io();
    }

    fn path(self: *Harness) [:0]const u8 {
        return std.fmt.bufPrintZ(&self.buf, ".zig-cache/tmp/{s}/querylog.db", .{self.tmp.sub_path}) catch
            unreachable;
    }

    /// A v1 file with two domains and two log rows, stamped and closed.
    fn createV1(self: *Harness) !void {
        var database = try db.Db.open(self.path(), .{ .mode = .read_write_create });
        defer database.close();
        try db.applyPragmas(&database, .{});
        try database.exec(syn_ddl_v1);
        try database.exec(
            \\INSERT INTO domains (id, domain) VALUES (1, 'a.example'), (2, 'b.example');
            \\INSERT INTO query_log (id, domain_id, client_ip)
            \\VALUES (1, 1, '10.0.0.1'), (2, 2, '10.0.0.2');
        );
        try database.exec("PRAGMA user_version = 1;");
    }

    fn openLive(self: *Harness) !db.Db {
        var database = try db.Db.open(self.path(), .{ .mode = .read_write_existing });
        errdefer database.close();
        try db.applyPragmas(&database, .{});
        return database;
    }

    fn backupNames(self: *Harness, out: *std.ArrayList([]u8)) !void {
        var it = self.tmp.dir.iterate();
        while (try it.next(self.io())) |entry| {
            if (!std.mem.startsWith(u8, entry.name, "querylog.db" ++ backup_infix)) continue;
            try out.append(testing.allocator, try testing.allocator.dupe(u8, entry.name));
        }
    }
};

fn freeOwned(list: *std.ArrayList([]u8)) void {
    for (list.items) |item| testing.allocator.free(item);
    list.deinit(testing.allocator);
}

fn schemaTextOf(database: *db.Db, name: []const u8) ![]const u8 {
    var stmt = try database.prepare("SELECT sql FROM sqlite_schema WHERE name = ?1");
    defer stmt.deinit();
    try stmt.bindText(1, name);
    if (!try stmt.step()) return error.NoSuchObject;
    return testing.allocator.dupe(u8, stmt.columnText(0));
}

test "a synthetic chain migrates 1 to 3, keeps every row, and rebuilds verbatim" {
    var h: Harness = .init();
    defer h.deinit();
    try h.createV1();

    var database = try h.openLive();
    defer database.close();

    try runMigration(h.io(), std.Io.Dir.cwd(), h.path(), &database, syn_chain, 1, 3);

    try testing.expectEqual(@as(i64, 3), try database.queryInt("PRAGMA user_version"));
    try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM query_log"));
    try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM domains"));
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM notes"));

    // The rebuild rule, which is the whole reason the oracle's text layer can
    // be exact: the migrated table stores the target's bytes, not SQLite's
    // rewrite of the old ones.
    const rebuilt = try schemaTextOf(&database, "query_log");
    defer testing.allocator.free(rebuilt);
    try testing.expectEqualStrings(syn_query_log_v3, rebuilt);

    // Exactly one backup, and it is a real database holding the pre-migration
    // state — still at version 1, and without the table step 1 added.
    var names: std.ArrayList([]u8) = .empty;
    defer freeOwned(&names);
    try h.backupNames(&names);
    try testing.expectEqual(@as(usize, 1), names.items.len);

    var backup_path_buf: [512]u8 = undefined;
    const backup_path = try std.fmt.bufPrintZ(
        &backup_path_buf,
        ".zig-cache/tmp/{s}/{s}",
        .{ h.tmp.sub_path, names.items[0] },
    );
    var backup = try db.Db.open(backup_path, .{ .mode = .read_only });
    defer backup.close();
    try testing.expectEqual(@as(i64, 1), try backup.queryInt("PRAGMA user_version"));
    try testing.expectEqual(@as(i64, 2), try backup.queryInt("SELECT count(*) FROM query_log"));
    try testing.expectEqual(
        @as(i64, 0),
        try backup.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'notes'"),
    );

    // And it is equivalent to a file freshly created at the target schema.
    var fresh = try db.Db.open(":memory:", .{ .mode = .memory });
    defer fresh.close();
    try fresh.exec(syn_ddl_v3);
    try testing.expect(try schemaEquivalent(testing.allocator, &database, &fresh));
}

test "rebuilding a referenced parent leaves the child's foreign key intact" {
    var h: Harness = .init();
    defer h.deinit();
    try h.createV1();

    var database = try h.openLive();
    defer database.close();

    const chain: []const [:0]const u8 = &.{syn_step_rebuild_parent};
    try runMigration(h.io(), std.Io.Dir.cwd(), h.path(), &database, chain, 1, 2);

    // The hazard this exists for: with `foreign_keys` on, the rename would have
    // rewritten this text to `REFERENCES "domains_old"(id)`, and the DROP two
    // statements later would have left it pointing at nothing.
    const child = try schemaTextOf(&database, "query_log");
    defer testing.allocator.free(child);
    try testing.expectEqualStrings(syn_query_log_v1, child);

    var check = try database.prepare("PRAGMA foreign_key_check");
    defer check.deinit();
    try testing.expect(!try check.step());

    try testing.expectEqual(@as(i64, 1), try database.queryInt("PRAGMA foreign_keys"));
    try testing.expectEqual(@as(i64, 0), try database.queryInt("PRAGMA legacy_alter_table"));
    try testing.expectEqual(@as(i64, 2), try database.queryInt("PRAGMA user_version"));
}

test "a failed parent rebuild restores both pragmas and leaves the parent whole" {
    var h: Harness = .init();
    defer h.deinit();
    try h.createV1();

    var database = try h.openLive();
    defer database.close();

    // The rebuild, then a statement that cannot run: the failure lands with
    // `domains_old` already dropped inside the transaction, which is the worst
    // moment for the rollback and the pragma restore to be wrong.
    const failing: [:0]const u8 = syn_step_rebuild_parent ++ "UPDATE no_such_table SET x = 1;\n";

    expected_failures.begin();
    defer expected_failures.end();
    try testing.expectError(
        error.MigrationFailed,
        runMigration(h.io(), std.Io.Dir.cwd(), h.path(), &database, &.{failing}, 1, 2),
    );

    // Connection-global and non-transactional: the rollback cannot restore
    // these, so the runner has to, on this path as much as on the happy one.
    try testing.expectEqual(@as(i64, 1), try database.queryInt("PRAGMA foreign_keys"));
    try testing.expectEqual(@as(i64, 0), try database.queryInt("PRAGMA legacy_alter_table"));

    try testing.expectEqual(@as(i64, 1), try database.queryInt("PRAGMA user_version"));
    try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM domains"));
    const child = try schemaTextOf(&database, "query_log");
    defer testing.allocator.free(child);
    try testing.expectEqualStrings(syn_query_log_v1, child);
}

test "a mid-chain failure leaves the file untouched and deletes only this run's backup" {
    var h: Harness = .init();
    defer h.deinit();
    try h.createV1();

    // A backup from an earlier, successful migration. It is the operator's last
    // copy and a failed run must not take it.
    try h.tmp.dir.writeFile(h.io(), .{
        .sub_path = "querylog.db" ++ backup_infix ++ "1600000000",
        .data = "an older backup",
    });

    var database = try h.openLive();
    defer database.close();

    const broken: [:0]const u8 = "UPDATE no_such_table SET x = 1;";
    const chain: []const [:0]const u8 = &.{ syn_step_1_to_2, broken };

    expected_failures.begin();
    defer expected_failures.end();
    try testing.expectError(
        error.MigrationFailed,
        runMigration(h.io(), std.Io.Dir.cwd(), h.path(), &database, chain, 1, 3),
    );

    // Logically unchanged: step 1's table is gone with the rolled-back
    // transaction, the stamp never moved, and the rows are all there.
    try testing.expectEqual(@as(i64, 1), try database.queryInt("PRAGMA user_version"));
    try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM query_log"));
    try testing.expectEqual(
        @as(i64, 0),
        try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'notes'"),
    );
    // The pragmas are back to what every connection to this file must have.
    try testing.expectEqual(@as(i64, 1), try database.queryInt("PRAGMA foreign_keys"));
    try testing.expectEqual(@as(i64, 0), try database.queryInt("PRAGMA legacy_alter_table"));

    var names: std.ArrayList([]u8) = .empty;
    defer freeOwned(&names);
    try h.backupNames(&names);
    try testing.expectEqual(@as(usize, 1), names.items.len);
    try testing.expectEqualStrings("querylog.db" ++ backup_infix ++ "1600000000", names.items[0]);
}

test "a step cannot smuggle a transaction statement past the runner" {
    var h: Harness = .init();
    defer h.deinit();
    try h.createV1();

    var database = try h.openLive();
    defer database.close();

    // The exact bypass a post-step autocommit check would miss: after this the
    // connection IS inside a transaction again, so autocommit reads 0 and the
    // step looks clean — while the work before it has been committed
    // irreversibly.
    const smuggler: [:0]const u8 =
        \\CREATE TABLE half_done (id INTEGER PRIMARY KEY);
        \\COMMIT;
        \\BEGIN IMMEDIATE;
    ;

    expected_failures.begin();
    defer expected_failures.end();

    var tx = try db.Tx.begin(&database);
    try testing.expectError(
        error.TransactionViolation,
        migrateSteps(&database, &.{smuggler}, 1, 2),
    );

    // The authorizer is gone, so the cleanup this failure requires can run at
    // all. A leaked one would deny this ROLLBACK and strand the connection
    // inside the migration transaction forever.
    tx.rollback();
    try testing.expect(!database.inTransaction());
    try database.exec("BEGIN IMMEDIATE;");
    try database.exec("COMMIT;");

    try testing.expectEqual(@as(i64, 1), try database.queryInt("PRAGMA user_version"));
    try testing.expectEqual(
        @as(i64, 0),
        try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'half_done'"),
    );
}

test "migrateSteps stamps the target and refuses a chain that does not span the range" {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try database.exec(syn_ddl_v1);
    try database.exec("PRAGMA user_version = 1;");

    var tx = try db.Tx.begin(&database);
    try migrateSteps(&database, syn_chain, 1, 3);
    try tx.commit();

    try testing.expectEqual(@as(i64, 3), try database.queryInt("PRAGMA user_version"));
}

test "PRAGMA user_version is transactional" {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try database.exec("PRAGMA user_version = 41;");

    var tx = try db.Tx.begin(&database);
    try database.exec("PRAGMA user_version = 42;");
    try testing.expectEqual(@as(i64, 42), try database.queryInt("PRAGMA user_version"));
    tx.rollback();

    // The stamp rides the transaction, which is what lets the runner put the
    // steps and the stamp in one unit and get all-or-nothing from SQLite.
    try testing.expectEqual(@as(i64, 41), try database.queryInt("PRAGMA user_version"));
}

test "the backup carries rows that are still only in the write-ahead log" {
    var h: Harness = .init();
    defer h.deinit();
    try h.createV1();

    var database = try h.openLive();
    defer database.close();

    // Committed, but deliberately not checkpointed: a byte copy of the main
    // file would miss this row entirely.
    try database.exec("INSERT INTO domains (id, domain) VALUES (3, 'wal-only.example');");
    const wal = try h.tmp.dir.statFile(h.io(), "querylog.db-wal", .{});
    try testing.expect(wal.size > 0);

    const chain: []const [:0]const u8 = &.{syn_step_1_to_2};
    try runMigration(h.io(), std.Io.Dir.cwd(), h.path(), &database, chain, 1, 2);

    var names: std.ArrayList([]u8) = .empty;
    defer freeOwned(&names);
    try h.backupNames(&names);
    try testing.expectEqual(@as(usize, 1), names.items.len);

    var backup_path_buf: [512]u8 = undefined;
    const backup_path = try std.fmt.bufPrintZ(
        &backup_path_buf,
        ".zig-cache/tmp/{s}/{s}",
        .{ h.tmp.sub_path, names.items[0] },
    );
    var backup = try db.Db.open(backup_path, .{ .mode = .read_only });
    defer backup.close();
    try testing.expectEqual(@as(i64, 3), try backup.queryInt("SELECT count(*) FROM domains"));
    var stmt = try backup.prepare("SELECT domain FROM domains WHERE id = 3");
    defer stmt.deinit();
    try testing.expect(try stmt.step());
    try testing.expectEqualStrings("wal-only.example", stmt.columnText(0));
}

test "two migrations leave exactly one backup, the newer" {
    var h: Harness = .init();
    defer h.deinit();
    try h.createV1();

    var database = try h.openLive();
    defer database.close();

    try runMigration(h.io(), std.Io.Dir.cwd(), h.path(), &database, &.{syn_step_1_to_2}, 1, 2);
    var first: std.ArrayList([]u8) = .empty;
    defer freeOwned(&first);
    try h.backupNames(&first);
    try testing.expectEqual(@as(usize, 1), first.items.len);

    try runMigration(h.io(), std.Io.Dir.cwd(), h.path(), &database, &.{syn_step_2_to_3}, 2, 3);
    var second: std.ArrayList([]u8) = .empty;
    defer freeOwned(&second);
    try h.backupNames(&second);

    // Step 4 knows the exact name it just made, so retention needs no clock
    // comparison at all: everything else goes, even a same-second collision
    // name from the previous run.
    try testing.expectEqual(@as(usize, 1), second.items.len);
    var older = try db.Db.open(blk: {
        var b: [512]u8 = undefined;
        break :blk try std.fmt.bufPrintZ(&b, ".zig-cache/tmp/{s}/{s}", .{ h.tmp.sub_path, second.items[0] });
    }, .{ .mode = .read_only });
    defer older.close();
    // The kept backup is the one from the SECOND migration: version 2, not 1.
    try testing.expectEqual(@as(i64, 2), try older.queryInt("PRAGMA user_version"));
}

test "the conservative pass keeps every backup tied at the newest epoch" {
    var h: Harness = .init();
    defer h.deinit();
    try h.createV1();

    const seeded = [_][]const u8{
        "querylog.db" ++ backup_infix ++ "1600000000",
        "querylog.db" ++ backup_infix ++ "1700000000",
        "querylog.db" ++ backup_infix ++ "1700000000-2",
        // Not a name this code writes, so it is not this code's to delete.
        "querylog.db" ++ backup_infix ++ "not-an-epoch",
    };
    for (seeded) |name| {
        try h.tmp.dir.writeFile(h.io(), .{ .sub_path = name, .data = "x" });
    }

    pruneBackupsConservative(h.io(), std.Io.Dir.cwd(), h.path());

    var left: std.ArrayList([]u8) = .empty;
    defer freeOwned(&left);
    try h.backupNames(&left);
    try testing.expectEqual(@as(usize, 3), left.items.len);
    for (left.items) |name| {
        try testing.expect(!std.mem.eql(u8, name, seeded[0]));
    }
}

test "backupEpoch parses only the names this code writes" {
    const prefix = "querylog.db" ++ backup_infix;
    try testing.expectEqual(@as(i64, 1700000000), backupEpoch(prefix ++ "1700000000", prefix).?);
    try testing.expectEqual(@as(i64, 1700000000), backupEpoch(prefix ++ "1700000000-2", prefix).?);
    try testing.expect(backupEpoch(prefix ++ "1700000000-", prefix) == null);
    try testing.expect(backupEpoch(prefix ++ "1700000000-x", prefix) == null);
    try testing.expect(backupEpoch(prefix ++ "not-an-epoch", prefix) == null);
    try testing.expect(backupEpoch(prefix, prefix) == null);
    try testing.expect(backupEpoch("querylog.db", prefix) == null);
    try testing.expect(backupEpoch("querylog.db.corrupt-1700000000", prefix) == null);
}

test "quoteLiteral doubles every quote so a path cannot end the literal" {
    var buf: [64]u8 = undefined;
    try testing.expectEqualStrings("/var/lib/nxdns", try quoteLiteral(&buf, "/var/lib/nxdns"));
    try testing.expectEqualStrings("/o''brien/db", try quoteLiteral(&buf, "/o'brien/db"));
    try testing.expectEqualStrings("''''", try quoteLiteral(&buf, "''"));
    var tight: [3]u8 = undefined;
    try testing.expectError(error.NoSpaceLeft, quoteLiteral(&tight, "a'b"));
}

// ---------------------------------------------------------------------------
// the oracle
// ---------------------------------------------------------------------------

fn openWith(sql: [:0]const u8) !db.Db {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    errdefer database.close();
    try database.exec(sql);
    return database;
}

fn expectDifference(a_sql: [:0]const u8, b_sql: [:0]const u8) !void {
    var a = try openWith(a_sql);
    defer a.close();
    var b = try openWith(b_sql);
    defer b.close();
    try testing.expect(!try schemaEquivalent(testing.allocator, &a, &b));
}

const oracle_base: [:0]const u8 =
    \\CREATE TABLE parent (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
    \\CREATE TABLE child (
    \\    id INTEGER PRIMARY KEY,
    \\    parent_id INTEGER NOT NULL REFERENCES parent(id),
    \\    rcode INTEGER NOT NULL,
    \\    CHECK (rcode BETWEEN 0 AND 4095)
    \\);
    \\CREATE INDEX idx_child_parent ON child(parent_id) WHERE parent_id > 0;
    \\CREATE VIEW child_view AS SELECT id, rcode FROM child;
    \\CREATE TABLE bucket (a INTEGER NOT NULL, b TEXT NOT NULL, PRIMARY KEY (a, b)) WITHOUT ROWID;
;

test "the oracle calls two fresh databases equal" {
    var a = try openWith(oracle_base);
    defer a.close();
    var b = try openWith(oracle_base);
    defer b.close();
    try testing.expect(try schemaEquivalent(testing.allocator, &a, &b));
}

test "the oracle catches a dropped CHECK constraint" {
    const without_check: [:0]const u8 =
        \\CREATE TABLE parent (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
        \\CREATE TABLE child (
        \\    id INTEGER PRIMARY KEY,
        \\    parent_id INTEGER NOT NULL REFERENCES parent(id),
        \\    rcode INTEGER NOT NULL
        \\);
        \\CREATE INDEX idx_child_parent ON child(parent_id) WHERE parent_id > 0;
        \\CREATE VIEW child_view AS SELECT id, rcode FROM child;
        \\CREATE TABLE bucket (a INTEGER NOT NULL, b TEXT NOT NULL, PRIMARY KEY (a, b)) WITHOUT ROWID;
    ;
    try expectDifference(oracle_base, without_check);
}

test "the oracle catches a dropped foreign key" {
    const without_fk: [:0]const u8 =
        \\CREATE TABLE parent (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
        \\CREATE TABLE child (
        \\    id INTEGER PRIMARY KEY,
        \\    parent_id INTEGER NOT NULL,
        \\    rcode INTEGER NOT NULL,
        \\    CHECK (rcode BETWEEN 0 AND 4095)
        \\);
        \\CREATE INDEX idx_child_parent ON child(parent_id) WHERE parent_id > 0;
        \\CREATE VIEW child_view AS SELECT id, rcode FROM child;
        \\CREATE TABLE bucket (a INTEGER NOT NULL, b TEXT NOT NULL, PRIMARY KEY (a, b)) WITHOUT ROWID;
    ;
    try expectDifference(oracle_base, without_fk);
}

test "the oracle catches a dropped WITHOUT ROWID" {
    const with_rowid: [:0]const u8 =
        \\CREATE TABLE parent (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
        \\CREATE TABLE child (
        \\    id INTEGER PRIMARY KEY,
        \\    parent_id INTEGER NOT NULL REFERENCES parent(id),
        \\    rcode INTEGER NOT NULL,
        \\    CHECK (rcode BETWEEN 0 AND 4095)
        \\);
        \\CREATE INDEX idx_child_parent ON child(parent_id) WHERE parent_id > 0;
        \\CREATE VIEW child_view AS SELECT id, rcode FROM child;
        \\CREATE TABLE bucket (a INTEGER NOT NULL, b TEXT NOT NULL, PRIMARY KEY (a, b));
    ;
    try expectDifference(oracle_base, with_rowid);
}

test "the oracle catches an added column" {
    const extra: [:0]const u8 = oracle_base ++ "ALTER TABLE bucket ADD COLUMN c INTEGER;";
    try expectDifference(oracle_base, extra);
}

test "the oracle catches a rename that skipped the verbatim-text rule" {
    // The rebuild rule exists because SQLite rewrites the stored CREATE text
    // under a rename. A round trip back to the original NAME does not restore
    // the original BYTES, and the exact-text layer is what sees that.
    const renamed: [:0]const u8 = oracle_base ++
        \\ALTER TABLE child RENAME TO child_tmp;
        \\ALTER TABLE child_tmp RENAME TO child;
    ;

    var a = try openWith(oracle_base);
    defer a.close();
    var b = try openWith(renamed);
    defer b.close();

    const original = try schemaTextOf(&a, "child");
    defer testing.allocator.free(original);
    const rewritten = try schemaTextOf(&b, "child");
    defer testing.allocator.free(rewritten);

    // The premise, proved rather than assumed.
    try testing.expect(!std.mem.eql(u8, original, rewritten));
    try testing.expect(!try schemaEquivalent(testing.allocator, &a, &b));
}

test "the oracle calls a migrated file equal to a fresh one at the same version" {
    var h: Harness = .init();
    defer h.deinit();
    try h.createV1();

    var database = try h.openLive();
    defer database.close();
    try runMigration(h.io(), std.Io.Dir.cwd(), h.path(), &database, syn_chain, 1, 3);

    var fresh = try openWith(syn_ddl_v3);
    defer fresh.close();
    try testing.expect(try schemaEquivalent(testing.allocator, &database, &fresh));

    // And it is not simply calling everything equal.
    var stale = try openWith(syn_ddl_v1);
    defer stale.close();
    try testing.expect(!try schemaEquivalent(testing.allocator, &database, &stale));
}
