Skip to main content

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