From bdb6ace05086c2416bacc69ef02bc6a81eacae45 Mon Sep 17 00:00:00 2001 From: Cyrus AI Date: Mon, 20 Oct 2025 05:16:49 +0000 Subject: [PATCH] feat: Implement SQLite infrastructure layer for persistent storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements a complete SQLite persistence layer for the Logseq application, providing durable storage for Page aggregates and their associated Block hierarchies. ## Changes ### Dependencies - Added `rusqlite` 0.32 with bundled SQLite and JSON support to Cargo.toml ### Infrastructure Layer - Created new `infrastructure/persistence` module with three components: - `schema.rs`: Database schema initialization and migrations - `sqlite_page_repository.rs`: SQLite implementation of PageRepository trait - `mod.rs`: Module exports ### Database Schema Implements a normalized relational schema with five tables: - `pages`: Stores page metadata (id, title, timestamps) - `blocks`: Stores block content and hierarchy (with parent_id foreign key) - `block_children`: Junction table maintaining child order - `urls`: Stores URLs extracted from block content - `page_references`: Stores page references and tags All tables include appropriate indexes and foreign key constraints with CASCADE delete. ### Repository Implementation - `SqlitePageRepository`: Full implementation of PageRepository trait - Supports in-memory and file-based databases - Atomic transactions for all write operations - Proper ordering of block insertion to satisfy foreign key constraints - Complete serialization/deserialization of Page aggregates including: - Hierarchical block structures - URLs and page references - Parent-child relationships ### Testing - Comprehensive test suite with 11 tests covering: - Schema initialization and idempotency - CRUD operations (save, find, delete, update) - Complex block hierarchies - URL and page reference preservation - Edge cases (non-existent records, etc.) All 172 tests pass successfully. ## Technical Notes - Block insertion uses two-pass strategy: first insert all blocks, then insert child relationships to avoid foreign key constraint violations - Blocks are sorted by indent level before insertion to ensure parents are created before children - Uses rusqlite's bundled feature for portability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 3 + backend/src/infrastructure/mod.rs | 1 + backend/src/infrastructure/persistence/mod.rs | 5 + .../src/infrastructure/persistence/schema.rs | 168 ++++++ .../persistence/sqlite_page_repository.rs | 542 ++++++++++++++++++ 5 files changed, 719 insertions(+) create mode 100644 backend/src/infrastructure/persistence/mod.rs create mode 100644 backend/src/infrastructure/persistence/schema.rs create mode 100644 backend/src/infrastructure/persistence/sqlite_page_repository.rs diff --git a/Cargo.toml b/Cargo.toml index 30a2ed6..0477fd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,5 +58,8 @@ regex = "1.10" # Date/time handling chrono = { version = "0.4", features = ["serde"] } +# SQLite persistence +rusqlite = { version = "0.32", features = ["bundled", "serde_json"] } + [dev-dependencies] tempfile = "3.14" diff --git a/backend/src/infrastructure/mod.rs b/backend/src/infrastructure/mod.rs index d3e428c..b0c0bc7 100644 --- a/backend/src/infrastructure/mod.rs +++ b/backend/src/infrastructure/mod.rs @@ -1,3 +1,4 @@ pub mod embeddings; pub mod file_system; pub mod parsers; +pub mod persistence; diff --git a/backend/src/infrastructure/persistence/mod.rs b/backend/src/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..46e035a --- /dev/null +++ b/backend/src/infrastructure/persistence/mod.rs @@ -0,0 +1,5 @@ +mod schema; +mod sqlite_page_repository; + +pub use schema::initialize_database; +pub use sqlite_page_repository::SqlitePageRepository; diff --git a/backend/src/infrastructure/persistence/schema.rs b/backend/src/infrastructure/persistence/schema.rs new file mode 100644 index 0000000..54cfffe --- /dev/null +++ b/backend/src/infrastructure/persistence/schema.rs @@ -0,0 +1,168 @@ +use rusqlite::{Connection, Result}; + +/// Initialize the SQLite database with the required schema. +/// This function is idempotent and can be safely called multiple times. +pub fn initialize_database(conn: &Connection) -> Result<()> { + // Enable foreign key constraints + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + + // Create pages table + conn.execute( + "CREATE TABLE IF NOT EXISTS pages ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_pages_title ON pages(title)", + [], + )?; + + // Create blocks table + conn.execute( + "CREATE TABLE IF NOT EXISTS blocks ( + id TEXT PRIMARY KEY, + page_id TEXT NOT NULL, + parent_id TEXT, + content TEXT NOT NULL, + indent_level INTEGER NOT NULL, + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES blocks(id) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_blocks_page ON blocks(page_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_blocks_parent ON blocks(parent_id)", + [], + )?; + + // Create block_children junction table for maintaining child order + conn.execute( + "CREATE TABLE IF NOT EXISTS block_children ( + parent_id TEXT NOT NULL, + child_id TEXT NOT NULL, + position INTEGER NOT NULL, + PRIMARY KEY (parent_id, child_id), + FOREIGN KEY (parent_id) REFERENCES blocks(id) ON DELETE CASCADE, + FOREIGN KEY (child_id) REFERENCES blocks(id) ON DELETE CASCADE + )", + [], + )?; + + // Create URLs table + conn.execute( + "CREATE TABLE IF NOT EXISTS urls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + block_id TEXT NOT NULL, + url TEXT NOT NULL, + FOREIGN KEY (block_id) REFERENCES blocks(id) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_urls_block ON urls(block_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_urls_url ON urls(url)", + [], + )?; + + // Create page_references table + conn.execute( + "CREATE TABLE IF NOT EXISTS page_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + block_id TEXT NOT NULL, + title TEXT NOT NULL, + is_tag INTEGER NOT NULL, + FOREIGN KEY (block_id) REFERENCES blocks(id) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_refs_block ON page_references(block_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_refs_title ON page_references(title)", + [], + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initialize_database() { + let conn = Connection::open_in_memory().unwrap(); + initialize_database(&conn).unwrap(); + + // Verify all tables exist + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert!(tables.contains(&"pages".to_string())); + assert!(tables.contains(&"blocks".to_string())); + assert!(tables.contains(&"block_children".to_string())); + assert!(tables.contains(&"urls".to_string())); + assert!(tables.contains(&"page_references".to_string())); + + // Verify foreign keys are enabled + let foreign_keys: i32 = conn + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(foreign_keys, 1); + } + + #[test] + fn test_initialize_database_idempotent() { + let conn = Connection::open_in_memory().unwrap(); + + // Call initialize multiple times + initialize_database(&conn).unwrap(); + initialize_database(&conn).unwrap(); + initialize_database(&conn).unwrap(); + + // Should not error and all our tables should exist + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + + // Verify all 5 user tables exist + assert_eq!(tables.len(), 5); + assert!(tables.contains(&"pages".to_string())); + assert!(tables.contains(&"blocks".to_string())); + assert!(tables.contains(&"block_children".to_string())); + assert!(tables.contains(&"urls".to_string())); + assert!(tables.contains(&"page_references".to_string())); + } +} diff --git a/backend/src/infrastructure/persistence/sqlite_page_repository.rs b/backend/src/infrastructure/persistence/sqlite_page_repository.rs new file mode 100644 index 0000000..33bdef7 --- /dev/null +++ b/backend/src/infrastructure/persistence/sqlite_page_repository.rs @@ -0,0 +1,542 @@ +use crate::application::repositories::PageRepository; +use crate::domain::aggregates::Page; +use crate::domain::base::{DomainError, Entity}; +use crate::domain::entities::Block; +use crate::domain::value_objects::{ + BlockContent, BlockId, IndentLevel, PageId, PageReference, Url, +}; +use crate::domain::DomainResult; +use rusqlite::{params, Connection, Result as SqliteResult}; +use std::collections::HashMap; + +/// SQLite-based implementation of the PageRepository trait +pub struct SqlitePageRepository { + conn: Connection, +} + +impl SqlitePageRepository { + /// Create a new SQLite repository with the given connection + pub fn new(conn: Connection) -> Self { + SqlitePageRepository { conn } + } + + /// Create a new in-memory SQLite repository (useful for testing) + pub fn new_in_memory() -> SqliteResult { + let conn = Connection::open_in_memory()?; + super::schema::initialize_database(&conn)?; + Ok(SqlitePageRepository { conn }) + } + + /// Create a new file-based SQLite repository + pub fn new_with_path(path: impl AsRef) -> SqliteResult { + let conn = Connection::open(path)?; + super::schema::initialize_database(&conn)?; + Ok(SqlitePageRepository { conn }) + } + + /// Save a page and all its blocks in a single transaction + fn save_page_transaction(&mut self, page: Page) -> SqliteResult<()> { + let tx = self.conn.transaction()?; + + // Insert or update page + tx.execute( + "INSERT OR REPLACE INTO pages (id, title, created_at, updated_at) + VALUES (?1, ?2, datetime('now'), datetime('now'))", + params![page.id().as_str(), page.title()], + )?; + + // Delete existing blocks for this page + tx.execute( + "DELETE FROM blocks WHERE page_id = ?1", + params![page.id().as_str()], + )?; + + // Collect all blocks into a Vec and sort by indent level + // This ensures parent blocks (lower indent) are inserted before children (higher indent) + // which satisfies the FOREIGN KEY constraint on parent_id + let mut blocks: Vec<&Block> = page.all_blocks().collect(); + blocks.sort_by_key(|b| b.indent_level().value()); + + // First pass: Insert all blocks (without child relationships yet) + for block in &blocks { + // Insert block + tx.execute( + "INSERT INTO blocks (id, page_id, parent_id, content, indent_level, position, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, 0, datetime('now'), datetime('now'))", + params![ + block.id().as_str(), + page.id().as_str(), + block.parent_id().map(|id| id.as_str()), + block.content().as_str(), + block.indent_level().value() as i64, + ], + )?; + + // Insert URLs + for url in block.urls() { + tx.execute( + "INSERT INTO urls (block_id, url) VALUES (?1, ?2)", + params![block.id().as_str(), url.as_str()], + )?; + } + + // Insert page references + for page_ref in block.page_references() { + tx.execute( + "INSERT INTO page_references (block_id, title, is_tag) + VALUES (?1, ?2, ?3)", + params![ + block.id().as_str(), + page_ref.title(), + if page_ref.is_tag() { 1 } else { 0 } + ], + )?; + } + } + + // Second pass: Insert block_children relationships + // Now all blocks exist, so foreign key constraints will be satisfied + for block in &blocks { + for (idx, child_id) in block.child_ids().iter().enumerate() { + tx.execute( + "INSERT INTO block_children (parent_id, child_id, position) + VALUES (?1, ?2, ?3)", + params![block.id().as_str(), child_id.as_str(), idx as i64], + )?; + } + } + + tx.commit()?; + Ok(()) + } + + /// Load a page with all its blocks and relationships + fn load_page(&self, page_id: &PageId) -> SqliteResult> { + // Load page metadata + let page_result: Result<(String, String), _> = self.conn.query_row( + "SELECT id, title FROM pages WHERE id = ?1", + params![page_id.as_str()], + |row| Ok((row.get(0)?, row.get(1)?)), + ); + + let (_, title) = match page_result { + Ok(data) => data, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e), + }; + + // Create page + let mut page = Page::new(page_id.clone(), title); + + // Load all blocks for this page + let mut stmt = self.conn.prepare( + "SELECT id, parent_id, content, indent_level + FROM blocks + WHERE page_id = ?1 + ORDER BY position", + )?; + + let blocks_data: Vec<(String, Option, String, i64)> = stmt + .query_map(params![page_id.as_str()], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::>>()?; + + // Load URLs for all blocks in this page + let mut url_map: HashMap> = HashMap::new(); + let mut url_stmt = self + .conn + .prepare("SELECT block_id, url FROM urls WHERE block_id IN (SELECT id FROM blocks WHERE page_id = ?1)")?; + + let urls_data: Vec<(String, String)> = url_stmt + .query_map(params![page_id.as_str()], |row| { + Ok((row.get(0)?, row.get(1)?)) + })? + .collect::>>()?; + + for (block_id_str, url_str) in urls_data { + let block_id = BlockId::new(block_id_str).map_err(|_| { + rusqlite::Error::InvalidQuery // Convert domain error to sqlite error + })?; + let url = Url::new(url_str).map_err(|_| rusqlite::Error::InvalidQuery)?; + + url_map.entry(block_id).or_insert_with(Vec::new).push(url); + } + + // Load page references for all blocks in this page + let mut ref_map: HashMap> = HashMap::new(); + let mut ref_stmt = self.conn.prepare( + "SELECT block_id, title, is_tag FROM page_references WHERE block_id IN (SELECT id FROM blocks WHERE page_id = ?1)", + )?; + + let refs_data: Vec<(String, String, i32)> = ref_stmt + .query_map(params![page_id.as_str()], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })? + .collect::>>()?; + + for (block_id_str, title, is_tag) in refs_data { + let block_id = BlockId::new(block_id_str).map_err(|_| rusqlite::Error::InvalidQuery)?; + let page_ref = if is_tag != 0 { + PageReference::from_tag(title) + } else { + PageReference::from_brackets(title) + } + .map_err(|_| rusqlite::Error::InvalidQuery)?; + + ref_map + .entry(block_id) + .or_insert_with(Vec::new) + .push(page_ref); + } + + // Load child relationships + let mut child_map: HashMap> = HashMap::new(); + let mut child_stmt = self.conn.prepare( + "SELECT parent_id, child_id FROM block_children + WHERE parent_id IN (SELECT id FROM blocks WHERE page_id = ?1) + ORDER BY position", + )?; + + let children_data: Vec<(String, String)> = child_stmt + .query_map(params![page_id.as_str()], |row| { + Ok((row.get(0)?, row.get(1)?)) + })? + .collect::>>()?; + + for (parent_id_str, child_id_str) in children_data { + let parent_id = + BlockId::new(parent_id_str).map_err(|_| rusqlite::Error::InvalidQuery)?; + let child_id = BlockId::new(child_id_str).map_err(|_| rusqlite::Error::InvalidQuery)?; + + child_map + .entry(parent_id) + .or_insert_with(Vec::new) + .push(child_id); + } + + // Build blocks in correct order (parents before children) + // We need to build root blocks first, then children + let mut blocks_to_add: Vec = Vec::new(); + + for (id_str, parent_id_opt, content_str, indent_level) in blocks_data { + let block_id = BlockId::new(id_str).map_err(|_| rusqlite::Error::InvalidQuery)?; + let content = BlockContent::new(content_str); + let indent = IndentLevel::new(indent_level as usize); + + let mut block = if let Some(parent_id_str) = parent_id_opt { + let parent_id = + BlockId::new(parent_id_str).map_err(|_| rusqlite::Error::InvalidQuery)?; + Block::new_child(block_id.clone(), content, parent_id, indent) + } else { + Block::new_root(block_id.clone(), content) + }; + + // Add URLs + if let Some(urls) = url_map.get(&block_id) { + for url in urls { + block.add_url(url.clone()); + } + } + + // Add page references + if let Some(refs) = ref_map.get(&block_id) { + for page_ref in refs { + block.add_page_reference(page_ref.clone()); + } + } + + blocks_to_add.push(block); + } + + // Add blocks to page (roots first, then children) + // Sort blocks so parents come before children + blocks_to_add.sort_by_key(|b| b.indent_level().value()); + + for block in blocks_to_add { + page.add_block(block) + .map_err(|_| rusqlite::Error::InvalidQuery)?; + } + + Ok(Some(page)) + } +} + +impl PageRepository for SqlitePageRepository { + fn save(&mut self, page: Page) -> DomainResult<()> { + self.save_page_transaction(page) + .map_err(|e| DomainError::InvalidOperation(format!("Database error: {}", e))) + } + + fn find_by_id(&self, id: &PageId) -> DomainResult> { + self.load_page(id) + .map_err(|e| DomainError::InvalidOperation(format!("Database error: {}", e))) + } + + fn find_by_title(&self, title: &str) -> DomainResult> { + // First, find the page ID by title + let page_id_result: Result = self.conn.query_row( + "SELECT id FROM pages WHERE title = ?1", + params![title], + |row| row.get(0), + ); + + let page_id_str = match page_id_result { + Ok(id) => id, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => { + return Err(DomainError::InvalidOperation(format!( + "Database error: {}", + e + ))) + } + }; + + let page_id = PageId::new(page_id_str)?; + self.find_by_id(&page_id) + } + + fn find_all(&self) -> DomainResult> { + // Get all page IDs + let mut stmt = self + .conn + .prepare("SELECT id FROM pages") + .map_err(|e| DomainError::InvalidOperation(format!("Database error: {}", e)))?; + + let page_ids: Vec = stmt + .query_map([], |row| row.get(0)) + .map_err(|e| DomainError::InvalidOperation(format!("Database error: {}", e)))? + .collect::>>() + .map_err(|e| DomainError::InvalidOperation(format!("Database error: {}", e)))?; + + // Load each page + let mut pages = Vec::new(); + for id_str in page_ids { + let page_id = PageId::new(id_str)?; + if let Some(page) = self.find_by_id(&page_id)? { + pages.push(page); + } + } + + Ok(pages) + } + + fn delete(&mut self, id: &PageId) -> DomainResult { + let rows_affected = self + .conn + .execute("DELETE FROM pages WHERE id = ?1", params![id.as_str()]) + .map_err(|e| DomainError::InvalidOperation(format!("Database error: {}", e)))?; + + Ok(rows_affected > 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::value_objects::IndentLevel; + + fn create_test_page() -> Page { + let page_id = PageId::new("test-page-1").unwrap(); + let mut page = Page::new(page_id, "Test Page".to_string()); + + // Add root block + let root_id = BlockId::new("block-1").unwrap(); + let mut root_block = Block::new_root(root_id.clone(), BlockContent::new("Root block")); + root_block.add_url(Url::new("https://example.com").unwrap()); + root_block.add_page_reference(PageReference::from_brackets("referenced-page").unwrap()); + page.add_block(root_block).unwrap(); + + // Add child block + let child_id = BlockId::new("block-2").unwrap(); + let mut child_block = Block::new_child( + child_id, + BlockContent::new("Child block"), + root_id, + IndentLevel::new(1), + ); + child_block.add_page_reference(PageReference::from_tag("tag").unwrap()); + page.add_block(child_block).unwrap(); + + page + } + + #[test] + fn test_save_and_find_by_id() { + let mut repo = SqlitePageRepository::new_in_memory().unwrap(); + let page = create_test_page(); + let page_id = page.id().clone(); + + // Save page + repo.save(page.clone()).unwrap(); + + // Load page + let loaded_page = repo.find_by_id(&page_id).unwrap().unwrap(); + + assert_eq!(loaded_page.id(), page.id()); + assert_eq!(loaded_page.title(), page.title()); + assert_eq!(loaded_page.root_blocks().len(), page.root_blocks().len()); + } + + #[test] + fn test_find_by_title() { + let mut repo = SqlitePageRepository::new_in_memory().unwrap(); + let page = create_test_page(); + let title = page.title().to_string(); + + repo.save(page.clone()).unwrap(); + + let loaded_page = repo.find_by_title(&title).unwrap().unwrap(); + assert_eq!(loaded_page.id(), page.id()); + assert_eq!(loaded_page.title(), title); + } + + #[test] + fn test_find_by_title_not_found() { + let repo = SqlitePageRepository::new_in_memory().unwrap(); + let result = repo.find_by_title("nonexistent").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_find_all() { + let mut repo = SqlitePageRepository::new_in_memory().unwrap(); + + // Create and save multiple pages + let page1 = create_test_page(); + let page2_id = PageId::new("test-page-2").unwrap(); + let page2 = Page::new(page2_id, "Test Page 2".to_string()); + + repo.save(page1).unwrap(); + repo.save(page2).unwrap(); + + // Load all pages + let pages = repo.find_all().unwrap(); + assert_eq!(pages.len(), 2); + } + + #[test] + fn test_delete() { + let mut repo = SqlitePageRepository::new_in_memory().unwrap(); + let page = create_test_page(); + let page_id = page.id().clone(); + + repo.save(page).unwrap(); + + // Delete page + let deleted = repo.delete(&page_id).unwrap(); + assert!(deleted); + + // Verify page is gone + let result = repo.find_by_id(&page_id).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_delete_nonexistent() { + let mut repo = SqlitePageRepository::new_in_memory().unwrap(); + let page_id = PageId::new("nonexistent").unwrap(); + + let deleted = repo.delete(&page_id).unwrap(); + assert!(!deleted); + } + + #[test] + fn test_update_page() { + let mut repo = SqlitePageRepository::new_in_memory().unwrap(); + let page = create_test_page(); + let page_id = page.id().clone(); + + // Save original page + repo.save(page).unwrap(); + + // Modify and save again + let mut updated_page = Page::new(page_id.clone(), "Updated Title".to_string()); + let new_block = Block::new_root( + BlockId::new("block-3").unwrap(), + BlockContent::new("New block"), + ); + updated_page.add_block(new_block).unwrap(); + + repo.save(updated_page.clone()).unwrap(); + + // Load and verify + let loaded_page = repo.find_by_id(&page_id).unwrap().unwrap(); + assert_eq!(loaded_page.title(), "Updated Title"); + assert_eq!(loaded_page.root_blocks().len(), 1); + } + + #[test] + fn test_block_hierarchy_preserved() { + let mut repo = SqlitePageRepository::new_in_memory().unwrap(); + + // Create page with deep hierarchy + let page_id = PageId::new("hierarchy-test").unwrap(); + let mut page = Page::new(page_id.clone(), "Hierarchy Test".to_string()); + + let root_id = BlockId::new("root").unwrap(); + let root = Block::new_root(root_id.clone(), BlockContent::new("Root")); + page.add_block(root).unwrap(); + + let child1_id = BlockId::new("child1").unwrap(); + let child1 = Block::new_child( + child1_id.clone(), + BlockContent::new("Child 1"), + root_id.clone(), + IndentLevel::new(1), + ); + page.add_block(child1).unwrap(); + + let child2_id = BlockId::new("child2").unwrap(); + let child2 = Block::new_child( + child2_id.clone(), + BlockContent::new("Child 2"), + child1_id.clone(), + IndentLevel::new(2), + ); + page.add_block(child2).unwrap(); + + // Save and load + repo.save(page).unwrap(); + let loaded_page = repo.find_by_id(&page_id).unwrap().unwrap(); + + // Verify hierarchy + assert_eq!(loaded_page.root_blocks().len(), 1); + let root = loaded_page.get_block(&root_id).unwrap(); + assert_eq!(root.child_ids().len(), 1); + + let child1 = loaded_page.get_block(&child1_id).unwrap(); + assert_eq!(child1.parent_id(), Some(&root_id)); + assert_eq!(child1.child_ids().len(), 1); + + let child2 = loaded_page.get_block(&child2_id).unwrap(); + assert_eq!(child2.parent_id(), Some(&child1_id)); + } + + #[test] + fn test_urls_and_references_preserved() { + let mut repo = SqlitePageRepository::new_in_memory().unwrap(); + let page = create_test_page(); + let page_id = page.id().clone(); + + repo.save(page).unwrap(); + let loaded_page = repo.find_by_id(&page_id).unwrap().unwrap(); + + // Check URLs + let root_block = &loaded_page.root_blocks()[0]; + assert_eq!(root_block.urls().len(), 1); + assert_eq!(root_block.urls()[0].as_str(), "https://example.com"); + + // Check page references + assert_eq!(root_block.page_references().len(), 1); + assert_eq!(root_block.page_references()[0].title(), "referenced-page"); + assert!(!root_block.page_references()[0].is_tag()); + + // Check child block tags + let child_id = BlockId::new("block-2").unwrap(); + let child_block = loaded_page.get_block(&child_id).unwrap(); + assert_eq!(child_block.page_references().len(), 1); + assert_eq!(child_block.page_references()[0].title(), "tag"); + assert!(child_block.page_references()[0].is_tag()); + } +}