@appweaver/create-weaver-app 1.1.6 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appweaver/create-weaver-app",
3
- "version": "1.1.6",
3
+ "version": "1.2.0",
4
4
  "description": "Appweaver - the backend framework for AI-first development (@create-weaver-app)",
5
5
  "author": "Luka Matosevic",
6
6
  "license": "MIT",
@@ -73,7 +73,7 @@ export default createModel({
73
73
  enabled: { type: 'boolean', default: true }
74
74
  },
75
75
  relations: {
76
- category: { model: 'Category', mappedBy: 'products', owner: true, output: { type: 'always' } }
76
+ category: { model: 'Category', type: 'oneToMany', mappedBy: 'products', owner: true, output: { type: 'always' } }
77
77
  },
78
78
  files: {
79
79
  photo: { mimeType: 'image/*', maxSize: '2 MB' }
package/skill/SKILL.md CHANGED
@@ -209,6 +209,7 @@ export default createModel({
209
209
  relations: {
210
210
  category: {
211
211
  model: 'Category',
212
+ type: 'oneToMany',
212
213
  mappedBy: 'products',
213
214
  owner: true,
214
215
  output: {
@@ -361,6 +362,84 @@ export default createAuthService({
361
362
  });
362
363
  ```
363
364
 
365
+ #### Querying resources with filters
366
+
367
+ The `filter` argument of the `query`, `aggregate`, and `export` service methods (and of the matching `POST /query`,
368
+ `POST /aggregate`, `POST /export` routes) mirrors the WHERE part of a database query. It combines `_`-prefixed operators
369
+ with plain value shorthands and nests through relations:
370
+
371
+ - **Logical**: `_and`, `_or`, `_not`, `_nor` — take a filter object (each entry becomes one condition) or a list of
372
+ filter objects.
373
+ - **Comparison**: `_eq`, `_ne`, `_gt`, `_gte`, `_lt`, `_lte`, `_in`, `_nin`, `_between`, `_like`, `_ilike`, `_starts`,
374
+ `_ends`, `_contains`, `_exists`, `_not`. Operators combined in one object must all match.
375
+ - **List fields**: `_has`, `_hasSome`, `_hasEvery`, `_isEmpty`.
376
+ - **Relations**: `_some`, `_every`, `_none` for list relations, `_exists` for any relation.
377
+ - **Shorthands**: a bare value matches by equality, a list by inclusion, a two-value list on a numeric or date field as
378
+ an inclusive range, and a bare value or list on a relation matches by id.
379
+
380
+ ```ts
381
+ import { injectService } from '@appweaver/core';
382
+ import { UserQuery } from '@/types/generated';
383
+
384
+ const filter: UserQuery = {
385
+ _and: {
386
+ firstName: { _eq: 'John', _exists: true },
387
+ avatar: { _or: { title: { _eq: 'Avatar' }, description: { _like: '%avatar%' } } }
388
+ },
389
+ _or: [{ firstName: { _like: 'Jo%' } }, { lastName: 'Doe' }],
390
+ roles: { _some: { name: { _contains: 'Admin' } } }
391
+ };
392
+
393
+ const users = await injectService('User').query(filter, 1, 50, '-createdAt,id');
394
+ ```
395
+
396
+ Filters are typed by `QueryFilter<T>` from `@appweaver/common`, and `weaver generate` emits a
397
+ `<Model>Query = QueryFilter<Model>` alias per model. Over HTTP, they are validated against a generated per-model
398
+ `<Model>QueryFilter` JSON schema, which strips unknown and hidden fields.
399
+
400
+ ### Sorting
401
+
402
+ The `sort` argument of `query` and `export`, and the `sort` property of the `POST /query` and `POST /export` bodies,
403
+ accept either a comma-separated field list, where a `-` prefix sorts descending, or an object of `asc` and `desc` field
404
+ directions. Both sort by a field of an included to-one relation and by the record count of a to-many relation:
405
+
406
+ ```ts
407
+ await injectService('Post').query({}, 1, 50, '-author.createdAt,tagsCount,id');
408
+ await injectService('Post').query({}, 1, 50, {
409
+ author: { createdAt: 'desc' },
410
+ tagsCount: 'asc',
411
+ id: 'asc'
412
+ });
413
+ ```
414
+
415
+ A hidden, virtual, or array scalar field, a field of a to-many relation, or a relation the action does not include is
416
+ rejected with a `400` error. Sort inputs are typed by `QuerySort<T>` from `@appweaver/common`, with a `<Model>Sort`
417
+ alias emitted per model, and validated over HTTP against a generated `<Model>QuerySort` JSON schema. The default is
418
+ `-createdAt,id`. See [resources.md](./references/resources.md) for the full rules.
419
+
420
+ ### Aggregating
421
+
422
+ The required `select` argument of `aggregate` (and of the `POST /aggregate` body) holds the operators to apply per
423
+ field. Only the numeric fields (`count`, `sum`, `avg`, `min`, `max`, `first`, `last`), the date fields (all but `sum`
424
+ and `avg`), and the numeric `id` and audit fields of the model can be aggregated:
425
+
426
+ ```ts
427
+ await injectService('Post').aggregate({}, {
428
+ counter: { count: true, sum: true, avg: true, first: true, last: true },
429
+ publishedAt: { min: true, max: true }
430
+ }, 'createdAt', '2026-01-01T00:00:00.000Z', '2026-01-08T00:00:00.000Z');
431
+ ```
432
+
433
+ `first` and `last` take the value held by the earliest and the latest record of a period, ordered by the aggregated
434
+ `dateField` (ties broken by `id`). The database cannot aggregate them, so each period requesting them costs up to two
435
+ additional queries, skipped for the periods holding no record.
436
+
437
+ Any other field (string, boolean, enum, JSON, array, hidden, virtual, or a relation), an operator its type does not
438
+ support, an empty selection, or a `dateField` that is not a date field is rejected with a `400` error. Selections are
439
+ typed by `AggregateSelect<T>` from `@appweaver/common`, with a `<Model>Aggregate` alias emitted per model, and
440
+ validated over HTTP against a generated `<Model>AggregateSelect` JSON schema. The response stays untyped JSON, since
441
+ its shape follows the selection.
442
+
364
443
  ### Registering a custom route
365
444
 
366
445
  Use `registerRoute` to register a custom [Fastify route](https://fastify.dev/docs/latest/Reference/Routes/) handler. The
@@ -578,6 +657,27 @@ npm run e2e # e2e tests
578
657
  Test files must use the **`.test.ts`** extension. Place unit tests in `test/unit/` and end-to-end tests in `test/e2e/`,
579
658
  naming each file after its module. Add or update tests whenever a feature is added or existing behaviour changes.
580
659
 
660
+ The e2e setup and teardown are wired automatically, but **each e2e test file must register the per-file database reset
661
+ itself**, after the hook that stops the application:
662
+
663
+ ```ts
664
+ import { resetTestData } from './support/reset';
665
+
666
+ describe('My e2e test', () => {
667
+ let app: Application;
668
+
669
+ beforeAll(async () => {
670
+ app = await createApp({ autoStartServer: false });
671
+ });
672
+
673
+ afterAll(async () => {
674
+ await app.stop();
675
+ });
676
+
677
+ afterAll(resetTestData, 10_000);
678
+ });
679
+ ```
680
+
581
681
  ### Format code
582
682
 
583
683
  ```sh
@@ -49,9 +49,9 @@ Reads an OpenAPI v3 schema and generates TypeScript types and a typed client cla
49
49
 
50
50
  **Arguments:**
51
51
 
52
- | Argument | Description |
53
- |----------------|--------------------------------------------------------------------------------------------------------------------------------------|
54
- | `<schemaPath>` | Path to the OpenAPI schema. Accepts a file path or URL (`http://`, `https://`, `file://`). JSON and YAML formats are both supported. |
52
+ | Argument | Description |
53
+ |----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
54
+ | `<schemaPath>` | Path to the OpenAPI schema. Accepts a relative or absolute file path (including a Windows drive path such as `C:\api\openapi.json`) or a URL (`http://`, `https://`, `file://`). JSON and YAML formats are both supported. |
55
55
 
56
56
  **Options:**
57
57
 
@@ -313,15 +313,20 @@ Exposes CRUD and file operations for a single resource endpoint.
313
313
  // Find a single record
314
314
  const post = await client.post.find(1);
315
315
 
316
- // Query with filters and pagination
316
+ // Query with filters, sorting and pagination. The sort accepts a comma-separated
317
+ // field list ('-createdAt,id') or an object of field directions
317
318
  const result = await client.post.query({
318
319
  filter: { published: true },
319
- sort: [{ field: 'createdAt', direction: 'desc' }],
320
- page: { offset: 0, limit: 20 }
320
+ sort: { author: { lastName: 'asc' }, createdAt: 'desc' },
321
+ page: 1,
322
+ size: 20
321
323
  });
322
324
 
323
- // Aggregate
324
- const stats = await client.post.aggregate({ count: true });
325
+ // Aggregate. The select holds the operators to apply per numeric or date field
326
+ const stats = await client.post.aggregate({
327
+ select: { counter: { count: true, sum: true }, createdAt: { min: true } },
328
+ dateField: 'createdAt'
329
+ });
325
330
 
326
331
  // Create
327
332
  const newPost = await client.post.create({ title: 'Hello', body: '...' });
@@ -248,6 +248,7 @@ const config = {
248
248
  relations: {
249
249
  category: {
250
250
  model: 'Category',
251
+ type: 'oneToMany',
251
252
  mappedBy: 'products',
252
253
  owner: true,
253
254
  output: {
@@ -256,8 +257,8 @@ const config = {
256
257
  },
257
258
  reviews: {
258
259
  model: 'Review',
260
+ type: 'oneToMany',
259
261
  mappedBy: 'product',
260
- array: true,
261
262
  output: {
262
263
  type: 'single',
263
264
  count: true
@@ -273,8 +274,8 @@ const config = {
273
274
  relations: {
274
275
  products: {
275
276
  model: 'Product',
277
+ type: 'oneToMany',
276
278
  mappedBy: 'category',
277
- array: true,
278
279
  output: {
279
280
  type: 'single'
280
281
  }
@@ -289,6 +290,7 @@ const config = {
289
290
  relations: {
290
291
  product: {
291
292
  model: 'Product',
293
+ type: 'oneToMany',
292
294
  mappedBy: 'reviews',
293
295
  owner: true,
294
296
  input: {
@@ -299,30 +301,29 @@ const config = {
299
301
  };
300
302
  ```
301
303
 
302
- | Property | Type | Default | Description |
303
- |---------------------|-------------------|--------------|--------------------------------------------------------------------------|
304
- | `model` | string | **required** | Target model name. |
305
- | `owner` | boolean | `false` | This side owns the foreign key (only one side should be owner). |
306
- | `mappedBy` | string | - | Name of the inverse relation on the target model. |
307
- | `array` | boolean | `false` | One-to-many value or many-to-many relation. |
308
- | `unique` | boolean | `false` | One-to-one relation (unique foreign key). |
309
- | `required` | boolean | `true` | Whether the relation is required (nullable foreign key if not required). |
310
- | `minItems` | number | - | Minimum items for array relations. |
311
- | `createIfNotExists` | boolean | `false` | Auto-create related record if it doesn't exist. |
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. |
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
317
 
318
318
  **ReferentialAction values**: `'cascade'`, `'restrict'`, `'noAction'`, `'setNull'`, `'setDefault'`
319
319
 
320
320
  #### Relationship types
321
321
 
322
- The combination of `owner`, `array`, and `unique` properties determines the relationship type:
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:
323
324
 
324
- **One-to-One**: One side has `owner: true` and `unique: true`, the other side has `owner: false` (default) and
325
- `unique: false` (default).
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.
326
327
 
327
328
  ```ts
328
329
  // User model
@@ -330,9 +331,9 @@ const config = {
330
331
  relations: {
331
332
  profile: {
332
333
  model: 'Profile',
334
+ type: 'oneToOne',
333
335
  mappedBy: 'user',
334
336
  owner: true,
335
- unique: true,
336
337
  required: false // otherwise the Profile DTO must be sent when creating the user resource
337
338
  }
338
339
  }
@@ -345,34 +346,36 @@ const config = {
345
346
  relations: {
346
347
  user: {
347
348
  model: 'User',
349
+ type: 'oneToOne',
348
350
  mappedBy: 'profile'
349
351
  }
350
352
  }
351
353
  };
352
354
  ```
353
355
 
354
- **One-to-Many**: The "one" side has `owner: false` (default) and `array: true`, the "many" side has `owner: true` and
355
- `array: false` (default).
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.
356
358
 
357
359
  ```ts
358
- // Category model (one)
360
+ // Category model (one, list side)
359
361
  const config = {
360
362
  relations: {
361
363
  products: {
362
364
  model: 'Product',
363
- mappedBy: 'category',
364
- array: true
365
+ type: 'oneToMany',
366
+ mappedBy: 'category'
365
367
  }
366
368
  }
367
369
  };
368
370
  ```
369
371
 
370
372
  ```ts
371
- // Product model (many)
373
+ // Product model (many, foreign key side)
372
374
  const config = {
373
375
  relations: {
374
376
  category: {
375
377
  model: 'Category',
378
+ type: 'oneToMany',
376
379
  mappedBy: 'products',
377
380
  owner: true
378
381
  }
@@ -380,8 +383,8 @@ const config = {
380
383
  };
381
384
  ```
382
385
 
383
- **Many-to-Many**: Both sides have `array: true`. Only one side should have `owner: true` (determines which creates the
384
- join table).
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.
385
388
 
386
389
  ```ts
387
390
  // Post model
@@ -389,9 +392,8 @@ const config = {
389
392
  relations: {
390
393
  tags: {
391
394
  model: 'Tag',
392
- mappedBy: 'posts',
393
- owner: true,
394
- array: true
395
+ type: 'manyToMany',
396
+ mappedBy: 'posts'
395
397
  }
396
398
  }
397
399
  };
@@ -403,20 +405,69 @@ const config = {
403
405
  relations: {
404
406
  posts: {
405
407
  model: 'Post',
406
- mappedBy: 'tags',
407
- array: true
408
+ type: 'manyToMany',
409
+ mappedBy: 'tags'
408
410
  }
409
411
  }
410
412
  };
411
413
  ```
412
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
+
413
427
  #### Relation input
414
428
 
415
- | Property | Type | Description |
416
- |-------------|-------------------------------------------------|---------------------------------------------------|
417
- | `type` | `'all'` \| `'create'` \| `'update'` \| `'none'` | When the relation field is available as input. |
418
- | `uniqueKey` | string | Use a unique key field instead of ID for linking. |
419
- | `fullModel` | boolean | Accept full nested model object as input. |
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`.
420
471
 
421
472
  #### Relation output
422
473
 
@@ -632,6 +683,11 @@ const config = {
632
683
  | `exclude` | boolean | Exclude this field from exports. |
633
684
  | `mapValue` | string \| function | Transform the value during export. |
634
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
+
635
691
  ### Index config
636
692
 
637
693
  Define database indexes as a flat array (single-field indexes) or nested arrays (composite indexes):
@@ -693,7 +749,7 @@ function createService(config: ResourceServiceConfig, override ?: Partial<Resour
693
749
  |-------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|
694
750
  | `modelName` | string | Model name to bind this service to (required). |
695
751
  | `beforeFind` | `(id) => void` | Hook called before finding a single resource. |
696
- | `beforeQuery` | `(filter, page, size, sort) => void` | Hook called before querying resources. |
752
+ | `beforeQuery` | `(filter, page, size, sort) => void` | Hook called before querying resources. `sort` is a field list string or a sort object. |
697
753
  | `beforeAggregate` | `(filter, select, dateField, from?, to?, step?, safeIncrement?) => void` | Hook called before aggregation. |
698
754
  | `beforeCreate` | `(data) => void` | Hook called before creating a resource. Mutate `data` to modify input. |
699
755
  | `beforeUpdate` | `(id, data) => void` | Hook called before updating a resource. |
@@ -712,14 +768,167 @@ All hooks can be synchronous or return a `Promise`.
712
768
 
713
769
  The created service exposes the following methods:
714
770
 
715
- | Method | Signature | Description |
716
- |-------------|---------------------------------------------------------------------------------------------------|----------------------------------------------------------|
717
- | `find` | `(id) => Promise<ReadOne>` | Find a single resource by ID. |
718
- | `query` | `(filter?, page?, size?, sort?) => Promise<QueryResponse>` | Query resources with filtering, pagination, and sorting. |
719
- | `aggregate` | `(filter?, select?, dateField?, from?, to?, step?, safeIncrement?) => Promise<AggregateResponse>` | Aggregate resources with time-series grouping. |
720
- | `create` | `(data) => Promise<ReadOne>` | Create a new resource. |
721
- | `update` | `(id, data) => Promise<ReadOne>` | Update an existing resource. |
722
- | `delete` | `(id) => Promise<ReadOne>` | Delete a resource. |
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
+ ```
723
932
 
724
933
  ### Query response
725
934
 
@@ -731,28 +940,80 @@ const config = {
731
940
  };
732
941
  ```
733
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
+
734
993
  ### Aggregate response
735
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
+
736
998
  ```ts
737
999
  const resp = {
738
- total: AggregateValue, // Overall aggregation
739
- items: Arrray<AggregateResult> // Per-period results
1000
+ total: AggregateValue, // Overall aggregation
1001
+ items: Array<AggregateResult> // Per-period results
740
1002
  };
741
1003
 
742
1004
  // Each AggregateResult:
743
1005
  const result = {
744
1006
  date: 'Date',
745
1007
  result: {
746
- [field]:
747
- {
748
- count: 123,
749
- min: 123,
750
- max: 123,
751
- avg: 123,
752
- sum: 123,
753
- first: 123,
754
- last: 123
755
- }
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
+ }
756
1017
  }
757
1018
  };
758
1019
  ```
@@ -64,7 +64,7 @@ if `SECURITY_JWT_SECRET` is set.
64
64
 
65
65
  | Scope | Purpose | Access |
66
66
  |-----------|-----------------------|------------------------------------------------------------------------|
67
- | `Auth` | Full API access | All routes except `/refresh`, `/2fa-send-code`, `/verify-2fa-code` |
67
+ | `Auth` | Full API access | All routes except `/refresh`, `/send-2fa-code`, `/verify-2fa-code` |
68
68
  | `Refresh` | Token renewal only | Only `POST /auth/refresh` |
69
69
  | `TwoFA` | 2FA verification only | Only `POST /account/send-2fa-code` and `POST /account/verify-2fa-code` |
70
70
 
@@ -16,7 +16,6 @@
16
16
  }
17
17
  ]
18
18
  ],
19
- "setupFilesAfterEnv": ["<rootDir>/test/e2e/support/each.ts"],
20
19
  "globalSetup": "<rootDir>/test/e2e/support/setup.ts",
21
20
  "globalTeardown": "<rootDir>/test/e2e/support/teardown.ts"
22
21
  }
@@ -1,4 +1,5 @@
1
1
  import { Application, createApp } from '@appweaver/core';
2
+ import { resetTestData } from './support/reset';
2
3
 
3
4
  describe('Sample e2e test', () => {
4
5
  let app: Application;
@@ -11,6 +12,8 @@ describe('Sample e2e test', () => {
11
12
  await app.stop();
12
13
  });
13
14
 
15
+ afterAll(resetTestData, 10_000);
16
+
14
17
  test('Info endpoint /api', async () => {
15
18
  const resp = await app.server.inject({
16
19
  method: 'GET',
@@ -1,6 +1,5 @@
1
1
  import setup from './setup';
2
2
  import teardown from './teardown';
3
- import './each';
4
3
 
5
4
  beforeAll(async () => {
6
5
  await setup();
@@ -0,0 +1,26 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ /**
4
+ * Clears the test database and the file storage. Register it with `afterAll`
5
+ * in every end-to-end test file, after the hook that stops the application, so
6
+ * each test file starts from an empty database:
7
+ *
8
+ * ```ts
9
+ * afterAll(async () => {
10
+ * await app.stop();
11
+ * });
12
+ *
13
+ * afterAll(resetTestData, 10_000);
14
+ * ```
15
+ */
16
+ export function resetTestData(): void {
17
+ const { error } = spawnSync('weaver test reset', {
18
+ stdio: 'inherit',
19
+ shell: true
20
+ });
21
+
22
+ if (error) {
23
+ console.error(error);
24
+ process.exit(1);
25
+ }
26
+ }
@@ -1,13 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
-
3
- afterEach(() => {
4
- const { error } = spawnSync('weaver test reset', {
5
- stdio: 'inherit',
6
- shell: true
7
- });
8
-
9
- if (error) {
10
- console.error(error);
11
- process.exit(1);
12
- }
13
- }, 10_000);