@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
@@ -13,7 +13,9 @@ import { applyPatches } from '../../patches/index.js';
13
13
  import { normaliseDateFields } from '../../utils/normalise-dates.js';
14
14
  import { getDefaultStatus } from '../../workflow/workflow.js';
15
15
  import { assignCounterValues } from '../assign-counter-values.js';
16
- import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, selfHealTreePlacement, } from './internals.js';
16
+ import { normalizeNumericFields } from '../normalize-numeric-fields.js';
17
+ import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
18
+ import { selfHealTreePlacement } from './tree.js';
17
19
  /**
18
20
  * Update a document via full replacement (PUT semantics).
19
21
  *
@@ -22,8 +24,8 @@ import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invok
22
24
  *
23
25
  * Flow:
24
26
  * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
25
- * 2. `normaliseDateFields(data)`
26
- * 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`
27
+ * 2. Normalize date and numeric fields
28
+ * 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`, then normalize numerics again
27
29
  * 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
28
30
  * 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
29
31
  */
@@ -43,7 +45,9 @@ export async function updateDocument(ctx, params) {
43
45
  });
44
46
  const originalData = latest ?? {};
45
47
  normaliseDateFields(data);
48
+ normalizeNumericFields(definition.fields, data);
46
49
  await invokeHook(hooks?.beforeUpdate, { data, originalData, collectionPath });
50
+ normalizeNumericFields(definition.fields, data);
47
51
  // Counter fields are immutable: carry their values forward from the
48
52
  // previous version rather than trusting whatever (or nothing) the
49
53
  // caller sent. Lazy-allocates when a counter was added to the
@@ -111,8 +115,8 @@ export async function updateDocument(ctx, params) {
111
115
  * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
112
116
  * 2. Optimistic concurrency check on `documentVersionId`
113
117
  * 3. `applyPatches(definition, originalData, patches)` → `nextData`
114
- * 4. `normaliseDateFields(nextData)`
115
- * 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`
118
+ * 4. Normalize date and numeric fields
119
+ * 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`, then normalize numerics again
116
120
  * 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
117
121
  * 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
118
122
  *
@@ -160,8 +164,10 @@ export async function updateDocumentWithPatches(ctx, params) {
160
164
  const nextData = patchedDocument;
161
165
  // 4. Normalise dates.
162
166
  normaliseDateFields(nextData);
167
+ normalizeNumericFields(definition.fields, nextData);
163
168
  // 5. beforeUpdate hook.
164
169
  await invokeHook(hooks?.beforeUpdate, { data: nextData, originalData, collectionPath });
170
+ normalizeNumericFields(definition.fields, nextData);
165
171
  // 5b. Carry counter values forward from the previous version (or
166
172
  // lazy-allocate if the previous version is missing a value). See
167
173
  // updateDocument for the rationale — patch-based updates are
@@ -24,6 +24,14 @@ const minimalCollection = {
24
24
  ],
25
25
  },
26
26
  };
27
+ const numericCollection = {
28
+ ...minimalCollection,
29
+ fields: [
30
+ { name: 'quantity', type: 'integer' },
31
+ { name: 'score', type: 'float' },
32
+ { name: 'price', type: 'decimal' },
33
+ ],
34
+ };
27
35
  /** Build a mock IDbAdapter. Returns the adapter plus individual mock fns. */
