@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.
Files changed (111) hide show
  1. package/dist/@types/db-types.d.ts +32 -1
  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 +15 -0
  13. package/dist/lib/errors.js +32 -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 +2 -1
  33. package/dist/services/document-lifecycle/committed-hook.js +19 -2
  34. package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
  35. package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
  36. package/dist/services/document-lifecycle/create.d.ts +1 -0
  37. package/dist/services/document-lifecycle/create.js +4 -20
  38. package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
  39. package/dist/services/document-lifecycle/delete-locale.js +35 -37
  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 +48 -41
  44. package/dist/services/document-lifecycle/index.d.ts +3 -1
  45. package/dist/services/document-lifecycle/index.js +3 -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 +19 -25
  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 +101 -80
  77. package/dist/services/document-lifecycle.test.node.js +401 -73
  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 +74 -77
  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 +67 -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/read-snapshot.d.ts +3 -0
  108. package/dist/storage/read-snapshot.js +55 -0
  109. package/dist/storage/revision-store.test-helper.d.ts +3 -0
  110. package/dist/storage/revision-store.test-helper.js +16 -0
  111. package/package.json +2 -2
@@ -9,12 +9,14 @@ import { ERR_UNAUTHENTICATED } from '@byline/auth';
9
9
  import { resolveHooks } from '../../@types/index.js';
10
10
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
11
11
  import { documentAbilityKey } from '../../auth/register-collection-abilities.js';
12
- import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION, } from '../../lib/errors.js';
12
+ import { ERR_CONFLICT, ERR_DOCUMENT_STALE, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION, } from '../../lib/errors.js';
13
13
  import { withLogContext } from '../../lib/logger.js';
14
+ import { documentRevisionFromDatabase } from '../../storage/document-revision.js';
14
15
  import { getWorkflow, validateStatusTransition } from '../../workflow/workflow.js';
15
16
  import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
16
17
  import { actorId, invokeHook } from './internals.js';
17
18
  import { publishScheduleAuditValue } from './publish-schedule-consistency.js';
19
+ import { assertLifecycleTransactionOwnership, commitGuardedDocumentMutation, readDocumentForMutation, } from './revision-guard.js';
18
20
  import { commitDocumentStatusTransition } from './status-transition.js';
