package tenant import ( "database/sql" "fmt" "os" "path/filepath" _ "modernc.org/sqlite" ) func openDB(dataDir, tenantID string, inMemory bool) (*sql.DB, error) { var dsn string if inMemory { // named in-memory DB with shared cache so all 'connections' share the same database dsn = "file:" + tenantID + "?mode=memory&cache=shared&_pragma=busy_timeout(5000)" } else { dbPath := filepath.Join(dataDir, tenantID+".db") if err := os.MkdirAll(dataDir, 0755); err != nil { return nil, err } dsn = dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" } db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } if err := initSchema(db); err != nil { db.Close() return nil, err } return db, nil } func initSchema(db *sql.DB) error { schema := ` CREATE TABLE IF NOT EXISTS posts ( id TEXT PRIMARY KEY, slug TEXT UNIQUE NOT NULL, title TEXT, description TEXT, tags TEXT, cover_image TEXT, content_md TEXT, content_html TEXT, is_published INTEGER DEFAULT 0, members_only INTEGER DEFAULT 0, published_at TEXT, updated_at TEXT, aliases TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, modified_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug); CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published_at DESC) WHERE is_published = 1; CREATE TABLE IF NOT EXISTS post_drafts ( post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE, slug TEXT NOT NULL, title TEXT, description TEXT, tags TEXT, cover_image TEXT, members_only INTEGER DEFAULT 0, content_md TEXT, content_html TEXT, modified_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS post_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, slug TEXT NOT NULL, title TEXT, description TEXT, tags TEXT, cover_image TEXT, content_md TEXT, content_html TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_post_versions_post ON post_versions(post_id); CREATE TABLE IF NOT EXISTS pages ( path TEXT PRIMARY KEY, html BLOB, etag TEXT, built_at TEXT ); CREATE TABLE IF NOT EXISTS assets ( id TEXT PRIMARY KEY, filename TEXT NOT NULL, r2_key TEXT NOT NULL, content_type TEXT, size INTEGER, width INTEGER, height INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS site_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL, name TEXT, avatar_url TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), expires_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); CREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id), post_slug TEXT NOT NULL, content TEXT NOT NULL, content_html TEXT, parent_id INTEGER REFERENCES comments(id), created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_slug); CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id); CREATE TABLE IF NOT EXISTS reactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT REFERENCES users(id), anon_id TEXT, post_slug TEXT NOT NULL, emoji TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, post_slug, emoji), UNIQUE(anon_id, post_slug, emoji) ); CREATE INDEX IF NOT EXISTS idx_reactions_post ON reactions(post_slug); CREATE TABLE IF NOT EXISTS page_views ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL, post_slug TEXT, referrer TEXT, user_agent TEXT, visitor_hash TEXT, utm_source TEXT, utm_medium TEXT, utm_campaign TEXT, device_type TEXT, browser TEXT, os TEXT, country TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_page_views_path ON page_views(path); CREATE INDEX IF NOT EXISTS idx_page_views_created ON page_views(created_at); CREATE INDEX IF NOT EXISTS idx_page_views_visitor ON page_views(visitor_hash); CREATE TABLE IF NOT EXISTS daily_analytics ( date TEXT PRIMARY KEY, requests INTEGER DEFAULT 0, page_views INTEGER DEFAULT 0, unique_visitors INTEGER DEFAULT 0, bandwidth INTEGER DEFAULT 0, browsers TEXT, os TEXT, devices TEXT, countries TEXT, paths TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS members ( user_id TEXT PRIMARY KEY REFERENCES users(id), email TEXT NOT NULL, name TEXT, tier TEXT NOT NULL, status TEXT NOT NULL, expires_at TEXT, synced_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS api_keys ( key TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, last_used_at TEXT ); CREATE TABLE IF NOT EXISTS components ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL, source TEXT NOT NULL, compiled TEXT, client_directive TEXT DEFAULT 'load', created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS plugins ( id TEXT PRIMARY KEY, name TEXT NOT NULL, language TEXT NOT NULL, source TEXT NOT NULL, wasm BLOB, hooks TEXT NOT NULL, enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS webhooks ( id TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL, events TEXT NOT NULL, secret TEXT, enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP, last_triggered_at TEXT, last_status TEXT ); CREATE TABLE IF NOT EXISTS webhook_deliveries ( id INTEGER PRIMARY KEY AUTOINCREMENT, webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, event TEXT NOT NULL, payload TEXT, status TEXT NOT NULL, response_code INTEGER, response_body TEXT, attempts INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, created_at DESC); ` _, err := db.Exec(schema) if err != nil { return fmt.Errorf("init schema: %w", err) } _, err = db.Exec(` CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( slug UNINDEXED, collection_slug UNINDEXED, title, description, content, type UNINDEXED, url UNINDEXED, date UNINDEXED ) `) if err != nil { return fmt.Errorf("init fts5: %w", err) } return nil }