pyreon

@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, fetching, searching, creating, updating, and deleting records — all composed from @pyreon/query (server state), @pyreon/form (forms + validation), @pyreon/table (tables), and @pyreon/store (client-side cache).

@pyreon/featurestable

Installation

npm install @pyreon/feature
bun add @pyreon/feature
pnpm add @pyreon/feature
yarn add @pyreon/feature

@pyreon/feature composes five Pyreon packages under the hood — @pyreon/query, @pyreon/form, @pyreon/validation, @pyreon/store, and @pyreon/table. They are declared as dependencies, so a single install pulls them in. You also need a validation library for your schema (zod, valibot, or arktype).

Quick Start

Define a feature with a validation schema and an API base path, then use the auto-generated hooks in your components:

import { defineFeature } from '@pyreon/feature'
import { z } from 'zod'

const tasks = defineFeature({
  name: 'tasks',
  schema: z.object({
    title: z.string().min(1),
    status: z.enum(['todo', 'in-progress', 'done']),
    assignee: z.string().optional(),
    dueDate: z.string().optional(),
  }),
  api: '/api/tasks',
})

Use the generated query hook in a component. useList returns @pyreon/query's UseQueryResultdata, isLoading, error, etc. are all reactive signals you call:

function TaskList() {
  const { data, isLoading, error } = tasks.useList()

  return () => (
    <div>
      {isLoading() && <p>Loading tasks…</p>}
      {error() && <p>Error: {error()?.message}</p>}
      <For each={() => data() ?? []} by={(t) => t.id}>
        {(task) => <li>{task.title}{task.status}</li>}
      </For>
    </div>
  )
}

Add a creation form with a single hook. useForm returns @pyreon/form's FormState directly — register, handleSubmit, and isSubmitting live on it:

function CreateTask() {
  const form = tasks.useForm()

  return () => (
    <form onSubmit={form.handleSubmit}>
      <input {...form.register('title')} placeholder="Task title" />
      <button type="submit" disabled={form.isSubmitting()}>Create</button>
    </form>
  )
}
defineFeature — CRUD from a schema

defineFeature Configuration

defineFeature(config) accepts a FeatureConfig object and returns a Feature with the generated hooks and metadata:

const tasks = defineFeature({
  // Required: unique name — used as the @pyreon/store ID and query-key namespace
  name: 'tasks',

  // Required: a validation schema. Validation works for Zod AND any Standard
  // Schema (Valibot / ArkType / modern Zod / `@pyreon/validate`'s `s`).
  // NOTE: field INTROSPECTION (auto form fields, table columns, create-form
  // defaults) is Zod-only — with a Valibot/ArkType schema you must supply
  // `initialValues` + table `columns` explicitly. Zod's `_output` drives
  // TypeScript inference of the entity type.
  schema: z.object({
    title: z.string().min(1),
    status: z.enum(['todo', 'in-progress', 'done']),
  }),

  // Required: REST base path (a STRING, not a config object)
  api: '/api/tasks',

  // Optional: override schema-derived create-form defaults
  initialValues: { status: 'todo' },

  // Optional: custom schema-level validation (overrides auto-detection)
  validate: myValidateFn,

  // Optional: custom fetch implementation (defaults to global fetch)
  fetcher: myAuthedFetch,
})

FeatureConfig

PropertyTypeRequiredDescription
namestringYesUnique feature name. Used as the @pyreon/store ID and the query-key namespace.
schemavalidation schemaYesZod, or any Standard Schema (Valibot / ArkType / s) — see Validators & introspection. Zod's _output infers the entity type.
apistringYesREST base path, e.g. '/api/tasks'. Endpoint URLs derive from RESTful conventions.
initialValuesPartial<TValues>NoOverride the schema-derived defaults used to seed useForm create-mode.
validateSchemaValidateFn<TValues>NoCustom validation function. When provided, replaces auto-detection from the schema.
fetchertypeof fetchNoCustom fetch (auth headers, base URL rewriting, mocking). Defaults to global fetch.

Return Value

defineFeature returns a Feature<TValues> object:

PropertyTypeDescription
namestringThe feature name passed in.
apistringThe API base path passed in.
schemaunknownThe original schema (untyped — passed through verbatim).
fieldsFieldInfo[]Schema fields introspected at definition time.
queryKey(suffix?: string | number) => QueryKeyNamespaced query-key factory: ['tasks'] / ['tasks', 5].
useList(opts?: ListOptions) => UseQueryResult<TValues[]>Fetch a list (optionally paginated / filtered).
useById(id: string | number) => UseQueryResult<TValues>Fetch one entity by ID.
useSearch(term: Signal<string>, opts?: ListOptions) => UseQueryResult<TValues[]>Reactive search keyed on a signal.
useCreate() => UseMutationResult<TValues, unknown, Partial<TValues>>Create mutation (POST).
useUpdate() => UseMutationResult<TValues, unknown, { id; data }>Update mutation (PUT) with optimistic cache updates.
useDelete() => UseMutationResult<void, unknown, string | number>Delete mutation (DELETE).
useForm(opts?: FeatureFormOptions) => FormState<TValues>Form with create / edit modes + schema validation.
useTable(data, opts?: FeatureTableOptions) => FeatureTableResult<TValues>Table with schema-inferred columns.
useStore() => StoreApi<FeatureStore<TValues>>Reactive client-side cache.

Validators & introspection

defineFeature uses the schema for two independent jobs, and they have different validator coverage:

JobWhat it powersValidator support
ValidationuseForm submit/blur validationZod AND any Standard Schema — Valibot, ArkType (its callable schema included), modern Zod, @pyreon/validate's s. A raw schema is routed through standardSchemaToValidator; errors land on the right field.
Field introspectionfeature.fields, auto create-form initialValues, auto useTable columnsZod only. extractFields reads Zod's _def.shape / _zod.def.shape. There is no cross-library shape-introspection standard, so a Valibot / ArkType schema yields no fields.

The query hooks (useList, useById, useSearch, useCreate, useUpdate, useDelete) and useStore are schema-agnostic — they only touch api and the row type, so every validator works with them.

API Conventions

api is a base path. Each hook derives its endpoint URL from RESTful conventions:

HookMethodURL
useListGET/api/tasks
useByIdGET/api/tasks/:id
useSearchGET/api/tasks?q=…
useCreatePOST/api/tasks
useUpdatePUT/api/tasks/:id
useDeleteDELETE/api/tasks/:id

useForm submits via POST /api/tasks in create mode and PUT /api/tasks/:id in edit mode. Responses are parsed as JSON; a 204 No Content resolves to undefined. A non-2xx response throws an Error carrying status and (when the body has an errors field) an errors property.

The built-in fetcher sends Content-Type: application/json and JSON-stringifies the body for POST / PUT. To add auth headers or rewrite URLs, pass a custom fetcher:

const tasks = defineFeature({
  name: 'tasks',
  schema: taskSchema,
  api: '/api/tasks',
  fetcher: (url, init) =>
    fetch(url, {
      ...init,
      headers: { ...init?.headers, Authorization: `Bearer ${getToken()}` },
    }),
})

Query Hooks

useList

Fetches a list of entities. Returns @pyreon/query's UseQueryResult — every field is a reactive signal you call:

function TaskList() {
  const { data, isLoading, isError, error, refetch } = tasks.useList()

  return () => (
    <div>
      {isLoading() && <p>Loading…</p>}
      {isError() && <p>Error: {error()?.message}</p>}
      <ul>
        <For each={() => data() ?? []} by={(t) => t.id}>
          {(task) => <li>{task.title}</li>}
        </For>
      </ul>
      <button onClick={() => refetch()}>Reload</button>
    </div>
  )
}

UseQueryResult exposes result, data, error, status, isPending, isLoading, isFetching, isError, isSuccess, and refetch().

ListOptions

useList (and useSearch) accept a ListOptions object:

OptionTypeDescription
paramsRecord<string, string | number | boolean>Extra query params appended to the URL.
pagenumber | Signal<number>Page number. When set, page + pageSize are appended to the query params.
pageSizenumberItems per page. Defaults to 20 when page is provided.
staleTimenumberOverride the query's stale time.
enabledbooleanEnable / disable the query.
function PaginatedTasks() {
  const page = signal(1)
  const { data, isFetching } = tasks.useList({ page, pageSize: 25 })

  return () => (
    <div>
      <For each={() => data() ?? []} by={(t) => t.id}>
        {(task) => <li>{task.title}</li>}
      </For>
      <button onClick={() => page.update((p) => Math.max(1, p - 1))} disabled={page() <= 1}>
        Previous
      </button>
      <span>Page {page()}{isFetching() && ' …'}</span>
      <button onClick={() => page.update((p) => p + 1)}>Next</button>
    </div>
  )
}

