Skip to content

Commit 7c4bc14

Browse files
committed
feat(core): implement MetricsStore with SQLite persistence
Phase 1.2-1.3: Core metrics infrastructure - Created MetricsStore class with CRUD operations - Implemented SQLite schema with WAL mode for concurrency - Added Zod schemas for snapshot query validation - Comprehensive test coverage (25 tests, all passing) Features: - recordSnapshot(): Store index/update snapshots - getSnapshots(): Query with filters (time, repo, trigger) - getLatestSnapshot(): Retrieve most recent snapshot - pruneOldSnapshots(): Retention policy enforcement - Kero logger integration (optional) Database optimizations: - WAL mode for concurrent reads/writes - Denormalized fields for fast queries - Indexes on timestamp, repository, trigger Next: Event bus integration for automatic persistence
1 parent 225850c commit 7c4bc14

6 files changed

Lines changed: 688 additions & 0 deletions

File tree

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './git';
77
export * from './github';
88
export * from './indexer';
99
export * from './map';
10+
export * from './metrics';
1011
export * from './observability';
1112
export * from './scanner';
1213
export * from './storage';
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
/**
2+
* Tests for MetricsStore
3+
*/
4+
5+
import * as fs from 'node:fs';
6+
import * as os from 'node:os';
7+
import * as path from 'node:path';
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9+
import { createDetailedIndexStats } from '../../indexer/__tests__/test-factories.js';
10+
import { MetricsStore } from '../store.js';
11+
12+
describe('MetricsStore', () => {
13+
let tempDbPath: string;
14+
let store: MetricsStore;
15+
16+
beforeEach(() => {
17+
// Create temp database path
18+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'metrics-test-'));
19+
tempDbPath = path.join(tempDir, 'test-metrics.db');
20+
store = new MetricsStore(tempDbPath);
21+
});
22+
23+
afterEach(() => {
24+
// Clean up
25+
store.close();
26+
if (fs.existsSync(tempDbPath)) {
27+
fs.unlinkSync(tempDbPath);
28+
const tempDir = path.dirname(tempDbPath);
29+
fs.rmSync(tempDir, { recursive: true });
30+
}
31+
});
32+
33+
describe('recordSnapshot', () => {
34+
it('should record a snapshot successfully', () => {
35+
const stats = createDetailedIndexStats({
36+
repositoryPath: '/test/repo',
37+
filesScanned: 10,
38+
documentsIndexed: 20,
39+
vectorsStored: 20,
40+
duration: 1000,
41+
});
42+
43+
const id = store.recordSnapshot(stats, 'index');
44+
45+
expect(id).toBeTruthy();
46+
expect(typeof id).toBe('string');
47+
});
48+
49+
it('should generate unique IDs for each snapshot', () => {
50+
const stats = createDetailedIndexStats();
51+
52+
const id1 = store.recordSnapshot(stats, 'index');
53+
const id2 = store.recordSnapshot(stats, 'update');
54+
55+
expect(id1).not.toBe(id2);
56+
});
57+
58+
it('should store both index and update triggers', () => {
59+
const stats = createDetailedIndexStats();
60+
61+
const indexId = store.recordSnapshot(stats, 'index');
62+
const updateId = store.recordSnapshot(stats, 'update');
63+
64+
const indexSnapshot = store.getSnapshot(indexId);
65+
const updateSnapshot = store.getSnapshot(updateId);
66+
67+
expect(indexSnapshot?.trigger).toBe('index');
68+
expect(updateSnapshot?.trigger).toBe('update');
69+
});
70+
});
71+
72+
describe('getSnapshot', () => {
73+
it('should retrieve a snapshot by ID', () => {
74+
const stats = createDetailedIndexStats({
75+
repositoryPath: '/test/repo',
76+
filesScanned: 10,
77+
documentsIndexed: 20,
78+
});
79+
80+
const id = store.recordSnapshot(stats, 'index');
81+
const snapshot = store.getSnapshot(id);
82+
83+
expect(snapshot).toBeTruthy();
84+
expect(snapshot?.id).toBe(id);
85+
expect(snapshot?.repositoryPath).toBe('/test/repo');
86+
expect(snapshot?.stats.filesScanned).toBe(10);
87+
expect(snapshot?.stats.documentsIndexed).toBe(20);
88+
expect(snapshot?.trigger).toBe('index');
89+
});
90+
91+
it('should return null for non-existent ID', () => {
92+
const snapshot = store.getSnapshot('non-existent-id');
93+
expect(snapshot).toBeNull();
94+
});
95+
});
96+
97+
describe('getSnapshots', () => {
98+
beforeEach(() => {
99+
// Seed with multiple snapshots
100+
const repo1 = '/test/repo1';
101+
const repo2 = '/test/repo2';
102+
103+
store.recordSnapshot(createDetailedIndexStats({ repositoryPath: repo1 }), 'index');
104+
store.recordSnapshot(createDetailedIndexStats({ repositoryPath: repo1 }), 'update');
105+
store.recordSnapshot(createDetailedIndexStats({ repositoryPath: repo2 }), 'index');
106+
});
107+
108+
it('should retrieve all snapshots with default limit', () => {
109+
const snapshots = store.getSnapshots({});
110+
expect(snapshots.length).toBe(3);
111+
});
112+
113+
it('should filter by repository path', () => {
114+
const snapshots = store.getSnapshots({ repositoryPath: '/test/repo1' });
115+
expect(snapshots.length).toBe(2);
116+
expect(snapshots.every((s) => s.repositoryPath === '/test/repo1')).toBe(true);
117+
});
118+
119+
it('should filter by trigger type', () => {
120+
const snapshots = store.getSnapshots({ trigger: 'index' });
121+
expect(snapshots.length).toBe(2);
122+
expect(snapshots.every((s) => s.trigger === 'index')).toBe(true);
123+
});
124+
125+
it('should respect limit parameter', () => {
126+
const snapshots = store.getSnapshots({ limit: 2 });
127+
expect(snapshots.length).toBe(2);
128+
});
129+
130+
it('should return snapshots in descending timestamp order', () => {
131+
const snapshots = store.getSnapshots({});
132+
expect(snapshots.length).toBeGreaterThan(1);
133+
134+
for (let i = 1; i < snapshots.length; i++) {
135+
expect(snapshots[i - 1].timestamp.getTime()).toBeGreaterThanOrEqual(
136+
snapshots[i].timestamp.getTime()
137+
);
138+
}
139+
});
140+
141+
it('should filter by since date', () => {
142+
const now = new Date();
143+
const oneHourAgo = new Date(now.getTime() - 3600000);
144+
145+
const snapshots = store.getSnapshots({ since: oneHourAgo });
146+
expect(snapshots.length).toBeGreaterThan(0);
147+
});
148+
149+
it('should filter by until date', () => {
150+
const futureDate = new Date(Date.now() + 3600000);
151+
const snapshots = store.getSnapshots({ until: futureDate });
152+
expect(snapshots.length).toBe(3);
153+
});
154+
});
155+
156+
describe('getLatestSnapshot', () => {
157+
it('should return the most recent snapshot', async () => {
158+
const stats1 = createDetailedIndexStats({ filesScanned: 10 });
159+
const stats2 = createDetailedIndexStats({ filesScanned: 20 });
160+
161+
store.recordSnapshot(stats1, 'index');
162+
// Wait 1ms to ensure different timestamps
163+
await new Promise((resolve) => setTimeout(resolve, 1));
164+
const latestId = store.recordSnapshot(stats2, 'update');
165+
166+
const latest = store.getLatestSnapshot();
167+
expect(latest?.id).toBe(latestId);
168+
expect(latest?.stats.filesScanned).toBe(20);
169+
});
170+
171+
it('should filter by repository path', async () => {
172+
store.recordSnapshot(createDetailedIndexStats({ repositoryPath: '/repo1' }), 'index');
173+
// Wait 1ms to ensure different timestamps
174+
await new Promise((resolve) => setTimeout(resolve, 1));
175+
const repo2Id = store.recordSnapshot(
176+
createDetailedIndexStats({ repositoryPath: '/repo2' }),
177+
'index'
178+
);
179+
180+
const latest = store.getLatestSnapshot('/repo2');
181+
expect(latest?.id).toBe(repo2Id);
182+
});
183+
184+
it('should return null when no snapshots exist', () => {
185+
const latest = store.getLatestSnapshot();
186+
expect(latest).toBeNull();
187+
});
188+
});
189+
190+
describe('getCount', () => {
191+
it('should return correct count of all snapshots', () => {
192+
store.recordSnapshot(createDetailedIndexStats(), 'index');
193+
store.recordSnapshot(createDetailedIndexStats(), 'update');
194+
store.recordSnapshot(createDetailedIndexStats(), 'index');
195+
196+
expect(store.getCount()).toBe(3);
197+
});
198+
199+
it('should return correct count filtered by repository path', () => {
200+
store.recordSnapshot(createDetailedIndexStats({ repositoryPath: '/repo1' }), 'index');
201+
store.recordSnapshot(createDetailedIndexStats({ repositoryPath: '/repo1' }), 'update');
202+
store.recordSnapshot(createDetailedIndexStats({ repositoryPath: '/repo2' }), 'index');
203+
204+
expect(store.getCount('/repo1')).toBe(2);
205+
expect(store.getCount('/repo2')).toBe(1);
206+
});
207+
208+
it('should return 0 for empty database', () => {
209+
expect(store.getCount()).toBe(0);
210+
});
211+
});
212+
213+
describe('pruneOldSnapshots', () => {
214+
it('should delete snapshots older than retention period', async () => {
215+
// Record a snapshot
216+
store.recordSnapshot(createDetailedIndexStats(), 'index');
217+
218+
// Wait 2ms to ensure the snapshot is in the past
219+
await new Promise((resolve) => setTimeout(resolve, 2));
220+
221+
// Prune snapshots older than 0 days (should delete all)
222+
const deleted = store.pruneOldSnapshots(0);
223+
expect(deleted).toBeGreaterThan(0);
224+
expect(store.getCount()).toBe(0);
225+
});
226+
227+
it('should not delete recent snapshots', () => {
228+
store.recordSnapshot(createDetailedIndexStats(), 'index');
229+
store.recordSnapshot(createDetailedIndexStats(), 'update');
230+
231+
// Prune snapshots older than 90 days (should delete none)
232+
const deleted = store.pruneOldSnapshots(90);
233+
expect(deleted).toBe(0);
234+
expect(store.getCount()).toBe(2);
235+
});
236+
237+
it('should return 0 when no snapshots to prune', () => {
238+
const deleted = store.pruneOldSnapshots(30);
239+
expect(deleted).toBe(0);
240+
});
241+
});
242+
243+
describe('close', () => {
244+
it('should close database without error', () => {
245+
expect(() => store.close()).not.toThrow();
246+
});
247+
248+
it('should not throw when closed multiple times', () => {
249+
store.close();
250+
expect(() => store.close()).not.toThrow();
251+
});
252+
});
253+
254+
describe('logger integration', () => {
255+
it('should work without a logger', () => {
256+
const storeWithoutLogger = new MetricsStore(tempDbPath);
257+
const stats = createDetailedIndexStats();
258+
259+
expect(() => {
260+
storeWithoutLogger.recordSnapshot(stats, 'index');
261+
}).not.toThrow();
262+
263+
storeWithoutLogger.close();
264+
});
265+
266+
it('should call logger methods when provided', () => {
267+
const mockLogger = {
268+
trace: vi.fn(),
269+
debug: vi.fn(),
270+
info: vi.fn(),
271+
warn: vi.fn(),
272+
error: vi.fn(),
273+
success: vi.fn(),
274+
fatal: vi.fn(),
275+
child: vi.fn(),
276+
startTimer: vi.fn(),
277+
isLevelEnabled: vi.fn(),
278+
level: 'info' as const,
279+
};
280+
281+
const tempDbPath2 = path.join(path.dirname(tempDbPath), 'test-metrics-2.db');
282+
const storeWithLogger = new MetricsStore(tempDbPath2, mockLogger);
283+
const stats = createDetailedIndexStats();
284+
285+
storeWithLogger.recordSnapshot(stats, 'index');
286+
287+
expect(mockLogger.info).toHaveBeenCalled();
288+
expect(mockLogger.debug).toHaveBeenCalled();
289+
290+
storeWithLogger.close();
291+
fs.unlinkSync(tempDbPath2);
292+
});
293+
});
294+
});

