fix(web): add mutation retry handling

This commit is contained in:
Saravana Achu Mac 2026-05-05 11:45:25 -07:00
parent 1fb682a77a
commit 454b2003e9
9 changed files with 315 additions and 62 deletions

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { createNotesApiClient } from "@/lib/api-helpers"; import { createNotesApiClient } from "@/lib/api-helpers";
import { RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry";
// ── Types ──────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────
@ -63,9 +64,12 @@ export async function submitIntake(
const payload: Record<string, string> = { url }; const payload: Record<string, string> = { url };
if (workspaceId) payload.workspaceId = workspaceId; if (workspaceId) payload.workspaceId = workspaceId;
if (templateOverride) payload.templateOverride = templateOverride; if (templateOverride) payload.templateOverride = templateOverride;
return api.fetch("/intake", { return withMutationRetry({
method: "POST", run: () => api.fetch("/intake", {
body: JSON.stringify(payload), method: "POST",
body: JSON.stringify(payload),
}),
offlineMessage: RETRY_WHEN_ONLINE_MESSAGE,
}); });
} }
@ -98,16 +102,23 @@ export async function createIntakeRule(
rule: Omit<IntakeRule, "id" | "userId">, rule: Omit<IntakeRule, "id" | "userId">,
): Promise<IntakeRule> { ): Promise<IntakeRule> {
const api = createNotesApiClient(); const api = createNotesApiClient();
return api.fetch("/intake-rules", { return withMutationRetry({
method: "POST", run: () => api.fetch("/intake-rules", {
body: JSON.stringify(rule), method: "POST",
body: JSON.stringify(rule),
}),
queue: { id: rule.name, action: "post", path: "/intake-rules", payload: rule },
}); });
} }
export async function deleteIntakeRule(id: string): Promise<void> { export async function deleteIntakeRule(id: string): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch(`/intake-rules/${encodeURIComponent(id)}`, { const path = `/intake-rules/${encodeURIComponent(id)}`;
method: "DELETE", await withMutationRetry({
run: () => api.fetch(path, {
method: "DELETE",
}),
queue: { id, action: "delete", path, payload: {} },
}); });
} }
@ -120,9 +131,13 @@ export async function shareNoteWithUser(
permission: "view" | "comment" | "edit" = "view", permission: "view" | "comment" | "edit" = "view",
): Promise<unknown> { ): Promise<unknown> {
const api = createNotesApiClient(); const api = createNotesApiClient();
return api.fetch(`/notes/${encodeURIComponent(noteId)}/share-with-user`, { const path = `/notes/${encodeURIComponent(noteId)}/share-with-user`;
method: "POST", return withMutationRetry({
body: JSON.stringify({ workspaceId, sharedWithUserId, permission }), run: () => api.fetch(path, {
method: "POST",
body: JSON.stringify({ workspaceId, sharedWithUserId, permission }),
}),
offlineMessage: RETRY_WHEN_ONLINE_MESSAGE,
}); });
} }
@ -146,8 +161,12 @@ export async function removeCollaborator(
userId: string, userId: string,
): Promise<void> { ): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch(`/notes/${encodeURIComponent(noteId)}/collaborators/${encodeURIComponent(userId)}`, { const path = `/notes/${encodeURIComponent(noteId)}/collaborators/${encodeURIComponent(userId)}`;
method: "DELETE", await withMutationRetry({
run: () => api.fetch(path, {
method: "DELETE",
}),
offlineMessage: RETRY_WHEN_ONLINE_MESSAGE,
}); });
} }

View File

