@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.
- 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/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 +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 +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 +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/package.json +2 -2
|
@@ -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>;
|
|
@@ -13,6 +13,7 @@ import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
|
13
13
|
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
14
14
|
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook } from './internals.js';
|
|
15
15
|
import { persistExistingDocumentVersion } from './persistence.js';
|
|
16
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
16
17
|
/**
|
|
17
18
|
* Restore a historical document version as the new current version.
|
|
18
19
|
*
|
|
@@ -54,9 +55,12 @@ import { persistExistingDocumentVersion } from './persistence.js';
|
|
|
54
55
|
* 8. `hooks.afterUpdate({ ..., restore })`
|
|
55
56
|
*/
|
|
56
57
|
export async function restoreDocumentVersion(ctx, params) {
|
|
58
|
+
params = { ...params };
|
|
57
59
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'restoreDocumentVersion' }, async () => {
|
|
58
60
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
59
61
|
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
62
|
+
const latest = await readDocumentForMutation(ctx, { ...params, locale: 'all' });
|
|
63
|
+
const previousVersionId = latest.document_version_id;
|
|
60
64
|
const hooks = await resolveHooks(definition);
|
|
61
65
|
// 1. Read source version (full multi-locale tree).
|
|
62
66
|
const source = await db.queries.documents.getDocumentByVersion({
|
|
@@ -82,17 +86,7 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
82
86
|
}
|
|
83
87
|
// 3. Current version metadata — used both for the already-current
|
|
84
88
|
// guard and for the sticky path resolution.
|
|
85
|
-
|
|
86
|
-
collection_id: collectionId,
|
|
87
|
-
document_id: params.documentId,
|
|
88
|
-
});
|
|
89
|
-
if (currentMeta == null) {
|
|
90
|
-
throw ERR_NOT_FOUND({
|
|
91
|
-
message: 'document not found',
|
|
92
|
-
details: { documentId: params.documentId },
|
|
93
|
-
}).log(ctx.logger);
|
|
94
|
-
}
|
|
95
|
-
if (currentMeta.document_version_id === params.sourceVersionId) {
|
|
89
|
+
if (previousVersionId === params.sourceVersionId) {
|
|
96
90
|
throw ERR_INVALID_TRANSITION({
|
|
97
91
|
message: 'source version is already the current version of this document',
|
|
98
92
|
details: {
|
|
@@ -103,12 +97,6 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
103
97
|
}
|
|
104
98
|
// 4. originalData for hooks: full reconstruction of the current
|
|
105
99
|
// version (locale-scoped, matching updateDocument's semantics).
|
|
106
|
-
const latest = await db.queries.documents.getDocumentById({
|
|
107
|
-
collection_id: collectionId,
|
|
108
|
-
document_id: params.documentId,
|
|
109
|
-
locale: defaultLocale,
|
|
110
|
-
reconstruct: true,
|
|
111
|
-
});
|
|
112
100
|
const originalData = latest ?? {};
|
|
113
101
|
const sourceFields = source.fields ?? {};
|
|
114
102
|
const restoreContext = { sourceVersionId: params.sourceVersionId };
|
|
@@ -131,19 +119,24 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
131
119
|
//
|
|
132
120
|
// No `path` is passed: restore does not change the document's path
|
|
133
121
|
// (the existing byline_document_paths row stays as-is — sticky).
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 };
|
|
141
132
|
});
|
|
133
|
+
const result = committed.value;
|
|
134
|
+
const revision = committed.revision;
|
|
142
135
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
143
136
|
const documentVersionId = extractVersionId(result.document);
|
|
144
137
|
// 7. afterUpdate. Restore is path-sticky: the canonical path comes
|
|
145
138
|
// from the current version's envelope (originalData), not the source.
|
|
146
|
-
await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId }, () => invokeHook(hooks?.afterUpdate, {
|
|
139
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId, revision }, () => invokeHook(hooks?.afterUpdate, {
|
|
147
140
|
data: sourceFields,
|
|
148
141
|
originalData,
|
|
149
142
|
collectionPath,
|
|
@@ -156,6 +149,7 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
156
149
|
documentId,
|
|
157
150
|
documentVersionId,
|
|
158
151
|
sourceVersionId: params.sourceVersionId,
|
|
152
|
+
revision,
|
|
159
153
|
};
|
|
160
154
|
});
|
|
161
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
|