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) { if (this.used) return false; this.used = true; await action(); return true; } } export class SessionHistory { 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 | undefined; private lastSaved: ProjectEditableState; private pending: PendingSave | undefined; private retryIndex = 0; private timer: ReturnType | 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); } }