Commit 4323f51
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
0 commit comments