Files
craftvia/src/lib/offline/outbox-core.ts
T

351 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
}
}