macrame/temporal/archive.rs
1use std::path::Path;
2
3use libsql::TransactionBehavior;
4
5use crate::error::{Result, WriteOp};
6use crate::schema::ddl::ARCHIVE_SESSION_MARKER;
7
8/// Outcome of one archive session.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ArchiveReport {
11 pub links_archived: usize,
12 /// Concepts moved to `cold.concepts` (0.9.0, C2). Always `0` before v9,
13 /// where no concept could leave the hot table at all.
14 pub concepts_archived: usize,
15 pub log_entries_archived: usize,
16 /// Oldest `transaction_log.seq_id` still present in the hot file after the
17 /// session, i.e. the new horizon (see glossary). `None` if the hot log is empty.
18 pub horizon: Option<i64>,
19}
20
21/// Schema of the cold database. Deliberately trigger-free and FK-free.
22///
23/// **Corrected 2026-08-07.** This comment used to justify the FK-free part with
24/// *"concepts are never archived (D-022)"*, which stopped being true in 0.9.0
25/// when C2 added `cold.concepts` — the table declared a few lines below. The
26/// reasons that survive are the other two, and they are the load-bearing ones:
27/// a FK from `cold.links` to `concepts` still could not be satisfied, because
28/// the cold file holds only the concepts that have gone cold and `cold.links`
29/// may name any of them; and the delete guards must not exist on a file whose
30/// whole purpose is to receive rows and, on rehydration, to give them back.
31const COLD_SCHEMA: &[&str] = &[
32 // `weight` carries the same CHECK as the hot table (T2.1, D-083). Not
33 // symmetry for its own sake: the cold file is read back by `reconstruct`
34 // through the same `f64` decode, so a text weight is the same panic there
35 // as it is here, and a negative one is the same unsound shortest path.
36 //
37 // The hot table's constraint does not protect this one. Rows arrive by
38 // `INSERT … SELECT` across an ATTACH, which re-checks against *this*
39 // table's constraints — and a cold file may predate the hot file's rung, or
40 // have been written by a version that had neither.
41 //
42 // `IF NOT EXISTS` means an existing cold database keeps whatever definition
43 // it was created with; this constrains new cold files, and the loader guard
44 // is what covers the old ones. That is the same division of labour §4.7
45 // describes, and the reason the guard stays.
46 r#"CREATE TABLE IF NOT EXISTS cold.links (
47 source_id TEXT NOT NULL,
48 target_id TEXT NOT NULL,
49 edge_type TEXT NOT NULL,
50 valid_from TEXT NOT NULL,
51 recorded_at TEXT NOT NULL,
52 valid_to TEXT NOT NULL,
53 weight REAL NOT NULL CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real'),
54 properties TEXT NOT NULL,
55 PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at)
56 )"#,
57 // Concepts, as of v9 (C2). Trigger-free and FK-free like `cold.links`, and
58 // for the same reasons -- but note what it does NOT drop.
59 //
60 // **Every column crosses, `content` included.** Archival is a move, not a
61 // rewrite (2.3), and a move that drops a column is a rewrite. The log
62 // payload for a concept carries its `content` (4.3), so a cold concept
63 // whose text had been dropped would contradict `cold.transaction_log` about
64 // itself, and rehydration would return a concept the ledger never recorded:
65 // empty text where the log says there was text. That is the unexplained
66 // absence Doctrine V exists to prevent.
67 //
68 // The tension with D-116 is apparent rather than real. D-116 governs the
69 // *in-memory* `NodeData` representation -- `content` is not loaded by
70 // default because most readers do not want it. This is *on-disk* storage.
71 // Disk carries the text; memory does not populate it until asked. Two
72 // independent defaults, and conflating them would make rehydration lossy to
73 // save a read nobody was performing.
74 //
75 // `rowid_pk` crosses as the record of what the rowid *was*. Restoring it is
76 // C3's problem and not obviously safe: `concepts.rowid_pk` is a plain
77 // INTEGER PRIMARY KEY, so SQLite may reuse a freed value, and archiving the
78 // highest rowids can leave a later insert holding one a cold row still
79 // claims. The column is carried because a move must not lose it.
80 //
81 // **The hazard has two exits and C3 must take one of them explicitly.**
82 // Either reinstate the original `rowid_pk` when it is still free, or assign
83 // a fresh one — and in the second case **update `concepts_fts`'s
84 // `content_rowid` mapping to match**, because the FTS index is
85 // external-content keyed on this column (4.6, D-119). A rehydration that
86 // reassigns the rowid without re-pointing the index leaves the search index
87 // silently describing the wrong row, which is the exact failure `rowid_pk`
88 // was made explicit to prevent. Named here so C3 meets both exits rather
89 // than rediscovering the FTS coupling.
90 r#"CREATE TABLE IF NOT EXISTS cold.concepts (
91 rowid_pk INTEGER,
92 id TEXT NOT NULL PRIMARY KEY,
93 title TEXT NOT NULL,
94 content TEXT NOT NULL DEFAULT '',
95 embedding_model TEXT,
96 valid_from TEXT NOT NULL,
97 valid_to TEXT NOT NULL,
98 recorded_at TEXT NOT NULL,
99 retired INTEGER NOT NULL DEFAULT 0
100 )"#,
101 // seq_id is carried over verbatim from the hot log, so it is a plain
102 // INTEGER PRIMARY KEY -- never AUTOINCREMENT, which would renumber history.
103 r#"CREATE TABLE IF NOT EXISTS cold.transaction_log (
104 seq_id INTEGER PRIMARY KEY,
105 table_name TEXT NOT NULL,
106 entity_id TEXT NOT NULL,
107 operation TEXT NOT NULL,
108 payload TEXT NOT NULL,
109 recorded_at TEXT NOT NULL
110 )"#,
111 "CREATE INDEX IF NOT EXISTS cold.idx_cold_txlog_entity ON transaction_log (entity_id)",
112 "CREATE INDEX IF NOT EXISTS cold.idx_cold_txlog_time ON transaction_log (recorded_at)",
113 r#"CREATE TABLE IF NOT EXISTS cold.archive_horizon (
114 archived_at TEXT NOT NULL,
115 cutoff TEXT NOT NULL,
116 horizon INTEGER
117 )"#,
118];
119
120/// A links assertion is archivable when it is older than the cutoff AND it is
121/// either superseded by a later assertion for the same interval key, or it is
122/// the current belief for an interval that closed before the cutoff.
123///
124/// This keeps every row that `links_current` still projects (Doctrine VI: the
125/// materialization must stay rebuildable from `links`) while moving exactly the
126/// "closed intervals, superseded history" the §2 diagram assigns to the cold file.
127const LINKS_ARCHIVABLE: &str = r#"
128 recorded_at < :cutoff AND (
129 EXISTS (
130 SELECT 1 FROM links newer
131 WHERE newer.source_id = links.source_id
132 AND newer.target_id = links.target_id
133 AND newer.edge_type = links.edge_type
134 AND newer.valid_from = links.valid_from
135 AND newer.recorded_at > links.recorded_at
136 )
137 OR (valid_to <> '9999-12-31T23:59:59.999999Z' AND valid_to <= :cutoff)
138 )
139"#;
140
141/// A log entry is archivable when it is older than the cutoff and a later entry
142/// exists for the same entity, i.e. it is superseded. The newest entry per
143/// entity always stays hot so that `reconstruct(now)` never needs the cold file.
144const LOG_ARCHIVABLE: &str = r#"
145 recorded_at < :cutoff AND EXISTS (
146 SELECT 1 FROM transaction_log newer
147 WHERE newer.entity_id = transaction_log.entity_id
148 AND newer.seq_id > transaction_log.seq_id
149 )
150"#;
151
152/// A concept is archivable when it is `retired`, both its clocks are behind the
153/// cutoff, **and no surviving row of hot `links` mentions it in either
154/// direction** (C1, D-128).
155///
156/// # Why reachability, and not a closed interval
157///
158/// A link assertion has a closed interval, so [`LINKS_ARCHIVABLE`] can ask
159/// whether the interval ended. A concept is an *entity*, and has no closed
160/// state: `retired = 1` says belief in it stopped, which is not the same claim
161/// as "nothing points at it any more". The two `links` foreign keys are what
162/// make that difference matter — archiving a concept physically removes its row,
163/// and a surviving hot link naming it would leave the key unsatisfiable.
164/// `ON DELETE CASCADE` is not the way out, because the rows it would cascade
165/// onto are ledger rows.
166///
167/// So concept archival is **strictly downstream of link archival**: a concept
168/// becomes eligible only once every edge mentioning it has itself gone cold.
169/// Inside a session this predicate is therefore evaluated *after* the `links`
170/// delete and never before it, and the same question asked before and after one
171/// session legitimately gives two different answers. That is a property of the
172/// predicate, not a race.
173///
174/// # The other two foreign keys, and why they are not clauses here
175///
176/// `concepts` also has inbound keys from `analytics_annotations` and from every
177/// registered `embeddings_*` table ([`crate::schema::migrations`] lists all
178/// four). Neither appears above, and the distinction is the point: those hold
179/// **derived** rows. Doctrine VII makes an embedding an artifact of a model
180/// applied to content, and an annotation is the output of an algorithm that read
181/// `concepts` in the first place. A derived row is removed and recomputed; a
182/// ledger row is neither. Making archivability wait on a recomputable artifact
183/// would answer "not yet" forever for any concept that had ever been embedded.
184///
185/// # Both clocks, because one of them is not enough
186///
187/// The specification for this predicate named `valid_to` alone.
188/// `recorded_at < :cutoff` is here as well, mirroring [`LINKS_ARCHIVABLE`]:
189/// a concept retired with its `valid_to` behind the cutoff but *recorded* at or
190/// after it is a fact the session is not meant to touch yet, and archiving it
191/// would send the concept cold while the log entries describing it stayed hot.
192/// That is the same two-clock mismatch the `links_current` compensation carried
193/// until Wave 4.5 (see [`archive_session`]), reached from the other side.
194/// Doctrine II: two clocks, never mixed.
195///
196/// The open sentinel needs no clause of its own — `9999-12-31T23:59:59.999999Z`
197/// sorts above every canonical stamp (D-029), so a concept whose validity is
198/// still open fails `valid_to < :cutoff` for any cutoff a caller can pass.
199const CONCEPTS_ARCHIVABLE: &str = r#"
200 retired = 1
201 AND recorded_at < :cutoff
202 AND valid_to < :cutoff
203 AND NOT EXISTS (
204 SELECT 1 FROM links
205 WHERE links.source_id = concepts.id
206 OR links.target_id = concepts.id
207 )
208"#;
209
210/// The ids of every concept that `CONCEPTS_ARCHIVABLE` admits at `cutoff`, in
211/// `id` order.
212///
213/// **Read-only, and deliberately available before anything can act on it.**
214/// Concept archival is the one operation in this crate a caller cannot undo
215/// without a cold file to hand, so the predicate that decides it is observable
216/// on its own rather than only as a count in a report after the fact.
217///
218/// The answer is a function of the hot state *now*. Archiving links first will
219/// generally enlarge it — that is the downstream relationship
220/// `CONCEPTS_ARCHIVABLE` describes, not an inconsistency — so a caller
221/// planning a session should ask after the link archive, not before it.
222pub async fn archivable_concepts(conn: &libsql::Connection, cutoff: &str) -> Result<Vec<String>> {
223 let mut rows = conn
224 .query(
225 &format!("SELECT id FROM concepts WHERE {CONCEPTS_ARCHIVABLE} ORDER BY id"),
226 libsql::named_params! {":cutoff": cutoff},
227 )
228 .await?;
229
230 let mut ids = Vec::new();
231 while let Some(row) = rows.next().await? {
232 ids.push(row.get::<String>(0)?);
233 }
234 Ok(ids)
235}
236
237/// Move closed edge intervals and superseded log rows older than `cutoff` into
238/// the cold database at `archive_path` (§5.7, D-012, D-022).
239///
240/// The whole session is one `BEGIN IMMEDIATE … COMMIT` transaction (D-012):
241/// copy-then-delete must be atomic, or a crash between the phases duplicates or
242/// loses rows. The archive-session marker that unlocks the delete guards
243/// (D-008 revised) is created as the first statement of that transaction and
244/// dropped as the last, so it never exists as committed state — commit drops
245/// it, rollback discards it, and there is no crash path that leaves the guards
246/// disarmed.
247///
248/// ATTACH is issued outside the transaction and DETACH is issued unconditionally
249/// on the way out, including on error: ATTACH is not transactional and survives
250/// ROLLBACK, so a leaked handle would make every later archive or cold-DB
251/// reconstruct fail with "database cold is already in use".
252/// `archived_at` is **when the session ran**; `cutoff` is the boundary it used.
253///
254/// Both go into `cold.archive_horizon`, and until Wave 4.5 both columns were
255/// written with the cutoff — so the table recorded that every archive had run at
256/// the instant it was archiving *up to*, which is the one time it certainly did
257/// not run. The two are different clocks (Doctrine II) and the row exists to
258/// carry both: the cutoff says what was moved, `archived_at` says when the
259/// decision was taken, and only the second can answer "how stale is this cold
260/// file". The column was there, correctly named, holding the wrong value.
261pub async fn archive(
262 conn: &libsql::Connection,
263 cutoff: &str,
264 archived_at: &str,
265 archive_path: &Path,
266) -> Result<ArchiveReport> {
267 crate::temporal::replay::detach_stale_cold(conn).await;
268
269 // ATTACH creates the cold file if it does not exist.
270 conn.execute(
271 "ATTACH DATABASE ?1 AS cold",
272 libsql::params![archive_path.to_string_lossy().as_ref()],
273 )
274 .await?;
275
276 let result = archive_session(conn, cutoff, archived_at).await;
277
278 // Unconditional: see the DETACH note above.
279 if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
280 tracing::warn!("archive: failed to DETACH cold database: {e}");
281 }
282
283 result
284}
285
286/// `conn` is passed alongside `tx` only so [`delete_guarded`] can hand it to
287/// `classify`, which queries on the error path. Both name the same connection.
288async fn archive_session(
289 conn: &libsql::Connection,
290 cutoff: &str,
291 archived_at: &str,
292) -> Result<ArchiveReport> {
293 for ddl in COLD_SCHEMA {
294 conn.execute(ddl, ()).await?;
295 }
296
297 let tx = conn
298 .transaction_with_behavior(TransactionBehavior::Immediate)
299 .await?;
300
301 // --- archive session opens: the delete guards are now satisfied ---
302 tx.execute(&format!("CREATE TABLE {ARCHIVE_SESSION_MARKER} (x)"), ())
303 .await?;
304
305 let links_archived = tx
306 .execute(
307 &format!(
308 "INSERT OR IGNORE INTO cold.links
309 (source_id, target_id, edge_type, valid_from, recorded_at,
310 valid_to, weight, properties)
311 SELECT source_id, target_id, edge_type, valid_from, recorded_at,
312 valid_to, weight, properties
313 FROM links WHERE {LINKS_ARCHIVABLE}"
314 ),
315 libsql::named_params! {":cutoff": cutoff},
316 )
317 .await? as usize;
318
319 let links_deleted = delete_guarded(
320 &tx,
321 conn,
322 &format!("DELETE FROM links WHERE {LINKS_ARCHIVABLE}"),
323 cutoff,
324 "links",
325 )
326 .await?;
327
328 // links_current is derivative (Doctrine VI) and must equal the latest-belief
329 // projection of whatever remains in links, or audit_current() reports drift
330 // the moment an archive runs. Re-derive it rather than trying to describe
331 // the deletion's shadow: this used to be a hand-written
332 // `DELETE FROM links_current WHERE valid_to <= :cutoff`, which filters on
333 // *valid* time while LINKS_ARCHIVABLE also requires `recorded_at < :cutoff`.
334 // A row closed at the cutoff but recorded at or after it therefore survived
335 // in links and was deleted from links_current — permanent drift no later
336 // audit could explain, from a compensation that had quietly stopped being
337 // the image of the thing it compensated for. Doctrine II: two clocks, never
338 // mixed. Deriving from the definition cannot drift from the definition.
339 //
340 // **Skipped when the DELETE removed nothing (T1.1, D-080).** `links_current`
341 // is a function of `links`, so if `links` did not change its projection did
342 // not either, and there is no drift for a rebuild to repair. This was
343 // harmless while `archive()` was called once against a whole backlog,
344 // because the one session always had work. It stops being harmless the
345 // moment the caller windows: `rebuild_within` costs O(surviving `links`)
346 // regardless of how much the session archived (D-077), so without this a run
347 // of twenty windows over a quiet stretch of history pays twenty full
348 // reprojections to delete nothing — and windowing makes the archive slower
349 // in total than not windowing. `log_entries_archived` deliberately does not
350 // enter into it: archiving the transaction log cannot change `links`.
351 if links_deleted > 0 {
352 crate::integrity::rebuild::rebuild_within(&tx, crate::integrity::rebuild::Verify::No)
353 .await?;
354 }
355
356 // Concepts, and **only now** — after the `links` delete, never before it
357 // ([D-128](../../docs/architecture/s13-decision-register.md)). A concept is
358 // archivable when nothing in hot `links` names it, so evaluating the
359 // predicate before the edges have gone cold archives strictly less than the
360 // session is entitled to. This ordering is the whole content of "concept
361 // archival is downstream of link archival".
362 let concepts_archived = archive_concepts(&tx, conn, cutoff).await?;
363
364 let log_entries_archived = tx
365 .execute(
366 &format!(
367 "INSERT OR IGNORE INTO cold.transaction_log
368 (seq_id, table_name, entity_id, operation, payload, recorded_at)
369 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at
370 FROM transaction_log WHERE {LOG_ARCHIVABLE}"
371 ),
372 libsql::named_params! {":cutoff": cutoff},
373 )
374 .await? as usize;
375
376 delete_guarded(
377 &tx,
378 conn,
379 &format!("DELETE FROM transaction_log WHERE {LOG_ARCHIVABLE}"),
380 cutoff,
381 "transaction_log",
382 )
383 .await?;
384
385 // Record the new horizon in the cold file so a pre-horizon reconstruct can
386 // tell "archived" from "never existed" (glossary; R14).
387 let horizon: Option<i64> = tx
388 .query("SELECT MIN(seq_id) FROM transaction_log", ())
389 .await?
390 .next()
391 .await?
392 .and_then(|row| row.get(0).ok());
393
394 tx.execute(
395 "INSERT INTO cold.archive_horizon (archived_at, cutoff, horizon) VALUES (?1, ?2, ?3)",
396 libsql::params![archived_at, cutoff, horizon],
397 )
398 .await?;
399
400 // --- archive session closes: the guards re-arm before COMMIT ---
401 tx.execute(&format!("DROP TABLE {ARCHIVE_SESSION_MARKER}"), ())
402 .await?;
403
404 tx.commit().await?;
405
406 Ok(ArchiveReport {
407 links_archived,
408 concepts_archived,
409 log_entries_archived,
410 horizon,
411 })
412}
413
414/// Move every concept [`CONCEPTS_ARCHIVABLE`] admits into `cold.concepts`, and
415/// dispose of its derived rows (C2).
416///
417/// # The partition, which is the decision this function encodes
418///
419/// **Entity data crosses; derivative data does not.** The concept row itself is
420/// moved column for column — a move that drops a column is a rewrite, and
421/// [Doctrine V] does not permit an absence the ledger cannot explain. Its
422/// `analytics_annotations` and `embeddings_*` rows are *deleted* rather than
423/// moved, because [Doctrine VII] makes both recomputable from the content that
424/// did cross. Carrying them would also be unimplementable for the vectors:
425/// `F32_BLOB` and DiskANN are libSQL-specific and the cold file is a plain
426/// database opened through `ATTACH`.
427///
428/// The disposal is not incidental to the move — it is what makes the move
429/// legal. `concepts` has four inbound foreign keys, and the two derived ones
430/// would refuse the `DELETE` outright.
431///
432/// # Why the deletes are not logged, and why that is right
433///
434/// `concepts` carries log triggers on insert and update but **not** on delete —
435/// there was no delete path to log while the guard was unconditional, and there
436/// deliberately still is not. Archival mints no transaction-time facts: the
437/// concept is in the cold file, the log entries describing it are either still
438/// hot or in `cold.transaction_log`, and nothing about what was believed, or
439/// when, has changed. A log entry here would assert that something happened to
440/// the concept at archive time, which is exactly the lie [Doctrine III] forbids.
441async fn archive_concepts(
442 tx: &libsql::Transaction,
443 conn: &libsql::Connection,
444 cutoff: &str,
445) -> Result<usize> {
446 let moved = tx
447 .execute(
448 &format!(
449 "INSERT OR IGNORE INTO cold.concepts
450 (rowid_pk, id, title, content, embedding_model,
451 valid_from, valid_to, recorded_at, retired)
452 SELECT rowid_pk, id, title, content, embedding_model,
453 valid_from, valid_to, recorded_at, retired
454 FROM concepts WHERE {CONCEPTS_ARCHIVABLE}"
455 ),
456 libsql::named_params! {":cutoff": cutoff},
457 )
458 .await? as usize;
459
460 if moved == 0 {
461 return Ok(0);
462 }
463
464 // The derived rows, before the concept they hang off. `embeddings_*` is
465 // enumerated from the catalogue rather than from a list, because the set is
466 // whatever `register_model` has created on *this* database and a hard-coded
467 // list would silently miss a model the caller added.
468 let mut derived: Vec<String> = vec!["analytics_annotations".to_string()];
469 let mut rows = tx
470 .query(
471 "SELECT name FROM sqlite_master WHERE type = 'table' \
472 AND name LIKE 'embeddings\\_%' ESCAPE '\\'",
473 (),
474 )
475 .await?;
476 while let Some(row) = rows.next().await? {
477 derived.push(row.get::<String>(0)?);
478 }
479 drop(rows);
480
481 for table in &derived {
482 tx.execute(
483 &format!(
484 "DELETE FROM {table} WHERE concept_id IN \
485 (SELECT id FROM cold.concepts)"
486 ),
487 (),
488 )
489 .await?;
490 }
491
492 // `trg_concepts_fts_delete` fires on this and keeps the search index
493 // correct — the capability v8 installed inert and this rung made reachable.
494 let deleted = delete_guarded(
495 tx,
496 conn,
497 &format!("DELETE FROM concepts WHERE {CONCEPTS_ARCHIVABLE}"),
498 cutoff,
499 "concepts",
500 )
501 .await? as usize;
502
503 debug_assert_eq!(
504 moved, deleted,
505 "the predicate selected a different set for the copy than for the delete"
506 );
507
508 Ok(deleted)
509}
510
511/// Outcome of one rehydration (0.9.0, C3).
512#[derive(Debug, Clone, PartialEq, Eq)]
513pub struct RehydrateReport {
514 /// Concepts moved back into the hot table.
515 pub concepts_rehydrated: usize,
516 /// Of those, how many could **not** keep their original `rowid_pk` because
517 /// something else had claimed it while they were cold, and were reassigned
518 /// with the FTS index re-pointed to match.
519 ///
520 /// Reported rather than hidden because it is the one way a rehydrated
521 /// concept differs from the row that was archived, and a caller comparing
522 /// rowids across the boundary should be able to see that it happened.
523 pub rowids_reassigned: usize,
524}
525
526/// Move concepts back from the cold file into the hot tables (§2.3, C3).
527///
528/// # Rehydration is a move back, not a write
529///
530/// It mints no transaction-time facts and is invisible to both clocks. The
531/// concept's log entries were never removed, so the ledger already says
532/// everything true about it; writing a fresh `'I'` would assert the concept was
533/// *learned* at rehydration time, and — because the fold takes the highest
534/// `seq_id` per entity — would additionally outrank any later `'U'` that retired
535/// it. See [`crate::schema::ddl::CREATE_CONCEPTS_LOG_INSERT`], which is
536/// marker-gated at v10 for exactly this reason. The whole operation therefore
537/// runs inside a declared archive session, which is what suppresses the trigger.
538///
539/// # `rowid_pk`: reinstate, or reassign and re-point the index
540///
541/// The common case has no collision — the rowid was freed by archival and
542/// nothing has claimed it since — and reinstating is the clean move-back with no
543/// side effects at all. When something *has* taken it, the fallback is a fresh
544/// `rowid_pk` plus an FTS correction: `concepts_fts` is external-content keyed
545/// on `rowid_pk` ([D-119]), so a reassignment without re-pointing leaves the
546/// index describing the wrong row, silently. Both exits are taken here rather
547/// than one being assumed, and [`RehydrateReport::rowids_reassigned`] reports
548/// which was used.
549pub async fn rehydrate(
550 conn: &libsql::Connection,
551 ids: &[&str],
552 archive_path: &Path,
553) -> Result<RehydrateReport> {
554 if ids.is_empty() {
555 return Ok(RehydrateReport {
556 concepts_rehydrated: 0,
557 rowids_reassigned: 0,
558 });
559 }
560
561 crate::temporal::replay::detach_stale_cold(conn).await;
562 conn.execute(
563 "ATTACH DATABASE ?1 AS cold",
564 libsql::params![archive_path.to_string_lossy().as_ref()],
565 )
566 .await?;
567
568 let result = rehydrate_session(conn, ids).await;
569
570 if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
571 tracing::warn!("rehydrate: failed to DETACH cold database: {e}");
572 }
573 result
574}
575
576async fn rehydrate_session(conn: &libsql::Connection, ids: &[&str]) -> Result<RehydrateReport> {
577 let tx = conn
578 .transaction_with_behavior(TransactionBehavior::Immediate)
579 .await?;
580
581 // The session opens for the same reason the archive's does, plus one more:
582 // it is what stops `trg_concepts_log_insert` from firing (v10).
583 tx.execute(&format!("CREATE TABLE {ARCHIVE_SESSION_MARKER} (x)"), ())
584 .await?;
585
586 let mut rehydrated = 0usize;
587 let mut reassigned = 0usize;
588
589 for id in ids {
590 let Some(row) = tx
591 .query(
592 "SELECT rowid_pk, id, title, content, embedding_model, \
593 valid_from, valid_to, recorded_at, retired \
594 FROM cold.concepts WHERE id = ?1",
595 libsql::params![*id],
596 )
597 .await?
598 .next()
599 .await?
600 else {
601 continue;
602 };
603
604 let old_rowid: i64 = row.get(0)?;
605 let title: String = row.get(2)?;
606 let content: String = row.get(3)?;
607 let model: Option<String> = row.get(4)?;
608 let valid_from: String = row.get(5)?;
609 let valid_to: String = row.get(6)?;
610 let recorded_at: String = row.get(7)?;
611 let retired: i64 = row.get(8)?;
612
613 let taken: i64 = tx
614 .query(
615 "SELECT COUNT(*) FROM concepts WHERE rowid_pk = ?1",
616 libsql::params![old_rowid],
617 )
618 .await?
619 .next()
620 .await?
621 .expect("COUNT(*) always returns a row")
622 .get(0)?;
623
624 if taken == 0 {
625 // The clean move back: same row, same rowid, no side effects.
626 tx.execute(
627 "INSERT INTO concepts (rowid_pk, id, title, content, embedding_model, \
628 valid_from, valid_to, recorded_at, retired) \
629 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
630 libsql::params![
631 old_rowid,
632 *id,
633 title.clone(),
634 content.clone(),
635 model,
636 valid_from,
637 valid_to,
638 recorded_at,
639 retired
640 ],
641 )
642 .await?;
643 } else {
644 // Something claimed the rowid while this concept was cold. Take a
645 // fresh one, then correct the index: `concepts_fts` is
646 // external-content keyed on `rowid_pk`, and its insert trigger will
647 // have written an entry at the *new* rowid — what has to be undone
648 // is the stale entry still sitting at the old one, which the archive
649 // could not remove because the row it described had already gone.
650 tx.execute(
651 "INSERT INTO concepts (id, title, content, embedding_model, \
652 valid_from, valid_to, recorded_at, retired) \
653 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
654 libsql::params![
655 *id,
656 title.clone(),
657 content.clone(),
658 model,
659 valid_from,
660 valid_to,
661 recorded_at,
662 retired
663 ],
664 )
665 .await?;
666 tx.execute(
667 "INSERT INTO concepts_fts (concepts_fts, rowid, title, content) \
668 VALUES ('delete', ?1, ?2, ?3)",
669 libsql::params![old_rowid, title, content],
670 )
671 .await?;
672 reassigned += 1;
673 }
674
675 tx.execute(
676 "DELETE FROM cold.concepts WHERE id = ?1",
677 libsql::params![*id],
678 )
679 .await?;
680 rehydrated += 1;
681 }
682
683 tx.execute(&format!("DROP TABLE {ARCHIVE_SESSION_MARKER}"), ())
684 .await?;
685 tx.commit().await?;
686
687 Ok(RehydrateReport {
688 concepts_rehydrated: rehydrated,
689 rowids_reassigned: reassigned,
690 })
691}
692
693/// Run one of the archive's `DELETE`s, naming the table if a guard refuses it.
694///
695/// **This is what closes defect AC, and the shape of the fix is the point.**
696/// There used to be a second classifier here — `classify_archive_violation` —
697/// which was defined, delegated correctly to [`crate::error::abort_kind`], and
698/// called from nowhere, so `DbError::ArchiveViolation` was unreachable by any
699/// code path in the crate. It was recorded as defect H, marked Fixed by a commit
700/// that made the *body* delegate rather than making the function *called*, and
701/// so survived its own repair. It is deleted rather than wired up, because
702/// [`crate::error::classify`] with [`WriteOp::Delete`] already did exactly what
703/// it did: the defect was one classifier too many, not one too few.
704///
705/// A guard firing here means the marker table is absent or was dropped early —
706/// the session's invariant broken from inside. That is worth a typed error
707/// naming the table rather than a raw engine message naming a trigger.
708async fn delete_guarded(
709 tx: &libsql::Transaction,
710 conn: &libsql::Connection,
711 sql: &str,
712 cutoff: &str,
713 table: &str,
714) -> Result<u64> {
715 match tx
716 .execute(sql, libsql::named_params! {":cutoff": cutoff})
717 .await
718 {
719 Ok(n) => Ok(n),
720 Err(e) => Err(crate::error::classify(conn, e, WriteOp::Delete { table }).await),
721 }
722}