157 lines
4.8 KiB
TypeScript
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);
|
|
}
|
|
}
|