856 lines
25 KiB
TypeScript
856 lines
25 KiB
TypeScript
import type { CogniteClient } from "@cognite/sdk";
|
|
import type {
|
|
CDFEdge,
|
|
CDFNode,
|
|
DataModelInfo,
|
|
GraphData,
|
|
ReverseRelationQuery,
|
|
ViewPriorityConfig,
|
|
} from "./types";
|
|
|
|
// =============================================================================
|
|
// Node Type Detection
|
|
// =============================================================================
|
|
|
|
const DEFAULT_VIEW_TYPE_PRIORITY = [
|
|
"ISA95Asset",
|
|
"Equipment",
|
|
"Area",
|
|
"Site",
|
|
"Enterprise",
|
|
"WorkCell",
|
|
"WorkCenter",
|
|
"ProcessCell",
|
|
"ProcessArea",
|
|
"ProductionLine",
|
|
"ProductionRun",
|
|
"Product",
|
|
"WorkOrder",
|
|
"WorkUnit",
|
|
"QualityAlert",
|
|
"FaultCode",
|
|
"CogniteAsset",
|
|
"CogniteEquipment",
|
|
"CogniteFile",
|
|
"CogniteTimeSeries",
|
|
"CogniteActivity",
|
|
"Cognite3DModel",
|
|
"CogniteDescribable",
|
|
"CogniteSourceable",
|
|
"CogniteVisualizable",
|
|
"CogniteSchedulable",
|
|
];
|
|
|
|
const DEFAULT_SKIP_VIEWS_FOR_TYPE = new Set([
|
|
"CogniteDescribable",
|
|
"CogniteSourceable",
|
|
"CogniteVisualizable",
|
|
"CogniteSchedulable",
|
|
"CogniteSourceSystem",
|
|
]);
|
|
|
|
const DEFAULT_SKIP_VIEWS_FOR_PROPERTIES = new Set([
|
|
"CogniteSourceable",
|
|
"CogniteVisualizable",
|
|
"CogniteSchedulable",
|
|
]);
|
|
|
|
function deriveNodeTypeFromProperties(
|
|
properties: Record<string, Record<string, unknown>> | undefined,
|
|
viewPriorityConfig?: ViewPriorityConfig
|
|
): { space: string; externalId: string } | undefined {
|
|
if (!properties) return undefined;
|
|
|
|
const viewTypePriority = viewPriorityConfig?.viewTypePriority ?? DEFAULT_VIEW_TYPE_PRIORITY;
|
|
const skipViewsForType = viewPriorityConfig?.skipViewsForType
|
|
? new Set(viewPriorityConfig.skipViewsForType)
|
|
: DEFAULT_SKIP_VIEWS_FOR_TYPE;
|
|
|
|
const viewKeys: Array<{
|
|
space: string;
|
|
externalId: string;
|
|
priority: number;
|
|
}> = [];
|
|
|
|
for (const [spaceKey, viewsObj] of Object.entries(properties)) {
|
|
if (typeof viewsObj !== "object" || viewsObj === null) continue;
|
|
|
|
for (const viewKey of Object.keys(viewsObj)) {
|
|
const viewExternalId = viewKey.split("/")[0];
|
|
if (skipViewsForType.has(viewExternalId)) continue;
|
|
|
|
const priorityIndex = viewTypePriority.indexOf(viewExternalId);
|
|
const priority = priorityIndex >= 0 ? priorityIndex : 100;
|
|
|
|
viewKeys.push({
|
|
space: spaceKey,
|
|
externalId: viewExternalId,
|
|
priority,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (viewKeys.length === 0) return undefined;
|
|
|
|
viewKeys.sort((a, b) => a.priority - b.priority);
|
|
|
|
return {
|
|
space: viewKeys[0].space,
|
|
externalId: viewKeys[0].externalId,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Query with cursor-based pagination
|
|
// =============================================================================
|
|
|
|
const QUERY_PAGE_LIMIT = 10_000;
|
|
|
|
/**
|
|
* Paginate a CDF instances.query selection until either:
|
|
* 1. the API has no more cursors,
|
|
* 2. the cumulative number of items reaches `maxTotal` (hard cap), or
|
|
* 3. an empty page is returned.
|
|
*
|
|
* `maxTotal` is a HARD MAXIMUM. The function never returns more than `maxTotal`
|
|
* items, and it stops fetching additional pages as soon as the cap is reached.
|
|
* Pass `Infinity` to disable the cap (legacy "fetch everything" behaviour).
|
|
*/
|
|
async function queryWithCursorPagination<T>(
|
|
client: CogniteClient,
|
|
selectionName: string,
|
|
query: {
|
|
with: Record<
|
|
string,
|
|
{
|
|
nodes?: { filter?: unknown };
|
|
edges?: { filter?: unknown };
|
|
limit?: number;
|
|
}
|
|
>;
|
|
select: Record<string, { sources?: unknown[]; sort?: unknown[] }>;
|
|
includeTyping?: boolean;
|
|
},
|
|
limitPerPage: number = QUERY_PAGE_LIMIT,
|
|
maxTotal: number = Infinity
|
|
): Promise<T[]> {
|
|
const results: T[] = [];
|
|
let cursors: Record<string, string> | undefined;
|
|
|
|
while (results.length < maxTotal) {
|
|
const remaining = maxTotal - results.length;
|
|
const pageLimit = Math.max(1, Math.min(limitPerPage, remaining));
|
|
|
|
const withWithLimit = { ...query.with };
|
|
const firstKey = Object.keys(withWithLimit)[0];
|
|
if (firstKey && withWithLimit[firstKey]) {
|
|
withWithLimit[firstKey] = {
|
|
...withWithLimit[firstKey],
|
|
limit: pageLimit,
|
|
};
|
|
}
|
|
const request = {
|
|
...query,
|
|
with: withWithLimit,
|
|
cursors,
|
|
};
|
|
const res = await client.instances.query(
|
|
request as Parameters<CogniteClient["instances"]["query"]>[0]
|
|
);
|
|
const chunk = (res.items[selectionName] ?? []) as T[];
|
|
results.push(...chunk);
|
|
cursors =
|
|
res.nextCursor && Object.keys(res.nextCursor).length > 0
|
|
? (res.nextCursor as Record<string, string>)
|
|
: undefined;
|
|
if (chunk.length === 0 || !cursors?.[selectionName]) break;
|
|
}
|
|
|
|
return results.length > maxTotal ? results.slice(0, maxTotal) : results;
|
|
}
|
|
|
|
// =============================================================================
|
|
// fetchNodeDetails
|
|
// =============================================================================
|
|
|
|
export async function fetchNodeDetails(
|
|
client: CogniteClient | null,
|
|
space: string,
|
|
externalId: string
|
|
): Promise<CDFNode | null> {
|
|
if (!client) {
|
|
throw new Error("CDF client is not available");
|
|
}
|
|
|
|
try {
|
|
const inspectResult = await client.instances.inspect({
|
|
items: [
|
|
{
|
|
instanceType: "node" as const,
|
|
space,
|
|
externalId,
|
|
},
|
|
],
|
|
inspectionOperations: {
|
|
involvedViews: {
|
|
allVersions: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const involvedViews = inspectResult.items?.[0]?.inspectionResults?.involvedViews || [];
|
|
|
|
const sources = involvedViews.slice(0, 10).map((view) => ({
|
|
source: {
|
|
type: "view" as const,
|
|
space: view.space,
|
|
externalId: view.externalId,
|
|
version: view.version ?? "latest",
|
|
},
|
|
}));
|
|
|
|
const response = await client.instances.retrieve({
|
|
items: [
|
|
{
|
|
instanceType: "node" as const,
|
|
space,
|
|
externalId,
|
|
},
|
|
],
|
|
includeTyping: true,
|
|
sources: sources.length > 0 ? sources : undefined,
|
|
});
|
|
|
|
if (response.items.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const item = response.items[0];
|
|
const properties = item.properties as Record<string, Record<string, unknown>>;
|
|
const derivedType = deriveNodeTypeFromProperties(properties);
|
|
|
|
return {
|
|
space: item.space,
|
|
externalId: item.externalId,
|
|
instanceType: "node" as const,
|
|
version: item.version,
|
|
createdTime: item.createdTime,
|
|
lastUpdatedTime: item.lastUpdatedTime,
|
|
type: item.type || derivedType,
|
|
properties,
|
|
};
|
|
} catch (error) {
|
|
console.error("[GraphViewer] Error fetching node details:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// fetchConnectedNodes
|
|
// =============================================================================
|
|
|
|
export interface ExpandNodeResult {
|
|
newNodes: CDFNode[];
|
|
newEdges: CDFEdge[];
|
|
connectedNodeIds: string[];
|
|
}
|
|
|
|
export async function fetchConnectedNodes(
|
|
client: CogniteClient | null,
|
|
nodeSpace: string,
|
|
nodeExternalId: string,
|
|
existingNodeIds: Set<string>,
|
|
_dataModel?: DataModelInfo,
|
|
limit = 100,
|
|
whitelistedRelationProps?: Set<string>,
|
|
coreReverseQueries?: ReverseRelationQuery[],
|
|
viewPriorityConfig?: ViewPriorityConfig
|
|
): Promise<ExpandNodeResult> {
|
|
if (!client) {
|
|
throw new Error("CDF client is not available. Please ensure you are authenticated.");
|
|
}
|
|
|
|
const extractDirectRelations = (
|
|
properties: Record<string, Record<string, unknown>> | undefined
|
|
): Array<{ space: string; externalId: string }> => {
|
|
if (!properties) return [];
|
|
|
|
const refs: Array<{ space: string; externalId: string }> = [];
|
|
const seen = new Set<string>();
|
|
|
|
const processValue = (val: unknown) => {
|
|
if (val && typeof val === "object" && "space" in val && "externalId" in val) {
|
|
const ref = val as { space: string; externalId: string };
|
|
if (typeof ref.space === "string" && typeof ref.externalId === "string") {
|
|
const key = `${ref.space}:${ref.externalId}`;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
refs.push({ space: ref.space, externalId: ref.externalId });
|
|
}
|
|
}
|
|
} else if (Array.isArray(val)) {
|
|
val.forEach(processValue);
|
|
}
|
|
};
|
|
|
|
for (const spaceObj of Object.values(properties)) {
|
|
if (typeof spaceObj !== "object" || spaceObj === null) continue;
|
|
for (const viewObj of Object.values(spaceObj)) {
|
|
if (typeof viewObj !== "object" || viewObj === null) continue;
|
|
for (const [propName, propVal] of Object.entries(viewObj as Record<string, unknown>)) {
|
|
if (!whitelistedRelationProps || whitelistedRelationProps.has(propName)) {
|
|
processValue(propVal);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return refs;
|
|
};
|
|
|
|
const [sourceNodeRefs, edgeResponse] = await Promise.all([
|
|
(async () => {
|
|
try {
|
|
const sourceInspect = await client.instances.inspect({
|
|
items: [
|
|
{
|
|
instanceType: "node" as const,
|
|
space: nodeSpace,
|
|
externalId: nodeExternalId,
|
|
},
|
|
],
|
|
inspectionOperations: { involvedViews: { allVersions: false } },
|
|
});
|
|
|
|
if (sourceInspect.items.length > 0) {
|
|
const involvedViews =
|
|
(
|
|
sourceInspect.items[0] as {
|
|
inspectionResults?: {
|
|
involvedViews?: Array<{
|
|
space: string;
|
|
externalId: string;
|
|
version: string;
|
|
}>;
|
|
};
|
|
}
|
|
).inspectionResults?.involvedViews || [];
|
|
|
|
const sources = involvedViews.slice(0, 10).map((v) => ({
|
|
source: {
|
|
type: "view" as const,
|
|
space: v.space,
|
|
externalId: v.externalId,
|
|
version: v.version,
|
|
},
|
|
}));
|
|
|
|
if (sources.length > 0) {
|
|
const sourceNodeResponse = await client.instances.retrieve({
|
|
items: [
|
|
{
|
|
instanceType: "node" as const,
|
|
space: nodeSpace,
|
|
externalId: nodeExternalId,
|
|
},
|
|
],
|
|
sources,
|
|
includeTyping: true,
|
|
});
|
|
|
|
if (sourceNodeResponse.items.length > 0) {
|
|
const props = sourceNodeResponse.items[0].properties as Record<
|
|
string,
|
|
Record<string, unknown>
|
|
>;
|
|
return extractDirectRelations(props);
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
})(),
|
|
|
|
(async () => {
|
|
type EdgeItem = {
|
|
space: string;
|
|
externalId: string;
|
|
version: number;
|
|
createdTime: number;
|
|
lastUpdatedTime: number;
|
|
type: { space: string; externalId: string };
|
|
startNode: { space: string; externalId: string };
|
|
endNode: { space: string; externalId: string };
|
|
properties?: Record<string, Record<string, unknown>>;
|
|
};
|
|
const items = await queryWithCursorPagination<EdgeItem>(
|
|
client,
|
|
"edges",
|
|
{
|
|
with: {
|
|
edges: {
|
|
edges: {
|
|
filter: {
|
|
or: [
|
|
{
|
|
equals: {
|
|
property: ["edge", "startNode"],
|
|
value: { space: nodeSpace, externalId: nodeExternalId },
|
|
},
|
|
},
|
|
{
|
|
equals: {
|
|
property: ["edge", "endNode"],
|
|
value: { space: nodeSpace, externalId: nodeExternalId },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: { edges: {} },
|
|
includeTyping: true,
|
|
},
|
|
Math.min(limit * 2, QUERY_PAGE_LIMIT),
|
|
// Hard cap: never return more than `limit` edges per expansion. This
|
|
// upper bound protects the consumer from runaway pagination and matches
|
|
// the documented contract of `initialConnectionLimit`.
|
|
limit
|
|
);
|
|
return { items };
|
|
})(),
|
|
]);
|
|
|
|
const newEdges: CDFEdge[] = edgeResponse.items.map((edgeItem) => {
|
|
const item = edgeItem as {
|
|
space: string;
|
|
externalId: string;
|
|
version: number;
|
|
createdTime: number;
|
|
lastUpdatedTime: number;
|
|
type: { space: string; externalId: string };
|
|
startNode: { space: string; externalId: string };
|
|
endNode: { space: string; externalId: string };
|
|
properties?: Record<string, Record<string, unknown>>;
|
|
};
|
|
|
|
return {
|
|
space: item.space,
|
|
externalId: item.externalId,
|
|
instanceType: "edge" as const,
|
|
version: item.version,
|
|
createdTime: item.createdTime,
|
|
lastUpdatedTime: item.lastUpdatedTime,
|
|
type: item.type,
|
|
startNode: item.startNode,
|
|
endNode: item.endNode,
|
|
properties: item.properties,
|
|
};
|
|
});
|
|
|
|
const connectedNodeRefs = new Map<string, { space: string; externalId: string }>();
|
|
|
|
for (const edge of newEdges) {
|
|
const startKey = `${edge.startNode.space}:${edge.startNode.externalId}`;
|
|
const endKey = `${edge.endNode.space}:${edge.endNode.externalId}`;
|
|
|
|
if (!existingNodeIds.has(startKey)) {
|
|
connectedNodeRefs.set(startKey, edge.startNode);
|
|
}
|
|
if (!existingNodeIds.has(endKey)) {
|
|
connectedNodeRefs.set(endKey, edge.endNode);
|
|
}
|
|
}
|
|
|
|
const sourceNodeKey = `${nodeSpace}:${nodeExternalId}`;
|
|
const directRelationEdges: CDFEdge[] = [];
|
|
|
|
for (const ref of sourceNodeRefs) {
|
|
const refKey = `${ref.space}:${ref.externalId}`;
|
|
if (
|
|
refKey !== sourceNodeKey &&
|
|
!existingNodeIds.has(refKey) &&
|
|
!connectedNodeRefs.has(refKey)
|
|
) {
|
|
connectedNodeRefs.set(refKey, ref);
|
|
|
|
directRelationEdges.push({
|
|
space: nodeSpace,
|
|
externalId: `synthetic:${nodeExternalId}->direct->${ref.externalId}`,
|
|
instanceType: "edge" as const,
|
|
version: 1,
|
|
createdTime: Date.now(),
|
|
lastUpdatedTime: Date.now(),
|
|
type: { space: "synthetic", externalId: "direct-relation" },
|
|
startNode: { space: nodeSpace, externalId: nodeExternalId },
|
|
endNode: { space: ref.space, externalId: ref.externalId },
|
|
properties: {},
|
|
});
|
|
}
|
|
}
|
|
|
|
newEdges.push(...directRelationEdges);
|
|
|
|
// Reverse relation queries
|
|
const reverseRelationRefs: Array<{ space: string; externalId: string }> = [];
|
|
const CORE_REVERSE_QUERIES = coreReverseQueries ?? [];
|
|
|
|
try {
|
|
const nodeRef = { space: nodeSpace, externalId: nodeExternalId };
|
|
|
|
// Spread the per-expansion budget across all configured reverse queries so
|
|
// the total number of nodes contributed by reverse relations stays within
|
|
// `limit`. Each query gets at least 1 row.
|
|
const perQueryLimit =
|
|
CORE_REVERSE_QUERIES.length > 0
|
|
? Math.max(1, Math.ceil(limit / CORE_REVERSE_QUERIES.length))
|
|
: limit;
|
|
|
|
const queryPromises = CORE_REVERSE_QUERIES.map(
|
|
async ([viewSpace, viewExternalId, viewVersion, propertyName, isList]) => {
|
|
try {
|
|
const propertyPath = [
|
|
viewSpace,
|
|
`${viewExternalId}/${viewVersion}`,
|
|
propertyName,
|
|
];
|
|
|
|
const filter = isList
|
|
? {
|
|
containsAny: {
|
|
property: propertyPath,
|
|
values: [nodeRef],
|
|
},
|
|
}
|
|
: {
|
|
equals: {
|
|
property: propertyPath,
|
|
value: nodeRef,
|
|
},
|
|
};
|
|
|
|
const items = await queryWithCursorPagination<{
|
|
space: string;
|
|
externalId: string;
|
|
}>(
|
|
client,
|
|
"nodes",
|
|
{
|
|
with: {
|
|
nodes: {
|
|
nodes: { filter },
|
|
},
|
|
},
|
|
select: { nodes: {} },
|
|
includeTyping: false,
|
|
},
|
|
Math.min(50, perQueryLimit),
|
|
perQueryLimit
|
|
);
|
|
return items.map((item) => ({
|
|
space: item.space,
|
|
externalId: item.externalId,
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
);
|
|
|
|
const results = await Promise.all(queryPromises);
|
|
for (const refs of results) {
|
|
reverseRelationRefs.push(...refs);
|
|
}
|
|
} catch {
|
|
// Silently ignore reverse relation query failures
|
|
}
|
|
|
|
const syntheticEdges: CDFEdge[] = [];
|
|
|
|
for (const ref of reverseRelationRefs) {
|
|
const refKey = `${ref.space}:${ref.externalId}`;
|
|
if (
|
|
refKey !== sourceNodeKey &&
|
|
!existingNodeIds.has(refKey) &&
|
|
!connectedNodeRefs.has(refKey)
|
|
) {
|
|
connectedNodeRefs.set(refKey, ref);
|
|
|
|
syntheticEdges.push({
|
|
space: ref.space,
|
|
externalId: `synthetic:${ref.externalId}->assets->${nodeExternalId}`,
|
|
instanceType: "edge" as const,
|
|
version: 1,
|
|
createdTime: Date.now(),
|
|
lastUpdatedTime: Date.now(),
|
|
type: { space: "cdf_cdm", externalId: "references-asset" },
|
|
startNode: { space: ref.space, externalId: ref.externalId },
|
|
endNode: { space: nodeSpace, externalId: nodeExternalId },
|
|
properties: {},
|
|
});
|
|
}
|
|
}
|
|
|
|
newEdges.push(...syntheticEdges);
|
|
|
|
const connectedNodeIds = newEdges.flatMap((edge) => [
|
|
`${edge.startNode.space}:${edge.startNode.externalId}`,
|
|
`${edge.endNode.space}:${edge.endNode.externalId}`,
|
|
]);
|
|
|
|
const nodesToFetch = Array.from(connectedNodeRefs.values()).slice(0, limit);
|
|
|
|
let newNodes: CDFNode[] = [];
|
|
|
|
if (nodesToFetch.length > 0) {
|
|
const inspectResponse = await client.instances.inspect({
|
|
items: nodesToFetch.map((ref) => ({
|
|
instanceType: "node" as const,
|
|
space: ref.space,
|
|
externalId: ref.externalId,
|
|
})),
|
|
inspectionOperations: { involvedViews: {} },
|
|
});
|
|
|
|
const nodeTypeMap = new Map<string, { space: string; externalId: string }>();
|
|
const allViews = new Map<string, { space: string; externalId: string; version: string }>();
|
|
|
|
for (const inspectItem of inspectResponse.items) {
|
|
const item = inspectItem as {
|
|
space: string;
|
|
externalId: string;
|
|
inspectionResults?: {
|
|
involvedViews?: Array<{
|
|
space: string;
|
|
externalId: string;
|
|
version: string;
|
|
}>;
|
|
};
|
|
};
|
|
const key = `${item.space}:${item.externalId}`;
|
|
const views = item.inspectionResults?.involvedViews || [];
|
|
|
|
const skipViewsForProperties = viewPriorityConfig?.skipViewsForProperties
|
|
? new Set(viewPriorityConfig.skipViewsForProperties)
|
|
: DEFAULT_SKIP_VIEWS_FOR_PROPERTIES;
|
|
const skipViewsForType = viewPriorityConfig?.skipViewsForType
|
|
? new Set(viewPriorityConfig.skipViewsForType)
|
|
: DEFAULT_SKIP_VIEWS_FOR_TYPE;
|
|
|
|
for (const view of views) {
|
|
if (!skipViewsForProperties.has(view.externalId)) {
|
|
const viewKey = `${view.space}:${view.externalId}`;
|
|
if (!allViews.has(viewKey)) {
|
|
allViews.set(viewKey, view);
|
|
}
|
|
}
|
|
}
|
|
|
|
const domainView = views.find(
|
|
(v) => !v.space.startsWith("cdf_cdm") && !skipViewsForType.has(v.externalId)
|
|
);
|
|
const cdmView = views.find(
|
|
(v) => v.space.startsWith("cdf_cdm") && !skipViewsForType.has(v.externalId)
|
|
);
|
|
const bestView = domainView || cdmView;
|
|
|
|
if (bestView) {
|
|
nodeTypeMap.set(key, {
|
|
space: bestView.space,
|
|
externalId: bestView.externalId,
|
|
});
|
|
}
|
|
}
|
|
|
|
const sources = [
|
|
{
|
|
source: {
|
|
type: "view" as const,
|
|
space: "cdf_cdm",
|
|
externalId: "CogniteDescribable",
|
|
version: "v1",
|
|
},
|
|
},
|
|
...Array.from(allViews.values()).map((v) => ({
|
|
source: {
|
|
type: "view" as const,
|
|
space: v.space,
|
|
externalId: v.externalId,
|
|
version: v.version,
|
|
},
|
|
})),
|
|
];
|
|
|
|
const retrieveItems = nodesToFetch.map((ref) => ({
|
|
instanceType: "node" as const,
|
|
space: ref.space,
|
|
externalId: ref.externalId,
|
|
}));
|
|
|
|
let nodeResponse: Awaited<ReturnType<CogniteClient["instances"]["retrieve"]>>;
|
|
try {
|
|
nodeResponse = await client.instances.retrieve({
|
|
items: retrieveItems,
|
|
includeTyping: true,
|
|
});
|
|
} catch {
|
|
nodeResponse = { items: [] };
|
|
}
|
|
|
|
const propertiesMap = new Map<string, Record<string, Record<string, unknown>>>();
|
|
if (sources.length > 0 && nodeResponse.items.length > 0) {
|
|
try {
|
|
const propsResponse = await client.instances.retrieve({
|
|
items: retrieveItems,
|
|
sources,
|
|
includeTyping: true,
|
|
});
|
|
|
|
for (const item of propsResponse.items) {
|
|
const key = `${item.space}:${item.externalId}`;
|
|
if (item.properties) {
|
|
propertiesMap.set(key, item.properties as Record<string, Record<string, unknown>>);
|
|
}
|
|
}
|
|
} catch {
|
|
// Continue without additional properties
|
|
}
|
|
}
|
|
|
|
const nodesWithoutProps = retrieveItems.filter((item) => {
|
|
const key = `${item.space}:${item.externalId}`;
|
|
return !propertiesMap.has(key);
|
|
});
|
|
|
|
if (nodesWithoutProps.length > 0) {
|
|
const individualFetches = nodesWithoutProps.map(async (nodeRef) => {
|
|
const key = `${nodeRef.space}:${nodeRef.externalId}`;
|
|
try {
|
|
const inspectResult = await client.instances.inspect({
|
|
items: [nodeRef],
|
|
inspectionOperations: { involvedViews: { allVersions: false } },
|
|
});
|
|
|
|
const views =
|
|
(
|
|
inspectResult.items?.[0] as {
|
|
inspectionResults?: {
|
|
involvedViews?: Array<{
|
|
space: string;
|
|
externalId: string;
|
|
version: string;
|
|
}>;
|
|
};
|
|
}
|
|
)?.inspectionResults?.involvedViews || [];
|
|
|
|
if (views.length > 0) {
|
|
const nodeSources = views.slice(0, 10).map((v) => ({
|
|
source: {
|
|
type: "view" as const,
|
|
space: v.space,
|
|
externalId: v.externalId,
|
|
version: v.version,
|
|
},
|
|
}));
|
|
|
|
const propsResp = await client.instances.retrieve({
|
|
items: [nodeRef],
|
|
sources: nodeSources,
|
|
includeTyping: true,
|
|
});
|
|
|
|
if (propsResp.items.length > 0 && propsResp.items[0].properties) {
|
|
propertiesMap.set(
|
|
key,
|
|
propsResp.items[0].properties as Record<string, Record<string, unknown>>
|
|
);
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently ignore individual fetch failures
|
|
}
|
|
});
|
|
|
|
await Promise.all(individualFetches);
|
|
}
|
|
|
|
newNodes = nodeResponse.items
|
|
.filter(
|
|
(item): item is typeof item & { instanceType: "node" } =>
|
|
(item as { instanceType?: string }).instanceType === "node" || !("instanceType" in item)
|
|
)
|
|
.map((item) => {
|
|
const key = `${item.space}:${item.externalId}`;
|
|
const inspectType = nodeTypeMap.get(key);
|
|
const mergedProps =
|
|
propertiesMap.get(key) || (item.properties as Record<string, Record<string, unknown>>);
|
|
const derivedType =
|
|
deriveNodeTypeFromProperties(mergedProps, viewPriorityConfig) || inspectType;
|
|
|
|
return {
|
|
space: item.space,
|
|
externalId: item.externalId,
|
|
instanceType: "node" as const,
|
|
version: item.version,
|
|
createdTime: item.createdTime,
|
|
lastUpdatedTime: item.lastUpdatedTime,
|
|
type: item.type || derivedType,
|
|
properties: mergedProps,
|
|
};
|
|
});
|
|
}
|
|
|
|
return { newNodes, newEdges, connectedNodeIds };
|
|
}
|
|
|
|
// =============================================================================
|
|
// Graph utility functions (pure, no API calls)
|
|
// =============================================================================
|
|
|
|
export function getGraphStats(graphData: GraphData) {
|
|
const nodeTypes = new Map<string, number>();
|
|
const connectionTypes = new Map<string, number>();
|
|
|
|
graphData.nodes.forEach((node) => {
|
|
const type = node.data.type?.externalId || "Unknown";
|
|
nodeTypes.set(type, (nodeTypes.get(type) || 0) + 1);
|
|
});
|
|
|
|
graphData.connections.forEach((connection) => {
|
|
const type = connection.data.type.externalId;
|
|
connectionTypes.set(type, (connectionTypes.get(type) || 0) + 1);
|
|
});
|
|
|
|
return {
|
|
totalNodes: graphData.nodes.length,
|
|
totalConnections: graphData.connections.length,
|
|
nodeTypes: Object.fromEntries(nodeTypes),
|
|
connectionTypes: Object.fromEntries(connectionTypes),
|
|
};
|
|
}
|
|
|
|
export function getConnectedNodes(graphData: GraphData, nodeId: string) {
|
|
const connectedNodeIds = new Set<string>();
|
|
|
|
graphData.connections.forEach((connection) => {
|
|
if (connection.source === nodeId) {
|
|
connectedNodeIds.add(connection.target);
|
|
}
|
|
if (connection.target === nodeId) {
|
|
connectedNodeIds.add(connection.source);
|
|
}
|
|
});
|
|
|
|
return graphData.nodes.filter((node) => connectedNodeIds.has(node.id));
|
|
}
|
|
|
|
export function getNodeEdges(graphData: GraphData, nodeId: string) {
|
|
return graphData.connections.filter(
|
|
(connection) => connection.source === nodeId || connection.target === nodeId
|
|
);
|
|
}
|