milestone 27: diagnostics — operational failures land in one curated log, resolved history purgeable
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s

This commit is contained in:
2026-08-20 20:05:59 +02:00
parent 3dd8214ef2
commit 037f209179
50 changed files with 8608 additions and 102 deletions
+113 -4
View File
@@ -55,7 +55,24 @@ fn assertOrdered(list: []const Step) void {
/// foreign_keys` is a no-op inside a transaction, so applying it afterwards
/// would silently leave referential integrity off.
pub fn migrate(database: *db.Db) Error!u32 {
return migrateSteps(database, &steps);
const version = try migrateSteps(database, &steps);
try bridgeOperationalEvents(database);
return version;
}
/// **Removable when the v0.1 adoption gate lands.**
///
/// `operational_events` joined `config_schema.ddl_v1` after databases stamped
/// version 1 already existed, and a stamped database never runs step 1 again —
/// so on those installs nothing would ever create the table, silently, and the
/// diagnostics store would fail to open forever. This runs after the stamp,
/// with every statement conditional, and touches no other table.
///
/// It belongs here and not in `cli.openConfigDb`: that runs *before* migration
/// everywhere (`app.zig`, `cli.zig`), so creating the table there would make a
/// fresh database's unconditional `CREATE TABLE` in `ddl_v1` fail.
fn bridgeOperationalEvents(database: *db.Db) db.Error!void {
return database.exec(config_schema.operational_events_bridge);
}
/// Same logic against an injected step list. The seam exists for the rollback
@@ -159,7 +176,7 @@ test "migrate on a fresh database creates every table and seeds the default grou
const expected = [_][]const u8{
"schema_version", "groups", "clients", "client_prefixes",
"upstreams", "rules", "local_records", "forward_zones",
"blocklist_sources", "group_sources", "settings",
"blocklist_sources", "group_sources", "settings", "operational_events",
};
for (expected) |name| {
try testing.expect(try tableExists(&database, name));
@@ -373,9 +390,101 @@ test "delete_order and table_names name exactly the tables the schema creates" {
for (config_schema.table_names) |name| {
try testing.expect(try tableExists(&database, name));
}
// delete_order covers every table except `schema_version`.
// delete_order covers every table except two: `schema_version`, which is
// the migration's own, and `operational_events`, which is runtime state an
// import must never wipe.
try testing.expect(!try tableExists(&database, "no_such_table"));
try testing.expect(try tableExists(&database, "operational_events"));
try testing.expectEqual(
@as(i64, config_schema.delete_order.len + 1),
@as(i64, config_schema.delete_order.len + 2),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
}
test "a version-1 database created without operational_events gains exactly it" {
// The silent divergence the bridge exists for: this is what the Pi's
// `config.db` looks like — stamped 1, so step 1 never runs again.
var database = try openMigrated();
defer database.close();
try database.exec(config_schema.ddl_v1);
try database.exec("DROP TABLE operational_events;");
try database.exec("INSERT INTO schema_version (version) VALUES (1);");
try testing.expect(!try tableExists(&database, "operational_events"));
const tables_before = try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'");
const groups_before = try database.queryInt("SELECT count(*) FROM groups");
try testing.expectEqual(@as(u32, 1), try migrate(&database));
try testing.expect(try tableExists(&database, "operational_events"));
try testing.expectEqual(
tables_before + 1,
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
// Both indexes came with it, and no other table moved.
try testing.expectEqual(
@as(i64, 2),
try database.queryInt(
"SELECT count(*) FROM sqlite_schema WHERE type='index' AND tbl_name='operational_events'",
),
);
try testing.expectEqual(groups_before, try database.queryInt("SELECT count(*) FROM groups"));
}
test "the bridge does not double-create on a fresh database or on a second run" {
var database = try openMigrated();
defer database.close();
// `ddl_v1` creates the table unconditionally, so a bridge that ran as part
// of the step list would fail here rather than be a no-op.
try testing.expectEqual(@as(u32, 1), try migrate(&database));
const schema_rows = try database.queryInt("SELECT count(*) FROM sqlite_schema");
try database.exec(
\\INSERT INTO operational_events
\\ (code, subject_key, subject_label, severity, first_seen, last_seen, occurrences)
\\VALUES ('disk.space', 'data', 'data', 'warning', 100, 100, 1);
);
try testing.expectEqual(@as(u32, 1), try migrate(&database));
try testing.expectEqual(schema_rows, try database.queryInt("SELECT count(*) FROM sqlite_schema"));
// A `CREATE TABLE IF NOT EXISTS` that had somehow replaced the table would
// show up as a lost row, not as a schema difference.
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM operational_events"));
}
test "the partial unique index allows one active row per key and any number of resolved ones" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
const insert =
\\INSERT INTO operational_events
\\ (code, subject_key, subject_label, severity, first_seen, last_seen, occurrences, resolved_at)
\\VALUES ('blocklist.refresh', 'https://a.example', 'a', 'warning', 100, 100, 1, ?1)
;
{
var stmt = try database.prepare(insert);
defer stmt.deinit();
try stmt.bindNull(1);
try stmt.exec();
}
{
// A second active row for the same (code, subject_key) is what
// `report`'s overflow probe exists to avoid, and the index proves it.
// Its own statement: `Stmt.reset` re-reports the code of a failed step,
// so a reused one would answer `error.Constraint` a second time.
var stmt = try database.prepare(insert);
defer stmt.deinit();
try stmt.bindNull(1);
try testing.expectError(error.Constraint, stmt.step());
}
var resolved = try database.prepare(insert);
defer resolved.deinit();
for ([_]i64{ 200, 300 }) |resolved_at| {
try resolved.reset();
try resolved.bindInt(1, resolved_at);
try resolved.exec();
}
try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM operational_events"));
}