@byline/core 6.2.1 → 6.4.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/field-types.d.ts +22 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/schemas/zod/builder.d.ts +1 -1
- package/dist/schemas/zod/builder.js +40 -39
- package/dist/schemas/zod/builder.test.node.js +2 -2
- package/dist/schemas/zod/review-comparison-probe.test.node.d.ts +1 -0
- package/dist/schemas/zod/review-comparison-probe.test.node.js +26 -0
- package/dist/services/document-lifecycle/create.js +23 -2
- package/dist/services/document-lifecycle/persistence.js +38 -0
- package/dist/services/document-lifecycle/restore.d.ts +24 -3
- package/dist/services/document-lifecycle/restore.js +27 -3
- package/dist/services/document-lifecycle.test.node.js +222 -9
- package/dist/services/normalize-numeric-fields.d.ts +1 -1
- package/dist/services/normalize-numeric-fields.js +38 -13
- package/dist/services/normalize-numeric-fields.test.node.js +8 -0
- package/dist/services/restore-review-probe.test.node.d.ts +8 -0
- package/dist/services/restore-review-probe.test.node.js +3397 -0
- package/dist/services/review-probes.test.node.d.ts +8 -0
- package/dist/services/review-probes.test.node.js +349 -0
- package/dist/services/singleton-lifecycle/index.d.ts +1 -0
- package/dist/services/singleton-lifecycle/restore.d.ts +11 -1
- package/dist/services/singleton-lifecycle/restore.js +14 -2
- package/dist/services/singleton-lifecycle.test.node.js +34 -0
- package/dist/validation/document-fields.d.ts +71 -0
- package/dist/validation/document-fields.js +218 -0
- package/dist/validation/document-fields.test.node.d.ts +8 -0
- package/dist/validation/document-fields.test.node.js +290 -0
- package/package.json +6 -6
|
@@ -337,6 +337,68 @@ describe('Document lifecycle service', () => {
|
|
|
337
337
|
// createDocument
|
|
338
338
|
// -----------------------------------------------------------------------
|
|
339
339
|
describe('createDocument', () => {
|
|
340
|
+
it.each(['explicit', 'legacy'])('rejects a create-only published override (%s)', async (source) => {
|
|
341
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
342
|
+
const ctx = buildCtx(db);
|
|
343
|
+
ctx.requestContext = createRequestContext({
|
|
344
|
+
actor: new AdminAuth({ id: 'editor', abilities: ['collections.articles.create'] }),
|
|
345
|
+
});
|
|
346
|
+
await expect(createDocument(ctx, {
|
|
347
|
+
data: { title: 'Hello', ...(source === 'legacy' ? { status: 'published' } : {}) },
|
|
348
|
+
...(source === 'explicit' ? { status: 'published' } : {}),
|
|
349
|
+
})).rejects.toMatchObject({ code: AuthErrorCodes.FORBIDDEN });
|
|
350
|
+
expect(createDocumentVersion).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
it('rejects undeclared statuses and hook-produced publication', async () => {
|
|
353
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
354
|
+
await expect(createDocument(buildCtx(db), { data: { title: 'Hello' }, status: 'bogus' })).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
|
|
355
|
+
const ctx = buildCtx(db, {
|
|
356
|
+
...minimalCollection,
|
|
357
|
+
hooks: {
|
|
358
|
+
beforeCreate: ({ data }) => {
|
|
359
|
+
data.status = 'published';
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
ctx.requestContext = createRequestContext({
|
|
364
|
+
actor: new AdminAuth({ id: 'editor', abilities: ['collections.articles.create'] }),
|
|
365
|
+
});
|
|
366
|
+
await expect(createDocument(ctx, { data: { title: 'Hello' } })).rejects.toMatchObject({
|
|
367
|
+
code: AuthErrorCodes.FORBIDDEN,
|
|
368
|
+
});
|
|
369
|
+
expect(createDocumentVersion).not.toHaveBeenCalled();
|
|
370
|
+
});
|
|
371
|
+
it('preserves publication by configured default for create-only actors', async () => {
|
|
372
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
373
|
+
const ctx = buildCtx(db, {
|
|
374
|
+
...minimalCollection,
|
|
375
|
+
workflow: { statuses: [{ name: 'published', label: 'Published' }] },
|
|
376
|
+
});
|
|
377
|
+
ctx.requestContext = createRequestContext({
|
|
378
|
+
actor: new AdminAuth({ id: 'editor', abilities: ['collections.articles.create'] }),
|
|
379
|
+
});
|
|
380
|
+
await createDocument(ctx, { data: { title: 'Hello' } });
|
|
381
|
+
expect(createDocumentVersion).toHaveBeenCalledWith(expect.objectContaining({ status: 'published' }));
|
|
382
|
+
});
|
|
383
|
+
it('rejects missing required data and values invalidated by hooks before persistence', async () => {
|
|
384
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
385
|
+
await expect(createDocument(buildCtx(db), { data: {} })).rejects.toMatchObject({
|
|
386
|
+
code: ErrorCodes.VALIDATION,
|
|
387
|
+
details: { reason: 'invalid_document_fields' },
|
|
388
|
+
});
|
|
389
|
+
const ctx = buildCtx(db, {
|
|
390
|
+
...minimalCollection,
|
|
391
|
+
hooks: {
|
|
392
|
+
beforeCreate: ({ data }) => {
|
|
393
|
+
data.title = 42;
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
await expect(createDocument(ctx, { data: { title: 'Hello' } })).rejects.toMatchObject({
|
|
398
|
+
code: ErrorCodes.VALIDATION,
|
|
399
|
+
});
|
|
400
|
+
expect(createDocumentVersion).not.toHaveBeenCalled();
|
|
401
|
+
});
|
|
340
402
|
it('calls createDocumentVersion and returns IDs', async () => {
|
|
341
403
|
const { db, createDocumentVersion } = createMockDb();
|
|
342
404
|
const ctx = buildCtx(db);
|
|
@@ -2208,7 +2270,10 @@ describe('Document lifecycle service', () => {
|
|
|
2208
2270
|
}
|
|
2209
2271
|
it('reads the source with locale: "all" and re-emits via createDocumentVersion', async () => {
|
|
2210
2272
|
const { db, createDocumentVersion, sourceVersionId, sourceFields, currentVersionId } = setupRestore();
|
|
2211
|
-
const ctx = buildCtx(db
|
|
2273
|
+
const ctx = buildCtx(db, {
|
|
2274
|
+
...minimalCollection,
|
|
2275
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
2276
|
+
});
|
|
2212
2277
|
const result = await restoreDocumentVersion(ctx, {
|
|
2213
2278
|
expectedRevision: 1,
|
|
2214
2279
|
documentId: 'doc-1',
|
|
@@ -2231,9 +2296,137 @@ describe('Document lifecycle service', () => {
|
|
|
2231
2296
|
sourceVersionId,
|
|
2232
2297
|
});
|
|
2233
2298
|
});
|
|
2299
|
+
describe('exemption from field validation', () => {
|
|
2300
|
+
// A historical version cannot be corrected through ordinary editing
|
|
2301
|
+
// before it is restored, and both the schema and the validation rules
|
|
2302
|
+
// may have moved since it was written. Enforcing today's rules would
|
|
2303
|
+
// make that content permanently unrecoverable.
|
|
2304
|
+
const tightened = {
|
|
2305
|
+
...minimalCollection,
|
|
2306
|
+
fields: [
|
|
2307
|
+
{ name: 'title', type: 'text', localized: true },
|
|
2308
|
+
{ name: 'summary', type: 'text', label: 'Summary' },
|
|
2309
|
+
],
|
|
2310
|
+
};
|
|
2311
|
+
it('restores a version that predates a required field and reports it', async () => {
|
|
2312
|
+
const { db, createDocumentVersion, sourceVersionId } = setupRestore();
|
|
2313
|
+
const result = await restoreDocumentVersion(buildCtx(db, tightened), {
|
|
2314
|
+
expectedRevision: 1,
|
|
2315
|
+
documentId: 'doc-1',
|
|
2316
|
+
sourceVersionId,
|
|
2317
|
+
});
|
|
2318
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
2319
|
+
expect(result.validationIssues).toEqual([
|
|
2320
|
+
{ field: 'summary', message: 'Summary is required', kind: 'required' },
|
|
2321
|
+
]);
|
|
2322
|
+
});
|
|
2323
|
+
it('restores content that fails a rule the schema never changed', async () => {
|
|
2324
|
+
// The corrected email rule is the concrete case: a version written
|
|
2325
|
+
// when the rule was inert must stay restorable.
|
|
2326
|
+
const emailCollection = {
|
|
2327
|
+
...minimalCollection,
|
|
2328
|
+
fields: [
|
|
2329
|
+
{ name: 'title', type: 'text', localized: true },
|
|
2330
|
+
{
|
|
2331
|
+
name: 'contact',
|
|
2332
|
+
type: 'text',
|
|
2333
|
+
validation: { rules: [{ type: 'email', value: true }] },
|
|
2334
|
+
},
|
|
2335
|
+
],
|
|
2336
|
+
};
|
|
2337
|
+
const { db, createDocumentVersion, sourceVersionId } = setupRestore({
|
|
2338
|
+
sourceFields: { title: { en: 'Old EN' }, contact: 'not-an-email' },
|
|
2339
|
+
});
|
|
2340
|
+
const result = await restoreDocumentVersion(buildCtx(db, emailCollection), {
|
|
2341
|
+
expectedRevision: 1,
|
|
2342
|
+
documentId: 'doc-1',
|
|
2343
|
+
sourceVersionId,
|
|
2344
|
+
});
|
|
2345
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
2346
|
+
expect(result.validationIssues?.map((issue) => issue.field)).toEqual(['contact']);
|
|
2347
|
+
expect(result.validationIssues?.[0]?.kind).toBe('invalid');
|
|
2348
|
+
});
|
|
2349
|
+
it('restores into a published default status, republishing invalid content', async () => {
|
|
2350
|
+
// Accepted recovery semantics, not a guarantee about restored content.
|
|
2351
|
+
const publishedOnly = {
|
|
2352
|
+
...tightened,
|
|
2353
|
+
workflow: {
|
|
2354
|
+
statuses: [
|
|
2355
|
+
{ name: 'draft', label: 'Draft' },
|
|
2356
|
+
{ name: 'published', label: 'Published' },
|
|
2357
|
+
{ name: 'archived', label: 'Archived' },
|
|
2358
|
+
],
|
|
2359
|
+
defaultStatus: 'published',
|
|
2360
|
+
},
|
|
2361
|
+
};
|
|
2362
|
+
const { db, createDocumentVersion, sourceVersionId } = setupRestore();
|
|
2363
|
+
const result = await restoreDocumentVersion(buildCtx(db, publishedOnly), {
|
|
2364
|
+
expectedRevision: 1,
|
|
2365
|
+
documentId: 'doc-1',
|
|
2366
|
+
sourceVersionId,
|
|
2367
|
+
});
|
|
2368
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('published');
|
|
2369
|
+
expect(result.validationIssues?.map((issue) => issue.field)).toEqual(['summary']);
|
|
2370
|
+
});
|
|
2371
|
+
it('omits validationIssues when the source satisfies the current rules', async () => {
|
|
2372
|
+
const { db, sourceVersionId } = setupRestore({
|
|
2373
|
+
sourceFields: { title: { en: 'Old EN' }, summary: 'Present' },
|
|
2374
|
+
});
|
|
2375
|
+
const result = await restoreDocumentVersion(buildCtx(db, tightened), {
|
|
2376
|
+
expectedRevision: 1,
|
|
2377
|
+
documentId: 'doc-1',
|
|
2378
|
+
sourceVersionId,
|
|
2379
|
+
});
|
|
2380
|
+
expect(result.validationIssues).toBeUndefined();
|
|
2381
|
+
});
|
|
2382
|
+
it('is not blocked by a validator that throws on the historical shape', async () => {
|
|
2383
|
+
// The diagnostic pass runs schema-author code against data it may not
|
|
2384
|
+
// anticipate. An exception there must not abort recovery.
|
|
2385
|
+
const throwing = {
|
|
2386
|
+
...minimalCollection,
|
|
2387
|
+
fields: [
|
|
2388
|
+
{ name: 'title', type: 'text', localized: true },
|
|
2389
|
+
{
|
|
2390
|
+
name: 'slug',
|
|
2391
|
+
type: 'text',
|
|
2392
|
+
optional: true,
|
|
2393
|
+
validate: (value) => (value.trim() === '' ? 'empty' : undefined),
|
|
2394
|
+
},
|
|
2395
|
+
],
|
|
2396
|
+
};
|
|
2397
|
+
const { db, createDocumentVersion, sourceVersionId } = setupRestore();
|
|
2398
|
+
const result = await restoreDocumentVersion(buildCtx(db, throwing), {
|
|
2399
|
+
expectedRevision: 1,
|
|
2400
|
+
documentId: 'doc-1',
|
|
2401
|
+
sourceVersionId,
|
|
2402
|
+
});
|
|
2403
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
2404
|
+
expect(result.validationIssues?.map((issue) => issue.field)).toEqual(['slug']);
|
|
2405
|
+
expect(result.validationIssues?.[0]?.message).toBe('slug: could not be validated');
|
|
2406
|
+
});
|
|
2407
|
+
it('does not extend the exemption to duplication', async () => {
|
|
2408
|
+
// Duplicate reads the current version, which the editor can open and
|
|
2409
|
+
// correct, so it keeps the gate.
|
|
2410
|
+
const { db } = setupRestore();
|
|
2411
|
+
vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
|
|
2412
|
+
document_version_id: 'ver-current',
|
|
2413
|
+
document_id: 'doc-1',
|
|
2414
|
+
path: 'sticky-path',
|
|
2415
|
+
status: 'published',
|
|
2416
|
+
fields: { title: { en: 'Current' } },
|
|
2417
|
+
});
|
|
2418
|
+
await expect(duplicateDocument(buildCtx(db, tightened), {
|
|
2419
|
+
expectedRevision: 1,
|
|
2420
|
+
sourceDocumentId: 'doc-1',
|
|
2421
|
+
})).rejects.toThrow('Some document fields are invalid');
|
|
2422
|
+
});
|
|
2423
|
+
});
|
|
2234
2424
|
it('hard-defaults the new version status to the workflow default (never inherits source status)', async () => {
|
|
2235
2425
|
const { db, createDocumentVersion, sourceVersionId } = setupRestore();
|
|
2236
|
-
const ctx = buildCtx(db
|
|
2426
|
+
const ctx = buildCtx(db, {
|
|
2427
|
+
...minimalCollection,
|
|
2428
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
2429
|
+
});
|
|
2237
2430
|
await restoreDocumentVersion(ctx, {
|
|
2238
2431
|
expectedRevision: 1,
|
|
2239
2432
|
documentId: 'doc-1',
|
|
@@ -2246,7 +2439,10 @@ describe('Document lifecycle service', () => {
|
|
|
2246
2439
|
const { db, createDocumentVersion, sourceVersionId } = setupRestore({
|
|
2247
2440
|
currentPath: 'sticky-path',
|
|
2248
2441
|
});
|
|
2249
|
-
const ctx = buildCtx(db
|
|
2442
|
+
const ctx = buildCtx(db, {
|
|
2443
|
+
...minimalCollection,
|
|
2444
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
2445
|
+
});
|
|
2250
2446
|
await restoreDocumentVersion(ctx, {
|
|
2251
2447
|
expectedRevision: 1,
|
|
2252
2448
|
documentId: 'doc-1',
|
|
@@ -2261,7 +2457,10 @@ describe('Document lifecycle service', () => {
|
|
|
2261
2457
|
const { db, sourceVersionId, createDocumentVersion } = setupRestore({
|
|
2262
2458
|
sourceDocumentId: 'doc-OTHER',
|
|
2263
2459
|
});
|
|
2264
|
-
const ctx = buildCtx(db
|
|
2460
|
+
const ctx = buildCtx(db, {
|
|
2461
|
+
...minimalCollection,
|
|
2462
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
2463
|
+
});
|
|
2265
2464
|
try {
|
|
2266
2465
|
await restoreDocumentVersion(ctx, {
|
|
2267
2466
|
expectedRevision: 1,
|
|
@@ -2296,7 +2495,10 @@ describe('Document lifecycle service', () => {
|
|
|
2296
2495
|
created_at: new Date(),
|
|
2297
2496
|
updated_at: new Date(),
|
|
2298
2497
|
});
|
|
2299
|
-
const ctx = buildCtx(db
|
|
2498
|
+
const ctx = buildCtx(db, {
|
|
2499
|
+
...minimalCollection,
|
|
2500
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
2501
|
+
});
|
|
2300
2502
|
try {
|
|
2301
2503
|
await restoreDocumentVersion(ctx, {
|
|
2302
2504
|
expectedRevision: 1,
|
|
@@ -2314,7 +2516,11 @@ describe('Document lifecycle service', () => {
|
|
|
2314
2516
|
const beforeUpdate = vi.fn();
|
|
2315
2517
|
const afterUpdate = vi.fn();
|
|
2316
2518
|
const { db, sourceVersionId } = setupRestore();
|
|
2317
|
-
const definition = {
|
|
2519
|
+
const definition = {
|
|
2520
|
+
...minimalCollection,
|
|
2521
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
2522
|
+
hooks: { beforeUpdate, afterUpdate },
|
|
2523
|
+
};
|
|
2318
2524
|
const ctx = buildCtx(db, definition);
|
|
2319
2525
|
await restoreDocumentVersion(ctx, {
|
|
2320
2526
|
expectedRevision: 1,
|
|
@@ -2340,6 +2546,7 @@ describe('Document lifecycle service', () => {
|
|
|
2340
2546
|
const { db, sourceVersionId, createDocumentVersion } = setupRestore();
|
|
2341
2547
|
const definition = {
|
|
2342
2548
|
...minimalCollection,
|
|
2549
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
2343
2550
|
hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
2344
2551
|
};
|
|
2345
2552
|
await expect(restoreDocumentVersion(buildCtx(db, definition), {
|
|
@@ -2359,7 +2566,10 @@ describe('Document lifecycle service', () => {
|
|
|
2359
2566
|
});
|
|
2360
2567
|
it('throws ERR_FORBIDDEN when actor lacks collections.<path>.update', async () => {
|
|
2361
2568
|
const { db, sourceVersionId } = setupRestore();
|
|
2362
|
-
const ctx = buildCtx(db
|
|
2569
|
+
const ctx = buildCtx(db, {
|
|
2570
|
+
...minimalCollection,
|
|
2571
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
2572
|
+
});
|
|
2363
2573
|
const actor = new AdminAuth({
|
|
2364
2574
|
id: 'editor',
|
|
2365
2575
|
abilities: ['collections.articles.read'],
|
|
@@ -2380,7 +2590,10 @@ describe('Document lifecycle service', () => {
|
|
|
2380
2590
|
});
|
|
2381
2591
|
it('permits restore when actor holds only collections.<path>.update (no separate restore verb needed)', async () => {
|
|
2382
2592
|
const { db, createDocumentVersion, sourceVersionId } = setupRestore();
|
|
2383
|
-
const ctx = buildCtx(db
|
|
2593
|
+
const ctx = buildCtx(db, {
|
|
2594
|
+
...minimalCollection,
|
|
2595
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
2596
|
+
});
|
|
2384
2597
|
const actor = new AdminAuth({
|
|
2385
2598
|
id: 'editor',
|
|
2386
2599
|
abilities: ['collections.articles.update', 'collections.articles.read'],
|
|
@@ -2738,7 +2951,7 @@ describe('Document lifecycle service', () => {
|
|
|
2738
2951
|
useAsTitle: 'title',
|
|
2739
2952
|
fields: [
|
|
2740
2953
|
{ name: 'title', type: 'text', localized: true },
|
|
2741
|
-
{ name: 'tagline', type: 'text', localized: true },
|
|
2954
|
+
{ name: 'tagline', type: 'text', optional: true, localized: true },
|
|
2742
2955
|
{ name: 'sku', type: 'text' /* non-localized */ },
|
|
2743
2956
|
{
|
|
2744
2957
|
name: 'sections',
|
|
@@ -14,7 +14,7 @@ export declare function isCanonicalNumericValue(fieldType: CanonicalNumericField
|
|
|
14
14
|
* Convert a tolerant numeric write value to its canonical representation.
|
|
15
15
|
* `undefined` means the value was empty and should be removed.
|
|
16
16
|
*/
|
|
17
|
-
export declare function normalizeNumericValue(fieldType: CanonicalNumericFieldType, value: unknown, path: string): CanonicalNumericValue | undefined;
|
|
17
|
+
export declare function normalizeNumericValue(fieldType: CanonicalNumericFieldType, value: unknown, path: string, issuePath?: string): CanonicalNumericValue | undefined;
|
|
18
18
|
/**
|
|
19
19
|
* Normalize every user-writable numeric leaf in a schema-shaped data tree.
|
|
20
20
|
* Mutates `data` in place. Counter fields are deliberately excluded because
|
|
@@ -21,32 +21,32 @@ export function isCanonicalNumericValue(fieldType, value) {
|
|
|
21
21
|
* Convert a tolerant numeric write value to its canonical representation.
|
|
22
22
|
* `undefined` means the value was empty and should be removed.
|
|
23
23
|
*/
|
|
24
|
-
export function normalizeNumericValue(fieldType, value, path) {
|
|
24
|
+
export function normalizeNumericValue(fieldType, value, path, issuePath = path) {
|
|
25
25
|
if (typeof value === 'string') {
|
|
26
26
|
const trimmed = value.trim();
|
|
27
27
|
if (trimmed === '')
|
|
28
28
|
return undefined;
|
|
29
29
|
if (!NUMERIC_LITERAL_RE.test(trimmed))
|
|
30
|
-
throwNumericValidation(fieldType, value, path);
|
|
30
|
+
throwNumericValidation(fieldType, value, path, issuePath);
|
|
31
31
|
if (fieldType === 'decimal')
|
|
32
32
|
return trimmed;
|
|
33
33
|
const numberValue = Number(trimmed);
|
|
34
34
|
if (!Number.isFinite(numberValue))
|
|
35
|
-
throwNumericValidation(fieldType, value, path);
|
|
35
|
+
throwNumericValidation(fieldType, value, path, issuePath);
|
|
36
36
|
if (fieldType === 'integer' && !Number.isInteger(numberValue)) {
|
|
37
|
-
throwNumericValidation(fieldType, value, path);
|
|
37
|
+
throwNumericValidation(fieldType, value, path, issuePath);
|
|
38
38
|
}
|
|
39
39
|
return numberValue;
|
|
40
40
|
}
|
|
41
41
|
if (typeof value === 'number') {
|
|
42
42
|
if (!Number.isFinite(value))
|
|
43
|
-
throwNumericValidation(fieldType, value, path);
|
|
43
|
+
throwNumericValidation(fieldType, value, path, issuePath);
|
|
44
44
|
if (fieldType === 'integer' && !Number.isInteger(value)) {
|
|
45
|
-
throwNumericValidation(fieldType, value, path);
|
|
45
|
+
throwNumericValidation(fieldType, value, path, issuePath);
|
|
46
46
|
}
|
|
47
47
|
return fieldType === 'decimal' ? String(value) : value;
|
|
48
48
|
}
|
|
49
|
-
throwNumericValidation(fieldType, value, path);
|
|
49
|
+
throwNumericValidation(fieldType, value, path, issuePath);
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Normalize every user-writable numeric leaf in a schema-shaped data tree.
|
|
@@ -59,15 +59,15 @@ export function normalizeNumericFields(fields, data) {
|
|
|
59
59
|
continue;
|
|
60
60
|
if (leaf.field.localized === true && isLocaleMap(leaf.value)) {
|
|
61
61
|
for (const [locale, localeValue] of Object.entries(leaf.value)) {
|
|
62
|
-
normalizeLeafValue(leaf.field.type, leaf.value, locale, localeValue, `${leaf.fieldPath}.${locale}`);
|
|
62
|
+
normalizeLeafValue(leaf.field.type, leaf.value, locale, localeValue, `${leaf.fieldPath}.${locale}`, toInstancePath(data, `${leaf.fieldPath}.${locale}`));
|
|
63
63
|
}
|
|
64
64
|
continue;
|
|
65
65
|
}
|
|
66
|
-
normalizeLeafValue(leaf.field.type, leaf.parent, leaf.key, leaf.value, leaf.fieldPath);
|
|
66
|
+
normalizeLeafValue(leaf.field.type, leaf.parent, leaf.key, leaf.value, leaf.fieldPath, toInstancePath(data, leaf.fieldPath));
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
-
function normalizeLeafValue(fieldType, parent, key, value, path) {
|
|
70
|
-
const normalized = normalizeNumericValue(fieldType, value, path);
|
|
69
|
+
function normalizeLeafValue(fieldType, parent, key, value, path, issuePath) {
|
|
70
|
+
const normalized = normalizeNumericValue(fieldType, value, path, issuePath);
|
|
71
71
|
if (normalized === undefined) {
|
|
72
72
|
delete parent[key];
|
|
73
73
|
}
|
|
@@ -81,9 +81,34 @@ function isWritableNumericType(type) {
|
|
|
81
81
|
function isLocaleMap(value) {
|
|
82
82
|
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
83
83
|
}
|
|
84
|
-
function throwNumericValidation(fieldType, value, path) {
|
|
84
|
+
function throwNumericValidation(fieldType, value, path, issuePath) {
|
|
85
85
|
throw ERR_VALIDATION({
|
|
86
86
|
message: `invalid ${fieldType} value at '${path}'`,
|
|
87
|
-
details: {
|
|
87
|
+
details: {
|
|
88
|
+
reason: 'invalid_document_fields',
|
|
89
|
+
issues: [{ field: issuePath, message: `Invalid ${fieldType} value` }],
|
|
90
|
+
path,
|
|
91
|
+
fieldType,
|
|
92
|
+
value,
|
|
93
|
+
},
|
|
88
94
|
});
|
|
89
95
|
}
|
|
96
|
+
/** Map walker diagnostic indices to the editor's stable instance paths. */
|
|
97
|
+
function toInstancePath(data, path) {
|
|
98
|
+
let current = data;
|
|
99
|
+
let result = '';
|
|
100
|
+
for (const part of path.split('.')) {
|
|
101
|
+
if (Array.isArray(current) && /^\d+$/.test(part)) {
|
|
102
|
+
current = current[Number(part)];
|
|
103
|
+
result +=
|
|
104
|
+
typeof current?._id === 'string' && /^[\w-]+$/.test(current._id)
|
|
105
|
+
? `[id=${current._id}]`
|
|
106
|
+
: `[${part}]`;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
result += `${result ? '.' : ''}${part}`;
|
|
110
|
+
current = current?.[part];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
@@ -86,6 +86,14 @@ describe('normalizeNumericFields', () => {
|
|
|
86
86
|
details: expect.objectContaining({ path: 'nested.2.value' }),
|
|
87
87
|
}));
|
|
88
88
|
});
|
|
89
|
+
it('preserves diagnostic paths and maps field errors to stable item identities', () => {
|
|
90
|
+
expect(() => normalizeNumericFields(fields, { rows: [{ _id: 'row-1', quantity: 'bad' }] })).toThrowError(expect.objectContaining({
|
|
91
|
+
details: expect.objectContaining({
|
|
92
|
+
path: 'rows.0.quantity',
|
|
93
|
+
issues: [{ field: 'rows[id=row-1].quantity', message: 'Invalid integer value' }],
|
|
94
|
+
}),
|
|
95
|
+
}));
|
|
96
|
+
});
|
|
89
97
|
it('throws a BylineError with ERR_VALIDATION', () => {
|
|
90
98
|
try {
|
|
91
99
|
normalizeNumericValue('integer', {}, 'count');
|
|
@@ -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 {};
|