refactor: better resume two-way sync in case of MCP/API updates

This commit is contained in:
Amruth Pillai
2026-05-26 12:05:38 +02:00
parent 19b412d84d
commit dd1e37e579
11 changed files with 548 additions and 93 deletions
-1
View File
@@ -39,7 +39,6 @@
"@reactive-resume/fonts": "workspace:*",
"@reactive-resume/import": "workspace:*",
"@reactive-resume/pdf": "workspace:*",
"@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/ui": "workspace:*",
"@reactive-resume/utils": "workspace:*",
@@ -0,0 +1,340 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Resume } from "./draft";
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { useBuilderResumeUpdateSubscription, useResumeStore, useResumeUpdateSubscription } from "./draft";
const orpcMocks = vi.hoisted(() => ({
getResumeById: vi.fn(),
patchResume: vi.fn(),
streamSubscribe: vi.fn(),
updateResume: vi.fn(),
}));
const consumeEventIteratorMock = vi.hoisted(() => vi.fn());
const queryClientMock = vi.hoisted(() => ({
setQueryData: vi.fn(),
}));
const routerParamsMock = vi.hoisted(() => ({
value: {} as { resumeId?: string },
}));
const toastMocks = vi.hoisted(() => ({
dismiss: vi.fn(),
error: vi.fn(() => "sync-error-toast"),
}));
vi.mock("@orpc/client", () => ({
consumeEventIterator: consumeEventIteratorMock,
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => queryClientMock,
}));
vi.mock("@tanstack/react-router", () => ({
useParams: () => routerParamsMock.value,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
getById: {
call: orpcMocks.getResumeById,
queryOptions: ({ input }: { input: { id: string } }) => ({
queryKey: ["resume", "getById", input.id],
}),
},
patch: {
call: orpcMocks.patchResume,
},
update: {
call: orpcMocks.updateResume,
},
},
},
streamClient: {
resume: {
updates: {
subscribe: orpcMocks.streamSubscribe,
},
},
},
}));
vi.mock("sonner", () => ({
toast: toastMocks,
}));
function cloneResumeData(data: ResumeData): ResumeData {
return structuredClone(data);
}
function makeResume(id: string): Resume {
return {
id,
name: "Resume",
slug: id,
tags: [],
data: cloneResumeData(defaultResumeData),
isLocked: false,
isPublic: false,
hasPassword: false,
updatedAt: new Date("2026-05-26T12:00:00.000Z"),
};
}
function withBasicsName(resume: Resume, name: string): Resume {
return {
...resume,
data: {
...resume.data,
basics: {
...resume.data.basics,
name,
},
},
};
}
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
describe("builder resume autosave", () => {
beforeEach(() => {
vi.useFakeTimers();
orpcMocks.getResumeById.mockReset();
orpcMocks.patchResume.mockReset();
orpcMocks.streamSubscribe.mockReset();
orpcMocks.updateResume.mockReset();
consumeEventIteratorMock.mockReset();
queryClientMock.setQueryData.mockClear();
routerParamsMock.value = {};
i18n.loadAndActivate({ locale: "en-US", messages: {} });
toastMocks.dismiss.mockClear();
toastMocks.error.mockClear();
useResumeStore.getState().reset();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
useResumeStore.getState().reset();
});
it("coalesces rapid local edits into one full-data update", async () => {
const initial = makeResume("resume-rapid");
const updated = withBasicsName(initial, "Latest Name");
orpcMocks.updateResume.mockResolvedValue(updated);
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "First Name";
});
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Latest Name";
});
vi.advanceTimersByTime(500);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1);
expect(orpcMocks.updateResume).toHaveBeenCalledWith(
{ id: initial.id, data: updated.data },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
});
it("saves the latest pending snapshot after an in-flight save resolves", async () => {
const initial = makeResume("resume-in-flight");
const first = withBasicsName(initial, "First Name");
const latest = withBasicsName(initial, "Latest Name");
let resolveFirst!: (resume: Resume) => void;
orpcMocks.updateResume
.mockReturnValueOnce(
new Promise<Resume>((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValueOnce(latest);
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "First Name";
});
vi.advanceTimersByTime(500);
await flushMicrotasks();
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Latest Name";
});
vi.advanceTimersByTime(500);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1);
resolveFirst(first);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.updateResume.mock.calls[0]?.[0]).toEqual({ id: initial.id, data: first.data });
expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data });
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
});
it("does not run a stale debounced save after immediately saving an edit made during an in-flight save", async () => {
const initial = makeResume("resume-stale-timer");
const first = withBasicsName(initial, "First Name");
const latest = withBasicsName(initial, "Latest Name");
let resolveFirst!: (resume: Resume) => void;
orpcMocks.updateResume
.mockReturnValueOnce(
new Promise<Resume>((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValue(latest);
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "First Name";
});
vi.advanceTimersByTime(500);
await flushMicrotasks();
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Latest Name";
});
resolveFirst(first);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(500);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data });
});
it("keeps the latest draft data and shows a persistent toast when saving fails", async () => {
const initial = makeResume("resume-failure");
orpcMocks.updateResume.mockRejectedValue(new Error("network down"));
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Unsaved Name";
});
vi.advanceTimersByTime(500);
await flushMicrotasks();
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Unsaved Name");
expect(toastMocks.error).toHaveBeenCalledWith(
"Your latest changes could not be saved.",
expect.objectContaining({ duration: Number.POSITIVE_INFINITY }),
);
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
});
});
describe("resume update stream subscription", () => {
beforeEach(() => {
vi.useFakeTimers();
orpcMocks.streamSubscribe.mockReset();
consumeEventIteratorMock.mockReset();
orpcMocks.getResumeById.mockReset();
queryClientMock.setQueryData.mockClear();
routerParamsMock.value = {};
i18n.loadAndActivate({ locale: "en-US", messages: {} });
useResumeStore.getState().reset();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
useResumeStore.getState().reset();
});
it("subscribes by explicit resume id and calls the provided update handler", async () => {
const cancel = vi.fn().mockResolvedValue(undefined);
const onUpdate = vi.fn().mockResolvedValue(undefined);
consumeEventIteratorMock.mockReturnValue(cancel);
const { unmount } = renderHook(() =>
useResumeUpdateSubscription({
resumeId: "resume-stream",
onUpdate,
}),
);
expect(orpcMocks.streamSubscribe).toHaveBeenCalledWith({ id: "resume-stream" });
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as { onEvent: () => Promise<void> } | undefined;
expect(handlers).toBeDefined();
await act(async () => {
await handlers?.onEvent();
});
expect(onUpdate).toHaveBeenCalledTimes(1);
unmount();
expect(cancel).toHaveBeenCalledTimes(1);
});
it("replaces the builder draft from the server when there are no pending local edits", async () => {
const initial = makeResume("resume-clean");
const remote = withBasicsName(initial, "Remote Name");
const cancel = vi.fn().mockResolvedValue(undefined);
consumeEventIteratorMock.mockReturnValue(cancel);
orpcMocks.getResumeById.mockResolvedValue(remote);
routerParamsMock.value = { resumeId: initial.id };
useResumeStore.getState().initialize(initial);
renderHook(() => useBuilderResumeUpdateSubscription());
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as { onEvent: () => Promise<void> } | undefined;
await act(async () => {
await handlers?.onEvent();
});
expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote);
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Remote Name");
});
it("does not overwrite pending local builder edits when a remote update arrives", async () => {
const initial = makeResume("resume-pending");
const remote = withBasicsName(initial, "Remote Name");
const cancel = vi.fn().mockResolvedValue(undefined);
consumeEventIteratorMock.mockReturnValue(cancel);
orpcMocks.getResumeById.mockResolvedValue(remote);
routerParamsMock.value = { resumeId: initial.id };
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Local Name";
});
renderHook(() => useBuilderResumeUpdateSubscription());
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as { onEvent: () => Promise<void> } | undefined;
await act(async () => {
await handlers?.onEvent();
});
expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote);
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name");
});
});
+110 -84
View File
@@ -10,7 +10,6 @@ import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
import { applyResumePatches, createResumePatches } from "@reactive-resume/resume/patch";
import { orpc, streamClient } from "@/libs/orpc/client";
export type Resume = {
@@ -46,13 +45,20 @@ type ResumeStore = ResumeStoreState & ResumeStoreActions;
type Runtime = {
abortController: AbortController;
queryClient?: QueryClient;
baselineData?: ResumeData;
hasPendingLocalChanges: boolean;
isSaving: boolean;
pendingResume?: Resume;
syncErrorToastId?: string | number;
syncResume: ReturnType<typeof debounce<(resume: Resume) => Promise<void>>>;
syncResume: ReturnType<typeof debounce<(resume: Resume) => void>>;
beforeUnloadHandler?: () => void;
};
type ResumeUpdateSubscriptionOptions = {
resumeId?: string;
onUpdate: () => Promise<void> | void;
onError?: (error: unknown) => void;
};
const SAVE_DEBOUNCE_MS = 500;
const runtimes = new Map<string, Runtime>();
@@ -66,66 +72,86 @@ function cloneResumeData(data: ResumeData): ResumeData {
return structuredClone(data);
}
function cloneResume(resume: Resume): Resume {
return { ...resume, data: cloneResumeData(resume.data) };
}
function createResumeUpdateEventIterator(resumeId: string) {
return streamClient.resume.updates.subscribe({ id: resumeId });
}
function setRuntimeBaseline(resume: Resume) {
const runtime = getRuntime(resume.id);
runtime.baselineData = cloneResumeData(resume.data);
runtime.hasPendingLocalChanges = false;
runtime.pendingResume = undefined;
}
async function flushResumeSave(id: string) {
const runtime = runtimes.get(id);
if (!runtime || runtime.isSaving || !runtime.pendingResume) return;
const submitted = runtime.pendingResume;
const submittedData = cloneResumeData(submitted.data);
runtime.pendingResume = undefined;
runtime.isSaving = true;
try {
const updated = (await orpc.resume.update.call(
{ id: submitted.id, data: submittedData },
{ signal: runtime.abortController.signal },
)) as Resume;
runtime.queryClient?.setQueryData(getResumeQueryKey(submitted.id), updated);
const currentResume = useResumeStore.getState().resume;
const currentDataStillMatchesSubmission =
currentResume?.id === submitted.id && isEqual(currentResume.data, submittedData);
if (currentDataStillMatchesSubmission && !runtime.pendingResume) {
runtime.hasPendingLocalChanges = false;
useResumeStore.getState().replaceResumeFromServer(updated);
} else {
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().mergeResumeMetadata(updated);
if (!runtime.pendingResume && currentResume?.id === submitted.id && !isEqual(currentResume.data, submittedData)) {
runtime.syncResume.cancel();
runtime.pendingResume = cloneResume(currentResume);
}
}
if (runtime.syncErrorToastId !== undefined) {
toast.dismiss(runtime.syncErrorToastId);
runtime.syncErrorToastId = undefined;
}
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") return;
runtime.pendingResume ??= submitted;
runtime.hasPendingLocalChanges = true;
runtime.syncErrorToastId = toast.error(t`Your latest changes could not be saved.`, {
id: runtime.syncErrorToastId,
duration: Number.POSITIVE_INFINITY,
});
} finally {
runtime.isSaving = false;
if (runtime.pendingResume && runtime.syncErrorToastId === undefined) void flushResumeSave(id);
}
}
function queueResumeSave(resume: Resume) {
const runtime = getRuntime(resume.id);
runtime.pendingResume = cloneResume(resume);
runtime.hasPendingLocalChanges = true;
void flushResumeSave(resume.id);
}
function createRuntime(): Runtime {
const abortController = new AbortController();
const syncResume = debounce(
async (resume: Resume) => {
const runtime = runtimes.get(resume.id);
if (!runtime) return;
const baselineData = runtime.baselineData ?? cloneResumeData(resume.data);
const operations = createResumePatches(baselineData, resume.data);
if (operations.length === 0) {
runtime.hasPendingLocalChanges = false;
return;
}
const submittedData = cloneResumeData(resume.data);
try {
const updated = (await orpc.resume.patch.call(
{ id: resume.id, operations },
{ signal: abortController.signal },
)) as Resume;
runtime.queryClient?.setQueryData(getResumeQueryKey(resume.id), updated);
runtime.baselineData = cloneResumeData(updated.data);
const currentResume = useResumeStore.getState().resume;
const currentDataStillMatchesSubmission =
currentResume?.id === resume.id && isEqual(currentResume.data, submittedData);
if (currentDataStillMatchesSubmission) {
runtime.hasPendingLocalChanges = false;
useResumeStore.getState().replaceResumeFromServer(updated);
} else {
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().mergeResumeMetadata(updated);
syncCurrentResume(resume.id);
}
if (runtime.syncErrorToastId === undefined) return;
toast.dismiss(runtime.syncErrorToastId);
runtime.syncErrorToastId = undefined;
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") return;
runtime.syncErrorToastId = toast.error(t`Your latest changes could not be saved.`, {
id: runtime.syncErrorToastId,
duration: Number.POSITIVE_INFINITY,
});
}
(resume: Resume) => {
queueResumeSave(resume);
},
SAVE_DEBOUNCE_MS,
{ signal: abortController.signal },
@@ -134,6 +160,7 @@ function createRuntime(): Runtime {
const runtime: Runtime = {
abortController,
hasPendingLocalChanges: false,
isSaving: false,
syncResume,
};
@@ -330,56 +357,26 @@ export function useUpdateResumeData() {
);
}
export function useResumeUpdateSubscription() {
const queryClient = useQueryClient();
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
export function useResumeUpdateSubscription({ resumeId, onUpdate, onError }: ResumeUpdateSubscriptionOptions) {
const [_retryNonce, setRetryNonce] = useState(0);
useEffect(() => {
if (!resumeId) return;
bindRuntimeQueryClient(resumeId, queryClient);
let didCancel = false;
let retryTimer: number | undefined;
const cancel = consumeEventIterator(createResumeUpdateEventIterator(resumeId), {
onEvent: async () => {
try {
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
if (hasPendingLocalChanges(resumeId)) {
const runtime = getRuntime(resumeId);
const currentResume = useResumeStore.getState().resume;
const baselineData = runtime.baselineData ?? currentResume?.data;
if (currentResume && baselineData) {
const localOperations = createResumePatches(baselineData, currentResume.data);
const mergedData = applyResumePatches(resume.data, localOperations);
runtime.baselineData = cloneResumeData(resume.data);
runtime.hasPendingLocalChanges = localOperations.length > 0;
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
useResumeStore.getState().replaceResumeDraft({ ...resume, data: mergedData });
syncCurrentResume(resumeId);
} else {
runtime.baselineData = cloneResumeData(resume.data);
useResumeStore.getState().mergeResumeMetadata(resume);
}
return;
}
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
replaceResumeFromServer(resume);
await onUpdate();
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
console.warn("Failed to refresh resume after update event:", error);
onError?.(error);
}
},
onError: (error) => {
if (didCancel) return;
console.warn("Resume update stream failed, reconnecting:", error);
onError?.(error);
retryTimer = window.setTimeout(() => setRetryNonce((value) => value + 1), 2500);
},
});
@@ -389,7 +386,36 @@ export function useResumeUpdateSubscription() {
if (retryTimer) window.clearTimeout(retryTimer);
void cancel().catch(() => {});
};
}, [onError, onUpdate, resumeId]);
}
export function useBuilderResumeUpdateSubscription() {
const queryClient = useQueryClient();
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const onUpdate = useCallback(async () => {
if (!resumeId) return;
bindRuntimeQueryClient(resumeId, queryClient);
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
if (hasPendingLocalChanges(resumeId)) {
useResumeStore.getState().mergeResumeMetadata(resume);
return;
}
replaceResumeFromServer(resume);
}, [queryClient, replaceResumeFromServer, resumeId]);
const onError = useCallback((error: unknown) => {
console.warn("Resume update stream failed, reconnecting:", error);
}, []);
useResumeUpdateSubscription({ resumeId, onUpdate, onError });
}
export function useResumeCleanup() {
+2
View File
@@ -57,6 +57,7 @@ import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc, streamClient } from "@/libs/orpc/client";
import { AgentThreadSidebar } from "./-components/thread-sidebar";
import { attachmentIdsFromTransportBody, buildAgentChatSubmission } from "./-helpers/chat-attachments";
import { useAgentResumeUpdateSubscription } from "./-hooks/use-agent-resume-updates";
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
type AgentAction = AgentThreadDetail["actions"][number];
@@ -1218,6 +1219,7 @@ function RouteComponent() {
const [isThreadsCollapsed, setIsThreadsCollapsed] = useState(false);
const [isResumeCollapsed, setIsResumeCollapsed] = useState(false);
const { data, isLoading, error } = useQuery(orpc.agent.threads.get.queryOptions({ input: { id: threadId } }));
useAgentResumeUpdateSubscription({ resumeId: data?.resume?.id, threadId });
const toggleThreadsPanel = useCallback(() => {
const panel = threadsPanelRef.current;
@@ -0,0 +1,64 @@
// @vitest-environment happy-dom
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAgentResumeUpdateSubscription } from "./use-agent-resume-updates";
const subscriptionMock = vi.hoisted(() => vi.fn());
const queryClientMock = vi.hoisted(() => ({
invalidateQueries: vi.fn(),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => queryClientMock,
}));
vi.mock("@/features/resume/builder/draft", () => ({
useResumeUpdateSubscription: subscriptionMock,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
agent: {
threads: {
get: {
queryKey: ({ input }: { input: { id: string } }) => ["agent", "threads", "get", input.id],
},
list: {
queryKey: () => ["agent", "threads", "list"],
},
},
},
},
}));
describe("useAgentResumeUpdateSubscription", () => {
beforeEach(() => {
subscriptionMock.mockReset();
queryClientMock.invalidateQueries.mockClear();
});
it("invalidates the active thread and thread list when the working resume changes", async () => {
renderHook(() =>
useAgentResumeUpdateSubscription({
resumeId: "resume-1",
threadId: "thread-1",
}),
);
const options = subscriptionMock.mock.calls[0]?.[0] as
| { resumeId?: string; onUpdate: () => Promise<void> }
| undefined;
expect(options?.resumeId).toBe("resume-1");
await options?.onUpdate();
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["agent", "threads", "list"],
});
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["agent", "threads", "get", "thread-1"],
});
});
});
@@ -0,0 +1,28 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { useResumeUpdateSubscription } from "@/features/resume/builder/draft";
import { orpc } from "@/libs/orpc/client";
type UseAgentResumeUpdateSubscriptionInput = {
resumeId?: string;
threadId: string;
};
export function useAgentResumeUpdateSubscription({ resumeId, threadId }: UseAgentResumeUpdateSubscriptionInput) {
const queryClient = useQueryClient();
const onUpdate = useCallback(async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }) }),
]);
}, [queryClient, threadId]);
useResumeUpdateSubscription({
resumeId,
onUpdate,
onError: useCallback((error: unknown) => {
console.warn("Agent resume update stream failed, reconnecting:", error);
}, []),
});
}
@@ -8,11 +8,11 @@ import { useEffect, useRef } from "react";
import { usePanelRef } from "react-resizable-panels";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import {
useBuilderResumeUpdateSubscription,
useInitializeResumeStore,
useMergeResumeMetadata,
useResumeCleanup,
useResumeStore,
useResumeUpdateSubscription,
} from "@/features/resume/builder/draft";
import { useIsMobile } from "@/hooks/use-mobile";
import { orpc } from "@/libs/orpc/client";
@@ -62,7 +62,7 @@ function RouteComponent() {
const isInitialized = isReady && initializedResumeId === resumeId;
useResumeCleanup();
useResumeUpdateSubscription();
useBuilderResumeUpdateSubscription();
useEffect(() => {
if (isInitialized) return;
-1
View File
@@ -26,7 +26,6 @@
"@better-auth/infra",
"@better-auth/passkey",
"@orpc/experimental-ratelimit",
"@react-pdf/renderer",
"@sindresorhus/slugify",
"@t3-oss/env-core",
"@uiw/color-convert",
+1 -1
View File
@@ -91,7 +91,7 @@ describe("rateLimitConfig", () => {
});
it("provides reasonable mutation throughput for resume edits", () => {
expect(rateLimitConfig.orpc.resumeMutations).toEqual({ maxRequests: 60, window: 60 * 1000 });
expect(rateLimitConfig.orpc.resumeMutations).toEqual({ maxRequests: 300, window: 60 * 1000 });
});
it("limits storage uploads more strictly than deletes", () => {
+1 -1
View File
@@ -45,6 +45,6 @@ export const rateLimitConfig = {
jobsTestConnection: { maxRequests: 10, window: 60 * 1000 },
storageUpload: { maxRequests: 20, window: 60 * 1000 },
storageDelete: { maxRequests: 30, window: 60 * 1000 },
resumeMutations: { maxRequests: 60, window: 60 * 1000 },
resumeMutations: { maxRequests: 300, window: 60 * 1000 },
},
} as const;
-3
View File
@@ -336,9 +336,6 @@ importers:
'@reactive-resume/pdf':
specifier: workspace:*
version: link:../../packages/pdf
'@reactive-resume/resume':
specifier: workspace:*
version: link:../../packages/resume
'@reactive-resume/schema':
specifier: workspace:*
version: link:../../packages/schema