aboutsummaryrefslogtreecommitdiffstats
path: root/src/db.ts
blob: 511b519796fc66b902e0e2431b7a446cf9307442 (plain)
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
/* SQLite3 persistence layer replacing previous PG / Slonik implementation.
 *
 * Features:
 * - Uses better-sqlite3 (synchronous, high-performance, safe for WAL mode)
 * - Separate "write" connection (single) and a small pool of readonly connections for parallel reads
 * - Applies requested PRAGMAs on every connection
 * - Uses BEGIN IMMEDIATE transactions for write operations
 * - STRICT tables (requires SQLite 3.37+)
 *
 * Environment:
 *   SQLITE_PATH (optional) - path to DB file. Defaults to ./data/hommabot.db
 *
 * Schema (mirrors previous PG schema):
 *   active_chats(id INTEGER PRIMARY KEY)
 *   permitted_users(id INTEGER PRIMARY KEY)
 */

import fs from "fs";
import path from "path";
import Database, { Database as BetterSqliteDb } from "better-sqlite3";

interface ActiveChat {
  id: number;
}

interface PermittedUser {
  id: number;
}

const DB_FILE =
  process.env.SQLITE_PATH || path.join(process.cwd(), "data", "hommabot.db");

ensurePath();
const db = new Database(DB_FILE, {
  fileMustExist: false,
  readonly: false,
});
db.pragma("journal_mode = WAL");
db.pragma("busy_timeout = 5000");
db.pragma("synchronous = NORMAL");
db.pragma("cache_size = 1000000000");
db.pragma("foreign_keys = ON");
db.pragma("temp_store = MEMORY");

const ddl = `
  BEGIN IMMEDIATE;
  CREATE TABLE IF NOT EXISTS active_chats (
    id INTEGER PRIMARY KEY
  ) STRICT;

  CREATE TABLE IF NOT EXISTS permitted_users (
    id INTEGER PRIMARY KEY
  ) STRICT;
  COMMIT;
`;
db.exec(ddl);

/**
 * Initialize directory for DB file if required.
 */
function ensurePath() {
  const dir = path.dirname(DB_FILE);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
}

/**
 * Wrap a write operation in a BEGIN IMMEDIATE transaction.
 */
function withWriteTx<T>(fn: (db: BetterSqliteDb) => T): T {
  const begin = db.prepare("BEGIN IMMEDIATE");
  const commit = db.prepare("COMMIT");
  const rollback = db.prepare("ROLLBACK");

  begin.run();
  try {
    const result = fn(db);
    commit.run();
    return result;
  } catch (err) {
    try {
      rollback.run();
    } catch {
      /* ignore */
    }
    throw err;
  }
}

/* Public API (mirrors old Slonik-based version) */

export const getActiveChats = async (): Promise<readonly ActiveChat[]> => {
  const stmt = db.prepare("SELECT id FROM active_chats");
  return stmt.all() as ActiveChat[];
};

export const addActiveChat = async (id: number): Promise<void> => {
  withWriteTx((db) => {
    const stmt = db.prepare(
      "INSERT OR IGNORE INTO active_chats (id) VALUES (?)",
    );
    stmt.run(id);
  });
};

export const removeActiveChat = async (id: number): Promise<void> => {
  withWriteTx((db) => {
    const stmt = db.prepare("DELETE FROM active_chats WHERE id = ?");
    stmt.run(id);
  });
};

export const getPermittedUsers = async (): Promise<
  readonly PermittedUser[]
> => {
  const stmt = db.prepare("SELECT id FROM permitted_users");
  return stmt.all() as PermittedUser[];
};