@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,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.string(),
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
- schema = relationValue.nullable();
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
+ });
@@ -19,7 +19,7 @@ export interface CollectionRecord {
19
19
  schemaHash: string;
20
20
  }
21
21
  export interface EnsureCollectionsInput {
22
- definitions: CollectionDefinition[];
22
+ definitions: readonly CollectionDefinition[];
23
23
  db: IDbAdapter;
24
24
  logger?: BylineLogger;
25
25
  }
@@ -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. `normaliseDateFields(data)`
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. `normaliseDateFields(data)`
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. `normaliseDateFields(data)`
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. `normaliseDateFields(nextData)`
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. `normaliseDateFields(data)`
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. `normaliseDateFields(nextData)`
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
@@ -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';
@@ -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;