@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.
Files changed (117) hide show
  1. package/dist/@types/db-types.d.ts +38 -4
  2. package/dist/@types/document-revision.d.ts +92 -0
  3. package/dist/@types/document-revision.js +8 -0
  4. package/dist/@types/index.d.ts +1 -0
  5. package/dist/@types/index.js +1 -0
  6. package/dist/config/attach-hooks.test.node.js +2 -1
  7. package/dist/config/config-hooks.test.node.js +2 -1
  8. package/dist/core.js +8 -0
  9. package/dist/core.test.node.js +27 -2
  10. package/dist/index.d.ts +3 -1
  11. package/dist/index.js +3 -1
  12. package/dist/lib/errors.d.ts +25 -0
  13. package/dist/lib/errors.js +42 -0
  14. package/dist/lib/lock-conflict.test.node.d.ts +1 -0
  15. package/dist/lib/lock-conflict.test.node.js +31 -0
  16. package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
  17. package/dist/scheduler/run-due-tasks.test.node.js +3 -2
  18. package/dist/scheduler/scheduled-publication.d.ts +16 -1
  19. package/dist/scheduler/scheduled-publication.js +27 -1
  20. package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
  21. package/dist/scheduler/scheduled-publication.test.node.js +18 -3
  22. package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
  23. package/dist/scheduler/scheduler-boot.test.node.js +40 -12
  24. package/dist/scheduler/ticker.test.node.d.ts +0 -7
  25. package/dist/scheduler/ticker.test.node.js +3 -2
  26. package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
  27. package/dist/services/collection-bootstrap.test.node.js +12 -3
  28. package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
  29. package/dist/services/discover-counter-groups.test.node.js +19 -3
  30. package/dist/services/document-lifecycle/audit.d.ts +1 -0
  31. package/dist/services/document-lifecycle/audit.js +1 -0
  32. package/dist/services/document-lifecycle/committed-hook.d.ts +25 -0
  33. package/dist/services/document-lifecycle/committed-hook.js +96 -0
  34. package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
  35. package/dist/services/document-lifecycle/copy-to-locale.js +30 -44
  36. package/dist/services/document-lifecycle/create.d.ts +1 -0
  37. package/dist/services/document-lifecycle/create.js +6 -21
  38. package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
  39. package/dist/services/document-lifecycle/delete-locale.js +42 -39
  40. package/dist/services/document-lifecycle/delete.d.ts +8 -2
  41. package/dist/services/document-lifecycle/delete.js +24 -15
  42. package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
  43. package/dist/services/document-lifecycle/duplicate.js +55 -43
  44. package/dist/services/document-lifecycle/index.d.ts +5 -1
  45. package/dist/services/document-lifecycle/index.js +4 -1
  46. package/dist/services/document-lifecycle/persistence.js +18 -7
  47. package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
  48. package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
  49. package/dist/services/document-lifecycle/reorder.d.ts +11 -0
  50. package/dist/services/document-lifecycle/reorder.js +83 -0
  51. package/dist/services/document-lifecycle/restore.d.ts +2 -0
  52. package/dist/services/document-lifecycle/restore.js +21 -26
  53. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
  54. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
  55. package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
  56. package/dist/services/document-lifecycle/revision-guard.js +154 -0
  57. package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
  58. package/dist/services/document-lifecycle/same-document-data.js +17 -0
  59. package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
  60. package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
  61. package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
  62. package/dist/services/document-lifecycle/stale-document.js +95 -0
  63. package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
  64. package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
  65. package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
  66. package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
  67. package/dist/services/document-lifecycle/status.d.ts +5 -21
  68. package/dist/services/document-lifecycle/status.js +90 -133
  69. package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
  70. package/dist/services/document-lifecycle/system-fields.js +118 -116
  71. package/dist/services/document-lifecycle/tree.d.ts +9 -6
  72. package/dist/services/document-lifecycle/tree.js +65 -39
  73. package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
  74. package/dist/services/document-lifecycle/tree.test.node.js +152 -35
  75. package/dist/services/document-lifecycle/update.d.ts +14 -6
  76. package/dist/services/document-lifecycle/update.js +104 -82
  77. package/dist/services/document-lifecycle.test.node.js +556 -70
  78. package/dist/services/field-upload.d.ts +1 -0
  79. package/dist/services/field-upload.js +5 -1
  80. package/dist/services/field-upload.test.node.d.ts +0 -7
  81. package/dist/services/field-upload.test.node.js +38 -2
  82. package/dist/services/populate.test.node.d.ts +0 -7
  83. package/dist/services/populate.test.node.js +19 -3
  84. package/dist/services/richtext-populate.test.node.d.ts +0 -7
  85. package/dist/services/richtext-populate.test.node.js +3 -2
  86. package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
  87. package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
  88. package/dist/services/singleton-lifecycle/index.d.ts +1 -1
  89. package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
  90. package/dist/services/singleton-lifecycle/internals.js +75 -73
  91. package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
  92. package/dist/services/singleton-lifecycle/restore.js +3 -1
  93. package/dist/services/singleton-lifecycle/update.d.ts +6 -5
  94. package/dist/services/singleton-lifecycle/update.js +158 -91
  95. package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
  96. package/dist/services/singleton-lifecycle.test.node.js +75 -23
  97. package/dist/storage/db-adapter.test-helper.d.ts +26 -0
  98. package/dist/storage/db-adapter.test-helper.js +117 -0
  99. package/dist/storage/document-revision-capability.d.ts +3 -0
  100. package/dist/storage/document-revision-capability.js +15 -0
  101. package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
  102. package/dist/storage/document-revision-capability.test.node.js +53 -0
  103. package/dist/storage/document-revision.d.ts +12 -0
  104. package/dist/storage/document-revision.js +37 -0
  105. package/dist/storage/document-revision.test.node.d.ts +1 -0
  106. package/dist/storage/document-revision.test.node.js +48 -0
  107. package/dist/storage/document-version-parent.d.ts +20 -0
  108. package/dist/storage/document-version-parent.js +34 -0
  109. package/dist/storage/document-version-parent.test.node.d.ts +8 -0
  110. package/dist/storage/document-version-parent.test.node.js +60 -0
  111. package/dist/storage/index.d.ts +1 -0
  112. package/dist/storage/index.js +1 -0
  113. package/dist/storage/read-snapshot.d.ts +3 -0
  114. package/dist/storage/read-snapshot.js +55 -0
  115. package/dist/storage/revision-store.test-helper.d.ts +3 -0
  116. package/dist/storage/revision-store.test-helper.js +16 -0
  117. package/package.json +2 -2
