@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.
@@ -27,8 +27,9 @@ class FileService {
27
27
  this.assertPathNotReserved(fileName);
28
28
  let file;
29
29
  try {
30
+ // The files a deleted resource keeps are retained, but no longer served
30
31
  file = (await this._db.client().file.findFirst({
31
- where: { name: fileName }
32
+ where: { name: fileName, ...(0, utils_1.liveRecordFilter)('File') }
32
33
  }));
33
34
  }
34
35
  catch (e) {
@@ -253,25 +254,34 @@ class FileService {
253
254
  return deletedFiles;
254
255
  }
255
256
  /**
256
- * Deletes all files associated with a resource for file fields configured
257
- * with `onResourceDeleted: 'delete'`. Files belonging to fields without this
258
- * setting (or set to `'keep'`) are left untouched.
257
+ * Deletes all files associated with a resource removed from the database,
258
+ * for the file fields not set to `onResourceDeleted: 'keep'`.
259
259
  *
260
260
  * @param {string} resourceName - The resource model name.
261
261
  * @param {ResourceId} id - The ID of the deleted resource.
262
262
  * @return {Promise<File[]>} A promise that resolves to the list of successfully deleted files.
263
263
  */
264
264
  async deleteResourceFiles(resourceName, id) {
265
+ return this.deleteResourcesFiles(resourceName, [id]);
266
+ }
267
+ /**
268
+ * Deletes all files associated with the given deleted resources of one model from the storage and the database. A
269
+ * resource removed from the database loses the files of every field not set to `onResourceDeleted: 'keep'`, while a
270
+ * soft deleted resource only loses the files of the fields set to `onResourceSoftDeleted: 'delete'`.
271
+ *
272
+ * @param {string} resourceName - The resource model name.
273
+ * @param {ResourceId[]} ids - The IDs of the deleted resources.
274
+ * @param {boolean} [softDeleted=false] - Whether the resources were soft deleted.
275
+ * @return {Promise<File[]>} A promise that resolves to the list of successfully deleted files.
276
+ */
277
+ async deleteResourcesFiles(resourceName, ids, softDeleted = false) {
265
278
  // Owning ids are stored as text, whatever the model primary key type is
266
- const resourceId = String(id);
279
+ const resourceIds = ids.map(String);
267
280
  const resourceModel = (0, context_1.injectModel)(resourceName, false);
268
- if (!resourceModel) {
281
+ if (!resourceModel || resourceIds.length === 0) {
269
282
  return [];
270
283
  }
271
- const filesConfig = resourceModel.config.files ?? {};
272
- const deleteFields = Object.entries(filesConfig)
273
- .filter(([_, config]) => config.onResourceDeleted !== 'keep')
274
- .map(([field]) => field);
284
+ const { deleted: deleteFields } = (0, utils_1.deletedResourceFileFields)(resourceModel.config.files, softDeleted);
275
285
  if (deleteFields.length === 0) {
276
286
  return [];
277
287
  }
@@ -280,13 +290,13 @@ class FileService {
280
290
  files = (await this._db.client().file.findMany({
281
291
  where: {
282
292
  resourceName,
283
- resourceId,
293
+ resourceId: { in: resourceIds },
284
294
  resourceField: { in: deleteFields }
285
295
  }
286
296
  }));
287
297
  }
288
298
  catch (e) {
289
- common_1.logger.error({ resourceName, resourceId, error: e }, 'Error finding resource files for deletion');
299
+ common_1.logger.error({ resourceName, resourceIds, error: e }, 'Error finding resource files for deletion');
290
300
  return [];
291
301
  }
292
302
  const deletedFiles = [];
@@ -296,11 +306,11 @@ class FileService {
296
306
  deletedFiles.push(file);
297
307
  }
298
308
  else {
299
- common_1.logger.error({ resourceName, resourceId, fileName: file.name }, 'Failed to delete file');
309
+ common_1.logger.error({ resourceName, resourceId: file.resourceId, fileName: file.name }, 'Failed to delete file');
300
310
  }
301
311
  }
302
312
  if (deletedFiles.length > 0) {
303
- common_1.logger.debug({ resourceName, resourceId, deletedCount: deletedFiles.length }, 'Resource files deleted');
313
+ common_1.logger.debug({ resourceName, resourceIds, deletedCount: deletedFiles.length }, 'Resource files deleted');
304
314
  }
305
315
  return deletedFiles;
306
316
  }
@@ -4,6 +4,9 @@ const factory_1 = require("../../../factory");
4
4
  const utils_1 = require("../../../utils");
5
5
  exports.default = (0, factory_1.createModel)({
6
6
  name: 'File',
7
+ // The files a deleted resource keeps are retained for audit, but no longer
8
+ // served, by marking their rows deleted together with the resource
9
+ softDelete: true,
7
10
  scalars: {
8
11
  name: {
9
12
  type: 'string',
@@ -72,5 +75,6 @@ exports.default = (0, factory_1.createModel)({
72
75
  }
73
76
  }
74
77
  },
75
- index: [['resourceField', 'resourceName', 'resourceId']]
78
+ // Leads with the exact match columns, so the prefix also serves lookups
79
+ index: [['resourceName', 'resourceId', 'resourceField']]
76
80
  });
@@ -290,6 +290,7 @@ export type File = {
290
290
  updatedAt: Date;
291
291
  createdAt: Date;
292
292
  createdById?: number | null;
293
+ deletedAt?: Date | null;
293
294
  };
294
295
  export type FileSingle = {
295
296
  id: number;
@@ -9,6 +9,20 @@ import { File } from '../types';
9
9
  * @return {string} The absolute URL for accessing the file.
10
10
  */
11
11
  export declare function buildFileUrl(file: File): string;
12
+ /**
13
+ * Splits the file fields of a deleted resource by what happens to their files. On a delete removing the resource from
14
+ * the database each field follows its `onResourceDeleted` option, which defaults to `'delete'`, while on a soft delete
15
+ * it follows its `onResourceSoftDeleted` option, which defaults to `'keep'`.
16
+ *
17
+ * @param {FilesConfig} [files] - The file fields of the deleted resource's model.
18
+ * @param {boolean} softDeleted - Whether the resource was soft deleted.
19
+ * @return {{deleted: string[], kept: string[]}} The fields whose files are removed from storage, and the fields whose
20
+ * files are retained, but no longer served.
21
+ */
22
+ export declare function deletedResourceFileFields(files: FilesConfig | undefined, softDeleted: boolean): {
23
+ deleted: string[];
24
+ kept: string[];
25
+ };
12
26
  /**
13
27
  * Parses a range string and converts it into an object with start and end values.
14
28
  *
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildFileUrl = buildFileUrl;
4
+ exports.deletedResourceFileFields = deletedResourceFileFields;
4
5
  exports.parseRange = parseRange;
5
6
  exports.maxFileSize = maxFileSize;
6
7
  exports.sizeInBytes = sizeInBytes;
@@ -26,6 +27,26 @@ function buildFileUrl(file) {
26
27
  const routePrefix = `/${common_1.config.STORAGE_FILES_ROUTE_PREFIX}/`.replace(/\/+/g, '/');
27
28
  return `${common_1.config.APP_HOSTNAME}${routePrefix}${pathPrefix}/${file.name}`;
28
29
  }
30
+ /**
31
+ * Splits the file fields of a deleted resource by what happens to their files. On a delete removing the resource from
32
+ * the database each field follows its `onResourceDeleted` option, which defaults to `'delete'`, while on a soft delete
33
+ * it follows its `onResourceSoftDeleted` option, which defaults to `'keep'`.
34
+ *
35
+ * @param {FilesConfig} [files] - The file fields of the deleted resource's model.
36
+ * @param {boolean} softDeleted - Whether the resource was soft deleted.
37
+ * @return {{deleted: string[], kept: string[]}} The fields whose files are removed from storage, and the fields whose
38
+ * files are retained, but no longer served.
39
+ */
40
+ function deletedResourceFileFields(files = {}, softDeleted) {
41
+ const fields = { deleted: [], kept: [] };
42
+ for (const [field, fileField] of Object.entries(files)) {
43
+ const action = softDeleted
44
+ ? (fileField.onResourceSoftDeleted ?? 'keep')
45
+ : (fileField.onResourceDeleted ?? 'delete');
46
+ fields[action === 'delete' ? 'deleted' : 'kept'].push(field);
47
+ }
48
+ return fields;
49
+ }
29
50
  /**
30
51
  * Parses a range string and converts it into an object with start and end values.
31
52
  *
@@ -1,4 +1,28 @@
1
1
  import { ResourceModel } from '@appweaver/common';
2
+ /**
3
+ * Checks whether the model with the given name soft deletes its records.
4
+ *
5
+ * @param {string} [resourceName] - The name of the model to check.
6
+ * @return {boolean} True if the model is loaded and has `softDelete` enabled.
7
+ */
8
+ export declare function isSoftDeleteModel(resourceName?: string): boolean;
9
+ /**
10
+ * Builds the condition matching only the records of a model that are not soft
11
+ * deleted, for the queries reading its records outside the resource service.
12
+ *
13
+ * @param {string} resourceName - The name of the model the condition is built for.
14
+ * @return {Object} The `deletedAt: null` condition for a soft deleted model, or
15
+ * an empty object otherwise.
16
+ */
17
+ export declare function liveRecordFilter(resourceName: string): Record<string, any>;
18
+ /**
19
+ * Validates that every relation cascading on delete from a model with
20
+ * `softDelete` enabled belongs to a model that soft deletes its records too.
21
+ *
22
+ * @param {Record<string, ResourceModel>} models - All loaded models keyed by name.
23
+ * @throws {Error} When a soft deleted model cascades into a model without soft delete.
24
+ */
25
+ export declare function validateSoftDeleteCascades(models: Record<string, ResourceModel>): void;
2
26
  /**
3
27
  * Validates that the default value of every scalar and virtual field satisfies
4
28
  * the constraints declared on that same field. A default outside its own
@@ -1,7 +1,50 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSoftDeleteModel = isSoftDeleteModel;
4
+ exports.liveRecordFilter = liveRecordFilter;
5
+ exports.validateSoftDeleteCascades = validateSoftDeleteCascades;
3
6
  exports.validateScalarDefaults = validateScalarDefaults;
4
7
  const common_1 = require("@appweaver/common");
8
+ const context_1 = require("../context");
9
+ /**
10
+ * Checks whether the model with the given name soft deletes its records.
11
+ *
12
+ * @param {string} [resourceName] - The name of the model to check.
13
+ * @return {boolean} True if the model is loaded and has `softDelete` enabled.
14
+ */
15
+ function isSoftDeleteModel(resourceName) {
16
+ if (!resourceName) {
17
+ return false;
18
+ }
19
+ const model = (0, context_1.injectModel)((0, common_1.capitalize)(resourceName), false);
20
+ return (0, common_1.hasSoftDelete)(model?.config);
21
+ }
22
+ /**
23
+ * Builds the condition matching only the records of a model that are not soft
24
+ * deleted, for the queries reading its records outside the resource service.
25
+ *
26
+ * @param {string} resourceName - The name of the model the condition is built for.
27
+ * @return {Object} The `deletedAt: null` condition for a soft deleted model, or
28
+ * an empty object otherwise.
29
+ */
30
+ function liveRecordFilter(resourceName) {
31
+ return isSoftDeleteModel(resourceName) ? { deletedAt: null } : {};
32
+ }
33
+ /**
34
+ * Validates that every relation cascading on delete from a model with
35
+ * `softDelete` enabled belongs to a model that soft deletes its records too.
36
+ *
37
+ * @param {Record<string, ResourceModel>} models - All loaded models keyed by name.
38
+ * @throws {Error} When a soft deleted model cascades into a model without soft delete.
39
+ */
40
+ function validateSoftDeleteCascades(models) {
41
+ const errors = (0, common_1.softDeleteCascadeErrors)(models);
42
+ if (errors.length > 0) {
43
+ throw new Error(`Invalid resource model soft delete cascades:\n${errors
44
+ .map((error) => ` - ${error}`)
45
+ .join('\n')}`);
46
+ }
47
+ }
5
48
  /**
6
49
  * Validates that the default value of every scalar and virtual field satisfies
7
50
  * the constraints declared on that same field. A default outside its own