All files / src/lib/db utils.ts

92.1% Statements 35/38
82.35% Branches 14/17
100% Functions 13/13
91.17% Lines 31/34

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 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                                                                                                                                5392x 5392x   5392x 1x     5391x 1x     29738x   5390x 5390x   5392x 29738x       5392x 23396x     5392x   5392x                                                               5382x 5382x                                               1449x     2034x 1449x     1449x                               439x 439x     439x 439x 733x                             97x     339x 97x                                           568x 568x          
/**
 * SQL builder utilities for common database operations.
 * Reduces boilerplate for UPSERT and other repetitive patterns.
 */
 
import { getDatabase } from "./index.js";
import type { SQLQueryBindings } from "./sqlite.js";
 
/** Valid SQLite binding value (re-exported from sqlite.ts adapter) */
export type SqlValue = SQLQueryBindings;
 
/**
 * Result of building an SQL query with parameterized values.
 */
export type SqlQuery = {
  /** The SQL string with ? placeholders */
  sql: string;
  /** The values to bind to the placeholders */
  values: SqlValue[];
};
 
/**
 * Options for the upsert function.
 */
export type UpsertOptions<T> = {
  /** Columns to exclude from the UPDATE SET clause */
  excludeFromUpdate?: (keyof T)[];
};
 
/**
 * Build an UPSERT (INSERT ... ON CONFLICT DO UPDATE) statement for SQLite.
 *
 * This helper eliminates repetitive UPSERT boilerplate by automatically
 * generating the INSERT and ON CONFLICT DO UPDATE clauses from an object.
 *
 * @param table - The table name to insert into
 * @param data - Object with column names as keys and values to insert
 * @param conflictColumns - Column(s) that form the unique constraint
 * @param options - Optional configuration
 * @returns Object with { sql, values } ready for db.query(sql).run(...values)
 *
 * @example
 * // Simple upsert
 * const { sql, values } = upsert('auth', { id: 1, token: 'abc' }, ['id']);
 * db.query(sql).run(...values);
 * // INSERT INTO auth (id, token) VALUES (?, ?)
 * // ON CONFLICT(id) DO UPDATE SET token = excluded.token
 *
 * @example
 * // Exclude columns from update
 * const { sql, values } = upsert(
 *   'users',
 *   { id: 1, name: 'Bob', created_at: now },
 *   ['id'],
 *   { excludeFromUpdate: ['created_at'] }
 * );
 * // created_at won't be updated on conflict, only on insert
 */
export function upsert<T extends Record<string, SqlValue>>(
  table: string,
  data: T,
  conflictColumns: (keyof T)[],
  options: UpsertOptions<T> = {}
): SqlQuery {
  const columns = Object.keys(data);
  const values = Object.values(data) as SqlValue[];
 
  if (columns.length === 0) {
    throw new Error("upsert: data object must have at least one column");
  }
 
  if (conflictColumns.length === 0) {
    throw new Error("upsert: must specify at least one conflict column");
  }
 
  const placeholders = columns.map(() => "?").join(", ");
 
  const conflictSet = new Set(conflictColumns as string[]);
  const excludeSet = new Set((options.excludeFromUpdate ?? []) as string[]);
 
  const updateColumns = columns.filter(
    (col) => !(conflictSet.has(col) || excludeSet.has(col))
  );
 
  const updateClause =
    updateColumns.length > 0
      ? `DO UPDATE SET ${updateColumns.map((col) => `${col} = excluded.${col}`).join(", ")}`
      : "DO NOTHING";
 
  const sql = `INSERT INTO ${table} (${columns.join(", ")}) VALUES (${placeholders}) ON CONFLICT(${(conflictColumns as string[]).join(", ")}) ${updateClause}`;
 
  return { sql, values };
}
 
/** Minimal db interface needed for query execution and metadata helpers */
type QueryRunner = {
  query(sql: string): {
    run(...values: SqlValue[]): void;
    all(...values: SqlValue[]): Record<string, SqlValue>[];
  };
  transaction<T>(fn: () => T): () => T;
};
 
