@byline/core 4.19.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.
Files changed (111) 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/config/attach-hooks.test.node.js +2 -1
  7. package/dist/config/config-hooks.test.node.js +2 -1
  8. package/dist/core.js +8 -0
  9. package/dist/core.test.node.js +27 -2
  10. package/dist/index.d.ts +3 -1
  11. package/dist/index.js +3 -1
  12. package/dist/lib/errors.d.ts +15 -0
  13. package/dist/lib/errors.js +32 -0
  14. package/dist/lib/lock-conflict.test.node.d.ts +1 -0
  15. package/dist/lib/lock-conflict.test.node.js +31 -0
  16. package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
  17. package/dist/scheduler/run-due-tasks.test.node.js +3 -2
  18. package/dist/scheduler/scheduled-publication.d.ts +16 -1
  19. package/dist/scheduler/scheduled-publication.js +27 -1
  20. package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
  21. package/dist/scheduler/scheduled-publication.test.node.js +18 -3
  22. package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
  23. package/dist/scheduler/scheduler-boot.test.node.js +40 -12
  24. package/dist/scheduler/ticker.test.node.d.ts +0 -7
  25. package/dist/scheduler/ticker.test.node.js +3 -2
  26. package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
  27. package/dist/services/collection-bootstrap.test.node.js +12 -3
  28. package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
  29. package/dist/services/discover-counter-groups.test.node.js +19 -3
  30. package/dist/services/document-lifecycle/audit.d.ts +1 -0
  31. package/dist/services/document-lifecycle/audit.js +1 -0
  32. package/dist/services/document-lifecycle/committed-hook.d.ts +2 -1
  33. package/dist/services/document-lifecycle/committed-hook.js +19 -2
  34. package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
  35. package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
  36. package/dist/services/document-lifecycle/create.d.ts +1 -0
  37. package/dist/services/document-lifecycle/create.js +4 -20
  38. package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
  39. package/dist/services/document-lifecycle/delete-locale.js +35 -37
  40. package/dist/services/document-lifecycle/delete.d.ts +8 -2
  41. package/dist/services/document-lifecycle/delete.js +24 -15
  42. package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
  43. package/dist/services/document-lifecycle/duplicate.js +48 -41
  44. package/dist/services/document-lifecycle/index.d.ts +3 -1
  45. package/dist/services/document-lifecycle/index.js +3 -1
  46. package/dist/services/document-lifecycle/persistence.js +18 -7
  47. package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
  48. package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
  49. package/dist/services/document-lifecycle/reorder.d.ts +11 -0
  50. package/dist/services/document-lifecycle/reorder.js +83 -0
  51. package/dist/services/document-lifecycle/restore.d.ts +2 -0
  52. package/dist/services/document-lifecycle/restore.js +19 -25
  53. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
  54. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
  55. package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
  56. package/dist/services/document-lifecycle/revision-guard.js +154 -0
  57. package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
  58. package/dist/services/document-lifecycle/same-document-data.js +17 -0
  59. package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
  60. package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
  61. package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
  62. package/dist/services/document-lifecycle/stale-document.js +95 -0
  63. package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
  64. package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
  65. package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
  66. package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
  67. package/dist/services/document-lifecycle/status.d.ts +5 -21
  68. package/dist/services/document-lifecycle/status.js +90 -133
  69. package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
  70. package/dist/services/document-lifecycle/system-fields.js +118 -116
  71. package/dist/services/document-lifecycle/tree.d.ts +9 -6
  72. package/dist/services/document-lifecycle/tree.js +65 -39
  73. package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
  74. package/dist/services/document-lifecycle/tree.test.node.js +152 -35
  75. package/dist/services/document-lifecycle/update.d.ts +14 -6
  76. package/dist/services/document-lifecycle/update.js +101 -80
  77. package/dist/services/document-lifecycle.test.node.js +401 -73
  78. package/dist/services/field-upload.d.ts +1 -0
  79. package/dist/services/field-upload.js +5 -1
  80. package/dist/services/field-upload.test.node.d.ts +0 -7
  81. package/dist/services/field-upload.test.node.js +38 -2
  82. package/dist/services/populate.test.node.d.ts +0 -7
  83. package/dist/services/populate.test.node.js +19 -3
  84. package/dist/services/richtext-populate.test.node.d.ts +0 -7
  85. package/dist/services/richtext-populate.test.node.js +3 -2
  86. package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
  87. package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
  88. package/dist/services/singleton-lifecycle/index.d.ts +1 -1
  89. package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
  90. package/dist/services/singleton-lifecycle/internals.js +74 -77
  91. package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
  92. package/dist/services/singleton-lifecycle/restore.js +3 -1
  93. package/dist/services/singleton-lifecycle/update.d.ts +6 -5
  94. package/dist/services/singleton-lifecycle/update.js +158 -91
  95. package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
  96. package/dist/services/singleton-lifecycle.test.node.js +67 -23
  97. package/dist/storage/db-adapter.test-helper.d.ts +26 -0
  98. package/dist/storage/db-adapter.test-helper.js +117 -0
  99. package/dist/storage/document-revision-capability.d.ts +3 -0
  100. package/dist/storage/document-revision-capability.js +15 -0
  101. package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
  102. package/dist/storage/document-revision-capability.test.node.js +53 -0
  103. package/dist/storage/document-revision.d.ts +12 -0
  104. package/dist/storage/document-revision.js +37 -0
  105. package/dist/storage/document-revision.test.node.d.ts +1 -0
  106. package/dist/storage/document-revision.test.node.js +48 -0
  107. package/dist/storage/read-snapshot.d.ts +3 -0
  108. package/dist/storage/read-snapshot.js +55 -0
  109. package/dist/storage/revision-store.test-helper.d.ts +3 -0
  110. package/dist/storage/revision-store.test-helper.js +16 -0
  111. package/package.json +2 -2
