@byline/core 3.19.0 → 3.20.1
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/field-types.d.ts +40 -0
- package/dist/config/validate-collections.d.ts +3 -0
- package/dist/config/validate-collections.js +101 -0
- package/dist/config/validate-collections.test.node.js +150 -0
- package/dist/patches/apply-patches.js +30 -5
- package/dist/patches/patch.test.node.js +80 -0
- package/package.json +2 -2
|
@@ -221,6 +221,46 @@ interface BaseField {
|
|
|
221
221
|
* `optional: true` or a `defaultValue`.
|
|
222
222
|
*/
|
|
223
223
|
condition?: FieldCondition;
|
|
224
|
+
/**
|
|
225
|
+
* When `true`, the field is **virtual**: its value participates in the
|
|
226
|
+
* editing session and the write pipeline, but is **never persisted** and
|
|
227
|
+
* therefore never read back.
|
|
228
|
+
*
|
|
229
|
+
* Lifecycle of a virtual value:
|
|
230
|
+
*
|
|
231
|
+
* 1. The admin renders and edits the field normally; its value travels
|
|
232
|
+
* with the save (as `field.set` patches / document data) like any
|
|
233
|
+
* other field.
|
|
234
|
+
* 2. Every lifecycle hook sees it — `beforeCreate` / `beforeUpdate`
|
|
235
|
+
* (and the `after*` counterparts) receive the in-flight `data` with
|
|
236
|
+
* virtual values present, so hooks can act on them (the canonical
|
|
237
|
+
* use case: a "generate thumbnail" checkbox that triggers cover
|
|
238
|
+
* generation during the save).
|
|
239
|
+
* 3. The storage layer's flatten step skips virtual fields wholesale —
|
|
240
|
+
* no `store_*` row is ever written. This is the single enforcement
|
|
241
|
+
* point; adapters implementing `IDbAdapter` MUST honour it (see
|
|
242
|
+
* `@byline/db-postgres` → storage-flatten).
|
|
243
|
+
* 4. Reads reconstruct from stored rows, so the value is structurally
|
|
244
|
+
* absent afterwards: the next editing session starts with the field
|
|
245
|
+
* empty / at its `defaultValue`, and saves that don't touch it can
|
|
246
|
+
* never re-trigger whatever side effect it drives.
|
|
247
|
+
*
|
|
248
|
+
* Applies at any nesting depth — a virtual field inside an `array` item
|
|
249
|
+
* `group` (e.g. `files[].filesGroup.generateThumbnail`) is skipped per
|
|
250
|
+
* item. Setting `virtual` on a structure field (`group` / `array` /
|
|
251
|
+
* `blocks`) omits the entire subtree.
|
|
252
|
+
*
|
|
253
|
+
* Constraints (enforced at boot by `validateCollections`):
|
|
254
|
+
* - A virtual field must be `optional: true` **or** declare a
|
|
255
|
+
* `defaultValue` — the value is absent on every read, so a required
|
|
256
|
+
* virtual field could never validate on a subsequent save.
|
|
257
|
+
* - `counter` fields cannot be virtual (allocator-owned).
|
|
258
|
+
* - Upload-capable `file` / `image` fields cannot be virtual (their
|
|
259
|
+
* stored bytes are a side effect that "not persisting" can't undo).
|
|
260
|
+
* - Fields referenced by `useAsTitle` / `useAsPath` / `search` cannot
|
|
261
|
+
* be virtual (those subsystems read persisted values).
|
|
262
|
+
*/
|
|
263
|
+
virtual?: boolean;
|
|
224
264
|
/**
|
|
225
265
|
* Optional submit-time validator. Called by `validateForm()` for every field
|
|
226
266
|
* type — including structure fields (group, array, blocks).
|
|
@@ -33,6 +33,9 @@ export declare const RESERVED_FIELD_NAMES: ReadonlySet<string>;
|
|
|
33
33
|
* - A collection may not set both `tree: true` and `orderable: true`. A
|
|
34
34
|
* document-tree owns ordering per-parent on the tree edge, so
|
|
35
35
|
* `byline_documents.order_key` is inert for it.
|
|
36
|
+
* - `virtual` fields must satisfy the constraints in
|
|
37
|
+
* {@link validateVirtualFields} (optional-or-default, no counters, no
|
|
38
|
+
* upload fields, not referenced by useAsTitle / useAsPath / search).
|
|
36
39
|
*
|
|
37
40
|
* Throws a plain `Error` (not a `BylineError`) because configuration
|
|
38
41
|
* validation runs at startup, before the logger and error registry are
|
|
@@ -64,6 +64,103 @@ function walkFields(fields, visit) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Path-aware variant of `walkFields` — visits every field with its dotted
|
|
69
|
+
* schema path (e.g. `files.filesGroup.generateThumbnail`) so validation
|
|
70
|
+
* errors can point at the exact declaration site.
|
|
71
|
+
*/
|
|
72
|
+
function walkFieldsWithPath(fields, visit, prefix = '') {
|
|
73
|
+
for (const field of fields) {
|
|
74
|
+
const fieldPath = prefix === '' ? field.name : `${prefix}.${field.name}`;
|
|
75
|
+
visit(field, fieldPath);
|
|
76
|
+
if (field.type === 'group' || field.type === 'array') {
|
|
77
|
+
walkFieldsWithPath(field.fields, visit, fieldPath);
|
|
78
|
+
}
|
|
79
|
+
else if (field.type === 'blocks') {
|
|
80
|
+
for (const block of field.blocks) {
|
|
81
|
+
walkFieldsWithPath(block.fields, visit, fieldPath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Top-level field names a collection's `search` config references (body +
|
|
88
|
+
* facets + filters). Used to reject `virtual` on searchable fields —
|
|
89
|
+
* search indexing reads persisted values, which virtual fields never have.
|
|
90
|
+
*/
|
|
91
|
+
function searchReferencedFieldNames(collection) {
|
|
92
|
+
const names = new Set();
|
|
93
|
+
const search = collection.search;
|
|
94
|
+
if (search == null)
|
|
95
|
+
return names;
|
|
96
|
+
for (const decl of search.body ?? []) {
|
|
97
|
+
names.add(typeof decl === 'string' ? decl : decl.field);
|
|
98
|
+
}
|
|
99
|
+
for (const decl of search.facets ?? []) {
|
|
100
|
+
names.add(typeof decl === 'string' ? decl : decl.field);
|
|
101
|
+
}
|
|
102
|
+
for (const name of search.filters ?? []) {
|
|
103
|
+
names.add(name);
|
|
104
|
+
}
|
|
105
|
+
return names;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Enforce the `virtual` field constraints for one collection. See
|
|
109
|
+
* `BaseField.virtual` in field-types.ts for the contract these rules back:
|
|
110
|
+
*
|
|
111
|
+
* - virtual ⇒ `optional: true` OR a `defaultValue` — the value is absent
|
|
112
|
+
* on every read, so a required virtual field could never validate on a
|
|
113
|
+
* subsequent save.
|
|
114
|
+
* - `counter` fields cannot be virtual (allocator-assigned, and the
|
|
115
|
+
* lifecycle layer must be able to carry the value forward).
|
|
116
|
+
* - Upload-capable `file` / `image` fields cannot be virtual — their
|
|
117
|
+
* stored bytes are a side effect that "not persisting" can't undo.
|
|
118
|
+
* - Fields referenced by `useAsTitle` / `useAsPath` / `search` cannot be
|
|
119
|
+
* virtual — those subsystems read persisted values.
|
|
120
|
+
*/
|
|
121
|
+
function validateVirtualFields(collection) {
|
|
122
|
+
const searchNames = searchReferencedFieldNames(collection);
|
|
123
|
+
walkFieldsWithPath(collection.fields, (field, fieldPath) => {
|
|
124
|
+
if (field.virtual !== true)
|
|
125
|
+
return;
|
|
126
|
+
const hasDefault = 'defaultValue' in field && field.defaultValue !== undefined;
|
|
127
|
+
if (field.optional !== true && !hasDefault) {
|
|
128
|
+
throw new Error(`Collection "${collection.path}" declares virtual field "${fieldPath}" without ` +
|
|
129
|
+
'`optional: true` or a `defaultValue`. Virtual values are never persisted, so the ' +
|
|
130
|
+
'field is absent on every read — a required virtual field could never validate on a ' +
|
|
131
|
+
'subsequent save.');
|
|
132
|
+
}
|
|
133
|
+
if (field.type === 'counter') {
|
|
134
|
+
throw new Error(`Collection "${collection.path}" declares virtual counter field "${fieldPath}". ` +
|
|
135
|
+
'Counter values are allocator-assigned and carried forward across versions — they ' +
|
|
136
|
+
'cannot be virtual.');
|
|
137
|
+
}
|
|
138
|
+
if ((field.type === 'file' || field.type === 'image') && field.upload != null) {
|
|
139
|
+
throw new Error(`Collection "${collection.path}" declares virtual upload field "${fieldPath}". ` +
|
|
140
|
+
'Upload fields write bytes to storage as a side effect, which "not persisting" the ' +
|
|
141
|
+
'field value cannot undo — remove `virtual` or the `upload` block.');
|
|
142
|
+
}
|
|
143
|
+
// Collection-level directives read persisted values; a virtual source
|
|
144
|
+
// would resolve to nothing on every read. These only apply to top-level
|
|
145
|
+
// fields (nested paths never match a directive's field name).
|
|
146
|
+
const isTopLevel = !fieldPath.includes('.');
|
|
147
|
+
if (isTopLevel) {
|
|
148
|
+
if (collection.useAsTitle === field.name) {
|
|
149
|
+
throw new Error(`Collection "${collection.path}" sets \`useAsTitle: '${field.name}'\` but that field ` +
|
|
150
|
+
'is virtual — titles are read from persisted values.');
|
|
151
|
+
}
|
|
152
|
+
if (collection.useAsPath === field.name) {
|
|
153
|
+
throw new Error(`Collection "${collection.path}" sets \`useAsPath: '${field.name}'\` but that field ` +
|
|
154
|
+
'is virtual — paths are derived from persisted values.');
|
|
155
|
+
}
|
|
156
|
+
if (searchNames.has(field.name)) {
|
|
157
|
+
throw new Error(`Collection "${collection.path}" references virtual field "${field.name}" in its ` +
|
|
158
|
+
'`search` config — search indexing reads persisted values, which virtual fields ' +
|
|
159
|
+
'never have.');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
67
164
|
/**
|
|
68
165
|
* Validate every collection in a configuration.
|
|
69
166
|
*
|
|
@@ -83,6 +180,9 @@ function walkFields(fields, visit) {
|
|
|
83
180
|
* - A collection may not set both `tree: true` and `orderable: true`. A
|
|
84
181
|
* document-tree owns ordering per-parent on the tree edge, so
|
|
85
182
|
* `byline_documents.order_key` is inert for it.
|
|
183
|
+
* - `virtual` fields must satisfy the constraints in
|
|
184
|
+
* {@link validateVirtualFields} (optional-or-default, no counters, no
|
|
185
|
+
* upload fields, not referenced by useAsTitle / useAsPath / search).
|
|
86
186
|
*
|
|
87
187
|
* Throws a plain `Error` (not a `BylineError`) because configuration
|
|
88
188
|
* validation runs at startup, before the logger and error registry are
|
|
@@ -111,5 +211,6 @@ export function validateCollections(collections) {
|
|
|
111
211
|
if (collection.tree === true && collection.orderable === true) {
|
|
112
212
|
throw new Error(`Collection "${collection.path}" sets both \`tree: true\` and \`orderable: true\`. A document-tree collection owns ordering on the tree edge (per-parent), so \`byline_documents.order_key\` is inert — set only \`tree: true\`.`);
|
|
113
213
|
}
|
|
214
|
+
validateVirtualFields(collection);
|
|
114
215
|
}
|
|
115
216
|
}
|
|
@@ -260,4 +260,154 @@ describe('validateCollections', () => {
|
|
|
260
260
|
};
|
|
261
261
|
expect(() => validateCollections([collection])).not.toThrow();
|
|
262
262
|
});
|
|
263
|
+
// -------------------------------------------------------------------------
|
|
264
|
+
// Virtual fields
|
|
265
|
+
// -------------------------------------------------------------------------
|
|
266
|
+
it('accepts an optional virtual field', () => {
|
|
267
|
+
const collection = {
|
|
268
|
+
...baseCollection,
|
|
269
|
+
fields: [
|
|
270
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
271
|
+
{
|
|
272
|
+
name: 'regenerate',
|
|
273
|
+
label: 'Regenerate',
|
|
274
|
+
type: 'checkbox',
|
|
275
|
+
virtual: true,
|
|
276
|
+
optional: true,
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
expect(() => validateCollections([collection])).not.toThrow();
|
|
281
|
+
});
|
|
282
|
+
it('accepts a virtual field with a defaultValue', () => {
|
|
283
|
+
const collection = {
|
|
284
|
+
...baseCollection,
|
|
285
|
+
fields: [
|
|
286
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
287
|
+
{ name: 'page', label: 'Page', type: 'integer', virtual: true, defaultValue: 1 },
|
|
288
|
+
],
|
|
289
|
+
};
|
|
290
|
+
expect(() => validateCollections([collection])).not.toThrow();
|
|
291
|
+
});
|
|
292
|
+
it('rejects a required virtual field with no defaultValue', () => {
|
|
293
|
+
const collection = {
|
|
294
|
+
...baseCollection,
|
|
295
|
+
fields: [
|
|
296
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
297
|
+
{ name: 'regenerate', label: 'Regenerate', type: 'checkbox', virtual: true },
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
expect(() => validateCollections([collection])).toThrow(/virtual field "regenerate".*optional.*defaultValue/s);
|
|
301
|
+
});
|
|
302
|
+
it('rejects a required virtual field nested in an array group — error names the full path', () => {
|
|
303
|
+
const collection = {
|
|
304
|
+
...baseCollection,
|
|
305
|
+
fields: [
|
|
306
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
307
|
+
{
|
|
308
|
+
name: 'files',
|
|
309
|
+
label: 'Files',
|
|
310
|
+
type: 'array',
|
|
311
|
+
fields: [
|
|
312
|
+
{
|
|
313
|
+
name: 'filesGroup',
|
|
314
|
+
type: 'group',
|
|
315
|
+
fields: [
|
|
316
|
+
{ name: 'generateThumbnail', label: 'Generate', type: 'checkbox', virtual: true },
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
expect(() => validateCollections([collection])).toThrow(/files\.filesGroup\.generateThumbnail/);
|
|
324
|
+
});
|
|
325
|
+
it('rejects a virtual counter field', () => {
|
|
326
|
+
const collection = {
|
|
327
|
+
...baseCollection,
|
|
328
|
+
fields: [
|
|
329
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
330
|
+
{
|
|
331
|
+
name: 'serial',
|
|
332
|
+
label: 'Serial',
|
|
333
|
+
type: 'counter',
|
|
334
|
+
group: 'serials',
|
|
335
|
+
virtual: true,
|
|
336
|
+
optional: true,
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
};
|
|
340
|
+
expect(() => validateCollections([collection])).toThrow(/virtual counter field/);
|
|
341
|
+
});
|
|
342
|
+
it('rejects a virtual upload-capable file field', () => {
|
|
343
|
+
const collection = {
|
|
344
|
+
...baseCollection,
|
|
345
|
+
fields: [
|
|
346
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
347
|
+
{
|
|
348
|
+
name: 'attachment',
|
|
349
|
+
label: 'Attachment',
|
|
350
|
+
type: 'file',
|
|
351
|
+
virtual: true,
|
|
352
|
+
optional: true,
|
|
353
|
+
upload: { mimeTypes: ['application/pdf'] },
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
expect(() => validateCollections([collection])).toThrow(/virtual upload field/);
|
|
358
|
+
});
|
|
359
|
+
it('accepts a virtual file field WITHOUT an upload block', () => {
|
|
360
|
+
const collection = {
|
|
361
|
+
...baseCollection,
|
|
362
|
+
fields: [
|
|
363
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
364
|
+
{ name: 'scratchFile', label: 'Scratch', type: 'file', virtual: true, optional: true },
|
|
365
|
+
],
|
|
366
|
+
};
|
|
367
|
+
expect(() => validateCollections([collection])).not.toThrow();
|
|
368
|
+
});
|
|
369
|
+
it('rejects useAsTitle pointing at a virtual field', () => {
|
|
370
|
+
const collection = {
|
|
371
|
+
...baseCollection,
|
|
372
|
+
useAsTitle: 'ephemeral',
|
|
373
|
+
fields: [
|
|
374
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
375
|
+
{ name: 'ephemeral', label: 'Ephemeral', type: 'text', virtual: true, optional: true },
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
expect(() => validateCollections([collection])).toThrow(/useAsTitle.*virtual/s);
|
|
379
|
+
});
|
|
380
|
+
it('rejects useAsPath pointing at a virtual field', () => {
|
|
381
|
+
const collection = {
|
|
382
|
+
...baseCollection,
|
|
383
|
+
useAsPath: 'ephemeral',
|
|
384
|
+
fields: [
|
|
385
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
386
|
+
{ name: 'ephemeral', label: 'Ephemeral', type: 'text', virtual: true, optional: true },
|
|
387
|
+
],
|
|
388
|
+
};
|
|
389
|
+
expect(() => validateCollections([collection])).toThrow(/useAsPath.*virtual/s);
|
|
390
|
+
});
|
|
391
|
+
it('rejects a search body entry referencing a virtual field', () => {
|
|
392
|
+
const collection = {
|
|
393
|
+
...baseCollection,
|
|
394
|
+
search: { body: ['title', 'ephemeral'] },
|
|
395
|
+
fields: [
|
|
396
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
397
|
+
{ name: 'ephemeral', label: 'Ephemeral', type: 'text', virtual: true, optional: true },
|
|
398
|
+
],
|
|
399
|
+
};
|
|
400
|
+
expect(() => validateCollections([collection])).toThrow(/search/);
|
|
401
|
+
});
|
|
402
|
+
it('accepts a virtual field that search does not reference', () => {
|
|
403
|
+
const collection = {
|
|
404
|
+
...baseCollection,
|
|
405
|
+
search: { body: ['title'] },
|
|
406
|
+
fields: [
|
|
407
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
408
|
+
{ name: 'ephemeral', label: 'Ephemeral', type: 'text', virtual: true, optional: true },
|
|
409
|
+
],
|
|
410
|
+
};
|
|
411
|
+
expect(() => validateCollections([collection])).not.toThrow();
|
|
412
|
+
});
|
|
263
413
|
});
|
|
@@ -218,6 +218,28 @@ function resolveArrayAtPath(doc, path) {
|
|
|
218
218
|
}
|
|
219
219
|
return parent[key];
|
|
220
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Resolve an `itemId` to an array index: stable `_id` match first, then —
|
|
223
|
+
* only when `itemId` is a *pure* integer string — an index fallback for
|
|
224
|
+
* items that have no stable id yet (e.g. added client-side by an older
|
|
225
|
+
* admin that did not assign `_id` at add time). The strict `^\d+$` test
|
|
226
|
+
* matters: `Number.parseInt` accepts leading digits, so a real-but-absent
|
|
227
|
+
* uuid like `'3f2a…'` would otherwise silently resolve to index 3 and the
|
|
228
|
+
* patch would hit the wrong item.
|
|
229
|
+
*
|
|
230
|
+
* Returns -1 when the item cannot be located either way.
|
|
231
|
+
*/
|
|
232
|
+
function resolveItemIndex(array, itemId) {
|
|
233
|
+
const byId = array.findIndex((item) => item && item._id === itemId);
|
|
234
|
+
if (byId !== -1)
|
|
235
|
+
return byId;
|
|
236
|
+
if (/^\d+$/.test(itemId)) {
|
|
237
|
+
const index = Number.parseInt(itemId, 10);
|
|
238
|
+
if (index >= 0 && index < array.length)
|
|
239
|
+
return index;
|
|
240
|
+
}
|
|
241
|
+
return -1;
|
|
242
|
+
}
|
|
221
243
|
function applyArrayPatch(doc, patch, definition) {
|
|
222
244
|
const array = resolveArrayAtPath(doc, patch.path);
|
|
223
245
|
if (patch.kind === 'array.insert') {
|
|
@@ -225,10 +247,8 @@ function applyArrayPatch(doc, patch, definition) {
|
|
|
225
247
|
array.splice(index, 0, patch.item);
|
|
226
248
|
}
|
|
227
249
|
else if (patch.kind === 'array.move') {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
const index = currentIndex === -1 ? Number.parseInt(patch.itemId, 10) : currentIndex;
|
|
231
|
-
if (!Number.isFinite(index) || index < 0 || index >= array.length) {
|
|
250
|
+
const index = resolveItemIndex(array, patch.itemId);
|
|
251
|
+
if (index === -1) {
|
|
232
252
|
throw new Error(`array.move: item with idOrIndex=${patch.itemId} not found`);
|
|
233
253
|
}
|
|
234
254
|
const [moved] = array.splice(index, 1);
|
|
@@ -236,8 +256,13 @@ function applyArrayPatch(doc, patch, definition) {
|
|
|
236
256
|
array.splice(toIndex, 0, moved);
|
|
237
257
|
}
|
|
238
258
|
else if (patch.kind === 'array.remove') {
|
|
239
|
-
const index = array
|
|
259
|
+
const index = resolveItemIndex(array, patch.itemId);
|
|
240
260
|
if (index === -1) {
|
|
261
|
+
// Removal is idempotent — an item that is already gone stays gone.
|
|
262
|
+
// (Historically this branch also swallowed a client bug where the
|
|
263
|
+
// admin sent a positional id for `_id`-carrying items; the admin now
|
|
264
|
+
// sends `_id` and the index fallback above covers id-less items, so
|
|
265
|
+
// reaching here should be rare.)
|
|
241
266
|
return;
|
|
242
267
|
}
|
|
243
268
|
array.splice(index, 1);
|
|
@@ -156,6 +156,86 @@ describe('applyPatches', () => {
|
|
|
156
156
|
const moved = afterMove;
|
|
157
157
|
expect(moved.reviews.map((r) => r._id)).toEqual(['c', 'a', 'b']);
|
|
158
158
|
});
|
|
159
|
+
it('array.remove removes an item by stable _id', () => {
|
|
160
|
+
const original = {
|
|
161
|
+
reviews: [
|
|
162
|
+
{ _id: 'a', rating: 3 },
|
|
163
|
+
{ _id: 'b', rating: 4 },
|
|
164
|
+
{ _id: 'c', rating: 5 },
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
const { doc, errors } = applyPatches(DocsDefinition, original, [
|
|
168
|
+
{ kind: 'array.remove', path: 'reviews', itemId: 'b' },
|
|
169
|
+
]);
|
|
170
|
+
expect(errors).toHaveLength(0);
|
|
171
|
+
const result = doc;
|
|
172
|
+
expect(result.reviews.map((r) => r._id)).toEqual(['a', 'c']);
|
|
173
|
+
});
|
|
174
|
+
it('array.remove falls back to a positional index for items without stable ids', () => {
|
|
175
|
+
// Regression: an admin session can add an item (no _id yet) and remove
|
|
176
|
+
// it in the same save; the remove patch carries the item's index. This
|
|
177
|
+
// previously no-opped silently and the "removed" item reappeared.
|
|
178
|
+
const original = {
|
|
179
|
+
reviews: [{ rating: 3 }, { rating: 4 }, { rating: 5 }],
|
|
180
|
+
};
|
|
181
|
+
const { doc, errors } = applyPatches(DocsDefinition, original, [
|
|
182
|
+
{ kind: 'array.remove', path: 'reviews', itemId: '1' },
|
|
183
|
+
]);
|
|
184
|
+
expect(errors).toHaveLength(0);
|
|
185
|
+
const result = doc;
|
|
186
|
+
expect(result.reviews.map((r) => r.rating)).toEqual([3, 5]);
|
|
187
|
+
});
|
|
188
|
+
it('array.remove of a missing itemId is an idempotent no-op', () => {
|
|
189
|
+
const original = {
|
|
190
|
+
reviews: [{ _id: 'a', rating: 3 }],
|
|
191
|
+
};
|
|
192
|
+
const { doc, errors } = applyPatches(DocsDefinition, original, [
|
|
193
|
+
{ kind: 'array.remove', path: 'reviews', itemId: 'gone' },
|
|
194
|
+
]);
|
|
195
|
+
expect(errors).toHaveLength(0);
|
|
196
|
+
expect(doc.reviews).toHaveLength(1);
|
|
197
|
+
});
|
|
198
|
+
it('does NOT misread a digit-prefixed uuid as an index (strict integer fallback)', () => {
|
|
199
|
+
// Number.parseInt('3f2a…') === 3 — a real-but-absent _id starting with
|
|
200
|
+
// digits must not resolve to a positional index and hit the wrong item.
|
|
201
|
+
const original = {
|
|
202
|
+
reviews: [
|
|
203
|
+
{ _id: 'a', rating: 1 },
|
|
204
|
+
{ _id: 'b', rating: 2 },
|
|
205
|
+
{ _id: 'c', rating: 3 },
|
|
206
|
+
{ _id: 'd', rating: 4 },
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
const removeResult = applyPatches(DocsDefinition, original, [
|
|
210
|
+
{ kind: 'array.remove', path: 'reviews', itemId: '3f2a09c1-dead-beef-0000-000000000000' },
|
|
211
|
+
]);
|
|
212
|
+
expect(removeResult.errors).toHaveLength(0);
|
|
213
|
+
// Nothing removed — in particular NOT the item at index 3.
|
|
214
|
+
expect(removeResult.doc.reviews).toHaveLength(4);
|
|
215
|
+
const moveResult = applyPatches(DocsDefinition, original, [
|
|
216
|
+
{
|
|
217
|
+
kind: 'array.move',
|
|
218
|
+
path: 'reviews',
|
|
219
|
+
itemId: '3f2a09c1-dead-beef-0000-000000000000',
|
|
220
|
+
toIndex: 0,
|
|
221
|
+
},
|
|
222
|
+
]);
|
|
223
|
+
// array.move keeps its throw-on-missing contract.
|
|
224
|
+
expect(moveResult.errors).toHaveLength(1);
|
|
225
|
+
expect(moveResult.errors[0]?.message).toContain('not found');
|
|
226
|
+
});
|
|
227
|
+
it('array.insert followed by array.remove of the same _id within one patch batch', () => {
|
|
228
|
+
// Mirrors the admin flow after the fix: add assigns _id client-side, so
|
|
229
|
+
// add-then-remove in a single editing session round-trips cleanly.
|
|
230
|
+
const original = { reviews: [{ _id: 'a', rating: 3 }] };
|
|
231
|
+
const { doc, errors } = applyPatches(DocsDefinition, original, [
|
|
232
|
+
{ kind: 'array.insert', path: 'reviews', index: 1, item: { _id: 'new-1', rating: 5 } },
|
|
233
|
+
{ kind: 'array.remove', path: 'reviews', itemId: 'new-1' },
|
|
234
|
+
]);
|
|
235
|
+
expect(errors).toHaveLength(0);
|
|
236
|
+
const result = doc;
|
|
237
|
+
expect(result.reviews.map((r) => r._id)).toEqual(['a']);
|
|
238
|
+
});
|
|
159
239
|
it('supports block.add and block.updateField on content blocks', () => {
|
|
160
240
|
const original = {
|
|
161
241
|
content: [],
|
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": "3.
|
|
5
|
+
"version": "3.20.1",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"pino": "^10.3.1",
|
|
77
77
|
"sharp": "^0.35.3",
|
|
78
78
|
"zod": "^4.4.3",
|
|
79
|
-
"@byline/auth": "3.
|
|
79
|
+
"@byline/auth": "3.20.1"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@biomejs/biome": "2.5.2",
|