Changeset 0.17.1 (#76)

This commit is contained in:
2026-02-28 01:40:31 +00:00
parent c9141a6896
commit 856dc9b0ac
64 changed files with 8037 additions and 1657 deletions

209
server/database/compat.go Normal file
View File

@@ -0,0 +1,209 @@
package database
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// ── SQL Dialect Adapter ─────────────────────
//
// Q adapts Postgres-flavoured SQL to the current dialect.
// On Postgres it is a no-op. On SQLite it rewrites:
// - $1, $2 … $20 → ?
// - ::jsonb, ::text → removed
// - NOW() → datetime('now')
// - true / false → 1 / 0 (bare keywords only, not inside strings)
// - NULLS LAST → removed (SQLite default)
// - COALESCE(x::text, …) → COALESCE(x, …)
//
// Call it at every database.DB.Query / Exec / QueryRow site in handlers.
func Q(query string) string {
if !IsSQLite() {
return query
}
q := query
// Replace $N placeholders high-to-low to avoid $1 matching inside $10.
for i := 20; i >= 1; i-- {
q = strings.ReplaceAll(q, fmt.Sprintf("$%d", i), "?")
}
// Postgres type casts
q = strings.ReplaceAll(q, "::jsonb", "")
q = strings.ReplaceAll(q, "::text", "")
// Time functions
q = strings.ReplaceAll(q, "NOW()", "datetime('now')")
// Boolean literals (bare keywords, not inside quotes)
q = strings.ReplaceAll(q, "= true", "= 1")
q = strings.ReplaceAll(q, "= false", "= 0")
q = strings.ReplaceAll(q, "COALESCE(is_private, false)", "COALESCE(is_private, 0)")
// NULL sort order (SQLite puts NULLs last by default for DESC)
q = strings.ReplaceAll(q, "NULLS LAST", "")
// Case-insensitive LIKE (SQLite LIKE is already case-insensitive for ASCII)
q = strings.ReplaceAll(q, "ILIKE", "LIKE")
return q
}
// InsertReturningID executes an INSERT … RETURNING id query.
// On Postgres it uses RETURNING directly.
// On SQLite it strips RETURNING, generates a UUID, prepends it to the args,
// adds an "id" column to the INSERT, and executes.
func InsertReturningID(query string, args ...interface{}) (string, error) {
if !IsSQLite() {
var id string
err := DB.QueryRow(query, args...).Scan(&id)
return id, err
}
// Generate ID for SQLite
id := uuid.New().String()
// Adapt SQL: strip RETURNING, convert placeholders, add id column+value
q := Q(query)
// Remove RETURNING clause
if idx := strings.Index(strings.ToUpper(q), "RETURNING"); idx >= 0 {
q = strings.TrimSpace(q[:idx])
}
// Inject id column and value into INSERT
// Pattern: INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...)
q = injectIDColumn(q)
// Prepend id to args
newArgs := make([]interface{}, 0, len(args)+1)
newArgs = append(newArgs, id)
newArgs = append(newArgs, args...)
_, err := DB.Exec(q, newArgs...)
return id, err
}
// InjectIDForTest adds "id" as the first column and "?" as the first value
// in an INSERT statement. Exported for use in test helpers.
func InjectIDForTest(q string) string {
return injectIDColumn(q)
}
// injectIDColumn adds "id" as the first column and "?" as the first value
// in an INSERT statement.
func injectIDColumn(q string) string {
// Find the opening paren of the column list
upper := strings.ToUpper(q)
insertIdx := strings.Index(upper, "INSERT INTO")
if insertIdx < 0 {
return q
}
// Find first ( after INSERT INTO tablename
firstParen := strings.Index(q[insertIdx:], "(")
if firstParen < 0 {
return q
}
firstParen += insertIdx
// Find VALUES (
valuesIdx := strings.Index(upper, "VALUES")
if valuesIdx < 0 {
return q
}
valuesParen := strings.Index(q[valuesIdx:], "(")
if valuesParen < 0 {
return q
}
valuesParen += valuesIdx
// Insert "id, " after first ( and "?, " after VALUES (
result := q[:firstParen+1] + "id, " + q[firstParen+1:valuesParen+1] + "?, " + q[valuesParen+1:]
return result
}
// ── Time Scan Helpers ───────────────────────
//
// SQLite returns timestamps as TEXT strings. These helpers wrap *time.Time
// and sql.NullTime destinations so Scan() works on both dialects.
// ScanTime wraps a *time.Time for use in handler-level Scan() calls.
// On Postgres, time.Time scans natively. On SQLite, parses from string.
type ScanTime struct{ T *time.Time }
func (s *ScanTime) Scan(src interface{}) error {
if src == nil {
return nil
}
switch v := src.(type) {
case time.Time:
*s.T = v
return nil
case string:
for _, f := range compatTimeFormats {
if p, err := time.Parse(f, v); err == nil {
*s.T = p
return nil
}
}
return fmt.Errorf("ScanTime: cannot parse %q", v)
default:
return fmt.Errorf("ScanTime: unsupported type %T", src)
}
}
// ST creates a ScanTime wrapper for use in Scan() argument lists.
func ST(t *time.Time) *ScanTime { return &ScanTime{T: t} }
// ScanNullTime wraps a nullable time destination.
type ScanNullTime struct{ T **time.Time }
func (s *ScanNullTime) Scan(src interface{}) error {
if src == nil {
*s.T = nil
return nil
}
var parsed time.Time
scanner := ST(&parsed)
if err := scanner.Scan(src); err != nil {
return err
}
*s.T = &parsed
return nil
}
// SNT creates a ScanNullTime wrapper for use in Scan() argument lists.
func SNT(t **time.Time) *ScanNullTime { return &ScanNullTime{T: t} }
// NullTimeValue extracts a time.Time from sql.NullTime or *time.Time,
// returning the zero value if nil/invalid. Useful in JSON serialization.
func NullTimeValue(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}
var compatTimeFormats = []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05Z",
time.RFC3339,
"2006-01-02 15:04:05.000000000+00:00",
}
// ── Unique Constraint Detection ─────────────
// IsUniqueViolation checks if an error is a unique constraint violation,
// working on both Postgres and SQLite.
func IsUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "duplicate key") || // Postgres
strings.Contains(msg, "UNIQUE constraint failed") // SQLite
}

