// ===================================================================== // db.sx — whole-model persistence to `/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 `/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 `/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; }