@byline/core 6.8.0 → 6.10.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 (37) hide show
  1. package/dist/@types/admin-types.d.ts +6 -6
  2. package/dist/@types/admin-types.js +3 -3
  3. package/dist/@types/form-admin-config.test.node.js +1 -1
  4. package/dist/@types/search-types.d.ts +34 -0
  5. package/dist/@types/search-types.js +2 -0
  6. package/dist/@types/search-types.test.node.d.ts +8 -0
  7. package/dist/@types/search-types.test.node.js +21 -0
  8. package/dist/@types/singleton-admin-config.test.node.js +8 -5
  9. package/dist/config/config.d.ts +2 -2
  10. package/dist/config/config.js +6 -6
  11. package/dist/config/group-collections.js +1 -1
  12. package/dist/config/group-collections.test.node.js +6 -6
  13. package/dist/config/validate-admin-configs.d.ts +1 -1
  14. package/dist/config/validate-admin-configs.js +10 -10
  15. package/dist/config/validate-admin-configs.test.node.js +22 -22
  16. package/dist/paths/path-dialects.test.node.js +2 -2
  17. package/dist/paths/paths.test.node.js +1 -1
  18. package/dist/services/document-lifecycle/copy-to-locale.js +2 -1
  19. package/dist/services/document-lifecycle/delete-locale.js +27 -0
  20. package/dist/services/document-lifecycle/internals.d.ts +3 -2
  21. package/dist/services/document-lifecycle/internals.js +3 -3
  22. package/dist/services/document-lifecycle/merge-write-locale.d.ts +12 -0
  23. package/dist/services/document-lifecycle/merge-write-locale.js +53 -0
  24. package/dist/services/document-lifecycle/persistence.d.ts +4 -1
  25. package/dist/services/document-lifecycle/persistence.js +23 -1
  26. package/dist/services/document-lifecycle/update.js +2 -2
  27. package/dist/services/document-lifecycle.test.node.js +4 -2
  28. package/dist/services/singleton-lifecycle/copy-to-locale.js +1 -1
  29. package/dist/services/singleton-lifecycle/update.js +1 -1
  30. package/dist/storage/index.d.ts +1 -1
  31. package/dist/storage/storage-flatten.js +8 -2
  32. package/dist/storage/storage-restore.d.ts +15 -2
  33. package/dist/storage/storage-restore.js +66 -15
  34. package/dist/storage/unavailable-blocks.test.node.d.ts +8 -0
  35. package/dist/storage/unavailable-blocks.test.node.js +132 -0
  36. package/dist/validation/document-fields.js +7 -4
  37. package/package.json +6 -6
@@ -0,0 +1,53 @@
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
+ /** Preserve other locales by item identity after unavailable blocks were compacted.
9
+ * Incoming structure is authoritative; never carry old positional store rows forward.
10
+ */
11
+ export function mergeWriteLocale(fields, previous, incoming, locale) {
12
+ const result = {};
13
+ for (const field of fields) {
14
+ const value = incoming[field.name];
15
+ const old = previous?.[field.name];
16
+ if (field.type === 'group') {
17
+ if (value != null)
18
+ result[field.name] = mergeWriteLocale(field.fields, old ?? {}, value, locale);
19
+ }
20
+ else if (field.type === 'array' || field.type === 'blocks') {
21
+ if (!Array.isArray(value))
22
+ continue;
23
+ result[field.name] = value.map((item, index) => {
24
+ // Hand-built payloads may omit identity metadata. Match their compacted
25
+ // position, as ordinary locale writes do; an explicit ID stays authoritative.
26
+ const prior = Array.isArray(old)
27
+ ? item._id != null
28
+ ? old.find((candidate) => candidate._id === item._id)
29
+ : old[index]
30
+ : undefined;
31
+ const childFields = field.type === 'array'
32
+ ? field.fields
33
+ : field.blocks.find((block) => block.blockType === item._type)?.fields;
34
+ return {
35
+ ...(item._id != null ? { _id: item._id } : {}),
36
+ ...(field.type === 'blocks' ? { _type: item._type } : {}),
37
+ ...mergeWriteLocale(childFields ?? [], prior ?? {}, item, locale),
38
+ };
39
+ });
40
+ }
41
+ else if (field.localized) {
42
+ const localized = { ...old };
43
+ delete localized[locale];
44
+ if (value !== undefined)
45
+ localized[locale] = value;
46
+ if (Object.keys(localized).length > 0)
47
+ result[field.name] = localized;
48
+ }
49
+ else if (value !== undefined)
50
+ result[field.name] = value;
51
+ }
52
+ return result;
53
+ }
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { IDocumentCommands } from '../../@types/index.js';
9
+ import type { UnavailableBlock } from '../../storage/storage-restore.js';
9
10
  import type { DocumentLifecycleContext } from './context.js';
