.agent
src
modules
cards
application
domain
infrastructure
tests
shared
types
webapp
···
1
1
+
# Sigma graph library
2
2
+
3
3
+
## Data Schema / Graph Data Model
4
4
+
5
5
+
### Node Types
6
6
+
7
7
+
The graph consists of four primary node types:
8
8
+
9
9
+
1. **Users** - User DIDs (Decentralized Identifiers)
10
10
+
- Source: `users.id`
11
11
+
- Display: `users.handle` or DID
12
12
+
13
13
+
2. **URLs** - Web resources saved as URL cards
14
14
+
- Source: `cards.url` where `cards.type = 'URL'`
15
15
+
- Metadata: `cards.urlType`, `cards.contentData`
16
16
+
17
17
+
3. **Collections** - Curated groups of cards
18
18
+
- Source: `collections.id`
19
19
+
- Display: `collections.name`
20
20
+
- Metadata: `collections.description`, `collections.accessType`
21
21
+
22
22
+
4. **Note Cards** - User annotations and notes
23
23
+
- Source: `cards.id` where `cards.type = 'NOTE'`
24
24
+
- Metadata: `cards.contentData`
25
25
+
26
26
+
### Edge Types
27
27
+
28
28
+
The graph relationships are derived from the following sources:
29
29
+
30
30
+
#### 1. Follow Relationships
31
31
+
32
32
+
**User → User** (social follows)
33
33
+
34
34
+
```sql
35
35
+
SELECT followerId, targetId
36
36
+
FROM follows
37
37
+
WHERE targetType = 'user'
38
38
+
```
39
39
+
40
40
+
**User → Collection** (collection follows)
41
41
+
42
42
+
```sql
43
43
+
SELECT followerId, targetId
44
44
+
FROM follows
45
45
+
WHERE targetType = 'collection'
46
46
+
```
47
47
+
48
48
+
#### 2. Authorship (User → URL)
49
49
+
50
50
+
Inferred from URL card creation:
51
51
+
52
52
+
```sql
53
53
+
SELECT authorId as userId, url
54
54
+
FROM cards
55
55
+
WHERE type = 'URL' AND url IS NOT NULL
56
56
+
```
57
57
+
58
58
+
#### 3. Note Connections (Note → URL)
59
59
+
60
60
+
Inferred from note cards referencing parent URL cards:
61
61
+
62
62
+
```sql
63
63
+
SELECT n.id as noteCardId, p.url
64
64
+
FROM cards n
65
65
+
JOIN cards p ON n.parentCardId = p.id
66
66
+
WHERE n.type = 'NOTE' AND p.type = 'URL'
67
67
+
```
68
68
+
69
69
+
#### 4. Collection Memberships (Collection ↔ URL)
70
70
+
71
71
+
Inferred from collection-card relationships:
72
72
+
73
73
+
```sql
74
74
+
SELECT cc.collectionId, c.url
75
75
+
FROM collection_cards cc
76
76
+
JOIN cards c ON cc.cardId = c.id
77
77
+
WHERE c.type = 'URL' AND c.url IS NOT NULL
78
78
+
```
79
79
+
80
80
+
Alternative (include metadata):
81
81
+
82
82
+
```sql
83
83
+
SELECT
84
84
+
cc.collectionId,
85
85
+
c.url,
86
86
+
cc.addedBy,
87
87
+
cc.addedAt
88
88
+
FROM collection_cards cc
89
89
+
JOIN cards c ON cc.cardId = c.id
90
90
+
WHERE c.url IS NOT NULL
91
91
+
```
92
92
+
93
93
+
### Additional Relationship Data
94
94
+
95
95
+
The `connections` table provides explicit curator-defined relationships between nodes:
96
96
+
97
97
+
```sql
98
98
+
SELECT
99
99
+
curatorId,
100
100
+
sourceType, -- 'URL' or 'CARD'
101
101
+
sourceValue, -- URL string or Card UUID
102
102
+
targetType, -- 'URL' or 'CARD'
103
103
+
targetValue, -- URL string or Card UUID
104
104
+
connectionType, -- SUPPORTS, OPPOSES, etc.
105
105
+
note
106
106
+
FROM connections
107
107
+
```
108
108
+
109
109
+
These can be used to create typed/weighted edges with semantic meaning (e.g., "supports", "opposes").
110
110
+
111
111
+
---
112
112
+
113
113
+
## **Sigma.js + Next.js Setup**
114
114
+
115
115
+
### **1. Install**
116
116
+
117
117
+
```bash
118
118
+
npm install sigma graphology @react-sigma/core @react-sigma/layout-forceatlas2
119
119
+
```
120
120
+
121
121
+
### **2. Dynamic Import Component**
122
122
+
123
123
+
Create `components/Graph.tsx` (no `'use client'` needed—dynamic import handles it):
124
124
+
125
125
+
```typescript
126
126
+
'use client';
127
127
+
import { SigmaContainer, useSigma } from '@react-sigma/core';
128
128
+
import { useEffect, useState } from 'react';
129
129
+
import Graph from 'graphology';
130
130
+
131
131
+
export default function GraphView() {
132
132
+
const [graph, setGraph] = useState<Graph | null>(null);
133
133
+
134
134
+
useEffect(() => {
135
135
+
const g = new Graph();
136
136
+
g.addNode('a', { x: 0, y: 0, size: 10, label: 'Node A' });
137
137
+
g.addNode('b', { x: 1, y: 1, size: 15, label: 'Node B' });
138
138
+
g.addEdge('a', 'b');
139
139
+
setGraph(g);
140
140
+
}, []);
141
141
+
142
142
+
if (!graph) return null;
143
143
+
144
144
+
return (
145
145
+
<SigmaContainer style={{ height: '600px', background: '#1e1e1e' }} graph={graph}>
146
146
+
<GraphEvents />
147
147
+
</SigmaContainer>
148
148
+
);
149
149
+
}
150
150
+
```
151
151
+
152
152
+
### **3. Events & Camera Controls**
153
153
+
154
154
+
```typescript
155
155
+
function GraphEvents() {
156
156
+
const sigma = useSigma();
157
157
+
158
158
+
const zoomToNode = (nodeId: string) => {
159
159
+
const node = sigma.getGraph().getNodeAttributes(nodeId);
160
160
+
sigma.getCamera().animate(
161
161
+
{ x: node.x, y: node.y, ratio: 0.3 },
162
162
+
{ duration: 800 }
163
163
+
);
164
164
+
};
165
165
+
166
166
+
return (
167
167
+
<Controls zoomToNode={zoomToNode} />
168
168
+
);
169
169
+
}
170
170
+
```
171
171
+
172
172
+
### **4. Page Integration**
173
173
+
174
174
+
```typescript
175
175
+
import dynamic from 'next/dynamic';
176
176
+
177
177
+
const Graph = dynamic(() => import('@/components/Graph'), { ssr: false });
178
178
+
179
179
+
export default function Page() {
180
180
+
return <Graph />;
181
181
+
}
182
182
+
```
183
183
+
184
184
+
### **5. Force Layout**
185
185
+
186
186
+
Add physics with `useLayoutForceAtlas2()` hook from `@react-sigma/layout-forceatlas2` [^3]:
187
187
+
188
188
+
```typescript
189
189
+
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2';
190
190
+
191
191
+
function GraphWithPhysics({ graph }: { graph: Graph }) {
192
192
+
const { start, stop } = useLayoutForceAtlas2({
193
193
+
settings: { gravity: 0.5 },
194
194
+
});
195
195
+
196
196
+
useEffect(() => {
197
197
+
start();
198
198
+
return () => stop();
199
199
+
}, []);
200
200
+
return null;
201
201
+
}
202
202
+
```
203
203
+
204
204
+
**Key notes:** Always use `ssr: false` [^2]. Sigma uses WebGL [^1]—canvas rendering, not DOM elements.
205
205
+
206
206
+
[^1]: [Introduction | sigma.js](https://www.sigmajs.org/docs/) (27%)
207
207
+
208
208
+
[^2]: [Frequently Asked Questions | React Sigma](https://sim51.github.io/react-sigma/docs/faq/) (21%)
209
209
+
210
210
+
[^3]: [react-sigma / sigmaJS example using a force layout?](https://stackoverflow.com/questions/78805061/react-sigma-sigmajs-example-using-a-force-layout) (20%)
211
211
+
212
212
+
[^4]: [Introduction | React Sigma - GitHub Pages](https://sim51.github.io/react-sigma/docs/start-introduction/) (18%)
213
213
+
214
214
+
[^5]: [SEO: Dynamic Imports for Components](https://nextjs.org/learn/seo/dynamic-import-components) (6%)
215
215
+
216
216
+
[^6]: [SEO: Dynamic Imports | Next.js](https://nextjs.org/learn/seo/dynamic-imports) (5%)
217
217
+
218
218
+
[^7]: [React Sigma | React Sigma - GitHub Pages](https://sim51.github.io/react-sigma/) (3%)
···
1
1
+
import { Result, ok, err } from '../../../../../shared/core/Result';
2
2
+
import { UseCase } from '../../../../../shared/core/UseCase';
3
3
+
import { IGraphQueryRepository } from '../../../domain/IGraphQueryRepository';
4
4
+
5
5
+
export interface GetGraphDataQuery {
6
6
+
// No parameters needed for global graph
7
7
+
}
8
8
+
9
9
+
export interface GraphNode {
10
10
+
id: string;
11
11
+
type: 'USER' | 'URL' | 'COLLECTION' | 'NOTE';
12
12
+
label: string;
13
13
+
metadata: Record<string, any>;
14
14
+
}
15
15
+
16
16
+
export interface GraphEdge {
17
17
+
id: string;
18
18
+
source: string;
19
19
+
target: string;
20
20
+
type:
21
21
+
| 'USER_FOLLOWS_USER'
22
22
+
| 'USER_FOLLOWS_COLLECTION'
23
23
+
| 'USER_AUTHORED_URL'
24
24
+
| 'NOTE_REFERENCES_URL'
25
25
+
| 'COLLECTION_CONTAINS_URL'
26
26
+
| 'URL_CONNECTS_URL';
27
27
+
metadata?: Record<string, any>;
28
28
+
}
29
29
+
30
30
+
export interface GetGraphDataResult {
31
31
+
nodes: GraphNode[];
32
32
+
edges: GraphEdge[];
33
33
+
}
34
34
+
35
35
+
export class GetGraphDataUseCase
36
36
+
implements UseCase<GetGraphDataQuery, Result<GetGraphDataResult>>
37
37
+
{
38
38
+
constructor(private graphQueryRepo: IGraphQueryRepository) {}
39
39
+
40
40
+
async execute(query: GetGraphDataQuery): Promise<Result<GetGraphDataResult>> {
41
41
+
try {
42
42
+
// Fetch all graph data
43
43
+
const graphData = await this.graphQueryRepo.getGraphData();
44
44
+
45
45
+
return ok({
46
46
+
nodes: graphData.nodes,
47
47
+
edges: graphData.edges,
48
48
+
});
49
49
+
} catch (error) {
50
50
+
return err(
51
51
+
new Error(
52
52
+
`Failed to retrieve graph data: ${error instanceof Error ? error.message : 'Unknown error'}`,
53
53
+
),
54
54
+
);
55
55
+
}
56
56
+
}
57
57
+
}
···
1
1
+
// DTOs for graph visualization
2
2
+
export interface GraphNodeDTO {
3
3
+
id: string; // Unique identifier for the node
4
4
+
type: 'USER' | 'URL' | 'COLLECTION' | 'NOTE';
5
5
+
label: string; // Display name/title
6
6
+
metadata: Record<string, any>; // Type-specific data (handle, url, description, etc.)
7
7
+
}
8
8
+
9
9
+
export interface GraphEdgeDTO {
10
10
+
id: string; // Unique identifier for the edge
11
11
+
source: string; // Source node ID
12
12
+
target: string; // Target node ID
13
13
+
type:
14
14
+
| 'USER_FOLLOWS_USER'
15
15
+
| 'USER_FOLLOWS_COLLECTION'
16
16
+
| 'USER_AUTHORED_URL'
17
17
+
| 'NOTE_REFERENCES_URL'
18
18
+
| 'COLLECTION_CONTAINS_URL'
19
19
+
| 'URL_CONNECTS_URL';
20
20
+
metadata?: Record<string, any>; // Optional edge data (connection type, added date, etc.)
21
21
+
}
22
22
+
23
23
+
export interface GraphDataDTO {
24
24
+
nodes: GraphNodeDTO[];
25
25
+
edges: GraphEdgeDTO[];
26
26
+
}
27
27
+
28
28
+
export interface IGraphQueryRepository {
29
29
+
/**
30
30
+
* Get all nodes and edges for the global graph visualization
31
31
+
* Returns the complete graph structure with all relationships
32
32
+
*/
33
33
+
getGraphData(): Promise<GraphDataDTO>;
34
34
+
}
···
1
1
+
import { Controller } from '../../../../../shared/infrastructure/http/Controller';
2
2
+
import { Response } from 'express';
3
3
+
import { GetGraphDataUseCase } from '../../../application/useCases/queries/GetGraphDataUseCase';
4
4
+
import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware';
5
5
+
6
6
+
export class GetGraphDataController extends Controller {
7
7
+
constructor(private getGraphDataUseCase: GetGraphDataUseCase) {
8
8
+
super();
9
9
+
}
10
10
+
11
11
+
async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> {
12
12
+
try {
13
13
+
const result = await this.getGraphDataUseCase.execute({});
14
14
+
15
15
+
if (result.isErr()) {
16
16
+
return this.fail(res, result.error);
17
17
+
}
18
18
+
19
19
+
return this.ok(res, result.value);
20
20
+
} catch (error: any) {
21
21
+
return this.handleError(res, error);
22
22
+
}
23
23
+
}
24
24
+
}
···
1
1
+
import { Router } from 'express';
2
2
+
import { GetGraphDataController } from '../controllers/GetGraphDataController';
3
3
+
import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware';
4
4
+
5
5
+
export function createGraphRoutes(
6
6
+
authMiddleware: AuthMiddleware,
7
7
+
getGraphDataController: GetGraphDataController,
8
8
+
): Router {
9
9
+
const router = Router();
10
10
+
11
11
+
// Query routes
12
12
+
// GET /api/graph/data - Get all nodes and edges for graph visualization
13
13
+
router.get('/data', authMiddleware.optionalAuth(), (req, res) =>
14
14
+
getGraphDataController.execute(req, res),
15
15
+
);
16
16
+
17
17
+
return router;
18
18
+
}
···
1
1
+
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
2
2
+
import {
3
3
+
IGraphQueryRepository,
4
4
+
GraphDataDTO,
5
5
+
} from '../../domain/IGraphQueryRepository';
6
6
+
import { GraphQueryService } from './query-services/GraphQueryService';
7
7
+
8
8
+
export class DrizzleGraphQueryRepository implements IGraphQueryRepository {
9
9
+
private graphQueryService: GraphQueryService;
10
10
+
11
11
+
constructor(private db: PostgresJsDatabase) {
12
12
+
this.graphQueryService = new GraphQueryService(db);
13
13
+
}
14
14
+
15
15
+
async getGraphData(): Promise<GraphDataDTO> {
16
16
+
return this.graphQueryService.getGraphData();
17
17
+
}
18
18
+
}
···
1
1
+
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
2
2
+
import { eq, and, sql } from 'drizzle-orm';
3
3
+
import {
4
4
+
GraphNodeDTO,
5
5
+
GraphEdgeDTO,
6
6
+
GraphDataDTO,
7
7
+
} from '../../../domain/IGraphQueryRepository';
8
8
+
import { cards } from '../schema/card.sql';
9
9
+
import { collections, collectionCards } from '../schema/collection.sql';
10
10
+
import { connections } from '../schema/connection.sql';
11
11
+
import { follows } from '../../../../user/infrastructure/repositories/schema/follows.sql';
12
12
+
import { users } from '../../../../user/infrastructure/repositories/schema/user.sql';
13
13
+
14
14
+
export class GraphQueryService {
15
15
+
constructor(private db: PostgresJsDatabase) {}
16
16
+
17
17
+
async getGraphData(): Promise<GraphDataDTO> {
18
18
+
// Fetch all data in parallel
19
19
+
const [
20
20
+
userNodes,
21
21
+
urlNodes,
22
22
+
collectionNodes,
23
23
+
noteNodes,
24
24
+
userFollowEdges,
25
25
+
collectionFollowEdges,
26
26
+
authorshipEdges,
27
27
+
noteUrlEdges,
28
28
+
collectionUrlEdges,
29
29
+
urlConnectionEdges,
30
30
+
] = await Promise.all([
31
31
+
this.getUserNodes(),
32
32
+
this.getUrlNodes(),
33
33
+
this.getCollectionNodes(),
34
34
+
this.getNoteNodes(),
35
35
+
this.getUserFollowEdges(),
36
36
+
this.getCollectionFollowEdges(),
37
37
+
this.getAuthorshipEdges(),
38
38
+
this.getNoteUrlEdges(),
39
39
+
this.getCollectionUrlEdges(),
40
40
+
this.getUrlConnectionEdges(),
41
41
+
]);
42
42
+
43
43
+
// Combine all nodes and edges
44
44
+
const nodes = [...userNodes, ...urlNodes, ...collectionNodes, ...noteNodes];
45
45
+
46
46
+
const edges = [
47
47
+
...userFollowEdges,
48
48
+
...collectionFollowEdges,
49
49
+
...authorshipEdges,
50
50
+
...noteUrlEdges,
51
51
+
...collectionUrlEdges,
52
52
+
...urlConnectionEdges,
53
53
+
];
54
54
+
55
55
+
return { nodes, edges };
56
56
+
}
57
57
+
58
58
+
private async getUserNodes(): Promise<GraphNodeDTO[]> {
59
59
+
const results = await this.db
60
60
+
.select({
61
61
+
id: users.id,
62
62
+
handle: users.handle,
63
63
+
})
64
64
+
.from(users);
65
65
+
66
66
+
return results.map((row) => ({
67
67
+
id: `user:${row.id}`,
68
68
+
type: 'USER' as const,
69
69
+
label: row.handle || row.id,
70
70
+
metadata: {
71
71
+
did: row.id,
72
72
+
handle: row.handle,
73
73
+
},
74
74
+
}));
75
75
+
}
76
76
+
77
77
+
private async getUrlNodes(): Promise<GraphNodeDTO[]> {
78
78
+
const results = await this.db
79
79
+
.select({
80
80
+
id: cards.id,
81
81
+
url: cards.url,
82
82
+
contentData: cards.contentData,
83
83
+
urlType: cards.urlType,
84
84
+
})
85
85
+
.from(cards)
86
86
+
.where(and(eq(cards.type, 'URL'), sql`${cards.url} IS NOT NULL`));
87
87
+
88
88
+
return results.map((row) => {
89
89
+
const contentData = row.contentData as any;
90
90
+
const title = contentData?.title || row.url || 'Untitled URL';
91
91
+
92
92
+
return {
93
93
+
id: `url:${row.url}`,
94
94
+
type: 'URL' as const,
95
95
+
label: title,
96
96
+
metadata: {
97
97
+
cardId: row.id,
98
98
+
url: row.url,
99
99
+
urlType: row.urlType,
100
100
+
title,
101
101
+
description: contentData?.description,
102
102
+
imageUrl: contentData?.imageUrl,
103
103
+
},
104
104
+
};
105
105
+
});
106
106
+
}
107
107
+
108
108
+
private async getCollectionNodes(): Promise<GraphNodeDTO[]> {
109
109
+
const results = await this.db
110
110
+
.select({
111
111
+
id: collections.id,
112
112
+
name: collections.name,
113
113
+
description: collections.description,
114
114
+
authorId: collections.authorId,
115
115
+
cardCount: collections.cardCount,
116
116
+
})
117
117
+
.from(collections);
118
118
+
119
119
+
return results.map((row) => ({
120
120
+
id: `collection:${row.id}`,
121
121
+
type: 'COLLECTION' as const,
122
122
+
label: row.name,
123
123
+
metadata: {
124
124
+
collectionId: row.id,
125
125
+
name: row.name,
126
126
+
description: row.description,
127
127
+
authorId: row.authorId,
128
128
+
cardCount: row.cardCount,
129
129
+
},
130
130
+
}));
131
131
+
}
132
132
+
133
133
+
private async getNoteNodes(): Promise<GraphNodeDTO[]> {
134
134
+
const results = await this.db
135
135
+
.select({
136
136
+
id: cards.id,
137
137
+
contentData: cards.contentData,
138
138
+
authorId: cards.authorId,
139
139
+
})
140
140
+
.from(cards)
141
141
+
.where(eq(cards.type, 'NOTE'));
142
142
+
143
143
+
return results.map((row) => {
144
144
+
const contentData = row.contentData as any;
145
145
+
const noteText = contentData?.note || '';
146
146
+
const preview =
147
147
+
noteText.substring(0, 50) + (noteText.length > 50 ? '...' : '');
148
148
+
149
149
+
return {
150
150
+
id: `note:${row.id}`,
151
151
+
type: 'NOTE' as const,
152
152
+
label: preview || 'Note',
153
153
+
metadata: {
154
154
+
cardId: row.id,
155
155
+
note: noteText,
156
156
+
authorId: row.authorId,
157
157
+
},
158
158
+
};
159
159
+
});
160
160
+
}
161
161
+
162
162
+
private async getUserFollowEdges(): Promise<GraphEdgeDTO[]> {
163
163
+
const results = await this.db
164
164
+
.select({
165
165
+
followerId: follows.followerId,
166
166
+
targetId: follows.targetId,
167
167
+
createdAt: follows.createdAt,
168
168
+
})
169
169
+
.from(follows)
170
170
+
.where(eq(follows.targetType, 'user'));
171
171
+
172
172
+
return results.map((row) => ({
173
173
+
id: `follow-user:${row.followerId}:${row.targetId}`,
174
174
+
source: `user:${row.followerId}`,
175
175
+
target: `user:${row.targetId}`,
176
176
+
type: 'USER_FOLLOWS_USER' as const,
177
177
+
metadata: {
178
178
+
createdAt: row.createdAt.toISOString(),
179
179
+
},
180
180
+
}));
181
181
+
}
182
182
+
183
183
+
private async getCollectionFollowEdges(): Promise<GraphEdgeDTO[]> {
184
184
+
const results = await this.db
185
185
+
.select({
186
186
+
followerId: follows.followerId,
187
187
+
targetId: follows.targetId,
188
188
+
createdAt: follows.createdAt,
189
189
+
})
190
190
+
.from(follows)
191
191
+
.where(eq(follows.targetType, 'collection'));
192
192
+
193
193
+
return results.map((row) => ({
194
194
+
id: `follow-collection:${row.followerId}:${row.targetId}`,
195
195
+
source: `user:${row.followerId}`,
196
196
+
target: `collection:${row.targetId}`,
197
197
+
type: 'USER_FOLLOWS_COLLECTION' as const,
198
198
+
metadata: {
199
199
+
createdAt: row.createdAt.toISOString(),
200
200
+
},
201
201
+
}));
202
202
+
}
203
203
+
204
204
+
private async getAuthorshipEdges(): Promise<GraphEdgeDTO[]> {
205
205
+
const results = await this.db
206
206
+
.select({
207
207
+
authorId: cards.authorId,
208
208
+
url: cards.url,
209
209
+
createdAt: cards.createdAt,
210
210
+
})
211
211
+
.from(cards)
212
212
+
.where(and(eq(cards.type, 'URL'), sql`${cards.url} IS NOT NULL`));
213
213
+
214
214
+
return results.map((row) => ({
215
215
+
id: `authorship:${row.authorId}:${row.url}`,
216
216
+
source: `user:${row.authorId}`,
217
217
+
target: `url:${row.url}`,
218
218
+
type: 'USER_AUTHORED_URL' as const,
219
219
+
metadata: {
220
220
+
createdAt: row.createdAt.toISOString(),
221
221
+
},
222
222
+
}));
223
223
+
}
224
224
+
225
225
+
private async getNoteUrlEdges(): Promise<GraphEdgeDTO[]> {
226
226
+
// Join notes with their parent URL cards using raw SQL for self-join
227
227
+
const results = await this.db.execute<{
228
228
+
note_id: string;
229
229
+
parent_url: string;
230
230
+
created_at: Date;
231
231
+
}>(sql`
232
232
+
SELECT
233
233
+
note_cards.id as note_id,
234
234
+
parent_cards.url as parent_url,
235
235
+
note_cards.created_at
236
236
+
FROM cards as note_cards
237
237
+
INNER JOIN cards as parent_cards
238
238
+
ON note_cards.parent_card_id = parent_cards.id
239
239
+
AND parent_cards.type = 'URL'
240
240
+
WHERE note_cards.type = 'NOTE'
241
241
+
AND parent_cards.url IS NOT NULL
242
242
+
`);
243
243
+
244
244
+
return results.map((row) => ({
245
245
+
id: `note-url:${row.note_id}:${row.parent_url}`,
246
246
+
source: `note:${row.note_id}`,
247
247
+
target: `url:${row.parent_url}`,
248
248
+
type: 'NOTE_REFERENCES_URL' as const,
249
249
+
metadata: {
250
250
+
createdAt: row.created_at.toISOString(),
251
251
+
},
252
252
+
}));
253
253
+
}
254
254
+
255
255
+
private async getCollectionUrlEdges(): Promise<GraphEdgeDTO[]> {
256
256
+
const results = await this.db
257
257
+
.select({
258
258
+
collectionId: collectionCards.collectionId,
259
259
+
cardId: collectionCards.cardId,
260
260
+
url: cards.url,
261
261
+
addedAt: collectionCards.addedAt,
262
262
+
addedBy: collectionCards.addedBy,
263
263
+
})
264
264
+
.from(collectionCards)
265
265
+
.innerJoin(cards, eq(collectionCards.cardId, cards.id))
266
266
+
.where(and(eq(cards.type, 'URL'), sql`${cards.url} IS NOT NULL`));
267
267
+
268
268
+
return results.map((row) => ({
269
269
+
id: `collection-url:${row.collectionId}:${row.url}`,
270
270
+
source: `collection:${row.collectionId}`,
271
271
+
target: `url:${row.url}`,
272
272
+
type: 'COLLECTION_CONTAINS_URL' as const,
273
273
+
metadata: {
274
274
+
addedAt: row.addedAt.toISOString(),
275
275
+
addedBy: row.addedBy,
276
276
+
},
277
277
+
}));
278
278
+
}
279
279
+
280
280
+
private async getUrlConnectionEdges(): Promise<GraphEdgeDTO[]> {
281
281
+
const results = await this.db
282
282
+
.select({
283
283
+
id: connections.id,
284
284
+
sourceValue: connections.sourceValue,
285
285
+
targetValue: connections.targetValue,
286
286
+
connectionType: connections.connectionType,
287
287
+
note: connections.note,
288
288
+
curatorId: connections.curatorId,
289
289
+
createdAt: connections.createdAt,
290
290
+
})
291
291
+
.from(connections)
292
292
+
.where(
293
293
+
and(
294
294
+
eq(connections.sourceType, 'URL'),
295
295
+
eq(connections.targetType, 'URL'),
296
296
+
),
297
297
+
);
298
298
+
299
299
+
return results.map((row) => ({
300
300
+
id: `connection:${row.id}`,
301
301
+
source: `url:${row.sourceValue}`,
302
302
+
target: `url:${row.targetValue}`,
303
303
+
type: 'URL_CONNECTS_URL' as const,
304
304
+
metadata: {
305
305
+
connectionType: row.connectionType,
306
306
+
note: row.note,
307
307
+
curatorId: row.curatorId,
308
308
+
createdAt: row.createdAt.toISOString(),
309
309
+
},
310
310
+
}));
311
311
+
}
312
312
+
}
···
1
1
+
import {
2
2
+
IGraphQueryRepository,
3
3
+
GraphDataDTO,
4
4
+
} from '../../domain/IGraphQueryRepository';
5
5
+
6
6
+
export class InMemoryGraphQueryRepository implements IGraphQueryRepository {
7
7
+
private static instance: InMemoryGraphQueryRepository;
8
8
+
9
9
+
private constructor() {}
10
10
+
11
11
+
public static getInstance(): InMemoryGraphQueryRepository {
12
12
+
if (!InMemoryGraphQueryRepository.instance) {
13
13
+
InMemoryGraphQueryRepository.instance =
14
14
+
new InMemoryGraphQueryRepository();
15
15
+
}
16
16
+
return InMemoryGraphQueryRepository.instance;
17
17
+
}
18
18
+
19
19
+
async getGraphData(): Promise<GraphDataDTO> {
20
20
+
// For in-memory implementation, return empty graph
21
21
+
// In a real test scenario, you would populate this with test data
22
22
+
return { nodes: [], edges: [] };
23
23
+
}
24
24
+
}
···
6
6
import { createAtprotoRoutes } from '../../../modules/atproto/infrastructure/atprotoRoutes';
7
7
import { createCardsModuleRoutes } from '../../../modules/cards/infrastructure/http/routes';
8
8
import { createConnectionRoutes } from '../../../modules/cards/infrastructure/http/routes/connectionRoutes';
9
9
+
import { createGraphRoutes } from '../../../modules/cards/infrastructure/http/routes/graphRoutes';
9
10
import { createFeedRoutes } from '../../../modules/feeds/infrastructure/http/routes/feedRoutes';
10
11
import { createSearchRoutes } from '../../../modules/search/infrastructure/http/routes/searchRoutes';
11
12
import { createNotificationRoutes } from '../../../modules/notifications/infrastructure/http/routes/notificationRoutes';
···
141
142
controllers.getBackwardConnectionsForUrlController,
142
143
);
143
144
145
145
+
const graphRouter = createGraphRoutes(
146
146
+
services.authMiddleware,
147
147
+
controllers.getGraphDataController,
148
148
+
);
149
149
+
144
150
const feedRouter = createFeedRoutes(
145
151
services.authMiddleware,
146
152
controllers.getGlobalFeedController,
···
173
179
app.use('/atproto', atprotoRouter);
174
180
app.use('/api', cardsRouter);
175
181
app.use('/api/connections', connectionRouter);
182
182
+
app.use('/api/graph', graphRouter);
176
183
app.use('/api/feeds', feedRouter);
177
184
app.use('/api/search', searchRouter);
178
185
app.use('/api/notifications', notificationRouter);
···
62
62
import { GetForwardConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController';
63
63
import { GetBackwardConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetBackwardConnectionsForUrlController';
64
64
import { SearchUrlsController } from '../../../../modules/cards/infrastructure/http/controllers/SearchUrlsController';
65
65
+
import { GetGraphDataController } from '../../../../modules/cards/infrastructure/http/controllers/GetGraphDataController';
65
66
import { CookieService } from '../services/CookieService';
66
67
67
68
export interface Controllers {
···
118
119
deleteConnectionController: DeleteConnectionController;
119
120
getForwardConnectionsForUrlController: GetForwardConnectionsForUrlController;
120
121
getBackwardConnectionsForUrlController: GetBackwardConnectionsForUrlController;
122
122
+
// Graph controllers
123
123
+
getGraphDataController: GetGraphDataController;
121
124
// Search controllers
122
125
searchUrlsController: SearchUrlsController;
123
126
// Feed controllers
···
309
312
new GetBackwardConnectionsForUrlController(
310
313
useCases.getBackwardConnectionsForUrlUseCase,
311
314
),
315
315
+
316
316
+
// Graph controllers
317
317
+
getGraphDataController: new GetGraphDataController(
318
318
+
useCases.getGraphDataUseCase,
319
319
+
),
312
320
313
321
// Search controllers
314
322
searchUrlsController: new SearchUrlsController(
···
51
51
import { IFollowsRepository } from '../../../../modules/user/domain/repositories/IFollowsRepository';
52
52
import { DrizzleFollowsRepository } from '../../../../modules/user/infrastructure/repositories/DrizzleFollowsRepository';
53
53
import { InMemoryFollowsRepository } from '../../../../modules/user/tests/infrastructure/InMemoryFollowsRepository';
54
54
+
import { IGraphQueryRepository } from '../../../../modules/cards/domain/IGraphQueryRepository';
55
55
+
import { DrizzleGraphQueryRepository } from '../../../../modules/cards/infrastructure/repositories/DrizzleGraphQueryRepository';
56
56
+
import { InMemoryGraphQueryRepository } from '../../../../modules/cards/tests/utils/InMemoryGraphQueryRepository';
54
57
55
58
export interface Repositories {
56
59
userRepository: IUserRepository;
···
61
64
collectionQueryRepository: ICollectionQueryRepository;
62
65
connectionRepository: IConnectionRepository;
63
66
connectionQueryRepository: IConnectionQueryRepository;
67
67
+
graphQueryRepository: IGraphQueryRepository;
64
68
appPasswordSessionRepository: IAppPasswordSessionRepository;
65
69
feedRepository: IFeedRepository;
66
70
followsRepository: IFollowsRepository;
···
93
97
const connectionQueryRepository = new InMemoryConnectionQueryRepository(
94
98
connectionRepository,
95
99
);
100
100
+
const graphQueryRepository = InMemoryGraphQueryRepository.getInstance();
96
101
const appPasswordSessionRepository =
97
102
InMemoryAppPasswordSessionRepository.getInstance();
98
103
const feedRepository = InMemoryFeedRepository.getInstance();
···
119
124
collectionQueryRepository,
120
125
connectionRepository,
121
126
connectionQueryRepository,
127
127
+
graphQueryRepository,
122
128
appPasswordSessionRepository,
123
129
feedRepository,
124
130
followsRepository,
···
146
152
collectionQueryRepository: new DrizzleCollectionQueryRepository(db),
147
153
connectionRepository: new DrizzleConnectionRepository(db),
148
154
connectionQueryRepository: new DrizzleConnectionQueryRepository(db),
155
155
+
graphQueryRepository: new DrizzleGraphQueryRepository(db),
149
156
appPasswordSessionRepository: new DrizzleAppPasswordSessionRepository(db),
150
157
feedRepository: new DrizzleFeedRepository(db),
151
158
followsRepository: new DrizzleFollowsRepository(db),
···
73
73
import { GetCollectionFollowersCountUseCase } from '../../../../modules/user/application/useCases/queries/GetCollectionFollowersCountUseCase';
74
74
import { GetCollectionContributorsUseCase } from '../../../../modules/cards/application/useCases/queries/GetCollectionContributorsUseCase';
75
75
import { SearchUrlsUseCase } from '../../../../modules/cards/application/useCases/queries/SearchUrlsUseCase';
76
76
+
import { GetGraphDataUseCase } from '../../../../modules/cards/application/useCases/queries/GetGraphDataUseCase';
76
77
77
78
export interface WorkerUseCases {
78
79
addActivityToFeedUseCase: AddActivityToFeedUseCase;
···
148
149
createConnectionUseCase: CreateConnectionUseCase;
149
150
updateConnectionUseCase: UpdateConnectionUseCase;
150
151
deleteConnectionUseCase: DeleteConnectionUseCase;
152
152
+
// Graph use cases
153
153
+
getGraphDataUseCase: GetGraphDataUseCase;
151
154
// Search use cases
152
155
searchUrlsUseCase: SearchUrlsUseCase;
153
156
// Feed use cases
···
426
429
repositories.connectionRepository,
427
430
services.connectionPublisher,
428
431
services.eventPublisher,
432
432
+
),
433
433
+
434
434
+
// Graph use cases
435
435
+
getGraphDataUseCase: new GetGraphDataUseCase(
436
436
+
repositories.graphQueryRepository,
429
437
),
430
438
431
439
// Search use cases
···
352
352
identifier: string; // Can be DID or handle
353
353
connectionTypes?: ConnectionType[];
354
354
}
355
355
+
356
356
+
// Graph request types
357
357
+
export interface GetGraphDataParams {
358
358
+
// No parameters needed for global graph in v1
359
359
+
}
···
468
468
pagination: Pagination;
469
469
sorting: ConnectionSorting;
470
470
}
471
471
+
472
472
+
// Graph response types
473
473
+
export interface GraphNode {
474
474
+
id: string;
475
475
+
type: 'USER' | 'URL' | 'COLLECTION' | 'NOTE';
476
476
+
label: string;
477
477
+
metadata: Record<string, any>;
478
478
+
}
479
479
+
480
480
+
export interface GraphEdge {
481
481
+
id: string;
482
482
+
source: string;
483
483
+
target: string;
484
484
+
type:
485
485
+
| 'USER_FOLLOWS_USER'
486
486
+
| 'USER_FOLLOWS_COLLECTION'
487
487
+
| 'USER_AUTHORED_URL'
488
488
+
| 'NOTE_REFERENCES_URL'
489
489
+
| 'COLLECTION_CONTAINS_URL'
490
490
+
| 'URL_CONNECTS_URL';
491
491
+
metadata?: Record<string, any>;
492
492
+
}
493
493
+
494
494
+
export interface GetGraphDataResponse {
495
495
+
nodes: GraphNode[];
496
496
+
edges: GraphEdge[];
497
497
+
}
···
117
117
// Search types
118
118
SearchUrlsParams,
119
119
SearchUrlsResponse,
120
120
+
// Graph types
121
121
+
GetGraphDataResponse,
120
122
} from '@semble/types';
121
123
122
124
// Main API Client class using composition
···
514
516
// Search operations
515
517
async searchUrls(params: SearchUrlsParams): Promise<SearchUrlsResponse> {
516
518
return this.queryClient.searchUrls(params);
519
519
+
}
520
520
+
521
521
+
// Graph operations
522
522
+
async getGraphData(): Promise<GetGraphDataResponse> {
523
523
+
return this.queryClient.getGraphData();
517
524
}
518
525
}
519
526
···
56
56
GetBackwardConnectionsForUrlResponse,
57
57
SearchUrlsParams,
58
58
SearchUrlsResponse,
59
59
+
GetGraphDataParams,
60
60
+
GetGraphDataResponse,
59
61
} from '@semble/types';
60
62
61
63
export class QueryClient extends BaseClient {
···
548
550
'GET',
549
551
`/api/cards/search?${searchParams}`,
550
552
);
553
553
+
}
554
554
+
555
555
+
async getGraphData(
556
556
+
params?: GetGraphDataParams,
557
557
+
): Promise<GetGraphDataResponse> {
558
558
+
return this.request<GetGraphDataResponse>('GET', '/api/graph/data');
551
559
}
552
560
}