"use client"; /** * BlockBatchQueue — the debounced, order-preserving block-save queue. * * Domain logic is kept OUT of React (AGENTS.md: keep domain logic * independent of UI): the queue is a plain class, one instance per page * (shared between the editor's useBlockSaveQueue and the useBatchSave hook * so they never double-flush). Ops are appended FIFO and flushed as ONE * POST /blocks/batch at most `debounceMs` after the last enqueue. On a * failed flush the ops are re-queued (ahead of any newer ops) so a * transient failure retries on the next change / unmount flush. */ import { blockApi, type BatchBlockOp } from "./api"; import { BLOCK_SAVE_DEBOUNCE_MS } from "./constants"; export class BlockBatchQueue { private ops: BatchBlockOp[] = []; private timer: ReturnType | null = null; private inFlight: Promise | null = null; constructor( private readonly pageId: string, private readonly debounceMs: number = BLOCK_SAVE_DEBOUNCE_MS ) {} get pendingCount(): number { return this.ops.length; } /** Enqueue one or more ops; (re)arms the debounce timer. */ queue(op: BatchBlockOp | BatchBlockOp[]): void { this.ops.push(...(Array.isArray(op) ? op : [op])); if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(() => { void this.flush(); }, this.debounceMs); } /** Flush immediately (navigation, unmount). Resolves when the batch lands. */ flush(): Promise { if (this.timer) { clearTimeout(this.timer); this.timer = null; } if (this.ops.length === 0) return Promise.resolve(); const ops = this.ops.splice(0, this.ops.length); this.inFlight = blockApi .batch(this.pageId, ops) .catch(() => { // Re-queue ahead of anything newer; the next change or unmount // flush retries. (Block content is last-writer-wins, so replaying // the same ops is safe.) this.ops = [...ops, ...this.ops]; }) .finally(() => { this.inFlight = null; }); return this.inFlight; } /** * Drop all pending (not-yet-flushed) ops that touch one block — e.g. the * editor's delete-on-blur cleanup removed a block that still has a * debounced content save queued. Without this the next flush would send a * `update` for a deleted id and the batch endpoint 400s (the op is * validated against the row), aborting the whole batch including the * delete's own flush. */ dropBlock(blockId: string): void { if (this.ops.length === 0) return; this.ops = this.ops.filter((op) => op.blockId !== blockId && op.block?.id !== blockId); } /** Cancel any pending debounce without flushing (page discarded). */ dispose(): void { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } } const queues = new Map(); /** Shared per-page queue instance (the whole tab edits one page at a time). */ export function getBlockBatchQueue(pageId: string, debounceMs?: number): BlockBatchQueue { let q = queues.get(pageId); if (!q) { q = new BlockBatchQueue(pageId, debounceMs); queues.set(pageId, q); } return q; } /** Test hook: drop all page queues. */ export function resetBlockBatchQueues(): void { queues.clear(); }