Filter by passing params:

const { data } = tasks.useList({ params: { status: 'todo', sort: 'dueDate' } })
// → GET /api/tasks?status=todo&sort=dueDate

useById

Fetches a single entity by ID. The query is disabled when the id is null / undefined:

function TaskDetail(props: { id: string }) {
  const { data, isLoading, error } = tasks.useById(props.id)

  return () => (
    <div>
      {isLoading() ? (
        <p>Loading…</p>
      ) : error() ? (
        <p>Error: {error()?.message}</p>
      ) : (
        <div>
          <h2>{data()?.title}</h2>
          <p>Status: {data()?.status}</p>
        </div>
      )}
    </div>
  )
}

useSearch

Reactive search keyed on a Signal<string>. The query is disabled while the term is empty, and the term is sent as the q query param:

function TaskSearch() {
  const term = signal('')
  const { data, isFetching } = tasks.useSearch(term)

  return () => (
    <div>
      <input value={term()} onInput={(e) => term.set(e.currentTarget.value)} placeholder="Search…" />
      {isFetching() && <p>Searching…</p>}
      <For each={() => data() ?? []} by={(t) => t.id}>
        {(task) => <li>{task.title}</li>}
      </For>
    </div>
  )
}

useSearch reads the signal inside the query key, so every keystroke that mutates term refetches. It accepts a second ListOptions argument for extra params / staleTime.

Mutations

The three mutation hooks return @pyreon/query's UseMutationResult: mutate (fire-and-forget), mutateAsync (promise), isPending, isSuccess, isError, error, data, and reset.

useCreate

POSTs to the API. On success it invalidates the list query so any useList / useSearch view refetches:

function CreateButton() {
  const { mutate, isPending, error } = tasks.useCreate()

  return () => (
    <div>
      <button
        disabled={isPending()}
        onClick={() => mutate({ title: 'New task', status: 'todo' })}
      >
        {isPending() ? 'Creating…' : 'Create'}
      </button>
      {error() && <p>Failed: {error()?.message}</p>}
    </div>
  )
}

For try/catch, use mutateAsync:

const { mutateAsync } = tasks.useCreate()
try {
  const created = await mutateAsync({ title: 'New task', status: 'todo' })
} catch (err) {
  // handle failure
}

useUpdate

PUTs to /:id with optimistic cache updates. The mutation variable is { id, data }:

function ToggleStatus(props: { task: Task }) {
  const { mutate } = tasks.useUpdate()

  const toggle = () =>
    mutate({
      id: props.task.id,
      data: { status: props.task.status === 'done' ? 'todo' : 'done' },
    })

  return () => <button onClick={toggle}>Toggle</button>
}

How the optimistic update works

Optimism is built in — there is no opt-out flag. On mutate({ id, data }):

  1. onMutate cancels any in-flight useById(id) query and snapshots the current cached entity.

  2. The cached entity at [name, id] is merged with data immediately, so the UI updates before the server responds.

  3. If the request fails, onError restores the snapshot — the optimistic change is rolled back.

  4. On success, both the list query and the [name, id] query are invalidated, so they refetch the server's authoritative value (including any server-computed fields).

useDelete

DELETEs /:id. The mutation variable is the id directly. Invalidates the list on success:

function DeleteButton(props: { id: string }) {
  const { mutate, isPending } = tasks.useDelete()
  return () => (
    <button disabled={isPending()} onClick={() => mutate(props.id)}>
      Delete
    </button>
  )
}

Forms

useForm returns @pyreon/form's FormState directly, pre-wired with schema validation (via @pyreon/validation), schema-derived initial values, and an API-submitting onSubmit. Spread form.register(field) onto inputs and bind form.handleSubmit to the <form>.

Create mode (default)

function CreateTask() {
  const form = tasks.useForm()

  return () => (
    <form onSubmit={form.handleSubmit}>
      <input {...form.register('title')} placeholder="Title" />

      <select {...form.register('status')}>
        <option value="todo">To Do</option>
        <option value="in-progress">In Progress</option>
        <option value="done">Done</option>
      </select>

      <span {...form.errorProps('title')}>{form.errors().title?.message}</span>

      <button type="submit" disabled={form.isSubmitting()}>Create</button>
    </form>
  )
}

