@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
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { createSuperAdminContext } from '@byline/auth';
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { TREE_HOOK_COMMITTED_MARKER } from '../../lib/errors.js';
|
|
11
|
+
import { validateTreeAuditCapability } from './audit.js';
|
|
12
|
+
import { createDocument } from './create.js';
|
|
13
|
+
import { deleteDocument } from './delete.js';
|
|
14
|
+
import { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|
|
15
|
+
const ACTOR_ID = '01901234-0000-7000-8000-000000000001';
|
|
16
|
+
function createHarness(options = {}) {
|
|
17
|
+
let placements = new Map(Object.entries(options.initial ?? {}));
|
|
18
|
+
let auditRows = [];
|
|
19
|
+
let key = 0;
|
|
20
|
+
let writes = 0;
|
|
21
|
+
let deleted = false;
|
|
22
|
+
const calls = [];
|
|
23
|
+
const snapshot = (documentId) => {
|
|
24
|
+
const placement = placements.get(documentId);
|
|
25
|
+
if (placement == null) {
|
|
26
|
+
return {
|
|
27
|
+
state: { placed: false, parentDocumentId: null, orderKey: null, index: null },
|
|
28
|
+
siblings: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const siblings = [...placements.entries()]
|
|
32
|
+
.filter(([, candidate]) => candidate.parentDocumentId === placement.parentDocumentId)
|
|
33
|
+
.map(([id]) => id);
|
|
34
|
+
return {
|
|
35
|
+
state: {
|
|
36
|
+
placed: true,
|
|
37
|
+
parentDocumentId: placement.parentDocumentId,
|
|
38
|
+
orderKey: placement.orderKey,
|
|
39
|
+
index: siblings.indexOf(documentId),
|
|
40
|
+
},
|
|
41
|
+
siblings,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
const subtree = (documentId) => {
|
|
45
|
+
const ids = [documentId];
|
|
46
|
+
for (const [childId, placement] of placements) {
|
|
47
|
+
if (placement.parentDocumentId === documentId)
|
|
48
|
+
ids.push(...subtree(childId));
|
|
49
|
+
}
|
|
50
|
+
return ids;
|
|
51
|
+
};
|
|
52
|
+
const place = vi.fn(async (params) => {
|
|
53
|
+
calls.push(`place:${params.documentId}`);
|
|
54
|
+
if (options.failPlace)
|
|
55
|
+
throw new Error('tree write failed');
|
|
56
|
+
const before = snapshot(params.documentId);
|
|
57
|
+
if (params.ifUnplaced && before.state.placed) {
|
|
58
|
+
return {
|
|
59
|
+
changed: false,
|
|
60
|
+
before: before.state,
|
|
61
|
+
after: before.state,
|
|
62
|
+
beforeSiblingDocumentIds: before.siblings,
|
|
63
|
+
beforeSubtreeDocumentIds: [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const target = [...placements.entries()]
|
|
67
|
+
.filter(([id, placement]) => id !== params.documentId && placement.parentDocumentId === params.parentDocumentId)
|
|
68
|
+
.map(([id]) => id);
|
|
69
|
+
let index = target.length;
|
|
70
|
+
if (params.afterDocumentId)
|
|
71
|
+
index = target.indexOf(params.afterDocumentId);
|
|
72
|
+
else if (params.beforeDocumentId)
|
|
73
|
+
index = target.indexOf(params.beforeDocumentId) + 1;
|
|
74
|
+
if (before.state.placed &&
|
|
75
|
+
before.state.parentDocumentId === params.parentDocumentId &&
|
|
76
|
+
before.state.index === index) {
|
|
77
|
+
return {
|
|
78
|
+
changed: false,
|
|
79
|
+
before: before.state,
|
|
80
|
+
after: before.state,
|
|
81
|
+
beforeSiblingDocumentIds: before.siblings,
|
|
82
|
+
beforeSubtreeDocumentIds: [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const orderKey = `key-${++key}`;
|
|
86
|
+
writes++;
|
|
87
|
+
placements.set(params.documentId, { parentDocumentId: params.parentDocumentId, orderKey });
|
|
88
|
+
return {
|
|
89
|
+
changed: true,
|
|
90
|
+
before: before.state,
|
|
91
|
+
after: { placed: true, parentDocumentId: params.parentDocumentId, orderKey, index },
|
|
92
|
+
beforeSiblingDocumentIds: before.siblings,
|
|
93
|
+
beforeSubtreeDocumentIds: [],
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
const remove = vi.fn(async ({ documentId, includeSubtree, }) => {
|
|
97
|
+
calls.push(`remove:${documentId}`);
|
|
98
|
+
const before = snapshot(documentId);
|
|
99
|
+
if (!before.state.placed) {
|
|
100
|
+
return {
|
|
101
|
+
changed: false,
|
|
102
|
+
before: before.state,
|
|
103
|
+
after: before.state,
|
|
104
|
+
beforeSiblingDocumentIds: [],
|
|
105
|
+
beforeSubtreeDocumentIds: includeSubtree ? subtree(documentId) : [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
placements.delete(documentId);
|
|
109
|
+
writes++;
|
|
110
|
+
return {
|
|
111
|
+
changed: true,
|
|
112
|
+
before: before.state,
|
|
113
|
+
after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
|
|
114
|
+
beforeSiblingDocumentIds: before.siblings,
|
|
115
|
+
beforeSubtreeDocumentIds: includeSubtree ? subtree(documentId) : [],
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
const promote = vi.fn(async ({ documentId }) => {
|
|
119
|
+
calls.push(`promote:${documentId}`);
|
|
120
|
+
const parent = snapshot(documentId);
|
|
121
|
+
const children = [...placements.entries()].filter(([, placement]) => placement.parentDocumentId === documentId);
|
|
122
|
+
const promoted = children.map(([childId, placement], index) => {
|
|
123
|
+
const before = {
|
|
124
|
+
placed: true,
|
|
125
|
+
parentDocumentId: documentId,
|
|
126
|
+
orderKey: placement.orderKey,
|
|
127
|
+
index,
|
|
128
|
+
};
|
|
129
|
+
const orderKey = `key-${++key}`;
|
|
130
|
+
const after = {
|
|
131
|
+
placed: true,
|
|
132
|
+
parentDocumentId: null,
|
|
133
|
+
orderKey,
|
|
134
|
+
index: null,
|
|
135
|
+
};
|
|
136
|
+
placements.set(childId, { parentDocumentId: null, orderKey });
|
|
137
|
+
return { documentId: childId, before, after };
|
|
138
|
+
});
|
|
139
|
+
placements.delete(documentId);
|
|
140
|
+
return {
|
|
141
|
+
removed: {
|
|
142
|
+
changed: parent.state.placed,
|
|
143
|
+
before: parent.state,
|
|
144
|
+
after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
|
|
145
|
+
beforeSiblingDocumentIds: parent.siblings,
|
|
146
|
+
beforeSubtreeDocumentIds: [],
|
|
147
|
+
},
|
|
148
|
+
promoted,
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
const append = vi.fn(async (input) => {
|
|
152
|
+
calls.push('audit');
|
|
153
|
+
if (options.failAudit || options.failAuditAt === append.mock.calls.length) {
|
|
154
|
+
throw new Error('audit failed');
|
|
155
|
+
}
|
|
156
|
+
auditRows.push(input);
|
|
157
|
+
return { id: `audit-${auditRows.length}` };
|
|
158
|
+
});
|
|
159
|
+
const getTreeParent = vi.fn(async ({ document_id }) => {
|
|
160
|
+
const placement = placements.get(document_id);
|
|
161
|
+
return placement
|
|
162
|
+
? { placed: true, parentDocumentId: placement.parentDocumentId }
|
|
163
|
+
: { placed: false, parentDocumentId: null };
|
|
164
|
+
});
|
|
165
|
+
const getTreeChildren = vi.fn(async ({ parentDocumentId }) => [...placements.entries()]
|
|
166
|
+
.filter(([, placement]) => placement.parentDocumentId === parentDocumentId)
|
|
167
|
+
.map(([document_id, placement]) => ({ document_id, order_key: placement.orderKey })));
|
|
168
|
+
const getTreeSubtree = vi.fn(async ({ rootDocumentId }) => {
|
|
169
|
+
const ids = rootDocumentId == null
|
|
170
|
+
? [...placements.entries()]
|
|
171
|
+
.filter(([, placement]) => placement.parentDocumentId == null)
|
|
172
|
+
.flatMap(([id]) => subtree(id))
|
|
173
|
+
: subtree(rootDocumentId);
|
|
174
|
+
return ids.map((document_id) => ({ document_id }));
|
|
175
|
+
});
|
|
176
|
+
const withTransaction = vi.fn(async (fn) => {
|
|
177
|
+
calls.push('tx:start');
|
|
178
|
+
const placementSnapshot = new Map(placements);
|
|
179
|
+
const auditSnapshot = [...auditRows];
|
|
180
|
+
const deletedSnapshot = deleted;
|
|
181
|
+
try {
|
|
182
|
+
const result = await fn();
|
|
183
|
+
calls.push('tx:commit');
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
placements = placementSnapshot;
|
|
188
|
+
auditRows = auditSnapshot;
|
|
189
|
+
deleted = deletedSnapshot;
|
|
190
|
+
calls.push('tx:rollback');
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
const definition = {
|
|
195
|
+
path: 'pages',
|
|
196
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
197
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
198
|
+
tree: true,
|
|
199
|
+
hooks: options.afterTreeChange || options.afterDelete
|
|
200
|
+
? { afterTreeChange: options.afterTreeChange, afterDelete: options.afterDelete }
|
|
201
|
+
: undefined,
|
|
202
|
+
};
|
|
203
|
+
const logger = {
|
|
204
|
+
log: vi.fn(),
|
|
205
|
+
fatal: vi.fn(),
|
|
206
|
+
error: vi.fn(),
|
|
207
|
+
warn: vi.fn(),
|
|
208
|
+
info: vi.fn(),
|
|
209
|
+
debug: vi.fn(),
|
|
210
|
+
trace: vi.fn(),
|
|
211
|
+
silent: vi.fn(),
|
|
212
|
+
};
|
|
213
|
+
const db = {
|
|
214
|
+
commands: {
|
|
215
|
+
documents: {
|
|
216
|
+
placeTreeNode: place,
|
|
217
|
+
removeFromTree: remove,
|
|
218
|
+
promoteChildrenAndRemoveFromTree: promote,
|
|
219
|
+
softDeleteDocument: vi.fn(async () => {
|
|
220
|
+
deleted = true;
|
|
221
|
+
return 1;
|
|
222
|
+
}),
|
|
223
|
+
},
|
|
224
|
+
audit: { append },
|
|
225
|
+
},
|
|
226
|
+
queries: {
|
|
227
|
+
documents: {
|
|
228
|
+
getTreeParent,
|
|
229
|
+
getTreeChildren,
|
|
230
|
+
getTreeSubtree,
|
|
231
|
+
getDocumentById: vi.fn(async () => ({ path: '/node', fields: {} })),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
withTransaction,
|
|
235
|
+
};
|
|
236
|
+
const ctx = {
|
|
237
|
+
db,
|
|
238
|
+
definition,
|
|
239
|
+
collectionId: 'collection-1',
|
|
240
|
+
collectionVersion: 1,
|
|
241
|
+
collectionPath: definition.path,
|
|
242
|
+
logger,
|
|
243
|
+
defaultLocale: 'en',
|
|
244
|
+
requestContext: createSuperAdminContext({ id: ACTOR_ID }),
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
ctx,
|
|
248
|
+
calls,
|
|
249
|
+
place,
|
|
250
|
+
remove,
|
|
251
|
+
promote,
|
|
252
|
+
append,
|
|
253
|
+
getTreeChildren,
|
|
254
|
+
getTreeSubtree,
|
|
255
|
+
withTransaction,
|
|
256
|
+
auditRows: () => auditRows,
|
|
257
|
+
writes: () => writes,
|
|
258
|
+
deleted: () => deleted,
|
|
259
|
+
placement: (documentId) => placements.get(documentId),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
describe('document-tree lifecycle audit contract', () => {
|
|
263
|
+
it('validates the full tree audit capability only for tree-enabled collections', () => {
|
|
264
|
+
const harness = createHarness();
|
|
265
|
+
const unsupported = {
|
|
266
|
+
...harness.ctx.db,
|
|
267
|
+
commands: {
|
|
268
|
+
...harness.ctx.db.commands,
|
|
269
|
+
documents: {
|
|
270
|
+
...harness.ctx.db.commands.documents,
|
|
271
|
+
promoteChildrenAndRemoveFromTree: undefined,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
expect(() => validateTreeAuditCapability([{ ...harness.ctx.definition, tree: false }], unsupported)).not.toThrow();
|
|
276
|
+
expect(() => validateTreeAuditCapability([harness.ctx.definition], unsupported)).toThrow(/tree-enabled writes require/);
|
|
277
|
+
});
|
|
278
|
+
it('records place, reparent, and reorder actions with structural details', async () => {
|
|
279
|
+
const harness = createHarness({
|
|
280
|
+
initial: { left: { parentDocumentId: 'parent', orderKey: 'left-key' } },
|
|
281
|
+
});
|
|
282
|
+
await placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: null });
|
|
283
|
+
await placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: 'parent' });
|
|
284
|
+
await placeTreeNode(harness.ctx, {
|
|
285
|
+
documentId: 'node',
|
|
286
|
+
parentDocumentId: 'parent',
|
|
287
|
+
afterDocumentId: 'left',
|
|
288
|
+
});
|
|
289
|
+
expect(harness.auditRows().map((row) => row.action)).toEqual([
|
|
290
|
+
'document.tree.placed',
|
|
291
|
+
'document.tree.reparented',
|
|
292
|
+
'document.tree.reordered',
|
|
293
|
+
]);
|
|
294
|
+
expect(harness.auditRows()[0]).toMatchObject({
|
|
295
|
+
documentId: 'node',
|
|
296
|
+
collectionId: 'collection-1',
|
|
297
|
+
actorId: ACTOR_ID,
|
|
298
|
+
actorRealm: 'admin',
|
|
299
|
+
field: 'tree',
|
|
300
|
+
before: { placed: false, parentDocumentId: null, orderKey: null, index: null },
|
|
301
|
+
after: { placed: true, parentDocumentId: null, orderKey: 'key-1' },
|
|
302
|
+
});
|
|
303
|
+
expect(harness.auditRows()[2]?.after).toMatchObject({
|
|
304
|
+
parentDocumentId: 'parent',
|
|
305
|
+
orderKey: 'key-3',
|
|
306
|
+
beforeDocumentId: null,
|
|
307
|
+
afterDocumentId: 'left',
|
|
308
|
+
});
|
|
309
|
+
expect(harness.withTransaction).toHaveBeenCalledTimes(3);
|
|
310
|
+
expect(harness.getTreeSubtree).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
it('records removal and emits no audit or storage write for an unplaced no-op', async () => {
|
|
313
|
+
const harness = createHarness({
|
|
314
|
+
initial: { node: { parentDocumentId: 'parent', orderKey: 'old-key' } },
|
|
315
|
+
});
|
|
316
|
+
await removeFromTree(harness.ctx, { documentId: 'node' });
|
|
317
|
+
await removeFromTree(harness.ctx, { documentId: 'node' });
|
|
318
|
+
expect(harness.remove).toHaveBeenCalledTimes(2);
|
|
319
|
+
expect(harness.writes()).toBe(1);
|
|
320
|
+
expect(harness.auditRows()).toHaveLength(1);
|
|
321
|
+
expect(harness.auditRows()[0]).toMatchObject({
|
|
322
|
+
action: 'document.tree.removed',
|
|
323
|
+
field: 'tree',
|
|
324
|
+
before: {
|
|
325
|
+
placed: true,
|
|
326
|
+
parentDocumentId: 'parent',
|
|
327
|
+
orderKey: 'old-key',
|
|
328
|
+
index: 0,
|
|
329
|
+
mode: 'remove',
|
|
330
|
+
},
|
|
331
|
+
after: { placed: false, mode: 'remove' },
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
it('emits no write, audit, or hook for a same-position placement request', async () => {
|
|
335
|
+
const hook = vi.fn();
|
|
336
|
+
const harness = createHarness({
|
|
337
|
+
initial: {
|
|
338
|
+
sibling: { parentDocumentId: null, orderKey: 'a-key' },
|
|
339
|
+
node: { parentDocumentId: null, orderKey: 'b-key' },
|
|
340
|
+
},
|
|
341
|
+
afterTreeChange: hook,
|
|
342
|
+
});
|
|
343
|
+
await expect(placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: null })).resolves.toEqual({ orderKey: 'b-key' });
|
|
344
|
+
expect(harness.writes()).toBe(0);
|
|
345
|
+
expect(harness.auditRows()).toEqual([]);
|
|
346
|
+
expect(hook).not.toHaveBeenCalled();
|
|
347
|
+
expect(harness.getTreeSubtree).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
it('re-fires a coarse placement event on an explicit no-op reconciliation retry', async () => {
|
|
350
|
+
const hook = vi
|
|
351
|
+
.fn()
|
|
352
|
+
.mockRejectedValueOnce(new Error('hook failed'))
|
|
353
|
+
.mockResolvedValue(undefined);
|
|
354
|
+
const harness = createHarness({ afterTreeChange: hook });
|
|
355
|
+
await expect(placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: null })).rejects.toThrow('hook failed');
|
|
356
|
+
await expect(placeTreeNode(harness.ctx, {
|
|
357
|
+
documentId: 'node',
|
|
358
|
+
parentDocumentId: null,
|
|
359
|
+
reconcile: true,
|
|
360
|
+
})).resolves.toEqual({ orderKey: 'key-1' });
|
|
361
|
+
expect(harness.writes()).toBe(1);
|
|
362
|
+
expect(harness.auditRows()).toHaveLength(1);
|
|
363
|
+
expect(hook).toHaveBeenCalledTimes(2);
|
|
364
|
+
expect(hook.mock.calls[1]?.[0]).toMatchObject({
|
|
365
|
+
change: 'place',
|
|
366
|
+
documentId: 'node',
|
|
367
|
+
affectedDocumentIds: ['node'],
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
it('uses locked pre-removal descendants and reconciles a failed remove event', async () => {
|
|
371
|
+
const events = [];
|
|
372
|
+
let fail = true;
|
|
373
|
+
const hook = vi.fn(async (event) => {
|
|
374
|
+
events.push(event);
|
|
375
|
+
if (fail) {
|
|
376
|
+
fail = false;
|
|
377
|
+
throw new Error('hook failed');
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
const harness = createHarness({
|
|
381
|
+
initial: {
|
|
382
|
+
parent: { parentDocumentId: null, orderKey: 'parent-key' },
|
|
383
|
+
node: { parentDocumentId: 'parent', orderKey: 'node-key' },
|
|
384
|
+
child: { parentDocumentId: 'node', orderKey: 'child-key' },
|
|
385
|
+
grandchild: { parentDocumentId: 'child', orderKey: 'grandchild-key' },
|
|
386
|
+
},
|
|
387
|
+
afterTreeChange: hook,
|
|
388
|
+
});
|
|
389
|
+
await expect(removeFromTree(harness.ctx, { documentId: 'node' })).rejects.toThrow('hook failed');
|
|
390
|
+
expect(events[0]?.affectedDocumentIds).toEqual(expect.arrayContaining(['node', 'child', 'grandchild', 'parent']));
|
|
391
|
+
await removeFromTree(harness.ctx, { documentId: 'node', reconcile: true });
|
|
392
|
+
expect(harness.writes()).toBe(1);
|
|
393
|
+
expect(harness.auditRows()).toHaveLength(1);
|
|
394
|
+
expect(events).toHaveLength(2);
|
|
395
|
+
expect(events[1]?.affectedDocumentIds).toEqual(expect.arrayContaining(['parent', 'node', 'child', 'grandchild']));
|
|
396
|
+
});
|
|
397
|
+
it('records child promotion and parent removal as one atomic structural event', async () => {
|
|
398
|
+
const harness = createHarness({
|
|
399
|
+
initial: {
|
|
400
|
+
deleted: { parentDocumentId: 'grandparent', orderKey: 'deleted-key' },
|
|
401
|
+
childA: { parentDocumentId: 'deleted', orderKey: 'a-key' },
|
|
402
|
+
childB: { parentDocumentId: 'deleted', orderKey: 'b-key' },
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
await promoteChildrenAndRemove(harness.ctx, { documentId: 'deleted' });
|
|
406
|
+
expect(harness.placement('deleted')).toBeUndefined();
|
|
407
|
+
expect(harness.placement('childA')?.parentDocumentId).toBeNull();
|
|
408
|
+
expect(harness.placement('childB')?.parentDocumentId).toBeNull();
|
|
409
|
+
expect(harness.auditRows()).toHaveLength(3);
|
|
410
|
+
expect(harness.auditRows()[2]).toMatchObject({
|
|
411
|
+
documentId: 'deleted',
|
|
412
|
+
action: 'document.tree.removed',
|
|
413
|
+
before: { parentDocumentId: 'grandparent', mode: 'promoteChildren', children: 2 },
|
|
414
|
+
after: {
|
|
415
|
+
placed: false,
|
|
416
|
+
mode: 'promoteChildren',
|
|
417
|
+
promotedDocumentIds: ['childA', 'childB'],
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
expect(harness.auditRows().slice(0, 2)).toEqual([
|
|
421
|
+
expect.objectContaining({
|
|
422
|
+
documentId: 'childA',
|
|
423
|
+
action: 'document.tree.reparented',
|
|
424
|
+
before: expect.objectContaining({ parentDocumentId: 'deleted', mode: 'promoteOnDelete' }),
|
|
425
|
+
after: expect.objectContaining({ parentDocumentId: null, mode: 'promoteOnDelete' }),
|
|
426
|
+
}),
|
|
427
|
+
expect.objectContaining({
|
|
428
|
+
documentId: 'childB',
|
|
429
|
+
action: 'document.tree.reparented',
|
|
430
|
+
before: expect.objectContaining({ parentDocumentId: 'deleted', mode: 'promoteOnDelete' }),
|
|
431
|
+
after: expect.objectContaining({ parentDocumentId: null, mode: 'promoteOnDelete' }),
|
|
432
|
+
}),
|
|
433
|
+
]);
|
|
434
|
+
expect(harness.calls).toEqual([
|
|
435
|
+
'tx:start',
|
|
436
|
+
'promote:deleted',
|
|
437
|
+
'audit',
|
|
438
|
+
'audit',
|
|
439
|
+
'audit',
|
|
440
|
+
'tx:commit',
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
it('soft-deletes, promotes children, and appends all audit rows in one transaction', async () => {
|
|
444
|
+
const harness = createHarness({
|
|
445
|
+
initial: {
|
|
446
|
+
deleted: { parentDocumentId: null, orderKey: 'deleted-key' },
|
|
447
|
+
child: { parentDocumentId: 'deleted', orderKey: 'child-key' },
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
await expect(deleteDocument(harness.ctx, { documentId: 'deleted' })).resolves.toEqual({
|
|
451
|
+
deletedVersionCount: 1,
|
|
452
|
+
outcome: 'committed',
|
|
453
|
+
sideEffectFailures: [],
|
|
454
|
+
});
|
|
455
|
+
expect(harness.deleted()).toBe(true);
|
|
456
|
+
expect(harness.placement('deleted')).toBeUndefined();
|
|
457
|
+
expect(harness.placement('child')?.parentDocumentId).toBeNull();
|
|
458
|
+
expect(harness.auditRows().map((row) => [row.documentId, row.action])).toEqual([
|
|
459
|
+
['deleted', 'document.deleted'],
|
|
460
|
+
['child', 'document.tree.reparented'],
|
|
461
|
+
['deleted', 'document.tree.removed'],
|
|
462
|
+
]);
|
|
463
|
+
expect(harness.withTransaction).toHaveBeenCalledOnce();
|
|
464
|
+
});
|
|
465
|
+
it('rolls back soft-delete, promotion, and every audit row on a late audit failure', async () => {
|
|
466
|
+
const harness = createHarness({
|
|
467
|
+
initial: {
|
|
468
|
+
deleted: { parentDocumentId: null, orderKey: 'deleted-key' },
|
|
469
|
+
child: { parentDocumentId: 'deleted', orderKey: 'child-key' },
|
|
470
|
+
},
|
|
471
|
+
failAuditAt: 3,
|
|
472
|
+
});
|
|
473
|
+
await expect(deleteDocument(harness.ctx, { documentId: 'deleted' })).rejects.toThrow('audit failed');
|
|
474
|
+
expect(harness.deleted()).toBe(false);
|
|
475
|
+
expect(harness.placement('deleted')).toEqual({
|
|
476
|
+
parentDocumentId: null,
|
|
477
|
+
orderKey: 'deleted-key',
|
|
478
|
+
});
|
|
479
|
+
expect(harness.placement('child')).toEqual({
|
|
480
|
+
parentDocumentId: 'deleted',
|
|
481
|
+
orderKey: 'child-key',
|
|
482
|
+
});
|
|
483
|
+
expect(harness.auditRows()).toEqual([]);
|
|
484
|
+
});
|
|
485
|
+
it('keeps delete, promotion, and audits committed when afterTreeChange fails', async () => {
|
|
486
|
+
const afterDelete = vi.fn();
|
|
487
|
+
const harness = createHarness({
|
|
488
|
+
initial: {
|
|
489
|
+
deleted: { parentDocumentId: null, orderKey: 'deleted-key' },
|
|
490
|
+
child: { parentDocumentId: 'deleted', orderKey: 'child-key' },
|
|
491
|
+
},
|
|
492
|
+
afterTreeChange: async () => {
|
|
493
|
+
throw new Error('hook failed');
|
|
494
|
+
},
|
|
495
|
+
afterDelete,
|
|
496
|
+
});
|
|
497
|
+
await expect(deleteDocument(harness.ctx, { documentId: 'deleted' })).resolves.toEqual({
|
|
498
|
+
deletedVersionCount: 1,
|
|
499
|
+
outcome: 'committed-with-side-effect-failures',
|
|
500
|
+
sideEffectFailures: [{ phase: 'afterTreeChange', code: 'ERR_UNHANDLED' }],
|
|
501
|
+
});
|
|
502
|
+
expect(harness.deleted()).toBe(true);
|
|
503
|
+
expect(harness.placement('deleted')).toBeUndefined();
|
|
504
|
+
expect(harness.placement('child')?.parentDocumentId).toBeNull();
|
|
505
|
+
expect(harness.auditRows()).toHaveLength(3);
|
|
506
|
+
expect(harness.calls).toContain('tx:commit');
|
|
507
|
+
expect(afterDelete).toHaveBeenCalledOnce();
|
|
508
|
+
expect(harness.ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'deleted' }), 'post-commit delete side effects failed');
|
|
509
|
+
});
|
|
510
|
+
it('attempts and serializes both failing delete hook families after commit', async () => {
|
|
511
|
+
const treeError = Object.assign(new Error('tree hook failed'), { code: 'ERR_TREE_HOOK' });
|
|
512
|
+
const deleteError = new Error('delete hook failed');
|
|
513
|
+
const afterTreeChange = vi.fn(async () => {
|
|
514
|
+
throw treeError;
|
|
515
|
+
});
|
|
516
|
+
const afterDelete = vi.fn(async () => {
|
|
517
|
+
throw deleteError;
|
|
518
|
+
});
|
|
519
|
+
const harness = createHarness({
|
|
520
|
+
initial: { deleted: { parentDocumentId: null, orderKey: 'deleted-key' } },
|
|
521
|
+
afterTreeChange,
|
|
522
|
+
afterDelete,
|
|
523
|
+
});
|
|
524
|
+
const result = await deleteDocument(harness.ctx, { documentId: 'deleted' });
|
|
525
|
+
expect(result).toEqual({
|
|
526
|
+
deletedVersionCount: 1,
|
|
527
|
+
outcome: 'committed-with-side-effect-failures',
|
|
528
|
+
sideEffectFailures: [
|
|
529
|
+
{ phase: 'afterTreeChange', code: 'ERR_UNHANDLED' },
|
|
530
|
+
{ phase: 'afterDelete', code: 'ERR_UNHANDLED' },
|
|
531
|
+
],
|
|
532
|
+
});
|
|
533
|
+
expect(JSON.parse(JSON.stringify(result))).toEqual(result);
|
|
534
|
+
expect(JSON.stringify(result)).not.toContain('tree hook failed');
|
|
535
|
+
expect(JSON.stringify(result)).not.toContain('delete hook failed');
|
|
536
|
+
expect(JSON.stringify(result)).not.toContain('ERR_TREE_HOOK');
|
|
537
|
+
expect(afterTreeChange).toHaveBeenCalledOnce();
|
|
538
|
+
expect(afterDelete).toHaveBeenCalledOnce();
|
|
539
|
+
expect(harness.ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({ err: treeError, documentId: 'deleted' }), 'afterTreeChange hook failed after document delete');
|
|
540
|
+
expect(harness.ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({ err: deleteError, documentId: 'deleted' }), 'afterDelete hook failed after document delete');
|
|
541
|
+
expect(harness.deleted()).toBe(true);
|
|
542
|
+
});
|
|
543
|
+
it('returns a committed fallback when a hostile error and logger both throw', async () => {
|
|
544
|
+
const hostileError = new Proxy({}, {
|
|
545
|
+
get() {
|
|
546
|
+
throw new Error('hostile getter');
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
const harness = createHarness({
|
|
550
|
+
initial: { deleted: { parentDocumentId: null, orderKey: 'deleted-key' } },
|
|
551
|
+
afterDelete: async () => {
|
|
552
|
+
throw hostileError;
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
const loggerError = vi.fn(() => {
|
|
556
|
+
throw new Error('logger failed');
|
|
557
|
+
});
|
|
558
|
+
harness.ctx.logger.error = loggerError;
|
|
559
|
+
await expect(deleteDocument(harness.ctx, { documentId: 'deleted' })).resolves.toEqual({
|
|
560
|
+
deletedVersionCount: 1,
|
|
561
|
+
outcome: 'committed-with-side-effect-failures',
|
|
562
|
+
sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
|
|
563
|
+
});
|
|
564
|
+
expect(loggerError).toHaveBeenCalledTimes(2);
|
|
565
|
+
expect(harness.deleted()).toBe(true);
|
|
566
|
+
});
|
|
567
|
+
it('rolls back tree state when audit append fails and does not fire the hook', async () => {
|
|
568
|
+
const hook = vi.fn();
|
|
569
|
+
const harness = createHarness({
|
|
570
|
+
initial: { node: { parentDocumentId: null, orderKey: 'old-key' } },
|
|
571
|
+
failAudit: true,
|
|
572
|
+
afterTreeChange: hook,
|
|
573
|
+
});
|
|
574
|
+
await expect(placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: 'new-parent' })).rejects.toThrow('audit failed');
|
|
575
|
+
expect(harness.placement('node')).toEqual({ parentDocumentId: null, orderKey: 'old-key' });
|
|
576
|
+
expect(harness.auditRows()).toEqual([]);
|
|
577
|
+
expect(hook).not.toHaveBeenCalled();
|
|
578
|
+
expect(harness.calls).toContain('tx:rollback');
|
|
579
|
+
});
|
|
580
|
+
it('rolls back remove and child promotion when audit append fails', async () => {
|
|
581
|
+
const removeHarness = createHarness({
|
|
582
|
+
initial: { node: { parentDocumentId: 'parent', orderKey: 'old-key' } },
|
|
583
|
+
failAudit: true,
|
|
584
|
+
});
|
|
585
|
+
await expect(removeFromTree(removeHarness.ctx, { documentId: 'node' })).rejects.toThrow('audit failed');
|
|
586
|
+
expect(removeHarness.placement('node')).toEqual({
|
|
587
|
+
parentDocumentId: 'parent',
|
|
588
|
+
orderKey: 'old-key',
|
|
589
|
+
});
|
|
590
|
+
const promoteHarness = createHarness({
|
|
591
|
+
initial: {
|
|
592
|
+
deleted: { parentDocumentId: null, orderKey: 'deleted-key' },
|
|
593
|
+
child: { parentDocumentId: 'deleted', orderKey: 'child-key' },
|
|
594
|
+
},
|
|
595
|
+
failAudit: true,
|
|
596
|
+
});
|
|
597
|
+
await expect(promoteChildrenAndRemove(promoteHarness.ctx, { documentId: 'deleted' })).rejects.toThrow('audit failed');
|
|
598
|
+
expect(promoteHarness.placement('deleted')).toEqual({
|
|
599
|
+
parentDocumentId: null,
|
|
600
|
+
orderKey: 'deleted-key',
|
|
601
|
+
});
|
|
602
|
+
expect(promoteHarness.placement('child')).toEqual({
|
|
603
|
+
parentDocumentId: 'deleted',
|
|
604
|
+
orderKey: 'child-key',
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
it('does not append audit on a failed storage mutation', async () => {
|
|
608
|
+
const harness = createHarness({ failPlace: true });
|
|
609
|
+
await expect(placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: null })).rejects.toThrow('tree write failed');
|
|
610
|
+
expect(harness.append).not.toHaveBeenCalled();
|
|
611
|
+
expect(harness.auditRows()).toEqual([]);
|
|
612
|
+
});
|
|
613
|
+
it('commits tree and audit data before a failing afterTreeChange hook', async () => {
|
|
614
|
+
const hook = vi.fn(async () => {
|
|
615
|
+
throw new Error('hook failed');
|
|
616
|
+
});
|
|
617
|
+
const harness = createHarness({ afterTreeChange: hook });
|
|
618
|
+
await expect(placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: null })).rejects.toMatchObject({
|
|
619
|
+
name: 'BylineError',
|
|
620
|
+
code: 'ERR_TREE_HOOK_COMMITTED',
|
|
621
|
+
message: expect.stringMatching(new RegExp(`^${TREE_HOOK_COMMITTED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*hook failed`)),
|
|
622
|
+
});
|
|
623
|
+
expect(harness.placement('node')).toEqual({ parentDocumentId: null, orderKey: 'key-1' });
|
|
624
|
+
expect(harness.auditRows()).toHaveLength(1);
|
|
625
|
+
expect(harness.calls).toContain('tx:commit');
|
|
626
|
+
});
|
|
627
|
+
it('rejects tree create before persistence when audit capability is unsupported', async () => {
|
|
628
|
+
const harness = createHarness();
|
|
629
|
+
const createDocumentVersion = vi.fn(async () => ({
|
|
630
|
+
document: { id: 'version-1', document_id: 'created-node' },
|
|
631
|
+
fieldCount: 1,
|
|
632
|
+
}));
|
|
633
|
+
harness.ctx.db = {
|
|
634
|
+
...harness.ctx.db,
|
|
635
|
+
commands: {
|
|
636
|
+
...harness.ctx.db.commands,
|
|
637
|
+
documents: {
|
|
638
|
+
...harness.ctx.db.commands.documents,
|
|
639
|
+
createDocumentVersion,
|
|
640
|
+
},
|
|
641
|
+
audit: undefined,
|
|
642
|
+
},
|
|
643
|
+
withTransaction: undefined,
|
|
644
|
+
};
|
|
645
|
+
await expect(createDocument(harness.ctx, { data: { title: 'Created' }, locale: 'en' })).rejects.toMatchObject({ code: 'ERR_AUDIT_UNSUPPORTED' });
|
|
646
|
+
expect(createDocumentVersion).not.toHaveBeenCalled();
|
|
647
|
+
expect(harness.place).not.toHaveBeenCalled();
|
|
648
|
+
expect(harness.ctx.logger.error).not.toHaveBeenCalled();
|
|
649
|
+
});
|
|
650
|
+
it('keeps create-time placement best-effort and unplaced when audit append fails', async () => {
|
|
651
|
+
const harness = createHarness({ failAudit: true });
|
|
652
|
+
const createDocumentVersion = vi.fn(async () => ({
|
|
653
|
+
document: { id: 'version-1', document_id: 'created-node' },
|
|
654
|
+
fieldCount: 1,
|
|
655
|
+
}));
|
|
656
|
+
harness.ctx.db.commands.documents.createDocumentVersion = createDocumentVersion;
|
|
657
|
+
await expect(createDocument(harness.ctx, { data: { title: 'Created' }, locale: 'en' })).resolves.toEqual({ documentId: 'created-node', documentVersionId: 'version-1' });
|
|
658
|
+
expect(harness.place).toHaveBeenCalledOnce();
|
|
659
|
+
expect(harness.placement('created-node')).toBeUndefined();
|
|
660
|
+
expect(harness.auditRows()).toEqual([]);
|
|
661
|
+
expect(harness.ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'created-node' }), 'failed to auto-place new document in tree');
|
|
662
|
+
});
|
|
663
|
+
});
|
|
@@ -14,7 +14,8 @@ 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
16
|
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
17
|
-
import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict,
|
|
17
|
+
import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
|
|
18
|
+
import { selfHealTreePlacement } from './tree.js';
|
|
18
19
|
/**
|
|
19
20
|
* Update a document via full replacement (PUT semantics).
|
|
20
21
|
*
|