Files
einkaufsapp/web/html/js/db.js
2026-08-08 20:31:58 +02:00

152 lines
4.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
}