`dist ci publish` now seeds the Repo from a pre-existing <store>/db.json before find-or-create, so separate CLI invocations share state: a new version accumulates under the single found app, and re-publishing the same release id is rejected by the P2.3 integrity transaction (db.json left unchanged). An absent db.json still starts empty. The loaded model grows through its owning allocator (context.allocator), per the long-lived rule. Wiring db.load into the dist program (which already links manifest.sx) exposed two latent issues, both fixed: - db.sx's load-path helpers (dup_str/obj_find/req_obj/req_arr/ artifact_from_json) collided by name with manifest.sx's same-named helpers; sx resolves bare top-level names across the whole program, so load_into bound to manifest's versions and failed the LoadErr error-set check. Renamed db.sx's five helpers with a db_ prefix (load-path only; save path and public API untouched). - publish's `existing!.id` (only reachable once an app is found, i.e. never before this change) read garbage: sx miscompiles postfix-`!` chained with `.field`. Bound the unwrap to a local first, matching the codebase idiom. tests/publish_persist.sx drives build/dist twice into one store: publish A, then a different version B accumulates (two releases, one app, both objects), then re-publishing A's id fails and leaves db.json unchanged. Fails on the pre-fix write-only persistence, passes after.
438 lines
16 KiB
Plaintext
438 lines
16 KiB
Plaintext
// =====================================================================
|
|
// 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";
|
|
// Also reached through an alias so the json reader is called as `jsonp.parse`
|
|
// — a bare `parse` would bind to `std.cli`'s `parse` once both modules share
|
|
// one program (the `dist` CLI), which returns a different type.
|
|
jsonp :: #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`) ────────────
|
|
// These carry a `db_` prefix because the `dist` program links this module
|
|
// alongside `manifest.sx`, which declares its own same-purpose `dup_str` /
|
|
// `obj_find` / `req_obj` / `req_arr` / `artifact_from_json`. sx resolves a
|
|
// bare top-level name across the WHOLE program (see the `jsonp` note above),
|
|
// so an unprefixed name here would bind to manifest's version and mismatch
|
|
// this module's `LoadErr` error set. The prefix keeps the load path bound to
|
|
// its own helpers.
|
|
|
|
// Copy `s` into `alloc`-owned, null-terminated storage so it survives the
|
|
// parse scratch / source buffer being freed.
|
|
db_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 };
|
|
}
|
|
|
|
db_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 := db_obj_find(o, key);
|
|
if v == null { raise error.BadShape; }
|
|
val := v!;
|
|
if val != .str { raise error.BadShape; }
|
|
return db_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 := db_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 := db_obj_find(o, key);
|
|
if v == null { raise error.BadShape; }
|
|
val := v!;
|
|
if val != .int_ { raise error.BadShape; }
|
|
return val.int_;
|
|
}
|
|
|
|
db_req_arr :: (o: Object, key: string) -> (Array, !LoadErr) {
|
|
v := db_obj_find(o, key);
|
|
if v == null { raise error.BadShape; }
|
|
val := v!;
|
|
if val != .array { raise error.BadShape; }
|
|
return val.array;
|
|
}
|
|
|
|
db_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 db_req_arr(o, "bundle_ids");
|
|
i := 0;
|
|
while i < bids.len {
|
|
bo := try db_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;
|
|
}
|
|
|
|
db_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 := jsonp.parse(bytes, scratch);
|
|
if pe { raise error.Parse; }
|
|
ro := try db_req_obj(root_val);
|
|
|
|
apps_arr := try db_req_arr(ro, "apps");
|
|
i := 0;
|
|
while i < apps_arr.len {
|
|
ao := try db_req_obj(apps_arr.items[i]);
|
|
repo.create_app(try app_from_json(ao, oa));
|
|
i += 1;
|
|
}
|
|
|
|
rel_arr := try db_req_arr(ro, "releases");
|
|
i = 0;
|
|
while i < rel_arr.len {
|
|
o := try db_req_obj(rel_arr.items[i]);
|
|
repo.create_release(try release_from_json(o, oa));
|
|
i += 1;
|
|
}
|
|
|
|
art_arr := try db_req_arr(ro, "artifacts");
|
|
i = 0;
|
|
while i < art_arr.len {
|
|
o := try db_req_obj(art_arr.items[i]);
|
|
repo.create_artifact(try db_artifact_from_json(o, oa));
|
|
i += 1;
|
|
}
|
|
|
|
chan_arr := try db_req_arr(ro, "channels");
|
|
i = 0;
|
|
while i < chan_arr.len {
|
|
o := try db_req_obj(chan_arr.items[i]);
|
|
repo.create_channel(try channel_from_json(o, oa));
|
|
i += 1;
|
|
}
|
|
|
|
ev_arr := try db_req_arr(ro, "audit_events");
|
|
i = 0;
|
|
while i < ev_arr.len {
|
|
o := try db_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;
|
|
}
|