The amalgamation and the bindings now ship with sx itself (sx library/vendors/sqlite/ — bindings + c/ amalgamation); every import flips from ../src/db/sqlite.sx to vendors/sqlite/sqlite.sx, resolved through the compiler's stdlib search paths. vendor/ and src/db/ leave this repo entirely. make test 22/22 — the object cache keys on content, not path, so the relocated source still hits the existing cache entries.
148 lines
9.2 KiB
Plaintext
148 lines
9.2 KiB
Plaintext
// 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 "vendors/sqlite/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;
|
|
}
|