@byline/core 4.6.1 → 4.7.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.
@@ -0,0 +1,319 @@
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 { ERR_DATABASE } from '../lib/errors.js';
10
+ import { getLogger } from '../lib/logger.js';
11
+ import { isCanonicalNumericValue } from '../services/normalize-numeric-fields.js';
12
+ // ------------------------------------------------------------------------------
13
+ // Flattening logic: take a document's nested field data and flatten it into an
14
+ // array of field values with associated metadata such as field path and locale.
15
+ // ------------------------------------------------------------------------------
16
+ /**
17
+ * Main entrypoint for flattening a document's (nested) field data into a flat
18
+ * array of field values.
19
+ *
20
+ * @param fields - The field definitions for the collection.
21
+ * @param data - The document's field data to flatten.
22
+ * @param locale - The locale to flatten for (or 'all' to flatten all locales).
23
+ */
24
+ export const flattenFieldSetData = (fields, data, locale) => {
25
+ return Array.from(flattenFieldSetDataGen(fields, data, locale));
26
+ };
27
+ /**
28
+ * Flatten data for a FieldSet (Field[]) -- this could be the top-level field
29
+ * data for a document, or the data inside a group, array or blocks field.
30
+ *
31
+ * @param fields - The field definitions for the current field set.
32
+ * @param data - The field data for the current field set.
33
+ * @param locale - The locale to flatten for (or 'all' to flatten all locales).
34
+ * @param parent_path - The path segments leading up to the current field set.
35
+ * @returns
36
+ */
37
+ function* flattenFieldSetDataGen(fields, data, locale, parent_path = []) {
38
+ if (data == null) {
39
+ return;
40
+ }
41
+ const fieldData = data;
42
+ for (const field of fields) {
43
+ yield* flattenFieldDataGen(field, fieldData[field.name], locale, [...parent_path, field.name]);
44
+ }
45
+ }
46
+ /**
47
+ * Flatten data for a single field.
48
+ *
49
+ * @param field - The field definition for the current field.
50
+ * @param data - The field data for the current field.
51
+ * @param locale - The locale to flatten for (or 'all' to flatten all locales).
52
+ * @param field_path - The path segments up to and including the current field.
53
+ * @returns
54
+ */
55
+ function* flattenFieldDataGen(field, data, locale, field_path) {
56
+ // Virtual fields are never persisted. The value participated in the write
57
+ // pipeline (patches applied it, lifecycle hooks saw and could act on it),
58
+ // but no store_* row is written, so reads reconstruct the field as absent
59
+ // and the next editing session starts clean. This is the single
60
+ // enforcement point for the `virtual` contract — every nesting level
61
+ // (top-level, group children, array items, block fields) funnels through
62
+ // this function, and a virtual structure field omits its whole subtree.
63
+ // See `BaseField.virtual` in @byline/core.
64
+ if (field.virtual === true) {
65
+ return;
66
+ }
67
+ // Treat `null` the same as `undefined` — a removed/cleared field emits no
68
+ // storage rows for the new version, so reads reconstruct as absent. Without
69
+ // this guard, downstream value extractors (relation, file/image, group,
70
+ // localized) dereference `null` and crash.
71
+ if (data == null) {
72
+ return;
73
+ }
74
+ // Group fields are simple -- just use flattenFieldSetDataGen. It will handle
75
+ // adding the appropriate field names to field_path.
76
+ if (field.type === 'group') {
77
+ yield* flattenFieldSetDataGen(field.fields, data, locale, field_path);
78
+ }
79
+ // For array fields, we must emit a single FlattenedMetaFieldData for each
80
+ // item to capture the item's stable ID, and then flatten the fields within
81
+ // each item using flattenFieldSetDataGen().
82
+ else if (field.type === 'array') {
83
+ const items = data;
84
+ for (let i = 0; i < items.length; i++) {
85
+ // biome-ignore lint/style/noNonNullAssertion: i is always in-bounds
86
+ const { _id, ...item_data } = items[i];
87
+ const item_path = [...field_path, `${i}`];
88
+ yield {
89
+ locale: 'all',
90
+ field_path: item_path,
91
+ field_type: 'meta',
92
+ type: 'array_item',
93
+ item_id: _id ?? uuidv7(),
94
+ meta: null,
95
+ };
96
+ yield* flattenFieldSetDataGen(field.fields, item_data, locale, item_path);
97
+ }
98
+ }
99
+ // Blocks are similar to arrays, but with an additional type discriminator for
100
+ // block variants.
101
+ else if (field.type === 'blocks') {
102
+ const items = data;
103
+ for (let i = 0; i < items.length; i++) {
104
+ // biome-ignore lint/style/noNonNullAssertion: i is always in-bounds
105
+ const { _id, _type, ...item_data } = items[i];
106
+ const item_path = [...field_path, `${i}`, _type];
107
+ const block = field.blocks.find((f) => f.blockType === _type);
108
+ if (!block) {
109
+ throw ERR_DATABASE({
110
+ message: `invalid block type: ${_type}`,
111
+ details: { blockType: _type },
112
+ }).log(getLogger());
113
+ }
114
+ yield {
115
+ locale: 'all',
116
+ field_path: item_path,
117
+ field_type: 'meta',
118
+ type: 'group',
119
+ item_id: _id ?? uuidv7(),
120
+ meta: null,
121
+ };
122
+ yield* flattenFieldSetDataGen(block.fields, item_data, locale, item_path);
123
+ }
124
+ }
125
+ // Ordered multi-target relation: emit one relation row per item at an
126
+ // indexed path (`<field>.0`, `<field>.1`, …), mirroring the array path
127
+ // convention. `restoreFieldSetData` reassembles the ordered array.
128
+ // Relations are non-localized, so every row carries locale 'all'. Identity
129
+ // is the `targetDocumentId` (the widget dedups), so no `store_meta` rows are
130
+ // needed. Must intercept before the generic value branch, which would
131
+ // otherwise read the whole array as a single relation value.
132
+ else if (field.type === 'relation' && field.hasMany) {
133
+ const items = data;
134
+ for (let i = 0; i < items.length; i++) {
135
+ const item = items[i];
136
+ if (item == null)
137
+ continue;
138
+ yield flattenValueFieldData(field, [...field_path, `${i}`], item, 'all');
139
+ }
140
+ }
141
+ // Handle localized fields separately.
142
+ else if (field.localized) {
143
+ // If locale is 'all', data is expected to be an object that maps locales to
144
+ // the corresponding localized values, and we emit a separate
145
+ // FlattenedFieldData for each.
146
+ if (locale === 'all') {
147
+ const localizedData = data;
148
+ for (const [locale, value] of Object.entries(localizedData)) {
149
+ if (value !== undefined) {
150
+ yield flattenValueFieldData(field, field_path, value, locale);
151
+ }
152
+ }
153
+ }
154
+ // If locale is not 'all', data is expected to be a single localized value
155
+ // for the specified locale, and we emit a single FlattenedFieldData for
156
+ // that locale.
157
+ else {
158
+ yield flattenValueFieldData(field, field_path, data, locale);
159
+ }
160
+ }
161
+ // For non-localized fields, data is expected to be the (non-localized) field
162
+ // value, and we emit a single FlattenedFieldData with locale 'all'.
163
+ else {
164
+ yield flattenValueFieldData(field, field_path, data, 'all');
165
+ }
166
+ }
167
+ const flattenValueFieldData = (field, field_path, value, locale) => {
168
+ const field_type = field.type;
169
+ switch (field_type) {
170
+ case 'text':
171
+ case 'textArea':
172
+ case 'code':
173
+ case 'select':
174
+ return {
175
+ locale,
176
+ field_path,
177
+ field_type: 'text',
178
+ value: value,
179
+ };
180
+ case 'float':
181
+ assertCanonicalNumericValue(field_type, value, field_path);
182
+ return {
183
+ locale,
184
+ field_path,
185
+ field_type: 'numeric',
186
+ number_type: 'float',
187
+ value_float: value,
188
+ };
189
+ case 'integer':
190
+ assertCanonicalNumericValue(field_type, value, field_path);
191
+ return {
192
+ locale,
193
+ field_path,
194
+ field_type: 'numeric',
195
+ number_type: 'integer',
196
+ value_integer: value,
197
+ };
198
+ case 'counter':
199
+ if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
200
+ throwNonCanonicalNumericValue(field_type, value, field_path);
201
+ }
202
+ return {
203
+ locale,
204
+ field_path,
205
+ field_type: 'numeric',
206
+ number_type: 'integer',
207
+ value_integer: value,
208
+ };
209
+ case 'decimal':
210
+ assertCanonicalNumericValue(field_type, value, field_path);
211
+ return {
212
+ locale,
213
+ field_path,
214
+ field_type: 'numeric',
215
+ number_type: 'decimal',
216
+ value_decimal: value,
217
+ };
218
+ case 'boolean':
219
+ case 'checkbox':
220
+ return {
221
+ locale,
222
+ field_path,
223
+ field_type: 'boolean',
224
+ value: value,
225
+ };
226
+ case 'time':
227
+ return {
228
+ locale,
229
+ field_path,
230
+ field_type: 'datetime',
231
+ date_type: 'time',
232
+ value_time: value,
233
+ };
234
+ case 'date':
235
+ return {
236
+ locale,
237
+ field_path,
238
+ field_type: 'datetime',
239
+ date_type: 'date',
240
+ value_date: value,
241
+ };
242
+ case 'datetime':
243
+ return {
244
+ locale,
245
+ field_path,
246
+ field_type: 'datetime',
247
+ date_type: 'datetime',
248
+ value_timestamp_tz: value,
249
+ };
250
+ case 'file':
251
+ case 'image': {
252
+ const v = value;
253
+ return {
254
+ locale,
255
+ field_path,
256
+ field_type: 'file',
257
+ file_id: v.fileId,
258
+ filename: v.filename,
259
+ original_filename: v.originalFilename,
260
+ mime_type: v.mimeType,
261
+ file_size: v.fileSize,
262
+ storage_provider: v.storageProvider,
263
+ storage_path: v.storagePath,
264
+ storage_url: v.storageUrl,
265
+ file_hash: v.fileHash,
266
+ image_width: v.imageWidth,
267
+ image_height: v.imageHeight,
268
+ image_format: v.imageFormat,
269
+ processing_status: v.processingStatus,
270
+ thumbnail_generated: v.thumbnailGenerated,
271
+ variants: v.variants?.map((variant) => ({
272
+ name: variant.name,
273
+ storage_path: variant.storagePath,
274
+ storage_url: variant.storageUrl,
275
+ width: variant.width,
276
+ height: variant.height,
277
+ format: variant.format,
278
+ })),
279
+ };
280
+ }
281
+ case 'relation': {
282
+ const v = value;
283
+ return {
284
+ locale,
285
+ field_path,
286
+ field_type: 'relation',
287
+ target_document_id: v.targetDocumentId,
288
+ target_collection_id: v.targetCollectionId,
289
+ relationship_type: v.relationshipType,
290
+ cascade_delete: v.cascadeDelete,
291
+ };
292
+ }
293
+ case 'richText':
294
+ case 'json':
295
+ case 'object':
296
+ return {
297
+ locale,
298
+ field_path,
299
+ field_type: 'json',
300
+ value,
301
+ };
302
+ default:
303
+ throw ERR_DATABASE({
304
+ message: `unsupported field type: ${field_type}`,
305
+ details: { fieldType: field_type },
306
+ }).log(getLogger());
307
+ }
308
+ };
309
+ function assertCanonicalNumericValue(fieldType, value, fieldPath) {
310
+ if (!isCanonicalNumericValue(fieldType, value)) {
311
+ throwNonCanonicalNumericValue(fieldType, value, fieldPath);
312
+ }
313
+ }
314
+ function throwNonCanonicalNumericValue(fieldType, value, fieldPath) {
315
+ throw ERR_DATABASE({
316
+ message: `non-canonical ${fieldType} value reached storage at '${fieldPath.join('.')}'`,
317
+ details: { path: fieldPath.join('.'), fieldType, value },
318
+ });
319
+ }
@@ -0,0 +1,28 @@
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, UnifiedFieldValue } from './storage-row-types.js';
10
+ export interface RestoreResult {
11
+ data: any;
12
+ warnings: string[];
13
+ }
14
+ /**
15
+ * Main entrypoint for restoring a document's field data from flattened form
16
+ * back into the original nested structure.
17
+ *
18
+ * Always returns `{ data, warnings }`. Warnings carry per-row reasons (e.g.
19
+ * "Invalid block index 'undefined'") that are typically the result of a
20
+ * collection schema change leaving orphan rows behind. Callers decide what
21
+ * to do with them — strict reads reject any non-empty `warnings`, lenient
22
+ * reads (the admin edit path) surface them to the UI.
23
+ *
24
+ * @param fields - The field definitions for the collection.
25
+ * @param flattenedData - The flattened field data to restore.
26
+ */
27
+ export declare const restoreFieldSetData: (fields: FieldSet, flattenedData: FlattenedFieldValue[], resolveLocale?: string) => RestoreResult;
28
+ export declare const extractFlattenedFieldValue: (unifiedValue: UnifiedFieldValue) => FlattenedFieldValue;