sqlite persistence: the store moves from db.json to dist.db (P5.2)

src/repo/db.sx persists the whole Repo to <store>/dist.db through the
vendored SQLite bindings, keeping the load-whole/save-whole call shape.
One table per entity; enums as lowercase variant names; list order
round-trips via rowid. Enforced uniqueness: apps.slug,
channels(app_id, name), tokens.token_hash; lookup indexes on
releases(app_id) and artifacts(sha256) (non-unique - identical bytes
may ship in several releases). save is DELETE-all + INSERT-all inside
BEGIN IMMEDIATE...COMMIT with rollback on failure; every connection
sets busy_timeout so the CLI and a running distd interleave safely.

A store holding only a pre-SQLite db.json imports once on first load,
then the file is renamed db.json.imported; a store with neither starts
empty. Consumers gate on db.store_exists instead of probing db.json.
The JSON read-back stays for the import path; the entity->json writers
stay for distd's /api responses.

Tests that parsed db.json directly now assert by querying dist.db
through the SQLite bindings; tests/db_import.sx pins the import path;
tests/repo_roundtrip.sx pins the SQLite round-trip. make test 22/22.
This commit is contained in:
agra
2026-06-12 16:16:13 +03:00
parent 3747c40e90
commit a1f13c4356
21 changed files with 1168 additions and 487 deletions

View File

