Fix node:sqlite V8 crash on invalid UTF-8 in text columns (#272)

node:sqlite calls v8::String::NewFromUtf8 with kAbort on TEXT columns.
Cursor chat blobs often contain truncated multi-byte chars from streaming
boundaries, which triggers a V8 CHECK abort (not a JS exception).

Select all text-content columns as CAST(col AS BLOB) so node:sqlite
returns Uint8Array instead. Decode in JS with TextDecoder fatal:false
which replaces bad bytes with U+FFFD. Covers all three SQLite providers
(Cursor, Goose, OpenCode).

Removes the version blocklist (MIN_NODE_22_PATCH) and lowers engines
requirement from >=22.20 to >=22 since the BLOB cast approach works
on all Node 22.x versions.

Closes #264
Closes #250
This commit is contained in:
Resham Joshi 2026-05-10 17:05:08 -07:00 committed by GitHub
parent d142bd97ef
commit 02f4635cec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 101 additions and 76 deletions

View file

@ -4,7 +4,7 @@ import { homedir } from 'os'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
import type {
Provider,
SessionSource,
@ -15,18 +15,18 @@ import type {
type MessageRow = {
id: string
time_created: number
data: string
data: Uint8Array | string
}
type PartRow = {
message_id: string
data: string
data: Uint8Array | string
}
type SessionRow = {
id: string
directory: string
title: string
directory: Uint8Array | string
title: Uint8Array | string
time_created: number
}
@ -169,19 +169,19 @@ function createParser(
}
const messages = db.query<MessageRow>(
'SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC',
'SELECT id, time_created, CAST(data AS BLOB) AS data FROM message WHERE session_id = ? ORDER BY time_created ASC',
[sessionId],
)
const parts = db.query<PartRow>(
'SELECT message_id, data FROM part WHERE session_id = ? ORDER BY message_id, id',
'SELECT message_id, CAST(data AS BLOB) AS data FROM part WHERE session_id = ? ORDER BY message_id, id',
[sessionId],
)
const partsByMsg = new Map<string, PartData[]>()
for (const part of parts) {
try {
const parsed = JSON.parse(part.data) as PartData
const parsed = JSON.parse(blobToText(part.data)) as PartData
const list = partsByMsg.get(part.message_id) ?? []
list.push(parsed)
partsByMsg.set(part.message_id, list)
@ -195,7 +195,7 @@ function createParser(
for (const msg of messages) {
let data: MessageData
try {
data = JSON.parse(msg.data) as MessageData
data = JSON.parse(blobToText(msg.data)) as MessageData
} catch {
continue
}
@ -294,14 +294,18 @@ async function discoverFromDb(dbPath: string): Promise<SessionSource[]> {
try {
const rows = db.query<SessionRow>(
'SELECT id, directory, title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC',
'SELECT id, CAST(directory AS BLOB) AS directory, CAST(title AS BLOB) AS title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC',
)
return rows.map((row) => ({
path: `${dbPath}:${row.id}`,
project: row.directory ? sanitize(row.directory) : sanitize(row.title),
provider: 'opencode',
}))
return rows.map((row) => {
const dir = blobToText(row.directory)
const title = blobToText(row.title)
return {
path: `${dbPath}:${row.id}`,
project: dir ? sanitize(dir) : sanitize(title),
provider: 'opencode',
}
})
} catch {
return []
} finally {