baustelle-pwa/lib/idb.js
Eddy 30202f8f28 Keine Doppel-Uploads mehr, App-Update laedt nicht mitten in der Arbeit neu
Doppel-Uploads (Prod-Log 17.09.: reihenweise duplicate:true): Seite
(syncQueue) und Service Worker (Background Sync) arbeiteten dieselbe
IndexedDB-Queue ab, ohne voneinander zu wissen. Dank md5-Abgleich
folgenlos, aber doppeltes Datenvolumen. Mit eingestelltem Verkleinern
sogar zwei verschiedene Dateien im Auftrag: der Worker lud das
Original, die Seite danach die kleine Fassung.

- idb.js: queueKeys/queuePatch/queueClaim/queueRelease - Lesen-Aendern-
  Schreiben in einer readwrite-Transaktion, ueber Kontexte atomar
- syncQueue (Seite) und drainQueue (Worker) belegen jedes Foto vor dem
  Upload (Lease 90 s > laengster Upload-Timeout); belegt = ueberspringen,
  abgelaufen = uebernehmen. Nur noch ein Foto gleichzeitig im Speicher
- enqueuePhoto({hold:true}) + releaseHold: waehrend des Verkleinerns
  bleibt das Foto belegt
- Versuchszaehler, retryFailed und replaceQueuedBlob per Patch statt
  ganzes Item zurueckschreiben

Reload nach App-Update: controllerchange lud sofort neu - offene Kamera,
getippte Notiz, halbe Unterschrift weg; mit Fotos in der Warteschlange
kam dazu der Browserdialog "Seite verlassen?".

- window.appBusy(): Modal, Auswahl, laufender Upload, Eingabe im Fokus,
  laufende Sprachnotiz. PIN-Sperre zaehlt bewusst nicht
- index.php holt den Reload nach, sobald nichts mehr offen ist; eigener
  Reload setzt __selfReload, beforeunload warnt dann nicht

Verifiziert lokal in Chromium: echte Kamera-Oberflaeche mit Canvas-
Stream offline, paralleler Zugriff Seite+Worker (3 Fotos = 3 POSTs im
Serverlog, kein duplicate), Verkleinern (eine Datei, kleine Fassung),
fremde Lease frisch/abgelaufen, aufgeschobener Reload ohne Dialog.
Nicht getestet: Kamera-Hardware und Background Sync bei Display aus am
Handy. Kein Deploy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:49:02 +02:00

174 lines
6.6 KiB
JavaScript

/* Mini IndexedDB Wrapper für die PWA.
* Speichert: Auth-Token, Offline-Queue für Uploads.
*/
(function () {
const DB_NAME = 'baustelle-pwa-v1';
const DB_VERSION = 1;
let dbPromise = null;
function open() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('kv')) {
db.createObjectStore('kv');
}
if (!db.objectStoreNames.contains('queue')) {
db.createObjectStore('queue', { keyPath: 'id', autoIncrement: true });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
async function get(key) {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('kv', 'readonly');
const r = tx.objectStore('kv').get(key);
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
async function set(key, val) {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('kv', 'readwrite');
tx.objectStore('kv').put(val, key);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
async function del(key) {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('kv', 'readwrite');
tx.objectStore('kv').delete(key);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
// Alle Schluessel im kv-Store — gebraucht, um den Offline-Spiegel aufzuraeumen
async function keys() {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('kv', 'readonly');
const r = tx.objectStore('kv').getAllKeys();
r.onsuccess = () => res(r.result || []);
r.onerror = () => rej(r.error);
});
}
async function queuePush(item) {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('queue', 'readwrite');
const r = tx.objectStore('queue').add(item);
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
async function queueAll() {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('queue', 'readonly');
const r = tx.objectStore('queue').getAll();
r.onsuccess = () => res(r.result || []);
r.onerror = () => rej(r.error);
});
}
async function queueDelete(id) {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('queue', 'readwrite');
tx.objectStore('queue').delete(id);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
// Item aktualisieren (z.B. Versuchszähler / Fehler-Markierung) — put upsert-t per keyPath id
async function queueUpdate(item) {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('queue', 'readwrite');
tx.objectStore('queue').put(item);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
// Nur die Ids — wer die Queue abarbeitet, holt die Eintraege danach einzeln. queueAll()
// zieht dagegen jedes Foto komplett in den Speicher.
async function queueKeys() {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('queue', 'readonly');
const r = tx.objectStore('queue').getAllKeys();
r.onsuccess = () => res(r.result || []);
r.onerror = () => rej(r.error);
});
}
/* Lesen-Aendern-Schreiben in EINER Transaktion. IndexedDB reiht readwrite-Transaktionen
* auf demselben Store ueber alle Kontexte hinweg hintereinander — Seite und Service
* Worker koennen sich hier also nicht ins Wort fallen. `fn(item)` aendert den Eintrag an
* Ort und Stelle; gibt sie `false` zurueck, wird NICHT geschrieben.
* Rueckgabe: der Eintrag nach der Aenderung, oder null (weg bzw. nicht geschrieben). */
async function queuePatch(id, fn) {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('queue', 'readwrite');
const store = tx.objectStore('queue');
let out = null;
const r = store.get(id);
r.onsuccess = () => {
const it = r.result;
if (!it) return; // inzwischen hochgeladen und geloescht
if (fn(it) === false) return;
store.put(it);
out = it;
};
tx.oncomplete = () => res(out);
tx.onerror = () => rej(tx.error);
tx.onabort = () => rej(tx.error);
});
}
/* Einen Eintrag fuer den Upload belegen (Lease). Seite und Service Worker arbeiten
* dieselbe Queue ab; ohne Absprache laden beide dasselbe Foto hoch (Prod-Log 17.09.2026:
* reihenweise duplicate:true). Belegt wird nur, was frei ist oder dessen Lease abgelaufen
* ist — eine eingefrorene oder abgeschossene Seite blockiert ihr Foto also nicht ewig.
* DIESELBE LOGIK STEHT IN sw.js (queueClaim) — beide Stellen zusammen aendern.
* Rueckgabe: der belegte Eintrag, oder null (weg, fehlgeschlagen-markiert oder belegt). */
function queueClaim(id, owner, leaseMs) {
const now = Date.now();
return queuePatch(id, (it) => {
if (it.failed) return false;
if (it.uploading_since && it.uploading_by !== owner
&& (now - it.uploading_since) < leaseMs) return false;
it.uploading_since = now;
it.uploading_by = owner;
});
}
function queueRelease(id, extra) {
return queuePatch(id, (it) => {
delete it.uploading_since;
delete it.uploading_by;
if (extra) extra(it);
});
}
window.idb = { get, set, del, keys, queuePush, queueAll, queueDelete, queueUpdate,
queueKeys, queuePatch, queueClaim, queueRelease };
})();