@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.
@@ -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 currentIndex = array.findIndex((item) => item && item._id === patch.itemId);
229
- // Fallback for arrays without stable ids: treat itemId as an index
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.findIndex((item) => item && item._id === patch.itemId);
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.19.0",
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.19.0"
79
+ "@byline/auth": "3.20.1"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@biomejs/biome": "2.5.2",