@byline/core 3.20.4 → 3.21.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 (42) hide show
  1. package/dist/@types/collection-types.d.ts +6 -0
  2. package/dist/@types/field-data-types.d.ts +8 -10
  3. package/dist/@types/field-data-types.js +1 -1
  4. package/dist/@types/field-data-types.test.node.d.ts +1 -0
  5. package/dist/@types/field-data-types.test.node.js +70 -0
  6. package/dist/@types/index.d.ts +1 -0
  7. package/dist/@types/index.js +1 -0
  8. package/dist/@types/relation-types.d.ts +38 -0
  9. package/dist/@types/relation-types.js +8 -0
  10. package/dist/@types/site-config.d.ts +1 -1
  11. package/dist/codegen/fixtures/all-fields.d.ts +342 -0
  12. package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
  13. package/dist/codegen/fixtures/all-fields.expected.js +1 -0
  14. package/dist/codegen/fixtures/all-fields.js +94 -0
  15. package/dist/codegen/index.d.ts +16 -0
  16. package/dist/codegen/index.js +431 -0
  17. package/dist/codegen/index.test.node.d.ts +1 -0
  18. package/dist/codegen/index.test.node.js +230 -0
  19. package/dist/core.d.ts +1 -1
  20. package/dist/query/parse-where.d.ts +1 -1
  21. package/dist/schemas/zod/builder.js +7 -3
  22. package/dist/schemas/zod/builder.test.node.d.ts +1 -0
  23. package/dist/schemas/zod/builder.test.node.js +49 -0
  24. package/dist/services/collection-bootstrap.d.ts +1 -1
  25. package/dist/services/discover-counter-groups.d.ts +1 -1
  26. package/dist/services/document-lifecycle/create.d.ts +2 -2
  27. package/dist/services/document-lifecycle/create.js +5 -2
  28. package/dist/services/document-lifecycle/update.d.ts +4 -4
  29. package/dist/services/document-lifecycle/update.js +9 -4
  30. package/dist/services/document-lifecycle.test.node.js +66 -0
  31. package/dist/services/index.d.ts +1 -0
  32. package/dist/services/index.js +1 -0
  33. package/dist/services/normalize-numeric-fields.d.ts +23 -0
  34. package/dist/services/normalize-numeric-fields.js +89 -0
  35. package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
  36. package/dist/services/normalize-numeric-fields.test.node.js +99 -0
  37. package/dist/services/populate.d.ts +2 -20
  38. package/dist/services/richtext-populate.d.ts +1 -1
  39. package/dist/services/validate-search-config.d.ts +1 -1
  40. package/dist/storage/collection-fingerprint.js +6 -0
  41. package/dist/storage/collection-fingerprint.test.node.js +21 -0
  42. package/package.json +7 -2
