export type PageSaveState = 'saved' | 'saving' | 'dirty' | 'conflict' type Draft = { content: string revision: number savedRevision: number // The server version this content was edited from (review DATA-03). It is // persisted with the draft so a recovery after reload cannot silently // re-base onto a newer remote version. baseVersion: number | undefined // Server version reported by a 409 or detected during recovery, while // autosave is paused (review DATA-04). Typing never resolves a conflict. conflict: number | null timer: ReturnType | null saving: Promise | null } // Storage envelope: content plus the server version it was based on. Plain // string drafts written by older builds are still recovered, but their // unknown base pauses recovery for review (DATA-06) — never a silent rebase. type Envelope = { content: string; baseVersion?: number } // Thrown by the save callback when the server rejects a stale base version // (optimistic concurrency, review DATA-01). serverVersion is the revision the // server reported; the queue enters a paused conflict state instead of // adopting it — overwriting requires an explicit user decision (DATA-04). export class PageConflictError extends Error { constructor(readonly serverVersion?: number) { super('Page was modified elsewhere') } } // Each page owns its debounce and serial save queue. A late response cannot // overwrite a newer edit, and tab-local drafts survive a failed save/navigation. export class PageAutosave { private drafts = new Map() // Server revision per page: sent with content saves and refreshed from save // responses, so concurrent tabs/devices get 409s instead of silent overwrites. private versions = new Map() constructor( // Resolves with the server's new version when the response carries one. private save: (id: string, content: string, version: number | undefined) => Promise, private state: (id: string, state: PageSaveState) => void, ) {} setVersion(id: string, version: number) { this.versions.set(id, version) } private key(id: string) { return `computer:page-draft:${id}` } private store(id: string, draft: Draft | null) { try { if (draft === null) sessionStorage.removeItem(this.key(id)) else { const envelope: Envelope = { content: draft.content } if (draft.baseVersion !== undefined) envelope.baseVersion = draft.baseVersion sessionStorage.setItem(this.key(id), JSON.stringify(envelope)) } } catch { /* Saving to the API still works if browser storage is unavailable. */ } } // restore recovers an unsaved draft for id, comparing the version it was // based on with the freshly fetched server version (review DATA-03). A // mismatch means another session saved in between: the draft enters the // paused conflict state instead of being rebased onto the newer version. restore(id: string, serverVersion?: number): string | undefined { let draft = this.drafts.get(id) if (draft && draft.revision <= draft.savedRevision) draft = undefined if (!draft) { try { const raw = sessionStorage.getItem(this.key(id)) if (raw !== null) { const parsed: unknown = JSON.parse(raw) let content: string | undefined let baseVersion: number | undefined if (typeof parsed === 'string') content = parsed // legacy plain-string draft else if (parsed && typeof parsed === 'object') { const envelope = parsed as Envelope if (typeof envelope.content === 'string') content = envelope.content if (typeof envelope.baseVersion === 'number') baseVersion = envelope.baseVersion } if (content !== undefined) { draft = { content, revision: 1, savedRevision: 0, baseVersion, conflict: null, timer: null, saving: null } this.drafts.set(id, draft) } } } catch { /* Ignore unavailable storage or malformed drafts. */ } } if (!draft) return undefined // A known stale base (DATA-03) — or an unknown one (review DATA-06): // legacy plain-string drafts and envelopes stored before base tracking // cannot prove they predate the remote content, so recovery pauses them // for explicit review (compare via discard-to-load, copy, overwrite) // instead of letting the next keystroke save over a newer version. if (draft.conflict === null && typeof serverVersion === 'number' && (draft.baseVersion === undefined || draft.baseVersion !== serverVersion)) { draft.conflict = serverVersion } this.state(id, draft.conflict !== null ? 'conflict' : 'dirty') return draft.content } edit(id: string, content: string) { const draft = this.drafts.get(id) ?? { content, revision: 0, savedRevision: 0, baseVersion: this.versions.get(id), conflict: null, timer: null, saving: null, } draft.content = content draft.revision++ if (draft.timer) { clearTimeout(draft.timer); draft.timer = null } this.drafts.set(id, draft) this.store(id, draft) // Review DATA-04: while a conflict is unresolved, typing keeps the local // draft but never schedules a save — keystrokes are not consent to // overwrite another session's work. if (draft.conflict !== null) { this.state(id, 'conflict'); return } this.state(id, 'dirty') draft.timer = setTimeout(() => { void this.flush(id) }, 800) } async flush(id: string): Promise { const draft = this.drafts.get(id) if (!draft) return true if (draft.timer) { clearTimeout(draft.timer); draft.timer = null } if (draft.saving) return draft.saving if (draft.conflict !== null) return false if (draft.revision === draft.savedRevision) return true draft.saving = this.persist(id, draft) try { return await draft.saving } finally { draft.saving = null } } private async persist(id: string, draft: Draft): Promise { while (draft.savedRevision < draft.revision) { const revision = draft.revision const content = draft.content this.state(id, 'saving') try { // Review CR-17: the expected revision stays bound to the content // snapshot this draft was actually edited from (its baseVersion). // The shared version map can move via other responses; consulting it // here would let a metadata mutation silently rebase a stale draft // onto a newer remote save. The base advances only from an // acknowledged save of this content (below) or an explicit // conflict resolution (resolveOverwrite). const base = draft.baseVersion ?? this.versions.get(id) const version = await this.save(id, content, base) if (typeof version === 'number') { this.versions.set(id, version) draft.baseVersion = version } } catch (error) { if (error instanceof PageConflictError) { // Pause on the reported remote version without adopting it as the // save base; resolveOverwrite is the only path that rebases. draft.conflict = typeof error.serverVersion === 'number' ? error.serverVersion : (this.versions.get(id) ?? 0) this.state(id, 'conflict') return false } this.state(id, 'dirty') return false } draft.savedRevision = revision // Review DATA-07: refresh the stored envelope after every successful // iteration, not only from edit(). If a later save in this loop fails // (or a reload interrupts it), recovery must see the newest content // with the advanced base — a stale stored base would flag an already // clean draft as a spurious conflict. this.store(id, draft) } if (draft.timer) { clearTimeout(draft.timer); draft.timer = null } this.store(id, null) this.state(id, 'saved') return true } // conflictVersion reports the remote version a paused conflict was detected // against, or null when the page is not in the conflict state. conflictVersion(id: string): number | null { return this.drafts.get(id)?.conflict ?? null } // resolveOverwrite is the explicit user decision to replace the newer // remote content with the local draft (review DATA-04). async resolveOverwrite(id: string): Promise { const draft = this.drafts.get(id) if (!draft || draft.conflict === null) return true this.versions.set(id, draft.conflict) draft.baseVersion = draft.conflict draft.conflict = null // Review DATA-07: persist the rebased envelope immediately so a failed // overwrite save still recovers against the version it chose to replace. this.store(id, draft) this.state(id, 'dirty') return this.flush(id) } // discard drops the local draft after an explicit user decision; the caller // reloads the page so the editor shows the remote content again. discard(id: string) { const draft = this.drafts.get(id) if (draft?.timer) clearTimeout(draft.timer) this.drafts.delete(id) this.store(id, null) this.state(id, 'saved') } hasUnsaved() { return [...this.drafts.values()].some(draft => draft.revision > draft.savedRevision) } async flushAll() { return (await Promise.all([...this.drafts.keys()].map(id => this.flush(id)))).every(Boolean) } forget(id: string) { const draft = this.drafts.get(id) if (draft?.timer) clearTimeout(draft.timer) this.drafts.delete(id) this.versions.delete(id) this.store(id, null) } }