@grafema/api 0.2.5-beta
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/LICENSE +190 -0
- package/README.md +219 -0
- package/dist/context.d.ts +22 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +18 -0
- package/dist/context.js.map +1 -0
- package/dist/dataloaders/index.d.ts +18 -0
- package/dist/dataloaders/index.d.ts.map +1 -0
- package/dist/dataloaders/index.js +17 -0
- package/dist/dataloaders/index.js.map +1 -0
- package/dist/dataloaders/nodeLoader.d.ts +19 -0
- package/dist/dataloaders/nodeLoader.d.ts.map +1 -0
- package/dist/dataloaders/nodeLoader.js +31 -0
- package/dist/dataloaders/nodeLoader.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/pagination.d.ts +50 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +71 -0
- package/dist/pagination.js.map +1 -0
- package/dist/resolvers/edge.d.ts +22 -0
- package/dist/resolvers/edge.d.ts.map +1 -0
- package/dist/resolvers/edge.js +36 -0
- package/dist/resolvers/edge.js.map +1 -0
- package/dist/resolvers/index.d.ts +159 -0
- package/dist/resolvers/index.d.ts.map +1 -0
- package/dist/resolvers/index.js +21 -0
- package/dist/resolvers/index.js.map +1 -0
- package/dist/resolvers/mutation.d.ts +69 -0
- package/dist/resolvers/mutation.d.ts.map +1 -0
- package/dist/resolvers/mutation.js +82 -0
- package/dist/resolvers/mutation.js.map +1 -0
- package/dist/resolvers/node.d.ts +50 -0
- package/dist/resolvers/node.d.ts.map +1 -0
- package/dist/resolvers/node.js +69 -0
- package/dist/resolvers/node.js.map +1 -0
- package/dist/resolvers/query.d.ts +169 -0
- package/dist/resolvers/query.d.ts.map +1 -0
- package/dist/resolvers/query.js +188 -0
- package/dist/resolvers/query.js.map +1 -0
- package/dist/schema/enums.graphql +27 -0
- package/dist/schema/mutations.graphql +53 -0
- package/dist/schema/queries.graphql +213 -0
- package/dist/schema/scalars.graphql +2 -0
- package/dist/schema/subscriptions.graphql +84 -0
- package/dist/schema/types.graphql +440 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +109 -0
- package/dist/server.js.map +1 -0
- package/package.json +51 -0
- package/src/context.ts +33 -0
- package/src/dataloaders/index.ts +24 -0
- package/src/dataloaders/nodeLoader.ts +41 -0
- package/src/index.ts +11 -0
- package/src/pagination.ts +109 -0
- package/src/resolvers/edge.ts +39 -0
- package/src/resolvers/index.ts +24 -0
- package/src/resolvers/mutation.ts +108 -0
- package/src/resolvers/node.ts +118 -0
- package/src/resolvers/query.ts +307 -0
- package/src/schema/enums.graphql +27 -0
- package/src/schema/mutations.graphql +53 -0
- package/src/schema/queries.graphql +213 -0
- package/src/schema/scalars.graphql +2 -0
- package/src/schema/subscriptions.graphql +84 -0
- package/src/schema/types.graphql +440 -0
- package/src/server.ts +140 -0
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grafema/api",
|
|
3
|
+
"version": "0.2.5-beta",
|
|
4
|
+
"description": "GraphQL API server for Grafema code analysis toolkit",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"grafema",
|
|
20
|
+
"graphql",
|
|
21
|
+
"api",
|
|
22
|
+
"code-analysis"
|
|
23
|
+
],
|
|
24
|
+
"license": "Apache-2.0",
|
|
25
|
+
"author": "Vadim Reshetnikov",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/Disentinel/grafema.git",
|
|
29
|
+
"directory": "packages/api"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"dataloader": "^2.2.3",
|
|
33
|
+
"graphql": "^16.10.0",
|
|
34
|
+
"graphql-scalars": "^1.24.0",
|
|
35
|
+
"graphql-yoga": "^5.10.11",
|
|
36
|
+
"@grafema/core": "0.2.5-beta",
|
|
37
|
+
"@grafema/types": "0.2.5-beta"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^25.0.8",
|
|
41
|
+
"tsx": "^4.19.2",
|
|
42
|
+
"typescript": "^5.9.3"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc && pnpm run copy-schema",
|
|
46
|
+
"copy-schema": "mkdir -p dist/schema && cp src/schema/*.graphql dist/schema/",
|
|
47
|
+
"clean": "rm -rf dist",
|
|
48
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
49
|
+
"test:watch": "node --import tsx --test --watch test/*.test.ts"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL Context
|
|
3
|
+
*
|
|
4
|
+
* Request-scoped context containing backend connection and DataLoaders.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IncomingMessage } from 'node:http';
|
|
8
|
+
import type { RFDBServerBackend } from '@grafema/core';
|
|
9
|
+
import { createDataLoaders, type DataLoaders } from './dataloaders/index.js';
|
|
10
|
+
|
|
11
|
+
export interface GraphQLContext {
|
|
12
|
+
/** Graph backend connection */
|
|
13
|
+
backend: RFDBServerBackend;
|
|
14
|
+
/** DataLoaders for batching (per-request) */
|
|
15
|
+
loaders: DataLoaders;
|
|
16
|
+
/** Request start time for timeout tracking */
|
|
17
|
+
startTime: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create context for a GraphQL request.
|
|
22
|
+
* Creates fresh DataLoaders to ensure no cross-request caching.
|
|
23
|
+
*/
|
|
24
|
+
export function createContext(
|
|
25
|
+
backend: RFDBServerBackend,
|
|
26
|
+
_req: IncomingMessage
|
|
27
|
+
): GraphQLContext {
|
|
28
|
+
return {
|
|
29
|
+
backend,
|
|
30
|
+
loaders: createDataLoaders(backend),
|
|
31
|
+
startTime: Date.now(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataLoader Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates all DataLoaders for a request context.
|
|
5
|
+
* DataLoaders batch and cache database requests within a single GraphQL request.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RFDBServerBackend } from '@grafema/core';
|
|
9
|
+
import { createNodeLoader } from './nodeLoader.js';
|
|
10
|
+
|
|
11
|
+
export interface DataLoaders {
|
|
12
|
+
/** Batch node lookups by ID */
|
|
13
|
+
node: ReturnType<typeof createNodeLoader>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create all DataLoaders for a request.
|
|
18
|
+
* DataLoaders are per-request to prevent cross-request caching issues.
|
|
19
|
+
*/
|
|
20
|
+
export function createDataLoaders(backend: RFDBServerBackend): DataLoaders {
|
|
21
|
+
return {
|
|
22
|
+
node: createNodeLoader(backend),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node DataLoader
|
|
3
|
+
*
|
|
4
|
+
* Batches multiple getNode() calls into efficient parallel lookups.
|
|
5
|
+
* Critical for preventing N+1 queries in GraphQL.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import DataLoader from 'dataloader';
|
|
9
|
+
import type { BaseNodeRecord } from '@grafema/types';
|
|
10
|
+
import type { RFDBServerBackend } from '@grafema/core';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a DataLoader for batching node lookups.
|
|
14
|
+
*
|
|
15
|
+
* The loader batches all node ID requests made within a single tick
|
|
16
|
+
* and resolves them with parallel backend calls.
|
|
17
|
+
*
|
|
18
|
+
* Complexity: O(n) where n = unique node IDs requested
|
|
19
|
+
*/
|
|
20
|
+
export function createNodeLoader(
|
|
21
|
+
backend: RFDBServerBackend
|
|
22
|
+
): DataLoader<string, BaseNodeRecord | null> {
|
|
23
|
+
return new DataLoader<string, BaseNodeRecord | null>(
|
|
24
|
+
async (ids: readonly string[]) => {
|
|
25
|
+
// Parallelize individual lookups
|
|
26
|
+
// Future optimization: add batch getNodes() to RFDB protocol
|
|
27
|
+
const results = await Promise.all(
|
|
28
|
+
ids.map((id) => backend.getNode(id).catch(() => null))
|
|
29
|
+
);
|
|
30
|
+
return results;
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
// Cache results within this request
|
|
34
|
+
cache: true,
|
|
35
|
+
// Use identity function for cache key
|
|
36
|
+
cacheKeyFn: (id) => id,
|
|
37
|
+
// Max batch size to prevent overwhelming backend
|
|
38
|
+
maxBatchSize: 100,
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @grafema/api - GraphQL API for Grafema code graph
|
|
3
|
+
*
|
|
4
|
+
* Provides a GraphQL endpoint for querying the code graph.
|
|
5
|
+
* Supports cursor-based pagination, subscriptions for streaming,
|
|
6
|
+
* and Datalog query passthrough.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { createGraphQLServer, startServer } from './server.js';
|
|
10
|
+
export type { GraphQLServerOptions } from './server.js';
|
|
11
|
+
export type { GraphQLContext } from './context.js';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor-based Pagination Utilities
|
|
3
|
+
*
|
|
4
|
+
* Implements Relay Connection spec for cursor-based pagination.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Encode a cursor from an ID.
|
|
9
|
+
* Format: base64("cursor:${id}")
|
|
10
|
+
*/
|
|
11
|
+
export function encodeCursor(id: string): string {
|
|
12
|
+
return Buffer.from(`cursor:${id}`).toString('base64');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Decode a cursor to get the ID.
|
|
17
|
+
* Returns null if cursor is invalid.
|
|
18
|
+
*/
|
|
19
|
+
export function decodeCursor(cursor: string): string | null {
|
|
20
|
+
try {
|
|
21
|
+
const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
|
|
22
|
+
if (decoded.startsWith('cursor:')) {
|
|
23
|
+
return decoded.slice(7);
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* PageInfo structure per Relay spec.
|
|
33
|
+
*/
|
|
34
|
+
export interface PageInfo {
|
|
35
|
+
hasNextPage: boolean;
|
|
36
|
+
hasPreviousPage: boolean;
|
|
37
|
+
startCursor: string | null;
|
|
38
|
+
endCursor: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Edge structure for connections.
|
|
43
|
+
*/
|
|
44
|
+
export interface Edge<T> {
|
|
45
|
+
node: T;
|
|
46
|
+
cursor: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Connection structure per Relay spec.
|
|
51
|
+
*/
|
|
52
|
+
export interface Connection<T> {
|
|
53
|
+
edges: Edge<T>[];
|
|
54
|
+
pageInfo: PageInfo;
|
|
55
|
+
totalCount: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Apply cursor-based pagination to an array.
|
|
60
|
+
*
|
|
61
|
+
* @param items - All items (already filtered)
|
|
62
|
+
* @param first - Number of items to return (default: 50, max: 250)
|
|
63
|
+
* @param after - Cursor to start after
|
|
64
|
+
* @param getId - Function to get ID from item for cursor encoding
|
|
65
|
+
* @returns Connection structure
|
|
66
|
+
*/
|
|
67
|
+
export function paginateArray<T>(
|
|
68
|
+
items: T[],
|
|
69
|
+
first: number | null | undefined,
|
|
70
|
+
after: string | null | undefined,
|
|
71
|
+
getId: (item: T) => string
|
|
72
|
+
): Connection<T> {
|
|
73
|
+
const limit = Math.min(first ?? 50, 250);
|
|
74
|
+
|
|
75
|
+
// Find start index based on cursor
|
|
76
|
+
let startIndex = 0;
|
|
77
|
+
if (after) {
|
|
78
|
+
const afterId = decodeCursor(after);
|
|
79
|
+
if (afterId) {
|
|
80
|
+
const afterIndex = items.findIndex((item) => getId(item) === afterId);
|
|
81
|
+
if (afterIndex !== -1) {
|
|
82
|
+
startIndex = afterIndex + 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Slice items
|
|
88
|
+
const slicedItems = items.slice(startIndex, startIndex + limit);
|
|
89
|
+
|
|
90
|
+
// Build edges
|
|
91
|
+
const edges: Edge<T>[] = slicedItems.map((item) => ({
|
|
92
|
+
node: item,
|
|
93
|
+
cursor: encodeCursor(getId(item)),
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
// Build pageInfo
|
|
97
|
+
const pageInfo: PageInfo = {
|
|
98
|
+
hasNextPage: startIndex + limit < items.length,
|
|
99
|
+
hasPreviousPage: startIndex > 0,
|
|
100
|
+
startCursor: edges.length > 0 ? edges[0].cursor : null,
|
|
101
|
+
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
edges,
|
|
106
|
+
pageInfo,
|
|
107
|
+
totalCount: items.length,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge Type Resolvers
|
|
3
|
+
*
|
|
4
|
+
* Resolves fields on Edge type that require lookups.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { EdgeRecord } from '@grafema/types';
|
|
8
|
+
import type { GraphQLContext } from '../context.js';
|
|
9
|
+
|
|
10
|
+
export const edgeResolvers = {
|
|
11
|
+
/**
|
|
12
|
+
* Resolve source node.
|
|
13
|
+
*/
|
|
14
|
+
async src(parent: EdgeRecord, _args: unknown, context: GraphQLContext) {
|
|
15
|
+
return context.loaders.node.load(parent.src);
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve destination node.
|
|
20
|
+
*/
|
|
21
|
+
async dst(parent: EdgeRecord, _args: unknown, context: GraphQLContext) {
|
|
22
|
+
return context.loaders.node.load(parent.dst);
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve metadata field.
|
|
27
|
+
*/
|
|
28
|
+
metadata(parent: EdgeRecord) {
|
|
29
|
+
if (!parent.metadata) return null;
|
|
30
|
+
if (typeof parent.metadata === 'string') {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(parent.metadata);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parent.metadata;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL Resolver Map
|
|
3
|
+
*
|
|
4
|
+
* Combines all resolvers and adds custom scalar handlers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { JSONResolver } from 'graphql-scalars';
|
|
8
|
+
import { nodeResolvers } from './node.js';
|
|
9
|
+
import { edgeResolvers } from './edge.js';
|
|
10
|
+
import { queryResolvers } from './query.js';
|
|
11
|
+
import { mutationResolvers } from './mutation.js';
|
|
12
|
+
|
|
13
|
+
export const resolvers = {
|
|
14
|
+
// Custom scalars
|
|
15
|
+
JSON: JSONResolver,
|
|
16
|
+
|
|
17
|
+
// Type resolvers
|
|
18
|
+
Node: nodeResolvers,
|
|
19
|
+
Edge: edgeResolvers,
|
|
20
|
+
|
|
21
|
+
// Root resolvers
|
|
22
|
+
Query: queryResolvers,
|
|
23
|
+
Mutation: mutationResolvers,
|
|
24
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation Resolvers
|
|
3
|
+
*
|
|
4
|
+
* Implements all Mutation type fields.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { GraphQLContext } from '../context.js';
|
|
8
|
+
|
|
9
|
+
export const mutationResolvers = {
|
|
10
|
+
/**
|
|
11
|
+
* Run project analysis.
|
|
12
|
+
* Placeholder - would integrate with Orchestrator.
|
|
13
|
+
*/
|
|
14
|
+
async analyzeProject(
|
|
15
|
+
_: unknown,
|
|
16
|
+
_args: { service?: string | null; force?: boolean | null },
|
|
17
|
+
_context: GraphQLContext
|
|
18
|
+
) {
|
|
19
|
+
// Placeholder - would integrate with Orchestrator
|
|
20
|
+
return {
|
|
21
|
+
success: false,
|
|
22
|
+
status: {
|
|
23
|
+
running: false,
|
|
24
|
+
phase: null,
|
|
25
|
+
message: 'Analysis via GraphQL not yet implemented',
|
|
26
|
+
servicesDiscovered: 0,
|
|
27
|
+
servicesAnalyzed: 0,
|
|
28
|
+
error: 'Not implemented',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a new guarantee.
|
|
35
|
+
* Placeholder - would integrate with GuaranteeManager.
|
|
36
|
+
*/
|
|
37
|
+
async createGuarantee(
|
|
38
|
+
_: unknown,
|
|
39
|
+
_args: { input: Record<string, unknown> },
|
|
40
|
+
_context: GraphQLContext
|
|
41
|
+
) {
|
|
42
|
+
throw new Error('createGuarantee not yet implemented');
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Delete a guarantee.
|
|
47
|
+
* Placeholder.
|
|
48
|
+
*/
|
|
49
|
+
async deleteGuarantee(
|
|
50
|
+
_: unknown,
|
|
51
|
+
_args: { name: string },
|
|
52
|
+
_context: GraphQLContext
|
|
53
|
+
) {
|
|
54
|
+
throw new Error('deleteGuarantee not yet implemented');
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check guarantees.
|
|
59
|
+
* Placeholder.
|
|
60
|
+
*/
|
|
61
|
+
async checkGuarantees(
|
|
62
|
+
_: unknown,
|
|
63
|
+
_args: { names?: string[] | null },
|
|
64
|
+
_context: GraphQLContext
|
|
65
|
+
) {
|
|
66
|
+
return {
|
|
67
|
+
total: 0,
|
|
68
|
+
passed: 0,
|
|
69
|
+
failed: 0,
|
|
70
|
+
results: [],
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check ad-hoc invariant.
|
|
76
|
+
*/
|
|
77
|
+
async checkInvariant(
|
|
78
|
+
_: unknown,
|
|
79
|
+
args: { rule: string; description?: string | null },
|
|
80
|
+
context: GraphQLContext
|
|
81
|
+
) {
|
|
82
|
+
try {
|
|
83
|
+
const results = await context.backend.checkGuarantee(args.rule);
|
|
84
|
+
const passed = results.length === 0;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
guaranteeId: 'adhoc',
|
|
88
|
+
passed,
|
|
89
|
+
violationCount: results.length,
|
|
90
|
+
violations: results.slice(0, 10).map((_r) => {
|
|
91
|
+
// Would need async resolution to populate node data
|
|
92
|
+
return {
|
|
93
|
+
node: null,
|
|
94
|
+
file: null,
|
|
95
|
+
line: null,
|
|
96
|
+
};
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
} catch {
|
|
100
|
+
return {
|
|
101
|
+
guaranteeId: 'adhoc',
|
|
102
|
+
passed: false,
|
|
103
|
+
violationCount: 0,
|
|
104
|
+
violations: [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node Type Resolvers
|
|
3
|
+
*
|
|
4
|
+
* Resolves relationship fields on Node type.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { BaseNodeRecord, EdgeRecord } from '@grafema/types';
|
|
8
|
+
import type { GraphQLContext } from '../context.js';
|
|
9
|
+
import { paginateArray } from '../pagination.js';
|
|
10
|
+
|
|
11
|
+
export const nodeResolvers = {
|
|
12
|
+
/**
|
|
13
|
+
* Resolve outgoing edges with cursor-based pagination.
|
|
14
|
+
*
|
|
15
|
+
* Complexity: O(k) where k = number of outgoing edges from this node
|
|
16
|
+
*/
|
|
17
|
+
async outgoingEdges(
|
|
18
|
+
parent: BaseNodeRecord,
|
|
19
|
+
args: { types?: string[] | null; first?: number | null; after?: string | null },
|
|
20
|
+
context: GraphQLContext
|
|
21
|
+
) {
|
|
22
|
+
const edges = await context.backend.getOutgoingEdges(
|
|
23
|
+
parent.id,
|
|
24
|
+
args.types || null
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
return paginateArray(
|
|
28
|
+
edges,
|
|
29
|
+
args.first,
|
|
30
|
+
args.after,
|
|
31
|
+
(e: EdgeRecord) => `${e.src}:${e.dst}:${e.type}`
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve incoming edges with cursor-based pagination.
|
|
37
|
+
*
|
|
38
|
+
* Complexity: O(k) where k = number of incoming edges to this node
|
|
39
|
+
*/
|
|
40
|
+
async incomingEdges(
|
|
41
|
+
parent: BaseNodeRecord,
|
|
42
|
+
args: { types?: string[] | null; first?: number | null; after?: string | null },
|
|
43
|
+
context: GraphQLContext
|
|
44
|
+
) {
|
|
45
|
+
const edges = await context.backend.getIncomingEdges(
|
|
46
|
+
parent.id,
|
|
47
|
+
args.types || null
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return paginateArray(
|
|
51
|
+
edges,
|
|
52
|
+
args.first,
|
|
53
|
+
args.after,
|
|
54
|
+
(e: EdgeRecord) => `${e.src}:${e.dst}:${e.type}`
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve child nodes (via CONTAINS edges) with cursor-based pagination.
|
|
60
|
+
*
|
|
61
|
+
* Complexity: O(c) where c = number of children
|
|
62
|
+
*/
|
|
63
|
+
async children(
|
|
64
|
+
parent: BaseNodeRecord,
|
|
65
|
+
args: { first?: number | null; after?: string | null },
|
|
66
|
+
context: GraphQLContext
|
|
67
|
+
) {
|
|
68
|
+
const edges = await context.backend.getOutgoingEdges(parent.id, ['CONTAINS']);
|
|
69
|
+
|
|
70
|
+
// Use DataLoader to batch child node lookups
|
|
71
|
+
const childIds = edges.map((e) => e.dst);
|
|
72
|
+
const children = await context.loaders.node.loadMany(childIds);
|
|
73
|
+
|
|
74
|
+
// Filter out errors and nulls
|
|
75
|
+
const validChildren = children.filter(
|
|
76
|
+
(c): c is BaseNodeRecord => c != null && !(c instanceof Error)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return paginateArray(
|
|
80
|
+
validChildren,
|
|
81
|
+
args.first,
|
|
82
|
+
args.after,
|
|
83
|
+
(n: BaseNodeRecord) => n.id
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve parent node (via incoming CONTAINS edge).
|
|
89
|
+
*
|
|
90
|
+
* Complexity: O(1) - single lookup
|
|
91
|
+
*/
|
|
92
|
+
async parent(
|
|
93
|
+
parent: BaseNodeRecord,
|
|
94
|
+
_args: unknown,
|
|
95
|
+
context: GraphQLContext
|
|
96
|
+
) {
|
|
97
|
+
const edges = await context.backend.getIncomingEdges(parent.id, ['CONTAINS']);
|
|
98
|
+
if (edges.length === 0) return null;
|
|
99
|
+
|
|
100
|
+
return context.loaders.node.load(edges[0].src);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve metadata field.
|
|
105
|
+
* Parses JSON string if needed.
|
|
106
|
+
*/
|
|
107
|
+
metadata(parent: BaseNodeRecord) {
|
|
108
|
+
if (!parent.metadata) return null;
|
|
109
|
+
if (typeof parent.metadata === 'string') {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(parent.metadata);
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return parent.metadata;
|
|
117
|
+
},
|
|
118
|
+
};
|