@byline/core 4.6.2 → 4.8.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 (34) hide show
  1. package/dist/@types/db-types.d.ts +11 -0
  2. package/dist/@types/store-types.d.ts +6 -42
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/lib/errors.d.ts +26 -0
  6. package/dist/lib/errors.js +19 -0
  7. package/dist/services/document-lifecycle/create.js +1 -1
  8. package/dist/services/document-lifecycle/duplicate.js +2 -2
  9. package/dist/services/document-lifecycle/internals.d.ts +9 -11
  10. package/dist/services/document-lifecycle/internals.js +16 -23
  11. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +8 -0
  12. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +41 -0
  13. package/dist/services/document-lifecycle/system-fields.js +1 -1
  14. package/dist/services/document-lifecycle/update.js +2 -2
  15. package/dist/services/document-lifecycle.test.node.js +14 -1
  16. package/dist/storage/index.d.ts +5 -0
  17. package/dist/storage/index.js +4 -0
  18. package/dist/storage/multi-relation.test.node.d.ts +8 -0
  19. package/dist/storage/multi-relation.test.node.js +92 -0
  20. package/dist/storage/storage-flatten-reconstruct.test.node.d.ts +8 -0
  21. package/dist/storage/storage-flatten-reconstruct.test.node.js +408 -0
  22. package/dist/storage/storage-flatten.d.ts +18 -0
  23. package/dist/storage/storage-flatten.js +319 -0
  24. package/dist/storage/storage-restore.d.ts +28 -0
  25. package/dist/storage/storage-restore.js +370 -0
  26. package/dist/storage/storage-row-types.d.ts +115 -0
  27. package/dist/storage/storage-row-types.js +8 -0
  28. package/dist/storage/storage-utils.d.ts +21 -0
  29. package/dist/storage/storage-utils.js +60 -0
  30. package/dist/storage/store-manifest.d.ts +50 -0
  31. package/dist/storage/store-manifest.js +219 -0
  32. package/dist/storage/store-manifest.test.node.d.ts +8 -0
  33. package/dist/storage/store-manifest.test.node.js +128 -0
  34. package/package.json +3 -2
