Files
distribution/tests/repo_transaction.sx
agra aa3b690381 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).
2026-06-06 01:08:01 +03:00

146 lines
7.1 KiB
Plaintext

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