//! `clients` and `client_prefixes`. //! //! `listClients` returns only `hand_edited = 1` rows. A client the server //! materialised from live traffic is runtime state, not configuration, and must //! not appear in an export. `hand_edited` is the only marker of operator intent //! in this table, so it also decides what the reconcile engine may delete: a //! declared row the file drops is removed, an observed row is kept whatever the //! file says, and declaring an observed address promotes that row in place. //! `countClients` counts **all** rows and is a test helper. //! //! The configuration path is list / insert / update / delete / count, plus the //! two runtime calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s //! tracker owns. //! The 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; 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; // --------------------------------------------------------------------------- // clients // --------------------------------------------------------------------------- const list_clients_sql = \\SELECT c.ip, c.name, g.name FROM clients c \\ JOIN groups g ON g.id = c.group_id \\ WHERE c.hand_edited = 1 \\ ORDER BY c.ip ; /// Every string in the result is a heap copy owned by `gpa`. pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) { return crud.listRows(model.Client, database, gpa, list_clients_sql, readClient); } fn readClient(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Client { const ip = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(ip); // `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which is // exactly the model's default. const name = try stmt.columnTextAlloc(gpa, 1); errdefer gpa.free(name); const group = try stmt.columnTextAlloc(gpa, 2); errdefer gpa.free(group); return .{ .ip = ip, .name = name, .group = group }; } pub fn freeClients(gpa: Allocator, items: []const model.Client) void { crud.freeRows(model.Client, gpa, items); } const insert_client_sql = \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) \\VALUES (?1, ?2, ?3, 1, ?4, ?4) ; /// `hand_edited` is 1: a client that reached a repository through the config /// model came from an operator's file, by definition. pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void { const group_id = try ctx.groupId(item.group); var stmt = try database.prepare(insert_client_sql); defer stmt.deinit(); try stmt.bindText(1, item.ip); try stmt.bindText(2, item.name); try stmt.bindInt(3, group_id); try stmt.bindInt(4, ctx.now); try stmt.exec(); } const upsert_seen_sql = \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) \\VALUES (?1, NULL, (SELECT id FROM groups WHERE name = 'default'), 0, ?2, ?2) \\ON CONFLICT(ip) DO UPDATE SET last_seen = excluded.last_seen ; /// Materialises a client seen in live traffic (PLAN §7.2), or touches /// `last_seen` on the row that already holds `ip`. /// /// The conflict target is `clients.ip`, which the schema declares UNIQUE. Only /// `last_seen` is updated: `name`, `group_id` and `hand_edited` are the /// operator's, and a device that keeps querying must not overwrite them. A /// hand-edited row is touched too, so the operator sees liveness for the /// clients they named. /// /// A new row lands in the `default` group. `groupForClient` resolves the real /// group from the prefix rules at query time, so the column here only decides /// what the operator sees before they assign the device themselves. /// /// A database with no group named `default` fails the insert with /// `error.Constraint` rather than writing a dangling row. `validate.zig` /// rejects such a configuration long before a server serves from it. pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void { var stmt = try database.prepare(upsert_seen_sql); defer stmt.deinit(); try stmt.bindText(1, ip); try stmt.bindInt(2, now_s); try stmt.exec(); } /// Deletes the auto-materialised clients whose last query predates `cutoff_s`, /// and returns how many rows went. `hand_edited = 1` rows are configuration and /// survive any silence. /// /// `last_seen` is the only signal, because §3.6 forbids joining `config.db` /// against the query log. pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32 { var stmt = try database.prepare("DELETE FROM clients WHERE hand_edited = 0 AND last_seen < ?1"); defer stmt.deinit(); try stmt.bindInt(1, cutoff_s); try stmt.exec(); const deleted = database.changes(); return @intCast(@min(deleted, std.math.maxInt(u32))); } pub fn deleteAllClients(database: *db.Db) db.Error!void { return database.exec("DELETE FROM clients;"); } /// Counts every row, including the materialised ones `listClients` filters out. /// Used by tests. pub fn countClients(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM clients"); } // --------------------------------------------------------------------------- // client_prefixes // --------------------------------------------------------------------------- const list_client_prefixes_sql = \\SELECT p.prefix, g.name, p.priority FROM client_prefixes p \\ JOIN groups g ON g.id = p.group_id \\ ORDER BY p.prefix ; pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) { return crud.listRows(model.ClientPrefix, database, gpa, list_client_prefixes_sql, readClientPrefix); } fn readClientPrefix(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ClientPrefix { const prefix = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(prefix); const group = try stmt.columnTextAlloc(gpa, 1); errdefer gpa.free(group); // The column is a 64-bit integer; the model 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 .{ .prefix = prefix, .group = group, .priority = priority }; } pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void { crud.freeRows(model.ClientPrefix, gpa, items); } pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void { const group_id = try ctx.groupId(item.group); var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)"); defer stmt.deinit(); try stmt.bindText(1, item.prefix); try stmt.bindInt(2, group_id); try stmt.bindInt(3, item.priority); try stmt.exec(); } pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void { return database.exec("DELETE FROM client_prefixes;"); } 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) { return crud.listRows(ClientRow, database, gpa, list_client_rows_sql, readClientRow); } pub fn freeClientRow(gpa: Allocator, row: ClientRow) void { crud.freeRow(ClientRow, gpa, row); } pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void { crud.freeRows(ClientRow, gpa, items); } 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) { return crud.listRows(ClientPrefixRow, database, gpa, list_client_prefix_rows_sql, readClientPrefixRow); } fn readClientPrefixRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientPrefixRow { 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; return .{ .id = stmt.columnInt(0), .prefix = prefix, .group_id = stmt.columnInt(2), .group = group, .priority = priority, }; } pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void { crud.freeRow(ClientPrefixRow, gpa, row); } pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void { crud.freeRows(ClientPrefixRow, gpa, items); } /// 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(); } // --------------------------------------------------------------------------- // reconcile surface (milestone 20) // --------------------------------------------------------------------------- // // `replaceClientPrefixes` above is the REST list resource: one atomic swap of // the whole table, in a transaction of its own. The reconcile engine cannot use // it — it runs inside a transaction already, and rewriting every row would // forfeit the row ids and the zero-writes property the engine exists for — so // it edits and removes prefixes one at a time instead. /// Writes the two columns a prefix row carries besides its identity. /// /// `error.NotFound`: no prefix holds `id`. `error.Constraint`: /// `client_prefixes.prefix` is UNIQUE, or `group_id` names no group. pub fn updateClientPrefix(database: *db.Db, id: i64, item: ClientPrefixInput) db.Error!void { var stmt = try database.prepare( "UPDATE client_prefixes SET prefix = ?2, group_id = ?3, priority = ?4 WHERE id = ?1", ); defer stmt.deinit(); try stmt.bindInt(1, id); try stmt.bindText(2, item.prefix); try stmt.bindInt(3, item.group_id); try stmt.bindInt(4, item.priority); return crud.execStrict(database, &stmt); } /// `error.NotFound`: no prefix holds `id`. Nothing references /// `client_prefixes`, so a delete cannot violate a constraint. pub fn deleteClientPrefix(database: *db.Db, id: i64) db.Error!void { var stmt = try database.prepare("DELETE FROM client_prefixes WHERE id = ?1"); defer stmt.deinit(); try stmt.bindInt(1, id); return crud.execStrict(database, &stmt); } /// Moves the observed clients of one group to another, and reports how many /// rows moved. /// /// `clients.group_id` references `groups(id)` with no `ON DELETE` action /// (config_schema.zig:26), so a group that any client still sits in cannot be /// deleted. When a configuration stops declaring a group, its *declared* /// clients go with it, but the devices the DNS path materialised into it did /// not come from the configuration and must not be deleted for a decision that /// was never about them. They move to the default group, which is also the /// semantics the operator asked for: they un-declared the group, not the /// devices. /// /// `hand_edited = 1` rows are untouched — those are configuration, and the /// reconcile engine has already accounted for them. pub fn reassignObservedClients(database: *db.Db, from_group_id: i64, to_group_id: i64) db.Error!u32 { var stmt = try database.prepare( "UPDATE clients SET group_id = ?2 WHERE hand_edited = 0 AND group_id = ?1", ); defer stmt.deinit(); try stmt.bindInt(1, from_group_id); try stmt.bindInt(2, to_group_id); try stmt.exec(); const moved = database.changes(); return @intCast(@min(moved, std.math.maxInt(u32))); } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; fn openMigrated() !db.Db { var database = try db.Db.open(":memory:", .{ .mode = .memory }); errdefer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); return database; } /// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join /// has two distinct groups to resolve. fn seedGroups(database: *db.Db) !IdMap { try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); var ids: IdMap = .empty; errdefer ids.deinit(testing.allocator); try ids.put(testing.allocator, "default", 1); try ids.put(testing.allocator, "kids", 2); return ids; } fn seedClients(database: *db.Db, ids: *const IdMap) !void { const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids }; try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx); try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx); try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx); } fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void { const ctx: InsertContext = .{ .group_ids = ids }; try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx); try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx); try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx); } test "clients round-trip in ip order with group names resolved" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClients(&database, &ids); var items = try listClients(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeClients(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); try testing.expectEqualStrings("192.168.1.10", items.items[0].ip); try testing.expectEqualStrings("desk", items.items[0].name); try testing.expectEqualStrings("default", items.items[0].group); try testing.expectEqualStrings("192.168.1.20", items.items[1].ip); try testing.expectEqualStrings("laptop", items.items[1].name); try testing.expectEqualStrings("kids", items.items[1].group); try testing.expectEqualStrings("fd00::1", items.items[2].ip); try testing.expectEqualStrings("", items.items[2].name); try testing.expectEqualStrings("kids", items.items[2].group); try testing.expectEqual( @as(i64, 1700000000), try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"), ); try testing.expectEqual( @as(i64, 1700000000), try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"), ); } test "a hand_edited = 0 client is absent from listClients but counted by countClients" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClients(&database, &ids); try database.exec( \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) \\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1); ); try testing.expectEqual(@as(i64, 4), try countClients(&database)); var items = try listClients(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeClients(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); for (items.items) |item| { try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5")); } } test "deleteAllClients empties the table and countClients reflects it" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClients(&database, &ids); try testing.expectEqual(@as(i64, 3), try countClients(&database)); try deleteAllClients(&database); try testing.expectEqual(@as(i64, 0), try countClients(&database)); } test "insertClient reports a group the caller's map does not hold" { var database = try openMigrated(); defer database.close(); const ctx: InsertContext = .{}; try testing.expectError( error.NotFound, insertClient(&database, .{ .ip = "192.168.1.1" }, ctx), ); } fn listClientsUnderFailure(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); var items = try listClients(&database, gpa); defer items.deinit(gpa); defer freeClients(gpa, items.items); } test "listClients 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, listClientsUnderFailure, .{&ids}); } fn seenRow(database: *db.Db, ip: []const u8) !struct { hand_edited: i64, first_seen: i64, last_seen: i64, group_id: i64 } { var stmt = try database.prepare( "SELECT hand_edited, first_seen, last_seen, group_id FROM clients WHERE ip = ?1", ); defer stmt.deinit(); try stmt.bindText(1, ip); try testing.expect(try stmt.step()); return .{ .hand_edited = stmt.columnInt(0), .first_seen = stmt.columnInt(1), .last_seen = stmt.columnInt(2), .group_id = stmt.columnInt(3), }; } test "upsertSeen materialises an unseen client in the default group" { var database = try openMigrated(); defer database.close(); try upsertSeen(&database, "192.168.1.50", 1700000000); try testing.expectEqual(@as(i64, 1), try countClients(&database)); const row = try seenRow(&database, "192.168.1.50"); try testing.expectEqual(@as(i64, 0), row.hand_edited); try testing.expectEqual(@as(i64, 1700000000), row.first_seen); try testing.expectEqual(@as(i64, 1700000000), row.last_seen); try testing.expectEqual(@as(i64, 1), row.group_id); // Materialised clients are runtime state, so an export must not see them. var items = try listClients(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeClients(testing.allocator, items.items); try testing.expectEqual(@as(usize, 0), items.items.len); } test "upsertSeen touches last_seen and leaves first_seen alone" { var database = try openMigrated(); defer database.close(); try upsertSeen(&database, "192.168.1.50", 1700000000); try upsertSeen(&database, "192.168.1.50", 1700000600); try testing.expectEqual(@as(i64, 1), try countClients(&database)); const row = try seenRow(&database, "192.168.1.50"); try testing.expectEqual(@as(i64, 1700000000), row.first_seen); try testing.expectEqual(@as(i64, 1700000600), row.last_seen); } test "upsertSeen keeps a hand-edited row's name, group and flag" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClients(&database, &ids); try upsertSeen(&database, "192.168.1.20", 1700009999); try testing.expectEqual(@as(i64, 3), try countClients(&database)); const row = try seenRow(&database, "192.168.1.20"); try testing.expectEqual(@as(i64, 1), row.hand_edited); try testing.expectEqual(@as(i64, 2), row.group_id); try testing.expectEqual(@as(i64, 1700000000), row.first_seen); try testing.expectEqual(@as(i64, 1700009999), row.last_seen); var items = try listClients(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeClients(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); try testing.expectEqualStrings("laptop", items.items[1].name); try testing.expectEqualStrings("kids", items.items[1].group); } test "upsertSeen reports a database with no default group" { var database = try openMigrated(); defer database.close(); try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;"); try testing.expectError(error.Constraint, upsertSeen(&database, "192.168.1.50", 1700000000)); try testing.expectEqual(@as(i64, 0), try countClients(&database)); } test "pruneStale removes only stale auto-materialised rows" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); // A hand-edited row far older than the cutoff. try seedClients(&database, &ids); try upsertSeen(&database, "10.0.0.1", 1700000000); try upsertSeen(&database, "10.0.0.2", 1700000199); // Exactly at the cutoff: the comparison is strict, so it stays. try upsertSeen(&database, "10.0.0.3", 1700000200); try upsertSeen(&database, "10.0.0.4", 1700000300); try testing.expectEqual(@as(u32, 2), try pruneStale(&database, 1700000200)); try testing.expectEqual(@as(i64, 5), try countClients(&database)); try testing.expectEqual( @as(i64, 0), try database.queryInt("SELECT count(*) FROM clients WHERE ip IN ('10.0.0.1', '10.0.0.2')"), ); try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM clients WHERE hand_edited = 1")); // A second pass over the same cutoff finds nothing left to do. try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1700000200)); } test "pruneStale spares hand-edited rows however stale" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClients(&database, &ids); try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000)); try testing.expectEqual(@as(i64, 3), try countClients(&database)); } test "client_prefixes round-trip in prefix order with group names resolved" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClientPrefixes(&database, &ids); var items = try listClientPrefixes(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeClientPrefixes(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix); try testing.expectEqualStrings("default", items.items[0].group); try testing.expectEqual(@as(i32, 50), items.items[0].priority); try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix); try testing.expectEqualStrings("kids", items.items[1].group); try testing.expectEqual(@as(i32, 10), items.items[1].priority); try testing.expectEqualStrings("fd00::/48", items.items[2].prefix); try testing.expectEqualStrings("kids", items.items[2].group); try testing.expectEqual(@as(i32, 100), items.items[2].priority); } test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClientPrefixes(&database, &ids); try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database)); try deleteAllClientPrefixes(&database); try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database)); } fn listClientPrefixesUnderFailure(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 seedClientPrefixes(&database, ids); var items = try listClientPrefixes(&database, gpa); defer items.deinit(gpa); defer freeClientPrefixes(gpa, items.items); } test "listClientPrefixes 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, 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}); }