feat(core): complete Task 2.1 - Extend Schema with Missing Phase 2 Entities

This commit is contained in:
Mike Kell 2026-08-22 08:56:10 -04:00
parent 2039f5ae48
commit 3edce7e119
5 changed files with 576 additions and 3 deletions

View File

@ -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. - 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`. - 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 ## [2026-08-22] - ADL-006: Architecture Review Board Assessment & Master Development Plan v2
- **Status**: Accepted - **Status**: Accepted

View File

@ -31,7 +31,12 @@ pub const KEY_LEN: usize = 32;
const SCHEMA_SQL: &str = include_str!("../../../docs/API_CONTRACTS/schema.sql"); const SCHEMA_SQL: &str = include_str!("../../../docs/API_CONTRACTS/schema.sql");
/// Expected tables that must exist after schema initialization. /// 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] = &[ const EXPECTED_TABLES: &[&str] = &[
// Phase 1 — original tables
"namespaces", "namespaces",
"projects", "projects",
"tasks", "tasks",
@ -39,6 +44,13 @@ const EXPECTED_TABLES: &[&str] = &[
"task_note_links", "task_note_links",
"ingestion_logs", "ingestion_logs",
"crdt_docs", "crdt_docs",
// Phase 2 — extension tables (Task 2.1)
"time_blocks",
"attachments",
"calendar_feeds",
"node_peers",
"automation_rules",
"ingestion_configs",
]; ];
// ── Database struct ────────────────────────────────────────────────────────── // ── Database struct ──────────────────────────────────────────────────────────
@ -256,11 +268,25 @@ mod tests {
let db = Database::open_in_memory(&TEST_KEY).unwrap(); let db = Database::open_in_memory(&TEST_KEY).unwrap();
let expected_indexes = [ let expected_indexes = [
// Phase 1 indexes
"idx_tasks_namespace", "idx_tasks_namespace",
"idx_tasks_eisenhower", "idx_tasks_eisenhower",
"idx_tasks_due_date", "idx_tasks_due_date",
"idx_projects_parent", "idx_projects_parent",
"idx_crdt_docs_type", "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 { 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] #[test]
fn foreign_keys_are_enabled() { fn foreign_keys_are_enabled() {
let db = Database::open_in_memory(&TEST_KEY).unwrap(); let db = Database::open_in_memory(&TEST_KEY).unwrap();
@ -511,4 +553,283 @@ mod tests {
let db = Database::open_in_memory(&TEST_KEY).unwrap(); let db = Database::open_in_memory(&TEST_KEY).unwrap();
assert!(!db.delete_crdt_doc("nonexistent").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");
}
} }

View File

@ -84,15 +84,151 @@ CREATE TABLE IF NOT EXISTS ingestion_logs (
); );
-- CRDT Document Storage (Automerge binary state) -- CRDT Document Storage (Automerge binary state)
-- entity_type: 'task' | 'note' | 'time_block'
CREATE TABLE IF NOT EXISTS crdt_docs ( CREATE TABLE IF NOT EXISTS crdt_docs (
entity_id TEXT PRIMARY KEY, -- References task.id or note.id entity_id TEXT PRIMARY KEY, -- References task.id, note.id, or time_blocks.id
entity_type TEXT NOT NULL, -- 'task' or 'note' entity_type TEXT NOT NULL, -- 'task', 'note', or 'time_block'
doc_bytes BLOB NOT NULL, -- Serialized Automerge document doc_bytes BLOB NOT NULL, -- Serialized Automerge document
updated_at INTEGER NOT NULL -- Unix Epoch (ms) 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_namespace ON tasks(namespace_id);
CREATE INDEX IF NOT EXISTS idx_tasks_eisenhower ON tasks(eisenhower_quadrant); 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_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_projects_parent ON projects(parent_id);
CREATE INDEX IF NOT EXISTS idx_crdt_docs_type ON crdt_docs(entity_type); 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);

View File

@ -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.

View File

@ -12,7 +12,7 @@
## Task 2.1 — Extend Schema with Missing Entities ## 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 - `time_blocks` — Calendar time-blocking entries linked to tasks
- `attachments` — Binary/file attachments linked to notes or ingestion logs - `attachments` — Binary/file attachments linked to notes or ingestion logs
- `calendar_feeds` — CalDAV external feed configuration per namespace - `calendar_feeds` — CalDAV external feed configuration per namespace