-- v1.13.3: statement_timeout is set at database level via: -- ALTER DATABASE boocode SET statement_timeout = '30s'; -- ALTER DATABASE can't run inside a DO block, so this is an operational -- step rather than schema. Re-apply after a volume reset (the setting -- lives in pg_db which survives `docker compose up --build` but NOT a -- `docker volume rm boocode_pgdata`). CREATE TABLE IF NOT EXISTS projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), last_session_id UUID ); CREATE TABLE IF NOT EXISTS sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, model TEXT NOT NULL, system_prompt TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC); CREATE TABLE IF NOT EXISTS messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', tool_calls JSONB, tool_results JSONB, status TEXT NOT NULL DEFAULT 'complete', last_seq INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); -- v1.13.0: granular message parts table for AI SDK migration. Old -- messages.content / tool_calls / tool_results columns stay authoritative -- for reads in v1.13.0; this table is dual-written so the swap can happen -- in a later dispatch without a backfill window. ON DELETE CASCADE means -- removing a message removes its parts in one go. CREATE TABLE IF NOT EXISTS message_parts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE, sequence int NOT NULL, kind text NOT NULL, payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT clock_timestamp(), CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start')), CONSTRAINT message_parts_seq_uniq UNIQUE (message_id, sequence) ); CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence); -- v1.13.4: prune support. hidden_at marks parts that have been pruned out -- of the model payload by the two-tier compaction prune (services/inference/ -- prune.ts). Rows stay in the DB so frontend can still display them with a -- "hidden" indicator (out of scope this dispatch). messages_with_parts -- view filters these out — see below. Partial index speeds the common -- "visible parts only" filter. DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'message_parts' AND column_name = 'hidden_at' ) THEN ALTER TABLE message_parts ADD COLUMN hidden_at timestamptz NULL; END IF; END $$; CREATE INDEX IF NOT EXISTS message_parts_hidden_idx ON message_parts (message_id) WHERE hidden_at IS NULL; -- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts -- instead of messages so tool_calls / tool_results / reasoning_parts come -- from the granular message_parts table. The COALESCE means pre-v1.13.0 -- history (no parts rows) still resolves via the legacy JSON columns; the -- dual-write from v1.13.0 keeps both in sync for all rows written since. -- Writes continue to target `messages` directly — the view is read-only. -- Shapes match the in-memory ToolCall / ToolResult types: tool_calls is a -- jsonb array of {id, name, args}, tool_results is a single jsonb object -- {tool_call_id, output, truncated, error?}. reasoning_parts is new — only -- consumed by the inference history fetch (payload.ts) so v1.13.1-C can -- wire reasoning into the model payload. Not surfaced in external APIs yet. CREATE OR REPLACE VIEW messages_with_parts AS SELECT m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status, m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max, m.started_at, m.finished_at, m.created_at, m.metadata, m.summary, m.tail_start_id, m.compacted_at, -- v1.13.4: prune semantics need to distinguish "no parts row exists" -- (pre-v1.13.0 fallback to legacy column) from "all parts hidden" -- (prune intended — return null/empty so the row drops from the model -- payload). A naive COALESCE would fall back to the legacy column when -- every part is hidden, undoing the prune. CASE on EXISTS(any kind) -- splits the two cases. CASE WHEN EXISTS (SELECT 1 FROM message_parts pp WHERE pp.message_id = m.id AND pp.kind = 'tool_call') THEN (SELECT jsonb_agg(p.payload ORDER BY p.sequence) FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL) ELSE m.tool_calls END AS tool_calls, CASE WHEN EXISTS (SELECT 1 FROM message_parts pp WHERE pp.message_id = m.id AND pp.kind = 'tool_result') THEN (SELECT p.payload FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL ORDER BY p.sequence LIMIT 1) ELSE m.tool_results END AS tool_results, (SELECT jsonb_agg(p.payload ORDER BY p.sequence) FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts FROM messages m; ALTER TABLE messages ADD COLUMN IF NOT EXISTS tokens_used INTEGER; ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_used INTEGER; ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_max INTEGER; ALTER TABLE messages ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ; ALTER TABLE messages ADD COLUMN IF NOT EXISTS finished_at TIMESTAMPTZ; ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value JSONB NOT NULL ); INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING; -- v1.12.1: deprecated session_panes table removed. Workspace pane state now -- lives in sessions.workspace_panes (jsonb), see below. DROP TABLE IF EXISTS session_panes; -- v1.12.1: server-side workspace pane layout, replaces localStorage so every -- device sees the same panes for a given session. Shape matches -- WorkspacePane[] from apps/server/src/types/api.ts. ALTER TABLE sessions ADD COLUMN IF NOT EXISTS workspace_panes JSONB NOT NULL DEFAULT '[]'::jsonb; -- v1.2: sessions.status (open | archived) ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'; -- v1.2: chats table CREATE TABLE IF NOT EXISTS chats ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, name TEXT, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'archived')), created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); CREATE INDEX IF NOT EXISTS idx_chats_session_status ON chats (session_id, status, updated_at DESC); -- v1.2: messages.chat_id + messages.kind ALTER TABLE messages ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE CASCADE; ALTER TABLE messages ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'message'; CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages (chat_id, created_at); -- Backfill: one chat per existing session that has none yet INSERT INTO chats (session_id, name, status, created_at, updated_at) SELECT s.id, s.name, 'open', s.created_at, s.updated_at FROM sessions s WHERE NOT EXISTS ( SELECT 1 FROM chats c WHERE c.session_id = s.id ); -- Backfill: link orphaned messages to their session's first chat UPDATE messages SET chat_id = ( SELECT c.id FROM chats c WHERE c.session_id = messages.session_id ORDER BY c.created_at ASC LIMIT 1 ) WHERE chat_id IS NULL; -- Enforce NOT NULL on chat_id once all rows are backfilled DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'messages' AND column_name = 'chat_id' AND is_nullable = 'YES' ) AND NOT EXISTS ( SELECT 1 FROM messages WHERE chat_id IS NULL ) THEN ALTER TABLE messages ALTER COLUMN chat_id SET NOT NULL; END IF; END $$; -- v1.2.1: CHECK constraints for sessions.status and messages (role, status) -- KEEP IN SYNC: apps/server/src/types/api.ts (MESSAGE_ROLES, MESSAGE_STATUSES, SessionStatus) DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sessions_status_chk') THEN ALTER TABLE sessions ADD CONSTRAINT sessions_status_chk CHECK (status IN ('open', 'archived')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_chk') THEN ALTER TABLE messages ADD CONSTRAINT messages_role_chk CHECK (role IN ('user', 'assistant', 'system', 'tool')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_chk') THEN ALTER TABLE messages ADD CONSTRAINT messages_status_chk CHECK (status IN ('streaming', 'complete', 'failed', 'cancelled')); END IF; END $$; -- v1.12.1: drop stale inline CHECK constraints that were superseded by the -- named *_chk variants above. messages_status_check missed 'cancelled' and -- messages_role_check missed 'system' — both narrower than what's in use. DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN ALTER TABLE messages DROP CONSTRAINT messages_status_check; END IF; IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN ALTER TABLE messages DROP CONSTRAINT messages_role_check; END IF; END $$; -- v1.2-project-ux: projects.status + projects.gitea_remote -- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'; ALTER TABLE projects ADD COLUMN IF NOT EXISTS gitea_remote TEXT; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'projects_status_chk') THEN ALTER TABLE projects ADD CONSTRAINT projects_status_chk CHECK (status IN ('open', 'archived')); END IF; END $$; -- v1.3-tab-close-chat-archive: align chats.status vocabulary with projects ('archived' not 'closed') -- KEEP IN SYNC: apps/server/src/types/api.ts CHAT_STATUSES -- Order matters: (1) drop the OLD inline CHECK that only allowed ('open','closed'); -- (2) migrate existing rows; (3) add new named CHECK allowing ('open','archived'). ALTER TABLE chats DROP CONSTRAINT IF EXISTS chats_status_check; UPDATE chats SET status = 'archived' WHERE status = 'closed'; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chats_status_chk') THEN ALTER TABLE chats ADD CONSTRAINT chats_status_chk CHECK (status IN ('open', 'archived')); END IF; END $$; -- v1.x-batch9: per-session agent reference. Agent definitions are not stored in -- the DB; they live in builtins (services/agents.ts) and a per-project AGENTS.md. -- agent_id is the slugified agent name. NULL means "use BooCode defaults". ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT; -- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error -- reasons. JSONB so future kinds can extend without further schema churn. -- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number, -- agent_name: string|null, can_continue: boolean } -- Shape for errors: { error_reason: 'llm_provider_error'|..., error_text: string } ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB; -- themes-v1: idempotent seeds for the two theme preference keys. The settings -- table is a key/value store (see line 43) so theme prefs live as two rows, -- not new columns. Defaults match docs/themes_v1.md: obsidian (dark). INSERT INTO settings (key, value) VALUES ('theme_id', '"obsidian"') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (key) DO NOTHING; -- v1.9: per-project defaults that new sessions inherit, plus a per-session -- web-search override. Empty string on either prompt column means "inherit" -- (resolved in services/system-prompt.ts buildSystemPrompt). web_search_enabled is the -- only tri-state field: null on session = inherit from project default. ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT ''; ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false; ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN; -- v1.11: anchored rolling compaction. -- compacted_at — marks rows that are "behind the curtain" of the latest -- summary. Inference assembly filters compacted_at IS NULL; -- the API GET still returns all rows so the UI can show -- history with the summary card inline. -- summary — true on the assistant row that IS the anchored summary. -- Exactly one row per chat is the "current" summary -- (every prior summary row is itself compacted_at-stamped -- when superseded, leaving one live anchor). -- tail_start_id — points at the first preserved message that the summary -- covers up to (exclusive). Lets the UI/debug reason about -- the boundary without re-deriving from compacted_at. -- needs_compaction — flag on chats (not sessions) because chat history is -- per-chat; sessions have 1:N chats. Set true post-overflow, -- cleared by compaction.process at the start of the next -- inference turn. ALTER TABLE messages ADD COLUMN IF NOT EXISTS compacted_at TIMESTAMPTZ; ALTER TABLE messages ADD COLUMN IF NOT EXISTS summary BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL; ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE; CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);