Matrix: harden IndexedDB snapshot persistence

This commit is contained in:
Gustavo Madeira Santana
2026-03-10 14:38:34 -04:00
parent 96c3fd2e75
commit 1dd61062d8
3 changed files with 270 additions and 3 deletions

View File

@@ -0,0 +1,173 @@
import "fake-indexeddb/auto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { persistIdbToDisk, restoreIdbFromDisk } from "./idb-persistence.js";
import { LogService } from "./logger.js";
async function clearAllIndexedDbState(): Promise<void> {
const databases = await indexedDB.databases();
await Promise.all(
databases
.map((entry) => entry.name)
.filter((name): name is string => Boolean(name))
.map(
(name) =>
new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(name);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => resolve();
}),
),
);
}
async function seedDatabase(params: {
name: string;
version?: number;
storeName: string;
records: Array<{ key: IDBValidKey; value: unknown }>;
}): Promise<void> {
await new Promise<void>((resolve, reject) => {
const req = indexedDB.open(params.name, params.version ?? 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(params.storeName)) {
db.createObjectStore(params.storeName);
}
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction(params.storeName, "readwrite");
const store = tx.objectStore(params.storeName);
for (const record of params.records) {
store.put(record.value, record.key);
}
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => reject(tx.error);
};
req.onerror = () => reject(req.error);
});
}
async function readDatabaseRecords(params: {
name: string;
version?: number;
storeName: string;
}): Promise<Array<{ key: IDBValidKey; value: unknown }>> {
return await new Promise((resolve, reject) => {
const req = indexedDB.open(params.name, params.version ?? 1);
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction(params.storeName, "readonly");
const store = tx.objectStore(params.storeName);
const keysReq = store.getAllKeys();
const valuesReq = store.getAll();
let keys: IDBValidKey[] | null = null;
let values: unknown[] | null = null;
const maybeResolve = () => {
if (!keys || !values) {
return;
}
db.close();
resolve(keys.map((key, index) => ({ key, value: values[index] })));
};
keysReq.onsuccess = () => {
keys = keysReq.result;
maybeResolve();
};
valuesReq.onsuccess = () => {
values = valuesReq.result;
maybeResolve();
};
keysReq.onerror = () => reject(keysReq.error);
valuesReq.onerror = () => reject(valuesReq.error);
};
req.onerror = () => reject(req.error);
});
}
describe("Matrix IndexedDB persistence", () => {
let tmpDir: string;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-idb-persist-"));
warnSpy = vi.spyOn(LogService, "warn").mockImplementation(() => {});
await clearAllIndexedDbState();
});
afterEach(async () => {
warnSpy.mockRestore();
await clearAllIndexedDbState();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("persists and restores database contents for the selected prefix", async () => {
const snapshotPath = path.join(tmpDir, "crypto-idb-snapshot.json");
await seedDatabase({
name: "openclaw-matrix-test::matrix-sdk-crypto",
storeName: "sessions",
records: [{ key: "room-1", value: { session: "abc123" } }],
});
await seedDatabase({
name: "other-prefix::matrix-sdk-crypto",
storeName: "sessions",
records: [{ key: "room-2", value: { session: "should-not-restore" } }],
});
await persistIdbToDisk({
snapshotPath,
databasePrefix: "openclaw-matrix-test",
});
expect(fs.existsSync(snapshotPath)).toBe(true);
const mode = fs.statSync(snapshotPath).mode & 0o777;
expect(mode).toBe(0o600);
await clearAllIndexedDbState();
const restored = await restoreIdbFromDisk(snapshotPath);
expect(restored).toBe(true);
const restoredRecords = await readDatabaseRecords({
name: "openclaw-matrix-test::matrix-sdk-crypto",
storeName: "sessions",
});
expect(restoredRecords).toEqual([{ key: "room-1", value: { session: "abc123" } }]);
const dbs = await indexedDB.databases();
expect(dbs.some((entry) => entry.name === "other-prefix::matrix-sdk-crypto")).toBe(false);
});
it("returns false and logs a warning for malformed snapshots", async () => {
const snapshotPath = path.join(tmpDir, "bad-snapshot.json");
fs.writeFileSync(snapshotPath, JSON.stringify([{ nope: true }]), "utf8");
const restored = await restoreIdbFromDisk(snapshotPath);
expect(restored).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
"IdbPersistence",
expect.stringContaining(`Failed to restore IndexedDB snapshot from ${snapshotPath}:`),
expect.any(Error),
);
});
it("returns false for empty snapshot payloads without restoring databases", async () => {
const snapshotPath = path.join(tmpDir, "empty-snapshot.json");
fs.writeFileSync(snapshotPath, JSON.stringify([]), "utf8");
const restored = await restoreIdbFromDisk(snapshotPath);
expect(restored).toBe(false);
const dbs = await indexedDB.databases();
expect(dbs).toEqual([]);
});
});

