drupal-mcp-connector 2.14.2 → 2.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,20 +17,37 @@ import {
17
17
  normalizeRelationship,
18
18
  BASE_ATTRIBUTE_FIELDS,
19
19
  } from "../canonical.js";
20
+ import { isPositiveNid, normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../path-alias.js";
20
21
 
21
22
  // Drupal exposes internal identifiers under drupal_internal__* attributes.
22
23
  // They are dropped from canonical `fields` except for the identifiers that
23
24
  // governed read/write workflows explicitly need.
24
25
  const INTERNAL_ATTR_RE = /^drupal_internal__/;
25
26
 
26
- // countEntities() pagination. Drupal core JSON:API returns no total in `meta`,
27
- // so an exact count is obtained by walking pages until `links.next` is gone.
28
- // COUNT_PAGE_SIZE is Drupal's default max page size; COUNT_MAX_RECORDS bounds
29
- // the walk so a huge collection can't issue unbounded requests (mirrors the
30
- // GraphQL backend's MAX_CLIENT_RECORDS) — past it the count is approximate.
27
+ // countEntities() / listEntities() pagination. Drupal core JSON:API returns no
28
+ // total in `meta` and silently caps `page[limit]` at OffsetPage::SIZE_MAX
29
+ // (50 by default). COUNT_PAGE_SIZE matches that default; COUNT_MAX_RECORDS
30
+ // bounds a walk so a huge collection can't issue unbounded requests (mirrors
31
+ // the GraphQL backend's MAX_CLIENT_RECORDS) — past it the count is approximate.
31
32
  const COUNT_PAGE_SIZE = 50;
32
33
  const COUNT_MAX_RECORDS = 1000;
33
34
 
35
+ /**
36
+ * Whether a JSON:API collection document advertises another page.
37
+ * `links.next` may be a string href or a `{ href }` link object.
38
+ * @param {?object} data JSON:API document.
39
+ * @returns {boolean}
40
+ */
41
+ function jsonApiHasNext(data) {
42
+ const next = data?.links?.next;
43
+ if (next === undefined || next === null || next === false) return false;
44
+ if (typeof next === "string") return next.length > 0;
45
+ if (typeof next === "object" && next.href !== undefined && next.href !== null) {
46
+ return String(next.href).length > 0;
47
+ }
48
+ return Boolean(next);
49
+ }
50
+
34
51
  /**
35
52
  * Detect the JSON:API error Drupal returns when a write attempts to set the
36
53
  * `status` (published) field on a content_moderation-governed entity. Such
@@ -267,24 +284,72 @@ export class JsonApiBackend extends Backend {
267
284
  }
268
285
 
269
286
  /**
270
- * List entities for a descriptor. Server-side filter/sort/paging means the
271
- * result is always exact (`approximate`/`truncated` are false).
287
+ * List entities for a descriptor.
288
+ *
289
+ * Drupal core JSON:API has no `meta.count` (jsonapi_extras can add it) and
290
+ * silently caps `page[limit]` at OffsetPage::SIZE_MAX (50 by default). When
291
+ * the caller asks for more rows than one Drupal page returns and
292
+ * `links.next` is present, this method follows that link until the requested
293
+ * window is filled, the collection ends, or COUNT_MAX_RECORDS is hit.
294
+ * `page.total` is exact when `meta.count` is present or this window reached
295
+ * the end; otherwise it is the number of rows seen so far and `approximate`
296
+ * is true. Never report a single page's length as an exact collection total.
297
+ *
272
298
  * @param {import("../canonical.js").QueryDescriptor} descriptor
273
299
  * @returns {Promise<import("./backend-interface.js").ListResult>}
274
300
  */
275
301
  async listEntities(descriptor) {
276
- const params = this.compileQuery(descriptor);
277
- const qs = params.toString();
278
- const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
279
- const path = qs ? `${base}?${qs}` : base;
280
- const data = await drupalFetch(this.site, path);
281
- const entities = (data.data || []).map((r) => this.toCanonical(r));
282
- const total = data.meta?.count ?? entities.length;
302
+ const requestedLimit = descriptor.page?.limit;
303
+ const startOffset = descriptor.page?.offset ?? 0;
304
+ const fillTo = typeof requestedLimit === "number"
305
+ ? Math.min(Math.max(0, requestedLimit), COUNT_MAX_RECORDS)
306
+ : null;
307
+
308
+ const entities = [];
309
+ let offset = startOffset;
310
+ let hasNext = false;
311
+ let metaCount = null;
312
+
313
+ for (;;) {
314
+ const remaining = fillTo === null ? requestedLimit : fillTo - entities.length;
315
+ const page = {
316
+ ...descriptor.page,
317
+ offset,
318
+ ...(typeof remaining === "number" ? { limit: remaining } : {}),
319
+ };
320
+ const params = this.compileQuery({ ...descriptor, page });
321
+ const qs = params.toString();
322
+ const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
323
+ const path = qs ? `${base}?${qs}` : base;
324
+ const data = await drupalFetch(this.site, path);
325
+ if (metaCount === null && typeof data?.meta?.count === "number") {
326
+ metaCount = data.meta.count;
327
+ }
328
+ const pageEntities = (data.data || []).map((r) => this.toCanonical(r));
329
+ entities.push(...pageEntities);
330
+ hasNext = jsonApiHasNext(data);
331
+
332
+ if (fillTo === null) break;
333
+ if (!hasNext || pageEntities.length === 0) break;
334
+ if (entities.length >= fillTo) break;
335
+ offset += pageEntities.length;
336
+ }
337
+
338
+ const truncated = fillTo !== null
339
+ && typeof requestedLimit === "number"
340
+ && entities.length < requestedLimit
341
+ && hasNext;
342
+ const seen = startOffset + entities.length;
343
+ const exact = typeof metaCount === "number" || !hasNext;
283
344
  return {
284
345
  entities,
285
- page: { total, hasNext: Boolean(data.links?.next), cursor: null },
286
- approximate: false,
287
- truncated: false,
346
+ page: {
347
+ total: typeof metaCount === "number" ? metaCount : seen,
348
+ hasNext,
349
+ cursor: null,
350
+ },
351
+ approximate: !exact,
352
+ truncated,
288
353
  };
289
354
  }
290
355
 
@@ -310,19 +375,88 @@ export class JsonApiBackend extends Backend {
310
375
  * (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
311
376
  * — so this method exposes it. Returns nulls for entities/backends without a
312
377
  * path field. See the 1.5.1 alias fix.
313
- * @param {{entityType: string, bundle: string, id: string}} ref
314
- * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
378
+ *
379
+ * Unpublished default / forward revisions often omit `pid` on the computed
380
+ * `path` field even when a `path_alias` row exists. When the node numeric id
381
+ * is known, this method also looks up that row (aliases are not revisioned)
382
+ * so title-only edits can pin the live alias (#274).
383
+ * @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} ref
384
+ * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string), aliasId: ?string}>}
315
385
  */
