Files
craftvia/src/lib/offline/sync-engine.ts
T

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;
}