npm package: @mehdashti/data-client
Description: TanStack Query adapter with error mapping and retry logic for Smart Platform
Author: mehdashti
Latest version: 0.4.2

# @smart/data-client

> TanStack Query adapter with error mapping and retry logic for Smart Platform

## Installation

```bash
pnpm add @smart/data-client @tanstack/react-query
```

## Features

- ✅ **Type-safe Query Keys**: Factory functions for consistent query key patterns
- ✅ **Error Mapping**: Automatic mapping from @smart/contracts ErrorResponse
- ✅ **Retry Logic**: Smart retry behavior (5xx, 429, network errors)
- ✅ **Custom Hooks**: useApiQuery, useApiMutation, usePaginatedQuery
- ✅ **Query Client**: Pre-configured QueryClient with platform defaults

## Quick Start

### 1. Setup QueryClient

```typescript
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@smart/data-client";

const queryClient = createQueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}
```

### 2. Use in Components

```typescript
import { useApiQuery, apiFetch, queryKeys } from "@smart/data-client";

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading } = useApiQuery({
    queryKey: queryKeys.users.detail(userId),
    queryFn: () => apiFetch(`/api/users/${userId}`),
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <div>{data.name}</div>;
}
```

## Query Keys

Type-safe query key factories for consistent patterns:

```typescript
import { createQueryKeyFactory, queryKeys } from "@smart/data-client";

// Built-in factories
queryKeys.users.all; // ["users"]
queryKeys.users.lists(); // ["users", "list"]
queryKeys.users.list({ role: "admin" }); // ["users", "list", { role: "admin" }]
queryKeys.users.details(); // ["users", "detail"]
queryKeys.users.detail(123); // ["users", "detail", 123]

// Create custom factory
const productKeys = createQueryKeyFactory("products");
productKeys.all; // ["products"]
productKeys.list({ category: "electronics" }); // ["products", "list", { category: "electronics" }]
```

## Hooks

### useApiQuery

Wrapper around `useQuery` with automatic error mapping:

```typescript
import { useApiQuery, apiFetch } from "@smart/data-client";

const { data, error, isLoading } = useApiQuery({
  queryKey: ["users", userId],
  queryFn: () => apiFetch<User>(`/api/users/${userId}`),
});

// error is automatically typed as ApiError
if (error) {
  console.log(error.response.detail); // ErrorResponse detail
  console.log(error.status); // HTTP status code
}
```

### useApiMutation

Wrapper around `useMutation` with automatic error mapping:

```typescript
import { useApiMutation, apiMutate } from "@smart/data-client";
import { useQueryClient } from "@tanstack/react-query";

const queryClient = useQueryClient();

const mutation = useApiMutation({
  mutationFn: (data: CreateUserData) =>
    apiMutate<User, CreateUserData>("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    }),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["users"] });
  },
});

// Use the mutation
mutation.mutate({ name: "John", email: "john@example.com" });
```

### usePaginatedQuery

Helper for paginated queries with Smart Platform pagination contract:

```typescript
import { usePaginatedQuery } from "@smart/data-client";

const { data, isLoading } = usePaginatedQuery<User>({
  queryKey: ["users", { page, page_size }],
  endpoint: "/api/users",
  params: {
    page,
    page_size: 20,
    role: "admin",
  },
});

// data.data contains the array of users
// data.meta contains pagination metadata
console.log(data.data); // User[]
console.log(data.meta); // { page, page_size, total_items, total_pages }
```

## Error Mapping

Utilities for working with API errors:

```typescript
import {
  isApiError,
  getErrorMessage,
  getFieldErrors,
  getFieldError,
  isValidationError,
  isAuthError,
} from "@smart/data-client";

// Check error type
if (isApiError(error)) {
  console.log(error.response.type); // "validation_error", "not_found", etc.
}

// Get error message
const message = getErrorMessage(error); // Works with any error type

// Handle validation errors
if (isValidationError(error)) {
  const fieldErrors = getFieldErrors(error);
  fieldErrors.forEach((e) => {
    console.log(`${e.field}: ${e.message}`);
  });

  // Get specific field error
  const emailError = getFieldError(error, "email");
}

// Check specific error types
if (isAuthError(error)) {
  // Redirect to login
}
```

## Retry Logic

Automatic retry with exponential backoff:

