Files
openclaw/src/infra/sqlite-integrity.ts
T

287 lines
9.4 KiB
TypeScript

import type { DatabaseSync } from "node:sqlite";
import { openNodeSqliteDatabase } from "./node-sqlite.js";
import {
readStableSqliteFileGeneration,
sameSqliteFileGeneration,
type SqliteFileGeneration,
} from "./sqlite-file-generation.js";
type SqliteIntegrityChecks = {
integrityCheck: "ok";
};
type UnboundSqliteIntegrityConfirmation =
| { status: "failed"; error: Error; terminal: boolean }
| { status: "healthy" };
export type SqliteIntegrityConfirmation =
| { status: "failed"; error: Error; terminal: false }
| { status: "failed"; error: Error; generation: SqliteFileGeneration; terminal: true }
| { status: "healthy"; generation: SqliteFileGeneration };
type SqliteCheckPragma = "integrity_check";
type SqliteForeignKeyViolation = {
fkid: bigint;
parent: string;
rowid: bigint | null;
table: string;
};
const MAX_REPORTED_FOREIGN_KEY_VIOLATIONS = 5;
const SQLITE_CORRUPT_ERRCODE = 11;
const SQLITE_NOTADB_ERRCODE = 26;
/** Return whether a named integrity failure proves persistent database damage. */
export function isTerminalSqliteIntegrityError(error: Error): boolean {
if (error.name !== "SqliteIntegrityError") {
return false;
}
const cause = error.cause as { errcode?: unknown } | undefined;
if (!cause) {
// No cause means the check pragma itself reported corruption rows: persistent.
return true;
}
if (typeof cause.errcode !== "number") {
return false;
}
// Mask extended codes to the primary; transient lock/busy failures must not latch.
const primaryCode = cause.errcode & 0xff;
return primaryCode === SQLITE_CORRUPT_ERRCODE || primaryCode === SQLITE_NOTADB_ERRCODE;
}
/** Require structural, table/index, and referential consistency before trusting a database. */
export function assertSqliteIntegrity(
database: DatabaseSync,
databaseLabel: string,
): SqliteIntegrityChecks {
const integrityCheck = runSqliteCheck(database, databaseLabel, "integrity_check");
runSqliteForeignKeyCheck(database, databaseLabel);
return { integrityCheck };
}
/** Run integrity checks and preserve whether a failure proves persistent damage. */
function confirmSqliteIntegrity(
database: DatabaseSync,
databaseLabel: string,
): UnboundSqliteIntegrityConfirmation {
try {
assertSqliteIntegrity(database, databaseLabel);
return { status: "healthy" };
} catch (error) {
return failedSqliteIntegrityConfirmation(error);
}
}
/** Reconfirm an advisory failure against the database currently at a closed path. */
export function confirmSqliteFileIntegrity(
pathname: string,
databaseLabel: string,
): SqliteIntegrityConfirmation {
for (let attempt = 0; attempt < 3; attempt += 1) {
let initial: SqliteFileGeneration;
try {
initial = readStableSqliteFileGeneration(pathname);
} catch (error) {
return unboundSqliteIntegrityFailure(error);
}
let database: DatabaseSync;
try {
database = openNodeSqliteDatabase(pathname, { readOnly: true });
} catch (error) {
// A failed SQLite open exposes no descriptor identity. Path snapshots
// cannot bind the error safely across an A -> B -> A file rotation.
return unboundSqliteIntegrityFailure(error);
}
let opened: SqliteFileGeneration;
try {
opened = readStableSqliteFileGeneration(pathname);
} catch {
const closeError = closeSqliteDatabase(database);
if (closeError) {
return unboundSqliteIntegrityFailure(closeError);
}
continue;
}
if (!sameSqliteFileGeneration(initial, opened)) {
const closeError = closeSqliteDatabase(database);
if (closeError) {
return unboundSqliteIntegrityFailure(closeError);
}
continue;
}
let confirmation = confirmSqliteIntegrity(database, databaseLabel);
const closeError = closeSqliteDatabase(database);
if (closeError && confirmation.status === "healthy") {
confirmation = failedSqliteIntegrityConfirmation(closeError);
}
let final: SqliteFileGeneration;
try {
final = readStableSqliteFileGeneration(pathname);
} catch {
continue;
}
if (!sameSqliteFileGeneration(opened, final)) {
continue;
}
return bindSqliteIntegrityConfirmation(confirmation, final);
}
return unboundSqliteIntegrityFailure(
new Error(`SQLite file generation did not stabilize during confirmation: ${pathname}`),
);
}
function bindSqliteIntegrityConfirmation(
confirmation: UnboundSqliteIntegrityConfirmation,
generation: SqliteFileGeneration,
): SqliteIntegrityConfirmation {
if (confirmation.status === "healthy") {
return { status: "healthy", generation };
}
if (confirmation.terminal) {
return { ...confirmation, generation, terminal: true };
}
return { ...confirmation, terminal: false };
}
function failedSqliteIntegrityConfirmation(error: unknown): UnboundSqliteIntegrityConfirmation {
const normalized = error instanceof Error ? error : new Error(String(error));
return {
status: "failed",
error: normalized,
terminal: isTerminalSqliteIntegrityError(normalized),
};
}
function unboundSqliteIntegrityFailure(error: unknown): SqliteIntegrityConfirmation {
const normalized = error instanceof Error ? error : new Error(String(error));
return { status: "failed", error: normalized, terminal: false };
}
function closeSqliteDatabase(database: DatabaseSync): Error | undefined {
try {
database.close();
return undefined;
} catch (error) {
return error instanceof Error ? error : new Error(String(error));
}
}
/** Require table and associated index consistency before trusting indexed reads. */
export function assertSqliteTableIntegrity(
database: DatabaseSync,
databaseLabel: string,
tableName: string,
): void {
runSqliteCheck(database, `${databaseLabel} table ${tableName}`, "integrity_check", tableName);
}
function runSqliteCheck(
database: DatabaseSync,
databaseLabel: string,
pragma: SqliteCheckPragma,
tableName?: string,
): "ok" {
const argument = tableName ? `('${tableName.replaceAll("'", "''")}')` : "";
let rows: Array<Record<string, unknown>>;
try {
rows = database.prepare(`PRAGMA ${pragma}${argument};`).all() as Array<Record<string, unknown>>;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw createSqliteIntegrityError(
`SQLite ${pragma} failed for ${databaseLabel}: ${message}`,
error,
);
}
const results = rows.map((row) => row[pragma] ?? Object.values(row)[0]);
if (results.length === 1 && results[0] === "ok") {
return "ok";
}
const details = results.map((result) => String(result)).join("; ") || "no result";
throw createSqliteIntegrityError(`SQLite ${pragma} failed for ${databaseLabel}: ${details}`);
}
function runSqliteForeignKeyCheck(database: DatabaseSync, databaseLabel: string): void {
let violationCount = 0;
const violations: SqliteForeignKeyViolation[] = [];
try {
// Use direct PRAGMA syntax because a real schema object can shadow the
// table-valued pragma name and make a corrupt database appear clean.
const statement = database.prepare("PRAGMA foreign_key_check;");
statement.setReadBigInts(true);
// OpenClaw's Node >=22.22.3 floor includes iterate(), added in Node 22.13.
for (const violation of statement.iterate() as Iterable<SqliteForeignKeyViolation>) {
violationCount += 1;
retainSortedForeignKeyViolation(violations, violation);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw createSqliteIntegrityError(
`SQLite foreign_key_check failed for ${databaseLabel}: ${message}`,
error,
);
}
if (violations.length === 0) {
return;
}
const details = violations.map(formatSqliteForeignKeyViolation);
if (violationCount > MAX_REPORTED_FOREIGN_KEY_VIOLATIONS) {
details.push("additional violations omitted");
}
throw createSqliteIntegrityError(
`SQLite foreign_key_check failed for ${databaseLabel}: ${details.join("; ")}`,
);
}
function createSqliteIntegrityError(message: string, cause?: unknown): Error {
const error = cause === undefined ? new Error(message) : new Error(message, { cause });
error.name = "SqliteIntegrityError";
return error;
}
function retainSortedForeignKeyViolation(
retained: SqliteForeignKeyViolation[],
violation: SqliteForeignKeyViolation,
): void {
retained.push(violation);
retained.sort(compareSqliteForeignKeyViolations);
if (retained.length > MAX_REPORTED_FOREIGN_KEY_VIOLATIONS) {
retained.pop();
}
}
function compareSqliteForeignKeyViolations(
left: SqliteForeignKeyViolation,
right: SqliteForeignKeyViolation,
): number {
const tableOrder = Buffer.compare(Buffer.from(left.table), Buffer.from(right.table));
if (tableOrder !== 0) {
return tableOrder;
}
if (left.rowid === null || right.rowid === null) {
if (left.rowid !== right.rowid) {
return left.rowid === null ? -1 : 1;
}
} else if (left.rowid !== right.rowid) {
return left.rowid < right.rowid ? -1 : 1;
}
const parentOrder = Buffer.compare(Buffer.from(left.parent), Buffer.from(right.parent));
if (parentOrder !== 0) {
return parentOrder;
}
if (left.fkid === right.fkid) {
return 0;
}
return left.fkid < right.fkid ? -1 : 1;
}
function formatSqliteForeignKeyViolation(violation: SqliteForeignKeyViolation): string {
const row = violation.rowid === null ? "row without rowid" : `row ${violation.rowid.toString()}`;
return `${violation.table} ${row} references ${violation.parent} (foreign key ${violation.fkid.toString()})`;
}