@atolis-hq/corum 0.1.13 → 0.1.15

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.
60
+ if (document.components?.schemas) {
61
+ for (const [name, schema] of Object.entries(document.components.schemas)) {
62
+ if (isRefSchema(schema))
63
+ continue;
64
+ const s = schema;
65
+ const component = deriveComponentForSchema(name, document, entry);
66
+ if (!component)
67
+ continue;
68
+ if (s.type !== 'object' && s.enum) {
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.
34
77
  if (document.components?.schemas) {
35
78
  for (const [name, schema] of Object.entries(document.components.schemas)) {
36
79
  if (isRefSchema(schema))
37
80
  continue;
38
81
  const s = schema;
39
82
  if (s.type !== 'object' && s.enum) {
40
- const component = deriveComponentForSchema(name, document, entry);
41
- if (!component)
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 ?? []);
@@ -137,18 +229,16 @@ function extractParameters(pathItem, operation, packConfig, specPath, diagnostic
137
229
  if (!schema)
138
230
  continue;
139
231
  let type;
140
- let cardinality;
232
+ let collection;
141
233
  if (schema.type === 'array') {
142
- cardinality = 'many';
234
+ collection = 'array';
143
235
  const items = schema.items;
144
236
  type = deriveScalarType(items?.type ?? 'string', items?.format, packConfig.scalarTypes) ?? 'string';
145
237
  }
146
238
  else if (schema.enum) {
147
- cardinality = 'one';
148
239
  type = 'string';
149
240
  }
150
241
  else {
151
- cardinality = 'one';
152
242
  const derived = deriveScalarType(schema.type ?? 'string', schema.format, packConfig.scalarTypes);
153
243
  if (!derived) {
154
244
  diagnostics.push({ severity: 'warning', file: specPath, message: `Unknown type for parameter ${name}: ${schema.type}/${schema.format}, defaulting to string` });
@@ -162,7 +252,7 @@ function extractParameters(pathItem, operation, packConfig, specPath, diagnostic
162
252
  location: param.in,
163
253
  type,
164
254
  required: param.required ?? false,
165
- cardinality,
255
+ ...(collection ? { collection } : {}),
166
256
  };
167
257
  }
168
258
  return Object.keys(parameters).length > 0 ? parameters : undefined;
@@ -182,52 +272,165 @@ function makeNode(template, component, specPath, id) {
182
272
  properties: {},
183
273
  };
184
274
  }
185
- function emitSchemaNode(schema, name, parentId, section, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas) {
275
+ function emitSchemaNode(schema, name, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
276
+ schema = resolveAllOfRef(schema);
186
277
  if (isRefSchema(schema)) {
187
- return sharedSchemas.get(refName(schema.$ref));
278
+ const schemaName = refName(schema.$ref);
279
+ const globalId = sharedSchemas.get(schemaName);
280
+ if (globalId) {
281
+ if (rootId)
282
+ emitReadsEdge(rootId, globalId, edges);
283
+ return globalId;
284
+ }
285
+ if (localSchemas.has(schemaName))
286
+ return localSchemas.get(schemaName);
287
+ const sourceSchema = sourceSchemas.get(schemaName);
288
+ if (sourceSchema) {
289
+ const effectiveParent = rootId ?? parentId;
290
+ return createInlineSchema(sourceSchema, schemaName, effectiveParent, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
291
+ }
292
+ return undefined;
188
293
  }
294
+ if (localSchemas.has(name))
295
+ return localSchemas.get(name);
296
+ return createInlineSchema(schema, name, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
297
+ }
298
+ function createInlineSchema(schema, name, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
189
299
  const schemaId = deriveNodeId('schema', undefined, name, parentId, section);
190
300
  const [component] = parentId.split('.');
191
301
  const node = makeNode(packConfig.constructs.requestSchema?.template ?? 'Schema', component, specPath, schemaId);
192
302
  nodes.push(node);
193
303
  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}`;
304
+ const localRef = `#/${section}/${name}`;
305
+ localSchemas.set(name, localRef);
306
+ emitFields(schema, schemaId, 'fields', rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
307
+ return localRef;
196
308
  }
197
- function emitFields(schema, parentId, section, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas) {
198
- for (const [fieldName, fieldSchema] of Object.entries(schema.properties ?? {})) {
309
+ function resolveFieldRef(schemaName, collection, required, rootId, readsSource, refSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
310
+ const extra = { nullable: !required };
311
+ if (collection !== 'one')
312
+ extra.collection = collection;
313
+ const globalId = sharedSchemas.get(schemaName);
314
+ if (globalId) {
315
+ emitReadsEdge(readsSource, globalId, edges);
316
+ return { $ref: globalId, ...extra };
317
+ }
318
+ if (localSchemas.has(schemaName))
319
+ return { $ref: localSchemas.get(schemaName), ...extra };
320
+ if (rootId) {
321
+ const localRef = emitSchemaNode(refSchema, schemaName, rootId, 'schemas', rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
322
+ if (localRef)
323
+ return { $ref: localRef, ...extra };
324
+ }
325
+ return { $ref: schemaName, ...extra };
326
+ }
327
+ function emitFields(schema, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
328
+ // readsSource: when in endpoint context use rootId, otherwise use the schema itself
329
+ const readsSource = rootId ?? parentId;
330
+ for (const [fieldName, rawFieldSchema] of Object.entries(schema.properties ?? {})) {
331
+ const fieldSchema = resolveAllOfRef(rawFieldSchema);
199
332
  const fieldId = deriveNodeId('field', undefined, fieldName, parentId, section);
200
333
  const [component] = parentId.split('.');
201
334
  const fieldNode = makeNode(packConfig.constructs.schemaProperty?.template ?? 'Field', component, specPath, fieldId);
202
335
  const required = Array.isArray(schema.required) && schema.required.includes(fieldName);
203
336
  if (isRefSchema(fieldSchema)) {
204
- const ref = refName(fieldSchema.$ref);
205
- fieldNode.properties = { $ref: sharedSchemas.get(ref) ?? ref, nullable: !required, cardinality: 'one' };
337
+ fieldNode.properties = resolveFieldRef(refName(fieldSchema.$ref), 'one', required, rootId, readsSource, fieldSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
206
338
  }
207
339
  else {
208
340
  const fs = fieldSchema;
209
341
  if (fs.enum && fs.type !== 'object') {
210
342
  const enumRef = sharedSchemas.get(fieldName);
211
- fieldNode.properties = { ...(enumRef ? { $ref: enumRef } : { type: 'string' }), nullable: !required, cardinality: 'one' };
343
+ fieldNode.properties = { ...(enumRef ? { $ref: enumRef } : { type: 'string' }), nullable: !required };
212
344
  }
213
345
  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' };
346
+ const rawItems = fs.items;
347
+ const items = rawItems ? resolveAllOfRef(rawItems) : undefined;
348
+ if (!items) {
349
+ fieldNode.properties = { type: 'string', nullable: !required, collection: 'array' };
350
+ }
351
+ else if (isRefSchema(items)) {
352
+ fieldNode.properties = resolveFieldRef(refName(items.$ref), 'array', required, rootId, readsSource, items, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
353
+ }
354
+ else {
355
+ const itemType = deriveScalarType(items.type ?? 'string', items.format, packConfig.scalarTypes);
356
+ fieldNode.properties = { type: itemType ?? 'string', nullable: !required, collection: 'array' };
357
+ }
358
+ }
359
+ else if (fs.type === 'object' && fs.properties) {
360
+ // Anonymous object with named properties → inline as sibling schema
361
+ if (rootId) {
362
+ const localRef = emitSchemaNode(fs, fieldName, rootId, 'schemas', rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
363
+ fieldNode.properties = localRef
364
+ ? { $ref: localRef, nullable: !required }
365
+ : { type: 'string', nullable: !required };
366
+ }
367
+ else {
368
+ diagnostics.push({ severity: 'warning', file: specPath, message: `Inline object for field ${fieldId} has no endpoint context; treating as string` });
369
+ fieldNode.properties = { type: 'string', nullable: !required };
370
+ }
371
+ }
372
+ else if (fs.type === 'object' && fs.additionalProperties) {
373
+ // Map/dictionary: keyed collection
374
+ const addlRaw = fs.additionalProperties;
375
+ if (typeof addlRaw === 'boolean') {
376
+ fieldNode.properties = { type: 'string', nullable: !required, collection: 'map' };
217
377
  }
218
378
  else {
219
- const itemType = deriveScalarType(items?.type ?? 'string', items?.format, packConfig.scalarTypes);
220
- fieldNode.properties = { type: itemType ?? 'string', nullable: !required, cardinality: 'many' };
379
+ const addlSchema = resolveAllOfRef(addlRaw);
380
+ if (isRefSchema(addlSchema)) {
381
+ fieldNode.properties = resolveFieldRef(refName(addlSchema.$ref), 'map', required, rootId, readsSource, addlSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
382
+ }
383
+ else {
384
+ const addlObj = addlSchema;
385
+ if (addlObj.type === 'object') {
386
+ const innerAddl = addlObj.additionalProperties;
387
+ if (!innerAddl || typeof innerAddl === 'boolean') {
388
+ fieldNode.properties = { type: 'string', nullable: !required, collection: 'map-of-map' };
389
+ }
390
+ else {
391
+ const innerSchema = resolveAllOfRef(innerAddl);
392
+ if (isRefSchema(innerSchema)) {
393
+ fieldNode.properties = resolveFieldRef(refName(innerSchema.$ref), 'map-of-map', required, rootId, readsSource, innerSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
394
+ }
395
+ else {
396
+ const inner = innerSchema;
397
+ const scalarType = deriveScalarType(inner.type ?? 'string', inner.format, packConfig.scalarTypes);
398
+ if (scalarType) {
399
+ fieldNode.properties = { type: scalarType, nullable: !required, collection: 'map-of-map' };
400
+ }
401
+ else {
402
+ diagnostics.push({ severity: 'warning', file: specPath, message: `[WARN] Double-nested map for field ${fieldId}; inner value type not representable, using string` });
403
+ fieldNode.properties = { type: 'string', nullable: !required, collection: 'map-of-map' };
404
+ }
405
+ }
406
+ }
407
+ }
408
+ else if (addlObj.type === 'array') {
409
+ const rawItems = addlObj.items;
410
+ const items = rawItems ? resolveAllOfRef(rawItems) : undefined;
411
+ if (items && isRefSchema(items)) {
412
+ fieldNode.properties = resolveFieldRef(refName(items.$ref), 'map-of-array', required, rootId, readsSource, items, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
413
+ }
414
+ else {
415
+ const scalarType = items ? deriveScalarType(items.type ?? 'string', items.format, packConfig.scalarTypes) : undefined;
416
+ fieldNode.properties = { type: scalarType ?? 'string', nullable: !required, collection: 'map-of-array' };
417
+ }
418
+ }
419
+ else {
420
+ const scalarType = deriveScalarType(addlObj.type ?? 'string', addlObj.format, packConfig.scalarTypes);
421
+ fieldNode.properties = { type: scalarType ?? 'string', nullable: !required, collection: 'map' };
422
+ }
423
+ }
221
424
  }
222
425
  }
223
426
  else {
224
427
  const scalarType = deriveScalarType(fs.type ?? 'string', fs.format, packConfig.scalarTypes);
225
428
  if (scalarType) {
226
- fieldNode.properties = { type: scalarType, nullable: !required, cardinality: 'one' };
429
+ fieldNode.properties = { type: scalarType, nullable: !required };
227
430
  }
228
431
  else {
229
432
  diagnostics.push({ severity: 'warning', file: specPath, message: `Unknown type for field ${fieldId}: ${fs.type}/${fs.format}` });
230
- fieldNode.properties = { type: 'string', nullable: !required, cardinality: 'one' };
433
+ fieldNode.properties = { type: 'string', nullable: !required };
231
434
  }
232
435
  }
233
436
  }
@@ -239,7 +442,6 @@ function deriveComponentForSchema(name, document, entry, visited = new Set()) {
239
442
  if (visited.has(name))
240
443
  return undefined;
241
444
  visited.add(name);
242
- // Direct: collect all components whose operations reference this schema
243
445
  const directComponents = new Set();
244
446
  for (const [urlPath, pathItem] of Object.entries(document.paths ?? {})) {
245
447
  if (!pathItem)
@@ -262,7 +464,6 @@ function deriveComponentForSchema(name, document, entry, visited = new Set()) {
262
464
  return 'shared';
263
465
  if (directComponents.size === 1)
264
466
  return [...directComponents][0];
265
- // Indirect: find another component schema that references this one and use its component
266
467
  for (const [schemaName, schema] of Object.entries(document.components?.schemas ?? {})) {
267
468
  if (schemaName === name || isRefSchema(schema))
268
469
  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.15",
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,27 @@ 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 c = properties?.collection;
216
+ const suffix = c === 'array' ? '[]' : c === 'map' ? '{}' : c === 'map-of-map' ? '{{}}' : c === 'map-of-array' ? '{[]}' : '';
217
+ if (properties?.type) return `${properties.type}${suffix}`;
208
218
  const ref = properties?.['$ref'];
209
- if (ref) return `${refName(ref)}${cardinality}`;
210
- return cardinality ? `unknown${cardinality}` : 'unknown';
219
+ if (ref) return `${refName(ref)}${suffix}`;
220
+ return suffix ? `unknown${suffix}` : 'unknown';
211
221
  }
212
222
 
213
223
  function fieldRequirement(properties) {
@@ -217,7 +227,7 @@ function fieldRequirement(properties) {
217
227
  }
218
228
 
219
229
  function fieldCardinality(properties) {
220
- return properties?.cardinality ?? '-';
230
+ return properties?.collection ?? 'one';
221
231
  }
222
232
 
223
233
  function fieldDetails(properties) {
@@ -253,7 +263,11 @@ function enumValueEnumName(nodeId) {
253
263
  const valueMarker = '.values.';
254
264
  const enumIdx = nodeId.indexOf(enumMarker);
255
265
  const valueIdx = nodeId.indexOf(valueMarker);
256
- if (enumIdx < 0 || valueIdx < 0) return null;
266
+ if (valueIdx < 0) return null;
267
+ if (enumIdx < 0) {
268
+ // Standalone EnumDefinition node: e.g. component.EnumDefinition.Name.values.X → 'Name'
269
+ return nodeId.slice(0, valueIdx).split('.').pop() ?? null;
270
+ }
257
271
  return nodeId.slice(enumIdx + enumMarker.length, valueIdx);
258
272
  }
259
273
 
@@ -266,7 +280,11 @@ function enumValueDescription(node) {
266
280
  }
267
281
 
268
282
  function buildSchemaModel(schemaNodes, allNodes) {
269
- const schemasByName = new Map(schemaNodes.map(node => [localSchemaName(node.id), node]));
283
+ // Include all Schema nodes from allNodes so canExpand works for included/referenced schemas
284
+ const allSchemaNodes = (allNodes ?? []).filter(n => n.template === 'Schema');
285
+ const schemasByName = new Map(allSchemaNodes.map(node => [localSchemaName(node.id), node]));
286
+ // Ensure the primary schemaNodes are always present (they may not be in allNodes)
287
+ for (const node of schemaNodes) schemasByName.set(localSchemaName(node.id), node);
270
288
  const fieldsBySchema = new Map();
271
289
  const referencedSchemas = new Set();
272
290
 
@@ -317,7 +335,8 @@ function SchemaFieldRows({ schemaName, model, prefix = '', depth = 0, visited =
317
335
  const canExpand = localRef !== null && model.schemasByName.has(localRef) && !visited.has(localRef);
318
336
  const childSchemaNode = canExpand ? model.schemasByName.get(localRef) : null;
319
337
  const childGhostFields = childSchemaNode ? overlayFieldsForSchema(overlayFields, childSchemaNode.id) : [];
320
- const childPrefix = `${prefix}${name}${field.properties?.cardinality === 'many' ? '[].' : '.'}`;
338
+ const c = field.properties?.collection;
339
+ const childPrefix = `${prefix}${name}${c === 'map-of-map' || c === 'map-of-array' ? '[][].' : c === 'array' || c === 'map' ? '[].' : '.'}`;
321
340
  const nextVisited = new Set(visited);
322
341
  nextVisited.add(schemaName);
323
342
  const links = edges.filter(e =>
@@ -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>
@@ -532,7 +552,7 @@ function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFie
532
552
  <div />
533
553
  <div className="label-xs">Name</div>
534
554
  <div className="label-xs">Type</div>
535
- <div className="label-xs">Cardinality</div>
555
+ <div className="label-xs">Collection</div>
536
556
  <div className="label-xs">Req</div>
537
557
  <div className="label-xs">State</div>
538
558
  <div className="label-xs">Links</div>