sqlite_core/row_history.rs
1//! Per-table, per-rowid VERSION HISTORY over a `SQLite` database's WAL temporal
2//! model — the evidence-based "row history" (Phase 1 of the WAL-temporal work).
3//!
4//! [`Database::row_histories`](crate::Database::row_histories) walks each salt
5//! epoch's [`CommitSnapshot`](crate::CommitSnapshot)s in commit order, then the
6//! final live view, and for every rowid emits the sequence of distinct record
7//! values it held — an `INSERT`/`UPDATE`/`DELETE`/reinsert history reconstructed
8//! purely from the bytes, with NO timestamps (SQLite WAL carries no wall-clock
9//! time; `commit_seq` is LOGICAL order within an epoch only).
10//!
11//! The two correctness-critical transforms are pure and unit-testable here:
12//! [`build_rowid_versions`] collapses identical consecutive values into one
13//! version (labelled by the EARLIEST view it appeared in) and classifies each
14//! version's [`ViewState`] from evidence; the same walk detects rowid REUSE (a
15//! present→absent→present-with-a-different-record gap is a delete+reinsert, two
16//! entities, never one continuous update).
17
18use crate::Value;
19use std::collections::BTreeMap;
20
21/// Where a [`RowVersion`] came from. Logical provenance only — never a timestamp.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum VersionOrigin {
24 /// The current on-disk ⊕ WAL live view (the FINAL state).
25 Live,
26 /// A materializable WAL commit, addressed by its [`CommitId`](crate::CommitId).
27 Commit(crate::CommitId),
28 /// Free-space carved residue with no precise temporal position (freeblocks
29 /// persist across commits, so the carve is ORDER-UNKNOWN).
30 CarvedResidue,
31}
32
33/// The EVIDENCE-based state of a row version relative to the FINAL live view.
34///
35/// Strictly evidence, never intent: it records what the bytes show (present /
36/// changed-later / absent / carved), not what the user meant to do.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ViewState {
39 /// This exact value is present and unchanged in the final live view — the
40 /// current row.
41 PresentInFinalView,
42 /// An earlier value that a later view replaced with a DIFFERENT value for the
43 /// same rowid.
44 ValueChangedLater,
45 /// The rowid's last value before it disappeared: absent from the final live
46 /// view (a deletion).
47 AbsentInFinalView,
48 /// A free-space carve — order-unknown residue, not a positioned commit value.
49 CarvedResidue,
50}
51
52/// One version of one row: a distinct record value the rowid held at some point,
53/// with its evidence-based classification and logical provenance.
54// The boolean flags are independent EVIDENCE bits (deleted / guessed / reused /
55// uncertain), each separately consumed by a renderer — not a hidden state machine
56// that a two-variant enum would clarify, so the excessive-bools lint is waived.
57#[allow(clippy::struct_excessive_bools)]
58#[derive(Debug, Clone, PartialEq)]
59pub struct RowVersion {
60 /// The rowid this version belongs to. `None` only for a carved residue whose
61 /// rowid was destroyed.
62 pub rowid: Option<i64>,
63 /// The decoded column values of this version, in column order.
64 pub values: Vec<Value>,
65 /// Logical provenance of the version.
66 pub origin: VersionOrigin,
67 /// The LOGICAL commit sequence (per salt epoch, monotonic WITHIN an epoch
68 /// only) of the EARLIEST view this value appeared in. `None` for the live
69 /// view and for carved residue (order-unknown).
70 pub commit_seq: Option<u32>,
71 /// Evidence-based view state.
72 pub view_state: ViewState,
73 /// Whether this version's rowid is absent from the final live view (a
74 /// deletion or a completed pre-reuse entity).
75 pub is_deleted: bool,
76 /// Whether this version was reconstructed/guessed rather than read directly
77 /// (carved residue with inferred attribution).
78 pub is_guessed: bool,
79 /// Whether this rowid was REUSED — present, then absent for ≥1 view, then
80 /// present again with a DIFFERENT record. Set on every version of a reused
81 /// rowid so a renderer never presents two entities as one continuous update.
82 pub rowid_reused: bool,
83 /// Whether this version's attribution is uncertain — its source view was
84 /// checksum-invalid residue, or its schema could not be reconstructed.
85 pub attribution_uncertain: bool,
86 /// Whether this value went ABSENT from ≥1 intermediate view and then
87 /// reappeared with the IDENTICAL record. Such a run collapses (it is not
88 /// rowid reuse — the same record by evidence), but the absence itself is
89 /// forensic evidence: a delete-then-reinsert of the same value (e.g. a message
90 /// deleted and re-sent). A LOW-CONFIDENCE signal — a transient absence in the
91 /// materialized commit views is indistinguishable from a genuine round-trip,
92 /// so it records what the bytes show, never intent (§4.2).
93 pub reinserted_after_gap: bool,
94}
95
96/// The version history of one user table.
97#[derive(Debug, Clone, PartialEq)]
98pub struct TableHistory {
99 /// The table's `sqlite_master.name`.
100 pub table: String,
101 /// The table's column names (real names where the schema parsed, else generic).
102 pub columns: Vec<String>,
103 /// Whether this is a `WITHOUT ROWID` table — such tables have no rowid and are
104 /// not version-tracked here; `versions` is then empty (presence recorded only),
105 /// and the live rows are carried in [`without_rowid_rows`](Self::without_rowid_rows).
106 pub without_rowid: bool,
107 /// Every row version, sorted by `rowid` (`None` last), then `commit_seq`
108 /// ascending, with carved-residue (order-unknown) versions grouped after the
109 /// ordered versions of their rowid.
110 pub versions: Vec<RowVersion>,
111 /// For a `WITHOUT ROWID` table (§1.4): its live rows read from the index
112 /// b-tree, in the table's column order. Empty for an ordinary table and for a
113 /// `WITHOUT ROWID` table whose rows could not be read. These have no version
114 /// history (the model is rowid-keyed); they are the present live state.
115 pub without_rowid_rows: Vec<Vec<Value>>,
116}
117
118/// One chronological VIEW of a single table: a rowid→values map plus the view's
119/// logical position and trust flags. The ordered list of these (commit views in
120/// epoch order, then the final live view last) is the input to
121/// [`build_rowid_versions`].
122// `is_final` / `checksum_valid` / `schema_known` are independent per-view trust
123// bits, not a state machine; the excessive-bools lint is waived for clarity.
124#[allow(clippy::struct_excessive_bools)]
125#[derive(Debug, Clone, PartialEq)]
126pub struct RowView {
127 /// LOGICAL commit sequence within the salt epoch (monotonic within an epoch
128 /// only). `None` for the final live view.
129 pub commit_seq: Option<u32>,
130 /// Whether this is the final live view (the current state).
131 pub is_final: bool,
132 /// Whether the source commit's checksum chain validated. `false` = residue:
133 /// rows first attributed here are flagged `attribution_uncertain`.
134 pub checksum_valid: bool,
135 /// Whether the table's schema could be reconstructed for this view. `false`
136 /// flags rows attributed here `attribution_uncertain`.
137 pub schema_known: bool,
138 /// This view's logical provenance — [`VersionOrigin::Live`] for the final
139 /// view, [`VersionOrigin::Commit`] (carrying the real [`CommitId`](crate::CommitId))
140 /// for a WAL commit view. Copied verbatim onto every version first seen here.
141 pub origin: VersionOrigin,
142 /// This view's rows: rowid → decoded values. A rowid absent from the map is
143 /// absent in this view.
144 pub rows: BTreeMap<i64, Vec<Value>>,
145}
146
147/// Build the ordered [`RowVersion`]s for ONE `rowid` by walking `views` (already
148/// in chronological order: epoch commits ascending, then the final live view).
149///
150/// Emits a version only when the value CHANGES (insert / update / delete /
151/// reinsert), collapsing identical consecutive values into one version labelled
152/// by the EARLIEST view it appeared in. View-state is evidence-based:
153/// `PresentInFinalView` when the value is the final live value; `ValueChangedLater`
154/// when a later view replaced it with a different value; `AbsentInFinalView` when
155/// the rowid's last value disappeared. Rowid REUSE (present → absent → present
156/// with a different record) flags every version of the rowid `rowid_reused` and
157/// treats the post-gap value as a fresh insert.
158///
159/// Pure: no I/O. The result is in chronological order for this rowid.
160#[must_use]
161pub fn build_rowid_versions(rowid: i64, views: &[RowView]) -> Vec<RowVersion> {
162 // ---- 1. Collapse the per-view presence sequence into maximal runs --------
163 // A "present run" is a maximal stretch of views holding the SAME value with
164 // no intervening absence; an absence (the rowid missing from a view's map)
165 // breaks a run even when the value later reappears identically. We record
166 // each present run's value, the EARLIEST view it appeared in (the label),
167 // that view's trust flags, and whether an absence GAP preceded it.
168 struct Run<'a> {
169 values: &'a [Value],
170 earliest_seq: Option<u32>,
171 origin: VersionOrigin,
172 uncertain: bool,
173 gap_before: bool,
174 // The value went absent then reappeared IDENTICAL within this run (§4.2):
175 // collapsed as one record, but the absence is recorded as evidence.
176 reappeared_after_gap: bool,
177 }
178 let mut runs: Vec<Run> = Vec::new();
179 let mut seen_present = false;
180 let mut pending_gap = false;
181 for view in views {
182 match view.rows.get(&rowid) {
183 None => {
184 // An absence only counts as a gap once the rowid has appeared.
185 if seen_present {
186 pending_gap = true;
187 }
188 }
189 Some(values) => {
190 let uncertain = !view.checksum_valid || !view.schema_known;
191 let extends = matches!(runs.last(), Some(r) if r.values == values.as_slice());
192 if extends && !pending_gap {
193 // Same value, no gap: extend the current run (collapse).
194 } else if extends && pending_gap {
195 // Same value across a gap: still the same record by evidence —
196 // collapse into the existing run (not a reuse), clearing the
197 // gap so it is not treated as a delete+reinsert. Record the
198 // absence as a low-confidence evidence bit (§4.2) rather than
199 // discarding it silently.
200 if let Some(r) = runs.last_mut() {
201 r.reappeared_after_gap = true;
202 }
203 } else {
204 runs.push(Run {
205 values,
206 earliest_seq: view.commit_seq,
207 origin: view.origin.clone(),
208 uncertain,
209 gap_before: pending_gap,
210 reappeared_after_gap: false,
211 });
212 }
213 seen_present = true;
214 pending_gap = false;
215 }
216 }
217 }
218
219 if runs.is_empty() {
220 return Vec::new();
221 }
222
223 // ---- 2. Final-view facts -------------------------------------------------
224 // The rowid's value in the final live view (None if absent there). Whether
225 // the LAST run reaches the final view (no trailing gap after it).
226 let final_values: Option<&[Value]> = views
227 .iter()
228 .rev()
229 .find(|v| v.is_final)
230 .and_then(|v| v.rows.get(&rowid))
231 .map(Vec::as_slice);
232 let present_in_final = final_values.is_some();
233
234 // ---- 3. Reuse detection --------------------------------------------------
235 // Reuse = a present run, a gap, then a DIFFERENT value. `gap_before` on any
236 // run past the first is exactly that signal (a same-value-across-gap run was
237 // collapsed in step 1, so it never carries gap_before here).
238 let rowid_reused = runs.iter().skip(1).any(|r| r.gap_before);
239
240 // ---- 4. Emit one RowVersion per run --------------------------------------
241 let last_idx = runs.len() - 1;
242 // A run is "ended by deletion" iff the rowid DISAPPEARED right after it — the
243 // NEXT run begins after a gap (reuse), or it is the last run and the rowid is
244 // absent in the final view. A run replaced by a different value with NO gap is
245 // a direct UPDATE (ValueChangedLater), not a deletion.
246 let next_gap: Vec<bool> = (0..runs.len())
247 .map(|i| runs.get(i + 1).is_some_and(|r| r.gap_before))
248 .collect();
249 runs.iter()
250 .enumerate()
251 .map(|(i, run)| {
252 let is_last = i == last_idx;
253 // The final value reaches the last run iff that run is present in the
254 // final view AND its value equals the final value.
255 let is_final_value = is_last && present_in_final && final_values == Some(run.values);
256 let deleted_here = next_gap[i] || (is_last && !present_in_final);
257 let view_state = if is_final_value {
258 ViewState::PresentInFinalView
259 } else if deleted_here {
260 // The rowid disappeared after this value — its last value before
261 // the gap/end is AbsentInFinalView (a completed deletion).
262 ViewState::AbsentInFinalView
263 } else {
264 // An earlier value later replaced by a different one (direct update).
265 ViewState::ValueChangedLater
266 };
267 let is_deleted = matches!(view_state, ViewState::AbsentInFinalView);
268 RowVersion {
269 rowid: Some(rowid),
270 values: run.values.to_vec(),
271 // The version's origin is the EARLIEST view it appeared in — the
272 // same view that supplies its `commit_seq` label.
273 origin: run.origin.clone(),
274 commit_seq: run.earliest_seq,
275 view_state,
276 is_deleted,
277 is_guessed: false,
278 rowid_reused,
279 attribution_uncertain: run.uncertain,
280 reinserted_after_gap: run.reappeared_after_gap,
281 }
282 })
283 .collect()
284}
285
286/// Assemble the [`TableHistory`] for ONE table from its chronological `views`
287/// (commit views in epoch order, then the final live view last) and its schema.
288///
289/// `WITHOUT ROWID` tables have no rowid to key a history on, so they are recorded
290/// with `without_rowid = true` and NO versions (a renderer annotates their
291/// presence). For an ordinary table, every rowid that appears in any view is run
292/// through [`build_rowid_versions`]; the resulting versions are sorted by `rowid`
293/// then `commit_seq` ascending (a `None` `commit_seq` — the live or order-unknown
294/// value — sorts LAST within a rowid).
295///
296/// Pure: no I/O.
297#[must_use]
298pub fn table_history(
299 table: String,
300 columns: Vec<String>,
301 without_rowid: bool,
302 views: &[RowView],
303) -> TableHistory {
304 if without_rowid {
305 // No rowid → no version history. The live rows (from the index b-tree) are
306 // filled in by the caller (see `Database::row_histories`), which has the
307 // reader; the pure builder leaves them empty.
308 return TableHistory {
309 table,
310 columns,
311 without_rowid: true,
312 versions: Vec::new(),
313 without_rowid_rows: Vec::new(),
314 };
315 }
316 // Every rowid seen across all views, ascending.
317 let mut rowids: std::collections::BTreeSet<i64> = std::collections::BTreeSet::new();
318 for v in views {
319 rowids.extend(v.rows.keys().copied());
320 }
321 let mut versions: Vec<RowVersion> = Vec::new();
322 for rowid in rowids {
323 versions.extend(build_rowid_versions(rowid, views));
324 }
325 sort_versions(&mut versions);
326 TableHistory {
327 table,
328 columns,
329 without_rowid: false,
330 versions,
331 without_rowid_rows: Vec::new(),
332 }
333}
334
335/// Re-sort a table's versions into the canonical order after a caller has
336/// appended more (e.g. the forensic layer merging carved residue): by `rowid`
337/// (`None` last), then `commit_seq` ascending with `None` (live / order-unknown
338/// residue) grouped LAST within the rowid. Stable.
339pub fn sort_table_versions(versions: &mut [RowVersion]) {
340 sort_versions(versions);
341}
342
343/// Sort versions by `rowid` (`None` last), then `commit_seq` ascending with a
344/// `None` `commit_seq` (live / order-unknown carved residue) grouped LAST within
345/// the rowid. Stable, so equal keys keep chronological insertion order.
346fn sort_versions(versions: &mut [RowVersion]) {
347 versions.sort_by(|a, b| {
348 // rowid: Some(_) before None; within Some, ascending by id.
349 let ra = a.rowid;
350 let rb = b.rowid;
351 let rowid_ord = match (ra, rb) {
352 (Some(x), Some(y)) => x.cmp(&y),
353 (Some(_), None) => std::cmp::Ordering::Less,
354 (None, Some(_)) => std::cmp::Ordering::Greater,
355 (None, None) => std::cmp::Ordering::Equal,
356 };
357 rowid_ord.then_with(|| {
358 // commit_seq: Some(_) (positioned) before None (live/residue), then
359 // ascending within Some.
360 match (a.commit_seq, b.commit_seq) {
361 (Some(x), Some(y)) => x.cmp(&y),
362 (Some(_), None) => std::cmp::Ordering::Less,
363 (None, Some(_)) => std::cmp::Ordering::Greater,
364 (None, None) => std::cmp::Ordering::Equal,
365 }
366 })
367 });
368}