//! SQLCipher database initializer and connection manager. //! //! Provides [`Database`], the primary entry-point for encrypted local storage //! in ChronoGrid P2P. Every database is encrypted at rest using SQLCipher 4.x //! (AES-256-CBC) as mandated by `docs/ARCHITECTURE/02-security-and-crypto.md`. //! //! ## Key Handling //! //! The 32-byte master key produced by `chronogrid_crypto::kdf::derive_master_key` //! is passed as a **raw hex key** via `PRAGMA key = "x'…'"`, which instructs //! SQLCipher to use the bytes directly as the encryption key, bypassing its //! internal PBKDF2 derivation (the Argon2id KDF already provides equivalent //! or stronger protection). //! //! ## Schema //! //! The canonical schema is embedded at compile time from //! `docs/API_CONTRACTS/schema.sql` via [`include_str!`], ensuring this crate //! always stays in sync with the single source of truth. use std::path::Path; use rusqlite::Connection; use crate::error::{CoreError, Result}; /// Length of the raw encryption key expected by SQLCipher (256-bit / 32 bytes). pub const KEY_LEN: usize = 32; /// Canonical schema SQL, embedded at compile-time from the single source of truth. const SCHEMA_SQL: &str = include_str!("../../../docs/API_CONTRACTS/schema.sql"); /// Expected tables that must exist after schema initialization. /// /// Includes the 7 original Phase 1 tables plus the 6 Phase 2 extension tables /// added in Task 2.1. The total is 13 tables as specified in the Phase 2 /// completion criteria. const EXPECTED_TABLES: &[&str] = &[ // Phase 1 — original tables "namespaces", "projects", "tasks", "notes", "task_note_links", "ingestion_logs", "crdt_docs", // Phase 2 — extension tables (Task 2.1) "time_blocks", "attachments", "calendar_feeds", "node_peers", "automation_rules", "ingestion_configs", ]; // ── Database struct ────────────────────────────────────────────────────────── /// An encrypted SQLCipher database connection with the ChronoGrid schema applied. /// /// Wraps a [`rusqlite::Connection`] that has been keyed with a 256-bit raw key /// and initialized with all required tables and indexes. pub struct Database { conn: Connection, } impl std::fmt::Debug for Database { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Database") .field("conn", &"") .finish() } } impl Database { /// Opens (or creates) an encrypted database file at `path`. /// /// * Applies the raw 256-bit key via `PRAGMA key`. /// * Enables foreign-key enforcement. /// * Runs the canonical schema DDL (idempotent `CREATE … IF NOT EXISTS`). /// /// # Errors /// /// Returns [`CoreError::InvalidKeyLength`] if `key` is not exactly 32 bytes. /// Returns [`CoreError::Database`] on connection or PRAGMA failures. /// Returns [`CoreError::SchemaInit`] if any expected table is missing after DDL. pub fn open(path: impl AsRef, key: &[u8]) -> Result { if key.len() != KEY_LEN { return Err(CoreError::InvalidKeyLength(key.len())); } let conn = Connection::open(path)?; Self::configure_and_init(conn, key) } /// Opens an **in-memory** encrypted database (useful for testing). /// /// Semantics are identical to [`Database::open`] except no file is created. pub fn open_in_memory(key: &[u8]) -> Result { if key.len() != KEY_LEN { return Err(CoreError::InvalidKeyLength(key.len())); } let conn = Connection::open_in_memory()?; Self::configure_and_init(conn, key) } /// Returns a shared reference to the underlying [`rusqlite::Connection`]. #[inline] pub fn connection(&self) -> &Connection { &self.conn } /// Returns a mutable reference to the underlying [`rusqlite::Connection`]. #[inline] pub fn connection_mut(&mut self) -> &mut Connection { &mut self.conn } // ── CRDT document persistence ──────────────────────────────────────── /// Saves (upserts) an Automerge CRDT document's serialized bytes into the /// `crdt_docs` table. /// /// If a row for `entity_id` already exists it is replaced. pub fn save_crdt_doc( &self, entity_id: &str, entity_type: &str, doc_bytes: &[u8], updated_at: u64, ) -> Result<()> { self.conn.execute( "INSERT OR REPLACE INTO crdt_docs (entity_id, entity_type, doc_bytes, updated_at) VALUES (?1, ?2, ?3, ?4);", rusqlite::params![entity_id, entity_type, doc_bytes, updated_at as i64], )?; Ok(()) } /// Loads the serialized Automerge CRDT document bytes for a given entity. /// /// Returns `None` if no CRDT document has been stored for `entity_id`. pub fn load_crdt_doc(&self, entity_id: &str) -> Result>> { let mut stmt = self .conn .prepare("SELECT doc_bytes FROM crdt_docs WHERE entity_id = ?1;")?; let result = stmt.query_row([entity_id], |row| row.get::<_, Vec>(0)); match result { Ok(bytes) => Ok(Some(bytes)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(CoreError::Database(e)), } } /// Deletes the CRDT document for a given entity. /// /// Returns `true` if a row was deleted, `false` if none existed. pub fn delete_crdt_doc(&self, entity_id: &str) -> Result { let affected = self.conn.execute( "DELETE FROM crdt_docs WHERE entity_id = ?1;", [entity_id], )?; Ok(affected > 0) } // ── Private helpers ────────────────────────────────────────────────── /// Applies encryption key, PRAGMAs, schema DDL, and validates table presence. fn configure_and_init(conn: Connection, key: &[u8]) -> Result { // 1. Apply the raw hex key to encrypt / decrypt the database. let hex_key = bytes_to_hex(key); conn.execute_batch(&format!("PRAGMA key = \"x'{hex_key}'\";"))?; // 2. Enforce SQLCipher 4 compatibility defaults. conn.execute_batch( "PRAGMA cipher_compatibility = 4; PRAGMA foreign_keys = ON;", )?; // 3. Quick-check: verify the database is actually readable with this key. // `PRAGMA cipher_integrity_check` is expensive; a lightweight SELECT on // sqlite_master suffices to prove the key is correct. conn.query_row( "SELECT count(*) FROM sqlite_master;", [], |row| row.get::<_, i64>(0), ) .map_err(|e| { CoreError::SchemaInit(format!( "database key verification failed (wrong key or corrupt file): {e}" )) })?; // 4. Apply the canonical schema (all DDL uses IF NOT EXISTS). conn.execute_batch(SCHEMA_SQL)?; // 5. Validate that every expected table was created. Self::validate_schema(&conn)?; Ok(Self { conn }) } /// Checks that all tables declared in `EXPECTED_TABLES` exist in `sqlite_master`. fn validate_schema(conn: &Connection) -> Result<()> { let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type = 'table';")?; let existing: Vec = stmt .query_map([], |row| row.get(0))? .collect::, _>>()?; for &table in EXPECTED_TABLES { if !existing.iter().any(|t| t == table) { return Err(CoreError::SchemaInit(format!( "expected table '{table}' was not created" ))); } } Ok(()) } } // ── Utility ────────────────────────────────────────────────────────────────── /// Converts a byte slice to a lowercase hex string (no allocations beyond the /// output `String` itself). fn bytes_to_hex(bytes: &[u8]) -> String { let mut hex = String::with_capacity(bytes.len() * 2); for &b in bytes { use std::fmt::Write; let _ = write!(hex, "{b:02x}"); } hex } // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; /// A deterministic 32-byte key used exclusively in tests. const TEST_KEY: [u8; KEY_LEN] = [0xAA; KEY_LEN]; // ── open_in_memory ─────────────────────────────────────────────────── #[test] fn open_in_memory_creates_all_tables() { let db = Database::open_in_memory(&TEST_KEY).expect("open_in_memory should succeed"); for &table in EXPECTED_TABLES { let exists: bool = db .connection() .query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1);", [table], |row| row.get(0), ) .unwrap(); assert!(exists, "table '{table}' must exist after initialization"); } } #[test] fn open_in_memory_creates_indexes() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let expected_indexes = [ // Phase 1 indexes "idx_tasks_namespace", "idx_tasks_eisenhower", "idx_tasks_due_date", "idx_projects_parent", "idx_crdt_docs_type", // Phase 2 indexes (Task 2.1) "idx_time_blocks_namespace", "idx_time_blocks_task", "idx_time_blocks_start", "idx_attachments_note", "idx_attachments_ingestion", "idx_calendar_feeds_namespace", "idx_node_peers_swarm", "idx_node_peers_last_seen", "idx_automation_rules_namespace", "idx_automation_rules_trigger", "idx_ingestion_configs_namespace", "idx_ingestion_configs_type", ]; for &idx in &expected_indexes { let exists: bool = db .connection() .query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='index' AND name=?1);", [idx], |row| row.get(0), ) .unwrap(); assert!(exists, "index '{idx}' must exist after initialization"); } } /// Verifies the total table count matches the Phase 2 completion criterion /// of exactly 13 tables (7 original + 6 Phase 2 extension tables). #[test] fn schema_has_exactly_13_tables() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let count: i64 = db .connection() .query_row( "SELECT count(*) FROM sqlite_master WHERE type='table';", [], |r| r.get(0), ) .unwrap(); assert_eq!(count, 13, "schema must contain exactly 13 tables (7 Phase 1 + 6 Phase 2)"); } #[test] fn foreign_keys_are_enabled() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let fk_status: i64 = db .connection() .query_row("PRAGMA foreign_keys;", [], |row| row.get(0)) .unwrap(); assert_eq!(fk_status, 1, "foreign_keys PRAGMA must be ON"); } // ── File-backed database ───────────────────────────────────────────── #[test] fn open_file_creates_and_reopens() { let dir = tempfile::tempdir().unwrap(); let db_path = dir.path().join("test.db"); // First open: creates the database and schema. { let db = Database::open(&db_path, &TEST_KEY).expect("first open should succeed"); db.connection() .execute( "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4);", rusqlite::params!["ns-1", "Personal", 1000, 1000], ) .unwrap(); } // Second open: data must persist. { let db = Database::open(&db_path, &TEST_KEY).expect("reopen should succeed"); let name: String = db .connection() .query_row( "SELECT name FROM namespaces WHERE id = ?1;", ["ns-1"], |row| row.get(0), ) .unwrap(); assert_eq!(name, "Personal"); } } #[test] fn wrong_key_fails() { let dir = tempfile::tempdir().unwrap(); let db_path = dir.path().join("encrypted.db"); // Create with one key. { Database::open(&db_path, &TEST_KEY).unwrap(); } // Attempt reopen with a different key. let bad_key = [0xBB; KEY_LEN]; let result = Database::open(&db_path, &bad_key); assert!(result.is_err(), "opening with a wrong key must fail"); } // ── Key validation ─────────────────────────────────────────────────── #[test] fn reject_short_key() { let short = [0u8; 16]; let result = Database::open_in_memory(&short); assert!(result.is_err()); match result.unwrap_err() { CoreError::InvalidKeyLength(len) => assert_eq!(len, 16), other => panic!("expected InvalidKeyLength, got: {other}"), } } #[test] fn reject_empty_key() { let result = Database::open_in_memory(&[]); assert!(result.is_err()); match result.unwrap_err() { CoreError::InvalidKeyLength(0) => {} other => panic!("expected InvalidKeyLength(0), got: {other}"), } } // ── Schema DDL is idempotent ───────────────────────────────────────── #[test] fn schema_init_is_idempotent() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); // Running the schema DDL a second time must not fail. db.connection().execute_batch(SCHEMA_SQL).unwrap(); } // ── Insert/query round-trip for every table ────────────────────────── #[test] fn crud_round_trip_all_tables() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let c = db.connection(); // namespaces c.execute( "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Work',1,1);", [], ) .unwrap(); // projects c.execute( "INSERT INTO projects (id, namespace_id, title, sort_order, created_at, hlc_timestamp) VALUES ('p1','ns1','Project Alpha',1.0,1,'hlc-1');", [], ) .unwrap(); // tasks c.execute( "INSERT INTO tasks (id, namespace_id, project_id, title, created_at, hlc_timestamp) VALUES ('t1','ns1','p1','My Task',1,'hlc-2');", [], ) .unwrap(); // notes c.execute( "INSERT INTO notes (id, namespace_id, project_id, title, content_markdown, created_at, updated_at, hlc_timestamp) VALUES ('n1','ns1','p1','Note','# Hello',1,1,'hlc-3');", [], ) .unwrap(); // task_note_links c.execute( "INSERT INTO task_note_links (task_id, note_id, created_at) VALUES ('t1','n1',1);", [], ) .unwrap(); // ingestion_logs c.execute( "INSERT INTO ingestion_logs (id, source_type, ingested_at) VALUES ('il1','jot_hotkey',1);", [], ) .unwrap(); // Verify counts let count: i64 = c .query_row("SELECT count(*) FROM tasks;", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 1); } // ── Foreign key enforcement ────────────────────────────────────────── #[test] fn foreign_key_violation_is_rejected() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let result = db.connection().execute( "INSERT INTO projects (id, namespace_id, title, sort_order, created_at, hlc_timestamp) VALUES ('p1','nonexistent_ns','Bad',1.0,1,'hlc-1');", [], ); assert!(result.is_err(), "FK violation must be rejected"); } #[test] fn cascade_delete_removes_children() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let c = db.connection(); c.execute( "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','X',1,1);", [], ) .unwrap(); c.execute( "INSERT INTO tasks (id, namespace_id, title, created_at, hlc_timestamp) VALUES ('t1','ns1','Task',1,'hlc');", [], ) .unwrap(); // Deleting the namespace must cascade-delete the task. c.execute("DELETE FROM namespaces WHERE id = 'ns1';", []) .unwrap(); let count: i64 = c .query_row("SELECT count(*) FROM tasks;", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 0, "cascade delete must remove child tasks"); } // ── CRDT document persistence ──────────────────────────────────────── #[test] fn save_and_load_crdt_doc() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let blob = b"fake-automerge-bytes"; db.save_crdt_doc("t-001", "task", blob, 1000).unwrap(); let loaded = db.load_crdt_doc("t-001").unwrap(); assert_eq!(loaded, Some(blob.to_vec())); } #[test] fn load_missing_crdt_doc_returns_none() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); assert_eq!(db.load_crdt_doc("nonexistent").unwrap(), None); } #[test] fn save_crdt_doc_upserts() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); db.save_crdt_doc("t-001", "task", b"v1", 1000).unwrap(); db.save_crdt_doc("t-001", "task", b"v2", 2000).unwrap(); let loaded = db.load_crdt_doc("t-001").unwrap().unwrap(); assert_eq!(loaded, b"v2"); } #[test] fn delete_crdt_doc_removes_row() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); db.save_crdt_doc("t-001", "task", b"data", 1000).unwrap(); assert!(db.delete_crdt_doc("t-001").unwrap()); assert_eq!(db.load_crdt_doc("t-001").unwrap(), None); } #[test] fn delete_missing_crdt_doc_returns_false() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); assert!(!db.delete_crdt_doc("nonexistent").unwrap()); } // ── Phase 2 extension table CRUD round-trips (Task 2.1) ────────────── /// Helper: inserts a minimal namespace + task into `c` for FK-dependent tests. fn seed_namespace_and_task(c: &Connection) { c.execute( "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);", [], ) .unwrap(); c.execute( "INSERT INTO tasks (id, namespace_id, title, created_at, hlc_timestamp) VALUES ('t1','ns1','Seed Task',1,'hlc-seed');", [], ) .unwrap(); } /// Helper: inserts a minimal namespace + note into `c` for FK-dependent tests. fn seed_namespace_and_note(c: &Connection) { c.execute( "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);", [], ) .unwrap(); c.execute( "INSERT INTO notes (id, namespace_id, title, content_markdown, created_at, updated_at, hlc_timestamp) VALUES ('n1','ns1','Seed Note','# body',1,1,'hlc-note');", [], ) .unwrap(); } #[test] fn crud_time_blocks() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let c = db.connection(); seed_namespace_and_task(c); // Insert a time block linked to the task. c.execute( "INSERT INTO time_blocks (id, namespace_id, task_id, title, start_time, end_time, created_at, updated_at, hlc_timestamp) VALUES ('tb1','ns1','t1','Deep Work',1000,3600000,1,1,'hlc-tb1');", [], ) .unwrap(); let title: String = c .query_row( "SELECT title FROM time_blocks WHERE id='tb1';", [], |r| r.get(0), ) .unwrap(); assert_eq!(title, "Deep Work"); // Verify FK: deleting the task must SET NULL on time_blocks.task_id. c.execute("DELETE FROM tasks WHERE id='t1';", []).unwrap(); let task_id: Option = c .query_row( "SELECT task_id FROM time_blocks WHERE id='tb1';", [], |r| r.get(0), ) .unwrap(); assert!(task_id.is_none(), "task_id must be NULL after task deletion"); } #[test] fn crud_attachments() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let c = db.connection(); seed_namespace_and_note(c); c.execute( "INSERT INTO attachments (id, note_id, file_name, mime_type, file_size_bytes, storage_path, checksum_sha256, created_at, hlc_timestamp) VALUES ('att1','n1','report.pdf','application/pdf',204800,'vault/att1.pdf','deadbeef',1,'hlc-att1');", [], ) .unwrap(); let fname: String = c .query_row( "SELECT file_name FROM attachments WHERE id='att1';", [], |r| r.get(0), ) .unwrap(); assert_eq!(fname, "report.pdf"); // Cascade: deleting the note must remove the attachment. c.execute("DELETE FROM notes WHERE id='n1';", []).unwrap(); let count: i64 = c .query_row("SELECT count(*) FROM attachments;", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 0, "attachment must be cascade-deleted with its note"); } #[test] fn crud_calendar_feeds() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let c = db.connection(); c.execute( "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);", [], ) .unwrap(); c.execute( "INSERT INTO calendar_feeds (id, namespace_id, display_name, feed_url, created_at, updated_at, hlc_timestamp) VALUES ('cf1','ns1','Work Cal','https://caldav.example.com/cal',1,1,'hlc-cf1');", [], ) .unwrap(); let url: String = c .query_row( "SELECT feed_url FROM calendar_feeds WHERE id='cf1';", [], |r| r.get(0), ) .unwrap(); assert_eq!(url, "https://caldav.example.com/cal"); // Cascade: deleting the namespace must remove the feed. c.execute("DELETE FROM namespaces WHERE id='ns1';", []).unwrap(); let count: i64 = c .query_row("SELECT count(*) FROM calendar_feeds;", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 0, "calendar_feed must be cascade-deleted with its namespace"); } #[test] fn crud_node_peers() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let c = db.connection(); // node_peers has no FK to namespaces — it is a global peer registry. c.execute( "INSERT INTO node_peers (id, public_key_bytes, swarm_key_fingerprint, is_trusted, created_at, updated_at) VALUES ('peer-abc123', X'AABBCCDD', 'fp0011223344556677', 1, 1, 1);", [], ) .unwrap(); let trusted: i64 = c .query_row( "SELECT is_trusted FROM node_peers WHERE id='peer-abc123';", [], |r| r.get(0), ) .unwrap(); assert_eq!(trusted, 1); // Update last_seen_at. c.execute( "UPDATE node_peers SET last_seen_at=9999 WHERE id='peer-abc123';", [], ) .unwrap(); let last_seen: i64 = c .query_row( "SELECT last_seen_at FROM node_peers WHERE id='peer-abc123';", [], |r| r.get(0), ) .unwrap(); assert_eq!(last_seen, 9999); } #[test] fn crud_automation_rules() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let c = db.connection(); c.execute( "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);", [], ) .unwrap(); c.execute( "INSERT INTO automation_rules (id, namespace_id, name, trigger_type, trigger_config, action_type, action_config, created_at, updated_at, hlc_timestamp) VALUES ('ar1','ns1','Auto-assign','task_created','{\"project\":\"p1\"}','set_field','{\"field\":\"status\",\"value\":\"in_progress\"}',1,1,'hlc-ar1');", [], ) .unwrap(); let name: String = c .query_row( "SELECT name FROM automation_rules WHERE id='ar1';", [], |r| r.get(0), ) .unwrap(); assert_eq!(name, "Auto-assign"); // Cascade: deleting the namespace must remove the rule. c.execute("DELETE FROM namespaces WHERE id='ns1';", []).unwrap(); let count: i64 = c .query_row("SELECT count(*) FROM automation_rules;", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 0, "automation_rule must be cascade-deleted with its namespace"); } #[test] fn crud_ingestion_configs() { let db = Database::open_in_memory(&TEST_KEY).unwrap(); let c = db.connection(); c.execute( "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);", [], ) .unwrap(); // IMAP config c.execute( "INSERT INTO ingestion_configs (id, namespace_id, config_type, display_name, imap_host, imap_port, imap_username, imap_use_tls, created_at, updated_at, hlc_timestamp) VALUES ('ic1','ns1','imap','Work IMAP','mail.example.com',993,'user@example.com',1,1,1,'hlc-ic1');", [], ) .unwrap(); // File-watcher config c.execute( "INSERT INTO ingestion_configs (id, namespace_id, config_type, display_name, watch_path, watch_extensions, created_at, updated_at, hlc_timestamp) VALUES ('ic2','ns1','file_watcher','Docs Watcher','/home/user/docs','[\"md\",\"pdf\"]',1,1,'hlc-ic2');", [], ) .unwrap(); let count: i64 = c .query_row("SELECT count(*) FROM ingestion_configs;", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 2); let host: String = c .query_row( "SELECT imap_host FROM ingestion_configs WHERE id='ic1';", [], |r| r.get(0), ) .unwrap(); assert_eq!(host, "mail.example.com"); // Cascade: deleting the namespace must remove both configs. c.execute("DELETE FROM namespaces WHERE id='ns1';", []).unwrap(); let count: i64 = c .query_row("SELECT count(*) FROM ingestion_configs;", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 0, "ingestion_configs must be cascade-deleted with their namespace"); } #[test] fn crdt_doc_time_block_entity_type() { // Verifies that 'time_block' is a valid entity_type value in crdt_docs. let db = Database::open_in_memory(&TEST_KEY).unwrap(); db.save_crdt_doc("tb-001", "time_block", b"fake-am-bytes", 1000) .unwrap(); let loaded = db.load_crdt_doc("tb-001").unwrap(); assert_eq!(loaded, Some(b"fake-am-bytes".to_vec())); // Confirm entity_type is stored correctly. let entity_type: String = db .connection() .query_row( "SELECT entity_type FROM crdt_docs WHERE entity_id='tb-001';", [], |r| r.get(0), ) .unwrap(); assert_eq!(entity_type, "time_block"); } }