@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
|
@@ -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:
|
|
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({
|
|
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 = {
|
|
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 = {
|
|
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: {
|
|
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',
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { ErrorCodes } from '../../lib/errors.js';
|
|
9
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
10
|
+
export type DocumentHookCommittedPhase = 'afterCreate' | 'afterUpdate' | 'afterSave' | 'afterSystemFieldsChange' | 'afterStatusChange' | 'afterUnpublish';
|
|
11
|
+
export type DocumentHookSideEffectCode = typeof ErrorCodes.STORAGE | typeof ErrorCodes.UNHANDLED;
|
|
12
|
+
export interface DocumentHookCommittedDetails {
|
|
13
|
+
phase: DocumentHookCommittedPhase;
|
|
14
|
+
documentId: string;
|
|
15
|
+
documentVersionId: string;
|
|
16
|
+
revision: number;
|
|
17
|
+
sideEffectCode: DocumentHookSideEffectCode;
|
|
18
|
+
}
|
|
19
|
+
/** Read and validate the public, non-sensitive metadata from a committed hook failure. */
|
|
20
|
+
export declare function getDocumentHookCommittedDetails(error: unknown): DocumentHookCommittedDetails | null;
|
|
21
|
+
/**
|
|
22
|
+
* Run a hook after its document version has committed, preserving rejection
|
|
23
|
+
* semantics while making the committed state machine-readable to hosts.
|
|
24
|
+
*/
|
|
25
|
+
export declare function runCommittedDocumentHook(ctx: DocumentLifecycleContext, details: Omit<DocumentHookCommittedDetails, 'sideEffectCode'>, callback: () => Promise<void>): Promise<void>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { DOCUMENT_HOOK_COMMITTED_MARKER, ERR_DOCUMENT_HOOK_COMMITTED, ErrorCodes, } from '../../lib/errors.js';
|
|
9
|
+
function readStringProperty(value, key) {
|
|
10
|
+
try {
|
|
11
|
+
if ((typeof value !== 'object' || value === null) && typeof value !== 'function') {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const property = Reflect.get(value, key);
|
|
15
|
+
return typeof property === 'string' ? property : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function sideEffectCode(error) {
|
|
22
|
+
return readStringProperty(error, 'code') === ErrorCodes.STORAGE
|
|
23
|
+
? ErrorCodes.STORAGE
|
|
24
|
+
: ErrorCodes.UNHANDLED;
|
|
25
|
+
}
|
|
26
|
+
/** Read and validate the public, non-sensitive metadata from a committed hook failure. */
|
|
27
|
+
export function getDocumentHookCommittedDetails(error) {
|
|
28
|
+
if (readStringProperty(error, 'code') !== ErrorCodes.DOCUMENT_HOOK_COMMITTED)
|
|
29
|
+
return null;
|
|
30
|
+
let details;
|
|
31
|
+
try {
|
|
32
|
+
details = Reflect.get(error, 'details');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (typeof details !== 'object' || details === null)
|
|
38
|
+
return null;
|
|
39
|
+
const phase = readStringProperty(details, 'phase');
|
|
40
|
+
const documentId = readStringProperty(details, 'documentId');
|
|
41
|
+
const documentVersionId = readStringProperty(details, 'documentVersionId');
|
|
42
|
+
const failureCode = readStringProperty(details, 'sideEffectCode');
|
|
43
|
+
if (phase !== 'afterCreate' &&
|
|
44
|
+
phase !== 'afterUpdate' &&
|
|
45
|
+
phase !== 'afterSave' &&
|
|
46
|
+
phase !== 'afterSystemFieldsChange' &&
|
|
47
|
+
phase !== 'afterStatusChange' &&
|
|
48
|
+
phase !== 'afterUnpublish')
|
|
49
|
+
return null;
|
|
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)
|
|
62
|
+
return null;
|
|
63
|
+
if (failureCode !== ErrorCodes.STORAGE && failureCode !== ErrorCodes.UNHANDLED)
|
|
64
|
+
return null;
|
|
65
|
+
return {
|
|
66
|
+
phase,
|
|
67
|
+
documentId,
|
|
68
|
+
documentVersionId,
|
|
69
|
+
sideEffectCode: failureCode,
|
|
70
|
+
revision,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run a hook after its document version has committed, preserving rejection
|
|
75
|
+
* semantics while making the committed state machine-readable to hosts.
|
|
76
|
+
*/
|
|
77
|
+
export async function runCommittedDocumentHook(ctx, details, callback) {
|
|
78
|
+
try {
|
|
79
|
+
await callback();
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
const committedError = ERR_DOCUMENT_HOOK_COMMITTED({
|
|
83
|
+
message: `${DOCUMENT_HOOK_COMMITTED_MARKER} document write committed but ${details.phase} failed`,
|
|
84
|
+
cause,
|
|
85
|
+
captureStack: true,
|
|
86
|
+
details: { ...details, sideEffectCode: sideEffectCode(cause) },
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
committedError.log(ctx.logger);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Diagnostic logging cannot erase the machine-readable committed result.
|
|
93
|
+
}
|
|
94
|
+
throw committedError;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -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,12 +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 {
|
|
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
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
13
14
|
import { applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
|
|
14
15
|
import { mergeLocaleData } from './merge-locale-data.js';
|
|
15
16
|
import { persistExistingDocumentVersion } from './persistence.js';
|
|
17
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
18
|
+
import { sameDocumentData } from './same-document-data.js';
|
|
16
19
|
/**
|
|
17
20
|
* Copy a document's content from one locale into another, in place on
|
|
18
21
|
* the same document.
|
|
@@ -46,6 +49,7 @@ import { persistExistingDocumentVersion } from './persistence.js';
|
|
|
46
49
|
* 8. `hooks.afterUpdate({ ..., copyToLocale })`.
|
|
47
50
|
*/
|
|
48
51
|
export async function copyToLocale(ctx, params) {
|
|
52
|
+
params = { ...params };
|
|
49
53
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'copyToLocale' }, async () => {
|
|
50
54
|
const { db, definition, collectionId, collectionPath } = ctx;
|
|
51
55
|
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
@@ -60,48 +64,22 @@ export async function copyToLocale(ctx, params) {
|
|
|
60
64
|
}).log(ctx.logger);
|
|
61
65
|
}
|
|
62
66
|
// 1. Source read.
|
|
63
|
-
const source = await
|
|
64
|
-
|
|
65
|
-
document_id: params.documentId,
|
|
67
|
+
const source = await readDocumentForMutation(ctx, {
|
|
68
|
+
...params,
|
|
66
69
|
locale: params.sourceLocale,
|
|
67
|
-
reconstruct: true,
|
|
68
70
|
lenient: true,
|
|
69
|
-
requestContext: ctx.requestContext,
|
|
70
71
|
});
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
message: 'document not found in source locale',
|
|
74
|
-
details: {
|
|
75
|
-
documentId: params.documentId,
|
|
76
|
-
sourceLocale: params.sourceLocale,
|
|
77
|
-
collectionPath,
|
|
78
|
-
},
|
|
79
|
-
}).log(ctx.logger);
|
|
80
|
-
}
|
|
81
|
-
// 2. Target read — needed for both originalData (hooks) and to
|
|
82
|
-
// preserve non-localized values + structural shape.
|
|
83
|
-
const target = await db.queries.documents.getDocumentById({
|
|
84
|
-
collection_id: collectionId,
|
|
85
|
-
document_id: params.documentId,
|
|
72
|
+
const target = await readDocumentForMutation(ctx, {
|
|
73
|
+
...params,
|
|
86
74
|
locale: params.targetLocale,
|
|
87
|
-
reconstruct: true,
|
|
88
75
|
lenient: true,
|
|
89
|
-
requestContext: ctx.requestContext,
|
|
90
76
|
});
|
|
91
|
-
|
|
92
|
-
throw ERR_NOT_FOUND({
|
|
93
|
-
message: 'document not found in target locale',
|
|
94
|
-
details: {
|
|
95
|
-
documentId: params.documentId,
|
|
96
|
-
targetLocale: params.targetLocale,
|
|
97
|
-
collectionPath,
|
|
98
|
-
},
|
|
99
|
-
}).log(ctx.logger);
|
|
100
|
-
}
|
|
77
|
+
const previousVersionId = target.document_version_id;
|
|
101
78
|
const sourceRecord = source;
|
|
102
79
|
const targetRecord = target;
|
|
103
80
|
const sourceFields = sourceRecord.fields ?? {};
|
|
104
81
|
const targetFields = targetRecord.fields ?? {};
|
|
82
|
+
const baseline = structuredClone(targetFields);
|
|
105
83
|
// 3. Merge.
|
|
106
84
|
const merged = mergeLocaleData(definition.fields, sourceFields, targetFields, params.overwrite);
|
|
107
85
|
// 4. Hooks see the target-locale view as originalData (consistent
|
|
@@ -122,18 +100,25 @@ export async function copyToLocale(ctx, params) {
|
|
|
122
100
|
// storage primitive's cross-locale carry-forward fires for every
|
|
123
101
|
// *other* locale (not source, not target — those rows are
|
|
124
102
|
// rewritten by this call).
|
|
125
|
-
const previousVersionId = targetRecord.document_version_id ?? undefined;
|
|
126
103
|
await applyRichTextEmbed(ctx, merged.data);
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 };
|
|
134
118
|
});
|
|
135
|
-
const documentVersionId =
|
|
136
|
-
|
|
119
|
+
const documentVersionId = committed.value;
|
|
120
|
+
const revision = committed.revision;
|
|
121
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId: params.documentId, documentVersionId, revision }, () => invokeHook(hooks?.afterUpdate, {
|
|
137
122
|
data: merged.data,
|
|
138
123
|
originalData: targetFields,
|
|
139
124
|
collectionPath,
|
|
@@ -143,13 +128,14 @@ export async function copyToLocale(ctx, params) {
|
|
|
143
128
|
// touches it. Read it off the target envelope.
|
|
144
129
|
path: targetRecord.path ?? '',
|
|
145
130
|
copyToLocale: copyToLocaleMarker,
|
|
146
|
-
});
|
|
131
|
+
}));
|
|
147
132
|
return {
|
|
148
133
|
documentId: params.documentId,
|
|
149
134
|
documentVersionId,
|
|
150
135
|
sourceLocale: params.sourceLocale,
|
|
151
136
|
targetLocale: params.targetLocale,
|
|
152
137
|
fieldsUpdated: merged.fieldsUpdated,
|
|
138
|
+
revision,
|
|
153
139
|
};
|
|
154
140
|
});
|
|
155
141
|
}
|