@@ -9,6 +9,7 @@ import { createSuperAdminContext } from '@byline/auth';
9
9
  import { AUDIT_ACTIONS, requireAuditCapability } from '../services/document-lifecycle/audit.js';
10
10
  import { publishScheduleAuditValue } from '../services/document-lifecycle/publish-schedule-consistency.js';
11
11
  import { publishClaimedScheduledDocument } from '../services/document-lifecycle/scheduled-publish.js';
12
+ import { documentRevisionFromDatabase } from '../storage/document-revision.js';
12
13
  import { SCHEDULED_PUBLICATION_LEASE_MS } from './scheduled-publication-constants.js';
13
14
  const DEFAULT_BATCH_SIZE = 25;
14
15
  const DEFAULT_BUDGET_MS = 45_000;
@@ -50,6 +51,28 @@ function buildSystemContext(core, claim, logger) {
50
51
  async function finalizeClaim(params) {
51
52
  const audit = requireAuditCapability(params.core.db);
52
53
  return audit.withTransaction(async () => {
54
+ const db = params.core.db;
55
+ const definition = resolveCollection(params.core, params.claim.collectionId)?.definition;
56
+ await db.commands.collections.lockCollectionRegistration(params.claim.collectionId, definition?.tree || definition?.singleton ? 'exclusive' : 'shared');
57
+ const observed = await db.withReadSnapshot(async (queries) => ({
58
+ current: await queries.documents.getCurrentVersionMetadata({
59
+ collection_id: params.claim.collectionId,
60
+ document_id: params.claim.documentId,
61
+ }),
62
+ revision: await queries.documents.getDocumentRevision({
63
+ collection_id: params.claim.collectionId,
64
+ document_id: params.claim.documentId,
65
+ }),
66
+ }));
67
+ const [document] = observed.current === null
68
+ ? []
69
+ : await db.revisions.lock([
70
+ {
71
+ documentId: params.claim.documentId,
72
+ collectionId: params.claim.collectionId,
73
+ expectedRevision: documentRevisionFromDatabase(observed.revision),
74
+ },
75
+ ]);
53
76
  const locked = await params.core.db.commands.documents.publishSchedules.lockClaim({
54
77
  documentId: params.claim.documentId,
55
78
  executionToken: params.claim.executionToken,
@@ -60,6 +83,7 @@ async function finalizeClaim(params) {
60
83
  ? await params.core.db.commands.documents.publishSchedules.suspendClaimForContentEdit({
61
84
  documentId: params.claim.documentId,
62
85
  executionToken: params.claim.executionToken,
86
+ reason: params.reason === 'content_edited' ? 'content_edited' : 'document_metadata_changed',
63
87
  })
64
88
  : await params.core.db.commands.documents.publishSchedules.deleteClaim({
65
89
  documentId: params.claim.documentId,
@@ -80,6 +104,8 @@ async function finalizeClaim(params) {
80
104
  ? { state: 'needs_reconfirm', reason: params.reason }
81
105
  : { reason: params.reason },
82
106
  });
107
+ if (document)
108
+ await db.revisions.advance(document);
83
109
  return true;
84
110
  });
85
111
  }
@@ -109,7 +135,7 @@ async function processClaim(params) {
109
135
  core: params.core,
110
136
  claim: params.claim,
111
137
  outcome: 'suspend',
112
- reason: 'content_edited',
138
+ reason: result.reason,
113
139
  });
114
140
  return 'handled';
115
141
  }
@@ -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,5 @@
1
+ import { testAdapter } from '../storage/db-adapter.test-helper.js';
2
+ import { runReadSnapshot } from '../storage/read-snapshot.js';
1
3
  /**
2
4
  * This Source Code is subject to the terms of the Mozilla Public
3
5
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -21,7 +23,7 @@ function emptyCore() {
21
23
  const claimDue = vi.fn().mockResolvedValue([]);
22
24
  return {
23
25
  core: {
24
- db: { commands: { documents: { publishSchedules: { claimDue } } } },
26
+ db: testAdapter({ commands: { documents: { publishSchedules: { claimDue } } } }),
25
27
  logger,
26
28
  },
27
29
  claimDue,
@@ -33,6 +35,7 @@ function claim(documentId) {
33
35
  documentId,
34
36
  collectionId: 'unregistered-collection',
35
37
  targetVersionId: `version-${documentId}`,
38
+ authorizedRevision: 1,
36
39
  publishAt: now,
37
40
  state: 'armed',
38
41
  suspendedAt: null,
@@ -91,15 +94,27 @@ describe('runScheduledPublicationSweep', () => {
91
94
  const core = {
92
95
  collections: [],
93
96
  collectionRecords: new Map(),
94
- db: {
97
+ db: testAdapter({
98
+ withReadSnapshot: async (fn) => {
99
+ const db = testAdapter({
100
+ queries: {
101
+ documents: {
102
+ getCurrentVersionMetadata: async () => null,
103
+ getDocumentRevision: async () => 1,
104
+ },
105
+ },
106
+ });
107
+ return runReadSnapshot(db.queries, fn);
108
+ },
95
109
  withTransaction: async (fn) => fn(),
96
110
  commands: {
111
+ collections: { lockCollectionRegistration: vi.fn(async () => { }) },
97
112
  audit: { append: vi.fn().mockResolvedValue({ id: 'audit-1' }) },
98
113
  documents: {
99
114
  publishSchedules: { claimDue, lockClaim, deleteClaim, releaseClaim },
100
115
  },
101
116
  },
102
- },
117
+ }),
103
118
  logger,
104
119
  };
105
120
  await expect(runScheduledPublicationSweep(core, { batchSize: 10, budgetMs: 5_000 })).resolves.toEqual({ published: 0, failed: 1, workRemaining: false });
@@ -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,12 +1,7 @@
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
- */
1
+ import { testAdapter } from '../storage/db-adapter.test-helper.js';
8
2
  import { describe, expect, it } from 'vitest';
9
3
  import { initBylineCore } from '../core.js';
4
+ import { unusedRevisionStore } from '../storage/revision-store.test-helper.js';
10
5
  import { defineRecurringTask } from './define-recurring-task.js';
11
6
  // Boot-level coverage for the scheduler wiring inside `initBylineCore()`,
12
7
  // complementing the pure-function coverage in `validate-scheduler-config.test.node.ts`.
@@ -32,15 +27,26 @@ const task = defineRecurringTask({
32
27
  });
33
28
  describe('initBylineCore scheduler wiring', () => {
34
29
  it('rejects recurring tasks registered against an adapter without the scheduler capability', async () => {
35
- const config = serverConfig({});
30
+ const config = serverConfig(testAdapter({}));
36
31
  config.recurringTasks = [task];
37
32
  await expect(initBylineCore(config, {})).rejects.toThrow(/analytics\.rollup/);
38
33
  });
39
34
  it('gates scheduled publication on the scheduler capability and contributes its built-in task', async () => {
40
- const unsupported = serverConfig({});
35
+ const unsupported = serverConfig(testAdapter({}));
41
36
  unsupported.scheduledPublication = { enabled: true };
42
37
  await expect(initBylineCore(unsupported, {})).rejects.toThrow(/documents\.publish-scheduled/);
43
- const capable = serverConfig({ scheduler: {} });
38
+ const capable = serverConfig(testAdapter({
39
+ scheduler: {},
40
+ withTransaction: async (fn) => fn(),
41
+ withReadSnapshot: async () => {
42
+ throw new Error('Unexpected editable read');
43
+ },
44
+ commands: {
45
+ collections: { lockCollectionRegistration: async () => { } },
46
+ documents: { publishSchedules: { lockDocuments: async () => { } } },
47
+ },
48
+ revisions: { ...unusedRevisionStore, assertCompatibleSchema: async () => { } },
49
+ }));
44
50
  capable.scheduledPublication = { enabled: true };
45
51
  capable.recurringTasks = [task];
46
52
  const core = await initBylineCore(capable, {});
@@ -54,7 +60,18 @@ describe('initBylineCore scheduler wiring', () => {
54
60
  });
55
61
  });
56
62
  it('populates core.recurringTasks with the registered set when the adapter is capable', async () => {
57
- const db = { scheduler: {} };
63
+ const db = testAdapter({
64
+ scheduler: {},
65
+ withTransaction: async (fn) => fn(),
66
+ withReadSnapshot: async () => {
67
+ throw new Error('Unexpected editable read');
68
+ },
69
+ commands: {
70
+ collections: { lockCollectionRegistration: async () => { } },
71
+ documents: { publishSchedules: { lockDocuments: async () => { } } },
72
+ },
73
+ revisions: { ...unusedRevisionStore, assertCompatibleSchema: async () => { } },
74
+ });
58
75
  const config = serverConfig(db);
59
76
  config.recurringTasks = [task];
60
77
  const core = await initBylineCore(config, {});
@@ -63,7 +80,18 @@ describe('initBylineCore scheduler wiring', () => {
63
80
  expect(registered).toMatchObject({ name: 'analytics.rollup', intervalMs: 3_600_000 });
64
81
  });
65
82
  it('freezes the validated snapshot so post-init mutation of the caller input cannot alter it', async () => {
66
- const db = { scheduler: {} };
83
+ const db = testAdapter({
84
+ scheduler: {},
85
+ withTransaction: async (fn) => fn(),
86
+ withReadSnapshot: async () => {
87
+ throw new Error('Unexpected editable read');
88
+ },
89
+ commands: {
90
+ collections: { lockCollectionRegistration: async () => { } },
91
+ documents: { publishSchedules: { lockDocuments: async () => { } } },
92
+ },
93
+ revisions: { ...unusedRevisionStore, assertCompatibleSchema: async () => { } },
94
+ });
67
95
  const localTask = defineRecurringTask({
68
96
  name: 'analytics.local',
69
97
  intervalMs: 3_600_000,
@@ -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
@@ -229,7 +230,7 @@ describe('startBylineScheduler', () => {
229
230
  const injectedRun = vi.fn(async () => { });
230
231
  const store = fakeStore({ claim: vi.fn(async ({ name }) => claimed(name)) });
231
232
  const core = {
232
- db: { scheduler: store },
233
+ db: testAdapter({ scheduler: store }),
233
234
  recurringTasks: [task('core-task', coreRun)],
234
235
  logger: silentLogger,
235
236
  };
@@ -242,7 +243,7 @@ describe('startBylineScheduler', () => {
242
243
  expect(coreRun).toHaveBeenCalledTimes(1);
243
244
  expect(injectedRun).not.toHaveBeenCalled();
244
245
  await controller.stop();
245
- expect(() => startBylineScheduler({ ...core, db: {} }, {
246
+ expect(() => startBylineScheduler({ ...core, db: testAdapter() }, {
246
247
  startupJitterMs: 0,
247
248
  })).toThrow(/scheduler capability/i);
248
249
  });
@@ -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
@@ -7,6 +8,7 @@
7
8
  */
8
9
  import { describe, expect, it, vi } from 'vitest';
9
10
  import { fingerprintCollection } from '../storage/collection-fingerprint.js';
11
+ import { unusedRevisionStore } from '../storage/revision-store.test-helper.js';
10
12
  import { ensureCollections } from './collection-bootstrap.js';
11
13
  // ---------------------------------------------------------------------------
12
14
  // Fixtures
@@ -37,10 +39,14 @@ function createMockDb(options) {
37
39
  ? vi.fn(fail)
38
40
  : vi.fn().mockResolvedValue([{ id: options.existingRow?.id ?? 'col-new' }]);
39
41
  const db = {
42
+ revisions: unusedRevisionStore,
43
+ withReadSnapshot: async () => {
44
+ throw new Error('Unexpected editable snapshot in this test');
45
+ },
40
46
  commands: {
41
- collections: { create, update, delete: vi.fn(fail) },
47
+ collections: { lockCollectionRegistration: vi.fn(fail), create, update, delete: vi.fn(fail) },
42
48
  documents: {
43
- publishSchedules: {},
49
+ publishSchedules: testAdapter().commands.documents.publishSchedules,
44
50
  createDocumentVersion: vi.fn(fail),
45
51
  updateDocumentPath: vi.fn(fail),
46
52
  setDocumentAvailableLocales: vi.fn(fail),
@@ -82,7 +88,10 @@ function createMockDb(options) {
82
88
  getCollectionById: vi.fn(fail),
83
89
  },
84
90
  documents: {
85
- publishSchedules: {},
91
+ publishSchedules: testAdapter().queries.documents.publishSchedules,
92
+ getDocumentRevision: async () => {
93
+ throw new Error('Unexpected revision read in this test');
94
+ },
86
95
  getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
87
96
  getDocumentById: vi.fn(fail),
88
97
  getCurrentVersionMetadata: vi.fn(fail),
@@ -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
@@ -6,6 +7,7 @@
6
7
  * Copyright (c) Infonomic Company Limited
7
8
  */
8
9
  import { describe, expect, it, vi } from 'vitest';
10
+ import { unusedRevisionStore } from '../storage/revision-store.test-helper.js';
9
11
  import { discoverCounterGroups } from './discover-counter-groups.js';
10
12
  // ---------------------------------------------------------------------------
11
13
  // Fixtures
@@ -20,10 +22,21 @@ function makeAdapter(options) {
20
22
  throw new Error('unexpected call');
21
23
  };
22
24
  const db = {
25
+ revisions: unusedRevisionStore,
26
+ withReadSnapshot: async () => {
27
+ throw new Error('Unexpected editable snapshot in this test');
28
+ },
23
29
  commands: {
24
- collections: { create: vi.fn(fail), update: vi.fn(fail), delete: vi.fn(fail) },
30
+ collections: {
31
+ lockCollectionRegistration: vi.fn(async () => {
32
+ throw new Error('Unexpected collection lock');
33
+ }),
34
+ create: vi.fn(fail),
35
+ update: vi.fn(fail),
36
+ delete: vi.fn(fail),
37
+ },
25
38
  documents: {
26
- publishSchedules: {},
39
+ publishSchedules: testAdapter().commands.documents.publishSchedules,
27
40
  createDocumentVersion: vi.fn(fail),
28
41
  updateDocumentPath: vi.fn(fail),
29
42
  setDocumentAvailableLocales: vi.fn(fail),
@@ -65,7 +78,10 @@ function makeAdapter(options) {
65
78
  getCollectionById: vi.fn(fail),
66
79
  },
67
80
  documents: {
68
- publishSchedules: {},
81
+ publishSchedules: testAdapter().queries.documents.publishSchedules,
82
+ getDocumentRevision: async () => {
83
+ throw new Error('Unexpected revision read in this test');
84
+ },
69
85
  getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
70
86
  getDocumentById: vi.fn(fail),
71
87
  getCurrentVersionMetadata: vi.fn(fail),
@@ -10,6 +10,7 @@ import type { DocumentLifecycleContext } from './context.js';
10
10
  /** Namespaced audit actions for document-grain changes. */
11
11
  export declare const AUDIT_ACTIONS: {
12
12
  readonly pathChanged: 'document.path.changed';
13
+ readonly orderChanged: 'document.order.changed';
13
14
  readonly localesChanged: 'document.locales.changed';
14
15
  readonly statusChanged: 'document.status.changed';
15
16
  readonly publishScheduled: 'document.publish.scheduled';
@@ -19,6 +19,7 @@ import { actorId } from './internals.js';
19
19
  /** Namespaced audit actions for document-grain changes. */
20
20
  export const AUDIT_ACTIONS = {
21
21
  pathChanged: 'document.path.changed',
22
+ orderChanged: 'document.order.changed',
22
23
  localesChanged: 'document.locales.changed',
23
24
  statusChanged: 'document.status.changed',
24
25
  publishScheduled: 'document.publish.scheduled',
@@ -7,12 +7,13 @@
7
7
  */
8
8
  import { ErrorCodes } from '../../lib/errors.js';
9
9
  import type { DocumentLifecycleContext } from './context.js';
10
- export type DocumentHookCommittedPhase = 'afterCreate' | 'afterUpdate' | 'afterSave';
10
+ export type DocumentHookCommittedPhase = 'afterCreate' | 'afterUpdate' | 'afterSave' | 'afterSystemFieldsChange' | 'afterStatusChange' | 'afterUnpublish';
11
11
  export type DocumentHookSideEffectCode = typeof ErrorCodes.STORAGE | typeof ErrorCodes.UNHANDLED;
12
12
  export interface DocumentHookCommittedDetails {
13
13
  phase: DocumentHookCommittedPhase;
14
14
  documentId: string;
15
15
  documentVersionId: string;
16
+ revision: number;
16
17
  sideEffectCode: DocumentHookSideEffectCode;
17
18
  }
18
19
  /** Read and validate the public, non-sensitive metadata from a committed hook failure. */
@@ -40,9 +40,25 @@ export function getDocumentHookCommittedDetails(error) {
40
40
  const documentId = readStringProperty(details, 'documentId');
41
41
  const documentVersionId = readStringProperty(details, 'documentVersionId');
42
42
  const failureCode = readStringProperty(details, 'sideEffectCode');
43
- if (phase !== 'afterCreate' && phase !== 'afterUpdate' && phase !== 'afterSave')
43
+ if (phase !== 'afterCreate' &&
44
+ phase !== 'afterUpdate' &&
45
+ phase !== 'afterSave' &&
46
+ phase !== 'afterSystemFieldsChange' &&
47
+ phase !== 'afterStatusChange' &&
48
+ phase !== 'afterUnpublish')
44
49
  return null;
45
- if (!documentId || !documentVersionId)
50
+ if (!documentId ||
51
+ (phase !== 'afterSystemFieldsChange' && !documentVersionId) ||
52
+ documentVersionId === undefined)
53
+ return null;
54
+ let revision;
55
+ try {
56
+ revision = Reflect.get(details, 'revision');
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 1)
46
62
  return null;
47
63
  if (failureCode !== ErrorCodes.STORAGE && failureCode !== ErrorCodes.UNHANDLED)
48
64
  return null;
@@ -51,6 +67,7 @@ export function getDocumentHookCommittedDetails(error) {
51
67
  documentId,
52
68
  documentVersionId,
53
69
  sideEffectCode: failureCode,
70
+ revision,
54
71
  };
55
72
  }
56
73
  /**
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { DocumentLifecycleContext } from './context.js';
9
9
  export interface CopyToLocaleResult {
10
+ revision: number;
10
11
  documentId: string;
11
12
  documentVersionId: string;
12
13
  /** Source locale read for the copy. */
@@ -56,6 +57,7 @@ export interface CopyToLocaleResult {
56
57
  */
57
58
  export declare function copyToLocale(ctx: DocumentLifecycleContext, params: {
58
59
  documentId: string;
60
+ expectedRevision: number;
59
61
  sourceLocale: string;
60
62
  targetLocale: string;
61
63
  overwrite: boolean;
@@ -7,13 +7,15 @@
7
7
  */
8
8
  import { resolveHooks } from '../../@types/index.js';
9
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
- import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
10
+ import { ERR_VALIDATION } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { getDefaultStatus } from '../../workflow/workflow.js';
13
13
  import { runCommittedDocumentHook } from './committed-hook.js';
14
14
  import { applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
15
15
  import { mergeLocaleData } from './merge-locale-data.js';
16
16
  import { persistExistingDocumentVersion } from './persistence.js';
17
+ import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
18
+ import { sameDocumentData } from './same-document-data.js';
17
19
  /**
18
20
  * Copy a document's content from one locale into another, in place on
19
21
  * the same document.
@@ -47,6 +49,7 @@ import { persistExistingDocumentVersion } from './persistence.js';
47
49
  * 8. `hooks.afterUpdate({ ..., copyToLocale })`.
48
50
  */
49
51
  export async function copyToLocale(ctx, params) {
52
+ params = { ...params };
50
53
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'copyToLocale' }, async () => {
51
54
  const { db, definition, collectionId, collectionPath } = ctx;
52
55
  assertActorCanPerform(ctx.requestContext, definition, 'update');
@@ -61,48 +64,22 @@ export async function copyToLocale(ctx, params) {
61
64
  }).log(ctx.logger);
62
65
  }
63
66
  // 1. Source read.
64
- const source = await db.queries.documents.getDocumentById({
65
- collection_id: collectionId,
66
- document_id: params.documentId,
67
+ const source = await readDocumentForMutation(ctx, {
68
+ ...params,
67
69
  locale: params.sourceLocale,
68
- reconstruct: true,
69
70
  lenient: true,
70
- requestContext: ctx.requestContext,
71
71
  });
72
- if (source == null) {
73
- throw ERR_NOT_FOUND({
74
- message: 'document not found in source locale',
75
- details: {
76
- documentId: params.documentId,
77
- sourceLocale: params.sourceLocale,
78
- collectionPath,
79
- },
80
- }).log(ctx.logger);
81
- }
82
- // 2. Target read — needed for both originalData (hooks) and to
83
- // preserve non-localized values + structural shape.
84
- const target = await db.queries.documents.getDocumentById({
85
- collection_id: collectionId,
86
- document_id: params.documentId,
72
+ const target = await readDocumentForMutation(ctx, {
73
+ ...params,
87
74
  locale: params.targetLocale,
88
- reconstruct: true,
89
75
  lenient: true,
90
- requestContext: ctx.requestContext,
91
76
  });
92
- if (target == null) {
93
- throw ERR_NOT_FOUND({
94
- message: 'document not found in target locale',
95
- details: {
96
- documentId: params.documentId,
97
- targetLocale: params.targetLocale,
98
- collectionPath,
99
- },
100
- }).log(ctx.logger);
101
- }
77
+ const previousVersionId = target.document_version_id;
102
78
  const sourceRecord = source;
103
79
  const targetRecord = target;
104
80
  const sourceFields = sourceRecord.fields ?? {};
105
81
  const targetFields = targetRecord.fields ?? {};
82
+ const baseline = structuredClone(targetFields);
106
83
  // 3. Merge.
107
84
  const merged = mergeLocaleData(definition.fields, sourceFields, targetFields, params.overwrite);
108
85
  // 4. Hooks see the target-locale view as originalData (consistent
@@ -123,18 +100,25 @@ export async function copyToLocale(ctx, params) {
123
100
  // storage primitive's cross-locale carry-forward fires for every
124
101
  // *other* locale (not source, not target — those rows are
125
102
  // rewritten by this call).
126
- const previousVersionId = targetRecord.document_version_id ?? undefined;
127
103
  await applyRichTextEmbed(ctx, merged.data);
128
- const writeResult = await persistExistingDocumentVersion(ctx, {
129
- documentId: params.documentId,
130
- action: 'copy_to_locale',
131
- documentData: merged.data,
132
- status: getDefaultStatus(definition),
133
- locale: params.targetLocale,
134
- previousVersionId,
104
+ const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId, locale: params.targetLocale }, async () => {
105
+ if ((targetRecord._availableVersionLocales?.includes(params.targetLocale) ||
106
+ targetRecord._localeAgnostic === true) &&
107
+ sameDocumentData(merged.data, baseline))
108
+ return { value: previousVersionId, changed: false };
109
+ const writeResult = await persistExistingDocumentVersion(ctx, {
110
+ documentId: params.documentId,
111
+ action: 'copy_to_locale',
112
+ documentData: merged.data,
113
+ status: getDefaultStatus(definition),
114
+ locale: params.targetLocale,
115
+ previousVersionId,
116
+ });
117
+ return { value: extractVersionId(writeResult.document), changed: true };
135
118
  });
136
- const documentVersionId = extractVersionId(writeResult.document);
137
- await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId: params.documentId, documentVersionId }, () => invokeHook(hooks?.afterUpdate, {
119
+ const documentVersionId = committed.value;
120
+ const revision = committed.revision;
121
+ await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId: params.documentId, documentVersionId, revision }, () => invokeHook(hooks?.afterUpdate, {
138
122
  data: merged.data,
139
123
  originalData: targetFields,
140
124
  collectionPath,
@@ -151,6 +135,7 @@ export async function copyToLocale(ctx, params) {
151
135
  sourceLocale: params.sourceLocale,
152
136
  targetLocale: params.targetLocale,
153
137
  fieldsUpdated: merged.fieldsUpdated,
138
+ revision,
154
139
  };
155
140
  });
156
141
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { DocumentLifecycleContext } from './context.js';
9
9
  export interface CreateDocumentResult {
10
+ revision: number;
10
11
  documentId: string;
11
12
  documentVersionId: string;
12
13
  }
@@ -18,7 +18,7 @@ import { requireTreeAuditCapability } from './audit.js';
18
18
  import { runCommittedDocumentHook } from './committed-hook.js';
19
19
  import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
20
20
  import { persistInitialDocumentVersion } from './persistence.js';
21
- import { appendTreeRoot } from './tree.js';
21
+ import { assertLifecycleTransactionOwnership } from './revision-guard.js';
22
22
  /**
23
23
  * Create a new document.
24
24
  *
@@ -37,6 +37,7 @@ export async function createDocument(ctx, params) {
37
37
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'createDocument' }, async () => {
38
38
  const { db, definition, collectionPath, defaultLocale } = ctx;
39
39
  assertActorCanPerform(ctx.requestContext, definition, 'create');
40
+ assertLifecycleTransactionOwnership(ctx);
40
41
  // Reject unsupported tree adapters before hooks, counters, or persistence
41
42
  // can create a document that cannot be placed and audited safely.
42
43
  if (definition.tree === true)
@@ -85,30 +86,13 @@ export async function createDocument(ctx, params) {
85
86
  }).catch((err) => rethrowPathConflict(db, err, resolvedPath, defaultLocale, 'create'));
86
87
  const documentId = extractDocumentId(result.document);
87
88
  const documentVersionId = extractVersionId(result.document);
88
- // `tree: true` collections place every document in the tree by default:
89
- // a new document is appended as a root (a top-level nav entry) so it is
90
- // never stranded in the "unplaced" limbo. This is a system step of create
91
- // (the actor already passed the `create` ability), so it uses the internal
92
- // placement primitive without an `update` re-assertion or separate tree
93
- // event (afterCreate covers invalidation). Placement + audit are atomic.
94
- // Post-version and best-effort: a runtime audit/storage failure leaves the
95
- // document created-but-unplaced and is logged. Missing capability was
96
- // rejected before persistence above.
97
- if (definition.tree === true) {
98
- try {
99
- await appendTreeRoot(ctx, documentId);
100
- }
101
- catch (err) {
102
- ctx.logger.error({ err, documentId }, 'failed to auto-place new document in tree');
103
- }
104
- }
105
- await runCommittedDocumentHook(ctx, { phase: 'afterCreate', documentId, documentVersionId }, () => invokeHook(hooks?.afterCreate, {
89
+ await runCommittedDocumentHook(ctx, { phase: 'afterCreate', documentId, documentVersionId, revision: 1 }, () => invokeHook(hooks?.afterCreate, {
106
90
  data,
107
91
  collectionPath,
108
92
  documentId,
109
93
  documentVersionId,
110
94
  path: resolvedPath,
111
95
  }));
112
- return { documentId, documentVersionId };
96
+ return { documentId, documentVersionId, revision: 1 };
113
97
  });
114
98
  }