@@ -0,0 +1,89 @@
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 } from '../lib/errors.js';
9
+ import { walkFieldTree } from './walk-field-tree.js';
10
+ const NUMERIC_LITERAL_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
11
+ /** Return whether a value already has the storage-facing canonical type and shape. */
12
+ export function isCanonicalNumericValue(fieldType, value) {
13
+ if (fieldType === 'decimal') {
14
+ return typeof value === 'string' && value.trim() === value && NUMERIC_LITERAL_RE.test(value);
15
+ }
16
+ return (typeof value === 'number' &&
17
+ Number.isFinite(value) &&
18
+ (fieldType !== 'integer' || Number.isInteger(value)));
19
+ }
20
+ /**
21
+ * Convert a tolerant numeric write value to its canonical representation.
22
+ * `undefined` means the value was empty and should be removed.
23
+ */
24
+ export function normalizeNumericValue(fieldType, value, path) {
25
+ if (typeof value === 'string') {
26
+ const trimmed = value.trim();
27
+ if (trimmed === '')
28
+ return undefined;
29
+ if (!NUMERIC_LITERAL_RE.test(trimmed))
30
+ throwNumericValidation(fieldType, value, path);
31
+ if (fieldType === 'decimal')
32
+ return trimmed;
33
+ const numberValue = Number(trimmed);
34
+ if (!Number.isFinite(numberValue))
35
+ throwNumericValidation(fieldType, value, path);
36
+ if (fieldType === 'integer' && !Number.isInteger(numberValue)) {
37
+ throwNumericValidation(fieldType, value, path);
38
+ }
39
+ return numberValue;
40
+ }
41
+ if (typeof value === 'number') {
42
+ if (!Number.isFinite(value))
43
+ throwNumericValidation(fieldType, value, path);
44
+ if (fieldType === 'integer' && !Number.isInteger(value)) {
45
+ throwNumericValidation(fieldType, value, path);
46
+ }
47
+ return fieldType === 'decimal' ? String(value) : value;
48
+ }
49
+ throwNumericValidation(fieldType, value, path);
50
+ }
51
+ /**
52
+ * Normalize every user-writable numeric leaf in a schema-shaped data tree.
53
+ * Mutates `data` in place. Counter fields are deliberately excluded because
54
+ * their values are supplied by the lifecycle allocator.
55
+ */
56
+ export function normalizeNumericFields(fields, data) {
57
+ for (const leaf of walkFieldTree(fields, data)) {
58
+ if (!isWritableNumericType(leaf.field.type))
59
+ continue;
60
+ if (leaf.field.localized === true && isLocaleMap(leaf.value)) {
61
+ for (const [locale, localeValue] of Object.entries(leaf.value)) {
62
+ normalizeLeafValue(leaf.field.type, leaf.value, locale, localeValue, `${leaf.fieldPath}.${locale}`);
63
+ }
64
+ continue;
65
+ }
66
+ normalizeLeafValue(leaf.field.type, leaf.parent, leaf.key, leaf.value, leaf.fieldPath);
67
+ }
68
+ }
69
+ function normalizeLeafValue(fieldType, parent, key, value, path) {
70
+ const normalized = normalizeNumericValue(fieldType, value, path);
71
+ if (normalized === undefined) {
72
+ delete parent[key];
73
+ }
74
+ else {
75
+ parent[key] = normalized;
76
+ }
77
+ }
78
+ function isWritableNumericType(type) {
79
+ return type === 'integer' || type === 'float' || type === 'decimal';
80
+ }
81
+ function isLocaleMap(value) {
82
+ return value != null && typeof value === 'object' && !Array.isArray(value);
83
+ }
84
+ function throwNumericValidation(fieldType, value, path) {
85
+ throw ERR_VALIDATION({
86
+ message: `invalid ${fieldType} value at '${path}'`,
87
+ details: { path, fieldType, value },
88
+ });
89
+ }
@@ -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,99 @@
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 { BylineError, ErrorCodes } from '../lib/errors.js';
10
+ import { normalizeNumericFields, normalizeNumericValue } from './normalize-numeric-fields.js';
11
+ const fields = [
12
+ { name: 'count', type: 'integer' },
13
+ { name: 'ratio', type: 'float' },
14
+ { name: 'price', type: 'decimal' },
15
+ { name: 'sequence', type: 'counter', group: 'test' },
16
+ {
17
+ name: 'settings',
18
+ type: 'group',
19
+ fields: [{ name: 'threshold', type: 'float' }],
20
+ },
21
+ {
22
+ name: 'rows',
23
+ type: 'array',
24
+ fields: [{ name: 'quantity', type: 'integer' }],
25
+ },
26
+ {
27
+ name: 'content',
28
+ type: 'blocks',
29
+ blocks: [
30
+ {
31
+ blockType: 'amount',
32
+ fields: [{ name: 'value', type: 'decimal' }],
33
+ },
34
+ ],
35
+ },
36
+ ];
37
+ describe('normalizeNumericFields', () => {
38
+ it('normalizes numeric leaves through groups, arrays, and blocks while leaving counters alone', () => {
39
+ const data = {
40
+ count: '12',
41
+ ratio: ' 1.25e2 ',
42
+ price: ' 001.2300 ',
43
+ sequence: 'caller-owned-value',
44
+ settings: { threshold: 2 },
45
+ rows: [{ quantity: '3.0' }, { quantity: 4 }],
46
+ content: [{ _type: 'amount', value: 5.5 }],
47
+ };
48
+ normalizeNumericFields(fields, data);
49
+ expect(data).toEqual({
50
+ count: 12,
51
+ ratio: 125,
52
+ price: '001.2300',
53
+ sequence: 'caller-owned-value',
54
+ settings: { threshold: 2 },
55
+ rows: [{ quantity: 3 }, { quantity: 4 }],
56
+ content: [{ _type: 'amount', value: '5.5' }],
57
+ });
58
+ normalizeNumericFields(fields, data);
59
+ expect(data.price).toBe('001.2300');
60
+ });
61
+ it('removes empty and whitespace-only values', () => {
62
+ const data = { count: '', ratio: ' ', price: '\t' };
63
+ normalizeNumericFields(fields, data);
64
+ expect(data).toEqual({});
65
+ });
66
+ it('normalizes all-locale value maps and reports the locale-qualified path', () => {
67
+ const localizedFields = [
68
+ { name: 'amount', type: 'decimal', localized: true },
69
+ ];
70
+ const data = { amount: { en: ' 1.20 ', fr: 'not-a-number', de: '' } };
71
+ expect(() => normalizeNumericFields(localizedFields, data)).toThrowError(expect.objectContaining({
72
+ code: ErrorCodes.VALIDATION,
73
+ details: expect.objectContaining({ path: 'amount.fr' }),
74
+ }));
75
+ expect(data.amount.en).toBe('1.20');
76
+ });
77
+ it.each([
78
+ ['integer', '1.2'],
79
+ ['integer', Number.NaN],
80
+ ['float', Number.POSITIVE_INFINITY],
81
+ ['float', '1e'],
82
+ ['decimal', '--1'],
83
+ ])('rejects invalid %s input', (fieldType, value) => {
84
+ expect(() => normalizeNumericValue(fieldType, value, 'nested.2.value')).toThrowError(expect.objectContaining({
85
+ code: ErrorCodes.VALIDATION,
86
+ details: expect.objectContaining({ path: 'nested.2.value' }),
87
+ }));
88
+ });
89
+ it('throws a BylineError with ERR_VALIDATION', () => {
90
+ try {
91
+ normalizeNumericValue('integer', {}, 'count');
92
+ expect.fail('expected ERR_VALIDATION');
93
+ }
94
+ catch (error) {
95
+ expect(error).toBeInstanceOf(BylineError);
96
+ expect(error).toMatchObject({ code: ErrorCodes.VALIDATION });
97
+ }
98
+ });
99
+ });
@@ -134,10 +134,11 @@ export type { ReadContext } from '../@types/index.js';
134
134
  /** Build a fresh ReadContext. */
