@byline/core 3.5.1 → 3.7.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/field-types.d.ts +22 -0
- package/dist/@types/site-config.d.ts +8 -1
- package/dist/services/document-lifecycle/context.d.ts +79 -0
- package/dist/services/document-lifecycle/context.js +8 -0
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +62 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +157 -0
- package/dist/services/document-lifecycle/create.d.ts +45 -0
- package/dist/services/document-lifecycle/create.js +91 -0
- package/dist/services/document-lifecycle/delete-locale.d.ts +44 -0
- package/dist/services/document-lifecycle/delete-locale.js +117 -0
- package/dist/services/document-lifecycle/delete.d.ts +37 -0
- package/dist/services/document-lifecycle/delete.js +113 -0
- package/dist/services/document-lifecycle/duplicate.d.ts +60 -0
- package/dist/services/document-lifecycle/duplicate.js +219 -0
- package/dist/services/document-lifecycle/index.d.ts +43 -0
- package/dist/services/document-lifecycle/index.js +33 -0
- package/dist/services/document-lifecycle/internals.d.ts +113 -0
- package/dist/services/document-lifecycle/internals.js +218 -0
- package/dist/services/document-lifecycle/merge-locale-data.d.ts +48 -0
- package/dist/services/document-lifecycle/merge-locale-data.js +147 -0
- package/dist/services/document-lifecycle/restore.d.ts +57 -0
- package/dist/services/document-lifecycle/restore.js +162 -0
- package/dist/services/document-lifecycle/status.d.ts +41 -0
- package/dist/services/document-lifecycle/status.js +150 -0
- package/dist/services/document-lifecycle/system-fields.d.ts +62 -0
- package/dist/services/document-lifecycle/system-fields.js +100 -0
- package/dist/services/document-lifecycle/update.d.ts +84 -0
- package/dist/services/document-lifecycle/update.js +216 -0
- package/dist/services/document-lifecycle.test.node.js +1 -1
- package/dist/services/document-to-markdown.d.ts +79 -0
- package/dist/services/document-to-markdown.js +267 -0
- package/dist/services/document-to-markdown.test.node.d.ts +8 -0
- package/dist/services/document-to-markdown.test.node.js +161 -0
- package/dist/services/field-upload.js +1 -1
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +2 -1
- package/package.json +2 -2
|
@@ -0,0 +1,150 @@
|
|
|
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 { resolveHooks } from '../../@types/index.js';
|
|
9
|
+
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
|
+
import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
11
|
+
import { withLogContext } from '../../lib/logger.js';
|
|
12
|
+
import { getWorkflow, validateStatusTransition } from '../../workflow/workflow.js';
|
|
13
|
+
import { invokeHook } from './internals.js';
|
|
14
|
+
/**
|
|
15
|
+
* Change a document's workflow status.
|
|
16
|
+
*
|
|
17
|
+
* Flow:
|
|
18
|
+
* 1. Fetch current document metadata
|
|
19
|
+
* 2. Validate transition via `validateStatusTransition()`
|
|
20
|
+
* 3. `hooks.beforeStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
|
|
21
|
+
* 4. `db.commands.documents.setDocumentStatus(...)` — in-place mutation
|
|
22
|
+
* 5. Auto-archive: if transitioning to `'published'`, archive other published versions
|
|
23
|
+
* 6. `hooks.afterStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
|
|
24
|
+
*/
|
|
25
|
+
export async function changeDocumentStatus(ctx, params) {
|
|
26
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'changeDocumentStatus' }, async () => {
|
|
27
|
+
const { db, definition, collectionId, collectionPath } = ctx;
|
|
28
|
+
// Every transition requires the general changeStatus ability.
|
|
29
|
+
// Transitions that target the `published` status additionally
|
|
30
|
+
// require the narrower `publish` ability — so installations can
|
|
31
|
+
// grant "move things through the workflow" without also granting
|
|
32
|
+
// "flip the final publish switch".
|
|
33
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'changeStatus');
|
|
34
|
+
if (params.nextStatus === 'published') {
|
|
35
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'publish');
|
|
36
|
+
}
|
|
37
|
+
// Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups)
|
|
38
|
+
// have no transitions to perform. Reject early with a clear message
|
|
39
|
+
// rather than relying on the generic ±1-step validator.
|
|
40
|
+
const workflow = getWorkflow(definition);
|
|
41
|
+
if (workflow.statuses.length <= 1) {
|
|
42
|
+
throw ERR_INVALID_TRANSITION({
|
|
43
|
+
message: `collection '${collectionPath}' has a single-status workflow; status transitions are not supported`,
|
|
44
|
+
details: { collectionPath, nextStatus: params.nextStatus },
|
|
45
|
+
}).log(ctx.logger);
|
|
46
|
+
}
|
|
47
|
+
const hooks = await resolveHooks(definition);
|
|
48
|
+
// 1. Fetch current version metadata. No field reconstruction needed —
|
|
49
|
+
// status transitions only touch the document_versions.status column.
|
|
50
|
+
const latest = await db.queries.documents.getCurrentVersionMetadata({
|
|
51
|
+
collection_id: collectionId,
|
|
52
|
+
document_id: params.documentId,
|
|
53
|
+
});
|
|
54
|
+
if (latest == null) {
|
|
55
|
+
throw ERR_NOT_FOUND({
|
|
56
|
+
message: 'document not found',
|
|
57
|
+
details: { documentId: params.documentId },
|
|
58
|
+
}).log(ctx.logger);
|
|
59
|
+
}
|
|
60
|
+
const currentStatus = latest.status ?? 'draft';
|
|
61
|
+
const documentVersionId = latest.document_version_id;
|
|
62
|
+
// 2. Validate transition.
|
|
63
|
+
const result = validateStatusTransition(workflow, currentStatus, params.nextStatus);
|
|
64
|
+
if (!result.valid) {
|
|
65
|
+
throw ERR_INVALID_TRANSITION({
|
|
66
|
+
message: result.reason ??
|
|
67
|
+
`invalid status transition from '${currentStatus}' to '${params.nextStatus}'`,
|
|
68
|
+
details: { currentStatus, nextStatus: params.nextStatus },
|
|
69
|
+
}).log(ctx.logger);
|
|
70
|
+
}
|
|
71
|
+
// Resolve the document's canonical path so the hooks can act on the
|
|
72
|
+
// specific document/URL (CDN purge, cache-key drop). Narrow lookup —
|
|
73
|
+
// getCurrentVersionMetadata deliberately omits the path subquery.
|
|
74
|
+
const path = (await ctx.db.queries.documents.getCurrentPath({
|
|
75
|
+
collection_id: collectionId,
|
|
76
|
+
document_id: params.documentId,
|
|
77
|
+
})) ?? '';
|
|
78
|
+
const hookCtx = {
|
|
79
|
+
documentId: params.documentId,
|
|
80
|
+
documentVersionId,
|
|
81
|
+
collectionPath,
|
|
82
|
+
path,
|
|
83
|
+
previousStatus: currentStatus,
|
|
84
|
+
nextStatus: params.nextStatus,
|
|
85
|
+
};
|
|
86
|
+
// 3. beforeStatusChange hook.
|
|
87
|
+
await invokeHook(hooks?.beforeStatusChange, hookCtx);
|
|
88
|
+
// 4. Mutate status in-place.
|
|
89
|
+
await db.commands.documents.setDocumentStatus({
|
|
90
|
+
document_version_id: documentVersionId,
|
|
91
|
+
status: params.nextStatus,
|
|
92
|
+
});
|
|
93
|
+
// 5. Auto-archive previous published versions.
|
|
94
|
+
if (params.nextStatus === 'published') {
|
|
95
|
+
await db.commands.documents.archivePublishedVersions({
|
|
96
|
+
document_id: params.documentId,
|
|
97
|
+
excludeVersionId: documentVersionId,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// 6. afterStatusChange hook.
|
|
101
|
+
await invokeHook(hooks?.afterStatusChange, hookCtx);
|
|
102
|
+
return { previousStatus: currentStatus, newStatus: params.nextStatus };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Unpublish a document by archiving its published version(s).
|
|
107
|
+
*
|
|
108
|
+
* Flow:
|
|
109
|
+
* 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
|
|
110
|
+
* 2. `db.commands.documents.archivePublishedVersions(...)`
|
|
111
|
+
* 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
|
|
112
|
+
*/
|
|
113
|
+
export async function unpublishDocument(ctx, params) {
|
|
114
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'unpublishDocument' }, async () => {
|
|
115
|
+
const { db, collectionId, collectionPath, definition } = ctx;
|
|
116
|
+
// Unpublish is a workflow transition out of `published` — reuse the
|
|
117
|
+
// changeStatus gate rather than a separate ability.
|
|
118
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'changeStatus');
|
|
119
|
+
// Single-status workflows have nothing to unpublish to.
|
|
120
|
+
const workflow = getWorkflow(definition);
|
|
121
|
+
if (workflow.statuses.length <= 1) {
|
|
122
|
+
throw ERR_INVALID_TRANSITION({
|
|
123
|
+
message: `collection '${collectionPath}' has a single-status workflow; unpublish is not supported`,
|
|
124
|
+
details: { collectionPath },
|
|
125
|
+
}).log(ctx.logger);
|
|
126
|
+
}
|
|
127
|
+
const hooks = await resolveHooks(definition);
|
|
128
|
+
// Resolve the document's canonical path so the hooks can target the
|
|
129
|
+
// specific document/URL (CDN purge, cache-key drop).
|
|
130
|
+
const path = (await db.queries.documents.getCurrentPath({
|
|
131
|
+
collection_id: collectionId,
|
|
132
|
+
document_id: params.documentId,
|
|
133
|
+
})) ?? '';
|
|
134
|
+
await invokeHook(hooks?.beforeUnpublish, {
|
|
135
|
+
documentId: params.documentId,
|
|
136
|
+
collectionPath,
|
|
137
|
+
path,
|
|
138
|
+
});
|
|
139
|
+
const archivedCount = await db.commands.documents.archivePublishedVersions({
|
|
140
|
+
document_id: params.documentId,
|
|
141
|
+
});
|
|
142
|
+
await invokeHook(hooks?.afterUnpublish, {
|
|
143
|
+
documentId: params.documentId,
|
|
144
|
+
collectionPath,
|
|
145
|
+
path,
|
|
146
|
+
archivedCount,
|
|
147
|
+
});
|
|
148
|
+
return { archivedCount };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { DocumentLifecycleContext } from './context.js';
|
|
9
|
+
export interface UpdateDocumentSystemFieldsResult {
|
|
10
|
+
documentId: string;
|
|
11
|
+
/** The path actually written, or `undefined` when no path write occurred. */
|
|
12
|
+
path?: string;
|
|
13
|
+
/** Whether the advertised-locale set was rewritten this call. */
|
|
14
|
+
availableLocalesWritten: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Write a document's system-managed, document-grain fields — `path` and the
|
|
18
|
+
* editorial `availableLocales` set — **without** minting a new version or
|
|
19
|
+
* touching workflow status.
|
|
20
|
+
*
|
|
21
|
+
* These fields are document-grain (they live in `byline_document_paths` and
|
|
22
|
+
* `byline_document_available_locales`, keyed by logical document, sticky across
|
|
23
|
+
* versions), so a workflow status change would falsely imply the edit is gated
|
|
24
|
+
* behind publish. It is not: the write is immediate and applies across every
|
|
25
|
+
* version. This service backs the admin path / available-locales widgets'
|
|
26
|
+
* direct-write Save (the `direct-write` and `both` dirty-reason cases). The
|
|
27
|
+
* public *advertised* set remains the intersection of `availableLocales` with
|
|
28
|
+
* the resolved version's completeness ledger. See docs/I18N.md.
|
|
29
|
+
*
|
|
30
|
+
* Flow:
|
|
31
|
+
* 1. `assertActorCanPerform('update')` — same auth gate as content writes.
|
|
32
|
+
* 2. Fetch the document to resolve its `source_locale` anchor + current path.
|
|
33
|
+
* 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
|
|
34
|
+
* rule (translation-locale path edits are dropped with a warn); a real
|
|
35
|
+
* change is written via `updateDocumentPath`, mapping the unique-constraint
|
|
36
|
+
* violation to `ERR_PATH_CONFLICT`.
|
|
37
|
+
* 4. `availableLocales` (when supplied): rewritten wholesale via
|
|
38
|
+
* `setDocumentAvailableLocales`.
|
|
39
|
+
*
|
|
40
|
+
* No content hooks fire — these are not content writes. Accountability for
|
|
41
|
+
* these mutations is the job of the (planned) document-grain audit log.
|
|
42
|
+
*
|
|
43
|
+
* @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
|
|
44
|
+
* @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
|
|
45
|
+
*/
|
|
46
|
+
export declare function updateDocumentSystemFields(ctx: DocumentLifecycleContext, params: {
|
|
47
|
+
documentId: string;
|
|
48
|
+
locale?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Explicit path override from the path widget. `null` / empty / omitted
|
|
51
|
+
* means "no path write" (the existing row stays sticky). A non-empty
|
|
52
|
+
* string is written when the request locale is the document's source
|
|
53
|
+
* locale; on a translation locale it is dropped with a warn.
|
|
54
|
+
*/
|
|
55
|
+
path?: string | null;
|
|
56
|
+
/**
|
|
57
|
+
* The editorial advertised-locale set from the available-locales widget.
|
|
58
|
+
* `undefined` means "no advertised-locale write"; an explicit array — `[]`
|
|
59
|
+
* included — replaces the set wholesale.
|
|
60
|
+
*/
|
|
61
|
+
availableLocales?: string[];
|
|
62
|
+
}): Promise<UpdateDocumentSystemFieldsResult>;
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
9
|
+
import { ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
10
|
+
import { withLogContext } from '../../lib/logger.js';
|
|
11
|
+
import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
|
|
12
|
+
/**
|
|
13
|
+
* Write a document's system-managed, document-grain fields — `path` and the
|
|
14
|
+
* editorial `availableLocales` set — **without** minting a new version or
|
|
15
|
+
* touching workflow status.
|
|
16
|
+
*
|
|
17
|
+
* These fields are document-grain (they live in `byline_document_paths` and
|
|
18
|
+
* `byline_document_available_locales`, keyed by logical document, sticky across
|
|
19
|
+
* versions), so a workflow status change would falsely imply the edit is gated
|
|
20
|
+
* behind publish. It is not: the write is immediate and applies across every
|
|
21
|
+
* version. This service backs the admin path / available-locales widgets'
|
|
22
|
+
* direct-write Save (the `direct-write` and `both` dirty-reason cases). The
|
|
23
|
+
* public *advertised* set remains the intersection of `availableLocales` with
|
|
24
|
+
* the resolved version's completeness ledger. See docs/I18N.md.
|
|
25
|
+
*
|
|
26
|
+
* Flow:
|
|
27
|
+
* 1. `assertActorCanPerform('update')` — same auth gate as content writes.
|
|
28
|
+
* 2. Fetch the document to resolve its `source_locale` anchor + current path.
|
|
29
|
+
* 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
|
|
30
|
+
* rule (translation-locale path edits are dropped with a warn); a real
|
|
31
|
+
* change is written via `updateDocumentPath`, mapping the unique-constraint
|
|
32
|
+
* violation to `ERR_PATH_CONFLICT`.
|
|
33
|
+
* 4. `availableLocales` (when supplied): rewritten wholesale via
|
|
34
|
+
* `setDocumentAvailableLocales`.
|
|
35
|
+
*
|
|
36
|
+
* No content hooks fire — these are not content writes. Accountability for
|
|
37
|
+
* these mutations is the job of the (planned) document-grain audit log.
|
|
38
|
+
*
|
|
39
|
+
* @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
|
|
40
|
+
* @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
|
|
41
|
+
*/
|
|
42
|
+
export async function updateDocumentSystemFields(ctx, params) {
|
|
43
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentSystemFields' }, async () => {
|
|
44
|
+
const { db, collectionId, collectionPath, defaultLocale } = ctx;
|
|
45
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
46
|
+
const requestLocale = params.locale ?? defaultLocale;
|
|
47
|
+
// Resolve the document's source-locale anchor + current path. Both feed
|
|
48
|
+
// the path source-locale guard below; the fetch also asserts existence.
|
|
49
|
+
const latest = await db.queries.documents.getDocumentById({
|
|
50
|
+
collection_id: collectionId,
|
|
51
|
+
document_id: params.documentId,
|
|
52
|
+
locale: requestLocale,
|
|
53
|
+
reconstruct: true,
|
|
54
|
+
});
|
|
55
|
+
if (latest == null) {
|
|
56
|
+
throw ERR_NOT_FOUND({
|
|
57
|
+
message: 'document not found',
|
|
58
|
+
details: { documentId: params.documentId },
|
|
59
|
+
}).log(ctx.logger);
|
|
60
|
+
}
|
|
61
|
+
const originalData = latest;
|
|
62
|
+
const sourceLocale = originalData.source_locale ?? defaultLocale;
|
|
63
|
+
// Path: honour the same source-locale-only rule the versioned write
|
|
64
|
+
// uses. `resolvePathForUpdate` returns `undefined` to mean "skip the
|
|
65
|
+
// write" (null/empty override, or a translation-locale save).
|
|
66
|
+
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
67
|
+
const pathForCommand = resolvePathForUpdate({
|
|
68
|
+
explicitPath,
|
|
69
|
+
currentPath: originalData.path,
|
|
70
|
+
requestLocale,
|
|
71
|
+
sourceLocale,
|
|
72
|
+
documentId: params.documentId,
|
|
73
|
+
logger: ctx.logger,
|
|
74
|
+
});
|
|
75
|
+
if (pathForCommand !== undefined) {
|
|
76
|
+
await db.commands.documents
|
|
77
|
+
.updateDocumentPath({
|
|
78
|
+
documentId: params.documentId,
|
|
79
|
+
collectionId,
|
|
80
|
+
locale: sourceLocale,
|
|
81
|
+
path: pathForCommand,
|
|
82
|
+
})
|
|
83
|
+
.catch((err) => rethrowPathConflict(err, pathForCommand, defaultLocale));
|
|
84
|
+
}
|
|
85
|
+
// Advertised locales: rewrite the document-grain set wholesale.
|
|
86
|
+
const availableLocalesWritten = params.availableLocales !== undefined;
|
|
87
|
+
if (params.availableLocales !== undefined) {
|
|
88
|
+
await db.commands.documents.setDocumentAvailableLocales({
|
|
89
|
+
documentId: params.documentId,
|
|
90
|
+
collectionId,
|
|
91
|
+
availableLocales: params.availableLocales,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
documentId: params.documentId,
|
|
96
|
+
path: pathForCommand,
|
|
97
|
+
availableLocalesWritten,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { DocumentPatch } from '../../patches/index.js';
|
|
9
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
10
|
+
export interface UpdateDocumentResult {
|
|
11
|
+
documentId: string;
|
|
12
|
+
documentVersionId: string;
|
|
13
|
+
}
|
|
14
|
+
export interface UpdateDocumentWithPatchesResult {
|
|
15
|
+
documentId: string;
|
|
16
|
+
documentVersionId: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Update a document via full replacement (PUT semantics).
|
|
20
|
+
*
|
|
21
|
+
* Unlike the previous implementation, this now fetches the current version
|
|
22
|
+
* from storage to provide a real `originalData` to hooks.
|
|
23
|
+
*
|
|
24
|
+
* Flow:
|
|
25
|
+
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
26
|
+
* 2. `normaliseDateFields(data)`
|
|
27
|
+
* 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`
|
|
28
|
+
* 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
29
|
+
* 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
|
|
30
|
+
*/
|
|
31
|
+
export declare function updateDocument(ctx: DocumentLifecycleContext, params: {
|
|
32
|
+
documentId: string;
|
|
33
|
+
data: Record<string, any>;
|
|
34
|
+
locale?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Explicit path override. When omitted, the previous version's path
|
|
37
|
+
* carries forward unchanged (sticky). The lifecycle never re-derives
|
|
38
|
+
* `path` from the source field on update — that is an explicit user
|
|
39
|
+
* action driven by the admin path widget.
|
|
40
|
+
*/
|
|
41
|
+
path?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The editorial advertised-locale set. `undefined` leaves the existing
|
|
44
|
+
* set untouched (sticky — document-grain, like `path`); an explicit array
|
|
45
|
+
* (empty included) replaces it wholesale. Driven by the admin
|
|
46
|
+
* available-locales sidebar widget. See docs/I18N.md.
|
|
47
|
+
*/
|
|
48
|
+
availableLocales?: string[];
|
|
49
|
+
}): Promise<UpdateDocumentResult>;
|
|
50
|
+
/**
|
|
51
|
+
* Update a document via patch application.
|
|
52
|
+
*
|
|
53
|
+
* Flow:
|
|
54
|
+
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
55
|
+
* 2. Optimistic concurrency check on `documentVersionId`
|
|
56
|
+
* 3. `applyPatches(definition, originalData, patches)` → `nextData`
|
|
57
|
+
* 4. `normaliseDateFields(nextData)`
|
|
58
|
+
* 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`
|
|
59
|
+
* 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
60
|
+
* 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
|
|
61
|
+
*
|
|
62
|
+
* @throws {BylineError} ERR_CONFLICT if the supplied `documentVersionId` does not match the current version.
|
|
63
|
+
* @throws {BylineError} ERR_PATCH_FAILED if `applyPatches` fails.
|
|
64
|
+
*/
|
|
65
|
+
export declare function updateDocumentWithPatches(ctx: DocumentLifecycleContext, params: {
|
|
66
|
+
documentId: string;
|
|
67
|
+
patches: DocumentPatch[];
|
|
68
|
+
/** Client-supplied version ID for optimistic concurrency. */
|
|
69
|
+
documentVersionId?: string;
|
|
70
|
+
locale?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Explicit path override (typically supplied alongside patches when
|
|
73
|
+
* the admin path widget has been edited). When omitted, sticky from
|
|
74
|
+
* the previous version.
|
|
75
|
+
*/
|
|
76
|
+
path?: string;
|
|
77
|
+
/**
|
|
78
|
+
* The editorial advertised-locale set (typically supplied alongside
|
|
79
|
+
* patches when the admin available-locales widget has been edited).
|
|
80
|
+
* `undefined` leaves the existing set untouched (sticky); an explicit
|
|
81
|
+
* array replaces it wholesale. See docs/I18N.md.
|
|
82
|
+
*/
|
|
83
|
+
availableLocales?: string[];
|
|
84
|
+
}): Promise<UpdateDocumentWithPatchesResult>;
|
|
@@ -0,0 +1,216 @@
|
|
|
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 { resolveHooks } from '../../@types/index.js';
|
|
9
|
+
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
|
+
import { ERR_CONFLICT, ERR_NOT_FOUND, ERR_PATCH_FAILED } from '../../lib/errors.js';
|
|
11
|
+
import { withLogContext } from '../../lib/logger.js';
|
|
12
|
+
import { applyPatches } from '../../patches/index.js';
|
|
13
|
+
import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
14
|
+
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
15
|
+
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
|
+
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
|
|
17
|
+
/**
|
|
18
|
+
* Update a document via full replacement (PUT semantics).
|
|
19
|
+
*
|
|
20
|
+
* Unlike the previous implementation, this now fetches the current version
|
|
21
|
+
* from storage to provide a real `originalData` to hooks.
|
|
22
|
+
*
|
|
23
|
+
* Flow:
|
|
24
|
+
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
25
|
+
* 2. `normaliseDateFields(data)`
|
|
26
|
+
* 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`
|
|
27
|
+
* 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
28
|
+
* 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
|
|
29
|
+
*/
|
|
30
|
+
export async function updateDocument(ctx, params) {
|
|
31
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocument' }, async () => {
|
|
32
|
+
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
33
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
34
|
+
const hooks = await resolveHooks(definition);
|
|
35
|
+
const data = params.data;
|
|
36
|
+
// Fetch the real original so hooks get accurate originalData (fixes the
|
|
37
|
+
// PUT handler bug where originalData === data).
|
|
38
|
+
const latest = await db.queries.documents.getDocumentById({
|
|
39
|
+
collection_id: collectionId,
|
|
40
|
+
document_id: params.documentId,
|
|
41
|
+
locale: params.locale ?? defaultLocale,
|
|
42
|
+
reconstruct: true,
|
|
43
|
+
});
|
|
44
|
+
const originalData = latest ?? {};
|
|
45
|
+
normaliseDateFields(data);
|
|
46
|
+
await invokeHook(hooks?.beforeUpdate, { data, originalData, collectionPath });
|
|
47
|
+
// Counter fields are immutable: carry their values forward from the
|
|
48
|
+
// previous version rather than trusting whatever (or nothing) the
|
|
49
|
+
// caller sent. Lazy-allocates when a counter was added to the
|
|
50
|
+
// collection after this document was first created.
|
|
51
|
+
// originalData is the document envelope (with `.fields`, `.path`,
|
|
52
|
+
// `.document_version_id`); assignCounterValues expects field-shape.
|
|
53
|
+
await assignCounterValues({
|
|
54
|
+
fields: definition.fields,
|
|
55
|
+
data,
|
|
56
|
+
previousData: originalData.fields ?? originalData,
|
|
57
|
+
counters: db.commands.counters,
|
|
58
|
+
});
|
|
59
|
+
const defaultStatus = getDefaultStatus(definition);
|
|
60
|
+
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
61
|
+
const requestLocale = params.locale ?? defaultLocale;
|
|
62
|
+
// The document's own content-locale anchor governs which save writes the
|
|
63
|
+
// path row — not the mutable global default. Falls back to the global
|
|
64
|
+
// default for rows predating source_locale (not yet backfilled).
|
|
65
|
+
const sourceLocale = originalData.source_locale ?? defaultLocale;
|
|
66
|
+
const pathForCommand = resolvePathForUpdate({
|
|
67
|
+
explicitPath,
|
|
68
|
+
currentPath: originalData.path,
|
|
69
|
+
requestLocale,
|
|
70
|
+
sourceLocale,
|
|
71
|
+
documentId: params.documentId,
|
|
72
|
+
logger: ctx.logger,
|
|
73
|
+
});
|
|
74
|
+
await applyRichTextEmbed(ctx, data);
|
|
75
|
+
const result = await db.commands.documents
|
|
76
|
+
.createDocumentVersion({
|
|
77
|
+
documentId: params.documentId,
|
|
78
|
+
collectionId,
|
|
79
|
+
collectionVersion: ctx.collectionVersion,
|
|
80
|
+
collectionConfig: definition,
|
|
81
|
+
action: 'update',
|
|
82
|
+
documentData: data,
|
|
83
|
+
path: pathForCommand,
|
|
84
|
+
availableLocales: params.availableLocales,
|
|
85
|
+
status: defaultStatus,
|
|
86
|
+
locale: requestLocale,
|
|
87
|
+
previousVersionId: originalData.document_version_id,
|
|
88
|
+
})
|
|
89
|
+
.catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
|
|
90
|
+
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
91
|
+
const documentVersionId = extractVersionId(result.document);
|
|
92
|
+
await invokeHook(hooks?.afterUpdate, {
|
|
93
|
+
data,
|
|
94
|
+
originalData,
|
|
95
|
+
collectionPath,
|
|
96
|
+
documentId,
|
|
97
|
+
documentVersionId,
|
|
98
|
+
path: pathForCommand ?? originalData.path,
|
|
99
|
+
});
|
|
100
|
+
return { documentId, documentVersionId };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Update a document via patch application.
|
|
105
|
+
*
|
|
106
|
+
* Flow:
|
|
107
|
+
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
108
|
+
* 2. Optimistic concurrency check on `documentVersionId`
|
|
109
|
+
* 3. `applyPatches(definition, originalData, patches)` → `nextData`
|
|
110
|
+
* 4. `normaliseDateFields(nextData)`
|
|
111
|
+
* 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`
|
|
112
|
+
* 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
113
|
+
* 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
|
|
114
|
+
*
|
|
115
|
+
* @throws {BylineError} ERR_CONFLICT if the supplied `documentVersionId` does not match the current version.
|
|
116
|
+
* @throws {BylineError} ERR_PATCH_FAILED if `applyPatches` fails.
|
|
117
|
+
*/
|
|
118
|
+
export async function updateDocumentWithPatches(ctx, params) {
|
|
119
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentWithPatches' }, async () => {
|
|
120
|
+
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
121
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
122
|
+
const hooks = await resolveHooks(definition);
|
|
123
|
+
// 1. Fetch current document.
|
|
124
|
+
const latest = await db.queries.documents.getDocumentById({
|
|
125
|
+
collection_id: collectionId,
|
|
126
|
+
document_id: params.documentId,
|
|
127
|
+
locale: params.locale ?? defaultLocale,
|
|
128
|
+
reconstruct: true,
|
|
129
|
+
});
|
|
130
|
+
if (latest == null) {
|
|
131
|
+
throw ERR_NOT_FOUND({
|
|
132
|
+
message: 'document not found',
|
|
133
|
+
details: { documentId: params.documentId },
|
|
134
|
+
}).log(ctx.logger);
|
|
135
|
+
}
|
|
136
|
+
const originalData = latest;
|
|
137
|
+
// 2. Optimistic concurrency check.
|
|
138
|
+
if (params.documentVersionId &&
|
|
139
|
+
params.documentVersionId !== originalData.document_version_id) {
|
|
140
|
+
throw ERR_CONFLICT({
|
|
141
|
+
message: 'document has been modified since you loaded it',
|
|
142
|
+
details: {
|
|
143
|
+
currentVersionId: originalData.document_version_id,
|
|
144
|
+
yourVersionId: params.documentVersionId,
|
|
145
|
+
},
|
|
146
|
+
}).log(ctx.logger);
|
|
147
|
+
}
|
|
148
|
+
// 3. Apply patches (patches operate on flat field data, not the full envelope).
|
|
149
|
+
const { doc: patchedDocument, errors } = applyPatches(definition, originalData.fields ?? {}, params.patches);
|
|
150
|
+
if (errors.length > 0) {
|
|
151
|
+
throw ERR_PATCH_FAILED({
|
|
152
|
+
message: `failed to apply patches: ${errors.map((e) => e.message).join('; ')}`,
|
|
153
|
+
details: { errors },
|
|
154
|
+
}).log(ctx.logger);
|
|
155
|
+
}
|
|
156
|
+
const nextData = patchedDocument;
|
|
157
|
+
// 4. Normalise dates.
|
|
158
|
+
normaliseDateFields(nextData);
|
|
159
|
+
// 5. beforeUpdate hook.
|
|
160
|
+
await invokeHook(hooks?.beforeUpdate, { data: nextData, originalData, collectionPath });
|
|
161
|
+
// 5b. Carry counter values forward from the previous version (or
|
|
162
|
+
// lazy-allocate if the previous version is missing a value). See
|
|
163
|
+
// updateDocument for the rationale — patch-based updates are
|
|
164
|
+
// subject to the same immutability contract.
|
|
165
|
+
await assignCounterValues({
|
|
166
|
+
fields: definition.fields,
|
|
167
|
+
data: nextData,
|
|
168
|
+
previousData: originalData.fields ?? {},
|
|
169
|
+
counters: db.commands.counters,
|
|
170
|
+
});
|
|
171
|
+
// 6. Persist.
|
|
172
|
+
const defaultStatus = getDefaultStatus(definition);
|
|
173
|
+
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
174
|
+
const requestLocale = params.locale ?? defaultLocale;
|
|
175
|
+
// The document's own content-locale anchor governs which save writes the
|
|
176
|
+
// path row — not the mutable global default. Falls back to the global
|
|
177
|
+
// default for rows predating source_locale (not yet backfilled).
|
|
178
|
+
const sourceLocale = originalData.source_locale ?? defaultLocale;
|
|
179
|
+
const pathForCommand = resolvePathForUpdate({
|
|
180
|
+
explicitPath,
|
|
181
|
+
currentPath: originalData.path,
|
|
182
|
+
requestLocale,
|
|
183
|
+
sourceLocale,
|
|
184
|
+
documentId: params.documentId,
|
|
185
|
+
logger: ctx.logger,
|
|
186
|
+
});
|
|
187
|
+
await applyRichTextEmbed(ctx, nextData);
|
|
188
|
+
const result = await db.commands.documents
|
|
189
|
+
.createDocumentVersion({
|
|
190
|
+
documentId: params.documentId,
|
|
191
|
+
collectionId,
|
|
192
|
+
collectionVersion: ctx.collectionVersion,
|
|
193
|
+
collectionConfig: definition,
|
|
194
|
+
action: 'update',
|
|
195
|
+
documentData: nextData,
|
|
196
|
+
path: pathForCommand,
|
|
197
|
+
availableLocales: params.availableLocales,
|
|
198
|
+
status: defaultStatus,
|
|
199
|
+
locale: requestLocale,
|
|
200
|
+
previousVersionId: originalData.document_version_id,
|
|
201
|
+
})
|
|
202
|
+
.catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
|
|
203
|
+
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
204
|
+
const documentVersionId = extractVersionId(result.document);
|
|
205
|
+
// 7. afterUpdate hook.
|
|
206
|
+
await invokeHook(hooks?.afterUpdate, {
|
|
207
|
+
data: nextData,
|
|
208
|
+
originalData,
|
|
209
|
+
collectionPath,
|
|
210
|
+
documentId,
|
|
211
|
+
documentVersionId,
|
|
212
|
+
path: pathForCommand ?? originalData.path,
|
|
213
|
+
});
|
|
214
|
+
return { documentId, documentVersionId };
|
|
215
|
+
});
|
|
216
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
|
|
9
9
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { BylineError, ERR_PATH_CONFLICT, ErrorCodes } from '../lib/errors.js';
|
|
11
|
-
import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle.js';
|
|
11
|
+
import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle/index.js';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Fixtures / Helpers
|
|
14
14
|
// ---------------------------------------------------------------------------
|