@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.
Files changed (71) hide show
  1. package/README.md +140 -448
  2. package/dist/admin/capability-matrix.d.mts +33 -0
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +4 -2
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +12 -8
  8. package/dist/api-keys/capability-matrix.mjs +17 -85
  9. package/dist/api-keys/collection.mjs +2 -1
  10. package/dist/api-keys/fields.mjs +36 -24
  11. package/dist/auth/resolve.mjs +44 -6
  12. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +2 -1
  13. package/dist/capabilities.mjs +41 -43
  14. package/dist/define-tool.d.mts +23 -0
  15. package/dist/{types.mjs → define-tool.mjs} +1 -1
  16. package/dist/endpoint/errors.mjs +5 -6
  17. package/dist/endpoint/handler.mjs +49 -19
  18. package/dist/endpoint/server.mjs +72 -20
  19. package/dist/entity.d.mts +9 -0
  20. package/dist/guards.mjs +7 -0
  21. package/dist/i18n.mjs +10 -8
  22. package/dist/index.d.mts +5 -2
  23. package/dist/index.mjs +3 -2
  24. package/dist/options.mjs +52 -44
  25. package/dist/plugin.mjs +0 -1
  26. package/dist/request.d.mts +2 -3
  27. package/dist/request.mjs +2 -3
  28. package/dist/result.d.mts +2 -3
  29. package/dist/result.mjs +3 -3
  30. package/dist/schema/admin-hidden.mjs +91 -0
  31. package/dist/schema/describe.mjs +17 -33
  32. package/dist/schema/errors.mjs +8 -0
  33. package/dist/schema/index.mjs +5 -2
  34. package/dist/schema/lexical-pointer.mjs +18 -20
  35. package/dist/schema/lexical.mjs +44 -93
  36. package/dist/schema/outline.mjs +5 -15
  37. package/dist/schema/path.mjs +28 -0
  38. package/dist/schema/pointer.mjs +18 -25
  39. package/dist/schema/shape.mjs +25 -71
  40. package/dist/schema/walk.mjs +43 -45
  41. package/dist/tools/builtin.mjs +6 -4
  42. package/dist/tools/create-document.mjs +26 -23
  43. package/dist/tools/describe-schema.mjs +38 -34
  44. package/dist/tools/document.mjs +124 -0
  45. package/dist/tools/find-documents.mjs +32 -19
  46. package/dist/tools/find-versions.mjs +74 -0
  47. package/dist/tools/get-document.mjs +80 -49
  48. package/dist/tools/list-capabilities.mjs +25 -16
  49. package/dist/tools/patch-document.mjs +75 -49
  50. package/dist/tools/publish-document.mjs +26 -32
  51. package/dist/tools/query-paths.mjs +57 -0
  52. package/dist/tools/read-request.d.mts +10 -0
  53. package/dist/tools/read-request.mjs +43 -0
  54. package/dist/tools/shared.mjs +74 -96
  55. package/dist/tools/validate-document.mjs +18 -30
  56. package/dist/tools/versions.mjs +136 -0
  57. package/dist/types.d.mts +119 -73
  58. package/dist/write/draft-guard.mjs +23 -41
  59. package/dist/write/patch.mjs +27 -183
  60. package/dist/write/publish-blockers.mjs +7 -14
  61. package/dist/write/publish-intent.mjs +6 -4
  62. package/dist/write/row-ids.mjs +46 -0
  63. package/dist/write/transaction.mjs +5 -8
  64. package/dist/write/write-data.mjs +64 -0
  65. package/package.json +6 -5
  66. package/dist/client/capability-matrix.d.mts +0 -26
  67. package/dist/client/capability-matrix.mjs +0 -349
  68. package/dist/endpoint/index.mjs +0 -4
  69. package/dist/tools/target.mjs +0 -45
  70. /package/dist/{client → admin}/index.d.mts +0 -0
  71. /package/dist/{client → admin}/index.mjs +0 -0
