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.
99 lines
3.1 KiB
TypeScript
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()
|
|
}
|