@byline/core 6.3.0 → 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.
Files changed (29) hide show
  1. package/dist/@types/field-types.d.ts +22 -3
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/schemas/zod/builder.d.ts +1 -1
  5. package/dist/schemas/zod/builder.js +40 -39
  6. package/dist/schemas/zod/builder.test.node.js +2 -2
  7. package/dist/schemas/zod/review-comparison-probe.test.node.d.ts +1 -0
  8. package/dist/schemas/zod/review-comparison-probe.test.node.js +26 -0
  9. package/dist/services/document-lifecycle/create.js +23 -2
  10. package/dist/services/document-lifecycle/persistence.js +38 -0
  11. package/dist/services/document-lifecycle/restore.d.ts +24 -3
  12. package/dist/services/document-lifecycle/restore.js +27 -3
  13. package/dist/services/document-lifecycle.test.node.js +222 -9
  14. package/dist/services/normalize-numeric-fields.d.ts +1 -1
  15. package/dist/services/normalize-numeric-fields.js +38 -13
  16. package/dist/services/normalize-numeric-fields.test.node.js +8 -0
  17. package/dist/services/restore-review-probe.test.node.d.ts +8 -0
  18. package/dist/services/restore-review-probe.test.node.js +3397 -0
  19. package/dist/services/review-probes.test.node.d.ts +8 -0
  20. package/dist/services/review-probes.test.node.js +349 -0
  21. package/dist/services/singleton-lifecycle/index.d.ts +1 -0
  22. package/dist/services/singleton-lifecycle/restore.d.ts +11 -1
  23. package/dist/services/singleton-lifecycle/restore.js +14 -2
  24. package/dist/services/singleton-lifecycle.test.node.js +34 -0
  25. package/dist/validation/document-fields.d.ts +71 -0
  26. package/dist/validation/document-fields.js +218 -0
  27. package/dist/validation/document-fields.test.node.d.ts +8 -0
  28. package/dist/validation/document-fields.test.node.js +290 -0
  29. 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 = { ...minimalCollection, hooks: { beforeUpdate, afterUpdate } };
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: { path, fieldType, value },
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 {};