chronogrid/crates/chronogrid_core/src/db.rs

836 lines
29 KiB
Rust

//! 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", &"<rusqlite::Connection>")
.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<Path>, key: &[u8]) -> Result<Self> {
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<Self> {
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<Option<Vec<u8>>> {
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<u8>>(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<bool> {
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<Self> {
// 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<String> = stmt
.query_map([], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
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<String> = 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");
}
}