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; } }