Files
CTXD/app/src/ctxd/schema.sql
T
overseer 8395df0f80 fix: complete user-delete FK lockstep across PG and SQLite schemas
The prior user-deletion work updated the PG schema and a live-PG migration
but left the canonical schema definitions inconsistent, breaking user
deletion on fresh PG installs and on all SQLite dev installs.

- schema.sql: add ON DELETE SET NULL to context_files.updated_by (was the
  only user FK missing it; fresh PG installs could not delete an authoring
  user).
- schema_sqlite.sql: bring five user_id FK columns into lockstep with PG
  (drop NOT NULL, add ON DELETE SET NULL): project_context.updated_by,
  context_files.updated_by, change_requests.submitted_by,
  reviews.reviewer_id, audit_log.user_id.
- schema_sqlite.sql: remove the audit_log append-only UPDATE/DELETE triggers.
  ON DELETE SET NULL on audit_log.user_id is an UPDATE the trigger aborted,
  so deleting any user who had ever logged in failed. This mirrors schema.sql,
  which dropped the equivalent PG triggers in fc1a2f5; append-only is enforced
  at the application layer (db.py only INSERTs into audit_log).
- db.py: user_delete no longer swallows non-FK exceptions on the SQLite path
  (Exception masked sqlite3.IntegrityError); only FK violations map to the
  soft "user_has_references" response, everything else propagates. PG
  rollback-on-any-error (shared-connection cascade fix) is preserved.
- db.py: document that SQLite cannot ALTER FK constraints in place; existing
  dev DBs must be recreated to pick up these changes.
- server.py: the global 409 handler no longer leaks raw psycopg text (index
  names, column expressions) to API callers; it is logged instead.
- migrate_user_fk_set_null.py: use the column from FKS_TO_FIX directly instead
  of re-deriving it from the constraint name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:35:34 +00:00

345 lines
16 KiB
PL/PgSQL

