@appweaver/core 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.
Files changed (42) hide show
  1. package/cache/eviction/lfu-eviction-index.js +3 -0
  2. package/export/export-service.d.ts +4 -2
  3. package/export/export-service.js +27 -17
  4. package/factory/create-model.js +72 -36
  5. package/factory/create-service.js +1 -1
  6. package/package.json +1 -1
  7. package/resource/index.d.ts +1 -0
  8. package/resource/index.js +1 -0
  9. package/resource/resource-schema.d.ts +0 -5
  10. package/resource/resource-schema.js +25 -10
  11. package/resource/resource-service.d.ts +214 -36
  12. package/resource/resource-service.js +247 -404
  13. package/resource/schemas/index.d.ts +3 -0
  14. package/resource/schemas/index.js +19 -0
  15. package/resource/schemas/resource-aggregate-schema.d.ts +54 -0
  16. package/resource/schemas/resource-aggregate-schema.js +131 -0
  17. package/resource/schemas/resource-filter-schema.d.ts +39 -0
  18. package/resource/schemas/resource-filter-schema.js +153 -0
  19. package/resource/schemas/resource-sort-schema.d.ts +37 -0
  20. package/resource/schemas/resource-sort-schema.js +114 -0
  21. package/resource/utils/aggregate-util.d.ts +113 -0
  22. package/resource/utils/aggregate-util.js +323 -0
  23. package/resource/utils/filter-util.d.ts +24 -0
  24. package/resource/utils/filter-util.js +283 -0
  25. package/resource/utils/index.d.ts +4 -0
  26. package/resource/utils/index.js +20 -0
  27. package/resource/utils/relation-util.d.ts +84 -0
  28. package/resource/utils/relation-util.js +344 -0
  29. package/resource/utils/sort-util.d.ts +20 -0
  30. package/resource/utils/sort-util.js +218 -0
  31. package/security/create-auth-resources.js +2 -2
  32. package/security/helper.js +1 -1
  33. package/security/resources/api-key/model.js +1 -0
  34. package/security/resources/api-key/service.d.ts +1 -1
  35. package/security/resources/role/model.js +1 -1
  36. package/server/create-server.js +4 -1
  37. package/server/register-route.js +1 -0
  38. package/server/swagger.js +87 -21
  39. package/server/virtual-projection.js +10 -8
  40. package/types/generated.d.ts +110 -4
  41. package/utils/schema-util.js +1 -1
  42. package/utils/virtual-util.js +1 -1
