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";
import { createNotesApiClient } from "@/lib/api-helpers";
import { RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry";
// ── Types ────────────────────────────────────────────────────────
@ -63,9 +64,12 @@ export async function submitIntake(
const payload: Record<string, string> = { url };
if (workspaceId) payload.workspaceId = workspaceId;
if (templateOverride) payload.templateOverride = templateOverride;
return api.fetch("/intake", {
method: "POST",
body: JSON.stringify(payload),
return withMutationRetry({
run: () => api.fetch("/intake", {
method: "POST",
body: JSON.stringify(payload),
}),
offlineMessage: RETRY_WHEN_ONLINE_MESSAGE,
});
}
@ -98,16 +102,23 @@ export async function createIntakeRule(
rule: Omit<IntakeRule, "id" | "userId">,
): Promise<IntakeRule> {
const api = createNotesApiClient();
return api.fetch("/intake-rules", {
method: "POST",
body: JSON.stringify(rule),
return withMutationRetry({
run: () => api.fetch("/intake-rules", {
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> {
const api = createNotesApiClient();
await api.fetch(`/intake-rules/${encodeURIComponent(id)}`, {
method: "DELETE",
const path = `/intake-rules/${encodeURIComponent(id)}`;
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",
): Promise<unknown> {
const api = createNotesApiClient();
return api.fetch(`/notes/${encodeURIComponent(noteId)}/share-with-user`, {
method: "POST",
body: JSON.stringify({ workspaceId, sharedWithUserId, permission }),
const path = `/notes/${encodeURIComponent(noteId)}/share-with-user`;
return withMutationRetry({
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,
): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/notes/${encodeURIComponent(noteId)}/collaborators/${encodeURIComponent(userId)}`, {
method: "DELETE",
const path = `/notes/${encodeURIComponent(noteId)}/collaborators/${encodeURIComponent(userId)}`;
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";
const fetchMock = vi.fn();
const enqueueMock = vi.fn();
vi.mock("@bytelyst/api-client", () => ({
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", () => {
beforeEach(() => {
fetchMock.mockReset();
enqueueMock.mockClear();
setOnline(true);
});
it("returns persisted tasks only, preserves artifact blob metadata, and normalizes review state", async () => {
@ -131,3 +146,41 @@ describe("getNoteDetail", () => {
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";
import { createNotesApiClient } from "@/lib/api-helpers";
import { withMutationRetry } from "@/lib/mutation-retry";
import type {
AgentTimelineItem,
ArtifactSummary,
@ -264,13 +265,14 @@ export async function updateNoteDetail(
},
): Promise<void> {
const api = createNotesApiClient();
await api.fetch(
`/notes/${encodeURIComponent(noteId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{
const path = `/notes/${encodeURIComponent(noteId)}?workspaceId=${encodeURIComponent(workspaceId)}`;
await withMutationRetry({
run: () => api.fetch(path, {
method: "PATCH",
body: JSON.stringify(updates),
},
);
}),
queue: { id: noteId, action: "patch", path, payload: updates },
});
}
export async function createNoteArtifact(input: {
@ -285,9 +287,12 @@ export async function createNoteArtifact(input: {
sizeBytes?: number;
}): Promise<void> {
const api = createNotesApiClient();
await api.fetch("/note-artifacts", {
method: "POST",
body: JSON.stringify(input),
await withMutationRetry({
run: () => api.fetch("/note-artifacts", {
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";
}): Promise<void> {
const api = createNotesApiClient();
await api.fetch("/note-tasks", {
method: "POST",
body: JSON.stringify(input),
await withMutationRetry({
run: () => api.fetch("/note-tasks", {
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;
}): Promise<NoteDoc> {
const api = createNotesApiClient();
return api.fetch<NoteDoc>("/notes", {
method: "POST",
body: JSON.stringify(input),
return withMutationRetry({
run: () => api.fetch<NoteDoc>("/notes", {
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> {
const api = createNotesApiClient();
await api.fetch(`/notes/${encodeURIComponent(noteId)}/archive`, {
method: "POST",
body: JSON.stringify({ workspaceId }),
const path = `/notes/${encodeURIComponent(noteId)}/archive`;
await withMutationRetry({
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> {
const api = createNotesApiClient();
await api.fetch(`/notes/${encodeURIComponent(noteId)}/restore`, {
method: "POST",
body: JSON.stringify({ workspaceId }),
const path = `/notes/${encodeURIComponent(noteId)}/restore`;
await withMutationRetry({
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;
}): Promise<WorkspaceDoc> {
const api = createNotesApiClient();
return api.fetch<WorkspaceDoc>("/workspaces", {
method: "POST",
body: JSON.stringify(input),
return withMutationRetry({
run: () => api.fetch<WorkspaceDoc>("/workspaces", {
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 },
): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
const path = `/workspaces/${encodeURIComponent(workspaceId)}`;
await withMutationRetry({
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> {
const api = createNotesApiClient();
await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, {
method: "DELETE",
const path = `/workspaces/${encodeURIComponent(workspaceId)}`;
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;
}): Promise<void> {
const api = createNotesApiClient();
await api.fetch("/note-relationships", {
method: "POST",
body: JSON.stringify(input),
await withMutationRetry({
run: () => api.fetch("/note-relationships", {
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> = {
create: "POST",
post: "POST",
update: "PATCH",
patch: "PATCH",
delete: "DELETE",
};

View File

@ -23,10 +23,19 @@ import {
getKnowledgeGaps,
listPromptHistory,
} 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", () => {
beforeEach(() => {
fetchMock.mockReset();
setOnline(true);
});
it("listPromptTemplates returns items array", async () => {
@ -68,6 +77,14 @@ describe("prompt-client", () => {
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 () => {
fetchMock.mockResolvedValue({ tags: ["tag1", "tag2"] });
const tags = await suggestTags("n1", "ws1");

View File

@ -1,6 +1,7 @@
"use client";
import { createNotesApiClient, getAccessToken } from "@/lib/api-helpers";
import { RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry";
import type {
PromptTemplate,
RunPromptInput,
@ -26,24 +27,34 @@ export async function createPromptTemplate(
input: Omit<PromptTemplate, "id" | "isBuiltin">,
): Promise<PromptTemplate> {
const api = createNotesApiClient();
return api.fetch<PromptTemplate>("/note-prompts", {
method: "POST",
body: JSON.stringify(input),
return withMutationRetry({
run: () => api.fetch<PromptTemplate>("/note-prompts", {
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> {
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 ───────────────────────────────────────────────
export async function runPrompt(input: RunPromptInput): Promise<RunPromptOutput> {
const api = createNotesApiClient();
return api.fetch<RunPromptOutput>("/note-prompts/run", {
method: "POST",
body: JSON.stringify(input),
return withMutationRetry({
run: () => api.fetch<RunPromptOutput>("/note-prompts/run", {
method: "POST",
body: JSON.stringify(input),
}),
offlineMessage: RETRY_WHEN_ONLINE_MESSAGE,
});
}

View File

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