feat: complete TASK-WP2-02 project autosave
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user