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;

147
tests/db_import.sx Normal file
View File

@@ -0,0 +1,147 @@
// Pinned acceptance for P5.2 — one-time import of a pre-SQLite store.
//
// A store laid down by the db.json era (no dist.db) must keep working:
// the FIRST load imports every entity into `<store>/dist.db` and renames
// the JSON file to `db.json.imported`, after which SQLite is the only
// authority. Drives the BUILT `build/dist` binary:
//
// 1. A store holding ONLY an old-layout db.json (app + bundle id, two
// releases, artifact, channel, token, audit event) answers
// `token list` with the minted token → the import ran: dist.db
// exists, db.json is GONE, db.json.imported holds the original.
// 2. Every entity survived the import field-for-field where it counts
// (queried from dist.db via the SQLite bindings).
// 3. A follow-up write op (`release promote`) works against the
// imported store and does NOT re-import (db.json.imported stays).
// 4. A store with NO database at all refuses ops that need one
// (`release promote` → exit 1, store.load).
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
sq :: #import "../src/db/sqlite.sx";
STORE :: ".sx-tmp/db_import";
EMPTY :: ".sx-tmp/db_import_empty";
// An old-layout db.json covering every persisted table.
OLD_DB :: "{\"apps\":[{\"id\":\"app-old\",\"slug\":\"old-app\",\"display_name\":\"Old App\",\"bundle_ids\":[{\"platform\":\"ios\",\"value\":\"co.old.app\"}],\"owner\":\"ci\",\"visibility\":\"private\",\"created_at\":1700000000,\"updated_at\":1700000000}],\"releases\":[{\"id\":\"rel-1\",\"app_id\":\"app-old\",\"version\":\"1.0.0\",\"build\":1,\"channel\":\"beta\",\"notes\":\"\",\"created_by\":\"ci\",\"created_at\":1700000100,\"published_at\":1700000100},{\"id\":\"rel-2\",\"app_id\":\"app-old\",\"version\":\"1.0.1\",\"build\":2,\"channel\":\"beta\",\"notes\":\"\",\"created_by\":\"ci\",\"created_at\":1700000200,\"published_at\":1700000200}],\"artifacts\":[{\"id\":\"rel-1-android_apk\",\"app_id\":\"app-old\",\"release_id\":\"rel-1\",\"platform\":\"android_apk\",\"filename\":\"old.apk\",\"content_type\":\"application/vnd.android.package-archive\",\"size_bytes\":5,\"sha256\":\"55a008aa634d45313ef0a758624e0d2a356c156e507f28a2c60d19d38893af09\",\"storage_key\":\"55a008aa634d45313ef0a758624e0d2a356c156e507f28a2c60d19d38893af09\",\"metadata\":\"\",\"validation_status\":\"valid\"}],\"channels\":[{\"app_id\":\"app-old\",\"name\":\"beta\",\"current_release_id\":\"rel-2\",\"policy\":\"manual\",\"rollout_percent\":100}],\"tokens\":[{\"id\":\"tok-abcdefabcdef\",\"name\":\"legacy-ci\",\"token_hash\":\"55a008aa634d45313ef0a758624e0d2a356c156e507f28a2c60d19d38893af09\",\"scopes\":\"publish\",\"app_slug\":\"\",\"channel\":\"\",\"created_at\":1700000300,\"expires_at\":0,\"last_used_at\":0,\"revoked_at\":0}],\"audit_events\":[{\"id\":\"evt-publish-rel-1\",\"actor\":\"ci\",\"action\":\"release.publish\",\"target_type\":\"release\",\"target_id\":\"rel-1\",\"metadata\":\"\",\"created_at\":1700000100}]}";
get :: (o: Object, key: string) -> Value {
i := 0;
while i < o.len {
if o.items[i].key == key { return o.items[i].val; }
i += 1;
}
process.assert(false, concat("missing json key: ", key));
dummy : Value = .null_;
return dummy;
}
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
// One-row scalar queries over `<STORE>/dist.db` ("" = unbound binding).
db_open_ro :: () -> sq.Sqlite {
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
c.busy_timeout(2000);
return c;
}
q_text :: (sql: string, p1: string) -> string {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_text(0);
st.finalize();
c.close();
return out;
}
q_int :: (sql: string, p1: string) -> i64 {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_int64(0);
st.finalize();
c.close();
return out;
}
main :: () -> i32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 1 << 20);
defer arena.deinit();
process.run(concat("rm -rf ", STORE));
process.run(concat("rm -rf ", EMPTY));
process.run(concat("mkdir -p ", STORE));
process.run(concat("mkdir -p ", EMPTY));
process.run(concat(concat(concat("printf '%s' '", OLD_DB), "' > "), path_join(STORE, "db.json")));
// ── 1. First load imports: db.json -> dist.db + db.json.imported ──
tl := process.run("build/dist token list --local-store .sx-tmp/db_import --json 2>/dev/null");
process.assert(tl != null and tl!.exit_code == 0, "token list over a db.json-only store must exit 0");
lv, le := parse(tl!.stdout, xx arena);
if le { process.assert(false, "token list stdout must be one JSON object"); return 1; }
toks := get_arr(lv.object, "tokens");
process.assert(toks.len == 1, "the legacy token is listed");
process.assert(get_str(toks.items[0].object, "id") == "tok-abcdefabcdef", "legacy token id survives");
process.assert(fs.exists(path_join(STORE, "dist.db")), "import created dist.db");
process.assert(!fs.exists(path_join(STORE, "db.json")), "import consumed db.json");
process.assert(fs.exists(path_join(STORE, "db.json.imported")), "the original is kept as db.json.imported");
print(" first load imported db.json into dist.db\n");
// ── 2. Every entity survived the import ──────────────────────────
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "imported: one app");
process.assert(q_text("SELECT slug FROM apps", "") == "old-app", "imported: app slug");
process.assert(q_int("SELECT COUNT(*) FROM app_bundle_ids", "") == 1, "imported: one bundle id");
process.assert(q_text("SELECT value FROM app_bundle_ids WHERE platform = ?1", "ios") == "co.old.app",
"imported: iOS bundle id value");
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 2, "imported: both releases");
process.assert(q_int("SELECT COUNT(*) FROM artifacts", "") == 1, "imported: the artifact");
process.assert(q_text("SELECT validation_status FROM artifacts", "") == "valid",
"imported: artifact status");
process.assert(q_text("SELECT current_release_id FROM channels WHERE name = ?1", "beta") == "rel-2",
"imported: channel pointer");
process.assert(q_text("SELECT token_hash FROM tokens WHERE id = ?1", "tok-abcdefabcdef")
== "55a008aa634d45313ef0a758624e0d2a356c156e507f28a2c60d19d38893af09",
"imported: token hash at rest");
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "release.publish") == 1,
"imported: the audit event");
print(" imported store carries every entity\n");
// ── 3. A write op works on the imported store, no re-import ──────
pr := process.run("build/dist release promote --app old-app --channel beta --release rel-1 --local-store .sx-tmp/db_import --json 2>/dev/null");
process.assert(pr != null and pr!.exit_code == 0, "promote on the imported store must exit 0");
process.assert(q_text("SELECT current_release_id FROM channels WHERE name = ?1", "beta") == "rel-1",
"promote moved the imported channel pointer");
process.assert(fs.exists(path_join(STORE, "db.json.imported")), "db.json.imported is not consumed again");
process.assert(!fs.exists(path_join(STORE, "db.json")), "no db.json reappears");
print(" imported store accepts writes; import ran exactly once\n");
// ── 4. A store with NO database refuses ops that need one ────────
pn := process.run("build/dist release promote --app old-app --channel beta --release rel-1 --local-store .sx-tmp/db_import_empty --json 2>/dev/null");
process.assert(pn != null and pn!.exit_code == 1, "promote on an empty store must exit 1");
nv, ne := parse(pn!.stdout, xx arena);
if ne { process.assert(false, "empty-store promote stdout must be one JSON object"); return 1; }
process.assert(get_str(get_obj(nv.object, "error"), "code") == "store.load",
"empty-store promote names store.load");
process.assert(!fs.exists(path_join(EMPTY, "dist.db")), "a refused op creates no database");
print(" empty store: ops that need state refuse with store.load\n");
process.run(concat("rm -rf ", STORE));
process.run(concat("rm -rf ", EMPTY));
print("db_import: ALL CASES PASS\n");
return 0;
}

