| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import Database from 'better-sqlite3'; |
| import path from 'path'; |
| import fs from 'fs'; |
|
|
| |
| const coreDatabases = new Map<string, Database.Database>(); |
| const runtimeDatabases = new Map<string, Database.Database>(); |
| const analyticsDatabases = new Map<string, Database.Database>(); |
| const projectDatabases = new Map<string, Database.Database>(); |
|
|
| |
| |
| |
| |
| function getDataDir(): string { |
| return process.env.DATA_DIR || path.join(process.cwd(), 'data'); |
| } |
|
|
| |
| |
| |
| |
| function getDeploymentsDir(): string { |
| const deploymentsDir = process.env.DEPLOYMENTS_DIR || path.join(process.cwd(), 'deployments'); |
| const oldSitesDir = path.join(process.cwd(), 'sites'); |
|
|
| |
| try { |
| if (!fs.existsSync(deploymentsDir) && fs.existsSync(oldSitesDir)) { |
| fs.renameSync(oldSitesDir, deploymentsDir); |
| } |
| } catch { |
| |
| if (!fs.existsSync(deploymentsDir)) { |
| throw new Error('Neither deployments/ nor sites/ directory exists'); |
| } |
| } |
|
|
| return deploymentsDir; |
| } |
|
|
| |
| |
| |
| function ensureDir(dirPath: string): void { |
| if (!fs.existsSync(dirPath)) { |
| fs.mkdirSync(dirPath, { recursive: true }); |
| } |
| } |
|
|
| |
| |
| |
| function validateIdFormat(id: string, label: string): void { |
| if (!/^[a-f0-9-]+$/i.test(id)) { |
| throw new Error(`Invalid ${label} format: ${id}`); |
| } |
| } |
|
|
| |
| |
| |
| 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'); |
| db.pragma('temp_store = MEMORY'); |
| } |
|
|
| |
| |
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function migrateDeploymentDatabase(deploymentDir: string, deploymentId?: string): void { |
| |
| 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'); |
|
|
| |
| if (fs.existsSync(runtimePath)) return; |
|
|
| |
| let sourcePath: string | null = null; |
| if (fs.existsSync(oldDeploymentPath)) { |
| sourcePath = oldDeploymentPath; |
| } else if (fs.existsSync(oldSitePath)) { |
| sourcePath = oldSitePath; |
| } |
|
|
| if (!sourcePath) return; |
|
|
| |
| renameSqliteFile(sourcePath, runtimePath); |
|
|
| |
| try { |
| const runtimeDb = new Database(runtimePath); |
| configureDatabase(runtimeDb); |
|
|
| try { |
| |
| const hasPageviews = runtimeDb.prepare( |
| "SELECT name FROM sqlite_master WHERE type='table' AND name='pageviews'" |
| ).get(); |
|
|
| if (hasPageviews) { |
| |
| runtimeDb.exec(`ATTACH DATABASE '${analyticsPath}' AS analytics_new`); |
|
|
| |
| 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)`); |
|
|
| |
| 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`); |
|
|
| |
| runtimeDb.exec(`DETACH DATABASE analytics_new`); |
|
|
| |
| 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); |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| migrateDeploymentDatabase(deploymentDir, deploymentId); |
|
|
| const dbPath = path.join(deploymentDir, 'runtime.sqlite'); |
| const db = new Database(dbPath); |
| configureDatabase(db); |
|
|
| runtimeDatabases.set(deploymentId, db); |
| return db; |
| } |
|
|
| |
| |
| |
| |
| 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); |
|
|
| |
| migrateDeploymentDatabase(deploymentDir, deploymentId); |
|
|
| const dbPath = path.join(deploymentDir, 'analytics.sqlite'); |
| const db = new Database(dbPath); |
| configureDatabase(db); |
|
|
| analyticsDatabases.set(deploymentId, db); |
| return db; |
| } |
|
|
| |
| |
| |
| |
| export function getDeploymentDatabase(deploymentId: string): Database.Database { |
| return getRuntimeDatabaseConnection(deploymentId); |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| export function deleteDeploymentDatabase(deploymentId: string): void { |
| validateIdFormat(deploymentId, 'deployment ID'); |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| export function closeRuntimeDatabase(deploymentId: string): void { |
| const db = runtimeDatabases.get(deploymentId); |
| if (db) { |
| try { |
| db.close(); |
| } catch { |
| |
| } |
| runtimeDatabases.delete(deploymentId); |
| } |
| } |
|
|
| |
| |
| |
| export function closeAnalyticsDatabase(deploymentId: string): void { |
| const db = analyticsDatabases.get(deploymentId); |
| if (db) { |
| try { |
| db.close(); |
| } catch { |
| |
| } |
| analyticsDatabases.delete(deploymentId); |
| } |
| } |
|
|
| |
| |
| |
| export function closeDeploymentDatabase(deploymentId: string): void { |
| closeRuntimeDatabase(deploymentId); |
| closeAnalyticsDatabase(deploymentId); |
| } |
|
|
| |
| |
| |
| |
| |
| export function closeCoreDatabaseByPath(dbPath: string): void { |
| const db = coreDatabases.get(dbPath); |
| if (db) { |
| try { db.close(); } catch {} |
| coreDatabases.delete(dbPath); |
| } |
| } |
|
|
| |
| |
| |
| export function closeCoreDatabase(): void { |
| for (const [, db] of coreDatabases) { |
| try { db.close(); } catch {} |
| } |
| coreDatabases.clear(); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export function getProjectDatabasePath(projectId: string, baseDir?: string): string { |
| validateIdFormat(projectId, 'project ID'); |
| const dataDir = baseDir || getDataDir(); |
| return path.join(dataDir, 'projects', projectId, 'database.sqlite'); |
| } |
|
|
| |
| |
| |
| export function projectDatabaseExists(projectId: string, baseDir?: string): boolean { |
| return fs.existsSync(getProjectDatabasePath(projectId, baseDir)); |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| export function closeAllConnections(): void { |
| |
| for (const [deploymentId] of runtimeDatabases) { |
| closeRuntimeDatabase(deploymentId); |
| } |
|
|
| |
| for (const [deploymentId] of analyticsDatabases) { |
| closeAnalyticsDatabase(deploymentId); |
| } |
|
|
| |
| for (const [, db] of projectDatabases) { |
| try { db.close(); } catch {} |
| } |
| projectDatabases.clear(); |
|
|
| |
| closeCoreDatabase(); |
| } |
|
|
| |
| |
| |
| 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); |
| |
| 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); |
| } |
|
|
| |
| |
| |
| export function getDeploymentDatabasePath(deploymentId: string): string { |
| validateIdFormat(deploymentId, 'deployment ID'); |
| const deploymentsDir = getDeploymentsDir(); |
| return path.join(deploymentsDir, deploymentId, 'runtime.sqlite'); |
| } |
|
|
| |
| |
| |
| export function getCoreDatabasePath(): string { |
| const dataDir = getDataDir(); |
| return path.join(dataDir, 'osws.sqlite'); |
| } |
|
|