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:
426
src/repo/db.sx
Normal file
426
src/repo/db.sx
Normal 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
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