View File

@@ -9,17 +9,19 @@
// * exit code 1 (command failed; NOT the parser's EX_USAGE 64),
// * stdout under `--json` is a SINGLE JSON object
// `{"status":"error","error":{"code":<dotted code>,"message":...}}`,
// * nothing is persisted: a fresh store gains no db.json.
// * nothing is persisted: a fresh store gains no dist.db.
//
// The no-partial-state crux is then asserted against a NON-EMPTY store:
// publish version A successfully, fail version B on a digest mismatch into
// the SAME store, and require db.json byte-state unchanged — one release,
// the SAME store, and require the store state unchanged — one release,
// channel still pointing at A (no partially-published release, no moved
// channel pointer).
// channel pointer). Store state is queried from `<store>/dist.db` via the
// SQLite bindings.
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
sq :: #import "../src/db/sqlite.sx";
STORE :: ".sx-tmp/publish_fail";
MDIR :: ".sx-tmp/publish_fail_m";
@@ -54,6 +56,42 @@ publish_cmd :: (mpath: string, store: string) -> string {
return concat(c, " --json 2>/dev/null");
}
// One-row scalar queries over `<STORE>/dist.db` ("" = unbound binding).
db_open_ro :: () -> sq.Sqlite {
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
c.busy_timeout(2000);
return c;
}
q_text :: (sql: string, p1: string) -> string {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_text(0);
st.finalize();
c.close();
return out;
}
q_int :: (sql: string, p1: string) -> i64 {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_int64(0);
st.finalize();
c.close();
return out;
}
// Write `body` to `path` via the shell (single-quoted, so the JSON's double
// quotes pass through literally).
write_file :: (path: string, body: string) {
@@ -97,19 +135,19 @@ main :: () -> i32 {
write_file(path_join(MDIR, "not_json.json"), NOT_JSON);
// ── each failure class: exit 1 + the precise dotted code, into a
// fresh per-case store that must gain NO db.json ────────────────
// fresh per-case store that must gain NO database ────────────────
assert_fails("digest mismatch", path_join(MDIR, "bad_digest.json"), concat(STORE, "-digest"), "validation.digest_mismatch", xx arena);
assert_fails("size mismatch", path_join(MDIR, "bad_size.json"), concat(STORE, "-size"), "validation.size_mismatch", xx arena);
assert_fails("unknown platform", path_join(MDIR, "bad_platform.json"), concat(STORE, "-platform"), "manifest.unknown_platform", xx arena);
assert_fails("missing artifact", path_join(MDIR, "no_artifact.json"), concat(STORE, "-missing"), "manifest.missing_artifact", xx arena);
assert_fails("malformed manifest",path_join(MDIR, "not_json.json"), concat(STORE, "-badjson"), "manifest.bad_json", xx arena);
process.assert(!fs.exists(concat(STORE, "-digest/db.json")),
"failed publish into a fresh store must not create db.json");
process.assert(!fs.exists(concat(STORE, "-digest/dist.db")),
"failed publish into a fresh store must not create dist.db");
// ── no-partial-state against a NON-EMPTY store ────────────────────
// Publish A successfully, then fail B on a digest mismatch into the
// SAME store: db.json must be unchanged (one release, channel → A).
// SAME store: the db must be unchanged (one release, channel → A).
ra := process.run(publish_cmd(path_join(MDIR, "good_a.json"), STORE));
process.assert(ra != null, "spawn publish A failed");
process.assert(ra!.exit_code == 0, "publish A must exit 0");
@@ -118,17 +156,11 @@ main :: () -> i32 {
process.assert(rb != null, "spawn failing publish B failed");
process.assert(rb!.exit_code == 1, "publish B must exit 1 (digest mismatch)");
db_bytes := fs.read_file(path_join(STORE, "db.json"));
process.assert(db_bytes != null, "db.json from publish A must still exist");
dv, de := parse(db_bytes!, xx arena);
if de { process.assert(false, "db.json must be valid JSON"); return 1; }
dbo := dv.object;
process.assert(get_arr(dbo, "releases").len == 1, "after failed B: db still has ONE release (A)");
chans := get_arr(dbo, "channels");
process.assert(chans.len == 1, "after failed B: one channel");
process.assert(get_str(chans.items[0].object, "current_release_id") == "rel-acme-app-1.2.3",
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "after failed B: db still has ONE release (A)");
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "after failed B: one channel");
process.assert(q_text("SELECT current_release_id FROM channels", "") == "rel-acme-app-1.2.3",
"after failed B: channel still points at A (no moved pointer)");
print(" non-empty store: failed publish left db.json unchanged\n");
print(" non-empty store: failed publish left the db unchanged\n");
process.run(concat("rm -rf ", concat(STORE, "-digest")));
process.run(concat("rm -rf ", concat(STORE, "-size")));

View File

@@ -9,10 +9,10 @@
// 2. the emitted release id / artifact ids / sha256 / local URLs MATCH the
// store: each `<store>/objects/<sha256>` exists and re-hashes (std.hash)
// to its own key, and each url is `file://<abs-store>/objects/<sha256>`.
// 3. `<store>/db.json` (re-parsed via std.json) records the release, both
// artifacts (storage_key == sha256, validation_status valid), the
// channel pointer (current_release_id == the release), and an audit
// event per upload/publish/promotion.
// 3. `<store>/dist.db` (queried through the SQLite bindings) records the
// release, both artifacts (storage_key == sha256, validation_status
// valid), the channel pointer (current_release_id == the release), and
// an audit event per upload/publish/promotion.
//
// This FAILS against the pre-P3.4a stub (which rejects --manifest /
// --local-store as unknown flags, exiting 64, and writes no store) and
@@ -22,6 +22,7 @@
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
hash :: #import "modules/std/hash.sx";
sq :: #import "../src/db/sqlite.sx";
cstd :: #library "c";
c_getcwd :: (buf: [*]u8, size: usize) -> *u8 #foreign cstd "getcwd";
@@ -64,18 +65,44 @@ rehashes_to :: (path: string, want: string) -> bool {
return view == want;
}
// Count audit events whose "action" equals `action`.
count_action :: (events: Array, action: string) -> i64 {
c : i64 = 0;
i := 0;
while i < events.len {
eo := events.items[i].object;
if get_str(eo, "action") == action { c += 1; }
i += 1;
}
// Open the store database read-only (asserts it exists and opens).
db_open_ro :: () -> sq.Sqlite {
c, oe := sq.Sqlite.open_v2(path_join(STORE_REL, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
c.busy_timeout(2000);
return c;
}
// One-row TEXT scalar with one optional text binding ("" = unbound).
q_text :: (sql: string, p1: string) -> string {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_text(0);
st.finalize();
c.close();
return out;
}
// One-row INTEGER scalar with one optional text binding ("" = unbound).
q_int :: (sql: string, p1: string) -> i64 {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_int64(0);
st.finalize();
c.close();
return out;
}
main :: () -> i32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 1 << 20);
@@ -129,41 +156,31 @@ main :: () -> i32 {
}
print(" {} artifacts stored + re-hash to their keys\n", arts.len);
// ── 3. db.json records the published aggregate ──────────────────────
db_bytes := fs.read_file(path_join(STORE_REL, "db.json"));
process.assert(db_bytes != null, "db.json must exist under the store");
dv, de := parse(db_bytes!, xx arena);
if de { process.assert(false, "db.json must be valid JSON"); return 1; }
dbo := dv.object;
// ── 3. dist.db records the published aggregate ──────────────────────
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "db: one release");
process.assert(q_text("SELECT id FROM releases", "") == rel_id, "db: release id matches");
db_rels := get_arr(dbo, "releases");
process.assert(db_rels.len == 1, "db: one release");
process.assert(get_str(db_rels.items[0].object, "id") == rel_id, "db: release id matches");
process.assert(q_int("SELECT COUNT(*) FROM artifacts", "") == 2, "db: two artifacts");
process.assert(q_int("SELECT COUNT(*) FROM artifacts WHERE storage_key = sha256", "") == 2,
"db: storage_key == sha256 (content-addressed)");
process.assert(q_int("SELECT COUNT(*) FROM artifacts WHERE validation_status = 'valid'", "") == 2,
"db: artifact validation passed");
process.assert(q_int("SELECT COUNT(*) FROM artifacts WHERE release_id = ?1", rel_id) == 2,
"db: artifacts belong to the release");
db_arts := get_arr(dbo, "artifacts");
process.assert(db_arts.len == 2, "db: two artifacts");
j := 0;
while j < db_arts.len {
dao := db_arts.items[j].object;
process.assert(get_str(dao, "storage_key") == get_str(dao, "sha256"),
"db: storage_key == sha256 (content-addressed)");
process.assert(get_str(dao, "validation_status") == "valid",
"db: artifact validation passed");
process.assert(get_str(dao, "release_id") == rel_id, "db: artifact belongs to the release");
j += 1;
}
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "db: one channel");
process.assert(q_text("SELECT name FROM channels", "") == "stable", "db: channel name");
process.assert(q_text("SELECT current_release_id FROM channels", "") == rel_id,
"db: channel points at the release");
db_chans := get_arr(dbo, "channels");
process.assert(db_chans.len == 1, "db: one channel");
ch := db_chans.items[0].object;
process.assert(get_str(ch, "name") == "stable", "db: channel name");
process.assert(get_str(ch, "current_release_id") == rel_id, "db: channel points at the release");
db_events := get_arr(dbo, "audit_events");
process.assert(count_action(db_events, "artifact.upload") == 2, "db: one upload event per artifact");
process.assert(count_action(db_events, "release.publish") == 1, "db: one publish event");
process.assert(count_action(db_events, "channel.promote") == 1, "db: one promotion event");
print(" db.json records release/artifacts/channel + {} audit events\n", db_events.len);
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "artifact.upload") == 2,
"db: one upload event per artifact");
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "release.publish") == 1,
"db: one publish event");
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "channel.promote") == 1,
"db: one promotion event");
print(" dist.db records release/artifacts/channel + {} audit events\n",
q_int("SELECT COUNT(*) FROM audit_events", ""));
process.run(concat("rm -rf ", STORE_REL));
print("publish_happy: ALL CASES PASS\n");