- **Retries**: Network errors, 5xx server errors, 429 rate limit
- **No retry**: 4xx client errors, auth errors, validation errors
- **Max retries**: 3 attempts
- **Backoff**: 1s, 2s, 4s (exponential)

```typescript
import { shouldRetry, getRetryDelay, retryConfig } from "@smart/data-client";

// Use in custom QueryClient
import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: retryConfig,
  },
});

// Or customize
const customClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        // Custom logic
        if (failureCount > 5) return false;
        return shouldRetry(failureCount, error);
      },
      retryDelay: getRetryDelay,
    },
  },
});
```

## Query Client Configuration

The `createQueryClient` function provides smart defaults:

```typescript
import { createQueryClient } from "@smart/data-client";

const queryClient = createQueryClient({
  // Override defaults
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes instead of 1
    },
  },
});
```

**Default configuration:**

- `staleTime`: 1 minute
- `gcTime`: 5 minutes (garbage collection)
- `refetchOnWindowFocus`: false
- `retry`: Smart retry logic (5xx, 429, network)
- `retryDelay`: Exponential backoff (1s, 2s, 4s)

## Examples

### Fetching a List

```typescript
import { useApiQuery, apiFetch, queryKeys } from "@smart/data-client";

function UserList() {
  const { data, error, isLoading } = useApiQuery({
    queryKey: queryKeys.users.list(),
    queryFn: () => apiFetch<User[]>("/api/users"),
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
```

### Creating a Resource

```typescript
import { useApiMutation, apiMutate } from "@smart/data-client";
import { useQueryClient } from "@tanstack/react-query";

function CreateUserForm() {
  const queryClient = useQueryClient();

  const mutation = useApiMutation({
    mutationFn: (data: CreateUserData) =>
      apiMutate<User>("/api/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });

  const handleSubmit = (data: CreateUserData) => {
    mutation.mutate(data);
  };

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      handleSubmit({ name: "John", email: "john@example.com" });
    }}>
      {mutation.error && (
        <div className="error">
          {getErrorMessage(mutation.error)}
        </div>
      )}
      <button type="submit" disabled={mutation.isPending}>
        Create User
      </button>
    </form>
  );
}
```

### Pagination with Filters

```typescript
import { usePaginatedQuery } from "@smart/data-client";
import { useState } from "react";

function UserTable() {
  const [page, setPage] = useState(1);
  const [role, setRole] = useState<string | undefined>();

  const { data, isLoading } = usePaginatedQuery<User>({
    queryKey: ["users", { page, role }],
    endpoint: "/api/users",
    params: {
      page,
      page_size: 20,
      role,
    },
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <select onChange={(e) => setRole(e.target.value || undefined)}>
        <option value="">All roles</option>
        <option value="admin">Admin</option>
        <option value="viewer">Viewer</option>
      </select>

      <table>
        <tbody>
          {data.data.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <div>
        <button
          onClick={() => setPage(page - 1)}
          disabled={page === 1}
        >
          Previous
        </button>
        <span>Page {data.meta.page} of {data.meta.total_pages}</span>
        <button
          onClick={() => setPage(page + 1)}
          disabled={page >= data.meta.total_pages}
        >
          Next
        </button>
      </div>
    </div>
  );
}
```

## Correlation IDs & Request Tracing

Correlation IDs allow you to trace related requests across frontend and backend services. All API calls automatically include a correlation ID in the `X-Correlation-Id` header.

### Basic Usage (Automatic)

By default, each API call gets a unique correlation ID:

```typescript
import { apiFetch } from "@smart/data-client";

// Each call gets a unique correlation ID
await apiFetch("/api/users/123");
await apiFetch("/api/posts/456");
// ☝️ These have different correlation IDs
```

### Context-Aware Correlation (Recommended)

For better tracing, use `CorrelationProvider` to share correlation IDs across related requests:

```typescript
import { CorrelationProvider } from "@smart/data-client";

function App() {
  return (
    <CorrelationProvider>
      <QueryClientProvider client={queryClient}>
        <YourApp />
      </QueryClientProvider>
    </CorrelationProvider>
  );
}
```

### Using Correlation Scopes

**Option 1: Manual Scope Control**

