Files
suaveplan/packages/foundation/data/pwa/src/sync-queue/indexeddb-store.ts
2026-05-27 23:47:53 +01:00

107 lines
4.1 KiB
TypeScript

import {
DEFAULT_IDB_DATABASE,
DEFAULT_IDB_OBJECT_STORE,
} from "../constants/index";
import type { SyncQueueEntry } from "../types/common";
import type { SyncQueueStore } from "./sync-queue.types";
/** Options consumed by {@link IndexedDbStore}. */
export interface IndexedDbStoreOptions {
/** `IDBFactory` seam. Defaults to `globalThis.indexedDB`. */
factory?: IDBFactory;
/** Database name. Defaults to `__suaveplan_pwa__`. */
databaseName?: string;
/** Object-store name. Defaults to `sync_queue`. */
objectStoreName?: string;
}
/**
* IndexedDB-backed `SyncQueueStore`. Default production store: scales beyond
* the 5 MiB `localStorage` budget and lets large request bodies persist
* without blocking the main thread on JSON serialisation.
*/
export class IndexedDbStore implements SyncQueueStore {
private readonly _factory: IDBFactory;
private readonly _databaseName: string;
private readonly _objectStoreName: string;
public constructor(options: IndexedDbStoreOptions = {}) {
const candidate =
options.factory ??
(globalThis as { indexedDB?: IDBFactory }).indexedDB;
if (candidate === undefined) {
throw new Error(
"IndexedDbStore requires an IDBFactory; none was supplied and globalThis.indexedDB is undefined"
);
}
this._factory = candidate;
this._databaseName = options.databaseName ?? DEFAULT_IDB_DATABASE;
this._objectStoreName =
options.objectStoreName ?? DEFAULT_IDB_OBJECT_STORE;
}
public async load(): Promise<Record<string, unknown>[]> {
const db = await this._open();
try {
return await new Promise<Record<string, unknown>[]>(
(resolve, reject) => {
const tx = db.transaction(
this._objectStoreName,
"readonly"
);
const store = tx.objectStore(this._objectStoreName);
const req = store.getAll();
req.onsuccess = () => {
const result = req.result as Record<string, unknown>[];
resolve(result);
};
req.onerror = () =>
reject(req.error ?? new Error("idb getAll failed"));
}
);
} finally {
db.close();
}
}
public async save(entries: readonly SyncQueueEntry[]): Promise<void> {
const db = await this._open();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(this._objectStoreName, "readwrite");
const store = tx.objectStore(this._objectStoreName);
store.clear();
for (const entry of entries) {
store.put(entry);
}
tx.oncomplete = () => resolve();
tx.onerror = () =>
reject(tx.error ?? new Error("idb save failed"));
tx.onabort = () =>
reject(tx.error ?? new Error("idb save aborted"));
});
} finally {
db.close();
}
}
private async _open(): Promise<IDBDatabase> {
return await new Promise<IDBDatabase>((resolve, reject) => {
const req = this._factory.open(this._databaseName, 1);
req.onupgradeneeded = () => {
// `onupgradeneeded` only fires when the requested version is
// higher than the on-disk version; at v1 fresh installs there
// is no existing object store to clash with.
req.result.createObjectStore(this._objectStoreName, {
keyPath: "id",
});
};
req.onsuccess = () => resolve(req.result);
req.onerror = () =>
reject(req.error ?? new Error("idb open failed"));
req.onblocked = () =>
reject(new Error("idb open blocked by another connection"));
});
}
}