All files / src/lib/db index.ts

89.7% Statements 61/68
77.77% Branches 14/18
100% Functions 10/10
89.7% Lines 61/68

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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                      257x           257x   257x   257x     257x     257x     257x   257x 257x     30630x 30630x           22178x       2030x       2028x 2028x 2028x     2028x 2028x       2028x 2028x                     20114x     20114x             20114x 18084x     2030x   2030x   2030x           2030x 2030x 2030x 2030x   2030x 2030x 2030x 2030x         2030x 2028x             2028x   2028x     2x 2x 2x           4549x 1971x 1971x 1971x 1971x                   29x   8x     27x     27x       1446x       136x 136x 136x   136x     136x     136x       136x           1446x 136x      
/**
 * SQLite database connection manager for CLI configuration storage.
 * Uses the sqlite.ts adapter which wraps node:sqlite's DatabaseSync
 * with a bun:sqlite-compatible API surface.
 */
 
import { chmodSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import { getEnv } from "../env.js";
 
const _require = createRequire(import.meta.url);
 
import { migrateFromJson } from "./migration.js";
import { initSchema, runMigrations } from "./schema.js";
import { Database } from "./sqlite.js";
 
export const CONFIG_DIR_ENV_VAR = "SENTRY_CONFIG_DIR";
 
const DEFAULT_CONFIG_DIR_NAME = ".sentry";
 
const DB_FILENAME = "cli.db";
 
/** 7-day TTL for cache entries (milliseconds) */
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
 
/** Probability of running cleanup on write operations */
const CLEANUP_PROBABILITY = 0.1;
 
/** Traced database wrapper (returned by getDatabase) */
let db: Database | null = null;
/** Raw database without tracing (used for repair operations) */
let rawDb: Database | null = null;
let dbOpenedPath: string | null = null;
 
export function getConfigDir(): string {
  const { homedir } = _require("node:os");
  return (
    getEnv()[CONFIG_DIR_ENV_VAR] || join(homedir(), DEFAULT_CONFIG_DIR_NAME)
  );
}
 
export function getDbPath(): string {
  return join(getConfigDir(), DB_FILENAME);
}
 
function ensureConfigDir(): void {
  mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
}
 
function setDbPermissions(): void {
  const dbPath = getDbPath();
  try {
    chmodSync(dbPath, 0o600);
    // WAL mode creates -wal and -shm files that may contain sensitive data
    // Chmod them too if they exist (they may not exist on first run)
    try {
      chmodSync(`${dbPath}-wal`, 0o600);
    } catch {
      // File may not exist yet
    }
    try {
      chmodSync(`${dbPath}-shm`, 0o600);
    } catch {
      // File may not exist yet
    }
  } catch {
    // Windows doesn't support chmod
  }
}
 
/** Get or initialize the database connection. */
export function getDatabase(): Database {
  const dbPath = getDbPath();
 
  // Auto-invalidate if config directory changed (for tests)
  Iif (db && dbOpenedPath !== dbPath) {
    db.close();
    db = null;
    rawDb = null;
    dbOpenedPath = null;
  }
 
  if (db) {
    return db;
  }
 
  ensureConfigDir();
 
  rawDb = new Database(dbPath);
 
  try {
    // 5000ms busy_timeout prevents SQLITE_BUSY errors during concurrent CLI access.
    // When multiple CLI instances run simultaneously (e.g., parallel terminals, CI jobs),
    // SQLite needs time to acquire locks. WAL mode allows concurrent reads, but writers
    // must wait. Without sufficient timeout, concurrent processes fail immediately.
    // Set busy_timeout FIRST - before WAL mode - to handle lock contention during init.
    rawDb.exec("PRAGMA busy_timeout = 5000");
    rawDb.exec("PRAGMA journal_mode = WAL");
    rawDb.exec("PRAGMA foreign_keys = ON");
    rawDb.exec("PRAGMA synchronous = NORMAL");
 
    setDbPermissions();
    initSchema(rawDb);
    runMigrations(rawDb);
    migrateFromJson(rawDb);
 
    // Wrap with tracing proxy for automatic query instrumentation.
    // Lazy-require telemetry to avoid top-level import of @sentry/node-core (~85ms).
    // Shell completions set SENTRY_CLI_NO_TELEMETRY=1 to skip this entirely.
    if (getEnv().SENTRY_CLI_NO_TELEMETRY === "1") {
      db = rawDb;
    } else E{
      const { createTracedDatabase } = _require("../telemetry.js") as {
        createTracedDatabase: (d: Database) => Database;
      };
      db = createTracedDatabase(rawDb);
    }
    dbOpenedPath = dbPath;
 
    return db;
  } catch (error) {
    // Clean up on initialization failure to prevent connection leak
    rawDb.close();
    rawDb = null;
    throw error;
  }
}
 
/** Close the database connection (used for testing). */
export function closeDatabase(): void {
  if (db) {
    db.close();
    db = null;
    rawDb = null;
    dbOpenedPath = null;
  }
}
 
/**
 * Get the raw (unwrapped) database connection.
 * Used for repair operations to avoid triggering the traced wrapper's
 * auto-repair logic (which would cause infinite loops).
 */
export function getRawDatabase(): Database {
  if (!rawDb) {
    // Ensure database is initialized
    getDatabase();
  }
  // After getDatabase() call, rawDb is guaranteed to be set
  Iif (!rawDb) {
    throw new Error("Database initialization failed");
  }
  return rawDb;
}
 
function shouldRunCleanup(): boolean {
  return Math.random() < CLEANUP_PROBABILITY;
}
 
function cleanupExpiredCaches(): void {
  const database = getDatabase();
  const expiryTime = Date.now() - CACHE_TTL_MS;
  const now = Date.now();
 
  database
    .query("DELETE FROM project_cache WHERE last_accessed < ?")
    .run(expiryTime);
  database
    .query("DELETE FROM dsn_cache WHERE last_accessed < ?")
    .run(expiryTime);
  database
    .query("DELETE FROM project_aliases WHERE last_accessed < ?")
    .run(expiryTime);
  // project_root_cache uses ttl_expires_at instead of last_accessed
  database
    .query("DELETE FROM project_root_cache WHERE ttl_expires_at < ?")
    .run(now);
}
 
export function maybeCleanupCaches(): void {
  if (shouldRunCleanup()) {
    cleanupExpiredCaches();
  }
}