@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
@@ -5,103 +5,170 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { ERR_CONFLICT, ERR_VALIDATION } from '../../lib/errors.js';
9
- import { withLogContext } from '../../lib/logger.js';
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 { applyRichTextEmbed } from '../document-lifecycle/internals.js';
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, commitSingletonSave } from './internals.js';
17
- /** Materialise or update a singleton slot as one immutable document version. */
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
- return withLogContext({ domain: 'services', module: 'singleton-lifecycle', function: 'updateSingleton' }, async () => {
20
- const definition = authorizeSingletonUpdate(ctx);
21
- const locale = params.locale ?? ctx.defaultLocale;
22
- const data = params.data;
23
- return commitSingletonSave({
24
- ctx,
25
- definition,
26
- operation: { type: 'save' },
27
- prepare: async (slot) => {
28
- if (slot.documentId == null && locale !== ctx.defaultLocale) {
29
- throw ERR_VALIDATION({
30
- message: `a singleton's first save must use the default content locale ` +
31
- `('${ctx.defaultLocale}'); received '${locale}'`,
32
- details: {
33
- singletonPath: ctx.collectionPath,
34
- defaultLocale: ctx.defaultLocale,
35
- providedLocale: locale,
36
- },
37
- }).log(ctx.logger);
38
- }
39
- if (params.expectedVersionId != null) {
40
- const currentVersionId = slot.currentVersion?.document_version_id;
41
- if (currentVersionId !== params.expectedVersionId) {
42
- throw ERR_CONFLICT({
43
- message: 'singleton version conflict',
44
- details: {
45
- singletonPath: ctx.collectionPath,
46
- expectedVersionId: params.expectedVersionId,
47
- currentVersionId: currentVersionId ?? null,
48
- },
49
- }).log(ctx.logger);
50
- }
51
- }
52
- const current = slot.documentId == null
53
- ? null
54
- : await ctx.db.queries.documents.getDocumentById({
55
- collection_id: ctx.collectionId,
56
- document_id: slot.documentId,
57
- locale,
58
- reconstruct: true,
59
- lenient: true,
60
- requestContext: ctx.requestContext,
61
- });
62
- if (slot.documentId != null && current == null) {
63
- throw ERR_CONFLICT({
64
- message: `singleton '${ctx.collectionPath}' could not reconstruct its mapped document`,
65
- details: { singletonPath: ctx.collectionPath, documentId: slot.documentId },
66
- }).log(ctx.logger);
67
- }
68
- const currentRecord = current;
69
- const originalData = currentRecord?.fields ?? null;
70
- normaliseDateFields(data);
71
- normalizeNumericFields(definition.fields, data);
72
- return {
73
- data,
74
- originalData,
75
- locale,
76
- write: async () => {
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(async (operation) => {
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(true);
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, { data: { title: 'Incoming' } });
215
- expect(result).toEqual({ documentId: 'doc-1', documentVersionId: 'ver-1' });
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: 'ERR_CONFLICT',
254
- message: "singleton 'site-settings' was materialised concurrently",
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
- expectedVersionId: 'ver-current',
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('enforces expectedVersionId under the locked slot state', async () => {
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
- expectedVersionId: 'ver-current',
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' }, expectedVersionId: 'ver-stale' })).rejects.toMatchObject({ code: 'ERR_CONFLICT' });
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
- expectedVersionId: 'ver-believed-current',
311
- })).rejects.toMatchObject({ code: 'ERR_CONFLICT' });
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: 'ERR_CONFLICT' });
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.toThrow('notification failed');
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, { sourceVersionId: 'ver-source' });
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
+ }
@@ -0,0 +1,3 @@
1
+ import type { IDbAdapter } from '../@types/db-types.js';
2
+ /** Must run before collection reconciliation, counters, or boot maintenance writes. */
3
+ export declare function assertDocumentRevisionCapability(db: IDbAdapter): Promise<void>;