View File

@@ -1,29 +1,31 @@
// Regression for P3.4a-001 — `dist ci publish` must LOAD an existing
// `<store>/db.json` before publishing, so separate CLI invocations SHARE
// Regression for P3.4a-001 — `dist ci publish` must LOAD the existing
// store database before publishing, so separate CLI invocations SHARE
// state through the store (not start from an empty Repo and clobber it).
//
// Drives the BUILT `build/dist` binary (via `process.run`, like
// publish_happy.sx) twice into ONE store and asserts cross-invocation
// persistence:
// persistence (store state queried from `<store>/dist.db` via the SQLite
// bindings):
//
// 1. Publish version A (1.2.3) into a fresh store → db.json has the release.
// 1. Publish version A (1.2.3) into a fresh store → the db has the release.
// 2. Publish a DIFFERENT version B (1.2.4) of the SAME app into the SAME
// store → exit 0, and db.json now records BOTH releases under ONE app
// store → exit 0, and the db now records BOTH releases under ONE app
// (the app is FOUND, not duplicated); the channel points at the latest
// release B; both content-addressed objects exist.
// 3. Re-publishing the SAME release id (A again) into the same store FAILS
// (exit != 0 — the P2.3 integrity transaction rejects the duplicate
// release id) and leaves db.json UNCHANGED (still two releases).
// release id) and leaves the db UNCHANGED (still two releases).
//
// FAIL-BEFORE / PASS-AFTER: against the pre-fix publish (which never reads
// db.json and so begins every invocation from an EMPTY Repo) step 2 CLOBBERS
// A — db.json ends with one release, not two — and step 3 "succeeds" (exit 0)
// and overwrites. Both assertions fail. After the load-then-merge fix they
// pass. Fresh store per run.
// prior state and so begins every invocation from an EMPTY Repo) step 2
// CLOBBERS A — the db ends with one release, not two — and step 3 "succeeds"
// (exit 0) and overwrites. Both assertions fail. After the load-then-merge
// fix they pass. Fresh store per run.
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
sq :: #import "../src/db/sqlite.sx";
STORE :: ".sx-tmp/publish_persist";
MDIR :: ".sx-tmp/publish_persist_m";
@@ -52,26 +54,64 @@ get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
// Count audit events whose "action" equals `action`.
count_action :: (events: Array, action: string) -> i64 {
c : i64 = 0;
i := 0;
while i < events.len {
eo := events.items[i].object;
if get_str(eo, "action") == action { c += 1; }
i += 1;
}
// Open the store database read-only (asserts it exists and opens).
db_open_ro :: () -> sq.Sqlite {
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
c.busy_timeout(2000);
return c;
}
// True iff `releases` (a db.json array) contains a release with id `id`.
has_release :: (releases: Array, id: string) -> bool {
i := 0;
while i < releases.len {
if get_str(releases.items[i].object, "id") == id { return true; }
i += 1;
// One-row TEXT scalar with one optional text binding ("" = unbound).
q_text :: (sql: string, p1: string) -> string {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_text(0);
st.finalize();
c.close();
return out;
}
// One-row INTEGER scalar with one optional text binding ("" = unbound).
q_int :: (sql: string, p1: string) -> i64 {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_int64(0);
st.finalize();
c.close();
return out;
}
// True iff a release row with this id exists.
has_release :: (id: string) -> bool {
return q_int("SELECT COUNT(*) FROM releases WHERE id = ?1", id) == 1;
}
// Every artifact's content-addressed object exists under `<store>/objects/`.
assert_objects_exist :: () {
c := db_open_ro();
st, pe := c.prepare("SELECT sha256 FROM artifacts ORDER BY rowid");
process.assert(!pe, "artifact digest query must prepare");
while true {
rc, se := st.step();
process.assert(!se, "artifact digest query must step");
if rc != sq.SQLITE_ROW { break; }
sha := st.column_text(0);
process.assert(fs.exists(path_join(STORE, concat("objects/", sha))),
"after B: object exists at objects/<sha256>");
}
return false;
st.finalize();
c.close();
}
// `build/dist ci publish` for `mpath` into the shared store, JSON mode,
@@ -89,15 +129,6 @@ write_file :: (path: string, body: string) {
process.run(cmd);
}
// Parse `<STORE>/db.json` into its root object (re-read fresh each call).
load_db :: (scratch: Allocator) -> Object {
db_bytes := fs.read_file(path_join(STORE, "db.json"));
process.assert(db_bytes != null, "db.json must exist under the store");
dv, de := parse(db_bytes!, scratch);
if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
return dv.object;
}
main :: () -> i32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 1 << 20);
@@ -122,9 +153,8 @@ main :: () -> i32 {
if ea { process.assert(false, "publish A stdout must be one JSON object"); return 1; }
process.assert(get_str(get_obj(va.object, "release"), "id") == rel_a, "A release id");
db1 := load_db(xx arena);
process.assert(get_arr(db1, "releases").len == 1, "after A: db has one release");
process.assert(get_arr(db1, "apps").len == 1, "after A: db has one app");
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "after A: db has one release");
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "after A: db has one app");
print(" A published; db has 1 release\n");
// ── 2. Publish a DIFFERENT version B into the SAME store ────────────
@@ -136,34 +166,23 @@ main :: () -> i32 {
if eb { process.assert(false, "publish B stdout must be one JSON object"); return 1; }
process.assert(get_str(get_obj(vb.object, "release"), "id") == rel_b, "B release id");
db2 := load_db(xx arena);
// The crux: BOTH releases under ONE app (app found, not duplicated).
db2_rels := get_arr(db2, "releases");
process.assert(db2_rels.len == 2, "after B: db records BOTH releases (no clobber)");
process.assert(has_release(db2_rels, rel_a), "after B: release A still present");
process.assert(has_release(db2_rels, rel_b), "after B: release B present");
process.assert(get_arr(db2, "apps").len == 1, "after B: still ONE app (found, not duplicated)");
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 2, "after B: db records BOTH releases (no clobber)");
process.assert(has_release(rel_a), "after B: release A still present");
process.assert(has_release(rel_b), "after B: release B present");
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "after B: still ONE app (found, not duplicated)");
// Four artifacts (two per release); channel promoted to the latest (B).
process.assert(get_arr(db2, "artifacts").len == 4, "after B: four artifacts (two per release)");
db2_chans := get_arr(db2, "channels");
process.assert(db2_chans.len == 1, "after B: one channel");
process.assert(get_str(db2_chans.items[0].object, "current_release_id") == rel_b,
process.assert(q_int("SELECT COUNT(*) FROM artifacts", "") == 4, "after B: four artifacts (two per release)");
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "after B: one channel");
process.assert(q_text("SELECT current_release_id FROM channels", "") == rel_b,
"after B: channel promoted to the latest release");
// Each artifact's content-addressed object exists on disk.
db2_arts := get_arr(db2, "artifacts");
k := 0;
while k < db2_arts.len {
sha := get_str(db2_arts.items[k].object, "sha256");
process.assert(fs.exists(path_join(STORE, concat("objects/", sha))),
"after B: object exists at objects/<sha256>");
k += 1;
}
assert_objects_exist();
// Audit accumulated across both publishes (>= 2 publish events).
process.assert(count_action(get_arr(db2, "audit_events"), "release.publish") == 2,
// Audit accumulated across both publishes.
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "release.publish") == 2,
"after B: one publish event per release");
print(" B accumulated; db has 2 releases under 1 app\n");
@@ -173,9 +192,8 @@ main :: () -> i32 {
res_dup := rdup!;
process.assert(res_dup.exit_code != 0, "re-publishing the same release id must FAIL (duplicate)");
db3 := load_db(xx arena);
process.assert(get_arr(db3, "releases").len == 2, "after duplicate: db UNCHANGED (still two releases)");
process.assert(get_arr(db3, "apps").len == 1, "after duplicate: still one app");
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 2, "after duplicate: db UNCHANGED (still two releases)");
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "after duplicate: still one app");
print(" duplicate release id rejected; db unchanged\n");
process.run(concat("rm -rf ", STORE));

