@byline/core 4.19.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/@types/db-types.d.ts +32 -1
  2. package/dist/@types/document-revision.d.ts +92 -0
  3. package/dist/@types/document-revision.js +8 -0
  4. package/dist/@types/index.d.ts +1 -0
  5. package/dist/@types/index.js +1 -0
  6. package/dist/@types/site-config.d.ts +3 -1
  7. package/dist/config/attach-hooks.test.node.js +2 -1
  8. package/dist/config/config-hooks.test.node.js +2 -1
  9. package/dist/core.js +13 -1
  10. package/dist/core.test.node.js +27 -2
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.js +3 -1
  13. package/dist/lib/errors.d.ts +15 -0
  14. package/dist/lib/errors.js +32 -0
  15. package/dist/lib/lock-conflict.test.node.d.ts +1 -0
  16. package/dist/lib/lock-conflict.test.node.js +31 -0
  17. package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
  18. package/dist/scheduler/run-due-tasks.test.node.js +3 -2
  19. package/dist/scheduler/scheduled-publication.d.ts +16 -1
  20. package/dist/scheduler/scheduled-publication.js +27 -1
  21. package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
  22. package/dist/scheduler/scheduled-publication.test.node.js +18 -3
  23. package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
  24. package/dist/scheduler/scheduler-boot.test.node.js +54 -12
  25. package/dist/scheduler/ticker.test.node.d.ts +0 -7
  26. package/dist/scheduler/ticker.test.node.js +3 -2
  27. package/dist/scheduler/validate-scheduler-config.d.ts +2 -0
  28. package/dist/scheduler/validate-scheduler-config.js +5 -0
  29. package/dist/scheduler/validate-scheduler-config.test.node.js +16 -0
  30. package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
  31. package/dist/services/collection-bootstrap.test.node.js +12 -3
  32. package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
  33. package/dist/services/discover-counter-groups.test.node.js +19 -3
  34. package/dist/services/document-lifecycle/audit.d.ts +1 -0
  35. package/dist/services/document-lifecycle/audit.js +1 -0
  36. package/dist/services/document-lifecycle/committed-hook.d.ts +2 -1
  37. package/dist/services/document-lifecycle/committed-hook.js +19 -2
  38. package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
  39. package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
  40. package/dist/services/document-lifecycle/create.d.ts +1 -0
  41. package/dist/services/document-lifecycle/create.js +4 -20
  42. package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
  43. package/dist/services/document-lifecycle/delete-locale.js +35 -37
  44. package/dist/services/document-lifecycle/delete.d.ts +8 -2
  45. package/dist/services/document-lifecycle/delete.js +24 -15
  46. package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
  47. package/dist/services/document-lifecycle/duplicate.js +48 -41
  48. package/dist/services/document-lifecycle/index.d.ts +3 -1
  49. package/dist/services/document-lifecycle/index.js +3 -1
  50. package/dist/services/document-lifecycle/persistence.js +18 -7
  51. package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
  52. package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
  53. package/dist/services/document-lifecycle/reorder.d.ts +11 -0
  54. package/dist/services/document-lifecycle/reorder.js +83 -0
  55. package/dist/services/document-lifecycle/restore.d.ts +2 -0
  56. package/dist/services/document-lifecycle/restore.js +19 -25
  57. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
  58. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
  59. package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
  60. package/dist/services/document-lifecycle/revision-guard.js +154 -0
  61. package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
  62. package/dist/services/document-lifecycle/same-document-data.js +17 -0
  63. package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
  64. package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
  65. package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
  66. package/dist/services/document-lifecycle/stale-document.js +95 -0
  67. package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
  68. package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
  69. package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
  70. package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
  71. package/dist/services/document-lifecycle/status.d.ts +5 -21
  72. package/dist/services/document-lifecycle/status.js +90 -133
  73. package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
  74. package/dist/services/document-lifecycle/system-fields.js +118 -116
  75. package/dist/services/document-lifecycle/tree.d.ts +9 -6
  76. package/dist/services/document-lifecycle/tree.js +65 -39
  77. package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
  78. package/dist/services/document-lifecycle/tree.test.node.js +152 -35
  79. package/dist/services/document-lifecycle/update.d.ts +14 -6
  80. package/dist/services/document-lifecycle/update.js +101 -80
  81. package/dist/services/document-lifecycle.test.node.js +401 -73
  82. package/dist/services/field-upload.d.ts +1 -0
  83. package/dist/services/field-upload.js +5 -1
  84. package/dist/services/field-upload.test.node.d.ts +0 -7
  85. package/dist/services/field-upload.test.node.js +38 -2
  86. package/dist/services/populate.test.node.d.ts +0 -7
  87. package/dist/services/populate.test.node.js +19 -3
  88. package/dist/services/richtext-populate.test.node.d.ts +0 -7
  89. package/dist/services/richtext-populate.test.node.js +3 -2
  90. package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
  91. package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
  92. package/dist/services/singleton-lifecycle/index.d.ts +1 -1
  93. package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
  94. package/dist/services/singleton-lifecycle/internals.js +74 -77
  95. package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
  96. package/dist/services/singleton-lifecycle/restore.js +3 -1
  97. package/dist/services/singleton-lifecycle/update.d.ts +6 -5
  98. package/dist/services/singleton-lifecycle/update.js +158 -91
  99. package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
  100. package/dist/services/singleton-lifecycle.test.node.js +67 -23
  101. package/dist/storage/db-adapter.test-helper.d.ts +26 -0
  102. package/dist/storage/db-adapter.test-helper.js +117 -0
  103. package/dist/storage/document-revision-capability.d.ts +3 -0
  104. package/dist/storage/document-revision-capability.js +15 -0
  105. package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
  106. package/dist/storage/document-revision-capability.test.node.js +53 -0
  107. package/dist/storage/document-revision.d.ts +12 -0
  108. package/dist/storage/document-revision.js +37 -0
  109. package/dist/storage/document-revision.test.node.d.ts +1 -0
  110. package/dist/storage/document-revision.test.node.js +48 -0
  111. package/dist/storage/read-snapshot.d.ts +3 -0
  112. package/dist/storage/read-snapshot.js +55 -0
  113. package/dist/storage/revision-store.test-helper.d.ts +3 -0
  114. package/dist/storage/revision-store.test-helper.js +16 -0
  115. package/dist/validation/password-sign-in.test.node.d.ts +8 -0
  116. package/dist/validation/password-sign-in.test.node.js +21 -0
  117. package/dist/validation/shared.d.ts +5 -11
  118. package/dist/validation/shared.js +7 -1
  119. package/package.json +8 -8