@@ -0,0 +1,344 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mapRelationInclusions = mapRelationInclusions;
4
+ exports.mapRelationActions = mapRelationActions;
5
+ exports.relationWriteData = relationWriteData;
6
+ exports.missingRelationFields = missingRelationFields;
7
+ exports.createdByConnect = createdByConnect;
8
+ const common_1 = require("@appweaver/common");
9
+ const context_1 = require("../../context");
10
+ const security_1 = require("../../security");
11
+ const errors_1 = require("../../errors");
12
+ /**
13
+ * Builds the Prisma `include` clause for the relation and file fields of a resource model. A field is included when
14
+ * its configured output type allows it for the given action, or always when no action is specified, and the relations
15
+ * configured with an output count contribute to the `_count` selection instead.
16
+ *
17
+ * @param {string} resourceName - The name of the model whose relations are included.
18
+ * @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
19
+ * of every field. When omitted, all fields whose output type is not `none` are included.
20
+ * @return {Object} The `include` clause mapping each included field to `true` or to its own nested `include` clause,
21
+ * extended with a `_count` selection for the relations configured with an output count.
22
+ */
23
+ function mapRelationInclusions(resourceName, action) {
24
+ const inclusion = {};
25
+ const resourceModel = (0, context_1.injectModel)(resourceName);
26
+ const relationConfig = resourceModel.config.relations;
27
+ const fileConfig = resourceModel.config.files;
28
+ const relationModelProps = (0, common_1.extractSchemaProperties)(resourceModel.relationsModel);
29
+ const fileModelProps = (0, common_1.extractSchemaProperties)(resourceModel.filesModel);
30
+ // Add relation and file fields to the inclusion map if the include type is
31
+ // satisfied or the requested action is not specified. Also, add the count
32
+ // aggregation actions for relations if configured.
33
+ for (const key of Object.keys({
34
+ ...relationModelProps,
35
+ ...fileModelProps
36
+ })) {
37
+ const relationField = relationConfig?.[key] || fileConfig?.[key];
38
+ if (relationField?.output?.count) {
39
+ inclusion._count = inclusion._count ?? { select: {} };
40
+ inclusion._count.select[key] = true;
41
+ }
42
+ // Check if the relation should be included based on the output type
43
+ if (shouldIncludeRelation(relationField?.output?.type, action)) {
44
+ inclusion[key] = buildNestedInclusion(relationField, action);
45
+ }
46
+ }
47
+ return inclusion;
48
+ }
49
+ /**
50
+ * Maps the relation and file fields of a write payload to the Prisma nested write actions. Bare key values and arrays
51
+ * of them are normalized to objects first, then every item is classified: items with an id and additional data become
52
+ * inline updates when the parent action is an update and inline updates are enabled for the relation, items with only
53
+ * an id are connected, and items without an id are created inline or matched through a connect-or-create when
54
+ * `input.uniqueKey` is configured. On an update action, the items of the current record that are absent from the new
55
+ * value, as well as the relations set to null, are disconnected, or deleted when `orphanRemoval` is configured.
56
+ * Relations that were not loaded on the current record are left untouched.
57
+ *
58
+ * @param {string} resourceName - The name of the model the write payload belongs to.
59
+ * @param {'create'|'update'} action - The write action the relation actions are mapped for. Inline updates and the
60
+ * removal of the relations absent from the new value are only applied on an update action.
61
+ * @param {Object} data - The sanitized write payload whose relation and file fields are mapped. The non-relation
62
+ * fields are skipped, except for the null values of the array scalar fields, which are mapped to an empty array on an
63
+ * update action and to undefined on a create action.
64
+ * @param {Object} [currentData] - The currently stored record with its relations loaded, required on an update action
65
+ * to determine which relations to disconnect or delete.
66
+ * @return {RelationActions} The nested write clause per relation field, mapping each one to its `connect`, `create`,
67
+ * `update`, `connectOrCreate`, `disconnect` and `delete` actions, or to undefined for the relations no action can be
68
+ * applied to.
69
+ * @throws {HttpError} 400 if an inline create payload is missing required fields, or if the relation accepts no new
70
+ * records and an id was not provided.
71
+ */
72
+ function mapRelationActions(resourceName, action, data, currentData) {
73
+ const relations = {};
74
+ const resourceModel = (0, context_1.injectModel)(resourceName);
75
+ const readModel = resourceModel.readModel;
76
+ const relationsModel = resourceModel.relationsModel;
77
+ const relationsConfig = resourceModel.config.relations;
78
+ for (const key in data) {
79
+ let value = data[key];
80
+ const relationSchema = (0, common_1.extractSchemaProperties)(relationsModel, key);
81
+ if (!relationSchema) {
82
+ // Set empty array or undefined value for null array type fields
83
+ if (value === null) {
84
+ if ((0, common_1.extractSchemaProperties)(readModel, key)?.type === 'array') {
85
+ relations[key] = action === 'update' ? [] : undefined;
86
+ }
87
+ else if (action === 'create') {
88
+ relations[key] = undefined;
89
+ }
90
+ }
91
+ // Skip mapping for non-relation fields
92
+ continue;
93
+ }
94
+ const config = relationsConfig?.[key];
95
+ // The unique key is only used to match existing records for the
96
+ // connect-or-create action; connect and inline update always use the id
97
+ const uniqueKey = config?.input?.allowCreate
98
+ ? config?.input?.uniqueKey || 'id'
99
+ : 'id';
100
+ const isArrayType = relationSchema.type === 'array';
101
+ // Normalize array values to single values if a value type is not an array
102
+ if (!isArrayType && (0, common_1.isArray)(value)) {
103
+ value = value[0];
104
+ }
105
+ // Set null values to undefined value for create actions. On update action
106
+ // they will be returned as disconnected relations.
107
+ if (action === 'create' && value === null) {
108
+ relations[key] = undefined;
109
+ continue;
110
+ }
111
+ // Normalize plain key values or arrays to object values
112
+ if (isArrayType) {
113
+ if ((0, common_1.isArray)(value) && !(0, common_1.isPlainObject)(value[0])) {
114
+ value = value.map((v) => ({
115
+ [uniqueKey]: v
116
+ }));
117
+ }
118
+ }
119
+ else if (!(0, common_1.isObject)(value)) {
120
+ // Only bare key values are wrapped. The loose object check keeps
121
+ // null values untouched, so they still map to a disconnect action.
122
+ value = { [uniqueKey]: value };
123
+ }
124
+ // Classify every relation input item into its relation write action:
125
+ // items with an id and additional data become inline updates (on parent
126
+ // update requests with inline updates enabled), items with only an id are
127
+ // connected, and items without an id are created inline or matched with
128
+ // connect-or-create, both of which require `allowCreate`
129
+ if (value) {
130
+ const items = isArrayType && (0, common_1.isArray)(value) ? value : [value];
131
+ const createdBy = createdByConnect(config?.model ?? resourceName);
132
+ const actions = {
133
+ connect: [],
134
+ update: [],
135
+ create: [],
136
+ connectOrCreate: []
137
+ };
138
+ for (const item of items) {
139
+ if (!(0, common_1.isPlainObject)(item)) {
140
+ continue;
141
+ }
142
+ if (item.id !== undefined && item.id !== null) {
143
+ const { id, ...itemData } = item;
144
+ const updateData = relationWriteData(config?.model, 'update', itemData);
145
+ if (action === 'update' &&
146
+ config?.input?.allowUpdate &&
147
+ Object.keys(updateData).length > 0) {
148
+ actions.update.push({ where: { id }, data: updateData });
149
+ }
150
+ else {
151
+ actions.connect.push({ id });
152
+ }
153
+ }
154
+ else if (!config?.input?.allowCreate) {
155
+ // Without inline creation the related record must already exist,
156
+ // so it is created through its own endpoint first
157
+ throw new errors_1.HttpError(`${resourceName} relation '${key}' does not accept new records, an id is required`, 400);
158
+ }
159
+ else {
160
+ const createData = relationWriteData(config?.model, 'create', item);
161
+ const missingFields = missingRelationFields(config?.model, createData);
162
+ if (missingFields.length > 0) {
163
+ throw new errors_1.HttpError(`${resourceName} relation '${key}' is missing required fields: ${missingFields.join(', ')}`, 400);
164
+ }
165
+ // A unique key matches an existing record before a new one is created
166
+ if (config?.input?.uniqueKey) {
167
+ actions.connectOrCreate.push({
168
+ where: { [uniqueKey]: item[uniqueKey] },
169
+ create: { ...createData, createdBy }
170
+ });
171
+ }
172
+ else {
173
+ actions.create.push({ ...createData, createdBy });
174
+ }
175
+ }
176
+ }
177
+ const relationActions = {};
178
+ for (const [actionName, actionItems] of Object.entries(actions)) {
179
+ if (actionItems.length > 0) {
180
+ relationActions[actionName] = isArrayType
181
+ ? actionItems
182
+ : actionItems[0];
183
+ }
184
+ }
185
+ if (Object.keys(relationActions).length > 0) {
186
+ relations[key] = relationActions;
187
+ }
188
+ }
189
+ // Map relation disconnects if keys are no longer present or the new
190
+ // value is null. Delete relations if orphanRemoval is set to true.
191
+ // Relations that are not set on the current resource are left untouched,
192
+ // so no relation action is applied for them.
193
+ if (action === 'update') {
194
+ const currentValue = currentData[key];
195
+ const removalMethod = config?.orphanRemoval ? 'delete' : 'disconnect';
196
+ if (currentValue && isArrayType) {
197
+ const newValueKeys = value?.map((v) => v[uniqueKey]) ?? [];
198
+ const currentValueKeys = currentValue
199
+ .filter((v) => newValueKeys.indexOf(v[uniqueKey]) === -1)
200
+ .map((v) => ({
201
+ [uniqueKey]: v[uniqueKey]
202
+ }));
203
+ if (currentValueKeys.length > 0) {
204
+ relations[key] = {
205
+ [removalMethod]: currentValueKeys,
206
+ ...(relations[key] ?? {})
207
+ };
208
+ }
209
+ }
210
+ else if (currentValue && value === null) {
211
+ relations[key] = {
212
+ [removalMethod]: { [uniqueKey]: currentValue[uniqueKey] },
213
+ ...(relations[key] ?? {})
214
+ };
215
+ }
216
+ else if (currentValue === undefined) {
217
+ // The relation was not loaded on the current record, so no relation
218
+ // action can be applied safely
219
+ relations[key] = undefined;
220
+ }
221
+ }
222
+ }
223
+ return relations;
224
+ }
225
+ /**
226
+ * Restricts an inline relation payload to the fields the related model accepts for the given action. The request
227
+ * schema accepts the create and the update fields together, since the properties it does not declare are stripped
228
+ * before the request reaches the service, so the configured field restrictions of the related model are applied here
229
+ * instead.
230
+ *
231
+ * @param {string} [resourceName] - The name of the related model whose field restrictions are applied. When omitted,
232
+ * the payload is returned unchanged.
233
+ * @param {'create'|'update'} action - The write action the payload is restricted for, selecting either the relation
234
+ * create or the relation update model of the related resource.
235
+ * @param {Object} data - The inline relation payload to restrict.
236
+ * @return {Object} The payload reduced to the fields the related model accepts for the action, excluding the `id`
237
+ * field, or the payload unchanged if the related model declares no fields for it.
238
+ */
239
+ function relationWriteData(resourceName, action, data) {
240
+ const resourceModel = resourceName
241
+ ? (0, context_1.injectModel)(resourceName, false)
242
+ : undefined;
243
+ const writeModel = action === 'create'
244
+ ? resourceModel?.relationCreateModel
245
+ : resourceModel?.relationUpdateModel;
246
+ const properties = (0, common_1.extractSchemaProperties)(writeModel);
247
+ if (!properties) {
248
+ return data;
249
+ }
250
+ return (0, common_1.pickProperties)(data, Object.keys(properties).filter((name) => name !== 'id'));
251
+ }
252
+ /**
253
+ * Lists the fields the related model requires on creation that the given inline relation payload does not provide.
254
+ *
255
+ * @param {string} [resourceName] - The name of the related model whose required fields are checked. When omitted, no
256
+ * fields are reported as missing.
257
+ * @param {Object} data - The inline relation payload to check.
258
+ * @return {string[]} The names of the fields the related model requires on creation that the payload leaves
259
+ * undefined, or an empty list if the related model declares no create schema.
260
+ */
261
+ function missingRelationFields(resourceName, data) {
262
+ const resourceModel = resourceName
263
+ ? (0, context_1.injectModel)(resourceName, false)
264
+ : undefined;
265
+ const createModel = resourceModel?.relationCreateModel;
266
+ if (!createModel) {
267
+ return [];
268
+ }
269
+ const schema = createModel['$ref']
270
+ ? createModel['$defs']?.[createModel['$ref']]
271
+ : createModel;
272
+ return (schema?.required ?? []).filter((name) => data[name] === undefined);
273
+ }
274
+ /**
275
+ * Builds the connect action for the `createdBy` audit relation of a resource, pointing at the currently authenticated
276
+ * user. Returns undefined when the model does not audit the `createdById` field or when no user is authenticated.
277
+ *
278
+ * @param {string} resourceName - The name of the model the audit relation is built for.
279
+ * @return {{connect: {id: number}}|undefined} The connect action pointing at the id of the currently authenticated
280
+ * user, or undefined if the model does not audit the `createdById` field or no user is authenticated.
281
+ */
282
+ function createdByConnect(resourceName) {
283
+ const resourceModel = (0, context_1.injectModel)(resourceName, false);
284
+ if (resourceModel?.config.audit?.createdById === false) {
285
+ return undefined;
286
+ }
287
+ const currentUser = (0, security_1.currentAuthUser)();
288
+ return currentUser
289
+ ? {
290
+ connect: {
291
+ id: currentUser.id
292
+ }
293
+ }
294
+ : undefined;
295
+ }
296
+ /**
297
+ * Resolves the inclusion value of a single relation field by walking its configured nested output includes
298
+ * recursively. Returns `true` when the relation has no nested includes to apply for the given action, or a nested
299
+ * `include` clause otherwise.
300
+ *
301
+ * @param {RelationField} relationField - The configuration of the relation whose inclusion value is resolved, read
302
+ * from its `output.include` property.
303
+ * @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
304
+ * of every nested relation.
305
+ * @return {boolean|Object} True if the relation has no nested relations to include for the given action, or the
306
+ * nested `include` clause otherwise.
307
+ */
308
+ function buildNestedInclusion(relationField, action) {
309
+ const nestedIncludeConfig = relationField?.output?.include;
310
+ if (!nestedIncludeConfig || Object.keys(nestedIncludeConfig).length === 0) {
311
+ return true;
312
+ }
313
+ const nestedInclusion = {};
314
+ for (const [nestedKey, nestedOutput] of Object.entries(nestedIncludeConfig)) {
315
+ // Check if the nested relation should be included
316
+ if (shouldIncludeRelation(nestedOutput?.type, action)) {
317
+ // Recursively build nested inclusions
318
+ nestedInclusion[nestedKey] = buildNestedInclusion({ output: nestedOutput }, action);
319
+ }
320
+ }
321
+ return Object.keys(nestedInclusion).length > 0
322
+ ? { include: nestedInclusion }
323
+ : true;
324
+ }
325
+ /**
326
+ * Decides whether a relation with the given configured output type is included for the given action. The `none` type
327
+ * is never included, the `single` type is excluded from the query action, and the `multiple` type is only included on
328
+ * the query action or when no action is specified.
329
+ *
330
+ * @param {OutputType} [outputType] - The configured output type of the relation. When omitted, the relation is
331
+ * included for every action.
332
+ * @param {ActionType} [action] - The action the relation would be included for. When omitted, every output type
333
+ * except `none` is included.
334
+ * @return {boolean} True if the relation should be included, false otherwise.
335
+ */
336
+ function shouldIncludeRelation(outputType, action) {
337
+ if (outputType === 'none') {
338
+ return false;
339
+ }
340
+ if (outputType === 'single' && action === 'query') {
341
+ return false;
342
+ }
343
+ return !(outputType === 'multiple' && action && action !== 'query');
344
+ }
@@ -0,0 +1,20 @@
1
+ import { ActionType, QuerySort } from '@appweaver/common';
2
+ /**
3
+ * Maps a sort input to the ordered list of Prisma `orderBy` entries. The input is accepted either as a comma-separated
4
+ * field list, where a field prefixed with `-` is sorted in descending order, or as a (possibly nested) object of `asc`
5
+ * and `desc` field directions. Both forms support the same field paths: a scalar field of the
6
+ * model, a field of a to-one relation (`author.createdAt` or `{ author: { createdAt: 'desc' } }`), and the related
7
+ * record count of a to-many relation, given either as its count field (`tagsCount`) or as the relation itself
8
+ * (`tags`). The default `createdAt` sort is dropped when the model does not audit that field.
9
+ *
10
+ * @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
11
+ * @param {string} resourceName - The name of the model the sort is applied on.
12
+ * @param {ActionType} [action] - The action the sort is applied on, deciding which relations are included in the
13
+ * response and are therefore available to sort by. Defaults to the query action.
14
+ * @return {Object[]} One `orderBy` entry per field, in the order the fields were listed, each holding the single path
15
+ * of that field mapped to `asc` or `desc`. Two fields of the same relation get an entry each, since a database order
16
+ * entry accepts a single field path.
17
+ * @throws {HttpError} 400 if a field does not exist on its model, cannot be sorted by, is given an unknown sort
18
+ * direction, or targets a relation that the action does not include in its response.
19
+ */
20
+ export declare function mapSortValues(sort: QuerySort, resourceName: string, action?: ActionType): any[];
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mapSortValues = mapSortValues;
4
+ const common_1 = require("@appweaver/common");
5
+ const context_1 = require("../../context");
6
+ const errors_1 = require("../../errors");
7
+ const relation_util_1 = require("./relation-util");
8
+ /** The audit fields every model declares unless it opts out of them. */
9
+ const defaultAuditFields = {
10
+ updatedAt: true,
11
+ createdAt: true,
12
+ createdById: true
13
+ };
14
+ /**
15
+ * Maps a sort input to the ordered list of Prisma `orderBy` entries. The input is accepted either as a comma-separated
16
+ * field list, where a field prefixed with `-` is sorted in descending order, or as a (possibly nested) object of `asc`
17
+ * and `desc` field directions. Both forms support the same field paths: a scalar field of the
18
+ * model, a field of a to-one relation (`author.createdAt` or `{ author: { createdAt: 'desc' } }`), and the related
19
+ * record count of a to-many relation, given either as its count field (`tagsCount`) or as the relation itself
20
+ * (`tags`). The default `createdAt` sort is dropped when the model does not audit that field.
21
+ *
22
+ * @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
23
+ * @param {string} resourceName - The name of the model the sort is applied on.
24
+ * @param {ActionType} [action] - The action the sort is applied on, deciding which relations are included in the
25
+ * response and are therefore available to sort by. Defaults to the query action.
26
+ * @return {Object[]} One `orderBy` entry per field, in the order the fields were listed, each holding the single path
27
+ * of that field mapped to `asc` or `desc`. Two fields of the same relation get an entry each, since a database order
28
+ * entry accepts a single field path.
29
+ * @throws {HttpError} 400 if a field does not exist on its model, cannot be sorted by, is given an unknown sort
30
+ * direction, or targets a relation that the action does not include in its response.
31
+ */
32
+ function mapSortValues(sort, resourceName, action = 'query') {
33
+ const inclusions = (0, relation_util_1.mapRelationInclusions)(resourceName, action);
34
+ const orderBy = [];
35
+ const mappedPaths = new Set();
36
+ for (const [path, direction] of sortEntries(sort)) {
37
+ const mappedPath = mapSortPath(resourceName, path, inclusions);
38
+ // Every field gets an entry of its own, since a database order entry holds
39
+ // a single field path, even when several of them share a relation. A field
40
+ // listed twice is already ordered by its first entry, so it is dropped
41
+ if (!mappedPath || mappedPaths.has(mappedPath)) {
42
+ continue;
43
+ }
44
+ mappedPaths.add(mappedPath);
45
+ orderBy.push((0, common_1.setValue)({}, mappedPath, direction));
46
+ }
47
+ return orderBy;
48
+ }
49
+ /**
50
+ * Flattens a sort input into the list of its field paths and directions, keeping the order the fields were declared
51
+ * in. String inputs are split on commas, with a `-` or `+` prefix selecting the direction, and object inputs are
52
+ * walked recursively, joining the nested keys with a dot.
53
+ *
54
+ * @param {QuerySort} sort - The sort input to flatten.
55
+ * @param {string} [parentPath] - The dot notation path of the object the walk descended from.
56
+ * @return {SortEntry[]} The flattened sort entries, each holding a dot notation field path and its direction.
57
+ * @throws {HttpError} 400 if a sort value is neither a direction nor a nested object of directions.
58
+ */
59
+ function sortEntries(sort, parentPath = '') {
60
+ if ((0, common_1.isString)(sort)) {
61
+ return sort
62
+ .split(',')
63
+ .map((part) => part.trim())
64
+ .filter((part) => part.length > 0 && part !== '-' && part !== '+')
65
+ .map((part) => [
66
+ part.replace(/^[-+]/, ''),
67
+ part.startsWith('-') ? 'desc' : 'asc'
68
+ ]);
69
+ }
70
+ if (!(0, common_1.isPlainObject)(sort)) {
71
+ throw new errors_1.HttpError(`Invalid sort value${parentPath ? ` for the '${parentPath}' field` : ''}, ` +
72
+ 'expected a field list string or a sort object', 400);
73
+ }
74
+ const entries = [];
75
+ for (const [field, value] of Object.entries(sort)) {
76
+ if (value === undefined || value === null) {
77
+ continue;
78
+ }
79
+ const path = parentPath ? `${parentPath}.${field}` : field;
80
+ if ((0, common_1.isPlainObject)(value)) {
81
+ entries.push(...sortEntries(value, path));
82
+ continue;
83
+ }
84
+ if (value !== 'asc' && value !== 'desc') {
85
+ throw new errors_1.HttpError(`Invalid sort direction '${value}' for the '${path}' field, expected 'asc' or 'desc'`, 400);
86
+ }
87
+ entries.push([path, value]);
88
+ }
89
+ return entries;
90
+ }
91
+ /**
92
+ * Resolves a single sort field path against the model it is applied on, validating every segment and rewriting the
93
+ * relation counts to their `_count` form. Relation segments are additionally checked against the relations the action
94
+ * includes in its response, since a relation that is not included cannot be sorted by.
95
+ *
96
+ * @param {string} resourceName - The name of the model the path starts at.
97
+ * @param {string} path - The dot notation field path to resolve.
98
+ * @param {Object} inclusions - The `include` clause of the action, as built by {@link mapRelationInclusions}.
99
+ * @return {string|undefined} The resolved dot notation path, or undefined when the field is the `createdAt` audit
100
+ * field of a model that does not audit it, in which case the sort entry is dropped.
101
+ * @throws {HttpError} 400 if a segment does not exist on its model, cannot be sorted by, or targets a relation the
102
+ * action does not include in its response.
103
+ */
104
+ function mapSortPath(resourceName, path, inclusions) {
105
+ const segments = path.split('.').filter((segment) => segment.length > 0);
106
+ const mappedSegments = [];
107
+ let modelName = resourceName;
108
+ let modelInclusions = inclusions;
109
+ for (const [index, field] of segments.entries()) {
110
+ const model = (0, context_1.injectModel)(modelName, false);
111
+ if (!model) {
112
+ throw new errors_1.HttpError(`Cannot sort by the '${path}' field, the '${modelName}' model is not loaded`, 400);
113
+ }
114
+ const isLast = index === segments.length - 1;
115
+ const relation = relationField(model, field);
116
+ // A scalar field ends the path, so it is the only segment a relation of the
117
+ // same name cannot shadow
118
+ if (!relation && isScalarField(model, field)) {
119
+ if (!isLast) {
120
+ throw new errors_1.HttpError(`Cannot sort by the '${path}' field, '${field}' is not a relation of the ${model.name} model`, 400);
121
+ }
122
+ // The default sort still names the createdAt field on the models that do
123
+ // not audit it, so the entry is dropped instead of rejected
124
+ if (field === 'createdAt' && model.config.audit?.createdAt === false) {
125
+ return undefined;
126
+ }
127
+ mappedSegments.push(field);
128
+ break;
129
+ }
130
+ // A count field sorts by the number of related records, which the database
131
+ // orders by through the _count aggregation of the relation
132
+ if (!relation && isLast && (0, common_1.isCountField)(field)) {
133
+ const countedField = field.slice(0, -'Count'.length);
134
+ const countedRelation = relationField(model, countedField);
135
+ if (countedRelation && isFieldArray(countedRelation)) {
136
+ mappedSegments.push(countedField, '_count');
137
+ break;
138
+ }
139
+ }
140
+ if (!relation) {
141
+ throw new errors_1.HttpError(`Cannot sort by the '${path}' field, '${field}' is not a sortable field of the ${model.name} model`, 400);
142
+ }
143
+ // A to-many relation holds no single value to sort by, so it is sorted by
144
+ // the number of its related records instead
145
+ if (isFieldArray(relation)) {
146
+ if (!isLast) {
147
+ throw new errors_1.HttpError(`Cannot sort by the '${path}' field, the '${field}' relation holds a list of records, ` +
148
+ `sort by the '${(0, common_1.countFieldName)(field)}' field instead`, 400);
149
+ }
150
+ mappedSegments.push(field, '_count');
151
+ break;
152
+ }
153
+ if (isLast) {
154
+ throw new errors_1.HttpError(`Cannot sort by the '${path}' field, the '${field}' relation requires a nested field to sort by`, 400);
155
+ }
156
+ const inclusion = modelInclusions?.[field];
157
+ if (!inclusion) {
158
+ throw new errors_1.HttpError(`Cannot sort by the '${path}' field, the '${field}' relation is not included in the response`, 400);
159
+ }
160
+ mappedSegments.push(field);
161
+ modelName = (0, common_1.capitalize)(relatedModelName(model, field));
162
+ modelInclusions = (0, common_1.isPlainObject)(inclusion) ? inclusion.include : undefined;
163
+ }
164
+ return mappedSegments.join('.');
165
+ }
166
+ /**
167
+ * Reads the relation or file field configuration of a model field.
168
+ *
169
+ * @param {ResourceModel} model - The model the field belongs to.
170
+ * @param {string} field - The name of the field to read.
171
+ * @return {RelationField|FileField|undefined} The relation or file configuration of the field, or undefined when the
172
+ * field is not a relation.
173
+ */
174
+ function relationField(model, field) {
175
+ return model.config.relations?.[field] ?? model.config.files?.[field];
176
+ }
177
+ /**
178
+ * Resolves the model name a relation or file field of a model points at.
179
+ *
180
+ * @param {ResourceModel} model - The model the relation belongs to.
181
+ * @param {string} field - The name of the relation field.
182
+ * @return {string} The name of the related model, which is always `File` for a file field.
183
+ */
184
+ function relatedModelName(model, field) {
185
+ return model.config.relations?.[field]?.model ?? 'File';
186
+ }
187
+ /**
188
+ * Determines whether a relation or file field holds a list of related records, which can only be sorted by their
189
+ * count.
190
+ *
191
+ * @param {RelationField|FileField} field - The relation or file field configuration.
192
+ * @return {boolean} True when the field holds a list of related records.
193
+ */
194
+ function isFieldArray(field) {
195
+ return 'model' in field ? (0, common_1.isRelationArray)(field) : field.array === true;
196
+ }
197
+ /**
198
+ * Determines whether a model field is a scalar column the database can sort by. The id and the audited fields are
199
+ * always sortable, the configured scalars are sortable unless they are hidden or hold a list of values, and the
200
+ * virtual fields are never sortable, since they have no column of their own.
201
+ *
202
+ * @param {ResourceModel} model - The model the field belongs to.
203
+ * @param {string} field - The name of the field to check.
204
+ * @return {boolean} True when the field can be sorted by.
205
+ */
206
+ function isScalarField(model, field) {
207
+ if (field === 'id') {
208
+ return true;
209
+ }
210
+ const auditFields = { ...defaultAuditFields, ...(model.config.audit ?? {}) };
211
+ if (field in auditFields) {
212
+ // The createdAt field of a model that does not audit it is still accepted,
213
+ // so the default sort value does not have to be adjusted per model
214
+ return auditFields[field] === true || field === 'createdAt';
215
+ }
216
+ const scalar = model.config.scalars?.[field];
217
+ return !!scalar && !scalar.hidden && scalar.array !== true;
218
+ }
@@ -58,7 +58,7 @@ function createAuthModel(config) {
58
58
  const authModelRelations = {
59
59
  roles: {
60
60
  model: 'Role',
61
- array: true,
61
+ type: 'manyToMany',
62
62
  input: {
63
63
  type: 'all'
64
64
  },
@@ -75,8 +75,8 @@ function createAuthModel(config) {
75
75
  ? {
76
76
  apiKeys: {
77
77
  model: 'ApiKey',
78
+ type: 'oneToMany',
78
79
  mappedBy: (0, common_1.uncapitalize)(config.name),
79
- array: true,
80
80
  input: {
81
81
  type: 'none'
82
82
  },
@@ -244,7 +244,7 @@ function checkScopeAccess(url, authScope) {
244
244
  const securityPrefix = common_1.config.SECURITY_ROUTE_PREFIX.replace(/\/$/, '');
245
245
  const accountPrefix = common_1.config.SECURITY_ACCOUNT_ROUTE_PREFIX.replace(/\/$/, '');
246
246
  const refreshPath = `${securityPrefix}/refresh`;
247
- const twoFASendPath = `${accountPrefix}/2fa-send-code`;
247
+ const twoFASendPath = `${accountPrefix}/send-2fa-code`;
248
248
  const twoFAVerifyPath = `${accountPrefix}/verify-2fa-code`;
249
249
  const scopeConfigs = [
250
250
  {
@@ -41,6 +41,7 @@ exports.default = shouldCreateModel
41
41
  relations: {
42
42
  [(0, common_1.uncapitalize)(authModel.name)]: {
43
43
  model: authModel.name,
44
+ type: 'oneToMany',
44
45
  mappedBy: 'apiKeys',
45
46
  owner: true,
46
47
  input: {
@@ -1,3 +1,3 @@
1
1
  import { ApiKey } from '../../../types';
2
- declare const _default: import("@appweaver/common").Ctor<import("../../..").ResourceService<ApiKey, ApiKey, ApiKey, any, any>> | undefined;
2
+ declare const _default: import("@appweaver/common").Ctor<import("../../..").ResourceService<ApiKey, ApiKey, ApiKey, any, import("@appweaver/common").QueryFilter<ApiKey>>> | undefined;
3
3
  export default _default;
@@ -13,7 +13,7 @@ exports.default = (0, factory_1.createModel)({
13
13
  relations: {
14
14
  permissions: {
15
15
  model: 'Permission',
16
- array: true
16
+ type: 'manyToMany'
17
17
  }
18
18
  },
19
19
  read: {
@@ -45,7 +45,10 @@ function createServer() {
45
45
  const server = (0, fastify_1.default)({
46
46
  ajv: {
47
47
  customOptions: {
48
- removeAdditional: 'all'
48
+ removeAdditional: 'all',
49
+ // Query filter schemas declare plain values as a list of accepted
50
+ // primitive types, so the validator does not coerce them
51
+ allowUnionTypes: true
49
52
  },
50
53
  plugins: [(ajv) => ajv.addKeyword('example')]
51
54
  },
@@ -16,6 +16,7 @@ function registerRoute(handler, config) {
16
16
  const tempServer = (0, fastify_1.default)({
17
17
  logger: false,
18
18
  ajv: {
19
+ customOptions: { allowUnionTypes: true },
19
20
  plugins: [(ajv) => ajv.addKeyword('example')]
20
21
  }
21
22
  });