Files
Will Hunt 25e92009b7 Throw more helpful errors from indexedb-local-backend (#5282)
* Throw more helpful errors from indexedb-local-backend

* Add a test

* Do not tempt fate
2026-04-17 08:33:56 +00:00

379 lines
13 KiB
TypeScript

/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* @vitest-environment happy-dom
*/
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { IndexedDBStore, type IStateEventWithRoomId, MemoryStore, User, UserEvent } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { type LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
describe("IndexedDBStore", () => {
afterEach(() => {
vi.clearAllMocks();
});
const roomId = "!room:id";
it("should degrade to MemoryStore on IDB errors", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();
const member1: IStateEventWithRoomId = {
room_id: roomId,
event_id: "!ev1:id",
sender: "@user1:id",
state_key: "@user1:id",
type: "m.room.member",
origin_server_ts: 123,
content: {},
};
const member2: IStateEventWithRoomId = {
room_id: roomId,
event_id: "!ev2:id",
sender: "@user2:id",
state_key: "@user2:id",
type: "m.room.member",
origin_server_ts: 123,
content: {},
};
expect(await store.getOutOfBandMembers(roomId)).toBe(null);
await store.setOutOfBandMembers(roomId, [member1]);
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(1);
// Simulate a broken IDB
(store.backend as LocalIndexedDBStoreBackend)["db"]!.transaction = (): IDBTransaction => {
const err = new Error(
"Failed to execute 'transaction' on 'IDBDatabase': " + "The database connection is closing.",
);
err.name = "InvalidStateError";
throw err;
};
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(1);
await Promise.all([
emitPromise(store["emitter"], "degraded"),
store.setOutOfBandMembers(roomId, [member1, member2]),
]);
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2);
});
it("should handle failed queries", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();
// Simulate a failed query
let txn: IDBRequest;
(store.backend as LocalIndexedDBStoreBackend)["db"]!.transaction = (): IDBTransaction => {
return {
objectStore: (name: string) =>
({
name,
openCursor: (query: unknown) => {
return (txn = {
error: new DOMException("Expected error"),
} as IDBRequest);
},
}) as IDBObjectStore,
} as IDBTransaction;
};
// Call backend directly as otherwise the error is masked.
const promise = store.backend.getClientOptions();
// The function uses a Promise.then(() => trick to delay execution
// so we need to wait before we can call the txn onerror handler.
process.nextTick(() => {
txn!.onerror!(new Event("we-ignore-this"));
});
await expect(() => promise).rejects.toThrow("selectQuery failed for client_options");
});
it("Should load presence events on startup", async () => {
// 1. Create idb database
const indexedDB = new IDBFactory();
const setupResolvers = Promise.withResolvers<Event>();
const req = indexedDB.open("matrix-js-sdk:db3", 1);
let db: IDBDatabase;
req.onupgradeneeded = () => {
db = req.result;
db.createObjectStore("users", { keyPath: ["userId"] });
db.createObjectStore("accountData", { keyPath: ["type"] });
db.createObjectStore("sync", { keyPath: ["clobber"] });
};
req.onsuccess = setupResolvers.resolve;
await setupResolvers.promise;
// 2. Fill in user presence data
const writeResolvers = Promise.withResolvers<Event>();
const transaction = db!.transaction(["users"], "readwrite");
const objectStore = transaction.objectStore("users");
const request = objectStore.put({
userId: "@alice:matrix.org",
event: {
content: {
presence: "online",
},
sender: "@alice:matrix.org",
type: "m.presence",
},
});
request.onsuccess = writeResolvers.resolve;
await writeResolvers.promise;
// 3. Close database
req.result.close();
// 2. Check if the code loads presence events
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "db3",
localStorage,
});
let userCreated = false;
let presenceEventEmitted = false;
store.setUserCreator((id: string) => {
userCreated = true;
const user = new User(id);
user.on(UserEvent.Presence, () => {
presenceEventEmitted = true;
});
return user;
});
await store.startup();
expect(userCreated).toBe(true);
expect(presenceEventEmitted).toBe(true);
});
it("should use MemoryStore methods for pending events if no localStorage", async () => {
vi.spyOn(MemoryStore.prototype, "setPendingEvents");
vi.spyOn(MemoryStore.prototype, "getPendingEvents");
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage: undefined,
});
const events = [{ type: "test" }];
await store.setPendingEvents(roomId, events);
expect(MemoryStore.prototype.setPendingEvents).toHaveBeenCalledWith(roomId, events);
await expect(store.getPendingEvents(roomId)).resolves.toEqual(events);
expect(MemoryStore.prototype.getPendingEvents).toHaveBeenCalledWith(roomId);
});
it("should persist pending events to localStorage if available", async () => {
vi.spyOn(MemoryStore.prototype, "setPendingEvents");
vi.spyOn(MemoryStore.prototype, "getPendingEvents");
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await expect(store.getPendingEvents(roomId)).resolves.toEqual([]);
const events = [{ type: "test" }];
await store.setPendingEvents(roomId, events);
expect(MemoryStore.prototype.setPendingEvents).not.toHaveBeenCalled();
await expect(store.getPendingEvents(roomId)).resolves.toEqual(events);
expect(MemoryStore.prototype.getPendingEvents).not.toHaveBeenCalled();
expect(localStorage.getItem("mx_pending_events_" + roomId)).toBe(JSON.stringify(events));
await store.setPendingEvents(roomId, []);
expect(localStorage.getItem("mx_pending_events_" + roomId)).toBeNull();
});
it("should resolve isNewlyCreated to true if no database existed initially", async () => {
const store = new IndexedDBStore({
indexedDB,
dbName: "db1",
localStorage,
});
await store.startup();
await expect(store.isNewlyCreated()).resolves.toBeTruthy();
});
it("should resolve isNewlyCreated to false if database existed already", async () => {
let store = new IndexedDBStore({
indexedDB,
dbName: "db2",
localStorage,
});
await store.startup();
store = new IndexedDBStore({
indexedDB,
dbName: "db2",
localStorage,
});
await store.startup();
await expect(store.isNewlyCreated()).resolves.toBeFalsy();
});
it("should resolve isNewlyCreated to false if database existed already but needs upgrade", async () => {
const requestSuccessResolvers = Promise.withResolvers<Event>();
// seed db3 to Version 1 so it forces a migration
const req = indexedDB.open("matrix-js-sdk:db3", 1);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore("users", { keyPath: ["userId"] });
db.createObjectStore("accountData", { keyPath: ["type"] });
db.createObjectStore("sync", { keyPath: ["clobber"] });
};
req.onsuccess = requestSuccessResolvers.resolve;
await requestSuccessResolvers.promise;
req.result.close();
const store = new IndexedDBStore({
indexedDB,
dbName: "db3",
localStorage,
});
await store.startup();
await expect(store.isNewlyCreated()).resolves.toBeFalsy();
});
it("should emit 'closed' if database is unexpectedly closed", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();
const storeClosedResolvers = Promise.withResolvers<void>();
store.on("closed", storeClosedResolvers.resolve);
// @ts-ignore - private field access
(store.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
await expect(storeClosedResolvers.promise).resolves.toBeUndefined();
});
it("should use remote backend if workerFactory passed", async () => {
const workerPostMessageResolvers = Promise.withResolvers<void>();
class MockWorker {
postMessage(data: any) {
if (data.command === "setupWorker") {
workerPostMessageResolvers.resolve();
}
}
}
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => new MockWorker() as Worker,
});
store.startup();
await expect(workerPostMessageResolvers.promise).resolves.toBeUndefined();
});
it("remote worker should pass closed event", async () => {
const worker = new (class MockWorker {
postMessage(data: any) {}
})() as Worker;
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
store.startup();
const storeClosedResolvers = Promise.withResolvers<void>();
store.on("closed", storeClosedResolvers.resolve);
(worker as any).onmessage({ data: { command: "closed" } });
await expect(storeClosedResolvers.promise).resolves.toBeUndefined();
});
it("remote worker should pass command failures", async () => {
const worker = new (class MockWorker {
private onmessage!: (data: any) => void;
postMessage(data: any) {
if (data.command === "setupWorker" || data.command === "connect") {
this.onmessage({
data: {
command: "cmd_success",
seq: data.seq,
},
});
return;
}
this.onmessage({
data: {
command: "cmd_fail",
seq: data.seq,
error: new Error("Test"),
},
});
}
})() as unknown as Worker;
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
await expect(store.startup()).rejects.toThrow("Test");
});
it("remote worker should terminate upon destroy call", async () => {
const terminate = vi.fn();
const worker = new (class MockWorker {
private onmessage!: (data: any) => void;
postMessage(data: any) {
this.onmessage({
data: {
command: "cmd_success",
seq: data.seq,
result: [],
},
});
}
public terminate = terminate;
})() as unknown as Worker;
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
await store.startup();
await store.destroy();
expect(terminate).toHaveBeenCalled();
});
});