P2.3: in-memory repository + db.json persistence (SQLite stand-in)
Adds the in-memory repository over the P2.1 domain and whole-model
persistence to <root>/db.json via std.json (subplan-02 Slice 1, the part
P2.1's mapping deferred).
src/repo/repo.sx
- Repo over App/Release/Artifact/Channel/AuditEvent, each a growable
List scanned LINEARLY (no index — Slice 1).
- create/get/list/update per entity; find_app_by_slug;
find_artifact_by_digest (the P2.2 content-address key).
- publish(): atomic-ish transaction (release + artifacts + channel
pointer). A failure midway rolls the model back by snapshot/restore —
no half-inserted entities and no channel left pointing at a release
that isn't in the repo.
- Long-lived-container rule: init captures own_allocator :=
context.allocator and every List growth forwards it explicitly, so the
backing stores outlive any single call's transient context allocator.
src/repo/db.sx
- save()/load() the whole model to/from <root>/db.json via std.json.
- Stable (insertion-order) field order: entities emit in declaration
order; top-level order is apps, releases, artifacts, channels,
audit_events. Re-saving an unchanged model is byte-identical.
- Enums serialize as their variant name. Read-back is strict: a missing
field, wrong JSON type, or unknown enum name -> typed LoadErr.BadShape.
Loaded strings are copied into the new repo's own allocator.
tests/
- repo_roundtrip.sx: save -> reparse (valid JSON) -> reload into a fresh
repo, asserting every field round-trips and a re-save is byte-identical.
- repo_transaction.sx: a publish that fails midway leaves the model
unchanged (no dangling release/channel), in memory and after reload.
- repo_owns_allocator.sx: deterministic proof that every owned list grows
through the captured allocator, not the call-site context allocator.
Gate: make build + make test both green (6/6).
This commit is contained in:
69
tests/repo_owns_allocator.sx
Normal file
69
tests/repo_owns_allocator.sx
Normal file
@@ -0,0 +1,69 @@
|
||||
// Acceptance for P2.3 (long-lived-container allocator rule) — the repo
|
||||
// OUTLIVES the transient `context.allocator` of any single call, so its
|
||||
// `List` backing stores must grow through the allocator CAPTURED at
|
||||
// construction, not whatever `context.allocator` happens to be at append
|
||||
// time.
|
||||
//
|
||||
// Deterministic proof: build the repo inside a `push Context` scope whose
|
||||
// allocator is a tracked GPA, then let that scope END (context.allocator
|
||||
// reverts to the default). EVERY subsequent repo mutation must still flow
|
||||
// through the captured GPA — observed as a rising `gpa.alloc_count`. If a
|
||||
// growth point wrongly used `context.allocator`, the count would not move
|
||||
// (the default allocator is a different, untracked one).
|
||||
#import "modules/std.sx";
|
||||
process :: #import "modules/process.sx";
|
||||
#import "../src/domain/platform.sx";
|
||||
#import "../src/domain/app.sx";
|
||||
#import "../src/domain/release.sx";
|
||||
#import "../src/domain/artifact.sx";
|
||||
#import "../src/domain/channel.sx";
|
||||
#import "../src/domain/audit.sx";
|
||||
#import "../src/repo/repo.sx";
|
||||
|
||||
main :: () -> s32 {
|
||||
gpa := GPA.init();
|
||||
|
||||
// Construct the repo under the tracked allocator, then leave the scope.
|
||||
repo : Repo = .{};
|
||||
push Context.{ allocator = xx gpa, data = null } {
|
||||
repo = Repo.init(); // own_allocator := this gpa
|
||||
}
|
||||
|
||||
// We are now back on the default context allocator. Touch all five
|
||||
// owned lists; each first append allocates its backing store, which
|
||||
// MUST come from the captured gpa (not the current context allocator).
|
||||
before := gpa.alloc_count;
|
||||
repo.create_app(App.{ id = "a1", slug = "acme", display_name = "Acme",
|
||||
owner = "u", created_at = 1, updated_at = 1 });
|
||||
repo.create_release(Release.{ id = "r1", app_id = "a1", version = "1.0.0",
|
||||
build = 1, channel = "stable", notes = "",
|
||||
created_by = "ci", created_at = 1, published_at = 0 });
|
||||
repo.create_artifact(Artifact.{ id = "art1", app_id = "a1", release_id = "r1",
|
||||
platform = .android_apk, filename = "a.apk",
|
||||
content_type = "x", size_bytes = 1,
|
||||
sha256 = "deadbeef", storage_key = "k",
|
||||
metadata = "", validation_status = .pending });
|
||||
repo.create_channel(Channel.{ app_id = "a1", name = "stable",
|
||||
current_release_id = "r1" });
|
||||
repo.create_audit_event(AuditEvent.{ id = "e1", actor = "u", action = "publish",
|
||||
target_type = "release", target_id = "r1",
|
||||
metadata = "", created_at = 1 });
|
||||
grew := gpa.alloc_count - before;
|
||||
|
||||
// Five distinct lists each allocated their first backing store through
|
||||
// the captured allocator -> at least five allocations attributed to it.
|
||||
process.assert(grew >= 5, "every owned list must grow through the captured allocator");
|
||||
|
||||
// And the data is intact after the construction scope ended.
|
||||
process.assert(repo.apps.len == 1, "app retained");
|
||||
process.assert(repo.releases.len == 1, "release retained");
|
||||
process.assert(repo.artifacts.len == 1, "artifact retained");
|
||||
process.assert(repo.channels.len == 1, "channel retained");
|
||||
process.assert(repo.audit_events.len == 1, "audit event retained");
|
||||
ag := repo.find_app_by_slug("acme");
|
||||
process.assert(ag != null, "app readable after scope end");
|
||||
|
||||
print(" repo grew through its captured allocator ({} allocs), data intact\n", grew);
|
||||
print("repo_owns_allocator: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
209
tests/repo_roundtrip.sx
Normal file
209
tests/repo_roundtrip.sx
Normal file
@@ -0,0 +1,209 @@
|
||||
// Acceptance for P2.3 (round-trip) — the in-memory repository persisted to
|
||||
// `<root>/db.json` via `std.json` and reloaded into a FRESH repository.
|
||||
//
|
||||
// Asserts:
|
||||
// 1. Every entity (app + bundle ids, release, two artifacts, channel,
|
||||
// audit event) survives save -> reload field-for-field.
|
||||
// 2. db.json is valid JSON, re-parseable by `std.json` (re-parse it).
|
||||
// 3. Re-saving the reloaded repo yields BYTE-IDENTICAL db.json — the
|
||||
// stable (insertion-order) key-order guarantee.
|
||||
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up. Exits 0 only if
|
||||
// every assertion holds (process.assert aborts otherwise).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
#import "modules/fs.sx";
|
||||
process :: #import "modules/process.sx";
|
||||
#import "../src/domain/platform.sx";
|
||||
#import "../src/domain/app.sx";
|
||||
#import "../src/domain/release.sx";
|
||||
#import "../src/domain/artifact.sx";
|
||||
#import "../src/domain/channel.sx";
|
||||
#import "../src/domain/audit.sx";
|
||||
#import "../src/repo/repo.sx";
|
||||
#import "../src/repo/db.sx";
|
||||
|
||||
// ── Field-for-field equality over the domain entities ────────────────
|
||||
bundle_eq :: (a: BundleId, b: BundleId) -> bool {
|
||||
return a.platform == b.platform and a.value == b.value;
|
||||
}
|
||||
app_eq :: (a: App, b: App) -> bool {
|
||||
if a.id != b.id { return false; }
|
||||
if a.slug != b.slug { return false; }
|
||||
if a.display_name != b.display_name { return false; }
|
||||
if a.owner != b.owner { return false; }
|
||||
if a.visibility != b.visibility { return false; }
|
||||
if a.created_at != b.created_at { return false; }
|
||||
if a.updated_at != b.updated_at { return false; }
|
||||
if a.bundle_ids.len != b.bundle_ids.len { return false; }
|
||||
i := 0;
|
||||
while i < a.bundle_ids.len {
|
||||
if !bundle_eq(a.bundle_ids.items[i], b.bundle_ids.items[i]) { return false; }
|
||||
i += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
release_eq :: (a: Release, b: Release) -> bool {
|
||||
return a.id == b.id and a.app_id == b.app_id and a.version == b.version
|
||||
and a.build == b.build and a.channel == b.channel and a.notes == b.notes
|
||||
and a.created_by == b.created_by and a.created_at == b.created_at
|
||||
and a.published_at == b.published_at;
|
||||
}
|
||||
artifact_eq :: (a: Artifact, b: Artifact) -> bool {
|
||||
return a.id == b.id and a.app_id == b.app_id and a.release_id == b.release_id
|
||||
and a.platform == b.platform and a.filename == b.filename
|
||||
and a.content_type == b.content_type and a.size_bytes == b.size_bytes
|
||||
and a.sha256 == b.sha256 and a.storage_key == b.storage_key
|
||||
and a.metadata == b.metadata and a.validation_status == b.validation_status;
|
||||
}
|
||||
channel_eq :: (a: Channel, b: Channel) -> bool {
|
||||
return a.app_id == b.app_id and a.name == b.name
|
||||
and a.current_release_id == b.current_release_id
|
||||
and a.policy == b.policy and a.rollout_percent == b.rollout_percent;
|
||||
}
|
||||
audit_eq :: (a: AuditEvent, b: AuditEvent) -> bool {
|
||||
return a.id == b.id and a.actor == b.actor and a.action == b.action
|
||||
and a.target_type == b.target_type and a.target_id == b.target_id
|
||||
and a.metadata == b.metadata and a.created_at == b.created_at;
|
||||
}
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────
|
||||
the_app :: (alloc: Allocator) -> App {
|
||||
a : App = .{
|
||||
id = "app_01", slug = "acme-app", display_name = "Acme App",
|
||||
owner = "user_01", visibility = .public,
|
||||
created_at = 1700000000, updated_at = 1700000050,
|
||||
};
|
||||
a.bundle_ids.append(BundleId.{ platform = .ios, value = "co.acme.app" }, alloc);
|
||||
a.bundle_ids.append(BundleId.{ platform = .android_apk, value = "co.acme.app.android" }, alloc);
|
||||
return a;
|
||||
}
|
||||
the_release :: () -> Release {
|
||||
return Release.{
|
||||
id = "rel_01", app_id = "app_01", version = "1.2.3-beta.1", build = 42,
|
||||
channel = "stable", notes = "first cut\nwith a newline", created_by = "user_01",
|
||||
created_at = 1700000100, published_at = 1700000200,
|
||||
};
|
||||
}
|
||||
apk_artifact :: () -> Artifact {
|
||||
return Artifact.{
|
||||
id = "art_apk", app_id = "app_01", release_id = "rel_01", platform = .android_apk,
|
||||
filename = "acme.apk", content_type = "application/vnd.android.package-archive",
|
||||
size_bytes = 10485760,
|
||||
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
storage_key = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
metadata = "{\"min_os\":\"14\"}", validation_status = .valid,
|
||||
};
|
||||
}
|
||||
ipa_artifact :: () -> Artifact {
|
||||
return Artifact.{
|
||||
id = "art_ipa", app_id = "app_01", release_id = "rel_01", platform = .ios,
|
||||
filename = "acme.ipa", content_type = "application/octet-stream",
|
||||
size_bytes = 20971520,
|
||||
sha256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
storage_key = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
metadata = "", validation_status = .pending,
|
||||
};
|
||||
}
|
||||
the_channel :: () -> Channel {
|
||||
return Channel.{
|
||||
app_id = "app_01", name = "stable", current_release_id = "rel_01",
|
||||
policy = .percentage, rollout_percent = 25,
|
||||
};
|
||||
}
|
||||
the_event :: () -> AuditEvent {
|
||||
return AuditEvent.{
|
||||
id = "ev_01", actor = "user_01", action = "publish",
|
||||
target_type = "release", target_id = "rel_01",
|
||||
metadata = "{\"channel\":\"stable\"}", created_at = 1700000300,
|
||||
};
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
root := ".sx-tmp/repo-roundtrip";
|
||||
root2 := ".sx-tmp/repo-roundtrip-2";
|
||||
process.run(concat("rm -rf ", root));
|
||||
process.run(concat("rm -rf ", root2));
|
||||
|
||||
// ── Build the original model ─────────────────────────────────────
|
||||
repo := Repo.init();
|
||||
app0 := the_app(repo.own_allocator);
|
||||
rel0 := the_release();
|
||||
apk0 := apk_artifact();
|
||||
ipa0 := ipa_artifact();
|
||||
chan0 := the_channel();
|
||||
ev0 := the_event();
|
||||
repo.create_app(app0);
|
||||
repo.create_release(rel0);
|
||||
repo.create_artifact(apk0);
|
||||
repo.create_artifact(ipa0);
|
||||
repo.create_channel(chan0);
|
||||
repo.create_audit_event(ev0);
|
||||
|
||||
// ── Persist ──────────────────────────────────────────────────────
|
||||
serr := false;
|
||||
save(repo, root) catch { serr = true; };
|
||||
process.assert(!serr, "save must succeed");
|
||||
process.assert(fs.exists(path_join(root, "db.json")), "db.json must exist after save");
|
||||
|
||||
// ── 2. db.json is valid JSON re-parseable by std.json ────────────
|
||||
raw := fs.read_file(path_join(root, "db.json"));
|
||||
process.assert(raw != null, "db.json must be readable");
|
||||
bytes := raw!;
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 65536);
|
||||
_, perr := parse(bytes, xx arena);
|
||||
process.assert(!perr, "db.json must be valid JSON re-parseable by std.json");
|
||||
arena.deinit();
|
||||
print(" db.json is valid JSON ({} bytes)\n", bytes.len);
|
||||
|
||||
// ── 1. Reload into a FRESH repo and compare field-for-field ──────
|
||||
repo2, lerr := load(root);
|
||||
if lerr { process.assert(false, "load must succeed"); return 1; }
|
||||
process.assert(repo2.apps.len == 1, "one app reloaded");
|
||||
process.assert(repo2.releases.len == 1, "one release reloaded");
|
||||
process.assert(repo2.artifacts.len == 2, "two artifacts reloaded");
|
||||
process.assert(repo2.channels.len == 1, "one channel reloaded");
|
||||
process.assert(repo2.audit_events.len == 1, "one audit event reloaded");
|
||||
|
||||
process.assert(app_eq(repo2.apps.items[0], app0), "app survives round-trip");
|
||||
|
||||
rg := repo2.get_release("rel_01");
|
||||
process.assert(rg != null, "release found by id after reload");
|
||||
process.assert(release_eq(rg!, rel0), "release survives round-trip");
|
||||
|
||||
// find_artifact_by_digest — the P2.2 content-address lookup.
|
||||
ag := repo2.find_artifact_by_digest(apk0.sha256);
|
||||
process.assert(ag != null, "apk artifact found by digest after reload");
|
||||
process.assert(artifact_eq(ag!, apk0), "apk artifact survives round-trip");
|
||||
ig := repo2.find_artifact_by_digest(ipa0.sha256);
|
||||
process.assert(ig != null, "ipa artifact found by digest after reload");
|
||||
process.assert(artifact_eq(ig!, ipa0), "ipa artifact survives round-trip");
|
||||
|
||||
cg := repo2.get_channel("app_01", "stable");
|
||||
process.assert(cg != null, "channel found after reload");
|
||||
process.assert(channel_eq(cg!, chan0), "channel survives round-trip");
|
||||
|
||||
process.assert(audit_eq(repo2.audit_events.items[0], ev0), "audit event survives round-trip");
|
||||
|
||||
// find_app_by_slug — the slug lookup.
|
||||
sg := repo2.find_app_by_slug("acme-app");
|
||||
process.assert(sg != null, "app found by slug after reload");
|
||||
sga := sg!;
|
||||
process.assert(sga.id == "app_01", "slug lookup returns the right app");
|
||||
print(" reloaded model equals original (every field)\n");
|
||||
|
||||
// ── 3. Re-save the reloaded repo -> byte-identical db.json ───────
|
||||
serr2 := false;
|
||||
save(repo2, root2) catch { serr2 = true; };
|
||||
process.assert(!serr2, "re-save must succeed");
|
||||
raw2 := fs.read_file(path_join(root2, "db.json"));
|
||||
process.assert(raw2 != null, "re-saved db.json must be readable");
|
||||
process.assert(raw2! == bytes, "re-save is byte-identical (stable key order)");
|
||||
print(" re-save is byte-identical (stable key order)\n");
|
||||
|
||||
// ── cleanup ──────────────────────────────────────────────────────
|
||||
process.run(concat("rm -rf ", root));
|
||||
process.run(concat("rm -rf ", root2));
|
||||
print("repo_roundtrip: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
145
tests/repo_transaction.sx
Normal file
145
tests/repo_transaction.sx
Normal file
@@ -0,0 +1,145 @@
|
||||
// Acceptance for P2.3 (transaction safety) — the publish transaction is
|
||||
// atomic: a failure midway leaves the model EXACTLY as it was, with no
|
||||
// half-inserted release/artifacts and no channel left pointing at a release
|
||||
// that isn't in the repo (the "no dangling release" invariant).
|
||||
//
|
||||
// Flow:
|
||||
// 1. Seed via a SUCCESSFUL publish (rel_00 + art_00, channel stable ->
|
||||
// rel_00) — exercises the commit path.
|
||||
// 2. A publish whose 2nd artifact is invalid must RAISE and roll back:
|
||||
// counts unchanged, channel still -> rel_00, rel_01 absent, the (valid)
|
||||
// first artifact absent too.
|
||||
// 3. A publish whose artifact names the wrong release_id must RAISE
|
||||
// Integrity and roll back identically.
|
||||
// 4. Persist + reload: the rolled-back state is what hits db.json — the
|
||||
// reloaded channel still points at rel_00 and rel_01 is absent.
|
||||
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up.
|
||||
#import "modules/std.sx";
|
||||
process :: #import "modules/process.sx";
|
||||
#import "../src/domain/platform.sx";
|
||||
#import "../src/domain/app.sx";
|
||||
#import "../src/domain/release.sx";
|
||||
#import "../src/domain/artifact.sx";
|
||||
#import "../src/domain/channel.sx";
|
||||
#import "../src/domain/audit.sx";
|
||||
#import "../src/repo/repo.sx";
|
||||
#import "../src/repo/db.sx";
|
||||
|
||||
DIGEST_A :: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
DIGEST_B :: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
mk_release :: (id: string, version: string) -> Release {
|
||||
return Release.{
|
||||
id = id, app_id = "app_01", version = version, build = 1,
|
||||
channel = "stable", notes = "", created_by = "ci",
|
||||
created_at = 1700000000, published_at = 0,
|
||||
};
|
||||
}
|
||||
mk_artifact :: (id: string, release_id: string, digest: string) -> Artifact {
|
||||
return Artifact.{
|
||||
id = id, app_id = "app_01", release_id = release_id, platform = .android_apk,
|
||||
filename = "a.apk", content_type = "application/vnd.android.package-archive",
|
||||
size_bytes = 1024, sha256 = digest, storage_key = digest,
|
||||
metadata = "", validation_status = .pending,
|
||||
};
|
||||
}
|
||||
the_channel :: () -> Channel {
|
||||
return Channel.{
|
||||
app_id = "app_01", name = "stable", current_release_id = "",
|
||||
policy = .manual, rollout_percent = 100,
|
||||
};
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
root := ".sx-tmp/repo-transaction";
|
||||
process.run(concat("rm -rf ", root));
|
||||
|
||||
repo := Repo.init();
|
||||
repo.create_app(App.{
|
||||
id = "app_01", slug = "acme-app", display_name = "Acme",
|
||||
owner = "user_01", created_at = 1, updated_at = 1,
|
||||
});
|
||||
|
||||
// ── 1. Seed via a successful publish (commit path) ───────────────
|
||||
arts0 : List(Artifact) = .{};
|
||||
arts0.append(mk_artifact("art_00", "rel_00", DIGEST_A));
|
||||
seed_failed := false;
|
||||
repo.publish(mk_release("rel_00", "1.0.0"), @arts0, the_channel()) catch { seed_failed = true; };
|
||||
process.assert(!seed_failed, "seed publish must commit");
|
||||
process.assert(repo.releases.len == 1, "seed: one release");
|
||||
process.assert(repo.artifacts.len == 1, "seed: one artifact");
|
||||
process.assert(repo.channels.len == 1, "seed: one channel");
|
||||
c0 := repo.get_channel("app_01", "stable");
|
||||
process.assert(c0 != null, "seed: channel exists");
|
||||
c0v := c0!;
|
||||
process.assert(c0v.current_release_id == "rel_00", "seed: channel points at rel_00");
|
||||
print(" seed publish committed: channel stable -> rel_00\n");
|
||||
|
||||
// ── snapshot the pre-transaction state ───────────────────────────
|
||||
rel_n := repo.releases.len;
|
||||
art_n := repo.artifacts.len;
|
||||
chan_n := repo.channels.len;
|
||||
|
||||
// ── 2. Failing publish: 2nd artifact invalid -> rollback ─────────
|
||||
arts1 : List(Artifact) = .{};
|
||||
arts1.append(mk_artifact("art_01a", "rel_01", DIGEST_B)); // valid
|
||||
arts1.append(mk_artifact("art_01b", "rel_01", "not-a-sha")); // invalid digest
|
||||
failed := false;
|
||||
was_validation := false;
|
||||
repo.publish(mk_release("rel_01", "1.1.0"), @arts1, the_channel()) catch e {
|
||||
failed = true;
|
||||
was_validation = (e == error.Validation);
|
||||
};
|
||||
process.assert(failed, "publish with an invalid artifact must fail");
|
||||
process.assert(was_validation, "invalid artifact must raise Validation");
|
||||
|
||||
// Rollback: nothing added, channel still points at the prior release.
|
||||
process.assert(repo.releases.len == rel_n, "rollback: release count unchanged");
|
||||
process.assert(repo.artifacts.len == art_n, "rollback: artifact count unchanged");
|
||||
process.assert(repo.channels.len == chan_n, "rollback: channel count unchanged");
|
||||
process.assert(repo.get_release("rel_01") == null, "rollback: no dangling release inserted");
|
||||
process.assert(repo.find_artifact_by_digest(DIGEST_B) == null, "rollback: valid first artifact not inserted");
|
||||
c1 := repo.get_channel("app_01", "stable");
|
||||
process.assert(c1 != null, "rollback: channel still exists");
|
||||
c1v := c1!;
|
||||
process.assert(c1v.current_release_id == "rel_00", "rollback: no dangling channel pointer (still rel_00)");
|
||||
print(" failed publish rolled back: no dangling release/channel\n");
|
||||
|
||||
// ── 3. Integrity failure: artifact names the wrong release ───────
|
||||
arts2 : List(Artifact) = .{};
|
||||
arts2.append(mk_artifact("art_02", "WRONG", DIGEST_B)); // release_id mismatch
|
||||
ifailed := false;
|
||||
was_integrity := false;
|
||||
repo.publish(mk_release("rel_02", "1.2.0"), @arts2, the_channel()) catch e {
|
||||
ifailed = true;
|
||||
was_integrity = (e == error.Integrity);
|
||||
};
|
||||
process.assert(ifailed, "publish with mismatched artifact.release_id must fail");
|
||||
process.assert(was_integrity, "release_id mismatch must raise Integrity");
|
||||
process.assert(repo.releases.len == rel_n, "integrity rollback: release count unchanged");
|
||||
process.assert(repo.artifacts.len == art_n, "integrity rollback: artifact count unchanged");
|
||||
c2 := repo.get_channel("app_01", "stable");
|
||||
process.assert(c2 != null, "integrity rollback: channel still exists");
|
||||
c2v := c2!;
|
||||
process.assert(c2v.current_release_id == "rel_00", "integrity rollback: channel still rel_00");
|
||||
print(" integrity failure rolled back: channel still rel_00\n");
|
||||
|
||||
// ── 4. Persisted state reflects the rollback, not the attempts ───
|
||||
serr := false;
|
||||
save(repo, root) catch { serr = true; };
|
||||
process.assert(!serr, "save must succeed");
|
||||
repo2, lerr := load(root);
|
||||
if lerr { process.assert(false, "load must succeed"); return 1; }
|
||||
process.assert(repo2.releases.len == rel_n, "persisted: release count is the committed one");
|
||||
process.assert(repo2.get_release("rel_01") == null, "persisted: rolled-back release absent");
|
||||
rc := repo2.get_channel("app_01", "stable");
|
||||
process.assert(rc != null, "persisted: channel present");
|
||||
rcv := rc!;
|
||||
process.assert(rcv.current_release_id == "rel_00", "persisted: channel points at a release that exists");
|
||||
process.assert(repo2.get_release(rcv.current_release_id) != null, "persisted: channel target is a real release (no dangling)");
|
||||
print(" persisted db.json reflects the rolled-back state\n");
|
||||
|
||||
process.run(concat("rm -rf ", root));
|
||||
print("repo_transaction: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user