feat: complete TASK-WP2-02 project autosave

This commit is contained in:
suyx
2026-08-02 18:28:07 +08:00
parent 4d38530361
commit da6fa25e60
17 changed files with 2268 additions and 35 deletions
+187
View File
@@ -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);
}
}