@@ -0,0 +1,57 @@
1
+ import { isPlainObject } from "../guards.mjs";
2
+ import { addressesAdminHidden } from "../schema/admin-hidden.mjs";
3
+ import "../schema/index.mjs";
4
+ import { APIError, getLocalizedPaths } from "payload";
5
+ //#region src/tools/query-paths.ts
6
+ /**
7
+ * The paths of a `where`, found the way Payload's `validateQueryPaths` finds
8
+ * them: `and` and `or` (any case) hold arrays of clauses, every other key is a
9
+ * path.
10
+ */ const wherePaths = (where) => isPlainObject(where) ? Object.entries(where).flatMap(([key, clause]) => ["and", "or"].includes(key.toLowerCase()) && Array.isArray(clause) ? clause.flatMap(wherePaths) : [key]) : [];
11
+ /**
12
+ * The paths of a `sort`: a field name, `-` for descending, comma separated.
13
+ */ const sortPaths = (sort) => (sort ?? "").split(",").map((entry) => entry.trim().replace(/^-/, "")).filter(Boolean);
14
+ const normalise = (path) => path === "_id" ? "id" : path.replaceAll("__", ".");
15
+ const isRefusedPath = (scope, args, seen = /* @__PURE__ */ new Set()) => {
16
+ const key = `${args.collection}:${args.path}`;
17
+ const { payload } = scope.req;
18
+ if (seen.has(key)) return false;
19
+ seen.add(key);
20
+ const hops = getLocalizedPaths({
21
+ collectionSlug: args.collection,
22
+ fields: payload.collections[args.collection]?.config.flattenedFields ?? [],
23
+ incomingPath: args.path,
24
+ ...args.locale ? { locale: args.locale } : {},
25
+ payload
26
+ });
27
+ return hops.some((hop, index) => {
28
+ const { collectionSlug, field } = hop;
29
+ const via = hops[index - 1]?.field;
30
+ const isBareId = hop.path === "id" && (via?.type === "relationship" || via?.type === "upload" || via?.type === "join");
31
+ if (collectionSlug !== void 0 && addressesAdminHidden(payload.config, collectionSlug, hop.path)) return true;
32
+ if (index > 0 && collectionSlug !== void 0 && !scope.collections.readable.includes(collectionSlug) && !isBareId) return true;
33
+ return !hop.invalid && "virtual" in field && typeof field.virtual === "string" && isRefusedPath(scope, {
34
+ collection: collectionSlug ?? args.collection,
35
+ path: normalise(field.virtual),
36
+ locale: args.locale
37
+ }, seen);
38
+ });
39
+ };
40
+ /**
41
+ * Refuses a `where` or `sort` that traverses a relationship, upload or join
42
+ * field into a collection the key cannot read, since Payload answers it from
43
+ * documents the key may not see, and one that names an `admin.hidden` field,
44
+ * whose value the read tools withhold. Naming the relation field itself, or
45
+ * its id, stays allowed, as does traversal into a readable collection. Unknown
46
+ * paths are left to Payload.
47
+ */ const assertQueryable = (scope, args) => {
48
+ const reaches = (path) => isRefusedPath(scope, {
49
+ collection: args.collection,
50
+ path: normalise(path),
51
+ locale: args.locale ?? void 0
52
+ });
53
+ for (const path of wherePaths(args.where)) if (reaches(path)) throw new APIError(`This key cannot query through "${path}".`, 400);
54
+ for (const path of sortPaths(args.sort)) if (reaches(path) || normalise(path).split(".").some(reaches)) throw new APIError(`This key cannot query through "${path}".`, 400);
55
+ };
56
+ //#endregion
57
+ export { assertQueryable, sortPaths, wherePaths };
@@ -0,0 +1,10 @@
1
+ import { McpxToolScope } from "../types.mjs";
2
+ import { PayloadRequest } from "payload";
3
+ //#region src/tools/read-request.d.ts
4
+ /**
5
+ * A read request that populates only into collections this key may read, at
6
+ * every depth, through a data loader of its own. A refused relation resolves to
7
+ * its id. Each call returns a fresh request.
8
+ */
9
+ export declare const mcpxReadRequest: (scope: McpxToolScope) => PayloadRequest;
10
+ //#endregion
@@ -0,0 +1,43 @@
1
+ import { createDataloaderCacheKey, getDataLoader, isolateObjectProperty } from "payload";
2
+ //#region src/tools/read-request.ts
3
+ const LOADER_KEY = (() => {
4
+ const slug = "\0slug";
5
+ const id = "\0id";
6
+ const parts = JSON.parse(createDataloaderCacheKey({
7
+ collectionSlug: slug,
8
+ currentDepth: 0,
9
+ depth: 0,
10
+ docID: id,
11
+ draft: false,
12
+ fallbackLocale: false,
13
+ locale: "",
14
+ overrideAccess: false,
15
+ showHiddenFields: false,
16
+ transactionID: ""
17
+ }));
18
+ return Array.isArray(parts) ? {
19
+ slug: parts.indexOf(slug),
20
+ id: parts.indexOf(id)
21
+ } : {
22
+ slug: -1,
23
+ id: -1
24
+ };
25
+ })();
26
+ /**
27
+ * A read request that populates only into collections this key may read, at
28
+ * every depth, through a data loader of its own. A refused relation resolves to
29
+ * its id. Each call returns a fresh request.
30
+ */ const mcpxReadRequest = (scope) => {
31
+ const req = isolateObjectProperty(scope.req, "payloadDataLoader");
32
+ const loader = getDataLoader(req);
33
+ const load = loader.load.bind(loader);
34
+ loader.load = (key) => {
35
+ const parts = JSON.parse(key);
36
+ const collection = parts[LOADER_KEY.slug];
37
+ return typeof collection === "string" && scope.collections.readable.includes(collection) ? load(key) : Promise.resolve(parts[LOADER_KEY.id]);
38
+ };
39
+ req.payloadDataLoader = loader;
40
+ return req;
41
+ };
42
+ //#endregion
43
+ export { mcpxReadRequest };
@@ -1,149 +1,127 @@
1
- import { canCreate, canPublish, isLiveWrite } from "../capabilities.mjs";
2
- import { translateStatic } from "../i18n.mjs";
3
- import { NotFound } from "payload";
1
+ import { canCreate, isLiveWrite } from "../capabilities.mjs";
4
2
  import { z } from "zod";
