/** * @factory/editor — 800 ms debounced autosave (TASK-0047 R5–R6). * * `AutosaveController` turns editor mutations into the batch wire format: * * 1. **Track** — `setState(blocks)` after every doc change; changed ids * (vs the last-saved baseline) are "dirty". * 2. **Debounce** — 800 ms after the last change (injectable clock/timers * for fake-timer tests). * 3. **Diff** — `diffBlocks(baseline, current)` → minimal * `insert | update | move | delete` ops (the exact `@factory/shared` * wire shape). * 4. **Flush** — `POST /v1/blocks` in chunks of ≤ 200 ops (the batch * endpoint caps at 500; we chunk at 200 per spec). * 5. **Conflict** — on `409 CONFLICT` (stale `version`): fetch the fresh * block, rebase (our content, server position/version) and retry ONCE; * second failure → `onConflict` (the "Reload page" toast). Never * silently overwrite. * * Save state (`idle | dirty | saving | saved | error | conflict`) is * observable for the top-bar indicator. */ import { diffBlocks } from "./codec.js"; import type { AutosaveConfig, BlockDto, ConflictInfo, EditorBlockOperation, SaveState, } from "./types.js"; const DEFAULT_DEBOUNCE_MS = 800; const DEFAULT_MAX_BATCH_OPS = 200; /** * Thrown by the transport on a `409 CONFLICT` batch response. Carries the * offending block id + the server's actual `version` (the 409 `details`). */ export class BlockConflictError extends Error { constructor( readonly blockId: string, readonly actualVersion: number, ) { super(`409 CONFLICT on block ${blockId} (server version ${actualVersion})`); this.name = "BlockConflictError"; } } type BlockLike = Pick; function replaceById( blocks: readonly BlockDto[], id: string, replacement: BlockDto, ): BlockDto[] { return blocks.map((b) => (b.id === id ? replacement : b)); } function findBlock(blocks: readonly BlockDto[], id: string): BlockDto | undefined { return blocks.find((b) => b.id === id); } /** * Rebase a local edit onto a fresher server row: our content/type, the * server's position/parentId/version (the conflict is a placement/version * race — we adopt the server's placement and keep our content). */ function buildRebased(fresh: BlockDto, local: BlockDto): BlockDto { // Keep the server row's identity/position/version, take the local // content + type, prefer local properties (fall back to the fresh // server properties). BlockDto is fully readonly — build the literal. return { id: fresh.id, type: local.type, content: local.content, parentId: fresh.parentId, position: fresh.position, version: fresh.version, ...(local.properties !== undefined ? { properties: local.properties } : fresh.properties !== undefined ? { properties: fresh.properties } : {}), }; } /** * The autosave pipeline. Owns no ProseMirror/Tiptap state — it consumes * snapshots of the block forest and emits ops through an injectable * transport (so the unit tests mock `fetch` cleanly). */ export class AutosaveController { private readonly config: AutosaveConfig; private baseline: BlockDto[]; private current: BlockDto[]; private timer: ReturnType | null = null; private state: SaveState = "idle"; private flushing = false; private flushAgain = false; private disposed = false; constructor( initialBlocks: readonly BlockDto[], config: AutosaveConfig, ) { this.config = config; this.baseline = [...initialBlocks]; this.current = [...initialBlocks]; } /** The current save-state (top-bar indicator). */ get saveState(): SaveState { return this.state; } /** Number of blocks pending persistence. */ pendingCount(): number { return this.changedIds().length; } /** * Record a new snapshot of the block forest (after an editor change) and * (re)schedule the debounced flush. No-op when the snapshot is unchanged. */ setState(blocks: readonly BlockDto[]): void { if (this.disposed) return; this.current = [...blocks]; if (this.pendingCount() === 0) { if (this.timer !== null) { this.clear(this.timer); this.timer = null; } this.setStateSafe("idle"); return; } this.setStateSafe("dirty"); if (this.timer !== null) return; // a debounce is already armed if (this.flushing) { // A flush is in flight: mark for one more pass when it completes // (the finally-block re-flushes while `flushAgain` is set). this.flushAgain = true; return; } const debounceMs = this.config.debounceMs ?? DEFAULT_DEBOUNCE_MS; this.timer = this.set( () => { this.timer = null; void this.flush(); }, debounceMs, ); } /** Force an immediate flush (blur / before-unload / manual save). */ async flush(): Promise { if (this.disposed) return; if (this.flushing) { this.flushAgain = true; return; } if (this.timer !== null) { this.clear(this.timer); this.timer = null; } this.flushing = true; try { await this.flushOnce(); } finally { this.flushing = false; if (this.flushAgain) { this.flushAgain = false; await this.flush(); } } } /** Flush (if dirty) and release timers. Call on page teardown. */ async dispose(): Promise { if (this.disposed) return; await this.flush(); this.disposed = true; if (this.timer !== null) { this.clear(this.timer); this.timer = null; } } // --- internals ------------------------------------------------------------- private changedIds(): string[] { const base = new Map(); for (const b of this.baseline) base.set(b.id, b); const now = new Map(); for (const b of this.current) now.set(b.id, b); const changed: string[] = []; for (const [id, b] of now) { const p = base.get(id); if (p === undefined) { changed.push(id); continue; } if (p.position !== b.position || p.parentId !== b.parentId || p.content !== b.content) { changed.push(id); } } for (const id of base.keys()) { if (!now.has(id)) changed.push(id); } return changed; } private async flushOnce(): Promise { const baseline = this.baseline; const current = this.current; const diff = diffBlocks(baseline, current); if (diff.operations.length === 0) { this.baseline = [...current]; this.setStateSafe("saved"); return; } this.setStateSafe("saving"); const maxBatch = this.config.maxBatchOps ?? DEFAULT_MAX_BATCH_OPS; const chunks = this.chunk(diff.operations, maxBatch); for (const chunk of chunks) { try { await this.config.transport.sendBatch(chunk); } catch (error) { if (error instanceof BlockConflictError) { const resolved = await this.rebaseAndRetry(error, chunk); if (!resolved) return; // conflict surfaced — stop continue; } this.setStateSafe("error"); this.config.onError?.(error); return; } } // All chunks applied server-side: the current snapshot is now the truth. this.baseline = [...this.current]; this.setStateSafe("saved"); } /** * 409 handling (TASK-0047 R6): fetch the fresh row, rebase — keep our * content/properties, adopt the server's `position`/`parentId`/`version` * — retry the chunk once. Second conflict → surface to the user. */ private async rebaseAndRetry( conflict: BlockConflictError, chunk: readonly EditorBlockOperation[], ): Promise { let fresh: BlockDto; try { fresh = await this.config.transport.fetchBlock(conflict.blockId); } catch (error) { this.setStateSafe("error"); this.config.onError?.(error); return true; // transport-level failure is handled as an error, not a conflict } // Rebase: our intended content (from `current`) on top of the server's // row (position/parentId/version). If we no longer have the block // (deleted locally), adopt the server row as-is — the delete op in the // chunk will re-apply on the fresh version. const local = findBlock(this.current, conflict.blockId); const rebased: BlockDto = local === undefined ? fresh : buildRebased(fresh, local); const apply = (blocks: readonly BlockDto[]): BlockDto[] => replaceById(blocks, conflict.blockId, rebased); this.baseline = apply(this.baseline); this.current = apply(this.current); // Retry the same chunk once (its ops are idempotent against the // rebased baseline). try { await this.config.transport.sendBatch(chunk); this.baseline = [...this.current]; this.setStateSafe("saved"); return true; } catch { const info: ConflictInfo = { id: conflict.blockId, actualVersion: fresh.version, }; this.setStateSafe("conflict"); this.config.onConflict?.(info, true); return false; } } private chunk(ops: readonly T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < ops.length; i += size) { out.push(ops.slice(i, i + size)); } return out; } private setStateSafe(state: SaveState): void { if (state === this.state) return; this.state = state; this.config.onStateChange?.(state); } private set(fn: () => void, ms: number): ReturnType { const set = this.config.setTimeout ?? globalThis.setTimeout; return set(fn, ms) as ReturnType; } private clear(handle: ReturnType): void { const clear = this.config.clearTimeout ?? globalThis.clearTimeout; clear(handle); } }