From 0d23871499f88fb99c384a4fffc8d53574d82775 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 28 Aug 2026 19:24:35 +0000 Subject: [PATCH] feat(editor): add serialized autosave coordinator --- lib/editor/autosave.test.ts | 116 +++++++++++++++++++++++++ lib/editor/autosave.ts | 166 ++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 lib/editor/autosave.test.ts create mode 100644 lib/editor/autosave.ts diff --git a/lib/editor/autosave.test.ts b/lib/editor/autosave.test.ts new file mode 100644 index 0000000..32c9792 --- /dev/null +++ b/lib/editor/autosave.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + AutosaveCoordinator, + type AutosaveResponse, + type AutosaveStatus, +} from "./autosave"; + +afterEach(() => { + vi.useRealTimers(); +}); + +async function advance(milliseconds: number) { + vi.advanceTimersByTime(milliseconds); + await Promise.resolve(); + await Promise.resolve(); +} + +function harness( + responses: AutosaveResponse[] = [ + { status: "saved", version: 2 }, + ], +) { + const saves: Array<{ value: string; expectedVersion: number }> = []; + const statuses: AutosaveStatus[] = []; + const save = vi.fn(async (payload: { value: string; expectedVersion: number }) => { + saves.push(payload); + return responses.shift() ?? { status: "saved" as const, version: payload.expectedVersion + 1 }; + }); + const coordinator = new AutosaveCoordinator({ + initialVersion: 1, + createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }), + save, + onStatus: (status) => statuses.push(status), + debounceMs: 750, + retryBaseMs: 100, + retryCapMs: 400, + maxRetries: 2, + }); + return { coordinator, save, saves, statuses }; +} + +describe("AutosaveCoordinator", () => { + it("debounces edits and sends only the latest draft", async () => { + vi.useFakeTimers(); + const { coordinator, save, saves } = harness(); + coordinator.schedule("หนึ่ง"); + await advance(500); + coordinator.schedule("สอง"); + await advance(749); + expect(save).not.toHaveBeenCalled(); + await advance(1); + expect(saves).toEqual([{ value: "สอง", expectedVersion: 1 }]); + }); + + it("serializes a new edit behind an in-flight save", async () => { + vi.useFakeTimers(); + let resolveFirst: ((value: AutosaveResponse) => void) | undefined; + const saves: string[] = []; + const coordinator = new AutosaveCoordinator({ + initialVersion: 4, + debounceMs: 10, + createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }), + save: async ({ value, expectedVersion }) => { + saves.push(`${value}:${expectedVersion}`); + if (saves.length === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return { status: "saved", version: expectedVersion + 1 }; + }, + }); + coordinator.schedule("แรก"); + await advance(10); + coordinator.schedule("ล่าสุด"); + await advance(10); + expect(saves).toEqual(["แรก:4"]); + resolveFirst?.({ status: "saved", version: 5 }); + await advance(0); + expect(saves).toEqual(["แรก:4", "ล่าสุด:5"]); + }); + + it("retries only transient failures with capped exponential delays", async () => { + vi.useFakeTimers(); + const { coordinator, save } = harness([ + { status: "transient-error", message: "network" }, + { status: "transient-error", message: "network" }, + { status: "saved", version: 2 }, + ]); + coordinator.schedule("ร่าง"); + await advance(750); + expect(save).toHaveBeenCalledTimes(1); + await advance(99); + expect(save).toHaveBeenCalledTimes(1); + await advance(1); + expect(save).toHaveBeenCalledTimes(2); + await advance(200); + expect(save).toHaveBeenCalledTimes(3); + }); + + it("stops on conflicts and validation errors", async () => { + vi.useFakeTimers(); + const conflict = harness([{ status: "conflict", currentVersion: 8 }]); + conflict.coordinator.schedule("ชนกัน"); + await advance(750); + expect(conflict.save).toHaveBeenCalledTimes(1); + expect(conflict.statuses.at(-1)).toEqual({ state: "conflict", currentVersion: 8 }); + + const invalid = harness([{ status: "validation-error", issues: ["title"] }]); + invalid.coordinator.schedule("ผิด"); + await advance(750); + expect(invalid.save).toHaveBeenCalledTimes(1); + expect(invalid.statuses.at(-1)).toMatchObject({ state: "error", issues: ["title"] }); + }); +}); diff --git a/lib/editor/autosave.ts b/lib/editor/autosave.ts new file mode 100644 index 0000000..ab6f792 --- /dev/null +++ b/lib/editor/autosave.ts @@ -0,0 +1,166 @@ +export type AutosaveResponse = + | { status: "saved"; version: number; snapshot?: Snapshot } + | { status: "conflict"; currentVersion: number } + | { status: "validation-error"; issues: string[] } + | { status: "transient-error"; message: string }; + +export type AutosaveStatus = + | { state: "idle" } + | { state: "dirty" } + | { state: "saving"; attempt: number } + | { state: "saved"; version: number; savedAt: Date } + | { state: "conflict"; currentVersion: number } + | { state: "error"; message: string; issues?: string[] }; + +interface AutosaveCoordinatorOptions { + initialVersion: number; + createPayload: (draft: Draft, expectedVersion: number) => Payload; + save: (payload: Payload) => Promise>; + onStatus?: (status: AutosaveStatus) => void; + onSaved?: (snapshot: Snapshot | undefined, version: number) => void; + debounceMs?: number; + maxRetries?: number; + retryBaseMs?: number; + retryCapMs?: number; +} + +export class AutosaveCoordinator { + private version: number; + private pending: Draft | undefined; + private debounceTimer: ReturnType | undefined; + private retryTimer: ReturnType | undefined; + private saving = false; + private disposed = false; + private generation = 0; + + constructor( + private readonly options: AutosaveCoordinatorOptions< + Draft, + Payload, + Snapshot + >, + ) { + this.version = options.initialVersion; + this.emit({ state: "idle" }); + } + + get currentVersion(): number { + return this.version; + } + + schedule(draft: Draft): void { + if (this.disposed) return; + this.pending = draft; + this.generation += 1; + this.clearDebounce(); + this.clearRetry(); + this.emit({ state: "dirty" }); + this.debounceTimer = setTimeout( + () => void this.flush(), + this.options.debounceMs ?? 750, + ); + } + + async flush(): Promise { + this.clearDebounce(); + if (this.disposed || this.saving || this.pending === undefined) return; + + const draft = this.pending; + const generation = this.generation; + this.pending = undefined; + this.saving = true; + await this.attempt(draft, generation, 0); + } + + dispose(): void { + this.disposed = true; + this.clearDebounce(); + this.clearRetry(); + this.pending = undefined; + } + + private async attempt( + draft: Draft, + generation: number, + attempt: number, + ): Promise { + if (this.disposed) return; + this.emit({ state: "saving", attempt: attempt + 1 }); + + let response: AutosaveResponse; + try { + response = await this.options.save( + this.options.createPayload(draft, this.version), + ); + } catch (cause) { + response = { + status: "transient-error", + message: cause instanceof Error ? cause.message : "บันทึกไม่สำเร็จ", + }; + } + + if (this.disposed) return; + if (response.status === "saved") { + this.version = response.version; + this.saving = false; + this.options.onSaved?.(response.snapshot, response.version); + this.emit({ state: "saved", version: response.version, savedAt: new Date() }); + if (this.pending !== undefined) void this.flush(); + return; + } + + if (response.status === "conflict") { + this.saving = false; + this.pending = draft; + this.emit({ state: "conflict", currentVersion: response.currentVersion }); + return; + } + + if (response.status === "validation-error") { + this.saving = false; + this.emit({ + state: "error", + message: "ข้อมูลบางรายการไม่ถูกต้อง", + issues: response.issues, + }); + if (this.pending !== undefined && generation !== this.generation) { + this.schedule(this.pending); + } + return; + } + + const maxRetries = this.options.maxRetries ?? 3; + if (attempt < maxRetries && generation === this.generation) { + const delay = Math.min( + (this.options.retryBaseMs ?? 300) * 2 ** attempt, + this.options.retryCapMs ?? 2_400, + ); + this.retryTimer = setTimeout( + () => void this.attempt(draft, generation, attempt + 1), + delay, + ); + return; + } + + this.saving = false; + if (generation === this.generation) this.pending = draft; + this.emit({ state: "error", message: response.message }); + if (this.pending !== undefined && generation !== this.generation) { + this.schedule(this.pending); + } + } + + private emit(status: AutosaveStatus): void { + this.options.onStatus?.(status); + } + + private clearDebounce(): void { + if (this.debounceTimer !== undefined) clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + + private clearRetry(): void { + if (this.retryTimer !== undefined) clearTimeout(this.retryTimer); + this.retryTimer = undefined; + } +}