View File

@@ -4,8 +4,12 @@ import (
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"strings"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
"git.gobha.me/xcaliber/chat-switchboard/config"
)
@@ -13,21 +17,38 @@ import (
// DB is the application-wide database connection pool.
var DB *sql.DB
// Connect opens a connection pool to PostgreSQL using the
// DATABASE_URL from config. It pings to verify connectivity.
// Connect opens a connection pool to the configured database.
// Driver is selected by DB_DRIVER env var: "postgres" (default) or "sqlite".
// For SQLite the DATABASE_URL is treated as a file path; ":memory:" is supported.
func Connect(cfg *config.Config) error {
if cfg.DatabaseURL == "" {
driver := strings.ToLower(cfg.DBDriver)
if driver == "" {
driver = detectDriver(cfg.DatabaseURL)
}
switch driver {
case "sqlite", "sqlite3":
return connectSQLite(cfg.DatabaseURL)
case "postgres", "postgresql", "":
return connectPostgres(cfg.DatabaseURL)
default:
return fmt.Errorf("unsupported DB_DRIVER: %q (expected postgres or sqlite)", driver)
}
}
// connectPostgres opens a PostgreSQL connection pool.
func connectPostgres(dsn string) error {
if dsn == "" {
log.Println("⚠ DATABASE_URL not set — running without database")
return nil
}
var err error
DB, err = sql.Open("postgres", cfg.DatabaseURL)
DB, err = sql.Open("postgres", dsn)
if err != nil {
return fmt.Errorf("database open: %w", err)
}
// Connection pool tuning
DB.SetMaxOpenConns(25)
DB.SetMaxIdleConns(5)
@@ -35,10 +56,75 @@ func Connect(cfg *config.Config) error {
return fmt.Errorf("database ping: %w", err)
}
log.Println("✅ Database connected")
CurrentDialect = DialectPostgres
log.Println("✅ Database connected (postgres)")
return nil
}
// connectSQLite opens a SQLite database with WAL mode enabled.
func connectSQLite(dsn string) error {
if dsn == "" {
dsn = "switchboard.db"
}
// Ensure parent directory exists for file-based DBs.
if dsn != ":memory:" && !strings.HasPrefix(dsn, "file:") {
dir := filepath.Dir(dsn)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create db directory %s: %w", dir, err)
}
}
}
// modernc.org/sqlite uses "sqlite" as the driver name.
// Append pragmas via query string for file paths.
// _time_format tells the driver how to convert between Go time.Time and SQLite TEXT timestamps.
connStr := dsn
if dsn != ":memory:" && !strings.Contains(dsn, "?") {
connStr = fmt.Sprintf("file:%s?_pragma=journal_mode%%3Dwal&_pragma=busy_timeout%%3D5000&_pragma=foreign_keys%%3Don&_time_format=2006-01-02T15%%3A04%%3A05Z", dsn)
} else if dsn == ":memory:" {
connStr = "file::memory:?_pragma=foreign_keys%%3Don&_time_format=2006-01-02T15%%3A04%%3A05Z"
}
var err error
DB, err = sql.Open("sqlite", connStr)
if err != nil {
return fmt.Errorf("database open: %w", err)
}
// SQLite performs best with a single writer.
DB.SetMaxOpenConns(1)
DB.SetMaxIdleConns(1)
if err := DB.Ping(); err != nil {
return fmt.Errorf("database ping: %w", err)
}
// Verify WAL mode took effect (some in-memory modes ignore it).
var mode string
DB.QueryRow("PRAGMA journal_mode").Scan(&mode)
log.Printf("✅ Database connected (sqlite, journal_mode=%s, path=%s)", mode, dsn)
CurrentDialect = DialectSQLite
return nil
}
// detectDriver infers the driver from DATABASE_URL format.
func detectDriver(dsn string) string {
if dsn == "" {
return "postgres"
}
if strings.HasPrefix(dsn, "postgres") {
return "postgres"
}
if strings.HasSuffix(dsn, ".db") || strings.HasSuffix(dsn, ".sqlite") ||
dsn == ":memory:" || strings.HasPrefix(dsn, "file:") {
return "sqlite"
}
return "postgres"
}
// Close gracefully shuts down the connection pool.
func Close() {
if DB != nil {

View File

@@ -0,0 +1,31 @@
package database
// Dialect identifies the active database backend.
type Dialect int
const (
DialectPostgres Dialect = iota
DialectSQLite
)
// CurrentDialect is set during Connect() and read by store constructors
// to select the appropriate SQL flavour.
var CurrentDialect Dialect
// String returns a human-readable label.
func (d Dialect) String() string {
switch d {
case DialectPostgres:
return "postgres"
case DialectSQLite:
return "sqlite"
default:
return "unknown"
}
}
// IsPostgres returns true when connected to PostgreSQL.
func IsPostgres() bool { return CurrentDialect == DialectPostgres }
// IsSQLite returns true when connected to SQLite.
func IsSQLite() bool { return CurrentDialect == DialectSQLite }

View File

@@ -16,26 +16,31 @@ var schemaVersion string = "none"
// SchemaVersion returns the current schema version string.
func SchemaVersion() string { return schemaVersion }
// Migrate runs all pending migrations. It creates the schema_migrations
// tracking table if it doesn't exist, then applies each .sql file that
// hasn't been applied yet, in order.
// Migrate runs all pending migrations for the current dialect.
// Migration files live in migrations/<dialect>/ subdirectories.
// Falls back to the root migrations/ directory if no subdirectory exists
// (backward compatible with pre-v0.17.1 postgres-only layouts).
func Migrate() error {
if DB == nil {
return fmt.Errorf("database not connected")
}
// Ensure tracking table exists
_, err := DB.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
// Create tracking table — syntax compatible with both drivers.
createMigrations := `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT DEFAULT (datetime('now'))
)`
if IsPostgres() {
createMigrations = `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
`)
if err != nil {
)`
}
if _, err := DB.Exec(createMigrations); err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
// Find migration files
// Locate migration files for current dialect.
migrationsDir := findMigrationsDir()
if migrationsDir == "" {
log.Println("⚠ No migrations directory found — skipping schema migration")
@@ -47,7 +52,6 @@ func Migrate() error {
return fmt.Errorf("read migrations dir: %w", err)
}
// Collect and sort .sql files
var files []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
@@ -62,14 +66,15 @@ func Migrate() error {
}
// Compat: rename old numeric-only version entries to full filenames.
// Earlier extractVersion used regex ^(\d+), recording "001" instead of
// "001_v09_schema.sql". Fix them in-place so CI's db-migrate.sh matches.
for _, file := range files {
prefix := strings.SplitN(file, "_", 2)[0] // "001"
DB.Exec("UPDATE schema_migrations SET version = $1 WHERE version = $2 AND version != $1", file, prefix)
prefix := strings.SplitN(file, "_", 2)[0]
DB.Exec("UPDATE schema_migrations SET version = ? WHERE version = ? AND version != ?", file, prefix, file)
if IsPostgres() {
DB.Exec("UPDATE schema_migrations SET version = $1 WHERE version = $2 AND version != $1", file, prefix)
}
}
// Apply pending migrations
// Apply pending migrations.
applied := 0
for _, file := range files {
version := extractVersion(file)
@@ -77,29 +82,36 @@ func Migrate() error {
continue
}
// Check if already applied
var exists bool
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
if IsPostgres() {
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
} else {
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", version).Scan(&exists)
}
if exists {
schemaVersion = version
continue
}
// Read and execute
path := filepath.Join(migrationsDir, file)
sql, err := os.ReadFile(path)
sqlBytes, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", file, err)
}
log.Printf(" Applying migration %s...", file)
if _, err := DB.Exec(string(sql)); err != nil {
if _, err := DB.Exec(string(sqlBytes)); err != nil {
return fmt.Errorf("apply %s: %w", file, err)
}
// Record
if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil {
return fmt.Errorf("record %s: %w", file, err)
if IsPostgres() {
if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil {
return fmt.Errorf("record %s: %w", file, err)
}
} else {
if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
return fmt.Errorf("record %s: %w", file, err)
}
}
schemaVersion = version
@@ -117,38 +129,49 @@ func Migrate() error {
// extractVersion returns the filename as the version key if it's a valid
// migration file (starts with digit, ends with .sql).
// "001_v09_schema.sql" → "001_v09_schema.sql" (matches db-migrate.sh convention)
func extractVersion(filename string) string {
// Use full filename as version to match db-migrate.sh convention
if !strings.HasSuffix(filename, ".sql") {
return ""
}
// Must start with a digit (e.g., 001_v09_schema.sql)
if len(filename) == 0 || filename[0] < '0' || filename[0] > '9' {
return ""
}
return filename
}
// findMigrationsDir locates the migrations directory.
// Checks relative to the binary, then relative to the source file.
// findMigrationsDir locates the dialect-specific migrations directory.
// Looks for migrations/<dialect>/ first, then falls back to migrations/.
func findMigrationsDir() string {
candidates := []string{
dialectDir := CurrentDialect.String() // "postgres" or "sqlite"
// Candidates: dialect-specific first, then generic.
bases := []string{
"database/migrations",
"server/database/migrations",
"../database/migrations",
}
// Also check relative to this source file (for tests)
// Also check relative to this source file (for tests).
_, thisFile, _, ok := runtime.Caller(0)
if ok {
dir := filepath.Dir(thisFile)
candidates = append(candidates, filepath.Join(dir, "migrations"))
bases = append(bases, filepath.Join(dir, "migrations"))
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && info.IsDir() {
return c
// Try dialect-specific subdir first.
for _, base := range bases {
candidate := filepath.Join(base, dialectDir)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
// Fall back to root migrations/ (backward compat for postgres).
if IsPostgres() {
for _, base := range bases {
if info, err := os.Stat(base); err == nil && info.IsDir() {
return base
}
}
}

View File

@@ -0,0 +1,22 @@
-- Schema patch for 001_v017_schema.sql
-- Change the kb_chunks table to include the embedding column:
--
-- BEFORE:
-- CREATE TABLE IF NOT EXISTS kb_chunks (
-- ...
-- token_count INTEGER NOT NULL DEFAULT 0,
-- -- embedding column omitted: vector search feature-gated for SQLite.
-- -- Embedding bytes can be stored as BLOB if sqlite-vec is available.
-- metadata TEXT NOT NULL DEFAULT '{}',
-- ...
--
-- AFTER:
-- CREATE TABLE IF NOT EXISTS kb_chunks (
-- ...
-- token_count INTEGER NOT NULL DEFAULT 0,
-- embedding TEXT, -- JSON array of float64 for app-level cosine similarity
-- metadata TEXT NOT NULL DEFAULT '{}',
-- ...
--
-- For existing SQLite databases, run:
-- ALTER TABLE kb_chunks ADD COLUMN embedding TEXT;

View File

@@ -0,0 +1,791 @@
-- ==========================================
-- Chat Switchboard — v0.16.0 Consolidated Schema (SQLite)
-- ==========================================
-- SQLite equivalent of the Postgres schema.
--
-- Key differences from Postgres:
-- • No extensions (pgcrypto, pgvector)
-- • UUID generated application-side (Go uuid.New())
-- • JSONB → TEXT (JSON stored as text)
-- • TEXT[] / UUID[] → TEXT (JSON arrays as text)
-- • TIMESTAMPTZ → TEXT (ISO 8601 strings)
-- • BYTEA → BLOB
-- • NUMERIC → REAL
-- • TSVECTOR/VECTOR columns omitted (feature-gated)
-- • No COMMENT ON statements
-- • Triggers use SQLite syntax (WHEN guard to prevent recursion)
-- • No partial index WHERE clauses on GIN indexes
-- ==========================================
-- ── WAL mode (also set via connection pragma) ──
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- =========================================
-- 1. USERS
-- =========================================
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
role TEXT DEFAULT 'user'
CHECK (role IN ('user', 'admin')),
is_active INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
-- Vault: per-user encryption key (UEK) for BYOK API keys
encrypted_uek BLOB,
uek_salt BLOB,
uek_nonce BLOB,
vault_set INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
last_login_at TEXT
);
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);
CREATE TRIGGER IF NOT EXISTS users_updated_at AFTER UPDATE ON users
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 2. AUTH
-- =========================================
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash)
WHERE revoked_at IS NULL;
-- =========================================
-- 3. TEAMS
-- =========================================
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT DEFAULT '',
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = 1;
CREATE TRIGGER IF NOT EXISTS teams_updated_at AFTER UPDATE ON teams
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE teams SET updated_at = datetime('now') WHERE id = NEW.id;
END;
CREATE TABLE IF NOT EXISTS team_members (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member'
CHECK (role IN ('admin', 'member')),
joined_at TEXT DEFAULT (datetime('now')),
UNIQUE(team_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
-- =========================================
-- 4. GROUPS
-- =========================================
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'global'
CHECK (scope IN ('global', 'team')),
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
CONSTRAINT groups_scope_team CHECK (
(scope = 'global' AND team_id IS NULL) OR
(scope = 'team' AND team_id IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'));
CREATE TRIGGER IF NOT EXISTS groups_updated_at AFTER UPDATE ON groups
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE groups SET updated_at = datetime('now') WHERE id = NEW.id;
END;
CREATE TABLE IF NOT EXISTS group_members (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
added_by TEXT NOT NULL REFERENCES users(id),
added_at TEXT DEFAULT (datetime('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
-- =========================================
CREATE TABLE IF NOT EXISTS provider_configs (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL
CHECK (scope IN ('global', 'team', 'personal')),
owner_id TEXT,
name TEXT NOT NULL,
provider TEXT NOT NULL,
endpoint TEXT NOT NULL,
api_key_enc BLOB,
key_nonce BLOB,
key_scope TEXT NOT NULL DEFAULT 'global',
model_default TEXT,
config TEXT DEFAULT '{}',
headers TEXT DEFAULT '{}',
settings TEXT DEFAULT '{}',
is_active INTEGER DEFAULT 1,
is_private INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
CREATE INDEX IF NOT EXISTS idx_provider_configs_owner ON provider_configs(owner_id)
WHERE owner_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_provider_configs_active ON provider_configs(is_active)
WHERE is_active = 1;
CREATE TRIGGER IF NOT EXISTS provider_configs_updated_at AFTER UPDATE ON provider_configs
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE provider_configs SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 6. MODEL CATALOG
-- =========================================
CREATE TABLE IF NOT EXISTS model_catalog (
id TEXT PRIMARY KEY,
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
display_name TEXT,
model_type TEXT DEFAULT 'chat',
capabilities TEXT NOT NULL DEFAULT '{}',
pricing TEXT,
visibility TEXT DEFAULT 'disabled'
CHECK (visibility IN ('enabled', 'disabled', 'team')),
last_synced_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(provider_config_id, model_id)
);
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';
CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type);
CREATE TRIGGER IF NOT EXISTS model_catalog_updated_at AFTER UPDATE ON model_catalog
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE model_catalog SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 7. PERSONAS
-- =========================================
CREATE TABLE IF NOT EXISTS personas (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
icon TEXT DEFAULT '',
avatar TEXT DEFAULT '',
base_model_id TEXT NOT NULL,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
system_prompt TEXT DEFAULT '',
temperature REAL,
max_tokens INTEGER,
thinking_budget INTEGER,
top_p REAL,
scope TEXT NOT NULL
CHECK (scope IN ('global', 'team', 'personal')),
owner_id TEXT,
created_by TEXT NOT NULL REFERENCES users(id),
is_active INTEGER DEFAULT 1,
is_shared INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id) WHERE owner_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active) WHERE is_active = 1;
CREATE TRIGGER IF NOT EXISTS personas_updated_at AFTER UPDATE ON personas
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE personas SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 8. PERSONA GRANTS
-- =========================================
CREATE TABLE IF NOT EXISTS persona_grants (
id TEXT PRIMARY KEY,
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
grant_type TEXT NOT NULL,
grant_ref TEXT NOT NULL,
config TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(persona_id, grant_type, grant_ref)
);
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);
-- =========================================
-- 9. RESOURCE GRANTS
-- =========================================
-- granted_groups stored as JSON text array: '["uuid1","uuid2"]'
CREATE TABLE IF NOT EXISTS resource_grants (
id TEXT PRIMARY KEY,
resource_type TEXT NOT NULL
CHECK (resource_type IN ('persona', 'knowledge_base')),
resource_id TEXT NOT NULL,
grant_scope TEXT NOT NULL DEFAULT 'team_only'
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
granted_groups TEXT NOT NULL DEFAULT '[]',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(resource_type, resource_id)
);
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
ON resource_grants(resource_type, resource_id);
CREATE TRIGGER IF NOT EXISTS resource_grants_updated_at AFTER UPDATE ON resource_grants
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE resource_grants SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 10. PLATFORM POLICIES
-- =========================================
CREATE TABLE IF NOT EXISTS platform_policies (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_by TEXT REFERENCES users(id),
updated_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO platform_policies (key, value) VALUES
('allow_user_byok', 'false'),
('allow_user_personas', 'false'),
('allow_raw_model_access', 'true'),
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true');
-- =========================================
-- 11. GLOBAL SETTINGS
-- =========================================
CREATE TABLE IF NOT EXISTS global_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '{}',
updated_at TEXT DEFAULT (datetime('now')),
updated_by TEXT REFERENCES users(id)
);
INSERT OR IGNORE INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'),
('banner_presets', '{"development": {"text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff"}, "testing": {"text": "TESTING", "bg": "#502b85", "fg": "#ffffff"}, "staging": {"text": "STAGING", "bg": "#0033a0", "fg": "#ffffff"}, "production": {"text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff"}, "training": {"text": "TRAINING", "bg": "#ff8c00", "fg": "#000000"}, "demo": {"text": "DEMO", "bg": "#fce83a", "fg": "#000000"}}'),
('model_roles', '{"utility": {"primary": null, "fallback": null}, "embedding": {"primary": null, "fallback": null}, "generation": {"primary": null, "fallback": null}}');
-- =========================================
-- 12. USER MODEL SETTINGS
-- =========================================
CREATE TABLE IF NOT EXISTS user_model_settings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
hidden INTEGER DEFAULT 0,
preferred_temperature REAL,
preferred_max_tokens INTEGER,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
CREATE TRIGGER IF NOT EXISTS user_model_settings_updated_at AFTER UPDATE ON user_model_settings
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE user_model_settings SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 13. CHANNELS
-- =========================================
-- tags stored as JSON text array: '["tag1","tag2"]'
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
type TEXT DEFAULT 'direct'
CHECK (type IN ('direct', 'group', 'channel')),
model TEXT,
system_prompt TEXT,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
is_archived INTEGER DEFAULT 0,
is_pinned INTEGER DEFAULT 0,
folder_id TEXT,
folder TEXT,
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
settings TEXT DEFAULT '{}',
tags TEXT DEFAULT '[]',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
CREATE TRIGGER IF NOT EXISTS channels_updated_at AFTER UPDATE ON channels
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE channels SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 14. MESSAGES
-- =========================================
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role TEXT NOT NULL
CHECK (role IN ('user', 'assistant', 'system', 'tool')),
content TEXT NOT NULL,
model TEXT,
tokens_used INTEGER,
tool_calls TEXT,
metadata TEXT DEFAULT '{}',
parent_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
sibling_index INTEGER DEFAULT 0,
participant_type TEXT DEFAULT 'user',
participant_id TEXT,
deleted_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index)
WHERE deleted_at IS NULL;
-- =========================================
-- 15. CHANNEL MEMBERS & MODELS
-- =========================================
CREATE TABLE IF NOT EXISTS channel_members (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member',
joined_at TEXT DEFAULT (datetime('now')),
last_read_at TEXT DEFAULT (datetime('now')),
UNIQUE(channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id);
CREATE TABLE IF NOT EXISTS channel_models (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
display_name TEXT,
system_prompt TEXT,
settings TEXT DEFAULT '{}',
is_default INTEGER DEFAULT 0,
added_at TEXT DEFAULT (datetime('now')),
UNIQUE(channel_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
-- =========================================
-- 16. CHANNEL CURSORS
-- =========================================
CREATE TABLE IF NOT EXISTS channel_cursors (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
active_leaf_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
-- =========================================
-- 17. FOLDERS
-- =========================================
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, name, parent_id)
);
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
CREATE TRIGGER IF NOT EXISTS folders_updated_at AFTER UPDATE ON folders
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE folders SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 18. NOTES
-- =========================================
-- search_vector and embedding columns omitted (Postgres-only features).
-- Full-text search uses LIKE fallback. Semantic search feature-gated.
-- tags stored as JSON text array.
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT DEFAULT '',
folder_path TEXT DEFAULT '/',
tags TEXT DEFAULT '[]',
metadata TEXT DEFAULT '{}',
source_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
CREATE INDEX IF NOT EXISTS idx_notes_folder ON notes(user_id, folder_path);
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL;
CREATE TRIGGER IF NOT EXISTS notes_updated_at AFTER UPDATE ON notes
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE notes SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 19. AUDIT LOG
-- =========================================
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
metadata TEXT DEFAULT '{}',
ip_address TEXT,
user_agent TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
-- =========================================
-- 20. USAGE TRACKING
-- =========================================
CREATE TABLE IF NOT EXISTS usage_log (
id TEXT PRIMARY KEY,
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
provider_scope TEXT NOT NULL DEFAULT 'global',
model_id TEXT NOT NULL,
role TEXT,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cost_input REAL,
cost_output REAL,
created_at TEXT NOT NULL DEFAULT (datetime('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 TEXT PRIMARY KEY,
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
input_per_m REAL,
output_per_m REAL,
cache_create_per_m REAL,
cache_read_per_m REAL,
currency TEXT NOT NULL DEFAULT 'USD',
source TEXT NOT NULL DEFAULT 'manual',
updated_at TEXT DEFAULT (datetime('now')),
updated_by TEXT REFERENCES users(id),
UNIQUE(provider_config_id, model_id)
);
-- =========================================
-- 22. EXTENSIONS
-- =========================================
CREATE TABLE IF NOT EXISTS extensions (
id TEXT PRIMARY KEY,
ext_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '0.0.0',
tier TEXT NOT NULL DEFAULT 'browser',
description TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
manifest TEXT NOT NULL DEFAULT '{}',
is_system INTEGER NOT NULL DEFAULT 0,
is_enabled INTEGER NOT NULL DEFAULT 1,
scope TEXT NOT NULL DEFAULT 'global',
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 = 1;
CREATE TABLE IF NOT EXISTS extension_user_settings (
extension_id TEXT NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings TEXT NOT NULL DEFAULT '{}',
is_enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (extension_id, user_id)
);
-- =========================================
-- 23. ATTACHMENTS
-- =========================================
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
storage_key TEXT NOT NULL,
extracted_text TEXT,
metadata TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('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
-- =========================================
-- Vector columns: kb_chunks.embedding stored as JSON TEXT for app-level cosine similarity.
-- search_vector columns omitted (Postgres-only tsvector feature).
-- KB ingestion works (stores text chunks) but similarity search is
-- feature-gated: requires sqlite-vec extension or returns graceful error.
CREATE TABLE IF NOT EXISTS knowledge_bases (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'global',
owner_id TEXT REFERENCES users(id) ON DELETE CASCADE,
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
embedding_config TEXT NOT NULL DEFAULT '{}',
document_count INTEGER NOT NULL DEFAULT 0,
chunk_count INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
discoverable INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT PRIMARY KEY,
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
storage_key TEXT NOT NULL,
extracted_text TEXT,
chunk_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
uploaded_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id);
CREATE TABLE IF NOT EXISTS kb_chunks (
id TEXT PRIMARY KEY,
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
document_id TEXT NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
token_count INTEGER NOT NULL DEFAULT 0,
embedding TEXT, -- JSON array of float64 for app-level cosine similarity
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('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);
CREATE TABLE IF NOT EXISTS channel_knowledge_bases (
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 1,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (channel_id, kb_id)
);
-- =========================================
-- 25. PERSONA-KB BINDING (v0.17.0)
-- =========================================
CREATE TABLE IF NOT EXISTS persona_knowledge_bases (
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
auto_search INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (persona_id, kb_id)
);
CREATE INDEX IF NOT EXISTS idx_persona_kb_persona ON persona_knowledge_bases(persona_id);
CREATE INDEX IF NOT EXISTS idx_persona_kb_kb ON persona_knowledge_bases(kb_id);
-- v0.17.0 policy
INSERT OR IGNORE INTO platform_policies (key, value) VALUES
('kb_direct_access', 'true');

View File

@@ -8,7 +8,9 @@ import (
"strings"
"testing"
"github.com/google/uuid"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
)
// TestDB holds a connection to the test database.
@@ -17,9 +19,9 @@ var TestDB *sql.DB
const testDBName = "chat_switchboard_ci"
// SetupTestDB connects to PostgreSQL, creates the CI test database (or
// connects to an existing one created by CI bootstrap), runs all
// migrations, and sets database.DB so handlers can use it.
// SetupTestDB connects to the appropriate database backend (Postgres or
// SQLite based on DB_DRIVER env), runs migrations, and sets database.DB
// so handlers can use it.
//
// Call in TestMain:
//
@@ -30,11 +32,78 @@ const testDBName = "chat_switchboard_ci"
// os.Exit(code)
// }
//
// Requires env: TEST_DATABASE_URL (full DSN to maintenance DB, e.g. postgres)
// OR individual: PGHOST, PGPORT, PGUSER, PGPASSWORD
// For Postgres: requires PGHOST+PGUSER or TEST_DATABASE_URL.
// For SQLite: set DB_DRIVER=sqlite (uses temp file, no external deps).
//
// Returns a cleanup function that drops the test database.
// Returns a cleanup function.
func SetupTestDB() func() {
driver := os.Getenv("DB_DRIVER")
if driver == "sqlite" {
return setupSQLiteTestDB()
}
return setupPostgresTestDB()
}
// ── SQLite test setup ───────────────────────
func setupSQLiteTestDB() func() {
CurrentDialect = DialectSQLite
// Use a temp file so multiple connections share the same DB.
tmpFile, err := os.CreateTemp("", "switchboard-test-*.db")
if err != nil {
log.Printf("⚠ Cannot create temp SQLite DB: %v — skipping DB tests", err)
return func() {}
}
dbPath := tmpFile.Name()
tmpFile.Close()
DB, err = sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
log.Printf("⚠ Cannot open SQLite DB: %v — skipping DB tests", err)
os.Remove(dbPath)
return func() {}
}
if err := DB.Ping(); err != nil {
DB.Close()
DB = nil
log.Printf("⚠ Cannot ping SQLite DB: %v — skipping DB tests", err)
os.Remove(dbPath)
return func() {}
}
// SQLite: single writer
DB.SetMaxOpenConns(1)
TestDB = DB
// Run SQLite migrations
if err := Migrate(); err != nil {
DB.Close()
DB = nil
TestDB = nil
os.Remove(dbPath)
log.Fatalf("❌ SQLite migration failed on test DB: %v", err)
}
log.Printf("✓ SQLite test database ready: %s", dbPath)
return func() {
if DB != nil {
DB.Close()
DB = nil
TestDB = nil
}
os.Remove(dbPath)
log.Printf("✓ Removed SQLite test database: %s", dbPath)
}
}
// ── Postgres test setup (original) ──────────
func setupPostgresTestDB() func() {
CurrentDialect = DialectPostgres
mainDSN := os.Getenv("TEST_DATABASE_URL")
host := envOr("PGHOST", "")
port := envOr("PGPORT", "5432")
@@ -50,7 +119,6 @@ func SetupTestDB() func() {
host, port, user, pass)
}
// Try to connect to admin/maintenance DB for create/drop operations
adminDB, err := sql.Open("postgres", mainDSN)
if err != nil {
log.Printf("⚠ Cannot connect to admin DB: %v — skipping DB tests", err)
@@ -62,15 +130,10 @@ func SetupTestDB() func() {
return func() {}
}
// Check if test DB already exists (e.g. created by CI bootstrap with
// admin privileges and extensions like pgvector already installed).
// Only create if it doesn't exist — never drop, because recreating
// as the app user would lose extensions that require superuser.
createdByUs := false
var dbExists bool
err = adminDB.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", testDBName).Scan(&dbExists)
if err != nil {
// DB doesn't exist — create it
if _, err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)); err != nil {
log.Printf("⚠ Cannot CREATE DATABASE %s (will try to connect to existing): %v", testDBName, err)
} else {
@@ -81,7 +144,6 @@ func SetupTestDB() func() {
log.Printf("✓ Test database %s already exists (keeping extensions)", testDBName)
}
// Build DSN for the test DB
var testDSN string
if host != "" {
testDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
@@ -90,7 +152,6 @@ func SetupTestDB() func() {
testDSN = replaceDBName(mainDSN, testDBName)
}
// Connect to test DB
DB, err = sql.Open("postgres", testDSN)
if err != nil {
adminDB.Close()
@@ -106,7 +167,6 @@ func SetupTestDB() func() {
}
TestDB = DB
// Ensure required extensions (may already exist from CI bootstrap)
for _, ext := range []string{"uuid-ossp", "pgcrypto", "vector"} {
DB.Exec(fmt.Sprintf(`CREATE EXTENSION IF NOT EXISTS "%s"`, ext))
}
@@ -121,7 +181,6 @@ func SetupTestDB() func() {
END $$;
`)
// Run all migrations
if err := Migrate(); err != nil {
DB.Close()
DB = nil
@@ -133,7 +192,6 @@ func SetupTestDB() func() {
log.Fatalf("❌ Migration failed on test DB: %v", err)
}
// Return cleanup
return func() {
if DB != nil {
DB.Close()
@@ -148,22 +206,52 @@ func SetupTestDB() func() {
}
}
// ── Dialect helpers ─────────────────────────
// PH returns a placeholder for the Nth parameter (1-indexed).
// Postgres: $1, $2, ... SQLite: ?, ?, ...
// Exported for use by test files in other packages (e.g. handlers).
func PH(n int) string {
if IsSQLite() {
return "?"
}
return fmt.Sprintf("$%d", n)
}
// placeholders returns "?, ?, ?" or "$1, $2, $3" for n params.
func placeholders(n int) string {
parts := make([]string, n)
for i := range parts {
parts[i] = PH(i + 1)
}
return strings.Join(parts, ", ")
}
// nowSQL returns the SQL expression for "current timestamp".
func nowSQL() string {
if IsSQLite() {
return "datetime('now')"
}
return "NOW()"
}
// ── Shared test helpers ─────────────────────
// RequireTestDB skips a test if no test database is available.
func RequireTestDB(t *testing.T) {
t.Helper()
if DB == nil || TestDB == nil {
t.Skip("requires TEST_DATABASE_URL or PGHOST+PGUSER environment variables")
t.Skip("requires TEST_DATABASE_URL or PGHOST+PGUSER environment variables (or DB_DRIVER=sqlite)")
}
}
// TruncateAll truncates all application tables for test isolation.
// Faster than recreating the DB for each test.
func TruncateAll(t *testing.T) {
t.Helper()
if DB == nil {
return
}
// Order matters due to foreign keys — truncate with CASCADE
tables := []string{
"resource_grants",
"group_members",
@@ -177,6 +265,7 @@ func TruncateAll(t *testing.T) {
"channel_models",
"channel_members",
"channel_knowledge_bases",
"persona_knowledge_bases",
"kb_chunks",
"kb_documents",
"knowledge_bases",
@@ -192,44 +281,87 @@ func TruncateAll(t *testing.T) {
"users",
"platform_policies",
"global_config",
}
for _, table := range tables {
DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
"global_settings",
"folders",
"attachments",
"extension_user_settings",
"extensions",
}
// Re-seed global_settings — CASCADE from users wipes rows with updated_by FK.
// Re-run the seed SQL from migrations 001 + 004.
DB.Exec(`
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'::jsonb),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'::jsonb),
('banner_presets', '{}'::jsonb),
('model_roles', '{
"utility": { "primary": null, "fallback": null },
"embedding": { "primary": null, "fallback": null },
"generation": { "primary": null, "fallback": null }
}'::jsonb)
ON CONFLICT (key) DO NOTHING
`)
if IsSQLite() {
DB.Exec("PRAGMA foreign_keys = OFF")
for _, table := range tables {
DB.Exec(fmt.Sprintf("DELETE FROM %s", table))
}
DB.Exec("PRAGMA foreign_keys = ON")
} else {
for _, table := range tables {
DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
}
}
// Re-seed platform_policies — also wiped by CASCADE from users truncation
// (platform_policies.updated_by REFERENCES users(id)).
DB.Exec(`
INSERT INTO platform_policies (key, value) VALUES
('allow_user_byok', 'false'),
('allow_user_personas', 'false'),
('allow_raw_model_access', 'true'),
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true')
ON CONFLICT (key) DO NOTHING
`)
// Re-seed default config rows.
if IsSQLite() {
DB.Exec(`
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'),
('banner_presets', '{}'),
('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}')
ON CONFLICT (key) DO NOTHING
`)
DB.Exec(`
INSERT INTO platform_policies (key, value) VALUES
('allow_user_byok', 'false'),
('allow_user_personas', 'false'),
('allow_raw_model_access', 'true'),
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true')
ON CONFLICT (key) DO NOTHING
`)
} else {
DB.Exec(`
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'::jsonb),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'::jsonb),
('banner_presets', '{}'::jsonb),
('model_roles', '{
"utility": { "primary": null, "fallback": null },
"embedding": { "primary": null, "fallback": null },
"generation": { "primary": null, "fallback": null }
}'::jsonb)
ON CONFLICT (key) DO NOTHING
`)
DB.Exec(`
INSERT INTO platform_policies (key, value) VALUES
('allow_user_byok', 'false'),
('allow_user_personas', 'false'),
('allow_raw_model_access', 'true'),
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true')
ON CONFLICT (key) DO NOTHING
`)
}
}
// SeedTestUser creates a test user and returns the user ID.
func SeedTestUser(t *testing.T, username, email string) string {
t.Helper()
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`
INSERT INTO users (id, username, email, password_hash, role)
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user')
`, id, username, email)
if err != nil {
t.Fatalf("SeedTestUser: %v", err)
}
return id
}
var id string
err := DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role)
@@ -245,29 +377,35 @@ func SeedTestUser(t *testing.T, username, email string) string {
// SeedTestChannel creates a test channel owned by userID and returns the channel ID.
func SeedTestChannel(t *testing.T, userID, title string) string {
t.Helper()
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`INSERT INTO channels (id, user_id, title) VALUES (?, ?, ?)`, id, userID, title)
if err != nil {
t.Fatalf("SeedTestChannel: %v", err)
}
return id
}
var id string
err := DB.QueryRow(`
INSERT INTO channels (user_id, title)
VALUES ($1, $2)
RETURNING id
`, userID, title).Scan(&id)
err := DB.QueryRow(`INSERT INTO channels (user_id, title) VALUES ($1, $2) RETURNING id`, userID, title).Scan(&id)
if err != nil {
t.Fatalf("SeedTestChannel: %v", err)
}
return id
}
// ── Helpers ─────────────────────────────────
// SeedTestTeam creates a test team and returns the team ID.
func SeedTestTeam(t *testing.T, name, createdBy string) string {
t.Helper()
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`INSERT INTO teams (id, name, created_by) VALUES (?, ?, ?)`, id, name, createdBy)
if err != nil {
t.Fatalf("SeedTestTeam: %v", err)
}
return id
}
var id string
err := DB.QueryRow(`
INSERT INTO teams (name, created_by)
VALUES ($1, $2)
RETURNING id
`, name, createdBy).Scan(&id)
err := DB.QueryRow(`INSERT INTO teams (name, created_by) VALUES ($1, $2) RETURNING id`, name, createdBy).Scan(&id)
if err != nil {
t.Fatalf("SeedTestTeam: %v", err)
}
@@ -277,10 +415,19 @@ func SeedTestTeam(t *testing.T, name, createdBy string) string {
// SeedTestTeamMember adds a user to a team.
func SeedTestTeamMember(t *testing.T, teamID, userID, role string) {
t.Helper()
_, err := DB.Exec(`
if IsSQLite() {
_, err := DB.Exec(`INSERT INTO team_members (id, team_id, user_id, role) VALUES (?, ?, ?, ?)`,
uuid.New().String(), teamID, userID, role)
if err != nil {
t.Fatalf("SeedTestTeamMember: %v", err)
}
return
}
q := fmt.Sprintf(`
INSERT INTO team_members (team_id, user_id, role)
VALUES ($1, $2, $3)
`, teamID, userID, role)
VALUES (%s, %s, %s)
`, PH(1), PH(2), PH(3))
_, err := DB.Exec(q, teamID, userID, role)
if err != nil {
t.Fatalf("SeedTestTeamMember: %v", err)
}
@@ -289,16 +436,22 @@ func SeedTestTeamMember(t *testing.T, teamID, userID, role string) {
// SeedTestGroup creates a test group and returns the group ID.
func SeedTestGroup(t *testing.T, name, scope string, teamID *string, createdBy string) string {
t.Helper()
var id string
var teamArg interface{}
if teamID != nil {
teamArg = *teamID
}
err := DB.QueryRow(`
INSERT INTO groups (name, scope, team_id, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id
`, name, scope, teamArg, createdBy).Scan(&id)
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`INSERT INTO groups (id, name, scope, team_id, created_by) VALUES (?, ?, ?, ?, ?)`,
id, name, scope, teamArg, createdBy)
if err != nil {
t.Fatalf("SeedTestGroup: %v", err)
}
return id
}
var id string
err := DB.QueryRow(`INSERT INTO groups (name, scope, team_id, created_by) VALUES ($1, $2, $3, $4) RETURNING id`,
name, scope, teamArg, createdBy).Scan(&id)
if err != nil {
t.Fatalf("SeedTestGroup: %v", err)
}
@@ -308,15 +461,26 @@ func SeedTestGroup(t *testing.T, name, scope string, teamID *string, createdBy s
// SeedGroupMember adds a user to a group.
func SeedGroupMember(t *testing.T, groupID, userID, addedBy string) {
t.Helper()
_, err := DB.Exec(`
if IsSQLite() {
_, err := DB.Exec(`INSERT INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`,
uuid.New().String(), groupID, userID, addedBy)
if err != nil {
t.Fatalf("SeedGroupMember: %v", err)
}
return
}
q := fmt.Sprintf(`
INSERT INTO group_members (group_id, user_id, added_by)
VALUES ($1, $2, $3)
`, groupID, userID, addedBy)
VALUES (%s, %s, %s)
`, PH(1), PH(2), PH(3))
_, err := DB.Exec(q, groupID, userID, addedBy)
if err != nil {
t.Fatalf("SeedGroupMember: %v", err)
}
}
// ── Internal helpers ────────────────────────
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
@@ -324,9 +488,7 @@ func envOr(key, fallback string) string {
return fallback
}
// replaceDBName swaps the dbname in a DSN string.
func replaceDBName(dsn, newDB string) string {
// Handle key=value format
if strings.Contains(dsn, "dbname=") {
parts := strings.Fields(dsn)
for i, p := range parts {
@@ -336,7 +498,6 @@ func replaceDBName(dsn, newDB string) string {
}
}
}
// Handle URL format: postgres://user:pass@host/olddb?...
if strings.Contains(dsn, "://") {
idx := strings.LastIndex(dsn, "/")
qIdx := strings.Index(dsn, "?")
@@ -345,6 +506,5 @@ func replaceDBName(dsn, newDB string) string {
}
return dsn[:idx+1] + newDB
}
// Fallback: append dbname
return dsn + " dbname=" + newDB
}