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

@@ -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");