@appweaver/core 1.4.0 → 1.5.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/export/export-service.js +6 -0
- package/factory/create-model.js +43 -6
- package/package.json +1 -1
- package/prisma/client/internal/class.js +4 -4
- package/prisma/client/internal/prismaNamespace.d.ts +1 -0
- package/prisma/client/internal/prismaNamespace.js +2 -1
- package/prisma/client/internal/prismaNamespaceBrowser.d.ts +1 -0
- package/prisma/client/internal/prismaNamespaceBrowser.js +2 -1
- package/prisma/client/models/ConnectedAccount.d.ts +6 -1
- package/prisma/client/models/File.d.ts +29 -1
- package/resource/resource-loader.js +3 -0
- package/resource/resource-routes.js +0 -1
- package/resource/resource-service.d.ts +35 -8
- package/resource/resource-service.js +110 -23
- package/resource/utils/delete-util.d.ts +120 -0
- package/resource/utils/delete-util.js +385 -0
- package/resource/utils/filter-util.js +11 -5
- package/resource/utils/index.d.ts +1 -0
- package/resource/utils/index.js +1 -0
- package/resource/utils/relation-util.js +68 -19
- package/security/api-key/api-key-auth.js +4 -3
- package/security/oauth2/oauth2-service.js +19 -5
- package/security/resources/api-key/model.js +2 -0
- package/security/resources/connected-account/model.js +3 -1
- package/server/swagger.js +49 -1
- package/storage/file-service.d.ts +13 -3
- package/storage/file-service.js +24 -14
- package/storage/resources/file/model.js +5 -1
- package/types/generated.d.ts +1 -0
- package/utils/file-util.d.ts +14 -0
- package/utils/file-util.js +21 -0
- package/utils/model-util.d.ts +24 -0
- package/utils/model-util.js +43 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.liveRelationFilter = liveRelationFilter;
|
|
4
|
+
exports.liveInclusionFilter = liveInclusionFilter;
|
|
5
|
+
exports.hideDeletedRelations = hideDeletedRelations;
|
|
6
|
+
exports.softDeleteData = softDeleteData;
|
|
7
|
+
exports.softDeleteCascade = softDeleteCascade;
|
|
8
|
+
exports.cascadedRecords = cascadedRecords;
|
|
9
|
+
exports.removeOrphans = removeOrphans;
|
|
10
|
+
exports.retainDeletedFiles = retainDeletedFiles;
|
|
11
|
+
exports.mergeAffectedRecords = mergeAffectedRecords;
|
|
12
|
+
exports.assertLiveRelationTargets = assertLiveRelationTargets;
|
|
13
|
+
const common_1 = require("@appweaver/common");
|
|
14
|
+
const context_1 = require("../../context");
|
|
15
|
+
const security_1 = require("../../security");
|
|
16
|
+
const errors_1 = require("../../errors");
|
|
17
|
+
const utils_1 = require("../../utils");
|
|
18
|
+
/** The condition matching the records that are not soft deleted. */
|
|
19
|
+
const LIVE_RECORD = Object.freeze({ deletedAt: null });
|
|
20
|
+
/** The condition matching the soft deleted records. */
|
|
21
|
+
const DELETED_RECORD = Object.freeze({ deletedAt: { not: null } });
|
|
22
|
+
/** Referential actions that prevent deleting a record while others reference it. */
|
|
23
|
+
const RESTRICTING_ACTIONS = ['restrict', 'noAction'];
|
|
24
|
+
/**
|
|
25
|
+
* Restricts a mapped relation filter to the related records that are not soft deleted, so a filter never matches
|
|
26
|
+
* through a deleted record, and a null check treats a deleted related record as missing.
|
|
27
|
+
*
|
|
28
|
+
* @param {*} condition - The mapped database condition of the relation field.
|
|
29
|
+
* @param {string} resourceName - The name of the related model.
|
|
30
|
+
* @param {boolean} isArrayType - Whether the relation is a list (to-many) relation.
|
|
31
|
+
* @return {*} The condition restricted to the live related records, or unchanged if the related model does not soft
|
|
32
|
+
* delete its records.
|
|
33
|
+
*/
|
|
34
|
+
function liveRelationFilter(condition, resourceName, isArrayType) {
|
|
35
|
+
if (!(0, utils_1.isSoftDeleteModel)(resourceName)) {
|
|
36
|
+
return condition;
|
|
37
|
+
}
|
|
38
|
+
if (isArrayType) {
|
|
39
|
+
if (!(0, common_1.isPlainObject)(condition)) {
|
|
40
|
+
return condition;
|
|
41
|
+
}
|
|
42
|
+
const mapped = { ...condition };
|
|
43
|
+
for (const quantifier of ['some', 'none']) {
|
|
44
|
+
if (mapped[quantifier] !== undefined) {
|
|
45
|
+
mapped[quantifier] = { AND: [mapped[quantifier], LIVE_RECORD] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (mapped.every !== undefined) {
|
|
49
|
+
mapped.every = { OR: [mapped.every, DELETED_RECORD] };
|
|
50
|
+
}
|
|
51
|
+
return mapped;
|
|
52
|
+
}
|
|
53
|
+
if (condition === null) {
|
|
54
|
+
return { isNot: LIVE_RECORD };
|
|
55
|
+
}
|
|
56
|
+
if (!(0, common_1.isPlainObject)(condition)) {
|
|
57
|
+
return condition;
|
|
58
|
+
}
|
|
59
|
+
const { is, isNot, ...fields } = condition;
|
|
60
|
+
const matches = [];
|
|
61
|
+
const mapped = {};
|
|
62
|
+
if (is === null) {
|
|
63
|
+
mapped.isNot = LIVE_RECORD;
|
|
64
|
+
}
|
|
65
|
+
else if (is !== undefined) {
|
|
66
|
+
matches.push(is);
|
|
67
|
+
}
|
|
68
|
+
if (isNot === null) {
|
|
69
|
+
matches.push({});
|
|
70
|
+
}
|
|
71
|
+
else if (isNot !== undefined) {
|
|
72
|
+
mapped.isNot = { AND: [isNot, LIVE_RECORD] };
|
|
73
|
+
}
|
|
74
|
+
// The fields given without `is` are its shorthand
|
|
75
|
+
if (Object.keys(fields).length > 0) {
|
|
76
|
+
matches.push(fields);
|
|
77
|
+
}
|
|
78
|
+
if (matches.length > 0) {
|
|
79
|
+
mapped.is = { AND: [...matches, LIVE_RECORD] };
|
|
80
|
+
}
|
|
81
|
+
return mapped;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Builds the inclusion filter of a list relation, so only the related records that are not soft deleted are read.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} [resourceName] - The name of the related model.
|
|
87
|
+
* @return {Object|undefined} The `where` clause of the inclusion, or undefined if the related model does not soft
|
|
88
|
+
* delete its records.
|
|
89
|
+
*/
|
|
90
|
+
function liveInclusionFilter(resourceName) {
|
|
91
|
+
return (0, utils_1.isSoftDeleteModel)(resourceName)
|
|
92
|
+
? { where: { ...LIVE_RECORD } }
|
|
93
|
+
: undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Replaces the soft deleted records in the single relations of a resource with null, recursively. Unlike the list
|
|
97
|
+
* relations, a single relation cannot be filtered when it is read.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} resource - The resource as returned by the database client.
|
|
100
|
+
* @param {string} resourceName - The name of the model describing the resource.
|
|
101
|
+
* @return {Object} A copy of the resource without the soft deleted related records, or the resource itself if it
|
|
102
|
+
* holds no relations.
|
|
103
|
+
*/
|
|
104
|
+
function hideDeletedRelations(resource, resourceName) {
|
|
105
|
+
const relations = (0, context_1.injectModel)(resourceName, false)?.config?.relations;
|
|
106
|
+
if (!(0, common_1.isPlainObject)(resource) || !relations) {
|
|
107
|
+
return resource;
|
|
108
|
+
}
|
|
109
|
+
const projected = { ...resource };
|
|
110
|
+
for (const [key, relation] of Object.entries(relations)) {
|
|
111
|
+
const value = projected[key];
|
|
112
|
+
const relatedName = (0, common_1.capitalize)(relation.model);
|
|
113
|
+
if ((0, common_1.isArray)(value)) {
|
|
114
|
+
projected[key] = value.map((item) => hideDeletedRelations(item, relatedName));
|
|
115
|
+
}
|
|
116
|
+
else if ((0, common_1.isPlainObject)(value)) {
|
|
117
|
+
projected[key] =
|
|
118
|
+
value.deletedAt != null && (0, utils_1.isSoftDeleteModel)(relatedName)
|
|
119
|
+
? null
|
|
120
|
+
: hideDeletedRelations(value, relatedName);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return projected;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Builds the column values marking a record as soft deleted now by the current user, who is only recorded when the
|
|
127
|
+
* application defines an auth model.
|
|
128
|
+
*
|
|
129
|
+
* @return {SoftDeleteData} The soft delete column values.
|
|
130
|
+
*/
|
|
131
|
+
function softDeleteData() {
|
|
132
|
+
const data = { deletedAt: new Date() };
|
|
133
|
+
if ((0, security_1.resourceAuthModel)()) {
|
|
134
|
+
data.deletedById = (0, security_1.currentAuthUser)()?.id ?? null;
|
|
135
|
+
}
|
|
136
|
+
return data;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Mirrors the referential actions of a database delete for soft deleted records: cascading records are soft deleted
|
|
140
|
+
* with the same column values, level by level, a restricting relation aborts the delete, and references set to null on
|
|
141
|
+
* delete are kept for a manual restore. The records themselves are not updated.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} tx - The transaction client the delete runs in.
|
|
144
|
+
* @param {string} resourceName - The name of the model of the deleted records.
|
|
145
|
+
* @param {ResourceId[]} ids - The ids of the deleted records.
|
|
146
|
+
* @param {SoftDeleteData} data - The soft delete column values applied to the cascaded records.
|
|
147
|
+
* @return {Promise<AffectedRecords>} The ids of the soft deleted records per model, excluding the records themselves.
|
|
148
|
+
* @throws {HttpError} 409 if a live record references a deleted record through a restricting relation.
|
|
149
|
+
*/
|
|
150
|
+
async function softDeleteCascade(tx, resourceName, ids, data) {
|
|
151
|
+
return walkReferencingRecords(tx, resourceName, ids, async (relation) => {
|
|
152
|
+
const client = tx[(0, common_1.uncapitalize)(relation.modelName)];
|
|
153
|
+
const where = {
|
|
154
|
+
[relation.foreignKey]: { in: relation.ids },
|
|
155
|
+
...(0, utils_1.liveRecordFilter)(relation.modelName)
|
|
156
|
+
};
|
|
157
|
+
if (RESTRICTING_ACTIONS.includes(relation.onDelete)) {
|
|
158
|
+
await assertNotReferenced(client, where, relation);
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
if (relation.onDelete !== 'cascade') {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
// Loading validates every cascade of a soft deleted model, so this only
|
|
165
|
+
// guards the models registered after it
|
|
166
|
+
if (!(0, utils_1.isSoftDeleteModel)(relation.modelName)) {
|
|
167
|
+
throw new errors_1.HttpError(`${relation.modelName} must enable soft delete to cascade from ${relation.referencedName}`, 500);
|
|
168
|
+
}
|
|
169
|
+
const records = await client.findMany({ where, select: { id: true } });
|
|
170
|
+
const cascadedIds = records.map((record) => record.id);
|
|
171
|
+
if (cascadedIds.length > 0) {
|
|
172
|
+
await client.updateMany({ where: { id: { in: cascadedIds } }, data });
|
|
173
|
+
}
|
|
174
|
+
return cascadedIds;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Collects the records the database cascade removes together with deleted records, so their files and caches can be
|
|
179
|
+
* cleaned up once the delete commits.
|
|
180
|
+
*
|
|
181
|
+
* @param {Object} tx - The transaction client the delete runs in.
|
|
182
|
+
* @param {string} resourceName - The name of the model of the deleted records.
|
|
183
|
+
* @param {ResourceId[]} ids - The ids of the deleted records.
|
|
184
|
+
* @return {Promise<AffectedRecords>} The ids of the cascaded records per model, excluding the records themselves.
|
|
185
|
+
*/
|
|
186
|
+
async function cascadedRecords(tx, resourceName, ids) {
|
|
187
|
+
return walkReferencingRecords(tx, resourceName, ids, async (relation) => {
|
|
188
|
+
if (relation.onDelete !== 'cascade') {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
// Soft deleted records are removed by the cascade as well
|
|
192
|
+
const records = await tx[(0, common_1.uncapitalize)(relation.modelName)].findMany({
|
|
193
|
+
where: { [relation.foreignKey]: { in: relation.ids } },
|
|
194
|
+
select: { id: true }
|
|
195
|
+
});
|
|
196
|
+
return records.map((record) => record.id);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Removes the orphans an update maps to a nested `delete` action (`orphanRemoval`). Orphans of a soft deleted model are
|
|
201
|
+
* soft deleted with their cascade and keep their references, so their `delete` action is dropped; the others are left
|
|
202
|
+
* to the `delete` action, collecting the records its cascade removes.
|
|
203
|
+
*
|
|
204
|
+
* @param {Object} tx - The transaction client the update runs in.
|
|
205
|
+
* @param {string} resourceName - The name of the model the relation actions belong to.
|
|
206
|
+
* @param {RelationActions} relationActions - The mapped relation write actions, from which the nested `delete` actions
|
|
207
|
+
* of the soft deleted orphans are removed.
|
|
208
|
+
* @param {SoftDeleteData} data - The soft delete column values applied to the soft deleted orphans.
|
|
209
|
+
* @return {Promise<DeletedRecords>} The ids of the soft deleted and the removed records per model.
|
|
210
|
+
* @throws {HttpError} 409 if a live record references a soft deleted orphan through a restricting relation.
|
|
211
|
+
*/
|
|
212
|
+
async function removeOrphans(tx, resourceName, relationActions, data) {
|
|
213
|
+
const relations = (0, context_1.injectModel)(resourceName, false)?.config?.relations ?? {};
|
|
214
|
+
const deleted = { soft: {}, hard: {} };
|
|
215
|
+
for (const [key, actions] of Object.entries(relationActions)) {
|
|
216
|
+
const relation = relations[key];
|
|
217
|
+
if (!actions?.delete || !relation) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const relatedName = (0, common_1.capitalize)(relation.model);
|
|
221
|
+
const client = tx[(0, common_1.uncapitalize)(relatedName)];
|
|
222
|
+
const orphans = await client.findMany({
|
|
223
|
+
where: { OR: asList(actions.delete) },
|
|
224
|
+
select: { id: true }
|
|
225
|
+
});
|
|
226
|
+
const ids = orphans.map((orphan) => orphan.id);
|
|
227
|
+
if (ids.length === 0) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if ((0, utils_1.isSoftDeleteModel)(relatedName)) {
|
|
231
|
+
// Left undefined rather than removed, so the raw input value of the
|
|
232
|
+
// relation does not reach the database write in its place
|
|
233
|
+
delete actions.delete;
|
|
234
|
+
if (Object.keys(actions).length === 0) {
|
|
235
|
+
relationActions[key] = undefined;
|
|
236
|
+
}
|
|
237
|
+
const cascaded = await softDeleteCascade(tx, relatedName, ids, data);
|
|
238
|
+
await client.updateMany({ where: { id: { in: ids } }, data });
|
|
239
|
+
mergeAffectedRecords(deleted.soft, { [relatedName]: ids }, cascaded);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const cascaded = await cascadedRecords(tx, relatedName, ids);
|
|
243
|
+
mergeAffectedRecords(deleted.hard, { [relatedName]: ids }, cascaded);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return deleted;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Marks the file rows of the files the deleted records keep (`onResourceDeleted` / `onResourceSoftDeleted: 'keep'`) as
|
|
250
|
+
* deleted with the values of the delete, so they stay stored, e.g. for audit, but are no longer served.
|
|
251
|
+
*
|
|
252
|
+
* @param {Object} tx - The transaction client the delete runs in.
|
|
253
|
+
* @param {DeletedRecords} deleted - The soft deleted and the removed records.
|
|
254
|
+
* @param {SoftDeleteData} data - The soft delete column values of the delete.
|
|
255
|
+
* @return {Promise<void>} Resolves when the kept files are marked deleted.
|
|
256
|
+
*/
|
|
257
|
+
async function retainDeletedFiles(tx, deleted, data) {
|
|
258
|
+
const groups = [
|
|
259
|
+
[true, deleted.soft],
|
|
260
|
+
[false, deleted.hard]
|
|
261
|
+
];
|
|
262
|
+
for (const [softDeleted, records] of groups) {
|
|
263
|
+
for (const [modelName, ids] of Object.entries(records)) {
|
|
264
|
+
const files = (0, context_1.injectModel)(modelName, false)?.config.files;
|
|
265
|
+
const { kept } = (0, utils_1.deletedResourceFileFields)(files, softDeleted);
|
|
266
|
+
if (kept.length === 0 || ids.length === 0) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
await tx.file.updateMany({
|
|
270
|
+
where: {
|
|
271
|
+
resourceName: modelName,
|
|
272
|
+
// Owning ids are stored as text, whatever the model primary key is
|
|
273
|
+
resourceId: { in: ids.map(String) },
|
|
274
|
+
resourceField: { in: kept },
|
|
275
|
+
...(0, utils_1.liveRecordFilter)('File')
|
|
276
|
+
},
|
|
277
|
+
data
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Merges the affected records of several deletes into the target, keeping the ids of every model unique.
|
|
284
|
+
*
|
|
285
|
+
* @param {AffectedRecords} target - The affected records merged into.
|
|
286
|
+
* @param {AffectedRecords[]} sources - The affected records to add.
|
|
287
|
+
* @return {AffectedRecords} The target holding the ids of every source.
|
|
288
|
+
*/
|
|
289
|
+
function mergeAffectedRecords(target, ...sources) {
|
|
290
|
+
for (const source of sources) {
|
|
291
|
+
for (const [modelName, ids] of Object.entries(source)) {
|
|
292
|
+
const seen = new Set((target[modelName] ?? []).map(String));
|
|
293
|
+
target[modelName] = [
|
|
294
|
+
...(target[modelName] ?? []),
|
|
295
|
+
...ids.filter((id) => !seen.has(String(id)))
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return target;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Rejects the relation write actions pointing at soft deleted records, which the database would otherwise connect or
|
|
303
|
+
* update, since it does not know the record is deleted.
|
|
304
|
+
*
|
|
305
|
+
* @param {Object} client - The database client, or the transaction client the write runs in.
|
|
306
|
+
* @param {string} resourceName - The name of the model the relation actions belong to.
|
|
307
|
+
* @param {RelationActions} relationActions - The mapped relation write actions.
|
|
308
|
+
* @return {Promise<void>} Resolves when no action points at a soft deleted record.
|
|
309
|
+
* @throws {HttpError} 400 if a connect, update or connect-or-create action matches a soft deleted record.
|
|
310
|
+
*/
|
|
311
|
+
async function assertLiveRelationTargets(client, resourceName, relationActions) {
|
|
312
|
+
const relations = (0, context_1.injectModel)(resourceName, false)?.config?.relations ?? {};
|
|
313
|
+
for (const [key, actions] of Object.entries(relationActions)) {
|
|
314
|
+
const relatedName = relations[key]?.model;
|
|
315
|
+
if (!actions || !(0, utils_1.isSoftDeleteModel)(relatedName)) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const matches = [
|
|
319
|
+
...asList(actions.connect).map((item) => ({ id: item.id })),
|
|
320
|
+
...asList(actions.update).map((item) => item.where),
|
|
321
|
+
...asList(actions.connectOrCreate).map((item) => item.where)
|
|
322
|
+
];
|
|
323
|
+
if (matches.length === 0) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const deletedCount = await client[(0, common_1.uncapitalize)(relatedName)].count({
|
|
327
|
+
where: { AND: [DELETED_RECORD, { OR: matches }] }
|
|
328
|
+
});
|
|
329
|
+
if (deletedCount > 0) {
|
|
330
|
+
throw new errors_1.HttpError(`${resourceName} relation '${key}' references a ${(0, common_1.capitalize)(relatedName)} record that does not exist`, 400);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Wraps a single relation action value into a list. @internal */
|
|
335
|
+
function asList(value) {
|
|
336
|
+
return value === undefined ? [] : (0, common_1.isArray)(value) ? value : [value];
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Rejects a delete while records reference the deleted ones through a restricting relation.
|
|
340
|
+
*
|
|
341
|
+
* @internal
|
|
342
|
+
*/
|
|
343
|
+
async function assertNotReferenced(client, where, relation) {
|
|
344
|
+
const count = await client.count({ where });
|
|
345
|
+
if (count > 0) {
|
|
346
|
+
throw new errors_1.HttpError(`${relation.referencedName} cannot be deleted while ${relation.modelName} records reference it`, 409);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Walks the records referencing the given records breadth first. The visitor gets every referencing relation with the
|
|
351
|
+
* referenced ids and returns the ids of the records the walk continues from.
|
|
352
|
+
*
|
|
353
|
+
* @internal
|
|
354
|
+
*/
|
|
355
|
+
async function walkReferencingRecords(tx, resourceName, ids, visit) {
|
|
356
|
+
const models = Object.fromEntries(context_1.context.resource.models);
|
|
357
|
+
const affected = {};
|
|
358
|
+
const queue = [
|
|
359
|
+
{ modelName: resourceName, ids }
|
|
360
|
+
];
|
|
361
|
+
while (queue.length > 0) {
|
|
362
|
+
const level = queue.shift();
|
|
363
|
+
for (const relation of (0, common_1.referencingRelations)(models, level.modelName)) {
|
|
364
|
+
const visitedIds = await visit({
|
|
365
|
+
...relation,
|
|
366
|
+
ids: level.ids,
|
|
367
|
+
referencedName: level.modelName
|
|
368
|
+
});
|
|
369
|
+
// A record reached twice, i.e. through a self relation, is walked once
|
|
370
|
+
const seen = new Set((affected[relation.modelName] ?? []).map(String));
|
|
371
|
+
if (relation.modelName === resourceName) {
|
|
372
|
+
ids.forEach((id) => seen.add(String(id)));
|
|
373
|
+
}
|
|
374
|
+
const newIds = visitedIds.filter((value) => !seen.has(String(value)));
|
|
375
|
+
if (newIds.length > 0) {
|
|
376
|
+
affected[relation.modelName] = [
|
|
377
|
+
...(affected[relation.modelName] ?? []),
|
|
378
|
+
...newIds
|
|
379
|
+
];
|
|
380
|
+
queue.push({ modelName: relation.modelName, ids: newIds });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return affected;
|
|
385
|
+
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.mapQueryFilter = mapQueryFilter;
|
|
4
4
|
const common_1 = require("@appweaver/common");
|
|
5
5
|
const context_1 = require("../../context");
|
|
6
|
+
const delete_util_1 = require("./delete-util");
|
|
6
7
|
/**
|
|
7
8
|
* Logical filter operators mapped to their database query connectives. Both
|
|
8
9
|
* `_not` and `_nor` negate the conjunction of their conditions.
|
|
@@ -121,10 +122,15 @@ function mapQueryFilter(filter, resourceName) {
|
|
|
121
122
|
relationSchema?.type === 'array' ||
|
|
122
123
|
fileSchema?.type === 'array';
|
|
123
124
|
const isArrayValue = (0, common_1.isArray)(value);
|
|
124
|
-
//
|
|
125
|
-
|
|
125
|
+
// A soft deleted related record is matched as if it did not exist
|
|
126
|
+
const relationName = (0, common_1.extractResourceName)(relationSchema);
|
|
127
|
+
const liveRelation = (condition) => relationName
|
|
128
|
+
? (0, delete_util_1.liveRelationFilter)(condition, relationName, isArrayType)
|
|
129
|
+
: condition;
|
|
130
|
+
// Null values match records without a value or, for relations, without a
|
|
131
|
+
// related record, a soft deleted one included
|
|
126
132
|
if (value === null) {
|
|
127
|
-
queryFilter[key] = null;
|
|
133
|
+
queryFilter[key] = liveRelation(null);
|
|
128
134
|
continue;
|
|
129
135
|
}
|
|
130
136
|
// Recursively map nested objects and handle arrays of objects. Arrays of
|
|
@@ -134,7 +140,7 @@ function mapQueryFilter(filter, resourceName) {
|
|
|
134
140
|
if (relatedName) {
|
|
135
141
|
queryFilter[key] = isArrayValue
|
|
136
142
|
? value.map((item) => mapQueryFilter(item, relatedName))
|
|
137
|
-
: mapRelationFilter(value, relatedName, isArrayType);
|
|
143
|
+
: liveRelation(mapRelationFilter(value, relatedName, isArrayType));
|
|
138
144
|
}
|
|
139
145
|
else {
|
|
140
146
|
// Objects that match no relation have their field operators
|
|
@@ -145,7 +151,7 @@ function mapQueryFilter(filter, resourceName) {
|
|
|
145
151
|
// Map ID values for both single and array types of relationships
|
|
146
152
|
else if (relationSchema || fileSchema) {
|
|
147
153
|
const queryId = { id: isArrayValue ? { in: value } : value };
|
|
148
|
-
queryFilter[key] = isArrayType ? { some: queryId } : queryId;
|
|
154
|
+
queryFilter[key] = liveRelation(isArrayType ? { some: queryId } : queryId);
|
|
149
155
|
}
|
|
150
156
|
// Map fields without relationships, supporting array types
|
|
151
157
|
else if (readSchema) {
|
package/resource/utils/index.js
CHANGED
|
@@ -9,6 +9,10 @@ const common_1 = require("@appweaver/common");
|
|
|
9
9
|
const context_1 = require("../../context");
|
|
10
10
|
const security_1 = require("../../security");
|
|
11
11
|
const errors_1 = require("../../errors");
|
|
12
|
+
const delete_util_1 = require("./delete-util");
|
|
13
|
+
/** Levels of a self referencing relation read when the relation configures no
|
|
14
|
+
* `maxDepth`, i.e. the relation itself and nothing below it. */
|
|
15
|
+
const DEFAULT_RELATION_MAX_DEPTH = 1;
|
|
12
16
|
/**
|
|
13
17
|
* Builds the Prisma `include` clause for the relation and file fields of a resource model. A field is included when
|
|
14
18
|
* its configured output type allows it for the given action, or always when no action is specified, and the relations
|
|
@@ -37,11 +41,13 @@ function mapRelationInclusions(resourceName, action) {
|
|
|
37
41
|
const relationField = relationConfig?.[key] || fileConfig?.[key];
|
|
38
42
|
if (relationField?.output?.count) {
|
|
39
43
|
inclusion._count = inclusion._count ?? { select: {} };
|
|
40
|
-
|
|
44
|
+
// Soft deleted related records are not counted
|
|
45
|
+
inclusion._count.select[key] =
|
|
46
|
+
(0, delete_util_1.liveInclusionFilter)(relationField.model) ?? true;
|
|
41
47
|
}
|
|
42
48
|
// Check if the relation should be included based on the output type
|
|
43
49
|
if (shouldIncludeRelation(relationField?.output?.type, action)) {
|
|
44
|
-
inclusion[key] = buildNestedInclusion(relationField, action);
|
|
50
|
+
inclusion[key] = buildNestedInclusion(relationField, key, action);
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
53
|
return inclusion;
|
|
@@ -294,33 +300,76 @@ function createdByConnect(resourceName) {
|
|
|
294
300
|
: undefined;
|
|
295
301
|
}
|
|
296
302
|
/**
|
|
297
|
-
* Resolves the inclusion value of a single relation field
|
|
298
|
-
*
|
|
299
|
-
* `include` clause otherwise.
|
|
303
|
+
* Resolves the inclusion value of a single relation field, walking both the levels a self referencing relation
|
|
304
|
+
* repeats itself for and the nested includes it configures. Returns `true` when the relation reads no further than
|
|
305
|
+
* itself for the given action, or a nested `include` clause otherwise.
|
|
306
|
+
*
|
|
307
|
+
* A relation whose related model holds the same relation again points back at its own model, so it repeats itself
|
|
308
|
+
* down the tree up to its configured `output.maxDepth`, letting a category carry its ancestors without every level
|
|
309
|
+
* being spelled out. An `output.include` entry naming that same relation configures the level itself and replaces
|
|
310
|
+
* the repetition.
|
|
300
311
|
*
|
|
301
312
|
* @param {RelationField} relationField - The configuration of the relation whose inclusion value is resolved, read
|
|
302
|
-
* from its `output.include`
|
|
313
|
+
* from its `output.include` and `output.maxDepth` properties.
|
|
314
|
+
* @param {string} key - The field name the relation is declared under, matched against the related model to detect a
|
|
315
|
+
* relation pointing back at its own model.
|
|
303
316
|
* @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
|
|
304
317
|
* of every nested relation.
|
|
305
|
-
* @
|
|
306
|
-
* nested `include` clause
|
|
318
|
+
* @param {number} [depth=1] - The level of the relation being resolved, counting the relation itself as the first.
|
|
319
|
+
* @return {boolean|Object} True if the relation reads no further than itself, or its nested `include` clause, and
|
|
320
|
+
* for a list relation of a soft deleted model, the `where` clause skipping the deleted records.
|
|
307
321
|
*/
|
|
308
|
-
function buildNestedInclusion(relationField, action) {
|
|
322
|
+
function buildNestedInclusion(relationField, key, action, depth = 1) {
|
|
309
323
|
const nestedIncludeConfig = relationField?.output?.include;
|
|
310
|
-
if (!nestedIncludeConfig || Object.keys(nestedIncludeConfig).length === 0) {
|
|
311
|
-
return true;
|
|
312
|
-
}
|
|
313
324
|
const nestedInclusion = {};
|
|
314
|
-
|
|
325
|
+
// A file field carries no related model, so it reads no further than itself
|
|
326
|
+
const relatedModel = relationField?.model
|
|
327
|
+
? (0, context_1.injectModel)((0, common_1.capitalize)(relationField.model), false)
|
|
328
|
+
: undefined;
|
|
329
|
+
const selfRelation = selfReferencingRelation(relatedModel, key);
|
|
330
|
+
const maxDepth = relationField?.output?.maxDepth ?? DEFAULT_RELATION_MAX_DEPTH;
|
|
331
|
+
if (selfRelation && depth < maxDepth && !nestedIncludeConfig?.[key]) {
|
|
332
|
+
nestedInclusion[key] = buildNestedInclusion({ ...selfRelation, output: relationField.output }, key, action, depth + 1);
|
|
333
|
+
}
|
|
334
|
+
for (const [nestedKey, nestedOutput] of Object.entries(nestedIncludeConfig ?? {})) {
|
|
315
335
|
// Check if the nested relation should be included
|
|
316
|
-
if (shouldIncludeRelation(nestedOutput?.type, action)) {
|
|
317
|
-
|
|
318
|
-
nestedInclusion[nestedKey] = buildNestedInclusion({ output: nestedOutput }, action);
|
|
336
|
+
if (!shouldIncludeRelation(nestedOutput?.type, action)) {
|
|
337
|
+
continue;
|
|
319
338
|
}
|
|
339
|
+
// The related model carries the field the nested include names, so each
|
|
340
|
+
// level is resolved against the model it actually belongs to
|
|
341
|
+
const nestedRelation = relatedModel?.config?.relations?.[nestedKey] ??
|
|
342
|
+
relatedModel?.config?.files?.[nestedKey];
|
|
343
|
+
nestedInclusion[nestedKey] = buildNestedInclusion({ ...nestedRelation, output: nestedOutput }, nestedKey, action);
|
|
344
|
+
}
|
|
345
|
+
// A list relation reads only the related records that are not soft deleted,
|
|
346
|
+
// while a single relation is cleared of them after it is read
|
|
347
|
+
const liveFilter = relatedModel && (0, common_1.isRelationArray)(relationField)
|
|
348
|
+
? (0, delete_util_1.liveInclusionFilter)(relatedModel.name)
|
|
349
|
+
: undefined;
|
|
350
|
+
const hasNested = Object.keys(nestedInclusion).length > 0;
|
|
351
|
+
if (!hasNested && !liveFilter) {
|
|
352
|
+
return true;
|
|
320
353
|
}
|
|
321
|
-
return
|
|
322
|
-
|
|
323
|
-
:
|
|
354
|
+
return {
|
|
355
|
+
...(liveFilter ?? {}),
|
|
356
|
+
...(hasNested ? { include: nestedInclusion } : {})
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Reads the relation a related model holds under the given field name, when it points back at that same model. It is
|
|
361
|
+
* the relation a self referencing field repeats itself through, i.e. the `parent` of the parent of a category.
|
|
362
|
+
*
|
|
363
|
+
* @param {ResourceModel} [model] - The related model the field is looked up on.
|
|
364
|
+
* @param {string} key - The field name of the relation.
|
|
365
|
+
* @return {RelationField | undefined} The relation of the model under that name when it references the model itself,
|
|
366
|
+
* undefined otherwise.
|
|
367
|
+
*/
|
|
368
|
+
function selfReferencingRelation(model, key) {
|
|
369
|
+
const relation = key ? model?.config?.relations?.[key] : undefined;
|
|
370
|
+
return relation && (0, common_1.capitalize)(relation.model) === model?.name
|
|
371
|
+
? relation
|
|
372
|
+
: undefined;
|
|
324
373
|
}
|
|
325
374
|
/**
|
|
326
375
|
* Decides whether a relation with the given configured output type is included for the given action. The `none` type
|
|
@@ -9,6 +9,7 @@ const fastify_plugin_1 = __importDefault(require("fastify-plugin"));
|
|
|
9
9
|
const request_context_1 = require("@fastify/request-context");
|
|
10
10
|
const common_1 = require("@appweaver/common");
|
|
11
11
|
const context_1 = require("../../context");
|
|
12
|
+
const utils_1 = require("../../utils");
|
|
12
13
|
const helper_1 = require("../helper");
|
|
13
14
|
const auth_service_1 = require("../auth-service");
|
|
14
15
|
const errors_1 = require("../../errors");
|
|
@@ -38,9 +39,9 @@ exports.apiKeyAuth = (0, fastify_plugin_1.default)(async (server) => {
|
|
|
38
39
|
if (!apiKey) {
|
|
39
40
|
try {
|
|
40
41
|
// The generated client types the id after the configured primary key
|
|
41
|
-
apiKey = await db
|
|
42
|
-
.
|
|
43
|
-
|
|
42
|
+
apiKey = await db.client().apiKey.findFirst({
|
|
43
|
+
where: { id: apiKeyId, ...(0, utils_1.liveRecordFilter)('ApiKey') }
|
|
44
|
+
});
|
|
44
45
|
}
|
|
45
46
|
catch (e) {
|
|
46
47
|
throw new errors_1.HttpError(`Invalid API key format`, 401);
|
|
@@ -112,11 +112,16 @@ class OAuth2Service {
|
|
|
112
112
|
if (account && this.connectedAccountOwnerId(account) !== authUser.id) {
|
|
113
113
|
throw new errors_1.HttpError('This provider account is already linked to another user', 403);
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
if (account) {
|
|
116
|
+
try {
|
|
117
117
|
await service.update(account.id, { scope, lastLoginAt: new Date() });
|
|
118
|
-
return;
|
|
119
118
|
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
throw new errors_1.HttpError('Connected account link error', 500, e);
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
120
125
|
await service.create({
|
|
121
126
|
provider: source,
|
|
122
127
|
providerAccountId,
|
|
@@ -124,11 +129,20 @@ class OAuth2Service {
|
|
|
124
129
|
lastLoginAt: new Date(),
|
|
125
130
|
[(0, common_1.uncapitalize)(this._authUserService.modelName)]: { id: authUser.id }
|
|
126
131
|
});
|
|
127
|
-
common_1.logger.debug({ id: authUser.id, source }, 'OAuth2 provider account linked');
|
|
128
132
|
}
|
|
129
133
|
catch (e) {
|
|
130
|
-
|
|
134
|
+
// A concurrent sign-in may have created the link first, which the unique
|
|
135
|
+
// constraint on the provider account rejects this one for
|
|
136
|
+
const linked = await this.findConnectedAccount(source, providerAccountId).catch(() => null);
|
|
137
|
+
if (!linked) {
|
|
138
|
+
throw new errors_1.HttpError('Connected account link error', 500, e);
|
|
139
|
+
}
|
|
140
|
+
if (this.connectedAccountOwnerId(linked) !== authUser.id) {
|
|
141
|
+
throw new errors_1.HttpError('This provider account is already linked to another user', 403);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
131
144
|
}
|
|
145
|
+
common_1.logger.debug({ id: authUser.id, source }, 'OAuth2 provider account linked');
|
|
132
146
|
}
|
|
133
147
|
/**
|
|
134
148
|
* Reads the owning user id off a link, which the generated model exposes as a `<authModel>Id` foreign key.
|
|
@@ -9,6 +9,8 @@ const shouldCreateModel = common_1.config.SECURITY_API_KEY_ENABLED ||
|
|
|
9
9
|
exports.default = shouldCreateModel
|
|
10
10
|
? (0, factory_1.createModel)({
|
|
11
11
|
name: 'ApiKey',
|
|
12
|
+
// Cascades from the auth model, so it is soft deleted together with it
|
|
13
|
+
softDelete: !!authModel?.config.softDelete,
|
|
12
14
|
scalars: {
|
|
13
15
|
key: {
|
|
14
16
|
type: 'string'
|
|
@@ -9,6 +9,8 @@ const shouldCreateModel = (0, helper_1.isOAuth2Enabled)() ||
|
|
|
9
9
|
exports.default = shouldCreateModel
|
|
10
10
|
? (0, factory_1.createModel)({
|
|
11
11
|
name: 'ConnectedAccount',
|
|
12
|
+
// Cascades from the auth model, so it is soft deleted together with it
|
|
13
|
+
softDelete: !!authModel?.config.softDelete,
|
|
12
14
|
audit: {
|
|
13
15
|
createdById: false
|
|
14
16
|
},
|
|
@@ -50,6 +52,6 @@ exports.default = shouldCreateModel
|
|
|
50
52
|
}
|
|
51
53
|
: {}),
|
|
52
54
|
// A provider account may only ever be linked to a single user
|
|
53
|
-
|
|
55
|
+
unique: [['provider', 'providerAccountId']]
|
|
54
56
|
})
|
|
55
57
|
: undefined;
|