feat(editor): add serialized autosave coordinator
This commit is contained in:
@@ -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<AutosaveResponse>((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"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user