@appweaver/create-weaver-app 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,6 +99,7 @@ The config object is frozen with `Object.freeze()` after loading to prevent runt
99
99
  | `LOG_ROTATE_INTERVAL` | string | `'1d'` | Rotation interval (e.g. `'1d'` for daily). |
100
100
  | `LOG_ROTATE_COMPRESS` | boolean | `true` | Compress rotated log files with gzip. |
101
101
  | `LOG_PRETTY` | boolean | `false` | Enable pretty-printed JSON logs. |
102
+ | `LOG_SYNC` | boolean | `false` | Write each record before the next statement runs, instead of buffering it. |
102
103
 
103
104
  ### Server (SERVER\_\*)
104
105
 
@@ -256,12 +257,12 @@ The config object is frozen with `Object.freeze()` after loading to prevent runt
256
257
 
257
258
  #### OAuth2 general
258
259
 
259
- | Property | Type | Default | Description |
260
- |----------------------------------------------------------|---------|----------|------------------------------------------------------------------------------------------------------------------------------------|
261
- | `SECURITY_OAUTH2_STATE_TTL` | integer | `600000` | OAuth2 state parameter TTL in milliseconds (default 10 min). |
262
- | `SECURITY_OAUTH2_REGISTRATION_ENABLED` | boolean | `true` | Allow registering new users via OAuth2 login. When `false`, only already existing users (matched by email) can log in via OAuth2. |
263
- | `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED` | boolean | `false` | Download the user's avatar from the OAuth2 provider during registration and pass it as `avatarFile` to `registrationData`. |
264
- | `SECURITY_OAUTH2_CONNECTED_ACCOUNTS_KEEP_DATABASE_TABLE` | boolean | `false` | Keep the `ConnectedAccount` table even when every OAuth2 provider is disabled, so the links are not dropped by the next migration. |
260
+ | Property | Type | Default | Description |
261
+ |----------------------------------------------------------|---------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
262
+ | `SECURITY_OAUTH2_STATE_TTL` | integer | `600000` | OAuth2 state parameter TTL in milliseconds (default 10 min). |
263
+ | `SECURITY_OAUTH2_REGISTRATION_ENABLED` | boolean | `true` | Allow registering new users via OAuth2 login. When `false`, only already existing users (matched by email) can log in via OAuth2. |
264
+ | `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED` | boolean | `true` | Download the user's avatar from the OAuth2 provider during registration and pass it as `avatarFile` to `registrationData` and `registrationFiles`. Set to `false` to pass only `avatarUrl` and skip the download. |
265
+ | `SECURITY_OAUTH2_CONNECTED_ACCOUNTS_KEEP_DATABASE_TABLE` | boolean | `false` | Keep the `ConnectedAccount` table even when every OAuth2 provider is disabled, so the links are not dropped by the next migration. |
265
266
 
266
267
  #### OAuth2 Google
267
268
 
@@ -50,7 +50,7 @@ function createModel(config: ResourceModelConfig, override ?: Partial<ResourceMo
50
50
  | `create` | OperationConfig | no | - | Pick/omit fields for the create DTO. |
51
51
  | `update` | OperationConfig | no | - | Pick/omit fields for the update DTO. |
52
52
  | `export` | Record\<string, ExportField> | no | - | CSV export field configuration. |
53
- | `index` | string[] \| string[][] | no | - | Database index definitions. |
53
+ | `index` | string[] \| string[][] | no | - | Database index definitions (`-field` desc, `+field` asc). |
54
54
 
55
55
  ### ID field
56
56
 
@@ -70,10 +70,58 @@ const config = {
70
70
  };
71
71
  ```
72
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. |
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.
77
125
 
78
126
  ### Audit fields
79
127
 
@@ -112,6 +160,10 @@ All scalar fields share these common properties:
112
160
  | `array` | boolean | `false` | Store as array (supported on string, int, float). |
113
161
  | `example` | string \| number \| boolean | - | Example value for OpenAPI (Swagger) schema documentation. |
114
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
+
115
167
  #### String
116
168
 
117
169
  ```ts
@@ -145,7 +197,8 @@ const config = {
145
197
  | `format` | `'email'` \| `'hostname'` \| `'ipv4'` \| `'ipv6'` \| `'uri'` \| `'uuid'` \| `'regex'` | Built-in format validation. |
146
198
  | `pattern` | string | Custom regex pattern for validation. |
147
199
 
148
- String defaults can also be ID generators: `'uuid()'`, `'uuid(7)'`, `'cuid()'`, `'cuid(2)'`.
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)).
149
202
 
