chore: merge feature/2.1-extend-schema-missing-entities into main
This commit is contained in:
commit
65c1f560e4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue