@byline/core 3.21.0 → 4.1.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 (77) hide show
  1. package/dist/@types/collection-types.d.ts +66 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-types.d.ts +25 -1
  4. package/dist/@types/query-predicate.d.ts +3 -3
  5. package/dist/@types/search-types.d.ts +3 -4
  6. package/dist/@types/site-config.d.ts +51 -12
  7. package/dist/auth/apply-before-read.d.ts +23 -11
  8. package/dist/auth/apply-before-read.js +139 -33
  9. package/dist/auth/apply-before-read.test.node.js +241 -3
  10. package/dist/auth/index.d.ts +1 -1
  11. package/dist/auth/index.js +1 -1
  12. package/dist/auth/read-context-scope.d.ts +20 -0
  13. package/dist/auth/read-context-scope.js +48 -0
  14. package/dist/codegen/index.js +34 -3
  15. package/dist/codegen/index.test.node.js +20 -2
  16. package/dist/config/attach-hooks.d.ts +25 -0
  17. package/dist/config/attach-hooks.js +130 -0
  18. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  19. package/dist/config/attach-hooks.test.node.js +173 -0
  20. package/dist/config/config-hooks.test.node.d.ts +1 -0
  21. package/dist/config/config-hooks.test.node.js +56 -0
  22. package/dist/config/config.d.ts +9 -5
  23. package/dist/config/config.js +20 -2
  24. package/dist/config/routes.d.ts +5 -5
  25. package/dist/config/routes.js +42 -9
  26. package/dist/config/routes.test.node.d.ts +1 -0
  27. package/dist/config/routes.test.node.js +152 -0
  28. package/dist/core.d.ts +2 -2
  29. package/dist/core.js +20 -14
  30. package/dist/core.test.node.d.ts +1 -0
  31. package/dist/core.test.node.js +28 -0
  32. package/dist/host/host-request-bridge.d.ts +62 -0
  33. package/dist/host/host-request-bridge.js +38 -0
  34. package/dist/index.d.ts +5 -3
  35. package/dist/index.js +5 -3
  36. package/dist/lib/errors.d.ts +13 -0
  37. package/dist/lib/errors.js +14 -0
  38. package/dist/query/parse-where.d.ts +9 -0
  39. package/dist/query/parse-where.js +146 -2
  40. package/dist/query/parse-where.test.node.js +60 -1
  41. package/dist/services/collection-bootstrap.test.node.js +30 -59
  42. package/dist/services/discover-counter-groups.test.node.js +23 -0
  43. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  44. package/dist/services/document-lifecycle/audit.js +32 -1
  45. package/dist/services/document-lifecycle/create.js +13 -6
  46. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  47. package/dist/services/document-lifecycle/delete.js +91 -26
  48. package/dist/services/document-lifecycle/index.d.ts +1 -1
  49. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  50. package/dist/services/document-lifecycle/internals.js +22 -46
  51. package/dist/services/document-lifecycle/status.d.ts +1 -1
  52. package/dist/services/document-lifecycle/status.js +20 -3
  53. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  54. package/dist/services/document-lifecycle/system-fields.js +112 -74
  55. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  56. package/dist/services/document-lifecycle/tree.js +244 -123
  57. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  58. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  59. package/dist/services/document-lifecycle/update.js +2 -1
  60. package/dist/services/document-lifecycle.test.node.js +360 -16
  61. package/dist/services/document-read.d.ts +14 -6
  62. package/dist/services/document-read.js +47 -9
  63. package/dist/services/field-upload.test.node.js +70 -0
  64. package/dist/services/index.d.ts +2 -2
  65. package/dist/services/index.js +2 -2
  66. package/dist/services/populate.d.ts +7 -3
  67. package/dist/services/populate.js +180 -28
  68. package/dist/services/populate.test.node.js +59 -0
  69. package/dist/services/richtext-embed.d.ts +39 -2
  70. package/dist/services/richtext-embed.js +4 -34
  71. package/dist/services/richtext-embed.test.node.js +15 -0
  72. package/dist/services/richtext-populate.d.ts +20 -2
  73. package/dist/services/richtext-populate.js +160 -19
  74. package/dist/services/richtext-populate.test.node.js +523 -2
  75. package/dist/utils/root-relative-redirect.d.ts +6 -0
  76. package/dist/utils/root-relative-redirect.js +37 -0
  77. package/package.json +2 -2
@@ -42,6 +42,7 @@ function createMockDb() {
42
42
  const archivePublishedVersions = vi.fn().mockResolvedValue(0);
43
43
  const softDeleteDocument = vi.fn().mockResolvedValue(1);
44
44
  const getDocumentById = vi.fn().mockResolvedValue(null);
45
+ const getDocumentSystemFieldsForUpdate = vi.fn().mockResolvedValue(null);
45
46
  const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
46
47
  const getCurrentPath = vi.fn().mockResolvedValue('current-path');
47
48
  // Audit capability (docs/06-auth-and-security/02-auditability.md — W2). `withTransaction` is a passthrough
@@ -67,6 +68,16 @@ function createMockDb() {
67
68
  setOrderKey: vi.fn(),
68
69
  placeTreeNode: vi.fn(),
69
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
+ })),
70
81
  },
71
82
  counters: {
72
83
  ensureCounterGroup: vi.fn(),
@@ -83,6 +94,7 @@ function createMockDb() {
83
94
  getCollectionById: vi.fn(),
84
95
  },
85
96
  documents: {
97
+ getDocumentSystemFieldsForUpdate,
86
98
  getDocumentById,
87
99
  getCurrentVersionMetadata,
88
100
  getCurrentPath,
@@ -103,6 +115,16 @@ function createMockDb() {
103
115
  getTreeParent: vi.fn(),
104
116
  getTreeSubtree: vi.fn(),
105
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
+ },
106
128
  },
107
129
  };
108
130
  return {
@@ -112,6 +134,7 @@ function createMockDb() {
112
134
  archivePublishedVersions,
113
135
  softDeleteDocument,
114
136
  getDocumentById,
137
+ getDocumentSystemFieldsForUpdate,
115
138
  getCurrentVersionMetadata,
116
139
  getCurrentPath,
117
140
  auditAppend,
@@ -890,6 +913,38 @@ describe('Document lifecycle service', () => {
890
913
  expect(result.archivedCount).toBe(1);
891
914
  expect(archivePublishedVersions).toHaveBeenCalledWith({ document_id: 'doc-1' });
892
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
+ });
893
948
  it('invokes beforeUnpublish and afterUnpublish hooks', async () => {
894
949
  const callOrder = [];
895
950
  const hooks = {
@@ -993,6 +1048,23 @@ describe('Document lifecycle service', () => {
993
1048
  action: 'document.deleted',
994
1049
  }));
995
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
+ });
996
1068
  it('cleans up stored files for upload fields at any nesting depth', async () => {
997
1069
  const { db, getDocumentById } = createMockDb();
998
1070
  const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
@@ -1045,28 +1117,108 @@ describe('Document lifecycle service', () => {
1045
1117
  'files/b.pdf',
1046
1118
  ]);
1047
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
+ });
1048
1194
  });
1049
1195
  // -----------------------------------------------------------------------
1050
1196
  // updateDocumentSystemFields (audited, non-versioned)
1051
1197
  // -----------------------------------------------------------------------
