drupal-mcp-connector 0.6.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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Typed error classes for the backend abstraction layer.
3
+ *
4
+ * Single responsibility: give callers distinct, catchable error types for the
5
+ * two backend-specific failure modes (unsupported operation vs. no usable
6
+ * backend), separate from generic transport/HTTP errors.
7
+ */
8
+
9
+ /**
10
+ * Thrown when a resolved backend cannot perform a requested operation
11
+ * (e.g. attempting a write through the read-only GraphQL backend).
12
+ */
13
+ export class BackendCapabilityError extends Error {
14
+ /** @param {string} message Human-readable explanation. */
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "BackendCapabilityError";
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Thrown when no usable backend can be resolved for a site (misconfigured
23
+ * `api` setting, or no protocol reachable during the probe).
24
+ */
25
+ export class BackendResolutionError extends Error {
26
+ /** @param {string} message Human-readable explanation. */
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "BackendResolutionError";
30
+ }
31
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Client-side filter and sort over canonical entities.
3
+ *
4
+ * Single responsibility: apply filter predicates and sort orders in JS for the
5
+ * GraphQL backend, which cannot filter (and can only sort by a fixed enum of
6
+ * keys) server-side. This runs over the bounded set of records the backend
7
+ * already paged in (up to its client-record cap), which is why the results it
8
+ * feeds are flagged `approximate`/`truncated` upstream.
9
+ */
10
+
11
+ const BASE_GETTERS = new Map([
12
+ ["id", (e) => e.id],
13
+ ["title", (e) => e.title],
14
+ ["status", (e) => e.status],
15
+ ["langcode",(e) => e.langcode],
16
+ ["created", (e) => e.created],
17
+ ["changed", (e) => e.changed],
18
+ ["url", (e) => e.url],
19
+ ]);
20
+
21
+ /**
22
+ * Build a value accessor for one entity. The entity's custom fields are copied
23
+ * into a Map ONCE here, so repeated lookups across many filters/sort keys do not
24
+ * re-build it per access (this runs over up to ~1000 records in the GraphQL path).
25
+ * The Map(Object.entries()) lookup also keeps field access object-injection-safe.
26
+ * @param {import("../canonical.js").CanonicalEntity} entity
27
+ * @returns {(field: string) => *} Resolver returning the value for a field name.
28
+ */
29
+ function accessorFor(entity) {
30
+ const custom = entity.fields ? new Map(Object.entries(entity.fields)) : new Map();
31
+ return (field) => {
32
+ const base = BASE_GETTERS.get(field);
33
+ return base ? base(entity) : custom.get(field);
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Evaluate a single filter condition against an entity's value accessor.
39
+ * @param {(field: string) => *} get Value accessor from {@link accessorFor}.
40
+ * @param {{field: string, op?: string, value: *}} cond Filter condition.
41
+ * @returns {boolean} True when the condition holds (unknown ops return false).
42
+ */
43
+ function matches(get, { field, op = "eq", value }) {
44
+ const v = get(field);
45
+ switch (op) {
46
+ case "eq": return v === value;
47
+ case "neq": return v !== value;
48
+ case "gt": return v > value;
49
+ case "gte": return v >= value;
50
+ case "lt": return v < value;
51
+ case "lte": return v <= value;
52
+ case "contains": return String(v ?? "").toLowerCase().includes(String(value).toLowerCase());
53
+ case "in": return Array.isArray(value) && value.includes(v);
54
+ case "isNull": return v === null || v === undefined;
55
+ default: return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Filter entities by an AND-combined list of conditions.
61
+ * @param {import("../canonical.js").CanonicalEntity[]} entities
62
+ * @param {Array<{field: string, op?: string, value: *}>} [filters]
63
+ * @returns {import("../canonical.js").CanonicalEntity[]} New filtered array
64
+ * (or the input array unchanged when there are no filters).
65
+ */
66
+ export function applyClientFilters(entities, filters = []) {
67
+ if (!filters.length) return entities;
68
+ return entities.filter((e) => {
69
+ const get = accessorFor(e);
70
+ return filters.every((f) => matches(get, f));
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Sort entities by an ordered list of sort keys (later keys break ties).
76
+ * @param {import("../canonical.js").CanonicalEntity[]} entities
77
+ * @param {Array<{field: string, dir?: "asc"|"desc"}>} [sort]
78
+ * @returns {import("../canonical.js").CanonicalEntity[]} New sorted array
79
+ * (or the input array unchanged when there is no sort).
80
+ */
81
+ export function applyClientSort(entities, sort = []) {
82
+ if (!sort.length) return entities;
83
+ // Build one accessor per entity up front (keyed by the entity object) so the
84
+ // comparator never re-builds the per-entity fields Map.
85
+ const accessors = new Map(entities.map((e) => [e, accessorFor(e)]));
86
+ const sorted = [...entities];
87
+ sorted.sort((a, b) => {
88
+ const ga = accessors.get(a);
89
+ const gb = accessors.get(b);
90
+ for (const { field, dir = "asc" } of sort) {
91
+ const av = ga(field);
92
+ const bv = gb(field);
93
+ if (av < bv) return dir === "desc" ? 1 : -1;
94
+ if (av > bv) return dir === "desc" ? -1 : 1;
95
+ }
96
+ return 0;
97
+ });
98
+ return sorted;
99
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Pure helpers mapping graphql_compose type names to Drupal entityType/bundle.
3
+ *
4
+ * graphql_compose names a type as <Prefix><PascalBundle>, where Prefix encodes
5
+ * the entity type (Node, Media, Term=taxonomy_term, Paragraph, BlockContent,
6
+ * Menu, User). Single-type entities (User, Menu) have no bundle suffix.
7
+ */
8
+
9
+ // Type-name prefix -> Drupal entity type. Order matters: longer/more-specific
10
+ // prefixes are listed first so a type like "BlockContent..." matches
11
+ // "BlockContent" before a hypothetical shorter "Block" prefix.
12
+ const PREFIXES = [
13
+ ["BlockContent", "block_content"],
14
+ ["Paragraph", "paragraph"],
15
+ ["Media", "media"],
16
+ ["Term", "taxonomy_term"],
17
+ ["Menu", "menu"],
18
+ ["Node", "node"],
19
+ ["User", "user"],
20
+ ];
21
+
22
+ /**
23
+ * Convert a PascalCase fragment to snake_case (the Drupal bundle convention).
24
+ * @param {string} s e.g. "BasicPage".
25
+ * @returns {string} e.g. "basic_page".
26
+ */
27
+ export function pascalToSnake(s) {
28
+ // Two passes: lower/digit->Upper boundaries, then acronym->Word boundaries,
29
+ // so both "fooBar" and "URLAlias" split correctly before lowercasing.
30
+ return s
31
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
32
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
33
+ .toLowerCase();
34
+ }
35
+
36
+ /**
37
+ * Convert a snake_case machine name to PascalCase.
38
+ * @param {string} s e.g. "basic_page".
39
+ * @returns {string} e.g. "BasicPage".
40
+ */
41
+ export function snakeToPascal(s) {
42
+ return s.split("_").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
43
+ }
44
+
45
+ /**
46
+ * Map a graphql_compose type name to its Drupal entityType/bundle.
47
+ * @param {string} typeName e.g. "NodeArticle".
48
+ * @returns {{entityType: string, bundle: string}|null} Pair, or null when the
49
+ * name matches no known prefix.
50
+ */
51
+ export function graphqlTypeToEntity(typeName) {
52
+ for (const [prefix, entityType] of PREFIXES) {
53
+ if (typeName === prefix) {
54
+ // Single-type entity (User, Menu): bundle == entityType.
55
+ return { entityType, bundle: entityType };
56
+ }
57
+ if (typeName.startsWith(prefix)) {
58
+ const rest = typeName.slice(prefix.length);
59
+ return { entityType, bundle: pascalToSnake(rest) };
60
+ }
61
+ }
62
+ return null;
63
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * GraphQL result normalization.
3
+ *
4
+ * Single responsibility: convert a graphql_compose node object into the
5
+ * shared CanonicalEntity shape, promoting base fields, collapsing entity
6
+ * references into canonical relationship refs, and leaving everything else as
7
+ * raw field values.
8
+ */
9
+
10
+ import { makeCanonicalEntity } from "../canonical.js";
11
+ import { graphqlTypeToEntity } from "./graphql-names.js";
12
+
13
+ // Node keys that map to canonical base properties, not to `fields`.
14
+ const BASE_KEYS = new Set(["__typename", "id", "title", "status", "langcode", "created", "changed", "path"]);
15
+
16
+ /**
17
+ * Detect a single entity reference object (has `__typename` and `id`).
18
+ * @param {*} v Candidate value.
19
+ * @returns {boolean}
20
+ */
21
+ function isEntityRef(v) {
22
+ return v && typeof v === "object" && typeof v.__typename === "string" && "id" in v;
23
+ }
24
+
25
+ /**
26
+ * Collapse an entity-reference object into a canonical relationship ref.
27
+ * @param {{__typename: string, id: string}} v
28
+ * @returns {{id: string, entityType: ?string, bundle: ?string}}
29
+ */
30
+ function refToCanonical(v) {
31
+ const entity = graphqlTypeToEntity(v.__typename) ?? { entityType: null, bundle: null };
32
+ return { id: v.id, entityType: entity.entityType, bundle: entity.bundle };
33
+ }
34
+
35
+ /**
36
+ * Normalize a graphql_compose node into a CanonicalEntity.
37
+ * @param {object} node Raw GraphQL node object (includes `__typename`, `id`).
38
+ * @returns {import("../canonical.js").CanonicalEntity}
39
+ */
40
+ export function graphqlNodeToCanonical(node) {
41
+ const entity = graphqlTypeToEntity(node.__typename || "") ?? { entityType: null, bundle: null };
42
+
43
+ const fieldsMap = new Map();
44
+ const relationshipsMap = new Map();
45
+ for (const [k, v] of Object.entries(node)) {
46
+ if (BASE_KEYS.has(k)) continue;
47
+ if (isEntityRef(v)) {
48
+ relationshipsMap.set(k, refToCanonical(v));
49
+ } else if (Array.isArray(v) && v.length && v.every(isEntityRef)) {
50
+ relationshipsMap.set(k, v.map(refToCanonical));
51
+ } else {
52
+ // Note: an empty array ([]) lands in `fields`, not `relationships` — we
53
+ // cannot tell an empty multi-ref from an empty scalar list without schema
54
+ // context, so empties stay as raw field values.
55
+ fieldsMap.set(k, v);
56
+ }
57
+ }
58
+
59
+ return makeCanonicalEntity({
60
+ id: node.id,
61
+ entityType: entity.entityType,
62
+ bundle: entity.bundle,
63
+ title: node.title ?? null,
64
+ status: typeof node.status === "boolean" ? node.status : (node.status ?? null),
65
+ langcode: node.langcode?.id ?? null,
66
+ created: node.created?.time ?? null,
67
+ changed: node.changed?.time ?? null,
68
+ url: node.path ?? null,
69
+ fields: Object.fromEntries(fieldsMap),
70
+ relationships: Object.fromEntries(relationshipsMap),
71
+ backend: "graphql",
72
+ });
73
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * GraphQL selection-set and query-document builders for graphql_compose.
3
+ *
4
+ * Single responsibility: turn a resolved SchemaEntry into a valid GraphQL
5
+ * query string. The builders are type-aware so the generated selection only
6
+ * requests fields the server can actually resolve (scalars are selected bare,
7
+ * scalar-wrapper objects get a fixed sub-selection, entity references collapse
8
+ * to `{ __typename id }`, and unknown wrappers are skipped to keep the query
9
+ * valid).
10
+ */
11
+
12
+ import { graphqlTypeToEntity } from "./graphql-names.js";
13
+
14
+ // graphql_compose wraps some scalars in object types; each needs an explicit
15
+ // sub-selection because GraphQL forbids selecting an object without one.
16
+ const OBJECT_SUBSELECTIONS = new Map([
17
+ ["DateTime", "{ time }"],
18
+ ["Language", "{ id }"],
19
+ ["TextSummary", "{ value summary format }"],
20
+ ]);
21
+
22
+ // Field kinds that are selected as a bare field name (no sub-selection).
23
+ const SCALAR_KINDS = new Set(["SCALAR", "ENUM"]);
24
+
25
+ /**
26
+ * Map an entity union type name to its companion `<Entity>Interface`.
27
+ * An entity union/interface (TermUnion, MediaUnion, ...) exposes a matching
28
+ * <Entity>Interface with an `id`. Non-entity unions (e.g. MetaTagUnion) do
29
+ * NOT, so an inline fragment on their "interface" would be invalid — return
30
+ * null so the caller skips them.
31
+ * @param {?string} unionTypeName e.g. "MediaUnion".
32
+ * @returns {?string} e.g. "MediaInterface", or null when not an entity union.
33
+ */
34
+ function entityInterfaceFor(unionTypeName) {
35
+ if (!unionTypeName || !graphqlTypeToEntity(unionTypeName)) return null;
36
+ return unionTypeName.replace(/Union$/, "Interface");
37
+ }
38
+
39
+ /**
40
+ * Build the selection text for a single field, or null to skip it.
41
+ * @param {string} name Field name.
42
+ * @param {object} desc describeType() result for the field.
43
+ * @returns {?string} Selection fragment, or null when the field is unselectable.
44
+ */
45
+ function selectField(name, desc) {
46
+ if (SCALAR_KINDS.has(desc.kind)) return name;
47
+
48
+ if (desc.kind === "OBJECT") {
49
+ const sub = OBJECT_SUBSELECTIONS.get(desc.typeName);
50
+ if (sub) return `${name} ${sub}`;
51
+ // Another entity (User, MediaImage, ...) -> relationship reference.
52
+ if (graphqlTypeToEntity(desc.typeName)) return `${name} { __typename id }`;
53
+ return null; // unknown object wrapper -> skip to keep the query valid
54
+ }
55
+
56
+ // Single union/interface field (e.g. heroImage: MediaUnion) -> entity ref.
57
+ if (desc.kind === "UNION" || desc.kind === "INTERFACE") {
58
+ const iface = entityInterfaceFor(desc.typeName);
59
+ return iface ? `${name} { __typename ... on ${iface} { __typename id } }` : null;
60
+ }
61
+
62
+ if (desc.kind === "LIST") {
63
+ if (desc.ofTypeKind === "UNION" || desc.ofTypeKind === "INTERFACE") {
64
+ const iface = entityInterfaceFor(desc.ofTypeName);
65
+ return iface ? `${name} { __typename ... on ${iface} { __typename id } }` : null;
66
+ }
67
+ if (desc.ofTypeKind === "OBJECT" && graphqlTypeToEntity(desc.ofTypeName)) {
68
+ return `${name} { __typename id }`;
69
+ }
70
+ if (SCALAR_KINDS.has(desc.ofTypeKind)) return name;
71
+ return null;
72
+ }
73
+
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Build the selection set (without surrounding braces) for an entity entry.
79
+ * Always includes `__typename` and `id` so results can be normalized later.
80
+ * @param {import("./graphql-schema.js").SchemaEntry} entry
81
+ * @returns {string} Space-joined selection fragments.
82
+ */
83
+ export function buildSelection(entry) {
84
+ const parts = ["__typename"];
85
+ for (const [name, desc] of entry.fields) {
86
+ if (name === "__typename") continue;
87
+ const sel = selectField(name, desc);
88
+ if (sel && sel !== "__typename") parts.push(sel);
89
+ }
90
+ // Ensure id present even if not in fields map.
91
+ if (!parts.some((p) => p === "id" || p.startsWith("id "))) parts.splice(1, 0, "id");
92
+ return parts.join(" ");
93
+ }
94
+
95
+ /**
96
+ * Render collection arguments into GraphQL argument syntax.
97
+ * @param {{first?: number, after?: string, sortKey?: string, reverse?: boolean}} args
98
+ * @returns {string} e.g. "(first: 50, sortKey: CREATED_AT)" or "" when empty.
99
+ */
100
+ function formatArgs(args) {
101
+ const out = [];
102
+ if (args.first !== undefined && args.first !== null) out.push(`first: ${args.first}`);
103
+ if (args.after) out.push(`after: ${JSON.stringify(args.after)}`);
104
+ if (args.sortKey) out.push(`sortKey: ${args.sortKey}`);
105
+ if (args.reverse !== undefined) out.push(`reverse: ${args.reverse}`);
106
+ return out.length ? `(${out.join(", ")})` : "";
107
+ }
108
+
109
+ /**
110
+ * Build a collection (connection) query document.
111
+ * @param {import("./graphql-schema.js").SchemaEntry} entry
112
+ * @param {{first?: number, after?: string, sortKey?: string, reverse?: boolean}} [args]
113
+ * @returns {string} A complete GraphQL query string.
114
+ */
115
+ export function buildCollectionQuery(entry, args = {}) {
116
+ const selection = buildSelection(entry);
117
+ return `{ ${entry.collection}${formatArgs(args)} { pageInfo { hasNextPage endCursor } nodes { ${selection} } } }`;
118
+ }
119
+
120
+ /**
121
+ * Build a single-entity query document.
122
+ * @param {import("./graphql-schema.js").SchemaEntry} entry
123
+ * @param {string} id Entity id (UUID) to fetch.
124
+ * @returns {string} A complete GraphQL query string.
125
+ */
126
+ export function buildSingleQuery(entry, id) {
127
+ const selection = buildSelection(entry);
128
+ return `{ ${entry.single}(id: ${JSON.stringify(id)}) { ${selection} } }`;
129
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * GraphQL schema introspection and the queryable SchemaMap.
3
+ *
4
+ * Single responsibility: introspect a site's GraphQL schema once and build a
5
+ * lookup from Drupal entityType/bundle to the graphql_compose query fields
6
+ * (single + collection), field names, and field type kinds. The resolved map
7
+ * is cached per site so introspection runs at most once per process per site.
8
+ */
9
+
10
+ import { drupalGraphqlFetch } from "../drupal-fetch.js";
11
+ import { graphqlTypeToEntity } from "./graphql-names.js";
12
+
13
+ // Introspection is intentionally nested four `ofType` levels deep: a field type
14
+ // can be wrapped as NON_NULL(LIST(NON_NULL(NamedType))), so four levels are
15
+ // needed to reach the underlying named type through every wrapper combination.
16
+ const INTROSPECTION_QUERY = `
17
+ {
18
+ __schema {
19
+ queryType { name }
20
+ types {
21
+ name
22
+ kind
23
+ enumValues { name }
24
+ fields {
25
+ name
26
+ args { name }
27
+ type { name kind ofType { name kind ofType { name kind ofType { name kind } } } }
28
+ }
29
+ }
30
+ }
31
+ }`;
32
+
33
+ const cache = new Map();
34
+
35
+ /**
36
+ * Test helper: clear the per-site schema cache.
37
+ * @returns {void}
38
+ */
39
+ export function _clearSchemaCache() {
40
+ cache.clear();
41
+ }
42
+
43
+ /**
44
+ * Unwrap NON_NULL/LIST wrappers down to the meaningful kind and type names.
45
+ * @param {object|null} t An introspection type node.
46
+ * @returns {{kind: ?string, typeName: ?string, ofTypeKind: ?string, ofTypeName: ?string}}
47
+ * For LIST types, `ofType*` describes the element type; otherwise `typeName`
48
+ * is the named type.
49
+ */
50
+ function describeType(t) {
51
+ // Returns { kind, typeName, ofTypeKind, ofTypeName }
52
+ if (!t) return { kind: null, typeName: null, ofTypeKind: null, ofTypeName: null };
53
+ if (t.kind === "NON_NULL") return describeType(t.ofType);
54
+ if (t.kind === "LIST") {
55
+ const inner = t.ofType?.kind === "NON_NULL" ? t.ofType.ofType : t.ofType;
56
+ return { kind: "LIST", typeName: null, ofTypeKind: inner?.kind ?? null, ofTypeName: inner?.name ?? null };
57
+ }
58
+ return { kind: t.kind, typeName: t.name, ofTypeKind: null, ofTypeName: null };
59
+ }
60
+
61
+ /**
62
+ * @typedef {Object} SchemaEntry
63
+ * @property {string} typeName graphql_compose object type, e.g. "NodeArticle".
64
+ * @property {string} entityType Drupal entity type, e.g. "node".
65
+ * @property {string} bundle Drupal bundle, e.g. "article".
66
+ * @property {?string} single Query field returning one entity, or null.
67
+ * @property {?string} collection Query field returning a connection, or null.
68
+ * @property {Map<string, object>} fields Field name -> describeType() result.
69
+ */
70
+
71
+ /**
72
+ * Resolved schema lookup: entityType/bundle -> SchemaEntry, plus the set of
73
+ * server-supported sort-key enum values.
74
+ */
75
+ class SchemaMap {
76
+ constructor() {
77
+ this._byEntity = new Map(); // "node:article" -> SchemaEntry
78
+ this._typeToEntity = new Map(); // "NodeArticle" -> {entityType, bundle}
79
+ /** @type {Set<string>} Enum values of ConnectionSortKeys (server-side sort). */
80
+ this.sortKeys = new Set();
81
+ }
82
+
83
+ /**
84
+ * Compose the internal map key for an entity/bundle pair.
85
+ * @param {string} entityType
86
+ * @param {string} bundle
87
+ * @returns {string}
88
+ */
89
+ static _key(entityType, bundle) { return `${entityType}:${bundle}`; }
90
+
91
+ /**
92
+ * Look up the schema entry for an entity/bundle.
93
+ * @param {string} entityType
94
+ * @param {string} bundle
95
+ * @returns {?SchemaEntry}
96
+ */
97
+ forEntity(entityType, bundle) {
98
+ return this._byEntity.get(SchemaMap._key(entityType, bundle)) ?? null;
99
+ }
100
+
101
+ /**
102
+ * Reverse lookup: GraphQL type name -> entity/bundle.
103
+ * @param {string} typeName
104
+ * @returns {{entityType: string, bundle: string}|null}
105
+ */
106
+ entityForType(typeName) {
107
+ return this._typeToEntity.get(typeName) ?? null;
108
+ }
109
+
110
+ /**
111
+ * List bundles of the `node` entity type.
112
+ * @returns {string[]}
113
+ */
114
+ nodeBundles() {
115
+ const out = [];
116
+ for (const [key, v] of this._byEntity) {
117
+ if (key.startsWith("node:")) out.push(v.bundle);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * List the bundles of a given entity type.
124
+ * @param {string} entityType
125
+ * @returns {string[]}
126
+ */
127
+ bundlesOf(entityType) {
128
+ const out = [];
129
+ const prefix = `${entityType}:`;
130
+ for (const [key, v] of this._byEntity) {
131
+ if (key.startsWith(prefix)) out.push(v.bundle);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * List every known entity/bundle pair.
138
+ * @returns {Array<{entityType: string, bundle: string}>}
139
+ */
140
+ allEntities() {
141
+ const out = [];
142
+ for (const v of this._byEntity.values()) {
143
+ out.push({ entityType: v.entityType, bundle: v.bundle });
144
+ }
145
+ return out;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Build a SchemaMap from a raw introspection response.
151
+ * @param {object} introspection The `{ data: { __schema } }` response.
152
+ * @returns {SchemaMap}
153
+ */
154
+ function buildSchemaMap(introspection) {
155
+ const schema = introspection.data.__schema;
156
+ const typesByName = new Map(schema.types.map((t) => [t.name, t]));
157
+ const queryType = typesByName.get(schema.queryType.name);
158
+ const map = new SchemaMap();
159
+
160
+ // Sort keys
161
+ const sortEnum = typesByName.get("ConnectionSortKeys");
162
+ for (const v of sortEnum?.enumValues ?? []) map.sortKeys.add(v.name);
163
+
164
+ // Index single + collection fields by the concrete entity object type they expose.
165
+ const singles = new Map(); // typeName -> queryField
166
+ const collections = new Map(); // typeName -> queryField
167
+ for (const f of queryType.fields ?? []) {
168
+ const d = describeType(f.type);
169
+ // graphql_compose names collection types <Entity>Connection; the nodes-type
170
+ // guard below is sufficient, so we don't also require a pageInfo field here.
171
+ if (d.kind === "OBJECT" && d.typeName?.endsWith("Connection")) {
172
+ const conn = typesByName.get(d.typeName);
173
+ const nodesField = conn?.fields?.find((x) => x.name === "nodes");
174
+ const nodeType = describeType(nodesField?.type).ofTypeName;
175
+ if (nodeType) collections.set(nodeType, f.name);
176
+ } else if (d.kind === "OBJECT" && (f.args ?? []).some((a) => a.name === "id")) {
177
+ singles.set(d.typeName, f.name);
178
+ }
179
+ }
180
+
181
+ // For each entity object type with a single OR collection field, build the entry.
182
+ const typeNames = new Set([...singles.keys(), ...collections.keys()]);
183
+ for (const typeName of typeNames) {
184
+ const entity = graphqlTypeToEntity(typeName);
185
+ if (!entity) continue;
186
+ const typeDef = typesByName.get(typeName);
187
+ const fields = new Map();
188
+ for (const ff of typeDef?.fields ?? []) {
189
+ fields.set(ff.name, describeType(ff.type));
190
+ }
191
+ const entry = {
192
+ typeName,
193
+ entityType: entity.entityType,
194
+ bundle: entity.bundle,
195
+ single: singles.get(typeName) ?? null,
196
+ collection: collections.get(typeName) ?? null,
197
+ fields,
198
+ };
199
+ map._byEntity.set(SchemaMap._key(entity.entityType, entity.bundle), entry);
200
+ map._typeToEntity.set(typeName, { entityType: entity.entityType, bundle: entity.bundle });
201
+ }
202
+
203
+ return map;
204
+ }
205
+
206
+ /**
207
+ * Load (or return the cached) SchemaMap for a site.
208
+ * @param {object} site Site config; must include `_name` (the cache key).
209
+ * @returns {Promise<SchemaMap>}
210
+ * @throws {Error} When introspection fails (the cache entry is cleared first).
211
+ */
212
+ export async function loadSchemaMap(site) {
213
+ // Cache the in-flight Promise (not the resolved value) so concurrent first
214
+ // calls share a single introspection. Clear on failure so a transient error
215
+ // does not poison the cache.
216
+ if (!cache.has(site._name)) {
217
+ const pending = drupalGraphqlFetch(site, { query: INTROSPECTION_QUERY })
218
+ .then(buildSchemaMap)
219
+ .catch((err) => {
220
+ cache.delete(site._name);
221
+ throw err;
222
+ });
223
+ cache.set(site._name, pending);
224
+ }
225
+ return cache.get(site._name);
226
+ }