drupal-mcp-connector 2.7.0 → 2.7.1

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.
@@ -0,0 +1,176 @@
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.
32
+ * Clearing the blocking row is revision surgery outside JSON:API.
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
+ * Thrown when the core working-copy PATCH guard rejects a write (or its probe).
43
+ */
44
+ export class PatchBlockedError extends Error {
45
+ /** @param {?Error} [cause] The original Drupal 400. */
46
+ constructor(cause) {
47
+ super(PATCH_BLOCKED_MESSAGE);
48
+ this.name = "PatchBlockedError";
49
+ this.code = PATCH_BLOCKED_CODE;
50
+ if (cause) this.cause = cause;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Whether an error is Drupal core's working-copy PATCH guard (core #2795279).
56
+ * @param {unknown} err
57
+ * @returns {boolean}
58
+ */
59
+ export function isWorkingCopyPatchError(err) {
60
+ return WORKING_COPY_PATCH_RE.test(String(err?.message || ""));
61
+ }
62
+
63
+ /**
64
+ * Rewrite a core working-copy 400 into {@link PatchBlockedError}; otherwise
65
+ * return the original value.
66
+ * @param {unknown} err
67
+ * @returns {unknown}
68
+ */
69
+ export function rewriteWorkingCopyPatchError(err) {
70
+ if (!isWorkingCopyPatchError(err)) return err;
71
+ return new PatchBlockedError(err instanceof Error ? err : new Error(String(err)));
72
+ }
73
+
74
+ /**
75
+ * Whether this update should run the PATCH probe.
76
+ * Skip unmoderated / non-revisionable bundles — the guard is about
77
+ * revisionable entities under content_moderation.
78
+ * @param {{existing?: ?object, attributes?: object}} input
79
+ * @returns {boolean}
80
+ */
81
+ export function shouldPreflightPatch({ existing, attributes } = {}) {
82
+ if (hasExplicitModerationState(attributes)) return true;
83
+ return entityLooksModerated(existing);
84
+ }
85
+
86
+ /**
87
+ * UUID used as `data.id` on the probe PATCH so it cannot match the URL
88
+ * entity. Core throws after the working-copy guard and before save.
89
+ * @see EntityResource::patchIndividual()
90
+ */
91
+ export const PATCH_PROBE_MISMATCH_ID = "00000000-0000-4000-a000-000000000001";
92
+
93
+ const ID_MISMATCH_RE = /does not match the ID in the payload/i;
94
+
95
+ /**
96
+ * Whether a probe error means the working-copy guard passed and no row
97
+ * was written (id mismatch, or a 422 during deserialize).
98
+ * @param {unknown} err
99
+ * @returns {boolean}
100
+ */
101
+ export function isProbePassedWithoutSave(err) {
102
+ const msg = String(err?.message || "");
103
+ return ID_MISMATCH_RE.test(msg) || /Drupal 422\b/.test(msg);
104
+ }
105
+
106
+ /**
107
+ * Probe the same guard core uses on the canonical PATCH URL.
108
+ *
109
+ * Sends a PATCH whose `data.id` does not match the URL entity. Core runs
110
+ * the working-copy check first; a match on that phrase means no row was
111
+ * written. An id-mismatch 400 (or deserialize 422) means the guard passed
112
+ * and save was not reached. A 2xx would have saved a revision and is
113
+ * treated as a probe failure. Do not treat "latest-version vid === default
114
+ * vid" as writable.
115
+ *
116
+ * @param {object} args
117
+ * @param {object} args.backend Backend with `rawQuery` + `resourcePath`.
118
+ * @param {string} args.entityType
119
+ * @param {string} args.bundle
120
+ * @param {string} args.id
121
+ * @param {?object} [args.existing]
122
+ * @param {object} [args.attributes]
123
+ * @returns {Promise<{probed: boolean, writable?: boolean|string, skipped?: string}>}
124
+ * @throws {PatchBlockedError} When the guard rejects the probe.
125
+ */
126
+ export async function preflightPatchWritable({
127
+ backend, entityType, bundle, id, existing, attributes,
128
+ }) {
129
+ if (!shouldPreflightPatch({ existing, attributes })) {
130
+ return { probed: false };
131
+ }
132
+ if (typeof backend?.rawQuery !== "function" || typeof backend?.resourcePath !== "function") {
133
+ return { probed: false, skipped: "backend cannot issue a raw PATCH probe" };
134
+ }
135
+ const path = `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
136
+ const type = `${entityType}--${bundle}`;
137
+ const probeId = id === PATCH_PROBE_MISMATCH_ID
138
+ ? "00000000-0000-4000-a000-000000000002"
139
+ : PATCH_PROBE_MISMATCH_ID;
140
+ try {
141
+ await backend.rawQuery({
142
+ path,
143
+ options: {
144
+ method: "PATCH",
145
+ body: JSON.stringify({ data: { type, id: probeId } }),
146
+ },
147
+ });
148
+ throw new Error(
149
+ "PATCH probe unexpectedly succeeded (2xx). The probe must fail after " +
150
+ "core's working-copy guard so no revision is written."
151
+ );
152
+ } catch (err) {
153
+ if (isWorkingCopyPatchError(err)) {
154
+ throw new PatchBlockedError(err instanceof Error ? err : new Error(String(err)));
155
+ }
156
+ if (isProbePassedWithoutSave(err)) {
157
+ return { probed: true, writable: true };
158
+ }
159
+ throw err;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * `backend.updateEntity` with the core working-copy 400 rewritten.
165
+ * @param {object} backend
166
+ * @param {object} input updateEntity argument.
167
+ * @returns {Promise<*>}
168
+ * @throws {PatchBlockedError|*}
169
+ */
170
+ export async function updateEntityGuarded(backend, input) {
171
+ try {
172
+ return await backend.updateEntity(input);
173
+ } catch (err) {
174
+ throw rewriteWorkingCopyPatchError(err);
175
+ }
176
+ }
@@ -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
  },