@abinnovision/payloadcms-mcpx 1.0.0-beta.13 → 1.0.0-beta.14

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.
@@ -6,10 +6,7 @@ import { z } from "zod";
6
6
  import { Pointer, applyPatch } from "rfc6902";
7
7
  //#region src/write/patch.ts
8
8
  const POINTER = z.string().regex(JSON_POINTER_PATTERN);
9
- /**
10
- * One RFC 6902 operation as accepted by `patchDocument`. Discriminated on `op`
11
- * so an operation carries only the members RFC 6902 defines for it.
12
- */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
9
+ /** Discriminated on `op`, so an operation carries only its own members. */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
13
10
  z.strictObject({
14
11
  op: z.literal("add"),
15
12
  path: POINTER,
@@ -40,31 +37,18 @@ const POINTER = z.string().regex(JSON_POINTER_PATTERN);
40
37
  value: z.unknown()
41
38
  })
42
39
  ]).describe("An RFC 6902 operation.");
43
- /**
44
- * Whether a pointer touches a field Payload maintains.
45
- */ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
46
- /**
47
- * The pointer an operation removes a value from, if it removes one at all.
48
- */ const droppedPointer = (operation) => {
40
+ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
41
+ const droppedPointer = (operation) => {
49
42
  if (operation.op === "remove") return operation.path;
50
43
  return operation.op === "move" ? operation.from : void 0;
51
44
  };
52
- /**
53
- * Whether a pointer addresses a list element rather than a field.
54
- */ const isElementPointer = (pointer) => {
45
+ const isElementPointer = (pointer) => {
55
46
  const last = pointer.split("/").pop() ?? "";
56
47
  return last === "-" || /^\d+$/.test(last);
57
48
  };
58
49
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
59
- /**
60
- * A Lexical editor state. Its nodes manage their own ids, so it is never
61
- * descended into.
62
- */ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
63
- /**
64
- * Visits every row in a value: a plain object that carries `blockType` or
65
- * sits directly inside an array. Rich text states manage their own nodes and
66
- * are never descended into.
67
- */ const walkRows = (value, visit, isRow = false) => {
50
+ /** Its nodes manage their own ids, so it is never descended into. */ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
51
+ /** A row is a plain object carrying `blockType`, or one sitting in an array. */ const walkRows = (value, visit, isRow = false) => {
68
52
  if (Array.isArray(value)) {
69
53
  for (const entry of value) walkRows(entry, visit, true);
70
54
  return;
@@ -97,10 +81,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
97
81
  delete row["id"];
98
82
  });
99
83
  };
100
- /**
101
- * Drops every row id from a copy of `value`. Used on create, where no stored
102
- * row exists and any incoming id is client-invented.
103
- */ const stripRowIds = (value) => {
84
+ /** For create, where no stored row exists and any incoming id is invented. */ const stripRowIds = (value) => {
104
85
  const next = structuredClone(value);
105
86
  walkRows(next, (row) => {
106
87
  delete row["id"];
@@ -122,34 +103,24 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
122
103
  op: "add"
123
104
  } : cloned;
124
105
  };
125
- /**
126
- * The value an operation writes at its path: the one it carries, or the one it
127
- * takes from `from`. A `remove` writes nothing.
128
- */ const effectiveValue = (operation, doc) => {
106
+ /** The one it carries, or the one it takes from `from`. `remove` writes nothing. */ const effectiveValue = (operation, doc) => {
129
107
  if ("value" in operation) return operation.value;
130
108
  return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
131
109
  };
132
- /**
133
- * Whether a resolved pointer lands in a read-only field. A pointer that stops
134
- * short of one addresses a subtree, and the fields beneath it decide.
135
- */ const resolvesReadOnly = (resolution) => {
110
+ /** A pointer stopping short addresses a subtree; the fields beneath decide. */ const resolvesReadOnly = (resolution) => {
136
111
  if (resolution.descriptor) return resolution.descriptor.readOnly === true;
137
112
  const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
138
113
  return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
139
114
  };
140
- /**
141
- * Whether the pointer addresses something read-only. An element carries no
142
- * descriptor of its own, so the field it belongs to is read one segment up.
143
- */ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
115
+ /** An element has no descriptor, so its field is read one segment up. */ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
144
116
  doc: target.doc,
145
117
  pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
146
118
  ref: target.ref
147
119
  }));
148
120
  /**
149
- * Checks one operation against the schema, in the state the document is in
150
- * when that operation runs. Both pointers must resolve, whatever the operation
151
- * writes at its path must pass write validation, and what it drops must not sit
152
- * in a read-only field.
121
+ * Checked against the document as it stands when this operation runs: both
122
+ * pointers must resolve, the written value must pass write validation, and what
123
+ * it drops must not sit in a read-only field.
153
124
  */ const findOperationProblems = (config, target) => {
154
125
  const { doc, operation, ref } = target;
155
126
  const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
@@ -191,13 +162,10 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
191
162
  }
192
163
  };
193
164
  /**
194
- * Validates and applies every operation against one evolving copy of the
195
- * document, so an operation that depends on an earlier one resolves against
196
- * the shape it actually modifies.
197
- *
198
- * The copy means a failing operation leaves the original untouched, and the
199
- * caller writes nothing unless the whole batch came back applied, so a
200
- * partially applied batch is never persisted.
165
+ * One evolving copy, so an operation depending on an earlier one resolves
166
+ * against the shape it actually modifies, and a failure leaves the original
167
+ * untouched. The caller writes nothing unless the whole batch came back
168
+ * applied, so a partial batch is never persisted.
201
169
  */ const applyPatchOperations = (config, target) => {
202
170
  const next = structuredClone(target.doc);
203
171
  for (const [index, operation] of target.patches.entries()) {
@@ -214,18 +182,15 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
214
182
  reconcileRowIds(next, target.doc);
215
183
  return { next };
216
184
  };
217
- /**
218
- * Keys Payload manages on a row that travel back into the write unchanged.
219
- */ const ROW_KEYS = /* @__PURE__ */ new Set([
185
+ const ROW_KEYS = /* @__PURE__ */ new Set([
220
186
  "blockName",
221
187
  "blockType",
222
188
  "id"
223
189
  ]);
224
190
  /**
225
- * Picks the keys the schema walker describes out of `value`, descending into
226
- * groups, named tabs, arrays and blocks. Everything Payload maintains or
227
- * derives (`_status`, timestamps, join and virtual fields, upload base fields)
228
- * is left out, so the write-back carries only what a client could have set.
191
+ * Everything Payload maintains or derives (`_status`, timestamps, join and
192
+ * virtual fields, upload base fields) is left out, so the write-back carries
193
+ * only what a client could have set.
229
194
  */ const pickDescribed = (config, value, at) => {
230
195
  const { fields, prefix, isRow } = at;
231
196
  const relative = describeAddressableFields(fields).flatMap((descriptor) => {
@@ -279,10 +244,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
279
244
  }
280
245
  return result;
281
246
  };
282
- /**
283
- * The data handed to `payload.update` after a patch: the patched document
284
- * reduced to the fields the client may write, plus row identity keys.
285
- */ const buildWriteData = (config, target, doc) => {
247
+ /** The patched document reduced to writable fields, plus row identity keys. */ const buildWriteData = (config, target, doc) => {
286
248
  return pickDescribed(config, doc, {
287
249
  fields: target.flattenedFields,
288
250
  prefix: [],
@@ -5,23 +5,17 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
5
5
  /**
6
6
  * Runs Payload's own field validation over a draft without saving anything.
7
7
  *
8
- * Draft saves skip validation unless `versions.drafts.validate` is set, so an
9
- * agent building a document incrementally gets no signal until a human presses
10
- * Publish. This is the same traversal a real save runs, exported from
11
- * `payload`, called with `skipValidation: false` so it collects into `errors`
12
- * instead of throwing. The `beforeValidate` pass runs first because some field
13
- * hooks (Lexical's) prepare state in `context` that their `beforeChange`
14
- * counterpart depends on.
8
+ * The same traversal a real save runs, exported from `payload` and called with
9
+ * `skipValidation: false` so it collects into `errors` instead of throwing. The
10
+ * `beforeValidate` pass runs first because some field hooks (Lexical's) prepare
11
+ * state in `context` that their `beforeChange` counterpart depends on.
15
12
  *
16
13
  * Nothing is written: `data` is a copy, the context is a scratch copy and the
17
14
  * locale merge actions are discarded. `overrideAccess` is true because the
18
15
  * question is "could this be published", not "may this client write it".
19
16
  *
20
- * Limits: only the locale the doc was read in is checked, and field-level
21
- * `beforeChange` hooks run again, which is safe only for pure ones.
22
- *
23
- * `unavailable` marks a traversal that threw, which is not the same answer as
24
- * a document with nothing wrong with it.
17
+ * `unavailable` marks a traversal that threw, which is not the same answer as a
18
+ * document with nothing wrong with it.
25
19
  */ const collectPublishBlockers = async (req, target) => {
26
20
  const { doc, entity } = target;
27
21
  const id = doc["id"];
@@ -1,39 +1,17 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
1
+ import { randomUUID } from "node:crypto";
2
2
  //#region src/write/publish-intent.ts
3
- const store = new AsyncLocalStorage();
4
- /** Runs `fn` with `intent` in force. */ const withPublishIntent = async (intent, fn) => await store.run({
5
- ...intent,
6
- claimed: false
7
- }, fn);
8
- const activeFor = (target) => {
9
- const active = store.getStore();
10
- return active && active.kind === target.kind && active.slug === target.slug && active.id === target.id ? active : void 0;
11
- };
12
- /**
13
- * Claims the intent for one operation, which `beforeOperation` does so that a
14
- * re-entrant write to the same document — an `afterChange` hook calling
15
- * `payload.update`, say — cannot ride along on it. Only the first operation to
16
- * ask gets it.
17
- */ const claimPublishIntent = (target) => {
18
- const active = activeFor(target);
19
- if (!active || active.claimed) return false;
20
- active.claimed = true;
3
+ const PUBLISH_INTENT = "__mcpxPublishIntent";
4
+ const TOKEN = randomUUID();
5
+ const withPublishIntent = (data) => ({
6
+ ...data,
7
+ [PUBLISH_INTENT]: TOKEN
8
+ });
9
+ const carries = (data) => typeof data === "object" && data !== null && data[PUBLISH_INTENT] === TOKEN;
10
+ const hasPublishIntent = (data) => carries(data);
11
+ /** Asked by the last hook that needs it, so it takes the marker off. */ const takePublishIntent = (data) => {
12
+ if (!carries(data)) return false;
13
+ delete data[PUBLISH_INTENT];
21
14
  return true;
22
15
  };
23
- /**
24
- * Whether this change belongs to the operation that claimed the intent, which
25
- * is what lets the `beforeChange` alarm accept a published status.
26
- *
27
- * The id is deliberately not compared here. A `beforeChange` hook reads it from
28
- * the loaded document, where Payload has already coerced it to the collection's
29
- * id type, while the claim above saw the raw tool argument; comparing the two
30
- * would refuse a legitimate publish over `1` versus `"1"`. Nothing is lost: a
31
- * nested write to another document of the same collection during the publish
32
- * cannot claim the intent, so `forceDraftWrite` has already stripped its
33
- * `_status` and it fails the alarm on that.
34
- */ const isClaimedPublish = (kind, slug) => {
35
- const active = store.getStore();
36
- return active?.kind === kind && active.slug === slug && active.claimed;
37
- };
38
16
  //#endregion
39
- export { claimPublishIntent, isClaimedPublish, withPublishIntent };
17
+ export { hasPublishIntent, takePublishIntent, withPublishIntent };
@@ -1,9 +1,8 @@
1
1
  import { commitTransaction, initTransaction, killTransaction } from "payload";
2
2
  //#region src/write/transaction.ts
3
3
  /**
4
- * Runs `fn` inside one database transaction on `req`, so a read followed by a
5
- * write is committed or rolled back together. Adapters without transaction
6
- * support, or a request that already owns one, run `fn` as is.
4
+ * Adapters without transaction support, or a request that already owns one, run
5
+ * `fn` as is.
7
6
  *
8
7
  * Atomicity, not isolation: neither SQLite nor Postgres at read committed locks
9
8
  * the row on the read, so an `expectedUpdatedAt` check remains best effort. Nor
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "1.0.0-beta.13",
4
+ "version": "1.0.0-beta.14",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",