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:
agra
2026-06-06 01:08:01 +03:00
parent a2f7ad2a79
commit aa3b690381
5 changed files with 1092 additions and 0 deletions

426
src/repo/db.sx Normal file
View File

@@ -0,0 +1,426 @@
// =====================================================================
// db.sx — whole-model persistence to `<root>/db.json` via `std.json`
// (the SQLite stand-in for subplan 02, Slice 1).
//
// FIELD ORDER (the "stable key order" guarantee): `Object.put` preserves
// INSERTION ORDER and the writer emits members in that order, so the only
// thing that fixes db.json's layout is the ORDER these functions `put`.
// Every entity is emitted in its struct's declaration-field order, and the
// top-level object is emitted as apps, releases, artifacts, channels,
// audit_events. Re-saving an unchanged model yields byte-identical output.
//
// Enums serialize as their lowercase variant NAME (e.g. "android_apk",
// "percentage"), never an ordinal — readable and reorder-proof.
//
// READ BACK is strict: a missing field, a wrong JSON type, or an
// unrecognized enum name surfaces as a typed `LoadErr.BadShape` — never a
// silent default. Decoded string fields are COPIED into the loaded repo's
// own allocator, so the reloaded model does not alias the parse scratch or
// the source buffer.
// =====================================================================
#import "modules/std.sx";
#import "modules/std/json.sx";
#import "modules/fs.sx";
#import "../domain/platform.sx";
#import "../domain/app.sx";
#import "../domain/release.sx";
#import "../domain/artifact.sx";
#import "../domain/channel.sx";
#import "../domain/audit.sx";
#import "../domain/validate.sx";
#import "repo.sx";
// Persistence failure classes. `Io` = db.json could not be written/read;
// `Parse` = the bytes were not valid JSON; `BadShape` = valid JSON whose
// structure/types/enum-names don't match the model (a missing or
// wrong-typed field, or an unknown enum variant).
LoadErr :: error {
Io,
Parse,
BadShape,
}
// ── enum -> stable variant name ──────────────────────────────────────
visibility_str :: (v: Visibility) -> string {
if v == .private { return "private"; }
if v == .unlisted { return "unlisted"; }
return "public";
}
platform_str :: (p: Platform) -> string {
if p == .ios { return "ios"; }
if p == .android_apk { return "android_apk"; }
if p == .macos { return "macos"; }
if p == .linux { return "linux"; }
return "windows";
}
policy_str :: (p: RolloutPolicy) -> string {
if p == .manual { return "manual"; }
return "percentage";
}
status_str :: (s: ValidationStatus) -> string {
if s == .pending { return "pending"; }
if s == .valid { return "valid"; }
return "invalid";
}
// ── variant name -> enum (typed failure on an unknown name) ──────────
parse_visibility :: (s: string) -> (Visibility, !LoadErr) {
if s == "private" { return .private; }
if s == "unlisted" { return .unlisted; }
if s == "public" { return .public; }
raise error.BadShape;
}
platform_from :: (s: string) -> (Platform, !LoadErr) {
p, e := parse_platform(s); // reuse the domain parser
if e { raise error.BadShape; }
return p;
}
parse_policy :: (s: string) -> (RolloutPolicy, !LoadErr) {
if s == "manual" { return .manual; }
if s == "percentage" { return .percentage; }
raise error.BadShape;
}
parse_status :: (s: string) -> (ValidationStatus, !LoadErr) {
if s == "pending" { return .pending; }
if s == "valid" { return .valid; }
if s == "invalid" { return .invalid; }
raise error.BadShape;
}
// ── serialize: entity -> json Value (declaration field order) ────────
app_to_json :: (a: App, alloc: Allocator) -> Value {
o : Object = .{};
o.put("id", .str(a.id), alloc);
o.put("slug", .str(a.slug), alloc);
o.put("display_name", .str(a.display_name), alloc);
bids : Array = .{};
i := 0;
while i < a.bundle_ids.len {
b := a.bundle_ids.items[i];
bo : Object = .{};
bo.put("platform", .str(platform_str(b.platform)), alloc);
bo.put("value", .str(b.value), alloc);
bids.add(.object(bo), alloc);
i += 1;
}
o.put("bundle_ids", .array(bids), alloc);
o.put("owner", .str(a.owner), alloc);
o.put("visibility", .str(visibility_str(a.visibility)), alloc);
o.put("created_at", .int_(a.created_at), alloc);
o.put("updated_at", .int_(a.updated_at), alloc);
return .object(o);
}
release_to_json :: (r: Release, alloc: Allocator) -> Value {
o : Object = .{};
o.put("id", .str(r.id), alloc);
o.put("app_id", .str(r.app_id), alloc);
o.put("version", .str(r.version), alloc);
o.put("build", .int_(r.build), alloc);
o.put("channel", .str(r.channel), alloc);
o.put("notes", .str(r.notes), alloc);
o.put("created_by", .str(r.created_by), alloc);
o.put("created_at", .int_(r.created_at), alloc);
o.put("published_at", .int_(r.published_at), alloc);
return .object(o);
}
artifact_to_json :: (a: Artifact, alloc: Allocator) -> Value {
o : Object = .{};
o.put("id", .str(a.id), alloc);
o.put("app_id", .str(a.app_id), alloc);
o.put("release_id", .str(a.release_id), alloc);
o.put("platform", .str(platform_str(a.platform)), alloc);
o.put("filename", .str(a.filename), alloc);
o.put("content_type", .str(a.content_type), alloc);
o.put("size_bytes", .int_(a.size_bytes), alloc);
o.put("sha256", .str(a.sha256), alloc);
o.put("storage_key", .str(a.storage_key), alloc);
o.put("metadata", .str(a.metadata), alloc);
o.put("validation_status", .str(status_str(a.validation_status)), alloc);
return .object(o);
}
channel_to_json :: (c: Channel, alloc: Allocator) -> Value {
o : Object = .{};
o.put("app_id", .str(c.app_id), alloc);
o.put("name", .str(c.name), alloc);
o.put("current_release_id", .str(c.current_release_id), alloc);
o.put("policy", .str(policy_str(c.policy)), alloc);
o.put("rollout_percent", .int_(c.rollout_percent), alloc);
return .object(o);
}
audit_to_json :: (e: AuditEvent, alloc: Allocator) -> Value {
o : Object = .{};
o.put("id", .str(e.id), alloc);
o.put("actor", .str(e.actor), alloc);
o.put("action", .str(e.action), alloc);
o.put("target_type", .str(e.target_type), alloc);
o.put("target_id", .str(e.target_id), alloc);
o.put("metadata", .str(e.metadata), alloc);
o.put("created_at", .int_(e.created_at), alloc);
return .object(o);
}
// Build the whole model as one json Value: a top-level object whose five
// members are arrays of entity objects, in the fixed order documented above.
model_to_json :: (self: *Repo, alloc: Allocator) -> Value {
root : Object = .{};
apps : Array = .{};
i := 0;
while i < self.apps.len { apps.add(app_to_json(self.apps.items[i], alloc), alloc); i += 1; }
root.put("apps", .array(apps), alloc);
rels : Array = .{};
i = 0;
while i < self.releases.len { rels.add(release_to_json(self.releases.items[i], alloc), alloc); i += 1; }
root.put("releases", .array(rels), alloc);
arts : Array = .{};
i = 0;
while i < self.artifacts.len { arts.add(artifact_to_json(self.artifacts.items[i], alloc), alloc); i += 1; }
root.put("artifacts", .array(arts), alloc);
chans : Array = .{};
i = 0;
while i < self.channels.len { chans.add(channel_to_json(self.channels.items[i], alloc), alloc); i += 1; }
root.put("channels", .array(chans), alloc);
evs : Array = .{};
i = 0;
while i < self.audit_events.len { evs.add(audit_to_json(self.audit_events.items[i], alloc), alloc); i += 1; }
root.put("audit_events", .array(evs), alloc);
return .object(root);
}
// Serialize the whole repo to `<root>/db.json`. The json value tree is
// built in a local arena (freed on return) and STREAMED to the file
// through a fixed staging buffer, so no whole-document string is held.
save :: (self: *Repo, root_dir: string) -> !LoadErr {
if !create_dir_all(root_dir) { raise error.Io; }
gpa := GPA.init();
arena := Arena.init(xx gpa, 65536);
defer arena.deinit();
root_val := model_to_json(self, xx arena);
path := path_join(root_dir, "db.json");
fh := open_file(path, .write);
if fh == null { raise error.Io; }
f := fh!;
stage : [4096]u8 = ---;
werr := false;
write_to_file(root_val, @f, string.{ ptr = @stage[0], len = 4096 }) catch { werr = true; };
f.close();
if werr { raise error.Io; }
return;
}
// ── read-back helpers (strict; copy strings into `alloc`) ────────────
// Copy `s` into `alloc`-owned, null-terminated storage so it survives the
// parse scratch / source buffer being freed.
dup_str :: (s: string, alloc: Allocator) -> string {
raw : [*]u8 = xx alloc.alloc(s.len + 1);
if s.len > 0 { memcpy(raw, s.ptr, s.len); }
raw[s.len] = 0;
return string.{ ptr = raw, len = s.len };
}
obj_find :: (o: Object, key: string) -> ?Value {
i := 0;
while i < o.len {
if o.items[i].key == key { return o.items[i].val; }
i += 1;
}
return null;
}
// Required string field, copied into `alloc`.
req_str :: (o: Object, key: string, alloc: Allocator) -> (string, !LoadErr) {
v := obj_find(o, key);
if v == null { raise error.BadShape; }
val := v!;
if val != .str { raise error.BadShape; }
return dup_str(val.str, alloc);
}
// Required string field as a borrowed VIEW (for enum-name parsing only;
// not stored, so no copy needed).
req_str_view :: (o: Object, key: string) -> (string, !LoadErr) {
v := obj_find(o, key);
if v == null { raise error.BadShape; }
val := v!;
if val != .str { raise error.BadShape; }
return val.str;
}
req_int :: (o: Object, key: string) -> (s64, !LoadErr) {
v := obj_find(o, key);
if v == null { raise error.BadShape; }
val := v!;
if val != .int_ { raise error.BadShape; }
return val.int_;
}
req_arr :: (o: Object, key: string) -> (Array, !LoadErr) {
v := obj_find(o, key);
if v == null { raise error.BadShape; }
val := v!;
if val != .array { raise error.BadShape; }
return val.array;
}
req_obj :: (v: Value) -> (Object, !LoadErr) {
if v != .object { raise error.BadShape; }
return v.object;
}
// ── deserialize: json Object -> entity (strings copied into `alloc`) ──
app_from_json :: (o: Object, alloc: Allocator) -> (App, !LoadErr) {
a : App = .{};
a.id = try req_str(o, "id", alloc);
a.slug = try req_str(o, "slug", alloc);
a.display_name = try req_str(o, "display_name", alloc);
bids := try req_arr(o, "bundle_ids");
i := 0;
while i < bids.len {
bo := try req_obj(bids.items[i]);
p := try platform_from(try req_str_view(bo, "platform"));
bid : BundleId = .{ platform = p, value = try req_str(bo, "value", alloc) };
a.bundle_ids.append(bid, alloc);
i += 1;
}
a.owner = try req_str(o, "owner", alloc);
a.visibility = try parse_visibility(try req_str_view(o, "visibility"));
a.created_at = try req_int(o, "created_at");
a.updated_at = try req_int(o, "updated_at");
return a;
}
release_from_json :: (o: Object, alloc: Allocator) -> (Release, !LoadErr) {
r : Release = .{};
r.id = try req_str(o, "id", alloc);
r.app_id = try req_str(o, "app_id", alloc);
r.version = try req_str(o, "version", alloc);
r.build = try req_int(o, "build");
r.channel = try req_str(o, "channel", alloc);
r.notes = try req_str(o, "notes", alloc);
r.created_by = try req_str(o, "created_by", alloc);
r.created_at = try req_int(o, "created_at");
r.published_at = try req_int(o, "published_at");
return r;
}
artifact_from_json :: (o: Object, alloc: Allocator) -> (Artifact, !LoadErr) {
a : Artifact = .{};
a.id = try req_str(o, "id", alloc);
a.app_id = try req_str(o, "app_id", alloc);
a.release_id = try req_str(o, "release_id", alloc);
a.platform = try platform_from(try req_str_view(o, "platform"));
a.filename = try req_str(o, "filename", alloc);
a.content_type = try req_str(o, "content_type", alloc);
a.size_bytes = try req_int(o, "size_bytes");
a.sha256 = try req_str(o, "sha256", alloc);
a.storage_key = try req_str(o, "storage_key", alloc);
a.metadata = try req_str(o, "metadata", alloc);
a.validation_status = try parse_status(try req_str_view(o, "validation_status"));
return a;
}
channel_from_json :: (o: Object, alloc: Allocator) -> (Channel, !LoadErr) {
c : Channel = .{};
c.app_id = try req_str(o, "app_id", alloc);
c.name = try req_str(o, "name", alloc);
c.current_release_id = try req_str(o, "current_release_id", alloc);
c.policy = try parse_policy(try req_str_view(o, "policy"));
c.rollout_percent = try req_int(o, "rollout_percent");
return c;
}
audit_from_json :: (o: Object, alloc: Allocator) -> (AuditEvent, !LoadErr) {
e : AuditEvent = .{};
e.id = try req_str(o, "id", alloc);
e.actor = try req_str(o, "actor", alloc);
e.action = try req_str(o, "action", alloc);
e.target_type = try req_str(o, "target_type", alloc);
e.target_id = try req_str(o, "target_id", alloc);
e.metadata = try req_str(o, "metadata", alloc);
e.created_at = try req_int(o, "created_at");
return e;
}
// Parse `bytes` and fill `repo` via its create_* methods (which forward the
// repo's own allocator). All string fields are copied into that allocator.
load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr {
oa := repo.own_allocator;
root_val, pe := parse(bytes, scratch);
if pe { raise error.Parse; }
ro := try req_obj(root_val);
apps_arr := try req_arr(ro, "apps");
i := 0;
while i < apps_arr.len {
ao := try req_obj(apps_arr.items[i]);
repo.create_app(try app_from_json(ao, oa));
i += 1;
}
rel_arr := try req_arr(ro, "releases");
i = 0;
while i < rel_arr.len {
o := try req_obj(rel_arr.items[i]);
repo.create_release(try release_from_json(o, oa));
i += 1;
}
art_arr := try req_arr(ro, "artifacts");
i = 0;
while i < art_arr.len {
o := try req_obj(art_arr.items[i]);
repo.create_artifact(try artifact_from_json(o, oa));
i += 1;
}
chan_arr := try req_arr(ro, "channels");
i = 0;
while i < chan_arr.len {
o := try req_obj(chan_arr.items[i]);
repo.create_channel(try channel_from_json(o, oa));
i += 1;
}
ev_arr := try req_arr(ro, "audit_events");
i = 0;
while i < ev_arr.len {
o := try req_obj(ev_arr.items[i]);
repo.create_audit_event(try audit_from_json(o, oa));
i += 1;
}
return;
}
// Load `<root>/db.json` into a FRESH repository. The new repo captures the
// active `context.allocator` as its owning allocator; all entity strings
// are copied into it, so the result is independent of the file bytes and
// the parse scratch (both freed before this returns).
load :: (root_dir: string) -> (Repo, !LoadErr) {
path := path_join(root_dir, "db.json");
src := read_file(path);
if src == null { raise error.Io; }
bytes := src!;
gpa := GPA.init();
arena := Arena.init(xx gpa, 65536);
defer arena.deinit();
repo := Repo.init();
try load_into(@repo, bytes, xx arena);
return repo;
}

