@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.
- package/dist/@types/db-types.d.ts +32 -1
- 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/@types/site-config.d.ts +3 -1
- package/dist/config/attach-hooks.test.node.js +2 -1
- package/dist/config/config-hooks.test.node.js +2 -1
- package/dist/core.js +13 -1
- 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 +15 -0
- package/dist/lib/errors.js +32 -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 +54 -12
- package/dist/scheduler/ticker.test.node.d.ts +0 -7
- package/dist/scheduler/ticker.test.node.js +3 -2
- package/dist/scheduler/validate-scheduler-config.d.ts +2 -0
- package/dist/scheduler/validate-scheduler-config.js +5 -0
- package/dist/scheduler/validate-scheduler-config.test.node.js +16 -0
- 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 +2 -1
- package/dist/services/document-lifecycle/committed-hook.js +19 -2
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
- package/dist/services/document-lifecycle/create.d.ts +1 -0
- package/dist/services/document-lifecycle/create.js +4 -20
- package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
- package/dist/services/document-lifecycle/delete-locale.js +35 -37
- 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 +48 -41
- package/dist/services/document-lifecycle/index.d.ts +3 -1
- package/dist/services/document-lifecycle/index.js +3 -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 +19 -25
- 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 +101 -80
- package/dist/services/document-lifecycle.test.node.js +401 -73
- 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 +74 -77
- 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 +67 -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/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/dist/validation/password-sign-in.test.node.d.ts +8 -0
- package/dist/validation/password-sign-in.test.node.js +21 -0
- package/dist/validation/shared.d.ts +5 -11
- package/dist/validation/shared.js +7 -1
- package/package.json +8 -8
|
@@ -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`.
|
|
@@ -31,16 +26,41 @@ const task = defineRecurringTask({
|
|
|
31
26
|
run: async () => { },
|
|
32
27
|
});
|
|
33
28
|
describe('initBylineCore scheduler wiring', () => {
|
|
29
|
+
it('rejects missing password-sign-in cleanup registration before database initialization', async () => {
|
|
30
|
+
const config = serverConfig(testAdapter({}));
|
|
31
|
+
config.passwordSignIn = {
|
|
32
|
+
resolveClientIp: () => '192.0.2.1',
|
|
33
|
+
limiter: {
|
|
34
|
+
requiredCleanupTask: 'auth.sign-in-counters.cleanup',
|
|
35
|
+
acquire: async () => () => { },
|
|
36
|
+
consume: async () => ({ allowed: true, retryAfterSeconds: 0 }),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
await expect(initBylineCore(config, {})).rejects.toThrow(/Register the limiter's cleanupTask/);
|
|
40
|
+
config.recurringTasks = [{ ...task, name: 'auth.sign-in-counters.cleanup' }];
|
|
41
|
+
await expect(initBylineCore(config, {})).rejects.toThrow(/scheduler capability/);
|
|
42
|
+
});
|
|
34
43
|
it('rejects recurring tasks registered against an adapter without the scheduler capability', async () => {
|
|
35
|
-
const config = serverConfig({});
|
|
44
|
+
const config = serverConfig(testAdapter({}));
|
|
36
45
|
config.recurringTasks = [task];
|
|
37
46
|
await expect(initBylineCore(config, {})).rejects.toThrow(/analytics\.rollup/);
|
|
38
47
|
});
|
|
39
48
|
it('gates scheduled publication on the scheduler capability and contributes its built-in task', async () => {
|
|
40
|
-
const unsupported = serverConfig({});
|
|
49
|
+
const unsupported = serverConfig(testAdapter({}));
|
|
41
50
|
unsupported.scheduledPublication = { enabled: true };
|
|
42
51
|
await expect(initBylineCore(unsupported, {})).rejects.toThrow(/documents\.publish-scheduled/);
|
|
43
|
-
const capable = serverConfig({
|
|
52
|
+
const capable = serverConfig(testAdapter({
|
|
53
|
+
scheduler: {},
|
|
54
|
+
withTransaction: async (fn) => fn(),
|
|
55
|
+
withReadSnapshot: async () => {
|
|
56
|
+
throw new Error('Unexpected editable read');
|
|
57
|
+
},
|
|
58
|
+
commands: {
|
|
59
|
+
collections: { lockCollectionRegistration: async () => { } },
|
|
60
|
+
documents: { publishSchedules: { lockDocuments: async () => { } } },
|
|
61
|
+
},
|
|
62
|
+
revisions: { ...unusedRevisionStore, assertCompatibleSchema: async () => { } },
|
|
63
|
+
}));
|
|
44
64
|
capable.scheduledPublication = { enabled: true };
|
|
45
65
|
capable.recurringTasks = [task];
|
|
46
66
|
const core = await initBylineCore(capable, {});
|
|
@@ -54,7 +74,18 @@ describe('initBylineCore scheduler wiring', () => {
|
|
|
54
74
|
});
|
|
55
75
|
});
|
|
56
76
|
it('populates core.recurringTasks with the registered set when the adapter is capable', async () => {
|
|
57
|
-
const db = {
|
|
77
|
+
const db = testAdapter({
|
|
78
|
+
scheduler: {},
|
|
79
|
+
withTransaction: async (fn) => fn(),
|
|
80
|
+
withReadSnapshot: async () => {
|
|
81
|
+
throw new Error('Unexpected editable read');
|
|
82
|
+
},
|
|
83
|
+
commands: {
|
|
84
|
+
collections: { lockCollectionRegistration: async () => { } },
|
|
85
|
+
documents: { publishSchedules: { lockDocuments: async () => { } } },
|
|
86
|
+
},
|
|
87
|
+
revisions: { ...unusedRevisionStore, assertCompatibleSchema: async () => { } },
|
|
88
|
+
});
|
|
58
89
|
const config = serverConfig(db);
|
|
59
90
|
config.recurringTasks = [task];
|
|
60
91
|
const core = await initBylineCore(config, {});
|
|
@@ -63,7 +94,18 @@ describe('initBylineCore scheduler wiring', () => {
|
|
|
63
94
|
expect(registered).toMatchObject({ name: 'analytics.rollup', intervalMs: 3_600_000 });
|
|
64
95
|
});
|
|
65
96
|
it('freezes the validated snapshot so post-init mutation of the caller input cannot alter it', async () => {
|
|
66
|
-
const db = {
|
|
97
|
+
const db = testAdapter({
|
|
98
|
+
scheduler: {},
|
|
99
|
+
withTransaction: async (fn) => fn(),
|
|
100
|
+
withReadSnapshot: async () => {
|
|
101
|
+
throw new Error('Unexpected editable read');
|
|
102
|
+
},
|
|
103
|
+
commands: {
|
|
104
|
+
collections: { lockCollectionRegistration: async () => { } },
|
|
105
|
+
documents: { publishSchedules: { lockDocuments: async () => { } } },
|
|
106
|
+
},
|
|
107
|
+
revisions: { ...unusedRevisionStore, assertCompatibleSchema: async () => { } },
|
|
108
|
+
});
|
|
67
109
|
const localTask = defineRecurringTask({
|
|
68
110
|
name: 'analytics.local',
|
|
69
111
|
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
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import type { PasswordSignInProtection } from '@byline/auth';
|
|
8
9
|
import type { ISchedulerStore, RecurringTaskDefinition } from './types.js';
|
|
9
10
|
/**
|
|
10
11
|
* Boot-time gate. Recurring tasks registered against an adapter that does not
|
|
@@ -12,6 +13,7 @@ import type { ISchedulerStore, RecurringTaskDefinition } from './types.js';
|
|
|
12
13
|
* fails loudly at `initBylineCore()` instead.
|
|
13
14
|
*/
|
|
14
15
|
export declare function validateSchedulerConfig(params: {
|
|
16
|
+
passwordSignIn?: PasswordSignInProtection;
|
|
15
17
|
tasks?: readonly RecurringTaskDefinition[];
|
|
16
18
|
adapter: {
|
|
17
19
|
scheduler?: ISchedulerStore;
|
|
@@ -13,6 +13,11 @@ import { validateRecurringTasks } from './validate-tasks.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export function validateSchedulerConfig(params) {
|
|
15
15
|
const tasks = params.tasks ?? [];
|
|
16
|
+
const cleanup = params.passwordSignIn?.limiter.requiredCleanupTask;
|
|
17
|
+
if (cleanup && !tasks.some((task) => task.name === cleanup)) {
|
|
18
|
+
throw new Error(`Password sign-in requires recurring task "${cleanup}". Register the limiter's cleanupTask ` +
|
|
19
|
+
'in recurringTasks and start the host scheduler (or invoke runDueTasks externally).');
|
|
20
|
+
}
|
|
16
21
|
if (tasks.length === 0)
|
|
17
22
|
return;
|
|
18
23
|
validateRecurringTasks(tasks);
|
|
@@ -16,6 +16,22 @@ const task = defineRecurringTask({
|
|
|
16
16
|
});
|
|
17
17
|
const store = {};
|
|
18
18
|
describe('validateSchedulerConfig', () => {
|
|
19
|
+
it('accepts matching cleanup registration and permits self-expiring custom limiters', () => {
|
|
20
|
+
const passwordSignIn = {
|
|
21
|
+
resolveClientIp: () => '192.0.2.1',
|
|
22
|
+
limiter: {
|
|
23
|
+
requiredCleanupTask: task.name,
|
|
24
|
+
acquire: async () => () => { },
|
|
25
|
+
consume: async () => ({ allowed: true, retryAfterSeconds: 0 }),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
expect(() => validateSchedulerConfig({ passwordSignIn, tasks: [task], adapter: { scheduler: store } })).not.toThrow();
|
|
29
|
+
const { requiredCleanupTask: _, ...ttlLimiter } = passwordSignIn.limiter;
|
|
30
|
+
expect(() => validateSchedulerConfig({
|
|
31
|
+
passwordSignIn: { ...passwordSignIn, limiter: ttlLimiter },
|
|
32
|
+
adapter: {},
|
|
33
|
+
})).not.toThrow();
|
|
34
|
+
});
|
|
19
35
|
it('passes when tasks are registered against a scheduler-capable adapter', () => {
|
|
20
36
|
expect(() => validateSchedulerConfig({ tasks: [task], adapter: { scheduler: store } })).not.toThrow();
|
|
21
37
|
});
|
|
@@ -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',
|
|
@@ -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' &&
|
|
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 ||
|
|
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 {
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 =
|
|
137
|
-
|
|
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
|
}
|