@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
1,128 lines (1,023 loc) • 46.2 kB
text/typescript
import { createHash, randomBytes } from "node:crypto";
import {
chmodSync,
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { DatabaseSync } from "./sqlite.ts";
import { KxmConfigError, type KxmConfigIssue } from "./project-config.ts";
import { kxmProjectRunEventsPath, kxmRuntimePaths, projectRuntimeKey, type KxmRuntimePaths } from "./runtime-paths.ts";
export interface DatabaseSchemaSpec {
schema: string;
version: number;
tables?: Readonly<Record<string, readonly string[]>>;
timeoutMs?: number;
}
export interface BackupStoreRecord {
storeId: string;
sourcePath: string;
backupFile: string;
schemaVersion: number;
sha256: string;
bytes: number;
integrity: "ok";
}
export interface BackupFileRecord {
id: string;
sourcePath: string;
backupFile: string;
sha256: string;
bytes: number;
}
/**
* What a backup holds from the user state root. `project` (the default): the
* checkout's own Runtime event store and its prompt sidecar. `all-projects`: the
* shared Runtime registry and every project's event store, so restoring it rolls
* back every project on the machine.
*/
export type KxmBackupScope = "project" | "all-projects";
export interface BackupManifest {
schema: "kxm.backup-manifest.v1";
backupId: string;
createdAt: string;
projectRoot?: string;
/** Absent on manifests written before backups were scoped. */
scope?: KxmBackupScope;
/** The user state root the Runtime stores were copied from; restore rebases them onto the current one. */
stateRoot?: string;
/** The Runtime store key of the checkout the backup ran from, derived as the Runtime derives it. */
runtimeProjectKey?: string;
stores: BackupStoreRecord[];
files?: BackupFileRecord[];
/** False when a discovered store or prompt sidecar was left out. Absent on legacy manifests. */
complete?: boolean;
omitted?: string[];
manifestSha256?: string;
}
export interface RestoreStoreRecord {
storeId: string;
sourcePath: string;
backupFile: string;
schemaVersion: number;
integrity: "ok";
}
export interface RestoreResult {
manifestPath: string;
backupId: string;
restoredStores: RestoreStoreRecord[];
}
export function databaseError(code: string, file: string, message: string): KxmConfigError {
const issue: KxmConfigIssue = { phase: "semantic", code, file, message };
return new KxmConfigError([issue]);
}
export function checkedParent(path: string, description: string): void {
const parent = dirname(path);
if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 });
const stat = lstatSync(parent, { throwIfNoEntry: false });
if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
throw databaseError("runtime_path_invalid", description, `${description} parent must be a regular directory, not a link`);
}
}
export function userTables(database: DatabaseSync): string[] {
const rows = database.prepare(
"SELECT name FROM sqlite_master WHERE type = \x27table\x27 AND name NOT LIKE \x27sqlite_%\x27 ORDER BY name",
).all() as Array<{ name: string }>;
return rows.map((row) => row.name);
}
export function tableColumns(database: DatabaseSync, table: string): string[] {
const rows = database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
return rows.map((row) => row.name).sort();
}
export function verifyExpectedTables(
database: DatabaseSync,
file: string,
description: string,
expected: Readonly<Record<string, readonly string[]>>,
): void {
const present = new Set(userTables(database));
for (const [table, columns] of Object.entries(expected)) {
if (!present.has(table)) {
throw databaseError("runtime_schema_shape_invalid", file, `${description} is missing table ${table}`);
}
const actual = tableColumns(database, table);
const missing = columns.filter((column) => !actual.includes(column));
if (missing.length > 0) {
throw databaseError("runtime_schema_shape_invalid", file, `${description} table ${table} is missing columns ${missing.join(", ")}`);
}
}
}
export function ensureWalJournalMode(database: DatabaseSync, file: string, description: string, timeoutMs = 5000): void {
const deadline = Date.now() + timeoutMs;
const sleeper = new Int32Array(new SharedArrayBuffer(4));
while (true) {
try {
const current = database.prepare("PRAGMA journal_mode").get() as { journal_mode?: string } | undefined;
if (current?.journal_mode === "wal") {
return;
}
const updated = database.prepare("PRAGMA journal_mode = WAL").get() as { journal_mode?: string } | undefined;
if (updated?.journal_mode === "wal") {
return;
}
} catch (error) {
const sqliteError = error as { code?: string; errcode?: number };
if (sqliteError.code === "ERR_SQLITE_ERROR" && sqliteError.errcode === 5 && Date.now() < deadline) {
Atomics.wait(sleeper, 0, 0, 10);
continue;
}
throw error;
}
if (Date.now() >= deadline) {
throw databaseError("runtime_timeout", file, `${description} timed out enabling WAL journal mode`);
}
Atomics.wait(sleeper, 0, 0, 10);
}
}
/** Parent, file, and WAL sidecar must be regular filesystem objects. A symlink is refused before the file is opened. */
export function assertDatabaseFile(file: string, description: string): void {
checkedParent(file, description);
const stat = lstatSync(file, { throwIfNoEntry: false });
if (stat) {
if (stat.isSymbolicLink() || !stat.isFile()) {
throw databaseError("runtime_path_invalid", description, `${description} must be a regular file, not a link or directory`);
}
}
for (const sidecar of [`${file}-wal`, `${file}-shm`]) {
const info = lstatSync(sidecar, { throwIfNoEntry: false });
if (info?.isSymbolicLink()) {
throw databaseError("runtime_path_invalid", description, `${description} sidecar must not be a link`);
}
}
}
export function openDatabase(file: string, description: string, spec: DatabaseSchemaSpec): DatabaseSync {
const isMemory = file === ":memory:";
if (!isMemory) {
assertDatabaseFile(file, description);
}
const database = new DatabaseSync(file);
let transaction = false;
try {
database.exec(`PRAGMA busy_timeout = ${spec.timeoutMs ?? 5000}`);
if (!isMemory) {
ensureWalJournalMode(database, file, description, spec.timeoutMs);
}
database.exec("PRAGMA synchronous = NORMAL");
database.exec("PRAGMA foreign_keys = ON");
database.exec("BEGIN IMMEDIATE");
transaction = true;
const row = database.prepare("PRAGMA user_version").get() as { user_version: number } | undefined;
const version = row?.user_version ?? 0;
if (version > spec.version) {
throw databaseError("runtime_schema_newer", file, `${description} schema version ${version} is newer than this runtime supports`);
}
if (version === 0) {
const existing = userTables(database);
if (existing.length > 0) {
throw databaseError("runtime_schema_shape_invalid", file, `${description} has tables at schema version 0`);
}
database.exec(spec.schema);
database.exec(`PRAGMA user_version = ${spec.version}`);
} else if (version < spec.version) {
// No migration lanes, except the Runtime registry copy from schema 1 to 2
// (migrateRegistryLanes). This is a single-operator tool: every other older
// database is re-initialised, not upgraded in place, and the code never carries
// two schema shapes at once. Silently accepting an older file would mean every
// query has to work against shapes it no longer tests.
throw databaseError(
"runtime_schema_outdated",
file,
`${description} is schema version ${version}; this build requires ${spec.version}. Delete the state file to start fresh and let its owning process recreate it (\`kxm hub start\` for hub state, the Runtime for event stores). The Runtime registry copy from schema 1 to 2 is the one exception. \`kxm init\` is project-only and rebuilds no database. Upgrading any other old state in place is deliberately unsupported`,
);
}
if (spec.tables) {
verifyExpectedTables(database, file, description, spec.tables);
}
database.exec("COMMIT");
transaction = false;
return database;
} catch (error) {
if (transaction) {
try { database.exec("ROLLBACK"); } catch { /* already rolled back */ }
}
database.close();
throw error;
}
}
const activeTransactions = new WeakSet<DatabaseSync>();
/**
* How long a connection refuses to retry a `BEGIN` that lost the write race.
*
* SQLite's busy timeout is per connection, so a `BEGIN` against a locked
* database waits the full timeout before failing. Callers that recover from a
* lost write open several transactions in a row; without this window each of
* them pays the timeout, and a suite run showed that turning one 4-second test
* into 17 minutes. The old code got that speed by accident — it left the
* connection permanently marked as in-transaction after a failed `BEGIN`, which
* is the defect fixed below — so the backoff replaces the fast-fail without
* re-introducing the poison.
*
* Two limits, both deliberate:
* - It is a **throttle, not a queue**. A caller whose lock cleared 1 ms later is
* still refused for the rest of the window; the refusal is explicit
* (`runtime_transaction_busy`, message says `retry deferred`) and bounded by
* this constant. A retry after the window may pay the busy timeout again.
* - It is **per connection object, in this process**. It is not a cross-process
* backoff and does not leak to another connection to the same file.
*/
export const TRANSACTION_BUSY_BACKOFF_MS = 1_000;
/**
* Monotonic elapsed time, deliberately not `Date.now()`.
*
* A wall-clock step backwards would otherwise keep a long-gone write lock
* refusing transactions until real time caught up, and a step forward would end
* the throttle early. `hrtime.bigint()` is relative to an arbitrary past origin
* and never moves backwards, so the window is bounded by
* {@link TRANSACTION_BUSY_BACKOFF_MS} no matter what the system clock does.
*
* Injectable: production callers never pass it, and a test drives it directly so
* the window is stepped rather than raced or slept through.
*/
export type MonotonicClock = () => number;
function monotonicNowMs(): number {
return Number(process.hrtime.bigint() / 1_000_000n);
}
/**
* Pending throttle per connection, **keyed by the clock that armed it**.
*
* The nesting is the fix, not tidiness. A deadline is a number from *some* clock,
* and the injectable `clock` exists only so a test can step the window; comparing
* a deadline armed by one clock against a reading taken from another is how an
* injected "one hour from now" throttles a production caller that never passed a
* clock at all — and how that same caller could clear a deadline it never armed.
* Each clock domain therefore gets its own deadline and can only read, expire or
* replace its own. The inner map is **weak in the clock**: it keeps no otherwise
* unreachable clock function alive, so an attempt that builds a fresh closure per
* call leaves nothing behind once that closure is collected. A clock that *is*
* retained keeps its entry until it expires or is replaced — collection is neither
* immediate nor size-bounded, and no committed test measures any of this, because no
* production caller passes a clock.
*/
const transactionThrottles = new WeakMap<DatabaseSync, WeakMap<MonotonicClock, number>>();
/**
* A clock reading this helper can reason about.
*
* Scope, stated as measured rather than as a slogan. The clock is read at **two call
* sites**: checking an existing deadline on a non-`DEFERRED` attempt, and arming after a
* contended `BEGIN` failure. Reads per call, each reproduced by an assertion in
* `test/core/intake.test.ts`: clean success with no pending entry 0; successful
* `DEFERRED`, pending entry present or not, 0; fresh contention 1; refusal inside a window
* 1; an expired deadline followed by renewed contention 2 in that call; a permanent
* `BEGIN` failure with no pending entry 0; a nested-transaction rejection 0.
*
* Which means it is wrong in both directions to say either "every `BEGIN` validates the
* clock" or "the *only* transaction that skips it is a clean uncontended success" — the
* second was mine, twice, and the second correction to it was still a slogan. The tests
* count; the prose only points at them. Production cannot reach the guard at all, because
* the default is `hrtime`; it exists so the injectable seam cannot become a silent
* bypass.
*/
function finiteNow(clock: MonotonicClock, label: string): number {
const now = clock();
if (typeof now !== "number" || !Number.isFinite(now)) {
throw databaseError(
"runtime_transaction_clock_invalid",
"transaction",
`${label} must return a finite monotonic number; got ${String(now)}`,
);
}
return now;
}
/**
* Is this `BEGIN` failure contention for the write lock, as opposed to a
* programming or environment error?
*
* Only contention may be retried, and only contention earns the throttle window.
* Anything else — "cannot start a transaction within a transaction", a closed
* connection, a miscompiled statement — must surface unchanged, or a permanent
* bug looks like a transient one and the caller retries forever.
*
* SQLite's **numeric result code decides** where one exists, because it is stable
* across versions while message text is not: the primary code is `code & 0xff`, so
* extended forms land on their primaries — `SQLITE_BUSY_RECOVERY` (261) and
* `SQLITE_BUSY_SNAPSHOT` (517) on `SQLITE_BUSY` (5), `SQLITE_LOCKED_SHAREDCACHE`
* (262) on `SQLITE_LOCKED` (6). `SQLITE_PROTOCOL` (15) is included deliberately:
* SQLite raises it when repeated attempts to start a transaction under WAL exhaust
* the retry count, which is a lock-acquisition retry condition, not a broken
* database.
*
* Where both runtimes expose one, **the number wins over the text**: Node spells it
* `errcode`, `bun:sqlite` spells it `errno` (verified on Bun 1.3.14, where a shared-
* cache `BEGIN` arrives as `errno: 262`, `code: "SQLITE_LOCKED_SHAREDCACHE"`,
* message "database schema is locked: shared"). A symbolic `code`/`name` of the form
* `SQLITE_BUSY*`/`SQLITE_LOCKED*`/`SQLITE_PROTOCOL*` is accepted next, and bare
* message matching is the last resort — applied only when neither exists, so a
* wrapper that merely quotes "database is locked" alongside a permanent code is not
* mistaken for contention.
*
* Deliberately **not** contention: `SQLITE_FULL` / "database or disk is full",
* "unable to open database file", and WAL shared-memory I/O failures. Those stay
* broken until something outside this connection changes.
*/
/** Node: `errcode`. Bun: `errno`. Both spell the extended code as a number. */
const CONTENTION_PRIMARY_CODES: readonly number[] = [5, 6, 15];
/** `bun:sqlite` puts the symbolic name in `code`; Node puts its own kind there. */
const CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
/** Any SQLite result name. If one is present it decides, so text cannot argue. */
const SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
const CONTENTION_MESSAGES =
/^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;
export function isTransactionContention(error: unknown): boolean {
const carrier = error as { errcode?: unknown; errCode?: unknown; errno?: unknown; code?: unknown; name?: unknown }
| undefined;
// A numeric code wins first, then a symbolic result name, and both win **over the
// message**: `errno` is what `bun:sqlite` exposes for the extended result code while
// its `code` field holds the symbolic name, and Node spells the number `errcode`.
// All three properties are read; what the list orders is which **integer value
// decides** — a present `errcode` outranks `errCode`, which outranks `errno`, and a
// later number is never consulted. Text is consulted only when the error carries
// neither a number nor a SQLite result name, so no wrapper quoting an older
// "database is locked" can outvote a code on either runtime.
for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 0xff);
}
for (const value of [carrier?.code, carrier?.name]) {
// A result **name** is as authoritative as a number, and in both directions:
// `SQLITE_FULL` wearing a "database is locked" message is not contention. Node
// spells its own error kind `ERR_SQLITE_ERROR`, which is deliberately not a
// SQLite result name and so never reaches a verdict here.
if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
return CONTENTION_SYMBOLIC_NAMES.test(value);
}
}
return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
}
export function withDatabaseTransaction<T>(
database: DatabaseSync,
work: () => T,
mode: "IMMEDIATE" | "DEFERRED" | "EXCLUSIVE" = "IMMEDIATE",
clock: MonotonicClock = monotonicNowMs,
): T {
if (activeTransactions.has(database)) {
throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
}
// A DEFERRED BEGIN takes no write lock, so it is exempt from the *check*: refusing
// it would deny legitimate work over a contention it did not ask for. Exempt from
// the check only — shared-cache schema locks can still make a deferred `BEGIN`
// fail, and that failure arms a deadline like any other, because the next attempt
// would stall the same way.
if (mode !== "DEFERRED") {
const deadlines = transactionThrottles.get(database);
const until = deadlines?.get(clock);
if (until !== undefined) {
const remaining = until - finiteNow(clock, "the transaction clock");
if (remaining > 0) {
throw databaseError(
"runtime_transaction_busy",
"transaction",
`a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`,
);
}
deadlines?.delete(clock);
}
}
// Claim the marker only once BEGIN has succeeded. If BEGIN throws — another
// writer held the database past the busy timeout — a connection that never
// entered a transaction must not be left permanently marked as inside one,
// which would fail every later transaction on it with a misleading
// "nested" error. `finally` cannot cover this: it only runs after the try.
try {
database.exec(`BEGIN ${mode}`);
} catch (error) {
if (!isTransactionContention(error)) throw error;
const now = finiteNow(clock, "the transaction clock");
let deadlines = transactionThrottles.get(database);
if (deadlines === undefined) {
deadlines = new WeakMap<MonotonicClock, number>();
transactionThrottles.set(database, deadlines);
}
deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
throw databaseError(
"runtime_transaction_busy",
"transaction",
`BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`,
);
}
activeTransactions.add(database);
// A successful write-mode BEGIN means this connection is holding the slot, so any
// earlier contention is over. A DEFERRED success proves nothing about the write
// lock and must not clear a throttle that another caller's contention armed.
if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
try {
const result = work();
database.exec("COMMIT");
return result;
} catch (error) {
// A failed ROLLBACK usually means the connection is gone. Clearing the marker
// is bookkeeping, not proof: it does **not** show SQLite exited the
// transaction, and nothing here invalidates a handle whose rollback failed.
// Pre-existing, named rather than papered over — the caller receives the
// original error.
try { database.exec("ROLLBACK"); } catch { /* ignore rollback error if connection dead */ }
throw error;
} finally {
activeTransactions.delete(database);
}
}
export function checkpointWal(
database: DatabaseSync,
mode: "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE" = "TRUNCATE",
): { busy: number; log: number; checkpointed: number } {
const row = database.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as {
busy?: number;
log?: number;
checkpointed?: number;
} | undefined;
return {
busy: row?.busy ?? 0,
log: row?.log ?? 0,
checkpointed: row?.checkpointed ?? 0,
};
}
export function checkIntegrity(database: DatabaseSync): boolean {
const rows = database.prepare("PRAGMA integrity_check").all() as Array<{ integrity_check?: string }>;
return rows.length === 1 && rows[0]?.integrity_check === "ok";
}
export function fileSha256(filePath: string): string {
const bytes = readFileSync(filePath);
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}
export function backupDatabaseFile(sourcePath: string, targetPath: string, storeId: string): BackupStoreRecord {
const resolvedSource = resolve(sourcePath);
const resolvedTarget = resolve(targetPath);
const sourceStat = lstatSync(resolvedSource, { throwIfNoEntry: false });
if (!sourceStat || !sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw databaseError("runtime_path_invalid", resolvedSource, `source database ${resolvedSource} must be a regular file, not a link or directory`);
}
checkedParent(resolvedTarget, "backup target");
if (existsSync(resolvedTarget)) {
unlinkSync(resolvedTarget);
}
const sourceDb = new DatabaseSync(resolvedSource);
let schemaVersion = 0;
try {
sourceDb.exec("PRAGMA busy_timeout = 5000");
checkpointWal(sourceDb, "TRUNCATE");
if (!checkIntegrity(sourceDb)) {
throw databaseError("database_corrupted", resolvedSource, `database ${resolvedSource} failed integrity check`);
}
const versionRow = sourceDb.prepare("PRAGMA user_version").get() as { user_version: number } | undefined;
schemaVersion = versionRow?.user_version ?? 0;
const escapedTarget = resolvedTarget.replace(/\x27/g, "\x27\x27");
sourceDb.exec(`VACUUM INTO \x27${escapedTarget}\x27`);
} finally {
sourceDb.close();
}
const targetDb = new DatabaseSync(resolvedTarget);
try {
targetDb.exec("PRAGMA busy_timeout = 5000");
if (!checkIntegrity(targetDb)) {
throw databaseError("database_corrupted", resolvedTarget, `backup database ${resolvedTarget} failed integrity check`);
}
} finally {
targetDb.close();
}
try { chmodSync(resolvedTarget, 0o600); } catch { /* Windows */ }
const sha256 = fileSha256(resolvedTarget);
const bytes = lstatSync(resolvedTarget).size;
return {
storeId,
sourcePath: resolvedSource,
backupFile: basename(resolvedTarget),
schemaVersion,
sha256,
bytes,
integrity: "ok",
};
}
export function restoreDatabaseFile(
backupPath: string,
targetPath: string,
storeId: string,
expectedSchemaVersion?: number,
maxSupportedVersion?: number,
): RestoreStoreRecord {
const resolvedBackup = resolve(backupPath);
const resolvedTarget = resolve(targetPath);
const backupStat = lstatSync(resolvedBackup, { throwIfNoEntry: false });
if (!backupStat || !backupStat.isFile() || backupStat.isSymbolicLink()) {
throw databaseError("runtime_path_invalid", resolvedBackup, `backup database ${resolvedBackup} must be a regular file, not a link or directory`);
}
const backupDb = new DatabaseSync(resolvedBackup);
let schemaVersion = 0;
try {
backupDb.exec("PRAGMA busy_timeout = 5000");
if (!checkIntegrity(backupDb)) {
throw databaseError("database_corrupted", resolvedBackup, `backup database ${resolvedBackup} failed integrity check`);
}
const versionRow = backupDb.prepare("PRAGMA user_version").get() as { user_version: number } | undefined;
schemaVersion = versionRow?.user_version ?? 0;
if (maxSupportedVersion !== undefined && schemaVersion > maxSupportedVersion) {
throw databaseError(
"runtime_schema_newer",
resolvedBackup,
`backup store ${storeId} schema version ${schemaVersion} is newer than supported maximum ${maxSupportedVersion}`,
);
}
if (expectedSchemaVersion !== undefined && schemaVersion !== expectedSchemaVersion) {
throw databaseError(
"runtime_schema_mismatch",
resolvedBackup,
`backup store ${storeId} schema version ${schemaVersion} does not match manifest version ${expectedSchemaVersion}`,
);
}
} finally {
backupDb.close();
}
checkedParent(resolvedTarget, "restore target");
for (const file of [resolvedTarget, `${resolvedTarget}-wal`, `${resolvedTarget}-shm`]) {
if (existsSync(file)) {
try { unlinkSync(file); } catch { /* ignore */ }
}
}
copyFileSync(resolvedBackup, resolvedTarget);
try { chmodSync(resolvedTarget, 0o600); } catch { /* Windows */ }
const targetDb = new DatabaseSync(resolvedTarget);
try {
targetDb.exec("PRAGMA busy_timeout = 5000");
if (!checkIntegrity(targetDb)) {
throw databaseError("database_corrupted", resolvedTarget, `restored database ${resolvedTarget} failed integrity check`);
}
} finally {
targetDb.close();
}
return {
storeId,
sourcePath: resolvedTarget,
backupFile: basename(resolvedBackup),
schemaVersion,
integrity: "ok",
};
}
/**
* Per-store restore ceilings, held in **one** table read by both backup discovery
* and restore. Two copies of these numbers drifted once already: a schema bump
* raised the restore ceiling and left the discovery ceiling behind, and nothing
* caught it because nothing read the stale copy.
*
* Each value MUST track the version its store declares — `HUB_STORE_SCHEMA_VERSION`
* in `store.ts`, `KXM_REGISTRY_SCHEMA_VERSION` and `KXM_EVENT_STORE_SCHEMA_VERSION`
* in `runtime-store.ts`. Those modules import this one, so the constants cannot be
* named here; the gate is the named test
* `restore ceilings track every store's own schema version` in
* `test/core/e6-backup-restore-migrations.test.ts`, which is what the deleted
* "must track" comments were only pretending to be.
*/
export const KXM_BACKUP_CEILINGS = {
"hub-store": 5,
registry: 2,
"binding-store": 1,
events: 7,
} as const;
export function kxmBackupCeiling(storeId: string): number {
if (storeId === "runtime-registry") return KXM_BACKUP_CEILINGS.registry;
if (storeId.startsWith("events:")) return KXM_BACKUP_CEILINGS.events;
// An id this build does not know keeps the ceiling restore has always defaulted to.
return KXM_BACKUP_CEILINGS[storeId as keyof typeof KXM_BACKUP_CEILINGS] ?? KXM_BACKUP_CEILINGS["hub-store"];
}
interface DiscoveredStore {
storeId: string;
sourcePath: string;
maxSupportedVersion: number;
}
interface DiscoveredFile {
id: string;
sourcePath: string;
}
function pushStore(stores: DiscoveredStore[], storeId: string, sourcePath: string): void {
if (stores.some((store) => store.storeId === storeId || store.sourcePath === sourcePath)) return;
stores.push({ storeId, sourcePath, maxSupportedVersion: kxmBackupCeiling(storeId) });
}
interface DiscoveredSources {
stores: DiscoveredStore[];
files: DiscoveredFile[];
stateRoot?: string;
runtimeProjectKey: string;
}
interface BackupDiscoverOptions {
hubDataPath?: string;
env?: NodeJS.ProcessEnv;
allProjects?: boolean;
}
function pushEventStore(stores: DiscoveredStore[], files: DiscoveredFile[], paths: KxmRuntimePaths, key: string): void {
const dbPath = join(paths.projectsDir, key, "run-events.db");
if (!existsSync(dbPath)) return;
const safe = key.replace(/[^a-zA-Z0-9_.-]/g, "_");
let storeId = `events:${safe}`;
if (stores.some((store) => store.storeId === storeId)) storeId = `events:runtime:${safe}`;
pushStore(stores, storeId, dbPath);
const sidecar = `${dbPath}.run-prompts.json`;
if (existsSync(sidecar)) {
const id = `${storeId}:run-prompts`;
if (!files.some((file) => file.id === id || file.sourcePath === sidecar)) {
files.push({ id, sourcePath: sidecar });
}
}
}
/**
* The Runtime stores under the user state root. By default only this checkout's
* event store: the registry and the other projects' event stores are shared by every
* project on the machine, and a restore of them rolls all of those projects back, so
* only an explicit `allProjects` backup holds them.
*/
function discoverUserRuntime(
runtimeProjectKey: string,
options: BackupDiscoverOptions,
stores: DiscoveredStore[],
files: DiscoveredFile[],
): string | undefined {
let paths: KxmRuntimePaths;
try {
paths = kxmRuntimePaths(options.env ? { env: options.env } : {});
} catch {
return undefined;
}
if (!options.allProjects) {
pushEventStore(stores, files, paths, runtimeProjectKey);
return paths.stateRoot;
}
if (existsSync(paths.registryDb)) {
const storeId = stores.some((store) => store.storeId === "registry") ? "runtime-registry" : "registry";
pushStore(stores, storeId, paths.registryDb);
}
if (!existsSync(paths.projectsDir)) return paths.stateRoot;
let entries;
try {
entries = readdirSync(paths.projectsDir, { withFileTypes: true });
} catch {
return paths.stateRoot;
}
for (const entry of entries) {
if (entry.isDirectory()) pushEventStore(stores, files, paths, entry.name);
}
return paths.stateRoot;
}
function discoverBackupSources(projectRoot: string, options: BackupDiscoverOptions = {}): DiscoveredSources {
const root = resolve(projectRoot);
const stores: DiscoveredStore[] = [];
const files: DiscoveredFile[] = [];
const hubPath = options.hubDataPath ? resolve(options.hubDataPath) : join(root, ".kxm", "state", "kxm.db");
if (existsSync(hubPath)) pushStore(stores, "hub-store", hubPath);
const registryPath = join(root, ".kxm", "runtime", "registry.db");
if (existsSync(registryPath)) pushStore(stores, "registry", registryPath);
const bindingsPath = join(root, ".kxm", "runtime", "bindings.db");
if (existsSync(bindingsPath)) pushStore(stores, "binding-store", bindingsPath);
const eventsDir = join(root, ".kxm", "runtime", "events");
if (existsSync(eventsDir)) {
const entries = readdirSync(eventsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith(".db")) {
const key = entry.name.replace(/\.db$/, "").replace(/[^a-zA-Z0-9_.-]/g, "_");
pushStore(stores, `events:${key}`, join(eventsDir, entry.name));
}
}
}
const runtimeProjectKey = projectRuntimeKey(root);
const stateRoot = discoverUserRuntime(runtimeProjectKey, options, stores, files);
return { stores, files, runtimeProjectKey, ...(stateRoot !== undefined ? { stateRoot } : {}) };
}
function backupPlainFile(sourcePath: string, targetPath: string, id: string): BackupFileRecord {
const resolvedSource = resolve(sourcePath);
const sourceStat = lstatSync(resolvedSource, { throwIfNoEntry: false });
if (!sourceStat || !sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw databaseError("runtime_path_invalid", resolvedSource, `backup file ${resolvedSource} must be a regular file, not a link or directory`);
}
checkedParent(targetPath, "backup target");
if (existsSync(targetPath)) unlinkSync(targetPath);
copyFileSync(resolvedSource, targetPath);
try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
return {
id,
sourcePath: resolvedSource,
backupFile: basename(targetPath),
sha256: fileSha256(targetPath),
bytes: sourceStat.size,
};
}
function backupFilename(sourcePath: string, id: string, used: Set<string>): string {
let filename = basename(sourcePath);
if (used.has(filename)) {
filename = `${id.replace(/[^a-zA-Z0-9_.-]/g, "_")}-${filename}`;
}
used.add(filename);
return filename;
}
function restorePlainFile(backupFilePath: string, targetPath: string): void {
checkedParent(targetPath, "restore target");
if (existsSync(targetPath)) unlinkSync(targetPath);
copyFileSync(backupFilePath, targetPath);
try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
}
export function discoverProjectStores(projectRoot: string, options: BackupDiscoverOptions = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
return discoverBackupSources(projectRoot, options).stores;
}
export interface BackupPlan {
projectRoot: string;
outDir: string;
createdAt: string;
scope: KxmBackupScope;
stateRoot?: string;
runtimeProjectKey: string;
stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
files: Array<{ id: string; sourcePath: string; backupFile: string }>;
}
function backupDiscoverOptions(options: BackupDiscoverOptions): BackupDiscoverOptions {
return {
...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
...(options.env !== undefined ? { env: options.env } : {}),
...(options.allProjects === true ? { allProjects: true } : {}),
};
}
/** Which stores and files a backup would copy and where, without opening any of them.
* Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
export function planBackup(options: BackupDiscoverOptions & {
projectRoot?: string;
outDir?: string;
} = {}): BackupPlan {
const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
const discovered = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
if (discovered.stores.length === 0) {
throw databaseError("backup_no_stores", projectRoot, "no existing SQLite stores found to backup");
}
const createdAt = new Date().toISOString();
const timestamp = createdAt.replace(/[:.]/g, "-");
const outDir = options.outDir ? resolve(options.outDir) : join(projectRoot, ".kxm", "backups", `backup-${timestamp}`);
const usedFilenames = new Set<string>();
const stores = discovered.stores.map((store) => ({
storeId: store.storeId,
sourcePath: store.sourcePath,
backupFile: backupFilename(store.sourcePath, store.storeId, usedFilenames),
}));
const files = discovered.files.map((file) => ({
id: file.id,
sourcePath: file.sourcePath,
backupFile: backupFilename(file.sourcePath, file.id, usedFilenames),
}));
return {
projectRoot,
outDir,
createdAt,
scope: options.allProjects === true ? "all-projects" : "project",
...(discovered.stateRoot !== undefined ? { stateRoot: discovered.stateRoot } : {}),
runtimeProjectKey: discovered.runtimeProjectKey,
stores,
files,
};
}
export function createBackup(options: BackupDiscoverOptions & {
projectRoot?: string;
outDir?: string;
} = {}): { manifest: BackupManifest; outDir: string } {
const { projectRoot, outDir, createdAt, scope, stateRoot, runtimeProjectKey, stores, files } = planBackup(options);
const backupId = `bk_${randomBytes(8).toString("hex")}`;
if (!existsSync(outDir)) {
mkdirSync(outDir, { recursive: true, mode: 0o700 });
}
const backedUpStores: BackupStoreRecord[] = [];
const backedUpFiles: BackupFileRecord[] = [];
const omitted: string[] = [];
for (const store of stores) {
try {
backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
} catch {
omitted.push(store.storeId);
}
}
for (const file of files) {
try {
backedUpFiles.push(backupPlainFile(file.sourcePath, join(outDir, file.backupFile), file.id));
} catch {
omitted.push(file.id);
}
}
if (backedUpStores.length === 0) {
throw databaseError("backup_no_stores", projectRoot, "no SQLite store could be copied");
}
const again = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
for (const store of again.stores) {
if (!backedUpStores.some((copied) => copied.storeId === store.storeId) && !omitted.includes(store.storeId)) {
omitted.push(store.storeId);
}
}
for (const file of again.files) {
if (!backedUpFiles.some((copied) => copied.id === file.id) && !omitted.includes(file.id)) {
omitted.push(file.id);
}
}
const manifest: BackupManifest = {
schema: "kxm.backup-manifest.v1",
backupId,
createdAt,
projectRoot,
scope,
...(stateRoot !== undefined ? { stateRoot } : {}),
runtimeProjectKey,
stores: backedUpStores,
...(backedUpFiles.length > 0 ? { files: backedUpFiles } : {}),
complete: omitted.length === 0,
...(omitted.length > 0 ? { omitted } : {}),
};
const manifestJson = JSON.stringify(manifest, null, 2) + "\n";
const manifestSha256 = `sha256:${createHash("sha256").update(manifestJson, "utf8").digest("hex")}`;
manifest.manifestSha256 = manifestSha256;
const finalJson = JSON.stringify(manifest, null, 2) + "\n";
const manifestPath = join(outDir, "manifest.json");
writeFileSync(manifestPath, finalJson, "utf8");
return { manifest, outDir };
}
export interface RestorePlan {
manifestPath: string;
backupId: string;
/** The manifest's scope; absent on manifests written before backups were scoped. */
scope?: KxmBackupScope;
stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
files: Array<{ id: string; backupFilePath: string; targetPath: string }>;
}
export interface RestoreOptions {
/** The checkout to restore into. Its stores are rebased onto it, and its own Runtime
* event store is the one the Runtime derives for it. */
projectRoot?: string;
env?: NodeJS.ProcessEnv;
/** Also restore the shared Runtime registry and other projects' event stores. */
allProjects?: boolean;
}
function pathUnder(root: string | undefined, path: string): string | undefined {
if (!root) return undefined;
const rel = relative(root, path);
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
return rel;
}
/**
* Where one backed-up store or file goes, and whether it belongs to this checkout.
*
* A path under the manifest's project root is rebased onto the checkout being restored.
* The checkout's own Runtime event store (and its prompt sidecar) goes to the store the
* Runtime derives for that checkout, under the current user state root. Anything else
* under the user state root is machine-wide — the registry, another project's event
* store — and so is anything outside both recorded roots except a relocated hub store,
* which is how a manifest written before scopes records Runtime stores. Those restore
* only with `allProjects`, rebased onto the current user state root when the manifest
* recorded one.
*/
function restoreTarget(
manifest: BackupManifest,
sourcePath: string,
id: string,
options: RestoreOptions,
currentStateRoot: () => string,
): { targetPath: string; machineWide: boolean } {
const stateRel = pathUnder(manifest.stateRoot, sourcePath);
const projectRel = pathUnder(manifest.projectRoot, sourcePath);
// KXM_STATE_HOME may sit inside the checkout; the deeper root owns the path.
if (stateRel !== undefined && (projectRel === undefined || stateRel.length <= projectRel.length)) {
const ownEvents = manifest.runtimeProjectKey !== undefined
? join("runtime", "projects", manifest.runtimeProjectKey, "run-events.db")
: undefined;
const own = ownEvents !== undefined && (stateRel === ownEvents || stateRel === `${ownEvents}.run-prompts.json`);
if (own && !options.allProjects && options.projectRoot !== undefined) {
const eventsPath = kxmProjectRunEventsPath(resolve(options.projectRoot), options.env ?? process.env);
return { targetPath: stateRel === ownEvents ? eventsPath : `${eventsPath}.run-prompts.json`, machineWide: false };
}
return { targetPath: join(currentStateRoot(), stateRel), machineWide: !own };
}
if (projectRel !== undefined) {
return { targetPath: options.projectRoot ? join(resolve(options.projectRoot), projectRel) : sourcePath, machineWide: false };
}
return { targetPath: sourcePath, machineWide: id !== "hub-store" };
}
/** Everything restore checks before it overwrites anything: the manifest, each
* backup file's digest, each store's schema against this build's ceiling (from
* the manifest), and that nothing outside this checkout would be overwritten
* without `allProjects`. Reads files; opens no database. */
export function planRestore(
manifestPathOrDir: string,
options: RestoreOptions = {},
): RestorePlan {
let manifestPath = resolve(manifestPathOrDir);
const stat = lstatSync(manifestPath, { throwIfNoEntry: false });
if (!stat) {
throw databaseError("runtime_path_invalid", manifestPath, `manifest path ${manifestPath} does not exist`);
}
if (stat.isDirectory()) {
manifestPath = join(manifestPath, "manifest.json");
}
if (!existsSync(manifestPath)) {
throw databaseError("runtime_path_invalid", manifestPath, `backup manifest ${manifestPath} not found`);
}
const manifestDir = dirname(manifestPath);
const rawText = readFileSync(manifestPath, "utf8");
let manifest: BackupManifest;
try {
manifest = JSON.parse(rawText) as BackupManifest;
} catch (error) {
throw databaseError("restore_manifest_invalid", manifestPath, `failed to parse backup manifest: ${error instanceof Error ? error.message : String(error)}`);
}
if (manifest.schema !== "kxm.backup-manifest.v1" || !Array.isArray(manifest.stores) || manifest.stores.length === 0) {
throw databaseError("restore_manifest_invalid", manifestPath, "manifest is not a valid kxm.backup-manifest.v1 document");
}
if (manifest.complete === false) {
throw databaseError("restore_incomplete", manifestPath, "backup manifest is incomplete; refusing to restore a partial copy");
}
let stateRoot: string | undefined;
const currentStateRoot = (): string => (stateRoot ??= kxmRuntimePaths({ env: options.env ?? process.env }).stateRoot);
const machineWide: string[] = [];
const stores: RestorePlan["stores"] = [];
for (const store of manifest.stores) {
const backupFilePath = join(manifestDir, store.backupFile);
if (!existsSync(backupFilePath)) {
throw databaseError("restore_file_missing", backupFilePath, `backup file ${store.backupFile} missing from ${manifestDir}`);
}
const actualSha256 = fileSha256(backupFilePath);
if (actualSha256 !== store.sha256) {
throw databaseError(
"restore_manifest_digest_mismatch",
backupFilePath,
`backup file ${store.backupFile} sha256 ${actualSha256} does not match manifest hash ${store.sha256}`,
);
}
const maxSupportedVersion = kxmBackupCeiling(store.storeId);
if (store.schemaVersion > maxSupportedVersion) {
throw databaseError(
"runtime_schema_newer",
backupFilePath,
`backup store ${store.storeId} schema version ${store.schemaVersion} is newer than supported maximum ${maxSupportedVersion}`,
);
}
const target = restoreTarget(manifest, store.sourcePath, store.storeId, options, currentStateRoot);
if (target.machineWide) machineWide.push(store.storeId);
stores.push({ storeId: store.storeId, backupFilePath, targetPath: target.targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
}
const files: RestorePlan["files"] = [];
for (const file of manifest.files ?? []) {
const backupFilePath = join(manifestDir, file.backupFile);
if (!existsSync(backupFilePath)) {
throw databaseError("restore_file_missing", backupFilePath, `backup file ${file.backupFile} missing from ${manifestDir}`);
}
const actualSha256 = fileSha256(backupFilePath);
if (actualSha256 !== file.sha256) {
throw databaseError(
"restore_manifest_digest_mismatch",
backupFilePath,
`backup file ${file.backupFile} sha256 ${actualSha256} does not match manifest hash ${file.sha256}`,
);
}
const target = restoreTarget(manifest, file.sourcePath, file.id, options, currentStateRoot);
if (target.machineWide) machineWide.push(file.id);
files.push({ id: file.id, backupFilePath, targetPath: target.targetPath });
}
if (machineWide.length > 0 && !options.allProjects) {
throw databaseError(
"restore_requires_all_projects",
manifestPath,
`backup ${manifest.backupId} holds Runtime state shared by every project on this machine (${machineWide.join(", ")}); `
+ "restoring it rolls back the registry or other projects' runs. Pass --all-projects to restore all of it, or restore a backup taken without --all-projects",
);
}
return {
manifestPath,
backupId: manifest.backupId,
...(manifest.scope !== undefined ? { scope: manifest.scope } : {}),
stores,
files,
};
}
/** Overwrite each target in a checked plan: the stores, then their prompt sidecars. */
export function applyRestorePlan(plan: RestorePlan): RestoreResult {
const restoredStores = plan.stores.map((store) => restoreDatabaseFile(
store.backupFilePath,
store.targetPath,
store.storeId,
store.schemaVersion,
store.maxSupportedVersion,
));
for (const file of plan.files) {
restorePlainFile(file.backupFilePath, file.targetPath);
}
return {
manifestPath: plan.manifestPath,
backupId: plan.backupId,
restoredStores,
};
}
export function restoreBackup(
manifestPathOrDir: string,
options: RestoreOptions = {},
): RestoreResult {
return applyRestorePlan(planRestore(manifestPathOrDir, options));
}