@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.
- package/dist/@types/store-types.d.ts +6 -42
- package/dist/storage/index.d.ts +5 -0
- package/dist/storage/index.js +4 -0
- package/dist/storage/multi-relation.test.node.d.ts +8 -0
- package/dist/storage/multi-relation.test.node.js +92 -0
- package/dist/storage/storage-flatten-reconstruct.test.node.d.ts +8 -0
- package/dist/storage/storage-flatten-reconstruct.test.node.js +408 -0
- package/dist/storage/storage-flatten.d.ts +18 -0
- package/dist/storage/storage-flatten.js +319 -0
- package/dist/storage/storage-restore.d.ts +28 -0
- package/dist/storage/storage-restore.js +370 -0
- package/dist/storage/storage-row-types.d.ts +115 -0
- package/dist/storage/storage-row-types.js +8 -0
- package/dist/storage/storage-utils.d.ts +21 -0
- package/dist/storage/storage-utils.js +60 -0
- package/dist/storage/store-manifest.d.ts +50 -0
- package/dist/storage/store-manifest.js +215 -0
- package/dist/storage/store-manifest.test.node.d.ts +8 -0
- package/dist/storage/store-manifest.test.node.js +128 -0
- package/package.json +3 -2
|
@@ -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;
|