28
36
  function createMockDb() {
29
37
  const createDocumentVersion = vi.fn().mockResolvedValue({
@@ -34,6 +42,7 @@ function createMockDb() {
34
42
  const archivePublishedVersions = vi.fn().mockResolvedValue(0);
35
43
  const softDeleteDocument = vi.fn().mockResolvedValue(1);
36
44
  const getDocumentById = vi.fn().mockResolvedValue(null);
45
+ const getDocumentSystemFieldsForUpdate = vi.fn().mockResolvedValue(null);
37
46
  const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
38
47
  const getCurrentPath = vi.fn().mockResolvedValue('current-path');
39
48
  // Audit capability (docs/06-auth-and-security/02-auditability.md — W2). `withTransaction` is a passthrough
@@ -59,6 +68,16 @@ function createMockDb() {
59
68
  setOrderKey: vi.fn(),
60
69
  placeTreeNode: vi.fn(),
61
70
  removeFromTree: vi.fn(),
71
+ promoteChildrenAndRemoveFromTree: vi.fn(async () => ({
72
+ removed: {
73
+ changed: false,
74
+ before: { placed: false, parentDocumentId: null, orderKey: null, index: null },
75
+ after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
76
+ beforeSiblingDocumentIds: [],
77
+ beforeSubtreeDocumentIds: [],
78
+ },
79
+ promoted: [],
80
+ })),
62
81
  },
63
82
  counters: {
64
83
  ensureCounterGroup: vi.fn(),
@@ -75,6 +94,7 @@ function createMockDb() {
75
94
  getCollectionById: vi.fn(),
76
95
  },
77
96
  documents: {
97
+ getDocumentSystemFieldsForUpdate,
78
98
  getDocumentById,
79
99
  getCurrentVersionMetadata,
80
100
  getCurrentPath,
@@ -95,6 +115,16 @@ function createMockDb() {
95
115
  getTreeParent: vi.fn(),
96
116
  getTreeSubtree: vi.fn(),
97
117
  },
118
+ audit: {
119
+ getDocumentAuditLog: vi.fn(async () => ({
120
+ entries: [],
121
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
122
+ })),
123
+ findAuditLog: vi.fn(async () => ({
124
+ entries: [],
125
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
126
+ })),
127
+ },
98
128
  },
99
129
  };
100
130
  return {
@@ -104,6 +134,7 @@ function createMockDb() {
104
134
  archivePublishedVersions,
105
135
  softDeleteDocument,
106
136
  getDocumentById,
137
+ getDocumentSystemFieldsForUpdate,
107
138
  getCurrentVersionMetadata,
108
139
  getCurrentPath,
109
140
  auditAppend,
@@ -235,6 +266,23 @@ describe('Document lifecycle service', () => {
235
266
  const persistedData = createDocumentVersion.mock.calls[0]?.[0].documentData;
236
267
  expect(persistedData.title).toBe('Mutated');
237
268
  });
269
+ it('normalizes numeric values before and after beforeCreate', async () => {
270
+ const { db, createDocumentVersion } = createMockDb();
271
+ const beforeCreate = vi.fn(({ data }) => {
272
+ expect(data).toMatchObject({ quantity: 2, score: 1.5, price: '10.00' });
273
+ data.quantity = '3';
274
+ data.price = 12.5;
275
+ });
276
+ const definition = { ...numericCollection, hooks: { beforeCreate } };
277
+ await createDocument(buildCtx(db, definition), {
278
+ data: { quantity: '2', score: '1.5', price: ' 10.00 ' },
279
+ });
280
+ expect(createDocumentVersion.mock.calls[0]?.[0].documentData).toMatchObject({
281
+ quantity: 3,
282
+ score: 1.5,
283
+ price: '12.5',
284
+ });
285
+ });
238
286
  it('derives path from useAsPath source field via the slugifier', async () => {
239
287
  const { db, createDocumentVersion } = createMockDb();
240
288
  const definition = { ...minimalCollection, useAsPath: 'title' };
@@ -378,6 +426,24 @@ describe('Document lifecycle service', () => {
378
426
  }));
379
427
  expect(createDocumentVersion).toHaveBeenCalledOnce();
380
428
  });
429
+ it('normalizes numeric values before and after beforeUpdate', async () => {
430
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
431
+ getDocumentById.mockResolvedValue({ fields: { quantity: 1, score: 1, price: '1.0' } });
432
+ const beforeUpdate = vi.fn(({ data }) => {
433
+ expect(data.score).toBe(25);
434
+ data.score = '3.5';
435
+ });
436
+ const definition = { ...numericCollection, hooks: { beforeUpdate } };
437
+ await updateDocument(buildCtx(db, definition), {
438
+ documentId: 'doc-1',
439
+ data: { quantity: '2', score: '2.5e1', price: 4.25 },
440
+ });
441
+ expect(createDocumentVersion.mock.calls[0]?.[0].documentData).toEqual({
442
+ quantity: 2,
443
+ score: 3.5,
444
+ price: '4.25',
445
+ });
446
+ });
381
447
  it('afterUpdate receives documentId and documentVersionId', async () => {
382
448
  const afterUpdate = vi.fn();
383
449
  const { db, getDocumentById } = createMockDb();
@@ -670,6 +736,29 @@ describe('Document lifecycle service', () => {
670
736
  documentVersionId: 'ver-1',
671
737
  }));
672
738
  });
739
+ it('normalizes patched and hook-produced numeric values before persistence', async () => {
740
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
741
+ getDocumentById.mockResolvedValue({
742
+ fields: { quantity: 1, score: 1, price: '1.00' },
743
+ });
744
+ const beforeUpdate = vi.fn(({ data }) => {
745
+ expect(data.quantity).toBe(7);
746
+ data.price = ' 8.500 ';
747
+ });
748
+ const definition = { ...numericCollection, hooks: { beforeUpdate } };
749
+ await updateDocumentWithPatches(buildCtx(db, definition), {
750
+ documentId: 'doc-1',
751
+ patches: [
752
+ { kind: 'field.set', path: 'quantity', value: '7.0' },
753
+ { kind: 'field.set', path: 'score', value: '2.75' },
754
+ ],
755
+ });
756
+ expect(createDocumentVersion.mock.calls[0]?.[0].documentData).toEqual({
757
+ quantity: 7,
758
+ score: 2.75,
759
+ price: '8.500',
760
+ });
761
+ });
673
762
  });
674
763
  // -----------------------------------------------------------------------
675
764
  // changeDocumentStatus
@@ -824,6 +913,38 @@ describe('Document lifecycle service', () => {
824
913
  expect(result.archivedCount).toBe(1);
825
914
  expect(archivePublishedVersions).toHaveBeenCalledWith({ document_id: 'doc-1' });
826
915
  });
916
+ it('records an atomic published to archived status audit when versions change', async () => {
917
+ const { db, archivePublishedVersions, auditAppend, withTransaction } = createMockDb();
918
+ archivePublishedVersions.mockResolvedValue(2);
919
+ await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
920
+ expect(withTransaction).toHaveBeenCalledOnce();
921
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
922
+ documentId: 'doc-1',
923
+ collectionId: 'col-1',
924
+ actorId: TEST_ACTOR_ID,
925
+ actorRealm: 'admin',
926
+ action: 'document.status.changed',
927
+ field: 'status',
928
+ before: 'published',
929
+ after: 'archived',
930
+ }));
931
+ });
932
+ it('does not record an audit row when no published version changes', async () => {
933
+ const { db, archivePublishedVersions, auditAppend } = createMockDb();
934
+ archivePublishedVersions.mockResolvedValue(0);
935
+ await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
936
+ expect(auditAppend).not.toHaveBeenCalled();
937
+ });
938
+ it('does not run afterUnpublish when the atomic audit unit fails', async () => {
939
+ const afterUnpublish = vi.fn();
940
+ const { db, archivePublishedVersions, auditAppend } = createMockDb();
941
+ archivePublishedVersions.mockResolvedValue(1);
942
+ auditAppend.mockRejectedValue(new Error('audit failed'));
943
+ await expect(unpublishDocument(buildCtx(db, { ...minimalCollection, hooks: { afterUnpublish } }), {
944
+ documentId: 'doc-1',
945
+ })).rejects.toThrow('audit failed');
946
+ expect(afterUnpublish).not.toHaveBeenCalled();
947
+ });
827
948
  it('invokes beforeUnpublish and afterUnpublish hooks', async () => {
828
949
  const callOrder = [];
829
950
  const hooks = {
@@ -927,6 +1048,23 @@ describe('Document lifecycle service', () => {
927
1048
  action: 'document.deleted',
928
1049
  }));
929
1050
  });
