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