drupal-mcp-connector 2.14.2 → 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.
- package/.agents/commands/drupal-create-translation.md +7 -5
- package/.agents/commands/drupal-entity-update.md +1 -1
- package/.agents/commands/drupal-get-paragraph.md +5 -3
- package/.agents/commands/drupal-list-translations.md +2 -2
- package/.agents/commands/drupal-update-node.md +2 -2
- package/.agents/commands/drupal-update-paragraph.md +6 -3
- package/CHANGELOG.md +52 -0
- package/README.md +5 -3
- package/package.json +1 -1
- package/scripts/generate-commands.js +63 -4
- package/scripts/install-commands.js +33 -10
- package/src/lib/backends/backend-interface.js +5 -3
- package/src/lib/backends/jsonapi.js +79 -9
- package/src/lib/draft-write.js +220 -11
- package/src/lib/entity-response.js +25 -0
- package/src/lib/patch-preflight.js +122 -11
- package/src/lib/path-alias.js +37 -0
- package/src/tools/entities.js +1 -1
- package/src/tools/nodes.js +91 -21
- package/src/tools/paragraphs.js +51 -11
- package/src/tools/revisions.js +2 -1
- package/src/tools/translations.js +70 -41
package/src/lib/draft-write.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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({
|
|
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 =
|
|
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",
|
|
@@ -35,6 +35,9 @@ export const PATCH_WORKING_COPY_STALE_CODE = "PATCH_WORKING_COPY_STALE";
|
|
|
35
35
|
/** Stable error code when the working-copy resource does not match the target. */
|
|
36
36
|
export const PATCH_TARGET_AMBIGUOUS_CODE = "PATCH_TARGET_AMBIGUOUS";
|
|
37
37
|
|
|
38
|
+
/** Stable error code for Sentinel's save-time stale-default-revision refusal. */
|
|
39
|
+
export const STALE_COPY_CODE = "STALE_COPY";
|
|
40
|
+
|
|
38
41
|
const WORKING_COPY_PATCH_RE = /has a working copy is not yet supported/i;
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -98,6 +101,81 @@ export class WorkingCopyStaleError extends Error {
|
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Sentinel's save-time stale-default check (McpWritePreconditions) is not
|
|
106
|
+
* exercised by the id-mismatch PATCH probe — that probe fails before
|
|
107
|
+
* entity validation / presave. A published node with no distinct working
|
|
108
|
+
* copy and a changed timestamp later than its own revision_timestamp is
|
|
109
|
+
* the readable fingerprint (`possiblyPatchBlocked`). dryRun and the real
|
|
110
|
+
* write must refuse the same way; reloading and retrying does not help.
|
|
111
|
+
* Do not bypass the draft or publish gate. See connector #273.
|
|
112
|
+
*/
|
|
113
|
+
export const STALE_COPY_MESSAGE =
|
|
114
|
+
"This entity cannot be updated: MCP Sentinel refused a stale default-revision " +
|
|
115
|
+
"write (the content changed after this copy was loaded). dryRun and the real " +
|
|
116
|
+
"write share this check. rel:latest-version and rel:working-copy report the " +
|
|
117
|
+
"same vid, but the default revision's changed timestamp is later than its " +
|
|
118
|
+
"revision_timestamp (possiblyPatchBlocked). Reloading and retrying the same " +
|
|
119
|
+
"canonical PATCH will not help. Do not bypass the draft or publish gate. " +
|
|
120
|
+
"See connector #273 / #201.";
|
|
121
|
+
|
|
122
|
+
const STALE_COPY_RE = /changed after this copy was loaded/i;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Thrown when a canonical write (or its dryRun) would hit Sentinel's
|
|
126
|
+
* stale-default-revision check (#273).
|
|
127
|
+
*/
|
|
128
|
+
export class StaleCopyError extends Error {
|
|
129
|
+
/**
|
|
130
|
+
* @param {?Error} [cause]
|
|
131
|
+
*/
|
|
132
|
+
constructor(cause) {
|
|
133
|
+
super(STALE_COPY_MESSAGE);
|
|
134
|
+
this.name = "StaleCopyError";
|
|
135
|
+
this.code = STALE_COPY_CODE;
|
|
136
|
+
if (cause) this.cause = cause;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether an error is Sentinel's stale-version refusal (or our rewrite).
|
|
142
|
+
* @param {unknown} err
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function isStaleCopyError(err) {
|
|
146
|
+
if (err instanceof StaleCopyError) return true;
|
|
147
|
+
return STALE_COPY_RE.test(String(err?.message || ""));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether a canonical entity's `changed` is later than its own
|
|
152
|
+
* `revision_timestamp` — the same fingerprint `drupal_list_revisions`
|
|
153
|
+
* reports as `possiblyPatchBlocked`.
|
|
154
|
+
* @param {?object} entity Canonical entity or revision summary.
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
export function changedAheadOfRevision(entity) {
|
|
158
|
+
if (!entity || typeof entity !== "object") return false;
|
|
159
|
+
const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : {};
|
|
160
|
+
const changed = entity.changed;
|
|
161
|
+
const rev = entity.revisionTimestamp ?? fields.revision_timestamp;
|
|
162
|
+
if (!changed || !rev) return false;
|
|
163
|
+
const changedMs = Date.parse(changed);
|
|
164
|
+
const revMs = Date.parse(rev);
|
|
165
|
+
if (!Number.isFinite(changedMs) || !Number.isFinite(revMs)) return false;
|
|
166
|
+
return changedMs > revMs;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Rewrite Sentinel's save-time stale-version refusal into {@link StaleCopyError}.
|
|
171
|
+
* @param {unknown} err
|
|
172
|
+
* @returns {unknown}
|
|
173
|
+
*/
|
|
174
|
+
export function rewriteStaleCopyError(err) {
|
|
175
|
+
if (!isStaleCopyError(err) || err instanceof StaleCopyError) return err;
|
|
176
|
+
return new StaleCopyError(err instanceof Error ? err : new Error(String(err)));
|
|
177
|
+
}
|
|
178
|
+
|
|
101
179
|
/**
|
|
102
180
|
* Thrown when `rel:working-copy` resolves to a different UUID than the
|
|
103
181
|
* entity being updated.
|
|
@@ -305,6 +383,25 @@ export async function prepareGuardedPatch(backend, {
|
|
|
305
383
|
const target = shouldPreflightPatch({ existing, attributes })
|
|
306
384
|
? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
|
|
307
385
|
: { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
|
|
386
|
+
if (shouldPreflightPatch({ existing, attributes }) && !target.resourceVersion) {
|
|
387
|
+
// Canonical path (no distinct working copy). The id-mismatch probe never
|
|
388
|
+
// reaches Sentinel's save-time stale-default check; refuse here when the
|
|
389
|
+
// possiblyPatchBlocked fingerprint is already readable (#273).
|
|
390
|
+
let fingerprint = existing;
|
|
391
|
+
const fields = fingerprint?.fields && typeof fingerprint.fields === "object" ? fingerprint.fields : {};
|
|
392
|
+
const hasTimestamps = Boolean(
|
|
393
|
+
fingerprint?.changed && (fingerprint.revisionTimestamp || fields.revision_timestamp),
|
|
394
|
+
);
|
|
395
|
+
if (!hasTimestamps && typeof backend?.getEntity === "function") {
|
|
396
|
+
const latest = await backend.getEntity({
|
|
397
|
+
entityType, bundle, id, resourceVersion: "rel:latest-version",
|
|
398
|
+
}).catch(() => null);
|
|
399
|
+
if (latest) fingerprint = latest;
|
|
400
|
+
}
|
|
401
|
+
if (changedAheadOfRevision(fingerprint)) {
|
|
402
|
+
throw new StaleCopyError();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
308
405
|
if (langcode) {
|
|
309
406
|
if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
|
|
310
407
|
throw new Error(
|
|
@@ -313,23 +410,35 @@ export async function prepareGuardedPatch(backend, {
|
|
|
313
410
|
);
|
|
314
411
|
}
|
|
315
412
|
target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
413
|
+
try {
|
|
414
|
+
await writeDraft(backend, {
|
|
415
|
+
entityType, bundle, id, attributes, relationships, langcode,
|
|
416
|
+
draftRevision: target.draftRevision,
|
|
417
|
+
}, true);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
throw rewriteStaleCopyError(err);
|
|
420
|
+
}
|
|
320
421
|
return target;
|
|
321
422
|
}
|
|
322
423
|
if (target.resourceVersion) {
|
|
323
424
|
target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
425
|
+
try {
|
|
426
|
+
await writeDraft(backend, {
|
|
427
|
+
entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
|
|
428
|
+
}, true);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
throw rewriteStaleCopyError(err);
|
|
431
|
+
}
|
|
327
432
|
return target;
|
|
328
433
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
434
|
+
try {
|
|
435
|
+
await preflightPatchWritable({
|
|
436
|
+
backend, entityType, bundle, id, existing, attributes,
|
|
437
|
+
resourceVersion: target.resourceVersion,
|
|
438
|
+
});
|
|
439
|
+
} catch (err) {
|
|
440
|
+
throw rewriteStaleCopyError(err);
|
|
441
|
+
}
|
|
333
442
|
return target;
|
|
334
443
|
}
|
|
335
444
|
|
|
@@ -350,6 +459,8 @@ export async function updateEntityGuarded(backend, input) {
|
|
|
350
459
|
}
|
|
351
460
|
return await backend.updateEntity(input);
|
|
352
461
|
} catch (err) {
|
|
462
|
+
const stale = rewriteStaleCopyError(err);
|
|
463
|
+
if (stale !== err) throw stale;
|
|
353
464
|
if (!isWorkingCopyPatchError(err)) throw err;
|
|
354
465
|
const cause = err instanceof Error ? err : new Error(String(err));
|
|
355
466
|
if (input?.resourceVersion === "rel:working-copy") {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-alias helpers shared by node writes and the JSON:API path reader.
|
|
3
|
+
*
|
|
4
|
+
* Path aliases are not revisioned. The node's `path` field is a computed
|
|
5
|
+
* view of `path_alias` rows; unpublished / forward revisions often omit
|
|
6
|
+
* `pid` even when a row exists. Round-tripping that pid (and verifying
|
|
7
|
+
* after save) is how title-only edits keep the existing alias (#274).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** JSON:API entity type + bundle for a path alias row. */
|
|
11
|
+
export const PATH_ALIAS_ENTITY_TYPE = "path_alias";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalize a URL-alias path for storage/comparison: trim, ensure a single
|
|
15
|
+
* leading slash, drop a trailing slash (except root).
|
|
16
|
+
* @param {*} value A raw alias.
|
|
17
|
+
* @returns {?string} The normalized alias, or null when empty.
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeAlias(value) {
|
|
20
|
+
if (value === undefined || value === null) return null;
|
|
21
|
+
let s = String(value).trim();
|
|
22
|
+
if (!s) return null;
|
|
23
|
+
if (!s.startsWith("/")) s = `/${s}`;
|
|
24
|
+
if (s.length > 1) s = s.replace(/\/+$/, "");
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a value is a positive integer node id suitable for `/node/{nid}`.
|
|
30
|
+
* @param {*} value Raw drupal internal id.
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function isPositiveNid(value) {
|
|
34
|
+
if (value === undefined || value === null || value === "") return false;
|
|
35
|
+
const n = Number(value);
|
|
36
|
+
return Number.isInteger(n) && n > 0;
|
|
37
|
+
}
|
package/src/tools/entities.js
CHANGED
|
@@ -294,7 +294,7 @@ export const definitions = [
|
|
|
294
294
|
langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
|
|
295
295
|
attributes: { type: "object" },
|
|
296
296
|
relationships: { type: "object" },
|
|
297
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
|
|
297
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
|
|
298
298
|
returning: RETURNING_SCHEMA,
|
|
299
299
|
},
|
|
300
300
|
},
|