Bundle Bun for headless Orca and profile persistence (#22635)

Bundle a pinned, verified Bun runtime for headless Orca so existing Node launch commands can hand off before opening a profile. Keep desktop execution on Electron.

Add the Bun SQLite adapter and terminal backend, bounded shutdown, process inspection and cross-platform artifact qualification. Keep future managed SSH deployment separate from current production launch paths.
This commit is contained in:
OrcaWin
2026-09-25 22:49:06 -07:00
committed by GitHub
parent 38bcdf76ac
commit 6fc3cdcad6
173 changed files with 13419 additions and 948 deletions
+180
View File
@@ -0,0 +1,180 @@
import * as fs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { runProcess } from '../../shared/child-process/run-process'
import { initializeBunReadonlyWal } from './bun-readonly-wal'
import Database from './sync-database'
vi.mock('node:fs', async (importOriginal) => {
const original = await importOriginal<typeof fs>()
return { ...original, openSync: vi.fn(original.openSync) }
})
const directories: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
const original = await vi.importActual<typeof fs>('node:fs')
vi.mocked(fs.openSync).mockImplementation(original.openSync)
for (const directory of directories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true })
}
})
function fixture(wal = true): string {
const directory = fs.mkdtempSync(join(tmpdir(), 'orca-readonly-wal-'))
directories.push(directory)
const file = join(directory, 'state.db')
const db = new Database(file)
db.exec('CREATE TABLE state (id INTEGER PRIMARY KEY, value TEXT)')
if (wal) {
db.pragma('journal_mode=WAL')
}
db.close()
return file
}
describe('readonly WAL initialization', () => {
it.each(['EACCES', 'EPERM', 'EROFS'])(
'lets SQLite decide read access when WAL creation fails with %s',
async (code) => {
const file = fixture()
const { openSync: open } = await vi.importActual<typeof fs>('node:fs')
vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => {
if (path === `${file}-wal` && flags === 'wx') {
throw Object.assign(new Error('read-only directory'), { code })
}
return open(path, flags, ...rest)
})
expect(() => initializeBunReadonlyWal(file)).not.toThrow()
expect(fs.existsSync(`${file}-wal`)).toBe(false)
}
)
it('still reports unexpected WAL creation failures', async () => {
const file = fixture()
const { openSync: open } = await vi.importActual<typeof fs>('node:fs')
vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => {
if (path === `${file}-wal` && flags === 'wx') {
throw Object.assign(new Error('disk failure'), { code: 'EIO' })
}
return open(path, flags, ...rest)
})
expect(() => initializeBunReadonlyWal(file)).toThrow('disk failure')
})
it('allows a clean WAL database to reopen without changing its bytes or admitting SQL writes', () => {
const file = fixture()
const bytes = fs.readFileSync(file)
initializeBunReadonlyWal(file)
if (process.platform !== 'win32') {
expect(fs.statSync(`${file}-wal`).mode & 0o777).toBe(0o600)
}
const reader = new Database(file, { readonly: true })
try {
expect(reader.prepare('SELECT COUNT(*) AS count FROM state').get()).toEqual({ count: 0 })
expect(() => reader.exec("INSERT INTO state VALUES(1,'write')")).toThrow()
} finally {
reader.close()
}
expect(fs.readFileSync(file)).toEqual(bytes)
})
it('leaves a concurrent writer’s newly published WAL intact', async () => {
const file = fixture()
const { openSync: open } = await vi.importActual<typeof fs>('node:fs')
vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => {
if (path === `${file}-wal` && flags === 'wx') {
fs.writeFileSync(path, 'concurrent writer evidence')
}
return open(path, flags, ...rest)
})
initializeBunReadonlyWal(file)
expect(fs.readFileSync(`${file}-wal`, 'utf8')).toBe('concurrent writer evidence')
})
it('leaves a current writer’s WAL bytes intact', () => {
const file = fixture()
const writer = new Database(file)
try {
writer.exec("INSERT INTO state VALUES(1,'durable')")
const before = fs.readFileSync(`${file}-wal`)
initializeBunReadonlyWal(file)
expect(fs.readFileSync(`${file}-wal`)).toEqual(before)
const reader = new Database(file, { readonly: true })
try {
expect(reader.prepare('SELECT value FROM state').get()).toEqual({ value: 'durable' })
} finally {
reader.close()
}
} finally {
writer.close()
}
})
it.skipIf(process.platform === 'win32')('opens a cold WAL database through a symlink', () => {
const file = fixture()
const alias = join(directories[0]!, 'alias.db')
fs.symlinkSync(file, alias)
const reader = new Database(alias, { readonly: true })
try {
expect(reader.prepare('SELECT COUNT(*) AS count FROM state').get()).toEqual({ count: 0 })
expect(fs.existsSync(`${alias}-wal`)).toBe(false)
} finally {
reader.close()
}
})
it('does not add recovery evidence to a rollback-journal database or malformed file', () => {
const file = fixture(false)
initializeBunReadonlyWal(file)
expect(fs.existsSync(`${file}-wal`)).toBe(false)
fs.writeFileSync(file, 'not SQLite')
initializeBunReadonlyWal(file)
expect(fs.existsSync(`${file}-wal`)).toBe(false)
})
it.skipIf(process.platform !== 'darwin' || !process.versions.bun)(
'preserves another connection’s exclusive lock while inspecting the database header',
async () => {
const file = fixture(false)
const writer = new Database(file)
const probe = () =>
runProcess({
program: process.execPath,
args: [
'-e',
`const { Database } = require('bun:sqlite')
const db = new Database(process.argv[1], { readonly: true })
try { db.prepare('SELECT * FROM state').all(); process.stdout.write('readable') }
catch (error) { process.stdout.write(error.code) }
finally { db.close(true) }`,
file
],
timeoutMs: 5_000
})
try {
writer.exec("BEGIN EXCLUSIVE; INSERT INTO state VALUES(1,'uncommitted')")
expect(await probe()).toMatchObject({ code: 0, stdout: 'SQLITE_BUSY' })
const reader = new Database(file, { readonly: true })
reader.close()
expect(await probe()).toMatchObject({ code: 0, stdout: 'SQLITE_BUSY' })
} finally {
writer.close()
}
}
)
it.skipIf(process.platform === 'win32')(
'does not follow an existing dangling WAL symlink',
() => {
const file = fixture()
const outside = join(directories[0]!, 'unrelated')
fs.symlinkSync(outside, `${file}-wal`)
initializeBunReadonlyWal(file)
expect(fs.existsSync(outside)).toBe(false)
expect(fs.lstatSync(`${file}-wal`).isSymbolicLink()).toBe(true)
}
)
})
+37
View File
@@ -0,0 +1,37 @@
import { closeSync, existsSync, openSync, readSync } from 'node:fs'
/** Match SQLite's WAL creation without permitting writes through the database connection. */
export function initializeBunReadonlyWal(path: string): void {
const wal = `${path}-wal`
if (existsSync(wal) || !hasWalHeader(path)) {
return
}
try {
// Apple's SQLite requires an existing WAL; exclusive creation preserves every existing byte.
closeSync(openSync(wal, 'wx', 0o600))
} catch (error) {
if (
typeof error !== 'object' ||
error === null ||
!('code' in error) ||
!['EEXIST', 'EACCES', 'EPERM', 'EROFS'].includes(String(error.code))
) {
throw error
}
}
}
function hasWalHeader(path: string): boolean {
const file = openSync(path, 'r')
try {
const header = Buffer.alloc(20)
return (
readSync(file, header, 0, header.length, 0) === header.length &&
header.subarray(0, 16).toString() === 'SQLite format 3\0' &&
header[18] === 2 &&
header[19] === 2
)
} finally {
closeSync(file)
}
}
+129
View File
@@ -0,0 +1,129 @@
import type { DatabaseSync } from 'node:sqlite'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { BunSqliteStatement, type BunStatement } from './bun-sqlite-statement'
import { initializeBunReadonlyWal } from './bun-readonly-wal'
type BunDatabase = {
exec(sql: string): void
prepare(sql: string): BunStatement
readonly inTransaction: boolean
close(throwOnError: boolean): void
fileControl(command: number, value: Int32Array): number
}
type BunSqlite = {
Database: new (path: string, flags: number) => BunDatabase
}
const SQLITE_OPEN_READONLY = 0x01
const SQLITE_OPEN_READWRITE = 0x02
const SQLITE_OPEN_CREATE = 0x04
const SQLITE_OPEN_URI = 0x40
const SQLITE_FCNTL_PERSIST_WAL = 10
export function loadBunSqlite(): BunSqlite | undefined {
if (!process.versions.bun || typeof process.getBuiltinModule !== 'function') {
return undefined
}
const sqlite: unknown = process.getBuiltinModule('bun:sqlite')
return isBunSqlite(sqlite) ? sqlite : undefined
}
function isBunSqlite(value: unknown): value is BunSqlite {
return (
typeof value === 'object' &&
value !== null &&
'Database' in value &&
typeof value.Database === 'function'
)
}
export class BunSqliteDatabase {
private readonly database: BunDatabase
constructor(
path: ConstructorParameters<typeof DatabaseSync>[0],
options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {}
) {
const sqlite = loadBunSqlite()
if (!sqlite) {
throw new Error('SQLite is unavailable in this runtime')
}
const filename =
path instanceof URL ? fileURLToPath(path) : typeof path === 'string' ? path : path.toString()
const flags = options.readonly
? SQLITE_OPEN_READONLY
: SQLITE_OPEN_READWRITE | (options.fileMustExist ? 0 : SQLITE_OPEN_CREATE)
this.database = new sqlite.Database(
filename === ':memory:' || filename === '' ? filename : sqliteFileUri(filename),
// URI parsing defaults differ between the platform SQLite libraries.
flags | SQLITE_OPEN_URI
)
try {
if (process.platform === 'darwin' && filename !== ':memory:' && filename !== '') {
// Apple's default retains WAL files; match the other shipped SQLite drivers.
if (this.database.fileControl(SQLITE_FCNTL_PERSIST_WAL, new Int32Array(2)) !== 0) {
throw new Error('SQLite cannot configure WAL cleanup')
}
if (options.readonly) {
const statement = this.database.prepare('PRAGMA database_list')
try {
const path = statement.get()?.file
if (typeof path !== 'string' || path.length === 0) {
throw new Error('SQLite did not report its database filename')
}
// SQLite resolves symlinks before locating its sidecars.
initializeBunReadonlyWal(path)
} finally {
statement.finalize()
}
}
}
const timeout = options.timeout ?? 0
if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 2_147_483_647) {
throw new RangeError('SQLite busy timeout must be a nonnegative 32-bit integer')
}
this.database.exec(`PRAGMA foreign_keys = ON; PRAGMA busy_timeout = ${timeout}`)
} catch (error) {
this.database.close(true)
throw error
}
}
exec(sql: string): void {
this.database.exec(sql)
}
prepare(sql: string): BunSqliteStatement {
return new BunSqliteStatement(this.database.prepare(sql), () => this.database.prepare(sql))
}
get isTransaction(): boolean {
return this.database.inTransaction
}
/** Logical snapshot; implicit rowids may change. The caller runs this in its backup worker. */
backup(path: string): void {
const statement = this.database.prepare('VACUUM INTO ?')
try {
statement.run(sqliteFileUri(path))
} finally {
statement.finalize()
}
}
close(): void {
this.database.close(true)
}
}
function sqliteFileUri(path: string): string {
const url = pathToFileURL(path)
if (url.hostname) {
const hostname = url.hostname
url.hostname = ''
// SQLite accepts UNC paths with an empty URI authority on Windows.
url.pathname = `//${hostname}${url.pathname}`
}
return url.href
}
+77
View File
@@ -0,0 +1,77 @@
import type { SQLInputValue, StatementResultingChanges } from 'node:sqlite'
import { SqliteIntegerReader } from './sqlite-integer-reader'
import type { SqliteBindings, SqliteRow, SqliteStatement } from './sqlite-statement'
type BunBindings = (SQLInputValue | SQLInputValue[])[]
const EMPTY_BINDINGS: BunBindings = [[]]
export type BunStatement = {
all(...parameters: BunBindings): SqliteRow[]
get(...parameters: BunBindings): SqliteRow | null
run(...parameters: BunBindings): StatementResultingChanges
iterate(...parameters: BunBindings): IterableIterator<SqliteRow>
finalize(): void
safeIntegers(enabled: boolean): void
readonly paramsCount: number
}
export class BunSqliteStatement implements SqliteStatement {
private readonly integers = new SqliteIntegerReader()
private readonly parameterCount: number
constructor(
private readonly statement: BunStatement,
private readonly prepareIterator: () => BunStatement
) {
statement.safeIntegers(true)
this.parameterCount = statement.paramsCount
}
all(...parameters: SqliteBindings): SqliteRow[] {
const rows = this.statement.all(...this.bindings(parameters))
for (const row of rows) {
this.integers.row(row)
}
return rows
}
get(...parameters: SqliteBindings): SqliteRow | undefined {
const row = this.statement.get(...this.bindings(parameters))
return row === null ? undefined : this.integers.row(row)
}
run(...parameters: SqliteBindings): StatementResultingChanges {
return this.integers.result(this.statement.run(...this.bindings(parameters)))
}
*iterate(...parameters: SqliteBindings): IterableIterator<SqliteRow> {
// Bun leaves interrupted iterators positioned on their last row and exposes no reset.
const statement = this.prepareIterator()
try {
statement.safeIntegers(true)
for (const row of statement.iterate(...this.bindings(parameters))) {
yield this.integers.row(row)
}
} finally {
statement.finalize()
}
}
setReadBigInts(enabled: boolean): void {
this.integers.readBigInts = enabled
}
private bindings(parameters: SqliteBindings): BunBindings {
if (parameters.some((value) => value === undefined)) {
throw new TypeError('Undefined cannot be bound to a SQLite parameter')
}
// No arguments would reuse the driver's previous bindings.
if (parameters.length === 0) {
return EMPTY_BINDINGS
}
if (parameters.length >= this.parameterCount) {
return parameters
}
return [...parameters, ...Array<null>(this.parameterCount - parameters.length).fill(null)]
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { StatementResultingChanges } from 'node:sqlite'
import { SqliteIntegerReader } from './sqlite-integer-reader'
import type { SqliteBindings, SqliteRow, SqliteStatement } from './sqlite-statement'
export class NodeSqliteStatement implements SqliteStatement {
private readonly integers = new SqliteIntegerReader()
constructor(private readonly statement: SqliteStatement) {
// Native number reads overflow their INT64_MIN guard on some builds and round insert rowids.
statement.setReadBigInts(true)
}
all(...parameters: SqliteBindings): SqliteRow[] {
const rows = this.statement.all(...parameters)
for (const row of rows) {
this.integers.row(row)
}
return rows
}
get(...parameters: SqliteBindings): SqliteRow | undefined {
const row = this.statement.get(...parameters)
return row === undefined ? undefined : this.integers.row(row)
}
run(...parameters: SqliteBindings): StatementResultingChanges {
return this.integers.result(this.statement.run(...parameters))
}
*iterate(...parameters: SqliteBindings): IterableIterator<SqliteRow> {
for (const row of this.statement.iterate(...parameters)) {
yield this.integers.row(row)
}
}
setReadBigInts(enabled: boolean): void {
this.integers.readBigInts = enabled
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { StatementResultingChanges } from 'node:sqlite'
import type { SqliteRow } from './sqlite-statement'
export class SqliteIntegerReader {
readBigInts = false
row(row: SqliteRow): SqliteRow {
if (!this.readBigInts) {
for (const key of Object.keys(row)) {
const value = row[key]
if (typeof value === 'bigint') {
row[key] = this.integer(value)
}
}
}
return row
}
result(result: StatementResultingChanges): StatementResultingChanges {
return {
changes: this.integer(result.changes, false),
lastInsertRowid: this.integer(result.lastInsertRowid, false)
}
}
private integer(value: number | bigint, requireSafeNumber = true): number | bigint {
if (this.readBigInts) {
return BigInt(value)
}
const number = Number(value)
if (!Number.isSafeInteger(number)) {
// Metadata conversion must not report failure after a write has committed.
if (!requireSafeNumber) {
return BigInt(value)
}
throw new RangeError('SQLite integer cannot be represented safely as a JavaScript number')
}
return number
}
}
+11 -8
View File
@@ -10,13 +10,12 @@ import { classifySqliteReadFailure, isTransientSqliteContention } from './sqlite
// with no usable -shm reports errcode 14 ("unable to open database file"). The
// two need opposite responses, so the classifier must never conflate them.
let tempDirs: string[] = []
const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs) {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
tempDirs = []
})
function contendedDatabase(): { path: string; release: () => void } {
@@ -40,18 +39,22 @@ function contendedDatabase(): { path: string; release: () => void } {
describe('isTransientSqliteContention', () => {
it('recognizes a real SQLITE_BUSY thrown by a read-only open', () => {
const contended = contendedDatabase()
let reader: SyncDatabase | undefined
let thrown: unknown
try {
new SyncDatabase(contended.path, { readonly: true, timeout: 0 })
.prepare('SELECT id FROM session')
.all()
reader = new SyncDatabase(contended.path, { readonly: true, timeout: 0 })
reader.prepare('SELECT id FROM session').all()
} catch (error) {
thrown = error
} finally {
contended.release()
try {
reader?.close()
} finally {
contended.release()
}
}
expect((thrown as { errcode?: number }).errcode).toBe(5)
expect(thrown).toMatchObject({ [process.versions.bun ? 'errno' : 'errcode']: 5 })
expect(isTransientSqliteContention(thrown)).toBe(true)
})
+2 -2
View File
@@ -13,10 +13,10 @@ const SQLITE_CANTOPEN = 14
const CONTENTION_MESSAGE = /SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i
function primaryErrcode(error: unknown): number | null {
if (!error || typeof error !== 'object' || !('errcode' in error)) {
if (!error || typeof error !== 'object') {
return null
}
const errcode = (error as { errcode?: unknown }).errcode
const errcode = 'errcode' in error ? error.errcode : 'errno' in error ? error.errno : undefined
return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null
}
+12
View File
@@ -0,0 +1,12 @@
import type { SQLInputValue, SQLOutputValue, StatementResultingChanges } from 'node:sqlite'
export type SqliteBindings = SQLInputValue[]
export type SqliteRow = Record<string, SQLOutputValue>
export type SqliteStatement = {
all(...parameters: SqliteBindings): SqliteRow[]
get(...parameters: SqliteBindings): SqliteRow | undefined
run(...parameters: SqliteBindings): StatementResultingChanges
iterate(...parameters: SqliteBindings): IterableIterator<SqliteRow>
setReadBigInts(enabled: boolean): void
}
@@ -0,0 +1,86 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import { expect, it } from 'vitest'
import SyncDatabase from './sync-database'
const WRITER_SOURCE = `
const { parentPort, workerData } = require('node:worker_threads')
const Database = process.versions.bun
? require('bun:sqlite').Database
: require('node:sqlite').DatabaseSync
const db = new Database(workerData.path)
db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL')
const count = new Int32Array(workerData.count)
let revision = 0
function commit() {
revision += 1
db.exec('BEGIN IMMEDIATE; UPDATE marker SET revision=' + revision + '; COMMIT')
Atomics.store(count, 0, revision)
if (revision === 1) {
parentPort.once('message', commit)
parentPort.postMessage('writing')
return
}
if (revision < 40) setTimeout(commit, 2)
else { db.close(true); parentPort.close() }
}
commit()
`
it('backs up one complete revision while another thread commits to the WAL', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-sqlite-concurrent-backup-'))
const path = join(directory, 'source.db')
const target = join(directory, 'snapshot.db')
const source = new SyncDatabase(path)
let writer: Worker | undefined
let snapshot: SyncDatabase | undefined
try {
source.exec(`
PRAGMA journal_mode=WAL;
PRAGMA synchronous=FULL;
CREATE TABLE marker(name TEXT PRIMARY KEY, revision INTEGER NOT NULL);
INSERT INTO marker VALUES('first',0),('second',0);
CREATE TABLE payload(id INTEGER PRIMARY KEY, value BLOB NOT NULL);
WITH RECURSIVE rows(id) AS (VALUES(1) UNION ALL SELECT id+1 FROM rows WHERE id<1024)
INSERT INTO payload SELECT id, zeroblob(65536) FROM rows;
`)
const sharedCount = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)
const count = new Int32Array(sharedCount)
writer = new Worker(WRITER_SOURCE, { eval: true, workerData: { path, count: sharedCount } })
const firstCommit = new Promise<void>((resolve, reject) => {
writer?.once('message', () => resolve())
writer?.once('error', reject)
})
const writerExit = new Promise<void>((resolve, reject) => {
writer?.once('error', reject)
writer?.once('exit', (code) =>
code === 0 ? resolve() : reject(new Error(`Writer exited ${code}`))
)
})
// Attach failure handling before awaiting either event.
void writerExit.catch(() => {})
await firstCommit
const before = Atomics.load(count, 0)
// Keep the writer alive until this thread is ready to start the backup.
writer.postMessage('continue')
await source.backup(target)
const after = Atomics.load(count, 0)
expect(after).toBeGreaterThan(before)
await writerExit
snapshot = new SyncDatabase(target, { readonly: true, fileMustExist: true })
const rows = snapshot.prepare('SELECT revision FROM marker ORDER BY name').all()
expect(rows).toHaveLength(2)
expect(rows[0]).toEqual(rows[1])
expect(rows[0]?.revision).toBeGreaterThanOrEqual(before)
expect(rows[0]?.revision).toBeLessThanOrEqual(Atomics.load(count, 0))
expect(snapshot.prepare('SELECT count(*) AS count FROM payload').get()).toEqual({ count: 1024 })
expect(snapshot.pragma('integrity_check', { simple: true })).toBe('ok')
} finally {
await writer?.terminate()
snapshot?.close()
source.close()
rmSync(directory, { recursive: true, force: true })
}
}, 20_000)
@@ -0,0 +1,278 @@
import { existsSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import SyncDatabase, { isSqliteAvailable } from './sync-database'
const directories: string[] = []
const databases: SyncDatabase[] = []
function fixture(): string {
const directory = mkdtempSync(join(tmpdir(), 'orca-sqlite-runtime-'))
directories.push(directory)
return directory
}
function open(path: string): SyncDatabase {
const db = new SyncDatabase(path)
databases.push(db)
return db
}
afterEach(() => {
for (const database of databases.splice(0)) {
try {
database.close()
} catch {
// A close-contract test already released this connection.
}
}
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('SQLite runtime contract', () => {
it('restarts an interrupted cached iterator without skipping its failed row', () => {
const db = open(':memory:')
db.exec('CREATE TABLE rows(value INTEGER); INSERT INTO rows VALUES(1),(2),(3)')
const statement = db.prepare('SELECT value FROM rows ORDER BY value')
for (let attempt = 0; attempt < 3; attempt++) {
expect(() => {
for (const row of statement.iterate()) {
expect(row.value).toBe(1)
throw new Error('invalid row')
}
}).toThrow('invalid row')
}
expect([...statement.iterate()]).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }])
db.exec('DROP TABLE rows')
})
it('admits the actual runtime driver', () => {
expect(isSqliteAvailable()).toBe(true)
})
it('enforces foreign keys by default', () => {
const db = open(':memory:')
db.exec(
'CREATE TABLE parents(id INTEGER PRIMARY KEY); CREATE TABLE children(parent INTEGER REFERENCES parents(id))'
)
expect(() => db.prepare('INSERT INTO children VALUES(?)').run(1)).toThrow()
expect(db.prepare('SELECT * FROM children').all()).toEqual([])
db.prepare('INSERT INTO parents VALUES(?)').run(1)
expect(db.prepare('INSERT INTO children VALUES(?)').run(1).changes).toBe(1)
expect(() => db.prepare('DELETE FROM parents WHERE id = ?').run(1)).toThrow()
})
it('returns safe integers as numbers without changing other SQLite values', () => {
const db = open(':memory:')
const row = db
.prepare('SELECT ? AS integer, ? AS real, ? AS text, ? AS blob, ? AS empty')
.get(Number.MAX_SAFE_INTEGER, 1.25, 'héllo', Buffer.from([0, 255, 17]), null)
expect(row).toEqual({
integer: Number.MAX_SAFE_INTEGER,
real: 1.25,
text: 'héllo',
blob: expect.any(Uint8Array),
empty: null
})
expect(row?.blob).toEqual(new Uint8Array([0, 255, 17]))
})
it('preserves 64-bit integers and rejects rounding in every reader by default', () => {
const db = open(':memory:')
const statement = db.prepare('SELECT ? AS integer')
for (const value of [
-(1n << 63n),
-(1n << 63n) + 1n,
-(1n << 53n),
1n << 53n,
(1n << 53n) + 1n,
(1n << 63n) - 1n
]) {
expect(() => statement.get(value)).toThrow(RangeError)
expect(() => statement.all(value)).toThrow(RangeError)
expect(() => [...statement.iterate(value)]).toThrow(RangeError)
statement.setReadBigInts(true)
expect(statement.get(value)).toEqual({ integer: value })
expect(statement.all(value)).toEqual([{ integer: value }])
expect([...statement.iterate(value)]).toEqual([{ integer: value }])
statement.setReadBigInts(false)
}
})
it('distinguishes large REAL values from INTEGER values in the same column', () => {
const db = open(':memory:')
db.exec('CREATE TABLE values_by_type(value); INSERT INTO values_by_type VALUES(1)')
const statement = db.prepare('SELECT value FROM values_by_type')
for (const readBigInts of [false, true, false]) {
statement.setReadBigInts(readBigInts)
db.exec('DELETE FROM values_by_type; INSERT INTO values_by_type VALUES(9007199254740991)')
const safe = readBigInts ? 9007199254740991n : Number.MAX_SAFE_INTEGER
expect(statement.get()).toEqual({ value: safe })
db.exec(
'DELETE FROM values_by_type; INSERT INTO values_by_type VALUES(CAST(-9223372036854775808 AS REAL))'
)
const real = { value: Number(-(1n << 63n)) }
expect(statement.get()).toEqual(real)
expect(statement.all()).toEqual([real])
expect([...statement.iterate()]).toEqual([real])
db.exec('DELETE FROM values_by_type; INSERT INTO values_by_type VALUES(-9223372036854775808)')
if (readBigInts) {
expect(statement.get()).toEqual({ value: -(1n << 63n) })
} else {
expect(() => statement.get()).toThrow(RangeError)
expect(() => statement.all()).toThrow(RangeError)
expect(() => [...statement.iterate()]).toThrow(RangeError)
}
}
})
it('rejects integers outside SQLite range before changing rows', () => {
const db = open(':memory:')
db.exec('CREATE TABLE items(value INTEGER)')
const insert = db.prepare('INSERT INTO items VALUES(?)')
for (const value of [-(1n << 63n) - 1n, 1n << 63n]) {
expect(() => insert.run(value)).toThrow()
}
expect(db.prepare('SELECT count(*) AS count FROM items').get()).toEqual({ count: 0 })
})
it('clears old bindings and binds omitted positional values as null', () => {
const db = open(':memory:')
const statement = db.prepare('SELECT ? AS first, ? AS second')
expect(statement.get('old', 'secret')).toEqual({ first: 'old', second: 'secret' })
expect(statement.get('new')).toEqual({ first: 'new', second: null })
expect(statement.get()).toEqual({ first: null, second: null })
expect(statement.all()).toEqual([{ first: null, second: null }])
expect([...statement.iterate()]).toEqual([{ first: null, second: null }])
expect(db.prepare('SELECT 1 WHERE 0').get()).toBeUndefined()
})
it('reports changes and explicit rowids using the requested integer mode', () => {
const db = open(':memory:')
db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT)')
const insert = db.prepare('INSERT INTO items VALUES(?, ?)')
expect(insert.run(3, 'first')).toEqual({ changes: 1, lastInsertRowid: 3 })
insert.setReadBigInts(true)
expect(insert.run(9007199254740993n, 'large')).toEqual({
changes: 1n,
lastInsertRowid: 9007199254740993n
})
})
it('preserves large write metadata without reporting a committed write as failed', () => {
const db = open(':memory:')
db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY)')
const rowid = 9007199254740993n
expect(db.prepare('INSERT INTO items VALUES(?)').run(rowid)).toEqual({
changes: 1,
lastInsertRowid: rowid
})
expect(db.prepare('UPDATE items SET id=id').run()).toEqual({
changes: 1,
lastInsertRowid: rowid
})
const statement = db.prepare('SELECT id FROM items')
statement.setReadBigInts(true)
expect(statement.all()).toEqual([{ id: rowid }])
})
it('rejects explicit undefined bindings before modifying rows', () => {
const db = open(':memory:')
db.exec('CREATE TABLE items(value TEXT)')
const insert = db.prepare('INSERT INTO items VALUES(?)')
// @ts-expect-error Exercise invalid input from untyped callers.
expect(() => insert.run(undefined)).toThrow()
expect(db.prepare('SELECT count(*) AS count FROM items').get()).toEqual({ count: 0 })
const select = db.prepare('SELECT ? AS value')
// @ts-expect-error Exercise invalid input from untyped callers.
expect(() => select.get(undefined)).toThrow()
// @ts-expect-error Exercise invalid input from untyped callers.
expect(() => select.all(undefined)).toThrow()
// @ts-expect-error Exercise invalid input from untyped callers.
expect(() => [...select.iterate(undefined)]).toThrow()
})
it('releases statements and an unfinished iterator before filesystem retirement', () => {
const path = join(fixture(), 'database.db')
const db = open(path)
db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY); INSERT INTO items VALUES(1),(2)')
db.exec("CREATE VIRTUAL TABLE search USING fts5(content); INSERT INTO search VALUES('needle')")
const statement = db.prepare('SELECT id FROM items ORDER BY id')
const search = db.prepare("SELECT content FROM search WHERE search MATCH 'needle'")
expect(search.get()).toEqual({ content: 'needle' })
const iterator = statement.iterate()
expect(iterator.next().value).toEqual({ id: 1 })
db.close()
expect(() => statement.get()).toThrow()
expect(() => search.get()).toThrow()
renameSync(path, `${path}.retired`)
const reopened = open(`${path}.retired`)
reopened.exec('DROP TABLE items')
})
it('opens literal filenames, URL paths and Buffer paths without treating them as data', () => {
const path = join(fixture(), "profile % # ' é.db")
const writer = open(path)
writer.exec('CREATE TABLE items(id INTEGER PRIMARY KEY); INSERT INTO items VALUES(9)')
for (const input of [pathToFileURL(path), Buffer.from(path)]) {
const reader = new SyncDatabase(input, { readonly: true, fileMustExist: true })
databases.push(reader)
expect(reader.prepare('SELECT id FROM items').get()).toEqual({ id: 9 })
}
expect(existsSync(path)).toBe(true)
})
it('refuses missing databases and changes through read-only connections', () => {
const path = join(fixture(), 'database.db')
for (const input of [path, pathToFileURL(path), Buffer.from(path)]) {
expect(() => new SyncDatabase(input, { fileMustExist: true })).toThrow()
expect(() => new SyncDatabase(input, { readonly: true, fileMustExist: true })).toThrow()
expect(existsSync(path)).toBe(false)
}
const writer = open(path)
writer.exec('CREATE TABLE items(id INTEGER PRIMARY KEY)')
const reader = new SyncDatabase(path, { readonly: true, timeout: 4321 })
databases.push(reader)
expect(reader.pragma('busy_timeout', { simple: true })).toBe(4321)
expect(() => reader.exec('INSERT INTO items VALUES(1)')).toThrow()
expect(writer.pragma('busy_timeout', { simple: true })).toBe(0)
})
it('copies committed WAL data through a read-only source into privately precreated output', async () => {
const directory = fixture()
const path = join(directory, 'database.db')
const target = join(directory, "snapshot % # '.db")
const writer = open(path)
writer.exec(
'PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT)'
)
writer.prepare('INSERT INTO items VALUES(?, ?)').run(19, 'committed in WAL')
expect(existsSync(`${path}-wal`)).toBe(true)
const source = new SyncDatabase(path, { readonly: true, fileMustExist: true })
databases.push(source)
writeFileSync(target, '', { flag: 'wx', mode: 0o600 })
await source.backup(target)
const snapshot = open(target)
expect(snapshot.prepare('SELECT id, value FROM items').get()).toEqual({
id: 19,
value: 'committed in WAL'
})
expect(snapshot.pragma('integrity_check', { simple: true })).toBe('ok')
})
it('refuses an active transaction before creating any backup destination', async () => {
const directory = fixture()
const db = open(join(directory, 'database.db'))
db.exec(
'CREATE TABLE items(id INTEGER PRIMARY KEY); BEGIN IMMEDIATE; INSERT INTO items VALUES(1)'
)
const target = join(directory, 'backup.db')
await expect(db.backup(target)).rejects.toThrow(/idle/)
expect(existsSync(target)).toBe(false)
db.exec('ROLLBACK')
})
})
+1 -1
View File
@@ -213,7 +213,7 @@ describe('SyncDatabase read-only opens under contention', () => {
thrown = error
}
expect((thrown as { errcode?: number }).errcode).toBe(5)
expect(thrown).toMatchObject({ [process.versions.bun ? 'errno' : 'errcode']: 5 })
expect((thrown as Error).message).toContain('database is locked')
expect(Date.now() - startedAt).toBeLessThan(200)
})
+63 -27
View File
@@ -1,5 +1,8 @@
import { existsSync } from 'node:fs'
import type { backup, BackupOptions, DatabaseSync, StatementSync, SQLInputValue } from 'node:sqlite'
import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite'
import { BunSqliteDatabase, loadBunSqlite } from './bun-sqlite-database'
import { NodeSqliteStatement } from './node-sqlite-statement'
import type { SqliteStatement } from './sqlite-statement'
type SqlitePath = ConstructorParameters<typeof DatabaseSync>[0]
@@ -13,7 +16,7 @@ type PragmaOptions = {
simple?: boolean
}
export type SqliteStatement = StatementSync
export type { SqliteStatement } from './sqlite-statement'
// Why: dynamic `IN (?,?,…)` clauses mint a new SQL string per arity, so the cache must stay bounded.
const STATEMENT_CACHE_LIMIT = 256
@@ -32,8 +35,20 @@ function loadDatabaseSync(): typeof DatabaseSync {
if (typeof process.getBuiltinModule !== 'function') {
throw new Error('node:sqlite is unavailable in this Node.js runtime')
}
return (process.getBuiltinModule('node:sqlite') as { DatabaseSync: typeof DatabaseSync })
.DatabaseSync
const sqlite: unknown = process.getBuiltinModule('node:sqlite')
if (!hasDatabaseSync(sqlite)) {
throw new Error('node:sqlite is unavailable in this Node.js runtime')
}
return sqlite.DatabaseSync
}
function hasDatabaseSync(value: unknown): value is { DatabaseSync: typeof DatabaseSync } {
return (
typeof value === 'object' &&
value !== null &&
'DatabaseSync' in value &&
typeof value.DatabaseSync === 'function'
)
}
function hasBackup(value: unknown): value is { backup: typeof backup } {
@@ -45,24 +60,35 @@ function hasBackup(value: unknown): value is { backup: typeof backup } {
)
}
export function isSqliteAvailable(): boolean {
try {
if (process.versions.bun) {
return loadBunSqlite() !== undefined
}
const sqlite: unknown = process.getBuiltinModule?.('node:sqlite')
return hasDatabaseSync(sqlite) && hasBackup(sqlite)
} catch {
return false
}
}
class SyncDatabase {
private readonly db: DatabaseSync
private readonly statementCache = new Map<string, StatementSync>()
private readonly db: DatabaseSync | BunSqliteDatabase
private readonly statementCache = new Map<string, SqliteStatement>()
constructor(path: SqlitePath, options: SyncDatabaseOptions = {}) {
if (
options.fileMustExist &&
typeof path === 'string' &&
path !== ':memory:' &&
!existsSync(path)
) {
throw new Error(`SQLite database does not exist: ${path}`)
if (options.fileMustExist && path !== ':memory:' && !existsSync(path)) {
throw new Error(`SQLite database does not exist: ${String(path)}`)
}
if (process.versions.bun) {
this.db = new BunSqliteDatabase(path, options)
} else {
const DatabaseSync = loadDatabaseSync()
this.db = new DatabaseSync(path, {
readOnly: options.readonly,
timeout: options.timeout
})
}
const DatabaseSync = loadDatabaseSync()
this.db = new DatabaseSync(path, {
readOnly: options.readonly,
timeout: options.timeout
})
}
exec(sql: string): void {
@@ -73,14 +99,17 @@ class SyncDatabase {
this.db.exec(sql)
}
prepare(sql: string): StatementSync {
prepare(sql: string): SqliteStatement {
const cached = this.statementCache.get(sql)
if (cached) {
this.statementCache.delete(sql)
this.statementCache.set(sql, cached)
return cached
}
const statement = this.db.prepare(sql)
const statement =
this.db instanceof BunSqliteDatabase
? this.db.prepare(sql)
: new NodeSqliteStatement(this.db.prepare(sql))
if (isStatementCacheable(sql)) {
if (this.statementCache.size >= STATEMENT_CACHE_LIMIT) {
const oldest = this.statementCache.keys().next().value
@@ -94,7 +123,7 @@ class SyncDatabase {
}
pragma(sql: string, options?: PragmaOptions): unknown {
const statement = this.db.prepare(`PRAGMA ${sql}`)
const statement = this.prepare(`PRAGMA ${sql}`)
if (options?.simple) {
const row = statement.get()
if (!row) {
@@ -109,8 +138,18 @@ class SyncDatabase {
return this.db.isTransaction
}
/** The source connection must remain open until the native backup settles. */
async backup(path: string, options?: BackupOptions): Promise<number> {
/** Keep the source open until completion; Bun's compact snapshot runs synchronously. */
async backup(path: string, options?: BackupOptions): Promise<void> {
if (this.db.isTransaction) {
throw new Error('SQLite backup requires an idle database connection')
}
if (this.db instanceof BunSqliteDatabase) {
if (options && Object.keys(options).length > 0) {
throw new Error('Incremental SQLite backup options are unavailable in this runtime')
}
this.db.backup(path)
return
}
const sqlite: unknown =
typeof process.getBuiltinModule === 'function'
? process.getBuiltinModule('node:sqlite')
@@ -118,10 +157,7 @@ class SyncDatabase {
if (!hasBackup(sqlite)) {
throw new Error('Asynchronous SQLite backup is unavailable in this Node.js runtime')
}
if (this.db.isTransaction) {
throw new Error('Asynchronous SQLite backup requires an idle database connection')
}
return sqlite.backup(this.db, path, options ?? {})
await sqlite.backup(this.db, path, options ?? {})
}
close(): void {