@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.
- package/dist/@types/collection-types.d.ts +66 -31
- package/dist/@types/db-types.d.ts +93 -47
- package/dist/@types/field-types.d.ts +25 -1
- package/dist/@types/query-predicate.d.ts +3 -3
- package/dist/@types/search-types.d.ts +3 -4
- package/dist/@types/site-config.d.ts +51 -12
- package/dist/auth/apply-before-read.d.ts +23 -11
- package/dist/auth/apply-before-read.js +139 -33
- package/dist/auth/apply-before-read.test.node.js +241 -3
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/read-context-scope.d.ts +20 -0
- package/dist/auth/read-context-scope.js +48 -0
- package/dist/codegen/index.js +34 -3
- package/dist/codegen/index.test.node.js +20 -2
- package/dist/config/attach-hooks.d.ts +25 -0
- package/dist/config/attach-hooks.js +130 -0
- package/dist/config/attach-hooks.test.node.d.ts +1 -0
- package/dist/config/attach-hooks.test.node.js +173 -0
- package/dist/config/config-hooks.test.node.d.ts +1 -0
- package/dist/config/config-hooks.test.node.js +56 -0
- package/dist/config/config.d.ts +9 -5
- package/dist/config/config.js +20 -2
- package/dist/config/routes.d.ts +5 -5
- package/dist/config/routes.js +42 -9
- package/dist/config/routes.test.node.d.ts +1 -0
- package/dist/config/routes.test.node.js +152 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +20 -14
- package/dist/core.test.node.d.ts +1 -0
- package/dist/core.test.node.js +28 -0
- package/dist/host/host-request-bridge.d.ts +62 -0
- package/dist/host/host-request-bridge.js +38 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/lib/errors.d.ts +13 -0
- package/dist/lib/errors.js +14 -0
- package/dist/query/parse-where.d.ts +9 -0
- package/dist/query/parse-where.js +146 -2
- package/dist/query/parse-where.test.node.js +60 -1
- package/dist/services/collection-bootstrap.test.node.js +30 -59
- package/dist/services/discover-counter-groups.test.node.js +23 -0
- package/dist/services/document-lifecycle/audit.d.ts +22 -1
- package/dist/services/document-lifecycle/audit.js +32 -1
- package/dist/services/document-lifecycle/create.js +13 -6
- package/dist/services/document-lifecycle/delete.d.ts +17 -1
- package/dist/services/document-lifecycle/delete.js +91 -26
- package/dist/services/document-lifecycle/index.d.ts +1 -1
- package/dist/services/document-lifecycle/internals.d.ts +0 -20
- package/dist/services/document-lifecycle/internals.js +22 -46
- package/dist/services/document-lifecycle/status.d.ts +1 -1
- package/dist/services/document-lifecycle/status.js +20 -3
- package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
- package/dist/services/document-lifecycle/system-fields.js +112 -74
- package/dist/services/document-lifecycle/tree.d.ts +22 -24
- package/dist/services/document-lifecycle/tree.js +244 -123
- package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
- package/dist/services/document-lifecycle/tree.test.node.js +663 -0
- package/dist/services/document-lifecycle/update.js +2 -1
- package/dist/services/document-lifecycle.test.node.js +360 -16
- package/dist/services/document-read.d.ts +14 -6
- package/dist/services/document-read.js +47 -9
- package/dist/services/field-upload.test.node.js +70 -0
- package/dist/services/index.d.ts +2 -2
- package/dist/services/index.js +2 -2
- package/dist/services/populate.d.ts +7 -3
- package/dist/services/populate.js +180 -28
- package/dist/services/populate.test.node.js +59 -0
- package/dist/services/richtext-embed.d.ts +39 -2
- package/dist/services/richtext-embed.js +4 -34
- package/dist/services/richtext-embed.test.node.js +15 -0
- package/dist/services/richtext-populate.d.ts +20 -2
- package/dist/services/richtext-populate.js +160 -19
- package/dist/services/richtext-populate.test.node.js +523 -2
- package/dist/utils/root-relative-redirect.d.ts +6 -0
- package/dist/utils/root-relative-redirect.js +37 -0
- 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(
|
|
1054
|
-
|
|
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,
|
|
1066
|
-
setupDoc(
|
|
1208
|
+
const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
|
|
1209
|
+
setupDoc(getDocumentSystemFieldsForUpdate);
|
|
1067
1210
|
const ctx = buildCtx(db);
|
|
1068
|
-
await updateDocumentSystemFields(ctx, {
|
|
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('
|
|
1078
|
-
const { db,
|
|
1079
|
-
setupDoc(
|
|
1080
|
-
const
|
|
1081
|
-
|
|
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,
|
|
1086
|
-
setupDoc(
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* No-op when the collection has no hook, when this exact materialized object
|
|
36
|
+
* completed the hook already. An active logical version fails closed with
|
|
37
|
+
* `ERR_READ_RECURSION`: even the same object may be only partially processed,
|
|
38
|
+
* so returning it cannot be proven safe. Fresh raw objects are always redacted
|
|
39
|
+
* once the active call completes. Failed hooks do not mark the object complete,
|
|
40
|
+
* and active state is cleared in `finally` so a later retry can proceed.
|
|
37
41
|
*
|
|
38
42
|
* The hook receives the raw storage-shape document (mutable) and the
|
|
39
|
-
* shared `ReadContext
|
|
43
|
+
* operation's authenticated context plus the shared `ReadContext`, so it can
|
|
44
|
+
* redact by actor and thread the same context through any
|
|
40
45
|
* nested `client.collection(...).findById(id, { _readContext })` calls.
|
|
41
46
|
*/
|
|
42
47
|
export async function applyAfterRead(params) {
|
|
@@ -47,14 +52,47 @@ export async function applyAfterRead(params) {
|
|
|
47
52
|
const docId = params.doc?.document_id;
|
|
48
53
|
if (typeof docId !== 'string')
|
|
49
54
|
return;
|
|
50
|
-
const
|
|
51
|
-
|
|
55
|
+
const versionId = typeof params.doc?.document_version_id === 'string'
|
|
56
|
+
? params.doc.document_version_id
|
|
57
|
+
: `document:${docId}`;
|
|
58
|
+
const state = getAfterReadState(params.readContext);
|
|
59
|
+
if (state.processed.has(params.doc))
|
|
52
60
|
return;
|
|
53
|
-
|
|
61
|
+
// ReadContext already scopes the operation; keeping identity out of this key
|
|
62
|
+
// prevents a nested caller from evading the guard with another context clone.
|
|
63
|
+
const activeKey = `${params.definition.path}:${versionId}`;
|
|
64
|
+
if (state.active.has(activeKey)) {
|
|
65
|
+
throw ERR_READ_RECURSION({
|
|
66
|
+
message: `afterRead recursion blocked for active version '${versionId}'`,
|
|
67
|
+
details: {
|
|
68
|
+
collectionPath: params.definition.path,
|
|
69
|
+
documentId: docId,
|
|
70
|
+
documentVersionId: versionId,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
54
74
|
const ctx = {
|
|
55
75
|
doc: params.doc,
|
|
56
76
|
collectionPath: params.definition.path,
|
|
77
|
+
requestContext: params.requestContext,
|
|
57
78
|
readContext: params.readContext,
|
|
58
79
|
};
|
|
59
|
-
|
|
80
|
+
state.active.add(activeKey);
|
|
81
|
+
try {
|
|
82
|
+
await invokeHook(hook, ctx);
|
|
83
|
+
state.processed.add(params.doc);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
state.active.delete(activeKey);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const afterReadStates = new WeakMap();
|
|
90
|
+
function getAfterReadState(readContext) {
|
|
91
|
+
const root = resolveReadContextRoot(readContext);
|
|
92
|
+
const existing = afterReadStates.get(root);
|
|
93
|
+
if (existing)
|
|
94
|
+
return existing;
|
|
95
|
+
const state = { active: new Set(), processed: new WeakSet() };
|
|
96
|
+
afterReadStates.set(root, state);
|
|
97
|
+
return state;
|
|
60
98
|
}
|