Changeset 0.17.1 (#76)
This commit is contained in:
209
server/database/compat.go
Normal file
209
server/database/compat.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
31
server/database/dialect.go
Normal file
31
server/database/dialect.go
Normal 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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
server/database/migrations/sqlite/001_embedding_patch.sql
Normal file
22
server/database/migrations/sqlite/001_embedding_patch.sql
Normal 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;
|
||||
791
server/database/migrations/sqlite/001_v017_schema.sql
Normal file
791
server/database/migrations/sqlite/001_v017_schema.sql
Normal 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');
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user