drupal-mcp-connector 2.7.0 → 2.7.2

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.
@@ -162,20 +162,23 @@ export async function assertSourceGovernance(site) {
162
162
 
163
163
  /**
164
164
  * Per-site governance condition for operator diagnostics. No secrets: only
165
- * the site name, whether governance is required, the verdict, and the reason.
165
+ * the site name, whether this client requires governance, whether the
166
+ * readiness endpoint was probed, the verdict, and the server's reason.
167
+ *
168
+ * Always probes `GET /drupal-mcp/readiness`, even when `requireGovernance`
169
+ * is off — the server may still refuse governed paths (#208). Never reports
170
+ * `ok: true` without a check.
166
171
  *
167
172
  * @param {Array<object>} sites Resolved site configs.
168
- * @returns {Promise<Array<{site: string, required: boolean, ok: boolean, reason: string|null, checkedAt: number|null}>>}
173
+ * @returns {Promise<Array<{site: string, required: boolean, checked: boolean, ok: boolean, reason: string|null, checkedAt: number}>>}
169
174
  */
170
175
  export async function governanceStatus(sites) {
171
176
  return Promise.all(sites.map(async (site) => {
172
- if (!requiresGovernance(site)) {
173
- return { site: site._name, required: false, ok: true, reason: null, checkedAt: null };
174
- }
175
177
  const result = await verifySourceGovernance(site);
176
178
  return {
177
179
  site: site._name,
178
- required: true,
180
+ required: requiresGovernance(site),
181
+ checked: true,
179
182
  ok: result.ok,
180
183
  reason: result.reason,
181
184
  checkedAt: result.checkedAt,
@@ -0,0 +1,248 @@
1
+ /**
2
+ * JSON:API PATCH preflight for Drupal core's working-copy guard (#201).
3
+ *
4
+ * `EntityResource::patchIndividual()` rejects a canonical PATCH when the
5
+ * stored entity is not both the latest and the default revision. That check
6
+ * runs against the revision table *before* the payload is deserialized.
7
+ * An empty-body PATCH that returns 2xx still calls `$entity->save()` (and
8
+ * often `setNewRevision`) — so the probe must fail *after* the guard and
9
+ * *before* save. Core next compares `data.id` to the URL entity UUID; a
10
+ * well-formed but non-matching id yields 400 "does not match the ID in the
11
+ * payload" with no row written. Content-moderation's `rel:latest-version` /
12
+ * `rel:working-copy` aliases can disagree with storage (a revision row with
13
+ * no `content_moderation_state`), which is how every read tool reports
14
+ * clean and the write then 400s.
15
+ *
16
+ * `workingCopy: null` and "latest-version vid === default vid" are not proof
17
+ * the node is writable. That is the #201 lie.
18
+ *
19
+ * Distinct from #166: there a working copy is visible and the fix is PATCH
20
+ * `?resourceVersion=rel:working-copy`. This module does not implement that.
21
+ */
22
+
23
+ import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
24
+
25
+ /** Stable error code for a core working-copy / not-latest-revision block. */
26
+ export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
27
+
28
+ const WORKING_COPY_PATCH_RE = /has a working copy is not yet supported/i;
29
+
30
+ /**
31
+ * Actionable replacement for core's "has a working copy" 400 when no
32
+ * content_moderation working copy is addressable — a stray revision row.
33
+ */
34
+ export const PATCH_BLOCKED_MESSAGE =
35
+ "This entity cannot be updated over JSON:API because the stored entity is not " +
36
+ "the latest revision (Drupal core #2795279). The JSON:API aliases " +
37
+ "rel:latest-version and rel:working-copy cannot show the blocking row. " +
38
+ "Clearing it requires revision surgery outside JSON:API (Drush / the entity API). " +
39
+ "See connector #201. Do not retry the same canonical PATCH.";
40
+
41
+ /**
42
+ * Read a revision id off a working-copy body (canonical or raw-ish).
43
+ * @param {?object} workingCopy
44
+ * @returns {?number|string}
45
+ */
46
+ function workingCopyVid(workingCopy) {
47
+ if (!workingCopy || typeof workingCopy !== "object") return null;
48
+ const fields = workingCopy.fields && typeof workingCopy.fields === "object"
49
+ ? workingCopy.fields
50
+ : {};
51
+ const attrs = workingCopy.attributes && typeof workingCopy.attributes === "object"
52
+ ? workingCopy.attributes
53
+ : {};
54
+ const raw = workingCopy.vid
55
+ ?? fields.drupal_internal__vid
56
+ ?? attrs.drupal_internal__vid
57
+ ?? workingCopy.drupal_internal__vid;
58
+ if (raw === undefined || raw === null || raw === "") return null;
59
+ const n = Number(raw);
60
+ return Number.isFinite(n) ? n : raw;
61
+ }
62
+
63
+ /**
64
+ * Operator message for a core working-copy 400.
65
+ * A resolvable working copy is an ordinary pending draft — do not prescribe
66
+ * revision surgery. Surgery is only for the invisible-row case (#201 follow-up).
67
+ * @param {?object} [workingCopy]
68
+ * @returns {string}
69
+ */
70
+ export function patchBlockedMessage(workingCopy) {
71
+ if (workingCopy) {
72
+ const vid = workingCopyVid(workingCopy);
73
+ const which = vid !== null && vid !== undefined ? ` (vid ${vid})` : "";
74
+ return `This node has a pending draft${which}. Publish or discard it `
75
+ + "before a canonical PATCH.";
76
+ }
77
+ return PATCH_BLOCKED_MESSAGE;
78
+ }
79
+
80
+ /**
81
+ * Thrown when the core working-copy PATCH guard rejects a write (or its probe).
82
+ */
83
+ export class PatchBlockedError extends Error {
84
+ /**
85
+ * @param {?Error} [cause] The original Drupal 400.
86
+ * @param {{workingCopy?: ?object}} [options]
87
+ */
88
+ constructor(cause, { workingCopy } = {}) {
89
+ super(patchBlockedMessage(workingCopy ?? null));
90
+ this.name = "PatchBlockedError";
91
+ this.code = PATCH_BLOCKED_CODE;
92
+ if (workingCopy) this.workingCopyVid = workingCopyVid(workingCopy);
93
+ if (cause) this.cause = cause;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Whether an error is Drupal core's working-copy PATCH guard (core #2795279).
99
+ * @param {unknown} err
100
+ * @returns {boolean}
101
+ */
102
+ export function isWorkingCopyPatchError(err) {
103
+ return WORKING_COPY_PATCH_RE.test(String(err?.message || ""));
104
+ }
105
+
106
+ /**
107
+ * Rewrite a core working-copy 400 into {@link PatchBlockedError}; otherwise
108
+ * return the original value. Pass `workingCopy` when the pending draft is
109
+ * addressable so the message does not prescribe revision surgery.
110
+ * @param {unknown} err
111
+ * @param {{workingCopy?: ?object}} [options]
112
+ * @returns {unknown}
113
+ */
114
+ export function rewriteWorkingCopyPatchError(err, { workingCopy } = {}) {
115
+ if (!isWorkingCopyPatchError(err)) return err;
116
+ return new PatchBlockedError(
117
+ err instanceof Error ? err : new Error(String(err)),
118
+ { workingCopy },
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Load `rel:working-copy` so a blocked PATCH can name a pending draft.
124
+ * @param {object} backend
125
+ * @param {{entityType: string, bundle: string, id: string}} ref
126
+ * @returns {Promise<?object>}
127
+ */
128
+ async function loadWorkingCopy(backend, { entityType, bundle, id }) {
129
+ if (typeof backend?.getEntity !== "function") return null;
130
+ try {
131
+ const wc = await backend.getEntity({
132
+ entityType, bundle, id, resourceVersion: "rel:working-copy",
133
+ });
134
+ return wc || null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Whether this update should run the PATCH probe.
142
+ * Skip unmoderated / non-revisionable bundles — the guard is about
143
+ * revisionable entities under content_moderation.
144
+ * @param {{existing?: ?object, attributes?: object}} input
145
+ * @returns {boolean}
146
+ */
147
+ export function shouldPreflightPatch({ existing, attributes } = {}) {
148
+ if (hasExplicitModerationState(attributes)) return true;
149
+ return entityLooksModerated(existing);
150
+ }
151
+
152
+ /**
153
+ * UUID used as `data.id` on the probe PATCH so it cannot match the URL
154
+ * entity. Core throws after the working-copy guard and before save.
155
+ * @see EntityResource::patchIndividual()
156
+ */
157
+ export const PATCH_PROBE_MISMATCH_ID = "00000000-0000-4000-a000-000000000001";
158
+
159
+ const ID_MISMATCH_RE = /does not match the ID in the payload/i;
160
+
161
+ /**
162
+ * Whether a probe error means the working-copy guard passed and no row
163
+ * was written (id mismatch, or a 422 during deserialize).
164
+ * @param {unknown} err
165
+ * @returns {boolean}
166
+ */
167
+ export function isProbePassedWithoutSave(err) {
168
+ const msg = String(err?.message || "");
169
+ return ID_MISMATCH_RE.test(msg) || /Drupal 422\b/.test(msg);
170
+ }
171
+
172
+ /**
173
+ * Probe the same guard core uses on the canonical PATCH URL.
174
+ *
175
+ * Sends a PATCH whose `data.id` does not match the URL entity. Core runs
176
+ * the working-copy check first; a match on that phrase means no row was
177
+ * written. An id-mismatch 400 (or deserialize 422) means the guard passed
178
+ * and save was not reached. A 2xx would have saved a revision and is
179
+ * treated as a probe failure. Do not treat "latest-version vid === default
180
+ * vid" as writable.
181
+ *
182
+ * @param {object} args
183
+ * @param {object} args.backend Backend with `rawQuery` + `resourcePath`.
184
+ * @param {string} args.entityType
185
+ * @param {string} args.bundle
186
+ * @param {string} args.id
187
+ * @param {?object} [args.existing]
188
+ * @param {object} [args.attributes]
189
+ * @returns {Promise<{probed: boolean, writable?: boolean|string, skipped?: string}>}
190
+ * @throws {PatchBlockedError} When the guard rejects the probe.
191
+ */
192
+ export async function preflightPatchWritable({
193
+ backend, entityType, bundle, id, existing, attributes,
194
+ }) {
195
+ if (!shouldPreflightPatch({ existing, attributes })) {
196
+ return { probed: false };
197
+ }
198
+ if (typeof backend?.rawQuery !== "function" || typeof backend?.resourcePath !== "function") {
199
+ return { probed: false, skipped: "backend cannot issue a raw PATCH probe" };
200
+ }
201
+ const path = `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
202
+ const type = `${entityType}--${bundle}`;
203
+ const probeId = id === PATCH_PROBE_MISMATCH_ID
204
+ ? "00000000-0000-4000-a000-000000000002"
205
+ : PATCH_PROBE_MISMATCH_ID;
206
+ try {
207
+ await backend.rawQuery({
208
+ path,
209
+ options: {
210
+ method: "PATCH",
211
+ body: JSON.stringify({ data: { type, id: probeId } }),
212
+ },
213
+ });
214
+ throw new Error(
215
+ "PATCH probe unexpectedly succeeded (2xx). The probe must fail after " +
216
+ "core's working-copy guard so no revision is written."
217
+ );
218
+ } catch (err) {
219
+ if (isWorkingCopyPatchError(err)) {
220
+ const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
221
+ throw new PatchBlockedError(
222
+ err instanceof Error ? err : new Error(String(err)),
223
+ { workingCopy },
224
+ );
225
+ }
226
+ if (isProbePassedWithoutSave(err)) {
227
+ return { probed: true, writable: true };
228
+ }
229
+ throw err;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * `backend.updateEntity` with the core working-copy 400 rewritten.
235
+ * @param {object} backend
236
+ * @param {object} input updateEntity argument.
237
+ * @returns {Promise<*>}
238
+ * @throws {PatchBlockedError|*}
239
+ */
240
+ export async function updateEntityGuarded(backend, input) {
241
+ try {
242
+ return await backend.updateEntity(input);
243
+ } catch (err) {
244
+ if (!isWorkingCopyPatchError(err)) throw err;
245
+ const workingCopy = await loadWorkingCopy(backend, input);
246
+ throw rewriteWorkingCopyPatchError(err, { workingCopy });
247
+ }
248
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Choose which entity body represents a write (#169).
3
+ *
4
+ * When relationships were sent, the canonical/default revision is the
5
+ * published node — after a draft ERR attach it still shows the *old* refs.
6
+ * Prefer `rel:working-copy`. If that alias is not addressable, return the
7
+ * PATCH body (or the canonical re-read) plus `_revision.relationshipsUnverified`.
8
+ */
9
+
10
+ /**
11
+ * @param {object} args
12
+ * @param {object} args.backend
13
+ * @param {string} args.entityType
14
+ * @param {string} args.bundle
15
+ * @param {string} args.id
16
+ * @param {boolean} args.relationshipsSent
17
+ * @param {?object} [args.patchResult] Canonicalised PATCH response body.
18
+ * @param {boolean} [args.preferCanonical] When no relationships were sent,
19
+ * re-GET the canonical resource (nodes do this for the persisted alias).
20
+ * @returns {Promise<object>} Entity to return, with `_revision` when relevant.
21
+ */
22
+ export async function readWrittenRevision({
23
+ backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
24
+ }) {
25
+ if (!relationshipsSent) {
26
+ if (preferCanonical && typeof backend.getEntity === "function") {
27
+ const fresh = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
28
+ return fresh ?? patchResult ?? { id };
29
+ }
30
+ return patchResult ?? { id };
31
+ }
32
+
33
+ // Content-moderation working-copy aliases are a node (host) feature. Other
34
+ // entity types keep the PATCH body and an unverified marker.
35
+ let workingCopy = null;
36
+ if (entityType === "node" && typeof backend.getEntity === "function") {
37
+ try {
38
+ workingCopy = await backend.getEntity({
39
+ entityType, bundle, id, resourceVersion: "rel:working-copy",
40
+ });
41
+ } catch {
42
+ workingCopy = null;
43
+ }
44
+ }
45
+ if (workingCopy) {
46
+ return {
47
+ ...workingCopy,
48
+ _revision: {
49
+ source: "working-copy",
50
+ note:
51
+ "Returned from rel:working-copy — the revision that was written, not the " +
52
+ "published default. Canonical re-reads hide a draft ERR attach (#169).",
53
+ },
54
+ };
55
+ }
56
+
57
+ let fallback = patchResult;
58
+ if (!fallback && preferCanonical && typeof backend.getEntity === "function") {
59
+ fallback = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
60
+ }
61
+ return {
62
+ ...(fallback ?? { id }),
63
+ _revision: {
64
+ source: patchResult ? "patch" : "canonical",
65
+ relationshipsUnverified: true,
66
+ note:
67
+ "Relationships were sent on this write. The body below is not the written " +
68
+ "revision (no addressable working copy). It is not proof an ERR field landed. " +
69
+ "Inspect rel:working-copy with drupal_get_revision, or treat the field as unverified (#169).",
70
+ },
71
+ };
72
+ }
package/src/tools/bulk.js CHANGED
@@ -14,7 +14,12 @@
14
14
  import { getSiteConfig } from "../lib/config.js";
15
15
  import { resolveBackend } from "../lib/backends/index.js";
16
16
  import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
17
- import { applySafeDraftDefault } from "../lib/moderation-default.js";
17
+ import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
18
+ import {
19
+ resolveErrRelationships, embedParagraphRef,
20
+ resolveParagraphRevisionId, missingParagraphRevisionError,
21
+ } from "../lib/err-relationships.js";
22
+ import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
18
23
 
19
24
  /**
20
25
  * Normalize an unknown thrown value into a human-readable message.
@@ -48,13 +53,20 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
48
53
  const item = rawItem || {};
49
54
  try {
50
55
  assertPublishAllowed(sec, item.attributes ?? {});
56
+ const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
51
57
  const entity = await backend.createEntity({
52
58
  entityType, bundle,
53
59
  attributes: item.attributes ?? {},
54
- relationships: item.relationships ?? {},
60
+ relationships: resolvedRelationships,
55
61
  });
56
62
  created += 1;
57
- results.push({ index, success: true, id: entity?.id });
63
+ const row = { index, success: true, id: entity?.id };
64
+ if (entityType === "paragraph" && entity?.id) {
65
+ const revisionId = await resolveParagraphRevisionId(backend, entity, bundle);
66
+ if (revisionId === null) throw missingParagraphRevisionError(entity.id);
67
+ row.relationshipData = embedParagraphRef(entity.bundle || bundle, entity.id, revisionId);
68
+ }
69
+ results.push(row);
58
70
  } catch (err) {
59
71
  failed += 1;
60
72
  results.push({ index, success: false, error: errorMessage(err) });
@@ -91,15 +103,28 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
91
103
  const item = rawItem || {};
92
104
  try {
93
105
  if (!item.id) throw new Error("Missing 'id' for update item");
106
+ let existing = null;
107
+ if (!hasExplicitModerationState(item.attributes ?? {})) {
108
+ try {
109
+ existing = (await backend.getEntity({ entityType, bundle, id: item.id })) ?? null;
110
+ } catch {
111
+ existing = null;
112
+ }
113
+ }
94
114
  const attributes = await applySafeDraftDefault({
95
115
  backend, entityType, bundle, id: item.id,
96
116
  attributes: item.attributes ?? {},
117
+ existingEntity: existing,
97
118
  });
98
119
  assertPublishAllowed(sec, attributes);
99
- const entity = await backend.updateEntity({
120
+ const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
121
+ await preflightPatchWritable({
122
+ backend, entityType, bundle, id: item.id, existing, attributes,
123
+ });
124
+ const entity = await updateEntityGuarded(backend, {
100
125
  entityType, bundle, id: item.id,
101
126
  attributes,
102
- relationships: item.relationships ?? {},
127
+ relationships: resolvedRelationships,
103
128
  });
104
129
  updated += 1;
105
130
  results.push({ index, success: true, id: entity?.id ?? item.id });
@@ -123,7 +148,7 @@ const itemAttributesSchema = {
123
148
  export const definitions = [
124
149
  {
125
150
  name: "drupal_bulk_create",
126
- description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Writes default to unpublished/draft.",
151
+ description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Paragraph items also return relationshipData with meta.target_revision_id for a later host attach. Writes default to unpublished/draft.",
127
152
  inputSchema: {
128
153
  type: "object", required: ["entityType", "bundle", "items"],
129
154
  properties: {
@@ -12,6 +12,12 @@ import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
13
  import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
14
14
  import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
15
+ import {
16
+ resolveErrRelationships, relationshipsWereSent, embedParagraphRef,
17
+ resolveParagraphRevisionId, missingParagraphRevisionError,
18
+ } from "../lib/err-relationships.js";
19
+ import { readWrittenRevision } from "../lib/write-revision.js";
20
+ import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
15
21
  import {
16
22
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
17
23
  redactCanonicalEntity, getSecuritySummary,
@@ -63,9 +69,16 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
63
69
  const sec = resolveSecurityConfig(site);
64
70
  assertWriteAllowed(sec, "create", entityType, bundle);
65
71
  assertPublishAllowed(sec, attributes);
66
- if (dryRun) return { dryRun: true, operation: "create", entityType, bundle, attributes, relationships };
67
72
  const backend = await resolveBackend(site);
68
- return shapeWriteResponse(await backend.createEntity({ entityType, bundle, attributes, relationships }), returning);
73
+ const resolvedRelationships = await resolveErrRelationships(backend, relationships);
74
+ if (dryRun) return { dryRun: true, operation: "create", entityType, bundle, attributes, relationships: resolvedRelationships };
75
+ const created = await backend.createEntity({ entityType, bundle, attributes, relationships: resolvedRelationships });
76
+ if (entityType === "paragraph") {
77
+ const revisionId = await resolveParagraphRevisionId(backend, created, bundle);
78
+ if (revisionId === null) throw missingParagraphRevisionError(created.id);
79
+ created.relationshipData = embedParagraphRef(created.bundle || bundle, created.id, revisionId);
80
+ }
81
+ return shapeWriteResponse(created, returning);
69
82
  }
70
83
 
71
84
  /**
@@ -103,9 +116,26 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
103
116
  backend, entityType, bundle, id, attributes, existingEntity: existing,
104
117
  });
105
118
  assertPublishAllowed(sec, safeAttributes);
106
- if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes: safeAttributes, relationships };
107
- const result = await backend.updateEntity({ entityType, bundle, id, attributes: safeAttributes, relationships });
108
- return shapeWriteResponse(flagUnrequestedStatusChange(result, existing, safeAttributes), returning);
119
+ const resolvedRelationships = await resolveErrRelationships(backend, relationships);
120
+ await preflightPatchWritable({
121
+ backend, entityType, bundle, id, existing, attributes: safeAttributes,
122
+ });
123
+ if (dryRun) {
124
+ return {
125
+ dryRun: true, operation: "update", entityType, bundle, id,
126
+ attributes: safeAttributes, relationships: resolvedRelationships,
127
+ };
128
+ }
129
+ const result = await updateEntityGuarded(backend, {
130
+ entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
131
+ });
132
+ const written = await readWrittenRevision({
133
+ backend, entityType, bundle, id,
134
+ relationshipsSent: relationshipsWereSent(resolvedRelationships),
135
+ patchResult: result,
136
+ preferCanonical: false,
137
+ });
138
+ return shapeWriteResponse(flagUnrequestedStatusChange(written, existing, safeAttributes), returning);
109
139
  }
110
140
 
111
141
  /**
@@ -246,7 +276,7 @@ export const definitions = [
246
276
  },
247
277
  {
248
278
  name: "drupal_entity_update",
249
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision).",
279
+ description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets an id-mismatch PATCH preflight runs first (including dryRun) so a core working-copy guard failure is reported before the real write and no revision is saved by the probe (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.",
250
280
  inputSchema: {
251
281
  type: "object", required: ["entityType", "bundle", "id"],
252
282
  properties: {
@@ -256,7 +286,7 @@ export const definitions = [
256
286
  id: { type: "string" },
257
287
  attributes: { type: "object" },
258
288
  relationships: { type: "object" },
259
- dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
289
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
260
290
  returning: RETURNING_SCHEMA,
261
291
  },
262
292
  },
@@ -15,6 +15,9 @@ import {
15
15
  } from "../lib/security.js";
16
16
  import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
17
17
  import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
18
+ import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
19
+ import { readWrittenRevision } from "../lib/write-revision.js";
20
+ import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
18
21
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
19
22
 
20
23
  /** Fallback language for an alias when the node exposes none. */
@@ -269,14 +272,15 @@ async function createNode({ site: siteName, type, title, body, summary, format,
269
272
  const bodyAttr = buildBodyAttribute(body, summary, format, site);
270
273
  if (bodyAttr) attributes.body = bodyAttr;
271
274
  assertPublishAllowed(sec, attributes);
272
- if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
273
275
  const backend = await resolveBackend(site);
276
+ const resolvedRelationships = await resolveErrRelationships(backend, relationships);
277
+ if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships: resolvedRelationships };
274
278
  // Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
275
279
  // `path` is omitted so pathauto generates the alias (DEV-116).
276
280
  const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
277
281
  if (pathAttr === undefined) delete attributes.path;
278
282
  else attributes.path = pathAttr;
279
- const created = await backend.createEntity({ entityType: "node", bundle: type, attributes, relationships });
283
+ const created = await backend.createEntity({ entityType: "node", bundle: type, attributes, relationships: resolvedRelationships });
280
284
  // Honest response: re-read so the persisted alias (explicit or pathauto-generated)
281
285
  // is reflected rather than the pre-alias write response.
282
286
  const fresh = await backend.getEntity({ entityType: "node", bundle: type, id: created.id }).catch(() => null);
@@ -334,7 +338,20 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
334
338
  backend, entityType: "node", bundle: type, id, attributes, existingEntity: existing,
335
339
  });
336
340
  assertPublishAllowed(sec, attributes);
337
- if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes, relationships };
341
+ // #192: resolve paragraph ERR identifiers before any host PATCH. An unresolved
342
+ // list would persist empty — fail the whole write instead.
343
+ const resolvedRelationships = await resolveErrRelationships(backend, relationships);
344
+ // #201: the core working-copy guard runs before deserialize. A no-op probe
345
+ // against the same canonical URL is a true preflight, including on dryRun.
346
+ await preflightPatchWritable({
347
+ backend, entityType: "node", bundle: type, id, existing, attributes,
348
+ });
349
+ if (dryRun) {
350
+ return {
351
+ dryRun: true, operation: "update", entityType: "node", bundle: type, id,
352
+ attributes, relationships: resolvedRelationships,
353
+ };
354
+ }
338
355
  // Alias handling (DEV-116): an explicit `path.alias` is set in place by
339
356
  // round-tripping the existing alias's pid (no duplicate); a path-less update
340
357
  // re-pins the current alias *with its pid* so the save can't revert/duplicate
@@ -342,11 +359,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
342
359
  const { pathAttr, redirect } = await resolvePathWrite({ backend, type, id, providedPath: attributes.path, isCreate: false });
343
360
  if (pathAttr === undefined) delete attributes.path;
344
361
  else attributes.path = pathAttr;
345
- await backend.updateEntity({ entityType: "node", bundle: type, id, attributes, relationships });
362
+ const patched = await updateEntityGuarded(backend, {
363
+ entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
364
+ });
346
365
  const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
347
- // Honest response: re-read persisted state so the returned `url` is the alias
348
- // that actually resolves, never the just-sent value.
349
- const fresh = await backend.getEntity({ entityType: "node", bundle: type, id }).catch(() => null);
366
+ // #169: when relationships were sent, the canonical re-read is the published
367
+ // revision and is not proof an ERR field landed. Prefer rel:working-copy.
368
+ const fresh = await readWrittenRevision({
369
+ backend, entityType: "node", bundle: type, id,
370
+ relationshipsSent: relationshipsWereSent(resolvedRelationships),
371
+ patchResult: patched,
372
+ preferCanonical: true,
373
+ });
350
374
  // #171: an unrequested published-state flip in the persisted node is
351
375
  // reported via _statusChanged rather than returned as a clean success.
352
376
  const flagged = flagUnrequestedStatusChange(fresh, existing, attributes);
@@ -441,7 +465,7 @@ export const definitions = [
441
465
  },
442
466
  {
443
467
  name: "drupal_update_node",
444
- description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`.",
468
+ description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets an id-mismatch PATCH preflight runs first — including on dryRun — so a core working-copy guard failure is reported before the real write and no revision is saved by the probe. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
445
469
  inputSchema: {
446
470
  type: "object", required: ["type", "id"],
447
471
  properties: {
@@ -455,8 +479,8 @@ export const definitions = [
455
479
  status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
456
480
  moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'." },
457
481
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
458
- relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }." },
459
- dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
482
+ relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot." },
483
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
460
484
  returning: RETURNING_SCHEMA,
461
485
  },
462
486
  },