//! The `querylog.db` schema and its open policy.
//!
//! `querylog.db` carries a logical schema version in `PRAGMA user_version`
//! (`querylog_versions.zig`), and a file stamped below the current version is
//! MIGRATED in place. A healthy file is never replaced and never set aside: a
//! version this build cannot reach refuses the startup with instructions
//! instead, because the operator's query history is not this program's to
//! discard.
//!
//! **Recreating is destructive, so the predicate is a positive whitelist.** Only
//! a missing file, `error.Corrupt`, `error.NotADb` and a failed `PRAGMA
//! quick_check` recreate — genuine corruption, nothing else. Every other error
//! propagates and the file on disk is not touched. `error.Busy` / `error.Locked`
//! mean another process holds the write lock — waiting is right, deleting is
//! catastrophic. `error.OutOfMemory` is this process's problem. `error.CantOpen`
//! is usually a permission or missing-directory problem that recreating would
//! mask rather than fix. Same for `error.ReadOnly`, `error.IoErr`, `error.Full`,
//! `error.Perm`, `error.Auth` and `error.Canceled`.

const std = @import("std");

const db = @import("db.zig");
const migrations = @import("querylog_migrations.zig");
const versions = @import("querylog_versions.zig");

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

/// See `querylog_migrations.fail`: `err`, unless a test has said it is causing
/// this refusal on purpose.
fn fail(comptime fmt: []const u8, args: anytype) void {
    if (migrations.expected_failures.capturing()) {
        log.warn(fmt, args);
    } else {
        log.err(fmt, args);
    }
}

/// PLAN §11.3, plus the coverage watermark of milestone 28. Multi-statement
/// text — it goes through `db.Db.exec`, never through `prepare`.
///
/// The INSERT seeds `querylog_meta`, which is part of the schema
/// rather than a later step: a `query_log` with no watermark beside it cannot
/// answer whether an empty result means "no queries" or "no history", and every
/// database this program reads from is created by executing this string.
/// `unixepoch()` is SQLite's own UTC clock, which is the clock every
/// `timestamp` in the file is measured against.
///
/// `available_since` starts one second *after* `created_at` on purpose. A row
/// logged in the same second the file was created is not evidence that the
/// second is completely covered, and the watermark's whole job is to be
/// conservative. From there it only ever advances, in `queries_repo.pruneOlderThan`.
///
/// The four `bucket_*` tables are the Overview projections (milestone 36), on a
/// 30-minute grain that divides every serving width the API offers. They carry
/// no history of their own: they are born with the file and maintained in the
/// same transaction as every insert and every prune, so SQLite's transaction is
/// the only coherence mechanism there is. There is no backfill path — a file
/// whose projections could disagree with its rows cannot exist.
pub const ddl: [:0]const u8 =
    \\CREATE TABLE domains (
    \\    id INTEGER PRIMARY KEY,
    \\    domain TEXT NOT NULL UNIQUE
    \\);
    \\
    \\CREATE TABLE query_log (
    \\    id INTEGER PRIMARY KEY,
    \\    timestamp INTEGER NOT NULL,
    \\    domain_id INTEGER NOT NULL REFERENCES domains(id),
    \\    client_ip TEXT NOT NULL,            -- text, not a FK: log rows are immutable facts
    \\    qtype INTEGER,
    \\    blocked INTEGER NOT NULL,
    \\    response_time_us INTEGER,
    \\    cache_hit INTEGER,
    \\    upstream TEXT,
    \\    qclass INTEGER NOT NULL,
    \\    rcode INTEGER NOT NULL,
    \\    group_id INTEGER,                   -- text/id pairs, not FKs: a renamed
    \\    group_name TEXT,                    -- group must not rewrite history
    \\    policy_action TEXT NOT NULL,
    \\    policy_reason TEXT NOT NULL,
    \\    matched TEXT,
    \\    source_id INTEGER,
    \\    source_name TEXT,
    \\    cname_target TEXT,
    \\    safe_search_target TEXT,
    \\    route_kind TEXT NOT NULL,
    \\    forward_zone TEXT,
    \\    CHECK (rcode BETWEEN 0 AND 4095)    -- twelve bits (RFC 6891 6.1.3)
    \\);
    \\CREATE INDEX idx_query_log_ts ON query_log(timestamp);
    \\CREATE INDEX idx_query_log_client ON query_log(client_ip);
    \\CREATE INDEX idx_query_log_domain ON query_log(domain_id);
    \\
    \\CREATE TABLE querylog_meta (
    \\    id INTEGER PRIMARY KEY CHECK (id = 1),   -- one row, enforced by the schema
    \\    created_at INTEGER NOT NULL,
    \\    available_since INTEGER NOT NULL
    \\);
    \\INSERT INTO querylog_meta (id, created_at, available_since)
    \\VALUES (1, unixepoch(), unixepoch() + 1);
    \\
    \\CREATE TABLE bucket_totals (
    \\    bucket INTEGER PRIMARY KEY,
    \\    queries INTEGER NOT NULL,
    \\    blocked INTEGER NOT NULL,
    \\    cached INTEGER NOT NULL,
    \\    rt_sum INTEGER NOT NULL,     -- sum(response_time_us) over timed rows
    \\    rt_count INTEGER NOT NULL    -- count(response_time_us)
    \\) WITHOUT ROWID;
    \\
    \\CREATE TABLE bucket_clients (
    \\    bucket INTEGER NOT NULL,
    \\    client_ip TEXT NOT NULL,
    \\    queries INTEGER NOT NULL,
    \\    PRIMARY KEY (bucket, client_ip)
    \\) WITHOUT ROWID;
    \\
    \\CREATE TABLE bucket_types (
    \\    bucket INTEGER NOT NULL,
    \\    qtype INTEGER NOT NULL,      -- -1 encodes a NULL qtype, losslessly
    \\    count INTEGER NOT NULL,
    \\    PRIMARY KEY (bucket, qtype)
    \\) WITHOUT ROWID;
    \\
    \\CREATE TABLE bucket_routes (
    \\    bucket INTEGER NOT NULL,
    \\    route_kind TEXT NOT NULL,
    \\    source_present INTEGER NOT NULL,  -- 0: source NULL; 1: source = source_text
    \\    source_text TEXT NOT NULL,        -- '' when source_present = 0
    \\    count INTEGER NOT NULL,
    \\    PRIMARY KEY (bucket, route_kind, source_present, source_text),
    \\    CHECK (source_present IN (0, 1)),
    \\    CHECK (source_present = 1 OR source_text = '')
    \\) WITHOUT ROWID;
