mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-28 02:15:06 +00:00
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
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()
|
|
},
|
|
}
|
|
}
|