ework-web 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
@@ -42,7 +42,8 @@
42
42
  "start": "bun src/index.ts",
43
43
  "check": "tsc --noEmit",
44
44
  "typecheck": "tsc --noEmit",
45
- "test": "bun test"
45
+ "test": "bun test",
46
+ "test:mysql": "bash scripts/test-mysql.sh"
46
47
  },
47
48
  "dependencies": {
48
49
  "@types/dompurify": "^3.2.0",
@@ -51,6 +52,7 @@
51
52
  "jsdom": "^29.1.1",
52
53
  "marked": "^15.0.0",
53
54
  "marked-highlight": "^2.2.0",
55
+ "mysql2": "^3.23.1",
54
56
  "zod": "^3.23.8"
55
57
  },
56
58
  "devDependencies": {
package/src/auth.ts CHANGED
@@ -94,7 +94,7 @@ export async function checkAuth(req: Request, cfg: Config, ip?: string | null):
94
94
  const payload = `${COOKIE_VERSION}.${parsed.login}.${parsed.issued}`;
95
95
  const expected = await hmac(cfg.cookieSecret, payload);
96
96
  if (!ctEqual(parsed.sig, expected)) return { ok: false, user: null };
97
- const user = getUserByLogin(parsed.login);
97
+ const user = await getUserByLogin(parsed.login);
98
98
  if (!user || !user.is_active) return { ok: false, user: null };
99
99
  return { ok: true, user };
100
100
  }
@@ -110,7 +110,7 @@ export async function checkAuth(req: Request, cfg: Config, ip?: string | null):
110
110
  if (!ctEqual(sig, expected) || !ctEqual(token, cfg.authToken)) {
111
111
  return { ok: false, user: null };
112
112
  }
113
- const user = getUserByLogin(cfg.operatorLogin);
113
+ const user = await getUserByLogin(cfg.operatorLogin);
114
114
  if (!user || !user.is_active) return { ok: false, user: null };
115
115
  return { ok: true, user };
116
116
  }
@@ -138,19 +138,19 @@ export async function checkAuth(req: Request, cfg: Config, ip?: string | null):
138
138
  return { ok: false, user: null };
139
139
  }
140
140
 
