mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
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:
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user