@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.
- package/dist/@types/db-types.d.ts +38 -4
- package/dist/@types/document-revision.d.ts +92 -0
- package/dist/@types/document-revision.js +8 -0
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/config/attach-hooks.test.node.js +2 -1
- package/dist/config/config-hooks.test.node.js +2 -1
- package/dist/core.js +8 -0
- package/dist/core.test.node.js +27 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lib/errors.d.ts +25 -0
- package/dist/lib/errors.js +42 -0
- package/dist/lib/lock-conflict.test.node.d.ts +1 -0
- package/dist/lib/lock-conflict.test.node.js +31 -0
- package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
- package/dist/scheduler/run-due-tasks.test.node.js +3 -2
- package/dist/scheduler/scheduled-publication.d.ts +16 -1
- package/dist/scheduler/scheduled-publication.js +27 -1
- package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
- package/dist/scheduler/scheduled-publication.test.node.js +18 -3
- package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
- package/dist/scheduler/scheduler-boot.test.node.js +40 -12
- package/dist/scheduler/ticker.test.node.d.ts +0 -7
- package/dist/scheduler/ticker.test.node.js +3 -2
- package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
- package/dist/services/collection-bootstrap.test.node.js +12 -3
- package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
- package/dist/services/discover-counter-groups.test.node.js +19 -3
- package/dist/services/document-lifecycle/audit.d.ts +1 -0
- package/dist/services/document-lifecycle/audit.js +1 -0
- package/dist/services/document-lifecycle/committed-hook.d.ts +25 -0
- package/dist/services/document-lifecycle/committed-hook.js +96 -0
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +30 -44
- package/dist/services/document-lifecycle/create.d.ts +1 -0
- package/dist/services/document-lifecycle/create.js +6 -21
- package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
- package/dist/services/document-lifecycle/delete-locale.js +42 -39
- 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 +55 -43
- package/dist/services/document-lifecycle/index.d.ts +5 -1
- package/dist/services/document-lifecycle/index.js +4 -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 +21 -26
- 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 +104 -82
- package/dist/services/document-lifecycle.test.node.js +556 -70
- 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 +75 -73
- 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 +75 -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/document-version-parent.d.ts +20 -0
- package/dist/storage/document-version-parent.js +34 -0
- package/dist/storage/document-version-parent.test.node.d.ts +8 -0
- package/dist/storage/document-version-parent.test.node.js +60 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/read-snapshot.d.ts +3 -0
- package/dist/storage/read-snapshot.js +55 -0
- package/dist/storage/revision-store.test-helper.d.ts +3 -0
- package/dist/storage/revision-store.test-helper.js +16 -0
- package/package.json +2 -2
|
@@ -12,6 +12,7 @@ export function publishScheduleAuditValue(schedule) {
|
|
|
12
12
|
state: schedule.state,
|
|
13
13
|
publishAt: schedule.publishAt.toISOString(),
|
|
14
14
|
targetVersionId: schedule.targetVersionId,
|
|
15
|
+
authorizedRevision: schedule.authorizedRevision,
|
|
15
16
|
lastAuthorizedBy: schedule.lastAuthorizedBy,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
@@ -49,32 +50,35 @@ export async function appendPublishScheduleCancellationAudit(params) {
|
|
|
49
50
|
*/
|
|
50
51
|
export async function commitContentVersionWithScheduleSuspension(params) {
|
|
51
52
|
const audit = requireAuditCapability(params.ctx.db);
|
|
52
|
-
const actor = auditActor(params.ctx);
|
|
53
53
|
return audit.withTransaction(async () => {
|
|
54
54
|
const result = await params.write();
|
|
55
|
-
|
|
56
|
-
documentId: params.documentId,
|
|
57
|
-
collectionId: params.ctx.collectionId,
|
|
58
|
-
});
|
|
59
|
-
if (suspension.status === 'suspended') {
|
|
60
|
-
await audit.append({
|
|
61
|
-
documentId: params.documentId,
|
|
62
|
-
collectionId: params.ctx.collectionId,
|
|
63
|
-
actorId: actor.actorId,
|
|
64
|
-
actorRealm: actor.actorRealm,
|
|
65
|
-
action: AUDIT_ACTIONS.publishScheduleSuspended,
|
|
66
|
-
field: 'scheduled_publish',
|
|
67
|
-
before: {
|
|
68
|
-
state: 'armed',
|
|
69
|
-
targetVersionId: suspension.schedule.targetVersionId,
|
|
70
|
-
publishAt: suspension.schedule.publishAt.toISOString(),
|
|
71
|
-
},
|
|
72
|
-
after: {
|
|
73
|
-
state: 'needs_reconfirm',
|
|
74
|
-
reason: 'content_edited',
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
}
|
|
55
|
+
await suspendPublishScheduleForEdit(params.ctx, audit, params.documentId, 'content_edited');
|
|
78
56
|
return result;
|
|
79
57
|
});
|
|
80
58
|
}
|
|
59
|
+
/** Called after the primary writes, under their document lock and transaction. */
|
|
60
|
+
export async function suspendPublishScheduleForEdit(ctx, audit, documentId, reason) {
|
|
61
|
+
const actor = auditActor(ctx);
|
|
62
|
+
const suspension = await ctx.db.commands.documents.publishSchedules.suspendForContentEdit({
|
|
63
|
+
documentId,
|
|
64
|
+
collectionId: ctx.collectionId,
|
|
65
|
+
reason,
|
|
66
|
+
});
|
|
67
|
+
if (suspension.status !== 'suspended')
|
|
68
|
+
return false;
|
|
69
|
+
await audit.append({
|
|
70
|
+
documentId,
|
|
71
|
+
collectionId: ctx.collectionId,
|
|
72
|
+
actorId: actor.actorId,
|
|
73
|
+
actorRealm: actor.actorRealm,
|
|
74
|
+
action: AUDIT_ACTIONS.publishScheduleSuspended,
|
|
75
|
+
field: 'scheduled_publish',
|
|
76
|
+
before: {
|
|
77
|
+
state: 'armed',
|
|
78
|
+
targetVersionId: suspension.schedule.targetVersionId,
|
|
79
|
+
publishAt: suspension.schedule.publishAt.toISOString(),
|
|
80
|
+
},
|
|
81
|
+
after: { state: 'needs_reconfirm', reason },
|
|
82
|
+
});
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StructuralMutationReceipt } from '../../@types/index.js';
|
|
2
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
3
|
+
/** Flat ordering and any sibling-key repairs share one guarded transaction. */
|
|
4
|
+
export declare function reorderDocument(ctx: DocumentLifecycleContext, params: {
|
|
5
|
+
documentId: string;
|
|
6
|
+
expectedRevision: number;
|
|
7
|
+
beforeDocumentId?: string | null;
|
|
8
|
+
afterDocumentId?: string | null;
|
|
9
|
+
}): Promise<StructuralMutationReceipt & {
|
|
10
|
+
orderKey: string;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
2
|
+
import { ERR_CONFLICT, ERR_VALIDATION } from '../../lib/errors.js';
|
|
3
|
+
import { generateKeyBetween, generateNKeysBetween } from '../../lib/fractional-index.js';
|
|
4
|
+
import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
|
|
5
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
6
|
+
/** Flat ordering and any sibling-key repairs share one guarded transaction. */
|
|
7
|
+
export async function reorderDocument(ctx, params) {
|
|
8
|
+
assertActorCanPerform(ctx.requestContext, ctx.definition, 'update');
|
|
9
|
+
await readDocumentForMutation(ctx, params);
|
|
10
|
+
if (ctx.definition.orderable !== true || ctx.definition.tree === true)
|
|
11
|
+
throw ERR_VALIDATION({ message: 'Flat reorder requires an orderable, non-tree collection' });
|
|
12
|
+
const audit = requireAuditCapability(ctx.db);
|
|
13
|
+
const committed = await commitGuardedDocumentMutation(ctx, params, async () => {
|
|
14
|
+
const rows = await ctx.db.withReadSnapshot((queries) => queries.documents.getCanonicalDocumentOrder({ collection_id: ctx.collectionId }));
|
|
15
|
+
const keys = new Map(rows.map((row) => [row.id, row.order_key]));
|
|
16
|
+
const keyed = rows.filter((row) => row.order_key !== null);
|
|
17
|
+
const corrupt = keyed.some((row, index) => {
|
|
18
|
+
if (index === 0)
|
|
19
|
+
return false;
|
|
20
|
+
const previous = keyed[index - 1];
|
|
21
|
+
return previous !== undefined && row.order_key <= previous.order_key;
|
|
22
|
+
});
|
|
23
|
+
if (corrupt) {
|
|
24
|
+
const replacements = generateNKeysBetween(null, null, rows.length);
|
|
25
|
+
rows.forEach((row, index) => {
|
|
26
|
+
keys.set(row.id, replacements[index]);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const missing = rows.filter((row) => row.order_key === null);
|
|
31
|
+
const replacements = generateNKeysBetween(keyed.at(-1)?.order_key ?? null, null, missing.length);
|
|
32
|
+
missing.forEach((row, index) => {
|
|
33
|
+
keys.set(row.id, replacements[index]);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const siblings = rows.filter((row) => row.id !== params.documentId);
|
|
37
|
+
let leftId = params.beforeDocumentId ?? null;
|
|
38
|
+
const rightId = params.afterDocumentId ?? null;
|
|
39
|
+
if (leftId === null && rightId === null)
|
|
40
|
+
leftId = siblings.at(-1)?.id ?? null;
|
|
41
|
+
const leftIndex = leftId === null ? -1 : siblings.findIndex((row) => row.id === leftId);
|
|
42
|
+
const rightIndex = rightId === null ? siblings.length : siblings.findIndex((row) => row.id === rightId);
|
|
43
|
+
if ((leftId !== null && leftIndex < 0) ||
|
|
44
|
+
(rightId !== null && rightIndex < 0) ||
|
|
45
|
+
rightIndex !== leftIndex + 1)
|
|
46
|
+
throw ERR_CONFLICT({
|
|
47
|
+
message: 'The supplied ordering neighbors have changed. Reload before reordering.',
|
|
48
|
+
});
|
|
49
|
+
const left = leftId === null ? null : (keys.get(leftId) ?? null);
|
|
50
|
+
const right = rightId === null ? null : (keys.get(rightId) ?? null);
|
|
51
|
+
const current = keys.get(params.documentId);
|
|
52
|
+
const orderKey = current && (left === null || left < current) && (right === null || current < right)
|
|
53
|
+
? current
|
|
54
|
+
: generateKeyBetween(left, right);
|
|
55
|
+
keys.set(params.documentId, orderKey);
|
|
56
|
+
let changed = false;
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
const next = keys.get(row.id);
|
|
59
|
+
if (next === undefined || next === null || next === row.order_key)
|
|
60
|
+
continue;
|
|
61
|
+
await ctx.db.commands.documents.setOrderKey({ document_id: row.id, order_key: next });
|
|
62
|
+
await audit.append({
|
|
63
|
+
documentId: row.id,
|
|
64
|
+
collectionId: ctx.collectionId,
|
|
65
|
+
...auditActor(ctx),
|
|
66
|
+
action: AUDIT_ACTIONS.orderChanged,
|
|
67
|
+
field: 'order_key',
|
|
68
|
+
before: row.order_key,
|
|
69
|
+
after: next,
|
|
70
|
+
});
|
|
71
|
+
if (row.id === params.documentId)
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
return { value: orderKey, changed };
|
|
75
|
+
}, { structuralScope: 'collection' });
|
|
76
|
+
return {
|
|
77
|
+
documentId: params.documentId,
|
|
78
|
+
orderKey: committed.value,
|
|
79
|
+
revision: committed.revision,
|
|
80
|
+
affectedDocuments: committed.affectedDocuments,
|
|
81
|
+
scheduledPublicationsNeedReconfirmation: committed.scheduledPublicationsNeedReconfirmation,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { DocumentLifecycleContext } from './context.js';
|
|
9
9
|
export interface RestoreVersionResult {
|
|
10
|
+
revision: number;
|
|
10
11
|
documentId: string;
|
|
11
12
|
documentVersionId: string;
|
|
12
13
|
sourceVersionId: string;
|
|
@@ -53,5 +54,6 @@ export interface RestoreVersionResult {
|
|
|
53
54
|
*/
|
|
54
55
|
export declare function restoreDocumentVersion(ctx: DocumentLifecycleContext, params: {
|
|
55
56
|
documentId: string;
|
|
57
|
+
expectedRevision: number;
|
|
56
58
|
sourceVersionId: string;
|
|
57
59
|
}): Promise<RestoreVersionResult>;
|
|
@@ -10,8 +10,10 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
13
14
|
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook } from './internals.js';
|
|
14
15
|
import { persistExistingDocumentVersion } from './persistence.js';
|
|
16
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
15
17
|
/**
|
|
16
18
|
* Restore a historical document version as the new current version.
|
|
17
19
|
*
|
|
@@ -53,9 +55,12 @@ import { persistExistingDocumentVersion } from './persistence.js';
|
|
|
53
55
|
* 8. `hooks.afterUpdate({ ..., restore })`
|
|
54
56
|
*/
|
|
55
57
|
export async function restoreDocumentVersion(ctx, params) {
|
|
58
|
+
params = { ...params };
|
|
56
59
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'restoreDocumentVersion' }, async () => {
|
|
57
60
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
58
61
|
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
62
|
+
const latest = await readDocumentForMutation(ctx, { ...params, locale: 'all' });
|
|
63
|
+
const previousVersionId = latest.document_version_id;
|
|
59
64
|
const hooks = await resolveHooks(definition);
|
|
60
65
|
// 1. Read source version (full multi-locale tree).
|
|
61
66
|
const source = await db.queries.documents.getDocumentByVersion({
|
|
@@ -81,17 +86,7 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
81
86
|
}
|
|
82
87
|
// 3. Current version metadata — used both for the already-current
|
|
83
88
|
// guard and for the sticky path resolution.
|
|
84
|
-
|
|
85
|
-
collection_id: collectionId,
|
|
86
|
-
document_id: params.documentId,
|
|
87
|
-
});
|
|
88
|
-
if (currentMeta == null) {
|
|
89
|
-
throw ERR_NOT_FOUND({
|
|
90
|
-
message: 'document not found',
|
|
91
|
-
details: { documentId: params.documentId },
|
|
92
|
-
}).log(ctx.logger);
|
|
93
|
-
}
|
|
94
|
-
if (currentMeta.document_version_id === params.sourceVersionId) {
|
|
89
|
+
if (previousVersionId === params.sourceVersionId) {
|
|
95
90
|
throw ERR_INVALID_TRANSITION({
|
|
96
91
|
message: 'source version is already the current version of this document',
|
|
97
92
|
details: {
|
|
@@ -102,12 +97,6 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
102
97
|
}
|
|
103
98
|
// 4. originalData for hooks: full reconstruction of the current
|
|
104
99
|
// version (locale-scoped, matching updateDocument's semantics).
|
|
105
|
-
const latest = await db.queries.documents.getDocumentById({
|
|
106
|
-
collection_id: collectionId,
|
|
107
|
-
document_id: params.documentId,
|
|
108
|
-
locale: defaultLocale,
|
|
109
|
-
reconstruct: true,
|
|
110
|
-
});
|
|
111
100
|
const originalData = latest ?? {};
|
|
112
101
|
const sourceFields = source.fields ?? {};
|
|
113
102
|
const restoreContext = { sourceVersionId: params.sourceVersionId };
|
|
@@ -130,19 +119,24 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
130
119
|
//
|
|
131
120
|
// No `path` is passed: restore does not change the document's path
|
|
132
121
|
// (the existing byline_document_paths row stays as-is — sticky).
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
122
|
+
const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId, locale: 'all' }, async () => {
|
|
123
|
+
const result = await persistExistingDocumentVersion(ctx, {
|
|
124
|
+
documentId: params.documentId,
|
|
125
|
+
action: 'restore',
|
|
126
|
+
documentData: sourceFields,
|
|
127
|
+
status: getDefaultStatus(definition),
|
|
128
|
+
locale: 'all',
|
|
129
|
+
previousVersionId: previousVersionId,
|
|
130
|
+
});
|
|
131
|
+
return { value: result, changed: true };
|
|
140
132
|
});
|
|
133
|
+
const result = committed.value;
|
|
134
|
+
const revision = committed.revision;
|
|
141
135
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
142
136
|
const documentVersionId = extractVersionId(result.document);
|
|
143
137
|
// 7. afterUpdate. Restore is path-sticky: the canonical path comes
|
|
144
138
|
// from the current version's envelope (originalData), not the source.
|
|
145
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
139
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId, revision }, () => invokeHook(hooks?.afterUpdate, {
|
|
146
140
|
data: sourceFields,
|
|
147
141
|
originalData,
|
|
148
142
|
collectionPath,
|
|
@@ -150,11 +144,12 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
150
144
|
documentVersionId,
|
|
151
145
|
path: originalData.path ?? '',
|
|
152
146
|
restore: restoreContext,
|
|
153
|
-
});
|
|
147
|
+
}));
|
|
154
148
|
return {
|
|
155
149
|
documentId,
|
|
156
150
|
documentVersionId,
|
|
157
151
|
sourceVersionId: params.sourceVersionId,
|
|
152
|
+
revision,
|
|
158
153
|
};
|
|
159
154
|
});
|
|
160
155
|
}
|
|
@@ -1,8 +1 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
-
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
-
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
-
*
|
|
6
|
-
* Copyright (c) Infonomic Company Limited
|
|
7
|
-
*/
|
|
8
1
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testAdapter } from '../../storage/db-adapter.test-helper.js';
|
|
1
2
|
/**
|
|
2
3
|
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
4
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
@@ -8,7 +9,7 @@
|
|
|
8
9
|
import { describe, expect, it } from 'vitest';
|
|
9
10
|
import { ErrorCodes } from '../../lib/errors.js';
|
|
10
11
|
import { rethrowPathConflict } from './internals.js';
|
|
11
|
-
const adapterWith = (c) => ({ classifyError: c === undefined ? undefined : () => c });
|
|
12
|
+
const adapterWith = (c) => testAdapter({ classifyError: c === undefined ? undefined : () => c });
|
|
12
13
|
describe('rethrowPathConflict', () => {
|
|
13
14
|
it.each([
|
|
14
15
|
[
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { DocumentRevisionTarget, LockedDocumentRevision } from '../../@types/index.js';
|
|
9
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
10
|
+
/** Public lifecycle services own their commit boundary, including post-commit hooks. */
|
|
11
|
+
export declare function assertLifecycleTransactionOwnership(ctx: DocumentLifecycleContext): void;
|
|
12
|
+
/** Authorized preflight: reject known staleness before hooks or external preparation. */
|
|
13
|
+
export declare function readDocumentForMutation(ctx: DocumentLifecycleContext, params: {
|
|
14
|
+
documentId: string;
|
|
15
|
+
expectedRevision: number;
|
|
16
|
+
locale?: string;
|
|
17
|
+
lenient?: boolean;
|
|
18
|
+
}): Promise<Record<string, any>>;
|
|
19
|
+
/** Internal final boundary. The callback contains storage work only, never user hooks. */
|
|
20
|
+
export declare function commitGuardedDocumentMutation<T>(ctx: DocumentLifecycleContext, target: Omit<DocumentRevisionTarget, 'collectionId'>, write: (locked: LockedDocumentRevision) => Promise<{
|
|
21
|
+
value: T;
|
|
22
|
+
changed: boolean;
|
|
23
|
+
}>, options?: {
|
|
24
|
+
collectionLock?: 'exclusive';
|
|
25
|
+
structuralScope?: 'collection';
|
|
26
|
+
}): Promise<{
|
|
27
|
+
value: T;
|
|
28
|
+
revision: number;
|
|
29
|
+
affectedDocuments: {
|
|
30
|
+
documentId: string;
|
|
31
|
+
revision: number;
|
|
32
|
+
}[];
|
|
33
|
+
scheduledPublicationsNeedReconfirmation: boolean;
|
|
34
|
+
}>;
|
|
@@ -0,0 +1,154 @@
|
|
|
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 { documentAbilityKey } from '../../auth/register-collection-abilities.js';
|
|
9
|
+
import { ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
10
|
+
import { documentRevisionFromDatabase, parseDocumentRevision, } from '../../storage/document-revision.js';
|
|
11
|
+
import { requireAuditCapability } from './audit.js';
|
|
12
|
+
import { suspendPublishScheduleForEdit } from './publish-schedule-consistency.js';
|
|
13
|
+
/** Public lifecycle services own their commit boundary, including post-commit hooks. */
|
|
14
|
+
export function assertLifecycleTransactionOwnership(ctx) {
|
|
15
|
+
if (ctx.db.revisions.isInTransaction()) {
|
|
16
|
+
throw ERR_VALIDATION({
|
|
17
|
+
message: 'Public document lifecycle operations cannot run inside an externally owned transaction',
|
|
18
|
+
details: { reason: 'external_lifecycle_transaction' },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Authorized preflight: reject known staleness before hooks or external preparation. */
|
|
23
|
+
export async function readDocumentForMutation(ctx, params) {
|
|
24
|
+
assertLifecycleTransactionOwnership(ctx);
|
|
25
|
+
const expectedRevision = parseDocumentRevision(params.expectedRevision);
|
|
26
|
+
return ctx.db.withReadSnapshot(async (queries) => {
|
|
27
|
+
const source = await queries.documents.getDocumentById({
|
|
28
|
+
collection_id: ctx.collectionId,
|
|
29
|
+
document_id: params.documentId,
|
|
30
|
+
locale: params.locale ?? ctx.defaultLocale,
|
|
31
|
+
reconstruct: true,
|
|
32
|
+
readMode: 'any',
|
|
33
|
+
lenient: params.lenient,
|
|
34
|
+
requestContext: ctx.requestContext,
|
|
35
|
+
});
|
|
36
|
+
if (source == null)
|
|
37
|
+
throw ERR_NOT_FOUND({
|
|
38
|
+
message: 'document not found',
|
|
39
|
+
details: { documentId: params.documentId },
|
|
40
|
+
});
|
|
41
|
+
const currentRevision = documentRevisionFromDatabase(await queries.documents.getDocumentRevision({
|
|
42
|
+
collection_id: ctx.collectionId,
|
|
43
|
+
document_id: params.documentId,
|
|
44
|
+
}));
|
|
45
|
+
if (currentRevision !== expectedRevision)
|
|
46
|
+
throw ERR_DOCUMENT_STALE({
|
|
47
|
+
message: 'This document has changed. Reload it before making changes.',
|
|
48
|
+
details: {
|
|
49
|
+
reason: 'revision_mismatch',
|
|
50
|
+
documentId: params.documentId,
|
|
51
|
+
expectedRevision,
|
|
52
|
+
currentRevision,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
return source;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Internal final boundary. The callback contains storage work only, never user hooks. */
|
|
59
|
+
export async function commitGuardedDocumentMutation(ctx, target, write, options = {}) {
|
|
60
|
+
assertLifecycleTransactionOwnership(ctx);
|
|
61
|
+
target = { ...target, expectedRevision: parseDocumentRevision(target.expectedRevision) };
|
|
62
|
+
const audit = requireAuditCapability(ctx.db);
|
|
63
|
+
return audit.withTransaction(async () => {
|
|
64
|
+
// Acquire the collection lock before any document lock, including FK locks
|
|
65
|
+
// from version/path/audit inserts. Ordinary writers share it; exclusive
|
|
66
|
+
// operations select their mode up front, never upgrading after locking a document.
|
|
67
|
+
const mode = options.structuralScope === 'collection' ||
|
|
68
|
+
options.collectionLock === 'exclusive' ||
|
|
69
|
+
ctx.definition.singleton === true ||
|
|
70
|
+
ctx.definition.tree === true
|
|
71
|
+
? 'exclusive'
|
|
72
|
+
: 'shared';
|
|
73
|
+
await ctx.db.commands.collections.lockCollectionRegistration(ctx.collectionId, mode);
|
|
74
|
+
if (ctx.definition.singleton === true) {
|
|
75
|
+
const mapped = await ctx.db.queries.singletons.getMappedDocumentId(ctx.collectionId);
|
|
76
|
+
if (mapped !== target.documentId)
|
|
77
|
+
throw ERR_NOT_FOUND({ message: 'Singleton target is unavailable; reload before saving' });
|
|
78
|
+
}
|
|
79
|
+
const structural = ctx.definition.tree === true || options.structuralScope === 'collection';
|
|
80
|
+
const before = structural
|
|
81
|
+
? await ctx.db.revisions.readStructure({
|
|
82
|
+
collectionId: ctx.collectionId,
|
|
83
|
+
...(options.structuralScope === 'collection'
|
|
84
|
+
? {}
|
|
85
|
+
: { documentIds: [target.documentId], parentDocumentId: target.documentId }),
|
|
86
|
+
})
|
|
87
|
+
: [];
|
|
88
|
+
const targets = before
|
|
89
|
+
.filter((row) => row.live && row.documentId !== target.documentId)
|
|
90
|
+
.map((row) => ({
|
|
91
|
+
documentId: row.documentId,
|
|
92
|
+
collectionId: ctx.collectionId,
|
|
93
|
+
expectedRevision: row.revision,
|
|
94
|
+
}));
|
|
95
|
+
const lockedDocuments = await ctx.db.revisions.lock([
|
|
96
|
+
...targets,
|
|
97
|
+
{ ...target, collectionId: ctx.collectionId },
|
|
98
|
+
]);
|
|
99
|
+
const locked = lockedDocuments.find((row) => row.documentId === target.documentId);
|
|
100
|
+
if (!locked)
|
|
101
|
+
throw new Error('Revision guard returned no locked observation');
|
|
102
|
+
if (structural)
|
|
103
|
+
await ctx.db.commands.documents.publishSchedules.lockDocuments(lockedDocuments.map((row) => row.documentId));
|
|
104
|
+
const outcome = await write(locked);
|
|
105
|
+
const after = structural
|
|
106
|
+
? await ctx.db.revisions.readStructure({
|
|
107
|
+
collectionId: ctx.collectionId,
|
|
108
|
+
documentIds: before.map((row) => row.documentId),
|
|
109
|
+
})
|
|
110
|
+
: [];
|
|
111
|
+
const structuralChanges = new Set();
|
|
112
|
+
for (const previous of before) {
|
|
113
|
+
const current = after.find((row) => row.documentId === previous.documentId);
|
|
114
|
+
if (current &&
|
|
115
|
+
(current.orderKey !== previous.orderKey ||
|
|
116
|
+
current.placed !== previous.placed ||
|
|
117
|
+
current.parentDocumentId !== previous.parentDocumentId ||
|
|
118
|
+
current.treeOrderKey !== previous.treeOrderKey))
|
|
119
|
+
structuralChanges.add(previous.documentId);
|
|
120
|
+
}
|
|
121
|
+
// Legacy/internal writes can leave edges attached to unavailable documents.
|
|
122
|
+
// Never commit a derived structural write that was not revision-locked.
|
|
123
|
+
if ([...structuralChanges].some((id) => !lockedDocuments.some((row) => row.documentId === id)))
|
|
124
|
+
throw ERR_NOT_FOUND({
|
|
125
|
+
message: 'A structural target is unavailable; reload before changing this tree.',
|
|
126
|
+
});
|
|
127
|
+
let suspended = false;
|
|
128
|
+
const affectedDocuments = [];
|
|
129
|
+
let revision = locked.revision;
|
|
130
|
+
for (const document of lockedDocuments) {
|
|
131
|
+
const changedStructure = structuralChanges.has(document.documentId);
|
|
132
|
+
if (changedStructure)
|
|
133
|
+
suspended =
|
|
134
|
+
(await suspendPublishScheduleForEdit(ctx, audit, document.documentId, 'document_metadata_changed')) || suspended;
|
|
135
|
+
if ((document.documentId === target.documentId && outcome.changed) || changedStructure) {
|
|
136
|
+
const receipt = await ctx.db.revisions.advance(document);
|
|
137
|
+
affectedDocuments.push(receipt);
|
|
138
|
+
if (document.documentId === target.documentId)
|
|
139
|
+
revision = receipt.revision;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const actor = ctx.requestContext?.actor;
|
|
143
|
+
const canSeeSchedules = actor?.hasAbility(documentAbilityKey(ctx.definition, 'publish')) === true &&
|
|
144
|
+
actor.hasAbility(documentAbilityKey(ctx.definition, 'changeStatus'));
|
|
145
|
+
return {
|
|
146
|
+
value: outcome.value,
|
|
147
|
+
revision,
|
|
148
|
+
affectedDocuments: actor?.hasAbility(documentAbilityKey(ctx.definition, 'read'))
|
|
149
|
+
? affectedDocuments
|
|
150
|
+
: affectedDocuments.filter((row) => row.documentId === target.documentId),
|
|
151
|
+
scheduledPublicationsNeedReconfirmation: canSeeSchedules && suspended,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Compare reconstructed field data without treating omitted optional keys as edits. */
|
|
2
|
+
export function sameDocumentData(left, right) {
|
|
3
|
+
if (Object.is(left, right))
|
|
4
|
+
return true;
|
|
5
|
+
if (left instanceof Date && right instanceof Date)
|
|
6
|
+
return left.getTime() === right.getTime();
|
|
7
|
+
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object')
|
|
8
|
+
return false;
|
|
9
|
+
if (Array.isArray(left) || Array.isArray(right))
|
|
10
|
+
return (Array.isArray(left) &&
|
|
11
|
+
Array.isArray(right) &&
|
|
12
|
+
left.length === right.length &&
|
|
13
|
+
left.every((value, index) => sameDocumentData(value, right[index])));
|
|
14
|
+
const a = left, b = right;
|
|
15
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
16
|
+
return [...keys].every((key) => sameDocumentData(a[key], b[key]));
|
|
17
|
+
}
|
|
@@ -15,6 +15,7 @@ export type ClaimedScheduledPublicationResult = {
|
|
|
15
15
|
status: 'claim_lost';
|
|
16
16
|
} | {
|
|
17
17
|
status: 'target_changed';
|
|
18
|
+
reason: 'content_edited' | 'document_metadata_changed';
|
|
18
19
|
} | {
|
|
19
20
|
status: 'terminal';
|
|
20
21
|
reason: 'document_not_found' | 'already_published' | 'invalid_transition';
|
|
@@ -23,14 +24,24 @@ export declare function scheduleDocumentPublish(ctx: DocumentLifecycleContext, p
|
|
|
23
24
|
documentId: string;
|
|
24
25
|
publishAt: string;
|
|
25
26
|
expectedVersionId: string;
|
|
26
|
-
|
|
27
|
+
expectedRevision: number;
|
|
28
|
+
}): Promise<DocumentPublishSchedule & {
|
|
29
|
+
revision: number;
|
|
30
|
+
}>;
|
|
27
31
|
export declare function confirmDocumentScheduledPublish(ctx: DocumentLifecycleContext, params: {
|
|
28
32
|
documentId: string;
|
|
29
33
|
expectedVersionId: string;
|
|
30
|
-
|
|
34
|
+
expectedRevision: number;
|
|
35
|
+
}): Promise<DocumentPublishSchedule & {
|
|
36
|
+
revision: number;
|
|
37
|
+
}>;
|
|
31
38
|
export declare function cancelDocumentScheduledPublish(ctx: DocumentLifecycleContext, params: {
|
|
32
39
|
documentId: string;
|
|
33
|
-
|
|
40
|
+
expectedRevision: number;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
schedule: DocumentPublishSchedule | null;
|
|
43
|
+
revision: number;
|
|
44
|
+
}>;
|
|
34
45
|
export declare function getDocumentScheduledPublish(ctx: DocumentLifecycleContext, params: {
|
|
35
46
|
documentId: string;
|
|
36
47
|
}): Promise<DocumentPublishSchedule | null>;
|
|
@@ -39,7 +50,12 @@ export declare function listDocumentPublishSchedules(core: BylineCore, requestCo
|
|
|
39
50
|
lastAuthorizedBy?: string;
|
|
40
51
|
page: number;
|
|
41
52
|
pageSize: number;
|
|
42
|
-
}): Promise<DocumentPublishSchedulePage
|
|
53
|
+
}): Promise<Omit<DocumentPublishSchedulePage, 'schedules'> & {
|
|
54
|
+
schedules: (DocumentPublishSchedule & {
|
|
55
|
+
revision: number;
|
|
56
|
+
documentPath: string | null;
|
|
57
|
+
})[];
|
|
58
|
+
}>;
|
|
43
59
|
/**
|
|
44
60
|
* Execute one token-fenced publication through the normal status lifecycle.
|
|
45
61
|
* Internal to the server-only sweep: authorization was captured when the row
|