drupal-mcp-connector 2.15.1 → 2.15.2
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-list-revisions.md +2 -2
- package/.agents/commands/drupal-update-node.md +2 -2
- package/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/src/lib/node-draft-inventory.js +47 -0
- package/src/lib/patch-preflight.js +23 -6
- package/src/lib/write-revision.js +9 -2
- package/src/tools/entities.js +1 -0
- package/src/tools/nodes.js +4 -2
- package/src/tools/revisions.js +18 -2
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Surface the
|
|
2
|
+
description: "Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge."
|
|
3
3
|
argument-hint: "<type> <id> [site]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_list_revisions`.
|
|
7
7
|
|
|
8
|
-
Surface the
|
|
8
|
+
Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit.
|
|
2
|
+
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents."
|
|
3
3
|
argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [langcode] [fields] [relationships] [dryRun] [returning]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_update_node`.
|
|
7
7
|
|
|
8
|
-
Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit.
|
|
8
|
+
Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.15.2] - 2026-09-11
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Discover and continue translation-only forward revisions (#297).** When core's
|
|
14
|
+
working-copy alias is absent or echoes live, node revision listing and guarded
|
|
15
|
+
updates consult Sentinel's translation inventory. Existing unpublished languages
|
|
16
|
+
continue through the governed endpoint with explicit language and live/working
|
|
17
|
+
preconditions. Published languages and ambiguous multilingual edits remain refused.
|
|
18
|
+
Permission and transport failures stop discovery rather than becoming “no draft”.
|
|
19
|
+
Update responses preserve the saved translation instead of replacing it with live
|
|
20
|
+
English. An unresolved revision conflict no longer prescribes revision surgery.
|
|
21
|
+
|
|
10
22
|
## [2.15.1] - 2026-09-10
|
|
11
23
|
|
|
12
24
|
### Fixed
|
|
@@ -1571,6 +1583,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
1571
1583
|
- User tools gained explicit PII-access assertions.
|
|
1572
1584
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
1573
1585
|
|
|
1586
|
+
[2.15.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.2
|
|
1574
1587
|
[2.15.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.1
|
|
1575
1588
|
[2.15.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.0
|
|
1576
1589
|
[2.14.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.2",
|
|
4
4
|
"description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL access, governed draft translations, content tools, audit reports, and an SSH Drush bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Sentinel discovery for translation-only node revisions (#297). */
|
|
2
|
+
import { readTranslationInventory } from "./draft-write.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read optional inventory, falling back only when the endpoint is unsupported.
|
|
6
|
+
* Permission, transport and malformed-response failures are not absence.
|
|
7
|
+
* @param {object} backend
|
|
8
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
9
|
+
* @returns {Promise<object|null>}
|
|
10
|
+
*/
|
|
11
|
+
export async function readNodeDraftInventory(backend, ref) {
|
|
12
|
+
if (ref.entityType !== "node" || typeof backend.rawQuery !== "function"
|
|
13
|
+
|| typeof backend.resourcePath !== "function") return null;
|
|
14
|
+
let inventory;
|
|
15
|
+
try {
|
|
16
|
+
inventory = await readTranslationInventory(backend, ref);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (/does not provide Sentinel's governed draft-translation endpoint/.test(error.message)) return null;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
const validVid = (vid) => /^[1-9]\d*$/.test(String(vid ?? "")) && Number.isSafeInteger(Number(vid));
|
|
22
|
+
if (!validVid(inventory.live?.vid)
|
|
23
|
+
|| (inventory.working && (!validVid(inventory.working.vid)
|
|
24
|
+
|| !Array.isArray(inventory.working.translations)
|
|
25
|
+
|| inventory.working.translations.some((row) => !row || typeof row.langcode !== "string"
|
|
26
|
+
|| typeof row.status !== "boolean")))) {
|
|
27
|
+
throw new Error("Sentinel returned an invalid node revision inventory. Re-read before updating.");
|
|
28
|
+
}
|
|
29
|
+
return inventory;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ensure inventory discovery does not turn a published language into a draft.
|
|
34
|
+
* Sentinel still validates the revision pair and language on every request.
|
|
35
|
+
* @param {object} inventory
|
|
36
|
+
* @param {string|undefined} langcode
|
|
37
|
+
*/
|
|
38
|
+
export function assertInventoryDraftLanguage(inventory, langcode) {
|
|
39
|
+
const rows = inventory.working?.translations ?? [];
|
|
40
|
+
if (!langcode && rows.length !== 1) {
|
|
41
|
+
throw new Error("This working revision contains translations. Pass an explicit langcode for an existing unpublished language; no draft was created.");
|
|
42
|
+
}
|
|
43
|
+
const row = langcode ? rows.find((item) => item.langcode === langcode) : rows[0];
|
|
44
|
+
if (!row || row.status !== false) {
|
|
45
|
+
throw new Error("The requested language is not an existing unpublished working draft. Published languages and other drafts were left unchanged.");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* Sentinel's /mcp-draft endpoint with verified live/working revision IDs.
|
|
17
17
|
* Core rejects resourceVersion on PATCH. Never retry the canonical URL.
|
|
18
18
|
* - **#201** — the working-copy alias does not resolve, but core still
|
|
19
|
-
* blocks.
|
|
20
|
-
*
|
|
19
|
+
* blocks. Consult Sentinel inventory for translation-only revisions before
|
|
20
|
+
* refusing the unresolved revision conflict. `workingCopy: null` is not proof the node is writable.
|
|
21
21
|
*
|
|
22
22
|
* Unsupported endpoints and stale revisions fail closed, without discard.
|
|
23
23
|
*/
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
|
|
26
26
|
import { entityRevisionId } from "./write-revision.js";
|
|
27
27
|
import { writeDraft } from "./draft-write.js";
|
|
28
|
+
import { readNodeDraftInventory, assertInventoryDraftLanguage } from "./node-draft-inventory.js";
|
|
28
29
|
|
|
29
30
|
/** Stable error code for a core working-copy / not-latest-revision block. */
|
|
30
31
|
export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
|
|
@@ -48,7 +49,7 @@ export const PATCH_BLOCKED_MESSAGE =
|
|
|
48
49
|
"This entity cannot be updated over JSON:API because the stored entity is not " +
|
|
49
50
|
"the latest revision (Drupal core #2795279). The JSON:API aliases " +
|
|
50
51
|
"rel:latest-version and rel:working-copy cannot show the blocking row. " +
|
|
51
|
-
"
|
|
52
|
+
"Inspect the translation inventory and revision history before considering repair; do not delete drafts. " +
|
|
52
53
|
"See connector #201. Do not retry the same canonical PATCH.";
|
|
53
54
|
|
|
54
55
|
/**
|
|
@@ -248,17 +249,32 @@ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
|
|
|
248
249
|
*/
|
|
249
250
|
export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
|
|
250
251
|
const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
|
|
252
|
+
if (workingCopy?.id && workingCopy.id !== id) {
|
|
253
|
+
throw new PatchTargetAmbiguousError(id, workingCopy.id);
|
|
254
|
+
}
|
|
251
255
|
let liveVid = entityRevisionId(existing);
|
|
252
256
|
if (workingCopy && (liveVid === null || liveVid === undefined) && typeof backend?.getEntity === "function") {
|
|
253
257
|
const live = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
|
|
254
258
|
liveVid = entityRevisionId(live);
|
|
255
259
|
}
|
|
260
|
+
const aliasVid = entityRevisionId(workingCopy);
|
|
261
|
+
if (!workingCopy || (liveVid !== null && String(aliasVid) === String(liveVid))) {
|
|
262
|
+
const inventory = await readNodeDraftInventory(backend, { entityType, bundle, id });
|
|
263
|
+
if (inventory) {
|
|
264
|
+
if (liveVid !== null && String(liveVid) !== String(inventory.live.vid)) {
|
|
265
|
+
throw new WorkingCopyStaleError(new Error("Sentinel's live revision changed during discovery."));
|
|
266
|
+
}
|
|
267
|
+
liveVid = Number(inventory.live.vid);
|
|
268
|
+
const workingVid = inventory.working ? Number(inventory.working.vid) : null;
|
|
269
|
+
if (workingVid !== null && workingVid !== liveVid) {
|
|
270
|
+
return { resourceVersion: `id:${workingVid}`, workingCopy: null,
|
|
271
|
+
liveVid, workingVid, inventory };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
256
275
|
if (!workingCopy) {
|
|
257
276
|
return { resourceVersion: undefined, workingCopy: null, liveVid, workingVid: null };
|
|
258
277
|
}
|
|
259
|
-
if (workingCopy.id && workingCopy.id !== id) {
|
|
260
|
-
throw new PatchTargetAmbiguousError(id, workingCopy.id);
|
|
261
|
-
}
|
|
262
278
|
const workingVid = entityRevisionId(workingCopy);
|
|
263
279
|
if (workingVid !== null && liveVid !== null && String(workingVid) === String(liveVid)) {
|
|
264
280
|
return { resourceVersion: undefined, workingCopy, liveVid, workingVid };
|
|
@@ -402,6 +418,7 @@ export async function prepareGuardedPatch(backend, {
|
|
|
402
418
|
throw new StaleCopyError();
|
|
403
419
|
}
|
|
404
420
|
}
|
|
421
|
+
if (target.inventory) assertInventoryDraftLanguage(target.inventory, langcode);
|
|
405
422
|
if (langcode) {
|
|
406
423
|
if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
|
|
407
424
|
throw new Error(
|
|
@@ -65,7 +65,10 @@ export async function attachWrittenRevisionPair({
|
|
|
65
65
|
backend, entityType, bundle, id, entity, liveVid,
|
|
66
66
|
}) {
|
|
67
67
|
if (liveVid === null || liveVid === undefined || !entity) return entity;
|
|
68
|
-
let workingVid =
|
|
68
|
+
let workingVid = entityRevisionId(entity);
|
|
69
|
+
if (workingVid !== null && String(workingVid) !== String(liveVid)) {
|
|
70
|
+
return attachRevisionPair(entity, { live: liveVid, working: workingVid });
|
|
71
|
+
}
|
|
69
72
|
if (typeof backend?.getEntity === "function") {
|
|
70
73
|
try {
|
|
71
74
|
const wc = await backend.getEntity({
|
|
@@ -93,12 +96,16 @@ export async function attachWrittenRevisionPair({
|
|
|
93
96
|
* for the write (e.g. `rel:working-copy`). PreferCanonical re-reads this
|
|
94
97
|
* resource instead of the default revision so a draft PATCH is not
|
|
95
98
|
* replaced by the live body.
|
|
99
|
+
* @param {?string} [args.langcode] Language returned by governed draft continuation.
|
|
96
100
|
* @returns {Promise<object>} Entity to return, with `_revision` when relevant.
|
|
97
101
|
*/
|
|
98
102
|
export async function readWrittenRevision({
|
|
99
103
|
backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
|
|
100
|
-
resourceVersion,
|
|
104
|
+
resourceVersion, langcode,
|
|
101
105
|
}) {
|
|
106
|
+
// Sentinel returned the requested translation; a canonical read would replace
|
|
107
|
+
// it with live English, and the core working-copy alias may not address it.
|
|
108
|
+
if (langcode && patchResult) return patchResult;
|
|
102
109
|
if (!relationshipsSent) {
|
|
103
110
|
if (preferCanonical && typeof backend.getEntity === "function") {
|
|
104
111
|
const fresh = await backend.getEntity({
|
package/src/tools/entities.js
CHANGED
|
@@ -136,6 +136,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
136
136
|
const written = await readWrittenRevision({
|
|
137
137
|
backend, entityType, bundle, id,
|
|
138
138
|
relationshipsSent: relationshipsWereSent(resolvedRelationships),
|
|
139
|
+
langcode,
|
|
139
140
|
patchResult: result,
|
|
140
141
|
preferCanonical: false,
|
|
141
142
|
});
|
package/src/tools/nodes.js
CHANGED
|
@@ -469,16 +469,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
469
469
|
let fresh = await readWrittenRevision({
|
|
470
470
|
backend, entityType: "node", bundle: type, id,
|
|
471
471
|
relationshipsSent: relationshipsWereSent(resolvedRelationships),
|
|
472
|
+
langcode,
|
|
472
473
|
patchResult: patched,
|
|
473
474
|
preferCanonical: true,
|
|
474
475
|
resourceVersion: patchTarget.resourceVersion,
|
|
475
476
|
});
|
|
476
477
|
const intendedAlias = pathAttr?.alias ? normalizeAlias(pathAttr.alias) : null;
|
|
477
478
|
if (intendedAlias) {
|
|
478
|
-
|
|
479
|
+
const aliasChecked = await assertAliasPersisted({
|
|
479
480
|
backend, sec, type, id, intendedAlias,
|
|
480
481
|
resourceVersion: patchTarget.resourceVersion, written: fresh,
|
|
481
482
|
});
|
|
483
|
+
fresh = langcode ? { ...fresh, url: aliasChecked.url } : aliasChecked;
|
|
482
484
|
}
|
|
483
485
|
// Redirect only after the new alias is what re-reads show (#274).
|
|
484
486
|
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
@@ -582,7 +584,7 @@ export const definitions = [
|
|
|
582
584
|
},
|
|
583
585
|
{
|
|
584
586
|
name: "drupal_update_node",
|
|
585
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit.
|
|
587
|
+
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
|
|
586
588
|
inputSchema: {
|
|
587
589
|
type: "object", required: ["type", "id"],
|
|
588
590
|
properties: {
|
package/src/tools/revisions.js
CHANGED
|
@@ -23,6 +23,7 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
23
23
|
import {
|
|
24
24
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
|
|
25
25
|
} from "../lib/security.js";
|
|
26
|
+
import { readNodeDraftInventory } from "../lib/node-draft-inventory.js";
|
|
26
27
|
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
|
|
27
28
|
import { omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
28
29
|
|
|
@@ -177,13 +178,28 @@ async function listRevisions({ site: siteName, type, id }) {
|
|
|
177
178
|
|
|
178
179
|
// The working-copy alias only resolves on entities under a content_moderation
|
|
179
180
|
// workflow with a pending forward revision; absence is expected, not an error.
|
|
180
|
-
|
|
181
|
+
let workingCopy = await fetchRevisionResource(backend, "node", type, id, "rel:working-copy")
|
|
181
182
|
.then(summarizeRevision)
|
|
182
183
|
.catch(() => null);
|
|
183
184
|
|
|
185
|
+
if (!workingCopy || String(workingCopy.vid) === String(latestVersion?.vid)) {
|
|
186
|
+
const inventory = await readNodeDraftInventory(backend, { entityType: "node", bundle: type, id });
|
|
187
|
+
if (inventory?.working && String(inventory.working.vid) !== String(inventory.live.vid)) {
|
|
188
|
+
if (latestVersion && String(latestVersion.vid) !== String(inventory.live.vid)) {
|
|
189
|
+
throw new Error("The live revision changed during discovery. Re-read the revision inventory.");
|
|
190
|
+
}
|
|
191
|
+
// No aggregate publication flag: English may be published while Spanish is draft.
|
|
192
|
+
workingCopy = { vid: Number(inventory.working.vid), source: "sentinel-inventory",
|
|
193
|
+
translations: inventory.working.translations.map(({ langcode, status, moderation_state, default: isDefault }) => ({
|
|
194
|
+
langcode, status, moderation_state, default: isDefault,
|
|
195
|
+
})) };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
184
199
|
const possiblyPatchBlocked = Boolean(latestVersion && changedAheadOfRevision(latestVersion));
|
|
185
200
|
let note = LIST_REVISIONS_BASE_NOTE;
|
|
186
201
|
if (!workingCopy) note += LIST_REVISIONS_NULL_WC_NOTE;
|
|
202
|
+
if (workingCopy?.source === "sentinel-inventory") note += " Working revision discovered through Sentinel; publication state is language-specific. Continue an unpublished translation with explicit langcode.";
|
|
187
203
|
if (possiblyPatchBlocked) note += LIST_REVISIONS_FINGERPRINT_NOTE;
|
|
188
204
|
|
|
189
205
|
return {
|
|
@@ -332,7 +348,7 @@ export const definitions = [
|
|
|
332
348
|
{
|
|
333
349
|
name: "drupal_list_revisions",
|
|
334
350
|
description:
|
|
335
|
-
"Surface the
|
|
351
|
+
"Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.",
|
|
336
352
|
inputSchema: {
|
|
337
353
|
type: "object", required: ["type", "id"],
|
|
338
354
|
properties: {
|