L7 Offline & PWA: Outbox-Kern, IndexedDB-Store, Sync-Engine und Bundle-Logik
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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> = {}): SyncOpResult => ({ clientOpId: op.clientOpId, status: "applied", ...extra });
|
||||||
|
|
||||||
|
function order(id: string, extra: Partial<BundleOrderData> = {}): 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:<clientId>");
|
||||||
|
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<string, unknown>, 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);
|
||||||
|
});
|
||||||
@@ -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<T extends Pick<BundleOrderData, "status" | "plannedStart" | "plannedEnd">>(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<T>(v: T): T {
|
||||||
|
return typeof structuredClone === "function" ? structuredClone(v) : (JSON.parse(JSON.stringify(v)) as T);
|
||||||
|
}
|
||||||
@@ -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 = <T>(r: IDBRequest<T>) =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
|
r.onsuccess = () => resolve(r.result);
|
||||||
|
r.onerror = () => reject(r.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const done = (tx: IDBTransaction) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error ?? new Error("transaction aborted"));
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(): Promise<IDBDatabase> {
|
||||||
|
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<IDBDatabase> | null = null;
|
||||||
|
const db = () => (dbPromise ??= open().catch((err) => {
|
||||||
|
dbPromise = null;
|
||||||
|
throw err;
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function run<T>(names: StoreName | StoreName[], mode: IDBTransactionMode, fn: (tx: IDBTransaction) => Promise<T> | T): Promise<T> {
|
||||||
|
const tx = (await db()).transaction(names, mode);
|
||||||
|
const finished = done(tx);
|
||||||
|
const value = await fn(tx);
|
||||||
|
await finished;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCtx = <T>(name: StoreName, ctxKey: string) => run(name, "readonly", (tx) => req(tx.objectStore(name).index("ctxKey").getAll(ctxKey)) as Promise<T[]>);
|
||||||
|
|
||||||
|
return {
|
||||||
|
putOp: (entry) => run("outbox", "readwrite", (tx) => void tx.objectStore("outbox").put(entry)),
|
||||||
|
listOps: async (ctxKey) => (await byCtx<OutboxEntry>("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<BlobEntry>("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<BundleRecord>("bundle", ctxKey),
|
||||||
|
|
||||||
|
getMeta: async <T>(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<string>();
|
||||||
|
for (const name of STORES) {
|
||||||
|
// unique index keys = contexts present in this store
|
||||||
|
await new Promise<void>((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<OfflineStore> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string> = 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<T extends DocMeta>(docs: T[]): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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:<workOrderId>` or `report:<workOrderId>:daily`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const metaKey = (key: string) => `draft:${key}`;
|
||||||
|
|
||||||
|
export async function loadDraft<T>(key: string): Promise<T | null> {
|
||||||
|
if (!(await whenReady())) return null;
|
||||||
|
const ctx = getOfflineState().ctx;
|
||||||
|
if (!ctx) return null;
|
||||||
|
try {
|
||||||
|
return await (await getOfflineStore()).getMeta<T>(ctxKeyOf(ctx), metaKey(key));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDraft(key: string, value: unknown): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
const ctx = getOfflineState().ctx;
|
||||||
|
if (!ctx) return;
|
||||||
|
try {
|
||||||
|
await (await getOfflineStore()).deleteMeta(ctxKeyOf(ctx), metaKey(key));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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:<clientId>"`)
|
||||||
|
* - 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<OutboxError["code"]>(["internal", "network"]);
|
||||||
|
|
||||||
|
export type QueuedOpInput = {
|
||||||
|
opType: SyncOpType;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
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<string>();
|
||||||
|
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<QueuedOpInput, "payload" | "entityType" | "entityId">): 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<OutboxEntry, "status">) => 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<string>();
|
||||||
|
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<string, unknown> | 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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<string, unknown>, idMap: Record<string, string>): Record<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string> } {
|
||||||
|
const iso = now.toISOString();
|
||||||
|
const byOp = new Map(results.map((r) => [r.clientOpId, r]));
|
||||||
|
const changed = new Map<string, OutboxEntry>();
|
||||||
|
const idMap: Record<string, string> = {};
|
||||||
|
const versions = new Map<string, number>();
|
||||||
|
|
||||||
|
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<OutboxEntry, "status">) => 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<OutboxEntry, "status" | "opType" | "lastError">): 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<OfflineState>) {
|
||||||
|
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<boolean> {
|
||||||
|
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<string, (percent: number) => 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<UploadOutcome>((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<void> {
|
||||||
|
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<string>(key, META_LAST_PULL),
|
||||||
|
lastSyncAt: await store.getMeta<string>(key, "lastSyncAt"),
|
||||||
|
});
|
||||||
|
readyWaiters.forEach((w) => w());
|
||||||
|
readyWaiters = [];
|
||||||
|
await refreshSummary();
|
||||||
|
void requestPersistence();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshSummary(): Promise<void> {
|
||||||
|
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<void> | 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<void> {
|
||||||
|
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<void>) {
|
||||||
|
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<void> } }).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<SyncOpResult> {
|
||||||
|
const outcome = await browserTransport.sendBatch(deviceId(), [
|
||||||
|
{ clientOpId, opType: op.opType, payload: op.payload as Record<string, unknown>, 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<SyncOpResult> {
|
||||||
|
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<string, unknown> }, 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<BlobInput, "clientId"> & { 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<OutboxListing> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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<PullResult> {
|
||||||
|
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<CachedDoc[]> {
|
||||||
|
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<number> {
|
||||||
|
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<Set<string>> {
|
||||||
|
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<void> {
|
||||||
|
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<StorageInfo> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
if (!navigator.storage?.persist) return false;
|
||||||
|
return (await navigator.storage.persisted?.()) || (await navigator.storage.persist());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OrdersRead> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<BatchOutcome>;
|
||||||
|
upload(blob: BlobEntry): Promise<UploadOutcome>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function enqueueOp(store: OfflineStore, ctx: OfflineContext, op: QueuedOpInput, clientOpId: string, now = new Date()): Promise<OutboxEntry> {
|
||||||
|
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<string> {
|
||||||
|
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<PassResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<SyncOpResult["errorCode"]> | "network" | "unauthorized" | "upload"; message?: string };
|
||||||
|
|
||||||
|
export type OutboxEntry = {
|
||||||
|
clientOpId: string;
|
||||||
|
ctxKey: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
opType: SyncOpType;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
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:<clientId>") */
|
||||||
|
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<void>;
|
||||||
|
listOps(ctxKey: string): Promise<OutboxEntry[]>;
|
||||||
|
deleteOp(clientOpId: string): Promise<void>;
|
||||||
|
// blobs
|
||||||
|
putBlob(entry: BlobEntry): Promise<void>;
|
||||||
|
getBlob(clientId: string): Promise<BlobEntry | null>;
|
||||||
|
listBlobs(ctxKey: string): Promise<BlobEntry[]>;
|
||||||
|
deleteBlob(clientId: string): Promise<void>;
|
||||||
|
// bundle (server snapshot per order)
|
||||||
|
replaceBundle(ctxKey: string, records: BundleRecord[]): Promise<void>;
|
||||||
|
listBundle(ctxKey: string): Promise<BundleRecord[]>;
|
||||||
|
// meta + drafts (key/value per context)
|
||||||
|
getMeta<T = unknown>(ctxKey: string, key: string): Promise<T | null>;
|
||||||
|
setMeta(ctxKey: string, key: string, value: unknown): Promise<void>;
|
||||||
|
deleteMeta(ctxKey: string, key: string): Promise<void>;
|
||||||
|
// contexts
|
||||||
|
listContexts(): Promise<string[]>;
|
||||||
|
clearContext(ctxKey: string): Promise<void>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user