@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.
- package/dist/@types/collection-types.d.ts +6 -0
- 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/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
|
+
});
|
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
|
|
@@ -24,6 +24,14 @@ const minimalCollection = {
|
|
|
24
24
|
],
|
|
25
25
|
},
|
|
26
26
|
};
|
|
27
|
+
const numericCollection = {
|
|
28
|
+
...minimalCollection,
|
|
29
|
+
fields: [
|
|
30
|
+
{ name: 'quantity', type: 'integer' },
|
|
31
|
+
{ name: 'score', type: 'float' },
|
|
32
|
+
{ name: 'price', type: 'decimal' },
|
|
33
|
+
],
|
|
34
|
+
};
|
|
27
35
|
/** Build a mock IDbAdapter. Returns the adapter plus individual mock fns. */
|
|
28
36
|
function createMockDb() {
|
|
29
37
|
const createDocumentVersion = vi.fn().mockResolvedValue({
|
|
@@ -235,6 +243,23 @@ describe('Document lifecycle service', () => {
|
|
|
235
243
|
const persistedData = createDocumentVersion.mock.calls[0]?.[0].documentData;
|
|
236
244
|
expect(persistedData.title).toBe('Mutated');
|
|
237
245
|
});
|
|
246
|
+
it('normalizes numeric values before and after beforeCreate', async () => {
|
|
247
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
248
|
+
const beforeCreate = vi.fn(({ data }) => {
|
|
249
|
+
expect(data).toMatchObject({ quantity: 2, score: 1.5, price: '10.00' });
|
|
250
|
+
data.quantity = '3';
|
|
251
|
+
data.price = 12.5;
|
|
252
|
+
});
|
|
253
|
+
const definition = { ...numericCollection, hooks: { beforeCreate } };
|
|
254
|
+
await createDocument(buildCtx(db, definition), {
|
|
255
|
+
data: { quantity: '2', score: '1.5', price: ' 10.00 ' },
|
|
256
|
+
});
|
|
257
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].documentData).toMatchObject({
|
|
258
|
+
quantity: 3,
|
|
259
|
+
score: 1.5,
|
|
260
|
+
price: '12.5',
|
|
261
|
+
});
|
|
262
|
+
});
|
|
238
263
|
it('derives path from useAsPath source field via the slugifier', async () => {
|
|
239
264
|
const { db, createDocumentVersion } = createMockDb();
|
|
240
265
|
const definition = { ...minimalCollection, useAsPath: 'title' };
|
|
@@ -378,6 +403,24 @@ describe('Document lifecycle service', () => {
|
|
|
378
403
|
}));
|
|
379
404
|
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
380
405
|
});
|
|
406
|
+
it('normalizes numeric values before and after beforeUpdate', async () => {
|
|
407
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
408
|
+
getDocumentById.mockResolvedValue({ fields: { quantity: 1, score: 1, price: '1.0' } });
|
|
409
|
+
const beforeUpdate = vi.fn(({ data }) => {
|
|
410
|
+
expect(data.score).toBe(25);
|
|
411
|
+
data.score = '3.5';
|
|
412
|
+
});
|
|
413
|
+
const definition = { ...numericCollection, hooks: { beforeUpdate } };
|
|
414
|
+
await updateDocument(buildCtx(db, definition), {
|
|
415
|
+
documentId: 'doc-1',
|
|
416
|
+
data: { quantity: '2', score: '2.5e1', price: 4.25 },
|
|
417
|
+
});
|
|
418
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].documentData).toEqual({
|
|
419
|
+
quantity: 2,
|
|
420
|
+
score: 3.5,
|
|
421
|
+
price: '4.25',
|
|
422
|
+
});
|
|
423
|
+
});
|
|
381
424
|
it('afterUpdate receives documentId and documentVersionId', async () => {
|
|
382
425
|
const afterUpdate = vi.fn();
|
|
383
426
|
const { db, getDocumentById } = createMockDb();
|
|
@@ -670,6 +713,29 @@ describe('Document lifecycle service', () => {
|
|
|
670
713
|
documentVersionId: 'ver-1',
|
|
671
714
|
}));
|
|
672
715
|
});
|
|
716
|
+
it('normalizes patched and hook-produced numeric values before persistence', async () => {
|
|
717
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
718
|
+
getDocumentById.mockResolvedValue({
|
|
719
|
+
fields: { quantity: 1, score: 1, price: '1.00' },
|
|
720
|
+
});
|
|
721
|
+
const beforeUpdate = vi.fn(({ data }) => {
|
|
722
|
+
expect(data.quantity).toBe(7);
|
|
723
|
+
data.price = ' 8.500 ';
|
|
724
|
+
});
|
|
725
|
+
const definition = { ...numericCollection, hooks: { beforeUpdate } };
|
|
726
|
+
await updateDocumentWithPatches(buildCtx(db, definition), {
|
|
727
|
+
documentId: 'doc-1',
|
|
728
|
+
patches: [
|
|
729
|
+
{ kind: 'field.set', path: 'quantity', value: '7.0' },
|
|
730
|
+
{ kind: 'field.set', path: 'score', value: '2.75' },
|
|
731
|
+
],
|
|
732
|
+
});
|
|
733
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].documentData).toEqual({
|
|
734
|
+
quantity: 7,
|
|
735
|
+
score: 2.75,
|
|
736
|
+
price: '8.500',
|
|
737
|
+
});
|
|
738
|
+
});
|
|
673
739
|
});
|
|
674
740
|
// -----------------------------------------------------------------------
|
|
675
741
|
// changeDocumentStatus
|
package/dist/services/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './document-read.js';
|
|
|
9
9
|
export * from './document-to-markdown.js';
|
|
10
10
|
export * from './field-upload.js';
|
|
11
11
|
export { type InterfaceI18nConfig, type TranslationDriftWarning, type ValidateTranslationsResult, validateTranslations, } from './i18n-validator.js';
|
|
12
|
+
export { type CanonicalNumericFieldType, type CanonicalNumericValue, isCanonicalNumericValue, normalizeNumericFields, normalizeNumericValue, } from './normalize-numeric-fields.js';
|
|
12
13
|
export { type CycleRelationValue, createReadContext, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateOptions, type PopulateSpec, populateDocuments, type ReadContext, resolveIdentityField, type UnresolvedRelationValue, } from './populate.js';
|
|
13
14
|
export { buildRelationSummaryPopulateMap, type RelationTargetResolver, resolveRelationProjection, } from './relation-projection.js';
|
|
14
15
|
export { type EmbedRichTextFieldsOptions, embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
|
package/dist/services/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export * from './document-read.js';
|
|
|
10
10
|
export * from './document-to-markdown.js';
|
|
11
11
|
export * from './field-upload.js';
|
|
12
12
|
export { validateTranslations, } from './i18n-validator.js';
|
|
13
|
+
export { isCanonicalNumericValue, normalizeNumericFields, normalizeNumericValue, } from './normalize-numeric-fields.js';
|
|
13
14
|
export { createReadContext, populateDocuments, resolveIdentityField, } from './populate.js';
|
|
14
15
|
export { buildRelationSummaryPopulateMap, resolveRelationProjection, } from './relation-projection.js';
|
|
15
16
|
export { embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
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 type { FieldSet } from '../@types/index.js';
|
|
9
|
+
export type CanonicalNumericFieldType = 'integer' | 'float' | 'decimal';
|
|
10
|
+
export type CanonicalNumericValue = number | string;
|
|
11
|
+
/** Return whether a value already has the storage-facing canonical type and shape. */
|
|
12
|
+
export declare function isCanonicalNumericValue(fieldType: CanonicalNumericFieldType, value: unknown): value is CanonicalNumericValue;
|
|
13
|
+
/**
|
|
14
|
+
* Convert a tolerant numeric write value to its canonical representation.
|
|
15
|
+
* `undefined` means the value was empty and should be removed.
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizeNumericValue(fieldType: CanonicalNumericFieldType, value: unknown, path: string): CanonicalNumericValue | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Normalize every user-writable numeric leaf in a schema-shaped data tree.
|
|
20
|
+
* Mutates `data` in place. Counter fields are deliberately excluded because
|
|
21
|
+
* their values are supplied by the lifecycle allocator.
|
|
22
|
+
*/
|
|
23
|
+
export declare function normalizeNumericFields(fields: FieldSet, data: Record<string, any>): void;
|