P2.3: in-memory repository + db.json persistence (SQLite stand-in)
Adds the in-memory repository over the P2.1 domain and whole-model
persistence to <root>/db.json via std.json (subplan-02 Slice 1, the part
P2.1's mapping deferred).
src/repo/repo.sx
- Repo over App/Release/Artifact/Channel/AuditEvent, each a growable
List scanned LINEARLY (no index — Slice 1).
- create/get/list/update per entity; find_app_by_slug;
find_artifact_by_digest (the P2.2 content-address key).
- publish(): atomic-ish transaction (release + artifacts + channel
pointer). A failure midway rolls the model back by snapshot/restore —
no half-inserted entities and no channel left pointing at a release
that isn't in the repo.
- Long-lived-container rule: init captures own_allocator :=
context.allocator and every List growth forwards it explicitly, so the
backing stores outlive any single call's transient context allocator.
src/repo/db.sx
- save()/load() the whole model to/from <root>/db.json via std.json.
- Stable (insertion-order) field order: entities emit in declaration
order; top-level order is apps, releases, artifacts, channels,
audit_events. Re-saving an unchanged model is byte-identical.
- Enums serialize as their variant name. Read-back is strict: a missing
field, wrong JSON type, or unknown enum name -> typed LoadErr.BadShape.
Loaded strings are copied into the new repo's own allocator.
tests/
- repo_roundtrip.sx: save -> reparse (valid JSON) -> reload into a fresh
repo, asserting every field round-trips and a re-save is byte-identical.
- repo_transaction.sx: a publish that fails midway leaves the model
unchanged (no dangling release/channel), in memory and after reload.
- repo_owns_allocator.sx: deterministic proof that every owned list grows
through the captured allocator, not the call-site context allocator.
Gate: make build + make test both green (6/6).
This commit is contained in:
69
tests/repo_owns_allocator.sx
Normal file
69
tests/repo_owns_allocator.sx
Normal file
@@ -0,0 +1,69 @@
|
||||
// Acceptance for P2.3 (long-lived-container allocator rule) — the repo
|
||||
// OUTLIVES the transient `context.allocator` of any single call, so its
|
||||
// `List` backing stores must grow through the allocator CAPTURED at
|
||||
// construction, not whatever `context.allocator` happens to be at append
|
||||
// time.
|
||||
//
|
||||
// Deterministic proof: build the repo inside a `push Context` scope whose
|
||||
// allocator is a tracked GPA, then let that scope END (context.allocator
|
||||
// reverts to the default). EVERY subsequent repo mutation must still flow
|
||||
// through the captured GPA — observed as a rising `gpa.alloc_count`. If a
|
||||
// growth point wrongly used `context.allocator`, the count would not move
|
||||
// (the default allocator is a different, untracked one).
|
||||
#import "modules/std.sx";
|
||||
process :: #import "modules/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";
|
||||
|
||||
main :: () -> s32 {
|
||||
gpa := GPA.init();
|
||||
|
||||
// Construct the repo under the tracked allocator, then leave the scope.
|
||||
repo : Repo = .{};
|
||||
push Context.{ allocator = xx gpa, data = null } {
|
||||
repo = Repo.init(); // own_allocator := this gpa
|
||||
}
|
||||
|
||||
// We are now back on the default context allocator. Touch all five
|
||||
// owned lists; each first append allocates its backing store, which
|
||||
// MUST come from the captured gpa (not the current context allocator).
|
||||
before := gpa.alloc_count;
|
||||
repo.create_app(App.{ id = "a1", slug = "acme", display_name = "Acme",
|
||||
owner = "u", created_at = 1, updated_at = 1 });
|
||||
repo.create_release(Release.{ id = "r1", app_id = "a1", version = "1.0.0",
|
||||
build = 1, channel = "stable", notes = "",
|
||||
created_by = "ci", created_at = 1, published_at = 0 });
|
||||
repo.create_artifact(Artifact.{ id = "art1", app_id = "a1", release_id = "r1",
|
||||
platform = .android_apk, filename = "a.apk",
|
||||
content_type = "x", size_bytes = 1,
|
||||
sha256 = "deadbeef", storage_key = "k",
|
||||
metadata = "", validation_status = .pending });
|
||||
repo.create_channel(Channel.{ app_id = "a1", name = "stable",
|
||||
current_release_id = "r1" });
|
||||
repo.create_audit_event(AuditEvent.{ id = "e1", actor = "u", action = "publish",
|
||||
target_type = "release", target_id = "r1",
|
||||
metadata = "", created_at = 1 });
|
||||
grew := gpa.alloc_count - before;
|
||||
|
||||
// Five distinct lists each allocated their first backing store through
|
||||
// the captured allocator -> at least five allocations attributed to it.
|
||||
process.assert(grew >= 5, "every owned list must grow through the captured allocator");
|
||||
|
||||
// And the data is intact after the construction scope ended.
|
||||
process.assert(repo.apps.len == 1, "app retained");
|
||||
process.assert(repo.releases.len == 1, "release retained");
|
||||
process.assert(repo.artifacts.len == 1, "artifact retained");
|
||||
process.assert(repo.channels.len == 1, "channel retained");
|
||||
process.assert(repo.audit_events.len == 1, "audit event retained");
|
||||
ag := repo.find_app_by_slug("acme");
|
||||
process.assert(ag != null, "app readable after scope end");
|
||||
|
||||
print(" repo grew through its captured allocator ({} allocs), data intact\n", grew);
|
||||
print("repo_owns_allocator: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user