150
203
  #### Number (int, bigInt, float)
151
204
 
@@ -317,6 +370,11 @@ const config = {
317
370
 
318
371
  **ReferentialAction values**: `'cascade'`, `'restrict'`, `'noAction'`, `'setNull'`, `'setDefault'`
319
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
+
320
378
  #### Relationship types
321
379
 
322
380
  The `type` property declares the relation cardinality explicitly, and `owner` marks the side that holds the foreign key
@@ -471,11 +529,61 @@ record by that field before creating a new one, so the inline create becomes a c
471
529
 
472
530
  #### Relation output
473
531
 
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. |
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
+ | `maxDepth` | number | Levels of a relation pointing back at its own model. Default `1`. |
537
+ | `count` | boolean | Include a count of related records. |
538
+
539
+ A relation is typed as the related model's `<Model>Single`, so a nested record carries its own relations again and a
540
+ self-reference recurses. The schema says what a response can hold, the config how deep one is read.
541
+
542
+ A relation pointing back at its own model is read one level deep like any other, and `maxDepth` repeats it down the
543
+ tree, counting the relation itself as the first level. An `include` naming that same relation replaces the repetition;
544
+ any other `include` is applied at every level. Each level is a database join, cheap on a to-one relation such as
545
+ `parent` and expensive on a list one such as `children`.
546
+
547
+ ```ts
548
+ const config = {
549
+ relations: {
550
+ // A category response carries three levels of ancestors
551
+ parent: {
552
+ model: 'Category',
553
+ type: 'oneToMany',
554
+ mappedBy: 'children',
555
+ owner: true,
556
+ required: false,
557
+ output: { type: 'always', maxDepth: 3 }
558
+ },
559
+ // Kept out of the response, counted as childrenCount instead
560
+ children: {
561
+ model: 'Category',
562
+ type: 'oneToMany',
563
+ mappedBy: 'parent',
564
+ output: { type: 'none', count: true }
565
+ }
566
+ }
567
+ }
568
+ ```
569
+
570
+ A nested `include` entry carries its own `maxDepth`, applied to the model that entry belongs to:
571
+
572
+ ```ts
573
+ // A post reads its category with the breadcrumb above it
574
+ const config = {
575
+ category: {
576
+ model: 'Category',
577
+ type: 'oneToMany',
578
+ mappedBy: 'posts',
579
+ owner: true,
580
+ output: {
581
+ type: 'always',
582
+ include: { parent: { type: 'always', maxDepth: 3 } }
583
+ }
584
+ }
585
+ }
586
+ ```
479
587
 
480
588
  ### File fields
481
589
 
@@ -509,7 +617,7 @@ const config = {
509
617
  | `array` | boolean | Allow multiple files. |
510
618
  | `maxSize` | number \| string | Maximum file size (e.g. `'2 MB'`, `5242880`). |
511
619
  | `maxCount` | number | Maximum number of files (for array fields). |
512
- | `output` | RelationOutput | When to include file info in output. |
620
+ | `output` | RelationOutput | When to include file info in output, and its count. Takes no `include` or `maxDepth`. |
513
621
  | `onResourceDeleted` | `'delete'` \| `'keep'` | When the owning resource is deleted. `'delete'` (default) removes files from storage, `'keep'` leaves them. |
514
622
  | `image` | ImageConfig | Image compression and resize settings. Only applies to image MIME types (excluding GIF). |
515
623
 
@@ -659,7 +767,7 @@ const config = {
659
767
  headerName: 'Product Price',
660
768
  mapValue: 'price'
661
769
  },
662
- passwordHash: {
770
+ internalNotes: {
663
771
  exclude: true
664
772
  },
665
773
  status: {
@@ -688,6 +796,9 @@ related record (and off every item for array relations, joined with `,`); on a s
688
796
  record itself. A function `mapValue` receives the field value (or each item of an array field) and returns the column
689
797
  value.
690
798
 
799
+ Hidden fields and virtual fields with `output: { type: 'none' }` are never exported, nested in a relation either. A
800
+ relation without a `mapValue` writes one column per field of the related record.
801
+
691
802
  ### Index config
692
803
 
693
804
  Define database indexes as a flat array (single-field indexes) or nested arrays (composite indexes):
@@ -698,24 +809,36 @@ index: [['status', 'categoryId']] // Composite index on status + categor
698
809
  index: ['email', ['status', 'createdAt']] // Both single and composite
699
810
  ```
700
811
 
812
+ Prefix a field name with `-` for a descending index or `+` for an ascending one. Without a prefix the database default
813
+ order is used:
814
+
815
+ ```ts
816
+ index: ['-createdAt'] // @@index(createdAt(sort: Desc))
817
+ index: ['+title'] // @@index(title(sort: Asc))
818
+ index: [['status', '-createdAt']] // @@index([status, createdAt(sort: Desc)])
819
+ ```
820
+
821
+ The prefix is part of the index identity, so `['createdAt', '-createdAt']` emits two separate indexes.
822
+
701
823
  ### Generated models
702
824
 
703
825
  `createModel` produces the following TypeBox schema models used internally by routes and services:
704
826
 
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 |
827
+ | Model | Purpose |
828
+ |------------------------|------------------------------------------------|
829
+ | `readModel` | Full model with all visible fields |
830
+ | `createModel` | Request body for create operations |
831
+ | `updateModel` | Request body for update operations |
832
+ | `relationsModel` | Relations-only subset |
833
+ | `virtualModel` | Virtual fields-only subset |
834
+ | `filesModel` | File fields-only subset |
835
+ | `readOneModel` | Response for single-item reads |
836
+ | `readManyModel` | Response for list reads |
837
+ | `readOneNullableModel` | `readOneModel` or null, for optional relations |
838
+ | `createOneModel` | Request for create endpoint |
839
+ | `updateOneModel` | Request for update endpoint |
840
+ | `fileUploadModel` | Request for file upload endpoint |
841
+ | `fileDeleteModel` | Request for file delete endpoint |
719
842
 
720
843
  ---
721
844
 
@@ -749,7 +872,7 @@ function createService(config: ResourceServiceConfig, override ?: Partial<Resour
749
872
  |-------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|
750
873
  | `modelName` | string | Model name to bind this service to (required). |
751
874
  | `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. |
875
+ | `beforeQuery` | `(filter, page, size, sort, cursor, totalCount) => void` | Hook called before querying resources. `sort` is a field list string or a sort object. |
753
876
  | `beforeAggregate` | `(filter, select, dateField, from?, to?, step?, safeIncrement?) => void` | Hook called before aggregation. |
754
877
  | `beforeCreate` | `(data) => void` | Hook called before creating a resource. Mutate `data` to modify input. |
755
878
  | `beforeUpdate` | `(id, data) => void` | Hook called before updating a resource. |
@@ -768,14 +891,33 @@ All hooks can be synchronous or return a `Promise`.
768
891
 
769
892
  The created service exposes the following methods:
770
893
 
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. |
894
+ | Method | Signature | Description |
895
+ |-------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
896
+ | `find` | `(id) => Promise<ReadOne>` | Find a single resource by ID. |
897
+ | `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)). |
898
+ | `aggregate` | `(filter?, select?, dateField?, from?, to?, step?, safeIncrement?) => Promise<AggregateResponse>` | Aggregate resources with time-series grouping (see [Aggregate selection](#aggregate-selection)). |
899
+ | `create` | `(data) => Promise<ReadOne>` | Create a new resource. |
900
+ | `update` | `(id, data) => Promise<ReadOne>` | Update an existing resource. |
901
+ | `delete` | `(id) => Promise<ReadOne>` | Delete a resource. |
902
+ | `client` | `ResourceClient` (property) | Database client of the model, for operations outside the model contract. |
903
+
904
+ ### Typed service injection
905
+
906
+ `weaver generate` emits a `<Model>ResourceService` alias per model, so `injectService` needs no hand-written type:
907
+
908
+ ```ts
909
+ import { injectService } from '@appweaver/core';
910
+ import { PostResourceService } from '@/types/generated';
911
+
912
+ const posts = injectService<PostResourceService>('Post');
913
+ ```
914
+
915
+ The alias is `IResourceService<<Model>, <Model>Multiple, <Model>Create, <Model>Update, <Model>Query>`, so the
916
+ `<Model>Query`, `<Model>Sort`, and `<Model>Aggregate` aliases are exactly the inputs its methods accept.
917
+
918
+ The `create` and `update` inputs are the model's declared contracts, so a field an operation config omits, a hidden
919
+ scalar, or a relation with `input: { type: 'none' }` is deliberately not part of them. A write outside the contract
920
+ belongs on `service.client`, the database client of the model.
779
921
 
780
922
  ### Query filters
781
923
 
@@ -858,7 +1000,8 @@ matches missing values or related records.
858
1000
  },
859
1001
  "page": 1,
860
1002
  "size": 50,
861
- "sort": "-createdAt,id"
1003
+ "sort": "-createdAt",
1004
+ "totalCount": true
862
1005
  }
863
1006
  ```
864
1007
 
@@ -916,7 +1059,8 @@ unknown sort direction — is rejected with a `400` error naming the offending f
916
1059
  Over HTTP the sort object is additionally validated against a generated per-model `<Model>QuerySort` schema, which
917
1060
  strips unknown fields the same way the query filter schema does.
918
1061
 
919
- The default sort is `-createdAt,id`, and its `createdAt` part is dropped for models configured with
1062
+ The default sort is `-createdAt`. Every sort is terminated with the primary key when it does not already order by one,
1063
+ so paging stays deterministic, and the `createdAt` entry is dropped for models configured with
920
1064
  `audit: { createdAt: false }`.
921
1065
 
922
1066
  Sort inputs are typed by `QuerySort<T>` from `@appweaver/common`, and `weaver generate` emits a
@@ -934,12 +1078,48 @@ const posts = await postService.query({}, 1, 50, sort);
934
1078
 
935
1079
  ```ts
936
1080
  const config = {
937
- resultCount: 123, // Items in this page
938
- totalCount: 123, // Total items matching filter
939
- items: [] // Page data
1081
+ resultCount: 50, // Items in this page
1082
+ totalCount: 123, // Total items matching filter, omitted when totalCount is false
1083
+ nextCursor: '...', // Cursor of the following page, absent on the last page
1084
+ prevCursor: '...', // Cursor of the preceding page, absent on the first page
1085
+ items: [] // Page data
940
1086
  };
941
1087
  ```
942
1088
 
1089
+ ### Cursor pagination
1090
+
1091
+ The response returns a `nextCursor` and a `prevCursor`; send one back as `cursor` to get that page. The direction is
1092
+ part of the cursor, so a request never names one. A cursor takes precedence over `page` and does not slow down on the
1093
+ later pages.
1094
+
1095
+ ```ts
1096
+ // First page counted, the following ones skipping the count
1097
+ let result = await postService.query({}, 1, 50);
1098
+
1099
+ while (result.nextCursor) {
1100
+ result = await postService.query({}, 1, 50, undefined, result.nextCursor, false);
1101
+ }
1102
+ ```
1103
+
1104
+ ```json5
1105
+ // POST /posts/query
1106
+ {
1107
+ "filter": {
1108
+ "enabled": true
1109
+ },
1110
+ "size": 50,
1111
+ "sort": "-createdAt",
1112
+ "cursor": "eyJpIjo0MiwiZiI6IkhkQjVfa2VMTVlyNyJ9",
1113
+ "totalCount": false
1114
+ }
1115
+ ```
1116
+
1117
+ `totalCount` defaults to `true` and scans every matching record, so count once and send `false` afterward, which returns
1118
+ it as `null`.
1119
+
1120
+ A cursor is opaque and bound to the query that issued it: reusing one under a different resource, filter, or sort is
1121
+ rejected with a 400.
1122
+
943
1123
  ### Aggregate selection
944
1124
 
945
1125
  The `select` argument of `aggregate` (and the required `select` property of the `POST /aggregate` request body) holds
@@ -990,10 +1170,24 @@ const select: PostAggregate = { counter: { sum: true }, createdAt: { max: true }
990
1170
  const stats = await postService.aggregate({}, select);
991
1171
  ```
992
1172
 
1173
+ `aggregate` infers the response type from the selection it is given, so a selection passed as an object literal, or
1174
+ declared with `satisfies`, narrows the response to the fields it names, while one annotated as `<Model>Aggregate` keeps
1175
+ every aggregatable field of the model:
1176
+
1177
+ ```ts
1178
+ const narrow = await postService.aggregate({}, { counter: { sum: true } });
1179
+ narrow.total.counter?.sum; // typed
1180
+ narrow.total.createdAt; // compile error, the field was not selected
1181
+
1182
+ const select = { counter: { sum: true } } satisfies PostAggregate; // narrows and checks against the model
1183
+ const wide: PostAggregate = { counter: { sum: true } }; // keeps the whole model in the response type
1184
+ ```
1185
+
993
1186
  ### Aggregate response
994
1187
 
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:
1188
+ The response shape follows whatever was selected, and its type carries the fields of the selection (see
1189
+ [Aggregate selection](#aggregate-selection)). Each aggregated field holds one property per operator applied to it, and
1190
+ the operators the selection left out are `undefined`:
997
1191
 
998
1192
  ```ts
999
1193
  const resp = {
@@ -321,11 +321,35 @@ export default createAuthService({
321
321
  });
322
322
  ```
323
323
 
324
- **User avatar** — the provider's avatar/picture URL is passed to `registrationData` as `additionalData.avatarUrl`. When
325
- `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED=true` (JSON: `security.oauth2.fetchAvatarEnabled`), the avatar image is also
326
- downloaded during registration and passed as `additionalData.avatarFile`
327
- (`{ name, mimeType, size, data: Buffer }`), so it can be mapped to a model field or stored via the file service. The
328
- download is best-effort: failures are logged and registration proceeds without the file.
324
+ **User avatar** — `registrationData` and `registrationFiles` receive the provider's picture URL as
325
+ `additionalData.avatarUrl` and the downloaded image as `additionalData.avatarFile`
326
+ (`{ name, mimeType, size, data: Buffer }`). `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED=false` (JSON:
327
+ `security.oauth2.fetchAvatarEnabled`) skips the download, leaving `avatarFile` `undefined`. The download is best-effort:
328
+ failures are logged and registration proceeds without the file.
329
+
330
+ **`registrationFiles` callback** — an optional callback on `createAuthService` that attaches the avatar (or any other
331
+ file) to a newly registered user. A file must be linked to an existing resource, so it cannot be part of the
332
+ registration payload and is stored right after the user record is created. Return a map of the model's **file fields**
333
+ to the files to store; nullish values are skipped, so nothing is stored unless the callback asks for it:
334
+
335
+ ```ts
336
+ // src/resources/user/service.ts
337
+ export default createAuthService({
338
+ modelName: 'User',
339
+ registrationData: (source, email, password, additionalData) => ({
340
+ email,
341
+ password,
342
+ firstName: additionalData?.firstName ?? ''
343
+ }),
344
+ registrationFiles: (source, additionalData) => ({
345
+ avatar: additionalData?.avatarFile
346
+ })
347
+ });
348
+ ```
349
+
350
+ The file is validated against the `files.avatar` config of the model (media type, size limit, name pattern, image
351
+ processing). Storing it is the best effort: a rejected file is logged and never fails the registration. Outside
352
+ registration, use [`FileService.saveBuffer()`](./storage.md#saving-an-in-memory-file).
329
353
 
330
354
  ### Client-side OAuth2 integration example
331
355
 
@@ -220,26 +220,54 @@ When saving a file via `FileService`, you must provide the multipart data, the r
220
220
  `ResourceClient`.
221
221
 
222
222
  ```ts
223
- import { inject, injectService, injectModel } from '@appweaver/core';
223
+ import { inject, injectService } from '@appweaver/core';
224
224
  import { FileService } from '@appweaver/core/storage';
225
225
 
226
226
  export class PostService {
227
227
  private readonly _fileService = inject(FileService);
228
228
  private readonly _postService = injectService('Post');
229
- private readonly _postClient = injectModel('Post');
230
229
 
231
230
  async uploadImage(postId: number, data: MultipartFile) {
232
231
  const post = await this._postService.find(postId);
233
232
 
234
233
  // saveFile stores the file in Storage AND creates a File record in the DB
235
- // linked to the 'image' field of the 'post' resource.
236
- const file = await this._fileService.saveFile(data, post, this._postClient);
234
+ // linked to the file field named by the multipart field of the 'post' resource.
235
+ const file = await this._fileService.saveFile(
236
+ data,
237
+ post,
238
+ this._postService.client
239
+ );
237
240
 
238
241
  return file;
239
242
  }
240
243
  }
241
244
  ```
242
245
 
246
+ ### Saving an in-memory file
247
+
248
+ `saveBuffer()` stores a file that did not arrive as a multipart upload — a downloaded avatar, a generated report, a
249
+ thumbnail. It behaves exactly like `saveFile()` (media type check, size limit, name pattern, image processing,
250
+ checksum, `File` record), except the content comes from a buffer and the target file field is named explicitly:
251
+
252
+ ```ts
253
+ import { inject, injectService } from '@appweaver/core';
254
+ import { FileService } from '@appweaver/core/storage';
255
+
256
+ const users = injectService('User');
257
+ const user = await users.find(userId);
258
+
259
+ const file = await inject(FileService).saveBuffer(
260
+ 'avatar', // the file field of the User model
261
+ { name: 'avatar.png', mimeType: 'image/png', data: buffer },
262
+ user,
263
+ users.client
264
+ );
265
+ ```
266
+
267
+ The optional `size` (defaults to the buffer length) is what the size limit is checked against, and `encoding` defaults
268
+ to `7bit`. The owning resource must already exist — to attach files while a user is registering, use the
269
+ [`registrationFiles`](./security.md) callback of `createAuthService`.
270
+
243
271
  ### File integrity (checksum)
244
272
 
245
273
  When a file is uploaded through `FileService.saveFile()` (or the resource file upload routes), a **SHA-256 checksum**
@@ -326,3 +354,9 @@ You can also call `deleteResourceFiles` manually if needed:
326
354
  ```ts
327
355
  await fileService.deleteResourceFiles('Post', postId);
328
356
  ```
357
+
358
+ ### Owning resource reference
359
+
360
+ The `File` model records its owner through the `resourceName`, `resourceField` and `resourceId` columns. `resourceId`
361
+ is a text column holding the owning record ID, so files attach to models with either an integer or a string primary
362
+ key, and `deleteResourceFiles` accepts an ID of either type.
@@ -2,7 +2,8 @@
2
2
  "$schema": "./node_modules/@appweaver/common/config/schema.json",
3
3
  "config": {
4
4
  "log": {
5
- "level": "silent"
5
+ "level": "silent",
6
+ "sync": true
6
7
  },
7
8
  "database": {
8
9
  "url": "{{DATABASE_TEST_URL}}",
@@ -12,5 +12,6 @@ export default createAuthService<UserCreate>({
12
12
  twoFactorAuth: 'None',
13
13
  roles: [{ id: 1 }]
14
14
  };
15
- }
15
+ },
16
+ registrationFiles: (_, data) => ({ avatar: data?.avatarFile })
16
17
  });