;

/// The fingerprint of an arbitrary DDL text. `tools/cut.zig` calls this at
/// runtime on the DDL of the previous release tag, so the release gate and the
/// server compute the same number from the same function rather than from two
/// copies of one expression.
pub fn fingerprintOf(text: []const u8) i32 {
    return @bitCast(std.hash.Crc32.hash(text));
}

/// `PRAGMA user_version` is a signed 32-bit field. Deriving the fingerprint from
/// the DDL means editing the schema automatically invalidates every existing
/// file — which is exactly the policy.
pub const fingerprint: i32 = blk: {
    // Covers the CRC lookup-table generation in std.hash.crc, which evaluates
    // under this scope's quota and overflows the 1000 default (and 100k).
    @setEvalBranchQuota(2_000_000);
    break :blk fingerprintOf(ddl);
};

/// What a fresh file is stamped with. The logical version, not the fingerprint:
/// from this release on, `user_version` is a version number.
const set_user_version = std.fmt.comptimePrint(
    "PRAGMA user_version = {d};",
    .{versions.current_version},
);

/// Long enough for any path this program will be handed, plus the aside suffix.
/// A longer path is `error.NameTooLong`, which is what the filesystem calls
/// would have returned anyway.
const path_buf_len = 4096 + 64;

/// Corruption, and nothing else. A healthy file with a version this build does
/// not handle refuses the startup; it is never recreated and never set aside.
pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_failed };

pub const OpenResult = struct {
    database: db.Db,
    /// Non-null feeds a counter and the `/api/health` rollup.
    recreated: ?RecreateReason,
    /// The path the previous file was kept as, by value. It existed only in a
    /// stack buffer inside `open` before the diagnostics event needed it, and a
    /// slice of that buffer would dangle the moment `open` returned.
    ///
    /// Empty when nothing was renamed aside, which `.missing` and a clean open
    /// both are.
    aside_buf: [path_buf_len]u8 = undefined,
    aside_len: u16 = 0,

    pub fn aside(self: *const OpenResult) []const u8 {
        return self.aside_buf[0..self.aside_len];
    }
};

pub const Error = db.Error || error{
    AsideNameCollision,
    /// The file's version is above this build's. A downgrade, almost always.
    SchemaTooNew,
    /// The file's version is one this build cannot migrate from: older than
    /// `minimum_supported_version`, or not a stamp nxdns ever wrote.
    SchemaUnsupported,
    MigrationFailed,
    MigrationBackupFailed,
    NameTooLong,
} || std.Io.Dir.RenamePreserveError || std.Io.Dir.DeleteFileError || std.Io.Dir.AccessError;

/// The version metadata `openVersioned` works against. Production passes
/// `production_plan`; tests inject synthetic chains, which is what makes
/// migration, the post-commit branch and the ownership of the handle testable
/// through the real open path while the shipped chain is still empty.
pub const Plan = struct {
    minimum: i32,
    current: i32,
    legacy_fingerprint: i32,
    step_sql: []const [:0]const u8,
};

pub const production_plan: Plan = .{
    .minimum = versions.minimum_supported_version,
    .current = versions.current_version,
    .legacy_fingerprint = versions.legacy_fingerprint,
    .step_sql = versions.step_sql,
};

/// What a stamped `user_version` means. A pure function of the stamp and the
/// plan's three numbers — no file, no clock, no mutation.
pub const Action = enum { open_current, migrate, refuse_too_new, refuse_unsupported };

pub const Classification = struct {
    /// The stamp mapped onto the version line. Equal to the stamp except for
    /// the legacy fingerprint, which IS version 1.
    logical: i32,
    action: Action,
    /// The legacy fingerprint must be replaced by its logical number before the
    /// file is used — but only on a lane that accepts the file. An unsupported
    /// file is never modified.
    restamp: bool,
};

/// The classification table. The order is load-bearing: the legacy fingerprint
/// becomes version 1 FIRST, and only then is version 1 judged against the
/// plan's range. After a future explicit break raises the minimum above 1, a
/// legacy-stamped file therefore classifies as below-minimum and refuses
/// without ever being restamped.
pub fn classify(stamped: i32, plan: Plan) Classification {
    const legacy = stamped == plan.legacy_fingerprint;
    const logical: i32 = if (legacy) 1 else stamped;

    const action: Action = if (logical == plan.current)
        .open_current
    else if (logical >= plan.minimum and logical < plan.current)
        .migrate
    else if (logical > plan.current and logical <= versions.version_floor_guard)
        .refuse_too_new
    else
        .refuse_unsupported;

    const accepted = action == .open_current or action == .migrate;
    return .{ .logical = logical, .action = action, .restamp = legacy and accepted };
}

