Files
orca/src/main/sqlite/sync-database.ts
T
Brennan Benson 29a5f89b8a Keep same-machine folders with matching Git remotes in separate projects (#32272)
* WIP: same-host same-remote checkouts keep their own project id

* WIP: claim the shared project id per checkout surface; drop the display-only clone split

* WIP: index derived ids by repo in the hydration normalizer

* Resolve workspace project affiliation from current checkout owners

* fix: preserve concrete project ownership across catalogs and operations

* fix: combine recent project selector imports

* fix: enforce canonical catalog reads and preserve search continuity

* fix: scope catalog lint exceptions to renderer files

* Resolve project search ownership before native candidate limits

* Combine duplicate session scope imports

* fix: derive returned history row ownership on the host

* Reconcile full session history replies by content

* Require authoritative project catalogs for terminal snapshots

* Preserve registered project claims and partial history results

* test: check orphaned setup cleanup state without assertions

* fix: align catalog callers and native search fixture contracts

* test: check full repo shapes in runtime routing fixtures

* Fix checked catalog inputs in compiler regression fixtures

* Keep checked PTY fixture repos mutable

* test: align catalog fixtures with canonical readers

* test: check catalog and runtime fixture inputs

* test: check catalog fixtures without outer assertions

* test: follow persistence harness relocation

* Adapt catalog test inputs to integrated main checks

* Test History hashing across cached depth revalidation

* test(settings): expect host-qualified setup removal

* test: capture host split listings and scope relay recovery

* ci: regenerate merge ref

* test: type host-split listing diagnostics
2026-10-26 19:07:06 -07:00

172 lines
4.9 KiB
TypeScript

import { existsSync } from 'node:fs'
import type {
backup,
BackupOptions,
DatabaseSync,
FunctionOptions,
SQLInputValue
} from 'node:sqlite'
import { hasNodeSqliteReaderApi } from './node-sqlite-reader-api'
import { NodeSqliteStatement } from './node-sqlite-statement'
import type { SqliteStatement } from './sqlite-statement'
type SqlitePath = ConstructorParameters<typeof DatabaseSync>[0]
type SyncDatabaseOptions = {
readonly?: boolean
fileMustExist?: boolean
timeout?: number
}
type PragmaOptions = {
simple?: boolean
}
export type { SqliteStatement } from './sqlite-statement'
// Dynamic IN arity creates unique SQL strings; bound the statement cache.
const STATEMENT_CACHE_LIMIT = 256
const AGGREGATE_STAR = /\(\s*\*\s*\)/g
const PRAGMA_STATEMENT = /^\s*PRAGMA\b/i
const SCHEMA_CHANGING_SQL = /\b(?:ALTER|CREATE|DROP|REINDEX|VACUUM|ATTACH|DETACH)\b/i
// Reused wildcard rows can retain stale column names after DDL; PRAGMAs are one-shot settings.
function isStatementCacheable(sql: string): boolean {
return !PRAGMA_STATEMENT.test(sql) && !sql.replace(AGGREGATE_STAR, '').includes('*')
}
// Node 18 SSH companions import this module without opening SQLite; load the builtin lazily.
function loadDatabaseSync(): typeof DatabaseSync {
if (typeof process.getBuiltinModule !== 'function') {
throw new Error('node:sqlite is unavailable in this Node.js runtime')
}
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 } {
return (
typeof value === 'object' &&
value !== null &&
'backup' in value &&
typeof value.backup === 'function'
)
}
export function isSqliteAvailable(): boolean {
try {
const sqlite: unknown = process.getBuiltinModule?.('node:sqlite')
return hasNodeSqliteReaderApi(sqlite)
} catch {
return false
}
}
class SyncDatabase {
private readonly db: DatabaseSync
private readonly statementCache = new Map<string, SqliteStatement>()
constructor(path: SqlitePath, options: SyncDatabaseOptions = {}) {
if (options.fileMustExist && path !== ':memory:' && !existsSync(path)) {
throw new Error(`SQLite database does not exist: ${String(path)}`)
}
const DatabaseSync = loadDatabaseSync()
this.db = new DatabaseSync(path, {
readOnly: options.readonly,
timeout: options.timeout
})
}
exec(sql: string): void {
// Invalidate before DDL so partially applied batches cannot retain stale statements.
if (SCHEMA_CHANGING_SQL.test(sql)) {
this.statementCache.clear()
}
this.db.exec(sql)
}
function(
name: string,
options: FunctionOptions,
fn: Parameters<DatabaseSync['function']>[1]
): void {
this.db.function(name, options, fn)
}
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 = new NodeSqliteStatement(this.db.prepare(sql))
if (isStatementCacheable(sql)) {
if (this.statementCache.size >= STATEMENT_CACHE_LIMIT) {
const oldest = this.statementCache.keys().next().value
if (oldest !== undefined) {
this.statementCache.delete(oldest)
}
}
this.statementCache.set(sql, statement)
}
return statement
}
pragma(sql: string, options?: PragmaOptions): unknown {
const statement = this.prepare(`PRAGMA ${sql}`)
if (options?.simple) {
const row = statement.get()
if (!row) {
return undefined
}
return Object.values(row)[0]
}
return statement.all()
}
get isTransaction(): boolean {
return this.db.isTransaction
}
/** Keep the source open until completion. */
async backup(path: string, options?: BackupOptions): Promise<void> {
if (this.db.isTransaction) {
throw new Error('SQLite backup requires an idle database connection')
}
const sqlite: unknown =
typeof process.getBuiltinModule === 'function'
? process.getBuiltinModule('node:sqlite')
: undefined
if (!hasBackup(sqlite)) {
throw new Error('Asynchronous SQLite backup is unavailable in this Node.js runtime')
}
await sqlite.backup(this.db, path, options ?? {})
}
close(): void {
this.statementCache.clear()
this.db.close()
}
}
namespace SyncDatabase {
export type Database = SyncDatabase
export type Statement = SqliteStatement
export type BindValue = SQLInputValue
}
export default SyncDatabase