View File

@@ -7,21 +7,25 @@
// 1. Publish release A (1.2.3, channel beta) then B (1.2.4, beta) into
// one store → beta points at B.
// 2. `release rollback --app --channel beta` → exit 0, JSON
// `rolled_back` from B to A; db.json's beta points at A; a
// `rolled_back` from B to A; the store's beta channel points at A; a
// `channel.rollback` audit event (actor "cli") is recorded.
// 3. `release promote --release <B>` → exit 0, JSON `promoted` with
// previous_release_id A; beta points at B again; a `channel.promote`
// audit event by actor "cli" is recorded (distinct from the publish
// pipeline's "ci" promote events).
// 4. Promoting an UNKNOWN release id → exit 1 + JSON error
// (`promote.unknown_release`); db.json unchanged (beta still → B).
// (`promote.unknown_release`); the store unchanged (beta still → B).
// 5. Rollback again (B → A), then rollback at the EARLIEST release →
// exit 1 + `rollback.no_previous`; beta still → A. A failed op never
// moves the pointer.
//
// Store-side state is asserted by QUERYING `<store>/dist.db` through the
// SQLite bindings.
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
sq :: #import "../src/db/sqlite.sx";
STORE :: ".sx-tmp/release_ops";
MDIR :: ".sx-tmp/release_ops_m";
@@ -46,38 +50,55 @@ get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
// Count audit events matching (actor, action) — distinguishes the CLI's
// channel events from the publish pipeline's "ci" ones.
count_actor_action :: (events: Array, actor: string, action: string) -> i64 {
c : i64 = 0;
i := 0;
while i < events.len {
eo := events.items[i].object;
if get_str(eo, "actor") == actor and get_str(eo, "action") == action { c += 1; }
i += 1;
}
return c;
}
write_file :: (path: string, body: string) {
cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
process.run(cmd);
}
load_db :: (scratch: Allocator) -> Object {
db_bytes := fs.read_file(path_join(STORE, "db.json"));
process.assert(db_bytes != null, "db.json must exist under the store");
dv, de := parse(db_bytes!, scratch);
if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
return dv.object;
// Open the store database read-only (asserts it exists and opens).
db_open_ro :: () -> sq.Sqlite {
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
c.busy_timeout(2000);
return c;
}
// The beta channel's current_release_id, read fresh from db.json.
beta_pointer :: (scratch: Allocator) -> string {
dbo := load_db(scratch);
chans := get_arr(dbo, "channels");
process.assert(chans.len == 1, "store has exactly one channel");
return get_str(chans.items[0].object, "current_release_id");
// One-row TEXT scalar with up to two text bindings ("" = unbound).
q_text :: (sql: string, p1: string, p2: string) -> string {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_text(0);
st.finalize();
c.close();
return out;
}
// One-row INTEGER scalar with up to two text bindings ("" = unbound).
q_int :: (sql: string, p1: string, p2: string) -> i64 {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_int64(0);
st.finalize();
c.close();
return out;
}
// The beta channel's current_release_id, read fresh from dist.db.
beta_pointer :: () -> string {
process.assert(q_int("SELECT COUNT(*) FROM channels", "", "") == 1, "store has exactly one channel");
return q_text("SELECT current_release_id FROM channels", "", "");
}
publish_cmd :: (mpath: string) -> string {
@@ -110,7 +131,7 @@ main :: () -> i32 {
process.assert(ra != null and ra!.exit_code == 0, "publish A must exit 0");
rb := process.run(publish_cmd(path_join(MDIR, "b.json")));
process.assert(rb != null and rb!.exit_code == 0, "publish B must exit 0");
process.assert(beta_pointer(xx arena) == REL_B, "after publishes: beta -> B");
process.assert(beta_pointer() == REL_B, "after publishes: beta -> B");
print(" published A then B; beta -> B\n");
// ── 2. Rollback: beta moves B -> A, audited ──────────────────────
@@ -123,9 +144,8 @@ main :: () -> i32 {
process.assert(get_str(ro, "status") == "rolled_back", "rollback json status");
process.assert(get_str(ro, "from_release_id") == REL_B, "rollback json from B");
process.assert(get_str(get_obj(ro, "to"), "id") == REL_A, "rollback json to A");
process.assert(beta_pointer(xx arena) == REL_A, "after rollback: beta -> A");
db2 := load_db(xx arena);
process.assert(count_actor_action(get_arr(db2, "audit_events"), "cli", "channel.rollback") == 1,
process.assert(beta_pointer() == REL_A, "after rollback: beta -> A");
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "cli", "channel.rollback") == 1,
"rollback recorded one cli channel.rollback audit event");
print(" rollback: beta B -> A, audited\n");
@@ -139,9 +159,8 @@ main :: () -> i32 {
process.assert(get_str(po, "status") == "promoted", "promote json status");
process.assert(get_str(get_obj(po, "release"), "id") == REL_B, "promote json release B");
process.assert(get_str(po, "previous_release_id") == REL_A, "promote json previous A");
process.assert(beta_pointer(xx arena) == REL_B, "after promote: beta -> B");
db3 := load_db(xx arena);
process.assert(count_actor_action(get_arr(db3, "audit_events"), "cli", "channel.promote") == 1,
process.assert(beta_pointer() == REL_B, "after promote: beta -> B");
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "cli", "channel.promote") == 1,
"promote recorded one cli channel.promote audit event");
print(" promote: beta -> B (was A), audited\n");
@@ -156,13 +175,13 @@ main :: () -> i32 {
process.assert(get_str(no, "status") == "error", "promote-unknown json status error");
process.assert(get_str(get_obj(no, "error"), "code") == "promote.unknown_release",
"promote-unknown json names the code");
process.assert(beta_pointer(xx arena) == REL_B, "after failed promote: beta unchanged (-> B)");
process.assert(beta_pointer() == REL_B, "after failed promote: beta unchanged (-> B)");
print(" promote unknown release: exit 1 + JSON error, beta unchanged\n");
// ── 5. Rollback to the earliest, then once more → no_previous ────
r2 := process.run(ROLLBACK_CMD);
process.assert(r2 != null and r2!.exit_code == 0, "second rollback must exit 0 (B -> A)");
process.assert(beta_pointer(xx arena) == REL_A, "after second rollback: beta -> A");
process.assert(beta_pointer() == REL_A, "after second rollback: beta -> A");
r3 := process.run(ROLLBACK_CMD);
process.assert(r3 != null, "spawn third rollback failed");
@@ -172,7 +191,7 @@ main :: () -> i32 {
o3 := v3.object;
process.assert(get_str(get_obj(o3, "error"), "code") == "rollback.no_previous",
"no-previous json names the code");
process.assert(beta_pointer(xx arena) == REL_A, "after failed rollback: beta unchanged (-> A)");
process.assert(beta_pointer() == REL_A, "after failed rollback: beta unchanged (-> A)");
print(" rollback at earliest: exit 1 + no_previous, beta unchanged\n");
process.run(concat("rm -rf ", STORE));

View File

@@ -7,7 +7,8 @@
// * happy path: exit 0; stdout is the publish JSON (status published,
// release id, per-artifact sha256 equal to an independent digest of
// the fixture); the SERVER's store gained objects/<sha>, the release,
// the channel pointer, and `token:<name>` audit actors.
// the channel pointer, and `token:<name>` audit actors (store state
// queried from `<store>/dist.db` via the SQLite bindings).
// * duplicate version: exit 1, the server's transaction.integrity code
// passed through verbatim.
// * wrong secret: exit 1, auth.unknown_token.
@@ -21,6 +22,7 @@
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
hash :: #import "modules/std/hash.sx";
sq :: #import "../src/db/sqlite.sx";
STORE :: ".sx-tmp/remote_publish";
MDIR :: ".sx-tmp/remote_publish_m";
@@ -54,6 +56,42 @@ parse_body :: (body: string, what: string, scratch: Allocator) -> Object {
return v.object;
}
// One-row scalar queries over `<STORE>/dist.db` ("" = unbound binding).
db_open_ro :: () -> sq.Sqlite {
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
c.busy_timeout(2000);
return c;
}
q_text :: (sql: string, p1: string) -> string {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_text(0);
st.finalize();
c.close();
return out;
}
q_int :: (sql: string, p1: string) -> i64 {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_int64(0);
st.finalize();
c.close();
return out;
}
// Run a remote publish with `server` and `token`; returns the run result.
remote_publish :: (server: string, token: string) -> ?process.ProcessResult {
cmd := concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json"));
@@ -124,14 +162,11 @@ main :: () -> i32 {
process.assert(fs.exists(path_join(STORE, concat("objects/", expect_sha))),
"the SERVER's store holds the uploaded object");
dbo := parse_body(fs.read_file(path_join(STORE, "db.json"))!, "db.json", xx arena);
rels := get_arr(dbo, "releases");
process.assert(rels.len == 1, "server db records the release");
process.assert(get_str(rels.items[0].object, "created_by") == "token:ci-remote",
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "server db records the release");
process.assert(q_text("SELECT created_by FROM releases", "") == "token:ci-remote",
"release created_by carries the token actor");
chans := get_arr(dbo, "channels");
process.assert(chans.len == 1, "server db records the channel");
process.assert(get_str(chans.items[0].object, "current_release_id") == "rel-acme-app-2.0.0",
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "server db records the channel");
process.assert(q_text("SELECT current_release_id FROM channels", "") == "rel-acme-app-2.0.0",
"beta points at the published release");
print(" remote publish: CI contract round trip ok\n");

View File

@@ -1,18 +1,21 @@
// Acceptance for P2.3 (round-trip) — the in-memory repository persisted to
// `<root>/db.json` via `std.json` and reloaded into a FRESH repository.
// Acceptance for P5.2 (round-trip) — the in-memory repository persisted to
// `<root>/dist.db` via the vendored SQLite and reloaded into a FRESH
// repository.
//
// Asserts:
// 1. Every entity (app + bundle ids, release, two artifacts, channel,
// audit event) survives save -> reload field-for-field.
// 2. db.json is valid JSON, re-parseable by `std.json` (re-parse it).
// 3. Re-saving the reloaded repo yields BYTE-IDENTICAL db.json — the
// stable (insertion-order) key-order guarantee.
// 2. The store file is a real SQLite database: dist.db exists (and no
// db.json is written), and an independent SQL query over it sees the
// saved rows.
// 3. Re-saving the reloaded repo into a second root reloads equal again
// (save -> load is idempotent on the model).
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up. Exits 0 only if
// every assertion holds (process.assert aborts otherwise).
#import "modules/std.sx";
#import "modules/std/json.sx";
#import "modules/std/fs.sx";
process :: #import "modules/std/process.sx";
sq :: #import "../src/db/sqlite.sx";
#import "../src/domain/platform.sx";
#import "../src/domain/app.sx";
#import "../src/domain/release.sx";
@@ -143,18 +146,20 @@ main :: () -> i32 {
serr := false;
save(repo, root) catch { serr = true; };
process.assert(!serr, "save must succeed");
process.assert(fs.exists(path_join(root, "db.json")), "db.json must exist after save");
process.assert(fs.exists(path_join(root, "dist.db")), "dist.db must exist after save");
process.assert(!fs.exists(path_join(root, "db.json")), "save must not write a db.json");
// ── 2. db.json is valid JSON re-parseable by std.json ────────────
raw := fs.read_file(path_join(root, "db.json"));
process.assert(raw != null, "db.json must be readable");
bytes := raw!;
gpa := GPA.init();
arena := Arena.init(xx gpa, 65536);
_, perr := parse(bytes, xx arena);
process.assert(!perr, "db.json must be valid JSON re-parseable by std.json");
arena.deinit();
print(" db.json is valid JSON ({} bytes)\n", bytes.len);
// ── 2. dist.db is a real SQLite database (independent SQL read) ──
conn, oe := sq.Sqlite.open_v2(path_join(root, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
st, pe := conn.prepare("SELECT COUNT(*) FROM artifacts");
process.assert(!pe, "artifacts must be queryable");
rc, se := st.step();
process.assert(!se, "count query must step");
process.assert(rc == sq.SQLITE_ROW and st.column_int64(0) == 2, "SQL sees both artifact rows");
st.finalize();
conn.close();
print(" dist.db is a queryable SQLite database\n");
// ── 1. Reload into a FRESH repo and compare field-for-field ──────
repo2, lerr := load(root);
@@ -192,14 +197,19 @@ main :: () -> i32 {
process.assert(sga.id == "app_01", "slug lookup returns the right app");
print(" reloaded model equals original (every field)\n");
// ── 3. Re-save the reloaded repo -> byte-identical db.json ───────
// ── 3. Re-save the reloaded repo -> reloads equal again ──────────
serr2 := false;
save(repo2, root2) catch { serr2 = true; };
process.assert(!serr2, "re-save must succeed");
raw2 := fs.read_file(path_join(root2, "db.json"));
process.assert(raw2 != null, "re-saved db.json must be readable");
process.assert(raw2! == bytes, "re-save is byte-identical (stable key order)");
print(" re-save is byte-identical (stable key order)\n");
repo3, lerr2 := load(root2);
if lerr2 { process.assert(false, "re-load must succeed"); return 1; }
process.assert(repo3.apps.len == 1 and app_eq(repo3.apps.items[0], app0), "app survives second round-trip");
process.assert(repo3.releases.len == 1 and release_eq(repo3.releases.items[0], rel0), "release survives second round-trip");
process.assert(repo3.artifacts.len == 2 and artifact_eq(repo3.artifacts.items[0], apk0)
and artifact_eq(repo3.artifacts.items[1], ipa0), "artifacts survive second round-trip in order");
process.assert(repo3.channels.len == 1 and channel_eq(repo3.channels.items[0], chan0), "channel survives second round-trip");
process.assert(repo3.audit_events.len == 1 and audit_eq(repo3.audit_events.items[0], ev0), "audit event survives second round-trip");
print(" save -> load is idempotent on the model\n");
// ── cleanup ──────────────────────────────────────────────────────
process.run(concat("rm -rf ", root));

View File

@@ -14,7 +14,7 @@
// (artifact.app_id != release.app_id), (6) channel-NAME mismatch
// (chan.name != release.channel), (7) release-id collision
// (release.id already exists).
// 8. Persist + reload: the rolled-back state is what hits db.json — the
// 8. Persist + reload: the rolled-back state is what hits the store — the
// reloaded channel still points at rel_00 and the attempted releases
// are absent.
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up.
@@ -250,7 +250,7 @@ main :: () -> i32 {
rcv := rc!;
process.assert(rcv.current_release_id == "rel_00", "persisted: channel points at a release that exists");
process.assert(repo2.get_release(rcv.current_release_id) != null, "persisted: channel target is a real release (no dangling)");
print(" persisted db.json reflects the rolled-back state\n");
print(" persisted store reflects the rolled-back state\n");
process.run(concat("rm -rf ", root));
print("repo_transaction: ALL CASES PASS\n");

View File

@@ -16,12 +16,13 @@
// * any other path → JSON error http.not_found
//
// FRESHNESS is asserted by publishing a SECOND version while the server
// is running: the next /api/apps/<slug> must list both releases (db.json
// is reloaded per request — no stale cache).
// is running: the next /api/apps/<slug> must list both releases (the
// store database is reloaded per request — no stale cache).
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
sq :: #import "../src/db/sqlite.sx";
STORE :: ".sx-tmp/server_http";
MDIR :: ".sx-tmp/server_http_m";
@@ -162,10 +163,17 @@ main :: () -> i32 {
print(" api routes ok\n");
// ── download: bytes identical to the source fixture ───────────────
db_bytes := fs.read_file(path_join(STORE, "db.json"));
process.assert(db_bytes != null, "db.json must exist");
dbo := parse_body(db_bytes!, "db.json", xx arena);
sha := get_str(get_arr(dbo, "artifacts").items[0].object, "sha256");
// The published artifact's digest, read from the store database.
conn, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
conn.busy_timeout(2000);
stq, pe := conn.prepare("SELECT sha256 FROM artifacts ORDER BY rowid");
process.assert(!pe, "artifact digest query must prepare");
src, se := stq.step();
process.assert(!se and src == sq.SQLITE_ROW, "store must record the artifact");
sha := stq.column_text(0);
stq.finalize();
conn.close();
dl := process.run(concat(concat(concat(concat("curl -s -m 2 -o .sx-tmp/server_http_dl.bin ", BASE), "/download/"), sha), " && cmp -s .sx-tmp/server_http_dl.bin examples/fixtures/acme-1.2.3-android.apk && echo SAME"));
process.assert(dl != null, "download curl spawn failed");

View File

@@ -22,11 +22,15 @@
// * channel: promote / rollback move the pointer exactly like their
// CLI twins; unknown release id is 404.
// * methods: anything but GET/POST is 405.
//
// Store-side state is asserted by QUERYING `<store>/dist.db` through the
// SQLite bindings.
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
hash :: #import "modules/std/hash.sx";
sq :: #import "../src/db/sqlite.sx";
STORE :: ".sx-tmp/server_write";
PORT :: "18793";
@@ -103,35 +107,42 @@ err_code :: (body: string, what: string, scratch: Allocator) -> string {
return get_str(get_obj(parse_body(body, what, scratch), "error"), "code");
}
load_db :: (scratch: Allocator) -> Object {
b := fs.read_file(path_join(STORE, "db.json"));
process.assert(b != null, "db.json must exist under the store");
return parse_body(b!, "db.json", scratch);
// Open the store database read-only (asserts it exists and opens).
db_open_ro :: () -> sq.Sqlite {
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
c.busy_timeout(2000);
return c;
}
// current_release_id of channel `name`, "" when the channel doesn't exist.
channel_pointer :: (name: string, scratch: Allocator) -> string {
chans := get_arr(load_db(scratch), "channels");
i := 0;
while i < chans.len {
co := chans.items[i].object;
if get_str(co, "name") == name { return get_str(co, "current_release_id"); }
i += 1;
}
return "";
channel_pointer :: (name: string) -> string {
c := db_open_ro();
st, pe := c.prepare("SELECT current_release_id FROM channels WHERE name = ?1");
process.assert(!pe, "channel query must prepare");
st.bind_text(1, name) catch { process.assert(false, "channel bind failed"); };
rc, se := st.step();
process.assert(!se, "channel query must step");
out := "";
if rc == sq.SQLITE_ROW { out = st.column_text(0); }
st.finalize();
c.close();
return out;
}
// last_used_at of the token named `name`.
token_last_used :: (name: string, scratch: Allocator) -> i64 {
toks := get_arr(load_db(scratch), "tokens");
i := 0;
while i < toks.len {
to := toks.items[i].object;
if get_str(to, "name") == name { return get_int(to, "last_used_at"); }
i += 1;
}
process.assert(false, concat("token not in db.json: ", name));
return -1;
// last_used_at of the token named `name` (asserts the token exists).
token_last_used :: (name: string) -> i64 {
c := db_open_ro();
st, pe := c.prepare("SELECT last_used_at FROM tokens WHERE name = ?1");
process.assert(!pe, "token query must prepare");
st.bind_text(1, name) catch { process.assert(false, "token bind failed"); };
rc, se := st.step();
process.assert(!se, "token query must step");
process.assert(rc == sq.SQLITE_ROW, concat("token not in the store: ", name));
out := st.column_int64(0);
st.finalize();
c.close();
return out;
}
// JSON body for a release POST referencing `sha` (single android artifact).
@@ -151,7 +162,7 @@ main :: () -> i32 {
process.run("pkill -f 'dist server run --local-store .sx-tmp/server_write' 2>/dev/null");
process.run(concat("rm -rf ", STORE));
// ── tokens (this also creates db.json on the fresh store) ─────────
// ── tokens (this also creates dist.db on the fresh store) ─────────
publisher := mint("--name publisher", xx arena);
reader := mint("--name reader --scope read", xx arena);
wrong_app := mint("--name wrong-app --app other-app", xx arena);
@@ -228,7 +239,7 @@ main :: () -> i32 {
rb := parse_body(post("/api/apps/acme-app/releases", publisher, release_body("1.2.3", "beta", sha)), "release", xx arena);
process.assert(get_str(rb, "status") == "published", "release json status published");
process.assert(get_str(get_obj(rb, "release"), "id") == "rel-acme-app-1.2.3", "release id");
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.3", "beta points at the new release");
process.assert(channel_pointer("beta") == "rel-acme-app-1.2.3", "beta points at the new release");
det := parse_body(fetch("/api/apps/acme-app"), "detail", xx arena);
process.assert(get_arr(det, "releases").len == 1, "GET reflects the POSTed release (per-request reload)");
@@ -243,7 +254,7 @@ main :: () -> i32 {
"unknown object is 404");
process.assert(err_code(post("/api/apps/acme-app/releases", publisher, release_body("9.9.9", "nightly", UNKNOWN)), "unknown-object body", xx arena) == "api.unknown_object",
"unknown object names api.unknown_object");
process.assert(channel_pointer("nightly", xx arena) == "", "aborted publish leaves no channel behind");
process.assert(channel_pointer("nightly") == "", "aborted publish leaves no channel behind");
process.assert(post_code("/api/apps/acme-app/releases", publisher, release_body("1.2.3", "beta", sha)) == "409",
"duplicate release id is 409");
@@ -252,23 +263,23 @@ main :: () -> i32 {
// ── channel ops mirror the CLI ─────────────────────────────────────
p2 := parse_body(post("/api/apps/acme-app/releases", publisher, release_body("1.2.4", "beta", sha)), "release B", xx arena);
process.assert(get_str(p2, "status") == "published", "second release published");
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.4", "beta -> 1.2.4");
process.assert(channel_pointer("beta") == "rel-acme-app-1.2.4", "beta -> 1.2.4");
rbk := parse_body(post("/api/apps/acme-app/channels/beta/rollback", publisher, "-d ''"), "rollback", xx arena);
process.assert(get_str(rbk, "status") == "rolled_back", "rollback json status");
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.3", "rollback moved beta back");
process.assert(channel_pointer("beta") == "rel-acme-app-1.2.3", "rollback moved beta back");
pm := parse_body(post("/api/apps/acme-app/channels/beta/promote", publisher, "-d '{\"release_id\":\"rel-acme-app-1.2.4\"}'"), "promote", xx arena);
process.assert(get_str(pm, "status") == "promoted", "promote json status");
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.4", "promote moved beta forward");
process.assert(channel_pointer("beta") == "rel-acme-app-1.2.4", "promote moved beta forward");
process.assert(post_code("/api/apps/acme-app/channels/beta/promote", publisher, "-d '{\"release_id\":\"rel-nope\"}'") == "404",
"promoting an unknown release is 404");
print(" channel ops: promote/rollback mirror the CLI\n");
// ── last-used stamping + method gate ───────────────────────────────
process.assert(token_last_used("publisher", xx arena) > 0, "publisher token got last_used_at stamped");
process.assert(token_last_used("reader", xx arena) == 0, "refused token is never stamped");
process.assert(token_last_used("publisher") > 0, "publisher token got last_used_at stamped");
process.assert(token_last_used("reader") == 0, "refused token is never stamped");
mc := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' -X DELETE ", BASE), "/healthz"));
process.assert(mc != null and mc!.stdout == "405", "non-GET/POST method is 405");

View File

@@ -6,8 +6,8 @@
// scope match, and the unscoped-matches-anything rules.
// * validate_token — each invalid form rejected with its SPECIFIC tag.
// * mark_token_used — last-used stamping through the repo.
// * db.json round trip — tokens persist field-for-field, and an absent
// `tokens` member (a db.json from before tokens existed) loads as zero
// * store round trip — tokens persist field-for-field, and an absent
// `tokens` member (an import-path db.json from before tokens existed) loads as zero
// tokens instead of BadShape.
#import "modules/std.sx";
#import "../src/domain/platform.sx";
@@ -213,7 +213,7 @@ tok_mark_used_shim :: (repo: *Repo, id: string, now: i64) -> bool {
// ── persistence ──────────────────────────────────────────────────────
// db.json with no `tokens` member loads as zero tokens (compat with
// An import-path db.json with no `tokens` member loads as zero tokens (compat with
// pre-token layouts), not BadShape.
NO_TOKENS_DB :: "{\"apps\":[],\"releases\":[],\"artifacts\":[],\"channels\":[],\"audit_events\":[]}";

View File

@@ -3,24 +3,28 @@
//
// Drives the BUILT `build/dist` binary through the slice contract:
//
// 1. `token create` on a FRESH store (no db.json yet) → exit 0; the JSON
// carries the raw secret (`dist_` + 64 hex) exactly once; db.json
// 1. `token create` on a FRESH store (no database yet) → exit 0; the JSON
// carries the raw secret (`dist_` + 64 hex) exactly once; dist.db
// stores ONLY sha256(secret) — the secret's bytes appear nowhere under
// the store — plus a `token.create` audit event.
// 2. A second token with `--scope read --expires-in 60` → expires_at is
// created_at + 60.
// 3. `token list` → both tokens "active"; no secret in the output.
// 4. `token revoke` → exit 0; db.json gains revoked_at + a `token.revoke`
// 4. `token revoke` → exit 0; dist.db gains revoked_at + a `token.revoke`
// audit event; list now shows "revoked".
// 5. Revoking an UNKNOWN id → exit 1 + `token.unknown`; revoking AGAIN →
// exit 1 + `token.already_revoked`; both leave db.json unchanged.
// exit 1 + `token.already_revoked`; both leave dist.db unchanged.
// 6. A publish into the same store still works (the model with tokens
// round-trips through the publish pipeline's load/save).
//
// Store-side state is asserted by QUERYING `<store>/dist.db` through the
// SQLite bindings — independent of the db.sx load path under test.
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
hash :: #import "modules/std/hash.sx";
sq :: #import "../src/db/sqlite.sx";
STORE :: ".sx-tmp/token_ops";
MDIR :: ".sx-tmp/token_ops_m";
@@ -54,41 +58,52 @@ contains :: (haystack: string, needle: string) -> bool {
return false;
}
count_actor_action :: (events: Array, actor: string, action: string) -> i64 {
c : i64 = 0;
i := 0;
while i < events.len {
eo := events.items[i].object;
if get_str(eo, "actor") == actor and get_str(eo, "action") == action { c += 1; }
i += 1;
}
return c;
}
// The raw bytes of the store database (for "secret appears nowhere" and
// byte-identity assertions).
db_bytes :: () -> string {
b := fs.read_file(path_join(STORE, "db.json"));
process.assert(b != null, "db.json must exist under the store");
b := fs.read_file(path_join(STORE, "dist.db"));
process.assert(b != null, "dist.db must exist under the store");
return b!;
}
load_db :: (scratch: Allocator) -> Object {
dv, de := parse(db_bytes(), scratch);
if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
return dv.object;
// Open the store database read-only (asserts it exists and opens).
db_open_ro :: () -> sq.Sqlite {
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
process.assert(!oe, "dist.db must open as a SQLite database");
c.busy_timeout(2000);
return c;
}
// The db.json token entry with this id.
db_token :: (id: string, scratch: Allocator) -> Object {
toks := get_arr(load_db(scratch), "tokens");
i := 0;
while i < toks.len {
to := toks.items[i].object;
if get_str(to, "id") == id { return to; }
i += 1;
}
process.assert(false, concat("token not in db.json: ", id));
dummy : Object = .{};
return dummy;
// One-row TEXT scalar with up to two text bindings ("" = unbound).
q_text :: (sql: string, p1: string, p2: string) -> string {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_text(0);
st.finalize();
c.close();
return out;
}
// One-row INTEGER scalar with up to two text bindings ("" = unbound).
q_int :: (sql: string, p1: string, p2: string) -> i64 {
c := db_open_ro();
st, pe := c.prepare(sql);
process.assert(!pe, concat("prepare must succeed: ", sql));
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; }
rc, se := st.step();
process.assert(!se, concat("step must succeed: ", sql));
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
out := st.column_int64(0);
st.finalize();
c.close();
return out;
}
// The "status" of token `id` in `token list --json` output.
@@ -139,14 +154,12 @@ main :: () -> i32 {
process.assert(get_str(t1, "scopes") == "publish", "default scope is publish");
process.assert(get_int(t1, "expires_at") == 0, "default expiry is never");
dt1 := db_token(id1, xx arena);
stored_hash := get_str(dt1, "token_hash");
stored_hash := q_text("SELECT token_hash FROM tokens WHERE id = ?1", id1, "");
d := hash.sha256_hex(secret);
expect_hash := string.{ ptr = @d[0], len = 64 };
process.assert(stored_hash == expect_hash, "db stores sha256(secret) as token_hash");
process.assert(!contains(db_bytes(), secret), "the raw secret appears nowhere under the store");
db1 := load_db(xx arena);
process.assert(count_actor_action(get_arr(db1, "audit_events"), "cli", "token.create") == 1,
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "cli", "token.create") == 1,
"create recorded one cli token.create audit event");
print(" create: secret shown once, only sha256 at rest, audited\n");
@@ -177,12 +190,10 @@ main :: () -> i32 {
rv, re := parse(rr!.stdout, xx arena);
if re { process.assert(false, "revoke stdout must be one JSON object"); return 1; }
process.assert(get_str(rv.object, "status") == "revoked", "revoke json status");
dt1b := db_token(id1, xx arena);
revoked_at := get_int(dt1b, "revoked_at");
revoked_at := q_int("SELECT revoked_at FROM tokens WHERE id = ?1", id1, "");
process.assert(revoked_at >= created1, "db records revoked_at");
process.assert(list_status(id1, xx arena) == "revoked", "token 1 listed revoked");
db4 := load_db(xx arena);
process.assert(count_actor_action(get_arr(db4, "audit_events"), "cli", "token.revoke") == 1,
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "cli", "token.revoke") == 1,
"revoke recorded one cli token.revoke audit event");
print(" revoke: status flips, audited\n");
@@ -201,17 +212,16 @@ main :: () -> i32 {
if ae { process.assert(false, "double-revoke stdout must be one JSON object"); return 1; }
process.assert(get_str(get_obj(av.object, "error"), "code") == "token.already_revoked",
"double-revoke json names token.already_revoked");
process.assert(db_bytes() == before, "failed revokes leave db.json byte-identical");
print(" failures: unknown + double revoke exit 1, db.json untouched\n");
process.assert(db_bytes() == before, "failed revokes leave dist.db byte-identical");
print(" failures: unknown + double revoke exit 1, dist.db untouched\n");
// ── 6. Publish coexists with tokens in the same store ────────────
pc := concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json"));
pc = concat(pc, " --local-store .sx-tmp/token_ops --json 2>/dev/null");
rp := process.run(pc);
process.assert(rp != null and rp!.exit_code == 0, "publish into a tokened store must exit 0");
db6 := load_db(xx arena);
process.assert(get_arr(db6, "tokens").len == 2, "publish preserved both tokens");
process.assert(get_arr(db6, "releases").len == 1, "publish landed its release");
process.assert(q_int("SELECT COUNT(*) FROM tokens", "", "") == 2, "publish preserved both tokens");
process.assert(q_int("SELECT COUNT(*) FROM releases", "", "") == 1, "publish landed its release");
print(" publish: round-trips the model with tokens intact\n");
process.run(concat("rm -rf ", STORE));