// ===================================================================== // 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..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/token.sx"; #import "../domain/audit.sx"; #import "../domain/validate.sx"; // Failure classes for the publish transaction. `Validation` = a release / // artifact / channel failed domain validation; `Integrity` = the published // aggregate is cross-identity inconsistent — the release names an app that // doesn't exist, a release id that already exists, an artifact whose app_id / // release_id doesn't match the release, or a promoted channel that belongs to // a different app or is not the channel the release targets (name mismatch). 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); tokens: List(Token); 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) -> i64 { 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; } // ── Tokens ─────────────────────────────────────────────────────── create_token :: (self: *Repo, t: Token) { self.tokens.append(t, self.own_allocator); } get_token :: (self: *Repo, id: string) -> ?Token { i := 0; while i < self.tokens.len { if self.tokens.items[i].id == id { return self.tokens.items[i]; } i += 1; } return null; } // The auth lookup: a presented secret is hashed and matched here. find_token_by_hash :: (self: *Repo, token_hash: string) -> ?Token { i := 0; while i < self.tokens.len { if self.tokens.items[i].token_hash == token_hash { return self.tokens.items[i]; } i += 1; } return null; } list_tokens :: (self: *Repo) -> []Token { return .{ ptr = self.tokens.items, len = self.tokens.len }; } update_token :: (self: *Repo, t: Token) -> bool { i := 0; while i < self.tokens.len { if self.tokens.items[i].id == t.id { self.tokens.items[i] = t; return true; } i += 1; } return false; } // ── 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). // // The published aggregate must also form ONE consistent identity graph, // else committing it would create a dangling or ambiguous edge. So, as // Integrity preconditions: the release's app must exist; the release id // must be new (a colliding id would shadow the existing release, leaving // the channel edge pointing at a different release than the one // published); the promoted channel must belong to that app // (chan.app_id == release.app_id) AND be the channel this release targets // (chan.name == release.channel); and every artifact must belong to that // app AND name this release (a.app_id == release.app_id and // a.release_id == release.id). // // 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; }; // Cross-entity identity preconditions for the whole aggregate: the // release's app must exist, the release id must be new (else the // channel edge would resolve to a pre-existing release, not this one), // and the promoted channel must belong to that same app AND be the // channel this release targets (chan.name == release.channel). // (Per-artifact identity is checked in the loop.) if !failed { if self.get_app(release.app_id) == null { integrity = true; failed = true; } } if !failed { if self.get_release(release.id) != null { integrity = true; failed = true; } } if !failed { if chan.app_id != release.app_id { integrity = true; failed = true; } } if !failed { if chan.name != release.channel { integrity = true; failed = true; } } if !failed { self.releases.append(release, self.own_allocator); i := 0; while i < arts.len { a := arts.items[i]; if a.app_id != release.app_id or 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; } }