@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.
- package/package.json +1 -1
- package/skill/GUIDELINES.md +18 -4
- package/skill/SKILL.md +753 -707
- package/skill/references/cli.md +4 -0
- package/skill/references/client.md +609 -599
- package/skill/references/configuration.md +7 -6
- package/skill/references/resources.md +237 -43
- package/skill/references/security.md +29 -5
- package/skill/references/storage.md +38 -4
- package/templates/default/appweaver.test.json.tpl +2 -1
- package/templates/default/src/resources/user/service.ts.tpl +2 -1
|
@@ -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 | `
|
|
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
|
|
74
|
-
|
|
75
|
-
| `type` | `'string'` \| `'int'` \| `'bigInt'`
|
|
76
|
-
| `generator` | `'uuid()'` \| `'uuid(7)'` \| `'cuid()'` \| `'cuid(2)'` \| `'autoincrement()'` | `'autoincrement()'` | Value generator. String types use UUID/CUID
|
|
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
|
|
475
|
-
|
|
476
|
-
| `type`
|
|
477
|
-
| `include`
|
|
478
|
-
| `
|
|
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
|
-
|
|
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
|
|
706
|
-
|
|
707
|
-
| `readModel`
|
|
708
|
-
| `createModel`
|
|
709
|
-
| `updateModel`
|
|
710
|
-
| `relationsModel`
|
|
711
|
-
| `virtualModel`
|
|
712
|
-
| `filesModel`
|
|
713
|
-
| `readOneModel`
|
|
714
|
-
| `readManyModel`
|
|
715
|
-
| `
|
|
716
|
-
| `
|
|
717
|
-
| `
|
|
718
|
-
| `
|
|
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`
|
|
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>`
|
|
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,
|
|
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
|
|
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:
|
|
938
|
-
totalCount: 123,
|
|
939
|
-
|
|
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
|
|
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
|
|
325
|
-
`
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
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
|
|
236
|
-
const file = await this._fileService.saveFile(
|
|
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.
|