/// Opens `path`, recreating it if and only if it is genuinely unusable, and
/// migrating it if and only if it carries an older supported version.
///
/// `path` is resolved twice by two different mechanisms: `dir`-relative for the
/// filesystem calls, and process-cwd-relative by SQLite's VFS, which knows
/// nothing about `dir`. The caller must therefore pass either an absolute path
/// with `dir` open on its parent, or `std.Io.Dir.cwd()` with a cwd-relative
/// path.
pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
    var handle: ?db.Db = null;
    errdefer if (handle) |*h| h.close();

    const cause: RecreateReason = probe: {
        dir.access(io, path, .{}) catch |e| switch (e) {
            error.FileNotFound => break :probe .missing,
            else => |other| return other,
        };

        handle = db.Db.open(path, .{ .mode = .read_write_existing }) catch |e|
            break :probe recreatable(e) orelse return e;
        const opened = &handle.?;

        db.applyPragmas(opened, .{}) catch |e|
            break :probe recreatable(e) orelse return e;

        const healthy = quickCheck(opened) catch |e|
            break :probe recreatable(e) orelse return e;
        if (!healthy) break :probe .quick_check_failed;

        try openVersioned(io, dir, path, &handle, production_plan);
        return .{ .database = handle.?, .recreated = null };
    };

    // Close first, so SQLite checkpoints and drops `-wal`/`-shm` where it can.
    if (handle) |*h| h.close();
    handle = null;

    var aside_buf: [path_buf_len]u8 = undefined;
    const aside: ?[]const u8 = if (cause == .missing)
        null
    else
        try renameAside(io, dir, path, cause, &aside_buf);

    // Not optional: a stale WAL left beside the renamed database would be
    // replayed into the freshly created file and corrupt it immediately. Any
    // failure other than "already gone" propagates rather than building the new
    // database on a half-cleaned state.
    try deleteSidecars(io, dir, path);

    const fresh = try createFresh(path);
    if (cause == .missing) {
        log.info("created querylog database '{s}'", .{path});
    } else {
        log.warn("recreated querylog database '{s}': {s}; previous file kept as '{s}'", .{
            path,
            @tagName(cause),
            aside.?,
        });
    }
    var result: OpenResult = .{ .database = fresh, .recreated = cause };
    if (aside) |name| {
        result.aside_len = @intCast(name.len);
        @memcpy(result.aside_buf[0..name.len], name);
    }
    return result;
}

/// The version half of `open`, against an injectable `plan`.
///
/// `handle` is the slot holding the healthy, pragma-applied connection to
/// `path`. On success the connection stays in it, at `plan.current`. On every
/// failure this function closes the connection and sets the slot to null, so
/// the caller's own error-path close cannot double-close it — including the
/// committed-but-unclean path, which is the one place the handle must be
/// dropped even though the file on disk is fine.
///
/// **nxdns owns `path` exclusively.** It opens `querylog.db` once at startup,
/// before it serves anything, and no second process shares a data directory —
/// the standing deployment contract. The backup-then-lock sequence in
/// `querylog_migrations.runMigration` relies on it: between the `VACUUM INTO`
/// and the `BEGIN IMMEDIATE` there is no lock, and the re-read of
/// `user_version` under the lock is what turns a violation of that contract
/// into a refusal instead of a corrupted migration.
pub fn openVersioned(
    io: std.Io,
    dir: std.Io.Dir,
    path: [:0]const u8,
    handle: *?db.Db,
    plan: Plan,
) Error!void {
    const database = &(handle.*.?);
    errdefer closeSlot(handle);

    const stamped64 = try database.queryInt("PRAGMA user_version");
    const stamped = std.math.cast(i32, stamped64) orelse {
        // `user_version` is a signed 32-bit field, so this cannot come from
        // SQLite. Refusing is the same answer any other foreign stamp gets.
        refusalLog(path, stamped64, plan, "SchemaUnsupported");
        return error.SchemaUnsupported;
    };

    const verdict = classify(stamped, plan);
    switch (verdict.action) {
        .refuse_too_new => {
            refusalLog(path, stamped64, plan, "SchemaTooNew");
            return error.SchemaTooNew;
        },
        .refuse_unsupported => {
            refusalLog(path, stamped64, plan, "SchemaUnsupported");
            return error.SchemaUnsupported;
        },
        .open_current, .migrate => {},
    }

    if (verdict.restamp) try restampLegacy(database, path, verdict.logical);

    switch (verdict.action) {
        .migrate => {
            const first = @as(usize, @intCast(verdict.logical - plan.minimum));
            migrations.runMigration(
                io,
                dir,
                path,
                database,
                plan.step_sql[first..],
                verdict.logical,
                plan.current,
            ) catch |e| switch (e) {
                // Two different states of the FILE — migrated and kept with its
                // backup, or logically untouched — and one shared state of the
                // CONNECTION: its pragmas are not what `applyPragmas`
                // guarantees, so it must not serve. Closing it here is the
                // single close either path gets. After a commit the next start
                // opens the migrated file on the current-version lane; after a
                // failure it retries the migration from the top.
                error.MigrationCommittedButUnclean, error.MigrationFailedUnclean => {
                    closeSlot(handle);
                    return error.MigrationFailed;
                },
                error.MigrationBackupFailed => return error.MigrationBackupFailed,
                error.MigrationFailed, error.NameTooLong => return error.MigrationFailed,
                else => |other| return other,
            };
        },
        .open_current => migrations.pruneBackupsConservative(io, dir, path),
        else => unreachable,
    }
}

/// Replaces the 0.0.12/0.0.13 fingerprint stamp with the logical version it
/// stands for. This is the milestone's only real mutation of operator data, so
/// it runs in its own transaction and any failure leaves the legacy stamp and
/// every row exactly as they were — a refusal, never a recreate.
fn restampLegacy(database: *db.Db, path: []const u8, logical: i32) Error!void {
    var stamp_buf: [64]u8 = undefined;
    const stamp = std.fmt.bufPrintZ(&stamp_buf, "PRAGMA user_version = {d};", .{logical}) catch
        unreachable; // an i32 and a fixed prefix cannot overrun 64 bytes

    restamp: {
        var tx = db.Tx.begin(database) catch break :restamp;
        database.exec(stamp) catch {
            tx.rollback();
            break :restamp;
        };
        tx.commit() catch {
            tx.rollback();
            break :restamp;
        };
        log.info("querylog database '{s}' carried the 0.0.12 schema fingerprint; " ++
            "restamped as schema version {d}", .{ path, logical });
        return;
    }

    var buf: [256]u8 = undefined;
    fail("cannot restamp querylog database '{s}' as schema version {d}: {s}; " ++
        "the file is unchanged", .{ path, logical, database.lastError(&buf) });
    return error.MigrationFailed;
}

fn closeSlot(handle: *?db.Db) void {
    if (handle.*) |*h| h.close();
    handle.* = null;
}

