- message_parts.hidden_at timestamptz column (NULL by default) with a partial index on (message_id) WHERE hidden_at IS NULL for the common visible-parts filter. - messages_with_parts view changed from COALESCE(parts, legacy) to CASE WHEN EXISTS(any parts of kind) THEN visible-parts ELSE legacy. COALESCE would have leaked hidden parts back via the legacy fallback when every part was pruned (smoke caught it pre-commit). The CASE distinguishes "no parts at all → fall back to legacy column for pre-v1.13.0 history" from "all parts hidden → return null/empty so the row drops out of the model payload" exactly. - prune.ts: scans tool_result parts newest-first, protects the last 40k tokens (PROTECTED_TOKENS), marks older candidates hidden when their combined estimate clears 20k (PRUNE_TRIGGER_TOKENS — equal to COMPACTION_BUFFER from v1.11.0, so a successful prune is exactly the budget the summary path would have freed). Stops at chats.tail_start_id so it doesn't double-erase across the last summary boundary. Pure decision helper selectPruneTargets exported separately for unit tests. - Wired into maybeFlagForCompaction: prune runs synchronously when overflow is detected; if it freed >= PRUNE_TRIGGER_TOKENS, the needs_compaction flag is NOT set and the (expensive) summary inference call is skipped this turn. The next turn's overflow check re-evaluates from scratch. - 6 new unit tests in prune.test.ts cover: empty input, protection-only (no candidates), candidates below trigger, candidates above trigger, candidates straddling a summary boundary, exactly-protection-tokens. 179 tests total (was 173). Smoke verified post-rebuild: - \\d message_parts shows hidden_at + partial index. - View definition shows AND p.hidden_at IS NULL filters on all three subselects. - Synthetic hide-then-restore confirmed the view drops the tool_result jsonb to null when its only part is hidden, and restores when un-hidden. - EXPLAIN ANALYZE on the 42-message stress chat: 0.325ms (faster than v1.13.1-B's 1.018ms — EXISTS short-circuits cleanly for the common no-parts case). - Normal turn (plain text prompt) completes unaffected. Closes a v1.11.0 design item that was scoped but never implemented. With v1.13's parts table the prune is dramatically cheaper to write — pre-parts it would have meant editing JSON blobs in-place; now it's a hidden_at flag and a view subselect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
296 lines
14 KiB
SQL
296 lines
14 KiB
SQL
-- 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);
|