/** * @factory/collaboration — the offline outbox (TASK-0095 R1). * * Every REST mutation the app makes while the realtime path cannot deliver * it (offline / reconnecting) is enqueued here instead of sent: * `{key (idempotency key), method, path, body, queuedAt, resource}`. On * reconnect the {@link OutboxQueue.drain} worker replays them: * * - **per-resource ordering** — a later mutation to the same resource * never replays before an earlier one (per-resource FIFO chain; * different resources interleave, `maxConcurrentResources` chains in * parallel — the recorded concurrency, default 3). * - **idempotency** — every replay sends `Idempotency-Key: `; the * API middleware (apps/api, same task) checks a 24 h Redis keyspace * `idem:{userId}:{key}` BEFORE execution and replays the stored * response on a hit — a double-replay (crash mid-drain) creates no * duplicate. The key is `key_` + 32 hex (unpredictable — a collision * is a non-event, recorded in the task Security section). * - **failure classification** — 4xx → the item moves to the **failed** * list (the "Review failed changes" dialog: per-item Retry/Discard); * 5xx / network → retry with backoff `min(1 s · 2^attempts, 30 s)`; * `200/201/…` (incl. an idempotent hit) → done, removed. * - **retry = a fresh key** — "a retry is a new attempt, the old key is * dead" (the server's 24 h stored response for the old key must not be * replayed for a deliberately re-sent mutation). * - **GC** — pending items older than 24 h are garbage-collected * (the task's offline-file-upload rule generalizes; recorded: FAILED * items are NOT auto-GC'd — they need an explicit user Discard). * - **cap** — 500 pending items max; enqueuing beyond the cap evicts * the OLDEST pending item (oldest is the least likely to replay * cleanly after a day offline — recorded; the eviction is surfaced * via `onEvicted`). * * The queue is deterministic (injected clock/timers) and storage-agnostic * (`OutboxStore` — apps/web implements it with `idb`, the y-indexeddb * sibling; tests use a memory store). LWW-with-consent (title 409) and * property LWW are app-level decisions ON TOP of the 4xx→failed * classification — the queue never auto-resolves conflicts (recorded). */ import type { OutboxItem, OutboxResult, OutboxStore } from "./types.js"; /** The outbox's public types (the app imports them via `./outbox`). */ export type { OutboxItem, OutboxResult, OutboxStore } from "./types.js"; /** The outbox cap (the task's 500-item limit). */ export const OUTBOX_MAX_ITEMS = 500; /** Items pending longer than this are GC'd (the task's 24 h rule). */ export const OUTBOX_GC_MS = 24 * 60 * 60 * 1000; /** Backoff bounds: 1 s → 30 s (the task's 1 s → 30 s max). */ export const OUTBOX_BACKOFF_MIN_MS = 1000; export const OUTBOX_BACKOFF_MAX_MS = 30_000; /** Default parallel resource chains (the recorded concurrency). */ export const OUTBOX_DEFAULT_CONCURRENCY = 3; /** `key_` + 32 hex — unpredictable (crypto when present, fallback random). */ export function generateIdempotencyKey(): string { const c = (globalThis as Record).crypto as | { getRandomValues?: (arr: Uint8Array) => Uint8Array } | undefined; if (c !== undefined && typeof c.getRandomValues === "function") { const buf = new Uint8Array(16); c.getRandomValues(buf); return ( "key_" + Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("") ); } let out = ""; for (let i = 0; i < 32; i += 1) { out += Math.floor(Math.random() * 16).toString(16); } return "key_" + out; } /** The per-resource chain key (ordering scope). */ export function resourceKey(resource: { type: string; id: string }): string { return `${resource.type}:${resource.id}`; } /** Backoff delay for an item at `attempts` (1 s · 2^n, capped at 30 s). */ export function backoffForAttempt(attempts: number): number { const n = Math.max(0, attempts); return Math.min(OUTBOX_BACKOFF_MIN_MS * 2 ** n, OUTBOX_BACKOFF_MAX_MS); } export interface OutboxQueueOptions { /** The persistence backend (idb in the app, a memory store in tests). */ readonly store: OutboxStore; /** Sends one item with the Idempotency-Key header (the api client). */ readonly send: (item: OutboxItem) => Promise; /** Injectable clock (ms) — determinism (AGENTS.md rule 25). */ readonly now?: () => number; /** Parallel resource chains during a drain (default 3). */ readonly maxConcurrentResources?: number; /** Pending/failed counts changed (the banner's count input). */ readonly onCountChange?: (info: { pending: number; failed: number }) => void; /** An item was evicted by the 500-cap (the toast material). */ readonly onEvicted?: (item: OutboxItem) => void; /** A drain finished (observability — the task's "drains with counts"). */ readonly onDrain?: (result: DrainResult) => void; /** Initial items (skips the store load; tests). */ readonly initial?: readonly OutboxItem[]; } export interface DrainResult { readonly sent: number; readonly failed: number; readonly retrying: number; readonly pending: number; readonly failedCount: number; } /** * The outbox queue (the app's `offlineQueue` Zustand store wraps this — * the store owns the UI, the queue owns the state machine). */ export class OutboxQueue { private readonly store: OutboxStore; private readonly send: (item: OutboxItem) => Promise; private readonly now: () => number; private readonly maxConcurrentResources: number; // `| undefined` (not `?`): exactOptionalPropertyTypes — the constructor // may legitimately assign `undefined` (option absent = callback off). private readonly onCountChange: ((info: { pending: number; failed: number }) => void) | undefined; private readonly onEvicted: ((item: OutboxItem) => void) | undefined; private readonly onDrain: ((result: DrainResult) => void) | undefined; private pending: OutboxItem[] = []; private failed: OutboxItem[] = []; private draining = false; private loaded = false; constructor(opts: OutboxQueueOptions) { this.store = opts.store; this.send = opts.send; this.now = opts.now ?? (() => Date.now()); this.maxConcurrentResources = opts.maxConcurrentResources ?? OUTBOX_DEFAULT_CONCURRENCY; this.onCountChange = opts.onCountChange; this.onEvicted = opts.onEvicted; this.onDrain = opts.onDrain; if (opts.initial !== undefined) { for (const item of opts.initial) { (item.lastError !== undefined ? this.failed : this.pending).push(item); } this.loaded = true; } } /** Load persisted items (call once before use; a no-op when `initial` was set). */ async init(): Promise { if (this.loaded) return; const items = await this.store.load(); for (const item of items) { (item.lastError !== undefined ? this.failed : this.pending).push(item); } this.loaded = true; this.persist(); } get pendingItems(): readonly OutboxItem[] { return this.pending; } get failedItems(): readonly OutboxItem[] { return this.failed; } /** The banner's pending count (CRDT-pending is summed by the app). */ count(): number { return this.pending.length; } failedCount(): number { return this.failed.length; } /** * Enqueue a mutation for replay. Evicts the oldest pending item when * the 500 cap is reached (recorded). Returns the item (the caller * shows the optimistic UI keyed on it). */ enqueue( method: OutboxItem["method"], path: string, body: Record | undefined, resource: { type: string; id: string }, ): OutboxItem { // GC first — a day-old queue makes room for the new mutation. this.gc(); const item: OutboxItem = { key: generateIdempotencyKey(), method, path, ...(body !== undefined ? { body } : {}), queuedAt: this.now(), resource, attempts: 0, }; this.pending.push(item); while (this.pending.length > OUTBOX_MAX_ITEMS) { const evicted = this.pending.shift(); if (evicted !== undefined) this.onEvicted?.(evicted); } this.persist(); this.emitCount(); return item; } /** * Drop peers silent forever: remove pending items queued > 24 h ago * (the task's GC rule). FAILED items are kept (explicit Discard only). * Returns the removed items. */ gc(): readonly OutboxItem[] { const cutoff = this.now() - OUTBOX_GC_MS; const kept: OutboxItem[] = []; const removed: OutboxItem[] = []; for (const item of this.pending) { (item.queuedAt < cutoff ? removed : kept).push(item); } if (removed.length > 0) { this.pending = kept; this.persist(); this.emitCount(); } return removed; } /** * A 4xx failed mutation: retry with a FRESH key (the old key is dead — * the server's stored response for it must not replay, recorded in the * task). The item re-enters the pending chain head of its resource. */ retry(item: OutboxItem): OutboxItem { const idx = this.failed.findIndex((i) => i.key === item.key); if (idx < 0) return item; this.failed.splice(idx, 1); const fresh: OutboxItem = { ...item, key: generateIdempotencyKey(), attempts: 0, queuedAt: this.now(), }; delete (fresh as { nextAttemptAt?: number }).nextAttemptAt; delete (fresh as { lastError?: string }).lastError; this.pending.push(fresh); this.persist(); this.emitCount(); return fresh; } /** The user's explicit Discard (the confirm dialog precedes the call). */ discard(item: OutboxItem): void { const fIdx = this.failed.findIndex((i) => i.key === item.key); if (fIdx >= 0) { this.failed.splice(fIdx, 1); } else { const pIdx = this.pending.findIndex((i) => i.key === item.key); if (pIdx >= 0) this.pending.splice(pIdx, 1); } this.persist(); this.emitCount(); } /** * Replay pending mutations: per-resource FIFO chains, up to * `maxConcurrentResources` chains in parallel (the recorded * concurrency). Items whose backoff window is still open are left for * the next drain (they are NOT re-sent early). Idempotent hits count * as sent (the server replayed the stored response — no re-run). */ async drain(): Promise { if (this.draining) { return { sent: 0, failed: 0, retrying: 0, pending: this.pending.length, failedCount: this.failed.length, }; } this.draining = true; try { const sent = { count: 0 }; const failedMoves = { count: 0 }; const retrying = { count: 0 }; // Group by resource (queuedAt order preserved within each chain). const chains = new Map(); const sorted = [...this.pending].sort((a, b) => a.queuedAt - b.queuedAt); for (const item of sorted) { const key = resourceKey(item.resource); const list = chains.get(key) ?? []; list.push(item); chains.set(key, list); } const keys = [...chains.keys()]; // Run up to N chains in parallel; each chain is strictly FIFO. const runChain = async (key: string): Promise => { const items = chains.get(key); if (items === undefined) return; for (const item of items) { // Backoff window still open → wait for the next drain. if (item.nextAttemptAt !== undefined && this.now() < item.nextAttemptAt) { retrying.count += 1; continue; } let result: OutboxResult; try { result = await this.send(item); } catch (err) { // Network failure (the send threw, not resolved) → retryable. result = { ok: false, error: err instanceof Error ? err.message : String(err) }; } if (result.ok) { this.removePending(item); sent.count += 1; } else if (result.status !== undefined && result.status >= 400 && result.status < 500) { // 4xx → the failed list (a user decision: Retry/Discard). this.moveToFailed(item, result.error ?? `HTTP ${result.status}`); failedMoves.count += 1; } else { // 5xx / network → retry with backoff (key stays — the server // never executed, or the response was not stored: a retry of // the SAME key is safe, the middleware checks before run). this.pending = this.pending.map((p) => p.key === item.key ? { ...p, attempts: p.attempts + 1, nextAttemptAt: this.now() + backoffForAttempt(p.attempts + 1), ...(result.error !== undefined ? { lastError: result.error } : {}), } : p, ); retrying.count += 1; } } }; // A simple bounded pool over the resource chains. const queue = [...keys]; const workers = Array.from( { length: Math.min(this.maxConcurrentResources, queue.length) }, async () => { for (;;) { const key = queue.shift(); if (key === undefined) return; await runChain(key); } }, ); await Promise.all(workers); this.persist(); this.emitCount(); const result: DrainResult = { sent: sent.count, failed: failedMoves.count, retrying: retrying.count, pending: this.pending.length, failedCount: this.failed.length, }; this.onDrain?.(result); return result; } finally { this.draining = false; } } /** Logout = explicit abandon (the recorded rule): clear the outbox. */ async clear(): Promise { this.pending = []; this.failed = []; await this.persist(); this.emitCount(); } private removePending(item: OutboxItem): void { const idx = this.pending.findIndex((i) => i.key === item.key); if (idx >= 0) this.pending.splice(idx, 1); } private moveToFailed(item: OutboxItem, error: string): void { this.removePending(item); this.failed = this.failed.filter((i) => i.key !== item.key); this.failed.push({ ...item, lastError: error }); } private async persist(): Promise { try { await this.store.save([...this.pending, ...this.failed]); } catch { // Persistence failure is non-fatal (the in-memory queue is the // source of truth for the session; a reload loses unsaved // enqueues — recorded: the app surfaces a reload warning). } } private emitCount(): void { this.onCountChange?.({ pending: this.pending.length, failed: this.failed.length }); } } /** A memory `OutboxStore` for tests (the app uses `idb`). */ export class MemoryOutboxStore implements OutboxStore { items: OutboxItem[] = []; async load(): Promise { return this.items; } async save(items: readonly OutboxItem[]): Promise { this.items = [...items]; } }