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

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