fn refusalLog(path: []const u8, stamped: i64, plan: Plan, name: []const u8) void {
    fail("refusing to open querylog database '{s}': it is stamped {d}, and this build " ++
        "supports schema versions {d} to {d} ({s}). The file is left exactly as it is; " ++
        "see docs/how-to/troubleshoot.md, \"The server refuses to start over querylog.db\"", .{
        path, stamped, plan.minimum, plan.current, name,
    });
}

/// An additional connection to a `querylog.db` that `open` has already
/// established, with the pragmas every connection to the file needs.
///
/// The canonical opener for every background connection: the log writer, the
/// retention pass and the web task each own one (`retention.zig`'s contract),
/// and a logger generation opens one per writer for that writer's whole life
/// (`logger_controller.zig`) — two writers must never share a handle.
///
/// `dir` and `path` follow `open`'s resolution rule, and `dir` participates in
/// it the same way: the caller passes either an absolute path with `dir` open
/// on its parent, or `std.Io.Dir.cwd()` with a cwd-relative path. Nothing here
/// touches the directory itself — the file already exists by contract — so the
/// handle is present to make the pairing explicit at every call site rather
/// than to be dereferenced.
pub fn reopen(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) db.Error!db.Db {
    _ = io;
    _ = dir;
    var database = try db.Db.open(path, .{ .mode = .read_write_existing });
    errdefer database.close();
    try db.applyPragmas(&database, .{});
    return database;
}

/// The whitelist. `null` means "propagate, do not touch the file".
fn recreatable(e: db.Error) ?RecreateReason {
    return switch (e) {
        error.Corrupt => .corrupt,
        error.NotADb => .not_a_database,
        else => null,
    };
}

/// `PRAGMA quick_check` rather than `integrity_check`: it skips the expensive
/// index-vs-table cross-check while still catching structural damage, and a
/// damaged index on an expendable log is not worth a multi-second startup scan.
fn quickCheck(database: *db.Db) db.Error!bool {
    var stmt = try database.prepare("PRAGMA quick_check");
    defer stmt.deinit();
    if (!try stmt.step()) return false;
    return std.ascii.eqlIgnoreCase(stmt.columnText(0), "ok");
}

/// What the aside file's name calls the reason it was set aside.
///
/// The name is the only account of the reason an operator gets: the log line
/// naming it scrolls away, the file stays for months. Every tag here names real
/// damage, which is the whole set of reasons left — a healthy file whose
/// version this build cannot handle refuses the startup and is not renamed.
fn asideTag(reason: RecreateReason) []const u8 {
    return switch (reason) {
        .missing => unreachable, // there is no file to rename
        .corrupt => "corrupt",
        .not_a_database => "not-a-database",
        .quick_check_failed => "quick-check-failed",
    };
}

/// Renames the unusable file out of the way and returns the name it now has.
///
/// `renamePreserve` is `RENAME_NOREPLACE`: it returns `error.PathAlreadyExists`
/// instead of overwriting. A previously saved file must never be destroyed by
/// the next recreate, and two recreates in the same second are not hypothetical
/// on a boot loop — hence the uniquifying retries.
fn renameAside(io: std.Io, dir: std.Io.Dir, path: []const u8, reason: RecreateReason, buf: []u8) Error![]const u8 {
    const tag = asideTag(reason);
    const seconds = std.Io.Clock.real.now(io).toSeconds();
    var attempt: u32 = 0;
    while (attempt < 100) : (attempt += 1) {
        const aside = if (attempt == 0)
            std.fmt.bufPrint(buf, "{s}.{s}-{d}", .{ path, tag, seconds }) catch return error.NameTooLong
        else
            std.fmt.bufPrint(buf, "{s}.{s}-{d}-{d}", .{ path, tag, seconds, attempt }) catch return error.NameTooLong;

        dir.renamePreserve(path, dir, aside, io) catch |e| switch (e) {
            error.PathAlreadyExists => continue,
            else => |other| return other,
        };
        return aside;
    }
    return error.AsideNameCollision;
}

fn deleteSidecars(io: std.Io, dir: std.Io.Dir, path: []const u8) Error!void {
    var buf: [path_buf_len]u8 = undefined;
    for ([_][]const u8{ "-wal", "-shm" }) |suffix| {
        const sidecar = std.fmt.bufPrint(&buf, "{s}{s}", .{ path, suffix }) catch return error.NameTooLong;
        dir.deleteFile(io, sidecar) catch |e| switch (e) {
            error.FileNotFound => {},
            else => |other| return other,
        };
    }
}

fn createFresh(path: [:0]const u8) db.Error!db.Db {
    var database = try db.Db.open(path, .{ .mode = .read_write_create });
    errdefer database.close();
    try db.applyPragmas(&database, .{});

    var tx = try db.Tx.begin(&database);
    errdefer tx.rollback();
    try database.exec(ddl);
    try database.exec(set_user_version);
    try tx.commit();

    return database;
}

const testing = std.testing;

test "fingerprint matches a fresh hash of the DDL" {
    try testing.expectEqual(fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(ddl))));
    // The runtime entry point the release gate uses is the same function the
    // comptime constant is built from.
    try testing.expectEqual(fingerprint, fingerprintOf(ddl));
    try testing.expect(fingerprintOf(ddl[0 .. ddl.len - 1]) != fingerprint);
}

test "ddl creates the query-log tables and every index" {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    try database.exec(ddl);

    try testing.expectEqual(
        @as(i64, 7),
        try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
    );
    // The three explicit indexes plus `domains.domain`'s autoindex, and
    // nothing else: the four projection tables are WITHOUT ROWID, so each
    // one's PRIMARY KEY *is* its storage rather than a second b-tree to keep
    // in step on every insert.
    try testing.expectEqual(
        @as(i64, 4),
        try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='index'"),
    );
    const objects = [_][]const u8{
        "domains",              "query_log",
        "idx_query_log_ts",     "idx_query_log_client",
        "idx_query_log_domain", "querylog_meta",
        "bucket_totals",        "bucket_clients",
        "bucket_types",         "bucket_routes",
    };
    for (objects) |name| {
        var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
        defer stmt.deinit();
        try stmt.bindText(1, name);
        try testing.expect(try stmt.step());
        try testing.expectEqual(@as(i64, 1), stmt.columnInt(0));
    }
}