316
- async getPathInfo({ entityType, bundle, id }) {
386
+ async getPathInfo({ entityType, bundle, id, resourceVersion }) {
317
387
  validateUuid(id);
318
- const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`);
388
+ let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
389
+ if (resourceVersion) {
390
+ path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
391
+ }
392
+ const data = await drupalFetch(this.site, path);
319
393
  const attrs = data?.data?.attributes ?? {};
320
- const path = attrs.path ?? null;
394
+ const nodePath = attrs.path ?? null;
395
+ const drupalId = attrs.drupal_internal__nid ?? attrs.drupal_internal__id ?? null;
396
+ const langcode = nodePath?.langcode ?? attrs.langcode ?? null;
397
+ let alias = nodePath?.alias ?? null;
398
+ let pid = nodePath?.pid ?? null;
399
+ let aliasId = null;
400
+
401
+ if (entityType === "node" && isPositiveNid(drupalId)) {
402
+ const row = await this.lookupPathAliasRow(`/node/${Number(drupalId)}`, {
403
+ langcode,
404
+ preferredAlias: alias,
405
+ });
406
+ if (row) {
407
+ aliasId = row.id;
408
+ if (pid === undefined || pid === null) pid = row.pid;
409
+ // Pathauto / unpublished computed fields can omit alias; the row is
410
+ // the router-visible value.
411
+ if (!alias) alias = row.alias;
412
+ }
413
+ }
414
+
415
+ return { alias, pid, langcode, drupalId, aliasId };
416
+ }
417
+
418
+ /**
419
+ * Load the path_alias row for a node source path. Best-effort: missing
420
+ * JSON:API exposure or an empty collection returns null.
421
+ * @param {string} sourcePath Drupal system path, e.g. `/node/44`.
422
+ * @param {{langcode?: ?string, preferredAlias?: ?string}} [opts]
423
+ * @returns {Promise<?{id: string, alias: ?string, pid: ?(number|string), langcode: ?string}>}
424
+ */
425
+ async lookupPathAliasRow(sourcePath, opts = {}) {
426
+ if (!/^\/node\/[1-9]\d*$/.test(sourcePath)) return null;
427
+ const params = new URLSearchParams();
428
+ params.set("filter[path]", sourcePath);
429
+ if (opts.langcode) params.set("filter[langcode]", String(opts.langcode));
430
+ let data;
431
+ try {
432
+ data = await drupalFetch(
433
+ this.site,
434
+ `${this.resourcePath(PATH_ALIAS_ENTITY_TYPE, PATH_ALIAS_ENTITY_TYPE)}?${params}`,
435
+ );
436
+ } catch {
437
+ return null;
438
+ }
439
+ const rows = Array.isArray(data?.data) ? data.data : [];
440
+ if (!rows.length) return null;
441
+ const preferred = normalizeAlias(opts.preferredAlias);
442
+ let picked = rows[0];
443
+ if (preferred) {
444
+ for (const row of rows) {
445
+ const attrs = row && typeof row === "object" ? row.attributes : null;
446
+ const rowAlias = attrs && typeof attrs === "object" ? attrs.alias : null;
447
+ if (normalizeAlias(rowAlias) === preferred) {
448
+ picked = row;
449
+ break;
450
+ }
451
+ }
452
+ }
453
+ if (!picked?.id) return null;
454
+ const a = picked.attributes && typeof picked.attributes === "object" ? picked.attributes : {};
321
455
  return {
322
- alias: path?.alias ?? null,
323
- pid: path?.pid ?? null,
324
- langcode: path?.langcode ?? attrs.langcode ?? null,
325
- drupalId: attrs.drupal_internal__nid ?? attrs.drupal_internal__id ?? null,
456
+ id: picked.id,
457
+ alias: a.alias ?? null,
458
+ pid: a.drupal_internal__id ?? a.pid ?? null,
459
+ langcode: a.langcode ?? null,
326
460
  };
327
461
  }
328
462
 
@@ -4,6 +4,8 @@
4
4
  * Translation create/update uses the same surface with X-MCP-Draft-Langcode.
5
5
  */
6
6
 
7
+ import { entityRevisionId } from "./write-revision.js";
8
+
7
9
  const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
8
10
  const MISSING_DRAFT_ENDPOINT =
9
11
  "The site does not provide Sentinel's governed draft endpoint (d.o #3621022). " +
@@ -56,9 +58,9 @@ function requireWorkingPair(draftRevision) {
56
58
  * @param {string} id
57
59
  * @returns {string}
58
60
  */
59
- function nodeResource(backend, entityType, bundle, id) {
60
- if (entityType !== "node") {
61
- throw new Error("Governed draft translation is implemented for nodes.");
61
+ function draftResource(backend, entityType, bundle, id) {
62
+ if (entityType !== "node" && entityType !== "paragraph") {
63
+ throw new Error("Governed draft translation is implemented for nodes and paragraphs.");
62
64
  }
63
65
  if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
64
66
  throw new Error("This backend does not support governed draft continuation.");
@@ -66,6 +68,18 @@ function nodeResource(backend, entityType, bundle, id) {
66
68
  return `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
67
69
  }
68
70
 
71
+ /**
72
+ * @param {object} [draftRevision]
73
+ * @returns {string}
74
+ */
75
+ function requireParagraphRevisionId(draftRevision) {
76
+ const revisionId = String(draftRevision?.revisionId ?? draftRevision?.workingVid ?? "");
77
+ if (!/^[1-9]\d*$/.test(revisionId)) {
78
+ throw new Error("Paragraph translation requires a verified paragraph revision ID.");
79
+ }
80
+ return revisionId;
81
+ }
82
+
69
83
  /**
70
84
  * Validate or continue a draft, using the same payload and revision precondition.
71
85
  * No canonical fallback: an absent endpoint or refused precondition stops work.
@@ -76,8 +90,11 @@ function nodeResource(backend, entityType, bundle, id) {
76
90
  */
77
91
  export async function writeDraft(backend, input, preflight = false) {
78
92
  const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
93
+ if (entityType === "paragraph") {
94
+ return writeParagraphDraft(backend, input, preflight);
95
+ }
79
96
  const { live, working } = requireWorkingPair(draftRevision);
80
- const base = nodeResource(backend, entityType, bundle, id);
97
+ const base = draftResource(backend, entityType, bundle, id);
81
98
  const data = { type: `${entityType}--${bundle}`, id, attributes };
82
99
  if (relationships) data.relationships = relationships;
83
100
  const headers = {
@@ -123,6 +140,11 @@ export async function writeDraft(backend, input, preflight = false) {
123
140
  export async function createTranslationDraft(backend, input, preflight = false) {
124
141
  const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
125
142
  const langcode = assertDraftLangcode(input.langcode);
143
+ if (entityType === "paragraph") {
144
+ return createParagraphTranslationDraft(backend, {
145
+ entityType, bundle, id, attributes, relationships, draftRevision, langcode,
146
+ }, preflight);
147
+ }
126
148
  const live = String(draftRevision?.liveVid ?? "");
127
149
  const workingRaw = draftRevision?.workingVid;
128
150
  const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
@@ -134,7 +156,7 @@ export async function createTranslationDraft(backend, input, preflight = false)
134
156
  if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
135
157
  throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
136
158
  }
137
- const base = nodeResource(backend, entityType, bundle, id);
159
+ const base = draftResource(backend, entityType, bundle, id);
138
160
  const safeAttributes = { ...attributes };
139
161
  delete safeAttributes.langcode;
140
162
  const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
@@ -155,7 +177,7 @@ export async function createTranslationDraft(backend, input, preflight = false)
155
177
  },
156
178
  });
157
179
  } catch (error) {
158
- throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
180
+ throw rewriteTranslationWorkingRevisionError(missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT));
159
181
  }
