@byline/core 4.18.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/db-types.d.ts +38 -4
- package/dist/@types/document-revision.d.ts +92 -0
- package/dist/@types/document-revision.js +8 -0
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/config/attach-hooks.test.node.js +2 -1
- package/dist/config/config-hooks.test.node.js +2 -1
- package/dist/core.js +8 -0
- package/dist/core.test.node.js +27 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lib/errors.d.ts +25 -0
- package/dist/lib/errors.js +42 -0
- package/dist/lib/lock-conflict.test.node.d.ts +1 -0
- package/dist/lib/lock-conflict.test.node.js +31 -0
- package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
- package/dist/scheduler/run-due-tasks.test.node.js +3 -2
- package/dist/scheduler/scheduled-publication.d.ts +16 -1
- package/dist/scheduler/scheduled-publication.js +27 -1
- package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
- package/dist/scheduler/scheduled-publication.test.node.js +18 -3
- package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
- package/dist/scheduler/scheduler-boot.test.node.js +40 -12
- package/dist/scheduler/ticker.test.node.d.ts +0 -7
- package/dist/scheduler/ticker.test.node.js +3 -2
- package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
- package/dist/services/collection-bootstrap.test.node.js +12 -3
- package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
- package/dist/services/discover-counter-groups.test.node.js +19 -3
- package/dist/services/document-lifecycle/audit.d.ts +1 -0
- package/dist/services/document-lifecycle/audit.js +1 -0
- package/dist/services/document-lifecycle/committed-hook.d.ts +25 -0
- package/dist/services/document-lifecycle/committed-hook.js +96 -0
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +30 -44
- package/dist/services/document-lifecycle/create.d.ts +1 -0
- package/dist/services/document-lifecycle/create.js +6 -21
- package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
- package/dist/services/document-lifecycle/delete-locale.js +42 -39
- package/dist/services/document-lifecycle/delete.d.ts +8 -2
- package/dist/services/document-lifecycle/delete.js +24 -15
- package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
- package/dist/services/document-lifecycle/duplicate.js +55 -43
- package/dist/services/document-lifecycle/index.d.ts +5 -1
- package/dist/services/document-lifecycle/index.js +4 -1
- package/dist/services/document-lifecycle/persistence.js +18 -7
- package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
- package/dist/services/document-lifecycle/reorder.d.ts +11 -0
- package/dist/services/document-lifecycle/reorder.js +83 -0
- package/dist/services/document-lifecycle/restore.d.ts +2 -0
- package/dist/services/document-lifecycle/restore.js +21 -26
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
- package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
- package/dist/services/document-lifecycle/revision-guard.js +154 -0
- package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
- package/dist/services/document-lifecycle/same-document-data.js +17 -0
- package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
- package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
- package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
- package/dist/services/document-lifecycle/stale-document.js +95 -0
- package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
- package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
- package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
- package/dist/services/document-lifecycle/status.d.ts +5 -21
- package/dist/services/document-lifecycle/status.js +90 -133
- package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
- package/dist/services/document-lifecycle/system-fields.js +118 -116
- package/dist/services/document-lifecycle/tree.d.ts +9 -6
- package/dist/services/document-lifecycle/tree.js +65 -39
- package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/tree.test.node.js +152 -35
- package/dist/services/document-lifecycle/update.d.ts +14 -6
- package/dist/services/document-lifecycle/update.js +104 -82
- package/dist/services/document-lifecycle.test.node.js +556 -70
- package/dist/services/field-upload.d.ts +1 -0
- package/dist/services/field-upload.js +5 -1
- package/dist/services/field-upload.test.node.d.ts +0 -7
- package/dist/services/field-upload.test.node.js +38 -2
- package/dist/services/populate.test.node.d.ts +0 -7
- package/dist/services/populate.test.node.js +19 -3
- package/dist/services/richtext-populate.test.node.d.ts +0 -7
- package/dist/services/richtext-populate.test.node.js +3 -2
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
- package/dist/services/singleton-lifecycle/index.d.ts +1 -1
- package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
- package/dist/services/singleton-lifecycle/internals.js +75 -73
- package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
- package/dist/services/singleton-lifecycle/restore.js +3 -1
- package/dist/services/singleton-lifecycle/update.d.ts +6 -5
- package/dist/services/singleton-lifecycle/update.js +158 -91
- package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
- package/dist/services/singleton-lifecycle.test.node.js +75 -23
- package/dist/storage/db-adapter.test-helper.d.ts +26 -0
- package/dist/storage/db-adapter.test-helper.js +117 -0
- package/dist/storage/document-revision-capability.d.ts +3 -0
- package/dist/storage/document-revision-capability.js +15 -0
- package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
- package/dist/storage/document-revision-capability.test.node.js +53 -0
- package/dist/storage/document-revision.d.ts +12 -0
- package/dist/storage/document-revision.js +37 -0
- package/dist/storage/document-revision.test.node.d.ts +1 -0
- package/dist/storage/document-revision.test.node.js +48 -0
- package/dist/storage/document-version-parent.d.ts +20 -0
- package/dist/storage/document-version-parent.js +34 -0
- package/dist/storage/document-version-parent.test.node.d.ts +8 -0
- package/dist/storage/document-version-parent.test.node.js +60 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/read-snapshot.d.ts +3 -0
- package/dist/storage/read-snapshot.js +55 -0
- package/dist/storage/revision-store.test-helper.d.ts +3 -0
- package/dist/storage/revision-store.test-helper.js +16 -0
- package/package.json +2 -2
|
@@ -5,103 +5,170 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { resolveHooks } from '../../@types/index.js';
|
|
9
|
+
import { DbErrorCodes, ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ERR_VALIDATION, } from '../../lib/errors.js';
|
|
10
|
+
import { documentRevisionFromDatabase, parseDocumentRevision, } from '../../storage/document-revision.js';
|
|
10
11
|
import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
11
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
12
13
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
13
|
-
import {
|
|
14
|
+
import { runCommittedDocumentHook } from '../document-lifecycle/committed-hook.js';
|
|
15
|
+
import { applyRichTextEmbed, extractDocumentId, extractVersionId, } from '../document-lifecycle/internals.js';
|
|
14
16
|
import { persistExistingDocumentVersion, persistInitialDocumentVersion, } from '../document-lifecycle/persistence.js';
|
|
17
|
+
import { assertLifecycleTransactionOwnership } from '../document-lifecycle/revision-guard.js';
|
|
15
18
|
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
16
|
-
import { authorizeSingletonUpdate
|
|
17
|
-
/**
|
|
19
|
+
import { authorizeSingletonUpdate } from './internals.js';
|
|
20
|
+
/** Prepare outside locks; recheck the observed slot/document under S → D before committing. */
|
|
18
21
|
export async function updateSingleton(ctx, params) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
normalizeNumericFields(definition.fields, data);
|
|
78
|
-
await assignCounterValues({
|
|
79
|
-
fields: definition.fields,
|
|
80
|
-
data,
|
|
81
|
-
previousData: originalData ?? undefined,
|
|
82
|
-
counters: ctx.db.commands.counters,
|
|
83
|
-
});
|
|
84
|
-
await applyRichTextEmbed(ctx, data);
|
|
85
|
-
if (slot.documentId == null) {
|
|
86
|
-
return persistInitialDocumentVersion(ctx, {
|
|
87
|
-
action: 'create',
|
|
88
|
-
documentData: data,
|
|
89
|
-
path: crypto.randomUUID(),
|
|
90
|
-
status: getDefaultStatus(definition),
|
|
91
|
-
locale,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
return persistExistingDocumentVersion(ctx, {
|
|
95
|
-
documentId: slot.documentId,
|
|
96
|
-
action: 'update',
|
|
97
|
-
documentData: data,
|
|
98
|
-
status: getDefaultStatus(definition),
|
|
99
|
-
locale,
|
|
100
|
-
previousVersionId: slot.currentVersion?.document_version_id,
|
|
101
|
-
});
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
},
|
|
22
|
+
params = { ...params };
|
|
23
|
+
const definition = authorizeSingletonUpdate(ctx);
|
|
24
|
+
assertLifecycleTransactionOwnership(ctx);
|
|
25
|
+
const expectedEmpty = params.expectedState === 'empty';
|
|
26
|
+
if (expectedEmpty && params.expectedRevision !== undefined)
|
|
27
|
+
throw ERR_VALIDATION({ message: 'Specify an empty slot or a document revision, not both' });
|
|
28
|
+
if (params.expectedState !== undefined && !expectedEmpty)
|
|
29
|
+
throw ERR_VALIDATION({ message: 'Invalid singleton slot expectation' });
|
|
30
|
+
const expectedRevision = expectedEmpty
|
|
31
|
+
? undefined
|
|
32
|
+
: parseDocumentRevision(params.expectedRevision);
|
|
33
|
+
const locale = params.locale ?? ctx.defaultLocale;
|
|
34
|
+
const staleSlot = () => ERR_DOCUMENT_STALE({
|
|
35
|
+
message: 'This singleton has changed. Reload it before saving.',
|
|
36
|
+
details: {
|
|
37
|
+
reason: 'singleton_slot_changed',
|
|
38
|
+
singletonPath: ctx.collectionPath,
|
|
39
|
+
expectedState: 'empty',
|
|
40
|
+
currentState: 'document',
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const observed = await ctx.db.withReadSnapshot(async (queries) => {
|
|
44
|
+
const documentId = await queries.singletons.getMappedDocumentId(ctx.collectionId);
|
|
45
|
+
if (expectedEmpty && documentId !== null)
|
|
46
|
+
throw staleSlot();
|
|
47
|
+
if (!expectedEmpty && documentId === null)
|
|
48
|
+
throw ERR_NOT_FOUND({ message: 'Singleton document is unavailable' });
|
|
49
|
+
if (documentId === null)
|
|
50
|
+
return { documentId: null, current: null, revision: undefined };
|
|
51
|
+
const current = await queries.documents.getDocumentById({
|
|
52
|
+
collection_id: ctx.collectionId,
|
|
53
|
+
document_id: documentId,
|
|
54
|
+
locale,
|
|
55
|
+
reconstruct: true,
|
|
56
|
+
readMode: 'any',
|
|
57
|
+
lenient: true,
|
|
58
|
+
});
|
|
59
|
+
if (current === null)
|
|
60
|
+
throw ERR_NOT_FOUND({ message: 'Singleton document is unavailable' });
|
|
61
|
+
const revision = documentRevisionFromDatabase(await queries.documents.getDocumentRevision({
|
|
62
|
+
collection_id: ctx.collectionId,
|
|
63
|
+
document_id: documentId,
|
|
64
|
+
}));
|
|
65
|
+
if (revision !== expectedRevision)
|
|
66
|
+
throw ERR_DOCUMENT_STALE({
|
|
67
|
+
message: 'This singleton has changed. Reload it before saving.',
|
|
68
|
+
details: {
|
|
69
|
+
reason: 'revision_mismatch',
|
|
70
|
+
documentId,
|
|
71
|
+
expectedRevision: expectedRevision,
|
|
72
|
+
currentRevision: revision,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
return { documentId, current, revision };
|
|
76
|
+
});
|
|
77
|
+
if (expectedEmpty && locale !== ctx.defaultLocale)
|
|
78
|
+
throw ERR_VALIDATION({
|
|
79
|
+
message: `A singleton's first save must use the default content locale ('${ctx.defaultLocale}')`,
|
|
105
80
|
});
|
|
81
|
+
const previousVersionId = observed.current?.document_version_id;
|
|
82
|
+
const data = params.data;
|
|
83
|
+
const originalData = observed.current?.fields ?? null;
|
|
84
|
+
normaliseDateFields(data);
|
|
85
|
+
normalizeNumericFields(definition.fields, data);
|
|
86
|
+
const hooks = await resolveHooks(definition);
|
|
87
|
+
const before = {
|
|
88
|
+
data,
|
|
89
|
+
originalData,
|
|
90
|
+
singletonPath: ctx.collectionPath,
|
|
91
|
+
locale,
|
|
92
|
+
requestContext: ctx.requestContext,
|
|
93
|
+
isInitialSave: expectedEmpty,
|
|
94
|
+
operation: { type: 'save' },
|
|
95
|
+
documentId: observed.documentId,
|
|
96
|
+
};
|
|
97
|
+
const beforeHooks = hooks?.beforeSave == null
|
|
98
|
+
? []
|
|
99
|
+
: Array.isArray(hooks.beforeSave)
|
|
100
|
+
? hooks.beforeSave
|
|
101
|
+
: [hooks.beforeSave];
|
|
102
|
+
for (const hook of beforeHooks)
|
|
103
|
+
await hook(before);
|
|
104
|
+
normalizeNumericFields(definition.fields, data);
|
|
105
|
+
await assignCounterValues({
|
|
106
|
+
fields: definition.fields,
|
|
107
|
+
data,
|
|
108
|
+
previousData: originalData ?? undefined,
|
|
109
|
+
counters: ctx.db.commands.counters,
|
|
110
|
+
});
|
|
111
|
+
await applyRichTextEmbed(ctx, data);
|
|
112
|
+
const committed = await ctx.db.withTransaction(async () => {
|
|
113
|
+
await ctx.db.commands.singletons.lockSlot(ctx.collectionId);
|
|
114
|
+
const mappedDocumentId = await ctx.db.queries.singletons.getMappedDocumentId(ctx.collectionId);
|
|
115
|
+
if (expectedEmpty && mappedDocumentId !== null)
|
|
116
|
+
throw staleSlot();
|
|
117
|
+
if (mappedDocumentId !== observed.documentId)
|
|
118
|
+
throw ERR_NOT_FOUND({ message: 'Singleton document mapping changed; reload before saving' });
|
|
119
|
+
const locked = mappedDocumentId === null
|
|
120
|
+
? null
|
|
121
|
+
: (await ctx.db.revisions.lock([
|
|
122
|
+
{
|
|
123
|
+
documentId: mappedDocumentId,
|
|
124
|
+
collectionId: ctx.collectionId,
|
|
125
|
+
expectedRevision: expectedRevision,
|
|
126
|
+
previousVersionId,
|
|
127
|
+
locale,
|
|
128
|
+
},
|
|
129
|
+
]))[0];
|
|
130
|
+
const write = { documentData: data, status: getDefaultStatus(definition), locale };
|
|
131
|
+
const result = mappedDocumentId === null
|
|
132
|
+
? await persistInitialDocumentVersion(ctx, {
|
|
133
|
+
...write,
|
|
134
|
+
action: 'create',
|
|
135
|
+
path: crypto.randomUUID(),
|
|
136
|
+
})
|
|
137
|
+
: await persistExistingDocumentVersion(ctx, {
|
|
138
|
+
...write,
|
|
139
|
+
action: 'update',
|
|
140
|
+
documentId: mappedDocumentId,
|
|
141
|
+
previousVersionId,
|
|
142
|
+
});
|
|
143
|
+
const documentId = extractDocumentId(result.document);
|
|
144
|
+
const documentVersionId = extractVersionId(result.document);
|
|
145
|
+
if (!documentId || !documentVersionId)
|
|
146
|
+
throw new Error('Singleton persistence returned no document/version identity');
|
|
147
|
+
if (mappedDocumentId === null) {
|
|
148
|
+
try {
|
|
149
|
+
await ctx.db.commands.singletons.setMapping(ctx.collectionId, documentId);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (ctx.db.classifyError?.(error).code === DbErrorCodes.UNIQUE_VIOLATION)
|
|
153
|
+
throw staleSlot();
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const revision = locked === null ? 1 : (await ctx.db.revisions.advance(locked)).revision;
|
|
158
|
+
return { documentId, documentVersionId, revision };
|
|
159
|
+
});
|
|
160
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterSave', ...committed }, async () => {
|
|
161
|
+
const afterHooks = hooks?.afterSave == null
|
|
162
|
+
? []
|
|
163
|
+
: Array.isArray(hooks.afterSave)
|
|
164
|
+
? hooks.afterSave
|
|
165
|
+
: [hooks.afterSave];
|
|
166
|
+
for (const hook of afterHooks)
|
|
167
|
+
await hook({
|
|
168
|
+
...before,
|
|
169
|
+
documentId: committed.documentId,
|
|
170
|
+
documentVersionId: committed.documentVersionId,
|
|
171
|
+
});
|
|
106
172
|
});
|
|
173
|
+
return committed;
|
|
107
174
|
}
|
|
@@ -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,7 +8,8 @@
|
|
|
7
8
|
*/
|
|
8
9
|
import { AdminAuth, createRequestContext } from '@byline/auth';
|
|
9
10
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
-
import { DbErrorCodes } from '../lib/errors.js';
|
|
11
|
+
import { DbErrorCodes, ERR_DOCUMENT_STALE, ErrorCodes, } from '../lib/errors.js';
|
|
12
|
+
import { runReadSnapshot } from '../storage/read-snapshot.js';
|
|
11
13
|
import { copySingletonToLocale, resolveSingletonDocumentId, restoreSingletonVersion, updateSingleton, } from './singleton-lifecycle/index.js';
|
|
12
14
|
const noopLogger = {
|
|
13
15
|
log: vi.fn(),
|
|
@@ -47,10 +49,13 @@ function createHarness(options = {}) {
|
|
|
47
49
|
views.set('en', { fields: { title: 'Before', count: 1 } });
|
|
48
50
|
views.set('all', { fields: { title: { en: 'Before' }, count: 1 } });
|
|
49
51
|
}
|
|
52
|
+
let revision = 1;
|
|
50
53
|
let transactionDepth = 0;
|
|
51
54
|
let committedTransactions = 0;
|
|
52
55
|
let versionCounter = 0;
|
|
53
|
-
const withTransaction = vi.fn(
|
|
56
|
+
const withTransaction = vi.fn();
|
|
57
|
+
const transact = async (operation) => {
|
|
58
|
+
withTransaction();
|
|
54
59
|
const isOuter = transactionDepth === 0;
|
|
55
60
|
const snapshot = isOuter
|
|
56
61
|
? {
|
|
@@ -58,6 +63,7 @@ function createHarness(options = {}) {
|
|
|
58
63
|
currentVersion,
|
|
59
64
|
views: new Map(views),
|
|
60
65
|
versionCounter,
|
|
66
|
+
revision,
|
|
61
67
|
}
|
|
62
68
|
: null;
|
|
63
69
|
transactionDepth++;
|
|
@@ -77,10 +83,11 @@ function createHarness(options = {}) {
|
|
|
77
83
|
for (const [locale, view] of snapshot.views)
|
|
78
84
|
views.set(locale, view);
|
|
79
85
|
versionCounter = snapshot.versionCounter;
|
|
86
|
+
revision = snapshot.revision;
|
|
80
87
|
}
|
|
81
88
|
throw error;
|
|
82
89
|
}
|
|
83
|
-
}
|
|
90
|
+
};
|
|
84
91
|
const lockSlot = vi.fn(async () => {
|
|
85
92
|
expect(transactionDepth).toBeGreaterThan(0);
|
|
86
93
|
});
|
|
@@ -122,9 +129,38 @@ function createHarness(options = {}) {
|
|
|
122
129
|
});
|
|
123
130
|
const getDocumentByVersion = vi.fn(async (params) => versions.get(params.document_version_id));
|
|
124
131
|
const classifyError = vi.fn((_error) => ({ code: DbErrorCodes.UNKNOWN }));
|
|
125
|
-
const db = {
|
|
132
|
+
const db = testAdapter({
|
|
126
133
|
classifyError,
|
|
134
|
+
revisions: {
|
|
135
|
+
isInTransaction: () => transactionDepth > 0,
|
|
136
|
+
lock: vi.fn(async (targets) => targets.map((target) => {
|
|
137
|
+
expect(transactionDepth).toBeGreaterThan(0);
|
|
138
|
+
if (target.expectedRevision !== revision)
|
|
139
|
+
throw ERR_DOCUMENT_STALE({
|
|
140
|
+
message: 'Reload before saving',
|
|
141
|
+
details: {
|
|
142
|
+
reason: 'revision_mismatch',
|
|
143
|
+
documentId: target.documentId,
|
|
144
|
+
expectedRevision: target.expectedRevision,
|
|
145
|
+
currentRevision: revision,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
documentId: target.documentId,
|
|
150
|
+
collectionId: 'col-1',
|
|
151
|
+
revision,
|
|
152
|
+
currentVersionId: currentVersion?.document_version_id ?? null,
|
|
153
|
+
status: currentVersion?.status ?? null,
|
|
154
|
+
sourceLocale: 'en',
|
|
155
|
+
path: 'settings',
|
|
156
|
+
availableLocales: ['en'],
|
|
157
|
+
};
|
|
158
|
+
})),
|
|
159
|
+
advance: vi.fn(async (locked) => ({ documentId: locked.documentId, revision: ++revision })),
|
|
160
|
+
},
|
|
161
|
+
withReadSnapshot: ((callback) => runReadSnapshot(db.queries, callback)),
|
|
127
162
|
commands: {
|
|
163
|
+
collections: { lockCollectionRegistration: vi.fn(async () => { }) },
|
|
128
164
|
documents: {
|
|
129
165
|
createDocumentVersion,
|
|
130
166
|
publishSchedules: {
|
|
@@ -146,13 +182,14 @@ function createHarness(options = {}) {
|
|
|
146
182
|
queries: {
|
|
147
183
|
documents: {
|
|
148
184
|
getCurrentVersionMetadata,
|
|
185
|
+
getDocumentRevision: vi.fn(async () => revision),
|
|
149
186
|
getDocumentById,
|
|
150
187
|
getDocumentByVersion,
|
|
151
188
|
},
|
|
152
189
|
singletons: { getMappedDocumentId },
|
|
153
190
|
},
|
|
154
|
-
withTransaction,
|
|
155
|
-
};
|
|
191
|
+
withTransaction: transact,
|
|
192
|
+
});
|
|
156
193
|
const actor = new AdminAuth({
|
|
157
194
|
id: 'editor',
|
|
158
195
|
abilities: ['singletons.site-settings.update'],
|
|
@@ -200,7 +237,7 @@ describe('singleton lifecycle service', () => {
|
|
|
200
237
|
harness = createHarness({
|
|
201
238
|
hooks: {
|
|
202
239
|
beforeSave: (context) => {
|
|
203
|
-
expect(harness.isInTransaction()).toBe(
|
|
240
|
+
expect(harness.isInTransaction()).toBe(false);
|
|
204
241
|
contexts.push({ phase: 'before', ...context });
|
|
205
242
|
context.data.title = 'Mutated by hook';
|
|
206
243
|
},
|
|
@@ -211,8 +248,11 @@ describe('singleton lifecycle service', () => {
|
|
|
211
248
|
},
|
|
212
249
|
},
|
|
213
250
|
});
|
|
214
|
-
const result = await updateSingleton(harness.ctx, {
|
|
215
|
-
|
|
251
|
+
const result = await updateSingleton(harness.ctx, {
|
|
252
|
+
expectedState: 'empty',
|
|
253
|
+
data: { title: 'Incoming' },
|
|
254
|
+
});
|
|
255
|
+
expect(result).toEqual({ documentId: 'doc-1', documentVersionId: 'ver-1', revision: 1 });
|
|
216
256
|
expect(harness.lockSlot).toHaveBeenCalledOnce();
|
|
217
257
|
expect(harness.setMapping).toHaveBeenCalledWith('col-1', 'doc-1');
|
|
218
258
|
expect(harness.createDocumentVersion).toHaveBeenCalledWith(expect.objectContaining({
|
|
@@ -249,9 +289,9 @@ describe('singleton lifecycle service', () => {
|
|
|
249
289
|
code: DbErrorCodes.UNIQUE_VIOLATION,
|
|
250
290
|
constraint,
|
|
251
291
|
});
|
|
252
|
-
await expect(updateSingleton(harness.ctx, { data: { title: 'Losing write' } })).rejects.toMatchObject({
|
|
253
|
-
code: '
|
|
254
|
-
|
|
292
|
+
await expect(updateSingleton(harness.ctx, { expectedState: 'empty', data: { title: 'Losing write' } })).rejects.toMatchObject({
|
|
293
|
+
code: 'ERR_DOCUMENT_STALE',
|
|
294
|
+
details: { reason: 'singleton_slot_changed' },
|
|
255
295
|
});
|
|
256
296
|
expect(harness.classifyError).toHaveBeenCalledWith(uniqueViolation);
|
|
257
297
|
expect(harness.committedTransactions()).toBe(0);
|
|
@@ -268,7 +308,7 @@ describe('singleton lifecycle service', () => {
|
|
|
268
308
|
const harness = createHarness({ mapped: true, hooks: { beforeSave, afterSave } });
|
|
269
309
|
await updateSingleton(harness.ctx, {
|
|
270
310
|
data: { title: 'After', count: '2' },
|
|
271
|
-
|
|
311
|
+
expectedRevision: 1,
|
|
272
312
|
});
|
|
273
313
|
expect(harness.setMapping).not.toHaveBeenCalled();
|
|
274
314
|
expect(harness.createDocumentVersion).toHaveBeenCalledWith(expect.objectContaining({
|
|
@@ -289,39 +329,39 @@ describe('singleton lifecycle service', () => {
|
|
|
289
329
|
harness.ctx.requestContext = createRequestContext({
|
|
290
330
|
actor: new AdminAuth({ id: 'reader', abilities: ['singletons.site-settings.read'] }),
|
|
291
331
|
});
|
|
292
|
-
await expect(updateSingleton(harness.ctx, { data: { title: 'No' } })).rejects.toMatchObject({
|
|
332
|
+
await expect(updateSingleton(harness.ctx, { expectedState: 'empty', data: { title: 'No' } })).rejects.toMatchObject({
|
|
293
333
|
code: 'ERR_FORBIDDEN',
|
|
294
334
|
});
|
|
295
335
|
expect(harness.lockSlot).not.toHaveBeenCalled();
|
|
296
336
|
expect(harness.getMappedDocumentId).not.toHaveBeenCalled();
|
|
297
337
|
});
|
|
298
|
-
it('
|
|
338
|
+
it('requires the observed revision or an explicitly empty slot', async () => {
|
|
299
339
|
const current = createHarness({ mapped: true });
|
|
300
340
|
await expect(updateSingleton(current.ctx, {
|
|
301
341
|
data: { title: 'Yes' },
|
|
302
|
-
|
|
342
|
+
expectedRevision: 1,
|
|
303
343
|
})).resolves.toMatchObject({ documentId: 'doc-1' });
|
|
304
344
|
const stale = createHarness({ mapped: true });
|
|
305
|
-
await expect(updateSingleton(stale.ctx, { data: { title: 'No' },
|
|
345
|
+
await expect(updateSingleton(stale.ctx, { data: { title: 'No' }, expectedRevision: 2 })).rejects.toMatchObject({ code: 'ERR_DOCUMENT_STALE' });
|
|
306
346
|
expect(stale.createDocumentVersion).not.toHaveBeenCalled();
|
|
307
347
|
const unmaterialised = createHarness();
|
|
308
348
|
await expect(updateSingleton(unmaterialised.ctx, {
|
|
309
349
|
data: { title: 'No' },
|
|
310
|
-
|
|
311
|
-
})).rejects.toMatchObject({ code: '
|
|
350
|
+
expectedRevision: 1,
|
|
351
|
+
})).rejects.toMatchObject({ code: 'ERR_NOT_FOUND' });
|
|
312
352
|
expect(unmaterialised.createDocumentVersion).not.toHaveBeenCalled();
|
|
313
353
|
});
|
|
314
354
|
it('rejects a non-default first locale before document or mapping writes', async () => {
|
|
315
355
|
const beforeSave = vi.fn();
|
|
316
356
|
const harness = createHarness({ hooks: { beforeSave } });
|
|
317
|
-
await expect(updateSingleton(harness.ctx, { data: { title: 'ไทย' }, locale: 'th' })).rejects.toMatchObject({ code: 'ERR_VALIDATION' });
|
|
357
|
+
await expect(updateSingleton(harness.ctx, { expectedState: 'empty', data: { title: 'ไทย' }, locale: 'th' })).rejects.toMatchObject({ code: 'ERR_VALIDATION' });
|
|
318
358
|
expect(beforeSave).not.toHaveBeenCalled();
|
|
319
359
|
expect(harness.createDocumentVersion).not.toHaveBeenCalled();
|
|
320
360
|
expect(harness.setMapping).not.toHaveBeenCalled();
|
|
321
361
|
});
|
|
322
362
|
it('keeps a soft-deleted document mapped and rejects a replacement save', async () => {
|
|
323
363
|
const harness = createHarness({ mapped: true, softDeleted: true });
|
|
324
|
-
await expect(updateSingleton(harness.ctx, { data: { title: 'Replacement' } })).rejects.toMatchObject({ code: '
|
|
364
|
+
await expect(updateSingleton(harness.ctx, { expectedRevision: 1, data: { title: 'Replacement' } })).rejects.toMatchObject({ code: 'ERR_NOT_FOUND' });
|
|
325
365
|
expect(harness.mappedDocumentId()).toBe('doc-1');
|
|
326
366
|
expect(harness.createDocumentVersion).not.toHaveBeenCalled();
|
|
327
367
|
expect(harness.setMapping).not.toHaveBeenCalled();
|
|
@@ -334,7 +374,15 @@ describe('singleton lifecycle service', () => {
|
|
|
334
374
|
},
|
|
335
375
|
},
|
|
336
376
|
});
|
|
337
|
-
await expect(updateSingleton(harness.ctx, { data: { title: 'Committed' } })).rejects.
|
|
377
|
+
await expect(updateSingleton(harness.ctx, { expectedState: 'empty', data: { title: 'Committed' } })).rejects.toMatchObject({
|
|
378
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
379
|
+
details: {
|
|
380
|
+
phase: 'afterSave',
|
|
381
|
+
documentId: 'doc-1',
|
|
382
|
+
documentVersionId: 'ver-1',
|
|
383
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
384
|
+
},
|
|
385
|
+
});
|
|
338
386
|
expect(harness.committedTransactions()).toBe(1);
|
|
339
387
|
expect(harness.mappedDocumentId()).toBe('doc-1');
|
|
340
388
|
expect(harness.currentVersion()?.document_version_id).toBe('ver-1');
|
|
@@ -348,7 +396,10 @@ describe('singleton lifecycle service', () => {
|
|
|
348
396
|
document_id: 'doc-1',
|
|
349
397
|
fields: { title: { en: 'Historic', th: 'อดีต' }, count: 1 },
|
|
350
398
|
});
|
|
351
|
-
await restoreSingletonVersion(harness.ctx, {
|
|
399
|
+
await restoreSingletonVersion(harness.ctx, {
|
|
400
|
+
expectedRevision: 1,
|
|
401
|
+
sourceVersionId: 'ver-source',
|
|
402
|
+
});
|
|
352
403
|
const hookShape = expect.objectContaining({
|
|
353
404
|
data: { title: { en: 'Historic', th: 'อดีต' }, count: 1 },
|
|
354
405
|
originalData: { title: { en: 'Current', th: 'ปัจจุบัน' }, count: 2 },
|
|
@@ -367,6 +418,7 @@ describe('singleton lifecycle service', () => {
|
|
|
367
418
|
harness.views.set('en', { fields: { title: 'Source', count: 1 } });
|
|
368
419
|
harness.views.set('th', { fields: { title: 'Target', count: 1 } });
|
|
369
420
|
await copySingletonToLocale(harness.ctx, {
|
|
421
|
+
expectedRevision: 1,
|
|
370
422
|
sourceLocale: 'en',
|
|
371
423
|
targetLocale: 'th',
|
|
372
424
|
overwrite,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IDbAdapter } from '../@types/db-types.js';
|
|
2
|
+
/** Unit fixture only: unused capabilities throw, including transactions and snapshots.
|
|
3
|
+
* Tests must explicitly implement every operation they exercise. Live adapter tests
|
|
4
|
+
* supply the transaction/rollback evidence; this helper cannot simulate it. */
|
|
5
|
+
type Overrides = Partial<Omit<IDbAdapter, 'commands' | 'queries' | 'revisions'>> & {
|
|
6
|
+
revisions?: Partial<IDbAdapter['revisions']>;
|
|
7
|
+
commands?: {
|
|
8
|
+
collections?: Partial<IDbAdapter['commands']['collections']>;
|
|
9
|
+
documents?: Partial<Omit<IDbAdapter['commands']['documents'], 'publishSchedules'>> & {
|
|
10
|
+
publishSchedules?: Partial<IDbAdapter['commands']['documents']['publishSchedules']>;
|
|
11
|
+
};
|
|
12
|
+
counters?: Partial<IDbAdapter['commands']['counters']>;
|
|
13
|
+
audit?: Partial<IDbAdapter['commands']['audit']>;
|
|
14
|
+
singletons?: Partial<IDbAdapter['commands']['singletons']>;
|
|
15
|
+
};
|
|
16
|
+
queries?: {
|
|
17
|
+
collections?: Partial<IDbAdapter['queries']['collections']>;
|
|
18
|
+
documents?: Partial<Omit<IDbAdapter['queries']['documents'], 'publishSchedules'>> & {
|
|
19
|
+
publishSchedules?: Partial<IDbAdapter['queries']['documents']['publishSchedules']>;
|
|
20
|
+
};
|
|
21
|
+
audit?: Partial<IDbAdapter['queries']['audit']>;
|
|
22
|
+
singletons?: Partial<IDbAdapter['queries']['singletons']>;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export declare function testAdapter(overrides?: Overrides): IDbAdapter;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export function testAdapter(overrides = {}) {
|
|
2
|
+
const unused = () => {
|
|
3
|
+
throw new Error('Unexpected adapter operation in unit fixture');
|
|
4
|
+
};
|
|
5
|
+
return {
|
|
6
|
+
withTransaction: unused,
|
|
7
|
+
withReadSnapshot: unused,
|
|
8
|
+
...overrides,
|
|
9
|
+
revisions: {
|
|
10
|
+
readStructure: unused,
|
|
11
|
+
assertCompatibleSchema: unused,
|
|
12
|
+
isInTransaction: () => false,
|
|
13
|
+
lock: unused,
|
|
14
|
+
advance: unused,
|
|
15
|
+
...overrides.revisions,
|
|
16
|
+
},
|
|
17
|
+
commands: {
|
|
18
|
+
collections: {
|
|
19
|
+
lockCollectionRegistration: unused,
|
|
20
|
+
create: unused,
|
|
21
|
+
update: unused,
|
|
22
|
+
delete: unused,
|
|
23
|
+
...overrides.commands?.collections,
|
|
24
|
+
},
|
|
25
|
+
documents: {
|
|
26
|
+
createDocumentVersion: unused,
|
|
27
|
+
updateDocumentPath: unused,
|
|
28
|
+
setDocumentAvailableLocales: unused,
|
|
29
|
+
setDocumentStatus: unused,
|
|
30
|
+
archivePublishedVersions: unused,
|
|
31
|
+
softDeleteDocument: unused,
|
|
32
|
+
restoreSoftDeletedDocument: unused,
|
|
33
|
+
deleteDocumentLocale: unused,
|
|
34
|
+
setOrderKey: unused,
|
|
35
|
+
placeTreeNode: unused,
|
|
36
|
+
removeFromTree: unused,
|
|
37
|
+
promoteChildrenAndRemoveFromTree: unused,
|
|
38
|
+
...overrides.commands?.documents,
|
|
39
|
+
publishSchedules: {
|
|
40
|
+
lockDocuments: unused,
|
|
41
|
+
schedule: unused,
|
|
42
|
+
confirm: unused,
|
|
43
|
+
cancel: unused,
|
|
44
|
+
suspendForContentEdit: unused,
|
|
45
|
+
claimDue: unused,
|
|
46
|
+
lockClaim: unused,
|
|
47
|
+
deleteClaim: unused,
|
|
48
|
+
suspendClaimForContentEdit: unused,
|
|
49
|
+
releaseClaim: unused,
|
|
50
|
+
...overrides.commands?.documents?.publishSchedules,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
counters: {
|
|
54
|
+
ensureCounterGroup: unused,
|
|
55
|
+
nextCounterValue: unused,
|
|
56
|
+
nextScopedCounterValue: unused,
|
|
57
|
+
...overrides.commands?.counters,
|
|
58
|
+
},
|
|
59
|
+
audit: {
|
|
60
|
+
append: unused,
|
|
61
|
+
...overrides.commands?.audit,
|
|
62
|
+
},
|
|
63
|
+
singletons: {
|
|
64
|
+
lockSlot: unused,
|
|
65
|
+
setMapping: unused,
|
|
66
|
+
clearMapping: unused,
|
|
67
|
+
...overrides.commands?.singletons,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
queries: {
|
|
71
|
+
collections: {
|
|
72
|
+
getAllCollections: unused,
|
|
73
|
+
getCollectionByPath: unused,
|
|
74
|
+
getCollectionById: unused,
|
|
75
|
+
...overrides.queries?.collections,
|
|
76
|
+
},
|
|
77
|
+
documents: {
|
|
78
|
+
getDocumentRevision: unused,
|
|
79
|
+
getDocumentSystemFieldsForUpdate: unused,
|
|
80
|
+
getDocumentById: unused,
|
|
81
|
+
getCurrentVersionMetadata: unused,
|
|
82
|
+
getCurrentPath: unused,
|
|
83
|
+
getDocumentByPath: unused,
|
|
84
|
+
getDocumentByVersion: unused,
|
|
85
|
+
getDocumentsByVersionIds: unused,
|
|
86
|
+
getDocumentsByDocumentIds: unused,
|
|
87
|
+
getDocumentHistory: unused,
|
|
88
|
+
getPublishedVersion: unused,
|
|
89
|
+
getPublishedDocumentIds: unused,
|
|
90
|
+
getDocumentCountsByStatus: unused,
|
|
91
|
+
findDocuments: unused,
|
|
92
|
+
getLastOrderKey: unused,
|
|
93
|
+
getNeighborOrderKeys: unused,
|
|
94
|
+
getCanonicalDocumentOrder: unused,
|
|
95
|
+
getTreeAncestors: unused,
|
|
96
|
+
getTreeChildren: unused,
|
|
97
|
+
getTreeParent: unused,
|
|
98
|
+
getTreeSubtree: unused,
|
|
99
|
+
...overrides.queries?.documents,
|
|
100
|
+
publishSchedules: {
|
|
101
|
+
get: unused,
|
|
102
|
+
list: unused,
|
|
103
|
+
...overrides.queries?.documents?.publishSchedules,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
audit: {
|
|
107
|
+
getDocumentAuditLog: unused,
|
|
108
|
+
findAuditLog: unused,
|
|
109
|
+
...overrides.queries?.audit,
|
|
110
|
+
},
|
|
111
|
+
singletons: {
|
|
112
|
+
getMappedDocumentId: unused,
|
|
113
|
+
...overrides.queries?.singletons,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|