@abinnovision/payloadcms-mcpx 1.0.0-beta.4 → 1.0.0-beta.6

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.
@@ -1,4 +1,4 @@
1
- import { blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, splitPath } from "./walk.mjs";
1
+ import { blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
2
2
  //#region src/schema/pointer.ts
3
3
  const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
4
4
  const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isIndexSegment(segment) : part === segment);
@@ -32,7 +32,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
32
32
  * can only be resolved by reading `blockType` off `sections[3]`, since a blocks
33
33
  * field admits many shapes at the same index.
34
34
  */ const resolveDataPointer = (config, target) => {
35
- let fields = collectionOf(config, target.collection).flattenedFields;
35
+ let fields = targetOf(config, target.ref).flattenedFields;
36
36
  let data = target.doc;
37
37
  let blockType;
38
38
  let segments = splitPath(target.pointer);
@@ -1,4 +1,5 @@
1
- import { blockOf, blockSlugsOf, describeFields, findBlocksField, splitPath } from "./walk.mjs";
1
+ import { lexicalSubSchema } from "./lexical.mjs";
2
+ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
2
3
  //#region src/schema/shape.ts
3
4
  /**
4
5
  * Keys Payload manages on a row that a client may echo back harmlessly.
@@ -9,30 +10,79 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, splitPath } fro
9
10
  ]);
10
11
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
11
12
  /**
12
- * Checks every node type in an editor state against what the field's editor
13
- * can actually produce.
13
+ * Checks the fields a single Lexical node carries against the schema its
14
+ * feature declares for that node type.
15
+ *
16
+ * A node with nothing to declare, and one whose sub-fields cannot be named at
17
+ * a position, are both left alone.
18
+ */ const checkNodeFields = (scope, field, node) => {
19
+ const sub = lexicalSubSchema(field, node.type);
20
+ if (!sub) return;
21
+ const data = node.fields;
22
+ if (!isPlainObject(data)) {
23
+ scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
24
+ return;
25
+ }
26
+ const nested = {
27
+ ...scope,
28
+ pointer: `${scope.pointer}/fields`,
29
+ prefix: []
30
+ };
31
+ if (sub.kind === "fields") {
32
+ checkValue({
33
+ ...nested,
34
+ fields: sub.fields
35
+ }, data);
36
+ return;
37
+ }
38
+ const slug = data["blockType"];
39
+ const block = typeof slug === "string" ? blockOf(scope.config, sub.blocksField, slug) : void 0;
40
+ if (!block) {
41
+ scope.problems.push(`${scope.pointer}/fields: "${String(slug)}" is not allowed here. Allowed: ${blockSlugsOf(sub.blocksField).join(", ")}`);
42
+ return;
43
+ }
44
+ checkValue({
45
+ ...nested,
46
+ fields: block.flattenedFields
47
+ }, data);
48
+ };
49
+ /**
50
+ * Checks an editor state against what the field's editor can actually
51
+ * produce: every node type, and the fields each node carries.
14
52
  *
15
53
  * Payload does not: the Lexical validator runs node validations only for the
16
54
  * few node types that register one, so a `heading` inside a field whose
17
55
  * editor has no heading feature is stored without complaint and only fails
18
- * later, at render or when the document is reopened in the admin editor.
19
- */ const checkRichText = (scope, allowed, value) => {
56
+ * later, at render or when the document is reopened in the admin editor. A key
57
+ * a node's fields do not declare is dropped just as silently.
58
+ */ const checkRichText = (scope, editor, value) => {
20
59
  if (!isPlainObject(value) || !isPlainObject(value["root"])) {
21
60
  scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
22
61
  return;
23
62
  }
24
- const walk = (nodes) => {
63
+ const walk = (nodes, pointer) => {
25
64
  if (!Array.isArray(nodes)) return;
26
- for (const node of nodes) {
65
+ nodes.forEach((node, index) => {
66
+ const at = `${pointer}/${String(index)}`;
27
67
  if (!isPlainObject(node) || typeof node["type"] !== "string") {
28
- scope.problems.push(`${scope.pointer}: every node needs a "type".`);
29
- continue;
68
+ scope.problems.push(`${at}: every node needs a "type".`);
69
+ return;
30
70
  }
31
- if (!allowed.includes(node["type"])) scope.problems.push(`${scope.pointer}: "${node["type"]}" is not available in this field's editor. Allowed: ${allowed.join(", ")}`);
32
- walk(node["children"]);
33
- }
71
+ if (!editor.allowed.includes(node["type"])) {
72
+ scope.problems.push(`${at}: "${node["type"]}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
73
+ return;
74
+ }
75
+ if (editor.field) checkNodeFields({
76
+ ...scope,
77
+ pointer: at
78
+ }, editor.field, {
79
+ fields: node["fields"],
80
+ type: node["type"]
81
+ });
82
+ walk(node["children"], `${at}/children`);
83
+ });
34
84
  };
35
- walk(value["root"]["children"]);
85
+ walk(value["root"]["children"], `${scope.pointer}/root/children`);
36
86
  };
37
87
  const checkLeafValue = (scope, descriptor, value) => {
38
88
  if (descriptor.readOnly) {
@@ -40,7 +90,10 @@ const checkLeafValue = (scope, descriptor, value) => {
40
90
  return;
41
91
  }
42
92
  if (descriptor.type === "richText") {
43
- checkRichText(scope, descriptor.nodes ?? [], value);
93
+ checkRichText(scope, {
94
+ allowed: descriptor.nodes ?? [],
95
+ field: findRichTextField(scope.fields, splitPath(descriptor.path))
96
+ }, value);
44
97
  return;
45
98
  }
46
99
  if (descriptor.type !== "blocks") return;
@@ -0,0 +1 @@
1
+ import "payload";
@@ -109,10 +109,21 @@ const withRows = (descriptor, field) => ({
109
109
  if (field.type === "array" && path[1] === "*") return findBlocksField(field.flattenedFields, path.slice(2));
110
110
  }
111
111
  };
112
- const collectionOf = (config, collection) => {
113
- const found = config.collections.find((candidate) => candidate.slug === collection);
114
- if (!found) throw new Error(`Unknown collection "${collection}".`);
112
+ /**
113
+ * Locates the rich text field that a resolved descriptor path refers to, so
114
+ * its editor can be introspected for the fields its nodes carry.
115
+ */ const findRichTextField = (fields, path) => {
116
+ for (const field of fields) {
117
+ if (!("name" in field) || field.name !== path[0]) continue;
118
+ if (field.type === "richText" && path.length === 1) return field;
119
+ if (field.type === "tab" || field.type === "group") return findRichTextField(field.flattenedFields, path.slice(1));
120
+ if (field.type === "array" && path[1] === "*") return findRichTextField(field.flattenedFields, path.slice(2));
121
+ }
122
+ };
123
+ const targetOf = (config, ref) => {
124
+ const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
125
+ if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
115
126
  return found;
116
127
  };
117
128
  //#endregion
118
- export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, pointerFromPayloadPath, splitPath, staticDescription };
129
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
@@ -1,5 +1,6 @@
1
1
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, ensureAllowed, localeOf, localeShape, readDraft } from "./shared.mjs";
2
+ import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
3
+ import { resolveTarget } from "./target.mjs";
3
4
  import { validateWriteValue } from "../schema/shape.mjs";
4
5
  import { stripRowIds } from "../write/patch.mjs";
5
6
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
@@ -16,7 +17,7 @@ const createDocument = {
16
17
  },
17
18
  isEnabled: (scope) => scope.writable.length > 0,
18
19
  inputSchema: (scope) => ({
19
- collection: collectionEnum(scope.writable).describe("Collection to create the document in."),
20
+ collection: slugEnum(scope.writable).describe("Collection to create the document in."),
20
21
  ...localeShape(scope, {
21
22
  required: true,
22
23
  description: "Locale the localized fields of the seed belong to."
@@ -24,14 +25,14 @@ const createDocument = {
24
25
  data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
25
26
  }),
26
27
  handler: async (args, scope) => {
27
- const collection = ensureAllowed(scope, args.collection, "write");
28
+ const target = resolveTarget(scope, { collection: args.collection }, "write");
28
29
  const { payload } = scope.req;
29
30
  const locale = localeOf(scope, args.locale);
30
31
  const { id: _ignored, ...seed } = args.data;
31
32
  const problems = validateWriteValue(payload.config, {
32
33
  pointer: "",
33
34
  resolution: {
34
- fields: collection.flattenedFields,
35
+ fields: target.config.flattenedFields,
35
36
  prefix: []
36
37
  }
37
38
  }, seed);
@@ -45,15 +46,15 @@ const createDocument = {
45
46
  req: scope.req,
46
47
  ...locale === void 0 ? {} : { locale }
47
48
  });
48
- const saved = await readDraft(scope, {
49
- collection: args.collection,
49
+ const saved = await readTarget(scope, {
50
+ target,
50
51
  id: created["id"],
51
52
  locale,
52
53
  privileged: true
53
54
  });
54
55
  const publishBlockers = await collectPublishBlockers(scope.req, {
55
- collection,
56
- doc: saved
56
+ doc: saved,
57
+ entity: target
57
58
  });
58
59
  return jsonResult({
59
60
  id: saved["id"],
@@ -1,5 +1,6 @@
1
1
  import { jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, ensureAllowed } from "./shared.mjs";
2
+ import { targetShape } from "./shared.mjs";
3
+ import { refOf, resolveTarget } from "./target.mjs";
3
4
  import { describeNode, reachableSchemaPaths } from "../schema/describe.mjs";
4
5
  import { z } from "zod";
5
6
  //#region src/tools/describe-schema.ts
@@ -7,8 +8,12 @@ const describeSchema = {
7
8
  name: "describeSchema",
8
9
  description: `Describes the writable shape of a document, one node at a time.
9
10
 
11
+ Pass exactly one of "collection" and "global". A global is a singleton: it has no id, is not listed by findDocuments and cannot be created.
12
+
10
13
  Call it with no "paths" to get a collection's own fields. Every "blocks" field stops there and lists the block slugs it accepts instead of nesting them; each node's "next" lists the ready-to-use paths for those blocks, so pass any entry of "next" as a "paths" element to descend, e.g. "/layout/sections/sectionWrapper" and then "/layout/sections/sectionWrapper/modules/hero". A block is described as it exists at that position, because the same block can accept different children elsewhere.
11
14
 
15
+ A "richText" field stops there too. It lists the Lexical node types it accepts in "nodes", and "next" carries a path for every node type that holds fields of its own: "/content/link" for a link node, "/content/block/callout" and "/content/inlineBlock/badge" for the block nodes. Descend to get the real field list instead of guessing what a node carries. Upload nodes are not addressable, because their fields depend on the collection the node points at.
16
+
12
17
  Paths here use the same JSON Pointer syntax as getDocument and patchDocument, and are already resolved through anything that does not nest in the stored document. The difference is only what stands in an element position: a path names an array element "*" and a block by its slug, where a pointer into a document carries a 0-based index. So "/items/*/title" is written at "/items/0/title", and "/layout/sections/hero" at "/layout/sections/0".
13
18
 
14
19
  Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
@@ -16,19 +21,22 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
16
21
  readOnlyHint: true,
17
22
  openWorldHint: false
18
23
  },
19
- isEnabled: (scope) => scope.readable.length > 0,
24
+ isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
20
25
  inputSchema: (scope) => ({
21
- collection: collectionEnum(scope.readable).describe("Collection to describe."),
26
+ ...targetShape(scope, "read", {
27
+ collection: "Collection to describe.",
28
+ global: "Global to describe."
29
+ }),
22
30
  paths: z.array(z.string()).optional().describe("Schema paths to describe, e.g. \"/layout/sections/sectionWrapper\". Omit for the collection root."),
23
31
  expand: z.boolean().optional().describe("Return every node reachable from the root in one response. Ignores paths.")
24
32
  }),
25
33
  handler: (args, scope) => {
26
- ensureAllowed(scope, args.collection, "read");
34
+ const ref = refOf(resolveTarget(scope, args, "read"));
27
35
  const { config } = scope.req.payload;
28
- const expanded = args.expand === true ? reachableSchemaPaths(config, args.collection) : void 0;
36
+ const expanded = args.expand === true ? reachableSchemaPaths(config, ref) : void 0;
29
37
  const nodes = (expanded?.paths ?? (args.paths && args.paths.length > 0 ? args.paths : [""])).map((schemaPath) => {
30
38
  try {
31
- return describeNode(config, args.collection, schemaPath);
39
+ return describeNode(config, ref, schemaPath);
32
40
  } catch (error) {
33
41
  return {
34
42
  error: error instanceof Error ? error.message : "Unknown error",
@@ -1,5 +1,6 @@
1
1
  import { jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, depthShape, ensureAllowed, localeOf, localeShape } from "./shared.mjs";
2
+ import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
3
+ import { resolveTarget } from "./target.mjs";
3
4
  import { z } from "zod";
4
5
  //#region src/tools/find-documents.ts
5
6
  const findDocuments = {
@@ -11,7 +12,7 @@ const findDocuments = {
11
12
  },
12
13
  isEnabled: (scope) => scope.readable.length > 0,
13
14
  inputSchema: (scope) => ({
14
- collection: collectionEnum(scope.readable).describe("Collection to search."),
15
+ collection: slugEnum(scope.readable).describe("Collection to search."),
15
16
  where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
16
17
  sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
17
18
  limit: z.number().int().min(1).max(scope.options.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.options.limits.maxLimit)}.`),
@@ -25,7 +26,7 @@ const findDocuments = {
25
26
  draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
26
27
  }),
27
28
  handler: async (args, scope) => {
28
- ensureAllowed(scope, args.collection, "read");
29
+ resolveTarget(scope, { collection: args.collection }, "read");
29
30
  const locale = localeOf(scope, args.locale);
30
31
  const result = await scope.req.payload.find({
31
32
  collection: args.collection,
@@ -1,20 +1,26 @@
1
1
  import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
2
2
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
3
- import { collectionEnum, depthShape, ensureAllowed, idSchema, localeOf, localeShape } from "./shared.mjs";
3
+ import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
4
+ import { requireIdFor, resolveTarget } from "./target.mjs";
4
5
  import { z } from "zod";
5
6
  import { Pointer } from "rfc6902";
6
7
  //#region src/tools/get-document.ts
7
8
  const getDocument = {
8
9
  name: "getDocument",
9
- 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.`,
10
+ 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
+
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.`,
10
13
  annotations: {
11
14
  readOnlyHint: true,
12
15
  openWorldHint: false
13
16
  },
14
- isEnabled: (scope) => scope.readable.length > 0,
17
+ isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
15
18
  inputSchema: (scope) => ({
16
- collection: collectionEnum(scope.readable).describe("Collection holding the document."),
17
- id: idSchema,
19
+ ...targetShape(scope, "read", {
20
+ collection: "Collection holding the document.",
21
+ global: "Global to read."
22
+ }),
23
+ ...idShape(scope, "read"),
18
24
  path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("JSON pointer to return only a subtree, e.g. \"/layout/sections/0\"."),
19
25
  ...depthShape(scope),
20
26
  ...localeShape(scope, {
@@ -24,17 +30,24 @@ const getDocument = {
24
30
  draft: z.boolean().optional().describe("Return the latest draft. Default true.")
25
31
  }),
26
32
  handler: async (args, scope) => {
27
- ensureAllowed(scope, args.collection, "read");
33
+ const target = resolveTarget(scope, args, "read");
34
+ const id = requireIdFor(target, args.id);
28
35
  const locale = localeOf(scope, args.locale);
29
- const doc = await scope.req.payload.findByID({
30
- collection: args.collection,
31
- id: args.id,
36
+ const shared = {
32
37
  depth: args.depth ?? 0,
33
38
  draft: args.draft ?? true,
34
39
  overrideAccess: false,
35
40
  req: scope.req,
36
41
  ...locale === void 0 ? {} : { locale }
37
- });
42
+ };
43
+ const doc = await (target.kind === "collection" ? scope.req.payload.findByID({
44
+ ...shared,
45
+ collection: target.slug,
46
+ id
47
+ }) : scope.req.payload.findGlobal({
48
+ ...shared,
49
+ slug: target.slug
50
+ }));
38
51
  if (args.path === void 0 || args.path === "") return jsonResult(doc);
39
52
  let value;
40
53
  try {
@@ -43,7 +56,7 @@ const getDocument = {
43
56
  return errorResult(`"${args.path}" is not a valid JSON pointer.`);
44
57
  }
45
58
  return jsonResult({
46
- id: doc["id"],
59
+ ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
47
60
  status: doc["_status"],
48
61
  updatedAt: doc["updatedAt"],
49
62
  path: args.path,
@@ -5,7 +5,9 @@ import { hasDraftValidationEnabled } from "payload/shared";
5
5
  //#region src/tools/list-capabilities.ts
6
6
  const listCapabilities = {
7
7
  name: "listCapabilities",
8
- description: `Lists what this key may do: the collections 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.`,
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.
9
+
10
+ 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".`,
9
11
  annotations: {
10
12
  readOnlyHint: true,
11
13
  openWorldHint: false
@@ -34,8 +36,24 @@ const listCapabilities = {
34
36
  idType: collection.customIDType ?? payload.db.defaultIDType
35
37
  }];
36
38
  });
39
+ const globals = scope.options.globals.flatMap((entry) => {
40
+ const capability = scope.capabilities.globals[entry.slug];
41
+ const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
42
+ if (!capability || !config || !(capability.read || capability.write)) return [];
43
+ const description = staticDescription(config.admin.description);
44
+ return [{
45
+ slug: entry.slug,
46
+ label: translateLabel(scope, config.label, entry.slug),
47
+ ...description === void 0 ? {} : { description },
48
+ read: capability.read,
49
+ write: capability.write,
50
+ drafts: entry.hasDrafts,
51
+ draftValidation: hasDraftValidationEnabled(config)
52
+ }];
53
+ });
37
54
  return Promise.resolve(jsonResult({
38
55
  collections,
56
+ ...globals.length > 0 ? { globals } : {},
39
57
  locales: scope.locales ? {
40
58
  codes: scope.locales,
41
59
  default: scope.defaultLocale
@@ -1,5 +1,6 @@
1
1
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, ensureAllowed, idSchema, localeOf, localeShape, readDraft } from "./shared.mjs";
2
+ import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
3
+ import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
3
4
  import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
4
5
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
5
6
  import { withTransaction } from "../write/transaction.mjs";
@@ -8,6 +9,8 @@ import { Pointer } from "rfc6902";
8
9
  //#region src/tools/patch-document.ts
9
10
  const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
10
11
 
12
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
13
+
11
14
  The write always lands as a draft and is never published, whatever it contains; publishing stays a human action in the admin panel.
12
15
 
13
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.
@@ -48,10 +51,13 @@ const patchDocument = {
48
51
  idempotentHint: false,
49
52
  openWorldHint: false
50
53
  },
51
- isEnabled: (scope) => scope.writable.length > 0,
54
+ isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
52
55
  inputSchema: (scope) => ({
53
- collection: collectionEnum(scope.writable).describe("Collection holding the document."),
54
- id: idSchema,
56
+ ...targetShape(scope, "write", {
57
+ collection: "Collection holding the document.",
58
+ global: "Global to patch."
59
+ }),
60
+ ...idShape(scope, "write"),
55
61
  ...localeShape(scope, {
56
62
  required: true,
57
63
  description: "Locale the patch applies to. Localized fields write here only."
@@ -60,47 +66,55 @@ const patchDocument = {
60
66
  expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
61
67
  }),
62
68
  handler: async (args, scope) => {
63
- const collection = ensureAllowed(scope, args.collection, "write");
69
+ const target = resolveTarget(scope, args, "write");
70
+ const id = requireIdFor(target, args.id);
64
71
  const { payload } = scope.req;
65
72
  const locale = localeOf(scope, args.locale);
66
73
  return await withTransaction(scope.req, async () => {
67
- const doc = await readDraft(scope, {
68
- collection: args.collection,
69
- id: args.id,
74
+ const doc = await readTarget(scope, {
75
+ target,
76
+ id,
70
77
  locale
71
78
  });
72
79
  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
80
  const problems = findPatchProblems(payload.config, {
74
- collection: args.collection,
75
81
  doc,
76
- patches: args.patches
82
+ patches: args.patches,
83
+ ref: refOf(target)
77
84
  });
78
85
  if (problems.length > 0) return errorResult("No operation was applied.", { problems });
79
86
  const applied = applyPatchToCopy(doc, args.patches);
80
87
  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),
88
+ const write = {
89
+ data: buildWriteData(payload.config, target.config, applied.next),
85
90
  depth: 0,
86
91
  draft: true,
87
92
  overrideAccess: false,
88
93
  req: scope.req,
89
94
  ...locale === void 0 ? {} : { locale }
95
+ };
96
+ if (target.kind === "collection") await payload.update({
97
+ ...write,
98
+ collection: target.slug,
99
+ id
100
+ });
101
+ else await payload.updateGlobal({
102
+ ...write,
103
+ slug: target.slug
90
104
  });
91
- const saved = await readDraft(scope, {
92
- collection: args.collection,
93
- id: args.id,
105
+ const saved = await readTarget(scope, {
106
+ target,
107
+ id,
94
108
  locale,
95
109
  privileged: true
96
110
  });
97
111
  const notApplied = notAppliedPointers(args.patches, applied.next, saved);
98
112
  const publishBlockers = await collectPublishBlockers(scope.req, {
99
- collection,
100
- doc: saved
113
+ doc: saved,
114
+ entity: target
101
115
  });
102
116
  return jsonResult({
103
- id: saved["id"],
117
+ ...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
104
118
  status: saved["_status"],
105
119
  updatedAt: saved["updatedAt"],
106
120
  ...publishBlockers.length > 0 ? { publishBlockers } : {},
@@ -1,8 +1,39 @@
1
- import { Forbidden, NotFound } from "payload";
1
+ import { NotFound } from "payload";
2
2
  import { z } from "zod";
3
3
  //#region src/tools/shared.ts
4
- const collectionEnum = (slugs) => z.enum(slugs);
4
+ const slugEnum = (slugs) => z.enum(slugs);
5
5
  const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
6
+ const slugsFor = (scope, operation) => ({
7
+ collections: operation === "read" ? scope.readable : scope.writable,
8
+ globals: operation === "read" ? scope.readableGlobals : scope.writableGlobals
9
+ });
10
+ /**
11
+ * The `collection` and `global` arguments.
12
+ *
13
+ * When the key can reach no global, `global` is left out of the shape entirely
14
+ * and `collection` stays required, mirroring how {@link localeShape} omits
15
+ * `locale` when localization is off. A deployment without globals therefore
16
+ * sees exactly the schema it saw before. Only the mixed case makes either
17
+ * argument optional, and the handler enforces the exclusivity there.
18
+ */ const targetShape = (scope, operation, descriptions) => {
19
+ const { collections, globals } = slugsFor(scope, operation);
20
+ if (globals.length === 0) return { collection: slugEnum(collections).describe(descriptions.collection) };
21
+ if (collections.length === 0) return { global: slugEnum(globals).describe(descriptions.global) };
22
+ return {
23
+ collection: slugEnum(collections).optional().describe(descriptions.collection),
24
+ global: slugEnum(globals).optional().describe(descriptions.global)
25
+ };
26
+ };
27
+ /**
28
+ * The `id` argument, which only a collection document has. Omitted when the key
29
+ * can reach no collection, required when it can reach no global, and optional
30
+ * in between, where `requireIdFor` enforces the dependency.
31
+ */ const idShape = (scope, operation) => {
32
+ const { collections, globals } = slugsFor(scope, operation);
33
+ if (collections.length === 0) return {};
34
+ if (globals.length === 0) return { id: idSchema };
35
+ return { id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") };
36
+ };
6
37
  /**
7
38
  * The `locale` argument, present only when localization is configured.
8
39
  */ const localeShape = (scope, options) => {
@@ -12,15 +43,6 @@ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
12
43
  };
13
44
  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
45
  /**
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
46
  * The locale to operate on: the explicit argument, else the request's, else
25
47
  * the default. `undefined` when localization is off.
26
48
  */ const localeOf = (scope, locale) => {
@@ -31,23 +53,34 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
31
53
  /**
32
54
  * Reads the current draft in a fixed locale with no fallback, which is the
33
55
  * 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,
56
+ */ const readTarget = async (scope, args) => {
57
+ const { payload } = scope.req;
58
+ const privileged = args.privileged === true;
59
+ const shared = {
38
60
  depth: 0,
39
61
  draft: true,
40
62
  ...args.locale === void 0 ? {} : {
41
63
  locale: args.locale,
42
64
  fallbackLocale: false
43
65
  },
44
- overrideAccess: args.privileged === true,
45
- showHiddenFields: args.privileged === true,
46
- disableErrors: true,
66
+ overrideAccess: privileged,
67
+ showHiddenFields: privileged,
47
68
  req: scope.req
69
+ };
70
+ if (args.target.kind === "collection") {
71
+ const doc = await payload.findByID({
72
+ ...shared,
73
+ collection: args.target.slug,
74
+ id: args.id,
75
+ disableErrors: true
76
+ });
77
+ if (!doc) throw new NotFound(scope.req.t);
78
+ return doc;
79
+ }
80
+ return await payload.findGlobal({
81
+ ...shared,
82
+ slug: args.target.slug
48
83
  });
49
- if (!doc) throw new NotFound(scope.req.t);
50
- return doc;
51
84
  };
52
85
  /**
53
86
  * Resolves a collection label for the request's language.
@@ -62,4 +95,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
62
95
  return fallback;
63
96
  };
64
97
  //#endregion
65
- export { collectionEnum, depthShape, ensureAllowed, idSchema, localeOf, localeShape, readDraft, translateLabel };
98
+ export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
@@ -0,0 +1,3 @@
1
+ import "./types.mjs";
2
+ import "../schema/walk.mjs";
3
+ import "payload";