@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OFFLINE_QUEUE_MESSAGE, RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry";
const enqueueMock = vi.fn();
vi.mock("@/lib/offline-queue", () => ({
getOfflineQueue: () => ({ enqueue: enqueueMock }),
}));
function setOnline(value: boolean) {
Object.defineProperty(navigator, "onLine", {
configurable: true,
value,
});
}
describe("withMutationRetry", () => {
beforeEach(() => {
enqueueMock.mockClear();
setOnline(true);
});
it("returns the mutation result when the operation succeeds", async () => {
await expect(withMutationRetry({ run: async () => "ok" })).resolves.toBe("ok");
expect(enqueueMock).not.toHaveBeenCalled();
});
it("queues retryable mutations while offline and throws a clear queued message", async () => {
setOnline(false);
await expect(withMutationRetry({
run: async () => { throw new Error("Failed to fetch"); },
queue: {
id: "note-1",
action: "patch",
path: "/notes/note-1?workspaceId=ws-1",
payload: { title: "Offline edit" },
},
})).rejects.toThrow(OFFLINE_QUEUE_MESSAGE);
expect(enqueueMock).toHaveBeenCalledWith({
id: "note-1",
action: "patch",
path: "/notes/note-1?workspaceId=ws-1",
payload: { title: "Offline edit" },
});
});
it("uses clear retry UX for offline mutations that need immediate server results", async () => {
setOnline(false);
await expect(withMutationRetry({
run: async () => { throw new Error("Failed to fetch"); },
})).rejects.toThrow(RETRY_WHEN_ONLINE_MESSAGE);
expect(enqueueMock).not.toHaveBeenCalled();
});
it("does not queue validation or server errors while online", async () => {
await expect(withMutationRetry({
run: async () => { throw new Error("Validation failed"); },
queue: {
id: "note-1",
action: "patch",
path: "/notes/note-1",
payload: {},
},
})).rejects.toThrow("Validation failed");
expect(enqueueMock).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,44 @@
"use client";
import { getOfflineQueue } from "@/lib/offline-queue";
export const OFFLINE_QUEUE_MESSAGE = "You are offline. This change was saved and will retry automatically.";
export const RETRY_WHEN_ONLINE_MESSAGE = "You are offline. Reconnect and try again.";
type QueueAction = "post" | "patch" | "delete" | "create" | "update";
interface RetryableMutation<T> {
run: () => Promise<T>;
queue?: {
id: string;
action: QueueAction;
path: string;
payload: Record<string, unknown>;
};
offlineMessage?: string;
}
export function isOffline(): boolean {
return typeof navigator !== "undefined" && navigator.onLine === false;
}
export async function withMutationRetry<T>({
run,
queue,
offlineMessage,
}: RetryableMutation<T>): Promise<T> {
try {
return await run();
} catch (error) {
if (!isOffline()) {
throw error;
}
if (queue) {
getOfflineQueue().enqueue(queue);
throw new Error(offlineMessage ?? OFFLINE_QUEUE_MESSAGE);
}
throw new Error(offlineMessage ?? RETRY_WHEN_ONLINE_MESSAGE);
}
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
const fetchMock = vi.fn(); const fetchMock = vi.fn();
const enqueueMock = vi.fn();
vi.mock("@bytelyst/api-client", () => ({ vi.mock("@bytelyst/api-client", () => ({
createApiClient: () => ({ createApiClient: () => ({
@ -8,11 +9,25 @@ vi.mock("@bytelyst/api-client", () => ({
}), }),
})); }));
import { getNoteDetail } from "@/lib/notes-client"; vi.mock("@/lib/offline-queue", () => ({
getOfflineQueue: () => ({ enqueue: enqueueMock }),
}));
import { createNote, getNoteDetail, updateNoteDetail } from "@/lib/notes-client";
import { OFFLINE_QUEUE_MESSAGE } from "@/lib/mutation-retry";
function setOnline(value: boolean) {
Object.defineProperty(navigator, "onLine", {
configurable: true,
value,
});
}
describe("getNoteDetail", () => { describe("getNoteDetail", () => {
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset(); fetchMock.mockReset();
enqueueMock.mockClear();
setOnline(true);
}); });
it("returns persisted tasks only, preserves artifact blob metadata, and normalizes review state", async () => { it("returns persisted tasks only, preserves artifact blob metadata, and normalizes review state", async () => {
@ -131,3 +146,41 @@ describe("getNoteDetail", () => {
expect(note).toBeNull(); expect(note).toBeNull();
}); });
}); });
describe("retryable note mutations", () => {
beforeEach(() => {
fetchMock.mockReset();
enqueueMock.mockClear();
setOnline(true);
});
it("queues note updates when the browser is offline", async () => {
setOnline(false);
fetchMock.mockRejectedValue(new Error("Failed to fetch"));
await expect(updateNoteDetail("note-1", "ws-1", { title: "Offline edit" }))
.rejects.toThrow(OFFLINE_QUEUE_MESSAGE);
expect(enqueueMock).toHaveBeenCalledWith({
id: "note-1",
action: "patch",
path: "/notes/note-1?workspaceId=ws-1",
payload: { title: "Offline edit" },
});
});
it("queues note creation when the browser is offline", async () => {
setOnline(false);
fetchMock.mockRejectedValue(new Error("Failed to fetch"));
const input = { id: "note-new", workspaceId: "ws-1", title: "New", body: "Body" };
await expect(createNote(input)).rejects.toThrow(OFFLINE_QUEUE_MESSAGE);
expect(enqueueMock).toHaveBeenCalledWith({
id: "note-new",
action: "post",
path: "/notes",
payload: input,
});
});
});

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { createNotesApiClient } from "@/lib/api-helpers"; import { createNotesApiClient } from "@/lib/api-helpers";
import { withMutationRetry } from "@/lib/mutation-retry";
import type { import type {
AgentTimelineItem, AgentTimelineItem,
ArtifactSummary, ArtifactSummary,
@ -264,13 +265,14 @@ export async function updateNoteDetail(
}, },
): Promise<void> { ): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch( const path = `/notes/${encodeURIComponent(noteId)}?workspaceId=${encodeURIComponent(workspaceId)}`;
`/notes/${encodeURIComponent(noteId)}?workspaceId=${encodeURIComponent(workspaceId)}`, await withMutationRetry({
{ run: () => api.fetch(path, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(updates), body: JSON.stringify(updates),
}, }),
); queue: { id: noteId, action: "patch", path, payload: updates },
});
} }
export async function createNoteArtifact(input: { export async function createNoteArtifact(input: {
@ -285,9 +287,12 @@ export async function createNoteArtifact(input: {
sizeBytes?: number; sizeBytes?: number;
}): Promise<void> { }): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch("/note-artifacts", { await withMutationRetry({
method: "POST", run: () => api.fetch("/note-artifacts", {
body: JSON.stringify(input), method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/note-artifacts", payload: input },
}); });
} }
@ -301,9 +306,12 @@ export async function createNoteTask(input: {
source?: "manual" | "extracted"; source?: "manual" | "extracted";
}): Promise<void> { }): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch("/note-tasks", { await withMutationRetry({
method: "POST", run: () => api.fetch("/note-tasks", {
body: JSON.stringify(input), method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/note-tasks", payload: input },
}); });
} }
@ -391,25 +399,36 @@ export async function createNote(input: {
sourceType?: string; sourceType?: string;
}): Promise<NoteDoc> { }): Promise<NoteDoc> {
const api = createNotesApiClient(); const api = createNotesApiClient();
return api.fetch<NoteDoc>("/notes", { return withMutationRetry({
method: "POST", run: () => api.fetch<NoteDoc>("/notes", {
body: JSON.stringify(input), method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/notes", payload: input },
}); });
} }
export async function archiveNote(noteId: string, workspaceId: string): Promise<void> { export async function archiveNote(noteId: string, workspaceId: string): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch(`/notes/${encodeURIComponent(noteId)}/archive`, { const path = `/notes/${encodeURIComponent(noteId)}/archive`;
method: "POST", await withMutationRetry({
body: JSON.stringify({ workspaceId }), run: () => api.fetch(path, {
method: "POST",
body: JSON.stringify({ workspaceId }),
}),
queue: { id: `${noteId}:archive`, action: "post", path, payload: { workspaceId } },
}); });
} }
export async function restoreNote(noteId: string, workspaceId: string): Promise<void> { export async function restoreNote(noteId: string, workspaceId: string): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch(`/notes/${encodeURIComponent(noteId)}/restore`, { const path = `/notes/${encodeURIComponent(noteId)}/restore`;
method: "POST", await withMutationRetry({
body: JSON.stringify({ workspaceId }), run: () => api.fetch(path, {
method: "POST",
body: JSON.stringify({ workspaceId }),
}),
queue: { id: `${noteId}:restore`, action: "post", path, payload: { workspaceId } },
}); });
} }
@ -435,9 +454,12 @@ export async function createWorkspace(input: {
description?: string; description?: string;
}): Promise<WorkspaceDoc> { }): Promise<WorkspaceDoc> {
const api = createNotesApiClient(); const api = createNotesApiClient();
return api.fetch<WorkspaceDoc>("/workspaces", { return withMutationRetry({
method: "POST", run: () => api.fetch<WorkspaceDoc>("/workspaces", {
body: JSON.stringify(input), method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/workspaces", payload: input },
}); });
} }
@ -446,16 +468,24 @@ export async function updateWorkspace(
updates: { name?: string; description?: string }, updates: { name?: string; description?: string },
): Promise<void> { ): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, { const path = `/workspaces/${encodeURIComponent(workspaceId)}`;
method: "PATCH", await withMutationRetry({
body: JSON.stringify(updates), run: () => api.fetch(path, {
method: "PATCH",
body: JSON.stringify(updates),
}),
queue: { id: workspaceId, action: "patch", path, payload: updates },
}); });
} }
export async function deleteWorkspace(workspaceId: string): Promise<void> { export async function deleteWorkspace(workspaceId: string): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, { const path = `/workspaces/${encodeURIComponent(workspaceId)}`;
method: "DELETE", await withMutationRetry({
run: () => api.fetch(path, {
method: "DELETE",
}),
queue: { id: workspaceId, action: "delete", path, payload: {} },
}); });
} }
@ -467,8 +497,11 @@ export async function createNoteRelationship(input: {
relationshipType: string; relationshipType: string;
}): Promise<void> { }): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch("/note-relationships", { await withMutationRetry({
method: "POST", run: () => api.fetch("/note-relationships", {
body: JSON.stringify(input), method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/note-relationships", payload: input },
}); });
} }