5
3
  //#region src/tools/shared.ts
6
4
  /**
7
5
  * An out-of-scope slug fails schema validation before a handler runs, so a
8
6
  * client only ever sees what its key may touch.
9
7
  */ const slugEnum = (slugs) => z.enum(slugs);
10
- /** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
8
+ /**
9
+ * Payload's id type follows the adapter, so both forms are handed on as read.
10
+ */ const idSchema = z.union([z.string(), z.number()]);
11
+ const slugsOf = (scope, key) => ({
12
+ collections: scope.collections[key],
13
+ globals: scope.globals[key]
14
+ });
11
15
  const slugsWhere = (scope, predicate, allowed) => {
12
16
  const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
13
17
  return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
14
18
  };
15
19
  /**
16
- * Slugs this key may write whose writes land live rather than as a draft. An
17
- * entity without versions has no draft to land on, so `write: "live"` there
18
- * makes every write a live one. Empty for every key that can only write drafts.
19
- */ const liveWriteSlugs = (scope) => slugsWhere(scope, isLiveWrite, {
20
- collections: scope.writable,
21
- globals: scope.writableGlobals
22
- });
23
- /**
24
20
  * Slugs this key may write but never create in, because their documents are
25
21
  * files. Collection-only, since nothing creates a global either way.
26
22
  */ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
27
- collections: scope.writable,
23
+ collections: scope.collections.writable,
28
24
  globals: []
29
25
  });
