import type { Id } from "@/convex/_generated/dataModel"; const DB_NAME = "lemonspace.canvas.sync"; const DB_VERSION = 1; const STORE_NAME = "ops"; const FALLBACK_STORAGE_KEY = "lemonspace.canvas:sync-fallback:v1"; export const CANVAS_SYNC_RETENTION_MS = 24 * 60 * 60 * 1000; export type CanvasSyncOpPayloadByType = { moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number }; resizeNode: { nodeId: Id<"nodes">; width: number; height: number }; updateData: { nodeId: Id<"nodes">; data: unknown }; }; export type CanvasSyncOpType = keyof CanvasSyncOpPayloadByType; type CanvasSyncOpBase = { id: string; canvasId: string; enqueuedAt: number; attemptCount: number; nextRetryAt: number; expiresAt: number; lastError?: string; }; export type CanvasSyncOp = { [TType in CanvasSyncOpType]: CanvasSyncOpBase & { type: TType; payload: CanvasSyncOpPayloadByType[TType]; }; }[CanvasSyncOpType]; type CanvasSyncOpFor = Extract< CanvasSyncOp, { type: TType } >; type JsonRecord = Record; type EnqueueInput = { id: string; canvasId: string; type: TType; payload: CanvasSyncOpPayloadByType[TType]; now?: number; }; let dbPromise: Promise | null = null; function isRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null; } function getLocalStorage(): Storage | null { if (typeof window === "undefined") return null; try { return window.localStorage; } catch { return null; } } function safeParse(raw: string | null): unknown { if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } function readFallbackOps(): CanvasSyncOp[] { const storage = getLocalStorage(); if (!storage) return []; const parsed = safeParse(storage.getItem(FALLBACK_STORAGE_KEY)); if (!Array.isArray(parsed)) return []; return parsed .filter((entry): entry is JsonRecord => isRecord(entry)) .map(normalizeOp) .filter((entry): entry is CanvasSyncOp => entry !== null); } function writeFallbackOps(ops: CanvasSyncOp[]): void { const storage = getLocalStorage(); if (!storage) return; try { storage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(ops)); } catch { // Ignore storage quota failures in fallback layer. } } function openDb(): Promise { if (typeof window === "undefined" || typeof indexedDB === "undefined") { return Promise.resolve(null); } if (dbPromise) return dbPromise; dbPromise = new Promise((resolve) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (db.objectStoreNames.contains(STORE_NAME)) return; const store = db.createObjectStore(STORE_NAME, { keyPath: "id" }); store.createIndex("by_canvas", "canvasId", { unique: false }); store.createIndex("by_nextRetryAt", "nextRetryAt", { unique: false }); }; request.onsuccess = () => { const db = request.result; db.onversionchange = () => { db.close(); dbPromise = null; }; resolve(db); }; request.onerror = () => { resolve(null); }; }); return dbPromise; } function txDone(tx: IDBTransaction): Promise { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed")); tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted")); }); } function reqToPromise(req: IDBRequest): Promise { return new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed")); }); } function getNodeIdFromOp(op: CanvasSyncOp): string { const payload = op.payload as { nodeId?: string }; return typeof payload.nodeId === "string" ? payload.nodeId : ""; } function normalizeOp(raw: unknown): CanvasSyncOp | null { if (!isRecord(raw)) return null; const id = raw.id; const canvasId = raw.canvasId; const type = raw.type; const payload = raw.payload; if ( typeof id !== "string" || !id || typeof canvasId !== "string" || !canvasId || (type !== "moveNode" && type !== "resizeNode" && type !== "updateData") ) { return null; } const enqueuedAt = typeof raw.enqueuedAt === "number" ? raw.enqueuedAt : Date.now(); const attemptCount = typeof raw.attemptCount === "number" ? raw.attemptCount : 0; const nextRetryAt = typeof raw.nextRetryAt === "number" ? raw.nextRetryAt : enqueuedAt; const expiresAt = typeof raw.expiresAt === "number" ? raw.expiresAt : enqueuedAt + CANVAS_SYNC_RETENTION_MS; const lastError = typeof raw.lastError === "string" ? raw.lastError : undefined; if (!isRecord(payload)) return null; if ( type === "moveNode" && typeof payload.nodeId === "string" && typeof payload.positionX === "number" && typeof payload.positionY === "number" ) { return { id, canvasId, type, payload: { nodeId: payload.nodeId as Id<"nodes">, positionX: payload.positionX, positionY: payload.positionY, }, enqueuedAt, attemptCount, nextRetryAt, expiresAt, lastError, }; } if ( type === "resizeNode" && typeof payload.nodeId === "string" && typeof payload.width === "number" && typeof payload.height === "number" ) { return { id, canvasId, type, payload: { nodeId: payload.nodeId as Id<"nodes">, width: payload.width, height: payload.height, }, enqueuedAt, attemptCount, nextRetryAt, expiresAt, lastError, }; } if (type === "updateData" && typeof payload.nodeId === "string") { return { id, canvasId, type, payload: { nodeId: payload.nodeId as Id<"nodes">, data: payload.data, }, enqueuedAt, attemptCount, nextRetryAt, expiresAt, lastError, }; } return null; } function sortByEnqueued(a: CanvasSyncOp, b: CanvasSyncOp): number { if (a.enqueuedAt === b.enqueuedAt) return a.id.localeCompare(b.id); return a.enqueuedAt - b.enqueuedAt; } function toStoredOp( input: EnqueueInput, ): CanvasSyncOpFor { const now = input.now ?? Date.now(); return { id: input.id, canvasId: input.canvasId, type: input.type, payload: input.payload, enqueuedAt: now, attemptCount: 0, nextRetryAt: now, expiresAt: now + CANVAS_SYNC_RETENTION_MS, } as CanvasSyncOpFor; } function coalescingNodeId( op: Pick, ): string | null { if (op.type !== "moveNode" && op.type !== "resizeNode" && op.type !== "updateData") { return null; } const payload = op.payload as { nodeId?: string }; return typeof payload.nodeId === "string" && payload.nodeId.length > 0 ? payload.nodeId : null; } export async function listCanvasSyncOps(canvasId: string): Promise { const db = await openDb(); if (!db) { return readFallbackOps() .filter((entry) => entry.canvasId === canvasId) .sort(sortByEnqueued); } const tx = db.transaction(STORE_NAME, "readonly"); const store = tx.objectStore(STORE_NAME); const byCanvas = store.index("by_canvas"); const records = await reqToPromise(byCanvas.getAll(canvasId)); return (records as unknown[]) .map(normalizeOp) .filter((entry): entry is CanvasSyncOp => entry !== null) .sort(sortByEnqueued); } export async function countCanvasSyncOps(canvasId: string): Promise { const db = await openDb(); if (!db) { return readFallbackOps().filter((entry) => entry.canvasId === canvasId).length; } const tx = db.transaction(STORE_NAME, "readonly"); const store = tx.objectStore(STORE_NAME); const byCanvas = store.index("by_canvas"); const count = await reqToPromise(byCanvas.count(canvasId)); return count; } export async function enqueueCanvasSyncOp( input: EnqueueInput, ): Promise<{ replacedIds: string[] }> { const op = toStoredOp(input); const existing = await listCanvasSyncOps(input.canvasId); const nodeId = coalescingNodeId(op); const replacedIds: string[] = []; for (const candidate of existing) { if (candidate.type !== op.type) continue; if (nodeId === null) continue; if (getNodeIdFromOp(candidate) !== nodeId) continue; replacedIds.push(candidate.id); } const db = await openDb(); if (!db) { const fallback = readFallbackOps().filter( (entry) => !replacedIds.includes(entry.id), ); fallback.push(op); writeFallbackOps(fallback); return { replacedIds }; } const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); for (const id of replacedIds) { store.delete(id); } store.put(op); await txDone(tx); return { replacedIds }; } export async function ackCanvasSyncOp(opId: string): Promise { const db = await openDb(); if (!db) { const fallback = readFallbackOps().filter((entry) => entry.id !== opId); writeFallbackOps(fallback); return; } const tx = db.transaction(STORE_NAME, "readwrite"); tx.objectStore(STORE_NAME).delete(opId); await txDone(tx); } export async function markCanvasSyncOpFailed( opId: string, opts: { nextRetryAt: number; lastError?: string }, ): Promise { const db = await openDb(); if (!db) { const fallback = readFallbackOps().map((entry) => { if (entry.id !== opId) return entry; return { ...entry, attemptCount: entry.attemptCount + 1, nextRetryAt: opts.nextRetryAt, lastError: opts.lastError, }; }); writeFallbackOps(fallback); return; } await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); const getReq = store.get(opId); getReq.onerror = () => reject(getReq.error ?? new Error("IndexedDB get failed")); getReq.onsuccess = () => { const current = normalizeOp(getReq.result); if (!current) return; const next: CanvasSyncOp = { ...current, attemptCount: current.attemptCount + 1, nextRetryAt: opts.nextRetryAt, lastError: opts.lastError, }; store.put(next); }; tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed")); tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted")); }); } export async function dropExpiredCanvasSyncOps( canvasId: string, now: number, ): Promise { const all = await listCanvasSyncOps(canvasId); const expiredIds = all .filter((entry) => entry.expiresAt <= now) .map((entry) => entry.id); if (expiredIds.length === 0) return []; const db = await openDb(); if (!db) { const fallback = readFallbackOps().filter((entry) => !expiredIds.includes(entry.id)); writeFallbackOps(fallback); return expiredIds; } const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); for (const id of expiredIds) { store.delete(id); } await txDone(tx); return expiredIds; }