1"use client";
2
3
27import { create } from "zustand";
28import {
29 MemoryOutboxStore,
30 OutboxQueue,
31 type OutboxItem,
32 type OutboxResult,
33 type OutboxStore,
34} from "@factory/collaboration/outbox";
35
36
37
38
39const OUTBOX_DB = "factory-outbox";
40const OUTBOX_STORE = "items";
41
42function createOutboxStore(): OutboxStore {
43
44
45 if (typeof indexedDB === "undefined") return new MemoryOutboxStore();
46
47 const open = (): Promise<IDBDatabase> =>
48 new Promise((resolve, reject) => {
49 const req = indexedDB.open(OUTBOX_DB, 1);
50 req.onupgradeneeded = () => {
51 if (!req.result.objectStoreNames.contains(OUTBOX_STORE)) {
52 req.result.createObjectStore(OUTBOX_STORE, { keyPath: "key" });
53 }
54 };
55 req.onsuccess = () => resolve(req.result);
56 req.onerror = () => reject(req.error);
57 });
58
59 const run = async <T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest): Promise<T> => {
60 const db = await open();
61 return new Promise<T>((resolve, reject) => {
62 const tx = db.transaction(OUTBOX_STORE, mode);
63 const req = fn(tx.objectStore(OUTBOX_STORE));
64 req.onsuccess = () => resolve(req.result as T);
65 req.onerror = () => reject(req.error);
66 tx.oncomplete = () => db.close();
67 });
68 };
69
70 return {
71 load: async () => {
72 try {
73 const all = await run<OutboxItem[]>("readonly", (s) => s.getAll() as IDBRequest<OutboxItem[]>);
74 return all;
75 } catch {
76 return [];
77 }
78 },
79 save: async (items: readonly OutboxItem[]) => {
80 try {
81 await run("readwrite", (s) => {
82 const clearReq = s.clear();
83 for (const item of items) s.put(item);
84 return clearReq;
85 });
86 } catch {
87
88 }
89 },
90 };
91}
92
93
94
95
96type OutboxTransport = (item: OutboxItem) => Promise<OutboxResult>;
97const transportRef: { current: OutboxTransport | null } = { current: null };
98
99
100const queue = new OutboxQueue({
101 store: createOutboxStore(),
102 now: () => Date.now(),
103 send: (item) => {
104 const t = transportRef.current;
105 if (!t) return Promise.resolve({ ok: false, error: "outbox transport not registered" });
106 return t(item);
107 },
108 onCountChange: (c) => {
109 useOutboxStore.setState({ pending: c.pending, failed: c.failed, ready: true });
110 },
111 onEvicted: () => {
112
113
114 try {
115
116 void import("@/stores/ui-store").then(({ toast }) =>
117 toast("error", "Offline storage is full", "The oldest queued change was discarded."),
118 );
119 } catch {
120
121 }
122 },
123});
124
125
126export function registerOutboxTransport(transport: OutboxTransport): void {
127 transportRef.current = transport;
128}
129
130
131
132
138interface PendingMutation {
139 resolve: (body: unknown) => void;
140 reject: (err: unknown) => void;
141}
142const pendingMutations = new Map<string, PendingMutation>();
143
144
145
146const RESOURCE_ROOTS = new Set([
147 "pages",
148 "blocks",
149 "workspaces",
150 "comments",
151 "databases",
152 "records",
153 "templates",
154 "notifications",
155 "invites",
156 "files",
157 "teams",
158 "members",
159]);
160
161
172export function deriveOutboxResource(
173 path: string,
174 body?: Record<string, unknown> | null,
175): { type: string; id: string } {
176 const segs = (path.split("?")[0] ?? "").split("/").filter(Boolean);
177 const rootIdx = segs.findIndex((s) => RESOURCE_ROOTS.has(s));
178 if (rootIdx === -1) {
179
180 return { type: "unknown", id: segs.join("/") || "root" };
181 }
182 const root = segs[rootIdx] ?? "unknown";
183 const id = segs[rootIdx + 1];
184 if (id !== undefined) {
185
186
187 return { type: root, id };
188 }
189
190
191 const scope =
192 (typeof body?.pageId === "string" && body.pageId) ||
193 (typeof body?.workspaceId === "string" && body.workspaceId) ||
194 (typeof body?.databaseId === "string" && body.databaseId) ||
195 "__new__";
196 return { type: root, id: scope };
197}
198
199
200
201export interface EnqueueMutationInput {
202 method: OutboxItem["method"];
203 path: string;
204 body?: Record<string, unknown>;
205
206 resource?: { type: string; id: string };
207}
208
209
219export function enqueueMutation<T = unknown>(input: EnqueueMutationInput): Promise<T> {
220 const resource = input.resource ?? deriveOutboxResource(input.path, input.body);
221 const item = queue.enqueue(input.method, input.path, input.body, resource);
222 return new Promise<T>((resolve, reject) => {
223 pendingMutations.set(item.key, {
224 resolve: (body) => resolve(body as T),
225 reject,
226 });
227 });
228}
229
230
231export function settleMutationSuccess(item: OutboxItem, body: unknown): void {
232 const p = pendingMutations.get(item.key);
233 if (p) {
234 pendingMutations.delete(item.key);
235 p.resolve(body);
236 }
237}
238
239
240export function settleMutationFailure(item: OutboxItem, err: unknown): void {
241 const p = pendingMutations.get(item.key);
242 if (p) {
243 pendingMutations.delete(item.key);
244 p.reject(err);
245 }
246}
247
248
249export function dropMutation(item: OutboxItem): void {
250 pendingMutations.delete(item.key);
251}
252
253
254
255interface OutboxState {
256 ready: boolean;
257 pending: number;
258 failed: number;
259 load: () => Promise<void>;
260 drain: () => Promise<void>;
261 retry: (key: string) => void;
262 discard: (key: string) => void;
263 clear: () => Promise<void>;
264 pendingItems: () => readonly OutboxItem[];
265 failedItems: () => readonly OutboxItem[];
266}
267
268export const useOutboxStore = create<OutboxState>()((set) => ({
269 ready: false,
270 pending: 0,
271 failed: 0,
272 load: async () => {
273 await queue.init();
274 set({ ready: true });
275 },
276 drain: async () => {
277 await queue.drain();
278 },
279 retry: (key) => {
280 const item = [...queue.failedItems, ...queue.pendingItems].find((i) => i.key === key);
281 if (!item) return;
282 dropMutation(item);
283 queue.retry(item);
284 void queue.drain();
285 },
286 discard: (key) => {
287 const item = [...queue.failedItems, ...queue.pendingItems].find((i) => i.key === key);
288 if (!item) return;
289 dropMutation(item);
290 queue.discard(item);
291 },
292 clear: async () => {
293
294
295 for (const item of [...queue.pendingItems, ...queue.failedItems]) dropMutation(item);
296 await queue.clear();
297 set({ pending: 0, failed: 0 });
298 },
299 pendingItems: () => queue.pendingItems,
300 failedItems: () => queue.failedItems,
301}));
302
303
304
305
310let listenersWired = false;
311export function initOutbox(): void {
312 if (listenersWired) return;
313 listenersWired = true;
314 if (typeof window === "undefined") return;
315 void useOutboxStore.getState().load();
316 window.addEventListener("online", () => {
317 void useOutboxStore.getState().drain();
318 });
319}
320