-- ============================================================================ -- ctxd — Context Daemon Schema -- SQLite 3.x, WAL mode, FTS5 -- (Legacy fallback for local dev when DATABASE_URL is not set) -- ============================================================================ -- WAL for concurrent reads during writes; foreign keys enforced PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; -- ============================================================================ -- USERS -- ============================================================================ CREATE TABLE users ( user_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'contributor' CHECK (role IN ('admin', 'contributor', 'service')), token_hash TEXT, active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); CREATE UNIQUE INDEX idx_users_lower ON users (LOWER(user_id)); -- ============================================================================ -- PROJECTS -- ============================================================================ CREATE TABLE projects ( project_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, description TEXT, metadata_tags TEXT DEFAULT '[]', shared_version INTEGER NOT NULL DEFAULT 0, auto_sync INTEGER NOT NULL DEFAULT 0, sync_path TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); -- ============================================================================ -- PROJECT PERMISSIONS (admin overrides all) -- ============================================================================ CREATE TABLE project_permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, permission TEXT NOT NULL DEFAULT 'editor' CHECK (permission IN ('owner', 'editor', 'viewer')), UNIQUE(project_id, user_id) ); -- ============================================================================ -- USER PROFILES (personal context — timezone, preferences, style) -- ============================================================================ CREATE TABLE user_profiles ( user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, content TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); -- ============================================================================ -- PROJECT CONTEXT — THE AUTHORITATIVE SHARED COPY -- ============================================================================ CREATE TABLE project_context ( project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE, content TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 0, updated_by TEXT REFERENCES users(user_id), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); -- ============================================================================ -- CONTEXT FILES — individual files within a project -- ============================================================================ CREATE TABLE context_files ( file_id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, file_path TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1, updated_by TEXT REFERENCES users(user_id), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(project_id, file_path) ); -- ============================================================================ -- USER WORKSPACES — per-user forks of a project -- ============================================================================ CREATE TABLE user_workspaces ( workspace_id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'in_progress' CHECK (status IN ('in_progress', 'submitted', 'merged', 'abandoned')), base_version INTEGER NOT NULL, current_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(user_id, project_id, status) ); -- ============================================================================ -- WORKSPACE FILES — per-user fork of context_files -- ============================================================================ CREATE TABLE workspace_files ( file_id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE, file_path TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(workspace_id, file_path) ); -- ============================================================================ -- CHANGE REQUESTS — submit / review / merge workflow -- ============================================================================ CREATE TABLE change_requests ( request_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(project_id), submitted_by TEXT NOT NULL REFERENCES users(user_id), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'merged')), diff_summary TEXT, target_version INTEGER NOT NULL, base_version INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); -- ============================================================================ -- REVIEWS — approvals/rejections on change requests -- ============================================================================ CREATE TABLE reviews ( review_id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL REFERENCES change_requests(request_id) ON DELETE CASCADE, reviewer_id TEXT NOT NULL REFERENCES users(user_id), decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')), comments TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(request_id, reviewer_id) ); -- ============================================================================ -- SNAPSHOTS — point-in-time copies of project or workspace content -- ============================================================================ CREATE TABLE snapshots ( snapshot_id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE, workspace_id TEXT REFERENCES user_workspaces(workspace_id) ON DELETE SET NULL, version_from INTEGER, version_to INTEGER, storage_path TEXT NOT NULL, content_hash TEXT NOT NULL, size_bytes INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); CREATE INDEX idx_snapshots_cleanup ON snapshots (project_id, user_id, created_at); -- ============================================================================ -- AUDIT LOG — append-only (INSERT only, never UPDATE or DELETE) -- ============================================================================ CREATE TABLE audit_log ( entry_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(user_id), agent_id TEXT NOT NULL DEFAULT 'cli', session_id TEXT, project_id TEXT REFERENCES projects(project_id), operation TEXT NOT NULL CHECK (operation IN ( 'read', 'update', 'create', 'delete', 'submit', 'approve', 'reject', 'merge', 'sync', 'search', 'export', 'restore', 'login', 'logout', 'import' )), entity_type TEXT, entity_id TEXT, summary TEXT NOT NULL, details_json TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); CREATE INDEX idx_audit_user ON audit_log (user_id, created_at); CREATE INDEX idx_audit_project ON audit_log (project_id, created_at); CREATE INDEX idx_audit_agent ON audit_log (agent_id, created_at); CREATE INDEX idx_audit_op ON audit_log (operation, created_at); -- Trigger: audit_log is append-only CREATE TRIGGER tr_audit_log_no_update BEFORE UPDATE ON audit_log BEGIN SELECT RAISE(ABORT, 'audit_log is append-only — no updates allowed'); END; CREATE TRIGGER tr_audit_log_no_delete BEFORE DELETE ON audit_log BEGIN SELECT RAISE(ABORT, 'audit_log is append-only — no deletes allowed'); END; -- ============================================================================ -- FULL-TEXT SEARCH (FTS5) -- ============================================================================ CREATE VIRTUAL TABLE fts_context USING fts5( content, project_id UNINDEXED, file_path UNINDEXED, source_type UNINDEXED, tokenize='porter unicode61' ); -- Triggers to keep FTS index in sync with project_context CREATE TRIGGER tr_fts_project_context_insert AFTER INSERT ON project_context BEGIN INSERT INTO fts_context(rowid, content, project_id, file_path, source_type) VALUES (NEW.rowid, NEW.content, NEW.project_id, 'context.md', 'project_context'); END; CREATE TRIGGER tr_fts_project_context_update AFTER UPDATE ON project_context BEGIN DELETE FROM fts_context WHERE rowid = OLD.rowid; INSERT INTO fts_context(rowid, content, project_id, file_path, source_type) VALUES (NEW.rowid, NEW.content, NEW.project_id, 'context.md', 'project_context'); END; CREATE TRIGGER tr_fts_project_context_delete AFTER DELETE ON project_context BEGIN DELETE FROM fts_context WHERE rowid = OLD.rowid; END; -- Triggers for context_files CREATE TRIGGER tr_fts_context_files_insert AFTER INSERT ON context_files BEGIN INSERT INTO fts_context(rowid, content, project_id, file_path, source_type) VALUES (NEW.file_id + 1000000, NEW.content, NEW.project_id, NEW.file_path, 'context_file'); END; CREATE TRIGGER tr_fts_context_files_update AFTER UPDATE ON context_files BEGIN DELETE FROM fts_context WHERE rowid = OLD.file_id + 1000000; INSERT INTO fts_context(rowid, content, project_id, file_path, source_type) VALUES (NEW.file_id + 1000000, NEW.content, NEW.project_id, NEW.file_path, 'context_file'); END; CREATE TRIGGER tr_fts_context_files_delete AFTER DELETE ON context_files BEGIN DELETE FROM fts_context WHERE rowid = OLD.file_id + 1000000; END; -- Triggers for user_profiles CREATE TRIGGER tr_fts_user_profiles_insert AFTER INSERT ON user_profiles BEGIN INSERT INTO fts_context(rowid, content, project_id, file_path, source_type) VALUES (NEW.rowid + 2000000, NEW.content, '~user', NEW.user_id, 'user_profile'); END; CREATE TRIGGER tr_fts_user_profiles_update AFTER UPDATE ON user_profiles BEGIN DELETE FROM fts_context WHERE rowid = OLD.rowid + 2000000; INSERT INTO fts_context(rowid, content, project_id, file_path, source_type) VALUES (NEW.rowid + 2000000, NEW.content, '~user', NEW.user_id, 'user_profile'); END; -- ============================================================================ -- SEED DATA (for development / first-run) -- ============================================================================ INSERT INTO users (user_id, display_name, role) VALUES ('admin', 'Administrator', 'admin'), ('hermes-gateway', 'Hermes Agent', 'service'); INSERT INTO projects (project_id, display_name, description) VALUES ('welcome', 'Welcome', 'Getting started guide and documentation for ctxd'), ('remote-rig', 'RemoteRig', 'Multi-camera remote monitoring system'); -- Project context is seeded by the Python init code (cmd_init) -- to ensure real newlines, not literal backslash-n from SQL strings.