"use client"; /** * The offline outbox store — TASK-0095 R1 (the app half of `apps/web/stores/ * offlineQueue.ts`). Wraps the platform-agnostic `OutboxQueue` * (`@factory/collaboration/outbox`) with: * * - a persistence backend (IndexedDB — the spec's "a plain `idb` store"). * `idb` is only a TRANSITIVE of y-indexeddb (not resolvable under pnpm's * strict layout), so we use the raw IndexedDB API directly — ZERO new * dependencies (recorded against the spec's `idb` assumption); * - a Zustand mirror of the pending/failed counts (the banner's input — * re-renders on `onCountChange`); * - the `enqueueMutation` promise (an offline mutation suspends until its * replay produces a real 2xx body, then resolves with it; a 4xx rejects); * - the `online`-event drain (replay on reconnect — the spec's "the * provider's online event → the outbox worker drains FIFO per resource"); * - `clear()` on logout (the recorded rule: a signed-out user's queued * mutations are discarded). * * The TRANSPORT (the actual HTTP send) is injected by `lib/api.ts` via * `registerOutboxTransport` — this module does NOT import `api.ts` (no cycle: * api.ts → offlineQueue.ts one direction). The transport sends each item with * its OWN `Idempotency-Key` (the item carries the key; a 5xx retry reuses it, * a user Retry gets a fresh one from `OutboxQueue.retry`). */ import { create } from "zustand"; import { MemoryOutboxStore, OutboxQueue, type OutboxItem, type OutboxResult, type OutboxStore, } from "@factory/collaboration/outbox"; /* ---------------------------------------------------------------- store */ /** The raw IndexedDB outbox backend (DB "factory-outbox", store "items"). */ const OUTBOX_DB = "factory-outbox"; const OUTBOX_STORE = "items"; function createOutboxStore(): OutboxStore { // No IndexedDB (SSR / private mode / jsdom) → the in-memory store. The // state machine is identical; persistence is a browser concern. if (typeof indexedDB === "undefined") return new MemoryOutboxStore(); const open = (): Promise => new Promise((resolve, reject) => { const req = indexedDB.open(OUTBOX_DB, 1); req.onupgradeneeded = () => { if (!req.result.objectStoreNames.contains(OUTBOX_STORE)) { req.result.createObjectStore(OUTBOX_STORE, { keyPath: "key" }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); const run = async (mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest): Promise => { const db = await open(); return new Promise((resolve, reject) => { const tx = db.transaction(OUTBOX_STORE, mode); const req = fn(tx.objectStore(OUTBOX_STORE)); req.onsuccess = () => resolve(req.result as T); req.onerror = () => reject(req.error); tx.oncomplete = () => db.close(); }); }; return { load: async () => { try { const all = await run("readonly", (s) => s.getAll() as IDBRequest); return all; } catch { return []; // storage unavailable → start empty (fail-open, no crash) } }, save: async (items: readonly OutboxItem[]) => { try { await run("readwrite", (s) => { const clearReq = s.clear(); for (const item of items) s.put(item); return clearReq; }); } catch { /* storage unavailable → in-memory state still holds (non-fatal) */ } }, }; } /* --------------------------------------------------------------- queue */ /** The registered transport (lib/api.ts's send — sends an item with its key). */ type OutboxTransport = (item: OutboxItem) => Promise; const transportRef: { current: OutboxTransport | null } = { current: null }; /** The app-wide outbox (one queue per tab; the store mirrors its counts). */ const queue = new OutboxQueue({ store: createOutboxStore(), now: () => Date.now(), send: (item) => { const t = transportRef.current; if (!t) return Promise.resolve({ ok: false, error: "outbox transport not registered" }); return t(item); }, onCountChange: (c) => { useOutboxStore.setState({ pending: c.pending, failed: c.failed, ready: true }); }, onEvicted: () => { // A day's worth of offline mutations overflowed the 500-cap — the oldest // was dropped. Surface it (the toast material the spec calls for). try { // Lazy (avoids a cycle at module load; ui-store has no outbox import). void import("@/stores/ui-store").then(({ toast }) => toast("error", "Offline storage is full", "The oldest queued change was discarded."), ); } catch { /* toast is best-effort */ } }, }); /** Register the transport (lib/api.ts calls this once at module load). */ export function registerOutboxTransport(transport: OutboxTransport): void { transportRef.current = transport; } /* ------------------------------------------- in-flight mutation promises */ /** * The pending mutation callbacks (an offline mutation's caller promise). * Resolved when the item's replay produces a real 2xx body; rejected on a 4xx * (the item moves to the failed list). A 5xx/network keeps it PENDING (the * item retries; it resolves on a later success). */ interface PendingMutation { resolve: (body: unknown) => void; reject: (err: unknown) => void; } const pendingMutations = new Map(); /* ------------------------------------------------------------ resource */ const RESOURCE_ROOTS = new Set([ "pages", "blocks", "workspaces", "comments", "databases", "records", "templates", "notifications", "invites", "files", "teams", "members", ]); /** * Derive the per-resource ordering key for a mutation (the spec's `resource: * {type, id}` — a later mutation to the SAME resource never replays before an * earlier one; a different resource can interleave). Heuristic over the path * (the api client is generic; the caller does not pass a resource): * - `/pages/{id}` → {type:"page", id} (title/icon) * - `/blocks/{id}` → {type:"block", id} (block edit) * - `/databases/{id}/...` → {type:"database", id} (record/props) * - `POST /comments` → {type:"comment", body.pageId} (per-page order) * - a create to a collection → {type: , __new__|scope} */ export function deriveOutboxResource( path: string, body?: Record | null, ): { type: string; id: string } { const segs = (path.split("?")[0] ?? "").split("/").filter(Boolean); const rootIdx = segs.findIndex((s) => RESOURCE_ROOTS.has(s)); if (rootIdx === -1) { // Not a known root (an auth/search/etc. mutation) → order by the whole path. return { type: "unknown", id: segs.join("/") || "root" }; } const root = segs[rootIdx] ?? "unknown"; const id = segs[rootIdx + 1]; if (id !== undefined) { // `/root/{id}` or `/root/{id}/sub` → the resource is the id (mutations to // the same entity are ordered; sub-collections order under their parent). return { type: root, id }; } // A create to the collection `/root` (no id yet) → scope by the body's // parent (e.g. a comment's pageId) so same-parent creates stay in order. const scope = (typeof body?.pageId === "string" && body.pageId) || (typeof body?.workspaceId === "string" && body.workspaceId) || (typeof body?.databaseId === "string" && body.databaseId) || "__new__"; return { type: root, id: scope }; } /* ------------------------------------------------------------- enqueue */ export interface EnqueueMutationInput { method: OutboxItem["method"]; path: string; body?: Record; /** The per-resource ordering key (overrides the path-derived one). */ resource?: { type: string; id: string }; } /** * Enqueue an OFFLINE mutation. Returns a promise that: * - RESOLVES with the real 2xx body when the replay succeeds (the caller's * contract is preserved — it gets the server's response, just later); * - REJECTS with the ApiError on a 4xx (the item → the failed list). * The item's `key` is its idempotency token (the transport sends it as the * `Idempotency-Key` header; the API replays a seen key without re-running). * A Retry re-queues via `OutboxQueue.retry` (a FRESH key — the old key is * dead), so no key pinning is needed here. */ export function enqueueMutation(input: EnqueueMutationInput): Promise { const resource = input.resource ?? deriveOutboxResource(input.path, input.body); const item = queue.enqueue(input.method, input.path, input.body, resource); return new Promise((resolve, reject) => { pendingMutations.set(item.key, { resolve: (body) => resolve(body as T), reject, }); }); } /** Resolve the caller promise for a successfully-replayed item (2xx). */ export function settleMutationSuccess(item: OutboxItem, body: unknown): void { const p = pendingMutations.get(item.key); if (p) { pendingMutations.delete(item.key); p.resolve(body); } } /** Reject the caller promise for a 4xx-replayed item (→ the failed list). */ export function settleMutationFailure(item: OutboxItem, err: unknown): void { const p = pendingMutations.get(item.key); if (p) { pendingMutations.delete(item.key); p.reject(err); } } /** Drop a caller promise (a Discard / a clear) without resolving it. */ export function dropMutation(item: OutboxItem): void { pendingMutations.delete(item.key); } /* ---------------------------------------------------------------- store */ interface OutboxState { ready: boolean; pending: number; failed: number; load: () => Promise; drain: () => Promise; retry: (key: string) => void; discard: (key: string) => void; clear: () => Promise; pendingItems: () => readonly OutboxItem[]; failedItems: () => readonly OutboxItem[]; } export const useOutboxStore = create()((set) => ({ ready: false, pending: 0, failed: 0, load: async () => { await queue.init(); // load persisted items (splits pending vs failed) set({ ready: true }); }, drain: async () => { await queue.drain(); }, retry: (key) => { const item = [...queue.failedItems, ...queue.pendingItems].find((i) => i.key === key); if (!item) return; dropMutation(item); // the original caller already settled (a 4xx) — a fresh attempt queue.retry(item); void queue.drain(); }, discard: (key) => { const item = [...queue.failedItems, ...queue.pendingItems].find((i) => i.key === key); if (!item) return; dropMutation(item); queue.discard(item); }, clear: async () => { // A signed-out user's queued mutations are discarded (the recorded rule: // logout = an explicit abandon). Drop the caller promises (they are orphaned). for (const item of [...queue.pendingItems, ...queue.failedItems]) dropMutation(item); await queue.clear(); set({ pending: 0, failed: 0 }); }, pendingItems: () => queue.pendingItems, failedItems: () => queue.failedItems, })); /* --------------------------------------------------- drain on reconnect */ /** * Wire the `online` event → drain (the spec's replay-on-reconnect). Also * drains once on `load` (a returning tab with a persisted outbox). Idempotent * (a module-level guard); called from the app provider / a hook. */ let listenersWired = false; export function initOutbox(): void { if (listenersWired) return; listenersWired = true; if (typeof window === "undefined") return; void useOutboxStore.getState().load(); window.addEventListener("online", () => { void useOutboxStore.getState().drain(); }); }