View File

@@ -17,6 +17,75 @@ type IdbDatabaseSnapshot = {
stores: IdbStoreSnapshot[];
};
function isValidIdbIndexSnapshot(value: unknown): value is IdbStoreSnapshot["indexes"][number] {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<IdbStoreSnapshot["indexes"][number]>;
return (
typeof candidate.name === "string" &&
(typeof candidate.keyPath === "string" ||
(Array.isArray(candidate.keyPath) &&
candidate.keyPath.every((entry) => typeof entry === "string"))) &&
typeof candidate.multiEntry === "boolean" &&
typeof candidate.unique === "boolean"
);
}
function isValidIdbRecordSnapshot(value: unknown): value is IdbStoreSnapshot["records"][number] {
if (!value || typeof value !== "object") {
return false;
}
return "key" in value && "value" in value;
}
function isValidIdbStoreSnapshot(value: unknown): value is IdbStoreSnapshot {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<IdbStoreSnapshot>;
const validKeyPath =
candidate.keyPath === null ||
typeof candidate.keyPath === "string" ||
(Array.isArray(candidate.keyPath) &&
candidate.keyPath.every((entry) => typeof entry === "string"));
return (
typeof candidate.name === "string" &&
validKeyPath &&
typeof candidate.autoIncrement === "boolean" &&
Array.isArray(candidate.indexes) &&
candidate.indexes.every((entry) => isValidIdbIndexSnapshot(entry)) &&
Array.isArray(candidate.records) &&
candidate.records.every((entry) => isValidIdbRecordSnapshot(entry))
);
}
function isValidIdbDatabaseSnapshot(value: unknown): value is IdbDatabaseSnapshot {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<IdbDatabaseSnapshot>;
return (
typeof candidate.name === "string" &&
typeof candidate.version === "number" &&
Number.isFinite(candidate.version) &&
candidate.version > 0 &&
Array.isArray(candidate.stores) &&
candidate.stores.every((entry) => isValidIdbStoreSnapshot(entry))
);
}
function parseSnapshotPayload(data: string): IdbDatabaseSnapshot[] | null {
const parsed = JSON.parse(data) as unknown;
if (!Array.isArray(parsed) || parsed.length === 0) {
return null;
}
if (!parsed.every((entry) => isValidIdbDatabaseSnapshot(entry))) {
throw new Error("Malformed IndexedDB snapshot payload");
}
return parsed;
}
function idbReq<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
@@ -132,8 +201,8 @@ export async function restoreIdbFromDisk(snapshotPath?: string): Promise<boolean
for (const resolvedPath of candidatePaths) {
try {
const data = fs.readFileSync(resolvedPath, "utf8");
const snapshot: IdbDatabaseSnapshot[] = JSON.parse(data);
if (!Array.isArray(snapshot) || snapshot.length === 0) {
const snapshot = parseSnapshotPayload(data);
if (!snapshot) {
continue;
}
await restoreIndexedDatabases(snapshot);
@@ -142,7 +211,12 @@ export async function restoreIdbFromDisk(snapshotPath?: string): Promise<boolean
`Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`,
);
return true;
} catch {
} catch (err) {
LogService.warn(
"IdbPersistence",
`Failed to restore IndexedDB snapshot from ${resolvedPath}:`,
err,
);
continue;
}
}
@@ -159,6 +233,7 @@ export async function persistIdbToDisk(params?: {
if (snapshot.length === 0) return;
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot));
fs.chmodSync(snapshotPath, 0o600);
LogService.debug(
"IdbPersistence",
`Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`,