UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

184 lines (183 loc) 10.8 kB
import { t as openNodeSqliteDatabase, u as runSqliteImmediateTransactionSync } from "./node-sqlite-BpQX3W0e.js"; import { t as assertSqliteIntegrity } from "./sqlite-integrity-NpEtFIdK.js"; //#region src/infra/sqlite-strict.ts const DEFAULT_STRICT_MIGRATION_BUSY_TIMEOUT_MS = 5e3; const STRICT_MIGRATION_TABLE_PREFIX = "__openclaw_strict_migration_"; const SQLITE_ROWID_ALIASES = [ "_rowid_", "rowid", "oid" ]; function quoteSqliteIdentifier(identifier) { return `"${identifier.replaceAll("\"", "\"\"")}"`; } function readMainTableList(db) { return db.prepare("PRAGMA table_list").all().filter((row) => row.schema === "main" && typeof row.name === "string" && !row.name.startsWith("sqlite_")); } function readTableColumns(db, tableName) { return db.prepare(`PRAGMA table_xinfo(${quoteSqliteIdentifier(tableName)})`).all(); } function readVisibleColumns(db, tableName) { return readTableColumns(db, tableName).filter((row) => Number(row.hidden ?? 0) === 0).map((row) => { if (typeof row.name !== "string" || row.name.length === 0) throw new Error(`SQLite table ${tableName} has an invalid column name`); return row.name; }); } function readTableRowidModel(db, tableName, tableRow) { if (Number(tableRow.wr ?? 0) === 1) return { alias: null, storage: "without-rowid" }; const columns = readTableColumns(db, tableName); const primaryKeyColumns = columns.filter((column) => Number(column.pk ?? 0) > 0); const primaryKeyIndex = db.prepare(`SELECT 1 AS found FROM pragma_index_list(?) WHERE origin = 'pk' LIMIT 1`).get(tableName); const primaryKeyType = primaryKeyColumns[0]?.type; if (primaryKeyColumns.length === 1 && typeof primaryKeyType === "string" && primaryKeyType.toUpperCase() === "INTEGER" && !primaryKeyIndex) return { alias: null, storage: "integer-primary-key" }; const declaredNames = new Set(columns.flatMap((column) => typeof column.name === "string" ? [column.name.toLowerCase()] : [])); const alias = SQLITE_ROWID_ALIASES.find((candidate) => !declaredNames.has(candidate)) ?? null; if (!alias) throw new Error(`SQLite table ${tableName} shadows every rowid alias; its implicit rowids cannot be migrated safely`); return { alias, storage: "implicit" }; } function readCanonicalStrictTables(schemaSql) { const canonical = openNodeSqliteDatabase(":memory:"); try { canonical.exec(schemaSql); const tables = readMainTableList(canonical).filter((row) => row.type === "table"); const nonStrict = tables.flatMap((row) => Number(row.strict ?? 0) === 1 || typeof row.name !== "string" ? [] : [row.name]); if (nonStrict.length > 0) throw new Error(`Canonical SQLite schema contains non-STRICT tables: ${nonStrict.toSorted().join(", ")}`); return tables.map((row) => { if (typeof row.name !== "string") throw new Error("Canonical SQLite schema contains an unnamed table"); const schemaRow = canonical.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?").get(row.name); if (typeof schemaRow?.sql !== "string") throw new Error(`Canonical SQLite table ${row.name} has no CREATE statement`); const rowidModel = readTableRowidModel(canonical, row.name, row); return { columns: readVisibleColumns(canonical, row.name), createSql: schemaRow.sql, name: row.name, rowidAlias: rowidModel.alias, rowidStorage: rowidModel.storage, usesAutoincrement: /\bAUTOINCREMENT\b/iu.test(schemaRow.sql) }; }).toSorted((left, right) => left.name.localeCompare(right.name)); } finally { canonical.close(); } } function rewriteCreateTableName(createSql, replacementName) { const openingParen = createSql.indexOf("("); if (openingParen === -1) throw new Error("Canonical SQLite table CREATE statement has no column list"); return `CREATE TABLE ${quoteSqliteIdentifier(replacementName)} ${createSql.slice(openingParen)}`; } function readPreservedSchemaObjects(db, tableNames) { return db.prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE type IN ('index', 'trigger', 'view')").all().flatMap((row) => { if (row.type !== "index" && row.type !== "trigger" && row.type !== "view" || typeof row.name !== "string" || typeof row.tbl_name !== "string" || typeof row.sql !== "string" || row.type === "index" && !tableNames.has(row.tbl_name)) return []; return [{ name: row.name, sql: row.sql, type: row.type }]; }).toSorted((left, right) => { const typeOrder = { view: 0, index: 1, trigger: 2 }; return typeOrder[left.type] - typeOrder[right.type] || left.name.localeCompare(right.name); }); } function readAutoincrementHighWater(db, tableName) { if (!db.prepare("SELECT 1 AS found FROM sqlite_schema WHERE type = 'table' AND name = 'sqlite_sequence'").get()) return null; const row = db.prepare("SELECT CAST(seq AS TEXT) AS seq FROM sqlite_sequence WHERE name = ?").get(tableName); if (row === void 0) return null; const normalized = typeof row.seq === "string" ? /^(\d+)(?:\.0+)?$/u.exec(row.seq)?.[1] : null; if (!normalized) throw new Error(`SQLite table ${tableName} has an invalid AUTOINCREMENT high-water mark (${typeof row.seq}: ${String(row.seq)})`); return normalized; } function restoreAutoincrementHighWater(db, tableName, previousHighWater) { if (previousHighWater === null) return; const currentHighWater = readAutoincrementHighWater(db, tableName); const restored = currentHighWater === null || BigInt(previousHighWater) > BigInt(currentHighWater) ? previousHighWater : currentHighWater; db.prepare("DELETE FROM sqlite_sequence WHERE name = ?").run(tableName); db.prepare("INSERT INTO sqlite_sequence (name, seq) VALUES (?, CAST(? AS INTEGER))").run(tableName, restored); } function assertMatchingColumns(tableName, currentColumns, canonicalColumns) { const current = new Set(currentColumns); const canonical = new Set(canonicalColumns); const missing = canonicalColumns.filter((column) => !current.has(column)); const extra = currentColumns.filter((column) => !canonical.has(column)); if (missing.length === 0 && extra.length === 0) return; const details = [missing.length > 0 ? `missing ${missing.join(", ")}` : "", extra.length > 0 ? `extra ${extra.join(", ")}` : ""].filter(Boolean).join("; "); throw new Error(`SQLite table ${tableName} does not match its canonical columns (${details})`); } function readForeignKeysEnabled(db) { const row = db.prepare("PRAGMA foreign_keys").get(); return Number(row?.foreign_keys ?? 0) === 1; } /** * Rebuild canonical non-STRICT tables inside the caller's transaction. * Foreign-key enforcement must be disabled before BEGIN; integrity is checked * before this function returns so any bad row or relationship rolls back. */ function migrateSqliteSchemaToStrictInTransaction(db, schemaSql, options = {}) { if (!db.isTransaction) throw new Error("SQLite STRICT schema migration requires an active transaction"); const canonicalTables = readCanonicalStrictTables(schemaSql); db.exec(schemaSql); const currentTableRows = new Map(readMainTableList(db).filter((row) => row.type === "table" && typeof row.name === "string").map((row) => [row.name, row])); const tablesToMigrate = canonicalTables.filter((table) => Number(currentTableRows.get(table.name)?.strict ?? 0) !== 1); if (tablesToMigrate.length === 0) return { migratedTables: [] }; if (readForeignKeysEnabled(db)) throw new Error("SQLite STRICT schema migration requires foreign_keys=OFF before BEGIN"); const preservedObjects = readPreservedSchemaObjects(db, new Set(tablesToMigrate.map((table) => table.name))); for (const object of preservedObjects) if (object.type === "trigger") db.exec(`DROP TRIGGER ${quoteSqliteIdentifier(object.name)};`); for (const object of preservedObjects) if (object.type === "view") db.exec(`DROP VIEW ${quoteSqliteIdentifier(object.name)};`); for (const [index, table] of tablesToMigrate.entries()) { const migrationTable = `${STRICT_MIGRATION_TABLE_PREFIX}${index}_${table.name}`; if (currentTableRows.has(migrationTable)) throw new Error(`SQLite STRICT migration table already exists: ${migrationTable}`); const currentColumns = readVisibleColumns(db, table.name); assertMatchingColumns(table.name, currentColumns, table.columns); const currentTableRow = currentTableRows.get(table.name); if (!currentTableRow) throw new Error(`SQLite table ${table.name} disappeared during STRICT migration`); const currentRowidModel = readTableRowidModel(db, table.name, currentTableRow); if (currentRowidModel.storage !== table.rowidStorage) throw new Error(`SQLite table ${table.name} changes rowid storage from ${currentRowidModel.storage} to ${table.rowidStorage}; refusing an identity-changing STRICT migration`); const previousHighWater = table.usesAutoincrement ? readAutoincrementHighWater(db, table.name) : null; db.exec(rewriteCreateTableName(table.createSql, migrationTable)); const columns = table.columns.map(quoteSqliteIdentifier); if (table.rowidAlias) columns.unshift(quoteSqliteIdentifier(table.rowidAlias)); const copyColumns = columns.join(", "); try { db.exec(`INSERT INTO ${quoteSqliteIdentifier(migrationTable)} (${copyColumns}) SELECT ${copyColumns} FROM ${quoteSqliteIdentifier(table.name)};`); } catch (error) { throw new Error(`Failed migrating SQLite table ${table.name} to STRICT`, { cause: error }); } db.exec(`DROP TABLE ${quoteSqliteIdentifier(table.name)};`); db.exec(`ALTER TABLE ${quoteSqliteIdentifier(migrationTable)} RENAME TO ${quoteSqliteIdentifier(table.name)};`); restoreAutoincrementHighWater(db, table.name, previousHighWater); } db.exec(schemaSql); const findObject = db.prepare("SELECT 1 AS found FROM sqlite_schema WHERE type = ? AND name = ? LIMIT 1"); for (const object of preservedObjects) if (!findObject.get(object.type, object.name)) db.exec(object.sql); assertSqliteIntegrity(db, options.databaseLabel ?? "SQLite STRICT schema migration"); return { migratedTables: tablesToMigrate.map((table) => table.name) }; } /** Atomically upgrade OpenClaw-owned tables described by a canonical STRICT schema. */ function migrateSqliteSchemaToStrict(db, schemaSql, options = {}) { if (db.isTransaction) throw new Error("SQLite STRICT schema migration cannot start inside a transaction"); const foreignKeysWereEnabled = readForeignKeysEnabled(db); if (foreignKeysWereEnabled) db.exec("PRAGMA foreign_keys = OFF;"); try { return runSqliteImmediateTransactionSync(db, () => migrateSqliteSchemaToStrictInTransaction(db, schemaSql, options), { busyTimeoutMs: options.busyTimeoutMs ?? DEFAULT_STRICT_MIGRATION_BUSY_TIMEOUT_MS, databaseLabel: options.databaseLabel, operationLabel: "sqlite.strict-schema-migration" }); } finally { if (foreignKeysWereEnabled) db.exec("PRAGMA foreign_keys = ON;"); } } //#endregion export { migrateSqliteSchemaToStrictInTransaction as n, migrateSqliteSchemaToStrict as t };