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.
259 lines
14 KiB
Plaintext
259 lines
14 KiB
Plaintext
// Acceptance for P2.3 (transaction safety) — the publish transaction is
|
|
// atomic: a failure midway leaves the model EXACTLY as it was, with no
|
|
// half-inserted release/artifacts and no channel left pointing at a release
|
|
// that isn't in the repo (the "no dangling release" invariant).
|
|
//
|
|
// Flow:
|
|
// 1. Seed via a SUCCESSFUL publish (rel_00 + art_00, channel stable ->
|
|
// rel_00) — exercises the commit path.
|
|
// 2. A publish whose 2nd artifact is invalid must RAISE Validation and roll
|
|
// back: counts unchanged, channel still -> rel_00, nothing inserted.
|
|
// 3..7. Each Integrity precondition rejects + rolls back identically:
|
|
// (3) artifact.release_id mismatch, (4) cross-app channel
|
|
// (chan.app_id != release.app_id), (5) cross-app artifact
|
|
// (artifact.app_id != release.app_id), (6) channel-NAME mismatch
|
|
// (chan.name != release.channel), (7) release-id collision
|
|
// (release.id already exists).
|
|
// 8. Persist + reload: the rolled-back state is what hits the store — the
|
|
// reloaded channel still points at rel_00 and the attempted releases
|
|
// are absent.
|
|
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up.
|
|
#import "modules/std.sx";
|
|
process :: #import "modules/std/process.sx";
|
|
#import "../src/domain/platform.sx";
|
|
#import "../src/domain/app.sx";
|
|
#import "../src/domain/release.sx";
|
|
#import "../src/domain/artifact.sx";
|
|
#import "../src/domain/channel.sx";
|
|
#import "../src/domain/audit.sx";
|
|
#import "../src/repo/repo.sx";
|
|
#import "../src/repo/db.sx";
|
|
|
|
DIGEST_A :: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
|
DIGEST_B :: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
|
|
mk_release :: (id: string, version: string) -> Release {
|
|
return Release.{
|
|
id = id, app_id = "app_01", version = version, build = 1,
|
|
channel = "stable", notes = "", created_by = "ci",
|
|
created_at = 1700000000, published_at = 0,
|
|
};
|
|
}
|
|
mk_artifact :: (id: string, release_id: string, digest: string) -> Artifact {
|
|
return mk_artifact_for(id, "app_01", release_id, digest);
|
|
}
|
|
mk_artifact_for :: (id: string, app_id: string, release_id: string, digest: string) -> Artifact {
|
|
return Artifact.{
|
|
id = id, app_id = app_id, release_id = release_id, platform = .android_apk,
|
|
filename = "a.apk", content_type = "application/vnd.android.package-archive",
|
|
size_bytes = 1024, sha256 = digest, storage_key = digest,
|
|
metadata = "", validation_status = .pending,
|
|
};
|
|
}
|
|
the_channel :: () -> Channel {
|
|
return mk_channel_for("app_01");
|
|
}
|
|
mk_channel_for :: (app_id: string) -> Channel {
|
|
return mk_named_channel(app_id, "stable");
|
|
}
|
|
mk_named_channel :: (app_id: string, name: string) -> Channel {
|
|
return Channel.{
|
|
app_id = app_id, name = name, current_release_id = "",
|
|
policy = .manual, rollout_percent = 100,
|
|
};
|
|
}
|
|
|
|
main :: () -> i32 {
|
|
root := ".sx-tmp/repo-transaction";
|
|
process.run(concat("rm -rf ", root));
|
|
|
|
repo := Repo.init();
|
|
repo.create_app(App.{
|
|
id = "app_01", slug = "acme-app", display_name = "Acme",
|
|
owner = "user_01", created_at = 1, updated_at = 1,
|
|
});
|
|
|
|
// ── 1. Seed via a successful publish (commit path) ───────────────
|
|
arts0 : List(Artifact) = .{};
|
|
arts0.append(mk_artifact("art_00", "rel_00", DIGEST_A));
|
|
seed_failed := false;
|
|
repo.publish(mk_release("rel_00", "1.0.0"), @arts0, the_channel()) catch { seed_failed = true; };
|
|
process.assert(!seed_failed, "seed publish must commit");
|
|
process.assert(repo.releases.len == 1, "seed: one release");
|
|
process.assert(repo.artifacts.len == 1, "seed: one artifact");
|
|
process.assert(repo.channels.len == 1, "seed: one channel");
|
|
c0 := repo.get_channel("app_01", "stable");
|
|
process.assert(c0 != null, "seed: channel exists");
|
|
c0v := c0!;
|
|
process.assert(c0v.current_release_id == "rel_00", "seed: channel points at rel_00");
|
|
print(" seed publish committed: channel stable -> rel_00\n");
|
|
|
|
// ── snapshot the pre-transaction state ───────────────────────────
|
|
rel_n := repo.releases.len;
|
|
art_n := repo.artifacts.len;
|
|
chan_n := repo.channels.len;
|
|
|
|
// ── 2. Failing publish: 2nd artifact invalid -> rollback ─────────
|
|
arts1 : List(Artifact) = .{};
|
|
arts1.append(mk_artifact("art_01a", "rel_01", DIGEST_B)); // valid
|
|
arts1.append(mk_artifact("art_01b", "rel_01", "not-a-sha")); // invalid digest
|
|
failed := false;
|
|
was_validation := false;
|
|
repo.publish(mk_release("rel_01", "1.1.0"), @arts1, the_channel()) catch (e) {
|
|
failed = true;
|
|
was_validation = (e == error.Validation);
|
|
};
|
|
process.assert(failed, "publish with an invalid artifact must fail");
|
|
process.assert(was_validation, "invalid artifact must raise Validation");
|
|
|
|
// Rollback: nothing added, channel still points at the prior release.
|
|
process.assert(repo.releases.len == rel_n, "rollback: release count unchanged");
|
|
process.assert(repo.artifacts.len == art_n, "rollback: artifact count unchanged");
|
|
process.assert(repo.channels.len == chan_n, "rollback: channel count unchanged");
|
|
process.assert(repo.get_release("rel_01") == null, "rollback: no dangling release inserted");
|
|
process.assert(repo.find_artifact_by_digest(DIGEST_B) == null, "rollback: valid first artifact not inserted");
|
|
c1 := repo.get_channel("app_01", "stable");
|
|
process.assert(c1 != null, "rollback: channel still exists");
|
|
c1v := c1!;
|
|
process.assert(c1v.current_release_id == "rel_00", "rollback: no dangling channel pointer (still rel_00)");
|
|
print(" failed publish rolled back: no dangling release/channel\n");
|
|
|
|
// ── 3. Integrity failure: artifact names the wrong release ───────
|
|
arts2 : List(Artifact) = .{};
|
|
arts2.append(mk_artifact("art_02", "WRONG", DIGEST_B)); // release_id mismatch
|
|
ifailed := false;
|
|
was_integrity := false;
|
|
repo.publish(mk_release("rel_02", "1.2.0"), @arts2, the_channel()) catch (e) {
|
|
ifailed = true;
|
|
was_integrity = (e == error.Integrity);
|
|
};
|
|
process.assert(ifailed, "publish with mismatched artifact.release_id must fail");
|
|
process.assert(was_integrity, "release_id mismatch must raise Integrity");
|
|
process.assert(repo.releases.len == rel_n, "integrity rollback: release count unchanged");
|
|
process.assert(repo.artifacts.len == art_n, "integrity rollback: artifact count unchanged");
|
|
c2 := repo.get_channel("app_01", "stable");
|
|
process.assert(c2 != null, "integrity rollback: channel still exists");
|
|
c2v := c2!;
|
|
process.assert(c2v.current_release_id == "rel_00", "integrity rollback: channel still rel_00");
|
|
print(" integrity failure rolled back: channel still rel_00\n");
|
|
|
|
// ── 4. Cross-app channel promotion: chan.app_id != release.app_id ─
|
|
// The release belongs to app_01 but the promoted channel claims app_02.
|
|
// Committing it would leave a channel of one app pointing at another
|
|
// app's release — a cross-identity dangling edge. Must raise Integrity.
|
|
arts_xc : List(Artifact) = .{};
|
|
arts_xc.append(mk_artifact("art_xc", "rel_xc", DIGEST_B)); // self-consistent artifact
|
|
xc_failed := false;
|
|
xc_integrity := false;
|
|
repo.publish(mk_release("rel_xc", "1.3.0"), @arts_xc, mk_channel_for("app_02")) catch (e) {
|
|
xc_failed = true;
|
|
xc_integrity = (e == error.Integrity);
|
|
};
|
|
process.assert(xc_failed, "cross-app channel promotion must fail");
|
|
process.assert(xc_integrity, "cross-app channel promotion must raise Integrity");
|
|
process.assert(repo.releases.len == rel_n, "cross-app channel rollback: release count unchanged");
|
|
process.assert(repo.artifacts.len == art_n, "cross-app channel rollback: artifact count unchanged");
|
|
process.assert(repo.channels.len == chan_n, "cross-app channel rollback: channel count unchanged");
|
|
process.assert(repo.get_release("rel_xc") == null, "cross-app channel rollback: release absent");
|
|
process.assert(repo.get_channel("app_02", "stable") == null, "cross-app channel rollback: no app_02 channel created");
|
|
cx := repo.get_channel("app_01", "stable");
|
|
process.assert(cx != null, "cross-app channel rollback: app_01 channel still exists");
|
|
cxv := cx!;
|
|
process.assert(cxv.current_release_id == "rel_00", "cross-app channel rollback: app_01 channel still -> rel_00");
|
|
print(" cross-app channel promotion rejected: model unchanged\n");
|
|
|
|
// ── 5. Cross-app artifact: artifact.app_id != release.app_id ─────
|
|
// The release and channel both belong to app_01, but the artifact claims
|
|
// app_02 (its release_id still matches). Only release_id was checked
|
|
// before; the app_id mismatch must now raise Integrity and roll back.
|
|
arts_xa : List(Artifact) = .{};
|
|
arts_xa.append(mk_artifact_for("art_xa", "app_02", "rel_xa", DIGEST_B));
|
|
xa_failed := false;
|
|
xa_integrity := false;
|
|
repo.publish(mk_release("rel_xa", "1.4.0"), @arts_xa, the_channel()) catch (e) {
|
|
xa_failed = true;
|
|
xa_integrity = (e == error.Integrity);
|
|
};
|
|
process.assert(xa_failed, "cross-app artifact must fail");
|
|
process.assert(xa_integrity, "cross-app artifact must raise Integrity");
|
|
process.assert(repo.releases.len == rel_n, "cross-app artifact rollback: release count unchanged");
|
|
process.assert(repo.artifacts.len == art_n, "cross-app artifact rollback: artifact count unchanged");
|
|
process.assert(repo.channels.len == chan_n, "cross-app artifact rollback: channel count unchanged");
|
|
process.assert(repo.get_release("rel_xa") == null, "cross-app artifact rollback: release absent");
|
|
process.assert(repo.find_artifact_by_digest(DIGEST_B) == null, "cross-app artifact rollback: artifact absent");
|
|
cy := repo.get_channel("app_01", "stable");
|
|
process.assert(cy != null, "cross-app artifact rollback: channel still exists");
|
|
cyv := cy!;
|
|
process.assert(cyv.current_release_id == "rel_00", "cross-app artifact rollback: channel still -> rel_00");
|
|
print(" cross-app artifact rejected: model unchanged\n");
|
|
|
|
// ── 6. Channel-NAME mismatch: chan.name != release.channel ───────
|
|
// The release declares channel = "stable" as its target, but a channel
|
|
// NAMED "beta" (same app) is promoted. Committing would point the beta
|
|
// channel at a release that names stable as its target — an edge that
|
|
// contradicts the release's own declared channel. Must raise Integrity.
|
|
arts_cn : List(Artifact) = .{};
|
|
arts_cn.append(mk_artifact("art_cn", "rel_cn", DIGEST_B)); // self-consistent artifact
|
|
cn_failed := false;
|
|
cn_integrity := false;
|
|
repo.publish(mk_release("rel_cn", "1.5.0"), @arts_cn, mk_named_channel("app_01", "beta")) catch (e) {
|
|
cn_failed = true;
|
|
cn_integrity = (e == error.Integrity);
|
|
};
|
|
process.assert(cn_failed, "channel-name mismatch must fail");
|
|
process.assert(cn_integrity, "channel-name mismatch must raise Integrity");
|
|
process.assert(repo.releases.len == rel_n, "channel-name rollback: release count unchanged");
|
|
process.assert(repo.artifacts.len == art_n, "channel-name rollback: artifact count unchanged");
|
|
process.assert(repo.channels.len == chan_n, "channel-name rollback: channel count unchanged");
|
|
process.assert(repo.get_release("rel_cn") == null, "channel-name rollback: release absent");
|
|
process.assert(repo.get_channel("app_01", "beta") == null, "channel-name rollback: no beta channel created");
|
|
ccn := repo.get_channel("app_01", "stable");
|
|
process.assert(ccn != null, "channel-name rollback: stable channel still exists");
|
|
ccnv := ccn!;
|
|
process.assert(ccnv.current_release_id == "rel_00", "channel-name rollback: stable channel still -> rel_00");
|
|
print(" channel-name mismatch rejected: model unchanged\n");
|
|
|
|
// ── 7. Release-id collision: a publish must introduce a NEW release ─
|
|
// Re-publishing rel_00's id would shadow the existing release: get_release
|
|
// resolves to the OLD one, so the channel edge would silently point at a
|
|
// release other than the one being published. Must raise Integrity and
|
|
// leave the original rel_00 untouched.
|
|
arts_dup : List(Artifact) = .{};
|
|
arts_dup.append(mk_artifact("art_dup", "rel_00", DIGEST_B));
|
|
dup_failed := false;
|
|
dup_integrity := false;
|
|
repo.publish(mk_release("rel_00", "9.9.9"), @arts_dup, the_channel()) catch (e) {
|
|
dup_failed = true;
|
|
dup_integrity = (e == error.Integrity);
|
|
};
|
|
process.assert(dup_failed, "republishing an existing release id must fail");
|
|
process.assert(dup_integrity, "release-id collision must raise Integrity");
|
|
process.assert(repo.releases.len == rel_n, "collision rollback: release count unchanged");
|
|
process.assert(repo.artifacts.len == art_n, "collision rollback: artifact count unchanged");
|
|
process.assert(repo.channels.len == chan_n, "collision rollback: channel count unchanged");
|
|
rdup := repo.get_release("rel_00");
|
|
process.assert(rdup != null, "collision: original rel_00 still present");
|
|
rdupv := rdup!;
|
|
process.assert(rdupv.version == "1.0.0", "collision: original release not overwritten");
|
|
print(" release-id collision rejected: original release untouched\n");
|
|
|
|
// ── 8. Persisted state reflects the rollback, not the attempts ───
|
|
serr := false;
|
|
save(repo, root) catch { serr = true; };
|
|
process.assert(!serr, "save must succeed");
|
|
repo2, lerr := load(root);
|
|
if lerr { process.assert(false, "load must succeed"); return 1; }
|
|
process.assert(repo2.releases.len == rel_n, "persisted: release count is the committed one");
|
|
process.assert(repo2.get_release("rel_01") == null, "persisted: rolled-back release absent");
|
|
rc := repo2.get_channel("app_01", "stable");
|
|
process.assert(rc != null, "persisted: channel present");
|
|
rcv := rc!;
|
|
process.assert(rcv.current_release_id == "rel_00", "persisted: channel points at a release that exists");
|
|
process.assert(repo2.get_release(rcv.current_release_id) != null, "persisted: channel target is a real release (no dangling)");
|
|
print(" persisted store reflects the rolled-back state\n");
|
|
|
|
process.run(concat("rm -rf ", root));
|
|
print("repo_transaction: ALL CASES PASS\n");
|
|
return 0;
|
|
}
|