View File

@ -6,7 +6,9 @@ import { createNotesApiClient } from "@/lib/api-helpers";
const ACTION_METHOD: Record<string, string> = { const ACTION_METHOD: Record<string, string> = {
create: "POST", create: "POST",
post: "POST",
update: "PATCH", update: "PATCH",
patch: "PATCH",
delete: "DELETE", delete: "DELETE",
}; };

View File

@ -23,10 +23,19 @@ import {
getKnowledgeGaps, getKnowledgeGaps,
listPromptHistory, listPromptHistory,
} from "@/lib/prompt-client"; } from "@/lib/prompt-client";
import { RETRY_WHEN_ONLINE_MESSAGE } from "@/lib/mutation-retry";
function setOnline(value: boolean) {
Object.defineProperty(navigator, "onLine", {
configurable: true,
value,
});
}
describe("prompt-client", () => { describe("prompt-client", () => {
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset(); fetchMock.mockReset();
setOnline(true);
}); });
it("listPromptTemplates returns items array", async () => { it("listPromptTemplates returns items array", async () => {
@ -68,6 +77,14 @@ describe("prompt-client", () => {
expect(result.content).toBe("Summary"); expect(result.content).toBe("Summary");
}); });
it("runPrompt gives clear retry UX when offline", async () => {
setOnline(false);
fetchMock.mockRejectedValue(new Error("Failed to fetch"));
await expect(runPrompt({ templateId: "summarize", noteId: "n1", workspaceId: "ws1" }))
.rejects.toThrow(RETRY_WHEN_ONLINE_MESSAGE);
});
it("suggestTags returns tag array", async () => { it("suggestTags returns tag array", async () => {
fetchMock.mockResolvedValue({ tags: ["tag1", "tag2"] }); fetchMock.mockResolvedValue({ tags: ["tag1", "tag2"] });
const tags = await suggestTags("n1", "ws1"); const tags = await suggestTags("n1", "ws1");

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { createNotesApiClient, getAccessToken } from "@/lib/api-helpers"; import { createNotesApiClient, getAccessToken } from "@/lib/api-helpers";
import { RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry";
import type { import type {
PromptTemplate, PromptTemplate,
RunPromptInput, RunPromptInput,
@ -26,24 +27,34 @@ export async function createPromptTemplate(
input: Omit<PromptTemplate, "id" | "isBuiltin">, input: Omit<PromptTemplate, "id" | "isBuiltin">,
): Promise<PromptTemplate> { ): Promise<PromptTemplate> {
const api = createNotesApiClient(); const api = createNotesApiClient();
return api.fetch<PromptTemplate>("/note-prompts", { return withMutationRetry({
method: "POST", run: () => api.fetch<PromptTemplate>("/note-prompts", {
body: JSON.stringify(input), method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.slug, action: "post", path: "/note-prompts", payload: input },
}); });
} }
export async function deletePromptTemplate(id: string): Promise<void> { export async function deletePromptTemplate(id: string): Promise<void> {
const api = createNotesApiClient(); const api = createNotesApiClient();
await api.fetch(`/note-prompts/${encodeURIComponent(id)}`, { method: "DELETE" }); const path = `/note-prompts/${encodeURIComponent(id)}`;
await withMutationRetry({
run: () => api.fetch(path, { method: "DELETE" }),
queue: { id, action: "delete", path, payload: {} },
});
} }
// ── Run prompts ─────────────────────────────────────────────── // ── Run prompts ───────────────────────────────────────────────
export async function runPrompt(input: RunPromptInput): Promise<RunPromptOutput> { export async function runPrompt(input: RunPromptInput): Promise<RunPromptOutput> {
const api = createNotesApiClient(); const api = createNotesApiClient();
return api.fetch<RunPromptOutput>("/note-prompts/run", { return withMutationRetry({
method: "POST", run: () => api.fetch<RunPromptOutput>("/note-prompts/run", {
body: JSON.stringify(input), method: "POST",
body: JSON.stringify(input),
}),
offlineMessage: RETRY_WHEN_ONLINE_MESSAGE,
}); });
} }

View File

@ -1,4 +1,5 @@
import { createNotesApiClient } from "@/lib/api-helpers"; import { createNotesApiClient } from "@/lib/api-helpers";
import { RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry";
import type { AgentTimelineItem, ApprovalQueueItem, NoteAgentActionDoc, NoteAgentActionListResponse } from "@/lib/types"; import type { AgentTimelineItem, ApprovalQueueItem, NoteAgentActionDoc, NoteAgentActionListResponse } from "@/lib/types";
@ -46,18 +47,19 @@ async function updateAgentActionState(
reviewNote?: string, reviewNote?: string,
): Promise<NoteAgentActionDoc> { ): Promise<NoteAgentActionDoc> {
const api = createNotesApiClient(); const api = createNotesApiClient();
const path = `/note-agent-actions/${encodeURIComponent(id)}?workspaceId=${encodeURIComponent(workspaceId)}`;
return api.fetch<NoteAgentActionDoc>( return withMutationRetry({
`/note-agent-actions/${encodeURIComponent(id)}?workspaceId=${encodeURIComponent(workspaceId)}`, run: () => api.fetch<NoteAgentActionDoc>(path, {
{
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
state, state,
reviewedAt: new Date().toISOString(), reviewedAt: new Date().toISOString(),
...(reviewNote ? { reviewNote } : {}), ...(reviewNote ? { reviewNote } : {}),
}), }),
}, }),
); offlineMessage: RETRY_WHEN_ONLINE_MESSAGE,
});
} }
export async function listApprovalQueue(): Promise<ApprovalQueueItem[]> { export async function listApprovalQueue(): Promise<ApprovalQueueItem[]> {
@ -93,16 +95,16 @@ export async function batchReviewItems(
reviewNote?: string, reviewNote?: string,
): Promise<{ updated: number; total: number }> { ): Promise<{ updated: number; total: number }> {
const api = createNotesApiClient(); const api = createNotesApiClient();
const result = await api.fetch<{ updated: number; total: number }>( const result = await withMutationRetry({
"/note-agent-actions/batch-review", run: () => api.fetch<{ updated: number; total: number }>("/note-agent-actions/batch-review", {
{
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
ids: items.map((item) => ({ id: item.id, workspaceId: item.workspaceId })), ids: items.map((item) => ({ id: item.id, workspaceId: item.workspaceId })),
state, state,
...(reviewNote ? { reviewNote } : {}), ...(reviewNote ? { reviewNote } : {}),
}), }),
}, }),
); offlineMessage: RETRY_WHEN_ONLINE_MESSAGE,
});
return result; return result;
} }