@appweaver/core 1.4.1 → 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/factory/create-model.js +25 -5
- 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 +19 -6
- 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/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,7 @@ 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");
|
|
12
13
|
/** Levels of a self referencing relation read when the relation configures no
|
|
13
14
|
* `maxDepth`, i.e. the relation itself and nothing below it. */
|
|
14
15
|
const DEFAULT_RELATION_MAX_DEPTH = 1;
|
|
@@ -40,7 +41,9 @@ function mapRelationInclusions(resourceName, action) {
|
|
|
40
41
|
const relationField = relationConfig?.[key] || fileConfig?.[key];
|
|
41
42
|
if (relationField?.output?.count) {
|
|
42
43
|
inclusion._count = inclusion._count ?? { select: {} };
|
|
43
|
-
|
|
44
|
+
// Soft deleted related records are not counted
|
|
45
|
+
inclusion._count.select[key] =
|
|
46
|
+
(0, delete_util_1.liveInclusionFilter)(relationField.model) ?? true;
|
|
44
47
|
}
|
|
45
48
|
// Check if the relation should be included based on the output type
|
|
46
49
|
if (shouldIncludeRelation(relationField?.output?.type, action)) {
|
|
@@ -313,8 +316,8 @@ function createdByConnect(resourceName) {
|
|
|
313
316
|
* @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
|
|
314
317
|
* of every nested relation.
|
|
315
318
|
* @param {number} [depth=1] - The level of the relation being resolved, counting the relation itself as the first.
|
|
316
|
-
* @return {boolean|Object} True if the relation reads no further than itself, or
|
|
317
|
-
*
|
|
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.
|
|
318
321
|
*/
|
|
319
322
|
function buildNestedInclusion(relationField, key, action, depth = 1) {
|
|
320
323
|
const nestedIncludeConfig = relationField?.output?.include;
|
|
@@ -339,9 +342,19 @@ function buildNestedInclusion(relationField, key, action, depth = 1) {
|
|
|
339
342
|
relatedModel?.config?.files?.[nestedKey];
|
|
340
343
|
nestedInclusion[nestedKey] = buildNestedInclusion({ ...nestedRelation, output: nestedOutput }, nestedKey, action);
|
|
341
344
|
}
|
|
342
|
-
|
|
343
|
-
|
|
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;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
...(liveFilter ?? {}),
|
|
356
|
+
...(hasNested ? { include: nestedInclusion } : {})
|
|
357
|
+
};
|
|
345
358
|
}
|
|
346
359
|
/**
|
|
347
360
|
* Reads the relation a related model holds under the given field name, when it points back at that same model. It is
|
|
@@ -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;
|
|
@@ -95,13 +95,23 @@ export declare class FileService {
|
|
|
95
95
|
*/
|
|
96
96
|
deleteFiles(fileNames: Record<string, string | string[]>, resource: Resource, client: ResourceClient): Promise<File[]>;
|
|
97
97
|
/**
|
|
98
|
-
* Deletes all files associated with a resource
|
|
99
|
-
*
|
|
100
|
-
* setting (or set to `'keep'`) are left untouched.
|
|
98
|
+
* Deletes all files associated with a resource removed from the database,
|
|
99
|
+
* for the file fields not set to `onResourceDeleted: 'keep'`.
|
|
101
100
|
*
|
|
102
101
|
* @param {string} resourceName - The resource model name.
|
|
103
102
|
* @param {ResourceId} id - The ID of the deleted resource.
|
|
104
103
|
* @return {Promise<File[]>} A promise that resolves to the list of successfully deleted files.
|
|
105
104
|
*/
|
|
106
105
|
deleteResourceFiles(resourceName: string, id: ResourceId): Promise<File[]>;
|
|
106
|
+
/**
|
|
107
|
+
* Deletes all files associated with the given deleted resources of one model from the storage and the database. A
|
|
108
|
+
* resource removed from the database loses the files of every field not set to `onResourceDeleted: 'keep'`, while a
|
|
109
|
+
* soft deleted resource only loses the files of the fields set to `onResourceSoftDeleted: 'delete'`.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} resourceName - The resource model name.
|
|
112
|
+
* @param {ResourceId[]} ids - The IDs of the deleted resources.
|
|
113
|
+
* @param {boolean} [softDeleted=false] - Whether the resources were soft deleted.
|
|
114
|
+
* @return {Promise<File[]>} A promise that resolves to the list of successfully deleted files.
|
|
115
|
+
*/
|
|
116
|
+
deleteResourcesFiles(resourceName: string, ids: ResourceId[], softDeleted?: boolean): Promise<File[]>;
|
|
107
117
|
}
|