drupal-mcp-connector 3.1.0 → 3.2.0
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/.agents/commands/drupal-draft-nested-components.md +26 -0
- package/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +61 -0
- package/package.json +7 -7
- package/src/lib/backends/jsonapi.js +18 -5
- package/src/lib/canonical-draft.js +795 -0
- package/src/lib/component-draft.js +16 -0
- package/src/lib/nested-draft.js +1404 -0
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +34 -0
- package/src/lib/write-revision.js +66 -16
- package/src/tools/bulk.js +5 -9
- package/src/tools/entities.js +5 -11
- package/src/tools/index.js +2 -1
- package/src/tools/nested.js +68 -0
- package/src/tools/nodes.js +1 -1
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft open and continuation for library items and custom blocks (#420, #422).
|
|
3
|
+
*
|
|
4
|
+
* paragraphs_library_item and block_content use drupal_internal__revision_id.
|
|
5
|
+
* When the host inventory advertises open_draft, the first draft and a later
|
|
6
|
+
* continuation both use /mcp-draft. The body is the entity only: no meta and
|
|
7
|
+
* no mcp_components. If-Match is the live revision, or live:working when a
|
|
8
|
+
* forward draft already exists. The published revision and its paragraph
|
|
9
|
+
* pins are re-read afterwards. A continuation compares the forward pins with
|
|
10
|
+
* the pre-write working copy unless the caller submitted relationships.
|
|
11
|
+
*
|
|
12
|
+
* When that operation is absent, a published entity with no forward revision
|
|
13
|
+
* is still opened by a canonical JSON:API PATCH whose moderation_state is
|
|
14
|
+
* draft or review. A real forward revision is refused before that PATCH:
|
|
15
|
+
* saving the published default again creates another forward revision from
|
|
16
|
+
* the live copy and drops the existing draft.
|
|
17
|
+
*
|
|
18
|
+
* rel:working-copy is the latest revision. When its id differs from the
|
|
19
|
+
* default, a forward draft already exists. rel:latest-version is the latest
|
|
20
|
+
* default revision, not that draft. A 404, or a 403 whose message is
|
|
21
|
+
* Drupal's "No pending revision", on rel:working-copy means no draft. The
|
|
22
|
+
* same phrase at any other status fails closed. Any other 401/403, a
|
|
23
|
+
* timeout, or a missing revision id fails closed. A draft-inventory 404 or
|
|
24
|
+
* 405 is absence. Any other inventory failure fails closed. After the write
|
|
25
|
+
* the published revision, rel:working-copy, and the published paragraph pins
|
|
26
|
+
* (including order) are re-read. A completed 4xx is returned when that
|
|
27
|
+
* re-read matches the pre-write state. A lost response, or a 4xx whose
|
|
28
|
+
* re-read shows a change, is reported from the re-read and is not called a
|
|
29
|
+
* rollback. An unmoderated entity keeps the ordinary update.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { entityRevisionId } from "./write-revision.js";
|
|
33
|
+
import { httpStatusOf } from "./error-status.js";
|
|
34
|
+
import { paragraphPinsFromEntity } from "./err-relationships.js";
|
|
35
|
+
import { entityLooksModerated } from "./moderation-default.js";
|
|
36
|
+
import { supportsSentinelDraft } from "./sentinel-draft.js";
|
|
37
|
+
import { OPEN_DRAFT_OPERATION } from "./component-draft.js";
|
|
38
|
+
|
|
39
|
+
/** Entity types that stay on the canonical PATCH unless open_draft is advertised. */
|
|
40
|
+
export const CANONICAL_DRAFT_TYPES = new Set(["paragraphs_library_item", "block_content"]);
|
|
41
|
+
|
|
42
|
+
/** @param {?string} entityType */
|
|
43
|
+
export function isCanonicalDraftType(entityType) {
|
|
44
|
+
return CANONICAL_DRAFT_TYPES.has(entityType);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stable code when a revision id or version alias cannot be verified. */
|
|
48
|
+
export const REVISION_IDENTITY_CODE = "REVISION_IDENTITY";
|
|
49
|
+
|
|
50
|
+
/** Stable code when a forward draft has no governed continuation route. */
|
|
51
|
+
export const DRAFT_CONTINUATION_UNSUPPORTED_CODE = "DRAFT_CONTINUATION_UNSUPPORTED";
|
|
52
|
+
|
|
53
|
+
/** Stable code when a sent write could not be confirmed from a re-read. */
|
|
54
|
+
export const WRITE_RESULT_UNCERTAIN_CODE = "WRITE_RESULT_UNCERTAIN";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} entityType
|
|
58
|
+
* @param {string} detail
|
|
59
|
+
*/
|
|
60
|
+
export class RevisionIdentityError extends Error {
|
|
61
|
+
constructor(entityType, detail) {
|
|
62
|
+
super(
|
|
63
|
+
`The revision identity of this ${entityType} could not be verified (${detail}). ` +
|
|
64
|
+
"No write was attempted. See connector #420."
|
|
65
|
+
);
|
|
66
|
+
this.name = "RevisionIdentityError";
|
|
67
|
+
this.code = REVISION_IDENTITY_CODE;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} entityType
|
|
73
|
+
* @param {number|string} liveVid
|
|
74
|
+
* @param {number|string} latestVid
|
|
75
|
+
*/
|
|
76
|
+
export class UnsupportedDraftContinuationError extends Error {
|
|
77
|
+
constructor(entityType, liveVid, latestVid) {
|
|
78
|
+
super(
|
|
79
|
+
`A forward draft of this ${entityType} exists (published revision ${liveVid}, ` +
|
|
80
|
+
`latest revision ${latestVid}), and there is no governed continuation endpoint for it. ` +
|
|
81
|
+
"No write was attempted. Continue or discard that draft in Drupal, then retry. See connector #420."
|
|
82
|
+
);
|
|
83
|
+
this.name = "UnsupportedDraftContinuationError";
|
|
84
|
+
this.code = DRAFT_CONTINUATION_UNSUPPORTED_CODE;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} entityType
|
|
90
|
+
* @param {string} detail
|
|
91
|
+
* @returns {Error}
|
|
92
|
+
*/
|
|
93
|
+
export function uncertainWriteError(entityType, detail) {
|
|
94
|
+
const err = new Error(
|
|
95
|
+
`The ${entityType} write result is uncertain (${detail}). ` +
|
|
96
|
+
"Re-read the default revision and rel:latest-version before retrying. " +
|
|
97
|
+
"Do not assume the published revision is unchanged. See connector #420."
|
|
98
|
+
);
|
|
99
|
+
err.name = "UncertainWriteError";
|
|
100
|
+
err.code = WRITE_RESULT_UNCERTAIN_CODE;
|
|
101
|
+
return err;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load the default revision before an update.
|
|
106
|
+
* Canonical-draft types fail closed when that read is missing or denied.
|
|
107
|
+
* Other types keep the previous contract: an explicit moderation_state skips
|
|
108
|
+
* the read, and an unreadable target leaves the server-side gate in charge.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} backend
|
|
111
|
+
* @param {{entityType: string, bundle: string, id: string, attributes?: object}} ref
|
|
112
|
+
* @returns {Promise<?object>}
|
|
113
|
+
*/
|
|
114
|
+
export async function readUpdateTarget(backend, { entityType, bundle, id, attributes }) {
|
|
115
|
+
if (!isCanonicalDraftType(entityType)
|
|
116
|
+
&& attributes
|
|
117
|
+
&& Object.prototype.hasOwnProperty.call(attributes, "moderation_state")) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const existing = (await backend.getEntity({ entityType, bundle, id })) ?? null;
|
|
122
|
+
if (isCanonicalDraftType(entityType) && !existing) {
|
|
123
|
+
throw new RevisionIdentityError(entityType, "the default revision was not returned");
|
|
124
|
+
}
|
|
125
|
+
return existing;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof RevisionIdentityError) throw err;
|
|
128
|
+
if (!isCanonicalDraftType(entityType)) return null;
|
|
129
|
+
const status = httpStatusOf(err);
|
|
130
|
+
const detail = status === 401 || status === 403
|
|
131
|
+
? "reading the default revision was denied"
|
|
132
|
+
: "the default revision could not be read";
|
|
133
|
+
throw new RevisionIdentityError(entityType, detail);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Decide the PATCH target for a library item or custom block.
|
|
139
|
+
* An echoed default, confirmed by rel:latest-version, is not a draft.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} backend
|
|
142
|
+
* @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
|
|
143
|
+
* @returns {Promise<{resourceVersion: undefined, workingCopy: ?object, liveVid: number|string, workingVid: ?number|string}>}
|
|
144
|
+
*/
|
|
145
|
+
export async function resolveCanonicalDraftIdentity(backend, { entityType, bundle, id, existing }) {
|
|
146
|
+
const ref = { entityType, bundle, id };
|
|
147
|
+
const working = await readVersion(backend, ref, "rel:working-copy");
|
|
148
|
+
if (working.kind === "denied" || working.kind === "failed" || working.kind === "unavailable") {
|
|
149
|
+
throw new RevisionIdentityError(entityType, working.detail);
|
|
150
|
+
}
|
|
151
|
+
assertSameResource(entityType, id, working.entity, "rel:working-copy");
|
|
152
|
+
|
|
153
|
+
let live = existing ?? null;
|
|
154
|
+
let liveVid = entityRevisionId(live, entityType);
|
|
155
|
+
if (liveVid === null) {
|
|
156
|
+
const canonical = await readVersion(backend, ref);
|
|
157
|
+
if (canonical.kind !== "ok") {
|
|
158
|
+
throw new RevisionIdentityError(entityType, canonical.detail || "the default revision could not be read");
|
|
159
|
+
}
|
|
160
|
+
assertSameResource(entityType, id, canonical.entity, "the default revision");
|
|
161
|
+
live = canonical.entity;
|
|
162
|
+
liveVid = entityRevisionId(live, entityType);
|
|
163
|
+
}
|
|
164
|
+
const aliasVid = working.kind === "ok" ? entityRevisionId(working.entity, entityType) : null;
|
|
165
|
+
if (working.kind === "ok" && (aliasVid === null || liveVid === null)) {
|
|
166
|
+
throw new RevisionIdentityError(entityType, "rel:working-copy omitted a revision id");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const latest = await readVersion(backend, ref, "rel:latest-version");
|
|
170
|
+
if (latest.kind !== "ok") {
|
|
171
|
+
throw new RevisionIdentityError(entityType, latest.detail);
|
|
172
|
+
}
|
|
173
|
+
assertSameResource(entityType, id, latest.entity, "rel:latest-version");
|
|
174
|
+
const latestVid = entityRevisionId(latest.entity, entityType);
|
|
175
|
+
if (latestVid === null || liveVid === null) {
|
|
176
|
+
throw new RevisionIdentityError(entityType, "rel:latest-version omitted a revision id");
|
|
177
|
+
}
|
|
178
|
+
if (String(latestVid) !== String(liveVid)) {
|
|
179
|
+
throw new RevisionIdentityError(entityType, "rel:latest-version did not match the default revision");
|
|
180
|
+
}
|
|
181
|
+
const continuing = aliasVid !== null && String(aliasVid) !== String(liveVid);
|
|
182
|
+
const openingPublished = entityLooksModerated(live) && live.status === true && !continuing;
|
|
183
|
+
const governedDraft = (continuing || openingPublished)
|
|
184
|
+
? await selectGovernedDraft(backend, ref, { liveVid, workingVid: aliasVid })
|
|
185
|
+
: null;
|
|
186
|
+
if (!governedDraft && continuing) {
|
|
187
|
+
throw new UnsupportedDraftContinuationError(entityType, liveVid, aliasVid);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
resourceVersion: undefined,
|
|
191
|
+
workingCopy: working.kind === "ok" ? working.entity : null,
|
|
192
|
+
liveVid,
|
|
193
|
+
workingVid: aliasVid,
|
|
194
|
+
...(governedDraft ? { governedDraft } : {}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Canonical PATCH plus a before/after revision read.
|
|
200
|
+
* Call only when no Sentinel draft pair was selected.
|
|
201
|
+
*
|
|
202
|
+
* @param {object} backend
|
|
203
|
+
* @param {object} input updateEntity argument.
|
|
204
|
+
* @returns {Promise<object>}
|
|
205
|
+
*/
|
|
206
|
+
export async function writeCanonicalModeratedEntity(backend, input) {
|
|
207
|
+
const { entityType, bundle, id, attributes = {}, relationships } = input;
|
|
208
|
+
const ref = { entityType, bundle, id };
|
|
209
|
+
const preview = await readVersion(backend, ref);
|
|
210
|
+
if (preview.kind === "ok" && !entityLooksModerated(preview.entity)) {
|
|
211
|
+
return backend.updateEntity(input);
|
|
212
|
+
}
|
|
213
|
+
if (preview.kind !== "ok") {
|
|
214
|
+
throw new RevisionIdentityError(entityType, preview.detail || "the default revision could not be read");
|
|
215
|
+
}
|
|
216
|
+
const before = await readPair(backend, ref, "before");
|
|
217
|
+
if (String(before.latestVid) !== String(before.liveVid)) {
|
|
218
|
+
throw new RevisionIdentityError(entityType, "rel:latest-version did not match the default revision");
|
|
219
|
+
}
|
|
220
|
+
const continuing = before.workingVid !== null && String(before.workingVid) !== String(before.liveVid);
|
|
221
|
+
const openingDraft = entityLooksModerated(before.entity) && before.entity.status === true;
|
|
222
|
+
if (openingDraft && !isForwardModerationState(attributes.moderation_state)) {
|
|
223
|
+
throw new RevisionIdentityError(
|
|
224
|
+
entityType,
|
|
225
|
+
"a published moderated entity requires moderation_state draft or review"
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const governed = (continuing || openingDraft)
|
|
229
|
+
? await selectGovernedDraft(backend, ref, before)
|
|
230
|
+
: null;
|
|
231
|
+
if (governed) {
|
|
232
|
+
return writeGovernedCanonicalDraft(backend, input, before, governed);
|
|
233
|
+
}
|
|
234
|
+
if (continuing) {
|
|
235
|
+
throw new UnsupportedDraftContinuationError(entityType, before.liveVid, before.workingVid);
|
|
236
|
+
}
|
|
237
|
+
let result;
|
|
238
|
+
try {
|
|
239
|
+
result = await backend.updateEntity(input);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (isDefinitiveClientError(err)) {
|
|
242
|
+
const unchanged = await publishedStateUnchanged(backend, ref, before);
|
|
243
|
+
if (unchanged === true) {
|
|
244
|
+
if (err instanceof Error) {
|
|
245
|
+
err.message +=
|
|
246
|
+
" Re-read matched the published revision, rel:working-copy, and paragraph pins from before the request.";
|
|
247
|
+
}
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
throw uncertainWriteError(entityType, await lossDetail(backend, ref, err));
|
|
252
|
+
}
|
|
253
|
+
if (!result || result.id !== id) {
|
|
254
|
+
throw uncertainWriteError(entityType, await lossDetail(
|
|
255
|
+
backend, ref, new Error("the write response did not identify this entity")
|
|
256
|
+
));
|
|
257
|
+
}
|
|
258
|
+
const after = await readPair(backend, ref, "after");
|
|
259
|
+
if (openingDraft) {
|
|
260
|
+
assertPublishedPreserved(entityType, before, after, relationships);
|
|
261
|
+
} else if (submittedParagraphPins(relationships)) {
|
|
262
|
+
const written = after.workingVid !== null && String(after.workingVid) !== String(after.liveVid)
|
|
263
|
+
? after.working
|
|
264
|
+
: after.entity;
|
|
265
|
+
assertSubmittedPins(entityType, written, relationships);
|
|
266
|
+
}
|
|
267
|
+
return annotatedResult(result, before, after);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const VID_RE = /^[1-9]\d*$/;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {unknown} vid
|
|
274
|
+
* @returns {boolean}
|
|
275
|
+
*/
|
|
276
|
+
function validVid(vid) {
|
|
277
|
+
return VID_RE.test(String(vid ?? "")) && Number.isSafeInteger(Number(vid));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Library and custom-block inventory. A 404 or 405 is absence. Permission,
|
|
282
|
+
* transport, and a malformed inventory fail closed. Node and media inventory
|
|
283
|
+
* stays on readNodeDraftInventory.
|
|
284
|
+
*
|
|
285
|
+
* @param {object} backend
|
|
286
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
287
|
+
* @returns {Promise<?object>}
|
|
288
|
+
*/
|
|
289
|
+
async function readCanonicalDraftInventory(backend, ref) {
|
|
290
|
+
if (!isCanonicalDraftType(ref.entityType) || !supportsSentinelDraft(backend)) return null;
|
|
291
|
+
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
292
|
+
throw new RevisionIdentityError(ref.entityType, "the backend cannot read the draft inventory");
|
|
293
|
+
}
|
|
294
|
+
let result;
|
|
295
|
+
try {
|
|
296
|
+
result = await backend.rawQuery({
|
|
297
|
+
path: `${backend.resourcePath(ref.entityType, ref.bundle)}/${encodeURIComponent(ref.id)}/mcp-translations`,
|
|
298
|
+
options: { method: "GET" },
|
|
299
|
+
});
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const status = httpStatusOf(error);
|
|
302
|
+
if (status === 404 || status === 405) return null;
|
|
303
|
+
const detail = status === 401 || status === 403
|
|
304
|
+
? "reading the draft inventory was denied"
|
|
305
|
+
: "the draft inventory could not be read";
|
|
306
|
+
throw new RevisionIdentityError(ref.entityType, detail);
|
|
307
|
+
}
|
|
308
|
+
const inventory = result?.meta;
|
|
309
|
+
if (!inventory || typeof inventory !== "object" || !validVid(inventory.live?.vid)
|
|
310
|
+
|| (inventory.working && !validVid(inventory.working.vid))) {
|
|
311
|
+
throw new RevisionIdentityError(ref.entityType, "the draft inventory omitted a revision id");
|
|
312
|
+
}
|
|
313
|
+
return inventory;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Select /mcp-draft only when this host advertises open_draft and the
|
|
318
|
+
* inventory vids agree with the JSON:API default and working copy.
|
|
319
|
+
*
|
|
320
|
+
* @param {object} backend
|
|
321
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
322
|
+
* @param {{liveVid: number|string, workingVid: ?number|string}} identity
|
|
323
|
+
* @returns {Promise<?{live: string, working: string, langcode: string}>}
|
|
324
|
+
*/
|
|
325
|
+
async function selectGovernedDraft(backend, ref, identity) {
|
|
326
|
+
const inventory = await readCanonicalDraftInventory(backend, ref);
|
|
327
|
+
if (!inventory) return null;
|
|
328
|
+
const operations = Array.isArray(inventory.operations) ? inventory.operations : [];
|
|
329
|
+
if (!operations.includes(OPEN_DRAFT_OPERATION)) return null;
|
|
330
|
+
if (String(inventory.live.vid) !== String(identity.liveVid)) {
|
|
331
|
+
throw new RevisionIdentityError(
|
|
332
|
+
ref.entityType,
|
|
333
|
+
"the draft inventory live revision did not match the default revision"
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const continuing = identity.workingVid !== null
|
|
337
|
+
&& identity.workingVid !== undefined
|
|
338
|
+
&& String(identity.workingVid) !== String(identity.liveVid);
|
|
339
|
+
if (continuing) {
|
|
340
|
+
if (inventory.working?.vid === undefined || inventory.working?.vid === null
|
|
341
|
+
|| String(inventory.working.vid) !== String(identity.workingVid)) {
|
|
342
|
+
throw new RevisionIdentityError(
|
|
343
|
+
ref.entityType,
|
|
344
|
+
"the draft inventory working revision did not match rel:working-copy"
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
} else if (inventory.working?.vid && String(inventory.working.vid) !== String(identity.liveVid)) {
|
|
348
|
+
throw new RevisionIdentityError(
|
|
349
|
+
ref.entityType,
|
|
350
|
+
"the draft inventory reported a working revision rel:working-copy did not"
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const langcode = typeof inventory.defaultLangcode === "string" ? inventory.defaultLangcode : "";
|
|
354
|
+
if (!langcode || /[\r\n]/.test(langcode)) {
|
|
355
|
+
throw new RevisionIdentityError(ref.entityType, "the draft inventory omitted the default language");
|
|
356
|
+
}
|
|
357
|
+
if (continuing) {
|
|
358
|
+
const row = (inventory.working?.translations ?? []).find((entry) => entry?.langcode === langcode);
|
|
359
|
+
const state = typeof row?.moderation_state === "string" ? row.moderation_state : "";
|
|
360
|
+
if (!row || row.status === true || state === "published") {
|
|
361
|
+
throw new RevisionIdentityError(ref.entityType, "the working language is not an unpublished draft");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
live: String(identity.liveVid),
|
|
366
|
+
working: continuing ? String(identity.workingVid) : "",
|
|
367
|
+
langcode,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* One /mcp-draft for a library item or custom block. No meta.
|
|
373
|
+
*
|
|
374
|
+
* @param {object} backend
|
|
375
|
+
* @param {object} spec
|
|
376
|
+
* @returns {Promise<object>}
|
|
377
|
+
*/
|
|
378
|
+
function governedCanonicalDraftRequest(backend, {
|
|
379
|
+
entityType, bundle, id, attributes, relationships, live, working, langcode, preflight,
|
|
380
|
+
}) {
|
|
381
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes: attributes ?? {} };
|
|
382
|
+
if (relationships && typeof relationships === "object" && Object.keys(relationships).length) {
|
|
383
|
+
data.relationships = relationships;
|
|
384
|
+
}
|
|
385
|
+
return backend.rawQuery({
|
|
386
|
+
path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
|
|
387
|
+
options: {
|
|
388
|
+
method: "PATCH",
|
|
389
|
+
headers: {
|
|
390
|
+
"If-Match": working ? `"${live}:${working}"` : `"${live}"`,
|
|
391
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
392
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
393
|
+
},
|
|
394
|
+
body: JSON.stringify({ data }),
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Non-saving /mcp-draft check for a library item or custom block.
|
|
401
|
+
* The saving request reads the inventory again and does not reuse this result.
|
|
402
|
+
*
|
|
403
|
+
* @param {object} backend
|
|
404
|
+
* @param {object} input
|
|
405
|
+
* @returns {Promise<void>}
|
|
406
|
+
*/
|
|
407
|
+
export async function preflightGovernedCanonicalDraft(backend, input) {
|
|
408
|
+
const governed = input.governedDraft;
|
|
409
|
+
const checked = await governedCanonicalDraftRequest(backend, {
|
|
410
|
+
entityType: input.entityType,
|
|
411
|
+
bundle: input.bundle,
|
|
412
|
+
id: input.id,
|
|
413
|
+
attributes: input.attributes,
|
|
414
|
+
relationships: input.relationships,
|
|
415
|
+
live: governed.live,
|
|
416
|
+
working: governed.working,
|
|
417
|
+
langcode: governed.langcode,
|
|
418
|
+
preflight: true,
|
|
419
|
+
});
|
|
420
|
+
const meta = checked?.meta;
|
|
421
|
+
const working = governed.working || "";
|
|
422
|
+
if (meta?.draft_preflight !== true || String(meta.live) !== String(governed.live)
|
|
423
|
+
|| String(meta.working ?? "") !== working
|
|
424
|
+
|| (!working && meta.operation !== OPEN_DRAFT_OPERATION)) {
|
|
425
|
+
throw new Error("The site did not confirm a non-saving draft preflight. No write was attempted.");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Saving /mcp-draft after a fresh inventory read.
|
|
431
|
+
*
|
|
432
|
+
* @param {object} backend
|
|
433
|
+
* @param {object} input
|
|
434
|
+
* @param {object} before
|
|
435
|
+
* @param {{live: string, working: string, langcode: string}} governed
|
|
436
|
+
* @returns {Promise<object>}
|
|
437
|
+
*/
|
|
438
|
+
async function writeGovernedCanonicalDraft(backend, input, before, governed) {
|
|
439
|
+
const { entityType, bundle, id, attributes = {}, relationships } = input;
|
|
440
|
+
const ref = { entityType, bundle, id };
|
|
441
|
+
const continuing = governed.working !== "";
|
|
442
|
+
let result;
|
|
443
|
+
try {
|
|
444
|
+
result = await governedCanonicalDraftRequest(backend, {
|
|
445
|
+
entityType, bundle, id, attributes, relationships,
|
|
446
|
+
live: governed.live,
|
|
447
|
+
working: governed.working,
|
|
448
|
+
langcode: governed.langcode,
|
|
449
|
+
preflight: false,
|
|
450
|
+
});
|
|
451
|
+
} catch (err) {
|
|
452
|
+
if (isDefinitiveClientError(err)) {
|
|
453
|
+
const unchanged = await publishedStateUnchanged(backend, ref, before);
|
|
454
|
+
if (unchanged === true) {
|
|
455
|
+
if (err instanceof Error) {
|
|
456
|
+
err.message +=
|
|
457
|
+
" Re-read matched the published revision, rel:working-copy, and paragraph pins from before the request.";
|
|
458
|
+
}
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
throw uncertainWriteError(entityType, await lossDetail(backend, ref, err));
|
|
463
|
+
}
|
|
464
|
+
const writtenId = result?.data?.id ?? result?.id;
|
|
465
|
+
if (!result || writtenId !== id) {
|
|
466
|
+
throw uncertainWriteError(entityType, await lossDetail(
|
|
467
|
+
backend, ref, new Error("the write response did not identify this entity")
|
|
468
|
+
));
|
|
469
|
+
}
|
|
470
|
+
const after = await readPair(backend, ref, "after");
|
|
471
|
+
if (entityLooksModerated(before.entity) && before.entity.status === true) {
|
|
472
|
+
assertPublishedPreserved(entityType, before, after, relationships);
|
|
473
|
+
} else if (submittedParagraphPins(relationships)) {
|
|
474
|
+
const written = after.workingVid !== null && String(after.workingVid) !== String(after.liveVid)
|
|
475
|
+
? after.working
|
|
476
|
+
: after.entity;
|
|
477
|
+
assertSubmittedPins(entityType, written, relationships);
|
|
478
|
+
}
|
|
479
|
+
if (continuing && String(after.workingVid) === String(before.workingVid)) {
|
|
480
|
+
throw uncertainWriteError(entityType, "the working revision did not advance");
|
|
481
|
+
}
|
|
482
|
+
return annotatedResult(result, before, after);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Drupal's working-copy alias reports a missing draft as 403
|
|
487
|
+
* "No pending revision", not as 404. That phrase is absence only on 403.
|
|
488
|
+
* Any other 401/403 is a denied read. The phrase on 5xx or a timeout is a
|
|
489
|
+
* failed read.
|
|
490
|
+
*
|
|
491
|
+
* @param {object} backend
|
|
492
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
493
|
+
* @param {string} [resourceVersion]
|
|
494
|
+
* @returns {Promise<{kind: string, entity?: object, detail: string}>}
|
|
495
|
+
*/
|
|
496
|
+
async function readVersion(backend, ref, resourceVersion) {
|
|
497
|
+
const label = resourceVersion || "the default revision";
|
|
498
|
+
if (typeof backend?.getEntity !== "function") {
|
|
499
|
+
return { kind: "unavailable", detail: "the backend cannot read revisions" };
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
const entity = await backend.getEntity({
|
|
503
|
+
entityType: ref.entityType,
|
|
504
|
+
bundle: ref.bundle,
|
|
505
|
+
id: ref.id,
|
|
506
|
+
...(resourceVersion ? { resourceVersion } : {}),
|
|
507
|
+
});
|
|
508
|
+
if (!entity) return { kind: "absent", detail: `${label} was not found` };
|
|
509
|
+
return { kind: "ok", entity, detail: "" };
|
|
510
|
+
} catch (err) {
|
|
511
|
+
const status = httpStatusOf(err);
|
|
512
|
+
const message = String(err?.message || err || "");
|
|
513
|
+
const missingDraft = resourceVersion === "rel:working-copy"
|
|
514
|
+
&& status === 403
|
|
515
|
+
&& /no pending revision/i.test(message);
|
|
516
|
+
if (status === 404 || missingDraft) {
|
|
517
|
+
return { kind: "absent", detail: `${label} was not found` };
|
|
518
|
+
}
|
|
519
|
+
if (status === 401 || status === 403) {
|
|
520
|
+
return { kind: "denied", detail: `${label} was denied` };
|
|
521
|
+
}
|
|
522
|
+
return { kind: "failed", detail: `${label} could not be read` };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* @param {string} entityType
|
|
528
|
+
* @param {string} id
|
|
529
|
+
* @param {?object} entity
|
|
530
|
+
* @param {string} label
|
|
531
|
+
*/
|
|
532
|
+
function assertSameResource(entityType, id, entity, label, when = "before") {
|
|
533
|
+
if (entity?.id && entity.id !== id) {
|
|
534
|
+
const detail = `${label} returned ${entity.id} instead of ${id}`;
|
|
535
|
+
if (when === "after") throw uncertainWriteError(entityType, detail);
|
|
536
|
+
throw new RevisionIdentityError(entityType, detail);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Read the default revision and rel:latest-version.
|
|
542
|
+
* A forward revision is returned to the caller; this function does not refuse it.
|
|
543
|
+
* `when` is "before" (no request sent yet) or "after" (the response must not
|
|
544
|
+
* claim the write was skipped).
|
|
545
|
+
*
|
|
546
|
+
* @param {object} backend
|
|
547
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
548
|
+
* @param {"before"|"after"} when
|
|
549
|
+
* @returns {Promise<{entity: object, latest: object, working: ?object, liveVid: number|string, latestVid: number|string, workingVid: ?number|string, pins: object[]}>}
|
|
550
|
+
*/
|
|
551
|
+
async function readPair(backend, ref, when) {
|
|
552
|
+
const fail = (detail) => {
|
|
553
|
+
if (when === "after") throw uncertainWriteError(ref.entityType, detail);
|
|
554
|
+
throw new RevisionIdentityError(ref.entityType, detail);
|
|
555
|
+
};
|
|
556
|
+
const live = await readVersion(backend, ref);
|
|
557
|
+
if (live.kind !== "ok") fail(live.detail);
|
|
558
|
+
assertSameResource(ref.entityType, ref.id, live.entity, "the default revision", when);
|
|
559
|
+
const latest = await readVersion(backend, ref, "rel:latest-version");
|
|
560
|
+
if (latest.kind !== "ok") fail(latest.detail);
|
|
561
|
+
assertSameResource(ref.entityType, ref.id, latest.entity, "rel:latest-version", when);
|
|
562
|
+
const working = await readVersion(backend, ref, "rel:working-copy");
|
|
563
|
+
if (working.kind === "denied" || working.kind === "failed" || working.kind === "unavailable") {
|
|
564
|
+
fail(working.detail);
|
|
565
|
+
}
|
|
566
|
+
if (working.kind === "ok") {
|
|
567
|
+
assertSameResource(ref.entityType, ref.id, working.entity, "rel:working-copy", when);
|
|
568
|
+
}
|
|
569
|
+
const liveVid = entityRevisionId(live.entity, ref.entityType);
|
|
570
|
+
const latestVid = entityRevisionId(latest.entity, ref.entityType);
|
|
571
|
+
const workingVid = working.kind === "ok" ? entityRevisionId(working.entity, ref.entityType) : null;
|
|
572
|
+
if (liveVid === null || latestVid === null || (working.kind === "ok" && workingVid === null)) {
|
|
573
|
+
fail(when === "after"
|
|
574
|
+
? "a revision id was missing after the write"
|
|
575
|
+
: "a revision id was missing immediately before the write");
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
entity: live.entity,
|
|
579
|
+
latest: latest.entity,
|
|
580
|
+
working: working.kind === "ok" ? working.entity : null,
|
|
581
|
+
liveVid,
|
|
582
|
+
latestVid,
|
|
583
|
+
workingVid,
|
|
584
|
+
pins: normalizedPins(live.entity),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* @param {string} entityType
|
|
590
|
+
* @param {{liveVid: number|string, pins: object[]}} before
|
|
591
|
+
* @param {{entity: object, working: ?object, liveVid: number|string, latestVid: number|string, workingVid: ?number|string, pins: object[]}} after
|
|
592
|
+
* @param {?object} relationships
|
|
593
|
+
*/
|
|
594
|
+
function assertPublishedPreserved(entityType, before, after, relationships) {
|
|
595
|
+
const problems = [];
|
|
596
|
+
if (String(after.liveVid) !== String(before.liveVid)) {
|
|
597
|
+
problems.push(`published revision was ${before.liveVid} before the write and is ${after.liveVid} after`);
|
|
598
|
+
}
|
|
599
|
+
if (String(after.latestVid) !== String(after.liveVid)) {
|
|
600
|
+
problems.push(`rel:latest-version was ${after.latestVid} after the write and the default revision was ${after.liveVid}`);
|
|
601
|
+
}
|
|
602
|
+
if (pinKey(after.pins) !== pinKey(before.pins)) {
|
|
603
|
+
problems.push("published paragraph pins changed");
|
|
604
|
+
}
|
|
605
|
+
const continuing = before.workingVid !== null && String(before.workingVid) !== String(before.liveVid);
|
|
606
|
+
if (after.workingVid === null || String(after.workingVid) === String(before.liveVid)) {
|
|
607
|
+
problems.push("no forward revision was verified");
|
|
608
|
+
} else if (continuing && String(after.workingVid) === String(before.workingVid)) {
|
|
609
|
+
problems.push("the working revision did not advance");
|
|
610
|
+
}
|
|
611
|
+
const submitted = submittedParagraphPins(relationships);
|
|
612
|
+
const forwardPins = normalizedPins(after.working);
|
|
613
|
+
const baseline = continuing ? normalizedPins(before.working) : before.pins;
|
|
614
|
+
if (submitted) {
|
|
615
|
+
const landed = forwardPins.filter((pin) => submitted.fields.has(pin.field));
|
|
616
|
+
if (pinKey(landed) !== pinKey(submitted.pins)) {
|
|
617
|
+
problems.push("the forward revision does not pin the submitted paragraphs");
|
|
618
|
+
}
|
|
619
|
+
} else if (pinKey(forwardPins) !== pinKey(baseline)) {
|
|
620
|
+
problems.push("the forward revision changed paragraph pins that were not submitted");
|
|
621
|
+
}
|
|
622
|
+
if (problems.length) {
|
|
623
|
+
throw uncertainWriteError(entityType, problems.join("; "));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* @param {string} entityType
|
|
629
|
+
* @param {object} latest
|
|
630
|
+
* @param {?object} relationships
|
|
631
|
+
*/
|
|
632
|
+
function assertSubmittedPins(entityType, latest, relationships) {
|
|
633
|
+
const submitted = submittedParagraphPins(relationships);
|
|
634
|
+
if (!submitted) return;
|
|
635
|
+
const landed = normalizedPins(latest).filter((pin) => submitted.fields.has(pin.field));
|
|
636
|
+
if (pinKey(landed) !== pinKey(submitted.pins)) {
|
|
637
|
+
throw uncertainWriteError(entityType, "the written revision does not pin the submitted paragraphs");
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* @param {object} result
|
|
643
|
+
* @param {{liveVid: number|string}} before
|
|
644
|
+
* @param {{working: ?object, liveVid: number|string, workingVid: ?number|string}} after
|
|
645
|
+
* @returns {object}
|
|
646
|
+
*/
|
|
647
|
+
function annotatedResult(result, before, after) {
|
|
648
|
+
if (after.workingVid === null || String(after.workingVid) === String(after.liveVid)) return result;
|
|
649
|
+
return {
|
|
650
|
+
...after.working,
|
|
651
|
+
_revisions: { live: before.liveVid, working: after.workingVid },
|
|
652
|
+
_revision: {
|
|
653
|
+
source: "working-copy",
|
|
654
|
+
note:
|
|
655
|
+
"Returned from rel:working-copy. The published default revision and its " +
|
|
656
|
+
"paragraph pins were re-read and matched the revision from before the write.",
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* @param {object} backend
|
|
663
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
664
|
+
* @param {unknown} err
|
|
665
|
+
* @returns {Promise<string>}
|
|
666
|
+
*/
|
|
667
|
+
async function lossDetail(backend, ref, err) {
|
|
668
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
669
|
+
try {
|
|
670
|
+
const live = await readVersion(backend, ref);
|
|
671
|
+
const working = await readVersion(backend, ref, "rel:working-copy");
|
|
672
|
+
const liveVid = live.kind === "ok" ? entityRevisionId(live.entity, ref.entityType) : null;
|
|
673
|
+
const workingVid = working.kind === "ok" ? entityRevisionId(working.entity, ref.entityType) : null;
|
|
674
|
+
const workingLabel = workingVid ?? (working.kind === "absent" ? "none" : "unreadable");
|
|
675
|
+
const forward = liveVid !== null && workingVid !== null && String(workingVid) !== String(liveVid)
|
|
676
|
+
? " A forward revision is present; do not send another canonical update."
|
|
677
|
+
: "";
|
|
678
|
+
return `${reason}. Re-read: published revision ${liveVid ?? "unreadable"}, working revision ${workingLabel}.${forward}`;
|
|
679
|
+
} catch (readErr) {
|
|
680
|
+
const follow = readErr instanceof Error ? readErr.message : String(readErr);
|
|
681
|
+
return `${reason}. A follow-up re-read failed (${follow}).`;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* @param {?string} state
|
|
687
|
+
* @returns {boolean}
|
|
688
|
+
*/
|
|
689
|
+
function isForwardModerationState(state) {
|
|
690
|
+
return state === "draft" || state === "review";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* A completed 4xx names a rejected request. 408 and 429 do not: the write
|
|
695
|
+
* may still have been accepted. 5xx and transport errors stay uncertain.
|
|
696
|
+
* @param {unknown} err
|
|
697
|
+
* @returns {boolean}
|
|
698
|
+
*/
|
|
699
|
+
function isDefinitiveClientError(err) {
|
|
700
|
+
const status = httpStatusOf(err);
|
|
701
|
+
if (status === null || status < 400 || status >= 500) return false;
|
|
702
|
+
return status !== 408 && status !== 429;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Whether the published revision, its pins, and the absence of a new
|
|
707
|
+
* forward draft still match the pre-write read. Null means the re-read
|
|
708
|
+
* itself failed, which is not proof that nothing changed.
|
|
709
|
+
* @param {object} backend
|
|
710
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
711
|
+
* @param {{liveVid: number|string, pins: object[]}} before
|
|
712
|
+
* @returns {Promise<?boolean>}
|
|
713
|
+
*/
|
|
714
|
+
async function publishedStateUnchanged(backend, ref, before) {
|
|
715
|
+
try {
|
|
716
|
+
const after = await readPair(backend, ref, "after");
|
|
717
|
+
if (String(after.liveVid) !== String(before.liveVid)) return false;
|
|
718
|
+
if (String(after.latestVid) !== String(after.liveVid)) return false;
|
|
719
|
+
if (pinKey(after.pins) !== pinKey(before.pins)) return false;
|
|
720
|
+
const continuing = before.workingVid !== null && String(before.workingVid) !== String(before.liveVid);
|
|
721
|
+
if (continuing) {
|
|
722
|
+
return after.workingVid !== null && String(after.workingVid) === String(before.workingVid);
|
|
723
|
+
}
|
|
724
|
+
if (after.workingVid !== null && String(after.workingVid) !== String(before.liveVid)) return false;
|
|
725
|
+
return true;
|
|
726
|
+
} catch {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* @param {?object} entity
|
|
733
|
+
* @returns {Array<{field: string, id: string, revisionId: ?string}>}
|
|
734
|
+
*/
|
|
735
|
+
function normalizedPins(entity) {
|
|
736
|
+
return paragraphPinsFromEntity(entity).map((pin) => ({
|
|
737
|
+
field: pin.field,
|
|
738
|
+
id: pin.id,
|
|
739
|
+
revisionId: pin.revisionId === null || pin.revisionId === undefined || pin.revisionId === ""
|
|
740
|
+
? null
|
|
741
|
+
: String(pin.revisionId),
|
|
742
|
+
}));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* @param {?object} relationships
|
|
747
|
+
* @returns {?{fields: Set<string>, pins: object[]}}
|
|
748
|
+
*/
|
|
749
|
+
function submittedParagraphPins(relationships) {
|
|
750
|
+
if (!relationships || typeof relationships !== "object" || Array.isArray(relationships)) return null;
|
|
751
|
+
const pins = [];
|
|
752
|
+
const fields = new Set();
|
|
753
|
+
for (const [field, rel] of Object.entries(relationships)) {
|
|
754
|
+
if (!rel || typeof rel !== "object" || !Object.prototype.hasOwnProperty.call(rel, "data")) continue;
|
|
755
|
+
const data = rel.data;
|
|
756
|
+
const list = data === null || data === undefined ? [] : Array.isArray(data) ? data : [data];
|
|
757
|
+
let fieldHasParagraph = false;
|
|
758
|
+
for (const ref of list) {
|
|
759
|
+
if (!ref?.id) continue;
|
|
760
|
+
const type = typeof ref.type === "string" ? ref.type : "";
|
|
761
|
+
if (ref.entityType !== "paragraph" && !type.startsWith("paragraph--")) continue;
|
|
762
|
+
fieldHasParagraph = true;
|
|
763
|
+
const revisionId = ref.meta?.target_revision_id;
|
|
764
|
+
pins.push({
|
|
765
|
+
field,
|
|
766
|
+
id: ref.id,
|
|
767
|
+
revisionId: revisionId === null || revisionId === undefined || revisionId === ""
|
|
768
|
+
? null
|
|
769
|
+
: String(revisionId),
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
if (fieldHasParagraph || data === null || (Array.isArray(data) && data.length === 0)) {
|
|
773
|
+
fields.add(field);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (!fields.size) return null;
|
|
777
|
+
return { fields, pins };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* @param {object[]} pins
|
|
782
|
+
* @returns {string}
|
|
783
|
+
*/
|
|
784
|
+
function pinKey(pins) {
|
|
785
|
+
const groups = new Map();
|
|
786
|
+
for (const pin of pins) {
|
|
787
|
+
const row = `${pin.id}\0${pin.revisionId ?? ""}`;
|
|
788
|
+
if (!groups.has(pin.field)) groups.set(pin.field, []);
|
|
789
|
+
groups.get(pin.field).push(row);
|
|
790
|
+
}
|
|
791
|
+
return [...groups.keys()].sort().map((field) => {
|
|
792
|
+
const rows = groups.get(field) ?? [];
|
|
793
|
+
return `${field}\0${rows.join("\n")}`;
|
|
794
|
+
}).join("\n\n");
|
|
795
|
+
}
|