152 lines
4.5 KiB
JavaScript
152 lines
4.5 KiB
JavaScript
// Lokaler Speicher auf Basis von IndexedDB.
|
||
//
|
||
// Warum nicht localStorage: Der ist synchron (blockiert die Oberfläche),
|
||
// auf wenige Megabyte begrenzt und kennt keine Transaktionen. Für eine
|
||
// Warteschlange, die auch einen Absturz überleben muss, ist er ungeeignet.
|
||
"use strict";
|
||
|
||
const DB_NAME = "einkaufsapp";
|
||
const DB_VERSION = 1;
|
||
|
||
const STORE_CACHE = "cache"; // Schlüssel -> zuletzt gesehener Serverstand
|
||
const STORE_OUTBOX = "outbox"; // noch nicht bestätigte Operationen
|
||
|
||
let dbPromise = null;
|
||
|
||
function open() {
|
||
if (dbPromise) return dbPromise;
|
||
|
||
dbPromise = new Promise((resolve, reject) => {
|
||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||
|
||
request.onupgradeneeded = () => {
|
||
const db = request.result;
|
||
if (!db.objectStoreNames.contains(STORE_CACHE)) {
|
||
db.createObjectStore(STORE_CACHE);
|
||
}
|
||
if (!db.objectStoreNames.contains(STORE_OUTBOX)) {
|
||
const store = db.createObjectStore(STORE_OUTBOX, { keyPath: "op_id" });
|
||
// Reihenfolge je Liste: Operationen müssen in der Folge ankommen,
|
||
// in der sie entstanden sind.
|
||
store.createIndex("by_list_seq", ["list_id", "seq"]);
|
||
}
|
||
};
|
||
|
||
request.onsuccess = () => resolve(request.result);
|
||
request.onerror = () => reject(request.error);
|
||
request.onblocked = () =>
|
||
reject(new Error("Datenbank blockiert – bitte andere Tabs schließen."));
|
||
});
|
||
|
||
return dbPromise;
|
||
}
|
||
|
||
function run(storeName, mode, fn) {
|
||
return open().then((db) => new Promise((resolve, reject) => {
|
||
const tx = db.transaction(storeName, mode);
|
||
const store = tx.objectStore(storeName);
|
||
let result;
|
||
try {
|
||
result = fn(store);
|
||
} catch (err) {
|
||
reject(err);
|
||
return;
|
||
}
|
||
tx.oncomplete = () => resolve(result && result.__req ? result.__req.result : result);
|
||
tx.onerror = () => reject(tx.error);
|
||
tx.onabort = () => reject(tx.error || new Error("Transaktion abgebrochen"));
|
||
}));
|
||
}
|
||
|
||
function wrap(request) {
|
||
return { __req: request };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Zwischenspeicher
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function cacheGet(key) {
|
||
return run(STORE_CACHE, "readonly", (store) => wrap(store.get(key)));
|
||
}
|
||
|
||
export function cacheSet(key, value) {
|
||
return run(STORE_CACHE, "readwrite", (store) => wrap(store.put(value, key)));
|
||
}
|
||
|
||
export function cacheDelete(key) {
|
||
return run(STORE_CACHE, "readwrite", (store) => wrap(store.delete(key)));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Outbox
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Zufällige, praktisch eindeutige Kennung. crypto.randomUUID gibt es
|
||
* nur in sicheren Kontexten (HTTPS oder localhost) - deshalb der
|
||
* Rückfallweg. */
|
||
export function newOpId() {
|
||
if (globalThis.crypto?.randomUUID) return crypto.randomUUID();
|
||
const bytes = new Uint8Array(16);
|
||
(globalThis.crypto || {}).getRandomValues?.(bytes);
|
||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
|
||
|| `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
}
|
||
|
||
let seqCounter = Date.now();
|
||
|
||
export function enqueue(listId, kind, payload) {
|
||
const op = {
|
||
op_id: newOpId(),
|
||
list_id: listId,
|
||
kind,
|
||
payload,
|
||
seq: seqCounter++,
|
||
created_at: new Date().toISOString(),
|
||
attempts: 0,
|
||
};
|
||
return run(STORE_OUTBOX, "readwrite", (store) => {
|
||
store.add(op);
|
||
return op;
|
||
});
|
||
}
|
||
|
||
export function pending(listId) {
|
||
return run(STORE_OUTBOX, "readonly", (store) => {
|
||
const index = store.index("by_list_seq");
|
||
const range = IDBKeyRange.bound([listId, -Infinity], [listId, Infinity]);
|
||
return wrap(index.getAll(range));
|
||
});
|
||
}
|
||
|
||
export function pendingAll() {
|
||
return run(STORE_OUTBOX, "readonly", (store) => wrap(store.getAll()));
|
||
}
|
||
|
||
export function remove(opIds) {
|
||
return run(STORE_OUTBOX, "readwrite", (store) => {
|
||
for (const id of opIds) store.delete(id);
|
||
});
|
||
}
|
||
|
||
export function bumpAttempts(opIds) {
|
||
return run(STORE_OUTBOX, "readwrite", (store) => {
|
||
for (const id of opIds) {
|
||
const request = store.get(id);
|
||
request.onsuccess = () => {
|
||
const op = request.result;
|
||
if (op) {
|
||
op.attempts = (op.attempts || 0) + 1;
|
||
op.last_error_at = new Date().toISOString();
|
||
store.put(op);
|
||
}
|
||
};
|
||
}
|
||
});
|
||
}
|
||
|
||
export async function clearList(listId) {
|
||
const ops = await pending(listId);
|
||
await remove(ops.map((o) => o.op_id));
|
||
}
|