@abinnovision/payloadcms-mcpx 1.0.0-beta.3

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.
Files changed (40) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +258 -0
  3. package/dist/api-keys/collection.mjs +58 -0
  4. package/dist/api-keys/fields.mjs +88 -0
  5. package/dist/api-keys/key.mjs +10 -0
  6. package/dist/auth/resolve.mjs +62 -0
  7. package/dist/capabilities.mjs +30 -0
  8. package/dist/endpoint/handler.mjs +84 -0
  9. package/dist/endpoint/result.mjs +59 -0
  10. package/dist/endpoint/server.mjs +52 -0
  11. package/dist/index.d.mts +5 -0
  12. package/dist/index.mjs +4 -0
  13. package/dist/options.mjs +107 -0
  14. package/dist/plugin.d.mts +9 -0
  15. package/dist/plugin.mjs +42 -0
  16. package/dist/schema/describe.mjs +82 -0
  17. package/dist/schema/lexical.mjs +25 -0
  18. package/dist/schema/pointer.mjs +83 -0
  19. package/dist/schema/shape.mjs +147 -0
  20. package/dist/schema/walk.mjs +109 -0
  21. package/dist/tools/create-document.mjs +67 -0
  22. package/dist/tools/describe-schema.mjs +44 -0
  23. package/dist/tools/find-documents.mjs +54 -0
  24. package/dist/tools/get-document.mjs +54 -0
  25. package/dist/tools/index.mjs +23 -0
  26. package/dist/tools/list-capabilities.mjs +49 -0
  27. package/dist/tools/names.mjs +14 -0
  28. package/dist/tools/patch-document.mjs +113 -0
  29. package/dist/tools/shared.mjs +65 -0
  30. package/dist/tools/validate-document.mjs +48 -0
  31. package/dist/types.d.mts +113 -0
  32. package/dist/types.mjs +7 -0
  33. package/dist/version.mjs +6 -0
  34. package/dist/write/draft-guard.d.mts +10 -0
  35. package/dist/write/draft-guard.mjs +70 -0
  36. package/dist/write/patch.mjs +220 -0
  37. package/dist/write/publish-blockers.d.mts +13 -0
  38. package/dist/write/publish-blockers.mjs +72 -0
  39. package/dist/write/transaction.mjs +19 -0
  40. package/package.json +89 -0
