ai-chatbot/lib/video-canvas/save-queue.ts

157 lines
4.8 KiB
TypeScript

import type { CanvasConflictChoice } from "./merge-snapshot";
export type VideoCanvasSaveStatus = "saved" | "pending" | "saving" | "error";
/** Keep edits made during a request; never retry a stale revision as an overwrite. */
export class VideoCanvasSaveQueue {
private latest: string;
private saved: string;
private running: Promise<void> | null = null;
private blocked = false;
private conflict: CanvasRevisionConflict | null = null;
private status: VideoCanvasSaveStatus = "saved";
constructor(
baseline: string,
private revision: string,
private readonly write: (snapshot: string, revision: string) => Promise<string>,
private readonly changed: (status: VideoCanvasSaveStatus, message?: string) => void,
private readonly reconciliation?: {
merge: (
base: string,
local: string,
remote: string,
choice?: CanvasConflictChoice,
) => { snapshot: string; conflicts: string[] };
apply: (snapshot: string) => void;
conflict: (fields: string[]) => void;
},
) {
this.latest = baseline;
this.saved = baseline;
}
get backup() {
return { base: this.saved, snapshot: this.latest, revision: this.revision };
}
restoreBackup(base: string, snapshot: string) {
if (!this.reconciliation || snapshot === base) return;
const remote = new CanvasRevisionConflict(
"检测到未保存草稿与服务器有冲突,请选择保留哪一处修改",
this.saved,
this.revision,
);
const merged = this.reconciliation.merge(base, snapshot, this.saved);
if (merged.conflicts.length) {
this.saved = base;
this.latest = snapshot;
this.conflict = remote;
this.blocked = true;
this.reconciliation.apply(snapshot);
this.reconciliation.conflict(merged.conflicts);
this.setStatus("error", remote.message);
} else {
this.rebase(merged.snapshot, remote);
this.setStatus(this.latest === this.saved ? "saved" : "pending");
}
}
get failed() {
return this.status === "error";
}
get dirty() {
return this.latest !== this.saved || this.running !== null;
}
update(snapshot: string) {
this.latest = snapshot;
if (!this.running && this.status !== "error") {
this.setStatus(this.dirty ? "pending" : "saved");
}
}
private setStatus(status: VideoCanvasSaveStatus, message?: string) {
this.status = status;
this.changed(status, message);
}
flush(): Promise<void> {
if (this.running) return this.running;
if (this.blocked || !this.dirty) return Promise.resolve();
this.running = this.drain().finally(() => {
this.running = null;
});
return this.running;
}
private rebase(snapshot: string, conflict: CanvasRevisionConflict) {
this.saved = conflict.remoteSnapshot!;
this.revision = conflict.remoteRevision!;
this.latest = snapshot;
this.conflict = null;
this.blocked = false;
this.reconciliation?.conflict([]);
this.reconciliation?.apply(snapshot);
}
async resolveConflict(choice: CanvasConflictChoice) {
await this.running;
if (!this.conflict?.remoteSnapshot || !this.reconciliation) return;
const merged = this.reconciliation.merge(
this.saved,
this.latest,
this.conflict.remoteSnapshot,
choice,
);
this.rebase(merged.snapshot, this.conflict);
await this.flush();
}
private async drain() {
this.setStatus("saving");
try {
let merges = 0;
while (this.latest !== this.saved) {
const snapshot = this.latest;
try {
this.revision = await this.write(snapshot, this.revision);
this.saved = snapshot;
} catch (error) {
if (
error instanceof CanvasRevisionConflict &&
error.remoteSnapshot &&
error.remoteRevision &&
this.reconciliation
) {
this.conflict = error;
const merged = this.reconciliation.merge(this.saved, this.latest, error.remoteSnapshot);
if (merged.conflicts.length) {
this.reconciliation.conflict(merged.conflicts);
throw error;
}
this.rebase(merged.snapshot, error);
if (merges++ >= 3) {
throw new Error("其他页面正在频繁保存,当前修改已保留,请稍后重试保存");
}
continue;
}
throw error;
}
}
this.setStatus("saved");
} catch (error) {
this.blocked = error instanceof CanvasRevisionConflict;
this.setStatus("error", error instanceof Error ? error.message : "保存失败,请重试");
}
}
}
export class CanvasRevisionConflict extends Error {
constructor(
message: string,
public remoteSnapshot?: string,
public remoteRevision?: string,
) {
super(message);
}
}