@@ -11,7 +11,7 @@
// policy the install page cannot honor.
//
// FAILURE CONTRACT (as everywhere): every abort happens before `db.save`,
// so a failed set never changes db.json.
// so a failed set never changes the store.
// =====================================================================
#import "modules/std.sx";
@@ -31,10 +31,10 @@ jout :: #import "../json_out.sx";
pl :: #import "../publish/publish.sx";
AppOpError :: error {
Load, // db.json absent or unreadable
Load, // store database absent or unreadable
NotFound, // no app with that slug
Invalid, // the requested policy fails validation
Persist, // db.json could not be re-written
Persist, // the store database could not be re-written
}
// What `dist app set` wants to change; empty string = leave unchanged.
@@ -69,15 +69,15 @@ app_invalid_message :: (e: ValidationErr) -> string {
}
run_app_set :: (store_dir: string, slug: string, req: AppSetRequest, fail_out: *jout.CliFailure) -> (AppSetOutcome, !AppOpError) {
if !exists(path_join(store_dir, "db.json")) {
if !db.store_exists(store_dir) {
fail_out.code = "store.load";
fail_out.message = concat("no db.json under the store (nothing published yet): ", store_dir);
fail_out.message = concat("no store database (nothing published yet): ", store_dir);
raise error.Load;
}
repo, le := db.load(store_dir);
if le {
fail_out.code = "store.load";
fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
fail_out.message = concat("the store database could not be loaded: ", store_dir);
raise error.Load;
}
@@ -143,7 +143,7 @@ run_app_set :: (store_dir: string, slug: string, req: AppSetRequest, fail_out: *
db.save(@repo, store_dir) catch { werr = true; };
if werr {
fail_out.code = "persist.save";
fail_out.message = concat("db.json could not be written under the store: ", store_dir);
fail_out.message = concat("the store database could not be written: ", store_dir);
raise error.Persist;
}

View File

@@ -52,7 +52,7 @@ emit_human :: (s: string, json_mode: bool) {
if json_mode { eputs(s); } else { out(s); }
}
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> publish into a local store directory, OR:\n --server <url> publish against a running distd (http://<ipv4-or-localhost>:<port>)\n --token <secret> bearer token for --server (mint with: dist token create)\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n GET (public): / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n POST (Bearer token, publish scope): /api/upload, /api/apps/<slug>/releases,\n /api/apps/<slug>/channels/<name>/promote, /api/apps/<slug>/channels/<name>/rollback\n app\n app set edit an existing app's display name / iOS install policy\n --app <slug> app to edit (apps are created by publish)\n --local-store <dir> local artifact store + db.json directory\n --display-name <s> new display name\n --ios-mode <m> artifact_only | testflight | enterprise\n --testflight-url <u> TestFlight link (required for testflight mode)\n --ios-bundle-id <id> iOS bundle identifier (required for enterprise mode)\n token\n token create mint a scoped automation token (secret shown ONCE)\n --name <name> token name, [a-z0-9._-]\n --local-store <dir> local artifact store + db.json directory\n --scope <words> space-separated scopes: publish read (default: publish)\n --app <slug> restrict to one app (default: any)\n --channel <name> restrict to one channel (default: any)\n --expires-in <secs> lifetime in seconds (default: never expires)\n token list tokens with lifecycle status (never the secret)\n --local-store <dir> local artifact store + db.json directory\n token revoke revoke a token by id\n --id <token-id> token to revoke\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback/token op aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> publish into a local store directory, OR:\n --server <url> publish against a running distd (http://<ipv4-or-localhost>:<port>)\n --token <secret> bearer token for --server (mint with: dist token create)\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + dist.db directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + dist.db directory\n server\n server run serve the store over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + dist.db directory\n --port <n> TCP port (default 8787)\n GET (public): / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n POST (Bearer token, publish scope): /api/upload, /api/apps/<slug>/releases,\n /api/apps/<slug>/channels/<name>/promote, /api/apps/<slug>/channels/<name>/rollback\n app\n app set edit an existing app's display name / iOS install policy\n --app <slug> app to edit (apps are created by publish)\n --local-store <dir> local artifact store + dist.db directory\n --display-name <s> new display name\n --ios-mode <m> artifact_only | testflight | enterprise\n --testflight-url <u> TestFlight link (required for testflight mode)\n --ios-bundle-id <id> iOS bundle identifier (required for enterprise mode)\n token\n token create mint a scoped automation token (secret shown ONCE)\n --name <name> token name, [a-z0-9._-]\n --local-store <dir> local artifact store + dist.db directory\n --scope <words> space-separated scopes: publish read (default: publish)\n --app <slug> restrict to one app (default: any)\n --channel <name> restrict to one channel (default: any)\n --expires-in <secs> lifetime in seconds (default: never expires)\n token list tokens with lifecycle status (never the secret)\n --local-store <dir> local artifact store + dist.db directory\n token revoke revoke a token by id\n --id <token-id> token to revoke\n --local-store <dir> local artifact store + dist.db directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback/token op aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
// True if `name` appears as a token in `args`.
has_flag :: (args: []string, name: string) -> bool {

View File

@@ -3,17 +3,17 @@
// Wires the prior modules into one end-to-end publish:
//
// manifest (P3.2) -> store (P2.2) -> common validation (P3.3) ->
// repository transaction + audit (P2.3) -> db.json persistence (P2.3)
// repository transaction + audit (P2.3) -> SQLite persistence (P5.2)
//
// `run_publish(manifest_path, store_dir)` validates the manifest, LOADS any
// prior `<store>/db.json` so separate invocations share state (a new version
// prior `<store>/dist.db` so separate invocations share state (a new version
// accumulates; a duplicate release id is rejected), finds or creates the app,
// drafts a release, content-addresses every artifact into
// `<store>/objects/<sha256>`, validates each stored file, commits the whole
// aggregate through the integrity-checked repo transaction (channel
// promotion included), records an audit event per upload / publish /
// promotion, persists the merged `<store>/db.json`, and returns a
// `PublishOutcome` the CLI renders as stable JSON or a human summary.
// promotion included), persists the merged model to `<store>/dist.db`, and
// returns a `PublishOutcome` the CLI renders as stable JSON or a human
// summary.
//
// DECLARED-vs-DERIVED EXPECTATIONS (PO ruling): a manifest artifact may
// DECLARE `size` / `sha256`; when it does, that value is the expectation the
@@ -24,9 +24,9 @@
//
// FAILURE CONTRACT (P3.4b): every abort happens BEFORE `db.save`, and the
// repo transaction rolls itself back, so a failed publish never changes
// db.json — no partially-published release, no moved channel pointer. Each
// raise site first writes a `jout.CliFailure` (stable dotted code + human
// message naming the offending input) for the CLI to report.
// the store — no partially-published release, no moved channel pointer.
// Each raise site first writes a `jout.CliFailure` (stable dotted code +
// human message naming the offending input) for the CLI to report.
//
// LOCAL DOWNLOAD URL FORM: `file://<abs-store>/objects/<sha256>`, where
// <abs-store> is the `--local-store` directory resolved to an absolute path
@@ -66,7 +66,7 @@ c_getcwd :: (buf: [*]u8, size: usize) -> *u8 #foreign cstd "getcwd";
// Store — an artifact's bytes could not be content-addressed.
// Validation — a stored artifact failed the common validation pass.
// Transaction — the repo's integrity-checked publish rejected the aggregate.
// Persist — db.json could not be loaded at startup or written at the end.
// Persist — the store database could not be loaded at startup or written at the end.
PublishError :: error {
Manifest,
Store,
@@ -269,19 +269,20 @@ commit_publish :: (store_dir: string, slug: string, version: string, channel_nam
now := now_secs();
// Seed the Repo from any prior state so separate invocations SHARE
// state through the store: a pre-existing `<store>/db.json` is loaded so
// state through the store: a pre-existing store database is loaded so
// find-or-create sees earlier apps and the integrity transaction sees
// earlier releases. A new version then ACCUMULATES (the app is found, not
// duplicated); re-publishing the SAME release id is rejected as a
// duplicate by the transaction. An absent db.json starts empty. The loaded
// model grows through its own owning allocator (`context.allocator`, the
// process-lifetime default), per the long-lived-container rule.
// duplicate by the transaction. A store with no database starts empty.
// The loaded model grows through its own owning allocator
// (`context.allocator`, the process-lifetime default), per the
// long-lived-container rule.
repo := Repo.init();
if exists(path_join(store_dir, "db.json")) {
if db.store_exists(store_dir) {
loaded, le := db.load(store_dir);
if le {
fail_out.code = "persist.load";
fail_out.message = concat("existing db.json under the store could not be loaded: ", store_dir);
fail_out.message = concat("the existing store database could not be loaded: ", store_dir);
raise error.Persist;
}
repo = loaded;
@@ -381,7 +382,7 @@ commit_publish :: (store_dir: string, slug: string, version: string, channel_nam
db.save(repo, store_dir) catch { persist_err = true; };
if persist_err {
fail_out.code = "persist.save";
fail_out.message = concat("db.json could not be written under the store: ", store_dir);
fail_out.message = concat("the store database could not be written: ", store_dir);
raise error.Persist;
}

View File

@@ -2,9 +2,9 @@
// ops.sx — standalone channel operations over the persisted store
// (subplan 03 / P3.5): `dist release promote` and `dist release rollback`.
//
// Both load `<store>/db.json`, mutate ONE channel pointer, append an audit
// event, and re-persist. They are the human counterpart to the CI publish:
// CI writes releases; a release manager moves channel pointers.
// Both load the persisted store, mutate ONE channel pointer, append an
// audit event, and re-persist. They are the human counterpart to the CI
// publish: CI writes releases; a release manager moves channel pointers.
//
// PROMOTE points an (app, channel) at a given release id. The release must
// exist and belong to the app; it does NOT have to target that channel —
@@ -22,7 +22,7 @@
// release — or with no lineage at all — there is nothing to roll back to.
//
// FAILURE CONTRACT (mirrors P3.4b): every abort happens before `db.save`,
// so a failed operation never changes db.json. Each raise site first
// so a failed operation never changes the store. Each raise site first
// writes a `jout.CliFailure` (stable dotted code + human message).
// =====================================================================
@@ -43,12 +43,12 @@ pl :: #import "../publish/publish.sx";
// Failure classes for a channel operation. The precise reason travels in
// the caller's `jout.CliFailure` (see the failure contract above).
// Load — db.json absent or unreadable (no publishable state).
// Load — store database absent or unreadable (no publishable state).
// NotFound — the named app / release / channel does not exist.
// Invalid — the aggregate is inconsistent (release of another app,
// channel that fails domain validation, nothing to roll
// back to).
// Persist — db.json could not be re-written.
// Persist — the store database could not be re-written.
OpError :: error {
Load,
NotFound,
@@ -75,17 +75,17 @@ RollbackOutcome :: struct {
// ── shared steps ──────────────────────────────────────────────────────
// Load the persisted model, or fail with `store.load` when the store has
// no db.json (nothing was ever published there).
// no database (nothing was ever published there).
op_load_repo :: (store_dir: string, fail_out: *jout.CliFailure) -> (Repo, !OpError) {
if !exists(path_join(store_dir, "db.json")) {
if !db.store_exists(store_dir) {
fail_out.code = "store.load";
fail_out.message = concat("no db.json under the store (nothing published yet): ", store_dir);
fail_out.message = concat("no store database (nothing published yet): ", store_dir);
raise error.Load;
}
loaded, le := db.load(store_dir);
if le {
fail_out.code = "store.load";
fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
fail_out.message = concat("the store database could not be loaded: ", store_dir);
raise error.Load;
}
return loaded;
@@ -106,7 +106,7 @@ op_save :: (repo: *Repo, store_dir: string, fail_out: *jout.CliFailure) -> !OpEr
db.save(repo, store_dir) catch { werr = true; };
if werr {
fail_out.code = "persist.save";
fail_out.message = concat("db.json could not be written under the store: ", store_dir);
fail_out.message = concat("the store database could not be written: ", store_dir);
raise error.Persist;
}
return;

View File

@@ -1,23 +1,35 @@
// =====================================================================
// db.sx — whole-model persistence to `<root>/db.json` via `std.json`
// (the SQLite stand-in for subplan 02, Slice 1).
// db.sx — whole-model persistence to `<root>/dist.db`, the vendored
// SQLite database (subplan 02, Slice 2).
//
// 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,
// tokens, audit_events. Re-saving an unchanged model yields byte-identical
// output.
// CALL SHAPE: `save` writes the entire Repo, `load` reads one back —
// the same whole-model contract the db.json layer had, so the publish
// pipeline, the ops modules, and distd stay storage-agnostic. `save`
// wraps DELETE-all + INSERT-all in BEGIN IMMEDIATE…COMMIT and rolls
// back on any failure, so a failed save never leaves partial state.
// Every connection sets `busy_timeout`, so a CLI invocation and a
// running distd can interleave on one store without spurious failures.
//
// Enums serialize as their lowercase variant NAME (e.g. "android_apk",
// "percentage"), never an ordinal — readable and reorder-proof.
// SCHEMA: one table per entity (apps + app_bundle_ids, releases,
// artifacts, channels, tokens, audit_events), columns in struct
// declaration order; enums persist as their lowercase variant NAME
// (readable and reorder-proof). Uniqueness the domain guarantees is
// enforced: apps.slug, channels(app_id, name), tokens.token_hash.
// Lookup indexes: releases(app_id) and artifacts(sha256) — the digest
// index is NON-unique because identical bytes may ship in several
// releases (find_artifact_by_digest answers the first match). Audit
// event ids are NOT unique (re-promoting a channel reuses its event
// id), so audit_events carries no constraints. List order round-trips
// through rowid: save inserts in list order into emptied tables, load
// reads ORDER BY rowid.
//
// 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: a store with a `db.json` (the pre-SQLite layout) and no
// `dist.db` is imported ONCE on first load, then the file is renamed
// `db.json.imported` so SQLite stays authoritative. The JSON read-back
// half below exists for that path and keeps its strictness: a missing
// field, a wrong JSON type, or an unknown enum name is a typed
// `LoadErr.BadShape`, never a silent default. The entity -> json
// writers (app/release/channel) serve distd's /api responses.
// =====================================================================
#import "modules/std.sx";
@@ -27,6 +39,7 @@
// one program (the `dist` CLI), which returns a different type.
jsonp :: #import "modules/std/json.sx";
#import "modules/std/fs.sx";
#import "../db/sqlite.sx";
#import "../domain/platform.sx";
#import "../domain/app.sx";
#import "../domain/release.sx";
@@ -37,16 +50,26 @@ jsonp :: #import "modules/std/json.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).
// Persistence failure classes. `Io` = the store database could not be
// opened/read/written; `Parse` = an imported db.json was not valid JSON;
// `BadShape` = stored content that doesn't match the model (a missing or
// wrong-typed db.json field, or an unknown enum name).
LoadErr :: error {
Io,
Parse,
BadShape,
}
// The SQLite database under the store root.
DB_FILE :: "dist.db";
// True when the store has persisted state: a `dist.db`, or a pre-SQLite
// `db.json` that the next load will import.
store_exists :: (root_dir: string) -> bool {
if exists(path_join(root_dir, DB_FILE)) { return true; }
return exists(path_join(root_dir, "db.json"));
}
// ── enum -> stable variant name ──────────────────────────────────────
visibility_str :: (v: Visibility) -> string {
if v == .private { return "private"; }
@@ -104,7 +127,479 @@ parse_status :: (s: string) -> (ValidationStatus, !LoadErr) {
raise error.BadShape;
}
// ── SQLite: connection + schema ───────────────────────────────────────
db_ensure_schema :: (conn: *Sqlite) -> bool {
ok := true;
conn.exec("CREATE TABLE IF NOT EXISTS apps (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, owner TEXT NOT NULL, visibility TEXT NOT NULL, ios_mode TEXT NOT NULL, testflight_url TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)") catch { ok = false; };
if ok { conn.exec("CREATE TABLE IF NOT EXISTS app_bundle_ids (app_id TEXT NOT NULL, platform TEXT NOT NULL, value TEXT NOT NULL, UNIQUE (app_id, platform))") catch { ok = false; }; }
if ok { conn.exec("CREATE TABLE IF NOT EXISTS releases (id TEXT PRIMARY KEY, app_id TEXT NOT NULL, version TEXT NOT NULL, build INTEGER NOT NULL, channel TEXT NOT NULL, notes TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL, published_at INTEGER NOT NULL)") catch { ok = false; }; }
if ok { conn.exec("CREATE INDEX IF NOT EXISTS idx_releases_app_id ON releases (app_id)") catch { ok = false; }; }
if ok { conn.exec("CREATE TABLE IF NOT EXISTS artifacts (id TEXT NOT NULL, app_id TEXT NOT NULL, release_id TEXT NOT NULL, platform TEXT NOT NULL, filename TEXT NOT NULL, content_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, sha256 TEXT NOT NULL, storage_key TEXT NOT NULL, metadata TEXT NOT NULL, validation_status TEXT NOT NULL)") catch { ok = false; }; }
if ok { conn.exec("CREATE INDEX IF NOT EXISTS idx_artifacts_sha256 ON artifacts (sha256)") catch { ok = false; }; }
if ok { conn.exec("CREATE TABLE IF NOT EXISTS channels (app_id TEXT NOT NULL, name TEXT NOT NULL, current_release_id TEXT NOT NULL, policy TEXT NOT NULL, rollout_percent INTEGER NOT NULL, UNIQUE (app_id, name))") catch { ok = false; }; }
if ok { conn.exec("CREATE TABLE IF NOT EXISTS tokens (id TEXT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, scopes TEXT NOT NULL, app_slug TEXT NOT NULL, channel TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL, revoked_at INTEGER NOT NULL)") catch { ok = false; }; }
if ok { conn.exec("CREATE TABLE IF NOT EXISTS audit_events (id TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, target_type TEXT NOT NULL, target_id TEXT NOT NULL, metadata TEXT NOT NULL, created_at INTEGER NOT NULL)") catch { ok = false; }; }
return ok;
}
db_connect :: (root_dir: string, create: bool) -> (Sqlite, !LoadErr) {
flags : i32 = SQLITE_OPEN_READWRITE;
if create { flags = flags | SQLITE_OPEN_CREATE; }
conn, oe := Sqlite.open_v2(path_join(root_dir, DB_FILE), flags);
if oe { raise error.Io; }
conn.busy_timeout(5000);
if !db_ensure_schema(@conn) { conn.close(); raise error.Io; }
return conn;
}
// ── SQLite: model -> rows (insertion order = list order) ─────────────
db_write_apps :: (repo: *Repo, conn: *Sqlite) -> bool {
st, pe := conn.prepare("INSERT INTO apps (id, slug, display_name, owner, visibility, ios_mode, testflight_url, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)");
if pe { return false; }
bst, bpe := conn.prepare("INSERT INTO app_bundle_ids (app_id, platform, value) VALUES (?1, ?2, ?3)");
if bpe { st.finalize(); return false; }
ok := true;
i := 0;
while ok and i < repo.apps.len {
a := repo.apps.items[i];
st.bind_text(1, a.id) catch { ok = false; };
st.bind_text(2, a.slug) catch { ok = false; };
st.bind_text(3, a.display_name) catch { ok = false; };
st.bind_text(4, a.owner) catch { ok = false; };
st.bind_text(5, visibility_str(a.visibility)) catch { ok = false; };
st.bind_text(6, ios_mode_str(a.ios_mode)) catch { ok = false; };
st.bind_text(7, a.testflight_url) catch { ok = false; };
st.bind_int64(8, a.created_at) catch { ok = false; };
st.bind_int64(9, a.updated_at) catch { ok = false; };
if ok {
rc, se := st.step();
if se { ok = false; }
if rc != SQLITE_DONE { ok = false; }
}
st.reset();
j := 0;
while ok and j < a.bundle_ids.len {
b := a.bundle_ids.items[j];
bst.bind_text(1, a.id) catch { ok = false; };
bst.bind_text(2, platform_str(b.platform)) catch { ok = false; };
bst.bind_text(3, b.value) catch { ok = false; };
if ok {
brc, bse := bst.step();
if bse { ok = false; }
if brc != SQLITE_DONE { ok = false; }
}
bst.reset();
j += 1;
}
i += 1;
}
st.finalize();
bst.finalize();
return ok;
}
db_write_releases :: (repo: *Repo, conn: *Sqlite) -> bool {
st, pe := conn.prepare("INSERT INTO releases (id, app_id, version, build, channel, notes, created_by, created_at, published_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)");
if pe { return false; }
ok := true;
i := 0;
while ok and i < repo.releases.len {
r := repo.releases.items[i];
st.bind_text(1, r.id) catch { ok = false; };
st.bind_text(2, r.app_id) catch { ok = false; };
st.bind_text(3, r.version) catch { ok = false; };
st.bind_int64(4, r.build) catch { ok = false; };
st.bind_text(5, r.channel) catch { ok = false; };
st.bind_text(6, r.notes) catch { ok = false; };
st.bind_text(7, r.created_by) catch { ok = false; };
st.bind_int64(8, r.created_at) catch { ok = false; };
st.bind_int64(9, r.published_at) catch { ok = false; };
if ok {
rc, se := st.step();
if se { ok = false; }
if rc != SQLITE_DONE { ok = false; }
}
st.reset();
i += 1;
}
st.finalize();
return ok;
}
db_write_artifacts :: (repo: *Repo, conn: *Sqlite) -> bool {
st, pe := conn.prepare("INSERT INTO artifacts (id, app_id, release_id, platform, filename, content_type, size_bytes, sha256, storage_key, metadata, validation_status) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)");
if pe { return false; }
ok := true;
i := 0;
while ok and i < repo.artifacts.len {
a := repo.artifacts.items[i];
st.bind_text(1, a.id) catch { ok = false; };
st.bind_text(2, a.app_id) catch { ok = false; };
st.bind_text(3, a.release_id) catch { ok = false; };
st.bind_text(4, platform_str(a.platform)) catch { ok = false; };
st.bind_text(5, a.filename) catch { ok = false; };
st.bind_text(6, a.content_type) catch { ok = false; };
st.bind_int64(7, a.size_bytes) catch { ok = false; };
st.bind_text(8, a.sha256) catch { ok = false; };
st.bind_text(9, a.storage_key) catch { ok = false; };
st.bind_text(10, a.metadata) catch { ok = false; };
st.bind_text(11, status_str(a.validation_status)) catch { ok = false; };
if ok {
rc, se := st.step();
if se { ok = false; }
if rc != SQLITE_DONE { ok = false; }
}
st.reset();
i += 1;
}
st.finalize();
return ok;
}
db_write_channels :: (repo: *Repo, conn: *Sqlite) -> bool {
st, pe := conn.prepare("INSERT INTO channels (app_id, name, current_release_id, policy, rollout_percent) VALUES (?1, ?2, ?3, ?4, ?5)");
if pe { return false; }
ok := true;
i := 0;
while ok and i < repo.channels.len {
c := repo.channels.items[i];
st.bind_text(1, c.app_id) catch { ok = false; };
st.bind_text(2, c.name) catch { ok = false; };
st.bind_text(3, c.current_release_id) catch { ok = false; };
st.bind_text(4, policy_str(c.policy)) catch { ok = false; };
st.bind_int64(5, c.rollout_percent) catch { ok = false; };
if ok {
rc, se := st.step();
if se { ok = false; }
if rc != SQLITE_DONE { ok = false; }
}
st.reset();
i += 1;
}
st.finalize();
return ok;
}
db_write_tokens :: (repo: *Repo, conn: *Sqlite) -> bool {
st, pe := conn.prepare("INSERT INTO tokens (id, name, token_hash, scopes, app_slug, channel, created_at, expires_at, last_used_at, revoked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)");
if pe { return false; }
ok := true;
i := 0;
while ok and i < repo.tokens.len {
t := repo.tokens.items[i];
st.bind_text(1, t.id) catch { ok = false; };
st.bind_text(2, t.name) catch { ok = false; };
st.bind_text(3, t.token_hash) catch { ok = false; };
st.bind_text(4, t.scopes) catch { ok = false; };
st.bind_text(5, t.app_slug) catch { ok = false; };
st.bind_text(6, t.channel) catch { ok = false; };
st.bind_int64(7, t.created_at) catch { ok = false; };
st.bind_int64(8, t.expires_at) catch { ok = false; };
st.bind_int64(9, t.last_used_at) catch { ok = false; };
st.bind_int64(10, t.revoked_at) catch { ok = false; };
if ok {
rc, se := st.step();
if se { ok = false; }
if rc != SQLITE_DONE { ok = false; }
}
st.reset();
i += 1;
}
st.finalize();
return ok;
}
db_write_audit :: (repo: *Repo, conn: *Sqlite) -> bool {
st, pe := conn.prepare("INSERT INTO audit_events (id, actor, action, target_type, target_id, metadata, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)");
if pe { return false; }
ok := true;
i := 0;
while ok and i < repo.audit_events.len {
e := repo.audit_events.items[i];
st.bind_text(1, e.id) catch { ok = false; };
st.bind_text(2, e.actor) catch { ok = false; };
st.bind_text(3, e.action) catch { ok = false; };
st.bind_text(4, e.target_type) catch { ok = false; };
st.bind_text(5, e.target_id) catch { ok = false; };
st.bind_text(6, e.metadata) catch { ok = false; };
st.bind_int64(7, e.created_at) catch { ok = false; };
if ok {
rc, se := st.step();
if se { ok = false; }
if rc != SQLITE_DONE { ok = false; }
}
st.reset();
i += 1;
}
st.finalize();
return ok;
}
db_write_model :: (repo: *Repo, conn: *Sqlite) -> bool {
ok := true;
conn.exec("BEGIN IMMEDIATE") catch { ok = false; };
if !ok { return false; }
conn.exec("DELETE FROM apps; DELETE FROM app_bundle_ids; DELETE FROM releases; DELETE FROM artifacts; DELETE FROM channels; DELETE FROM tokens; DELETE FROM audit_events") catch { ok = false; };
if ok { ok = db_write_apps(repo, conn); }
if ok { ok = db_write_releases(repo, conn); }
if ok { ok = db_write_artifacts(repo, conn); }
if ok { ok = db_write_channels(repo, conn); }
if ok { ok = db_write_tokens(repo, conn); }
if ok { ok = db_write_audit(repo, conn); }
if ok { conn.exec("COMMIT") catch { ok = false; }; }
if !ok { conn.exec("ROLLBACK") catch {}; }
return ok;
}
// Persist the whole repo to `<root>/dist.db` in one IMMEDIATE
// transaction (rolled back on any failure).
save :: (self: *Repo, root_dir: string) -> !LoadErr {
if !create_dir_all(root_dir) { raise error.Io; }
conn := try db_connect(root_dir, true);
ok := db_write_model(self, @conn);
conn.close();
if !ok { raise error.Io; }
return;
}
// ── SQLite: rows -> model (ORDER BY rowid = original list order) ─────
db_read_apps :: (repo: *Repo, conn: *Sqlite) -> !LoadErr {
oa := repo.own_allocator;
st, pe := conn.prepare("SELECT id, slug, display_name, owner, visibility, ios_mode, testflight_url, created_at, updated_at FROM apps ORDER BY rowid");
if pe { raise error.Io; }
bst, bpe := conn.prepare("SELECT platform, value FROM app_bundle_ids WHERE app_id = ?1 ORDER BY rowid");
if bpe { st.finalize(); raise error.Io; }
io_bad := false;
shape_bad := false;
while true {
rc, se := st.step();
if se { io_bad = true; break; }
if rc != SQLITE_ROW { break; }
a : App = .{};
a.id = st.column_text(0);
a.slug = st.column_text(1);
a.display_name = st.column_text(2);
a.owner = st.column_text(3);
vis, vise := parse_visibility(st.column_text(4));
if vise { shape_bad = true; break; }
a.visibility = vis;
im, ime := ios_mode_from(st.column_text(5));
if ime { shape_bad = true; break; }
a.ios_mode = im;
a.testflight_url = st.column_text(6);
a.created_at = st.column_int64(7);
a.updated_at = st.column_int64(8);
bst.bind_text(1, a.id) catch { io_bad = true; };
if io_bad { break; }
while true {
brc, bse := bst.step();
if bse { io_bad = true; break; }
if brc != SQLITE_ROW { break; }
p, bpe2 := platform_from(bst.column_text(0));
if bpe2 { shape_bad = true; break; }
a.bundle_ids.append(BundleId.{ platform = p, value = bst.column_text(1) }, oa);
}
bst.reset();
if io_bad or shape_bad { break; }
repo.create_app(a);
}
st.finalize();
bst.finalize();
if shape_bad { raise error.BadShape; }
if io_bad { raise error.Io; }
return;
}
db_read_releases :: (repo: *Repo, conn: *Sqlite) -> !LoadErr {
st, pe := conn.prepare("SELECT id, app_id, version, build, channel, notes, created_by, created_at, published_at FROM releases ORDER BY rowid");
if pe { raise error.Io; }
io_bad := false;
while true {
rc, se := st.step();
if se { io_bad = true; break; }
if rc != SQLITE_ROW { break; }
r : Release = .{};
r.id = st.column_text(0);
r.app_id = st.column_text(1);
r.version = st.column_text(2);
r.build = st.column_int64(3);
r.channel = st.column_text(4);
r.notes = st.column_text(5);
r.created_by = st.column_text(6);
r.created_at = st.column_int64(7);
r.published_at = st.column_int64(8);
repo.create_release(r);
}
st.finalize();
if io_bad { raise error.Io; }
return;
}
db_read_artifacts :: (repo: *Repo, conn: *Sqlite) -> !LoadErr {
st, pe := conn.prepare("SELECT id, app_id, release_id, platform, filename, content_type, size_bytes, sha256, storage_key, metadata, validation_status FROM artifacts ORDER BY rowid");
if pe { raise error.Io; }
io_bad := false;
shape_bad := false;
while true {
rc, se := st.step();
if se { io_bad = true; break; }
if rc != SQLITE_ROW { break; }
a : Artifact = .{};
a.id = st.column_text(0);
a.app_id = st.column_text(1);
a.release_id = st.column_text(2);
p, pfe := platform_from(st.column_text(3));
if pfe { shape_bad = true; break; }
a.platform = p;
a.filename = st.column_text(4);
a.content_type = st.column_text(5);
a.size_bytes = st.column_int64(6);
a.sha256 = st.column_text(7);
a.storage_key = st.column_text(8);
a.metadata = st.column_text(9);
vst, vse := parse_status(st.column_text(10));
if vse { shape_bad = true; break; }
a.validation_status = vst;
repo.create_artifact(a);
}
st.finalize();
if shape_bad { raise error.BadShape; }
if io_bad { raise error.Io; }
return;
}
db_read_channels :: (repo: *Repo, conn: *Sqlite) -> !LoadErr {
st, pe := conn.prepare("SELECT app_id, name, current_release_id, policy, rollout_percent FROM channels ORDER BY rowid");
if pe { raise error.Io; }
io_bad := false;
shape_bad := false;
while true {
rc, se := st.step();
if se { io_bad = true; break; }
if rc != SQLITE_ROW { break; }
c : Channel = .{};
c.app_id = st.column_text(0);
c.name = st.column_text(1);
c.current_release_id = st.column_text(2);
pol, ple := parse_policy(st.column_text(3));
if ple { shape_bad = true; break; }
c.policy = pol;
c.rollout_percent = st.column_int64(4);
repo.create_channel(c);
}
st.finalize();
if shape_bad { raise error.BadShape; }
if io_bad { raise error.Io; }
return;
}
db_read_tokens :: (repo: *Repo, conn: *Sqlite) -> !LoadErr {
st, pe := conn.prepare("SELECT id, name, token_hash, scopes, app_slug, channel, created_at, expires_at, last_used_at, revoked_at FROM tokens ORDER BY rowid");
if pe { raise error.Io; }
io_bad := false;
while true {
rc, se := st.step();
if se { io_bad = true; break; }
if rc != SQLITE_ROW { break; }
t : Token = .{};
t.id = st.column_text(0);
t.name = st.column_text(1);
t.token_hash = st.column_text(2);
t.scopes = st.column_text(3);
t.app_slug = st.column_text(4);
t.channel = st.column_text(5);
t.created_at = st.column_int64(6);
t.expires_at = st.column_int64(7);
t.last_used_at = st.column_int64(8);
t.revoked_at = st.column_int64(9);
repo.create_token(t);
}
st.finalize();
if io_bad { raise error.Io; }
return;
}
db_read_audit :: (repo: *Repo, conn: *Sqlite) -> !LoadErr {
st, pe := conn.prepare("SELECT id, actor, action, target_type, target_id, metadata, created_at FROM audit_events ORDER BY rowid");
if pe { raise error.Io; }
io_bad := false;
while true {
rc, se := st.step();
if se { io_bad = true; break; }
if rc != SQLITE_ROW { break; }
e : AuditEvent = .{};
e.id = st.column_text(0);
e.actor = st.column_text(1);
e.action = st.column_text(2);
e.target_type = st.column_text(3);
e.target_id = st.column_text(4);
e.metadata = st.column_text(5);
e.created_at = st.column_int64(6);
repo.create_audit_event(e);
}
st.finalize();
if io_bad { raise error.Io; }
return;
}
db_read_model :: (repo: *Repo, conn: *Sqlite) -> !LoadErr {
try db_read_apps(repo, conn);
try db_read_releases(repo, conn);
try db_read_artifacts(repo, conn);
try db_read_channels(repo, conn);
try db_read_tokens(repo, conn);
try db_read_audit(repo, conn);
return;
}
// One-time migration of a pre-SQLite store: parse `<root>/db.json` into
// a transient Repo, persist it as `<root>/dist.db`, and rename the JSON
// file to `db.json.imported`.
import_db_json :: (root_dir: string) -> !LoadErr {
jpath := path_join(root_dir, "db.json");
src := read_file(jpath);
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);
try save(@repo, root_dir);
if !move(jpath, concat(jpath, ".imported")) { raise error.Io; }
return;
}
// Load `<root>/dist.db` into a FRESH repository (importing a pre-SQLite
// `db.json` first when no dist.db exists yet). The new repo captures the
// active `context.allocator` as its owning allocator; column reads copy
// into that same allocator, so the result is independent of SQLite's
// internal buffers (dead once the connection closes).
load :: (root_dir: string) -> (Repo, !LoadErr) {
if !exists(path_join(root_dir, DB_FILE)) {
if !exists(path_join(root_dir, "db.json")) { raise error.Io; }
try import_db_json(root_dir);
}
conn := try db_connect(root_dir, false);
repo := Repo.init();
lerr := false;
shape := false;
db_read_model(@repo, @conn) catch (e) { lerr = true; shape = (e == error.BadShape); };
conn.close();
if shape { raise error.BadShape; }
if lerr { raise error.Io; }
return repo;
}
// ── serialize: entity -> json Value (declaration field order) ────────
// These serve distd's /api responses (apps/releases/channels render
// through the same shape db.json used).
app_to_json :: (a: App, alloc: Allocator) -> Value {
o : Object = .{};
o.put("id", .str(a.id), alloc);
@@ -144,22 +639,6 @@ release_to_json :: (r: Release, alloc: Allocator) -> Value {
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);
@@ -170,95 +649,6 @@ channel_to_json :: (c: Channel, alloc: Allocator) -> Value {
return .object(o);
}
token_to_json :: (t: Token, alloc: Allocator) -> Value {
o : Object = .{};
o.put("id", .str(t.id), alloc);
o.put("name", .str(t.name), alloc);
o.put("token_hash", .str(t.token_hash), alloc);
o.put("scopes", .str(t.scopes), alloc);
o.put("app_slug", .str(t.app_slug), alloc);
o.put("channel", .str(t.channel), alloc);
o.put("created_at", .int_(t.created_at), alloc);
o.put("expires_at", .int_(t.expires_at), alloc);
o.put("last_used_at", .int_(t.last_used_at), alloc);
o.put("revoked_at", .int_(t.revoked_at), 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);
toks : Array = .{};
i = 0;
while i < self.tokens.len { toks.add(token_to_json(self.tokens.items[i], alloc), alloc); i += 1; }
root.put("tokens", .array(toks), 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` /
@@ -430,8 +820,9 @@ audit_from_json :: (o: Object, alloc: Allocator) -> (AuditEvent, !LoadErr) {
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.
// Parse `bytes` (the pre-SQLite db.json layout) 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;
@@ -496,22 +887,3 @@ load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr {
}
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;
}

View File

@@ -1,7 +1,8 @@
// =====================================================================
// 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).
// HashMap; the persistence layer's SQLite indexes (db.sx) don't change
// that, since a Repo is always loaded whole.
//
// LONG-LIVED ALLOCATOR (binding, project CLAUDE.md): a Repo OUTLIVES the
// transient `context.allocator` of any single CLI call. Its `List`

View File

@@ -12,7 +12,7 @@
// 403 Forbidden — the token exists but `check_token` refuses it; the
// code names the refusal (auth.revoked / auth.expired /
// auth.missing_scope / auth.app_forbidden / auth.channel_forbidden).
// 503 Unavailable — db.json exists but could not be loaded.
// 503 Unavailable — the store database exists but could not be loaded.
//
// The presented secret is re-hashed (`digest_of_bytes`, the store's
// SHA-256) and matched against hashes at rest — the secret itself is never
@@ -94,7 +94,7 @@ authenticate :: (store_dir: string, headers: string, scope: string, app_slug: st
}
presented_hash := digest_of_bytes(sq!);
if !exists(path_join(store_dir, "db.json")) {
if !db.store_exists(store_dir) {
fail_out.code = "auth.unknown_token";
fail_out.message = "unknown token";
raise error.Unauthorized;
@@ -102,7 +102,7 @@ authenticate :: (store_dir: string, headers: string, scope: string, app_slug: st
repo, le := db.load(store_dir);
if le {
fail_out.code = "store.load";
fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
fail_out.message = concat("the store database could not be loaded: ", store_dir);
raise error.Unavailable;
}

View File

@@ -2,9 +2,9 @@
// distd.sx — the distribution server over the local store (subplan 04,
// Slices 1-4), run as `dist server run`.
//
// Serves the state the CLI publishes — db.json metadata and the
// content-addressed objects — over HTTP (src/server/http.sx). Reads are
// public:
// Serves the state the CLI publishes — the store database's metadata and
// the content-addressed objects — over HTTP (src/server/http.sx). Reads
// are public:
//
// GET / HTML index: apps, channels, releases, links
// GET /healthz {"status":"ok"} — no store access
@@ -36,10 +36,10 @@
// (`{"status":"error","error":{code,message}}`) with the matching HTTP
// status — the API and the CLI report failures identically.
//
// FRESHNESS: db.json is RELOADED on every /api request, so a `dist ci
// publish` / `release promote` between requests is visible immediately —
// the store on disk stays the single source of truth (no cache to
// invalidate, LAN-scale traffic).
// FRESHNESS: the store database is RELOADED on every /api request, so a
// `dist ci publish` / `release promote` between requests is visible
// immediately — the store on disk stays the single source of truth (no
// cache to invalidate, LAN-scale traffic).
//
// RESPONSE BUFFERS are heap slices from the per-request arena, never big
// stack arrays: a stack array of 64K+ in one frame crashes the sx LLVM
@@ -138,17 +138,17 @@ respond_error :: (client: i32, code: i64, fail_code: string, fail_message: strin
// ── /api renders (builders own the `try`, callers catch) ─────────────
// Reload the persisted model. Null means the store has no readable
// db.json — the 503 error response has already been sent.
// database — the 503 error response has already been sent.
load_or_503 :: (client: i32, store_dir: string) -> ?Repo {
if !exists(path_join(store_dir, "db.json")) {
if !db.store_exists(store_dir) {
respond_error(client, 503, "store.load",
concat("no db.json under the store (nothing published yet): ", store_dir));
concat("no store database (nothing published yet): ", store_dir));
return null;
}
loaded, le := db.load(store_dir);
if le {
respond_error(client, 503, "store.load",
concat("db.json under the store could not be loaded: ", store_dir));
concat("the store database could not be loaded: ", store_dir));
return null;
}
return loaded;

View File

@@ -13,12 +13,12 @@
// domain's `check_token` (revocation, expiry, scope, app/channel match)
// and stamps usage via `mark_token_used`.
//
// STORE STATE: `create` and `list` treat an absent db.json as an empty
// store — CI tokens are minted BEFORE the first publish creates any state.
// A present-but-unreadable db.json is still a loud Load failure.
// STORE STATE: `create` and `list` treat a store with no database as an
// empty store — CI tokens are minted BEFORE the first publish creates any
// state. A present-but-unreadable database is still a loud Load failure.
//
// FAILURE CONTRACT (as everywhere): every abort happens before `db.save`,
// so a failed operation never changes db.json. Each raise site first
// so a failed operation never changes the store. Each raise site first
// writes a `jout.CliFailure` (stable dotted code + human message).
// =====================================================================
@@ -44,11 +44,11 @@ arc4random_buf :: (buf: [*]u8, n: usize) #foreign tokc "arc4random_buf";
// Failure classes for a token operation; the precise reason travels in the
// caller's `jout.CliFailure`.
// Load — db.json exists but could not be loaded.
// Load — the store database exists but could not be loaded.
// NotFound — no token with the given id.
// Invalid — the token fails domain validation, or the operation is
// meaningless (revoking an already-revoked token).
// Persist — db.json could not be re-written.
// Persist — the store database could not be re-written.
TokOpError :: error {
Load,
NotFound,
@@ -81,16 +81,16 @@ TokenRevokeOutcome :: struct {
// ── shared steps ──────────────────────────────────────────────────────
// Load the persisted model when one exists; start empty otherwise (a fresh
// store is a valid place to mint the first token). Only a PRESENT db.json
// that fails to load is an error.
// store is a valid place to mint the first token). Only a PRESENT store
// database that fails to load is an error.
tok_load_or_empty :: (store_dir: string, fail_out: *jout.CliFailure) -> (Repo, !TokOpError) {
if !exists(path_join(store_dir, "db.json")) {
if !db.store_exists(store_dir) {
return Repo.init();
}
loaded, le := db.load(store_dir);
if le {
fail_out.code = "store.load";
fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
fail_out.message = concat("the store database could not be loaded: ", store_dir);
raise error.Load;
}
return loaded;
@@ -101,7 +101,7 @@ tok_save :: (repo: *Repo, store_dir: string, fail_out: *jout.CliFailure) -> !Tok
db.save(repo, store_dir) catch { werr = true; };
if werr {
fail_out.code = "persist.save";
fail_out.message = concat("db.json could not be written under the store: ", store_dir);
fail_out.message = concat("the store database could not be written: ", store_dir);
raise error.Persist;
}
return;