drupal-mcp-connector 2.14.1 → 2.15.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.
@@ -17,6 +17,7 @@ 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
@@ -310,19 +311,88 @@ export class JsonApiBackend extends Backend {
310
311
  * (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
311
312
  * — so this method exposes it. Returns nulls for entities/backends without a
312
313
  * 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)}>}
314
+ *
315
+ * Unpublished default / forward revisions often omit `pid` on the computed
316
+ * `path` field even when a `path_alias` row exists. When the node numeric id
317
+ * is known, this method also looks up that row (aliases are not revisioned)
318
+ * so title-only edits can pin the live alias (#274).
319
+ * @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} ref
320
+ * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string), aliasId: ?string}>}
315
321
  */
316
- async getPathInfo({ entityType, bundle, id }) {
322
+ async getPathInfo({ entityType, bundle, id, resourceVersion }) {
317
323
  validateUuid(id);
318
- const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`);
324
+ let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
325
+ if (resourceVersion) {
326
+ path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
327
+ }
328
+ const data = await drupalFetch(this.site, path);
319
329
  const attrs = data?.data?.attributes ?? {};
320
- const path = attrs.path ?? null;
330
+ const nodePath = attrs.path ?? null;
331
+ const drupalId = attrs.drupal_internal__nid ?? attrs.drupal_internal__id ?? null;
332
+ const langcode = nodePath?.langcode ?? attrs.langcode ?? null;
333
+ let alias = nodePath?.alias ?? null;
334
+ let pid = nodePath?.pid ?? null;
335
+ let aliasId = null;
336
+
337
+ if (entityType === "node" && isPositiveNid(drupalId)) {
338
+ const row = await this.lookupPathAliasRow(`/node/${Number(drupalId)}`, {
339
+ langcode,
340
+ preferredAlias: alias,
341
+ });
342
+ if (row) {
343
+ aliasId = row.id;
344
+ if (pid === undefined || pid === null) pid = row.pid;
345
+ // Pathauto / unpublished computed fields can omit alias; the row is
346
+ // the router-visible value.
347
+ if (!alias) alias = row.alias;
348
+ }
349
+ }
350
+
351
+ return { alias, pid, langcode, drupalId, aliasId };
352
+ }
353
+
354
+ /**
355
+ * Load the path_alias row for a node source path. Best-effort: missing
356
+ * JSON:API exposure or an empty collection returns null.
357
+ * @param {string} sourcePath Drupal system path, e.g. `/node/44`.
358
+ * @param {{langcode?: ?string, preferredAlias?: ?string}} [opts]
359
+ * @returns {Promise<?{id: string, alias: ?string, pid: ?(number|string), langcode: ?string}>}
360
+ */
361
+ async lookupPathAliasRow(sourcePath, opts = {}) {
362
+ if (!/^\/node\/[1-9]\d*$/.test(sourcePath)) return null;
363
+ const params = new URLSearchParams();
364
+ params.set("filter[path]", sourcePath);
365
+ if (opts.langcode) params.set("filter[langcode]", String(opts.langcode));
366
+ let data;
367
+ try {
368
+ data = await drupalFetch(
369
+ this.site,
370
+ `${this.resourcePath(PATH_ALIAS_ENTITY_TYPE, PATH_ALIAS_ENTITY_TYPE)}?${params}`,
371
+ );
372
+ } catch {
373
+ return null;
374
+ }
375
+ const rows = Array.isArray(data?.data) ? data.data : [];
376
+ if (!rows.length) return null;
377
+ const preferred = normalizeAlias(opts.preferredAlias);
378
+ let picked = rows[0];
379
+ if (preferred) {
380
+ for (const row of rows) {
381
+ const attrs = row && typeof row === "object" ? row.attributes : null;
382
+ const rowAlias = attrs && typeof attrs === "object" ? attrs.alias : null;
383
+ if (normalizeAlias(rowAlias) === preferred) {
384
+ picked = row;
385
+ break;
386
+ }
387
+ }
388
+ }
389
+ if (!picked?.id) return null;
390
+ const a = picked.attributes && typeof picked.attributes === "object" ? picked.attributes : {};
321
391
  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,
392
+ id: picked.id,
393
+ alias: a.alias ?? null,
394
+ pid: a.drupal_internal__id ?? a.pid ?? null,
395
+ langcode: a.langcode ?? null,
326
396
  };
327
397
  }
328
398
 
@@ -1,8 +1,85 @@
1
1
  /**
2
- * Sentinel's governed draft-continuation contract (d.o #3621022).
2
+ * Sentinel's governed draft-continuation contract (d.o #3621022 / GitHub #176).
3
3
  * Core JSON:API revision selectors support reads, not PATCH requests.
4
+ * Translation create/update uses the same surface with X-MCP-Draft-Langcode.
4
5
  */
5
6
 
7
+ import { entityRevisionId } from "./write-revision.js";
8
+
9
+ const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
10
+ const MISSING_DRAFT_ENDPOINT =
11
+ "The site does not provide Sentinel's governed draft endpoint (d.o #3621022). " +
12
+ "Update the server-side module; the draft was not discarded and no canonical fallback was attempted.";
13
+ const MISSING_TRANSLATION_ENDPOINT =
14
+ "The site does not provide Sentinel's governed draft-translation endpoint. " +
15
+ "Update MCP Sentinel; no canonical langcode PATCH was attempted.";
16
+
17
+ /**
18
+ * @param {unknown} error
19
+ * @param {string} message
20
+ * @returns {Error}
21
+ */
22
+ function missingEndpointError(error, message) {
23
+ if (/Drupal (404|405)\b/.test(String(error?.message))) {
24
+ return new Error(message, { cause: error });
25
+ }
26
+ return error instanceof Error ? error : new Error(String(error));
27
+ }
28
+
29
+ /**
30
+ * @param {string} langcode
31
+ * @returns {string}
32
+ */
33
+ export function assertDraftLangcode(langcode) {
34
+ const value = String(langcode || "").trim();
35
+ if (!LANGCODE_RE.test(value)) {
36
+ throw new Error("A valid target langcode is required (for example 'es' or 'pt-br').");
37
+ }
38
+ return value;
39
+ }
40
+
41
+ /**
42
+ * @param {object} [draftRevision]
43
+ * @returns {{live: string, working: string}}
44
+ */
45
+ function requireWorkingPair(draftRevision) {
46
+ const live = String(draftRevision?.liveVid ?? "");
47
+ const working = String(draftRevision?.workingVid ?? "");
48
+ if (!/^[1-9]\d*$/.test(live) || !/^[1-9]\d*$/.test(working) || live === working) {
49
+ throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
50
+ }
51
+ return { live, working };
52
+ }
53
+
54
+ /**
55
+ * @param {object} backend
56
+ * @param {string} entityType
57
+ * @param {string} bundle
58
+ * @param {string} id
59
+ * @returns {string}
60
+ */
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.");
64
+ }
65
+ if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
66
+ throw new Error("This backend does not support governed draft continuation.");
67
+ }
68
+ return `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
69
+ }
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
+
6
83
  /**
7
84
  * Validate or continue a draft, using the same payload and revision precondition.
8
85
  * No canonical fallback: an absent endpoint or refused precondition stops work.
@@ -12,46 +89,342 @@
12
89
  * @returns {Promise<object>} Preflight metadata or the written canonical entity.
13
90
  */
14
91
  export async function writeDraft(backend, input, preflight = false) {
92
+ const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
93
+ if (entityType === "paragraph") {
94
+ return writeParagraphDraft(backend, input, preflight);
95
+ }
96
+ const { live, working } = requireWorkingPair(draftRevision);
97
+ const base = draftResource(backend, entityType, bundle, id);
98
+ const data = { type: `${entityType}--${bundle}`, id, attributes };
99
+ if (relationships) data.relationships = relationships;
100
+ const headers = {
101
+ "If-Match": `"${live}:${working}"`,
102
+ "X-MCP-Draft-Preflight": preflight ? "1" : "0",
103
+ };
104
+ const targetLang = langcode ? assertDraftLangcode(langcode) : null;
105
+ if (targetLang) headers["X-MCP-Draft-Langcode"] = targetLang;
106
+ let result;
107
+ try {
108
+ result = await backend.rawQuery({
109
+ path: `${base}/mcp-draft`,
110
+ options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
111
+ });
112
+ } catch (error) {
113
+ throw missingEndpointError(error, MISSING_DRAFT_ENDPOINT);
114
+ }
115
+ if (preflight) {
116
+ if (result?.meta?.draft_preflight !== true
117
+ || String(result.meta.live) !== live || String(result.meta.working) !== working) {
118
+ throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
119
+ }
120
+ if (targetLang && result.meta.langcode && String(result.meta.langcode) !== targetLang) {
121
+ throw new Error("The site did not confirm the requested translation language. Refusing to continue.");
122
+ }
123
+ return result;
124
+ }
125
+ if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
126
+ throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
127
+ }
128
+ return backend.toCanonical(result.data);
129
+ }
130
+
131
+ /**
132
+ * Create a target-language translation as an unpublished forward revision.
133
+ * If-Match is `"live"` when there is no working copy, or `"live:working"` when
134
+ * adding the language onto an existing unpublished English draft.
135
+ * @param {object} backend
136
+ * @param {object} input
137
+ * @param {boolean} [preflight]
138
+ * @returns {Promise<object>}
139
+ */
140
+ export async function createTranslationDraft(backend, input, preflight = false) {
15
141
  const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
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
+ }
16
148
  const live = String(draftRevision?.liveVid ?? "");
