From c1b5ed867eb5836fb32c0c0ea019ec191a26dffa Mon Sep 17 00:00:00 2001 From: gunshiz Date: Wed, 2 Sep 2026 12:59:14 +0700 Subject: [PATCH] fix(autosave) : retry constellation version conflicts --- hooks/use-autosave.ts | 5 ++++- lib/editor/autosave.test.ts | 22 ++++++++++++++++++++++ lib/editor/autosave.ts | 16 ++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/hooks/use-autosave.ts b/hooks/use-autosave.ts index 1308826..e219830 100644 --- a/hooks/use-autosave.ts +++ b/hooks/use-autosave.ts @@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useRef, useState } from "react"; import { toast } from "sonner"; +import { saveWithConflictRetry } from "@/lib/editor/autosave"; import { parseLocalDraft as parseStoredDraft, serializeLocalDraft } from "@/lib/editor/local-draft"; export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error"; @@ -95,7 +96,9 @@ export function useAutosave({ setSaving(true); setErrorFingerprint(null); 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.currentVersion) setVersion(result.currentVersion); setErrorFingerprint(snapshotFingerprint); diff --git a/lib/editor/autosave.test.ts b/lib/editor/autosave.test.ts index 32c9792..de3f47a 100644 --- a/lib/editor/autosave.test.ts +++ b/lib/editor/autosave.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AutosaveCoordinator, + saveWithConflictRetry, type AutosaveResponse, type AutosaveStatus, } from "./autosave"; @@ -114,3 +115,24 @@ describe("AutosaveCoordinator", () => { 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 }); + }); +}); diff --git a/lib/editor/autosave.ts b/lib/editor/autosave.ts index ab6f792..3739f9e 100644 --- a/lib/editor/autosave.ts +++ b/lib/editor/autosave.ts @@ -12,6 +12,22 @@ export type AutosaveStatus = | { state: "conflict"; currentVersion: number } | { state: "error"; message: string; issues?: string[] }; +export async function saveWithConflictRetry( + save: (value: Value, expectedVersion: number) => Promise, + 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 { initialVersion: number; createPayload: (draft: Draft, expectedVersion: number) => Payload;