forked from getagentseal/codeburn
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsqlite.ts
More file actions
139 lines (120 loc) · 4.87 KB
/
Copy pathsqlite.ts
File metadata and controls
139 lines (120 loc) · 4.87 KB
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
import { createRequire } from 'node:module'
/// Thin SQLite read-only wrapper over Node's built-in `node:sqlite` module (stable in
/// Node 24, experimental in Node 22 / 23). Replaces the earlier `better-sqlite3` binding
/// so the dependency graph no longer pulls in the deprecated `prebuild-install` package
/// (issue #75). Works across Cursor and OpenCode session DBs, both of which we only read.
const requireForSqlite = createRequire(import.meta.url)
type Row = Record<string, unknown>
export type SqliteDatabase = {
query<T extends Row = Row>(sql: string, params?: unknown[]): T[]
close(): void
}
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => {
prepare(sql: string): { all(...params: unknown[]): Row[] }
exec?(sql: string): void
close(): void
}
let DatabaseSync: DatabaseSyncCtor | null = null
let loadAttempted = false
let loadError: string | null = null
const textDecoder = new TextDecoder('utf-8', { fatal: false })
/// Safely decode a BLOB column (Uint8Array) to a UTF-8 string. Node's
/// node:sqlite crashes with a V8 CHECK abort when a TEXT column contains
/// invalid UTF-8 (common in Cursor chat blobs with truncated multi-byte
/// chars). By selecting those columns as `CAST(... AS BLOB)` in SQL, we
/// get a Uint8Array here and decode it in JS where bad bytes become the
/// U+FFFD replacement character instead of aborting the process.
export function blobToText(value: Uint8Array | string | null | undefined): string {
if (value == null) return ''
if (typeof value === 'string') return value
return textDecoder.decode(value)
}
/// Lazily imports `node:sqlite`. On Node 22/23 it emits an ExperimentalWarning the first
/// time the module is loaded; we silence that specific warning once so dashboards aren't
/// preceded by a scary stderr line every run. Any other warnings (including future
/// non-SQLite ones) are left untouched.
function loadDriver(): boolean {
if (loadAttempted) return DatabaseSync !== null
loadAttempted = true
const origEmit = process.emit.bind(process)
let restored = false
const restore = () => {
if (restored) return
restored = true
process.emit = origEmit
}
// Node's `process.emit` signature is overloaded; we intercept the 'warning' channel
// only and proxy everything else through unchanged. The `any` cast avoids chasing the
// overload union which isn't worth its verbosity for a single-purpose shim.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.emit = function patchedEmit(this: NodeJS.Process, event: string, ...args: any[]): boolean {
if (event === 'warning') {
const warning = args[0] as { name?: string; message?: string } | undefined
if (
warning?.name === 'ExperimentalWarning' &&
typeof warning.message === 'string' &&
/SQLite/i.test(warning.message)
) {
return false
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (origEmit as any).call(this, event, ...args)
} as typeof process.emit
try {
const mod = requireForSqlite('node:sqlite') as { DatabaseSync: DatabaseSyncCtor }
DatabaseSync = mod.DatabaseSync
return true
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
loadError =
'SQLite-based providers (Cursor, OpenCode) need Node 22+ with the node:sqlite module.\n' +
`Current Node: ${process.version}.\n` +
'Upgrade Node (https://nodejs.org) and run codeburn again.\n' +
`(underlying error: ${message})`
return false
} finally {
process.nextTick(restore)
}
}
export function isSqliteAvailable(): boolean {
return loadDriver()
}
export function getSqliteLoadError(): string {
return loadError ?? 'SQLite driver not available'
}
export function isSqliteBusyError(err: unknown): boolean {
const e = err as { code?: unknown; errcode?: unknown; errstr?: unknown; message?: unknown } | null
const code = typeof e?.code === 'string' ? e.code : ''
const errcode = typeof e?.errcode === 'number' ? e.errcode : null
const message = [
typeof e?.message === 'string' ? e.message : '',
typeof e?.errstr === 'string' ? e.errstr : '',
].join(' ')
return (
errcode === 5 ||
errcode === 6 ||
code === 'SQLITE_BUSY' ||
code === 'SQLITE_LOCKED' ||
/\bSQLITE_(BUSY|LOCKED)\b|database (?:is |table is )?locked/i.test(message)
)
}
export function openDatabase(path: string): SqliteDatabase {
if (!loadDriver() || DatabaseSync === null) {
throw new Error(getSqliteLoadError())
}
const db = new DatabaseSync(path, { readOnly: true })
try {
db.exec?.('PRAGMA busy_timeout = 1000')
} catch {
// Best effort. Some Node sqlite builds may not expose exec on DatabaseSync.
}
return {
query<T extends Row = Row>(sql: string, params: unknown[] = []): T[] {
return db.prepare(sql).all(...params) as T[]
},
close() {
db.close()
},
}
}