17
- const working = String(draftRevision?.workingVid ?? "");
18
- if (entityType !== "node" || !/^[1-9]\d*$/.test(live)
19
- || !/^[1-9]\d*$/.test(working) || live === working) {
20
- throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
149
+ const workingRaw = draftRevision?.workingVid;
150
+ const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
151
+ ? ""
152
+ : String(workingRaw);
153
+ if (entityType !== "node" || !/^[1-9]\d*$/.test(live)) {
154
+ throw new Error("Translation create requires a verified live node revision ID.");
21
155
  }
22
- if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
23
- throw new Error("This backend does not support governed draft continuation.");
156
+ if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
157
+ throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
24
158
  }
25
- const data = { type: `${entityType}--${bundle}`, id, attributes };
159
+ const base = draftResource(backend, entityType, bundle, id);
160
+ const safeAttributes = { ...attributes };
161
+ delete safeAttributes.langcode;
162
+ const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
26
163
  if (relationships) data.relationships = relationships;
164
+ const ifMatch = working ? `"${live}:${working}"` : `"${live}"`;
27
165
  let result;
28
166
  try {
29
167
  result = await backend.rawQuery({
30
- path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
168
+ path: `${base}/mcp-draft/translations`,
31
169
  options: {
32
- method: "PATCH",
170
+ method: "POST",
33
171
  headers: {
34
- "If-Match": `"${live}:${working}"`,
172
+ "If-Match": ifMatch,
35
173
  "X-MCP-Draft-Preflight": preflight ? "1" : "0",
174
+ "X-MCP-Draft-Langcode": langcode,
36
175
  },
37
176
  body: JSON.stringify({ data }),
38
177
  },
39
178
  });
40
179
  } catch (error) {
41
- if (/Drupal (404|405)\b/.test(String(error?.message))) {
42
- throw new Error("The site does not provide Sentinel's governed draft endpoint (d.o #3621022). Update the server-side module; the draft was not discarded and no canonical fallback was attempted.", { cause: error });
180
+ throw rewriteTranslationWorkingRevisionError(missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT));
181
+ }
182
+ if (preflight) {
183
+ if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
184
+ throw new Error("The site did not confirm a non-saving translation preflight. Refusing to continue.");
185
+ }
186
+ return result;
187
+ }
188
+ if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
189
+ throw new Error("Translation create response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
190
+ }
191
+ return backend.toCanonical(result.data);
192
+ }
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
+
248
+ /**
249
+ * Read live/working translation inventory from Sentinel.
250
+ * @param {object} backend
251
+ * @param {{entityType: string, bundle: string, id: string, revisionId?: string|number}} ref
252
+ * @returns {Promise<object>}
253
+ */
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
+ }
260
+ try {
261
+ const result = await backend.rawQuery({
262
+ path: `${base}/mcp-translations`,
263
+ options: Object.keys(headers).length ? { method: "GET", headers } : undefined,
264
+ });
265
+ if (!result?.meta?.live) {
266
+ throw new Error("The site did not return a translation inventory.");
43
267
  }
44
- throw error;
268
+ return result.meta;
269
+ } catch (error) {
270
+ throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Read one unpublished working translation.
276
+ * @param {object} backend
277
+ * @param {object} input
278
+ * @returns {Promise<object>}
279
+ */
280
+ export async function readDraftTranslation(backend, input) {
281
+ const { entityType, bundle, id, draftRevision } = input;
282
+ const langcode = assertDraftLangcode(input.langcode);
283
+ if (entityType === "paragraph") {
284
+ return readParagraphDraftTranslation(backend, input);
285
+ }
286
+ const { live, working } = requireWorkingPair(draftRevision);
287
+ const base = draftResource(backend, entityType, bundle, id);
288
+ let result;
289
+ try {
290
+ result = await backend.rawQuery({
291
+ path: `${base}/mcp-draft`,
292
+ options: {
293
+ method: "GET",
294
+ headers: {
295
+ "If-Match": `"${live}:${working}"`,
296
+ "X-MCP-Draft-Langcode": langcode,
297
+ },
298
+ },
299
+ });
300
+ } catch (error) {
301
+ throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
302
+ }
303
+ if (!result?.data || result.data.id !== id) {
304
+ throw new Error("Draft translation read did not identify the requested entity.");
305
+ }
306
+ return backend.toCanonical(result.data);
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);
45
341
  }
46
342
  if (preflight) {
47
343
  if (result?.meta?.draft_preflight !== true
48
- || String(result.meta.live) !== live || String(result.meta.working) !== working) {
49
- throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
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.");
50
347
  }
51
348
  return result;
52
349
  }
53
350
  if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
54
- throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
351
+ throw new Error("Paragraph translation write did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
55
352
  }
56
- return backend.toCanonical(result.data);
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 };
57
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",