import type { SyncOperationInput, SyncOpResult, SyncOpType } from "@/lib/sync/envelope"; import { CONFLICTING_OPS } from "@/lib/sync/envelope"; import { ctxKeyOf, type BlobEntry, type OfflineContext, type OutboxEntry, type OutboxError } from "./types"; /** * Pure outbox logic (lane L7, Spec §23.4/§23.5, ARCHITEKTUR §4.6) — no IndexedDB, no fetch. * Tested in scripts/test-offline-core.ts with the in-memory store. * * Rules: * - ops of one work order are sent strictly in creation order; an op that is still pending * (backoff, waiting for its upload) blocks the later ops of the SAME order only * - terminal outcomes (applied / conflict / rejected) never block other ops * - blob uploads happen before the op that references them (`documentId: "blob:"`) * - server idMaps (client id → server id) are applied to the payloads of pending ops * - transient failures (network, internal) → exponential backoff; deterministic ones are final */ export const MAX_BATCH = 50; export const BLOB_REF_PREFIX = "blob:"; export const BACKOFF_BASE_MS = 2_000; export const BACKOFF_MAX_MS = 5 * 60_000; /** Ops that change WorkOrder.version on the server (status changes). */ export const VERSION_CHANGING_OPS: readonly SyncOpType[] = ["session.start", "session.pause", "session.resume", "session.end", "session.stop_day", "session.segment", "work_order.transition", "report.submit"]; const TRANSIENT_CODES = new Set(["internal", "network"]); export type QueuedOpInput = { opType: SyncOpType; payload: Record; entityType?: string; entityId?: string; baseVersion?: number; }; export const blobRef = (clientId: string) => `${BLOB_REF_PREFIX}${clientId}`; /** Client ids of all blob references in a payload (deep). */ export function blobRefsIn(value: unknown): string[] { const out = new Set(); const walk = (v: unknown) => { if (typeof v === "string") { if (v.startsWith(BLOB_REF_PREFIX)) out.add(v.slice(BLOB_REF_PREFIX.length)); } else if (Array.isArray(v)) v.forEach(walk); else if (v && typeof v === "object") Object.values(v).forEach(walk); }; walk(value); return [...out]; } export function workOrderIdOf(op: Pick): string | null { const fromPayload = op.payload.workOrderId; if (typeof fromPayload === "string" && fromPayload) return fromPayload; return op.entityType === "work_order" && op.entityId ? op.entityId : null; } export const isPending = (e: Pick) => e.status === "queued" || e.status === "sending"; /** Exponential backoff with jitter (0.5–1.0 × of the step); `random` injectable for tests. */ export function backoffMs(attempts: number, random: () => number = Math.random): number { const step = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempts - 1)); return Math.round(step * (0.5 + random() * 0.5)); } let seqCounter = 0; /** Monotonic per device (ms timestamp × 1000 + counter). */ export function nextSeq(now: Date): number { seqCounter = (seqCounter + 1) % 1000; return now.getTime() * 1000 + seqCounter; } export function createEntry(ctx: OfflineContext, op: QueuedOpInput, opts: { clientOpId: string; now: Date; existing: OutboxEntry[]; seq?: number }): OutboxEntry { const workOrderId = workOrderIdOf(op); const iso = opts.now.toISOString(); // A conflict-checked op queued behind our own pending status change of the same order takes its // baseVersion from that op's server result — otherwise our own chain would always conflict. const chainedBase = CONFLICTING_OPS.includes(op.opType) && !!workOrderId && opts.existing.some((e) => e.workOrderId === workOrderId && isPending(e) && VERSION_CHANGING_OPS.includes(e.opType)); return { clientOpId: opts.clientOpId, ctxKey: ctxKeyOf(ctx), tenantId: ctx.tenantId, userId: ctx.userId, opType: op.opType, payload: op.payload, entityType: op.entityType, entityId: op.entityId, baseVersion: op.baseVersion, chainedBase: chainedBase || undefined, workOrderId, seq: opts.seq ?? nextSeq(opts.now), status: "queued", attempts: 0, lastError: null, clientCreatedAt: iso, updatedAt: iso, nextAttemptAt: null, appliedAt: null, blobRefs: blobRefsIn(op.payload), result: null, }; } const orderKey = (e: OutboxEntry) => e.workOrderId ?? "_global"; const due = (at: string | null, now: Date) => !at || new Date(at).getTime() <= now.getTime(); /** Entries left in `sending` by an interrupted pass (tab closed, crash) go back to the queue. */ export function recoverSending(ops: OutboxEntry[], now: Date): OutboxEntry[] { return ops.filter((o) => o.status === "sending").map((o) => ({ ...o, status: "queued" as const, updatedAt: now.toISOString() })); } /** Blobs that must be uploaded before the next batch (referenced by pending ops, not in backoff). */ export function selectUploads(ops: OutboxEntry[], blobs: BlobEntry[], now: Date): BlobEntry[] { const byId = new Map(blobs.map((b) => [b.clientId, b])); const out: BlobEntry[] = []; const seen = new Set(); for (const op of [...ops].sort((a, b) => a.seq - b.seq)) { if (op.status !== "queued") continue; for (const ref of op.blobRefs) { const b = byId.get(ref); if (!b || seen.has(ref) || b.status === "uploaded") continue; if (b.status === "failed" && b.nextAttemptAt === null) continue; // final failure if (!due(b.nextAttemptAt, now)) continue; seen.add(ref); out.push(b); } } return out; } /** Payload with blob references replaced by their uploaded documentIds; null if an upload is missing. */ export function resolvePayload(op: OutboxEntry, blobs: BlobEntry[]): Record | null { if (op.blobRefs.length === 0) return op.payload; const docs = new Map(blobs.filter((b) => b.status === "uploaded" && b.documentId).map((b) => [b.clientId, b.documentId as string])); if (!op.blobRefs.every((r) => docs.has(r))) return null; const walk = (v: unknown): unknown => { if (typeof v === "string" && v.startsWith(BLOB_REF_PREFIX)) return docs.get(v.slice(BLOB_REF_PREFIX.length)) ?? v; if (Array.isArray(v)) return v.map(walk); if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])); return v; }; return walk(op.payload) as Record; } export type Batch = { entries: OutboxEntry[]; operations: SyncOperationInput[] }; /** * Next batch (max `max` ops): in seq order; per work order stop at the first op that cannot be * sent yet (backoff, missing upload, failed upload) — later ops of other orders still go. */ export function selectBatch(ops: OutboxEntry[], blobs: BlobEntry[], now: Date, max = MAX_BATCH): Batch { const blocked = new Set(); const entries: OutboxEntry[] = []; const operations: SyncOperationInput[] = []; for (const op of [...ops].sort((a, b) => a.seq - b.seq)) { if (entries.length >= max) break; const key = orderKey(op); if (op.status === "sending") { blocked.add(key); continue; } if (op.status !== "queued") continue; if (blocked.has(key)) continue; if (!due(op.nextAttemptAt, now)) { blocked.add(key); continue; } const payload = resolvePayload(op, blobs); if (!payload) { blocked.add(key); continue; } entries.push(op); operations.push({ clientOpId: op.clientOpId, opType: op.opType, ...(op.entityType ? { entityType: op.entityType } : {}), ...(op.entityId ? { entityId: op.entityId } : {}), ...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}), payload, clientCreatedAt: op.clientCreatedAt, }); } return { entries, operations }; } /** Replaces client ids by server ids in a payload (deep). The own `clientId` field stays (idempotency). */ export function applyIdMapToPayload(payload: Record, idMap: Record): Record { const walk = (v: unknown, key?: string): unknown => { if (typeof v === "string") return key !== "clientId" && Object.hasOwn(idMap, v) ? idMap[v] : v; if (Array.isArray(v)) return v.map((x) => walk(x)); if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, k)])); return v; }; return walk(payload) as Record; } function originalStatus(result: SyncOpResult): "applied" | "conflict" | "rejected" { if (!result.errorCode) return "applied"; if (result.errorCode === "conflict" && /original status: conflict/.test(result.message ?? "")) return "conflict"; return "rejected"; } /** * Applies the server results of a batch. Returns all changed entries (batch entries and pending * entries rewritten by idMap / chained baseVersion) plus the merged idMap. */ export function applyResults( ops: OutboxEntry[], batch: OutboxEntry[], results: SyncOpResult[], now: Date, random: () => number = Math.random, ): { changed: OutboxEntry[]; idMap: Record } { const iso = now.toISOString(); const byOp = new Map(results.map((r) => [r.clientOpId, r])); const changed = new Map(); const idMap: Record = {}; const versions = new Map(); for (const entry of batch) { const r = byOp.get(entry.clientOpId); let next: OutboxEntry; if (!r) { next = transient(entry, { code: "internal", message: "missing result" }, now, random); } else { const status = r.status === "duplicate" ? originalStatus(r) : r.status; if (status === "applied") { next = { ...entry, status: "applied", appliedAt: iso, updatedAt: iso, lastError: null, nextAttemptAt: null, result: r }; Object.assign(idMap, r.idMap ?? {}); if (entry.workOrderId && r.entityVersion !== undefined) versions.set(entry.workOrderId, r.entityVersion); } else if (status === "conflict") { next = { ...entry, status: "conflict", updatedAt: iso, lastError: { code: "conflict", message: r.message }, nextAttemptAt: null, result: r }; } else { const code = r.errorCode ?? "internal"; next = TRANSIENT_CODES.has(code) ? transient(entry, { code, message: r.message }, now, random) : { ...entry, status: "rejected", updatedAt: iso, lastError: { code, message: r.message }, nextAttemptAt: null, result: r }; } } changed.set(next.clientOpId, next); } const inBatch = new Set(batch.map((b) => b.clientOpId)); const hasIds = Object.keys(idMap).length > 0; for (const op of ops) { if (inBatch.has(op.clientOpId) || !isPending(op)) continue; let next = op; if (hasIds) { const payload = applyIdMapToPayload(op.payload, idMap); if (JSON.stringify(payload) !== JSON.stringify(op.payload)) next = { ...next, payload, updatedAt: iso }; } if (next.chainedBase && next.workOrderId && versions.has(next.workOrderId)) { next = { ...next, baseVersion: versions.get(next.workOrderId), updatedAt: iso }; } if (next !== op) changed.set(next.clientOpId, next); } // a chained op in the same batch already went out with the old baseVersion — nothing to do there return { changed: [...changed.values()], idMap }; } function transient(entry: OutboxEntry, error: OutboxError, now: Date, random: () => number): OutboxEntry { const attempts = entry.attempts + 1; return { ...entry, status: "queued", attempts, lastError: error, updatedAt: now.toISOString(), nextAttemptAt: new Date(now.getTime() + backoffMs(attempts, random)).toISOString(), }; } /** Whole batch failed on transport level (offline, 5xx, 401). */ export function applyTransportFailure(batch: OutboxEntry[], error: OutboxError, now: Date, random: () => number = Math.random): OutboxEntry[] { return batch.map((e) => transient(e, error, now, random)); } /** Manual "erneut versuchen": transient entries retry now; rejected ones get a fresh clientOpId (the server stored the old outcome). */ export function retryEntry(entry: OutboxEntry, now: Date, newClientOpId: string): OutboxEntry | null { if (entry.status === "applied" || entry.status === "conflict" || entry.status === "sending") return null; const base = { ...entry, status: "queued" as const, nextAttemptAt: null, updatedAt: now.toISOString(), acknowledged: undefined }; return entry.status === "rejected" ? { ...base, clientOpId: newClientOpId, attempts: 0, lastError: null, result: null } : base; } /** Only rejected entries can be discarded (conflicts stay listed; they are reviewed in the office). */ export const canDiscard = (e: Pick) => e.status === "rejected"; export type OutboxSummary = { pendingOps: number; pendingUploads: number; uploadBytes: number; failedUploads: number; conflicts: number; rejected: number; /** oldest pending op (ISO) */ oldestPending: string | null; waitingForAuth: boolean; }; export function summarize(ops: OutboxEntry[], blobs: BlobEntry[]): OutboxSummary { const pending = ops.filter(isPending); const openBlobs = blobs.filter((b) => b.status !== "uploaded"); return { pendingOps: pending.length, pendingUploads: openBlobs.filter((b) => b.status !== "failed" || b.nextAttemptAt !== null).length, uploadBytes: openBlobs.reduce((s, b) => s + b.size, 0), failedUploads: openBlobs.filter((b) => b.status === "failed" && b.nextAttemptAt === null).length, conflicts: ops.filter((o) => o.status === "conflict" && !o.acknowledged).length, rejected: ops.filter((o) => o.status === "rejected" && !o.acknowledged).length, oldestPending: pending.length ? pending.reduce((m, o) => (o.clientCreatedAt < m ? o.clientCreatedAt : m), pending[0].clientCreatedAt) : null, waitingForAuth: pending.some((o) => o.lastError?.code === "unauthorized"), }; } /** Entries that can be removed: applied before the last bundle pull, acknowledged rejections. */ export function prunable(ops: OutboxEntry[], bundleSyncedAt: string | null): string[] { return ops .filter((o) => (o.status === "applied" && !!o.appliedAt && !!bundleSyncedAt && o.appliedAt <= bundleSyncedAt) || (o.status === "rejected" && o.acknowledged)) .map((o) => o.clientOpId); } /** i18n key (messages offline.problem.*) explaining a failed entry in plain language. */ export function problemKey(e: Pick): string { if (e.status === "conflict") { if (e.opType === "work_order.transition") return "conflictTransition"; if (e.opType === "report.submit") return "conflictReport"; return "conflict"; } switch (e.lastError?.code) { case "not_found": return "notFound"; case "forbidden": // L17 Pakete: op of a module that is not part of the package / switched off (services/sync/apply.ts) if (e.lastError?.message?.startsWith("not_in_plan")) return "notInPlan"; if (e.lastError?.message?.startsWith("module_disabled")) return "moduleDisabled"; return "forbidden"; case "blocked": return "blocked"; case "invalid": // L12: plain-language texts for rejected time entries if (e.lastError?.message === "overlap") return "timeOverlap"; if (["too_old", "in_future", "too_long", "end_before_start"].includes(e.lastError?.message ?? "")) return "timeWindow"; return "invalid"; case "conflict": if (e.lastError?.message?.startsWith("other_session_running")) return "otherSession"; return "internal"; case "upload": return "upload"; case "unauthorized": return "unauthorized"; case "network": return "network"; default: return "internal"; } }