From 3edce7e119d93a2d71ed377f572919be3a89338f Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 22 Aug 2026 08:56:10 -0400 Subject: [PATCH] feat(core): complete Task 2.1 - Extend Schema with Missing Phase 2 Entities --- DECISION_LOG.md | 18 + crates/chronogrid_core/src/db.rs | 321 ++++++++++++++++++ docs/API_CONTRACTS/schema.sql | 140 +++++++- .../0007-schema-extension-phase2-entities.md | 98 ++++++ docs/TASKS/phase_2_networking_and_schema.md | 2 +- 5 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 docs/ARCHITECTURE/ADR/0007-schema-extension-phase2-entities.md diff --git a/DECISION_LOG.md b/DECISION_LOG.md index 60210d9..8a77024 100644 --- a/DECISION_LOG.md +++ b/DECISION_LOG.md @@ -61,6 +61,24 @@ - 25 unit tests (all passing): engine lifecycle, DB CRDT doc persistence, task/note CRDT create/read/mutate/merge round-trips, crypto derivation, stateless read validation, no-engine error paths. - Full ADR at `docs/ARCHITECTURE/ADR/0005-ffi-bridge-flutter-rust-bridge.md`. +## [2026-08-22] - ADL-007: chronogrid_core Schema Extension — Phase 2 Missing Entities (Task 2.1) + +- **Status**: Accepted +- **Context**: Task 2.1 required extending `docs/API_CONTRACTS/schema.sql` with 6 missing entity tables identified in the ADL-006 architecture review as a critical gap blocking Phase 3 and Phase 4 development. +- **Decision**: + - Added 6 new tables: `time_blocks`, `attachments`, `calendar_feeds`, `node_peers`, `automation_rules`, `ingestion_configs`. + - All new tables include `hlc_timestamp TEXT NOT NULL` for CRDT compatibility, except `node_peers` which is managed by `chronogrid_net` and does not participate in CRDT sync. + - `node_peers` has no FK to `namespaces` — it is a global peer registry scoped to the node, not to a namespace. + - `attachments.storage_path` stores relative paths inside the encrypted vault directory; file bytes are stored outside SQLite to avoid BLOB size limits. + - `calendar_feeds` and `ingestion_configs` store credentials as encrypted blobs (`encrypted_password_blob`, `encrypted_imap_password_blob`) — never plaintext. + - `ingestion_configs` uses a `config_type` discriminator (`imap` | `file_watcher` | `hotkey`) with nullable type-specific fields, avoiding a separate table per ingestion type. + - `crdt_docs.entity_type` now supports `'time_block'` in addition to `'task'` and `'note'`. + - `EXPECTED_TABLES` in `crates/chronogrid_core/src/db.rs` updated from 7 to 13 entries; schema validation enforces all 13 tables on every database open. + - 12 new indexes added for all new FK columns and common query patterns. + - 8 new unit tests added covering CRUD round-trips, FK cascade/SET NULL behavior, and `time_block` CRDT doc persistence. + - Verification script confirmed: all 13 tables present in schema.sql (PASS), Flutter analysis clean (PASS). + - Full ADR at `docs/ARCHITECTURE/ADR/0007-schema-extension-phase2-entities.md`. + ## [2026-08-22] - ADL-006: Architecture Review Board Assessment & Master Development Plan v2 - **Status**: Accepted diff --git a/crates/chronogrid_core/src/db.rs b/crates/chronogrid_core/src/db.rs index f8dfca9..3e261e6 100644 --- a/crates/chronogrid_core/src/db.rs +++ b/crates/chronogrid_core/src/db.rs @@ -31,7 +31,12 @@ pub const KEY_LEN: usize = 32; 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", @@ -39,6 +44,13 @@ const EXPECTED_TABLES: &[&str] = &[ "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 ────────────────────────────────────────────────────────── @@ -256,11 +268,25 @@ mod tests { 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 { @@ -276,6 +302,22 @@ mod tests { } } + /// 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(); @@ -511,4 +553,283 @@ mod tests { 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"); + } } diff --git a/docs/API_CONTRACTS/schema.sql b/docs/API_CONTRACTS/schema.sql index de1c7dd..222dae4 100644 --- a/docs/API_CONTRACTS/schema.sql +++ b/docs/API_CONTRACTS/schema.sql @@ -84,15 +84,151 @@ CREATE TABLE IF NOT EXISTS ingestion_logs ( ); -- CRDT Document Storage (Automerge binary state) +-- entity_type: 'task' | 'note' | 'time_block' CREATE TABLE IF NOT EXISTS crdt_docs ( - entity_id TEXT PRIMARY KEY, -- References task.id or note.id - entity_type TEXT NOT NULL, -- 'task' or 'note' + entity_id TEXT PRIMARY KEY, -- References task.id, note.id, or time_blocks.id + entity_type TEXT NOT NULL, -- 'task', 'note', or 'time_block' doc_bytes BLOB NOT NULL, -- Serialized Automerge document updated_at INTEGER NOT NULL -- Unix Epoch (ms) ); +-- ============================================================================== +-- Phase 2 Extension Tables +-- ============================================================================== + +-- Calendar Time-Blocking Entries (linked to tasks) +-- Represents a scheduled block of time on the calendar grid for a task. +CREATE TABLE IF NOT EXISTS time_blocks ( + id TEXT PRIMARY KEY, -- UUIDv4 + namespace_id TEXT NOT NULL, + task_id TEXT, -- Nullable: a block may exist without a task + title TEXT NOT NULL, -- Display label for the block + start_time INTEGER NOT NULL, -- Unix Epoch (ms) — block start + end_time INTEGER NOT NULL, -- Unix Epoch (ms) — block end + color_hex TEXT DEFAULT '#4A90D9', -- Calendar display color + is_all_day INTEGER DEFAULT 0, -- 1 = all-day event, 0 = timed block + recurrence_rule TEXT, -- iCal RRULE string (nullable) + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc_timestamp TEXT NOT NULL, + FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE SET NULL +); + +-- Binary / File Attachments (linked to notes or ingestion logs) +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, -- UUIDv4 + note_id TEXT, -- Nullable: attachment may belong to a note + ingestion_log_id TEXT, -- Nullable: attachment may belong to an ingestion log + file_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + file_size_bytes INTEGER NOT NULL, + storage_path TEXT NOT NULL, -- Relative path inside the encrypted vault directory + checksum_sha256 TEXT NOT NULL, -- SHA-256 hex digest for integrity verification + created_at INTEGER NOT NULL, + hlc_timestamp TEXT NOT NULL, + FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE, + FOREIGN KEY(ingestion_log_id) REFERENCES ingestion_logs(id) ON DELETE CASCADE +); + +-- CalDAV External Feed Configuration (per namespace) +CREATE TABLE IF NOT EXISTS calendar_feeds ( + id TEXT PRIMARY KEY, -- UUIDv4 + namespace_id TEXT NOT NULL, + display_name TEXT NOT NULL, + feed_url TEXT NOT NULL, -- CalDAV server URL + username TEXT, -- CalDAV auth username (nullable for public feeds) + encrypted_password_blob TEXT, -- Encrypted credential blob (AES-GCM, key from keychain) + sync_interval_seconds INTEGER DEFAULT 300, + last_synced_at INTEGER, -- Unix Epoch (ms), nullable until first sync + is_enabled INTEGER DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc_timestamp TEXT NOT NULL, + FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE +); + +-- P2P Peer Registry (NodeId, public key, last seen, swarm membership) +CREATE TABLE IF NOT EXISTS node_peers ( + id TEXT PRIMARY KEY, -- NodeId: Ed25519 public key hash (hex) + display_name TEXT, -- Optional human-readable label + public_key_bytes BLOB NOT NULL, -- Raw Ed25519 public key bytes (32 bytes) + swarm_key_fingerprint TEXT NOT NULL, -- First 16 hex chars of the swarm key SHA-256 hash + last_seen_at INTEGER, -- Unix Epoch (ms), nullable until first contact + last_known_addrs TEXT, -- JSON array of multiaddr strings + is_trusted INTEGER DEFAULT 0, -- 1 = explicitly trusted peer + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Local Trigger / Action Automation Rules +CREATE TABLE IF NOT EXISTS automation_rules ( + id TEXT PRIMARY KEY, -- UUIDv4 + namespace_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + trigger_type TEXT NOT NULL, -- 'task_created', 'task_status_changed', 'ingestion_received', 'schedule' + trigger_config TEXT NOT NULL, -- JSON blob: trigger-specific parameters + action_type TEXT NOT NULL, -- 'set_field', 'create_task', 'send_notification', 'run_script' + action_config TEXT NOT NULL, -- JSON blob: action-specific parameters + is_enabled INTEGER DEFAULT 1, + execution_count INTEGER DEFAULT 0, + last_executed_at INTEGER, -- Unix Epoch (ms), nullable + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc_timestamp TEXT NOT NULL, + FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE +); + +-- IMAP Account and File-Watcher Ingestion Configuration (per namespace) +CREATE TABLE IF NOT EXISTS ingestion_configs ( + id TEXT PRIMARY KEY, -- UUIDv4 + namespace_id TEXT NOT NULL, + config_type TEXT NOT NULL, -- 'imap' | 'file_watcher' | 'hotkey' + display_name TEXT NOT NULL, + -- IMAP-specific fields (nullable for non-IMAP types) + imap_host TEXT, + imap_port INTEGER, + imap_username TEXT, + encrypted_imap_password_blob TEXT, -- Encrypted credential blob + imap_mailbox TEXT DEFAULT 'INBOX', + imap_use_tls INTEGER DEFAULT 1, + -- File-watcher-specific fields (nullable for non-file-watcher types) + watch_path TEXT, -- Absolute path to the monitored directory + watch_extensions TEXT, -- JSON array of file extensions, e.g. ["md","pdf","txt"] + -- Shared scheduling + poll_interval_seconds INTEGER DEFAULT 300, + last_polled_at INTEGER, -- Unix Epoch (ms), nullable + is_enabled INTEGER DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc_timestamp TEXT NOT NULL, + FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE +); + +-- ============================================================================== +-- Indexes — Original Tables +-- ============================================================================== + CREATE INDEX IF NOT EXISTS idx_tasks_namespace ON tasks(namespace_id); CREATE INDEX IF NOT EXISTS idx_tasks_eisenhower ON tasks(eisenhower_quadrant); CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date); CREATE INDEX IF NOT EXISTS idx_projects_parent ON projects(parent_id); CREATE INDEX IF NOT EXISTS idx_crdt_docs_type ON crdt_docs(entity_type); + +-- ============================================================================== +-- Indexes — Phase 2 Extension Tables +-- ============================================================================== + +CREATE INDEX IF NOT EXISTS idx_time_blocks_namespace ON time_blocks(namespace_id); +CREATE INDEX IF NOT EXISTS idx_time_blocks_task ON time_blocks(task_id); +CREATE INDEX IF NOT EXISTS idx_time_blocks_start ON time_blocks(start_time); +CREATE INDEX IF NOT EXISTS idx_attachments_note ON attachments(note_id); +CREATE INDEX IF NOT EXISTS idx_attachments_ingestion ON attachments(ingestion_log_id); +CREATE INDEX IF NOT EXISTS idx_calendar_feeds_namespace ON calendar_feeds(namespace_id); +CREATE INDEX IF NOT EXISTS idx_node_peers_swarm ON node_peers(swarm_key_fingerprint); +CREATE INDEX IF NOT EXISTS idx_node_peers_last_seen ON node_peers(last_seen_at); +CREATE INDEX IF NOT EXISTS idx_automation_rules_namespace ON automation_rules(namespace_id); +CREATE INDEX IF NOT EXISTS idx_automation_rules_trigger ON automation_rules(trigger_type); +CREATE INDEX IF NOT EXISTS idx_ingestion_configs_namespace ON ingestion_configs(namespace_id); +CREATE INDEX IF NOT EXISTS idx_ingestion_configs_type ON ingestion_configs(config_type); diff --git a/docs/ARCHITECTURE/ADR/0007-schema-extension-phase2-entities.md b/docs/ARCHITECTURE/ADR/0007-schema-extension-phase2-entities.md new file mode 100644 index 0000000..e7a9818 --- /dev/null +++ b/docs/ARCHITECTURE/ADR/0007-schema-extension-phase2-entities.md @@ -0,0 +1,98 @@ +# ADR-0007: Schema Extension — Phase 2 Missing Entities (Task 2.1) + +**Date:** 2026-08-22 +**Status:** Accepted +**Task:** 2.1 — Extend Schema with Missing Entities +**Author:** Autonomous Agent Cycle + +--- + +## Context + +The Phase 2 completion criteria for ChronoGrid P2P require 13 total database tables (7 original Phase 1 tables + 6 new Phase 2 extension tables). The architecture review (ADL-006) identified 6 missing schema tables as a critical gap blocking Phase 3 (Flutter UI requires calendar/time-block entities) and Phase 4 (AI/automation requires ingestion and rule entities). + +The canonical schema at `docs/API_CONTRACTS/schema.sql` is embedded at compile time via `include_str!` in `crates/chronogrid_core/src/db.rs`, making it the single source of truth for all database structure. + +--- + +## Decision + +### New Tables Added + +Six new tables were added to `docs/API_CONTRACTS/schema.sql`: + +#### 1. `time_blocks` +- **Purpose:** Calendar time-blocking entries linked to tasks. Represents a scheduled block of time on the calendar grid. +- **Key fields:** `id`, `namespace_id`, `task_id` (nullable FK → tasks, SET NULL), `title`, `start_time`, `end_time`, `color_hex`, `is_all_day`, `recurrence_rule`, `hlc_timestamp` +- **CRDT:** Supports `entity_type = 'time_block'` in `crdt_docs` table (documented in comment and tested). +- **Indexes:** `idx_time_blocks_namespace`, `idx_time_blocks_task`, `idx_time_blocks_start` + +#### 2. `attachments` +- **Purpose:** Binary/file attachments linked to notes or ingestion logs. +- **Key fields:** `id`, `note_id` (nullable FK → notes, CASCADE), `ingestion_log_id` (nullable FK → ingestion_logs, CASCADE), `file_name`, `mime_type`, `file_size_bytes`, `storage_path`, `checksum_sha256`, `hlc_timestamp` +- **Design note:** `storage_path` is a relative path inside the encrypted vault directory; actual file bytes are stored outside SQLite to avoid BLOB size limits. +- **Indexes:** `idx_attachments_note`, `idx_attachments_ingestion` + +#### 3. `calendar_feeds` +- **Purpose:** CalDAV external feed configuration per namespace. +- **Key fields:** `id`, `namespace_id` (FK → namespaces, CASCADE), `display_name`, `feed_url`, `username`, `encrypted_password_blob` (AES-GCM encrypted, key from keychain), `sync_interval_seconds`, `last_synced_at`, `is_enabled`, `hlc_timestamp` +- **Security:** Credentials are stored as encrypted blobs — never plaintext. +- **Indexes:** `idx_calendar_feeds_namespace` + +#### 4. `node_peers` +- **Purpose:** P2P peer registry — NodeId, public key, last seen, swarm membership. +- **Key fields:** `id` (NodeId: Ed25519 public key hash hex), `public_key_bytes` (BLOB, 32 bytes), `swarm_key_fingerprint`, `last_seen_at`, `last_known_addrs` (JSON array of multiaddr strings), `is_trusted` +- **Design note:** No FK to `namespaces` — peers are global to the node, not namespace-scoped. +- **No `hlc_timestamp`:** Peer registry entries are managed by the networking layer, not the CRDT engine. +- **Indexes:** `idx_node_peers_swarm`, `idx_node_peers_last_seen` + +#### 5. `automation_rules` +- **Purpose:** Local trigger/action rule definitions for the automation engine. +- **Key fields:** `id`, `namespace_id` (FK → namespaces, CASCADE), `name`, `trigger_type` (`task_created` | `task_status_changed` | `ingestion_received` | `schedule`), `trigger_config` (JSON), `action_type` (`set_field` | `create_task` | `send_notification` | `run_script`), `action_config` (JSON), `is_enabled`, `execution_count`, `last_executed_at`, `hlc_timestamp` +- **Indexes:** `idx_automation_rules_namespace`, `idx_automation_rules_trigger` + +#### 6. `ingestion_configs` +- **Purpose:** IMAP account and file-watcher configuration per namespace. +- **Key fields:** `id`, `namespace_id` (FK → namespaces, CASCADE), `config_type` (`imap` | `file_watcher` | `hotkey`), IMAP-specific fields (`imap_host`, `imap_port`, `imap_username`, `encrypted_imap_password_blob`, `imap_mailbox`, `imap_use_tls`), file-watcher fields (`watch_path`, `watch_extensions` JSON array), `poll_interval_seconds`, `last_polled_at`, `hlc_timestamp` +- **Design note:** Single table covers multiple ingestion types via `config_type` discriminator; type-specific fields are nullable for non-applicable types. +- **Indexes:** `idx_ingestion_configs_namespace`, `idx_ingestion_configs_type` + +--- + +## CRDT Compatibility + +All 6 new tables include `hlc_timestamp TEXT NOT NULL` (except `node_peers`, which is managed by the networking layer and does not participate in CRDT sync). The `crdt_docs` table comment was updated to document `time_block` as a valid `entity_type` value alongside `task` and `note`. + +--- + +## Code Changes + +| File | Change | +|------|--------| +| `docs/API_CONTRACTS/schema.sql` | Added 6 new tables + 12 new indexes | +| `crates/chronogrid_core/src/db.rs` | Updated `EXPECTED_TABLES` from 7 to 13 entries | +| `crates/chronogrid_core/src/db.rs` | Added 8 new unit tests covering all Phase 2 tables | + +--- + +## Test Coverage Added + +- `schema_has_exactly_13_tables` — asserts total table count = 13 +- `open_in_memory_creates_indexes` — expanded to cover all 17 indexes (5 Phase 1 + 12 Phase 2) +- `crud_time_blocks` — insert, read, FK SET NULL on task deletion +- `crud_attachments` — insert, read, CASCADE delete with note +- `crud_calendar_feeds` — insert, read, CASCADE delete with namespace +- `crud_node_peers` — insert, read, update `last_seen_at` +- `crud_automation_rules` — insert, read, CASCADE delete with namespace +- `crud_ingestion_configs` — IMAP + file-watcher configs, CASCADE delete +- `crdt_doc_time_block_entity_type` — verifies `time_block` entity_type in `crdt_docs` + +--- + +## Consequences + +- **Positive:** All Phase 2 completion criteria for schema are met (13 tables confirmed by verification script). +- **Positive:** `EXPECTED_TABLES` validation in `db.rs` now enforces all 13 tables on every database open, preventing schema drift. +- **Positive:** Existing Phase 1 tests are unaffected — all DDL uses `CREATE TABLE IF NOT EXISTS`. +- **Neutral:** `node_peers` intentionally omits `hlc_timestamp` as it is managed by `chronogrid_net`, not the CRDT engine. +- **Neutral:** `attachments.storage_path` stores relative paths; the vault directory root is resolved at runtime by the application layer. diff --git a/docs/TASKS/phase_2_networking_and_schema.md b/docs/TASKS/phase_2_networking_and_schema.md index 79d599c..5ed90a2 100644 --- a/docs/TASKS/phase_2_networking_and_schema.md +++ b/docs/TASKS/phase_2_networking_and_schema.md @@ -12,7 +12,7 @@ ## Task 2.1 — Extend Schema with Missing Entities -- [ ] **Task 2.1**: Extend `docs/API_CONTRACTS/schema.sql` with the following new tables: +- [x] **Task 2.1**: Extend `docs/API_CONTRACTS/schema.sql` with the following new tables: - `time_blocks` — Calendar time-blocking entries linked to tasks - `attachments` — Binary/file attachments linked to notes or ingestion logs - `calendar_feeds` — CalDAV external feed configuration per namespace