On submit, create mode POSTs the values to the API and invalidates the list query so a useList view shows the new item without a manual reload.

Edit mode

Pass mode: 'edit' and an id. The form auto-fetches the entity by id (toggling isSubmitting while loading), populates the fields, and PUTs on submit:

function EditTask(props: { id: string }) {
  const form = tasks.useForm({ mode: 'edit', id: props.id })

  return () => {
    if (form.isSubmitting()) return <p>Loading…</p>
    return (
      <form onSubmit={form.handleSubmit}>
        <input {...form.register('title')} />
        <select {...form.register('status')}>
          <option value="todo">To Do</option>
          <option value="in-progress">In Progress</option>
          <option value="done">Done</option>
        </select>
        <button type="submit">Save</button>
      </form>
    )
  }
}

The auto-fetch is unmount-safe: if the component unmounts before the getById promise settles (route nav, list re-render), the late resolution is dropped — no write to a disposed form.

FeatureFormOptions

OptionTypeDescription
mode'create' | 'edit''create' (default) POSTs; 'edit' auto-fetches by id and PUTs.
idstring | numberRequired in edit mode — drives the auto-fetch and the PUT /:id URL.
initialValuesPartial<TValues>Merged on top of the schema-derived + feature-level defaults.
validateOn'blur' | 'change' | 'submit'When to validate. Defaults to 'blur'.
onSuccess(result: unknown) => voidCalled after a successful create / update.
onError(error: unknown) => voidCalled when the submit throws (the error is re-thrown after).

Create vs Edit

Behaviormode: 'create'mode: 'edit'
Initial valuesSchema defaults + configAuto-fetched via GET /:id
Submit methodPOST /api/…PUT /api/…/:id
Auto-fetchNoYes (uses the built-in fetcher)
Loading signalisSubmittingfalseisSubmittingtrue until fetched
Invalidateslist querylist query + [name, id] query

Tables

useTable(data, options?) infers columns from the schema and returns a configured @pyreon/table instance plus reactive sorting / filtering signals. The data is the first argument — an array or an accessor; the table does not fetch on its own. Pair it with useList:

import { flexRender } from '@pyreon/table'

function TaskTable() {
  const { data } = tasks.useList()
  const { table, sorting, globalFilter } = tasks.useTable(() => data() ?? [], {
    columns: ['title', 'status', 'assignee'],
  })

  return () => (
    <div>
      <input
        value={globalFilter()}
        onInput={(e) => globalFilter.set(e.currentTarget.value)}
        placeholder="Filter…"
      />
      <table>
        <thead>
          <For each={() => table().getHeaderGroups()} by={(g) => g.id}>
            {(headerGroup) => (
              <tr>
                <For each={() => headerGroup.headers} by={(h) => h.id}>
                  {(header) => (
                    <th onClick={header.column.getToggleSortingHandler()}>
                      {flexRender(header.column.columnDef.header, header.getContext())}
                    </th>
                  )}
                </For>
              </tr>
            )}
          </For>
        </thead>
        <tbody>
          <For each={() => table().getRowModel().rows} by={(r) => r.id}>
            {(row) => (
              <tr>
                <For each={() => row.getVisibleCells()} by={(c) => c.id}>
                  {(cell) => <td>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>}
                </For>
              </tr>
            )}
          </For>
        </tbody>
      </table>
    </div>
  )
}

table is a Computed<Table<TValues>>call it (table()) to read the live TanStack Table instance. Core, sorted, and filtered row models are wired by default; pagination is enabled only when pageSize is set.

FeatureTableOptions

OptionTypeDescription
columns(keyof TValues & string)[]Subset of schema fields to show. Omit to show all fields.
columnOverridesPartial<Record<keyof TValues & string, Record<string, unknown>>>Per-column overrides (custom header, cell renderer, size, etc.).
pageSizenumberEnables client-side pagination. Omitted → no pagination row model.
const { table } = tasks.useTable(() => data() ?? [], {
  columns: ['title', 'status'],
  columnOverrides: {
    status: { header: 'State', cell: (info) => <Badge value={info.getValue()} /> },
  },
  pageSize: 20,
})

FeatureTableResult

