@appweaver/cli 1.3.1 → 1.4.0

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.
@@ -1,1284 +1,1424 @@
1
- # Resources
2
-
3
- Resources are the core building blocks of an Appweaver application. There are four resource types that form a dependency
4
- chain: **model** → **service** → **routes** → **policy**. Each resource type is created using a corresponding factory
5
- function and autoloaded from `src/resources/*/` on application start. Source directory and resources pattern could be
6
- changed with `APP_SOURCE_PATH` and `RESOURCE_{MODEL,SERVICE,...}_PATTERN` config variables.
7
-
8
- - A **model** is always required.
9
- - A **service** requires a model.
10
- - The **Routes** require a service.
11
- - A **policy** is optional and independent of the chain.
12
-
13
- ---
14
-
15
- ## createModel
16
-
17
- Creates a resource model definition. The model defines database fields, relations, files, virtual fields, DTOs for CRUD
18
- operations, and index configuration. It is used to generate Prisma schema, TypeScript types, and route request/response
19
- schemas.
20
-
21
- ```ts
22
- import { createModel } from '@appweaver/core';
23
-
24
- export default createModel({
25
- name: 'Product',
26
- // ... configuration
27
- });
28
- ```
29
-
30
- ### Configuration
31
-
32
- ```ts
33
- function createModel(config: ResourceModelConfig, override ?: Partial<ResourceModelConfig>) {
34
- }
35
- ```
36
-
37
- | Property | Type | Required | Default | Description |
38
- |------------------|--------------------------------|----------|-----------------------|---------------------------------------------------------------------|
39
- | `name` | string | yes | - | Model name (PascalCase). Used as database table name and type name. |
40
- | `tableName` | string | no | (model name) | Custom database table name override. |
41
- | `generateTypes` | boolean | no | `true` | Generate TypeScript types for this model. |
42
- | `generateSchema` | boolean | no | `true` | Generate Prisma schema for this model. |
43
- | `id` | IdField | no | Autoincrement integer | ID field configuration. |
44
- | `audit` | AuditFields | no | All included | Audit timestamps and creator tracking fields. |
45
- | `scalars` | Record\<string, ScalarField> | no | - | Scalar fields (database columns). |
46
- | `relations` | Record\<string, RelationField> | no | - | Relations to other models. |
47
- | `files` | Record\<string, FileField> | no | - | File upload fields. |
48
- | `virtual` | Record\<string, VirtualField> | no | - | Computed/virtual fields not stored in database. |
49
- | `read` | OperationConfig | no | - | Pick/omit fields for the read DTO. |
50
- | `create` | OperationConfig | no | - | Pick/omit fields for the create DTO. |
51
- | `update` | OperationConfig | no | - | Pick/omit fields for the update DTO. |
52
- | `export` | Record\<string, ExportField> | no | - | CSV export field configuration. |
53
- | `index` | string[] \| string[][] | no | - | Database index definitions. |
54
-
55
- ### ID field
56
-
57
- ```ts
58
- const config = {
59
- // Integer ID with autoincrement (default)
60
- id: {
61
- type: 'int',
62
- generator: 'autoincrement()'
63
- },
64
-
65
- // String ID with generator
66
- id: {
67
- type: 'string',
68
- generator: 'uuid()'
69
- }
70
- };
71
- ```
72
-
73
- | Property | Type | Default | Description |
74
- |-------------|-------------------------------------------------------------------------------|---------------------|-------------------------------------------------------------------------------|
75
- | `type` | `'string'` \| `'int'` \| `'bigInt'` | `'int'` | ID field data type. |
76
- | `generator` | `'uuid()'` \| `'uuid(7)'` \| `'cuid()'` \| `'cuid(2)'` \| `'autoincrement()'` | `'autoincrement()'` | Value generator. String types use UUID/CUID, integer types use autoincrement. |
77
-
78
- ### Audit fields
79
-
80
- It is recommended to always use all audit fields for all resource models, unless specified otherwise. In the usual
81
- scenario audit should be left out (including all fields by default).
82
-
83
- ```ts
84
- const config = {
85
- // By default all audit fields are included
86
- audit: {
87
- createdAt: true,
88
- updatedAt: true,
89
- createdById: true
90
- }
91
- };
92
- ```
93
-
94
- | Property | Type | Default | Description |
95
- |---------------|---------|---------|-------------------------------------------------|
96
- | `createdAt` | boolean | `true` | Add `createdAt` timestamp field. |
97
- | `updatedAt` | boolean | `true` | Add `updatedAt` timestamp field. |
98
- | `createdById` | boolean | `true` | Add `createdById` foreign key to the auth user. |
99
-
100
- ### Scalar field types
101
-
102
- All scalar fields share these common properties:
103
-
104
- | Property | Type | Default | Description |
105
- |---------------------|-----------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------|
106
- | `required` | boolean | `true` | Whether the field is required. |
107
- | `unique` | boolean | `false` | Add a unique constraint. |
108
- | `hidden` | boolean | `false` | Hide from API output (e.g. password hashes). |
109
- | `default` | varies | - | Default static value. |
110
- | `defaultGenerator` | string | - | Default is generated by function (e.g. uuid(), cuid(), autoincrement(), now(), ...). |
111
- | `defaultExpression` | string | - | Default is generated by database expression in supported database syntax (e.g. concat('token_', gen_random_uuid()))::TEXT). |
112
- | `array` | boolean | `false` | Store as array (supported on string, int, float). |
113
- | `example` | string \| number \| boolean | - | Example value for OpenAPI (Swagger) schema documentation. |
114
-
115
- #### String
116
-
117
- ```ts
118
- const config = {
119
- title: {
120
- type: 'string',
121
- minLength: 1,
122
- maxLength: 200,
123
- default: 'No title'
124
- },
125
- email: {
126
- type: 'string',
127
- format: 'email'
128
- },
129
- slug: {
130
- type: 'string',
131
- pattern: '^[a-z0-9-]+$'
132
- },
133
- code: {
134
- type: 'string',
135
- defaultGenerator: 'uuid()'
136
- }
137
- };
138
- ```
139
-
140
- | Property | Type | Description |
141
- |-------------|---------------------------------------------------------------------------------------|--------------------------------------|
142
- | `type` | `'string'` | String field type. |
143
- | `minLength` | number | Minimum string length. |
144
- | `maxLength` | number | Maximum string length. |
145
- | `format` | `'email'` \| `'hostname'` \| `'ipv4'` \| `'ipv6'` \| `'uri'` \| `'uuid'` \| `'regex'` | Built-in format validation. |
146
- | `pattern` | string | Custom regex pattern for validation. |
147
-
148
- String defaults can also be ID generators: `'uuid()'`, `'uuid(7)'`, `'cuid()'`, `'cuid(2)'`.
149
-
150
- #### Number (int, bigInt, float)
151
-
152
- ```ts
153
- const config = {
154
- price: {
155
- type: 'float',
156
- minimum: 0
157
- },
158
- quantity: {
159
- type: 'int',
160
- minimum: 0,
161
- maximum: 10000
162
- }
163
- };
164
- ```
165
-
166
- | Property | Type | Description |
167
- |-----------|------------------------------------|--------------------|
168
- | `type` | `'int'` \| `'bigInt'` \| `'float'` | Number field type. |
169
- | `minimum` | number | Minimum value. |
170
- | `maximum` | number | Maximum value. |
171
-
172
- Integer defaults can be `'autoincrement()'`.
173
-
174
- #### Boolean
175
-
176
- ```ts
177
- const config = {
178
- enabled: {
179
- type: 'boolean',
180
- default: true
181
- }
182
- };
183
- ```
184
-
185
- | Property | Type | Description |
186
- |----------|-------------|---------------------|
187
- | `type` | `'boolean'` | Boolean field type. |
188
-
189
- #### DateTime
190
-
191
- ```ts
192
- const config = {
193
- publishedAt: {
194
- type: 'dateTime',
195
- defaultGenerator: 'now()'
196
- },
197
- eventDate: {
198
- type: 'dateTime',
199
- format: 'date'
200
- }
201
- };
202
- ```
203
-
204
- | Property | Type | Description |
205
- |----------|---------------------------------------|----------------------|
206
- | `type` | `'dateTime'` | DateTime field type. |
207
- | `format` | `'date-time'` \| `'time'` \| `'date'` | DateTime format. |
208
-
209
- Default can be `'now()'` for current timestamp.
210
-
211
- #### JSON
212
-
213
- ```ts
214
- const config = {
215
- metadata: {
216
- type: 'json',
217
- default: {}
218
- }
219
- };
220
- ```
221
-
222
- | Property | Type | Description |
223
- |----------|----------|------------------------------------------------------|
224
- | `type` | `'json'` | JSON field type. Stores arbitrary objects or arrays. |
225
-
226
- #### Enum
227
-
228
- ```ts
229
- const config = {
230
- status: {
231
- type: 'enum',
232
- values: ['Draft', 'Active', 'Sold'],
233
- default: 'Draft'
234
- }
235
- };
236
- ```
237
-
238
- | Property | Type | Description |
239
- |----------|----------|---------------------------------|
240
- | `type` | `'enum'` | Enum field type. |
241
- | `values` | string[] | Allowed enum values (required). |
242
-
243
- ### Relations
244
-
245
- ```ts
246
- // src/resources/product/model.ts
247
- const config = {
248
- relations: {
249
- category: {
250
- model: 'Category',
251
- type: 'oneToMany',
252
- mappedBy: 'products',
253
- owner: true,
254
- output: {
255
- type: 'always'
256
- }
257
- },
258
- reviews: {
259
- model: 'Review',
260
- type: 'oneToMany',
261
- mappedBy: 'product',
262
- output: {
263
- type: 'single',
264
- count: true
265
- }
266
- }
267
- }
268
- };
269
- ```
270
-
271
- ```ts
272
- // src/resources/category/model.ts
273
- const config = {
274
- relations: {
275
- products: {
276
- model: 'Product',
277
- type: 'oneToMany',
278
- mappedBy: 'category',
279
- output: {
280
- type: 'single'
281
- }
282
- }
283
- }
284
- };
285
- ```
286
-
287
- ```ts
288
- // src/resources/review/model.ts
289
- const config = {
290
- relations: {
291
- product: {
292
- model: 'Product',
293
- type: 'oneToMany',
294
- mappedBy: 'reviews',
295
- owner: true,
296
- input: {
297
- type: 'none'
298
- }
299
- }
300
- }
301
- };
302
- ```
303
-
304
- | Property | Type | Default | Description |
305
- |-----------------|-------------------------------------------------|--------------|--------------------------------------------------------------------------|
306
- | `model` | string | **required** | Target model name. |
307
- | `type` | `'oneToOne'` \| `'oneToMany'` \| `'manyToMany'` | **required** | Relation cardinality between the two models. |
308
- | `owner` | boolean | `false` | This side owns the foreign key column (only one side should be owner). |
309
- | `mappedBy` | string | - | Name of the inverse relation on the target model. |
310
- | `required` | boolean | `true` | Whether the relation is required (nullable foreign key if not required). |
311
- | `minItems` | number | - | Minimum items for list relations. |
312
- | `orphanRemoval` | boolean | `false` | Delete orphaned records when parent is deleted. |
313
- | `onDelete` | ReferentialAction | - | Foreign key action on delete. |
314
- | `onUpdate` | ReferentialAction | - | Foreign key action on update. |
315
- | `input` | RelationInput | - | Input DTO configuration. |
316
- | `output` | RelationOutput | - | Output DTO configuration. |
317
-
318
- **ReferentialAction values**: `'cascade'`, `'restrict'`, `'noAction'`, `'setNull'`, `'setDefault'`
319
-
320
- #### Relationship types
321
-
322
- The `type` property declares the relation cardinality explicitly, and `owner` marks the side that holds the foreign key
323
- column in the generated table:
324
-
325
- **One-to-One** (`type: 'oneToOne'`): Both sides reference a single record. The side with `owner: true` holds a unique
326
- foreign key; the inverse side is always optional.
327
-
328
- ```ts
329
- // User model
330
- const config = {
331
- relations: {
332
- profile: {
333
- model: 'Profile',
334
- type: 'oneToOne',
335
- mappedBy: 'user',
336
- owner: true,
337
- required: false // otherwise the Profile DTO must be sent when creating the user resource
338
- }
339
- }
340
- };
341
- ```
342
-
343
- ```ts
344
- // Profile model
345
- const config = {
346
- relations: {
347
- user: {
348
- model: 'User',
349
- type: 'oneToOne',
350
- mappedBy: 'profile'
351
- }
352
- }
353
- };
354
- ```
355
-
356
- **One-to-Many** (`type: 'oneToMany'`): The "many" side (which holds the foreign key) has `owner: true` and references a
357
- single record; the "one" side has no `owner` and holds a list of related records.
358
-
359
- ```ts
360
- // Category model (one, list side)
361
- const config = {
362
- relations: {
363
- products: {
364
- model: 'Product',
365
- type: 'oneToMany',
366
- mappedBy: 'category'
367
- }
368
- }
369
- };
370
- ```
371
-
372
- ```ts
373
- // Product model (many, foreign key side)
374
- const config = {
375
- relations: {
376
- category: {
377
- model: 'Category',
378
- type: 'oneToMany',
379
- mappedBy: 'products',
380
- owner: true
381
- }
382
- }
383
- };
384
- ```
385
-
386
- **Many-to-Many** (`type: 'manyToMany'`): Both sides hold lists of related records, joined through an implicit join
387
- table. The `owner` property has no effect on this relation type.
388
-
389
- ```ts
390
- // Post model
391
- const config = {
392
- relations: {
393
- tags: {
394
- model: 'Tag',
395
- type: 'manyToMany',
396
- mappedBy: 'posts'
397
- }
398
- }
399
- };
400
- ```
401
-
402
- ```ts
403
- // Tag model
404
- const config = {
405
- relations: {
406
- posts: {
407
- model: 'Post',
408
- type: 'manyToMany',
409
- mappedBy: 'tags'
410
- }
411
- }
412
- };
413
- ```
414
-
415
- #### Relation pair validation
416
-
417
- `weaver generate` validates every bidirectional relation pair linked through `mappedBy` and fails schema generation with
418
- a descriptive error when the two sides are inconsistent:
419
-
420
- - Both sides must declare the same relation `type`.
421
- - The mapped relation must reference the declaring model back via its `model` property.
422
- - For `oneToOne` and `oneToMany` relations, exactly one side must declare `owner: true` (neither or both is an error).
423
-
424
- A relation whose `mappedBy` field does not exist on the target model is treated as single-sided and skipped by the
425
- validation; an inverse field is generated automatically in the Prisma schema.
426
-
427
- #### Relation input
428
-
429
- | Property | Type | Description |
430
- |---------------|-------------------------------------------------|-------------------------------------------------------------------------------------------------------|
431
- | `type` | `'all'` \| `'create'` \| `'update'` \| `'none'` | When the relation field is available as input. |
432
- | `allowCreate` | boolean | Allow creating related records inline (input objects without an `id`). |
433
- | `allowUpdate` | boolean | Allow updating related records inline on parent update requests (input objects with a required `id`). |
434
- | `uniqueKey` | string | Unique field matching existing records, turning an inline create into a connect-or-create. |
435
-
436
- Both flags are off by default: a relation only connects existing records unless `allowCreate` / `allowUpdate` is set.
437
-
438
- By default, a relation input only connects existing records. It accepts an id value, an `{ id }` object, or an array of
439
- either for list relations. The `allowCreate` and `allowUpdate` flags also accept the related model's own data:
440
-
441
- - **`allowCreate: true`** — input objects **without** an `id` create the related record inline. The accepted fields are
442
- the related model's create data, without its own relations and files (`<Model>RelationCreate`).
443
- - **`allowUpdate: true`** — input objects **with** an `id` and further fields update the related record inline
444
- (`<Model>RelationUpdate`). Objects carrying only an `id` are connected instead. This applies to parent **update**
445
- requests only. On parent **create** requests every object with an `id` is connected, since the database updates
446
- relations only within an update action.
447
-
448
- Relations that accept inline writes document their request shape as `<Model>RelationInput`. It holds the id and the
449
- fields of both shapes above, all optional. The shape stays permissive on purpose, since the server strips the properties
450
- that the matched schema does not declare. The service applies the restrictions instead. Fields excluded by the related
451
- model's `create` or `update` config are dropped. A missing required create field fails with a `400` error naming the
452
- field.
453
-
454
- Connect, create, and update inputs can be mixed within one list relation request:
455
-
456
- ```ts
457
- // PUT /api/users/1
458
- {
459
- posts: [
460
- 5, // connect post 5 by id
461
- { id: 7, title: 'Renamed' }, // update post 7 inline
462
- { title: 'Fresh post', slug: 'new' } // create a new post inline
463
- ]
464
- }
465
- ```
466
-
467
- Records without an `id` require `allowCreate: true`. Otherwise, the request fails with a `400` error and the related
468
- record has to be created through its own endpoint first. With `allowCreate` set, a `uniqueKey` matches an existing
469
- record by that field before creating a new one, so the inline create becomes a connect-or-create. Without
470
- `allowCreate` the `uniqueKey` has no effect. Plain connect and inline update always match related records by `id`.
471
-
472
- #### Relation output
473
-
474
- | Property | Type | Description |
475
- |-----------|------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
476
- | `type` | `'always'` \| `'single'` \| `'multiple'` \| `'none'` | When to include the relation in output. `always` = all reads, `single` = single record reads, `multiple` = list reads, `none` = never. |
477
- | `include` | Record\<string, RelationOutput> | Nested relation output configuration. |
478
- | `count` | boolean | Include a count of related records. |
479
-
480
- ### File fields
481
-
482
- ```ts
483
- const config = {
484
- files: {
485
- photo: {
486
- mimeType: 'image/*',
487
- namePattern: 'photos/{userId}-{name}-{hash}.{extension}',
488
- maxSize: '2 MB',
489
- image: {
490
- quality: 80,
491
- maxWidth: 1200,
492
- maxHeight: 1200,
493
- fit: 'inside'
494
- }
495
- },
496
- documents: {
497
- mimeType: 'application/pdf',
498
- array: true,
499
- maxCount: 5
500
- }
501
- }
502
- };
503
- ```
504
-
505
- | Property | Type | Description |
506
- |---------------------|------------------------|-------------------------------------------------------------------------------------------------------------|
507
- | `mimeType` | string \| RegExp | Allowed MIME types (glob patterns like `'image/*'` supported). |
508
- | `namePattern` | string \| function | File naming pattern or function (available variables are listed below). |
509
- | `array` | boolean | Allow multiple files. |
510
- | `maxSize` | number \| string | Maximum file size (e.g. `'2 MB'`, `5242880`). |
511
- | `maxCount` | number | Maximum number of files (for array fields). |
512
- | `output` | RelationOutput | When to include file info in output. |
513
- | `onResourceDeleted` | `'delete'` \| `'keep'` | When the owning resource is deleted. `'delete'` (default) removes files from storage, `'keep'` leaves them. |
514
- | `image` | ImageConfig | Image compression and resize settings. Only applies to image MIME types (excluding GIF). |
515
-
516
- #### Available namePattern variables
517
-
518
- Default pattern is: `{name}-{hash}.{extension}`.
519
-
520
- | Variable | Type | Description |
521
- |-----------------|--------|---------------------------------------------|
522
- | `name` | string | Original filename without extension. |
523
- | `extension` | string | Original file extension. |
524
- | `resourceField` | string | Field name the file is assigned to. |
525
- | `resourceName` | string | Resource model name. |
526
- | `resourceId` | string | Resource ID. |
527
- | `userId` | string | Authenticated user ID. |
528
- | `userEmail` | string | Authenticated user email. |
529
- | `year` | number | Current UTC year. |
530
- | `month` | number | Current UTC month (1-12). |
531
- | `day` | number | Current UTC day of month. |
532
- | `weekDay` | number | Current UTC day of week (0-6, Sunday is 0). |
533
- | `yearWeek` | number | ISO week number. |
534
- | `yearDay` | number | Day of year (1-366). |
535
- | `hours` | number | Current UTC hours. |
536
- | `minutes` | number | Current UTC minutes. |
537
- | `seconds` | number | Current UTC seconds. |
538
- | `milliseconds` | number | Current UTC milliseconds. |
539
- | `timestamp` | number | Unix timestamp in milliseconds. |
540
- | `date` | string | Current date in ISO 8601 format. |
541
- | `uuid` | string | Generated random UUID. |
542
- | `hash` | string | Generated random hash (32 bytes). |
543
-
544
- #### Image compression
545
-
546
- Configure automatic image compression and resizing by adding the `image` property to a file field. Processing only
547
- applies to supported image MIME types: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `image/tiff`. GIF files
548
- are passed through unchanged.
549
-
550
- | Property | Type | Description |
551
- |-------------|----------|----------------------------------------------------------------------------------------------------------------|
552
- | `quality` | number | Compression quality (1-100). Applies to JPEG, PNG, WebP, AVIF, and TIFF. |
553
- | `width` | number | Exact resize width in pixels. |
554
- | `height` | number | Exact resize height in pixels. |
555
- | `maxWidth` | number | Maximum width. Only downscales if the image exceeds this dimension. |
556
- | `maxHeight` | number | Maximum height. Only downscales if the image exceeds this dimension. |
557
- | `fit` | ImageFit | How the image fits the target dimensions: `'inside'` (default), `'contain'`, `'cover'`, `'fill'`, `'outside'`. |
558
-
559
- `width`/`height` take precedence over `maxWidth`/`maxHeight`. When using `maxWidth`/`maxHeight`, images smaller than the
560
- specified dimensions are not enlarged.
561
-
562
- ```ts
563
- // Compress and limit dimensions
564
- const config = {
565
- files: {
566
- avatar: {
567
- mimeType: 'image/*',
568
- maxSize: '5 MB',
569
- image: { quality: 80, maxWidth: 800, maxHeight: 800 }
570
- }
571
- }
572
- };
573
- ```
574
-
575
- ```ts
576
- // Exact resize for thumbnails
577
- const config = {
578
- files: {
579
- thumbnail: {
580
- mimeType: 'image/jpeg',
581
- image: { quality: 70, width: 200, height: 200, fit: 'inside' }
582
- }
583
- }
584
- }
585
- ```
586
-
587
- ### Virtual fields
588
-
589
- Virtual fields are computed values not stored in the database. They can appear in input DTOs (to receive data) and/or
590
- output DTOs (to return computed values).
591
-
592
- ```ts
593
- const config = {
594
- virtual: {
595
- displayName: {
596
- type: 'string',
597
- output: {
598
- type: 'always',
599
- value: (resource) => `${resource.firstName} ${resource.lastName}`
600
- }
601
- },
602
- inviteCode: {
603
- type: 'string',
604
- input: {
605
- type: 'create'
606
- }
607
- }
608
- }
609
- };
610
- ```
611
-
612
- | Property | Type | Description |
613
- |------------------|------------------------------------------------------|------------------------------------------------------------|
614
- | *(scalar props)* | - | All scalar field properties (type, minLength, etc.) apply. |
615
- | `input.type` | `'all'` \| `'create'` \| `'update'` \| `'none'` | When the virtual field accepts input. |
616
- | `input.value` | primitive \| function | Default value or transformer for input. |
617
- | `output.type` | `'always'` \| `'single'` \| `'multiple'` \| `'none'` | When the virtual field appears in output. |
618
- | `output.value` | primitive \| function | Computed value or transformer for output. |
619
-
620
- Virtual output values are applied automatically to responses of resource CRUD routes (including nested relation and file
621
- objects) and to responses of custom `registerRoute` routes whose 2xx response schemas reference resource output models.
622
- To apply them manually on a raw resource object (e.g. one fetched directly through a Prisma client), use the
623
- `projectVirtualFields` helper:
624
-
625
- ```ts
626
- import { projectVirtualFields } from '@appweaver/core';
627
-
628
- const projected = projectVirtualFields(post, 'Post'); // sets virtual values, recursing into relations and files
629
- ```
630
-
631
- ### Operation config (read, create, update)
632
-
633
- Control which fields appear in each DTO. Use `pick` for an allowlist or `omit` for a deny-list.
634
-
635
- ```ts
636
- const config = {
637
- create: {
638
- omit: ['status'] // All fields except status
639
- },
640
- update: {
641
- pick: ['title', 'price'] // Only title and price
642
- }
643
- };
644
- ```
645
-
646
- | Property | Type | Description |
647
- |----------|----------|------------------------------------------------|
648
- | `omit` | string[] | Fields to exclude from the DTO. |
649
- | `pick` | string[] | Fields to include in the DTO (overrides omit). |
650
-
651
- ### Export config
652
-
653
- Configure CSV export behavior per field:
654
-
655
- ```ts
656
- const config = {
657
- export: {
658
- price: {
659
- headerName: 'Product Price',
660
- mapValue: 'price'
661
- },
662
- passwordHash: {
663
- exclude: true
664
- },
665
- status: {
666
- mapValue: (val) => val.toUpperCase()
667
- },
668
- author: {
669
- firstName: {
670
- headerName: 'Given Name'
671
- },
672
- lastName: {
673
- headerName: 'Family Name'
674
- }
675
- }
676
- }
677
- };
678
- ```
679
-
680
- | Property | Type | Description |
681
- |--------------|--------------------|------------------------------------|
682
- | `headerName` | string | Custom CSV column header name. |
683
- | `exclude` | boolean | Exclude this field from exports. |
684
- | `mapValue` | string \| function | Transform the value during export. |
685
-
686
- A `string` `mapValue` names the field to read the column value from. On a relation or file field it is read off the
687
- related record (and off every item for array relations, joined with `,`); on a scalar field it is read off the exported
688
- record itself. A function `mapValue` receives the field value (or each item of an array field) and returns the column
689
- value.
690
-
691
- ### Index config
692
-
693
- Define database indexes as a flat array (single-field indexes) or nested arrays (composite indexes):
694
-
695
- ```ts
696
- index: ['title'] // Single-field index on title
697
- index: [['status', 'categoryId']] // Composite index on status + categoryId
698
- index: ['email', ['status', 'createdAt']] // Both single and composite
699
- ```
700
-
701
- ### Generated models
702
-
703
- `createModel` produces the following TypeBox schema models used internally by routes and services:
704
-
705
- | Model | Purpose |
706
- |-------------------|------------------------------------|
707
- | `readModel` | Full model with all visible fields |
708
- | `createModel` | Request body for create operations |
709
- | `updateModel` | Request body for update operations |
710
- | `relationsModel` | Relations-only subset |
711
- | `virtualModel` | Virtual fields-only subset |
712
- | `filesModel` | File fields-only subset |
713
- | `readOneModel` | Response for single-item reads |
714
- | `readManyModel` | Response for list reads |
715
- | `createOneModel` | Request for create endpoint |
716
- | `updateOneModel` | Request for update endpoint |
717
- | `fileUploadModel` | Request for file upload endpoint |
718
- | `fileDeleteModel` | Request for file delete endpoint |
719
-
720
- ---
721
-
722
- ## createService
723
-
724
- Creates a resource service with lifecycle hooks and business logic. The service handles all database operations for a
725
- model and triggers hooks on each CRUD operation before/after.
726
-
727
- ```ts
728
- import { createService } from '@appweaver/core';
729
-
730
- export default createService({
731
- modelName: 'Product',
732
- afterCreate: (resource) => {
733
- logger.info(`Product created: ${resource.id}`);
734
- },
735
- textSearch: {
736
- title: { contains: '{input}', mode: 'insensitive' }
737
- }
738
- });
739
- ```
740
-
741
- ### Configuration
742
-
743
- ```ts
744
- function createService(config: ResourceServiceConfig, override ?: Partial<ResourceServiceConfig>) {
745
- }
746
- ```
747
-
748
- | Property | Type | Description |
749
- |-------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|
750
- | `modelName` | string | Model name to bind this service to (required). |
751
- | `beforeFind` | `(id) => void` | Hook called before finding a single resource. |
752
- | `beforeQuery` | `(filter, page, size, sort) => void` | Hook called before querying resources. `sort` is a field list string or a sort object. |
753
- | `beforeAggregate` | `(filter, select, dateField, from?, to?, step?, safeIncrement?) => void` | Hook called before aggregation. |
754
- | `beforeCreate` | `(data) => void` | Hook called before creating a resource. Mutate `data` to modify input. |
755
- | `beforeUpdate` | `(id, data) => void` | Hook called before updating a resource. |
756
- | `beforeDelete` | `(id) => void` | Hook called before deleting a resource. |
757
- | `afterFind` | `(resource) => void` | Hook called after finding a resource. |
758
- | `afterQuery` | `(response) => void` | Hook called after querying resources. |
759
- | `afterAggregate` | `(response) => void` | Hook called after aggregation. |
760
- | `afterCreate` | `(resource) => void` | Hook called after creating a resource. |
761
- | `afterUpdate` | `(resource) => void` | Hook called after updating a resource. |
762
- | `afterDelete` | `(resource) => void` | Hook called after deleting a resource. |
763
- | `textSearch` | object \| function | Prisma filter object or function `(input: string) => filter` for text search. Use `'{input}'` as placeholder in filter objects. |
764
-
765
- All hooks can be synchronous or return a `Promise`.
766
-
767
- ### Service methods
768
-
769
- The created service exposes the following methods:
770
-
771
- | Method | Signature | Description |
772
- |-------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|
773
- | `find` | `(id) => Promise<ReadOne>` | Find a single resource by ID. |
774
- | `query` | `(filter?, page?, size?, sort?) => Promise<QueryResponse>` | Query resources with filtering, pagination, and sorting (see [Query sorting](#query-sorting)). |
775
- | `aggregate` | `(filter?, select?, dateField?, from?, to?, step?, safeIncrement?) => Promise<AggregateResponse>` | Aggregate resources with time-series grouping (see [Aggregate selection](#aggregate-selection)). |
776
- | `create` | `(data) => Promise<ReadOne>` | Create a new resource. |
777
- | `update` | `(id, data) => Promise<ReadOne>` | Update an existing resource. |
778
- | `delete` | `(id) => Promise<ReadOne>` | Delete a resource. |
779
-
780
- ### Query filters
781
-
782
- The `filter` argument of `query`, `aggregate`, and `export` mirrors the WHERE part of a database query. The matching
783
- `POST /query`, `POST /aggregate`, and `POST /export` routes accept the same structure, validated against a generated
784
- per-model `<Model>QueryFilter` schema that strips unknown and hidden fields.
785
-
786
- **Logical operators** (filter level) — take a single filter object (each entry becomes one condition) or a list of them:
787
-
788
- | Operator | Description |
789
- |----------|-------------------------------------------|
790
- | `_and` | All nested conditions must match. |
791
- | `_or` | At least one nested condition must match. |
792
- | `_not` | No nested condition may match. |
793
- | `_nor` | Alias of `_not`. |
794
-
795
- **Comparison operators** (field level) — combined inside one object, all must match:
796
-
797
- | Operator | Description |
798
- |---------------------------------|------------------------------------------------------------------------------------------------------------------------------|
799
- | `_eq` | Equal to the given value. |
800
- | `_ne` | Not equal to the given value. |
801
- | `_gt`, `_gte`, `_lt`, `_lte` | Greater/lower than (or equal to) the given value. |
802
- | `_in`, `_nin` | Included / not included in the given list. |
803
- | `_between` | Inside the inclusive `[min, max]` range. |
804
- | `_like` | SQL LIKE pattern with `%` wildcards (`Luk%` → starts with, `%avatar%` → contains, `%png` → ends with, no wildcard → equals). |
805
- | `_ilike` | Case-insensitive `_like` (uses `mode: 'insensitive'`, PostgreSQL and MongoDB only). |
806
- | `_starts`, `_ends`, `_contains` | Starts with / ends with / contains the given string. |
807
- | `_exists` | Not null (`true`) or null (`false`). |
808
- | `_not` | Negates a nested operator object or plain value. |
809
-
810
- **List (array scalar) operators**: `_has`, `_hasSome`, `_hasEvery`, `_isEmpty`.
811
-
812
- **Relation operators**: `_some`, `_every`, `_none` take a filter of the related model; `_exists` maps to an `is`/`isNot`
813
- null check on a single relation and to `some`/`none` on a list relation.
814
-
815
- **Plain value shorthands**: a bare value matches by equality, a list by inclusion, a two-value list on a numeric or date
816
- field as an inclusive range, a value or list on a relation by id, an array field uses `has`/`hasSome`, and `null`
817
- matches missing values or related records.
818
-
819
- ```json
820
- {
821
- "filter": {
822
- "_and": {
823
- "firstName": {
824
- "_eq": "Luka",
825
- "_exists": true
826
- },
827
- "avatar": {
828
- "_or": {
829
- "title": {
830
- "_eq": "New user avatar"
831
- },
832
- "description": {
833
- "_like": "%avatar%"
834
- }
835
- },
836
- "originalName": {
837
- "_eq": "new_user_avatar.png"
838
- }
839
- }
840
- },
841
- "_or": [
842
- {
843
- "firstName": {
844
- "_like": "Luk%"
845
- }
846
- },
847
- {
848
- "lastName": "Matošević"
849
- }
850
- ],
851
- "tags": {
852
- "_some": {
853
- "name": {
854
- "_contains": "news"
855
- }
856
- }
857
- }
858
- },
859
- "page": 1,
860
- "size": 50,
861
- "sort": "-createdAt,id"
862
- }
863
- ```
864
-
865
- The `QueryFilter<T>` type from `@appweaver/common` provides code completion, and `weaver generate` emits a
866
- `<Model>Query = QueryFilter<Model>` alias per model:
867
-
868
- ```ts
869
- import { QueryFilter } from '@appweaver/common';
870
- import { User, UserQuery } from '@/types/generated';
871
-
872
- const filter: UserQuery = {
873
- _and: {
874
- firstName: { _eq: 'Luka' },
875
- loginAt: { _exists: true }
876
- }
877
- };
878
- const users = await userService.query(filter);
879
- ```
880
-
881
- ### Query sorting
882
-
883
- The `sort` argument of `query` and `export` (and the `sort` property of the `POST /query` and `POST /export` request
884
- bodies) accepts two interchangeable forms, both applying their fields in the declared order:
885
-
886
- ```json
887
- {
888
- "sort": "-author.createdAt,tagsCount,id"
889
- }
890
- ```
891
-
892
- ```json
893
- {
894
- "sort": {
895
- "author": {
896
- "createdAt": "desc"
897
- },
898
- "tagsCount": "asc",
899
- "id": "asc"
900
- }
901
- }
902
- ```
903
-
904
- In the string form a `-` prefix sorts descending (`+` or no prefix ascending) and a dot notation path targets a relation
905
- field. In the object form a relation takes a nested object, and the only accepted directions are the lower case `asc`
906
- and `desc`.
907
-
908
- | Field | String form | Object form | Notes |
909
- |------------------------|---------------------|-------------------------------------|----------------------------------------------------------------------------------------|
910
- | Scalar, `id`, audit | `title`, `-id` | `{ title: 'asc' }` | Hidden scalars, array scalars, and virtual fields cannot be sorted by. |
911
- | To-one relation field | `-author.createdAt` | `{ author: { createdAt: 'desc' } }` | The relation must be included in the response of the action, at any nesting depth. |
912
- | To-many relation count | `-tagsCount` | `{ tagsCount: 'desc' }` | Sorts by the number of related records; the relation name alone (`-tags`) is an alias. |
913
-
914
- Anything else — a relation the action does not include, a field of a to-many relation, a hidden or virtual field, an
915
- unknown sort direction — is rejected with a `400` error naming the offending field instead of reaching the database.
916
- Over HTTP the sort object is additionally validated against a generated per-model `<Model>QuerySort` schema, which
917
- strips unknown fields the same way the query filter schema does.
918
-
919
- The default sort is `-createdAt,id`, and its `createdAt` part is dropped for models configured with
920
- `audit: { createdAt: false }`.
921
-
922
- Sort inputs are typed by `QuerySort<T>` from `@appweaver/common`, and `weaver generate` emits a
923
- `<Model>Sort = QuerySort<<Model>Multiple>` alias per model, built from the query output model so it only offers the
924
- relations a query response includes:
925
-
926
- ```ts
927
- import { PostSort } from '@/types/generated';
928
-
929
- const sort: PostSort = { author: { lastName: 'asc' }, createdAt: 'desc' };
930
- const posts = await postService.query({}, 1, 50, sort);
931
- ```
932
-
933
- ### Query response
934
-
935
- ```ts
936
- const config = {
937
- resultCount: 123, // Items in this page
938
- totalCount: 123, // Total items matching filter
939
- items: [] // Page data
940
- };
941
- ```
942
-
943
- ### Aggregate selection
944
-
945
- The `select` argument of `aggregate` (and the required `select` property of the `POST /aggregate` request body) holds
946
- the operators to apply per field. Only the fields the database can aggregate are accepted, which are the numeric and
947
- date scalars of the model together with its numeric `id` and audit fields:
948
-
949
- | Field kind | Operators |
950
- |------------------------------------|------------------------------------------------------|
951
- | Numeric (`int`, `bigInt`, `float`) | `count`, `sum`, `avg`, `min`, `max`, `first`, `last` |
952
- | Date (`dateTime`) | `count`, `min`, `max`, `first`, `last` |
953
-
954
- ```json
955
- {
956
- "select": {
957
- "counter": {
958
- "count": true,
959
- "sum": true,
960
- "avg": true,
961
- "first": true,
962
- "last": true
963
- },
964
- "publishedAt": {
965
- "min": true,
966
- "max": true
967
- }
968
- },
969
- "dateField": "createdAt",
970
- "from": "2026-01-01T00:00:00.000Z",
971
- "to": "2026-01-08T00:00:00.000Z"
972
- }
973
- ```
974
-
975
- **`first` and `last`** take the value held by the earliest and the latest record of a period, ordered by the aggregated
976
- `dateField` (ties broken by `id`), or `null` for a period holding no record. The database cannot aggregate them, so each
977
- non-empty period requesting them costs up to two extra queries.
978
-
979
- Any other field, an operator its field kind does not support, and an empty selection are rejected with a `400` error.
980
- Over HTTP the selection is also validated against a generated per-model `<Model>AggregateSelect` schema. The `dateField`
981
- must be a date field of the model (`createdAt` by default).
982
-
983
- Selections are typed by `AggregateSelect<T>` from `@appweaver/common`, with a `<Model>Aggregate` alias emitted per
984
- model:
985
-
986
- ```ts
987
- import { PostAggregate } from '@/types/generated';
988
-
989
- const select: PostAggregate = { counter: { sum: true }, createdAt: { max: true } };
990
- const stats = await postService.aggregate({}, select);
991
- ```
992
-
993
- ### Aggregate response
994
-
995
- The response is untyped JSON, since its shape follows whatever was selected. Each aggregated field holds one property
996
- per operator applied to it:
997
-
998
- ```ts
999
- const resp = {
1000
- total: AggregateValue, // Overall aggregation
1001
- items: Array<AggregateResult> // Per-period results
1002
- };
1003
-
1004
- // Each AggregateResult:
1005
- const result = {
1006
- date: 'Date',
1007
- result: {
1008
- [field]: {
1009
- count: 123,
1010
- min: 123, // an ISO date string for a date field
1011
- max: 123, // an ISO date string for a date field
1012
- avg: 123, // numeric fields only
1013
- sum: 123, // numeric fields only
1014
- first: 123, // value of the earliest record of the period
1015
- last: 123 // value of the latest record of the period
1016
- }
1017
- }
1018
- };
1019
- ```
1020
-
1021
- ### Text search example
1022
-
1023
- Object form with placeholder:
1024
-
1025
- ```ts
1026
- const config = {
1027
- textSearch: {
1028
- title: {
1029
- contains: '{input}', mode:
1030
- 'insensitive'
1031
- }
1032
- }
1033
- };
1034
- ```
1035
-
1036
- Function form for complex queries:
1037
-
1038
- ```ts
1039
- const config = {
1040
- textSearch: (input) => ({
1041
- OR: [
1042
- { title: { contains: input, mode: 'insensitive' } },
1043
- { description: { contains: input, mode: 'insensitive' } }
1044
- ]
1045
- })
1046
- };
1047
- ```
1048
-
1049
- ---
1050
-
1051
- ## createRoutes
1052
-
1053
- Creates CRUD route definitions for a resource. Routes are automatically registered with Fastify and derive their
1054
- request/response schemas from the resource model.
1055
-
1056
- ```ts
1057
- import { createRoutes } from '@appweaver/core';
1058
-
1059
- export default createRoutes({
1060
- modelName: 'Product',
1061
- path: '/products',
1062
- find: { roles: ['Admin', 'User'], rateLimit: { max: 100 } },
1063
- query: { cache: true, cacheTTL: 5000 },
1064
- create: { permissions: ['product:create'] },
1065
- delete: { exclude: true }
1066
- });
1067
- ```
1068
-
1069
- ### Configuration
1070
-
1071
- ```ts
1072
- function createRoutes(config: ResourceRoutesConfig, override ?: Partial<ResourceRoutesConfig>) {
1073
- }
1074
- ```
1075
-
1076
- | Property | Type | Description |
1077
- |--------------|-----------------|----------------------------------------------------------|
1078
- | `modelName` | string | Model name to bind routes to (required). |
1079
- | `path` | string | Custom base URL path (default: derived from model name). |
1080
- | `find` | ReadRouteConfig | `GET /:id` - Find single resource by ID. |
1081
- | `query` | ReadRouteConfig | `POST /query` - Query resources with filters. |
1082
- | `aggregate` | ReadRouteConfig | `POST /aggregate` - Aggregate resources. |
1083
- | `create` | RouteConfig | `POST /` - Create a new resource. |
1084
- | `update` | RouteConfig | `PUT /:id` - Update a resource. |
1085
- | `delete` | RouteConfig | `DELETE /:id` - Delete a resource. |
1086
- | `export` | RouteConfig | `POST /export` - Export resources to CSV. |
1087
- | `fileUpload` | RouteConfig | `POST /:id/files` - Upload files to a resource. |
1088
- | `fileDelete` | RouteConfig | `POST /:id/delete-files` - Delete files from a resource. |
1089
-
1090
- ### Route config (all operations)
1091
-
1092
- | Property | Type | Default | Description |
1093
- |-------------------|--------------------------|---------|---------------------------------------------------------------|
1094
- | `exclude` | boolean | `false` | Exclude this operation entirely. |
1095
- | `public` | boolean | `false` | No authentication required. |
1096
- | `roles` | string[] | - | Required roles (OR logic by default). |
1097
- | `permissions` | string[] | - | Required permissions (OR logic by default). |
1098
- | `auth` | AuthType[] | - | Allowed authentication types: `'jwt'`, `'apiKey'`, `'basic'`. |
1099
- | `rateLimit` | RateLimitConfig \| false | - | Per-operation rate limiting. `false` disables. |
1100
- | `recaptcha` | boolean | `false` | Require reCAPTCHA verification. |
1101
- | `recaptchaAction` | string | - | Expected reCAPTCHA action name for score validation. |
1102
-
1103
- ### Read route config (find, query, aggregate)
1104
-
1105
- Extends RouteConfig with caching options:
1106
-
1107
- | Property | Type | Default | Description |
1108
- |-------------------------|--------------------|---------|----------------------------------------------------------------|
1109
- | `cache` | boolean | `false` | Enable response caching. |
1110
- | `cacheKey` | string \| function | - | Custom cache key. Function signature: `(req, user) => string`. |
1111
- | `cacheTTL` | number | - | Cache TTL in milliseconds (overrides global default). |
1112
- | `cacheSkipInvalidation` | boolean | `false` | Skip automatic cache invalidation on writes. |
1113
-
1114
- ### Rate limit config
1115
-
1116
- ```ts
1117
- const config = {
1118
- rateLimit: {
1119
- max: 100,
1120
- timeWindow: 60000,
1121
- allowList: ['127.0.0.1'],
1122
- keyGenerator: (req) => req.ip
1123
- }
1124
- };
1125
- ```
1126
-
1127
- | Property | Type | Description |
1128
- |----------------|------------------------------|---------------------------------------------------------------------|
1129
- | `max` | number \| function | Maximum requests per time window. Function: `(req, key) => number`. |
1130
- | `timeWindow` | number \| string \| function | Window duration in ms. Function: `(req, key) => number`. |
1131
- | `allowList` | string[] \| function | IPs exempt from limiting. Function: `(req, key) => boolean`. |
1132
- | `keyGenerator` | function | Custom key generator. Signature: `(req) => string \| number`. |
1133
-
1134
- ---
1135
-
1136
- ## createPolicy
1137
-
1138
- Creates row-level security policies for a resource. The service layer evaluates the policy on every CRUD operation to
1139
- enforce fine-grained authorization beyond static role/permission checks.
1140
-
1141
- ```ts
1142
- import { createPolicy } from '@appweaver/core';
1143
-
1144
- export default createPolicy({
1145
- modelName: 'Product',
1146
- checkAccess: (user, resource, action) => resource.status === 'Draft',
1147
- readRestrictions: (user, resource, action) => ({
1148
- enabled: true
1149
- }),
1150
- files: {
1151
- photo: { accessType: 'public' }
1152
- }
1153
- });
1154
- ```
1155
-
1156
- ### Configuration
1157
-
1158
- ```ts
1159
- function createPolicy(config: ResourcePolicyConfig, override ?: Partial<ResourcePolicyConfig>) {
1160
- }
1161
- ```
1162
-
1163
- | Property | Type | Description |
1164
- |---------------------|---------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
1165
- | `modelName` | string | Model name to bind this policy to (required). |
1166
- | `checkAccess` | `(user, resource, action) => boolean` | Dynamic access check against a resource instance. Return `true` to allow, `false` to deny. |
1167
- | `readRestrictions` | `(user, resource, action) => filter` | Returns a Prisma filter object applied to all read queries (find, query, aggregate). Restricts which records are visible. |
1168
- | `writeRestrictions` | `(user, resource, action) => data` | Returns data to merge or validate on create/update operations. |
1169
- | `files` | Record\<string, FilePolicy> | Per-file field access policy. |
1170
-
1171
- **Action types**: `'find'`, `'query'`, `'aggregate'`, `'create'`, `'update'`, `'delete'`
1172
-
1173
- ### File policy
1174
-
1175
- | Property | Type | Default | Description |
1176
- |--------------|--------------------------------------------|---------------|--------------------------------------------------------------------------------------------------|
1177
- | `accessType` | `'public'` \| `'protected'` \| `'private'` | `'protected'` | File access level. `public` = anyone, `protected` = authenticated users, `private` = owner only. |
1178
- | `canAccess` | `(user, resource, file) => boolean` | - | Custom access check for reading files. |
1179
- | `canCreate` | `(user, resource, file) => boolean` | - | Custom access check for uploading files. |
1180
- | `canDelete` | `(user, resource, file) => boolean` | - | Custom access check for deleting files. |
1181
-
1182
- ---
1183
-
1184
- ## registerRoute
1185
-
1186
- Registers a custom Fastify route handler outside the resource system. Use this for endpoints that don't map to a
1187
- standard CRUD resource.
1188
-
1189
- ```ts
1190
- import { registerRoute, Router } from '@appweaver/core';
1191
- import { Type } from '@sinclair/typebox';
1192
-
1193
- registerRoute(
1194
- async function (router: Router) {
1195
- router.get('/search-result', {
1196
- schema: {
1197
- summary: 'Sample search result response route',
1198
- response: { 200: Type.Ref('SearchResult') }
1199
- },
1200
- handler: async () => {
1201
- return { message: 'Hello, world!' };
1202
- }
1203
- });
1204
- },
1205
- { public: true, cacheTTL: 15000 }
1206
- );
1207
- ```
1208
-
1209
- ### Config options
1210
-
1211
- | Property | Type | Description |
1212
- |-------------------------|--------------------------|---------------------------------------------|
1213
- | `exclude` | boolean | Skip registration of this route. |
1214
- | `public` | boolean | No authentication required. |
1215
- | `roles` | string[] | Required roles. |
1216
- | `permissions` | string[] | Required permissions. |
1217
- | `auth` | AuthType[] | Allowed authentication types. |
1218
- | `rateLimit` | RateLimitConfig \| false | Rate limiting configuration. |
1219
- | `recaptcha` | boolean | Require reCAPTCHA verification. |
1220
- | `recaptchaAction` | string | Expected reCAPTCHA action. |
1221
- | `cache` | boolean | Enable response caching. |
1222
- | `cacheKey` | string \| function | Custom cache key. |
1223
- | `cacheTTL` | number | Cache TTL in milliseconds. |
1224
- | `cacheSkipInvalidation` | boolean | Skip automatic cache invalidation. |
1225
- | `cacheModelName` | string | Model name for cache invalidation tracking. |
1226
- | `cacheRelations` | string[] | Related model names for cache invalidation. |
1227
-
1228
- ---
1229
-
1230
- ## registerModel
1231
-
1232
- Registers a custom TypeBox schema as a named model in the schema registry. Registered models can be referenced using
1233
- `Type.Ref('ModelName')` in route schemas.
1234
-
1235
- ```ts
1236
- import { registerModel } from '@appweaver/core';
1237
- import { Nullable } from '@appweaver/common';
1238
- import { Type } from '@sinclair/typebox';
1239
-
1240
- registerModel(
1241
- Type.Object(
1242
- {
1243
- id: Type.Integer(),
1244
- title: Type.String({ example: 'My Title' }),
1245
- description: Nullable(Type.String({ maxLength: 512 })),
1246
- score: Type.Number({ minimum: 0, maximum: 1 })
1247
- },
1248
- { $id: 'SearchResult' } // The prefered way for naming the model
1249
- ),
1250
- 'SearchResult' // Model name can be overriden as a second optional argument
1251
- );
1252
- ```
1253
-
1254
- | Parameter | Type | Description |
1255
- |-----------|---------|--------------------------------------------------------------|
1256
- | `schema` | TObject | TypeBox object schema definition. |
1257
- | `name` | string? | Override schema name identifier for `Type.Ref()` references. |
1258
-
1259
- ---
1260
-
1261
- ## registerPlugin
1262
-
1263
- Registers a custom Fastify plugin. Plugins are wrapped with `fastify-plugin` so their decorators and hooks are scoped to
1264
- the entire server instance.
1265
-
1266
- ```ts
1267
- import { registerPlugin } from '@appweaver/core';
1268
-
1269
- registerPlugin(
1270
- 'audit-log',
1271
- async (server) => {
1272
- server.addHook('onResponse', async (request, reply) => {
1273
- logger.info(`${request.method} ${request.url} -> ${reply.statusCode}`);
1274
- });
1275
- },
1276
- ['other-plugin'] // optional dependencies
1277
- );
1278
- ```
1279
-
1280
- | Parameter | Type | Description |
1281
- |----------------|-------------------------------------|-------------------------------------------------------|
1282
- | `name` | string | Plugin name (used for dependency resolution). |
1283
- | `plugin` | `(server) => void \| Promise<void>` | Fastify plugin function. |
1284
- | `dependencies` | string[] | Optional list of plugin names this plugin depends on. |
1
+ # Resources
2
+
3
+ Resources are the core building blocks of an Appweaver application. There are four resource types that form a dependency
4
+ chain: **model** → **service** → **routes** → **policy**. Each resource type is created using a corresponding factory
5
+ function and autoloaded from `src/resources/*/` on application start. Source directory and resources pattern could be
6
+ changed with `APP_SOURCE_PATH` and `RESOURCE_{MODEL,SERVICE,...}_PATTERN` config variables.
7
+
8
+ - A **model** is always required.
9
+ - A **service** requires a model.
10
+ - The **Routes** require a service.
11
+ - A **policy** is optional and independent of the chain.
12
+
13
+ ---
14
+
15
+ ## createModel
16
+
17
+ Creates a resource model definition. The model defines database fields, relations, files, virtual fields, DTOs for CRUD
18
+ operations, and index configuration. It is used to generate Prisma schema, TypeScript types, and route request/response
19
+ schemas.
20
+
21
+ ```ts
22
+ import { createModel } from '@appweaver/core';
23
+
24
+ export default createModel({
25
+ name: 'Product',
26
+ // ... configuration
27
+ });
28
+ ```
29
+
30
+ ### Configuration
31
+
32
+ ```ts
33
+ function createModel(config: ResourceModelConfig, override ?: Partial<ResourceModelConfig>) {
34
+ }
35
+ ```
36
+
37
+ | Property | Type | Required | Default | Description |
38
+ |------------------|--------------------------------|----------|-----------------------|---------------------------------------------------------------------|
39
+ | `name` | string | yes | - | Model name (PascalCase). Used as database table name and type name. |
40
+ | `tableName` | string | no | (model name) | Custom database table name override. |
41
+ | `generateTypes` | boolean | no | `true` | Generate TypeScript types for this model. |
42
+ | `generateSchema` | boolean | no | `true` | Generate Prisma schema for this model. |
43
+ | `id` | IdField | no | Autoincrement integer | ID field configuration. |
44
+ | `audit` | AuditFields | no | All included | Audit timestamps and creator tracking fields. |
45
+ | `scalars` | Record\<string, ScalarField> | no | - | Scalar fields (database columns). |
46
+ | `relations` | Record\<string, RelationField> | no | - | Relations to other models. |
47
+ | `files` | Record\<string, FileField> | no | - | File upload fields. |
48
+ | `virtual` | Record\<string, VirtualField> | no | - | Computed/virtual fields not stored in database. |
49
+ | `read` | OperationConfig | no | - | Pick/omit fields for the read DTO. |
50
+ | `create` | OperationConfig | no | - | Pick/omit fields for the create DTO. |
51
+ | `update` | OperationConfig | no | - | Pick/omit fields for the update DTO. |
52
+ | `export` | Record\<string, ExportField> | no | - | CSV export field configuration. |
53
+ | `index` | string[] \| string[][] | no | - | Database index definitions (`-field` desc, `+field` asc). |
54
+
55
+ ### ID field
56
+
57
+ ```ts
58
+ const config = {
59
+ // Integer ID with autoincrement (default)
60
+ id: {
61
+ type: 'int',
62
+ generator: 'autoincrement()'
63
+ },
64
+
65
+ // String ID with generator
66
+ id: {
67
+ type: 'string',
68
+ generator: 'uuid()'
69
+ }
70
+ };
71
+ ```
72
+
73
+ | Property | Type | Default | Description |
74
+ |-------------|-----------------------------------------------------------------------------------------------|-----------------------------------------------|------------------------------------------------------|
75
+ | `type` | `'string'` \| `'int'` \| `'bigInt'` | `'int'` | ID field data type. |
76
+ | `generator` | `'uuid()'` \| `'uuid(7)'` \| `'cuid()'` \| `'cuid(2)'` \| `'nanoid()'` \| `'autoincrement()'` | `'autoincrement()'` (`'uuid()'` for `string`) | Value generator. String types use UUID/CUID/Nano ID. |
77
+
78
+ Declaring only a string generator (i.e. `{ generator: 'cuid()' }`) infers the `'string'` type.
79
+
80
+ #### String IDs
81
+
82
+ String IDs are generated on creation like auto-incrementing integers, so no value is sent. Both ID types can be mixed
83
+ across models in the same project, and the choice flows through everywhere the primary key appears:
84
+
85
+ | Where | Integer ID | String ID |
86
+ |--------------------------------|--------------------------|-----------------------------------------|
87
+ | Prisma column | `id Int @id` | `id String @id` |
88
+ | Generated TypeScript type | `id: number` | `id: string` |
89
+ | Route path parameter | `GET /posts/{id}` number | `GET /comments/{id}` string |
90
+ | Foreign key on a related model | `authorId Int` | `pinnedCommentId String` |
91
+ | Relation input | `{ author: 12 }` | `{ pinnedComment: 'k4pcxi0t5vs8rl65' }` |
92
+ | `createdById` audit column | `Int?` | `String?` (follows the auth model) |
93
+ | Service methods | `find(12)` | `find('k4pcxi0t5vs8rl65')` |
94
+
95
+ #### Generated column types
96
+
97
+ Generated string columns are sized after the value they hold, on the primary key, the foreign keys referencing it, and
98
+ any string scalar with a `defaultGenerator`. SQLite keeps the plain column.
99
+
100
+ | Generator | PostgreSQL | MySQL | SQL Server |
101
+ |---------------------|-------------------|-------------------|------------------------|
102
+ | `uuid()`, `uuid(7)` | `@db.Uuid` | `@db.Char(36)` | `@db.UniqueIdentifier` |
103
+ | `cuid()` | `@db.VarChar(25)` | `@db.VarChar(25)` | `@db.VarChar(25)` |
104
+ | `cuid(2)` | `@db.VarChar(24)` | `@db.VarChar(24)` | `@db.VarChar(24)` |
105
+ | `nanoid()` | `@db.VarChar(21)` | `@db.VarChar(21)` | `@db.VarChar(21)` |
106
+
107
+ The generator width wins over an explicit `maxLength`, which only bounds what the API accepts.
108
+
109
+ Service and hook signatures take `ResourceId` (`number | string`), so they work with either ID type:
110
+
111
+ ```ts
112
+ import { ResourceId } from '@appweaver/common';
113
+
114
+ export default createService({
115
+ modelName: 'Comment',
116
+ beforeFind: (id: ResourceId) => console.log('Finding comment', id)
117
+ });
118
+ ```
119
+
120
+ The `resourceId` column of the built-in `File` model stores the owning record ID as text, so files attach to resources
121
+ with either ID type.
122
+
123
+ > Changing the ID type of the existing model rewrites its primary key column and every foreign key pointing at it. Run
124
+ > `weaver generate` then `weaver migration new <name>`, and treat it as destructive on a populated database.
125
+
126
+ ### Audit fields
127
+
128
+ It is recommended to always use all audit fields for all resource models, unless specified otherwise. In the usual
129
+ scenario audit should be left out (including all fields by default).
130
+
131
+ ```ts
132
+ const config = {
133
+ // By default all audit fields are included
134
+ audit: {
135
+ createdAt: true,
136
+ updatedAt: true,
137
+ createdById: true
138
+ }
139
+ };
140
+ ```
141
+
142
+ | Property | Type | Default | Description |
143
+ |---------------|---------|---------|-------------------------------------------------|
144
+ | `createdAt` | boolean | `true` | Add `createdAt` timestamp field. |
145
+ | `updatedAt` | boolean | `true` | Add `updatedAt` timestamp field. |
146
+ | `createdById` | boolean | `true` | Add `createdById` foreign key to the auth user. |
147
+
148
+ ### Scalar field types
149
+
150
+ All scalar fields share these common properties:
151
+
152
+ | Property | Type | Default | Description |
153
+ |---------------------|-----------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------|
154
+ | `required` | boolean | `true` | Whether the field is required. |
155
+ | `unique` | boolean | `false` | Add a unique constraint. |
156
+ | `hidden` | boolean | `false` | Hide from API output (e.g. password hashes). |
157
+ | `default` | varies | - | Default static value. |
158
+ | `defaultGenerator` | string | - | Default is generated by function (e.g. uuid(), cuid(), autoincrement(), now(), ...). |
159
+ | `defaultExpression` | string | - | Default is generated by database expression in supported database syntax (e.g. concat('token_', gen_random_uuid()))::TEXT). |
160
+ | `array` | boolean | `false` | Store as array (supported on string, int, float). |
161
+ | `example` | string \| number \| boolean | - | Example value for OpenAPI (Swagger) schema documentation. |
162
+
163
+ A `default` must satisfy the constraints declared on its own field (`minimum`, `maximum`, `minLength`, `maxLength`,
164
+ `pattern`, enum `values`) and match its type. The application **refuses to start** otherwise, naming every offending
165
+ field.
166
+
167
+ #### String
168
+
169
+ ```ts
170
+ const config = {
171
+ title: {
172
+ type: 'string',
173
+ minLength: 1,
174
+ maxLength: 200,
175
+ default: 'No title'
176
+ },
177
+ email: {
178
+ type: 'string',
179
+ format: 'email'
180
+ },
181
+ slug: {
182
+ type: 'string',
183
+ pattern: '^[a-z0-9-]+$'
184
+ },
185
+ code: {
186
+ type: 'string',
187
+ defaultGenerator: 'uuid()'
188
+ }
189
+ };
190
+ ```
191
+
192
+ | Property | Type | Description |
193
+ |-------------|---------------------------------------------------------------------------------------|--------------------------------------|
194
+ | `type` | `'string'` | String field type. |
195
+ | `minLength` | number | Minimum string length. |
196
+ | `maxLength` | number | Maximum string length. |
197
+ | `format` | `'email'` \| `'hostname'` \| `'ipv4'` \| `'ipv6'` \| `'uri'` \| `'uuid'` \| `'regex'` | Built-in format validation. |
198
+ | `pattern` | string | Custom regex pattern for validation. |
199
+
200
+ String defaults can also be ID generators: `'uuid()'`, `'uuid(7)'`, `'cuid()'`, `'cuid(2)'`, `'nanoid()'`, which also
201
+ size the column (see [Generated column types](#generated-column-types)).
202
+
203
+ #### Number (int, bigInt, float)
204
+
205
+ ```ts
206
+ const config = {
207
+ price: {
208
+ type: 'float',
209
+ minimum: 0
210
+ },
211
+ quantity: {
212
+ type: 'int',
213
+ minimum: 0,
214
+ maximum: 10000
215
+ }
216
+ };
217
+ ```
218
+
219
+ | Property | Type | Description |
220
+ |-----------|------------------------------------|--------------------|
221
+ | `type` | `'int'` \| `'bigInt'` \| `'float'` | Number field type. |
222
+ | `minimum` | number | Minimum value. |
223
+ | `maximum` | number | Maximum value. |
224
+
225
+ Integer defaults can be `'autoincrement()'`.
226
+
227
+ #### Boolean
228
+
229
+ ```ts
230
+ const config = {
231
+ enabled: {
232
+ type: 'boolean',
233
+ default: true
234
+ }
235
+ };
236
+ ```
237
+
238
+ | Property | Type | Description |
239
+ |----------|-------------|---------------------|
240
+ | `type` | `'boolean'` | Boolean field type. |
241
+
242
+ #### DateTime
243
+
244
+ ```ts
245
+ const config = {
246
+ publishedAt: {
247
+ type: 'dateTime',
248
+ defaultGenerator: 'now()'
249
+ },
250
+ eventDate: {
251
+ type: 'dateTime',
252
+ format: 'date'
253
+ }
254
+ };
255
+ ```
256
+
257
+ | Property | Type | Description |
258
+ |----------|---------------------------------------|----------------------|
259
+ | `type` | `'dateTime'` | DateTime field type. |
260
+ | `format` | `'date-time'` \| `'time'` \| `'date'` | DateTime format. |
261
+
262
+ Default can be `'now()'` for current timestamp.
263
+
264
+ #### JSON
265
+
266
+ ```ts
267
+ const config = {
268
+ metadata: {
269
+ type: 'json',
270
+ default: {}
271
+ }
272
+ };
273
+ ```
274
+
275
+ | Property | Type | Description |
276
+ |----------|----------|------------------------------------------------------|
277
+ | `type` | `'json'` | JSON field type. Stores arbitrary objects or arrays. |
278
+
279
+ #### Enum
280
+
281
+ ```ts
282
+ const config = {
283
+ status: {
284
+ type: 'enum',
285
+ values: ['Draft', 'Active', 'Sold'],
286
+ default: 'Draft'
287
+ }
288
+ };
289
+ ```
290
+
291
+ | Property | Type | Description |
292
+ |----------|----------|---------------------------------|
293
+ | `type` | `'enum'` | Enum field type. |
294
+ | `values` | string[] | Allowed enum values (required). |
295
+
296
+ ### Relations
297
+
298
+ ```ts
299
+ // src/resources/product/model.ts
300
+ const config = {
301
+ relations: {
302
+ category: {
303
+ model: 'Category',
304
+ type: 'oneToMany',
305
+ mappedBy: 'products',
306
+ owner: true,
307
+ output: {
308
+ type: 'always'
309
+ }
310
+ },
311
+ reviews: {
312
+ model: 'Review',
313
+ type: 'oneToMany',
314
+ mappedBy: 'product',
315
+ output: {
316
+ type: 'single',
317
+ count: true
318
+ }
319
+ }
320
+ }
321
+ };
322
+ ```
323
+
324
+ ```ts
325
+ // src/resources/category/model.ts
326
+ const config = {
327
+ relations: {
328
+ products: {
329
+ model: 'Product',
330
+ type: 'oneToMany',
331
+ mappedBy: 'category',
332
+ output: {
333
+ type: 'single'
334
+ }
335
+ }
336
+ }
337
+ };
338
+ ```
339
+
340
+ ```ts
341
+ // src/resources/review/model.ts
342
+ const config = {
343
+ relations: {
344
+ product: {
345
+ model: 'Product',
346
+ type: 'oneToMany',
347
+ mappedBy: 'reviews',
348
+ owner: true,
349
+ input: {
350
+ type: 'none'
351
+ }
352
+ }
353
+ }
354
+ };
355
+ ```
356
+
357
+ | Property | Type | Default | Description |
358
+ |-----------------|-------------------------------------------------|--------------|--------------------------------------------------------------------------|
359
+ | `model` | string | **required** | Target model name. |
360
+ | `type` | `'oneToOne'` \| `'oneToMany'` \| `'manyToMany'` | **required** | Relation cardinality between the two models. |
361
+ | `owner` | boolean | `false` | This side owns the foreign key column (only one side should be owner). |
362
+ | `mappedBy` | string | - | Name of the inverse relation on the target model. |
363
+ | `required` | boolean | `true` | Whether the relation is required (nullable foreign key if not required). |
364
+ | `minItems` | number | - | Minimum items for list relations. |
365
+ | `orphanRemoval` | boolean | `false` | Delete orphaned records when parent is deleted. |
366
+ | `onDelete` | ReferentialAction | - | Foreign key action on delete. |
367
+ | `onUpdate` | ReferentialAction | - | Foreign key action on update. |
368
+ | `input` | RelationInput | - | Input DTO configuration. |
369
+ | `output` | RelationOutput | - | Output DTO configuration. |
370
+
371
+ **ReferentialAction values**: `'cascade'`, `'restrict'`, `'noAction'`, `'setNull'`, `'setDefault'`
372
+
373
+ Without an explicit `onDelete`, a **required** owning relation falls back to `restrict`, so deleting the referenced
374
+ record fails with a foreign key violation while any child row still exists. Set `onDelete: 'cascade'` on relations whose
375
+ rows are owned by the parent and meaningless without it. Optional owning relations (`required: false`) fall back to
376
+ `setNull`, which already lets the referenced record be deleted.
377
+
378
+ #### Relationship types
379
+
380
+ The `type` property declares the relation cardinality explicitly, and `owner` marks the side that holds the foreign key
381
+ column in the generated table:
382
+
383
+ **One-to-One** (`type: 'oneToOne'`): Both sides reference a single record. The side with `owner: true` holds a unique
384
+ foreign key; the inverse side is always optional.
385
+
386
+ ```ts
387
+ // User model
388
+ const config = {
389
+ relations: {
390
+ profile: {
391
+ model: 'Profile',
392
+ type: 'oneToOne',
393
+ mappedBy: 'user',
394
+ owner: true,
395
+ required: false // otherwise the Profile DTO must be sent when creating the user resource
396
+ }
397
+ }
398
+ };
399
+ ```
400
+
401
+ ```ts
402
+ // Profile model
403
+ const config = {
404
+ relations: {
405
+ user: {
406
+ model: 'User',
407
+ type: 'oneToOne',
408
+ mappedBy: 'profile'
409
+ }
410
+ }
411
+ };
412
+ ```
413
+
414
+ **One-to-Many** (`type: 'oneToMany'`): The "many" side (which holds the foreign key) has `owner: true` and references a
415
+ single record; the "one" side has no `owner` and holds a list of related records.
416
+
417
+ ```ts
418
+ // Category model (one, list side)
419
+ const config = {
420
+ relations: {
421
+ products: {
422
+ model: 'Product',
423
+ type: 'oneToMany',
424
+ mappedBy: 'category'
425
+ }
426
+ }
427
+ };
428
+ ```
429
+
430
+ ```ts
431
+ // Product model (many, foreign key side)
432
+ const config = {
433
+ relations: {
434
+ category: {
435
+ model: 'Category',
436
+ type: 'oneToMany',
437
+ mappedBy: 'products',
438
+ owner: true
439
+ }
440
+ }
441
+ };
442
+ ```
443
+
444
+ **Many-to-Many** (`type: 'manyToMany'`): Both sides hold lists of related records, joined through an implicit join
445
+ table. The `owner` property has no effect on this relation type.
446
+
447
+ ```ts
448
+ // Post model
449
+ const config = {
450
+ relations: {
451
+ tags: {
452
+ model: 'Tag',
453
+ type: 'manyToMany',
454
+ mappedBy: 'posts'
455
+ }
456
+ }
457
+ };
458
+ ```
459
+
460
+ ```ts
461
+ // Tag model
462
+ const config = {
463
+ relations: {
464
+ posts: {
465
+ model: 'Post',
466
+ type: 'manyToMany',
467
+ mappedBy: 'tags'
468
+ }
469
+ }
470
+ };
471
+ ```
472
+
473
+ #### Relation pair validation
474
+
475
+ `weaver generate` validates every bidirectional relation pair linked through `mappedBy` and fails schema generation with
476
+ a descriptive error when the two sides are inconsistent:
477
+
478
+ - Both sides must declare the same relation `type`.
479
+ - The mapped relation must reference the declaring model back via its `model` property.
480
+ - For `oneToOne` and `oneToMany` relations, exactly one side must declare `owner: true` (neither or both is an error).
481
+
482
+ A relation whose `mappedBy` field does not exist on the target model is treated as single-sided and skipped by the
483
+ validation; an inverse field is generated automatically in the Prisma schema.
484
+
485
+ #### Relation input
486
+
487
+ | Property | Type | Description |
488
+ |---------------|-------------------------------------------------|-------------------------------------------------------------------------------------------------------|
489
+ | `type` | `'all'` \| `'create'` \| `'update'` \| `'none'` | When the relation field is available as input. |
490
+ | `allowCreate` | boolean | Allow creating related records inline (input objects without an `id`). |
491
+ | `allowUpdate` | boolean | Allow updating related records inline on parent update requests (input objects with a required `id`). |
492
+ | `uniqueKey` | string | Unique field matching existing records, turning an inline create into a connect-or-create. |
493
+
494
+ Both flags are off by default: a relation only connects existing records unless `allowCreate` / `allowUpdate` is set.
495
+
496
+ By default, a relation input only connects existing records. It accepts an id value, an `{ id }` object, or an array of
497
+ either for list relations. The `allowCreate` and `allowUpdate` flags also accept the related model's own data:
498
+
499
+ - **`allowCreate: true`** — input objects **without** an `id` create the related record inline. The accepted fields are
500
+ the related model's create data, without its own relations and files (`<Model>RelationCreate`).
501
+ - **`allowUpdate: true`** — input objects **with** an `id` and further fields update the related record inline
502
+ (`<Model>RelationUpdate`). Objects carrying only an `id` are connected instead. This applies to parent **update**
503
+ requests only. On parent **create** requests every object with an `id` is connected, since the database updates
504
+ relations only within an update action.
505
+
506
+ Relations that accept inline writes document their request shape as `<Model>RelationInput`. It holds the id and the
507
+ fields of both shapes above, all optional. The shape stays permissive on purpose, since the server strips the properties
508
+ that the matched schema does not declare. The service applies the restrictions instead. Fields excluded by the related
509
+ model's `create` or `update` config are dropped. A missing required create field fails with a `400` error naming the
510
+ field.
511
+
512
+ Connect, create, and update inputs can be mixed within one list relation request:
513
+
514
+ ```ts
515
+ // PUT /api/users/1
516
+ {
517
+ posts: [
518
+ 5, // connect post 5 by id
519
+ { id: 7, title: 'Renamed' }, // update post 7 inline
520
+ { title: 'Fresh post', slug: 'new' } // create a new post inline
521
+ ]
522
+ }
523
+ ```
524
+
525
+ Records without an `id` require `allowCreate: true`. Otherwise, the request fails with a `400` error and the related
526
+ record has to be created through its own endpoint first. With `allowCreate` set, a `uniqueKey` matches an existing
527
+ record by that field before creating a new one, so the inline create becomes a connect-or-create. Without
528
+ `allowCreate` the `uniqueKey` has no effect. Plain connect and inline update always match related records by `id`.
529
+
530
+ #### Relation output
531
+
532
+ | Property | Type | Description |
533
+ |-----------|------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
534
+ | `type` | `'always'` \| `'single'` \| `'multiple'` \| `'none'` | When to include the relation in output. `always` = all reads, `single` = single record reads, `multiple` = list reads, `none` = never. |
535
+ | `include` | Record\<string, RelationOutput> | Nested relation output configuration. |
536
+ | `count` | boolean | Include a count of related records. |
537
+
538
+ ### File fields
539
+
540
+ ```ts
541
+ const config = {
542
+ files: {
543
+ photo: {
544
+ mimeType: 'image/*',
545
+ namePattern: 'photos/{userId}-{name}-{hash}.{extension}',
546
+ maxSize: '2 MB',
547
+ image: {
548
+ quality: 80,
549
+ maxWidth: 1200,
550
+ maxHeight: 1200,
551
+ fit: 'inside'
552
+ }
553
+ },
554
+ documents: {
555
+ mimeType: 'application/pdf',
556
+ array: true,
557
+ maxCount: 5
558
+ }
559
+ }
560
+ };
561
+ ```
562
+
563
+ | Property | Type | Description |
564
+ |---------------------|------------------------|-------------------------------------------------------------------------------------------------------------|
565
+ | `mimeType` | string \| RegExp | Allowed MIME types (glob patterns like `'image/*'` supported). |
566
+ | `namePattern` | string \| function | File naming pattern or function (available variables are listed below). |
567
+ | `array` | boolean | Allow multiple files. |
568
+ | `maxSize` | number \| string | Maximum file size (e.g. `'2 MB'`, `5242880`). |
569
+ | `maxCount` | number | Maximum number of files (for array fields). |
570
+ | `output` | RelationOutput | When to include file info in output. |
571
+ | `onResourceDeleted` | `'delete'` \| `'keep'` | When the owning resource is deleted. `'delete'` (default) removes files from storage, `'keep'` leaves them. |
572
+ | `image` | ImageConfig | Image compression and resize settings. Only applies to image MIME types (excluding GIF). |
573
+
574
+ #### Available namePattern variables
575
+
576
+ Default pattern is: `{name}-{hash}.{extension}`.
577
+
578
+ | Variable | Type | Description |
579
+ |-----------------|--------|---------------------------------------------|
580
+ | `name` | string | Original filename without extension. |
581
+ | `extension` | string | Original file extension. |
582
+ | `resourceField` | string | Field name the file is assigned to. |
583
+ | `resourceName` | string | Resource model name. |
584
+ | `resourceId` | string | Resource ID. |
585
+ | `userId` | string | Authenticated user ID. |
586
+ | `userEmail` | string | Authenticated user email. |
587
+ | `year` | number | Current UTC year. |
588
+ | `month` | number | Current UTC month (1-12). |
589
+ | `day` | number | Current UTC day of month. |
590
+ | `weekDay` | number | Current UTC day of week (0-6, Sunday is 0). |
591
+ | `yearWeek` | number | ISO week number. |
592
+ | `yearDay` | number | Day of year (1-366). |
593
+ | `hours` | number | Current UTC hours. |
594
+ | `minutes` | number | Current UTC minutes. |
595
+ | `seconds` | number | Current UTC seconds. |
596
+ | `milliseconds` | number | Current UTC milliseconds. |
597
+ | `timestamp` | number | Unix timestamp in milliseconds. |
598
+ | `date` | string | Current date in ISO 8601 format. |
599
+ | `uuid` | string | Generated random UUID. |
600
+ | `hash` | string | Generated random hash (32 bytes). |
601
+
602
+ #### Image compression
603
+
604
+ Configure automatic image compression and resizing by adding the `image` property to a file field. Processing only
605
+ applies to supported image MIME types: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `image/tiff`. GIF files
606
+ are passed through unchanged.
607
+
608
+ | Property | Type | Description |
609
+ |-------------|----------|----------------------------------------------------------------------------------------------------------------|
610
+ | `quality` | number | Compression quality (1-100). Applies to JPEG, PNG, WebP, AVIF, and TIFF. |
611
+ | `width` | number | Exact resize width in pixels. |
612
+ | `height` | number | Exact resize height in pixels. |
613
+ | `maxWidth` | number | Maximum width. Only downscales if the image exceeds this dimension. |
614
+ | `maxHeight` | number | Maximum height. Only downscales if the image exceeds this dimension. |
615
+ | `fit` | ImageFit | How the image fits the target dimensions: `'inside'` (default), `'contain'`, `'cover'`, `'fill'`, `'outside'`. |
616
+
617
+ `width`/`height` take precedence over `maxWidth`/`maxHeight`. When using `maxWidth`/`maxHeight`, images smaller than the
618
+ specified dimensions are not enlarged.
619
+
620
+ ```ts
621
+ // Compress and limit dimensions
622
+ const config = {
623
+ files: {
624
+ avatar: {
625
+ mimeType: 'image/*',
626
+ maxSize: '5 MB',
627
+ image: { quality: 80, maxWidth: 800, maxHeight: 800 }
628
+ }
629
+ }
630
+ };
631
+ ```
632
+
633
+ ```ts
634
+ // Exact resize for thumbnails
635
+ const config = {
636
+ files: {
637
+ thumbnail: {
638
+ mimeType: 'image/jpeg',
639
+ image: { quality: 70, width: 200, height: 200, fit: 'inside' }
640
+ }
641
+ }
642
+ }
643
+ ```
644
+
645
+ ### Virtual fields
646
+
647
+ Virtual fields are computed values not stored in the database. They can appear in input DTOs (to receive data) and/or
648
+ output DTOs (to return computed values).
649
+
650
+ ```ts
651
+ const config = {
652
+ virtual: {
653
+ displayName: {
654
+ type: 'string',
655
+ output: {
656
+ type: 'always',
657
+ value: (resource) => `${resource.firstName} ${resource.lastName}`
658
+ }
659
+ },
660
+ inviteCode: {
661
+ type: 'string',
662
+ input: {
663
+ type: 'create'
664
+ }
665
+ }
666
+ }
667
+ };
668
+ ```
669
+
670
+ | Property | Type | Description |
671
+ |------------------|------------------------------------------------------|------------------------------------------------------------|
672
+ | *(scalar props)* | - | All scalar field properties (type, minLength, etc.) apply. |
673
+ | `input.type` | `'all'` \| `'create'` \| `'update'` \| `'none'` | When the virtual field accepts input. |
674
+ | `input.value` | primitive \| function | Default value or transformer for input. |
675
+ | `output.type` | `'always'` \| `'single'` \| `'multiple'` \| `'none'` | When the virtual field appears in output. |
676
+ | `output.value` | primitive \| function | Computed value or transformer for output. |
677
+
678
+ Virtual output values are applied automatically to responses of resource CRUD routes (including nested relation and file
679
+ objects) and to responses of custom `registerRoute` routes whose 2xx response schemas reference resource output models.
680
+ To apply them manually on a raw resource object (e.g. one fetched directly through a Prisma client), use the
681
+ `projectVirtualFields` helper:
682
+
683
+ ```ts
684
+ import { projectVirtualFields } from '@appweaver/core';
685
+
686
+ const projected = projectVirtualFields(post, 'Post'); // sets virtual values, recursing into relations and files
687
+ ```
688
+
689
+ ### Operation config (read, create, update)
690
+
691
+ Control which fields appear in each DTO. Use `pick` for an allowlist or `omit` for a deny-list.
692
+
693
+ ```ts
694
+ const config = {
695
+ create: {
696
+ omit: ['status'] // All fields except status
697
+ },
698
+ update: {
699
+ pick: ['title', 'price'] // Only title and price
700
+ }
701
+ };
702
+ ```
703
+
704
+ | Property | Type | Description |
705
+ |----------|----------|------------------------------------------------|
706
+ | `omit` | string[] | Fields to exclude from the DTO. |
707
+ | `pick` | string[] | Fields to include in the DTO (overrides omit). |
708
+
709
+ ### Export config
710
+
711
+ Configure CSV export behavior per field:
712
+
713
+ ```ts
714
+ const config = {
715
+ export: {
716
+ price: {
717
+ headerName: 'Product Price',
718
+ mapValue: 'price'
719
+ },
720
+ passwordHash: {
721
+ exclude: true
722
+ },
723
+ status: {
724
+ mapValue: (val) => val.toUpperCase()
725
+ },
726
+ author: {
727
+ firstName: {
728
+ headerName: 'Given Name'
729
+ },
730
+ lastName: {
731
+ headerName: 'Family Name'
732
+ }
733
+ }
734
+ }
735
+ };
736
+ ```
737
+
738
+ | Property | Type | Description |
739
+ |--------------|--------------------|------------------------------------|
740
+ | `headerName` | string | Custom CSV column header name. |
741
+ | `exclude` | boolean | Exclude this field from exports. |
742
+ | `mapValue` | string \| function | Transform the value during export. |
743
+
744
+ A `string` `mapValue` names the field to read the column value from. On a relation or file field it is read off the
745
+ related record (and off every item for array relations, joined with `,`); on a scalar field it is read off the exported
746
+ record itself. A function `mapValue` receives the field value (or each item of an array field) and returns the column
747
+ value.
748
+
749
+ ### Index config
750
+
751
+ Define database indexes as a flat array (single-field indexes) or nested arrays (composite indexes):
752
+
753
+ ```ts
754
+ index: ['title'] // Single-field index on title
755
+ index: [['status', 'categoryId']] // Composite index on status + categoryId
756
+ index: ['email', ['status', 'createdAt']] // Both single and composite
757
+ ```
758
+
759
+ Prefix a field name with `-` for a descending index or `+` for an ascending one. Without a prefix the database default
760
+ order is used:
761
+
762
+ ```ts
763
+ index: ['-createdAt'] // @@index(createdAt(sort: Desc))
764
+ index: ['+title'] // @@index(title(sort: Asc))
765
+ index: [['status', '-createdAt']] // @@index([status, createdAt(sort: Desc)])
766
+ ```
767
+
768
+ The prefix is part of the index identity, so `['createdAt', '-createdAt']` emits two separate indexes.
769
+
770
+ ### Generated models
771
+
772
+ `createModel` produces the following TypeBox schema models used internally by routes and services:
773
+
774
+ | Model | Purpose |
775
+ |-------------------|------------------------------------|
776
+ | `readModel` | Full model with all visible fields |
777
+ | `createModel` | Request body for create operations |
778
+ | `updateModel` | Request body for update operations |
779
+ | `relationsModel` | Relations-only subset |
780
+ | `virtualModel` | Virtual fields-only subset |
781
+ | `filesModel` | File fields-only subset |
782
+ | `readOneModel` | Response for single-item reads |
783
+ | `readManyModel` | Response for list reads |
784
+ | `createOneModel` | Request for create endpoint |
785
+ | `updateOneModel` | Request for update endpoint |
786
+ | `fileUploadModel` | Request for file upload endpoint |
787
+ | `fileDeleteModel` | Request for file delete endpoint |
788
+
789
+ ---
790
+
791
+ ## createService
792
+
793
+ Creates a resource service with lifecycle hooks and business logic. The service handles all database operations for a
794
+ model and triggers hooks on each CRUD operation before/after.
795
+
796
+ ```ts
797
+ import { createService } from '@appweaver/core';
798
+
799
+ export default createService({
800
+ modelName: 'Product',
801
+ afterCreate: (resource) => {
802
+ logger.info(`Product created: ${resource.id}`);
803
+ },
804
+ textSearch: {
805
+ title: { contains: '{input}', mode: 'insensitive' }
806
+ }
807
+ });
808
+ ```
809
+
810
+ ### Configuration
811
+
812
+ ```ts
813
+ function createService(config: ResourceServiceConfig, override ?: Partial<ResourceServiceConfig>) {
814
+ }
815
+ ```
816
+
817
+ | Property | Type | Description |
818
+ |-------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|
819
+ | `modelName` | string | Model name to bind this service to (required). |
820
+ | `beforeFind` | `(id) => void` | Hook called before finding a single resource. |
821
+ | `beforeQuery` | `(filter, page, size, sort, cursor, totalCount) => void` | Hook called before querying resources. `sort` is a field list string or a sort object. |
822
+ | `beforeAggregate` | `(filter, select, dateField, from?, to?, step?, safeIncrement?) => void` | Hook called before aggregation. |
823
+ | `beforeCreate` | `(data) => void` | Hook called before creating a resource. Mutate `data` to modify input. |
824
+ | `beforeUpdate` | `(id, data) => void` | Hook called before updating a resource. |
825
+ | `beforeDelete` | `(id) => void` | Hook called before deleting a resource. |
826
+ | `afterFind` | `(resource) => void` | Hook called after finding a resource. |
827
+ | `afterQuery` | `(response) => void` | Hook called after querying resources. |
828
+ | `afterAggregate` | `(response) => void` | Hook called after aggregation. |
829
+ | `afterCreate` | `(resource) => void` | Hook called after creating a resource. |
830
+ | `afterUpdate` | `(resource) => void` | Hook called after updating a resource. |
831
+ | `afterDelete` | `(resource) => void` | Hook called after deleting a resource. |
832
+ | `textSearch` | object \| function | Prisma filter object or function `(input: string) => filter` for text search. Use `'{input}'` as placeholder in filter objects. |
833
+
834
+ All hooks can be synchronous or return a `Promise`.
835
+
836
+ ### Service methods
837
+
838
+ The created service exposes the following methods:
839
+
840
+ | Method | Signature | Description |
841
+ |-------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
842
+ | `find` | `(id) => Promise<ReadOne>` | Find a single resource by ID. |
843
+ | `query` | `(filter?, page?, size?, sort?, cursor?, totalCount?) => Promise<QueryResponse>` | Query resources with filtering, pagination, and sorting (see [Query sorting](#query-sorting) and [Cursor pagination](#cursor-pagination)). |
844
+ | `aggregate` | `(filter?, select?, dateField?, from?, to?, step?, safeIncrement?) => Promise<AggregateResponse>` | Aggregate resources with time-series grouping (see [Aggregate selection](#aggregate-selection)). |
845
+ | `create` | `(data) => Promise<ReadOne>` | Create a new resource. |
846
+ | `update` | `(id, data) => Promise<ReadOne>` | Update an existing resource. |
847
+ | `delete` | `(id) => Promise<ReadOne>` | Delete a resource. |
848
+ | `client` | `ResourceClient` (property) | Database client of the model, for operations outside the model contract. |
849
+
850
+ ### Typed service injection
851
+
852
+ `weaver generate` emits a `<Model>ResourceService` alias per model, so `injectService` needs no hand-written type:
853
+
854
+ ```ts
855
+ import { injectService } from '@appweaver/core';
856
+ import { PostResourceService } from '@/types/generated';
857
+
858
+ const posts = injectService<PostResourceService>('Post');
859
+ ```
860
+
861
+ The alias is `IResourceService<<Model>, <Model>Multiple, <Model>Create, <Model>Update, <Model>Query>`, so the
862
+ `<Model>Query`, `<Model>Sort`, and `<Model>Aggregate` aliases are exactly the inputs its methods accept.
863
+
864
+ The `create` and `update` inputs are the model's declared contracts, so a field an operation config omits, a hidden
865
+ scalar, or a relation with `input: { type: 'none' }` is deliberately not part of them. A write outside the contract
866
+ belongs on `service.client`, the database client of the model.
867
+
868
+ ### Query filters
869
+
870
+ The `filter` argument of `query`, `aggregate`, and `export` mirrors the WHERE part of a database query. The matching
871
+ `POST /query`, `POST /aggregate`, and `POST /export` routes accept the same structure, validated against a generated
872
+ per-model `<Model>QueryFilter` schema that strips unknown and hidden fields.
873
+
874
+ **Logical operators** (filter level) — take a single filter object (each entry becomes one condition) or a list of them:
875
+
876
+ | Operator | Description |
877
+ |----------|-------------------------------------------|
878
+ | `_and` | All nested conditions must match. |
879
+ | `_or` | At least one nested condition must match. |
880
+ | `_not` | No nested condition may match. |
881
+ | `_nor` | Alias of `_not`. |
882
+
883
+ **Comparison operators** (field level) — combined inside one object, all must match:
884
+
885
+ | Operator | Description |
886
+ |---------------------------------|------------------------------------------------------------------------------------------------------------------------------|
887
+ | `_eq` | Equal to the given value. |
888
+ | `_ne` | Not equal to the given value. |
889
+ | `_gt`, `_gte`, `_lt`, `_lte` | Greater/lower than (or equal to) the given value. |
890
+ | `_in`, `_nin` | Included / not included in the given list. |
891
+ | `_between` | Inside the inclusive `[min, max]` range. |
892
+ | `_like` | SQL LIKE pattern with `%` wildcards (`Luk%` → starts with, `%avatar%` → contains, `%png` → ends with, no wildcard → equals). |
893
+ | `_ilike` | Case-insensitive `_like` (uses `mode: 'insensitive'`, PostgreSQL and MongoDB only). |
894
+ | `_starts`, `_ends`, `_contains` | Starts with / ends with / contains the given string. |
895
+ | `_exists` | Not null (`true`) or null (`false`). |
896
+ | `_not` | Negates a nested operator object or plain value. |
897
+
898
+ **List (array scalar) operators**: `_has`, `_hasSome`, `_hasEvery`, `_isEmpty`.
899
+
900
+ **Relation operators**: `_some`, `_every`, `_none` take a filter of the related model; `_exists` maps to an `is`/`isNot`
901
+ null check on a single relation and to `some`/`none` on a list relation.
902
+
903
+ **Plain value shorthands**: a bare value matches by equality, a list by inclusion, a two-value list on a numeric or date
904
+ field as an inclusive range, a value or list on a relation by id, an array field uses `has`/`hasSome`, and `null`
905
+ matches missing values or related records.
906
+
907
+ ```json
908
+ {
909
+ "filter": {
910
+ "_and": {
911
+ "firstName": {
912
+ "_eq": "Luka",
913
+ "_exists": true
914
+ },
915
+ "avatar": {
916
+ "_or": {
917
+ "title": {
918
+ "_eq": "New user avatar"
919
+ },
920
+ "description": {
921
+ "_like": "%avatar%"
922
+ }
923
+ },
924
+ "originalName": {
925
+ "_eq": "new_user_avatar.png"
926
+ }
927
+ }
928
+ },
929
+ "_or": [
930
+ {
931
+ "firstName": {
932
+ "_like": "Luk%"
933
+ }
934
+ },
935
+ {
936
+ "lastName": "Matošević"
937
+ }
938
+ ],
939
+ "tags": {
940
+ "_some": {
941
+ "name": {
942
+ "_contains": "news"
943
+ }
944
+ }
945
+ }
946
+ },
947
+ "page": 1,
948
+ "size": 50,
949
+ "sort": "-createdAt",
950
+ "totalCount": true
951
+ }
952
+ ```
953
+
954
+ The `QueryFilter<T>` type from `@appweaver/common` provides code completion, and `weaver generate` emits a
955
+ `<Model>Query = QueryFilter<Model>` alias per model:
956
+
957
+ ```ts
958
+ import { QueryFilter } from '@appweaver/common';
959
+ import { User, UserQuery } from '@/types/generated';
960
+
961
+ const filter: UserQuery = {
962
+ _and: {
963
+ firstName: { _eq: 'Luka' },
964
+ loginAt: { _exists: true }
965
+ }
966
+ };
967
+ const users = await userService.query(filter);
968
+ ```
969
+
970
+ ### Query sorting
971
+
972
+ The `sort` argument of `query` and `export` (and the `sort` property of the `POST /query` and `POST /export` request
973
+ bodies) accepts two interchangeable forms, both applying their fields in the declared order:
974
+
975
+ ```json
976
+ {
977
+ "sort": "-author.createdAt,tagsCount,id"
978
+ }
979
+ ```
980
+
981
+ ```json
982
+ {
983
+ "sort": {
984
+ "author": {
985
+ "createdAt": "desc"
986
+ },
987
+ "tagsCount": "asc",
988
+ "id": "asc"
989
+ }
990
+ }
991
+ ```
992
+
993
+ In the string form a `-` prefix sorts descending (`+` or no prefix ascending) and a dot notation path targets a relation
994
+ field. In the object form a relation takes a nested object, and the only accepted directions are the lower case `asc`
995
+ and `desc`.
996
+
997
+ | Field | String form | Object form | Notes |
998
+ |------------------------|---------------------|-------------------------------------|----------------------------------------------------------------------------------------|
999
+ | Scalar, `id`, audit | `title`, `-id` | `{ title: 'asc' }` | Hidden scalars, array scalars, and virtual fields cannot be sorted by. |
1000
+ | To-one relation field | `-author.createdAt` | `{ author: { createdAt: 'desc' } }` | The relation must be included in the response of the action, at any nesting depth. |
1001
+ | To-many relation count | `-tagsCount` | `{ tagsCount: 'desc' }` | Sorts by the number of related records; the relation name alone (`-tags`) is an alias. |
1002
+
1003
+ Anything else — a relation the action does not include, a field of a to-many relation, a hidden or virtual field, an
1004
+ unknown sort direction — is rejected with a `400` error naming the offending field instead of reaching the database.
1005
+ Over HTTP the sort object is additionally validated against a generated per-model `<Model>QuerySort` schema, which
1006
+ strips unknown fields the same way the query filter schema does.
1007
+
1008
+ The default sort is `-createdAt`. Every sort is terminated with the primary key when it does not already order by one,
1009
+ so paging stays deterministic, and the `createdAt` entry is dropped for models configured with
1010
+ `audit: { createdAt: false }`.
1011
+
1012
+ Sort inputs are typed by `QuerySort<T>` from `@appweaver/common`, and `weaver generate` emits a
1013
+ `<Model>Sort = QuerySort<<Model>Multiple>` alias per model, built from the query output model so it only offers the
1014
+ relations a query response includes:
1015
+
1016
+ ```ts
1017
+ import { PostSort } from '@/types/generated';
1018
+
1019
+ const sort: PostSort = { author: { lastName: 'asc' }, createdAt: 'desc' };
1020
+ const posts = await postService.query({}, 1, 50, sort);
1021
+ ```
1022
+
1023
+ ### Query response
1024
+
1025
+ ```ts
1026
+ const config = {
1027
+ resultCount: 50, // Items in this page
1028
+ totalCount: 123, // Total items matching filter, omitted when totalCount is false
1029
+ nextCursor: '...', // Cursor of the following page, absent on the last page
1030
+ prevCursor: '...', // Cursor of the preceding page, absent on the first page
1031
+ items: [] // Page data
1032
+ };
1033
+ ```
1034
+
1035
+ ### Cursor pagination
1036
+
1037
+ The response returns a `nextCursor` and a `prevCursor`; send one back as `cursor` to get that page. The direction is
1038
+ part of the cursor, so a request never names one. A cursor takes precedence over `page` and does not slow down on the
1039
+ later pages.
1040
+
1041
+ ```ts
1042
+ // First page counted, the following ones skipping the count
1043
+ let result = await postService.query({}, 1, 50);
1044
+
1045
+ while (result.nextCursor) {
1046
+ result = await postService.query({}, 1, 50, undefined, result.nextCursor, false);
1047
+ }
1048
+ ```
1049
+
1050
+ ```json5
1051
+ // POST /posts/query
1052
+ {
1053
+ "filter": {
1054
+ "enabled": true
1055
+ },
1056
+ "size": 50,
1057
+ "sort": "-createdAt",
1058
+ "cursor": "eyJpIjo0MiwiZiI6IkhkQjVfa2VMTVlyNyJ9",
1059
+ "totalCount": false
1060
+ }
1061
+ ```
1062
+
1063
+ `totalCount` defaults to `true` and scans every matching record, so count once and send `false` afterward, which
1064
+ returns it as `null`.
1065
+
1066
+ A cursor is opaque and bound to the query that issued it: reusing one under a different resource, filter, or sort is
1067
+ rejected with a 400.
1068
+
1069
+ ### Aggregate selection
1070
+
1071
+ The `select` argument of `aggregate` (and the required `select` property of the `POST /aggregate` request body) holds
1072
+ the operators to apply per field. Only the fields the database can aggregate are accepted, which are the numeric and
1073
+ date scalars of the model together with its numeric `id` and audit fields:
1074
+
1075
+ | Field kind | Operators |
1076
+ |------------------------------------|------------------------------------------------------|
1077
+ | Numeric (`int`, `bigInt`, `float`) | `count`, `sum`, `avg`, `min`, `max`, `first`, `last` |
1078
+ | Date (`dateTime`) | `count`, `min`, `max`, `first`, `last` |
1079
+
1080
+ ```json
1081
+ {
1082
+ "select": {
1083
+ "counter": {
1084
+ "count": true,
1085
+ "sum": true,
1086
+ "avg": true,
1087
+ "first": true,
1088
+ "last": true
1089
+ },
1090
+ "publishedAt": {
1091
+ "min": true,
1092
+ "max": true
1093
+ }
1094
+ },
1095
+ "dateField": "createdAt",
1096
+ "from": "2026-01-01T00:00:00.000Z",
1097
+ "to": "2026-01-08T00:00:00.000Z"
1098
+ }
1099
+ ```
1100
+
1101
+ **`first` and `last`** take the value held by the earliest and the latest record of a period, ordered by the aggregated
1102
+ `dateField` (ties broken by `id`), or `null` for a period holding no record. The database cannot aggregate them, so each
1103
+ non-empty period requesting them costs up to two extra queries.
1104
+
1105
+ Any other field, an operator its field kind does not support, and an empty selection are rejected with a `400` error.
1106
+ Over HTTP the selection is also validated against a generated per-model `<Model>AggregateSelect` schema. The `dateField`
1107
+ must be a date field of the model (`createdAt` by default).
1108
+
1109
+ Selections are typed by `AggregateSelect<T>` from `@appweaver/common`, with a `<Model>Aggregate` alias emitted per
1110
+ model:
1111
+
1112
+ ```ts
1113
+ import { PostAggregate } from '@/types/generated';
1114
+
1115
+ const select: PostAggregate = { counter: { sum: true }, createdAt: { max: true } };
1116
+ const stats = await postService.aggregate({}, select);
1117
+ ```
1118
+
1119
+ `aggregate` infers the response type from the selection it is given, so a selection passed as an object literal, or
1120
+ declared with `satisfies`, narrows the response to the fields it names, while one annotated as `<Model>Aggregate` keeps
1121
+ every aggregatable field of the model:
1122
+
1123
+ ```ts
1124
+ const narrow = await postService.aggregate({}, { counter: { sum: true } });
1125
+ narrow.total.counter?.sum; // typed
1126
+ narrow.total.createdAt; // compile error, the field was not selected
1127
+
1128
+ const select = { counter: { sum: true } } satisfies PostAggregate; // narrows and checks against the model
1129
+ const wide: PostAggregate = { counter: { sum: true } }; // keeps the whole model in the response type
1130
+ ```
1131
+
1132
+ ### Aggregate response
1133
+
1134
+ The response shape follows whatever was selected, and its type carries the fields of the selection (see
1135
+ [Aggregate selection](#aggregate-selection)). Each aggregated field holds one property per operator applied to it, and
1136
+ the operators the selection left out are `undefined`:
1137
+
1138
+ ```ts
1139
+ const resp = {
1140
+ total: AggregateValue, // Overall aggregation
1141
+ items: Array<AggregateResult> // Per-period results
1142
+ };
1143
+
1144
+ // Each AggregateResult:
1145
+ const result = {
1146
+ date: 'Date',
1147
+ result: {
1148
+ [field]: {
1149
+ count: 123,
1150
+ min: 123, // an ISO date string for a date field
1151
+ max: 123, // an ISO date string for a date field
1152
+ avg: 123, // numeric fields only
1153
+ sum: 123, // numeric fields only
1154
+ first: 123, // value of the earliest record of the period
1155
+ last: 123 // value of the latest record of the period
1156
+ }
1157
+ }
1158
+ };
1159
+ ```
1160
+
1161
+ ### Text search example
1162
+
1163
+ Object form with placeholder:
1164
+
1165
+ ```ts
1166
+ const config = {
1167
+ textSearch: {
1168
+ title: {
1169
+ contains: '{input}', mode:
1170
+ 'insensitive'
1171
+ }
1172
+ }
1173
+ };
1174
+ ```
1175
+
1176
+ Function form for complex queries:
1177
+
1178
+ ```ts
1179
+ const config = {
1180
+ textSearch: (input) => ({
1181
+ OR: [
1182
+ { title: { contains: input, mode: 'insensitive' } },
1183
+ { description: { contains: input, mode: 'insensitive' } }
1184
+ ]
1185
+ })
1186
+ };
1187
+ ```
1188
+
1189
+ ---
1190
+
1191
+ ## createRoutes
1192
+
1193
+ Creates CRUD route definitions for a resource. Routes are automatically registered with Fastify and derive their
1194
+ request/response schemas from the resource model.
1195
+
1196
+ ```ts
1197
+ import { createRoutes } from '@appweaver/core';
1198
+
1199
+ export default createRoutes({
1200
+ modelName: 'Product',
1201
+ path: '/products',
1202
+ find: { roles: ['Admin', 'User'], rateLimit: { max: 100 } },
1203
+ query: { cache: true, cacheTTL: 5000 },
1204
+ create: { permissions: ['product:create'] },
1205
+ delete: { exclude: true }
1206
+ });
1207
+ ```
1208
+
1209
+ ### Configuration
1210
+
1211
+ ```ts
1212
+ function createRoutes(config: ResourceRoutesConfig, override ?: Partial<ResourceRoutesConfig>) {
1213
+ }
1214
+ ```
1215
+
1216
+ | Property | Type | Description |
1217
+ |--------------|-----------------|----------------------------------------------------------|
1218
+ | `modelName` | string | Model name to bind routes to (required). |
1219
+ | `path` | string | Custom base URL path (default: derived from model name). |
1220
+ | `find` | ReadRouteConfig | `GET /:id` - Find single resource by ID. |
1221
+ | `query` | ReadRouteConfig | `POST /query` - Query resources with filters. |
1222
+ | `aggregate` | ReadRouteConfig | `POST /aggregate` - Aggregate resources. |
1223
+ | `create` | RouteConfig | `POST /` - Create a new resource. |
1224
+ | `update` | RouteConfig | `PUT /:id` - Update a resource. |
1225
+ | `delete` | RouteConfig | `DELETE /:id` - Delete a resource. |
1226
+ | `export` | RouteConfig | `POST /export` - Export resources to CSV. |
1227
+ | `fileUpload` | RouteConfig | `POST /:id/files` - Upload files to a resource. |
1228
+ | `fileDelete` | RouteConfig | `POST /:id/delete-files` - Delete files from a resource. |
1229
+
1230
+ ### Route config (all operations)
1231
+
1232
+ | Property | Type | Default | Description |
1233
+ |-------------------|--------------------------|---------|---------------------------------------------------------------|
1234
+ | `exclude` | boolean | `false` | Exclude this operation entirely. |
1235
+ | `public` | boolean | `false` | No authentication required. |
1236
+ | `roles` | string[] | - | Required roles (OR logic by default). |
1237
+ | `permissions` | string[] | - | Required permissions (OR logic by default). |
1238
+ | `auth` | AuthType[] | - | Allowed authentication types: `'jwt'`, `'apiKey'`, `'basic'`. |
1239
+ | `rateLimit` | RateLimitConfig \| false | - | Per-operation rate limiting. `false` disables. |
1240
+ | `recaptcha` | boolean | `false` | Require reCAPTCHA verification. |
1241
+ | `recaptchaAction` | string | - | Expected reCAPTCHA action name for score validation. |
1242
+
1243
+ ### Read route config (find, query, aggregate)
1244
+
1245
+ Extends RouteConfig with caching options:
1246
+
1247
+ | Property | Type | Default | Description |
1248
+ |-------------------------|--------------------|---------|----------------------------------------------------------------|
1249
+ | `cache` | boolean | `false` | Enable response caching. |
1250
+ | `cacheKey` | string \| function | - | Custom cache key. Function signature: `(req, user) => string`. |
1251
+ | `cacheTTL` | number | - | Cache TTL in milliseconds (overrides global default). |
1252
+ | `cacheSkipInvalidation` | boolean | `false` | Skip automatic cache invalidation on writes. |
1253
+
1254
+ ### Rate limit config
1255
+
1256
+ ```ts
1257
+ const config = {
1258
+ rateLimit: {
1259
+ max: 100,
1260
+ timeWindow: 60000,
1261
+ allowList: ['127.0.0.1'],
1262
+ keyGenerator: (req) => req.ip
1263
+ }
1264
+ };
1265
+ ```
1266
+
1267
+ | Property | Type | Description |
1268
+ |----------------|------------------------------|---------------------------------------------------------------------|
1269
+ | `max` | number \| function | Maximum requests per time window. Function: `(req, key) => number`. |
1270
+ | `timeWindow` | number \| string \| function | Window duration in ms. Function: `(req, key) => number`. |
1271
+ | `allowList` | string[] \| function | IPs exempt from limiting. Function: `(req, key) => boolean`. |
1272
+ | `keyGenerator` | function | Custom key generator. Signature: `(req) => string \| number`. |
1273
+
1274
+ ---
1275
+
1276
+ ## createPolicy
1277
+
1278
+ Creates row-level security policies for a resource. The service layer evaluates the policy on every CRUD operation to
1279
+ enforce fine-grained authorization beyond static role/permission checks.
1280
+
1281
+ ```ts
1282
+ import { createPolicy } from '@appweaver/core';
1283
+
1284
+ export default createPolicy({
1285
+ modelName: 'Product',
1286
+ checkAccess: (user, resource, action) => resource.status === 'Draft',
1287
+ readRestrictions: (user, resource, action) => ({
1288
+ enabled: true
1289
+ }),
1290
+ files: {
1291
+ photo: { accessType: 'public' }
1292
+ }
1293
+ });
1294
+ ```
1295
+
1296
+ ### Configuration
1297
+
1298
+ ```ts
1299
+ function createPolicy(config: ResourcePolicyConfig, override ?: Partial<ResourcePolicyConfig>) {
1300
+ }
1301
+ ```
1302
+
1303
+ | Property | Type | Description |
1304
+ |---------------------|---------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
1305
+ | `modelName` | string | Model name to bind this policy to (required). |
1306
+ | `checkAccess` | `(user, resource, action) => boolean` | Dynamic access check against a resource instance. Return `true` to allow, `false` to deny. |
1307
+ | `readRestrictions` | `(user, resource, action) => filter` | Returns a Prisma filter object applied to all read queries (find, query, aggregate). Restricts which records are visible. |
1308
+ | `writeRestrictions` | `(user, resource, action) => data` | Returns data to merge or validate on create/update operations. |
1309
+ | `files` | Record\<string, FilePolicy> | Per-file field access policy. |
1310
+
1311
+ **Action types**: `'find'`, `'query'`, `'aggregate'`, `'create'`, `'update'`, `'delete'`
1312
+
1313
+ ### File policy
1314
+
1315
+ | Property | Type | Default | Description |
1316
+ |--------------|--------------------------------------------|---------------|--------------------------------------------------------------------------------------------------|
1317
+ | `accessType` | `'public'` \| `'protected'` \| `'private'` | `'protected'` | File access level. `public` = anyone, `protected` = authenticated users, `private` = owner only. |
1318
+ | `canAccess` | `(user, resource, file) => boolean` | - | Custom access check for reading files. |
1319
+ | `canCreate` | `(user, resource, file) => boolean` | - | Custom access check for uploading files. |
1320
+ | `canDelete` | `(user, resource, file) => boolean` | - | Custom access check for deleting files. |
1321
+
1322
+ ---
1323
+
1324
+ ## registerRoute
1325
+
1326
+ Registers a custom Fastify route handler outside the resource system. Use this for endpoints that don't map to a
1327
+ standard CRUD resource.
1328
+
1329
+ ```ts
1330
+ import { registerRoute, Router } from '@appweaver/core';
1331
+ import { Type } from '@sinclair/typebox';
1332
+
1333
+ registerRoute(
1334
+ async function (router: Router) {
1335
+ router.get('/search-result', {
1336
+ schema: {
1337
+ summary: 'Sample search result response route',
1338
+ response: { 200: Type.Ref('SearchResult') }
1339
+ },
1340
+ handler: async () => {
1341
+ return { message: 'Hello, world!' };
1342
+ }
1343
+ });
1344
+ },
1345
+ { public: true, cacheTTL: 15000 }
1346
+ );
1347
+ ```
1348
+
1349
+ ### Config options
1350
+
1351
+ | Property | Type | Description |
1352
+ |-------------------------|--------------------------|---------------------------------------------|
1353
+ | `exclude` | boolean | Skip registration of this route. |
1354
+ | `public` | boolean | No authentication required. |
1355
+ | `roles` | string[] | Required roles. |
1356
+ | `permissions` | string[] | Required permissions. |
1357
+ | `auth` | AuthType[] | Allowed authentication types. |
1358
+ | `rateLimit` | RateLimitConfig \| false | Rate limiting configuration. |
1359
+ | `recaptcha` | boolean | Require reCAPTCHA verification. |
1360
+ | `recaptchaAction` | string | Expected reCAPTCHA action. |
1361
+ | `cache` | boolean | Enable response caching. |
1362
+ | `cacheKey` | string \| function | Custom cache key. |
1363
+ | `cacheTTL` | number | Cache TTL in milliseconds. |
1364
+ | `cacheSkipInvalidation` | boolean | Skip automatic cache invalidation. |
1365
+ | `cacheModelName` | string | Model name for cache invalidation tracking. |
1366
+ | `cacheRelations` | string[] | Related model names for cache invalidation. |
1367
+
1368
+ ---
1369
+
1370
+ ## registerModel
1371
+
1372
+ Registers a custom TypeBox schema as a named model in the schema registry. Registered models can be referenced using
1373
+ `Type.Ref('ModelName')` in route schemas.
1374
+
1375
+ ```ts
1376
+ import { registerModel } from '@appweaver/core';
1377
+ import { Nullable } from '@appweaver/common';
1378
+ import { Type } from '@sinclair/typebox';
1379
+
1380
+ registerModel(
1381
+ Type.Object(
1382
+ {
1383
+ id: Type.Integer(),
1384
+ title: Type.String({ example: 'My Title' }),
1385
+ description: Nullable(Type.String({ maxLength: 512 })),
1386
+ score: Type.Number({ minimum: 0, maximum: 1 })
1387
+ },
1388
+ { $id: 'SearchResult' } // The prefered way for naming the model
1389
+ ),
1390
+ 'SearchResult' // Model name can be overriden as a second optional argument
1391
+ );
1392
+ ```
1393
+
1394
+ | Parameter | Type | Description |
1395
+ |-----------|---------|--------------------------------------------------------------|
1396
+ | `schema` | TObject | TypeBox object schema definition. |
1397
+ | `name` | string? | Override schema name identifier for `Type.Ref()` references. |
1398
+
1399
+ ---
1400
+
1401
+ ## registerPlugin
1402
+
1403
+ Registers a custom Fastify plugin. Plugins are wrapped with `fastify-plugin` so their decorators and hooks are scoped to
1404
+ the entire server instance.
1405
+
1406
+ ```ts
1407
+ import { registerPlugin } from '@appweaver/core';
1408
+
1409
+ registerPlugin(
1410
+ 'audit-log',
1411
+ async (server) => {
1412
+ server.addHook('onResponse', async (request, reply) => {
1413
+ logger.info(`${request.method} ${request.url} -> ${reply.statusCode}`);
1414
+ });
1415
+ },
1416
+ ['other-plugin'] // optional dependencies
1417
+ );
1418
+ ```
1419
+
1420
+ | Parameter | Type | Description |
1421
+ |----------------|-------------------------------------|-------------------------------------------------------|
1422
+ | `name` | string | Plugin name (used for dependency resolution). |
1423
+ | `plugin` | `(server) => void \| Promise<void>` | Fastify plugin function. |
1424
+ | `dependencies` | string[] | Optional list of plugin names this plugin depends on. |