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.
- package/.claude/commands/drupal-bulk-create.md +2 -2
- package/.claude/commands/drupal-create-paragraph.md +2 -2
- package/.claude/commands/drupal-entity-update.md +3 -3
- package/.claude/commands/drupal-get-paragraph.md +2 -2
- package/.claude/commands/drupal-list-revisions.md +2 -2
- package/.claude/commands/drupal-update-node.md +4 -4
- package/.claude/commands/drupal-update-paragraph.md +2 -2
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/lib/backends/backend-interface.js +4 -1
- package/src/lib/backends/jsonapi.js +16 -6
- package/src/lib/canonical.js +11 -1
- package/src/lib/err-relationships.js +286 -0
- package/src/lib/patch-preflight.js +176 -0
- package/src/lib/write-revision.js +72 -0
- package/src/tools/bulk.js +31 -6
- package/src/tools/entities.js +37 -7
- package/src/tools/nodes.js +34 -10
- package/src/tools/paragraphs.js +56 -42
- package/src/tools/revisions.js +42 -7
|
@@ -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:
|
|
60
|
+
relationships: resolvedRelationships,
|
|
55
61
|
});
|
|
56
62
|
created += 1;
|
|
57
|
-
|
|
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
|
|
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:
|
|
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: {
|
package/src/tools/entities.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
348
|
-
//
|
|
349
|
-
const fresh = await
|
|
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
|
|
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
|
},
|