160
182
  if (preflight) {
161
183
  if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
@@ -169,16 +191,77 @@ export async function createTranslationDraft(backend, input, preflight = false)
169
191
  return backend.toCanonical(result.data);
170
192
  }
171
193
 
194
+ /**
195
+ * Live + working revision ids for a node translation create.
196
+ * Prefers Sentinel's mcp-translations inventory over JSON:API
197
+ * `rel:working-copy`, which 403s on some unpublished drafts even when a
198
+ * working revision exists (#282).
199
+ * @param {object} backend
200
+ * @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
201
+ * @returns {Promise<{liveVid: ?(number|string), workingVid: ?(number|string)}>}
202
+ */
203
+ export async function resolveNodeTranslationPair(backend, { entityType, bundle, id, existing }) {
204
+ const fromEntity = existing ? entityRevisionId(existing) : null;
205
+ try {
206
+ const meta = await readTranslationInventory(backend, { entityType, bundle, id });
207
+ const liveVid = meta.live?.vid ?? fromEntity;
208
+ const workingVid = meta.working?.vid;
209
+ const distinct = workingVid !== undefined && workingVid !== null && workingVid !== ""
210
+ && liveVid !== undefined && liveVid !== null
211
+ && String(workingVid) !== String(liveVid);
212
+ return { liveVid, workingVid: distinct ? workingVid : undefined };
213
+ } catch {
214
+ if (typeof backend.getEntity !== "function") {
215
+ return { liveVid: fromEntity, workingVid: undefined };
216
+ }
217
+ let workingCopy = null;
218
+ try {
219
+ workingCopy = await backend.getEntity({
220
+ entityType, bundle, id, resourceVersion: "rel:working-copy",
221
+ });
222
+ } catch {
223
+ workingCopy = null;
224
+ }
225
+ const workingVid = workingCopy ? entityRevisionId(workingCopy) : null;
226
+ const distinct = workingVid !== null && fromEntity !== null && String(workingVid) !== String(fromEntity);
227
+ return { liveVid: fromEntity, workingVid: distinct ? workingVid : undefined };
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Rewrite Sentinel's live-only 409 into an actionable connector error.
233
+ * @param {unknown} error
234
+ * @returns {Error}
235
+ */
236
+ export function rewriteTranslationWorkingRevisionError(error) {
237
+ if (/A working revision exists\. Reload and send both revision IDs/i.test(String(error?.message || ""))) {
238
+ return new Error(
239
+ "Translation create sent only the live revision, but a working draft exists. " +
240
+ "The connector should have sent both live and working revision IDs (If-Match). " +
241
+ "Reload with drupal_list_translations and retry. See connector #282.",
242
+ { cause: error instanceof Error ? error : undefined },
243
+ );
244
+ }
245
+ return error instanceof Error ? error : new Error(String(error));
246
+ }
247
+
172
248
  /**
173
249
  * Read live/working translation inventory from Sentinel.
174
250
  * @param {object} backend
175
- * @param {{entityType: string, bundle: string, id: string}} ref
251
+ * @param {{entityType: string, bundle: string, id: string, revisionId?: string|number}} ref
176
252
  * @returns {Promise<object>}
177
253
  */
178
- export async function readTranslationInventory(backend, { entityType, bundle, id }) {
179
- const base = nodeResource(backend, entityType, bundle, id);
254
+ export async function readTranslationInventory(backend, { entityType, bundle, id, revisionId }) {
255
+ const base = draftResource(backend, entityType, bundle, id);
256
+ const headers = {};
257
+ if (revisionId !== undefined && revisionId !== null && revisionId !== "") {
258
+ headers["If-Match"] = `"${requireParagraphRevisionId({ revisionId })}"`;
259
+ }
180
260
  try {
181
- const result = await backend.rawQuery({ path: `${base}/mcp-translations` });
261
+ const result = await backend.rawQuery({
262
+ path: `${base}/mcp-translations`,
263
+ options: Object.keys(headers).length ? { method: "GET", headers } : undefined,
264
+ });
182
265
  if (!result?.meta?.live) {
183
266
  throw new Error("The site did not return a translation inventory.");
184
267
  }
@@ -197,8 +280,11 @@ export async function readTranslationInventory(backend, { entityType, bundle, id
197
280
  export async function readDraftTranslation(backend, input) {
198
281
  const { entityType, bundle, id, draftRevision } = input;
199
282
  const langcode = assertDraftLangcode(input.langcode);
283
+ if (entityType === "paragraph") {
284
+ return readParagraphDraftTranslation(backend, input);
285
+ }
200
286
  const { live, working } = requireWorkingPair(draftRevision);
201
- const base = nodeResource(backend, entityType, bundle, id);
287
+ const base = draftResource(backend, entityType, bundle, id);
202
288
  let result;
203
289
  try {
204
290
  result = await backend.rawQuery({
@@ -219,3 +305,126 @@ export async function readDraftTranslation(backend, input) {
219
305
  }
220
306
  return backend.toCanonical(result.data);
221
307
  }
308
+
309
+ /**
310
+ * Continue an unpublished paragraph translation on a pinned revision.
311
+ * @param {object} backend
312
+ * @param {object} input
313
+ * @param {boolean} [preflight]
314
+ * @returns {Promise<object>}
315
+ */
316
+ async function writeParagraphDraft(backend, input, preflight = false) {
317
+ const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
318
+ const langcode = assertDraftLangcode(input.langcode);
319
+ const revisionId = requireParagraphRevisionId(draftRevision);
320
+ const draftState = input.draftState;
321
+ if (typeof draftState !== "string" || !/^[a-f0-9]{64}$/.test(draftState)) {
322
+ throw new Error("Paragraph translation update requires draftState from the previous draft read. Re-read the draft; do not retry old copy with a refreshed token.");
323
+ }
324
+ const base = draftResource(backend, entityType, bundle, id);
325
+ const data = { type: `${entityType}--${bundle}`, id, attributes };
326
+ if (relationships) data.relationships = relationships;
327
+ const headers = {
328
+ "If-Match": `"${revisionId}"`,
329
+ "X-MCP-Draft-Preflight": preflight ? "1" : "0",
330
+ "X-MCP-Draft-Langcode": langcode,
331
+ "X-MCP-Draft-State": draftState,
332
+ };
333
+ let result;
334
+ try {
335
+ result = await backend.rawQuery({
336
+ path: `${base}/mcp-draft`,
337
+ options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
338
+ });
339
+ } catch (error) {
340
+ throw missingEndpointError(error, MISSING_DRAFT_ENDPOINT);
341
+ }
342
+ if (preflight) {
343
+ if (result?.meta?.draft_preflight !== true
344
+ || String(result.meta.live) !== revisionId
345
+ || (result.meta.langcode && String(result.meta.langcode) !== langcode)) {
346
+ throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
347
+ }
348
+ return result;
349
+ }
350
+ if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
351
+ throw new Error("Paragraph translation write did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
352
+ }
353
+ return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
354
+ }
355
+
356
+ /**
357
+ * Create an unpublished paragraph translation on a pinned revision.
358
+ * @param {object} backend
359
+ * @param {object} input
360
+ * @param {boolean} [preflight]
361
+ * @returns {Promise<object>}
362
+ */
363
+ async function createParagraphTranslationDraft(backend, input, preflight = false) {
364
+ const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
365
+ const revisionId = requireParagraphRevisionId(draftRevision);
366
+ const base = draftResource(backend, entityType, bundle, id);
367
+ const safeAttributes = { ...attributes };
368
+ delete safeAttributes.langcode;
369
+ const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
370
+ if (relationships) data.relationships = relationships;
371
+ let result;
372
+ try {
373
+ result = await backend.rawQuery({
374
+ path: `${base}/mcp-draft/translations`,
375
+ options: {
376
+ method: "POST",
377
+ headers: {
378
+ "If-Match": `"${revisionId}"`,
379
+ "X-MCP-Draft-Preflight": preflight ? "1" : "0",
380
+ "X-MCP-Draft-Langcode": langcode,
381
+ },
382
+ body: JSON.stringify({ data }),
383
+ },
384
+ });
385
+ } catch (error) {
386
+ throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
387
+ }
388
+ if (preflight) {
389
+ if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== revisionId) {
390
+ throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
391
+ }
392
+ return result;
393
+ }
394
+ if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
395
+ throw new Error("Paragraph translation create did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
396
+ }
397
+ return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
398
+ }
399
+
400
+ /**
401
+ * Read one unpublished paragraph translation of a pinned revision.
402
+ * @param {object} backend
403
+ * @param {object} input
404
+ * @returns {Promise<object>}
405
+ */
406
+ async function readParagraphDraftTranslation(backend, input) {
407
+ const { entityType, bundle, id, draftRevision } = input;
408
+ const langcode = assertDraftLangcode(input.langcode);
409
+ const revisionId = requireParagraphRevisionId(draftRevision);
410
+ const base = draftResource(backend, entityType, bundle, id);
411
+ let result;
412
+ try {
413
+ result = await backend.rawQuery({
414
+ path: `${base}/mcp-draft`,
415
+ options: {
416
+ method: "GET",
417
+ headers: {
418
+ "If-Match": `"${revisionId}"`,
419
+ "X-MCP-Draft-Langcode": langcode,
420
+ },
421
+ },
422
+ });
423
+ } catch (error) {
424
+ throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
425
+ }
426
+ if (!result?.data || result.data.id !== id) {
427
+ throw new Error("Paragraph draft translation read did not identify the requested entity.");
428
+ }
429
+ return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
430
+ }
@@ -75,6 +75,31 @@ export function flagUnrequestedStatusChange(result, existing, sentAttributes) {
75
75
  };
76
76
  }
77
77
 
78
+ /** Why computed `metatag` is dropped on unpublished working-translation bodies. */
79
+ export const METATAG_OMITTED_NOTE =
80
+ "The computed metatag array is omitted: JSON:API resolves it from the live " +
81
+ "default revision, not the unpublished working translation. Use the stored " +
82
+ "field (field_metatags / field_metatag) to verify the draft. See connector #283.";
83
+
84
+ /**
85
+ * Drop the computed `metatag` array from a working-translation body.
86
+ * The stored override field is left intact. No-op when `metatag` is absent.
87
+ * @param {?object} entity Canonical entity.
88
+ * @returns {?object}
89
+ */
90
+ export function omitLiveComputedMetatag(entity) {
91
+ if (!entity || typeof entity !== "object") return entity;
92
+ const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : null;
93
+ if (!fields || !Object.prototype.hasOwnProperty.call(fields, "metatag")) return entity;
94
+ const nextFields = { ...fields };
95
+ delete nextFields.metatag;
96
+ return {
97
+ ...entity,
98
+ fields: nextFields,
99
+ _metatagOmitted: { reason: METATAG_OMITTED_NOTE },
100
+ };
101
+ }
102
+
78
103
  /** JSON Schema fragment for the shared `returning` parameter. */
79
104
  export const RETURNING_SCHEMA = {
80
105
  type: "string",