PropertyTypeDescription
tableComputed<Table<TValues>>The reactive TanStack Table instance — call it to read.
sortingSignal<SortingState>Sorting state. Bind to header click handlers.
globalFilterSignal<string>Global filter term. Bind to a search input.
columnsFieldInfo[]The introspected metadata for the visible columns.

Store

useStore() returns the @pyreon/store StoreApi wrapping the feature's reactive client-side cache. The state lives on storeApi.store:

function TaskDashboard() {
  const { store } = tasks.useStore()

  return () => (
    <div>
      <p>{store.items().length} tasks cached</p>
      <p>Loading: {store.loading() ? 'Yes' : 'No'}</p>
      {store.selected() && (
        <div>
          <h3>Selected: {store.selected()?.title}</h3>
          <button onClick={store.clear}>Deselect</button>
        </div>
      )}
    </div>
  )
}

FeatureStore (the store field)

MemberTypeDescription
itemsSignal<TValues[]>Cached list of entities.
selectedSignal<TValues | null>Currently selected entity.
loadingSignal<boolean>Loading state.
select(id)(id: string | number) => voidSelect an item by id (looks up items by .id).
clear()() => voidClear the selection.

Schema Introspection

defineFeature introspects the schema at definition time (the result is on feature.fields). The same helpers are exported so you can build custom form / table renderers.

extractFields

Returns a FieldInfo[] describing each field. Duck-types Zod v3 (._def.shape), Zod v4 (._zod.def.shape), and a direct .shape — without importing Zod:

import { extractFields } from '@pyreon/feature'
import { z } from 'zod'

const fields = extractFields(
  z.object({
    title: z.string(),
    views: z.number().optional(),
    status: z.enum(['draft', 'published']),
  }),
)
// [
//   { name: 'title',  type: 'string', optional: false, label: 'Title' },
//   { name: 'views',  type: 'number', optional: true,  label: 'Views' },
//   { name: 'status', type: 'enum',   optional: false, label: 'Status', enumValues: ['draft', 'published'] },
// ]

FieldInfo

PropertyTypeDescription
namestringField key from the schema.
typeFieldType'string', 'number', 'boolean', 'date', 'enum', 'array', 'object', 'reference', 'unknown'.
optionalbooleanWhether the field is wrapped in .optional() / .nullable().
labelstringHuman-readable label from the field name (firstNameFirst Name, created_atCreated At).
enumValues(string | number)[] (opt)Allowed values for enum fields.
referenceTostring (opt)Name of the referenced feature, for reference() fields.

defaultInitialValues

Generates create-mode seed values from extracted FieldInfo[]takes the fields, not a schema:

import { extractFields, defaultInitialValues } from '@pyreon/feature'

const fields = extractFields(taskSchema)
const initial = defaultInitialValues(fields)
// strings → '', numbers → 0, booleans → false,
// enums → first value (or ''), dates → '', anything else → ''

This is what useForm() uses internally to seed an empty create form. For edit mode the existing record (auto-fetched by id) supplies the values.

References

reference(feature) marks a schema field as a foreign key to another feature. It returns a Zod-compatible validator (validates as string | number) carrying a _featureName so introspection can surface it. Use it inside your validation schema:

import { defineFeature, reference } from '@pyreon/feature'
import { z } from 'zod'

const users = defineFeature({
  name: 'users',
  schema: z.object({ name: z.string(), email: z.string().email() }),
  api: '/api/users',
})

const tasks = defineFeature({
  name: 'tasks',
  schema: z.object({
    title: z.string(),
    status: z.enum(['todo', 'in-progress', 'done']),
    assigneeId: reference(users),       // FK to the users feature
    categoryId: reference({ name: 'categories' }), // or a bare { name }
  }),
  api: '/api/tasks',
})

