import type { SyncOperationInput, SyncResponse } from "@/lib/sync/envelope"; import { ctxKeyOf, type BlobEntry, type BlobKind, type OfflineContext, type OfflineStore, type OutboxEntry, type OutboxError } from "./types"; import { applyResults, applyTransportFailure, backoffMs, blobRef, createEntry, MAX_BATCH, recoverSending, selectBatch, selectUploads, type QueuedOpInput, } from "./outbox-core"; /** * One synchronisation pass over the outbox of a context (lane L7). Environment-free: storage and * network are injected, so the same code runs in the browser (outbox.ts: IndexedDB + fetch) and in * the tests (memory store + in-process applyOperations). */ export type BatchOutcome = { ok: true; response: SyncResponse } | { ok: false; error: "network" | "unauthorized" | "internal" }; export type UploadOutcome = { ok: true; documentId: string } | { ok: false; error: "network" | "unauthorized" | "invalid" | "forbidden" | "not_found" | "internal" }; export type Transport = { sendBatch(deviceId: string, operations: SyncOperationInput[]): Promise; upload(blob: BlobEntry): Promise; }; export type EngineDeps = { store: OfflineStore; ctx: OfflineContext; transport: Transport; deviceId: string; now?: () => Date; random?: () => number; maxBatch?: number; /** safety limit of batch rounds per pass */ maxRounds?: number; }; export type PassResult = { rounds: number; sent: number; applied: number; conflicts: number; rejected: number; uploaded: number; uploadFailed: number; /** transport-level stop reason of the pass */ stopped: null | "network" | "unauthorized" | "internal"; /** client id → server id of this pass */ idMap: Record; }; export async function enqueueOp(store: OfflineStore, ctx: OfflineContext, op: QueuedOpInput, clientOpId: string, now = new Date()): Promise { const existing = await store.listOps(ctxKeyOf(ctx)); const entry = createEntry(ctx, op, { clientOpId, now, existing }); await store.putOp(entry); // link queued blobs to the op waiting for them for (const ref of entry.blobRefs) { const blob = await store.getBlob(ref); if (!blob || blob.ctxKey !== entry.ctxKey) continue; // a new op on a finally failed upload gets one fresh upload attempt const rearm = blob.status === "failed" && blob.nextAttemptAt === null ? { status: "pending" as const, lastError: null, attempts: 0 } : {}; await store.putBlob({ ...blob, ...rearm, opClientOpId: entry.clientOpId, updatedAt: now.toISOString() }); } return entry; } export type BlobInput = { clientId: string; workOrderId: string; kind: BlobKind; blob: Blob; preview?: Blob | null; fileName: string }; /** Stores a blob for upload; returns the reference to put into the op payload (`documentId`). */ export async function enqueueBlob(store: OfflineStore, ctx: OfflineContext, input: BlobInput, now = new Date()): Promise { const iso = now.toISOString(); await store.putBlob({ clientId: input.clientId, ctxKey: ctxKeyOf(ctx), tenantId: ctx.tenantId, userId: ctx.userId, workOrderId: input.workOrderId, kind: input.kind, blob: input.blob, preview: input.preview ?? null, fileName: input.fileName, size: input.blob.size + (input.preview?.size ?? 0), status: "pending", documentId: null, attempts: 0, lastError: null, nextAttemptAt: null, opClientOpId: null, createdAt: iso, updatedAt: iso, }); return blobRef(input.clientId); } const FINAL_UPLOAD_ERRORS = new Set(["invalid", "forbidden", "not_found"]); export async function runSyncPass(deps: EngineDeps): Promise { const { store, ctx, transport, deviceId } = deps; const now = deps.now ?? (() => new Date()); const random = deps.random ?? Math.random; const key = ctxKeyOf(ctx); const res: PassResult = { rounds: 0, sent: 0, applied: 0, conflicts: 0, rejected: 0, uploaded: 0, uploadFailed: 0, stopped: null, idMap: {} }; for (const e of recoverSending(await store.listOps(key), now())) await store.putOp(e); const maxRounds = deps.maxRounds ?? 20; while (res.rounds < maxRounds) { const ops = await store.listOps(key); const blobs = await store.listBlobs(key); // 1. uploads before the ops that reference them for (const blob of selectUploads(ops, blobs, now())) { await store.putBlob({ ...blob, status: "uploading", updatedAt: now().toISOString() }); const up = await transport.upload(blob); const iso = now().toISOString(); if (up.ok) { await store.putBlob({ ...blob, status: "uploaded", documentId: up.documentId, lastError: null, nextAttemptAt: null, updatedAt: iso }); res.uploaded++; continue; } const attempts = blob.attempts + 1; const error: OutboxError = { code: up.error === "unauthorized" || up.error === "network" ? up.error : "upload", message: up.error }; if (FINAL_UPLOAD_ERRORS.has(up.error)) { await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: null, updatedAt: iso }); res.uploadFailed++; // the waiting op can never be sent → rejected with a clear reason for (const op of ops.filter((o) => o.status === "queued" && o.blobRefs.includes(blob.clientId))) { await store.putOp({ ...op, status: "rejected", lastError: { code: "upload", message: up.error }, updatedAt: iso, nextAttemptAt: null }); res.rejected++; } } else { await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: new Date(now().getTime() + backoffMs(attempts, random)).toISOString(), updatedAt: iso }); if (up.error === "network" || up.error === "unauthorized") { res.stopped = up.error; return res; } } } // 2. next batch const current = await store.listOps(key); const batch = selectBatch(current, await store.listBlobs(key), now(), deps.maxBatch ?? MAX_BATCH); if (batch.entries.length === 0) break; res.rounds++; for (const e of batch.entries) await store.putOp({ ...e, status: "sending", updatedAt: now().toISOString() }); const outcome = await transport.sendBatch(deviceId, batch.operations); if (!outcome.ok) { const failed = applyTransportFailure(batch.entries, { code: outcome.error === "internal" ? "internal" : outcome.error }, now(), random); for (const e of failed) await store.putOp(e); res.stopped = outcome.error; return res; } res.sent += batch.entries.length; const { changed, idMap } = applyResults(current, batch.entries, outcome.response.results, now(), random); Object.assign(res.idMap, idMap); for (const e of changed) { await store.putOp(e); if (!batch.entries.some((b) => b.clientOpId === e.clientOpId)) continue; if (e.status === "applied") { res.applied++; // uploaded bytes are no longer needed once the referencing op is applied for (const ref of e.blobRefs) await store.deleteBlob(ref); } else if (e.status === "conflict") res.conflicts++; else if (e.status === "rejected") res.rejected++; } } return res; }