@byline/core 6.3.0 → 6.4.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 (29) hide show
  1. package/dist/@types/field-types.d.ts +22 -3
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/schemas/zod/builder.d.ts +1 -1
  5. package/dist/schemas/zod/builder.js +40 -39
  6. package/dist/schemas/zod/builder.test.node.js +2 -2
  7. package/dist/schemas/zod/review-comparison-probe.test.node.d.ts +1 -0
  8. package/dist/schemas/zod/review-comparison-probe.test.node.js +26 -0
  9. package/dist/services/document-lifecycle/create.js +23 -2
  10. package/dist/services/document-lifecycle/persistence.js +38 -0
  11. package/dist/services/document-lifecycle/restore.d.ts +24 -3
  12. package/dist/services/document-lifecycle/restore.js +27 -3
  13. package/dist/services/document-lifecycle.test.node.js +222 -9
  14. package/dist/services/normalize-numeric-fields.d.ts +1 -1
  15. package/dist/services/normalize-numeric-fields.js +38 -13
  16. package/dist/services/normalize-numeric-fields.test.node.js +8 -0
  17. package/dist/services/restore-review-probe.test.node.d.ts +8 -0
  18. package/dist/services/restore-review-probe.test.node.js +3397 -0
  19. package/dist/services/review-probes.test.node.d.ts +8 -0
  20. package/dist/services/review-probes.test.node.js +349 -0
  21. package/dist/services/singleton-lifecycle/index.d.ts +1 -0
  22. package/dist/services/singleton-lifecycle/restore.d.ts +11 -1
  23. package/dist/services/singleton-lifecycle/restore.js +14 -2
  24. package/dist/services/singleton-lifecycle.test.node.js +34 -0
  25. package/dist/validation/document-fields.d.ts +71 -0
  26. package/dist/validation/document-fields.js +218 -0
  27. package/dist/validation/document-fields.test.node.d.ts +8 -0
  28. package/dist/validation/document-fields.test.node.js +290 -0
  29. package/package.json +6 -6