10
11
  type AdapterVersionWrite = Parameters<IDocumentCommands['createDocumentVersion']>[0];
11
12
  type LifecycleOwnedWriteKeys = 'collectionId' | 'collectionVersion' | 'collectionConfig' | 'createdBy';
@@ -25,5 +26,7 @@ export declare function persistInitialDocumentVersion(ctx: DocumentLifecycleCont
25
26
  * publication schedule atomically. All operation-specific preparation and
26
27
  * hook sequencing remains in the caller.
27
28
  */
28
- export declare function persistExistingDocumentVersion(ctx: DocumentLifecycleContext, write: ExistingDocumentVersionWrite): ReturnType<IDocumentCommands['createDocumentVersion']>;
29
+ export declare function persistExistingDocumentVersion(ctx: DocumentLifecycleContext, write: ExistingDocumentVersionWrite, previousRead?: {
30
+ unavailableBlocks?: readonly UnavailableBlock[];
31
+ } | null): ReturnType<IDocumentCommands['createDocumentVersion']>;
29
32
  export {};
@@ -8,6 +8,7 @@
8
8
  import { assertDocumentFields } from '../../validation/document-fields.js';
9
9
  import { requireAuditCapability } from './audit.js';
10
10
  import { actorId } from './internals.js';
11
+ import { mergeWriteLocale } from './merge-write-locale.js';
11
12
  import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
12
13
  import { normalizeDocumentVersionParentError } from './stale-document.js';
13
14
  import { appendTreeRoot } from './tree.js';
@@ -73,8 +74,29 @@ export async function persistInitialDocumentVersion(ctx, write) {
73
74
  * publication schedule atomically. All operation-specific preparation and
74
75
  * hook sequencing remains in the caller.
75
76
  */
76
- export function persistExistingDocumentVersion(ctx, write) {
77
+ export async function persistExistingDocumentVersion(ctx, write, previousRead) {
77
78
  assertWritableContent(ctx, write);
79
+ // Single-locale storage writes copy other locales' positional rows forward.
80
+ // After compaction those rows can resurrect unavailable blocks or attach a
81
+ // translation to the wrong survivor. Rebuild all locales by item identity.
82
+ if (previousRead?.unavailableBlocks?.length &&
83
+ write.previousVersionId &&
84
+ write.locale &&
85
+ write.locale !== 'all') {
86
+ const previous = await ctx.db.queries.documents.getDocumentByVersion({
87
+ document_version_id: write.previousVersionId,
88
+ document_id: write.documentId,
89
+ collection_id: ctx.collectionId,
90
+ locale: 'all',
91
+ });
92
+ if (previous?.unavailableBlocks?.length) {
93
+ write = {
94
+ ...write,
95
+ documentData: mergeWriteLocale(ctx.definition.fields, previous.fields, write.documentData, write.locale),
96
+ locale: 'all',
97
+ };
98
+ }
99
+ }
78
100
  return commitContentVersionWithScheduleSuspension({
79
101
  ctx,
80
102
  documentId: write.documentId,
@@ -102,7 +102,7 @@ async function replaceFields(ctx, params, policy) {
102
102
  status: policy === 'preserve' ? preservedStatus(ctx, locked.status) : defaultStatus,
103
103
  locale: requestLocale,
104
104
  previousVersionId,
105
- });
105
+ }, latest);
106
106
  if (policy === 'preserve' && locked.status === 'published') {
107
107
  await db.commands.documents.archivePublishedVersions({
108
108
  document_id: params.documentId,
@@ -212,7 +212,7 @@ export async function updateDocumentWithPatches(ctx, params) {
212
212
  status: defaultStatus,
213
213
  locale: requestLocale,
214
214
  previousVersionId,
215
- });
215
+ }, latest);
216
216
  await selfHealTreePlacement(ctx, params.documentId);
217
217
  return { value: { result, systemFields }, changed: true };
218
218
  }).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
@@ -2857,7 +2857,7 @@ describe('Document lifecycle service', () => {
2857
2857
  expect(call.path).toBe('hello-world-copy');
2858
2858
  expect(result.newPath).toBe('hello-world-copy');
2859
2859
  });
2860
- it('strips _id and _type metadata from blocks and array items so the new doc gets fresh identities', async () => {
2860
+ it('strips _id identities but preserves block types when duplicating', async () => {
2861
2861
  const mocks = createMockDb();
2862
2862
  setupSource(mocks, {
2863
2863
  sourceFields: {
@@ -2881,7 +2881,9 @@ describe('Document lifecycle service', () => {
2881
2881
  const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2882
2882
  const sections = call.documentData.sections;
2883
2883
  expect(sections[0]._id).toBeUndefined();
2884
- expect(sections[0]._type).toBeUndefined();
2884
+ expect(sections[0]._type).toBe('section');
2885
+ expect(sections[0].blocks[0]._type).toBe('photoBlock');
2886
+ expect(sections[0].blocks[1]._type).toBe('textBlock');
2885
2887
  expect(sections[0].blocks[0]._id).toBeUndefined();
2886
2888
  expect(sections[0].blocks[1]._id).toBeUndefined();
2887
2889
  // Content survives — only meta is stripped.
@@ -98,7 +98,7 @@ export async function copySingletonToLocale(ctx, params) {
98
98
  status: getDefaultStatus(definition),
99
99
  locale: params.targetLocale,
100
100
  previousVersionId: slot.currentVersion?.document_version_id,
101
- });
101
+ }, existingTarget ?? source);
102
102
  },
103
103
  };
104
104
  },
@@ -139,7 +139,7 @@ export async function updateSingleton(ctx, params) {
139
139
  action: 'update',
140
140
  documentId: mappedDocumentId,
141
141
  previousVersionId,
142
- });
142
+ }, observed.current);
143
143
  const documentId = extractDocumentId(result.document);
144
144
  const documentVersionId = extractVersionId(result.document);
145
145
  if (!documentId || !documentVersionId)
@@ -10,7 +10,7 @@ export { assertDocumentVersionParent, type DocumentVersionParentAssertion, type
10
10
  export { ALL_STORE_TYPES, type FieldStoreKind, type FieldStoreMapping, fieldTypeToStore, fieldTypeToStoreType, type StoreType, } from './field-store-map.js';
11
11
  export { deriveVersionLocaleLedger, resolveLocaleVisibility, resolveReadLocale, type VersionLocaleLedger, } from './locale-resolution.js';
12
12
  export { flattenFieldSetData } from './storage-flatten.js';
13
- export { extractFlattenedFieldValue, type RestoreResult, restoreFieldSetData, } from './storage-restore.js';
13
+ export { extractFlattenedFieldValue, type RestoreResult, restoreFieldSetData, type UnavailableBlock, } from './storage-restore.js';
14
14
  export { resolveStoreTypes } from './storage-utils.js';
15
15
  export { type ColumnDef, storeColumnManifest, storeTableNames } from './store-manifest.js';
16
16
  export type { FlattenedFieldValue, UnifiedFieldValue } from './storage-row-types.js';
@@ -101,8 +101,14 @@ function* flattenFieldDataGen(field, data, locale, field_path) {
101
101
  else if (field.type === 'blocks') {
102
102
  const items = data;
103
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];
104
+ const item = items[i];
105
+ if (item == null || typeof item !== 'object' || Array.isArray(item)) {
106
+ throw ERR_DATABASE({
107
+ message: `${field_path.join('.')}[${i}]: Block item is missing or must be an object`,
108
+ details: { fieldPath: field_path, index: i },
109
+ });
110
+ }
111
+ const { _id, _type, ...item_data } = item;
106
112
  const item_path = [...field_path, `${i}`, _type];
107
113
  const block = field.blocks.find((f) => f.blockType === _type);
108
114
  if (!block) {
@@ -7,15 +7,24 @@
7
7
  */
8
8
  import type { FieldSet } from '../@types/index.js';
9
9
  import type { FlattenedFieldValue, UnifiedFieldValue } from './storage-row-types.js';
10
+ export interface UnavailableBlock {
11
+ /** Instance path in the compacted document, e.g. sections[0].body. */
12
+ fieldPath: string;
13
+ /** Position in the stored blocks array before compaction. */
14
+ storedIndex: number;
15
+ blockType: string;
16
+ id?: string;
17
+ }
10
18
  export interface RestoreResult {
11
19
  data: any;
12
20
  warnings: string[];
21
+ unavailableBlocks: UnavailableBlock[];
13
22
  }
14
23
  /**
15
24
  * Main entrypoint for restoring a document's field data from flattened form
16
25
  * back into the original nested structure.
17
26
  *
18
- * Always returns `{ data, warnings }`. Warnings carry per-row reasons (e.g.
27
+ * Always returns `{ data, warnings, unavailableBlocks }`. Warnings carry per-row reasons (e.g.
19
28
  * "Invalid block index 'undefined'") that are typically the result of a
20
29
  * collection schema change leaving orphan rows behind. Callers decide what
21
30
  * to do with them — strict reads reject any non-empty `warnings`, lenient
@@ -24,5 +33,9 @@ export interface RestoreResult {
24
33
  * @param fields - The field definitions for the collection.
25
34
  * @param flattenedData - The flattened field data to restore.
26
35
  */
27
- export declare const restoreFieldSetData: (fields: FieldSet, flattenedData: FlattenedFieldValue[], resolveLocale?: string) => RestoreResult;
36
+ export declare const restoreFieldSetData: (fields: FieldSet, flattenedData: FlattenedFieldValue[], resolveLocale?: string, identity?: {
37
+ collectionPath: string;
38
+ documentId: string;
39
+ versionId: string;
40
+ }) => RestoreResult;
28
41
  export declare const extractFlattenedFieldValue: (unifiedValue: UnifiedFieldValue) => FlattenedFieldValue;
@@ -8,11 +8,16 @@
8
8
  import { RESERVED_FIELD_NAMES } from '../config/validate-collections.js';
9
9
  import { ERR_DATABASE } from '../lib/errors.js';
10
10
  import { getLogger } from '../lib/logger.js';
11
+ // Shared across duplicate server module graphs, just like the logger registry.
12
+ const loggedVersionsKey = Symbol.for('byline.unavailable-block-versions');
13
+ const processState = globalThis;
14
+ processState[loggedVersionsKey] ??= new Set();
15
+ const loggedVersions = processState[loggedVersionsKey];
11
16
  /**
12
17
  * Main entrypoint for restoring a document's field data from flattened form
13
18
  * back into the original nested structure.
14
19
  *
15
- * Always returns `{ data, warnings }`. Warnings carry per-row reasons (e.g.
20
+ * Always returns `{ data, warnings, unavailableBlocks }`. Warnings carry per-row reasons (e.g.
16
21
  * "Invalid block index 'undefined'") that are typically the result of a
17
22
  * collection schema change leaving orphan rows behind. Callers decide what
18
23
  * to do with them — strict reads reject any non-empty `warnings`, lenient
@@ -21,9 +26,10 @@ import { getLogger } from '../lib/logger.js';
21
26
  * @param fields - The field definitions for the collection.
22
27
  * @param flattenedData - The flattened field data to restore.
23
28
  */
24
- export const restoreFieldSetData = (fields, flattenedData, resolveLocale) => {
29
+ export const restoreFieldSetData = (fields, flattenedData, resolveLocale, identity) => {
25
30
  const result = {};
26
31
  const warnings = [];
32
+ const unavailableByArray = new Map();
27
33
  for (const item of flattenedData) {
28
34
  // biome-ignore lint/style/noNonNullAssertion: TODO: put in a proper check here?
29
35
  const fieldName = item.field_path[0];
@@ -38,7 +44,7 @@ export const restoreFieldSetData = (fields, flattenedData, resolveLocale) => {
38
44
  if (!field) {
39
45
  }
40
46
  else {
41
- result[fieldName] = restoreFieldData(field, result[fieldName], item, 1, warnings, resolveLocale);
47
+ result[fieldName] = restoreFieldData(field, result[fieldName], item, 1, warnings, unavailableByArray, resolveLocale);
42
48
  }
43
49
  }
44
50
  // Array/blocks/group/hasMany-relation fixup — a required list-shaped field
@@ -54,17 +60,52 @@ export const restoreFieldSetData = (fields, flattenedData, resolveLocale) => {
54
60
  result[field.name] = result[field.name] || {};
55
61
  }
56
62
  }
57
- return { data: result, warnings };
63
+ const unavailableBlocks = [];
64
+ // Finish only after all rows (including identity metadata) have been read.
65
+ // Walk parents before children so nested report paths use compacted indices.
66
+ const finish = (fields, value, prefix = '') => {
67
+ for (const field of fields) {
68
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
69
+ const child = value?.[field.name];
70
+ if (field.type === 'group' && child)
71
+ finish(field.fields, child, path);
72
+ if ((field.type === 'blocks' || field.type === 'array') && Array.isArray(child)) {
73
+ for (const issue of [...(unavailableByArray.get(child)?.values() ?? [])].sort((a, b) => a.storedIndex - b.storedIndex))
74
+ unavailableBlocks.push({ fieldPath: path, ...issue });
75
+ const compacted = child.filter((item) => item != null);
76
+ value[field.name] = compacted;
77
+ compacted.forEach((item, index) => {
78
+ const itemFields = field.type === 'array'
79
+ ? field.fields
80
+ : field.blocks.find((block) => block.blockType === item._type)?.fields;
81
+ if (itemFields)
82
+ finish(itemFields, item, `${path}[${index}]`);
83
+ });
84
+ }
85
+ }
86
+ };
87
+ finish(fields, result);
88
+ if (identity && unavailableBlocks.length > 0) {
89
+ const key = JSON.stringify([identity.collectionPath, identity.documentId, identity.versionId]);
90
+ if (!loggedVersions.has(key)) {
91
+ loggedVersions.add(key);
92
+ getLogger().warn({
93
+ ...identity,
94
+ blockTypes: [...new Set(unavailableBlocks.map((issue) => issue.blockType))],
95
+ }, 'Document contains unavailable blocks; saving content will remove them');
96
+ }
97
+ }
98
+ return { data: result, warnings, unavailableBlocks };
58
99
  };
59
- const restoreFieldData = (field, target, data, pathIndex, warnings, resolveLocale) => {
100
+ const restoreFieldData = (field, target, data, pathIndex, warnings, unavailableByArray, resolveLocale) => {
60
101
  if (field.type === 'group') {
61
- return restoreGroupFieldData(field, target, data, pathIndex, warnings, resolveLocale);
102
+ return restoreGroupFieldData(field, target, data, pathIndex, warnings, unavailableByArray, resolveLocale);
62
103
  }
63
104
  else if (field.type === 'array') {
64
- return restoreArrayFieldData(field, target, data, pathIndex, warnings, resolveLocale);
105
+ return restoreArrayFieldData(field, target, data, pathIndex, warnings, unavailableByArray, resolveLocale);
65
106
  }
66
107
  else if (field.type === 'blocks') {
67
- return restoreBlocksFieldData(field, target, data, pathIndex, warnings, resolveLocale);
108
+ return restoreBlocksFieldData(field, target, data, pathIndex, warnings, unavailableByArray, resolveLocale);
68
109
  }
69
110
  else if (field.type === 'relation' && field.hasMany) {
70
111
  return restoreMultiRelationFieldData(target, data, pathIndex, warnings);
@@ -95,7 +136,7 @@ const restoreFieldData = (field, target, data, pathIndex, warnings, resolveLocal
95
136
  }
96
137
  return target;
97
138
  };
98
- const restoreGroupFieldData = (field, target, data, pathIndex, warnings, resolveLocale) => {
139
+ const restoreGroupFieldData = (field, target, data, pathIndex, warnings, unavailableByArray, resolveLocale) => {
99
140
  if (pathIndex >= data.field_path.length) {
100
141
  warnings.push(`Path ended unexpectedly while restoring group: ${data.field_path.join('.')}`);
101
142
  return target;
@@ -108,10 +149,10 @@ const restoreGroupFieldData = (field, target, data, pathIndex, warnings, resolve
108
149
  return target;
109
150
  }
110
151
  target = target || {};
111
- target[fieldName] = restoreFieldData(subField, target[fieldName], data, pathIndex + 1, warnings, resolveLocale);
152
+ target[fieldName] = restoreFieldData(subField, target[fieldName], data, pathIndex + 1, warnings, unavailableByArray, resolveLocale);
112
153
  return target;
113
154
  };
114
- const restoreArrayFieldData = (field, target, data, pathIndex, warnings, resolveLocale) => {
155
+ const restoreArrayFieldData = (field, target, data, pathIndex, warnings, unavailableByArray, resolveLocale) => {
115
156
  if (pathIndex >= data.field_path.length) {
116
157
  warnings.push(`Path ended unexpectedly while restoring array: ${data.field_path.join('.')}`);
117
158
  return target;
@@ -140,10 +181,10 @@ const restoreArrayFieldData = (field, target, data, pathIndex, warnings, resolve
140
181
  // Sub-field was removed from the schema; silently skip orphaned rows.
141
182
  return target;
142
183
  }
143
- target[arrayIndex][fieldName] = restoreFieldData(subField, target[arrayIndex][fieldName], data, pathIndex + 2, warnings, resolveLocale);
184
+ target[arrayIndex][fieldName] = restoreFieldData(subField, target[arrayIndex][fieldName], data, pathIndex + 2, warnings, unavailableByArray, resolveLocale);
144
185
  return target;
145
186
  };
146
- const restoreBlocksFieldData = (field, target, data, pathIndex, warnings, resolveLocale) => {
187
+ const restoreBlocksFieldData = (field, target, data, pathIndex, warnings, unavailableByArray, resolveLocale) => {
147
188
  const arrayIndex = Number.parseInt(data.field_path[pathIndex] ?? '', 10);
148
189
  if (Number.isNaN(arrayIndex) || arrayIndex < 0) {
149
190
  warnings.push(`Invalid block index '${data.field_path[pathIndex]}' in path ${data.field_path.join('.')}`);
@@ -156,7 +197,17 @@ const restoreBlocksFieldData = (field, target, data, pathIndex, warnings, resolv
156
197
  }
157
198
  const block = field.blocks.find((f) => f.blockType === blockType);
158
199
  if (block == null) {
159
- // Block type was removed from the schema; silently skip orphaned rows.
200
+ target = target || [];
201
+ let issues = unavailableByArray.get(target);
202
+ if (!issues) {
203
+ issues = new Map();
204
+ unavailableByArray.set(target, issues);
205
+ }
206
+ const issue = issues.get(arrayIndex) ?? { storedIndex: arrayIndex, blockType };
207
+ if (data.field_type === 'meta' && pathIndex + 2 === data.field_path.length) {
208
+ issue.id = data.item_id;
209
+ }
210
+ issues.set(arrayIndex, issue);
160
211
  return target;
161
212
  }
162
213
  target = target || [];
@@ -178,7 +229,7 @@ const restoreBlocksFieldData = (field, target, data, pathIndex, warnings, resolv
178
229
  // Sub-field was removed from the schema; silently skip orphaned rows.
179
230
  return target;
180
231
  }
181
- target[arrayIndex][fieldName] = restoreFieldData(subField, target[arrayIndex][fieldName], data, pathIndex + 3, warnings, resolveLocale);
232
+ target[arrayIndex][fieldName] = restoreFieldData(subField, target[arrayIndex][fieldName], data, pathIndex + 3, warnings, unavailableByArray, resolveLocale);
182
233
  return target;
183
234
  };
184
235
  const restoreMultiRelationFieldData = (target, data, pathIndex, warnings) => {
@@ -0,0 +1,8 @@
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 {};
@@ -0,0 +1,132 @@
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 { describe, expect, it, vi } from 'vitest';
9
+ import { getLogger } from '../lib/logger.js';
10
+ import { applyPatches } from '../patches/apply-patches.js';
11
+ import { validateDocumentFields } from '../validation/document-fields.js';
12
+ import { flattenFieldSetData } from './storage-flatten.js';
13
+ import { restoreFieldSetData } from './storage-restore.js';
14
+ const logger = vi.hoisted(() => ({ warn: vi.fn() }));
15
+ vi.mock('../lib/logger.js', () => ({ getLogger: () => logger }));
16
+ const live = { blockType: 'live', fields: [{ name: 'text', type: 'text' }] };
17
+ const retired = { ...live, blockType: 'retired' };
18
+ const blocks = (optional = false) => ({
19
+ name: 'content',
20
+ type: 'blocks',
21
+ optional,
22
+ blocks: [live, retired],
23
+ });
24
+ const item = (_type, i) => ({ _id: `item-${i}`, _type, text: `${_type}-${i}` });
25
+ describe('unavailable blocks', () => {
26
+ for (const optional of [false, true]) {
27
+ for (const types of [
28
+ ['retired', 'live'],
29
+ ['live', 'retired', 'live'],
30
+ ['live', 'retired'],
31
+ ['retired'],
32
+ ['retired', 'retired', 'live'],
33
+ ]) {
34
+ it(`compacts ${types.join(',')} (optional=${optional}) with an independent report`, () => {
35
+ const before = [blocks(optional)];
36
+ const after = [{ ...blocks(optional), blocks: [live] }];
37
+ const original = { content: types.map(item) };
38
+ const rows = flattenFieldSetData(before, original, 'en');
39
+ const restored = restoreFieldSetData(after, rows, 'en');
40
+ expect(restored.warnings).toEqual([]);
41
+ expect(restored.unavailableBlocks).toEqual(types.flatMap((type, storedIndex) => type === 'retired'
42
+ ? [{ fieldPath: 'content', storedIndex, blockType: type, id: `item-${storedIndex}` }]
43
+ : []));
44
+ expect(restored.data.content).toEqual(original.content.filter((item) => item._type === 'live'));
45
+ for (const data of [restored.data, JSON.parse(JSON.stringify(restored.data))]) {
46
+ expect(validateDocumentFields(after, data)).toEqual([]);
47
+ const saved = restoreFieldSetData(after, flattenFieldSetData(after, data, 'en'), 'en');
48
+ expect(saved.unavailableBlocks).toEqual([]);
49
+ expect(saved.data.content ?? []).toEqual(restored.data.content);
50
+ }
51
+ });
52
+ }
53
+ }
54
+ it('reports groups, arrays and nested blocks using compacted parent indices', () => {
55
+ const before = [
56
+ { name: 'group', type: 'group', fields: [blocks()] },
57
+ { name: 'sections', type: 'array', fields: [blocks()] },
58
+ {
59
+ name: 'outer',
60
+ type: 'blocks',
61
+ blocks: [retired, { blockType: 'container', fields: [blocks()] }],
62
+ },
63
+ ];
64
+ const after = [
65
+ { name: 'group', type: 'group', fields: [{ ...blocks(), blocks: [live] }] },
66
+ { name: 'sections', type: 'array', fields: [{ ...blocks(), blocks: [live] }] },
67
+ {
68
+ name: 'outer',
69
+ type: 'blocks',
70
+ blocks: [{ blockType: 'container', fields: [{ ...blocks(), blocks: [live] }] }],
71
+ },
72
+ ];
73
+ const content = [item('retired', 0), item('live', 1)];
74
+ const rows = flattenFieldSetData(before, {
75
+ group: { content },
76
+ sections: [{ content }],
77
+ outer: [item('retired', 0), { _type: 'container', content }],
78
+ }, 'en');
79
+ const result = restoreFieldSetData(after, rows.reverse(), 'en');
80
+ expect(result.unavailableBlocks.map((issue) => issue.fieldPath)).toEqual([
81
+ 'group.content',
82
+ 'sections[0].content',
83
+ 'outer',
84
+ 'outer[0].content',
85
+ ]);
86
+ expect(result.warnings).toEqual([]);
87
+ expect(result.data.outer[0].content).toEqual([item('live', 1)]);
88
+ });
89
+ it('applies a positional patch to a surviving block after the missing card', () => {
90
+ const before = [blocks()];
91
+ const after = [{ ...blocks(), blocks: [live] }];
92
+ const source = { content: ['retired', 'live', 'live'].map(item) };
93
+ const { data } = restoreFieldSetData(after, flattenFieldSetData(before, source, 'en'));
94
+ const definition = {
95
+ path: 'pages',
96
+ labels: { singular: 'Page', plural: 'Pages' },
97
+ fields: after,
98
+ };
99
+ const patched = applyPatches(definition, data, [
100
+ { kind: 'field.set', path: 'content[1].text', value: 'edited' },
101
+ ]);
102
+ expect(patched.errors).toEqual([]);
103
+ const saved = restoreFieldSetData(after, flattenFieldSetData(after, patched.doc, 'en'));
104
+ expect(saved.data.content.map((item) => [item._id, item.text])).toEqual([
105
+ ['item-1', 'live-1'],
106
+ ['item-2', 'edited'],
107
+ ]);
108
+ });
109
+ it.each([new Array(1), [null], [undefined], [42], [[]]].map((content) => ({ content })))('rejects malformed items without a destructure crash: $content', ({ content }) => {
110
+ expect(validateDocumentFields([blocks()], { content })).not.toEqual([]);
111
+ expect(() => flattenFieldSetData([blocks()], { content }, 'en')).toThrow(/Block item is missing or must be an object/);
112
+ });
113
+ it('logs once per document version without adding a strict-read warning', () => {
114
+ const spy = vi.spyOn(getLogger(), 'warn').mockImplementation(() => { });
115
+ try {
116
+ const rows = flattenFieldSetData([blocks()], { content: [item('retired', 0)] }, 'en');
117
+ const identity = { collectionPath: 'pages', documentId: crypto.randomUUID(), versionId: 'v1' };
118
+ for (let i = 0; i < 2; i++)
119
+ expect(restoreFieldSetData([{ ...blocks(), blocks: [live] }], rows, 'en', identity).warnings).toEqual([]);
120
+ expect(spy).toHaveBeenCalledTimes(1);
121
+ expect(spy).toHaveBeenCalledWith({ ...identity, blockTypes: ['retired'] }, expect.any(String));
122
+ restoreFieldSetData([{ ...blocks(), blocks: [live] }], rows, 'en', {
123
+ ...identity,
124
+ versionId: 'v2',
125
+ });
126
+ expect(spy).toHaveBeenCalledTimes(2);
127
+ }
128
+ finally {
129
+ spy.mockRestore();
130
+ }
131
+ });
132
+ });
@@ -119,13 +119,16 @@ export function validateDocumentFields(fields, data, options = {}) {
119
119
  if (field.validation?.maxLength != null && value.length > field.validation.maxLength)
120
120
  add(path, `${label} allows at most ${field.validation.maxLength} items`);
121
121
  }
122
- value.forEach((item, index) => {
122
+ for (let index = 0; index < value.length; index++) {
123
+ const item = value[index];
123
124
  const itemPath = record(item) && typeof item._id === 'string' && /^[\w-]+$/.test(item._id)
124
125
  ? `${path}[id=${item._id}]`
125
126
  : `${path}[${index}]`;
126
127
  if (!record(item)) {
127
- add(itemPath, 'Item must be an object');
128
- return;
128
+ add(itemPath, field.type === 'blocks'
129
+ ? 'Block item is missing or must be an object'
130
+ : 'Item must be an object');
131
+ continue;
129
132
  }
130
133
  if (field.type === 'array')
131
134
  visit(field.fields, item, itemPath);
@@ -136,7 +139,7 @@ export function validateDocumentFields(fields, data, options = {}) {
136
139
  else
137
140
  visit(block.fields, item, itemPath);
138
141
  }
139
- });
142
+ }
140
143
  }
141
144
  }
142
145
  else {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "6.8.0",
5
+ "version": "6.10.0",
6
6
  "engines": {
7
7
  "node": ">=22.22.2"
8
8
  },
@@ -82,7 +82,7 @@
82
82
  "dist"
83
83
  ],
84
84
  "dependencies": {
85
- "@byline/auth": "6.8.0",
85
+ "@byline/auth": "6.10.0",
86
86
  "dotenv": "^18.0.8",
87
87
  "pino": "^10.4.0",
88
88
  "sharp": "^0.35.5",
@@ -90,15 +90,15 @@
90
90
  "zod": "^4.6.7"
91
91
  },
92
92
  "devDependencies": {
93
- "@biomejs/biome": "2.5.15",
93
+ "@biomejs/biome": "2.6.2",
94
94
  "@types/node": "^26.6.5",
95
- "chokidar": "^5.0.0",
95
+ "chokidar": "^6.0.0",
96
96
  "chokidar-cli": "^3.0.0",
97
97
  "npm-run-all": "^4.1.5",
98
- "tsc-alias": "^1.9.7",
98
+ "tsc-alias": "^1.9.8",
99
99
  "tsx": "^4.23.15",
100
100
  "typescript": "^7.0.2",
101
- "vitest": "^5.0.4"
101
+ "vitest": "^5.0.5"
102
102
  },
103
103
  "publishConfig": {
104
104
  "access": "public",