1051
+ it('rejects a beforeDelete failure without soft-deleting the document', async () => {
1052
+ const beforeDelete = vi.fn(async () => {
1053
+ throw new Error('pre-commit hook failed');
1054
+ });
1055
+ const afterDelete = vi.fn();
1056
+ const { db, getDocumentById, softDeleteDocument } = createMockDb();
1057
+ getDocumentById.mockResolvedValue({
1058
+ document_version_id: 'ver-1',
1059
+ document_id: 'doc-1',
1060
+ path: 'doc-to-delete',
1061
+ fields: {},
1062
+ });
1063
+ const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
1064
+ await expect(deleteDocument(buildCtx(db, definition), { documentId: 'doc-1' })).rejects.toThrow('pre-commit hook failed');
1065
+ expect(softDeleteDocument).not.toHaveBeenCalled();
1066
+ expect(afterDelete).not.toHaveBeenCalled();
1067
+ });
930
1068
  it('cleans up stored files for upload fields at any nesting depth', async () => {
931
1069
  const { db, getDocumentById } = createMockDb();
932
1070
  const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
@@ -979,28 +1117,108 @@ describe('Document lifecycle service', () => {
979
1117
  'files/b.pdf',
980
1118
  ]);
981
1119
  });
1120
+ it('returns only allowlisted failures and keeps raw details in internal logs', async () => {
1121
+ const { db, getDocumentById } = createMockDb();
1122
+ const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
1123
+ const hookError = Object.assign(new Error('hook leaked private/hooks/search.ts'), {
1124
+ code: 'ERR_SEARCH',
1125
+ });
1126
+ const definition = {
1127
+ ...minimalCollection,
1128
+ fields: [{ name: 'file', label: 'File', type: 'file', upload }],
1129
+ hooks: {
1130
+ afterDelete: async () => {
1131
+ throw hookError;
1132
+ },
1133
+ },
1134
+ };
1135
+ getDocumentById.mockResolvedValue({
1136
+ document_version_id: 'ver-1',
1137
+ document_id: 'doc-1',
1138
+ path: 'doc-to-delete',
1139
+ fields: {
1140
+ file: {
1141
+ storagePath: 'private/original.pdf',
1142
+ variants: [
1143
+ { storagePath: 'private/preview.pdf' },
1144
+ { storagePath: 'private/thumbnail.pdf' },
1145
+ ],
1146
+ },
1147
+ },
1148
+ });
1149
+ const storageDelete = vi.fn(async (storagePath) => {
1150
+ if (storagePath === 'private/original.pdf') {
1151
+ throw Object.assign(new Error('storage unavailable'), { code: ErrorCodes.STORAGE });
1152
+ }
1153
+ if (storagePath === 'private/thumbnail.pdf')
1154
+ throw new Error('cleanup failed');
1155
+ });
1156
+ const ctx = {
1157
+ ...buildCtx(db, definition),
1158
+ storage: { delete: storageDelete },
1159
+ logger: { ...noopLogger, error: vi.fn() },
1160
+ };
1161
+ const result = await deleteDocument(ctx, { documentId: 'doc-1' });
1162
+ expect(storageDelete.mock.calls.map((call) => call[0])).toEqual([
1163
+ 'private/original.pdf',
1164
+ 'private/preview.pdf',
1165
+ 'private/thumbnail.pdf',
1166
+ ]);
1167
+ expect(result).toEqual({
1168
+ deletedVersionCount: 1,
1169
+ outcome: 'committed-with-side-effect-failures',
1170
+ sideEffectFailures: [
1171
+ { phase: 'storageCleanup', code: 'ERR_STORAGE' },
1172
+ { phase: 'storageCleanup', code: 'ERR_UNHANDLED' },
1173
+ { phase: 'afterDelete', code: 'ERR_UNHANDLED' },
1174
+ ],
1175
+ });
1176
+ const serializedResult = JSON.stringify(result);
1177
+ expect(serializedResult).not.toContain('storage unavailable');
1178
+ expect(serializedResult).not.toContain('cleanup failed');
1179
+ expect(serializedResult).not.toContain('hook leaked');
1180
+ expect(serializedResult).not.toContain('private/');
1181
+ expect(serializedResult).not.toContain('ERR_SEARCH');
1182
+ expect(ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({
1183
+ err: expect.objectContaining({ message: 'storage unavailable' }),
1184
+ documentId: 'doc-1',
1185
+ storagePath: 'private/original.pdf',
1186
+ }), 'failed to delete storage file');
1187
+ expect(ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({
1188
+ err: expect.objectContaining({ message: 'cleanup failed' }),
1189
+ documentId: 'doc-1',
1190
+ storagePath: 'private/thumbnail.pdf',
1191
+ }), 'failed to delete storage file');
1192
+ expect(ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({ err: hookError, documentId: 'doc-1' }), 'afterDelete hook failed after document delete');
1193
+ });
982
1194
  });
