@byline/core 4.19.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +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 +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 +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/package.json +2 -2
|
@@ -10,7 +10,7 @@ import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_DATABASE, ERR_STORAGE, ERR_VALIDATION } from '../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../lib/logger.js';
|
|
12
12
|
import { resolveUploadFilename } from '../utils/slugify-filename.js';
|
|
13
|
-
import { createDocument } from './document-lifecycle/index.js';
|
|
13
|
+
import { createDocument, getDocumentHookCommittedDetails, } from './document-lifecycle/index.js';
|
|
14
14
|
function isMimeTypeAllowed(mimeType, allowedTypes) {
|
|
15
15
|
return allowedTypes.some((allowed) => {
|
|
16
16
|
if (allowed === '*/*')
|
|
@@ -339,10 +339,14 @@ export async function uploadField(ctx, params) {
|
|
|
339
339
|
return {
|
|
340
340
|
documentId: result.documentId,
|
|
341
341
|
documentVersionId: result.documentVersionId,
|
|
342
|
+
revision: result.revision,
|
|
342
343
|
storedFile: storedFileValue,
|
|
343
344
|
};
|
|
344
345
|
}
|
|
345
346
|
catch (err) {
|
|
347
|
+
// A committed document owns these files even when its after-hook fails.
|
|
348
|
+
if (getDocumentHookCommittedDetails(err) !== null)
|
|
349
|
+
throw err;
|
|
346
350
|
logger.error({ err, collectionPath, fieldName }, 'document creation failed — rolling back storage files');
|
|
347
351
|
const rollbackPaths = [storedFile.storagePath, ...variantStoragePaths];
|
|
348
352
|
for (const storagePath of rollbackPaths) {
|
|
@@ -1,8 +1 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
-
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
-
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
-
*
|
|
6
|
-
* Copyright (c) Infonomic Company Limited
|
|
7
|
-
*/
|
|
8
1
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testAdapter } from '../storage/db-adapter.test-helper.js';
|
|
1
2
|
/**
|
|
2
3
|
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
4
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
@@ -9,6 +10,7 @@ import { AdminAuth, createRequestContext, createSuperAdminContext } from '@bylin
|
|
|
9
10
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
11
|
import { commitHookAttachment, prepareHookAttachment } from '../config/attach-hooks.js';
|
|
11
12
|
import { BylineError, ErrorCodes } from '../lib/errors.js';
|
|
13
|
+
import { unusedRevisionStore } from '../storage/revision-store.test-helper.js';
|
|
12
14
|
import { uploadField } from './field-upload.js';
|
|
13
15
|
const uploadCollection = {
|
|
14
16
|
path: 'media',
|
|
@@ -55,14 +57,19 @@ function createMockDb() {
|
|
|
55
57
|
fieldCount: 2,
|
|
56
58
|
});
|
|
57
59
|
const db = {
|
|
60
|
+
revisions: unusedRevisionStore,
|
|
61
|
+
withReadSnapshot: async () => {
|
|
62
|
+
throw new Error('Unexpected editable snapshot in this test');
|
|
63
|
+
},
|
|
58
64
|
commands: {
|
|
59
65
|
collections: {
|
|
66
|
+
lockCollectionRegistration: vi.fn(async () => { }),
|
|
60
67
|
create: vi.fn(),
|
|
61
68
|
update: vi.fn(),
|
|
62
69
|
delete: vi.fn(),
|
|
63
70
|
},
|
|
64
71
|
documents: {
|
|
65
|
-
publishSchedules:
|
|
72
|
+
publishSchedules: testAdapter().commands.documents.publishSchedules,
|
|
66
73
|
createDocumentVersion,
|
|
67
74
|
updateDocumentPath: vi.fn(),
|
|
68
75
|
setDocumentAvailableLocales: vi.fn(),
|
|
@@ -104,7 +111,10 @@ function createMockDb() {
|
|
|
104
111
|
getCollectionById: vi.fn(),
|
|
105
112
|
},
|
|
106
113
|
documents: {
|
|
107
|
-
publishSchedules:
|
|
114
|
+
publishSchedules: testAdapter().queries.documents.publishSchedules,
|
|
115
|
+
getDocumentRevision: async () => {
|
|
116
|
+
throw new Error('Unexpected revision read in this test');
|
|
117
|
+
},
|
|
108
118
|
getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
|
|
109
119
|
getDocumentById: vi.fn(),
|
|
110
120
|
getCurrentVersionMetadata: vi.fn(),
|
|
@@ -392,6 +402,7 @@ describe('uploadField service', () => {
|
|
|
392
402
|
shouldCreateDocument: true,
|
|
393
403
|
locale: 'en',
|
|
394
404
|
});
|
|
405
|
+
expect(result.revision).toBe(1);
|
|
395
406
|
// beforeStore receives the rich context.
|
|
396
407
|
expect(beforeStore).toHaveBeenCalledWith(expect.objectContaining({
|
|
397
408
|
fieldName: 'image',
|
|
@@ -578,6 +589,31 @@ describe('uploadField service', () => {
|
|
|
578
589
|
expect(del).not.toHaveBeenCalled();
|
|
579
590
|
expect(result.storedFile.filename).toBe('hero.png');
|
|
580
591
|
});
|
|
592
|
+
it('preserves uploaded files and the receipt after a committed creation hook fails', async () => {
|
|
593
|
+
const { ctx, createDocumentVersion, del, storedPaths } = buildCtx({
|
|
594
|
+
definition: {
|
|
595
|
+
...uploadCollection,
|
|
596
|
+
hooks: {
|
|
597
|
+
afterCreate: () => {
|
|
598
|
+
throw new Error('index unavailable');
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
await expect(uploadField(ctx, {
|
|
604
|
+
buffer: Buffer.from('png'),
|
|
605
|
+
originalFilename: 'hero.png',
|
|
606
|
+
mimeType: 'image/png',
|
|
607
|
+
fileSize: 3,
|
|
608
|
+
shouldCreateDocument: true,
|
|
609
|
+
})).rejects.toMatchObject({
|
|
610
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
611
|
+
details: { phase: 'afterCreate', revision: 1 },
|
|
612
|
+
});
|
|
613
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
614
|
+
expect(del).not.toHaveBeenCalled();
|
|
615
|
+
expect(storedPaths.has('media/original.png')).toBe(true);
|
|
616
|
+
});
|
|
581
617
|
it('rolls back stored files when document creation fails', async () => {
|
|
582
618
|
const { db } = createMockDb();
|
|
583
619
|
db.commands.documents.createDocumentVersion = vi.fn().mockRejectedValue(new Error('db down'));
|
|
@@ -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
|
|
@@ -8,6 +9,7 @@
|
|
|
8
9
|
import { AdminAuth, createRequestContext } from '@byline/auth';
|
|
9
10
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
11
|
import { BylineError, ErrorCodes } from '../lib/errors.js';
|
|
12
|
+
import { unusedRevisionStore } from '../storage/revision-store.test-helper.js';
|
|
11
13
|
import { __internal, createReadContext, populateDocuments } from './populate.js';
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
13
15
|
// Fixtures
|
|
@@ -141,10 +143,21 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
|
|
|
141
143
|
return path ? { id, path } : null;
|
|
142
144
|
});
|
|
143
145
|
const db = {
|
|
146
|
+
revisions: unusedRevisionStore,
|
|
147
|
+
withReadSnapshot: async () => {
|
|
148
|
+
throw new Error('Unexpected editable snapshot in this test');
|
|
149
|
+
},
|
|
144
150
|
commands: {
|
|
145
|
-
collections: {
|
|
151
|
+
collections: {
|
|
152
|
+
lockCollectionRegistration: vi.fn(async () => {
|
|
153
|
+
throw new Error('Unexpected collection lock');
|
|
154
|
+
}),
|
|
155
|
+
create: vi.fn(),
|
|
156
|
+
update: vi.fn(),
|
|
157
|
+
delete: vi.fn(),
|
|
158
|
+
},
|
|
146
159
|
documents: {
|
|
147
|
-
publishSchedules:
|
|
160
|
+
publishSchedules: testAdapter().commands.documents.publishSchedules,
|
|
148
161
|
createDocumentVersion: vi.fn(),
|
|
149
162
|
updateDocumentPath: vi.fn(),
|
|
150
163
|
setDocumentAvailableLocales: vi.fn(),
|
|
@@ -186,7 +199,10 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
|
|
|
186
199
|
getCollectionById,
|
|
187
200
|
},
|
|
188
201
|
documents: {
|
|
189
|
-
publishSchedules:
|
|
202
|
+
publishSchedules: testAdapter().queries.documents.publishSchedules,
|
|
203
|
+
getDocumentRevision: async () => {
|
|
204
|
+
throw new Error('Unexpected revision read in this test');
|
|
205
|
+
},
|
|
190
206
|
getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
|
|
191
207
|
getDocumentById: vi.fn(),
|
|
192
208
|
getCurrentVersionMetadata: vi.fn(),
|
|
@@ -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
|
|
@@ -126,12 +127,12 @@ describe('createRichTextDocumentReader', () => {
|
|
|
126
127
|
const getCollectionByPath = vi.fn().mockResolvedValue({ id: 'media-id', path: 'media' });
|
|
127
128
|
const getDocumentsByDocumentIds = vi.fn().mockResolvedValue([]);
|
|
128
129
|
return {
|
|
129
|
-
db: {
|
|
130
|
+
db: testAdapter({
|
|
130
131
|
queries: {
|
|
131
132
|
collections: { getCollectionByPath },
|
|
132
133
|
documents: { getDocumentsByDocumentIds },
|
|
133
134
|
},
|
|
134
|
-
},
|
|
135
|
+
}),
|
|
135
136
|
getCollectionByPath,
|
|
136
137
|
getDocumentsByDocumentIds,
|
|
137
138
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { DocumentLifecycleContext } from '../document-lifecycle/context.js';
|
|
9
9
|
import type { SingletonSaveResult } from './internals.js';
|
|
10
10
|
export interface CopySingletonToLocaleParams {
|
|
11
|
+
expectedRevision: number;
|
|
11
12
|
sourceLocale: string;
|
|
12
13
|
targetLocale: string;
|
|
13
14
|
overwrite: boolean;
|
|
@@ -14,6 +14,7 @@ import { persistExistingDocumentVersion } from '../document-lifecycle/persistenc
|
|
|
14
14
|
import { authorizeSingletonUpdate, commitSingletonSave } from './internals.js';
|
|
15
15
|
/** Copy localized singleton fields to another locale on the mapped document. */
|
|
16
16
|
export async function copySingletonToLocale(ctx, params) {
|
|
17
|
+
params = { ...params };
|
|
17
18
|
return withLogContext({ domain: 'services', module: 'singleton-lifecycle', function: 'copySingletonToLocale' }, async () => {
|
|
18
19
|
const definition = authorizeSingletonUpdate(ctx);
|
|
19
20
|
if (params.sourceLocale === params.targetLocale) {
|
|
@@ -25,7 +26,13 @@ export async function copySingletonToLocale(ctx, params) {
|
|
|
25
26
|
return commitSingletonSave({
|
|
26
27
|
ctx,
|
|
27
28
|
definition,
|
|
28
|
-
|
|
29
|
+
expectedRevision: params.expectedRevision,
|
|
30
|
+
operation: {
|
|
31
|
+
type: 'copyToLocale',
|
|
32
|
+
sourceLocale: params.sourceLocale,
|
|
33
|
+
targetLocale: params.targetLocale,
|
|
34
|
+
overwrite: params.overwrite,
|
|
35
|
+
},
|
|
29
36
|
prepare: async (slot) => {
|
|
30
37
|
if (slot.documentId == null || slot.currentVersion == null) {
|
|
31
38
|
throw ERR_NOT_FOUND({
|
|
@@ -82,8 +89,8 @@ export async function copySingletonToLocale(ctx, params) {
|
|
|
82
89
|
data: merged.data,
|
|
83
90
|
originalData,
|
|
84
91
|
locale: params.targetLocale,
|
|
92
|
+
prepareWrite: () => applyRichTextEmbed(ctx, merged.data),
|
|
85
93
|
write: async () => {
|
|
86
|
-
await applyRichTextEmbed(ctx, merged.data);
|
|
87
94
|
return persistExistingDocumentVersion(ctx, {
|
|
88
95
|
documentId: slot.documentId,
|
|
89
96
|
action: 'copy_to_locale',
|
|
@@ -11,4 +11,4 @@ export { restoreSingletonVersion } from './restore.js';
|
|
|
11
11
|
export { updateSingleton } from './update.js';
|
|
12
12
|
export type { CopySingletonToLocaleParams } from './copy-to-locale.js';
|
|
13
13
|
export type { SingletonSaveResult } from './internals.js';
|
|
14
|
-
export type { UpdateSingletonParams } from './update.js';
|
|
14
|
+
export type { UpdateSingletonParams, UpdateSingletonResult } from './update.js';
|
|
@@ -10,6 +10,7 @@ import type { DocumentLifecycleContext } from '../document-lifecycle/context.js'
|
|
|
10
10
|
type VersionWriteResult = Awaited<ReturnType<IDocumentCommands['createDocumentVersion']>>;
|
|
11
11
|
type CurrentVersionMetadata = NonNullable<Awaited<ReturnType<IDocumentQueries['getCurrentVersionMetadata']>>>;
|
|
12
12
|
export interface SingletonSaveResult {
|
|
13
|
+
revision: number;
|
|
13
14
|
documentId: string;
|
|
14
15
|
documentVersionId: string;
|
|
15
16
|
}
|
|
@@ -21,6 +22,7 @@ interface PreparedSingletonSave {
|
|
|
21
22
|
data: Record<string, any>;
|
|
22
23
|
originalData: Record<string, any> | null;
|
|
23
24
|
locale: string;
|
|
25
|
+
prepareWrite: () => Promise<void>;
|
|
24
26
|
write: () => Promise<VersionWriteResult>;
|
|
25
27
|
}
|
|
26
28
|
/**
|
|
@@ -34,15 +36,11 @@ export declare function authorizeSingletonUpdate(ctx: DocumentLifecycleContext):
|
|
|
34
36
|
* or hooks. Client-facing callers must authorize before invoking this resolver.
|
|
35
37
|
*/
|
|
36
38
|
export declare function resolveSingletonDocumentId(ctx: DocumentLifecycleContext): Promise<string | null>;
|
|
37
|
-
/**
|
|
38
|
-
* Coordinate one singleton version write under the registered-slot lock.
|
|
39
|
-
* `prepare` runs after the mapping and current version have been read while
|
|
40
|
-
* locked. The before hook and write remain inside the outer transaction; the
|
|
41
|
-
* after hook cannot run until that transaction has committed.
|
|
42
|
-
*/
|
|
39
|
+
/** Prepare singleton restore/copy outside locks, then commit under S → D → P. */
|
|
43
40
|
export declare function commitSingletonSave(params: {
|
|
44
41
|
ctx: DocumentLifecycleContext;
|
|
45
42
|
definition: SingletonDefinition;
|
|
43
|
+
expectedRevision: number;
|
|
46
44
|
operation: SingletonSaveOperation;
|
|
47
45
|
prepare: (slot: LockedSingletonSlot) => Promise<PreparedSingletonSave>;
|
|
48
46
|
}): Promise<SingletonSaveResult>;
|
|
@@ -7,9 +7,12 @@
|
|
|
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_DOCUMENT_STALE, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
|
+
import { documentRevisionFromDatabase, parseDocumentRevision, } from '../../storage/document-revision.js';
|
|
11
12
|
import { runCommittedDocumentHook } from '../document-lifecycle/committed-hook.js';
|
|
12
13
|
import { extractDocumentId, extractVersionId } from '../document-lifecycle/internals.js';
|
|
14
|
+
import { assertLifecycleTransactionOwnership, commitGuardedDocumentMutation, readDocumentForMutation, } from '../document-lifecycle/revision-guard.js';
|
|
15
|
+
import { sameDocumentData } from '../document-lifecycle/same-document-data.js';
|
|
13
16
|
/**
|
|
14
17
|
* Assert the kind-aware singleton update ability without touching storage.
|
|
15
18
|
* The runtime kind guard keeps this internal service from becoming an
|
|
@@ -32,88 +35,82 @@ export function authorizeSingletonUpdate(ctx) {
|
|
|
32
35
|
export function resolveSingletonDocumentId(ctx) {
|
|
33
36
|
return ctx.db.queries.singletons.getMappedDocumentId(ctx.collectionId);
|
|
34
37
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Coordinate one singleton version write under the registered-slot lock.
|
|
37
|
-
* `prepare` runs after the mapping and current version have been read while
|
|
38
|
-
* locked. The before hook and write remain inside the outer transaction; the
|
|
39
|
-
* after hook cannot run until that transaction has committed.
|
|
40
|
-
*/
|
|
38
|
+
/** Prepare singleton restore/copy outside locks, then commit under S → D → P. */
|
|
41
39
|
export async function commitSingletonSave(params) {
|
|
42
40
|
const { ctx, definition, operation } = params;
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
await
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
41
|
+
assertLifecycleTransactionOwnership(ctx);
|
|
42
|
+
const expectedRevision = parseDocumentRevision(params.expectedRevision);
|
|
43
|
+
const slot = await ctx.db.withReadSnapshot(async (queries) => {
|
|
44
|
+
const documentId = await queries.singletons.getMappedDocumentId(ctx.collectionId);
|
|
45
|
+
if (documentId === null)
|
|
46
|
+
throw ERR_NOT_FOUND({ message: 'Singleton document is unavailable' });
|
|
47
|
+
const currentVersion = await queries.documents.getCurrentVersionMetadata({
|
|
48
|
+
collection_id: ctx.collectionId,
|
|
49
|
+
document_id: documentId,
|
|
50
|
+
});
|
|
51
|
+
if (currentVersion === null)
|
|
52
|
+
throw ERR_NOT_FOUND({ message: 'Singleton document is unavailable' });
|
|
53
|
+
const currentRevision = documentRevisionFromDatabase(await queries.documents.getDocumentRevision({
|
|
54
|
+
collection_id: ctx.collectionId,
|
|
55
|
+
document_id: documentId,
|
|
56
|
+
}));
|
|
57
|
+
if (currentRevision !== expectedRevision)
|
|
58
|
+
throw ERR_DOCUMENT_STALE({
|
|
59
|
+
message: 'This singleton has changed. Reload before saving.',
|
|
60
|
+
details: { reason: 'revision_mismatch', documentId, expectedRevision, currentRevision },
|
|
56
61
|
});
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
return { documentId, currentVersion };
|
|
63
|
+
});
|
|
64
|
+
const prepared = await params.prepare(slot);
|
|
65
|
+
// The operation-specific locale/history reads must still belong to this observation.
|
|
66
|
+
await readDocumentForMutation(ctx, {
|
|
67
|
+
documentId: slot.documentId,
|
|
68
|
+
expectedRevision,
|
|
69
|
+
locale: prepared.locale,
|
|
70
|
+
lenient: true,
|
|
71
|
+
});
|
|
72
|
+
const baseline = structuredClone(prepared.originalData);
|
|
73
|
+
const hooks = await resolveHooks(definition);
|
|
74
|
+
if (!ctx.requestContext)
|
|
75
|
+
throw new Error('Singleton authorization completed without a request context');
|
|
76
|
+
const beforeSaveContext = {
|
|
77
|
+
data: prepared.data,
|
|
78
|
+
originalData: prepared.originalData,
|
|
79
|
+
singletonPath: ctx.collectionPath,
|
|
80
|
+
locale: prepared.locale,
|
|
81
|
+
requestContext: ctx.requestContext,
|
|
82
|
+
isInitialSave: false,
|
|
83
|
+
operation,
|
|
84
|
+
documentId: slot.documentId,
|
|
85
|
+
};
|
|
86
|
+
await invokeSingletonHook(hooks?.beforeSave, beforeSaveContext);
|
|
87
|
+
await prepared.prepareWrite();
|
|
88
|
+
const committed = await commitGuardedDocumentMutation(ctx, {
|
|
89
|
+
documentId: slot.documentId,
|
|
90
|
+
expectedRevision,
|
|
91
|
+
previousVersionId: slot.currentVersion.document_version_id,
|
|
92
|
+
locale: prepared.locale,
|
|
93
|
+
}, async () => {
|
|
94
|
+
if (operation.type === 'copyToLocale' &&
|
|
95
|
+
baseline !== null &&
|
|
96
|
+
sameDocumentData(prepared.data, baseline)) {
|
|
97
|
+
return {
|
|
98
|
+
value: {
|
|
99
|
+
documentId: slot.documentId,
|
|
100
|
+
documentVersionId: slot.currentVersion.document_version_id,
|
|
101
|
+
},
|
|
102
|
+
changed: false,
|
|
103
|
+
};
|
|
63
104
|
}
|
|
64
|
-
const slot = { documentId: mappedDocumentId, currentVersion };
|
|
65
|
-
const prepared = await params.prepare(slot);
|
|
66
|
-
const isInitialSave = mappedDocumentId == null;
|
|
67
|
-
const beforeSaveContext = {
|
|
68
|
-
data: prepared.data,
|
|
69
|
-
originalData: prepared.originalData,
|
|
70
|
-
singletonPath: ctx.collectionPath,
|
|
71
|
-
locale: prepared.locale,
|
|
72
|
-
requestContext,
|
|
73
|
-
isInitialSave,
|
|
74
|
-
operation,
|
|
75
|
-
documentId: mappedDocumentId,
|
|
76
|
-
};
|
|
77
|
-
await invokeSingletonHook(hooks?.beforeSave, beforeSaveContext);
|
|
78
105
|
const writeResult = await prepared.write();
|
|
79
|
-
const documentId = extractDocumentId(writeResult.document)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
if (isInitialSave) {
|
|
85
|
-
try {
|
|
86
|
-
await ctx.db.commands.singletons.setMapping(ctx.collectionId, documentId);
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
if (ctx.db.classifyError?.(error)?.code === DbErrorCodes.UNIQUE_VIOLATION) {
|
|
90
|
-
throw ERR_CONFLICT({
|
|
91
|
-
message: `singleton '${ctx.collectionPath}' was materialised concurrently`,
|
|
92
|
-
details: { singletonPath: ctx.collectionPath },
|
|
93
|
-
}).log(ctx.logger);
|
|
94
|
-
}
|
|
95
|
-
throw error;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return {
|
|
99
|
-
documentId,
|
|
100
|
-
documentVersionId,
|
|
101
|
-
afterSaveContext: {
|
|
102
|
-
...beforeSaveContext,
|
|
103
|
-
documentId,
|
|
104
|
-
documentVersionId,
|
|
105
|
-
},
|
|
106
|
-
};
|
|
106
|
+
const documentId = extractDocumentId(writeResult.document), documentVersionId = extractVersionId(writeResult.document);
|
|
107
|
+
if (!documentId || !documentVersionId)
|
|
108
|
+
throw new Error('Singleton persistence returned no document/version identity');
|
|
109
|
+
return { value: { documentId, documentVersionId }, changed: true };
|
|
107
110
|
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
documentVersionId: committed.documentVersionId,
|
|
112
|
-
}, () => invokeSingletonHook(hooks?.afterSave, committed.afterSaveContext));
|
|
113
|
-
return {
|
|
114
|
-
documentId: committed.documentId,
|
|
115
|
-
documentVersionId: committed.documentVersionId,
|
|
116
|
-
};
|
|
111
|
+
const receipt = { ...committed.value, revision: committed.revision };
|
|
112
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterSave', ...receipt }, () => invokeSingletonHook(hooks?.afterSave, { ...beforeSaveContext, ...committed.value }));
|
|
113
|
+
return receipt;
|
|
117
114
|
}
|
|
118
115
|
async function invokeSingletonHook(hook, context) {
|
|
119
116
|
const hooks = hook == null ? [] : Array.isArray(hook) ? hook : [hook];
|
|
@@ -10,4 +10,5 @@ import type { SingletonSaveResult } from './internals.js';
|
|
|
10
10
|
/** Restore one historical singleton version as the new current version. */
|
|
11
11
|
export declare function restoreSingletonVersion(ctx: DocumentLifecycleContext, params: {
|
|
12
12
|
sourceVersionId: string;
|
|
13
|
+
expectedRevision: number;
|
|
13
14
|
}): Promise<SingletonSaveResult>;
|
|
@@ -13,11 +13,13 @@ import { persistExistingDocumentVersion } from '../document-lifecycle/persistenc
|
|
|
13
13
|
import { authorizeSingletonUpdate, commitSingletonSave } from './internals.js';
|
|
14
14
|
/** Restore one historical singleton version as the new current version. */
|
|
15
15
|
export async function restoreSingletonVersion(ctx, params) {
|
|
16
|
+
params = { ...params };
|
|
16
17
|
return withLogContext({ domain: 'services', module: 'singleton-lifecycle', function: 'restoreSingletonVersion' }, async () => {
|
|
17
18
|
const definition = authorizeSingletonUpdate(ctx);
|
|
18
19
|
return commitSingletonSave({
|
|
19
20
|
ctx,
|
|
20
21
|
definition,
|
|
22
|
+
expectedRevision: params.expectedRevision,
|
|
21
23
|
operation: { type: 'restore', sourceVersionId: params.sourceVersionId },
|
|
22
24
|
prepare: async (slot) => {
|
|
23
25
|
if (slot.documentId == null || slot.currentVersion == null) {
|
|
@@ -75,8 +77,8 @@ export async function restoreSingletonVersion(ctx, params) {
|
|
|
75
77
|
data,
|
|
76
78
|
originalData,
|
|
77
79
|
locale: 'all',
|
|
80
|
+
prepareWrite: () => applyRichTextEmbed(ctx, data),
|
|
78
81
|
write: async () => {
|
|
79
|
-
await applyRichTextEmbed(ctx, data);
|
|
80
82
|
return persistExistingDocumentVersion(ctx, {
|
|
81
83
|
documentId: slot.documentId,
|
|
82
84
|
action: 'restore',
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import type { DocumentRevisionReceipt, SingletonSavePrecondition } from '../../@types/index.js';
|
|
8
9
|
import type { DocumentLifecycleContext } from '../document-lifecycle/context.js';
|
|
9
10
|
import type { SingletonSaveResult } from './internals.js';
|
|
10
|
-
export
|
|
11
|
+
export type UpdateSingletonParams = SingletonSavePrecondition & {
|
|
11
12
|
data: Record<string, any>;
|
|
12
13
|
locale?: string;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
export declare function updateSingleton(ctx: DocumentLifecycleContext, params: UpdateSingletonParams): Promise<
|
|
14
|
+
};
|
|
15
|
+
export type UpdateSingletonResult = SingletonSaveResult & DocumentRevisionReceipt;
|
|
16
|
+
/** Prepare outside locks; recheck the observed slot/document under S → D before committing. */
|
|
17
|
+
export declare function updateSingleton(ctx: DocumentLifecycleContext, params: UpdateSingletonParams): Promise<UpdateSingletonResult>;
|