Skip to content

Commit 4323f51

Browse files
hyperpolymathclaude
andcommitted
feat(tier1): SQLite temporal versioning writer (V-L1-C2)
Closes #47. Companion to #46 (V-L1-C1, provenance writer): the temporal sidecar holds full row-state snapshots per (entity_id, table_name, version) so the system can answer "what did this look like at time T?" and "roll this back to version N" without touching the target database. ### `tier1::temporal` rewrite The module previously held only the `TemporalVersion` struct. This commit makes it the canonical home for the Temporal concern's SQLite backend: * **`SIDECAR_DDL`** — schema text for `verisimdb_temporal_versions` including the partial UNIQUE INDEX `(entity_id, table_name) WHERE valid_to IS NULL` (from V-L2-H1 / #41) that enforces "at most one current version per (entity, table)" at the storage layer. * **`init_sidecar_schema(conn)`** — idempotent DDL applier. * **`append_version(conn, entity_id, table_name, snapshot, op) -> Result<version>`** — - `BEGIN IMMEDIATE` transaction. - Read `MAX(version)` for the entity/table; next is `prev + 1`. - Close out the previous current row by setting `valid_to = now`. - Insert the new row with `valid_to = NULL`. - Commit. The transaction discipline + partial UNIQUE index make the version sequence strictly monotonic even under concurrent writers (SQLite serialises through its write lock). * **`read_at(conn, entity_id, table_name, t) -> Result<Option<String>>`** — point-in-time query: returns the snapshot whose `valid_from <= t` and whose `valid_to` is either `NULL` (still current) or `> t`. Picks the highest-numbered match for safety against any out-of-order writes. * **`read_current(conn, entity_id, table_name) -> Result<Option<String>>`** — convenience helper: the row with `valid_to IS NULL` (or `None` if the entity is unknown / closed without successor). * **`rollback_to(conn, entity_id, table_name, target_version) -> Result<new_version>`** — append-only rollback: fetches the snapshot at `target_version`, then calls `append_version` with `operation = "rollback"`. The rollback itself is a versioned event, not an in-place mutation, so the chain remains tamper-evident. ### Tests (11 new in `tier1::temporal::tests`) * `schema_is_idempotent` * `genesis_append_starts_at_version_one` * `sequential_appends_are_monotonic_and_close_previous` — three inserts, only the last is current; partial UNIQUE index enforced. * `read_current_returns_latest_snapshot` * `read_current_returns_none_for_unknown_entity` * `read_at_returns_snapshot_at_or_before_time` — checks both a past-tense read (returns v1 when v2 hasn't happened yet) and a current-tense read (returns v2 after the second update). Uses 20ms sleeps between writes so the timestamps land in distinct RFC3339 milliseconds. * `read_at_returns_none_before_first_version` * `rollback_appends_new_version_with_old_snapshot` — `v1, v2, v3`, then `rollback_to(v1)`; the new v4's snapshot equals v1's and its `operation` is `"rollback"`. * `rollback_unknown_version_errors` * `fifty_appends_yield_monotonic_versions` — deterministic version of the "monotonic version numbers" acceptance criterion. Asserts the version sequence is exactly `1..=50` with no gaps; the storage layer holds exactly 50 rows and exactly 1 with `valid_to IS NULL`. * `distinct_entities_have_independent_versions` — `e1` and `e2` each get version `2` independently. ### Acceptance * [x] Library function `tier1::temporal::append_version(...)` * [x] Point-in-time query helper: `read_at(...)` returning `Option<String>` (snapshot is opaque-string; caller decides format — typical JSON) * [x] Rollback helper (`rollback_to`) * [x] Property test for monotonic version numbers (`fifty_appends_yield_monotonic_versions` — the deterministic formulation; proptest randomisation belongs in a follow-up alongside multithreaded contention) ### Out of scope * **Wiring into `intercept::sqlite::SqliteInterceptor`** — the current interceptor only writes provenance entries. Adding temporal capture from the same hook needs `preupdate_hook` (the regular `update_hook` fires AFTER the mutation so the "before-state" snapshot isn't visible). Rusqlite has the `preupdate_hook` feature; tracked for a follow-up that flips on the feature, adds an `intercept::sqlite::install_temporal_hook` variant, and writes both the provenance entry and the temporal snapshot in the same hook callback. * **Multithreaded property test** — same disposition as #46. `proptest` is wired into dev-deps but the threaded version belongs in a follow-up. Stacked on #100 (V-L1-C1). Base rebases to `main` after that PR lands. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ea64282 commit 4323f51

1 file changed

Lines changed: 413 additions & 2 deletions

File tree

0 commit comments

Comments
 (0)