@abinnovision/payloadcms-mcpx 1.0.0-beta.11 → 1.0.0-beta.13
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/README.md +103 -44
- package/dist/api-keys/fields.mjs +17 -4
- package/dist/capabilities.mjs +22 -3
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -21
- package/dist/endpoint/handler.mjs +4 -2
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +10 -5
- package/dist/index.d.mts +4 -5
- package/dist/index.mjs +2 -2
- package/dist/options.mjs +26 -9
- package/dist/plugin.mjs +1 -0
- package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
- package/dist/request.mjs +8 -0
- package/dist/{endpoint/result.d.mts → result.d.mts} +1 -2
- package/dist/result.mjs +22 -0
- package/dist/schema/index.mjs +6 -0
- package/dist/schema/pointer.mjs +1 -1
- package/dist/schema/walk.mjs +11 -15
- package/dist/tools/{index.mjs → builtin.mjs} +4 -2
- package/dist/tools/create-document.mjs +14 -9
- package/dist/tools/describe-schema.mjs +3 -3
- package/dist/tools/find-documents.mjs +1 -2
- package/dist/tools/get-document.mjs +2 -2
- package/dist/tools/list-capabilities.mjs +3 -2
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +14 -15
- package/dist/tools/publish-document.mjs +81 -0
- package/dist/tools/shared.mjs +50 -5
- package/dist/tools/target.mjs +4 -4
- package/dist/tools/validate-document.mjs +8 -9
- package/dist/types.d.mts +44 -23
- package/dist/write/draft-guard.mjs +71 -36
- package/dist/write/patch.mjs +131 -57
- package/dist/write/publish-blockers.mjs +10 -3
- package/dist/write/publish-intent.mjs +39 -0
- package/dist/write/transaction.mjs +7 -1
- package/package.json +1 -1
- package/dist/i18n.d.mts +0 -1
- package/dist/schema/lexical.d.mts +0 -1
- package/dist/schema/walk.d.mts +0 -3
- package/dist/tools/target.d.mts +0 -3
- package/dist/write/publish-blockers.d.mts +0 -15
package/dist/schema/pointer.mjs
CHANGED
|
@@ -43,7 +43,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
43
43
|
if (isSubtreePrefix(descriptors, segments)) return {
|
|
44
44
|
...blockType === void 0 ? {} : { blockType },
|
|
45
45
|
fields,
|
|
46
|
-
prefix: segments
|
|
46
|
+
prefix: segments.map((segment) => isIndexSegment(segment) ? "*" : segment)
|
|
47
47
|
};
|
|
48
48
|
throw new Error(`"${joinPath(segments)}" is not a field here. Available: ${descriptors.map((descriptor) => descriptor.path).join(", ")}`);
|
|
49
49
|
}
|
package/dist/schema/walk.mjs
CHANGED
|
@@ -83,7 +83,7 @@ const withRows = (descriptor, field) => ({
|
|
|
83
83
|
* Whether a descriptor stands for a construct that only holds other fields.
|
|
84
84
|
*
|
|
85
85
|
* These describe a position rather than a value, so everything that resolves a
|
|
86
|
-
* path to something writable skips them; only {@link
|
|
86
|
+
* path to something writable skips them; only {@link nodeDescriber} reports
|
|
87
87
|
* them, to carry what the container itself declares.
|
|
88
88
|
*/ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
|
|
89
89
|
/**
|
|
@@ -133,29 +133,25 @@ const withRows = (descriptor, field) => ({
|
|
|
133
133
|
/**
|
|
134
134
|
* The descriptors that address a value, which is what every walk resolving a
|
|
135
135
|
* path against a document needs. A container describes a position rather than
|
|
136
|
-
* a value, so only {@link
|
|
136
|
+
* a value, so only {@link nodeDescriber} reports one.
|
|
137
137
|
*/ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
|
|
138
138
|
/**
|
|
139
|
-
* Locates the
|
|
140
|
-
*/ const
|
|
139
|
+
* Locates the field of `type` that a resolved descriptor path refers to.
|
|
140
|
+
*/ const findFieldAt = (fields, path, type) => {
|
|
141
141
|
for (const field of fields) {
|
|
142
142
|
if (!("name" in field) || field.name !== path[0]) continue;
|
|
143
|
-
if (field.type ===
|
|
144
|
-
if (field.type === "tab" || field.type === "group") return
|
|
145
|
-
if (field.type === "array" && path[1] === "*") return
|
|
143
|
+
if (field.type === type && path.length === 1) return field;
|
|
144
|
+
if (field.type === "tab" || field.type === "group") return findFieldAt(field.flattenedFields, path.slice(1), type);
|
|
145
|
+
if (field.type === "array" && path[1] === "*") return findFieldAt(field.flattenedFields, path.slice(2), type);
|
|
146
146
|
}
|
|
147
147
|
};
|
|
148
148
|
/**
|
|
149
|
+
* Locates the blocks field that a resolved descriptor path refers to.
|
|
150
|
+
*/ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
|
|
151
|
+
/**
|
|
149
152
|
* Locates the rich text field that a resolved descriptor path refers to, so
|
|
150
153
|
* its editor can be introspected for the fields its nodes carry.
|
|
151
|
-
*/ const findRichTextField = (fields, path) =>
|
|
152
|
-
for (const field of fields) {
|
|
153
|
-
if (!("name" in field) || field.name !== path[0]) continue;
|
|
154
|
-
if (field.type === "richText" && path.length === 1) return field;
|
|
155
|
-
if (field.type === "tab" || field.type === "group") return findRichTextField(field.flattenedFields, path.slice(1));
|
|
156
|
-
if (field.type === "array" && path[1] === "*") return findRichTextField(field.flattenedFields, path.slice(2));
|
|
157
|
-
}
|
|
158
|
-
};
|
|
154
|
+
*/ const findRichTextField = (fields, path) => findFieldAt(fields, path, "richText");
|
|
159
155
|
const targetOf = (config, ref) => {
|
|
160
156
|
const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
|
|
161
157
|
if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
|
|
@@ -4,8 +4,9 @@ import { findDocuments } from "./find-documents.mjs";
|
|
|
4
4
|
import { getDocument } from "./get-document.mjs";
|
|
5
5
|
import { listCapabilities } from "./list-capabilities.mjs";
|
|
6
6
|
import { patchDocument } from "./patch-document.mjs";
|
|
7
|
+
import { publishDocument } from "./publish-document.mjs";
|
|
7
8
|
import { validateDocument } from "./validate-document.mjs";
|
|
8
|
-
//#region src/tools/
|
|
9
|
+
//#region src/tools/builtin.ts
|
|
9
10
|
/**
|
|
10
11
|
* The builtin tools in registration order. They are ordinary {@link McpxTool}s
|
|
11
12
|
* that ship with the plugin and register through the same loop as the tools
|
|
@@ -20,7 +21,8 @@ import { validateDocument } from "./validate-document.mjs";
|
|
|
20
21
|
getDocument,
|
|
21
22
|
patchDocument,
|
|
22
23
|
createDocument,
|
|
23
|
-
validateDocument
|
|
24
|
+
validateDocument,
|
|
25
|
+
publishDocument
|
|
24
26
|
];
|
|
25
27
|
//#endregion
|
|
26
28
|
export { BUILTIN_TOOLS };
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { errorResult, jsonResult } from "../
|
|
2
|
-
import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
|
|
3
|
-
import { resolveTarget } from "./target.mjs";
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
4
2
|
import { validateWriteValue } from "../schema/shape.mjs";
|
|
3
|
+
import "../schema/index.mjs";
|
|
4
|
+
import { draftSentence, localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
|
|
5
|
+
import { resolveTarget } from "./target.mjs";
|
|
5
6
|
import { defineMcpxTool } from "../types.mjs";
|
|
6
7
|
import { stripRowIds } from "../write/patch.mjs";
|
|
7
8
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
//#region src/tools/create-document.ts
|
|
11
|
+
const DESCRIPTION = (scope) => `Creates a new document from a minimal seed. Only the fields describeSchema lists may appear in "data"; unknown keys are refused with the valid siblings, and "id" is Payload's to assign. The document may be incomplete: the response lists "publishBlockers", which patchDocument can then work through, and "publishBlockersUnavailable" when that check itself failed. Use this when no document exists yet; prefer patching an existing draft otherwise.
|
|
12
|
+
|
|
13
|
+
${draftSentence(scope)}`;
|
|
10
14
|
const createDocument = defineMcpxTool({
|
|
11
15
|
name: "createDocument",
|
|
12
|
-
description:
|
|
16
|
+
description: DESCRIPTION,
|
|
13
17
|
annotations: {
|
|
14
18
|
readOnlyHint: false,
|
|
15
19
|
destructiveHint: false,
|
|
@@ -29,18 +33,18 @@ const createDocument = defineMcpxTool({
|
|
|
29
33
|
const target = resolveTarget(scope, { collection: args.collection }, "write");
|
|
30
34
|
const { payload } = scope.req;
|
|
31
35
|
const locale = localeOf(scope, args.locale);
|
|
32
|
-
|
|
36
|
+
if ("id" in args.data) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
|
|
33
37
|
const problems = validateWriteValue(payload.config, {
|
|
34
38
|
pointer: "",
|
|
35
39
|
resolution: {
|
|
36
40
|
fields: target.config.flattenedFields,
|
|
37
41
|
prefix: []
|
|
38
42
|
}
|
|
39
|
-
},
|
|
43
|
+
}, args.data);
|
|
40
44
|
if (problems.length > 0) return errorResult("Nothing was created.", { problems });
|
|
41
45
|
const created = await payload.create({
|
|
42
46
|
collection: args.collection,
|
|
43
|
-
data: stripRowIds(
|
|
47
|
+
data: stripRowIds(args.data),
|
|
44
48
|
depth: 0,
|
|
45
49
|
draft: true,
|
|
46
50
|
overrideAccess: false,
|
|
@@ -53,7 +57,7 @@ const createDocument = defineMcpxTool({
|
|
|
53
57
|
locale,
|
|
54
58
|
privileged: true
|
|
55
59
|
});
|
|
56
|
-
const
|
|
60
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
57
61
|
doc: saved,
|
|
58
62
|
entity: target
|
|
59
63
|
});
|
|
@@ -61,7 +65,8 @@ const createDocument = defineMcpxTool({
|
|
|
61
65
|
id: saved["id"],
|
|
62
66
|
status: saved["_status"],
|
|
63
67
|
updatedAt: saved["updatedAt"],
|
|
64
|
-
...
|
|
68
|
+
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
69
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {}
|
|
65
70
|
});
|
|
66
71
|
}
|
|
67
72
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
1
2
|
import { translatorFor } from "../i18n.mjs";
|
|
2
|
-
import {
|
|
3
|
+
import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
|
|
4
|
+
import "../schema/index.mjs";
|
|
3
5
|
import { targetShape } from "./shared.mjs";
|
|
4
6
|
import { refOf, resolveTarget } from "./target.mjs";
|
|
5
7
|
import { defineMcpxTool } from "../types.mjs";
|
|
6
|
-
import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
|
|
7
8
|
import { z } from "zod";
|
|
8
|
-
//#region src/tools/describe-schema.ts
|
|
9
9
|
const describeSchema = defineMcpxTool({
|
|
10
10
|
name: "describeSchema",
|
|
11
11
|
description: `Describes the writable shape of a document, one node at a time.
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { jsonResult } from "../
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
2
2
|
import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
|
|
3
3
|
import { resolveTarget } from "./target.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
//#region src/tools/find-documents.ts
|
|
7
6
|
const findDocuments = defineMcpxTool({
|
|
8
7
|
name: "findDocuments",
|
|
9
8
|
description: `Finds documents in a collection. "where" is a Payload query object, e.g. {"title":{"contains":"home"}} or {"and":[...]}; "select" picks fields, e.g. {"title":true}. Drafts are included by default so unpublished work is visible. Keep depth at 0 unless populated relationships are needed; ids are enough for writes.`,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
1
2
|
import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
|
|
2
|
-
import
|
|
3
|
+
import "../schema/index.mjs";
|
|
3
4
|
import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
|
|
4
5
|
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
5
6
|
import { defineMcpxTool } from "../types.mjs";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { Pointer } from "rfc6902";
|
|
8
|
-
//#region src/tools/get-document.ts
|
|
9
9
|
const getDocument = defineMcpxTool({
|
|
10
10
|
name: "getDocument",
|
|
11
11
|
description: `Reads one document, or one subtree of it when "path" is given as a JSON pointer such as "/layout/sections/2". Returns the latest draft by default. Read before patching: the response carries "updatedAt" for expectedUpdatedAt and the indices pointers need.
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
1
2
|
import { translatorFor } from "../i18n.mjs";
|
|
2
|
-
import { jsonResult } from "../endpoint/result.mjs";
|
|
3
3
|
import { translateLabel } from "./shared.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
5
|
import { hasDraftValidationEnabled } from "payload/shared";
|
|
6
|
-
//#region src/tools/list-capabilities.ts
|
|
7
6
|
const listCapabilities = defineMcpxTool({
|
|
8
7
|
name: "listCapabilities",
|
|
9
8
|
description: `Lists what this key may do: the collections and globals it can read or write, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
|
|
@@ -33,6 +32,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
33
32
|
...description === void 0 ? {} : { description },
|
|
34
33
|
read: capability.read,
|
|
35
34
|
write: capability.write,
|
|
35
|
+
publish: capability.publish,
|
|
36
36
|
drafts: entry.hasDrafts,
|
|
37
37
|
draftValidation: hasDraftValidationEnabled(config),
|
|
38
38
|
idType: collection.customIDType ?? payload.db.defaultIDType
|
|
@@ -49,6 +49,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
49
49
|
...description === void 0 ? {} : { description },
|
|
50
50
|
read: capability.read,
|
|
51
51
|
write: capability.write,
|
|
52
|
+
publish: capability.publish,
|
|
52
53
|
drafts: entry.hasDrafts,
|
|
53
54
|
draftValidation: hasDraftValidationEnabled(config)
|
|
54
55
|
}];
|
package/dist/tools/names.mjs
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
|
-
import { errorResult, jsonResult } from "../
|
|
2
|
-
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
+
import { draftSentence, idShape, localeOf, localeShape, readTarget, sameInstant, targetShape } from "./shared.mjs";
|
|
3
3
|
import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
|
-
import { PATCH_OPERATION_SCHEMA,
|
|
5
|
+
import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
|
|
6
6
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
7
7
|
import { withTransaction } from "../write/transaction.mjs";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { Pointer } from "rfc6902";
|
|
10
10
|
//#region src/tools/patch-document.ts
|
|
11
|
-
const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
|
|
11
|
+
const DESCRIPTION = (scope) => `Applies RFC 6902 JSON Patch operations to one document.
|
|
12
12
|
|
|
13
13
|
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
${draftSentence(scope)}
|
|
16
16
|
|
|
17
17
|
Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index.
|
|
18
18
|
|
|
19
|
-
Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so
|
|
19
|
+
Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so an edit made since that read is refused rather than overwritten.
|
|
20
20
|
|
|
21
|
-
A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but
|
|
22
|
-
const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
21
|
+
A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but the document cannot be published until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
|
|
23
22
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
23
|
/**
|
|
25
24
|
* Whether the intended value survived the write. The saved document is
|
|
@@ -48,7 +47,7 @@ const patchDocument = defineMcpxTool({
|
|
|
48
47
|
description: DESCRIPTION,
|
|
49
48
|
annotations: {
|
|
50
49
|
readOnlyHint: false,
|
|
51
|
-
destructiveHint:
|
|
50
|
+
destructiveHint: true,
|
|
52
51
|
idempotentHint: false,
|
|
53
52
|
openWorldHint: false
|
|
54
53
|
},
|
|
@@ -64,7 +63,7 @@ const patchDocument = defineMcpxTool({
|
|
|
64
63
|
description: "Locale the patch applies to. Localized fields write here only."
|
|
65
64
|
}),
|
|
66
65
|
patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
|
|
67
|
-
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching.
|
|
66
|
+
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. Best effort: the write is refused if the document changed before the check, but not if it changes between the check and the write.")
|
|
68
67
|
}),
|
|
69
68
|
handler: async ({ args, scope }) => {
|
|
70
69
|
const target = resolveTarget(scope, args, "write");
|
|
@@ -79,13 +78,11 @@ const patchDocument = defineMcpxTool({
|
|
|
79
78
|
locale
|
|
80
79
|
});
|
|
81
80
|
if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again and re-apply the patch.", { updatedAt: doc["updatedAt"] });
|
|
82
|
-
const
|
|
81
|
+
const applied = applyPatchOperations(payload.config, {
|
|
83
82
|
doc,
|
|
84
83
|
patches,
|
|
85
84
|
ref: refOf(target)
|
|
86
85
|
});
|
|
87
|
-
if (problems.length > 0) return errorResult("No operation was applied.", { problems });
|
|
88
|
-
const applied = applyPatchToCopy(doc, patches);
|
|
89
86
|
if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
|
|
90
87
|
const write = {
|
|
91
88
|
data: buildWriteData(payload.config, target.config, applied.next),
|
|
@@ -102,6 +99,7 @@ const patchDocument = defineMcpxTool({
|
|
|
102
99
|
});
|
|
103
100
|
else await payload.updateGlobal({
|
|
104
101
|
...write,
|
|
102
|
+
fallbackLocale: false,
|
|
105
103
|
slug: target.slug
|
|
106
104
|
});
|
|
107
105
|
const saved = await readTarget(scope, {
|
|
@@ -111,7 +109,7 @@ const patchDocument = defineMcpxTool({
|
|
|
111
109
|
privileged: true
|
|
112
110
|
});
|
|
113
111
|
const notApplied = notAppliedPointers(patches, applied.next, saved);
|
|
114
|
-
const
|
|
112
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
115
113
|
doc: saved,
|
|
116
114
|
entity: target
|
|
117
115
|
});
|
|
@@ -119,7 +117,8 @@ const patchDocument = defineMcpxTool({
|
|
|
119
117
|
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
120
118
|
status: saved["_status"],
|
|
121
119
|
updatedAt: saved["updatedAt"],
|
|
122
|
-
...
|
|
120
|
+
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
121
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {},
|
|
123
122
|
...notApplied.length > 0 ? { notApplied } : {}
|
|
124
123
|
});
|
|
125
124
|
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
+
import { idShape, localeOf, readTarget, sameInstant, targetShape } from "./shared.mjs";
|
|
3
|
+
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
5
|
+
import { withTransaction } from "../write/transaction.mjs";
|
|
6
|
+
import { withPublishIntent } from "../write/publish-intent.mjs";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
const publishDocument = defineMcpxTool({
|
|
9
|
+
name: "publishDocument",
|
|
10
|
+
description: `Publishes the current draft, which changes what the public sees. This is the only tool that does; every other write lands as a draft. Call validateDocument first: a document that still has publish blockers is refused, and nothing is written.
|
|
11
|
+
|
|
12
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
13
|
+
|
|
14
|
+
The whole document is published, but Payload only validates the locale the publish runs in, so a required field left empty in another locale goes live empty. That is how the admin panel behaves too. Publishing is refused while a human holds the document open in the admin panel, and republishing an unchanged document is accepted but writes another version.
|
|
15
|
+
|
|
16
|
+
There is no unpublish: reverting to a draft stays a human action in the admin panel.`,
|
|
17
|
+
annotations: {
|
|
18
|
+
destructiveHint: true,
|
|
19
|
+
openWorldHint: false
|
|
20
|
+
},
|
|
21
|
+
isEnabled: (scope) => scope.publishable.length + scope.publishableGlobals.length > 0,
|
|
22
|
+
inputSchema: (scope) => ({
|
|
23
|
+
...targetShape(scope, "publish", {
|
|
24
|
+
collection: "Collection holding the document.",
|
|
25
|
+
global: "Global to publish."
|
|
26
|
+
}),
|
|
27
|
+
...idShape(scope, "publish"),
|
|
28
|
+
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before publishing. Best effort: the publish is refused if the document has changed since, but a write landing between the check and the publish is not.")
|
|
29
|
+
}),
|
|
30
|
+
handler: async ({ args, scope }) => {
|
|
31
|
+
const target = resolveTarget(scope, args, "publish");
|
|
32
|
+
const id = requireIdFor(target, args.id);
|
|
33
|
+
const { payload } = scope.req;
|
|
34
|
+
const locale = localeOf(scope, void 0);
|
|
35
|
+
return await withTransaction(scope.req, async () => {
|
|
36
|
+
const doc = await readTarget(scope, {
|
|
37
|
+
target,
|
|
38
|
+
id,
|
|
39
|
+
locale
|
|
40
|
+
});
|
|
41
|
+
if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again before publishing.", { updatedAt: doc["updatedAt"] });
|
|
42
|
+
const write = {
|
|
43
|
+
data: {},
|
|
44
|
+
depth: 0,
|
|
45
|
+
draft: false,
|
|
46
|
+
fallbackLocale: false,
|
|
47
|
+
overrideAccess: false,
|
|
48
|
+
req: scope.req,
|
|
49
|
+
...locale === void 0 ? {} : { locale }
|
|
50
|
+
};
|
|
51
|
+
await withPublishIntent({
|
|
52
|
+
kind: target.kind,
|
|
53
|
+
slug: target.slug,
|
|
54
|
+
id
|
|
55
|
+
}, async () => {
|
|
56
|
+
if (target.kind === "collection") await payload.update({
|
|
57
|
+
...write,
|
|
58
|
+
collection: target.slug,
|
|
59
|
+
id
|
|
60
|
+
});
|
|
61
|
+
else await payload.updateGlobal({
|
|
62
|
+
...write,
|
|
63
|
+
slug: target.slug
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
const saved = await readTarget(scope, {
|
|
67
|
+
target,
|
|
68
|
+
id,
|
|
69
|
+
locale,
|
|
70
|
+
privileged: true
|
|
71
|
+
});
|
|
72
|
+
return jsonResult({
|
|
73
|
+
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
74
|
+
status: saved["_status"],
|
|
75
|
+
updatedAt: saved["updatedAt"]
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
//#endregion
|
|
81
|
+
export { publishDocument };
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -1,18 +1,63 @@
|
|
|
1
|
+
import { canPublish, isLiveWrite } from "../capabilities.mjs";
|
|
1
2
|
import { translateStatic } from "../i18n.mjs";
|
|
2
3
|
import { NotFound } from "payload";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
//#region src/tools/shared.ts
|
|
5
6
|
const slugEnum = (slugs) => z.enum(slugs);
|
|
6
7
|
const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
8
|
+
const slugsWhere = (scope, predicate, allowed) => {
|
|
9
|
+
const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
|
|
10
|
+
return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Slugs this key may write whose writes land live rather than as a draft. An
|
|
14
|
+
* entity without versions has no draft to land on, so `write: "live"` there
|
|
15
|
+
* makes every write a live one. Empty for every key that can only write drafts.
|
|
16
|
+
*/ const liveWriteSlugs = (scope) => slugsWhere(scope, isLiveWrite, {
|
|
17
|
+
collections: scope.writable,
|
|
18
|
+
globals: scope.writableGlobals
|
|
19
|
+
});
|
|
20
|
+
/** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
|
|
21
|
+
collections: scope.publishable,
|
|
22
|
+
globals: scope.publishableGlobals
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* The sentence the write tools and the server instructions end on: what a write
|
|
26
|
+
* actually does for this key, and what it takes to make it public. The three
|
|
27
|
+
* groups are distinct — a live-write slug has no draft and no publish step, a
|
|
28
|
+
* publishable one has both — so a client is never told its writes are drafts
|
|
29
|
+
* while they are not, nor that publishing is out of reach when it is not.
|
|
30
|
+
*/ const draftSentence = (scope) => {
|
|
31
|
+
const live = liveWriteSlugs(scope);
|
|
32
|
+
const publishable = publishableWriteSlugs(scope);
|
|
33
|
+
return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Whether two timestamps name the same instant, which is how
|
|
37
|
+
* `expectedUpdatedAt` is compared: the value a client read back is a string,
|
|
38
|
+
* and what it is compared against may be a Date.
|
|
39
|
+
*/ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
7
40
|
/**
|
|
8
41
|
* Widens one branch to the superset a handler sees. The widening itself is
|
|
9
42
|
* unchecked — the runtime shape really does vary — so `Branch` checks what it
|
|
10
43
|
* can around it.
|
|
11
44
|
*/ const widen = (branch) => branch;
|
|
12
|
-
const slugsFor = (scope, operation) =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
45
|
+
const slugsFor = (scope, operation) => {
|
|
46
|
+
switch (operation) {
|
|
47
|
+
case "publish": return {
|
|
48
|
+
collections: scope.publishable,
|
|
49
|
+
globals: scope.publishableGlobals
|
|
50
|
+
};
|
|
51
|
+
case "read": return {
|
|
52
|
+
collections: scope.readable,
|
|
53
|
+
globals: scope.readableGlobals
|
|
54
|
+
};
|
|
55
|
+
case "write": return {
|
|
56
|
+
collections: scope.writable,
|
|
57
|
+
globals: scope.writableGlobals
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
};
|
|
16
61
|
/**
|
|
17
62
|
* The `collection` and `global` arguments.
|
|
18
63
|
*
|
|
@@ -99,4 +144,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
|
|
|
99
144
|
return translateStatic(resolved, i18n) ?? fallback;
|
|
100
145
|
};
|
|
101
146
|
//#endregion
|
|
102
|
-
export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
|
|
147
|
+
export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
|
package/dist/tools/target.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { slugsFor } from "./shared.mjs";
|
|
1
2
|
import { APIError, Forbidden } from "payload";
|
|
2
3
|
//#region src/tools/target.ts
|
|
3
4
|
const refOf = (target) => ({
|
|
@@ -14,12 +15,12 @@ const refOf = (target) => ({
|
|
|
14
15
|
* failed call teaches it.
|
|
15
16
|
*/ const resolveTarget = (scope, args, operation) => {
|
|
16
17
|
const { collection, global } = args;
|
|
18
|
+
const allowedSlugs = slugsFor(scope, operation);
|
|
17
19
|
if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
|
|
18
20
|
if (collection === void 0 && global === void 0) throw new APIError("One of \"collection\" or \"global\" is required. Call listCapabilities to see which slugs are available.", 400);
|
|
19
21
|
if (collection !== void 0) {
|
|
20
|
-
const allowed = operation === "read" ? scope.readable : scope.writable;
|
|
21
22
|
const found = scope.req.payload.collections[collection];
|
|
22
|
-
if (!
|
|
23
|
+
if (!allowedSlugs.collections.includes(collection) || !found) throw new Forbidden(scope.req.t);
|
|
23
24
|
return {
|
|
24
25
|
kind: "collection",
|
|
25
26
|
slug: collection,
|
|
@@ -27,9 +28,8 @@ const refOf = (target) => ({
|
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
const slug = global;
|
|
30
|
-
const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
|
|
31
31
|
const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
|
|
32
|
-
if (!
|
|
32
|
+
if (!allowedSlugs.globals.includes(slug) || !found) throw new Forbidden(scope.req.t);
|
|
33
33
|
return {
|
|
34
34
|
kind: "global",
|
|
35
35
|
slug,
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
import { jsonResult } from "../
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
2
2
|
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
3
|
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
5
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
6
|
-
//#region src/tools/validate-document.ts
|
|
7
6
|
const validateDocument = defineMcpxTool({
|
|
8
7
|
name: "validateDocument",
|
|
9
8
|
description: `Reports what still prevents a human from publishing the draft, without writing anything. The same list patchDocument returns after a write; use it to check work or to answer "is this ready".
|
|
10
9
|
|
|
11
|
-
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
},
|
|
10
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
11
|
+
|
|
12
|
+
Nothing is written, but the check runs the same field-level beforeValidate and beforeChange hooks a save would, so a hook with side effects fires. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing.`,
|
|
13
|
+
annotations: { openWorldHint: false },
|
|
16
14
|
isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
|
|
17
15
|
inputSchema: (scope) => ({
|
|
18
16
|
...targetShape(scope, "write", {
|
|
@@ -40,7 +38,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
40
38
|
locale,
|
|
41
39
|
privileged: true
|
|
42
40
|
});
|
|
43
|
-
const
|
|
41
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
44
42
|
doc,
|
|
45
43
|
entity: target
|
|
46
44
|
});
|
|
@@ -48,7 +46,8 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
48
46
|
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
49
47
|
status: doc["_status"],
|
|
50
48
|
updatedAt: doc["updatedAt"],
|
|
51
|
-
publishBlockers
|
|
49
|
+
publishBlockers: validation.blockers,
|
|
50
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {}
|
|
52
51
|
});
|
|
53
52
|
}
|
|
54
53
|
});
|