Skip to content

Commit 6e4015b

Browse files
ralyodioclaude
andcommitted
fix: auto-reconnect stale SQLite Cloud connection in getDb()
The cached Database client could hold a dead websocket after the SQLite Cloud node auto-pauses (free tier) and resumes, making every query fail with "Connection unavailable" until the process restarts — which silently emptied /blog and broke CrawlProof webhook inserts. Wrap the client's `sql` tagged-template so disconnect-class errors drop the client and retry once on a fresh connection. Drop-in: all call sites use only `db.sql`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 778da01 commit 6e4015b

1 file changed

Lines changed: 49 additions & 8 deletions

File tree

apps/web/lib/db.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,55 @@ import 'server-only';
22
import { Database } from '@sqlitecloud/drivers';
33
import { loadRootEnv } from './root-env';
44

5-
let db: Database | null = null;
5+
let client: Database | null = null;
66

7-
export function getDb(): Database {
8-
if (!db) {
9-
loadRootEnv();
10-
const url = process.env.SQLITECLOUD_URL;
11-
if (!url) throw new Error('SQLITECLOUD_URL is not set');
12-
db = new Database({ connectionstring: url, usewebsocket: true });
7+
function createClient(): Database {
8+
loadRootEnv();
9+
const url = process.env.SQLITECLOUD_URL;
10+
if (!url) throw new Error('SQLITECLOUD_URL is not set');
11+
return new Database({ connectionstring: url, usewebsocket: true });
12+
}
13+
14+
// A cached client can hold a dead websocket after the SQLite Cloud node pauses
15+
// and resumes (free-tier auto-pause). Without this, getDb() keeps returning the
16+
// same broken connection and every query fails with "Connection unavailable"
17+
// until the process is restarted — which is exactly how /blog silently emptied.
18+
// Treat disconnect-class errors as recoverable: drop the client and retry once
19+
// with a fresh connection.
20+
function isDisconnect(err: unknown): boolean {
21+
const msg = err instanceof Error ? err.message : String(err);
22+
return /connection unavailable|got disconnected|disconnected|connection not been established|ERR_CONNECTION/i.test(
23+
msg
24+
);
25+
}
26+
27+
async function runSql(args: unknown[]): Promise<unknown> {
28+
if (!client) client = createClient();
29+
try {
30+
return await (client.sql as (...a: unknown[]) => Promise<unknown>)(...args);
31+
} catch (err) {
32+
if (!isDisconnect(err)) throw err;
33+
try {
34+
(client as unknown as { close?: () => void }).close?.();
35+
} catch {
36+
/* ignore close failures on an already-dead socket */
37+
}
38+
client = createClient();
39+
return await (client.sql as (...a: unknown[]) => Promise<unknown>)(...args);
1340
}
14-
return db;
41+
}
42+
43+
// Returns the shared client with its `sql` tagged-template wrapped so a stale
44+
// connection self-heals. All call sites use only `db.sql`, so this is drop-in.
45+
export function getDb(): Database {
46+
if (!client) client = createClient();
47+
return new Proxy(client, {
48+
get(target, prop, receiver) {
49+
if (prop === 'sql') {
50+
return (...args: unknown[]) => runSql(args);
51+
}
52+
const value = Reflect.get(target, prop, receiver);
53+
return typeof value === 'function' ? value.bind(target) : value;
54+
},
55+
});
1556
}

0 commit comments

Comments
 (0)