30
- /** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
31
- collections: scope.publishable,
32
- globals: scope.publishableGlobals
33
- });
26
+ const widen = (branch) => branch;
34
27
  /**
35
- * What a write actually does for this key, and what it takes to make it public.
36
- * A live-write slug has no draft and no publish step; a publishable one has
37
- * both. Stated per key so a client is never told its writes are drafts while
38
- * they are not, nor that publishing is out of reach when it is not.
39
- */ const draftSentence = (scope) => {
40
- const live = liveWriteSlugs(scope);
41
- const publishable = publishableWriteSlugs(scope);
42
- return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
43
- };
44
- /** The value a client read back is a string; what it meets may be a Date. */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
45
- /** Unchecked, because the runtime shape really does vary; `Branch` guards it. */ const widen = (branch) => branch;
46
- /** The one list the shape helpers and {@link resolveTarget} both read. */ const slugsFor = (scope, operation) => {
28
+ * The one list the shape helpers and {@link resolveEntity} both read.
29
+ */ const slugsFor = (scope, operation) => {
47
30
  switch (operation) {
48
31
  case "create": return {
49
32
  collections: slugsWhere(scope, canCreate, {
50
- collections: scope.writable,
33
+ collections: scope.collections.writable,
51
34
  globals: []
52
35
  }),
53
36
  globals: []
54
37
  };
55
- case "publish": return {
56
- collections: scope.publishable,
57
- globals: scope.publishableGlobals
58
- };
59
- case "read": return {
60
- collections: scope.readable,
61
- globals: scope.readableGlobals
62
- };
63
- case "write": return {
64
- collections: scope.writable,
65
- globals: scope.writableGlobals
38
+ case "publish": return slugsOf(scope, "publishable");
39
+ case "read": return slugsOf(scope, "readable");
40
+ case "versions": return {
41
+ collections: slugsWhere(scope, (entity) => entity.hasVersions, {
42
+ collections: scope.collections.readable,
43
+ globals: []
44
+ }),
45
+ globals: slugsWhere(scope, (entity) => entity.hasVersions, {
46
+ collections: [],
47
+ globals: scope.globals.readable
48
+ })
66
49
  };
50
+ case "write": return slugsOf(scope, "writable");
67
51
  }
68
52
  };
69
53
  /**
54
+ * The slugs of {@link slugsFor} `"versions"` that also have drafts. Only they
55
+ * have `_status` on their versions, so only they can be queried by status.
56
+ */ const draftVersionSlugs = (scope) => {
57
+ const { collections, globals } = slugsFor(scope, "versions");
58
+ return {
59
+ collections: slugsWhere(scope, (entity) => entity.hasDrafts, {
60
+ collections,
61
+ globals: []
62
+ }),
63
+ globals: slugsWhere(scope, (entity) => entity.hasDrafts, {
64
+ collections: [],
65
+ globals
66
+ })
67
+ };
68
+ };
69
+ /**
70
+ * Slugs reachable by `operation` whose writes go live, because they have no
71
+ * draft to write.
72
+ */ const liveWriteSlugs = (scope, operation) => slugsWhere(scope, isLiveWrite, slugsFor(scope, operation));
73
+ /**
74
+ * Stated on the tools that write, since a client calling them must know before
75
+ * the call which writes are public.
76
+ */ const liveWriteSentence = (scope, operation) => {
77
+ const live = liveWriteSlugs(scope, operation);
78
+ return live.length === 0 ? "Every write is saved as a draft." : `Writes to ${live.join(", ")} go live immediately. Every other write is saved as a draft.`;
79
+ };
80
+ /**
70
81
  * With no reachable global, `global` is left out and `collection` stays
71
- * required, so a deployment without globals sees an unchanged schema. Only the
82
+ * required, so a deployment without globals gets no extra argument. Only the
72
83
  * mixed case makes either optional, and the handler enforces exclusivity there.
73
- */ const targetShape = (scope, operation, descriptions) => {
84
+ * `globalRule` states that rule on `global` for a tool without an `id`.
85
+ */ const entityShape = (scope, operation, globalRule) => {
74
86
  const { collections, globals } = slugsFor(scope, operation);
75
- if (globals.length === 0) return widen({ collection: slugEnum(collections).describe(descriptions.collection) });
76
- if (collections.length === 0) return widen({ global: slugEnum(globals).describe(descriptions.global) });
87
+ if (globals.length === 0) return widen({ collection: slugEnum(collections) });
88
+ if (collections.length === 0) return widen({ global: slugEnum(globals) });
89
+ const global = slugEnum(globals).optional();
77
90
  return widen({
78
- collection: slugEnum(collections).optional().describe(descriptions.collection),
79
- global: slugEnum(globals).optional().describe(descriptions.global)
91
+ collection: slugEnum(collections).optional(),
92
+ global: globalRule === void 0 ? global : global.describe(globalRule)
80
93
  });
81
94
  };
82
95
  /**
83
96
  * Only a collection document has one. Optional in the mixed case, where
84
- * `requireIdFor` enforces the dependency.
97
+ * `resolveDocument` enforces the dependency and the description states it.
85
98
  */ const idShape = (scope, operation) => {
86
99
  const { collections, globals } = slugsFor(scope, operation);
87
100
  if (collections.length === 0) return widen({});
88
101
  if (globals.length === 0) return widen({ id: idSchema });
89
- return widen({ id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") });
102
+ return widen({ id: idSchema.optional().describe("Required with \"collection\", omitted with \"global\".") });
90
103
  };
104
+ /**
105
+ * For the reads that fall back to the default locale, unlike the writes.
106
+ */ const READ_LOCALE_DESCRIPTION = "Default: the default locale. A value missing in this locale is shown from the default locale.";
91
107
  const localeShape = (scope, options) => {
92
- if (!scope.locales) return widen({});
93
- const locale = z.enum(scope.locales);
94
- return widen({ locale: (options.required ? locale : locale.optional()).describe(options.description) });
108
+ if (!scope.localization) return widen({});
109
+ const enumerated = z.enum(scope.localization.locales);
110
+ const locale = options.required ? enumerated : enumerated.optional();
111
+ return widen({ locale: options.description === void 0 ? locale : locale.describe(options.description) });
95
112
  };
96
113
  /**
97
114
  * Defaults to 0 rather than Payload's own default: a client usually wants ids
98
115
  * it can write back, and populating a relation costs a query.
99
- */ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
116
+ */ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe("Default 0. A relationship into a collection this key cannot read stays an id.") });
100
117
  /**
101
118
  * The locale to operate on: the explicit argument, else the request's, else
102
119
  * the default. `undefined` when localization is off.
103
120
  */ const localeOf = (scope, locale) => {
104
- if (!scope.locales) return;
121
+ if (!scope.localization) return;
122
+ const { locales, defaultLocale } = scope.localization;
105
123
  const requested = locale ?? scope.req.locale;
106
- return (requested && scope.locales.includes(requested) ? requested : scope.defaultLocale) ?? void 0;
107
- };
108
- /**
109
- * Reads the current draft in a fixed locale with no fallback, which is the
110
- * shape that may be written back or validated without mixing locales.
111
- */ const readTarget = async (scope, args) => {
112
- const { payload } = scope.req;
113
- const privileged = args.privileged === true;
114
- const shared = {
115
- depth: 0,
116
- draft: true,
117
- ...args.locale === void 0 ? {} : {
118
- locale: args.locale,
119
- fallbackLocale: false
120
- },
121
- overrideAccess: privileged,
122
- showHiddenFields: privileged,
123
- req: scope.req
124
- };
125
- if (args.target.kind === "collection") {
126
- const doc = await payload.findByID({
127
- ...shared,
128
- collection: args.target.slug,
129
- id: args.id,
130
- disableErrors: true
131
- });
132
- if (!doc) throw new NotFound(scope.req.t);
133
- return doc;
134
- }
135
- return await payload.findGlobal({
136
- ...shared,
137
- slug: args.target.slug
138
- });
139
- };
140
- const translateLabel = (scope, label, fallback) => {
141
- const { i18n, t } = scope.req;
142
- const resolved = typeof label === "function" ? label({
143
- i18n,
144
- t
145
- }) : label;
146
- return translateStatic(resolved, i18n) ?? fallback;
124
+ return requested && locales.includes(requested) ? requested : defaultLocale;
147
125
  };
148
126
  //#endregion
149
- export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, patchOnlySlugs, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
127
+ export { READ_LOCALE_DESCRIPTION, depthShape, draftVersionSlugs, entityShape, idSchema, idShape, liveWriteSentence, liveWriteSlugs, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor, widen };
@@ -1,57 +1,45 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
1
2
  import { jsonResult } from "../result.mjs";
2
- import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
3
- import { requireIdFor, resolveTarget } from "./target.mjs";
4
- import { defineMcpxTool } from "../types.mjs";
3
+ import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
5
4
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
5
+ import { collectLocaleBlockers, identityOf, readDraft, resolveDocument } from "./document.mjs";
6
6
  /**
7
- * Gated on write rather than read, because publish blockers only mean
8
- * something to a caller who can act on them.
7
+ * Gated on write rather than read, because publish blockers only mean something
8
+ * to a caller who can act on them.
9
9
  *
10
10
  * It reads the document twice on purpose: once under the key's own access to
11
- * refuse a caller who may not see it, then privileged, so the check runs over
12
- * every field rather than the subset the user can read. It carries no
13
- * `readOnlyHint`, because the traversal fires field hooks.
11
+ * refuse a caller who may not see it, then privileged, so the check covers every
12
+ * field and not only those the user can read. It has no `readOnlyHint` because
13
+ * the traversal fires field hooks.
14
14
  */ const validateDocument = defineMcpxTool({
15
15
  name: "validateDocument",
16
- 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".
17
-
18
- Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
19
-
20
- Nothing is written, but the check runs the same field-level beforeValidate and beforeChange hooks a save would, so a hook with side effects fires. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing.`,
16
+ description: `Lists what still blocks publishing one document or global, without saving. Checks one locale when "locale" is given, otherwise every locale. Returns "publishBlockers", each with a pointer and a message, and a "locale" when every locale was checked. An empty list means it can be published, unless "publishBlockersUnavailable" is true: then the check itself failed. The check runs the field hooks a save runs.`,
21
17
  annotations: { openWorldHint: false },
22
- isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
18
+ isEnabled: (scope) => scope.collections.writable.length + scope.globals.writable.length > 0,
23
19
  inputSchema: (scope) => ({
24
- ...targetShape(scope, "write", {
25
- collection: "Collection holding the document.",
26
- global: "Global to validate."
27
- }),
20
+ ...entityShape(scope, "write"),
28
21
  ...idShape(scope, "write"),
29
- ...localeShape(scope, {
30
- required: true,
31
- description: "Locale to validate."
32
- })
22
+ ...localeShape(scope, { required: false })
33
23
  }),
34
24
  handler: async ({ args, scope }) => {
35
- const target = resolveTarget(scope, args, "write");
36
- const id = requireIdFor(target, args.id);
25
+ const target = resolveDocument(scope, args, "write");
26
+ const { localization } = scope;
37
27
  const locale = localeOf(scope, args.locale);
38
- await readTarget(scope, {
28
+ await readDraft(scope, {
39
29
  target,
40
- id,
41
30
  locale
42
31
  });
43
- const doc = await readTarget(scope, {
32
+ const doc = await readDraft(scope, {
44
33
  target,
45
- id,
46
34
  locale,
47
35
  privileged: true
48
36
  });
49
- const validation = await collectPublishBlockers(scope.req, {
37
+ const validation = localization && args.locale === void 0 ? await collectLocaleBlockers(scope, target, localization.locales) : await collectPublishBlockers(scope.req, {
50
38
  doc,
51
39
  entity: target
52
40
  });
53
41
  return jsonResult({
54
- ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
42
+ ...identityOf(target, doc["id"]),
55
43
  status: doc["_status"],
56
44
  updatedAt: doc["updatedAt"],
57
45
  publishBlockers: validation.blockers,
@@ -0,0 +1,136 @@
1
+ import { stripAdminHidden } from "../schema/admin-hidden.mjs";
2
+ import "../schema/index.mjs";
3
+ import { mcpxReadRequest } from "./read-request.mjs";
4
+ import { hasDraftsEnabled } from "payload/shared";
5
+ import { Pointer, createPatch } from "rfc6902";
6
+ //#region src/tools/versions.ts
7
+ const NOISE = [
8
+ "id",
9
+ "globalType",
10
+ "createdAt",
11
+ "updatedAt",
12
+ "_status"
13
+ ];
14
+ const NEWEST_FIRST = ["-updatedAt", "-id"];
15
+ /**
16
+ * The document or global as the key's user sees it, without `admin.hidden`
17
+ * fields.
18
+ */ const readLive = async (scope, read, options) => {
19
+ const shared = {
20
+ depth: read.depth,
21
+ draft: options.draft,
22
+ overrideAccess: false,
23
+ req: mcpxReadRequest(scope),
24
+ ...options.select === void 0 ? {} : { select: options.select },
25
+ ...read.locale === void 0 ? {} : { locale: read.locale }
26
+ };
27
+ const doc = await (read.target.kind === "collection" ? scope.req.payload.findByID({
28
+ ...shared,
29
+ collection: read.target.slug,
30
+ id: read.target.id
31
+ }) : scope.req.payload.findGlobal({
32
+ ...shared,
33
+ slug: read.target.slug
34
+ }));
35
+ return stripAdminHidden(scope.req.payload.config, read.target, doc);
36
+ };
37
+ const withoutHidden = (scope, read, stored) => ({
38
+ ...stored,
39
+ version: stripAdminHidden(scope.req.payload.config, read.target, stored.version)
40
+ });
41
+ const assertReadable = async (scope, read) => {
42
+ await readLive(scope, {
43
+ ...read,
44
+ depth: 0
45
+ }, {
46
+ draft: true,
47
+ select: {}
48
+ });
49
+ };
50
+ /**
51
+ * `parent` comes back as a raw id or, populated, as the document itself.
52
+ */ const isVersionOf = (version, id) => {
53
+ const parent = version["parent"];
54
+ const parentId = typeof parent === "object" && parent !== null ? parent["id"] : parent;
55
+ return (typeof parentId === "string" || typeof parentId === "number") && String(parentId) === String(id);
56
+ };
57
+ /**
58
+ * Reads one version, or `null` when it does not exist, is not readable, or
59
+ * belongs to another document. `findVersionByID` knows nothing of the document
60
+ * the caller asked about, so the parent check is what keeps one readable
61
+ * document from opening the history of another.
62
+ */ const loadVersion = async (scope, read, versionId) => {
63
+ await assertReadable(scope, read);
64
+ const { payload } = scope.req;
65
+ const shared = {
66
+ id: versionId,
67
+ depth: read.depth,
68
+ disableErrors: true,
69
+ overrideAccess: false,
70
+ req: mcpxReadRequest(scope),
71
+ ...read.locale === void 0 ? {} : { locale: read.locale }
72
+ };
73
+ const version = await (read.target.kind === "collection" ? payload.findVersionByID({
74
+ ...shared,
75
+ collection: read.target.slug
76
+ }) : payload.findGlobalVersionByID({
77
+ ...shared,
78
+ slug: read.target.slug
79
+ }));
80
+ if (!version) return null;
81
+ if (read.target.kind === "collection" && !isVersionOf(version, read.target.id)) return null;
82
+ return withoutHidden(scope, read, version);
83
+ };
84
+ /**
85
+ * One page of a document's or global's history, newest first.
86
+ */ const queryVersions = async (scope, read, options) => {
87
+ await assertReadable(scope, read);
88
+ const where = {
89
+ ...read.target.kind === "collection" ? { parent: { equals: read.target.id } } : {},
90
+ ...options.status === void 0 ? {} : { "version._status": { equals: options.status } }
91
+ };
92
+ const shared = {
93
+ depth: read.depth,
94
+ limit: options.limit,
95
+ sort: NEWEST_FIRST,
96
+ overrideAccess: false,
97
+ req: mcpxReadRequest(scope),
98
+ where,
99
+ ...options.page === void 0 ? {} : { page: options.page },
100
+ ...read.locale === void 0 ? {} : { locale: read.locale }
101
+ };
102
+ return await (read.target.kind === "collection" ? scope.req.payload.findVersions({
103
+ ...shared,
104
+ collection: read.target.slug
105
+ }) : scope.req.payload.findGlobalVersions({
106
+ ...shared,
107
+ slug: read.target.slug
108
+ }));
109
+ };
110
+ /**
111
+ * The newest version whose status is published, or `null` if none is.
112
+ */ const loadPublished = async (scope, read) => {
113
+ if (!hasDraftsEnabled(read.target.config)) return null;
114
+ const [newest] = (await queryVersions(scope, read, {
115
+ status: "published",
116
+ limit: 1
117
+ })).docs;
118
+ return newest ? withoutHidden(scope, read, newest) : null;
119
+ };
120
+ const strip = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => !NOISE.includes(key)));
121
+ /**
122
+ * The RFC 6902 operations turning `from` into `to`, limited to the subtree at
123
+ * `pointer`. Op paths stay absolute, so they apply as `patchDocument` ops.
124
+ */ const diffDocuments = (from, to, pointer = Pointer.fromJSON("")) => {
125
+ const wrap = (doc) => {
126
+ const value = pointer.get(strip(doc));
127
+ return value === void 0 ? {} : { value };
128
+ };
129
+ const path = pointer.toString();
130
+ return createPatch(wrap(from), wrap(to)).map((op) => ({
131
+ ...op,
132
+ path: `${path}${op.path.slice(6)}`
133
+ }));
134
+ };
135
+ //#endregion
136
+ export { diffDocuments, isVersionOf, loadPublished, loadVersion, queryVersions, readLive };