-- ============================================================================
-- ctxd — Context Daemon Schema
-- PostgreSQL 16
-- ============================================================================
-- ============================================================================
-- 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 BOOLEAN NOT NULL DEFAULT TRUE,
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
);
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 BOOLEAN NOT NULL DEFAULT FALSE,
sync_path TEXT,
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
);
-- ============================================================================
-- PROJECT PERMISSIONS (admin overrides all)
-- ============================================================================
CREATE TABLE project_permissions (
id SERIAL PRIMARY KEY,
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 to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
);
-- ============================================================================
-- PROJECT CONTEXT — THE AUTHORITATIVE SHARED COPY
-- This is the compiled markdown: context.md + decisions/ + runbooks/
-- that gets served to agents.
-- ============================================================================
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) ON DELETE SET NULL,
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
);
-- ============================================================================
-- CONTEXT FILES — individual files within a project (decisions/, runbooks/)
-- The daemon compiles these into project_context.content on demand.
-- version tracks this file's edit count (independent of the shared version).
-- ============================================================================
CREATE TABLE context_files (
file_id SERIAL PRIMARY KEY,
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) ON DELETE SET NULL,
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
UNIQUE(project_id, file_path)
);
-- ============================================================================
-- USER WORKSPACES — per-user forks of a project
-- When a user edits, they work on their own copy. base_version tracks which
-- shared version they started from. current_version tracks their edits.
-- ============================================================================
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 to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
UNIQUE(user_id, project_id, status)
);
-- ============================================================================
-- WORKSPACE FILES — per-user fork of context_files
-- Mirrors the same file_path as context_files but in the user's workspace.
-- ============================================================================
CREATE TABLE workspace_files (
file_id SERIAL PRIMARY KEY,
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 to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
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 REFERENCES users(user_id) ON DELETE SET NULL,
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 to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
);
-- ============================================================================
-- REVIEWS — approvals/rejections on change requests
-- ============================================================================
CREATE TABLE reviews (
review_id SERIAL PRIMARY KEY,
request_id TEXT NOT NULL REFERENCES change_requests(request_id) ON DELETE CASCADE,
reviewer_id TEXT REFERENCES users(user_id) ON DELETE SET NULL,
decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')),
comments TEXT,
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
UNIQUE(request_id, reviewer_id)
);
-- ============================================================================
-- SNAPSHOTS — point-in-time copies of project or workspace content
-- Stored as files on disk at the path in storage_path.
-- ============================================================================
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 to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
);
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 SERIAL PRIMARY KEY,
user_id TEXT REFERENCES users(user_id) ON DELETE SET NULL,
agent_id TEXT NOT NULL DEFAULT 'cli',
session_id TEXT,
project_id TEXT REFERENCES projects(project_id) ON DELETE SET NULL,
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 to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
);
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);
-- Note: audit_log append-only enforcement is handled at the application layer.
-- DB-level BEFORE UPDATE/DELETE triggers conflict with FK ON DELETE SET NULL
-- cascades from projects, which internally issue UPDATE statements.
-- ============================================================================
-- FULL-TEXT SEARCH (tsvector with GIN index)
-- Separate FTS table with triggers to keep index in sync with source tables.
-- ============================================================================
CREATE TABLE fts_context (
id SERIAL PRIMARY KEY,
source_id TEXT NOT NULL,
content TEXT NOT NULL,
project_id TEXT NOT NULL,
file_path TEXT NOT NULL,
source_type TEXT NOT NULL,
tsv tsvector NOT NULL,
UNIQUE(source_type, source_id)
);
CREATE INDEX idx_fts_context_tsv ON fts_context USING GIN (tsv);
CREATE INDEX idx_fts_context_project ON fts_context (project_id);
CREATE INDEX idx_fts_context_source ON fts_context (source_type, source_id);
-- ── Trigger functions for project_context ───────────────────────────────────
CREATE OR REPLACE FUNCTION fts_pc_insert() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
VALUES (NEW.project_id, NEW.content, NEW.project_id, 'context.md', 'project_context',
to_tsvector('english', NEW.content));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_fts_pc_insert AFTER INSERT ON project_context
FOR EACH ROW EXECUTE FUNCTION fts_pc_insert();
CREATE OR REPLACE FUNCTION fts_pc_update() RETURNS TRIGGER AS $$
BEGIN
DELETE FROM fts_context WHERE source_type = 'project_context' AND source_id = OLD.project_id;
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
VALUES (NEW.project_id, NEW.content, NEW.project_id, 'context.md', 'project_context',
to_tsvector('english', NEW.content));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_fts_pc_update AFTER UPDATE ON project_context
FOR EACH ROW EXECUTE FUNCTION fts_pc_update();
CREATE OR REPLACE FUNCTION fts_pc_delete() RETURNS TRIGGER AS $$
BEGIN
DELETE FROM fts_context WHERE source_type = 'project_context' AND source_id = OLD.project_id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_fts_pc_delete AFTER DELETE ON project_context
FOR EACH ROW EXECUTE FUNCTION fts_pc_delete();
-- ── Trigger functions for context_files ─────────────────────────────────────
CREATE OR REPLACE FUNCTION fts_cf_insert() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
VALUES (NEW.file_id::text, NEW.content, NEW.project_id, NEW.file_path, 'context_file',
to_tsvector('english', NEW.content));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_fts_cf_insert AFTER INSERT ON context_files
FOR EACH ROW EXECUTE FUNCTION fts_cf_insert();
CREATE OR REPLACE FUNCTION fts_cf_update() RETURNS TRIGGER AS $$
BEGIN
DELETE FROM fts_context WHERE source_type = 'context_file' AND source_id = OLD.file_id::text;
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
VALUES (NEW.file_id::text, NEW.content, NEW.project_id, NEW.file_path, 'context_file',
to_tsvector('english', NEW.content));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_fts_cf_update AFTER UPDATE ON context_files
FOR EACH ROW EXECUTE FUNCTION fts_cf_update();
CREATE OR REPLACE FUNCTION fts_cf_delete() RETURNS TRIGGER AS $$
BEGIN
DELETE FROM fts_context WHERE source_type = 'context_file' AND source_id = OLD.file_id::text;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_fts_cf_delete AFTER DELETE ON context_files
FOR EACH ROW EXECUTE FUNCTION fts_cf_delete();
-- ── Trigger functions for user_profiles ─────────────────────────────────────
CREATE OR REPLACE FUNCTION fts_up_insert() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
VALUES (NEW.user_id, NEW.content, '~user', NEW.user_id, 'user_profile',
to_tsvector('english', NEW.content));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_fts_up_insert AFTER INSERT ON user_profiles
FOR EACH ROW EXECUTE FUNCTION fts_up_insert();
CREATE OR REPLACE FUNCTION fts_up_update() RETURNS TRIGGER AS $$
BEGIN
DELETE FROM fts_context WHERE source_type = 'user_profile' AND source_id = OLD.user_id;
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
VALUES (NEW.user_id, NEW.content, '~user', NEW.user_id, 'user_profile',
to_tsvector('english', NEW.content));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_fts_up_update AFTER UPDATE ON user_profiles
FOR EACH ROW EXECUTE FUNCTION fts_up_update();
CREATE OR REPLACE FUNCTION fts_up_delete() RETURNS TRIGGER AS $$
BEGIN
DELETE FROM fts_context WHERE source_type = 'user_profile' AND source_id = OLD.user_id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_fts_up_delete AFTER DELETE ON user_profiles
FOR EACH ROW EXECUTE FUNCTION fts_up_delete();
-- ============================================================================
-- SEED DATA (for development / first-run)
-- ============================================================================
INSERT INTO users (user_id, display_name, role) VALUES
('admin', 'Administrator', 'admin'),
('hermes-gateway', 'Hermes Agent', 'service')
ON CONFLICT DO NOTHING;
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')
ON CONFLICT DO NOTHING;
-- Project context is seeded by the Python init code (cmd_init)
-- to ensure real newlines, not literal backslash-n from SQL strings.