983
1195
  // -----------------------------------------------------------------------
984
1196
  // updateDocumentSystemFields (audited, non-versioned)
985
1197
  // -----------------------------------------------------------------------
986
1198
  describe('updateDocumentSystemFields', () => {
987
- function setupDoc(getDocumentById, overrides) {
988
- getDocumentById.mockResolvedValue({
989
- document_version_id: 'ver-1',
990
- document_id: 'doc-1',
1199
+ function setupDoc(getDocumentSystemFieldsForUpdate, overrides) {
1200
+ getDocumentSystemFieldsForUpdate.mockResolvedValue({
991
1201
  path: 'old-slug',
992
1202
  source_locale: 'en',
993
1203
  availableLocales: ['en'],
994
- fields: {},
995
1204
  ...overrides,
996
1205
  });
997
1206
  }
998
1207
  it('records document.path.changed when the path actually changes', async () => {
999
- const { db, getDocumentById, auditAppend, withTransaction } = createMockDb();
1000
- setupDoc(getDocumentById);
1208
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1209
+ setupDoc(getDocumentSystemFieldsForUpdate);
1001
1210
  const ctx = buildCtx(db);
1002
- await updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'new-slug' });
1211
+ const result = await updateDocumentSystemFields(ctx, {
1212
+ documentId: 'doc-1',
1213
+ path: 'new-slug',
1214
+ });
1003
1215
  expect(withTransaction).toHaveBeenCalledOnce();
1216
+ expect(result).toMatchObject({
1217
+ changed: true,
1218
+ pathChanged: true,
1219
+ availableLocalesChanged: false,
1220
+ path: 'new-slug',
1221
+ });
1004
1222
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1005
1223
  action: 'document.path.changed',
1006
1224
  field: 'path',
@@ -1008,21 +1226,38 @@ describe('Document lifecycle service', () => {
1008
1226
  after: 'new-slug',
1009
1227
  }));
1010
1228
  });
1011
- it('records no audit row when the path is unchanged', async () => {
1012
- const { db, getDocumentById, auditAppend } = createMockDb();
1013
- setupDoc(getDocumentById);
1014
- const ctx = buildCtx(db);
1015
- await updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'old-slug' });
1229
+ it('does not write, audit, or fire the hook when the path is unchanged', async () => {
1230
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1231
+ setupDoc(getDocumentSystemFieldsForUpdate);
1232
+ const afterSystemFieldsChange = vi.fn();
1233
+ const ctx = buildCtx(db, {
1234
+ ...minimalCollection,
1235
+ hooks: { afterSystemFieldsChange },
1236
+ });
1237
+ const result = await updateDocumentSystemFields(ctx, {
1238
+ documentId: 'doc-1',
1239
+ path: 'old-slug',
1240
+ });
1241
+ expect(result).toMatchObject({ changed: false, pathChanged: false });
1242
+ expect(db.commands.documents.updateDocumentPath).not.toHaveBeenCalled();
1243
+ expect(withTransaction).toHaveBeenCalledOnce();
1016
1244
  expect(auditAppend).not.toHaveBeenCalled();
1245
+ expect(afterSystemFieldsChange).not.toHaveBeenCalled();
1017
1246
  });
1018
1247
  it('records document.locales.changed with before/after sets', async () => {
1019
- const { db, getDocumentById, auditAppend } = createMockDb();
1020
- setupDoc(getDocumentById);
1248
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
1249
+ setupDoc(getDocumentSystemFieldsForUpdate);
1021
1250
  const ctx = buildCtx(db);
1022
- await updateDocumentSystemFields(ctx, {
1251
+ const result = await updateDocumentSystemFields(ctx, {
1023
1252
  documentId: 'doc-1',
1024
1253
  availableLocales: ['en', 'fr'],
1025
1254
  });
1255
+ expect(result).toMatchObject({
1256
+ changed: true,
1257
+ pathChanged: false,
1258
+ availableLocalesChanged: true,
1259
+ availableLocalesWritten: true,
1260
+ });
1026
1261
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1027
1262
  action: 'document.locales.changed',
1028
1263
  field: 'availableLocales',
@@ -1030,6 +1265,181 @@ describe('Document lifecycle service', () => {
1030
1265
  after: ['en', 'fr'],
1031
1266
  }));
1032
1267
  });
1268
+ it('treats reordered and duplicated locale values as a no-op set', async () => {
1269
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1270
+ setupDoc(getDocumentSystemFieldsForUpdate, { availableLocales: ['en', 'fr'] });
1271
+ const afterSystemFieldsChange = vi.fn();
1272
+ const ctx = buildCtx(db, {
1273
+ ...minimalCollection,
1274
+ hooks: { afterSystemFieldsChange },
1275
+ });
1276
+ const result = await updateDocumentSystemFields(ctx, {
1277
+ documentId: 'doc-1',
1278
+ availableLocales: ['fr', 'en', 'fr'],
1279
+ });
1280
+ expect(result).toMatchObject({ changed: false, availableLocalesChanged: false });
1281
+ expect(db.commands.documents.setDocumentAvailableLocales).not.toHaveBeenCalled();
1282
+ expect(withTransaction).toHaveBeenCalledOnce();
1283
+ expect(auditAppend).not.toHaveBeenCalled();
1284
+ expect(afterSystemFieldsChange).not.toHaveBeenCalled();
1285
+ });
1286
+ it('fires ordered hooks after commit with previous and current field snapshots', async () => {
1287
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1288
+ setupDoc(getDocumentSystemFieldsForUpdate);
1289
+ const order = [];
1290
+ db.commands.documents.updateDocumentPath.mockImplementation(async () => {
1291
+ order.push('path-write');
1292
+ });
1293
+ db.commands.documents.setDocumentAvailableLocales.mockImplementation(async () => {
1294
+ order.push('locales-write');
1295
+ });
1296
+ auditAppend.mockImplementation(async () => {
1297
+ order.push('audit');
1298
+ return { id: 'audit-1' };
1299
+ });
1300
+ withTransaction.mockImplementation(async (fn) => {
1301
+ order.push('transaction:start');
1302
+ const value = await fn();
1303
+ order.push('transaction:commit');
1304
+ return value;
1305
+ });
1306
+ const firstHook = vi.fn(async () => {
1307
+ order.push('hook:first');
1308
+ });
1309
+ const secondHook = vi.fn(async () => {
1310
+ order.push('hook:second');
1311
+ });
1312
+ const ctx = buildCtx(db, {
1313
+ ...minimalCollection,
1314
+ hooks: { afterSystemFieldsChange: [firstHook, secondHook] },
1315
+ });
1316
+ await updateDocumentSystemFields(ctx, {
1317
+ documentId: 'doc-1',
1318
+ path: 'new-slug',
1319
+ availableLocales: ['fr', 'en', 'fr'],
1320
+ });
1321
+ expect(order).toEqual([
1322
+ 'transaction:start',
1323
+ 'path-write',
1324
+ 'audit',
1325
+ 'locales-write',
1326
+ 'audit',
1327
+ 'transaction:commit',
1328
+ 'hook:first',
1329
+ 'hook:second',
1330
+ ]);
1331
+ expect(firstHook).toHaveBeenCalledWith({
1332
+ documentId: 'doc-1',
1333
+ collectionPath: 'articles',
1334
+ requested: { path: true, availableLocales: true },
1335
+ changed: { path: true, availableLocales: true },
1336
+ reconciliation: false,
1337
+ previousPath: 'old-slug',
1338
+ currentPath: 'new-slug',
1339
+ previousAvailableLocales: ['en'],
1340
+ currentAvailableLocales: ['fr', 'en'],
1341
+ });
1342
+ });
1343
+ it('does not fire side-effect hooks when the audited transaction fails', async () => {
1344
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
1345
+ setupDoc(getDocumentSystemFieldsForUpdate);
1346
+ const afterSystemFieldsChange = vi.fn();
1347
+ auditAppend.mockRejectedValue(new Error('audit failed'));
1348
+ const ctx = buildCtx(db, {
1349
+ ...minimalCollection,
1350
+ hooks: { afterSystemFieldsChange },
1351
+ });
1352
+ await expect(updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'new-slug' })).rejects.toThrow('audit failed');
1353
+ expect(db.commands.documents.updateDocumentPath).toHaveBeenCalledOnce();
1354
+ expect(afterSystemFieldsChange).not.toHaveBeenCalled();
1355
+ });
1356
+ it('propagates an after-hook failure without folding it into the committed transaction', async () => {
1357
+ const { db, getDocumentSystemFieldsForUpdate, withTransaction } = createMockDb();
1358
+ setupDoc(getDocumentSystemFieldsForUpdate);
1359
+ let committed = false;
1360
+ withTransaction.mockImplementation(async (fn) => {
1361
+ const value = await fn();
1362
+ committed = true;
1363
+ return value;
1364
+ });
1365
+ const afterSystemFieldsChange = vi.fn(async () => {
1366
+ expect(committed).toBe(true);
1367
+ throw new Error('cache unavailable');
1368
+ });
1369
+ const ctx = buildCtx(db, {
1370
+ ...minimalCollection,
1371
+ hooks: { afterSystemFieldsChange },
1372
+ });
1373
+ await expect(updateDocumentSystemFields(ctx, { documentId: 'doc-1', availableLocales: ['en', 'fr'] })).rejects.toThrow('cache unavailable');
1374
+ expect(committed).toBe(true);
1375
+ expect(db.commands.documents.setDocumentAvailableLocales).toHaveBeenCalledOnce();
1376
+ expect(afterSystemFieldsChange).toHaveBeenCalledOnce();
1377
+ });
1378
+ it('re-runs reconciliation on an explicit no-op retry after a committed hook failure', async () => {
1379
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
1380
+ setupDoc(getDocumentSystemFieldsForUpdate);
1381
+ const afterSystemFieldsChange = vi
1382
+ .fn()
1383
+ .mockRejectedValueOnce(new Error('search unavailable'))
1384
+ .mockResolvedValue(undefined);
1385
+ const ctx = buildCtx(db, {
1386
+ ...minimalCollection,
1387
+ hooks: { afterSystemFieldsChange },
1388
+ });
1389
+ await expect(updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'new-slug' })).rejects.toThrow('search unavailable');
1390
+ setupDoc(getDocumentSystemFieldsForUpdate, { path: 'new-slug' });
1391
+ const retry = await updateDocumentSystemFields(ctx, {
1392
+ documentId: 'doc-1',
1393
+ path: 'new-slug',
1394
+ reconcile: true,
1395
+ });
1396
+ expect(retry).toMatchObject({ changed: false, reconciliation: true });
1397
+ expect(db.commands.documents.updateDocumentPath).toHaveBeenCalledOnce();
1398
+ expect(auditAppend).toHaveBeenCalledOnce();
1399
+ expect(afterSystemFieldsChange).toHaveBeenCalledTimes(2);
1400
+ expect(afterSystemFieldsChange).toHaveBeenLastCalledWith(expect.objectContaining({
1401
+ requested: { path: true, availableLocales: false },
1402
+ changed: { path: false, availableLocales: false },
1403
+ reconciliation: true,
1404
+ previousPath: 'new-slug',
1405
+ currentPath: 'new-slug',
1406
+ }));
1407
+ });
1408
+ it('uses the locked transaction snapshot for audit and invalidation payloads', async () => {
1409
+ const { db, getDocumentById, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
1410
+ getDocumentById.mockResolvedValue({
1411
+ path: 'stale-path',
1412
+ source_locale: 'en',
1413
+ availableLocales: ['en'],
1414
+ });
1415
+ setupDoc(getDocumentSystemFieldsForUpdate, {
1416
+ path: 'concurrent-path',
1417
+ availableLocales: ['en', 'fr'],
1418
+ });
1419
+ const afterSystemFieldsChange = vi.fn();
1420
+ const ctx = buildCtx(db, {
1421
+ ...minimalCollection,
1422
+ hooks: { afterSystemFieldsChange },
1423
+ });
1424
+ await updateDocumentSystemFields(ctx, {
1425
+ documentId: 'doc-1',
1426
+ path: 'final-path',
1427
+ availableLocales: ['de'],
1428
+ });
1429
+ expect(getDocumentById).not.toHaveBeenCalled();
1430
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ field: 'path', before: 'concurrent-path', after: 'final-path' }));
1431
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1432
+ field: 'availableLocales',
1433
+ before: ['en', 'fr'],
1434
+ after: ['de'],
1435
+ }));
1436
+ expect(afterSystemFieldsChange).toHaveBeenCalledWith(expect.objectContaining({
1437
+ previousPath: 'concurrent-path',
1438
+ currentPath: 'final-path',
1439
+ previousAvailableLocales: ['en', 'fr'],
1440
+ currentAvailableLocales: ['de'],
1441
+ }));
1442
+ });
1033
1443
  });
