Files
nxdns/src/storage/db.zig
T
mokhtar 648d9b4496
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
milestone 30: overview as a dashboard, explicit health contract, period aggregations
2026-08-22 16:45:15 +02:00

1507 lines
61 KiB
Zig

//! The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this
//! file calls SQLite directly.
//!
//! **SQLite's own file I/O takes no `std.Io`.** It is the one deliberate
//! exception to Decision E. SQLite performs its own file I/O through its VFS;
//! routing it through `std.Io` would mean writing a custom SQLite VFS — a large,
//! security-sensitive component bought for nothing at household scale. Every
//! other storage file that touches the filesystem takes `io: std.Io`.
//!
//! The exception covers SQLite, not nxdns. The one place this file does its own
//! filesystem calls — the probes guarding `OpenMode.immutable`, at the open and
//! again at `Db.verifyImmutable` — follows the ordinary rule and takes an
//! `io: std.Io`, which is why that mode carries one.
//!
//! The C API is declared by hand below. No `@cImport` — the handles stay
//! opaque, matching `src/platform/tls_server.zig`'s Mbed TLS approach.
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const log = std.log.scoped(.db);
pub const c = struct {
pub const Sqlite3 = opaque {};
pub const Stmt = opaque {};
/// The C prototype is a function pointer, but the only value nxdns passes
/// is the `SQLITE_TRANSIENT` sentinel (-1), which is not a valid function
/// address — a Zig fn-pointer type would reject it on targets with aligned
/// function pointers (aarch64). `?*anyopaque` is ABI-identical.
pub const Destructor = ?*anyopaque;
/// `SQLITE_TRANSIENT`: tells SQLite to copy the bound bytes immediately.
pub const transient: Destructor = @ptrFromInt(@as(usize, @bitCast(@as(isize, -1))));
pub extern fn sqlite3_open_v2(filename: [*:0]const u8, ppDb: *?*Sqlite3, flags: c_int, zVfs: ?[*:0]const u8) c_int;
pub extern fn sqlite3_close_v2(db: ?*Sqlite3) c_int;
pub extern fn sqlite3_extended_result_codes(db: *Sqlite3, onoff: c_int) c_int;
pub extern fn sqlite3_busy_timeout(db: *Sqlite3, ms: c_int) c_int;
pub extern fn sqlite3_exec(db: *Sqlite3, sql: [*:0]const u8, cb: ?*const anyopaque, arg: ?*anyopaque, errmsg: ?*?[*:0]u8) c_int;
pub extern fn sqlite3_errmsg(db: *Sqlite3) [*:0]const u8;
pub extern fn sqlite3_errcode(db: *Sqlite3) c_int;
pub extern fn sqlite3_extended_errcode(db: *Sqlite3) c_int;
pub extern fn sqlite3_errstr(code: c_int) [*:0]const u8;
pub extern fn sqlite3_prepare_v2(db: *Sqlite3, sql: [*]const u8, n_byte: c_int, ppStmt: *?*c.Stmt, pzTail: ?*?[*]const u8) c_int;
pub extern fn sqlite3_step(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_reset(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_clear_bindings(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_finalize(stmt: ?*c.Stmt) c_int;
pub extern fn sqlite3_bind_int64(stmt: *c.Stmt, idx: c_int, value: i64) c_int;
pub extern fn sqlite3_bind_text(stmt: *c.Stmt, idx: c_int, text: [*]const u8, n: c_int, d: Destructor) c_int;
pub extern fn sqlite3_bind_null(stmt: *c.Stmt, idx: c_int) c_int;
pub extern fn sqlite3_bind_parameter_count(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_column_count(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_column_type(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_column_int64(stmt: *c.Stmt, col: c_int) i64;
pub extern fn sqlite3_column_text(stmt: *c.Stmt, col: c_int) ?[*]const u8;
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int;
};
/// Result codes, from the vendored `sqlite3.h` (3.53.4).
pub const result = struct {
pub const ok: c_int = 0;
pub const err: c_int = 1;
pub const internal: c_int = 2;
pub const perm: c_int = 3;
pub const abort: c_int = 4;
pub const busy: c_int = 5;
pub const locked: c_int = 6;
pub const nomem: c_int = 7;
pub const readonly: c_int = 8;
pub const interrupt: c_int = 9;
pub const ioerr: c_int = 10;
pub const corrupt: c_int = 11;
pub const notfound: c_int = 12;
pub const full: c_int = 13;
pub const cantopen: c_int = 14;
pub const protocol: c_int = 15;
pub const empty: c_int = 16;
pub const schema: c_int = 17;
pub const toobig: c_int = 18;
pub const constraint: c_int = 19;
pub const mismatch: c_int = 20;
pub const misuse: c_int = 21;
pub const nolfs: c_int = 22;
pub const auth: c_int = 23;
pub const format: c_int = 24;
pub const range: c_int = 25;
pub const notadb: c_int = 26;
pub const row: c_int = 100;
pub const done: c_int = 101;
};
/// Open flags, from the vendored `sqlite3.h` (3.53.4).
pub const open_flag = struct {
pub const readonly: c_int = 0x1;
pub const readwrite: c_int = 0x2;
pub const create: c_int = 0x4;
pub const uri: c_int = 0x40;
pub const nomutex: c_int = 0x8000;
pub const fullmutex: c_int = 0x10000;
pub const exrescode: c_int = 0x2000000;
};
/// Column type codes returned by `sqlite3_column_type`.
pub const column_type = struct {
pub const integer: c_int = 1;
pub const float: c_int = 2;
pub const text: c_int = 3;
pub const blob: c_int = 4;
pub const null_value: c_int = 5;
};
pub const Error = error{
Abort,
Auth,
Busy,
CantOpen,
Constraint,
Corrupt,
Empty,
Format,
Full,
Internal,
Interrupt,
IoErr,
Locked,
Mismatch,
Misuse,
NoLfs,
NotADb,
NotFound,
Perm,
Protocol,
Range,
ReadOnly,
Schema,
TooBig,
SqliteError,
OutOfMemory,
Unexpected,
/// Not a SQLite result code. An `OpenMode.immutable` read would have
/// answered from a stale main file, because `<path>-wal` holds bytes or the
/// files moved while the read ran. Raised by the open and again by
/// `Db.verifyImmutable`. See `OpenMode.immutable`.
WalPending,
};
/// Maps a primary SQLite result code to `Error`. `SQLITE_NOMEM` becomes
/// `error.OutOfMemory` so it joins `transport.LocalResource` semantics: out of
/// memory is never the data's fault.
///
/// The switch runs on the primary code (`code & 0xff`), so every extended code
/// (`SQLITE_IOERR_*`, `SQLITE_CONSTRAINT_*`, `SQLITE_BUSY_SNAPSHOT`, …) lands on
/// its family. The extended code stays visible to humans through `Db.lastError`.
///
/// `SQLITE_ERROR` — the generic "SQL error" — maps to `error.Unexpected`, not to
/// `error.SqliteError`. `SqliteError` is reserved for a primary code this
/// function does not know, so an unmapped future code stays distinguishable
/// from an ordinary SQL error.
pub fn mapCode(code: c_int) Error {
const primary = code & 0xff;
assert(primary != result.ok);
assert(primary != result.row);
assert(primary != result.done);
return switch (primary) {
result.err => error.Unexpected,
result.internal => error.Internal,
result.perm => error.Perm,
result.abort => error.Abort,
result.busy => error.Busy,
result.locked => error.Locked,
result.nomem => error.OutOfMemory,
result.readonly => error.ReadOnly,
result.interrupt => error.Interrupt,
result.ioerr => error.IoErr,
result.corrupt => error.Corrupt,
result.notfound => error.NotFound,
result.full => error.Full,
result.cantopen => error.CantOpen,
result.protocol => error.Protocol,
result.empty => error.Empty,
result.schema => error.Schema,
result.toobig => error.TooBig,
result.constraint => error.Constraint,
result.mismatch => error.Mismatch,
result.misuse => error.Misuse,
result.nolfs => error.NoLfs,
result.auth => error.Auth,
result.format => error.Format,
result.range => error.Range,
result.notadb => error.NotADb,
else => error.SqliteError,
};
}
fn check(code: c_int) Error!void {
if (code == result.ok) return;
return mapCode(code);
}
pub const OpenMode = union(enum) {
read_write_create,
read_write_existing,
read_only,
memory,
/// Read a database that this process promises not to change, and that no
/// writer may be touching: `SQLITE_OPEN_READONLY` plus the `immutable=1` URI
/// parameter, which makes the pager treat the file like a temp file — no
/// locking, no rollback journal, no wal-index — so **no `-wal` and no `-shm`
/// appear beside it**.
///
/// This mode exists for `nxdns check` (milestone-13 ruling F-c), which must
/// validate without writing. `.read_only` alone is not enough, and this is
/// measured, not assumed: reading a database whose header says WAL makes
/// SQLite build the wal-index, so `config.db-wal` (0 bytes) and
/// `config.db-shm` (32 KiB) are created, and a read-only connection cannot
/// remove them on close. A command that claims to write nothing must not
/// leave two files behind. Do not "simplify" this back to `.read_only`.
///
/// What `immutable=1` costs: SQLite then **ignores any `-wal` file**. The
/// newest committed rows live there, so an immutable read of a database with
/// an un-checkpointed WAL would answer from stale data and say nothing — a
/// worse failure than the two sidecar files it removes. `Db.open` therefore
/// refuses this mode with `error.WalPending` whenever `<path>-wal` exists and
/// is not empty; the caller reports that as a failure and names `nxdns run`,
/// which opens read-write and checkpoints, as the fix.
///
/// The guard lives inside `open` rather than in a helper callers are trusted
/// to call, because the failure it prevents is silent: a caller that forgets
/// a helper gets a plausible wrong answer, and nothing anywhere reports it.
///
/// **The open is half the guard.** `immutable=1` takes no lock, so nothing
/// keeps a writer out for the duration of the read, and a check made only
/// before the read can say only that the log was empty *then*.
/// `Db.verifyImmutable` makes the other half, and a caller that grades what
/// it read without calling it is back to the stale answer this mode exists
/// to refuse.
///
/// A zero-length `-wal` does not block the open: it holds no frames, so the
/// main file is complete. That is the exact leftover the pre-F-c `check`
/// used to create.
///
/// The `std.Io` is for that probe — the one filesystem call nxdns itself
/// makes in this file. Passing it is what makes the guard unskippable.
immutable: std.Io,
};
pub const OpenOptions = struct {
mode: OpenMode = .read_write_create,
busy_timeout_ms: c_int = 5000,
};
/// SQLite's name for the write-ahead log beside `<path>`. Exported so a caller
/// reporting `error.WalPending` can name the file without hard-coding SQLite's
/// naming convention, which this file owns.
pub const wal_suffix = "-wal";
/// One SQLite connection.
///
/// A `Db` must not move once a `Stmt` prepared from it is alive: every `Stmt`
/// holds a `*Db`.
pub const Db = struct {
handle: *c.Sqlite3,
/// Set by `OpenMode.immutable` and null in every other mode: what the
/// database file and its `-wal` looked like when the read began, for
/// `verifyImmutable` to compare against when it ends.
immutable_guard: ?ImmutableGuard = null,
/// Every mode carries `FULLMUTEX` (serialized mode). The query logger and
/// the web API handlers share one handle across `std.Io` tasks, and a
/// per-handle mutex inside SQLite is cheaper to be correct about than a
/// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE`
/// makes `sqlite3_extended_errcode` meaningful from the first call.
///
/// `open` deliberately applies no pragmas — see `applyPragmas`, which the
/// migration runner must call before it opens a transaction.
///
/// For `.memory`, `path` is ignored and `":memory:"` is used.
pub fn open(path: [:0]const u8, options: OpenOptions) Error!Db {
const base = open_flag.exrescode | open_flag.fullmutex;
const flags: c_int = switch (options.mode) {
.read_write_create, .memory => base | open_flag.readwrite | open_flag.create,
.read_write_existing => base | open_flag.readwrite,
.read_only => base | open_flag.readonly,
// Its own function: the URI buffer is 12 KiB, and every other open
// in the process would carry it in this frame.
.immutable => |io| return openImmutable(io, path, options.busy_timeout_ms),
};
const filename: [:0]const u8 = switch (options.mode) {
.memory => ":memory:",
else => path,
};
const h = try openHandle(filename, flags);
return applyBusyTimeout(h, options.busy_timeout_ms);
}
/// Proves that the files an `OpenMode.immutable` read answered from stood
/// still while it ran, and fails the read with `error.WalPending` when they
/// did not. Call it after the last read and before anything read is
/// reported.
///
/// `immutable=1` takes no lock at all — that is what stops the pager
/// building a wal-index, and the price is that nothing keeps a writer out.
/// The probe at open time can only say the log was empty at that instant: a
/// writer that appends one frame the instant after leaves the read answering
/// from the older pages of the main file, silently, which is the whole
/// failure `OpenMode.immutable`'s guard exists to prevent.
///
/// Two things are compared, because a writer can hide in either:
///
/// - `<path>-wal` holding bytes now. A log that appeared, one that grew, and
/// one written into the empty file the open accepted all land here.
/// - `<path>` itself moving — size, inode, mtime or ctime. This is the
/// checkpoint the log cannot show: a writer that checkpointed into the main
/// file and truncated its log back to nothing leaves both stats saying "no
/// frames" while the pages the read saw have been replaced.
///
/// Best effort, and honestly so: a filesystem with coarse timestamps can
/// hide a rewrite that lands on the same byte count in the same tick. That
/// cannot be fixed from outside SQLite's locking, and taking a lock is the
/// one thing this mode may not do. What it closes is the window a single
/// stat before the read leaves open for the whole of the read.
///
/// Two stats and nothing else: no `-wal` or `-shm` is created, and neither
/// sidecar is removed or truncated. Calling this on a handle opened in any
/// other mode is a caller bug.
pub fn verifyImmutable(self: *Db) Error!void {
const guard = self.immutable_guard orelse unreachable;
const now = try markImmutable(guard.io, guard.path);
if (!std.meta.eql(now, guard.mark)) {
log.warn(
"'{s}' changed while it was being read without a lock; the read is not trustworthy",
.{guard.path},
);
return error.WalPending;
}
}
pub fn close(self: *Db) void {
const rc = c.sqlite3_close_v2(self.handle);
if (rc != result.ok) {
log.err("sqlite3_close_v2 returned {s} (code {d})", .{
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
}
/// Borrowed; valid until the next SQLite call on this handle. Formats as
/// "<message> (code <primary>/<extended>)".
pub fn lastError(self: *Db, buf: []u8) []const u8 {
const extended = c.sqlite3_extended_errcode(self.handle);
const message = std.mem.span(c.sqlite3_errmsg(self.handle));
return std.fmt.bufPrint(buf, "{s} (code {d}/{d})", .{
message,
extended & 0xff,
extended,
}) catch "sqlite error (message did not fit the buffer)";
}
/// For DDL and multi-statement scripts. `errmsg` is passed as null and the
/// message is read back through `sqlite3_errmsg`, so there is no
/// `sqlite3_free` obligation.
pub fn exec(self: *Db, sql: [:0]const u8) Error!void {
return check(c.sqlite3_exec(self.handle, sql.ptr, null, null, null));
}
/// `sql` must hold exactly one statement; text with a second statement in it
/// is `error.Misuse` and belongs in `exec`.
pub fn prepare(self: *Db, sql: []const u8) Error!Stmt {
if (sql.len > std.math.maxInt(c_int)) return error.TooBig;
var handle: ?*c.Stmt = null;
var tail: ?[*]const u8 = null;
try check(c.sqlite3_prepare_v2(self.handle, sql.ptr, @intCast(sql.len), &handle, &tail));
const h = handle orelse return error.Misuse;
const tail_ptr = tail orelse sql.ptr + sql.len;
const consumed = @intFromPtr(tail_ptr) - @intFromPtr(sql.ptr);
const remaining = std.mem.trim(u8, sql[consumed..], " \t\r\n");
if (remaining.len != 0) {
_ = c.sqlite3_finalize(h);
return error.Misuse;
}
return .{ .handle = h, .db = self };
}
/// Runs `sql` (which must yield exactly one row with one integer column) and
/// returns it. A statement that produces no row is `error.SqliteError`.
///
/// The row shape is verified, not assumed: a result with a column count
/// other than 1, a first column that is not `SQLITE_INTEGER` (NULL, text,
/// float and blob all count), or a second row is `error.Misuse`. That is the
/// same member `prepare` returns for a caller that hands it the wrong SQL,
/// because these are the same class of fault — a caller bug or schema drift,
/// never a runtime condition. Without the checks a `SELECT` of the wrong
/// column silently returns 0.
pub fn queryInt(self: *Db, sql: []const u8) Error!i64 {
var stmt = try self.prepare(sql);
defer stmt.deinit();
if (!try stmt.step()) {
log.warn("queryInt produced no row for '{s}'", .{sql});
return error.SqliteError;
}
const columns = c.sqlite3_column_count(stmt.handle);
if (columns != 1) {
log.warn("queryInt expects 1 column, got {d}, for '{s}'", .{ columns, sql });
return error.Misuse;
}
const kind = c.sqlite3_column_type(stmt.handle, 0);
if (kind != column_type.integer) {
log.warn("queryInt expects an integer column, got type {d}, for '{s}'", .{ kind, sql });
return error.Misuse;
}
const value = stmt.columnInt(0);
if (try stmt.step()) {
log.warn("queryInt expects 1 row, got more, for '{s}'", .{sql});
return error.Misuse;
}
return value;
}
pub fn lastInsertRowid(self: *Db) i64 {
return c.sqlite3_last_insert_rowid(self.handle);
}
pub fn changes(self: *Db) i64 {
return c.sqlite3_changes(self.handle);
}
/// Every row this connection has inserted, updated or deleted since it was
/// opened. Monotonic, so a caller proves "this call wrote nothing" by
/// reading it either side and comparing — which is stronger than comparing
/// content, because an UPDATE that rewrites identical values still moves
/// this counter.
pub fn totalChanges(self: *Db) i64 {
return c.sqlite3_total_changes(self.handle);
}
};
fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
var handle: ?*c.Sqlite3 = null;
const rc = c.sqlite3_open_v2(filename.ptr, &handle, flags, null);
if (rc != result.ok) {
// sqlite3_open_v2 allocates a handle even on failure. Read the
// message from it, then close it; dropping it leaks on every
// failed open.
// Logged at `warn`, not `err`: the failure itself reaches the
// caller as a typed error, and this line only carries the message
// that would otherwise die with the handle.
if (handle) |h| {
log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d}/{d})", .{
filename,
std.mem.span(c.sqlite3_errmsg(h)),
rc & 0xff,
c.sqlite3_extended_errcode(h),
});
_ = c.sqlite3_close_v2(h);
} else {
log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d})", .{
filename,
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
return mapCode(rc);
}
return handle orelse error.SqliteError;
}
/// A silently ignored busy timeout is how a contended WAL database turns into
/// random SQLITE_BUSY failures under load.
fn applyBusyTimeout(h: *c.Sqlite3, busy_timeout_ms: c_int) Error!Db {
check(c.sqlite3_busy_timeout(h, busy_timeout_ms)) catch |e| {
_ = c.sqlite3_close_v2(h);
return e;
};
return .{ .handle = h };
}
// ---------------------------------------------------------------------------
// OpenMode.immutable
// ---------------------------------------------------------------------------
const uri_scheme = "file:";
const uri_immutable_query = "?immutable=1";
/// Worst case: every byte of the longest path the platform accepts becomes
/// `%HH`.
const immutable_uri_buf_len =
uri_scheme.len + 3 * std.Io.Dir.max_path_bytes + uri_immutable_query.len + 1;
fn openImmutable(io: std.Io, path: [:0]const u8, busy_timeout_ms: c_int) Error!Db {
const mark = try markImmutable(io, path);
var buf: [immutable_uri_buf_len]u8 = undefined;
const uri = try immutableUri(&buf, path);
// `uri` without `open_flag.uri` would be opened as a filename spelled
// "file:...", creating nothing and finding nothing.
const flags = open_flag.exrescode | open_flag.fullmutex |
open_flag.readonly | open_flag.uri;
const h = try openHandle(uri, flags);
var database = try applyBusyTimeout(h, busy_timeout_ms);
database.immutable_guard = .{ .io = io, .path = path, .mark = mark };
return database;
}
/// What `OpenMode.immutable` recorded at the start of a read so that
/// `Db.verifyImmutable` can prove nothing moved by the end of it.
pub const ImmutableGuard = struct {
io: std.Io,
/// Borrowed. Must outlive the `Db`, which every caller satisfies by owning
/// the path for at least as long as the connection it opened with it.
path: []const u8,
mark: FileMark,
};
/// The main database file at one instant, in the fields an outside observer can
/// compare cheaply. `atime` is deliberately absent: reading the file changes it,
/// so comparing it would report every read as a change.
///
/// All zero when the file does not exist, which is itself a state worth
/// comparing — a database replaced by an unlink is a database that moved.
const FileMark = struct {
present: bool,
size: u64,
inode: std.Io.File.INode,
mtime_ns: i96,
ctime_ns: i96,
};
/// The state an immutable read must find unchanged, or `error.WalPending` when
/// `<path>-wal` already holds bytes.
///
/// The `-wal` rule is deliberately conservative: any non-empty log fails.
/// Deciding whether it really holds committed frames means running WAL recovery
/// — checksums, salt, the wal-index — which is the writing that
/// `OpenMode.immutable` exists to avoid. A live writer, a crash, and a
/// checkpointed-but-retained log all land here, and refusing to answer is the
/// right side to err on: the alternative is a stale answer nobody can see is
/// stale. A zero-length log holds no frames, so the main file is complete and it
/// passes.
///
/// A failed stat is not "no WAL": it means this cannot be known, so it stays a
/// failure.
fn markImmutable(io: std.Io, path: []const u8) Error!FileMark {
var buf: [std.Io.Dir.max_path_bytes + wal_suffix.len]u8 = undefined;
const sidecar = std.fmt.bufPrint(&buf, "{s}{s}", .{ path, wal_suffix }) catch
return error.TooBig;
if (try statOrAbsent(io, sidecar)) |wal| {
if (wal.size > 0) return error.WalPending;
}
const main = try statOrAbsent(io, path) orelse return .{
.present = false,
.size = 0,
.inode = 0,
.mtime_ns = 0,
.ctime_ns = 0,
};
return .{
.present = true,
.size = main.size,
.inode = main.inode,
.mtime_ns = main.mtime.nanoseconds,
.ctime_ns = main.ctime.nanoseconds,
};
}
fn statOrAbsent(io: std.Io, path: []const u8) Error!?std.Io.Dir.Stat {
return std.Io.Dir.cwd().statFile(io, path, .{}) catch |e| switch (e) {
error.FileNotFound => return null,
else => {
log.warn("cannot stat '{s}': {t}", .{ path, e });
return error.IoErr;
},
};
}
/// `file:` + percent-encoded `path` + `?immutable=1`.
///
/// The encoding is load-bearing, not cosmetic. `?` opens SQLite's query section
/// and `#` its fragment, so an unencoded data directory named `dns?db` would
/// silently open a *different* file; `%` must be encoded because SQLite decodes
/// `%HH` on its way back to a filename. `--data-dir` is operator input, so all
/// three are reachable.
///
/// Everything outside the unreserved set (`A-Z a-z 0-9 - . _ ~ /`) is encoded,
/// which is always safe: SQLite decodes every escape in the path before handing
/// the name to its VFS, so the bytes it opens are the bytes passed in.
///
/// `/` stays literal to keep diagnostics readable, with one exception. SQLite
/// reads `file://…` as a URI authority and rejects any authority but the empty
/// one or `localhost` (`sqlite3ParseUri`), so a path beginning with `//` — legal
/// POSIX — has its second slash encoded.
fn immutableUri(buf: []u8, path: []const u8) error{TooBig}![:0]const u8 {
var out: usize = 0;
try appendSlice(buf, &out, uri_scheme);
for (path, 0..) |ch, i| {
const opens_authority = i == 1 and ch == '/' and path[0] == '/';
if (isUriUnreserved(ch) and !opens_authority) {
try appendByte(buf, &out, ch);
} else {
const hex = "0123456789ABCDEF";
try appendByte(buf, &out, '%');
try appendByte(buf, &out, hex[ch >> 4]);
try appendByte(buf, &out, hex[ch & 0xf]);
}
}
try appendSlice(buf, &out, uri_immutable_query);
try appendByte(buf, &out, 0);
return buf[0 .. out - 1 :0];
}
fn isUriUnreserved(ch: u8) bool {
return switch (ch) {
'a'...'z', 'A'...'Z', '0'...'9', '-', '.', '_', '~', '/' => true,
else => false,
};
}
fn appendByte(buf: []u8, out: *usize, ch: u8) error{TooBig}!void {
if (out.* == buf.len) return error.TooBig;
buf[out.*] = ch;
out.* += 1;
}
fn appendSlice(buf: []u8, out: *usize, bytes: []const u8) error{TooBig}!void {
for (bytes) |ch| try appendByte(buf, out, ch);
}
/// One prepared statement.
///
/// There is deliberately **no prepared-statement cache in this milestone**.
/// `config.db` is written a handful of times per process lifetime, so a cache is
/// unmeasured complexity here. Phase 6's query-log flush loop is the only hot
/// path and it owns its own long-lived statements. This is a decision, not an
/// oversight against PLAN §3.4.
pub const Stmt = struct {
handle: *c.Stmt,
db: *Db,
/// The code of the last failed `step`, or `SQLITE_OK`. `sqlite3_reset` and
/// `sqlite3_finalize` both re-report that code; without this the caller
/// would see one failure logged as a second, unrelated one.
pending_error: c_int = result.ok,
pub fn deinit(self: *Stmt) void {
const rc = c.sqlite3_finalize(self.handle);
if (rc != result.ok and rc != self.pending_error) {
log.err("sqlite3_finalize returned {s} (code {d})", .{
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
}
pub fn reset(self: *Stmt) Error!void {
const rc = c.sqlite3_reset(self.handle);
self.pending_error = result.ok;
try check(rc);
try check(c.sqlite3_clear_bindings(self.handle));
}
/// 1-based, matching SQLite.
pub fn bindInt(self: *Stmt, idx: c_int, value: i64) Error!void {
return check(c.sqlite3_bind_int64(self.handle, idx, value));
}
pub fn bindBool(self: *Stmt, idx: c_int, value: bool) Error!void {
return self.bindInt(idx, if (value) 1 else 0);
}
/// Binds with `SQLITE_TRANSIENT`, so SQLite copies the bytes and the caller
/// never has to keep `value` alive. The copy costs an allocation per bind;
/// at config.db volumes that is invisible, and it removes a whole class of
/// use-after-free from every caller.
pub fn bindText(self: *Stmt, idx: c_int, value: []const u8) Error!void {
if (value.len > std.math.maxInt(c_int)) return error.TooBig;
return check(c.sqlite3_bind_text(self.handle, idx, value.ptr, @intCast(value.len), c.transient));
}
pub fn bindTextOrNull(self: *Stmt, idx: c_int, value: ?[]const u8) Error!void {
if (value) |v| return self.bindText(idx, v);
return self.bindNull(idx);
}
pub fn bindNull(self: *Stmt, idx: c_int) Error!void {
return check(c.sqlite3_bind_null(self.handle, idx));
}
/// true = a row is available, false = the statement finished.
pub fn step(self: *Stmt) Error!bool {
const rc = c.sqlite3_step(self.handle);
if (rc == result.row) return true;
if (rc == result.done) return false;
self.pending_error = rc;
return mapCode(rc);
}
/// Runs to completion; asserts no rows were produced.
pub fn exec(self: *Stmt) Error!void {
const has_row = try self.step();
assert(!has_row);
}
pub fn columnInt(self: *Stmt, col: c_int) i64 {
return c.sqlite3_column_int64(self.handle, col);
}
pub fn columnBool(self: *Stmt, col: c_int) bool {
return self.columnInt(col) != 0;
}
pub fn isNull(self: *Stmt, col: c_int) bool {
return c.sqlite3_column_type(self.handle, col) == column_type.null_value;
}
/// Borrowed: valid only until the next `step`, `reset` or `deinit` on this
/// statement. Every caller that keeps the value must copy it.
///
/// A NULL column reads as `""`. A `NOT NULL` column makes that unreachable
/// in practice, but it must not be undefined behaviour.
pub fn columnText(self: *Stmt, col: c_int) []const u8 {
const ptr = c.sqlite3_column_text(self.handle, col) orelse return "";
const len = c.sqlite3_column_bytes(self.handle, col);
if (len <= 0) return "";
return ptr[0..@intCast(len)];
}
/// Borrowed under the same rules as `columnText`; NULL reads as `null`.
pub fn columnTextOrNull(self: *Stmt, col: c_int) ?[]const u8 {
if (self.isNull(col)) return null;
return self.columnText(col);
}
/// Copies into `gpa`. Caller owns the result.
pub fn columnTextAlloc(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}![]u8 {
return gpa.dupe(u8, self.columnText(col));
}
/// Copies into `gpa`. Caller owns the result. NULL reads as `null`.
pub fn columnTextAllocOrNull(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}!?[]u8 {
const value = self.columnTextOrNull(col) orelse return null;
return try gpa.dupe(u8, value);
}
};
pub const Pragmas = struct {
journal_wal: bool = true,
synchronous_normal: bool = true,
foreign_keys: bool = true,
/// Null leaves SQLite's 1000-page default. A caller that sets it owns the
/// durability consequences, which depend on what the database holds.
wal_autocheckpoint_pages: ?i32 = null,
};
/// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a
/// no-op inside a transaction, so applying it later silently leaves referential
/// integrity off.
pub fn applyPragmas(self: *Db, p: Pragmas) Error!void {
if (p.journal_wal) {
// The pragma returns a row holding the mode it actually reached. `exec`
// would discard that answer, and an in-memory database — which cannot do
// WAL — would look fine.
var stmt = try self.prepare("PRAGMA journal_mode = WAL");
defer stmt.deinit();
if (!try stmt.step()) return error.SqliteError;
const mode = stmt.columnText(0);
const wal = std.ascii.eqlIgnoreCase(mode, "wal");
const memory = std.ascii.eqlIgnoreCase(mode, "memory");
if (!wal and !memory) {
log.warn("PRAGMA journal_mode = WAL reported '{s}'", .{mode});
return error.SqliteError;
}
}
if (p.synchronous_normal) {
try self.exec("PRAGMA synchronous = NORMAL;");
}
if (p.foreign_keys) {
try self.exec("PRAGMA foreign_keys = ON;");
if (try self.queryInt("PRAGMA foreign_keys") != 1) {
log.warn("PRAGMA foreign_keys did not take", .{});
return error.SqliteError;
}
}
if (p.wal_autocheckpoint_pages) |pages| {
var buf: [64]u8 = undefined;
const sql = std.fmt.bufPrintZ(&buf, "PRAGMA wal_autocheckpoint = {d};", .{pages}) catch unreachable;
try self.exec(sql);
const applied = try self.queryInt("PRAGMA wal_autocheckpoint");
if (applied != pages) {
log.warn("PRAGMA wal_autocheckpoint = {d} reported {d}", .{ pages, applied });
return error.SqliteError;
}
}
}
/// A write transaction.
///
/// Usage contract, followed everywhere in this milestone:
///
/// ```zig
/// var tx = try Tx.begin(db);
/// errdefer tx.rollback();
/// ... // all writes
/// try tx.commit();
/// ```
///
/// `commit` and `rollback` both clear `active`, so the `errdefer` after a
/// successful commit is a no-op.
pub const Tx = struct {
db: *Db,
active: bool,
/// BEGIN IMMEDIATE — takes the write lock up front. A deferred transaction
/// that upgrades mid-way can fail with SQLITE_BUSY after arbitrary work;
/// immediate cannot.
pub fn begin(db: *Db) Error!Tx {
try db.exec("BEGIN IMMEDIATE;");
return .{ .db = db, .active = true };
}
pub fn commit(self: *Tx) Error!void {
assert(self.active);
try self.db.exec("COMMIT;");
self.active = false;
}
/// Safe in `errdefer` and after `commit`. Never returns an error; a failed
/// ROLLBACK is logged at `err` level with the SQLite message, because a
/// database that will not roll back is an operational event, not a detail.
pub fn rollback(self: *Tx) void {
if (!self.active) return;
self.active = false;
self.db.exec("ROLLBACK;") catch {
var buf: [256]u8 = undefined;
log.err("ROLLBACK failed: {s}", .{self.db.lastError(&buf)});
};
}
};
/// A read transaction: one consistent view of the file across several
/// statements.
///
/// `BEGIN DEFERRED`, not `Tx`'s `BEGIN IMMEDIATE`. A reader that took the write
/// lock would stall the logger and retention for the length of an HTTP
/// response; a deferred transaction that only ever reads never upgrades, so it
/// cannot hit the mid-way `SQLITE_BUSY` the `Tx` doc warns about. In WAL mode it
/// pins the snapshot at the first read, which is the point: an aggregate and the
/// coverage watermark beside it describe the same database state even when
/// retention prunes between them.
///
/// Ending it is fallible, and the caller must treat it that way. A connection
/// left inside a transaction refuses the next `BEGIN`, so a swallowed failure
/// here does not cost one response — it costs every later one on the same
/// connection, and they would each be a correct-looking answer over a foreign
/// snapshot or an outright error the client was never told about.
///
/// ```zig
/// var tx = try ReadTx.begin(db);
/// errdefer tx.rollback();
/// ... // reads only
/// try tx.commit();
/// ```
pub const ReadTx = struct {
db: *Db,
active: bool,
pub fn begin(db: *Db) Error!ReadTx {
try db.exec("BEGIN DEFERRED;");
return .{ .db = db, .active = true };
}
/// Ends the transaction, and says so. A read transaction has nothing to
/// conflict over, so a failed COMMIT means the connection is in a state
/// this code did not put it in: the ROLLBACK below is the attempt to hand
/// the next caller a usable connection anyway, and the error is returned so
/// the response it was serving fails rather than reporting success over a
/// database whose state nobody can name.
pub fn commit(self: *ReadTx) Error!void {
assert(self.active);
self.active = false;
self.execCommit() catch |err| {
self.reportFault("COMMIT");
self.forceRollback();
return err;
};
}
fn execCommit(self: *ReadTx) Error!void {
if (commitFaultTripped()) return error.Internal;
return self.db.exec("COMMIT;");
}
/// Safe in `errdefer` and after `commit`. Never returns an error: it runs
/// on the path where something has already gone wrong, and that error is
/// the one worth reporting.
pub fn rollback(self: *ReadTx) void {
if (!self.active) return;
self.active = false;
self.forceRollback();
}
/// A ROLLBACK that fails leaves the connection inside a transaction with no
/// way left to get it out. Every later `begin` on it fails, which is the
/// visible symptom this reports the cause of.
fn forceRollback(self: *ReadTx) void {
self.db.exec("ROLLBACK;") catch {
self.reportFault("ROLLBACK");
};
}
/// The only record that this connection may be unusable, so in a real build
/// it is `err`. A test that deliberately causes the fault captures it
/// instead — see `read_tx_faults`.
fn reportFault(self: *ReadTx, comptime what: []const u8) void {
var buf: [256]u8 = undefined;
const message = self.db.lastError(&buf);
if (faultCaptured()) return;
log.err("read-transaction " ++ what ++ " failed: {s}", .{message});
}
};
/// Drives and observes the read-transaction teardown faults, which a unit test
/// cannot arrange against a healthy SQLite connection. Test builds only; it
/// reduces to nothing everywhere else — the rotation seam's shape
/// (logging.zig).
const read_tx_seam = if (builtin.is_test) struct {
var fail_next_commit: bool = false;
var capturing: bool = false;
var faults: usize = 0;
} else struct {};
fn commitFaultTripped() bool {
if (!builtin.is_test) return false;
if (!read_tx_seam.fail_next_commit) return false;
read_tx_seam.fail_next_commit = false;
return true;
}
/// True when a test has said it is expecting this fault and will assert on it.
/// Capture is opt-in for exactly one reason: the test runner fails a test that
/// logs at `err`, so a blanket silence would turn an unexpected teardown fault
/// in some unrelated test into a silent pass.
fn faultCaptured() bool {
if (!builtin.is_test) return false;
if (!read_tx_seam.capturing) return false;
read_tx_seam.faults += 1;
return true;
}
/// The seam's controls, for tests in this file and in the web layer.
pub const read_tx_faults = if (builtin.is_test) struct {
/// Fails the next `ReadTx.commit` before it issues COMMIT, so the
/// transaction is still open when the recovery path runs — which is the
/// shape of a real COMMIT failure.
pub fn failNextCommit() void {
read_tx_seam.fail_next_commit = true;
}
pub fn beginCapture() void {
read_tx_seam.capturing = true;
read_tx_seam.faults = 0;
}
/// Stops capturing and answers how many faults were reported meanwhile.
pub fn endCapture() usize {
read_tx_seam.capturing = false;
return read_tx_seam.faults;
}
} else struct {};
const testing = std.testing;
fn openMemory() Error!Db {
return Db.open(":memory:", .{ .mode = .memory });
}
test "mapCode maps every primary result code to a distinct error" {
var seen: [26]Error = undefined;
var code: c_int = 1;
while (code <= 26) : (code += 1) {
seen[@intCast(code - 1)] = mapCode(code);
}
for (seen, 0..) |a, i| {
for (seen[i + 1 ..]) |b| {
try testing.expect(a != b);
}
}
}
test "mapCode maps SQLITE_NOMEM to error.OutOfMemory and keeps extended codes in the family" {
try testing.expectEqual(Error.OutOfMemory, mapCode(result.nomem));
// SQLITE_IOERR_READ = 266, SQLITE_CONSTRAINT_UNIQUE = 2067.
try testing.expectEqual(Error.IoErr, mapCode(266));
try testing.expectEqual(Error.Constraint, mapCode(2067));
// A primary code this build does not know stays visible as SqliteError.
try testing.expectEqual(Error.SqliteError, mapCode(99));
}
test "open and close an in-memory database" {
var db = try openMemory();
defer db.close();
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT 1"));
}
test "queryInt rejects a result that is not exactly one row of one integer" {
var db = try openMemory();
defer db.close();
// No row keeps the documented error.SqliteError.
try testing.expectError(error.SqliteError, db.queryInt("SELECT 1 WHERE 0"));
// Wrong column count.
try testing.expectError(error.Misuse, db.queryInt("SELECT 1, 2"));
// Wrong column type: NULL, text, float and blob are all rejected.
try testing.expectError(error.Misuse, db.queryInt("SELECT NULL"));
try testing.expectError(error.Misuse, db.queryInt("SELECT 'one'"));
try testing.expectError(error.Misuse, db.queryInt("SELECT 1.5"));
try testing.expectError(error.Misuse, db.queryInt("SELECT x'00'"));
// A second row.
try testing.expectError(error.Misuse, db.queryInt("SELECT 1 UNION ALL SELECT 2"));
}
test "queryInt accepts a single integer row after the shape checks" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
try db.exec("INSERT INTO t (id, name) VALUES (7, 'only');");
try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT id FROM t"));
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
try testing.expectEqual(@as(i64, -3), try db.queryInt("SELECT -3"));
// sum() over an empty table is NULL, not an integer: a caller that wants a
// total from a possibly-empty table must write total(), or COALESCE.
try testing.expectError(error.Misuse, db.queryInt("SELECT sum(id) FROM t WHERE 0"));
try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT sum(id) FROM t"));
}
test "applyPragmas succeeds and foreign_keys reads back as 1" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys"));
}
test "applyPragmas leaves wal_autocheckpoint at the default unless a page count is given" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try testing.expectEqual(@as(i64, 1000), try db.queryInt("PRAGMA wal_autocheckpoint"));
var configured = try openMemory();
defer configured.close();
try applyPragmas(&configured, .{ .wal_autocheckpoint_pages = 8192 });
try testing.expectEqual(@as(i64, 8192), try configured.queryInt("PRAGMA wal_autocheckpoint"));
}
test "open on a directory path returns error.CantOpen and leaks no handle" {
var i: usize = 0;
while (i < 1000) : (i += 1) {
try testing.expectError(error.CantOpen, Db.open(".", .{}));
}
}
test "prepare rejects text holding more than one statement" {
var db = try openMemory();
defer db.close();
try testing.expectError(error.Misuse, db.prepare("SELECT 1; SELECT 2"));
var stmt = try db.prepare("SELECT 1;");
stmt.deinit();
}
test "bind, step and column round-trip including a NULL text column" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, note TEXT, flag INTEGER NOT NULL);");
var insert = try db.prepare("INSERT INTO t (name, note, flag) VALUES (?1, ?2, ?3)");
defer insert.deinit();
try insert.bindText(1, "kitchen");
try insert.bindTextOrNull(2, null);
try insert.bindBool(3, true);
try insert.exec();
try testing.expectEqual(@as(i64, 1), db.changes());
try testing.expectEqual(@as(i64, 1), db.lastInsertRowid());
var select = try db.prepare("SELECT id, name, note, flag FROM t");
defer select.deinit();
try testing.expect(try select.step());
try testing.expectEqual(@as(i64, 1), select.columnInt(0));
try testing.expectEqualStrings("kitchen", select.columnText(1));
try testing.expect(select.isNull(2));
try testing.expectEqual(@as(?[]const u8, null), select.columnTextOrNull(2));
try testing.expectEqualStrings("", select.columnText(2));
try testing.expect(select.columnBool(3));
try testing.expect(!try select.step());
}
test "columnTextAlloc returns an owned copy that survives a subsequent step" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
try db.exec("INSERT INTO t (id, name) VALUES (1, 'first'), (2, 'second');");
var stmt = try db.prepare("SELECT name FROM t ORDER BY id");
defer stmt.deinit();
try testing.expect(try stmt.step());
const owned = try stmt.columnTextAlloc(testing.allocator, 0);
defer testing.allocator.free(owned);
const owned_or_null = try stmt.columnTextAllocOrNull(testing.allocator, 0);
defer if (owned_or_null) |v| testing.allocator.free(v);
try testing.expect(try stmt.step());
try testing.expectEqualStrings("second", stmt.columnText(0));
try testing.expectEqualStrings("first", owned);
try testing.expectEqualStrings("first", owned_or_null.?);
}
test "transaction commit persists and rollback discards" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
{
var tx = try Tx.begin(&db);
errdefer tx.rollback();
try db.exec("INSERT INTO t (id) VALUES (1);");
try tx.commit();
}
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
{
var tx = try Tx.begin(&db);
try db.exec("INSERT INTO t (id) VALUES (2);");
tx.rollback();
}
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
test "rollback after commit is a no-op" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
var tx = try Tx.begin(&db);
try db.exec("INSERT INTO t (id) VALUES (1);");
try tx.commit();
try testing.expect(!tx.active);
tx.rollback();
tx.rollback();
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
test "a read transaction sees one state and frees the connection when it commits" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
try db.exec("INSERT INTO t (id) VALUES (1);");
var tx = try ReadTx.begin(&db);
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
try tx.commit();
try testing.expect(!tx.active);
// Committed, so the connection takes a fresh transaction again.
var second = try ReadTx.begin(&db);
second.rollback();
try testing.expect(!second.active);
// Idempotent, so an `errdefer` that survives a successful rollback is a
// no-op rather than a stray ROLLBACK against the next transaction.
second.rollback();
var third = try ReadTx.begin(&db);
try third.commit();
}
test "a connection left inside a transaction refuses the next begin" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
// The poisoned connection, reached the only way it can be: a transaction
// that was opened and never ended. This is what a swallowed COMMIT failure
// would leave behind, and the point is that it is *loud* — the next reader
// gets an error it must report, never a silent read outside a snapshot.
try db.exec("BEGIN DEFERRED;");
try testing.expectError(error.Unexpected, ReadTx.begin(&db));
// And it is recoverable: ending the stray transaction restores the
// connection, which is what `commit`'s rollback attempt is reaching for.
try db.exec("ROLLBACK;");
var tx = try ReadTx.begin(&db);
try tx.commit();
}
test "a failed commit is reported, rolled back, and leaves the connection usable" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
try db.exec("INSERT INTO t (id) VALUES (1);");
read_tx_faults.beginCapture();
defer _ = read_tx_faults.endCapture();
var tx = try ReadTx.begin(&db);
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
// The failure lands where a real one does: the transaction is still open
// when the recovery path runs.
read_tx_faults.failNextCommit();
try testing.expectError(error.Internal, tx.commit());
try testing.expect(!tx.active);
// Exactly one fault: the COMMIT. The ROLLBACK behind it succeeded, which is
// the whole point of attempting it.
try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());
// The connection is not poisoned — the next reader gets a transaction
// rather than inheriting the fault.
read_tx_faults.beginCapture();
var next = try ReadTx.begin(&db);
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
try next.commit();
try testing.expectEqual(@as(usize, 0), read_tx_faults.endCapture());
}
test "an armed commit fault fires once and no further" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
read_tx_faults.beginCapture();
defer _ = read_tx_faults.endCapture();
read_tx_faults.failNextCommit();
var first = try ReadTx.begin(&db);
try testing.expectError(error.Internal, first.commit());
// The seam disarms itself, so it cannot leak into a later test in the same
// binary and fail a commit nobody asked to fail.
var second = try ReadTx.begin(&db);
try second.commit();
try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());
}
test "a row-producing statement reports its row through step" {
var db = try openMemory();
defer db.close();
// Stmt.exec asserts on this shape; the test observes it through `step`
// instead, so the assertion path stays out of the test binary.
var stmt = try db.prepare("SELECT 1");
defer stmt.deinit();
try testing.expect(try stmt.step());
}
// ---------------------------------------------------------------------------
// OpenMode.immutable
// ---------------------------------------------------------------------------
/// `std.testing.tmpDir` creates its directory under `.zig-cache/tmp/` relative to
/// the process working directory, which is also how SQLite's VFS resolves the
/// filename it is handed (`queries_repo.zig:721`).
const tmp_prefix = ".zig-cache/tmp/";
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;
const test_io = testing.io;
fn tmpPath(buf: []u8, tmp: *const testing.TmpDir, name: []const u8) ![:0]const u8 {
return std.fmt.bufPrintZ(buf, "{s}{s}/{s}", .{ tmp_prefix, &tmp.sub_path, name });
}
/// A file database in WAL mode holding one row, `id = marker`. Closing the last
/// connection checkpoints and unlinks both sidecars, but the header keeps saying
/// WAL — which is what makes a later `.read_only` open recreate them.
fn writeWalDatabase(path: [:0]const u8, marker: i64) !void {
var database = try Db.open(path, .{ .mode = .read_write_create });
defer database.close();
try applyPragmas(&database, .{});
try database.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
var stmt = try database.prepare("INSERT INTO t (id) VALUES (?1)");
defer stmt.deinit();
try stmt.bindInt(1, marker);
try stmt.exec();
}
/// A second writer doing what a running nxdns does: it appends to the
/// write-ahead log and, being the last connection, checkpoints into the main
/// file and unlinks both sidecars on close. The blob is what makes the main
/// file grow by whole pages, so a test that watches for the change does not rest
/// on the filesystem's timestamp resolution.
fn checkpointOver(path: [:0]const u8) !void {
var database = try Db.open(path, .{ .mode = .read_write_existing });
defer database.close();
try applyPragmas(&database, .{});
try database.exec("INSERT INTO t (id) VALUES (8);");
try database.exec("CREATE TABLE bulk (v TEXT);");
try database.exec("INSERT INTO bulk (v) VALUES (hex(randomblob(30000)));");
}
fn expectAbsent(dir: std.Io.Dir, name: []const u8) !void {
dir.access(test_io, name, .{}) catch |e| switch (e) {
error.FileNotFound => return,
else => |other| return other,
};
std.debug.print("sidecar '{s}' exists and must not\n", .{name});
return error.SidecarPresent;
}
test "immutableUri encodes what would otherwise change which file is opened" {
var buf: [256]u8 = undefined;
try testing.expectEqualStrings(
"file:/var/lib/nxdns/config.db?immutable=1",
try immutableUri(&buf, "/var/lib/nxdns/config.db"),
);
// '?' would start SQLite's query section, '#' its fragment, '%' an escape.
try testing.expectEqualStrings(
"file:/data%3Fdir/config.db?immutable=1",
try immutableUri(&buf, "/data?dir/config.db"),
);
try testing.expectEqualStrings(
"file:/data%23dir/config.db?immutable=1",
try immutableUri(&buf, "/data#dir/config.db"),
);
try testing.expectEqualStrings(
"file:/data%25dir/config.db?immutable=1",
try immutableUri(&buf, "/data%dir/config.db"),
);
try testing.expectEqualStrings(
"file:/a%20b/c%3Fd%23e%25f.db?immutable=1",
try immutableUri(&buf, "/a b/c?d#e%f.db"),
);
// A relative path stays relative: SQLite's VFS resolves it against the
// working directory, exactly as a bare filename would be.
try testing.expectEqualStrings(
"file:config.db?immutable=1",
try immutableUri(&buf, "config.db"),
);
// A leading "//" would be read as a URI authority and rejected.
try testing.expectEqualStrings(
"file:/%2Fnet/share/config.db?immutable=1",
try immutableUri(&buf, "//net/share/config.db"),
);
// Only the authority position is special: "//" further in stays literal.
try testing.expectEqualStrings(
"file:/net//share/config.db?immutable=1",
try immutableUri(&buf, "/net//share/config.db"),
);
// Non-ASCII bytes survive the round trip because SQLite decodes them back.
try testing.expectEqualStrings(
"file:/caf%C3%A9/config.db?immutable=1",
try immutableUri(&buf, "/café/config.db"),
);
var small: [16]u8 = undefined;
try testing.expectError(error.TooBig, immutableUri(&small, "/var/lib/nxdns/config.db"));
}
test "an immutable open of a WAL database creates no -wal and no -shm" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
const path = try tmpPath(&path_buf, &tmp, "config.db");
try writeWalDatabase(path, 7);
try expectAbsent(tmp.dir, "config.db-wal");
try expectAbsent(tmp.dir, "config.db-shm");
{
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
defer database.close();
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
// The point of the mode: a `.read_only` open creates both of these here,
// and cannot delete them on close.
try expectAbsent(tmp.dir, "config.db-wal");
try expectAbsent(tmp.dir, "config.db-shm");
}
try expectAbsent(tmp.dir, "config.db-wal");
try expectAbsent(tmp.dir, "config.db-shm");
}
test "an immutable open of a path holding URI metacharacters opens the intended file" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var decoy_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
var path_buf: [tmp_prefix.len + sub_path_len + 64]u8 = undefined;
// Unencoded, SQLite cuts the filename at the '?' and opens "<tmp>/d". That
// file exists here and holds a different database, so the failure without
// percent-encoding is a wrong answer, not an error.
try tmp.dir.createDirPath(test_io, "d?x#y%z");
try writeWalDatabase(try tmpPath(&decoy_buf, &tmp, "d"), 99);
const path = try tmpPath(&path_buf, &tmp, "d?x#y%z/config.db");
try writeWalDatabase(path, 7);
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
defer database.close();
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
}
test "an immutable open refuses a database whose -wal holds bytes" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
const path = try tmpPath(&path_buf, &tmp, "config.db");
try writeWalDatabase(path, 7);
// What an unclean shutdown leaves behind, written directly so the case does
// not depend on when SQLite decides to checkpoint.
try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = &([_]u8{0x37} ** 32) });
try testing.expectError(error.WalPending, Db.open(path, .{ .mode = .{ .immutable = test_io } }));
// A zero-length `-wal` holds no frames, so the main file is complete: the
// exact leftover the pre-F-c `check` created must not block a check.
try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = "" });
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
defer database.close();
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
}
test "an immutable read refuses a -wal that arrives while it is in flight" {
// The probe at open time can only say the log was empty *then*.
// `immutable=1` takes no lock, so a writer is free to arrive one instant
// later, and the read goes on answering from the older pages of the main
// file with nothing anywhere reporting it.
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
const path = try tmpPath(&path_buf, &tmp, "config.db");
try writeWalDatabase(path, 7);
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
defer database.close();
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
// Nothing has moved yet, so the read stands.
try database.verifyImmutable();
// A zero-length log still holds no frames: the rule at the end of the read
// is the rule at the start of it.
try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = "" });
try database.verifyImmutable();
// Frames, now, in the log the open accepted as empty.
try tmp.dir.writeFile(test_io, .{
.sub_path = "config.db" ++ wal_suffix,
.data = &([_]u8{0x37} ** 32),
});
try testing.expectError(error.WalPending, database.verifyImmutable());
// Repeatable: reporting the race is all it does.
try testing.expectError(error.WalPending, database.verifyImmutable());
// And it repairs nothing. The operator's log is byte for byte what was
// written, and no wal-index appeared beside it.
const wal = try tmp.dir.statFile(test_io, "config.db" ++ wal_suffix, .{});
try testing.expectEqual(@as(u64, 32), wal.size);
try expectAbsent(tmp.dir, "config.db-shm");
}
test "an immutable read refuses a main file checkpointed under it" {
// The case a `-wal` probe cannot see at either end: a writer checkpointed
// into the main file and, closing, unlinked its log again. Both stats say
// "no frames" while the pages the read answered from have been replaced.
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
const path = try tmpPath(&path_buf, &tmp, "config.db");
try writeWalDatabase(path, 7);
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
defer database.close();
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
try database.verifyImmutable();
const before = try tmp.dir.statFile(test_io, "config.db", .{});
try checkpointOver(path);
const after = try tmp.dir.statFile(test_io, "config.db", .{});
// The premise of the test, proved rather than assumed: the main file really
// did move, and the log really is gone again.
try testing.expect(after.size != before.size);
try expectAbsent(tmp.dir, "config.db" ++ wal_suffix);
try testing.expectError(error.WalPending, database.verifyImmutable());
}
test "a duplicate insert into a UNIQUE column returns error.Constraint" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);");
try db.exec("INSERT INTO t (name) VALUES ('only');");
var stmt = try db.prepare("INSERT INTO t (name) VALUES (?1)");
defer stmt.deinit();
try stmt.bindText(1, "only");
try testing.expectError(error.Constraint, stmt.step());
}