@abinnovision/payloadcms-mcpx 1.0.0-beta.9 → 1.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/CHANGELOG.md +33 -0
- package/README.md +328 -211
- package/dist/api-keys/fields.mjs +22 -5
- package/dist/api-keys/setup-guide.mjs +6 -4
- package/dist/auth/resolve.mjs +5 -7
- package/dist/capabilities.mjs +23 -4
- package/dist/client/index.d.mts +2 -2
- package/dist/client/setup-guide.d.mts +1 -1
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
- package/dist/endpoint/handler.mjs +11 -5
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +18 -26
- package/dist/i18n.mjs +4 -15
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -3
- package/dist/options.mjs +30 -26
- 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/result.d.mts +11 -0
- package/dist/result.mjs +20 -0
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +8 -0
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +195 -27
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +77 -30
- package/dist/schema/shape.mjs +133 -51
- package/dist/schema/walk.mjs +44 -64
- package/dist/tools/{index.mjs → builtin.mjs} +8 -5
- package/dist/tools/create-document.mjs +34 -15
- package/dist/tools/describe-schema.mjs +21 -7
- package/dist/tools/find-documents.mjs +13 -6
- package/dist/tools/get-document.mjs +45 -11
- package/dist/tools/list-capabilities.mjs +19 -9
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +32 -21
- package/dist/tools/publish-document.mjs +79 -0
- package/dist/tools/shared.mjs +84 -32
- package/dist/tools/target.mjs +7 -11
- package/dist/tools/validate-document.mjs +20 -12
- package/dist/types.d.mts +110 -42
- package/dist/types.mjs +3 -4
- package/dist/version.mjs +1 -1
- package/dist/write/draft-guard.mjs +47 -44
- package/dist/write/patch.mjs +174 -92
- package/dist/write/publish-blockers.mjs +13 -12
- package/dist/write/publish-intent.mjs +17 -0
- package/dist/write/transaction.mjs +8 -3
- package/package.json +3 -3
- package/dist/i18n.d.mts +0 -1
- package/dist/options.d.mts +0 -2
- 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/tools/types.d.mts +0 -5
- package/dist/write/publish-blockers.d.mts +0 -15
|
@@ -1,9 +1,16 @@
|
|
|
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
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
4
5
|
import { z } from "zod";
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Collection-only: a global is a singleton, so there is nothing to list.
|
|
8
|
+
*
|
|
9
|
+
* The query goes to Payload with `overrideAccess: false`, so the collection's
|
|
10
|
+
* own access control decides what comes back. `limit` and `depth` are bounded
|
|
11
|
+
* by the configured limits in the schema itself, which puts the ceiling in
|
|
12
|
+
* front of the client rather than silently clamping behind it.
|
|
13
|
+
*/ const findDocuments = defineMcpxTool({
|
|
7
14
|
name: "findDocuments",
|
|
8
15
|
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.`,
|
|
9
16
|
annotations: {
|
|
@@ -15,7 +22,7 @@ const findDocuments = {
|
|
|
15
22
|
collection: slugEnum(scope.readable).describe("Collection to search."),
|
|
16
23
|
where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
|
|
17
24
|
sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
|
|
18
|
-
limit: z.number().int().min(1).max(scope.
|
|
25
|
+
limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.limits.maxLimit)}.`),
|
|
19
26
|
page: z.number().int().min(1).optional().describe("Page number, from 1."),
|
|
20
27
|
...depthShape(scope),
|
|
21
28
|
select: z.record(z.string(), z.unknown()).optional().describe("Fields to return, e.g. {\"title\":true}."),
|
|
@@ -25,7 +32,7 @@ const findDocuments = {
|
|
|
25
32
|
}),
|
|
26
33
|
draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
|
|
27
34
|
}),
|
|
28
|
-
handler: async (args, scope) => {
|
|
35
|
+
handler: async ({ args, scope }) => {
|
|
29
36
|
resolveTarget(scope, { collection: args.collection }, "read");
|
|
30
37
|
const locale = localeOf(scope, args.locale);
|
|
31
38
|
const result = await scope.req.payload.find({
|
|
@@ -50,6 +57,6 @@ const findDocuments = {
|
|
|
50
57
|
hasNextPage: result.hasNextPage
|
|
51
58
|
});
|
|
52
59
|
}
|
|
53
|
-
};
|
|
60
|
+
});
|
|
54
61
|
//#endregion
|
|
55
62
|
export { findDocuments };
|
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
+
import { JSON_POINTER_PATTERN, findRichTextField, splitPath } from "../schema/walk.mjs";
|
|
3
|
+
import { lexicalOutline } from "../schema/outline.mjs";
|
|
4
|
+
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
5
|
+
import "../schema/index.mjs";
|
|
3
6
|
import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
|
|
4
|
-
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
7
|
+
import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
|
|
8
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
5
9
|
import { z } from "zod";
|
|
6
10
|
import { Pointer } from "rfc6902";
|
|
7
11
|
//#region src/tools/get-document.ts
|
|
8
|
-
const
|
|
12
|
+
const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
|
|
13
|
+
/**
|
|
14
|
+
* With `path` the handler returns the subtree plus the `id`, `_status` and
|
|
15
|
+
* `updatedAt` a client needs to write back, so a caller reading one branch
|
|
16
|
+
* still gets the timestamp `expectedUpdatedAt` wants without a second call.
|
|
17
|
+
*/ const getDocument = defineMcpxTool({
|
|
9
18
|
name: "getDocument",
|
|
10
19
|
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.
|
|
11
20
|
|
|
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
|
|
21
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
22
|
+
|
|
23
|
+
Set "outline" on a rich text "path" to get a compact positional listing of its nodes instead of the raw editor state.`,
|
|
13
24
|
annotations: {
|
|
14
25
|
readOnlyHint: true,
|
|
15
26
|
openWorldHint: false
|
|
@@ -27,9 +38,10 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
27
38
|
required: false,
|
|
28
39
|
description: "Locale to read. Defaults to the default locale."
|
|
29
40
|
}),
|
|
30
|
-
draft: z.boolean().optional().describe("Return the latest draft. Default true.")
|
|
41
|
+
draft: z.boolean().optional().describe("Return the latest draft. Default true."),
|
|
42
|
+
outline: z.boolean().optional().describe("For a rich text field, return a compact positional outline instead of the editor state. Requires \"path\".")
|
|
31
43
|
}),
|
|
32
|
-
handler: async (args, scope) => {
|
|
44
|
+
handler: async ({ args, scope }) => {
|
|
33
45
|
const target = resolveTarget(scope, args, "read");
|
|
34
46
|
const id = requireIdFor(target, args.id);
|
|
35
47
|
const locale = localeOf(scope, args.locale);
|
|
@@ -48,21 +60,43 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
48
60
|
...shared,
|
|
49
61
|
slug: target.slug
|
|
50
62
|
}));
|
|
51
|
-
if (args.path === void 0 || args.path === "")
|
|
63
|
+
if (args.path === void 0 || args.path === "") {
|
|
64
|
+
if (args.outline) return errorResult(OUTLINE_ERROR);
|
|
65
|
+
return jsonResult(doc);
|
|
66
|
+
}
|
|
52
67
|
let value;
|
|
53
68
|
try {
|
|
54
69
|
value = Pointer.fromJSON(args.path).get(doc);
|
|
55
70
|
} catch {
|
|
56
71
|
return errorResult(`"${args.path}" is not a valid JSON pointer.`);
|
|
57
72
|
}
|
|
58
|
-
|
|
73
|
+
const envelope = {
|
|
59
74
|
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
60
75
|
status: doc["_status"],
|
|
61
76
|
updatedAt: doc["updatedAt"],
|
|
62
|
-
path: args.path
|
|
77
|
+
path: args.path
|
|
78
|
+
};
|
|
79
|
+
if (!args.outline) return jsonResult({
|
|
80
|
+
...envelope,
|
|
63
81
|
value
|
|
64
82
|
});
|
|
83
|
+
let resolution;
|
|
84
|
+
try {
|
|
85
|
+
resolution = resolveDataPointer(scope.req.payload.config, {
|
|
86
|
+
doc,
|
|
87
|
+
pointer: args.path,
|
|
88
|
+
ref: refOf(target)
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return errorResult(error instanceof Error ? error.message : OUTLINE_ERROR);
|
|
92
|
+
}
|
|
93
|
+
const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findRichTextField(resolution.fields, splitPath(resolution.descriptor.path)) : void 0;
|
|
94
|
+
if (!field) return errorResult(OUTLINE_ERROR);
|
|
95
|
+
return jsonResult({
|
|
96
|
+
...envelope,
|
|
97
|
+
outline: lexicalOutline(value, args.path, field)
|
|
98
|
+
});
|
|
65
99
|
}
|
|
66
|
-
};
|
|
100
|
+
});
|
|
67
101
|
//#endregion
|
|
68
102
|
export { getDocument };
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
import { canCreate } from "../capabilities.mjs";
|
|
2
|
+
import { jsonResult } from "../result.mjs";
|
|
1
3
|
import { translatorFor } from "../i18n.mjs";
|
|
2
|
-
import { jsonResult } from "../endpoint/result.mjs";
|
|
3
4
|
import { translateLabel } from "./shared.mjs";
|
|
5
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
4
6
|
import { hasDraftValidationEnabled } from "payload/shared";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Registered for every key, including one with no capabilities ticked, so a
|
|
9
|
+
* client always has something to call and gets an empty surface described
|
|
10
|
+
* rather than an empty tool list. The response is assembled from the request
|
|
11
|
+
* scope and the sanitized config, never from the content model, so it stays the
|
|
12
|
+
* same size as a deployment grows.
|
|
13
|
+
*/ const listCapabilities = defineMcpxTool({
|
|
7
14
|
name: "listCapabilities",
|
|
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.
|
|
15
|
+
description: `Lists what this key may do: the collections and globals it can read or write, whether a collection can also be created in, 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.
|
|
9
16
|
|
|
10
17
|
A global is a singleton: it has no id, is not listed by findDocuments and cannot be created. Address one with the "global" argument where a collection document would take "collection" and "id".`,
|
|
11
18
|
annotations: {
|
|
@@ -14,10 +21,10 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
14
21
|
},
|
|
15
22
|
isEnabled: () => true,
|
|
16
23
|
inputSchema: () => ({}),
|
|
17
|
-
handler: (
|
|
24
|
+
handler: ({ scope }) => {
|
|
18
25
|
const { payload } = scope.req;
|
|
19
26
|
const translate = translatorFor(scope.req.i18n);
|
|
20
|
-
const collections = scope.
|
|
27
|
+
const collections = scope.exposure.collections.flatMap((entry) => {
|
|
21
28
|
const capability = scope.capabilities.collections[entry.slug];
|
|
22
29
|
const collection = payload.collections[entry.slug];
|
|
23
30
|
if (!capability || !collection || !(capability.read || capability.write)) return [];
|
|
@@ -32,12 +39,14 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
32
39
|
...description === void 0 ? {} : { description },
|
|
33
40
|
read: capability.read,
|
|
34
41
|
write: capability.write,
|
|
42
|
+
create: capability.write && canCreate(entry),
|
|
43
|
+
publish: capability.publish,
|
|
35
44
|
drafts: entry.hasDrafts,
|
|
36
45
|
draftValidation: hasDraftValidationEnabled(config),
|
|
37
46
|
idType: collection.customIDType ?? payload.db.defaultIDType
|
|
38
47
|
}];
|
|
39
48
|
});
|
|
40
|
-
const globals = scope.
|
|
49
|
+
const globals = scope.exposure.globals.flatMap((entry) => {
|
|
41
50
|
const capability = scope.capabilities.globals[entry.slug];
|
|
42
51
|
const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
|
|
43
52
|
if (!capability || !config || !(capability.read || capability.write)) return [];
|
|
@@ -48,6 +57,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
48
57
|
...description === void 0 ? {} : { description },
|
|
49
58
|
read: capability.read,
|
|
50
59
|
write: capability.write,
|
|
60
|
+
publish: capability.publish,
|
|
51
61
|
drafts: entry.hasDrafts,
|
|
52
62
|
draftValidation: hasDraftValidationEnabled(config)
|
|
53
63
|
}];
|
|
@@ -59,10 +69,10 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
59
69
|
codes: scope.locales,
|
|
60
70
|
default: scope.defaultLocale
|
|
61
71
|
} : null,
|
|
62
|
-
limits: scope.
|
|
72
|
+
limits: scope.limits,
|
|
63
73
|
tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name)
|
|
64
74
|
}));
|
|
65
75
|
}
|
|
66
|
-
};
|
|
76
|
+
});
|
|
67
77
|
//#endregion
|
|
68
78
|
export { listCapabilities };
|
package/dist/tools/names.mjs
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
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
|
-
import {
|
|
4
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
5
|
+
import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
|
|
5
6
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
6
7
|
import { withTransaction } from "../write/transaction.mjs";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { Pointer } from "rfc6902";
|
|
9
10
|
//#region src/tools/patch-document.ts
|
|
10
|
-
const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
|
|
11
|
+
const DESCRIPTION = (scope) => `Applies RFC 6902 JSON Patch operations to one document.
|
|
11
12
|
|
|
12
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.
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
${draftSentence(scope)}
|
|
15
16
|
|
|
16
|
-
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.
|
|
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. Inside a rich text field that substitution does not apply: a path there names the node type, and a block node its slug, where a pointer enters the stored state at "root" and walks "children" by an index counted over every child at that level, not over the blocks among them, with the node's own fields under "fields". So "/content/block/practice-note/variant" is written at "/content/root/children/7/fields/variant".
|
|
17
18
|
|
|
18
|
-
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.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
Inside a rich text field a pointer keeps going: "/content/root/children/2" is a node, "/content/root/children/2/tag" one of its properties, and "/content/root/children/2/fields/url" a field it carries. A node written at a position must carry everything Lexical serializes, "version" included, exactly as one written inside a whole state must; getDocument with "outline" returns each node's pointer and version, which is the cheapest way to get both right. A node's "type" cannot be replaced on its own, and neither can the root.
|
|
22
|
+
|
|
23
|
+
Node positions shift as soon as anything is added or removed, so read immediately before patching, order removals from the last index to the first, and use a "test" operation on "/content/root/children/2/type" to assert a position is what you think it is before writing to it.
|
|
24
|
+
|
|
25
|
+
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.`;
|
|
22
26
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
27
|
/**
|
|
24
28
|
* Whether the intended value survived the write. The saved document is
|
|
@@ -42,12 +46,18 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
42
46
|
const actual = pointer.get(saved);
|
|
43
47
|
return survives(expected, actual) ? [] : [operation.path];
|
|
44
48
|
});
|
|
45
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The handler validates the whole batch against the schema and the current
|
|
51
|
+
* document before it writes anything, runs the write in a transaction, then
|
|
52
|
+
* re-reads the saved document to report which pointers survived and what still
|
|
53
|
+
* blocks publishing. Nothing here decides where the write lands: the draft
|
|
54
|
+
* guard does that on the Payload operation.
|
|
55
|
+
*/ const patchDocument = defineMcpxTool({
|
|
46
56
|
name: "patchDocument",
|
|
47
57
|
description: DESCRIPTION,
|
|
48
58
|
annotations: {
|
|
49
59
|
readOnlyHint: false,
|
|
50
|
-
destructiveHint:
|
|
60
|
+
destructiveHint: true,
|
|
51
61
|
idempotentHint: false,
|
|
52
62
|
openWorldHint: false
|
|
53
63
|
},
|
|
@@ -63,13 +73,14 @@ const patchDocument = {
|
|
|
63
73
|
description: "Locale the patch applies to. Localized fields write here only."
|
|
64
74
|
}),
|
|
65
75
|
patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
|
|
66
|
-
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching.
|
|
76
|
+
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.")
|
|
67
77
|
}),
|
|
68
|
-
handler: async (args, scope) => {
|
|
78
|
+
handler: async ({ args, scope }) => {
|
|
69
79
|
const target = resolveTarget(scope, args, "write");
|
|
70
80
|
const id = requireIdFor(target, args.id);
|
|
71
81
|
const { payload } = scope.req;
|
|
72
82
|
const locale = localeOf(scope, args.locale);
|
|
83
|
+
const patches = args.patches;
|
|
73
84
|
return await withTransaction(scope.req, async () => {
|
|
74
85
|
const doc = await readTarget(scope, {
|
|
75
86
|
target,
|
|
@@ -77,13 +88,11 @@ const patchDocument = {
|
|
|
77
88
|
locale
|
|
78
89
|
});
|
|
79
90
|
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"] });
|
|
80
|
-
const
|
|
91
|
+
const applied = applyPatchOperations(payload.config, {
|
|
81
92
|
doc,
|
|
82
|
-
patches
|
|
93
|
+
patches,
|
|
83
94
|
ref: refOf(target)
|
|
84
95
|
});
|
|
85
|
-
if (problems.length > 0) return errorResult("No operation was applied.", { problems });
|
|
86
|
-
const applied = applyPatchToCopy(doc, args.patches);
|
|
87
96
|
if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
|
|
88
97
|
const write = {
|
|
89
98
|
data: buildWriteData(payload.config, target.config, applied.next),
|
|
@@ -100,6 +109,7 @@ const patchDocument = {
|
|
|
100
109
|
});
|
|
101
110
|
else await payload.updateGlobal({
|
|
102
111
|
...write,
|
|
112
|
+
fallbackLocale: false,
|
|
103
113
|
slug: target.slug
|
|
104
114
|
});
|
|
105
115
|
const saved = await readTarget(scope, {
|
|
@@ -108,8 +118,8 @@ const patchDocument = {
|
|
|
108
118
|
locale,
|
|
109
119
|
privileged: true
|
|
110
120
|
});
|
|
111
|
-
const notApplied = notAppliedPointers(
|
|
112
|
-
const
|
|
121
|
+
const notApplied = notAppliedPointers(patches, applied.next, saved);
|
|
122
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
113
123
|
doc: saved,
|
|
114
124
|
entity: target
|
|
115
125
|
});
|
|
@@ -117,11 +127,12 @@ const patchDocument = {
|
|
|
117
127
|
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
118
128
|
status: saved["_status"],
|
|
119
129
|
updatedAt: saved["updatedAt"],
|
|
120
|
-
...
|
|
130
|
+
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
131
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {},
|
|
121
132
|
...notApplied.length > 0 ? { notApplied } : {}
|
|
122
133
|
});
|
|
123
134
|
});
|
|
124
135
|
}
|
|
125
|
-
};
|
|
136
|
+
});
|
|
126
137
|
//#endregion
|
|
127
138
|
export { patchDocument };
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* The only tool that changes live content, available where the config sets
|
|
10
|
+
* `write: "live"` on a versioned entity and the key has both the `write` and
|
|
11
|
+
* `publish` checkboxes.
|
|
12
|
+
*/ const publishDocument = defineMcpxTool({
|
|
13
|
+
name: "publishDocument",
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
There is no unpublish: reverting to a draft stays a human action in the admin panel.`,
|
|
21
|
+
annotations: {
|
|
22
|
+
destructiveHint: true,
|
|
23
|
+
openWorldHint: false
|
|
24
|
+
},
|
|
25
|
+
isEnabled: (scope) => scope.publishable.length + scope.publishableGlobals.length > 0,
|
|
26
|
+
inputSchema: (scope) => ({
|
|
27
|
+
...targetShape(scope, "publish", {
|
|
28
|
+
collection: "Collection holding the document.",
|
|
29
|
+
global: "Global to publish."
|
|
30
|
+
}),
|
|
31
|
+
...idShape(scope, "publish"),
|
|
32
|
+
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.")
|
|
33
|
+
}),
|
|
34
|
+
handler: async ({ args, scope }) => {
|
|
35
|
+
const target = resolveTarget(scope, args, "publish");
|
|
36
|
+
const id = requireIdFor(target, args.id);
|
|
37
|
+
const { payload } = scope.req;
|
|
38
|
+
const locale = localeOf(scope, void 0);
|
|
39
|
+
return await withTransaction(scope.req, async () => {
|
|
40
|
+
const doc = await readTarget(scope, {
|
|
41
|
+
target,
|
|
42
|
+
id,
|
|
43
|
+
locale
|
|
44
|
+
});
|
|
45
|
+
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"] });
|
|
46
|
+
const write = {
|
|
47
|
+
data: withPublishIntent({}),
|
|
48
|
+
depth: 0,
|
|
49
|
+
draft: false,
|
|
50
|
+
fallbackLocale: false,
|
|
51
|
+
overrideAccess: false,
|
|
52
|
+
req: scope.req,
|
|
53
|
+
...locale === void 0 ? {} : { locale }
|
|
54
|
+
};
|
|
55
|
+
if (target.kind === "collection") await payload.update({
|
|
56
|
+
...write,
|
|
57
|
+
collection: target.slug,
|
|
58
|
+
id
|
|
59
|
+
});
|
|
60
|
+
else await payload.updateGlobal({
|
|
61
|
+
...write,
|
|
62
|
+
slug: target.slug
|
|
63
|
+
});
|
|
64
|
+
const saved = await readTarget(scope, {
|
|
65
|
+
target,
|
|
66
|
+
id,
|
|
67
|
+
locale,
|
|
68
|
+
privileged: true
|
|
69
|
+
});
|
|
70
|
+
return jsonResult({
|
|
71
|
+
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
72
|
+
status: saved["_status"],
|
|
73
|
+
updatedAt: saved["updatedAt"]
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
//#endregion
|
|
79
|
+
export { publishDocument };
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -1,48 +1,102 @@
|
|
|
1
|
+
import { canCreate, 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
/**
|
|
7
|
+
* An out-of-scope slug fails schema validation before a handler runs, so a
|
|
8
|
+
* client only ever sees what its key may touch.
|
|
9
|
+
*/ const slugEnum = (slugs) => z.enum(slugs);
|
|
10
|
+
/** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
11
|
+
const slugsWhere = (scope, predicate, allowed) => {
|
|
12
|
+
const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
|
|
13
|
+
return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Slugs this key may write whose writes land live rather than as a draft. An
|
|
17
|
+
* entity without versions has no draft to land on, so `write: "live"` there
|
|
18
|
+
* makes every write a live one. Empty for every key that can only write drafts.
|
|
19
|
+
*/ const liveWriteSlugs = (scope) => slugsWhere(scope, isLiveWrite, {
|
|
20
|
+
collections: scope.writable,
|
|
21
|
+
globals: scope.writableGlobals
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* Slugs this key may write but never create in, because their documents are
|
|
25
|
+
* files. Collection-only, since nothing creates a global either way.
|
|
26
|
+
*/ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
|
|
27
|
+
collections: scope.writable,
|
|
28
|
+
globals: []
|
|
29
|
+
});
|
|
30
|
+
/** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
|
|
31
|
+
collections: scope.publishable,
|
|
32
|
+
globals: scope.publishableGlobals
|
|
10
33
|
});
|
|
11
34
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
35
|
+
* What a write actually does for this key, and what it takes to make it public.
|
|
36
|
+
* A live-write slug has no draft and no publish step; a publishable one has
|
|
37
|
+
* both. Stated per key so a client is never told its writes are drafts while
|
|
38
|
+
* they are not, nor that publishing is out of reach when it is not.
|
|
39
|
+
*/ const draftSentence = (scope) => {
|
|
40
|
+
const live = liveWriteSlugs(scope);
|
|
41
|
+
const publishable = publishableWriteSlugs(scope);
|
|
42
|
+
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.`}`;
|
|
43
|
+
};
|
|
44
|
+
/** The value a client read back is a string; what it meets may be a Date. */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
45
|
+
/** Unchecked, because the runtime shape really does vary; `Branch` guards it. */ const widen = (branch) => branch;
|
|
46
|
+
/** The one list the shape helpers and {@link resolveTarget} both read. */ const slugsFor = (scope, operation) => {
|
|
47
|
+
switch (operation) {
|
|
48
|
+
case "create": return {
|
|
49
|
+
collections: slugsWhere(scope, canCreate, {
|
|
50
|
+
collections: scope.writable,
|
|
51
|
+
globals: []
|
|
52
|
+
}),
|
|
53
|
+
globals: []
|
|
54
|
+
};
|
|
55
|
+
case "publish": return {
|
|
56
|
+
collections: scope.publishable,
|
|
57
|
+
globals: scope.publishableGlobals
|
|
58
|
+
};
|
|
59
|
+
case "read": return {
|
|
60
|
+
collections: scope.readable,
|
|
61
|
+
globals: scope.readableGlobals
|
|
62
|
+
};
|
|
63
|
+
case "write": return {
|
|
64
|
+
collections: scope.writable,
|
|
65
|
+
globals: scope.writableGlobals
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* With no reachable global, `global` is left out and `collection` stays
|
|
71
|
+
* required, so a deployment without globals sees an unchanged schema. Only the
|
|
72
|
+
* mixed case makes either optional, and the handler enforces exclusivity there.
|
|
19
73
|
*/ const targetShape = (scope, operation, descriptions) => {
|
|
20
74
|
const { collections, globals } = slugsFor(scope, operation);
|
|
21
|
-
if (globals.length === 0) return { collection: slugEnum(collections).describe(descriptions.collection) };
|
|
22
|
-
if (collections.length === 0) return { global: slugEnum(globals).describe(descriptions.global) };
|
|
23
|
-
return {
|
|
75
|
+
if (globals.length === 0) return widen({ collection: slugEnum(collections).describe(descriptions.collection) });
|
|
76
|
+
if (collections.length === 0) return widen({ global: slugEnum(globals).describe(descriptions.global) });
|
|
77
|
+
return widen({
|
|
24
78
|
collection: slugEnum(collections).optional().describe(descriptions.collection),
|
|
25
79
|
global: slugEnum(globals).optional().describe(descriptions.global)
|
|
26
|
-
};
|
|
80
|
+
});
|
|
27
81
|
};
|
|
28
82
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* in between, where `requireIdFor` enforces the dependency.
|
|
83
|
+
* Only a collection document has one. Optional in the mixed case, where
|
|
84
|
+
* `requireIdFor` enforces the dependency.
|
|
32
85
|
*/ const idShape = (scope, operation) => {
|
|
33
86
|
const { collections, globals } = slugsFor(scope, operation);
|
|
34
|
-
if (collections.length === 0) return {};
|
|
35
|
-
if (globals.length === 0) return { id: idSchema };
|
|
36
|
-
return { id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") };
|
|
87
|
+
if (collections.length === 0) return widen({});
|
|
88
|
+
if (globals.length === 0) return widen({ id: idSchema });
|
|
89
|
+
return widen({ id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") });
|
|
37
90
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*/ const localeShape = (scope, options) => {
|
|
41
|
-
if (!scope.locales) return {};
|
|
91
|
+
const localeShape = (scope, options) => {
|
|
92
|
+
if (!scope.locales) return widen({});
|
|
42
93
|
const locale = z.enum(scope.locales);
|
|
43
|
-
return { locale: (options.required ? locale : locale.optional()).describe(options.description) };
|
|
94
|
+
return widen({ locale: (options.required ? locale : locale.optional()).describe(options.description) });
|
|
44
95
|
};
|
|
45
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Defaults to 0 rather than Payload's own default: a client usually wants ids
|
|
98
|
+
* it can write back, and populating a relation costs a query.
|
|
99
|
+
*/ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
|
|
46
100
|
/**
|
|
47
101
|
* The locale to operate on: the explicit argument, else the request's, else
|
|
48
102
|
* the default. `undefined` when localization is off.
|
|
@@ -83,9 +137,7 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
|
|
|
83
137
|
slug: args.target.slug
|
|
84
138
|
});
|
|
85
139
|
};
|
|
86
|
-
|
|
87
|
-
* Resolves a collection label for the request's language.
|
|
88
|
-
*/ const translateLabel = (scope, label, fallback) => {
|
|
140
|
+
const translateLabel = (scope, label, fallback) => {
|
|
89
141
|
const { i18n, t } = scope.req;
|
|
90
142
|
const resolved = typeof label === "function" ? label({
|
|
91
143
|
i18n,
|
|
@@ -94,4 +146,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
|
|
|
94
146
|
return translateStatic(resolved, i18n) ?? fallback;
|
|
95
147
|
};
|
|
96
148
|
//#endregion
|
|
97
|
-
export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
|
|
149
|
+
export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, patchOnlySlugs, 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) => ({
|
|
@@ -5,21 +6,17 @@ const refOf = (target) => ({
|
|
|
5
6
|
slug: target.slug
|
|
6
7
|
});
|
|
7
8
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* A tool's `inputSchema` returns a raw shape, which leaves no top-level
|
|
12
|
-
* `.refine` to express "exactly one of collection and global". The rule is
|
|
13
|
-
* enforced here instead, with a message naming the offending arguments so one
|
|
14
|
-
* failed call teaches it.
|
|
9
|
+
* A raw input shape leaves no top-level `.refine` to express "exactly one of
|
|
10
|
+
* collection and global", so the rule is enforced here, with a message naming
|
|
11
|
+
* the offending arguments.
|
|
15
12
|
*/ const resolveTarget = (scope, args, operation) => {
|
|
16
13
|
const { collection, global } = args;
|
|
14
|
+
const allowedSlugs = slugsFor(scope, operation);
|
|
17
15
|
if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
|
|
18
16
|
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
17
|
if (collection !== void 0) {
|
|
20
|
-
const allowed = operation === "read" ? scope.readable : scope.writable;
|
|
21
18
|
const found = scope.req.payload.collections[collection];
|
|
22
|
-
if (!
|
|
19
|
+
if (!allowedSlugs.collections.includes(collection) || !found) throw new Forbidden(scope.req.t);
|
|
23
20
|
return {
|
|
24
21
|
kind: "collection",
|
|
25
22
|
slug: collection,
|
|
@@ -27,9 +24,8 @@ const refOf = (target) => ({
|
|
|
27
24
|
};
|
|
28
25
|
}
|
|
29
26
|
const slug = global;
|
|
30
|
-
const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
|
|
31
27
|
const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
|
|
32
|
-
if (!
|
|
28
|
+
if (!allowedSlugs.globals.includes(slug) || !found) throw new Forbidden(scope.req.t);
|
|
33
29
|
return {
|
|
34
30
|
kind: "global",
|
|
35
31
|
slug,
|