Changeset 0.16.0 (#74)
This commit is contained in:
@@ -1,12 +1,22 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — v0.9.0 Consolidated Schema
|
||||
-- Chat Switchboard — v0.16.0 Consolidated Schema
|
||||
-- ==========================================
|
||||
-- Clean-slate schema. Replaces all 001–021 migrations.
|
||||
-- Clean-slate schema. Replaces all 001–009 migrations.
|
||||
-- Drop DB and re-create before applying.
|
||||
--
|
||||
-- Changes from v0.15.1 (9-file) schema:
|
||||
-- • Folded all incremental migrations into base CREATEs
|
||||
-- • Dropped api_key_plain (vault backfill complete)
|
||||
-- • Dropped projects, project_channels (unused, v0.19.0 redesigns)
|
||||
-- • Dropped 'moderator' from users.role CHECK (dead code)
|
||||
-- • CI usernames from the start (LOWER() unique indexes)
|
||||
-- • Added: groups, group_members, resource_grants (v0.16.0)
|
||||
-- • Added: pgvector extension declaration
|
||||
--
|
||||
-- Design principles:
|
||||
-- • Explicit scope enums over nullable column tri-states
|
||||
-- • Personas as trust boundaries with extensible grants
|
||||
-- • Groups as pure access-control lists (decouple from teams)
|
||||
-- • Secure by default (models hidden until admin enables)
|
||||
-- • Only tables with active handlers — no placeholder tables
|
||||
-- ==========================================
|
||||
@@ -14,6 +24,23 @@
|
||||
-- ── Extensions ──────────────────────────────
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||||
CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector (KB embeddings)
|
||||
|
||||
-- ── Upgrade-path cleanup ───────────────────
|
||||
-- These objects existed in the 001–009 migration chain but were removed
|
||||
-- in the v0.16.0 consolidation. Safe no-ops on fresh installs.
|
||||
|
||||
DROP TABLE IF EXISTS project_channels CASCADE;
|
||||
DROP TABLE IF EXISTS projects CASCADE;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'provider_configs' AND column_name = 'api_key_plain'
|
||||
) THEN
|
||||
ALTER TABLE provider_configs DROP COLUMN api_key_plain;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Utility: auto-update updated_at ─────────
|
||||
|
||||
@@ -32,26 +59,43 @@ $$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
avatar_url TEXT,
|
||||
role VARCHAR(20) DEFAULT 'user'
|
||||
CHECK (role IN ('user', 'admin', 'moderator')),
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Vault: per-user encryption key (UEK) for BYOK API keys
|
||||
encrypted_uek BYTEA,
|
||||
uek_salt BYTEA,
|
||||
uek_nonce BYTEA,
|
||||
vault_set BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Case-insensitive unique indexes (no bare UNIQUE constraint)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
DROP TRIGGER IF EXISTS users_updated_at ON users;
|
||||
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key';
|
||||
COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation';
|
||||
COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping';
|
||||
COMMENT ON COLUMN users.vault_set IS 'true once UEK has been generated and wrapped';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 2. AUTH
|
||||
@@ -107,9 +151,59 @@ CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 4. PROVIDER CONFIGS (replaces api_configs)
|
||||
-- 4. GROUPS (v0.16.0)
|
||||
-- =========================================
|
||||
-- Pure access-control lists. Decouple resource visibility from team membership.
|
||||
-- Teams = organizational structure. Roles = vertical permissions.
|
||||
-- Groups = who can access what (cross-cutting).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team')),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- Global groups: team_id must be NULL
|
||||
-- Team groups: team_id must be set
|
||||
CONSTRAINT groups_scope_team CHECK (
|
||||
(scope = 'global' AND team_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Unique name within scope (global names globally unique,
|
||||
-- team names unique within team)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
|
||||
ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'));
|
||||
|
||||
DROP TRIGGER IF EXISTS groups_updated_at ON groups;
|
||||
CREATE TRIGGER groups_updated_at BEFORE UPDATE ON groups
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE groups IS 'Access-control groups. Decouple resource visibility from team membership.';
|
||||
COMMENT ON COLUMN groups.scope IS 'global: admin-managed, can span teams. team: team-admin-managed, team-internal.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
added_by UUID NOT NULL REFERENCES users(id),
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 5. PROVIDER CONFIGS
|
||||
-- =========================================
|
||||
-- Explicit scope enum replaces nullable column tri-state.
|
||||
-- scope='global': admin-managed, visible to all users (owner_id IS NULL)
|
||||
-- scope='team': team admin-managed (owner_id = teams.id)
|
||||
-- scope='personal': user's own keys (owner_id = users.id)
|
||||
@@ -122,7 +216,12 @@ CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
name VARCHAR(100) NOT NULL,
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
api_key_enc TEXT,
|
||||
|
||||
-- Encrypted API key (AES-256-GCM)
|
||||
api_key_enc BYTEA,
|
||||
key_nonce BYTEA,
|
||||
key_scope TEXT NOT NULL DEFAULT 'global',
|
||||
|
||||
model_default VARCHAR(100),
|
||||
config JSONB DEFAULT '{}'::jsonb,
|
||||
headers JSONB DEFAULT '{}'::jsonb,
|
||||
@@ -142,22 +241,26 @@ CREATE TRIGGER provider_configs_updated_at BEFORE UPDATE ON provider_configs
|
||||
|
||||
COMMENT ON COLUMN provider_configs.scope IS 'global=admin-managed, team=team-scoped, personal=user BYOK';
|
||||
COMMENT ON COLUMN provider_configs.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal scope';
|
||||
COMMENT ON COLUMN provider_configs.api_key_enc IS 'AES-256-GCM encrypted API key (BYTEA)';
|
||||
COMMENT ON COLUMN provider_configs.key_nonce IS 'AES-GCM nonce for api_key_enc';
|
||||
COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team use env key, personal uses UEK';
|
||||
COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)';
|
||||
COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
|
||||
COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 5. MODEL CATALOG (replaces model_configs)
|
||||
-- 6. MODEL CATALOG
|
||||
-- =========================================
|
||||
-- Intrinsic capabilities for models the system knows about.
|
||||
-- Hidden by default — admin must enable.
|
||||
-- Disabled by default — admin must enable.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_catalog (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
model_type VARCHAR(20) DEFAULT 'chat',
|
||||
capabilities JSONB NOT NULL DEFAULT '{}',
|
||||
pricing JSONB,
|
||||
visibility VARCHAR(10) DEFAULT 'disabled'
|
||||
@@ -170,28 +273,18 @@ CREATE TABLE IF NOT EXISTS model_catalog (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled';
|
||||
|
||||
-- Fix CHECK constraint and default for existing databases (idempotent)
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE model_catalog DROP CONSTRAINT IF EXISTS model_catalog_visibility_check;
|
||||
-- Remap old values before adding new constraint
|
||||
UPDATE model_catalog SET visibility = 'enabled' WHERE visibility = 'visible';
|
||||
UPDATE model_catalog SET visibility = 'disabled' WHERE visibility = 'hidden';
|
||||
ALTER TABLE model_catalog ADD CONSTRAINT model_catalog_visibility_check
|
||||
CHECK (visibility IN ('enabled', 'disabled', 'team'));
|
||||
ALTER TABLE model_catalog ALTER COLUMN visibility SET DEFAULT 'disabled';
|
||||
EXCEPTION WHEN OTHERS THEN NULL;
|
||||
END $$;
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type);
|
||||
DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog;
|
||||
CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN model_catalog.visibility IS 'hidden by default — admin must enable for global models';
|
||||
COMMENT ON COLUMN model_catalog.visibility IS 'disabled by default — admin must enable for global models';
|
||||
COMMENT ON COLUMN model_catalog.model_type IS 'chat, embedding, image — from provider API sync';
|
||||
COMMENT ON COLUMN model_catalog.capabilities IS 'Intrinsic: {"streaming","tool_calling","vision","thinking","reasoning","code_optimized","web_search","max_context","max_output_tokens"}';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 6. PERSONAS (replaces model_presets)
|
||||
-- 7. PERSONAS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
@@ -239,8 +332,10 @@ COMMENT ON COLUMN personas.is_shared IS 'Personal Personas shared with others (r
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 7. PERSONA GRANTS (extensible resource binding)
|
||||
-- 8. PERSONA GRANTS (what a Persona can do)
|
||||
-- =========================================
|
||||
-- Controls tools, KBs, and API endpoints that a Persona has access TO.
|
||||
-- NOT to be confused with resource_grants (who can USE a resource).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_grants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -255,13 +350,53 @@ CREATE TABLE IF NOT EXISTS persona_grants (
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_persona ON persona_grants(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type);
|
||||
|
||||
COMMENT ON COLUMN persona_grants.grant_type IS 'Extensible: tool, knowledge_base (future), api_endpoint (future)';
|
||||
COMMENT ON TABLE persona_grants IS 'What a Persona can do: tools it can call, KBs it can search, etc.';
|
||||
COMMENT ON COLUMN persona_grants.grant_type IS 'Extensible: tool, knowledge_base, api_endpoint';
|
||||
COMMENT ON COLUMN persona_grants.grant_ref IS 'tool: function name. knowledge_base: UUID. api_endpoint: identifier.';
|
||||
COMMENT ON COLUMN persona_grants.config IS 'Type-specific config, e.g. {"read_only": true} for KB grants';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 8. PLATFORM POLICIES (replaces scattered global_settings checks)
|
||||
-- 9. RESOURCE GRANTS (v0.16.0 — who can USE a resource)
|
||||
-- =========================================
|
||||
-- Controls which users (via groups) can access a resource.
|
||||
-- NOT to be confused with persona_grants (what a Persona can do).
|
||||
--
|
||||
-- persona_grants: "CodeBot can use calculator" (Persona → tool)
|
||||
-- resource_grants: "Engineering can use CodeBot" (group → Persona)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
resource_type VARCHAR(30) NOT NULL
|
||||
CHECK (resource_type IN ('persona', 'knowledge_base')),
|
||||
resource_id UUID NOT NULL,
|
||||
grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only'
|
||||
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
|
||||
granted_groups UUID[] NOT NULL DEFAULT '{}',
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- One grant row per resource
|
||||
UNIQUE(resource_type, resource_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
|
||||
ON resource_grants(resource_type, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_groups
|
||||
ON resource_grants USING gin(granted_groups);
|
||||
|
||||
DROP TRIGGER IF EXISTS resource_grants_updated_at ON resource_grants;
|
||||
CREATE TRIGGER resource_grants_updated_at BEFORE UPDATE ON resource_grants
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE resource_grants IS 'Who can USE a resource (personas, KBs). Cross-team access via groups.';
|
||||
COMMENT ON COLUMN resource_grants.grant_scope IS 'team_only: existing team behavior. global: all users. groups: specific group list.';
|
||||
COMMENT ON COLUMN resource_grants.granted_groups IS 'UUID array of group IDs. Only meaningful when grant_scope = groups.';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 10. PLATFORM POLICIES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_policies (
|
||||
@@ -285,10 +420,8 @@ COMMENT ON TABLE platform_policies IS 'Global admin switches controlling platfor
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 9. GLOBAL SETTINGS (non-policy config)
|
||||
-- 11. GLOBAL SETTINGS (non-policy config)
|
||||
-- =========================================
|
||||
-- Retained for banner config, site branding, etc.
|
||||
-- Policy-like keys move to platform_policies.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
@@ -297,7 +430,6 @@ CREATE TABLE IF NOT EXISTS global_settings (
|
||||
updated_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Seed defaults
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'::jsonb),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
|
||||
@@ -315,12 +447,17 @@ INSERT INTO global_settings (key, value) VALUES
|
||||
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
|
||||
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
|
||||
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
|
||||
}'::jsonb),
|
||||
('model_roles', '{
|
||||
"utility": { "primary": null, "fallback": null },
|
||||
"embedding": { "primary": null, "fallback": null },
|
||||
"generation": { "primary": null, "fallback": null }
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 10. USER MODEL SETTINGS (replaces user_model_preferences)
|
||||
-- 12. USER MODEL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
@@ -343,7 +480,7 @@ CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settin
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 11. CHANNELS (unified chats)
|
||||
-- 13. CHANNELS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
@@ -358,8 +495,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
is_archived BOOLEAN DEFAULT false,
|
||||
is_pinned BOOLEAN DEFAULT false,
|
||||
folder_id UUID, -- FK added after folders table
|
||||
folder TEXT, -- backward compat: simple text folder name
|
||||
folder_id UUID, -- FK added after folders table created
|
||||
folder TEXT, -- simple text folder name (frontend-managed)
|
||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
tags TEXT[],
|
||||
@@ -380,7 +517,7 @@ COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, chann
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 12. MESSAGES (with tree/forking support)
|
||||
-- 14. MESSAGES (with tree/forking support)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -415,7 +552,7 @@ COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier stri
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 13. CHANNEL MEMBERS & MODELS
|
||||
-- 15. CHANNEL MEMBERS & MODELS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
@@ -448,7 +585,7 @@ CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 14. CHANNEL CURSORS (forking navigation)
|
||||
-- 16. CHANNEL CURSORS (forking navigation)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
@@ -465,8 +602,9 @@ CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 15. FOLDERS & PROJECTS
|
||||
-- 17. FOLDERS
|
||||
-- =========================================
|
||||
-- Channel folders. Note: projects table deferred to v0.19.0.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -485,41 +623,20 @@ DROP TRIGGER IF EXISTS folders_updated_at ON folders;
|
||||
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Now add the FK from channels
|
||||
-- Deferred FK: channels.folder_id → folders.id
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
|
||||
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_folder'
|
||||
) THEN
|
||||
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
|
||||
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_user ON projects(user_id);
|
||||
DROP TRIGGER IF EXISTS projects_updated_at ON projects;
|
||||
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_channels (
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (project_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 16. NOTES
|
||||
-- 18. NOTES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
@@ -533,6 +650,7 @@ CREATE TABLE IF NOT EXISTS notes (
|
||||
source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||
search_vector TSVECTOR,
|
||||
embedding VECTOR(3072),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -562,7 +680,7 @@ CREATE TRIGGER notes_search_update
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 17. AUDIT LOG
|
||||
-- 19. AUDIT LOG
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
@@ -583,3 +701,184 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, re
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||
|
||||
COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 20. USAGE TRACKING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
provider_scope TEXT NOT NULL DEFAULT 'global',
|
||||
model_id TEXT NOT NULL,
|
||||
role TEXT, -- NULL = user chat, 'utility', 'embedding', 'generation'
|
||||
prompt_tokens INT NOT NULL DEFAULT 0,
|
||||
completion_tokens INT NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INT NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INT NOT NULL DEFAULT 0,
|
||||
cost_input NUMERIC(12,6),
|
||||
cost_output NUMERIC(12,6),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 21. MODEL PRICING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
input_per_m NUMERIC(10,6),
|
||||
output_per_m NUMERIC(10,6),
|
||||
cache_create_per_m NUMERIC(10,6),
|
||||
cache_read_per_m NUMERIC(10,6),
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 22. EXTENSIONS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ext_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
|
||||
tier VARCHAR(20) NOT NULL DEFAULT 'browser',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author VARCHAR(200) NOT NULL DEFAULT '',
|
||||
manifest JSONB NOT NULL DEFAULT '{}',
|
||||
is_system BOOLEAN NOT NULL DEFAULT false,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'global',
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extension_user_settings (
|
||||
extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
PRIMARY KEY (extension_id, user_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 23. ATTACHMENTS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(127) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id) WHERE message_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE message_id IS NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 24. KNOWLEDGE BASES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global',
|
||||
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
embedding_config JSONB NOT NULL DEFAULT '{}',
|
||||
document_count INT NOT NULL DEFAULT 0,
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
total_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT kb_scope_check CHECK (
|
||||
(scope = 'global' AND owner_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL) OR
|
||||
(scope = 'personal' AND owner_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_scope ON knowledge_bases(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
document_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INT NOT NULL DEFAULT 0,
|
||||
embedding VECTOR(3072),
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_kb ON kb_chunks(kb_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_doc ON kb_chunks(document_id);
|
||||
|
||||
-- NOTE: IVFFlat index for similarity search created after first data load
|
||||
-- (needs rows to train). The ingest handler creates it.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_knowledge_bases (
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (channel_id, kb_id)
|
||||
);
|
||||
@@ -1,14 +0,0 @@
|
||||
-- 002_ci_username.sql
|
||||
-- Case-insensitive unique indexes for username and email.
|
||||
-- Replaces the default UNIQUE constraint (which is case-sensitive).
|
||||
|
||||
-- Normalize existing rows to lowercase first
|
||||
UPDATE users SET username = LOWER(username) WHERE username != LOWER(username);
|
||||
UPDATE users SET email = LOWER(email) WHERE email != LOWER(email);
|
||||
|
||||
-- Drop old case-sensitive unique constraints and add case-insensitive indexes
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_key;
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email));
|
||||
@@ -1,48 +0,0 @@
|
||||
-- 003_vault.sql — API Key Encryption + User Vault
|
||||
-- v0.9.4: Two-tier encryption for API keys
|
||||
--
|
||||
-- Phase 1 (this migration): Add new columns alongside existing plaintext.
|
||||
-- Phase 2 (Go startup): Backfill — encrypt plaintext keys, generate UEKs.
|
||||
-- Phase 3 (future): Drop api_key_plain after confirmed stable.
|
||||
|
||||
-- =========================================
|
||||
-- 1. USERS — Vault support
|
||||
-- =========================================
|
||||
-- Per-user encryption key (UEK) wrapped with password-derived key (Argon2id).
|
||||
-- UEK protects personal BYOK API keys. Platform admin cannot recover without
|
||||
-- the user's password.
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS encrypted_uek BYTEA;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS uek_salt BYTEA;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS uek_nonce BYTEA;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS vault_set BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key';
|
||||
COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation';
|
||||
COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping';
|
||||
COMMENT ON COLUMN users.vault_set IS 'true once UEK has been generated and wrapped';
|
||||
|
||||
-- =========================================
|
||||
-- 2. PROVIDER_CONFIGS — Encrypted key storage
|
||||
-- =========================================
|
||||
-- Rename current plaintext column, add encrypted BYTEA columns.
|
||||
-- Backfill happens in Go (needs ENCRYPTION_KEY env var).
|
||||
|
||||
-- Keep plaintext temporarily for backfill; Go startup reads it, encrypts,
|
||||
-- writes to new columns, then NULLs it out.
|
||||
ALTER TABLE provider_configs RENAME COLUMN api_key_enc TO api_key_plain;
|
||||
ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS api_key_enc BYTEA;
|
||||
ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS key_nonce BYTEA;
|
||||
ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS key_scope TEXT;
|
||||
|
||||
-- Backfill key_scope from existing scope column (safe default)
|
||||
UPDATE provider_configs SET key_scope = scope WHERE key_scope IS NULL;
|
||||
|
||||
-- Now make it NOT NULL with default
|
||||
ALTER TABLE provider_configs ALTER COLUMN key_scope SET NOT NULL;
|
||||
ALTER TABLE provider_configs ALTER COLUMN key_scope SET DEFAULT 'global';
|
||||
|
||||
COMMENT ON COLUMN provider_configs.api_key_plain IS 'DEPRECATED: plaintext key, cleared after vault backfill';
|
||||
COMMENT ON COLUMN provider_configs.api_key_enc IS 'AES-256-GCM encrypted API key (BYTEA)';
|
||||
COMMENT ON COLUMN provider_configs.key_nonce IS 'AES-GCM nonce for api_key_enc';
|
||||
COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team use env key, personal uses UEK';
|
||||
@@ -1,64 +0,0 @@
|
||||
-- 004_roles_usage.sql
|
||||
-- v0.10.0: Model Roles + Usage Tracking
|
||||
--
|
||||
-- New tables: usage_log, model_pricing
|
||||
-- Seed: model_roles in global_settings
|
||||
|
||||
-- ── Model roles seed ──────────────────────────
|
||||
-- Uses existing global_settings table. No new tables for roles.
|
||||
-- Team overrides go in teams.settings JSONB (existing column).
|
||||
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('model_roles', '{
|
||||
"utility": { "primary": null, "fallback": null },
|
||||
"embedding": { "primary": null, "fallback": null },
|
||||
"generation": { "primary": null, "fallback": null }
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- ── Usage tracking ────────────────────────────
|
||||
-- Every completion (streaming and non-streaming) logs token counts and cost.
|
||||
-- Cost is calculated at insert time from model_pricing.
|
||||
-- provider_scope is denormalized for efficient admin filtering (excludes BYOK).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
provider_scope TEXT NOT NULL DEFAULT 'global',
|
||||
model_id TEXT NOT NULL,
|
||||
role TEXT, -- NULL = user chat, 'utility', 'embedding', 'generation'
|
||||
prompt_tokens INT NOT NULL DEFAULT 0,
|
||||
completion_tokens INT NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INT NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INT NOT NULL DEFAULT 0,
|
||||
cost_input NUMERIC(12,6),
|
||||
cost_output NUMERIC(12,6),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope);
|
||||
|
||||
-- ── Model pricing ─────────────────────────────
|
||||
-- Two sources: 'catalog' (auto-populated from provider API sync) and
|
||||
-- 'manual' (admin override). Manual entries are never overwritten by sync.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
input_per_m NUMERIC(10,6),
|
||||
output_per_m NUMERIC(10,6),
|
||||
cache_create_per_m NUMERIC(10,6),
|
||||
cache_read_per_m NUMERIC(10,6),
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Migration 005: Add model_type to model_catalog
|
||||
--
|
||||
-- Providers like Venice return a "type" field per model (e.g. "text", "embedding", "image").
|
||||
-- This column captures that classification so role dropdowns can filter appropriately:
|
||||
-- - "embedding" role → only embedding models
|
||||
-- - "utility" role → only chat/text models
|
||||
--
|
||||
-- Default is "chat" (the overwhelming majority of models). Provider sync will
|
||||
-- populate from the wire response when available — NO hardcoding of model types.
|
||||
|
||||
ALTER TABLE model_catalog ADD COLUMN IF NOT EXISTS model_type VARCHAR(20) DEFAULT 'chat';
|
||||
|
||||
-- Index for role UI filtering (e.g. "show me all embedding models for this provider")
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type);
|
||||
@@ -1,41 +0,0 @@
|
||||
-- Migration 006: Extension system tables
|
||||
--
|
||||
-- Extensions are installable plugins that add renderers, tools, surfaces,
|
||||
-- and event handlers. Three tiers: browser (JS), starlark (sandbox),
|
||||
-- sidecar (container). Browser tier ships first (v0.11.0).
|
||||
--
|
||||
-- See EXTENSIONS.md for full spec.
|
||||
|
||||
-- ── Extension registry ──────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ext_id VARCHAR(100) NOT NULL UNIQUE, -- manifest id (e.g. "collapsible-code")
|
||||
name VARCHAR(200) NOT NULL, -- human label
|
||||
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
|
||||
tier VARCHAR(20) NOT NULL DEFAULT 'browser', -- browser | starlark | sidecar
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author VARCHAR(200) NOT NULL DEFAULT '',
|
||||
manifest JSONB NOT NULL DEFAULT '{}', -- full manifest JSON
|
||||
is_system BOOLEAN NOT NULL DEFAULT false, -- admin-pushed, users can't disable
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true, -- global enable/disable
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'global', -- global | team | personal
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- For bundled extensions that ship with core
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true;
|
||||
|
||||
-- ── Per-user extension settings ─────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extension_user_settings (
|
||||
extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings JSONB NOT NULL DEFAULT '{}', -- user's config values
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true, -- per-user toggle
|
||||
PRIMARY KEY (extension_id, user_id)
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
-- 007_attachments.sql
|
||||
-- File attachments for chat messages (v0.12.0)
|
||||
--
|
||||
-- Blobs live in object storage (PVC / S3). This table holds metadata.
|
||||
-- Access control: always join through channels — attachments inherit
|
||||
-- channel membership as their access boundary.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(127) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Primary access pattern: find attachments for a channel (auth check joins here)
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
|
||||
|
||||
-- Quota calculation: SUM(size_bytes) WHERE user_id = $1
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id);
|
||||
|
||||
-- Find attachments for a specific message
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id) WHERE message_id IS NOT NULL;
|
||||
|
||||
-- Orphan cleanup: find unlinked attachments older than threshold
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE message_id IS NULL;
|
||||
@@ -1,99 +0,0 @@
|
||||
-- 008_knowledge_bases.sql
|
||||
-- Knowledge Bases: KB metadata, documents, chunks, channel links.
|
||||
-- Requires: pgvector extension already installed (via db-bootstrap.sh).
|
||||
|
||||
-- ── Knowledge Bases ──────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global', -- global, team, personal
|
||||
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
|
||||
-- Snapshot of embedding config at creation time.
|
||||
-- Changing the model requires a full re-embed (rebuild endpoint).
|
||||
-- { "provider_config_id": "...", "model_id": "...", "dimensions": 1536 }
|
||||
embedding_config JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Denormalized stats, updated by ingest pipeline.
|
||||
document_count INT NOT NULL DEFAULT 0,
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
total_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active, processing, error
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT kb_scope_check CHECK (
|
||||
(scope = 'global' AND owner_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL) OR
|
||||
(scope = 'personal' AND owner_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_scope ON knowledge_bases(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
-- ── KB Documents ─────────────────────────────
|
||||
-- One row per uploaded file. Blobs live in ObjectStore under:
|
||||
-- kb/{kb_id}/{doc_id}_{filename}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT, -- full text for re-chunking
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending, chunking, embedding, ready, error
|
||||
error TEXT,
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id);
|
||||
|
||||
-- ── KB Chunks ────────────────────────────────
|
||||
-- Chunked text + embedding vector for similarity search.
|
||||
-- vector(3072) accommodates all current embedding models:
|
||||
-- OpenAI ada-002 / text-embedding-3-small = 1536
|
||||
-- OpenAI text-embedding-3-large = 3072
|
||||
-- Open-source models = typically 768-1024
|
||||
-- Smaller vectors are zero-padded at insert time.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
document_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INT NOT NULL DEFAULT 0,
|
||||
embedding VECTOR(3072),
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_kb ON kb_chunks(kb_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_doc ON kb_chunks(document_id);
|
||||
|
||||
-- NOTE: IVFFlat index for similarity search is created after first data
|
||||
-- load (needs rows to train). The ingest handler creates it:
|
||||
-- CREATE INDEX idx_kbchunk_embedding ON kb_chunks
|
||||
-- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
|
||||
-- ── Channel-KB Links ─────────────────────────
|
||||
-- Which KBs are active for a given channel.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_knowledge_bases (
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (channel_id, kb_id)
|
||||
);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- 009_notes_embedding.sql
|
||||
-- Adds vector embedding column to notes for semantic search.
|
||||
-- Uses the same vector(3072) dimension as kb_chunks for uniformity.
|
||||
|
||||
ALTER TABLE notes ADD COLUMN IF NOT EXISTS embedding vector(3072);
|
||||
|
||||
-- NOTE: pgvector HNSW indexes are limited to 2000 dimensions.
|
||||
-- For 3072-dim vectors, IVFFlat is the option but requires training
|
||||
-- data (rows). The notes table is typically small enough that a
|
||||
-- sequential scan is fast. If needed, create after data exists:
|
||||
-- CREATE INDEX idx_notes_embedding ON notes
|
||||
-- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
Reference in New Issue
Block a user