Skip to content

Commit f8fda31

Browse files
authored
Merge pull request #7 from prosdevlab/feat/sqlite-layer
feat(storage): implement SQLite database layer with tests
2 parents 0f85f74 + fd715ae commit f8fda31

File tree

18 files changed

+1655
-4
lines changed

18 files changed

+1655
-4
lines changed

.husky/pre-commit

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pnpm lint-staged
2+
pnpm lint
23
pnpm typecheck

WORKFLOW.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,64 @@ Issue: #<number>
206206
- **Breaking Changes**: API changes
207207
- **Known Limitations**: What doesn't work yet
208208

209+
## Commit Structure Preference
210+
211+
### Atomic Commits (Always)
212+
213+
**Every commit should be atomic:**
214+
- ✅ Complete logical unit of work
215+
- ✅ Can be reviewed independently
216+
- ✅ Can be reverted without breaking things
217+
- ✅ Tests pass at each commit
218+
- ✅ Working state maintained
219+
220+
**Commit frequently, but keep commits atomic.**
221+
222+
### Single Commit for Cohesive Features (Default)
223+
224+
**For cohesive features, use a single atomic commit:**
225+
226+
```
227+
feat(scope): implement feature with tests
228+
229+
- Implementation details
230+
- Tests included
231+
- All related changes together
232+
```
233+
234+
**When to use:**
235+
- ✅ New features/modules (e.g., new package)
236+
- ✅ Cohesive changes (< 1000 lines)
237+
- ✅ Implementation + tests belong together
238+
- ✅ Single logical unit of work
239+
240+
**Rationale:**
241+
- Easier to review as one unit
242+
- Tests validate implementation immediately
243+
- Cleaner git history
244+
- Simpler to revert if needed
245+
246+
### Multiple Commits (When Appropriate)
247+
248+
**Split into multiple atomic commits when:**
249+
- 🔀 Multiple unrelated concerns (e.g., DB + API + UI)
250+
- 🔀 Large features (1000+ lines) that benefit from incremental review
251+
- 🔀 Risky changes needing staged rollout
252+
- 🔀 Tests require significant refactoring separate from implementation
253+
254+
**Example split:**
255+
```
256+
feat(module): implement core functionality
257+
feat(module): add comprehensive test suite
258+
```
259+
260+
**Avoid splitting for:**
261+
- ❌ Small cohesive features
262+
- ❌ Implementation + tests (they belong together)
263+
- ❌ Artificial separation (e.g., "code" vs "tests")
264+
265+
**Key Principle:** Atomic ≠ Small. Atomic = Complete logical unit. A cohesive feature is one atomic unit.
266+
209267
## Testing Standards
210268

211269
### Coverage Goals

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@biomejs/biome": "2.3.8",
4747
"@tsconfig/node-lts": "^24.0.0",
4848
"@types/node": "^24.10.1",
49+
"@vitest/coverage-v8": "^4.0.15",
4950
"lint-staged": "16.2.7",
5051
"typescript": "^5.9.3",
5152
"vitest": "^4.0.15"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { CoreService, createCoreService } from './index';
2+
import { CoreService, createCoreService } from '../index';
33

