@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.
- package/dist/@types/field-types.d.ts +22 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/schemas/zod/builder.d.ts +1 -1
- package/dist/schemas/zod/builder.js +40 -39
- package/dist/schemas/zod/builder.test.node.js +2 -2
- package/dist/schemas/zod/review-comparison-probe.test.node.d.ts +1 -0
- package/dist/schemas/zod/review-comparison-probe.test.node.js +26 -0
- package/dist/services/document-lifecycle/create.js +23 -2
- package/dist/services/document-lifecycle/persistence.js +38 -0
- package/dist/services/document-lifecycle/restore.d.ts +24 -3
- package/dist/services/document-lifecycle/restore.js +27 -3
- package/dist/services/document-lifecycle.test.node.js +222 -9
- package/dist/services/normalize-numeric-fields.d.ts +1 -1
- package/dist/services/normalize-numeric-fields.js +38 -13
- package/dist/services/normalize-numeric-fields.test.node.js +8 -0
- package/dist/services/restore-review-probe.test.node.d.ts +8 -0
- package/dist/services/restore-review-probe.test.node.js +3397 -0
- package/dist/services/review-probes.test.node.d.ts +8 -0
- package/dist/services/review-probes.test.node.js +349 -0
- package/dist/services/singleton-lifecycle/index.d.ts +1 -0
- package/dist/services/singleton-lifecycle/restore.d.ts +11 -1
- package/dist/services/singleton-lifecycle/restore.js +14 -2
- package/dist/services/singleton-lifecycle.test.node.js +34 -0
- package/dist/validation/document-fields.d.ts +71 -0
- package/dist/validation/document-fields.js +218 -0
- package/dist/validation/document-fields.test.node.d.ts +8 -0
- package/dist/validation/document-fields.test.node.js +290 -0
- 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.
|
|
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.
|
|
90
|
-
"@byline/auth": "6.
|
|
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.
|
|
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.
|
|
98
|
+
"tsc-alias": "^1.9.5",
|
|
99
99
|
"tsx": "^4.23.13",
|
|
100
100
|
"typescript": "^7.0.2",
|
|
101
|
-
"vitest": "^5.0.
|
|
101
|
+
"vitest": "^5.0.1"
|
|
102
102
|
},
|
|
103
103
|
"publishConfig": {
|
|
104
104
|
"access": "public",
|