File size: 17,239 Bytes
94193b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
/**
 * 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<string, Database.Database>();
const runtimeDatabases = new Map<string, Database.Database>();
const analyticsDatabases = new Map<string, Database.Database>();
const projectDatabases = new Map<string, Database.Database>();

/**
 * 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');
}