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

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