1052
1198
  describe('updateDocumentSystemFields', () => {
1053
- function setupDoc(getDocumentById, overrides) {
1054
- getDocumentById.mockResolvedValue({
1055
- document_version_id: 'ver-1',
1056
- document_id: 'doc-1',
1199
+ function setupDoc(getDocumentSystemFieldsForUpdate, overrides) {
1200
+ getDocumentSystemFieldsForUpdate.mockResolvedValue({
1057
1201
  path: 'old-slug',
1058
1202
  source_locale: 'en',
1059
1203
  availableLocales: ['en'],
1060
- fields: {},
1061
1204
  ...overrides,
1062
1205
  });
1063
1206
  }
1064
1207
  it('records document.path.changed when the path actually changes', async () => {
1065
- const { db, getDocumentById, auditAppend, withTransaction } = createMockDb();
1066
- setupDoc(getDocumentById);
1208
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1209
+ setupDoc(getDocumentSystemFieldsForUpdate);
1067
1210
  const ctx = buildCtx(db);
1068
- await updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'new-slug' });
1211
+ const result = await updateDocumentSystemFields(ctx, {
1212
+ documentId: 'doc-1',
1213
+ path: 'new-slug',
1214
+ });
1069
1215
  expect(withTransaction).toHaveBeenCalledOnce();
1216
+ expect(result).toMatchObject({
1217
+ changed: true,
1218
+ pathChanged: true,
1219
+ availableLocalesChanged: false,
1220
+ path: 'new-slug',
1221
+ });
1070
1222
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1071
1223
  action: 'document.path.changed',
1072
1224
  field: 'path',
@@ -1074,21 +1226,38 @@ describe('Document lifecycle service', () => {
1074
1226
  after: 'new-slug',
1075
1227
  }));
1076
1228
  });
1077
- it('records no audit row when the path is unchanged', async () => {
1078
- const { db, getDocumentById, auditAppend } = createMockDb();
1079
- setupDoc(getDocumentById);
1080
- const ctx = buildCtx(db);
1081
- 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();
1082
1244
  expect(auditAppend).not.toHaveBeenCalled();
1245
+ expect(afterSystemFieldsChange).not.toHaveBeenCalled();
1083
1246
  });
1084
1247
  it('records document.locales.changed with before/after sets', async () => {
1085
- const { db, getDocumentById, auditAppend } = createMockDb();
1086
- setupDoc(getDocumentById);
1248
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
1249
+ setupDoc(getDocumentSystemFieldsForUpdate);
1087
1250
  const ctx = buildCtx(db);
1088
- await updateDocumentSystemFields(ctx, {
1251
+ const result = await updateDocumentSystemFields(ctx, {
1089
1252
  documentId: 'doc-1',
1090
1253
  availableLocales: ['en', 'fr'],
1091
1254
  });
1255
+ expect(result).toMatchObject({
1256
+ changed: true,
1257
+ pathChanged: false,
1258
+ availableLocalesChanged: true,
1259
+ availableLocalesWritten: true,
1260
+ });
1092
1261
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1093
1262
  action: 'document.locales.changed',
1094
1263
  field: 'availableLocales',
@@ -1096,6 +1265,181 @@ describe('Document lifecycle service', () => {
1096
1265
  after: ['en', 'fr'],
1097
1266
  }));
1098
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
+ });
1099
1443
  });
1100
1444
  // -----------------------------------------------------------------------
1101
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>;
@@ -21,6 +21,8 @@
21
21
  * `beforeCreate` / `afterCreate` hooks.
22
22
  */
23
23
  import { normalizeCollectionHook, resolveHooks } from '../@types/index.js';
24
+ import { resolveReadContextRoot } from '../auth/read-context-scope.js';
25
+ import { ERR_READ_RECURSION } from '../lib/errors.js';
24
26
  async function invokeHook(hook, ctx) {
25
27
  const fns = normalizeCollectionHook(hook);
26
28
  for (const fn of fns) {
@@ -30,13 +32,16 @@ async function invokeHook(hook, ctx) {
30
32
  /**
31
33
  * Fire the `afterRead` hook for one reconstructed document.
32
34
  *
33
- * No-op when the collection has no hook. No-op when the document has
34
- * already been through `afterRead` in this `ReadContext` enforces the
35
- * "once per document per logical request" rule that forecloses the
36
- * A→B→A infinite loop when hooks perform their own reads.
35
+ * No-op when the collection has no hook, when this exact materialized object
36
+ * completed the hook already. An active logical version fails closed with
37
+ * `ERR_READ_RECURSION`: even the same object may be only partially processed,
38
+ * so returning it cannot be proven safe. Fresh raw objects are always redacted
39
+ * once the active call completes. Failed hooks do not mark the object complete,
40
+ * and active state is cleared in `finally` so a later retry can proceed.
37
41
  *
38
42
  * The hook receives the raw storage-shape document (mutable) and the
39
- * shared `ReadContext` so it can thread the same context through any
43
+ * operation's authenticated context plus the shared `ReadContext`, so it can
44
+ * redact by actor and thread the same context through any
40
45
  * nested `client.collection(...).findById(id, { _readContext })` calls.
41
46
  */
42
47
  export async function applyAfterRead(params) {
@@ -47,14 +52,47 @@ export async function applyAfterRead(params) {
47
52
  const docId = params.doc?.document_id;
48
53
  if (typeof docId !== 'string')
49
54
  return;
50
- const key = `${params.definition.path}:${docId}`;
51
- if (params.readContext.afterReadFired.has(key))
55
+ const versionId = typeof params.doc?.document_version_id === 'string'
56
+ ? params.doc.document_version_id
57
+ : `document:${docId}`;
58
+ const state = getAfterReadState(params.readContext);
59
+ if (state.processed.has(params.doc))
52
60
  return;
53
- params.readContext.afterReadFired.add(key);
61
+ // ReadContext already scopes the operation; keeping identity out of this key
62
+ // prevents a nested caller from evading the guard with another context clone.
63
+ const activeKey = `${params.definition.path}:${versionId}`;
64
+ if (state.active.has(activeKey)) {
65
+ throw ERR_READ_RECURSION({
66
+ message: `afterRead recursion blocked for active version '${versionId}'`,
67
+ details: {
68
+ collectionPath: params.definition.path,
69
+ documentId: docId,
70
+ documentVersionId: versionId,
71
+ },
72
+ });
73
+ }
54
74
  const ctx = {
55
75
  doc: params.doc,
56
76
  collectionPath: params.definition.path,
77
+ requestContext: params.requestContext,
57
78
  readContext: params.readContext,
58
79
  };
59
- await invokeHook(hook, ctx);
80
+ state.active.add(activeKey);
81
+ try {
82
+ await invokeHook(hook, ctx);
83
+ state.processed.add(params.doc);
84
+ }
85
+ finally {
86
+ state.active.delete(activeKey);
87
+ }
88
+ }
89
+ const afterReadStates = new WeakMap();
90
+ function getAfterReadState(readContext) {
91
+ const root = resolveReadContextRoot(readContext);
92
+ const existing = afterReadStates.get(root);
93
+ if (existing)
94
+ return existing;
95
+ const state = { active: new Set(), processed: new WeakSet() };
96
+ afterReadStates.set(root, state);
97
+ return state;
60
98
  }