243
src/repo/repo.sx Normal file
View File

@@ -0,0 +1,243 @@
// =====================================================================
// repo.sx — in-memory repository over the P2.1 domain (subplan 02,
// Slice 1). Entities live in growable `List`s scanned LINEARLY — no
// HashMap, no index (that arrives with the SQLite schema in Slice 2).
//
// LONG-LIVED ALLOCATOR (binding, project CLAUDE.md): a Repo OUTLIVES the
// transient `context.allocator` of any single CLI call. Its `List`
// backing stores must NOT grow through the implicit context allocator —
// that memory would die when a caller's `push Context { allocator = .. }`
// scope ends, even though the Repo is still alive. So `init` CAPTURES the
// owning allocator (`own_allocator := context.allocator`) and every
// growth point forwards it explicitly: `self.<list>.append(x,
// self.own_allocator)`. There is no implicit-allocator growth anywhere in
// this file.
// =====================================================================
#import "modules/std.sx";
#import "../domain/platform.sx";
#import "../domain/app.sx";
#import "../domain/release.sx";
#import "../domain/artifact.sx";
#import "../domain/channel.sx";
#import "../domain/audit.sx";
#import "../domain/validate.sx";
// Failure classes for the publish transaction. `Validation` = a release /
// artifact / channel failed domain validation; `Integrity` = an artifact
// named a release_id other than the release being published.
PublishErr :: error {
Validation,
Integrity,
}
Repo :: struct {
// The allocator captured at construction. EVERY List growth in this
// repo forwards this explicitly, so the backing stores outlive any
// single call's transient `context.allocator` (see file header).
own_allocator: Allocator;
apps: List(App);
releases: List(Release);
artifacts: List(Artifact);
channels: List(Channel);
audit_events: List(AuditEvent);
// Capture the owning allocator. The List fields default to empty
// (items=null, len=0, cap=0) and first allocate on their first append.
init :: () -> Repo {
return Repo.{ own_allocator = context.allocator };
}
// ── Apps ─────────────────────────────────────────────────────────
create_app :: (self: *Repo, a: App) {
self.apps.append(a, self.own_allocator);
}
get_app :: (self: *Repo, id: string) -> ?App {
i := 0;
while i < self.apps.len {
if self.apps.items[i].id == id { return self.apps.items[i]; }
i += 1;
}
return null;
}
find_app_by_slug :: (self: *Repo, slug: string) -> ?App {
i := 0;
while i < self.apps.len {
if self.apps.items[i].slug == slug { return self.apps.items[i]; }
i += 1;
}
return null;
}
list_apps :: (self: *Repo) -> []App {
return .{ ptr = self.apps.items, len = self.apps.len };
}
update_app :: (self: *Repo, a: App) -> bool {
i := 0;
while i < self.apps.len {
if self.apps.items[i].id == a.id { self.apps.items[i] = a; return true; }
i += 1;
}
return false;
}
// ── Releases ─────────────────────────────────────────────────────
create_release :: (self: *Repo, r: Release) {
self.releases.append(r, self.own_allocator);
}
get_release :: (self: *Repo, id: string) -> ?Release {
i := 0;
while i < self.releases.len {
if self.releases.items[i].id == id { return self.releases.items[i]; }
i += 1;
}
return null;
}
list_releases :: (self: *Repo) -> []Release {
return .{ ptr = self.releases.items, len = self.releases.len };
}
update_release :: (self: *Repo, r: Release) -> bool {
i := 0;
while i < self.releases.len {
if self.releases.items[i].id == r.id { self.releases.items[i] = r; return true; }
i += 1;
}
return false;
}
// ── Artifacts ────────────────────────────────────────────────────
create_artifact :: (self: *Repo, a: Artifact) {
self.artifacts.append(a, self.own_allocator);
}
get_artifact :: (self: *Repo, id: string) -> ?Artifact {
i := 0;
while i < self.artifacts.len {
if self.artifacts.items[i].id == id { return self.artifacts.items[i]; }
i += 1;
}
return null;
}
// The content-address lookup from P2.2: scan by the sha256 digest.
find_artifact_by_digest :: (self: *Repo, digest: string) -> ?Artifact {
i := 0;
while i < self.artifacts.len {
if self.artifacts.items[i].sha256 == digest { return self.artifacts.items[i]; }
i += 1;
}
return null;
}
list_artifacts :: (self: *Repo) -> []Artifact {
return .{ ptr = self.artifacts.items, len = self.artifacts.len };
}
update_artifact :: (self: *Repo, a: Artifact) -> bool {
i := 0;
while i < self.artifacts.len {
if self.artifacts.items[i].id == a.id { self.artifacts.items[i] = a; return true; }
i += 1;
}
return false;
}
// ── Channels ─────────────────────────────────────────────────────
create_channel :: (self: *Repo, c: Channel) {
self.channels.append(c, self.own_allocator);
}
// Channels are keyed by (app_id, name) — there is no separate id.
channel_index :: (self: *Repo, app_id: string, name: string) -> s64 {
i := 0;
while i < self.channels.len {
c := self.channels.items[i];
if c.app_id == app_id and c.name == name { return i; }
i += 1;
}
return -1;
}
get_channel :: (self: *Repo, app_id: string, name: string) -> ?Channel {
idx := self.channel_index(app_id, name);
if idx < 0 { return null; }
return self.channels.items[idx];
}
list_channels :: (self: *Repo) -> []Channel {
return .{ ptr = self.channels.items, len = self.channels.len };
}
update_channel :: (self: *Repo, c: Channel) -> bool {
idx := self.channel_index(c.app_id, c.name);
if idx < 0 { return false; }
self.channels.items[idx] = c;
return true;
}
// ── Audit events ─────────────────────────────────────────────────
create_audit_event :: (self: *Repo, e: AuditEvent) {
self.audit_events.append(e, self.own_allocator);
}
get_audit_event :: (self: *Repo, id: string) -> ?AuditEvent {
i := 0;
while i < self.audit_events.len {
if self.audit_events.items[i].id == id { return self.audit_events.items[i]; }
i += 1;
}
return null;
}
list_audit_events :: (self: *Repo) -> []AuditEvent {
return .{ ptr = self.audit_events.items, len = self.audit_events.len };
}
// ── Publish transaction (atomic-ish) ─────────────────────────────
//
// Insert `release` + its `arts`, then point the (app_id, name) channel
// `chan` at that release. ATOMIC: a failure midway rolls the model back
// to exactly its pre-call state — no half-inserted release/artifacts
// and, in particular, no channel left pointing at a release that isn't
// in the repo (the "no dangling release" invariant).
//
// Rollback is by snapshot: List appends only bump `len`, so undoing them
// is a `len` reset; an updated existing channel is restored from a saved
// copy. The channel pointer is forced to `release.id` here, so a
// committed publish always points at the freshly inserted release.
publish :: (self: *Repo, release: Release, arts: *List(Artifact), chan: Channel) -> !PublishErr {
rel0 := self.releases.len;
art0 := self.artifacts.len;
chan0 := self.channels.len;
cidx := self.channel_index(chan.app_id, chan.name);
cprev : Channel = .{};
if cidx >= 0 { cprev = self.channels.items[cidx]; }
failed := false;
integrity := false;
validate_release(release) catch { failed = true; };
if !failed {
self.releases.append(release, self.own_allocator);
i := 0;
while i < arts.len {
a := arts.items[i];
if a.release_id != release.id { integrity = true; failed = true; break; }
validate_artifact(a) catch { failed = true; };
if failed { break; }
self.artifacts.append(a, self.own_allocator);
i += 1;
}
}
if !failed {
c := chan;
c.current_release_id = release.id;
validate_channel(c) catch { failed = true; };
if !failed {
if cidx >= 0 { self.channels.items[cidx] = c; }
else { self.channels.append(c, self.own_allocator); }
}
}
if failed {
// Undo every append (len reset) and restore the prior channel.
self.releases.len = rel0;
self.artifacts.len = art0;
self.channels.len = chan0;
if cidx >= 0 { self.channels.items[cidx] = cprev; }
if integrity { raise error.Integrity; }
raise error.Validation;
}
return;
}
}

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