@byline/core 4.19.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/db-types.d.ts +32 -1
- package/dist/@types/document-revision.d.ts +92 -0
- package/dist/@types/document-revision.js +8 -0
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/@types/site-config.d.ts +3 -1
- package/dist/config/attach-hooks.test.node.js +2 -1
- package/dist/config/config-hooks.test.node.js +2 -1
- package/dist/core.js +13 -1
- package/dist/core.test.node.js +27 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lib/errors.d.ts +15 -0
- package/dist/lib/errors.js +32 -0
- package/dist/lib/lock-conflict.test.node.d.ts +1 -0
- package/dist/lib/lock-conflict.test.node.js +31 -0
- package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
- package/dist/scheduler/run-due-tasks.test.node.js +3 -2
- package/dist/scheduler/scheduled-publication.d.ts +16 -1
- package/dist/scheduler/scheduled-publication.js +27 -1
- package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
- package/dist/scheduler/scheduled-publication.test.node.js +18 -3
- package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
- package/dist/scheduler/scheduler-boot.test.node.js +54 -12
- package/dist/scheduler/ticker.test.node.d.ts +0 -7
- package/dist/scheduler/ticker.test.node.js +3 -2
- package/dist/scheduler/validate-scheduler-config.d.ts +2 -0
- package/dist/scheduler/validate-scheduler-config.js +5 -0
- package/dist/scheduler/validate-scheduler-config.test.node.js +16 -0
- package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
- package/dist/services/collection-bootstrap.test.node.js +12 -3
- package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
- package/dist/services/discover-counter-groups.test.node.js +19 -3
- package/dist/services/document-lifecycle/audit.d.ts +1 -0
- package/dist/services/document-lifecycle/audit.js +1 -0
- package/dist/services/document-lifecycle/committed-hook.d.ts +2 -1
- package/dist/services/document-lifecycle/committed-hook.js +19 -2
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
- package/dist/services/document-lifecycle/create.d.ts +1 -0
- package/dist/services/document-lifecycle/create.js +4 -20
- package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
- package/dist/services/document-lifecycle/delete-locale.js +35 -37
- package/dist/services/document-lifecycle/delete.d.ts +8 -2
- package/dist/services/document-lifecycle/delete.js +24 -15
- package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
- package/dist/services/document-lifecycle/duplicate.js +48 -41
- package/dist/services/document-lifecycle/index.d.ts +3 -1
- package/dist/services/document-lifecycle/index.js +3 -1
- package/dist/services/document-lifecycle/persistence.js +18 -7
- package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
- package/dist/services/document-lifecycle/reorder.d.ts +11 -0
- package/dist/services/document-lifecycle/reorder.js +83 -0
- package/dist/services/document-lifecycle/restore.d.ts +2 -0
- package/dist/services/document-lifecycle/restore.js +19 -25
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
- package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
- package/dist/services/document-lifecycle/revision-guard.js +154 -0
- package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
- package/dist/services/document-lifecycle/same-document-data.js +17 -0
- package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
- package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
- package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
- package/dist/services/document-lifecycle/stale-document.js +95 -0
- package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
- package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
- package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
- package/dist/services/document-lifecycle/status.d.ts +5 -21
- package/dist/services/document-lifecycle/status.js +90 -133
- package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
- package/dist/services/document-lifecycle/system-fields.js +118 -116
- package/dist/services/document-lifecycle/tree.d.ts +9 -6
- package/dist/services/document-lifecycle/tree.js +65 -39
- package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/tree.test.node.js +152 -35
- package/dist/services/document-lifecycle/update.d.ts +14 -6
- package/dist/services/document-lifecycle/update.js +101 -80
- package/dist/services/document-lifecycle.test.node.js +401 -73
- package/dist/services/field-upload.d.ts +1 -0
- package/dist/services/field-upload.js +5 -1
- package/dist/services/field-upload.test.node.d.ts +0 -7
- package/dist/services/field-upload.test.node.js +38 -2
- package/dist/services/populate.test.node.d.ts +0 -7
- package/dist/services/populate.test.node.js +19 -3
- package/dist/services/richtext-populate.test.node.d.ts +0 -7
- package/dist/services/richtext-populate.test.node.js +3 -2
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
- package/dist/services/singleton-lifecycle/index.d.ts +1 -1
- package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
- package/dist/services/singleton-lifecycle/internals.js +74 -77
- package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
- package/dist/services/singleton-lifecycle/restore.js +3 -1
- package/dist/services/singleton-lifecycle/update.d.ts +6 -5
- package/dist/services/singleton-lifecycle/update.js +158 -91
- package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
- package/dist/services/singleton-lifecycle.test.node.js +67 -23
- package/dist/storage/db-adapter.test-helper.d.ts +26 -0
- package/dist/storage/db-adapter.test-helper.js +117 -0
- package/dist/storage/document-revision-capability.d.ts +3 -0
- package/dist/storage/document-revision-capability.js +15 -0
- package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
- package/dist/storage/document-revision-capability.test.node.js +53 -0
- package/dist/storage/document-revision.d.ts +12 -0
- package/dist/storage/document-revision.js +37 -0
- package/dist/storage/document-revision.test.node.d.ts +1 -0
- package/dist/storage/document-revision.test.node.js +48 -0
- package/dist/storage/read-snapshot.d.ts +3 -0
- package/dist/storage/read-snapshot.js +55 -0
- package/dist/storage/revision-store.test-helper.d.ts +3 -0
- package/dist/storage/revision-store.test-helper.js +16 -0
- package/dist/validation/password-sign-in.test.node.d.ts +8 -0
- package/dist/validation/password-sign-in.test.node.js +21 -0
- package/dist/validation/shared.d.ts +5 -11
- package/dist/validation/shared.js +7 -1
- package/package.json +8 -8
|
@@ -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 =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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>;
|