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.
220 lines
10 KiB
Plaintext
220 lines
10 KiB
Plaintext
// Acceptance for P5.2 (round-trip) — the in-memory repository persisted to
|
|
// `<root>/dist.db` via the vendored SQLite and reloaded into a FRESH
|
|
// repository.
|
|
//
|
|
// Asserts:
|
|
// 1. Every entity (app + bundle ids, release, two artifacts, channel,
|
|
// audit event) survives save -> reload field-for-field.
|
|
// 2. The store file is a real SQLite database: dist.db exists (and no
|
|
// db.json is written), and an independent SQL query over it sees the
|
|
// saved rows.
|
|
// 3. Re-saving the reloaded repo into a second root reloads equal again
|
|
// (save -> load is idempotent on the model).
|
|
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up. Exits 0 only if
|
|
// every assertion holds (process.assert aborts otherwise).
|
|
#import "modules/std.sx";
|
|
#import "modules/std/fs.sx";
|
|
process :: #import "modules/std/process.sx";
|
|
sq :: #import "vendors/sqlite/sqlite.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";
|
|
|
|
// ── Field-for-field equality over the domain entities ────────────────
|
|
bundle_eq :: (a: BundleId, b: BundleId) -> bool {
|
|
return a.platform == b.platform and a.value == b.value;
|
|
}
|
|
app_eq :: (a: App, b: App) -> bool {
|
|
if a.id != b.id { return false; }
|
|
if a.slug != b.slug { return false; }
|
|
if a.display_name != b.display_name { return false; }
|
|
if a.owner != b.owner { return false; }
|
|
if a.visibility != b.visibility { return false; }
|
|
if a.created_at != b.created_at { return false; }
|
|
if a.updated_at != b.updated_at { return false; }
|
|
if a.bundle_ids.len != b.bundle_ids.len { return false; }
|
|
i := 0;
|
|
while i < a.bundle_ids.len {
|
|
if !bundle_eq(a.bundle_ids.items[i], b.bundle_ids.items[i]) { return false; }
|
|
i += 1;
|
|
}
|
|
return true;
|
|
}
|
|
release_eq :: (a: Release, b: Release) -> bool {
|
|
return a.id == b.id and a.app_id == b.app_id and a.version == b.version
|
|
and a.build == b.build and a.channel == b.channel and a.notes == b.notes
|
|
and a.created_by == b.created_by and a.created_at == b.created_at
|
|
and a.published_at == b.published_at;
|
|
}
|
|
artifact_eq :: (a: Artifact, b: Artifact) -> bool {
|
|
return a.id == b.id and a.app_id == b.app_id and a.release_id == b.release_id
|
|
and a.platform == b.platform and a.filename == b.filename
|
|
and a.content_type == b.content_type and a.size_bytes == b.size_bytes
|
|
and a.sha256 == b.sha256 and a.storage_key == b.storage_key
|
|
and a.metadata == b.metadata and a.validation_status == b.validation_status;
|
|
}
|
|
channel_eq :: (a: Channel, b: Channel) -> bool {
|
|
return a.app_id == b.app_id and a.name == b.name
|
|
and a.current_release_id == b.current_release_id
|
|
and a.policy == b.policy and a.rollout_percent == b.rollout_percent;
|
|
}
|
|
audit_eq :: (a: AuditEvent, b: AuditEvent) -> bool {
|
|
return a.id == b.id and a.actor == b.actor and a.action == b.action
|
|
and a.target_type == b.target_type and a.target_id == b.target_id
|
|
and a.metadata == b.metadata and a.created_at == b.created_at;
|
|
}
|
|
|
|
// ── Fixtures ─────────────────────────────────────────────────────────
|
|
the_app :: (alloc: Allocator) -> App {
|
|
a : App = .{
|
|
id = "app_01", slug = "acme-app", display_name = "Acme App",
|
|
owner = "user_01", visibility = .public,
|
|
created_at = 1700000000, updated_at = 1700000050,
|
|
};
|
|
a.bundle_ids.append(BundleId.{ platform = .ios, value = "co.acme.app" }, alloc);
|
|
a.bundle_ids.append(BundleId.{ platform = .android_apk, value = "co.acme.app.android" }, alloc);
|
|
return a;
|
|
}
|
|
the_release :: () -> Release {
|
|
return Release.{
|
|
id = "rel_01", app_id = "app_01", version = "1.2.3-beta.1", build = 42,
|
|
channel = "stable", notes = "first cut\nwith a newline", created_by = "user_01",
|
|
created_at = 1700000100, published_at = 1700000200,
|
|
};
|
|
}
|
|
apk_artifact :: () -> Artifact {
|
|
return Artifact.{
|
|
id = "art_apk", app_id = "app_01", release_id = "rel_01", platform = .android_apk,
|
|
filename = "acme.apk", content_type = "application/vnd.android.package-archive",
|
|
size_bytes = 10485760,
|
|
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
storage_key = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
metadata = "{\"min_os\":\"14\"}", validation_status = .valid,
|
|
};
|
|
}
|
|
ipa_artifact :: () -> Artifact {
|
|
return Artifact.{
|
|
id = "art_ipa", app_id = "app_01", release_id = "rel_01", platform = .ios,
|
|
filename = "acme.ipa", content_type = "application/octet-stream",
|
|
size_bytes = 20971520,
|
|
sha256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
|
storage_key = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
|
metadata = "", validation_status = .pending,
|
|
};
|
|
}
|
|
the_channel :: () -> Channel {
|
|
return Channel.{
|
|
app_id = "app_01", name = "stable", current_release_id = "rel_01",
|
|
policy = .percentage, rollout_percent = 25,
|
|
};
|
|
}
|
|
the_event :: () -> AuditEvent {
|
|
return AuditEvent.{
|
|
id = "ev_01", actor = "user_01", action = "publish",
|
|
target_type = "release", target_id = "rel_01",
|
|
metadata = "{\"channel\":\"stable\"}", created_at = 1700000300,
|
|
};
|
|
}
|
|
|
|
main :: () -> i32 {
|
|
root := ".sx-tmp/repo-roundtrip";
|
|
root2 := ".sx-tmp/repo-roundtrip-2";
|
|
process.run(concat("rm -rf ", root));
|
|
process.run(concat("rm -rf ", root2));
|
|
|
|
// ── Build the original model ─────────────────────────────────────
|
|
repo := Repo.init();
|
|
app0 := the_app(repo.own_allocator);
|
|
rel0 := the_release();
|
|
apk0 := apk_artifact();
|
|
ipa0 := ipa_artifact();
|
|
chan0 := the_channel();
|
|
ev0 := the_event();
|
|
repo.create_app(app0);
|
|
repo.create_release(rel0);
|
|
repo.create_artifact(apk0);
|
|
repo.create_artifact(ipa0);
|
|
repo.create_channel(chan0);
|
|
repo.create_audit_event(ev0);
|
|
|
|
// ── Persist ──────────────────────────────────────────────────────
|
|
serr := false;
|
|
save(repo, root) catch { serr = true; };
|
|
process.assert(!serr, "save must succeed");
|
|
process.assert(fs.exists(path_join(root, "dist.db")), "dist.db must exist after save");
|
|
process.assert(!fs.exists(path_join(root, "db.json")), "save must not write a db.json");
|
|
|
|
// ── 2. dist.db is a real SQLite database (independent SQL read) ──
|
|
conn, oe := sq.Sqlite.open_v2(path_join(root, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
|
process.assert(!oe, "dist.db must open as a SQLite database");
|
|
st, pe := conn.prepare("SELECT COUNT(*) FROM artifacts");
|
|
process.assert(!pe, "artifacts must be queryable");
|
|
rc, se := st.step();
|
|
process.assert(!se, "count query must step");
|
|
process.assert(rc == sq.SQLITE_ROW and st.column_int64(0) == 2, "SQL sees both artifact rows");
|
|
st.finalize();
|
|
conn.close();
|
|
print(" dist.db is a queryable SQLite database\n");
|
|
|
|
// ── 1. Reload into a FRESH repo and compare field-for-field ──────
|
|
repo2, lerr := load(root);
|
|
if lerr { process.assert(false, "load must succeed"); return 1; }
|
|
process.assert(repo2.apps.len == 1, "one app reloaded");
|
|
process.assert(repo2.releases.len == 1, "one release reloaded");
|
|
process.assert(repo2.artifacts.len == 2, "two artifacts reloaded");
|
|
process.assert(repo2.channels.len == 1, "one channel reloaded");
|
|
process.assert(repo2.audit_events.len == 1, "one audit event reloaded");
|
|
|
|
process.assert(app_eq(repo2.apps.items[0], app0), "app survives round-trip");
|
|
|
|
rg := repo2.get_release("rel_01");
|
|
process.assert(rg != null, "release found by id after reload");
|
|
process.assert(release_eq(rg!, rel0), "release survives round-trip");
|
|
|
|
// find_artifact_by_digest — the P2.2 content-address lookup.
|
|
ag := repo2.find_artifact_by_digest(apk0.sha256);
|
|
process.assert(ag != null, "apk artifact found by digest after reload");
|
|
process.assert(artifact_eq(ag!, apk0), "apk artifact survives round-trip");
|
|
ig := repo2.find_artifact_by_digest(ipa0.sha256);
|
|
process.assert(ig != null, "ipa artifact found by digest after reload");
|
|
process.assert(artifact_eq(ig!, ipa0), "ipa artifact survives round-trip");
|
|
|
|
cg := repo2.get_channel("app_01", "stable");
|
|
process.assert(cg != null, "channel found after reload");
|
|
process.assert(channel_eq(cg!, chan0), "channel survives round-trip");
|
|
|
|
process.assert(audit_eq(repo2.audit_events.items[0], ev0), "audit event survives round-trip");
|
|
|
|
// find_app_by_slug — the slug lookup.
|
|
sg := repo2.find_app_by_slug("acme-app");
|
|
process.assert(sg != null, "app found by slug after reload");
|
|
sga := sg!;
|
|
process.assert(sga.id == "app_01", "slug lookup returns the right app");
|
|
print(" reloaded model equals original (every field)\n");
|
|
|
|
// ── 3. Re-save the reloaded repo -> reloads equal again ──────────
|
|
serr2 := false;
|
|
save(repo2, root2) catch { serr2 = true; };
|
|
process.assert(!serr2, "re-save must succeed");
|
|
repo3, lerr2 := load(root2);
|
|
if lerr2 { process.assert(false, "re-load must succeed"); return 1; }
|
|
process.assert(repo3.apps.len == 1 and app_eq(repo3.apps.items[0], app0), "app survives second round-trip");
|
|
process.assert(repo3.releases.len == 1 and release_eq(repo3.releases.items[0], rel0), "release survives second round-trip");
|
|
process.assert(repo3.artifacts.len == 2 and artifact_eq(repo3.artifacts.items[0], apk0)
|
|
and artifact_eq(repo3.artifacts.items[1], ipa0), "artifacts survive second round-trip in order");
|
|
process.assert(repo3.channels.len == 1 and channel_eq(repo3.channels.items[0], chan0), "channel survives second round-trip");
|
|
process.assert(repo3.audit_events.len == 1 and audit_eq(repo3.audit_events.items[0], ev0), "audit event survives second round-trip");
|
|
print(" save -> load is idempotent on the model\n");
|
|
|
|
// ── cleanup ──────────────────────────────────────────────────────
|
|
process.run(concat("rm -rf ", root));
|
|
process.run(concat("rm -rf ", root2));
|
|
print("repo_roundtrip: ALL CASES PASS\n");
|
|
return 0;
|
|
}
|