Skip to main content

latexml_post/
object_db.rs

1//! Object database for cross-document data sharing.
2//!
3//! Port of `LaTeXML::Util::ObjectDB` + `ObjectDB::Entry`.
4//! A key-value store used by Scan, CrossRef, MakeIndex, and MakeBibliography
5//! to share structural information across documents and processing phases.
6//!
7//! Keys follow conventions:
8//! - `ID:<xml:id>` — element data (type, parent, children, labels, location, etc.)
9//! - `LABEL:<label>` — label → ID mapping
10//! - `DOCUMENT:<path>` — document location → root ID mapping
11//! - `SITE_ROOT` — root document of the site
12//! - `BIBLABEL:<list>:<key>` — bibliography key → item ID
13//! - `GLOSSARY:<list>:<key>` — glossary entries
14//! - `INDEX:<phrase1>:<phrase2>:...` — index entries
15//! - `DECLARATION:(global|local):<name>` — declared symbols
16//! - `NOTATION:<name>` — notation entries
17
18use libxml::tree::Node;
19use rustc_hash::FxHashMap as HashMap;
20
21/// A single entry in the ObjectDB.
22///
23/// Port of `LaTeXML::Util::ObjectDB::Entry`.
24#[derive(Debug, Clone)]
25pub struct Entry {
26  /// The key this entry is stored under.
27  pub key: String,
28  /// Attribute-value pairs.
29  values:  HashMap<String, Value>,
30}
31
32/// A value stored in an Entry.
33///
34/// Values can be scalars, lists, nested hashes, or XML node references.
35#[derive(Debug, Clone)]
36pub enum Value {
37  /// A simple string value.
38  String(String),
39  /// An integer value.
40  Int(i64),
41  /// A boolean value.
42  Bool(bool),
43  /// A list of values.
44  List(Vec<Value>),
45  /// A nested hash (for associations like referrers).
46  Hash(HashMap<String, Value>),
47  /// An XML node (cloned from the document).
48  Xml(Node),
49  /// Null/undefined.
50  Null,
51}
52
53impl Value {
54  /// Get as string, if possible.
55  pub fn as_str(&self) -> Option<&str> {
56    match self {
57      Value::String(s) => Some(s),
58      _ => None,
59    }
60  }
61
62  /// Get as string, converting if needed.
63  pub fn as_string(&self) -> String {
64    match self {
65      Value::String(s) => s.clone(),
66      Value::Int(n) => n.to_string(),
67      Value::Bool(b) => b.to_string(),
68      Value::Xml(node) => node.get_content(),
69      Value::Null => String::new(),
70      _ => String::new(),
71    }
72  }
73
74  /// Check if the value is truthy (non-null, non-empty).
75  pub fn is_truthy(&self) -> bool {
76    match self {
77      Value::Null => false,
78      Value::String(s) => !s.is_empty(),
79      Value::Bool(b) => *b,
80      Value::List(v) => !v.is_empty(),
81      Value::Hash(h) => !h.is_empty(),
82      _ => true,
83    }
84  }
85}
86
87impl From<&str> for Value {
88  fn from(s: &str) -> Self { Value::String(s.to_string()) }
89}
90
91impl From<String> for Value {
92  fn from(s: String) -> Self { Value::String(s) }
93}
94
95impl std::fmt::Display for Value {
96  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97    match self {
98      Value::String(s) => write!(f, "{}", s),
99      Value::Int(n) => write!(f, "{}", n),
100      Value::Bool(b) => write!(f, "{}", b),
101      Value::Null => Ok(()),
102      Value::Xml(node) => write!(f, "{}", node.get_content()),
103      Value::List(_) | Value::Hash(_) => Ok(()),
104    }
105  }
106}
107
108// NOTE deliberately NO `impl From<Node> for Value`: a bare node handle points
109// into whichever document it came from, and storing one couples the DB's
110// validity to that document's lifetime (fatal under streaming, where page DOMs
111// are freed as soon as they are processed). Nodes enter the DB only through
112// `ObjectDB::adopt_xml`, which copies them into DB-owned storage.
113
114impl From<bool> for Value {
115  fn from(b: bool) -> Self { Value::Bool(b) }
116}
117
118impl From<Vec<String>> for Value {
119  fn from(v: Vec<String>) -> Self { Value::List(v.into_iter().map(Value::String).collect()) }
120}
121
122impl Entry {
123  /// Create a new entry with the given key.
124  pub fn new(key: &str) -> Self {
125    Entry {
126      key:    key.to_string(),
127      values: HashMap::default(),
128    }
129  }
130
131  /// Get the entry's key.
132  pub fn get_key(&self) -> &str { &self.key }
133
134  /// Check if the entry has a value for the given attribute.
135  pub fn has_value(&self, attr: &str) -> bool { self.values.contains_key(attr) }
136
137  /// Get a value by attribute name.
138  pub fn get_value(&self, attr: &str) -> Option<&Value> { self.values.get(attr) }
139
140  /// Get a string value by attribute name.
141  pub fn get_string(&self, attr: &str) -> Option<&str> {
142    self.values.get(attr).and_then(|v| v.as_str())
143  }
144
145  /// Get an XML node value by attribute name.
146  pub fn get_xml(&self, attr: &str) -> Option<&Node> {
147    match self.values.get(attr) {
148      Some(Value::Xml(n)) => Some(n),
149      _ => None,
150    }
151  }
152
153  /// Get a children list (as string IDs).
154  pub fn get_children(&self) -> Vec<String> {
155    match self.values.get("children") {
156      Some(Value::List(items)) => items
157        .iter()
158        .filter_map(|v| v.as_str().map(String::from))
159        .collect(),
160      _ => vec![],
161    }
162  }
163
164  /// Set multiple attribute-value pairs.
165  ///
166  /// Port of `Entry::setValues`.
167  pub fn set_values(&mut self, pairs: Vec<(&str, Value)>) {
168    for (key, value) in pairs {
169      match value {
170        Value::Null => {
171          self.values.remove(key);
172        },
173        _ => {
174          self.values.insert(key.to_string(), value);
175        },
176      }
177    }
178  }
179
180  /// Set a single value.
181  pub fn set_value(&mut self, attr: &str, value: Value) {
182    match value {
183      Value::Null => {
184        self.values.remove(attr);
185      },
186      _ => {
187        self.values.insert(attr.to_string(), value);
188      },
189    }
190  }
191
192  /// Push values onto a list attribute.
193  ///
194  /// Port of `Entry::pushValues`.
195  pub fn push_values(&mut self, attr: &str, values: Vec<Value>) {
196    let list = self
197      .values
198      .entry(attr.to_string())
199      .or_insert_with(|| Value::List(Vec::new()));
200    if let Value::List(items) = list {
201      for v in values {
202        items.push(v);
203      }
204    }
205  }
206
207  /// Push values onto a list attribute, skipping duplicates.
208  ///
209  /// Port of `Entry::pushNew`.
210  pub fn push_new(&mut self, attr: &str, values: Vec<Value>) {
211    let list = self
212      .values
213      .entry(attr.to_string())
214      .or_insert_with(|| Value::List(Vec::new()));
215    if let Value::List(items) = list {
216      for v in values {
217        let s = v.to_string();
218        if !items.iter().any(|existing| existing.to_string() == s) {
219          items.push(v);
220        }
221      }
222    }
223  }
224
225  /// Create nested hash association.
226  ///
227  /// Port of `Entry::noteAssociation`.
228  /// `noteAssociation("referrers", "parent_id")` creates `{referrers => {parent_id => 1}}`
229  pub fn note_association(&mut self, keys: &[&str]) {
230    if keys.is_empty() {
231      return;
232    }
233    if keys.len() == 1 {
234      self.values.insert(keys[0].to_string(), Value::Bool(true));
235      return;
236    }
237
238    // Navigate/create nested hash structure
239    let first = keys[0];
240    let rest = &keys[1..];
241
242    let hash = self
243      .values
244      .entry(first.to_string())
245      .or_insert_with(|| Value::Hash(HashMap::default()));
246
247    if let Value::Hash(h) = hash {
248      let mut current = h;
249      for (i, &key) in rest.iter().enumerate() {
250        if i == rest.len() - 1 {
251          // Last key: set to true
252          current.insert(key.to_string(), Value::Bool(true));
253        } else {
254          // Intermediate: navigate/create hash
255          let entry = current
256            .entry(key.to_string())
257            .or_insert_with(|| Value::Hash(HashMap::default()));
258          if let Value::Hash(inner) = entry {
259            current = inner;
260          } else {
261            break;
262          }
263        }
264      }
265    }
266  }
267}
268
269/// The Object Database.
270///
271/// Port of `LaTeXML::Util::ObjectDB`.
272/// In-memory key-value store. For now, no external DB persistence
273/// (the Perl version uses Berkeley DB via DB_File).
274pub struct ObjectDB {
275  /// In-memory entry storage.
276  objects:    HashMap<String, Entry>,
277  /// The ONE document owning every [`Value::Xml`] node the DB stores. A
278  /// stored node used to be a handle into the scanned page's own DOM, which
279  /// made the DB's lifetime silently depend on every page document staying
280  /// resident — a use-after-free the moment a streaming pipeline frees a
281  /// processed page. [`ObjectDB::adopt_xml`] deep-copies into this document
282  /// instead, so stored XML lives exactly as long as the DB, whatever happens
283  /// to the source.
284  ///
285  /// **One document, not one per value.** The first cut retained a whole fresh
286  /// `xmlDoc` per adopted node, and Scan adopts a `title` and `toctitle` for
287  /// every object it registers: on a 614 MB core XML (200,403 objects) that is
288  /// tens of thousands of documents — each with its own dictionary and
289  /// structure — to hold a few MB of title markup. Measured, that regressed
290  /// the split post-processing of that input from ~21 GB peak (completing over
291  /// 40,201 pages) to **67 GB and a memory-ceiling kill with zero pages
292  /// written**. Titles really are small; `xmlDoc`s are not.
293  xml_holder: Option<libxml::tree::Document>,
294  /// The attached external store, when this DB was opened via
295  /// [`ObjectDB::attach`] (Perl `--dbfile`); `None` for purely in-memory use.
296  external:   Option<ExternalDb>,
297}
298
299impl ObjectDB {
300  /// Create a new empty ObjectDB.
301  pub fn new() -> Self {
302    ObjectDB {
303      objects:    HashMap::default(),
304      xml_holder: None,
305      external:   None,
306    }
307  }
308
309  /// The DB's holding document, created on first adoption (so a DB that
310  /// stores no XML allocates none), with a root element to parent the copies
311  /// under — an unparented node would not be reached by the holder's own
312  /// `xmlFreeDoc` either.
313  fn xml_holder_mut(&mut self) -> Option<&mut libxml::tree::Document> {
314    if self.xml_holder.is_none() {
315      let mut doc = libxml::tree::Document::new().ok()?;
316      let root = Node::new("_objectdb_", None, &doc).ok()?;
317      doc.set_root_element(&root);
318      self.xml_holder = Some(doc);
319    }
320    self.xml_holder.as_mut()
321  }
322
323  /// Adopt an XML node for storage: deep-copy it into a document the DB owns
324  /// and return the [`Value::Xml`] wrapping the copy. This is the ONLY way a
325  /// node enters the DB (`From<Node> for Value` was removed on purpose), so
326  /// no stored value can dangle into a page document that was freed.
327  ///
328  /// Two hops, because the fork's copy primitives pull in opposite
329  /// directions: `dup_node_into_new_doc` copies from a LINKED source but only
330  /// into a fresh document, while `import_node` copies into an EXISTING
331  /// document but rejects a linked source. So: copy out to a scratch
332  /// document, detach, copy into the holder, and free the scratch copy —
333  /// an unlinked doc-owned node is freed by nobody (the rust-libxml `Linkage`
334  /// rule behind `Node::free_subtree`), so dropping the scratch document
335  /// alone would leak it. A one-hop version wants a fork method that copies
336  /// from a linked source into an existing document; that is a publish + dep
337  /// bump, and this is a regression fix.
338  ///
339  /// Returns `None` when the copy fails; callers should degrade to a string
340  /// form rather than store nothing, and never crash.
341  pub fn adopt_xml(&mut self, node: &Node) -> Option<Value> {
342    let scratch = libxml::tree::Document::dup_node_into_new_doc(node).ok()?;
343    let mut extracted = scratch.get_root_element()?;
344    extracted.unlink_node();
345    let holder = self.xml_holder_mut()?;
346    let mut adopted = holder.import_node(&mut extracted).ok()?;
347    let mut root = holder.get_root_element()?;
348    root.add_child(&mut adopted).ok()?;
349    extracted.free_subtree();
350    Some(Value::Xml(adopted))
351  }
352
353  /// Look up an entry by key.
354  ///
355  /// Port of `ObjectDB::lookup`.
356  pub fn lookup(&self, key: &str) -> Option<&Entry> { self.objects.get(key) }
357
358  /// Look up an entry by key (mutable).
359  pub fn lookup_mut(&mut self, key: &str) -> Option<&mut Entry> { self.objects.get_mut(key) }
360
361  /// Register an entry: create if new, or return existing.
362  /// Sets the given properties on the entry.
363  ///
364  /// Port of `ObjectDB::register`.
365  pub fn register(&mut self, key: &str, props: Vec<(&str, Value)>) -> &mut Entry {
366    let entry = self
367      .objects
368      .entry(key.to_string())
369      .or_insert_with(|| Entry::new(key));
370    if !props.is_empty() {
371      entry.set_values(props);
372    }
373    self.objects.get_mut(key).unwrap()
374  }
375
376  /// Remove an entry.
377  ///
378  /// Port of `ObjectDB::unregister`.
379  pub fn unregister(&mut self, key: &str) {
380    self.objects.remove(key);
381    // Perl ObjectDB.pm:183: "Must remove external entry (if any) as well,
382    // else it'll get pulled back in!" — without this the key resurrects on
383    // the next attach (review 2026-08-03 finding #1).
384    if let Some(external) = &mut self.external {
385      external.baseline.remove(key);
386      if !external.readonly {
387        let _ = external
388          .conn
389          .execute("DELETE FROM entries WHERE key = ?1", rusqlite::params![key]);
390      }
391    }
392  }
393
394  /// Get all keys, sorted.
395  ///
396  /// Port of `ObjectDB::getKeys`.
397  pub fn get_keys(&self) -> Vec<&String> {
398    let mut keys: Vec<_> = self.objects.keys().collect();
399    keys.sort();
400    keys
401  }
402
403  /// Number of registered entries. O(1); avoids `get_keys().len()`'s
404  /// sort + allocation when only the count is needed.
405  pub fn len(&self) -> usize { self.objects.len() }
406
407  /// True when no entries are registered.
408  pub fn is_empty(&self) -> bool { self.objects.is_empty() }
409
410  /// Iterate keys in arbitrary order without allocating/sorting. Use when
411  /// the traversal order does not matter (e.g. per-node fill-ins).
412  pub fn keys_iter(&self) -> impl Iterator<Item = &String> { self.objects.keys() }
413
414  /// Return a status string.
415  ///
416  /// Port of `ObjectDB::status`.
417  pub fn status(&self) -> String { format!("{} objects", self.objects.len()) }
418}
419
420impl Default for ObjectDB {
421  fn default() -> Self { Self::new() }
422}
423
424// ======================================================================
425// Perl `--dbfile` parity: SQLite persistence (design 2026-08-02, docs/
426// performance/STREAMING_POST_DESIGN_2026-07-06.md §6). Faithful to the
427// OBSERVABLE contract of `LaTeXML::Util::ObjectDB` (ObjectDB.pm):
428// `new(dbfile)` attaches a keyed store (creating it unless readonly),
429// `lookup`/`getKeys` see the union of stored + registered entries, and
430// `finish` writes back ONLY entries that differ from what is stored
431// (ObjectDB.pm `sub finish`: `next if compare_hash($row, thaw($stored))`),
432// then detaches. One deliberate internal divergence: Perl thaws entries
433// lazily per `lookup` because Berkeley DB reads are cheap point-lookups;
434// we load eagerly at attach — SQLite reads the whole table in one scan,
435// every consumer (CrossRef's nav/TOC walks) touches most keys anyway, and
436// eager load keeps `lookup(&self)` free of interior mutability.
437//
438// Storage: `entries(key TEXT PRIMARY KEY, props TEXT)` with the property
439// map JSON-encoded (Perl uses Storable `nfreeze`; JSON keeps the artifact
440// `sqlite3`-CLI-inspectable, which Storable never was), plus a `meta`
441// table and `PRAGMA user_version` for staleness: a format-version mismatch
442// refuses the file rather than limping (caller decides to rebuild).
443// `Value::Xml` round-trips as serialized XML, re-adopted into the reader's
444// own holder document via the same `adopt_xml` copy discipline Scan uses.
445
446/// Bump when the on-disk encoding changes shape. Mismatched files are
447/// refused at `attach` — never silently reinterpreted.
448pub const DBFILE_FORMAT_VERSION: i64 = 1;
449
450/// The attached external store: connection plus the as-stored JSON of every
451/// loaded entry, so `finish` can implement Perl's changed-only write-back
452/// by string comparison instead of re-reading the table.
453struct ExternalDb {
454  conn:     rusqlite::Connection,
455  readonly: bool,
456  baseline: HashMap<String, String>,
457}
458
459/// Options for [`ObjectDB::attach`], mirroring Perl `ObjectDB::new`'s
460/// `dbfile`/`clean`/`readonly` knobs.
461#[derive(Default)]
462pub struct DbAttachOptions {
463  /// Delete any existing file first (Perl `clean => 1`).
464  pub clean:    bool,
465  /// Open for reading only; `finish` will not write (Perl `readonly`).
466  pub readonly: bool,
467}
468
469impl ObjectDB {
470  /// Attach (and load) an external SQLite object store — Perl
471  /// `ObjectDB->new(dbfile => …)`.
472  pub fn attach(dbfile: &std::path::Path, options: DbAttachOptions) -> Result<Self, String> {
473    if options.clean && options.readonly {
474      return Err("dbfile: clean requires write access (Perl always opens O_RDWR)".to_string());
475    }
476    if options.clean && dbfile.exists() {
477      latexml_core::common::error::emit_warn(
478        "expected",
479        "dbfile",
480        &format!("Removing Object database file {}!", dbfile.display()),
481      );
482      std::fs::remove_file(dbfile)
483        .map_err(|e| format!("cannot remove {}: {e}", dbfile.display()))?;
484    }
485    let conn = if options.readonly {
486      rusqlite::Connection::open_with_flags(dbfile, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
487    } else {
488      rusqlite::Connection::open(dbfile)
489    }
490    .map_err(|e| format!("cannot attach DB {}: {e}", dbfile.display()))?;
491    // Concurrent workers are this layer's purpose: WAL lets N readers overlap
492    // one writer, and a busy timeout rides out a writer's commit instead of
493    // failing mid-transaction with SQLITE_BUSY.
494    if !options.readonly {
495      let _ = conn.query_row("PRAGMA journal_mode = WAL", [], |_| Ok(()));
496    }
497    conn
498      .busy_timeout(std::time::Duration::from_secs(10))
499      .map_err(|e| format!("cannot set busy_timeout: {e}"))?;
500
501    let version: i64 = conn
502      .query_row("PRAGMA user_version", [], |r| r.get(0))
503      .map_err(|e| format!("cannot read user_version: {e}"))?;
504    let entries_table: bool = conn
505      .query_row(
506        "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='entries'",
507        [],
508        |r| r.get::<_, i64>(0),
509      )
510      .map(|n| n > 0)
511      .unwrap_or(false);
512    let any_table: bool = conn
513      .query_row(
514        "SELECT COUNT(*) FROM sqlite_master WHERE type='table'",
515        [],
516        |r| r.get::<_, i64>(0),
517      )
518      .map(|n| n > 0)
519      .unwrap_or(false);
520    if version == 0 && any_table && !entries_table {
521      return Err(format!(
522        "{} is a SQLite file but not an ObjectDB (no entries table) — refusing to adopt it",
523        dbfile.display()
524      ));
525    }
526    if version == 0 && !options.readonly {
527      conn
528        .execute_batch(&format!(
529          "PRAGMA user_version = {DBFILE_FORMAT_VERSION};
530           CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
531           CREATE TABLE IF NOT EXISTS entries(key TEXT PRIMARY KEY, props TEXT NOT NULL);"
532        ))
533        .map_err(|e| format!("cannot initialize {}: {e}", dbfile.display()))?;
534    } else if version != 0 && version != DBFILE_FORMAT_VERSION {
535      return Err(format!(
536        "object database {} has format version {version}, this binary reads {DBFILE_FORMAT_VERSION} — rebuild it (--dbfile with a clean run)",
537        dbfile.display()
538      ));
539    }
540
541    let mut db = ObjectDB::new();
542    let mut baseline = HashMap::default();
543    // A fresh readonly file has no tables; treat as empty rather than erroring.
544    let table_exists: bool = conn
545      .query_row(
546        "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='entries'",
547        [],
548        |r| r.get::<_, i64>(0),
549      )
550      .map(|n| n > 0)
551      .unwrap_or(false);
552    if table_exists {
553      let mut stmt = conn
554        .prepare("SELECT key, props FROM entries")
555        .map_err(|e| format!("cannot read entries: {e}"))?;
556      let rows = stmt
557        .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
558        .map_err(|e| format!("cannot scan entries: {e}"))?;
559      for row in rows {
560        let (key, props) = row.map_err(|e| format!("bad row: {e}"))?;
561        // One bad row must not poison the whole file (review finding #4):
562        // warn with the key and skip; the on-disk row stays as it was.
563        match db.decode_entry(&key, &props) {
564          Ok(entry) => {
565            db.objects.insert(key.clone(), entry);
566            baseline.insert(key, props);
567          },
568          Err(e) => latexml_core::common::error::emit_warn("malformed", "dbfile_entry", &e),
569        }
570      }
571      drop(stmt);
572    }
573    db.external = Some(ExternalDb {
574      conn,
575      readonly: options.readonly,
576      baseline,
577    });
578    Ok(db)
579  }
580
581  /// Persist this IN-MEMORY db as a fresh dbfile (the parallel-render
582  /// handoff: the parent scans/sweeps in memory, saves once, and each worker
583  /// attaches the file readonly). Unlike [`ObjectDB::finish`] this does not
584  /// consume the attachment state — the db stays usable in memory, and any
585  /// existing file at `path` is replaced.
586  pub fn save_as(&self, path: &std::path::Path) -> Result<usize, String> {
587    if path.exists() {
588      std::fs::remove_file(path).map_err(|e| format!("cannot replace {}: {e}", path.display()))?;
589    }
590    let mut conn = rusqlite::Connection::open(path)
591      .map_err(|e| format!("cannot create dbfile {}: {e}", path.display()))?;
592    let _ = conn.query_row("PRAGMA journal_mode = WAL", [], |_| Ok(()));
593    conn
594      .execute_batch(&format!(
595        "PRAGMA user_version = {DBFILE_FORMAT_VERSION};
596         CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
597         CREATE TABLE entries(key TEXT PRIMARY KEY, props TEXT NOT NULL);"
598      ))
599      .map_err(|e| format!("cannot initialize {}: {e}", path.display()))?;
600    let tx = conn
601      .transaction()
602      .map_err(|e| format!("cannot begin save_as transaction: {e}"))?;
603    let mut stored = 0usize;
604    for (key, entry) in &self.objects {
605      let map: serde_json::Map<String, serde_json::Value> = entry
606        .values
607        .iter()
608        .map(|(k, v)| (k.clone(), value_to_json_with(self.xml_holder.as_ref(), v)))
609        .collect();
610      tx.execute(
611        "INSERT INTO entries(key, props) VALUES (?1, ?2)",
612        rusqlite::params![key, serde_json::Value::Object(map).to_string()],
613      )
614      .map_err(|e| format!("cannot store entry {key}: {e}"))?;
615      stored += 1;
616    }
617    tx.execute(
618      "INSERT INTO meta(key, value) VALUES ('latexml_version', ?1)",
619      rusqlite::params![env!("CARGO_PKG_VERSION")],
620    )
621    .map_err(|e| format!("cannot store meta: {e}"))?;
622    tx.commit()
623      .map_err(|e| format!("cannot commit save_as: {e}"))?;
624    Ok(stored)
625  }
626
627  /// Write back changed entries and detach — Perl `ObjectDB::finish`.
628  /// Returns how many entries were stored. Idempotent: a second call (or a
629  /// call on a never-attached DB) stores nothing.
630  pub fn finish(&mut self) -> Result<usize, String> {
631    let Some(external) = self.external.take() else {
632      return Ok(0);
633    };
634    if external.readonly {
635      return Ok(0);
636    }
637    // Stream: encode → compare → insert per entry inside one transaction, so
638    // peak memory is ONE encoded entry, not the whole changed set (review
639    // finding #5 — the baseline copy itself is dropped with `external` at
640    // the end of this function).
641    let mut stored = 0usize;
642    let mut conn = external.conn;
643    let tx = conn
644      .transaction()
645      .map_err(|e| format!("cannot begin dbfile transaction: {e}"))?;
646    for (key, entry) in &self.objects {
647      let props = {
648        let map: serde_json::Map<String, serde_json::Value> = entry
649          .values
650          .iter()
651          .map(|(k, v)| (k.clone(), value_to_json_with(self.xml_holder.as_ref(), v)))
652          .collect();
653        serde_json::Value::Object(map).to_string()
654      };
655      if external.baseline.get(key) == Some(&props) {
656        continue;
657      }
658      tx.execute(
659        "INSERT INTO entries(key, props) VALUES (?1, ?2)
660         ON CONFLICT(key) DO UPDATE SET props = excluded.props",
661        rusqlite::params![key, props],
662      )
663      .map_err(|e| format!("cannot store entry {key}: {e}"))?;
664      stored += 1;
665    }
666    tx.execute(
667      "INSERT INTO meta(key, value) VALUES ('latexml_version', ?1)
668       ON CONFLICT(key) DO UPDATE SET value = excluded.value",
669      rusqlite::params![env!("CARGO_PKG_VERSION")],
670    )
671    .map_err(|e| format!("cannot store meta: {e}"))?;
672    tx.commit()
673      .map_err(|e| format!("cannot commit dbfile: {e}"))?;
674    Ok(stored)
675  }
676
677  /// Tagged, self-describing encoding — one-key objects so `Hash` values
678  /// can never be confused with the wrapper: `{"s":…}` string, `{"i":…}`
679  /// int, `{"b":…}` bool, `{"l":[…]}` list, `{"h":{…}}` hash, `{"x":"<…>"}`
680  /// serialized XML, JSON `null` for `Null`.
681  fn value_to_json(&self, value: &Value) -> serde_json::Value {
682    value_to_json_with(self.xml_holder.as_ref(), value)
683  }
684
685  fn decode_entry(&mut self, key: &str, props: &str) -> Result<Entry, String> {
686    let parsed: serde_json::Value =
687      serde_json::from_str(props).map_err(|e| format!("entry {key} is not valid JSON: {e}"))?;
688    let serde_json::Value::Object(map) = parsed else {
689      return Err(format!("entry {key} is not a JSON object"));
690    };
691    let mut entry = Entry::new(key);
692    for (attr, jv) in map {
693      let value = self.json_to_value(&jv, key)?;
694      entry.set_value(&attr, value);
695    }
696    Ok(entry)
697  }
698
699  fn json_to_value(&mut self, jv: &serde_json::Value, key: &str) -> Result<Value, String> {
700    use serde_json::Value as J;
701    let J::Object(o) = jv else {
702      return match jv {
703        J::Null => Ok(Value::Null),
704        other => Err(format!("entry {key}: unexpected bare JSON value {other}")),
705      };
706    };
707    if let Some((tag, inner)) = o.iter().next()
708      && o.len() == 1
709    {
710      return match (tag.as_str(), inner) {
711        ("s", J::String(s)) => Ok(Value::String(s.clone())),
712        ("i", J::Number(n)) => n
713          .as_i64()
714          .map(Value::Int)
715          .ok_or_else(|| format!("entry {key}: non-integer numeric value {n}")),
716        ("b", J::Bool(b)) => Ok(Value::Bool(*b)),
717        ("l", J::Array(items)) => Ok(Value::List(
718          items
719            .iter()
720            .map(|v| self.json_to_value(v, key))
721            .collect::<Result<Vec<_>, _>>()?,
722        )),
723        ("h", J::Object(map)) => {
724          let mut out = HashMap::default();
725          for (k, v) in map {
726            out.insert(k.clone(), self.json_to_value(v, key)?);
727          }
728          Ok(Value::Hash(out))
729        },
730        ("x", J::String(xml)) => {
731          let parsed = libxml::parser::Parser::default()
732            .parse_string(xml)
733            .map_err(|e| format!("entry {key}: stored XML does not parse: {e}"))?;
734          let root = parsed
735            .get_root_element()
736            .ok_or_else(|| format!("entry {key}: stored XML is empty"))?;
737          self
738            .adopt_xml(&root)
739            .ok_or_else(|| format!("entry {key}: could not adopt stored XML"))
740        },
741        (tag, _) => Err(format!("entry {key}: unknown value tag '{tag}'")),
742      };
743    }
744    Err(format!("entry {key}: malformed value object"))
745  }
746}
747
748/// Tagged, self-describing encoding (see [`ObjectDB::value_to_json`]); a free
749/// function so `finish` can encode while iterating `self.objects`.
750fn value_to_json_with(holder: Option<&libxml::tree::Document>, value: &Value) -> serde_json::Value {
751  use serde_json::{Value as J, json};
752  match value {
753    Value::String(s) => json!({ "s": s }),
754    Value::Int(i) => json!({ "i": i }),
755    Value::Bool(b) => json!({ "b": b }),
756    Value::Null => J::Null,
757    Value::List(items) => {
758      json!({ "l": items.iter().map(|v| value_to_json_with(holder, v)).collect::<Vec<_>>() })
759    },
760    Value::Hash(map) => {
761      json!({ "h": map.iter().map(|(k, v)| (k.clone(), value_to_json_with(holder, v))).collect::<serde_json::Map<_, _>>() })
762    },
763    Value::Xml(node) => {
764      let xml = holder
765        .map(|doc| doc.node_to_string(node))
766        .unwrap_or_default();
767      json!({ "x": xml })
768    },
769  }
770}
771
772#[cfg(test)]
773mod tests {
774  use super::*;
775
776  #[test]
777  fn test_entry_basic() {
778    let mut entry = Entry::new("test:key");
779    assert_eq!(entry.get_key(), "test:key");
780    assert!(!entry.has_value("name"));
781
782    entry.set_value("name", Value::from("Alice"));
783    assert!(entry.has_value("name"));
784    assert_eq!(entry.get_string("name"), Some("Alice"));
785  }
786
787  #[test]
788  fn test_entry_push_new() {
789    let mut entry = Entry::new("test");
790    entry.push_new("children", vec![Value::from("a"), Value::from("b")]);
791    entry.push_new("children", vec![Value::from("b"), Value::from("c")]);
792    // "b" should not be duplicated
793    let children = entry.get_children();
794    assert_eq!(children, vec!["a", "b", "c"]);
795  }
796
797  #[test]
798  fn test_entry_note_association() {
799    let mut entry = Entry::new("test");
800    entry.note_association(&["referrers", "doc1"]);
801    entry.note_association(&["referrers", "doc2"]);
802
803    assert!(entry.has_value("referrers"));
804    if let Some(Value::Hash(refs)) = entry.get_value("referrers") {
805      assert!(refs.contains_key("doc1"));
806      assert!(refs.contains_key("doc2"));
807    } else {
808      panic!("Expected Hash value for referrers");
809    }
810  }
811
812  #[test]
813  fn test_db_register_lookup() {
814    let mut db = ObjectDB::new();
815
816    db.register("ID:doc1", vec![
817      ("type", Value::from("ltx:document")),
818      ("title", Value::from("Test Document")),
819    ]);
820
821    let entry = db.lookup("ID:doc1");
822    assert!(entry.is_some());
823    assert_eq!(entry.unwrap().get_string("type"), Some("ltx:document"));
824    assert_eq!(entry.unwrap().get_string("title"), Some("Test Document"));
825
826    // Lookup non-existent
827    assert!(db.lookup("ID:missing").is_none());
828  }
829
830  #[test]
831  fn test_db_register_updates() {
832    let mut db = ObjectDB::new();
833    db.register("ID:x", vec![("a", Value::from("1"))]);
834    db.register("ID:x", vec![("b", Value::from("2"))]);
835
836    let entry = db.lookup("ID:x").unwrap();
837    assert_eq!(entry.get_string("a"), Some("1"));
838    assert_eq!(entry.get_string("b"), Some("2"));
839  }
840
841  #[test]
842  fn test_db_get_keys() {
843    let mut db = ObjectDB::new();
844    db.register("B", vec![]);
845    db.register("A", vec![]);
846    db.register("C", vec![]);
847
848    let keys = db.get_keys();
849    assert_eq!(keys, vec![
850      &"A".to_string(),
851      &"B".to_string(),
852      &"C".to_string()
853    ]);
854  }
855
856  #[test]
857  fn test_db_status() {
858    let mut db = ObjectDB::new();
859    assert_eq!(db.status(), "0 objects");
860    db.register("x", vec![]);
861    assert_eq!(db.status(), "1 objects");
862  }
863
864  #[test]
865  fn many_adoptions_share_one_holding_document() {
866    // The regression this guards: one `xmlDoc` per adopted value. Scan adopts
867    // a title (and toctitle) per registered object, so a book-scale document
868    // adopted tens of thousands of times — measured, that took split
869    // post-processing of a 614 MB core XML from ~21 GB to 67 GB and a
870    // memory-ceiling kill. Every copy must live in ONE document, and all of
871    // them must still be readable after the sources are gone.
872    let parser = libxml::parser::Parser::default();
873    let mut db = ObjectDB::new();
874    let mut sources = Vec::new();
875    for i in 0..64 {
876      let doc = parser
877        .parse_string(format!("<title>Section <em>{i}</em></title>").as_bytes())
878        .expect("parse source");
879      let root = doc.get_root_element().expect("root");
880      let adopted = db.adopt_xml(&root).expect("adopt");
881      db.register(&format!("ID:S{i}"), vec![("title", adopted)]);
882      sources.push(doc);
883    }
884    drop(sources); // every page DOM goes away
885    for i in 0..64 {
886      let entry = db.lookup(&format!("ID:S{i}")).expect("entry");
887      let node = entry.get_xml("title").expect("stored node");
888      assert_eq!(node.get_content(), format!("Section {i}"));
889    }
890    // All 64 copies are children of the single holder root, which is what
891    // keeps the retained cost constant instead of linear in objects.
892    let holder = db
893      .xml_holder
894      .as_ref()
895      .expect("holder exists after adoption");
896    let root = holder.get_root_element().expect("holder root");
897    assert_eq!(root.get_child_elements().len(), 64);
898  }
899
900  #[test]
901  fn adopted_xml_survives_source_document_drop() {
902    // The streaming contract: a stored XML value must stay valid after the
903    // page document it was scanned from is freed. Under the old
904    // `Value::Xml(source_node)` representation this was a use-after-free.
905    let parser = libxml::parser::Parser::default();
906    let source = parser
907      .parse_string(
908        "<title xmlns:m=\"http://www.w3.org/1998/Math/MathML\">\
909         The <m:mi>\u{03b1}</m:mi> section</title>",
910      )
911      .expect("parse source");
912    let title = source.get_root_element().expect("root");
913
914    let mut db = ObjectDB::new();
915    let adopted = db.adopt_xml(&title).expect("adopt");
916    db.register("ID:S1", vec![("title", adopted)]);
917    drop(source); // the page DOM goes away, as it will under streaming
918
919    let entry = db.lookup("ID:S1").expect("entry");
920    let node = entry.get_xml("title").expect("stored node");
921    assert_eq!(node.get_name(), "title");
922    assert_eq!(node.get_content(), "The \u{03b1} section");
923    // Markup survives adoption, not just flattened text: the MathML child is
924    // still an element in its namespace.
925    let mi = node
926      .get_child_nodes()
927      .into_iter()
928      .find(|c| c.get_name() == "mi")
929      .expect("m:mi child survives");
930    assert_eq!(
931      mi.get_namespace().map(|ns| ns.get_href()),
932      Some(String::from("http://www.w3.org/1998/Math/MathML"))
933    );
934  }
935
936  #[test]
937  fn test_value_truthy() {
938    assert!(!Value::Null.is_truthy());
939    assert!(!Value::String(String::new()).is_truthy());
940    assert!(Value::String("hello".to_string()).is_truthy());
941    assert!(Value::Bool(true).is_truthy());
942    assert!(!Value::Bool(false).is_truthy());
943    assert!(Value::Int(42).is_truthy());
944    assert!(!Value::List(vec![]).is_truthy());
945    assert!(Value::List(vec![Value::Int(1)]).is_truthy());
946  }
947
948  /// Perl `--dbfile` parity: a DB round-trips through its SQLite file —
949  /// scalars, lists, nested hashes, and adopted XML (re-adopted into the
950  /// READER's holder document, never a dangling node).
951  #[test]
952  fn dbfile_round_trips_all_value_shapes() {
953    let dir = tempfile::tempdir().expect("tempdir");
954    let dbfile = dir.path().join("site.db");
955
956    let mut db = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("attach fresh");
957    let title_doc = libxml::parser::Parser::default()
958      .parse_string("<ltx:text xmlns:ltx=\"http://dlmf.nist.gov/LaTeXML\">A <ltx:emph>title</ltx:emph></ltx:text>")
959      .expect("title parses");
960    let title = db
961      .adopt_xml(&title_doc.get_root_element().expect("root"))
962      .expect("adopted");
963    db.register("doc1#s1", vec![
964      ("type", Value::String("ltx:section".into())),
965      ("pageid", Value::Int(3)),
966      ("fresh", Value::Bool(true)),
967      (
968        "children",
969        Value::List(vec![
970          Value::String("doc1#s1.p1".into()),
971          Value::String("doc1#s1.p2".into()),
972        ]),
973      ),
974      (
975        "referrers",
976        Value::Hash({
977          let mut h = HashMap::default();
978          h.insert("doc2".to_string(), Value::String("ref".into()));
979          h
980        }),
981      ),
982      ("title", title),
983      ("missing", Value::Null),
984    ]);
985    let stored = db.finish().expect("finish writes");
986    assert_eq!(stored, 1, "one changed entry stored");
987
988    let mut reloaded = ObjectDB::attach(&dbfile, DbAttachOptions {
989      readonly: true,
990      ..Default::default()
991    })
992    .expect("attach readonly");
993    let entry = reloaded.lookup("doc1#s1").expect("entry survives");
994    assert_eq!(entry.get_string("type"), Some("ltx:section"));
995    assert!(matches!(entry.get_value("pageid"), Some(Value::Int(3))));
996    assert!(matches!(entry.get_value("fresh"), Some(Value::Bool(true))));
997    assert_eq!(entry.get_children(), vec!["doc1#s1.p1", "doc1#s1.p2"]);
998    assert!(matches!(entry.get_value("referrers"), Some(Value::Hash(_))));
999    let title = entry.get_xml("title").expect("XML value survives");
1000    assert!(
1001      title.get_content().contains("A title"),
1002      "adopted XML content round-trips"
1003    );
1004    assert_eq!(reloaded.finish().expect("readonly finish"), 0);
1005
1006    // XML idempotence (review finding #3): re-attach READ-WRITE and finish —
1007    // if serialize→adopt→serialize is not byte-stable, the XML entry rewrites
1008    // on every finish and the changed-only contract silently degrades to
1009    // rewrite-everything.
1010    let mut again = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("attach rw");
1011    assert_eq!(
1012      again.finish().expect("idempotent finish"),
1013      0,
1014      "an untouched XML-bearing entry must not re-store"
1015    );
1016
1017    // unregister must delete the STORED row too (Perl ObjectDB.pm:183:
1018    // "else it'll get pulled back in!").
1019    let mut db = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("attach 3");
1020    db.unregister("doc1#s1");
1021    db.finish().expect("finish after unregister");
1022    let db = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("attach 4");
1023    assert!(
1024      db.lookup("doc1#s1").is_none(),
1025      "an unregistered key must not resurrect on re-attach"
1026    );
1027  }
1028
1029  /// Perl `finish` stores only entries that DIFFER from what the file holds
1030  /// (ObjectDB.pm: `next if compare_hash(...)`) — re-finishing an unchanged
1031  /// DB writes nothing; touching one entry writes exactly one.
1032  #[test]
1033  fn dbfile_finish_writes_only_changes() {
1034    let dir = tempfile::tempdir().expect("tempdir");
1035    let dbfile = dir.path().join("site.db");
1036    let mut db = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("attach");
1037    db.register("a", vec![("v", Value::Int(1))]);
1038    db.register("b", vec![("v", Value::Int(2))]);
1039    assert_eq!(db.finish().expect("first finish"), 2);
1040
1041    let mut db = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("re-attach");
1042    assert_eq!(
1043      db.finish().expect("no-op finish"),
1044      0,
1045      "unchanged DB stores nothing"
1046    );
1047
1048    let mut db = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("re-attach 2");
1049    db.register("b", vec![("v", Value::Int(99))]);
1050    db.register("c", vec![("v", Value::Int(3))]);
1051    assert_eq!(
1052      db.finish().expect("delta finish"),
1053      2,
1054      "one changed + one new"
1055    );
1056    let db = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("re-attach 3");
1057    assert!(matches!(
1058      db.lookup("b").and_then(|e| e.get_value("v")),
1059      Some(Value::Int(99))
1060    ));
1061    assert_eq!(db.len(), 3);
1062  }
1063
1064  /// The concurrency contract the layer exists for (parallel page-render
1065  /// workers): N readers attach the SAME file while a writer commits — WAL
1066  /// keeps readers unblocked on their snapshot — and a second writer rides
1067  /// out the first's transaction via busy_timeout instead of failing with
1068  /// SQLITE_BUSY. ObjectDB itself is !Send (libxml values), so each thread
1069  /// builds its OWN handle inside the thread — exactly the worker model.
1070  #[test]
1071  fn dbfile_concurrent_readers_and_writer_contention() {
1072    let dir = tempfile::tempdir().expect("tempdir");
1073    let dbfile = dir.path().join("site.db");
1074    {
1075      let mut db = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("seed");
1076      for i in 0..500 {
1077        db.register(&format!("k{i}"), vec![("v", Value::Int(i))]);
1078      }
1079      assert_eq!(db.finish().expect("seed finish"), 500);
1080    }
1081
1082    // Writer-vs-writer: hold an IMMEDIATE transaction on a raw connection,
1083    // then finish() a second writer — busy_timeout must ride it out.
1084    let blocker = rusqlite::Connection::open(&dbfile).expect("blocker open");
1085    blocker
1086      .execute_batch("BEGIN IMMEDIATE; UPDATE entries SET props = props WHERE key = 'k0';")
1087      .expect("blocker tx");
1088    let release = std::thread::spawn(move || {
1089      std::thread::sleep(std::time::Duration::from_millis(300));
1090      blocker.execute_batch("COMMIT;").expect("blocker commit");
1091    });
1092    let mut writer = ObjectDB::attach(&dbfile, DbAttachOptions::default()).expect("writer");
1093    writer.register("k0", vec![("v", Value::Int(-1))]);
1094    let t0 = std::time::Instant::now();
1095    assert_eq!(
1096      writer.finish().expect("contended finish succeeds"),
1097      1,
1098      "the second writer stores its one change after the blocker commits"
1099    );
1100    assert!(
1101      t0.elapsed() < std::time::Duration::from_secs(9),
1102      "finish waited out the blocker, not the full timeout"
1103    );
1104    release.join().expect("blocker thread");
1105
1106    // N concurrent readers, each with its own attach, racing a live writer.
1107    std::thread::scope(|scope| {
1108      let dbfile = &dbfile;
1109      let mut handles = Vec::new();
1110      for _ in 0..4 {
1111        handles.push(scope.spawn(move || {
1112          for _ in 0..10 {
1113            let db = ObjectDB::attach(dbfile, DbAttachOptions {
1114              readonly: true,
1115              ..Default::default()
1116            })
1117            .expect("reader attaches during writes");
1118            assert_eq!(db.len(), 500, "readers always see a full snapshot");
1119            assert!(db.lookup("k42").is_some());
1120          }
1121        }));
1122      }
1123      scope.spawn(move || {
1124        for round in 0..10 {
1125          let mut db =
1126            ObjectDB::attach(dbfile, DbAttachOptions::default()).expect("writer attaches");
1127          db.register("k1", vec![("v", Value::Int(1000 + round))]);
1128          db.finish().expect("interleaved writer finish");
1129        }
1130      });
1131      for h in handles {
1132        h.join().expect("reader thread");
1133      }
1134    });
1135  }
1136
1137  /// Staleness contract: a format-version mismatch REFUSES the file with a
1138  /// named error — never a silent reinterpretation.
1139  #[test]
1140  fn dbfile_version_mismatch_is_refused() {
1141    let dir = tempfile::tempdir().expect("tempdir");
1142    let dbfile = dir.path().join("site.db");
1143    {
1144      let conn = rusqlite::Connection::open(&dbfile).expect("open");
1145      conn
1146        .execute_batch("PRAGMA user_version = 999;")
1147        .expect("stamp");
1148    }
1149    let err = ObjectDB::attach(&dbfile, DbAttachOptions::default())
1150      .err()
1151      .expect("mismatched version must refuse");
1152    assert!(
1153      err.contains("format version 999"),
1154      "names the found version: {err}"
1155    );
1156    // `clean` is the sanctioned rebuild path (Perl `clean => 1`).
1157    let db = ObjectDB::attach(&dbfile, DbAttachOptions {
1158      clean: true,
1159      ..Default::default()
1160    })
1161    .expect("clean rebuild attaches");
1162    assert!(db.is_empty());
1163  }
1164}