107 lines
4.1 KiB
TypeScript
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"));
|
|
});
|
|
}
|
|
}
|