diff --git a/content/docs/charts/index.mdx b/content/docs/charts/index.mdx new file mode 100644 index 0000000..eb44174 --- /dev/null +++ b/content/docs/charts/index.mdx @@ -0,0 +1,270 @@ +--- +title: Charts +description: Reactive ECharts bridge with lazy loading, auto-detection, and typed options for Pyreon. +--- + +`@pyreon/charts` provides a reactive bridge to [Apache ECharts](https://echarts.apache.org/) for Pyreon applications. Chart modules are lazy-loaded on demand -- zero bundle cost until a chart actually renders. The Canvas renderer is used by default, with SVG available as an option. + + + +## Installation + +```package-install +@pyreon/charts +``` + +## Quick Start + +Use the `` component to render a chart. Pass an options function that returns a standard ECharts configuration -- signal reads inside the function are tracked for reactivity. + +```tsx +import { signal } from '@pyreon/reactivity' +import { Chart } from '@pyreon/charts' + +function SalesChart() { + const months = signal(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']) + const revenue = signal([120, 200, 150, 80, 70, 110]) + + return ( + ({ + xAxis: { type: 'category', data: months() }, + yAxis: { type: 'value' }, + tooltip: { trigger: 'axis' }, + series: [{ name: 'Revenue', type: 'bar', data: revenue() }], + })} + style="height: 400px" + /> + ) +} +``` + +The `options` prop accepts a function (not a plain object) so that signal reads are tracked. When any signal inside the function changes, the chart re-renders automatically. + +## API Reference + +### `` + +The primary component for rendering charts. + +| Prop | Type | Description | +|------|------|-------------| +| `options` | `() => EChartsOption` | Function returning ECharts configuration. Signal reads are tracked for reactivity. | +| `style` | `string` | Inline style string. Must include a height (ECharts requires a sized container). | +| `class` | `string` | CSS class name for the container element. | +| `renderer` | `'canvas' \| 'svg'` | Rendering mode. Defaults to `'canvas'`. | +| `onChartReady` | `(instance: ECharts) => void` | Callback fired after the chart instance is initialized. | +| `on*` | Event handlers | ECharts event bindings, e.g. `onClick`, `onMouseover`, `onLegendSelectChanged`. | + +```tsx + ({ /* ... */ })} + style="height: 300px" + renderer="svg" + onClick={(params) => console.log('Clicked:', params.name)} + onChartReady={(instance) => console.log('Chart ready:', instance)} +/> +``` + +### `useChart(optionsFn, config?)` + +A lower-level hook for programmatic control. Returns reactive signals for the chart instance and error state. + +```tsx +import { useChart } from '@pyreon/charts' + +function MyChart() { + const { containerRef, instance, error } = useChart(() => ({ + xAxis: { type: 'category', data: ['A', 'B', 'C'] }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', data: [10, 20, 30] }], + })) + + return ( +
+ {() => error() ?

{error()!.message}

: null} +
containerRef.set(el)} style="height: 400px" /> +
+ ) +} +``` + +**Config options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `renderer` | `'canvas' \| 'svg'` | `'canvas'` | Rendering mode | +| `notMerge` | `boolean` | `false` | Replace options entirely instead of merging | +| `lazyUpdate` | `boolean` | `false` | Defer chart update to next frame | + +**Return value:** + +| Property | Type | Description | +|----------|------|-------------| +| `containerRef` | `Signal` | Bind to a DOM element via `ref` | +| `instance` | `Signal` | The underlying ECharts instance (available after init) | +| `error` | `Signal` | Error signal for init or setOption failures | + +### Types + +`@pyreon/charts` re-exports all ECharts option types for strict typing: + +```tsx +import type { + ComposeOption, + BarSeriesOption, + LineSeriesOption, + PieSeriesOption, + ScatterSeriesOption, + RadarSeriesOption, + HeatmapSeriesOption, + TreemapSeriesOption, + SankeySeriesOption, + GaugeSeriesOption, + FunnelSeriesOption, + CandlestickSeriesOption, + GraphSeriesOption, +} from '@pyreon/charts' +``` + +## Auto-Detection + +When you provide an options function, `@pyreon/charts` inspects the configuration to determine which ECharts modules are needed: + +- **Series types** (`bar`, `line`, `pie`, etc.) are detected from `series[].type` +- **Components** (`tooltip`, `legend`, `dataZoom`, etc.) are detected from top-level keys +- **Axis types** (`category`, `value`, `time`, `log`) are detected from axis config + +Only the required modules are dynamically imported. A chart with `type: 'bar'` and `tooltip` will only load the bar series renderer and tooltip component -- not the full ECharts bundle. + +```tsx +// Only loads: BarChart, TooltipComponent, GridComponent, CanvasRenderer + ({ + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: labels() }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', data: values() }], + })} + style="height: 300px" +/> +``` + +## Strict Typing with ComposeOption + +For type-safe chart configurations, use `ComposeOption<>` to narrow the options type to only the series types you use: + +```tsx +import { useChart } from '@pyreon/charts' +import type { ComposeOption, BarSeriesOption, LineSeriesOption } from '@pyreon/charts' + +type DashboardOption = ComposeOption + +function Dashboard() { + const chart = useChart(() => ({ + xAxis: { type: 'category', data: ['Q1', 'Q2', 'Q3', 'Q4'] }, + yAxis: { type: 'value' }, + series: [ + { type: 'bar', data: [100, 200, 150, 300] }, + { type: 'line', data: [80, 170, 130, 280] }, + ], + })) + + return
chart.containerRef.set(el)} style="height: 400px" /> +} +``` + +This gives you autocomplete and type checking for the specific series options you declared. + +## Manual Registration + +For maximum tree-shaking control, use the `@pyreon/charts/manual` entry point. This disables auto-detection and requires you to register ECharts modules explicitly: + +```tsx +import { useChart, registerModules } from '@pyreon/charts/manual' +import { BarChart, LineChart } from 'echarts/charts' +import { TooltipComponent, GridComponent, LegendComponent } from 'echarts/components' +import { CanvasRenderer } from 'echarts/renderers' + +// Register once at app startup +registerModules([ + BarChart, + LineChart, + TooltipComponent, + GridComponent, + LegendComponent, + CanvasRenderer, +]) + +// Then use useChart / as normal +function MyChart() { + const chart = useChart(() => ({ + series: [{ type: 'bar', data: [1, 2, 3] }], + })) + + return
chart.containerRef.set(el)} style="height: 300px" /> +} +``` + +Use manual registration when you need deterministic bundle sizes or are building a library that should not auto-import ECharts modules. + +## Bundle Size + +| Import | Approximate Size (gzipped) | +|--------|---------------------------| +| `@pyreon/charts` (wrapper only) | ~2 KB | +| + Bar chart | ~15 KB | +| + Line chart | ~18 KB | +| + Pie chart | ~12 KB | +| + Tooltip + Legend | ~8 KB | +| Full ECharts (all modules) | ~300 KB | + +Auto-detection ensures you only pay for what you use. A typical dashboard with 2-3 chart types loads ~40-50 KB of ECharts code. + +## Error Handling + +Both `` and `useChart()` expose an `error` signal that captures initialization and rendering failures: + +```tsx +import { Chart } from '@pyreon/charts' + +function SafeChart() { + return ( + ({ + series: [{ type: 'bar', data: chartData() }], + })} + style="height: 300px" + onError={(err) => console.error('Chart error:', err)} + /> + ) +} +``` + +With `useChart()`, check the error signal directly: + +```tsx +import { useChart } from '@pyreon/charts' + +function SafeChart() { + const { containerRef, error } = useChart(() => ({ + series: [{ type: 'bar', data: chartData() }], + })) + + return ( +
+ {() => error() ? ( +
+

Failed to render chart: {error()!.message}

+
+ ) : null} +
containerRef.set(el)} style="height: 300px" /> +
+ ) +} +``` + +Common error scenarios: +- Container element has zero height (ECharts requires a sized container) +- Invalid option structure passed to `setOption` +- Network failure when lazy-loading ECharts modules diff --git a/content/docs/feature/index.mdx b/content/docs/feature/index.mdx new file mode 100644 index 0000000..bf581d1 --- /dev/null +++ b/content/docs/feature/index.mdx @@ -0,0 +1,670 @@ +--- +title: Feature +description: Schema-driven CRUD primitives with auto-generated hooks for lists, forms, tables, and stores. +--- + +`@pyreon/feature` eliminates CRUD boilerplate by deriving an entire feature's data layer from a single schema definition. Define your entity once with `defineFeature`, and get reactive hooks for listing, searching, creating, updating, and deleting records -- all backed by `@pyreon/query` for server state and `@pyreon/store` for client-side cache. + + + +## Installation + +```package-install +@pyreon/feature +``` + +## Quick Start + +Define a feature with a schema and API configuration, then use the auto-generated hooks in your components: + +```ts +import { defineFeature } from '@pyreon/feature' +import { z } from 'zod' + +const taskFeature = defineFeature({ + name: 'task', + schema: z.object({ + id: z.string(), + title: z.string().min(1), + status: z.enum(['todo', 'in-progress', 'done']), + assignee: z.string().optional(), + dueDate: z.string().optional(), + }), + api: { + baseUrl: '/api/tasks', + }, +}) +``` + +Use the generated hooks in a component: + +```tsx +import { defineComponent } from '@pyreon/core' + +const TaskList = defineComponent(() => { + const { items, isLoading } = taskFeature.useList() + + return () => ( +
+ {isLoading() ? ( +

Loading tasks...

+ ) : ( +
    + {items().map(task => ( +
  • + {task.title} — {task.status} +
  • + ))} +
+ )} +
+ ) +}) +``` + +Add a creation form with a single hook: + +```tsx +const CreateTask = defineComponent(() => { + const { form, handleSubmit, isSubmitting } = taskFeature.useForm({ mode: 'create' }) + + return () => ( +
+ + + +
+ ) +}) +``` + +## `defineFeature` Configuration + +The `defineFeature` function accepts a configuration object that drives all auto-generated hooks: + +```ts +const feature = defineFeature({ + // Required: unique name used as query key prefix and store ID + name: 'task', + + // Required: Zod schema describing the entity shape (including `id`) + schema: z.object({ + id: z.string(), + title: z.string().min(1), + status: z.enum(['todo', 'in-progress', 'done']), + assignee: z.string().optional(), + }), + + // Required: API configuration + api: { + baseUrl: '/api/tasks', + + // Optional: custom fetch headers + headers: () => ({ + Authorization: `Bearer ${getToken()}`, + }), + + // Optional: override individual endpoints + endpoints: { + list: (params) => ({ url: '/api/tasks', method: 'GET', params }), + byId: (id) => ({ url: `/api/tasks/${id}`, method: 'GET' }), + create: (data) => ({ url: '/api/tasks', method: 'POST', body: data }), + update: (id, data) => ({ url: `/api/tasks/${id}`, method: 'PATCH', body: data }), + delete: (id) => ({ url: `/api/tasks/${id}`, method: 'DELETE' }), + search: (query) => ({ url: '/api/tasks/search', method: 'GET', params: { q: query } }), + }, + }, + + // Optional: default page size for pagination + pageSize: 20, +}) +``` + +### Return Value + +`defineFeature` returns an object containing all generated hooks and utilities: + +| Property | Type | Description | +|----------|------|-------------| +| `useList` | `(opts?) => ListResult` | Fetch and display a paginated list of entities | +| `useById` | `(id) => ByIdResult` | Fetch a single entity by ID | +| `useSearch` | `(opts?) => SearchResult` | Search entities with a reactive query signal | +| `useCreate` | `() => CreateResult` | Mutation hook for creating entities | +| `useUpdate` | `(opts?) => UpdateResult` | Mutation hook with optimistic updates | +| `useDelete` | `() => DeleteResult` | Mutation hook for deleting entities | +| `useForm` | `(opts) => FormResult` | Form hook with create/edit modes | +| `useTable` | `(opts?) => TableResult` | Table hook with schema-inferred columns | +| `useStore` | `() => StoreResult` | Reactive client-side cache | +| `schema` | `ZodSchema` | The original schema passed to `defineFeature` | +| `name` | `string` | The feature name | + +## Hooks + +### `useList` + +Fetches a paginated list of entities. Returns reactive signals for the items, loading state, and pagination controls: + +```tsx +const TaskList = defineComponent(() => { + const { items, isLoading, error, page, pageSize, totalPages, nextPage, prevPage } = + taskFeature.useList({ pageSize: 10 }) + + return () => ( +
+ {isLoading() &&

Loading...

} + {error() &&

Error: {error().message}

} + +
    + {items().map(task => ( +
  • {task.title}
  • + ))} +
+ +
+ + Page {page()} of {totalPages()} + +
+
+ ) +}) +``` + +### `useById` + +Fetches a single entity by ID. The ID can be a reactive signal: + +```tsx +const TaskDetail = defineComponent((props: { id: string }) => { + const { data, isLoading, error } = taskFeature.useById(props.id) + + return () => ( +
+ {isLoading() ? ( +

Loading...

+ ) : error() ? ( +

Error: {error().message}

+ ) : ( +
+

{data().title}

+

Status: {data().status}

+

Assignee: {data().assignee ?? 'Unassigned'}

+
+ )} +
+ ) +}) +``` + +### `useSearch` + +Provides a reactive search query signal with debounced fetching: + +```tsx +const TaskSearch = defineComponent(() => { + const { query, results, isSearching } = taskFeature.useSearch({ debounceMs: 300 }) + + return () => ( +
+ query.set(e.target.value)} + placeholder="Search tasks..." + /> + + {isSearching() &&

Searching...

} + +
    + {results().map(task => ( +
  • {task.title}
  • + ))} +
+
+ ) +}) +``` + +### `useCreate` + +Mutation hook for creating new entities. Automatically invalidates the list query on success: + +```tsx +const { mutate, isSubmitting, error } = taskFeature.useCreate() + +await mutate({ + title: 'New Task', + status: 'todo', +}) +``` + +### `useUpdate` + +Mutation hook for updating entities with optimistic update support: + +```tsx +const { mutate, isSubmitting, error } = taskFeature.useUpdate() + +await mutate({ + id: 'task-1', + title: 'Updated Title', +}) +``` + +See the [Optimistic Updates](#optimistic-updates) section for details on how updates are applied immediately. + +### `useDelete` + +Mutation hook for deleting entities. Removes the entity from the cache on success: + +```tsx +const { mutate, isSubmitting } = taskFeature.useDelete() + +await mutate('task-1') +``` + +## Edit Form + +`useForm` supports two modes: `create` and `edit`. In edit mode, the form auto-fetches the entity by ID and populates the fields: + +```tsx +const EditTask = defineComponent((props: { id: string }) => { + const { form, handleSubmit, isSubmitting, isLoadingInitial } = taskFeature.useForm({ + mode: 'edit', + id: props.id, + }) + + return () => { + if (isLoadingInitial()) return

Loading task...

+ + return ( +
+ + + + + + + +
+ ) + } +}) +``` + +When the form is in `edit` mode: + +1. `useById` is called internally with the provided `id`. +2. Once the data arrives, the form's `initialValues` are populated from the response. +3. `isLoadingInitial()` is `true` until the fetch completes. +4. On submit, `useUpdate` is called instead of `useCreate`. + +### Create vs Edit Summary + +| Behavior | `mode: 'create'` | `mode: 'edit'` | +|----------|-------------------|----------------| +| Initial values | From schema defaults | Auto-fetched by ID | +| Submit action | `useCreate` | `useUpdate` | +| Auto-fetch | No | Yes (`useById`) | +| `isLoadingInitial` | Always `false` | `true` until fetched | + +## Pagination + +Every `useList` call returns pagination signals and controls. The page signal is reactive -- changing it automatically refetches: + +```tsx +const PaginatedTasks = defineComponent(() => { + const { items, page, pageSize, totalPages, nextPage, prevPage, goToPage } = + taskFeature.useList({ pageSize: 25 }) + + return () => ( +
+
    + {items().map(task => ( +
  • {task.title}
  • + ))} +
+ + + +

{pageSize()} items per page

+
+ ) +}) +``` + +| Signal / Method | Type | Description | +|-----------------|------|-------------| +| `page` | `Signal` | Current page number (1-indexed) | +| `pageSize` | `Signal` | Items per page | +| `totalPages` | `Computed` | Total number of pages | +| `nextPage()` | `() => void` | Increment page by 1 | +| `prevPage()` | `() => void` | Decrement page by 1 | +| `goToPage(n)` | `(n: number) => void` | Jump to a specific page | + +## Optimistic Updates + +`useUpdate` applies changes to the local cache immediately, before the server responds. If the server request fails, the change is rolled back: + +```tsx +const TaskToggle = defineComponent((props: { task: Task }) => { + const { mutate } = taskFeature.useUpdate({ optimistic: true }) + + const toggle = async () => { + const nextStatus = props.task.status === 'done' ? 'todo' : 'done' + + await mutate({ + id: props.task.id, + status: nextStatus, + }) + // UI updates instantly. If the request fails, it reverts. + } + + return () => ( + + ) +}) +``` + +### How Optimistic Updates Work + +1. The mutation payload is merged into the cached entity immediately via `useStore`. +2. The list query cache is updated to reflect the change. +3. The server request is sent in the background. +4. On success, the cache is replaced with the server's response (which may include server-computed fields). +5. On failure, the cache is rolled back to the previous state and the `error` signal is set. + +Optimistic updates are enabled by default for `useUpdate`. Pass `{ optimistic: false }` to disable them: + +```ts +const { mutate } = taskFeature.useUpdate({ optimistic: false }) +``` + +## References + +Use `reference()` to declare foreign key relationships between features. This enables automatic resolution and nested data fetching: + +```ts +import { defineFeature, reference } from '@pyreon/feature' + +const userFeature = defineFeature({ + name: 'user', + schema: z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + }), + api: { baseUrl: '/api/users' }, +}) + +const taskFeature = defineFeature({ + name: 'task', + schema: z.object({ + id: z.string(), + title: z.string(), + status: z.enum(['todo', 'in-progress', 'done']), + assigneeId: reference(userFeature), + }), + api: { baseUrl: '/api/tasks' }, +}) +``` + +When a field uses `reference()`, the feature knows how to resolve the related entity: + +```tsx +const TaskWithAssignee = defineComponent((props: { task: Task }) => { + const { data: assignee } = userFeature.useById(props.task.assigneeId) + + return () => ( +
+

{props.task.title}

+

Assigned to: {assignee()?.name ?? 'Loading...'}

+
+ ) +}) +``` + +`reference()` also provides metadata for table columns and form fields -- a referenced field renders as a select/autocomplete by default, populated from the related feature's `useList`. + +## Schema Introspection + +`@pyreon/feature` can introspect the schema to extract field metadata. This powers automatic table column generation, form field rendering, and default value computation. + +### `extractFields` + +Returns an array of `FieldInfo` objects describing each field in the schema: + +```ts +import { extractFields } from '@pyreon/feature' + +const fields = extractFields(taskFeature.schema) + +// [ +// { name: 'id', type: 'string', required: true, enumValues: undefined }, +// { name: 'title', type: 'string', required: true, enumValues: undefined }, +// { name: 'status', type: 'enum', required: true, enumValues: ['todo', 'in-progress', 'done'] }, +// { name: 'assignee', type: 'string', required: false, enumValues: undefined }, +// { name: 'dueDate', type: 'string', required: false, enumValues: undefined }, +// ] +``` + +### `FieldInfo` + +The shape returned by `extractFields` for each field: + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | Field name from the schema | +| `type` | `string` | Inferred type: `'string'`, `'number'`, `'boolean'`, `'enum'`, `'date'`, `'array'`, `'object'` | +| `required` | `boolean` | Whether the field is required | +| `enumValues` | `string[] \| undefined` | Possible values for enum fields | +| `defaultValue` | `unknown \| undefined` | Default value if defined in the schema | +| `reference` | `FeatureRef \| undefined` | Reference metadata if the field uses `reference()` | + +### `defaultInitialValues` + +Computes initial form values from the schema, using schema defaults and type-appropriate fallbacks: + +```ts +import { defaultInitialValues } from '@pyreon/feature' + +const initial = defaultInitialValues(taskFeature.schema) +// { id: '', title: '', status: 'todo', assignee: undefined, dueDate: undefined } +``` + +This is what `useForm({ mode: 'create' })` uses internally to populate the form's initial state. + +## `useTable` + +`useTable` generates table columns from the schema and wires up data fetching. It returns a configured `@pyreon/table` instance: + +```tsx +const TaskTable = defineComponent(() => { + const { table, isLoading } = taskFeature.useTable({ + columns: { + // Override specific columns + title: { header: 'Task Name', size: 300 }, + status: { + header: 'Status', + cell: (info) => {info.getValue()}, + }, + // Exclude columns + id: false, + }, + }) + + return () => ( +
+ {isLoading() ? ( +

Loading...

+ ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ))} + +
{flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ) +}) +``` + +## `useStore` + +`useStore` provides direct access to the feature's reactive client-side cache. It exposes signals for the entity list, selected item, and loading states: + +```tsx +const TaskDashboard = defineComponent(() => { + const { items, selected, loading, select, clear } = taskFeature.useStore() + + return () => ( +
+

{items().length} tasks loaded

+

Loading: {loading() ? 'Yes' : 'No'}

+ + {selected() && ( +
+

Selected: {selected().title}

+ +
+ )} +
+ ) +}) +``` + +| Signal / Method | Type | Description | +|-----------------|------|-------------| +| `items` | `Signal` | All cached entities | +| `selected` | `Signal` | Currently selected entity | +| `loading` | `Signal` | Whether any query is in flight | +| `select(item)` | `(item: T) => void` | Set the selected entity | +| `clear()` | `() => void` | Clear the selection | + +## Integration with Other Packages + +`@pyreon/feature` is a composition layer that builds on top of several Pyreon fundamentals packages. You can use them directly when you need more control. + +### With `@pyreon/query` + +All data fetching hooks (`useList`, `useById`, `useSearch`, `useCreate`, `useUpdate`, `useDelete`) are thin wrappers around `@pyreon/query`. You can access the underlying query options: + +```ts +import { useQuery } from '@pyreon/query' + +// Use the feature's query key factory for custom queries +const customQuery = useQuery({ + queryKey: [taskFeature.name, 'custom', { status: 'overdue' }], + queryFn: () => fetch('/api/tasks/overdue').then(r => r.json()), +}) +``` + +### With `@pyreon/form` + +`useForm` wraps `@pyreon/form`'s `useForm` with schema-derived validation and automatic initial values. You can pass any `@pyreon/form` option through: + +```ts +const { form } = taskFeature.useForm({ + mode: 'create', + validateOn: 'blur', + debounceMs: 200, +}) +``` + +### With `@pyreon/table` + +`useTable` wraps `@pyreon/table`'s `useTable` with schema-inferred column definitions. Pass additional TanStack Table options through: + +```ts +const { table } = taskFeature.useTable({ + enableSorting: true, + enableFiltering: true, + manualPagination: true, +}) +``` + +### With `@pyreon/validation` + +Schema validation uses `@pyreon/validation` adapters internally. The schema passed to `defineFeature` is automatically wrapped with the appropriate adapter (Zod, Valibot, or ArkType): + +```ts +import { z } from 'zod' + +// Zod schemas work out of the box +const feature = defineFeature({ + name: 'task', + schema: z.object({ /* ... */ }), + api: { baseUrl: '/api/tasks' }, +}) +``` + +### With `@pyreon/store` + +The feature's `useStore` is built on `@pyreon/store`'s `defineStore`. You can compose it with other stores: + +```ts +import { defineStore, signal, computed } from '@pyreon/store' + +const useDashboard = defineStore('dashboard', () => { + const tasks = taskFeature.useStore() + const users = userFeature.useStore() + + const assignedTaskCount = computed(() => + tasks.items().filter(t => t.assigneeId != null).length + ) + + return { tasks, users, assignedTaskCount } +}) +``` + +## Why + +A typical CRUD feature in a modern frontend requires list queries, detail queries, search, create/update/delete mutations, forms with validation, table columns, pagination, optimistic updates, and cache management. Written by hand, each feature requires approximately 200 lines of repetitive wiring code. + +`defineFeature` replaces that with roughly 10 lines. You declare the schema and the API base URL. The package generates every hook, infers form fields and table columns from the schema, handles optimistic updates, manages pagination signals, and keeps the client-side cache in sync. + +This is especially valuable in AI-assisted development workflows. Instead of generating 200 lines of boilerplate that must be reviewed line by line, an LLM produces a single `defineFeature` call. The generated code is declarative, auditable at a glance, and guaranteed to follow consistent patterns across every feature in your application.