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 pub index_start_page: u32,
19}
20
21#[derive(Debug, Clone, PartialEq)]
28pub struct CustomTableEntry {
29 pub name: String,
30 pub columns: Vec<String>,
31 pub first_row_page: u32,
33 pub last_row_page: u32,
35 pub num_rows: u32,
36}
37
38#[derive(Debug, Clone, PartialEq)]
41pub struct ViewEntry {
42 pub name: String,
43 pub sql: String,
44}
45
46pub 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 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 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 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 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 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}