@byline/core 3.20.3 → 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.
- package/dist/@types/collection-types.d.ts +28 -3
- package/dist/@types/field-data-types.d.ts +8 -10
- package/dist/@types/field-data-types.js +1 -1
- package/dist/@types/field-data-types.test.node.d.ts +1 -0
- package/dist/@types/field-data-types.test.node.js +70 -0
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/@types/relation-types.d.ts +38 -0
- package/dist/@types/relation-types.js +8 -0
- package/dist/@types/site-config.d.ts +1 -1
- package/dist/codegen/fixtures/all-fields.d.ts +342 -0
- package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
- package/dist/codegen/fixtures/all-fields.expected.js +1 -0
- package/dist/codegen/fixtures/all-fields.js +94 -0
- package/dist/codegen/index.d.ts +16 -0
- package/dist/codegen/index.js +431 -0
- package/dist/codegen/index.test.node.d.ts +1 -0
- package/dist/codegen/index.test.node.js +230 -0
- package/dist/config/validate-collections.d.ts +3 -0
- package/dist/config/validate-collections.js +25 -0
- package/dist/config/validate-collections.test.node.js +42 -0
- package/dist/core.d.ts +1 -1
- package/dist/query/parse-where.d.ts +1 -1
- package/dist/schemas/zod/builder.js +7 -3
- package/dist/schemas/zod/builder.test.node.d.ts +1 -0
- package/dist/schemas/zod/builder.test.node.js +49 -0
- package/dist/services/collection-bootstrap.d.ts +1 -1
- package/dist/services/discover-counter-groups.d.ts +1 -1
- package/dist/services/document-lifecycle/create.d.ts +2 -2
- package/dist/services/document-lifecycle/create.js +5 -2
- package/dist/services/document-lifecycle/update.d.ts +4 -4
- package/dist/services/document-lifecycle/update.js +9 -4
- package/dist/services/document-lifecycle.test.node.js +66 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/normalize-numeric-fields.d.ts +23 -0
- package/dist/services/normalize-numeric-fields.js +89 -0
- package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
- package/dist/services/normalize-numeric-fields.test.node.js +99 -0
- package/dist/services/populate.d.ts +2 -20
- package/dist/services/richtext-populate.d.ts +1 -1
- package/dist/services/validate-search-config.d.ts +1 -1
- package/dist/storage/collection-fingerprint.js +6 -0
- package/dist/storage/collection-fingerprint.test.node.js +21 -0
- package/package.json +7 -2
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
6
|
+
import { AllFieldsCollection, AllFieldsCollections, } from './fixtures/all-fields.js';
|
|
7
|
+
import { emitCollectionTypes } from './index.js';
|
|
8
|
+
const fixtureDirectory = resolve(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
9
|
+
function collection(path, fields) {
|
|
10
|
+
return { path, labels: { singular: path, plural: path }, fields };
|
|
11
|
+
}
|
|
12
|
+
describe('emitCollectionTypes', () => {
|
|
13
|
+
it('matches the checked-in all-fields fixture and canonical inferred contracts exactly', () => {
|
|
14
|
+
const result = emitCollectionTypes(AllFieldsCollections);
|
|
15
|
+
const fixture = readFileSync(resolve(fixtureDirectory, 'all-fields.generated.txt'), 'utf8');
|
|
16
|
+
expect(result.source).toBe(fixture);
|
|
17
|
+
expectTypeOf().toEqualTypeOf();
|
|
18
|
+
expectTypeOf().toEqualTypeOf();
|
|
19
|
+
expectTypeOf().toEqualTypeOf();
|
|
20
|
+
expectTypeOf().toEqualTypeOf();
|
|
21
|
+
expectTypeOf().toEqualTypeOf();
|
|
22
|
+
expectTypeOf().toEqualTypeOf();
|
|
23
|
+
expectTypeOf().toEqualTypeOf();
|
|
24
|
+
expectTypeOf().toEqualTypeOf();
|
|
25
|
+
expectTypeOf().toEqualTypeOf();
|
|
26
|
+
expectTypeOf().toEqualTypeOf();
|
|
27
|
+
});
|
|
28
|
+
it('emits a versioned header, verifiable body hash, and canonical formatting', () => {
|
|
29
|
+
const { source, hash } = emitCollectionTypes([collection('plain', [])]);
|
|
30
|
+
const body = source.split('\n').slice(4).join('\n');
|
|
31
|
+
expect(source).toMatch(/^\/\/ Generated by @byline\/core\/codegen\n\/\/ Format version: 1\n\/\/ Hash: [a-f0-9]{64}\n\n/);
|
|
32
|
+
expect(hash).toBe(createHash('sha256').update(`@byline/core/codegen:collection-types:v1\n${body}`).digest('hex'));
|
|
33
|
+
expect(source).toContain(`// Hash: ${hash}`);
|
|
34
|
+
expect(source.endsWith('\n')).toBe(true);
|
|
35
|
+
expect(source).not.toContain('\r');
|
|
36
|
+
expect(source).not.toContain(';');
|
|
37
|
+
});
|
|
38
|
+
it('sorts collections and declarations without changing field, select, or block union order', () => {
|
|
39
|
+
const alpha = collection('alpha', [
|
|
40
|
+
{
|
|
41
|
+
name: 'choice',
|
|
42
|
+
type: 'select',
|
|
43
|
+
options: [
|
|
44
|
+
{ label: 'Zulu', value: 'zulu' },
|
|
45
|
+
{ label: 'Duplicate Zulu', value: 'zulu' },
|
|
46
|
+
{ label: 'Alpha', value: 'alpha' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'content',
|
|
51
|
+
type: 'blocks',
|
|
52
|
+
blocks: [
|
|
53
|
+
{ blockType: 'zulu', fields: [] },
|
|
54
|
+
{ blockType: 'alpha', fields: [] },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
const zulu = collection('zulu', [{ name: 'title', type: 'text' }]);
|
|
59
|
+
const forward = emitCollectionTypes([zulu, alpha]);
|
|
60
|
+
const reverse = emitCollectionTypes([alpha, zulu]);
|
|
61
|
+
expect(forward).toEqual(reverse);
|
|
62
|
+
expect(forward.source).toContain(`choice: 'zulu' | 'alpha'`);
|
|
63
|
+
expect(forward.source).toContain(`content: Array<ZuluBlockData | AlphaBlockData>`);
|
|
64
|
+
expect(forward.source.indexOf(`alpha: AlphaFields`)).toBeLessThan(forward.source.indexOf(`zulu: ZuluFields`));
|
|
65
|
+
});
|
|
66
|
+
it('normalizes identifiers and quotes only invalid property keys', () => {
|
|
67
|
+
const source = emitCollectionTypes([
|
|
68
|
+
collection('API_response', [
|
|
69
|
+
{ name: 'validKey', type: 'text' },
|
|
70
|
+
{ name: 'café', type: 'text' },
|
|
71
|
+
{ name: 'not-valid', type: 'text' },
|
|
72
|
+
{
|
|
73
|
+
name: 'choice',
|
|
74
|
+
type: 'select',
|
|
75
|
+
options: [
|
|
76
|
+
{ label: 'First', value: 'first' },
|
|
77
|
+
{ label: 'First duplicate', value: 'first' },
|
|
78
|
+
{ label: 'Second', value: 'second' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'content',
|
|
83
|
+
type: 'blocks',
|
|
84
|
+
blocks: [{ blockType: 'photoBlock', fields: [] }],
|
|
85
|
+
},
|
|
86
|
+
]),
|
|
87
|
+
collection('photoBlock', []),
|
|
88
|
+
collection('café—API_response', []),
|
|
89
|
+
]).source;
|
|
90
|
+
expect(source).toContain('export type ApiResponseFields = {');
|
|
91
|
+
expect(source).toContain('export type PhotoBlockFields = {}');
|
|
92
|
+
expect(source).toContain('export type CafeApiResponseFields = {}');
|
|
93
|
+
expect(source).toContain('export type PhotoBlockData = {');
|
|
94
|
+
expect(source).toContain('validKey: string');
|
|
95
|
+
expect(source).toContain('café: string');
|
|
96
|
+
expect(source).toContain(`'not-valid': string`);
|
|
97
|
+
expect(source).toContain(`choice: 'first' | 'second'`);
|
|
98
|
+
expect(source).toContain('API_response: ApiResponseFields');
|
|
99
|
+
expect(source).toContain(`'café—API_response': CafeApiResponseFields`);
|
|
100
|
+
});
|
|
101
|
+
it('ignores non-type metadata but changes output for type-significant values', () => {
|
|
102
|
+
const base = collection('articles', [
|
|
103
|
+
{ name: 'title', type: 'text' },
|
|
104
|
+
{ name: 'kind', type: 'select', options: [{ label: 'News', value: 'news' }] },
|
|
105
|
+
]);
|
|
106
|
+
const metadataOnly = {
|
|
107
|
+
...base,
|
|
108
|
+
labels: { singular: 'Story', plural: 'Stories' },
|
|
109
|
+
version: 99,
|
|
110
|
+
fields: [
|
|
111
|
+
{ name: 'title', type: 'text', label: 'Headline', helpText: 'Displayed prominently' },
|
|
112
|
+
{
|
|
113
|
+
name: 'kind',
|
|
114
|
+
type: 'select',
|
|
115
|
+
label: 'Kind',
|
|
116
|
+
options: [{ label: 'Article', value: 'news' }],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
const changed = collection('articles', [
|
|
121
|
+
{ name: 'title', type: 'integer' },
|
|
122
|
+
{ name: 'kind', type: 'select', options: [{ label: 'News', value: 'report' }] },
|
|
123
|
+
]);
|
|
124
|
+
expect(emitCollectionTypes([metadataOnly])).toEqual(emitCollectionTypes([base]));
|
|
125
|
+
expect(emitCollectionTypes([changed]).hash).not.toBe(emitCollectionTypes([base]).hash);
|
|
126
|
+
});
|
|
127
|
+
it('imports only used canonical types in fixed order', () => {
|
|
128
|
+
const noImports = emitCollectionTypes([
|
|
129
|
+
collection('simple', [{ name: 'title', type: 'text' }]),
|
|
130
|
+
]).source;
|
|
131
|
+
const selectedImports = emitCollectionTypes([
|
|
132
|
+
collection('typed', [
|
|
133
|
+
{ name: 'data', type: 'json' },
|
|
134
|
+
{ name: 'relation', type: 'relation', targetCollection: 'simple' },
|
|
135
|
+
{ name: 'file', type: 'file' },
|
|
136
|
+
]),
|
|
137
|
+
]).source;
|
|
138
|
+
expect(noImports).not.toContain('import type');
|
|
139
|
+
expect(selectedImports).toContain("import type {\n JsonValue,\n RelatedDocumentValue,\n StoredFileValue,\n} from '@byline/core'");
|
|
140
|
+
expect(selectedImports).not.toContain('JsonObject');
|
|
141
|
+
});
|
|
142
|
+
it('reuses identical block contracts and suffixes incompatible normalized names', () => {
|
|
143
|
+
const sharedA = { blockType: 'hero', fields: [{ name: 'title', type: 'text' }] };
|
|
144
|
+
const sharedB = {
|
|
145
|
+
blockType: 'hero',
|
|
146
|
+
fields: [{ name: 'title', type: 'text', label: 'Ignored metadata' }],
|
|
147
|
+
};
|
|
148
|
+
const incompatible = {
|
|
149
|
+
blockType: 'hero',
|
|
150
|
+
fields: [{ name: 'title', type: 'integer' }],
|
|
151
|
+
};
|
|
152
|
+
const source = emitCollectionTypes([
|
|
153
|
+
collection('foo-bar', [{ name: 'content', type: 'blocks', blocks: [sharedA] }]),
|
|
154
|
+
collection('foo_bar', [{ name: 'content', type: 'blocks', blocks: [sharedB] }]),
|
|
155
|
+
collection('other', [{ name: 'content', type: 'blocks', blocks: [incompatible] }]),
|
|
156
|
+
]).source;
|
|
157
|
+
expect(source.match(/export type HeroBlockData_[a-f0-9]+ =/g)).toHaveLength(2);
|
|
158
|
+
expect(source.match(/export type FooBarFields_[a-f0-9]+ =/g)).toHaveLength(2);
|
|
159
|
+
expect(source.match(/_type: 'hero'/g)).toHaveLength(4);
|
|
160
|
+
});
|
|
161
|
+
it('escapes property keys, block discriminants, and select literals', () => {
|
|
162
|
+
const source = emitCollectionTypes([AllFieldsCollection]).source;
|
|
163
|
+
expect(source).toContain(`'content\\'s':`);
|
|
164
|
+
expect(source).toContain(`_type: 'quote\\'block'`);
|
|
165
|
+
expect(source).toContain(`'tone\\'choice'?: 'calm' | 'author\\'s' | undefined`);
|
|
166
|
+
});
|
|
167
|
+
it('does not mutate definitions and accepts deeply frozen input', () => {
|
|
168
|
+
const field = Object.freeze({ name: 'title', type: 'text' });
|
|
169
|
+
const fields = Object.freeze([field]);
|
|
170
|
+
const definition = Object.freeze({
|
|
171
|
+
path: 'frozen',
|
|
172
|
+
labels: Object.freeze({ singular: 'Frozen', plural: 'Frozen' }),
|
|
173
|
+
fields,
|
|
174
|
+
});
|
|
175
|
+
const before = JSON.stringify(definition);
|
|
176
|
+
emitCollectionTypes(Object.freeze([definition]));
|
|
177
|
+
expect(JSON.stringify(definition)).toBe(before);
|
|
178
|
+
});
|
|
179
|
+
it('rejects duplicate collection paths', () => {
|
|
180
|
+
expect(() => emitCollectionTypes([collection('same', []), collection('same', [])])).toThrow("duplicate collection path 'same'");
|
|
181
|
+
});
|
|
182
|
+
it('rejects duplicate sibling fields at any nesting level', () => {
|
|
183
|
+
expect(() => emitCollectionTypes([
|
|
184
|
+
collection('duplicates', [
|
|
185
|
+
{
|
|
186
|
+
name: 'group',
|
|
187
|
+
type: 'group',
|
|
188
|
+
fields: [
|
|
189
|
+
{ name: 'same', type: 'text' },
|
|
190
|
+
{ name: 'same', type: 'integer' },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
]),
|
|
194
|
+
])).toThrow("duplicate sibling field 'same'");
|
|
195
|
+
});
|
|
196
|
+
it('rejects duplicate block types within one blocks field', () => {
|
|
197
|
+
expect(() => emitCollectionTypes([
|
|
198
|
+
collection('duplicates', [
|
|
199
|
+
{
|
|
200
|
+
name: 'content',
|
|
201
|
+
type: 'blocks',
|
|
202
|
+
blocks: [
|
|
203
|
+
{ blockType: 'hero', fields: [] },
|
|
204
|
+
{ blockType: 'hero', fields: [{ name: 'title', type: 'text' }] },
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
]),
|
|
208
|
+
])).toThrow("duplicate local block type 'hero'");
|
|
209
|
+
});
|
|
210
|
+
it('rejects cyclic runtime definitions', () => {
|
|
211
|
+
const fields = [];
|
|
212
|
+
fields.push({ name: 'recursive', type: 'group', fields });
|
|
213
|
+
expect(() => emitCollectionTypes([collection('cycle', fields)])).toThrow('cycle detected');
|
|
214
|
+
});
|
|
215
|
+
it('rejects unsupported runtime field types', () => {
|
|
216
|
+
expect(() => emitCollectionTypes([
|
|
217
|
+
collection('unsupported', [{ name: 'future', type: 'future' }]),
|
|
218
|
+
])).toThrow("unsupported runtime field type 'future'");
|
|
219
|
+
});
|
|
220
|
+
it('is exposed only through the published codegen subpath', () => {
|
|
221
|
+
const packageJson = JSON.parse(readFileSync(resolve(fixtureDirectory, '../../../package.json'), 'utf8'));
|
|
222
|
+
const rootSource = readFileSync(resolve(fixtureDirectory, '../../index.ts'), 'utf8');
|
|
223
|
+
expect(packageJson.exports['./codegen']).toEqual({
|
|
224
|
+
types: './dist/codegen/index.d.ts',
|
|
225
|
+
import: './dist/codegen/index.js',
|
|
226
|
+
require: './dist/codegen/index.js',
|
|
227
|
+
});
|
|
228
|
+
expect(rootSource).not.toMatch(/codegen/);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -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
|
+
* - Each `listSearch` entry must name an existing top-level field whose
|
|
37
|
+
* type is persisted to the text store (the admin list-view search box
|
|
38
|
+
* is an `ILIKE` over `store_text`), and the field may not be virtual.
|
|
36
39
|
* - `virtual` fields must satisfy the constraints in
|
|
37
40
|
* {@link validateVirtualFields} (optional-or-default, no counters, no
|
|
38
41
|
* upload fields, not referenced by useAsTitle / useAsPath / search).
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { fieldTypeToStore } from '../storage/field-store-map.js';
|
|
8
9
|
/**
|
|
9
10
|
* Field names that cannot be declared in a collection schema because they
|
|
10
11
|
* collide with system-managed attributes on `documentVersions`. Exported
|
|
@@ -21,6 +22,15 @@ const RESERVED_FIELD_HINTS = {
|
|
|
21
22
|
path: "Use `useAsPath: '<sourceField>'` on the collection definition instead.",
|
|
22
23
|
availableLocales: 'Use `advertiseLocales: true` on the collection definition instead.',
|
|
23
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* Field types `listSearch` may name — the types persisted to the text
|
|
27
|
+
* store, since the admin list-view search box is an `ILIKE` over
|
|
28
|
+
* `store_text`. Derived from the canonical field→store mapping so the two
|
|
29
|
+
* can't drift.
|
|
30
|
+
*/
|
|
31
|
+
const LIST_SEARCH_SOURCE_TYPES = new Set(Object.entries(fieldTypeToStore)
|
|
32
|
+
.filter(([, mapping]) => mapping?.storeType === 'text')
|
|
33
|
+
.map(([type]) => type));
|
|
24
34
|
const USE_AS_PATH_SOURCE_TYPES = new Set([
|
|
25
35
|
'text',
|
|
26
36
|
'textArea',
|
|
@@ -180,6 +190,9 @@ function validateVirtualFields(collection) {
|
|
|
180
190
|
* - A collection may not set both `tree: true` and `orderable: true`. A
|
|
181
191
|
* document-tree owns ordering per-parent on the tree edge, so
|
|
182
192
|
* `byline_documents.order_key` is inert for it.
|
|
193
|
+
* - Each `listSearch` entry must name an existing top-level field whose
|
|
194
|
+
* type is persisted to the text store (the admin list-view search box
|
|
195
|
+
* is an `ILIKE` over `store_text`), and the field may not be virtual.
|
|
183
196
|
* - `virtual` fields must satisfy the constraints in
|
|
184
197
|
* {@link validateVirtualFields} (optional-or-default, no counters, no
|
|
185
198
|
* upload fields, not referenced by useAsTitle / useAsPath / search).
|
|
@@ -205,6 +218,18 @@ export function validateCollections(collections) {
|
|
|
205
218
|
throw new Error(`Collection "${collection.path}" sets \`useAsPath: '${collection.useAsPath}'\` but field "${collection.useAsPath}" has type "${source.type}". Supported source types: ${[...USE_AS_PATH_SOURCE_TYPES].join(', ')}.`);
|
|
206
219
|
}
|
|
207
220
|
}
|
|
221
|
+
for (const name of collection.listSearch ?? []) {
|
|
222
|
+
const source = collection.fields.find((f) => 'name' in f && f.name === name);
|
|
223
|
+
if (source == null) {
|
|
224
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`listSearch\` but no top-level field with that name exists.`);
|
|
225
|
+
}
|
|
226
|
+
if (!LIST_SEARCH_SOURCE_TYPES.has(source.type)) {
|
|
227
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`listSearch\` but field "${name}" has type "${source.type}". The list-view search box matches text-store fields only (${[...LIST_SEARCH_SOURCE_TYPES].join(', ')}).`);
|
|
228
|
+
}
|
|
229
|
+
if (source.virtual === true) {
|
|
230
|
+
throw new Error(`Collection "${collection.path}" names virtual field '${name}' in \`listSearch\` — list-view search reads persisted values, which virtual fields never have.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
208
233
|
if (collection.advertiseLocales === true && !hasLocalizedField(collection.fields)) {
|
|
209
234
|
throw new Error(`Collection "${collection.path}" sets \`advertiseLocales: true\` but has no localized fields. The available-locales control advertises content locales, which is only meaningful when at least one field is \`localized\`.`);
|
|
210
235
|
}
|
|
@@ -410,4 +410,46 @@ describe('validateCollections', () => {
|
|
|
410
410
|
};
|
|
411
411
|
expect(() => validateCollections([collection])).not.toThrow();
|
|
412
412
|
});
|
|
413
|
+
it('accepts listSearch naming text-store fields', () => {
|
|
414
|
+
const collection = {
|
|
415
|
+
...baseCollection,
|
|
416
|
+
listSearch: ['title', 'summary', 'kind'],
|
|
417
|
+
fields: [
|
|
418
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
419
|
+
{ name: 'summary', label: 'Summary', type: 'textArea' },
|
|
420
|
+
{
|
|
421
|
+
name: 'kind',
|
|
422
|
+
label: 'Kind',
|
|
423
|
+
type: 'select',
|
|
424
|
+
options: [{ label: 'A', value: 'a' }],
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
};
|
|
428
|
+
expect(() => validateCollections([collection])).not.toThrow();
|
|
429
|
+
});
|
|
430
|
+
it('rejects a listSearch entry referencing a missing field', () => {
|
|
431
|
+
expect(() => validateCollections([{ ...baseCollection, listSearch: ['nonexistent'] }])).toThrow(/listSearch.*no top-level field/s);
|
|
432
|
+
});
|
|
433
|
+
it('rejects a listSearch entry referencing a non-text-store field', () => {
|
|
434
|
+
const collection = {
|
|
435
|
+
...baseCollection,
|
|
436
|
+
listSearch: ['count'],
|
|
437
|
+
fields: [
|
|
438
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
439
|
+
{ name: 'count', label: 'Count', type: 'integer' },
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
expect(() => validateCollections([collection])).toThrow(/text-store fields only/);
|
|
443
|
+
});
|
|
444
|
+
it('rejects a listSearch entry referencing a virtual field', () => {
|
|
445
|
+
const collection = {
|
|
446
|
+
...baseCollection,
|
|
447
|
+
listSearch: ['ephemeral'],
|
|
448
|
+
fields: [
|
|
449
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
450
|
+
{ name: 'ephemeral', label: 'Ephemeral', type: 'text', virtual: true, optional: true },
|
|
451
|
+
],
|
|
452
|
+
};
|
|
453
|
+
expect(() => validateCollections([collection])).toThrow(/listSearch.*virtual/s);
|
|
454
|
+
});
|
|
413
455
|
});
|
package/dist/core.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type CollectionRecord } from './services/collection-bootstrap.js';
|
|
|
12
12
|
import type { CollectionDefinition, IDbAdapter, IStorageProvider, ServerConfig } from './@types/index.js';
|
|
13
13
|
export interface BylineCore<TAdminStore = unknown> {
|
|
14
14
|
config: ServerConfig<TAdminStore>;
|
|
15
|
-
collections: CollectionDefinition[];
|
|
15
|
+
collections: readonly CollectionDefinition[];
|
|
16
16
|
db: IDbAdapter;
|
|
17
17
|
storage: IStorageProvider | undefined;
|
|
18
18
|
logger: BylineLogger;
|
|
@@ -28,7 +28,7 @@ declare const DOCUMENT_LEVEL_KEYS: Set<string>;
|
|
|
28
28
|
*/
|
|
29
29
|
export interface ParseContext {
|
|
30
30
|
/** All registered collection definitions. */
|
|
31
|
-
collections: CollectionDefinition[];
|
|
31
|
+
collections: readonly CollectionDefinition[];
|
|
32
32
|
/** Resolve a collection path → DB row id. */
|
|
33
33
|
resolveCollectionId: (path: string) => Promise<string>;
|
|
34
34
|
/**
|
|
@@ -118,9 +118,12 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
118
118
|
schema = z.number().int();
|
|
119
119
|
break;
|
|
120
120
|
case 'float':
|
|
121
|
-
case 'decimal':
|
|
122
121
|
schema = z.number();
|
|
123
122
|
break;
|
|
123
|
+
case 'decimal':
|
|
124
|
+
// Postgres numeric values are restored as strings to preserve precision.
|
|
125
|
+
schema = z.string();
|
|
126
|
+
break;
|
|
124
127
|
case 'image':
|
|
125
128
|
case 'file': {
|
|
126
129
|
// StoredFileValue — the object written by the upload endpoint and
|
|
@@ -132,7 +135,7 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
132
135
|
filename: z.string(),
|
|
133
136
|
originalFilename: z.string(),
|
|
134
137
|
mimeType: z.string(),
|
|
135
|
-
fileSize: z.
|
|
138
|
+
fileSize: z.number(),
|
|
136
139
|
storageProvider: z.string(),
|
|
137
140
|
storagePath: z.string(),
|
|
138
141
|
storageUrl: z.string().nullable().optional(),
|
|
@@ -181,7 +184,8 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
181
184
|
schema = arr;
|
|
182
185
|
}
|
|
183
186
|
else {
|
|
184
|
-
|
|
187
|
+
// Storage omits cleared values; it never restores a relation as null.
|
|
188
|
+
schema = relationValue;
|
|
185
189
|
}
|
|
186
190
|
break;
|
|
187
191
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defineCollection } from '../../@types/collection-types.js';
|
|
3
|
+
import { createCollectionSchemas } from './builder.js';
|
|
4
|
+
const Assets = defineCollection({
|
|
5
|
+
path: 'assets',
|
|
6
|
+
labels: { singular: 'Asset', plural: 'Assets' },
|
|
7
|
+
fields: [
|
|
8
|
+
{ name: 'price', type: 'decimal' },
|
|
9
|
+
{ name: 'download', type: 'file' },
|
|
10
|
+
{ name: 'owner', type: 'relation', targetCollection: 'people' },
|
|
11
|
+
],
|
|
12
|
+
});
|
|
13
|
+
const fieldsFixture = {
|
|
14
|
+
price: '1234567890.123456789',
|
|
15
|
+
download: {
|
|
16
|
+
fileId: 'file-1',
|
|
17
|
+
filename: 'report.pdf',
|
|
18
|
+
originalFilename: 'Report.pdf',
|
|
19
|
+
mimeType: 'application/pdf',
|
|
20
|
+
fileSize: 4096,
|
|
21
|
+
storageProvider: 'local',
|
|
22
|
+
storagePath: 'assets/report.pdf',
|
|
23
|
+
processingStatus: 'complete',
|
|
24
|
+
},
|
|
25
|
+
owner: {
|
|
26
|
+
targetDocumentId: 'person-1',
|
|
27
|
+
targetCollectionId: 'people',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
describe('collection Zod schemas', () => {
|
|
31
|
+
it('parses the canonical decimal and file storage read shapes', () => {
|
|
32
|
+
const parsed = createCollectionSchemas(Assets).get.parse({
|
|
33
|
+
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
34
|
+
status: 'published',
|
|
35
|
+
createdAt: '2026-07-15T12:00:00.000Z',
|
|
36
|
+
updatedAt: '2026-07-15T12:00:00.000Z',
|
|
37
|
+
fields: fieldsFixture,
|
|
38
|
+
});
|
|
39
|
+
expect(parsed.fields.price).toBe(fieldsFixture.price);
|
|
40
|
+
expect(parsed.fields.download?.fileSize).toBe(4096);
|
|
41
|
+
});
|
|
42
|
+
it('rejects null for a required single relation in the strict fields schema', () => {
|
|
43
|
+
const result = createCollectionSchemas(Assets).fields.safeParse({
|
|
44
|
+
...fieldsFixture,
|
|
45
|
+
owner: null,
|
|
46
|
+
});
|
|
47
|
+
expect(result.success).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -9,7 +9,7 @@ import { type CollectionDefinition } from '../@types/index.js';
|
|
|
9
9
|
import type { IDbAdapter } from '../@types/db-types.js';
|
|
10
10
|
import type { BylineLogger } from '../lib/logger.js';
|
|
11
11
|
export interface DiscoverCounterGroupsInput {
|
|
12
|
-
definitions: CollectionDefinition[];
|
|
12
|
+
definitions: readonly CollectionDefinition[];
|
|
13
13
|
db: IDbAdapter;
|
|
14
14
|
logger?: BylineLogger;
|
|
15
15
|
}
|
|
@@ -17,8 +17,8 @@ export interface CreateDocumentResult {
|
|
|
17
17
|
* 1. Default-locale enforcement: reject if `params.locale` is anything
|
|
18
18
|
* other than the configured default content locale (a brand-new
|
|
19
19
|
* document's canonical `path` lives in the default locale).
|
|
20
|
-
* 2.
|
|
21
|
-
* 3. `hooks.beforeCreate({ data, collectionPath })
|
|
20
|
+
* 2. Normalize date and numeric fields
|
|
21
|
+
* 3. `hooks.beforeCreate({ data, collectionPath })`, then normalize numerics again
|
|
22
22
|
* 4. Resolve `path` — explicit `params.path` → derive via `useAsPath`
|
|
23
23
|
* → UUID fallback.
|
|
24
24
|
* 5. `db.commands.documents.createDocumentVersion(...)` (action = 'create')
|
|
@@ -13,6 +13,7 @@ import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
|
13
13
|
import { slugify } from '../../utils/slugify.js';
|
|
14
14
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
|
+
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
16
17
|
import { actorId, appendTreeRoot, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
|
|
17
18
|
/**
|
|
18
19
|
* Create a new document.
|
|
@@ -21,8 +22,8 @@ import { actorId, appendTreeRoot, applyRichTextEmbed, derivePath, extractDocumen
|
|
|
21
22
|
* 1. Default-locale enforcement: reject if `params.locale` is anything
|
|
22
23
|
* other than the configured default content locale (a brand-new
|
|
23
24
|
* document's canonical `path` lives in the default locale).
|
|
24
|
-
* 2.
|
|
25
|
-
* 3. `hooks.beforeCreate({ data, collectionPath })
|
|
25
|
+
* 2. Normalize date and numeric fields
|
|
26
|
+
* 3. `hooks.beforeCreate({ data, collectionPath })`, then normalize numerics again
|
|
26
27
|
* 4. Resolve `path` — explicit `params.path` → derive via `useAsPath`
|
|
27
28
|
* → UUID fallback.
|
|
28
29
|
* 5. `db.commands.documents.createDocumentVersion(...)` (action = 'create')
|
|
@@ -42,7 +43,9 @@ export async function createDocument(ctx, params) {
|
|
|
42
43
|
}).log(ctx.logger);
|
|
43
44
|
}
|
|
44
45
|
normaliseDateFields(data);
|
|
46
|
+
normalizeNumericFields(definition.fields, data);
|
|
45
47
|
await invokeHook(hooks?.beforeCreate, { data, collectionPath });
|
|
48
|
+
normalizeNumericFields(definition.fields, data);
|
|
46
49
|
// Allocate counter-field values after beforeCreate so user-land hooks
|
|
47
50
|
// can run their own logic on the raw payload, but before the flatten/
|
|
48
51
|
// insert pass so the assigned values are persisted on the same write.
|
|
@@ -23,8 +23,8 @@ export interface UpdateDocumentWithPatchesResult {
|
|
|
23
23
|
*
|
|
24
24
|
* Flow:
|
|
25
25
|
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
26
|
-
* 2.
|
|
27
|
-
* 3. `hooks.beforeUpdate({ data, originalData, collectionPath })
|
|
26
|
+
* 2. Normalize date and numeric fields
|
|
27
|
+
* 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`, then normalize numerics again
|
|
28
28
|
* 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
29
29
|
* 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
|
|
30
30
|
*/
|
|
@@ -54,8 +54,8 @@ export declare function updateDocument(ctx: DocumentLifecycleContext, params: {
|
|
|
54
54
|
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
55
55
|
* 2. Optimistic concurrency check on `documentVersionId`
|
|
56
56
|
* 3. `applyPatches(definition, originalData, patches)` → `nextData`
|
|
57
|
-
* 4.
|
|
58
|
-
* 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })
|
|
57
|
+
* 4. Normalize date and numeric fields
|
|
58
|
+
* 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`, then normalize numerics again
|
|
59
59
|
* 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
60
60
|
* 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
|
|
61
61
|
*
|
|
@@ -13,6 +13,7 @@ import { applyPatches } from '../../patches/index.js';
|
|
|
13
13
|
import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
14
14
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
|
+
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
16
17
|
import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, selfHealTreePlacement, } from './internals.js';
|
|
17
18
|
/**
|
|
18
19
|
* Update a document via full replacement (PUT semantics).
|
|
@@ -22,8 +23,8 @@ import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invok
|
|
|
22
23
|
*
|
|
23
24
|
* Flow:
|
|
24
25
|
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
25
|
-
* 2.
|
|
26
|
-
* 3. `hooks.beforeUpdate({ data, originalData, collectionPath })
|
|
26
|
+
* 2. Normalize date and numeric fields
|
|
27
|
+
* 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`, then normalize numerics again
|
|
27
28
|
* 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
28
29
|
* 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
|
|
29
30
|
*/
|
|
@@ -43,7 +44,9 @@ export async function updateDocument(ctx, params) {
|
|
|
43
44
|
});
|
|
44
45
|
const originalData = latest ?? {};
|
|
45
46
|
normaliseDateFields(data);
|
|
47
|
+
normalizeNumericFields(definition.fields, data);
|
|
46
48
|
await invokeHook(hooks?.beforeUpdate, { data, originalData, collectionPath });
|
|
49
|
+
normalizeNumericFields(definition.fields, data);
|
|
47
50
|
// Counter fields are immutable: carry their values forward from the
|
|
48
51
|
// previous version rather than trusting whatever (or nothing) the
|
|
49
52
|
// caller sent. Lazy-allocates when a counter was added to the
|
|
@@ -111,8 +114,8 @@ export async function updateDocument(ctx, params) {
|
|
|
111
114
|
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
112
115
|
* 2. Optimistic concurrency check on `documentVersionId`
|
|
113
116
|
* 3. `applyPatches(definition, originalData, patches)` → `nextData`
|
|
114
|
-
* 4.
|
|
115
|
-
* 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })
|
|
117
|
+
* 4. Normalize date and numeric fields
|
|
118
|
+
* 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`, then normalize numerics again
|
|
116
119
|
* 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
117
120
|
* 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
|
|
118
121
|
*
|
|
@@ -160,8 +163,10 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
160
163
|
const nextData = patchedDocument;
|
|
161
164
|
// 4. Normalise dates.
|
|
162
165
|
normaliseDateFields(nextData);
|
|
166
|
+
normalizeNumericFields(definition.fields, nextData);
|
|
163
167
|
// 5. beforeUpdate hook.
|
|
164
168
|
await invokeHook(hooks?.beforeUpdate, { data: nextData, originalData, collectionPath });
|
|
169
|
+
normalizeNumericFields(definition.fields, nextData);
|
|
165
170
|
// 5b. Carry counter values forward from the previous version (or
|
|
166
171
|
// lazy-allocate if the previous version is missing a value). See
|
|
167
172
|
// updateDocument for the rationale — patch-based updates are
|