diff --git a/.gitignore b/.gitignore
index b0a76c1..adad40b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -130,3 +130,7 @@ dist
.pnp.*
lib/
+
+# Project-specific files
+plan/
+CLAUDE.md
diff --git a/.npmignore b/.npmignore
index 824b66b..7d9551c 100644
--- a/.npmignore
+++ b/.npmignore
@@ -4,3 +4,6 @@
src
**/snapshot
/*.*
+!codemod
+codemod/__tests__
+codemod/__testfixtures__
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..153ea84
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,55 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.2.0-alpha.1] - 2025-07-17
+
+### Added
+
+- Backwards compatibility layer with deprecated hook wrappers
+ - All existing `useXxx()` hooks continue to work but are marked as deprecated
+ - Hooks include migration instructions in JSDoc comments
+ - Mutation hooks maintain automatic query invalidation behavior
+- jscodeshift codemod for automated migration from v0.1.x to v0.2.x
+ - Automatically transforms deprecated hooks to new queryOptions pattern
+ - Preserves all parameters, options, and TypeScript types
+ - Includes dry-run mode for safe preview of changes
+
+### Changed
+
+- Re-added deprecated hooks alongside new queryOptions exports for smoother migration path
+
+## [0.2.0-alpha.0] - 2025-07-17
+
+### Changed
+
+- **BREAKING**: Migrated from wrapper hooks to queryOptions/mutationOptions export pattern
+ - Changed from `useWidgets()` to `getWidgetsQueryOptions()`
+ - Changed from `useCreateWidget()` to `createWidgetMutationOptions()`
+ - Changed from `useInfiniteWidgets()` to `getWidgetsInfiniteQueryOptions()`
+- **BREAKING**: Updated query key structure for better cache management
+ - From: `['/widgets', compact({ status: params?.status })].filter(Boolean)`
+ - To: `['widget', 'getWidgets', params || {}] as const`
+- Added non-hook service getters in context for use in queryOptions
+
+### Added
+
+- Type-safe `matchQueryKey` function for building query keys with IntelliSense support
+ - Supports partial query matching at service, operation, or full parameter levels
+ - Provides compile-time type safety and autocomplete for all query operations
+ - Enables flexible cache invalidation patterns
+- Test coverage for infinite query options generation
+- Support for direct composition with React Query hooks
+- Better TypeScript inference with queryOptions pattern
+
+### Removed
+
+- Wrapper hook functions (use queryOptions with React Query hooks directly)
+- Complex query key filtering logic
+
+## [0.1.x] - Previous versions
+
+Initial implementation with wrapper hooks pattern.
diff --git a/README.md b/README.md
index d16892a..c8aac68 100644
--- a/README.md
+++ b/README.md
@@ -3,13 +3,106 @@
# React Query
-[Basketry generator](https://github.com/basketry/basketry) for generating React Query hooks. This parser can be coupled with any Basketry parser.
+[Basketry generator](https://basketry.io) for generating [React Query](https://tanstack.com/query) (TanStack Query) hooks and query/mutation options. This generator can be coupled with any Basketry parser to automatically generate type-safe React Query integration from your API definitions.
-## Quick Start
+## Features
-// TODO
+- Generates type-safe query and mutation options following React Query v5 patterns
+- Type-safe query key builder for cache operations with IntelliSense support
+- Support for infinite queries with Relay-style pagination
+- Full TypeScript support with proper type inference
+- Backwards compatibility with deprecated hook wrappers for smooth migration
----
+## Migration Guide (v0.1.x to v0.2.x)
+
+Starting with v0.2.0, this generator adopts the React Query v5 queryOptions pattern. The old hook wrappers are deprecated but still available for backwards compatibility.
+
+### Query Hooks
+
+```typescript
+// Old pattern (deprecated)
+import { useGetWidgets } from './hooks/widgets';
+const result = useGetWidgets(params);
+
+// New pattern
+import { useQuery } from '@tanstack/react-query';
+import { getWidgetsQueryOptions } from './hooks/widgets';
+const result = useQuery(getWidgetsQueryOptions(params));
+```
+
+### Mutation Hooks
+
+```typescript
+// Old pattern (deprecated)
+import { useCreateWidget } from './hooks/widgets';
+const mutation = useCreateWidget();
+
+// New pattern
+import { useMutation } from '@tanstack/react-query';
+import { createWidgetMutationOptions } from './hooks/widgets';
+const mutation = useMutation(createWidgetMutationOptions());
+```
+
+### Infinite Query Hooks
+
+```typescript
+// Old pattern (deprecated)
+import { useGetWidgetsInfinite } from './hooks/widgets';
+const result = useGetWidgetsInfinite(params);
+
+// New pattern
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { getWidgetsInfiniteQueryOptions } from './hooks/widgets';
+const result = useInfiniteQuery(getWidgetsInfiniteQueryOptions(params));
+```
+
+### Query Key Builder
+
+The new version includes a type-safe query key builder for cache operations:
+
+```typescript
+import { matchQueryKey } from './hooks/query-key-builder';
+
+// Invalidate all queries for a service
+queryClient.invalidateQueries({ queryKey: matchQueryKey('widgets') });
+
+// Invalidate specific operation
+queryClient.invalidateQueries({
+ queryKey: matchQueryKey('widgets', 'getWidgets'),
+});
+
+// Invalidate with specific parameters
+queryClient.invalidateQueries({
+ queryKey: matchQueryKey('widgets', 'getWidgets', { status: 'active' }),
+});
+```
+
+### Benefits of the New Pattern
+
+- Better tree-shaking - only import what you use
+- More flexible - compose with any React Query hook
+- Better TypeScript inference
+- Easier testing - options can be tested without React context
+- Consistent with React Query v5 best practices
+
+### Automated Migration
+
+We provide a jscodeshift codemod to automatically migrate your codebase:
+
+```bash
+# Using the provided script
+./node_modules/@basketry/react-query/codemod/run-migration.sh # Preview (dry run)
+./node_modules/@basketry/react-query/codemod/run-migration.sh --apply # Apply changes
+
+# Or using jscodeshift directly
+npx jscodeshift -t ./node_modules/@basketry/react-query/codemod/react-query-v0.2-migration.js \
+ src/ --extensions=ts,tsx --parser=tsx --dry # Preview (dry run)
+
+npx jscodeshift -t ./node_modules/@basketry/react-query/codemod/react-query-v0.2-migration.js \
+ src/ --extensions=ts,tsx --parser=tsx # Apply changes
+```
+
+See [codemod documentation](./codemod/README.md) for more details.
## For contributors:
diff --git a/codemod/README.md b/codemod/README.md
new file mode 100644
index 0000000..6e8daf2
--- /dev/null
+++ b/codemod/README.md
@@ -0,0 +1,203 @@
+# React Query v0.2 Migration Codemod
+
+This codemod helps automatically migrate your codebase from `@basketry/react-query` v0.1.x to v0.2.x by transforming deprecated hook patterns to the new queryOptions pattern.
+
+## What it does
+
+The codemod will transform:
+
+### Query Hooks
+
+```typescript
+// Before
+import { useGetWidgets } from '../hooks/widgets';
+const { data } = useGetWidgets({ status: 'active' });
+
+// After
+import { useQuery } from '@tanstack/react-query';
+import { getWidgetsQueryOptions } from '../hooks/widgets';
+const { data } = useQuery(getWidgetsQueryOptions({ status: 'active' }));
+```
+
+### Mutation Hooks
+
+```typescript
+// Before
+import { useCreateWidget } from '../hooks/widgets';
+const mutation = useCreateWidget({ onSuccess: handleSuccess });
+
+// After
+import { useMutation } from '@tanstack/react-query';
+import { createWidgetMutationOptions } from '../hooks/widgets';
+const mutation = useMutation(
+ createWidgetMutationOptions({ onSuccess: handleSuccess }),
+);
+```
+
+### Infinite Query Hooks
+
+```typescript
+// Before
+import { useGetWidgetsInfinite } from '../hooks/widgets';
+const { data, fetchNextPage } = useGetWidgetsInfinite({ limit: 20 });
+
+// After
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { getWidgetsInfiniteQueryOptions } from '../hooks/widgets';
+const { data, fetchNextPage } = useInfiniteQuery(
+ getWidgetsInfiniteQueryOptions({ limit: 20 }),
+);
+```
+
+### Suspense Hooks
+
+```typescript
+// Before
+import { useSuspenseGetWidgets } from '../hooks/widgets';
+const { data } = useSuspenseGetWidgets();
+
+// After
+import { useSuspenseQuery } from '@tanstack/react-query';
+import { getWidgetsQueryOptions } from '../hooks/widgets';
+const { data } = useSuspenseQuery(getWidgetsQueryOptions());
+```
+
+## Installation
+
+```bash
+# Install jscodeshift globally
+npm install -g jscodeshift
+
+# Or use npx (no installation needed)
+npx jscodeshift ...
+```
+
+## Usage
+
+### Basic Usage
+
+```bash
+# Dry run (preview changes without modifying files)
+jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx --dry
+
+# Run the transformation
+jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx
+```
+
+### Specific Files or Directories
+
+```bash
+# Transform a single file
+jscodeshift -t codemod/react-query-v0.2-migration.js src/components/WidgetList.tsx --parser=tsx
+
+# Transform a specific directory
+jscodeshift -t codemod/react-query-v0.2-migration.js src/features/widgets/ --extensions=ts,tsx --parser=tsx
+```
+
+### With Git
+
+```bash
+# See what would change
+jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx --dry
+
+# Run and see the diff
+jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx
+git diff
+
+# If something went wrong, revert
+git checkout -- .
+```
+
+## Features
+
+- ✅ Transforms all deprecated hook types (query, mutation, infinite, suspense)
+- ✅ Preserves TypeScript type parameters
+- ✅ Updates imports correctly
+- ✅ Handles multiple hooks from the same module
+- ✅ Adds React Query imports only when needed
+- ✅ Preserves existing React Query imports
+- ✅ Maintains code formatting
+- ✅ Only transforms hooks from generated `hooks/` modules
+
+## Limitations
+
+1. **Hooks in Dynamic Contexts**: The codemod may not handle hooks called in complex dynamic contexts (e.g., inside conditional logic or loops).
+
+2. **Custom Wrappers**: If you've created custom wrappers around the generated hooks, those won't be automatically migrated.
+
+3. **Import Aliases**: If you're using import aliases or renamed imports, you may need to update those manually:
+
+ ```typescript
+ // This won't be transformed automatically
+ import { useGetWidgets as useWidgets } from '../hooks/widgets';
+ ```
+
+4. **Side Effects**: The old mutation hooks automatically invalidated queries on success. The new pattern requires you to handle this in your mutationOptions if needed.
+
+## Testing the Codemod
+
+### Run Tests
+
+```bash
+# Install dependencies
+npm install
+
+# Run the test suite
+npm test codemod/__tests__/react-query-v0.2-migration.test.js
+```
+
+### Test on a Single File
+
+```bash
+# Create a test file
+echo "import { useGetWidgets } from './hooks/widgets';
+const Component = () => {
+ const { data } = useGetWidgets();
+ return
{data?.length}
;
+};" > test-migration.tsx
+
+# Run the codemod
+jscodeshift -t codemod/react-query-v0.2-migration.js test-migration.tsx --parser=tsx --print
+```
+
+## Manual Review Checklist
+
+After running the codemod, review:
+
+1. **Build**: Run `npm run build` to ensure no TypeScript errors
+2. **Tests**: Run your test suite to ensure functionality is preserved
+3. **Mutations**: Check that mutation success handlers still invalidate queries if needed
+4. **Imports**: Verify all imports are correct and no duplicates exist
+5. **Runtime**: Test your application to ensure everything works as expected
+
+## Troubleshooting
+
+### "Cannot find module" errors
+
+Make sure you're running the codemod from your project root where `node_modules` is located.
+
+### Parser errors
+
+Ensure you're using the `--parser=tsx` flag for TypeScript files.
+
+### Nothing is transformed
+
+Check that your imports match the expected pattern (from `'../hooks/[service]'` modules).
+
+### Formatting issues
+
+The codemod tries to preserve formatting, but you may want to run your formatter after:
+
+```bash
+npm run prettier -- --write src/
+# or
+npm run eslint -- --fix src/
+```
+
+## Need Help?
+
+If you encounter issues:
+
+1. Check the [migration guide](../README.md#migration-guide-v01x-to-v02x) in the main README
+2. Look at the generated hooks to understand the new pattern
+3. Open an issue with a code sample that isn't working correctly
diff --git a/codemod/__testfixtures__/react-query-v0.2-migration.input.tsx b/codemod/__testfixtures__/react-query-v0.2-migration.input.tsx
new file mode 100644
index 0000000..264582e
--- /dev/null
+++ b/codemod/__testfixtures__/react-query-v0.2-migration.input.tsx
@@ -0,0 +1,150 @@
+import React from 'react';
+import {
+ useGetWidgets,
+ useGetWidget,
+ useCreateWidget,
+ useUpdateWidget,
+ useDeleteWidget,
+ useGetWidgetsInfinite,
+ useSuspenseGetWidgets,
+ useSuspenseGetWidgetsInfinite,
+} from '../hooks/widgets';
+import { useGetGizmos, useCreateGizmo } from '../hooks/gizmos';
+import { SomeOtherExport } from '../hooks/widgets';
+
+// Simple query hook usage
+export function WidgetList() {
+ const { data, isLoading } = useGetWidgets({ status: 'active' });
+
+ if (isLoading) return Loading...
;
+
+ return (
+
+ {data?.items.map((widget) => (
+ {widget.name}
+ ))}
+
+ );
+}
+
+// Query with type parameters
+export function TypedWidgetDetail({ id }: { id: string }) {
+ const { data } = useGetWidget({ id });
+ return {data?.customField}
;
+}
+
+// Mutation hook usage
+export function CreateWidgetForm() {
+ const createWidget = useCreateWidget({
+ onSuccess: (data) => {
+ console.log('Created widget:', data);
+ },
+ onError: (error) => {
+ console.error('Failed to create widget:', error);
+ },
+ });
+
+ const updateWidget = useUpdateWidget();
+ const deleteWidget = useDeleteWidget();
+
+ return (
+
+ );
+}
+
+// Infinite query usage
+export function InfiniteWidgetList() {
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useGetWidgetsInfinite({ limit: 20 });
+
+ return (
+
+ {data?.pages.map((page, i) => (
+
+ {page.items.map((widget) => (
+
{widget.name}
+ ))}
+
+ ))}
+
fetchNextPage()}
+ disabled={!hasNextPage || isFetchingNextPage}
+ >
+ Load More
+
+
+ );
+}
+
+// Suspense query usage
+export function SuspenseWidgetList() {
+ const { data } = useSuspenseGetWidgets({ status: 'active' });
+
+ return (
+
+ {data.items.map((widget) => (
+ {widget.name}
+ ))}
+
+ );
+}
+
+// Suspense infinite query usage
+export function SuspenseInfiniteWidgets() {
+ const { data, fetchNextPage } = useSuspenseGetWidgetsInfinite({
+ limit: 10,
+ sort: 'name',
+ });
+
+ return (
+
+ {data.pages.map((page, i) => (
+
+ {page.items.map((widget) => (
+ {widget.name}
+ ))}
+
+ ))}
+
fetchNextPage()}>Next
+
+ );
+}
+
+// Multiple hooks from different services
+export function MultiServiceComponent() {
+ const widgets = useGetWidgets();
+ const gizmos = useGetGizmos({ type: 'advanced' });
+ const createGizmo = useCreateGizmo();
+
+ return (
+
+
Widgets: {widgets.data?.items.length || 0}
+ Gizmos: {gizmos.data?.items.length || 0}
+ createGizmo.mutate({ name: 'New Gizmo' })}>
+ Create Gizmo
+
+
+ );
+}
+
+// Edge case: hook in a callback
+export function CallbackComponent() {
+ const fetchData = React.useCallback(() => {
+ const result = useGetWidgets({ limit: 5 });
+ return result;
+ }, []);
+
+ return Callback component
;
+}
+
+// Custom type definitions for testing
+interface CustomWidget extends Widget {
+ customField: string;
+}
diff --git a/codemod/__testfixtures__/react-query-v0.2-migration.output.tsx b/codemod/__testfixtures__/react-query-v0.2-migration.output.tsx
new file mode 100644
index 0000000..c8d3c7b
--- /dev/null
+++ b/codemod/__testfixtures__/react-query-v0.2-migration.output.tsx
@@ -0,0 +1,166 @@
+import React from 'react';
+import {
+ useQuery,
+ useMutation,
+ useInfiniteQuery,
+ useSuspenseQuery,
+ useSuspenseInfiniteQuery,
+} from '@tanstack/react-query';
+import {
+ getWidgetsQueryOptions,
+ getWidgetQueryOptions,
+ createWidgetMutationOptions,
+ updateWidgetMutationOptions,
+ deleteWidgetMutationOptions,
+ getWidgetsInfiniteQueryOptions,
+ SomeOtherExport,
+} from '../hooks/widgets';
+import {
+ getGizmosQueryOptions,
+ createGizmoMutationOptions,
+} from '../hooks/gizmos';
+
+// Simple query hook usage
+export function WidgetList() {
+ const { data, isLoading } = useQuery(
+ getWidgetsQueryOptions({ status: 'active' }),
+ );
+
+ if (isLoading) return Loading...
;
+
+ return (
+
+ {data?.items.map((widget) => (
+ {widget.name}
+ ))}
+
+ );
+}
+
+// Query with type parameters
+export function TypedWidgetDetail({ id }: { id: string }) {
+ const { data } = useQuery(getWidgetQueryOptions({ id }));
+ return {data?.customField}
;
+}
+
+// Mutation hook usage
+export function CreateWidgetForm() {
+ const createWidget = useMutation(
+ createWidgetMutationOptions({
+ onSuccess: (data) => {
+ console.log('Created widget:', data);
+ },
+ onError: (error) => {
+ console.error('Failed to create widget:', error);
+ },
+ }),
+ );
+
+ const updateWidget = useMutation(updateWidgetMutationOptions());
+ const deleteWidget = useMutation(deleteWidgetMutationOptions());
+
+ return (
+
+ );
+}
+
+// Infinite query usage
+export function InfiniteWidgetList() {
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useInfiniteQuery(getWidgetsInfiniteQueryOptions({ limit: 20 }));
+
+ return (
+
+ {data?.pages.map((page, i) => (
+
+ {page.items.map((widget) => (
+
{widget.name}
+ ))}
+
+ ))}
+
fetchNextPage()}
+ disabled={!hasNextPage || isFetchingNextPage}
+ >
+ Load More
+
+
+ );
+}
+
+// Suspense query usage
+export function SuspenseWidgetList() {
+ const { data } = useSuspenseQuery(
+ getWidgetsQueryOptions({ status: 'active' }),
+ );
+
+ return (
+
+ {data.items.map((widget) => (
+ {widget.name}
+ ))}
+
+ );
+}
+
+// Suspense infinite query usage
+export function SuspenseInfiniteWidgets() {
+ const { data, fetchNextPage } = useSuspenseInfiniteQuery(
+ getWidgetsInfiniteQueryOptions({
+ limit: 10,
+ sort: 'name',
+ }),
+ );
+
+ return (
+
+ {data.pages.map((page, i) => (
+
+ {page.items.map((widget) => (
+ {widget.name}
+ ))}
+
+ ))}
+
fetchNextPage()}>Next
+
+ );
+}
+
+// Multiple hooks from different services
+export function MultiServiceComponent() {
+ const widgets = useQuery(getWidgetsQueryOptions());
+ const gizmos = useQuery(getGizmosQueryOptions({ type: 'advanced' }));
+ const createGizmo = useMutation(createGizmoMutationOptions());
+
+ return (
+
+
Widgets: {widgets.data?.items.length || 0}
+ Gizmos: {gizmos.data?.items.length || 0}
+ createGizmo.mutate({ name: 'New Gizmo' })}>
+ Create Gizmo
+
+
+ );
+}
+
+// Edge case: hook in a callback
+export function CallbackComponent() {
+ const fetchData = React.useCallback(() => {
+ const result = useQuery(getWidgetsQueryOptions({ limit: 5 }));
+ return result;
+ }, []);
+
+ return Callback component
;
+}
+
+// Custom type definitions for testing
+interface CustomWidget extends Widget {
+ customField: string;
+}
diff --git a/codemod/__tests__/react-query-v0.2-migration.test.js b/codemod/__tests__/react-query-v0.2-migration.test.js
new file mode 100644
index 0000000..8959ecf
--- /dev/null
+++ b/codemod/__tests__/react-query-v0.2-migration.test.js
@@ -0,0 +1,169 @@
+const { defineTest } = require('jscodeshift/dist/testUtils');
+
+// Basic transformation test
+defineTest(
+ __dirname,
+ '../react-query-v0.2-migration',
+ {},
+ 'react-query-v0.2-migration',
+ { parser: 'tsx' },
+);
+
+// You can also add more specific tests
+describe('react-query-v0.2-migration codemod', () => {
+ const jscodeshift = require('jscodeshift');
+ const transform = require('../react-query-v0.2-migration');
+
+ const transformOptions = {
+ jscodeshift,
+ stats: () => {},
+ report: () => {},
+ };
+
+ it('should transform simple query hooks', () => {
+ const input = `
+import { useGetWidgets } from '../hooks/widgets';
+
+function Component() {
+ const { data } = useGetWidgets({ limit: 10 });
+ return {data?.length}
;
+}
+`;
+
+ const expected = `
+import { useQuery } from '@tanstack/react-query';
+import { getWidgetsQueryOptions } from '../hooks/widgets';
+
+function Component() {
+ const { data } = useQuery(getWidgetsQueryOptions({ limit: 10 }));
+ return {data?.length}
;
+}
+`;
+
+ const result = transform(
+ { path: 'test.tsx', source: input },
+ transformOptions,
+ );
+
+ expect(result).toBe(expected.trim());
+ });
+
+ it('should preserve type parameters', () => {
+ const input = `
+import { useGetWidget } from '../hooks/widgets';
+
+function Component() {
+ const { data } = useGetWidget({ id: '123' });
+ return {data?.name}
;
+}
+`;
+
+ const expected = `
+import { useQuery } from '@tanstack/react-query';
+import { getWidgetQueryOptions } from '../hooks/widgets';
+
+function Component() {
+ const { data } = useQuery(getWidgetQueryOptions({ id: '123' }));
+ return {data?.name}
;
+}
+`;
+
+ const result = transform(
+ { path: 'test.tsx', source: input },
+ transformOptions,
+ );
+
+ expect(result).toBe(expected.trim());
+ });
+
+ it('should handle multiple hooks from same module', () => {
+ const input = `
+import { useGetWidgets, useCreateWidget, useUpdateWidget } from '../hooks/widgets';
+
+function Component() {
+ const widgets = useGetWidgets();
+ const create = useCreateWidget();
+ const update = useUpdateWidget();
+
+ return Test
;
+}
+`;
+
+ const expected = `
+import {
+ useQuery,
+ useMutation,
+} from '@tanstack/react-query';
+import { getWidgetsQueryOptions, createWidgetMutationOptions, updateWidgetMutationOptions } from '../hooks/widgets';
+
+function Component() {
+ const widgets = useQuery(getWidgetsQueryOptions());
+ const create = useMutation(createWidgetMutationOptions());
+ const update = useMutation(updateWidgetMutationOptions());
+
+ return Test
;
+}
+`;
+
+ const result = transform(
+ { path: 'test.tsx', source: input },
+ transformOptions,
+ );
+
+ expect(result).toBe(expected.trim());
+ });
+
+ it('should not transform non-generated hooks', () => {
+ const input = `
+import { useCustomHook } from './custom-hooks';
+import { useState } from 'react';
+
+function Component() {
+ const custom = useCustomHook();
+ const [state, setState] = useState();
+
+ return Test
;
+}
+`;
+
+ const result = transform(
+ { path: 'test.tsx', source: input },
+ transformOptions,
+ );
+
+ expect(result).toBe(input);
+ });
+
+ it('should handle existing React Query imports', () => {
+ const input = `
+import { useQueryClient } from '@tanstack/react-query';
+import { useGetWidgets } from '../hooks/widgets';
+
+function Component() {
+ const queryClient = useQueryClient();
+ const { data } = useGetWidgets();
+
+ return {data?.length}
;
+}
+`;
+
+ const expected = `
+import { useQueryClient, useQuery } from '@tanstack/react-query';
+import { getWidgetsQueryOptions } from '../hooks/widgets';
+
+function Component() {
+ const queryClient = useQueryClient();
+ const { data } = useQuery(getWidgetsQueryOptions());
+
+ return {data?.length}
;
+}
+`;
+
+ const result = transform(
+ { path: 'test.tsx', source: input },
+ transformOptions,
+ );
+
+ expect(result).toBe(expected.trim());
+ });
+});
diff --git a/codemod/react-query-v0.2-migration.js b/codemod/react-query-v0.2-migration.js
new file mode 100644
index 0000000..aaf10c9
--- /dev/null
+++ b/codemod/react-query-v0.2-migration.js
@@ -0,0 +1,258 @@
+/**
+ * jscodeshift codemod for migrating @basketry/react-query from v0.1.x to v0.2.x
+ *
+ * This transform will:
+ * 1. Replace deprecated hook calls with new queryOptions pattern
+ * 2. Update imports to include necessary React Query hooks
+ * 3. Preserve all arguments and type parameters
+ * 4. Handle query, mutation, and infinite query patterns
+ *
+ * Usage:
+ * jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx
+ */
+
+module.exports = function transformer(fileInfo, api) {
+ const j = api.jscodeshift;
+ const root = j(fileInfo.source);
+
+ let hasModifications = false;
+ const reactQueryImportsToAdd = new Set();
+ const hookImportsToRemove = new Set();
+ const optionsImportsToAdd = new Map(); // Map>
+
+ // Helper to convert hook name to options name
+ function getOptionsName(hookName, type) {
+ // Remove 'use' prefix and convert to camelCase
+ let baseName = hookName.substring(3);
+
+ // Handle suspense prefix
+ if (baseName.startsWith('Suspense')) {
+ baseName = baseName.substring(8); // Remove 'Suspense'
+ }
+
+ // For query hooks, check if we need to add back "get" prefix
+ // If the hook was "useWidgets" it came from "getWidgets", so options should be "getWidgetsQueryOptions"
+ if (
+ (type === 'query' ||
+ type === 'suspense' ||
+ type === 'infinite' ||
+ type === 'suspenseInfinite') &&
+ !baseName.toLowerCase().startsWith('get') &&
+ !hookName.match(
+ /use(Create|Update|Delete|Add|Remove|Set|Save|Post|Put|Patch)/,
+ )
+ ) {
+ baseName = 'get' + baseName;
+ }
+
+ const camelCaseName = baseName.charAt(0).toLowerCase() + baseName.slice(1);
+
+ switch (type) {
+ case 'infinite':
+ // useWidgetsInfinite -> getWidgetsInfiniteQueryOptions
+ return camelCaseName.replace(/Infinite$/, '') + 'InfiniteQueryOptions';
+ case 'suspenseInfinite':
+ // useSuspenseWidgetsInfinite -> getWidgetsInfiniteQueryOptions
+ return camelCaseName.replace(/Infinite$/, '') + 'InfiniteQueryOptions';
+ case 'suspense':
+ // useSuspenseWidgets -> getWidgetsQueryOptions
+ return camelCaseName + 'QueryOptions';
+ case 'mutation':
+ // useCreateWidget -> createWidgetMutationOptions
+ return camelCaseName + 'MutationOptions';
+ default:
+ // useWidgets -> getWidgetsQueryOptions
+ return camelCaseName + 'QueryOptions';
+ }
+ }
+
+ // Helper to determine hook type
+ function getHookType(hookName) {
+ if (hookName.includes('useSuspense') && hookName.endsWith('Infinite')) {
+ return 'suspenseInfinite';
+ }
+ if (hookName.endsWith('Infinite')) {
+ return 'infinite';
+ }
+ if (hookName.startsWith('useSuspense')) {
+ return 'suspense';
+ }
+ // Check if it's likely a mutation (contains Create, Update, Delete, etc.)
+ if (
+ hookName.match(
+ /use(Create|Update|Delete|Add|Remove|Set|Save|Post|Put|Patch)/,
+ )
+ ) {
+ return 'mutation';
+ }
+ return 'query';
+ }
+
+ // Helper to get the React Query hook name for a given type
+ function getReactQueryHook(type) {
+ switch (type) {
+ case 'infinite':
+ return 'useInfiniteQuery';
+ case 'suspenseInfinite':
+ return 'useSuspenseInfiniteQuery';
+ case 'suspense':
+ return 'useSuspenseQuery';
+ case 'mutation':
+ return 'useMutation';
+ default:
+ return 'useQuery';
+ }
+ }
+
+ // Find all imports from hooks modules
+ const hookImports = new Map(); // Map
+
+ root
+ .find(j.ImportDeclaration)
+ .filter((path) => {
+ const source = path.node.source.value;
+ return source.includes('/hooks/') && !source.includes('/hooks/runtime');
+ })
+ .forEach((path) => {
+ const modulePath = path.node.source.value;
+ path.node.specifiers.forEach((spec) => {
+ if (
+ j.ImportSpecifier.check(spec) &&
+ spec.imported.name.startsWith('use')
+ ) {
+ hookImports.set(spec.imported.name, modulePath);
+ }
+ });
+ });
+
+ // Transform hook calls
+ root
+ .find(j.CallExpression)
+ .filter((path) => {
+ const callee = path.node.callee;
+ if (j.Identifier.check(callee)) {
+ return hookImports.has(callee.name);
+ }
+ return false;
+ })
+ .forEach((path) => {
+ const hookName = path.node.callee.name;
+ const modulePath = hookImports.get(hookName);
+ const hookType = getHookType(hookName);
+ const optionsName = getOptionsName(hookName, hookType);
+ const reactQueryHook = getReactQueryHook(hookType);
+
+ hasModifications = true;
+ hookImportsToRemove.add(hookName);
+ reactQueryImportsToAdd.add(reactQueryHook);
+
+ // Track options import to add
+ if (!optionsImportsToAdd.has(modulePath)) {
+ optionsImportsToAdd.set(modulePath, new Set());
+ }
+ optionsImportsToAdd.get(modulePath).add(optionsName);
+
+ // Get the type parameters if any
+ const typeParams = path.node.typeParameters;
+
+ // Create the options call
+ const optionsCall = j.callExpression(
+ j.identifier(optionsName),
+ path.node.arguments,
+ );
+
+ // Preserve type parameters on the options call
+ if (typeParams) {
+ optionsCall.typeParameters = typeParams;
+ }
+
+ // Replace the hook call
+ j(path).replaceWith(
+ j.callExpression(j.identifier(reactQueryHook), [optionsCall]),
+ );
+ });
+
+ // Update imports if we made modifications
+ if (hasModifications) {
+ // Remove old hook imports and add new options imports
+ root
+ .find(j.ImportDeclaration)
+ .filter((path) => {
+ const source = path.node.source.value;
+ return source.includes('/hooks/') && !source.includes('/hooks/runtime');
+ })
+ .forEach((path) => {
+ const modulePath = path.node.source.value;
+ const optionsToAdd = optionsImportsToAdd.get(modulePath);
+
+ if (optionsToAdd) {
+ // Filter out removed hooks and add new options
+ const newSpecifiers = path.node.specifiers.filter((spec) => {
+ if (j.ImportSpecifier.check(spec)) {
+ return !hookImportsToRemove.has(spec.imported.name);
+ }
+ return true;
+ });
+
+ // Add new options imports
+ optionsToAdd.forEach((optionName) => {
+ newSpecifiers.push(j.importSpecifier(j.identifier(optionName)));
+ });
+
+ path.node.specifiers = newSpecifiers;
+ }
+ });
+
+ // Add or update React Query imports
+ const existingReactQueryImport = root.find(j.ImportDeclaration, {
+ source: { value: '@tanstack/react-query' },
+ });
+
+ if (existingReactQueryImport.length > 0) {
+ const importDecl = existingReactQueryImport.at(0).get();
+ const existingImports = new Set(
+ importDecl.node.specifiers
+ .filter((spec) => j.ImportSpecifier.check(spec))
+ .map((spec) => spec.imported.name),
+ );
+
+ // Add missing imports
+ reactQueryImportsToAdd.forEach((hookName) => {
+ if (!existingImports.has(hookName)) {
+ importDecl.node.specifiers.push(
+ j.importSpecifier(j.identifier(hookName)),
+ );
+ }
+ });
+ } else {
+ // Create new React Query import
+ const imports = Array.from(reactQueryImportsToAdd).map((name) =>
+ j.importSpecifier(j.identifier(name)),
+ );
+
+ const newImport = j.importDeclaration(
+ imports,
+ j.literal('@tanstack/react-query'),
+ );
+
+ // Add after the last import
+ const lastImport = root.find(j.ImportDeclaration).at(-1);
+ if (lastImport.length > 0) {
+ lastImport.insertAfter(newImport);
+ } else {
+ // If no imports, add at the beginning
+ root.get().node.program.body.unshift(newImport);
+ }
+ }
+ }
+
+ return hasModifications
+ ? root.toSource({
+ quote: 'single',
+ trailingComma: true,
+ })
+ : fileInfo.source;
+};
+
+// Export helper for testing
+module.exports.parser = 'tsx';
diff --git a/codemod/run-migration.sh b/codemod/run-migration.sh
new file mode 100755
index 0000000..320b359
--- /dev/null
+++ b/codemod/run-migration.sh
@@ -0,0 +1,145 @@
+#!/bin/bash
+
+# React Query v0.2 Migration Script
+# This script helps run the jscodeshift codemod with the correct settings
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Default values
+DRY_RUN=true
+TARGET_PATH="src/"
+EXTENSIONS="ts,tsx"
+
+# Parse command line arguments
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --apply)
+ DRY_RUN=false
+ shift
+ ;;
+ --path)
+ TARGET_PATH="$2"
+ shift 2
+ ;;
+ --help)
+ echo "Usage: $0 [options]"
+ echo ""
+ echo "Options:"
+ echo " --apply Apply the transformation (default is dry-run)"
+ echo " --path PATH Target path to transform (default: src/)"
+ echo " --help Show this help message"
+ echo ""
+ echo "Examples:"
+ echo " $0 # Dry run on src/ directory"
+ echo " $0 --apply # Apply transformation to src/"
+ echo " $0 --path lib/ # Dry run on lib/ directory"
+ echo " $0 --apply --path components/ # Apply to components/"
+ exit 0
+ ;;
+ *)
+ echo -e "${RED}Unknown option: $1${NC}"
+ echo "Use --help for usage information"
+ exit 1
+ ;;
+ esac
+done
+
+# Check if jscodeshift is available
+if ! command -v jscodeshift &> /dev/null && ! command -v npx &> /dev/null; then
+ echo -e "${RED}Error: jscodeshift is not installed and npx is not available${NC}"
+ echo "Please install jscodeshift globally: npm install -g jscodeshift"
+ echo "Or ensure npx is available"
+ exit 1
+fi
+
+# Use jscodeshift if available, otherwise use npx
+JSCODESHIFT_CMD="jscodeshift"
+if ! command -v jscodeshift &> /dev/null; then
+ JSCODESHIFT_CMD="npx jscodeshift"
+fi
+
+# Get the directory of this script
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+TRANSFORM_PATH="$SCRIPT_DIR/react-query-v0.2-migration.js"
+
+# Check if transform file exists
+if [ ! -f "$TRANSFORM_PATH" ]; then
+ echo -e "${RED}Error: Transform file not found at $TRANSFORM_PATH${NC}"
+ exit 1
+fi
+
+# Check if target path exists
+if [ ! -e "$TARGET_PATH" ]; then
+ echo -e "${RED}Error: Target path does not exist: $TARGET_PATH${NC}"
+ exit 1
+fi
+
+echo -e "${GREEN}React Query v0.2 Migration Codemod${NC}"
+echo "====================================="
+echo ""
+
+if [ "$DRY_RUN" = true ]; then
+ echo -e "${YELLOW}Running in DRY RUN mode${NC}"
+ echo "No files will be modified. Use --apply to apply changes."
+else
+ echo -e "${RED}Running in APPLY mode${NC}"
+ echo "Files will be modified. Make sure you have committed your changes!"
+ echo ""
+ read -p "Are you sure you want to continue? (y/N) " -n 1 -r
+ echo ""
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ echo "Aborted."
+ exit 0
+ fi
+fi
+
+echo ""
+echo "Target path: $TARGET_PATH"
+echo "Extensions: $EXTENSIONS"
+echo "Transform: $TRANSFORM_PATH"
+echo ""
+
+# Build the command
+CMD="$JSCODESHIFT_CMD -t $TRANSFORM_PATH $TARGET_PATH --extensions=$EXTENSIONS --parser=tsx"
+
+if [ "$DRY_RUN" = true ]; then
+ CMD="$CMD --dry"
+fi
+
+# Show the command
+echo "Running command:"
+echo " $CMD"
+echo ""
+
+# Execute the transformation
+$CMD
+
+# Check the exit code
+if [ $? -eq 0 ]; then
+ echo ""
+ echo -e "${GREEN}✓ Codemod completed successfully${NC}"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo ""
+ echo "This was a dry run. To apply the changes, run:"
+ echo " $0 --apply"
+ else
+ echo ""
+ echo "Changes have been applied. Next steps:"
+ echo "1. Review the changes: git diff"
+ echo "2. Run your build: npm run build"
+ echo "3. Run your tests: npm test"
+ echo "4. Test your application"
+ fi
+else
+ echo ""
+ echo -e "${RED}✗ Codemod failed${NC}"
+ echo "Please check the errors above and try again."
+ exit 1
+fi
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index a6e008a..882052d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@basketry/react-query",
- "version": "0.0.0",
+ "version": "0.2.0-alpha.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@basketry/react-query",
- "version": "0.0.0",
+ "version": "0.2.0-alpha.2",
"license": "MIT",
"dependencies": {
"@basketry/typescript": "^0.1.2",
diff --git a/package.json b/package.json
index c8e904c..5108b61 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@basketry/react-query",
- "version": "0.0.0",
+ "version": "0.2.0-alpha.2",
"description": "Basketry generator for generating Typescript interfaces",
"main": "./lib/index.js",
"scripts": {
diff --git a/src/context-file.ts b/src/context-file.ts
index 6fb1b8a..6280150 100644
--- a/src/context-file.ts
+++ b/src/context-file.ts
@@ -1,8 +1,10 @@
import { camel, pascal } from 'case';
import { ModuleBuilder } from './module-builder';
import { ImportBuilder } from './import-builder';
+import { NameFactory } from './name-factory';
export class ContextFile extends ModuleBuilder {
+ private readonly nameFactory = new NameFactory(this.service, this.options);
private readonly react = new ImportBuilder('react');
private readonly client = new ImportBuilder(
this.options?.reactQuery?.clientModule ?? '../http-client',
@@ -23,23 +25,43 @@ export class ContextFile extends ModuleBuilder {
const FetchLike = () => this.client.type('FetchLike');
const OptionsType = () => this.client.type(optionsName);
- yield `export interface ClientContextProps { fetch: ${FetchLike()}; options: ${OptionsType()}; }`;
- yield `const ClientContext = ${createContext()}( undefined );`;
+ const contextName = this.nameFactory.buildContextName();
+ const contextPropsName = pascal(`${contextName}_props`);
+ const providerName = this.nameFactory.buildProviderName();
+
+ yield `export interface ${contextPropsName} { fetch: ${FetchLike()}; options: ${OptionsType()}; }`;
+ yield `const ${contextName} = ${createContext()}<${contextPropsName} | undefined>( undefined );`;
+ yield ``;
+ // Store context for non-hook access
+ yield `let currentContext: ${contextPropsName} | undefined;`;
yield ``;
- yield `export const ClientProvider: ${FC()}<${PropsWithChildren()}> = ({ children, fetch, options }) => {`;
+ yield `export const ${providerName}: ${FC()}<${PropsWithChildren()}<${contextPropsName}>> = ({ children, fetch, options }) => {`;
yield ` const value = ${useMemo()}(() => ({ fetch, options }), [fetch, options.mapUnhandledException, options.mapValidationError, options.root]);`;
- yield ` return {children} ;`;
+ yield ` currentContext = value;`;
+ yield ` return <${contextName}.Provider value={value}>{children}${contextName}.Provider>;`;
yield `};`;
for (const int of this.service.interfaces) {
- const hookName = camel(`use_${int.name.value}_service`);
- const localName = camel(`${int.name.value}_service`);
+ const hookName = this.nameFactory.buildServiceHookName(int);
+ const localName = this.nameFactory.buildServiceName(int);
const interfaceName = pascal(`${int.name.value}_service`);
const className = pascal(`http_${int.name.value}_service`);
+ const getterName = camel(`get_${int.name.value}_service`);
+
+ yield ``;
+ yield `export const ${getterName} = () => {`;
+ yield ` if (!currentContext) { throw new Error('${getterName} called outside of ${providerName}'); }`;
+ yield ` const ${localName}: ${this.types.type(
+ interfaceName,
+ )} = new ${this.client.fn(
+ className,
+ )}(currentContext.fetch, currentContext.options);`;
+ yield ` return ${localName};`;
+ yield `}`;
yield ``;
yield `export const ${hookName} = () => {`;
- yield ` const context = ${useContext()}(ClientContext);`;
- yield ` if (!context) { throw new Error('${hookName} must be used within a ClientProvider'); }`;
+ yield ` const context = ${useContext()}(${contextName});`;
+ yield ` if (!context) { throw new Error('${hookName} must be used within a ${providerName}'); }`;
yield ` const ${localName}: ${this.types.type(
interfaceName,
)} = new ${this.client.fn(className)}(context.fetch, context.options);`;
diff --git a/src/hook-file.test.ts b/src/hook-file.test.ts
new file mode 100644
index 0000000..0a33167
--- /dev/null
+++ b/src/hook-file.test.ts
@@ -0,0 +1,854 @@
+import { File, Service } from 'basketry';
+import { generateHooks } from './hook-generator';
+import { NamespacedReactQueryOptions } from './types';
+
+describe('HookFile', () => {
+ describe('Infinite Query Options', () => {
+ it('generates infinite query options for relay-paginated methods', async () => {
+ const service: Service = {
+ basketry: '1.1-rc',
+ kind: 'Service',
+ title: { value: 'TestService' },
+ majorVersion: { value: 1 },
+ sourcePath: 'test.json',
+ loc: 'test.json',
+ interfaces: [
+ {
+ kind: 'Interface',
+ name: { value: 'widget' },
+ methods: [
+ {
+ kind: 'Method',
+ name: { value: 'getWidgets' },
+ security: [],
+ parameters: [
+ {
+ kind: 'Parameter',
+ name: { value: 'first' },
+ typeName: { value: 'integer' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Parameter',
+ name: { value: 'after' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Parameter',
+ name: { value: 'last' },
+ typeName: { value: 'integer' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Parameter',
+ name: { value: 'before' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ returnType: {
+ kind: 'ReturnType',
+ typeName: { value: 'WidgetConnection' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ },
+ ],
+ protocols: {
+ http: [
+ {
+ kind: 'HttpPath',
+ path: { value: '/widgets' },
+ methods: [
+ {
+ kind: 'HttpMethod',
+ name: { value: 'getWidgets' },
+ verb: { value: 'get' },
+ parameters: [],
+ successCode: { value: 200 },
+ requestMediaTypes: [],
+ responseMediaTypes: [],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ types: [
+ {
+ kind: 'Type',
+ name: { value: 'WidgetConnection' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'pageInfo' },
+ typeName: { value: 'PageInfo' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Property',
+ name: { value: 'data' },
+ typeName: { value: 'Widget' },
+ isPrimitive: false,
+ isArray: true,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ {
+ kind: 'Type',
+ name: { value: 'PageInfo' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'startCursor' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Property',
+ name: { value: 'endCursor' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Property',
+ name: { value: 'hasNextPage' },
+ typeName: { value: 'boolean' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Property',
+ name: { value: 'hasPreviousPage' },
+ typeName: { value: 'boolean' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ {
+ kind: 'Type',
+ name: { value: 'Widget' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'id' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Property',
+ name: { value: 'name' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ ],
+ enums: [],
+ unions: [],
+ meta: [],
+ };
+
+ const options: NamespacedReactQueryOptions = {
+ reactQuery: {
+ typesModule: '../types',
+ clientModule: '../http-client',
+ },
+ };
+
+ const files: File[] = [];
+ for await (const file of generateHooks(service, options)) {
+ files.push(file);
+ }
+
+ const widgetsFile = files.find(
+ (f) => f.path[f.path.length - 1] === 'widgets.ts',
+ );
+ expect(widgetsFile).toBeDefined();
+
+ const content = widgetsFile!.contents;
+
+ // Check that infinite query options are generated with full method names
+ expect(content).toContain('export const getWidgetsInfiniteQueryOptions');
+
+ // Verify the query key includes the infinite flag
+ expect(content).toMatch(
+ /queryKey:\s*\['widget',\s*'getWidgets',[^,]+,\s*\{\s*infinite:\s*true\s*\}/,
+ );
+
+ // Check that regular query options are also generated
+ expect(content).toContain('export const getWidgetsQueryOptions');
+
+ // Verify relay pagination utilities are used
+ expect(content).toContain('getNextPageParam');
+ expect(content).toContain('getPreviousPageParam');
+ expect(content).toContain('getInitialPageParam');
+ expect(content).toContain('applyPageParam');
+ });
+
+ it('does not generate infinite query options for non-relay-paginated methods', async () => {
+ const service: Service = {
+ basketry: '1.1-rc',
+ kind: 'Service',
+ title: { value: 'TestService' },
+ majorVersion: { value: 1 },
+ sourcePath: 'test.json',
+ loc: 'test.json',
+ interfaces: [
+ {
+ kind: 'Interface',
+ name: { value: 'widget' },
+ methods: [
+ {
+ kind: 'Method',
+ name: { value: 'getWidget' },
+ security: [],
+ parameters: [
+ {
+ kind: 'Parameter',
+ name: { value: 'id' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ returnType: {
+ kind: 'ReturnType',
+ typeName: { value: 'WidgetResponse' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ },
+ ],
+ protocols: {
+ http: [
+ {
+ kind: 'HttpPath',
+ path: { value: '/widgets/{id}' },
+ methods: [
+ {
+ kind: 'HttpMethod',
+ name: { value: 'getWidget' },
+ verb: { value: 'get' },
+ parameters: [],
+ successCode: { value: 200 },
+ requestMediaTypes: [],
+ responseMediaTypes: [],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ types: [
+ {
+ kind: 'Type',
+ name: { value: 'WidgetResponse' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'data' },
+ typeName: { value: 'Widget' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ {
+ kind: 'Type',
+ name: { value: 'Widget' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'id' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ ],
+ enums: [],
+ unions: [],
+ meta: [],
+ };
+
+ const options: NamespacedReactQueryOptions = {};
+
+ const files: File[] = [];
+ for await (const file of generateHooks(service, options)) {
+ files.push(file);
+ }
+
+ const widgetsFile = files.find(
+ (f) => f.path[f.path.length - 1] === 'widgets.ts',
+ );
+ expect(widgetsFile).toBeDefined();
+
+ const content = widgetsFile!.contents;
+
+ expect(content).toContain('export const getWidgetQueryOptions');
+ expect(content).not.toContain('InfiniteQueryOptions');
+ expect(content).not.toContain('infiniteQueryOptions');
+ });
+ });
+
+ describe('Deprecated Hook Generation', () => {
+ it('generates deprecated query hooks with proper deprecation messages', async () => {
+ const service: Service = {
+ basketry: '1.1-rc',
+ kind: 'Service',
+ title: { value: 'TestService' },
+ majorVersion: { value: 1 },
+ sourcePath: 'test.json',
+ loc: 'test.json',
+ interfaces: [
+ {
+ kind: 'Interface',
+ name: { value: 'widget' },
+ methods: [
+ {
+ kind: 'Method',
+ name: { value: 'getWidget' },
+ security: [],
+ parameters: [
+ {
+ kind: 'Parameter',
+ name: { value: 'id' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ returnType: {
+ kind: 'ReturnType',
+ typeName: { value: 'Widget' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ },
+ ],
+ protocols: {
+ http: [
+ {
+ kind: 'HttpPath',
+ path: { value: '/widgets/{id}' },
+ methods: [
+ {
+ kind: 'HttpMethod',
+ name: { value: 'getWidget' },
+ verb: { value: 'get' },
+ parameters: [],
+ successCode: { value: 200 },
+ requestMediaTypes: [],
+ responseMediaTypes: [],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ types: [
+ {
+ kind: 'Type',
+ name: { value: 'Widget' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'id' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ ],
+ enums: [],
+ unions: [],
+ meta: [],
+ };
+
+ const options: NamespacedReactQueryOptions = {};
+
+ const files: File[] = [];
+ for await (const file of generateHooks(service, options)) {
+ files.push(file);
+ }
+
+ const widgetsFile = files.find(
+ (f) => f.path[f.path.length - 1] === 'widgets.ts',
+ );
+ expect(widgetsFile).toBeDefined();
+
+ const content = widgetsFile!.contents;
+
+ // Check that deprecated hooks are generated
+ expect(content).toContain('export const useWidget');
+ expect(content).toContain('export const useSuspenseWidget');
+
+ // Check for deprecation messages
+ expect(content).toContain('@deprecated');
+ expect(content).toContain(
+ 'This hook is deprecated and will be removed in a future version',
+ );
+ expect(content).toContain('// Old pattern (deprecated)');
+ expect(content).toContain('// New pattern');
+ expect(content).toContain('const result = useWidget');
+ expect(content).toContain(
+ 'const result = useQuery(getWidgetQueryOptions',
+ );
+
+ // Check that hooks use the query options
+ expect(content).toMatch(/useWidget[^}]+useQuery\(getWidgetQueryOptions/s);
+ expect(content).toMatch(
+ /useSuspenseWidget[^}]+useSuspenseQuery\(getWidgetQueryOptions/s,
+ );
+ });
+
+ it('generates deprecated mutation hooks with query invalidation', async () => {
+ const service: Service = {
+ basketry: '1.1-rc',
+ kind: 'Service',
+ title: { value: 'TestService' },
+ majorVersion: { value: 1 },
+ sourcePath: 'test.json',
+ loc: 'test.json',
+ interfaces: [
+ {
+ kind: 'Interface',
+ name: { value: 'widget' },
+ methods: [
+ {
+ kind: 'Method',
+ name: { value: 'createWidget' },
+ security: [],
+ parameters: [
+ {
+ kind: 'Parameter',
+ name: { value: 'widget' },
+ typeName: { value: 'CreateWidgetInput' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ returnType: {
+ kind: 'ReturnType',
+ typeName: { value: 'Widget' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ },
+ ],
+ protocols: {
+ http: [
+ {
+ kind: 'HttpPath',
+ path: { value: '/widgets' },
+ methods: [
+ {
+ kind: 'HttpMethod',
+ name: { value: 'createWidget' },
+ verb: { value: 'post' },
+ parameters: [],
+ successCode: { value: 201 },
+ requestMediaTypes: [],
+ responseMediaTypes: [],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ types: [
+ {
+ kind: 'Type',
+ name: { value: 'CreateWidgetInput' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'name' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ {
+ kind: 'Type',
+ name: { value: 'Widget' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'id' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ ],
+ enums: [],
+ unions: [],
+ meta: [],
+ };
+
+ const options: NamespacedReactQueryOptions = {};
+
+ const files: File[] = [];
+ for await (const file of generateHooks(service, options)) {
+ files.push(file);
+ }
+
+ const widgetsFile = files.find(
+ (f) => f.path[f.path.length - 1] === 'widgets.ts',
+ );
+ expect(widgetsFile).toBeDefined();
+
+ const content = widgetsFile!.contents;
+
+ // Check that deprecated mutation hook is generated
+ expect(content).toContain('export const useCreateWidget');
+
+ // Check for deprecation message
+ expect(content).toContain('@deprecated');
+ expect(content).toContain('mutation hook is deprecated');
+
+ // Check that hook uses useQueryClient for invalidation
+ expect(content).toContain('const queryClient = useQueryClient()');
+ expect(content).toContain('useMutation({');
+ expect(content).toContain('...mutationOptions');
+ expect(content).toContain('onSuccess:');
+ expect(content).toContain(
+ "queryClient.invalidateQueries({ queryKey: ['widget'] })",
+ );
+
+ // Check that it preserves existing onSuccess
+ expect(content).toContain(
+ 'mutationOptions.onSuccess?.(data, variables, context)',
+ );
+ });
+
+ it('generates deprecated infinite query hooks for paginated endpoints', async () => {
+ const service: Service = {
+ basketry: '1.1-rc',
+ kind: 'Service',
+ title: { value: 'TestService' },
+ majorVersion: { value: 1 },
+ sourcePath: 'test.json',
+ loc: 'test.json',
+ interfaces: [
+ {
+ kind: 'Interface',
+ name: { value: 'widget' },
+ methods: [
+ {
+ kind: 'Method',
+ name: { value: 'getWidgets' },
+ security: [],
+ parameters: [
+ {
+ kind: 'Parameter',
+ name: { value: 'first' },
+ typeName: { value: 'integer' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Parameter',
+ name: { value: 'after' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Parameter',
+ name: { value: 'last' },
+ typeName: { value: 'integer' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Parameter',
+ name: { value: 'before' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ returnType: {
+ kind: 'ReturnType',
+ typeName: { value: 'WidgetConnection' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ },
+ ],
+ protocols: {
+ http: [
+ {
+ kind: 'HttpPath',
+ path: { value: '/widgets' },
+ methods: [
+ {
+ kind: 'HttpMethod',
+ name: { value: 'getWidgets' },
+ verb: { value: 'get' },
+ parameters: [],
+ successCode: { value: 200 },
+ requestMediaTypes: [],
+ responseMediaTypes: [],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ types: [
+ {
+ kind: 'Type',
+ name: { value: 'WidgetConnection' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'pageInfo' },
+ typeName: { value: 'PageInfo' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Property',
+ name: { value: 'data' },
+ typeName: { value: 'Widget' },
+ isPrimitive: false,
+ isArray: true,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ {
+ kind: 'Type',
+ name: { value: 'PageInfo' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'endCursor' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ {
+ kind: 'Property',
+ name: { value: 'hasNextPage' },
+ typeName: { value: 'boolean' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ {
+ kind: 'Type',
+ name: { value: 'Widget' },
+ properties: [
+ {
+ kind: 'Property',
+ name: { value: 'id' },
+ typeName: { value: 'string' },
+ isPrimitive: true,
+ isArray: false,
+ rules: [],
+ },
+ ],
+ rules: [],
+ },
+ ],
+ enums: [],
+ unions: [],
+ meta: [],
+ };
+
+ const options: NamespacedReactQueryOptions = {};
+
+ const files: File[] = [];
+ for await (const file of generateHooks(service, options)) {
+ files.push(file);
+ }
+
+ const widgetsFile = files.find(
+ (f) => f.path[f.path.length - 1] === 'widgets.ts',
+ );
+ expect(widgetsFile).toBeDefined();
+
+ const content = widgetsFile!.contents;
+
+ // Check that deprecated infinite hooks are generated
+ expect(content).toContain('export const useWidgetsInfinite');
+ expect(content).toContain('export const useSuspenseWidgetsInfinite');
+
+ // Check for deprecation messages
+ expect(content).toContain('@deprecated');
+ expect(content).toContain('infinite query hook is deprecated');
+
+ // Check that hooks use the infinite query options
+ expect(content).toMatch(
+ /useWidgetsInfinite[^}]+useInfiniteQuery\(getWidgetsInfiniteQueryOptions/s,
+ );
+ expect(content).toMatch(
+ /useSuspenseWidgetsInfinite[^}]+useSuspenseInfiniteQuery\(getWidgetsInfiniteQueryOptions/s,
+ );
+ });
+
+ it('verifies deprecation message format is consistent', async () => {
+ const service: Service = {
+ basketry: '1.1-rc',
+ kind: 'Service',
+ title: { value: 'TestService' },
+ majorVersion: { value: 1 },
+ sourcePath: 'test.json',
+ loc: 'test.json',
+ interfaces: [
+ {
+ kind: 'Interface',
+ name: { value: 'widget' },
+ methods: [
+ {
+ kind: 'Method',
+ name: { value: 'getWidget' },
+ security: [],
+ parameters: [],
+ returnType: {
+ kind: 'ReturnType',
+ typeName: { value: 'Widget' },
+ isPrimitive: false,
+ isArray: false,
+ rules: [],
+ },
+ },
+ ],
+ protocols: {
+ http: [
+ {
+ kind: 'HttpPath',
+ path: { value: '/widgets/{id}' },
+ methods: [
+ {
+ kind: 'HttpMethod',
+ name: { value: 'getWidget' },
+ verb: { value: 'get' },
+ parameters: [],
+ successCode: { value: 200 },
+ requestMediaTypes: [],
+ responseMediaTypes: [],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ types: [
+ {
+ kind: 'Type',
+ name: { value: 'Widget' },
+ properties: [],
+ rules: [],
+ },
+ ],
+ enums: [],
+ unions: [],
+ meta: [],
+ };
+
+ const options: NamespacedReactQueryOptions = {};
+
+ const files: File[] = [];
+ for await (const file of generateHooks(service, options)) {
+ files.push(file);
+ }
+
+ const widgetsFile = files.find(
+ (f) => f.path[f.path.length - 1] === 'widgets.ts',
+ );
+ const content = widgetsFile!.contents;
+
+ // Check deprecation message includes proper imports
+ expect(content).toMatch(
+ /import \{ useQuery \} from '@tanstack\/react-query'/,
+ );
+ expect(content).toMatch(
+ /import \{ getWidgetQueryOptions \} from '\.\/hooks\/widgets'/,
+ );
+
+ // Check code blocks are properly formatted
+ expect(content).toContain('```typescript');
+ expect(content).toContain('```');
+
+ // Verify migration example structure
+ const deprecationBlocks = content.match(/\/\*\*[\s\S]*?\*\//g) || [];
+ const queryDeprecation = deprecationBlocks.find(
+ (block) => block.includes('useWidget') && !block.includes('Suspense'),
+ );
+
+ expect(queryDeprecation).toBeDefined();
+ expect(queryDeprecation).toContain('Old pattern (deprecated)');
+ expect(queryDeprecation).toContain('New pattern');
+ });
+ });
+});
diff --git a/src/hook-file.ts b/src/hook-file.ts
index ad199a3..4f00468 100644
--- a/src/hook-file.ts
+++ b/src/hook-file.ts
@@ -21,7 +21,7 @@ import { camel } from 'case';
import { NamespacedReactQueryOptions } from './types';
import { ModuleBuilder } from './module-builder';
import { ImportBuilder } from './import-builder';
-import { getQueryOptionsName } from './name-factory';
+import { NameFactory } from './name-factory';
export class HookFile extends ModuleBuilder {
constructor(
@@ -31,6 +31,7 @@ export class HookFile extends ModuleBuilder {
) {
super(service, options);
}
+ private readonly nameFactory = new NameFactory(this.service, this.options);
private readonly tanstack = new ImportBuilder('@tanstack/react-query');
private readonly runtime = new ImportBuilder('./runtime');
private readonly context = new ImportBuilder('./context');
@@ -72,9 +73,6 @@ export class HookFile extends ModuleBuilder {
for (const method of [...this.int.methods].sort((a, b) =>
this.getHookName(a).localeCompare(this.getHookName(b)),
)) {
- const name = this.getHookName(method);
- const suspenseName = this.getHookName(method, { suspense: true });
- const infiniteName = this.getHookName(method, { infinite: true });
const paramsType = from(buildParamsType(method));
const httpMethod = getHttpMethodByName(this.service, method.name.value);
const httpPath = this.getHttpPath(httpMethod);
@@ -88,12 +86,16 @@ export class HookFile extends ModuleBuilder {
const isGet = httpMethod?.verb.value === 'get' && !!httpPath;
+ // Generate new query options exports (v0.2.0 feature)
if (isGet) {
yield* this.generateQueryOptions(method, httpPath);
}
+ // Generate original hooks with options parameters (v0.1.0 compatibility)
if (isGet) {
- const queryOptionsName = getQueryOptionsName(method);
+ const name = this.getHookName(method);
+ const suspenseName = this.getHookName(method, { suspense: true });
+ const queryOptionsName = this.nameFactory.buildQueryOptionsName(method);
const paramsCallsite = method.parameters.length ? 'params' : '';
const returnType = getTypeByName(
@@ -134,34 +136,68 @@ export class HookFile extends ModuleBuilder {
dataTypeName,
)} | undefined, (${queryParamsType})[]>,'queryKey' | 'queryFn' | 'select'>`;
- yield* buildDescription(
- method.description,
- undefined,
- method.deprecated?.value,
+ // Generate the regular hook
+ yield '';
+ yield* this.buildDeprecationMessage(
+ 'query',
+ method.name.value,
+ name,
+ camel(this.int.name.value),
);
- yield `export function ${name}(${[
+ yield `export const ${name} = (${[
paramsExpression,
optionsExpression,
- ].filter(Boolean)}) {`;
+ ].filter(Boolean).join(', ')}) => {`;
yield ` const defaultOptions = ${queryOptionsName}(${paramsCallsite});`;
yield ` return ${useQuery()}({...defaultOptions, ...options});`;
- yield `}`;
+ yield `};`;
+
+ // Generate the suspense hook
yield '';
- yield* buildDescription(
- method.description,
- undefined,
- method.deprecated?.value,
+ yield* this.buildDeprecationMessage(
+ 'suspenseQuery',
+ method.name.value,
+ suspenseName,
+ camel(this.int.name.value),
);
- yield `export function ${suspenseName}(${[
+ yield `export const ${suspenseName} = (${[
paramsExpression,
optionsExpression,
- ].filter(Boolean)}) {`;
+ ].filter(Boolean).join(', ')}) => {`;
yield ` const defaultOptions = ${queryOptionsName}(${paramsCallsite});`;
yield ` return ${useSuspenseQuery()}({...defaultOptions, ...options});`;
- yield `}`;
+ yield `};`;
} else if (httpPath) {
+ const mutationOptions = () => this.tanstack.fn('mutationOptions');
const paramsCallsite = method.parameters.length ? 'params' : '';
+ const serviceGetterName = camel(`get_${this.int.name.value}_service`);
+ const mutationOptionsName = camel(
+ `${method.name.value}_mutation_options`,
+ );
+
+ yield* buildDescription(
+ method.description,
+ undefined,
+ method.deprecated?.value,
+ );
+ yield `export const ${mutationOptionsName} = () => {`;
+ yield ` const ${serviceName} = ${this.context.fn(
+ serviceGetterName,
+ )}()`;
+ yield ` return ${mutationOptions()}({`;
+ yield ` mutationFn: async (${paramsExpression}) => {`;
+ yield ` const res = await ${serviceName}.${camel(
+ method.name.value,
+ )}(${paramsCallsite});`;
+ yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`;
+ yield ` else if (!res.data) { throw new Error('Unexpected data error: Failed to get example'); }`;
+ yield ` return res.data;`;
+ yield ` },`;
+ yield ` });`;
+ yield `}`;
+ // Generate original mutation hook with options parameter
+ const hookName = this.getHookName(method);
const returnType = getTypeByName(
this.service,
method.returnType?.typeName.value,
@@ -178,55 +214,81 @@ export class HookFile extends ModuleBuilder {
typeName,
)}, Error, ${type(paramsType)}, unknown>, 'mutationFn'>`;
- yield* buildDescription(
- method.description,
- undefined,
- method.deprecated?.value,
+ yield '';
+ yield* this.buildDeprecationMessage(
+ 'mutation',
+ method.name.value,
+ hookName,
+ camel(this.int.name.value),
);
- yield `export function ${name}(${optionsExpression}) {`;
+ yield `export const ${hookName} = (${optionsExpression}) => {`;
yield ` const queryClient = ${useQueryClient()}();`;
- yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}()`;
+ yield ` const mutationOptions = ${mutationOptionsName}();`;
yield ` return ${useMutation()}({`;
- yield ` mutationFn: async (${paramsExpression}) => {`;
- yield ` const res = await ${serviceName}.${camel(
- method.name.value,
- )}(${paramsCallsite});`;
- yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`;
- yield ` else if (!res.data) { throw new Error('Unexpected data error: Failed to get example'); }`;
-
- const queryKeys = new Set();
- queryKeys.add(this.buildResourceKey(httpPath, method)); // Invalidate this resource
- queryKeys.add(
- this.buildResourceKey(httpPath, method, { skipTerminalParams: true }), // Invalidate the parent resource group
- );
-
- for (const queryKey of Array.from(queryKeys)) {
- yield ` queryClient.invalidateQueries({ queryKey: [${queryKey}] });`;
- }
- yield ` return res.data;`;
+ yield ` ...mutationOptions,`;
+ yield ` onSuccess: (data, variables, context) => {`;
+ yield ` queryClient.invalidateQueries({ queryKey: ['${this.int.name.value}'] });`;
+ yield ` mutationOptions.onSuccess?.(data, variables, context);`;
yield ` },`;
yield ` ...options,`;
yield ` });`;
- yield `}`;
+ yield `};`;
}
if (isGet && this.isRelayPaginated(method)) {
+ const infiniteQueryOptions = () =>
+ this.tanstack.fn('infiniteQueryOptions');
const methodExpression = `${serviceName}.${camel(method.name.value)}`;
const paramsCallsite = method.parameters.length
? `${applyPageParam()}(params${q ? '?? {}' : ''}, pageParam)`
: '';
+ const infiniteName = this.getHookName(method, { infinite: true });
+ const serviceGetterName = camel(`get_${this.int.name.value}_service`);
+
+ // Export the infinite query options (v0.2.0 feature)
+ const infiniteOptionsName = camel(
+ `${method.name.value}_infinite_query_options`,
+ );
+
+ yield* buildDescription(
+ method.description,
+ undefined,
+ method.deprecated?.value,
+ );
+ yield `export const ${infiniteOptionsName} = (${paramsExpression}) => {`;
+ yield ` const ${serviceName} = ${this.context.fn(
+ serviceGetterName,
+ )}();`;
+ yield ` return ${infiniteQueryOptions()}({`;
+ yield ` queryKey: ['${this.int.name.value}', '${method.name.value}', ${
+ method.parameters.length ? 'params || {}' : '{}'
+ }, {infinite: true}] as const,`;
+ yield ` queryFn: async ({ pageParam }: ${PageParam()}) => {`;
+ yield ` const res = await ${methodExpression}(${paramsCallsite});`;
+ yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`;
+ yield ` return res;`;
+ yield ` },`;
+ yield* this.buildInfiniteSelectFn(method);
+ yield ` initialPageParam: ${getInitialPageParam()}(params${
+ q ? '?? {}' : ''
+ }),`;
+ yield ` ${getNextPageParam()},`;
+ yield ` ${getPreviousPageParam()},`;
+ yield ` });`;
+ yield `}`;
+ // Generate private infinite options hook for backward compatibility
const infiniteOptionsHook = camel(
`${this.getHookName(method, { infinite: true })}_query_options`,
);
+ yield '';
yield `function ${infiniteOptionsHook}(${paramsExpression}) {`;
yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}();`;
yield ` return {`;
- yield ` queryKey: ${this.buildQueryKey(httpPath, method, {
- includeRelayParams: false,
- infinite: true,
- })},`;
+ yield ` queryKey: ['${this.int.name.value}', '${method.name.value}', ${
+ method.parameters.length ? 'params || {}' : '{}'
+ }, {infinite: true}] as const,`;
yield ` queryFn: async ({ pageParam }: ${PageParam()}) => {`;
yield ` const res = await ${methodExpression}(${paramsCallsite});`;
yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`;
@@ -241,28 +303,33 @@ export class HookFile extends ModuleBuilder {
yield ` };`;
yield `}`;
- yield* buildDescription(
- method.description,
- undefined,
- method.deprecated?.value,
+ // Generate deprecated infinite query hook
+ yield '';
+ yield* this.buildDeprecationMessage(
+ 'infinite',
+ method.name.value,
+ infiniteName,
+ camel(this.int.name.value),
);
- yield `export const ${this.getHookName(method, {
- suspense: false,
- infinite: true,
- })} = (${paramsExpression}) => {`;
+ yield `export const ${infiniteName} = (${paramsExpression}) => {`;
yield ` const options = ${infiniteOptionsHook}(params);`;
yield ` return ${useInfiniteQuery()}(options);`;
yield `}`;
- yield* buildDescription(
- method.description,
- undefined,
- method.deprecated?.value,
- );
- yield `export const ${this.getHookName(method, {
+ // Generate deprecated suspense infinite query hook
+ const suspenseInfiniteName = this.getHookName(method, {
suspense: true,
infinite: true,
- })} = (${paramsExpression}) => {`;
+ });
+
+ yield '';
+ yield* this.buildDeprecationMessage(
+ 'suspenseInfinite',
+ method.name.value,
+ suspenseInfiniteName,
+ camel(this.int.name.value),
+ );
+ yield `export const ${suspenseInfiniteName} = (${paramsExpression}) => {`;
yield ` const options = ${infiniteOptionsHook}(params);`;
yield ` return ${useSuspenseInfiniteQuery()}(options);`;
yield `}`;
@@ -301,10 +368,11 @@ export class HookFile extends ModuleBuilder {
const queryOptions = () => this.tanstack.fn('queryOptions');
const CompositeError = () => this.runtime.fn('CompositeError');
const type = (t: string) => this.types.type(t);
+ const httpMethod = getHttpMethodByName(this.service, method.name.value);
const serviceName = camel(`${this.int.name.value}_service`);
- const serviceHookName = camel(`use_${this.int.name.value}_service`);
- const name = getQueryOptionsName(method);
+ const serviceGetterName = camel(`get_${this.int.name.value}_service`);
+ const name = this.nameFactory.buildQueryOptionsName(method);
const paramsType = from(buildParamsType(method));
const q = method.parameters.every((param) => !isRequired(param)) ? '?' : '';
const paramsExpression = method.parameters.length
@@ -323,12 +391,17 @@ export class HookFile extends ModuleBuilder {
(prop) => prop.name.value !== 'data' && prop.name.value !== 'errors',
);
- yield `const ${name} = (${paramsExpression}) => {`;
- yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}()`;
+ yield* buildDescription(
+ method.description,
+ undefined,
+ method.deprecated?.value,
+ );
+ yield `export const ${name} = (${paramsExpression}) => {`;
+ yield ` const ${serviceName} = ${this.context.fn(serviceGetterName)}()`;
yield ` return ${queryOptions()}({`;
- yield ` queryKey: ${this.buildQueryKey(httpPath, method, {
- includeRelayParams: true,
- })},`;
+ yield ` queryKey: ['${this.int.name.value}', '${method.name.value}', ${
+ method.parameters.length ? 'params || {}' : '{}'
+ }] as const,`;
yield ` queryFn: async () => {`;
yield ` const res = await ${serviceName}.${camel(
method.name.value,
@@ -344,27 +417,6 @@ export class HookFile extends ModuleBuilder {
yield `};`;
}
- private getHookName(
- method: Method,
- options?: { infinite?: boolean; suspense?: boolean },
- ): string {
- const name = method.name.value;
- const httpMethod = getHttpMethodByName(this.service, name);
-
- if (
- httpMethod?.verb.value === 'get' &&
- name.toLocaleLowerCase().startsWith('get')
- ) {
- return camel(
- `use_${options?.suspense ? 'suspense_' : ''}${
- options?.infinite ? 'infinite_' : ''
- }${name.slice(3)}`,
- );
- }
-
- return camel(`use_${name}`);
- }
-
private getHttpPath(
httpMethod: HttpMethod | undefined,
): HttpPath | undefined {
@@ -389,7 +441,6 @@ export class HookFile extends ModuleBuilder {
options?: { includeRelayParams?: boolean; infinite?: boolean },
): string {
const compact = () => this.runtime.fn('compact');
-
const resourceKey = this.buildResourceKey(httpPath, method);
const q = method.parameters.every((param) => !isRequired(param)) ? '?' : '';
@@ -416,7 +467,7 @@ export class HookFile extends ModuleBuilder {
}
if (options?.infinite) {
- queryKey.push('{inifinite: true}');
+ queryKey.push('{infinite: true}');
}
return `[${queryKey.join(', ')}]${
@@ -460,7 +511,6 @@ export class HookFile extends ModuleBuilder {
);
if (!returnType) return false;
- // TODO: Check if the return type has a `pageInfo` property
if (
!returnType.properties.some(
(prop) => camel(prop.name.value) === 'pageInfo',
@@ -515,6 +565,147 @@ export class HookFile extends ModuleBuilder {
return true;
}
+
+ // Private method to generate hook names with same logic as v0.1.0
+ private getHookName(
+ method: Method,
+ options?: { infinite?: boolean; suspense?: boolean },
+ ): string {
+ const name = method.name.value;
+ const httpMethod = getHttpMethodByName(this.service, name);
+
+ if (
+ httpMethod?.verb.value === 'get' &&
+ name.toLocaleLowerCase().startsWith('get')
+ ) {
+ return camel(
+ `use_${options?.suspense ? 'suspense_' : ''}${name.slice(3)}${
+ options?.infinite ? '_infinite' : ''
+ }`,
+ );
+ }
+
+ return camel(`use_${name}`);
+ }
+
+ private buildDeprecationMessage(
+ hookType:
+ | 'query'
+ | 'suspenseQuery'
+ | 'mutation'
+ | 'infinite'
+ | 'suspenseInfinite',
+ methodName: string,
+ hookName: string,
+ fileName: string,
+ ): string[] {
+ const pluralize = require('pluralize');
+ const pluralFileName = pluralize(fileName);
+ const lines: string[] = [];
+ lines.push('/**');
+
+ // Use appropriate deprecation message based on hook type
+ if (hookType === 'mutation') {
+ lines.push(
+ ' * @deprecated This mutation hook is deprecated and will be removed in a future version.',
+ );
+ } else if (hookType === 'infinite' || hookType === 'suspenseInfinite') {
+ lines.push(
+ ' * @deprecated This infinite query hook is deprecated and will be removed in a future version.',
+ );
+ } else {
+ lines.push(
+ ' * @deprecated This hook is deprecated and will be removed in a future version.',
+ );
+ }
+
+ lines.push(' * Please use the new query options pattern instead:');
+ lines.push(' * ');
+ lines.push(' * ```typescript');
+
+ switch (hookType) {
+ case 'query':
+ lines.push(" * import { useQuery } from '@tanstack/react-query';");
+ lines.push(
+ ` * import { ${methodName}QueryOptions } from './hooks/${pluralFileName}';`,
+ );
+ lines.push(' * ');
+ lines.push(' * // Old pattern (deprecated)');
+ lines.push(` * const result = ${hookName}(params);`);
+ lines.push(' * ');
+ lines.push(' * // New pattern');
+ lines.push(
+ ` * const result = useQuery(${methodName}QueryOptions(params));`,
+ );
+ break;
+ case 'suspenseQuery':
+ lines.push(
+ " * import { useSuspenseQuery } from '@tanstack/react-query';",
+ );
+ lines.push(
+ ` * import { ${methodName}QueryOptions } from './hooks/${pluralFileName}';`,
+ );
+ lines.push(' * ');
+ lines.push(' * // Old pattern (deprecated)');
+ lines.push(` * const result = ${hookName}(params);`);
+ lines.push(' * ');
+ lines.push(' * // New pattern');
+ lines.push(
+ ` * const result = useSuspenseQuery(${methodName}QueryOptions(params));`,
+ );
+ break;
+ case 'mutation':
+ lines.push(" * import { useMutation } from '@tanstack/react-query';");
+ lines.push(
+ ` * import { ${methodName}MutationOptions } from './hooks/${pluralFileName}';`,
+ );
+ lines.push(' * ');
+ lines.push(' * // Old pattern (deprecated)');
+ lines.push(` * const mutation = ${hookName}();`);
+ lines.push(' * ');
+ lines.push(' * // New pattern');
+ lines.push(
+ ` * const mutation = useMutation(${methodName}MutationOptions());`,
+ );
+ break;
+ case 'infinite':
+ lines.push(
+ " * import { useInfiniteQuery } from '@tanstack/react-query';",
+ );
+ lines.push(
+ ` * import { ${methodName}InfiniteQueryOptions } from './hooks/${pluralFileName}';`,
+ );
+ lines.push(' * ');
+ lines.push(' * // Old pattern (deprecated)');
+ lines.push(` * const result = ${hookName}(params);`);
+ lines.push(' * ');
+ lines.push(' * // New pattern');
+ lines.push(
+ ` * const result = useInfiniteQuery(${methodName}InfiniteQueryOptions(params));`,
+ );
+ break;
+ case 'suspenseInfinite':
+ lines.push(
+ " * import { useSuspenseInfiniteQuery } from '@tanstack/react-query';",
+ );
+ lines.push(
+ ` * import { ${methodName}InfiniteQueryOptions } from './hooks/${pluralFileName}';`,
+ );
+ lines.push(' * ');
+ lines.push(' * // Old pattern (deprecated)');
+ lines.push(` * const result = ${hookName}(params);`);
+ lines.push(' * ');
+ lines.push(' * // New pattern');
+ lines.push(
+ ` * const result = useSuspenseInfiniteQuery(${methodName}InfiniteQueryOptions(params));`,
+ );
+ break;
+ }
+
+ lines.push(' * ```');
+ lines.push(' */');
+ return lines;
+ }
}
function isPathParam(part: string): boolean {
diff --git a/src/hook-generator.test.ts b/src/hook-generator.test.ts
index 1c518bb..9e0bf31 100644
--- a/src/hook-generator.test.ts
+++ b/src/hook-generator.test.ts
@@ -2,7 +2,7 @@ import { readFileSync } from 'fs';
import { join } from 'path';
import { generateFiles } from './snapshot/test-utils';
-describe.skip('HookGenerator', () => {
+describe('HookGenerator', () => {
it('recreates a valid snapshot using the Engine', async () => {
for await (const file of generateFiles()) {
const snapshot = readFileSync(join(...file.path)).toString();
diff --git a/src/hook-generator.ts b/src/hook-generator.ts
index 189ba58..e74e81a 100644
--- a/src/hook-generator.ts
+++ b/src/hook-generator.ts
@@ -10,6 +10,7 @@ import { NamespacedReactQueryOptions } from './types';
import { HookFile } from './hook-file';
import { ContextFile } from './context-file';
import { RuntimeFile } from './runtime-file';
+import { QueryKeyBuilderFile } from './query-key-builder';
export const generateHooks: Generator = (service, options) => {
return new HookGenerator(service, options).generate();
@@ -40,6 +41,18 @@ class HookGenerator {
),
});
+ files.push({
+ path: buildFilePath(
+ ['hooks', 'query-key-builder.ts'],
+ this.service,
+ this.options,
+ ),
+ contents: format(
+ from(new QueryKeyBuilderFile(this.service, this.options).build()),
+ this.options,
+ ),
+ });
+
for (const int of this.service.interfaces) {
const contents = format(
from(new HookFile(this.service, this.options, int).build()),
diff --git a/src/name-factory.ts b/src/name-factory.ts
index 465525d..653226a 100644
--- a/src/name-factory.ts
+++ b/src/name-factory.ts
@@ -1,6 +1,74 @@
-import { Method } from 'basketry';
-import { camel } from 'case';
+import { Interface, Method, Service } from 'basketry';
+import { camel, pascal } from 'case';
+import { NamespacedReactQueryOptions } from './types';
-export function getQueryOptionsName(method: Method): string {
- return camel(`use_${method.name.value}_query_options`);
+export class NameFactory {
+ constructor(
+ private readonly service: Service,
+ private readonly options?: NamespacedReactQueryOptions,
+ ) {}
+
+ buildContextName(): string {
+ return pascal(`${this.service.title.value}_context`);
+ }
+
+ buildProviderName(): string {
+ return pascal(`${this.service.title.value}_provider`);
+ }
+
+ buildQueryOptionsName(method: Method): string {
+ return camel(`${method.name.value}_query_options`);
+ }
+
+ buildServiceName(int: Interface): string {
+ return camel(`${int.name.value}_service`);
+ }
+
+ buildServiceHookName(int: Interface): string {
+ return camel(`use_${this.buildServiceName(int)}`);
+ }
+
+ getHookName(method: Method, httpVerb?: string): string {
+ const name = method.name.value;
+
+ // If it's a GET method and the name starts with "get", remove the "Get" prefix
+ if (httpVerb === 'get' && name.toLowerCase().startsWith('get')) {
+ return camel(`use_${name.slice(3)}`);
+ }
+
+ return camel(`use_${name}`);
+ }
+
+ getSuspenseHookName(method: Method, httpVerb?: string): string {
+ const name = method.name.value;
+
+ // If it's a GET method and the name starts with "get", remove the "Get" prefix
+ if (httpVerb === 'get' && name.toLowerCase().startsWith('get')) {
+ return camel(`use_suspense_${name.slice(3)}`);
+ }
+
+ return camel(`use_suspense_${name}`);
+ }
+
+ getInfiniteHookName(method: Method, httpVerb?: string): string {
+ const name = method.name.value;
+
+ // If it's a GET method and the name starts with "get", remove the "Get" prefix
+ if (httpVerb === 'get' && name.toLowerCase().startsWith('get')) {
+ return camel(`use_${name.slice(3)}_infinite`);
+ }
+
+ return camel(`use_${name}_infinite`);
+ }
+
+ getSuspenseInfiniteHookName(method: Method, httpVerb?: string): string {
+ const name = method.name.value;
+
+ // If it's a GET method and the name starts with "get", remove the "Get" prefix
+ if (httpVerb === 'get' && name.toLowerCase().startsWith('get')) {
+ return camel(`use_suspense_${name.slice(3)}_infinite`);
+ }
+
+ return camel(`use_suspense_${name}_infinite`);
+ }
}
diff --git a/src/query-key-builder.test.ts b/src/query-key-builder.test.ts
new file mode 100644
index 0000000..f2983ee
--- /dev/null
+++ b/src/query-key-builder.test.ts
@@ -0,0 +1,339 @@
+import { Service } from 'basketry';
+import { QueryKeyBuilderFile } from './query-key-builder';
+import { NamespacedReactQueryOptions } from './types';
+
+describe('QueryKeyBuilderFile', () => {
+ const createService = (interfaces: any[]): Service => ({
+ kind: 'Service',
+ basketry: '1.1-rc',
+ title: { value: 'TestService' },
+ majorVersion: { value: 1 },
+ sourcePath: '',
+ interfaces,
+ types: [],
+ enums: [],
+ unions: [],
+ });
+
+ const createInterface = (name: string, methods: any[]) => ({
+ kind: 'Interface' as const,
+ name: { value: name },
+ methods,
+ protocols: {
+ http: [],
+ },
+ });
+
+ const createMethod = (
+ name: string,
+ parameters: any[] = [],
+ httpMethod: string = 'GET',
+ ) => ({
+ kind: 'Method' as const,
+ name: { value: name },
+ parameters,
+ returnType: undefined,
+ security: [],
+ });
+
+ const createParameter = (name: string, required = true) => ({
+ kind: 'Parameter' as const,
+ name: { value: name },
+ typeName: { value: 'string' },
+ isArray: false,
+ isPrimitive: true,
+ description: null,
+ deprecated: null,
+ errors: [],
+ warnings: [],
+ sourcePath: '',
+ rules: [
+ ...(required
+ ? [
+ {
+ kind: 'Rule' as const,
+ id: 'required',
+ value: null,
+ errors: [],
+ warnings: [],
+ sourcePath: '',
+ },
+ ]
+ : []),
+ ],
+ });
+
+ describe('QueryKeyMap generation', () => {
+ it('generates correct interface structure', () => {
+ const service = createService([
+ createInterface('Widget', [
+ createMethod('getWidgets', [createParameter('status', false)]),
+ createMethod('getWidgetById', [createParameter('id')]),
+ ]),
+ createInterface('Gizmo', [createMethod('getGizmos')]),
+ ]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain('export interface QueryKeyMap {');
+ expect(output).toContain('widget: {');
+ expect(output).toContain('getWidgets: GetWidgetsParams | undefined;');
+ expect(output).toContain('getWidgetById: GetWidgetByIdParams;');
+ expect(output).toContain('gizmo: {');
+ expect(output).toContain('getGizmos: GetGizmosParams | undefined;');
+ });
+
+ it('handles methods with no parameters', () => {
+ const service = createService([
+ createInterface('Widget', [createMethod('getAllWidgets')]),
+ ]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain(
+ 'getAllWidgets: GetAllWidgetsParams | undefined;',
+ );
+ });
+
+ it('handles methods with required parameters', () => {
+ const service = createService([
+ createInterface('Widget', [
+ createMethod('getWidget', [
+ createParameter('id', true),
+ createParameter('version', true),
+ ]),
+ ]),
+ ]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain('getWidget: GetWidgetParams;');
+ });
+
+ it('handles methods with optional parameters', () => {
+ const service = createService([
+ createInterface('Widget', [
+ createMethod('searchWidgets', [
+ createParameter('query', false),
+ createParameter('limit', false),
+ ]),
+ ]),
+ ]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain(
+ 'searchWidgets: SearchWidgetsParams | undefined;',
+ );
+ });
+ });
+
+ describe('Type helpers generation', () => {
+ it('generates ServiceKeys type', () => {
+ const service = createService([
+ createInterface('Widget', []),
+ createInterface('Gizmo', []),
+ ]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain('export type ServiceKeys = keyof QueryKeyMap;');
+ });
+
+ it('generates OperationKeys type', () => {
+ const service = createService([createInterface('Widget', [])]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain(
+ 'export type OperationKeys = keyof QueryKeyMap[S];',
+ );
+ });
+
+ it('generates OperationParams type', () => {
+ const service = createService([createInterface('Widget', [])]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain('export type OperationParams<');
+ expect(output).toContain('S extends ServiceKeys,');
+ expect(output).toContain('O extends OperationKeys');
+ expect(output).toContain('> = QueryKeyMap[S][O];');
+ });
+ });
+
+ describe('matchQueryKey function generation', () => {
+ it('generates function with three overloads', () => {
+ const service = createService([createInterface('Widget', [])]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ // Service-only overload
+ expect(output).toContain(
+ 'export function matchQueryKey(',
+ );
+ expect(output).toContain('service: S');
+ expect(output).toContain('): readonly [S];');
+
+ // Service + operation overload
+ expect(output).toMatch(
+ /export function matchQueryKey<[\s\S]*?S extends ServiceKeys,[\s\S]*?O extends OperationKeys[\s\S]*?>/,
+ );
+
+ // Full overload with params
+ expect(output).toContain(
+ 'params: OperationParams extends undefined ? undefined : OperationParams',
+ );
+ });
+
+ it('generates correct implementation', () => {
+ const service = createService([createInterface('Widget', [])]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ // Check implementation logic
+ expect(output).toContain(
+ 'if (arguments.length === 3 && operation !== undefined) {',
+ );
+ expect(output).toContain(
+ 'const finalParams = params === undefined ? {} : params;',
+ );
+ expect(output).toContain(
+ 'return [service, operation, finalParams] as const;',
+ );
+ expect(output).toContain('if (operation !== undefined) {');
+ expect(output).toContain('return [service, operation] as const;');
+ expect(output).toContain('return [service] as const;');
+ });
+
+ it('includes comprehensive JSDoc examples', () => {
+ const service = createService([createInterface('Widget', [])]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain('@example');
+ expect(output).toContain('// Match all queries for a service');
+ expect(output).toContain('matchQueryKey("widget")');
+ expect(output).toContain('// Returns: ["widget"]');
+ expect(output).toContain('// Match all queries for a specific operation');
+ expect(output).toContain('matchQueryKey("widget", "getWidgets")');
+ expect(output).toContain('// Match specific query with parameters');
+ });
+ });
+
+ describe('import management', () => {
+ it('imports types module correctly', () => {
+ const service = createService([
+ createInterface('Widget', [
+ createMethod('getWidget', [createParameter('id')]),
+ ]),
+ ]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toMatch(
+ /import type \{ GetWidgetParams \} from '\.\.\/types'/,
+ );
+ });
+
+ it('respects custom types module path', () => {
+ const service = createService([
+ createInterface('Widget', [
+ createMethod('getWidget', [createParameter('id')]),
+ ]),
+ ]);
+
+ const options: NamespacedReactQueryOptions = {
+ reactQuery: {
+ typesModule: '../../custom-types',
+ },
+ };
+
+ const builder = new QueryKeyBuilderFile(service, options);
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toMatch(
+ /import type \{ GetWidgetParams \} from '\.\.\/\.\.\/custom-types'/,
+ );
+ });
+
+ it('handles type imports setting', () => {
+ const service = createService([
+ createInterface('Widget', [
+ createMethod('getWidget', [createParameter('id')]),
+ ]),
+ ]);
+
+ const options: NamespacedReactQueryOptions = {
+ typescript: {
+ typeImports: true,
+ },
+ };
+
+ const builder = new QueryKeyBuilderFile(service, options);
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toMatch(
+ /import type \{ GetWidgetParams \} from '\.\.\/types'/,
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles multiple interfaces with multiple methods', () => {
+ const service = createService([
+ createInterface('Widget', [
+ createMethod('getWidgets'),
+ createMethod('createWidget', [], 'POST'),
+ createMethod('updateWidget', [createParameter('id')], 'PUT'),
+ ]),
+ createInterface('Gizmo', [
+ createMethod('getGizmos', [createParameter('type', false)]),
+ createMethod('deleteGizmo', [createParameter('id')], 'DELETE'),
+ ]),
+ ]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ // Check all methods are included
+ expect(output).toContain('getWidgets: GetWidgetsParams | undefined;');
+ expect(output).toContain('createWidget: CreateWidgetParams | undefined;');
+ expect(output).toContain('updateWidget: UpdateWidgetParams;');
+ expect(output).toContain('getGizmos: GetGizmosParams | undefined;');
+ expect(output).toContain('deleteGizmo: DeleteGizmoParams;');
+ });
+
+ it('handles empty service', () => {
+ const service = createService([]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain('export interface QueryKeyMap {');
+ expect(output).toContain('}'); // Empty interface
+ expect(output).toContain('export function matchQueryKey');
+ });
+
+ it('handles interface with no methods', () => {
+ const service = createService([createInterface('Widget', [])]);
+
+ const builder = new QueryKeyBuilderFile(service, {});
+ const output = Array.from(builder.build()).join('\n');
+
+ expect(output).toContain('widget: {');
+ expect(output).toContain('};'); // Empty methods object
+ });
+ });
+});
diff --git a/src/query-key-builder.ts b/src/query-key-builder.ts
new file mode 100644
index 0000000..c05784b
--- /dev/null
+++ b/src/query-key-builder.ts
@@ -0,0 +1,166 @@
+import { Interface, isRequired, Method, Service } from 'basketry';
+
+import { buildParamsType, buildTypeName } from '@basketry/typescript';
+
+import { camel } from 'case';
+import { NamespacedReactQueryOptions } from './types';
+import { ModuleBuilder } from './module-builder';
+import { ImportBuilder } from './import-builder';
+
+export class QueryKeyBuilderFile extends ModuleBuilder {
+ constructor(
+ service: Service,
+ options: NamespacedReactQueryOptions | undefined,
+ ) {
+ super(service, options);
+ }
+
+ private readonly types = new ImportBuilder(
+ this.options?.reactQuery?.typesModule ?? '../types',
+ );
+
+ protected readonly importBuilders = [this.types];
+
+ *body(): Iterable {
+ // Generate QueryKeyMap interface
+ yield* this.generateQueryKeyMap();
+ yield '';
+
+ // Generate type extraction helpers
+ yield* this.generateTypeHelpers();
+ yield '';
+
+ // Generate matchQueryKey function
+ yield* this.generateMatchQueryKeyFunction();
+ }
+
+ private *generateQueryKeyMap(): Iterable {
+ yield '/**';
+ yield ' * Type mapping for all available query keys in the service';
+ yield ' */';
+ yield 'export interface QueryKeyMap {';
+
+ for (const int of this.service.interfaces) {
+ const serviceName = camel(int.name.value);
+ yield ` ${serviceName}: {`;
+
+ for (const method of int.methods) {
+ const methodName = camel(method.name.value);
+ const paramsType = this.buildMethodParamsType(method);
+
+ yield ` ${methodName}: ${paramsType};`;
+ }
+
+ yield ' };';
+ }
+
+ yield '}';
+ }
+
+ private *generateTypeHelpers(): Iterable {
+ // ServiceKeys type
+ yield '/**';
+ yield ' * Extract all service names from QueryKeyMap';
+ yield ' */';
+ yield 'export type ServiceKeys = keyof QueryKeyMap;';
+ yield '';
+
+ // OperationKeys type
+ yield '/**';
+ yield ' * Extract operation names for a given service';
+ yield ' */';
+ yield 'export type OperationKeys = keyof QueryKeyMap[S];';
+ yield '';
+
+ // OperationParams type
+ yield '/**';
+ yield ' * Extract parameter type for a given service and operation';
+ yield ' */';
+ yield 'export type OperationParams<';
+ yield ' S extends ServiceKeys,';
+ yield ' O extends OperationKeys';
+ yield '> = QueryKeyMap[S][O];';
+ }
+
+ private *generateMatchQueryKeyFunction(): Iterable {
+ yield '/**';
+ yield ' * Build type-safe query keys for React Query cache operations';
+ yield ' * ';
+ yield ' * @example';
+ yield ' * // Match all queries for a service';
+ yield ' * matchQueryKey("widget")';
+ yield ' * // Returns: ["widget"]';
+ yield ' * ';
+ yield ' * @example';
+ yield ' * // Match all queries for a specific operation';
+ yield ' * matchQueryKey("widget", "getWidgets")';
+ yield ' * // Returns: ["widget", "getWidgets"]';
+ yield ' * ';
+ yield ' * @example';
+ yield ' * // Match specific query with parameters';
+ yield ' * matchQueryKey("widget", "getWidgets", { status: "active" })';
+ yield ' * // Returns: ["widget", "getWidgets", { status: "active" }]';
+ yield ' */';
+
+ // Function overloads
+ yield 'export function matchQueryKey(';
+ yield ' service: S';
+ yield '): readonly [S];';
+ yield '';
+
+ yield 'export function matchQueryKey<';
+ yield ' S extends ServiceKeys,';
+ yield ' O extends OperationKeys';
+ yield '>(';
+ yield ' service: S,';
+ yield ' operation: O';
+ yield '): readonly [S, O];';
+ yield '';
+
+ yield 'export function matchQueryKey<';
+ yield ' S extends ServiceKeys,';
+ yield ' O extends OperationKeys';
+ yield '>(';
+ yield ' service: S,';
+ yield ' operation: O,';
+ yield ' params: OperationParams extends undefined ? undefined : OperationParams';
+ yield '): readonly [S, O, OperationParams extends undefined ? {} : OperationParams];';
+ yield '';
+
+ // Implementation
+ yield 'export function matchQueryKey<';
+ yield ' S extends ServiceKeys,';
+ yield ' O extends OperationKeys';
+ yield '>(';
+ yield ' service: S,';
+ yield ' operation?: O,';
+ yield ' params?: OperationParams';
+ yield ') {';
+ yield ' if (arguments.length === 3 && operation !== undefined) {';
+ yield ' // When called with 3 arguments, always include params (use {} if undefined)';
+ yield ' const finalParams = params === undefined ? {} : params;';
+ yield ' return [service, operation, finalParams] as const;';
+ yield ' }';
+ yield ' if (operation !== undefined) {';
+ yield ' return [service, operation] as const;';
+ yield ' }';
+ yield ' return [service] as const;';
+ yield '}';
+ }
+
+ private buildMethodParamsType(method: Method): string {
+ const paramsType = Array.from(buildParamsType(method)).join('');
+
+ if (!paramsType) {
+ return 'undefined';
+ }
+
+ // Register the type with the import builder
+ if (paramsType) {
+ this.types.type(paramsType);
+ }
+
+ const hasRequired = method.parameters.some((p) => isRequired(p));
+ return hasRequired ? paramsType : `${paramsType} | undefined`;
+ }
+}
diff --git a/src/snapshot/v1/hooks/auth-permutations.ts b/src/snapshot/v1/hooks/auth-permutations.ts
new file mode 100644
index 0000000..c2a2d05
--- /dev/null
+++ b/src/snapshot/v1/hooks/auth-permutations.ts
@@ -0,0 +1,142 @@
+/**
+ * This code was generated by @basketry/react-query@{{version}}
+ *
+ * Changes to this file may cause incorrect behavior and will be lost if
+ * the code is regenerated.
+ *
+ * To make changes to the contents of this file:
+ * 1. Edit source/path.ext
+ * 2. Run the Basketry CLI
+ *
+ * About Basketry: https://github.com/basketry/basketry/wiki
+ * About @basketry/react-query: https://github.com/basketry/react-query#readme
+ */
+
+import {
+ mutationOptions,
+ queryOptions,
+ type UndefinedInitialDataOptions,
+ useMutation,
+ type UseMutationOptions,
+ useQuery,
+ useQueryClient,
+ useSuspenseQuery,
+} from '@tanstack/react-query';
+import type { ComboAuthSchemesParams } from '../types';
+import { getAuthPermutationService } from './context';
+import { CompositeError } from './runtime';
+
+export const allAuthSchemesQueryOptions = () => {
+ const authPermutationService = getAuthPermutationService();
+ return queryOptions({
+ queryKey: ['authPermutation', 'all-auth-schemes', {}] as const,
+ queryFn: async () => {
+ const res = await authPermutationService.allAuthSchemes();
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res;
+ },
+ select: (data) => data.data,
+ });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useQuery } from '@tanstack/react-query';
+ * import { all-auth-schemesQueryOptions } from './hooks/authPermutations';
+ *
+ * // Old pattern (deprecated)
+ * const result = useAllAuthSchemes(params);
+ *
+ * // New pattern
+ * const result = useQuery(all-auth-schemesQueryOptions(params));
+ * ```
+ */
+export const useAllAuthSchemes = (
+ options?: Omit<
+ UndefinedInitialDataOptions,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = allAuthSchemesQueryOptions();
+ return useQuery({ ...defaultOptions, ...options });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useSuspenseQuery } from '@tanstack/react-query';
+ * import { all-auth-schemesQueryOptions } from './hooks/authPermutations';
+ *
+ * // Old pattern (deprecated)
+ * const result = useAllAuthSchemes(params);
+ *
+ * // New pattern
+ * const result = useSuspenseQuery(all-auth-schemesQueryOptions(params));
+ * ```
+ */
+export const useAllAuthSchemes = (
+ options?: Omit<
+ UndefinedInitialDataOptions,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = allAuthSchemesQueryOptions();
+ return useSuspenseQuery({ ...defaultOptions, ...options });
+};
+
+export const comboAuthSchemesMutationOptions = () => {
+ const authPermutationService = getAuthPermutationService();
+ return mutationOptions({
+ mutationFn: async () => {
+ const res = await authPermutationService.comboAuthSchemes();
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res.data;
+ },
+ });
+};
+
+/**
+ * @deprecated This mutation hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useMutation } from '@tanstack/react-query';
+ * import { combo-auth-schemesMutationOptions } from './hooks/authPermutations';
+ *
+ * // Old pattern (deprecated)
+ * const mutation = useComboAuthSchemes();
+ *
+ * // New pattern
+ * const mutation = useMutation(combo-auth-schemesMutationOptions());
+ * ```
+ */
+export const useComboAuthSchemes = (
+ options?: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const mutationOptions = comboAuthSchemesMutationOptions();
+ return useMutation({
+ ...mutationOptions,
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: ['authPermutation'] });
+ mutationOptions.onSuccess?.(data, variables, context);
+ },
+ ...options,
+ });
+};
diff --git a/src/snapshot/v1/hooks/context.tsx b/src/snapshot/v1/hooks/context.tsx
new file mode 100644
index 0000000..3251d35
--- /dev/null
+++ b/src/snapshot/v1/hooks/context.tsx
@@ -0,0 +1,172 @@
+/**
+ * This code was generated by @basketry/react-query@{{version}}
+ *
+ * Changes to this file may cause incorrect behavior and will be lost if
+ * the code is regenerated.
+ *
+ * To make changes to the contents of this file:
+ * 1. Edit source/path.ext
+ * 2. Run the Basketry CLI
+ *
+ * About Basketry: https://github.com/basketry/basketry/wiki
+ * About @basketry/react-query: https://github.com/basketry/react-query#readme
+ */
+
+import {
+ createContext,
+ type FC,
+ type PropsWithChildren,
+ useContext,
+ useMemo,
+} from 'react';
+import {
+ type BasketryExampleOptions,
+ type FetchLike,
+ HttpAuthPermutationService,
+ HttpExhaustiveService,
+ HttpGizmoService,
+ HttpWidgetService,
+} from '../http-client';
+import type {
+ AuthPermutationService,
+ ExhaustiveService,
+ GizmoService,
+ WidgetService,
+} from '../types';
+
+export interface BasketryExampleContextProps {
+ fetch: FetchLike;
+ options: BasketryExampleOptions;
+}
+const BasketryExampleContext = createContext<
+ BasketryExampleContextProps | undefined
+>(undefined);
+
+let currentContext: BasketryExampleContextProps | undefined;
+
+export const BasketryExampleProvider: FC<
+ PropsWithChildren
+> = ({ children, fetch, options }) => {
+ const value = useMemo(
+ () => ({ fetch, options }),
+ [
+ fetch,
+ options.mapUnhandledException,
+ options.mapValidationError,
+ options.root,
+ ],
+ );
+ currentContext = value;
+ return (
+
+ {children}
+
+ );
+};
+
+export const getGizmoService = () => {
+ if (!currentContext) {
+ throw new Error(
+ 'getGizmoService called outside of BasketryExampleProvider',
+ );
+ }
+ const gizmoService: GizmoService = new HttpGizmoService(
+ currentContext.fetch,
+ currentContext.options,
+ );
+ return gizmoService;
+};
+
+export const useGizmoService = () => {
+ const context = useContext(BasketryExampleContext);
+ if (!context) {
+ throw new Error(
+ 'useGizmoService must be used within a BasketryExampleProvider',
+ );
+ }
+ const gizmoService: GizmoService = new HttpGizmoService(
+ context.fetch,
+ context.options,
+ );
+ return gizmoService;
+};
+
+export const getWidgetService = () => {
+ if (!currentContext) {
+ throw new Error(
+ 'getWidgetService called outside of BasketryExampleProvider',
+ );
+ }
+ const widgetService: WidgetService = new HttpWidgetService(
+ currentContext.fetch,
+ currentContext.options,
+ );
+ return widgetService;
+};
+
+export const useWidgetService = () => {
+ const context = useContext(BasketryExampleContext);
+ if (!context) {
+ throw new Error(
+ 'useWidgetService must be used within a BasketryExampleProvider',
+ );
+ }
+ const widgetService: WidgetService = new HttpWidgetService(
+ context.fetch,
+ context.options,
+ );
+ return widgetService;
+};
+
+export const getExhaustiveService = () => {
+ if (!currentContext) {
+ throw new Error(
+ 'getExhaustiveService called outside of BasketryExampleProvider',
+ );
+ }
+ const exhaustiveService: ExhaustiveService = new HttpExhaustiveService(
+ currentContext.fetch,
+ currentContext.options,
+ );
+ return exhaustiveService;
+};
+
+export const useExhaustiveService = () => {
+ const context = useContext(BasketryExampleContext);
+ if (!context) {
+ throw new Error(
+ 'useExhaustiveService must be used within a BasketryExampleProvider',
+ );
+ }
+ const exhaustiveService: ExhaustiveService = new HttpExhaustiveService(
+ context.fetch,
+ context.options,
+ );
+ return exhaustiveService;
+};
+
+export const getAuthPermutationService = () => {
+ if (!currentContext) {
+ throw new Error(
+ 'getAuthPermutationService called outside of BasketryExampleProvider',
+ );
+ }
+ const authPermutationService: AuthPermutationService =
+ new HttpAuthPermutationService(
+ currentContext.fetch,
+ currentContext.options,
+ );
+ return authPermutationService;
+};
+
+export const useAuthPermutationService = () => {
+ const context = useContext(BasketryExampleContext);
+ if (!context) {
+ throw new Error(
+ 'useAuthPermutationService must be used within a BasketryExampleProvider',
+ );
+ }
+ const authPermutationService: AuthPermutationService =
+ new HttpAuthPermutationService(context.fetch, context.options);
+ return authPermutationService;
+};
diff --git a/src/snapshot/v1/hooks/exhaustives.ts b/src/snapshot/v1/hooks/exhaustives.ts
new file mode 100644
index 0000000..cf3f7ec
--- /dev/null
+++ b/src/snapshot/v1/hooks/exhaustives.ts
@@ -0,0 +1,185 @@
+/**
+ * This code was generated by @basketry/react-query@{{version}}
+ *
+ * Changes to this file may cause incorrect behavior and will be lost if
+ * the code is regenerated.
+ *
+ * To make changes to the contents of this file:
+ * 1. Edit source/path.ext
+ * 2. Run the Basketry CLI
+ *
+ * About Basketry: https://github.com/basketry/basketry/wiki
+ * About @basketry/react-query: https://github.com/basketry/react-query#readme
+ */
+
+import {
+ queryOptions,
+ type UndefinedInitialDataOptions,
+ useQuery,
+ useSuspenseQuery,
+} from '@tanstack/react-query';
+import type { ExhaustiveFormatsParams, ExhaustiveParamsParams } from '../types';
+import { getExhaustiveService } from './context';
+import { CompositeError } from './runtime';
+
+export const exhaustiveFormatsQueryOptions = (
+ params?: ExhaustiveFormatsParams,
+) => {
+ const exhaustiveService = getExhaustiveService();
+ return queryOptions({
+ queryKey: ['exhaustive', 'exhaustiveFormats', params || {}] as const,
+ queryFn: async () => {
+ const res = await exhaustiveService.exhaustiveFormats(params);
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res;
+ },
+ select: (data) => data.data,
+ });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useQuery } from '@tanstack/react-query';
+ * import { exhaustiveFormatsQueryOptions } from './hooks/exhaustives';
+ *
+ * // Old pattern (deprecated)
+ * const result = useExhaustiveFormats(params);
+ *
+ * // New pattern
+ * const result = useQuery(exhaustiveFormatsQueryOptions(params));
+ * ```
+ */
+export const useExhaustiveFormats = (
+ params?: ExhaustiveFormatsParams,
+ options?: Omit<
+ UndefinedInitialDataOptions<
+ void,
+ Error,
+ void | undefined,
+ (string | Record)[]
+ >,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = exhaustiveFormatsQueryOptions(params);
+ return useQuery({ ...defaultOptions, ...options });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useSuspenseQuery } from '@tanstack/react-query';
+ * import { exhaustiveFormatsQueryOptions } from './hooks/exhaustives';
+ *
+ * // Old pattern (deprecated)
+ * const result = useExhaustiveFormats(params);
+ *
+ * // New pattern
+ * const result = useSuspenseQuery(exhaustiveFormatsQueryOptions(params));
+ * ```
+ */
+export const useExhaustiveFormats = (
+ params?: ExhaustiveFormatsParams,
+ options?: Omit<
+ UndefinedInitialDataOptions<
+ void,
+ Error,
+ void | undefined,
+ (string | Record)[]
+ >,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = exhaustiveFormatsQueryOptions(params);
+ return useSuspenseQuery({ ...defaultOptions, ...options });
+};
+
+export const exhaustiveParamsQueryOptions = (
+ params: ExhaustiveParamsParams,
+) => {
+ const exhaustiveService = getExhaustiveService();
+ return queryOptions({
+ queryKey: ['exhaustive', 'exhaustiveParams', params || {}] as const,
+ queryFn: async () => {
+ const res = await exhaustiveService.exhaustiveParams(params);
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res;
+ },
+ select: (data) => data.data,
+ });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useQuery } from '@tanstack/react-query';
+ * import { exhaustiveParamsQueryOptions } from './hooks/exhaustives';
+ *
+ * // Old pattern (deprecated)
+ * const result = useExhaustiveParams(params);
+ *
+ * // New pattern
+ * const result = useQuery(exhaustiveParamsQueryOptions(params));
+ * ```
+ */
+export const useExhaustiveParams = (
+ params: ExhaustiveParamsParams,
+ options?: Omit<
+ UndefinedInitialDataOptions<
+ void,
+ Error,
+ void | undefined,
+ (string | Record)[]
+ >,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = exhaustiveParamsQueryOptions(params);
+ return useQuery({ ...defaultOptions, ...options });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useSuspenseQuery } from '@tanstack/react-query';
+ * import { exhaustiveParamsQueryOptions } from './hooks/exhaustives';
+ *
+ * // Old pattern (deprecated)
+ * const result = useExhaustiveParams(params);
+ *
+ * // New pattern
+ * const result = useSuspenseQuery(exhaustiveParamsQueryOptions(params));
+ * ```
+ */
+export const useExhaustiveParams = (
+ params: ExhaustiveParamsParams,
+ options?: Omit<
+ UndefinedInitialDataOptions<
+ void,
+ Error,
+ void | undefined,
+ (string | Record)[]
+ >,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = exhaustiveParamsQueryOptions(params);
+ return useSuspenseQuery({ ...defaultOptions, ...options });
+};
diff --git a/src/snapshot/v1/hooks/gizmos.ts b/src/snapshot/v1/hooks/gizmos.ts
new file mode 100644
index 0000000..7a9175d
--- /dev/null
+++ b/src/snapshot/v1/hooks/gizmos.ts
@@ -0,0 +1,265 @@
+/**
+ * This code was generated by @basketry/react-query@{{version}}
+ *
+ * Changes to this file may cause incorrect behavior and will be lost if
+ * the code is regenerated.
+ *
+ * To make changes to the contents of this file:
+ * 1. Edit source/path.ext
+ * 2. Run the Basketry CLI
+ *
+ * About Basketry: https://github.com/basketry/basketry/wiki
+ * About @basketry/react-query: https://github.com/basketry/react-query#readme
+ */
+
+import {
+ mutationOptions,
+ queryOptions,
+ type UndefinedInitialDataOptions,
+ useMutation,
+ type UseMutationOptions,
+ useQuery,
+ useQueryClient,
+ useSuspenseQuery,
+} from '@tanstack/react-query';
+import type {
+ CreateGizmoParams,
+ GetGizmosParams,
+ Gizmo,
+ GizmosResponse,
+ UpdateGizmoParams,
+ UploadGizmoParams,
+} from '../types';
+import { getGizmoService } from './context';
+import { CompositeError } from './runtime';
+
+/**
+ * Has a summary in addition to a description
+ * Has a description in addition to a summary
+ */
+export const createGizmoMutationOptions = () => {
+ const gizmoService = getGizmoService();
+ return mutationOptions({
+ mutationFn: async (params?: CreateGizmoParams) => {
+ const res = await gizmoService.createGizmo(params);
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res.data;
+ },
+ });
+};
+
+/**
+ * @deprecated This mutation hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useMutation } from '@tanstack/react-query';
+ * import { createGizmoMutationOptions } from './hooks/gizmos';
+ *
+ * // Old pattern (deprecated)
+ * const mutation = useCreateGizmo();
+ *
+ * // New pattern
+ * const mutation = useMutation(createGizmoMutationOptions());
+ * ```
+ */
+export const useCreateGizmo = (
+ options?: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const mutationOptions = createGizmoMutationOptions();
+ return useMutation({
+ ...mutationOptions,
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: ['gizmo'] });
+ mutationOptions.onSuccess?.(data, variables, context);
+ },
+ ...options,
+ });
+};
+
+/**
+ * Only has a summary
+ * @deprecated
+ */
+export const getGizmosQueryOptions = (params?: GetGizmosParams) => {
+ const gizmoService = getGizmoService();
+ return queryOptions({
+ queryKey: ['gizmo', 'getGizmos', params || {}] as const,
+ queryFn: async () => {
+ const res = await gizmoService.getGizmos(params);
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res;
+ },
+ select: (data) => data.data,
+ });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useQuery } from '@tanstack/react-query';
+ * import { getGizmosQueryOptions } from './hooks/gizmos';
+ *
+ * // Old pattern (deprecated)
+ * const result = useGizmos(params);
+ *
+ * // New pattern
+ * const result = useQuery(getGizmosQueryOptions(params));
+ * ```
+ */
+export const useGizmos = (
+ params?: GetGizmosParams,
+ options?: Omit<
+ UndefinedInitialDataOptions<
+ GizmosResponse,
+ Error,
+ Gizmo | undefined,
+ (string | Record)[]
+ >,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = getGizmosQueryOptions(params);
+ return useQuery({ ...defaultOptions, ...options });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useSuspenseQuery } from '@tanstack/react-query';
+ * import { getGizmosQueryOptions } from './hooks/gizmos';
+ *
+ * // Old pattern (deprecated)
+ * const result = useSuspenseGizmos(params);
+ *
+ * // New pattern
+ * const result = useSuspenseQuery(getGizmosQueryOptions(params));
+ * ```
+ */
+export const useSuspenseGizmos = (
+ params?: GetGizmosParams,
+ options?: Omit<
+ UndefinedInitialDataOptions<
+ GizmosResponse,
+ Error,
+ Gizmo | undefined,
+ (string | Record)[]
+ >,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = getGizmosQueryOptions(params);
+ return useSuspenseQuery({ ...defaultOptions, ...options });
+};
+
+export const updateGizmoMutationOptions = () => {
+ const gizmoService = getGizmoService();
+ return mutationOptions({
+ mutationFn: async (params?: UpdateGizmoParams) => {
+ const res = await gizmoService.updateGizmo(params);
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res.data;
+ },
+ });
+};
+
+/**
+ * @deprecated This mutation hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useMutation } from '@tanstack/react-query';
+ * import { updateGizmoMutationOptions } from './hooks/gizmos';
+ *
+ * // Old pattern (deprecated)
+ * const mutation = useUpdateGizmo();
+ *
+ * // New pattern
+ * const mutation = useMutation(updateGizmoMutationOptions());
+ * ```
+ */
+export const useUpdateGizmo = (
+ options?: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const mutationOptions = updateGizmoMutationOptions();
+ return useMutation({
+ ...mutationOptions,
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: ['gizmo'] });
+ mutationOptions.onSuccess?.(data, variables, context);
+ },
+ ...options,
+ });
+};
+
+export const uploadGizmoMutationOptions = () => {
+ const gizmoService = getGizmoService();
+ return mutationOptions({
+ mutationFn: async (params: UploadGizmoParams) => {
+ const res = await gizmoService.uploadGizmo(params);
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res.data;
+ },
+ });
+};
+
+/**
+ * @deprecated This mutation hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useMutation } from '@tanstack/react-query';
+ * import { uploadGizmoMutationOptions } from './hooks/gizmos';
+ *
+ * // Old pattern (deprecated)
+ * const mutation = useUploadGizmo();
+ *
+ * // New pattern
+ * const mutation = useMutation(uploadGizmoMutationOptions());
+ * ```
+ */
+export const useUploadGizmo = (
+ options?: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const mutationOptions = uploadGizmoMutationOptions();
+ return useMutation({
+ ...mutationOptions,
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: ['gizmo'] });
+ mutationOptions.onSuccess?.(data, variables, context);
+ },
+ ...options,
+ });
+};
diff --git a/src/snapshot/v1/hooks/query-key-builder.ts b/src/snapshot/v1/hooks/query-key-builder.ts
new file mode 100644
index 0000000..9ab4250
--- /dev/null
+++ b/src/snapshot/v1/hooks/query-key-builder.ts
@@ -0,0 +1,129 @@
+/**
+ * This code was generated by @basketry/react-query@{{version}}
+ *
+ * Changes to this file may cause incorrect behavior and will be lost if
+ * the code is regenerated.
+ *
+ * To make changes to the contents of this file:
+ * 1. Edit source/path.ext
+ * 2. Run the Basketry CLI
+ *
+ * About Basketry: https://github.com/basketry/basketry/wiki
+ * About @basketry/react-query: https://github.com/basketry/react-query#readme
+ */
+
+import type {
+ AllAuthSchemesParams,
+ ComboAuthSchemesParams,
+ CreateGizmoParams,
+ CreateWidgetParams,
+ DeleteWidgetFooParams,
+ ExhaustiveFormatsParams,
+ ExhaustiveParamsParams,
+ GetGizmosParams,
+ GetWidgetFooParams,
+ GetWidgetsParams,
+ PutWidgetParams,
+ UpdateGizmoParams,
+ UploadGizmoParams,
+} from '../types';
+
+/**
+ * Type mapping for all available query keys in the service
+ */
+export interface QueryKeyMap {
+ gizmo: {
+ getGizmos: GetGizmosParams | undefined;
+ createGizmo: CreateGizmoParams | undefined;
+ updateGizmo: UpdateGizmoParams | undefined;
+ uploadGizmo: UploadGizmoParams;
+ };
+ widget: {
+ getWidgets: GetWidgetsParams | undefined;
+ createWidget: CreateWidgetParams | undefined;
+ putWidget: PutWidgetParams | undefined;
+ getWidgetFoo: GetWidgetFooParams;
+ deleteWidgetFoo: DeleteWidgetFooParams;
+ };
+ exhaustive: {
+ exhaustiveFormats: ExhaustiveFormatsParams | undefined;
+ exhaustiveParams: ExhaustiveParamsParams;
+ };
+ authPermutation: {
+ allAuthSchemes: AllAuthSchemesParams | undefined;
+ comboAuthSchemes: ComboAuthSchemesParams | undefined;
+ };
+}
+
+/**
+ * Extract all service names from QueryKeyMap
+ */
+export type ServiceKeys = keyof QueryKeyMap;
+
+/**
+ * Extract operation names for a given service
+ */
+export type OperationKeys = keyof QueryKeyMap[S];
+
+/**
+ * Extract parameter type for a given service and operation
+ */
+export type OperationParams<
+ S extends ServiceKeys,
+ O extends OperationKeys,
+> = QueryKeyMap[S][O];
+
+/**
+ * Build type-safe query keys for React Query cache operations
+ *
+ * @example
+ * // Match all queries for a service
+ * matchQueryKey("widget")
+ * // Returns: ["widget"]
+ *
+ * @example
+ * // Match all queries for a specific operation
+ * matchQueryKey("widget", "getWidgets")
+ * // Returns: ["widget", "getWidgets"]
+ *
+ * @example
+ * // Match specific query with parameters
+ * matchQueryKey("widget", "getWidgets", { status: "active" })
+ * // Returns: ["widget", "getWidgets", { status: "active" }]
+ */
+export function matchQueryKey(service: S): readonly [S];
+
+export function matchQueryKey<
+ S extends ServiceKeys,
+ O extends OperationKeys,
+>(service: S, operation: O): readonly [S, O];
+
+export function matchQueryKey<
+ S extends ServiceKeys,
+ O extends OperationKeys,
+>(
+ service: S,
+ operation: O,
+ params: OperationParams extends undefined
+ ? undefined
+ : OperationParams,
+): readonly [
+ S,
+ O,
+ OperationParams extends undefined ? {} : OperationParams,
+];
+
+export function matchQueryKey<
+ S extends ServiceKeys,
+ O extends OperationKeys,
+>(service: S, operation?: O, params?: OperationParams) {
+ if (arguments.length === 3 && operation !== undefined) {
+ // When called with 3 arguments, always include params (use {} if undefined)
+ const finalParams = params === undefined ? {} : params;
+ return [service, operation, finalParams] as const;
+ }
+ if (operation !== undefined) {
+ return [service, operation] as const;
+ }
+ return [service] as const;
+}
diff --git a/src/snapshot/v1/hooks/runtime.ts b/src/snapshot/v1/hooks/runtime.ts
new file mode 100644
index 0000000..7bc72bc
--- /dev/null
+++ b/src/snapshot/v1/hooks/runtime.ts
@@ -0,0 +1,109 @@
+/**
+ * This code was generated by @basketry/react-query@{{version}}
+ *
+ * Changes to this file may cause incorrect behavior and will be lost if
+ * the code is regenerated.
+ *
+ * To make changes to the contents of this file:
+ * 1. Edit source/path.ext
+ * 2. Run the Basketry CLI
+ *
+ * About Basketry: https://github.com/basketry/basketry/wiki
+ * About @basketry/react-query: https://github.com/basketry/react-query#readme
+ */
+
+import type {
+ GetNextPageParamFunction,
+ GetPreviousPageParamFunction,
+} from '@tanstack/react-query';
+
+export type PageParam = { pageParam?: string };
+
+export class CompositeError extends Error {
+ constructor(readonly errors: { title: string }[]) {
+ super(errors.map((e) => e.title).join(', '));
+ if (Error.captureStackTrace) Error.captureStackTrace(this, CompositeError);
+ }
+}
+
+export type RelayParams = {
+ first?: number;
+ after?: string;
+ last?: number;
+ before?: string;
+};
+
+export type Response = {
+ pageInfo?: {
+ startCursor?: string;
+ hasPreviousPage: boolean;
+ hasNextPage: boolean;
+ endCursor?: string;
+ };
+};
+
+export const getNextPageParam: GetNextPageParamFunction<
+ string | undefined,
+ Response
+> = (lastPage) => {
+ return lastPage.pageInfo?.hasNextPage
+ ? `after:${lastPage.pageInfo.endCursor}`
+ : undefined;
+};
+
+export const getPreviousPageParam: GetPreviousPageParamFunction<
+ string | undefined,
+ Response
+> = (lastPage) => {
+ return lastPage.pageInfo?.hasPreviousPage
+ ? `before:${lastPage.pageInfo.startCursor}`
+ : undefined;
+};
+
+export function applyPageParam(
+ params: T,
+ pageParam: string | undefined,
+): T {
+ const { first, after, last, before, ...rest } = params;
+ const syntheticParams: T = rest as T;
+
+ if (pageParam) {
+ const [key, value] = pageParam.split(':');
+
+ if (key === 'after') {
+ syntheticParams.first = first ?? last;
+ syntheticParams.after = value;
+ } else if (key === 'before') {
+ syntheticParams.last = last ?? first;
+ syntheticParams.before = value;
+ }
+ } else {
+ if (first) syntheticParams.first = first;
+ if (after) syntheticParams.after = after;
+ if (last) syntheticParams.last = last;
+ if (before) syntheticParams.before = before;
+ }
+
+ return syntheticParams;
+}
+
+export function getInitialPageParam(params: {
+ after?: string;
+ before?: string;
+}): string | undefined {
+ if (params.after) return `after:${params.after}`;
+ if (params.before) return `before:${params.before}`;
+ return;
+}
+
+export function compact(
+ params: Record,
+): Record | undefined {
+ const result: Record = Object.fromEntries(
+ Object.entries(params).filter(
+ ([, value]) => value !== null && value !== undefined,
+ ),
+ ) as any;
+
+ return Object.keys(result).length ? result : undefined;
+}
diff --git a/src/snapshot/v1/hooks/widgets.ts b/src/snapshot/v1/hooks/widgets.ts
new file mode 100644
index 0000000..267a755
--- /dev/null
+++ b/src/snapshot/v1/hooks/widgets.ts
@@ -0,0 +1,311 @@
+/**
+ * This code was generated by @basketry/react-query@{{version}}
+ *
+ * Changes to this file may cause incorrect behavior and will be lost if
+ * the code is regenerated.
+ *
+ * To make changes to the contents of this file:
+ * 1. Edit source/path.ext
+ * 2. Run the Basketry CLI
+ *
+ * About Basketry: https://github.com/basketry/basketry/wiki
+ * About @basketry/react-query: https://github.com/basketry/react-query#readme
+ */
+
+import {
+ mutationOptions,
+ queryOptions,
+ type UndefinedInitialDataOptions,
+ useMutation,
+ type UseMutationOptions,
+ useQuery,
+ useQueryClient,
+ useSuspenseQuery,
+} from '@tanstack/react-query';
+import type {
+ CreateWidgetParams,
+ DeleteWidgetFooParams,
+ GetWidgetFooParams,
+ PutWidgetParams,
+ Widget,
+} from '../types';
+import { getWidgetService } from './context';
+import { CompositeError } from './runtime';
+
+export const createWidgetMutationOptions = () => {
+ const widgetService = getWidgetService();
+ return mutationOptions({
+ mutationFn: async (params?: CreateWidgetParams) => {
+ const res = await widgetService.createWidget(params);
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res.data;
+ },
+ });
+};
+
+/**
+ * @deprecated This mutation hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useMutation } from '@tanstack/react-query';
+ * import { createWidgetMutationOptions } from './hooks/widgets';
+ *
+ * // Old pattern (deprecated)
+ * const mutation = useCreateWidget();
+ *
+ * // New pattern
+ * const mutation = useMutation(createWidgetMutationOptions());
+ * ```
+ */
+export const useCreateWidget = (
+ options?: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const mutationOptions = createWidgetMutationOptions();
+ return useMutation({
+ ...mutationOptions,
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: ['widget'] });
+ mutationOptions.onSuccess?.(data, variables, context);
+ },
+ ...options,
+ });
+};
+
+export const deleteWidgetFooMutationOptions = () => {
+ const widgetService = getWidgetService();
+ return mutationOptions({
+ mutationFn: async (params: DeleteWidgetFooParams) => {
+ const res = await widgetService.deleteWidgetFoo(params);
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res.data;
+ },
+ });
+};
+
+/**
+ * @deprecated This mutation hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useMutation } from '@tanstack/react-query';
+ * import { deleteWidgetFooMutationOptions } from './hooks/widgets';
+ *
+ * // Old pattern (deprecated)
+ * const mutation = useDeleteWidgetFoo();
+ *
+ * // New pattern
+ * const mutation = useMutation(deleteWidgetFooMutationOptions());
+ * ```
+ */
+export const useDeleteWidgetFoo = (
+ options?: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const mutationOptions = deleteWidgetFooMutationOptions();
+ return useMutation({
+ ...mutationOptions,
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: ['widget'] });
+ mutationOptions.onSuccess?.(data, variables, context);
+ },
+ ...options,
+ });
+};
+
+export const putWidgetMutationOptions = () => {
+ const widgetService = getWidgetService();
+ return mutationOptions({
+ mutationFn: async () => {
+ const res = await widgetService.putWidget();
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res.data;
+ },
+ });
+};
+
+/**
+ * @deprecated This mutation hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useMutation } from '@tanstack/react-query';
+ * import { putWidgetMutationOptions } from './hooks/widgets';
+ *
+ * // Old pattern (deprecated)
+ * const mutation = usePutWidget();
+ *
+ * // New pattern
+ * const mutation = useMutation(putWidgetMutationOptions());
+ * ```
+ */
+export const usePutWidget = (
+ options?: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+ >,
+) => {
+ const queryClient = useQueryClient();
+ const mutationOptions = putWidgetMutationOptions();
+ return useMutation({
+ ...mutationOptions,
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: ['widget'] });
+ mutationOptions.onSuccess?.(data, variables, context);
+ },
+ ...options,
+ });
+};
+
+export const getWidgetFooQueryOptions = (params: GetWidgetFooParams) => {
+ const widgetService = getWidgetService();
+ return queryOptions({
+ queryKey: ['widget', 'getWidgetFoo', params || {}] as const,
+ queryFn: async () => {
+ const res = await widgetService.getWidgetFoo(params);
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res;
+ },
+ });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useQuery } from '@tanstack/react-query';
+ * import { getWidgetFooQueryOptions } from './hooks/widgets';
+ *
+ * // Old pattern (deprecated)
+ * const result = useWidgetFoo(params);
+ *
+ * // New pattern
+ * const result = useQuery(getWidgetFooQueryOptions(params));
+ * ```
+ */
+export const useWidgetFoo = (
+ params: GetWidgetFooParams,
+ options?: Omit<
+ UndefinedInitialDataOptions,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = getWidgetFooQueryOptions(params);
+ return useQuery({ ...defaultOptions, ...options });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useSuspenseQuery } from '@tanstack/react-query';
+ * import { getWidgetFooQueryOptions } from './hooks/widgets';
+ *
+ * // Old pattern (deprecated)
+ * const result = useSuspenseWidgetFoo(params);
+ *
+ * // New pattern
+ * const result = useSuspenseQuery(getWidgetFooQueryOptions(params));
+ * ```
+ */
+export const useSuspenseWidgetFoo = (
+ params: GetWidgetFooParams,
+ options?: Omit<
+ UndefinedInitialDataOptions,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = getWidgetFooQueryOptions(params);
+ return useSuspenseQuery({ ...defaultOptions, ...options });
+};
+
+export const getWidgetsQueryOptions = () => {
+ const widgetService = getWidgetService();
+ return queryOptions({
+ queryKey: ['widget', 'getWidgets', {}] as const,
+ queryFn: async () => {
+ const res = await widgetService.getWidgets();
+ if (res.errors.length) {
+ throw new CompositeError(res.errors);
+ } else if (!res.data) {
+ throw new Error('Unexpected data error: Failed to get example');
+ }
+ return res;
+ },
+ });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useQuery } from '@tanstack/react-query';
+ * import { getWidgetsQueryOptions } from './hooks/widgets';
+ *
+ * // Old pattern (deprecated)
+ * const result = useWidgets(params);
+ *
+ * // New pattern
+ * const result = useQuery(getWidgetsQueryOptions(params));
+ * ```
+ */
+export const useWidgets = (
+ options?: Omit<
+ UndefinedInitialDataOptions,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = getWidgetsQueryOptions();
+ return useQuery({ ...defaultOptions, ...options });
+};
+
+/**
+ * @deprecated This hook is deprecated and will be removed in a future version.
+ * Please use the new query options pattern instead:
+ *
+ * ```typescript
+ * import { useSuspenseQuery } from '@tanstack/react-query';
+ * import { getWidgetsQueryOptions } from './hooks/widgets';
+ *
+ * // Old pattern (deprecated)
+ * const result = useSuspenseWidgets(params);
+ *
+ * // New pattern
+ * const result = useSuspenseQuery(getWidgetsQueryOptions(params));
+ * ```
+ */
+export const useSuspenseWidgets = (
+ options?: Omit<
+ UndefinedInitialDataOptions,
+ 'queryKey' | 'queryFn' | 'select'
+ >,
+) => {
+ const defaultOptions = getWidgetsQueryOptions();
+ return useSuspenseQuery({ ...defaultOptions, ...options });
+};
diff --git a/tsconfig.json b/tsconfig.json
index d8f8bc7..883bf2c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,5 +10,5 @@
"strictNullChecks": true
},
"include": ["src"],
- "exclude": ["**/*.test?.*"]
+ "exclude": ["**/*.test?.*", "src/snapshot/**"]
}