macrame/temporal/replay.rs
1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4
5use crate::error::{DbError, Result};
6use crate::temporal::as_of::NodeAttributes;
7
8/// Full materialized state reconstructed from transaction_log replay (§5.5).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct MaterializedState {
11 pub seq_anchor: i64,
12 pub timestamp: String,
13 pub concepts: HashMap<String, NodeAttributes>,
14 pub edges: Vec<(String, String, String, String, String)>,
15 /// **Nothing had been recorded yet at `timestamp`** (0.8.0, B5, D-121).
16 ///
17 /// An empty state has two meanings and a caller can act differently on
18 /// them. *Everything was retired by then* is a fact about the data;
19 /// *the ledger had not started* is a fact about the question. Both come
20 /// back as zero concepts and zero edges, so the difference has to be
21 /// carried rather than inferred.
22 ///
23 /// Set only when the log was verified **intact** — see
24 /// `hot_log_reach`. If rows had been archived away, `ts` below the hot
25 /// floor is not "before history", it is "the history is in the other file",
26 /// and that path raises instead of answering.
27 ///
28 /// `#[serde(default)]` so the field is additive: a snapshot written without
29 /// it deserialises with `false`, which is the right answer for any state
30 /// that had rows to fold. Old snapshots cannot actually reach this code —
31 /// the container carries `SCHEMA_VERSION` and v8 refused every v7 file
32 /// (D-043) — but the tolerance costs nothing and the next field to arrive
33 /// may not land in a release that bumps the schema.
34 #[serde(default)]
35 pub predates_recorded_history: bool,
36}
37
38impl MaterializedState {
39 /// The state before any log row has been applied.
40 fn empty(ts: &str) -> Self {
41 Self {
42 seq_anchor: 0,
43 timestamp: ts.to_string(),
44 concepts: HashMap::new(),
45 edges: Vec::new(),
46 predates_recorded_history: false,
47 }
48 }
49}
50
51/// The newest log payload shape this build writes and the highest it can read.
52///
53/// Kept beside the folds because they are the only readers, and bumped in step
54/// with the `json_object('v', …)` literals in `schema::ddl` — a test asserts the
55/// two agree, since nothing else would notice them drifting apart.
56pub(crate) const PAYLOAD_VERSION: u8 = 2;
57
58/// Every fold partitions on `(table_name, entity_id)`, never `entity_id` alone.
59///
60/// The two namespaces are not disjoint and nothing makes them so. A link's
61/// `entity_id` is the synthetic `source|target|type|valid_from`; a concept's is
62/// whatever the caller passed, unvalidated (defect AD). Partitioning on the id
63/// alone therefore lets a concept and a link contend for one window, and
64/// `ROW_NUMBER() = 1` hands the whole partition to whichever has the greater
65/// `seq_id` — so the loser vanishes from the reconstruction while sitting
66/// plainly in both `concepts` and `transaction_log`. Silent, and on the read
67/// path the ledger exists to make trustworthy.
68///
69/// Validating identifiers would make the collision unreachable and is the
70/// durable fix; this makes it harmless regardless, which is the property worth
71/// having at the fold. `table_name` leads the partition because the log is
72/// already indexed on `entity_id` and the discriminator is two values wide.
73const HOT_FOLD: &str = r#"
74 SELECT seq_id, table_name, entity_id, operation, payload
75 FROM (
76 SELECT seq_id, table_name, entity_id, operation, payload,
77 ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
78 FROM transaction_log
79 WHERE recorded_at <= ?1
80 ) WHERE rn = 1
81"#;
82
83/// Fold over hot and cold together (§5.5, D-026). Requires `cold` to be ATTACHed.
84///
85/// The hot entry wins for entities present in both files because its `seq_id` is
86/// greater — the same last-writer-wins rule as snapshot composition.
87const COLD_FOLD: &str = r#"
88 SELECT seq_id, table_name, entity_id, operation, payload
89 FROM (
90 SELECT seq_id, table_name, entity_id, operation, payload,
91 ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
92 FROM (
93 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM main.transaction_log
94 UNION ALL
95 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM cold.transaction_log
96 ) WHERE recorded_at <= ?1
97 ) WHERE rn = 1
98"#;
99
100/// Fold over the hot log *above a snapshot anchor* (§5.5, D-049).
101///
102/// `seq_id > ?2` is an inequality, and deliberately so: `AUTOINCREMENT` leaves
103/// gaps whenever a transaction rolls back, so successor arithmetic
104/// (`seq_id = :anchor + 1`) would stop at the first gap and silently truncate
105/// the delta. This is the first anchored fold in the crate, which makes it the
106/// first code D-024's rule has ever bound — before this the rule was vacuous,
107/// not satisfied.
108const ANCHORED_HOT_FOLD: &str = r#"
109 SELECT seq_id, table_name, entity_id, operation, payload
110 FROM (
111 SELECT seq_id, table_name, entity_id, operation, payload,
112 ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
113 FROM transaction_log
114 WHERE recorded_at <= ?1 AND seq_id > ?2
115 ) WHERE rn = 1
116"#;
117
118/// Fold over hot **and cold** above a snapshot anchor (§5.5, 0.5.5).
119///
120/// The union is what lets composition survive an archive. Rows keep their
121/// `seq_id` when they move to cold — the cold schema declares a plain `INTEGER
122/// PRIMARY KEY` precisely so history is not renumbered — so `seq_id > ?2`
123/// partitions the two files consistently and last-writer-wins across them by the
124/// same rule the unanchored folds use.
125const ANCHORED_COLD_FOLD: &str = r#"
126 SELECT seq_id, table_name, entity_id, operation, payload
127 FROM (
128 SELECT seq_id, table_name, entity_id, operation, payload,
129 ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
130 FROM (
131 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM main.transaction_log
132 UNION ALL
133 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM cold.transaction_log
134 ) WHERE recorded_at <= ?1 AND seq_id > ?2
135 ) WHERE rn = 1
136"#;
137
138/// The winning log rows for one fold, before they are applied to a base state.
139///
140/// Absence and disappearance are different facts, and a merge is where the
141/// difference starts to matter. A full fold from nothing can treat "this entity
142/// went away" and "there is no row for it" identically — both end as absence.
143/// Composed onto a snapshot they are opposites: a disappearance must *remove*
144/// the entity the snapshot carries, and skipping it leaves the snapshot's stale
145/// row standing as though nothing had happened. So they are collected rather
146/// than dropped, and the full fold applies them to an empty base, which keeps
147/// one code path for both cases (D-049).
148///
149/// **There is one such set, not two (D-072).** It used to carry `edges_gone`
150/// beside `concepts_gone`, and both were populated only from the `'D'` branch of
151/// [`fold_delta`] — so when that branch became an error, `edges_gone` was left
152/// reachable by nothing. Closing one unreachable path by opening another is not
153/// a fix, so it went too.
154///
155/// The asymmetry is real and worth stating, because "concepts can vanish and
156/// edges cannot" looks like an oversight until you follow it:
157///
158/// * A **concept** disappears by being *retired*, which writes a `'U'` row whose
159/// payload has `retired = 1`. That is a genuine removal from a composed state
160/// and `concepts_gone` carries it.
161/// * An **edge** never disappears. It is retired by asserting a successor over
162/// the same interval key — same `source|target|type|valid_from`, later
163/// `recorded_at` — so the log row is an `'I'` under the *same* `entity_id`, and
164/// last-writer-wins in [`Self::apply_to`] replaces the tuple in place. There is
165/// nothing to remove because nothing left; the interval simply closed.
166///
167/// That is Doctrine III showing through: an edge assertion is immutable and
168/// superseded, never deleted.
169#[derive(Default)]
170struct Delta {
171 concepts: HashMap<String, NodeAttributes>,
172 /// Keyed by `transaction_log.entity_id`: `source|target|type|valid_from`.
173 edges: HashMap<String, (String, String, String, String, String)>,
174 /// Concepts retired as of the fold's instant. See the type's note for why
175 /// there is no edge equivalent.
176 concepts_gone: HashSet<String>,
177 max_seq: i64,
178}
179
180/// The log's `entity_id` for a link, rebuilt from a materialised edge tuple.
181///
182/// Must match `trg_links_log_i`'s
183/// `source_id || '|' || target_id || '|' || edge_type || '|' || valid_from`
184/// exactly, or a delta row will fail to replace the snapshot row it supersedes.
185/// Safe because ULIDs are Crockford base32 and edge types are `[A-Z0-9]+`, so
186/// `|` cannot occur inside a component (§4.3).
187fn edge_key(e: &(String, String, String, String, String)) -> String {
188 format!("{}|{}|{}|{}", e.0, e.1, e.2, e.3)
189}
190
191/// Release a `cold` handle left attached by an earlier call (§5.5, D-044).
192///
193/// Both ATTACH sites pair with an unconditional DETACH on the way out, so in
194/// the normal course this finds nothing and the statement fails harmlessly with
195/// "no such database: cold". It exists for the case the pairing cannot cover: a
196/// panic unwinding between the two, which skips the DETACH no matter which exit
197/// path the `Result` would have taken.
198///
199/// A `Drop` guard is the reflex here and does not work — `execute` is `async`,
200/// and a `Drop` impl cannot await, so it would build a future, discard it, and
201/// leave the handle attached while looking like it had cleaned up. Recovering
202/// on the way *in* needs no destructor, works regardless of how the handle
203/// leaked, and turns permanent poisoning of the connection into one failed
204/// statement nobody sees.
205pub(crate) async fn detach_stale_cold(conn: &libsql::Connection) {
206 let _ = conn.execute("DETACH DATABASE cold", ()).await;
207}
208
209/// Reconstruct database state as believed at past instant `ts` using window-function log fold (§5.5, D-026).
210///
211/// When `ts` predates the hot log's horizon the cold database is ATTACHed for
212/// exactly one fold and DETACHed unconditionally on the way out, error paths
213/// included. ATTACH is not transactional and survives ROLLBACK, so a handle
214/// leaked by an early return would make every later `reconstruct` *and* every
215/// later `archive` fail with "database cold is already in use" — one corrupt
216/// payload would permanently poison the connection. This is the same failure
217/// mode `archive()` carries a note about, and the two now share a shape.
218/// Snapshot composition (§5.5, D-049) applies when `snapshots_dir` holds a
219/// snapshot at or before `ts` and no archive database exists — see
220/// `snapshot_anchor` for why archiving disables it. Otherwise the fold runs
221/// from genesis, which is correct and costs what the whole log costs.
222pub async fn reconstruct(
223 conn: &libsql::Connection,
224 ts: &str,
225 archive_path: Option<&Path>,
226 snapshots_dir: Option<&Path>,
227) -> Result<MaterializedState> {
228 match hot_log_reach(conn, ts, archive_path).await? {
229 HotLogReach::Covers => {
230 if let Some(base) = snapshot_anchor(snapshots_dir, ts) {
231 let anchor = base.seq_anchor;
232 let delta = fold_delta(conn, ANCHORED_HOT_FOLD, libsql::params![ts, anchor]).await?;
233 return Ok(delta.apply_to(base, ts));
234 }
235 return fold(conn, ts, HOT_FOLD).await;
236 }
237 HotLogReach::PredatesRecordedHistory => {
238 // Nothing had been recorded by `ts`, and nothing has been removed
239 // from the log, so there is no history anywhere to go looking for.
240 // The empty state is the answer, flagged so a caller can tell it
241 // from a state that is empty because everything was retired.
242 let mut state = MaterializedState::empty(ts);
243 state.predates_recorded_history = true;
244 return Ok(state);
245 }
246 HotLogReach::NeedsArchive => {}
247 }
248
249 // The delta lives in the cold archive database.
250 let archive = archive_path.ok_or_else(|| DbError::ReplayCorrupt {
251 seq: 0,
252 reason: format!("state at {ts} predates the hot log and no archive path was given"),
253 })?;
254 if !archive.exists() {
255 return Err(DbError::ReplayCorrupt {
256 seq: 0,
257 reason: format!("archive database file {archive:?} does not exist"),
258 });
259 }
260
261 detach_stale_cold(conn).await;
262
263 // Bound, not interpolated: a path is caller data, and hand-rolled quote
264 // doubling is a worse version of what the driver already does correctly.
265 conn.execute(
266 "ATTACH DATABASE ?1 AS cold",
267 libsql::params![archive.to_string_lossy().as_ref()],
268 )
269 .await?;
270
271 // Composition works across the archive boundary because the anchored fold
272 // unions both files; before 0.5.5 it was refused here rather than made to
273 // work, and the refusal was the only thing keeping the answer right.
274 let result = match snapshot_anchor(snapshots_dir, ts) {
275 Some(base) => {
276 let anchor = base.seq_anchor;
277 fold_delta(conn, ANCHORED_COLD_FOLD, libsql::params![ts, anchor])
278 .await
279 .map(|delta| delta.apply_to(base, ts))
280 }
281 None => fold(conn, ts, COLD_FOLD).await,
282 };
283
284 // Unconditional: see the ATTACH note above.
285 if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
286 tracing::warn!("reconstruct: failed to DETACH cold database: {e}");
287 }
288
289 result
290}
291
292/// Fold from genesis and compare against the composed answer (§5.5, T5.3,
293/// D-092).
294///
295/// # The problem this exists for
296///
297/// [`crate::temporal::save_snapshot`] is written by `write_final`, which calls
298/// [`reconstruct`] — and `reconstruct` composes onto the *previous* snapshot
299/// whenever one is usable. So snapshot *n* is derived from snapshot *n−1*, and
300/// there is no periodic full fold anywhere in the chain. An error introduced at
301/// any link is copied forward indefinitely, and every subsequent read agrees
302/// with it, because they are all reading the same descendant.
303///
304/// The project's own open item names the difficulty honestly: a full fold is
305/// exactly the cost snapshots exist to avoid, so this cannot run on every read.
306/// It is a **scheduling** problem, and this function is the thing to schedule.
307///
308/// # It reports; it does not repair
309///
310/// Deliberate, and not merely conservative. Under [Doctrine VI] a snapshot is
311/// derivative and disposable, so the repair is *delete the snapshots* — one
312/// line, available to the caller, and correct without this function's help.
313/// What the caller cannot get for themselves is the knowledge that the chain
314/// diverged, and silently rewriting the file would destroy the only evidence of
315/// a bug in composition. A divergence here is not a corrupt database; it is a
316/// wrong **cache**, and it means composition has a defect worth finding.
317///
318/// # Cost
319///
320/// One fold from genesis over the whole log, plus one composed reconstruction.
321/// That is the expensive path by construction — see [`crate::Database::
322/// verify_snapshot_chain`] for the handle-level entry point and the note on
323/// when to run it.
324///
325/// [Doctrine VI]: ../../../docs/architecture/s0-s3-foundations.md#doctrine-vi
326pub async fn verify_snapshot_chain(
327 conn: &libsql::Connection,
328 ts: &str,
329 archive_path: Option<&Path>,
330 snapshots_dir: &Path,
331) -> Result<ChainCheck> {
332 // The composed answer: what every reader gets today.
333 let composed = reconstruct(conn, ts, archive_path, Some(snapshots_dir)).await?;
334 // The authority: the same instant, with the snapshot directory withheld, so
335 // `snapshot_anchor` finds nothing and the fold runs from genesis. Passing
336 // `None` is what makes this an independent computation rather than a second
337 // call to the thing under test.
338 let folded = reconstruct(conn, ts, archive_path, None).await?;
339 Ok(ChainCheck::compare(ts, &composed, &folded))
340}
341
342/// The result of a [`verify_snapshot_chain`] cross-check.
343///
344/// Carries the disagreements rather than a bool, because "the chain diverged" is
345/// not actionable and "these three concepts differ, and this edge is present in
346/// one and not the other" is. Bounded — see [`ChainCheck::SAMPLE_LIMIT`] — since
347/// a chain that went wrong early can disagree about every row, and a report that
348/// is the size of the database is one nobody reads.
349#[derive(Debug, Clone)]
350pub struct ChainCheck {
351 pub timestamp: String,
352 /// `seq_anchor` of the composed answer and of the genesis fold. These
353 /// **may legitimately differ**: the composed answer anchors at the snapshot
354 /// it started from plus its delta, and the fold anchors at the newest row it
355 /// saw. Reported for diagnosis, never compared.
356 pub composed_anchor: i64,
357 pub folded_anchor: i64,
358 pub composed_concepts: usize,
359 pub folded_concepts: usize,
360 pub composed_edges: usize,
361 pub folded_edges: usize,
362 /// Concept ids present in one and not the other, or whose attributes differ.
363 pub concept_disagreements: Vec<String>,
364 /// Edge keys present in one and not the other.
365 pub edge_disagreements: Vec<String>,
366 /// True when either list was truncated at [`ChainCheck::SAMPLE_LIMIT`].
367 pub truncated: bool,
368}
369
370impl ChainCheck {
371 /// How many disagreements of each kind to carry.
372 pub const SAMPLE_LIMIT: usize = 32;
373
374 pub fn diverged(&self) -> bool {
375 !self.concept_disagreements.is_empty() || !self.edge_disagreements.is_empty()
376 }
377
378 fn compare(ts: &str, composed: &MaterializedState, folded: &MaterializedState) -> Self {
379 let mut concept_disagreements = Vec::new();
380 let mut truncated = false;
381
382 let mut ids: Vec<&String> = composed.concepts.keys().collect();
383 ids.extend(folded.concepts.keys());
384 ids.sort_unstable();
385 ids.dedup();
386 for id in ids {
387 let a = composed.concepts.get(id);
388 let b = folded.concepts.get(id);
389 let same = match (a, b) {
390 (Some(a), Some(b)) => {
391 a.title == b.title
392 && a.content == b.content
393 && a.embedding_model == b.embedding_model
394 }
395 (None, None) => true,
396 _ => false,
397 };
398 if !same {
399 if concept_disagreements.len() < Self::SAMPLE_LIMIT {
400 concept_disagreements.push(id.clone());
401 } else {
402 truncated = true;
403 }
404 }
405 }
406
407 // Edges are a `Vec` of tuples with no declared order, so the comparison
408 // is on the set. Comparing the vectors directly would report a
409 // divergence for a reordering, which is not one — and that false
410 // positive is worse than useless here, because the whole point of this
411 // check is that a report means "go and find the bug".
412 let key = |e: &(String, String, String, String, String)| {
413 format!("{}|{}|{}|{}|{}", e.0, e.1, e.2, e.3, e.4)
414 };
415 let ca: HashSet<String> = composed.edges.iter().map(key).collect();
416 let fa: HashSet<String> = folded.edges.iter().map(key).collect();
417 let mut edge_disagreements: Vec<String> = ca.symmetric_difference(&fa).cloned().collect();
418 edge_disagreements.sort_unstable();
419 if edge_disagreements.len() > Self::SAMPLE_LIMIT {
420 edge_disagreements.truncate(Self::SAMPLE_LIMIT);
421 truncated = true;
422 }
423
424 Self {
425 timestamp: ts.to_string(),
426 composed_anchor: composed.seq_anchor,
427 folded_anchor: folded.seq_anchor,
428 composed_concepts: composed.concepts.len(),
429 folded_concepts: folded.concepts.len(),
430 composed_edges: ca.len(),
431 folded_edges: fa.len(),
432 concept_disagreements,
433 edge_disagreements,
434 truncated,
435 }
436 }
437}
438
439impl std::fmt::Display for ChainCheck {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 if !self.diverged() {
442 return write!(
443 f,
444 "snapshot chain agrees with a genesis fold at {}: {} concepts, {} edges",
445 self.timestamp, self.folded_concepts, self.folded_edges
446 );
447 }
448 write!(
449 f,
450 "snapshot chain DIVERGED at {}: composed {} concepts / {} edges, \
451 genesis fold {} concepts / {} edges; {} concept and {} edge \
452 disagreements{}. The snapshots are a wrong cache, not a corrupt \
453 ledger — deleting the snapshot directory restores correctness and \
454 loses only speed (Doctrine VI). concepts: {:?} edges: {:?}",
455 self.timestamp,
456 self.composed_concepts,
457 self.composed_edges,
458 self.folded_concepts,
459 self.folded_edges,
460 self.concept_disagreements.len(),
461 self.edge_disagreements.len(),
462 if self.truncated { " (truncated)" } else { "" },
463 self.concept_disagreements,
464 self.edge_disagreements,
465 )
466 }
467}
468
469/// The newest usable snapshot at or before `ts`, or `None` to fold from genesis.
470///
471/// **Composition used to be disabled once an archive database existed, and as of
472/// 0.5.5 it is not.** The reason for the refusal was real: `LOG_ARCHIVABLE`
473/// (§5.7) removes superseded rows scattered through the sequence, so a row above
474/// the anchor and at or before `ts` could be in cold while a newer row for the
475/// same entity — recorded *after* `ts`, invisible to the fold — kept it out of
476/// the hot log. The delta missed it and the snapshot answered with a stale
477/// value. The fix is the one that note named: the cold log is now in the delta,
478/// via [`ANCHORED_COLD_FOLD`], so the archived row is visible again and there is
479/// nothing left to refuse.
480///
481/// Selection loads candidates newest-first and stops at the first whose
482/// timestamp is at or before `ts`, so the common case — `reconstruct(now)` —
483/// reads exactly one file. A snapshot this build cannot read
484/// ([`DbError::SnapshotIncompatible`], D-043) is skipped, not raised: an
485/// incompatible snapshot is an ordinary consequence of upgrading, and the whole
486/// point of distinguishing it from corruption is that the answer is to carry on
487/// without it.
488fn snapshot_anchor(snapshots_dir: Option<&Path>, ts: &str) -> Option<MaterializedState> {
489 let dir = snapshots_dir?;
490
491 let mut candidates: Vec<(i64, PathBuf)> = std::fs::read_dir(dir)
492 .ok()?
493 .flatten()
494 .map(|e| e.path())
495 .filter_map(|p| super::snapshot::seq_from_filename(&p).map(|s| (s, p)))
496 .collect();
497 candidates.sort_by_key(|(seq, _)| std::cmp::Reverse(*seq));
498
499 for (_, path) in candidates {
500 match super::snapshot::load_snapshot(&path) {
501 // Sound as a string comparison because every timestamp is the
502 // canonical fixed width (D-029).
503 Ok(state) if state.timestamp.as_str() <= ts => return Some(state),
504 Ok(_) => continue,
505 Err(DbError::SnapshotIncompatible { reason, .. }) => {
506 tracing::warn!("skipping snapshot {path:?}: {reason}");
507 continue;
508 }
509 Err(e) => {
510 tracing::warn!("skipping unreadable snapshot {path:?}: {e}");
511 continue;
512 }
513 }
514 }
515 None
516}
517
518/// Where the answer for `ts` lives.
519///
520/// Three cases, not two (0.8.0, B5, D-121). This used to be a `bool`, and the
521/// missing third case is the whole of B5: *below the log's floor* was folded in
522/// with *the delta is elsewhere*, so a question about a time before the ledger
523/// started came back as [`DbError::ReplayCorrupt`] — the class meaning the
524/// ledger is damaged — naming an archive file the caller had never created.
525enum HotLogReach {
526 /// The hot log holds everything needed at `ts`. Fold it.
527 Covers,
528 /// Nothing had been recorded by `ts`, and nothing has ever been removed
529 /// from the log, so no other file could hold it either. The empty state is
530 /// the correct answer, not a failure to find one.
531 PredatesRecordedHistory,
532 /// The delta is in the cold archive. If it cannot be reached, that is an
533 /// error and stays one.
534 NeedsArchive,
535}
536
537/// Whether the hot log alone can answer for `ts` — a *completeness* test.
538///
539/// **This replaces a reach test that was not one (0.5.5).** The previous version
540/// asked `MIN(recorded_at) <= ts`: whether the hot log stretches back far enough
541/// to contain `ts`. That is a different question from whether it still contains
542/// everything needed to answer at `ts`, and `LOG_ARCHIVABLE` (§5.7) is exactly
543/// what pulls the two apart — it removes *superseded* rows, scattered through
544/// the sequence rather than forming a prefix. One entity archived and another
545/// not is enough: the unarchived one keeps `MIN` pointing before the cutoff
546/// while the archived one's winning row is gone, and the fold silently returns a
547/// state missing an entity. Measured, not theorised — see
548/// `reconstructing_before_the_archive_cutoff_keeps_every_entity`.
549///
550/// The sound test rests on the one guarantee the archive does make: **the newest
551/// row per entity is never archivable**, because archivability requires a later
552/// row to exist. So if `ts` is at or after the newest hot stamp, every entity's
553/// winning row at `ts` is its newest row overall, and every such row is hot.
554/// That covers `reconstruct(now)` — the common case, and the case §5.7 designed
555/// `LOG_ARCHIVABLE` around — and nothing else.
556///
557/// Anything earlier goes to the cold file. That is more ATTACHes than the old
558/// rule performed, and the trade is not close: the old rule was cheaper because
559/// it was answering a question nobody asked.
560///
561/// With no archive database in play the reach test *is* the completeness test —
562/// nothing has been removed, so the hot log is the whole log — and it is kept,
563/// because it is also what distinguishes "before recorded history" from "the
564/// cold file is missing" (D-026).
565async fn hot_log_reach(
566 conn: &libsql::Connection,
567 ts: &str,
568 archive_path: Option<&Path>,
569) -> Result<HotLogReach> {
570 let row = conn
571 .query(
572 "SELECT MIN(recorded_at), MAX(recorded_at) FROM transaction_log",
573 (),
574 )
575 .await?
576 .next()
577 .await?;
578 let (min_recorded_at, max_recorded_at): (Option<String>, Option<String>) = match row {
579 Some(r) => (r.get(0).ok(), r.get(1).ok()),
580 None => (None, None),
581 };
582
583 // Sound as string comparisons because every recorded_at is the canonical
584 // fixed width (D-029).
585 if archive_path.is_some_and(|p| p.exists()) {
586 // An empty hot log beside an archive is the fully-archived case and
587 // covers nothing. It cannot arise from `archive()` itself — the newest
588 // row per entity always stays — but answering "covered" here would make
589 // such a file reconstruct to the empty state with no error at all.
590 return Ok(match max_recorded_at {
591 Some(max_ts) if max_ts.as_str() <= ts => HotLogReach::Covers,
592 _ => HotLogReach::NeedsArchive,
593 });
594 }
595
596 match min_recorded_at {
597 Some(min_ts) if min_ts.as_str() <= ts => Ok(HotLogReach::Covers),
598 // No log at all: a genuinely empty database, and the empty state has
599 // always been the answer here.
600 None => Ok(HotLogReach::PredatesRecordedHistory),
601 // `ts` is below the hot log's floor, and there is no archive file to
602 // consult. Which of the two meanings that has is decided by whether
603 // anything was ever removed from the log — see `hot_log_is_intact`.
604 Some(_) => Ok(if hot_log_is_intact(conn).await? {
605 HotLogReach::PredatesRecordedHistory
606 } else {
607 HotLogReach::NeedsArchive
608 }),
609 }
610}
611
612/// Was any row ever removed from `transaction_log`? — answered exactly, from
613/// the hot file alone (0.8.0, B5, D-121).
614///
615/// # Why this question needs answering at all
616///
617/// With `ts` below the hot log's floor and no archive file present, the state
618/// on disk is consistent with two very different histories: **nothing was ever
619/// archived**, in which case the hot log is the whole log and the answer to
620/// *what was believed at `ts`* is "nothing yet"; or **rows were archived and
621/// the cold file is gone**, in which case the answer is unknowable and saying
622/// "nothing" would be inventing one. Before this, the two were conflated and
623/// both raised — which made an ordinary question about a young database report
624/// the ledger as damaged.
625///
626/// # Why `seq_id` settles it, with no marker and no schema change
627///
628/// `transaction_log.seq_id` is `INTEGER PRIMARY KEY AUTOINCREMENT`, so values
629/// are allocated 1, 2, 3, … and **never reused**. A rolled-back transaction
630/// leaves no gap — `sqlite_sequence` rolls back with it, which
631/// [D-049](../../docs/architecture/s13-decision-register.md) established by
632/// measurement after assuming the opposite. So the only thing that can perturb
633/// the sequence is deletion, and `trg_txlog_guard_delete` confines deletion to
634/// an archive session.
635///
636/// Therefore: if nothing was removed, the ids are exactly `1..=MAX` and
637/// `COUNT(*) == MAX(seq_id)` with `MIN(seq_id) == 1`. And conversely — this is
638/// the half that makes it a proof rather than a heuristic — those two equalities
639/// force the set of `COUNT` distinct ids inside `[1, MAX]` to be all of it, so
640/// nothing is missing. The test is exact in both directions, not merely
641/// suggestive.
642///
643/// **It does not depend on the archive removing a contiguous block**, which it
644/// does not: `archive()` removes *superseded* rows scattered through the
645/// sequence. Scattered removal leaves interior gaps, which fails the count
646/// equality; removal from the front raises `MIN` above 1. Removal from the end
647/// cannot happen, because the newest row per entity is never archivable.
648///
649/// # What it deliberately does not claim
650///
651/// Nothing about *when* the archiving happened or *what* went, which is what
652/// the rejected hot-side marker would have carried. It answers one bit, and one
653/// bit is what the branch above needs.
654async fn hot_log_is_intact(conn: &libsql::Connection) -> Result<bool> {
655 let row = conn
656 .query(
657 "SELECT COUNT(*), MIN(seq_id), MAX(seq_id) FROM transaction_log",
658 (),
659 )
660 .await?
661 .next()
662 .await?;
663 let Some(row) = row else {
664 return Ok(true);
665 };
666 let count: i64 = row.get(0).unwrap_or(0);
667 if count == 0 {
668 return Ok(true);
669 }
670 let min: i64 = row.get(1).unwrap_or(0);
671 let max: i64 = row.get(2).unwrap_or(0);
672 Ok(min == 1 && count == max)
673}
674
675/// Run one fold query from nothing — the unanchored path.
676async fn fold(conn: &libsql::Connection, ts: &str, query: &str) -> Result<MaterializedState> {
677 let delta = fold_delta(conn, query, libsql::params![ts]).await?;
678 Ok(delta.apply_to(MaterializedState::empty(ts), ts))
679}
680
681/// Run one fold query and collect the winning rows, deletions included.
682async fn fold_delta(
683 conn: &libsql::Connection,
684 query: &str,
685 params: impl libsql::params::IntoParams,
686) -> Result<Delta> {
687 let mut rows = conn.query(query, params).await?;
688 let mut d = Delta::default();
689 let (concepts, edges, max_seq) = (&mut d.concepts, &mut d.edges, &mut d.max_seq);
690
691 while let Some(row) = rows.next().await? {
692 let seq_id: i64 = row.get(0)?;
693 let table_name: String = row.get(1)?;
694 let _entity_id: String = row.get(2)?;
695 let op: String = row.get(3)?;
696 let payload_str: String = row.get(4)?;
697
698 if seq_id > *max_seq {
699 *max_seq = seq_id;
700 }
701
702 // A `'D'` row is corruption, not a tombstone (D-072).
703 //
704 // Doctrine V permits no physical delete outside an archive session, and
705 // the archive *moves* rows rather than logging their removal — so no
706 // trigger in the schema writes a `'D'`, and no code path in the crate
707 // can produce one. This arm used to treat it as a tombstone, which read
708 // as a claim that deletions are recorded and reconstructible. They are
709 // not. Refusing here makes the doctrine enforced at the fold rather than
710 // assumed by it, and is the same call D-060 made for overlap: the layer
711 // that can notice should.
712 //
713 // Retirement is unaffected and is the mechanism that actually removes a
714 // concept from a composed state — see the `retired != 0` branch below,
715 // which is where `concepts_gone` is populated in practice.
716 if op == "D" {
717 return Err(DbError::ReplayCorrupt {
718 seq: seq_id,
719 reason: format!(
720 "transaction_log carries a 'D' operation for {table_name} \
721 entity {_entity_id:?}; Doctrine V permits no physical delete \
722 outside an archive session, and the archive logs none. This \
723 row was not written by this crate."
724 ),
725 });
726 }
727
728 let payload: serde_json::Value =
729 serde_json::from_str(&payload_str).map_err(|e| DbError::ReplayCorrupt {
730 seq: seq_id,
731 reason: format!("Failed to parse payload JSON: {e}"),
732 })?;
733
734 // v1 and v2 differ by one added field, so v1 folds by reading it as
735 // absent — which is what `Option` already means here. A future shape
736 // that *removes* or *retypes* a field would not be able to share this
737 // path, and would want a match on `v` rather than a ceiling.
738 let v = payload.get("v").and_then(|v| v.as_u64()).unwrap_or(1);
739 if v > PAYLOAD_VERSION as u64 {
740 return Err(DbError::PayloadVersion {
741 got: v as u8,
742 max: PAYLOAD_VERSION,
743 });
744 }
745
746 if table_name == "concepts" {
747 let id = _entity_id;
748 let retired = payload.get("retired").and_then(|r| r.as_i64()).unwrap_or(0);
749 if retired == 0 {
750 let title = payload
751 .get("title")
752 .and_then(|s| s.as_str())
753 .unwrap_or("")
754 .to_string();
755 let content = payload
756 .get("content")
757 .and_then(|s| s.as_str())
758 .unwrap_or("")
759 .to_string();
760 let embedding_model = payload
761 .get("embedding_model")
762 .and_then(|s| s.as_str())
763 .map(|s| s.to_string());
764 concepts.insert(
765 id.clone(),
766 NodeAttributes {
767 id,
768 title,
769 content,
770 embedding_model,
771 },
772 );
773 } else {
774 // Retirement is the application axis (§4.1), and a reconstruction
775 // shows what was visible. Onto a snapshot that means removing
776 // the concept, not declining to add it.
777 d.concepts_gone.insert(id);
778 }
779 } else if table_name == "links" {
780 let src = payload
781 .get("source_id")
782 .and_then(|s| s.as_str())
783 .unwrap_or("")
784 .to_string();
785 let tgt = payload
786 .get("target_id")
787 .and_then(|s| s.as_str())
788 .unwrap_or("")
789 .to_string();
790 let edge_type = payload
791 .get("edge_type")
792 .and_then(|s| s.as_str())
793 .unwrap_or("")
794 .to_string();
795 let vf = payload
796 .get("valid_from")
797 .and_then(|s| s.as_str())
798 .unwrap_or("")
799 .to_string();
800 let vt = payload
801 .get("valid_to")
802 .and_then(|s| s.as_str())
803 .unwrap_or("")
804 .to_string();
805 edges.insert(_entity_id, (src, tgt, edge_type, vf, vt));
806 }
807 }
808
809 Ok(d)
810}
811
812impl Delta {
813 /// Compose onto `base` under last-writer-wins by `seq_id` (§5.5).
814 ///
815 /// The delta is by construction newer than the base — it is the fold of
816 /// everything above the base's anchor — so every row it carries wins, and
817 /// every retirement it carries removes. This is the same rule
818 /// `trg_links_current_sync`'s upsert applies and the same rule the cold
819 /// fold applies; that the three agree is asserted by test rather than by
820 /// this comment (§8).
821 fn apply_to(self, base: MaterializedState, ts: &str) -> MaterializedState {
822 let mut concepts = base.concepts;
823 let mut edges: HashMap<String, (String, String, String, String, String)> =
824 base.edges.into_iter().map(|e| (edge_key(&e), e)).collect();
825
826 for id in self.concepts_gone {
827 concepts.remove(&id);
828 }
829 // No edge equivalent: an edge is superseded in place under the same
830 // `entity_id`, never removed — see [`Delta`] (D-072).
831 concepts.extend(self.concepts);
832 edges.extend(self.edges);
833
834 // Sorted so the result is a function of the state and not of hash
835 // iteration order — `reconstruct` is compared against itself by the
836 // property suite, and two runs must be equal, not merely equivalent.
837 let mut edges: Vec<_> = edges.into_values().collect();
838 edges.sort();
839
840 MaterializedState {
841 seq_anchor: self.max_seq.max(base.seq_anchor),
842 timestamp: ts.to_string(),
843 concepts,
844 edges,
845 // A delta was applied, so there was history to fold. `reconstruct`
846 // sets the flag on the one path that never gets here.
847 predates_recorded_history: false,
848 }
849 }
850}