Files
nexusterm/src/main/db/database.ts
T
ylhan 430d78b9f0 NexusTerm: an SSH session manager for Linux
Connection tree with interleaved folders and hosts, tabbed terminals
grouped by host, a pinned local shell, TOFU host key verification, and
credentials held by the OS keyring.

Terminal bytes travel on a per-session MessagePort with credit-based flow
control, so one session running `cat` on a large file cannot stall the
others. Transports reconnect on their own with jittered backoff, keeping
the scrollback and the tab while replacing only the connection beneath.

156 tests, including integration tests against a real sshd and a
migration exercised against a database built in the old shape.
2026-09-17 03:12:24 +08:00

99 lines
3.1 KiB
TypeScript

import Database from 'better-sqlite3'
import { MIGRATIONS } from './migrations'
export type Db = Database.Database
export interface OpenOptions {
/** Skip WAL. In-memory databases do not support it. */
memory?: boolean
}
/**
* Open the database, apply pragmas, and run any pending migrations.
*
* Called from the main process only. better-sqlite3 is synchronous, which is
* exactly what we want here: the main process is the sole writer, the queries
* are small, and synchronous access removes an entire class of interleaving
* bugs that async drivers would introduce.
*/
export function openDatabase(path: string, options: OpenOptions = {}): Db {
const db = new Database(path)
if (!options.memory) {
// WAL lets reads proceed during writes. With a single writer the practical
// benefit is durability behaviour rather than concurrency, but it also
// makes crashes much less likely to corrupt the file.
db.pragma('journal_mode = WAL')
}
// Off by default in SQLite. Without this, ON DELETE CASCADE is silently
// ignored and deleting a folder would orphan its hosts.
db.pragma('foreign_keys = ON')
db.pragma('synchronous = NORMAL')
db.pragma('busy_timeout = 5000')
migrate(db)
return db
}
/**
* Apply every migration newer than the database's recorded `user_version`.
*
* Each migration runs inside a transaction. SQLite makes DDL transactional, so
* a migration that fails partway leaves the database exactly as it was rather
* than half-migrated — which is the difference between a recoverable bug and a
* corrupted install.
*/
export function migrate(db: Db): number {
const current = db.pragma('user_version', { simple: true }) as number
const pending = MIGRATIONS.filter((m) => m.version > current).sort(
(a, b) => a.version - b.version,
)
for (const migration of pending) {
const rebuild = migration.rebuildsTables === true
// Outside the transaction on purpose: SQLite silently ignores both of
// these inside one.
if (rebuild) {
db.pragma('foreign_keys = OFF')
db.pragma('legacy_alter_table = ON')
}
try {
db.transaction(() => {
db.exec(migration.sql)
if (rebuild) {
// A rebuild that leaves dangling references must not commit. Failing
// here rolls the whole migration back, so a bad rebuild leaves the
// previous schema intact rather than a half-converted database.
const violations = db.pragma('foreign_key_check') as unknown[]
if (violations.length > 0) {
throw new Error(
`migration ${migration.version} (${migration.name}) left ` +
`${violations.length} foreign key violation(s)`,
)
}
}
// Interpolated, not bound: PRAGMA does not accept bound parameters. The
// value comes from our own migration table, never from user input.
db.pragma(`user_version = ${migration.version}`)
})()
} finally {
if (rebuild) {
db.pragma('legacy_alter_table = OFF')
db.pragma('foreign_keys = ON')
}
}
}
return pending.length
}
export function closeDatabase(db: Db): void {
db.close()
}