Skip to main content

mq_db/
store.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    sync::{Mutex, RwLock},
5};
6
7/// In-memory state for a user-defined table.
8///
9/// `first_row_page`/`last_row_page` track where this table's rows live in
10/// the backing storage file (0 = not persisted yet), so a SQL `INSERT`
11/// can append just the new rows to the chain instead of rewriting `rows`
12/// in full on every call. See [`Storage::write_table_rows`].
13pub(crate) struct CustomTableState {
14    pub columns: Vec<String>,
15    pub rows: Vec<Vec<String>>,
16    pub first_row_page: u32,
17    pub last_row_page: u32,
18}
19
20use mq_markdown::Markdown;
21
22use crate::{
23    block::DocumentId,
24    document::Document,
25    error::MqdbError,
26    index,
27    indexes::DocumentIndex,
28    query::Query,
29    storage::{
30        Storage,
31        catalog::{CatalogEntry, CustomTableEntry},
32        codec::{decode_zone_map, encode_zone_map},
33    },
34};
35
36/// Persists any table whose rows have never been written to `storage` (i.e.
37/// `first_row_page == 0`), then builds catalog entries for every table.
38///
39/// Tables that already have a row-page chain are left untouched here — their
40/// pages were already written by an earlier flush or incremental `INSERT`
41/// append (see [`DocumentStore::try_append_table_rows_to_storage`]).
42fn persist_unsaved_table_rows(
43    storage: &mut Storage,
44    custom_tables: &RwLock<HashMap<String, CustomTableState>>,
45) -> Result<Vec<CustomTableEntry>, MqdbError> {
46    let mut guard = custom_tables.write().unwrap();
47    for state in guard.values_mut() {
48        if state.first_row_page == 0 && !state.rows.is_empty() {
49            let (first, last) = storage.write_table_rows(&state.rows)?;
50            state.first_row_page = first;
51            state.last_row_page = last;
52        }
53    }
54    Ok(guard
55        .iter()
56        .map(|(name, state)| CustomTableEntry {
57            name: name.clone(),
58            columns: state.columns.clone(),
59            first_row_page: state.first_row_page,
60            last_row_page: state.last_row_page,
61            num_rows: state.rows.len() as u32,
62        })
63        .collect())
64}
65
66/// The top-level embedded document store.
67///
68/// Holds a collection of parsed Markdown documents and provides access to
69/// the query interface. Documents are stored in memory with their flattened
70/// block lists and interval indexes.
71///
72/// ## Load modes
73///
74/// - [`DocumentStore::new`] / [`DocumentStore::add_str`] — in-memory, blocks immediately available
75/// - [`DocumentStore::load`] — reads all blocks from a `.mq-db` file into memory
76/// - [`DocumentStore::open`] — reads catalog only; blocks loaded on demand via
77///   [`load_all_blocks`](DocumentStore::load_all_blocks)
78///
79/// Secondary indexes ([`DocumentIndex`]) are built once via
80/// [`load_all_indexes`](DocumentStore::load_all_indexes) and cached, so
81/// subsequent [`crate::SqlEngine`] construction is O(1).
82///
83/// # Example
84///
85/// ```rust
86/// use mq_db::DocumentStore;
87///
88/// let mut store = DocumentStore::new();
89/// store.add_str("# Hello\n\nWorld").unwrap();
90///
91/// let results = store.query().heading_depth(1).blocks();
92/// assert_eq!(results.len(), 1);
93/// assert_eq!(results[0].content, "Hello");
94/// ```
95pub struct DocumentStore {
96    documents: Vec<Document>,
97    next_doc_id: DocumentId,
98    /// When `false`, source line/column spans are discarded after parsing.
99    store_spans: bool,
100    /// Open storage file kept for lazy block / index loading. `None` when the
101    /// store was built entirely in memory or fully loaded via `load()`.
102    /// Wrapped in `Mutex` so DDL operations (which hold only `&DocumentStore`)
103    /// can flush the updated catalog to disk.
104    pub(crate) storage: Mutex<Option<Storage>>,
105    /// Per-document secondary index cache (same order as `documents`).
106    /// `None` means the index has not been built/loaded for that document yet.
107    pub(crate) doc_indexes: Vec<Option<DocumentIndex>>,
108    /// User-registered virtual tables: name → (columns, rows).
109    /// Uses `RwLock` for interior mutability so `SqlEngine` can execute DDL
110    /// (`CREATE TABLE`, `INSERT INTO`, `DROP TABLE`) with only `&DocumentStore`.
111    pub(crate) custom_tables: RwLock<HashMap<String, CustomTableState>>,
112}
113
114impl Default for DocumentStore {
115    fn default() -> Self {
116        Self {
117            documents: Vec::new(),
118            next_doc_id: 0,
119            store_spans: true,
120            storage: Mutex::new(None),
121            doc_indexes: Vec::new(),
122            custom_tables: RwLock::new(HashMap::new()),
123        }
124    }
125}
126
127impl DocumentStore {
128    /// Creates an empty document store.
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    /// When set to `false`, source line/column spans are stripped from every
134    /// block added after this call. Reduces memory by ~21 bytes per block.
135    pub fn set_store_spans(&mut self, val: bool) {
136        self.store_spans = val;
137    }
138
139    /// Register a custom virtual table that can be queried via SQL.
140    ///
141    /// The table is queryable with `SELECT … FROM <name>`. All column values
142    /// are treated as strings; cast them in SQL as needed.
143    ///
144    /// Calling this a second time with the same name replaces the previous table.
145    pub fn register_table(
146        &mut self,
147        name: impl Into<String>,
148        columns: Vec<String>,
149        rows: Vec<Vec<String>>,
150    ) {
151        self.custom_tables.write().unwrap().insert(
152            name.into(),
153            CustomTableState {
154                columns,
155                rows,
156                first_row_page: 0,
157                last_row_page: 0,
158            },
159        );
160    }
161
162    /// Remove a previously registered custom table. Returns `true` if it existed.
163    pub fn unregister_table(&mut self, name: &str) -> bool {
164        self.custom_tables.write().unwrap().remove(name).is_some()
165    }
166
167    /// Parses and adds a Markdown file from disk.
168    ///
169    /// Returns the assigned `DocumentId` on success.
170    pub fn add_file(&mut self, path: impl AsRef<Path>) -> Result<DocumentId, MqdbError> {
171        let path = path.as_ref();
172        let content = std::fs::read_to_string(path)?;
173        self.add_str_with_path(&content, Some(path.to_path_buf()))
174    }
175
176    /// Parses and adds Markdown content from a string.
177    ///
178    /// Returns the assigned `DocumentId` on success.
179    pub fn add_str(&mut self, content: &str) -> Result<DocumentId, MqdbError> {
180        self.add_str_with_path(content, None)
181    }
182
183    fn add_str_with_path(
184        &mut self,
185        content: &str,
186        path: Option<std::path::PathBuf>,
187    ) -> Result<DocumentId, MqdbError> {
188        let md =
189            Markdown::from_markdown_str(content).map_err(|e| MqdbError::Parse(e.to_string()))?;
190
191        let doc_id = self.next_doc_id;
192        self.next_doc_id += 1;
193
194        let mut blocks = index::build_blocks(doc_id, &md.nodes);
195        if !self.store_spans {
196            for block in &mut blocks {
197                block.span = None;
198            }
199        }
200        let doc = Document::new(doc_id, path, blocks);
201        self.documents.push(doc);
202        self.doc_indexes.push(None);
203
204        Ok(doc_id)
205    }
206
207    /// Append a Markdown string to the existing `.mq-db` file (in-place).
208    ///
209    /// Works only when the store was opened via [`DocumentStore::open`] (i.e.
210    /// `self.storage` is `Some`).  New block pages and an index page chain are
211    /// appended to the file and the catalog is rewritten to include the new
212    /// entry.
213    ///
214    /// When called on an in-memory store (no backing file) this behaves
215    /// identically to [`add_str`](DocumentStore::add_str).
216    pub fn append_str(&mut self, content: &str) -> Result<DocumentId, MqdbError> {
217        self.do_append(content, None)
218    }
219
220    /// Append a Markdown file to the existing `.mq-db` file (in-place).
221    ///
222    /// See [`append_str`](DocumentStore::append_str) for full semantics.
223    pub fn append_file(&mut self, path: impl AsRef<Path>) -> Result<DocumentId, MqdbError> {
224        let path = path.as_ref();
225        let content = std::fs::read_to_string(path)?;
226        self.do_append(&content, Some(path.to_path_buf()))
227    }
228
229    fn do_append(
230        &mut self,
231        content: &str,
232        md_path: Option<PathBuf>,
233    ) -> Result<DocumentId, MqdbError> {
234        let md =
235            Markdown::from_markdown_str(content).map_err(|e| MqdbError::Parse(e.to_string()))?;
236        let doc_id = self.next_doc_id;
237        self.next_doc_id += 1;
238
239        let mut blocks = index::build_blocks(doc_id, &md.nodes);
240        if !self.store_spans {
241            for block in &mut blocks {
242                block.span = None;
243            }
244        }
245        let mut doc = Document::new(doc_id, md_path, blocks);
246
247        let idx_opt = {
248            let mut storage_guard = self.storage.lock().unwrap();
249            if let Some(storage) = storage_guard.as_mut() {
250                // Reconstruct catalog entries from already-loaded document metadata.
251                let mut entries = self.catalog_entries();
252
253                let first_block_page = storage.write_document(&doc)?;
254                doc.first_block_page = first_block_page;
255
256                let idx = DocumentIndex::build(&doc.blocks);
257                let index_start_page = storage.write_index(&idx.to_bytes())?;
258                doc.index_start_page = index_start_page;
259
260                entries.push(CatalogEntry {
261                    document_id: doc.id,
262                    path: doc.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
263                    first_block_page,
264                    num_blocks: doc.block_count,
265                    zone_map_bytes: encode_zone_map(&doc.zone_maps),
266                    index_start_page,
267                });
268
269                let custom = persist_unsaved_table_rows(storage, &self.custom_tables)?;
270                storage.flush_catalog(&entries, &custom)?;
271                Some(idx)
272            } else {
273                None
274            }
275        };
276        self.doc_indexes.push(idx_opt);
277
278        self.documents.push(doc);
279        Ok(doc_id)
280    }
281
282    /// Returns a slice of all documents in the store.
283    pub fn documents(&self) -> &[Document] {
284        &self.documents
285    }
286
287    /// Looks up a document by its `DocumentId`.
288    pub fn get_document(&self, id: DocumentId) -> Option<&Document> {
289        self.documents.iter().find(|d| d.id == id)
290    }
291
292    /// Returns the number of documents in the store.
293    pub fn len(&self) -> usize {
294        self.documents.len()
295    }
296
297    /// Returns `true` if the store contains no documents.
298    pub fn is_empty(&self) -> bool {
299        self.documents.is_empty()
300    }
301
302    /// Creates a new query builder backed by this store.
303    pub fn query(&self) -> Query<'_> {
304        Query::new(self)
305    }
306
307    // ─────────────────────────────────────────────────────────────────────────
308    // Lazy loading
309    // ─────────────────────────────────────────────────────────────────────────
310
311    /// Load blocks for every document that has not yet been loaded.
312    ///
313    /// No-op when the store was built in memory or fully loaded via `load()`.
314    pub fn load_all_blocks(&mut self) -> Result<(), MqdbError> {
315        let mut guard = self.storage.lock().unwrap();
316        let storage = match guard.as_mut() {
317            Some(s) => s,
318            None => return Ok(()),
319        };
320        for doc in &mut self.documents {
321            if doc.blocks.is_empty() && doc.block_count > 0 {
322                doc.blocks = storage.read_blocks(doc.first_block_page, doc.block_count)?;
323            }
324        }
325        Ok(())
326    }
327
328    /// Build or load persisted secondary indexes for every document and cache them.
329    ///
330    /// Must be called after [`load_all_blocks`](DocumentStore::load_all_blocks).
331    /// Subsequent [`crate::SqlEngine`] construction reuses the cache and pays no
332    /// per-block index rebuild cost.
333    pub fn load_all_indexes(&mut self) -> Result<(), MqdbError> {
334        for i in 0..self.documents.len() {
335            if self.doc_indexes[i].is_some() {
336                continue;
337            }
338
339            let idx = self.build_or_load_index_at(i)?;
340            self.doc_indexes[i] = Some(idx);
341        }
342        Ok(())
343    }
344
345    fn build_or_load_index_at(&mut self, i: usize) -> Result<DocumentIndex, MqdbError> {
346        let index_start_page = self.documents[i].index_start_page;
347
348        if index_start_page > 0 {
349            let mut guard = self.storage.lock().unwrap();
350            if let Some(storage) = guard.as_mut() {
351                let bytes = storage.read_index_bytes(index_start_page)?;
352                return DocumentIndex::from_bytes(&bytes);
353            }
354        }
355
356        Ok(DocumentIndex::build(&self.documents[i].blocks))
357    }
358
359    /// Returns the cached `DocumentIndex` for the document at position `i`.
360    pub(crate) fn get_doc_index(&self, i: usize) -> Option<&DocumentIndex> {
361        self.doc_indexes.get(i).and_then(|o| o.as_ref())
362    }
363
364    /// Builds catalog entries for every in-memory document.
365    fn catalog_entries(&self) -> Vec<CatalogEntry> {
366        self.documents
367            .iter()
368            .map(|d| CatalogEntry {
369                document_id: d.id,
370                path: d.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
371                first_block_page: d.first_block_page,
372                num_blocks: d.block_count,
373                zone_map_bytes: encode_zone_map(&d.zone_maps),
374                index_start_page: d.index_start_page,
375            })
376            .collect()
377    }
378
379    /// Flush the catalog (including custom tables) to the backing storage file,
380    /// if one is open. Called automatically after DDL operations such as
381    /// `CREATE TABLE` and `DROP TABLE`. No-op for in-memory stores.
382    ///
383    /// Any table whose rows have never been persisted is written out in full
384    /// here (a one-time cost). Tables already backed by a row-page chain keep
385    /// their existing pages untouched — see
386    /// [`try_append_table_rows_to_storage`](DocumentStore::try_append_table_rows_to_storage)
387    /// for the incremental `INSERT` path.
388    pub(crate) fn try_flush_catalog_to_storage(&self) {
389        let mut guard = self.storage.lock().unwrap();
390        if let Some(storage) = guard.as_mut() {
391            let entries = self.catalog_entries();
392            if let Ok(custom) = persist_unsaved_table_rows(storage, &self.custom_tables) {
393                let _ = storage.flush_catalog(&entries, &custom);
394            }
395        }
396    }
397
398    /// Append `new_rows` to `table_name`'s on-disk row chain and flush a
399    /// lightweight catalog update — no full row rewrite. No-op for in-memory
400    /// stores or unknown tables.
401    ///
402    /// This is what makes `INSERT INTO <table>` incremental: the cost is
403    /// proportional to the rows being inserted, not to the table's total size.
404    pub(crate) fn try_append_table_rows_to_storage(
405        &self,
406        table_name: &str,
407        new_rows: &[Vec<String>],
408    ) {
409        let mut guard = self.storage.lock().unwrap();
410        let storage = match guard.as_mut() {
411            Some(s) => s,
412            None => return,
413        };
414
415        {
416            let mut ct_guard = self.custom_tables.write().unwrap();
417            if let Some(state) = ct_guard.get_mut(table_name) {
418                let persisted = if state.first_row_page == 0 {
419                    // Nothing persisted yet for this table — write everything
420                    // currently in memory (covers rows seeded via
421                    // `register_table` plus the ones just inserted).
422                    storage.write_table_rows(&state.rows)
423                } else {
424                    storage
425                        .append_table_rows(state.last_row_page, new_rows)
426                        .map(|last| (state.first_row_page, last))
427                };
428                if let Ok((first, last)) = persisted {
429                    state.first_row_page = first;
430                    state.last_row_page = last;
431                }
432            }
433        }
434
435        let entries = self.catalog_entries();
436        let ct_guard = self.custom_tables.read().unwrap();
437        let custom: Vec<CustomTableEntry> = ct_guard
438            .iter()
439            .map(|(name, state)| CustomTableEntry {
440                name: name.clone(),
441                columns: state.columns.clone(),
442                first_row_page: state.first_row_page,
443                last_row_page: state.last_row_page,
444                num_rows: state.rows.len() as u32,
445            })
446            .collect();
447        drop(ct_guard);
448        let _ = storage.flush_catalog(&entries, &custom);
449    }
450
451    // ─────────────────────────────────────────────────────────────────────────
452    // Persistence
453    // ─────────────────────────────────────────────────────────────────────────
454
455    /// Persist all in-memory documents to a `.mq-db` file, including secondary
456    /// indexes. Writes atomically: writes to `path.tmp` then renames to `path`.
457    pub fn save(&self, path: impl AsRef<Path>) -> Result<(), MqdbError> {
458        let path = path.as_ref();
459        let tmp_path = PathBuf::from(format!("{}.tmp", path.to_string_lossy()));
460        if tmp_path.exists() {
461            std::fs::remove_file(&tmp_path)?;
462        }
463
464        let write_result = (|| -> Result<(), MqdbError> {
465            let mut storage = Storage::create(&tmp_path)?;
466            let mut entries = Vec::with_capacity(self.documents.len());
467
468            // Phase 1: write block data
469            for doc in &self.documents {
470                let first_block_page = storage.write_document(doc)?;
471                entries.push(CatalogEntry {
472                    document_id: doc.id,
473                    path: doc.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
474                    first_block_page,
475                    num_blocks: doc.block_count,
476                    zone_map_bytes: encode_zone_map(&doc.zone_maps),
477                    index_start_page: 0,
478                });
479            }
480
481            // Phase 2: write secondary indexes
482            for (i, doc) in self.documents.iter().enumerate() {
483                let idx = if let Some(cached) = self.doc_indexes.get(i).and_then(|o| o.as_ref()) {
484                    std::borrow::Cow::Borrowed(cached)
485                } else {
486                    std::borrow::Cow::Owned(DocumentIndex::build(&doc.blocks))
487                };
488                let bytes = idx.to_bytes();
489                entries[i].index_start_page = storage.write_index(&bytes)?;
490            }
491
492            // This writes into a brand-new file, so each table's rows are
493            // written fresh here rather than reusing `first_row_page` /
494            // `last_row_page` from `self`, which (if set) point into a
495            // *different*, already-open backing file.
496            let ct_guard = self.custom_tables.read().unwrap();
497            let mut custom = Vec::with_capacity(ct_guard.len());
498            for (name, state) in ct_guard.iter() {
499                let (first_row_page, last_row_page) = storage.write_table_rows(&state.rows)?;
500                custom.push(CustomTableEntry {
501                    name: name.clone(),
502                    columns: state.columns.clone(),
503                    first_row_page,
504                    last_row_page,
505                    num_rows: state.rows.len() as u32,
506                });
507            }
508            drop(ct_guard);
509
510            storage.flush_catalog(&entries, &custom)?;
511            Ok(())
512        })();
513
514        if let Err(err) = write_result {
515            let _ = std::fs::remove_file(&tmp_path);
516            return Err(err);
517        }
518
519        std::fs::rename(&tmp_path, path)?;
520        Ok(())
521    }
522
523    /// Open a `.mq-db` file in lazy mode: reads only catalog and zone maps.
524    ///
525    /// Block data is not loaded until you call
526    /// [`load_all_blocks`](DocumentStore::load_all_blocks).  Secondary indexes
527    /// are not built until you call
528    /// [`load_all_indexes`](DocumentStore::load_all_indexes).
529    pub fn open(path: impl AsRef<Path>) -> Result<Self, MqdbError> {
530        let mut storage = Storage::open(path.as_ref())?;
531        let (entries, custom_table_entries) = storage.load_catalog()?;
532        let cap = entries.len();
533        let mut documents = Vec::with_capacity(cap);
534        let mut max_doc_id = None;
535
536        for entry in entries {
537            let zone_maps = decode_zone_map(&entry.zone_map_bytes)?;
538            let document_id = entry.document_id;
539            let path = entry.path.map(PathBuf::from);
540            documents.push(Document::from_catalog_lazy(
541                document_id,
542                path,
543                entry.num_blocks,
544                zone_maps,
545                entry.first_block_page,
546                entry.index_start_page,
547            ));
548            max_doc_id =
549                Some(max_doc_id.map_or(document_id, |cur: DocumentId| cur.max(document_id)));
550        }
551
552        let mut custom_tables = HashMap::new();
553        for ct in custom_table_entries {
554            let rows = storage.read_table_rows(ct.first_row_page, ct.num_rows, ct.columns.len())?;
555            custom_tables.insert(
556                ct.name,
557                CustomTableState {
558                    columns: ct.columns,
559                    rows,
560                    first_row_page: ct.first_row_page,
561                    last_row_page: ct.last_row_page,
562                },
563            );
564        }
565
566        Ok(Self {
567            documents,
568            next_doc_id: max_doc_id.map_or(0, |id| id.saturating_add(1)),
569            store_spans: true,
570            storage: Mutex::new(Some(storage)),
571            doc_indexes: vec![None; cap],
572            custom_tables: RwLock::new(custom_tables),
573        })
574    }
575
576    /// Load a `.mq-db` file and reconstruct the in-memory `DocumentStore`.
577    ///
578    /// All block data is read from disk. Secondary indexes are **not** built
579    /// here — [`crate::SqlEngine`] builds them lazily on construction.
580    pub fn load(path: impl AsRef<Path>) -> Result<Self, MqdbError> {
581        let mut storage = Storage::open(path.as_ref())?;
582        let (entries, custom_table_entries) = storage.load_catalog()?;
583        let cap = entries.len();
584        let mut documents = Vec::with_capacity(cap);
585        let mut max_doc_id = None;
586
587        for entry in entries {
588            let blocks = storage.read_blocks(entry.first_block_page, entry.num_blocks)?;
589            let zone_maps = decode_zone_map(&entry.zone_map_bytes)?;
590            let document_id = entry.document_id;
591            let path = entry.path.map(PathBuf::from);
592            let mut doc = Document::from_parts(document_id, path, blocks, zone_maps);
593            doc.index_start_page = entry.index_start_page;
594            documents.push(doc);
595            max_doc_id =
596                Some(max_doc_id.map_or(document_id, |cur: DocumentId| cur.max(document_id)));
597        }
598
599        let mut custom_tables = HashMap::new();
600        for ct in custom_table_entries {
601            let rows = storage.read_table_rows(ct.first_row_page, ct.num_rows, ct.columns.len())?;
602            custom_tables.insert(
603                ct.name,
604                CustomTableState {
605                    columns: ct.columns,
606                    rows,
607                    first_row_page: ct.first_row_page,
608                    last_row_page: ct.last_row_page,
609                },
610            );
611        }
612
613        Ok(Self {
614            documents,
615            next_doc_id: max_doc_id.map_or(0, |id| id.saturating_add(1)),
616            store_spans: true,
617            storage: Mutex::new(None),
618            doc_indexes: vec![None; cap],
619            custom_tables: RwLock::new(custom_tables),
620        })
621    }
622
623    /// Load only the catalog metadata from a `.mq-db` file — no block data.
624    ///
625    /// Documents have `block_count` populated from the catalog but `blocks`
626    /// is empty. Useful for commands that only need zone-map metadata (e.g.
627    /// `list`), avoiding the cost of deserialising all block data.
628    pub fn load_catalog_only(path: impl AsRef<Path>) -> Result<Self, MqdbError> {
629        let mut storage = Storage::open(path.as_ref())?;
630        let (entries, _custom_table_entries) = storage.load_catalog()?;
631        let cap = entries.len();
632        let mut documents = Vec::with_capacity(cap);
633        let mut max_doc_id = None;
634
635        for entry in entries {
636            let zone_maps = decode_zone_map(&entry.zone_map_bytes)?;
637            let document_id = entry.document_id;
638            let path = entry.path.map(PathBuf::from);
639            documents.push(Document::from_catalog(
640                document_id,
641                path,
642                entry.num_blocks,
643                zone_maps,
644            ));
645            max_doc_id =
646                Some(max_doc_id.map_or(document_id, |cur: DocumentId| cur.max(document_id)));
647        }
648
649        Ok(Self {
650            documents,
651            next_doc_id: max_doc_id.map_or(0, |id| id.saturating_add(1)),
652            store_spans: true,
653            storage: Mutex::new(None),
654            doc_indexes: vec![None; cap],
655            custom_tables: RwLock::new(HashMap::new()),
656        })
657    }
658}