1pub mod catalog;
2pub mod codec;
3pub mod page;
4
5use std::{collections::HashSet, path::Path};
6
7use crate::{
8 block::Block,
9 document::Document,
10 error::MqdbError,
11 storage::{
12 catalog::{
13 CatalogData, CatalogEntry, CustomTableEntry, ViewEntry, read_catalog, write_catalog,
14 },
15 codec::{decode_block, decode_table_rows, encode_block, encode_table_rows},
16 page::{
17 PAGE_BODY_SIZE, PAGE_HEADER_SIZE, PAGE_TYPE_BLOCK_DATA, PAGE_TYPE_CATALOG,
18 PAGE_TYPE_INDEX, PAGE_TYPE_OVERFLOW, PAGE_TYPE_TABLE_DATA, PageFile, make_page,
19 parse_page_header,
20 },
21 },
22};
23
24const TABLE_ROW_PAGE_CAPACITY: usize = PAGE_BODY_SIZE - 2;
27
28pub struct Storage {
29 page_file: PageFile,
30}
31
32fn invalid_data(message: impl Into<String>) -> MqdbError {
33 MqdbError::Storage(message.into())
34}
35
36impl Storage {
37 pub fn create(path: &Path) -> Result<Self, MqdbError> {
39 let mut page_file = PageFile::create(path)?;
40 let empty_catalog = 0u32.to_le_bytes();
41 let page = make_page(PAGE_TYPE_CATALOG, 1, 0, &empty_catalog);
42 let page_id = page_file.append_page(&page)?;
43 if page_id != 1 {
44 return Err(invalid_data(format!(
45 "expected catalog page id 1, found {page_id}"
46 )));
47 }
48 Ok(Self { page_file })
49 }
50
51 pub fn open(path: &Path) -> Result<Self, MqdbError> {
57 Ok(Self {
58 page_file: PageFile::open(path)?,
59 })
60 }
61
62 pub fn file_version(&self) -> u32 {
66 self.page_file.version
67 }
68
69 pub fn num_pages(&self) -> u32 {
71 self.page_file.num_pages
72 }
73
74 pub fn write_document(&mut self, doc: &Document) -> Result<u32, MqdbError> {
76 let mut bytes = Vec::new();
77 for block in &doc.blocks {
78 bytes.extend_from_slice(&encode_block(block));
79 }
80
81 let chunks: Vec<&[u8]> = if bytes.is_empty() {
82 vec![&[]]
83 } else {
84 bytes.chunks(PAGE_BODY_SIZE).collect()
85 };
86
87 let page_ids = self.reserve_page_ids(chunks.len())?;
88
89 for (index, chunk) in chunks.iter().enumerate() {
90 let page_id = page_ids[index];
91 let next_page = page_ids.get(index + 1).copied().unwrap_or(0);
92 let page_type = if index == 0 {
93 PAGE_TYPE_BLOCK_DATA
94 } else {
95 PAGE_TYPE_OVERFLOW
96 };
97 let page = make_page(page_type, page_id, next_page, chunk);
98 self.page_file.append_page(&page)?;
99 }
100
101 page_ids
102 .first()
103 .copied()
104 .ok_or_else(|| invalid_data("document page chain is empty"))
105 }
106
107 pub fn read_blocks(
109 &mut self,
110 first_page: u32,
111 num_blocks: u32,
112 ) -> Result<Vec<Block>, MqdbError> {
113 if num_blocks == 0 {
114 return Ok(Vec::new());
115 }
116
117 let mut bytes = Vec::new();
118 let mut page_id = first_page;
119 let mut visited = HashSet::new();
120 let mut first = true;
121
122 loop {
123 if !visited.insert(page_id) {
124 return Err(invalid_data("block page chain contains a cycle"));
125 }
126
127 let page = self.page_file.read_page(page_id)?;
128 let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
129 let expected_type = if first {
130 PAGE_TYPE_BLOCK_DATA
131 } else {
132 PAGE_TYPE_OVERFLOW
133 };
134 if page_type != expected_type {
135 return Err(invalid_data(format!(
136 "unexpected page type {page_type} in block chain; expected {expected_type}"
137 )));
138 }
139 if stored_page_id != page_id {
140 return Err(invalid_data(format!(
141 "block page header mismatch: expected {page_id}, found {stored_page_id}"
142 )));
143 }
144
145 bytes.extend_from_slice(&page[PAGE_HEADER_SIZE..]);
146
147 if next_page == 0 {
148 break;
149 }
150 page_id = next_page;
151 first = false;
152 }
153
154 let mut blocks = Vec::with_capacity(num_blocks as usize);
155 let mut offset = 0usize;
156 for _ in 0..num_blocks {
157 let (block, consumed) = decode_block(&bytes[offset..])?;
158 offset = offset
159 .checked_add(consumed)
160 .ok_or_else(|| invalid_data("block byte offset overflow"))?;
161 blocks.push(block);
162 }
163
164 Ok(blocks)
165 }
166
167 pub fn flush_catalog(
169 &mut self,
170 entries: &[CatalogEntry],
171 custom_tables: &[CustomTableEntry],
172 content_hashes: &[(u32, u64)],
173 views: &[ViewEntry],
174 ) -> Result<(), MqdbError> {
175 write_catalog(
176 &mut self.page_file,
177 entries,
178 custom_tables,
179 content_hashes,
180 views,
181 )?;
182 self.page_file.sync_header()
183 }
184
185 pub fn load_catalog(&mut self) -> Result<CatalogData, MqdbError> {
187 read_catalog(&mut self.page_file)
188 }
189
190 pub fn write_index(&mut self, bytes: &[u8]) -> Result<u32, MqdbError> {
192 let chunks: Vec<&[u8]> = if bytes.is_empty() {
193 vec![&[]]
194 } else {
195 bytes.chunks(PAGE_BODY_SIZE).collect()
196 };
197
198 let page_ids = self.reserve_page_ids(chunks.len())?;
199
200 for (i, chunk) in chunks.iter().enumerate() {
201 let page_id = page_ids[i];
202 let next_page = page_ids.get(i + 1).copied().unwrap_or(0);
203 let page_type = if i == 0 {
204 PAGE_TYPE_INDEX
205 } else {
206 PAGE_TYPE_OVERFLOW
207 };
208 let page = make_page(page_type, page_id, next_page, chunk);
209 self.page_file.append_page(&page)?;
210 }
211
212 page_ids
213 .first()
214 .copied()
215 .ok_or_else(|| invalid_data("empty index page chain"))
216 }
217
218 pub fn read_index_bytes(&mut self, first_page: u32) -> Result<Vec<u8>, MqdbError> {
220 let mut bytes = Vec::new();
221 let mut page_id = first_page;
222 let mut visited = HashSet::new();
223 let mut first = true;
224
225 loop {
226 if !visited.insert(page_id) {
227 return Err(invalid_data("index page chain contains a cycle"));
228 }
229
230 let page = self.page_file.read_page(page_id)?;
231 let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
232
233 let expected = if first {
234 PAGE_TYPE_INDEX
235 } else {
236 PAGE_TYPE_OVERFLOW
237 };
238 if page_type != expected {
239 return Err(invalid_data(format!(
240 "unexpected page type {page_type} in index chain; expected {expected}"
241 )));
242 }
243 if stored_page_id != page_id {
244 return Err(invalid_data(format!(
245 "index page header mismatch: expected {page_id}, found {stored_page_id}"
246 )));
247 }
248
249 bytes.extend_from_slice(&page[PAGE_HEADER_SIZE..]);
250
251 if next_page == 0 {
252 break;
253 }
254 page_id = next_page;
255 first = false;
256 }
257
258 Ok(bytes)
259 }
260
261 pub fn write_table_rows(&mut self, rows: &[Vec<String>]) -> Result<(u32, u32), MqdbError> {
265 self.write_table_row_chunks(rows, PAGE_TYPE_TABLE_DATA)
266 }
267
268 pub fn append_table_rows(
272 &mut self,
273 tail_page: u32,
274 rows: &[Vec<String>],
275 ) -> Result<u32, MqdbError> {
276 if rows.is_empty() {
277 return Ok(tail_page);
278 }
279
280 let (first_new, last_new) = self.write_table_row_chunks(rows, PAGE_TYPE_OVERFLOW)?;
284 self.relink_next(tail_page, first_new)?;
285 Ok(last_new)
286 }
287
288 fn write_table_row_chunks(
296 &mut self,
297 rows: &[Vec<String>],
298 head_page_type: u32,
299 ) -> Result<(u32, u32), MqdbError> {
300 if rows.is_empty() {
301 return Ok((0, 0));
302 }
303
304 let bytes = encode_table_rows(rows);
305 let chunks: Vec<&[u8]> = bytes.chunks(TABLE_ROW_PAGE_CAPACITY).collect();
306
307 let page_ids = self.reserve_page_ids(chunks.len())?;
308
309 for (index, chunk) in chunks.iter().enumerate() {
310 let page_id = page_ids[index];
311 let next_page = page_ids.get(index + 1).copied().unwrap_or(0);
312 let page_type = if index == 0 {
313 head_page_type
314 } else {
315 PAGE_TYPE_OVERFLOW
316 };
317 let mut body = Vec::with_capacity(2 + chunk.len());
318 body.extend_from_slice(&(chunk.len() as u16).to_le_bytes());
319 body.extend_from_slice(chunk);
320 let page = make_page(page_type, page_id, next_page, &body);
321 self.page_file.append_page(&page)?;
322 }
323
324 let first = *page_ids.first().expect("checked non-empty above");
325 let last = *page_ids.last().expect("checked non-empty above");
326 Ok((first, last))
327 }
328
329 fn reserve_page_ids(&self, count: usize) -> Result<Vec<u32>, MqdbError> {
337 let start = self.page_file.num_pages;
338 (0..count as u32)
339 .map(|i| {
340 start
341 .checked_add(i)
342 .ok_or_else(|| invalid_data("page count overflow"))
343 })
344 .collect()
345 }
346
347 fn relink_next(&mut self, page_id: u32, next_page: u32) -> Result<(), MqdbError> {
351 let page = self.page_file.read_page(page_id)?;
352 let (page_type, _, stored_page_id, _) = parse_page_header(&page);
353 let body = &page[PAGE_HEADER_SIZE..];
354 let relinked = make_page(page_type, stored_page_id, next_page, body);
355 self.page_file.write_page(page_id, &relinked)
356 }
357
358 pub fn read_table_rows(
360 &mut self,
361 first_page: u32,
362 num_rows: u32,
363 num_cols: usize,
364 ) -> Result<Vec<Vec<String>>, MqdbError> {
365 if first_page == 0 || num_rows == 0 {
366 return Ok(Vec::new());
367 }
368
369 let mut bytes = Vec::new();
370 let mut page_id = first_page;
371 let mut visited = HashSet::new();
372 let mut first = true;
373
374 loop {
375 if !visited.insert(page_id) {
376 return Err(invalid_data("table row page chain contains a cycle"));
377 }
378
379 let page = self.page_file.read_page(page_id)?;
380 let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
381 let expected_type = if first {
382 PAGE_TYPE_TABLE_DATA
383 } else {
384 PAGE_TYPE_OVERFLOW
385 };
386 if page_type != expected_type {
387 return Err(invalid_data(format!(
388 "unexpected page type {page_type} in table row chain; expected {expected_type}"
389 )));
390 }
391 if stored_page_id != page_id {
392 return Err(invalid_data(format!(
393 "table row page header mismatch: expected {page_id}, found {stored_page_id}"
394 )));
395 }
396
397 let body = &page[PAGE_HEADER_SIZE..];
398 let chunk_len = usize::from(u16::from_le_bytes([body[0], body[1]]));
399 let chunk_end = chunk_len
400 .checked_add(2)
401 .ok_or_else(|| invalid_data("table row page chunk length overflow"))?;
402 if chunk_end > body.len() {
403 return Err(invalid_data("table row page chunk length out of bounds"));
404 }
405 bytes.extend_from_slice(&body[2..chunk_end]);
406
407 if next_page == 0 {
408 break;
409 }
410 page_id = next_page;
411 first = false;
412 }
413
414 decode_table_rows(&bytes, num_rows as usize, num_cols)
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use std::{
421 path::{Path, PathBuf},
422 sync::atomic::{AtomicU64, Ordering},
423 time::{SystemTime, UNIX_EPOCH},
424 };
425
426 use super::*;
427 use rstest::rstest;
428
429 use crate::{
430 DocumentStore,
431 block::{BlockType, Properties, PropertyValue, Span},
432 document::{Document, ZoneMaps},
433 storage::{
434 catalog::CatalogEntry,
435 codec::{decode_block, decode_zone_map, encode_block, encode_zone_map},
436 },
437 };
438
439 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
440
441 fn test_file_path(name: &str) -> PathBuf {
442 let unique = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
443 let timestamp = SystemTime::now()
444 .duration_since(UNIX_EPOCH)
445 .expect("system time before UNIX_EPOCH")
446 .as_nanos();
447 let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
448 .join("target")
449 .join("mq-db-storage-tests");
450 std::fs::create_dir_all(&dir).unwrap();
451 dir.join(format!("{name}-{timestamp}-{unique}.mq-db"))
452 }
453
454 fn cleanup(path: &Path) {
455 let _ = std::fs::remove_file(path);
456 let tmp_path = PathBuf::from(format!("{}.tmp", path.to_string_lossy()));
457 let _ = std::fs::remove_file(tmp_path);
458 }
459
460 fn sample_block(block_type: BlockType, id: u32) -> Block {
461 let mut properties = Properties::new();
462 properties.set("name", format!("block-{id}"));
463 properties.set("count", i64::from(id));
464 properties.set("score", PropertyValue::Float(1.5f64 + f64::from(id)));
465 properties.set("flag", id.is_multiple_of(2));
466 properties.set(
467 "items",
468 PropertyValue::Array(vec![
469 PropertyValue::Null,
470 PropertyValue::String("value".to_string()),
471 PropertyValue::Int(-3),
472 PropertyValue::Float(2.25),
473 PropertyValue::Bool(true),
474 PropertyValue::Array(vec![PropertyValue::String("nested".to_string())]),
475 ]),
476 );
477
478 Block {
479 id,
480 document_id: 7,
481 block_type,
482 content: format!("content-{id}"),
483 span: Some(Span {
484 start_line: 1,
485 start_col: 2,
486 end_line: 3,
487 end_col: 4,
488 }),
489 pre: id * 2,
490 post: id * 2 + 1,
491 properties,
492 }
493 }
494
495 #[test]
496 fn block_codec_round_trip_all_block_types() {
497 let block_types = [
498 BlockType::Heading,
499 BlockType::Paragraph,
500 BlockType::Code,
501 BlockType::List,
502 BlockType::TableCell,
503 BlockType::TableRow,
504 BlockType::TableAlign,
505 BlockType::Blockquote,
506 BlockType::HorizontalRule,
507 BlockType::Html,
508 BlockType::Yaml,
509 BlockType::Toml,
510 BlockType::Math,
511 BlockType::Definition,
512 BlockType::Footnote,
513 ];
514
515 for (index, block_type) in block_types.into_iter().enumerate() {
516 let block = sample_block(block_type, index as u32 + 1);
517 let encoded = encode_block(&block);
518 let (decoded, consumed) = decode_block(&encoded).unwrap();
519 assert_eq!(consumed, encoded.len());
520 assert_eq!(decoded, block);
521 }
522 }
523
524 #[test]
525 fn zone_map_codec_round_trip() {
526 let mut zone_maps = ZoneMaps {
527 max_heading_depth: 4,
528 heading_slugs: ["intro".to_string(), "usage".to_string()]
529 .into_iter()
530 .collect(),
531 heading_contents: ["Intro".to_string(), "Usage".to_string()]
532 .into_iter()
533 .collect(),
534 code_languages: ["rust".to_string(), "python".to_string()]
535 .into_iter()
536 .collect(),
537 frontmatter_keys: ["title".to_string(), "tags".to_string()]
538 .into_iter()
539 .collect(),
540 title: Some("Storage Spec".to_string()),
541 tags: vec!["db".to_string(), "markdown".to_string()],
542 };
543 let encoded = encode_zone_map(&zone_maps);
544 let decoded = decode_zone_map(&encoded).unwrap();
545 assert_eq!(decoded, zone_maps);
546
547 zone_maps.title = None;
548 let encoded_without_title = encode_zone_map(&zone_maps);
549 let decoded_without_title = decode_zone_map(&encoded_without_title).unwrap();
550 assert_eq!(decoded_without_title, zone_maps);
551 }
552
553 #[test]
554 fn storage_round_trip_multi_page_document() {
555 let path = test_file_path("multi-page");
556 cleanup(&path);
557
558 let blocks: Vec<Block> = (0..32)
559 .map(|id| {
560 let mut block = sample_block(BlockType::Paragraph, id + 1);
561 block.content = "x".repeat(PAGE_BODY_SIZE / 2);
562 block.pre = id * 2;
563 block.post = id * 2 + 1;
564 block
565 })
566 .collect();
567 let document = Document::new(1, None, blocks.clone());
568
569 let mut storage = Storage::create(&path).unwrap();
570 let first_page = storage.write_document(&document).unwrap();
571 let catalog_entry = CatalogEntry {
572 document_id: document.id,
573 path: None,
574 first_block_page: first_page,
575 num_blocks: document.blocks.len() as u32,
576 zone_map_bytes: encode_zone_map(&document.zone_maps),
577 index_start_page: 0,
578 };
579 storage
580 .flush_catalog(&[catalog_entry], &[], &[], &[])
581 .unwrap();
582 drop(storage);
583
584 let mut reopened = Storage::open(&path).unwrap();
585 let (catalog, _, _, _) = reopened.load_catalog().unwrap();
586 assert_eq!(catalog.len(), 1);
587 assert_eq!(
588 decode_zone_map(&catalog[0].zone_map_bytes).unwrap(),
589 document.zone_maps
590 );
591 let decoded_blocks = reopened
592 .read_blocks(first_page, document.blocks.len() as u32)
593 .unwrap();
594 assert_eq!(decoded_blocks, blocks);
595
596 cleanup(&path);
597 }
598
599 #[test]
600 fn document_store_save_load_round_trip() {
601 let path = test_file_path("store-save-load");
602 cleanup(&path);
603
604 let mut store = DocumentStore::new();
605 store
606 .add_str(
607 "---\ntitle: Demo\ntags: [db, rust]\n---\n# Intro\n\nParagraph\n\n```rust\nfn main() {}\n```\n",
608 )
609 .unwrap();
610 store
611 .add_str("## Usage\n\n- item one\n- item two\n")
612 .unwrap();
613
614 store.save(&path).unwrap();
615 let loaded = DocumentStore::load(&path).unwrap();
616
617 assert_eq!(loaded.len(), store.len());
618 for (l, s) in loaded.documents().iter().zip(store.documents().iter()) {
621 assert_eq!(l.id, s.id);
622 assert_eq!(l.blocks, s.blocks);
623 assert_eq!(l.zone_maps, s.zone_maps);
624 }
625
626 cleanup(&path);
627 }
628
629 #[test]
630 fn persisted_index_round_trip() {
631 use crate::{SqlEngine, indexes::DocumentIndex};
632
633 let path = test_file_path("index-round-trip");
634 cleanup(&path);
635
636 let mut store = DocumentStore::new();
637 store
638 .add_str("# Hello\n\n## Arch\n\nDetails\n\n```rust\ncode\n```\n")
639 .unwrap();
640 store.add_str("## Usage\n\n- item\n").unwrap();
641 store.save(&path).unwrap();
642
643 let mut opened = DocumentStore::open(&path).unwrap();
645 assert!(
646 opened.documents()[0].blocks.is_empty(),
647 "blocks not loaded yet"
648 );
649
650 opened.load_all_blocks().unwrap();
652 opened.load_all_indexes().unwrap();
653
654 assert!(!opened.documents()[0].blocks.is_empty(), "blocks loaded");
655 assert!(opened.get_doc_index(0).is_some(), "index cached");
656
657 for (i, doc) in opened.documents().iter().enumerate() {
659 let from_file = opened.get_doc_index(i).unwrap().clone();
660 let from_blocks = DocumentIndex::build(&doc.blocks);
661 assert_eq!(
662 from_file.to_bytes(),
663 from_blocks.to_bytes(),
664 "index mismatch for doc {i}"
665 );
666 }
667
668 let engine = SqlEngine::new(&opened).unwrap();
670 let out = engine.execute("SELECT count(*) FROM blocks").unwrap();
671 assert!(!out.rows.is_empty());
672
673 cleanup(&path);
674 }
675
676 #[test]
677 fn save_then_open_round_trips_term_index_for_match_and_score() {
678 use crate::SqlEngine;
679
680 let path = test_file_path("term-index-round-trip");
681 cleanup(&path);
682
683 let mut store = DocumentStore::new();
684 store
685 .add_str("# Doc\n\nThe quick brown fox jumps over the lazy dog\n")
686 .unwrap();
687 store.save(&path).unwrap();
688
689 let mut opened = DocumentStore::open(&path).unwrap();
690 opened.load_all_blocks().unwrap();
691 opened.load_all_indexes().unwrap();
692
693 let engine = SqlEngine::new(&opened).unwrap();
694 let out = engine
695 .execute("SELECT content FROM blocks WHERE match(content, 'fox dog')")
696 .unwrap();
697 assert_eq!(out.rows.len(), 1);
698
699 cleanup(&path);
700 }
701
702 fn patch_version(path: &Path, version: u32) {
706 use crate::storage::page::{PAGE_HEADER_SIZE, PAGE_SIZE, compute_checksum};
707
708 let mut bytes = std::fs::read(path).unwrap();
709 let version_offset = PAGE_HEADER_SIZE + 4;
710 bytes[version_offset..version_offset + 4].copy_from_slice(&version.to_le_bytes());
711
712 let mut page = [0u8; PAGE_SIZE];
713 page.copy_from_slice(&bytes[0..PAGE_SIZE]);
714 let checksum = compute_checksum(&page);
715 bytes[4..8].copy_from_slice(&checksum.to_le_bytes());
716
717 std::fs::write(path, &bytes).unwrap();
718 }
719
720 #[test]
721 fn opening_unrecognised_version_file_fails_with_clear_error() {
722 let path = test_file_path("unrecognised-version-header");
723 cleanup(&path);
724
725 let mut store = DocumentStore::new();
726 store.add_str("# Hello\n\nBody\n").unwrap();
727 store.save(&path).unwrap();
728 patch_version(&path, 2);
731
732 let err = DocumentStore::load(&path)
733 .err()
734 .expect("expected version rejection");
735 assert!(err.to_string().contains("unsupported file version"));
736
737 cleanup(&path);
738 }
739
740 #[test]
741 fn open_rejects_legacy_version_and_points_to_migrate() {
742 let path = test_file_path("legacy-version-open");
743 cleanup(&path);
744
745 let mut store = DocumentStore::new();
746 store.add_str("# Hello\n\nBody\n").unwrap();
747 store.save(&path).unwrap();
748 patch_version(&path, 4);
749
750 let err = DocumentStore::open(&path)
751 .err()
752 .expect("open() must not silently mix legacy and current index bytes");
753 assert!(err.to_string().contains("migrate"));
754
755 cleanup(&path);
756 }
757
758 #[test]
759 fn load_reads_legacy_version_file_and_rebuilds_term_index() {
760 use crate::SqlEngine;
761
762 let path = test_file_path("legacy-version-load");
763 cleanup(&path);
764
765 let mut store = DocumentStore::new();
766 store
767 .add_str("# Doc\n\nThe quick brown fox jumps over the lazy dog\n")
768 .unwrap();
769 store.save(&path).unwrap();
770 patch_version(&path, 4);
771
772 assert_eq!(DocumentStore::file_version(&path).unwrap(), 4);
773
774 let mut opened = DocumentStore::load(&path).unwrap();
778 opened.load_all_indexes().unwrap();
779 let engine = SqlEngine::new(&opened).unwrap();
780 let out = engine
781 .execute("SELECT content FROM blocks WHERE match(content, 'fox dog')")
782 .unwrap();
783 assert_eq!(out.rows.len(), 1);
784
785 cleanup(&path);
786 }
787
788 #[test]
789 fn migrate_rewrites_legacy_file_to_current_version() {
790 let path = test_file_path("legacy-version-migrate");
791 cleanup(&path);
792
793 let mut store = DocumentStore::new();
794 store
795 .add_str("# Doc\n\nThe quick brown fox jumps over the lazy dog\n")
796 .unwrap();
797 store.save(&path).unwrap();
798 patch_version(&path, 4);
799
800 let old_version = DocumentStore::migrate(&path).unwrap();
801 assert_eq!(old_version, 4);
802 assert_eq!(
803 DocumentStore::file_version(&path).unwrap(),
804 crate::storage::page::FILE_VERSION
805 );
806
807 DocumentStore::open(&path).unwrap();
810
811 assert_eq!(
814 DocumentStore::migrate(&path).unwrap(),
815 crate::storage::page::FILE_VERSION
816 );
817
818 cleanup(&path);
819 }
820
821 #[test]
822 fn persisted_index_round_trip_large_block_content() {
823 use crate::indexes::DocumentIndex;
824
825 let path = test_file_path("index-round-trip-large-block");
828 cleanup(&path);
829
830 let big_code = "x".repeat(70_000);
831 let content = format!("# Title\n\n```text\n{big_code}\n```\n");
832
833 let mut store = DocumentStore::new();
834 store.add_str(&content).unwrap();
835 store.save(&path).unwrap();
836
837 let mut opened = DocumentStore::open(&path).unwrap();
838 opened.load_all_blocks().unwrap();
839 opened.load_all_indexes().unwrap();
840
841 let doc = &opened.documents()[0];
842 let from_file = opened.get_doc_index(0).unwrap().clone();
843 let from_blocks = DocumentIndex::build(&doc.blocks);
844 assert_eq!(from_file.to_bytes(), from_blocks.to_bytes());
845
846 cleanup(&path);
847 }
848
849 #[rstest]
850 #[case(BlockType::Heading)]
851 #[case(BlockType::Paragraph)]
852 #[case(BlockType::Code)]
853 #[case(BlockType::List)]
854 #[case(BlockType::TableCell)]
855 #[case(BlockType::TableRow)]
856 #[case(BlockType::TableAlign)]
857 #[case(BlockType::Blockquote)]
858 #[case(BlockType::HorizontalRule)]
859 #[case(BlockType::Html)]
860 #[case(BlockType::Yaml)]
861 #[case(BlockType::Toml)]
862 #[case(BlockType::Math)]
863 #[case(BlockType::Definition)]
864 #[case(BlockType::Footnote)]
865 fn block_codec_round_trip_param(#[case] block_type: BlockType) {
866 let block = sample_block(block_type, 42);
867 let encoded = encode_block(&block);
868 let (decoded, consumed) = decode_block(&encoded).unwrap();
869 assert_eq!(consumed, encoded.len());
870 assert_eq!(decoded, block);
871 }
872
873 #[test]
874 fn table_row_chain_round_trip_across_multiple_appends() {
875 let path = test_file_path("table-row-chain-append");
882 cleanup(&path);
883
884 let mut storage = Storage::create(&path).unwrap();
885 storage.flush_catalog(&[], &[], &[], &[]).unwrap();
886
887 let batch1 = vec![
888 vec!["1".to_string(), "a".to_string()],
889 vec!["2".to_string(), "b".to_string()],
890 ];
891 let batch2 = vec![vec!["3".to_string(), "c".to_string()]];
892 let batch3 = vec![
893 vec!["4".to_string(), "d".to_string()],
894 vec!["5".to_string(), "e".to_string()],
895 ];
896
897 let (first_page, last_page) = storage.write_table_rows(&batch1).unwrap();
898 let last_page = storage.append_table_rows(last_page, &batch2).unwrap();
899 let last_page = storage.append_table_rows(last_page, &batch3).unwrap();
900 assert_ne!(last_page, 0);
901
902 let all_rows = storage.read_table_rows(first_page, 5, 2).unwrap();
903 let expected: Vec<Vec<String>> = batch1.into_iter().chain(batch2).chain(batch3).collect();
904 assert_eq!(all_rows, expected);
905
906 let big_batch: Vec<Vec<String>> = (0..10)
909 .map(|i| vec![i.to_string(), "x".repeat(PAGE_BODY_SIZE)])
910 .collect();
911 let last_page = storage.append_table_rows(last_page, &big_batch).unwrap();
912 assert_ne!(last_page, 0);
913
914 let all_rows = storage.read_table_rows(first_page, 15, 2).unwrap();
915 let expected: Vec<Vec<String>> = expected.into_iter().chain(big_batch).collect();
916 assert_eq!(all_rows, expected);
917
918 cleanup(&path);
919 }
920
921 #[test]
922 fn custom_table_round_trip() {
923 let path = test_file_path("custom-table-round-trip");
924 cleanup(&path);
925
926 let mut store = DocumentStore::new();
927 store.add_str("# Hello\n\nWorld\n").unwrap();
928 store.save(&path).unwrap();
929
930 let mut opened = DocumentStore::open(&path).unwrap();
932 opened.load_all_blocks().unwrap();
933 opened.load_all_indexes().unwrap();
934 let engine = crate::SqlEngine::new(&opened).unwrap();
935 engine
936 .execute("CREATE TABLE notes (id TEXT, body TEXT)")
937 .unwrap();
938 engine
939 .execute("INSERT INTO notes VALUES ('1', 'hello')")
940 .unwrap();
941 engine
942 .execute("INSERT INTO notes VALUES ('2', 'world')")
943 .unwrap();
944 drop(engine);
945 drop(opened);
946
947 let mut reopened = DocumentStore::open(&path).unwrap();
949 reopened.load_all_blocks().unwrap();
950 reopened.load_all_indexes().unwrap();
951 let engine2 = crate::SqlEngine::new(&reopened).unwrap();
952 let out = engine2
953 .execute("SELECT body FROM notes WHERE id = '1'")
954 .unwrap();
955 assert_eq!(out.rows.len(), 1);
956 assert_eq!(out.rows[0][0], "hello");
957 let all = engine2.execute("SELECT * FROM notes").unwrap();
958 assert_eq!(all.rows.len(), 2);
959
960 cleanup(&path);
961 }
962
963 #[rstest]
964 #[case(Some("My Title"))]
965 #[case(None)]
966 #[case(Some("title with spaces and unicode: こんにちは"))]
967 fn zone_map_title_round_trip_param(#[case] title: Option<&str>) {
968 let zone_maps = ZoneMaps {
969 max_heading_depth: 3,
970 heading_slugs: ["intro", "usage"].iter().map(|s| s.to_string()).collect(),
971 heading_contents: ["Intro", "Usage"].iter().map(|s| s.to_string()).collect(),
972 code_languages: ["rust", "python"].iter().map(|s| s.to_string()).collect(),
973 frontmatter_keys: ["title", "tags"].iter().map(|s| s.to_string()).collect(),
974 title: title.map(|s| s.to_string()),
975 tags: vec!["db".to_string(), "markdown".to_string()],
976 };
977 let encoded = encode_zone_map(&zone_maps);
978 let decoded = decode_zone_map(&encoded).unwrap();
979 assert_eq!(decoded, zone_maps);
980 }
981}