19
21
  class ClaimedPublicationOutcome extends Error {
20
22
  result;
@@ -27,23 +29,25 @@ function assertScheduleAbilities(ctx) {
27
29
  assertActorCanPerform(ctx.requestContext, ctx.definition, 'changeStatus');
28
30
  assertActorCanPerform(ctx.requestContext, ctx.definition, 'publish');
29
31
  }
30
- async function assertCurrentVersionCanPublish(ctx, documentId, expectedVersionId) {
31
- const current = await ctx.db.queries.documents.getCurrentVersionMetadata({
32
- collection_id: ctx.collectionId,
33
- document_id: documentId,
34
- });
35
- if (current === null) {
32
+ async function assertCurrentVersionCanPublish(ctx, documentId, expectedVersionId, lockedCurrent) {
33
+ const current = lockedCurrent ??
34
+ (await ctx.db.queries.documents.getCurrentVersionMetadata({
35
+ collection_id: ctx.collectionId,
36
+ document_id: documentId,
37
+ }));
38
+ if (current === null || current.status === null || current.document_version_id === null) {
36
39
  throw ERR_NOT_FOUND({
37
40
  message: 'document not found',
38
41
  details: { documentId },
39
42
  }).log(ctx.logger);
40
43
  }
41
44
  if (current.document_version_id !== expectedVersionId) {
42
- throw ERR_CONFLICT({
45
+ throw ERR_DOCUMENT_STALE({
43
46
  message: 'the document changed before its publication schedule could be saved',
44
47
  details: {
45
48
  documentId,
46
- expectedVersionId,
49
+ reason: 'version_parent_mismatch',
50
+ previousVersionId: expectedVersionId,
47
51
  currentVersionId: current.document_version_id,
48
52
  },
49
53
  }).log(ctx.logger);
@@ -94,15 +98,21 @@ function throwScheduleStorageOutcome(ctx, documentId, status) {
94
98
  export async function scheduleDocumentPublish(ctx, params) {
95
99
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'scheduleDocumentPublish' }, async () => {
96
100
  assertScheduleAbilities(ctx);
101
+ await readDocumentForMutation(ctx, params);
97
102
  await assertCurrentVersionCanPublish(ctx, params.documentId, params.expectedVersionId);
98
103
  const publishAt = parsePublishAt(params.publishAt);
99
104
  const audit = requireAuditCapability(ctx.db);
100
105
  const actor = auditActor(ctx);
101
- return audit.withTransaction(async () => {
106
+ const committed = await commitGuardedDocumentMutation(ctx, params, async (locked) => {
107
+ await assertCurrentVersionCanPublish(ctx, params.documentId, params.expectedVersionId, {
108
+ document_version_id: locked.currentVersionId,
109
+ status: locked.status,
110
+ });
102
111
  const result = await ctx.db.commands.documents.publishSchedules.schedule({
103
112
  documentId: params.documentId,
104
113
  collectionId: ctx.collectionId,
105
114
  expectedVersionId: params.expectedVersionId,
115
+ authorizedRevision: locked.revision + 1,
106
116
  publishAt,
107
117
  actorId: actorId(ctx) ?? null,
108
118
  });
@@ -121,21 +131,28 @@ export async function scheduleDocumentPublish(ctx, params) {
121
131
  before: result.previous === null ? null : publishScheduleAuditValue(result.previous),
122
132
  after: publishScheduleAuditValue(result.schedule),
123
133
  });
124
- return result.schedule;
134
+ return { value: result.schedule, changed: true };
125
135
  });
136
+ return { ...committed.value, revision: committed.revision };
126
137
  });
127
138
  }
128
139
  export async function confirmDocumentScheduledPublish(ctx, params) {
129
140
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'confirmDocumentScheduledPublish' }, async () => {
130
141
  assertScheduleAbilities(ctx);
142
+ await readDocumentForMutation(ctx, params);
131
143
  await assertCurrentVersionCanPublish(ctx, params.documentId, params.expectedVersionId);
132
144
  const audit = requireAuditCapability(ctx.db);
133
145
  const actor = auditActor(ctx);
134
- return audit.withTransaction(async () => {
146
+ const committed = await commitGuardedDocumentMutation(ctx, params, async (locked) => {
147
+ await assertCurrentVersionCanPublish(ctx, params.documentId, params.expectedVersionId, {
148
+ document_version_id: locked.currentVersionId,
149
+ status: locked.status,
150
+ });
135
151
  const result = await ctx.db.commands.documents.publishSchedules.confirm({
136
152
  documentId: params.documentId,
137
153
  collectionId: ctx.collectionId,
138
154
  expectedVersionId: params.expectedVersionId,
155
+ authorizedRevision: locked.revision + 1,
139
156
  actorId: actorId(ctx) ?? null,
140
157
  });
141
158
  if (result.status !== 'confirmed') {
@@ -165,16 +182,18 @@ export async function confirmDocumentScheduledPublish(ctx, params) {
165
182
  },
166
183
  after: publishScheduleAuditValue(result.schedule),
167
184
  });
168
- return result.schedule;
185
+ return { value: result.schedule, changed: true };
169
186
  });
187
+ return { ...committed.value, revision: committed.revision };
170
188
  });
171
189
  }
172
190
  export async function cancelDocumentScheduledPublish(ctx, params) {
173
191
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'cancelDocumentScheduledPublish' }, async () => {
174
192
  assertScheduleAbilities(ctx);
193
+ await readDocumentForMutation(ctx, params);
175
194
  const audit = requireAuditCapability(ctx.db);
176
195
  const actor = auditActor(ctx);
177
- return audit.withTransaction(async () => {
196
+ const committed = await commitGuardedDocumentMutation(ctx, params, async (_locked) => {
178
197
  const schedule = await ctx.db.commands.documents.publishSchedules.cancel({
179
198
  documentId: params.documentId,
180
199
  collectionId: ctx.collectionId,
@@ -191,8 +210,9 @@ export async function cancelDocumentScheduledPublish(ctx, params) {
191
210
  after: { reason: 'explicit' },
192
211
  });
193
212
  }
194
- return schedule;
213
+ return { value: schedule, changed: schedule !== null };
195
214
  });
215
+ return { schedule: committed.value, revision: committed.revision };
196
216
  });
197
217
  }
198
218
  export async function getDocumentScheduledPublish(ctx, params) {
@@ -222,12 +242,25 @@ export async function listDocumentPublishSchedules(core, requestContext, params)
222
242
  collectionIds.push(core.getCollectionRecord(definition.path).collectionId);
223
243
  }
224
244
  }
225
- return core.db.queries.documents.publishSchedules.list({
226
- collectionIds,
227
- states: params.states,
228
- lastAuthorizedBy: params.lastAuthorizedBy,
229
- page: params.page,
230
- pageSize: params.pageSize,
245
+ return core.db.withReadSnapshot(async (queries) => {
246
+ const page = await queries.documents.publishSchedules.list({
247
+ collectionIds,
248
+ states: params.states,
249
+ lastAuthorizedBy: params.lastAuthorizedBy,
250
+ page: params.page,
251
+ pageSize: params.pageSize,
252
+ });
253
+ const schedules = [];
254
+ for (const schedule of page.schedules) {
255
+ const scope = { collection_id: schedule.collectionId, document_id: schedule.documentId };
256
+ const revision = documentRevisionFromDatabase(await queries.documents.getDocumentRevision(scope));
257
+ schedules.push({
258
+ ...schedule,
259
+ revision,
260
+ documentPath: await queries.documents.getCurrentPath(scope),
261
+ });
262
+ }
263
+ return { ...page, schedules };
231
264
  });
232
265
  }
233
266
  /**
@@ -236,23 +269,32 @@ export async function listDocumentPublishSchedules(core, requestContext, params)
236
269
  * was armed, so callers must construct an explicit system context.
237
270
  */
238
271
  export async function publishClaimedScheduledDocument(ctx, params) {
272
+ assertLifecycleTransactionOwnership(ctx);
239
273
  const workflow = getWorkflow(ctx.definition);
240
- const initial = await ctx.db.queries.documents.getCurrentVersionMetadata({
241
- collection_id: ctx.collectionId,
242
- document_id: params.documentId,
243
- });
244
- if (initial === null) {
274
+ const { initial, schedule, revision } = await ctx.db.withReadSnapshot(async (queries) => ({
275
+ initial: await queries.documents.getCurrentVersionMetadata({
276
+ collection_id: ctx.collectionId,
277
+ document_id: params.documentId,
278
+ }),
279
+ schedule: await queries.documents.publishSchedules.get({
280
+ documentId: params.documentId,
281
+ collectionId: ctx.collectionId,
282
+ }),
283
+ revision: await queries.documents.getDocumentRevision({
284
+ collection_id: ctx.collectionId,
285
+ document_id: params.documentId,
286
+ }),
287
+ }));
288
+ if (initial === null)
245
289
  return { status: 'terminal', reason: 'document_not_found' };
246
- }
247
- const schedule = await ctx.db.queries.documents.publishSchedules.get({
248
- documentId: params.documentId,
249
- collectionId: ctx.collectionId,
250
- });
251
- if (schedule === null || schedule.executionToken !== params.executionToken) {
290
+ if (schedule === null ||
291
+ schedule.state !== 'armed' ||
292
+ schedule.executionToken !== params.executionToken)
252
293
  return { status: 'claim_lost' };
253
- }
294
+ if (schedule.authorizedRevision === null || revision !== schedule.authorizedRevision)
295
+ return { status: 'target_changed', reason: 'document_metadata_changed' };
254
296
  if (initial.document_version_id !== schedule.targetVersionId) {
255
- return { status: 'target_changed' };
297
+ return { status: 'target_changed', reason: 'content_edited' };
256
298
  }
257
299
  if (initial.status === 'published') {
258
300
  return { status: 'terminal', reason: 'already_published' };
@@ -276,64 +318,84 @@ export async function publishClaimedScheduledDocument(ctx, params) {
276
318
  };
277
319
  await invokeHook(hooks?.beforeStatusChange, hookCtx);
278
320
  try {
279
- await commitDocumentStatusTransition({
280
- db: ctx.db,
281
- documentId: params.documentId,
282
- documentVersionId: initial.document_version_id,
283
- collectionId: ctx.collectionId,
284
- previousStatus: initial.status,
285
- nextStatus: 'published',
286
- actor: auditActor(ctx),
287
- contributions: {
288
- beforeStatusWrite: async () => {
289
- const locked = await ctx.db.commands.documents.publishSchedules.lockClaim({
290
- documentId: params.documentId,
291
- executionToken: params.executionToken,
292
- });
293
- if (locked === null) {
294
- throw new ClaimedPublicationOutcome({ status: 'claim_lost' });
295
- }
296
- const current = await ctx.db.queries.documents.getCurrentVersionMetadata({
297
- collection_id: ctx.collectionId,
298
- document_id: params.documentId,
299
- });
300
- if (current === null) {
301
- throw new ClaimedPublicationOutcome({
302
- status: 'terminal',
303
- reason: 'document_not_found',
304
- });
305
- }
306
- if (current.document_version_id !== locked.targetVersionId ||
307
- current.document_version_id !== initial.document_version_id) {
308
- throw new ClaimedPublicationOutcome({ status: 'target_changed' });
309
- }
310
- if (current.status === 'published') {
311
- throw new ClaimedPublicationOutcome({
312
- status: 'terminal',
313
- reason: 'already_published',
321
+ await commitGuardedDocumentMutation(ctx, { documentId: params.documentId, expectedRevision: schedule.authorizedRevision }, async (document) => {
322
+ await commitDocumentStatusTransition({
323
+ db: ctx.db,
324
+ documentId: params.documentId,
325
+ documentVersionId: initial.document_version_id,
326
+ collectionId: ctx.collectionId,
327
+ previousStatus: initial.status,
328
+ nextStatus: 'published',
329
+ actor: auditActor(ctx),
330
+ contributions: {
331
+ beforeStatusWrite: async () => {
332
+ const locked = await ctx.db.commands.documents.publishSchedules.lockClaim({
333
+ documentId: params.documentId,
334
+ executionToken: params.executionToken,
314
335
  });
315
- }
316
- if (!validateStatusTransition(workflow, current.status, 'published').valid) {
317
- throw new ClaimedPublicationOutcome({
318
- status: 'terminal',
319
- reason: 'invalid_transition',
336
+ if (locked === null) {
337
+ throw new ClaimedPublicationOutcome({ status: 'claim_lost' });
338
+ }
339
+ const current = document.currentVersionId === null
340
+ ? null
341
+ : { document_version_id: document.currentVersionId, status: document.status };
342
+ if (locked.authorizedRevision !== document.revision || locked.state !== 'armed')
343
+ throw new ClaimedPublicationOutcome({
344
+ status: 'target_changed',
345
+ reason: 'document_metadata_changed',
346
+ });
347
+ if (current === null) {
348
+ throw new ClaimedPublicationOutcome({
349
+ status: 'terminal',
350
+ reason: 'document_not_found',
351
+ });
352
+ }
353
+ if (current.document_version_id !== locked.targetVersionId ||
354
+ current.document_version_id !== initial.document_version_id) {
355
+ throw new ClaimedPublicationOutcome({
356
+ status: 'target_changed',
357
+ reason: 'document_metadata_changed',
358
+ });
359
+ }
360
+ if (current.status === 'published') {
361
+ throw new ClaimedPublicationOutcome({
362
+ status: 'terminal',
363
+ reason: 'already_published',
364
+ });
365
+ }
366
+ if (!validateStatusTransition(workflow, current.status ?? '', 'published').valid) {
367
+ throw new ClaimedPublicationOutcome({
368
+ status: 'terminal',
369
+ reason: 'invalid_transition',
370
+ });
371
+ }
372
+ },
373
+ afterAuditAppend: async () => {
374
+ const deleted = await ctx.db.commands.documents.publishSchedules.deleteClaim({
375
+ documentId: params.documentId,
376
+ executionToken: params.executionToken,
320
377
  });
321
- }
378
+ if (!deleted)
379
+ throw new ClaimedPublicationOutcome({ status: 'claim_lost' });
380
+ },
322
381
  },
323
- afterAuditAppend: async () => {
324
- const deleted = await ctx.db.commands.documents.publishSchedules.deleteClaim({
325
- documentId: params.documentId,
326
- executionToken: params.executionToken,
327
- });
328
- if (!deleted)
329
- throw new ClaimedPublicationOutcome({ status: 'claim_lost' });
330
- },
331
- },
382
+ });
383
+ return { value: undefined, changed: true };
332
384
  });
333
385
  }
334
386
  catch (error) {
335
387
  if (error instanceof ClaimedPublicationOutcome)
336
388
  return error.result;
389
+ if (error != null &&
390
+ typeof error === 'object' &&
391
+ 'code' in error &&
392
+ error.code === 'ERR_NOT_FOUND')
393
+ return { status: 'terminal', reason: 'document_not_found' };
394
+ if (error != null &&
395
+ typeof error === 'object' &&
396
+ 'code' in error &&
397
+ error.code === 'ERR_DOCUMENT_STALE')
398
+ return { status: 'target_changed', reason: 'document_metadata_changed' };
337
399
  throw error;
338
400
  }
339
401
  try {
@@ -0,0 +1,13 @@
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
+ import type { DocumentRevisionValidationDetails, DocumentStaleDetails } from '../../@types/document-revision.js';
9
+ /** Decode only allowlisted fields; works with live errors and serialized reports. */
10
+ export declare function getDocumentStaleDetails(error: unknown): DocumentStaleDetails | null;
11
+ export declare function getDocumentRevisionValidationDetails(error: unknown): DocumentRevisionValidationDetails | null;
12
+ /** Private lifecycle boundary adapter; never reclassify unrelated conflict errors. */
13
+ export declare function normalizeDocumentVersionParentError(error: unknown): unknown;
@@ -0,0 +1,95 @@
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
+ import { ERR_DOCUMENT_STALE, ErrorCodes } from '../../lib/errors.js';
9
+ import { isDocumentRevision } from '../../storage/document-revision.js';
10
+ function property(value, key) {
11
+ try {
12
+ if (typeof value !== 'object' || value === null)
13
+ return undefined;
14
+ return Reflect.get(value, key);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function nonemptyString(value) {
21
+ return typeof value === 'string' && value.trim().length > 0;
22
+ }
23
+ /** Decode only allowlisted fields; works with live errors and serialized reports. */
24
+ export function getDocumentStaleDetails(error) {
25
+ if (property(error, 'code') !== ErrorCodes.DOCUMENT_STALE)
26
+ return null;
27
+ const details = property(error, 'details');
28
+ const reason = property(details, 'reason');
29
+ if (reason === 'singleton_slot_changed') {
30
+ const singletonPath = property(details, 'singletonPath');
31
+ if (!nonemptyString(singletonPath) ||
32
+ property(details, 'expectedState') !== 'empty' ||
33
+ property(details, 'currentState') !== 'document') {
34
+ return null;
35
+ }
36
+ return { reason, singletonPath, expectedState: 'empty', currentState: 'document' };
37
+ }
38
+ const documentId = property(details, 'documentId');
39
+ if (!nonemptyString(documentId))
40
+ return null;
41
+ if (reason === 'revision_mismatch') {
42
+ const expectedRevision = property(details, 'expectedRevision');
43
+ const currentRevision = property(details, 'currentRevision');
44
+ if (!isDocumentRevision(expectedRevision) ||
45
+ !isDocumentRevision(currentRevision) ||
46
+ expectedRevision === currentRevision) {
47
+ return null;
48
+ }
49
+ return { reason, documentId, expectedRevision, currentRevision };
50
+ }
51
+ if (reason === 'version_parent_mismatch') {
52
+ const previousVersionId = property(details, 'previousVersionId');
53
+ const currentVersionId = property(details, 'currentVersionId');
54
+ if (!nonemptyString(previousVersionId) ||
55
+ (currentVersionId !== null && !nonemptyString(currentVersionId)) ||
56
+ previousVersionId === currentVersionId) {
57
+ return null;
58
+ }
59
+ return { reason, documentId, previousVersionId, currentVersionId };
60
+ }
61
+ return null;
62
+ }
63
+ export function getDocumentRevisionValidationDetails(error) {
64
+ if (property(error, 'code') !== ErrorCodes.VALIDATION)
65
+ return null;
66
+ const reason = property(property(error, 'details'), 'reason');
67
+ if (reason !== 'missing_document_revision' && reason !== 'invalid_document_revision') {
68
+ return null;
69
+ }
70
+ return { reason };
71
+ }
72
+ /** Private lifecycle boundary adapter; never reclassify unrelated conflict errors. */
73
+ export function normalizeDocumentVersionParentError(error) {
74
+ if (property(error, 'code') !== ErrorCodes.CONFLICT)
75
+ return error;
76
+ const details = property(error, 'details');
77
+ if (property(details, 'reason') !== 'stale')
78
+ return error;
79
+ const staleDetails = getDocumentStaleDetails({
80
+ code: ErrorCodes.DOCUMENT_STALE,
81
+ details: {
82
+ reason: 'version_parent_mismatch',
83
+ documentId: property(details, 'documentId'),
84
+ previousVersionId: property(details, 'previousVersionId'),
85
+ currentVersionId: property(details, 'currentVersionId'),
86
+ },
87
+ });
88
+ if (staleDetails === null)
89
+ return error;
90
+ return ERR_DOCUMENT_STALE({
91
+ message: 'the document changed before the new version could be saved',
92
+ details: staleDetails,
93
+ cause: error,
94
+ });
95
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest';
2
+ import { ERR_CONFLICT, ERR_DOCUMENT_STALE, ERR_VALIDATION, ErrorCodes } from '../../lib/errors.js';
3
+ import { assertDocumentVersionParent } from '../../storage/document-version-parent.js';
4
+ import { getDocumentRevisionValidationDetails, getDocumentStaleDetails, normalizeDocumentVersionParentError, } from './stale-document.js';
5
+ const variants = [
6
+ { reason: 'revision_mismatch', documentId: 'doc', expectedRevision: 1, currentRevision: 2 },
7
+ {
8
+ reason: 'version_parent_mismatch',
9
+ documentId: 'doc',
10
+ previousVersionId: 'previous',
11
+ currentVersionId: 'current',
12
+ },
13
+ {
14
+ reason: 'version_parent_mismatch',
15
+ documentId: 'doc',
16
+ previousVersionId: 'previous',
17
+ currentVersionId: null,
18
+ },
19
+ {
20
+ reason: 'singleton_slot_changed',
21
+ singletonPath: 'settings',
22
+ expectedState: 'empty',
23
+ currentState: 'document',
24
+ },
25
+ ];
26
+ describe('document stale error contract', () => {
27
+ it.each(variants)('decodes live and serialized $reason errors with allowlisted fields', (details) => {
28
+ const error = ERR_DOCUMENT_STALE({
29
+ message: 'stale',
30
+ details,
31
+ logExtra: { secret: 'private' },
32
+ });
33
+ expect(getDocumentStaleDetails(error)).toEqual(details);
34
+ const report = JSON.parse(JSON.stringify(error.report()));
35
+ report.details.secret = 'must not propagate';
36
+ expect(getDocumentStaleDetails(report)).toEqual(details);
37
+ expect(report).not.toHaveProperty('logExtra');
38
+ });
39
+ it.each([
40
+ null,
41
+ undefined,
42
+ {},
43
+ new Error('stale_document'),
44
+ { code: ErrorCodes.CONFLICT, details: variants[0] },
45
+ { code: ErrorCodes.DOCUMENT_STALE },
46
+ { code: ErrorCodes.DOCUMENT_STALE, details: { ...variants[0], expectedRevision: '1' } },
47
+ { code: ErrorCodes.DOCUMENT_STALE, details: { ...variants[0], currentRevision: 1 } },
48
+ { code: ErrorCodes.DOCUMENT_STALE, details: { ...variants[0], currentRevision: Infinity } },
49
+ { code: ErrorCodes.DOCUMENT_STALE, details: { ...variants[0], documentId: ' ' } },
50
+ { code: ErrorCodes.DOCUMENT_STALE, details: { ...variants[1], currentVersionId: undefined } },
51
+ { code: ErrorCodes.DOCUMENT_STALE, details: { ...variants[1], currentVersionId: 'previous' } },
52
+ { code: ErrorCodes.DOCUMENT_STALE, details: { ...variants[3], expectedState: 'document' } },
53
+ { code: ErrorCodes.DOCUMENT_STALE, details: { ...variants[3], singletonPath: '' } },
54
+ ])('rejects unrelated or malformed payload %s', (value) => {
55
+ expect(getDocumentStaleDetails(value)).toBeNull();
56
+ });
57
+ it('tolerates hostile properties without throwing', () => {
58
+ const hostile = new Proxy({}, {
59
+ get: () => {
60
+ throw new Error('unreadable');
61
+ },
62
+ });
63
+ expect(getDocumentStaleDetails(hostile)).toBeNull();
64
+ expect(getDocumentStaleDetails({ code: ErrorCodes.DOCUMENT_STALE, details: hostile })).toBeNull();
65
+ expect(getDocumentRevisionValidationDetails(hostile)).toBeNull();
66
+ expect(normalizeDocumentVersionParentError(hostile)).toBe(hostile);
67
+ });
68
+ it.each(['missing_document_revision', 'invalid_document_revision'])('decodes typed validation reason %s only with the validation code', (reason) => {
69
+ const error = ERR_VALIDATION({ message: 'invalid', details: { reason, secret: true } });
70
+ expect(getDocumentRevisionValidationDetails(error)).toEqual({ reason });
71
+ expect(getDocumentRevisionValidationDetails(JSON.parse(JSON.stringify(error.report())))).toEqual({ reason });
72
+ expect(getDocumentRevisionValidationDetails({ code: ErrorCodes.CONFLICT, details: { reason } })).toBeNull();
73
+ expect(getDocumentStaleDetails(error)).toBeNull();
74
+ });
75
+ it('does not treat an unrelated validation error as an old-client revision error', () => {
76
+ expect(getDocumentRevisionValidationDetails(ERR_VALIDATION({ message: 'wrong path' }))).toBeNull();
77
+ });
78
+ it('normalizes a real adapter parent conflict without changing the raw adapter contract', () => {
79
+ let raw;
80
+ try {
81
+ assertDocumentVersionParent({
82
+ documentId: 'doc',
83
+ locale: 'en',
84
+ previousVersionId: 'old',
85
+ currentVersionId: 'new',
86
+ });
87
+ }
88
+ catch (error) {
89
+ raw = error;
90
+ }
91
+ expect(raw).toMatchObject({ code: ErrorCodes.CONFLICT, details: { reason: 'stale' } });
92
+ const normalized = normalizeDocumentVersionParentError(raw);
93
+ expect(normalized).toMatchObject({ code: ErrorCodes.DOCUMENT_STALE, cause: raw });
94
+ expect(getDocumentStaleDetails(normalized)).toEqual({
95
+ reason: 'version_parent_mismatch',
96
+ documentId: 'doc',
97
+ previousVersionId: 'old',
98
+ currentVersionId: 'new',
99
+ });
100
+ });
101
+ it.each([
102
+ { reason: 'missing', documentId: 'doc', previousVersionId: null, currentVersionId: 'new' },
103
+ { reason: 'stale' },
104
+ { reason: 'stale', documentId: 'doc', previousVersionId: 'same', currentVersionId: 'same' },
105
+ { reason: 'status_changed' },
106
+ { reason: 'path_conflict' },
107
+ { reason: 'stale_tree' },
108
+ ])('preserves conflict identity for $reason without a valid stale-parent shape', (details) => {
109
+ const error = ERR_CONFLICT({ message: 'conflict', details });
110
+ expect(normalizeDocumentVersionParentError(error)).toBe(error);
111
+ });
112
+ it('retains existing committed-failure discriminants when adding required revision receipts', () => {
113
+ expectTypeOf().toEqualTypeOf();
114
+ expectTypeOf().toEqualTypeOf();
115
+ expectTypeOf().toExtend();
116
+ expectTypeOf().toExtend();
117
+ expectTypeOf().toExtend();
118
+ expectTypeOf().not.toExtend();
119
+ expectTypeOf().not.toExtend();
120
+ });
121
+ });
@@ -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
@@ -12,7 +13,7 @@ function createTransitionHarness() {
12
13
  const auditRows = [];
13
14
  let status = 'draft';
14
15
  let archived = false;
15
- const db = {
16
+ const db = testAdapter({
16
17
  commands: {
17
18
  documents: {
18
19
  setDocumentStatus: async ({ status: nextStatus }) => {
@@ -53,7 +54,7 @@ function createTransitionHarness() {
53
54
  throw error;
54
55
  }
55
56
  },
56
- };
57
+ });
57
58
  return {
58
59
  db,
59
60
  order,
@@ -5,37 +5,21 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import type { DocumentRevisionReceipt } from '../../@types/index.js';
8
9
  import type { DocumentLifecycleContext } from './context.js';
9
- export interface ChangeStatusResult {
10
+ export interface ChangeStatusResult extends DocumentRevisionReceipt {
10
11
  previousStatus: string;
11
12
  newStatus: string;
12
13
  }
13
- export interface UnpublishResult {
14
+ export interface UnpublishResult extends DocumentRevisionReceipt {
14
15
  archivedCount: number;
15
16
  }
16
- /**
17
- * Change a document's workflow status.
18
- *
19
- * Flow:
20
- * 1. Fetch current document metadata
21
- * 2. Validate transition via `validateStatusTransition()`
22
- * 3. `hooks.beforeStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
23
- * 4. `db.commands.documents.setDocumentStatus(...)` — in-place mutation
24
- * 5. Auto-archive: if transitioning to `'published'`, archive other published versions
25
- * 6. `hooks.afterStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
26
- */
27
17
  export declare function changeDocumentStatus(ctx: DocumentLifecycleContext, params: {
28
18
  documentId: string;
19
+ expectedRevision: number;
29
20
  nextStatus: string;
30
21
  }): Promise<ChangeStatusResult>;
31
- /**
32
- * Unpublish a document by archiving its published version(s).
33
- *
34
- * Flow:
35
- * 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
36
- * 2. Archive published versions and append the status audit atomically
37
- * 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
38
- */
39
22
  export declare function unpublishDocument(ctx: DocumentLifecycleContext, params: {
40
23
  documentId: string;
24
+ expectedRevision: number;
41
25
  }): Promise<UnpublishResult>;