ldrouter 1.6.0 → 1.6.2

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/cli.js +20 -0
  3. package/dist/server/app.js +138 -0
  4. package/dist/server/auth/api-key.js +75 -0
  5. package/dist/server/auth/crypto.js +94 -0
  6. package/dist/server/auth/ids.js +40 -0
  7. package/dist/server/auth/middleware.js +36 -0
  8. package/dist/server/auth/recovery.js +11 -0
  9. package/dist/server/caching/store.js +119 -0
  10. package/dist/server/cli/tui/ansi.js +58 -0
  11. package/dist/server/cli/tui/noise.js +397 -0
  12. package/dist/server/config/index.js +97 -0
  13. package/dist/server/db/index.js +64 -0
  14. package/dist/server/db/migrate.js +408 -0
  15. package/dist/server/db/repositories/audit.js +75 -0
  16. package/dist/server/db/repositories/settings.js +63 -0
  17. package/dist/server/db/schema.js +396 -0
  18. package/dist/server/errors.js +65 -0
  19. package/dist/server/gateway/runner.js +745 -0
  20. package/dist/server/logging/logger.js +35 -0
  21. package/dist/server/maintenance/retention.js +48 -0
  22. package/dist/server/metrics/registry.js +169 -0
  23. package/dist/server/protocols/anthropic.js +154 -0
  24. package/dist/server/protocols/canonical.js +201 -0
  25. package/dist/server/providers/index.js +89 -0
  26. package/dist/server/routes/admin/aliases.js +98 -0
  27. package/dist/server/routes/admin/api-keys.js +194 -0
  28. package/dist/server/routes/admin/audit.js +19 -0
  29. package/dist/server/routes/admin/auth.js +124 -0
  30. package/dist/server/routes/admin/backup.js +113 -0
  31. package/dist/server/routes/admin/combos.js +198 -0
  32. package/dist/server/routes/admin/dashboard.js +55 -0
  33. package/dist/server/routes/admin/models.js +178 -0
  34. package/dist/server/routes/admin/providers.js +212 -0
  35. package/dist/server/routes/admin/requests.js +156 -0
  36. package/dist/server/routes/admin/settings.js +197 -0
  37. package/dist/server/routes/admin/setup.js +80 -0
  38. package/dist/server/routes/admin/stats.js +180 -0
  39. package/dist/server/routes/admin.js +39 -0
  40. package/dist/server/routes/gateway/anthropic.js +112 -0
  41. package/dist/server/routes/gateway/openai.js +257 -0
  42. package/dist/server/routes/gateway.js +7 -0
  43. package/dist/server/routes/health.js +27 -0
  44. package/dist/server/routing/capabilities.js +52 -0
  45. package/dist/server/routing/circuit.js +37 -0
  46. package/dist/server/routing/combo.js +100 -0
  47. package/dist/server/routing/quota.js +51 -0
  48. package/dist/server/routing/ratelimit.js +58 -0
  49. package/dist/server/routing/resolver.js +43 -0
  50. package/dist/server/security/redact.js +111 -0
  51. package/dist/server/selfupdate/index.js +208 -0
  52. package/dist/server/upstream/client.js +179 -0
  53. package/dist/server/util/cidr.js +91 -0
  54. package/dist/server/util/client-ip.js +15 -0
  55. package/dist/server/util/stable-json.js +19 -0
  56. package/dist/server/version.js +35 -0
  57. package/dist/shared/types.js +2 -0
  58. package/dist/web/assets/index-B3mCvc2W.js +251 -0
  59. package/dist/web/assets/index-DSddXVaT.css +1 -0
  60. package/dist/web/favicon.png +0 -0
  61. package/dist/web/index.html +15 -0
  62. package/dist/web/logo.png +0 -0
  63. package/package.json +2 -2
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Zero-dependency interactive console UI for ldrouter.
3
+ *
4
+ * Raw stdin + ANSI escape codes only — no external dependencies. Boots the
5
+ * real server (DB -> Fastify listen), then drives a menu:
6
+ * Open Dashboard / Check for Updates / Exit.
7
+ */
8
+ import process from 'node:process';
9
+ import { spawn } from 'node:child_process';
10
+ import { hideCursor, showCursor, home, eraseDown, color } from './ansi.js';
11
+ import { getAppVersion } from '../../version.js';
12
+ import { loadConfig } from '../../config/index.js';
13
+ import { buildApp } from '../../app.js';
14
+ import { closeDb } from '../../db/index.js';
15
+ import { getSelfUpdater } from '../../selfupdate/index.js';
16
+ // Key sequences as escape literals (never raw control bytes in source).
17
+ const KEY = {
18
+ up: '\x1B[A', // ESC [ A
19
+ down: '\x1B[B', // ESC [ B
20
+ enter: '\r',
21
+ newline: '\n',
22
+ space: ' ',
23
+ ctrlC: '\x03', // Ctrl+C
24
+ esc: '\x1B', // Escape key
25
+ };
26
+ const WIDTH = 36;
27
+ const PAD = 2;
28
+ let cfg;
29
+ let app = null;
30
+ let startedAt = Date.now();
31
+ let screen = { name: 'menu' };
32
+ let menuSel = 0;
33
+ let lastRender = '';
34
+ // ---------------------------------------------------------------- output
35
+ function out(s) {
36
+ process.stdout.write(s);
37
+ }
38
+ function fmtUptime(ms) {
39
+ const s = Math.floor(ms / 1000);
40
+ const pad = (n) => String(n).padStart(2, '0');
41
+ return `${pad(Math.floor(s / 3600))}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`;
42
+ }
43
+ function kv(label, value) {
44
+ return `${' '.repeat(PAD)}${color.gray}${label.padEnd(12)}${color.reset}${value}`;
45
+ }
46
+ function menuItem(label, selected) {
47
+ const marker = selected ? `${color.cyan}❯ ${color.reset}` : ' ';
48
+ const text = selected ? `${color.cyan}${label}${color.reset}` : label;
49
+ return `${' '.repeat(PAD)}${marker}${text}`;
50
+ }
51
+ function header() {
52
+ return [
53
+ `${color.cyan}${color.bold}LateDev Router${color.reset}`,
54
+ `${color.gray}${'─'.repeat(WIDTH)}${color.reset}`,
55
+ '',
56
+ ];
57
+ }
58
+ function footer() {
59
+ return `${' '.repeat(PAD)}${color.gray}↑↓ Navigate Enter Select q Exit${color.reset}`;
60
+ }
61
+ function spinnerFrame(tick) {
62
+ const frames = ['◐', '◓', '◑', '◒'];
63
+ return frames[Math.floor(tick / 2) % frames.length];
64
+ }
65
+ function renderMenu() {
66
+ const url = `http://localhost:${cfg.port}`;
67
+ const items = ['Open Dashboard', 'Check for Updates', 'Exit'];
68
+ return [
69
+ ...header(),
70
+ `${color.green}● Server is running${color.reset}`,
71
+ '',
72
+ kv('Dashboard', url),
73
+ kv('Version', `v${getAppVersion()}`),
74
+ kv('Uptime', fmtUptime(Date.now() - startedAt)),
75
+ '',
76
+ ...items.map((label, i) => menuItem(label, i === menuSel)),
77
+ '',
78
+ footer(),
79
+ ].join('\n');
80
+ }
81
+ function renderCheck(tick) {
82
+ return [
83
+ ...header(),
84
+ `${spinnerFrame(tick)} Checking for updates...`,
85
+ ].join('\n');
86
+ }
87
+ function renderCheckResult(r, sel) {
88
+ const lines = [...header()];
89
+ if (r.latestVersion === null) {
90
+ lines.push(`${color.red}✗ Could not reach the update server${color.reset}`, '', kv('Version', `v${r.currentVersion}`), '', menuItem('Back', sel === 0));
91
+ }
92
+ else if (r.hasUpdate) {
93
+ lines.push(kv('Current', `v${r.currentVersion}`), kv('Latest', `v${r.latestVersion}`), '', `${color.green}New version available.${color.reset}`, '', menuItem(`Update to v${r.latestVersion}`, sel === 0), menuItem('Back', sel === 1));
94
+ }
95
+ else {
96
+ lines.push(`${color.green}✓ You're up to date${color.reset}`, '', kv('Version', `v${r.currentVersion}`), '', menuItem('Back', sel === 0));
97
+ }
98
+ lines.push('', footer());
99
+ return lines.join('\n');
100
+ }
101
+ function renderApplying(tick, to) {
102
+ return [
103
+ ...header(),
104
+ `${spinnerFrame(tick)} Updating to v${to}...`,
105
+ '',
106
+ `${' '.repeat(PAD)}${color.gray}Installing the new version with your package manager.${color.reset}`,
107
+ `${' '.repeat(PAD)}${color.gray}The gateway restarts automatically.${color.reset}`,
108
+ ].join('\n');
109
+ }
110
+ function renderUpdated(from, to, sel) {
111
+ return [
112
+ ...header(),
113
+ `${color.green}✓ LDRouter updated successfully${color.reset}`,
114
+ '',
115
+ `${' '.repeat(PAD)}v${from} → v${to}`,
116
+ '',
117
+ menuItem('Restart', sel === 0),
118
+ menuItem('Exit', sel === 1),
119
+ '',
120
+ footer(),
121
+ ].join('\n');
122
+ }
123
+ function renderMessage(title, lines, ok) {
124
+ return [
125
+ ...header(),
126
+ `${ok ? color.green + '✓' : color.red + '✗'} ${title}${color.reset}`,
127
+ ...lines.map((l) => `${' '.repeat(PAD)}${color.gray}${l}${color.reset}`),
128
+ '',
129
+ menuItem('Back', true),
130
+ '',
131
+ footer(),
132
+ ].join('\n');
133
+ }
134
+ function draw() {
135
+ let body;
136
+ const s = screen;
137
+ if (s.name === 'menu')
138
+ body = renderMenu();
139
+ else if (s.name === 'check')
140
+ body = renderCheck(s.spinner);
141
+ else if (s.name === 'checkResult')
142
+ body = renderCheckResult(s.result, s.sel);
143
+ else if (s.name === 'applying')
144
+ body = renderApplying(s.spinner, s.to);
145
+ else if (s.name === 'updated')
146
+ body = renderUpdated(s.from, s.to, s.sel);
147
+ else
148
+ body = renderMessage(s.title, s.lines, s.ok);
149
+ if (body === lastRender)
150
+ return;
151
+ lastRender = body;
152
+ out(home + body + '\n' + eraseDown);
153
+ }
154
+ // ---------------------------------------------------------------- keys
155
+ function readKey() {
156
+ return new Promise((resolve) => {
157
+ process.stdin.once('data', (d) => resolve(d.toString('utf8')));
158
+ });
159
+ }
160
+ // ---------------------------------------------------------------- helpers
161
+ function openUrl(url) {
162
+ const platform = process.platform;
163
+ let cmd;
164
+ let args;
165
+ if (platform === 'darwin') {
166
+ cmd = 'open';
167
+ args = [url];
168
+ }
169
+ else if (platform === 'win32') {
170
+ cmd = 'cmd';
171
+ args = ['/c', 'start', '', url.replace(/&/g, '^&')];
172
+ }
173
+ else {
174
+ cmd = 'xdg-open';
175
+ args = [url];
176
+ }
177
+ try {
178
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
179
+ }
180
+ catch {
181
+ /* spawn failed — surface as message upstream */
182
+ }
183
+ }
184
+ function respawn() {
185
+ // Re-exec this same CLI (preserves flags like --tui).
186
+ const child = spawn(process.execPath, process.argv.slice(1), {
187
+ stdio: 'inherit',
188
+ detached: true,
189
+ });
190
+ child.unref();
191
+ }
192
+ // ---------------------------------------------------------------- actions
193
+ async function openDashboard() {
194
+ const url = `http://localhost:${cfg.port}`;
195
+ openUrl(url);
196
+ screen = { name: 'message', title: 'Dashboard opened in your browser', lines: [url], ok: true };
197
+ }
198
+ async function runUpdateCheck() {
199
+ screen = { name: 'check', spinner: 0 };
200
+ draw();
201
+ const tick = setInterval(() => {
202
+ if (screen.name === 'check') {
203
+ screen.spinner++;
204
+ draw();
205
+ }
206
+ }, 120);
207
+ let result;
208
+ try {
209
+ result = await getSelfUpdater().check(true);
210
+ }
211
+ catch {
212
+ result = {
213
+ currentVersion: getAppVersion(),
214
+ latestVersion: null,
215
+ hasUpdate: false,
216
+ changelogUrl: null,
217
+ checkedAt: new Date().toISOString(),
218
+ watchtowerReachable: null,
219
+ };
220
+ }
221
+ finally {
222
+ clearInterval(tick);
223
+ }
224
+ screen = { name: 'checkResult', result, sel: 0 };
225
+ draw();
226
+ }
227
+ async function applyUpdate(result) {
228
+ const to = result.latestVersion;
229
+ if (!to)
230
+ return;
231
+ screen = { name: 'applying', spinner: 0, to };
232
+ draw();
233
+ const tick = setInterval(() => {
234
+ if (screen.name === 'applying') {
235
+ screen.spinner++;
236
+ draw();
237
+ }
238
+ }, 120);
239
+ try {
240
+ await getSelfUpdater().run();
241
+ clearInterval(tick);
242
+ screen = { name: 'updated', from: result.currentVersion, to, sel: 0 };
243
+ draw();
244
+ }
245
+ catch (e) {
246
+ clearInterval(tick);
247
+ screen = {
248
+ name: 'message',
249
+ title: 'Update failed',
250
+ lines: [e.message],
251
+ ok: false,
252
+ };
253
+ draw();
254
+ }
255
+ }
256
+ // ---------------------------------------------------------------- lifecycle
257
+ let shuttingDown = false;
258
+ function shutdown(code = 0, respawnAfter = false) {
259
+ if (shuttingDown)
260
+ return;
261
+ shuttingDown = true;
262
+ out(showCursor);
263
+ if (process.stdin.isTTY)
264
+ process.stdin.setRawMode(false);
265
+ process.stdin.pause();
266
+ void (async () => {
267
+ try {
268
+ if (app)
269
+ await app.close();
270
+ }
271
+ catch {
272
+ /* already closing */
273
+ }
274
+ finally {
275
+ closeDb();
276
+ if (respawnAfter)
277
+ respawn();
278
+ process.exit(code);
279
+ }
280
+ })();
281
+ }
282
+ export async function runCliTui() {
283
+ // Keep the TUI clean: suppress routine logs (fatal only). Must be set before
284
+ // loadConfig() reads it.
285
+ if (!process.env.LATEDEV_LOG_LEVEL)
286
+ process.env.LATEDEV_LOG_LEVEL = 'fatal';
287
+ cfg = loadConfig();
288
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
289
+ // No interactive terminal — fall back to the plain server.
290
+ const { startApp } = await import('../../app.js');
291
+ await startApp();
292
+ return;
293
+ }
294
+ process.on('SIGINT', () => shutdown(0));
295
+ process.on('SIGTERM', () => shutdown(0));
296
+ process.stdin.setRawMode(true);
297
+ process.stdin.resume();
298
+ out(hideCursor);
299
+ try {
300
+ app = await buildApp(); // opens the DB + runs migrations
301
+ await app.ready();
302
+ await app.listen({ host: cfg.host, port: cfg.port });
303
+ }
304
+ catch (e) {
305
+ out(showCursor);
306
+ if (process.stdin.isTTY)
307
+ process.stdin.setRawMode(false);
308
+ process.stderr.write(`Fatal: ${e.message}\n`);
309
+ closeDb();
310
+ process.exit(1);
311
+ }
312
+ startedAt = Date.now();
313
+ screen = { name: 'menu' };
314
+ draw();
315
+ // Live uptime + spinner redraw.
316
+ setInterval(() => {
317
+ if (screen.name === 'menu' || screen.name === 'check' || screen.name === 'applying')
318
+ draw();
319
+ }, 1000).unref();
320
+ for (;;) {
321
+ const key = await readKey();
322
+ if (key === KEY.ctrlC || key === KEY.esc) {
323
+ shutdown(0);
324
+ return;
325
+ }
326
+ // Key handling per screen. Snapshot the discriminant once — assignments
327
+ // to `screen` inside branches would otherwise re-narrow it mid-chain.
328
+ const screenName = screen.name;
329
+ if (screenName === 'menu') {
330
+ if (key === KEY.enter || key === KEY.newline || key === KEY.space) {
331
+ if (menuSel === 0)
332
+ await openDashboard();
333
+ else if (menuSel === 1)
334
+ await runUpdateCheck();
335
+ else {
336
+ shutdown(0);
337
+ return;
338
+ }
339
+ }
340
+ else if (key === KEY.up || key === 'k') {
341
+ menuSel = (menuSel + 2) % 3;
342
+ }
343
+ else if (key === KEY.down || key === 'j') {
344
+ menuSel = (menuSel + 1) % 3;
345
+ }
346
+ else if (key === 'q') {
347
+ shutdown(0);
348
+ return;
349
+ }
350
+ }
351
+ else if (screenName === 'checkResult') {
352
+ const cur = screen;
353
+ const hasChoice = cur.result.hasUpdate && cur.result.latestVersion !== null;
354
+ const count = hasChoice ? 2 : 1;
355
+ if (key === KEY.up || key === 'k') {
356
+ screen = { ...cur, sel: (cur.sel + count - 1) % count };
357
+ }
358
+ else if (key === KEY.down || key === 'j') {
359
+ screen = { ...cur, sel: (cur.sel + 1) % count };
360
+ }
361
+ else if (key === KEY.enter || key === KEY.newline || key === KEY.space) {
362
+ if (hasChoice && cur.sel === 0)
363
+ await applyUpdate(cur.result);
364
+ else
365
+ screen = { name: 'menu' };
366
+ }
367
+ else if (key === 'q' || key === 'b') {
368
+ screen = { name: 'menu' };
369
+ }
370
+ }
371
+ else if (screenName === 'updated') {
372
+ const cur = screen;
373
+ if (key === KEY.up || key === KEY.down || key === 'k' || key === 'j') {
374
+ screen = { ...cur, sel: (cur.sel + 1) % 2 };
375
+ }
376
+ else if (key === KEY.enter || key === KEY.newline || key === KEY.space) {
377
+ if (cur.sel === 0) {
378
+ shutdown(0, true);
379
+ return;
380
+ }
381
+ shutdown(0);
382
+ return;
383
+ }
384
+ else if (key === 'q') {
385
+ shutdown(0);
386
+ return;
387
+ }
388
+ }
389
+ else if (screenName === 'message') {
390
+ if (key === KEY.enter || key === KEY.newline || key === KEY.space || key === 'q' || key === 'b') {
391
+ screen = { name: 'menu' };
392
+ }
393
+ }
394
+ // 'check' and 'applying' screens ignore input while busy.
395
+ draw();
396
+ }
397
+ }
@@ -0,0 +1,97 @@
1
+ import { z } from 'zod';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import fs from 'node:fs';
5
+ import { getAppVersion } from '../version.js';
6
+ const EnvSchema = z.object({
7
+ LATEDEV_HOST: z.string().default('0.0.0.0'),
8
+ // Port 0 is valid: it means "OS-assigned random port" (used by tests and dev).
9
+ LATEDEV_PORT: z.coerce.number().int().min(0).max(65535).default(8787),
10
+ LATEDEV_DATA_DIR: z.string().optional(),
11
+ LATEDEV_MASTER_KEY: z.string().optional(),
12
+ LATEDEV_TRUST_PROXY: z.coerce.number().int().min(0).max(8).default(0),
13
+ LATEDEV_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
14
+ LATEDEV_DB_URL: z.string().optional(),
15
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
16
+ });
17
+ let cached = null;
18
+ /** Empty-string env vars (e.g. `LATEDEV_MASTER_KEY:` in docker-compose) must not
19
+ * shadow real defaults — treat them as unset. */
20
+ function normalizeEnv(env) {
21
+ const out = { ...env };
22
+ for (const k of Object.keys(out)) {
23
+ if (out[k] === '')
24
+ delete out[k];
25
+ }
26
+ return out;
27
+ }
28
+ function readMasterKeyFile(dataDir) {
29
+ try {
30
+ const p = path.join(dataDir, 'master.key');
31
+ if (fs.existsSync(p)) {
32
+ return fs.readFileSync(p, 'utf8').trim();
33
+ }
34
+ }
35
+ catch {
36
+ /* ignore unreadable file — treat as absent */
37
+ }
38
+ return null;
39
+ }
40
+ export function loadConfig(env = process.env, argv = process.argv) {
41
+ if (cached)
42
+ return cached;
43
+ const parsed = EnvSchema.parse(normalizeEnv(env));
44
+ const cliArgs = parseArgs(argv);
45
+ const dataDir = cliArgs.dataDir ??
46
+ parsed.LATEDEV_DATA_DIR ??
47
+ (parsed.NODE_ENV === 'test' ? path.resolve('../.tmp/test-data') : path.resolve(os.homedir(), '.latedev-router'));
48
+ const isContainer = Boolean(env.LATEDEV_DATA_DIR?.startsWith('/data') || process.env.CONTAINER === '1');
49
+ const dbFile = parsed.LATEDEV_DB_URL ?? path.join(dataDir, 'data.sqlite');
50
+ cached = {
51
+ host: cliArgs.host ?? parsed.LATEDEV_HOST,
52
+ port: cliArgs.port ?? parsed.LATEDEV_PORT,
53
+ dataDir,
54
+ dbFile,
55
+ masterKey: parsed.LATEDEV_MASTER_KEY ?? readMasterKeyFile(dataDir),
56
+ trustProxyHops: parsed.LATEDEV_TRUST_PROXY,
57
+ logLevel: parsed.LATEDEV_LOG_LEVEL,
58
+ env: parsed.NODE_ENV,
59
+ isContainer,
60
+ appVersion: getAppVersion(),
61
+ };
62
+ return cached;
63
+ }
64
+ export function resetConfigForTests() {
65
+ cached = null;
66
+ }
67
+ export function setConfigMasterKey(key) {
68
+ process.env.LATEDEV_MASTER_KEY = key; // future loadConfig reads pick it up
69
+ if (cached) {
70
+ cached.masterKey = key;
71
+ }
72
+ }
73
+ function parseArgs(argv) {
74
+ const out = {};
75
+ for (let i = 0; i < argv.length; i++) {
76
+ const a = argv[i];
77
+ if (a === '--host' && argv[i + 1]) {
78
+ out.host = argv[++i];
79
+ }
80
+ else if (a?.startsWith('--host=')) {
81
+ out.host = a.split('=')[1];
82
+ }
83
+ else if (a === '--port' && argv[i + 1]) {
84
+ out.port = Number(argv[++i]);
85
+ }
86
+ else if (a?.startsWith('--port=')) {
87
+ out.port = Number(a.split('=')[1]);
88
+ }
89
+ else if (a === '--data-dir' && argv[i + 1]) {
90
+ out.dataDir = argv[++i];
91
+ }
92
+ else if (a?.startsWith('--data-dir=')) {
93
+ out.dataDir = a.split('=')[1];
94
+ }
95
+ }
96
+ return out;
97
+ }
@@ -0,0 +1,64 @@
1
+ // Database bootstrap: opens SQLite WAL, applies pending migrations, returns a Drizzle DB handle.
2
+ import Database from 'better-sqlite3';
3
+ import { drizzle } from 'drizzle-orm/better-sqlite3';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import * as schema from './schema.js';
8
+ import { getLogger } from '../logging/logger.js';
9
+ import { runMigrations } from './migrate.js';
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ /** Locate the migrations directory across the possible layouts:
12
+ * - Docker dist: dist/server/db → ../../migrations (dist/migrations)
13
+ * - npm package dist: dist/server/db → ../../../migrations (<pkg>/migrations)
14
+ * - source tree (dev/test): src/server/db → ../../../migrations (repo/migrations)
15
+ */
16
+ function resolveMigrationsDir() {
17
+ const distDir = path.resolve(__dirname, '../../migrations');
18
+ const rootDir = path.resolve(__dirname, '../../../migrations');
19
+ if (fs.existsSync(distDir))
20
+ return distDir;
21
+ if (fs.existsSync(rootDir))
22
+ return rootDir;
23
+ return distDir; // let runMigrations log the missing-dir fallback
24
+ }
25
+ let _db = null;
26
+ let _raw = null;
27
+ export function openDb(dbFile) {
28
+ if (_db && _raw)
29
+ return { db: _db, raw: _raw };
30
+ const dir = path.dirname(dbFile);
31
+ if (dir && !fs.existsSync(dir))
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const raw = new Database(dbFile);
34
+ // Pragmas for WAL + safety
35
+ raw.pragma('journal_mode = WAL');
36
+ raw.pragma('synchronous = NORMAL');
37
+ raw.pragma('foreign_keys = ON');
38
+ raw.pragma('busy_timeout = 5000');
39
+ raw.pragma('temp_store = MEMORY');
40
+ raw.pragma('cache_size = -20000');
41
+ const db = drizzle(raw, { schema });
42
+ _raw = raw;
43
+ _db = db;
44
+ runMigrations(raw, getLogger(), resolveMigrationsDir());
45
+ return { db, raw };
46
+ }
47
+ export function closeDb() {
48
+ if (_raw) {
49
+ try {
50
+ _raw.close();
51
+ }
52
+ catch (e) {
53
+ getLogger().warn({ err: String(e) }, 'failed to close db');
54
+ }
55
+ _raw = null;
56
+ _db = null;
57
+ }
58
+ }
59
+ export function getDb() {
60
+ if (!_db)
61
+ throw new Error('Database not opened');
62
+ return _db;
63
+ }
64
+ export { schema };