@byline/core 4.15.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.
Files changed (92) hide show
  1. package/dist/@types/admin-types.d.ts +86 -31
  2. package/dist/@types/admin-types.js +12 -0
  3. package/dist/@types/collection-types.d.ts +211 -29
  4. package/dist/@types/collection-types.js +68 -8
  5. package/dist/@types/db-types.d.ts +50 -0
  6. package/dist/@types/form-admin-config.test.node.d.ts +6 -0
  7. package/dist/@types/form-admin-config.test.node.js +26 -0
  8. package/dist/@types/singleton-admin-config.test.node.js +66 -0
  9. package/dist/@types/singleton-definition.test.node.d.ts +8 -0
  10. package/dist/@types/singleton-definition.test.node.js +75 -0
  11. package/dist/@types/site-config.d.ts +9 -8
  12. package/dist/auth/apply-before-read.d.ts +2 -1
  13. package/dist/auth/apply-before-read.js +13 -3
  14. package/dist/auth/apply-before-read.test.node.js +44 -0
  15. package/dist/auth/assert-actor-can-perform.d.ts +4 -3
  16. package/dist/auth/assert-actor-can-perform.js +9 -7
  17. package/dist/auth/assert-actor-can-perform.test.node.js +32 -11
  18. package/dist/auth/filter-readable-collections.d.ts +6 -6
  19. package/dist/auth/filter-readable-collections.js +8 -8
  20. package/dist/auth/filter-readable-collections.test.node.js +26 -1
  21. package/dist/auth/index.d.ts +1 -1
  22. package/dist/auth/index.js +1 -1
  23. package/dist/auth/register-collection-abilities.d.ts +34 -30
  24. package/dist/auth/register-collection-abilities.js +81 -77
  25. package/dist/auth/register-collection-abilities.test.node.js +86 -8
  26. package/dist/codegen/fixtures/all-fields.expected.d.ts +3 -0
  27. package/dist/codegen/index.d.ts +1 -1
  28. package/dist/codegen/index.js +32 -16
  29. package/dist/codegen/index.test.node.js +35 -2
  30. package/dist/config/attach-hooks.d.ts +3 -3
  31. package/dist/config/attach-hooks.js +8 -0
  32. package/dist/config/attach-hooks.test.node.js +59 -0
  33. package/dist/config/config-hooks.test.node.js +34 -1
  34. package/dist/config/config.d.ts +2 -1
  35. package/dist/config/config.js +9 -1
  36. package/dist/config/group-collections.d.ts +10 -9
  37. package/dist/config/group-collections.js +4 -4
  38. package/dist/config/group-collections.test.node.js +14 -0
  39. package/dist/config/validate-admin-configs.d.ts +4 -4
  40. package/dist/config/validate-admin-configs.js +40 -7
  41. package/dist/config/validate-admin-configs.test.node.js +44 -0
  42. package/dist/config/validate-collections.js +43 -0
  43. package/dist/config/validate-collections.test.node.js +75 -0
  44. package/dist/core.d.ts +2 -3
  45. package/dist/core.js +3 -3
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +2 -2
  48. package/dist/lib/errors.d.ts +5 -3
  49. package/dist/lib/errors.js +1 -0
  50. package/dist/services/collection-bootstrap.js +12 -0
  51. package/dist/services/collection-bootstrap.test.node.js +63 -6
  52. package/dist/services/discover-counter-groups.test.node.js +6 -0
  53. package/dist/services/document-lifecycle/copy-to-locale.js +9 -17
  54. package/dist/services/document-lifecycle/create.js +6 -11
  55. package/dist/services/document-lifecycle/delete-locale.js +1 -1
  56. package/dist/services/document-lifecycle/delete.js +1 -1
  57. package/dist/services/document-lifecycle/duplicate.js +7 -18
  58. package/dist/services/document-lifecycle/persistence.d.ts +29 -0
  59. package/dist/services/document-lifecycle/persistence.js +42 -0
  60. package/dist/services/document-lifecycle/restore.js +9 -17
  61. package/dist/services/document-lifecycle/scheduled-publish.js +5 -5
  62. package/dist/services/document-lifecycle/status.js +3 -3
  63. package/dist/services/document-lifecycle/system-fields.js +1 -1
  64. package/dist/services/document-lifecycle/tree.js +2 -2
  65. package/dist/services/document-lifecycle/tree.test.node.js +6 -1
  66. package/dist/services/document-lifecycle/update.js +20 -36
  67. package/dist/services/document-lifecycle.test.node.js +35 -0
  68. package/dist/services/field-upload.d.ts +1 -1
  69. package/dist/services/field-upload.js +20 -6
  70. package/dist/services/field-upload.test.node.js +125 -9
  71. package/dist/services/index.d.ts +1 -0
  72. package/dist/services/index.js +1 -0
  73. package/dist/services/populate.js +1 -1
  74. package/dist/services/populate.test.node.js +9 -0
  75. package/dist/services/richtext-populate.js +1 -1
  76. package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +16 -0
  77. package/dist/services/singleton-lifecycle/copy-to-locale.js +100 -0
  78. package/dist/services/singleton-lifecycle/index.d.ts +14 -0
  79. package/dist/services/singleton-lifecycle/index.js +11 -0
  80. package/dist/services/singleton-lifecycle/internals.d.ts +49 -0
  81. package/dist/services/singleton-lifecycle/internals.js +118 -0
  82. package/dist/services/singleton-lifecycle/restore.d.ts +13 -0
  83. package/dist/services/singleton-lifecycle/restore.js +93 -0
  84. package/dist/services/singleton-lifecycle/update.d.ts +16 -0
  85. package/dist/services/singleton-lifecycle/update.js +107 -0
  86. package/dist/services/singleton-lifecycle.test.node.d.ts +8 -0
  87. package/dist/services/singleton-lifecycle.test.node.js +396 -0
  88. package/dist/storage/collection-fingerprint.js +18 -6
  89. package/dist/storage/collection-fingerprint.test.node.js +15 -0
  90. package/package.json +5 -5
  91. package/dist/config/resolve-item-view-columns.test.node.js +0 -30
  92. /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 { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
18
- import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
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, collectionPath, 'update');
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 commitContentVersionWithScheduleSuspension({
81
- ctx,
80
+ const result = await persistExistingDocumentVersion(ctx, {
82
81
  documentId: params.documentId,
83
- write: () => db.commands.documents.createDocumentVersion({
84
- documentId: params.documentId,
85
- collectionId,
86
- collectionVersion: ctx.collectionVersion,
87
- collectionConfig: definition,
88
- action: 'update',
89
- documentData: data,
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, collectionPath, 'update');
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 commitContentVersionWithScheduleSuspension({
202
- ctx,
193
+ const result = await persistExistingDocumentVersion(ctx, {
203
194
  documentId: params.documentId,
204
- write: () => db.commands.documents.createDocumentVersion({
205
- documentId: params.documentId,
206
- collectionId,
207
- collectionVersion: ctx.collectionVersion,
208
- collectionConfig: definition,
209
- action: 'update',
210
- documentData: nextData,
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 `create` ability
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
- // Upload is effectively a write under collection scope — enforce
127
- // the `create` ability even when `shouldCreateDocument: false` so
128
- // anonymous callers cannot push bytes into storage.
129
- assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
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 upload = vi.fn().mockResolvedValue({
132
- storageProvider: 'local',
133
- storagePath: 'media/original.png',
134
- storageUrl: '/uploads/media/original.png',
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 = {
@@ -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';
@@ -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.path, 'read');
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, collectionPath, 'read');
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';