```typescript
import { useCorrelation } from "@smart/data-client";

function SaveUserButton({ userData }) {
  const correlation = useCorrelation();

  const handleSave = async () => {
    // Begin correlation scope
    correlation?.beginCorrelation();

    try {
      // All these requests share the same correlation ID
      await createUser(userData);
      await uploadAvatar(userData.avatar);
      await sendWelcomeEmail(userData.email);
    } finally {
      // End correlation scope
      correlation?.endCorrelation();
    }
  };

  return <button onClick={handleSave}>Save User</button>;
}
```

**Option 2: Automatic Scope (Recommended)**

```typescript
import { useCorrelationScope } from "@smart/data-client";

function SaveUserButton({ userData }) {
  const withCorrelation = useCorrelationScope();

  // Automatically manages correlation scope
  const handleSave = withCorrelation(async (data) => {
    // All these requests share the same correlation ID
    await createUser(data);
    await uploadAvatar(data.avatar);
    await sendWelcomeEmail(data.email);
  });

  return <button onClick={() => handleSave(userData)}>Save User</button>;
}
```

### Benefits of Correlation Scopes

1. **Better Debugging**: Trace all related requests in your logs
2. **Error Tracking**: Group related errors together
3. **Performance Monitoring**: Measure end-to-end operation time
4. **Distributed Tracing**: Track requests across microservices

### Example: Form Submission with Correlation

```typescript
import { useCorrelationScope, useApiMutation } from "@smart/data-client";
import { useSmartForm } from "@mehdashti/forms";

function CreateOrderForm() {
  const withCorrelation = useCorrelationScope();

  const form = useSmartForm({
    schema: orderSchema,
    onSubmit: withCorrelation(async (data) => {
      // These requests share the same correlation ID:
      const order = await createOrder(data);
      await processPayment(order.id, data.payment);
      await sendOrderConfirmation(order.id);
      await updateInventory(data.items);
      // In logs, you can trace all 4 requests together
    }),
  });

  return <form>...</form>;
}
```

### Without CorrelationProvider

If you don't wrap your app in `CorrelationProvider`, the system falls back to generating a unique ID for each request (backward compatible behavior).

## Request Timeout & Cancellation

Control request timeouts and cancel requests using AbortSignal.

### Timeout

Set a timeout for requests that should not exceed a certain duration:

```typescript
import { apiFetch } from "@smart/data-client";

// Abort after 5 seconds
const user = await apiFetch<User>("/api/users/123", {
  timeout: 5000
});
```

### Manual Cancellation

Cancel requests manually using AbortController:

```typescript
import { apiFetch } from "@smart/data-client";

const controller = new AbortController();

// Start request
const promise = apiFetch<User>("/api/users/123", {
  signal: controller.signal
});

// Cancel it
controller.abort();

try {
  await promise;
} catch (error) {
  console.log("Request was aborted");
}
```

### Timeout + Manual Cancellation

Combine timeout with manual cancellation:

```typescript
import { apiFetch } from "@smart/data-client";

const controller = new AbortController();

const user = await apiFetch<User>("/api/users/123", {
  timeout: 10000, // Auto-abort after 10 seconds
  signal: controller.signal // Also allow manual abort
});

// Can still manually abort before timeout
controller.abort();
```

### Utilities

```typescript
import { createTimeoutSignal, combineAbortSignals } from "@smart/data-client";

// Create a timeout signal
const timeoutSignal = createTimeoutSignal(5000);
await apiFetch("/api/users", { signal: timeoutSignal });

// Combine multiple signals
const userSignal = new AbortController().signal;
const timeoutSignal = createTimeoutSignal(10000);
const combined = combineAbortSignals([userSignal, timeoutSignal]);
await apiFetch("/api/users", { signal: combined });
```

### With TanStack Query

TanStack Query automatically provides an AbortSignal for query cancellation:

```typescript
import { useApiQuery, apiFetch } from "@smart/data-client";

const { data } = useApiQuery({
  queryKey: ["users", userId],
  queryFn: ({ signal }) => apiFetch(`/api/users/${userId}`, {
    signal, // TanStack Query's signal
    timeout: 5000 // Add timeout
  })
});
```

## Type Safety

All exports are fully typed for TypeScript:

```typescript
import type {
  ApiError,
  QueryKeyFactory,
  PaginationParams,
  SortParams,
  FilterParams,
  QueryParams,
} from "@smart/data-client";
```

## License

MIT