Skip to main content

mq_db/storage/
catalog.rs

1use std::collections::HashSet;
2
3use crate::{
4    error::MqdbError,
5    storage::page::{
6        PAGE_BODY_SIZE, PAGE_HEADER_SIZE, PAGE_TYPE_CATALOG, PageFile, make_page, parse_page_header,
7    },
8};
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct CatalogEntry {
12    pub document_id: u32,
13    pub path: Option<String>,
14    pub first_block_page: u32,
15    pub num_blocks: u32,
16    pub zone_map_bytes: Vec<u8>,
17    /// First page of the persisted secondary index chain. 0 = not stored.
18    pub index_start_page: u32,
19}
20
21/// A user-defined table entry stored in the catalog.
22///
23/// Row data is *not* stored inline — it lives in its own page chain
24/// (see [`crate::storage::Storage::write_table_rows`]) so that appending
25/// rows only requires writing the new pages plus this small fixed-size
26/// entry, instead of rewriting every previously-inserted row.
27#[derive(Debug, Clone, PartialEq)]
28pub struct CustomTableEntry {
29    pub name: String,
30    pub columns: Vec<String>,
31    /// First page of the row chain. 0 = no rows persisted yet.
32    pub first_row_page: u32,
33    /// Last page of the row chain — new rows are appended after this page.
34    pub last_row_page: u32,
35    pub num_rows: u32,
36}
37
38/// A persisted `CREATE VIEW` definition: just a name and its `SELECT` text,
39/// re-executed against live data on every reference (never materialized).
40#[derive(Debug, Clone, PartialEq)]
41pub struct ViewEntry {
42    pub name: String,
43    pub sql: String,
44}
45
46/// Catalog entries, custom tables, content hashes, and views read from a page file.
47pub type CatalogData = (
48    Vec<CatalogEntry>,
49    Vec<CustomTableEntry>,
50    Vec<(u32, u64)>,
51    Vec<ViewEntry>,
52);
53
54fn invalid_data(message: impl Into<String>) -> MqdbError {
55    MqdbError::Storage(message.into())
56}
57
58fn as_u16(value: usize, field: &str) -> u16 {
59    u16::try_from(value).unwrap_or_else(|_| panic!("{field} exceeds u16 range"))
60}
61
62fn as_u32(value: usize, field: &str) -> u32 {
63    u32::try_from(value).unwrap_or_else(|_| panic!("{field} exceeds u32 range"))
64}
65
66struct Decoder<'a> {
67    data: &'a [u8],
68    pos: usize,
69}
70
71impl<'a> Decoder<'a> {
72    fn new(data: &'a [u8]) -> Self {
73        Self { data, pos: 0 }
74    }
75
76    fn read_exact(&mut self, len: usize) -> Result<&'a [u8], MqdbError> {
77        let end = self
78            .pos
79            .checked_add(len)
80            .ok_or_else(|| invalid_data("byte offset overflow"))?;
81        if end > self.data.len() {
82            return Err(invalid_data("unexpected end of catalog data"));
83        }
84        let bytes = &self.data[self.pos..end];
85        self.pos = end;
86        Ok(bytes)
87    }
88
89    fn read_u8(&mut self) -> Result<u8, MqdbError> {
90        Ok(self.read_exact(1)?[0])
91    }
92
93    fn read_u16(&mut self) -> Result<u16, MqdbError> {
94        let bytes: [u8; 2] = self
95            .read_exact(2)?
96            .try_into()
97            .map_err(|_| invalid_data("failed to read u16"))?;
98        Ok(u16::from_le_bytes(bytes))
99    }
100
101    fn read_u32(&mut self) -> Result<u32, MqdbError> {
102        let bytes: [u8; 4] = self
103            .read_exact(4)?
104            .try_into()
105            .map_err(|_| invalid_data("failed to read u32"))?;
106        Ok(u32::from_le_bytes(bytes))
107    }
108
109    fn read_u64(&mut self) -> Result<u64, MqdbError> {
110        let bytes: [u8; 8] = self
111            .read_exact(8)?
112            .try_into()
113            .map_err(|_| invalid_data("failed to read u64"))?;
114        Ok(u64::from_le_bytes(bytes))
115    }
116
117    fn read_string_u16(&mut self) -> Result<String, MqdbError> {
118        let len = usize::from(self.read_u16()?);
119        let bytes = self.read_exact(len)?;
120        String::from_utf8(bytes.to_vec())
121            .map_err(|e| invalid_data(format!("invalid catalog string UTF-8: {e}")))
122    }
123
124    fn remaining(&self) -> usize {
125        self.data.len() - self.pos
126    }
127}
128
129fn serialize_catalog(
130    entries: &[CatalogEntry],
131    custom_tables: &[CustomTableEntry],
132    content_hashes: &[(u32, u64)],
133    views: &[ViewEntry],
134) -> Vec<u8> {
135    let mut out = Vec::new();
136    out.extend_from_slice(&as_u32(entries.len(), "catalog entry count").to_le_bytes());
137
138    for entry in entries {
139        out.extend_from_slice(&entry.document_id.to_le_bytes());
140        match &entry.path {
141            Some(path) => {
142                out.push(1);
143                out.extend_from_slice(&as_u16(path.len(), "catalog path length").to_le_bytes());
144                out.extend_from_slice(path.as_bytes());
145            }
146            None => out.push(0),
147        }
148        out.extend_from_slice(&entry.first_block_page.to_le_bytes());
149        out.extend_from_slice(&entry.num_blocks.to_le_bytes());
150        out.extend_from_slice(&as_u32(entry.zone_map_bytes.len(), "zone map length").to_le_bytes());
151        out.extend_from_slice(&entry.zone_map_bytes);
152        out.extend_from_slice(&entry.index_start_page.to_le_bytes());
153    }
154
155    out.extend_from_slice(&as_u32(custom_tables.len(), "custom table count").to_le_bytes());
156    for ct in custom_tables {
157        out.extend_from_slice(&as_u16(ct.name.len(), "table name length").to_le_bytes());
158        out.extend_from_slice(ct.name.as_bytes());
159        out.extend_from_slice(&as_u16(ct.columns.len(), "column count").to_le_bytes());
160        for col in &ct.columns {
161            out.extend_from_slice(&as_u16(col.len(), "column name length").to_le_bytes());
162            out.extend_from_slice(col.as_bytes());
163        }
164        out.extend_from_slice(&ct.first_row_page.to_le_bytes());
165        out.extend_from_slice(&ct.last_row_page.to_le_bytes());
166        out.extend_from_slice(&ct.num_rows.to_le_bytes());
167    }
168
169    // Trailing optional sections (added after custom tables so older catalog
170    // blobs without them still parse — `read_catalog` only reads a section if
171    // bytes remain). Maps `document_id` -> content hash, used to skip
172    // re-parsing unchanged files on `reindex_paths`.
173    out.extend_from_slice(&as_u32(content_hashes.len(), "content hash count").to_le_bytes());
174    for (document_id, hash) in content_hashes {
175        out.extend_from_slice(&document_id.to_le_bytes());
176        out.extend_from_slice(&hash.to_le_bytes());
177    }
178
179    // `CREATE VIEW` definitions: name -> SELECT text, re-executed live on
180    // every reference (see `SqlEngine::resolve_view`), so no row data here.
181    out.extend_from_slice(&as_u32(views.len(), "view count").to_le_bytes());
182    for view in views {
183        out.extend_from_slice(&as_u16(view.name.len(), "view name length").to_le_bytes());
184        out.extend_from_slice(view.name.as_bytes());
185        out.extend_from_slice(&as_u16(view.sql.len(), "view sql length").to_le_bytes());
186        out.extend_from_slice(view.sql.as_bytes());
187    }
188
189    out
190}
191
192pub fn write_catalog(
193    pf: &mut PageFile,
194    entries: &[CatalogEntry],
195    custom_tables: &[CustomTableEntry],
196    content_hashes: &[(u32, u64)],
197    views: &[ViewEntry],
198) -> Result<(), MqdbError> {
199    if pf.num_pages < 2 {
200        return Err(invalid_data("catalog start page is missing"));
201    }
202
203    let bytes = serialize_catalog(entries, custom_tables, content_hashes, views);
204    let chunks: Vec<&[u8]> = if bytes.is_empty() {
205        vec![&[]]
206    } else {
207        bytes.chunks(PAGE_BODY_SIZE).collect()
208    };
209
210    let mut page_ids = Vec::with_capacity(chunks.len());
211    page_ids.push(1);
212
213    for _ in 1..chunks.len() {
214        let placeholder = make_page(PAGE_TYPE_CATALOG, 0, 0, &[]);
215        let page_id = pf.append_page(&placeholder)?;
216        page_ids.push(page_id);
217    }
218
219    for (index, chunk) in chunks.iter().enumerate() {
220        let page_id = page_ids[index];
221        let next_page = page_ids.get(index + 1).copied().unwrap_or(0);
222        let page = make_page(PAGE_TYPE_CATALOG, page_id, next_page, chunk);
223        pf.write_page(page_id, &page)?;
224    }
225
226    Ok(())
227}
228
229pub fn read_catalog(pf: &mut PageFile) -> Result<CatalogData, MqdbError> {
230    if pf.num_pages < 2 {
231        return Err(invalid_data("catalog start page is missing"));
232    }
233
234    let mut bytes = Vec::new();
235    let mut page_id = 1u32;
236    let mut visited = HashSet::new();
237
238    loop {
239        if !visited.insert(page_id) {
240            return Err(invalid_data("catalog page chain contains a cycle"));
241        }
242
243        let page = pf.read_page(page_id)?;
244        let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
245        if page_type != PAGE_TYPE_CATALOG {
246            return Err(invalid_data(format!(
247                "page {page_id} is not a catalog page"
248            )));
249        }
250        if stored_page_id != page_id {
251            return Err(invalid_data(format!(
252                "catalog page header mismatch: expected {page_id}, found {stored_page_id}"
253            )));
254        }
255
256        bytes.extend_from_slice(&page[PAGE_HEADER_SIZE..]);
257
258        if next_page == 0 {
259            break;
260        }
261        page_id = next_page;
262    }
263
264    let mut decoder = Decoder::new(&bytes);
265    let entry_count = usize::try_from(decoder.read_u32()?)
266        .map_err(|_| invalid_data("catalog entry count exceeds usize range"))?;
267    let mut entries = Vec::with_capacity(entry_count);
268
269    for _ in 0..entry_count {
270        let document_id = decoder.read_u32()?;
271        let path = match decoder.read_u8()? {
272            0 => None,
273            1 => Some(decoder.read_string_u16()?),
274            value => return Err(invalid_data(format!("invalid path presence tag: {value}"))),
275        };
276        let first_block_page = decoder.read_u32()?;
277        let num_blocks = decoder.read_u32()?;
278        let zone_map_len = usize::try_from(decoder.read_u32()?)
279            .map_err(|_| invalid_data("zone map length exceeds usize range"))?;
280        let zone_map_bytes = decoder.read_exact(zone_map_len)?.to_vec();
281
282        let index_start_page = decoder.read_u32()?;
283        entries.push(CatalogEntry {
284            document_id,
285            path,
286            first_block_page,
287            num_blocks,
288            zone_map_bytes,
289            index_start_page,
290        });
291    }
292
293    let custom_tables = if decoder.remaining() >= 4 {
294        let count = usize::try_from(decoder.read_u32()?)
295            .map_err(|_| invalid_data("custom table count exceeds usize range"))?;
296        let mut tables = Vec::with_capacity(count);
297        for _ in 0..count {
298            let name = decoder.read_string_u16()?;
299            let num_cols = usize::from(decoder.read_u16()?);
300            let mut columns = Vec::with_capacity(num_cols);
301            for _ in 0..num_cols {
302                columns.push(decoder.read_string_u16()?);
303            }
304            let first_row_page = decoder.read_u32()?;
305            let last_row_page = decoder.read_u32()?;
306            let num_rows = decoder.read_u32()?;
307            tables.push(CustomTableEntry {
308                name,
309                columns,
310                first_row_page,
311                last_row_page,
312                num_rows,
313            });
314        }
315        tables
316    } else {
317        vec![]
318    };
319
320    let content_hashes = if decoder.remaining() >= 4 {
321        let count = usize::try_from(decoder.read_u32()?)
322            .map_err(|_| invalid_data("content hash count exceeds usize range"))?;
323        let mut hashes = Vec::with_capacity(count);
324        for _ in 0..count {
325            let document_id = decoder.read_u32()?;
326            let hash = decoder.read_u64()?;
327            hashes.push((document_id, hash));
328        }
329        hashes
330    } else {
331        vec![]
332    };
333
334    let views = if decoder.remaining() >= 4 {
335        let count = usize::try_from(decoder.read_u32()?)
336            .map_err(|_| invalid_data("view count exceeds usize range"))?;
337        let mut views = Vec::with_capacity(count);
338        for _ in 0..count {
339            let name = decoder.read_string_u16()?;
340            let sql = decoder.read_string_u16()?;
341            views.push(ViewEntry { name, sql });
342        }
343        views
344    } else {
345        vec![]
346    };
347
348    Ok((entries, custom_tables, content_hashes, views))
349}
350
351#[cfg(test)]
352mod tests {
353    use std::{
354        path::{Path, PathBuf},
355        sync::atomic::{AtomicU64, Ordering},
356    };
357
358    use super::*;
359    use crate::storage::page::{PAGE_TYPE_CATALOG, PageFile, make_page};
360
361    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
362
363    fn test_file_path(name: &str) -> PathBuf {
364        let unique = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
365        let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
366            .join("target")
367            .join("mq-db-catalog-tests");
368        std::fs::create_dir_all(&dir).unwrap();
369        dir.join(format!("{name}-{unique}.mq-db"))
370    }
371
372    /// Encodes catalog bytes in the format that predates content-hash
373    /// tracking (entries + custom tables, no trailing hash section) — this
374    /// mirrors what `.mq-db` files written by older `mq-db` versions look
375    /// like on disk, to confirm `read_catalog` still parses them.
376    fn serialize_catalog_pre_hash_format(entries: &[CatalogEntry]) -> Vec<u8> {
377        let mut out = Vec::new();
378        out.extend_from_slice(&as_u32(entries.len(), "entry count").to_le_bytes());
379        for entry in entries {
380            out.extend_from_slice(&entry.document_id.to_le_bytes());
381            match &entry.path {
382                Some(path) => {
383                    out.push(1);
384                    out.extend_from_slice(&as_u16(path.len(), "path len").to_le_bytes());
385                    out.extend_from_slice(path.as_bytes());
386                }
387                None => out.push(0),
388            }
389            out.extend_from_slice(&entry.first_block_page.to_le_bytes());
390            out.extend_from_slice(&entry.num_blocks.to_le_bytes());
391            out.extend_from_slice(
392                &as_u32(entry.zone_map_bytes.len(), "zone map len").to_le_bytes(),
393            );
394            out.extend_from_slice(&entry.zone_map_bytes);
395            out.extend_from_slice(&entry.index_start_page.to_le_bytes());
396        }
397        // Pre-migration files always had a (possibly empty) custom-table
398        // count here, but no bytes at all after it.
399        out.extend_from_slice(&as_u32(0, "table count").to_le_bytes());
400        out
401    }
402
403    #[test]
404    fn read_catalog_parses_pre_content_hash_format() {
405        let path = test_file_path("pre-hash-format");
406        let _ = std::fs::remove_file(&path);
407
408        let entry = CatalogEntry {
409            document_id: 7,
410            path: Some("doc.md".to_string()),
411            first_block_page: 3,
412            num_blocks: 5,
413            zone_map_bytes: vec![1, 2, 3],
414            index_start_page: 9,
415        };
416        let bytes = serialize_catalog_pre_hash_format(std::slice::from_ref(&entry));
417
418        let mut pf = PageFile::create(&path).unwrap();
419        let page = make_page(PAGE_TYPE_CATALOG, 1, 0, &bytes);
420        pf.append_page(&page).unwrap();
421        pf.sync_header().unwrap();
422        drop(pf);
423
424        let mut reopened = PageFile::open(&path).unwrap();
425        let (entries, custom_tables, content_hashes, views) = read_catalog(&mut reopened).unwrap();
426
427        assert_eq!(entries, vec![entry]);
428        assert!(custom_tables.is_empty());
429        assert!(content_hashes.is_empty());
430        assert!(views.is_empty());
431
432        let _ = std::fs::remove_file(&path);
433    }
434
435    #[test]
436    fn write_then_read_catalog_round_trips_content_hashes() {
437        let path = test_file_path("hash-round-trip");
438        let _ = std::fs::remove_file(&path);
439
440        let entry = CatalogEntry {
441            document_id: 1,
442            path: Some("a.md".to_string()),
443            first_block_page: 1,
444            num_blocks: 2,
445            zone_map_bytes: vec![],
446            index_start_page: 0,
447        };
448        let hashes = vec![(1u32, 0xDEAD_BEEFu64), (2u32, 42u64)];
449
450        let mut pf = PageFile::create(&path).unwrap();
451        // Reserve page 1 the same way `Storage::create` does.
452        let placeholder = make_page(PAGE_TYPE_CATALOG, 1, 0, &0u32.to_le_bytes());
453        pf.append_page(&placeholder).unwrap();
454        write_catalog(&mut pf, std::slice::from_ref(&entry), &[], &hashes, &[]).unwrap();
455        pf.sync_header().unwrap();
456        drop(pf);
457
458        let mut reopened = PageFile::open(&path).unwrap();
459        let (entries, custom_tables, read_hashes, views) = read_catalog(&mut reopened).unwrap();
460
461        assert_eq!(entries, vec![entry]);
462        assert!(custom_tables.is_empty());
463        assert!(views.is_empty());
464        let mut read_hashes = read_hashes;
465        read_hashes.sort();
466        let mut expected = hashes;
467        expected.sort();
468        assert_eq!(read_hashes, expected);
469
470        let _ = std::fs::remove_file(&path);
471    }
472
473    #[test]
474    fn write_then_read_catalog_round_trips_views() {
475        let path = test_file_path("views-round-trip");
476        let _ = std::fs::remove_file(&path);
477
478        let entry = CatalogEntry {
479            document_id: 1,
480            path: Some("a.md".to_string()),
481            first_block_page: 1,
482            num_blocks: 2,
483            zone_map_bytes: vec![],
484            index_start_page: 0,
485        };
486        let views = vec![
487            ViewEntry {
488                name: "headings".to_string(),
489                sql: "SELECT content FROM blocks WHERE block_type = 'heading'".to_string(),
490            },
491            ViewEntry {
492                name: "empty_view".to_string(),
493                sql: "SELECT 1".to_string(),
494            },
495        ];
496
497        let mut pf = PageFile::create(&path).unwrap();
498        let placeholder = make_page(PAGE_TYPE_CATALOG, 1, 0, &0u32.to_le_bytes());
499        pf.append_page(&placeholder).unwrap();
500        write_catalog(&mut pf, std::slice::from_ref(&entry), &[], &[], &views).unwrap();
501        pf.sync_header().unwrap();
502        drop(pf);
503
504        let mut reopened = PageFile::open(&path).unwrap();
505        let (entries, custom_tables, content_hashes, read_views) =
506            read_catalog(&mut reopened).unwrap();
507
508        assert_eq!(entries, vec![entry]);
509        assert!(custom_tables.is_empty());
510        assert!(content_hashes.is_empty());
511        assert_eq!(read_views, views);
512
513        let _ = std::fs::remove_file(&path);
514    }
515}