querylog.db carries a schema version; migrations run at startup as one transaction after a vacuumed 0600 backup, and every failure refuses startup (exit 2, no systemd restart loop) instead of starting empty. corruption is the only automatic recreate left. the cut gate now requires a fixture-proven migration or an explicit versioned break with restore instructions, and locks shipped migration files and fixtures byte-for-byte.
536 lines
22 KiB
Zig
536 lines
22 KiB
Zig
//! The shipped `querylog.db` fixtures, and the proof that every supported
|
|
//! schema version reaches the current one with the operator's rows intact.
|
|
//!
|
|
//! A file of its own, not a section of `querylog_migrations.zig`, because of the
|
|
//! link contract that split `querylog_versions.zig` out in the first place. The
|
|
//! assertions here need `repositories/queries_repo.zig`'s projection-coherence
|
|
//! oracle, and that file reaches across `src/` for the config and filter types;
|
|
//! importing it from `querylog_migrations.zig` would pull all of it into the
|
|
//! module `tools/cut.zig` builds `querylog_schema.zig` as, where those paths lie
|
|
//! outside the module root and do not compile.
|
|
//!
|
|
//! The fixtures themselves — `testdata/querylog-v<N>-{schema,data}.sql` — are
|
|
//! immutable once released. Every version in `[minimum_supported_version,
|
|
//! current_version]` has a pair: the current version's pair is the next
|
|
//! migration's starting point, and an explicit break ships the new baseline.
|
|
|
|
const std = @import("std");
|
|
|
|
const db = @import("db.zig");
|
|
const migrations = @import("querylog_migrations.zig");
|
|
const queries_repo = @import("repositories/queries_repo.zig");
|
|
const querylog_schema = @import("querylog_schema.zig");
|
|
const versions = @import("querylog_versions.zig");
|
|
|
|
const testing = std.testing;
|
|
|
|
/// A temporary directory and the `querylog.db` path inside it. Deliberately not
|
|
/// `querylog_migrations.zig`'s test harness: that one builds synthetic schemas,
|
|
/// while everything here starts from the shipped fixture files.
|
|
const Harness = struct {
|
|
threaded: std.Io.Threaded,
|
|
tmp: std.testing.TmpDir,
|
|
buf: [256]u8 = undefined,
|
|
|
|
fn init() Harness {
|
|
return .{
|
|
.threaded = .init(testing.allocator, .{}),
|
|
.tmp = testing.tmpDir(.{ .iterate = true }),
|
|
};
|
|
}
|
|
|
|
fn deinit(self: *Harness) void {
|
|
self.tmp.cleanup();
|
|
self.threaded.deinit();
|
|
}
|
|
|
|
fn io(self: *Harness) std.Io {
|
|
return self.threaded.io();
|
|
}
|
|
|
|
fn path(self: *Harness) [:0]const u8 {
|
|
return std.fmt.bufPrintZ(&self.buf, ".zig-cache/tmp/{s}/querylog.db", .{self.tmp.sub_path}) catch
|
|
unreachable;
|
|
}
|
|
|
|
fn openLive(self: *Harness) !db.Db {
|
|
var database = try db.Db.open(self.path(), .{ .mode = .read_write_existing });
|
|
errdefer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
return database;
|
|
}
|
|
};
|
|
|
|
/// One shipped schema version's frozen pair. Both halves are immutable once
|
|
/// released — the release cut byte-compares them against the previous tag — and
|
|
/// every version in `[minimum_supported_version, current_version]` must have a
|
|
/// pair, which the cut gate also enforces.
|
|
const Fixture = struct {
|
|
version: i32,
|
|
schema: [:0]const u8,
|
|
data: [:0]const u8,
|
|
};
|
|
|
|
const fixtures = [_]Fixture{
|
|
.{
|
|
.version = 1,
|
|
.schema = @embedFile("testdata/querylog-v1-schema.sql"),
|
|
.data = @embedFile("testdata/querylog-v1-data.sql"),
|
|
},
|
|
};
|
|
|
|
fn fixtureFor(version: i32) ?Fixture {
|
|
for (fixtures) |fixture| {
|
|
if (fixture.version == version) return fixture;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Writes a fixture pair to `path` and stamps it. `stamp` is a parameter rather
|
|
/// than `fixture.version` because the legacy-fingerprint file is the same
|
|
/// version-1 bytes under a different stamp.
|
|
fn writeFixture(path: [:0]const u8, fixture: Fixture, stamp: i32) !void {
|
|
var database = try db.Db.open(path, .{ .mode = .read_write_create });
|
|
defer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
try database.exec(fixture.schema);
|
|
try database.exec(fixture.data);
|
|
|
|
var buf: [64]u8 = undefined;
|
|
const sql = std.fmt.bufPrintZ(&buf, "PRAGMA user_version = {d};", .{stamp}) catch unreachable;
|
|
try database.exec(sql);
|
|
}
|
|
|
|
/// A second path in the harness's directory, for the reference databases the
|
|
/// assertions below compare against.
|
|
fn sidePath(h: *Harness, buf: []u8, name: []const u8) [:0]const u8 {
|
|
return std.fmt.bufPrintZ(buf, ".zig-cache/tmp/{s}/{s}", .{ h.tmp.sub_path, name }) catch unreachable;
|
|
}
|
|
|
|
/// A database holding nothing but the current `ddl`, which is what a file
|
|
/// created by this build is.
|
|
fn openFreshCurrent(h: *Harness, buf: []u8) !db.Db {
|
|
var database = try db.Db.open(sidePath(h, buf, "fresh.db"), .{ .mode = .read_write_create });
|
|
errdefer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
try database.exec(querylog_schema.ddl);
|
|
return database;
|
|
}
|
|
|
|
/// An untouched load of `fixture`, to compare a migrated or opened file against
|
|
/// rather than restating the fixture's contents in the assertions.
|
|
fn openPristine(h: *Harness, buf: []u8, fixture: Fixture) !db.Db {
|
|
const path = sidePath(h, buf, "pristine.db");
|
|
try writeFixture(path, fixture, fixture.version);
|
|
var database = try db.Db.open(path, .{ .mode = .read_write_existing });
|
|
errdefer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
return database;
|
|
}
|
|
|
|
/// Every row of every table, as one canonical text. Exact equality is only the
|
|
/// right question for a file no migration has reshaped; a migrated file is
|
|
/// checked by the counts and the watermark instead.
|
|
fn dumpContent(gpa: std.mem.Allocator, database: *db.Db, out: *std.ArrayList(u8)) !void {
|
|
var tables: std.ArrayList([]u8) = .empty;
|
|
defer freeOwned(gpa, &tables);
|
|
{
|
|
var stmt = try database.prepare(
|
|
\\SELECT name FROM sqlite_schema
|
|
\\WHERE type = 'table' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'
|
|
\\ORDER BY name
|
|
);
|
|
defer stmt.deinit();
|
|
// Copied out before the per-table statements step: a borrowed
|
|
// `columnText` would not survive them.
|
|
while (try stmt.step()) try tables.append(gpa, try stmt.columnTextAlloc(gpa, 0));
|
|
}
|
|
|
|
// The lines are sorted here rather than by the query: the four `bucket_*`
|
|
// tables are WITHOUT ROWID, so `ORDER BY rowid` is not available to all of
|
|
// them and no single column list is.
|
|
var lines: std.ArrayList([]u8) = .empty;
|
|
defer freeOwned(gpa, &lines);
|
|
|
|
for (tables.items) |table| {
|
|
var sql_buf: [256]u8 = undefined;
|
|
const sql = std.fmt.bufPrint(&sql_buf, "SELECT * FROM \"{s}\"", .{table}) catch unreachable;
|
|
var stmt = try database.prepare(sql);
|
|
defer stmt.deinit();
|
|
while (try stmt.step()) {
|
|
var line: std.ArrayList(u8) = .empty;
|
|
errdefer line.deinit(gpa);
|
|
try line.print(gpa, "R|{s}", .{table});
|
|
var col: c_int = 0;
|
|
const columns: c_int = db.c.sqlite3_column_count(stmt.handle);
|
|
while (col < columns) : (col += 1) {
|
|
try line.print(gpa, "|{s}", .{stmt.columnTextOrNull(col) orelse "<null>"});
|
|
}
|
|
try lines.append(gpa, try line.toOwnedSlice(gpa));
|
|
}
|
|
}
|
|
|
|
std.mem.sortUnstable([]u8, lines.items, {}, struct {
|
|
fn lessThan(_: void, a: []u8, b: []u8) bool {
|
|
return std.mem.lessThan(u8, a, b);
|
|
}
|
|
}.lessThan);
|
|
for (lines.items) |line| {
|
|
try out.appendSlice(gpa, line);
|
|
try out.append(gpa, '\n');
|
|
}
|
|
}
|
|
|
|
/// One value, encoded so that no two different values can produce the same
|
|
/// bytes: a type tag, the byte length, then the bytes themselves.
|
|
///
|
|
/// Nothing here is a sentinel and nothing is escaped, which is the point. A
|
|
/// serialization that wrote NULL as `<null>` cannot tell a NULL apart from the
|
|
/// six-character string of the same name, and one that separated values with
|
|
/// `|` cannot tell `a|b` in one column from `a` and `b` in two — so a migration
|
|
/// that turned a NULL `upstream` into text, or shifted a value from one column
|
|
/// into its neighbour, would compare EQUAL to the original. The length prefix
|
|
/// makes the stream uniquely decodable, so equal encodings mean equal rows.
|
|
///
|
|
/// A float travels as its bit pattern rather than as printed digits: the
|
|
/// question here is whether the value survived, not whether it rounds the same.
|
|
fn writeValue(gpa: std.mem.Allocator, stmt: *db.Stmt, col: c_int, out: *std.ArrayList(u8)) !void {
|
|
switch (db.c.sqlite3_column_type(stmt.handle, col)) {
|
|
db.column_type.null_value => try out.print(gpa, "n0:", .{}),
|
|
db.column_type.integer => {
|
|
var buf: [24]u8 = undefined;
|
|
const text = std.fmt.bufPrint(&buf, "{d}", .{stmt.columnInt(col)}) catch unreachable;
|
|
try out.print(gpa, "i{d}:{s}", .{ text.len, text });
|
|
},
|
|
db.column_type.float => {
|
|
const bits: u64 = @bitCast(db.c.sqlite3_column_double(stmt.handle, col));
|
|
var buf: [24]u8 = undefined;
|
|
const text = std.fmt.bufPrint(&buf, "{d}", .{bits}) catch unreachable;
|
|
try out.print(gpa, "f{d}:{s}", .{ text.len, text });
|
|
},
|
|
db.column_type.blob => {
|
|
// `columnText` on a blob hands back the same bytes SQLite stores,
|
|
// which is what this compares; it is not read as text.
|
|
const bytes = stmt.columnText(col);
|
|
try out.print(gpa, "b{d}:{s}", .{ bytes.len, bytes });
|
|
},
|
|
else => {
|
|
const bytes = stmt.columnText(col);
|
|
try out.print(gpa, "t{d}:{s}", .{ bytes.len, bytes });
|
|
},
|
|
}
|
|
}
|
|
|
|
/// One query's rows, in the order the query returns them, tagged with `label` so
|
|
/// that a difference names the relation it came from. The column count is part
|
|
/// of each row for the same reason the lengths are part of each value.
|
|
fn dumpQuery(
|
|
gpa: std.mem.Allocator,
|
|
database: *db.Db,
|
|
label: []const u8,
|
|
sql: [:0]const u8,
|
|
out: *std.ArrayList(u8),
|
|
) !void {
|
|
var stmt = try database.prepare(sql);
|
|
defer stmt.deinit();
|
|
while (try stmt.step()) {
|
|
const columns: c_int = db.c.sqlite3_column_count(stmt.handle);
|
|
try out.print(gpa, "{s}:{d}:", .{ label, columns });
|
|
var col: c_int = 0;
|
|
while (col < columns) : (col += 1) {
|
|
try writeValue(gpa, &stmt, col, out);
|
|
}
|
|
try out.append(gpa, '\n');
|
|
}
|
|
}
|
|
|
|
/// The operator's data as the application means it, in an order a migration
|
|
/// cannot permute.
|
|
///
|
|
/// `q.*` rather than a column list on purpose: a migration that adds a column
|
|
/// must show that column here, and a hand-written list would quietly stop
|
|
/// covering the table the day it grows. `d.domain` rides along so that the text
|
|
/// a row names is compared, not only the id it happens to hold.
|
|
const logical_relations = [_]struct { label: []const u8, sql: [:0]const u8 }{
|
|
.{
|
|
.label = "query_log",
|
|
.sql =
|
|
\\SELECT q.*, d.domain FROM query_log q
|
|
\\JOIN domains d ON d.id = q.domain_id
|
|
\\ORDER BY q.id
|
|
,
|
|
},
|
|
.{ .label = "domains", .sql = "SELECT * FROM domains ORDER BY id" },
|
|
.{ .label = "querylog_meta", .sql = "SELECT * FROM querylog_meta ORDER BY id" },
|
|
};
|
|
|
|
fn dumpLogical(gpa: std.mem.Allocator, database: *db.Db, out: *std.ArrayList(u8)) !void {
|
|
for (logical_relations) |relation| {
|
|
try dumpQuery(gpa, database, relation.label, relation.sql, out);
|
|
}
|
|
}
|
|
|
|
fn freeOwned(gpa: std.mem.Allocator, list: *std.ArrayList([]u8)) void {
|
|
for (list.items) |item| gpa.free(item);
|
|
list.deinit(gpa);
|
|
}
|
|
|
|
fn expectSameContent(a: *db.Db, b: *db.Db) !void {
|
|
var text_a: std.ArrayList(u8) = .empty;
|
|
defer text_a.deinit(testing.allocator);
|
|
var text_b: std.ArrayList(u8) = .empty;
|
|
defer text_b.deinit(testing.allocator);
|
|
try dumpContent(testing.allocator, a, &text_a);
|
|
try dumpContent(testing.allocator, b, &text_b);
|
|
try testing.expectEqualStrings(text_a.items, text_b.items);
|
|
}
|
|
|
|
fn rowCount(database: *db.Db, table: []const u8) !i64 {
|
|
var buf: [128]u8 = undefined;
|
|
return database.queryInt(std.fmt.bufPrint(&buf, "SELECT count(*) FROM \"{s}\"", .{table}) catch unreachable);
|
|
}
|
|
|
|
/// What must hold after a fixture has come through `openVersioned`, whatever
|
|
/// path it took: every row the operator had is still there with the same
|
|
/// CONTENT, and the projections still agree with the raw rows.
|
|
///
|
|
/// Counting rows and checking one watermark is what this used to do, and a
|
|
/// migration that shifted a timestamp, dropped a `qtype` or crossed two rows'
|
|
/// `client_ip` values passed it. The comparison is therefore the full logical
|
|
/// content of the three operator tables, `available_since` included as one
|
|
/// column of `querylog_meta` among the rest. The counts stay because a count
|
|
/// difference is the failure worth naming plainly.
|
|
///
|
|
/// The relations are named rather than derived because they are the ones holding
|
|
/// operator data; the `bucket_*` projections are derived from them, which
|
|
/// `expectProjectionsMatchRecompute` is the right check for. A future migration
|
|
/// that renames a table amends this alongside the step that does it. So does one
|
|
/// that renumbers `id` values: this asserts they survive, which every rebuild
|
|
/// written to the A.2 rule does.
|
|
fn expectFixtureSurvived(opened: *db.Db, pristine: *db.Db) !void {
|
|
for ([_][]const u8{ "query_log", "domains", "querylog_meta" }) |table| {
|
|
try testing.expectEqual(try rowCount(pristine, table), try rowCount(opened, table));
|
|
}
|
|
|
|
var opened_text: std.ArrayList(u8) = .empty;
|
|
defer opened_text.deinit(testing.allocator);
|
|
var pristine_text: std.ArrayList(u8) = .empty;
|
|
defer pristine_text.deinit(testing.allocator);
|
|
try dumpLogical(testing.allocator, opened, &opened_text);
|
|
try dumpLogical(testing.allocator, pristine, &pristine_text);
|
|
try testing.expectEqualStrings(pristine_text.items, opened_text.items);
|
|
|
|
try queries_repo.expectProjectionsMatchRecompute(opened);
|
|
}
|
|
|
|
test "the survival comparison tells a NULL apart from text that looks like one" {
|
|
// The mutation a count-and-watermark check misses entirely, and a
|
|
// sentinel-string serialization misses just as completely: one column of one
|
|
// row stops being NULL and becomes the very text the sentinel used. Every
|
|
// count, the watermark and the projections all still agree.
|
|
const fixture = fixtureFor(1) orelse return error.MissingFixture;
|
|
|
|
var h: Harness = .init();
|
|
defer h.deinit();
|
|
try writeFixture(h.path(), fixture, fixture.version);
|
|
|
|
var side: [256]u8 = undefined;
|
|
var pristine = try openPristine(&h, &side, fixture);
|
|
defer pristine.close();
|
|
|
|
var corrupted = try h.openLive();
|
|
defer corrupted.close();
|
|
try testing.expectEqual(@as(i64, 0), corrupted.changes());
|
|
try corrupted.exec(
|
|
\\UPDATE query_log SET upstream = '<null>'
|
|
\\WHERE id = (SELECT min(id) FROM query_log WHERE upstream IS NULL)
|
|
);
|
|
// The fixture has to actually carry a NULL `upstream` for this to be a test
|
|
// of anything.
|
|
try testing.expectEqual(@as(i64, 1), corrupted.changes());
|
|
|
|
// Everything the old check looked at still agrees, which is why it passed.
|
|
for ([_][]const u8{ "query_log", "domains", "querylog_meta" }) |table| {
|
|
try testing.expectEqual(try rowCount(&pristine, table), try rowCount(&corrupted, table));
|
|
}
|
|
try testing.expectEqual(
|
|
try pristine.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1"),
|
|
try corrupted.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1"),
|
|
);
|
|
try queries_repo.expectProjectionsMatchRecompute(&corrupted);
|
|
|
|
// The dumps are compared here rather than through `expectFixtureSurvived`
|
|
// so that a PASSING run stays silent: `expectEqualStrings` prints the whole
|
|
// diff before it returns its error, and this is the comparison that function
|
|
// makes.
|
|
var corrupted_text: std.ArrayList(u8) = .empty;
|
|
defer corrupted_text.deinit(testing.allocator);
|
|
var pristine_text: std.ArrayList(u8) = .empty;
|
|
defer pristine_text.deinit(testing.allocator);
|
|
try dumpLogical(testing.allocator, &corrupted, &corrupted_text);
|
|
try dumpLogical(testing.allocator, &pristine, &pristine_text);
|
|
try testing.expect(!std.mem.eql(u8, corrupted_text.items, pristine_text.items));
|
|
}
|
|
|
|
test "every supported version ships a fixture pair" {
|
|
// The cut gate enforces this against a release; the suite enforces it
|
|
// against a commit, so a version bump that forgot its fixtures fails here
|
|
// long before anyone reaches for `zig build cut`.
|
|
var version = versions.minimum_supported_version;
|
|
while (version <= versions.current_version) : (version += 1) {
|
|
try testing.expect(fixtureFor(version) != null);
|
|
}
|
|
}
|
|
|
|
test "each shipped fixture pair is coherent before any migration touches it" {
|
|
for (fixtures) |fixture| {
|
|
var h: Harness = .init();
|
|
defer h.deinit();
|
|
try writeFixture(h.path(), fixture, fixture.version);
|
|
|
|
var database = try h.openLive();
|
|
defer database.close();
|
|
try testing.expectEqual(
|
|
@as(i64, fixture.version),
|
|
try database.queryInt("PRAGMA user_version"),
|
|
);
|
|
// An incoherent fixture has to fail as a fixture, not later as a
|
|
// migration that appears to have corrupted the projections.
|
|
try queries_repo.expectProjectionsMatchRecompute(&database);
|
|
try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM pragma_foreign_key_check"));
|
|
}
|
|
}
|
|
|
|
test "every fixture below the current version migrates to it through the shipped chain" {
|
|
// Empty while the chain is: `minimum_supported_version == current_version`
|
|
// today. It is written as the loop so that the day a step ships, the
|
|
// fixture it starts from is proved through the REAL production plan with no
|
|
// edit to this test.
|
|
var version = versions.minimum_supported_version;
|
|
while (version < versions.current_version) : (version += 1) {
|
|
const fixture = fixtureFor(version) orelse return error.MissingFixture;
|
|
|
|
var h: Harness = .init();
|
|
defer h.deinit();
|
|
try writeFixture(h.path(), fixture, version);
|
|
|
|
var side: [256]u8 = undefined;
|
|
var pristine = try openPristine(&h, &side, fixture);
|
|
defer pristine.close();
|
|
|
|
var result = try querylog_schema.open(h.io(), std.Io.Dir.cwd(), h.path());
|
|
defer result.database.close();
|
|
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
|
|
try testing.expectEqual(
|
|
@as(i64, versions.current_version),
|
|
try result.database.queryInt("PRAGMA user_version"),
|
|
);
|
|
|
|
var fresh_buf: [256]u8 = undefined;
|
|
var fresh = try openFreshCurrent(&h, &fresh_buf);
|
|
defer fresh.close();
|
|
try testing.expect(try migrations.schemaEquivalent(testing.allocator, &result.database, &fresh));
|
|
try expectFixtureSurvived(&result.database, &pristine);
|
|
}
|
|
}
|
|
|
|
test "the current version's fixture pair opens on the current lane unchanged" {
|
|
// The loop above never reaches this pair, and Gate 2 of the cut requires it
|
|
// to exist. This is what proves it is a real, coherent file rather than one
|
|
// shipped to satisfy a gate.
|
|
const fixture = fixtureFor(versions.current_version) orelse return error.MissingFixture;
|
|
|
|
var h: Harness = .init();
|
|
defer h.deinit();
|
|
try writeFixture(h.path(), fixture, versions.current_version);
|
|
|
|
var side: [256]u8 = undefined;
|
|
var pristine = try openPristine(&h, &side, fixture);
|
|
defer pristine.close();
|
|
|
|
var result = try querylog_schema.open(h.io(), std.Io.Dir.cwd(), h.path());
|
|
defer result.database.close();
|
|
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
|
|
try testing.expectEqual(
|
|
@as(i64, versions.current_version),
|
|
try result.database.queryInt("PRAGMA user_version"),
|
|
);
|
|
|
|
var fresh_buf: [256]u8 = undefined;
|
|
var fresh = try openFreshCurrent(&h, &fresh_buf);
|
|
defer fresh.close();
|
|
try testing.expect(try migrations.schemaEquivalent(testing.allocator, &result.database, &fresh));
|
|
try expectFixtureSurvived(&result.database, &pristine);
|
|
// No migration ran, so nothing reshaped anything: byte-for-byte the rows
|
|
// that were loaded.
|
|
try expectSameContent(&result.database, &pristine);
|
|
}
|
|
|
|
test "a fixture carrying the 0.0.12 fingerprint restamps, and refuses once the minimum rises" {
|
|
const fixture = fixtureFor(1) orelse return error.MissingFixture;
|
|
|
|
// Version 1 is the legacy fingerprint's logical version, so the pair only
|
|
// has anything to say while 1 is still supported.
|
|
if (versions.minimum_supported_version <= 1 and versions.current_version >= 1) {
|
|
var h: Harness = .init();
|
|
defer h.deinit();
|
|
try writeFixture(h.path(), fixture, versions.legacy_fingerprint);
|
|
|
|
var side: [256]u8 = undefined;
|
|
var pristine = try openPristine(&h, &side, fixture);
|
|
defer pristine.close();
|
|
|
|
var result = try querylog_schema.open(h.io(), std.Io.Dir.cwd(), h.path());
|
|
defer result.database.close();
|
|
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
|
|
try testing.expectEqual(
|
|
@as(i64, versions.current_version),
|
|
try result.database.queryInt("PRAGMA user_version"),
|
|
);
|
|
|
|
var fresh_buf: [256]u8 = undefined;
|
|
var fresh = try openFreshCurrent(&h, &fresh_buf);
|
|
defer fresh.close();
|
|
try testing.expect(try migrations.schemaEquivalent(testing.allocator, &result.database, &fresh));
|
|
try expectFixtureSurvived(&result.database, &pristine);
|
|
}
|
|
|
|
// The companion, already in the suite for the day a break raises the
|
|
// minimum above 1: the same bytes under the same stamp are then a file this
|
|
// build cannot reach, and it is refused without being touched.
|
|
var h: Harness = .init();
|
|
defer h.deinit();
|
|
try writeFixture(h.path(), fixture, versions.legacy_fingerprint);
|
|
|
|
const after_break: querylog_schema.Plan = .{
|
|
.minimum = 2,
|
|
.current = 2,
|
|
.legacy_fingerprint = versions.legacy_fingerprint,
|
|
.step_sql = &.{},
|
|
};
|
|
try testing.expect(querylog_schema.classify(versions.legacy_fingerprint, after_break).action ==
|
|
.refuse_unsupported);
|
|
try testing.expect(!querylog_schema.classify(versions.legacy_fingerprint, after_break).restamp);
|
|
|
|
var handle: ?db.Db = try h.openLive();
|
|
defer if (handle) |*open_db| open_db.close();
|
|
|
|
migrations.expected_failures.begin();
|
|
defer migrations.expected_failures.end();
|
|
try testing.expectError(
|
|
error.SchemaUnsupported,
|
|
querylog_schema.openVersioned(h.io(), std.Io.Dir.cwd(), h.path(), &handle, after_break),
|
|
);
|
|
|
|
var check = try h.openLive();
|
|
defer check.close();
|
|
try testing.expectEqual(
|
|
@as(i64, versions.legacy_fingerprint),
|
|
try check.queryInt("PRAGMA user_version"),
|
|
);
|
|
var pristine_buf: [256]u8 = undefined;
|
|
var pristine = try openPristine(&h, &pristine_buf, fixture);
|
|
defer pristine.close();
|
|
try expectSameContent(&check, &pristine);
|
|
}
|