Skip to content

Commit 6448c5e

Browse files
authored
Merge pull request #59 from codervisor:copilot/proceed-phase-3-analysis
Phase 3: TimescaleDB query optimizations with time_bucket and continuous aggregates
2 parents 82db870 + aa4104f commit 6448c5e

File tree

8 files changed

+2066
-122
lines changed

8 files changed

+2066
-122
lines changed
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
/**
2+
* Tests for TimescaleDB-optimized query methods in AgentEventService
3+
*
4+
* These tests verify the Phase 3 implementation of TimescaleDB features:
5+
* - time_bucket aggregations
6+
* - continuous aggregate queries
7+
* - optimized time-range queries
8+
*/
9+
10+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
11+
import { AgentEventService } from '../agent-event-service.js';
12+
import type { TimeBucketQueryParams, EventTimeBucketStats } from '../../../types/index.js';
13+
14+
describe('AgentEventService - TimescaleDB Optimizations', () => {
15+
let service: AgentEventService;
16+
17+
beforeEach(() => {
18+
service = AgentEventService.getInstance(1);
19+
});
20+
21+
afterEach(async () => {
22+
await service.dispose();
23+
});
24+
25+
describe('getTimeBucketStats', () => {
26+
it('should build correct SQL query for time_bucket aggregation', async () => {
27+
await service.initialize();
28+
29+
const params: TimeBucketQueryParams = {
30+
interval: '1 hour',
31+
projectId: 1,
32+
agentId: 'github-copilot',
33+
startTime: new Date('2025-11-01T00:00:00Z'),
34+
endTime: new Date('2025-11-02T00:00:00Z'),
35+
};
36+
37+
// Mock the prisma $queryRawUnsafe to verify the query
38+
const mockQueryRaw = vi.fn().mockResolvedValue([
39+
{
40+
bucket: new Date('2025-11-01T12:00:00Z'),
41+
project_id: 1,
42+
agent_id: 'github-copilot',
43+
event_count: BigInt(150),
44+
avg_duration: 1250.5,
45+
total_tokens: BigInt(15000),
46+
avg_prompt_tokens: 800.2,
47+
avg_response_tokens: 400.3,
48+
},
49+
]);
50+
51+
// Replace the prisma client's $queryRawUnsafe method
52+
if (service['prisma']) {
53+
service['prisma'].$queryRawUnsafe = mockQueryRaw;
54+
}
55+
56+
const results = await service.getTimeBucketStats(params);
57+
58+
// Verify query was called
59+
expect(mockQueryRaw).toHaveBeenCalled();
60+
61+
// Verify query contains time_bucket function
62+
const query = mockQueryRaw.mock.calls[0][0] as string;
63+
expect(query).toContain('time_bucket');
64+
expect(query).toContain('agent_events');
65+
expect(query).toContain('GROUP BY bucket');
66+
67+
// Verify results are properly mapped
68+
expect(results).toHaveLength(1);
69+
expect(results[0]).toMatchObject({
70+
bucket: expect.any(Date),
71+
projectId: 1,
72+
agentId: 'github-copilot',
73+
eventCount: 150,
74+
avgDuration: 1250.5,
75+
totalTokens: 15000,
76+
});
77+
});
78+
79+
it('should handle missing optional parameters', async () => {
80+
await service.initialize();
81+
82+
const params: TimeBucketQueryParams = {
83+
interval: '1 day',
84+
};
85+
86+
const mockQueryRaw = vi.fn().mockResolvedValue([]);
87+
if (service['prisma']) {
88+
service['prisma'].$queryRawUnsafe = mockQueryRaw;
89+
}
90+
91+
await service.getTimeBucketStats(params);
92+
93+
// Verify query doesn't include WHERE clause when no filters
94+
const query = mockQueryRaw.mock.calls[0][0] as string;
95+
expect(query).not.toContain('WHERE');
96+
});
97+
98+
it('should return empty array when prisma is not initialized', async () => {
99+
const newService = AgentEventService.getInstance(999);
100+
// Don't initialize - prisma will be null
101+
102+
const results = await newService.getTimeBucketStats({
103+
interval: '1 hour',
104+
projectId: 1,
105+
});
106+
107+
expect(results).toEqual([]);
108+
await newService.dispose();
109+
});
110+
});
111+
112+
describe('getHourlyStats', () => {
113+
it('should query continuous aggregate for hourly stats', async () => {
114+
await service.initialize();
115+
116+
const mockQueryRaw = vi.fn().mockResolvedValue([
117+
{
118+
bucket: new Date('2025-11-01T12:00:00Z'),
119+
project_id: 1,
120+
agent_id: 'github-copilot',
121+
event_type: 'file_write',
122+
event_count: BigInt(50),
123+
avg_duration: 1500.5,
124+
},
125+
]);
126+
127+
if (service['prisma']) {
128+
service['prisma'].$queryRawUnsafe = mockQueryRaw;
129+
}
130+
131+
const results = await service.getHourlyStats(
132+
1,
133+
'github-copilot',
134+
new Date('2025-11-01T00:00:00Z'),
135+
new Date('2025-11-02T00:00:00Z'),
136+
);
137+
138+
// Verify query targets continuous aggregate
139+
const query = mockQueryRaw.mock.calls[0][0] as string;
140+
expect(query).toContain('agent_events_hourly');
141+
expect(query).toContain('WHERE');
142+
expect(query).toContain('ORDER BY bucket DESC');
143+
144+
// Verify results
145+
expect(results).toHaveLength(1);
146+
expect(results[0]).toMatchObject({
147+
bucket: expect.any(Date),
148+
projectId: 1,
149+
agentId: 'github-copilot',
150+
eventCount: 50,
151+
});
152+
});
153+
154+
it('should fallback to getTimeBucketStats when continuous aggregate fails', async () => {
155+
await service.initialize();
156+
157+
// Mock continuous aggregate query to fail
158+
const mockQueryRaw = vi
159+
.fn()
160+
.mockRejectedValue(new Error('relation "agent_events_hourly" does not exist'));
161+
162+
if (service['prisma']) {
163+
service['prisma'].$queryRawUnsafe = mockQueryRaw;
164+
}
165+
166+
// Spy on getTimeBucketStats to verify fallback
167+
const getTimeBucketStatsSpy = vi.spyOn(service, 'getTimeBucketStats').mockResolvedValue([]);
168+
169+
await service.getHourlyStats(1);
170+
171+
// Verify fallback was called
172+
expect(getTimeBucketStatsSpy).toHaveBeenCalledWith(
173+
expect.objectContaining({
174+
interval: '1 hour',
175+
projectId: 1,
176+
}),
177+
);
178+
});
179+
});
180+
181+
describe('getDailyStats', () => {
182+
it('should query continuous aggregate for daily stats', async () => {
183+
await service.initialize();
184+
185+
const mockQueryRaw = vi.fn().mockResolvedValue([
186+
{
187+
bucket: new Date('2025-11-01T00:00:00Z'),
188+
project_id: 1,
189+
agent_id: 'github-copilot',
190+
event_count: BigInt(1000),
191+
session_count: BigInt(25),
192+
avg_prompt_tokens: 800.5,
193+
avg_response_tokens: 400.2,
194+
total_duration: BigInt(36000000),
195+
},
196+
]);
197+
198+
if (service['prisma']) {
199+
service['prisma'].$queryRawUnsafe = mockQueryRaw;
200+
}
201+
202+
const results = await service.getDailyStats(
203+
1,
204+
'github-copilot',
205+
new Date('2025-11-01T00:00:00Z'),
206+
new Date('2025-11-30T00:00:00Z'),
207+
);
208+
209+
// Verify query targets daily continuous aggregate
210+
const query = mockQueryRaw.mock.calls[0][0] as string;
211+
expect(query).toContain('agent_events_daily');
212+
expect(query).toContain('WHERE');
213+
214+
// Verify results
215+
expect(results).toHaveLength(1);
216+
expect(results[0]).toMatchObject({
217+
bucket: expect.any(Date),
218+
projectId: 1,
219+
agentId: 'github-copilot',
220+
eventCount: 1000,
221+
});
222+
});
223+
224+
it('should handle date range filters correctly', async () => {
225+
await service.initialize();
226+
227+
const mockQueryRaw = vi.fn().mockResolvedValue([]);
228+
if (service['prisma']) {
229+
service['prisma'].$queryRawUnsafe = mockQueryRaw;
230+
}
231+
232+
const startDate = new Date('2025-10-01T00:00:00Z');
233+
const endDate = new Date('2025-10-31T00:00:00Z');
234+
235+
await service.getDailyStats(1, undefined, startDate, endDate);
236+
237+
// Verify parameters include date range
238+
const params = mockQueryRaw.mock.calls[0].slice(1);
239+
expect(params).toContain(startDate);
240+
expect(params).toContain(endDate);
241+
});
242+
243+
it('should fallback to getTimeBucketStats when continuous aggregate fails', async () => {
244+
await service.initialize();
245+
246+
const mockQueryRaw = vi
247+
.fn()
248+
.mockRejectedValue(new Error('relation "agent_events_daily" does not exist'));
249+
250+
if (service['prisma']) {
251+
service['prisma'].$queryRawUnsafe = mockQueryRaw;
252+
}
253+
254+
const getTimeBucketStatsSpy = vi.spyOn(service, 'getTimeBucketStats').mockResolvedValue([]);
255+
256+
await service.getDailyStats(1);
257+
258+
expect(getTimeBucketStatsSpy).toHaveBeenCalledWith(
259+
expect.objectContaining({
260+
interval: '1 day',
261+
projectId: 1,
262+
}),
263+
);
264+
});
265+
});
266+
267+
describe('SQL query parameter handling', () => {
268+
it('should properly escape and parameterize SQL queries', async () => {
269+
await service.initialize();
270+
271+
const mockQueryRaw = vi.fn().mockResolvedValue([]);
272+
if (service['prisma']) {
273+
service['prisma'].$queryRawUnsafe = mockQueryRaw;
274+
}
275+
276+
await service.getTimeBucketStats({
277+
interval: '1 hour',
278+
projectId: 1,
279+
agentId: 'github-copilot',
280+
eventType: 'file_write',
281+
});
282+
283+
// Verify parameterized query (no raw values in SQL string)
284+
const query = mockQueryRaw.mock.calls[0][0] as string;
285+
expect(query).toContain('$1');
286+
expect(query).toContain('$2');
287+
expect(query).not.toContain('github-copilot'); // Should be parameterized
288+
expect(query).not.toContain('file_write'); // Should be parameterized
289+
});
290+
});
291+
292+
describe('result mapping', () => {
293+
it('should properly convert BigInt to Number in results', async () => {
294+
await service.initialize();
295+
296+
const mockQueryRaw = vi.fn().mockResolvedValue([
297+
{
298+
bucket: new Date('2025-11-01T12:00:00Z'),
299+
project_id: 1,
300+
agent_id: 'test-agent',
301+
event_count: BigInt(9999999999), // Large BigInt
302+
avg_duration: null, // Test null handling
303+
total_tokens: BigInt(0), // Test zero
304+
},
305+
]);
306+
307+
if (service['prisma']) {
308+
service['prisma'].$queryRawUnsafe = mockQueryRaw;
309+
}
310+
311+
const results = await service.getTimeBucketStats({
312+
interval: '1 hour',
313+
projectId: 1,
314+
});
315+
316+
expect(results[0].eventCount).toBe(9999999999);
317+
expect(results[0].avgDuration).toBeUndefined();
318+
expect(results[0].totalTokens).toBe(0);
319+
});
320+
});
321+
});

0 commit comments

Comments
 (0)