Offline: Fotos bei abgelaufener Testphase nicht mehr verwerfen

Upload-Antwort 422 trial_expired gilt als vorübergehend: Datei bleibt auf dem Gerät,
wartende Op bleibt in der Warteschlange, der Sync-Durchlauf stoppt mit Backoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 19:23:14 +02:00
co-authored by Claude Opus 5
parent a8fedc390f
commit dd6a67d329
3 changed files with 31 additions and 6 deletions
+12 -1
View File
@@ -43,7 +43,7 @@ const at = (ms: number) => new Date(T0.getTime() + ms);
const fixedRandom = () => 1; // backoff = full step const fixedRandom = () => 1; // backoff = full step
/** Fake server: records every batch, answers per op via `decide`. */ /** Fake server: records every batch, answers per op via `decide`. */
function fakeTransport(decide: (op: SyncOperationInput) => SyncOpResult, opts: { failBatches?: number; uploadError?: "network" | "invalid" } = {}) { function fakeTransport(decide: (op: SyncOperationInput) => SyncOpResult, opts: { failBatches?: number; uploadError?: "network" | "invalid" | "suspended" } = {}) {
const batches: SyncOperationInput[][] = []; const batches: SyncOperationInput[][] = [];
const uploads: string[] = []; const uploads: string[] = [];
const log: string[] = []; const log: string[] = [];
@@ -230,6 +230,17 @@ async function main() {
const p = await runSyncPass({ store: store3, ctx: A, transport: offline.transport, deviceId: "d", now: () => at(10), random: fixedRandom }); const p = await runSyncPass({ store: store3, ctx: A, transport: offline.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
const blob3 = await store3.getBlob(c3); const blob3 = await store3.getBlob(c3);
ok(p.stopped === "network" && blob3?.status === "failed" && blob3.nextAttemptAt === at(2010).toISOString() && offline.batches.length === 0, "Upload ohne Netz → Backoff, Op wartet"); ok(p.stopped === "network" && blob3?.status === "failed" && blob3.nextAttemptAt === at(2010).toISOString() && offline.batches.length === 0, "Upload ohne Netz → Backoff, Op wartet");
// L15: expired trial (read-only tenant) must not discard photos
const store5 = createMemoryStore();
const c5 = randomUUID();
const op5 = await enqueueOp(store5, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: await enqueueBlob(store5, A, { clientId: c5, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f" }, at(0)) } }, randomUUID(), at(1));
const suspended = fakeTransport((o) => applied(o), { uploadError: "suspended" });
const p5 = await runSyncPass({ store: store5, ctx: A, transport: suspended.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
const blob5 = await store5.getBlob(c5);
const op5After = (await store5.listOps(ctxKeyOf(A))).find((o) => o.clientOpId === op5.clientOpId);
ok(p5.stopped === "suspended" && p5.rejected === 0 && suspended.batches.length === 0, "Testphase abgelaufen → Pass stoppt, nichts verworfen");
ok(!!blob5 && blob5.nextAttemptAt !== null && blob5.lastError?.message === "trial_expired" && op5After?.status === "queued", "Foto bleibt auf dem Gerät, Op wartet (Backoff)");
} }
console.log("\n— idMap / verkettete baseVersion —"); console.log("\n— idMap / verkettete baseVersion —");
+11 -1
View File
@@ -31,7 +31,7 @@ export type OfflineState = {
summary: OutboxSummary; summary: OutboxSummary;
lastSyncAt: string | null; lastSyncAt: string | null;
lastPullAt: string | null; lastPullAt: string | null;
lastError: null | "network" | "unauthorized" | "internal"; lastError: null | "network" | "unauthorized" | "suspended" | "internal";
storage: StorageInfo; storage: StorageInfo;
/** upload progress per blob client id (0–100) */ /** upload progress per blob client id (0–100) */
uploads: Record<string, number>; uploads: Record<string, number>;
@@ -88,6 +88,14 @@ async function requireRuntime(): Promise<{ store: OfflineStore; ctx: OfflineCont
const uploadProgress = new Map<string, (percent: number) => void>(); const uploadProgress = new Map<string, (percent: number) => void>();
function isTrialExpired(body: string): boolean {
try {
return (JSON.parse(body) as { error?: { message?: string } }).error?.message === "trial_expired";
} catch {
return false;
}
}
const browserTransport: Transport = { const browserTransport: Transport = {
async sendBatch(device, operations) { async sendBatch(device, operations) {
let res: Response; let res: Response;
@@ -141,6 +149,8 @@ const browserTransport: Transport = {
return; return;
} }
if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" }); if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" });
// expired trial (read-only tenant) → transient: keep the file on the device until extended/converted
if (xhr.status === 422 && isTrialExpired(xhr.responseText)) return resolve({ ok: false, error: "suspended" });
// 422 = unified /api/v1 validation error; 429 (rate limit) stays transient → backoff // 422 = unified /api/v1 validation error; 429 (rate limit) stays transient → backoff
if (xhr.status === 400 || xhr.status === 413 || xhr.status === 422) return resolve({ ok: false, error: "invalid" }); if (xhr.status === 400 || xhr.status === 413 || xhr.status === 422) return resolve({ ok: false, error: "invalid" });
if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" }); if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" });
+8 -4
View File
@@ -20,7 +20,8 @@ import {
*/ */
export type BatchOutcome = { ok: true; response: SyncResponse } | { ok: false; error: "network" | "unauthorized" | "internal" }; 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" }; /** `suspended` = tenant temporarily read-only (expired trial) → transient, the file stays on the device. */
export type UploadOutcome = { ok: true; documentId: string } | { ok: false; error: "network" | "unauthorized" | "suspended" | "invalid" | "forbidden" | "not_found" | "internal" };
export type Transport = { export type Transport = {
sendBatch(deviceId: string, operations: SyncOperationInput[]): Promise<BatchOutcome>; sendBatch(deviceId: string, operations: SyncOperationInput[]): Promise<BatchOutcome>;
@@ -48,7 +49,7 @@ export type PassResult = {
uploaded: number; uploaded: number;
uploadFailed: number; uploadFailed: number;
/** transport-level stop reason of the pass */ /** transport-level stop reason of the pass */
stopped: null | "network" | "unauthorized" | "internal"; stopped: null | "network" | "unauthorized" | "suspended" | "internal";
/** client id → server id of this pass */ /** client id → server id of this pass */
idMap: Record<string, string>; idMap: Record<string, string>;
}; };
@@ -123,7 +124,10 @@ export async function runSyncPass(deps: EngineDeps): Promise<PassResult> {
continue; continue;
} }
const attempts = blob.attempts + 1; const attempts = blob.attempts + 1;
const error: OutboxError = { code: up.error === "unauthorized" || up.error === "network" ? up.error : "upload", message: up.error }; const error: OutboxError =
up.error === "suspended"
? { code: "blocked", message: "trial_expired" }
: { code: up.error === "unauthorized" || up.error === "network" ? up.error : "upload", message: up.error };
if (FINAL_UPLOAD_ERRORS.has(up.error)) { if (FINAL_UPLOAD_ERRORS.has(up.error)) {
await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: null, updatedAt: iso }); await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: null, updatedAt: iso });
res.uploadFailed++; res.uploadFailed++;
@@ -134,7 +138,7 @@ export async function runSyncPass(deps: EngineDeps): Promise<PassResult> {
} }
} else { } else {
await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: new Date(now().getTime() + backoffMs(attempts, random)).toISOString(), updatedAt: iso }); 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") { if (up.error === "network" || up.error === "unauthorized" || up.error === "suspended") {
res.stopped = up.error; res.stopped = up.error;
return res; return res;
} }