.claude/skills/recollect-caller-migration/SKILL.md
recollect-caller-migration
npx skillsauth add timelessco/recollect recollect-caller-migrationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Migrate frontend query hooks from v1 Pages Router URLs to v2 App Router endpoints using the proven 5-layer pattern. Each layer builds on the previous — execute in order, verify between layers.
v2 routes return T directly on success and {error: string} on failure — no {data, error} envelope. Route handlers use the v2 factory (create-handler-v2.ts) with createAxiomRouteHandler(withAuth/withPublic({...})) composition and RecollectApiError throws for error handling. Callers use ky (api from api-v2.ts) — no getApi, no v1 URL constants (NEXT_API_URL + constant pattern), no envelope unwrapping. V2 callers use V2_* constants from constants.ts with ky's prefix.
Mutation hook refactoring? Use the
recollect-mutation-hook-refactoringskill instead. It covers mutation-hook-template.ts restructuring, file renaming, and structural cleanup. This skill handles API caller migration — both query hooks and mutation hooks with ky.
In scope (easy-class): Query hooks that use simple axios crud helpers or use getApi. Straightforward request/response patterns with no session closures or complex type casts.
In scope (hard-class): Hooks that import from supabaseCrudHelpers with session closures and QueryFunctionContext. Session is removed from the queryFn (v2 auth is cookie-based) but KEPT in queryKey for cache scoping. See "Hard-Class Hooks" section below.
How to tell the difference: If the hook imports from supabaseCrudHelpers and the crud helper is a simple async function wrapping axios.get/axios.post with no session parameters, it's easy-class. If the hook uses useSupabaseClient(), session.access_token, or QueryFunctionContext<T>, it's hard-class. Both classes use the same 5-layer pattern; hard-class has additional considerations documented below.
Every ky call MUST use a V2_* constant from src/utils/constants.ts — never inline strings. Do not update the old constant to a v2 path — add a new V2_* constant instead.
export const V2_FETCH_USER_TAGS_API = "v2/tags/fetch-user-tags"; (no leading slash)api.get(V2_FETCH_USER_TAGS_API).json<T>()api.get("v2/tags/fetch-user-tags") — violates the V2 constant rule and gets flagged in reviewFETCH_USER_TAGS_API) is orphaned — if so, remove it from constants.tsThe query key constant (e.g., USER_TAGS_KEY) is NOT orphaned — it's still used in the hook's queryKey.
Replace the old caller (getApi/axios crud helper) with ky in queryFn. The hook file lives at src/async/queryHooks/{domain}/use-fetch-{name}.ts (kebab-case per project convention).
import { useQuery } from "@tanstack/react-query";
import type { CheckGeminiApiKeyOutputSchema } from "@/app/api/v2/check-gemini-api-key/schema";
import type { z } from "zod";
import { api } from "@/lib/api-helpers/api-v2";
import { API_KEY_CHECK_KEY, V2_CHECK_GEMINI_API_KEY_API } from "@/utils/constants";
type CheckApiKeyResponse = z.infer<typeof CheckGeminiApiKeyOutputSchema>;
export const useFetchCheckApiKey = () =>
useQuery({
queryFn: () => api.get(V2_CHECK_GEMINI_API_KEY_API).json<CheckApiKeyResponse>(),
queryKey: [API_KEY_CHECK_KEY],
});
Key details:
import { api } from "@/lib/api-helpers/api-v2" — NOT getApi from api.tsV2_CHECK_GEMINI_API_KEY_API = "v2/check-gemini-api-key" not "/v2/check-gemini-api-key" — ky's prefix joins /api + relative pathapi.get().json() returns a Promise directly, which queryFn acceptsonError — no manual error checking neededimport type for both the schema and z — zero runtime Zod at the consumer. The schema only exists for type inference{hasApiKey: boolean}, {apiKey: string}): Use z.infer<typeof OutputSchema>. The Zod type matches consumers perfectly and has no cache interaction.json<ApiType[]>() with the hand-written type from apiTypes.ts (e.g., FetchSharedCategoriesData[], UserTagsData[]). Zod schemas often use z.unknown() for complex fields like category_views, which causes unknown in the inferred type — breaks optimistic updaters that access typed properties. The apiTypes type matches what cache consumers already import and useSingleListData downstream): Use .json<SingleListData[]>() directly — do NOT use z.infer. The hand-written SingleListData interface diverges structurally from v2 Zod output schemas (different nullability, user_id shape, missing addedTags). as SingleListData casts fail with TS2352V2_* constant to src/utils/constants.ts (e.g., V2_FETCH_BOOKMARK_BY_ID_API = "v2/bookmarks/get/fetch-by-id") and use it in the hook. Remove the old v1 URL constant (FETCH_BOOKMARK_BY_ID_API) and NEXT_API_URL import if orphaned. V2 constants have no leading slash — ky's prefix handles the baseuseFetchCheckGeminiApiKey.ts → use-fetch-check-gemini-api-key.ts). Use git mv with temp-file two-step for case-only renames on macOSGET, POST, PATCH, PUT, DELETE) and match the ky method. v1 uses POST for everything — v2 uses semantically correct methods. api.patch() for updates, api.delete() for deletes, api.put() for upsertsEmpty body rule: When the v2 route's inputSchema is z.object({}) (empty input), ky calls MUST send { json: {} }. The v2 handler calls request.json() for non-GET methods — an empty body causes 400. Always read the v2 route's schema.ts to check. Example: api.delete(V2_DELETE_API_KEY_API, { json: {} }).json()
Dead payload fields: Compare the hook's payload type against the v2 inputSchema. Remove fields the server gets from auth context (e.g., id when v2 uses user.id from cookie auth — Zod strips extra fields silently, but the dead field misleads readers). After removing a field from the hook's type, update all consumer call sites that pass it, and chase the orphan chain (the session variable and useSupabaseSession import may become unused).
Lint comment cleanup: Migration often makes lint suppressions unnecessary. Remove these when they no longer apply:
@ts-expect-error comments on crud helper casts — ky's .json<T>() is properly typedoxlint-disable @tanstack/query/exhaustive-deps — often added because session was used in both queryFn and queryKey. After migration, session is only in queryKey (not queryFn), so the rule no longer flagsno-unsafe-type-assertion (as BookmarkResponse) — bare response removes the need for type assertionprefer-await-to-then lint rule: oxlint enforces async/await over .then() chains. If you need to map a response (e.g., field name translation), use async queryFn:
queryFn: async () => {
const data = await api.get(V2_URL).json<V2Response>();
return mapToLegacyType(data);
},
Do NOT use .json<T>().then(mapper) — it triggers the lint rule.
Reference implementations:
src/async/queryHooks/bookmarks/use-fetch-paginated-bookmarks.ts — hard-class with session, searchParams, useInfiniteQuerysrc/async/queryHooks/bookmarks/use-fetch-bookmark-by-id.ts — easy-class, simple useQuerysrc/async/queryHooks/bookmarks/use-fetch-bookmarks-count.ts — hard-class with field name mappingsrc/async/queryHooks/ai/api-key/use-fetch-check-gemini-api-key.ts — canonical for z.infer patternWith ky + bare responses, there is no unwrapping needed. The consumer gets T directly from React Query's data property.
Why this is simpler than before:
T directly (e.g., {hasApiKey: boolean}).json<T>() returns T to queryFndata property IS the payload — data.hasApiKey works directlyIf the previous caller used getApi:
getApi already unwrapped via handleResponse — the consumer shape is unchanged. No consumer edits needed.
If the previous caller used axios crud helpers:
Consumers need updating: data.data.field → data.field (the double-unwrap is gone).
// Before (with axios crud helper — double-unwrap)
const { data, isLoading } = useFetchCheckApiKey();
if (isLoading || !data?.data) return <Skeleton />;
const { hasApiKey } = data.data;
// After (with ky — flat access)
const { data, isLoading } = useFetchCheckApiKey();
if (isLoading || !data) return <Skeleton />;
const { hasApiKey } = data;
What to check in consumers:
data?.data → data / !data?.data → !datadata.data.field → data.fielddata?.data?.field → data?.fieldast-grep --lang tsx -p 'useFetchHookName' src/prefer-destructuring lint rule: When assigning an array element to a let variable, oxlint enforces destructuring. Use [currentBookmark] = bookmark instead of currentBookmark = bookmark[0]. For const, use const [bookmarkData] = bookmark instead of const bookmarkData = bookmark[0]When migrating hooks that use useMutation with onMutate/onError (optimistic patterns), audit onError immediately — pre-existing bugs become runtime failures after the ky switch.
Why this matters: Axios crud helpers catch errors and return them. onError never fires because mutationFn always resolves. With ky, non-2xx throws → mutateAsync rejects → onError fires for the first time. If the onError signature is wrong, cache rollback breaks silently.
The bug pattern:
// BROKEN — first arg is error, not context
onError: (context: { previousData: ProfilesTableTypes }) => {
queryClient.setQueryData([USER_PROFILE, userId], context?.previousData);
// context is actually the Error object — previousData is undefined
// Cache is set to undefined instead of rolled back
},
// CORRECT — context is the third argument
onError: (_error, _variables, context) => {
queryClient.setQueryData([USER_PROFILE, userId], context?.previousData);
},
Audit checklist for every optimistic mutation hook:
onError in the hook(context: { previousData: ... }))(_error, _variables, context) — React Query's onError signature is (error, variables, context)context type matches what onMutate returns (e.g., { previousData: unknown })Payload interface typing:
When defining local UpdatePayload interfaces for mutation hooks, use concrete types from apiTypes.ts:
bookmarks_view → ProfilesBookmarksView (not unknown — unknown can't be spread in optimistic updates)ai_features_toggle → AiFeaturesToggle (not unknown — same spread issue)category_order → number[] | nullUsing unknown for fields that get spread in onMutate causes TS2698 "Spread types may only be created from object types."
mutationApiCall + envelope check breakage (mutation hooks): Consumers that wrap mutateAsync with mutationApiCall and then check the v1 envelope shape break silently with v2 bare responses. These patterns all fail:
// BROKEN: isNull(undefined) returns false — success block never runs
const response = await mutationApiCall(mutation.mutateAsync(payload));
if (isNull(response?.error)) { successToast("Done"); }
// BROKEN: bare T has no .data property — isNil(undefined) is true, !true is false
if (!isNil(response?.data)) { successToast("Done"); }
// BROKEN: explicit cast doesn't help — property is still undefined
if (isNull((response as { error: Error })?.error)) { ... }
Fix: Replace mutationApiCall wrapper + envelope check with try/catch. Since ky throws on non-2xx and React Query propagates the error, reaching the next line after mutateAsync() means success:
try {
await mutation.mutateAsync(payload);
successToast("Done");
} catch {
errorToast("Failed");
}
Fire-and-forget wrappers: Even when the consumer doesn't check the result (void mutationApiCall(mutateAsync(...))), remove the wrapper — mutationApiCall only adds value for its error toast on response.response.status !== 200, which never triggers with ky (errors throw instead of returning status objects). Replace void mutationApiCall(mutateAsync(payload)) with void mutateAsync(payload).
After fixing, chase the orphan chain — each removal may orphan the next:
mutationApiCall import → remove if no other call in the fileisNull / isNil import → remove if no other usage in the filesession variable → may have been used only for id in the removed payload (see dead payload fields in Layer 2)useSupabaseSession import → remove if session was the only consumerThe old crud helpers stored { data: T[], error: Error } in React Query cache. v2 stores bare T[]. Any code that accesses .data on the cache entry silently gets undefined at runtime. This is the #1 source of silent breakage in caller migrations.
Before migrating a hook, grep for ALL consumers of its query key constant:
# Find direct cache readers (optimistic mutations, helpers)
ast-grep --lang ts -p 'SHARED_CATEGORIES_TABLE_NAME' src/
ast-grep --lang ts -p 'USER_TAGS_KEY' src/
What to fix in each cache consumer:
| Pattern | Before (envelope) | After (bare) |
|---------|------------------|-------------|
| getQueryData generic | <{ data: T[] }> | <T[]> |
| setQueryData generic | <{ data: T[] }> | <T[]> |
| Updater access | old?.data?.map(...) | old?.map(...) |
| find() on cache | find(data?.data, ...) | find(data, ...) |
| Helper function params | data: { data: T[] } | data: T[] |
| Immer produce drafts | draft.data.push(...) | draft.push(...) |
| useCallback dep arrays | sharedCategoriesData?.data | sharedCategoriesData |
| onMutate snapshot | getQueryData([KEY]) (untyped → unknown) | getQueryData<T[]>([KEY]) (typed → correct context inference for onError) |
| onError rollback | onError: (context: {...}) (wrong — first param is error) | onError: (_error, _variables, context) => { ... } |
Places that get missed (from real bugs):
queryClient.getQueryData in optimistic mutation hooks — they read cache directly, not via the query hook. Must be typed (getQueryData<T[]>) so onMutate context type is inferred correctly for onError rollbackqueryClient.setQueryData updater callbacks with (old: { data: T[] }) type annotationsquery-cache-helpers.ts that accept the old envelope shape as a parameterqueryClient.getQueryData in render (e.g., collectionsList.tsx, useGetSortBy.ts)onError callback signature — useMutation passes (error, variables, context) but legacy hooks often name the first param context, causing rollback to silently read error.previousData (always undefined). Fix: onError: (_error, _variables, context) => { ... }Hooks with enabled: false are consumed via refetch(). With ky, HTTP errors throw — but refetch() catches them by default and returns { data: undefined, error } instead of re-throwing. Consumers that relied on the old crud helper's handleClientError toast lose error feedback silently.
Fix: Pass throwOnError: true to let the consumer's existing catch block fire:
const { data } = await fetchApiKey({ throwOnError: true });
When a v2 route returns camelCase fields but the shared type in apiTypes.ts uses snake_case (matching the old v1 response), the shared type must be updated to match v2 — and the v1 route gets its own local interface to preserve its contract.
When this applies: The v2 output schema has categoryName, iconColor, isPublic but the shared apiTypes.ts type has category_name, icon_color, is_public. The web frontend now hits v2, so the shared type must match v2.
Steps:
apiTypes.ts to match v2 field names (camelCase). Also remove envelope fields (data/error) — v2 returns bare responseV1{RouteName}Response, use it only in that file's NextApiResponse<> genericgetStaticProps/getServerSideProps), components, and hooks that read the old snake_case fields. Search: ast-grep --lang tsx -p 'TypeName' src/page?.data → page?.bookmarks, data?.category_name → data?.categoryName)This is different from "Field Name Mismatch Mapping" — that pattern adds a runtime mapping function to preserve the old interface for cache consumers. This pattern updates the shared type itself because there are no downstream cache consumers relying on the old field names (or because consumers are few enough to update directly).
Reference: GetPublicCategoryBookmarksApiResponseType — updated from snake_case with data/error envelope to camelCase with bookmarks array. V1 handler got local V1PublicCategoryBookmarksResponse.
After verifying layers 1-3 work (the hook fires the v2 request and the UI renders correctly), remove the now-unused crud helper and its types.
Sequence matters: Always rewrite the hook first, verify it works, THEN delete dead code. Never delete before the new code is proven.
src/async/supabaseCrudHelpers/index.tsNO_BOOKMARKS_ID_ERROR), type imports, or utility constants that become orphaned when the function is deleted. Grep each import to verify it still has consumersconstants.ts — both URL constants AND any error/utility constants that were only used by the deleted crud helper. Run pnpm lint:knip to catch any missed orphanssrc/types/apiTypes.ts — response envelope interfaces like FetchUserTagsDataResponse ({ data: T[], error: PostgrestError }) that were only used by the deleted crud helper. Grep the type name across src/ — if zero consumers, delete itpnpm lint:knip to confirm no orphaned exports remain# Find what else uses the crud helper before deleting
ast-grep --lang ts -p 'functionName' src/
After the frontend caller is migrated and dead code is cleaned up, mark the old Pages Router route handler as deprecated so other consumers (extension, mobile, edge functions, cron) know v2 exists.
Add a JSDoc @deprecated comment at the top of the file (before the first import):
/**
* @deprecated Use the v2 App Router endpoint instead: /api/v2/{route-name}
* This Pages Router route will be removed after all consumers are migrated.
*/
import type { NextApiRequest, NextApiResponse } from "next";
Which file? The Pages Router handler for the endpoint you just migrated:
src/pages/api/v1/ → strip v1/ for the v2 path (e.g., v1/check-gemini-api-key → /api/v2/check-gemini-api-key)src/pages/api/ (non-v1) → same name (e.g., bookmark/search-bookmarks → /api/v2/bookmark/search-bookmarks)Do NOT deprecate routes whose frontend callers have not been migrated yet. This comment signals "the web frontend no longer calls this" — other consumers use it to plan their own migration.
Hooks with session closures and QueryFunctionContext follow the same 5-layer pattern with these additions:
v2 auth is cookie-based — the queryFn no longer needs a session parameter. But session?.user?.id MUST stay in queryKey for cache scoping. Without it, mutation hooks that use queryClient.getQueryData/setQueryData with the old key pattern won't find the cached data.
// session removed from queryFn, kept in queryKey
const session = useSupabaseSession((state) => state.session);
useInfiniteQuery({
queryFn: ({ pageParam }) =>
api.get(V2_FETCH_BOOKMARKS_DATA_API, {
searchParams: {
category_id: String(CATEGORY_ID ?? "null"),
from: pageParam,
...(sortBy ? { sort_by: sortBy } : {}),
},
}).json<SingleListData[]>(),
queryKey: [BOOKMARKS_KEY, session?.user?.id, CATEGORY_ID, sortBy],
});
The ?. on session?.user?.id is required — session type is { user: null | User } | undefined (starts undefined before auth).
CategoryIdUrlTypes = null | number | string — ky's searchParams doesn't accept null. Wrap with String(CATEGORY_ID ?? "null") to convert.
The old crud helpers returned { count, data: T[], error } per page. v2 returns bare T[]. This breaks page.data access in mutation hooks, query-cache-helpers.ts, previewLightBox.tsx, and useLightboxPrefetch.ts.
Always use bare response — return the v2 bare array directly from queryFn. Update all cache consumers:
PaginatedBookmarks type from { pages: { data: T[] }[] } to { pages: T[][] }page.data.filter(...) → page.filter(...)query-cache-helpers.ts: page.data.find(...) → page.find(...)page?.data?.length → page?.lengthPaginatedBookmarks for paginated cache consumers, keep BookmarksPaginatedDataTypes for search (shared with unmigrated search hooks)secondaryQueryKey warning: useReactQueryOptimisticMutation applies the SAME updater to both primary (paginated) and secondary (search) caches. If page shapes diverge (paginated = bare array, search = wrapped), hooks using secondaryQueryKey need search handling separated — either via additionalOptimisticUpdates with a search-specific updater, or by removing secondaryQueryKey and adding search invalidation in onSettled.
The old crud helper included count: BookmarksCountTypes per page — this was redundant. No consumer reads page.count from paginated data. Counts are fetched independently via useFetchBookmarksCount. Safe to omit.
When v2 schema field names differ from the hand-written TypeScript interface (e.g., BookmarksCountTypes uses trash but v2 returns trashCount), add a mapping function in the hook's queryFn to translate v2 names to the legacy interface. This stores the legacy-shaped data in React Query cache, so all consumers (direct hook callers AND indirect queryClient.getQueryData readers) see the expected type without field rename cascades.
type V2CountResponse = z.infer<typeof FetchBookmarksCountOutputSchema>;
function mapToBookmarksCountTypes(data: V2CountResponse): BookmarksCountTypes {
return {
audio: data.audioCount,
categoryCount: data.categoryCount,
everything: data.allCount,
// ... map each field
};
}
export default function useFetchBookmarksCount() {
const session = useSupabaseSession((state) => state.session);
const { data: bookmarksCountData } = useQuery({
queryFn: async () => {
const data = await api.get(V2_URL).json<V2CountResponse>();
return mapToBookmarksCountTypes(data);
},
queryKey: [BOOKMARKS_COUNT_KEY, session?.user?.id],
});
return { bookmarksCountData };
}
When to use: Only when the v2 Zod output schema has structurally different field names from the hand-written TS interface used by consumers. If the field names match (like SingleListData vs bookmark schemas — same names, just different nullability), use .json<LegacyType>() directly instead.
This is temporary — mapping functions are removed when hand-written types are retired in favor of Zod-inferred types post-migration.
Reference implementation:
src/async/queryHooks/bookmarks/use-fetch-bookmarks-count.ts — field mapping pathfinder
Reference implementation:
src/async/queryHooks/bookmarks/use-fetch-paginated-bookmarks.ts — hard-class pathfinder output
Worker files (worker.ts) and server libs (add-bookmark-min-data.ts, add-remaining-bookmark-data.ts) can't use ky — it's browser-only with prefix: "/api" that doesn't resolve server-side. Use fetch() with full URL construction instead.
Pattern:
void fetch(`${getBaseUrl()}${NEXT_API_URL}/${V2_AI_ENRICHMENT_API}`, {
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
method: "POST",
});
V2_* constant for the route segment (same V2 constant rule — never inline strings)getBaseUrl() + NEXT_API_URL for full URL constructionvoid prefix for fire-and-forget calls (worker dispatches)axios import if orphaned after migrationfetch(\${getBaseUrl()}${NEXT_API_URL}/${V2_ROUTE}?param=${value}`)` — query params in URLReference implementations:
src/utils/worker.ts — fire-and-forget POST (ai-enrichment, screenshot)src/lib/bookmarks/add-bookmark-min-data.ts — server-side GET with error handlingAfter completing all 5 layers:
pnpm fix # Auto-fix formatting
pnpm lint # All quality checks
pnpm build # Confirm build passes
pnpm lint:knip # Confirm no orphaned exports from dead code removal
Update migration tracker: Mark the completed row in docs/CALLER_MIGRATION.md with x in the Status column (per the file's legend: = not started, ~ = in progress, x = done). Do NOT use emoji — characters like ✅ have different widths and break markdown table column alignment.
| User says | Action |
|-----------|--------|
| "migrate caller" / "wire up v2" / "update hook to v2" | Execute the 5-layer workflow above |
| "ky" / "api-v2" / "bare response" | Execute the 5-layer workflow above |
| "consumer update" / "data.data" / "double unwrap" | See Layer 3 — consumer verification |
| "mutation hook template" / "postApi pattern" | Use recollect-mutation-hook-refactoring skill |
| "hard-class" / "session closure" / "QueryFunctionContext" | See "Hard-Class Hooks" section |
v2 route handlers import from create-handler-v2.ts, not create-handler.ts. The v2 factory uses a two-layer composition: createAxiomRouteHandler(withAuth/withPublic({...})). It does its own auth and validation, returning T on success and {error: string} on failure. No imports from response.ts needed.
Follows /logging-best-practices (wide events pattern):
createAxiomRouteHandler emits a single wide event at completion with all context (timing, status, user, business fields, error details). No scattered log calls.RecollectApiError) add error.toLogContext() to ctx.fields in the catch block. The outer wide event captures it automatically. No logger.warn("Known error") in handler code.onRequestError → Sentry.getServerContext()?.fields — no direct logger.info/console.log in handler body.args.import { createAxiomRouteHandler, withAuth } from "@/lib/api-helpers/create-handler-v2";
import { RecollectApiError } from "@/lib/api-helpers/errors";
import { getServerContext } from "@/lib/api-helpers/server-context";
export const GET = createAxiomRouteHandler(
withAuth({
handler: async ({ supabase, user }) => {
const { data, error: dbError } = await supabase.from("table").select("*");
if (dbError) {
throw new RecollectApiError("service_unavailable", {
cause: dbError,
message: "Failed to fetch",
operation: "fetch_data",
});
}
const ctx = getServerContext();
if (ctx?.fields) {
ctx.fields.user_id = user.id;
}
return data;
},
inputSchema: InputSchema,
outputSchema: OutputSchema,
route: "v2-route-name",
}),
);
throw RecollectApiError — caught by withAuth/withPublic, error context added to ctx.fields, returned as {error: string} with HTTP status. Outer wide event captures it (never Sentry)createAxiomRouteHandler, logged as Axiom error, re-thrown to onRequestError for SentrygetServerContext()?.fields — populate with business context for wide events (one log line per request with all context)create-handler.ts is envelope-only ({data: T, error: null}) — used by v1 routes onlyReference implementation: src/app/api/v2/check-gemini-api-key/route.ts (canonical v2 handler reference)
v2 output schema field names MUST match the frontend SingleListData convention:
addedCategories (not categories)addedTags (not tags)The frontend rendering components (bookmarkCardParts.tsx, bookmarkCard.tsx) access these fields directly. A mismatch causes silent undefined access — bookmarks render without categories/tags.
Checked: only fetch-bookmarks-data had this mismatch (now fixed). search-bookmarks and fetch-by-id already use the correct names. Routes that don't stitch junction data (add-bookmark-min-data, fetch-bookmarks-count, etc.) are unaffected.
Existing callers using getApi/postApi from api.ts with handleResponse continue working against v1 and envelope v2 routes. Do NOT modify api.ts or response.ts — they serve v1 callers until v1 dies. New v2 migrations always use api from api-v2.ts.
Mutation hooks that call crud helpers from supabaseCrudHelpers/index.ts. The mutationFn replaces the crud helper with a ky call. Same 5-layer pattern applies.
Key differences from query hooks:
src/async/mutationHooks/{domain}/use-{action}-{name}-mutation.tsPATCH, PUT, DELETE, etc.)onSuccess/onSettled): no consumer response shape updates needed for the ky change itself. But consumers that use mutationApiCall + envelope checks still break — see Layer 3 mutationApiCall sectiononSuccess. If onSuccess doesn't destructure or read the mutation result, the swap is trivialas unknown as ResponseType casts to bridge axios response shape. These are dead weight with ky — remove the interface and cast entirely// Mutation hook with ky — fire-and-forget pattern
export default function useDeleteSharedCategoriesUserMutation() {
const session = useSupabaseSession((state) => state.session);
const queryClient = useQueryClient();
const deleteSharedCategoriesUserMutation = useMutation({
mutationFn: (payload: { id: number }) =>
api.delete(V2_DELETE_SHARED_CATEGORIES_USER_API, { json: payload }).json(),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [SHARED_CATEGORIES_TABLE_NAME],
});
void queryClient.invalidateQueries({
queryKey: [CATEGORIES_KEY, session?.user?.id],
});
},
});
return { deleteSharedCategoriesUserMutation };
}
Reference implementations:
src/async/mutationHooks/share/use-delete-shared-categories-user-mutation.ts — DELETE with payloadsrc/async/mutationHooks/user/use-delete-user-mutation.ts — POST with empty body { json: {} }src/async/mutationHooks/user/use-api-key-user-mutation.ts — PUT with payload, .tsx→.ts renamesrc/async/mutationHooks/user/use-remove-user-profile-pic-mutation.ts — DELETE with empty body, dead id field removedtesting
v2-route-audit
tools
release
development
recollect-mutation-hook-refactoring
tools
prod-hotfix