drupal-mcp-connector 3.0.1 → 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.
@@ -0,0 +1,1404 @@
1
+ /**
2
+ * Governed nested-paragraph draft (#421).
3
+ *
4
+ * Sentinel component drafts edit only paragraphs the host pins directly.
5
+ * A nested child is replaced by new unpublished paragraphs. The host draft
6
+ * then points at a new direct parent. The published host and the paragraphs
7
+ * it already pins are not patched.
8
+ *
9
+ * The first host draft needs Sentinel open_draft (2.29.0). An existing
10
+ * working copy continues through /mcp-draft. There is no mcp_components
11
+ * payload and no direct paragraph update.
12
+ *
13
+ * When the host inventory advertises nested_replacement, one /mcp-draft
14
+ * carries meta.mcp_nested_replacement and this module creates no paragraphs.
15
+ * parentId is sent as parent. The host paragraph field is not also sent.
16
+ * resumeParentId stays on the client sequence: those paragraphs already exist.
17
+ */
18
+
19
+ import { readNodeDraftInventory, readTranslationInventory, isMissingTranslationEndpoint, createTranslationDraft } from "./sentinel-draft.js";
20
+ import { entityRevisionId } from "./write-revision.js";
21
+ import { embedParagraphRef, paragraphRevisionId } from "./err-relationships.js";
22
+ import { httpStatusOf } from "./error-status.js";
23
+ import { OPEN_DRAFT_OPERATION, MIN_SENTINEL_COMPONENTS_VERSION, valueMatches } from "./component-draft.js";
24
+ import { PREFLIGHT_NONE, PREFLIGHT_SENTINEL_DRAFT, dryRunChecks } from "./dry-run-checks.js";
25
+
26
+ export const NESTED_DRAFT_PARTIAL_CODE = "NESTED_DRAFT_PARTIAL";
27
+
28
+ /** Inventory operation: one atomic nested paragraph replacement. */
29
+ export const NESTED_REPLACEMENT_OPERATION = "nested_replacement";
30
+
31
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
32
+ const NAME_RE = /^[a-z][a-z0-9_]*$/;
33
+ const SKIP_RELATIONSHIPS = new Set(["uid", "revision_uid"]);
34
+ const SKIPPED_FIELDS = new Set([
35
+ "status", "moderation_state", "langcode", "default_langcode", "created", "changed",
36
+ "parent_id", "parent_type", "parent_field_name", "behavior_settings",
37
+ "revision_translation_affected", "content_translation_source",
38
+ "content_translation_outdated", "content_translation_uid", "content_translation_created",
39
+ ]);
40
+
41
+ /**
42
+ * @param {string} detail
43
+ * @param {object[]} prepared
44
+ * @returns {Error}
45
+ */
46
+ export function nestedDraftPartial(detail, prepared) {
47
+ const ids = prepared.map((row) => row.id).filter(Boolean).join(", ") || "none";
48
+ const hasParent = prepared.some((row) => row.role === "parent" && row.id);
49
+ const recovery = hasParent
50
+ ? "Delete them or retry the host pin with resumeParentId."
51
+ : "Delete them. There is no prepared parent to pin.";
52
+ const err = new Error(
53
+ `The nested draft did not finish (${detail}). Prepared paragraphs (${ids}) are not ` +
54
+ `referenced by the published host. ${recovery} ` +
55
+ "Do not edit the published child paragraphs."
56
+ );
57
+ err.name = "NestedDraftPartialError";
58
+ err.code = NESTED_DRAFT_PARTIAL_CODE;
59
+ err.prepared = prepared;
60
+ return err;
61
+ }
62
+
63
+ /**
64
+ * The host request was sent, or a preflight failed after creates, and the
65
+ * follow-up read did not prove what the host now pins.
66
+ * @param {string} detail
67
+ * @param {object[]} prepared
68
+ * @returns {Error}
69
+ */
70
+ export function nestedDraftUncertain(detail, prepared) {
71
+ const ids = prepared.map((row) => row.id).join(", ");
72
+ const err = new Error(
73
+ `The nested draft outcome is uncertain (${detail}). Prepared paragraphs (${ids}) may or may not be pinned. ` +
74
+ "Re-read the published revision and rel:working-copy before retrying. " +
75
+ "Do not edit the published child paragraphs, and do not assume the draft succeeded."
76
+ );
77
+ err.name = "NestedDraftPartialError";
78
+ err.code = NESTED_DRAFT_PARTIAL_CODE;
79
+ err.prepared = prepared;
80
+ err.uncertain = true;
81
+ return err;
82
+ }
83
+
84
+ /**
85
+ * @param {object} backend
86
+ * @param {object} input
87
+ * @param {{dryRun?: boolean, assertParagraphCreate?: (bundle: string) => void}} [options]
88
+ * @returns {Promise<object>}
89
+ */
90
+ export async function draftNestedComponents(backend, input, { dryRun = false, assertParagraphCreate } = {}) {
91
+ const spec = normalizeNestedInput(input);
92
+ const inventory = await readNodeDraftInventory(backend, spec);
93
+ if (!inventory) {
94
+ throw new Error(
95
+ "This host has no governed draft inventory. Nested edits require a node draft. " +
96
+ "No write was attempted, and no paragraph was created or edited."
97
+ );
98
+ }
99
+ const operations = Array.isArray(inventory.operations) ? inventory.operations : [];
100
+ const defaultLang = typeof inventory.defaultLangcode === "string" ? inventory.defaultLangcode : "";
101
+ if (!defaultLang) throw new Error("Sentinel did not report the default language. No write was attempted.");
102
+ const live = String(inventory.live.vid);
103
+ if (spec.liveRevisionId && String(spec.liveRevisionId) !== live) {
104
+ throw new Error(
105
+ `The host revision is stale (requested ${spec.liveRevisionId}, live revision ${live}). No write was attempted.`
106
+ );
107
+ }
108
+ const working = inventory.working?.vid && String(inventory.working.vid) !== live
109
+ ? String(inventory.working.vid)
110
+ : "";
111
+ if (!working && !operations.includes(OPEN_DRAFT_OPERATION)) {
112
+ throw new Error(
113
+ `This Sentinel host cannot open the first draft (missing ${OPEN_DRAFT_OPERATION}). ` +
114
+ `Update MCP Sentinel to ${MIN_SENTINEL_COMPONENTS_VERSION} or later. ` +
115
+ "No write was attempted, and no paragraph was created."
116
+ );
117
+ }
118
+ if (working) assertWorkingLanguageDraft(inventory, defaultLang);
119
+
120
+ const publishedHost = await readHost(backend, spec, "");
121
+ const publishedVid = entityRevisionId(publishedHost);
122
+ if (publishedVid === null || String(publishedVid) !== live) {
123
+ throw new Error("The host revision changed while reading it. No write was attempted.");
124
+ }
125
+ const publishedPins = fieldList(publishedHost, spec.field);
126
+ const publishedSlot = publishedPins.findIndex((ref) => sameId(ref?.id, spec.parentId));
127
+ if (publishedSlot < 0) {
128
+ throw new Error(
129
+ "This paragraph is not a direct component of the published host. " +
130
+ "Nested paragraphs are not edited in place. No write was attempted."
131
+ );
132
+ }
133
+ if (isReusable(publishedPins[publishedSlot])) throw reusableError();
134
+ for (const ref of publishedPins) requirePinRevision(ref, "A direct component pin");
135
+ let draftPins = publishedPins;
136
+ if (working) {
137
+ const workingHost = await readHost(backend, spec, working);
138
+ const workingVid = entityRevisionId(workingHost);
139
+ if (workingVid === null || String(workingVid) !== working) {
140
+ throw new Error("The working copy revision changed while reading it. No write was attempted.");
141
+ }
142
+ draftPins = fieldList(workingHost, spec.field);
143
+ for (const ref of draftPins) requirePinRevision(ref, "A working-copy component pin");
144
+ if (draftPins.length !== publishedPins.length) {
145
+ throw new Error("The working copy's component list does not match the published host. No write was attempted.");
146
+ }
147
+ }
148
+ let slot = draftPins.findIndex((ref) => sameId(ref?.id, spec.parentId));
149
+ if (slot < 0 && spec.resumeParentId) {
150
+ slot = draftPins.findIndex((ref) => sameId(ref?.id, spec.resumeParentId));
151
+ }
152
+ if (slot < 0) {
153
+ throw new Error(
154
+ "A forward draft already replaces this component. The published host still pins it. " +
155
+ "No further nested draft was started. Continue that draft in Drupal, or retry the host pin with resumeParentId."
156
+ );
157
+ }
158
+ if (slot !== publishedSlot) {
159
+ throw new Error("The working copy does not keep this component in the published slot. No write was attempted.");
160
+ }
161
+ const parentRef = publishedPins[publishedSlot];
162
+ const parentBundle = paragraphBundle(parentRef);
163
+ if (!parentBundle) throw new Error("The direct component has no paragraph type. No write was attempted.");
164
+ const parentRevision = revisionOf(parentRef);
165
+ const parent = await readParagraph(backend, parentBundle, spec.parentId, parentRevision);
166
+ if (!sameId(parent?.id, spec.parentId)) {
167
+ throw new Error("The component read did not match the requested paragraph. No write was attempted.");
168
+ }
169
+ const childPins = fieldList(parent, spec.childField);
170
+ if (childPins.some(isReusable)) throw reusableError();
171
+ for (const ref of childPins) requirePinRevision(ref, ref?.id || "A nested component");
172
+ if (spec.resumeParentId && liveParagraphIds(publishedPins, childPins).has(spec.resumeParentId.toLowerCase())) {
173
+ throw new Error("resumeParentId is a paragraph the published host already pins. No write was attempted.");
174
+ }
175
+ const requiredLangs = await requiredTranslationLangs(
176
+ backend, parentBundle, spec.parentId, defaultLang, parentRevision,
177
+ );
178
+ assertChildren(spec.children, childPins, requiredLangs);
179
+ if (!spec.resumeParentId) {
180
+ const bundles = [
181
+ ...spec.children.filter((child) => child.op !== "keep").map((child) => child.type),
182
+ parentBundle,
183
+ ];
184
+ for (const bundle of bundles) assertParagraphCreate?.(bundle);
185
+ }
186
+
187
+ if (!spec.resumeParentId && operations.includes(NESTED_REPLACEMENT_OPERATION)) {
188
+ return writeAtomicNestedReplacement(backend, {
189
+ spec, dryRun, live, working, defaultLang, publishedPins, slot, childPins,
190
+ });
191
+ }
192
+
193
+ if (dryRun) {
194
+ return {
195
+ dryRun: true,
196
+ operation: "update",
197
+ entityType: spec.entityType,
198
+ bundle: spec.bundle,
199
+ id: spec.id,
200
+ hostPayloadEvaluated: false,
201
+ createsParagraphs: !spec.resumeParentId,
202
+ opensWorkingCopy: !working,
203
+ replacesParentId: spec.parentId,
204
+ note: "No paragraphs were created. The host draft payload was not evaluated because the replacement paragraphs do not exist yet.",
205
+ ...dryRunChecks({ operation: "update", preflight: PREFLIGHT_NONE }),
206
+ };
207
+ }
208
+
209
+ const prepared = [];
210
+ const childRefs = [];
211
+ if (spec.resumeParentId) {
212
+ const resumed = await readParagraph(backend, parentBundle, spec.resumeParentId);
213
+ const revisionId = await assertResumeMatches(
214
+ backend, spec, parent, childPins, resumed, requiredLangs, defaultLang,
215
+ );
216
+ prepared.push({ id: spec.resumeParentId, bundle: parentBundle, role: "parent", revisionId });
217
+ } else {
218
+ for (const child of spec.children) {
219
+ if (child.op === "keep") {
220
+ const existing = childPins.find((ref) => sameId(ref?.id, child.id));
221
+ childRefs.push(pinRef(existing));
222
+ continue;
223
+ }
224
+ childRefs.push(await createParagraph(backend, child, prepared, assertParagraphCreate, defaultLang));
225
+ }
226
+ await createParagraph(backend, {
227
+ op: "insert",
228
+ type: parentBundle,
229
+ attributes: copiedAttributes(parent),
230
+ relationships: copiedRelationships(parent, spec.childField, childRefs),
231
+ translations: [],
232
+ }, prepared, assertParagraphCreate, defaultLang, "parent");
233
+ await copyParentTranslations(backend, parent, prepared.find((row) => row.role === "parent"), requiredLangs, prepared);
234
+ }
235
+
236
+ const draftParent = spec.resumeParentId
237
+ ? prepared[0]
238
+ : prepared.find((row) => row.role === "parent");
239
+ const nextPins = draftPins.map((ref, index) => (
240
+ index === slot ? embedParagraphRef(draftParent.bundle, draftParent.id, draftParent.revisionId) : pinRef(ref)
241
+ ));
242
+ if (working && sameId(draftPins[slot]?.id, draftParent.id)) {
243
+ const confirmed = await confirmLanded(backend, spec, live, nextPins);
244
+ if (confirmed.ok) return finished(spec, live, confirmed.workingVid, draftParent, prepared, false);
245
+ throw recoveryError(confirmed, "the existing draft pin could not be confirmed", prepared);
246
+ }
247
+
248
+ const data = {
249
+ type: `${spec.entityType}--${spec.bundle}`,
250
+ id: spec.id,
251
+ attributes: { moderation_state: "draft" },
252
+ relationships: { [spec.field]: { data: nextPins } },
253
+ };
254
+ const request = { ...spec, data, live, working, langcode: defaultLang };
255
+ try {
256
+ await confirmHostPreflight(backend, request);
257
+ } catch (err) {
258
+ const confirmed = await confirmLanded(backend, spec, live, nextPins);
259
+ if (confirmed.ok) return finished(spec, live, confirmed.workingVid, draftParent, prepared, !working);
260
+ throw recoveryError(confirmed, messageOf(err), prepared);
261
+ }
262
+ let result;
263
+ try {
264
+ result = await hostDraftRequest(backend, { ...request, preflight: false });
265
+ } catch (err) {
266
+ const confirmed = await confirmLanded(backend, spec, live, nextPins);
267
+ if (confirmed.ok) return finished(spec, live, confirmed.workingVid, draftParent, prepared, !working);
268
+ throw recoveryError(confirmed, messageOf(err), prepared);
269
+ }
270
+ if (!result?.data || result.data.id !== spec.id) {
271
+ const confirmed = await confirmLanded(backend, spec, live, nextPins);
272
+ if (confirmed.ok) return finished(spec, live, confirmed.workingVid, draftParent, prepared, !working);
273
+ throw recoveryError(confirmed, "the host draft response did not identify the node", prepared);
274
+ }
275
+ const landed = await confirmLanded(backend, spec, live, nextPins);
276
+ if (!landed.ok) {
277
+ throw recoveryError(landed, "the working copy did not pin the new component", prepared);
278
+ }
279
+ return finished(spec, live, landed.workingVid, draftParent, prepared, !working);
280
+ }
281
+
282
+ /**
283
+ * One server-side nested replacement. The connector creates nothing.
284
+ *
285
+ * @param {object} backend
286
+ * @param {object} args
287
+ * @returns {Promise<object>}
288
+ */
289
+ async function writeAtomicNestedReplacement(backend, {
290
+ spec, dryRun, live, working, defaultLang, publishedPins, slot, childPins,
291
+ }) {
292
+ const data = {
293
+ type: `${spec.entityType}--${spec.bundle}`,
294
+ id: spec.id,
295
+ attributes: { moderation_state: "draft" },
296
+ };
297
+ const request = {
298
+ ...spec,
299
+ data,
300
+ meta: {
301
+ mcp_nested_replacement: {
302
+ field: spec.field,
303
+ parent: spec.parentId,
304
+ childField: spec.childField,
305
+ children: spec.children.map(atomicChild),
306
+ },
307
+ },
308
+ live,
309
+ working,
310
+ langcode: defaultLang,
311
+ };
312
+ try {
313
+ await confirmAtomicPreflight(backend, request);
314
+ } catch (err) {
315
+ throw noWriteError(err);
316
+ }
317
+ if (dryRun) {
318
+ return {
319
+ dryRun: true,
320
+ operation: "update",
321
+ entityType: spec.entityType,
322
+ bundle: spec.bundle,
323
+ id: spec.id,
324
+ hostPayloadEvaluated: true,
325
+ createsParagraphs: false,
326
+ opensWorkingCopy: !working,
327
+ replacesParentId: spec.parentId,
328
+ atomic: true,
329
+ ...dryRunChecks({ operation: "update", preflight: PREFLIGHT_SENTINEL_DRAFT }),
330
+ };
331
+ }
332
+ let result;
333
+ try {
334
+ result = await atomicDraftRequest(backend, { ...request, preflight: false });
335
+ } catch (err) {
336
+ const confirmed = await confirmAtomicLanded(backend, spec, live, working, publishedPins, slot, childPins);
337
+ if (confirmed.ok) return atomicFinished(spec, live, confirmed, !working);
338
+ throw atomicFailure(confirmed, messageOf(err));
339
+ }
340
+ if (!result?.data || result.data.id !== spec.id) {
341
+ const confirmed = await confirmAtomicLanded(backend, spec, live, working, publishedPins, slot, childPins);
342
+ if (confirmed.ok) return atomicFinished(spec, live, confirmed, !working);
343
+ throw atomicFailure(confirmed, "the host draft response did not identify the node");
344
+ }
345
+ const landed = await confirmAtomicLanded(backend, spec, live, working, publishedPins, slot, childPins);
346
+ if (!landed.ok) throw atomicFailure(landed, "the working copy did not show the nested replacement");
347
+ return atomicFinished(spec, live, landed, !working);
348
+ }
349
+
350
+ /**
351
+ * @param {object} child
352
+ * @returns {object}
353
+ */
354
+ function atomicChild(child) {
355
+ if (child.op === "keep") return { op: "keep", id: child.id };
356
+ const entry = {
357
+ op: child.op,
358
+ type: `paragraph--${child.type}`,
359
+ attributes: child.attributes,
360
+ };
361
+ if (child.op === "replace") entry.id = child.id;
362
+ if (child.translations?.length) entry.translations = child.translations;
363
+ return entry;
364
+ }
365
+
366
+ /**
367
+ * @param {object} backend
368
+ * @param {object} request
369
+ * @returns {Promise<object>}
370
+ */
371
+ function atomicDraftRequest(backend, { entityType, bundle, id, data, meta, live, working, langcode, preflight }) {
372
+ return backend.rawQuery({
373
+ path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
374
+ options: {
375
+ method: "PATCH",
376
+ headers: {
377
+ "If-Match": working ? `"${live}:${working}"` : `"${live}"`,
378
+ "X-MCP-Draft-Preflight": preflight ? "1" : "0",
379
+ "X-MCP-Draft-Langcode": langcode,
380
+ },
381
+ body: JSON.stringify({ data, meta }),
382
+ },
383
+ });
384
+ }
385
+
386
+ /**
387
+ * @param {object} backend
388
+ * @param {object} request
389
+ * @returns {Promise<void>}
390
+ */
391
+ async function confirmAtomicPreflight(backend, request) {
392
+ const checked = await atomicDraftRequest(backend, { ...request, preflight: true });
393
+ const meta = checked?.meta;
394
+ if (meta?.draft_preflight !== true || String(meta.live) !== request.live
395
+ || String(meta.working ?? "") !== request.working
396
+ || meta.operation !== NESTED_REPLACEMENT_OPERATION) {
397
+ throw new Error("The site did not confirm a non-saving nested replacement preflight. No write was attempted.");
398
+ }
399
+ }
400
+
401
+ /**
402
+ * @param {object} backend
403
+ * @param {object} spec
404
+ * @param {string} live
405
+ * @param {string} previousWorking
406
+ * @param {object[]} publishedPins
407
+ * @param {number} slot
408
+ * @param {object[]} childPins
409
+ * @returns {Promise<object>}
410
+ */
411
+ async function confirmAtomicLanded(backend, spec, live, previousWorking, publishedPins, slot, childPins) {
412
+ let published;
413
+ let workingEntity;
414
+ try {
415
+ published = await backend.getEntity({ entityType: spec.entityType, bundle: spec.bundle, id: spec.id });
416
+ workingEntity = await backend.getEntity({
417
+ entityType: spec.entityType, bundle: spec.bundle, id: spec.id, resourceVersion: "rel:working-copy",
418
+ });
419
+ } catch (err) {
420
+ return { ok: false, uncertain: true, publishedIntact: false, detail: messageOf(err) };
421
+ }
422
+ if (!published || !workingEntity) {
423
+ return { ok: false, uncertain: true, publishedIntact: false, detail: "a host revision was not returned" };
424
+ }
425
+ const publishedVid = entityRevisionId(published);
426
+ const publishedNow = fieldList(published, spec.field);
427
+ const publishedIntact = publishedVid !== null && String(publishedVid) === String(live)
428
+ && samePinList(publishedNow, publishedPins);
429
+ if (!publishedIntact) {
430
+ return { ok: false, uncertain: true, publishedIntact: false, detail: "the published revision changed" };
431
+ }
432
+ const workingPins = fieldList(workingEntity, spec.field);
433
+ const newParentRef = workingPins[slot];
434
+ const stillOriginal = !newParentRef || sameId(newParentRef.id, spec.parentId);
435
+ const workingVid = entityRevisionId(workingEntity);
436
+ const advanced = workingVid !== null && String(workingVid) !== String(live)
437
+ && (!previousWorking || String(workingVid) !== String(previousWorking));
438
+ if (stillOriginal) {
439
+ return {
440
+ ok: false, uncertain: false, publishedIntact: true,
441
+ detail: "the published host still pins the original component",
442
+ };
443
+ }
444
+ if (!advanced || workingPins.length !== publishedPins.length
445
+ || workingPins.some((ref, index) => index !== slot && pinKey(ref) !== pinKey(publishedPins[index]))) {
446
+ return {
447
+ ok: false, uncertain: true, publishedIntact: true,
448
+ detail: advanced
449
+ ? "a new pin appeared without a verifiable component list"
450
+ : "a new pin appeared but the working revision did not advance",
451
+ };
452
+ }
453
+ const bundle = paragraphBundle(newParentRef);
454
+ const revisionId = revisionOf(newParentRef);
455
+ let parent;
456
+ try {
457
+ parent = await backend.getEntity({
458
+ entityType: "paragraph",
459
+ bundle,
460
+ id: newParentRef.id,
461
+ ...(revisionId ? { resourceVersion: `id:${revisionId}` } : {}),
462
+ });
463
+ } catch (err) {
464
+ return { ok: false, uncertain: true, publishedIntact: true, detail: messageOf(err) };
465
+ }
466
+ if (!isUnpublishedParagraph(parent)) {
467
+ return {
468
+ ok: false, uncertain: true, publishedIntact: true,
469
+ detail: "the new parent was not an unpublished paragraph",
470
+ };
471
+ }
472
+ const nextChildren = fieldList(parent, spec.childField);
473
+ const childDetail = await verifyAtomicChildren(backend, spec, childPins, nextChildren);
474
+ if (childDetail) {
475
+ return { ok: false, uncertain: true, publishedIntact: true, detail: childDetail };
476
+ }
477
+ const prepared = [{
478
+ id: newParentRef.id, bundle, role: "parent", revisionId, atomic: true,
479
+ }];
480
+ spec.children.forEach((child, index) => {
481
+ if (child.op === "keep") return;
482
+ const pin = nextChildren[index];
483
+ prepared.push({
484
+ id: pin.id, bundle: paragraphBundle(pin), role: "child", revisionId: revisionOf(pin), atomic: true,
485
+ });
486
+ });
487
+ return {
488
+ ok: true,
489
+ workingVid: String(workingVid),
490
+ parent: { id: newParentRef.id, bundle, revisionId },
491
+ prepared,
492
+ };
493
+ }
494
+
495
+ /**
496
+ * @param {object} backend
497
+ * @param {object} spec
498
+ * @param {object[]} originalPins
499
+ * @param {object[]} nextPins
500
+ * @returns {Promise<?string>}
501
+ */
502
+ async function verifyAtomicChildren(backend, spec, originalPins, nextPins) {
503
+ if (nextPins.length !== spec.children.length) {
504
+ return "the new parent child list does not match the request";
505
+ }
506
+ for (let index = 0; index < spec.children.length; index += 1) {
507
+ const child = spec.children[index];
508
+ const pin = nextPins[index];
509
+ if (child.op === "keep") {
510
+ const original = originalPins.find((ref) => sameId(ref?.id, child.id));
511
+ if (!sameId(pin?.id, child.id) || String(revisionOf(pin)) !== String(revisionOf(original))) {
512
+ return `kept child ${child.id} was not pinned at its original revision`;
513
+ }
514
+ continue;
515
+ }
516
+ if (child.op === "replace" && sameId(pin?.id, child.id)) {
517
+ return `replaced child ${child.id} was not a new paragraph`;
518
+ }
519
+ if (paragraphBundle(pin) !== child.type) return "a new child has the wrong paragraph type";
520
+ let entity;
521
+ try {
522
+ const revisionId = revisionOf(pin);
523
+ entity = await backend.getEntity({
524
+ entityType: "paragraph",
525
+ bundle: child.type,
526
+ id: pin.id,
527
+ ...(revisionId ? { resourceVersion: `id:${revisionId}` } : {}),
528
+ });
529
+ } catch (err) {
530
+ return messageOf(err);
531
+ }
532
+ if (!isUnpublishedParagraph(entity)) return "a new child was not unpublished";
533
+ const stored = entity.fields ?? entity.attributes ?? {};
534
+ const mismatch = Object.entries(child.attributes ?? {}).find(([key, value]) => !valueMatches(value, stored[key]));
535
+ if (mismatch) return `child field ${mismatch[0]} did not match`;
536
+ }
537
+ return null;
538
+ }
539
+
540
+ /**
541
+ * @param {?object} entity
542
+ * @returns {boolean}
543
+ */
544
+ function isUnpublishedParagraph(entity) {
545
+ if (!entity || typeof entity !== "object") return false;
546
+ const state = String(entity.fields?.moderation_state ?? entity.moderation_state ?? "").toLowerCase();
547
+ if (entity.status === true || state === "published") return false;
548
+ return entity.status === false || state === "draft" || state === "review";
549
+ }
550
+
551
+ /**
552
+ * @param {object[]} left
553
+ * @param {object[]} right
554
+ * @returns {boolean}
555
+ */
556
+ function samePinList(left, right) {
557
+ return left.length === right.length && left.every((ref, index) => pinKey(ref) === pinKey(right[index]));
558
+ }
559
+
560
+ /**
561
+ * @param {unknown} err
562
+ * @returns {Error}
563
+ */
564
+ function noWriteError(err) {
565
+ const message = messageOf(err);
566
+ if (/no write was attempted/i.test(message)) return err instanceof Error ? err : new Error(message);
567
+ return new Error(`${message} No write was attempted.`);
568
+ }
569
+
570
+ /**
571
+ * @param {object} confirmed
572
+ * @param {string} fallback
573
+ * @returns {Error}
574
+ */
575
+ function atomicFailure(confirmed, fallback) {
576
+ const detail = confirmed.detail && confirmed.detail !== fallback
577
+ ? `${fallback} (${confirmed.detail})`
578
+ : (confirmed.detail || fallback);
579
+ if (confirmed.uncertain || !confirmed.publishedIntact) return atomicUncertain(detail);
580
+ return atomicPartial(detail);
581
+ }
582
+
583
+ /**
584
+ * @param {string} detail
585
+ * @returns {Error}
586
+ */
587
+ function atomicPartial(detail) {
588
+ const err = new Error(
589
+ `The nested replacement did not finish (${detail}). The published host still pins the original component. ` +
590
+ "No paragraph was prepared by the connector. Do not edit the published child paragraphs."
591
+ );
592
+ err.name = "NestedDraftPartialError";
593
+ err.code = NESTED_DRAFT_PARTIAL_CODE;
594
+ err.prepared = [];
595
+ err.atomic = true;
596
+ return err;
597
+ }
598
+
599
+ /**
600
+ * @param {string} detail
601
+ * @returns {Error}
602
+ */
603
+ function atomicUncertain(detail) {
604
+ const err = new Error(
605
+ `The nested replacement outcome is uncertain (${detail}). ` +
606
+ "Re-read the published revision and rel:working-copy before retrying. " +
607
+ "Do not assume the published revision is unchanged. No paragraph was prepared by the connector."
608
+ );
609
+ err.name = "NestedDraftPartialError";
610
+ err.code = NESTED_DRAFT_PARTIAL_CODE;
611
+ err.prepared = [];
612
+ err.uncertain = true;
613
+ err.atomic = true;
614
+ return err;
615
+ }
616
+
617
+ /**
618
+ * @param {object} spec
619
+ * @param {string} live
620
+ * @param {object} confirmed
621
+ * @param {boolean} opened
622
+ * @returns {object}
623
+ */
624
+ function atomicFinished(spec, live, confirmed, opened) {
625
+ return {
626
+ id: spec.id,
627
+ entityType: spec.entityType,
628
+ bundle: spec.bundle,
629
+ publishedParentId: spec.parentId,
630
+ draftParentId: confirmed.parent.id,
631
+ publishedPinsUnchanged: true,
632
+ opensWorkingCopy: opened,
633
+ atomic: true,
634
+ prepared: confirmed.prepared,
635
+ _revisions: { live, working: confirmed.workingVid },
636
+ };
637
+ }
638
+
639
+ /**
640
+ * @param {object} input
641
+ * @returns {object}
642
+ */
643
+ function normalizeNestedInput(input) {
644
+ const entityType = input?.entityType;
645
+ if (entityType !== "node") {
646
+ throw new Error("Nested draft edits are supported on nodes. No write was attempted.");
647
+ }
648
+ const bundle = String(input.bundle ?? "");
649
+ const id = String(input.id ?? "");
650
+ const field = String(input.field ?? "");
651
+ const parentId = String(input.parentId ?? "");
652
+ const childField = String(input.childField ?? "");
653
+ if (!NAME_RE.test(bundle) || !NAME_RE.test(field) || !NAME_RE.test(childField)) {
654
+ throw new Error("bundle, field, and childField must be Drupal machine names.");
655
+ }
656
+ if (!UUID_RE.test(id) || !UUID_RE.test(parentId)) {
657
+ throw new Error("The host id and parentId must be UUIDs.");
658
+ }
659
+ const resumeParentId = input.resumeParentId ? String(input.resumeParentId) : "";
660
+ if (resumeParentId && (!UUID_RE.test(resumeParentId) || resumeParentId === parentId)) {
661
+ throw new Error("resumeParentId must be the prepared parent UUID, not the published component.");
662
+ }
663
+ const children = Array.isArray(input.children) ? input.children : null;
664
+ if (!children) throw new Error("children must be the full new child list.");
665
+ return {
666
+ entityType, bundle, id, field, parentId, childField, resumeParentId,
667
+ liveRevisionId: input.liveRevisionId,
668
+ children: children.map(normalizeChild),
669
+ };
670
+ }
671
+
672
+ /**
673
+ * @param {object} child
674
+ * @param {number} index
675
+ * @returns {object}
676
+ */
677
+ function normalizeChild(child, index) {
678
+ const where = `children[${index}]`;
679
+ if (!child || typeof child !== "object" || Array.isArray(child)) {
680
+ throw new Error(`${where} must be an object.`);
681
+ }
682
+ if (Object.hasOwn(child, "relationships") && child.op !== undefined) {
683
+ throw new Error(`${where}: a nested edit cannot change references inside the child. Create a new child instead.`);
684
+ }
685
+ const op = child.op;
686
+ if (op !== "keep" && op !== "replace" && op !== "insert") {
687
+ throw new Error(`${where}.op must be keep, replace, or insert. Omit a child to remove it from the draft.`);
688
+ }
689
+ if ((op === "keep" || op === "replace") && !UUID_RE.test(String(child.id ?? ""))) {
690
+ throw new Error(`${where}.id must be the existing child UUID.`);
691
+ }
692
+ if (op === "keep") return { op, id: String(child.id) };
693
+ const bundle = paragraphType(child.type, where);
694
+ const attributes = child.attributes;
695
+ if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) {
696
+ throw new Error(`${where}.attributes must be the field values for the new paragraph.`);
697
+ }
698
+ const translations = Array.isArray(child.translations) ? child.translations.map((row, translationIndex) => {
699
+ if (!row || typeof row.langcode !== "string" || !row.attributes || typeof row.attributes !== "object") {
700
+ throw new Error(`${where}.translations[${translationIndex}] must have langcode and attributes.`);
701
+ }
702
+ return { langcode: row.langcode, attributes: row.attributes };
703
+ }) : [];
704
+ return { op, id: child.id ? String(child.id) : "", type: bundle, attributes, translations };
705
+ }
706
+
707
+ /**
708
+ * @param {object[]} children
709
+ * @param {object[]} childPins
710
+ * @param {Set<string>} requiredLangs
711
+ */
712
+ function assertChildren(children, childPins, requiredLangs) {
713
+ const known = new Set(childPins.map((ref) => ref?.id?.toLowerCase()).filter(Boolean));
714
+ const seen = new Set();
715
+ for (const child of children) {
716
+ if (child.op === "insert") {
717
+ assertLangs(child, requiredLangs);
718
+ continue;
719
+ }
720
+ const key = child.id.toLowerCase();
721
+ if (!known.has(key)) {
722
+ throw new Error(
723
+ `${child.id} is not a child of this component. No write was attempted.`
724
+ );
725
+ }
726
+ if (seen.has(key)) throw new Error(`${child.id} is listed more than once.`);
727
+ seen.add(key);
728
+ if (child.op === "replace") assertLangs(child, requiredLangs);
729
+ }
730
+ }
731
+
732
+ /**
733
+ * @param {object} child
734
+ * @param {Set<string>} requiredLangs
735
+ */
736
+ function assertLangs(child, requiredLangs) {
737
+ const supplied = new Set(child.translations.map((row) => row.langcode));
738
+ for (const lang of requiredLangs) {
739
+ if (!supplied.has(lang)) {
740
+ throw new Error(
741
+ `This component has a ${lang} translation that was not included. No write was attempted.`
742
+ );
743
+ }
744
+ }
745
+ }
746
+
747
+ /**
748
+ * @param {object} backend
749
+ * @param {object} child
750
+ * @param {object[]} prepared
751
+ * @param {(bundle: string) => void} [assertParagraphCreate]
752
+ * @param {string} defaultLang
753
+ * @param {"child"|"parent"} [role]
754
+ * @returns {Promise<object>}
755
+ */
756
+ async function createParagraph(backend, child, prepared, assertParagraphCreate, defaultLang, role = "child") {
757
+ try {
758
+ assertParagraphCreate?.(child.type);
759
+ } catch (err) {
760
+ if (prepared.length) throw nestedDraftPartial(messageOf(err), prepared);
761
+ throw err;
762
+ }
763
+ let created;
764
+ try {
765
+ created = await backend.createEntity({
766
+ entityType: "paragraph",
767
+ bundle: child.type,
768
+ langcode: defaultLang,
769
+ attributes: child.attributes,
770
+ ...(child.relationships ? { relationships: child.relationships } : {}),
771
+ });
772
+ } catch (err) {
773
+ const orphan = err?.entity;
774
+ if (orphan?.id) {
775
+ prepared.push({
776
+ id: orphan.id, bundle: child.type, role, revisionId: paragraphRevisionId(orphan),
777
+ });
778
+ }
779
+ if (prepared.length) throw nestedDraftPartial(messageOf(err), prepared);
780
+ throw err;
781
+ }
782
+ const servedLang = created?.langcode ?? created?.fields?.langcode ?? "";
783
+ if (servedLang !== defaultLang) {
784
+ prepared.push({
785
+ id: created?.id, bundle: child.type, role, revisionId: paragraphRevisionId(created),
786
+ });
787
+ const reported = servedLang || "an unreported language";
788
+ throw nestedDraftPartial(
789
+ `paragraph ${created?.id} was created in "${reported}", not ${defaultLang}`,
790
+ prepared,
791
+ );
792
+ }
793
+ const revisionId = paragraphRevisionId(created);
794
+ if (revisionId === null) {
795
+ prepared.push({ id: created?.id, bundle: child.type, role, revisionId: null });
796
+ throw nestedDraftPartial(`paragraph ${created?.id} has no revision id`, prepared);
797
+ }
798
+ const row = { id: created.id, bundle: child.type, role, revisionId };
799
+ prepared.push(row);
800
+ for (const translation of child.translations ?? []) {
801
+ if (translation.langcode === defaultLang) continue;
802
+ try {
803
+ await createTranslationDraft(backend, {
804
+ entityType: "paragraph",
805
+ bundle: child.type,
806
+ id: created.id,
807
+ langcode: translation.langcode,
808
+ attributes: translation.attributes,
809
+ draftRevision: { revisionId },
810
+ });
811
+ } catch (err) {
812
+ throw nestedDraftPartial(messageOf(err), prepared);
813
+ }
814
+ }
815
+ return embedParagraphRef(child.type, created.id, revisionId);
816
+ }
817
+
818
+ /**
819
+ * @param {object} backend
820
+ * @param {object} spec
821
+ * @returns {Promise<object>}
822
+ */
823
+ function hostDraftRequest(backend, { entityType, bundle, id, data, live, working, langcode, preflight }) {
824
+ return backend.rawQuery({
825
+ path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
826
+ options: {
827
+ method: "PATCH",
828
+ headers: {
829
+ "If-Match": working ? `"${live}:${working}"` : `"${live}"`,
830
+ "X-MCP-Draft-Preflight": preflight ? "1" : "0",
831
+ "X-MCP-Draft-Langcode": langcode,
832
+ },
833
+ body: JSON.stringify({ data }),
834
+ },
835
+ });
836
+ }
837
+
838
+ /**
839
+ * @param {object} backend
840
+ * @param {object} request
841
+ * @returns {Promise<void>}
842
+ */
843
+ async function confirmHostPreflight(backend, request) {
844
+ const checked = await hostDraftRequest(backend, { ...request, preflight: true });
845
+ const meta = checked?.meta;
846
+ if (meta?.draft_preflight !== true || String(meta.live) !== request.live
847
+ || String(meta.working ?? "") !== request.working
848
+ || (!request.working && meta.operation !== OPEN_DRAFT_OPERATION)) {
849
+ throw new Error("The site did not confirm a non-saving host draft preflight.");
850
+ }
851
+ }
852
+
853
+ /**
854
+ * @param {object} backend
855
+ * @param {object} spec
856
+ * @param {string} live
857
+ * @param {object[]} expectedWorkingPins
858
+ * @returns {Promise<?string>} Working revision id when the draft pin landed.
859
+ */
860
+ async function confirmLanded(backend, spec, live, expectedWorkingPins) {
861
+ let published;
862
+ let workingEntity;
863
+ try {
864
+ published = await backend.getEntity({ entityType: spec.entityType, bundle: spec.bundle, id: spec.id });
865
+ workingEntity = await backend.getEntity({
866
+ entityType: spec.entityType, bundle: spec.bundle, id: spec.id, resourceVersion: "rel:working-copy",
867
+ });
868
+ } catch (err) {
869
+ return { ok: false, uncertain: true, detail: messageOf(err) };
870
+ }
871
+ if (!published || !workingEntity) {
872
+ return { ok: false, uncertain: true, detail: "a host revision was not returned" };
873
+ }
874
+ const publishedVid = entityRevisionId(published);
875
+ if (publishedVid === null || String(publishedVid) !== String(live)) {
876
+ return { ok: false, uncertain: false, detail: "the published revision changed" };
877
+ }
878
+ const publishedIds = fieldList(published, spec.field).map((ref) => ref?.id?.toLowerCase());
879
+ if (!publishedIds.includes(spec.parentId.toLowerCase())) {
880
+ return { ok: false, uncertain: false, detail: "the published host no longer pins the original component" };
881
+ }
882
+ const workingVid = entityRevisionId(workingEntity);
883
+ if (workingVid === null || String(workingVid) === String(live)) {
884
+ return { ok: false, uncertain: false, detail: "no forward revision was found" };
885
+ }
886
+ const workingKeys = fieldList(workingEntity, spec.field).map(pinKey);
887
+ const expectedKeys = expectedWorkingPins.map(pinKey);
888
+ if (workingKeys.length !== expectedKeys.length || workingKeys.some((key, index) => key !== expectedKeys[index])) {
889
+ return { ok: false, uncertain: false, detail: "the working copy did not pin the prepared revision" };
890
+ }
891
+ return { ok: true, workingVid: String(workingVid) };
892
+ }
893
+
894
+ /**
895
+ * @param {{ok: boolean, uncertain?: boolean, detail?: string}} confirmed
896
+ * @param {string} fallback
897
+ * @param {object[]} prepared
898
+ * @returns {Error}
899
+ */
900
+ function recoveryError(confirmed, fallback, prepared) {
901
+ if (confirmed.uncertain
902
+ || confirmed.detail === "the published revision changed"
903
+ || confirmed.detail === "the published host no longer pins the original component") {
904
+ const why = confirmed.uncertain ? `re-read failed: ${confirmed.detail}` : confirmed.detail;
905
+ return nestedDraftUncertain(`${fallback}; ${why}`, prepared);
906
+ }
907
+ const reason = confirmed.detail && confirmed.detail !== fallback
908
+ ? `${fallback} (${confirmed.detail})`
909
+ : fallback;
910
+ return nestedDraftPartial(reason, prepared);
911
+ }
912
+
913
+ /**
914
+ * Copy the published component's non-default translations onto the new parent.
915
+ * The new paragraph is not the one the published host pins.
916
+ * @param {object} backend
917
+ * @param {object} source Published parent paragraph.
918
+ * @param {object} created Prepared parent row.
919
+ * @param {Set<string>} langs
920
+ * @param {object[]} prepared
921
+ * @returns {Promise<void>}
922
+ */
923
+ async function copyParentTranslations(backend, source, created, langs, prepared) {
924
+ for (const lang of langs) {
925
+ let translated;
926
+ try {
927
+ translated = await backend.getEntity({
928
+ entityType: "paragraph",
929
+ bundle: created.bundle,
930
+ id: source.id,
931
+ langcode: lang,
932
+ resourceVersion: `id:${paragraphRevisionId(source)}`,
933
+ });
934
+ } catch (err) {
935
+ throw nestedDraftPartial(`the ${lang} translation could not be copied (${messageOf(err)})`, prepared);
936
+ }
937
+ if (!translated) {
938
+ throw nestedDraftPartial(`the ${lang} translation of the component could not be read`, prepared);
939
+ }
940
+ try {
941
+ await createTranslationDraft(backend, {
942
+ entityType: "paragraph",
943
+ bundle: created.bundle,
944
+ id: created.id,
945
+ langcode: lang,
946
+ attributes: copiedAttributes(translated),
947
+ draftRevision: { revisionId: created.revisionId },
948
+ });
949
+ } catch (err) {
950
+ throw nestedDraftPartial(messageOf(err), prepared);
951
+ }
952
+ }
953
+ }
954
+
955
+ /**
956
+ * @param {object} spec
957
+ * @param {string} live
958
+ * @param {string} workingVid
959
+ * @param {object} draftParent
960
+ * @param {object[]} prepared
961
+ * @param {boolean} opened
962
+ * @returns {object}
963
+ */
964
+ function finished(spec, live, workingVid, draftParent, prepared, opened) {
965
+ return {
966
+ id: spec.id,
967
+ entityType: spec.entityType,
968
+ bundle: spec.bundle,
969
+ publishedParentId: spec.parentId,
970
+ draftParentId: draftParent.id,
971
+ publishedPinsUnchanged: true,
972
+ opensWorkingCopy: opened,
973
+ prepared,
974
+ _revisions: { live, working: workingVid },
975
+ };
976
+ }
977
+
978
+ /**
979
+ * @param {object} inventory
980
+ * @param {string} defaultLang
981
+ */
982
+ function assertWorkingLanguageDraft(inventory, defaultLang) {
983
+ const row = (inventory.working.translations ?? []).find((item) => item?.langcode === defaultLang);
984
+ const state = typeof row?.moderation_state === "string" ? row.moderation_state : "";
985
+ if (!row || row.status === true || state === "published") {
986
+ throw new Error(
987
+ `The default language (${defaultLang}) is still published on the working copy, so it cannot be continued. ` +
988
+ "No write was attempted."
989
+ );
990
+ }
991
+ }
992
+
993
+ /**
994
+ * @param {object} backend
995
+ * @param {object} spec
996
+ * @param {string} working
997
+ * @returns {Promise<object>}
998
+ */
999
+ async function readHost(backend, spec, working) {
1000
+ try {
1001
+ const entity = await backend.getEntity({
1002
+ entityType: spec.entityType,
1003
+ bundle: spec.bundle,
1004
+ id: spec.id,
1005
+ ...(working ? { resourceVersion: `id:${working}` } : {}),
1006
+ });
1007
+ if (!entity) throw new Error("The host revision was not returned.");
1008
+ return entity;
1009
+ } catch (err) {
1010
+ if (httpStatusOf(err) === 401 || httpStatusOf(err) === 403) {
1011
+ throw new Error("Reading the host was denied. No write was attempted.");
1012
+ }
1013
+ throw err;
1014
+ }
1015
+ }
1016
+
1017
+ /**
1018
+ * @param {object} backend
1019
+ * @param {string} bundle
1020
+ * @param {string} id
1021
+ * @param {?number|string} [revisionId]
1022
+ * @returns {Promise<object>}
1023
+ */
1024
+ async function readParagraph(backend, bundle, id, revisionId, langcode) {
1025
+ try {
1026
+ const entity = await backend.getEntity({
1027
+ entityType: "paragraph",
1028
+ bundle,
1029
+ id,
1030
+ ...(revisionId ? { resourceVersion: `id:${revisionId}` } : {}),
1031
+ ...(langcode ? { langcode } : {}),
1032
+ });
1033
+ if (!entity) throw new Error(`Paragraph ${id} was not returned.`);
1034
+ return entity;
1035
+ } catch (err) {
1036
+ if (httpStatusOf(err) === 401 || httpStatusOf(err) === 403) {
1037
+ throw new Error("Reading a nested component was denied. No write was attempted.");
1038
+ }
1039
+ throw err;
1040
+ }
1041
+ }
1042
+
1043
+ /**
1044
+ * @param {object} backend
1045
+ * @param {string} bundle
1046
+ * @param {string} id
1047
+ * @param {string} defaultLang
1048
+ * @param {number|string|null} revisionId Pinned paragraph revision, not a later canonical revision.
1049
+ * @returns {Promise<Set<string>>}
1050
+ */
1051
+ async function requiredTranslationLangs(backend, bundle, id, defaultLang, revisionId) {
1052
+ try {
1053
+ const inventory = await readTranslationInventory(backend, {
1054
+ entityType: "paragraph", bundle, id, revisionId,
1055
+ });
1056
+ const langs = new Set();
1057
+ for (const row of inventory?.live?.translations ?? []) {
1058
+ if (row?.langcode && row.langcode !== defaultLang) langs.add(row.langcode);
1059
+ }
1060
+ return langs;
1061
+ } catch (err) {
1062
+ if (isMissingTranslationEndpoint(err)) return new Set();
1063
+ throw err;
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * @param {?object} entity
1069
+ * @returns {object}
1070
+ */
1071
+ function copiedAttributes(entity) {
1072
+ const fields = entity?.fields && typeof entity.fields === "object" ? entity.fields : {};
1073
+ const attributes = {};
1074
+ for (const [key, value] of Object.entries(fields)) {
1075
+ if (key.startsWith("drupal_internal__") || SKIPPED_FIELDS.has(key)) continue;
1076
+ attributes[key] = value;
1077
+ }
1078
+ return attributes;
1079
+ }
1080
+
1081
+ /**
1082
+ * Copy every content relationship except the child field being replaced.
1083
+ * Authorship relationships are left for the create policy to stamp.
1084
+ * @param {?object} entity
1085
+ * @param {string} childField
1086
+ * @param {object[]} childData
1087
+ * @returns {object}
1088
+ */
1089
+ function copiedRelationships(entity, childField, childData) {
1090
+ const rels = entity?.relationships && typeof entity.relationships === "object" ? entity.relationships : {};
1091
+ const out = {};
1092
+ for (const [key, value] of Object.entries(rels)) {
1093
+ if (key === childField || SKIP_RELATIONSHIPS.has(key)) continue;
1094
+ const payload = relationshipPayload(value);
1095
+ if (payload) out[key] = payload;
1096
+ }
1097
+ out[childField] = { data: childData };
1098
+ return out;
1099
+ }
1100
+
1101
+ /**
1102
+ * The resumed paragraph must be the one this request would have created:
1103
+ * same copied fields, same other references, and the requested child pins.
1104
+ * @param {object} backend
1105
+ * @param {object} spec
1106
+ * @param {object} parent
1107
+ * @param {object[]} childPins
1108
+ * @param {object} resumed
1109
+ * @param {Set<string>} requiredLangs
1110
+ * @param {string} defaultLang
1111
+ * @returns {Promise<number|string>}
1112
+ */
1113
+ async function assertResumeMatches(backend, spec, parent, childPins, resumed, requiredLangs, defaultLang) {
1114
+ const revisionId = paragraphRevisionId(resumed);
1115
+ if (revisionId === null || !sameId(resumed?.id, spec.resumeParentId)) {
1116
+ throw new Error("resumeParentId has no verified revision. No host draft was written.");
1117
+ }
1118
+ if (!sameAttributes(copiedAttributes(parent), copiedAttributes(resumed))
1119
+ || !sameRelationships(parent, resumed, spec.childField)) {
1120
+ throw new Error(
1121
+ "resumeParentId does not match the published component's fields and references. No host draft was written."
1122
+ );
1123
+ }
1124
+ const pins = fieldList(resumed, spec.childField);
1125
+ if (pins.length !== spec.children.length) {
1126
+ throw new Error("resumeParentId does not match the requested child list. No host draft was written.");
1127
+ }
1128
+ const publishedChildIds = new Set(childPins.map((ref) => ref?.id?.toLowerCase()).filter(Boolean));
1129
+ for (let index = 0; index < spec.children.length; index += 1) {
1130
+ const child = spec.children[index];
1131
+ const pin = pins[index];
1132
+ requirePinRevision(pin, "A resumed child pin");
1133
+ if (child.op === "keep") {
1134
+ const live = childPins.find((ref) => sameId(ref?.id, child.id));
1135
+ if (!sameId(pin?.id, child.id) || String(revisionOf(pin)) !== String(revisionOf(live))) {
1136
+ throw new Error("resumeParentId does not keep the requested child revision. No host draft was written.");
1137
+ }
1138
+ continue;
1139
+ }
1140
+ if (publishedChildIds.has(String(pin?.id ?? "").toLowerCase()) || sameId(pin?.id, child.id)) {
1141
+ throw new Error("resumeParentId still pins a published child. No host draft was written.");
1142
+ }
1143
+ const created = await readParagraph(backend, child.type, pin.id, revisionOf(pin));
1144
+ if (!sameId(created?.id, pin.id)
1145
+ || created?.bundle !== child.type
1146
+ || String(paragraphRevisionId(created)) !== String(revisionOf(pin))
1147
+ || !sameAttributes(child.attributes, copiedAttributes(created))) {
1148
+ throw new Error("resumeParentId does not match the requested child fields. No host draft was written.");
1149
+ }
1150
+ for (const translation of child.translations) {
1151
+ if (translation.langcode === defaultLang) continue;
1152
+ const translated = await readParagraph(backend, child.type, pin.id, revisionOf(pin), translation.langcode);
1153
+ const served = translated?.langcode ?? translated?.fields?.langcode ?? "";
1154
+ if (served !== translation.langcode || !sameAttributes(translation.attributes, copiedAttributes(translated))) {
1155
+ throw new Error(
1156
+ `resumeParentId is missing the ${translation.langcode} child translation. No host draft was written.`
1157
+ );
1158
+ }
1159
+ }
1160
+ }
1161
+ for (const lang of requiredLangs) {
1162
+ const source = await readParagraph(backend, parent.bundle, parent.id, paragraphRevisionId(parent), lang);
1163
+ const copy = await readParagraph(backend, resumed.bundle || parent.bundle, resumed.id, revisionId, lang);
1164
+ const sourceLang = source?.langcode ?? source?.fields?.langcode ?? "";
1165
+ const copyLang = copy?.langcode ?? copy?.fields?.langcode ?? "";
1166
+ if (sourceLang !== lang || copyLang !== lang || !sameAttributes(copiedAttributes(source), copiedAttributes(copy))) {
1167
+ throw new Error(`resumeParentId is missing the ${lang} translation. No host draft was written.`);
1168
+ }
1169
+ }
1170
+ return revisionId;
1171
+ }
1172
+
1173
+ /**
1174
+ * @param {object} expected
1175
+ * @param {object} actual
1176
+ * @returns {boolean}
1177
+ */
1178
+ function sameAttributes(expected, actual) {
1179
+ const left = Object.keys(expected).sort();
1180
+ const right = Object.keys(actual).sort();
1181
+ if (left.length !== right.length || left.some((key, index) => key !== right[index])) return false;
1182
+ return left.every((key) => JSON.stringify(expected[key]) === JSON.stringify(actual[key]));
1183
+ }
1184
+
1185
+ /**
1186
+ * @param {?object} left
1187
+ * @param {?object} right
1188
+ * @param {string} childField
1189
+ * @returns {boolean}
1190
+ */
1191
+ function sameRelationships(left, right, childField) {
1192
+ const a = contentRelationshipKeys(left, childField);
1193
+ const b = contentRelationshipKeys(right, childField);
1194
+ const keys = Object.keys(a).sort();
1195
+ const other = Object.keys(b).sort();
1196
+ if (keys.length !== other.length || keys.some((key, index) => key !== other[index])) return false;
1197
+ return keys.every((key) => a[key] === b[key]);
1198
+ }
1199
+
1200
+ /**
1201
+ * @param {?object} entity
1202
+ * @param {string} childField
1203
+ * @returns {object}
1204
+ */
1205
+ function contentRelationshipKeys(entity, childField) {
1206
+ const rels = entity?.relationships && typeof entity.relationships === "object" ? entity.relationships : {};
1207
+ const out = {};
1208
+ for (const [key, value] of Object.entries(rels)) {
1209
+ if (key === childField || SKIP_RELATIONSHIPS.has(key)) continue;
1210
+ out[key] = linkageKey(value);
1211
+ }
1212
+ return out;
1213
+ }
1214
+
1215
+ /**
1216
+ * @param {unknown} value
1217
+ * @returns {string}
1218
+ */
1219
+ function linkageKey(value) {
1220
+ return asList(value).map((ref) => {
1221
+ const type = typeof ref?.type === "string" && ref.type
1222
+ ? ref.type
1223
+ : (ref?.entityType && ref?.bundle ? `${ref.entityType}--${ref.bundle}` : "");
1224
+ const alt = typeof ref?.meta?.alt === "string" ? ref.meta.alt : "";
1225
+ return `${type}:${String(ref?.id ?? "").toLowerCase()}:${revisionOf(ref) ?? ""}:${alt}`;
1226
+ }).join("|");
1227
+ }
1228
+
1229
+ /**
1230
+ * @param {unknown} value
1231
+ * @returns {?object}
1232
+ */
1233
+ function relationshipPayload(value) {
1234
+ const wrapped = value && typeof value === "object" && Object.hasOwn(value, "data") ? value.data : value;
1235
+ if (Array.isArray(wrapped)) return { data: wrapped.map(toLinkage).filter(Boolean) };
1236
+ if (wrapped && typeof wrapped === "object") {
1237
+ const one = toLinkage(wrapped);
1238
+ return one ? { data: one } : null;
1239
+ }
1240
+ if (wrapped === null) return { data: null };
1241
+ return null;
1242
+ }
1243
+
1244
+ /**
1245
+ * @param {?object} ref
1246
+ * @returns {?object}
1247
+ */
1248
+ function toLinkage(ref) {
1249
+ if (!ref || typeof ref !== "object") return null;
1250
+ const type = typeof ref.type === "string" && ref.type.includes("--")
1251
+ ? ref.type
1252
+ : (ref.entityType && ref.bundle ? `${ref.entityType}--${ref.bundle}` : "");
1253
+ if (!type || !ref.id) return null;
1254
+ const out = { type, id: ref.id };
1255
+ const meta = {};
1256
+ const revisionId = ref.meta?.target_revision_id;
1257
+ if (revisionId !== undefined && revisionId !== null && revisionId !== "" && Number.isFinite(Number(revisionId))) {
1258
+ meta.target_revision_id = Number(revisionId);
1259
+ }
1260
+ if (typeof ref.meta?.alt === "string") meta.alt = ref.meta.alt;
1261
+ if (typeof ref.meta?.title === "string") meta.title = ref.meta.title;
1262
+ if (Object.keys(meta).length > 0) out.meta = meta;
1263
+ return out;
1264
+ }
1265
+
1266
+ /**
1267
+ * @param {?object} entity
1268
+ * @param {string} field
1269
+ * @returns {object[]}
1270
+ */
1271
+ function fieldList(entity, field) {
1272
+ const rels = entity?.relationships;
1273
+ if (!rels || typeof rels !== "object" || !Object.hasOwn(rels, field)) return [];
1274
+ return asList(rels[field]);
1275
+ }
1276
+
1277
+ /**
1278
+ * @param {unknown} value
1279
+ * @returns {object[]}
1280
+ */
1281
+ function asList(value) {
1282
+ if (Array.isArray(value)) return value;
1283
+ if (value && typeof value === "object" && Object.hasOwn(value, "data")) {
1284
+ if (value.data === null || value.data === undefined) return [];
1285
+ return Array.isArray(value.data) ? value.data : [value.data];
1286
+ }
1287
+ if (value && typeof value === "object") return [value];
1288
+ return [];
1289
+ }
1290
+
1291
+ /**
1292
+ * @param {?object} ref
1293
+ * @returns {string}
1294
+ */
1295
+ function paragraphBundle(ref) {
1296
+ const type = typeof ref?.type === "string" ? ref.type : "";
1297
+ if (type.startsWith("paragraph--")) return type.slice("paragraph--".length);
1298
+ if (ref?.entityType === "paragraph" && typeof ref.bundle === "string") return ref.bundle;
1299
+ return "";
1300
+ }
1301
+
1302
+ /**
1303
+ * @param {string} type
1304
+ * @param {string} where
1305
+ * @returns {string}
1306
+ */
1307
+ function paragraphType(type, where) {
1308
+ const raw = String(type ?? "");
1309
+ const bundle = raw.startsWith("paragraph--") ? raw.slice("paragraph--".length) : raw;
1310
+ if (!NAME_RE.test(bundle) || (raw.includes("--") && !raw.startsWith("paragraph--"))) {
1311
+ throw new Error(`${where}.type must be a paragraph bundle.`);
1312
+ }
1313
+ return bundle;
1314
+ }
1315
+
1316
+ /**
1317
+ * @param {?object} ref
1318
+ * @returns {boolean}
1319
+ */
1320
+ function isReusable(ref) {
1321
+ if (ref?.entityType === "paragraphs_library_item") return true;
1322
+ const type = typeof ref?.type === "string" ? ref.type : "";
1323
+ return type.startsWith("paragraphs_library_item") || paragraphBundle(ref) === "from_library";
1324
+ }
1325
+
1326
+ /**
1327
+ * @returns {Error}
1328
+ */
1329
+ function reusableError() {
1330
+ return new Error(
1331
+ "This component is a reusable library item. Update it with drupal_entity_update. " +
1332
+ "No nested draft was started, and the shared paragraph was not edited."
1333
+ );
1334
+ }
1335
+
1336
+ /**
1337
+ * @param {?object} ref
1338
+ * @returns {?number|string}
1339
+ */
1340
+ function revisionOf(ref) {
1341
+ const revisionId = ref?.meta?.target_revision_id;
1342
+ return revisionId === undefined || revisionId === null || revisionId === "" ? null : revisionId;
1343
+ }
1344
+
1345
+ /**
1346
+ * @param {?object} ref
1347
+ * @returns {object}
1348
+ */
1349
+ function pinRef(ref) {
1350
+ const bundle = paragraphBundle(ref);
1351
+ return embedParagraphRef(bundle, ref.id, revisionOf(ref));
1352
+ }
1353
+
1354
+ /**
1355
+ * UUID plus the pinned paragraph revision. The same UUID at another revision
1356
+ * is not the prepared draft.
1357
+ * @param {?object} ref
1358
+ * @returns {string}
1359
+ */
1360
+ function pinKey(ref) {
1361
+ return `${String(ref?.id ?? "").toLowerCase()}#${String(revisionOf(ref) ?? "")}`;
1362
+ }
1363
+
1364
+ /**
1365
+ * @param {unknown} err
1366
+ * @returns {string}
1367
+ */
1368
+ function messageOf(err) {
1369
+ return err instanceof Error ? err.message : String(err);
1370
+ }
1371
+
1372
+ /**
1373
+ * @param {?string} left
1374
+ * @param {?string} right
1375
+ * @returns {boolean}
1376
+ */
1377
+ function sameId(left, right) {
1378
+ return String(left ?? "").toLowerCase() === String(right ?? "").toLowerCase();
1379
+ }
1380
+
1381
+ /**
1382
+ * @param {?object} ref
1383
+ * @param {string} label
1384
+ * @returns {number|string}
1385
+ */
1386
+ function requirePinRevision(ref, label) {
1387
+ const revisionId = revisionOf(ref);
1388
+ const numeric = Number(revisionId);
1389
+ if (!Number.isFinite(numeric) || numeric <= 0) {
1390
+ throw new Error(`${label} has no paragraph revision id. No write was attempted.`);
1391
+ }
1392
+ return revisionId;
1393
+ }
1394
+
1395
+ /**
1396
+ * @param {object[]} hostPins
1397
+ * @param {object[]} childPins
1398
+ * @returns {Set<string>}
1399
+ */
1400
+ function liveParagraphIds(hostPins, childPins) {
1401
+ return new Set(
1402
+ [...hostPins, ...childPins].map((ref) => ref?.id?.toLowerCase()).filter(Boolean),
1403
+ );
1404
+ }