@atolis-hq/corum 0.1.13 → 0.1.14

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.
@@ -18,6 +18,16 @@ export function deriveScalarType(type, format, scalarTypes) {
18
18
  export function isRefSchema(schema) {
19
19
  return typeof schema === 'object' && schema !== null && '$ref' in schema;
20
20
  }
21
+ // Unwrap allOf: [{$ref}] — the OpenAPI 3.0 pattern for nullable/described refs
22
+ function resolveAllOfRef(schema) {
23
+ if (isRefSchema(schema))
24
+ return schema;
25
+ const s = schema;
26
+ if (Array.isArray(s.allOf) && s.allOf.length === 1 && isRefSchema(s.allOf[0])) {
27
+ return s.allOf[0];
28
+ }
29
+ return schema;
30
+ }
21
31
  export function deriveNodeId(kind, component, name, parentId, section) {
22
32
  if (kind === 'operation')
23
33
  return `${component}.APIEndpoint.${name}`;
@@ -26,24 +36,56 @@ export function deriveNodeId(kind, component, name, parentId, section) {
26
36
  export function refName(ref) {
27
37
  return ref.split('/').pop() ?? ref;
28
38
  }
39
+ function emitReadsEdge(from, to, edges) {
40
+ const id = `${from}__reads__${to}`;
41
+ if (!edges.some(e => e.id === id)) {
42
+ edges.push({ id, from, to, type: 'reads', state: 'implemented', stability: 'unstable' });
43
+ }
44
+ }
29
45
  export function mapDocument(document, entry, packConfig) {
30
46
  const nodes = [];
31
47
  const edges = [];
32
48
  const diagnostics = [];
33
49
  const sharedSchemas = new Map();
50
+ const sourceSchemas = new Map();
51
+ for (const [name, schema] of Object.entries(document.components?.schemas ?? {})) {
52
+ if (!isRefSchema(schema))
53
+ sourceSchemas.set(name, schema);
54
+ }
55
+ // Schemas referenced by 2+ operations become shared files; single-use schemas are inlined
56
+ const opCounts = countSchemaOperationUsage(document);
57
+ const sharedSchemaNames = collectAllSharedSchemaNames(document, opCounts);
58
+ // Pass 1: register all enum and shared-schema IDs upfront so cross-references
59
+ // resolve correctly regardless of definition order in the spec.
34
60
  if (document.components?.schemas) {
35
61
  for (const [name, schema] of Object.entries(document.components.schemas)) {
36
62
  if (isRefSchema(schema))
37
63
  continue;
38
64
  const s = schema;
65
+ const component = deriveComponentForSchema(name, document, entry);
66
+ if (!component)
67
+ continue;
39
68
  if (s.type !== 'object' && s.enum) {
40
- const component = deriveComponentForSchema(name, document, entry);
41
- if (!component)
69
+ sharedSchemas.set(name, `${component}.EnumDefinition.${name}`);
70
+ }
71
+ else if (sharedSchemaNames.has(name)) {
72
+ sharedSchemas.set(name, `${component}.Schema.${name}`);
73
+ }
74
+ }
75
+ }
76
+ // Pass 2: emit nodes and fields now that all IDs are registered.
77
+ if (document.components?.schemas) {
78
+ for (const [name, schema] of Object.entries(document.components.schemas)) {
79
+ if (isRefSchema(schema))
80
+ continue;
81
+ const s = schema;
82
+ if (s.type !== 'object' && s.enum) {
83
+ const enumId = sharedSchemas.get(name);
84
+ if (!enumId)
42
85
  continue;
43
- const enumId = `${component}.EnumDefinition.${name}`;
86
+ const [component] = enumId.split('.');
44
87
  const enumNode = makeNode(packConfig.constructs.enumDefinition?.template ?? 'EnumDefinition', component, entry.spec, enumId);
45
88
  nodes.push(enumNode);
46
- sharedSchemas.set(name, enumId);
47
89
  s.enum.forEach((value) => {
48
90
  const valueId = deriveNodeId('enumValue', undefined, String(value), enumId, 'values');
49
91
  const valueNode = makeNode(packConfig.constructs.enumValue?.template ?? 'EnumValue', component, entry.spec, valueId);
@@ -53,16 +95,17 @@ export function mapDocument(document, entry, packConfig) {
53
95
  });
54
96
  continue;
55
97
  }
56
- const component = deriveComponentForSchema(name, document, entry);
57
- if (!component) {
98
+ if (!sharedSchemaNames.has(name))
99
+ continue;
100
+ const schemaId = sharedSchemas.get(name);
101
+ if (!schemaId) {
58
102
  diagnostics.push({ severity: 'warning', file: entry.spec, message: `Cannot derive component for schema ${name}, skipping` });
59
103
  continue;
60
104
  }
61
- const schemaId = `${component}.Schema.${name}`;
62
- sharedSchemas.set(name, schemaId);
105
+ const [component] = schemaId.split('.');
63
106
  const node = makeNode(packConfig.constructs.requestSchema?.template ?? 'Schema', component, entry.spec, schemaId);
64
107
  nodes.push(node);
65
- emitFields(s, schemaId, 'fields', packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas);
108
+ emitFields(s, schemaId, 'fields', undefined, packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, new Map());
66
109
  }
67
110
  }
68
111
  for (const [urlPath, pathItem] of Object.entries(document.paths ?? {})) {
@@ -92,11 +135,12 @@ export function mapDocument(document, entry, packConfig) {
92
135
  const parameters = extractParameters(pathItem, operation, packConfig, entry.spec, diagnostics);
93
136
  if (parameters)
94
137
  endpointNode.properties.parameters = parameters;
138
+ const localSchemas = new Map();
95
139
  const requestBody = operation.requestBody;
96
140
  if (requestBody?.content) {
97
141
  const jsonContent = requestBody.content['application/json'];
98
142
  if (jsonContent?.schema) {
99
- const ref = emitSchemaNode(jsonContent.schema, `${operationId}-request`, endpointId, 'schemas', packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas);
143
+ const ref = emitSchemaNode(jsonContent.schema, `${operationId}-request`, endpointId, 'schemas', endpointId, packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
100
144
  if (ref)
101
145
  endpointNode.properties.request = ref;
102
146
  }
@@ -106,7 +150,7 @@ export function mapDocument(document, entry, packConfig) {
106
150
  const responseObj = response;
107
151
  const jsonContent = responseObj.content?.['application/json'];
108
152
  if (jsonContent?.schema) {
109
- const ref = emitSchemaNode(jsonContent.schema, `${operationId}-response-${status}`, endpointId, 'schemas', packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas);
153
+ const ref = emitSchemaNode(jsonContent.schema, `${operationId}-response-${status}`, endpointId, 'schemas', endpointId, packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
110
154
  if (ref)
111
155
  responses[status] = ref;
112
156
  }
@@ -117,6 +161,54 @@ export function mapDocument(document, entry, packConfig) {
117
161
  }
118
162
  return { nodes, edges, diagnostics };
119
163
  }
164
+ function countSchemaOperationUsage(document) {
165
+ const counts = new Map();
166
+ const schemaNames = Object.keys(document.components?.schemas ?? {});
167
+ for (const [, pathItem] of Object.entries(document.paths ?? {})) {
168
+ if (!pathItem)
169
+ continue;
170
+ const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
171
+ for (const method of methods) {
172
+ const operation = pathItem[method];
173
+ if (!operation)
174
+ continue;
175
+ const opJson = JSON.stringify(operation);
176
+ for (const name of schemaNames) {
177
+ if (opJson.includes(`"#/components/schemas/${name}"`)) {
178
+ counts.set(name, (counts.get(name) ?? 0) + 1);
179
+ }
180
+ }
181
+ }
182
+ }
183
+ return counts;
184
+ }
185
+ // Schemas referenced by 2+ operations are shared. Schemas referenced by shared schemas are also shared.
186
+ function collectAllSharedSchemaNames(document, opCounts) {
187
+ const shared = new Set();
188
+ for (const [name, count] of opCounts) {
189
+ if (count > 1)
190
+ shared.add(name);
191
+ }
192
+ let changed = true;
193
+ while (changed) {
194
+ changed = false;
195
+ for (const [candidateName] of Object.entries(document.components?.schemas ?? {})) {
196
+ if (shared.has(candidateName))
197
+ continue;
198
+ for (const sharedName of shared) {
199
+ const sharedSchema = document.components?.schemas?.[sharedName];
200
+ if (isRefSchema(sharedSchema))
201
+ continue;
202
+ if (JSON.stringify(sharedSchema).includes(`"#/components/schemas/${candidateName}"`)) {
203
+ shared.add(candidateName);
204
+ changed = true;
205
+ break;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ return shared;
211
+ }
120
212
  function extractParameters(pathItem, operation, packConfig, specPath, diagnostics) {
121
213
  const pathItemParams = (pathItem.parameters ?? []);
122
214
  const operationParams = (operation.parameters ?? []);
@@ -182,27 +274,69 @@ function makeNode(template, component, specPath, id) {
182
274
  properties: {},
183
275
  };
184
276
  }
185
- function emitSchemaNode(schema, name, parentId, section, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas) {
277
+ function emitSchemaNode(schema, name, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
278
+ schema = resolveAllOfRef(schema);
186
279
  if (isRefSchema(schema)) {
187
- return sharedSchemas.get(refName(schema.$ref));
280
+ const schemaName = refName(schema.$ref);
281
+ const globalId = sharedSchemas.get(schemaName);
282
+ if (globalId) {
283
+ if (rootId)
284
+ emitReadsEdge(rootId, globalId, edges);
285
+ return globalId;
286
+ }
287
+ if (localSchemas.has(schemaName))
288
+ return localSchemas.get(schemaName);
289
+ const sourceSchema = sourceSchemas.get(schemaName);
290
+ if (sourceSchema) {
291
+ const effectiveParent = rootId ?? parentId;
292
+ return createInlineSchema(sourceSchema, schemaName, effectiveParent, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
293
+ }
294
+ return undefined;
188
295
  }
296
+ if (localSchemas.has(name))
297
+ return localSchemas.get(name);
298
+ return createInlineSchema(schema, name, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
299
+ }
300
+ function createInlineSchema(schema, name, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
189
301
  const schemaId = deriveNodeId('schema', undefined, name, parentId, section);
190
302
  const [component] = parentId.split('.');
191
303
  const node = makeNode(packConfig.constructs.requestSchema?.template ?? 'Schema', component, specPath, schemaId);
192
304
  nodes.push(node);
193
305
  edges.push({ id: `${parentId}__has-field__${schemaId}`, from: parentId, to: schemaId, type: 'has-field', state: 'implemented', stability: 'unstable' });
194
- emitFields(schema, schemaId, 'fields', packConfig, specPath, nodes, edges, diagnostics, sharedSchemas);
195
- return `#/${section}/${name}`;
306
+ const localRef = `#/${section}/${name}`;
307
+ localSchemas.set(name, localRef);
308
+ emitFields(schema, schemaId, 'fields', rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
309
+ return localRef;
310
+ }
311
+ function resolveFieldRef(schemaName, cardinality, keyed, required, rootId, readsSource, refSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
312
+ const extra = { nullable: !required, cardinality };
313
+ if (keyed)
314
+ extra.keyed = true;
315
+ const globalId = sharedSchemas.get(schemaName);
316
+ if (globalId) {
317
+ emitReadsEdge(readsSource, globalId, edges);
318
+ return { $ref: globalId, ...extra };
319
+ }
320
+ if (localSchemas.has(schemaName))
321
+ return { $ref: localSchemas.get(schemaName), ...extra };
322
+ if (rootId) {
323
+ const localRef = emitSchemaNode(refSchema, schemaName, rootId, 'schemas', rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
324
+ if (localRef)
325
+ return { $ref: localRef, ...extra };
326
+ }
327
+ return { $ref: schemaName, ...extra };
196
328
  }
197
- function emitFields(schema, parentId, section, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas) {
198
- for (const [fieldName, fieldSchema] of Object.entries(schema.properties ?? {})) {
329
+ function emitFields(schema, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
330
+ // readsSource: when in endpoint context use rootId, otherwise use the schema itself
331
+ const readsSource = rootId ?? parentId;
332
+ for (const [fieldName, rawFieldSchema] of Object.entries(schema.properties ?? {})) {
333
+ const fieldSchema = resolveAllOfRef(rawFieldSchema);
199
334
  const fieldId = deriveNodeId('field', undefined, fieldName, parentId, section);
200
335
  const [component] = parentId.split('.');
201
336
  const fieldNode = makeNode(packConfig.constructs.schemaProperty?.template ?? 'Field', component, specPath, fieldId);
202
337
  const required = Array.isArray(schema.required) && schema.required.includes(fieldName);
203
338
  if (isRefSchema(fieldSchema)) {
204
- const ref = refName(fieldSchema.$ref);
205
- fieldNode.properties = { $ref: sharedSchemas.get(ref) ?? ref, nullable: !required, cardinality: 'one' };
339
+ fieldNode.properties = resolveFieldRef(refName(fieldSchema.$ref), 'one', false, required, rootId, readsSource, fieldSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
206
340
  }
207
341
  else {
208
342
  const fs = fieldSchema;
@@ -211,15 +345,56 @@ function emitFields(schema, parentId, section, packConfig, specPath, nodes, edge
211
345
  fieldNode.properties = { ...(enumRef ? { $ref: enumRef } : { type: 'string' }), nullable: !required, cardinality: 'one' };
212
346
  }
213
347
  else if (fs.type === 'array') {
214
- const items = fs.items;
215
- if (isRefSchema(items)) {
216
- fieldNode.properties = { objectRef: refName(items.$ref), nullable: !required, cardinality: 'many' };
348
+ const rawItems = fs.items;
349
+ const items = rawItems ? resolveAllOfRef(rawItems) : undefined;
350
+ if (!items) {
351
+ fieldNode.properties = { type: 'string', nullable: !required, cardinality: 'many' };
352
+ }
353
+ else if (isRefSchema(items)) {
354
+ fieldNode.properties = resolveFieldRef(refName(items.$ref), 'many', false, required, rootId, readsSource, items, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
217
355
  }
218
356
  else {
219
- const itemType = deriveScalarType(items?.type ?? 'string', items?.format, packConfig.scalarTypes);
357
+ const itemType = deriveScalarType(items.type ?? 'string', items.format, packConfig.scalarTypes);
220
358
  fieldNode.properties = { type: itemType ?? 'string', nullable: !required, cardinality: 'many' };
221
359
  }
222
360
  }
361
+ else if (fs.type === 'object' && fs.properties) {
362
+ // Anonymous object with named properties → inline as sibling schema
363
+ if (rootId) {
364
+ const localRef = emitSchemaNode(fs, fieldName, rootId, 'schemas', rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
365
+ fieldNode.properties = localRef
366
+ ? { $ref: localRef, nullable: !required, cardinality: 'one' }
367
+ : { type: 'string', nullable: !required, cardinality: 'one' };
368
+ }
369
+ else {
370
+ diagnostics.push({ severity: 'warning', file: specPath, message: `Inline object for field ${fieldId} has no endpoint context; treating as string` });
371
+ fieldNode.properties = { type: 'string', nullable: !required, cardinality: 'one' };
372
+ }
373
+ }
374
+ else if (fs.type === 'object' && fs.additionalProperties) {
375
+ // Map/dictionary: keyed collection
376
+ const addlRaw = fs.additionalProperties;
377
+ if (typeof addlRaw === 'boolean') {
378
+ fieldNode.properties = { type: 'string', nullable: !required, cardinality: 'many', keyed: true };
379
+ }
380
+ else {
381
+ const addlSchema = resolveAllOfRef(addlRaw);
382
+ if (isRefSchema(addlSchema)) {
383
+ fieldNode.properties = resolveFieldRef(refName(addlSchema.$ref), 'many', true, required, rootId, readsSource, addlSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
384
+ }
385
+ else {
386
+ const addlObj = addlSchema;
387
+ if (addlObj.type === 'object') {
388
+ diagnostics.push({ severity: 'warning', file: specPath, message: `Double-nested map for field ${fieldId}; value type represented as string` });
389
+ fieldNode.properties = { type: 'string', nullable: !required, cardinality: 'many', keyed: true };
390
+ }
391
+ else {
392
+ const scalarType = deriveScalarType(addlObj.type ?? 'string', addlObj.format, packConfig.scalarTypes);
393
+ fieldNode.properties = { type: scalarType ?? 'string', nullable: !required, cardinality: 'many', keyed: true };
394
+ }
395
+ }
396
+ }
397
+ }
223
398
  else {
224
399
  const scalarType = deriveScalarType(fs.type ?? 'string', fs.format, packConfig.scalarTypes);
225
400
  if (scalarType) {
@@ -239,7 +414,6 @@ function deriveComponentForSchema(name, document, entry, visited = new Set()) {
239
414
  if (visited.has(name))
240
415
  return undefined;
241
416
  visited.add(name);
242
- // Direct: collect all components whose operations reference this schema
243
417
  const directComponents = new Set();
244
418
  for (const [urlPath, pathItem] of Object.entries(document.paths ?? {})) {
245
419
  if (!pathItem)
@@ -262,7 +436,6 @@ function deriveComponentForSchema(name, document, entry, visited = new Set()) {
262
436
  return 'shared';
263
437
  if (directComponents.size === 1)
264
438
  return [...directComponents][0];
265
- // Indirect: find another component schema that references this one and use its component
266
439
  for (const [schemaName, schema] of Object.entries(document.components?.schemas ?? {})) {
267
440
  if (schemaName === name || isRefSchema(schema))
268
441
  continue;
@@ -43,15 +43,32 @@ export function getClusterView(graph, nodeId, includeEdgeTypes = []) {
43
43
  }
44
44
  const clusterIds = new Set([cluster.root.id, ...cluster.children.map(node => node.id)]);
45
45
  const requestedTypes = new Set(includeEdgeTypes);
46
+ // reads edges are directional (consumer → type); only follow outbound so viewing a shared
47
+ // Schema doesn't pull in every endpoint that references it
48
+ const inboundTypes = new Set([...requestedTypes].filter(t => t !== 'reads'));
46
49
  const includedNodeIds = new Set();
47
50
  const edges = [...cluster.edges];
48
51
  const seen = new Set(cluster.edges.map(edge => edge.id));
49
52
  for (const id of clusterIds) {
50
53
  for (const edge of graph.edgesByFrom.get(id) ?? []) {
51
- collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen);
54
+ collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen, graph);
52
55
  }
53
56
  for (const edge of graph.edgesByTo.get(id) ?? []) {
54
- collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen);
57
+ collectIncludedEdge(edge, inboundTypes, clusterIds, includedNodeIds, edges, seen, graph);
58
+ }
59
+ }
60
+ // BFS over included nodes (outbound only) to transitively pull in referenced schemas
61
+ const processedOutbound = new Set(clusterIds);
62
+ let prevSize = 0;
63
+ while (includedNodeIds.size > prevSize) {
64
+ prevSize = includedNodeIds.size;
65
+ for (const id of includedNodeIds) {
66
+ if (processedOutbound.has(id))
67
+ continue;
68
+ processedOutbound.add(id);
69
+ for (const edge of graph.edgesByFrom.get(id) ?? []) {
70
+ collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen, graph);
71
+ }
55
72
  }
56
73
  }
57
74
  return {
@@ -97,15 +114,21 @@ function collectMapsTo(edge, edges, nodeIds, seen) {
97
114
  nodeIds.add(edge.to);
98
115
  seen.add(edge.id);
99
116
  }
100
- function collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen) {
117
+ function collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen, graph) {
101
118
  if (!requestedTypes.has(edge.type) || seen.has(edge.id))
102
119
  return;
103
120
  edges.push(edge);
104
121
  seen.add(edge.id);
105
- if (!clusterIds.has(edge.from))
106
- includedNodeIds.add(edge.from);
107
- if (!clusterIds.has(edge.to))
108
- includedNodeIds.add(edge.to);
122
+ for (const endId of [edge.from, edge.to]) {
123
+ if (clusterIds.has(endId))
124
+ continue;
125
+ includedNodeIds.add(endId);
126
+ const prefix = `${endId}.`;
127
+ for (const id of graph.nodesById.keys()) {
128
+ if (id.startsWith(prefix))
129
+ includedNodeIds.add(id);
130
+ }
131
+ }
109
132
  }
110
133
  const OVERLAY_EXCLUDED = new Set(['local', 'shared']);
111
134
  export function computeClusterOverlay(multi, viewingRef, overlayRefs, clusterRootId) {
@@ -28,6 +28,18 @@ export async function runImport(config, runtimeConfig) {
28
28
  for (const node of [...toAdd, ...toUpdate, ...toRemove]) {
29
29
  graph.nodesById.set(node.id, node);
30
30
  }
31
+ const STRUCTURAL_EDGE_TYPES = new Set(['has-field', 'has-value']);
32
+ for (const edge of result.edges) {
33
+ if (STRUCTURAL_EDGE_TYPES.has(edge.type))
34
+ continue;
35
+ const existing = graph.edgesByFrom.get(edge.from) ?? [];
36
+ if (!existing.some(e => e.id === edge.id)) {
37
+ const updated = [...existing, edge];
38
+ graph.edgesByFrom.set(edge.from, updated);
39
+ const byTo = graph.edgesByTo.get(edge.to) ?? [];
40
+ graph.edgesByTo.set(edge.to, [...byTo, edge]);
41
+ }
42
+ }
31
43
  }
32
44
  const graphPath = runtimeConfig.kind === 'filesystem' ? runtimeConfig.graphPath : undefined;
33
45
  const contentMap = serializeGraph(graph, { sourceGraphPath: graphPath, outputGraphPath: graphPath });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/corum",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/web/app.jsx CHANGED
@@ -376,7 +376,7 @@ function NodePage({ nodeId, templates, onNavigate, refreshToken, viewingRef, ove
376
376
  const overlayParam = overlayRefs && overlayRefs.length > 0
377
377
  ? '&' + overlayRefs.map(ref => `overlayRefs=${encodeURIComponent(ref)}`).join('&')
378
378
  : '';
379
- fetch(`/api/cluster?nodeId=${encodeURIComponent(nodeId)}&includeEdges=maps-to${refParam}${overlayParam}`)
379
+ fetch(`/api/cluster?nodeId=${encodeURIComponent(nodeId)}&includeEdges=maps-to,reads${refParam}${overlayParam}`)
380
380
  .then(response => response.ok ? response.json() : Promise.reject(response.status))
381
381
  .then(setCluster)
382
382
  .catch(err => setError(String(err)));
@@ -402,12 +402,15 @@ function NodePage({ nodeId, templates, onNavigate, refreshToken, viewingRef, ove
402
402
  const displayedNodeIds = new Set([
403
403
  root.id,
404
404
  ...Array.from(displayChildren.values()).reduce((all, group) => all.concat(group), []).map(child => child.id),
405
+ ...includedNodes.map(n => n.id),
405
406
  ]);
406
407
  const rootSpecializedTemplates = new Set(['Schema', 'EnumDefinition']);
407
408
  const rootSpecializedNodes = rootSpecializedTemplates.has(root.template) ? [[root.template, [root]]] : [];
408
409
  const childDisplayEntries = [...displayChildren.entries()]
409
410
  .filter(([templateName]) => templateName !== 'Field' && templateName !== 'EnumValue');
410
411
  const displayEntries = [...rootSpecializedNodes, ...childDisplayEntries];
412
+ const includedSchemaNodes = includedNodes.filter(n => n.template === 'Schema');
413
+ const includedEnumNodes = includedNodes.filter(n => n.template === 'EnumDefinition');
411
414
 
412
415
  function handlePropertyNavigate(targetNodeId) {
413
416
  if (displayedNodeIds.has(targetNodeId)) {
@@ -465,6 +468,28 @@ function NodePage({ nodeId, templates, onNavigate, refreshToken, viewingRef, ove
465
468
  overlayRefs={cluster.overlay ? cluster.overlay.overlayRefs : null}
466
469
  />
467
470
  ))}
471
+ {includedSchemaNodes.length > 0 && (
472
+ <SchemaCard
473
+ key="__shared-schemas__"
474
+ title="Schema"
475
+ nodes={includedSchemaNodes}
476
+ allNodes={[root, ...descendants, ...includedNodes]}
477
+ edges={edges}
478
+ anchorIdForNode={anchorIdForNode}
479
+ isShared={true}
480
+ />
481
+ )}
482
+ {includedEnumNodes.length > 0 && (
483
+ <SchemaCard
484
+ key="__shared-enums__"
485
+ title="EnumDefinition"
486
+ nodes={includedEnumNodes}
487
+ allNodes={[root, ...descendants, ...includedNodes]}
488
+ edges={edges}
489
+ anchorIdForNode={anchorIdForNode}
490
+ isShared={true}
491
+ />
492
+ )}
468
493
  </div>
469
494
  );
470
495
  }
@@ -197,17 +197,28 @@ function refName(ref) {
197
197
  }
198
198
 
199
199
  function refLocalSchemaName(ref) {
200
- if (typeof ref === 'string') return ref.startsWith('#/schemas/') ? ref.slice(10) : null;
201
- if (ref && typeof ref === 'object' && 'display' in ref) return ref.display;
202
- return null;
200
+ if (typeof ref !== 'string') {
201
+ if (ref && typeof ref === 'object' && 'display' in ref) {
202
+ const d = String(ref.display);
203
+ const dot = d.lastIndexOf('.');
204
+ return dot >= 0 ? d.slice(dot + 1) : d;
205
+ }
206
+ return null;
207
+ }
208
+ if (ref.startsWith('#/schemas/')) return ref.slice(10);
209
+ // Global node ID (e.g. "component.Schema.TypeName") — local name is the final segment
210
+ const lastDot = ref.lastIndexOf('.');
211
+ return lastDot >= 0 ? ref.slice(lastDot + 1) : null;
203
212
  }
204
213
 
205
214
  function fieldType(properties) {
206
- const cardinality = properties?.cardinality === 'many' ? '[]' : '';
207
- if (properties?.type) return `${properties.type}${cardinality}`;
215
+ const suffix = properties?.cardinality === 'many'
216
+ ? (properties?.keyed ? '{}' : '[]')
217
+ : '';
218
+ if (properties?.type) return `${properties.type}${suffix}`;
208
219
  const ref = properties?.['$ref'];
209
- if (ref) return `${refName(ref)}${cardinality}`;
210
- return cardinality ? `unknown${cardinality}` : 'unknown';
220
+ if (ref) return `${refName(ref)}${suffix}`;
221
+ return suffix ? `unknown${suffix}` : 'unknown';
211
222
  }
212
223
 
213
224
  function fieldRequirement(properties) {
@@ -253,7 +264,11 @@ function enumValueEnumName(nodeId) {
253
264
  const valueMarker = '.values.';
254
265
  const enumIdx = nodeId.indexOf(enumMarker);
255
266
  const valueIdx = nodeId.indexOf(valueMarker);
256
- if (enumIdx < 0 || valueIdx < 0) return null;
267
+ if (valueIdx < 0) return null;
268
+ if (enumIdx < 0) {
269
+ // Standalone EnumDefinition node: e.g. component.EnumDefinition.Name.values.X → 'Name'
270
+ return nodeId.slice(0, valueIdx).split('.').pop() ?? null;
271
+ }
257
272
  return nodeId.slice(enumIdx + enumMarker.length, valueIdx);
258
273
  }
259
274
 
@@ -266,7 +281,11 @@ function enumValueDescription(node) {
266
281
  }
267
282
 
268
283
  function buildSchemaModel(schemaNodes, allNodes) {
269
- const schemasByName = new Map(schemaNodes.map(node => [localSchemaName(node.id), node]));
284
+ // Include all Schema nodes from allNodes so canExpand works for included/referenced schemas
285
+ const allSchemaNodes = (allNodes ?? []).filter(n => n.template === 'Schema');
286
+ const schemasByName = new Map(allSchemaNodes.map(node => [localSchemaName(node.id), node]));
287
+ // Ensure the primary schemaNodes are always present (they may not be in allNodes)
288
+ for (const node of schemaNodes) schemasByName.set(localSchemaName(node.id), node);
270
289
  const fieldsBySchema = new Map();
271
290
  const referencedSchemas = new Set();
272
291
 
@@ -454,7 +473,7 @@ function OverlayLegend({ overlayRefs }) {
454
473
  );
455
474
  }
456
475
 
457
- function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFields, overlayRefs }) {
476
+ function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFields, overlayRefs, isShared }) {
458
477
  if (!nodes || nodes.length === 0) return null;
459
478
 
460
479
  if (title === 'EnumDefinition') {
@@ -469,7 +488,7 @@ function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFie
469
488
 
470
489
  return (
471
490
  <div className="card enum-card">
472
- <div className="card-head">Enums</div>
491
+ <div className="card-head">{isShared ? 'Shared Enums' : 'Enums'}</div>
473
492
  <div className="card-body">
474
493
  {nodes.map(enumNode => {
475
494
  const enumName = localEnumName(enumNode.id);
@@ -513,7 +532,7 @@ function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFie
513
532
  const model = buildSchemaModel(nodes, allNodes ?? nodes);
514
533
  return (
515
534
  <div className="card schema-card">
516
- <div className="card-head">Schemas</div>
535
+ <div className="card-head">{isShared ? 'Shared Schemas' : 'Schemas'}</div>
517
536
  <div className="card-body">
518
537
  {model.topSchemas.map(schema => {
519
538
  const schemaName = localSchemaName(schema.id);
@@ -521,8 +540,9 @@ function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFie
521
540
  return (
522
541
  <div key={schema.id} className="schema-section" id={anchorIdForNode ? anchorIdForNode(schema.id) : undefined}>
523
542
  <div className="schema-section-head">
524
- <div>
543
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
525
544
  <div className="schema-title">{schemaName}</div>
545
+ {isShared && <span className="tag" style={{ fontSize: 10, padding: '1px 6px', background: 'var(--ink-4)', color: 'var(--bg)' }}>shared</span>}
526
546
  {schema.properties?.description && <div className="label-sm">{schema.properties.description}</div>}
527
547
  </div>
528
548
  <div className="label-sm mono">{schema.id}</div>