milestone 8: web server, rest api, sse, auth, metrics and static assets

This commit is contained in:
2026-08-02 00:54:13 +02:00
parent a8092bb1b9
commit 5253c47303
59 changed files with 19640 additions and 150 deletions
+52 -7
View File
@@ -7,9 +7,11 @@
//! the moment the query finishes. That is the whole reason this file has fixed
//! buffers instead of slices.
//!
//! The privacy transforms of §11.4 run inside `log`, before the entry is
//! enqueued, so nothing downstream — the database now, Phase 8's event stream
//! later — can observe a value the operator asked to hide.
//! The privacy transforms of §11.4 run in `transformed`, before the entry is
//! enqueued, so nothing downstream — the database or the event stream — can
//! observe a value the operator asked to hide. `log` is the two halves in
//! order; `QuerySink` calls them separately so both of its consumers see the
//! one transformed entry.
//!
//! Log rows are expendable. A full queue drops the oldest unflushed entry, a
//! failed batch is dropped whole, and a disk that crossed the critical
@@ -191,10 +193,23 @@ pub const Logger = struct {
/// Applies the privacy transforms and enqueues without ever blocking the
/// query path. A full queue loses its oldest unflushed entry (§11.4).
pub fn log(self: *Logger, io: std.Io, entry: Entry) void {
var transformed = entry;
if (self.cfg.hide_domains) transformed.setDomain(hidden_marker);
if (self.cfg.hide_client_ips) transformed.setClientIp(hidden_marker);
self.enqueue(io, transformed);
self.logTransformed(io, self.transformed(entry));
}
/// The §11.4 privacy transforms, on their own. `QuerySink` runs them once
/// and hands the result to every consumer, so nothing downstream — the
/// database or the event stream — can observe a value the operator asked
/// to hide.
pub fn transformed(self: *const Logger, entry: Entry) Entry {
var out = entry;
if (self.cfg.hide_domains) out.setDomain(hidden_marker);
if (self.cfg.hide_client_ips) out.setClientIp(hidden_marker);
return out;
}
/// `log` without the transforms, for a caller that already applied them.
pub fn logTransformed(self: *Logger, io: std.Io, entry: Entry) void {
self.enqueue(io, entry);
}
/// Retries until the put succeeds, and each failed attempt drops exactly
@@ -556,6 +571,36 @@ test "log hides only the field its switch names" {
try testing.expectEqualStrings("192.0.2.10", untouched.clientIp());
}
test "the split halves reproduce log byte for byte" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const configs = [_]model.Logging{
.{},
.{ .hide_domains = true },
.{ .hide_client_ips = true },
.{ .hide_domains = true, .hide_client_ips = true },
};
for (configs) |cfg| {
var buf: [4]Entry = undefined;
var logger: Logger = .init(cfg, &buf);
const source = sampleEntry(100, "tracker.example");
logger.log(io, source);
logger.logTransformed(io, logger.transformed(source));
const from_log = try logger.queue.getOne(io);
const from_halves = try logger.queue.getOne(io);
try testing.expectEqualSlices(
u8,
std.mem.asBytes(&from_log),
std.mem.asBytes(&from_halves),
);
}
}
test "a full queue drops the oldest entry and counts it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+4 -4
View File
@@ -362,10 +362,10 @@ test "S8 case 5: a retention pass prunes the old rows and truncates the write-ah
var pass: retention.Retention = .init(.{ .retention_days = 30 });
pass.runOnce(io, log_db.database());
try testing.expectEqual(@as(u64, 1), pass.stats.passes);
try testing.expectEqual(@as(u64, 2), pass.stats.rows_pruned);
try testing.expectEqual(@as(u64, 1), pass.stats.checkpoints);
try testing.expectEqual(@as(u64, 0), pass.stats.vacuums);
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes);
try testing.expectEqual(@as(u64, 2), pass.snapshotStats().rows_pruned);
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().checkpoints);
try testing.expectEqual(@as(u64, 0), pass.snapshotStats().vacuums);
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(log_db.database()));
// Both names stay: the dimension table is not collected.
try testing.expectEqual(@as(i64, 2), try queries_repo.countDomains(log_db.database()));
+438 -2
View File
@@ -5,8 +5,10 @@
//! not appear in an export. `countClients` counts **all** rows, because S5's
//! "has this database ever been configured" predicate needs the true count.
//!
//! Only list / insert / deleteAll / count, plus the two runtime calls
//! `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
//! The import path is list / insert / deleteAll / count, plus the two runtime
//! calls `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
//! Phase 8's REST surface is the third section: it speaks row ids and shows
//! every client, materialised ones included.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -15,6 +17,7 @@ const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
@@ -193,6 +196,255 @@ pub fn countClientPrefixes(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM client_prefixes");
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// `/api/clients` shows every row — a device the server materialised from live
// traffic is exactly what the operator wants to name — so these reads carry no
// `hand_edited` filter and report the flag instead. The write shapes take
// `group_id`, not a group name: the REST layer identifies every resource by row
// id, and a `group_id` no group holds must surface as the foreign-key violation
// it is.
pub const ClientRow = struct {
id: i64,
ip: []const u8,
/// `clients.name` is nullable; a NULL reads as `""`, as it does on the
/// import path.
name: []const u8,
group_id: i64,
group: []const u8,
hand_edited: bool,
first_seen: i64,
last_seen: i64,
};
/// What creating a client by hand needs. `first_seen` and `last_seen` are the
/// caller's clock, so this shape does not carry them.
pub const ClientInput = struct {
ip: []const u8,
name: []const u8 = "",
group_id: i64,
};
/// What editing a client may change (ruling 9). `ip` is absent on purpose: it is
/// the identity live traffic matches a row by, and rewriting it would collide
/// with the row the tracker materialises for the device that still holds it.
pub const ClientEdit = struct {
name: []const u8 = "",
group_id: i64,
};
const list_client_rows_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ ORDER BY c.ip
;
const get_client_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ WHERE c.id = ?1
;
/// Every client, materialised ones included. Every string is a heap copy owned
/// by `gpa`.
pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) {
var stmt = try database.prepare(list_client_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(ClientRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientRows(gpa, out.items);
while (try stmt.step()) {
const row = try readClientRow(&stmt, gpa);
errdefer freeClientRow(gpa, row);
try out.append(gpa, row);
}
return out;
}
pub fn freeClientRow(gpa: Allocator, row: ClientRow) void {
gpa.free(row.ip);
gpa.free(row.name);
gpa.free(row.group);
}
pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void {
for (items) |item| freeClientRow(gpa, item);
}
pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow {
var stmt = try database.prepare(get_client_sql);
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return try readClientRow(&stmt, gpa);
}
fn readClientRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientRow {
const ip = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(ip);
const name = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 4);
errdefer gpa.free(group);
return .{
.id = stmt.columnInt(0),
.ip = ip,
.name = name,
.group_id = stmt.columnInt(3),
.group = group,
.hand_edited = stmt.columnBool(5),
.first_seen = stmt.columnInt(6),
.last_seen = stmt.columnInt(7),
};
}
const insert_client_row_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
;
/// Creates a client the operator typed, so `hand_edited` is 1 — the difference
/// from `upsertSeen`, which materialises what the DNS path saw and never claims
/// a row is configuration.
///
/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it seeds both
/// timestamps, exactly as `insertClient` does on the import path.
///
/// `error.Constraint`: `clients.ip` is UNIQUE, or `group_id` names no group.
pub fn insertClientRow(database: *db.Db, item: ClientInput, now_s: i64) db.Error!i64 {
var stmt = try database.prepare(insert_client_row_sql);
defer stmt.deinit();
try stmt.bindText(1, item.ip);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, item.group_id);
try stmt.bindInt(4, now_s);
try stmt.exec();
return database.lastInsertRowid();
}
/// An edit is what makes a client configuration, so this sets `hand_edited` to
/// 1 on every call (ruling 9) and `pruneStale` stops considering the row.
/// `first_seen` and `last_seen` stay the tracker's.
///
/// `error.NotFound`: no client holds `id`. `error.Constraint`: `group_id` names
/// no group.
pub fn updateClient(database: *db.Db, id: i64, item: ClientEdit) db.Error!void {
var stmt = try database.prepare(
"UPDATE clients SET name = ?2, group_id = ?3, hand_edited = 1 WHERE id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, item.group_id);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no client holds `id`. Nothing references `clients`, so a
/// delete cannot violate a constraint — and a device that keeps querying
/// re-materialises through `upsertSeen`.
pub fn deleteClient(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM clients WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
pub const ClientPrefixRow = struct {
id: i64,
prefix: []const u8,
group_id: i64,
group: []const u8,
priority: i32,
};
pub const ClientPrefixInput = struct {
prefix: []const u8,
group_id: i64,
priority: i32 = 100,
};
const list_client_prefix_rows_sql =
\\SELECT p.id, p.prefix, p.group_id, g.name, p.priority FROM client_prefixes p
\\ JOIN groups g ON g.id = p.group_id
\\ ORDER BY p.prefix
;
/// Same order as `listClientPrefixes`; every string is a heap copy owned by
/// `gpa`.
pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) {
var stmt = try database.prepare(list_client_prefix_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(ClientPrefixRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientPrefixRows(gpa, out.items);
while (try stmt.step()) {
const prefix = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(group);
// The column is a 64-bit integer; the row field is `i32`. A value
// outside that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
try out.append(gpa, .{
.id = stmt.columnInt(0),
.prefix = prefix,
.group_id = stmt.columnInt(2),
.group = group,
.priority = priority,
});
}
return out;
}
pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void {
gpa.free(row.prefix);
gpa.free(row.group);
}
pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void {
for (items) |item| freeClientPrefixRow(gpa, item);
}
/// Replaces the whole prefix table inside a transaction (ruling 9 makes
/// `/api/client-prefixes` one atomic list resource). Row ids do not survive the
/// call: every row is written fresh.
///
/// `error.Constraint`: `client_prefixes.prefix` is UNIQUE, so a prefix repeated
/// in `items` is rejected rather than collapsed — two rows for one prefix with
/// different groups or priorities is a contradiction, not a set. Also fires when
/// a `group_id` names no group. Either way the old table survives untouched.
pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput) db.Error!void {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
try deleteAllClientPrefixes(database);
var stmt = try database.prepare(
"INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)",
);
defer stmt.deinit();
for (items) |item| {
// `reset` clears the bindings too, so all three are bound again on
// every pass.
try stmt.reset();
try stmt.bindText(1, item.prefix);
try stmt.bindInt(2, item.group_id);
try stmt.bindInt(3, item.priority);
try stmt.exec();
}
try tx.commit();
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -498,3 +750,187 @@ test "listClientPrefixes is leak-safe under allocation failure" {
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids});
}
// --- REST surface ----------------------------------------------------------
test "a client round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
const id = try insertClientRow(&database, .{
.ip = "192.168.1.7",
.name = "printer",
.group_id = 2,
}, 1700000000);
const fetched = (try getClient(&database, testing.allocator, id)).?;
defer freeClientRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqualStrings("192.168.1.7", fetched.ip);
try testing.expectEqualStrings("printer", fetched.name);
try testing.expectEqual(@as(i64, 2), fetched.group_id);
try testing.expectEqualStrings("kids", fetched.group);
try testing.expect(fetched.hand_edited);
try testing.expectEqual(@as(i64, 1700000000), fetched.first_seen);
try testing.expectEqual(@as(i64, 1700000000), fetched.last_seen);
try updateClient(&database, id, .{ .name = "label printer", .group_id = 1 });
const updated = (try getClient(&database, testing.allocator, id)).?;
defer freeClientRow(testing.allocator, updated);
try testing.expectEqualStrings("label printer", updated.name);
try testing.expectEqualStrings("default", updated.group);
try testing.expectEqualStrings("192.168.1.7", updated.ip);
// The tracker's timestamps are not the editor's to move.
try testing.expectEqual(@as(i64, 1700000000), updated.first_seen);
try deleteClient(&database, id);
try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "listClientRows shows materialised clients with hand_edited false" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
_ = try insertClientRow(&database, .{ .ip = "192.168.1.10", .name = "desk", .group_id = 1 }, 1700000000);
try upsertSeen(&database, "192.168.1.99", 1700000500);
var rows = try listClientRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("192.168.1.10", rows.items[0].ip);
try testing.expect(rows.items[0].hand_edited);
try testing.expectEqualStrings("192.168.1.99", rows.items[1].ip);
try testing.expect(!rows.items[1].hand_edited);
// A materialised row carries no name; NULL reads as the empty string.
try testing.expectEqualStrings("", rows.items[1].name);
try testing.expectEqualStrings("default", rows.items[1].group);
}
test "an edited client stops being a candidate for pruneStale" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.99", 1700000000);
const id = (try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.99'"));
try updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
const row = (try getClient(&database, testing.allocator, id)).?;
defer freeClientRow(testing.allocator, row);
try testing.expect(row.hand_edited);
}
test "client update and delete report NotFound for an id no row holds" {
var database = try openMigrated();
defer database.close();
try testing.expectError(error.NotFound, updateClient(&database, 404, .{ .group_id = 1 }));
try testing.expectError(error.NotFound, deleteClient(&database, 404));
try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, 404));
}
test "a duplicate ip and an unknown group both surface as error.Constraint" {
var database = try openMigrated();
defer database.close();
const id = try insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1);
try testing.expectError(
error.Constraint,
insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1),
);
try testing.expectError(
error.Constraint,
insertClientRow(&database, .{ .ip = "192.168.1.8", .group_id = 404 }, 1),
);
try testing.expectError(error.Constraint, updateClient(&database, id, .{ .group_id = 404 }));
try testing.expectEqual(@as(i64, 1), try countClients(&database));
}
test "client prefixes replace as one atomic list" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try replaceClientPrefixes(&database, &.{
.{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 10 },
.{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 },
});
var rows = try listClientPrefixRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientPrefixRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
try testing.expectEqual(@as(i64, 1), rows.items[0].group_id);
try testing.expectEqualStrings("default", rows.items[0].group);
try testing.expectEqual(@as(i32, 50), rows.items[0].priority);
try testing.expectEqualStrings("192.168.2.0/24", rows.items[1].prefix);
try testing.expectEqualStrings("kids", rows.items[1].group);
try testing.expectEqual(@as(i32, 10), rows.items[1].priority);
try testing.expect(rows.items[0].id != rows.items[1].id);
// The replacement is total, and the empty list clears the table.
try replaceClientPrefixes(&database, &.{.{ .prefix = "fd00::/48", .group_id = 2 }});
try testing.expectEqual(@as(i64, 1), try countClientPrefixes(&database));
try replaceClientPrefixes(&database, &.{});
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
}
test "replaceClientPrefixes rolls back on a duplicate prefix or an unknown group" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try replaceClientPrefixes(&database, &.{.{ .prefix = "192.168.1.0/24", .group_id = 1 }});
try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
.{ .prefix = "10.0.0.0/8", .group_id = 2 },
.{ .prefix = "10.0.0.0/8", .group_id = 1 },
}));
try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
.{ .prefix = "10.0.0.0/8", .group_id = 404 },
}));
// Both failures left the previous list in place.
var rows = try listClientPrefixRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientPrefixRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
}
fn clientRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClients(&database, ids);
try seedClientPrefixes(&database, ids);
var rows = try listClientRows(&database, gpa);
defer rows.deinit(gpa);
defer freeClientRows(gpa, rows.items);
const one = (try getClient(&database, gpa, rows.items[0].id)).?;
defer freeClientRow(gpa, one);
var prefixes = try listClientPrefixRows(&database, gpa);
defer prefixes.deinit(gpa);
defer freeClientPrefixRows(gpa, prefixes.items);
}
test "the client read surface is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, clientRowsUnderFailure, .{&ids});
}
+80
View File
@@ -0,0 +1,80 @@
//! What every by-id mutation in this directory shares.
//!
//! `UPDATE ... WHERE id = ?1` and `DELETE ... WHERE id = ?1` are silent about a
//! row that is not there: SQLite reports success and touches nothing. The REST
//! layer must answer 404 instead, so every mutation runs its statement through
//! `execStrict`, which turns "touched no row" into `error.NotFound`.
//!
//! `error.Constraint` needs no helper — `Stmt.exec` already reports it, and the
//! handler layer maps it to 409. Each mutation documents which constraint of
//! `config_schema.ddl_v1` can fire.
const std = @import("std");
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
/// Runs a statement that must touch exactly one row.
///
/// `Db.changes` counts the rows the *last completed* statement wrote, so it
/// must be read immediately after `exec`. SQLite counts a row an `UPDATE`
/// rewrote with identical values, so a no-op edit is not mistaken for a missing
/// row.
pub fn execStrict(database: *db.Db, stmt: *db.Stmt) db.Error!void {
try stmt.exec();
if (database.changes() == 0) return error.NotFound;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openTable() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
try database.exec("INSERT INTO forward_zones (id, zone, resolver) VALUES (1, 'home.arpa', 'udp://10.0.0.1:53');");
return database;
}
test "execStrict passes an update that touches a row" {
var database = try openTable();
defer database.close();
var stmt = try database.prepare("UPDATE forward_zones SET resolver = ?2 WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, 1);
try stmt.bindText(2, "udp://10.0.0.2:53");
try execStrict(&database, &stmt);
}
test "execStrict passes an update that rewrites the same value" {
var database = try openTable();
defer database.close();
var stmt = try database.prepare("UPDATE forward_zones SET resolver = ?2 WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, 1);
try stmt.bindText(2, "udp://10.0.0.1:53");
try execStrict(&database, &stmt);
}
test "execStrict reports NotFound for an id no row holds" {
var database = try openTable();
defer database.close();
var update = try database.prepare("UPDATE forward_zones SET resolver = 'x' WHERE id = ?1");
defer update.deinit();
try update.bindInt(1, 404);
try testing.expectError(error.NotFound, execStrict(&database, &update));
var delete = try database.prepare("DELETE FROM forward_zones WHERE id = ?1");
defer delete.deinit();
try delete.bindInt(1, 404);
try testing.expectError(error.NotFound, execStrict(&database, &delete));
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM forward_zones"));
}
+350 -3
View File
@@ -4,9 +4,9 @@
//! stable across an import, so an export carrying them would not re-import into
//! the same shape.
//!
//! Only list / insert / deleteAll / count exist. Update-by-id, delete-by-id and
//! paged reads are Phase 8's REST surface; adding them now would be untested,
//! unused generality.
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
//! is the second half of this file: it speaks row ids, because that is what a
//! `/api/groups/{id}` request names.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -15,6 +15,7 @@ const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
@@ -133,6 +134,148 @@ pub fn countGroupSources(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM group_sources");
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// The write shape is `model.Group`: its two fields are exactly the columns an
// operator may set, so the REST layer needs no third shape for them.
pub const GroupRow = struct { id: i64, name: []const u8, safe_search: bool };
/// Same order as `listGroups`; every string is a heap copy owned by `gpa`.
pub fn listGroupRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupRow) {
var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY name");
defer stmt.deinit();
var out: std.ArrayList(GroupRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeGroupRows(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
try out.append(gpa, .{
.id = stmt.columnInt(0),
.name = name,
.safe_search = stmt.columnBool(2),
});
}
return out;
}
pub fn freeGroupRow(gpa: Allocator, row: GroupRow) void {
gpa.free(row.name);
}
pub fn freeGroupRows(gpa: Allocator, items: []const GroupRow) void {
for (items) |item| freeGroupRow(gpa, item);
}
pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow {
var stmt = try database.prepare("SELECT id, name, safe_search FROM groups WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return .{
.id = stmt.columnInt(0),
.name = try stmt.columnTextAlloc(gpa, 1),
.safe_search = stmt.columnBool(2),
};
}
/// `error.Constraint`: `groups.name` is UNIQUE.
pub fn insertGroupRow(database: *db.Db, item: model.Group) db.Error!i64 {
var stmt = try database.prepare("INSERT INTO groups (name, safe_search) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.name);
try stmt.bindBool(2, item.safe_search);
try stmt.exec();
return database.lastInsertRowid();
}
/// `error.NotFound`: no group holds `id`. `error.Constraint`: `groups.name` is
/// UNIQUE.
pub fn updateGroup(database: *db.Db, id: i64, item: model.Group) db.Error!void {
var stmt = try database.prepare("UPDATE groups SET name = ?2, safe_search = ?3 WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.name);
try stmt.bindBool(3, item.safe_search);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no group holds `id`. `error.Constraint`: `clients.group_id`
/// references it and carries no `ON DELETE` action, so a group any client sits
/// in cannot go. `client_prefixes`, `group_sources` and `rules` cascade and
/// disappear with it.
pub fn deleteGroup(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM groups WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
/// The blocklist sources assigned to one group, ascending. An unknown
/// `group_id` yields an empty list, not an error: the caller that needs the
/// distinction reads the group itself.
pub fn listGroupSourceIds(database: *db.Db, gpa: Allocator, group_id: i64) db.Error!std.ArrayList(i64) {
var stmt = try database.prepare("SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id");
defer stmt.deinit();
try stmt.bindInt(1, group_id);
var out: std.ArrayList(i64) = .empty;
errdefer out.deinit(gpa);
while (try stmt.step()) try out.append(gpa, stmt.columnInt(0));
return out;
}
/// Replaces one group's whole source assignment inside a transaction, so a
/// caller never observes the group with half a set.
///
/// The assignment is a set: an id repeated in `source_ids` is written once.
/// Order does not survive, and re-running the call with the same ids is a no-op
/// as far as any reader can tell.
///
/// `error.NotFound`: no group holds `group_id` — checked explicitly, because an
/// empty `source_ids` writes nothing and would otherwise report success for a
/// group that does not exist. `error.Constraint`: an id in `source_ids` names no
/// `blocklist_sources` row.
pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64) db.Error!void {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
if (!try groupExists(database, group_id)) return error.NotFound;
{
var delete = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1");
defer delete.deinit();
try delete.bindInt(1, group_id);
try delete.exec();
}
var insert = try database.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (?1, ?2)");
defer insert.deinit();
for (source_ids, 0..) |source_id, i| {
if (std.mem.indexOfScalar(i64, source_ids[0..i], source_id) != null) continue;
// `reset` clears the bindings too, so both parameters are bound again
// on every pass.
try insert.reset();
try insert.bindInt(1, group_id);
try insert.bindInt(2, source_id);
try insert.exec();
}
try tx.commit();
}
fn groupExists(database: *db.Db, id: i64) db.Error!bool {
var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return stmt.step();
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -284,3 +427,207 @@ test "listGroupSources is leak-safe under allocation failure" {
defer ids.deinit(testing.allocator);
try testing.checkAllAllocationFailures(testing.allocator, listGroupSourcesUnderFailure, .{&ids});
}
// --- REST surface ----------------------------------------------------------
/// Two sources with known ids, for the group-source assignment tests.
fn seedSourceRows(database: *db.Db) !void {
try database.exec(
\\INSERT INTO blocklist_sources (id, url, name) VALUES
\\ (10, 'https://a.example/list.txt', 'A'),
\\ (20, 'https://b.example/list.txt', 'B'),
\\ (30, 'https://c.example/list.txt', 'C');
);
}
test "a group round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
const id = try insertGroupRow(&database, .{ .name = "kids", .safe_search = true });
try testing.expect(id > 1);
const fetched = (try getGroup(&database, testing.allocator, id)).?;
defer freeGroupRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqualStrings("kids", fetched.name);
try testing.expect(fetched.safe_search);
var rows = try listGroupRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeGroupRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("default", rows.items[0].name);
try testing.expectEqual(@as(i64, 1), rows.items[0].id);
try testing.expectEqualStrings("kids", rows.items[1].name);
try testing.expectEqual(id, rows.items[1].id);
try updateGroup(&database, id, .{ .name = "teens", .safe_search = false });
const updated = (try getGroup(&database, testing.allocator, id)).?;
defer freeGroupRow(testing.allocator, updated);
try testing.expectEqualStrings("teens", updated.name);
try testing.expect(!updated.safe_search);
try deleteGroup(&database, id);
try testing.expectEqual(@as(?GroupRow, null), try getGroup(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 1), try countGroups(&database));
}
test "update and delete report NotFound for an id no group holds" {
var database = try openMigrated();
defer database.close();
try testing.expectError(error.NotFound, updateGroup(&database, 404, .{ .name = "ghost" }));
try testing.expectError(error.NotFound, deleteGroup(&database, 404));
try testing.expectEqual(@as(?GroupRow, null), try getGroup(&database, testing.allocator, 404));
}
test "a duplicate group name surfaces as error.Constraint on insert and on update" {
var database = try openMigrated();
defer database.close();
const id = try insertGroupRow(&database, .{ .name = "kids" });
try testing.expectError(error.Constraint, insertGroupRow(&database, .{ .name = "kids" }));
try testing.expectError(error.Constraint, updateGroup(&database, id, .{ .name = "default" }));
}
test "deleting a group a client sits in surfaces as error.Constraint" {
var database = try openMigrated();
defer database.close();
const id = try insertGroupRow(&database, .{ .name = "kids" });
try database.exec(
\\INSERT INTO clients (ip, group_id, first_seen, last_seen)
\\VALUES ('192.168.1.9', 2, 1, 1);
);
try testing.expectError(error.Constraint, deleteGroup(&database, id));
try testing.expectEqual(@as(i64, 2), try countGroups(&database));
}
test "deleting a group takes its rules, prefixes and source assignment with it" {
var database = try openMigrated();
defer database.close();
try seedSourceRows(&database);
const id = try insertGroupRow(&database, .{ .name = "kids" });
try setGroupSources(&database, id, &.{ 10, 20 });
try database.exec("INSERT INTO rules (group_id, pattern, kind, action, created_at) VALUES (2, 'x.example', 'exact', 'block', 1);");
try database.exec("INSERT INTO client_prefixes (prefix, group_id) VALUES ('10.0.0.0/8', 2);");
try deleteGroup(&database, id);
try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM rules"));
try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM client_prefixes"));
}
fn sourceIds(database: *db.Db, group_id: i64) ![]i64 {
var list = try listGroupSourceIds(database, testing.allocator, group_id);
return list.toOwnedSlice(testing.allocator);
}
test "setGroupSources replaces the whole set and repeats without effect" {
var database = try openMigrated();
defer database.close();
try seedSourceRows(&database);
try setGroupSources(&database, 1, &.{ 20, 10 });
{
const ids = try sourceIds(&database, 1);
defer testing.allocator.free(ids);
try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids);
}
// Same set again: the rows are rewritten, the observable state is not.
try setGroupSources(&database, 1, &.{ 10, 20 });
{
const ids = try sourceIds(&database, 1);
defer testing.allocator.free(ids);
try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids);
}
try testing.expectEqual(@as(i64, 2), try countGroupSources(&database));
// A different set replaces, it does not merge.
try setGroupSources(&database, 1, &.{30});
{
const ids = try sourceIds(&database, 1);
defer testing.allocator.free(ids);
try testing.expectEqualSlices(i64, &.{30}, ids);
}
// The empty set clears it.
try setGroupSources(&database, 1, &.{});
try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
}
test "setGroupSources writes a repeated id once" {
var database = try openMigrated();
defer database.close();
try seedSourceRows(&database);
try setGroupSources(&database, 1, &.{ 10, 10, 20, 10 });
const ids = try sourceIds(&database, 1);
defer testing.allocator.free(ids);
try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids);
}
test "setGroupSources leaves other groups alone" {
var database = try openMigrated();
defer database.close();
try seedSourceRows(&database);
const kids = try insertGroupRow(&database, .{ .name = "kids" });
try setGroupSources(&database, 1, &.{10});
try setGroupSources(&database, kids, &.{ 20, 30 });
try setGroupSources(&database, kids, &.{20});
const default_ids = try sourceIds(&database, 1);
defer testing.allocator.free(default_ids);
try testing.expectEqualSlices(i64, &.{10}, default_ids);
const kids_ids = try sourceIds(&database, kids);
defer testing.allocator.free(kids_ids);
try testing.expectEqualSlices(i64, &.{20}, kids_ids);
}
test "setGroupSources reports NotFound for a group that does not exist" {
var database = try openMigrated();
defer database.close();
try seedSourceRows(&database);
try testing.expectError(error.NotFound, setGroupSources(&database, 404, &.{10}));
// Including the case where the empty set writes nothing at all.
try testing.expectError(error.NotFound, setGroupSources(&database, 404, &.{}));
}
test "setGroupSources rolls back and reports Constraint for an unknown source id" {
var database = try openMigrated();
defer database.close();
try seedSourceRows(&database);
try setGroupSources(&database, 1, &.{10});
try testing.expectError(error.Constraint, setGroupSources(&database, 1, &.{ 20, 999 }));
// The prior assignment survived: the failed call wrote nothing.
const ids = try sourceIds(&database, 1);
defer testing.allocator.free(ids);
try testing.expectEqualSlices(i64, &.{10}, ids);
}
fn groupRowsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
var rows = try listGroupRows(&database, gpa);
defer rows.deinit(gpa);
defer freeGroupRows(gpa, rows.items);
const one = (try getGroup(&database, gpa, 1)).?;
defer freeGroupRow(gpa, one);
var ids = try listGroupSourceIds(&database, gpa, 1);
defer ids.deinit(gpa);
}
test "the group read surface is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, groupRowsUnderFailure, .{});
}
+341 -1
View File
@@ -1,6 +1,8 @@
//! `local_records` and `forward_zones`.
//!
//! Only list / insert / deleteAll / count exist.
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
//! follows each table's section: it speaks row ids, because that is what an
//! `/api/local-records/{id}` or `/api/forward-zones/{id}` request names.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -9,6 +11,7 @@ const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");
const InsertContext = context.InsertContext;
@@ -121,6 +124,185 @@ pub fn countForwardZones(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM forward_zones");
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// Neither table references another, so the write shapes are `model.LocalRecord`
// and `model.ForwardZone` unchanged: their fields are exactly the columns.
pub const LocalRecordRow = struct {
id: i64,
name: []const u8,
rtype: model.RecordType,
value: []const u8,
ttl: u32,
};
const list_local_record_rows_sql =
\\SELECT id, name, rtype, value, ttl FROM local_records ORDER BY name, rtype, value
;
/// Same order as `listLocalRecords`; every string is a heap copy owned by `gpa`.
pub fn listLocalRecordRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(LocalRecordRow) {
var stmt = try database.prepare(list_local_record_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(LocalRecordRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeLocalRecordRows(gpa, out.items);
while (try stmt.step()) {
const row = try readLocalRecordRow(&stmt, gpa);
errdefer freeLocalRecordRow(gpa, row);
try out.append(gpa, row);
}
return out;
}
pub fn freeLocalRecordRow(gpa: Allocator, row: LocalRecordRow) void {
gpa.free(row.name);
gpa.free(row.value);
}
pub fn freeLocalRecordRows(gpa: Allocator, items: []const LocalRecordRow) void {
for (items) |item| freeLocalRecordRow(gpa, item);
}
pub fn getLocalRecord(database: *db.Db, gpa: Allocator, id: i64) db.Error!?LocalRecordRow {
var stmt = try database.prepare("SELECT id, name, rtype, value, ttl FROM local_records WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return try readLocalRecordRow(&stmt, gpa);
}
fn readLocalRecordRow(stmt: *db.Stmt, gpa: Allocator) db.Error!LocalRecordRow {
// The DDL's CHECK constraint makes the decode total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote, and the
// same goes for a `ttl` outside `u32`.
const rtype = model.RecordType.fromDb(stmt.columnText(2)) orelse return error.Mismatch;
const ttl = std.math.cast(u32, stmt.columnInt(4)) orelse return error.Mismatch;
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
const value = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(value);
return .{ .id = stmt.columnInt(0), .name = name, .rtype = rtype, .value = value, .ttl = ttl };
}
/// `error.Constraint`: `local_records` declares `UNIQUE(name, rtype, value)`, so
/// the same answer cannot be stored twice — a second TTL for one record would be
/// two truths.
pub fn insertLocalRecordRow(database: *db.Db, item: model.LocalRecord) db.Error!i64 {
var stmt = try database.prepare(insert_local_record_sql);
defer stmt.deinit();
try stmt.bindText(1, item.name);
try stmt.bindText(2, item.rtype.toDb());
try stmt.bindText(3, item.value);
try stmt.bindInt(4, item.ttl);
try stmt.exec();
return database.lastInsertRowid();
}
/// `error.NotFound`: no record holds `id`. `error.Constraint`:
/// `UNIQUE(name, rtype, value)`.
pub fn updateLocalRecord(database: *db.Db, id: i64, item: model.LocalRecord) db.Error!void {
var stmt = try database.prepare(
"UPDATE local_records SET name = ?2, rtype = ?3, value = ?4, ttl = ?5 WHERE id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.name);
try stmt.bindText(3, item.rtype.toDb());
try stmt.bindText(4, item.value);
try stmt.bindInt(5, item.ttl);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no record holds `id`. Nothing references `local_records`,
/// so a delete cannot violate a constraint.
pub fn deleteLocalRecord(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM local_records WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
pub const ForwardZoneRow = struct { id: i64, zone: []const u8, resolver: []const u8 };
/// Same order as `listForwardZones`; every string is a heap copy owned by `gpa`.
pub fn listForwardZoneRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ForwardZoneRow) {
var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones ORDER BY zone");
defer stmt.deinit();
var out: std.ArrayList(ForwardZoneRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeForwardZoneRows(gpa, out.items);
while (try stmt.step()) {
const row = try readForwardZoneRow(&stmt, gpa);
errdefer freeForwardZoneRow(gpa, row);
try out.append(gpa, row);
}
return out;
}
pub fn freeForwardZoneRow(gpa: Allocator, row: ForwardZoneRow) void {
gpa.free(row.zone);
gpa.free(row.resolver);
}
pub fn freeForwardZoneRows(gpa: Allocator, items: []const ForwardZoneRow) void {
for (items) |item| freeForwardZoneRow(gpa, item);
}
pub fn getForwardZone(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ForwardZoneRow {
var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return try readForwardZoneRow(&stmt, gpa);
}
fn readForwardZoneRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ForwardZoneRow {
const zone = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(zone);
const resolver = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(resolver);
return .{ .id = stmt.columnInt(0), .zone = zone, .resolver = resolver };
}
/// `error.Constraint`: `forward_zones.zone` is UNIQUE — one zone has one
/// resolver.
pub fn insertForwardZoneRow(database: *db.Db, item: model.ForwardZone) db.Error!i64 {
var stmt = try database.prepare("INSERT INTO forward_zones (zone, resolver) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.zone);
try stmt.bindText(2, item.resolver);
try stmt.exec();
return database.lastInsertRowid();
}
/// `error.NotFound`: no zone holds `id`. `error.Constraint`:
/// `forward_zones.zone` is UNIQUE.
pub fn updateForwardZone(database: *db.Db, id: i64, item: model.ForwardZone) db.Error!void {
var stmt = try database.prepare("UPDATE forward_zones SET zone = ?2, resolver = ?3 WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.zone);
try stmt.bindText(3, item.resolver);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no zone holds `id`. Nothing references `forward_zones`, so
/// a delete cannot violate a constraint.
pub fn deleteForwardZone(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM forward_zones WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -253,3 +435,161 @@ fn listForwardZonesUnderFailure(gpa: Allocator) !void {
test "listForwardZones is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listForwardZonesUnderFailure, .{});
}
// --- REST surface ----------------------------------------------------------
test "a local record round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
const id = try insertLocalRecordRow(&database, .{
.name = "nas.home.arpa",
.rtype = .a,
.value = "192.168.1.5",
});
const fetched = (try getLocalRecord(&database, testing.allocator, id)).?;
defer freeLocalRecordRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqualStrings("nas.home.arpa", fetched.name);
try testing.expectEqual(model.RecordType.a, fetched.rtype);
try testing.expectEqualStrings("192.168.1.5", fetched.value);
try testing.expectEqual(@as(u32, 300), fetched.ttl);
try updateLocalRecord(&database, id, .{
.name = "nas.home.arpa",
.rtype = .a,
.value = "192.168.1.6",
.ttl = 60,
});
const updated = (try getLocalRecord(&database, testing.allocator, id)).?;
defer freeLocalRecordRow(testing.allocator, updated);
try testing.expectEqualStrings("192.168.1.6", updated.value);
try testing.expectEqual(@as(u32, 60), updated.ttl);
var rows = try listLocalRecordRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeLocalRecordRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqual(id, rows.items[0].id);
try deleteLocalRecord(&database, id);
try testing.expectEqual(@as(?LocalRecordRow, null), try getLocalRecord(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 0), try countLocalRecords(&database));
}
test "local record update and delete report NotFound for an id no row holds" {
var database = try openMigrated();
defer database.close();
const item: model.LocalRecord = .{ .name = "x.home.arpa", .rtype = .a, .value = "10.0.0.1" };
try testing.expectError(error.NotFound, updateLocalRecord(&database, 404, item));
try testing.expectError(error.NotFound, deleteLocalRecord(&database, 404));
try testing.expectEqual(@as(?LocalRecordRow, null), try getLocalRecord(&database, testing.allocator, 404));
}
test "a duplicate name, rtype and value surfaces as error.Constraint" {
var database = try openMigrated();
defer database.close();
const first: model.LocalRecord = .{ .name = "nas.home.arpa", .rtype = .a, .value = "192.168.1.5" };
_ = try insertLocalRecordRow(&database, first);
// The TTL is not part of the UNIQUE key, so a second TTL is still a clash.
try testing.expectError(error.Constraint, insertLocalRecordRow(&database, .{
.name = "nas.home.arpa",
.rtype = .a,
.value = "192.168.1.5",
.ttl = 60,
}));
const other = try insertLocalRecordRow(&database, .{
.name = "nas.home.arpa",
.rtype = .aaaa,
.value = "fd00::5",
});
try testing.expectError(error.Constraint, updateLocalRecord(&database, other, first));
try testing.expectEqual(@as(i64, 2), try countLocalRecords(&database));
}
test "a forward zone round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
const id = try insertForwardZoneRow(&database, .{
.zone = "home.arpa",
.resolver = "udp://192.168.1.1:53",
});
const fetched = (try getForwardZone(&database, testing.allocator, id)).?;
defer freeForwardZoneRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqualStrings("home.arpa", fetched.zone);
try testing.expectEqualStrings("udp://192.168.1.1:53", fetched.resolver);
try updateForwardZone(&database, id, .{ .zone = "lab.example", .resolver = "tcp://[fd00::1]:53" });
const updated = (try getForwardZone(&database, testing.allocator, id)).?;
defer freeForwardZoneRow(testing.allocator, updated);
try testing.expectEqualStrings("lab.example", updated.zone);
try testing.expectEqualStrings("tcp://[fd00::1]:53", updated.resolver);
var rows = try listForwardZoneRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeForwardZoneRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqual(id, rows.items[0].id);
try deleteForwardZone(&database, id);
try testing.expectEqual(@as(?ForwardZoneRow, null), try getForwardZone(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 0), try countForwardZones(&database));
}
test "forward zone update and delete report NotFound for an id no row holds" {
var database = try openMigrated();
defer database.close();
const item: model.ForwardZone = .{ .zone = "home.arpa", .resolver = "udp://10.0.0.1:53" };
try testing.expectError(error.NotFound, updateForwardZone(&database, 404, item));
try testing.expectError(error.NotFound, deleteForwardZone(&database, 404));
try testing.expectEqual(@as(?ForwardZoneRow, null), try getForwardZone(&database, testing.allocator, 404));
}
test "a duplicate zone surfaces as error.Constraint on insert and on update" {
var database = try openMigrated();
defer database.close();
_ = try insertForwardZoneRow(&database, .{ .zone = "home.arpa", .resolver = "udp://10.0.0.1:53" });
const other = try insertForwardZoneRow(&database, .{ .zone = "lab.example", .resolver = "udp://10.0.0.2:53" });
try testing.expectError(error.Constraint, insertForwardZoneRow(&database, .{
.zone = "home.arpa",
.resolver = "udp://10.0.0.3:53",
}));
try testing.expectError(error.Constraint, updateForwardZone(&database, other, .{
.zone = "home.arpa",
.resolver = "udp://10.0.0.2:53",
}));
try testing.expectEqual(@as(i64, 2), try countForwardZones(&database));
}
fn localRowsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
try seedForwardZones(&database);
var records = try listLocalRecordRows(&database, gpa);
defer records.deinit(gpa);
defer freeLocalRecordRows(gpa, records.items);
const record = (try getLocalRecord(&database, gpa, records.items[0].id)).?;
defer freeLocalRecordRow(gpa, record);
var zones = try listForwardZoneRows(&database, gpa);
defer zones.deinit(gpa);
defer freeForwardZoneRows(gpa, zones.items);
const zone = (try getForwardZone(&database, gpa, zones.items[0].id)).?;
defer freeForwardZoneRow(gpa, zone);
}
test "the local read surface is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, localRowsUnderFailure, .{});
}
+668 -2
View File
@@ -2,8 +2,9 @@
//!
//! Two shapes live here. The free functions follow the milestone-4 repository
//! idiom — prepare, use, finalize — because retention runs them a handful of
//! times per day. The flush loop is the one hot path in the program, so it gets
//! `BatchWriter`, which owns its three statements for its whole life
//! times per day, and the API read layer at the bottom of the file runs once
//! per HTTP request. The flush loop is the one hot path in the program, so it
//! gets `BatchWriter`, which owns its three statements for its whole life
//! (`db.zig:360` names this file as the reason `db.zig` carries no statement
//! cache).
//!
@@ -15,6 +16,7 @@
//! decides what a failed batch means.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
@@ -181,6 +183,294 @@ pub fn countDomains(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM domains");
}
// ---------------------------------------------------------------------------
// the API read layer (`GET /api/queries`, `/api/stats`, `/api/stats/timeseries`)
// ---------------------------------------------------------------------------
/// One row of `GET /api/queries`, joined back through the `domains` dimension.
///
/// `block_reason` and `upstream` are nullable columns, and a NULL reads as `""`
/// — the same convention `Stmt.columnText` already uses. Neither column is ever
/// written as an empty string (a reason is a word, an upstream is a URL), so the
/// mapping loses nothing and the API layer can treat `""` as "absent".
pub const QueryRow = struct {
id: i64,
ts: i64,
domain: []const u8,
client_ip: []const u8,
qtype: ?u16,
blocked: bool,
block_reason: []const u8,
response_time_us: ?i64,
cache_hit: ?bool,
upstream: []const u8,
};
/// Every field is an independent narrowing; `null` means "do not filter on it".
///
/// `since` is inclusive and `until` is exclusive, so adjacent windows tile
/// without double-counting a row on the boundary.
pub const QueryFilter = struct {
limit: u32 = 100,
/// Keyset cursor: only rows with a strictly smaller `id`. Rows come back
/// newest-first, so this is the id of the last row of the previous page.
before: ?i64 = null,
/// Matched case-insensitively for ASCII, which is what SQLite's `LIKE`
/// does and what a domain search wants.
domain_substring: ?[]const u8 = null,
client: ?[]const u8 = null,
blocked: ?bool = null,
since: ?i64 = null,
until: ?i64 = null,
};
/// Ruling 11 caps the page at 1000; the repository enforces it too, so a caller
/// that forgets cannot ask this connection for the whole table.
pub const max_limit: u32 = 1000;
const select_head =
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
\\ q.block_reason, q.response_time_us, q.cache_hit, q.upstream
\\ FROM query_log q JOIN domains d ON d.id = q.domain_id
;
/// The escape character of `where_domain`. SQLite does not give string literals
/// C escapes, so `'\'` in the SQL text is one backslash.
const like_escape = '\\';
const where_before = " q.id < ?";
const where_domain = " d.domain LIKE ? ESCAPE '\\'";
const where_client = " q.client_ip = ?";
const where_blocked = " q.blocked = ?";
const where_since = " q.timestamp >= ?";
const where_until = " q.timestamp < ?";
const select_tail = " ORDER BY q.id DESC LIMIT ?";
const where_keyword = " WHERE";
const and_keyword = " AND";
/// Assembles the statement from the fixed fragments above and nothing else.
///
/// **No value ever reaches this buffer.** Every filter contributes a `?` and is
/// bound afterwards, in the order the predicates were appended: an unnumbered
/// parameter takes the next free index, so append order and bind order are the
/// same single contract.
const Sql = struct {
/// `where_keyword` is longer than `and_keyword` and is used at most once,
/// so counting six of it bounds every reachable combination.
const capacity = select_head.len + 6 * where_keyword.len + select_tail.len +
where_before.len + where_domain.len + where_client.len +
where_blocked.len + where_since.len + where_until.len;
buf: [capacity]u8 = undefined,
len: usize = 0,
has_where: bool = false,
fn put(self: *Sql, fragment: []const u8) void {
@memcpy(self.buf[self.len..][0..fragment.len], fragment);
self.len += fragment.len;
}
fn predicate(self: *Sql, fragment: []const u8) void {
self.put(if (self.has_where) and_keyword else where_keyword);
self.has_where = true;
self.put(fragment);
}
fn text(self: *const Sql) []const u8 {
return self.buf[0..self.len];
}
};
/// Rows come back newest-first (`id DESC`). Every string is allocated from
/// `arena`, including the list's own storage, so the caller frees the whole
/// result by resetting the arena — there is nothing to unwind on failure.
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db.Error!std.ArrayList(QueryRow) {
var sql: Sql = .{};
sql.put(select_head);
if (filter.before != null) sql.predicate(where_before);
if (filter.domain_substring != null) sql.predicate(where_domain);
if (filter.client != null) sql.predicate(where_client);
if (filter.blocked != null) sql.predicate(where_blocked);
if (filter.since != null) sql.predicate(where_since);
if (filter.until != null) sql.predicate(where_until);
sql.put(select_tail);
var stmt = try database.prepare(sql.text());
defer stmt.deinit();
var idx: c_int = 0;
if (filter.before) |v| {
idx += 1;
try stmt.bindInt(idx, v);
}
if (filter.domain_substring) |v| {
idx += 1;
try stmt.bindText(idx, try likePattern(arena, v));
}
if (filter.client) |v| {
idx += 1;
try stmt.bindText(idx, v);
}
if (filter.blocked) |v| {
idx += 1;
try stmt.bindBool(idx, v);
}
if (filter.since) |v| {
idx += 1;
try stmt.bindInt(idx, v);
}
if (filter.until) |v| {
idx += 1;
try stmt.bindInt(idx, v);
}
idx += 1;
try stmt.bindInt(idx, @min(filter.limit, max_limit));
var out: std.ArrayList(QueryRow) = .empty;
while (try stmt.step()) {
try out.append(arena, .{
.id = stmt.columnInt(0),
.ts = stmt.columnInt(1),
.domain = try stmt.columnTextAlloc(arena, 2),
.client_ip = try stmt.columnTextAlloc(arena, 3),
.qtype = if (stmt.isNull(4)) null else std.math.cast(u16, stmt.columnInt(4)) orelse
return error.Mismatch,
.blocked = stmt.columnBool(5),
.block_reason = try stmt.columnTextAlloc(arena, 6),
.response_time_us = if (stmt.isNull(7)) null else stmt.columnInt(7),
.cache_hit = if (stmt.isNull(8)) null else stmt.columnBool(8),
.upstream = try stmt.columnTextAlloc(arena, 9),
});
}
return out;
}
/// Wraps `needle` in `%` and neutralises the two `LIKE` metacharacters, so a
/// user searching for `a_b` gets domains containing `a_b` and not domains
/// containing `axb`. The escape character escapes itself.
fn likePattern(arena: Allocator, needle: []const u8) Allocator.Error![]const u8 {
var out: std.ArrayList(u8) = try .initCapacity(arena, needle.len * 2 + 2);
out.appendAssumeCapacity('%');
for (needle) |ch| {
if (ch == '%' or ch == '_' or ch == like_escape) out.appendAssumeCapacity(like_escape);
out.appendAssumeCapacity(ch);
}
out.appendAssumeCapacity('%');
return out.items;
}
/// The `/api/stats` rollup for one period. `avg_response_time_us` is `null` when
/// no row in the window recorded a response time.
pub const StatsTotals = struct {
queries: u64,
blocked: u64,
cached: u64,
distinct_clients: u64,
avg_response_time_us: ?i64,
};
/// The mean is derived from a sum and a count rather than SQL's `avg`, which
/// returns REAL: `Stmt` reads integers, and integer microseconds are exact.
const stats_totals_sql =
\\SELECT count(*),
\\ coalesce(sum(blocked <> 0), 0),
\\ coalesce(sum(cache_hit = 1), 0),
\\ count(DISTINCT client_ip),
\\ coalesce(sum(response_time_us), 0),
\\ count(response_time_us)
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?2
;
/// Aggregates `[since, until)`. An empty window is all zeros with a null mean,
/// not an error.
pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotals {
var stmt = try database.prepare(stats_totals_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, until);
// A bare aggregate always produces exactly one row; no row means the
// statement is not the one this function prepared.
if (!try stmt.step()) return error.Misuse;
const timed = stmt.columnInt(5);
return .{
.queries = try countOf(stmt.columnInt(0)),
.blocked = try countOf(stmt.columnInt(1)),
.cached = try countOf(stmt.columnInt(2)),
.distinct_clients = try countOf(stmt.columnInt(3)),
.avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(4), timed),
};
}
/// `count` and `sum` over non-negative columns cannot go negative; a negative
/// value means the row came from something other than this schema.
fn countOf(value: i64) db.Error!u64 {
if (value < 0) return error.Mismatch;
return @intCast(value);
}
/// One bucket of `/api/stats/timeseries`. `ts` is the bucket's inclusive start.
pub const Bucket = struct {
ts: i64,
queries: u64,
blocked: u64,
cached: u64,
};
const timeseries_sql =
\\SELECT (timestamp - ?1) / ?2,
\\ count(*),
\\ coalesce(sum(blocked <> 0), 0),
\\ coalesce(sum(cache_hit = 1), 0)
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?3
\\ GROUP BY 1
;
/// Fills `out` with `out.len` buckets of `bucket_seconds` each, covering
/// `[since, since + bucket_seconds * out.len)`, and returns how many it wrote.
///
/// Every bucket is present: a window with no rows in it is written with zeros
/// rather than skipped, so the caller charts a contiguous axis without
/// reconstructing the gaps. Buckets are aligned to `since`, so the caller —
/// which knows the period grammar of ruling 13 — owns UTC alignment by choosing
/// `since`.
pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Bucket) db.Error!usize {
if (out.len == 0) return 0;
// Both are caller bugs, not runtime conditions: a zero width would make the
// SQL divide by zero (SQLite yields NULL, silently emptying the chart), and
// a window that does not fit i64 cannot be asked about.
if (bucket_seconds == 0) return error.Misuse;
const width: i64 = bucket_seconds;
const span = std.math.mul(i64, width, std.math.cast(i64, out.len) orelse
return error.Misuse) catch return error.Misuse;
const until = std.math.add(i64, since, span) catch return error.Misuse;
for (out, 0..) |*bucket, i| {
bucket.* = .{ .ts = since + width * @as(i64, @intCast(i)), .queries = 0, .blocked = 0, .cached = 0 };
}
var stmt = try database.prepare(timeseries_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, width);
try stmt.bindInt(3, until);
while (try stmt.step()) {
// The WHERE clause already bounds the index to `out`; the check is
// cheap and keeps a schema surprise from writing past the slice.
const index = std.math.cast(usize, stmt.columnInt(0)) orelse return error.Mismatch;
if (index >= out.len) return error.Mismatch;
out[index].queries = try countOf(stmt.columnInt(1));
out[index].blocked = try countOf(stmt.columnInt(2));
out[index].cached = try countOf(stmt.columnInt(3));
}
return out.len;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -466,3 +756,379 @@ test "checkpointTruncate and vacuum run against a WAL file database" {
try testing.expectEqual(@as(i64, 1), try countRows(&database));
try testing.expectEqual(@as(i64, 2), try countDomains(&database));
}
// --- the read layer -------------------------------------------------------
/// `BatchWriter` assigns `query_log.id` in the order it is handed the rows, so
/// every test below knows the id of each seeded row: the nth row of the nth
/// batch has id n.
fn seed(database: *db.Db, rows: []const Row) !void {
var writer = try BatchWriter.init(database);
defer writer.deinit();
try writer.writeBatch(rows);
}
fn ids(rows: []const QueryRow, out: []i64) []const i64 {
for (rows, 0..) |row, i| out[i] = row.id;
return out[0..rows.len];
}
test "selectQueries returns the newest row first and reads every column" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try seed(&database, &.{
.{
.timestamp = 10,
.domain = "ads.example.net",
.client_ip = "192.0.2.10",
.qtype = 28,
.blocked = true,
.block_reason = "blocklist",
.response_time_us = 4200,
.cache_hit = true,
.upstream = "https://dns.example/dns-query",
},
.{
.timestamp = 20,
.domain = "quiet.example",
.client_ip = "hidden",
.qtype = null,
.blocked = false,
.block_reason = null,
.response_time_us = null,
.cache_hit = null,
.upstream = null,
},
});
const rows = try selectQueries(&database, arena_state.allocator(), .{});
try testing.expectEqual(@as(usize, 2), rows.items.len);
const newest = rows.items[0];
try testing.expectEqual(@as(i64, 2), newest.id);
try testing.expectEqual(@as(i64, 20), newest.ts);
try testing.expectEqualStrings("quiet.example", newest.domain);
try testing.expectEqualStrings("hidden", newest.client_ip);
try testing.expectEqual(@as(?u16, null), newest.qtype);
try testing.expect(!newest.blocked);
// A NULL text column reads as the empty string, by documented convention.
try testing.expectEqualStrings("", newest.block_reason);
try testing.expectEqual(@as(?i64, null), newest.response_time_us);
try testing.expectEqual(@as(?bool, null), newest.cache_hit);
try testing.expectEqualStrings("", newest.upstream);
const oldest = rows.items[1];
try testing.expectEqual(@as(i64, 1), oldest.id);
try testing.expectEqual(@as(i64, 10), oldest.ts);
try testing.expectEqualStrings("ads.example.net", oldest.domain);
try testing.expectEqualStrings("192.0.2.10", oldest.client_ip);
try testing.expectEqual(@as(?u16, 28), oldest.qtype);
try testing.expect(oldest.blocked);
try testing.expectEqualStrings("blocklist", oldest.block_reason);
try testing.expectEqual(@as(?i64, 4200), oldest.response_time_us);
try testing.expectEqual(@as(?bool, true), oldest.cache_hit);
try testing.expectEqualStrings("https://dns.example/dns-query", oldest.upstream);
}
test "selectQueries honours the limit and caps it at max_limit" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var rows: [1005]Row = undefined;
for (&rows, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com");
try seed(&database, &rows);
const few = try selectQueries(&database, arena, .{ .limit = 3 });
try testing.expectEqual(@as(usize, 3), few.items.len);
// Asked for more than the cap, and for more rows than the cap, so the cap
// is what bounds the answer rather than the table.
const capped = try selectQueries(&database, arena, .{ .limit = 5000 });
try testing.expectEqual(@as(usize, max_limit), capped.items.len);
const none = try selectQueries(&database, arena, .{ .limit = 0 });
try testing.expectEqual(@as(usize, 0), none.items.len);
}
test "keyset paging walks every row exactly once across the page boundaries" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var seeded: [7]Row = undefined;
for (&seeded, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com");
try seed(&database, &seeded);
var seen: std.ArrayList(i64) = .empty;
defer seen.deinit(testing.allocator);
var before: ?i64 = null;
var pages: usize = 0;
while (pages < 10) : (pages += 1) {
const page = try selectQueries(&database, arena, .{ .limit = 3, .before = before });
if (page.items.len == 0) break;
for (page.items) |row| try seen.append(testing.allocator, row.id);
before = page.items[page.items.len - 1].id;
}
// Two full pages and one short page; the fourth call returns nothing and
// breaks before the counter, which is how the walk knows it is done.
try testing.expectEqual(@as(usize, 3), pages);
try testing.expectEqualSlices(i64, &.{ 7, 6, 5, 4, 3, 2, 1 }, seen.items);
}
test "each filter narrows the result on its own" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var blocked_row = plainRow(200, "ads.example.net");
blocked_row.client_ip = "192.0.2.20";
blocked_row.blocked = true;
blocked_row.block_reason = "blocklist";
try seed(&database, &.{
plainRow(100, "one.example.com"),
blocked_row,
plainRow(300, "two.example.com"),
});
var buf: [8]i64 = undefined;
const by_domain = try selectQueries(&database, arena, .{ .domain_substring = "example.com" });
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(by_domain.items, &buf));
const by_client = try selectQueries(&database, arena, .{ .client = "192.0.2.20" });
try testing.expectEqualSlices(i64, &.{2}, ids(by_client.items, &buf));
// An exact match, not a prefix: the seeded clients share the first octets.
const no_client = try selectQueries(&database, arena, .{ .client = "192.0.2" });
try testing.expectEqual(@as(usize, 0), no_client.items.len);
const only_blocked = try selectQueries(&database, arena, .{ .blocked = true });
try testing.expectEqualSlices(i64, &.{2}, ids(only_blocked.items, &buf));
const only_allowed = try selectQueries(&database, arena, .{ .blocked = false });
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(only_allowed.items, &buf));
// Every filter at once, all satisfied by the one blocked row.
const combined = try selectQueries(&database, arena, .{
.limit = 10,
.before = 3,
.domain_substring = "ads",
.client = "192.0.2.20",
.blocked = true,
.since = 200,
.until = 300,
});
try testing.expectEqualSlices(i64, &.{2}, ids(combined.items, &buf));
}
test "since is inclusive, until is exclusive, and an empty range selects nothing" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try seed(&database, &.{
plainRow(100, "a.example"),
plainRow(200, "b.example"),
plainRow(300, "c.example"),
});
var buf: [8]i64 = undefined;
const window = try selectQueries(&database, arena, .{ .since = 100, .until = 300 });
try testing.expectEqualSlices(i64, &.{ 2, 1 }, ids(window.items, &buf));
const after = try selectQueries(&database, arena, .{ .since = 300 });
try testing.expectEqualSlices(i64, &.{3}, ids(after.items, &buf));
const empty = try selectQueries(&database, arena, .{ .since = 300, .until = 300 });
try testing.expectEqual(@as(usize, 0), empty.items.len);
const beyond = try selectQueries(&database, arena, .{ .since = 1000 });
try testing.expectEqual(@as(usize, 0), beyond.items.len);
}
test "a domain substring matches % and _ as literal characters" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try seed(&database, &.{
plainRow(10, "a_b.example"),
plainRow(20, "axb.example"),
plainRow(30, "a%b.example"),
plainRow(40, "azzb.example"),
plainRow(50, "back\\slash.example"),
});
var buf: [8]i64 = undefined;
// Unescaped, `_` is LIKE's single-character wildcard and would also match
// "axb"; escaped, it matches only the underscore.
const underscore = try selectQueries(&database, arena, .{ .domain_substring = "a_b" });
try testing.expectEqualSlices(i64, &.{1}, ids(underscore.items, &buf));
// Unescaped, `%` would match everything from "a" to "b", so "azzb" too.
const percent = try selectQueries(&database, arena, .{ .domain_substring = "a%b" });
try testing.expectEqualSlices(i64, &.{3}, ids(percent.items, &buf));
// The escape character escapes itself, so it is searchable as well.
const backslash = try selectQueries(&database, arena, .{ .domain_substring = "k\\s" });
try testing.expectEqualSlices(i64, &.{5}, ids(backslash.items, &buf));
// An empty needle is `%%`, which matches every row rather than none.
const all = try selectQueries(&database, arena, .{ .domain_substring = "" });
try testing.expectEqual(@as(usize, 5), all.items.len);
}
test "statsTotals aggregates the window and averages only the timed rows" {
var database = try openLog();
defer database.close();
var timed = plainRow(100, "a.example");
timed.response_time_us = 100;
var blocked_row = plainRow(150, "ads.example");
blocked_row.blocked = true;
blocked_row.block_reason = "blocklist";
blocked_row.response_time_us = 200;
var cached = plainRow(199, "b.example");
cached.client_ip = "192.0.2.99";
cached.cache_hit = true;
cached.response_time_us = null;
try seed(&database, &.{ timed, blocked_row, cached, plainRow(200, "outside.example") });
const totals = try statsTotals(&database, 100, 200);
try testing.expectEqual(@as(u64, 3), totals.queries);
try testing.expectEqual(@as(u64, 1), totals.blocked);
try testing.expectEqual(@as(u64, 1), totals.cached);
try testing.expectEqual(@as(u64, 2), totals.distinct_clients);
// (100 + 200) / 2 — the untimed row is not in the divisor.
try testing.expectEqual(@as(?i64, 150), totals.avg_response_time_us);
}
test "statsTotals over an empty window is zeros with a null average" {
var database = try openLog();
defer database.close();
try seed(&database, &.{plainRow(100, "a.example")});
for ([_][2]i64{ .{ 500, 600 }, .{ 100, 100 } }) |window| {
const totals = try statsTotals(&database, window[0], window[1]);
try testing.expectEqual(@as(u64, 0), totals.queries);
try testing.expectEqual(@as(u64, 0), totals.blocked);
try testing.expectEqual(@as(u64, 0), totals.cached);
try testing.expectEqual(@as(u64, 0), totals.distinct_clients);
try testing.expectEqual(@as(?i64, null), totals.avg_response_time_us);
}
}
test "timeseries writes every bucket, including the ones with no rows" {
var database = try openLog();
defer database.close();
var blocked_row = plainRow(1020, "ads.example");
blocked_row.blocked = true;
blocked_row.block_reason = "blocklist";
var cached = plainRow(1035, "b.example");
cached.cache_hit = true;
try seed(&database, &.{
plainRow(995, "before.example"),
plainRow(1000, "a.example"),
plainRow(1009, "a.example"),
blocked_row,
cached,
plainRow(1040, "after.example"),
});
var buckets: [4]Bucket = undefined;
try testing.expectEqual(@as(usize, 4), try timeseries(&database, 1000, 10, &buckets));
// The row at 995 is before the window and the row at 1040 is past its end;
// neither lands in a bucket.
try testing.expectEqualSlices(Bucket, &.{
.{ .ts = 1000, .queries = 2, .blocked = 0, .cached = 0 },
.{ .ts = 1010, .queries = 0, .blocked = 0, .cached = 0 },
.{ .ts = 1020, .queries = 1, .blocked = 1, .cached = 0 },
.{ .ts = 1030, .queries = 1, .blocked = 0, .cached = 1 },
}, &buckets);
}
test "timeseries over an empty table still writes the whole axis" {
var database = try openLog();
defer database.close();
var buckets: [3]Bucket = undefined;
try testing.expectEqual(@as(usize, 3), try timeseries(&database, 0, 60, &buckets));
try testing.expectEqualSlices(Bucket, &.{
.{ .ts = 0, .queries = 0, .blocked = 0, .cached = 0 },
.{ .ts = 60, .queries = 0, .blocked = 0, .cached = 0 },
.{ .ts = 120, .queries = 0, .blocked = 0, .cached = 0 },
}, &buckets);
}
test "timeseries rejects a zero-width bucket and accepts an empty slice" {
var database = try openLog();
defer database.close();
var buckets: [2]Bucket = undefined;
try testing.expectError(error.Misuse, timeseries(&database, 0, 0, &buckets));
var none: [0]Bucket = undefined;
try testing.expectEqual(@as(usize, 0), try timeseries(&database, 0, 0, &none));
}
test "timeseries reports a window that does not fit an i64 rather than wrapping" {
var database = try openLog();
defer database.close();
var buckets: [4]Bucket = undefined;
try testing.expectError(
error.Misuse,
timeseries(&database, std.math.maxInt(i64) - 1, 3600, &buckets),
);
}
test "the built SQL never carries a filter value and fits its buffer" {
var sql: Sql = .{};
sql.put(select_head);
sql.predicate(where_before);
sql.predicate(where_domain);
sql.predicate(where_client);
sql.predicate(where_blocked);
sql.predicate(where_since);
sql.predicate(where_until);
sql.put(select_tail);
// Every predicate present is the longest reachable statement.
try testing.expect(sql.len <= Sql.capacity);
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE"));
try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND"));
// Six filters plus the LIMIT, each a bare parameter.
try testing.expectEqual(@as(usize, 7), std.mem.count(u8, sql.text(), "?"));
}
test "likePattern wraps the needle and neutralises every metacharacter" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectEqualStrings("%plain%", try likePattern(arena, "plain"));
try testing.expectEqualStrings("%a\\_b%", try likePattern(arena, "a_b"));
try testing.expectEqualStrings("%a\\%b%", try likePattern(arena, "a%b"));
try testing.expectEqualStrings("%a\\\\b%", try likePattern(arena, "a\\b"));
try testing.expectEqualStrings("%%", try likePattern(arena, ""));
}
+240 -1
View File
@@ -12,7 +12,9 @@
//! order as a whole is: `import` inserts the rules in export order, so the new
//! ids ascend in exactly the order this statement produced.
//!
//! Only list / insert / deleteAll / count exist.
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
//! is the second half of this file: it speaks row ids, because that is what an
//! `/api/rules/{id}` request names.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -22,6 +24,7 @@ const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const groups_repo = @import("groups_repo.zig");
const crud = @import("crud.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
@@ -89,6 +92,145 @@ pub fn countRules(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM rules");
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// The read shape carries both `group_id` and the group name: the UI groups the
// rules it lists, and the client that edits one sends an id back. The write
// shape carries only the id, so a group that does not exist surfaces as the
// foreign-key violation it is instead of a lookup miss.
pub const RuleRow = struct {
id: i64,
group_id: i64,
group: []const u8,
pattern: []const u8,
kind: model.RuleKind,
action: model.RuleAction,
created_at: i64,
};
pub const RuleInput = struct {
group_id: i64,
pattern: []const u8,
kind: model.RuleKind,
action: model.RuleAction,
};
const list_rule_rows_sql =
\\SELECT r.id, r.group_id, g.name, r.pattern, r.kind, r.action, r.created_at FROM rules r
\\ JOIN groups g ON g.id = r.group_id
\\ ORDER BY g.name, r.kind, r.action, r.pattern, r.id
;
const get_rule_sql =
\\SELECT r.id, r.group_id, g.name, r.pattern, r.kind, r.action, r.created_at FROM rules r
\\ JOIN groups g ON g.id = r.group_id
\\ WHERE r.id = ?1
;
/// Same order as `listRules`; every string is a heap copy owned by `gpa`.
pub fn listRuleRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(RuleRow) {
var stmt = try database.prepare(list_rule_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(RuleRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeRuleRows(gpa, out.items);
while (try stmt.step()) {
const row = try readRuleRow(&stmt, gpa);
errdefer freeRuleRow(gpa, row);
try out.append(gpa, row);
}
return out;
}
pub fn freeRuleRow(gpa: Allocator, row: RuleRow) void {
gpa.free(row.group);
gpa.free(row.pattern);
}
pub fn freeRuleRows(gpa: Allocator, items: []const RuleRow) void {
for (items) |item| freeRuleRow(gpa, item);
}
pub fn getRule(database: *db.Db, gpa: Allocator, id: i64) db.Error!?RuleRow {
var stmt = try database.prepare(get_rule_sql);
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return try readRuleRow(&stmt, gpa);
}
fn readRuleRow(stmt: *db.Stmt, gpa: Allocator) db.Error!RuleRow {
// The DDL's CHECK constraints make both decodes total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const kind = model.RuleKind.fromDb(stmt.columnText(4)) orelse return error.Mismatch;
const action = model.RuleAction.fromDb(stmt.columnText(5)) orelse return error.Mismatch;
const group = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(group);
const pattern = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(pattern);
return .{
.id = stmt.columnInt(0),
.group_id = stmt.columnInt(1),
.group = group,
.pattern = pattern,
.kind = kind,
.action = action,
.created_at = stmt.columnInt(6),
};
}
/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it becomes
/// `created_at`, the column that dates a rule for the operator.
///
/// `error.Constraint`: `group_id` names no group. `rules` has no UNIQUE
/// constraint, so a rule identical to one already stored is accepted — the
/// table has always allowed that.
pub fn insertRuleRow(database: *db.Db, item: RuleInput, now_s: i64) db.Error!i64 {
var stmt = try database.prepare(insert_sql);
defer stmt.deinit();
try stmt.bindInt(1, item.group_id);
try stmt.bindText(2, item.pattern);
try stmt.bindText(3, item.kind.toDb());
try stmt.bindText(4, item.action.toDb());
try stmt.bindInt(5, now_s);
try stmt.exec();
return database.lastInsertRowid();
}
const update_rule_sql =
\\UPDATE rules SET group_id = ?2, pattern = ?3, kind = ?4, action = ?5 WHERE id = ?1
;
/// `created_at` is when the rule was written, not when it was last touched, so
/// an edit leaves it alone.
///
/// `error.NotFound`: no rule holds `id`. `error.Constraint`: `group_id` names no
/// group.
pub fn updateRule(database: *db.Db, id: i64, item: RuleInput) db.Error!void {
var stmt = try database.prepare(update_rule_sql);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindInt(2, item.group_id);
try stmt.bindText(3, item.pattern);
try stmt.bindText(4, item.kind.toDb());
try stmt.bindText(5, item.action.toDb());
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no rule holds `id`. Nothing references `rules`, so a delete
/// cannot violate a constraint.
pub fn deleteRule(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM rules WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -292,3 +434,100 @@ test "listRules is leak-safe under allocation failure" {
defer ids.deinit(testing.allocator);
try testing.checkAllAllocationFailures(testing.allocator, listRulesUnderFailure, .{&ids});
}
// --- REST surface ----------------------------------------------------------
test "a rule round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
const kids = try groups_repo.insertGroupRow(&database, .{ .name = "kids" });
const id = try insertRuleRow(&database, .{
.group_id = 1,
.pattern = "tracker.example",
.kind = .exact,
.action = .block,
}, 1700000000);
const fetched = (try getRule(&database, testing.allocator, id)).?;
defer freeRuleRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqual(@as(i64, 1), fetched.group_id);
try testing.expectEqualStrings("default", fetched.group);
try testing.expectEqualStrings("tracker.example", fetched.pattern);
try testing.expectEqual(model.RuleKind.exact, fetched.kind);
try testing.expectEqual(model.RuleAction.block, fetched.action);
try testing.expectEqual(@as(i64, 1700000000), fetched.created_at);
try updateRule(&database, id, .{
.group_id = kids,
.pattern = "*.ads.example",
.kind = .wildcard,
.action = .allow,
});
const updated = (try getRule(&database, testing.allocator, id)).?;
defer freeRuleRow(testing.allocator, updated);
try testing.expectEqual(kids, updated.group_id);
try testing.expectEqualStrings("kids", updated.group);
try testing.expectEqualStrings("*.ads.example", updated.pattern);
try testing.expectEqual(model.RuleKind.wildcard, updated.kind);
try testing.expectEqual(model.RuleAction.allow, updated.action);
// An edit is not a creation, so the date stands.
try testing.expectEqual(@as(i64, 1700000000), updated.created_at);
var rows = try listRuleRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeRuleRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqual(id, rows.items[0].id);
try deleteRule(&database, id);
try testing.expectEqual(@as(?RuleRow, null), try getRule(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 0), try countRules(&database));
}
test "rule update and delete report NotFound for an id no row holds" {
var database = try openMigrated();
defer database.close();
const item: RuleInput = .{ .group_id = 1, .pattern = "x.example", .kind = .exact, .action = .block };
try testing.expectError(error.NotFound, updateRule(&database, 404, item));
try testing.expectError(error.NotFound, deleteRule(&database, 404));
try testing.expectEqual(@as(?RuleRow, null), try getRule(&database, testing.allocator, 404));
}
test "a rule in a group that does not exist surfaces as error.Constraint" {
var database = try openMigrated();
defer database.close();
const item: RuleInput = .{ .group_id = 404, .pattern = "x.example", .kind = .exact, .action = .block };
try testing.expectError(error.Constraint, insertRuleRow(&database, item, 1));
const id = try insertRuleRow(&database, .{
.group_id = 1,
.pattern = "x.example",
.kind = .exact,
.action = .block,
}, 1);
try testing.expectError(error.Constraint, updateRule(&database, id, item));
try testing.expectEqual(@as(i64, 1), try countRules(&database));
}
fn ruleRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try seedRules(&database, ids);
var rows = try listRuleRows(&database, gpa);
defer rows.deinit(gpa);
defer freeRuleRows(gpa, rows.items);
const one = (try getRule(&database, gpa, rows.items[0].id)).?;
defer freeRuleRow(gpa, one);
}
test "the rule read surface is leak-safe under allocation failure" {
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try testing.checkAllAllocationFailures(testing.allocator, ruleRowsUnderFailure, .{&ids});
}
+67 -1
View File
@@ -4,7 +4,9 @@
//! `model.fromSettings` speak, so the scalar sections cross the storage boundary
//! without a second shape.
//!
//! Only list / insert / deleteAll / count exist.
//! The import path is list / insert / deleteAll / count; `putSetting` is Phase
//! 8's single-key write. `settings` has no row ids — the key is the identity —
//! so it gains no by-id surface.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -64,6 +66,32 @@ pub fn countSettings(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM settings");
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
const put_setting_sql =
\\INSERT INTO settings (key, value) VALUES (?1, ?2)
\\ON CONFLICT(key) DO UPDATE SET value = excluded.value
;
/// Writes one key, whether or not it is already stored.
///
/// `PUT /api/settings` is a partial update over a table whose rows the import
/// path writes once and never revisits, so a plain `INSERT` would fail on every
/// key the config already carries and a plain `UPDATE` would drop every key it
/// does not. The conflict target is `settings.key`, the table's PRIMARY KEY.
///
/// No constraint can fire: the table has one key column and one `NOT NULL`
/// value, and both are bound.
pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error!void {
var stmt = try database.prepare(put_setting_sql);
defer stmt.deinit();
try stmt.bindText(1, key);
try stmt.bindText(2, value);
try stmt.exec();
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -143,3 +171,41 @@ fn listSettingsUnderFailure(gpa: Allocator) !void {
test "listSettings is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listSettingsUnderFailure, .{});
}
// --- REST surface ----------------------------------------------------------
test "putSetting writes a key that is absent and overwrites one that is present" {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
try putSetting(&database, "web.port", "9090");
try putSetting(&database, "cache.max_entries", "20000");
var items = try listSettings(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeSettings(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 4), items.items.len);
try testing.expectEqualStrings("cache.max_entries", items.items[0].key);
try testing.expectEqualStrings("20000", items.items[0].value);
try testing.expectEqualStrings("web.port", items.items[3].key);
try testing.expectEqualStrings("9090", items.items[3].value);
// The keys it did not name are untouched.
try testing.expectEqualStrings("dns.port", items.items[1].key);
try testing.expectEqualStrings("53", items.items[1].value);
}
test "putSetting is idempotent and leaves the row count alone" {
var database = try openMigrated();
defer database.close();
try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA");
try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA");
try testing.expectEqual(@as(i64, 1), try countSettings(&database));
var items = try listSettings(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeSettings(testing.allocator, items.items);
try testing.expectEqualStrings("$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA", items.items[0].value);
}
+231 -26
View File
@@ -5,7 +5,8 @@
//! facts a running server produces; an insert leaves them at their column
//! defaults so two exports taken minutes apart stay identical.
//!
//! Only list / insert / deleteAll / count exist.
//! The import path is list / insert / deleteAll / count; the runtime columns and
//! Phase 8's REST surface follow it, both keyed by row id.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -14,6 +15,7 @@ const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");
const InsertContext = context.InsertContext;
@@ -91,6 +93,9 @@ pub const SourceRow = struct {
url: []const u8,
name: []const u8,
enabled: bool,
/// Defaulted because the blocklist manager builds `SourceRow` values from
/// the refresh columns alone; the REST layer is what reads this one.
is_suggested: bool = false,
last_updated: ?i64,
domain_count: i64,
wildcard_count: i64,
@@ -107,12 +112,15 @@ pub const SourceStats = struct {
checksum: []const u8,
};
const list_rows_sql =
const row_columns_sql =
\\SELECT id, url, name, enabled, last_updated,
\\ domain_count, wildcard_count, skipped_regex_count, checksum
\\ FROM blocklist_sources ORDER BY url
\\ domain_count, wildcard_count, skipped_regex_count, checksum,
\\ is_suggested
\\ FROM blocklist_sources
;
const list_rows_sql = row_columns_sql ++ " ORDER BY url";
/// Every source with its row id and its runtime columns, in the same `url`
/// order `listBlocklistSources` uses. Every string is a heap copy owned by
/// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list.
@@ -127,33 +135,42 @@ pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(S
errdefer freeSourceRows(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(name);
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
errdefer if (checksum) |value| gpa.free(value);
try out.append(gpa, .{
.id = stmt.columnInt(0),
.url = url,
.name = name,
.enabled = stmt.columnBool(3),
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
.domain_count = stmt.columnInt(5),
.wildcard_count = stmt.columnInt(6),
.skipped_regex_count = stmt.columnInt(7),
.checksum = checksum,
});
const row = try readSourceRow(&stmt, gpa);
errdefer freeSourceRow(gpa, row);
try out.append(gpa, row);
}
return out;
}
fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
const url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(name);
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
errdefer if (checksum) |value| gpa.free(value);
return .{
.id = stmt.columnInt(0),
.url = url,
.name = name,
.enabled = stmt.columnBool(3),
.is_suggested = stmt.columnBool(9),
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
.domain_count = stmt.columnInt(5),
.wildcard_count = stmt.columnInt(6),
.skipped_regex_count = stmt.columnInt(7),
.checksum = checksum,
};
}
pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void {
gpa.free(row.url);
gpa.free(row.name);
if (row.checksum) |value| gpa.free(value);
}
pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
for (items) |item| {
gpa.free(item.url);
gpa.free(item.name);
if (item.checksum) |value| gpa.free(value);
}
for (items) |item| freeSourceRow(gpa, item);
}
const update_stats_sql =
@@ -177,6 +194,71 @@ pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error
try stmt.exec();
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// `/api/blocklists` is this table. The read shape is `SourceRow` above — the
// UI wants the counters next to the configuration — and the write shape is
// `model.BlocklistSource`, whose four fields are the four columns an operator
// may set.
pub fn getSource(database: *db.Db, gpa: Allocator, id: i64) db.Error!?SourceRow {
var stmt = try database.prepare(row_columns_sql ++ " WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return try readSourceRow(&stmt, gpa);
}
/// The runtime columns stay at their defaults, so a source added through the
/// API looks exactly like an imported one until the first refresh.
///
/// `error.Constraint`: `blocklist_sources.url` is UNIQUE.
pub fn insertSourceRow(database: *db.Db, item: model.BlocklistSource) db.Error!i64 {
var stmt = try database.prepare(insert_sql);
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindText(2, item.name);
try stmt.bindBool(3, item.enabled);
try stmt.bindBool(4, item.is_suggested);
try stmt.exec();
return database.lastInsertRowid();
}
const update_source_sql =
\\UPDATE blocklist_sources
\\ SET url = ?2, name = ?3, enabled = ?4, is_suggested = ?5
\\ WHERE id = ?1
;
/// Writes the four configuration columns. The runtime columns are the refresh
/// path's and stay as they are — even when `url` changes, because the next
/// refresh compares checksums and replaces them anyway.
///
/// `error.NotFound`: no source holds `id`. `error.Constraint`:
/// `blocklist_sources.url` is UNIQUE.
pub fn updateSource(database: *db.Db, id: i64, item: model.BlocklistSource) db.Error!void {
var stmt = try database.prepare(update_source_sql);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.url);
try stmt.bindText(3, item.name);
try stmt.bindBool(4, item.enabled);
try stmt.bindBool(5, item.is_suggested);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no source holds `id`. `group_sources` references it
/// `ON DELETE CASCADE`, so every group's assignment loses it silently and no
/// constraint can fire.
pub fn deleteSource(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM blocklist_sources WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -361,3 +443,126 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
test "listSourceRows is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listSourceRowsUnderFailure, .{});
}
// --- REST surface ----------------------------------------------------------
test "a blocklist source round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
const id = try insertSourceRow(&database, .{
.url = "https://lists.example/hosts.txt",
.name = "Example",
.is_suggested = true,
});
const fetched = (try getSource(&database, testing.allocator, id)).?;
defer freeSourceRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqualStrings("https://lists.example/hosts.txt", fetched.url);
try testing.expectEqualStrings("Example", fetched.name);
try testing.expect(fetched.enabled);
try testing.expect(fetched.is_suggested);
try testing.expectEqual(@as(?i64, null), fetched.last_updated);
try updateSource(&database, id, .{
.url = "https://lists.example/hosts.txt",
.name = "Example list",
.enabled = false,
});
const updated = (try getSource(&database, testing.allocator, id)).?;
defer freeSourceRow(testing.allocator, updated);
try testing.expectEqualStrings("Example list", updated.name);
try testing.expect(!updated.enabled);
try testing.expect(!updated.is_suggested);
try deleteSource(&database, id);
try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
}
test "updateSource leaves the runtime columns where the refresh path left them" {
var database = try openMigrated();
defer database.close();
const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
try updateSourceStats(&database, id, .{
.last_updated = 1_700_000_000,
.domain_count = 12,
.wildcard_count = 3,
.skipped_regex_count = 1,
.checksum = "c" ** 64,
});
try updateSource(&database, id, .{ .url = "https://lists.example/b.txt", .name = "B" });
const row = (try getSource(&database, testing.allocator, id)).?;
defer freeSourceRow(testing.allocator, row);
try testing.expectEqualStrings("https://lists.example/b.txt", row.url);
try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated);
try testing.expectEqual(@as(i64, 12), row.domain_count);
try testing.expectEqualStrings("c" ** 64, row.checksum.?);
}
test "source update and delete report NotFound for an id no row holds" {
var database = try openMigrated();
defer database.close();
try testing.expectError(
error.NotFound,
updateSource(&database, 404, .{ .url = "https://lists.example/a.txt", .name = "A" }),
);
try testing.expectError(error.NotFound, deleteSource(&database, 404));
try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, 404));
}
test "a duplicate source url surfaces as error.Constraint on insert and on update" {
var database = try openMigrated();
defer database.close();
_ = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
const other = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });
try testing.expectError(
error.Constraint,
insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "again" }),
);
try testing.expectError(
error.Constraint,
updateSource(&database, other, .{ .url = "https://lists.example/a.txt", .name = "B" }),
);
try testing.expectEqual(@as(i64, 2), try countBlocklistSources(&database));
}
test "deleting a source drops it from every group assignment" {
var database = try openMigrated();
defer database.close();
const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
const keep = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });
try database.exec("INSERT INTO group_sources (group_id, source_id) VALUES (1, 1), (1, 2);");
try deleteSource(&database, id);
try testing.expectEqual(@as(i64, 1), try countGroupSources(&database));
try testing.expectEqual(
keep,
try database.queryInt("SELECT source_id FROM group_sources"),
);
}
fn countGroupSources(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM group_sources");
}
fn sourceRowUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
const one = (try getSource(&database, gpa, 1)).?;
defer freeSourceRow(gpa, one);
}
test "getSource is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, sourceRowUnderFailure, .{});
}
+202 -1
View File
@@ -4,7 +4,9 @@
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks
//! ties uniquely, which is what makes an export byte-stable.
//!
//! Only list / insert / deleteAll / count exist.
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
//! is the second half of this file: it speaks row ids, because that is what an
//! `/api/upstreams/{id}` request names.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -13,6 +15,7 @@ const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");
const InsertContext = context.InsertContext;
@@ -73,6 +76,118 @@ pub fn countUpstreams(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM upstreams");
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// The write shape is `model.UpstreamServer`: its four fields are exactly the
// columns of the table, so the REST layer needs no second shape for them.
pub const UpstreamRow = struct {
id: i64,
url: []const u8,
priority: i32,
enabled: bool,
tls_name: []const u8,
};
const list_upstream_rows_sql =
\\SELECT id, url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url
;
const get_upstream_sql =
\\SELECT id, url, priority, enabled, tls_name FROM upstreams WHERE id = ?1
;
/// Same order as `listUpstreams`; every string is a heap copy owned by `gpa`.
pub fn listUpstreamRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(UpstreamRow) {
var stmt = try database.prepare(list_upstream_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(UpstreamRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeUpstreamRows(gpa, out.items);
while (try stmt.step()) {
const row = try readUpstreamRow(&stmt, gpa);
errdefer freeUpstreamRow(gpa, row);
try out.append(gpa, row);
}
return out;
}
pub fn freeUpstreamRow(gpa: Allocator, row: UpstreamRow) void {
gpa.free(row.url);
gpa.free(row.tls_name);
}
pub fn freeUpstreamRows(gpa: Allocator, items: []const UpstreamRow) void {
for (items) |item| freeUpstreamRow(gpa, item);
}
pub fn getUpstream(database: *db.Db, gpa: Allocator, id: i64) db.Error!?UpstreamRow {
var stmt = try database.prepare(get_upstream_sql);
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return try readUpstreamRow(&stmt, gpa);
}
fn readUpstreamRow(stmt: *db.Stmt, gpa: Allocator) db.Error!UpstreamRow {
const url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(url);
const tls_name = try stmt.columnTextAlloc(gpa, 4);
errdefer gpa.free(tls_name);
// The column is a 64-bit integer; the row field is `i32`. A value outside
// that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
return .{
.id = stmt.columnInt(0),
.url = url,
.priority = priority,
.enabled = stmt.columnBool(3),
.tls_name = tls_name,
};
}
/// `error.Constraint`: `upstreams.url` is UNIQUE.
pub fn insertUpstreamRow(database: *db.Db, item: model.UpstreamServer) db.Error!i64 {
var stmt = try database.prepare(
"INSERT INTO upstreams (url, priority, enabled, tls_name) VALUES (?1, ?2, ?3, ?4)",
);
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindInt(2, item.priority);
try stmt.bindBool(3, item.enabled);
try stmt.bindText(4, item.tls_name);
try stmt.exec();
return database.lastInsertRowid();
}
/// `error.NotFound`: no upstream holds `id`. `error.Constraint`:
/// `upstreams.url` is UNIQUE.
pub fn updateUpstream(database: *db.Db, id: i64, item: model.UpstreamServer) db.Error!void {
var stmt = try database.prepare(
"UPDATE upstreams SET url = ?2, priority = ?3, enabled = ?4, tls_name = ?5 WHERE id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.url);
try stmt.bindInt(3, item.priority);
try stmt.bindBool(4, item.enabled);
try stmt.bindText(5, item.tls_name);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no upstream holds `id`. Nothing references `upstreams`, so
/// a delete cannot violate a constraint.
pub fn deleteUpstream(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM upstreams WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -151,3 +266,89 @@ fn listUpstreamsUnderFailure(gpa: Allocator) !void {
test "listUpstreams is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listUpstreamsUnderFailure, .{});
}
// --- REST surface ----------------------------------------------------------
test "an upstream round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
const id = try insertUpstreamRow(&database, .{
.url = "tls://1.1.1.1:853",
.priority = 10,
.tls_name = "one.one.one.one",
});
const fetched = (try getUpstream(&database, testing.allocator, id)).?;
defer freeUpstreamRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqualStrings("tls://1.1.1.1:853", fetched.url);
try testing.expectEqual(@as(i32, 10), fetched.priority);
try testing.expect(fetched.enabled);
try testing.expectEqualStrings("one.one.one.one", fetched.tls_name);
const second = try insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53", .priority = 20 });
var rows = try listUpstreamRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeUpstreamRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqual(id, rows.items[0].id);
try testing.expectEqual(second, rows.items[1].id);
try testing.expectEqualStrings("", rows.items[1].tls_name);
try updateUpstream(&database, id, .{
.url = "tls://1.0.0.1:853",
.priority = 5,
.enabled = false,
.tls_name = "",
});
const updated = (try getUpstream(&database, testing.allocator, id)).?;
defer freeUpstreamRow(testing.allocator, updated);
try testing.expectEqualStrings("tls://1.0.0.1:853", updated.url);
try testing.expectEqual(@as(i32, 5), updated.priority);
try testing.expect(!updated.enabled);
try testing.expectEqualStrings("", updated.tls_name);
try deleteUpstream(&database, id);
try testing.expectEqual(@as(?UpstreamRow, null), try getUpstream(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 1), try countUpstreams(&database));
}
test "upstream update and delete report NotFound for an id no row holds" {
var database = try openMigrated();
defer database.close();
try testing.expectError(error.NotFound, updateUpstream(&database, 404, .{ .url = "udp://9.9.9.9:53" }));
try testing.expectError(error.NotFound, deleteUpstream(&database, 404));
try testing.expectEqual(@as(?UpstreamRow, null), try getUpstream(&database, testing.allocator, 404));
}
test "a duplicate upstream url surfaces as error.Constraint on insert and on update" {
var database = try openMigrated();
defer database.close();
_ = try insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53" });
const other = try insertUpstreamRow(&database, .{ .url = "udp://1.1.1.1:53" });
try testing.expectError(error.Constraint, insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53" }));
try testing.expectError(error.Constraint, updateUpstream(&database, other, .{ .url = "udp://9.9.9.9:53" }));
try testing.expectEqual(@as(i64, 2), try countUpstreams(&database));
}
fn upstreamRowsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
var rows = try listUpstreamRows(&database, gpa);
defer rows.deinit(gpa);
defer freeUpstreamRows(gpa, rows.items);
const one = (try getUpstream(&database, gpa, rows.items[0].id)).?;
defer freeUpstreamRow(gpa, one);
}
test "the upstream read surface is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, upstreamRowsUnderFailure, .{});
}
+57 -27
View File
@@ -26,6 +26,7 @@ pub const vacuum_every_passes = 7;
/// expresses, so a finer schedule would prune nothing new.
pub const pass_interval_s = 86_400;
/// A consistent copy of the counters, for `/metrics` and the health rollup.
pub const Stats = struct {
passes: u64 = 0,
rows_pruned: u64 = 0,
@@ -33,12 +34,34 @@ pub const Stats = struct {
vacuums: u64 = 0,
};
/// The live counters. Atomic because the retention task writes them and the web
/// task reads them, on different threads, with no lock between the two — the
/// same shape the query logger uses for its own counters.
const Counters = struct {
passes: std.atomic.Value(u64) = .init(0),
rows_pruned: std.atomic.Value(u64) = .init(0),
checkpoints: std.atomic.Value(u64) = .init(0),
vacuums: std.atomic.Value(u64) = .init(0),
};
pub const Retention = struct {
cfg: model.Logging,
stats: Stats,
counters: Counters,
pub fn init(cfg: model.Logging) Retention {
return .{ .cfg = cfg, .stats = .{} };
return .{ .cfg = cfg, .counters = .{} };
}
/// The four counters, read one at a time. A scrape that lands mid-pass can
/// see a pass counted before the rows it pruned are; the alternative is a
/// lock on the pass itself, which buys a consistency no consumer needs.
pub fn snapshotStats(self: *const Retention) Stats {
return .{
.passes = self.counters.passes.load(.monotonic),
.rows_pruned = self.counters.rows_pruned.load(.monotonic),
.checkpoints = self.counters.checkpoints.load(.monotonic),
.vacuums = self.counters.vacuums.load(.monotonic),
};
}
/// One pass: prune, checkpoint, and on every seventh pass vacuum.
@@ -53,29 +76,36 @@ pub const Retention = struct {
///
/// `database` must be a connection no other task uses; see `run`.
pub fn runOnce(self: *Retention, io: std.Io, database: *db.Db) void {
self.stats.passes += 1;
const pass = add(&self.counters.passes, 1) + 1;
const cutoff = std.Io.Clock.real.now(io).toSeconds() - model.retentionSeconds(self.cfg);
if (queries_repo.pruneOlderThan(database, cutoff)) |deleted| {
self.stats.rows_pruned += @intCast(deleted);
_ = add(&self.counters.rows_pruned, @intCast(deleted));
} else |err| {
log.warn("retention prune before {d} failed: {s}", .{ cutoff, @errorName(err) });
}
if (queries_repo.checkpointTruncate(database)) {
self.stats.checkpoints += 1;
_ = add(&self.counters.checkpoints, 1);
} else |err| {
log.warn("retention checkpoint failed: {s}", .{@errorName(err)});
}
if (self.stats.passes % vacuum_every_passes != 0) return;
if (pass % vacuum_every_passes != 0) return;
if (queries_repo.vacuum(database)) {
self.stats.vacuums += 1;
_ = add(&self.counters.vacuums, 1);
} else |err| {
log.warn("retention vacuum failed: {s}", .{@errorName(err)});
}
}
/// Returns the value before the addition, which is what the pass counter
/// needs: only this task increments it, so `previous + 1` is this pass's
/// number.
fn add(counter: *std.atomic.Value(u64), delta: u64) u64 {
return counter.fetchAdd(delta, .monotonic);
}
/// Daily loop, first pass immediately. Phase 7 starts it.
///
/// `boot` rather than `awake`: a box that suspends overnight must still see
@@ -159,10 +189,10 @@ test "a pass prunes the rows past the retention window and keeps the rest" {
retention.runOnce(io, &database);
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned);
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
try testing.expectEqual(@as(u64, 0), retention.stats.vacuums);
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned);
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
}
test "the cutoff follows retention_days" {
@@ -182,12 +212,12 @@ test "the cutoff follows retention_days" {
var keeps: Retention = .init(.{ .retention_days = 7 });
keeps.runOnce(io, &database);
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 0), keeps.stats.rows_pruned);
try testing.expectEqual(@as(u64, 0), keeps.snapshotStats().rows_pruned);
var prunes: Retention = .init(.{ .retention_days = 1 });
prunes.runOnce(io, &database);
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 1), prunes.stats.rows_pruned);
try testing.expectEqual(@as(u64, 1), prunes.snapshotStats().rows_pruned);
}
test "the seventh pass vacuums and the six before it do not" {
@@ -201,17 +231,17 @@ test "the seventh pass vacuums and the six before it do not" {
var retention: Retention = .init(.{});
for (0..6) |_| {
retention.runOnce(io, &database);
try testing.expectEqual(@as(u64, 0), retention.stats.vacuums);
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
}
retention.runOnce(io, &database);
try testing.expectEqual(@as(u64, 7), retention.stats.passes);
try testing.expectEqual(@as(u64, 1), retention.stats.vacuums);
try testing.expectEqual(@as(u64, 7), retention.stats.checkpoints);
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().passes);
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().checkpoints);
for (0..7) |_| retention.runOnce(io, &database);
try testing.expectEqual(@as(u64, 14), retention.stats.passes);
try testing.expectEqual(@as(u64, 2), retention.stats.vacuums);
try testing.expectEqual(@as(u64, 14), retention.snapshotStats().passes);
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().vacuums);
}
test "a pass over an empty database still counts" {
@@ -225,9 +255,9 @@ test "a pass over an empty database still counts" {
var retention: Retention = .init(.{});
retention.runOnce(io, &database);
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned);
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned);
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
}
@@ -250,10 +280,10 @@ test "a failing prune counts the pass and leaves the rows alone" {
retention.runOnce(io, &database);
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned);
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned);
// The checkpoint runs whether or not the prune did.
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
}
test "the next pass retries what the failed one could not do" {
@@ -279,6 +309,6 @@ test "the next pass retries what the failed one could not do" {
retention.runOnce(io, &database);
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 2), retention.stats.passes);
try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned);
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().passes);
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned);
}