test "the schema refuses an rcode outside twelve bits" {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    try database.exec(ddl);
    try database.exec("INSERT INTO domains (id, domain) VALUES (1, 'a.example');");

    var stmt = try database.prepare(
        \\INSERT INTO query_log
        \\  (timestamp, domain_id, client_ip, blocked, qclass, rcode,
        \\   policy_action, policy_reason, route_kind)
        \\VALUES (1, 1, '10.0.0.1', 0, 1, ?1, 'not_evaluated', 'no_match', 'upstream')
    );
    defer stmt.deinit();

    // The whole range an EDNS extended RCODE can express, and nothing wider:
    // the producers are `u12`, and this is what stops any other writer — a
    // hand-run UPDATE included — from putting a value in the column that the
    // read path would have to reject.
    for ([_]i64{ 0, 4095 }) |accepted| {
        try stmt.reset();
        try stmt.bindInt(1, accepted);
        try stmt.exec();
    }
    for ([_]i64{ -1, 4096, 65535 }) |refused| {
        // `sqlite3_reset` repeats the error of the statement it is resetting,
        // which for every iteration after the first is the constraint failure
        // this loop just asserted — the same reason `BatchWriter.resetAll`
        // discards it.
        stmt.reset() catch {};
        try stmt.bindInt(1, refused);
        try testing.expectError(error.Constraint, stmt.exec());
    }

    try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM query_log"));
}

test "querylog_meta is seeded with one row the schema will not let a second join" {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    try database.exec(ddl);

    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM querylog_meta"));

    const created = try database.queryInt("SELECT created_at FROM querylog_meta");
    const since = try database.queryInt("SELECT available_since FROM querylog_meta");
    // Conservative by exactly one second: a row logged in the creating second
    // must not let a query claim that second is completely covered.
    try testing.expectEqual(created + 1, since);
    try testing.expect(created > 1_700_000_000);

    // `CHECK (id = 1)` is what makes "the singleton row" a schema fact rather
    // than a convention the read path has to defend against.
    try testing.expectError(error.Constraint, database.exec(
        "INSERT INTO querylog_meta (id, created_at, available_since) VALUES (2, 1, 1);",
    ));
    try testing.expectError(error.Constraint, database.exec(
        "INSERT INTO querylog_meta (id, created_at, available_since) VALUES (1, 1, 1);",
    ));
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM querylog_meta"));
}

test "the user_version statement stamps the current schema version" {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try database.exec(set_user_version);
    try testing.expectEqual(
        @as(i64, versions.current_version),
        try database.queryInt("PRAGMA user_version"),
    );
}

// The behaviour these two cases describe — a resource error leaves the file on
// disk alone — is proven against a real file by "S7 case 23" in
// `storage_integration_test.zig`, which locks a healthy `querylog.db` from a
// second connection and asserts `open` returns `error.Busy` with the bytes, the
// file and the absence of an aside all intact. `error.OutOfMemory` has no such
// case: `open` takes no allocator, and SQLite allocates through its own global
// allocator, so there is no seam to inject a failure through. The two tests
// below are what covers it.
test "recreatable is a whitelist and never selects a resource error" {
    try testing.expectEqual(RecreateReason.corrupt, recreatable(error.Corrupt).?);
    try testing.expectEqual(RecreateReason.not_a_database, recreatable(error.NotADb).?);
    const propagating = [_]db.Error{
        error.Busy,       error.Locked, error.OutOfMemory, error.CantOpen,
        error.ReadOnly,   error.IoErr,  error.Full,        error.Perm,
        error.Auth,       error.Misuse, error.Constraint,  error.SqliteError,
        error.Unexpected,
    };
    for (propagating) |e| {
        try testing.expect(recreatable(e) == null);
    }
}

test "the aside name says why, and a healthy file is never called corrupt" {
    try testing.expectEqualStrings("corrupt", asideTag(.corrupt));
    try testing.expectEqualStrings("not-a-database", asideTag(.not_a_database));
    try testing.expectEqualStrings("quick-check-failed", asideTag(.quick_check_failed));
}

test "recreatable selects exactly two of db.Error's members" {
    // Exhaustive over the whole set, so a variant added to `db.Error` later
    // defaults to propagate. The list above only proves the named errors are
    // safe today; this proves nothing else can join the whitelist unnoticed.
    var whitelisted: usize = 0;
    inline for (@typeInfo(db.Error).error_set.?) |member| {
        if (recreatable(@field(db.Error, member.name)) != null) whitelisted += 1;
    }
    try testing.expectEqual(@as(usize, 2), whitelisted);
}

test "a recreate returns the aside name by value and a fresh create returns none" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();

    var path_buf: [path_buf_len]u8 = undefined;
    const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});

    // First open: the file is missing, so nothing is renamed aside. The
    // one-shot event is deliberately not emitted for this case.
    var created = try open(io, std.Io.Dir.cwd(), path);
    created.database.close();
    try testing.expectEqual(RecreateReason.missing, created.recreated.?);
    try testing.expectEqualStrings("", created.aside());

    try tmp.dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });

    var recreated = try open(io, std.Io.Dir.cwd(), path);
    recreated.database.close();
    try testing.expectEqual(RecreateReason.not_a_database, recreated.recreated.?);
    try testing.expect(recreated.aside().len != 0);

    // The name is a real file, which is the whole reason it travels out.
    const kept = std.fs.path.basename(recreated.aside());
    try tmp.dir.access(io, kept, .{});
}

