Files
buzz-sheet/lib/editor/autosave.ts
T

167 lines
4.8 KiB
TypeScript

export type AutosaveResponse<Snapshot = unknown> =
| { 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<Draft, Payload, Snapshot> {
initialVersion: number;
createPayload: (draft: Draft, expectedVersion: number) => Payload;
save: (payload: Payload) => Promise<AutosaveResponse<Snapshot>>;
onStatus?: (status: AutosaveStatus) => void;
onSaved?: (snapshot: Snapshot | undefined, version: number) => void;
debounceMs?: number;
maxRetries?: number;
retryBaseMs?: number;
retryCapMs?: number;
}
export class AutosaveCoordinator<Draft, Payload, Snapshot = unknown> {
private version: number;
private pending: Draft | undefined;
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
private retryTimer: ReturnType<typeof setTimeout> | 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<void> {
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<void> {
if (this.disposed) return;
this.emit({ state: "saving", attempt: attempt + 1 });
let response: AutosaveResponse<Snapshot>;
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;
}
}