Files
brain/web/lib/page-autosave.ts
T
Andrew S Erwin b9721ff13f
release gate / check (push) Failing after 8m35s
initial commit on Federted servers
2026-10-19 16:23:55 -05:00

216 lines
9.4 KiB
TypeScript

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<typeof setTimeout> | null
saving: Promise<boolean> | 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<string, Draft>()
// 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<string, number>()
constructor(
// Resolves with the server's new version when the response carries one.
private save: (id: string, content: string, version: number | undefined) => Promise<number | void>,
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<boolean> {
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<boolean> {
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<boolean> {
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)
}
}