A reference() field shows up in extractFields as { type: 'reference', referenceTo: 'users' }, letting custom form / table renderers special-case it (e.g. a select dropdown populated from the related feature's useList). Resolve the related entity with the other feature's useById:

function TaskWithAssignee(props: { task: Task }) {
  const { data: assignee } = users.useById(props.task.assigneeId)
  return () => <p>Assigned to: {assignee()?.name ?? '…'}</p>
}

isReference

Type-guard for a value produced by reference(). References are symbol-tagged plain objects, so use this — never instanceof:

import { isReference } from '@pyreon/feature'

for (const [key, value] of Object.entries(rawSchemaShape)) {
  if (isReference(value)) {
    console.log(`${key}${value._featureName}`)
  }
}

Composed Packages

@pyreon/feature is a composition layer. Reach for the underlying packages directly when you need more control:

@pyreon/query

Every data hook is a thin @pyreon/query wrapper. Use the feature's queryKey factory for custom queries that share the cache namespace:

import { useQuery } from '@pyreon/query'

const overdue = useQuery(() => ({
  queryKey: [...tasks.queryKey(), 'overdue'],
  queryFn: () => fetch('/api/tasks/overdue').then((r) => r.json()),
}))

@pyreon/form

useForm wraps @pyreon/form's useForm with schema-derived validation + initial values. The returned object IS a FormStateregister, handleSubmit, values, errors, setFieldValue, reset, trigger, errorProps, labelProps, and friends are all available.

@pyreon/validation

A Zod schema (it exposes safeParseAsync) is wrapped with @pyreon/validation's zodSchema() adapter. Any other Standard Schema — Valibot, ArkType, @pyreon/validate's s, or modern Zod — is routed through standardSchemaToValidator (detecting the ~standard contract, callable schemas like ArkType included). Either way, submit/blur validation produces a per-field error record the form surfaces on the right field. Pass validate in the config to override with a custom validator.

@pyreon/table

useTable wraps @pyreon/table's useTable with schema-inferred ColumnDefs and wires the core / sorted / filtered (and optional paginated) row models. Use flexRender to render headers and cells.

@pyreon/store

useStore is built on @pyreon/store's defineStore, keyed by the feature name — so the cache is a singleton. Compose it with other stores:

import { defineStore, computed } from '@pyreon/store'

const useDashboard = defineStore('dashboard', () => {
  const taskStore = tasks.useStore().store
  const assignedCount = computed(() => taskStore.items().filter((t) => t.assigneeId != null).length)
  return { assignedCount }
})

API Reference

defineFeature(config)

<TValues>(config: FeatureConfig<TValues>) => Feature<TValues> — see FeatureConfig and Return Value.

Generated hooks

HookReturnsNotes
useList(opts?)UseQueryResult<TValues[]>GET /api. opts: ListOptions.
useById(id)UseQueryResult<TValues>GET /api/:id. Disabled when id is nullish.
useSearch(term, opts?)UseQueryResult<TValues[]>GET /api?q=…. term is a Signal<string>.
useCreate()UseMutationResult<TValues, _, Partial<TValues>>POST /api. Invalidates list on success.
useUpdate()UseMutationResult<TValues, _, { id, data }>PUT /api/:id. Optimistic; rolls back on error.
useDelete()UseMutationResult<void, _, string | number>DELETE /api/:id. Invalidates list on success.
useForm(opts?)FormState<TValues>Create / edit form. opts: FeatureFormOptions.
useTable(data, opts?)FeatureTableResult<TValues>data is an array or accessor. opts: FeatureTableOptions.
useStore()StoreApi<FeatureStore<TValues>>Singleton cache. State on .store.

Standalone exports

ExportSignatureDescription
reference(feature)(feature: { name: string }) => ReferenceSchemaMark a schema field as a foreign key.
isReference(value)(value: unknown) => value is ReferenceSchemaType-guard for reference() results.
extractFields(schema)(schema: unknown) => FieldInfo[]Introspect a schema's fields.
defaultInitialValues(fields)(fields: FieldInfo[]) => Record<string, unknown>Seed create-mode values from extracted fields.

Exported types

Feature, FeatureConfig, FeatureFormOptions, FeatureStore, FeatureTableOptions, FeatureTableResult, InferSchemaValues, ListOptions, FieldInfo, FieldType, ReferenceSchema.

Why

A typical CRUD feature needs list queries, detail queries, search, create / update / delete mutations, optimistic updates, a validated form for both create and edit, table columns, and a client cache — easily ~200 lines of repetitive wiring per entity.

defineFeature replaces that with roughly ten lines. You declare the schema and the API base path; the package generates every hook, infers form fields and table columns from the schema, applies optimistic updates, and keeps the client cache in sync.

This is especially valuable in AI-assisted workflows: instead of reviewing 200 lines of boilerplate line by line, an agent produces a single defineFeature call — declarative, auditable at a glance, and consistent across every feature in the app.

Feature