@qwickapps/qwickbrain-proxy 1.0.0
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/.github/workflows/publish.yml +92 -0
- package/CHANGELOG.md +47 -0
- package/LICENSE +45 -0
- package/README.md +165 -0
- package/dist/bin/cli.d.ts +3 -0
- package/dist/bin/cli.d.ts.map +1 -0
- package/dist/bin/cli.js +142 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/db/client.d.ts +10 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +23 -0
- package/dist/db/client.js.map +1 -0
- package/dist/db/schema.d.ts +551 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +65 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/__tests__/cache-manager.test.d.ts +2 -0
- package/dist/lib/__tests__/cache-manager.test.d.ts.map +1 -0
- package/dist/lib/__tests__/cache-manager.test.js +202 -0
- package/dist/lib/__tests__/cache-manager.test.js.map +1 -0
- package/dist/lib/__tests__/connection-manager.test.d.ts +2 -0
- package/dist/lib/__tests__/connection-manager.test.d.ts.map +1 -0
- package/dist/lib/__tests__/connection-manager.test.js +188 -0
- package/dist/lib/__tests__/connection-manager.test.js.map +1 -0
- package/dist/lib/__tests__/proxy-server.test.d.ts +2 -0
- package/dist/lib/__tests__/proxy-server.test.d.ts.map +1 -0
- package/dist/lib/__tests__/proxy-server.test.js +205 -0
- package/dist/lib/__tests__/proxy-server.test.js.map +1 -0
- package/dist/lib/__tests__/qwickbrain-client.test.d.ts +2 -0
- package/dist/lib/__tests__/qwickbrain-client.test.d.ts.map +1 -0
- package/dist/lib/__tests__/qwickbrain-client.test.js +233 -0
- package/dist/lib/__tests__/qwickbrain-client.test.js.map +1 -0
- package/dist/lib/cache-manager.d.ts +25 -0
- package/dist/lib/cache-manager.d.ts.map +1 -0
- package/dist/lib/cache-manager.js +149 -0
- package/dist/lib/cache-manager.js.map +1 -0
- package/dist/lib/connection-manager.d.ts +26 -0
- package/dist/lib/connection-manager.d.ts.map +1 -0
- package/dist/lib/connection-manager.js +130 -0
- package/dist/lib/connection-manager.js.map +1 -0
- package/dist/lib/proxy-server.d.ts +19 -0
- package/dist/lib/proxy-server.d.ts.map +1 -0
- package/dist/lib/proxy-server.js +258 -0
- package/dist/lib/proxy-server.js.map +1 -0
- package/dist/lib/qwickbrain-client.d.ts +24 -0
- package/dist/lib/qwickbrain-client.d.ts.map +1 -0
- package/dist/lib/qwickbrain-client.js +197 -0
- package/dist/lib/qwickbrain-client.js.map +1 -0
- package/dist/types/config.d.ts +186 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +42 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/mcp.d.ts +223 -0
- package/dist/types/mcp.d.ts.map +1 -0
- package/dist/types/mcp.js +78 -0
- package/dist/types/mcp.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +9 -0
- package/dist/version.js.map +1 -0
- package/drizzle/0000_fat_rafael_vega.sql +41 -0
- package/drizzle/0001_goofy_invisible_woman.sql +2 -0
- package/drizzle/meta/0000_snapshot.json +276 -0
- package/drizzle/meta/0001_snapshot.json +295 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +12 -0
- package/package.json +65 -0
- package/src/bin/cli.ts +158 -0
- package/src/db/client.ts +34 -0
- package/src/db/schema.ts +68 -0
- package/src/index.ts +6 -0
- package/src/lib/__tests__/cache-manager.test.ts +264 -0
- package/src/lib/__tests__/connection-manager.test.ts +255 -0
- package/src/lib/__tests__/proxy-server.test.ts +261 -0
- package/src/lib/__tests__/qwickbrain-client.test.ts +310 -0
- package/src/lib/cache-manager.ts +201 -0
- package/src/lib/connection-manager.ts +156 -0
- package/src/lib/proxy-server.ts +320 -0
- package/src/lib/qwickbrain-client.ts +260 -0
- package/src/types/config.ts +47 -0
- package/src/types/mcp.ts +97 -0
- package/src/version.ts +11 -0
- package/test/fixtures/test-mcp.json +5 -0
- package/test-mcp-client.js +67 -0
- package/test-proxy.sh +25 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { QwickBrainClient } from '../qwickbrain-client.js';
|
|
3
|
+
import type { Config } from '../../types/config.js';
|
|
4
|
+
|
|
5
|
+
// Mock the MCP SDK
|
|
6
|
+
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
|
7
|
+
Client: vi.fn().mockImplementation(() => ({
|
|
8
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
9
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
callTool: vi.fn(),
|
|
11
|
+
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
12
|
+
})),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
|
16
|
+
StdioClientTransport: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
|
|
20
|
+
SSEClientTransport: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Mock fetch for HTTP mode
|
|
24
|
+
global.fetch = vi.fn();
|
|
25
|
+
|
|
26
|
+
describe('QwickBrainClient', () => {
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('SSE mode', () => {
|
|
32
|
+
let client: QwickBrainClient;
|
|
33
|
+
let config: Config['qwickbrain'];
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
config = {
|
|
37
|
+
mode: 'sse',
|
|
38
|
+
url: 'http://test.local:3000',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
client = new QwickBrainClient(config);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should connect in SSE mode', async () => {
|
|
45
|
+
await client.connect();
|
|
46
|
+
|
|
47
|
+
expect(client['mode']).toBe('sse');
|
|
48
|
+
expect(client['client']).toBeDefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should get document via MCP in SSE mode', async () => {
|
|
52
|
+
await client.connect();
|
|
53
|
+
|
|
54
|
+
const mockResponse = {
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: 'text',
|
|
58
|
+
text: JSON.stringify({
|
|
59
|
+
document: {
|
|
60
|
+
content: 'test content',
|
|
61
|
+
metadata: { version: 1 },
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
(client['client']!.callTool as any).mockResolvedValue(mockResponse);
|
|
69
|
+
|
|
70
|
+
const result = await client.getDocument('workflow', 'test');
|
|
71
|
+
|
|
72
|
+
expect(result.content).toBe('test content');
|
|
73
|
+
expect(result.metadata).toEqual({ version: 1 });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should throw on invalid MCP response format', async () => {
|
|
77
|
+
await client.connect();
|
|
78
|
+
|
|
79
|
+
const mockResponse = {
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: 'image', // Invalid type
|
|
83
|
+
data: 'invalid',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
(client['client']!.callTool as any).mockResolvedValue(mockResponse);
|
|
89
|
+
|
|
90
|
+
await expect(client.getDocument('workflow', 'test')).rejects.toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('HTTP mode', () => {
|
|
95
|
+
let client: QwickBrainClient;
|
|
96
|
+
let config: Config['qwickbrain'];
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
config = {
|
|
100
|
+
mode: 'http',
|
|
101
|
+
url: 'http://api.test.com',
|
|
102
|
+
apiKey: 'test-key',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
client = new QwickBrainClient(config);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should get document via HTTP', async () => {
|
|
109
|
+
const mockResponse = {
|
|
110
|
+
content: 'test content',
|
|
111
|
+
metadata: { version: 1 },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
(global.fetch as any).mockResolvedValue({
|
|
115
|
+
ok: true,
|
|
116
|
+
json: async () => mockResponse,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const result = await client.getDocument('workflow', 'test');
|
|
120
|
+
|
|
121
|
+
expect(result.content).toBe('test content');
|
|
122
|
+
expect(result.metadata).toEqual({ version: 1 });
|
|
123
|
+
|
|
124
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
125
|
+
'http://api.test.com/mcp/document',
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: expect.objectContaining({
|
|
129
|
+
'Content-Type': 'application/json',
|
|
130
|
+
'Authorization': 'Bearer test-key',
|
|
131
|
+
}),
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should throw on HTTP error', async () => {
|
|
137
|
+
(global.fetch as any).mockResolvedValue({
|
|
138
|
+
ok: false,
|
|
139
|
+
status: 404,
|
|
140
|
+
statusText: 'Not Found',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await expect(client.getDocument('workflow', 'test')).rejects.toThrow('HTTP 404');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should validate HTTP response schema', async () => {
|
|
147
|
+
const invalidResponse = {
|
|
148
|
+
// Missing required 'content' field
|
|
149
|
+
metadata: {},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
(global.fetch as any).mockResolvedValue({
|
|
153
|
+
ok: true,
|
|
154
|
+
json: async () => invalidResponse,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await expect(client.getDocument('workflow', 'test')).rejects.toThrow();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should get memory via HTTP', async () => {
|
|
161
|
+
const mockResponse = {
|
|
162
|
+
content: 'memory content',
|
|
163
|
+
metadata: {},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
(global.fetch as any).mockResolvedValue({
|
|
167
|
+
ok: true,
|
|
168
|
+
json: async () => mockResponse,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const result = await client.getMemory('test-memory');
|
|
172
|
+
|
|
173
|
+
expect(result.content).toBe('memory content');
|
|
174
|
+
|
|
175
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
176
|
+
'http://api.test.com/mcp/memory',
|
|
177
|
+
expect.objectContaining({
|
|
178
|
+
method: 'POST',
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('MCP mode', () => {
|
|
185
|
+
let client: QwickBrainClient;
|
|
186
|
+
let config: Config['qwickbrain'];
|
|
187
|
+
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
config = {
|
|
190
|
+
mode: 'mcp',
|
|
191
|
+
command: 'npx',
|
|
192
|
+
args: ['qwickbrain-server'],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
client = new QwickBrainClient(config);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should connect in MCP mode', async () => {
|
|
199
|
+
await client.connect();
|
|
200
|
+
|
|
201
|
+
expect(client['mode']).toBe('mcp');
|
|
202
|
+
expect(client['client']).toBeDefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should require command in MCP mode', async () => {
|
|
206
|
+
const invalidConfig: Config['qwickbrain'] = {
|
|
207
|
+
mode: 'mcp',
|
|
208
|
+
// Missing command
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const invalidClient = new QwickBrainClient(invalidConfig);
|
|
212
|
+
|
|
213
|
+
await expect(invalidClient.connect()).rejects.toThrow('MCP mode requires command');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('healthCheck', () => {
|
|
218
|
+
it('should return true for successful MCP health check', async () => {
|
|
219
|
+
const config: Config['qwickbrain'] = {
|
|
220
|
+
mode: 'sse',
|
|
221
|
+
url: 'http://test.local:3000',
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const client = new QwickBrainClient(config);
|
|
225
|
+
await client.connect();
|
|
226
|
+
|
|
227
|
+
const isHealthy = await client.healthCheck();
|
|
228
|
+
|
|
229
|
+
expect(isHealthy).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should return true for successful HTTP health check', async () => {
|
|
233
|
+
const config: Config['qwickbrain'] = {
|
|
234
|
+
mode: 'http',
|
|
235
|
+
url: 'http://api.test.com',
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const client = new QwickBrainClient(config);
|
|
239
|
+
|
|
240
|
+
(global.fetch as any).mockResolvedValue({
|
|
241
|
+
ok: true,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const isHealthy = await client.healthCheck();
|
|
245
|
+
|
|
246
|
+
expect(isHealthy).toBe(true);
|
|
247
|
+
expect(global.fetch).toHaveBeenCalledWith('http://api.test.com/health');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should return false on health check failure', async () => {
|
|
251
|
+
const config: Config['qwickbrain'] = {
|
|
252
|
+
mode: 'sse',
|
|
253
|
+
url: 'http://test.local:3000',
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const client = new QwickBrainClient(config);
|
|
257
|
+
await client.connect();
|
|
258
|
+
|
|
259
|
+
(client['client']!.listTools as any).mockRejectedValue(new Error('Failed'));
|
|
260
|
+
|
|
261
|
+
const isHealthy = await client.healthCheck();
|
|
262
|
+
|
|
263
|
+
expect(isHealthy).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should return false for missing URL in HTTP mode', async () => {
|
|
267
|
+
const config: Config['qwickbrain'] = {
|
|
268
|
+
mode: 'http',
|
|
269
|
+
// Missing URL
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const client = new QwickBrainClient(config);
|
|
273
|
+
|
|
274
|
+
const isHealthy = await client.healthCheck();
|
|
275
|
+
|
|
276
|
+
expect(isHealthy).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('disconnect', () => {
|
|
281
|
+
it('should disconnect MCP client', async () => {
|
|
282
|
+
const config: Config['qwickbrain'] = {
|
|
283
|
+
mode: 'sse',
|
|
284
|
+
url: 'http://test.local:3000',
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const client = new QwickBrainClient(config);
|
|
288
|
+
await client.connect();
|
|
289
|
+
|
|
290
|
+
const closeMock = client['client']!.close;
|
|
291
|
+
|
|
292
|
+
await client.disconnect();
|
|
293
|
+
|
|
294
|
+
expect(closeMock).toHaveBeenCalled();
|
|
295
|
+
expect(client['client']).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should handle disconnect when not connected', async () => {
|
|
299
|
+
const config: Config['qwickbrain'] = {
|
|
300
|
+
mode: 'http',
|
|
301
|
+
url: 'http://api.test.com',
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const client = new QwickBrainClient(config);
|
|
305
|
+
|
|
306
|
+
// Should not throw
|
|
307
|
+
await expect(client.disconnect()).resolves.toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { eq, and, lt, lte, sql } from 'drizzle-orm';
|
|
2
|
+
import type { DB } from '../db/client.js';
|
|
3
|
+
import { documents, memories } from '../db/schema.js';
|
|
4
|
+
import type { Config } from '../types/config.js';
|
|
5
|
+
|
|
6
|
+
interface CachedItem<T> {
|
|
7
|
+
data: T;
|
|
8
|
+
cachedAt: Date;
|
|
9
|
+
expiresAt: Date;
|
|
10
|
+
age: number; // seconds
|
|
11
|
+
isExpired: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class CacheManager {
|
|
15
|
+
constructor(
|
|
16
|
+
private db: DB,
|
|
17
|
+
private config: Config['cache']
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
private getTTL(operation: string): number {
|
|
21
|
+
const ttlMap: Record<string, number> = {
|
|
22
|
+
get_workflow: this.config.ttl.workflows,
|
|
23
|
+
get_document: this.config.ttl.documents,
|
|
24
|
+
get_memory: this.config.ttl.memories,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return ttlMap[operation] || 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getDocument(docType: string, name: string, project?: string): Promise<CachedItem<any> | null> {
|
|
31
|
+
const projectValue = project || '';
|
|
32
|
+
|
|
33
|
+
const [cached] = await this.db
|
|
34
|
+
.select()
|
|
35
|
+
.from(documents)
|
|
36
|
+
.where(
|
|
37
|
+
and(
|
|
38
|
+
eq(documents.docType, docType),
|
|
39
|
+
eq(documents.name, name),
|
|
40
|
+
eq(documents.project, projectValue)
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
.limit(1);
|
|
44
|
+
|
|
45
|
+
if (!cached) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const now = new Date();
|
|
50
|
+
const age = Math.floor((now.getTime() - cached.cachedAt.getTime()) / 1000);
|
|
51
|
+
// Fix: Compare timestamp values explicitly to avoid Date comparison issues
|
|
52
|
+
const isExpired = now.getTime() > cached.expiresAt.getTime();
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
data: {
|
|
56
|
+
name: cached.name,
|
|
57
|
+
doc_type: cached.docType,
|
|
58
|
+
project: cached.project,
|
|
59
|
+
content: cached.content,
|
|
60
|
+
metadata: cached.metadata ? JSON.parse(cached.metadata) : {},
|
|
61
|
+
},
|
|
62
|
+
cachedAt: cached.cachedAt,
|
|
63
|
+
expiresAt: cached.expiresAt,
|
|
64
|
+
age,
|
|
65
|
+
isExpired,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async setDocument(
|
|
70
|
+
docType: string,
|
|
71
|
+
name: string,
|
|
72
|
+
content: string,
|
|
73
|
+
project?: string,
|
|
74
|
+
metadata?: Record<string, unknown>
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const now = new Date();
|
|
77
|
+
const ttl = this.getTTL('get_document');
|
|
78
|
+
const expiresAt = new Date(now.getTime() + ttl * 1000);
|
|
79
|
+
|
|
80
|
+
// Use empty string instead of null for project to make unique constraint work
|
|
81
|
+
// SQLite treats NULL as distinct values in unique constraints
|
|
82
|
+
const projectValue = project || '';
|
|
83
|
+
|
|
84
|
+
await this.db
|
|
85
|
+
.insert(documents)
|
|
86
|
+
.values({
|
|
87
|
+
docType,
|
|
88
|
+
name,
|
|
89
|
+
project: projectValue,
|
|
90
|
+
content,
|
|
91
|
+
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
92
|
+
cachedAt: now,
|
|
93
|
+
expiresAt,
|
|
94
|
+
synced: true,
|
|
95
|
+
})
|
|
96
|
+
.onConflictDoUpdate({
|
|
97
|
+
target: [documents.docType, documents.name, documents.project],
|
|
98
|
+
set: {
|
|
99
|
+
content,
|
|
100
|
+
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
101
|
+
cachedAt: now,
|
|
102
|
+
expiresAt,
|
|
103
|
+
synced: true,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async getMemory(name: string, project?: string): Promise<CachedItem<any> | null> {
|
|
109
|
+
const projectValue = project || '';
|
|
110
|
+
|
|
111
|
+
const [cached] = await this.db
|
|
112
|
+
.select()
|
|
113
|
+
.from(memories)
|
|
114
|
+
.where(
|
|
115
|
+
and(
|
|
116
|
+
eq(memories.name, name),
|
|
117
|
+
eq(memories.project, projectValue)
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
.limit(1);
|
|
121
|
+
|
|
122
|
+
if (!cached) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const now = new Date();
|
|
127
|
+
const age = Math.floor((now.getTime() - cached.cachedAt.getTime()) / 1000);
|
|
128
|
+
// Fix: Compare timestamp values explicitly to avoid Date comparison issues
|
|
129
|
+
const isExpired = now.getTime() > cached.expiresAt.getTime();
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
data: {
|
|
133
|
+
name: cached.name,
|
|
134
|
+
project: cached.project,
|
|
135
|
+
content: cached.content,
|
|
136
|
+
metadata: cached.metadata ? JSON.parse(cached.metadata) : {},
|
|
137
|
+
},
|
|
138
|
+
cachedAt: cached.cachedAt,
|
|
139
|
+
expiresAt: cached.expiresAt,
|
|
140
|
+
age,
|
|
141
|
+
isExpired,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async setMemory(
|
|
146
|
+
name: string,
|
|
147
|
+
content: string,
|
|
148
|
+
project?: string,
|
|
149
|
+
metadata?: Record<string, unknown>
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
const now = new Date();
|
|
152
|
+
const ttl = this.getTTL('get_memory');
|
|
153
|
+
const expiresAt = new Date(now.getTime() + ttl * 1000);
|
|
154
|
+
|
|
155
|
+
// Use empty string instead of null for project to make unique constraint work
|
|
156
|
+
const projectValue = project || '';
|
|
157
|
+
|
|
158
|
+
await this.db
|
|
159
|
+
.insert(memories)
|
|
160
|
+
.values({
|
|
161
|
+
name,
|
|
162
|
+
project: projectValue,
|
|
163
|
+
content,
|
|
164
|
+
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
165
|
+
cachedAt: now,
|
|
166
|
+
expiresAt,
|
|
167
|
+
synced: true,
|
|
168
|
+
})
|
|
169
|
+
.onConflictDoUpdate({
|
|
170
|
+
target: [memories.name, memories.project],
|
|
171
|
+
set: {
|
|
172
|
+
content,
|
|
173
|
+
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
174
|
+
cachedAt: now,
|
|
175
|
+
expiresAt,
|
|
176
|
+
synced: true,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async cleanupExpiredEntries(): Promise<{ documentsDeleted: number; memoriesDeleted: number }> {
|
|
182
|
+
const now = new Date();
|
|
183
|
+
|
|
184
|
+
// Delete expired documents (use lte to include items expiring exactly now)
|
|
185
|
+
const deletedDocs = await this.db
|
|
186
|
+
.delete(documents)
|
|
187
|
+
.where(lte(documents.expiresAt, now))
|
|
188
|
+
.returning({ id: documents.id });
|
|
189
|
+
|
|
190
|
+
// Delete expired memories (use lte to include items expiring exactly now)
|
|
191
|
+
const deletedMems = await this.db
|
|
192
|
+
.delete(memories)
|
|
193
|
+
.where(lte(memories.expiresAt, now))
|
|
194
|
+
.returning({ id: memories.id });
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
documentsDeleted: deletedDocs.length,
|
|
198
|
+
memoriesDeleted: deletedMems.length,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { ConnectionState } from '../types/mcp.js';
|
|
3
|
+
import type { Config } from '../types/config.js';
|
|
4
|
+
import type { QwickBrainClient } from './qwickbrain-client.js';
|
|
5
|
+
|
|
6
|
+
export class ConnectionManager extends EventEmitter {
|
|
7
|
+
private state: ConnectionState = 'disconnected';
|
|
8
|
+
private reconnectAttempts = 0;
|
|
9
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
10
|
+
private healthCheckTimer: NodeJS.Timeout | null = null;
|
|
11
|
+
private qwickbrainClient: QwickBrainClient;
|
|
12
|
+
private config: Config['connection'];
|
|
13
|
+
private isStopped = false;
|
|
14
|
+
private executionLock: Promise<void> = Promise.resolve();
|
|
15
|
+
|
|
16
|
+
constructor(qwickbrainClient: QwickBrainClient, config: Config['connection']) {
|
|
17
|
+
super();
|
|
18
|
+
this.qwickbrainClient = qwickbrainClient;
|
|
19
|
+
this.config = config;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getState(): ConnectionState {
|
|
23
|
+
return this.state;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setState(state: ConnectionState): void {
|
|
27
|
+
const previousState = this.state;
|
|
28
|
+
this.state = state;
|
|
29
|
+
|
|
30
|
+
if (previousState !== state) {
|
|
31
|
+
this.emit('stateChange', { from: previousState, to: state });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async start(): Promise<void> {
|
|
36
|
+
this.isStopped = false;
|
|
37
|
+
await this.healthCheck();
|
|
38
|
+
this.scheduleHealthCheck();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stop(): void {
|
|
42
|
+
this.isStopped = true;
|
|
43
|
+
if (this.healthCheckTimer) {
|
|
44
|
+
clearTimeout(this.healthCheckTimer);
|
|
45
|
+
this.healthCheckTimer = null;
|
|
46
|
+
}
|
|
47
|
+
if (this.reconnectTimer) {
|
|
48
|
+
clearTimeout(this.reconnectTimer);
|
|
49
|
+
this.reconnectTimer = null;
|
|
50
|
+
}
|
|
51
|
+
this.setState('offline');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async healthCheck(): Promise<boolean> {
|
|
55
|
+
const startTime = Date.now();
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const isHealthy = await this.qwickbrainClient.healthCheck();
|
|
59
|
+
const latencyMs = Date.now() - startTime;
|
|
60
|
+
|
|
61
|
+
if (isHealthy) {
|
|
62
|
+
this.setState('connected');
|
|
63
|
+
this.reconnectAttempts = 0;
|
|
64
|
+
this.clearReconnectTimer();
|
|
65
|
+
this.emit('connected', { latencyMs });
|
|
66
|
+
return true;
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error('Health check failed');
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
72
|
+
this.setState('disconnected');
|
|
73
|
+
this.emit('disconnected', { error: errorMessage });
|
|
74
|
+
this.scheduleReconnect();
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private scheduleHealthCheck(): void {
|
|
80
|
+
// Don't schedule if stopped
|
|
81
|
+
if (this.isStopped) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.healthCheckTimer) {
|
|
86
|
+
clearTimeout(this.healthCheckTimer);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.healthCheckTimer = setTimeout(async () => {
|
|
90
|
+
await this.healthCheck();
|
|
91
|
+
// Check again before rescheduling to prevent leak after stop()
|
|
92
|
+
if (!this.isStopped) {
|
|
93
|
+
this.scheduleHealthCheck();
|
|
94
|
+
}
|
|
95
|
+
}, this.config.healthCheckInterval);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private scheduleReconnect(): void {
|
|
99
|
+
if (this.reconnectTimer) {
|
|
100
|
+
return; // Already scheduled
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
104
|
+
this.setState('offline');
|
|
105
|
+
this.emit('maxReconnectAttemptsReached');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const delay = Math.min(
|
|
110
|
+
this.config.reconnectBackoff.initial *
|
|
111
|
+
Math.pow(this.config.reconnectBackoff.multiplier, this.reconnectAttempts),
|
|
112
|
+
this.config.reconnectBackoff.max
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
this.reconnectAttempts++;
|
|
116
|
+
this.setState('reconnecting');
|
|
117
|
+
|
|
118
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
119
|
+
this.reconnectTimer = null;
|
|
120
|
+
await this.healthCheck();
|
|
121
|
+
}, delay);
|
|
122
|
+
|
|
123
|
+
this.emit('reconnecting', { attempt: this.reconnectAttempts, delay });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private clearReconnectTimer(): void {
|
|
127
|
+
if (this.reconnectTimer) {
|
|
128
|
+
clearTimeout(this.reconnectTimer);
|
|
129
|
+
this.reconnectTimer = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
recordFailure(): void {
|
|
134
|
+
if (this.state === 'connected') {
|
|
135
|
+
this.setState('disconnected');
|
|
136
|
+
this.scheduleReconnect();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
|
141
|
+
// Wait for any ongoing state transitions to complete
|
|
142
|
+
await this.executionLock;
|
|
143
|
+
|
|
144
|
+
// Atomically check state and execute
|
|
145
|
+
if (this.state !== 'connected') {
|
|
146
|
+
throw new Error(`QwickBrain unavailable (state: ${this.state})`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
return await fn();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.recordFailure();
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|