135
135
  export declare function createReadContext(overrides?: Partial<ReadContext>): ReadContext;
136
136
  export type { PopulateFieldOptions, PopulateFieldSpec, PopulateMap, PopulateSpec, } from '../@types/populate-types.js';
137
+ export type { CycleRelationValue, PopulatedRelationValue, RelationFieldReadValue, RelationReadValue, UnpopulatedRelationValue, UnresolvedRelationValue, } from '../@types/relation-types.js';
137
138
  export interface PopulateOptions {
138
139
  db: IDbAdapter;
139
140
  /** Every collection definition in the app — needed to resolve target fields. */
140
- collections: CollectionDefinition[];
141
+ collections: readonly CollectionDefinition[];
141
142
  /** The source collection id for `documents`. */
142
143
  collectionId: string;
143
144
  /**
@@ -204,25 +205,6 @@ export interface PopulateOptions {
204
205
  */
205
206
  richTextPopulate?: import('../@types/index.js').RichTextPopulateFn;
206
207
  }
207
- /** Marker placed in a relation leaf when the target was already materialised earlier in this request. */
208
- export interface CycleRelationValue extends RelatedDocumentValue {
209
- _resolved: true;
210
- _cycle: true;
211
- }
212
- /** Marker placed in a relation leaf when the target was not found (deleted). */
213
- export interface UnresolvedRelationValue extends RelatedDocumentValue {
214
- _resolved: false;
215
- }
216
- /**
217
- * Envelope placed in a relation leaf when populate successfully fetched
218
- * the target document. The `document` field carries the raw storage-shape
219
- * doc (`@byline/client` then reshapes it to `ClientDocument` during
220
- * response shaping).
221
- */
222
- export interface PopulatedRelationValue extends RelatedDocumentValue {
223
- _resolved: true;
224
- document: Record<string, any>;
225
- }
226
208
  /**
227
209
  * Populate relation leaves in `opts.documents` in place, one DB
228
210
  * round-trip per depth level per target collection.
@@ -102,4 +102,4 @@ export interface RichTextAdapterPresence {
102
102
  * Called once at `initBylineCore()` time. Fail-fast at boot is the right
103
103
  * posture; the alternative is a silent broken renderer at request time.
104
104
  */
