@appweaver/create-weaver-app 1.1.6 → 1.2.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 +1 -1
- package/skill/SKILL.md +100 -0
- package/skill/references/client.md +31 -13
- package/skill/references/resources.md +321 -60
- package/skill/references/security.md +1 -1
- package/templates/default/test/e2e/jest.e2e-config.json.node +0 -1
- package/templates/default/test/e2e/main.test.ts.tpl +3 -0
- package/templates/default/test/e2e/support/preload.ts.bun +0 -1
- package/templates/default/test/e2e/support/reset.ts.tpl +26 -0
- package/templates/default/test/e2e/support/each.ts.tpl +0 -13
package/package.json
CHANGED
package/skill/GUIDELINES.md
CHANGED
|
@@ -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
|
|
|
@@ -71,12 +71,15 @@ Reads an OpenAPI v3 schema and generates TypeScript types and a typed client cla
|
|
|
71
71
|
1. Reads and parses the schema (JSON or YAML, local or remote).
|
|
72
72
|
2. Generates TypeScript interfaces via `openapi-typescript`, enriching them with JSDoc validation tags (`@minLength`,
|
|
73
73
|
`@maxLength`, `@minimum`, `@maximum`, `@pattern`, `@format`).
|
|
74
|
-
3. Deduplicates union types and extracts inline schemas to named exported types
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
3. Deduplicates union types and extracts inline schemas to named exported types, including the ones carrying a
|
|
75
|
+
description (i.e. `PostQuerySort`).
|
|
76
|
+
4. Hoists the enums the schema repeats inline into a single shared enum each, so every sortable field of every resource
|
|
77
|
+
shares one `SortDirection` rather than declaring an `asc | desc` enum of its own.
|
|
78
|
+
5. Classifies all API paths into route groups: resources, auth, account, health, files, and custom.
|
|
79
|
+
6. Emits a typed client class extending `FetchClient<Paths>` with a getter for each route group. Resources with
|
|
77
80
|
unsupported operations are excluded at compile time using `Omit`.
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
7. Formats all output with Prettier.
|
|
82
|
+
8. Writes files with an autogenerated header comment.
|
|
80
83
|
|
|
81
84
|
**Examples:**
|
|
82
85
|
|
|
@@ -133,6 +136,16 @@ Contains all TypeScript interfaces and type aliases derived from the OpenAPI sch
|
|
|
133
136
|
namespace used to parameterise `FetchClient`. Module-level types (`AuthModuleType`, `AccountModuleType`,
|
|
134
137
|
`HealthModuleType`, per-resource `*ResourceModuleType`) are also exported and consumed by the client class.
|
|
135
138
|
|
|
139
|
+
Every schema definition becomes an exported type named after it, so the request and response shapes can be referenced
|
|
140
|
+
directly. The per-resource module type holds the same types under the keys the `ResourceClient` methods use:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { PostQuerySort, SortDirection } from './generated/schema';
|
|
144
|
+
|
|
145
|
+
const sort: PostQuerySort = { createdAt: SortDirection.desc, title: SortDirection.asc };
|
|
146
|
+
const posts = await client.post.query({ sort, page: 1, size: 20 });
|
|
147
|
+
```
|
|
148
|
+
|
|
136
149
|
### Client file
|
|
137
150
|
|
|
138
151
|
```ts
|
|
@@ -313,15 +326,20 @@ Exposes CRUD and file operations for a single resource endpoint.
|
|
|
313
326
|
// Find a single record
|
|
314
327
|
const post = await client.post.find(1);
|
|
315
328
|
|
|
316
|
-
// Query with filters and pagination
|
|
329
|
+
// Query with filters, sorting and pagination. The sort accepts a comma-separated
|
|
330
|
+
// field list ('-createdAt,id') or an object of field directions
|
|
317
331
|
const result = await client.post.query({
|
|
318
332
|
filter: { published: true },
|
|
319
|
-
sort:
|
|
320
|
-
page:
|
|
333
|
+
sort: { author: { lastName: 'asc' }, createdAt: 'desc' },
|
|
334
|
+
page: 1,
|
|
335
|
+
size: 20
|
|
321
336
|
});
|
|
322
337
|
|
|
323
|
-
// Aggregate
|
|
324
|
-
const stats = await client.post.aggregate({
|
|
338
|
+
// Aggregate. The select holds the operators to apply per numeric or date field
|
|
339
|
+
const stats = await client.post.aggregate({
|
|
340
|
+
select: { counter: { count: true, sum: true }, createdAt: { min: true } },
|
|
341
|
+
dateField: 'createdAt'
|
|
342
|
+
});
|
|
325
343
|
|
|
326
344
|
// Create
|
|
327
345
|
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
|
|
303
|
-
|
|
304
|
-
| `model`
|
|
305
|
-
| `
|
|
306
|
-
| `
|
|
307
|
-
| `
|
|
308
|
-
| `
|
|
309
|
-
| `
|
|
310
|
-
| `
|
|
311
|
-
| `
|
|
312
|
-
| `
|
|
313
|
-
| `
|
|
314
|
-
| `
|
|
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
|
|
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
|
|
325
|
-
|
|
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
|
|
355
|
-
|
|
356
|
+
**One-to-Many** (`type: 'oneToMany'`): The "many" side (which holds the foreign key) has `owner: true` and references a
|
|
357
|
+
single record; the "one" side has no `owner` and holds a list of related records.
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
384
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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
|
|
416
|
-
|
|
417
|
-
| `type`
|
|
418
|
-
| `
|
|
419
|
-
| `
|
|
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,
|
|
739
|
-
items:
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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-
|
|
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
|
|
|
@@ -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',
|
|
@@ -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
|
+
}
|