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:
@@ -43,7 +43,7 @@ const at = (ms: number) => new Date(T0.getTime() + ms);
|
||||
const fixedRandom = () => 1; // backoff = full step
|
||||
|
||||
/** 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 uploads: 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 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");
|
||||
|
||||
// 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 —");
|
||||
|
||||
@@ -31,7 +31,7 @@ export type OfflineState = {
|
||||
summary: OutboxSummary;
|
||||
lastSyncAt: string | null;
|
||||
lastPullAt: string | null;
|
||||
lastError: null | "network" | "unauthorized" | "internal";
|
||||
lastError: null | "network" | "unauthorized" | "suspended" | "internal";
|
||||
storage: StorageInfo;
|
||||
/** upload progress per blob client id (0–100) */
|
||||
uploads: Record<string, number>;
|
||||
@@ -88,6 +88,14 @@ async function requireRuntime(): Promise<{ store: OfflineStore; ctx: OfflineCont
|
||||
|
||||
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 = {
|
||||
async sendBatch(device, operations) {
|
||||
let res: Response;
|
||||
@@ -141,6 +149,8 @@ const browserTransport: Transport = {
|
||||
return;
|
||||
}
|
||||
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
|
||||
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" });
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
*/
|
||||
|
||||
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 = {
|
||||
sendBatch(deviceId: string, operations: SyncOperationInput[]): Promise<BatchOutcome>;
|
||||
@@ -48,7 +49,7 @@ export type PassResult = {
|
||||
uploaded: number;
|
||||
uploadFailed: number;
|
||||
/** 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 */
|
||||
idMap: Record<string, string>;
|
||||
};
|
||||
@@ -123,7 +124,10 @@ export async function runSyncPass(deps: EngineDeps): Promise<PassResult> {
|
||||
continue;
|
||||
}
|
||||
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)) {
|
||||
await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: null, updatedAt: iso });
|
||||
res.uploadFailed++;
|
||||
@@ -134,7 +138,7 @@ export async function runSyncPass(deps: EngineDeps): Promise<PassResult> {
|
||||
}
|
||||
} 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") {
|
||||
if (up.error === "network" || up.error === "unauthorized" || up.error === "suspended") {
|
||||
res.stopped = up.error;
|
||||
return res;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user