test "a corrupt file is recreated, coverage restarts, and the old one is kept aside" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();

    var path_buf: [path_buf_len]u8 = undefined;
    const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});

    var created = try open(io, std.Io.Dir.cwd(), path);
    const first_coverage = try created.database.queryInt("SELECT available_since FROM querylog_meta");
    // A row in the file the operator is about to lose.
    try created.database.exec("INSERT INTO domains (domain) VALUES ('old.example');");
    created.database.close();

    // Real damage, which is now the only thing that recreates.
    try tmp.dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });

    var recreated = try open(io, std.Io.Dir.cwd(), path);
    defer recreated.database.close();

    try testing.expectEqual(RecreateReason.not_a_database, recreated.recreated.?);
    try testing.expect(std.mem.indexOf(u8, recreated.aside(), ".not-a-database-") != null);
    try tmp.dir.access(io, std.fs.path.basename(recreated.aside()), .{});

    // Exactly one meta row, and coverage starts at the recreate rather than
    // carrying the replaced file's promise forward.
    try testing.expectEqual(
        @as(i64, 1),
        try recreated.database.queryInt("SELECT count(*) FROM querylog_meta"),
    );
    const new_coverage = try recreated.database.queryInt("SELECT available_since FROM querylog_meta");
    try testing.expect(new_coverage >= first_coverage);

    // Nothing of the old file came across: the history is genuinely gone, which
    // is what the coverage start has to tell the operator.
    try testing.expectEqual(
        @as(i64, 0),
        try recreated.database.queryInt("SELECT count(*) FROM domains"),
    );
}

test "a clean reopen reports no recreate and no aside" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();

    var path_buf: [path_buf_len]u8 = undefined;
    const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});

    var first = try open(io, std.Io.Dir.cwd(), path);
    first.database.close();

    var second = try open(io, std.Io.Dir.cwd(), path);
    second.database.close();
    try testing.expectEqual(@as(?RecreateReason, null), second.recreated);
    try testing.expectEqualStrings("", second.aside());
}

test "classification is a pure function of the stamp and the plan's three numbers" {
    const plan: Plan = .{
        .minimum = 1,
        .current = 3,
        .legacy_fingerprint = versions.legacy_fingerprint,
        .step_sql = &.{},
    };

    try testing.expectEqual(Action.open_current, classify(3, plan).action);
    try testing.expectEqual(Action.migrate, classify(1, plan).action);
    try testing.expectEqual(Action.migrate, classify(2, plan).action);
    try testing.expectEqual(Action.refuse_too_new, classify(4, plan).action);
    try testing.expectEqual(Action.refuse_too_new, classify(versions.version_floor_guard, plan).action);
    // Above the floor guard is not a version this project ever wrote.
    try testing.expectEqual(
        Action.refuse_unsupported,
        classify(versions.version_floor_guard + 1, plan).action,
    );
    for ([_]i32{ 0, -1, -1_000_000, 603440875 }) |foreign| {
        try testing.expectEqual(Action.refuse_unsupported, classify(foreign, plan).action);
        try testing.expect(!classify(foreign, plan).restamp);
    }

    // The legacy fingerprint IS version 1, and being version 1 is what decides
    // its lane.
    const legacy = classify(versions.legacy_fingerprint, plan);
    try testing.expectEqual(@as(i32, 1), legacy.logical);
    try testing.expectEqual(Action.migrate, legacy.action);
    try testing.expect(legacy.restamp);
}

test "a legacy stamp below a raised minimum refuses without a restamp" {
    // What a future explicit break looks like from this side: the minimum has
    // moved past 1, so the 0.0.12 file is no longer reachable. The ORDER is the
    // point — mapping to 1 first and judging second is what stops the restamp
    // from mutating a file this build will refuse anyway.
    const after_break: Plan = .{
        .minimum = 3,
        .current = 3,
        .legacy_fingerprint = versions.legacy_fingerprint,
        .step_sql = &.{},
    };

    const legacy = classify(versions.legacy_fingerprint, after_break);
    try testing.expectEqual(@as(i32, 1), legacy.logical);
    try testing.expectEqual(Action.refuse_unsupported, legacy.action);
    try testing.expect(!legacy.restamp);

    // And versions 1 and 2, which the break dropped, refuse the same way.
    try testing.expectEqual(Action.refuse_unsupported, classify(1, after_break).action);
    try testing.expectEqual(Action.refuse_unsupported, classify(2, after_break).action);
    try testing.expectEqual(Action.open_current, classify(3, after_break).action);
}

test "the production plan classifies a fresh stamp as current" {
    try testing.expectEqual(
        Action.open_current,
        classify(versions.current_version, production_plan).action,
    );
    try testing.expect(classify(versions.legacy_fingerprint, production_plan).restamp);
}

/// The five lines every file-backed test below opens with.
const Fixture = struct {
    threaded: std.Io.Threaded,
    tmp: std.testing.TmpDir,
    buf: [256]u8 = undefined,

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

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

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

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

    fn stamp(self: *Fixture, value: i32) !void {
        var database = try db.Db.open(self.path(), .{ .mode = .read_write_existing });
        defer database.close();
        var sql: [64]u8 = undefined;
        try database.exec(try std.fmt.bufPrintZ(&sql, "PRAGMA user_version = {d};", .{value}));
    }

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

    fn countMatching(self: *Fixture, prefix: []const u8) !usize {
        var found: usize = 0;
        var it = self.tmp.dir.iterate();
        while (try it.next(self.io())) |entry| {
            if (std.mem.startsWith(u8, entry.name, prefix)) found += 1;
        }
        return found;
    }

    fn expectModeOfOnlyMatch(self: *Fixture, prefix: []const u8, expected: std.posix.mode_t) !void {
        var it = self.tmp.dir.iterate();
        while (try it.next(self.io())) |entry| {
            if (!std.mem.startsWith(u8, entry.name, prefix)) continue;
            const stat = try self.tmp.dir.statFile(self.io(), entry.name, .{});
            const mode = stat.permissions.toMode() & 0o777;
            if (mode != expected) {
                std.debug.print("mode of '{s}' is {o}, expected {o}\n", .{ entry.name, mode, expected });
                return error.TestUnexpectedResult;
            }
            return;
        }
        std.debug.print("no file starting with '{s}'\n", .{prefix});
        return error.TestUnexpectedResult;
    }
};

