188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
import type { ProjectEditableState } from "@dada/shared-contracts";
|
|
|
|
export type ProjectSaveStatus = "dirty" | "saving" | "saved" | "failed" | "conflicted";
|
|
|
|
export class ProjectStateConflict extends Error {
|
|
readonly latestStateVersion: number;
|
|
|
|
constructor(latestStateVersion: number) {
|
|
super("project_state_conflict");
|
|
this.latestStateVersion = latestStateVersion;
|
|
}
|
|
}
|
|
|
|
export class ConflictExportGuard {
|
|
used = false;
|
|
|
|
async run(action: () => Promise<void>) {
|
|
if (this.used) return false;
|
|
this.used = true;
|
|
await action();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export class SessionHistory<T> {
|
|
private current: T;
|
|
private readonly past: T[] = [];
|
|
private readonly future: T[] = [];
|
|
|
|
constructor(initial: T) {
|
|
this.current = structuredClone(initial);
|
|
}
|
|
|
|
get canRedo() { return this.future.length > 0; }
|
|
get canUndo() { return this.past.length > 0; }
|
|
get value() { return structuredClone(this.current); }
|
|
|
|
commit(next: T) {
|
|
this.past.push(structuredClone(this.current));
|
|
this.current = structuredClone(next);
|
|
this.future.length = 0;
|
|
}
|
|
|
|
undo() {
|
|
const previous = this.past.pop();
|
|
if (previous === undefined) return undefined;
|
|
this.future.push(structuredClone(this.current));
|
|
this.current = previous;
|
|
return structuredClone(this.current);
|
|
}
|
|
|
|
redo() {
|
|
const next = this.future.pop();
|
|
if (next === undefined) return undefined;
|
|
this.past.push(structuredClone(this.current));
|
|
this.current = next;
|
|
return structuredClone(this.current);
|
|
}
|
|
}
|
|
|
|
interface PendingSave {
|
|
operationId: string;
|
|
snapshot: ProjectEditableState;
|
|
}
|
|
|
|
export class ProjectAutoSaveQueue {
|
|
status: ProjectSaveStatus = "saved";
|
|
stateVersion: number;
|
|
conflictVersion?: number;
|
|
|
|
private disposed = false;
|
|
private inFlight: Promise<boolean> | undefined;
|
|
private lastSaved: ProjectEditableState;
|
|
private pending: PendingSave | undefined;
|
|
private retryIndex = 0;
|
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
private readonly debounceMs: number;
|
|
private readonly onConflict: ((latestVersion: number) => void) | undefined;
|
|
private readonly onSaved: ((snapshot: ProjectEditableState, stateVersion: number) => void) | undefined;
|
|
private readonly onStatus: ((status: ProjectSaveStatus) => void) | undefined;
|
|
private readonly retryDelaysMs: number[];
|
|
private readonly save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>;
|
|
|
|
constructor(input: {
|
|
debounceMs?: number;
|
|
initialState: ProjectEditableState;
|
|
initialVersion: number;
|
|
onConflict?: (latestVersion: number) => void;
|
|
onSaved?: (snapshot: ProjectEditableState, stateVersion: number) => void;
|
|
onStatus?: (status: ProjectSaveStatus) => void;
|
|
retryDelaysMs?: number[];
|
|
save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>;
|
|
}) {
|
|
this.debounceMs = input.debounceMs ?? 1_000;
|
|
this.lastSaved = structuredClone(input.initialState);
|
|
this.onConflict = input.onConflict;
|
|
this.onSaved = input.onSaved;
|
|
this.onStatus = input.onStatus;
|
|
this.retryDelaysMs = input.retryDelaysMs ?? [1_000, 2_000, 4_000, 8_000, 15_000];
|
|
this.save = input.save;
|
|
this.stateVersion = input.initialVersion;
|
|
}
|
|
|
|
commit(snapshot: ProjectEditableState) {
|
|
if (this.disposed || this.status === "conflicted") return;
|
|
this.pending = { operationId: crypto.randomUUID(), snapshot: structuredClone(snapshot) };
|
|
if (!this.inFlight) {
|
|
this.setStatus("dirty");
|
|
this.schedule(this.debounceMs);
|
|
}
|
|
}
|
|
|
|
async saveNow() {
|
|
if (this.disposed || (this.status as ProjectSaveStatus) === "conflicted") return false;
|
|
this.clearTimer();
|
|
if (this.inFlight) await this.inFlight;
|
|
if (this.disposed || this.status === "conflicted") return false;
|
|
if (!this.pending) return this.status === "saved";
|
|
return this.startSave();
|
|
}
|
|
|
|
dispose() {
|
|
this.disposed = true;
|
|
this.clearTimer();
|
|
}
|
|
|
|
private clearTimer() {
|
|
if (this.timer) clearTimeout(this.timer);
|
|
this.timer = undefined;
|
|
}
|
|
|
|
private schedule(delay: number) {
|
|
this.clearTimer();
|
|
this.timer = setTimeout(() => {
|
|
this.timer = undefined;
|
|
void this.startSave();
|
|
}, delay);
|
|
}
|
|
|
|
private startSave() {
|
|
if (this.inFlight || !this.pending || this.disposed || this.status === "conflicted") {
|
|
return this.inFlight ?? Promise.resolve(false);
|
|
}
|
|
const request = this.pending;
|
|
this.pending = undefined;
|
|
this.setStatus("saving");
|
|
const attempt = this.save(structuredClone(request.snapshot), this.stateVersion, request.operationId)
|
|
.then((result) => {
|
|
this.stateVersion = result.stateVersion;
|
|
this.lastSaved = structuredClone(request.snapshot);
|
|
this.retryIndex = 0;
|
|
this.onSaved?.(structuredClone(request.snapshot), this.stateVersion);
|
|
if (this.pending) {
|
|
this.setStatus("dirty");
|
|
this.schedule(this.debounceMs);
|
|
} else {
|
|
this.setStatus("saved");
|
|
}
|
|
return true;
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (error instanceof ProjectStateConflict) {
|
|
this.pending = undefined;
|
|
this.conflictVersion = error.latestStateVersion;
|
|
this.setStatus("conflicted");
|
|
this.onConflict?.(error.latestStateVersion);
|
|
return false;
|
|
}
|
|
if (!this.pending) this.pending = request;
|
|
this.setStatus("failed");
|
|
const delay = this.retryDelaysMs[Math.min(this.retryIndex, this.retryDelaysMs.length - 1)] ?? 15_000;
|
|
this.retryIndex += 1;
|
|
this.schedule(delay);
|
|
return false;
|
|
})
|
|
.finally(() => {
|
|
if (this.inFlight === attempt) this.inFlight = undefined;
|
|
});
|
|
this.inFlight = attempt;
|
|
return attempt;
|
|
}
|
|
|
|
private setStatus(status: ProjectSaveStatus) {
|
|
this.status = status;
|
|
this.onStatus?.(status);
|
|
}
|
|
}
|