blob: fe7fac3f348d78e96bde02faf76c6df26c42f05c [file]
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { createRequire } from 'node:module';
import type { DatabaseSync } from 'node:sqlite';
import {
configureSqliteRuntimeDatabase,
migrateSqliteRuntimeDatabase,
readUserVersion,
SQLITE_RUNTIME_SCHEMA_VERSION,
} from './sqlite-runtime-schema.js';
import {
migrateSqliteSessionMetadataDatabase,
SQLITE_SESSION_METADATA_SCHEMA_VERSION,
} from './sqlite-session-metadata-schema.js';
import {
migrateSqliteCoreExecutionDatabase,
SQLITE_CORE_EXECUTION_SCHEMA_VERSION,
} from './sqlite-core-execution-schema.js';
import {
migrateSqliteWorkflowDatabase,
SQLITE_WORKFLOW_SCHEMA_VERSION,
} from './sqlite-workflow-schema.js';
import { migrateSqliteUsageDatabase, SQLITE_USAGE_SCHEMA_VERSION } from './sqlite-usage-schema.js';
import {
migrateSqliteArtifactDatabase,
SQLITE_ARTIFACT_SCHEMA_VERSION,
} from './sqlite-artifact-schema.js';
import {
migrateSqliteAutomationDatabase,
SQLITE_AUTOMATION_SCHEMA_VERSION,
} from './sqlite-automation-schema.js';
export const OPERATIONAL_STATE_DATABASE_NAME = 'runtime.sqlite';
export const OPERATIONAL_STATE_SCHEMA_VERSION = 1;
const OPERATIONAL_SCHEMA_VERSIONS: ReadonlyMap<string, number> = new Map([
['runtime', SQLITE_RUNTIME_SCHEMA_VERSION],
['session_metadata', SQLITE_SESSION_METADATA_SCHEMA_VERSION],
['core_execution', SQLITE_CORE_EXECUTION_SCHEMA_VERSION],
['workflow', SQLITE_WORKFLOW_SCHEMA_VERSION],
['usage', SQLITE_USAGE_SCHEMA_VERSION],
['artifact', SQLITE_ARTIFACT_SCHEMA_VERSION],
['automation', SQLITE_AUTOMATION_SCHEMA_VERSION],
['operational', OPERATIONAL_STATE_SCHEMA_VERSION],
] as const);
const require = createRequire(import.meta.url);
const owners = new Map<string, OperationalStateDatabaseOwner>();
export interface OperationalStateDatabaseOptions {
now?: () => number;
}
export interface OperationalStateDatabaseLease {
readonly database: DatabaseSync;
readonly databasePath: string;
transaction<T>(mode: 'read' | 'write', operation: () => T): T;
backup(destinationPath: string): Promise<number>;
close(): void;
}
/**
* Operational state is disposable across incompatible app versions. Settings
* and credentials live in dedicated stores, so rebuild this database instead
* of migrating or downgrading its schema.
*/
export function resetIncompatibleOperationalStateDatabase(workspaceRoot: string): boolean {
const databasePath = resolve(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME);
if (!existsSync(databasePath)) return false;
if (owners.has(databasePath)) {
throw new Error('Operational state compatibility must be checked before opening the database');
}
const Database = loadDatabaseSync();
const database = new Database(databasePath, { readOnly: true });
let compatible: boolean;
try {
compatible = hasCurrentOperationalSchema(database);
} finally {
database.close();
}
if (compatible) return false;
for (const suffix of ['', '-wal', '-shm']) {
rmSync(`${databasePath}${suffix}`, { force: true });
}
return true;
}
function hasCurrentOperationalSchema(database: DatabaseSync): boolean {
if (readUserVersion(database) !== SQLITE_RUNTIME_SCHEMA_VERSION) return false;
const table = database
.prepare(`
SELECT 1 AS present
FROM sqlite_master
WHERE type = 'table' AND name = 'operational_schema_migrations'
`)
.get() as { present?: unknown } | undefined;
if (table?.present !== 1) return false;
const rows = database
.prepare('SELECT scope, version FROM operational_schema_migrations')
.all() as Array<{ scope?: unknown; version?: unknown }>;
if (rows.length !== OPERATIONAL_SCHEMA_VERSIONS.size) return false;
return rows.every(
({ scope, version }) =>
typeof scope === 'string' && OPERATIONAL_SCHEMA_VERSIONS.get(scope) === version,
);
}
/**
* Acquire the process-local owner for the operational SQLite authority.
*
* Repositories receive leases instead of opening independent connections.
* The last lease closes the connection, while transaction boundaries remain
* centralized on the owner for the lifetime of the workspace.
*/
export function acquireOperationalStateDatabase(
workspaceRoot: string,
options: OperationalStateDatabaseOptions = {},
): OperationalStateDatabaseLease {
const databasePath = resolve(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME);
let owner = owners.get(databasePath);
if (!owner) {
owner = new OperationalStateDatabaseOwner(databasePath, options);
owners.set(databasePath, owner);
}
return owner.acquire();
}
class OperationalStateDatabaseOwner {
readonly database: DatabaseSync;
private references = 0;
private closed = false;
private transactionDepth = 0;
constructor(
readonly databasePath: string,
options: OperationalStateDatabaseOptions,
) {
mkdirSync(dirname(databasePath), { recursive: true });
const Database = loadDatabaseSync();
this.database = new Database(databasePath);
try {
configureSqliteRuntimeDatabase(this.database);
migrateSqliteRuntimeDatabase(this.database);
migrateSqliteSessionMetadataDatabase(this.database);
migrateSqliteCoreExecutionDatabase(this.database);
migrateSqliteWorkflowDatabase(this.database);
migrateSqliteUsageDatabase(this.database);
migrateSqliteArtifactDatabase(this.database);
migrateSqliteAutomationDatabase(this.database);
migrateOperationalStateDatabase(this.database, options.now ?? Date.now);
} catch (error) {
this.database.close();
this.closed = true;
throw error;
}
}
acquire(): OperationalStateDatabaseLease {
if (this.closed) throw new Error('Operational state database is closed');
this.references += 1;
let released = false;
return {
database: this.database,
databasePath: this.databasePath,
transaction: (mode, operation) => this.transaction(mode, operation),
backup: (destinationPath) => this.backup(destinationPath),
close: () => {
if (released) return;
released = true;
this.releaseReference();
},
};
}
private async backup(destinationPath: string): Promise<number> {
if (this.closed) throw new Error('Operational state database is closed');
if (!destinationPath) throw new Error('Operational state backup destination is required');
const canonicalDestination = resolve(destinationPath);
if (canonicalDestination === this.databasePath) {
throw new Error('Operational state backup destination must differ from the source database');
}
if (existsSync(canonicalDestination)) {
throw new Error(
`Operational state backup destination already exists: ${canonicalDestination}`,
);
}
mkdirSync(dirname(canonicalDestination), { recursive: true });
this.references += 1;
try {
return await loadSqliteModule().backup(this.database, canonicalDestination);
} finally {
this.releaseReference();
}
}
private releaseReference(): void {
this.references -= 1;
if (this.references !== 0) return;
this.closed = true;
owners.delete(this.databasePath);
this.database.close();
}
private transaction<T>(mode: 'read' | 'write', operation: () => T): T {
if (this.closed) throw new Error('Operational state database is closed');
if (this.transactionDepth > 0) return operation();
this.database.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN');
this.transactionDepth += 1;
try {
const result = operation();
this.database.exec('COMMIT');
return result;
} catch (error) {
rollback(this.database);
throw error;
} finally {
this.transactionDepth -= 1;
}
}
}
function migrateOperationalStateDatabase(db: DatabaseSync, now: () => number): void {
db.exec('BEGIN IMMEDIATE');
try {
db.exec(`
CREATE TABLE IF NOT EXISTS operational_schema_migrations (
scope TEXT PRIMARY KEY,
version INTEGER NOT NULL CHECK (version >= 0),
applied_at INTEGER NOT NULL CHECK (applied_at >= 0)
);
`);
const appliedAt = now();
for (const [scope, version] of OPERATIONAL_SCHEMA_VERSIONS) {
registerSchema(db, scope, version, appliedAt);
}
db.exec('COMMIT');
} catch (error) {
rollback(db);
throw error;
}
}
function registerSchema(db: DatabaseSync, scope: string, version: number, appliedAt: number): void {
const existing = db
.prepare('SELECT version FROM operational_schema_migrations WHERE scope = ?')
.get(scope) as { version?: unknown } | undefined;
if (
existing &&
(typeof existing.version !== 'number' ||
!Number.isSafeInteger(existing.version) ||
existing.version > version)
) {
throw new Error(`Operational schema ${scope} is newer than supported version ${version}`);
}
db.prepare(`
INSERT INTO operational_schema_migrations(scope, version, applied_at)
VALUES (?, ?, ?)
ON CONFLICT(scope) DO UPDATE SET
version = excluded.version,
applied_at = CASE
WHEN operational_schema_migrations.version = excluded.version
THEN operational_schema_migrations.applied_at
ELSE excluded.applied_at
END
`).run(scope, version, appliedAt);
}
function loadDatabaseSync(): typeof import('node:sqlite').DatabaseSync {
const emitWarning = process.emitWarning;
process.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
const warningType = typeof args[0] === 'string' ? args[0] : undefined;
if (
warningType === 'ExperimentalWarning' &&
String(warning).startsWith('SQLite is an experimental feature')
) {
return;
}
Reflect.apply(emitWarning, process, [warning, ...args]);
}) as typeof process.emitWarning;
try {
return (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync;
} finally {
process.emitWarning = emitWarning;
}
}
function loadSqliteModule(): typeof import('node:sqlite') {
return require('node:sqlite') as typeof import('node:sqlite');
}
function rollback(db: DatabaseSync): void {
try {
db.exec('ROLLBACK');
} catch {
// Preserve the failure that triggered rollback.
}
}