@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.
- package/dist/@types/collection-types.d.ts +72 -31
- package/dist/@types/db-types.d.ts +93 -47
- package/dist/@types/field-data-types.d.ts +8 -10
- package/dist/@types/field-data-types.js +1 -1
- package/dist/@types/field-data-types.test.node.d.ts +1 -0
- package/dist/@types/field-data-types.test.node.js +70 -0
- package/dist/@types/field-types.d.ts +25 -1
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/@types/query-predicate.d.ts +3 -3
- package/dist/@types/relation-types.d.ts +38 -0
- package/dist/@types/relation-types.js +8 -0
- package/dist/@types/search-types.d.ts +3 -4
- package/dist/@types/site-config.d.ts +52 -13
- 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/fixtures/all-fields.d.ts +342 -0
- package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
- package/dist/codegen/fixtures/all-fields.expected.js +1 -0
- package/dist/codegen/fixtures/all-fields.js +94 -0
- package/dist/codegen/index.d.ts +16 -0
- package/dist/codegen/index.js +431 -0
- package/dist/codegen/index.test.node.d.ts +1 -0
- package/dist/codegen/index.test.node.js +230 -0
- 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 +3 -3
- 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/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/lib/errors.d.ts +13 -0
- package/dist/lib/errors.js +14 -0
- package/dist/query/parse-where.d.ts +10 -1
- package/dist/query/parse-where.js +146 -2
- package/dist/query/parse-where.test.node.js +60 -1
- package/dist/schemas/zod/builder.js +7 -3
- package/dist/schemas/zod/builder.test.node.d.ts +1 -0
- package/dist/schemas/zod/builder.test.node.js +49 -0
- package/dist/services/collection-bootstrap.d.ts +1 -1
- package/dist/services/collection-bootstrap.test.node.js +30 -59
- package/dist/services/discover-counter-groups.d.ts +1 -1
- 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.d.ts +2 -2
- package/dist/services/document-lifecycle/create.js +18 -8
- 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.d.ts +4 -4
- package/dist/services/document-lifecycle/update.js +11 -5
- package/dist/services/document-lifecycle.test.node.js +426 -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 +3 -2
- package/dist/services/index.js +3 -2
- package/dist/services/normalize-numeric-fields.d.ts +23 -0
- package/dist/services/normalize-numeric-fields.js +89 -0
- package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
- package/dist/services/normalize-numeric-fields.test.node.js +99 -0
- package/dist/services/populate.d.ts +9 -23
- 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 +21 -3
- package/dist/services/richtext-populate.js +160 -19
- package/dist/services/richtext-populate.test.node.js +523 -2
- package/dist/services/validate-search-config.d.ts +1 -1
- package/dist/storage/collection-fingerprint.js +6 -0
- package/dist/storage/collection-fingerprint.test.node.js +21 -0
- package/dist/utils/root-relative-redirect.d.ts +6 -0
- package/dist/utils/root-relative-redirect.js +37 -0
- 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 {
|
|
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.
|
|
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.
|
|
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(
|
|
988
|
-
|
|
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,
|
|
1000
|
-
setupDoc(
|
|
1208
|
+
const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
|
|
1209
|
+
setupDoc(getDocumentSystemFieldsForUpdate);
|
|
1001
1210
|
const ctx = buildCtx(db);
|
|
1002
|
-
await updateDocumentSystemFields(ctx, {
|
|
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('
|
|
1012
|
-
const { db,
|
|
1013
|
-
setupDoc(
|
|
1014
|
-
const
|
|
1015
|
-
|
|
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,
|
|
1020
|
-
setupDoc(
|
|
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
|
|
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>;
|