@@ -0,0 +1,408 @@
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 { v7 as uuidv7 } from 'uuid';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { defineCollection } from '../@types/index.js';
11
+ import { flattenFieldSetData } from './storage-flatten.js';
12
+ import { restoreFieldSetData } from './storage-restore.js';
13
+ import { resolveStoreTypes } from './storage-utils.js';
14
+ const DocsCollectionConfig = defineCollection({
15
+ path: 'docs',
16
+ labels: {
17
+ singular: 'Document',
18
+ plural: 'Documents',
19
+ },
20
+ fields: [
21
+ { name: 'title', type: 'text', localized: true },
22
+ { name: 'summary', type: 'text', localized: true },
23
+ {
24
+ name: 'publishedOn',
25
+ type: 'datetime',
26
+ mode: 'datetime',
27
+ optional: true,
28
+ },
29
+ {
30
+ name: 'featured',
31
+ label: 'Featured',
32
+ type: 'checkbox',
33
+ optional: true,
34
+ helpText: 'Is this page featured on the home page?',
35
+ },
36
+ { name: 'views', type: 'integer', optional: true },
37
+ { name: 'price', label: 'Price', type: 'decimal', optional: true },
38
+ { name: 'snippet', type: 'code', language: 'typescript', optional: true },
39
+ {
40
+ name: 'content',
41
+ type: 'blocks',
42
+ blocks: [
43
+ {
44
+ blockType: 'richTextBlock',
45
+ fields: [
46
+ { name: 'constrainedWidth', type: 'boolean', optional: true },
47
+ { name: 'richText', type: 'richText', localized: true },
48
+ ],
49
+ },
50
+ {
51
+ blockType: 'photoBlock',
52
+ fields: [
53
+ { name: 'display', type: 'text', optional: true },
54
+ { name: 'photo', type: 'image' },
55
+ { name: 'alt', type: 'text', localized: false },
56
+ { name: 'caption', type: 'richText', optional: true, localized: true },
57
+ ],
58
+ },
59
+ ],
60
+ },
61
+ {
62
+ name: 'reviews',
63
+ type: 'array',
64
+ fields: [
65
+ {
66
+ name: 'reviewItem',
67
+ type: 'group',
68
+ fields: [
69
+ { name: 'rating', type: 'integer' },
70
+ { name: 'comment', type: 'richText', localized: false },
71
+ ],
72
+ },
73
+ ],
74
+ },
75
+ {
76
+ name: 'links',
77
+ type: 'array',
78
+ fields: [{ name: 'link', type: 'text' }],
79
+ },
80
+ ],
81
+ });
82
+ const fileId = uuidv7();
83
+ // Test document using the flat blocks shape: { _type, ...fields }
84
+ // This is the shape used by the application layer (forms, patches, API).
85
+ const sampleDocument = {
86
+ title: {
87
+ en: 'My First Document',
88
+ es: 'Mi Primer Documento',
89
+ fr: 'Mon Premier Document',
90
+ },
91
+ summary: {
92
+ en: 'This is a sample document for testing purposes.',
93
+ es: 'Este es un documento de muestra para fines de prueba.',
94
+ fr: "Il s'agit d'un document d'exemple à des fins de test.",
95
+ },
96
+ publishedOn: new Date('2024-01-15T10:00:00Z'),
97
+ featured: true,
98
+ views: 100,
99
+ price: '19.99',
100
+ snippet: 'const answer: number = 42\nexport { answer }',
101
+ content: [
102
+ {
103
+ _id: 'block1',
104
+ _type: 'richTextBlock',
105
+ constrainedWidth: true,
106
+ richText: {
107
+ en: { root: { paragraph: 'Some text here...' } },
108
+ es: { root: { paragraph: 'Some spanish text here' } },
109
+ },
110
+ },
111
+ {
112
+ _id: 'block2',
113
+ _type: 'photoBlock',
114
+ display: 'wide',
115
+ photo: {
116
+ fileHash: undefined,
117
+ fileId: fileId,
118
+ filename: 'docs-photo-01.jpg',
119
+ imageFormat: undefined,
120
+ imageHeight: undefined,
121
+ imageWidth: undefined,
122
+ originalFilename: 'some-original-filename.jpg',
123
+ mimeType: 'image/jpeg',
124
+ fileSize: 123456,
125
+ storageProvider: 'local',
126
+ storagePath: 'uploads/docs-photo-01.jpg',
127
+ processingStatus: 'pending',
128
+ storageUrl: undefined,
129
+ thumbnailGenerated: undefined,
130
+ },
131
+ alt: 'Some alt text here',
132
+ caption: {
133
+ en: { root: { paragraph: 'Some text here...' } },
134
+ es: { root: { paragraph: 'Some spanish text here...' } },
135
+ },
136
+ },
137
+ ],
138
+ reviews: [
139
+ {
140
+ _id: 'review1',
141
+ reviewItem: { rating: 6, comment: { root: { paragraph: 'Some review text here...' } } },
142
+ },
143
+ {
144
+ _id: 'review2',
145
+ reviewItem: {
146
+ rating: 2,
147
+ comment: { root: { paragraph: 'Some more reviews here...' } },
148
+ },
149
+ },
150
+ ],
151
+ links: [
152
+ { _id: 'link1', link: 'https://example.com' },
153
+ { _id: 'link2', link: 'https://another-example.com' },
154
+ ],
155
+ };
156
+ // restoreFieldSetData produces the flat block shape directly: { _id, _type, ...fields }
157
+ // This matches the application layer shape — no separate attachMetaToDocument step needed.
158
+ const expectedRestored = {
159
+ title: {
160
+ en: 'My First Document',
161
+ es: 'Mi Primer Documento',
162
+ fr: 'Mon Premier Document',
163
+ },
164
+ summary: {
165
+ en: 'This is a sample document for testing purposes.',
166
+ es: 'Este es un documento de muestra para fines de prueba.',
167
+ fr: "Il s'agit d'un document d'exemple à des fins de test.",
168
+ },
169
+ publishedOn: '2024-01-15T10:00:00.000Z',
170
+ featured: true,
171
+ views: 100,
172
+ price: '19.99',
173
+ snippet: 'const answer: number = 42\nexport { answer }',
174
+ content: [
175
+ {
176
+ _id: 'block1',
177
+ _type: 'richTextBlock',
178
+ constrainedWidth: true,
179
+ richText: {
180
+ en: { root: { paragraph: 'Some text here...' } },
181
+ es: { root: { paragraph: 'Some spanish text here' } },
182
+ },
183
+ },
184
+ {
185
+ _id: 'block2',
186
+ _type: 'photoBlock',
187
+ display: 'wide',
188
+ photo: {
189
+ fileHash: undefined,
190
+ fileId: fileId,
191
+ filename: 'docs-photo-01.jpg',
192
+ imageFormat: undefined,
193
+ imageHeight: undefined,
194
+ imageWidth: undefined,
195
+ originalFilename: 'some-original-filename.jpg',
196
+ mimeType: 'image/jpeg',
197
+ fileSize: 123456,
198
+ storageProvider: 'local',
199
+ storagePath: 'uploads/docs-photo-01.jpg',
200
+ processingStatus: 'pending',
201
+ storageUrl: undefined,
202
+ thumbnailGenerated: undefined,
203
+ },
204
+ alt: 'Some alt text here',
205
+ caption: {
206
+ en: { root: { paragraph: 'Some text here...' } },
207
+ es: { root: { paragraph: 'Some spanish text here...' } },
208
+ },
209
+ },
210
+ ],
211
+ reviews: [
212
+ {
213
+ _id: 'review1',
214
+ reviewItem: { rating: 6, comment: { root: { paragraph: 'Some review text here...' } } },
215
+ },
216
+ {
217
+ _id: 'review2',
218
+ reviewItem: {
219
+ rating: 2,
220
+ comment: { root: { paragraph: 'Some more reviews here...' } },
221
+ },
222
+ },
223
+ ],
224
+ links: [
225
+ { _id: 'link1', link: 'https://example.com' },
226
+ { _id: 'link2', link: 'https://another-example.com' },
227
+ ],
228
+ };
229
+ describe('01 Document Flattening and Reconstruction', () => {
230
+ it('should flatten and reconstruct a document via schema-aware round-trip', () => {
231
+ const flattened = flattenFieldSetData(DocsCollectionConfig.fields, sampleDocument, 'all');
232
+ expect(flattened, 'Flattened document should not be null or undefined').toBeTruthy();
233
+ expect(flattened.length > 0, 'Flattened document should contain field values').toBe(true);
234
+ const { data: restored, warnings } = restoreFieldSetData(DocsCollectionConfig.fields, flattened);
235
+ expect(restored, 'Restored document should not be null or undefined').toBeTruthy();
236
+ expect(warnings, 'Round-trip restore should produce no warnings').toEqual([]);
237
+ const restoredJson = JSON.stringify(restored, null, 2);
238
+ const expectedJson = JSON.stringify(expectedRestored, null, 2);
239
+ expect(JSON.parse(restoredJson), 'Restored document should match the expected flat block shape').toEqual(JSON.parse(expectedJson));
240
+ });
241
+ it('should resolve localized fields when a specific locale is requested', () => {
242
+ const flattened = flattenFieldSetData(DocsCollectionConfig.fields, sampleDocument, 'all');
243
+ const { data: restored } = restoreFieldSetData(DocsCollectionConfig.fields, flattened, 'en');
244
+ expect(restored.title).toBe('My First Document');
245
+ expect(restored.summary).toBe('This is a sample document for testing purposes.');
246
+ });
247
+ });
248
+ describe('resolveStoreTypes', () => {
249
+ it('should resolve text fields to text store', () => {
250
+ const stores = resolveStoreTypes(DocsCollectionConfig.fields, ['path', 'title', 'summary']);
251
+ expect([...stores].sort()).toEqual(['text']);
252
+ });
253
+ it('should resolve mixed field types to their respective stores', () => {
254
+ const stores = resolveStoreTypes(DocsCollectionConfig.fields, [
255
+ 'title',
256
+ 'publishedOn',
257
+ 'featured',
258
+ 'views',
259
+ 'price',
260
+ ]);
261
+ expect([...stores].sort()).toEqual(['boolean', 'datetime', 'numeric', 'text']);
262
+ });
263
+ it('should resolve blocks field to all child store types', () => {
264
+ const stores = resolveStoreTypes(DocsCollectionConfig.fields, ['content']);
265
+ // content blocks contain: richText (json), boolean, text, image (file)
266
+ expect(stores.has('json'), 'should include json for richText').toBeTruthy();
267
+ expect(stores.has('boolean'), 'should include boolean for constrainedWidth').toBeTruthy();
268
+ expect(stores.has('text'), 'should include text for display/alt').toBeTruthy();
269
+ expect(stores.has('file'), 'should include file for photo/image').toBeTruthy();
270
+ });
271
+ it('should resolve array field to child store types', () => {
272
+ const stores = resolveStoreTypes(DocsCollectionConfig.fields, ['reviews']);
273
+ // reviews array contains group with: integer (numeric), richText (json)
274
+ expect(stores.has('numeric'), 'should include numeric for rating').toBeTruthy();
275
+ expect(stores.has('json'), 'should include json for comment richText').toBeTruthy();
276
+ });
277
+ it('should ignore field names that do not exist in the collection', () => {
278
+ const stores = resolveStoreTypes(DocsCollectionConfig.fields, [
279
+ 'status',
280
+ 'updated_at',
281
+ 'nonexistent',
282
+ ]);
283
+ expect(stores.size, 'metadata fields should not resolve to any store').toBe(0);
284
+ });
285
+ it('should return empty set for empty field list', () => {
286
+ const stores = resolveStoreTypes(DocsCollectionConfig.fields, []);
287
+ expect(stores.size).toBe(0);
288
+ });
289
+ });
290
+ describe('reserved field-name tolerance on restore', () => {
291
+ // Installations that declared `path` as a user field before the
292
+ // promotion to a system attribute may have left orphan rows in
293
+ // `store_text` with `field_name = 'path'`. Those rows are not
294
+ // referenced by any current collection schema, but they are still
295
+ // read back by the UNION ALL. `restoreFieldSetData` must silently
296
+ // skip them — the alternative would be a hard
297
+ // "Field path not found" reconstruction failure that lights up
298
+ // every read.
299
+ it('silently skips orphan rows whose field_name is a reserved system attribute', () => {
300
+ const orphanPathRow = {
301
+ locale: 'all',
302
+ field_path: ['path'],
303
+ field_type: 'text',
304
+ value: 'leftover-from-legacy-schema',
305
+ };
306
+ // A normal row alongside the orphan so we can prove reconstruction
307
+ // succeeded rather than returning early.
308
+ const titleRow = {
309
+ locale: 'en',
310
+ field_path: ['title'],
311
+ field_type: 'text',
312
+ value: 'Hello',
313
+ };
314
+ const { data: restored, warnings } = restoreFieldSetData(DocsCollectionConfig.fields, [orphanPathRow, titleRow], 'en');
315
+ expect(restored.path, 'reserved-name row must not land on the reconstructed document').toBe(undefined);
316
+ expect(restored.title, 'non-reserved rows must still be restored').toBe('Hello');
317
+ expect(warnings, 'reserved-name orphan must not surface as a restore warning').toEqual([]);
318
+ });
319
+ it('does not raise warnings when the only row present is a reserved-name orphan', () => {
320
+ const orphanPathRow = {
321
+ locale: 'all',
322
+ field_path: ['path'],
323
+ field_type: 'text',
324
+ value: 'whatever',
325
+ };
326
+ const { warnings } = restoreFieldSetData(DocsCollectionConfig.fields, [orphanPathRow]);
327
+ expect(warnings, 'a reserved-name orphan must not be treated as an unknown field').toEqual([]);
328
+ });
329
+ });
330
+ describe('virtual fields', () => {
331
+ // Mirrors the publications shape that motivated `virtual`: a per-item
332
+ // "generate thumbnail" checkbox + page number inside an array group,
333
+ // plus a top-level virtual scalar. Virtual values must flow through the
334
+ // write pipeline (hooks see them) but emit NO store rows — reads
335
+ // reconstruct them as absent, so the next editing session starts clean.
336
+ const VirtualCollectionConfig = defineCollection({
337
+ path: 'virtual-docs',
338
+ labels: { singular: 'Virtual Doc', plural: 'Virtual Docs' },
339
+ fields: [
340
+ { name: 'title', type: 'text' },
341
+ { name: 'regenerateAll', type: 'checkbox', virtual: true, optional: true },
342
+ {
343
+ name: 'files',
344
+ type: 'array',
345
+ fields: [
346
+ {
347
+ name: 'filesGroup',
348
+ type: 'group',
349
+ fields: [
350
+ { name: 'label', type: 'text' },
351
+ { name: 'generateThumbnail', type: 'checkbox', virtual: true, optional: true },
352
+ { name: 'thumbnailPage', type: 'integer', virtual: true, defaultValue: 1 },
353
+ ],
354
+ },
355
+ ],
356
+ },
357
+ {
358
+ name: 'scratch',
359
+ type: 'group',
360
+ virtual: true,
361
+ optional: true,
362
+ fields: [{ name: 'note', type: 'text', optional: true }],
363
+ },
364
+ ],
365
+ });
366
+ const virtualDocument = {
367
+ title: 'Publication-ish',
368
+ regenerateAll: true,
369
+ files: [
370
+ {
371
+ _id: 'item-1',
372
+ filesGroup: { label: 'English PDF', generateThumbnail: true, thumbnailPage: 3 },
373
+ },
374
+ {
375
+ _id: 'item-2',
376
+ filesGroup: { label: 'Thai PDF', generateThumbnail: false, thumbnailPage: 1 },
377
+ },
378
+ ],
379
+ scratch: { note: 'never stored' },
380
+ };
381
+ it('emits no store rows for virtual fields at any nesting depth', () => {
382
+ const flattened = flattenFieldSetData(VirtualCollectionConfig.fields, virtualDocument, 'all');
383
+ const paths = flattened.map((row) => row.field_path.join('.'));
384
+ // Persisted values survive…
385
+ expect(paths).toContain('title');
386
+ expect(paths).toContain('files.0.filesGroup.label');
387
+ expect(paths).toContain('files.1.filesGroup.label');
388
+ // …virtual leaves do not (top-level, nested in array group)…
389
+ expect(paths.some((p) => p.includes('regenerateAll'))).toBe(false);
390
+ expect(paths.some((p) => p.includes('generateThumbnail'))).toBe(false);
391
+ expect(paths.some((p) => p.includes('thumbnailPage'))).toBe(false);
392
+ // …and a virtual structure field omits its whole subtree.
393
+ expect(paths.some((p) => p.startsWith('scratch'))).toBe(false);
394
+ });
395
+ it('round-trip reconstruction leaves virtual fields structurally absent', () => {
396
+ const flattened = flattenFieldSetData(VirtualCollectionConfig.fields, virtualDocument, 'all');
397
+ const { data: restored, warnings } = restoreFieldSetData(VirtualCollectionConfig.fields, flattened);
398
+ expect(warnings).toEqual([]);
399
+ expect(restored.title).toBe('Publication-ish');
400
+ expect(restored.regenerateAll).toBeUndefined();
401
+ expect(restored.scratch).toBeUndefined();
402
+ const items = restored.files;
403
+ expect(items).toHaveLength(2);
404
+ expect(items[0]?.filesGroup.label).toBe('English PDF');
405
+ expect(items[0]?.filesGroup.generateThumbnail).toBeUndefined();
406
+ expect(items[0]?.filesGroup.thumbnailPage).toBeUndefined();
407
+ });
408
+ });
@@ -0,0 +1,18 @@
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 { FieldSet } from '../@types/index.js';
9
+ import type { FlattenedFieldValue } from './storage-row-types.js';
10
+ /**
11
+ * Main entrypoint for flattening a document's (nested) field data into a flat
12
+ * array of field values.
13
+ *
14
+ * @param fields - The field definitions for the collection.
15
+ * @param data - The document's field data to flatten.
16
+ * @param locale - The locale to flatten for (or 'all' to flatten all locales).
17
+ */
18
+ export declare const flattenFieldSetData: (fields: FieldSet, data: unknown, locale: string) => FlattenedFieldValue[];