@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.
- package/dist/services/document-lifecycle/delete.js +16 -10
- package/dist/services/document-lifecycle.test.node.js +52 -0
- package/dist/utils/storage-utils.d.ts +11 -10
- package/dist/utils/storage-utils.js +30 -11
- package/dist/utils/storage-utils.test.node.d.ts +8 -0
- package/dist/utils/storage-utils.test.node.js +90 -0
- package/package.json +2 -2
|
@@ -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 {
|
|
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
|
|
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.
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
78
|
+
const stored = fieldValue;
|
|
79
|
+
if (typeof stored.storagePath === 'string') {
|
|
80
|
+
storagePathsToDelete.push(stored.storagePath);
|
|
75
81
|
}
|
|
76
|
-
if (Array.isArray(
|
|
77
|
-
for (const variant of
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
79
|
+
"@byline/auth": "3.17.1"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@biomejs/biome": "2.4.15",
|