@byline/core 4.18.0 → 5.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/db-types.d.ts +38 -4
- package/dist/@types/document-revision.d.ts +92 -0
- package/dist/@types/document-revision.js +8 -0
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/config/attach-hooks.test.node.js +2 -1
- package/dist/config/config-hooks.test.node.js +2 -1
- package/dist/core.js +8 -0
- package/dist/core.test.node.js +27 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lib/errors.d.ts +25 -0
- package/dist/lib/errors.js +42 -0
- package/dist/lib/lock-conflict.test.node.d.ts +1 -0
- package/dist/lib/lock-conflict.test.node.js +31 -0
- package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
- package/dist/scheduler/run-due-tasks.test.node.js +3 -2
- package/dist/scheduler/scheduled-publication.d.ts +16 -1
- package/dist/scheduler/scheduled-publication.js +27 -1
- package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
- package/dist/scheduler/scheduled-publication.test.node.js +18 -3
- package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
- package/dist/scheduler/scheduler-boot.test.node.js +40 -12
- package/dist/scheduler/ticker.test.node.d.ts +0 -7
- package/dist/scheduler/ticker.test.node.js +3 -2
- package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
- package/dist/services/collection-bootstrap.test.node.js +12 -3
- package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
- package/dist/services/discover-counter-groups.test.node.js +19 -3
- package/dist/services/document-lifecycle/audit.d.ts +1 -0
- package/dist/services/document-lifecycle/audit.js +1 -0
- package/dist/services/document-lifecycle/committed-hook.d.ts +25 -0
- package/dist/services/document-lifecycle/committed-hook.js +96 -0
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +30 -44
- package/dist/services/document-lifecycle/create.d.ts +1 -0
- package/dist/services/document-lifecycle/create.js +6 -21
- package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
- package/dist/services/document-lifecycle/delete-locale.js +42 -39
- package/dist/services/document-lifecycle/delete.d.ts +8 -2
- package/dist/services/document-lifecycle/delete.js +24 -15
- package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
- package/dist/services/document-lifecycle/duplicate.js +55 -43
- package/dist/services/document-lifecycle/index.d.ts +5 -1
- package/dist/services/document-lifecycle/index.js +4 -1
- package/dist/services/document-lifecycle/persistence.js +18 -7
- package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
- package/dist/services/document-lifecycle/reorder.d.ts +11 -0
- package/dist/services/document-lifecycle/reorder.js +83 -0
- package/dist/services/document-lifecycle/restore.d.ts +2 -0
- package/dist/services/document-lifecycle/restore.js +21 -26
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
- package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
- package/dist/services/document-lifecycle/revision-guard.js +154 -0
- package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
- package/dist/services/document-lifecycle/same-document-data.js +17 -0
- package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
- package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
- package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
- package/dist/services/document-lifecycle/stale-document.js +95 -0
- package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
- package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
- package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
- package/dist/services/document-lifecycle/status.d.ts +5 -21
- package/dist/services/document-lifecycle/status.js +90 -133
- package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
- package/dist/services/document-lifecycle/system-fields.js +118 -116
- package/dist/services/document-lifecycle/tree.d.ts +9 -6
- package/dist/services/document-lifecycle/tree.js +65 -39
- package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/tree.test.node.js +152 -35
- package/dist/services/document-lifecycle/update.d.ts +14 -6
- package/dist/services/document-lifecycle/update.js +104 -82
- package/dist/services/document-lifecycle.test.node.js +556 -70
- package/dist/services/field-upload.d.ts +1 -0
- package/dist/services/field-upload.js +5 -1
- package/dist/services/field-upload.test.node.d.ts +0 -7
- package/dist/services/field-upload.test.node.js +38 -2
- package/dist/services/populate.test.node.d.ts +0 -7
- package/dist/services/populate.test.node.js +19 -3
- package/dist/services/richtext-populate.test.node.d.ts +0 -7
- package/dist/services/richtext-populate.test.node.js +3 -2
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
- package/dist/services/singleton-lifecycle/index.d.ts +1 -1
- package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
- package/dist/services/singleton-lifecycle/internals.js +75 -73
- package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
- package/dist/services/singleton-lifecycle/restore.js +3 -1
- package/dist/services/singleton-lifecycle/update.d.ts +6 -5
- package/dist/services/singleton-lifecycle/update.js +158 -91
- package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
- package/dist/services/singleton-lifecycle.test.node.js +75 -23
- package/dist/storage/db-adapter.test-helper.d.ts +26 -0
- package/dist/storage/db-adapter.test-helper.js +117 -0
- package/dist/storage/document-revision-capability.d.ts +3 -0
- package/dist/storage/document-revision-capability.js +15 -0
- package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
- package/dist/storage/document-revision-capability.test.node.js +53 -0
- package/dist/storage/document-revision.d.ts +12 -0
- package/dist/storage/document-revision.js +37 -0
- package/dist/storage/document-revision.test.node.d.ts +1 -0
- package/dist/storage/document-revision.test.node.js +48 -0
- package/dist/storage/document-version-parent.d.ts +20 -0
- package/dist/storage/document-version-parent.js +34 -0
- package/dist/storage/document-version-parent.test.node.d.ts +8 -0
- package/dist/storage/document-version-parent.test.node.js +60 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/read-snapshot.d.ts +3 -0
- package/dist/storage/read-snapshot.js +55 -0
- package/dist/storage/revision-store.test-helper.d.ts +3 -0
- package/dist/storage/revision-store.test-helper.js +16 -0
- package/package.json +2 -2
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
8
9
|
/** Atomic, audited lifecycle orchestration for document-tree mutations. */
|
|
9
10
|
import { resolveHooks } from '../../@types/index.js';
|
|
10
11
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
@@ -60,7 +61,7 @@ async function runCommittedTreeHook(event, callback) {
|
|
|
60
61
|
throw ERR_TREE_HOOK_COMMITTED({
|
|
61
62
|
message: `${TREE_HOOK_COMMITTED_MARKER} tree mutation committed but afterTreeChange failed: ${message}`,
|
|
62
63
|
cause,
|
|
63
|
-
details: event,
|
|
64
|
+
details: { ...event },
|
|
64
65
|
});
|
|
65
66
|
}
|
|
66
67
|
}
|
|
@@ -89,23 +90,24 @@ async function appendPlacementAudit(ctx, capability, params, mutation) {
|
|
|
89
90
|
},
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
|
-
/** Locked storage inspection/mutation and audit
|
|
93
|
+
/** Locked storage inspection/mutation, derived revisions and audit in one transaction. */
|
|
93
94
|
async function auditedPlace(ctx, params) {
|
|
94
95
|
const capability = requireTreeAuditCapability(ctx.db);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
mutation = await capability.place({
|
|
98
|
-
collectionId: ctx.collectionId,
|
|
99
|
-
documentId: params.documentId,
|
|
100
|
-
parentDocumentId: params.parentDocumentId,
|
|
101
|
-
beforeDocumentId: params.beforeDocumentId ?? null,
|
|
102
|
-
afterDocumentId: params.afterDocumentId ?? null,
|
|
103
|
-
ifUnplaced: params.ifUnplaced,
|
|
104
|
-
});
|
|
96
|
+
const committed = await commitGuardedDocumentMutation(ctx, params, async () => {
|
|
97
|
+
const mutation = await capability.place({ collectionId: ctx.collectionId, ...params });
|
|
105
98
|
if (mutation.changed)
|
|
106
99
|
await appendPlacementAudit(ctx, capability, params, mutation);
|
|
107
|
-
|
|
108
|
-
|
|
100
|
+
return { value: mutation, changed: mutation.changed };
|
|
101
|
+
}, { collectionLock: 'exclusive' });
|
|
102
|
+
return {
|
|
103
|
+
mutation: committed.value,
|
|
104
|
+
receipt: {
|
|
105
|
+
documentId: params.documentId,
|
|
106
|
+
revision: committed.revision,
|
|
107
|
+
affectedDocuments: committed.affectedDocuments,
|
|
108
|
+
scheduledPublicationsNeedReconfirmation: committed.scheduledPublicationsNeedReconfirmation,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
109
111
|
}
|
|
110
112
|
/** Place, re-parent, or reorder a node; explicit no-op retries may reconcile hooks. */
|
|
111
113
|
export async function placeTreeNode(ctx, params) {
|
|
@@ -113,13 +115,14 @@ export async function placeTreeNode(ctx, params) {
|
|
|
113
115
|
assertActorCanPerform(ctx.requestContext, ctx.definition, 'update');
|
|
114
116
|
const hooks = await resolveHooks(ctx.definition);
|
|
115
117
|
const wantsEvent = hooks?.afterTreeChange != null;
|
|
116
|
-
|
|
118
|
+
await readDocumentForMutation(ctx, params);
|
|
119
|
+
const { mutation, receipt } = await auditedPlace(ctx, params);
|
|
117
120
|
const orderKey = mutation.after.orderKey;
|
|
118
121
|
if (orderKey == null)
|
|
119
122
|
throw new Error('placed tree mutation did not return an order key');
|
|
120
123
|
if (!mutation.changed) {
|
|
121
124
|
if (wantsEvent && params.reconcile === true) {
|
|
122
|
-
await runCommittedTreeHook({ change: 'place', documentId: params.documentId }, async () => {
|
|
125
|
+
await runCommittedTreeHook({ ...receipt, change: 'place', documentId: params.documentId }, async () => {
|
|
123
126
|
await fireTreeChange(ctx, ctx.definition, {
|
|
124
127
|
change: 'place',
|
|
125
128
|
documentId: params.documentId,
|
|
@@ -127,10 +130,10 @@ export async function placeTreeNode(ctx, params) {
|
|
|
127
130
|
});
|
|
128
131
|
});
|
|
129
132
|
}
|
|
130
|
-
return { orderKey };
|
|
133
|
+
return { orderKey, ...receipt };
|
|
131
134
|
}
|
|
132
135
|
if (wantsEvent) {
|
|
133
|
-
await runCommittedTreeHook({ change: 'place', documentId: params.documentId }, async () => {
|
|
136
|
+
await runCommittedTreeHook({ ...receipt, change: 'place', documentId: params.documentId }, async () => {
|
|
134
137
|
const [subtree, newSiblings] = await Promise.all([
|
|
135
138
|
subtreeIds(ctx, params.documentId),
|
|
136
139
|
childIds(ctx, params.parentDocumentId),
|
|
@@ -148,41 +151,46 @@ export async function placeTreeNode(ctx, params) {
|
|
|
148
151
|
});
|
|
149
152
|
});
|
|
150
153
|
}
|
|
151
|
-
return { orderKey };
|
|
154
|
+
return { orderKey, ...receipt };
|
|
152
155
|
});
|
|
153
156
|
}
|
|
154
|
-
/**
|
|
157
|
+
/** Internal placement, only inside the initiating create/save transaction. */
|
|
155
158
|
export async function appendTreeRoot(ctx, documentId) {
|
|
156
|
-
|
|
159
|
+
if (!ctx.db.revisions.isInTransaction())
|
|
160
|
+
throw new Error('Automatic tree placement requires the initiating transaction');
|
|
161
|
+
const capability = requireTreeAuditCapability(ctx.db);
|
|
162
|
+
const mutation = await capability.place({
|
|
163
|
+
collectionId: ctx.collectionId,
|
|
164
|
+
documentId,
|
|
165
|
+
parentDocumentId: null,
|
|
166
|
+
ifUnplaced: true,
|
|
167
|
+
});
|
|
168
|
+
if (mutation.changed)
|
|
169
|
+
await appendPlacementAudit(ctx, capability, { documentId, parentDocumentId: null }, mutation);
|
|
157
170
|
}
|
|
158
|
-
/**
|
|
171
|
+
/** Placement failure aborts the initiating save, including its version and audit. */
|
|
159
172
|
export async function selfHealTreePlacement(ctx, documentId) {
|
|
160
|
-
if (ctx.definition.tree
|
|
161
|
-
return;
|
|
162
|
-
try {
|
|
173
|
+
if (ctx.definition.tree === true)
|
|
163
174
|
await appendTreeRoot(ctx, documentId);
|
|
164
|
-
}
|
|
165
|
-
catch (err) {
|
|
166
|
-
ctx.logger.error({ err, documentId }, 'failed to self-heal tree placement on update');
|
|
167
|
-
}
|
|
168
175
|
}
|
|
169
176
|
/** Remove a node to the unplaced state; an already-unplaced node is a true no-op. */
|
|
170
177
|
export async function removeFromTree(ctx, params) {
|
|
171
178
|
return withLogContext({ domain: 'services', module: 'tree', function: 'removeFromTree' }, async () => {
|
|
172
179
|
assertActorCanPerform(ctx.requestContext, ctx.definition, 'update');
|
|
180
|
+
await readDocumentForMutation(ctx, params);
|
|
173
181
|
const hooks = await resolveHooks(ctx.definition);
|
|
174
182
|
const wantsEvent = hooks?.afterTreeChange != null;
|
|
175
183
|
const capability = requireTreeAuditCapability(ctx.db);
|
|
176
184
|
const actor = auditActor(ctx);
|
|
177
185
|
let mutation;
|
|
178
|
-
await
|
|
186
|
+
const committed = await commitGuardedDocumentMutation(ctx, params, async () => {
|
|
179
187
|
mutation = await capability.remove({
|
|
180
188
|
collectionId: ctx.collectionId,
|
|
181
189
|
documentId: params.documentId,
|
|
182
190
|
includeSubtree: wantsEvent,
|
|
183
191
|
});
|
|
184
192
|
if (!mutation.changed)
|
|
185
|
-
return;
|
|
193
|
+
return { value: mutation, changed: false };
|
|
186
194
|
await capability.append({
|
|
187
195
|
documentId: params.documentId,
|
|
188
196
|
collectionId: ctx.collectionId,
|
|
@@ -193,13 +201,20 @@ export async function removeFromTree(ctx, params) {
|
|
|
193
201
|
before: { ...mutation.before, mode: 'remove' },
|
|
194
202
|
after: { ...mutation.after, mode: 'remove' },
|
|
195
203
|
});
|
|
196
|
-
|
|
204
|
+
return { value: mutation, changed: true };
|
|
205
|
+
}, { collectionLock: 'exclusive' });
|
|
206
|
+
const receipt = {
|
|
207
|
+
documentId: params.documentId,
|
|
208
|
+
revision: committed.revision,
|
|
209
|
+
affectedDocuments: committed.affectedDocuments,
|
|
210
|
+
scheduledPublicationsNeedReconfirmation: committed.scheduledPublicationsNeedReconfirmation,
|
|
211
|
+
};
|
|
197
212
|
if (!mutation || !wantsEvent)
|
|
198
|
-
return;
|
|
213
|
+
return receipt;
|
|
199
214
|
const committedMutation = mutation;
|
|
200
215
|
if (!committedMutation.changed) {
|
|
201
216
|
if (params.reconcile === true) {
|
|
202
|
-
await runCommittedTreeHook({ change: 'remove', documentId: params.documentId }, async () => {
|
|
217
|
+
await runCommittedTreeHook({ ...receipt, change: 'remove', documentId: params.documentId }, async () => {
|
|
203
218
|
await fireTreeChange(ctx, ctx.definition, {
|
|
204
219
|
change: 'remove',
|
|
205
220
|
documentId: params.documentId,
|
|
@@ -210,9 +225,9 @@ export async function removeFromTree(ctx, params) {
|
|
|
210
225
|
});
|
|
211
226
|
});
|
|
212
227
|
}
|
|
213
|
-
return;
|
|
228
|
+
return receipt;
|
|
214
229
|
}
|
|
215
|
-
await runCommittedTreeHook({ change: 'remove', documentId: params.documentId }, async () => {
|
|
230
|
+
await runCommittedTreeHook({ ...receipt, change: 'remove', documentId: params.documentId }, async () => {
|
|
216
231
|
await fireTreeChange(ctx, ctx.definition, {
|
|
217
232
|
change: 'remove',
|
|
218
233
|
documentId: params.documentId,
|
|
@@ -225,6 +240,7 @@ export async function removeFromTree(ctx, params) {
|
|
|
225
240
|
],
|
|
226
241
|
});
|
|
227
242
|
});
|
|
243
|
+
return receipt;
|
|
228
244
|
});
|
|
229
245
|
}
|
|
230
246
|
/**
|
|
@@ -295,11 +311,21 @@ export async function firePromoteTreeChange(ctx, documentId, result) {
|
|
|
295
311
|
/** Standalone audited reconciliation used by internal tooling and tests. */
|
|
296
312
|
export async function promoteChildrenAndRemove(ctx, params) {
|
|
297
313
|
return withLogContext({ domain: 'services', module: 'tree', function: 'promoteChildrenAndRemove' }, async () => {
|
|
314
|
+
assertActorCanPerform(ctx.requestContext, ctx.definition, 'update');
|
|
315
|
+
await readDocumentForMutation(ctx, params);
|
|
298
316
|
const capability = requireTreeAuditCapability(ctx.db);
|
|
299
317
|
let result;
|
|
300
|
-
await
|
|
318
|
+
const committed = await commitGuardedDocumentMutation(ctx, params, async () => {
|
|
301
319
|
result = await reconcileTreeOnDeleteInTransaction(ctx, params.documentId, capability);
|
|
302
|
-
|
|
303
|
-
|
|
320
|
+
return { value: result, changed: result.removed.changed || result.promoted.length > 0 };
|
|
321
|
+
}, { collectionLock: 'exclusive' });
|
|
322
|
+
const receipt = {
|
|
323
|
+
documentId: params.documentId,
|
|
324
|
+
revision: committed.revision,
|
|
325
|
+
affectedDocuments: committed.affectedDocuments,
|
|
326
|
+
scheduledPublicationsNeedReconfirmation: committed.scheduledPublicationsNeedReconfirmation,
|
|
327
|
+
};
|
|
328
|
+
await runCommittedTreeHook({ ...receipt, change: 'promote-on-delete' }, () => firePromoteTreeChange(ctx, params.documentId, committed.value));
|
|
329
|
+
return receipt;
|
|
304
330
|
});
|
|
305
331
|
}
|
|
@@ -1,8 +1 @@
|
|
|
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
1
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testAdapter } from '../../storage/db-adapter.test-helper.js';
|
|
1
2
|
/**
|
|
2
3
|
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
4
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
@@ -9,6 +10,8 @@ import { createSuperAdminContext } from '@byline/auth';
|
|
|
9
10
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
11
|
import { isSingleton } from '../../@types/index.js';
|
|
11
12
|
import { TREE_HOOK_COMMITTED_MARKER } from '../../lib/errors.js';
|
|
13
|
+
import { runReadSnapshot } from '../../storage/read-snapshot.js';
|
|
14
|
+
import { unusedRevisionStore } from '../../storage/revision-store.test-helper.js';
|
|
12
15
|
import { validateTreeAuditCapability } from './audit.js';
|
|
13
16
|
import { createDocument } from './create.js';
|
|
14
17
|
import { deleteDocument } from './delete.js';
|
|
@@ -20,6 +23,8 @@ function createHarness(options = {}) {
|
|
|
20
23
|
let key = 0;
|
|
21
24
|
let writes = 0;
|
|
22
25
|
let deleted = false;
|
|
26
|
+
let revisions = new Map();
|
|
27
|
+
let transactionDepth = 0;
|
|
23
28
|
const calls = [];
|
|
24
29
|
const snapshot = (documentId) => {
|
|
25
30
|
const placement = placements.get(documentId);
|
|
@@ -172,13 +177,22 @@ function createHarness(options = {}) {
|
|
|
172
177
|
.filter(([, placement]) => placement.parentDocumentId == null)
|
|
173
178
|
.flatMap(([id]) => subtree(id))
|
|
174
179
|
: subtree(rootDocumentId);
|
|
175
|
-
return ids.map((document_id) => ({
|
|
180
|
+
return ids.map((document_id) => ({
|
|
181
|
+
document_id,
|
|
182
|
+
parent_document_id: placements.get(document_id)?.parentDocumentId ?? null,
|
|
183
|
+
depth: 0,
|
|
184
|
+
order_key: placements.get(document_id)?.orderKey ?? 'a0',
|
|
185
|
+
}));
|
|
176
186
|
});
|
|
177
|
-
const withTransaction = vi.fn(
|
|
187
|
+
const withTransaction = vi.fn();
|
|
188
|
+
const transact = async (fn) => {
|
|
189
|
+
withTransaction();
|
|
190
|
+
transactionDepth++;
|
|
178
191
|
calls.push('tx:start');
|
|
179
192
|
const placementSnapshot = new Map(placements);
|
|
180
193
|
const auditSnapshot = [...auditRows];
|
|
181
194
|
const deletedSnapshot = deleted;
|
|
195
|
+
const revisionSnapshot = new Map(revisions);
|
|
182
196
|
try {
|
|
183
197
|
const result = await fn();
|
|
184
198
|
calls.push('tx:commit');
|
|
@@ -188,10 +202,14 @@ function createHarness(options = {}) {
|
|
|
188
202
|
placements = placementSnapshot;
|
|
189
203
|
auditRows = auditSnapshot;
|
|
190
204
|
deleted = deletedSnapshot;
|
|
205
|
+
revisions = revisionSnapshot;
|
|
191
206
|
calls.push('tx:rollback');
|
|
192
207
|
throw error;
|
|
193
208
|
}
|
|
194
|
-
|
|
209
|
+
finally {
|
|
210
|
+
transactionDepth--;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
195
213
|
const definition = {
|
|
196
214
|
path: 'pages',
|
|
197
215
|
labels: { singular: 'Page', plural: 'Pages' },
|
|
@@ -211,11 +229,15 @@ function createHarness(options = {}) {
|
|
|
211
229
|
trace: vi.fn(),
|
|
212
230
|
silent: vi.fn(),
|
|
213
231
|
};
|
|
214
|
-
const db = {
|
|
232
|
+
const db = testAdapter({
|
|
215
233
|
commands: {
|
|
234
|
+
collections: { lockCollectionRegistration: vi.fn(async () => { }) },
|
|
235
|
+
singletons: { lockSlot: vi.fn(async () => { }) },
|
|
216
236
|
documents: {
|
|
217
237
|
publishSchedules: {
|
|
238
|
+
lockDocuments: vi.fn(async () => { }),
|
|
218
239
|
cancel: vi.fn(async () => null),
|
|
240
|
+
suspendForContentEdit: vi.fn(async () => ({ status: 'schedule_not_found' })),
|
|
219
241
|
},
|
|
220
242
|
placeTreeNode: place,
|
|
221
243
|
removeFromTree: remove,
|
|
@@ -228,15 +250,60 @@ function createHarness(options = {}) {
|
|
|
228
250
|
audit: { append },
|
|
229
251
|
},
|
|
230
252
|
queries: {
|
|
253
|
+
collections: {},
|
|
254
|
+
audit: {},
|
|
255
|
+
singletons: {},
|
|
231
256
|
documents: {
|
|
232
257
|
getTreeParent,
|
|
233
258
|
getTreeChildren,
|
|
234
259
|
getTreeSubtree,
|
|
235
|
-
getDocumentById: vi.fn(async () => ({
|
|
260
|
+
getDocumentById: vi.fn(async () => ({
|
|
261
|
+
document_version_id: 'version-1',
|
|
262
|
+
path: '/node',
|
|
263
|
+
fields: {},
|
|
264
|
+
})),
|
|
265
|
+
getDocumentRevision: async ({ document_id }) => revisions.get(document_id) ?? 1,
|
|
266
|
+
publishSchedules: {},
|
|
236
267
|
},
|
|
237
268
|
},
|
|
238
|
-
|
|
239
|
-
|
|
269
|
+
withReadSnapshot: (fn) => runReadSnapshot(db.queries, fn),
|
|
270
|
+
revisions: {
|
|
271
|
+
...unusedRevisionStore,
|
|
272
|
+
isInTransaction: () => transactionDepth > 0,
|
|
273
|
+
readStructure: async ({ documentIds, parentDocumentId }) => {
|
|
274
|
+
const ids = new Set(documentIds ?? [...placements.keys()]);
|
|
275
|
+
if (parentDocumentId)
|
|
276
|
+
for (const [id, placement] of placements)
|
|
277
|
+
if (placement.parentDocumentId === parentDocumentId)
|
|
278
|
+
ids.add(id);
|
|
279
|
+
return [...ids].map((documentId) => ({
|
|
280
|
+
documentId,
|
|
281
|
+
revision: revisions.get(documentId) ?? 1,
|
|
282
|
+
orderKey: null,
|
|
283
|
+
placed: placements.has(documentId),
|
|
284
|
+
parentDocumentId: placements.get(documentId)?.parentDocumentId ?? null,
|
|
285
|
+
treeOrderKey: placements.get(documentId)?.orderKey ?? null,
|
|
286
|
+
live: true,
|
|
287
|
+
}));
|
|
288
|
+
},
|
|
289
|
+
lock: async (targets) => targets.map((target) => ({
|
|
290
|
+
documentId: target.documentId,
|
|
291
|
+
collectionId: target.collectionId,
|
|
292
|
+
revision: revisions.get(target.documentId) ?? 1,
|
|
293
|
+
currentVersionId: 'version-1',
|
|
294
|
+
status: 'draft',
|
|
295
|
+
sourceLocale: 'en',
|
|
296
|
+
path: '/node',
|
|
297
|
+
availableLocales: ['en'],
|
|
298
|
+
})),
|
|
299
|
+
advance: async (locked) => {
|
|
300
|
+
const revision = locked.revision + 1;
|
|
301
|
+
revisions.set(locked.documentId, revision);
|
|
302
|
+
return { documentId: locked.documentId, revision };
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
withTransaction: transact,
|
|
306
|
+
});
|
|
240
307
|
const ctx = {
|
|
241
308
|
db,
|
|
242
309
|
definition,
|
|
@@ -266,7 +333,7 @@ function createHarness(options = {}) {
|
|
|
266
333
|
describe('document-tree lifecycle audit contract', () => {
|
|
267
334
|
it('validates the full tree audit capability only for tree-enabled collections', () => {
|
|
268
335
|
const harness = createHarness();
|
|
269
|
-
const unsupported = {
|
|
336
|
+
const unsupported = testAdapter({
|
|
270
337
|
...harness.ctx.db,
|
|
271
338
|
commands: {
|
|
272
339
|
...harness.ctx.db.commands,
|
|
@@ -275,7 +342,7 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
275
342
|
promoteChildrenAndRemoveFromTree: undefined,
|
|
276
343
|
},
|
|
277
344
|
},
|
|
278
|
-
};
|
|
345
|
+
});
|
|
279
346
|
const definition = harness.ctx.definition;
|
|
280
347
|
if (isSingleton(definition)) {
|
|
281
348
|
throw new Error('tree lifecycle harness must use a multi-document collection');
|
|
@@ -287,9 +354,18 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
287
354
|
const harness = createHarness({
|
|
288
355
|
initial: { left: { parentDocumentId: 'parent', orderKey: 'left-key' } },
|
|
289
356
|
});
|
|
290
|
-
await placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: null });
|
|
291
|
-
await placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: 'parent' });
|
|
292
357
|
await placeTreeNode(harness.ctx, {
|
|
358
|
+
expectedRevision: 1,
|
|
359
|
+
documentId: 'node',
|
|
360
|
+
parentDocumentId: null,
|
|
361
|
+
});
|
|
362
|
+
await placeTreeNode(harness.ctx, {
|
|
363
|
+
expectedRevision: 2,
|
|
364
|
+
documentId: 'node',
|
|
365
|
+
parentDocumentId: 'parent',
|
|
366
|
+
});
|
|
367
|
+
await placeTreeNode(harness.ctx, {
|
|
368
|
+
expectedRevision: 3,
|
|
293
369
|
documentId: 'node',
|
|
294
370
|
parentDocumentId: 'parent',
|
|
295
371
|
afterDocumentId: 'left',
|
|
@@ -321,8 +397,8 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
321
397
|
const harness = createHarness({
|
|
322
398
|
initial: { node: { parentDocumentId: 'parent', orderKey: 'old-key' } },
|
|
323
399
|
});
|
|
324
|
-
await removeFromTree(harness.ctx, { documentId: 'node' });
|
|
325
|
-
await removeFromTree(harness.ctx, { documentId: 'node' });
|
|
400
|
+
await removeFromTree(harness.ctx, { expectedRevision: 1, documentId: 'node' });
|
|
401
|
+
await removeFromTree(harness.ctx, { expectedRevision: 2, documentId: 'node' });
|
|
326
402
|
expect(harness.remove).toHaveBeenCalledTimes(2);
|
|
327
403
|
expect(harness.writes()).toBe(1);
|
|
328
404
|
expect(harness.auditRows()).toHaveLength(1);
|
|
@@ -348,7 +424,17 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
348
424
|
},
|
|
349
425
|
afterTreeChange: hook,
|
|
350
426
|
});
|
|
351
|
-
await expect(placeTreeNode(harness.ctx, {
|
|
427
|
+
await expect(placeTreeNode(harness.ctx, {
|
|
428
|
+
expectedRevision: 1,
|
|
429
|
+
documentId: 'node',
|
|
430
|
+
parentDocumentId: null,
|
|
431
|
+
})).resolves.toEqual({
|
|
432
|
+
orderKey: 'b-key',
|
|
433
|
+
documentId: 'node',
|
|
434
|
+
revision: 1,
|
|
435
|
+
affectedDocuments: [],
|
|
436
|
+
scheduledPublicationsNeedReconfirmation: false,
|
|
437
|
+
});
|
|
352
438
|
expect(harness.writes()).toBe(0);
|
|
353
439
|
expect(harness.auditRows()).toEqual([]);
|
|
354
440
|
expect(hook).not.toHaveBeenCalled();
|
|
@@ -360,12 +446,23 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
360
446
|
.mockRejectedValueOnce(new Error('hook failed'))
|
|
361
447
|
.mockResolvedValue(undefined);
|
|
362
448
|
const harness = createHarness({ afterTreeChange: hook });
|
|
363
|
-
await expect(placeTreeNode(harness.ctx, { documentId: 'node', parentDocumentId: null })).rejects.toThrow('hook failed');
|
|
364
449
|
await expect(placeTreeNode(harness.ctx, {
|
|
450
|
+
expectedRevision: 1,
|
|
451
|
+
documentId: 'node',
|
|
452
|
+
parentDocumentId: null,
|
|
453
|
+
})).rejects.toThrow('hook failed');
|
|
454
|
+
await expect(placeTreeNode(harness.ctx, {
|
|
455
|
+
expectedRevision: 2,
|
|
365
456
|
documentId: 'node',
|
|
366
457
|
parentDocumentId: null,
|
|
367
458
|
reconcile: true,
|
|
368
|
-
})).resolves.toEqual({
|
|
459
|
+
})).resolves.toEqual({
|
|
460
|
+
orderKey: 'key-1',
|
|
461
|
+
documentId: 'node',
|
|
462
|
+
revision: 2,
|
|
463
|
+
affectedDocuments: [],
|
|
464
|
+
scheduledPublicationsNeedReconfirmation: false,
|
|
465
|
+
});
|
|
369
466
|
expect(harness.writes()).toBe(1);
|
|
370
467
|
expect(harness.auditRows()).toHaveLength(1);
|
|
371
468
|
expect(hook).toHaveBeenCalledTimes(2);
|
|
@@ -394,9 +491,9 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
394
491
|
},
|
|
395
492
|
afterTreeChange: hook,
|
|
396
493
|
});
|
|
397
|
-
await expect(removeFromTree(harness.ctx, { documentId: 'node' })).rejects.toThrow('hook failed');
|
|
494
|
+
await expect(removeFromTree(harness.ctx, { expectedRevision: 1, documentId: 'node' })).rejects.toThrow('hook failed');
|
|
398
495
|
expect(events[0]?.affectedDocumentIds).toEqual(expect.arrayContaining(['node', 'child', 'grandchild', 'parent']));
|
|
399
|
-
await removeFromTree(harness.ctx, { documentId: 'node', reconcile: true });
|
|
496
|
+
await removeFromTree(harness.ctx, { expectedRevision: 2, documentId: 'node', reconcile: true });
|
|
400
497
|
expect(harness.writes()).toBe(1);
|
|
401
498
|
expect(harness.auditRows()).toHaveLength(1);
|
|
402
499
|
expect(events).toHaveLength(2);
|
|
@@ -410,7 +507,7 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
410
507
|
childB: { parentDocumentId: 'deleted', orderKey: 'b-key' },
|
|
411
508
|
},
|
|
412
509
|
});
|
|
413
|
-
await promoteChildrenAndRemove(harness.ctx, { documentId: 'deleted' });
|
|
510
|
+
await promoteChildrenAndRemove(harness.ctx, { expectedRevision: 1, documentId: 'deleted' });
|
|
414
511
|
expect(harness.placement('deleted')).toBeUndefined();
|
|
415
512
|
expect(harness.placement('childA')?.parentDocumentId).toBeNull();
|
|
416
513
|
expect(harness.placement('childB')?.parentDocumentId).toBeNull();
|
|
@@ -455,8 +552,10 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
455
552
|
child: { parentDocumentId: 'deleted', orderKey: 'child-key' },
|
|
456
553
|
},
|
|
457
554
|
});
|
|
458
|
-
await expect(deleteDocument(harness.ctx, { documentId: 'deleted' })).resolves.
|
|
555
|
+
await expect(deleteDocument(harness.ctx, { expectedRevision: 1, documentId: 'deleted' })).resolves.toMatchObject({
|
|
459
556
|
deletedVersionCount: 1,
|
|
557
|
+
documentId: 'deleted',
|
|
558
|
+
revision: 2,
|
|
460
559
|
outcome: 'committed',
|
|
461
560
|
sideEffectFailures: [],
|
|
462
561
|
});
|
|
@@ -478,7 +577,7 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
478
577
|
},
|
|
479
578
|
failAuditAt: 3,
|
|
480
579
|
});
|
|
481
|
-
await expect(deleteDocument(harness.ctx, { documentId: 'deleted' })).rejects.toThrow('audit failed');
|
|
580
|
+
await expect(deleteDocument(harness.ctx, { expectedRevision: 1, documentId: 'deleted' })).rejects.toThrow('audit failed');
|
|
482
581
|
expect(harness.deleted()).toBe(false);
|
|
483
582
|
expect(harness.placement('deleted')).toEqual({
|
|
484
583
|
parentDocumentId: null,
|
|
@@ -502,8 +601,10 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
502
601
|
},
|
|
503
602
|
afterDelete,
|
|
504
603
|
});
|
|
505
|
-
await expect(deleteDocument(harness.ctx, { documentId: 'deleted' })).resolves.
|
|
604
|
+
await expect(deleteDocument(harness.ctx, { expectedRevision: 1, documentId: 'deleted' })).resolves.toMatchObject({
|
|
506
605
|
deletedVersionCount: 1,
|
|
606
|
+
documentId: 'deleted',
|
|
607
|
+
revision: 2,
|
|
507
608
|
outcome: 'committed-with-side-effect-failures',
|
|
508
609
|
sideEffectFailures: [{ phase: 'afterTreeChange', code: 'ERR_UNHANDLED' }],
|
|
509
610
|
});
|
|
@@ -529,9 +630,11 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
529
630
|
afterTreeChange,
|
|
530
631
|
afterDelete,
|
|
531
632
|
});
|
|
532
|
-
const result = await deleteDocument(harness.ctx, { documentId: 'deleted' });
|
|
533
|
-
expect(result).
|
|
633
|
+
const result = await deleteDocument(harness.ctx, { expectedRevision: 1, documentId: 'deleted' });
|
|
634
|
+
expect(result).toMatchObject({
|
|
534
635
|
deletedVersionCount: 1,
|
|
636
|
+
documentId: 'deleted',
|
|
637
|
+
revision: 2,
|
|
535
638
|
outcome: 'committed-with-side-effect-failures',
|
|
536
639
|
sideEffectFailures: [
|
|
537
640
|
{ phase: 'afterTreeChange', code: 'ERR_UNHANDLED' },
|
|
@@ -564,8 +667,10 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
564
667
|
throw new Error('logger failed');
|
|
565
668
|
});
|
|
566
669
|
harness.ctx.logger.error = loggerError;
|
|
567
|
-
await expect(deleteDocument(harness.ctx, { documentId: 'deleted' })).resolves.
|
|
670
|
+
await expect(deleteDocument(harness.ctx, { expectedRevision: 1, documentId: 'deleted' })).resolves.toMatchObject({
|
|
568
671
|
deletedVersionCount: 1,
|
|
672
|
+
documentId: 'deleted',
|
|
673
|
+
revision: 2,
|
|
569
674
|
outcome: 'committed-with-side-effect-failures',
|
|
570
675
|
sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
|
|
571
676
|
});
|
|
@@ -579,7 +684,11 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
579
684
|
failAudit: true,
|
|
580
685
|
afterTreeChange: hook,
|
|
581
686
|
});
|
|
582
|
-
await expect(placeTreeNode(harness.ctx, {
|
|
687
|
+
await expect(placeTreeNode(harness.ctx, {
|
|
688
|
+
expectedRevision: 1,
|
|
689
|
+
documentId: 'node',
|
|
690
|
+
parentDocumentId: 'new-parent',
|
|
691
|
+
})).rejects.toThrow('audit failed');
|
|
583
692
|
expect(harness.placement('node')).toEqual({ parentDocumentId: null, orderKey: 'old-key' });
|
|
584
693
|
expect(harness.auditRows()).toEqual([]);
|
|
585
694
|
expect(hook).not.toHaveBeenCalled();
|
|
@@ -590,7 +699,7 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
590
699
|
initial: { node: { parentDocumentId: 'parent', orderKey: 'old-key' } },
|
|
591
700
|
failAudit: true,
|
|
592
701
|
});
|
|
593
|
-
await expect(removeFromTree(removeHarness.ctx, { documentId: 'node' })).rejects.toThrow('audit failed');
|
|
702
|
+
await expect(removeFromTree(removeHarness.ctx, { expectedRevision: 1, documentId: 'node' })).rejects.toThrow('audit failed');
|
|
594
703
|
expect(removeHarness.placement('node')).toEqual({
|
|
595
704
|
parentDocumentId: 'parent',
|
|
596
705
|
orderKey: 'old-key',
|
|
@@ -602,7 +711,7 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
602
711
|
},
|
|
603
712
|
failAudit: true,
|
|
604
713
|
});
|
|
605
|
-
await expect(promoteChildrenAndRemove(promoteHarness.ctx, { documentId: 'deleted' })).rejects.toThrow('audit failed');
|
|
714
|
+
await expect(promoteChildrenAndRemove(promoteHarness.ctx, { expectedRevision: 1, documentId: 'deleted' })).rejects.toThrow('audit failed');
|
|
606
715
|
expect(promoteHarness.placement('deleted')).toEqual({
|
|
607
716
|
parentDocumentId: null,
|
|
608
717
|
orderKey: 'deleted-key',
|
|
@@ -614,7 +723,11 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
614
723
|
});
|
|
615
724
|
it('does not append audit on a failed storage mutation', async () => {
|
|
616
725
|
const harness = createHarness({ failPlace: true });
|
|
617
|
-
await expect(placeTreeNode(harness.ctx, {
|
|
726
|
+
await expect(placeTreeNode(harness.ctx, {
|
|
727
|
+
expectedRevision: 1,
|
|
728
|
+
documentId: 'node',
|
|
729
|
+
parentDocumentId: null,
|
|
730
|
+
})).rejects.toThrow('tree write failed');
|
|
618
731
|
expect(harness.append).not.toHaveBeenCalled();
|
|
619
732
|
expect(harness.auditRows()).toEqual([]);
|
|
620
733
|
});
|
|
@@ -623,7 +736,11 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
623
736
|
throw new Error('hook failed');
|
|
624
737
|
});
|
|
625
738
|
const harness = createHarness({ afterTreeChange: hook });
|
|
626
|
-
await expect(placeTreeNode(harness.ctx, {
|
|
739
|
+
await expect(placeTreeNode(harness.ctx, {
|
|
740
|
+
expectedRevision: 1,
|
|
741
|
+
documentId: 'node',
|
|
742
|
+
parentDocumentId: null,
|
|
743
|
+
})).rejects.toMatchObject({
|
|
627
744
|
name: 'BylineError',
|
|
628
745
|
code: 'ERR_TREE_HOOK_COMMITTED',
|
|
629
746
|
message: expect.stringMatching(new RegExp(`^${TREE_HOOK_COMMITTED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*hook failed`)),
|
|
@@ -638,7 +755,7 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
638
755
|
document: { id: 'version-1', document_id: 'created-node' },
|
|
639
756
|
fieldCount: 1,
|
|
640
757
|
}));
|
|
641
|
-
harness.ctx.db = {
|
|
758
|
+
harness.ctx.db = testAdapter({
|
|
642
759
|
...harness.ctx.db,
|
|
643
760
|
commands: {
|
|
644
761
|
...harness.ctx.db.commands,
|
|
@@ -649,23 +766,23 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
649
766
|
audit: undefined,
|
|
650
767
|
},
|
|
651
768
|
withTransaction: undefined,
|
|
652
|
-
};
|
|
769
|
+
});
|
|
653
770
|
await expect(createDocument(harness.ctx, { data: { title: 'Created' }, locale: 'en' })).rejects.toMatchObject({ code: 'ERR_AUDIT_UNSUPPORTED' });
|
|
654
771
|
expect(createDocumentVersion).not.toHaveBeenCalled();
|
|
655
772
|
expect(harness.place).not.toHaveBeenCalled();
|
|
656
773
|
expect(harness.ctx.logger.error).not.toHaveBeenCalled();
|
|
657
774
|
});
|
|
658
|
-
it('
|
|
775
|
+
it('rolls back initiating creation and placement when its audit append fails', async () => {
|
|
659
776
|
const harness = createHarness({ failAudit: true });
|
|
660
777
|
const createDocumentVersion = vi.fn(async () => ({
|
|
661
778
|
document: { id: 'version-1', document_id: 'created-node' },
|
|
662
779
|
fieldCount: 1,
|
|
663
780
|
}));
|
|
664
781
|
harness.ctx.db.commands.documents.createDocumentVersion = createDocumentVersion;
|
|
665
|
-
await expect(createDocument(harness.ctx, { data: { title: 'Created' }, locale: 'en' })).
|
|
782
|
+
await expect(createDocument(harness.ctx, { data: { title: 'Created' }, locale: 'en' })).rejects.toThrow('audit failed');
|
|
666
783
|
expect(harness.place).toHaveBeenCalledOnce();
|
|
667
784
|
expect(harness.placement('created-node')).toBeUndefined();
|
|
668
785
|
expect(harness.auditRows()).toEqual([]);
|
|
669
|
-
expect(harness.
|
|
786
|
+
expect(harness.calls).toContain('tx:rollback');
|
|
670
787
|
});
|
|
671
788
|
});
|