141
- export function ensureBootstrapAdmin(login: string): UserRow {
142
- const existing = getUserByLogin(login);
141
+ export async function ensureBootstrapAdmin(login: string): Promise<UserRow> {
142
+ const existing = await getUserByLogin(login);
143
143
  if (existing) return existing;
144
- return ensureUser(login, "human");
144
+ return await ensureUser(login, "human");
145
145
  }
146
146
 
147
147
  // Reserved system user for automated actions (cron, import jobs, future CI
148
148
  // integration). kind=system, no password (cannot login via UI). Created on
149
149
  // boot if missing. UI guards prevent disabling/deleting it.
150
- export function ensureBootstrapSystem(login: string): UserRow {
151
- const existing = getUserByLogin(login);
150
+ export async function ensureBootstrapSystem(login: string): Promise<UserRow> {
151
+ const existing = await getUserByLogin(login);
152
152
  if (existing) return existing;
153
- return ensureUser(login, "system");
153
+ return await ensureUser(login, "system");
154
154
  }
155
155
 
156
156
  export function isReservedSystemLogin(login: string, cfg: Config): boolean {
package/src/config.ts CHANGED
@@ -107,7 +107,7 @@ export const SETTINGS_GROUPS: SettingGroup[] = [
107
107
  {
108
108
  title: "AI 模型",
109
109
  fields: [
110
- { key: "defaultModel", label: "默认模型(空 = 用 opencode 自己的默认)", type: "model" },
110
+ { key: "defaultModel", label: "默认模型(空 = 用 opencode.json;注意环境变量可能污染,建议点下方刷新自动选一个)", type: "model" },
111
111
  ],
112
112
  },
113
113
  {
@@ -148,8 +148,8 @@ export const SETTINGS_GROUPS: SettingGroup[] = [
148
148
  ];
149
149
  export const DB_OVERRIDABLE: (keyof Config)[] = SETTINGS_GROUPS.flatMap((g) => g.fields.map((f) => f.key));
150
150
 
151
- export function loadConfig(): Config {
152
- const db = getConfigAll();
151
+ export async function loadConfig(): Promise<Config> {
152
+ const db = await getConfigAll();
153
153
  return configSchema.parse({
154
154
  port: process.env.WORK_PORT,
155
155
  host: process.env.WORK_HOST,
package/src/db.ts CHANGED
@@ -1,19 +1,78 @@
1
- // SQLite store. One DB file holds the runtime config table AND the ework schema
2
- // (projects/issues/comments/labels/reactions/attachments/users) so transactions
3
- // span them. Schema is applied on boot via schema.sql.
1
+ // Storage bootstrap. Owns the AsyncDatabase singleton. Two drivers behind one
2
+ // AsyncDatabase surface: SQLite (bun:sqlite, default) and MySQL (mysql2/promise).
3
+ // Driver picked by WORK_DB_DRIVER (sqlite|mysql). Schema + migrations run in
4
+ // connect(). Callers MUST `await initDB()` once at boot before issuing queries.
5
+ //
6
+ // Table prefix: every table reference in SQL is written as a {{table}} token;
7
+ // applyPrefix() rewrites {{name}} -> <WORK_DB_PREFIX>+name before execution.
8
+ // Default prefix "" leaves SQL identical (backward-compatible with existing
9
+ // ework.db files). WORK_DB_PREFIX is ENV-ONLY (never stored in the config
10
+ // table) — the prefix is needed to locate the config table itself, so it
11
+ // cannot live inside it (chicken-and-egg).
4
12
 
5
- import { Database } from "bun:sqlite";
13
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
14
+ import { createPool, type Pool, type PoolConnection, type ResultSetHeader } from "mysql2/promise";
6
15
  import { mkdirSync, readFileSync } from "fs";
7
16
  import { dirname, join } from "path";
8
17
 
18
+ // ---- public async interface (driver-agnostic) ----
19
+ export interface DbRunResult {
20
+ /** Rowid of the last inserted row (SQLite lastInsertRowid / MySQL insertId). */
21
+ insertId: number;
22
+ /** Number of rows affected by the statement. */
23
+ changes: number;
24
+ }
25
+
26
+ export interface AsyncDatabase {
27
+ /** SELECT → all matching rows. Empty array when none. */
28
+ all<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
29
+ /** SELECT → first matching row or null. */
30
+ get<T = unknown>(sql: string, params?: unknown[]): Promise<T | null>;
31
+ /** INSERT/UPDATE/DELETE → insertId + affected-row count. */
32
+ run(sql: string, params?: unknown[]): Promise<DbRunResult>;
33
+ /** Execute DDL / raw statement (no params, no rows back). */
34
+ exec(sql: string): Promise<void>;
35
+ /** Run fn inside a transaction: commit on resolve, rollback on throw. */
36
+ transaction<T>(fn: () => Promise<T>): Promise<T>;
37
+ /** Release the connection/pool. Idempotent. */
38
+ close(): Promise<void>;
39
+ /** Driver dialect — lets callers branch on SQLite vs MySQL specifics. */
40
+ readonly dialect: "sqlite" | "mysql";
41
+ }
42
+
9
43
  const DB_PATH =
10
44
  process.env.WORK_DB_PATH ||
11
45
  join(process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share`, "ework", "ework.db");
12
46
 
13
- let _db: Database | null = null;
47
+ // ---- table prefix (env-only; read once at module load) ----
48
+ // Validated as a safe SQL identifier prefix. Empty = no prefix (default,
49
+ // backward-compatible). A non-empty prefix lets multiple ework instances (or
50
+ // ework + another app) share one database without colliding on table names.
51
+ const DB_PREFIX = (() => {
52
+ const raw = (process.env.WORK_DB_PREFIX ?? "").trim();
53
+ if (raw && !/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(raw)) {
54
+ throw new Error(
55
+ `Invalid WORK_DB_PREFIX "${raw}": must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$`
56
+ );
57
+ }
58
+ return raw;
59
+ })();
60
+
61
+ /** Rewrite {{table}} tokens -> <prefix>table. No-op when sql contains no tokens. */
62
+ export function applyPrefix(sql: string): string {
63
+ if (!sql.includes("{{")) return sql;
64
+ return sql.replace(/\{\{(\w+)\}\}/g, (_m, name: string) => DB_PREFIX + name);
65
+ }
66
+
67
+ // ---- driver selection (env-only; read once at module load) ----
68
+ const DB_DRIVER = (process.env.WORK_DB_DRIVER ?? "sqlite").trim().toLowerCase();
69
+ const DB_SKIP_CREATE = process.env.WORK_DB_SKIP_CREATE === "1" || process.env.WORK_DB_SKIP_CREATE === "true";
70
+ if (DB_DRIVER !== "sqlite" && DB_DRIVER !== "mysql") {
71
+ throw new Error(`Unsupported WORK_DB_DRIVER "${DB_DRIVER}": must be "sqlite" or "mysql"`);
72
+ }
14
73
 
15
74
  function userTableColumns(db: Database): Set<string> {
16
- const rows = db.query("PRAGMA table_info(users)").all() as { name: string }[];
75
+ const rows = db.query(applyPrefix("PRAGMA table_info({{users}})")).all() as { name: string }[];
17
76
  return new Set(rows.map((r) => r.name));
18
77
  }
19
78
 
@@ -23,21 +82,21 @@ function migrateUsersTable(db: Database): void {
23
82
  if (userTableColumns(db).size === 0) return; // users doesn't exist yet; schema.sql will create it
24
83
  const have = userTableColumns(db);
25
84
  const additions: { col: string; ddl: string }[] = [
26
- { col: "password_hash", ddl: "ALTER TABLE users ADD COLUMN password_hash TEXT" },
27
- { col: "email", ddl: "ALTER TABLE users ADD COLUMN email TEXT" },
28
- { col: "is_admin", ddl: "ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0" },
29
- { col: "is_active", ddl: "ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1" },
30
- { col: "updated_at", ddl: "ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''" },
85
+ { col: "password_hash", ddl: "ALTER TABLE {{users}} ADD COLUMN password_hash TEXT" },
86
+ { col: "email", ddl: "ALTER TABLE {{users}} ADD COLUMN email TEXT" },
87
+ { col: "is_admin", ddl: "ALTER TABLE {{users}} ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0" },
88
+ { col: "is_active", ddl: "ALTER TABLE {{users}} ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1" },
89
+ { col: "updated_at", ddl: "ALTER TABLE {{users}} ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''" },
31
90
  ];
32
91
  for (const m of additions) {
33
92
  if (have.has(m.col)) continue;
34
- db.exec(m.ddl);
93
+ db.exec(applyPrefix(m.ddl));
35
94
  }
36
- db.exec("CREATE INDEX IF NOT EXISTS users_is_admin ON users (is_admin) WHERE is_admin = 1");
95
+ db.exec(applyPrefix("CREATE INDEX IF NOT EXISTS users_is_admin ON {{users}} (is_admin) WHERE is_admin = 1"));
37
96
  }
38
97
 
39
98
  function tableColumns(db: Database, name: string): Set<string> {
40
- const rows = db.query(`PRAGMA table_info(${name})`).all() as { name: string }[];
99
+ const rows = db.query(applyPrefix(`PRAGMA table_info({{${name}}})`)).all() as { name: string }[];
41
100
  return new Set(rows.map((r) => r.name));
42
101
  }
43
102
 
@@ -45,7 +104,7 @@ function migratePatTable(db: Database): void {
45
104
  const have = tableColumns(db, "personal_access_tokens");
46
105
  if (have.size === 0) return;
47
106
  if (!have.has("ip_allowlist")) {
48
- db.exec("ALTER TABLE personal_access_tokens ADD COLUMN ip_allowlist TEXT NOT NULL DEFAULT '[]'");
107
+ db.exec(applyPrefix("ALTER TABLE {{personal_access_tokens}} ADD COLUMN ip_allowlist TEXT NOT NULL DEFAULT '[]'"));
49
108
  }
50
109
  }
51
110
 
@@ -53,10 +112,10 @@ function migrateProjectsTable(db: Database): void {
53
112
  const have = tableColumns(db, "projects");
54
113
  if (have.size === 0) return;
55
114
  if (!have.has("upstream_urls")) {
56
- db.exec("ALTER TABLE projects ADD COLUMN upstream_urls TEXT NOT NULL DEFAULT '[]'");
115
+ db.exec(applyPrefix("ALTER TABLE {{projects}} ADD COLUMN upstream_urls TEXT NOT NULL DEFAULT '[]'"));
57
116
  }
58
117
  if (!have.has("model")) {
59
- db.exec("ALTER TABLE projects ADD COLUMN model TEXT NOT NULL DEFAULT ''");
118
+ db.exec(applyPrefix("ALTER TABLE {{projects}} ADD COLUMN model TEXT NOT NULL DEFAULT ''"));
60
119
  }
61
120
  }
62
121
 
@@ -64,37 +123,216 @@ function migrateIssuesTable(db: Database): void {
64
123
  const have = tableColumns(db, "issues");
65
124
  if (have.size === 0) return;
66
125
  if (!have.has("closed_at")) {
67
- db.exec("ALTER TABLE issues ADD COLUMN closed_at TEXT");
126
+ db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN closed_at TEXT"));
68
127
  }
69
128
  }
70
129
 
71
- function db(): Database {
72
- if (_db) return _db;
73
- mkdirSync(dirname(DB_PATH), { recursive: true });
74
- _db = new Database(DB_PATH, { create: true, readwrite: true });
75
- _db.exec("PRAGMA journal_mode = WAL");
76
- _db.exec("PRAGMA foreign_keys = ON");
77
- _db.exec(
78
- "CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL)"
79
- );
80
- // Migration must run BEFORE schema.sql: schema.sql's CREATE TABLE for fresh
81
- // DBs doesn't help legacy DBs, and any index that references a new column
82
- // would fail if schema.sql ran first.
83
- migrateUsersTable(_db);
84
- migratePatTable(_db);
85
- migrateProjectsTable(_db);
86
- migrateIssuesTable(_db);
87
- _db.exec(readFileSync(join(import.meta.dir, "schema.sql"), "utf8"));
88
- return _db;
130
+ // ---- SqliteDriver: wraps bun:sqlite behind AsyncDatabase ----
131
+ class SqliteDriver implements AsyncDatabase {
132
+ readonly dialect = "sqlite" as const;
133
+ private readonly db: Database;
134
+ private inTx = false;
135
+ private constructor(db: Database) { this.db = db; }
136
+
137
+ static async create(): Promise<SqliteDriver> {
138
+ mkdirSync(dirname(DB_PATH), { recursive: true });
139
+ const db = new Database(DB_PATH, { create: true, readwrite: true });
140
+ db.exec("PRAGMA journal_mode = WAL");
141
+ db.exec("PRAGMA foreign_keys = ON");
142
+ db.exec(
143
+ applyPrefix(
144
+ "CREATE TABLE IF NOT EXISTS {{config}} (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL)"
145
+ )
146
+ );
147
+ // Migration must run BEFORE schema.sql (same ordering as the original file).
148
+ migrateUsersTable(db);
149
+ migratePatTable(db);
150
+ migrateProjectsTable(db);
151
+ migrateIssuesTable(db);
152
+ db.exec(applyPrefix(readFileSync(join(import.meta.dir, "schema.sql"), "utf8")));
153
+ return new SqliteDriver(db);
154
+ }
155
+
156
+ async all<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
157
+ return this.db.query(applyPrefix(sql)).all(...(params as SQLQueryBindings[])) as T[];
158
+ }
159
+ async get<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
160
+ return (this.db.query(applyPrefix(sql)).get(...(params as SQLQueryBindings[])) as T | null) ?? null;
161
+ }
162
+ async run(sql: string, params: unknown[] = []): Promise<DbRunResult> {
163
+ const info = this.db.query(applyPrefix(sql)).run(...(params as SQLQueryBindings[])) as unknown as {
164
+ lastInsertRowid: number | bigint;
165
+ changes: number;
166
+ };
167
+ return { insertId: Number(info.lastInsertRowid), changes: info.changes };
168
+ }
169
+ async exec(sql: string): Promise<void> {
170
+ this.db.exec(applyPrefix(sql));
171
+ }
172
+ async transaction<T>(fn: () => Promise<T>): Promise<T> {
173
+ if (this.inTx) {
174
+ // SQLite can't nest BEGIN without SAVEPOINT; current codebase has no
175
+ // nesting, so this safety net just runs the body inline.
176
+ return fn();
177
+ }
178
+ this.db.exec("BEGIN");
179
+ this.inTx = true;
180
+ try {
181
+ const r = await fn();
182
+ this.db.exec("COMMIT");
183
+ return r;
184
+ } catch (e) {
185
+ try { this.db.exec("ROLLBACK"); } catch { /* already rolled back */ }
186
+ throw e;
187
+ } finally {
188
+ this.inTx = false;
189
+ }
190
+ }
191
+ async close(): Promise<void> {
192
+ try { this.db.close(); } catch { /* already closed */ }
193
+ }
89
194
  }
90
195
 
91
- export function rawDB(): Database {
92
- return db();
196
+ // ---- MysqlDriver: wraps mysql2/promise behind AsyncDatabase ----
197
+ // MySQL transactions must run on a single connection, so transaction() checks
198
+ // out a connection, pins it as txConn, and routes all/get/run/exec through it
199
+ // until commit/rollback. Outside a transaction, queries hit the pool. SQLite-
200
+ // specific SQL (INSERT OR IGNORE, ON CONFLICT ... excluded.x) is translated to
201
+ // MySQL equivalents by translateForMysql() so store.ts stays single-dialect.
202
+ interface MysqlOptions {
203
+ host: string;
204
+ port: number;
205
+ user: string;
206
+ password: string;
207
+ database: string;
208
+ skipCreate: boolean;
93
209
  }
94
210
 
95
- export function getConfigAll(): Record<string, string> {
211
+ function translateForMysql(sql: string): string {
212
+ return sql
213
+ .replace(/INSERT OR IGNORE INTO/g, "INSERT IGNORE INTO")
214
+ .replace(/ON CONFLICT\((\w+)\) DO UPDATE SET/g, "ON DUPLICATE KEY UPDATE")
215
+ .replace(/excluded\.(\w+)/g, "VALUES($1)");
216
+ }
217
+
218
+ class MysqlDriver implements AsyncDatabase {
219
+ readonly dialect = "mysql" as const;
220
+ private readonly pool: Pool;
221
+ private txConn: PoolConnection | null = null;
222
+ private constructor(pool: Pool) { this.pool = pool; }
223
+
224
+ private get conn(): Pool | PoolConnection {
225
+ return this.txConn ?? this.pool;
226
+ }
227
+
228
+ static async create(opts: MysqlOptions): Promise<MysqlDriver> {
229
+ const pool = createPool({
230
+ host: opts.host,
231
+ port: opts.port,
232
+ user: opts.user,
233
+ password: opts.password,
234
+ database: opts.database,
235
+ waitForConnections: true,
236
+ connectionLimit: 10,
237
+ charset: "utf8mb4",
238
+ });
239
+ const probe = await pool.getConnection();
240
+ try {
241
+ await probe.ping();
242
+ } finally {
243
+ probe.release();
244
+ }
245
+ if (!opts.skipCreate) {
246
+ const raw = applyPrefix(readFileSync(join(import.meta.dir, "schema-mysql.sql"), "utf8"));
247
+ // MySQL has no CREATE INDEX IF NOT EXISTS, so split into statements and
248
+ // tolerate ER_DUP_KEYNAME (1061) so re-runs stay idempotent. Comment lines
249
+ // are stripped first — they may contain ';' which would corrupt the split.
250
+ const schema = raw.split("\n").filter((l) => !l.trimStart().startsWith("--")).join("\n");
251
+ for (const stmt of schema.split(";").map((s) => s.trim()).filter((s) => s.length > 0)) {
252
+ try {
253
+ await pool.query(stmt);
254
+ } catch (e) {
255
+ if (e && typeof e === "object" && "errno" in e && (e as { errno: number }).errno === 1061) continue;
256
+ throw e;
257
+ }
258
+ }
259
+ }
260
+ return new MysqlDriver(pool);
261
+ }
262
+
263
+ private prepare(sql: string): string {
264
+ return translateForMysql(applyPrefix(sql));
265
+ }
266
+
267
+ async all<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
268
+ const [rows] = await this.conn.query(this.prepare(sql), params);
269
+ return rows as T[];
270
+ }
271
+ async get<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
272
+ const [rows] = await this.conn.query(this.prepare(sql), params);
273
+ const arr = rows as T[];
274
+ return arr[0] ?? null;
275
+ }
276
+ async run(sql: string, params: unknown[] = []): Promise<DbRunResult> {
277
+ const [result] = await this.conn.query(this.prepare(sql), params);
278
+ const r = result as ResultSetHeader;
279
+ return { insertId: Number(r.insertId), changes: r.affectedRows };
280
+ }
281
+ async exec(sql: string): Promise<void> {
282
+ await this.conn.query(this.prepare(sql));
283
+ }
284
+ async transaction<T>(fn: () => Promise<T>): Promise<T> {
285
+ if (this.txConn) return fn();
286
+ const conn = await this.pool.getConnection();
287
+ this.txConn = conn;
288
+ await conn.beginTransaction();
289
+ try {
290
+ const r = await fn();
291
+ await conn.commit();
292
+ return r;
293
+ } catch (e) {
294
+ try { await conn.rollback(); } catch { /* already rolled back */ }
295
+ throw e;
296
+ } finally {
297
+ this.txConn = null;
298
+ conn.release();
299
+ }
300
+ }
301
+ async close(): Promise<void> {
302
+ try { await this.pool.end(); } catch { /* already closed */ }
303
+ }
304
+ }
305
+
306
+ let _driver: AsyncDatabase | null = null;
307
+
308
+ /** Initialize + connect the database. MUST be awaited once at boot. */
309
+ export async function initDB(): Promise<AsyncDatabase> {
310
+ if (_driver) return _driver;
311
+ if (DB_DRIVER === "mysql") {
312
+ _driver = await MysqlDriver.create({
313
+ host: process.env.WORK_DB_HOST ?? "127.0.0.1",
314
+ port: Number(process.env.WORK_DB_PORT ?? 3306),
315
+ user: process.env.WORK_DB_USER ?? "ework",
316
+ password: process.env.WORK_DB_PASSWORD ?? "",
317
+ database: process.env.WORK_DB_NAME ?? "ework",
318
+ skipCreate: DB_SKIP_CREATE,
319
+ });
320
+ } else {
321
+ _driver = await SqliteDriver.create();
322
+ }
323
+ return _driver;
324
+ }
325
+
326
+ /** Returns the initialized AsyncDatabase. Throws if initDB() wasn't awaited. */
327
+ export function getDB(): AsyncDatabase {
328
+ if (!_driver) throw new Error("getDB() called before initDB(); await initDB() at boot first");
329
+ return _driver;
330
+ }
331
+
332
+ export async function getConfigAll(): Promise<Record<string, string>> {
96
333
  try {
97
- const rows = db().query("SELECT key, value FROM config").all() as { key: string; value: string }[];
334
+ const driver = getDB();
335
+ const rows = await driver.all<{ key: string; value: string }>("SELECT key, value FROM {{config}}");
98
336
  const out: Record<string, string> = {};
99
337
  for (const r of rows) out[r.key] = r.value;
100
338
  return out;
@@ -103,16 +341,15 @@ export function getConfigAll(): Record<string, string> {
103
341
  }
104
342
  }
105
343
 
106
- export function setConfig(key: string, value: string): void {
344
+ export async function setConfig(key: string, value: string): Promise<void> {
107
345
  const now = new Date().toISOString();
108
- db()
109
- .query(
110
- "INSERT INTO config (key, value, updated_at) VALUES (?, ?, ?) " +
111
- "ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
112
- )
113
- .run(key, value, now);
346
+ await getDB().run(
347
+ "INSERT INTO {{config}} (key, value, updated_at) VALUES (?, ?, ?) " +
348
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
349
+ [key, value, now]
350
+ );
114
351
  }
115
352
 
116
- export function deleteConfig(key: string): void {
117
- db().query("DELETE FROM config WHERE key = ?").run(key);
353
+ export async function deleteConfig(key: string): Promise<void> {
354
+ await getDB().run("DELETE FROM {{config}} WHERE key = ?", [key]);
118
355
  }