fix(autosave) : retry constellation version conflicts
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { saveWithConflictRetry } from "@/lib/editor/autosave";
|
||||||
import { parseLocalDraft as parseStoredDraft, serializeLocalDraft } from "@/lib/editor/local-draft";
|
import { parseLocalDraft as parseStoredDraft, serializeLocalDraft } from "@/lib/editor/local-draft";
|
||||||
|
|
||||||
export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error";
|
export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error";
|
||||||
@@ -95,7 +96,9 @@ export function useAutosave<Value, Draft = Value>({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setErrorFingerprint(null);
|
setErrorFingerprint(null);
|
||||||
try {
|
try {
|
||||||
const result = await persist(snapshot, version);
|
const attempt = await saveWithConflictRetry(persist, snapshot, version);
|
||||||
|
const result = attempt.response;
|
||||||
|
if (attempt.retried) setVersion(attempt.version);
|
||||||
if (result.status === "error" || result.status === "conflict") {
|
if (result.status === "error" || result.status === "conflict") {
|
||||||
if (result.currentVersion) setVersion(result.currentVersion);
|
if (result.currentVersion) setVersion(result.currentVersion);
|
||||||
setErrorFingerprint(snapshotFingerprint);
|
setErrorFingerprint(snapshotFingerprint);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AutosaveCoordinator,
|
AutosaveCoordinator,
|
||||||
|
saveWithConflictRetry,
|
||||||
type AutosaveResponse,
|
type AutosaveResponse,
|
||||||
type AutosaveStatus,
|
type AutosaveStatus,
|
||||||
} from "./autosave";
|
} from "./autosave";
|
||||||
@@ -114,3 +115,24 @@ describe("AutosaveCoordinator", () => {
|
|||||||
expect(invalid.statuses.at(-1)).toMatchObject({ state: "error", issues: ["title"] });
|
expect(invalid.statuses.at(-1)).toMatchObject({ state: "error", issues: ["title"] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("saveWithConflictRetry", () => {
|
||||||
|
it("retries one version conflict against the current server version", async () => {
|
||||||
|
const calls: number[] = [];
|
||||||
|
const save = vi.fn(async (_value: string, expectedVersion: number) => {
|
||||||
|
calls.push(expectedVersion);
|
||||||
|
if (calls.length === 1) return { status: "conflict" as const, currentVersion: 8 };
|
||||||
|
return { status: "saved" as const, version: 9 };
|
||||||
|
});
|
||||||
|
const result = await saveWithConflictRetry(save, "constellation draft", 7);
|
||||||
|
expect(calls).toEqual([7, 8]);
|
||||||
|
expect(result).toEqual({ response: { status: "saved", version: 9 }, version: 8, retried: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after one retry when another concurrent save wins", async () => {
|
||||||
|
const save = vi.fn(async (_value: string, expectedVersion: number) => ({ status: "conflict" as const, currentVersion: expectedVersion + 1 }));
|
||||||
|
const result = await saveWithConflictRetry(save, "constellation draft", 3);
|
||||||
|
expect(save).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.response).toEqual({ status: "conflict", currentVersion: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ export type AutosaveStatus =
|
|||||||
| { state: "conflict"; currentVersion: number }
|
| { state: "conflict"; currentVersion: number }
|
||||||
| { state: "error"; message: string; issues?: string[] };
|
| { state: "error"; message: string; issues?: string[] };
|
||||||
|
|
||||||
|
export async function saveWithConflictRetry<Value, Response extends { status: string; currentVersion?: number }>(
|
||||||
|
save: (value: Value, expectedVersion: number) => Promise<Response>,
|
||||||
|
value: Value,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<{ response: Response; version: number; retried: boolean }> {
|
||||||
|
const first = await save(value, expectedVersion);
|
||||||
|
if (first.status !== "conflict" || !first.currentVersion || first.currentVersion === expectedVersion) {
|
||||||
|
return { response: first, version: expectedVersion, retried: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
response: await save(value, first.currentVersion),
|
||||||
|
version: first.currentVersion,
|
||||||
|
retried: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface AutosaveCoordinatorOptions<Draft, Payload, Snapshot> {
|
interface AutosaveCoordinatorOptions<Draft, Payload, Snapshot> {
|
||||||
initialVersion: number;
|
initialVersion: number;
|
||||||
createPayload: (draft: Draft, expectedVersion: number) => Payload;
|
createPayload: (draft: Draft, expectedVersion: number) => Payload;
|
||||||
|
|||||||
Reference in New Issue
Block a user