/** * SQLite Connection Manager * * Singleton manager for SQLite database connections with WAL mode * for better concurrency. Manages: * - Core database (data/osws.sqlite) - projects, templates, skills * - Runtime databases (deployments/{deploymentId}/runtime.sqlite) - per-deployment runtime data * - Analytics databases (deployments/{deploymentId}/analytics.sqlite) - per-deployment analytics * * Migration: On first access, splits old unified deployment.sqlite into * runtime.sqlite + analytics.sqlite if needed. */ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; // Connection caches const coreDatabases = new Map(); const runtimeDatabases = new Map(); const analyticsDatabases = new Map(); const projectDatabases = new Map(); /** * Get the base directory for data storage * Supports DATA_DIR environment variable for custom paths */ function getDataDir(): string { return process.env.DATA_DIR || path.join(process.cwd(), 'data'); } /** * Get the base directory for deployment storage * Includes backward compatibility: renames old sites/ dir to deployments/ if needed */ function getDeploymentsDir(): string { const deploymentsDir = process.env.DEPLOYMENTS_DIR || path.join(process.cwd(), 'deployments'); const oldSitesDir = path.join(process.cwd(), 'sites'); // Backward compatibility: rename sites/ to deployments/ if old dir exists and new one doesn't try { if (!fs.existsSync(deploymentsDir) && fs.existsSync(oldSitesDir)) { fs.renameSync(oldSitesDir, deploymentsDir); } } catch { // Race condition: another request may have already renamed the directory if (!fs.existsSync(deploymentsDir)) { throw new Error('Neither deployments/ nor sites/ directory exists'); } } return deploymentsDir; } /** * Ensure a directory exists, creating it if necessary */ function ensureDir(dirPath: string): void { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } /** * Validate that an ID is safe for use in file paths (prevents path traversal) */ function validateIdFormat(id: string, label: string): void { if (!/^[a-f0-9-]+$/i.test(id)) { throw new Error(`Invalid ${label} format: ${id}`); } } /** * Configure a database with WAL mode and foreign keys */ function configureDatabase(db: Database.Database): void { const encryptionKey = process.env.DB_ENCRYPTION_KEY; if (encryptionKey) { db.pragma(`key='${encryptionKey}'`); } db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.pragma('synchronous = NORMAL'); db.pragma('cache_size = -64000'); // 64MB cache db.pragma('temp_store = MEMORY'); } /** * Rename helper for SQLite files including WAL and SHM */ function renameSqliteFile(oldPath: string, newPath: string): void { fs.renameSync(oldPath, newPath); for (const ext of ['-wal', '-shm']) { const oldExt = oldPath + ext; if (fs.existsSync(oldExt)) { fs.renameSync(oldExt, newPath + ext); } } } /** * Migrate old unified deployment.sqlite to split runtime.sqlite + analytics.sqlite * Called on first access to a deployment's database. * * Migration steps: * 1. Rename deployment.sqlite → runtime.sqlite * 2. Create analytics.sqlite with analytics tables * 3. Copy analytics data from runtime to analytics * 4. Drop analytics tables from runtime */ function migrateDeploymentDatabase(deploymentDir: string, deploymentId?: string): void { // Validate deploymentId format before using in ATTACH DATABASE to prevent path injection if (deploymentId && !/^[a-f0-9-]+$/i.test(deploymentId)) { throw new Error(`Invalid deployment ID format: ${deploymentId}`); } const oldDeploymentPath = path.join(deploymentDir, 'deployment.sqlite'); const oldSitePath = path.join(deploymentDir, 'site.sqlite'); const runtimePath = path.join(deploymentDir, 'runtime.sqlite'); const analyticsPath = path.join(deploymentDir, 'analytics.sqlite'); // Already migrated or no old database exists if (fs.existsSync(runtimePath)) return; // Determine which old file to migrate from let sourcePath: string | null = null; if (fs.existsSync(oldDeploymentPath)) { sourcePath = oldDeploymentPath; } else if (fs.existsSync(oldSitePath)) { sourcePath = oldSitePath; } if (!sourcePath) return; // No database to migrate // Step 1: Rename old database to runtime.sqlite renameSqliteFile(sourcePath, runtimePath); // Step 2: Create analytics.sqlite and migrate analytics data try { const runtimeDb = new Database(runtimePath); configureDatabase(runtimeDb); try { // Check if analytics tables exist in the old unified database const hasPageviews = runtimeDb.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='pageviews'" ).get(); if (hasPageviews) { // Attach analytics database (creates file if it doesn't exist) runtimeDb.exec(`ATTACH DATABASE '${analyticsPath}' AS analytics_new`); // Create analytics tables in new database runtimeDb.exec(` CREATE TABLE IF NOT EXISTS analytics_new.pageviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, page_path TEXT NOT NULL, referrer TEXT, country TEXT, user_agent TEXT, device_type TEXT, session_id TEXT NOT NULL, load_time INTEGER, timestamp TEXT NOT NULL DEFAULT (datetime('now')) ) `); runtimeDb.exec(`CREATE INDEX IF NOT EXISTS analytics_new.idx_pageviews_timestamp ON pageviews(timestamp)`); runtimeDb.exec(`CREATE INDEX IF NOT EXISTS analytics_new.idx_pageviews_session_id ON pageviews(session_id)`); runtimeDb.exec(` CREATE TABLE IF NOT EXISTS analytics_new.interactions ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, page_path TEXT NOT NULL, interaction_type TEXT NOT NULL, element_selector TEXT, coordinates TEXT, scroll_depth INTEGER, time_on_page INTEGER, timestamp TEXT NOT NULL DEFAULT (datetime('now')) ) `); runtimeDb.exec(`CREATE INDEX IF NOT EXISTS analytics_new.idx_interactions_page_path ON interactions(page_path)`); runtimeDb.exec(`CREATE INDEX IF NOT EXISTS analytics_new.idx_interactions_timestamp ON interactions(timestamp)`); runtimeDb.exec(` CREATE TABLE IF NOT EXISTS analytics_new.sessions ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, entry_page TEXT, exit_page TEXT, page_count INTEGER DEFAULT 1, duration INTEGER, is_bounce INTEGER DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), ended_at TEXT ) `); runtimeDb.exec(`CREATE INDEX IF NOT EXISTS analytics_new.idx_sessions_session_id ON sessions(session_id)`); runtimeDb.exec(`CREATE INDEX IF NOT EXISTS analytics_new.idx_sessions_created_at ON sessions(created_at)`); // Copy analytics data runtimeDb.exec(`INSERT INTO analytics_new.pageviews SELECT * FROM main.pageviews`); runtimeDb.exec(`INSERT INTO analytics_new.interactions SELECT * FROM main.interactions`); runtimeDb.exec(`INSERT INTO analytics_new.sessions SELECT * FROM main.sessions`); // Detach runtimeDb.exec(`DETACH DATABASE analytics_new`); // Drop analytics tables from runtime database runtimeDb.exec(`DROP TABLE IF EXISTS pageviews`); runtimeDb.exec(`DROP TABLE IF EXISTS interactions`); runtimeDb.exec(`DROP TABLE IF EXISTS sessions`); } } finally { runtimeDb.close(); } } catch (err) { console.error('[SQLite Migration] Failed to split deployment database:', err); // Migration failure is non-fatal — runtime.sqlite still has all data } } /** * Get the core database connection (cached per path) * Creates data/osws.sqlite if it doesn't exist */ export function getCoreDatabase(customPath?: string): Database.Database { const dataDir = getDataDir(); const dbPath = customPath || path.join(dataDir, 'osws.sqlite'); const cached = coreDatabases.get(dbPath); if (cached) return cached; ensureDir(path.dirname(dbPath)); const db = new Database(dbPath); configureDatabase(db); coreDatabases.set(dbPath, db); return db; } /** * Get a deployment's runtime database connection (cached) * Creates deployments/{deploymentId}/runtime.sqlite if it doesn't exist * Runs migration from old deployment.sqlite if needed */ export function getRuntimeDatabaseConnection(deploymentId: string): Database.Database { validateIdFormat(deploymentId, 'deployment ID'); const cached = runtimeDatabases.get(deploymentId); if (cached) { return cached; } const deploymentsDir = getDeploymentsDir(); const deploymentDir = path.join(deploymentsDir, deploymentId); ensureDir(deploymentDir); // Run migration if old unified database exists migrateDeploymentDatabase(deploymentDir, deploymentId); const dbPath = path.join(deploymentDir, 'runtime.sqlite'); const db = new Database(dbPath); configureDatabase(db); runtimeDatabases.set(deploymentId, db); return db; } /** * Get a deployment's analytics database connection (cached) * Creates deployments/{deploymentId}/analytics.sqlite if it doesn't exist */ export function getAnalyticsDatabaseConnection(deploymentId: string): Database.Database { validateIdFormat(deploymentId, 'deployment ID'); const cached = analyticsDatabases.get(deploymentId); if (cached) { return cached; } const deploymentsDir = getDeploymentsDir(); const deploymentDir = path.join(deploymentsDir, deploymentId); ensureDir(deploymentDir); // Ensure migration has run (in case analytics DB is requested first) migrateDeploymentDatabase(deploymentDir, deploymentId); const dbPath = path.join(deploymentDir, 'analytics.sqlite'); const db = new Database(dbPath); configureDatabase(db); analyticsDatabases.set(deploymentId, db); return db; } /** * @deprecated Use getRuntimeDatabaseConnection instead * Backward compatibility: returns runtime database connection */ export function getDeploymentDatabase(deploymentId: string): Database.Database { return getRuntimeDatabaseConnection(deploymentId); } /** * Check if a deployment database exists (either format) */ export function deploymentExists(deploymentId: string): boolean { validateIdFormat(deploymentId, 'deployment ID'); const deploymentsDir = getDeploymentsDir(); const dir = path.join(deploymentsDir, deploymentId); const runtimePath = path.join(dir, 'runtime.sqlite'); const oldDeploymentPath = path.join(dir, 'deployment.sqlite'); const oldSitePath = path.join(dir, 'site.sqlite'); return fs.existsSync(runtimePath) || fs.existsSync(oldDeploymentPath) || fs.existsSync(oldSitePath); } /** * Delete a deployment's databases and directory */ export function deleteDeploymentDatabase(deploymentId: string): void { validateIdFormat(deploymentId, 'deployment ID'); // Close all connections closeRuntimeDatabase(deploymentId); closeAnalyticsDatabase(deploymentId); const deploymentsDir = getDeploymentsDir(); const deploymentDir = path.join(deploymentsDir, deploymentId); if (fs.existsSync(deploymentDir)) { const files = fs.readdirSync(deploymentDir); for (const file of files) { fs.unlinkSync(path.join(deploymentDir, file)); } fs.rmdirSync(deploymentDir); } } /** * Close a specific deployment's runtime database connection */ export function closeRuntimeDatabase(deploymentId: string): void { const db = runtimeDatabases.get(deploymentId); if (db) { try { db.close(); } catch { // Ignore errors on close } runtimeDatabases.delete(deploymentId); } } /** * Close a specific deployment's analytics database connection */ export function closeAnalyticsDatabase(deploymentId: string): void { const db = analyticsDatabases.get(deploymentId); if (db) { try { db.close(); } catch { // Ignore errors on close } analyticsDatabases.delete(deploymentId); } } /** * @deprecated Use closeRuntimeDatabase instead */ export function closeDeploymentDatabase(deploymentId: string): void { closeRuntimeDatabase(deploymentId); closeAnalyticsDatabase(deploymentId); } /** * Close a specific core database connection by path. * Use this when closing a single workspace adapter to avoid * destroying connections belonging to other workspaces. */ export function closeCoreDatabaseByPath(dbPath: string): void { const db = coreDatabases.get(dbPath); if (db) { try { db.close(); } catch {} coreDatabases.delete(dbPath); } } /** * Close all core database connections */ export function closeCoreDatabase(): void { for (const [, db] of coreDatabases) { try { db.close(); } catch {} } coreDatabases.clear(); } // ============================================ // Project Database Connections // ============================================ /** * Get the file path for a project's database */ export function getProjectDatabasePath(projectId: string, baseDir?: string): string { validateIdFormat(projectId, 'project ID'); const dataDir = baseDir || getDataDir(); return path.join(dataDir, 'projects', projectId, 'database.sqlite'); } /** * Check if a project database exists */ export function projectDatabaseExists(projectId: string, baseDir?: string): boolean { return fs.existsSync(getProjectDatabasePath(projectId, baseDir)); } /** * Get a project's database connection (cached) * Creates data/projects/{projectId}/database.sqlite if it doesn't exist */ export function getProjectDatabaseConnection(projectId: string, baseDir?: string): Database.Database { validateIdFormat(projectId, 'project ID'); const cacheKey = baseDir ? `${baseDir}:${projectId}` : projectId; const cached = projectDatabases.get(cacheKey); if (cached) return cached; const dataDir = baseDir || getDataDir(); const projectDir = path.join(dataDir, 'projects', projectId); ensureDir(projectDir); const dbPath = path.join(projectDir, 'database.sqlite'); const db = new Database(dbPath); configureDatabase(db); projectDatabases.set(cacheKey, db); return db; } /** * Close a specific project's database connection */ export function closeProjectDatabase(projectId: string, baseDir?: string): void { const cacheKey = baseDir ? `${baseDir}:${projectId}` : projectId; const db = projectDatabases.get(cacheKey); if (db) { try { db.close(); } catch {} projectDatabases.delete(cacheKey); } } /** * Delete a project's database and directory */ export function deleteProjectDatabase(projectId: string, baseDir?: string): void { validateIdFormat(projectId, 'project ID'); closeProjectDatabase(projectId, baseDir); const dataDir = baseDir || getDataDir(); const projectDir = path.join(dataDir, 'projects', projectId); if (fs.existsSync(projectDir)) { const files = fs.readdirSync(projectDir); for (const file of files) { fs.unlinkSync(path.join(projectDir, file)); } fs.rmdirSync(projectDir); } } /** * Close all database connections (for cleanup/shutdown) */ export function closeAllConnections(): void { // Close all runtime databases for (const [deploymentId] of runtimeDatabases) { closeRuntimeDatabase(deploymentId); } // Close all analytics databases for (const [deploymentId] of analyticsDatabases) { closeAnalyticsDatabase(deploymentId); } // Close all project databases (keys may be composite "baseDir:projectId") for (const [, db] of projectDatabases) { try { db.close(); } catch {} } projectDatabases.clear(); // Close core database closeCoreDatabase(); } /** * List all deployment IDs that have databases */ export function listDeploymentIds(): string[] { const deploymentsDir = getDeploymentsDir(); if (!fs.existsSync(deploymentsDir)) { return []; } const entries = fs.readdirSync(deploymentsDir, { withFileTypes: true }); return entries .filter(entry => entry.isDirectory()) .filter(entry => { const dir = path.join(deploymentsDir, entry.name); // Check for any database format return fs.existsSync(path.join(dir, 'runtime.sqlite')) || fs.existsSync(path.join(dir, 'deployment.sqlite')) || fs.existsSync(path.join(dir, 'site.sqlite')); }) .map(entry => entry.name); } /** * Get the file path for a deployment's runtime database (for export/backup) */ export function getDeploymentDatabasePath(deploymentId: string): string { validateIdFormat(deploymentId, 'deployment ID'); const deploymentsDir = getDeploymentsDir(); return path.join(deploymentsDir, deploymentId, 'runtime.sqlite'); } /** * Get the file path for the core database (for export/backup) */ export function getCoreDatabasePath(): string { const dataDir = getDataDir(); return path.join(dataDir, 'osws.sqlite'); }