@@ -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 append in one transaction. */
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
- let mutation;
96
- await capability.withTransaction(async () => {
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
- return mutation;
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
- const mutation = await auditedPlace(ctx, params);
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
- /** Best-effort create/self-heal primitive that never moves an already-placed node. */
157
+ /** Internal placement, only inside the initiating create/save transaction. */
155
158
  export async function appendTreeRoot(ctx, documentId) {
156
- await auditedPlace(ctx, { documentId, parentDocumentId: null, ifUnplaced: true });
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
- /** Best-effort post-version repair; locked `ifUnplaced` closes the check/write race. */
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 !== true)
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 capability.withTransaction(async () => {
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 capability.withTransaction(async () => {
318
+ const committed = await commitGuardedDocumentMutation(ctx, params, async () => {
301
319
  result = await reconcileTreeOnDeleteInTransaction(ctx, params.documentId, capability);
302
- });
303
- await firePromoteTreeChange(ctx, params.documentId, result);
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) => ({ 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(async (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 () => ({ path: '/node', fields: {} })),
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
- withTransaction,
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, { documentId: 'node', parentDocumentId: null })).resolves.toEqual({ orderKey: 'b-key' });
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({ orderKey: 'key-1' });
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.toEqual({
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.toEqual({
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).toEqual({
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.toEqual({
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, { documentId: 'node', parentDocumentId: 'new-parent' })).rejects.toThrow('audit failed');
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, { documentId: 'node', parentDocumentId: null })).rejects.toThrow('tree write failed');
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, { documentId: 'node', parentDocumentId: null })).rejects.toMatchObject({
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('keeps create-time placement best-effort and unplaced when audit append fails', async () => {
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' })).resolves.toEqual({ documentId: 'created-node', documentVersionId: 'version-1' });
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.ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'created-node' }), 'failed to auto-place new document in tree');
786
+ expect(harness.calls).toContain('tx:rollback');
670
787
  });
671
788
  });