packages/core/src/metrics/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Metrics Module
3+
*
4+
* Provides persistent storage for repository metrics and snapshots.
5+
*/
6+
7+
export { initializeDatabase, METRICS_SCHEMA_V1 } from './schema.js';
8+
export { MetricsStore } from './store.js';
9+
export type {
10+
MetricsConfig,
11+
Snapshot,
12+
SnapshotQuery,
13+
} from './types.js';
14+
export {
15+
DEFAULT_METRICS_CONFIG,
16+
SnapshotQuerySchema,
17+
} from './types.js';
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Metrics Database Schema
3+
*
4+
* SQLite schema definitions for metrics storage.
5+
*/
6+
7+
import type Database from 'better-sqlite3';
8+
9+
/**
10+
* Schema version 1: Core snapshots table
11+
*
12+
* Design philosophy:
13+
* - Single table for MVP (snapshots)
14+
* - JSON storage for flexibility (no schema migrations needed)
15+
* - Denormalized fields for fast queries
16+
* - Future tables can be added without breaking this
17+
*/
18+
export const METRICS_SCHEMA_V1 = `
19+
-- Core snapshots table
20+
CREATE TABLE IF NOT EXISTS snapshots (
21+
id TEXT PRIMARY KEY,
22+
timestamp INTEGER NOT NULL,
23+
repository_path TEXT NOT NULL,
24+
stats TEXT NOT NULL, -- JSON serialized DetailedIndexStats
25+
26+
-- Denormalized for fast queries (avoid parsing JSON)
27+
trigger TEXT CHECK(trigger IN ('index', 'update')),
28+
total_files INTEGER,
29+
total_documents INTEGER,
30+
total_vectors INTEGER,
31+
duration_ms INTEGER,
32+
33+
created_at INTEGER NOT NULL
34+
);
35+
36+
-- Index for time-based queries (most common)
37+
CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp
38+
ON snapshots(timestamp DESC);
39+
40+
-- Index for repository-specific queries
41+
CREATE INDEX IF NOT EXISTS idx_snapshots_repo
42+
ON snapshots(repository_path, timestamp DESC);
43+
44+
-- Index for filtering by trigger type
45+
CREATE INDEX IF NOT EXISTS idx_snapshots_trigger
46+
ON snapshots(trigger, timestamp DESC);
47+
`;
48+
49+
/**
50+
* Initialize database with schema and optimizations
51+
*/
52+
export function initializeDatabase(db: Database.Database): void {
53+
// Enable WAL (Write-Ahead Logging) mode for better concurrency
54+
// This allows readers and writers to operate concurrently
55+
db.pragma('journal_mode = WAL');
56+
57+
// Use NORMAL synchronous mode for better performance
58+
// Still safe with WAL mode enabled
59+
db.pragma('synchronous = NORMAL');
60+
61+
// Enable foreign keys
62+
db.pragma('foreign_keys = ON');
63+
64+
// Create schema
65+
db.exec(METRICS_SCHEMA_V1);
66+
}

0 commit comments

Comments
 (0)