44
describe('CoreService', () => {
55
it('should create a CoreService instance', () => {

packages/storage/drizzle.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'drizzle-kit';
2+
3+
export default defineConfig({
4+
schema: './src/schema.ts',
5+
out: './drizzle',
6+
dialect: 'sqlite',
7+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
CREATE TABLE `documents` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`path` text NOT NULL,
4+
`hash` text,
5+
`status` text DEFAULT 'pending' NOT NULL,
6+
`data` text NOT NULL,
7+
`created_at` integer DEFAULT '"2025-12-07T11:19:23.284Z"' NOT NULL
8+
);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{
2+
"version": "6",
3+
"dialect": "sqlite",
4+
"id": "6eba05ff-3086-4f83-bc86-506b17db1c03",
5+
"prevId": "00000000-0000-0000-0000-000000000000",
6+
"tables": {
7+
"documents": {
8+
"name": "documents",
9+
"columns": {
10+
"id": {
11+
"name": "id",
12+
"type": "text",
13+
"primaryKey": true,
14+
"notNull": true,
15+
"autoincrement": false
16+
},
17+
"path": {
18+
"name": "path",
19+
"type": "text",
20+
"primaryKey": false,
21+
"notNull": true,
22+
"autoincrement": false
23+
},
24+
"hash": {
25+
"name": "hash",
26+
"type": "text",
27+
"primaryKey": false,
28+
"notNull": false,
29+
"autoincrement": false
30+
},
31+
"status": {
32+
"name": "status",
33+
"type": "text",
34+
"primaryKey": false,
35+
"notNull": true,
36+
"autoincrement": false,
37+
"default": "'pending'"
38+
},
39+
"data": {
40+
"name": "data",
41+
"type": "text",
42+
"primaryKey": false,
43+
"notNull": true,
44+
"autoincrement": false
45+
},
46+
"created_at": {
47+
"name": "created_at",
48+
"type": "integer",
49+
"primaryKey": false,
50+
"notNull": true,
51+
"autoincrement": false,
52+
"default": "'\"2025-12-07T11:19:23.284Z\"'"
53+
}
54+
},
55+
"indexes": {},
56+
"foreignKeys": {},
57+
"compositePrimaryKeys": {},
58+
"uniqueConstraints": {},
59+
"checkConstraints": {}
60+
}
61+
},
62+
"views": {},
63+
"enums": {},
64+
"_meta": {
65+
"schemas": {},
66+
"tables": {},
67+
"columns": {}
68+
},
69+
"internal": {
70+
"indexes": {}
71+
}
72+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"version": "7",
3+
"dialect": "sqlite",
4+
"entries": [
5+
{
6+
"idx": 0,
7+
"version": "6",
8+
"when": 1765106363288,
9+
"tag": "0000_blue_legion",
10+
"breakpoints": true
11+
}
12+
]
13+
}

packages/storage/package.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "@doc-agent/storage",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"private": true,
6+
"main": "./dist/index.js",
7+
"types": "./dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.js",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsc",
17+
"dev": "tsc --watch",
18+
"clean": "rm -rf dist",
19+
"typecheck": "tsc --noEmit",
20+
"lint": "biome lint ./src",
21+
"format": "biome format --write ./src",
22+
"test": "vitest run",
23+
"generate": "drizzle-kit generate",
24+
"migrate": "drizzle-kit migrate"
25+
},
26+
"dependencies": {
27+
"@doc-agent/core": "workspace:*",
28+
"@lytics/kero": "^1.0.0",
29+
"better-sqlite3": "^11.6.0",
30+
"drizzle-orm": "^0.36.4",
31+
"env-paths": "^3.0.0",
32+
"zod": "^3.23.8"
33+
},
34+
"devDependencies": {
35+
"@types/better-sqlite3": "^7.6.12",
36+
"@types/node": "^22.10.1",
37+
"drizzle-kit": "^0.28.1",
38+
"typescript": "^5.9.3"
39+
}
40+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import * as fs from 'node:fs';
2+
import os from 'node:os';
3+
import * as path from 'node:path';
4+
import Database from 'better-sqlite3';
5+
import { drizzle } from 'drizzle-orm/better-sqlite3';
6+
import { beforeEach, describe, expect, it, vi } from 'vitest';
7+
import { createDb, ensureDirectoryExists, getDbPath, runMigrations } from '../db.js';
8+
9+
// Mock the migrator module
10+
vi.mock('drizzle-orm/better-sqlite3/migrator', () => ({
11+
migrate: vi.fn(),
12+
}));
13+
14+
// Mock the logger using hoisted function
15+
const mockError = vi.hoisted(() => vi.fn());
16+
vi.mock('@lytics/kero', () => ({
17+
default: {
18+
createLogger: () => ({
19+
error: mockError,
20+
warn: vi.fn(),
21+
info: vi.fn(),
22+
debug: vi.fn(),
23+
}),
24+
},
25+
}));
26+
27+
describe('db', () => {
28+
beforeEach(() => {
29+
mockError.mockClear();
30+
});
31+
32+
describe('ensureDirectoryExists', () => {
33+
it('should create directory if it does not exist', () => {
34+
const tempDir = path.join(os.tmpdir(), `doc-agent-test-${Date.now()}`);
35+
expect(fs.existsSync(tempDir)).toBe(false);
36+
37+
ensureDirectoryExists(tempDir);
38+
39+
expect(fs.existsSync(tempDir)).toBe(true);
40+
41+
// Cleanup
42+
fs.rmdirSync(tempDir);
43+
});
44+
45+
it('should not fail if directory already exists', () => {
46+
const tempDir = path.join(os.tmpdir(), `doc-agent-test-${Date.now()}`);
47+
fs.mkdirSync(tempDir, { recursive: true });
48+
49+
expect(() => ensureDirectoryExists(tempDir)).not.toThrow();
50+
expect(fs.existsSync(tempDir)).toBe(true);
51+
52+
// Cleanup
53+
fs.rmdirSync(tempDir);
54+
});
55+
});
56+
57+
describe('getDbPath', () => {
58+
it('should return a valid database path', () => {
59+
const dbPath = getDbPath();
60+
expect(dbPath).toBeTruthy();
61+
expect(dbPath).toContain('doc-agent.db');
62+
expect(typeof dbPath).toBe('string');
63+
});
64+
65+
it('should accept custom data directory', () => {
66+
const customDir = path.join(os.tmpdir(), `doc-agent-custom-${Date.now()}`);
67+
const dbPath = getDbPath(customDir);
68+
69+
expect(dbPath).toContain('doc-agent.db');
70+
expect(dbPath).toContain(customDir);
71+
expect(fs.existsSync(customDir)).toBe(true);
72+
73+
// Cleanup
74+
fs.rmdirSync(customDir);
75+
});
76+
});
77+
78+
describe('runMigrations', () => {
79+
it('should handle migration failures gracefully', async () => {
80+
const { migrate } = await import('drizzle-orm/better-sqlite3/migrator');
81+
const db = drizzle(new Database(':memory:'), { schema: {} });
82+
83+
// Create a temp directory that exists but has no valid migrations
84+
const tempMigrationsDir = path.join(os.tmpdir(), `migrations-test-${Date.now()}`);
85+
fs.mkdirSync(tempMigrationsDir, { recursive: true });
86+
87+
// Make migrate throw an error
88+
vi.mocked(migrate).mockImplementation(() => {
89+
throw new Error('Test migration failure');
90+
});
91+
92+
// This should not throw, just log the error
93+
expect(() => runMigrations(db, tempMigrationsDir)).not.toThrow();
94+
expect(mockError).toHaveBeenCalledWith(expect.any(Error), 'Migration failed');
95+
96+
// Cleanup
97+
fs.rmdirSync(tempMigrationsDir);
98+
});
99+
100+
it('should skip migrations if folder does not exist', () => {
101+
const db = drizzle(new Database(':memory:'), { schema: {} });
102+
const nonExistentDir = path.join(os.tmpdir(), `non-existent-${Date.now()}`);
103+
104+
// Should not throw
105+
expect(() => runMigrations(db, nonExistentDir)).not.toThrow();
106+
});
107+
});
108+
109+
describe('createDb', () => {
110+
it('should accept custom connection string', () => {
111+
const db = createDb(':memory:');
112+
expect(db).toBeDefined();
113+
});
114+
115+
it('should use default path when no connection string provided', () => {
116+
const db = createDb();
117+
expect(db).toBeDefined();
118+
});
119+
});
120+
});

0 commit comments

Comments
 (0)