@byline/core 3.20.4 → 4.0.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 (103) hide show
  1. package/dist/@types/collection-types.d.ts +72 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-data-types.d.ts +8 -10
  4. package/dist/@types/field-data-types.js +1 -1
  5. package/dist/@types/field-data-types.test.node.d.ts +1 -0
  6. package/dist/@types/field-data-types.test.node.js +70 -0
  7. package/dist/@types/field-types.d.ts +25 -1
  8. package/dist/@types/index.d.ts +1 -0
  9. package/dist/@types/index.js +1 -0
  10. package/dist/@types/query-predicate.d.ts +3 -3
  11. package/dist/@types/relation-types.d.ts +38 -0
  12. package/dist/@types/relation-types.js +8 -0
  13. package/dist/@types/search-types.d.ts +3 -4
  14. package/dist/@types/site-config.d.ts +52 -13
  15. package/dist/auth/apply-before-read.d.ts +23 -11
  16. package/dist/auth/apply-before-read.js +139 -33
  17. package/dist/auth/apply-before-read.test.node.js +241 -3
  18. package/dist/auth/index.d.ts +1 -1
  19. package/dist/auth/index.js +1 -1
  20. package/dist/auth/read-context-scope.d.ts +20 -0
  21. package/dist/auth/read-context-scope.js +48 -0
  22. package/dist/codegen/fixtures/all-fields.d.ts +342 -0
  23. package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
  24. package/dist/codegen/fixtures/all-fields.expected.js +1 -0
  25. package/dist/codegen/fixtures/all-fields.js +94 -0
  26. package/dist/codegen/index.d.ts +16 -0
  27. package/dist/codegen/index.js +431 -0
  28. package/dist/codegen/index.test.node.d.ts +1 -0
  29. package/dist/codegen/index.test.node.js +230 -0
  30. package/dist/config/attach-hooks.d.ts +25 -0
  31. package/dist/config/attach-hooks.js +130 -0
  32. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  33. package/dist/config/attach-hooks.test.node.js +173 -0
  34. package/dist/config/config-hooks.test.node.d.ts +1 -0
  35. package/dist/config/config-hooks.test.node.js +56 -0
  36. package/dist/config/config.d.ts +9 -5
  37. package/dist/config/config.js +20 -2
  38. package/dist/config/routes.d.ts +5 -5
  39. package/dist/config/routes.js +42 -9
  40. package/dist/config/routes.test.node.d.ts +1 -0
  41. package/dist/config/routes.test.node.js +152 -0
  42. package/dist/core.d.ts +3 -3
  43. package/dist/core.js +20 -14
  44. package/dist/core.test.node.d.ts +1 -0
  45. package/dist/core.test.node.js +28 -0
  46. package/dist/index.d.ts +4 -3
  47. package/dist/index.js +4 -3
  48. package/dist/lib/errors.d.ts +13 -0
  49. package/dist/lib/errors.js +14 -0
  50. package/dist/query/parse-where.d.ts +10 -1
  51. package/dist/query/parse-where.js +146 -2
  52. package/dist/query/parse-where.test.node.js +60 -1
  53. package/dist/schemas/zod/builder.js +7 -3
  54. package/dist/schemas/zod/builder.test.node.d.ts +1 -0
  55. package/dist/schemas/zod/builder.test.node.js +49 -0
  56. package/dist/services/collection-bootstrap.d.ts +1 -1
  57. package/dist/services/collection-bootstrap.test.node.js +30 -59
  58. package/dist/services/discover-counter-groups.d.ts +1 -1
  59. package/dist/services/discover-counter-groups.test.node.js +23 -0
  60. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  61. package/dist/services/document-lifecycle/audit.js +32 -1
  62. package/dist/services/document-lifecycle/create.d.ts +2 -2
  63. package/dist/services/document-lifecycle/create.js +18 -8
  64. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  65. package/dist/services/document-lifecycle/delete.js +91 -26
  66. package/dist/services/document-lifecycle/index.d.ts +1 -1
  67. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  68. package/dist/services/document-lifecycle/internals.js +22 -46
  69. package/dist/services/document-lifecycle/status.d.ts +1 -1
  70. package/dist/services/document-lifecycle/status.js +20 -3
  71. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  72. package/dist/services/document-lifecycle/system-fields.js +112 -74
  73. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  74. package/dist/services/document-lifecycle/tree.js +244 -123
  75. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  76. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  77. package/dist/services/document-lifecycle/update.d.ts +4 -4
  78. package/dist/services/document-lifecycle/update.js +11 -5
  79. package/dist/services/document-lifecycle.test.node.js +426 -16
  80. package/dist/services/document-read.d.ts +14 -6
  81. package/dist/services/document-read.js +47 -9
  82. package/dist/services/field-upload.test.node.js +70 -0
  83. package/dist/services/index.d.ts +3 -2
  84. package/dist/services/index.js +3 -2
  85. package/dist/services/normalize-numeric-fields.d.ts +23 -0
  86. package/dist/services/normalize-numeric-fields.js +89 -0
  87. package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
  88. package/dist/services/normalize-numeric-fields.test.node.js +99 -0
  89. package/dist/services/populate.d.ts +9 -23
  90. package/dist/services/populate.js +180 -28
  91. package/dist/services/populate.test.node.js +59 -0
  92. package/dist/services/richtext-embed.d.ts +39 -2
  93. package/dist/services/richtext-embed.js +4 -34
  94. package/dist/services/richtext-embed.test.node.js +15 -0
  95. package/dist/services/richtext-populate.d.ts +21 -3
  96. package/dist/services/richtext-populate.js +160 -19
  97. package/dist/services/richtext-populate.test.node.js +523 -2
  98. package/dist/services/validate-search-config.d.ts +1 -1
  99. package/dist/storage/collection-fingerprint.js +6 -0
  100. package/dist/storage/collection-fingerprint.test.node.js +21 -0
  101. package/dist/utils/root-relative-redirect.d.ts +6 -0
  102. package/dist/utils/root-relative-redirect.js +37 -0
  103. package/package.json +7 -2
