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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
664
src/repo/db.sx
664
src/repo/db.sx
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user