@@ -0,0 +1,113 @@
1
+ import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
+ import { collectionEnum, ensureAllowed, idSchema, localeOf, localeShape, readDraft } from "./shared.mjs";
3
+ import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
4
+ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
5
+ import { withTransaction } from "../write/transaction.mjs";
6
+ import { z } from "zod";
7
+ import { Pointer } from "rfc6902";
8
+ //#region src/tools/patch-document.ts
9
+ const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
10
+
11
+ The write always lands as a draft and is never published, whatever it contains; publishing stays a human action in the admin panel.
12
+
13
+ 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. Use describeSchema to find a field's path, then turn it into a pointer by replacing "." with "/" and each "[]" with a 0-based index.
14
+
15
+ 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 a concurrent edit is refused rather than overwritten.
16
+
17
+ 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 a human cannot publish the document until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update.`;
18
+ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
19
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
20
+ /**
21
+ * Whether the intended value survived the write. The saved document is
22
+ * allowed to carry more than was sent: Payload assigns fresh row ids and
23
+ * backfills defaults and nulls on save, so `id` keys are ignored and only
24
+ * the keys the client sent are compared. Null and absent count as equal.
25
+ */ const survives = (expected, actual) => {
26
+ if (expected === void 0 || expected === null) return actual === void 0 || actual === null;
27
+ if (Array.isArray(expected)) return Array.isArray(actual) && expected.length === actual.length && expected.every((entry, index) => survives(entry, actual[index]));
28
+ if (isPlainObject(expected)) return isPlainObject(actual) && Object.entries(expected).every(([key, value]) => key === "id" || survives(value, actual[key]));
29
+ return isPlainObject(actual) || Array.isArray(actual) ? false : JSON.stringify(expected) === JSON.stringify(actual);
30
+ };
31
+ /**
32
+ * Pointers whose intended value did not survive the write. Element pointers
33
+ * are skipped: an append pointer (`/-`) does not resolve against the saved
34
+ * document.
35
+ */ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operation) => {
36
+ if (operation.op !== "add" && operation.op !== "replace" || isElementPointer(operation.path)) return [];
37
+ const pointer = Pointer.fromJSON(operation.path);
38
+ const expected = pointer.get(intended);
39
+ const actual = pointer.get(saved);
40
+ return survives(expected, actual) ? [] : [operation.path];
41
+ });
42
+ const patchDocument = {
43
+ name: "patchDocument",
44
+ description: DESCRIPTION,
45
+ annotations: {
46
+ readOnlyHint: false,
47
+ destructiveHint: false,
48
+ idempotentHint: false,
49
+ openWorldHint: false
50
+ },
51
+ isEnabled: (scope) => scope.writable.length > 0,
52
+ inputSchema: (scope) => ({
53
+ collection: collectionEnum(scope.writable).describe("Collection holding the document."),
54
+ id: idSchema,
55
+ ...localeShape(scope, {
56
+ required: true,
57
+ description: "Locale the patch applies to. Localized fields write here only."
58
+ }),
59
+ patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
60
+ expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
61
+ }),
62
+ handler: async (args, scope) => {
63
+ const collection = ensureAllowed(scope, args.collection, "write");
64
+ const { payload } = scope.req;
65
+ const locale = localeOf(scope, args.locale);
66
+ return await withTransaction(scope.req, async () => {
67
+ const doc = await readDraft(scope, {
68
+ collection: args.collection,
69
+ id: args.id,
70
+ locale
71
+ });
72
+ 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"] });
73
+ const problems = findPatchProblems(payload.config, {
74
+ collection: args.collection,
75
+ doc,
76
+ patches: args.patches
77
+ });
78
+ if (problems.length > 0) return errorResult("No operation was applied.", { problems });
79
+ const applied = applyPatchToCopy(doc, args.patches);
80
+ if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
81
+ await payload.update({
82
+ collection: args.collection,
83
+ id: args.id,
84
+ data: buildWriteData(payload.config, collection, applied.next),
85
+ depth: 0,
86
+ draft: true,
87
+ overrideAccess: false,
88
+ req: scope.req,
89
+ ...locale === void 0 ? {} : { locale }
90
+ });
91
+ const saved = await readDraft(scope, {
92
+ collection: args.collection,
93
+ id: args.id,
94
+ locale,
95
+ privileged: true
96
+ });
97
+ const notApplied = notAppliedPointers(args.patches, applied.next, saved);
98
+ const publishBlockers = await collectPublishBlockers(scope.req, {
99
+ collection,
100
+ doc: saved
101
+ });
102
+ return jsonResult({
103
+ id: saved["id"],
104
+ status: saved["_status"],
105
+ updatedAt: saved["updatedAt"],
106
+ ...publishBlockers.length > 0 ? { publishBlockers } : {},
107
+ ...notApplied.length > 0 ? { notApplied } : {}
108
+ });
109
+ });
110
+ }
111
+ };
112
+ //#endregion
113
+ export { patchDocument };
@@ -0,0 +1,65 @@
1
+ import { Forbidden, NotFound } from "payload";
2
+ import { z } from "zod";
3
+ //#region src/tools/shared.ts
4
+ const collectionEnum = (slugs) => z.enum(slugs);
5
+ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
6
+ /**
7
+ * The `locale` argument, present only when localization is configured.
8
+ */ const localeShape = (scope, options) => {
9
+ if (!scope.locales) return {};
10
+ const locale = z.enum(scope.locales);
11
+ return { locale: (options.required ? locale : locale.optional()).describe(options.description) };
12
+ };
13
+ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.options.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.options.limits.maxDepth)}.`) });
14
+ /**
15
+ * Throws unless the key may perform `operation` on `slug`. The input schema
16
+ * already limits the enum, so this only guards against a stale tool list.
17
+ */ const ensureAllowed = (scope, slug, operation) => {
18
+ const allowed = operation === "read" ? scope.readable : scope.writable;
19
+ const collection = scope.req.payload.collections[slug];
20
+ if (!allowed.includes(slug) || !collection) throw new Forbidden(scope.req.t);
21
+ return collection.config;
22
+ };
23
+ /**
24
+ * The locale to operate on: the explicit argument, else the request's, else
25
+ * the default. `undefined` when localization is off.
26
+ */ const localeOf = (scope, locale) => {
27
+ if (!scope.locales) return;
28
+ const requested = locale ?? scope.req.locale;
29
+ return (requested && scope.locales.includes(requested) ? requested : scope.defaultLocale) ?? void 0;
30
+ };
31
+ /**
32
+ * Reads the current draft in a fixed locale with no fallback, which is the
33
+ * shape that may be written back or validated without mixing locales.
34
+ */ const readDraft = async (scope, args) => {
35
+ const doc = await scope.req.payload.findByID({
36
+ collection: args.collection,
37
+ id: args.id,
38
+ depth: 0,
39
+ draft: true,
40
+ ...args.locale === void 0 ? {} : {
41
+ locale: args.locale,
42
+ fallbackLocale: false
43
+ },
44
+ overrideAccess: args.privileged === true,
45
+ showHiddenFields: args.privileged === true,
46
+ disableErrors: true,
47
+ req: scope.req
48
+ });
49
+ if (!doc) throw new NotFound(scope.req.t);
50
+ return doc;
51
+ };
52
+ /**
53
+ * Resolves a collection label for the request's language.
54
+ */ const translateLabel = (scope, label, fallback) => {
55
+ const { i18n, t } = scope.req;
56
+ const resolved = typeof label === "function" ? label({
57
+ i18n,
58
+ t
59
+ }) : label;
60
+ if (typeof resolved === "string") return resolved;
61
+ if (resolved && typeof resolved === "object") return resolved[i18n.language] ?? Object.values(resolved)[0] ?? fallback;
62
+ return fallback;
63
+ };
64
+ //#endregion
65
+ export { collectionEnum, depthShape, ensureAllowed, idSchema, localeOf, localeShape, readDraft, translateLabel };
@@ -0,0 +1,48 @@
1
+ import { jsonResult } from "../endpoint/result.mjs";
2
+ import { collectionEnum, ensureAllowed, idSchema, localeOf, localeShape, readDraft } from "./shared.mjs";
3
+ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
4
+ //#region src/tools/validate-document.ts
5
+ const validateDocument = {
6
+ name: "validateDocument",
7
+ 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".`,
8
+ annotations: {
9
+ readOnlyHint: true,
10
+ openWorldHint: false
11
+ },
12
+ isEnabled: (scope) => scope.writable.length > 0,
13
+ inputSchema: (scope) => ({
14
+ collection: collectionEnum(scope.writable).describe("Collection holding the document."),
15
+ id: idSchema,
16
+ ...localeShape(scope, {
17
+ required: true,
18
+ description: "Locale to validate."
19
+ })
20
+ }),
21
+ handler: async (args, scope) => {
22
+ const collection = ensureAllowed(scope, args.collection, "write");
23
+ const locale = localeOf(scope, args.locale);
24
+ await readDraft(scope, {
25
+ collection: args.collection,
26
+ id: args.id,
27
+ locale
28
+ });
29
+ const doc = await readDraft(scope, {
30
+ collection: args.collection,
31
+ id: args.id,
32
+ locale,
33
+ privileged: true
34
+ });
35
+ const publishBlockers = await collectPublishBlockers(scope.req, {
36
+ collection,
37
+ doc
38
+ });
39
+ return jsonResult({
40
+ id: doc["id"],
41
+ status: doc["_status"],
42
+ updatedAt: doc["updatedAt"],
43
+ publishBlockers
44
+ });
45
+ }
46
+ };
47
+ //#endregion
48
+ export { validateDocument };
@@ -0,0 +1,113 @@
1
+ import { CollectionConfig, CollectionSlug, PayloadRequest, TypedUser } from "payload";
2
+ import { z } from "zod";
3
+ import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
4
+ import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
5
+ //#region src/types.d.ts
6
+ declare module "payload" {
7
+ interface RequestContext {
8
+ mcpx?: McpxRequestContext;
9
+ }
10
+ interface RegisteredPlugins {
11
+ "@abinnovision/payloadcms-mcpx": McpxPluginOptions;
12
+ }
13
+ }
14
+ /**
15
+ * What an exposed collection offers to MCP clients. A key can only enable
16
+ * what the config exposes here.
17
+ */
18
+ interface McpxCollectionOptions {
19
+ /** Expose `describeSchema`, `findDocuments` and `getDocument`. Default `true`. */
20
+ read?: boolean;
21
+ /**
22
+ * Expose `patchDocument`, `createDocument` and `validateDocument`. Default
23
+ * `false`. Requires `versions.drafts` unless `allowLiveWrites` is set.
24
+ */
25
+ write?: boolean;
26
+ /**
27
+ * Permit writes to a collection without drafts. Such writes land on the live
28
+ * document because there is no draft to land on. Default `false`.
29
+ */
30
+ allowLiveWrites?: boolean;
31
+ }
32
+ type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
33
+ /**
34
+ * A custom tool. It is gated by its own checkbox on every API key and runs
35
+ * with `req.user` resolved from the key and `req.context.mcpx` set.
36
+ */
37
+ interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape> {
38
+ /** camelCase, unique, not one of the builtin tool names. */
39
+ name: string;
40
+ description: string;
41
+ inputSchema?: Shape;
42
+ annotations?: ToolAnnotations;
43
+ handler(ctx: {
44
+ args: z.infer<z.ZodObject<Shape>>;
45
+ req: PayloadRequest;
46
+ extra: McpxToolExtra;
47
+ }): CallToolResult | Promise<CallToolResult>;
48
+ }
49
+ /**
50
+ * Identity helper that infers the argument type of a custom tool's handler
51
+ * from its input schema.
52
+ */
53
+ declare const defineMcpxTool: <Shape extends z.ZodRawShape>(tool: McpxTool<Shape>) => McpxTool<Shape>;
54
+ /**
55
+ * Outcome of resolving an API key. `user` must carry `collection`.
56
+ */
57
+ interface McpxAuthResult {
58
+ user: TypedUser;
59
+ apiKeyId: number | string;
60
+ /** The `capabilities` group as stored on the key document. */
61
+ capabilities: unknown;
62
+ }
63
+ type McpxPluginOptions = {
64
+ /** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
65
+ collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
66
+ /** Collection the keys act as. Default `config.admin.user`, then `users`. */
67
+ userCollection?: CollectionSlug;
68
+ apiKeys?: {
69
+ /** Slug of the generated API key collection. Default `mcpx-api-keys`. */
70
+ slug?: string;
71
+ /** Final override applied to the generated collection. */
72
+ overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
73
+ };
74
+ endpoint?: {
75
+ /** Endpoint path below the API route. Default `/mcpx`. */
76
+ path?: string;
77
+ };
78
+ limits?: {
79
+ /** Upper bound for `findDocuments.limit`. Default 25. */
80
+ maxLimit?: number;
81
+ /** Upper bound for `depth` on reads. Default 1. */
82
+ maxDepth?: number;
83
+ };
84
+ tools?: McpxTool[];
85
+ auth?: {
86
+ /** Replace or wrap the default key resolution. Return `null` for 401. */
87
+ resolve?: (args: {
88
+ req: PayloadRequest;
89
+ resolveDefault: () => Promise<McpxAuthResult | null>;
90
+ }) => Promise<McpxAuthResult | null>;
91
+ };
92
+ serverInfo?: {
93
+ name?: string;
94
+ version?: string;
95
+ };
96
+ };
97
+ interface McpxCollectionCapabilities {
98
+ read: boolean;
99
+ write: boolean;
100
+ }
101
+ /**
102
+ * Capabilities in force for one request: plugin config AND key checkboxes.
103
+ */
104
+ interface McpxResolvedCapabilities {
105
+ collections: Record<string, McpxCollectionCapabilities>;
106
+ tools: Record<string, boolean>;
107
+ }
108
+ interface McpxRequestContext {
109
+ apiKeyId: number | string;
110
+ capabilities: McpxResolvedCapabilities;
111
+ }
112
+ //#endregion
113
+ export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
package/dist/types.mjs ADDED
@@ -0,0 +1,7 @@
1
+ //#region src/types.ts
2
+ /**
3
+ * Identity helper that infers the argument type of a custom tool's handler
4
+ * from its input schema.
5
+ */ const defineMcpxTool = (tool) => tool;
6
+ //#endregion
7
+ export { defineMcpxTool };
@@ -0,0 +1,6 @@
1
+ //#region src/version.ts
2
+ /**
3
+ * Package version injected at build time; sources under Vitest report "dev".
4
+ */ const MCPX_VERSION = "1.0.0-beta.3";
5
+ //#endregion
6
+ export { MCPX_VERSION };
@@ -0,0 +1,10 @@
1
+ import { CollectionConfig, PayloadRequest } from "payload";
2
+ //#region src/write/draft-guard.d.ts
3
+ /**
4
+ * Whether a request originated from the MCP endpoint. The endpoint stamps
5
+ * `req.context.mcpx`, which travels into every local API call made with the
6
+ * same `req`, including those made by custom tools.
7
+ */
8
+ declare const isMcpxRequest: (req: PayloadRequest) => boolean;
9
+ //#endregion
10
+ export { isMcpxRequest };
@@ -0,0 +1,70 @@
1
+ import { APIError } from "payload";
2
+ import { hasDraftsEnabled } from "payload/shared";
3
+ //#region src/write/draft-guard.ts
4
+ /**
5
+ * Operation arguments that widen or redirect a write. Cleared on every MCP
6
+ * create and update so a tool cannot smuggle them in.
7
+ */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
8
+ "where",
9
+ "publishAllLocales",
10
+ "publishSpecificLocale",
11
+ "unpublishAllLocales",
12
+ "duplicateFromID",
13
+ "selectedLocales",
14
+ "overwriteExistingFiles"
15
+ ]);
16
+ /**
17
+ * Whether a request originated from the MCP endpoint. The endpoint stamps
18
+ * `req.context.mcpx`, which travels into every local API call made with the
19
+ * same `req`, including those made by custom tools.
20
+ */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
21
+ /**
22
+ * Forces every MCP write into a draft save.
23
+ *
24
+ * `draft` alone is not enough: Payload's update path only saves a draft when
25
+ * `data._status !== "published"`, so `_status` is dropped and left to Payload.
26
+ * This runs as `beforeOperation`, before Payload reads any of these arguments,
27
+ * so it holds for every create and update on an MCP request, not only the
28
+ * builtin tools. Deletes are not guarded in v1; custom tools that delete are
29
+ * the integrator's responsibility.
30
+ */ const forceDraftWrite = (hookArgs) => {
31
+ const { args, operation, req } = hookArgs;
32
+ if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
33
+ const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
34
+ if (next["data"] && typeof next["data"] === "object") {
35
+ const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
36
+ next["data"] = data;
37
+ }
38
+ next["draft"] = true;
39
+ next["autosave"] = false;
40
+ next["overrideLock"] = false;
41
+ next["trash"] = false;
42
+ return next;
43
+ };
44
+ /**
45
+ * Refuses an MCP write that would still not land as a draft. An alarm rather
46
+ * than the guarantee: `forceDraftWrite` should make it unreachable. It throws
47
+ * instead of correcting `_status` because Payload has already chosen the write
48
+ * branch by the time a `beforeChange` hook runs.
49
+ */ const refusePublish = ({ collection, data, req }) => {
50
+ if (!isMcpxRequest(req)) return data;
51
+ const status = data._status;
52
+ if (status === "draft") return data;
53
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${collection.slug} that would not have been a draft (_status: ${String(status)}).`);
54
+ throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one.", 403);
55
+ };
56
+ /**
57
+ * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
58
+ * (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
59
+ * exist. Applied to the built collection list so nothing can join later
60
+ * without being covered.
61
+ */ const installDraftGuards = (collections) => collections.map((collection) => ({
62
+ ...collection,
63
+ hooks: {
64
+ ...collection.hooks,
65
+ beforeOperation: [...collection.hooks?.beforeOperation ?? [], forceDraftWrite],
66
+ ...hasDraftsEnabled(collection) ? { beforeChange: [...collection.hooks?.beforeChange ?? [], refusePublish] } : {}
67
+ }
68
+ }));
69
+ //#endregion
70
+ export { forceDraftWrite, installDraftGuards, isMcpxRequest, refusePublish };
@@ -0,0 +1,220 @@
1
+ import { RESERVED_FIELD_NAMES, blockOf, describeFields, findBlocksField, splitPath } from "../schema/walk.mjs";
2
+ import { validateWriteValue } from "../schema/shape.mjs";
3
+ import { resolveDataPointer } from "../schema/pointer.mjs";
4
+ import { z } from "zod";
5
+ import { Pointer, applyPatch } from "rfc6902";
6
+ //#region src/write/patch.ts
7
+ /**
8
+ * One RFC 6902 operation as accepted by `patchDocument`.
9
+ */ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
10
+ const PATCH_OPERATION_SCHEMA = z.object({
11
+ from: z.string().regex(JSON_POINTER_PATTERN).optional(),
12
+ op: z.enum([
13
+ "add",
14
+ "copy",
15
+ "move",
16
+ "remove",
17
+ "replace",
18
+ "test"
19
+ ]),
20
+ path: z.string().regex(JSON_POINTER_PATTERN),
21
+ value: z.unknown().optional()
22
+ }).describe("An RFC 6902 operation.");
23
+ /**
24
+ * Whether a pointer touches a field Payload maintains.
25
+ */ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
26
+ /**
27
+ * The pointer an operation removes a value from, if it removes one at all.
28
+ */ const droppedPointer = (operation) => {
29
+ if (operation.op === "remove") return operation.path;
30
+ return operation.op === "move" ? operation.from : void 0;
31
+ };
32
+ /**
33
+ * Whether a pointer addresses a list element rather than a field.
34
+ */ const isElementPointer = (pointer) => {
35
+ const last = pointer.split("/").pop() ?? "";
36
+ return last === "-" || /^\d+$/.test(last);
37
+ };
38
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
39
+ /**
40
+ * A Lexical editor state. Its nodes manage their own ids, so it is never
41
+ * descended into.
42
+ */ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
43
+ /**
44
+ * Visits every row in a value: a plain object that carries `blockType` or
45
+ * sits directly inside an array. Rich text states manage their own nodes and
46
+ * are never descended into.
47
+ */ const walkRows = (value, visit, isRow = false) => {
48
+ if (Array.isArray(value)) {
49
+ for (const entry of value) walkRows(entry, visit, true);
50
+ return;
51
+ }
52
+ if (!isPlainObject(value) || isRichTextState(value)) return;
53
+ if (isRow || typeof value["blockType"] === "string") visit(value);
54
+ for (const entry of Object.values(value)) walkRows(entry, visit);
55
+ };
56
+ /**
57
+ * Keeps a row id only when the stored document already has it and no earlier
58
+ * row in the write claimed it; every other id is dropped so Payload assigns a
59
+ * fresh one.
60
+ *
61
+ * A kept id makes Payload update the row in place, which preserves the other
62
+ * locales of any localized field inside it. A duplicated id (a copied row) or
63
+ * an id from elsewhere would violate a SQL primary key, so those never pass.
64
+ */ const reconcileRowIds = (next, stored) => {
65
+ const known = /* @__PURE__ */ new Set();
66
+ walkRows(stored, (row) => {
67
+ if (row["id"] !== void 0) known.add(row["id"]);
68
+ });
69
+ const seen = /* @__PURE__ */ new Set();
70
+ walkRows(next, (row) => {
71
+ const id = row["id"];
72
+ if (id === void 0) return;
73
+ if (known.has(id) && !seen.has(id)) {
74
+ seen.add(id);
75
+ return;
76
+ }
77
+ delete row["id"];
78
+ });
79
+ };
80
+ /**
81
+ * Drops every row id from a copy of `value`. Used on create, where no stored
82
+ * row exists and any incoming id is client-invented.
83
+ */ const stripRowIds = (value) => {
84
+ const next = structuredClone(value);
85
+ walkRows(next, (row) => {
86
+ delete row["id"];
87
+ });
88
+ return next;
89
+ };
90
+ /**
91
+ * Applies a patch to a deep copy of the document, so a failing operation
92
+ * leaves the original untouched and nothing partial is ever written.
93
+ */ const applyPatchToCopy = (doc, patches) => {
94
+ const next = structuredClone(doc);
95
+ const prepared = patches.map((operation) => {
96
+ const cloned = "value" in operation ? {
97
+ ...operation,
98
+ value: structuredClone(operation.value)
99
+ } : operation;
100
+ if (cloned.op === "replace" && !isElementPointer(cloned.path) && Pointer.fromJSON(cloned.path).get(next) === void 0) return {
101
+ ...cloned,
102
+ op: "add"
103
+ };
104
+ return cloned;
105
+ });
106
+ const problems = applyPatch(next, prepared).flatMap((error, index) => error ? [`patches[${String(index)}]: ${error.message}`] : []);
107
+ if (problems.length > 0) return { problems };
108
+ reconcileRowIds(next, doc);
109
+ return { next };
110
+ };
111
+ /**
112
+ * Checks every operation against the schema before any is applied.
113
+ *
114
+ * A partially applied batch is worse than a refused one, so this returns all
115
+ * problems and the caller applies nothing unless the list is empty.
116
+ */ const findPatchProblems = (config, target) => target.patches.flatMap((operation, index) => {
117
+ const at = `patches[${String(index)}]`;
118
+ const pointers = [operation.path, ..."from" in operation && operation.from ? [operation.from] : []];
119
+ if (pointers.includes("")) return [`${at}: an empty pointer addresses the whole document. Address a field instead.`];
120
+ const reserved = pointers.find(isReservedPointer);
121
+ if (reserved !== void 0) return [`${at}: "${reserved}" addresses a field Payload maintains. Drafts are the only thing this tool writes, and id, _status, createdAt and updatedAt are not writable.`];
122
+ const dropped = droppedPointer(operation);
123
+ if (dropped !== void 0 && !isElementPointer(dropped)) return [`${at}: "${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
124
+ const value = "value" in operation ? operation.value : void 0;
125
+ try {
126
+ const moved = "from" in operation && operation.from ? Pointer.fromJSON(operation.from).get(target.doc) : void 0;
127
+ for (const pointer of pointers) {
128
+ const resolution = resolveDataPointer(config, {
129
+ addedValue: value ?? moved,
130
+ collection: target.collection,
131
+ doc: target.doc,
132
+ pointer
133
+ });
134
+ if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
135
+ pointer,
136
+ resolution
137
+ }, value).map((problem) => `${at}: ${problem}`);
138
+ }
139
+ return [];
140
+ } catch (error) {
141
+ return [`${at}: ${error instanceof Error ? error.message : "invalid"}`];
142
+ }
143
+ });
144
+ /**
145
+ * Keys Payload manages on a row that travel back into the write unchanged.
146
+ */ const ROW_KEYS = /* @__PURE__ */ new Set([
147
+ "blockName",
148
+ "blockType",
149
+ "id"
150
+ ]);
151
+ /**
152
+ * Picks the keys the schema walker describes out of `value`, descending into
153
+ * groups, named tabs, arrays and blocks. Everything Payload maintains or
154
+ * derives (`_status`, timestamps, join and virtual fields, upload base fields)
155
+ * is left out, so the write-back carries only what a client could have set.
156
+ */ const pickDescribed = (config, value, at) => {
157
+ const { fields, prefix, isRow } = at;
158
+ const relative = describeFields(fields).flatMap((descriptor) => {
159
+ const parts = splitPath(descriptor.path);
160
+ return prefix.every((part, offset) => part === parts[offset]) ? [{
161
+ descriptor,
162
+ parts: parts.slice(prefix.length)
163
+ }] : [];
164
+ });
165
+ const result = {};
166
+ if (isRow) {
167
+ for (const key of ROW_KEYS) if (key in value) result[key] = value[key];
168
+ }
169
+ for (const [key, entry] of Object.entries(value)) {
170
+ const candidates = relative.filter(({ parts }) => parts[0] === key);
171
+ if (candidates.length === 0) continue;
172
+ const exact = candidates.find(({ parts }) => parts.length === 1);
173
+ if (exact?.descriptor.type === "blocks" && Array.isArray(entry)) {
174
+ const field = findBlocksField(fields, splitPath(exact.descriptor.path));
175
+ result[key] = entry.map((row) => {
176
+ const block = field && isPlainObject(row) && typeof row["blockType"] === "string" ? blockOf(config, field, row["blockType"]) : void 0;
177
+ return block && isPlainObject(row) ? pickDescribed(config, row, {
178
+ fields: block.flattenedFields,
179
+ prefix: [],
180
+ isRow: true
181
+ }) : row;
182
+ });
183
+ continue;
184
+ }
185
+ if (exact) {
186
+ result[key] = entry;
187
+ continue;
188
+ }
189
+ if (candidates.some(({ parts }) => parts[1] === "[]")) {
190
+ result[key] = Array.isArray(entry) ? entry.map((row) => isPlainObject(row) ? pickDescribed(config, row, {
191
+ fields,
192
+ prefix: [
193
+ ...prefix,
194
+ key,
195
+ "[]"
196
+ ],
197
+ isRow: true
198
+ }) : row) : entry;
199
+ continue;
200
+ }
201
+ result[key] = isPlainObject(entry) ? pickDescribed(config, entry, {
202
+ fields,
203
+ prefix: [...prefix, key],
204
+ isRow: false
205
+ }) : entry;
206
+ }
207
+ return result;
208
+ };
209
+ /**
210
+ * The data handed to `payload.update` after a patch: the patched document
211
+ * reduced to the fields the client may write, plus row identity keys.
212
+ */ const buildWriteData = (config, collection, doc) => {
213
+ return pickDescribed(config, doc, {
214
+ fields: collection.flattenedFields,
215
+ prefix: [],
216
+ isRow: false
217
+ });
218
+ };
219
+ //#endregion
220
+ export { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, droppedPointer, findPatchProblems, isElementPointer, isReservedPointer, stripRowIds };
@@ -0,0 +1,13 @@
1
+ import { PayloadRequest } from "payload";
2
+ //#region src/write/publish-blockers.d.ts
3
+ /**
4
+ * One reason a human could not publish the draft as it stands.
5
+ */
6
+ interface PublishBlocker {
7
+ /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
8
+ field?: string;
9
+ message: string;
10
+ path: string;
11
+ }
12
+ //#endregion
13
+ export type { PublishBlocker };