105
- export declare function validateRichTextFieldFlags(collections: CollectionDefinition[], adapters: RichTextAdapterPresence): void;
105
+ export declare function validateRichTextFieldFlags(collections: readonly CollectionDefinition[], adapters: RichTextAdapterPresence): void;
@@ -25,4 +25,4 @@ export interface SearchProviderPresence {
25
25
  * actually opts in. Installations that don't use search leave
26
26
  * `ServerConfig.search` unset and pass cleanly.
27
27
  */
28
- export declare function validateSearchConfig(collections: CollectionDefinition[], adapters: SearchProviderPresence): void;
28
+ export declare function validateSearchConfig(collections: readonly CollectionDefinition[], adapters: SearchProviderPresence): void;
@@ -33,6 +33,12 @@ function canonicalField(field) {
33
33
  base.targetCollection = field.targetCollection;
34
34
  if (field.displayField !== undefined)
35
35
  base.displayField = field.displayField;
36
+ if (field.hasMany === true)
37
+ base.hasMany = true;
38
+ if (field.minItems !== undefined)
39
+ base.minItems = field.minItems;
40
+ if (field.maxItems !== undefined)
41
+ base.maxItems = field.maxItems;
36
42
  return base;
37
43
  case 'datetime':
38
44
  if (field.mode !== undefined)
@@ -145,6 +145,27 @@ describe('fingerprintCollection', () => {
145
145
  rel.targetCollection = 'tags';
146
146
  expect(await fingerprintCollection(b)).not.toBe(a);
147
147
  });
148
+ it('changes when a relation becomes hasMany', async () => {
149
+ const a = await fingerprintCollection(baseCollection());
150
+ const b = baseCollection();
151
+ const rel = b.fields.find((f) => f.name === 'category');
152
+ rel.hasMany = true;
153
+ expect(await fingerprintCollection(b)).not.toBe(a);
154
+ });
155
+ it.each([
156
+ 'minItems',
157
+ 'maxItems',
158
+ ])('changes when relation %s changes', async (constraint) => {
159
+ const a = baseCollection();
160
+ const aRel = a.fields.find((f) => f.name === 'category');
161
+ aRel.hasMany = true;
162
+ aRel[constraint] = 1;
163
+ const b = baseCollection();
164
+ const bRel = b.fields.find((f) => f.name === 'category');
165
+ bRel.hasMany = true;
166
+ bRel[constraint] = 2;
167
+ expect(await fingerprintCollection(b)).not.toBe(await fingerprintCollection(a));
168
+ });
148
169
  it('changes when a block variant is renamed', async () => {
149
170
  const a = await fingerprintCollection(baseCollection());
150
171
  const b = baseCollection();
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.20.4",
5
+ "version": "3.21.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -31,6 +31,11 @@
31
31
  "import": "./dist/index.js",
32
32
  "require": "./dist/index.js"
33
33
  },
34
+ "./codegen": {
35
+ "types": "./dist/codegen/index.d.ts",
36
+ "import": "./dist/codegen/index.js",
37
+ "require": "./dist/codegen/index.js"
38
+ },
34
39
  "./zod-schemas": {
35
40
  "types": "./dist/schemas/zod/index.d.ts",
36
41
  "import": "./dist/schemas/zod/index.js",
@@ -76,7 +81,7 @@
76
81
  "pino": "^10.3.1",
77
82
  "sharp": "^0.35.3",
78
83
  "zod": "^4.4.3",
79
- "@byline/auth": "3.20.4"
84
+ "@byline/auth": "3.21.0"
80
85
  },
81
86
  "devDependencies": {
82
87
  "@biomejs/biome": "2.5.2",