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:
243
src/repo/repo.sx
Normal file
243
src/repo/repo.sx
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user