@@ -21,6 +21,8 @@
21
21
  * `beforeCreate` / `afterCreate` hooks.
22
22
  */
23
23
  import { normalizeCollectionHook, resolveHooks } from '../@types/index.js';
24
+ import { resolveReadContextRoot } from '../auth/read-context-scope.js';
25
+ import { ERR_READ_RECURSION } from '../lib/errors.js';
24
26
  async function invokeHook(hook, ctx) {
25
27
  const fns = normalizeCollectionHook(hook);
26
28
  for (const fn of fns) {
@@ -30,13 +32,16 @@ async function invokeHook(hook, ctx) {
30
32
  /**
31
33
  * Fire the `afterRead` hook for one reconstructed document.
32
34
  *
33
- * No-op when the collection has no hook. No-op when the document has
34
- * already been through `afterRead` in this `ReadContext` enforces the
35
- * "once per document per logical request" rule that forecloses the
36
- * A→B→A infinite loop when hooks perform their own reads.
35
+ * No-op when the collection has no hook, when this exact materialized object
36
+ * completed the hook already. An active logical version fails closed with
37
+ * `ERR_READ_RECURSION`: even the same object may be only partially processed,
38
+ * so returning it cannot be proven safe. Fresh raw objects are always redacted
39
+ * once the active call completes. Failed hooks do not mark the object complete,
40
+ * and active state is cleared in `finally` so a later retry can proceed.
37
41
  *
38
42
  * The hook receives the raw storage-shape document (mutable) and the
39
- * shared `ReadContext` so it can thread the same context through any
43
+ * operation's authenticated context plus the shared `ReadContext`, so it can
44
+ * redact by actor and thread the same context through any
40
45
  * nested `client.collection(...).findById(id, { _readContext })` calls.
41
46
  */
42
47
  export async function applyAfterRead(params) {
@@ -47,14 +52,47 @@ export async function applyAfterRead(params) {
47
52
  const docId = params.doc?.document_id;
48
53
  if (typeof docId !== 'string')
49
54
  return;
50
- const key = `${params.definition.path}:${docId}`;
51
- if (params.readContext.afterReadFired.has(key))
55
+ const versionId = typeof params.doc?.document_version_id === 'string'
56
+ ? params.doc.document_version_id
57
+ : `document:${docId}`;
58
+ const state = getAfterReadState(params.readContext);
59
+ if (state.processed.has(params.doc))
52
60
  return;
53
- params.readContext.afterReadFired.add(key);
61
+ // ReadContext already scopes the operation; keeping identity out of this key
62
+ // prevents a nested caller from evading the guard with another context clone.
63
+ const activeKey = `${params.definition.path}:${versionId}`;
64
+ if (state.active.has(activeKey)) {
65
+ throw ERR_READ_RECURSION({
66
+ message: `afterRead recursion blocked for active version '${versionId}'`,
67
+ details: {
68
+ collectionPath: params.definition.path,
69
+ documentId: docId,
70
+ documentVersionId: versionId,
71
+ },
72
+ });
73
+ }
54
74
  const ctx = {
55
75
  doc: params.doc,
56
76
  collectionPath: params.definition.path,
77
+ requestContext: params.requestContext,
57
78
  readContext: params.readContext,
58
79
  };
59
- await invokeHook(hook, ctx);
80
+ state.active.add(activeKey);
81
+ try {
82
+ await invokeHook(hook, ctx);
83
+ state.processed.add(params.doc);
84
+ }
85
+ finally {
86
+ state.active.delete(activeKey);
87
+ }
88
+ }
89
+ const afterReadStates = new WeakMap();
90
+ function getAfterReadState(readContext) {
91
+ const root = resolveReadContextRoot(readContext);
92
+ const existing = afterReadStates.get(root);
93
+ if (existing)
94
+ return existing;
95
+ const state = { active: new Set(), processed: new WeakSet() };
96
+ afterReadStates.set(root, state);
97
+ return state;
60
98
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { createSuperAdminContext } from '@byline/auth';
9
9
  import { describe, expect, it, vi } from 'vitest';
10
+ import { commitHookAttachment, prepareHookAttachment } from '../config/attach-hooks.js';
10
11
  import { BylineError, ErrorCodes } from '../lib/errors.js';
11
12
  import { uploadField } from './field-upload.js';
12
13
  const uploadCollection = {
@@ -62,12 +63,23 @@ function createMockDb() {
62
63
  setOrderKey: vi.fn(),
63
64
  placeTreeNode: vi.fn(),
64
65
  removeFromTree: vi.fn(),
66
+ promoteChildrenAndRemoveFromTree: vi.fn(async () => ({
67
+ removed: {
68
+ changed: false,
69
+ before: { placed: false, parentDocumentId: null, orderKey: null, index: null },
70
+ after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
71
+ beforeSiblingDocumentIds: [],
72
+ beforeSubtreeDocumentIds: [],
73
+ },
74
+ promoted: [],
75
+ })),
65
76
  },
66
77
  counters: {
67
78
  ensureCounterGroup: vi.fn(),
68
79
  nextCounterValue: vi.fn(),
69
80
  nextScopedCounterValue: vi.fn(),
70
81
  },
82
+ audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
71
83
  },
72
84
  queries: {
73
85
  collections: {
@@ -76,6 +88,7 @@ function createMockDb() {
76
88
  getCollectionById: vi.fn(),
77
89
  },
78
90
  documents: {
91
+ getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
79
92
  getDocumentById: vi.fn(),
80
93
  getCurrentVersionMetadata: vi.fn(),
81
94
  getCurrentPath: vi.fn(),
@@ -96,7 +109,18 @@ function createMockDb() {
96
109
  getTreeParent: vi.fn(),
97
110
  getTreeSubtree: vi.fn(),
98
111
  },
112
+ audit: {
113
+ getDocumentAuditLog: vi.fn(async () => ({
114
+ entries: [],
115
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
116
+ })),
117
+ findAuditLog: vi.fn(async () => ({
118
+ entries: [],
119
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
120
+ })),
121
+ },
99
122
  },
123
+ withTransaction: async (fn) => fn(),
100
124
  };
101
125
  return { db, createDocumentVersion };
102
126
  }
@@ -162,6 +186,52 @@ function buildCtx(overrides) {
162
186
  return { ctx, createDocumentVersion, upload, del, imageProcessor };
163
187
  }
164
188
  describe('uploadField service', () => {
189
+ it('fires registry hooks for a nested array instance selected by its unique upload leaf', async () => {
190
+ const beforeStore = vi.fn();
191
+ const publicationFile = {
192
+ name: 'publicationFile',
193
+ type: 'file',
194
+ upload: { mimeTypes: ['application/pdf'] },
195
+ };
196
+ const definition = {
197
+ path: 'documents',
198
+ labels: { singular: 'Document', plural: 'Documents' },
199
+ fields: [
200
+ {
201
+ name: 'files',
202
+ type: 'array',
203
+ fields: [{ name: 'filesGroup', type: 'group', fields: [publicationFile] }],
204
+ },
205
+ ],
206
+ };
207
+ commitHookAttachment(prepareHookAttachment({
208
+ collections: [definition],
209
+ hooks: {
210
+ uploads: {
211
+ 'documents.files.filesGroup.publicationFile': { beforeStore },
212
+ },
213
+ },
214
+ }));
215
+ const { ctx } = buildCtx({
216
+ definition,
217
+ collectionPath: 'documents',
218
+ fieldName: 'publicationFile',
219
+ });
220
+ await uploadField(ctx, {
221
+ buffer: Buffer.from('pdf'),
222
+ originalFilename: 'publication.pdf',
223
+ mimeType: 'application/pdf',
224
+ fileSize: 3,
225
+ fields: { runtimeFormPath: 'files[2].filesGroup.publicationFile' },
226
+ shouldCreateDocument: false,
227
+ });
228
+ expect(beforeStore).toHaveBeenCalledOnce();
229
+ expect(beforeStore).toHaveBeenCalledWith(expect.objectContaining({
230
+ fieldName: 'publicationFile',
231
+ field: publicationFile,
232
+ fields: { runtimeFormPath: 'files[2].filesGroup.publicationFile' },
233
+ }));
234
+ });
165
235
  it('uploads a file without creating a document when requested', async () => {
166
236
  const { ctx, createDocumentVersion, upload } = buildCtx();
167
237
  const result = await uploadField(ctx, {
@@ -1,4 +1,4 @@
1
- export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
1
+ export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_VALIDATION, } from '../lib/errors.js';
2
2
  export { normaliseDateFields } from '../utils/normalise-dates.js';
3
3
  export { type AssignCounterValuesInput, assignCounterValues } from './assign-counter-values.js';
4
4
  export { type BuildSearchDocumentOptions, buildSearchDocument, resolveSearchZones, type SearchSourceDocument, } from './build-search-document.js';
@@ -9,9 +9,10 @@ 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';
15
- export { collectRichTextLeaves, type PopulateRichTextFieldsOptions, populateRichTextFields, type RichTextAdapterPresence, type RichTextLeaf, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
16
+ export { collectRichTextLeaves, createRichTextDocumentReader, type PopulateRichTextFieldsOptions, populateRichTextFields, type RichTextAdapterPresence, type RichTextLeaf, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
16
17
  export { type SearchProviderPresence, validateSearchConfig, } from './validate-search-config.js';
17
18
  export { type FieldLeaf, walkFieldTree } from './walk-field-tree.js';
@@ -1,5 +1,5 @@
1
1
  // packages/core/src/services/index.ts
2
- export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
2
+ export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_VALIDATION, } from '../lib/errors.js';
3
3
  export { normaliseDateFields } from '../utils/normalise-dates.js';
4
4
  export { assignCounterValues } from './assign-counter-values.js';
5
5
  export { buildSearchDocument, resolveSearchZones, } from './build-search-document.js';
@@ -10,9 +10,10 @@ 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';
16
- export { collectRichTextLeaves, populateRichTextFields, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
17
+ export { collectRichTextLeaves, createRichTextDocumentReader, populateRichTextFields, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
17
18
  export { validateSearchConfig, } from './validate-search-config.js';
18
19
  export { walkFieldTree } from './walk-field-tree.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;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { ERR_VALIDATION } from '../lib/errors.js';
9
+ import { walkFieldTree } from './walk-field-tree.js';
10
+ const NUMERIC_LITERAL_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
11
+ /** Return whether a value already has the storage-facing canonical type and shape. */
12
+ export function isCanonicalNumericValue(fieldType, value) {
13
+ if (fieldType === 'decimal') {
14
+ return typeof value === 'string' && value.trim() === value && NUMERIC_LITERAL_RE.test(value);
15
+ }
16
+ return (typeof value === 'number' &&
17
+ Number.isFinite(value) &&
18
+ (fieldType !== 'integer' || Number.isInteger(value)));
19
+ }
20
+ /**
21
+ * Convert a tolerant numeric write value to its canonical representation.
22
+ * `undefined` means the value was empty and should be removed.
23
+ */
24
+ export function normalizeNumericValue(fieldType, value, path) {
25
+ if (typeof value === 'string') {
26
+ const trimmed = value.trim();
27
+ if (trimmed === '')
28
+ return undefined;
29
+ if (!NUMERIC_LITERAL_RE.test(trimmed))
30
+ throwNumericValidation(fieldType, value, path);
31
+ if (fieldType === 'decimal')
32
+ return trimmed;
33
+ const numberValue = Number(trimmed);
34
+ if (!Number.isFinite(numberValue))
35
+ throwNumericValidation(fieldType, value, path);
36
+ if (fieldType === 'integer' && !Number.isInteger(numberValue)) {
37
+ throwNumericValidation(fieldType, value, path);
38
+ }
39
+ return numberValue;
40
+ }
41
+ if (typeof value === 'number') {
42
+ if (!Number.isFinite(value))
43
+ throwNumericValidation(fieldType, value, path);
44
+ if (fieldType === 'integer' && !Number.isInteger(value)) {
45
+ throwNumericValidation(fieldType, value, path);
46
+ }
47
+ return fieldType === 'decimal' ? String(value) : value;
48
+ }
49
+ throwNumericValidation(fieldType, value, path);
50
+ }
51
+ /**
52
+ * Normalize every user-writable numeric leaf in a schema-shaped data tree.
53
+ * Mutates `data` in place. Counter fields are deliberately excluded because
54
+ * their values are supplied by the lifecycle allocator.
55
+ */
56
+ export function normalizeNumericFields(fields, data) {
57
+ for (const leaf of walkFieldTree(fields, data)) {
58
+ if (!isWritableNumericType(leaf.field.type))
59
+ continue;
60
+ if (leaf.field.localized === true && isLocaleMap(leaf.value)) {
61
+ for (const [locale, localeValue] of Object.entries(leaf.value)) {
62
+ normalizeLeafValue(leaf.field.type, leaf.value, locale, localeValue, `${leaf.fieldPath}.${locale}`);
63
+ }
64
+ continue;
65
+ }
66
+ normalizeLeafValue(leaf.field.type, leaf.parent, leaf.key, leaf.value, leaf.fieldPath);
67
+ }
68
+ }
69
+ function normalizeLeafValue(fieldType, parent, key, value, path) {
70
+ const normalized = normalizeNumericValue(fieldType, value, path);
71
+ if (normalized === undefined) {
72
+ delete parent[key];
73
+ }
74
+ else {
75
+ parent[key] = normalized;
76
+ }
77
+ }
78
+ function isWritableNumericType(type) {
79
+ return type === 'integer' || type === 'float' || type === 'decimal';
80
+ }
81
+ function isLocaleMap(value) {
82
+ return value != null && typeof value === 'object' && !Array.isArray(value);
83
+ }
84
+ function throwNumericValidation(fieldType, value, path) {
85
+ throw ERR_VALIDATION({
86
+ message: `invalid ${fieldType} value at '${path}'`,
87
+ details: { path, fieldType, value },
88
+ });
89
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,99 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { BylineError, ErrorCodes } from '../lib/errors.js';
10
+ import { normalizeNumericFields, normalizeNumericValue } from './normalize-numeric-fields.js';
11
+ const fields = [
12
+ { name: 'count', type: 'integer' },
13
+ { name: 'ratio', type: 'float' },
14
+ { name: 'price', type: 'decimal' },
15
+ { name: 'sequence', type: 'counter', group: 'test' },
16
+ {
17
+ name: 'settings',
18
+ type: 'group',
19
+ fields: [{ name: 'threshold', type: 'float' }],
20
+ },
21
+ {
22
+ name: 'rows',
23
+ type: 'array',
24
+ fields: [{ name: 'quantity', type: 'integer' }],
25
+ },
26
+ {
27
+ name: 'content',
28
+ type: 'blocks',
29
+ blocks: [
30
+ {
31
+ blockType: 'amount',
32
+ fields: [{ name: 'value', type: 'decimal' }],
33
+ },
34
+ ],
35
+ },
36
+ ];
37
+ describe('normalizeNumericFields', () => {
38
+ it('normalizes numeric leaves through groups, arrays, and blocks while leaving counters alone', () => {
39
+ const data = {
40
+ count: '12',
41
+ ratio: ' 1.25e2 ',
42
+ price: ' 001.2300 ',
43
+ sequence: 'caller-owned-value',
44
+ settings: { threshold: 2 },
45
+ rows: [{ quantity: '3.0' }, { quantity: 4 }],
46
+ content: [{ _type: 'amount', value: 5.5 }],
47
+ };
48
+ normalizeNumericFields(fields, data);
49
+ expect(data).toEqual({
50
+ count: 12,
51
+ ratio: 125,
52
+ price: '001.2300',
53
+ sequence: 'caller-owned-value',
54
+ settings: { threshold: 2 },
55
+ rows: [{ quantity: 3 }, { quantity: 4 }],
56
+ content: [{ _type: 'amount', value: '5.5' }],
57
+ });
58
+ normalizeNumericFields(fields, data);
59
+ expect(data.price).toBe('001.2300');
60
+ });
61
+ it('removes empty and whitespace-only values', () => {
62
+ const data = { count: '', ratio: ' ', price: '\t' };
63
+ normalizeNumericFields(fields, data);
64
+ expect(data).toEqual({});
65
+ });
66
+ it('normalizes all-locale value maps and reports the locale-qualified path', () => {
67
+ const localizedFields = [
68
+ { name: 'amount', type: 'decimal', localized: true },
69
+ ];
70
+ const data = { amount: { en: ' 1.20 ', fr: 'not-a-number', de: '' } };
71
+ expect(() => normalizeNumericFields(localizedFields, data)).toThrowError(expect.objectContaining({
72
+ code: ErrorCodes.VALIDATION,
73
+ details: expect.objectContaining({ path: 'amount.fr' }),
74
+ }));
75
+ expect(data.amount.en).toBe('1.20');
76
+ });
77
+ it.each([
78
+ ['integer', '1.2'],
79
+ ['integer', Number.NaN],
80
+ ['float', Number.POSITIVE_INFINITY],
81
+ ['float', '1e'],
82
+ ['decimal', '--1'],
83
+ ])('rejects invalid %s input', (fieldType, value) => {
84
+ expect(() => normalizeNumericValue(fieldType, value, 'nested.2.value')).toThrowError(expect.objectContaining({
85
+ code: ErrorCodes.VALIDATION,
86
+ details: expect.objectContaining({ path: 'nested.2.value' }),
87
+ }));
88
+ });
89
+ it('throws a BylineError with ERR_VALIDATION', () => {
90
+ try {
91
+ normalizeNumericValue('integer', {}, 'count');
92
+ expect.fail('expected ERR_VALIDATION');
93
+ }
94
+ catch (error) {
95
+ expect(error).toBeInstanceOf(BylineError);
96
+ expect(error).toMatchObject({ code: ErrorCodes.VALIDATION });
97
+ }
98
+ });
99
+ });
@@ -128,16 +128,17 @@
128
128
  * wins; otherwise selects union together, identity field is always
129
129
  * added).
130
130
  */
131
- import type { RequestContext } from '@byline/auth';
131
+ import { type RequestContext } from '@byline/auth';
132
132
  import type { CollectionDefinition, FieldSet, IDbAdapter, PopulateFieldSpec, PopulateSpec, ReadContext, ReadMode, RelatedDocumentValue, RelationField } from '../@types/index.js';
133
133
  export type { ReadContext } from '../@types/index.js';
134
134
  /** Build a fresh ReadContext. */
135
135
  export declare function createReadContext(overrides?: Partial<ReadContext>): ReadContext;
136
136
  export type { PopulateFieldOptions, PopulateFieldSpec, PopulateMap, PopulateSpec, } from '../@types/populate-types.js';
137
+ export type { CycleRelationValue, PopulatedRelationValue, RelationFieldReadValue, RelationReadValue, UnpopulatedRelationValue, UnresolvedRelationValue, } from '../@types/relation-types.js';
137
138
  export interface PopulateOptions {
138
139
  db: IDbAdapter;
139
140
  /** Every collection definition in the app — needed to resolve target fields. */
140
- collections: CollectionDefinition[];
141
+ collections: readonly CollectionDefinition[];
141
142
  /** The source collection id for `documents`. */
142
143
  collectionId: string;
143
144
  /**
@@ -178,13 +179,17 @@ export interface PopulateOptions {
178
179
  /**
179
180
  * Request-scoped auth context. Required when any target collection in the
180
181
  * walk has a `beforeRead` hook configured. Each target's hook is invoked
181
- * (and cached on `readContext.beforeReadCache`) before its batch fetch,
182
+ * (and cached in private authority-bound state) before its batch fetch,
182
183
  * and the resulting predicate is ANDed onto the fetch's WHERE. When
183
184
  * omitted — most synthetic / test call paths — `beforeRead` hooks are
184
185
  * skipped entirely; the production read paths all forward this through
185
- * from `CollectionHandle`.
186
+ * from `CollectionHandle`. Low-level synthetic calls still receive an
187
+ * anonymous operation context in `afterRead`, so the hook contract never
188
+ * lacks identity state.
186
189
  */
187
190
  requestContext?: RequestContext;
191
+ /** Private cache domain explicitly shared by every read path in one client instance. */
192
+ securityDomain?: object;
188
193
  /**
189
194
  * Skip `beforeRead` hook resolution on every target collection. The
190
195
  * top-level read's `_bypassBeforeRead` flag rides through to populate
@@ -204,25 +209,6 @@ export interface PopulateOptions {
204
209
  */
205
210
  richTextPopulate?: import('../@types/index.js').RichTextPopulateFn;
206
211
  }
207
- /** Marker placed in a relation leaf when the target was already materialised earlier in this request. */
208
- export interface CycleRelationValue extends RelatedDocumentValue {
209
- _resolved: true;
210
- _cycle: true;
211
- }
212
- /** Marker placed in a relation leaf when the target was not found (deleted). */
213
- export interface UnresolvedRelationValue extends RelatedDocumentValue {
214
- _resolved: false;
215
- }
216
- /**
217
- * Envelope placed in a relation leaf when populate successfully fetched
218
- * the target document. The `document` field carries the raw storage-shape
219
- * doc (`@byline/client` then reshapes it to `ClientDocument` during
220
- * response shaping).
221
- */
222
- export interface PopulatedRelationValue extends RelatedDocumentValue {
223
- _resolved: true;
224
- document: Record<string, any>;
225
- }
226
212
  /**
227
213
  * Populate relation leaves in `opts.documents` in place, one DB
228
214
  * round-trip per depth level per target collection.