175 lines
7.1 KiB
TypeScript
175 lines
7.1 KiB
TypeScript
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;
|
|
}
|