@pyreon/feature 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Vit Bokisch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,382 @@
1
+ # @pyreon/feature
2
+
3
+ Schema-driven feature primitives for Pyreon. Define a Zod schema and API path once, get fully typed CRUD hooks, forms, tables, stores, pagination, optimistic updates, and references -- all wired together automatically.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @pyreon/feature
9
+ ```
10
+
11
+ Peer dependencies: `@pyreon/core`, `@pyreon/reactivity`
12
+
13
+ ## Quick Start
14
+
15
+ ```tsx
16
+ import { defineFeature, reference } from '@pyreon/feature'
17
+ import { z } from 'zod'
18
+
19
+ const users = defineFeature({
20
+ name: 'users',
21
+ schema: z.object({
22
+ name: z.string().min(2),
23
+ email: z.string().email(),
24
+ role: z.enum(['admin', 'editor', 'viewer']),
25
+ }),
26
+ api: '/api/users',
27
+ })
28
+
29
+ // List query
30
+ function UserList() {
31
+ const { data, isPending } = users.useList()
32
+ if (isPending()) return <p>Loading...</p>
33
+ return (
34
+ <ul>
35
+ {data()!.map((u) => (
36
+ <li>{u.name} ({u.email})</li>
37
+ ))}
38
+ </ul>
39
+ )
40
+ }
41
+
42
+ // Create form
43
+ function CreateUser() {
44
+ const form = users.useForm()
45
+ return (
46
+ <form onSubmit={(e) => form.handleSubmit(e)}>
47
+ <input {...form.register('name')} />
48
+ <input {...form.register('email')} />
49
+ <select {...form.register('role')}>
50
+ <option value="admin">Admin</option>
51
+ <option value="editor">Editor</option>
52
+ <option value="viewer">Viewer</option>
53
+ </select>
54
+ <button type="submit">Create</button>
55
+ </form>
56
+ )
57
+ }
58
+
59
+ // Edit form (auto-fetches data)
60
+ function EditUser({ id }: { id: number }) {
61
+ const form = users.useForm({ mode: 'edit', id })
62
+ if (form.isSubmitting()) return <p>Loading...</p>
63
+ return (
64
+ <form onSubmit={(e) => form.handleSubmit(e)}>
65
+ <input {...form.register('name')} />
66
+ <input {...form.register('email')} />
67
+ <button type="submit">Save</button>
68
+ </form>
69
+ )
70
+ }
71
+ ```
72
+
73
+ ## API Reference
74
+
75
+ ### `defineFeature(config)`
76
+
77
+ Creates a feature object with all CRUD hooks, form, table, and store.
78
+
79
+ | Parameter | Type | Description |
80
+ | --- | --- | --- |
81
+ | `name` | `string` | Unique feature name -- used for store ID and query key namespace |
82
+ | `schema` | `ZodSchema` | Validation schema -- passed to `zodSchema()` for form validation |
83
+ | `api` | `string` | API base path (e.g., `/api/users`) |
84
+ | `initialValues?` | `Partial<TValues>` | Default values for create forms (auto-generated from schema if omitted) |
85
+ | `validate?` | `SchemaValidateFn<TValues>` | Custom schema-level validation (overrides auto-detection) |
86
+ | `fetcher?` | `typeof fetch` | Custom fetch function (defaults to global `fetch`) |
87
+
88
+ ### Returned Feature Object
89
+
90
+ | Property / Hook | Returns | Description |
91
+ | --- | --- | --- |
92
+ | `name` | `string` | Feature name |
93
+ | `api` | `string` | API base path |
94
+ | `schema` | `unknown` | The schema passed to `defineFeature` |
95
+ | `fields` | `FieldInfo[]` | Introspected field metadata from the schema |
96
+ | `queryKey(suffix?)` | `QueryKey` | Generate namespaced query keys |
97
+ | `useList(opts?)` | `UseQueryResult<T[]>` | GET `api` -- list query with optional pagination and params |
98
+ | `useById(id)` | `UseQueryResult<T>` | GET `api/:id` -- single item query |
99
+ | `useSearch(term, opts?)` | `UseQueryResult<T[]>` | GET `api?q=term` -- search with reactive signal |
100
+ | `useCreate()` | `UseMutationResult` | POST `api` -- invalidates list on success |
101
+ | `useUpdate()` | `UseMutationResult` | PUT `api/:id` -- optimistic update with rollback on error |
102
+ | `useDelete()` | `UseMutationResult` | DELETE `api/:id` -- invalidates list on success |
103
+ | `useForm(opts?)` | `FormState<T>` | Form with schema validation + API submit |
104
+ | `useTable(data, opts?)` | `FeatureTableResult<T>` | Reactive table with schema-inferred columns |
105
+ | `useStore()` | `StoreApi<FeatureStore<T>>` | Reactive store for items, selection, and loading state |
106
+
107
+ ### `reference(feature)`
108
+
109
+ Creates a typed foreign key field for cross-feature relationships.
110
+
111
+ | Parameter | Type | Description |
112
+ | --- | --- | --- |
113
+ | `feature` | `{ name: string }` | The referenced feature (or any object with a `name` property) |
114
+
115
+ Returns a Zod-compatible schema that validates as `string | number` and carries metadata about the referenced feature.
116
+
117
+ ### `extractFields(schema)`
118
+
119
+ Extracts field metadata from a Zod object schema.
120
+
121
+ | Parameter | Type | Description |
122
+ | --- | --- | --- |
123
+ | `schema` | `unknown` | A Zod object schema (duck-typed, works with v3 and v4) |
124
+
125
+ Returns `FieldInfo[]` with `name`, `type`, `optional`, `enumValues`, `referenceTo`, and `label` for each field.
126
+
127
+ ### `isReference(value)`
128
+
129
+ Returns `true` if a value is a reference schema created by `reference()`.
130
+
131
+ ### `defaultInitialValues(fields)`
132
+
133
+ Generates default initial values from a `FieldInfo[]` array. Strings default to `''`, numbers to `0`, booleans to `false`, enums to the first value.
134
+
135
+ ## useStore
136
+
137
+ The feature store provides a reactive cache for list data and selection state. It uses `@pyreon/store` internally with the feature name as the store ID.
138
+
139
+ ```tsx
140
+ function UserManager() {
141
+ const { store } = users.useStore()
142
+ const { data } = users.useList()
143
+
144
+ // Sync query data to store
145
+ effect(() => {
146
+ const items = data()
147
+ if (items) store.items.set(items)
148
+ })
149
+
150
+ return (
151
+ <div>
152
+ <ul>
153
+ {store.items().map((u) => (
154
+ <li onClick={() => store.select(u.id)}>
155
+ {u.name}
156
+ </li>
157
+ ))}
158
+ </ul>
159
+ {store.selected() && (
160
+ <div>Selected: {store.selected()!.name}</div>
161
+ )}
162
+ <button onClick={() => store.clear()}>Clear Selection</button>
163
+ </div>
164
+ )
165
+ }
166
+ ```
167
+
168
+ **Store API:**
169
+
170
+ | Property | Type | Description |
171
+ | --- | --- | --- |
172
+ | `items` | `Signal<TValues[]>` | Cached list of items |
173
+ | `selected` | `Signal<TValues \| null>` | Currently selected item |
174
+ | `loading` | `Signal<boolean>` | Loading state |
175
+ | `select(id)` | `(id: string \| number) => void` | Find and select an item by ID from the items list |
176
+ | `clear()` | `() => void` | Clear the current selection |
177
+
178
+ ## Pagination
179
+
180
+ Pass `page` (number or reactive signal) and `pageSize` to `useList()` for automatic pagination. Each page is cached independently.
181
+
182
+ ```tsx
183
+ function PaginatedUsers() {
184
+ const page = signal(1)
185
+ const { data, isPending } = users.useList({ page, pageSize: 10 })
186
+
187
+ return (
188
+ <div>
189
+ {isPending() ? (
190
+ <p>Loading...</p>
191
+ ) : (
192
+ <ul>
193
+ {data()!.map((u) => <li>{u.name}</li>)}
194
+ </ul>
195
+ )}
196
+ <button onClick={() => page.set(page() - 1)} disabled={page() <= 1}>
197
+ Previous
198
+ </button>
199
+ <button onClick={() => page.set(page() + 1)}>
200
+ Next
201
+ </button>
202
+ </div>
203
+ )
204
+ }
205
+ ```
206
+
207
+ **ListOptions:**
208
+
209
+ | Parameter | Type | Description |
210
+ | --- | --- | --- |
211
+ | `params?` | `Record<string, string \| number \| boolean>` | Additional query parameters |
212
+ | `page?` | `number \| Signal<number>` | Page number (reactive or static) |
213
+ | `pageSize?` | `number` | Items per page (defaults to 20 when `page` is set) |
214
+ | `staleTime?` | `number` | Override stale time for this query |
215
+ | `enabled?` | `boolean` | Enable/disable the query |
216
+
217
+ ## Edit Form (Auto-fetch)
218
+
219
+ When `useForm()` is called with `mode: 'edit'` and an `id`, it automatically fetches the item and populates the form. The form's `isSubmitting` signal is `true` until the data loads.
220
+
221
+ ```tsx
222
+ function EditUser({ id }: { id: number }) {
223
+ const form = users.useForm({
224
+ mode: 'edit',
225
+ id,
226
+ onSuccess: () => console.log('Updated!'),
227
+ onError: (err) => console.error(err),
228
+ })
229
+
230
+ if (form.isSubmitting()) return <p>Loading user...</p>
231
+
232
+ return (
233
+ <form onSubmit={(e) => form.handleSubmit(e)}>
234
+ <input {...form.register('name')} />
235
+ <input {...form.register('email')} />
236
+ <button type="submit">Save</button>
237
+ </form>
238
+ )
239
+ }
240
+ ```
241
+
242
+ **FeatureFormOptions:**
243
+
244
+ | Parameter | Type | Description |
245
+ | --- | --- | --- |
246
+ | `mode?` | `'create' \| 'edit'` | Form mode (default: `'create'`) |
247
+ | `id?` | `string \| number` | Item ID for edit mode (triggers auto-fetch) |
248
+ | `initialValues?` | `Partial<TValues>` | Override initial values |
249
+ | `validateOn?` | `'blur' \| 'change' \| 'submit'` | Validation trigger (default: `'blur'`) |
250
+ | `onSuccess?` | `(result: unknown) => void` | Called after successful submit |
251
+ | `onError?` | `(error: unknown) => void` | Called on submit error |
252
+
253
+ ## Optimistic Updates
254
+
255
+ `useUpdate()` automatically performs optimistic cache updates. When a mutation starts, the query cache is updated immediately with the new data. If the server returns an error, the cache rolls back to the previous value.
256
+
257
+ ```tsx
258
+ function UserRow({ user }: { user: User }) {
259
+ const { mutate: update } = users.useUpdate()
260
+
261
+ const toggleActive = () => {
262
+ // Cache updates immediately, rolls back on error
263
+ update({ id: user.id, data: { active: !user.active } })
264
+ }
265
+
266
+ return (
267
+ <tr>
268
+ <td>{user.name}</td>
269
+ <td>
270
+ <button onClick={toggleActive}>
271
+ {user.active ? 'Deactivate' : 'Activate'}
272
+ </button>
273
+ </td>
274
+ </tr>
275
+ )
276
+ }
277
+ ```
278
+
279
+ ## References
280
+
281
+ Use `reference()` to define typed foreign keys between features. Reference fields validate as `string | number` and carry metadata for form dropdowns and table links.
282
+
283
+ ```tsx
284
+ import { defineFeature, reference } from '@pyreon/feature'
285
+ import { z } from 'zod'
286
+
287
+ const users = defineFeature({
288
+ name: 'users',
289
+ schema: z.object({ name: z.string(), email: z.string().email() }),
290
+ api: '/api/users',
291
+ })
292
+
293
+ const posts = defineFeature({
294
+ name: 'posts',
295
+ schema: z.object({
296
+ title: z.string(),
297
+ body: z.string(),
298
+ authorId: reference(users), // typed foreign key
299
+ }),
300
+ api: '/api/posts',
301
+ })
302
+
303
+ // Field introspection detects the reference
304
+ const authorField = posts.fields.find((f) => f.name === 'authorId')
305
+ // { name: 'authorId', type: 'reference', referenceTo: 'users', ... }
306
+ ```
307
+
308
+ ## Schema Introspection
309
+
310
+ Every feature exposes `fields: FieldInfo[]` with metadata extracted from the schema at runtime. This powers automatic table columns, form generation, and reference detection.
311
+
312
+ ```tsx
313
+ function AutoForm({ feature }: { feature: Feature<any> }) {
314
+ const form = feature.useForm()
315
+ return (
316
+ <form onSubmit={(e) => form.handleSubmit(e)}>
317
+ {feature.fields.map((field) => {
318
+ if (field.type === 'enum') {
319
+ return (
320
+ <select {...form.register(field.name)}>
321
+ {field.enumValues!.map((v) => (
322
+ <option value={v}>{v}</option>
323
+ ))}
324
+ </select>
325
+ )
326
+ }
327
+ if (field.type === 'boolean') {
328
+ return <input type="checkbox" {...form.register(field.name, { type: 'checkbox' })} />
329
+ }
330
+ if (field.type === 'reference') {
331
+ return <p>Reference to: {field.referenceTo}</p>
332
+ }
333
+ return <input {...form.register(field.name)} placeholder={field.label} />
334
+ })}
335
+ <button type="submit">Submit</button>
336
+ </form>
337
+ )
338
+ }
339
+ ```
340
+
341
+ **FieldInfo:**
342
+
343
+ | Property | Type | Description |
344
+ | --- | --- | --- |
345
+ | `name` | `string` | Field name (key in the schema) |
346
+ | `type` | `FieldType` | `'string'`, `'number'`, `'boolean'`, `'date'`, `'enum'`, `'array'`, `'object'`, `'reference'`, or `'unknown'` |
347
+ | `optional` | `boolean` | Whether the field is optional |
348
+ | `enumValues?` | `(string \| number)[]` | Allowed values for enum fields |
349
+ | `referenceTo?` | `string` | Referenced feature name for reference fields |
350
+ | `label` | `string` | Human-readable label (e.g., `firstName` becomes `First Name`) |
351
+
352
+ ## Error Handling
353
+
354
+ The built-in fetcher parses structured error responses from the API. Errors with a `message` field in the response body use that as the error message. Errors with an `errors` object attach it to the thrown error for field-level error handling.
355
+
356
+ ```tsx
357
+ function CreateUser() {
358
+ const { mutate, error, isError } = users.useCreate()
359
+
360
+ const handleCreate = () => {
361
+ mutate({ name: 'Alice', email: 'taken@example.com' })
362
+ }
363
+
364
+ return (
365
+ <div>
366
+ <button onClick={handleCreate}>Create</button>
367
+ {isError() && (
368
+ <div>
369
+ <p>{(error() as Error).message}</p>
370
+ {(error() as any).errors?.email && (
371
+ <p>Email: {(error() as any).errors.email}</p>
372
+ )}
373
+ </div>
374
+ )}
375
+ </div>
376
+ )
377
+ }
378
+ ```
379
+
380
+ ## Why
381
+
382
+ An AI agent asked to "add user management" writes 10 lines of schema instead of 200 lines of components, hooks, and wiring. The feature definition is the single source of truth -- types, validation, API calls, cache management, optimistic updates, pagination, and store state all flow from the schema. Human developers get the same leverage: one `defineFeature()` call replaces dozens of boilerplate files.