Skip to content

Commit 5f6170c

Browse files
committed
Add complete REST API with WebSocket events
Implements comprehensive HTTP API server with real-time event streaming: - Core business logic extracted to src/core/{nodes,edges,tags}.ts - Full CRUD endpoints for nodes, edges, and tags under /api/v1 - Advanced edge management: accept, reject, explain, promote, sweep, undo - WebSocket endpoint (/ws) with event subscriptions and tag filtering - Event bus system broadcasting all graph mutations - Consistent error handling and response envelopes - Pagination support with offset-based navigation All endpoints tested and functional. WebSocket events verified with node:created, node:updated, node:deleted, edge:*, and tag:renamed.
1 parent 139f730 commit 5f6170c

11 files changed

Lines changed: 2815 additions & 1 deletion

File tree

src/core/edges.ts

Lines changed: 570 additions & 0 deletions
Large diffs are not rendered by default.

src/core/nodes.ts

Lines changed: 415 additions & 0 deletions
Large diffs are not rendered by default.

src/core/tags.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { NodeRecord, listNodes, updateNodeIndexData } from '../lib/db';
2+
import { eventBus } from '../server/events/eventBus';
3+
4+
export type TagWithCount = {
5+
name: string;
6+
count: number;
7+
lastUsed: string;
8+
};
9+
10+
export type ListTagsOptions = {
11+
sort?: 'name' | 'count';
12+
order?: 'asc' | 'desc';
13+
};
14+
15+
export type ListTagsResult = {
16+
tags: TagWithCount[];
17+
total: number;
18+
};
19+
20+
export type GetNodesByTagOptions = {
21+
limit?: number;
22+
offset?: number;
23+
};
24+
25+
export type GetNodesByTagResult = {
26+
tag: string;
27+
nodes: NodeRecord[];
28+
total: number;
29+
};
30+
31+
export type RenameTagResult = {
32+
from: string;
33+
to: string;
34+
nodesAffected: number;
35+
};
36+
37+
export type TagCoOccurrence = {
38+
tag: string;
39+
count: number;
40+
};
41+
42+
export type TagStatsOptions = {
43+
focusTag?: string;
44+
minCount?: number;
45+
top?: number;
46+
};
47+
48+
export type TagStatsResult = {
49+
totalTags: number;
50+
topTags: TagWithCount[];
51+
coOccurrences?: TagCoOccurrence[];
52+
};
53+
54+
export async function listTagsCore(options: ListTagsOptions = {}): Promise<ListTagsResult> {
55+
const nodes = await listNodes();
56+
const tagMap = new Map<string, { count: number; lastUsed: string }>();
57+
58+
// Build tag counts and last used timestamps
59+
for (const node of nodes) {
60+
for (const tag of node.tags) {
61+
const existing = tagMap.get(tag);
62+
if (!existing) {
63+
tagMap.set(tag, {
64+
count: 1,
65+
lastUsed: node.updatedAt,
66+
});
67+
} else {
68+
existing.count += 1;
69+
// Update lastUsed if this node is more recent
70+
if (new Date(node.updatedAt) > new Date(existing.lastUsed)) {
71+
existing.lastUsed = node.updatedAt;
72+
}
73+
}
74+
}
75+
}
76+
77+
// Convert to array
78+
let tags: TagWithCount[] = Array.from(tagMap.entries()).map(([name, data]) => ({
79+
name,
80+
count: data.count,
81+
lastUsed: data.lastUsed,
82+
}));
83+
84+
// Apply sorting
85+
const sort = options.sort ?? 'count';
86+
const order = options.order ?? 'desc';
87+
88+
tags.sort((a, b) => {
89+
let comparison = 0;
90+
91+
if (sort === 'count') {
92+
comparison = a.count - b.count;
93+
} else if (sort === 'name') {
94+
comparison = a.name.localeCompare(b.name);
95+
}
96+
97+
return order === 'asc' ? comparison : -comparison;
98+
});
99+
100+
return {
101+
tags,
102+
total: tags.length,
103+
};
104+
}
105+
106+
export async function getNodesByTagCore(
107+
tagName: string,
108+
options: GetNodesByTagOptions = {},
109+
): Promise<GetNodesByTagResult> {
110+
const nodes = await listNodes();
111+
const taggedNodes = nodes.filter((node) => node.tags.includes(tagName));
112+
113+
// Sort by updated date descending
114+
taggedNodes.sort(
115+
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
116+
);
117+
118+
const total = taggedNodes.length;
119+
120+
// Apply pagination
121+
const limit = options.limit ?? 20;
122+
const offset = options.offset ?? 0;
123+
const paginatedNodes = taggedNodes.slice(offset, offset + limit);
124+
125+
return {
126+
tag: tagName,
127+
nodes: paginatedNodes,
128+
total,
129+
};
130+
}
131+
132+
export async function renameTagCore(
133+
oldName: string,
134+
newName: string,
135+
): Promise<RenameTagResult> {
136+
if (!oldName || !newName) {
137+
throw new Error('Both old and new tag names are required');
138+
}
139+
140+
if (oldName === newName) {
141+
throw new Error('Old and new tag names must be different');
142+
}
143+
144+
const nodes = await listNodes();
145+
let nodesAffected = 0;
146+
147+
for (const node of nodes) {
148+
if (node.tags.includes(oldName)) {
149+
const updatedTags = node.tags.map((tag) => (tag === oldName ? newName : tag));
150+
await updateNodeIndexData(node.id, updatedTags, node.tokenCounts);
151+
nodesAffected += 1;
152+
}
153+
}
154+
155+
// Emit event
156+
eventBus.emitTagRenamed(oldName, newName, nodesAffected);
157+
158+
return {
159+
from: oldName,
160+
to: newName,
161+
nodesAffected,
162+
};
163+
}
164+
165+
export async function getTagStatsCore(
166+
options: TagStatsOptions = {},
167+
): Promise<TagStatsResult> {
168+
const nodes = await listNodes();
169+
const tagCounts = new Map<string, { count: number; lastUsed: string }>();
170+
171+
// Build tag counts
172+
for (const node of nodes) {
173+
for (const tag of node.tags) {
174+
const existing = tagCounts.get(tag);
175+
if (!existing) {
176+
tagCounts.set(tag, {
177+
count: 1,
178+
lastUsed: node.updatedAt,
179+
});
180+
} else {
181+
existing.count += 1;
182+
if (new Date(node.updatedAt) > new Date(existing.lastUsed)) {
183+
existing.lastUsed = node.updatedAt;
184+
}
185+
}
186+
}
187+
}
188+
189+
// Convert to array and filter by minCount
190+
const minCount = options.minCount ?? 0;
191+
let tags: TagWithCount[] = Array.from(tagCounts.entries())
192+
.filter(([_, data]) => data.count >= minCount)
193+
.map(([name, data]) => ({
194+
name,
195+
count: data.count,
196+
lastUsed: data.lastUsed,
197+
}));
198+
199+
// Sort by count descending
200+
tags.sort((a, b) => b.count - a.count);
201+
202+
// Limit to top N
203+
const top = options.top ?? 10;
204+
const topTags = tags.slice(0, top);
205+
206+
// Compute co-occurrences if focusTag is specified
207+
let coOccurrences: TagCoOccurrence[] | undefined;
208+
209+
if (options.focusTag) {
210+
const coOccurrenceMap = new Map<string, number>();
211+
const focusNodes = nodes.filter((node) => node.tags.includes(options.focusTag!));
212+
213+
for (const node of focusNodes) {
214+
for (const tag of node.tags) {
215+
if (tag !== options.focusTag) {
216+
coOccurrenceMap.set(tag, (coOccurrenceMap.get(tag) ?? 0) + 1);
217+
}
218+
}
219+
}
220+
221+
coOccurrences = Array.from(coOccurrenceMap.entries())
222+
.map(([tag, count]) => ({ tag, count }))
223+
.filter((item) => item.count >= minCount)
224+
.sort((a, b) => b.count - a.count)
225+
.slice(0, top);
226+
}
227+
228+
return {
229+
totalTags: tags.length,
230+
topTags,
231+
coOccurrences,
232+
};
233+
}

0 commit comments

Comments
 (0)