// 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 `` 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; }