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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user