test "a fresh file is stamped with the current schema version" {
    var f: Fixture = .init();
    defer f.deinit();

    var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
    defer created.database.close();
    try testing.expectEqual(RecreateReason.missing, created.recreated.?);
    try testing.expectEqual(
        @as(i64, versions.current_version),
        try created.database.queryInt("PRAGMA user_version"),
    );
}

test "a 0.0.13 file is restamped as version 1 and keeps every row" {
    var f: Fixture = .init();
    defer f.deinit();

    {
        var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
        defer created.database.close();
        try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
    }
    // Exactly what 0.0.12 and 0.0.13 wrote: the CRC of their DDL, which is this
    // build's DDL unchanged.
    try f.stamp(versions.legacy_fingerprint);
    try testing.expectEqual(versions.legacy_fingerprint, fingerprint);

    {
        var upgraded = try open(f.io(), std.Io.Dir.cwd(), f.path());
        defer upgraded.database.close();
        try testing.expectEqual(@as(?RecreateReason, null), upgraded.recreated);
        try testing.expectEqual(@as(i64, 1), try upgraded.database.queryInt("PRAGMA user_version"));
        try testing.expectEqual(
            @as(i64, 1),
            try upgraded.database.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
        );
    }

    // Nothing was set aside on the way, and the second start is an ordinary
    // current-version open.
    try testing.expectEqual(@as(usize, 0), try f.countMatching("querylog.db.schema"));
    var again = try open(f.io(), std.Io.Dir.cwd(), f.path());
    defer again.database.close();
    try testing.expectEqual(@as(?RecreateReason, null), again.recreated);
    try testing.expectEqual(@as(i64, 1), try again.database.queryInt("PRAGMA user_version"));
}

test "a version this build cannot handle refuses and leaves the file alone" {
    var f: Fixture = .init();
    defer f.deinit();

    {
        var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
        defer created.database.close();
        try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
    }
    const watermark = blk: {
        var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
        defer probe.close();
        break :blk try probe.queryInt("SELECT available_since FROM querylog_meta");
    };

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

    const lanes = [_]struct { stamp: i32, expected: anyerror }{
        .{ .stamp = versions.current_version + 1, .expected = error.SchemaTooNew },
        .{ .stamp = versions.version_floor_guard, .expected = error.SchemaTooNew },
        .{ .stamp = 0, .expected = error.SchemaUnsupported },
        .{ .stamp = -3, .expected = error.SchemaUnsupported },
        .{ .stamp = 603440875, .expected = error.SchemaUnsupported },
    };

    for (lanes) |lane| {
        try f.stamp(lane.stamp);
        try testing.expectError(lane.expected, open(f.io(), std.Io.Dir.cwd(), f.path()));

        // Schema, rows, watermark and stamp all as they were, and nothing new
        // beside the file.
        var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
        defer probe.close();
        try testing.expectEqual(@as(i64, lane.stamp), try probe.queryInt("PRAGMA user_version"));
        try testing.expectEqual(
            @as(i64, 1),
            try probe.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
        );
        try testing.expectEqual(
            watermark,
            try probe.queryInt("SELECT available_since FROM querylog_meta"),
        );
        try testing.expectEqual(@as(usize, 0), try f.countMatching("querylog.db."));
    }
}

test "a restamp that fails at the statement or at the commit refuses without loss" {
    for ([_][]const u8{ "PRAGMA user_version = 1;", "COMMIT;" }) |failing| {
        var f: Fixture = .init();
        defer f.deinit();

        {
            var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
            defer created.database.close();
            try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
        }
        try f.stamp(versions.legacy_fingerprint);

        migrations.expected_failures.begin();
        defer migrations.expected_failures.end();
        db.exec_faults.failNextMatching(failing);
        defer db.exec_faults.disarm();

        try testing.expectError(
            error.MigrationFailed,
            open(f.io(), std.Io.Dir.cwd(), f.path()),
        );
        try testing.expect(!db.exec_faults.armed());

        // The legacy stamp and every row are exactly as they were: this is a
        // refusal, and a refusal never costs the operator anything.
        {
            var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
            defer probe.close();
            try testing.expectEqual(
                @as(i64, versions.legacy_fingerprint),
                try probe.queryInt("PRAGMA user_version"),
            );
        }

        // And the next start, with nothing injected, does the restamp properly.
        var recovered = try open(f.io(), std.Io.Dir.cwd(), f.path());
        defer recovered.database.close();
        try testing.expectEqual(@as(i64, 1), try recovered.database.queryInt("PRAGMA user_version"));
        try testing.expectEqual(
            @as(i64, 1),
            try recovered.database.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
        );
    }
}

/// A one-step chain from the real current version to one above it. Nothing in
/// the shipped chain can exercise migration while `step_sql` is empty, so the
/// open path's migration lanes are driven through this instead.
const synthetic_plan: Plan = .{
    .minimum = versions.current_version,
    .current = versions.current_version + 1,
    .legacy_fingerprint = versions.legacy_fingerprint,
    .step_sql = &.{"CREATE TABLE migration_marker (id INTEGER PRIMARY KEY);"},
};