@@ -0,0 +1,218 @@
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 { ERR_VALIDATION, ErrorCodes } from '../lib/errors.js';
9
+ import { fieldToZodSchema } from '../schemas/zod/builder.js';
10
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
11
+ /**
12
+ * Message for a schema-author callback that threw.
13
+ *
14
+ * Deliberately fixed and detail-free. Issue messages cross the server boundary
15
+ * into the editor, and an unexpected exception can carry internal diagnostics —
16
+ * a connection string, a file path, an upstream response. Omitting the stack is
17
+ * not enough, because `error.message` itself is the leak. The real error goes
18
+ * to `onCallbackError` for server-side logging; a message a validator
19
+ * deliberately *returns* is authored for the editor and is kept verbatim.
20
+ */
21
+ const CALLBACK_FAILED = 'could not be validated';
22
+ /**
23
+ * Validate schema data without transforming or stripping persistence values.
24
+ *
25
+ * Never throws. Three things here run schema-author code and can fail on data
26
+ * they did not anticipate: `validate`, `condition`, and the per-field Zod
27
+ * schema, whose `validation.rules` may carry a throwing `custom` predicate or a
28
+ * malformed `pattern` that fails while the schema is being built. A validator
29
+ * calling `value.trim()` meets a historical version that lacks the field. A raw
30
+ * exception would abort the walk, discarding the issues already collected, and
31
+ * escape as an unhandled error rather than a field result.
32
+ *
33
+ * Each site is therefore isolated and a failure recorded as an `invalid` issue,
34
+ * so an ordinary save still refuses the write, and a caller using this for
35
+ * diagnostics only — restore — is never blocked by it. The editor-facing
36
+ * message is fixed; the exception goes to `onCallbackError`.
37
+ */
38
+ export function validateDocumentFields(fields, data, options = {}) {
39
+ const issues = [];
40
+ const add = (field, message, kind = 'invalid') => issues.push({ field, message, kind });
41
+ /**
42
+ * Report a failed callback. The sink is caller-supplied and reaches a logger,
43
+ * so it can fail too — a full disk, a transport error. Diagnostics must never
44
+ * be able to abort the validation they are describing, least of all on the
45
+ * recovery path whose whole purpose is to not be blocked.
46
+ */
47
+ const report = (path, site, error) => {
48
+ try {
49
+ options.onCallbackError?.({ path, site, error });
50
+ }
51
+ catch {
52
+ // Nothing left to report it to.
53
+ }
54
+ };
55
+ const visit = (fieldSet, values, prefix = '') => {
56
+ for (const field of fieldSet) {
57
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
58
+ if (options.skip?.(path))
59
+ continue;
60
+ // A condition that throws is a misconfiguration: keep the field visible
61
+ // (fail closed) and surface it, rather than silently hiding the field.
62
+ if (options.respectConditions && field.condition) {
63
+ let visible = true;
64
+ try {
65
+ visible = Boolean(field.condition(data, values));
66
+ }
67
+ catch (error) {
68
+ report(path, 'condition', error);
69
+ add(path, `${field.label ?? field.name}: ${CALLBACK_FAILED}`);
70
+ }
71
+ if (!visible)
72
+ continue;
73
+ }
74
+ const value = values[field.name];
75
+ if (field.localized && options.locale === 'all' && record(value)) {
76
+ const locales = Object.keys(value);
77
+ if (!locales.length && !field.optional)
78
+ add(path, `${field.label ?? field.name} is required`, 'required');
79
+ for (const locale of locales)
80
+ validate(field, value[locale], `${path}.${locale}`);
81
+ }
82
+ else
83
+ validate(field, value, path);
84
+ }
85
+ };
86
+ const validate = (field, value, path) => {
87
+ const label = field.label ?? field.name;
88
+ if (field.validate) {
89
+ try {
90
+ // A returned message is authored for the editor: keep it verbatim.
91
+ const message = field.validate(value, data);
92
+ if (message)
93
+ add(path, message);
94
+ }
95
+ catch (error) {
96
+ report(path, 'validate', error);
97
+ add(path, `${label}: ${CALLBACK_FAILED}`);
98
+ }
99
+ }
100
+ if (value == null || value === '') {
101
+ if (!field.optional && field.type !== 'counter')
102
+ add(path, `${label} is required`, 'required');
103
+ // Empty values count as absent when checking requiredness.
104
+ return;
105
+ }
106
+ if (field.type === 'group') {
107
+ if (!record(value))
108
+ add(path, `${label} must be an object`);
109
+ else
110
+ visit(field.fields, value, path);
111
+ }
112
+ else if (field.type === 'array' || field.type === 'blocks') {
113
+ if (!Array.isArray(value))
114
+ add(path, `${label} must be an array`);
115
+ else {
116
+ if (field.type === 'array') {
117
+ if (field.validation?.minLength != null && value.length < field.validation.minLength)
118
+ add(path, `${label} requires at least ${field.validation.minLength} items`);
119
+ if (field.validation?.maxLength != null && value.length > field.validation.maxLength)
120
+ add(path, `${label} allows at most ${field.validation.maxLength} items`);
121
+ }
122
+ value.forEach((item, index) => {
123
+ const itemPath = record(item) && typeof item._id === 'string' && /^[\w-]+$/.test(item._id)
124
+ ? `${path}[id=${item._id}]`
125
+ : `${path}[${index}]`;
126
+ if (!record(item)) {
127
+ add(itemPath, 'Item must be an object');
128
+ return;
129
+ }
130
+ if (field.type === 'array')
131
+ visit(field.fields, item, itemPath);
132
+ else {
133
+ const block = field.blocks.find((candidate) => candidate.blockType === item._type);
134
+ if (!block)
135
+ add(itemPath, 'Unknown block type');
136
+ else
137
+ visit(block.fields, item, itemPath);
138
+ }
139
+ });
140
+ }
141
+ }
142
+ else {
143
+ // Both halves are schema-author reachable: `validation.rules` can carry a
144
+ // `custom` predicate that throws through `refine`, and a malformed
145
+ // `pattern` throws from `new RegExp` while the schema is still being
146
+ // built — before any value is parsed.
147
+ try {
148
+ const parsed = fieldToZodSchema(field).safeParse(value);
149
+ if (!parsed.success)
150
+ add(path, `${label}: ${parsed.error.issues[0]?.message ?? 'Invalid value'}`);
151
+ }
152
+ catch (error) {
153
+ report(path, 'schema', error);
154
+ add(path, `${label}: ${CALLBACK_FAILED}`);
155
+ }
156
+ }
157
+ };
158
+ if (!record(data))
159
+ return [{ field: '', message: 'Document fields must be an object', kind: 'invalid' }];
160
+ visit(fields, data);
161
+ return issues;
162
+ }
163
+ /**
164
+ * Enforce the collection's declared field contract over prepared content.
165
+ *
166
+ * Every versioned write passes through this except a restore, which is exempt
167
+ * structurally rather than by a parameter here — see `assertWritableContent`
168
+ * in the lifecycle's `persistence.ts` for why, and for what that exemption
169
+ * does and does not promise.
170
+ */
171
+ export function assertDocumentFields(fields, data, locale, logger) {
172
+ const issues = validateDocumentFields(fields, data, {
173
+ locale,
174
+ onCallbackError: logger
175
+ ? ({ path, site, error }) => logger.error({ err: error, fieldPath: path, site }, 'field validation callback threw')
176
+ : undefined,
177
+ });
178
+ if (issues.length)
179
+ throw ERR_VALIDATION({
180
+ message: 'Some document fields are invalid.',
181
+ details: {
182
+ reason: 'invalid_document_fields',
183
+ issues,
184
+ },
185
+ });
186
+ }
187
+ /** Accept live errors and serialized reports, discarding all non-contract data. */
188
+ export function getDocumentFieldValidationDetails(error) {
189
+ try {
190
+ if (!record(error) || error.code !== ErrorCodes.VALIDATION)
191
+ return null;
192
+ const details = error.details;
193
+ if (!record(details) ||
194
+ details.reason !== 'invalid_document_fields' ||
195
+ !Array.isArray(details.issues) ||
196
+ !details.issues.length)
197
+ return null;
198
+ const issues = [];
199
+ for (const issue of details.issues) {
200
+ if (!record(issue) ||
201
+ typeof issue.field !== 'string' ||
202
+ typeof issue.message !== 'string' ||
203
+ !issue.message.trim())
204
+ return null;
205
+ // An unrecognised or absent `kind` decodes as `invalid` — the blocking
206
+ // reading. A waiver must never be inferred from a malformed payload.
207
+ issues.push({
208
+ field: issue.field,
209
+ message: issue.message,
210
+ kind: issue.kind === 'required' ? 'required' : 'invalid',
211
+ });
212
+ }
213
+ return { reason: 'invalid_document_fields', issues };
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ }
@@ -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,290 @@
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 } from 'vitest';
9
+ import { ERR_VALIDATION } from '../lib/errors.js';
10
+ import { assertDocumentFields, getDocumentFieldValidationDetails, validateDocumentFields, } from './document-fields.js';
11
+ const fields = [
12
+ { name: 'title', type: 'text', validation: { minLength: 3 } },
13
+ { name: 'details', type: 'group', fields: [{ name: 'name', type: 'text' }] },
14
+ { name: 'items', type: 'array', fields: [{ name: 'name', type: 'text' }] },
15
+ {
16
+ name: 'content',
17
+ type: 'blocks',
18
+ blocks: [{ blockType: 'heading', fields: [{ name: 'text', type: 'text' }] }],
19
+ },
20
+ ];
21
+ const valid = { title: 'Hello', details: { name: 'OK' }, items: [], content: [] };
22
+ describe('document field validation', () => {
23
+ it('validates root, group, array and block requirements with stable item paths', () => {
24
+ const issues = validateDocumentFields(fields, {
25
+ title: '',
26
+ details: {},
27
+ items: [{ _id: 'item-1' }],
28
+ content: [{ _id: 'block-1', _type: 'heading' }],
29
+ });
30
+ expect(issues.map((i) => i.field)).toEqual([
31
+ 'title',
32
+ 'details.name',
33
+ 'items[id=item-1].name',
34
+ 'content[id=block-1].text',
35
+ ]);
36
+ });
37
+ it('accepts valid fields without stripping metadata or changing values', () => {
38
+ expect(validateDocumentFields(fields, valid)).toEqual([]);
39
+ expect(valid).toEqual({ title: 'Hello', details: { name: 'OK' }, items: [], content: [] });
40
+ });
41
+ it('rejects malformed structures and unknown block types', () => {
42
+ expect(validateDocumentFields(fields, {
43
+ ...valid,
44
+ details: 'wrong',
45
+ items: [42],
46
+ content: [{ _type: 'unknown' }],
47
+ }).map((i) => i.field)).toEqual(['details', 'items[0]', 'content[0]']);
48
+ });
49
+ it('enforces declared scalar constraints and relation cardinality', () => {
50
+ const schema = [
51
+ { name: 'count', type: 'integer', validation: { min: 1, max: 5 } },
52
+ { name: 'email', type: 'text', validation: { rules: [{ type: 'email', value: true }] } },
53
+ {
54
+ name: 'links',
55
+ type: 'relation',
56
+ targetCollection: 'pages',
57
+ hasMany: true,
58
+ minItems: 1,
59
+ maxItems: 2,
60
+ },
61
+ ];
62
+ expect(validateDocumentFields(schema, { count: 7, email: 'bad', links: [] }).map((i) => i.field)).toEqual(['count', 'email', 'links']);
63
+ });
64
+ it('accepts JSON and object values and enforces text-area and code rules', () => {
65
+ const schema = [
66
+ { name: 'json', type: 'json' },
67
+ { name: 'object', type: 'object' },
68
+ { name: 'area', type: 'textArea', validation: { pattern: '^ok' } },
69
+ { name: 'code', type: 'code', validation: { rules: [{ type: 'min', value: 3 }] } },
70
+ ];
71
+ expect(validateDocumentFields(schema, {
72
+ json: [true, null],
73
+ object: { count: 1 },
74
+ area: 'okay',
75
+ code: 'abc',
76
+ })).toEqual([]);
77
+ expect(validateDocumentFields(schema, { json: new Date(), object: [], area: 'bad', code: 'x' }).map((issue) => issue.field)).toEqual(['json', 'object', 'area', 'code']);
78
+ });
79
+ it('allows omitted optional containers but validates present children', () => {
80
+ const schema = [
81
+ { name: 'details', type: 'group', optional: true, fields: [{ name: 'title', type: 'text' }] },
82
+ ];
83
+ expect(validateDocumentFields(schema, {})).toEqual([]);
84
+ expect(validateDocumentFields(schema, { details: {} })[0]?.field).toBe('details.title');
85
+ });
86
+ it('validates locale-all values individually, without assuming an editor JSON shape', () => {
87
+ const schema = [{ name: 'title', type: 'text', localized: true }];
88
+ expect(validateDocumentFields(schema, { title: { en: 'Hi', fr: '' } }, { locale: 'all' })[0]?.field).toBe('title.fr');
89
+ expect(validateDocumentFields(schema, { title: 'Hi' }, { locale: 'en' })).toEqual([]);
90
+ });
91
+ it('only exempts condition-hidden fields in the browser precheck', () => {
92
+ const schema = [{ name: 'hidden', type: 'text', condition: () => false }];
93
+ expect(validateDocumentFields(schema, {}, { respectConditions: true })).toEqual([]);
94
+ expect(validateDocumentFields(schema, {})).toHaveLength(1);
95
+ });
96
+ it('does not require a client-supplied counter', () => {
97
+ expect(validateDocumentFields([{ name: 'id', type: 'counter', group: 'ids' }], {})).toEqual([]);
98
+ });
99
+ it('decodes only field-validation details and strips private properties', () => {
100
+ const error = ERR_VALIDATION({
101
+ message: 'private',
102
+ details: {
103
+ reason: 'invalid_document_fields',
104
+ sql: 'private',
105
+ issues: [{ field: 'title', message: 'Required', value: 'private' }],
106
+ },
107
+ });
108
+ expect(getDocumentFieldValidationDetails(error)).toEqual({
109
+ reason: 'invalid_document_fields',
110
+ issues: [{ field: 'title', message: 'Required', kind: 'invalid' }],
111
+ });
112
+ expect(getDocumentFieldValidationDetails({
113
+ code: error.code,
114
+ details: { reason: 'missing_document_revision' },
115
+ })).toBeNull();
116
+ expect(getDocumentFieldValidationDetails({
117
+ code: error.code,
118
+ details: { reason: 'invalid_document_fields', issues: [{ field: 'title' }] },
119
+ })).toBeNull();
120
+ expect(getDocumentFieldValidationDetails({
121
+ get code() {
122
+ throw new Error('getter');
123
+ },
124
+ })).toBeNull();
125
+ });
126
+ it('throws a structured validation error', () => {
127
+ expect(() => assertDocumentFields(fields, {})).toThrow('Some document fields are invalid');
128
+ });
129
+ describe('issue classification', () => {
130
+ it('marks absent declared values required and everything else invalid', () => {
131
+ const issues = validateDocumentFields(fields, {
132
+ title: 'ab',
133
+ details: { name: 'ok' },
134
+ items: 'not-an-array',
135
+ content: [{ _id: 'b1', _type: 'unknown' }],
136
+ });
137
+ const byField = Object.fromEntries(issues.map((issue) => [issue.field, issue.kind]));
138
+ expect(byField).toEqual({
139
+ title: 'invalid',
140
+ items: 'invalid',
141
+ 'content[id=b1]': 'invalid',
142
+ });
143
+ expect(validateDocumentFields(fields, {}).every((issue) => issue.kind === 'required')).toBe(true);
144
+ });
145
+ it('classifies a custom validator as invalid even when it describes a requirement', () => {
146
+ const conditional = [
147
+ { name: 'kind', type: 'text' },
148
+ {
149
+ name: 'doi',
150
+ type: 'text',
151
+ optional: true,
152
+ validate: (value, data) => data.kind === 'journal' && !value ? 'DOI is required for journal articles' : undefined,
153
+ },
154
+ ];
155
+ const issues = validateDocumentFields(conditional, { kind: 'journal' });
156
+ expect(issues).toEqual([
157
+ { field: 'doi', message: 'DOI is required for journal articles', kind: 'invalid' },
158
+ ]);
159
+ expect(() => assertDocumentFields(conditional, { kind: 'journal' })).toThrow('Some document fields are invalid');
160
+ // The condition itself is never consulted by the lifecycle.
161
+ expect(validateDocumentFields(conditional, { kind: 'blog' })).toEqual([]);
162
+ });
163
+ });
164
+ describe('schema-author callbacks that throw', () => {
165
+ // A validator written against today's shape meets a historical version
166
+ // that lacks the field. An exception must not escape the walk.
167
+ const throwingValidate = [
168
+ { name: 'title', type: 'text' },
169
+ {
170
+ name: 'slug',
171
+ type: 'text',
172
+ optional: true,
173
+ validate: (value) => (value.trim() === '' ? 'empty' : undefined),
174
+ },
175
+ ];
176
+ it('records a failed validator as an issue instead of throwing', () => {
177
+ const issues = validateDocumentFields(throwingValidate, { title: 'ok' });
178
+ expect(issues).toEqual([
179
+ { field: 'slug', message: 'slug: could not be validated', kind: 'invalid' },
180
+ ]);
181
+ });
182
+ it('isolates a throwing custom validation rule', () => {
183
+ const issues = validateDocumentFields([
184
+ { name: 'title', type: 'text' },
185
+ {
186
+ name: 'code',
187
+ type: 'text',
188
+ validation: { rules: [{ type: 'custom', value: (v) => v.deep.thing === 1 }] },
189
+ },
190
+ ], { title: 't', code: 'x' });
191
+ expect(issues).toEqual([
192
+ { field: 'code', message: 'code: could not be validated', kind: 'invalid' },
193
+ ]);
194
+ });
195
+ it('isolates a failure while the schema itself is being built', () => {
196
+ // A malformed `pattern` throws from `new RegExp` before any parse.
197
+ const issues = validateDocumentFields([{ name: 'p', type: 'text', validation: { rules: [{ type: 'pattern', value: '([' }] } }], { p: 'x' });
198
+ expect(issues).toEqual([
199
+ { field: 'p', message: 'p: could not be validated', kind: 'invalid' },
200
+ ]);
201
+ });
202
+ it('keeps collecting issues after one callback fails', () => {
203
+ const issues = validateDocumentFields(throwingValidate, {});
204
+ expect(issues.map((issue) => issue.field)).toEqual(['title', 'slug']);
205
+ });
206
+ it('still refuses an ordinary save, with a readable message', () => {
207
+ expect(() => assertDocumentFields(throwingValidate, { title: 'ok' })).toThrow('Some document fields are invalid');
208
+ });
209
+ it('treats a throwing condition as visible and surfaces it', () => {
210
+ const throwingCondition = [
211
+ { name: 'a', type: 'text', condition: (data) => data.missing.deep === 1 },
212
+ ];
213
+ const issues = validateDocumentFields(throwingCondition, {}, { respectConditions: true });
214
+ // Fail closed: the field is still validated, and the misconfiguration
215
+ // is reported rather than silently hiding the field.
216
+ expect(issues.map((issue) => issue.message)).toEqual([
217
+ 'a: could not be validated',
218
+ 'a is required',
219
+ ]);
220
+ });
221
+ it('never copies an exception message into an editor-facing issue', () => {
222
+ // Issue messages cross the server boundary. An unexpected exception can
223
+ // carry internal diagnostics, so the message is fixed and detail-free.
224
+ const secret = 'ECONNREFUSED postgres://user:hunter2@10.0.0.4:5432/prod';
225
+ const seen = [];
226
+ const issues = validateDocumentFields([
227
+ {
228
+ name: 'x',
229
+ type: 'text',
230
+ validate: () => {
231
+ throw new Error(secret);
232
+ },
233
+ },
234
+ ], { x: 'v' }, { onCallbackError: ({ error, site }) => seen.push([site, error]) });
235
+ expect(issues).toEqual([
236
+ { field: 'x', message: 'x: could not be validated', kind: 'invalid' },
237
+ ]);
238
+ expect(JSON.stringify(issues)).not.toContain('hunter2');
239
+ // The real error is still available to server-side diagnostics.
240
+ expect(seen).toEqual([['validate', expect.objectContaining({ message: secret })]]);
241
+ });
242
+ it('is not aborted by a diagnostic sink that throws', () => {
243
+ // The sink reaches a logger, which can fail too. Diagnostics must never
244
+ // abort the validation they describe.
245
+ const issues = validateDocumentFields([
246
+ {
247
+ name: 'x',
248
+ type: 'text',
249
+ validate: () => {
250
+ throw new Error('inner');
251
+ },
252
+ },
253
+ ], { x: 'v' }, {
254
+ onCallbackError: () => {
255
+ throw new Error('logger is down');
256
+ },
257
+ });
258
+ expect(issues).toEqual([
259
+ { field: 'x', message: 'x: could not be validated', kind: 'invalid' },
260
+ ]);
261
+ });
262
+ it('keeps a message a validator deliberately returns', () => {
263
+ const issues = validateDocumentFields([
264
+ {
265
+ name: 'doi',
266
+ type: 'text',
267
+ optional: true,
268
+ validate: () => 'DOI must be a 10.x prefix',
269
+ },
270
+ ], { doi: 'x' });
271
+ expect(issues[0]?.message).toBe('DOI must be a 10.x prefix');
272
+ });
273
+ });
274
+ describe('the serialized decoder', () => {
275
+ it('decodes an unknown or absent kind as invalid, never as waivable', () => {
276
+ const details = getDocumentFieldValidationDetails({
277
+ code: ERR_VALIDATION({ message: 'x' }).code,
278
+ details: {
279
+ reason: 'invalid_document_fields',
280
+ issues: [
281
+ { field: 'a', message: 'A' },
282
+ { field: 'b', message: 'B', kind: 'bogus' },
283
+ { field: 'c', message: 'C', kind: 'required' },
284
+ ],
285
+ },
286
+ });
287
+ expect(details?.issues.map((issue) => issue.kind)).toEqual(['invalid', 'invalid', 'required']);
288
+ });
289
+ });
290
+ });
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.3.0",
5
+ "version": "6.4.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -86,19 +86,19 @@
86
86
  "pino": "^10.3.1",
87
87
  "sharp": "^0.35.4",
88
88
  "uuid": "^14.0.2",
89
- "zod": "^4.6.2",
90
- "@byline/auth": "6.3.0"
89
+ "zod": "^4.6.5",
90
+ "@byline/auth": "6.4.0"
91
91
  },
92
92
  "devDependencies": {
93
93
  "@biomejs/biome": "2.5.13",
94
- "@types/node": "^26.5.1",
94
+ "@types/node": "^26.6.1",
95
95
  "chokidar": "^5.0.0",
96
96
  "chokidar-cli": "^3.0.0",
97
97
  "npm-run-all": "^4.1.5",
98
- "tsc-alias": "^1.9.4",
98
+ "tsc-alias": "^1.9.5",
99
99
  "tsx": "^4.23.13",
100
100
  "typescript": "^7.0.2",
101
- "vitest": "^5.0.0"
101
+ "vitest": "^5.0.1"
102
102
  },
103
103
  "publishConfig": {
104
104
  "access": "public",