@byline/core 4.16.0 → 4.17.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/dist/@types/admin-types.d.ts +86 -31
- package/dist/@types/admin-types.js +12 -0
- package/dist/@types/collection-types.d.ts +211 -29
- package/dist/@types/collection-types.js +68 -8
- package/dist/@types/db-types.d.ts +50 -0
- package/dist/@types/form-admin-config.test.node.d.ts +6 -0
- package/dist/@types/form-admin-config.test.node.js +26 -0
- package/dist/@types/singleton-admin-config.test.node.js +66 -0
- package/dist/@types/singleton-definition.test.node.d.ts +8 -0
- package/dist/@types/singleton-definition.test.node.js +75 -0
- package/dist/@types/site-config.d.ts +9 -8
- package/dist/auth/apply-before-read.d.ts +2 -1
- package/dist/auth/apply-before-read.js +13 -3
- package/dist/auth/apply-before-read.test.node.js +44 -0
- package/dist/auth/assert-actor-can-perform.d.ts +4 -3
- package/dist/auth/assert-actor-can-perform.js +9 -7
- package/dist/auth/assert-actor-can-perform.test.node.js +32 -11
- package/dist/auth/filter-readable-collections.d.ts +6 -6
- package/dist/auth/filter-readable-collections.js +8 -8
- package/dist/auth/filter-readable-collections.test.node.js +26 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/register-collection-abilities.d.ts +34 -30
- package/dist/auth/register-collection-abilities.js +81 -77
- package/dist/auth/register-collection-abilities.test.node.js +86 -8
- package/dist/codegen/fixtures/all-fields.expected.d.ts +3 -0
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/index.js +32 -16
- package/dist/codegen/index.test.node.js +35 -2
- package/dist/config/attach-hooks.d.ts +3 -3
- package/dist/config/attach-hooks.js +8 -0
- package/dist/config/attach-hooks.test.node.js +59 -0
- package/dist/config/config-hooks.test.node.js +34 -1
- package/dist/config/config.d.ts +2 -1
- package/dist/config/config.js +9 -1
- package/dist/config/group-collections.d.ts +10 -9
- package/dist/config/group-collections.js +4 -4
- package/dist/config/group-collections.test.node.js +14 -0
- package/dist/config/validate-admin-configs.d.ts +4 -4
- package/dist/config/validate-admin-configs.js +40 -7
- package/dist/config/validate-admin-configs.test.node.js +44 -0
- package/dist/config/validate-collections.js +43 -0
- package/dist/config/validate-collections.test.node.js +75 -0
- package/dist/core.d.ts +2 -3
- package/dist/core.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/lib/errors.d.ts +5 -3
- package/dist/lib/errors.js +1 -0
- package/dist/services/collection-bootstrap.js +12 -0
- package/dist/services/collection-bootstrap.test.node.js +63 -6
- package/dist/services/discover-counter-groups.test.node.js +6 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +9 -17
- package/dist/services/document-lifecycle/create.js +6 -11
- package/dist/services/document-lifecycle/delete-locale.js +1 -1
- package/dist/services/document-lifecycle/delete.js +1 -1
- package/dist/services/document-lifecycle/duplicate.js +7 -18
- package/dist/services/document-lifecycle/persistence.d.ts +29 -0
- package/dist/services/document-lifecycle/persistence.js +42 -0
- package/dist/services/document-lifecycle/restore.js +9 -17
- package/dist/services/document-lifecycle/scheduled-publish.js +5 -5
- package/dist/services/document-lifecycle/status.js +3 -3
- package/dist/services/document-lifecycle/system-fields.js +1 -1
- package/dist/services/document-lifecycle/tree.js +2 -2
- package/dist/services/document-lifecycle/tree.test.node.js +6 -1
- package/dist/services/document-lifecycle/update.js +20 -36
- package/dist/services/document-lifecycle.test.node.js +35 -0
- package/dist/services/field-upload.d.ts +1 -1
- package/dist/services/field-upload.js +20 -6
- package/dist/services/field-upload.test.node.js +125 -9
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/populate.js +1 -1
- package/dist/services/populate.test.node.js +9 -0
- package/dist/services/richtext-populate.js +1 -1
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +16 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +100 -0
- package/dist/services/singleton-lifecycle/index.d.ts +14 -0
- package/dist/services/singleton-lifecycle/index.js +11 -0
- package/dist/services/singleton-lifecycle/internals.d.ts +49 -0
- package/dist/services/singleton-lifecycle/internals.js +118 -0
- package/dist/services/singleton-lifecycle/restore.d.ts +13 -0
- package/dist/services/singleton-lifecycle/restore.js +93 -0
- package/dist/services/singleton-lifecycle/update.d.ts +16 -0
- package/dist/services/singleton-lifecycle/update.js +107 -0
- package/dist/services/singleton-lifecycle.test.node.d.ts +8 -0
- package/dist/services/singleton-lifecycle.test.node.js +396 -0
- package/dist/storage/collection-fingerprint.js +18 -6
- package/dist/storage/collection-fingerprint.test.node.js +15 -0
- package/package.json +5 -5
- package/dist/config/resolve-item-view-columns.test.node.js +0 -30
- /package/dist/{config/resolve-item-view-columns.test.node.d.ts → @types/singleton-admin-config.test.node.d.ts} +0 -0
|
@@ -14,8 +14,8 @@ import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
|
14
14
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
16
|
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
|
|
18
|
+
import { persistExistingDocumentVersion } from './persistence.js';
|
|
19
19
|
import { selfHealTreePlacement } from './tree.js';
|
|
20
20
|
/**
|
|
21
21
|
* Update a document via full replacement (PUT semantics).
|
|
@@ -33,7 +33,7 @@ import { selfHealTreePlacement } from './tree.js';
|
|
|
33
33
|
export async function updateDocument(ctx, params) {
|
|
34
34
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocument' }, async () => {
|
|
35
35
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
36
|
-
assertActorCanPerform(ctx.requestContext,
|
|
36
|
+
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
37
37
|
const hooks = await resolveHooks(definition);
|
|
38
38
|
const data = params.data;
|
|
39
39
|
// Fetch the real original so hooks get accurate originalData (fixes the
|
|
@@ -77,23 +77,15 @@ export async function updateDocument(ctx, params) {
|
|
|
77
77
|
logger: ctx.logger,
|
|
78
78
|
});
|
|
79
79
|
await applyRichTextEmbed(ctx, data);
|
|
80
|
-
const result = await
|
|
81
|
-
ctx,
|
|
80
|
+
const result = await persistExistingDocumentVersion(ctx, {
|
|
82
81
|
documentId: params.documentId,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
path: pathForCommand,
|
|
91
|
-
availableLocales: params.availableLocales,
|
|
92
|
-
status: defaultStatus,
|
|
93
|
-
locale: requestLocale,
|
|
94
|
-
previousVersionId: originalData.document_version_id,
|
|
95
|
-
createdBy: actorId(ctx),
|
|
96
|
-
}),
|
|
82
|
+
action: 'update',
|
|
83
|
+
documentData: data,
|
|
84
|
+
path: pathForCommand,
|
|
85
|
+
availableLocales: params.availableLocales,
|
|
86
|
+
status: defaultStatus,
|
|
87
|
+
locale: requestLocale,
|
|
88
|
+
previousVersionId: originalData.document_version_id,
|
|
97
89
|
}).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
|
|
98
90
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
99
91
|
const documentVersionId = extractVersionId(result.document);
|
|
@@ -129,7 +121,7 @@ export async function updateDocument(ctx, params) {
|
|
|
129
121
|
export async function updateDocumentWithPatches(ctx, params) {
|
|
130
122
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentWithPatches' }, async () => {
|
|
131
123
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
132
|
-
assertActorCanPerform(ctx.requestContext,
|
|
124
|
+
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
133
125
|
const hooks = await resolveHooks(definition);
|
|
134
126
|
// 1. Fetch current document.
|
|
135
127
|
const latest = await db.queries.documents.getDocumentById({
|
|
@@ -198,23 +190,15 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
198
190
|
logger: ctx.logger,
|
|
199
191
|
});
|
|
200
192
|
await applyRichTextEmbed(ctx, nextData);
|
|
201
|
-
const result = await
|
|
202
|
-
ctx,
|
|
193
|
+
const result = await persistExistingDocumentVersion(ctx, {
|
|
203
194
|
documentId: params.documentId,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
path: pathForCommand,
|
|
212
|
-
availableLocales: params.availableLocales,
|
|
213
|
-
status: defaultStatus,
|
|
214
|
-
locale: requestLocale,
|
|
215
|
-
previousVersionId: originalData.document_version_id,
|
|
216
|
-
createdBy: actorId(ctx),
|
|
217
|
-
}),
|
|
195
|
+
action: 'update',
|
|
196
|
+
documentData: nextData,
|
|
197
|
+
path: pathForCommand,
|
|
198
|
+
availableLocales: params.availableLocales,
|
|
199
|
+
status: defaultStatus,
|
|
200
|
+
locale: requestLocale,
|
|
201
|
+
previousVersionId: originalData.document_version_id,
|
|
218
202
|
}).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
|
|
219
203
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
220
204
|
const documentVersionId = extractVersionId(result.document);
|
|
@@ -86,6 +86,9 @@ function createMockDb() {
|
|
|
86
86
|
// records the calls so write-point tests can assert the audit rows emitted.
|
|
87
87
|
const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
|
|
88
88
|
const withTransaction = vi.fn(async (fn) => fn());
|
|
89
|
+
const fail = () => {
|
|
90
|
+
throw new Error('unexpected singleton mapping call');
|
|
91
|
+
};
|
|
89
92
|
// Fake classifier mirroring db-postgres's real one closely enough for
|
|
90
93
|
// rethrowPathConflict's consumption of the seam: a pg-shaped 23505 with a
|
|
91
94
|
// constraint name classifies as a unique violation; anything else is
|
|
@@ -146,6 +149,11 @@ function createMockDb() {
|
|
|
146
149
|
nextScopedCounterValue: vi.fn(),
|
|
147
150
|
},
|
|
148
151
|
audit: { append: auditAppend },
|
|
152
|
+
singletons: {
|
|
153
|
+
lockSlot: vi.fn(fail),
|
|
154
|
+
setMapping: vi.fn(fail),
|
|
155
|
+
clearMapping: vi.fn(fail),
|
|
156
|
+
},
|
|
149
157
|
},
|
|
150
158
|
withTransaction: withTransaction,
|
|
151
159
|
queries: {
|
|
@@ -190,6 +198,7 @@ function createMockDb() {
|
|
|
190
198
|
meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
|
|
191
199
|
})),
|
|
192
200
|
},
|
|
201
|
+
singletons: { getMappedDocumentId: vi.fn(fail) },
|
|
193
202
|
},
|
|
194
203
|
};
|
|
195
204
|
return {
|
|
@@ -366,6 +375,32 @@ describe('Document lifecycle service', () => {
|
|
|
366
375
|
price: '12.5',
|
|
367
376
|
});
|
|
368
377
|
});
|
|
378
|
+
it('allocates counters after beforeCreate and before persistence', async () => {
|
|
379
|
+
const callOrder = [];
|
|
380
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
381
|
+
const nextCounterValue = db.commands.counters.nextCounterValue;
|
|
382
|
+
nextCounterValue.mockImplementation(async () => {
|
|
383
|
+
callOrder.push('counter');
|
|
384
|
+
return 42;
|
|
385
|
+
});
|
|
386
|
+
createDocumentVersion.mockImplementation(async () => {
|
|
387
|
+
callOrder.push('persist');
|
|
388
|
+
return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
|
|
389
|
+
});
|
|
390
|
+
const definition = {
|
|
391
|
+
...minimalCollection,
|
|
392
|
+
fields: [{ name: 'serial', type: 'counter', group: 'article-serials' }],
|
|
393
|
+
hooks: {
|
|
394
|
+
beforeCreate: vi.fn(({ data }) => {
|
|
395
|
+
callOrder.push('before');
|
|
396
|
+
data.serial = 999;
|
|
397
|
+
}),
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
await createDocument(buildCtx(db, definition), { data: {} });
|
|
401
|
+
expect(callOrder).toEqual(['before', 'counter', 'persist']);
|
|
402
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].documentData.serial).toBe(42);
|
|
403
|
+
});
|
|
369
404
|
it('derives path from useAsPath source field via the slugifier', async () => {
|
|
370
405
|
const { db, createDocumentVersion } = createMockDb();
|
|
371
406
|
const definition = { ...minimalCollection, useAsPath: 'title' };
|
|
@@ -77,7 +77,7 @@ export interface FieldUploadContext {
|
|
|
77
77
|
/**
|
|
78
78
|
* Request-scoped auth context. Forwarded to the internal
|
|
79
79
|
* `DocumentLifecycleContext` when an upload creates a document, and
|
|
80
|
-
* consulted directly at the upload entry for the
|
|
80
|
+
* consulted directly at the upload entry for the kind-aware write ability
|
|
81
81
|
* check. Required at the field-level upload boundary so `beforeStore` /
|
|
82
82
|
* `afterStore` hooks can branch on `actor`.
|
|
83
83
|
*/
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import { resolveUploadHooks } from '../@types/index.js';
|
|
8
|
+
import { isSingleton, resolveUploadHooks } from '../@types/index.js';
|
|
9
9
|
import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
|
|
10
10
|
import { ERR_DATABASE, ERR_STORAGE, ERR_VALIDATION } from '../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../lib/logger.js';
|
|
@@ -123,10 +123,25 @@ function posixBasename(storagePath) {
|
|
|
123
123
|
export async function uploadField(ctx, params) {
|
|
124
124
|
return withLogContext({ domain: 'services', module: 'upload', function: 'uploadField' }, async () => {
|
|
125
125
|
const { definition, collectionPath, storage, db, collectionId, logger, imageProcessor, fieldName, } = ctx;
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
126
|
+
const { buffer, originalFilename, mimeType, fileSize, fields = {}, shouldCreateDocument = true, locale, } = params;
|
|
127
|
+
// Upload is a write even when no document is created. Preserve the
|
|
128
|
+
// anti-abuse auth gate while selecting the verb from the resource kind.
|
|
129
|
+
// A singleton's document cardinality belongs exclusively to
|
|
130
|
+
// updateSingleton, so reject the collection-only create branch before
|
|
131
|
+
// resolving hooks or writing any bytes.
|
|
132
|
+
if (isSingleton(definition)) {
|
|
133
|
+
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
134
|
+
if (shouldCreateDocument) {
|
|
135
|
+
throw ERR_VALIDATION({
|
|
136
|
+
message: `Cannot create a document while uploading to singleton '${collectionPath}'. ` +
|
|
137
|
+
'Upload the field only and persist it through updateSingleton().',
|
|
138
|
+
details: { collectionPath, fieldName },
|
|
139
|
+
}, uploadField).log(logger);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
assertActorCanPerform(ctx.requestContext, definition, 'create');
|
|
144
|
+
}
|
|
130
145
|
const field = findUploadField(definition.fields, fieldName);
|
|
131
146
|
if (!field) {
|
|
132
147
|
throw ERR_VALIDATION({
|
|
@@ -143,7 +158,6 @@ export async function uploadField(ctx, params) {
|
|
|
143
158
|
details: { collectionPath, fieldName },
|
|
144
159
|
}, uploadField).log(logger);
|
|
145
160
|
}
|
|
146
|
-
const { buffer, originalFilename, mimeType, fileSize, fields = {}, shouldCreateDocument = true, locale, } = params;
|
|
147
161
|
// -- Validation runs FIRST. Hooks never see a file that's about to
|
|
148
162
|
// be rejected.
|
|
149
163
|
if (upload.mimeTypes && upload.mimeTypes.length > 0) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import { createSuperAdminContext } from '@byline/auth';
|
|
8
|
+
import { AdminAuth, createRequestContext, createSuperAdminContext } from '@byline/auth';
|
|
9
9
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { commitHookAttachment, prepareHookAttachment } from '../config/attach-hooks.js';
|
|
11
11
|
import { BylineError, ErrorCodes } from '../lib/errors.js';
|
|
@@ -28,6 +28,12 @@ const uploadCollection = {
|
|
|
28
28
|
{ name: 'caption', label: 'Caption', type: 'textArea', optional: true },
|
|
29
29
|
],
|
|
30
30
|
};
|
|
31
|
+
const uploadSingleton = {
|
|
32
|
+
path: 'site-settings',
|
|
33
|
+
label: 'Site settings',
|
|
34
|
+
singleton: true,
|
|
35
|
+
fields: structuredClone(uploadCollection.fields),
|
|
36
|
+
};
|
|
31
37
|
function withFieldUpload(collection, fieldName, patch) {
|
|
32
38
|
return {
|
|
33
39
|
...collection,
|
|
@@ -41,6 +47,9 @@ function withFieldUpload(collection, fieldName, patch) {
|
|
|
41
47
|
};
|
|
42
48
|
}
|
|
43
49
|
function createMockDb() {
|
|
50
|
+
const fail = () => {
|
|
51
|
+
throw new Error('unexpected singleton mapping call');
|
|
52
|
+
};
|
|
44
53
|
const createDocumentVersion = vi.fn().mockResolvedValue({
|
|
45
54
|
document: { id: 'ver-1', document_id: 'doc-1' },
|
|
46
55
|
fieldCount: 2,
|
|
@@ -82,6 +91,11 @@ function createMockDb() {
|
|
|
82
91
|
nextScopedCounterValue: vi.fn(),
|
|
83
92
|
},
|
|
84
93
|
audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
|
|
94
|
+
singletons: {
|
|
95
|
+
lockSlot: vi.fn(fail),
|
|
96
|
+
setMapping: vi.fn(fail),
|
|
97
|
+
clearMapping: vi.fn(fail),
|
|
98
|
+
},
|
|
85
99
|
},
|
|
86
100
|
queries: {
|
|
87
101
|
collections: {
|
|
@@ -122,25 +136,32 @@ function createMockDb() {
|
|
|
122
136
|
meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
|
|
123
137
|
})),
|
|
124
138
|
},
|
|
139
|
+
singletons: { getMappedDocumentId: vi.fn(fail) },
|
|
125
140
|
},
|
|
126
141
|
withTransaction: async (fn) => fn(),
|
|
127
142
|
};
|
|
128
143
|
return { db, createDocumentVersion };
|
|
129
144
|
}
|
|
130
145
|
function createMockStorage() {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
146
|
+
const storedPaths = new Set();
|
|
147
|
+
const upload = vi.fn().mockImplementation(async () => {
|
|
148
|
+
storedPaths.add('media/original.png');
|
|
149
|
+
return {
|
|
150
|
+
storageProvider: 'local',
|
|
151
|
+
storagePath: 'media/original.png',
|
|
152
|
+
storageUrl: '/uploads/media/original.png',
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
const del = vi.fn().mockImplementation(async (storagePath) => {
|
|
156
|
+
storedPaths.delete(storagePath);
|
|
135
157
|
});
|
|
136
|
-
const del = vi.fn().mockResolvedValue(undefined);
|
|
137
158
|
const storage = {
|
|
138
159
|
providerName: 'local',
|
|
139
160
|
upload,
|
|
140
161
|
delete: del,
|
|
141
162
|
getUrl: vi.fn((storagePath) => `/uploads/${storagePath}`),
|
|
142
163
|
};
|
|
143
|
-
return { storage, upload, del };
|
|
164
|
+
return { storage, upload, del, storedPaths };
|
|
144
165
|
}
|
|
145
166
|
const noopLogger = {
|
|
146
167
|
log: vi.fn(),
|
|
@@ -170,7 +191,7 @@ function createImageProcessor(overrides) {
|
|
|
170
191
|
}
|
|
171
192
|
function buildCtx(overrides) {
|
|
172
193
|
const { db, createDocumentVersion } = createMockDb();
|
|
173
|
-
const { storage, upload, del } = createMockStorage();
|
|
194
|
+
const { storage, upload, del, storedPaths } = createMockStorage();
|
|
174
195
|
const imageProcessor = createImageProcessor();
|
|
175
196
|
const ctx = {
|
|
176
197
|
db,
|
|
@@ -186,9 +207,104 @@ function buildCtx(overrides) {
|
|
|
186
207
|
requestContext: createSuperAdminContext({ id: 'test-super-admin' }),
|
|
187
208
|
...overrides,
|
|
188
209
|
};
|
|
189
|
-
return { ctx, createDocumentVersion, upload, del, imageProcessor };
|
|
210
|
+
return { ctx, createDocumentVersion, upload, del, storedPaths, imageProcessor };
|
|
190
211
|
}
|
|
191
212
|
describe('uploadField service', () => {
|
|
213
|
+
it('authorizes a singleton field-only upload with its update ability', async () => {
|
|
214
|
+
const { ctx, upload } = buildCtx({
|
|
215
|
+
definition: uploadSingleton,
|
|
216
|
+
collectionPath: uploadSingleton.path,
|
|
217
|
+
requestContext: createRequestContext({
|
|
218
|
+
actor: new AdminAuth({
|
|
219
|
+
id: 'settings-editor',
|
|
220
|
+
abilities: ['singletons.site-settings.update'],
|
|
221
|
+
}),
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
await expect(uploadField(ctx, {
|
|
225
|
+
buffer: Buffer.from('png'),
|
|
226
|
+
originalFilename: 'hero.png',
|
|
227
|
+
mimeType: 'image/png',
|
|
228
|
+
fileSize: 3,
|
|
229
|
+
shouldCreateDocument: false,
|
|
230
|
+
})).resolves.toMatchObject({ storedFile: { filename: 'hero.png' } });
|
|
231
|
+
expect(upload).toHaveBeenCalledOnce();
|
|
232
|
+
});
|
|
233
|
+
it('rejects an anonymous singleton upload before writing bytes', async () => {
|
|
234
|
+
const { ctx, upload, storedPaths } = buildCtx({
|
|
235
|
+
definition: uploadSingleton,
|
|
236
|
+
collectionPath: uploadSingleton.path,
|
|
237
|
+
requestContext: createRequestContext({ actor: null, readMode: 'published' }),
|
|
238
|
+
});
|
|
239
|
+
await expect(uploadField(ctx, {
|
|
240
|
+
buffer: Buffer.from('png'),
|
|
241
|
+
originalFilename: 'hero.png',
|
|
242
|
+
mimeType: 'image/png',
|
|
243
|
+
fileSize: 3,
|
|
244
|
+
shouldCreateDocument: false,
|
|
245
|
+
})).rejects.toMatchObject({ code: 'ERR_UNAUTHENTICATED' });
|
|
246
|
+
expect(upload).not.toHaveBeenCalled();
|
|
247
|
+
expect(storedPaths.size).toBe(0);
|
|
248
|
+
});
|
|
249
|
+
it('rejects singleton document creation before hooks, storage, or variants', async () => {
|
|
250
|
+
const beforeStore = vi.fn();
|
|
251
|
+
const definition = withFieldUpload(uploadSingleton, 'image', (field) => {
|
|
252
|
+
field.upload.hooks = { beforeStore };
|
|
253
|
+
});
|
|
254
|
+
const { ctx, upload, storedPaths, imageProcessor } = buildCtx({
|
|
255
|
+
definition,
|
|
256
|
+
collectionPath: definition.path,
|
|
257
|
+
requestContext: createRequestContext({
|
|
258
|
+
actor: new AdminAuth({
|
|
259
|
+
id: 'settings-editor',
|
|
260
|
+
abilities: ['singletons.site-settings.update'],
|
|
261
|
+
}),
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
await expect(uploadField(ctx, {
|
|
265
|
+
buffer: Buffer.from('png'),
|
|
266
|
+
originalFilename: 'hero.png',
|
|
267
|
+
mimeType: 'image/png',
|
|
268
|
+
fileSize: 3,
|
|
269
|
+
shouldCreateDocument: true,
|
|
270
|
+
})).rejects.toMatchObject({
|
|
271
|
+
code: ErrorCodes.VALIDATION,
|
|
272
|
+
message: expect.stringMatching(/singleton 'site-settings'.*updateSingleton/),
|
|
273
|
+
});
|
|
274
|
+
expect(beforeStore).not.toHaveBeenCalled();
|
|
275
|
+
expect(upload).not.toHaveBeenCalled();
|
|
276
|
+
expect(imageProcessor.extractMeta).not.toHaveBeenCalled();
|
|
277
|
+
expect(imageProcessor.generateVariants).not.toHaveBeenCalled();
|
|
278
|
+
expect(storedPaths.size).toBe(0);
|
|
279
|
+
});
|
|
280
|
+
it('keeps collection uploads gated on the create ability', async () => {
|
|
281
|
+
const denied = buildCtx({
|
|
282
|
+
requestContext: createRequestContext({
|
|
283
|
+
actor: new AdminAuth({ id: 'media-editor', abilities: ['collections.media.update'] }),
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
await expect(uploadField(denied.ctx, {
|
|
287
|
+
buffer: Buffer.from('png'),
|
|
288
|
+
originalFilename: 'hero.png',
|
|
289
|
+
mimeType: 'image/png',
|
|
290
|
+
fileSize: 3,
|
|
291
|
+
shouldCreateDocument: false,
|
|
292
|
+
})).rejects.toMatchObject({ code: 'ERR_FORBIDDEN' });
|
|
293
|
+
expect(denied.upload).not.toHaveBeenCalled();
|
|
294
|
+
const allowed = buildCtx({
|
|
295
|
+
requestContext: createRequestContext({
|
|
296
|
+
actor: new AdminAuth({ id: 'media-creator', abilities: ['collections.media.create'] }),
|
|
297
|
+
}),
|
|
298
|
+
});
|
|
299
|
+
await expect(uploadField(allowed.ctx, {
|
|
300
|
+
buffer: Buffer.from('png'),
|
|
301
|
+
originalFilename: 'hero.png',
|
|
302
|
+
mimeType: 'image/png',
|
|
303
|
+
fileSize: 3,
|
|
304
|
+
shouldCreateDocument: false,
|
|
305
|
+
})).resolves.toBeDefined();
|
|
306
|
+
expect(allowed.upload).toHaveBeenCalledOnce();
|
|
307
|
+
});
|
|
192
308
|
it('fires registry hooks for a nested array instance selected by its unique upload leaf', async () => {
|
|
193
309
|
const beforeStore = vi.fn();
|
|
194
310
|
const publicationFile = {
|
package/dist/services/index.d.ts
CHANGED
|
@@ -14,5 +14,6 @@ export { type CycleRelationValue, createReadContext, type PopulatedRelationValue
|
|
|
14
14
|
export { buildRelationSummaryPopulateMap, type RelationTargetResolver, resolveRelationProjection, } from './relation-projection.js';
|
|
15
15
|
export { type EmbedRichTextFieldsOptions, embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
|
|
16
16
|
export { collectRichTextLeaves, createRichTextDocumentReader, type PopulateRichTextFieldsOptions, populateRichTextFields, type RichTextAdapterPresence, type RichTextLeaf, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
|
|
17
|
+
export * from './singleton-lifecycle/index.js';
|
|
17
18
|
export { type SearchProviderPresence, validateSearchConfig, } from './validate-search-config.js';
|
|
18
19
|
export { type FieldLeaf, walkFieldTree } from './walk-field-tree.js';
|
package/dist/services/index.js
CHANGED
|
@@ -15,5 +15,6 @@ export { createReadContext, populateDocuments, resolveIdentityField, } from './p
|
|
|
15
15
|
export { buildRelationSummaryPopulateMap, resolveRelationProjection, } from './relation-projection.js';
|
|
16
16
|
export { embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
|
|
17
17
|
export { collectRichTextLeaves, createRichTextDocumentReader, populateRichTextFields, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
|
|
18
|
+
export * from './singleton-lifecycle/index.js';
|
|
18
19
|
export { validateSearchConfig, } from './validate-search-config.js';
|
|
19
20
|
export { walkFieldTree } from './walk-field-tree.js';
|
|
@@ -219,7 +219,7 @@ export async function populateDocuments(opts) {
|
|
|
219
219
|
// target collection before any adapter access; unresolved target schemas
|
|
220
220
|
// remain unresolved rather than bypassing the collection gate.
|
|
221
221
|
if (opts.requestContext && targetDef) {
|
|
222
|
-
assertActorCanPerform(opts.requestContext, targetDef
|
|
222
|
+
assertActorCanPerform(opts.requestContext, targetDef, 'read');
|
|
223
223
|
}
|
|
224
224
|
// Only fetch IDs we haven't materialised earlier in this request.
|
|
225
225
|
const idsToFetch = Array.from(new Set(leaves
|
|
@@ -129,6 +129,9 @@ function populatedEnvelope(collectionId, documentId, document) {
|
|
|
129
129
|
* `getCollectionById(id)` to resolve them to a path.
|
|
130
130
|
*/
|
|
131
131
|
function makeMockAdapter(store = {}, pathByCollectionId = {}) {
|
|
132
|
+
const fail = () => {
|
|
133
|
+
throw new Error('unexpected singleton mapping call');
|
|
134
|
+
};
|
|
132
135
|
const getDocumentsByDocumentIds = vi.fn(async (params) => {
|
|
133
136
|
const bucket = store[params.collection_id] ?? {};
|
|
134
137
|
return params.document_ids.map((id) => bucket[id]).filter((d) => d != null);
|
|
@@ -170,6 +173,11 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
|
|
|
170
173
|
nextScopedCounterValue: vi.fn(),
|
|
171
174
|
},
|
|
172
175
|
audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
|
|
176
|
+
singletons: {
|
|
177
|
+
lockSlot: vi.fn(fail),
|
|
178
|
+
setMapping: vi.fn(fail),
|
|
179
|
+
clearMapping: vi.fn(fail),
|
|
180
|
+
},
|
|
173
181
|
},
|
|
174
182
|
queries: {
|
|
175
183
|
collections: {
|
|
@@ -210,6 +218,7 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
|
|
|
210
218
|
meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
|
|
211
219
|
})),
|
|
212
220
|
},
|
|
221
|
+
singletons: { getMappedDocumentId: vi.fn(fail) },
|
|
213
222
|
},
|
|
214
223
|
withTransaction: async (fn) => fn(),
|
|
215
224
|
};
|
|
@@ -87,7 +87,7 @@ export function createRichTextDocumentReader(options) {
|
|
|
87
87
|
details: { collectionPath },
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
|
-
assertActorCanPerform(requestContext,
|
|
90
|
+
assertActorCanPerform(requestContext, definition, 'read');
|
|
91
91
|
let filters;
|
|
92
92
|
if (!bypassBeforeRead) {
|
|
93
93
|
filters = await compileBeforeReadFilters({
|
|
@@ -0,0 +1,16 @@
|
|
|
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 type { DocumentLifecycleContext } from '../document-lifecycle/context.js';
|
|
9
|
+
import type { SingletonSaveResult } from './internals.js';
|
|
10
|
+
export interface CopySingletonToLocaleParams {
|
|
11
|
+
sourceLocale: string;
|
|
12
|
+
targetLocale: string;
|
|
13
|
+
overwrite: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Copy localized singleton fields to another locale on the mapped document. */
|
|
16
|
+
export declare function copySingletonToLocale(ctx: DocumentLifecycleContext, params: CopySingletonToLocaleParams): Promise<SingletonSaveResult>;
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
9
|
+
import { withLogContext } from '../../lib/logger.js';
|
|
10
|
+
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
11
|
+
import { applyRichTextEmbed } from '../document-lifecycle/internals.js';
|
|
12
|
+
import { mergeLocaleData } from '../document-lifecycle/merge-locale-data.js';
|
|
13
|
+
import { persistExistingDocumentVersion } from '../document-lifecycle/persistence.js';
|
|
14
|
+
import { authorizeSingletonUpdate, commitSingletonSave } from './internals.js';
|
|
15
|
+
/** Copy localized singleton fields to another locale on the mapped document. */
|
|
16
|
+
export async function copySingletonToLocale(ctx, params) {
|
|
17
|
+
return withLogContext({ domain: 'services', module: 'singleton-lifecycle', function: 'copySingletonToLocale' }, async () => {
|
|
18
|
+
const definition = authorizeSingletonUpdate(ctx);
|
|
19
|
+
if (params.sourceLocale === params.targetLocale) {
|
|
20
|
+
throw ERR_VALIDATION({
|
|
21
|
+
message: 'sourceLocale and targetLocale must differ',
|
|
22
|
+
details: { sourceLocale: params.sourceLocale, targetLocale: params.targetLocale },
|
|
23
|
+
}).log(ctx.logger);
|
|
24
|
+
}
|
|
25
|
+
return commitSingletonSave({
|
|
26
|
+
ctx,
|
|
27
|
+
definition,
|
|
28
|
+
operation: { type: 'copyToLocale', ...params },
|
|
29
|
+
prepare: async (slot) => {
|
|
30
|
+
if (slot.documentId == null || slot.currentVersion == null) {
|
|
31
|
+
throw ERR_NOT_FOUND({
|
|
32
|
+
message: `singleton '${ctx.collectionPath}' has not been saved`,
|
|
33
|
+
details: { singletonPath: ctx.collectionPath },
|
|
34
|
+
}).log(ctx.logger);
|
|
35
|
+
}
|
|
36
|
+
const source = await ctx.db.queries.documents.getDocumentById({
|
|
37
|
+
collection_id: ctx.collectionId,
|
|
38
|
+
document_id: slot.documentId,
|
|
39
|
+
locale: params.sourceLocale,
|
|
40
|
+
reconstruct: true,
|
|
41
|
+
lenient: true,
|
|
42
|
+
onMissingLocale: 'omit',
|
|
43
|
+
requestContext: ctx.requestContext,
|
|
44
|
+
});
|
|
45
|
+
if (source == null) {
|
|
46
|
+
throw ERR_NOT_FOUND({
|
|
47
|
+
message: 'singleton not found in source locale',
|
|
48
|
+
details: { singletonPath: ctx.collectionPath, sourceLocale: params.sourceLocale },
|
|
49
|
+
}).log(ctx.logger);
|
|
50
|
+
}
|
|
51
|
+
const existingTarget = await ctx.db.queries.documents.getDocumentById({
|
|
52
|
+
collection_id: ctx.collectionId,
|
|
53
|
+
document_id: slot.documentId,
|
|
54
|
+
locale: params.targetLocale,
|
|
55
|
+
reconstruct: true,
|
|
56
|
+
lenient: true,
|
|
57
|
+
onMissingLocale: 'omit',
|
|
58
|
+
requestContext: ctx.requestContext,
|
|
59
|
+
});
|
|
60
|
+
const target = existingTarget ??
|
|
61
|
+
(await ctx.db.queries.documents.getDocumentById({
|
|
62
|
+
collection_id: ctx.collectionId,
|
|
63
|
+
document_id: slot.documentId,
|
|
64
|
+
locale: params.targetLocale,
|
|
65
|
+
reconstruct: true,
|
|
66
|
+
lenient: true,
|
|
67
|
+
requestContext: ctx.requestContext,
|
|
68
|
+
}));
|
|
69
|
+
if (target == null) {
|
|
70
|
+
throw ERR_NOT_FOUND({
|
|
71
|
+
message: 'mapped singleton document not found',
|
|
72
|
+
details: { singletonPath: ctx.collectionPath, documentId: slot.documentId },
|
|
73
|
+
}).log(ctx.logger);
|
|
74
|
+
}
|
|
75
|
+
const sourceFields = source.fields ?? {};
|
|
76
|
+
const targetFields = target.fields ?? {};
|
|
77
|
+
const originalData = existingTarget == null
|
|
78
|
+
? null
|
|
79
|
+
: (existingTarget.fields ?? {});
|
|
80
|
+
const merged = mergeLocaleData(definition.fields, sourceFields, targetFields, params.overwrite);
|
|
81
|
+
return {
|
|
82
|
+
data: merged.data,
|
|
83
|
+
originalData,
|
|
84
|
+
locale: params.targetLocale,
|
|
85
|
+
write: async () => {
|
|
86
|
+
await applyRichTextEmbed(ctx, merged.data);
|
|
87
|
+
return persistExistingDocumentVersion(ctx, {
|
|
88
|
+
documentId: slot.documentId,
|
|
89
|
+
action: 'copy_to_locale',
|
|
90
|
+
documentData: merged.data,
|
|
91
|
+
status: getDefaultStatus(definition),
|
|
92
|
+
locale: params.targetLocale,
|
|
93
|
+
previousVersionId: slot.currentVersion?.document_version_id,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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 { copySingletonToLocale } from './copy-to-locale.js';
|
|
9
|
+
export { resolveSingletonDocumentId } from './internals.js';
|
|
10
|
+
export { restoreSingletonVersion } from './restore.js';
|
|
11
|
+
export { updateSingleton } from './update.js';
|
|
12
|
+
export type { CopySingletonToLocaleParams } from './copy-to-locale.js';
|
|
13
|
+
export type { SingletonSaveResult } from './internals.js';
|
|
14
|
+
export type { UpdateSingletonParams } from './update.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
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 { copySingletonToLocale } from './copy-to-locale.js';
|
|
9
|
+
export { resolveSingletonDocumentId } from './internals.js';
|
|
10
|
+
export { restoreSingletonVersion } from './restore.js';
|
|
11
|
+
export { updateSingleton } from './update.js';
|