@appweaver/core 1.4.1 → 1.5.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.
@@ -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
- // Null values are passed through untouched, matching records without a
125
- // value or, for relations, without a related record.
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) {
@@ -3,3 +3,4 @@ export * from './cursor-util';
3
3
  export * from './filter-util';
4
4
  export * from './relation-util';
5
5
  export * from './sort-util';
6
+ export * from './delete-util';
@@ -19,3 +19,4 @@ __exportStar(require("./cursor-util"), exports);
19
19
  __exportStar(require("./filter-util"), exports);
20
20
  __exportStar(require("./relation-util"), exports);
21
21
  __exportStar(require("./sort-util"), exports);
22
+ __exportStar(require("./delete-util"), exports);
@@ -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
- inclusion._count.select[key] = true;
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 the nested `include` clause
317
- * otherwise.
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
- return Object.keys(nestedInclusion).length > 0
343
- ? { include: nestedInclusion }
344
- : true;
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
- .client()
43
- .apiKey.findFirst({ where: { id: apiKeyId } });
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
- try {
116
- if (account) {
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
- throw new errors_1.HttpError('Connected account link error', 500, e);
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
- index: [['provider', 'providerAccountId']]
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 for file fields configured
99
- * with `onResourceDeleted: 'delete'`. Files belonging to fields without this
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
  }