@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.
- package/dist/@types/collection-types.d.ts +72 -31
- package/dist/@types/db-types.d.ts +93 -47
- 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/field-types.d.ts +25 -1
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/@types/query-predicate.d.ts +3 -3
- package/dist/@types/relation-types.d.ts +38 -0
- package/dist/@types/relation-types.js +8 -0
- package/dist/@types/search-types.d.ts +3 -4
- package/dist/@types/site-config.d.ts +52 -13
- package/dist/auth/apply-before-read.d.ts +23 -11
- package/dist/auth/apply-before-read.js +139 -33
- package/dist/auth/apply-before-read.test.node.js +241 -3
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/read-context-scope.d.ts +20 -0
- package/dist/auth/read-context-scope.js +48 -0
- package/dist/codegen/fixtures/all-fields.d.ts +342 -0
- package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
- package/dist/codegen/fixtures/all-fields.expected.js +1 -0
- package/dist/codegen/fixtures/all-fields.js +94 -0
- package/dist/codegen/index.d.ts +16 -0
- package/dist/codegen/index.js +431 -0
- package/dist/codegen/index.test.node.d.ts +1 -0
- package/dist/codegen/index.test.node.js +230 -0
- package/dist/config/attach-hooks.d.ts +25 -0
- package/dist/config/attach-hooks.js +130 -0
- package/dist/config/attach-hooks.test.node.d.ts +1 -0
- package/dist/config/attach-hooks.test.node.js +173 -0
- package/dist/config/config-hooks.test.node.d.ts +1 -0
- package/dist/config/config-hooks.test.node.js +56 -0
- package/dist/config/config.d.ts +9 -5
- package/dist/config/config.js +20 -2
- package/dist/config/routes.d.ts +5 -5
- package/dist/config/routes.js +42 -9
- package/dist/config/routes.test.node.d.ts +1 -0
- package/dist/config/routes.test.node.js +152 -0
- package/dist/core.d.ts +3 -3
- package/dist/core.js +20 -14
- package/dist/core.test.node.d.ts +1 -0
- package/dist/core.test.node.js +28 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/lib/errors.d.ts +13 -0
- package/dist/lib/errors.js +14 -0
- package/dist/query/parse-where.d.ts +10 -1
- package/dist/query/parse-where.js +146 -2
- package/dist/query/parse-where.test.node.js +60 -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/collection-bootstrap.test.node.js +30 -59
- package/dist/services/discover-counter-groups.d.ts +1 -1
- package/dist/services/discover-counter-groups.test.node.js +23 -0
- package/dist/services/document-lifecycle/audit.d.ts +22 -1
- package/dist/services/document-lifecycle/audit.js +32 -1
- package/dist/services/document-lifecycle/create.d.ts +2 -2
- package/dist/services/document-lifecycle/create.js +18 -8
- package/dist/services/document-lifecycle/delete.d.ts +17 -1
- package/dist/services/document-lifecycle/delete.js +91 -26
- package/dist/services/document-lifecycle/index.d.ts +1 -1
- package/dist/services/document-lifecycle/internals.d.ts +0 -20
- package/dist/services/document-lifecycle/internals.js +22 -46
- package/dist/services/document-lifecycle/status.d.ts +1 -1
- package/dist/services/document-lifecycle/status.js +20 -3
- package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
- package/dist/services/document-lifecycle/system-fields.js +112 -74
- package/dist/services/document-lifecycle/tree.d.ts +22 -24
- package/dist/services/document-lifecycle/tree.js +244 -123
- package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
- package/dist/services/document-lifecycle/tree.test.node.js +663 -0
- package/dist/services/document-lifecycle/update.d.ts +4 -4
- package/dist/services/document-lifecycle/update.js +11 -5
- package/dist/services/document-lifecycle.test.node.js +426 -16
- package/dist/services/document-read.d.ts +14 -6
- package/dist/services/document-read.js +47 -9
- package/dist/services/field-upload.test.node.js +70 -0
- package/dist/services/index.d.ts +3 -2
- package/dist/services/index.js +3 -2
- 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 +9 -23
- package/dist/services/populate.js +180 -28
- package/dist/services/populate.test.node.js +59 -0
- package/dist/services/richtext-embed.d.ts +39 -2
- package/dist/services/richtext-embed.js +4 -34
- package/dist/services/richtext-embed.test.node.js +15 -0
- package/dist/services/richtext-populate.d.ts +21 -3
- package/dist/services/richtext-populate.js +160 -19
- package/dist/services/richtext-populate.test.node.js +523 -2
- 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/dist/utils/root-relative-redirect.d.ts +6 -0
- package/dist/utils/root-relative-redirect.js +37 -0
- 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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/services/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|