@byline/core 3.17.0 → 3.17.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.
@@ -9,7 +9,8 @@ import { resolveHooks } from '../../@types/index.js';
9
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
10
  import { ERR_NOT_FOUND } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
- import { getUploadFields } from '../../utils/storage-utils.js';
12
+ import { hasUploadField, isUploadField } from '../../utils/storage-utils.js';
13
+ import { walkFieldTree } from '../walk-field-tree.js';
13
14
  import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
14
15
  import { invokeHook } from './internals.js';
15
16
  import { promoteChildrenAndRemove } from './tree.js';
@@ -46,8 +47,7 @@ export async function deleteDocument(ctx, params) {
46
47
  // AND a storage provider, fetch with reconstruct: true so we
47
48
  // can read the stored file paths (and persisted variant paths)
48
49
  // from the field values before the DB rows are deleted.
49
- const uploadFieldNames = getUploadFields(definition).map((f) => f.name);
50
- const isUploadCollection = uploadFieldNames.length > 0 && ctx.storage != null;
50
+ const isUploadCollection = hasUploadField(definition) && ctx.storage != null;
51
51
  const latest = await db.queries.documents.getDocumentById({
52
52
  collection_id: ctx.collectionId,
53
53
  document_id: params.documentId,
@@ -60,21 +60,27 @@ export async function deleteDocument(ctx, params) {
60
60
  }).log(ctx.logger);
61
61
  }
62
62
  // Collect storage paths for every upload-capable field on the doc:
63
- // the original file plus every persisted variant. Reading the
63
+ // the original file plus every persisted variant. The schema/data
64
+ // walk descends into `group` / `array` / `blocks`, so upload fields
65
+ // nested in repeating structures are cleaned up too. Reading the
64
66
  // variants from the field value (rather than re-deriving from
65
67
  // `upload.sizes`) keeps cleanup correct even when the size set
66
68
  // changes between upload and delete.
67
69
  const storagePathsToDelete = [];
68
70
  if (isUploadCollection) {
69
- for (const fieldName of uploadFieldNames) {
70
- const fieldValue = latest?.fields?.[fieldName];
71
+ const data = latest?.fields;
72
+ for (const leaf of walkFieldTree(definition.fields, data)) {
73
+ if (!isUploadField(leaf.field))
74
+ continue;
75
+ const fieldValue = leaf.value;
71
76
  if (!fieldValue || typeof fieldValue !== 'object')
72
77
  continue;
73
- if (typeof fieldValue.storagePath === 'string') {
74
- storagePathsToDelete.push(fieldValue.storagePath);
78
+ const stored = fieldValue;
79
+ if (typeof stored.storagePath === 'string') {
80
+ storagePathsToDelete.push(stored.storagePath);
75
81
  }
76
- if (Array.isArray(fieldValue.variants)) {
77
- for (const variant of fieldValue.variants) {
82
+ if (Array.isArray(stored.variants)) {
83
+ for (const variant of stored.variants) {
78
84
  if (variant && typeof variant.storagePath === 'string') {
79
85
  storagePathsToDelete.push(variant.storagePath);
80
86
  }
@@ -926,6 +926,58 @@ describe('Document lifecycle service', () => {
926
926
  action: 'document.deleted',
927
927
  }));
928
928
  });
929
+ it('cleans up stored files for upload fields at any nesting depth', async () => {
930
+ const { db, getDocumentById } = createMockDb();
931
+ const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
932
+ const definition = {
933
+ ...minimalCollection,
934
+ fields: [
935
+ { name: 'cover', label: 'Cover', type: 'image', upload },
936
+ {
937
+ name: 'files',
938
+ label: 'Files',
939
+ type: 'array',
940
+ fields: [
941
+ {
942
+ name: 'filesGroup',
943
+ type: 'group',
944
+ fields: [{ name: 'publicationFile', label: 'File', type: 'file', upload }],
945
+ },
946
+ ],
947
+ },
948
+ ],
949
+ };
950
+ getDocumentById.mockResolvedValue({
951
+ document_version_id: 'ver-1',
952
+ document_id: 'doc-1',
953
+ path: 'doc-to-delete',
954
+ fields: {
955
+ cover: {
956
+ storagePath: 'covers/original.jpg',
957
+ variants: [{ storagePath: 'covers/thumb.avif' }],
958
+ },
959
+ files: [
960
+ { filesGroup: { publicationFile: { storagePath: 'files/a.pdf' } } },
961
+ { filesGroup: { publicationFile: null } },
962
+ { filesGroup: { publicationFile: { storagePath: 'files/b.pdf' } } },
963
+ ],
964
+ },
965
+ });
966
+ const storageDelete = vi.fn().mockResolvedValue(undefined);
967
+ const ctx = {
968
+ ...buildCtx(db, definition),
969
+ storage: { delete: storageDelete },
970
+ };
971
+ await deleteDocument(ctx, { documentId: 'doc-1' });
972
+ // reconstruct: true because the collection is upload-capable
973
+ expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: true }));
974
+ expect(storageDelete.mock.calls.map((c) => c[0])).toEqual([
975
+ 'covers/original.jpg',
976
+ 'covers/thumb.avif',
977
+ 'files/a.pdf',
978
+ 'files/b.pdf',
979
+ ]);
980
+ });
929
981
  });
930
982
  // -----------------------------------------------------------------------
931
983
  // updateDocumentSystemFields (audited, non-versioned)
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { type Field, type FileField, type ImageField } from '../@types/field-types.js';
8
9
  import type { CollectionDefinition } from '../@types/collection-types.js';
9
- import type { Field, FileField, ImageField } from '../@types/field-types.js';
10
10
  /**
11
11
  * Predicate: does this field carry an `upload` config? True for any
12
12
  * `image` / `file` field with an `upload` block declared.
@@ -15,20 +15,21 @@ export declare function isUploadField(field: Field): field is (ImageField | File
15
15
  upload: NonNullable<(ImageField | FileField)['upload']>;
16
16
  };
17
17
  /**
18
- * Walk the top-level field set and return every upload-capable
19
- * image/file field on the collection. Used by the delete path,
20
- * the upload-route resolver, and any UI that needs to reason about
21
- * "is this collection upload-capable, and which fields take uploads?"
18
+ * Walk the field set and return every upload-capable image/file field
19
+ * on the collection, recursing into `group` / `array` / `blocks`
20
+ * structure fields. Used by the delete path, the upload-route resolver,
21
+ * and any UI that needs to reason about "is this collection
22
+ * upload-capable, and which fields take uploads?"
22
23
  *
23
- * Does not recurse into `group` / `array` / `blocks` the supported
24
- * transport surface is top-level upload fields. Nested upload fields
25
- * are reachable through the core upload service via `findUploadField`,
26
- * but require a richer transport selector.
24
+ * Field names are the upload transport's selector, so a schema should
25
+ * not declare two upload fields with the same name in different nesting
26
+ * scopes resolvers match by name and take the first hit in
27
+ * declaration order.
27
28
  */
28
29
  export declare function getUploadFields(definition: Pick<CollectionDefinition, 'fields'>): (ImageField | FileField)[];
29
30
  /**
30
31
  * Convenience: does this collection have at least one upload-capable
31
- * image/file field at the top level? Replaces the old
32
+ * image/file field at any nesting depth? Replaces the old
32
33
  * `definition.upload != null` discriminator that "this collection is
33
34
  * an upload collection / media library."
34
35
  */
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { isArrayField, isBlocksField, isGroupField, } from '../@types/field-types.js';
8
9
  /**
9
10
  * Predicate: does this field carry an `upload` config? True for any
10
11
  * `image` / `file` field with an `upload` block declared.
@@ -13,25 +14,43 @@ export function isUploadField(field) {
13
14
  return (field.type === 'image' || field.type === 'file') && field.upload != null;
14
15
  }
15
16
  /**
16
- * Walk the top-level field set and return every upload-capable
17
- * image/file field on the collection. Used by the delete path,
18
- * the upload-route resolver, and any UI that needs to reason about
19
- * "is this collection upload-capable, and which fields take uploads?"
17
+ * Walk the field set and return every upload-capable image/file field
18
+ * on the collection, recursing into `group` / `array` / `blocks`
19
+ * structure fields. Used by the delete path, the upload-route resolver,
20
+ * and any UI that needs to reason about "is this collection
21
+ * upload-capable, and which fields take uploads?"
20
22
  *
21
- * Does not recurse into `group` / `array` / `blocks` the supported
22
- * transport surface is top-level upload fields. Nested upload fields
23
- * are reachable through the core upload service via `findUploadField`,
24
- * but require a richer transport selector.
23
+ * Field names are the upload transport's selector, so a schema should
24
+ * not declare two upload fields with the same name in different nesting
25
+ * scopes resolvers match by name and take the first hit in
26
+ * declaration order.
25
27
  */
26
28
  export function getUploadFields(definition) {
27
- return definition.fields.filter(isUploadField);
29
+ const found = [];
30
+ collectUploadFields(definition.fields, found);
31
+ return found;
32
+ }
33
+ function collectUploadFields(fields, found) {
34
+ for (const field of fields) {
35
+ if (isUploadField(field)) {
36
+ found.push(field);
37
+ }
38
+ else if (isGroupField(field) || isArrayField(field)) {
39
+ collectUploadFields(field.fields, found);
40
+ }
41
+ else if (isBlocksField(field)) {
42
+ for (const block of field.blocks) {
43
+ collectUploadFields(block.fields, found);
44
+ }
45
+ }
46
+ }
28
47
  }
29
48
  /**
30
49
  * Convenience: does this collection have at least one upload-capable
31
- * image/file field at the top level? Replaces the old
50
+ * image/file field at any nesting depth? Replaces the old
32
51
  * `definition.upload != null` discriminator that "this collection is
33
52
  * an upload collection / media library."
34
53
  */
35
54
  export function hasUploadField(definition) {
36
- return definition.fields.some(isUploadField);
55
+ return getUploadFields(definition).length > 0;
37
56
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,90 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { getUploadFields, hasUploadField, isUploadField } from './storage-utils.js';
10
+ const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
11
+ const coverField = { name: 'cover', label: 'Cover', type: 'image', upload };
12
+ const nestedFileField = { name: 'publicationFile', label: 'File', type: 'file', upload };
13
+ describe('isUploadField', () => {
14
+ it('is true for image/file fields with an upload block', () => {
15
+ expect(isUploadField(coverField)).toBe(true);
16
+ expect(isUploadField(nestedFileField)).toBe(true);
17
+ });
18
+ it('is false for image/file fields without an upload block', () => {
19
+ expect(isUploadField({ name: 'pic', label: 'Pic', type: 'image' })).toBe(false);
20
+ expect(isUploadField({ name: 'doc', label: 'Doc', type: 'file' })).toBe(false);
21
+ });
22
+ it('is false for non-media fields', () => {
23
+ expect(isUploadField({ name: 'title', label: 'Title', type: 'text' })).toBe(false);
24
+ });
25
+ });
26
+ describe('getUploadFields', () => {
27
+ it('returns top-level upload fields', () => {
28
+ const fields = [{ name: 'title', label: 'Title', type: 'text' }, coverField];
29
+ expect(getUploadFields({ fields }).map((f) => f.name)).toEqual(['cover']);
30
+ });
31
+ it('recurses into group / array / blocks structure fields', () => {
32
+ const fields = [
33
+ coverField,
34
+ {
35
+ name: 'files',
36
+ label: 'Files',
37
+ type: 'array',
38
+ fields: [
39
+ {
40
+ name: 'filesGroup',
41
+ type: 'group',
42
+ fields: [nestedFileField, { name: 'label', label: 'Label', type: 'text' }],
43
+ },
44
+ ],
45
+ },
46
+ {
47
+ name: 'content',
48
+ label: 'Content',
49
+ type: 'blocks',
50
+ blocks: [
51
+ {
52
+ blockType: 'photo',
53
+ label: 'Photo',
54
+ fields: [{ name: 'photo', label: 'Photo', type: 'image', upload }],
55
+ },
56
+ ],
57
+ },
58
+ ];
59
+ expect(getUploadFields({ fields }).map((f) => f.name)).toEqual([
60
+ 'cover',
61
+ 'publicationFile',
62
+ 'photo',
63
+ ]);
64
+ });
65
+ it('skips nested image/file fields without an upload block', () => {
66
+ const fields = [
67
+ {
68
+ name: 'gallery',
69
+ label: 'Gallery',
70
+ type: 'group',
71
+ fields: [{ name: 'pic', label: 'Pic', type: 'image' }],
72
+ },
73
+ ];
74
+ expect(getUploadFields({ fields })).toEqual([]);
75
+ });
76
+ });
77
+ describe('hasUploadField', () => {
78
+ it('detects upload fields at any nesting depth', () => {
79
+ const fields = [
80
+ {
81
+ name: 'files',
82
+ label: 'Files',
83
+ type: 'array',
84
+ fields: [{ name: 'filesGroup', type: 'group', fields: [nestedFileField] }],
85
+ },
86
+ ];
87
+ expect(hasUploadField({ fields })).toBe(true);
88
+ expect(hasUploadField({ fields: [{ name: 'title', label: 'Title', type: 'text' }] })).toBe(false);
89
+ });
90
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.17.0",
5
+ "version": "3.17.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -76,7 +76,7 @@
76
76
  "pino": "^10.3.1",
77
77
  "sharp": "^0.34.5",
78
78
  "zod": "^4.4.3",
79
- "@byline/auth": "3.17.0"
79
+ "@byline/auth": "3.17.1"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@biomejs/biome": "2.4.15",