@atolis-hq/corum 0.1.12 → 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.
- package/dist/src/adapters/openapi/mapper.js +198 -25
- package/dist/src/graph/index.js +30 -7
- package/dist/src/import/runner.js +12 -0
- package/package.json +1 -1
- package/web/app.jsx +26 -1
- package/web/primitives.jsx +33 -13
|
@@ -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
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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;
|
package/dist/src/graph/index.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
includedNodeIds.add(
|
|
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
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
|
}
|
package/web/primitives.jsx
CHANGED
|
@@ -197,17 +197,28 @@ function refName(ref) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
function refLocalSchemaName(ref) {
|
|
200
|
-
if (typeof ref
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
207
|
-
|
|
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)}${
|
|
210
|
-
return
|
|
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 (
|
|
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
|
-
|
|
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>
|