diff --git a/extensions/matrix/src/matrix/sdk/crypto-runtime.ts b/extensions/matrix/src/matrix/sdk/crypto-runtime.ts index 0fbefd95cf9b..f2be358b735b 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-runtime.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-runtime.ts @@ -1,5 +1,8 @@ // Matrix plugin module implements crypto runtime behavior. import "fake-indexeddb/auto"; +import { installFakeIndexedDbTransactionPruner } from "./fake-indexeddb-prune.js"; + +installFakeIndexedDbTransactionPruner(); export { MatrixCryptoBootstrapper } from "./crypto-bootstrap.js"; export type { MatrixCryptoBootstrapResult } from "./crypto-bootstrap.js"; diff --git a/extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.test.ts b/extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.test.ts new file mode 100644 index 000000000000..8b78fb606fb7 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.test.ts @@ -0,0 +1,126 @@ +// Matrix tests cover fake-indexeddb transaction pruning for crypto stores. +import "fake-indexeddb/auto"; +import { afterEach, describe, expect, it } from "vitest"; +import { + installFakeIndexedDbTransactionPruner, + pruneFinishedFakeIndexedDbTransactions, +} from "./fake-indexeddb-prune.js"; + +function openDatabase(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.addEventListener("upgradeneeded", () => { + request.result.createObjectStore("sessions", { keyPath: "key" }); + }); + request.addEventListener("success", () => resolve(request.result), { once: true }); + request.addEventListener( + "error", + () => reject(request.error ?? new Error("database open failed")), + { + once: true, + }, + ); + }); +} + +function deleteDatabase(name: string): Promise { + return new Promise((resolve) => { + const request = indexedDB.deleteDatabase(name); + request.addEventListener("success", () => resolve(), { once: true }); + request.addEventListener("error", () => resolve(), { once: true }); + request.addEventListener("blocked", () => resolve(), { once: true }); + }); +} + +function putRecord(db: IDBDatabase, key: string): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction("sessions", "readwrite"); + transaction.objectStore("sessions").put({ key, value: "payload" }); + transaction.addEventListener("complete", () => resolve(), { once: true }); + transaction.addEventListener( + "error", + () => reject(transaction.error ?? new Error("transaction failed")), + { + once: true, + }, + ); + transaction.addEventListener( + "abort", + () => reject(transaction.error ?? new Error("transaction aborted")), + { + once: true, + }, + ); + }); +} + +function rawTransactions(db: IDBDatabase): Array<{ _state?: string }> { + return ( + (db as unknown as { _rawDatabase?: { transactions?: Array<{ _state?: string }> } })[ + "_rawDatabase" + ]?.transactions ?? [] + ); +} + +describe("Matrix fake-indexeddb transaction pruning", () => { + const databaseNames = new Set(); + + afterEach(async () => { + for (const name of databaseNames) { + await deleteDatabase(name); + } + databaseNames.clear(); + }); + + it("prunes finished transactions for Matrix crypto databases", async () => { + installFakeIndexedDbTransactionPruner(); + const databaseName = `openclaw-matrix-prune-test-${Date.now()}::matrix-sdk-crypto`; + databaseNames.add(databaseName); + const db = await openDatabase(databaseName); + + for (let i = 0; i < 5; i += 1) { + await putRecord(db, `key-${i}`); + } + + expect(rawTransactions(db)).toHaveLength(0); + db.close(); + }); + + it("prunes finished transactions for Matrix crypto metadata databases", async () => { + installFakeIndexedDbTransactionPruner(); + const databaseName = `openclaw-matrix-meta-prune-test-${Date.now()}::matrix-sdk-crypto-meta`; + databaseNames.add(databaseName); + const db = await openDatabase(databaseName); + + for (let i = 0; i < 5; i += 1) { + await putRecord(db, `key-${i}`); + } + + expect(rawTransactions(db)).toHaveLength(0); + db.close(); + }); + + it("does not prune unrelated fake-indexeddb databases", async () => { + installFakeIndexedDbTransactionPruner(); + const databaseName = `openclaw-matrix-unrelated-prune-test-${Date.now()}`; + databaseNames.add(databaseName); + const db = await openDatabase(databaseName); + + await putRecord(db, "key-1"); + + expect( + rawTransactions(db).filter((transaction) => transaction["_state"] === "finished").length, + ).toBeGreaterThan(0); + db.close(); + }); + + it("keeps active transactions when pruning the raw transaction queue", () => { + const rawDatabase = { + name: "openclaw-matrix-direct-prune-test::matrix-sdk-crypto", + transactions: [{ _state: "finished" }, { _state: "active" }, { _state: "inactive" }], + }; + + expect(pruneFinishedFakeIndexedDbTransactions(rawDatabase)).toBe(1); + expect(rawDatabase.transactions).toEqual([{ _state: "active" }, { _state: "inactive" }]); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.ts b/extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.ts new file mode 100644 index 000000000000..55b4b0aae5b1 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/fake-indexeddb-prune.ts @@ -0,0 +1,119 @@ +// Matrix SDK helper mitigates fake-indexeddb finished-transaction retention. +const MATRIX_CRYPTO_DATABASE_SUFFIXES = [ + "::matrix-sdk-crypto", + "::matrix-sdk-crypto-meta", +] as const; +const PRUNER_INSTALLED = Symbol.for("openclaw.matrix.fakeIndexedDbTransactionPruner"); + +type FakeIndexedDbTransaction = { + _state?: string; + addEventListener?: (type: "complete" | "abort", listener: () => void) => void; + db?: FakeIndexedDbDatabaseConnection; +}; + +type FakeIndexedDbRawDatabase = { + name?: string; + transactions?: FakeIndexedDbTransaction[]; +}; + +type FakeIndexedDbDatabaseConnection = { + _rawDatabase?: FakeIndexedDbRawDatabase; +}; + +type FakeIndexedDbDatabasePrototype = FakeIndexedDbDatabaseConnection & { + transaction?: IDBDatabase["transaction"]; + [PRUNER_INSTALLED]?: true; +}; + +type GlobalWithFakeIndexedDb = typeof globalThis & { + IDBDatabase?: { + prototype?: FakeIndexedDbDatabasePrototype; + }; +}; + +function getRawDatabase(value: unknown): FakeIndexedDbRawDatabase | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const rawDatabase = (value as FakeIndexedDbDatabaseConnection)["_rawDatabase"]; + if (!rawDatabase || typeof rawDatabase !== "object") { + return undefined; + } + return rawDatabase; +} + +function isMatrixCryptoDatabase( + rawDatabase: FakeIndexedDbRawDatabase | undefined, +): rawDatabase is FakeIndexedDbRawDatabase & { + transactions: FakeIndexedDbTransaction[]; +} { + if (!rawDatabase || !Array.isArray(rawDatabase.transactions)) { + return false; + } + const databaseName = rawDatabase.name; + return ( + typeof databaseName === "string" && + MATRIX_CRYPTO_DATABASE_SUFFIXES.some((suffix) => databaseName.endsWith(suffix)) + ); +} + +export function pruneFinishedFakeIndexedDbTransactions(rawDatabase: unknown): number { + const matrixRawDatabase = rawDatabase as FakeIndexedDbRawDatabase | undefined; + if (!isMatrixCryptoDatabase(matrixRawDatabase)) { + return 0; + } + + const transactions = matrixRawDatabase.transactions; + const activeTransactions = transactions.filter( + (transaction) => transaction?.["_state"] !== "finished", + ); + const removed = transactions.length - activeTransactions.length; + if (removed > 0) { + transactions.splice(0, transactions.length, ...activeTransactions); + } + return removed; +} + +export function installFakeIndexedDbTransactionPruner(): void { + const globalObject = globalThis as GlobalWithFakeIndexedDb; + const databasePrototype = globalObject.IDBDatabase?.prototype; + const originalTransaction = databasePrototype?.transaction; + if ( + !databasePrototype || + typeof originalTransaction !== "function" || + databasePrototype[PRUNER_INSTALLED] + ) { + return; + } + + Object.defineProperty(databasePrototype, PRUNER_INSTALLED, { + configurable: false, + enumerable: false, + value: true, + }); + + const patchedTransaction = function patchedMatrixFakeIndexedDbTransaction( + this: IDBDatabase & FakeIndexedDbDatabaseConnection, + ...args: Parameters + ): ReturnType { + pruneFinishedFakeIndexedDbTransactions(getRawDatabase(this)); + + const transaction = originalTransaction.apply(this, args) as IDBTransaction & + FakeIndexedDbTransaction; + const rawDatabase = getRawDatabase(transaction?.db) ?? getRawDatabase(this); + if ( + isMatrixCryptoDatabase(rawDatabase) && + typeof transaction?.addEventListener === "function" + ) { + const prune = (): void => { + pruneFinishedFakeIndexedDbTransactions(rawDatabase); + }; + transaction.addEventListener("complete", prune); + transaction.addEventListener("abort", prune); + } + + return transaction; + } as IDBDatabase["transaction"]; + + databasePrototype.transaction = patchedTransaction; +}