Split Connection Pools: - writeDB: MaxOpenConns=1, serializes all writes (no SQLITE_BUSY) - readDB: MaxOpenConns=8, query_only=ON, for all SELECTs - QueryDB() helper returns read pool when available SQL Query Interface: - New 'query' action via execute MCP tool - Read-only enforcement (PRAGMA query_only=ON + SQL validation) - Curated views: my_messages, my_channels, channel_messages - Per-agent access control via CTE injection - Auto LIMIT 100, 5s timeout, SELECT-only validation - Blocks: INSERT, UPDATE, DELETE, DROP, PRAGMA, etc. - 12 new tests (access control, validation, limits, CTEs) Migration 016: agent query views (v_agent_messages, etc.) Action registry: 30 actions (was 29, added 'query') All 29 test packages pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
146 lines
3.5 KiB
Go
146 lines
3.5 KiB
Go
// Package storage provides SQLite storage layer for SynapBus.
|
|
package storage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// DB wraps a write-only *sql.DB and an optional read-only *sql.DB
|
|
// for split connection pool architecture. The write pool has MaxOpenConns=1
|
|
// to serialize writes and eliminate SQLITE_BUSY errors. The read pool has
|
|
// MaxOpenConns=8 and query_only=ON for safe concurrent reads.
|
|
type DB struct {
|
|
*sql.DB // Write pool (MaxOpenConns=1)
|
|
ReadDB *sql.DB // Read pool (MaxOpenConns=8, query_only=ON) — nil for :memory: DBs
|
|
}
|
|
|
|
// New opens a SQLite database with WAL mode, split read/write pools, and foreign keys.
|
|
// If dataDir is empty or ":memory:", an in-memory database is used (single pool, no split).
|
|
func New(ctx context.Context, dataDir string) (*DB, error) {
|
|
var dsn string
|
|
isMemory := dataDir == "" || dataDir == ":memory:"
|
|
|
|
if isMemory {
|
|
dsn = ":memory:"
|
|
} else {
|
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create data directory: %w", err)
|
|
}
|
|
dsn = filepath.Join(dataDir, "synapbus.db")
|
|
}
|
|
|
|
// Open WRITE pool (single connection, serializes all writes)
|
|
writeDB, err := openPool(ctx, dsn, poolConfig{
|
|
maxOpen: 1,
|
|
maxIdle: 1,
|
|
queryOnly: false,
|
|
label: "write",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open write pool: %w", err)
|
|
}
|
|
|
|
result := &DB{DB: writeDB}
|
|
|
|
// For file-based databases, open a separate READ pool
|
|
if !isMemory {
|
|
readDB, err := openPool(ctx, dsn, poolConfig{
|
|
maxOpen: 8,
|
|
maxIdle: 4,
|
|
queryOnly: true,
|
|
label: "read",
|
|
})
|
|
if err != nil {
|
|
writeDB.Close()
|
|
return nil, fmt.Errorf("open read pool: %w", err)
|
|
}
|
|
result.ReadDB = readDB
|
|
}
|
|
|
|
// Verify settings on write pool
|
|
var journalMode string
|
|
if err := writeDB.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
|
result.Close()
|
|
return nil, fmt.Errorf("verify journal_mode: %w", err)
|
|
}
|
|
|
|
slog.Info("database opened",
|
|
"dsn", dsn,
|
|
"journal_mode", journalMode,
|
|
"write_pool", "MaxOpenConns=1",
|
|
"read_pool_enabled", result.ReadDB != nil,
|
|
)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
type poolConfig struct {
|
|
maxOpen int
|
|
maxIdle int
|
|
queryOnly bool
|
|
label string
|
|
}
|
|
|
|
func openPool(ctx context.Context, dsn string, cfg poolConfig) (*sql.DB, error) {
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %s pool: %w", cfg.label, err)
|
|
}
|
|
|
|
db.SetMaxOpenConns(cfg.maxOpen)
|
|
db.SetMaxIdleConns(cfg.maxIdle)
|
|
|
|
pragmas := []string{
|
|
"PRAGMA journal_mode=WAL",
|
|
"PRAGMA busy_timeout=15000",
|
|
"PRAGMA foreign_keys=ON",
|
|
"PRAGMA synchronous=NORMAL",
|
|
"PRAGMA wal_autocheckpoint=1000",
|
|
}
|
|
if cfg.queryOnly {
|
|
pragmas = append(pragmas, "PRAGMA query_only=ON")
|
|
}
|
|
|
|
for _, pragma := range pragmas {
|
|
if _, err := db.ExecContext(ctx, pragma); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("execute %s: %w", pragma, err)
|
|
}
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// QueryDB returns the read pool if available, otherwise falls back to the write pool.
|
|
// Use this for all SELECT queries to avoid blocking writers.
|
|
func (db *DB) QueryDB() *sql.DB {
|
|
if db.ReadDB != nil {
|
|
return db.ReadDB
|
|
}
|
|
return db.DB
|
|
}
|
|
|
|
// Close closes both the write and read database connections.
|
|
func (db *DB) Close() error {
|
|
var errs []error
|
|
if db.ReadDB != nil {
|
|
if err := db.ReadDB.Close(); err != nil {
|
|
errs = append(errs, fmt.Errorf("close read pool: %w", err))
|
|
}
|
|
}
|
|
if err := db.DB.Close(); err != nil {
|
|
errs = append(errs, fmt.Errorf("close write pool: %w", err))
|
|
}
|
|
if len(errs) > 0 {
|
|
return errs[0]
|
|
}
|
|
return nil
|
|
}
|