91 lines
3.1 KiB
TypeScript
91 lines
3.1 KiB
TypeScript
export type SaveResponse<Snapshot = unknown> =
|
|
| { status: "saved"; version: number; snapshot?: Snapshot }
|
|
| { status: "conflict"; currentVersion: number }
|
|
| { status: "validation-error"; issues: string[] }
|
|
| { status: "transient-error"; message: string };
|
|
|
|
export type SaveStatus =
|
|
| { state: "idle" }
|
|
| { state: "dirty" }
|
|
| { state: "saving" }
|
|
| { state: "saved"; version: number; savedAt: Date }
|
|
| { state: "conflict"; currentVersion: number }
|
|
| { state: "error"; message: string; issues?: string[] };
|
|
|
|
interface ManualSaveCoordinatorOptions<Draft, Payload, Snapshot> {
|
|
initialVersion: number;
|
|
createPayload: (draft: Draft, expectedVersion: number) => Payload;
|
|
save: (payload: Payload) => Promise<SaveResponse<Snapshot>>;
|
|
onStatus?: (status: SaveStatus) => void;
|
|
onSaved?: (snapshot: Snapshot | undefined, version: number) => void;
|
|
}
|
|
|
|
export class ManualSaveCoordinator<Draft, Payload, Snapshot = unknown> {
|
|
private version: number;
|
|
private pending: Draft | undefined;
|
|
private saving = false;
|
|
private disposed = false;
|
|
|
|
constructor(private readonly options: ManualSaveCoordinatorOptions<Draft, Payload, Snapshot>) {
|
|
this.version = options.initialVersion;
|
|
this.emit({ state: "idle" });
|
|
}
|
|
|
|
updateVersion(version: number): void {
|
|
this.version = Math.max(this.version, version);
|
|
}
|
|
|
|
stage(draft: Draft): void {
|
|
if (this.disposed) return;
|
|
this.pending = draft;
|
|
if (!this.saving) this.emit({ state: "dirty" });
|
|
}
|
|
|
|
cancelPending(): void {
|
|
this.pending = undefined;
|
|
}
|
|
|
|
async flush(): Promise<void> {
|
|
if (this.disposed || this.saving || this.pending === undefined) return;
|
|
const draft = this.pending;
|
|
this.pending = undefined;
|
|
this.saving = true;
|
|
this.emit({ state: "saving" });
|
|
try {
|
|
const response = await this.options.save(this.options.createPayload(draft, this.version));
|
|
if (this.disposed) return;
|
|
if (response.status === "saved") {
|
|
this.updateVersion(response.version);
|
|
if (Object.is(this.pending, draft)) this.pending = undefined;
|
|
this.options.onSaved?.(response.snapshot, this.version);
|
|
this.emit({ state: "saved", version: this.version, savedAt: new Date() });
|
|
} else {
|
|
this.pending ??= draft;
|
|
if (response.status === "conflict") {
|
|
this.emit({ state: "conflict", currentVersion: response.currentVersion });
|
|
} else if (response.status === "validation-error") {
|
|
this.emit({ state: "error", message: "ข้อมูลบางรายการไม่ถูกต้อง", issues: response.issues });
|
|
} else {
|
|
this.emit({ state: "error", message: response.message });
|
|
}
|
|
}
|
|
} catch (cause) {
|
|
if (!this.disposed) {
|
|
this.pending ??= draft;
|
|
this.emit({ state: "error", message: cause instanceof Error ? cause.message : "บันทึกไม่สำเร็จ" });
|
|
}
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
}
|
|
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
this.pending = undefined;
|
|
}
|
|
|
|
private emit(status: SaveStatus): void {
|
|
if (!this.disposed) this.options.onStatus?.(status);
|
|
}
|
|
}
|