1034
1444
  // -----------------------------------------------------------------------
1035
1445
  // restoreDocumentVersion
@@ -5,21 +5,29 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { CollectionDefinition, ReadContext } from '../@types/index.js';
8
+ import type { CollectionDefinition, ReadContext, ReadMode } from '../@types/index.js';
9
9
  /**
10
10
  * Fire the `afterRead` hook for one reconstructed document.
11
11
  *
12
- * No-op when the collection has no hook. No-op when the document has
13
- * already been through `afterRead` in this `ReadContext` enforces the
14
- * "once per document per logical request" rule that forecloses the
15
- * A→B→A infinite loop when hooks perform their own reads.
12
+ * No-op when the collection has no hook, when this exact materialized object
13
+ * completed the hook already. An active logical version fails closed with
14
+ * `ERR_READ_RECURSION`: even the same object may be only partially processed,
15
+ * so returning it cannot be proven safe. Fresh raw objects are always redacted
16
+ * once the active call completes. Failed hooks do not mark the object complete,
17
+ * and active state is cleared in `finally` so a later retry can proceed.
16
18
  *
17
19
  * The hook receives the raw storage-shape document (mutable) and the
18
- * shared `ReadContext` so it can thread the same context through any
20
+ * operation's authenticated context plus the shared `ReadContext`, so it can
21
+ * redact by actor and thread the same context through any
19
22
  * nested `client.collection(...).findById(id, { _readContext })` calls.
20
23
  */
21
24
  export declare function applyAfterRead(params: {
22
25
  doc: Record<string, any>;
23
26
  definition: CollectionDefinition;
24
27
  readContext: ReadContext;
28
+ requestContext: import('@byline/auth').RequestContext;
29
+ locale?: string;
30
+ readMode?: ReadMode;
31
+ projection?: readonly string[];
32
+ materialization?: string;
25
33
  }): Promise<void>;