Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/data-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,13 @@ component / composable
`getLoaded(key)` reads the map synchronously and throws if the store was
never preloaded; the planning engine and `PriceBook` rely on it. After
writing to IndexedDB, call `preload(true)` or synchronous readers won't see
the new rows (the `Get*` queries do this).
the new rows (the `Get*` queries do this through `storeAndPreload`).
`fill(rows, replace)` loads rows into memory without the DB; `storeAndPreload`
falls back to it when IndexedDB fails (connection closed, quota), so a
broken cache never fails a query. `getDB` reopens after the browser closes
the connection; on an `InvalidStateError` or `UnknownError` (a connection
lost without a `close` event) `storeAndPreload` calls `dropDB` so the next
`getDB` reopens it.
- **`services/`** is the API consumers should use:

| Service | Provides |
Expand Down
18 changes: 14 additions & 4 deletions src/database/composables/useDB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,25 @@ export function useDB<T extends object, K extends keyof T & string>(
// skip if already loaded
if (!force && state.loaded) return;

const all = await store.getAll();
fill(await store.getAll(), true);
}

state.allData.value = all;
state.cache.clear();
/**
* Loads rows into the in-memory layer without IndexedDB, for when the
* database can't be written or read.
*
* @param {T[]} rows Rows to load
* @param {boolean} replace Drop the rows already loaded, else upsert
*/
function fill(rows: T[], replace: boolean) {
if (replace) state.cache.clear();

for (const item of all) {
for (const item of rows) {
// @ts-expect-error keyPath dynamically
state.cache.set(item[store.keyPath], item);
}

state.allData.value = replace ? rows : [...state.cache.values()];
state.loaded = true;
}

Expand Down Expand Up @@ -89,6 +98,7 @@ export function useDB<T extends object, K extends keyof T & string>(
allData: state.allData,
cacheData: state.cache,
preload,
fill,
get,
getLoaded,
};
Expand Down
37 changes: 30 additions & 7 deletions src/database/composables/useIndexedDBStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,16 @@ export async function getDB() {
}
}
},
// the browser closed the connection (site data cleared, eviction):
// the next call reopens it
terminated() {
dbPromise = null;
},
}
);
).catch((err: unknown) => {
dbPromise = null;
throw err;
});
}
// Request persistence after DB is ready
try {
Expand All @@ -52,6 +60,19 @@ export async function getDB() {
return dbPromise;
}

/**
* Drops the cached connection so the next `getDB` reopens it, for a
* connection that died without a `close` event.
*/
export function dropDB(): void {
dbPromise
?.then((db) => db.close())
.catch(() => {
// already closed or never opened
});
dbPromise = null;
}

/**
* Testing / dev utility: completely resets the DB and clears cached dbPromise.
*/
Expand Down Expand Up @@ -100,12 +121,14 @@ export function useIndexedDBStore<T extends object, K extends keyof T & string>(
const tx = db.transaction(storeName, "readwrite");
const store = tx.objectStore(storeName);

// if set, clears existing store data
if (wipe) await store.clear();

items.forEach((i) => store.put(i));

await tx.done;
// requests run in order, so the clear (if set) lands before the puts.
// Await every request: an aborted transaction (e.g. quota) rejects
// them all, and unawaited ones become unhandled rejections
await Promise.all([
...(wipe ? [store.clear()] : []),
...items.map((i) => store.put(i)),
tx.done,
]);
}

async function remove<K extends keyof T>(
Expand Down
29 changes: 25 additions & 4 deletions src/lib/query_cache/queries/queries.util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useDB } from "@/database/composables/useDB";
import type { useIndexedDBStore } from "@/database/composables/useIndexedDBStore";
import {
dropDB,
type useIndexedDBStore,
} from "@/database/composables/useIndexedDBStore";
import { useQueryStore } from "@/lib/query_cache/queryStore";

// Types & Interfaces
Expand Down Expand Up @@ -34,7 +37,9 @@ export function staleMinutes(minutes: number): number {

/**
* Writes game data into IndexedDB and reloads the in-memory layer, so
* synchronous readers (`getLoaded`) see the new rows.
* synchronous readers (`getLoaded`) see the new rows. If IndexedDB fails
* (connection closed, quota exceeded), the rows go into the in-memory layer
* only, so the query still succeeds with the API data.
*
* @param store IndexedDB store
* @param {T[]} data Rows to write
Expand All @@ -48,8 +53,24 @@ export async function storeAndPreload<
data: T[],
wipe: boolean = false
): Promise<void> {
await store.setMany(data, wipe);
await useDB(store).preload(true);
const db = useDB(store);
try {
await store.setMany(data, wipe);
await db.preload(true);
} catch (err) {
console.warn(
`IndexedDB cache for '${store.storeName}' failed, keeping data in memory.`,
err
);
// a dead connection that never fired `close` (WebKit reports a lost
// connection as UnknownError): reopen on the next call
if (
err instanceof DOMException &&
(err.name === "InvalidStateError" || err.name === "UnknownError")
)
dropDB();
db.fill(data, wipe);
}
}

/**
Expand Down
22 changes: 22 additions & 0 deletions src/tests/database/composables/useDB.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,26 @@ describe("useDB composable", () => {
expect(getLoaded("1")).toEqual({ id: "1", name: "Alpha" });
expect(getLoaded("2")).toBeUndefined();
});

it("fill() replaces or upserts without touching the store", () => {
const { fill, getLoaded, allData } = useDB<TestItem, "id">(
fakeStore as any
);

fill([{ id: "1", name: "Alpha" }], true);
fill(
[
{ id: "1", name: "Alpha 2" },
{ id: "2", name: "Beta" },
],
false
);
expect(getLoaded("1")).toEqual({ id: "1", name: "Alpha 2" });
expect(allData.value).toHaveLength(2);

fill([{ id: "3", name: "Gamma" }], true);
expect(getLoaded("1")).toBeUndefined();
expect(allData.value).toEqual([{ id: "3", name: "Gamma" }]);
expect(fakeStore.getAll).not.toHaveBeenCalled();
});
});
65 changes: 64 additions & 1 deletion src/tests/database/composables/useIndexedDBStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { openDB } from "idb";
import { openDB, unwrap } from "idb";
import { forceCloseDatabase } from "fake-indexeddb";

import config from "@/lib/config";
import {
dropDB,
getDB,
resetDB,
useIndexedDBStore,
Expand Down Expand Up @@ -68,6 +70,31 @@ describe("useIndexedDBStore", () => {
expect(db1).toBe(db2);
});

it("getDB reopens after the browser closes the connection", async () => {
const db1 = await getDB();
forceCloseDatabase(unwrap(db1));

const db2 = await getDB();

expect(db2).not.toBe(db1);
await store.set(fakeMaterial_1);
expect(await store.get("m1")).toEqual(fakeMaterial_1);
});

it("getDB retries after a failed open", async () => {
// a newer version on disk makes the open fail with a VersionError
const newer = await openDB(
config.INDEXEDDB_DBNAME,
Number(__INDEXEDDB_VERSION__) + 1
);
newer.close();
await expect(getDB()).rejects.toThrow();

await resetDB();

expect((await getDB()).name).toBe(config.INDEXEDDB_DBNAME);
});

describe("IndexedDB upgrade", () => {
beforeEach(async () => {
await resetDB();
Expand Down Expand Up @@ -155,6 +182,42 @@ describe("useIndexedDBStore", () => {
expect(result).toHaveLength(2);
});

it("setMany rejects once, without unhandled rejections, when the transaction aborts", async () => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
// survives only if the clear is rolled back with the failed puts
await store.set(fakeMaterial_2);
process.on("unhandledRejection", onUnhandled);

// the duplicate material_id breaks the unique index and aborts the tx
await expect(
store.setMany(
[
fakeMaterial_1,
{ ...fakeMaterial_2, material_id: "foo" },
{ ...fakeMaterial_2, ticker: "m3", material_id: "m3" },
],
true
)
).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 10));
process.off("unhandledRejection", onUnhandled);

expect(unhandled).toEqual([]);
expect(await store.getAll()).toEqual([fakeMaterial_2]);
});

it("dropDB makes the next getDB reopen", async () => {
const db1 = await getDB();

dropDB();
const db2 = await getDB();

expect(db2).not.toBe(db1);
await store.set(fakeMaterial_1);
expect(await store.get("m1")).toEqual(fakeMaterial_1);
});

it("should remove an item", async () => {
const materials: Material[] = [fakeMaterial_1, fakeMaterial_2];
await store.setMany(materials, true);
Expand Down
104 changes: 104 additions & 0 deletions src/tests/lib/query_cache/queries/queries.util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { useDB } from "@/database/composables/useDB";
import {
getDB,
resetDB,
useIndexedDBStore,
} from "@/database/composables/useIndexedDBStore";
import { storeAndPreload } from "@/lib/query_cache/queries/queries.util";

import type { Material } from "@/features/api/schemas/gameData.schemas";

function material(ticker: string): Material {
return {
material_id: ticker,
category_name: "c",
category_id: "c",
name: ticker,
ticker,
weight: 1,
volume: 1,
};
}

describe("storeAndPreload", () => {
// a fresh handle per test, so the shared in-memory state starts empty
let store: ReturnType<typeof useIndexedDBStore<Material, "ticker">>;

beforeEach(async () => {
await resetDB();
store = useIndexedDBStore<Material, "ticker">(
"gamedata_materials",
"ticker"
);
vi.spyOn(console, "warn").mockImplementation(() => {});
});

it("writes to IndexedDB and preloads", async () => {
await storeAndPreload(store, [material("A")], true);

expect(await store.getAll()).toEqual([material("A")]);
expect(useDB(store).getLoaded("A")).toEqual(material("A"));
expect(console.warn).not.toHaveBeenCalled();
});

it("keeps the data in memory when the write fails", async () => {
vi.spyOn(store, "setMany").mockRejectedValue(
new DOMException("quota", "QuotaExceededError")
);

await expect(
storeAndPreload(store, [material("A")], true)
).resolves.toBeUndefined();

expect(useDB(store).getLoaded("A")).toEqual(material("A"));
expect(console.warn).toHaveBeenCalledOnce();
});

it("keeps the data in memory when the read-back fails", async () => {
vi.spyOn(store, "getAll").mockRejectedValue(
new DOMException("closing", "InvalidStateError")
);

await storeAndPreload(store, [material("A")], true);

expect(useDB(store).getLoaded("A")).toEqual(material("A"));
});

it("reopens the connection after InvalidStateError or UnknownError only", async () => {
const db1 = await getDB();
vi.spyOn(store, "setMany").mockRejectedValueOnce(
new DOMException("quota", "QuotaExceededError")
);
await storeAndPreload(store, [material("A")], true);
expect(await getDB()).toBe(db1);

vi.spyOn(store, "setMany").mockRejectedValueOnce(
new DOMException("closing", "InvalidStateError")
);
await storeAndPreload(store, [material("A")], true);
expect(await getDB()).not.toBe(db1);

const db2 = await getDB();
vi.spyOn(store, "setMany").mockRejectedValueOnce(
new DOMException("lost", "UnknownError")
);
await storeAndPreload(store, [material("A")], true);
expect(await getDB()).not.toBe(db2);
});

it("upserts into the loaded rows without wipe", async () => {
await storeAndPreload(store, [material("A")], true);
vi.spyOn(store, "setMany").mockRejectedValue(
new DOMException("closing", "InvalidStateError")
);

await storeAndPreload(store, [material("B")]);

const db = useDB(store);
expect(db.getLoaded("A")).toEqual(material("A"));
expect(db.getLoaded("B")).toEqual(material("B"));
expect(db.allData.value).toHaveLength(2);
});
});
Loading