@@ -93,6 +93,7 @@ export interface UploadFieldParams {
93
93
  locale?: string;
94
94
  }
95
95
  export interface UploadFieldResult {
96
+ revision?: number;
96
97
  documentId?: string;
97
98
  documentVersionId?: string;
98
99
  /**
@@ -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: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
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
- operation: { type: 'copyToLocale', ...params },
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,8 +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 { DbErrorCodes, ERR_CONFLICT, ERR_VALIDATION } from '../../lib/errors.js';
10
+ import { ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
11
+ import { documentRevisionFromDatabase, parseDocumentRevision, } from '../../storage/document-revision.js';
12
+ import { runCommittedDocumentHook } from '../document-lifecycle/committed-hook.js';
11
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';
12
16
  /**
13
17
  * Assert the kind-aware singleton update ability without touching storage.
14
18
  * The runtime kind guard keeps this internal service from becoming an
@@ -31,84 +35,82 @@ export function authorizeSingletonUpdate(ctx) {
31
35
  export function resolveSingletonDocumentId(ctx) {
32
36
  return ctx.db.queries.singletons.getMappedDocumentId(ctx.collectionId);
33
37
  }
34
- /**
35
- * Coordinate one singleton version write under the registered-slot lock.
36
- * `prepare` runs after the mapping and current version have been read while
37
- * locked. The before hook and write remain inside the outer transaction; the
38
- * after hook cannot run until that transaction has committed.
39
- */
38
+ /** Prepare singleton restore/copy outside locks, then commit under S → D → P. */
40
39
  export async function commitSingletonSave(params) {
41
40
  const { ctx, definition, operation } = params;
42
- const hooks = await resolveHooks(definition);
43
- const requestContext = ctx.requestContext;
44
- if (requestContext == null) {
45
- throw new Error('singleton authorization completed without a request context');
46
- }
47
- const committed = await ctx.db.withTransaction(async () => {
48
- await ctx.db.commands.singletons.lockSlot(ctx.collectionId);
49
- const mappedDocumentId = await resolveSingletonDocumentId(ctx);
50
- const currentVersion = mappedDocumentId == null
51
- ? null
52
- : await ctx.db.queries.documents.getCurrentVersionMetadata({
53
- collection_id: ctx.collectionId,
54
- document_id: mappedDocumentId,
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 },
55
61
  });
56
- if (mappedDocumentId != null && currentVersion == null) {
57
- throw ERR_CONFLICT({
58
- message: `singleton '${ctx.collectionPath}' is mapped to a deleted or unavailable document; ` +
59
- 'clear the mapping deliberately before rematerialising the slot',
60
- details: { singletonPath: ctx.collectionPath, documentId: mappedDocumentId },
61
- }).log(ctx.logger);
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
+ };
62
104
  }
63
- const slot = { documentId: mappedDocumentId, currentVersion };
64
- const prepared = await params.prepare(slot);
65
- const isInitialSave = mappedDocumentId == null;
66
- const beforeSaveContext = {
67
- data: prepared.data,
68
- originalData: prepared.originalData,
69
- singletonPath: ctx.collectionPath,
70
- locale: prepared.locale,
71
- requestContext,
72
- isInitialSave,
73
- operation,
74
- documentId: mappedDocumentId,
75
- };
76
- await invokeSingletonHook(hooks?.beforeSave, beforeSaveContext);
77
105
  const writeResult = await prepared.write();
78
- const documentId = extractDocumentId(writeResult.document) || mappedDocumentId || '';
79
- const documentVersionId = extractVersionId(writeResult.document);
80
- if (documentId === '' || documentVersionId === '') {
81
- throw new Error('singleton persistence did not return document and version ids');
82
- }
83
- if (isInitialSave) {
84
- try {
85
- await ctx.db.commands.singletons.setMapping(ctx.collectionId, documentId);
86
- }
87
- catch (error) {
88
- if (ctx.db.classifyError?.(error)?.code === DbErrorCodes.UNIQUE_VIOLATION) {
89
- throw ERR_CONFLICT({
90
- message: `singleton '${ctx.collectionPath}' was materialised concurrently`,
91
- details: { singletonPath: ctx.collectionPath },
92
- }).log(ctx.logger);
93
- }
94
- throw error;
95
- }
96
- }
97
- return {
98
- documentId,
99
- documentVersionId,
100
- afterSaveContext: {
101
- ...beforeSaveContext,
102
- documentId,
103
- documentVersionId,
104
- },
105
- };
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 };
106
110
  });
107
- await invokeSingletonHook(hooks?.afterSave, committed.afterSaveContext);
108
- return {
109
- documentId: committed.documentId,
110
- documentVersionId: committed.documentVersionId,
111
- };
111
+ const receipt = { ...committed.value, revision: committed.revision };
112
+ await runCommittedDocumentHook(ctx, { phase: 'afterSave', ...receipt }, () => invokeSingletonHook(hooks?.afterSave, { ...beforeSaveContext, ...committed.value }));
113
+ return receipt;
112
114
  }
113
115
  async function invokeSingletonHook(hook, context) {
114
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 interface UpdateSingletonParams {
11
+ export type UpdateSingletonParams = SingletonSavePrecondition & {
11
12
  data: Record<string, any>;
12
13
  locale?: string;
13
- expectedVersionId?: string;
14
- }
15
- /** Materialise or update a singleton slot as one immutable document version. */
16
- export declare function updateSingleton(ctx: DocumentLifecycleContext, params: UpdateSingletonParams): Promise<SingletonSaveResult>;
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>;