storage: version querylog.db and migrate it in place, never reset a healthy file
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m46s
Gates / test-aarch64 (push) Successful in 8m38s
Gates / package (push) Successful in 4m39s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 31m58s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m46s
Gates / test-aarch64 (push) Successful in 8m38s
Gates / package (push) Successful in 4m39s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 31m58s
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.
This commit is contained in:
+826
-33
@@ -57,6 +57,13 @@ const http = std.http;
|
||||
/// the expression that computes it.
|
||||
const querylog_schema = @import("querylog_schema");
|
||||
|
||||
/// The migration metadata the gates below judge: the supported version range
|
||||
/// and the step chain, as `querylog_versions.zig` declares it and
|
||||
/// `querylog_schema.open` runs it. Reached through `production_plan` rather
|
||||
/// than as a second module because `querylog_schema.zig` already imports that
|
||||
/// file, and one source file cannot belong to two modules.
|
||||
const querylog_versions = querylog_schema.production_plan;
|
||||
|
||||
const max_input_bytes = 1 << 30;
|
||||
|
||||
/// The only repository this program can ever act on. There is no flag for it:
|
||||
@@ -561,9 +568,283 @@ fn disclosesHistoryReset(section: []const u8) bool {
|
||||
return std.mem.indexOf(u8, section, history_reset_phrase) != null;
|
||||
}
|
||||
|
||||
/// The file whose DDL decides whether `querylog.db` survives an upgrade.
|
||||
/// The phrase a changelog section must carry to release a MIGRATION. It is the
|
||||
/// other operator-facing consequence: the history survives, and the first start
|
||||
/// after the upgrade rewrites the file to get there.
|
||||
const migration_phrase = "migrates your query log in place";
|
||||
|
||||
fn disclosesMigration(section: []const u8) bool {
|
||||
return std.mem.indexOf(u8, section, migration_phrase) != null;
|
||||
}
|
||||
|
||||
/// The heading under which an explicit break tells the operator how to get
|
||||
/// their history back. A break is allowed; a break with nowhere to turn is not.
|
||||
const restore_heading = "### Restoring your query history";
|
||||
|
||||
/// Whether the section carries `restore_heading` AND something under it. An
|
||||
/// empty section under the heading is the failure mode this exists to catch:
|
||||
/// the heading alone would satisfy a substring check while telling the operator
|
||||
/// nothing at all.
|
||||
fn disclosesRestoreInstructions(section: []const u8) bool {
|
||||
var lines = std.mem.splitScalar(u8, section, '\n');
|
||||
var under_heading = false;
|
||||
while (lines.next()) |raw| {
|
||||
const line = std.mem.trim(u8, std.mem.trimEnd(u8, raw, "\r"), " \t");
|
||||
if (under_heading) {
|
||||
if (std.mem.startsWith(u8, line, "#")) return false;
|
||||
if (!isBlank(line)) return true;
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, line, restore_heading)) under_heading = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the two migration gates (specs/milestone-38.md B.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A file that is immutable once released, and what became of it in this tree.
|
||||
///
|
||||
/// The gate never sees the bytes. Reading two revisions of a file is the
|
||||
/// driver's job; deciding what a difference means is a pure function of these
|
||||
/// three states, which is what makes every rule below a unit test.
|
||||
const ShippedFile = struct {
|
||||
kind: enum { step, fixture },
|
||||
path: []const u8,
|
||||
status: enum { identical, differs, missing },
|
||||
};
|
||||
|
||||
/// One link of the chain this build ships: the bytes `querylog_versions.step_sql`
|
||||
/// carries for it, and the bytes of the tree file it is supposed to be an
|
||||
/// `@embedFile` of.
|
||||
///
|
||||
/// The pair is what makes "a step is a SQL file, period" checkable. Counting
|
||||
/// steps proves only that the chain is the right LENGTH; comparing these two
|
||||
/// byte strings proves each link is the frozen file the previous release can be
|
||||
/// diffed against, so inline SQL, a reordered chain and an edited file all fail.
|
||||
const ChainStep = struct {
|
||||
embedded: []const u8,
|
||||
/// The tree's `src/storage/migrations/v<from>.sql`, or null when that file
|
||||
/// does not exist.
|
||||
on_disk: ?[]const u8,
|
||||
};
|
||||
|
||||
/// Everything the gates judge: the tree's migration metadata, the previous
|
||||
/// release's, whether the schema text moved, what became of the files the
|
||||
/// previous release froze, and the changelog section for this version.
|
||||
const GateInput = struct {
|
||||
ddl_changed: bool,
|
||||
current_version: i32,
|
||||
minimum_version: i32,
|
||||
legacy_fingerprint: i32,
|
||||
/// The chain in `step_sql` order: `chain[i]` migrates
|
||||
/// `minimum_version + i` to `+ i + 1`.
|
||||
chain: []const ChainStep,
|
||||
prev_version: i32,
|
||||
prev_minimum: i32,
|
||||
/// One entry per step file and fixture file the PREVIOUS tag shipped.
|
||||
shipped: []const ShippedFile,
|
||||
/// The versions in the tree that have BOTH halves of a fixture pair.
|
||||
fixture_versions: []const i32,
|
||||
/// The `## [<version>]` section, or empty when CHANGELOG.md could not be
|
||||
/// read — which fails every rule that needs a disclosure, on purpose.
|
||||
changelog_section: []const u8,
|
||||
};
|
||||
|
||||
/// Which lane, if any, a schema text change is released under.
|
||||
const Gate1 = enum {
|
||||
/// The DDL is byte-identical to the previous release's, so this gate has
|
||||
/// nothing to say. Gate 2 still runs.
|
||||
unchanged,
|
||||
migration_lane,
|
||||
break_lane,
|
||||
/// The schema moved under neither lane. This is the v0.0.9 failure.
|
||||
no_lane,
|
||||
};
|
||||
|
||||
/// The metadata a release can only have by being an explicit break: a new
|
||||
/// version, no way back from the previous one, and a changelog that says so and
|
||||
/// says how to recover.
|
||||
fn isExplicitBreak(in: GateInput) bool {
|
||||
return in.current_version > in.prev_version and
|
||||
in.minimum_version == in.current_version and
|
||||
disclosesHistoryReset(in.changelog_section) and
|
||||
disclosesRestoreInstructions(in.changelog_section);
|
||||
}
|
||||
|
||||
fn gate1(in: GateInput) Gate1 {
|
||||
if (!in.ddl_changed) return .unchanged;
|
||||
|
||||
// `prev_minimum <= prev_version` is what makes the previous release's files
|
||||
// reachable. An explicit break sets `minimum == current > prev_version`, so
|
||||
// it fails this test and can never wear the migration lane.
|
||||
const chain_spans_range = in.chain.len == stepsBetween(in.minimum_version, in.current_version);
|
||||
if (in.current_version > in.prev_version and
|
||||
in.prev_version >= in.minimum_version and
|
||||
chain_spans_range) return .migration_lane;
|
||||
|
||||
if (isExplicitBreak(in)) return .break_lane;
|
||||
return .no_lane;
|
||||
}
|
||||
|
||||
/// How many steps a contiguous chain from `from` to `to` has. Zero when the
|
||||
/// range is empty or inverted, so a regressed version cannot produce a negative
|
||||
/// count that would wrap.
|
||||
fn stepsBetween(from: i32, to: i32) usize {
|
||||
if (to <= from) return 0;
|
||||
return @intCast(to - from);
|
||||
}
|
||||
|
||||
/// Everything Gate 2 refuses. It runs whether or not the DDL moved: a
|
||||
/// data-only migration and an edit to a released step file both leave the
|
||||
/// schema text alone.
|
||||
const Gate2Reason = enum {
|
||||
step_edited,
|
||||
step_missing,
|
||||
step_has_no_file,
|
||||
step_not_its_file,
|
||||
fixture_edited,
|
||||
fixture_missing,
|
||||
fixture_pair_absent,
|
||||
legacy_fingerprint_edited,
|
||||
version_regressed,
|
||||
minimum_regressed,
|
||||
minimum_raised_without_break,
|
||||
bump_without_step_or_break,
|
||||
migration_undisclosed,
|
||||
};
|
||||
|
||||
const Gate2Problem = struct {
|
||||
reason: Gate2Reason,
|
||||
/// The file or version the reason is about, for the message. Empty when the
|
||||
/// reason is about the metadata as a whole.
|
||||
subject: []const u8 = "",
|
||||
};
|
||||
|
||||
/// The literal `querylog_versions.legacy_fingerprint` is frozen forever:
|
||||
/// editing it strands every 0.0.12/0.0.13 file that has not yet been opened by
|
||||
/// a migration-aware build. The gate holds the same number the module does.
|
||||
const frozen_legacy_fingerprint: i32 = 1975011655;
|
||||
|
||||
fn gate2(arena: Allocator, in: GateInput) ?Gate2Problem {
|
||||
if (in.legacy_fingerprint != frozen_legacy_fingerprint) {
|
||||
return .{ .reason = .legacy_fingerprint_edited };
|
||||
}
|
||||
|
||||
for (in.shipped) |file| {
|
||||
const reason: ?Gate2Reason = switch (file.status) {
|
||||
.identical => null,
|
||||
.differs => switch (file.kind) {
|
||||
.step => .step_edited,
|
||||
.fixture => .fixture_edited,
|
||||
},
|
||||
.missing => switch (file.kind) {
|
||||
.step => .step_missing,
|
||||
.fixture => .fixture_missing,
|
||||
},
|
||||
};
|
||||
if (reason) |r| return .{ .reason = r, .subject = file.path };
|
||||
}
|
||||
|
||||
// Every link of the chain is the frozen file at its own index. The path is
|
||||
// computed here rather than taken from the input, so a step can only clear
|
||||
// this rule by being the `@embedFile` of the one file the next release will
|
||||
// byte-compare against its predecessor.
|
||||
for (in.chain, 0..) |step, index| {
|
||||
const from = in.minimum_version + @as(i32, @intCast(index));
|
||||
const path = std.fmt.allocPrint(arena, "{s}/v{d}.sql", .{ migrations_dir, from }) catch @panic("OOM");
|
||||
const on_disk = step.on_disk orelse return .{ .reason = .step_has_no_file, .subject = path };
|
||||
if (!std.mem.eql(u8, on_disk, step.embedded)) {
|
||||
return .{ .reason = .step_not_its_file, .subject = path };
|
||||
}
|
||||
}
|
||||
|
||||
var version = in.minimum_version;
|
||||
while (version <= in.current_version) : (version += 1) {
|
||||
if (std.mem.indexOfScalar(i32, in.fixture_versions, version) == null) {
|
||||
return .{
|
||||
.reason = .fixture_pair_absent,
|
||||
.subject = std.fmt.allocPrint(arena, "{d}", .{version}) catch @panic("OOM"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (in.current_version < in.prev_version) return .{ .reason = .version_regressed };
|
||||
if (in.minimum_version < in.prev_minimum) return .{ .reason = .minimum_regressed };
|
||||
// Raising the minimum drops support for schemas the previous release
|
||||
// carried. That is allowed exactly once per break and never quietly, and
|
||||
// the DDL fingerprint has no say in it — a break can leave the text alone.
|
||||
if (in.minimum_version > in.prev_minimum and !isExplicitBreak(in)) {
|
||||
return .{ .reason = .minimum_raised_without_break };
|
||||
}
|
||||
|
||||
if (in.current_version > in.prev_version) {
|
||||
const new_steps = in.chain.len > stepsBetween(in.prev_minimum, in.prev_version);
|
||||
const a_break = in.minimum_version == in.current_version;
|
||||
if (!new_steps and !a_break) return .{ .reason = .bump_without_step_or_break };
|
||||
// A break discloses under Gate 1's break lane instead: its history does
|
||||
// not migrate, it is thrown away.
|
||||
if (new_steps and !a_break and !disclosesMigration(in.changelog_section)) {
|
||||
return .{ .reason = .migration_undisclosed };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The file whose DDL decides what shape `querylog.db` has.
|
||||
const querylog_schema_path = "src/storage/querylog_schema.zig";
|
||||
|
||||
/// The file whose constants decide whether an existing `querylog.db` survives
|
||||
/// the upgrade, and how.
|
||||
const querylog_versions_path = "src/storage/querylog_versions.zig";
|
||||
|
||||
const migrations_dir = "src/storage/migrations";
|
||||
const fixtures_dir = "src/storage/testdata";
|
||||
|
||||
/// A `pub const <name>: i32 = <literal>;` out of any revision of
|
||||
/// `querylog_versions.zig`, read as text for the same reason `extractDdl` reads
|
||||
/// the DDL as text: the previous release's copy only exists as `git show`
|
||||
/// output. Null when the declaration is absent or is not a plain literal, which
|
||||
/// is a refusal rather than a default — guessing a version would let a gate
|
||||
/// pass a release it never measured.
|
||||
fn extractVersionConst(file_text: []const u8, name: []const u8) ?i32 {
|
||||
var lines = std.mem.splitScalar(u8, file_text, '\n');
|
||||
while (lines.next()) |raw| {
|
||||
const line = std.mem.trim(u8, std.mem.trimEnd(u8, raw, "\r"), " \t");
|
||||
var prefix_buf: [64]u8 = undefined;
|
||||
const prefix = std.fmt.bufPrint(&prefix_buf, "pub const {s}: i32 = ", .{name}) catch return null;
|
||||
if (!std.mem.startsWith(u8, line, prefix)) continue;
|
||||
|
||||
const rest = line[prefix.len..];
|
||||
const end = std.mem.indexOfScalar(u8, rest, ';') orelse return null;
|
||||
var digits: [32]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
for (std.mem.trim(u8, rest[0..end], " \t")) |ch| {
|
||||
if (ch == '_') continue;
|
||||
if (len == digits.len) return null;
|
||||
digits[len] = ch;
|
||||
len += 1;
|
||||
}
|
||||
return std.fmt.parseInt(i32, digits[0..len], 10) catch null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The version a fixture path names, for either half of a pair. Null for any
|
||||
/// name that is not one, so an unrelated file in `testdata/` is ignored rather
|
||||
/// than parsed into a version that does not exist.
|
||||
fn fixtureVersionOf(name: []const u8) ?i32 {
|
||||
const prefix = "querylog-v";
|
||||
if (!std.mem.startsWith(u8, name, prefix)) return null;
|
||||
const rest = name[prefix.len..];
|
||||
const dash = std.mem.indexOfScalar(u8, rest, '-') orelse return null;
|
||||
const suffix = rest[dash..];
|
||||
if (!std.mem.eql(u8, suffix, "-schema.sql") and !std.mem.eql(u8, suffix, "-data.sql")) return null;
|
||||
return std.fmt.parseInt(i32, rest[0..dash], 10) catch null;
|
||||
}
|
||||
|
||||
/// The declaration line the DDL follows, matched whole so no other `ddl` in the
|
||||
/// file can be mistaken for it.
|
||||
const ddl_declaration = "pub const ddl: [:0]const u8 =";
|
||||
@@ -1561,22 +1842,26 @@ fn preflight(ctx: *Ctx, version: []const u8, bump_needed: bool, plan: Plan) !Pre
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Refuses a release that changes the querylog schema without saying so.
|
||||
/// Refuses a release whose querylog schema or migration metadata moved without
|
||||
/// the release saying what that costs the operator.
|
||||
///
|
||||
/// `querylog.db` is never migrated: the server compares the file's stamped
|
||||
/// fingerprint against this build's and, on a mismatch, sets the file aside and
|
||||
/// creates an empty one. Every query the operator ever logged is gone on the
|
||||
/// first start after the upgrade. v0.0.9 shipped exactly that while its
|
||||
/// announcement claimed no such change, which is what this check exists to stop.
|
||||
/// TWO INDEPENDENT GATES, both measured against the previous release TAG rather
|
||||
/// than the last commit, because the tag is what an operator upgrades from.
|
||||
///
|
||||
/// The comparison is between the DDL of the previous release tag and this
|
||||
/// tree's, so it measures the release, not the last commit. Every step that can
|
||||
/// fail — listing the tags, reading the old file, parsing it — is a refusal
|
||||
/// naming the step: a gate that cannot tell whether the schema moved must not
|
||||
/// report that it did not.
|
||||
/// Gate 1 is about the schema TEXT. A changed DDL has to be released under one
|
||||
/// of exactly two lanes: a migration that carries the file forward, or an
|
||||
/// explicit break that throws the history away and says how to get it back.
|
||||
/// v0.0.9 shipped a silent break while its announcement claimed no such change,
|
||||
/// which is what this gate exists to stop.
|
||||
///
|
||||
/// Gate 2 is about the migration METADATA, and it runs whether or not the text
|
||||
/// moved: a data-only migration, an edit to a step that has already shipped, an
|
||||
/// edited fixture and a quietly raised minimum all leave the DDL alone.
|
||||
///
|
||||
/// Every step that can fail — listing the tags, reading the old files, parsing
|
||||
/// them — is a refusal naming the step. A gate that cannot tell whether
|
||||
/// something moved must not report that it did not.
|
||||
fn schemaGate(ctx: *Ctx, version: []const u8, target: Semver, changelog: ?[]const u8) !void {
|
||||
const current = querylog_schema.fingerprint;
|
||||
|
||||
const tags = try gitCapture(ctx, &.{ "git", "ls-remote", "--tags", "origin" }, git_network_timeout_s);
|
||||
if (!tags.ok()) {
|
||||
ctx.soft("schema-gate", "`git ls-remote --tags origin` exited {d}: {s}", .{
|
||||
@@ -1611,33 +1896,184 @@ fn schemaGate(ctx: *Ctx, version: []const u8, target: Semver, changelog: ?[]cons
|
||||
});
|
||||
return;
|
||||
};
|
||||
const old = querylog_schema.fingerprintOf(old_ddl);
|
||||
const old_fingerprint = querylog_schema.fingerprintOf(old_ddl);
|
||||
const current_fingerprint = querylog_schema.fingerprint;
|
||||
|
||||
if (old == current) {
|
||||
ctx.pass("schema-gate", "the querylog schema is unchanged since {s} (fingerprint {d})", .{ previous_tag, current });
|
||||
return;
|
||||
// The previous release's metadata. `querylog_versions.zig` did not exist
|
||||
// before milestone 38, and every file such a release created is a version-1
|
||||
// file — that is what the legacy fingerprint stands for — so an absent
|
||||
// module is 1 and 1 rather than a refusal. The object itself is known good
|
||||
// by now: the DDL above came out of it.
|
||||
var prev_version: i32 = 1;
|
||||
var prev_minimum: i32 = 1;
|
||||
const old_versions = try gitCapture(ctx, &.{
|
||||
"git", "show", ctx.fmt("{s}:{s}", .{ previous.object, querylog_versions_path }),
|
||||
}, git_local_timeout_s);
|
||||
if (old_versions.ok()) {
|
||||
prev_version = extractVersionConst(old_versions.stdout, "current_version") orelse {
|
||||
ctx.soft("schema-gate", "cannot read `current_version` out of {s}:{s} ({s})", .{
|
||||
previous.object, querylog_versions_path, previous_tag,
|
||||
});
|
||||
return;
|
||||
};
|
||||
prev_minimum = extractVersionConst(old_versions.stdout, "minimum_supported_version") orelse {
|
||||
ctx.soft("schema-gate", "cannot read `minimum_supported_version` out of {s}:{s} ({s})", .{
|
||||
previous.object, querylog_versions_path, previous_tag,
|
||||
});
|
||||
return;
|
||||
};
|
||||
} else {
|
||||
ctx.note("schema-gate: {s} predates {s}, so it is read as schema version 1", .{
|
||||
previous_tag, querylog_versions_path,
|
||||
});
|
||||
}
|
||||
|
||||
const source = changelog orelse {
|
||||
const shipped = frozenFiles(ctx, previous.object, previous_tag) catch |err| switch (err) {
|
||||
error.CheckFailed => return,
|
||||
else => return err,
|
||||
};
|
||||
|
||||
const in: GateInput = .{
|
||||
.ddl_changed = old_fingerprint != current_fingerprint,
|
||||
.current_version = querylog_versions.current,
|
||||
.minimum_version = querylog_versions.minimum,
|
||||
.legacy_fingerprint = querylog_versions.legacy_fingerprint,
|
||||
.chain = treeChain(ctx),
|
||||
.prev_version = prev_version,
|
||||
.prev_minimum = prev_minimum,
|
||||
.shipped = shipped,
|
||||
.fixture_versions = treeFixtureVersions(ctx),
|
||||
.changelog_section = if (changelog) |source| changelogSection(source, version) orelse "" else "",
|
||||
};
|
||||
|
||||
if (changelog == null) {
|
||||
// The changelog check already reported why it could not be read; this
|
||||
// reports what that costs, because the gate has no way to clear itself.
|
||||
ctx.soft("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and CHANGELOG.md could not be read to check the disclosure", .{
|
||||
previous_tag, old, current,
|
||||
// reports what that costs, because neither gate can clear itself
|
||||
// without the disclosure it is looking for.
|
||||
ctx.soft("schema-gate", "CHANGELOG.md could not be read, so no disclosure can be checked", .{});
|
||||
}
|
||||
|
||||
switch (gate1(in)) {
|
||||
.unchanged => ctx.pass("schema-gate", "the querylog schema is unchanged since {s} (fingerprint {d})", .{
|
||||
previous_tag, current_fingerprint,
|
||||
}),
|
||||
.migration_lane => ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and schema version {d} migrates to {d} in place", .{
|
||||
previous_tag, old_fingerprint, current_fingerprint, prev_version, in.current_version,
|
||||
}),
|
||||
.break_lane => ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) as an explicit break to schema version {d}, and the `## [{s}]` section says so and says how to recover", .{
|
||||
previous_tag, old_fingerprint, current_fingerprint, in.current_version, version,
|
||||
}),
|
||||
.no_lane => ctx.soft(
|
||||
"schema-gate",
|
||||
"the querylog schema changed since {s} ({d} to {d}) under neither lane. Either ship a migration (raise `current_version` above {d}, keeping `minimum_supported_version` at or below it, with a step per version) or declare an explicit break (`minimum_supported_version == current_version`) and give the `## [{s}]` section both the phrase '{s}' and a `{s}` section with recovery steps",
|
||||
.{ previous_tag, old_fingerprint, current_fingerprint, prev_version, version, history_reset_phrase, restore_heading },
|
||||
),
|
||||
}
|
||||
|
||||
const problem = gate2(ctx.arena, in) orelse {
|
||||
ctx.pass("schema-gate-metadata", "the migration metadata is consistent with {s}: schema versions {d}..{d}, {d} step(s), every released step and fixture untouched", .{
|
||||
previous_tag, in.minimum_version, in.current_version, in.chain.len,
|
||||
});
|
||||
return;
|
||||
};
|
||||
const section = changelogSection(source, version) orelse "";
|
||||
if (!disclosesHistoryReset(section)) {
|
||||
ctx.soft(
|
||||
"schema-gate",
|
||||
"the querylog schema changed since {s} ({d} to {d}), so the first start after this release sets querylog.db aside and creates an empty one; say so in the `## [{s}]` section, which must contain the phrase '{s}'",
|
||||
.{ previous_tag, old, current, version, history_reset_phrase },
|
||||
);
|
||||
return;
|
||||
switch (problem.reason) {
|
||||
.step_edited => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and this tree changes it; a released migration step is immutable, so add a new step instead", .{ problem.subject, previous_tag }),
|
||||
.step_missing => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and is gone from this tree; a released migration step is immutable and every operator still below its target needs it", .{ problem.subject, previous_tag }),
|
||||
.step_has_no_file => ctx.soft("schema-gate-metadata", "step {s} of the chain has no `{s}`; a step is a SQL file and nothing else, so inline SQL leaves the next release nothing to byte-compare and no operator a way to audit what ran", .{ problem.subject, problem.subject }),
|
||||
.step_not_its_file => ctx.soft("schema-gate-metadata", "the chain's bytes for `{s}` are not that file's bytes; every step is the `@embedFile` of its own `v<from>.sql`, so rebuild the chain from the files rather than editing one side of the pair", .{problem.subject}),
|
||||
.fixture_edited => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and this tree changes it; a released fixture is the file the next migration is proved against, so a new schema version ships a NEW pair", .{ problem.subject, previous_tag }),
|
||||
.fixture_missing => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and is gone from this tree; a released fixture is immutable", .{ problem.subject, previous_tag }),
|
||||
.fixture_pair_absent => ctx.soft("schema-gate-metadata", "schema version {s} is supported but has no `{s}/querylog-v{s}-schema.sql` and `-data.sql` pair; every version in {d}..{d} needs one", .{ problem.subject, fixtures_dir, problem.subject, in.minimum_version, in.current_version }),
|
||||
.legacy_fingerprint_edited => ctx.soft("schema-gate-metadata", "`legacy_fingerprint` is {d}, not the frozen {d}; it is the literal stamp the 0.0.12 and 0.0.13 binaries wrote, and changing it strands every such file that no migration-aware build has opened yet", .{ in.legacy_fingerprint, frozen_legacy_fingerprint }),
|
||||
.version_regressed => ctx.soft("schema-gate-metadata", "`current_version` is {d} and {s} shipped {d}; the schema version never regresses", .{ in.current_version, previous_tag, prev_version }),
|
||||
.minimum_regressed => ctx.soft("schema-gate-metadata", "`minimum_supported_version` is {d} and {s} shipped {d}; this build claims to migrate files the previous one could not, with no step to do it", .{ in.minimum_version, previous_tag, prev_minimum }),
|
||||
.minimum_raised_without_break => ctx.soft("schema-gate-metadata", "`minimum_supported_version` rises from {d} to {d}, which drops support for schemas {s} could open. That is only releasable as the full explicit break: `minimum_supported_version == current_version`, a `current_version` above {d}, and a `## [{s}]` section carrying both '{s}' and a `{s}` section", .{ prev_minimum, in.minimum_version, previous_tag, prev_version, version, history_reset_phrase, restore_heading }),
|
||||
.bump_without_step_or_break => ctx.soft("schema-gate-metadata", "`current_version` rises from {d} to {d} with no new step file and no explicit break; a version an operator's file cannot reach and is not refused for is a silent reset", .{ prev_version, in.current_version }),
|
||||
.migration_undisclosed => ctx.soft("schema-gate-metadata", "this release migrates querylog.db from schema version {d} to {d}, so the `## [{s}]` section must contain the phrase '{s}'", .{ prev_version, in.current_version, version, migration_phrase }),
|
||||
}
|
||||
ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and the `## [{s}]` section discloses it", .{
|
||||
previous_tag, old, current, version,
|
||||
});
|
||||
}
|
||||
|
||||
/// The step and fixture files the previous tag froze, each paired with what
|
||||
/// this tree did to it.
|
||||
///
|
||||
/// `git ls-tree` lists the tag's side; the tree's side is read off disk,
|
||||
/// because a fixture added in this working copy is not in any index yet.
|
||||
fn frozenFiles(ctx: *Ctx, object: []const u8, previous_tag: []const u8) ![]const ShippedFile {
|
||||
const listing = try gitCapture(ctx, &.{
|
||||
"git", "ls-tree", "-r", "--name-only", object, "--", migrations_dir, fixtures_dir,
|
||||
}, git_local_timeout_s);
|
||||
if (!listing.ok()) {
|
||||
ctx.soft("schema-gate-metadata", "`git ls-tree {s}` for {s} exited {d}: {s}", .{
|
||||
object, previous_tag, listing.code, std.mem.trimEnd(u8, listing.combined(ctx.arena), "\n"),
|
||||
});
|
||||
return CheckFailed;
|
||||
}
|
||||
|
||||
var files: std.ArrayList(ShippedFile) = .empty;
|
||||
var lines = std.mem.splitScalar(u8, listing.stdout, '\n');
|
||||
while (lines.next()) |raw| {
|
||||
const path = std.mem.trim(u8, raw, " \t\r");
|
||||
if (path.len == 0) continue;
|
||||
|
||||
const kind: @FieldType(ShippedFile, "kind") = if (std.mem.startsWith(u8, path, migrations_dir ++ "/"))
|
||||
.step
|
||||
else if (fixtureVersionOf(std.fs.path.basename(path)) != null)
|
||||
.fixture
|
||||
else
|
||||
// Anything else under `testdata/` belongs to some other test and
|
||||
// carries no immutability promise.
|
||||
continue;
|
||||
|
||||
const released = try gitCapture(ctx, &.{
|
||||
"git", "show", ctx.fmt("{s}:{s}", .{ object, path }),
|
||||
}, git_local_timeout_s);
|
||||
if (!released.ok()) {
|
||||
ctx.soft("schema-gate-metadata", "`git show {s}:{s}` exited {d}: {s}", .{
|
||||
object, path, released.code, std.mem.trimEnd(u8, released.combined(ctx.arena), "\n"),
|
||||
});
|
||||
return CheckFailed;
|
||||
}
|
||||
|
||||
const status: @FieldType(ShippedFile, "status") = blk: {
|
||||
const current = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch
|
||||
break :blk .missing;
|
||||
break :blk if (std.mem.eql(u8, current, released.stdout)) .identical else .differs;
|
||||
};
|
||||
files.append(ctx.arena, .{ .kind = kind, .path = path, .status = status }) catch @panic("OOM");
|
||||
}
|
||||
return files.items;
|
||||
}
|
||||
|
||||
/// The chain this build embedded, each step paired with the tree file it claims
|
||||
/// to be. Reading the file is all this does; whether the two agree is Gate 2's
|
||||
/// rule, and an unreadable file reads as absent so that the gate names the step
|
||||
/// rather than the syscall.
|
||||
fn treeChain(ctx: *Ctx) []const ChainStep {
|
||||
var chain: std.ArrayList(ChainStep) = .empty;
|
||||
for (querylog_versions.step_sql, 0..) |embedded, index| {
|
||||
const from = querylog_versions.minimum + @as(i32, @intCast(index));
|
||||
const path = ctx.fmt("{s}/v{d}.sql", .{ migrations_dir, from });
|
||||
const on_disk = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch null;
|
||||
chain.append(ctx.arena, .{ .embedded = embedded, .on_disk = on_disk }) catch @panic("OOM");
|
||||
}
|
||||
return chain.items;
|
||||
}
|
||||
|
||||
/// The versions this tree has BOTH halves of a fixture pair for, over the range
|
||||
/// the metadata claims to support. Probing the range beats listing the
|
||||
/// directory: the range is what the rule is about, and a stray `querylog-v9-`
|
||||
/// file for some unsupported version proves nothing either way.
|
||||
fn treeFixtureVersions(ctx: *Ctx) []const i32 {
|
||||
var found: std.ArrayList(i32) = .empty;
|
||||
var version = querylog_versions.minimum;
|
||||
while (version <= querylog_versions.current) : (version += 1) {
|
||||
const schema = ctx.fmt("{s}/querylog-v{d}-schema.sql", .{ fixtures_dir, version });
|
||||
const data = ctx.fmt("{s}/querylog-v{d}-data.sql", .{ fixtures_dir, version });
|
||||
_ = Io.Dir.cwd().readFileAlloc(ctx.io, schema, ctx.arena, .limited(max_input_bytes)) catch continue;
|
||||
_ = Io.Dir.cwd().readFileAlloc(ctx.io, data, ctx.arena, .limited(max_input_bytes)) catch continue;
|
||||
found.append(ctx.arena, version) catch @panic("OOM");
|
||||
}
|
||||
return found.items;
|
||||
}
|
||||
|
||||
/// What to do about a `v<version>` tag that exists locally.
|
||||
@@ -2673,3 +3109,360 @@ test "a published release is only reported from a payload that carries one" {
|
||||
// A missing tag_name yields the empty string, which never equals a tag.
|
||||
try testing.expectEqualStrings("", jsonString(no_assets.object, "id"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the two migration gates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A release with nothing to declare: the schema is unchanged, the metadata is
|
||||
/// the previous release's, and every frozen file is where it was. Each test
|
||||
/// below changes exactly the fields its rule is about, so what it is testing is
|
||||
/// what it names.
|
||||
fn baseGateInput() GateInput {
|
||||
return .{
|
||||
.ddl_changed = false,
|
||||
.current_version = 1,
|
||||
.minimum_version = 1,
|
||||
.legacy_fingerprint = frozen_legacy_fingerprint,
|
||||
.chain = &.{},
|
||||
.prev_version = 1,
|
||||
.prev_minimum = 1,
|
||||
.shipped = &.{},
|
||||
.fixture_versions = &.{1},
|
||||
.changelog_section = "",
|
||||
};
|
||||
}
|
||||
|
||||
/// Two steps of plausible SQL, and the chains a correctly authored release
|
||||
/// carries them in: the embedded bytes ARE the file's bytes.
|
||||
const step_v1_sql = "ALTER TABLE domains RENAME TO domains_old;\n";
|
||||
const step_v2_sql = "DROP VIEW recent_queries;\n";
|
||||
const one_frozen_step: []const ChainStep = &.{
|
||||
.{ .embedded = step_v1_sql, .on_disk = step_v1_sql },
|
||||
};
|
||||
const two_frozen_steps: []const ChainStep = &.{
|
||||
.{ .embedded = step_v1_sql, .on_disk = step_v1_sql },
|
||||
.{ .embedded = step_v2_sql, .on_disk = step_v2_sql },
|
||||
};
|
||||
|
||||
const migration_section = "This release " ++ migration_phrase ++ ", so nothing is lost.\n";
|
||||
const break_section = "This release " ++ history_reset_phrase ++ ".\n\n" ++
|
||||
restore_heading ++ "\n\nStop the server and move the aside file back.\n";
|
||||
|
||||
/// A release that migrates schema version 1 to 2: one new step, one new fixture
|
||||
/// pair, and the changelog phrase that discloses it.
|
||||
fn migratingGateInput() GateInput {
|
||||
var in = baseGateInput();
|
||||
in.ddl_changed = true;
|
||||
in.current_version = 2;
|
||||
in.chain = one_frozen_step;
|
||||
in.fixture_versions = &.{ 1, 2 };
|
||||
in.changelog_section = migration_section;
|
||||
return in;
|
||||
}
|
||||
|
||||
/// A release that abandons schema version 1 instead of migrating it.
|
||||
fn breakingGateInput() GateInput {
|
||||
var in = baseGateInput();
|
||||
in.ddl_changed = true;
|
||||
in.current_version = 2;
|
||||
in.minimum_version = 2;
|
||||
in.chain = &.{};
|
||||
in.fixture_versions = &.{2};
|
||||
in.changelog_section = break_section;
|
||||
return in;
|
||||
}
|
||||
|
||||
fn expectGate2(in: GateInput, expected: ?Gate2Reason) !void {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const problem = gate2(arena_state.allocator(), in);
|
||||
if (expected) |reason| {
|
||||
try testing.expectEqual(reason, (problem orelse return error.GatePassed).reason);
|
||||
} else {
|
||||
if (problem) |actual| {
|
||||
std.debug.print("unexpected gate 2 failure: {t} ({s})\n", .{ actual.reason, actual.subject });
|
||||
return error.GateFailed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "a release that touches neither the schema nor the metadata passes both gates" {
|
||||
const in = baseGateInput();
|
||||
try testing.expectEqual(Gate1.unchanged, gate1(in));
|
||||
try expectGate2(in, null);
|
||||
}
|
||||
|
||||
test "a migration is released under the migration lane" {
|
||||
const in = migratingGateInput();
|
||||
try testing.expectEqual(Gate1.migration_lane, gate1(in));
|
||||
try expectGate2(in, null);
|
||||
}
|
||||
|
||||
test "an explicit break is released under the break lane" {
|
||||
const in = breakingGateInput();
|
||||
try testing.expectEqual(Gate1.break_lane, gate1(in));
|
||||
try expectGate2(in, null);
|
||||
}
|
||||
|
||||
test "a schema change under neither lane is refused" {
|
||||
var in = baseGateInput();
|
||||
in.ddl_changed = true;
|
||||
// The v0.0.9 shape exactly: the DDL moved and nothing else did.
|
||||
try testing.expectEqual(Gate1.no_lane, gate1(in));
|
||||
}
|
||||
|
||||
test "break metadata cannot be released as a migration" {
|
||||
var in = breakingGateInput();
|
||||
// `minimum == current` means the previous release's files cannot reach the
|
||||
// new version at all. Saying they migrate does not make them.
|
||||
in.changelog_section = migration_section;
|
||||
try testing.expectEqual(Gate1.no_lane, gate1(in));
|
||||
}
|
||||
|
||||
test "a version bump whose chain does not span the supported range is refused" {
|
||||
var in = migratingGateInput();
|
||||
in.current_version = 3;
|
||||
in.fixture_versions = &.{ 1, 2, 3 };
|
||||
// One step cannot carry a file from 1 to 3.
|
||||
try testing.expectEqual(Gate1.no_lane, gate1(in));
|
||||
}
|
||||
|
||||
test "an edited or deleted released step is refused however the version moved" {
|
||||
const path = migrations_dir ++ "/v1.sql";
|
||||
for ([_]@FieldType(ShippedFile, "status"){ .differs, .missing }) |status| {
|
||||
var in = migratingGateInput();
|
||||
// A perfectly well-formed version append, which is exactly the case
|
||||
// that must not launder an edit to a step already in operators' hands.
|
||||
in.current_version = 3;
|
||||
in.chain = two_frozen_steps;
|
||||
in.fixture_versions = &.{ 1, 2, 3 };
|
||||
in.shipped = &.{.{ .kind = .step, .path = path, .status = status }};
|
||||
|
||||
try testing.expectEqual(Gate1.migration_lane, gate1(in));
|
||||
try expectGate2(in, if (status == .differs) .step_edited else .step_missing);
|
||||
}
|
||||
}
|
||||
|
||||
test "every step of the chain must be the frozen file at its own index" {
|
||||
// Matching bytes are the whole rule, so start by proving they pass.
|
||||
const frozen = migratingGateInput();
|
||||
try expectGate2(frozen, null);
|
||||
|
||||
// A step written inline, with no `v1.sql` for the next release to compare
|
||||
// against. Counting steps calls this chain complete; the byte comparison
|
||||
// does not.
|
||||
var inline_only = migratingGateInput();
|
||||
inline_only.chain = &.{.{ .embedded = step_v1_sql, .on_disk = null }};
|
||||
try testing.expectEqual(Gate1.migration_lane, gate1(inline_only));
|
||||
try expectGate2(inline_only, .step_has_no_file);
|
||||
|
||||
// The file edited after the fact, so the binary runs SQL the audited file no
|
||||
// longer contains.
|
||||
var edited = migratingGateInput();
|
||||
edited.chain = &.{.{ .embedded = step_v1_sql, .on_disk = step_v1_sql ++ "DROP TABLE domains;\n" }};
|
||||
try expectGate2(edited, .step_not_its_file);
|
||||
|
||||
// And a chain listing its files out of order: index 0 must be `v1.sql`.
|
||||
var reordered = migratingGateInput();
|
||||
reordered.current_version = 3;
|
||||
reordered.fixture_versions = &.{ 1, 2, 3 };
|
||||
reordered.chain = &.{
|
||||
.{ .embedded = step_v2_sql, .on_disk = step_v1_sql },
|
||||
.{ .embedded = step_v1_sql, .on_disk = step_v2_sql },
|
||||
};
|
||||
try expectGate2(reordered, .step_not_its_file);
|
||||
}
|
||||
|
||||
test "an edited or deleted released fixture is refused" {
|
||||
const path = fixtures_dir ++ "/querylog-v1-data.sql";
|
||||
for ([_]@FieldType(ShippedFile, "status"){ .differs, .missing }) |status| {
|
||||
var in = migratingGateInput();
|
||||
in.shipped = &.{.{ .kind = .fixture, .path = path, .status = status }};
|
||||
try expectGate2(in, if (status == .differs) .fixture_edited else .fixture_missing);
|
||||
}
|
||||
}
|
||||
|
||||
test "a supported version with no fixture pair is refused" {
|
||||
var in = migratingGateInput();
|
||||
// The starting fixture is there; the version being released has none, so
|
||||
// the migration it ships was never proved to land anywhere.
|
||||
in.fixture_versions = &.{1};
|
||||
try expectGate2(in, .fixture_pair_absent);
|
||||
}
|
||||
|
||||
test "the schema version never regresses" {
|
||||
var in = baseGateInput();
|
||||
in.prev_version = 3;
|
||||
in.prev_minimum = 1;
|
||||
in.fixture_versions = &.{1};
|
||||
try expectGate2(in, .version_regressed);
|
||||
}
|
||||
|
||||
test "a version bump with neither a step nor a break is refused" {
|
||||
var in = baseGateInput();
|
||||
in.current_version = 2;
|
||||
in.fixture_versions = &.{ 1, 2 };
|
||||
in.changelog_section = migration_section;
|
||||
try expectGate2(in, .bump_without_step_or_break);
|
||||
}
|
||||
|
||||
test "a data-only migration must disclose itself even though the schema text held still" {
|
||||
var in = migratingGateInput();
|
||||
in.ddl_changed = false;
|
||||
in.changelog_section = "";
|
||||
// Gate 1 has nothing to say, which is the whole reason Gate 2 runs
|
||||
// independently of it.
|
||||
try testing.expectEqual(Gate1.unchanged, gate1(in));
|
||||
try expectGate2(in, .migration_undisclosed);
|
||||
|
||||
in.changelog_section = migration_section;
|
||||
try expectGate2(in, null);
|
||||
}
|
||||
|
||||
test "the supported minimum never regresses" {
|
||||
var in = baseGateInput();
|
||||
in.prev_minimum = 2;
|
||||
in.minimum_version = 1;
|
||||
in.current_version = 2;
|
||||
in.prev_version = 2;
|
||||
in.fixture_versions = &.{ 1, 2 };
|
||||
try expectGate2(in, .minimum_regressed);
|
||||
}
|
||||
|
||||
test "raising the minimum is only releasable as the full explicit break" {
|
||||
// Dropping support for a schema is the one change that silently discards an
|
||||
// operator's history, so every half-measure below is refused — including the
|
||||
// one where the schema text did not move at all.
|
||||
var partial = breakingGateInput();
|
||||
partial.changelog_section = migration_section;
|
||||
try expectGate2(partial, .minimum_raised_without_break);
|
||||
|
||||
var no_heading = breakingGateInput();
|
||||
no_heading.changelog_section = "This release " ++ history_reset_phrase ++ ".\n";
|
||||
try expectGate2(no_heading, .minimum_raised_without_break);
|
||||
|
||||
var empty_heading = breakingGateInput();
|
||||
empty_heading.changelog_section = "This release " ++ history_reset_phrase ++ ".\n\n" ++
|
||||
restore_heading ++ "\n\n## [0.0.1] - 2020-01-01\n";
|
||||
try expectGate2(empty_heading, .minimum_raised_without_break);
|
||||
|
||||
var same_version = breakingGateInput();
|
||||
same_version.current_version = 1;
|
||||
same_version.minimum_version = 1;
|
||||
same_version.prev_minimum = 0;
|
||||
same_version.fixture_versions = &.{1};
|
||||
try expectGate2(same_version, .minimum_raised_without_break);
|
||||
|
||||
var unchanged_ddl = breakingGateInput();
|
||||
unchanged_ddl.ddl_changed = false;
|
||||
unchanged_ddl.changelog_section = migration_section;
|
||||
try expectGate2(unchanged_ddl, .minimum_raised_without_break);
|
||||
}
|
||||
|
||||
test "the legacy fingerprint is frozen" {
|
||||
// Recomputing the anchor from a later DDL is the plausible way it gets
|
||||
// edited, so the substitute is any other CRC-shaped number.
|
||||
var in = baseGateInput();
|
||||
in.legacy_fingerprint = 603440875;
|
||||
try expectGate2(in, .legacy_fingerprint_edited);
|
||||
|
||||
// And the tree's own constant is the frozen one, which is what makes the
|
||||
// rule above a check on this repository rather than on its own literal.
|
||||
try testing.expectEqual(frozen_legacy_fingerprint, querylog_versions.legacy_fingerprint);
|
||||
|
||||
// The DDL has not moved since 0.0.12, so today the anchor and the schema
|
||||
// fingerprint are the same number. They are not the same THING: the anchor
|
||||
// is frozen at that value forever, and the fingerprint follows the schema.
|
||||
try testing.expectEqual(frozen_legacy_fingerprint, querylog_schema.fingerprint);
|
||||
}
|
||||
|
||||
test "this tree passes both gates against itself" {
|
||||
// The state every cut starts from: nothing moved since the previous
|
||||
// release. A tree that cannot pass this has a metadata bug, not a
|
||||
// disclosure one.
|
||||
var in = baseGateInput();
|
||||
in.current_version = querylog_versions.current;
|
||||
in.minimum_version = querylog_versions.minimum;
|
||||
in.legacy_fingerprint = querylog_versions.legacy_fingerprint;
|
||||
in.prev_version = querylog_versions.current;
|
||||
in.prev_minimum = querylog_versions.minimum;
|
||||
|
||||
// The tree's own chain, each step paired with itself: reading the file off
|
||||
// disk is `treeChain`'s job and needs an `Io` this test has no business
|
||||
// holding. What this covers is the metadata — the chain's LENGTH against the
|
||||
// supported range — which is the part a self-test can judge.
|
||||
var chain: std.ArrayList(ChainStep) = .empty;
|
||||
defer chain.deinit(testing.allocator);
|
||||
for (querylog_versions.step_sql) |sql| {
|
||||
try chain.append(testing.allocator, .{ .embedded = sql, .on_disk = sql });
|
||||
}
|
||||
in.chain = chain.items;
|
||||
|
||||
var versions: std.ArrayList(i32) = .empty;
|
||||
defer versions.deinit(testing.allocator);
|
||||
var version = querylog_versions.minimum;
|
||||
while (version <= querylog_versions.current) : (version += 1) {
|
||||
try versions.append(testing.allocator, version);
|
||||
}
|
||||
in.fixture_versions = versions.items;
|
||||
|
||||
try testing.expectEqual(Gate1.unchanged, gate1(in));
|
||||
try expectGate2(in, null);
|
||||
}
|
||||
|
||||
test "a previous tag without the versions module reads as schema version 1" {
|
||||
// What `git show <old tag>:src/storage/querylog_versions.zig` hands back is
|
||||
// nothing at all, and the driver answers 1 and 1 — every file such a release
|
||||
// created is a version-1 file, which is what the legacy fingerprint stands
|
||||
// for. This proves the extractor does not invent a number from a file that
|
||||
// has no such declaration.
|
||||
try testing.expect(extractVersionConst("pub const ddl = \"\";\n", "current_version") == null);
|
||||
try testing.expect(extractVersionConst("", "minimum_supported_version") == null);
|
||||
|
||||
const in = baseGateInput();
|
||||
try testing.expectEqual(@as(i32, 1), in.prev_version);
|
||||
try testing.expectEqual(@as(i32, 1), in.prev_minimum);
|
||||
try testing.expectEqual(Gate1.unchanged, gate1(in));
|
||||
try expectGate2(in, null);
|
||||
}
|
||||
|
||||
test "the version constants of the file on disk are the ones the gate compiled" {
|
||||
// The same round trip the DDL extractor gets: `git show` will hand this
|
||||
// text to `extractVersionConst`, so the parse has to agree with the
|
||||
// compiler on the file it can check.
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const source = try Io.Dir.cwd().readFileAlloc(
|
||||
threaded.io(),
|
||||
querylog_versions_path,
|
||||
arena_state.allocator(),
|
||||
.limited(max_input_bytes),
|
||||
);
|
||||
try testing.expectEqual(querylog_versions.current, extractVersionConst(source, "current_version").?);
|
||||
try testing.expectEqual(querylog_versions.minimum, extractVersionConst(source, "minimum_supported_version").?);
|
||||
try testing.expectEqual(
|
||||
querylog_versions.legacy_fingerprint,
|
||||
extractVersionConst(source, "legacy_fingerprint").?,
|
||||
);
|
||||
}
|
||||
|
||||
test "a fixture name yields its version, and nothing else does" {
|
||||
try testing.expectEqual(@as(i32, 1), fixtureVersionOf("querylog-v1-schema.sql").?);
|
||||
try testing.expectEqual(@as(i32, 12), fixtureVersionOf("querylog-v12-data.sql").?);
|
||||
try testing.expect(fixtureVersionOf("querylog-v1-notes.sql") == null);
|
||||
try testing.expect(fixtureVersionOf("querylog-schema.sql") == null);
|
||||
try testing.expect(fixtureVersionOf("config-v1-schema.sql") == null);
|
||||
try testing.expect(fixtureVersionOf("querylog-vx-data.sql") == null);
|
||||
}
|
||||
|
||||
test "restore instructions need a heading and something under it" {
|
||||
try testing.expect(disclosesRestoreInstructions(break_section));
|
||||
try testing.expect(!disclosesRestoreInstructions(restore_heading ++ "\n\n"));
|
||||
try testing.expect(!disclosesRestoreInstructions(restore_heading ++ "\n\n### Something else\nbody\n"));
|
||||
try testing.expect(!disclosesRestoreInstructions("### Restoring\nbody\n"));
|
||||
try testing.expect(disclosesRestoreInstructions("intro\n" ++ restore_heading ++ "\n- move it back\n"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user