From 9f73cdae493acff31484fa0db5b7713127441f15 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 17:19:43 +0200 Subject: [PATCH] L7 Offline & PWA: Outbox-Kern, IndexedDB-Store, Sync-Engine und Bundle-Logik Co-Authored-By: Claude Opus 5 --- scripts/test-offline-core.ts | 379 ++++++++++++++++++++++++++++ src/lib/offline/bundle-core.ts | 171 +++++++++++++ src/lib/offline/db.ts | 136 ++++++++++ src/lib/offline/doc-cache.ts | 54 ++++ src/lib/offline/drafts.ts | 42 ++++ src/lib/offline/ids.ts | 28 +++ src/lib/offline/memory-store.ts | Bin 0 -> 2798 bytes src/lib/offline/outbox-core.ts | 350 ++++++++++++++++++++++++++ src/lib/offline/outbox.ts | 425 ++++++++++++++++++++++++++++++++ src/lib/offline/prefetch.ts | 122 +++++++++ src/lib/offline/read.ts | 31 +++ src/lib/offline/sync-engine.ts | 174 +++++++++++++ src/lib/offline/types.ts | 169 +++++++++++++ 13 files changed, 2081 insertions(+) create mode 100644 scripts/test-offline-core.ts create mode 100644 src/lib/offline/bundle-core.ts create mode 100644 src/lib/offline/db.ts create mode 100644 src/lib/offline/doc-cache.ts create mode 100644 src/lib/offline/drafts.ts create mode 100644 src/lib/offline/ids.ts create mode 100644 src/lib/offline/memory-store.ts create mode 100644 src/lib/offline/outbox-core.ts create mode 100644 src/lib/offline/outbox.ts create mode 100644 src/lib/offline/prefetch.ts create mode 100644 src/lib/offline/read.ts create mode 100644 src/lib/offline/sync-engine.ts create mode 100644 src/lib/offline/types.ts diff --git a/scripts/test-offline-core.ts b/scripts/test-offline-core.ts new file mode 100644 index 0000000..4a41e40 --- /dev/null +++ b/scripts/test-offline-core.ts @@ -0,0 +1,379 @@ +// Lane L7 „Offline & PWA" — reine Outbox-/Bundle-Logik ohne IndexedDB und ohne Server: +// Reihenfolge je Auftrag, Retry/Backoff, idMap-Anwendung, Blob-Upload vor der Op, Konflikt stoppt +// keine unabhängigen Ops, verkettete baseVersion, Mandanten-/User-Trennung der lokalen Stores, +// optimistische Auftragsansicht, Vorab-Download-Auswahl, Dokument-Cache (LRU), Service-Worker-Regeln. +// +// Lauf: npx tsx scripts/test-offline-core.ts (keine Infrastruktur nötig) + +import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import type { SyncOperationInput, SyncOpResult } from "../src/lib/sync/envelope"; +import { createMemoryStore } from "../src/lib/offline/memory-store"; +import { + applyIdMapToPayload, + applyResults, + backoffMs, + BACKOFF_MAX_MS, + blobRef, + canDiscard, + createEntry, + MAX_BATCH, + problemKey, + prunable, + retryEntry, + selectBatch, + summarize, +} from "../src/lib/offline/outbox-core"; +import { enqueueBlob, enqueueOp, runSyncPass, type Transport } from "../src/lib/offline/sync-engine"; +import { buildOrderView, isBundleStale, parseMaxDays, selectOfflineOrders, toBundleRecords } from "../src/lib/offline/bundle-core"; +import { DOC_CACHE, DOC_CACHE_MAX_BYTES, PAGE_CACHE, selectEvictions, selectOfflineDocuments } from "../src/lib/offline/doc-cache"; +import { ctxKeyOf, type BundleOrderData, type OutboxEntry } from "../src/lib/offline/types"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +const A = { tenantId: "tenant-a", userId: "user-1" }; +const A2 = { tenantId: "tenant-a", userId: "user-2" }; +const B = { tenantId: "tenant-b", userId: "user-1" }; +const T0 = new Date("2026-09-14T08:00:00.000Z"); +const at = (ms: number) => new Date(T0.getTime() + ms); +const fixedRandom = () => 1; // backoff = full step + +/** Fake server: records every batch, answers per op via `decide`. */ +function fakeTransport(decide: (op: SyncOperationInput) => SyncOpResult, opts: { failBatches?: number; uploadError?: "network" | "invalid" } = {}) { + const batches: SyncOperationInput[][] = []; + const uploads: string[] = []; + const log: string[] = []; + let failBatches = opts.failBatches ?? 0; + const transport: Transport = { + async sendBatch(_device, operations) { + if (failBatches > 0) { + failBatches--; + log.push("batch:network"); + return { ok: false, error: "network" }; + } + batches.push(operations); + log.push(...operations.map((o) => `op:${o.opType}`)); + return { ok: true, response: { results: operations.map(decide), serverTime: new Date().toISOString() } }; + }, + async upload(blob) { + log.push(`upload:${blob.clientId}`); + if (opts.uploadError) return { ok: false, error: opts.uploadError }; + uploads.push(blob.clientId); + return { ok: true, documentId: `doc-${blob.clientId.slice(0, 8)}` }; + }, + }; + return { transport, batches, uploads, log }; +} + +const applied = (op: SyncOperationInput, extra: Partial = {}): SyncOpResult => ({ clientOpId: op.clientOpId, status: "applied", ...extra }); + +function order(id: string, extra: Partial = {}): BundleOrderData { + return { + id, + number: `A-${id}`, + title: `Auftrag ${id}`, + status: "assigned", + statusGroup: "planned", + priority: "normal", + isEmergency: false, + plannedStart: null, + plannedEnd: null, + version: 3, + customer: { companyName: "Kunde", firstName: null, lastName: null, street: null, houseNumber: null, postalCode: null, city: null }, + checklistItems: [{ id: "chk-1", label: "Absichern", required: true, requiresPhoto: false, checked: false, checkedAt: null, comment: null }], + photoRequirements: [{ id: "req-1", key: "typ", label: "Typenschild", _count: { photos: 0 } }], + materialPlans: [], + materialUsages: [], + documents: [], + siteHistory: [], + ...extra, + }; +} + +async function main() { + console.log("\n— Reihenfolge je Auftrag / Batch —"); + { + const store = createMemoryStore(); + const ids: string[] = []; + for (let i = 0; i < 3; i++) { + const e = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", clientId: randomUUID(), kind: "general", text: `n${i}` } }, randomUUID(), at(i)); + ids.push(e.clientOpId); + } + await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-2", clientId: randomUUID(), kind: "general", text: "x" } }, randomUUID(), at(10)); + const fake = fakeTransport((op) => applied(op)); + const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(100) }); + const sentIds = fake.batches.flat().map((o) => o.clientOpId); + ok(pass.applied === 4 && fake.batches.length === 1, "4 Ops in einem Batch übertragen und angewendet"); + ok(JSON.stringify(sentIds.slice(0, 3)) === JSON.stringify(ids), "Ops eines Auftrags in Erfassungsreihenfolge gesendet"); + ok((await store.listOps(ctxKeyOf(A))).every((o) => o.status === "applied" && !!o.appliedAt), "lokaler Status applied mit Zeitstempel"); + } + { + const ops: OutboxEntry[] = []; + for (let i = 0; i < 120; i++) ops.push(createEntry(A, { opType: "note.create", payload: { workOrderId: `wo-${i % 7}`, kind: "general", text: "t" } }, { clientOpId: randomUUID(), now: at(i), existing: [], seq: i })); + const b = selectBatch(ops, [], at(1000)); + ok(b.entries.length === MAX_BATCH && b.operations.length === MAX_BATCH, `Batch auf ${MAX_BATCH} Ops begrenzt`); + const store = createMemoryStore(); + for (const o of ops) await store.putOp(o); + const fake = fakeTransport((op) => applied(op)); + const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(2000) }); + ok(pass.applied === 120 && fake.batches.length === 3 && fake.batches.every((x) => x.length <= MAX_BATCH), "120 Ops → 3 Batches (50/50/20)"); + } + + console.log("\n— Retry / Backoff —"); + { + ok(backoffMs(1, fixedRandom) === 2000 && backoffMs(2, fixedRandom) === 4000 && backoffMs(4, fixedRandom) === 16000, "Backoff verdoppelt sich (2 s, 4 s, 16 s)"); + ok(backoffMs(30, fixedRandom) === BACKOFF_MAX_MS, "Backoff gedeckelt auf 5 min"); + ok(backoffMs(3, () => 0) === 4000, "Jitter halbiert höchstens"); + + const store = createMemoryStore(); + const e1 = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0)); + const e2 = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "b" } }, randomUUID(), at(1)); + const fake = fakeTransport((op) => applied(op), { failBatches: 1 }); + const p1 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10), random: fixedRandom }); + let ops = await store.listOps(ctxKeyOf(A)); + ok(p1.stopped === "network" && ops.every((o) => o.status === "queued" && o.attempts === 1 && o.lastError?.code === "network"), "Netzfehler → zurück in die Warteschlange, Versuch gezählt, Fehler gespeichert"); + ok(ops.every((o) => o.nextAttemptAt === at(10 + 2000).toISOString()), "nächster Versuch nach Backoff terminiert"); + const p2 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(500), random: fixedRandom }); + ok(p2.sent === 0 && fake.batches.length === 0, "während Backoff wird nicht gesendet"); + const p3 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(3000), random: fixedRandom }); + ops = await store.listOps(ctxKeyOf(A)); + ok(p3.applied === 2 && ops.every((o) => o.status === "applied"), "nach Backoff erfolgreich übertragen"); + ok(fake.batches[0].map((o) => o.clientOpId).join() === [e1.clientOpId, e2.clientOpId].join(), "gleiche clientOpIds beim Wiederholen (Idempotenz)"); + + // server-side transient error per op + const store2 = createMemoryStore(); + await enqueueOp(store2, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0)); + const fake2 = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "rejected", errorCode: "internal" })); + await runSyncPass({ store: store2, ctx: A, transport: fake2.transport, deviceId: "d", now: () => at(10), random: fixedRandom }); + const [t] = await store2.listOps(ctxKeyOf(A)); + ok(t.status === "queued" && t.attempts === 1 && !!t.nextAttemptAt, "rejected internal → transient, erneuter Versuch geplant"); + const store3 = createMemoryStore(); + await enqueueOp(store3, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0)); + const fake3 = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "rejected", errorCode: "not_found" })); + await runSyncPass({ store: store3, ctx: A, transport: fake3.transport, deviceId: "d", now: () => at(10) }); + const [r] = await store3.listOps(ctxKeyOf(A)); + ok(r.status === "rejected" && r.lastError?.code === "not_found" && canDiscard(r) && problemKey(r) === "notFound", "rejected not_found → endgültig, verwerfbar, Klartext-Schlüssel"); + const retried = retryEntry(r, at(20), "new-op-id"); + ok(retried?.status === "queued" && retried.clientOpId === "new-op-id" && retried.attempts === 0, "erneut versuchen: neue clientOpId (Server hat altes Ergebnis gespeichert)"); + const unauthorized = fakeTransport((op) => applied(op)); + unauthorized.transport.sendBatch = async () => ({ ok: false, error: "unauthorized" }); + const store4 = createMemoryStore(); + await enqueueOp(store4, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0)); + const p4 = await runSyncPass({ store: store4, ctx: A, transport: unauthorized.transport, deviceId: "d", now: () => at(10) }); + ok(p4.stopped === "unauthorized" && summarize(await store4.listOps(ctxKeyOf(A)), []).waitingForAuth, "401 → Pass stoppt, Hinweis „neu anmelden“"); + } + + console.log("\n— Konflikt stoppt keine unabhängigen Ops —"); + { + const store = createMemoryStore(); + const tr = await enqueueOp(store, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "accepted" } }, randomUUID(), at(0)); + const sameOrderNote = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "danach" } }, randomUUID(), at(1)); + const other = await enqueueOp(store, A, { opType: "checklist.toggle", payload: { workOrderId: "wo-2", itemId: "c", checked: true } }, randomUUID(), at(2)); + const fake = fakeTransport((op) => (op.opType === "work_order.transition" ? { clientOpId: op.clientOpId, status: "conflict", errorCode: "conflict", entityVersion: 5 } : applied(op))); + const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) }); + const byId = new Map((await store.listOps(ctxKeyOf(A))).map((o) => [o.clientOpId, o])); + ok(pass.conflicts === 1 && byId.get(tr.clientOpId)?.status === "conflict", "Statusänderung → conflict (nicht überschrieben)"); + ok(byId.get(sameOrderNote.clientOpId)?.status === "applied" && byId.get(other.clientOpId)?.status === "applied", "additive Op desselben Auftrags und Op eines anderen Auftrags trotzdem angewendet"); + ok(problemKey(byId.get(tr.clientOpId)!) === "conflictTransition" && !canDiscard(byId.get(tr.clientOpId)!), "Konflikt: Klartext „im Büro geändert“, nicht verwerfbar"); + ok(summarize([...byId.values()], []).conflicts === 1, "Konflikt in der Zusammenfassung gezählt"); + // duplicate of a stored conflict stays a conflict + const dupStore = createMemoryStore(); + await enqueueOp(dupStore, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "accepted" } }, randomUUID(), at(0)); + const dup = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "duplicate", errorCode: "conflict", message: "original status: conflict" })); + await runSyncPass({ store: dupStore, ctx: A, transport: dup.transport, deviceId: "d", now: () => at(10) }); + ok((await dupStore.listOps(ctxKeyOf(A)))[0].status === "conflict", "duplicate eines gespeicherten Konflikts bleibt conflict"); + } + { + // pending op (backoff) blocks only later ops of the same order + const e1 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, { clientOpId: "a1", now: T0, existing: [], seq: 1 }); + const e2 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "b" } }, { clientOpId: "a2", now: T0, existing: [], seq: 2 }); + const e3 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-2", kind: "general", text: "c" } }, { clientOpId: "b1", now: T0, existing: [], seq: 3 }); + const b = selectBatch([{ ...e1, nextAttemptAt: at(60_000).toISOString(), attempts: 1 }, e2, e3], [], at(1000)); + ok(b.entries.map((e) => e.clientOpId).join() === "b1", "Op im Backoff blockiert nur Folge-Ops desselben Auftrags"); + } + + console.log("\n— Blob-Upload vor der Op —"); + { + const store = createMemoryStore(); + const clientId = randomUUID(); + const ref = await enqueueBlob(store, A, { clientId, workOrderId: "wo-1", kind: "photo", blob: new Blob([new Uint8Array(1024)], { type: "image/jpeg" }), fileName: "f.jpg" }, at(0)); + ok(ref === blobRef(clientId), "Blob-Referenz blob:"); + const op = await enqueueOp(store, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", clientId: randomUUID(), documentId: ref, phase: "during" } }, randomUUID(), at(1)); + ok(op.blobRefs.length === 1 && (await store.getBlob(clientId))?.opClientOpId === op.clientOpId, "Op mit Blob verknüpft"); + ok(selectBatch(await store.listOps(ctxKeyOf(A)), await store.listBlobs(ctxKeyOf(A)), at(2)).entries.length === 0, "ohne Upload wird die Op nicht gesendet"); + ok(summarize(await store.listOps(ctxKeyOf(A)), await store.listBlobs(ctxKeyOf(A))).pendingUploads === 1, "ausstehender Upload gezählt"); + const fake = fakeTransport((o) => applied(o)); + const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) }); + ok(fake.log.indexOf(`upload:${clientId}`) === 0 && fake.log.indexOf("op:photo.attach") === 1, "Upload vor photo.attach"); + ok(fake.batches[0][0].payload.documentId === `doc-${clientId.slice(0, 8)}`, "Payload auf Server-documentId umgeschrieben"); + ok(pass.applied === 1 && (await store.getBlob(clientId)) === null, "nach applied: Blob lokal freigegeben"); + + const store2 = createMemoryStore(); + const c2 = randomUUID(); + const ref2 = await enqueueBlob(store2, A, { clientId: c2, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f.jpg" }, at(0)); + await enqueueOp(store2, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: ref2 } }, randomUUID(), at(1)); + const note = await enqueueOp(store2, A, { opType: "note.create", payload: { workOrderId: "wo-9", kind: "general", text: "unabhängig" } }, randomUUID(), at(2)); + const bad = fakeTransport((o) => applied(o), { uploadError: "invalid" }); + await runSyncPass({ store: store2, ctx: A, transport: bad.transport, deviceId: "d", now: () => at(10) }); + const ops2 = await store2.listOps(ctxKeyOf(A)); + const photoOp = ops2.find((o) => o.opType === "photo.attach")!; + ok(photoOp.status === "rejected" && problemKey(photoOp) === "upload", "Upload endgültig abgelehnt → Op rejected mit Klartext"); + ok(ops2.find((o) => o.clientOpId === note.clientOpId)?.status === "applied", "unabhängige Op trotzdem gesendet"); + const store3 = createMemoryStore(); + const c3 = randomUUID(); + await enqueueOp(store3, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: await enqueueBlob(store3, A, { clientId: c3, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f" }, at(0)) } }, randomUUID(), at(1)); + const offline = fakeTransport((o) => applied(o), { uploadError: "network" }); + const p = await runSyncPass({ store: store3, ctx: A, transport: offline.transport, deviceId: "d", now: () => at(10), random: fixedRandom }); + const blob3 = await store3.getBlob(c3); + ok(p.stopped === "network" && blob3?.status === "failed" && blob3.nextAttemptAt === at(2010).toISOString() && offline.batches.length === 0, "Upload ohne Netz → Backoff, Op wartet"); + } + + console.log("\n— idMap / verkettete baseVersion —"); + { + ok( + JSON.stringify(applyIdMapToPayload({ clientId: "c1", photoId: "c1", nested: { ref: "c1" }, list: ["c1", "x"] }, { c1: "srv-1" })) === + JSON.stringify({ clientId: "c1", photoId: "srv-1", nested: { ref: "srv-1" }, list: ["srv-1", "x"] }), + "idMap ersetzt Referenzen, eigenes clientId-Feld bleibt", + ); + const store = createMemoryStore(); + const photoClient = randomUUID(); + const first = await enqueueOp(store, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", clientId: photoClient, documentId: "doc-1" } }, randomUUID(), at(0)); + const second = await enqueueOp(store, A, { opType: "material.upsert", payload: { workOrderId: "wo-1", clientId: randomUUID(), name: "Rohr", quantity: 1, unit: "m", usageStatus: "additional", photoId: photoClient } }, randomUUID(), at(1)); + const ops = await store.listOps(ctxKeyOf(A)); + const { changed, idMap } = applyResults(ops, [ops[0]], [{ clientOpId: first.clientOpId, status: "applied", idMap: { [photoClient]: "photo-srv" } }], at(5)); + const rewritten = changed.find((c) => c.clientOpId === second.clientOpId); + ok(idMap[photoClient] === "photo-srv" && rewritten?.payload.photoId === "photo-srv", "idMap auf wartende Ops angewendet (client id → server id)"); + + const chain = createMemoryStore(); + const start = await enqueueOp(chain, A, { opType: "session.start", payload: { workOrderId: "wo-1", mode: "work", clientId: randomUUID() } }, randomUUID(), at(0)); + const complete = await enqueueOp(chain, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "technically_completed" } }, randomUUID(), at(1)); + ok(complete.chainedBase === true, "Statuswechsel hinter eigener Session-Op → baseVersion verkettet"); + const fake = fakeTransport((op) => (op.opType === "session.start" ? applied(op, { entityVersion: 5 }) : op.baseVersion === 5 ? applied(op, { entityVersion: 6 }) : { clientOpId: op.clientOpId, status: "conflict", errorCode: "conflict" })); + await runSyncPass({ store: chain, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10), maxBatch: 1 }); + const chained = (await chain.listOps(ctxKeyOf(A))).find((o) => o.clientOpId === complete.clientOpId); + ok(fake.batches.length === 2 && fake.batches[1][0].baseVersion === 5 && chained?.status === "applied", "baseVersion aus Server-Ergebnis der Vorgänger-Op übernommen"); + ok(start.chainedBase === undefined, "additive/Session-Op ohne Verkettung"); + } + + console.log("\n— Mandanten-/User-Trennung der lokalen Stores —"); + { + const store = createMemoryStore(); + await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "A" } }, randomUUID(), at(0)); + await enqueueOp(store, B, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "B" } }, randomUUID(), at(1)); + await enqueueOp(store, A2, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "A2" } }, randomUUID(), at(2)); + await enqueueBlob(store, B, { clientId: randomUUID(), workOrderId: "wo-1", kind: "photo", blob: new Blob(["b"]), fileName: "b" }, at(3)); + await store.replaceBundle(ctxKeyOf(A), toBundleRecords(ctxKeyOf(A), [order("wo-1")], at(0).toISOString())); + await store.replaceBundle(ctxKeyOf(B), toBundleRecords(ctxKeyOf(B), [order("wo-b")], at(0).toISOString())); + await store.setMeta(ctxKeyOf(A), "draft:note:wo-1", { text: "geheim A" }); + const opsA = await store.listOps(ctxKeyOf(A)); + ok(opsA.length === 1 && opsA[0].payload.text === "A" && opsA[0].tenantId === "tenant-a" && opsA[0].userId === "user-1", "Outbox liefert nur Ops des eigenen Mandanten+Users"); + ok((await store.listOps(ctxKeyOf(A2))).length === 1 && (await store.listOps(ctxKeyOf(B)))[0].payload.text === "B", "anderer User desselben Mandanten / anderer Mandant getrennt"); + ok((await store.listBlobs(ctxKeyOf(A))).length === 0 && (await store.listBlobs(ctxKeyOf(B))).length === 1, "Blobs je Kontext getrennt"); + ok((await store.listBundle(ctxKeyOf(B))).map((r) => r.workOrderId).join() === "wo-b", "Bundle je Kontext getrennt"); + ok((await store.getMeta(ctxKeyOf(B), "draft:note:wo-1")) === null, "Entwürfe je Kontext getrennt"); + await store.replaceBundle(ctxKeyOf(A), [...toBundleRecords(ctxKeyOf(B), [order("wo-injected")], T0.toISOString()), ...toBundleRecords(ctxKeyOf(A), [order("wo-1")], T0.toISOString())]); + ok((await store.listBundle(ctxKeyOf(B))).every((r) => r.workOrderId === "wo-b") && (await store.listBundle(ctxKeyOf(A))).length === 1, "replaceBundle schreibt keine Datensätze fremder Kontexte"); + await store.clearContext(ctxKeyOf(A)); + ok((await store.listOps(ctxKeyOf(A))).length === 0 && (await store.listBundle(ctxKeyOf(A))).length === 0 && (await store.getMeta(ctxKeyOf(A), "draft:note:wo-1")) === null, "Kontext A gelöscht (Logout)"); + ok((await store.listOps(ctxKeyOf(B))).length === 1 && (await store.listOps(ctxKeyOf(A2))).length === 1, "Daten anderer Kontexte bleiben erhalten"); + ok(!(await store.listContexts()).includes(ctxKeyOf(A)), "Kontext A nicht mehr gelistet"); + // a pass for context A never sends ops of B + const fake = fakeTransport((o) => applied(o)); + await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) }); + ok(fake.batches.length === 0, "Sync-Pass von A sendet keine Ops von B/A2"); + } + + console.log("\n— Optimistische Auftragsansicht —"); + { + const record = toBundleRecords("tenant-a:user-1", [order("wo-1", { status: "accepted" })], at(0).toISOString())[0]; + const mk = (opType: OutboxEntry["opType"], payload: Record, seq: number, status: OutboxEntry["status"] = "queued") => ({ + ...createEntry(A, { opType, payload }, { clientOpId: `op-${seq}`, now: at(seq * 1000), existing: [], seq }), + status, + ...(status === "applied" ? { appliedAt: at(seq * 1000).toISOString() } : {}), + }); + const ops = [ + mk("session.start", { workOrderId: "wo-1", mode: "work" }, 1), + mk("note.create", { workOrderId: "wo-1", clientId: "n-1", kind: "work_done", text: "Montiert" }, 2), + mk("checklist.toggle", { workOrderId: "wo-1", itemId: "chk-1", checked: true }, 3), + mk("photo.attach", { workOrderId: "wo-1", clientId: "p-1", documentId: blobRef("b-1"), photoRequirementId: "req-1" }, 4), + mk("session.pause", { workOrderId: "wo-1" }, 5), + mk("note.create", { workOrderId: "wo-1", kind: "general", text: "abgelehnt" }, 6, "rejected"), + mk("note.create", { workOrderId: "wo-2", kind: "general", text: "anderer Auftrag" }, 7), + ]; + const view = buildOrderView(record, ops); + ok(view.status === "paused" && view.local.session === "paused" && view.statusGroup === "in_progress", "Zeiten/Status optimistisch angewendet"); + ok(view.local.notes.length === 1 && view.local.notes[0].text === "Montiert" && view.local.notes[0].pending, "Notiz lokal sichtbar (ausstehend), abgelehnte nicht"); + ok(view.checklistItems[0].checked && view.photoRequirements[0]._count.photos === 1 && view.local.photos[0].blobClientId === "b-1", "Checkliste und Pflichtfoto lokal aktualisiert"); + ok(view.local.pendingOps === 5 && view.local.rejected, "ausstehende Ops gezählt, Fehlerhinweis gesetzt"); + ok(record.data.status === "accepted" && !record.data.checklistItems[0].checked, "Server-Snapshot unverändert"); + const afterPull = buildOrderView({ ...record, syncedAt: at(60_000).toISOString() }, ops.map((o) => ({ ...o, status: "applied" as const, appliedAt: at(10_000).toISOString() }))); + ok(afterPull.status === "accepted" && afterPull.local.notes.length === 0, "nach neuem Pull gilt der Server-Stand"); + ok(prunable(ops.map((o) => ({ ...o, status: "applied" as const, appliedAt: at(10_000).toISOString() })), at(60_000).toISOString()).length === ops.length, "angewendete Ops nach Pull aufräumbar"); + } + + console.log("\n— Vorab-Download / Veraltet / Dokument-Cache —"); + { + const now = new Date("2026-09-14T10:00:00+02:00"); + const d = (days: number, h = 9) => { + const x = new Date(now); + x.setHours(h, 0, 0, 0); + x.setDate(x.getDate() + days); + return x.toISOString(); + }; + const orders = [ + order("today", { plannedStart: d(0), plannedEnd: d(0, 17) }), + order("in3", { plannedStart: d(3) }), + order("in5", { plannedStart: d(5) }), + order("running", { status: "in_progress", plannedStart: d(-10) }), + order("past-planned", { plannedStart: d(-2), plannedEnd: d(-2, 12) }), + order("undated"), + ]; + const sel = selectOfflineOrders(orders, now).map((o) => o.id); + ok(sel.join() === "today,in3,running", "heute + 3 Tage + laufende Aufträge ausgewählt"); + ok(!isBundleStale(new Date(now.getTime() - 6 * 864e5).toISOString(), now, 7) && isBundleStale(new Date(now.getTime() - 8 * 864e5).toISOString(), now, 7), "Bundle nach OFFLINE_MAX_DAYS als veraltet markiert"); + ok(parseMaxDays(undefined) === 7 && parseMaxDays("14") === 14 && parseMaxDays("0") === 7 && parseMaxDays("abc") === 7, "OFFLINE_MAX_DAYS mit Default 7"); + const docs = selectOfflineDocuments([ + { id: "1", category: "technical_drawing", fileSize: 1e6 }, + { id: "2", category: "assembly_instructions", fileSize: 25 * 1024 * 1024 }, + { id: "3", category: "safety_document", fileSize: 26 * 1024 * 1024 }, + { id: "4", category: "order_confirmation", fileSize: 1e5 }, + { id: "1", category: "technical_drawing", fileSize: 1e6 }, + ]); + ok(docs.map((x) => x.id).join() === "1,2", "Zeichnung/Montageanleitung/Sicherheit ≤ 25 MB, ohne Dubletten"); + const MB = 1024 * 1024; + const ev = selectEvictions( + [ + { url: "a", size: 100 * MB, lastUsed: 1 }, + { url: "b", size: 150 * MB, lastUsed: 3 }, + { url: "c", size: 40 * MB, lastUsed: 2 }, + ], + DOC_CACHE_MAX_BYTES, + 20 * MB, + ); + ok(ev.join() === "a", "LRU: ältester Eintrag verdrängt, bis 300 MB passen"); + ok(selectEvictions([{ url: "a", size: 10, lastUsed: 1 }], DOC_CACHE_MAX_BYTES).length === 0, "unter dem Limit keine Verdrängung"); + ok(selectEvictions([{ url: "keep", size: 200 * MB, lastUsed: 1 }, { url: "old", size: 200 * MB, lastUsed: 5 }], DOC_CACHE_MAX_BYTES, 0, new Set(["keep"])).join() === "old", "Dokumente des aktuellen Bundles zuletzt verdrängt"); + } + + console.log("\n— Service Worker (public/sw.js) —"); + { + const sw = readFileSync(new URL("../public/sw.js", import.meta.url), "utf8"); + ok(sw.includes(`"${DOC_CACHE}"`) && sw.includes(`"${PAGE_CACHE}"`) && sw.includes(String(DOC_CACHE_MAX_BYTES / 1024 / 1024)), "Cache-Namen/Limit in sw.js und doc-cache.ts identisch"); + ok(/request\.method !== "GET"/.test(sw), "nur GET-Anfragen werden behandelt (keine Mutationen)"); + ok(/NEVER_CACHE/.test(sw) && sw.includes('"/api/v1/sync"') && sw.includes('"/api/auth"') && sw.includes('"/login"'), "Sync-API, Auth-Routen und Login ausgeschlossen"); + ok(sw.includes('"/m/offline"') && sw.includes("/_next/static/"), "Offline-Fallback und statische Assets berücksichtigt"); + ok(sw.includes("SKIP_WAITING"), "Update-Flow über SKIP_WAITING"); + } + + console.log(`\n${failures === 0 ? "Alle Prüfungen bestanden." : `${failures} Prüfung(en) fehlgeschlagen.`}`); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/lib/offline/bundle-core.ts b/src/lib/offline/bundle-core.ts new file mode 100644 index 0000000..0e08616 --- /dev/null +++ b/src/lib/offline/bundle-core.ts @@ -0,0 +1,171 @@ +import { STATUS_GROUP, type WorkOrderStatus } from "@/lib/work-orders/status"; +import type { BundleOrderData, BundleRecord, OutboxEntry } from "./types"; +import { isPending } from "./outbox-core"; + +/** + * Pure bundle logic (lane L7, Spec §23.2): which orders are kept offline, staleness, and the + * optimistic order view = server snapshot + own ops that the server has not confirmed yet (or that + * were applied after the last pull). The snapshot itself is never mutated, so a rejected op + * disappears from the view automatically. + */ + +export const DEFAULT_OFFLINE_MAX_DAYS = 7; +export const PREFETCH_DAYS = 3; + +const RUNNING: string[] = ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created", "technically_completed", "signature_pending"]; + +export function parseMaxDays(raw: string | undefined | null): number { + const n = Number.parseInt(raw ?? "", 10); + return Number.isFinite(n) && n >= 1 && n <= 365 ? n : DEFAULT_OFFLINE_MAX_DAYS; +} + +/** Today + the next `days` days (local time) and all running orders. */ +export function selectOfflineOrders>(orders: T[], now: Date, days = PREFETCH_DAYS): T[] { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + days + 1); + return orders.filter((o) => { + if (RUNNING.includes(o.status)) return true; + const ps = o.plannedStart ? new Date(o.plannedStart) : null; + const pe = o.plannedEnd ? new Date(o.plannedEnd) : null; + if (!ps) return false; + return ps < end && (pe ? pe >= start : ps >= start); + }); +} + +export function isBundleStale(syncedAt: string | null, now: Date, maxDays: number): boolean { + if (!syncedAt) return true; + return now.getTime() - new Date(syncedAt).getTime() > maxDays * 24 * 60 * 60 * 1000; +} + +export function toBundleRecords(ctxKey: string, orders: BundleOrderData[], syncedAt: string): BundleRecord[] { + return orders.map((data) => ({ ctxKey, workOrderId: data.id, data, syncedAt })); +} + +export type LocalNote = { id: string; kind: string; text: string; createdAt: string; pending: boolean }; +export type LocalPhoto = { id: string; phase: string | null; comment: string | null; createdAt: string; pending: boolean; blobClientId: string | null }; +export type SessionState = "en_route" | "running" | "paused" | null; + +export type OrderView = BundleOrderData & { + local: { + notes: LocalNote[]; + photos: LocalPhoto[]; + voiceNotes: number; + session: SessionState; + pendingOps: number; + conflict: boolean; + rejected: boolean; + }; +}; + +const str = (v: unknown): string | null => (typeof v === "string" ? v : null); + +/** Server snapshot + own ops (pending, or applied but not yet contained in the snapshot). */ +export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderView { + const data: BundleOrderData = structuredCloneSafe(record.data); + const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data.status), pendingOps: 0, conflict: false, rejected: false } }; + const mine = ops.filter((o) => o.workOrderId === data.id).sort((a, b) => a.seq - b.seq); + + for (const op of mine) { + if (op.status === "conflict" && !op.acknowledged) view.local.conflict = true; + if (op.status === "rejected" && !op.acknowledged) view.local.rejected = true; + const unconfirmed = isPending(op) || (op.status === "applied" && !!op.appliedAt && op.appliedAt > record.syncedAt); + if (!unconfirmed) continue; + if (isPending(op)) view.local.pendingOps++; + applyOp(view, op); + } + view.statusGroup = STATUS_GROUP[view.status as WorkOrderStatus] ?? view.statusGroup; + return view; +} + +function initialSession(status: string): SessionState { + // The bundle carries no sessions; the order status is the best local approximation. + if (status === "en_route") return "en_route"; + if (status === "in_progress") return "running"; + if (status === "paused") return "paused"; + return null; +} + +function applyOp(view: OrderView, op: OutboxEntry) { + const p = op.payload; + switch (op.opType) { + case "session.start": + if (p.mode === "travel") { + view.status = "en_route"; + view.local.session = "en_route"; + } else { + view.status = "in_progress"; + view.local.session = "running"; + } + break; + case "session.pause": + view.status = "paused"; + view.local.session = "paused"; + break; + case "session.resume": + view.status = "in_progress"; + view.local.session = "running"; + break; + case "session.end": + view.local.session = null; + break; + case "work_order.transition": + if (typeof p.to === "string") view.status = p.to; + break; + case "note.create": + view.local.notes.unshift({ id: str(p.clientId) ?? op.clientOpId, kind: str(p.kind) ?? "general", text: str(p.text) ?? "", createdAt: op.clientCreatedAt, pending: isPending(op) }); + break; + case "checklist.toggle": { + const item = view.checklistItems.find((i) => i.id === p.itemId); + if (item) { + item.checked = p.checked === true; + item.checkedAt = item.checked ? op.clientCreatedAt : null; + if (p.comment !== undefined) item.comment = str(p.comment); + } + break; + } + case "material.upsert": { + const planId = str(p.materialPlanId); + const usage = { + id: str(p.clientId) ?? op.clientOpId, + materialPlanId: planId, + name: str(p.name), + articleNumber: str(p.articleNumber), + actualQuantity: typeof p.quantity === "number" ? p.quantity : 0, + unit: str(p.unit) ?? "", + usageStatus: str(p.usageStatus) ?? "additional", + deviationReason: str(p.deviationReason), + notes: str(p.notes), + clientId: str(p.clientId), + }; + const idx = planId ? view.materialUsages.findIndex((u) => u.materialPlanId === planId) : view.materialUsages.findIndex((u) => !!usage.clientId && u.clientId === usage.clientId); + if (idx >= 0) view.materialUsages[idx] = { ...view.materialUsages[idx], ...usage, id: view.materialUsages[idx].id }; + else view.materialUsages.push(usage); + break; + } + case "photo.attach": { + const doc = str(p.documentId); + view.local.photos.unshift({ + id: str(p.clientId) ?? op.clientOpId, + phase: str(p.phase), + comment: str(p.comment), + createdAt: op.clientCreatedAt, + pending: isPending(op), + blobClientId: doc?.startsWith("blob:") ? doc.slice(5) : null, + }); + const req = view.photoRequirements.find((r) => r.id === p.photoRequirementId); + if (req) req._count = { photos: req._count.photos + 1 }; + break; + } + case "voice.attach": + view.local.voiceNotes++; + break; + default: + break; + } +} + +function structuredCloneSafe(v: T): T { + return typeof structuredClone === "function" ? structuredClone(v) : (JSON.parse(JSON.stringify(v)) as T); +} diff --git a/src/lib/offline/db.ts b/src/lib/offline/db.ts new file mode 100644 index 0000000..17c4638 --- /dev/null +++ b/src/lib/offline/db.ts @@ -0,0 +1,136 @@ +import type { BlobEntry, BundleRecord, DraftRecord, OfflineStore, OutboxEntry } from "./types"; +import { createMemoryStore } from "./memory-store"; + +/** + * IndexedDB implementation of OfflineStore (lane L7, Spec §23.2/§23.4) — a thin promise layer over + * the native API, no dependency. Database `craftvia-offline`, stores: + * outbox key clientOpId, index ctxKey — ops with status/attempts/errors (OutboxEntry) + * blobs key clientId, index ctxKey — photos/voice notes waiting for upload (BlobEntry) + * bundle key [ctxKey, workOrderId], index ctxKey — server snapshot per order (BundleRecord) + * meta key [ctxKey, key], index ctxKey — lastPull, drafts, misc (DraftRecord) + * Every read filters by ctxKey (= tenantId:userId); data of other contexts is never returned. + */ + +const DB_NAME = "craftvia-offline"; +const DB_VERSION = 1; +const STORES = ["outbox", "blobs", "bundle", "meta"] as const; +type StoreName = (typeof STORES)[number]; + +const req = (r: IDBRequest) => + new Promise((resolve, reject) => { + r.onsuccess = () => resolve(r.result); + r.onerror = () => reject(r.error); + }); + +const done = (tx: IDBTransaction) => + new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error ?? new Error("transaction aborted")); + }); + +function open(): Promise { + return new Promise((resolve, reject) => { + const r = indexedDB.open(DB_NAME, DB_VERSION); + r.onupgradeneeded = () => { + const db = r.result; + if (!db.objectStoreNames.contains("outbox")) db.createObjectStore("outbox", { keyPath: "clientOpId" }).createIndex("ctxKey", "ctxKey"); + if (!db.objectStoreNames.contains("blobs")) db.createObjectStore("blobs", { keyPath: "clientId" }).createIndex("ctxKey", "ctxKey"); + if (!db.objectStoreNames.contains("bundle")) db.createObjectStore("bundle", { keyPath: ["ctxKey", "workOrderId"] }).createIndex("ctxKey", "ctxKey"); + if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta", { keyPath: ["ctxKey", "key"] }).createIndex("ctxKey", "ctxKey"); + }; + r.onsuccess = () => { + const db = r.result; + // another tab upgrades the schema → release our connection + db.onversionchange = () => db.close(); + resolve(db); + }; + r.onerror = () => reject(r.error); + r.onblocked = () => reject(new Error("indexedDB blocked")); + }); +} + +export function createIdbStore(): OfflineStore { + let dbPromise: Promise | null = null; + const db = () => (dbPromise ??= open().catch((err) => { + dbPromise = null; + throw err; + })); + + async function run(names: StoreName | StoreName[], mode: IDBTransactionMode, fn: (tx: IDBTransaction) => Promise | T): Promise { + const tx = (await db()).transaction(names, mode); + const finished = done(tx); + const value = await fn(tx); + await finished; + return value; + } + + const byCtx = (name: StoreName, ctxKey: string) => run(name, "readonly", (tx) => req(tx.objectStore(name).index("ctxKey").getAll(ctxKey)) as Promise); + + return { + putOp: (entry) => run("outbox", "readwrite", (tx) => void tx.objectStore("outbox").put(entry)), + listOps: async (ctxKey) => (await byCtx("outbox", ctxKey)).sort((a, b) => a.seq - b.seq), + deleteOp: (id) => run("outbox", "readwrite", (tx) => void tx.objectStore("outbox").delete(id)), + + putBlob: (entry) => run("blobs", "readwrite", (tx) => void tx.objectStore("blobs").put(entry)), + getBlob: (id) => run("blobs", "readonly", async (tx) => ((await req(tx.objectStore("blobs").get(id))) as BlobEntry | undefined) ?? null), + listBlobs: (ctxKey) => byCtx("blobs", ctxKey), + deleteBlob: (id) => run("blobs", "readwrite", (tx) => void tx.objectStore("blobs").delete(id)), + + replaceBundle: (ctxKey, records) => + run("bundle", "readwrite", async (tx) => { + const store = tx.objectStore("bundle"); + const keys = await req(store.index("ctxKey").getAllKeys(ctxKey)); + for (const k of keys) store.delete(k); + for (const r of records) if (r.ctxKey === ctxKey) store.put(r); + }), + listBundle: (ctxKey) => byCtx("bundle", ctxKey), + + getMeta: async (ctxKey: string, key: string) => + run("meta", "readonly", async (tx) => (((await req(tx.objectStore("meta").get([ctxKey, key]))) as DraftRecord | undefined)?.value as T | undefined) ?? null), + setMeta: (ctxKey, key, value) => run("meta", "readwrite", (tx) => void tx.objectStore("meta").put({ ctxKey, key, value, updatedAt: new Date().toISOString() } satisfies DraftRecord)), + deleteMeta: (ctxKey, key) => run("meta", "readwrite", (tx) => void tx.objectStore("meta").delete([ctxKey, key])), + + listContexts: () => + run([...STORES], "readonly", async (tx) => { + const keys = new Set(); + for (const name of STORES) { + // unique index keys = contexts present in this store + await new Promise((resolve, reject) => { + const cur = tx.objectStore(name).index("ctxKey").openKeyCursor(null, "nextunique"); + cur.onsuccess = () => { + const c = cur.result; + if (!c) return resolve(); + keys.add(String(c.key)); + c.continue(); + }; + cur.onerror = () => reject(cur.error); + }); + } + return [...keys]; + }), + clearContext: (ctxKey) => + run([...STORES], "readwrite", async (tx) => { + for (const name of STORES) { + const store = tx.objectStore(name); + for (const k of await req(store.index("ctxKey").getAllKeys(ctxKey))) store.delete(k); + } + }), + }; +} + +let shared: OfflineStore | null = null; + +/** Browser store; falls back to memory when IndexedDB is unavailable (private mode, old Safari). */ +export async function getOfflineStore(): Promise { + if (shared) return shared; + if (typeof indexedDB === "undefined") return (shared = createMemoryStore()); + const store = createIdbStore(); + try { + await store.listContexts(); + shared = store; + } catch { + shared = createMemoryStore(); + } + return shared; +} diff --git a/src/lib/offline/doc-cache.ts b/src/lib/offline/doc-cache.ts new file mode 100644 index 0000000..f18eb2e --- /dev/null +++ b/src/lib/offline/doc-cache.ts @@ -0,0 +1,54 @@ +/** + * Document cache for offline use (lane L7). Constants are mirrored in public/sw.js (static file, + * cannot import TS) — scripts/test-offline-core.ts checks that both stay in sync. + */ + +export const DOC_CACHE = "craftvia-docs-v1"; +export const PAGE_CACHE = "craftvia-pages-v1"; +export const STATIC_CACHE_PREFIX = "craftvia-static-"; + +/** Cache limit for offline documents (LRU). */ +export const DOC_CACHE_MAX_BYTES = 300 * 1024 * 1024; +/** Single documents above this size are never taken offline. */ +export const DOC_MAX_BYTES = 25 * 1024 * 1024; + +/** Categories that are taken offline automatically (Spec §23.2: drawings, manuals, safety documents). */ +export const OFFLINE_DOC_CATEGORIES = ["technical_drawing", "floor_plan", "wiring_diagram", "assembly_instructions", "safety_document"] as const; + +/** URL the mobile app uses to open a document (authorised field route, see L4). */ +export const docUrl = (documentId: string) => `/api/v1/field/documents/${encodeURIComponent(documentId)}`; + +export type CachedDoc = { url: string; size: number; lastUsed: number }; + +/** + * LRU eviction: returns the urls to delete so that the cache (plus `incomingBytes`) fits into + * `maxBytes`. Urls in `keep` (documents of the current bundle) are evicted only after all others. + */ +export function selectEvictions(entries: CachedDoc[], maxBytes: number, incomingBytes = 0, keep: ReadonlySet = new Set()): string[] { + let total = entries.reduce((s, e) => s + e.size, 0) + incomingBytes; + if (total <= maxBytes) return []; + const order = [...entries].sort((a, b) => { + const ka = keep.has(a.url) ? 1 : 0; + const kb = keep.has(b.url) ? 1 : 0; + return ka - kb || a.lastUsed - b.lastUsed; + }); + const out: string[] = []; + for (const e of order) { + if (total <= maxBytes) break; + out.push(e.url); + total -= e.size; + } + return out; +} + +type DocMeta = { id: string; category: string; fileSize: number }; + +/** Documents of the bundle that should be cached offline. */ +export function selectOfflineDocuments(docs: T[]): T[] { + const seen = new Set(); + return docs.filter((d) => { + if (seen.has(d.id)) return false; + seen.add(d.id); + return (OFFLINE_DOC_CATEGORIES as readonly string[]).includes(d.category) && d.fileSize > 0 && d.fileSize <= DOC_MAX_BYTES; + }); +} diff --git a/src/lib/offline/drafts.ts b/src/lib/offline/drafts.ts new file mode 100644 index 0000000..993a143 --- /dev/null +++ b/src/lib/offline/drafts.ts @@ -0,0 +1,42 @@ +import { getOfflineStore } from "./db"; +import { getOfflineState, whenReady } from "./outbox"; +import { ctxKeyOf } from "./types"; + +/** + * Form drafts in IndexedDB (lane L7, Spec §22 "automatische Zwischenspeicherung"): note text, + * report draft … Stored per tenant+user context, deleted with the local data on logout. + * `key` is a stable form id, e.g. `note:` or `report::daily`. + */ + +const metaKey = (key: string) => `draft:${key}`; + +export async function loadDraft(key: string): Promise { + if (!(await whenReady())) return null; + const ctx = getOfflineState().ctx; + if (!ctx) return null; + try { + return await (await getOfflineStore()).getMeta(ctxKeyOf(ctx), metaKey(key)); + } catch { + return null; + } +} + +export async function saveDraft(key: string, value: unknown): Promise { + const ctx = getOfflineState().ctx; + if (!ctx) return; + try { + await (await getOfflineStore()).setMeta(ctxKeyOf(ctx), metaKey(key), value); + } catch { + // quota / private mode — the form keeps working without a draft + } +} + +export async function clearDraft(key: string): Promise { + const ctx = getOfflineState().ctx; + if (!ctx) return; + try { + await (await getOfflineStore()).deleteMeta(ctxKeyOf(ctx), metaKey(key)); + } catch { + /* ignore */ + } +} diff --git a/src/lib/offline/ids.ts b/src/lib/offline/ids.ts new file mode 100644 index 0000000..07a8a26 --- /dev/null +++ b/src/lib/offline/ids.ts @@ -0,0 +1,28 @@ +/** Device-side ids (moved from src/lib/field/client-ops.ts, which re-exports them). */ + +/** RFC 4122 v4 id; falls back to getRandomValues outside secure contexts. */ +export function newClientId(): string { + const c = globalThis.crypto; + if (typeof c?.randomUUID === "function") return c.randomUUID(); + const b = new Uint8Array(16); + c.getRandomValues(b); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join(""); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +} + +const DEVICE_KEY = "craftvia.field.deviceId"; + +export function deviceId(): string { + try { + let id = localStorage.getItem(DEVICE_KEY); + if (!id) { + id = newClientId(); + localStorage.setItem(DEVICE_KEY, id); + } + return id; + } catch { + return "unknown-device"; + } +} diff --git a/src/lib/offline/memory-store.ts b/src/lib/offline/memory-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc891f52345550a7e817ce90ddffae6b63a26f70 GIT binary patch literal 2798 zcmaJ@&2Hm15Z<$&Vqh$=RNyE*C+h^rZVzd(4bTMbAute18f^$mq)Jjr48y=aM4zxv z(iu{cWk=aute@fhd_SY8x-r&+-!uqYxUNk0tM+!Yfa_Kl75<62vBd&z%d%1$@4T@{ zcdgINYY2oLl-ATRPtQ1qn_tXkXD25!IDs2|T4QbO22|ZtSflpRE2F`b2l?Q?AOAw@ zuz+j>{t3aO^A3c|t!lhG9NIS*xI)4&?Baqwd7TIugl8XFss*(V2;D$%PNQ?B!utXgy+ zErRp5&3$XJIL#}gsc&Z>r#{d+`qiI2)Gdw5kpx!(Hh}1OJc}h-)H!wJWQ{Zg+!_ei(_QA?W80S+KMH#&!=^|y^6-P=vly#OGT zQinc1PV-99``hM*Oct2$!UIqMy9H(L(^bk*r0W{oXY49Jq-n~@pMz9IvPw%;d9*^9 z(6>or+d(n$GD#h|UkJH?EW}7^?H@RWjDM4~mW>EqO|?^Cg&t`@RqT7=2I32T%xoVG zL6|VZ$Ecj0h2;|+rfN12`Sv0hG zVvl#o$2*5FjvMNsk7uR;h?E~!IZEH@VyePaPG;(h(H>vD6Z=#hcDU&`{T=%5OVX`t zRx5v}MGQ6k-^6(9CA_a)DvLt&3va-DbS~p_hb9`G?EN!!ave@~3g%jZNE&Lc-#rpY zcpIO(rcz$aKg^SdUwZuw^5C7$XI2w7%`23)!*Eb>FT&BN4~mwWS#>}qph_5^8Gj1` zjK}sq06S#LLZ$#+%+-T+kZXEb0QAo5>U8Fd(;d_bF!T5$+*YyRI(1%J@BUW)NsPY6 Uk3>X>?vnRhgpcU%Vz!(84~6w|qyPW_ literal 0 HcmV?d00001 diff --git a/src/lib/offline/outbox-core.ts b/src/lib/offline/outbox-core.ts new file mode 100644 index 0000000..affe083 --- /dev/null +++ b/src/lib/offline/outbox-core.ts @@ -0,0 +1,350 @@ +import type { SyncOperationInput, SyncOpResult, SyncOpType } from "@/lib/sync/envelope"; +import { CONFLICTING_OPS } from "@/lib/sync/envelope"; +import { ctxKeyOf, type BlobEntry, type OfflineContext, type OutboxEntry, type OutboxError } from "./types"; + +/** + * Pure outbox logic (lane L7, Spec §23.4/§23.5, ARCHITEKTUR §4.6) — no IndexedDB, no fetch. + * Tested in scripts/test-offline-core.ts with the in-memory store. + * + * Rules: + * - ops of one work order are sent strictly in creation order; an op that is still pending + * (backoff, waiting for its upload) blocks the later ops of the SAME order only + * - terminal outcomes (applied / conflict / rejected) never block other ops + * - blob uploads happen before the op that references them (`documentId: "blob:"`) + * - server idMaps (client id → server id) are applied to the payloads of pending ops + * - transient failures (network, internal) → exponential backoff; deterministic ones are final + */ + +export const MAX_BATCH = 50; +export const BLOB_REF_PREFIX = "blob:"; +export const BACKOFF_BASE_MS = 2_000; +export const BACKOFF_MAX_MS = 5 * 60_000; + +/** Ops that change WorkOrder.version on the server (status changes). */ +export const VERSION_CHANGING_OPS: readonly SyncOpType[] = ["session.start", "session.pause", "session.resume", "session.end", "work_order.transition", "report.submit"]; + +const TRANSIENT_CODES = new Set(["internal", "network"]); + +export type QueuedOpInput = { + opType: SyncOpType; + payload: Record; + entityType?: string; + entityId?: string; + baseVersion?: number; +}; + +export const blobRef = (clientId: string) => `${BLOB_REF_PREFIX}${clientId}`; + +/** Client ids of all blob references in a payload (deep). */ +export function blobRefsIn(value: unknown): string[] { + const out = new Set(); + const walk = (v: unknown) => { + if (typeof v === "string") { + if (v.startsWith(BLOB_REF_PREFIX)) out.add(v.slice(BLOB_REF_PREFIX.length)); + } else if (Array.isArray(v)) v.forEach(walk); + else if (v && typeof v === "object") Object.values(v).forEach(walk); + }; + walk(value); + return [...out]; +} + +export function workOrderIdOf(op: Pick): string | null { + const fromPayload = op.payload.workOrderId; + if (typeof fromPayload === "string" && fromPayload) return fromPayload; + return op.entityType === "work_order" && op.entityId ? op.entityId : null; +} + +export const isPending = (e: Pick) => e.status === "queued" || e.status === "sending"; + +/** Exponential backoff with jitter (0.5–1.0 × of the step); `random` injectable for tests. */ +export function backoffMs(attempts: number, random: () => number = Math.random): number { + const step = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempts - 1)); + return Math.round(step * (0.5 + random() * 0.5)); +} + +let seqCounter = 0; +/** Monotonic per device (ms timestamp × 1000 + counter). */ +export function nextSeq(now: Date): number { + seqCounter = (seqCounter + 1) % 1000; + return now.getTime() * 1000 + seqCounter; +} + +export function createEntry(ctx: OfflineContext, op: QueuedOpInput, opts: { clientOpId: string; now: Date; existing: OutboxEntry[]; seq?: number }): OutboxEntry { + const workOrderId = workOrderIdOf(op); + const iso = opts.now.toISOString(); + // A conflict-checked op queued behind our own pending status change of the same order takes its + // baseVersion from that op's server result — otherwise our own chain would always conflict. + const chainedBase = + CONFLICTING_OPS.includes(op.opType) && + !!workOrderId && + opts.existing.some((e) => e.workOrderId === workOrderId && isPending(e) && VERSION_CHANGING_OPS.includes(e.opType)); + return { + clientOpId: opts.clientOpId, + ctxKey: ctxKeyOf(ctx), + tenantId: ctx.tenantId, + userId: ctx.userId, + opType: op.opType, + payload: op.payload, + entityType: op.entityType, + entityId: op.entityId, + baseVersion: op.baseVersion, + chainedBase: chainedBase || undefined, + workOrderId, + seq: opts.seq ?? nextSeq(opts.now), + status: "queued", + attempts: 0, + lastError: null, + clientCreatedAt: iso, + updatedAt: iso, + nextAttemptAt: null, + appliedAt: null, + blobRefs: blobRefsIn(op.payload), + result: null, + }; +} + +const orderKey = (e: OutboxEntry) => e.workOrderId ?? "_global"; +const due = (at: string | null, now: Date) => !at || new Date(at).getTime() <= now.getTime(); + +/** Entries left in `sending` by an interrupted pass (tab closed, crash) go back to the queue. */ +export function recoverSending(ops: OutboxEntry[], now: Date): OutboxEntry[] { + return ops.filter((o) => o.status === "sending").map((o) => ({ ...o, status: "queued" as const, updatedAt: now.toISOString() })); +} + +/** Blobs that must be uploaded before the next batch (referenced by pending ops, not in backoff). */ +export function selectUploads(ops: OutboxEntry[], blobs: BlobEntry[], now: Date): BlobEntry[] { + const byId = new Map(blobs.map((b) => [b.clientId, b])); + const out: BlobEntry[] = []; + const seen = new Set(); + for (const op of [...ops].sort((a, b) => a.seq - b.seq)) { + if (op.status !== "queued") continue; + for (const ref of op.blobRefs) { + const b = byId.get(ref); + if (!b || seen.has(ref) || b.status === "uploaded") continue; + if (b.status === "failed" && b.nextAttemptAt === null) continue; // final failure + if (!due(b.nextAttemptAt, now)) continue; + seen.add(ref); + out.push(b); + } + } + return out; +} + +/** Payload with blob references replaced by their uploaded documentIds; null if an upload is missing. */ +export function resolvePayload(op: OutboxEntry, blobs: BlobEntry[]): Record | null { + if (op.blobRefs.length === 0) return op.payload; + const docs = new Map(blobs.filter((b) => b.status === "uploaded" && b.documentId).map((b) => [b.clientId, b.documentId as string])); + if (!op.blobRefs.every((r) => docs.has(r))) return null; + const walk = (v: unknown): unknown => { + if (typeof v === "string" && v.startsWith(BLOB_REF_PREFIX)) return docs.get(v.slice(BLOB_REF_PREFIX.length)) ?? v; + if (Array.isArray(v)) return v.map(walk); + if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])); + return v; + }; + return walk(op.payload) as Record; +} + +export type Batch = { entries: OutboxEntry[]; operations: SyncOperationInput[] }; + +/** + * Next batch (max `max` ops): in seq order; per work order stop at the first op that cannot be + * sent yet (backoff, missing upload, failed upload) — later ops of other orders still go. + */ +export function selectBatch(ops: OutboxEntry[], blobs: BlobEntry[], now: Date, max = MAX_BATCH): Batch { + const blocked = new Set(); + const entries: OutboxEntry[] = []; + const operations: SyncOperationInput[] = []; + for (const op of [...ops].sort((a, b) => a.seq - b.seq)) { + if (entries.length >= max) break; + const key = orderKey(op); + if (op.status === "sending") { + blocked.add(key); + continue; + } + if (op.status !== "queued") continue; + if (blocked.has(key)) continue; + if (!due(op.nextAttemptAt, now)) { + blocked.add(key); + continue; + } + const payload = resolvePayload(op, blobs); + if (!payload) { + blocked.add(key); + continue; + } + entries.push(op); + operations.push({ + clientOpId: op.clientOpId, + opType: op.opType, + ...(op.entityType ? { entityType: op.entityType } : {}), + ...(op.entityId ? { entityId: op.entityId } : {}), + ...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}), + payload, + clientCreatedAt: op.clientCreatedAt, + }); + } + return { entries, operations }; +} + +/** Replaces client ids by server ids in a payload (deep). The own `clientId` field stays (idempotency). */ +export function applyIdMapToPayload(payload: Record, idMap: Record): Record { + const walk = (v: unknown, key?: string): unknown => { + if (typeof v === "string") return key !== "clientId" && Object.hasOwn(idMap, v) ? idMap[v] : v; + if (Array.isArray(v)) return v.map((x) => walk(x)); + if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, k)])); + return v; + }; + return walk(payload) as Record; +} + +function originalStatus(result: SyncOpResult): "applied" | "conflict" | "rejected" { + if (!result.errorCode) return "applied"; + if (result.errorCode === "conflict" && /original status: conflict/.test(result.message ?? "")) return "conflict"; + return "rejected"; +} + +/** + * Applies the server results of a batch. Returns all changed entries (batch entries and pending + * entries rewritten by idMap / chained baseVersion) plus the merged idMap. + */ +export function applyResults( + ops: OutboxEntry[], + batch: OutboxEntry[], + results: SyncOpResult[], + now: Date, + random: () => number = Math.random, +): { changed: OutboxEntry[]; idMap: Record } { + const iso = now.toISOString(); + const byOp = new Map(results.map((r) => [r.clientOpId, r])); + const changed = new Map(); + const idMap: Record = {}; + const versions = new Map(); + + for (const entry of batch) { + const r = byOp.get(entry.clientOpId); + let next: OutboxEntry; + if (!r) { + next = transient(entry, { code: "internal", message: "missing result" }, now, random); + } else { + const status = r.status === "duplicate" ? originalStatus(r) : r.status; + if (status === "applied") { + next = { ...entry, status: "applied", appliedAt: iso, updatedAt: iso, lastError: null, nextAttemptAt: null, result: r }; + Object.assign(idMap, r.idMap ?? {}); + if (entry.workOrderId && r.entityVersion !== undefined) versions.set(entry.workOrderId, r.entityVersion); + } else if (status === "conflict") { + next = { ...entry, status: "conflict", updatedAt: iso, lastError: { code: "conflict", message: r.message }, nextAttemptAt: null, result: r }; + } else { + const code = r.errorCode ?? "internal"; + next = TRANSIENT_CODES.has(code) + ? transient(entry, { code, message: r.message }, now, random) + : { ...entry, status: "rejected", updatedAt: iso, lastError: { code, message: r.message }, nextAttemptAt: null, result: r }; + } + } + changed.set(next.clientOpId, next); + } + + const inBatch = new Set(batch.map((b) => b.clientOpId)); + const hasIds = Object.keys(idMap).length > 0; + for (const op of ops) { + if (inBatch.has(op.clientOpId) || !isPending(op)) continue; + let next = op; + if (hasIds) { + const payload = applyIdMapToPayload(op.payload, idMap); + if (JSON.stringify(payload) !== JSON.stringify(op.payload)) next = { ...next, payload, updatedAt: iso }; + } + if (next.chainedBase && next.workOrderId && versions.has(next.workOrderId)) { + next = { ...next, baseVersion: versions.get(next.workOrderId), updatedAt: iso }; + } + if (next !== op) changed.set(next.clientOpId, next); + } + // a chained op in the same batch already went out with the old baseVersion — nothing to do there + return { changed: [...changed.values()], idMap }; +} + +function transient(entry: OutboxEntry, error: OutboxError, now: Date, random: () => number): OutboxEntry { + const attempts = entry.attempts + 1; + return { + ...entry, + status: "queued", + attempts, + lastError: error, + updatedAt: now.toISOString(), + nextAttemptAt: new Date(now.getTime() + backoffMs(attempts, random)).toISOString(), + }; +} + +/** Whole batch failed on transport level (offline, 5xx, 401). */ +export function applyTransportFailure(batch: OutboxEntry[], error: OutboxError, now: Date, random: () => number = Math.random): OutboxEntry[] { + return batch.map((e) => transient(e, error, now, random)); +} + +/** Manual "erneut versuchen": transient entries retry now; rejected ones get a fresh clientOpId (the server stored the old outcome). */ +export function retryEntry(entry: OutboxEntry, now: Date, newClientOpId: string): OutboxEntry | null { + if (entry.status === "applied" || entry.status === "conflict" || entry.status === "sending") return null; + const base = { ...entry, status: "queued" as const, nextAttemptAt: null, updatedAt: now.toISOString(), acknowledged: undefined }; + return entry.status === "rejected" ? { ...base, clientOpId: newClientOpId, attempts: 0, lastError: null, result: null } : base; +} + +/** Only rejected entries can be discarded (conflicts stay listed; they are reviewed in the office). */ +export const canDiscard = (e: Pick) => e.status === "rejected"; + +export type OutboxSummary = { + pendingOps: number; + pendingUploads: number; + uploadBytes: number; + failedUploads: number; + conflicts: number; + rejected: number; + /** oldest pending op (ISO) */ + oldestPending: string | null; + waitingForAuth: boolean; +}; + +export function summarize(ops: OutboxEntry[], blobs: BlobEntry[]): OutboxSummary { + const pending = ops.filter(isPending); + const openBlobs = blobs.filter((b) => b.status !== "uploaded"); + return { + pendingOps: pending.length, + pendingUploads: openBlobs.filter((b) => b.status !== "failed" || b.nextAttemptAt !== null).length, + uploadBytes: openBlobs.reduce((s, b) => s + b.size, 0), + failedUploads: openBlobs.filter((b) => b.status === "failed" && b.nextAttemptAt === null).length, + conflicts: ops.filter((o) => o.status === "conflict" && !o.acknowledged).length, + rejected: ops.filter((o) => o.status === "rejected" && !o.acknowledged).length, + oldestPending: pending.length ? pending.reduce((m, o) => (o.clientCreatedAt < m ? o.clientCreatedAt : m), pending[0].clientCreatedAt) : null, + waitingForAuth: pending.some((o) => o.lastError?.code === "unauthorized"), + }; +} + +/** Entries that can be removed: applied before the last bundle pull, acknowledged rejections. */ +export function prunable(ops: OutboxEntry[], bundleSyncedAt: string | null): string[] { + return ops + .filter((o) => (o.status === "applied" && !!o.appliedAt && !!bundleSyncedAt && o.appliedAt <= bundleSyncedAt) || (o.status === "rejected" && o.acknowledged)) + .map((o) => o.clientOpId); +} + +/** i18n key (messages offline.problem.*) explaining a failed entry in plain language. */ +export function problemKey(e: Pick): string { + if (e.status === "conflict") { + if (e.opType === "work_order.transition") return "conflictTransition"; + if (e.opType === "report.submit") return "conflictReport"; + return "conflict"; + } + switch (e.lastError?.code) { + case "not_found": + return "notFound"; + case "forbidden": + return "forbidden"; + case "blocked": + return "blocked"; + case "invalid": + return "invalid"; + case "upload": + return "upload"; + case "unauthorized": + return "unauthorized"; + case "network": + return "network"; + default: + return "internal"; + } +} diff --git a/src/lib/offline/outbox.ts b/src/lib/offline/outbox.ts new file mode 100644 index 0000000..6dd9f40 --- /dev/null +++ b/src/lib/offline/outbox.ts @@ -0,0 +1,425 @@ +import type { SyncOpResult, SyncResponse } from "@/lib/sync/envelope"; +import { getOfflineStore } from "./db"; +import { deviceId, newClientId } from "./ids"; +import { canDiscard, isPending, prunable, retryEntry, summarize, type OutboxSummary, type QueuedOpInput } from "./outbox-core"; +import { enqueueBlob, enqueueOp, runSyncPass, type BlobInput, type Transport, type UploadOutcome } from "./sync-engine"; +import { clearUserCaches, META_LAST_PULL, pullBundle, requestPersistence, storageInfo, type StorageInfo } from "./prefetch"; +import { DEFAULT_OFFLINE_MAX_DAYS } from "./bundle-core"; +import { ctxKeyOf, type BlobEntry, type OfflineContext, type OfflineStore, type OutboxEntry } from "./types"; + +/** + * Browser outbox (lane L7, Spec §23.4, ARCHITEKTUR §4.6): IndexedDB store + sync loop. + * `submitOp` (re-exported by src/lib/field/client-ops.ts, signature unchanged) stores every op + * locally first, then — when online — runs a sync pass right away and returns the server result. + * Offline it returns immediately with `message: "queued"` (see `isQueued`). + * + * Triggers: online event, every 30 s, tab becomes visible, "Jetzt synchronisieren", Background + * Sync message from the service worker (Chromium only). + */ + +export const SYNC_INTERVAL_MS = 30_000; +const PULL_MIN_INTERVAL_MS = 5 * 60_000; +const SUBMIT_WAIT_MS = 15_000; +const LAST_CTX_KEY = "craftvia.offline.lastContext"; + +export type OfflineState = { + ready: boolean; + ctx: OfflineContext | null; + maxDays: number; + online: boolean; + syncing: boolean; + summary: OutboxSummary; + lastSyncAt: string | null; + lastPullAt: string | null; + lastError: null | "network" | "unauthorized" | "internal"; + storage: StorageInfo; + /** upload progress per blob client id (0–100) */ + uploads: Record; +}; + +const EMPTY_SUMMARY: OutboxSummary = { pendingOps: 0, pendingUploads: 0, uploadBytes: 0, failedUploads: 0, conflicts: 0, rejected: 0, oldestPending: null, waitingForAuth: false }; + +let state: OfflineState = { + ready: false, + ctx: null, + maxDays: DEFAULT_OFFLINE_MAX_DAYS, + online: true, + syncing: false, + summary: EMPTY_SUMMARY, + lastSyncAt: null, + lastPullAt: null, + lastError: null, + storage: null, + uploads: {}, +}; +const listeners = new Set<() => void>(); +let readyWaiters: Array<() => void> = []; + +function setState(patch: Partial) { + state = { ...state, ...patch }; + listeners.forEach((l) => l()); +} + +export const getOfflineState = () => state; +export const getServerOfflineState = () => state; +export function subscribeOffline(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Resolves once the mobile shell configured the context (or after `timeoutMs`, then false). */ +export function whenReady(timeoutMs = 3000): Promise { + if (state.ready) return Promise.resolve(true); + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), timeoutMs); + readyWaiters.push(() => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +async function requireRuntime(): Promise<{ store: OfflineStore; ctx: OfflineContext } | null> { + if (!state.ctx) return null; + return { store: await getOfflineStore(), ctx: state.ctx }; +} + +// ---------------------------------------------------------------- transport (fetch / XHR) + +const uploadProgress = new Map void>(); + +const browserTransport: Transport = { + async sendBatch(device, operations) { + let res: Response; + try { + res = await fetch("/api/v1/sync", { + method: "POST", + credentials: "same-origin", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceId: device, operations }), + }); + } catch { + return { ok: false, error: "network" }; + } + if (res.status === 401) return { ok: false, error: "unauthorized" }; + if (!res.ok) return { ok: false, error: "internal" }; + try { + return { ok: true, response: (await res.json()) as SyncResponse }; + } catch { + return { ok: false, error: "internal" }; + } + }, + upload(blob: BlobEntry) { + return new Promise((resolve) => { + const form = new FormData(); + form.append("clientId", blob.clientId); + form.append("workOrderId", blob.workOrderId); + form.append("kind", blob.kind); + form.append("file", blob.blob, blob.fileName); + if (blob.preview) form.append("preview", blob.preview, `thumb-${blob.fileName}`); + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/v1/uploads"); + xhr.withCredentials = true; + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable) return; + const percent = Math.round((e.loaded / e.total) * 100); + uploadProgress.get(blob.clientId)?.(percent); + setState({ uploads: { ...state.uploads, [blob.clientId]: percent } }); + }; + xhr.onerror = () => resolve({ ok: false, error: "network" }); + xhr.onload = () => { + const { [blob.clientId]: _done, ...rest } = state.uploads; + void _done; + setState({ uploads: rest }); + if (xhr.status === 200 || xhr.status === 201) { + try { + resolve({ ok: true, documentId: (JSON.parse(xhr.responseText) as { documentId: string }).documentId }); + } catch { + resolve({ ok: false, error: "internal" }); + } + return; + } + if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" }); + if (xhr.status === 400 || xhr.status === 413) return resolve({ ok: false, error: "invalid" }); + if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" }); + if (xhr.status === 404) return resolve({ ok: false, error: "not_found" }); + resolve({ ok: false, error: xhr.status === 0 ? "network" : "internal" }); + }; + xhr.send(form); + }); + }, +}; + +// ---------------------------------------------------------------- configuration / lifecycle + +/** + * Called by the mobile shell (OfflineRuntimeClient) with the signed-in tenant/user. Removes local + * data of other contexts that has nothing left to send, and the page/document caches when the + * device changed hands (other user or tenant). + */ +export async function configureOffline(ctx: OfflineContext, opts: { maxDays: number }): Promise { + if (state.ctx && ctxKeyOf(state.ctx) === ctxKeyOf(ctx) && state.maxDays === opts.maxDays) return; + const store = await getOfflineStore(); + const key = ctxKeyOf(ctx); + try { + const previous = localStorage.getItem(LAST_CTX_KEY); + if (previous && previous !== key) await clearUserCaches(); + localStorage.setItem(LAST_CTX_KEY, key); + } catch { + // storage blocked — caches stay (they are cleared on logout) + } + for (const other of await store.listContexts()) { + if (other === key) continue; + const [ops, blobs] = await Promise.all([store.listOps(other), store.listBlobs(other)]); + if (!ops.some(isPending) && blobs.every((b) => b.status === "uploaded")) await store.clearContext(other); + } + setState({ + ready: true, + ctx, + maxDays: opts.maxDays, + online: typeof navigator === "undefined" ? true : navigator.onLine, + lastPullAt: await store.getMeta(key, META_LAST_PULL), + lastSyncAt: await store.getMeta(key, "lastSyncAt"), + }); + readyWaiters.forEach((w) => w()); + readyWaiters = []; + await refreshSummary(); + void requestPersistence(); +} + +export async function refreshSummary(): Promise { + const rt = await requireRuntime(); + if (!rt) return; + const key = ctxKeyOf(rt.ctx); + const [ops, blobs, storage] = await Promise.all([rt.store.listOps(key), rt.store.listBlobs(key), storageInfo()]); + setState({ summary: summarize(ops, blobs), storage }); +} + +let running: Promise | null = null; +let rerun = false; + +/** Runs a sync pass (single flight per tab, serialised across tabs via Web Locks when available). */ +export function syncNow(opts: { pull?: boolean } = {}): Promise { + if (running) { + rerun = true; + return running; + } + running = (async () => { + do { + rerun = false; + await withLock(() => pass(opts.pull === true)); + } while (rerun); + })().finally(() => { + running = null; + }); + return running; +} + +async function withLock(fn: () => Promise) { + const locks = typeof navigator !== "undefined" ? (navigator as Navigator & { locks?: LockManager }).locks : undefined; + if (locks?.request && state.ctx) await locks.request(`craftvia-sync:${ctxKeyOf(state.ctx)}`, fn); + else await fn(); +} + +async function pass(forcePull: boolean) { + const rt = await requireRuntime(); + if (!rt) return; + const key = ctxKeyOf(rt.ctx); + setState({ syncing: true, online: navigator.onLine }); + try { + const result = await runSyncPass({ store: rt.store, ctx: rt.ctx, transport: browserTransport, deviceId: deviceId() }); + const now = new Date().toISOString(); + if (result.stopped) { + setState({ lastError: result.stopped }); + } else { + await rt.store.setMeta(key, "lastSyncAt", now); + setState({ lastSyncAt: now, lastError: null }); + const lastPull = state.lastPullAt ? new Date(state.lastPullAt).getTime() : 0; + if (forcePull || result.sent > 0 || result.uploaded > 0 || Date.now() - lastPull > PULL_MIN_INTERVAL_MS) { + const pulled = await pullBundle(rt.store, rt.ctx); + if (pulled.ok) setState({ lastPullAt: pulled.syncedAt }); + else if (pulled.error !== "network") setState({ lastError: pulled.error }); + } + } + const ops = await rt.store.listOps(key); + for (const id of prunable(ops, state.lastPullAt)) await rt.store.deleteOp(id); + // files that never got an op (form closed before saving) are dropped after a day + const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + for (const b of await rt.store.listBlobs(key)) { + if (!b.opClientOpId && b.createdAt < dayAgo && !ops.some((o) => o.blobRefs.includes(b.clientId))) await rt.store.deleteBlob(b.clientId); + } + } finally { + setState({ syncing: false }); + await refreshSummary(); + } +} + +/** Starts the automatic sync triggers; returns the cleanup function. */ +export function startSyncLoop(): () => void { + const onOnline = () => { + setState({ online: true }); + void syncNow(); + }; + const onOffline = () => setState({ online: false }); + const onVisible = () => { + if (document.visibilityState === "visible" && navigator.onLine) void syncNow(); + }; + const onMessage = (e: MessageEvent) => { + if ((e.data as { type?: string } | null)?.type === "craftvia:sync") void syncNow(); + }; + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + document.addEventListener("visibilitychange", onVisible); + navigator.serviceWorker?.addEventListener("message", onMessage); + const timer = setInterval(() => { + if (navigator.onLine) void syncNow(); + }, SYNC_INTERVAL_MS); + if (navigator.onLine) void syncNow(); + return () => { + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + document.removeEventListener("visibilitychange", onVisible); + navigator.serviceWorker?.removeEventListener("message", onMessage); + clearInterval(timer); + }; +} + +function registerBackgroundSync() { + navigator.serviceWorker?.ready + .then((reg) => (reg as ServiceWorkerRegistration & { sync?: { register(tag: string): Promise } }).sync?.register("craftvia-outbox")) + .catch(() => undefined); +} + +// ---------------------------------------------------------------- submit API + +type SubmitInput = { opType: QueuedOpInput["opType"]; payload: unknown; entityType?: string; entityId?: string; baseVersion?: number }; + +/** Result for an op that is stored locally and will be sent later. */ +export const queuedResult = (clientOpId: string): SyncOpResult => ({ clientOpId, status: "applied", message: "queued" }); +export const isQueued = (r: SyncOpResult) => r.status === "applied" && r.message === "queued"; + +async function directSubmit(op: SubmitInput, clientOpId: string): Promise { + const outcome = await browserTransport.sendBatch(deviceId(), [ + { clientOpId, opType: op.opType, payload: op.payload as Record, entityType: op.entityType, entityId: op.entityId, baseVersion: op.baseVersion, clientCreatedAt: new Date().toISOString() }, + ]); + if (!outcome.ok) return { clientOpId, status: "rejected", errorCode: outcome.error === "unauthorized" ? "forbidden" : "internal", message: outcome.error === "network" ? "network" : undefined }; + return outcome.response.results[0] ?? { clientOpId, status: "rejected", errorCode: "internal" }; +} + +export async function submitOp(op: SubmitInput): Promise { + const clientOpId = newClientId(); + const rt = (await whenReady(1500)) ? await requireRuntime() : null; + // outside the mobile shell (no context): behave like before — send immediately + if (!rt) return directSubmit(op, clientOpId); + + const key = ctxKeyOf(rt.ctx); + await enqueueOp(rt.store, rt.ctx, { ...op, payload: op.payload as Record }, clientOpId); + registerBackgroundSync(); + await refreshSummary(); + if (!navigator.onLine) return queuedResult(clientOpId); + + await Promise.race([syncNow(), new Promise((r) => setTimeout(r, SUBMIT_WAIT_MS))]); + const entry = (await rt.store.listOps(key)).find((e) => e.clientOpId === clientOpId); + if (!entry || isPending(entry)) return queuedResult(clientOpId); + if (entry.status === "applied") return entry.result ?? { clientOpId, status: "applied" }; + if (entry.status === "rejected") { + // shown inline by the calling form → not repeated in the error list of /m/sync + await rt.store.putOp({ ...entry, acknowledged: true }); + await refreshSummary(); + } + return entry.result ?? { clientOpId, status: "rejected", errorCode: entry.lastError?.code === "upload" ? "invalid" : "internal", message: entry.lastError?.message }; +} + +/** + * Queues a photo/voice note for upload; returns the reference for the op payload (`documentId`). + * Outside the mobile shell the file is uploaded directly and the real documentId is returned. + */ +export async function queueBlob(input: Omit & { onProgress?: (percent: number) => void }): Promise<{ ok: true; documentId: string } | { ok: false; error: string }> { + const clientId = newClientId(); + const rt = (await whenReady(1500)) ? await requireRuntime() : null; + if (!rt) { + if (input.onProgress) uploadProgress.set(clientId, input.onProgress); + const up = await browserTransport.upload({ clientId, workOrderId: input.workOrderId, kind: input.kind, blob: input.blob, preview: input.preview ?? null, fileName: input.fileName } as BlobEntry); + uploadProgress.delete(clientId); + return up.ok ? { ok: true, documentId: up.documentId } : { ok: false, error: up.error }; + } + if (input.onProgress) { + const cb = input.onProgress; + uploadProgress.set(clientId, (p) => { + cb(p); + if (p >= 100) uploadProgress.delete(clientId); + }); + } + try { + return { ok: true, documentId: await enqueueBlob(rt.store, rt.ctx, { ...input, clientId }) }; + } catch { + // quota exceeded + return { ok: false, error: "storage" }; + } +} + +// ---------------------------------------------------------------- /m/sync actions + +export type OutboxListing = { ops: OutboxEntry[]; blobs: BlobEntry[] }; + +export async function listOutbox(): Promise { + const rt = await requireRuntime(); + if (!rt) return { ops: [], blobs: [] }; + const key = ctxKeyOf(rt.ctx); + return { ops: await rt.store.listOps(key), blobs: await rt.store.listBlobs(key) }; +} + +export async function retryOp(clientOpId: string): Promise { + const rt = await requireRuntime(); + if (!rt) return; + const entry = (await rt.store.listOps(ctxKeyOf(rt.ctx))).find((e) => e.clientOpId === clientOpId); + const next = entry ? retryEntry(entry, new Date(), newClientId()) : null; + if (!entry || !next) return; + if (next.clientOpId !== entry.clientOpId) await rt.store.deleteOp(entry.clientOpId); + await rt.store.putOp(next); + // a failed upload of this op gets a fresh chance as well + for (const ref of next.blobRefs) { + const blob = await rt.store.getBlob(ref); + if (blob && blob.status === "failed") await rt.store.putBlob({ ...blob, status: "pending", nextAttemptAt: null, lastError: null, opClientOpId: next.clientOpId }); + } + await refreshSummary(); + if (navigator.onLine) void syncNow(); +} + +/** Discards a rejected entry (after confirmation in the UI) including its local file. */ +export async function discardOp(clientOpId: string): Promise { + const rt = await requireRuntime(); + if (!rt) return false; + const entry = (await rt.store.listOps(ctxKeyOf(rt.ctx))).find((e) => e.clientOpId === clientOpId); + if (!entry || !canDiscard(entry)) return false; + await rt.store.deleteOp(entry.clientOpId); + for (const ref of entry.blobRefs) await rt.store.deleteBlob(ref); + await refreshSummary(); + return true; +} + +/** Hides a conflict notice on this device (the conflict itself stays in the office list). */ +export async function acknowledgeConflict(clientOpId: string): Promise { + const rt = await requireRuntime(); + if (!rt) return; + const entry = (await rt.store.listOps(ctxKeyOf(rt.ctx))).find((e) => e.clientOpId === clientOpId); + if (entry?.status === "conflict") await rt.store.putOp({ ...entry, acknowledged: true }); + await refreshSummary(); +} + +/** Number of ops/uploads that would be lost when the local data of this context is deleted. */ +export async function unsentCount(): Promise { + const { ops, blobs } = await listOutbox(); + return ops.filter(isPending).length + blobs.filter((b) => b.status !== "uploaded" && !ops.some((o) => o.blobRefs.includes(b.clientId) && isPending(o))).length; +} + +/** Deletes all local data of the current context (logout, "lokale Daten zurücksetzen"). */ +export async function clearLocalData(opts: { refetch?: boolean } = {}): Promise { + const rt = await requireRuntime(); + if (rt) await rt.store.clearContext(ctxKeyOf(rt.ctx)); + await clearUserCaches(); + setState({ lastPullAt: null, lastSyncAt: null, lastError: null }); + await refreshSummary(); + if (opts.refetch && navigator.onLine) void syncNow({ pull: true }); +} diff --git a/src/lib/offline/prefetch.ts b/src/lib/offline/prefetch.ts new file mode 100644 index 0000000..a195ada --- /dev/null +++ b/src/lib/offline/prefetch.ts @@ -0,0 +1,122 @@ +import { selectOfflineOrders, toBundleRecords } from "./bundle-core"; +import { DOC_CACHE, DOC_CACHE_MAX_BYTES, docUrl, PAGE_CACHE, selectEvictions, selectOfflineDocuments, type CachedDoc } from "./doc-cache"; +import { ctxKeyOf, type BundleOrderData, type OfflineContext, type OfflineStore } from "./types"; + +/** + * "Für offline speichern" (lane L7, Spec §23.2): pulls GET /api/v1/field/bundle, keeps today's, + * the next 3 days' and all running orders in IndexedDB and caches their drawings / manuals / + * safety documents (≤ 25 MB) in the document cache (LRU, 300 MB) that the service worker serves. + */ + +export const META_LAST_PULL = "lastPullAt"; + +export type PullResult = { ok: true; orders: number; documents: number; syncedAt: string } | { ok: false; error: "network" | "unauthorized" | "internal" }; + +export async function pullBundle(store: OfflineStore, ctx: OfflineContext): Promise { + let res: Response; + try { + res = await fetch("/api/v1/field/bundle", { credentials: "same-origin", cache: "no-store", headers: { Accept: "application/json" } }); + } catch { + return { ok: false, error: "network" }; + } + if (res.status === 401) return { ok: false, error: "unauthorized" }; + if (!res.ok || res.redirected) return { ok: false, error: "internal" }; + let body: { serverTime: string; orders: BundleOrderData[] }; + try { + body = (await res.json()) as typeof body; + } catch { + return { ok: false, error: "internal" }; + } + const key = ctxKeyOf(ctx); + const selected = selectOfflineOrders(body.orders, new Date()); + await store.replaceBundle(key, toBundleRecords(key, selected, body.serverTime)); + await store.setMeta(key, META_LAST_PULL, body.serverTime); + const docs = selectOfflineDocuments(selected.flatMap((o) => o.documents)); + const documents = await cacheDocuments(docs).catch(() => 0); + return { ok: true, orders: selected.length, documents, syncedAt: body.serverTime }; +} + +const pathOf = (u: string) => new URL(u, location.origin).pathname; + +async function cachedEntries(cache: Cache): Promise { + const out: CachedDoc[] = []; + for (const request of await cache.keys()) { + const hit = await cache.match(request); + out.push({ url: pathOf(request.url), size: Number(hit?.headers.get("x-craftvia-size") ?? 0), lastUsed: Number(hit?.headers.get("x-craftvia-used") ?? 0) }); + } + return out; +} + +/** Downloads missing documents into the SW document cache; returns the number of cached bundle documents. */ +export async function cacheDocuments(docs: Array<{ id: string; fileSize: number }>): Promise { + if (typeof caches === "undefined") return 0; + const cache = await caches.open(DOC_CACHE); + let entries = await cachedEntries(cache); + const keep = new Set(docs.map((d) => docUrl(d.id))); + let cached = 0; + for (const doc of docs) { + const url = docUrl(doc.id); + if (entries.some((e) => e.url === url)) { + cached++; + continue; + } + for (const evict of selectEvictions(entries, DOC_CACHE_MAX_BYTES, doc.fileSize, keep)) { + await cache.delete(evict); + entries = entries.filter((e) => e.url !== evict); + } + try { + const res = await fetch(url, { credentials: "same-origin", cache: "no-store" }); + if (!res.ok || res.redirected) continue; + const blob = await res.blob(); + const headers = new Headers(); + for (const h of ["content-type", "content-disposition", "x-content-type-options"]) { + const v = res.headers.get(h); + if (v) headers.set(h, v); + } + headers.set("x-craftvia-size", String(blob.size)); + headers.set("x-craftvia-used", String(Date.now())); + await cache.put(url, new Response(blob, { status: 200, headers })); + entries.push({ url, size: blob.size, lastUsed: Date.now() }); + cached++; + } catch { + // offline or quota exceeded — try again on the next sync + } + } + return cached; +} + +/** Ids of documents available offline (for the "offline verfügbar" hint). */ +export async function cachedDocumentIds(): Promise> { + if (typeof caches === "undefined") return new Set(); + const cache = await caches.open(DOC_CACHE); + const prefix = docUrl(""); + return new Set((await cache.keys()).map((r) => pathOf(r.url)).filter((p) => p.startsWith(prefix)).map((p) => decodeURIComponent(p.slice(prefix.length)))); +} + +/** Pages and documents of the previous user/tenant must not stay on a shared device. */ +export async function clearUserCaches(): Promise { + if (typeof caches === "undefined") return; + await Promise.all([caches.delete(PAGE_CACHE), caches.delete(DOC_CACHE)]); +} + +export type StorageInfo = { usage: number; quota: number; persisted: boolean } | null; + +export async function storageInfo(): Promise { + if (typeof navigator === "undefined" || !navigator.storage?.estimate) return null; + try { + const [est, persisted] = await Promise.all([navigator.storage.estimate(), navigator.storage.persisted?.() ?? Promise.resolve(false)]); + return { usage: est.usage ?? 0, quota: est.quota ?? 0, persisted }; + } catch { + return null; + } +} + +/** Asks the browser not to evict offline data under storage pressure (granted silently or not at all). */ +export async function requestPersistence(): Promise { + try { + if (!navigator.storage?.persist) return false; + return (await navigator.storage.persisted?.()) || (await navigator.storage.persist()); + } catch { + return false; + } +} diff --git a/src/lib/offline/read.ts b/src/lib/offline/read.ts new file mode 100644 index 0000000..6fc0875 --- /dev/null +++ b/src/lib/offline/read.ts @@ -0,0 +1,31 @@ +import { getOfflineStore } from "./db"; +import { buildOrderView, isBundleStale, type OrderView } from "./bundle-core"; +import { getOfflineState, syncNow, whenReady } from "./outbox"; +import { ctxKeyOf } from "./types"; + +/** + * Data access of the mobile client views (lane L7, Spec §23.2): online a fresh bundle is pulled + * first (server data), offline the local bundle is used. Either way the view contains the own + * unsent changes (optimistic), so the user sees what they entered. + */ + +export type OrdersRead = { ready: boolean; orders: OrderView[]; syncedAt: string | null; stale: boolean }; + +export async function readOrders(opts: { refresh?: boolean } = {}): Promise { + if (!(await whenReady())) return { ready: false, orders: [], syncedAt: null, stale: true }; + if (opts.refresh && navigator.onLine) await syncNow({ pull: true }).catch(() => undefined); + const { ctx, maxDays, lastPullAt } = getOfflineState(); + if (!ctx) return { ready: false, orders: [], syncedAt: null, stale: true }; + const store = await getOfflineStore(); + const key = ctxKeyOf(ctx); + const [records, ops] = await Promise.all([store.listBundle(key), store.listOps(key)]); + const orders = records + .map((r) => buildOrderView(r, ops)) + .sort((a, b) => (a.plannedStart ?? "9999").localeCompare(b.plannedStart ?? "9999") || a.number.localeCompare(b.number)); + return { ready: true, orders, syncedAt: lastPullAt, stale: isBundleStale(lastPullAt, new Date(), maxDays) }; +} + +export async function readOrder(workOrderId: string, opts: { refresh?: boolean } = {}): Promise<{ read: OrdersRead; order: OrderView | null }> { + const read = await readOrders(opts); + return { read, order: read.orders.find((o) => o.id === workOrderId) ?? null }; +} diff --git a/src/lib/offline/sync-engine.ts b/src/lib/offline/sync-engine.ts new file mode 100644 index 0000000..e070463 --- /dev/null +++ b/src/lib/offline/sync-engine.ts @@ -0,0 +1,174 @@ +import type { SyncOperationInput, SyncResponse } from "@/lib/sync/envelope"; +import { ctxKeyOf, type BlobEntry, type BlobKind, type OfflineContext, type OfflineStore, type OutboxEntry, type OutboxError } from "./types"; +import { + applyResults, + applyTransportFailure, + backoffMs, + blobRef, + createEntry, + MAX_BATCH, + recoverSending, + selectBatch, + selectUploads, + type QueuedOpInput, +} from "./outbox-core"; + +/** + * One synchronisation pass over the outbox of a context (lane L7). Environment-free: storage and + * network are injected, so the same code runs in the browser (outbox.ts: IndexedDB + fetch) and in + * the tests (memory store + in-process applyOperations). + */ + +export type BatchOutcome = { ok: true; response: SyncResponse } | { ok: false; error: "network" | "unauthorized" | "internal" }; +export type UploadOutcome = { ok: true; documentId: string } | { ok: false; error: "network" | "unauthorized" | "invalid" | "forbidden" | "not_found" | "internal" }; + +export type Transport = { + sendBatch(deviceId: string, operations: SyncOperationInput[]): Promise; + upload(blob: BlobEntry): Promise; +}; + +export type EngineDeps = { + store: OfflineStore; + ctx: OfflineContext; + transport: Transport; + deviceId: string; + now?: () => Date; + random?: () => number; + maxBatch?: number; + /** safety limit of batch rounds per pass */ + maxRounds?: number; +}; + +export type PassResult = { + rounds: number; + sent: number; + applied: number; + conflicts: number; + rejected: number; + uploaded: number; + uploadFailed: number; + /** transport-level stop reason of the pass */ + stopped: null | "network" | "unauthorized" | "internal"; + /** client id → server id of this pass */ + idMap: Record; +}; + +export async function enqueueOp(store: OfflineStore, ctx: OfflineContext, op: QueuedOpInput, clientOpId: string, now = new Date()): Promise { + const existing = await store.listOps(ctxKeyOf(ctx)); + const entry = createEntry(ctx, op, { clientOpId, now, existing }); + await store.putOp(entry); + // link queued blobs to the op waiting for them + for (const ref of entry.blobRefs) { + const blob = await store.getBlob(ref); + if (!blob || blob.ctxKey !== entry.ctxKey) continue; + // a new op on a finally failed upload gets one fresh upload attempt + const rearm = blob.status === "failed" && blob.nextAttemptAt === null ? { status: "pending" as const, lastError: null, attempts: 0 } : {}; + await store.putBlob({ ...blob, ...rearm, opClientOpId: entry.clientOpId, updatedAt: now.toISOString() }); + } + return entry; +} + +export type BlobInput = { clientId: string; workOrderId: string; kind: BlobKind; blob: Blob; preview?: Blob | null; fileName: string }; + +/** Stores a blob for upload; returns the reference to put into the op payload (`documentId`). */ +export async function enqueueBlob(store: OfflineStore, ctx: OfflineContext, input: BlobInput, now = new Date()): Promise { + const iso = now.toISOString(); + await store.putBlob({ + clientId: input.clientId, + ctxKey: ctxKeyOf(ctx), + tenantId: ctx.tenantId, + userId: ctx.userId, + workOrderId: input.workOrderId, + kind: input.kind, + blob: input.blob, + preview: input.preview ?? null, + fileName: input.fileName, + size: input.blob.size + (input.preview?.size ?? 0), + status: "pending", + documentId: null, + attempts: 0, + lastError: null, + nextAttemptAt: null, + opClientOpId: null, + createdAt: iso, + updatedAt: iso, + }); + return blobRef(input.clientId); +} + +const FINAL_UPLOAD_ERRORS = new Set(["invalid", "forbidden", "not_found"]); + +export async function runSyncPass(deps: EngineDeps): Promise { + const { store, ctx, transport, deviceId } = deps; + const now = deps.now ?? (() => new Date()); + const random = deps.random ?? Math.random; + const key = ctxKeyOf(ctx); + const res: PassResult = { rounds: 0, sent: 0, applied: 0, conflicts: 0, rejected: 0, uploaded: 0, uploadFailed: 0, stopped: null, idMap: {} }; + + for (const e of recoverSending(await store.listOps(key), now())) await store.putOp(e); + + const maxRounds = deps.maxRounds ?? 20; + while (res.rounds < maxRounds) { + const ops = await store.listOps(key); + const blobs = await store.listBlobs(key); + + // 1. uploads before the ops that reference them + for (const blob of selectUploads(ops, blobs, now())) { + await store.putBlob({ ...blob, status: "uploading", updatedAt: now().toISOString() }); + const up = await transport.upload(blob); + const iso = now().toISOString(); + if (up.ok) { + await store.putBlob({ ...blob, status: "uploaded", documentId: up.documentId, lastError: null, nextAttemptAt: null, updatedAt: iso }); + res.uploaded++; + continue; + } + const attempts = blob.attempts + 1; + const error: OutboxError = { code: up.error === "unauthorized" || up.error === "network" ? up.error : "upload", message: up.error }; + if (FINAL_UPLOAD_ERRORS.has(up.error)) { + await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: null, updatedAt: iso }); + res.uploadFailed++; + // the waiting op can never be sent → rejected with a clear reason + for (const op of ops.filter((o) => o.status === "queued" && o.blobRefs.includes(blob.clientId))) { + await store.putOp({ ...op, status: "rejected", lastError: { code: "upload", message: up.error }, updatedAt: iso, nextAttemptAt: null }); + res.rejected++; + } + } else { + await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: new Date(now().getTime() + backoffMs(attempts, random)).toISOString(), updatedAt: iso }); + if (up.error === "network" || up.error === "unauthorized") { + res.stopped = up.error; + return res; + } + } + } + + // 2. next batch + const current = await store.listOps(key); + const batch = selectBatch(current, await store.listBlobs(key), now(), deps.maxBatch ?? MAX_BATCH); + if (batch.entries.length === 0) break; + res.rounds++; + for (const e of batch.entries) await store.putOp({ ...e, status: "sending", updatedAt: now().toISOString() }); + + const outcome = await transport.sendBatch(deviceId, batch.operations); + if (!outcome.ok) { + const failed = applyTransportFailure(batch.entries, { code: outcome.error === "internal" ? "internal" : outcome.error }, now(), random); + for (const e of failed) await store.putOp(e); + res.stopped = outcome.error; + return res; + } + + res.sent += batch.entries.length; + const { changed, idMap } = applyResults(current, batch.entries, outcome.response.results, now(), random); + Object.assign(res.idMap, idMap); + for (const e of changed) { + await store.putOp(e); + if (!batch.entries.some((b) => b.clientOpId === e.clientOpId)) continue; + if (e.status === "applied") { + res.applied++; + // uploaded bytes are no longer needed once the referencing op is applied + for (const ref of e.blobRefs) await store.deleteBlob(ref); + } else if (e.status === "conflict") res.conflicts++; + else if (e.status === "rejected") res.rejected++; + } + } + return res; +} diff --git a/src/lib/offline/types.ts b/src/lib/offline/types.ts new file mode 100644 index 0000000..3828eb3 --- /dev/null +++ b/src/lib/offline/types.ts @@ -0,0 +1,169 @@ +import type { SyncOpResult, SyncOpType } from "@/lib/sync/envelope"; + +/** + * Local offline storage model (lane L7, Spec §23, ARCHITEKTUR §4.6). Client-safe, no browser APIs: + * shared by the IndexedDB adapter (db.ts), the in-memory adapter (memory-store.ts, tests) and the + * pure outbox/bundle logic (outbox-core.ts, bundle-core.ts). + * + * Every record carries tenantId + userId and is addressed through `ctxKey` — data of one + * tenant/user context is never returned for another one. + */ + +export type OfflineContext = { tenantId: string; userId: string }; + +export const ctxKeyOf = (c: OfflineContext): string => `${c.tenantId}:${c.userId}`; + +/** Local lifecycle of an op (Spec §23.4 "Synchronisationsstatus"). */ +export type OutboxStatus = "queued" | "sending" | "applied" | "conflict" | "rejected"; + +export type OutboxError = { code: NonNullable | "network" | "unauthorized" | "upload"; message?: string }; + +export type OutboxEntry = { + clientOpId: string; + ctxKey: string; + tenantId: string; + userId: string; + opType: SyncOpType; + payload: Record; + entityType?: string; + entityId?: string; + baseVersion?: number; + /** baseVersion is taken from the server result of the preceding op of the same order (own chain). */ + chainedBase?: boolean; + /** work order the op belongs to — ops of one order are sent strictly in order */ + workOrderId: string | null; + /** monotonic local sequence (creation order) */ + seq: number; + status: OutboxStatus; + attempts: number; + lastError: OutboxError | null; + clientCreatedAt: string; + updatedAt: string; + /** earliest time of the next send attempt (exponential backoff) */ + nextAttemptAt: string | null; + appliedAt: string | null; + /** client ids of queued blobs referenced by the payload (documentId: "blob:") */ + blobRefs: string[]; + result: SyncOpResult | null; + /** the result was already shown inline to the user (immediate online submit) */ + acknowledged?: boolean; +}; + +export type BlobKind = "photo" | "voice_note"; +export type BlobStatus = "pending" | "uploading" | "uploaded" | "failed"; + +export type BlobEntry = { + clientId: string; + ctxKey: string; + tenantId: string; + userId: string; + workOrderId: string; + kind: BlobKind; + blob: Blob; + preview: Blob | null; + fileName: string; + size: number; + status: BlobStatus; + documentId: string | null; + attempts: number; + lastError: OutboxError | null; + nextAttemptAt: string | null; + /** op waiting for this upload */ + opClientOpId: string | null; + createdAt: string; + updatedAt: string; +}; + +/** One order of GET /api/v1/field/bundle as stored locally (server snapshot, never mutated optimistically). */ +export type BundleOrderData = { + id: string; + number: string; + title: string; + status: string; + statusGroup: string; + priority: string; + isEmergency: boolean; + plannedStart: string | null; + plannedEnd: string | null; + version: number; + updatedAt?: string; + externalOrderNumber?: string | null; + description?: string | null; + scope?: string | null; + technicianNotes?: string | null; + signatureRequired?: boolean; + orderType?: { name: string } | null; + customer: { + id?: string; + companyName: string | null; + firstName: string | null; + lastName: string | null; + street: string | null; + houseNumber: string | null; + postalCode: string | null; + city: string | null; + phone?: string | null; + mobile?: string | null; + email?: string | null; + }; + contact?: { name: string; role: string | null; phone: string | null; mobile: string | null; email: string | null } | null; + site?: { + id: string; + name: string | null; + street: string | null; + houseNumber: string | null; + postalCode: string | null; + city: string | null; + phone?: string | null; + onSiteContact?: string | null; + accessNotes?: string | null; + parkingNotes?: string | null; + safetyNotes?: string | null; + technicalNotes?: string | null; + contact?: { name: string; role: string | null; phone: string | null; mobile: string | null; email: string | null } | null; + } | null; + checklistItems: Array<{ id: string; label: string; required: boolean; requiresPhoto: boolean; checked: boolean; checkedAt: string | null; comment: string | null }>; + photoRequirements: Array<{ id: string; key: string; label: string; _count: { photos: number } }>; + materialPlans: Array<{ id: string; name: string; articleNumber: string | null; plannedQuantity: number | string; unit: string; notes: string | null }>; + materialUsages: Array<{ + id: string; + materialPlanId: string | null; + name: string | null; + articleNumber: string | null; + actualQuantity: number | string; + unit: string; + usageStatus: string; + deviationReason: string | null; + notes: string | null; + clientId: string | null; + }>; + documents: Array<{ id: string; title: string | null; fileName: string; category: string; mimeType: string; fileSize: number; checksum?: string | null; version: number; lineageId: string }>; + siteHistory: Array<{ reportId: string; reportType: string; reportDate: string; workOrderId: string; workOrderNumber: string; workOrderTitle: string; pdfHref: string }>; +}; + +export type BundleRecord = { ctxKey: string; workOrderId: string; data: BundleOrderData; syncedAt: string }; + +export type DraftRecord = { ctxKey: string; key: string; value: unknown; updatedAt: string }; + +/** Storage adapter. IndexedDB in the browser (db.ts), in-memory in tests (memory-store.ts). */ +export interface OfflineStore { + // outbox + putOp(entry: OutboxEntry): Promise; + listOps(ctxKey: string): Promise; + deleteOp(clientOpId: string): Promise; + // blobs + putBlob(entry: BlobEntry): Promise; + getBlob(clientId: string): Promise; + listBlobs(ctxKey: string): Promise; + deleteBlob(clientId: string): Promise; + // bundle (server snapshot per order) + replaceBundle(ctxKey: string, records: BundleRecord[]): Promise; + listBundle(ctxKey: string): Promise; + // meta + drafts (key/value per context) + getMeta(ctxKey: string, key: string): Promise; + setMeta(ctxKey: string, key: string, value: unknown): Promise; + deleteMeta(ctxKey: string, key: string): Promise; + // contexts + listContexts(): Promise; + clearContext(ctxKey: string): Promise; +}