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.
@@ -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
+ }