test "a post-commit failure keeps the migration, keeps the backup, and refuses once" {
    var f: Fixture = .init();
    defer f.deinit();

    {
        var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
        defer created.database.close();
        try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
    }

    {
        var handle = try f.liveHandle();
        errdefer if (handle) |*h| h.close();

        migrations.expected_failures.begin();
        defer migrations.expected_failures.end();
        // The first statement of the pragma restore, which runs only after a
        // successful COMMIT.
        db.exec_faults.failNextMatching("legacy_alter_table = OFF");
        defer db.exec_faults.disarm();

        try testing.expectError(
            error.MigrationFailed,
            openVersioned(f.io(), std.Io.Dir.cwd(), f.path(), &handle, synthetic_plan),
        );
        // Closed exactly once, by the open path: the slot it was handed is
        // empty, so no caller can close it again.
        try testing.expect(handle == null);
    }

    // The file IS migrated. The log said so, and this is what it meant.
    {
        var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
        defer probe.close();
        try testing.expectEqual(
            @as(i64, synthetic_plan.current),
            try probe.queryInt("PRAGMA user_version"),
        );
        try testing.expectEqual(
            @as(i64, 1),
            try probe.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'migration_marker'"),
        );
        try testing.expectEqual(
            @as(i64, 1),
            try probe.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
        );
    }
    try testing.expectEqual(@as(usize, 1), try f.countMatching("querylog.db.pre-migrate-"));
    // The backup is the whole query history at the moment of the migration, so
    // it carries the live file's mode and not SQLite's `0644 & ~umask`.
    try f.expectModeOfOnlyMatch("querylog.db.pre-migrate-", 0o600);

    // The next start is ordinary: the current-version lane, no second
    // migration, and the one backup still there for the operator.
    var handle = try f.liveHandle();
    defer if (handle) |*h| h.close();
    try openVersioned(f.io(), std.Io.Dir.cwd(), f.path(), &handle, synthetic_plan);
    try testing.expect(handle != null);
    try testing.expectEqual(
        @as(i64, synthetic_plan.current),
        try handle.?.queryInt("PRAGMA user_version"),
    );
    try testing.expectEqual(@as(usize, 1), try f.countMatching("querylog.db.pre-migrate-"));
}

/// The same shape as `synthetic_plan`, with a step SQLite refuses to prepare. It
/// drives the pre-commit failure path without any fault seam, leaving the seam
/// free for the pragma restore.
const failing_plan: Plan = .{
    .minimum = versions.current_version,
    .current = versions.current_version + 1,
    .legacy_fingerprint = versions.legacy_fingerprint,
    .step_sql = &.{"CREATE TABLE migration_marker (id INTEGER PRIMARY KEY) NOT A STATEMENT;"},
};

test "a pre-commit failure whose pragma restore also fails closes the connection" {
    var f: Fixture = .init();
    defer f.deinit();

    {
        var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
        defer created.database.close();
        try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
    }

    // The runner's own answer first: a failed restore is a DIFFERENT error from
    // a failed migration, because the two leave the connection in different
    // states even though they leave the file in the same one.
    {
        var handle = try f.liveHandle();
        defer if (handle) |*h| h.close();

        migrations.expected_failures.begin();
        defer migrations.expected_failures.end();
        db.exec_faults.failNextMatching("legacy_alter_table = OFF");
        defer db.exec_faults.disarm();

        try testing.expectError(error.MigrationFailedUnclean, migrations.runMigration(
            f.io(),
            std.Io.Dir.cwd(),
            f.path(),
            &handle.?,
            failing_plan.step_sql,
            failing_plan.minimum,
            failing_plan.current,
        ));
        try testing.expect(!db.exec_faults.armed());
    }

    // And the open path's answer: the handle is closed, exactly as it is after a
    // post-commit restore failure. A connection that may still hold
    // `foreign_keys = OFF` never reaches the server.
    {
        var handle = try f.liveHandle();
        errdefer if (handle) |*h| h.close();

        migrations.expected_failures.begin();
        defer migrations.expected_failures.end();
        db.exec_faults.failNextMatching("legacy_alter_table = OFF");
        defer db.exec_faults.disarm();

        try testing.expectError(
            error.MigrationFailed,
            openVersioned(f.io(), std.Io.Dir.cwd(), f.path(), &handle, failing_plan),
        );
        try testing.expect(handle == null);
    }

    // The contrast that makes the rule visible, back at the runner, where the
    // connection survives to be inspected: the same failing step with the
    // restore working is a plain `MigrationFailed`, and that error promises the
    // pragmas `applyPragmas` guarantees.
    {
        var handle = try f.liveHandle();
        defer if (handle) |*h| h.close();

        migrations.expected_failures.begin();
        defer migrations.expected_failures.end();
        try testing.expectError(error.MigrationFailed, migrations.runMigration(
            f.io(),
            std.Io.Dir.cwd(),
            f.path(),
            &handle.?,
            failing_plan.step_sql,
            failing_plan.minimum,
            failing_plan.current,
        ));
        try testing.expectEqual(@as(i64, 1), try handle.?.queryInt("PRAGMA foreign_keys"));
        try testing.expectEqual(@as(i64, 0), try handle.?.queryInt("PRAGMA legacy_alter_table"));
    }

    // No path committed anything, and every run deleted its own backup.
    var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
    defer probe.close();
    try testing.expectEqual(
        @as(i64, failing_plan.minimum),
        try probe.queryInt("PRAGMA user_version"),
    );
    try testing.expectEqual(
        @as(i64, 1),
        try probe.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
    );
    try testing.expectEqual(@as(usize, 0), try f.countMatching("querylog.db.pre-migrate-"));
}

test "a plain open retries a retention cleanup that once failed" {
    var f: Fixture = .init();
    defer f.deinit();

    {
        var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
        created.database.close();
    }

    // What a migration whose step-4 cleanup failed leaves behind: an older
    // epoch, the newest epoch, and a same-second collision name tied with it.
    const seeded = [_][]const u8{
        "querylog.db.pre-migrate-1600000000",
        "querylog.db.pre-migrate-1700000000",
        "querylog.db.pre-migrate-1700000000-2",
        "querylog.db.pre-migrate-handwritten",
    };
    for (seeded) |name| {
        try f.tmp.dir.writeFile(f.io(), .{ .sub_path = name, .data = "x" });
    }

    var opened = try open(f.io(), std.Io.Dir.cwd(), f.path());
    defer opened.database.close();
    try testing.expectEqual(@as(?RecreateReason, null), opened.recreated);

    // Only the strictly older epoch goes: both files tied at the newest epoch
    // survive, because this pass cannot tell which of them a migration made,
    // and a name it did not write is never its to delete.
    try testing.expect(!try exists(&f, seeded[0]));
    for (seeded[1..]) |name| try testing.expect(try exists(&f, name));
}

fn exists(f: *Fixture, name: []const u8) !bool {
    f.tmp.dir.access(f.io(), name, .{}) catch |e| switch (e) {
        error.FileNotFound => return false,
        else => return e,
    };
    return true;
}