/**
 * Execute an UPSERT statement directly on the database.
 *
 * Convenience wrapper that combines upsert() SQL generation with execution.
 * For advanced options like excludeFromUpdate, use upsert() directly.
 *
 * @param db - The database instance to execute on
 * @param table - The table name to insert into
 * @param data - Object with column names as keys and values to insert
 * @param conflictColumns - Column(s) that form the unique constraint
 *
 * @example
 * runUpsert(db, 'auth', { id: 1, token: 'abc' }, ['id']);
 */
export function runUpsert<T extends Record<string, SqlValue>>(
  db: QueryRunner,
  table: string,
  data: T,
  conflictColumns: (keyof T)[]
): void {
  const { sql, values } = upsert(table, data, conflictColumns);
  db.query(sql).run(...values);
}
 
// ---------------------------------------------------------------------------
// Metadata table helpers
// ---------------------------------------------------------------------------
 
type MetadataRow = { key: string; value: string };
 
/**
 * Read multiple values from the `metadata` key-value table in a single query.
 *
 * @param db - Database instance
 * @param keys - The metadata keys to read
 * @returns Map of key → value for keys that exist. Missing keys are omitted.
 *
 * @example
 * const m = getMetadata(db, ["install.method", "install.path"]);
 * const method = m.get("install.method"); // string | undefined
 */
export function getMetadata(
  db: QueryRunner,
  keys: string[]
): Map<string, string> {
  Iif (keys.length === 0) {
    return new Map();
  }
  const placeholders = keys.map(() => "?").join(", ");
  const rows = db
    .query(`SELECT key, value FROM metadata WHERE key IN (${placeholders})`)
    .all(...keys) as MetadataRow[];
  return new Map(rows.map((r) => [r.key, r.value]));
}
 
/**
 * Write multiple key-value pairs to the `metadata` table in a single transaction.
 *
 * @param db - Database instance
 * @param entries - Object mapping metadata keys to string values
 *
 * @example
 * setMetadata(db, { "install.method": "binary", "install.path": "/usr/bin/sentry" });
 */
export function setMetadata(
  db: QueryRunner,
  entries: Record<string, string>
): void {
  const pairs = Object.entries(entries);
  Iif (pairs.length === 0) {
    return;
  }
  db.transaction(() => {
    for (const [key, value] of pairs) {
      runUpsert(db, "metadata", { key, value }, ["key"]);
    }
  })();
}
 
/**
 * Delete multiple keys from the `metadata` table in a single query.
 *
 * @param db - Database instance
 * @param keys - The metadata keys to delete
 *
 * @example
 * clearMetadata(db, ["install.method", "install.path", "install.version"]);
 */
export function clearMetadata(db: QueryRunner, keys: string[]): void {
  Iif (keys.length === 0) {
    return;
  }
  const placeholders = keys.map(() => "?").join(", ");
  db.query(`DELETE FROM metadata WHERE key IN (${placeholders})`).run(...keys);
}
 
// ---------------------------------------------------------------------------
// Cache entry helpers
// ---------------------------------------------------------------------------
 
/**
 * Update the `last_accessed` timestamp for a cache entry.
 *
 * Shared helper to avoid duplicating the same UPDATE pattern in every
 * cache module. Calls `getDatabase()` internally.
 *
 * @param table - Cache table name (e.g., "dsn_cache", "project_cache")
 * @param keyColumn - Name of the primary key column (e.g., "directory", "cache_key")
 * @param keyValue - The key value identifying the row to touch
 */
export function touchCacheEntry(
  table: string,
  keyColumn: string,
  keyValue: string
): void {
  const db = getDatabase();
  db.query(`UPDATE ${table} SET last_accessed = ? WHERE ${keyColumn} = ?`).run(
    Date.now(),
    keyValue
  );
}