@abinnovision/payloadcms-mcpx 1.1.0-beta.5 → 2.0.0-beta.10
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 +140 -448
- package/dist/admin/capability-matrix.d.mts +33 -0
- package/dist/admin/capability-matrix.mjs +354 -0
- package/dist/admin/capability-toggles.mjs +73 -0
- package/dist/{client → admin}/setup-guide.d.mts +4 -2
- package/dist/{client → admin}/setup-guide.mjs +3 -10
- package/dist/api-keys/capability-matrix.d.mts +12 -8
- package/dist/api-keys/capability-matrix.mjs +17 -85
- package/dist/api-keys/collection.mjs +2 -1
- package/dist/api-keys/fields.mjs +36 -24
- package/dist/auth/resolve.mjs +44 -6
- package/dist/{tools/names.mjs → builtin-tool-names.mjs} +2 -1
- package/dist/capabilities.mjs +41 -43
- package/dist/define-tool.d.mts +23 -0
- package/dist/{types.mjs → define-tool.mjs} +1 -1
- package/dist/endpoint/errors.mjs +5 -6
- package/dist/endpoint/handler.mjs +49 -19
- package/dist/endpoint/server.mjs +72 -20
- package/dist/entity.d.mts +9 -0
- package/dist/guards.mjs +7 -0
- package/dist/i18n.mjs +10 -8
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -2
- package/dist/options.mjs +52 -44
- package/dist/plugin.mjs +0 -1
- package/dist/request.d.mts +2 -3
- package/dist/request.mjs +2 -3
- package/dist/result.d.mts +2 -3
- package/dist/result.mjs +3 -3
- package/dist/schema/admin-hidden.mjs +91 -0
- package/dist/schema/describe.mjs +17 -33
- package/dist/schema/errors.mjs +8 -0
- package/dist/schema/index.mjs +5 -2
- package/dist/schema/lexical-pointer.mjs +18 -20
- package/dist/schema/lexical.mjs +44 -93
- package/dist/schema/outline.mjs +5 -15
- package/dist/schema/path.mjs +28 -0
- package/dist/schema/pointer.mjs +18 -25
- package/dist/schema/shape.mjs +25 -71
- package/dist/schema/walk.mjs +43 -45
- package/dist/tools/builtin.mjs +6 -4
- package/dist/tools/create-document.mjs +26 -23
- package/dist/tools/describe-schema.mjs +38 -34
- package/dist/tools/document.mjs +124 -0
- package/dist/tools/find-documents.mjs +32 -19
- package/dist/tools/find-versions.mjs +74 -0
- package/dist/tools/get-document.mjs +80 -49
- package/dist/tools/list-capabilities.mjs +25 -16
- package/dist/tools/patch-document.mjs +75 -49
- package/dist/tools/publish-document.mjs +26 -32
- package/dist/tools/query-paths.mjs +57 -0
- package/dist/tools/read-request.d.mts +10 -0
- package/dist/tools/read-request.mjs +43 -0
- package/dist/tools/shared.mjs +74 -96
- package/dist/tools/validate-document.mjs +18 -30
- package/dist/tools/versions.mjs +136 -0
- package/dist/types.d.mts +119 -73
- package/dist/write/draft-guard.mjs +23 -41
- package/dist/write/patch.mjs +27 -183
- package/dist/write/publish-blockers.mjs +7 -14
- package/dist/write/publish-intent.mjs +6 -4
- package/dist/write/row-ids.mjs +46 -0
- package/dist/write/transaction.mjs +5 -8
- package/dist/write/write-data.mjs +64 -0
- package/package.json +6 -5
- package/dist/client/capability-matrix.d.mts +0 -26
- package/dist/client/capability-matrix.mjs +0 -349
- package/dist/endpoint/index.mjs +0 -4
- package/dist/tools/target.mjs +0 -45
- /package/dist/{client → admin}/index.d.mts +0 -0
- /package/dist/{client → admin}/index.mjs +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineMcpxTool } from "../define-tool.mjs";
|
|
2
|
+
import { jsonResult } from "../result.mjs";
|
|
3
|
+
import { draftVersionSlugs, entityShape, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
|
|
4
|
+
import { resolveDocument } from "./document.mjs";
|
|
5
|
+
import { queryVersions } from "./versions.mjs";
|
|
6
|
+
import { APIError } from "payload";
|
|
7
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
//#region src/tools/find-versions.ts
|
|
10
|
+
const DESCRIPTION = `Lists the versions of one document or global, newest first, autosaves included. Returns per version "versionId", "createdAt", "updatedAt", "status", "latest", "autosave" and, where set, "publishedLocale", without the content. Read a version with getDocument "versionId", or compare it with "diffFrom".`;
|
|
11
|
+
const statusShape = (scope) => {
|
|
12
|
+
const { collections, globals } = draftVersionSlugs(scope);
|
|
13
|
+
if (collections.length + globals.length === 0) return widen({});
|
|
14
|
+
return widen({ status: z.enum(["published", "draft"]).optional() });
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Bodies are left out so a long history stays small; `getDocument` reads one
|
|
18
|
+
* when it is needed. The document's `read` access is checked first, then
|
|
19
|
+
* Payload's `readVersions`. Only entities that keep Payload versions are
|
|
20
|
+
* reachable.
|
|
21
|
+
*/ const findVersions = defineMcpxTool({
|
|
22
|
+
name: "findVersions",
|
|
23
|
+
description: DESCRIPTION,
|
|
24
|
+
annotations: {
|
|
25
|
+
readOnlyHint: true,
|
|
26
|
+
openWorldHint: false
|
|
27
|
+
},
|
|
28
|
+
isEnabled: (scope) => {
|
|
29
|
+
const { collections, globals } = slugsFor(scope, "versions");
|
|
30
|
+
return collections.length + globals.length > 0;
|
|
31
|
+
},
|
|
32
|
+
inputSchema: (scope) => ({
|
|
33
|
+
...entityShape(scope, "versions"),
|
|
34
|
+
...idShape(scope, "versions"),
|
|
35
|
+
limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe("Default 10."),
|
|
36
|
+
page: z.number().int().min(1).optional(),
|
|
37
|
+
...statusShape(scope),
|
|
38
|
+
...localeShape(scope, {
|
|
39
|
+
required: false,
|
|
40
|
+
description: "Locale whose \"status\" is reported. Defaults to the default locale."
|
|
41
|
+
})
|
|
42
|
+
}),
|
|
43
|
+
handler: async ({ args, scope }) => {
|
|
44
|
+
const target = resolveDocument(scope, args, "versions");
|
|
45
|
+
if (args.status !== void 0 && !hasDraftsEnabled(target.config)) throw new APIError(`"${target.slug}" has no drafts, so its versions carry no status.`, 400);
|
|
46
|
+
const result = await queryVersions(scope, {
|
|
47
|
+
target,
|
|
48
|
+
depth: 0,
|
|
49
|
+
locale: localeOf(scope, args.locale)
|
|
50
|
+
}, {
|
|
51
|
+
limit: args.limit ?? 10,
|
|
52
|
+
...args.page === void 0 ? {} : { page: args.page },
|
|
53
|
+
...args.status === void 0 ? {} : { status: args.status }
|
|
54
|
+
});
|
|
55
|
+
const versions = result.docs.map((doc) => ({
|
|
56
|
+
versionId: doc.id,
|
|
57
|
+
createdAt: doc["createdAt"],
|
|
58
|
+
updatedAt: doc["updatedAt"],
|
|
59
|
+
status: doc.version["_status"] ?? null,
|
|
60
|
+
latest: doc["latest"] === true,
|
|
61
|
+
autosave: doc["autosave"] === true,
|
|
62
|
+
...doc["publishedLocale"] ? { publishedLocale: doc["publishedLocale"] } : {}
|
|
63
|
+
}));
|
|
64
|
+
return jsonResult({
|
|
65
|
+
versions,
|
|
66
|
+
totalDocs: result.totalDocs,
|
|
67
|
+
page: result.page,
|
|
68
|
+
totalPages: result.totalPages,
|
|
69
|
+
hasNextPage: result.hasNextPage
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
//#endregion
|
|
74
|
+
export { findVersions };
|
|
@@ -1,80 +1,110 @@
|
|
|
1
|
+
import { defineMcpxTool } from "../define-tool.mjs";
|
|
1
2
|
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
-
import { JSON_POINTER_PATTERN,
|
|
3
|
+
import { JSON_POINTER_PATTERN, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
|
|
4
|
+
import { findRichTextField } from "../schema/walk.mjs";
|
|
5
|
+
import { SchemaError } from "../schema/errors.mjs";
|
|
3
6
|
import { lexicalOutline } from "../schema/outline.mjs";
|
|
4
7
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
5
8
|
import "../schema/index.mjs";
|
|
6
|
-
import { depthShape, idShape, localeOf, localeShape,
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
+
import { READ_LOCALE_DESCRIPTION, depthShape, entityShape, idSchema, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
|
|
10
|
+
import { identityOf, resolveDocument } from "./document.mjs";
|
|
11
|
+
import { diffDocuments, loadPublished, loadVersion, readLive } from "./versions.mjs";
|
|
12
|
+
import { APIError } from "payload";
|
|
9
13
|
import { z } from "zod";
|
|
10
14
|
import { Pointer } from "rfc6902";
|
|
11
15
|
//#region src/tools/get-document.ts
|
|
12
16
|
const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
|
|
17
|
+
const VERSION_PARAGRAPH = `
|
|
18
|
+
|
|
19
|
+
"versionId" and "diffFrom" work where findVersions does. To revert, call getDocument with the old version as "versionId", the version findVersions marks "latest" as "diffFrom" and the locale you will write, then apply the returned "patch" with patchDocument.`;
|
|
20
|
+
const DESCRIPTION = `Reads one document or global, or with "path" only the value at that pointer. Returns the latest draft by default, with the "updatedAt" a write takes as "expectedUpdatedAt".`;
|
|
21
|
+
const exposesVersions = (scope) => {
|
|
22
|
+
const { collections, globals } = slugsFor(scope, "versions");
|
|
23
|
+
return collections.length + globals.length > 0;
|
|
24
|
+
};
|
|
25
|
+
const assertVersionArgs = (scope, target, args) => {
|
|
26
|
+
if (args.versionId === void 0 && args.diffFrom === void 0) return;
|
|
27
|
+
const { collections, globals } = slugsFor(scope, "versions");
|
|
28
|
+
if (!(target.kind === "collection" ? collections : globals).includes(target.slug)) throw new APIError(`"${target.slug}" does not expose version history.`, 400);
|
|
29
|
+
if (args.versionId !== void 0 && args.draft !== void 0) throw new APIError("Pass either \"versionId\" or \"draft\", not both.", 400);
|
|
30
|
+
if (args.diffFrom !== void 0 && args.outline) throw new APIError("\"outline\" cannot be combined with \"diffFrom\".", 400);
|
|
31
|
+
};
|
|
32
|
+
const diffResult = async (scope, read, diff, doc) => {
|
|
33
|
+
const from = diff.from === "published" ? await loadPublished(scope, read) : await loadVersion(scope, read, diff.from);
|
|
34
|
+
if (!from) return errorResult(diff.from === "published" ? `"${read.target.slug}" has no published version to diff from.` : `Version "${String(diff.from)}" not found.`);
|
|
35
|
+
return jsonResult({
|
|
36
|
+
from: from.id,
|
|
37
|
+
to: diff.to ?? "current",
|
|
38
|
+
...diff.pointer === void 0 ? {} : { path: diff.pointer.toString() },
|
|
39
|
+
patch: diffDocuments(from.version, doc, diff.pointer)
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
const versionShape = (scope) => {
|
|
43
|
+
if (!exposesVersions(scope)) return widen({});
|
|
44
|
+
return widen({
|
|
45
|
+
versionId: idSchema.optional().describe("From findVersions. Not with \"draft\"."),
|
|
46
|
+
diffFrom: idSchema.optional().describe("Version id, or \"published\" for the newest version published in any locale. Returns {from, to, patch}, the JSON Patch from that version to what is read.")
|
|
47
|
+
});
|
|
48
|
+
};
|
|
13
49
|
/**
|
|
14
50
|
* With `path` the handler returns the subtree plus the `id`, `_status` and
|
|
15
|
-
* `updatedAt` a client needs to write back, so
|
|
16
|
-
*
|
|
51
|
+
* `updatedAt` a client needs to write back, so reading one branch still gives
|
|
52
|
+
* the `expectedUpdatedAt` timestamp without a second call.
|
|
17
53
|
*/ const getDocument = defineMcpxTool({
|
|
18
54
|
name: "getDocument",
|
|
19
|
-
description:
|
|
20
|
-
|
|
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.`,
|
|
55
|
+
description: (scope) => exposesVersions(scope) ? DESCRIPTION + VERSION_PARAGRAPH : DESCRIPTION,
|
|
24
56
|
annotations: {
|
|
25
57
|
readOnlyHint: true,
|
|
26
58
|
openWorldHint: false
|
|
27
59
|
},
|
|
28
|
-
isEnabled: (scope) => scope.readable.length + scope.
|
|
60
|
+
isEnabled: (scope) => scope.collections.readable.length + scope.globals.readable.length > 0,
|
|
29
61
|
inputSchema: (scope) => ({
|
|
30
|
-
...
|
|
31
|
-
collection: "Collection holding the document.",
|
|
32
|
-
global: "Global to read."
|
|
33
|
-
}),
|
|
62
|
+
...entityShape(scope, "read"),
|
|
34
63
|
...idShape(scope, "read"),
|
|
35
|
-
path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("
|
|
64
|
+
path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("e.g. \"/layout/sections/0\"."),
|
|
36
65
|
...depthShape(scope),
|
|
37
66
|
...localeShape(scope, {
|
|
38
67
|
required: false,
|
|
39
|
-
description:
|
|
68
|
+
description: READ_LOCALE_DESCRIPTION
|
|
40
69
|
}),
|
|
41
|
-
draft: z.boolean().optional().describe("
|
|
42
|
-
outline: z.boolean().optional().describe("
|
|
70
|
+
draft: z.boolean().optional().describe("Default true. false reads the published version."),
|
|
71
|
+
outline: z.boolean().optional().describe("With \"path\" at a rich text field: list each node's pointer, type, \"version\" and text."),
|
|
72
|
+
...versionShape(scope)
|
|
43
73
|
}),
|
|
44
74
|
handler: async ({ args, scope }) => {
|
|
45
|
-
const target =
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
75
|
+
const target = resolveDocument(scope, args, "read");
|
|
76
|
+
assertVersionArgs(scope, target, args);
|
|
77
|
+
const prototyped = args.path ? prototypeSegmentProblem(args.path) : void 0;
|
|
78
|
+
if (prototyped !== void 0) return errorResult(prototyped);
|
|
79
|
+
let pointer;
|
|
80
|
+
try {
|
|
81
|
+
pointer = args.path ? Pointer.fromJSON(args.path) : void 0;
|
|
82
|
+
} catch {
|
|
83
|
+
return errorResult(`"${String(args.path)}" is not a valid JSON pointer.`);
|
|
84
|
+
}
|
|
85
|
+
const read = {
|
|
86
|
+
target,
|
|
49
87
|
depth: args.depth ?? 0,
|
|
50
|
-
|
|
51
|
-
overrideAccess: false,
|
|
52
|
-
req: scope.req,
|
|
53
|
-
...locale === void 0 ? {} : { locale }
|
|
88
|
+
locale: localeOf(scope, args.locale)
|
|
54
89
|
};
|
|
55
|
-
const doc = await (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (args.path === void 0 || args.path === "") {
|
|
90
|
+
const doc = args.versionId === void 0 ? await readLive(scope, read, { draft: args.draft ?? true }) : (await loadVersion(scope, read, args.versionId))?.version;
|
|
91
|
+
if (!doc) return errorResult(`Version "${String(args.versionId)}" not found.`);
|
|
92
|
+
if (args.diffFrom !== void 0) return await diffResult(scope, read, {
|
|
93
|
+
from: args.diffFrom,
|
|
94
|
+
to: args.versionId,
|
|
95
|
+
pointer
|
|
96
|
+
}, doc);
|
|
97
|
+
if (pointer === void 0) {
|
|
64
98
|
if (args.outline) return errorResult(OUTLINE_ERROR);
|
|
65
99
|
return jsonResult(doc);
|
|
66
100
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
value = Pointer.fromJSON(args.path).get(doc);
|
|
70
|
-
} catch {
|
|
71
|
-
return errorResult(`"${args.path}" is not a valid JSON pointer.`);
|
|
72
|
-
}
|
|
101
|
+
const path = pointer.toString();
|
|
102
|
+
const value = pointer.get(doc);
|
|
73
103
|
const envelope = {
|
|
74
|
-
...target.
|
|
104
|
+
...identityOf(target, args.id),
|
|
75
105
|
status: doc["_status"],
|
|
76
106
|
updatedAt: doc["updatedAt"],
|
|
77
|
-
path
|
|
107
|
+
path
|
|
78
108
|
};
|
|
79
109
|
if (!args.outline) return jsonResult({
|
|
80
110
|
...envelope,
|
|
@@ -84,17 +114,18 @@ Set "outline" on a rich text "path" to get a compact positional listing of its n
|
|
|
84
114
|
try {
|
|
85
115
|
resolution = resolveDataPointer(scope.req.payload.config, {
|
|
86
116
|
doc,
|
|
87
|
-
pointer:
|
|
88
|
-
ref:
|
|
117
|
+
pointer: path,
|
|
118
|
+
ref: target
|
|
89
119
|
});
|
|
90
120
|
} catch (error) {
|
|
91
|
-
|
|
121
|
+
if (!(error instanceof SchemaError)) throw error;
|
|
122
|
+
return errorResult(error.message);
|
|
92
123
|
}
|
|
93
124
|
const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findRichTextField(resolution.fields, splitPath(resolution.descriptor.path)) : void 0;
|
|
94
125
|
if (!field) return errorResult(OUTLINE_ERROR);
|
|
95
126
|
return jsonResult({
|
|
96
127
|
...envelope,
|
|
97
|
-
outline: lexicalOutline(value,
|
|
128
|
+
outline: lexicalOutline(value, path, field)
|
|
98
129
|
});
|
|
99
130
|
}
|
|
100
131
|
});
|
|
@@ -1,20 +1,27 @@
|
|
|
1
|
+
import { defineMcpxTool } from "../define-tool.mjs";
|
|
1
2
|
import { canCreate } from "../capabilities.mjs";
|
|
2
3
|
import { jsonResult } from "../result.mjs";
|
|
3
|
-
import { translatorFor } from "../i18n.mjs";
|
|
4
|
-
import { translateLabel } from "./shared.mjs";
|
|
5
|
-
import { defineMcpxTool } from "../types.mjs";
|
|
4
|
+
import { translateStatic, translatorFor } from "../i18n.mjs";
|
|
6
5
|
import { hasDraftValidationEnabled } from "payload/shared";
|
|
6
|
+
//#region src/tools/list-capabilities.ts
|
|
7
|
+
const DESCRIPTION = `Lists what this key may do. Call it first. Returns each collection and global the key can read or write, with whether it can create, publish, has drafts and exposes versions to findVersions, and a collection's "idType". Also returns the locales, the "limits" on page size and depth, and the enabled custom tools.`;
|
|
8
|
+
const translateLabel = (scope, label, fallback) => {
|
|
9
|
+
const { i18n, t } = scope.req;
|
|
10
|
+
const resolved = typeof label === "function" ? label({
|
|
11
|
+
i18n,
|
|
12
|
+
t
|
|
13
|
+
}) : label;
|
|
14
|
+
return translateStatic(resolved, i18n) ?? fallback;
|
|
15
|
+
};
|
|
7
16
|
/**
|
|
8
|
-
* Registered for every key,
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
17
|
+
* Registered for every key, even one with no capabilities ticked, so a client
|
|
18
|
+
* always has a tool to call and gets an empty surface described instead of an
|
|
19
|
+
* empty tool list. The response comes from the request scope and the sanitized
|
|
20
|
+
* config, not the content model, so it stays the same size as a deployment
|
|
21
|
+
* grows.
|
|
13
22
|
*/ const listCapabilities = defineMcpxTool({
|
|
14
23
|
name: "listCapabilities",
|
|
15
|
-
description:
|
|
16
|
-
|
|
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".`,
|
|
24
|
+
description: DESCRIPTION,
|
|
18
25
|
annotations: {
|
|
19
26
|
readOnlyHint: true,
|
|
20
27
|
openWorldHint: false
|
|
@@ -42,6 +49,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
42
49
|
create: capability.write && canCreate(entry),
|
|
43
50
|
publish: capability.publish,
|
|
44
51
|
drafts: entry.hasDrafts,
|
|
52
|
+
versions: entry.hasVersions,
|
|
45
53
|
draftValidation: hasDraftValidationEnabled(config),
|
|
46
54
|
idType: collection.customIDType ?? payload.db.defaultIDType
|
|
47
55
|
}];
|
|
@@ -59,19 +67,20 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
59
67
|
write: capability.write,
|
|
60
68
|
publish: capability.publish,
|
|
61
69
|
drafts: entry.hasDrafts,
|
|
70
|
+
versions: entry.hasVersions,
|
|
62
71
|
draftValidation: hasDraftValidationEnabled(config)
|
|
63
72
|
}];
|
|
64
73
|
});
|
|
65
|
-
return
|
|
74
|
+
return jsonResult({
|
|
66
75
|
collections,
|
|
67
76
|
...globals.length > 0 ? { globals } : {},
|
|
68
|
-
locales: scope.
|
|
69
|
-
codes: scope.locales,
|
|
70
|
-
default: scope.defaultLocale
|
|
77
|
+
locales: scope.localization ? {
|
|
78
|
+
codes: scope.localization.locales,
|
|
79
|
+
default: scope.localization.defaultLocale
|
|
71
80
|
} : null,
|
|
72
81
|
limits: scope.limits,
|
|
73
82
|
tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name)
|
|
74
|
-
})
|
|
83
|
+
});
|
|
75
84
|
}
|
|
76
85
|
});
|
|
77
86
|
//#endregion
|
|
@@ -1,45 +1,69 @@
|
|
|
1
|
+
import { defineMcpxTool } from "../define-tool.mjs";
|
|
2
|
+
import { isPlainObject } from "../guards.mjs";
|
|
1
3
|
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
|
|
4
|
+
import { JSON_POINTER_PATTERN } from "../schema/path.mjs";
|
|
5
|
+
import "../schema/index.mjs";
|
|
6
|
+
import { entityShape, idShape, liveWriteSentence, localeOf, localeShape } from "./shared.mjs";
|
|
6
7
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
8
|
+
import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
|
|
9
|
+
import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
|
|
7
10
|
import { withTransaction } from "../write/transaction.mjs";
|
|
11
|
+
import { buildWriteData } from "../write/write-data.mjs";
|
|
12
|
+
import { Forbidden, NotFound } from "payload";
|
|
8
13
|
import { z } from "zod";
|
|
9
14
|
import { Pointer } from "rfc6902";
|
|
10
15
|
//#region src/tools/patch-document.ts
|
|
11
|
-
const DESCRIPTION = (scope) => `Applies
|
|
16
|
+
const DESCRIPTION = (scope) => `Applies JSON Patch operations to one document or global, e.g. {"op":"replace","path":"/title","value":"Home"}. If any operation fails, nothing is written and the response lists the problems.
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
${liveWriteSentence(scope, "write")}
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
Read the document with getDocument right before patching. Append to a list with "/-". A new block needs "blockType". To clear a field, "replace" it with null, or with [] for an array or blocks field. "remove" only removes list elements. Indices shift with every add or remove, so remove from the last index to the first.
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
In a rich text field a pointer continues into the rich text state: "/content/root/children/2" is a node, "/content/root/children/2/tag" one of its properties and "/content/root/children/3/fields/tone" a field of a block node. getDocument "outline" lists each node's pointer and "version". Build nodes from describeSchema "nodeProperties". Check a position before writing to it, e.g. {"op":"test","path":"/content/root/children/2/type","value":"heading"}.
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.`;
|
|
26
|
-
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
The response carries "updatedAt" and, if any, "publishBlockers": what must be fixed before publishing. "publishBlockersUnavailable" means that check failed. The write stands either way. "notApplied" lists pointers whose value did not change or cannot be read back, for example where field access denies the update.`;
|
|
25
|
+
const POINTER = z.string().regex(JSON_POINTER_PATTERN);
|
|
26
|
+
const PATCHES_LIMIT = 500;
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
* Discriminated on `op`, so an operation carries only its own members.
|
|
29
|
+
*/ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
|
|
30
|
+
z.strictObject({
|
|
31
|
+
op: z.literal("add"),
|
|
32
|
+
path: POINTER,
|
|
33
|
+
value: z.unknown()
|
|
34
|
+
}),
|
|
35
|
+
z.strictObject({
|
|
36
|
+
op: z.literal("remove"),
|
|
37
|
+
path: POINTER
|
|
38
|
+
}),
|
|
39
|
+
z.strictObject({
|
|
40
|
+
op: z.literal("replace"),
|
|
41
|
+
path: POINTER,
|
|
42
|
+
value: z.unknown()
|
|
43
|
+
}),
|
|
44
|
+
z.strictObject({
|
|
45
|
+
from: POINTER,
|
|
46
|
+
op: z.literal("move"),
|
|
47
|
+
path: POINTER
|
|
48
|
+
}),
|
|
49
|
+
z.strictObject({
|
|
50
|
+
from: POINTER,
|
|
51
|
+
op: z.literal("copy"),
|
|
52
|
+
path: POINTER
|
|
53
|
+
}),
|
|
54
|
+
z.strictObject({
|
|
55
|
+
op: z.literal("test"),
|
|
56
|
+
path: POINTER,
|
|
57
|
+
value: z.unknown()
|
|
58
|
+
})
|
|
59
|
+
]);
|
|
60
|
+
const survives = (expected, actual) => {
|
|
33
61
|
if (expected === void 0 || expected === null) return actual === void 0 || actual === null;
|
|
34
62
|
if (Array.isArray(expected)) return Array.isArray(actual) && expected.length === actual.length && expected.every((entry, index) => survives(entry, actual[index]));
|
|
35
63
|
if (isPlainObject(expected)) return isPlainObject(actual) && Object.entries(expected).every(([key, value]) => key === "id" || survives(value, actual[key]));
|
|
36
64
|
return isPlainObject(actual) || Array.isArray(actual) ? false : JSON.stringify(expected) === JSON.stringify(actual);
|
|
37
65
|
};
|
|
38
|
-
|
|
39
|
-
* Pointers whose intended value did not survive the write. Element pointers
|
|
40
|
-
* are skipped: an append pointer (`/-`) does not resolve against the saved
|
|
41
|
-
* document.
|
|
42
|
-
*/ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operation) => {
|
|
66
|
+
const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operation) => {
|
|
43
67
|
if (operation.op !== "add" && operation.op !== "replace" || isElementPointer(operation.path)) return [];
|
|
44
68
|
const pointer = Pointer.fromJSON(operation.path);
|
|
45
69
|
const expected = pointer.get(intended);
|
|
@@ -48,10 +72,10 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
48
72
|
});
|
|
49
73
|
/**
|
|
50
74
|
* The handler validates the whole batch against the schema and the current
|
|
51
|
-
* document before
|
|
75
|
+
* document before writing anything, runs the write in a transaction, then
|
|
52
76
|
* re-reads the saved document to report which pointers survived and what still
|
|
53
|
-
* blocks publishing.
|
|
54
|
-
*
|
|
77
|
+
* blocks publishing. The draft guard, not this tool, decides where the write
|
|
78
|
+
* lands.
|
|
55
79
|
*/ const patchDocument = defineMcpxTool({
|
|
56
80
|
name: "patchDocument",
|
|
57
81
|
description: DESCRIPTION,
|
|
@@ -61,37 +85,33 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
61
85
|
idempotentHint: false,
|
|
62
86
|
openWorldHint: false
|
|
63
87
|
},
|
|
64
|
-
isEnabled: (scope) => scope.writable.length + scope.
|
|
88
|
+
isEnabled: (scope) => scope.collections.writable.length + scope.globals.writable.length > 0,
|
|
65
89
|
inputSchema: (scope) => ({
|
|
66
|
-
...
|
|
67
|
-
collection: "Collection holding the document.",
|
|
68
|
-
global: "Global to patch."
|
|
69
|
-
}),
|
|
90
|
+
...entityShape(scope, "write"),
|
|
70
91
|
...idShape(scope, "write"),
|
|
71
92
|
...localeShape(scope, {
|
|
72
93
|
required: true,
|
|
73
|
-
description: "
|
|
94
|
+
description: "Localized fields are written in this locale only."
|
|
74
95
|
}),
|
|
75
|
-
patches: z.array(PATCH_OPERATION_SCHEMA).min(1).
|
|
76
|
-
expectedUpdatedAt: z.string().optional().describe("
|
|
96
|
+
patches: z.array(PATCH_OPERATION_SCHEMA).min(1).max(PATCHES_LIMIT),
|
|
97
|
+
expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
|
|
77
98
|
}),
|
|
78
99
|
handler: async ({ args, scope }) => {
|
|
79
|
-
const target =
|
|
80
|
-
const id = requireIdFor(target, args.id);
|
|
100
|
+
const target = resolveDocument(scope, args, "write");
|
|
81
101
|
const { payload } = scope.req;
|
|
82
102
|
const locale = localeOf(scope, args.locale);
|
|
83
103
|
const patches = args.patches;
|
|
84
104
|
return await withTransaction(scope.req, async () => {
|
|
85
|
-
const doc = await
|
|
105
|
+
const doc = await readDraft(scope, {
|
|
86
106
|
target,
|
|
87
|
-
id,
|
|
88
107
|
locale
|
|
89
108
|
});
|
|
90
|
-
|
|
109
|
+
const stale = staleReadResult(doc, args.expectedUpdatedAt, "The document changed since you read it. Read it again and re-apply the patch.");
|
|
110
|
+
if (stale) return stale;
|
|
91
111
|
const applied = applyPatchOperations(payload.config, {
|
|
92
112
|
doc,
|
|
93
113
|
patches,
|
|
94
|
-
ref:
|
|
114
|
+
ref: target
|
|
95
115
|
});
|
|
96
116
|
if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
|
|
97
117
|
const write = {
|
|
@@ -105,26 +125,32 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
105
125
|
if (target.kind === "collection") await payload.update({
|
|
106
126
|
...write,
|
|
107
127
|
collection: target.slug,
|
|
108
|
-
id
|
|
128
|
+
id: target.id
|
|
109
129
|
});
|
|
110
130
|
else await payload.updateGlobal({
|
|
111
131
|
...write,
|
|
112
132
|
fallbackLocale: false,
|
|
113
133
|
slug: target.slug
|
|
114
134
|
});
|
|
115
|
-
const saved = await
|
|
135
|
+
const saved = await readDraft(scope, {
|
|
116
136
|
target,
|
|
117
|
-
id,
|
|
118
137
|
locale,
|
|
119
138
|
privileged: true
|
|
120
139
|
});
|
|
121
|
-
const
|
|
140
|
+
const readable = await readDraft(scope, {
|
|
141
|
+
target,
|
|
142
|
+
locale
|
|
143
|
+
}).catch((error) => {
|
|
144
|
+
if (error instanceof NotFound || error instanceof Forbidden) return;
|
|
145
|
+
throw error;
|
|
146
|
+
});
|
|
147
|
+
const notApplied = readable ? notAppliedPointers(patches, applied.next, readable) : [];
|
|
122
148
|
const validation = await collectPublishBlockers(scope.req, {
|
|
123
149
|
doc: saved,
|
|
124
150
|
entity: target
|
|
125
151
|
});
|
|
126
152
|
return jsonResult({
|
|
127
|
-
...target
|
|
153
|
+
...identityOf(target, saved["id"]),
|
|
128
154
|
status: saved["_status"],
|
|
129
155
|
updatedAt: saved["updatedAt"],
|
|
130
156
|
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
@@ -135,4 +161,4 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
135
161
|
}
|
|
136
162
|
});
|
|
137
163
|
//#endregion
|
|
138
|
-
export { patchDocument };
|
|
164
|
+
export { PATCH_OPERATION_SCHEMA, patchDocument };
|
|
@@ -1,48 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { defineMcpxTool } from "../define-tool.mjs";
|
|
2
|
+
import { jsonResult } from "../result.mjs";
|
|
3
|
+
import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
|
|
4
|
+
import { collectLocaleBlockers, identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
|
|
5
5
|
import { withTransaction } from "../write/transaction.mjs";
|
|
6
6
|
import { withPublishIntent } from "../write/publish-intent.mjs";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `publish` checkboxes.
|
|
9
|
+
* Publishes a draft. Available where the config exposes `publish` on an entity
|
|
10
|
+
* with drafts and the key has both the `write` and `publish` checkboxes.
|
|
12
11
|
*/ const publishDocument = defineMcpxTool({
|
|
13
12
|
name: "publishDocument",
|
|
14
|
-
description: `
|
|
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.`,
|
|
13
|
+
description: `Makes the current draft of one document or global public, in every locale or only in "locale". A scoped publish keeps the other locales of localized fields as last published, and a never-published document goes live in full. A draft with publish blockers is refused, so call validateDocument first. Payload checks only the published locale, so "otherLocaleBlockers" lists what other locales still lack. There is no unpublish.`,
|
|
21
14
|
annotations: {
|
|
22
15
|
destructiveHint: true,
|
|
23
16
|
openWorldHint: false
|
|
24
17
|
},
|
|
25
|
-
isEnabled: (scope) => scope.publishable.length + scope.
|
|
18
|
+
isEnabled: (scope) => scope.collections.publishable.length + scope.globals.publishable.length > 0,
|
|
26
19
|
inputSchema: (scope) => ({
|
|
27
|
-
...
|
|
28
|
-
collection: "Collection holding the document.",
|
|
29
|
-
global: "Global to publish."
|
|
30
|
-
}),
|
|
20
|
+
...entityShape(scope, "publish"),
|
|
31
21
|
...idShape(scope, "publish"),
|
|
32
|
-
|
|
22
|
+
...localeShape(scope, { required: false }),
|
|
23
|
+
expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
|
|
33
24
|
}),
|
|
34
25
|
handler: async ({ args, scope }) => {
|
|
35
|
-
const target =
|
|
36
|
-
const id = requireIdFor(target, args.id);
|
|
26
|
+
const target = resolveDocument(scope, args, "publish");
|
|
37
27
|
const { payload } = scope.req;
|
|
38
|
-
const locale = localeOf(scope,
|
|
28
|
+
const locale = localeOf(scope, args.locale);
|
|
29
|
+
const { localization } = scope;
|
|
39
30
|
return await withTransaction(scope.req, async () => {
|
|
40
|
-
const doc = await
|
|
31
|
+
const doc = await readDraft(scope, {
|
|
41
32
|
target,
|
|
42
|
-
id,
|
|
43
33
|
locale
|
|
44
34
|
});
|
|
45
|
-
|
|
35
|
+
const stale = staleReadResult(doc, args.expectedUpdatedAt, "The document changed since you read it. Read it again before publishing.");
|
|
36
|
+
if (stale) return stale;
|
|
46
37
|
const write = {
|
|
47
38
|
data: withPublishIntent({}),
|
|
48
39
|
depth: 0,
|
|
@@ -50,27 +41,30 @@ There is no unpublish: reverting to a draft stays a human action in the admin pa
|
|
|
50
41
|
fallbackLocale: false,
|
|
51
42
|
overrideAccess: false,
|
|
52
43
|
req: scope.req,
|
|
53
|
-
...locale === void 0 ? {} : { locale }
|
|
44
|
+
...locale === void 0 ? {} : { locale },
|
|
45
|
+
...args.locale === void 0 ? {} : { publishSpecificLocale: args.locale }
|
|
54
46
|
};
|
|
55
47
|
if (target.kind === "collection") await payload.update({
|
|
56
48
|
...write,
|
|
57
49
|
collection: target.slug,
|
|
58
|
-
id
|
|
50
|
+
id: target.id
|
|
59
51
|
});
|
|
60
52
|
else await payload.updateGlobal({
|
|
61
53
|
...write,
|
|
62
54
|
slug: target.slug
|
|
63
55
|
});
|
|
64
|
-
const saved = await
|
|
56
|
+
const saved = await readDraft(scope, {
|
|
65
57
|
target,
|
|
66
|
-
id,
|
|
67
58
|
locale,
|
|
68
59
|
privileged: true
|
|
69
60
|
});
|
|
61
|
+
const others = await collectLocaleBlockers(scope, target, localization?.locales.filter((entry) => entry !== locale) ?? []);
|
|
70
62
|
return jsonResult({
|
|
71
|
-
...target
|
|
63
|
+
...identityOf(target, saved["id"]),
|
|
72
64
|
status: saved["_status"],
|
|
73
|
-
updatedAt: saved["updatedAt"]
|
|
65
|
+
updatedAt: saved["updatedAt"],
|
|
66
|
+
...others.blockers.length > 0 ? { otherLocaleBlockers: others.blockers } : {},
|
|
67
|
+
...others.unavailable ? { otherLocaleBlockersUnavailable: true } : {}
|
|
74
68
|
});
|
|
75
69
|
});
|
|
76
70
|
}
|