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}
87
88/// The version history of one user table.
89#[derive(Debug, Clone, PartialEq)]
90pub struct TableHistory {
91 /// The table's `sqlite_master.name`.
92 pub table: String,
93 /// The table's column names (real names where the schema parsed, else generic).
94 pub columns: Vec<String>,
95 /// Whether this is a `WITHOUT ROWID` table — such tables have no rowid and are
96 /// not version-tracked here; `versions` is then empty (presence recorded only).
97 pub without_rowid: bool,
98 /// Every row version, sorted by `rowid` (`None` last), then `commit_seq`
99 /// ascending, with carved-residue (order-unknown) versions grouped after the
100 /// ordered versions of their rowid.
101 pub versions: Vec<RowVersion>,
102}
103
104/// One chronological VIEW of a single table: a rowid→values map plus the view's
105/// logical position and trust flags. The ordered list of these (commit views in
106/// epoch order, then the final live view last) is the input to
107/// [`build_rowid_versions`].
108// `is_final` / `checksum_valid` / `schema_known` are independent per-view trust
109// bits, not a state machine; the excessive-bools lint is waived for clarity.
110#[allow(clippy::struct_excessive_bools)]
111#[derive(Debug, Clone, PartialEq)]
112pub struct RowView {
113 /// LOGICAL commit sequence within the salt epoch (monotonic within an epoch
114 /// only). `None` for the final live view.
115 pub commit_seq: Option<u32>,
116 /// Whether this is the final live view (the current state).
117 pub is_final: bool,
118 /// Whether the source commit's checksum chain validated. `false` = residue:
119 /// rows first attributed here are flagged `attribution_uncertain`.
120 pub checksum_valid: bool,
121 /// Whether the table's schema could be reconstructed for this view. `false`
122 /// flags rows attributed here `attribution_uncertain`.
123 pub schema_known: bool,
124 /// This view's logical provenance — [`VersionOrigin::Live`] for the final
125 /// view, [`VersionOrigin::Commit`] (carrying the real [`CommitId`](crate::CommitId))
126 /// for a WAL commit view. Copied verbatim onto every version first seen here.
127 pub origin: VersionOrigin,
128 /// This view's rows: rowid → decoded values. A rowid absent from the map is
129 /// absent in this view.
130 pub rows: BTreeMap<i64, Vec<Value>>,
131}
132
133/// Build the ordered [`RowVersion`]s for ONE `rowid` by walking `views` (already
134/// in chronological order: epoch commits ascending, then the final live view).
135///
136/// Emits a version only when the value CHANGES (insert / update / delete /
137/// reinsert), collapsing identical consecutive values into one version labelled
138/// by the EARLIEST view it appeared in. View-state is evidence-based:
139/// `PresentInFinalView` when the value is the final live value; `ValueChangedLater`
140/// when a later view replaced it with a different value; `AbsentInFinalView` when
141/// the rowid's last value disappeared. Rowid REUSE (present → absent → present
142/// with a different record) flags every version of the rowid `rowid_reused` and
143/// treats the post-gap value as a fresh insert.
144///
145/// Pure: no I/O. The result is in chronological order for this rowid.
146#[must_use]
147pub fn build_rowid_versions(rowid: i64, views: &[RowView]) -> Vec<RowVersion> {
148 // ---- 1. Collapse the per-view presence sequence into maximal runs --------
149 // A "present run" is a maximal stretch of views holding the SAME value with
150 // no intervening absence; an absence (the rowid missing from a view's map)
151 // breaks a run even when the value later reappears identically. We record
152 // each present run's value, the EARLIEST view it appeared in (the label),
153 // that view's trust flags, and whether an absence GAP preceded it.
154 struct Run<'a> {
155 values: &'a [Value],
156 earliest_seq: Option<u32>,
157 origin: VersionOrigin,
158 uncertain: bool,
159 gap_before: bool,
160 }
161 let mut runs: Vec<Run> = Vec::new();
162 let mut seen_present = false;
163 let mut pending_gap = false;
164 for view in views {
165 match view.rows.get(&rowid) {
166 None => {
167 // An absence only counts as a gap once the rowid has appeared.
168 if seen_present {
169 pending_gap = true;
170 }
171 }
172 Some(values) => {
173 let uncertain = !view.checksum_valid || !view.schema_known;
174 let extends = matches!(runs.last(), Some(r) if r.values == values.as_slice());
175 if extends && !pending_gap {
176 // Same value, no gap: extend the current run (collapse).
177 } else if extends && pending_gap {
178 // Same value across a gap: still the same record by evidence —
179 // collapse into the existing run (not a reuse), clearing the
180 // gap so it is not treated as a delete+reinsert.
181 } else {
182 runs.push(Run {
183 values,
184 earliest_seq: view.commit_seq,
185 origin: view.origin.clone(),
186 uncertain,
187 gap_before: pending_gap,
188 });
189 }
190 seen_present = true;
191 pending_gap = false;
192 }
193 }
194 }
195
196 if runs.is_empty() {
197 return Vec::new();
198 }
199
200 // ---- 2. Final-view facts -------------------------------------------------
201 // The rowid's value in the final live view (None if absent there). Whether
202 // the LAST run reaches the final view (no trailing gap after it).
203 let final_values: Option<&[Value]> = views
204 .iter()
205 .rev()
206 .find(|v| v.is_final)
207 .and_then(|v| v.rows.get(&rowid))
208 .map(Vec::as_slice);
209 let present_in_final = final_values.is_some();
210
211 // ---- 3. Reuse detection --------------------------------------------------
212 // Reuse = a present run, a gap, then a DIFFERENT value. `gap_before` on any
213 // run past the first is exactly that signal (a same-value-across-gap run was
214 // collapsed in step 1, so it never carries gap_before here).
215 let rowid_reused = runs.iter().skip(1).any(|r| r.gap_before);
216
217 // ---- 4. Emit one RowVersion per run --------------------------------------
218 let last_idx = runs.len() - 1;
219 // A run is "ended by deletion" iff the rowid DISAPPEARED right after it — the
220 // NEXT run begins after a gap (reuse), or it is the last run and the rowid is
221 // absent in the final view. A run replaced by a different value with NO gap is
222 // a direct UPDATE (ValueChangedLater), not a deletion.
223 let next_gap: Vec<bool> = (0..runs.len())
224 .map(|i| runs.get(i + 1).is_some_and(|r| r.gap_before))
225 .collect();
226 runs.iter()
227 .enumerate()
228 .map(|(i, run)| {
229 let is_last = i == last_idx;
230 // The final value reaches the last run iff that run is present in the
231 // final view AND its value equals the final value.
232 let is_final_value = is_last && present_in_final && final_values == Some(run.values);
233 let deleted_here = next_gap[i] || (is_last && !present_in_final);
234 let view_state = if is_final_value {
235 ViewState::PresentInFinalView
236 } else if deleted_here {
237 // The rowid disappeared after this value — its last value before
238 // the gap/end is AbsentInFinalView (a completed deletion).
239 ViewState::AbsentInFinalView
240 } else {
241 // An earlier value later replaced by a different one (direct update).
242 ViewState::ValueChangedLater
243 };
244 let is_deleted = matches!(view_state, ViewState::AbsentInFinalView);
245 RowVersion {
246 rowid: Some(rowid),
247 values: run.values.to_vec(),
248 // The version's origin is the EARLIEST view it appeared in — the
249 // same view that supplies its `commit_seq` label.
250 origin: run.origin.clone(),
251 commit_seq: run.earliest_seq,
252 view_state,
253 is_deleted,
254 is_guessed: false,
255 rowid_reused,
256 attribution_uncertain: run.uncertain,
257 }
258 })
259 .collect()
260}
261
262/// Assemble the [`TableHistory`] for ONE table from its chronological `views`
263/// (commit views in epoch order, then the final live view last) and its schema.
264///
265/// `WITHOUT ROWID` tables have no rowid to key a history on, so they are recorded
266/// with `without_rowid = true` and NO versions (a renderer annotates their
267/// presence). For an ordinary table, every rowid that appears in any view is run
268/// through [`build_rowid_versions`]; the resulting versions are sorted by `rowid`
269/// then `commit_seq` ascending (a `None` `commit_seq` — the live or order-unknown
270/// value — sorts LAST within a rowid).
271///
272/// Pure: no I/O.
273#[must_use]
274pub fn table_history(
275 table: String,
276 columns: Vec<String>,
277 without_rowid: bool,
278 views: &[RowView],
279) -> TableHistory {
280 if without_rowid {
281 return TableHistory {
282 table,
283 columns,
284 without_rowid: true,
285 versions: Vec::new(),
286 };
287 }
288 // Every rowid seen across all views, ascending.
289 let mut rowids: std::collections::BTreeSet<i64> = std::collections::BTreeSet::new();
290 for v in views {
291 rowids.extend(v.rows.keys().copied());
292 }
293 let mut versions: Vec<RowVersion> = Vec::new();
294 for rowid in rowids {
295 versions.extend(build_rowid_versions(rowid, views));
296 }
297 sort_versions(&mut versions);
298 TableHistory {
299 table,
300 columns,
301 without_rowid: false,
302 versions,
303 }
304}
305
306/// Re-sort a table's versions into the canonical order after a caller has
307/// appended more (e.g. the forensic layer merging carved residue): by `rowid`
308/// (`None` last), then `commit_seq` ascending with `None` (live / order-unknown
309/// residue) grouped LAST within the rowid. Stable.
310pub fn sort_table_versions(versions: &mut [RowVersion]) {
311 sort_versions(versions);
312}
313
314/// Sort versions by `rowid` (`None` last), then `commit_seq` ascending with a
315/// `None` `commit_seq` (live / order-unknown carved residue) grouped LAST within
316/// the rowid. Stable, so equal keys keep chronological insertion order.
317fn sort_versions(versions: &mut [RowVersion]) {
318 versions.sort_by(|a, b| {
319 // rowid: Some(_) before None; within Some, ascending by id.
320 let ra = a.rowid;
321 let rb = b.rowid;
322 let rowid_ord = match (ra, rb) {
323 (Some(x), Some(y)) => x.cmp(&y),
324 (Some(_), None) => std::cmp::Ordering::Less,
325 (None, Some(_)) => std::cmp::Ordering::Greater,
326 (None, None) => std::cmp::Ordering::Equal,
327 };
328 rowid_ord.then_with(|| {
329 // commit_seq: Some(_) (positioned) before None (live/residue), then
330 // ascending within Some.
331 match (a.commit_seq, b.commit_seq) {
332 (Some(x), Some(y)) => x.cmp(&y),
333 (Some(_), None) => std::cmp::Ordering::Less,
334 (None, Some(_)) => std::cmp::Ordering::Greater,
335 (None, None) => std::cmp::Ordering::Equal,
336 }
337 })
338 });
339}