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.
204 lines
9.6 KiB
Plaintext
204 lines
9.6 KiB
Plaintext
// Regression for P3.4a-001 — `dist ci publish` must LOAD the existing
|
|
// store database before publishing, so separate CLI invocations SHARE
|
|
// state through the store (not start from an empty Repo and clobber it).
|
|
//
|
|
// Drives the BUILT `build/dist` binary (via `process.run`, like
|
|
// publish_happy.sx) twice into ONE store and asserts cross-invocation
|
|
// persistence (store state queried from `<store>/dist.db` via the SQLite
|
|
// bindings):
|
|
//
|
|
// 1. Publish version A (1.2.3) into a fresh store → the db has the release.
|
|
// 2. Publish a DIFFERENT version B (1.2.4) of the SAME app into the SAME
|
|
// store → exit 0, and the db now records BOTH releases under ONE app
|
|
// (the app is FOUND, not duplicated); the channel points at the latest
|
|
// release B; both content-addressed objects exist.
|
|
// 3. Re-publishing the SAME release id (A again) into the same store FAILS
|
|
// (exit != 0 — the P2.3 integrity transaction rejects the duplicate
|
|
// release id) and leaves the db UNCHANGED (still two releases).
|
|
//
|
|
// FAIL-BEFORE / PASS-AFTER: against the pre-fix publish (which never reads
|
|
// prior state and so begins every invocation from an EMPTY Repo) step 2
|
|
// CLOBBERS A — the db ends with one release, not two — and step 3 "succeeds"
|
|
// (exit 0) and overwrites. Both assertions fail. After the load-then-merge
|
|
// fix they pass. Fresh store per run.
|
|
#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/publish_persist";
|
|
MDIR :: ".sx-tmp/publish_persist_m";
|
|
A_PATH :: ".sx-tmp/publish_persist_m/a.json";
|
|
B_PATH :: ".sx-tmp/publish_persist_m/b.json";
|
|
|
|
// Two manifests for the SAME app/channel/fixtures, differing only in version.
|
|
// Artifact paths resolve relative to the manifest's own directory (MDIR), so
|
|
// `../../examples/fixtures/...` reaches the repo's committed fixtures.
|
|
MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"},{\"platform\":\"ios\",\"path\":\"../../examples/fixtures/acme-1.2.3-ios.ipa\"}]}";
|
|
MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"},{\"platform\":\"ios\",\"path\":\"../../examples/fixtures/acme-1.2.3-ios.ipa\"}]}";
|
|
|
|
// Fetch a member value by key, asserting presence (the publish/db output is a
|
|
// fixed shape, so an absent key is a hard failure).
|
|
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; }
|
|
|
|
// 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;
|
|
}
|
|
|
|
// One-row TEXT scalar with one optional text binding ("" = unbound).
|
|
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;
|
|
}
|
|
|
|
// One-row INTEGER scalar with one optional text binding ("" = unbound).
|
|
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;
|
|
}
|
|
|
|
// True iff a release row with this id exists.
|
|
has_release :: (id: string) -> bool {
|
|
return q_int("SELECT COUNT(*) FROM releases WHERE id = ?1", id) == 1;
|
|
}
|
|
|
|
// Every artifact's content-addressed object exists under `<store>/objects/`.
|
|
assert_objects_exist :: () {
|
|
c := db_open_ro();
|
|
st, pe := c.prepare("SELECT sha256 FROM artifacts ORDER BY rowid");
|
|
process.assert(!pe, "artifact digest query must prepare");
|
|
while true {
|
|
rc, se := st.step();
|
|
process.assert(!se, "artifact digest query must step");
|
|
if rc != sq.SQLITE_ROW { break; }
|
|
sha := st.column_text(0);
|
|
process.assert(fs.exists(path_join(STORE, concat("objects/", sha))),
|
|
"after B: object exists at objects/<sha256>");
|
|
}
|
|
st.finalize();
|
|
c.close();
|
|
}
|
|
|
|
// `build/dist ci publish` for `mpath` into the shared store, JSON mode,
|
|
// stderr suppressed so stdout stays pure for the parser.
|
|
publish_cmd :: (mpath: string) -> string {
|
|
c := concat("build/dist ci publish --manifest ", mpath);
|
|
c = concat(c, concat(" --local-store ", STORE));
|
|
return concat(c, " --json 2>/dev/null");
|
|
}
|
|
|
|
// Write `body` to `path` via the shell (single-quoted, so the JSON's double
|
|
// quotes pass through literally).
|
|
write_file :: (path: string, body: string) {
|
|
cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
|
|
process.run(cmd);
|
|
}
|
|
|
|
main :: () -> i32 {
|
|
gpa := GPA.init();
|
|
arena := Arena.init(xx gpa, 1 << 20);
|
|
defer arena.deinit();
|
|
|
|
// Fresh store + manifest dir, even after a crashed prior run.
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
process.run(concat("mkdir -p ", MDIR));
|
|
write_file(A_PATH, MANIFEST_A);
|
|
write_file(B_PATH, MANIFEST_B);
|
|
|
|
rel_a := "rel-acme-app-1.2.3";
|
|
rel_b := "rel-acme-app-1.2.4";
|
|
|
|
// ── 1. Publish version A into the fresh store ───────────────────────
|
|
ra := process.run(publish_cmd(A_PATH));
|
|
process.assert(ra != null, "spawn publish A failed");
|
|
res_a := ra!;
|
|
process.assert(res_a.exit_code == 0, "publish A must exit 0");
|
|
va, ea := parse(res_a.stdout, xx arena);
|
|
if ea { process.assert(false, "publish A stdout must be one JSON object"); return 1; }
|
|
process.assert(get_str(get_obj(va.object, "release"), "id") == rel_a, "A release id");
|
|
|
|
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "after A: db has one release");
|
|
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "after A: db has one app");
|
|
print(" A published; db has 1 release\n");
|
|
|
|
// ── 2. Publish a DIFFERENT version B into the SAME store ────────────
|
|
rb := process.run(publish_cmd(B_PATH));
|
|
process.assert(rb != null, "spawn publish B failed");
|
|
res_b := rb!;
|
|
process.assert(res_b.exit_code == 0, "publish B must exit 0 (accumulates)");
|
|
vb, eb := parse(res_b.stdout, xx arena);
|
|
if eb { process.assert(false, "publish B stdout must be one JSON object"); return 1; }
|
|
process.assert(get_str(get_obj(vb.object, "release"), "id") == rel_b, "B release id");
|
|
|
|
// The crux: BOTH releases under ONE app (app found, not duplicated).
|
|
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 2, "after B: db records BOTH releases (no clobber)");
|
|
process.assert(has_release(rel_a), "after B: release A still present");
|
|
process.assert(has_release(rel_b), "after B: release B present");
|
|
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "after B: still ONE app (found, not duplicated)");
|
|
|
|
// Four artifacts (two per release); channel promoted to the latest (B).
|
|
process.assert(q_int("SELECT COUNT(*) FROM artifacts", "") == 4, "after B: four artifacts (two per release)");
|
|
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "after B: one channel");
|
|
process.assert(q_text("SELECT current_release_id FROM channels", "") == rel_b,
|
|
"after B: channel promoted to the latest release");
|
|
|
|
// Each artifact's content-addressed object exists on disk.
|
|
assert_objects_exist();
|
|
|
|
// Audit accumulated across both publishes.
|
|
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "release.publish") == 2,
|
|
"after B: one publish event per release");
|
|
print(" B accumulated; db has 2 releases under 1 app\n");
|
|
|
|
// ── 3. Re-publish the SAME release id (A) → duplicate is rejected ───
|
|
rdup := process.run(publish_cmd(A_PATH));
|
|
process.assert(rdup != null, "spawn duplicate publish failed");
|
|
res_dup := rdup!;
|
|
process.assert(res_dup.exit_code != 0, "re-publishing the same release id must FAIL (duplicate)");
|
|
|
|
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 2, "after duplicate: db UNCHANGED (still two releases)");
|
|
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "after duplicate: still one app");
|
|
print(" duplicate release id rejected; db unchanged\n");
|
|
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
print("publish_persist: ALL CASES PASS\n");
|
|
return 0;
|
|
}
|