@vanshbhardwaj/worklog 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.
Files changed (68) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +28 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +19 -0
  4. package/.github/workflows/release.yml +63 -0
  5. package/.github/workflows/validate.yml +103 -0
  6. package/.prettierrc +8 -0
  7. package/ARCHITECTURE.md +82 -0
  8. package/CODE_OF_CONDUCT.md +65 -0
  9. package/CONTRIBUTING.md +74 -0
  10. package/INSTALLATION.md +98 -0
  11. package/LICENSE +21 -0
  12. package/README.md +246 -0
  13. package/SECURITY.md +18 -0
  14. package/benchmarks/benchmark.ts +149 -0
  15. package/benchmarks/vitest.bench.config.ts +9 -0
  16. package/dist/database/migrations/001_initial.sql +60 -0
  17. package/dist/database/migrations/002_relational_tables.sql +22 -0
  18. package/dist/index.cjs +31007 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1873 -0
  22. package/dist/index.js.map +1 -0
  23. package/eslint.config.js +38 -0
  24. package/package.json +49 -0
  25. package/pnpm-workspace.yaml +6 -0
  26. package/scripts/build-bin.js +75 -0
  27. package/sea-config.json +5 -0
  28. package/src/cli/commands/add.ts +176 -0
  29. package/src/cli/commands/continue.ts +80 -0
  30. package/src/cli/commands/dashboard.ts +30 -0
  31. package/src/cli/commands/export.ts +72 -0
  32. package/src/cli/commands/init.ts +52 -0
  33. package/src/cli/commands/list.ts +33 -0
  34. package/src/cli/commands/search.ts +33 -0
  35. package/src/cli/commands/show.ts +42 -0
  36. package/src/cli/commands/stats.ts +23 -0
  37. package/src/cli/commands/today.ts +27 -0
  38. package/src/cli/commands/week.ts +93 -0
  39. package/src/cli/index.ts +294 -0
  40. package/src/cli/ui.ts +323 -0
  41. package/src/core/config.ts +69 -0
  42. package/src/core/discovery.ts +38 -0
  43. package/src/core/logger.ts +53 -0
  44. package/src/database/connection.ts +95 -0
  45. package/src/database/migration-data.ts +71 -0
  46. package/src/database/migrations/001_initial.sql +60 -0
  47. package/src/database/migrations/002_relational_tables.sql +22 -0
  48. package/src/database/migrations.ts +65 -0
  49. package/src/errors/index.ts +51 -0
  50. package/src/exporters/csv.ts +59 -0
  51. package/src/exporters/json.ts +8 -0
  52. package/src/exporters/markdown.ts +71 -0
  53. package/src/models/work-unit.ts +38 -0
  54. package/src/repositories/work-unit.ts +466 -0
  55. package/src/services/work-unit.ts +131 -0
  56. package/src/tests/cli/__snapshots__/cli-productivity.test.ts.snap +37 -0
  57. package/src/tests/cli/__snapshots__/cli.test.ts.snap +20 -0
  58. package/src/tests/cli/cli-productivity.test.ts +167 -0
  59. package/src/tests/cli/cli.test.ts +171 -0
  60. package/src/tests/core/config.test.ts +53 -0
  61. package/src/tests/core/discovery.test.ts +46 -0
  62. package/src/tests/database/migrations.test.ts +75 -0
  63. package/src/tests/repositories/work-unit.test.ts +243 -0
  64. package/src/tests/services/work-unit.test.ts +87 -0
  65. package/src/validators/work-unit.ts +63 -0
  66. package/tsconfig.json +16 -0
  67. package/tsup.config.ts +50 -0
  68. package/vitest.config.ts +8 -0
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { execSync } from 'child_process';
5
+
6
+ interface CLIResult {
7
+ stdout: string;
8
+ stderr: string;
9
+ exitCode: number;
10
+ }
11
+
12
+ function runCLI(args: string[], cwd: string): CLIResult {
13
+ const cliPath = path.resolve(process.cwd(), 'dist', 'index.js');
14
+ try {
15
+ const stdout = execSync(`node ${cliPath} ${args.join(' ')}`, {
16
+ cwd,
17
+ stdio: 'pipe',
18
+ });
19
+ return {
20
+ stdout: stdout.toString(),
21
+ stderr: '',
22
+ exitCode: 0,
23
+ };
24
+ } catch (error: any) {
25
+ return {
26
+ stdout: error.stdout ? error.stdout.toString() : '',
27
+ stderr: error.stderr ? error.stderr.toString() : '',
28
+ exitCode: error.status !== undefined ? error.status : 1,
29
+ };
30
+ }
31
+ }
32
+
33
+ function sanitizeStdout(stdout: string): string {
34
+ return stdout
35
+ .replace(/(Created|Updated):\s+.*/g, (_, label) => `${label}: [DATE]`)
36
+ .replace(/\b[A-Za-z]{3}\s+\d{1,2}\b/g, '[DATE]')
37
+ .replace(/\d{4}-\d{2}-\d{2}/g, '[DATE]')
38
+ .replace(/\d{1,2}:\d{2}\s+(AM|PM)/g, '[TIME]');
39
+ }
40
+
41
+ describe('WorkLog CLI Productivity Commands', () => {
42
+ const tempProdDir = path.join(process.cwd(), 'src', 'tests', 'temp-prod-test');
43
+
44
+ beforeAll(() => {
45
+ if (fs.existsSync(tempProdDir)) {
46
+ fs.rmSync(tempProdDir, { recursive: true, force: true });
47
+ }
48
+ fs.mkdirSync(tempProdDir, { recursive: true });
49
+ runCLI(['init'], tempProdDir);
50
+ });
51
+
52
+ afterAll(() => {
53
+ if (fs.existsSync(tempProdDir)) {
54
+ fs.rmSync(tempProdDir, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ it('should run all commands successfully on empty repository', () => {
59
+ expect(runCLI(['today'], tempProdDir).stdout.trim()).toBe('No work units logged today.');
60
+ expect(runCLI(['week'], tempProdDir).stdout.trim()).toBe(
61
+ 'No work units logged in the past 7 days.',
62
+ );
63
+ expect(runCLI(['dashboard'], tempProdDir).exitCode).toBe(0);
64
+ expect(runCLI(['continue'], tempProdDir).stdout.trim()).toBe('No unfinished work units found.');
65
+ expect(runCLI(['stats'], tempProdDir).exitCode).toBe(0);
66
+ expect(runCLI(['export'], tempProdDir).stdout.trim()).toBe('No work units found.');
67
+ });
68
+
69
+ it('should support Unicode characters and long text', () => {
70
+ const unicodeTitle = 'Unicode test ☕ 汉字';
71
+ const res = runCLI(
72
+ ['add', '--title', `"${unicodeTitle}"`, '--type', 'idea', '--status', 'planned'],
73
+ tempProdDir,
74
+ );
75
+ expect(res.exitCode).toBe(0);
76
+
77
+ const showRes = runCLI(['show', '1', '--json'], tempProdDir);
78
+ const unit = JSON.parse(showRes.stdout);
79
+ expect(unit.title).toBe(unicodeTitle);
80
+ });
81
+
82
+ it('should perform advanced search by tags and status', () => {
83
+ runCLI(
84
+ [
85
+ 'add',
86
+ '--title',
87
+ '"Tags task"',
88
+ '--type',
89
+ 'bug',
90
+ '--status',
91
+ 'blocked',
92
+ '--tags',
93
+ 'frontend,issue-9',
94
+ '--module',
95
+ 'billing',
96
+ ],
97
+ tempProdDir,
98
+ );
99
+
100
+ // Search tag
101
+ const searchTag = runCLI(['search', '--tag', 'frontend', '--json'], tempProdDir);
102
+ const results = JSON.parse(searchTag.stdout);
103
+ expect(results.length).toBe(1);
104
+ expect(results[0].title).toBe('Tags task');
105
+
106
+ // Combine FTS text + tag filter
107
+ const searchCombined = runCLI(['search', 'Tags', '--status', 'blocked', '--json'], tempProdDir);
108
+ const results2 = JSON.parse(searchCombined.stdout);
109
+ expect(results2.length).toBe(1);
110
+ });
111
+
112
+ it('should run continue command and resume last unfinished unit', () => {
113
+ // We have unit #1 (Planned) and unit #2 (Blocked)
114
+ // Run continue (will choose unit 2 since it was updated last)
115
+ const res = runCLI(['continue', '--json'], tempProdDir);
116
+ expect(res.exitCode).toBe(0);
117
+ const data = JSON.parse(res.stdout);
118
+ expect(data.id).toBe(2);
119
+ expect(data.status).toBe('In Progress');
120
+ });
121
+
122
+ it('should display dashboard (snapshot)', () => {
123
+ const res = runCLI(['dashboard'], tempProdDir);
124
+ expect(res.exitCode).toBe(0);
125
+ expect(sanitizeStdout(res.stdout)).toMatchSnapshot();
126
+ });
127
+
128
+ it('should display stats summary (snapshot)', () => {
129
+ const res = runCLI(['stats'], tempProdDir);
130
+ expect(res.exitCode).toBe(0);
131
+ expect(sanitizeStdout(res.stdout)).toMatchSnapshot();
132
+ });
133
+
134
+ it('should export all to markdown and JSON file', () => {
135
+ const mdPath = path.join(tempProdDir, 'export.md');
136
+ const resMd = runCLI(
137
+ ['export', '--format', 'markdown', '--output', `"${mdPath}"`],
138
+ tempProdDir,
139
+ );
140
+ expect(resMd.exitCode).toBe(0);
141
+ expect(fs.existsSync(mdPath)).toBe(true);
142
+ expect(fs.readFileSync(mdPath, 'utf8')).toContain('# WorkLog Export');
143
+
144
+ const singleJSON = runCLI(['export', '--id', '1', '--format', 'json'], tempProdDir);
145
+ expect(singleJSON.exitCode).toBe(0);
146
+ const data = JSON.parse(singleJSON.stdout);
147
+ expect(data.id).toBe(1);
148
+ });
149
+
150
+ it('should throw exit code 1 on invalid export path', () => {
151
+ const res = runCLI(
152
+ ['export', '--format', 'markdown', '--output', '"/non/existent/path/export.md"'],
153
+ tempProdDir,
154
+ );
155
+ expect(res.exitCode).toBe(1);
156
+ expect(res.stderr).toContain('Failed to write to file');
157
+ });
158
+
159
+ it('should handle database corruption/unexpected failures gracefully with exit code 2', () => {
160
+ const dbPath = path.join(tempProdDir, '.worklog', 'worklog.db');
161
+ fs.writeFileSync(dbPath, 'completely corrupted database text', 'utf8');
162
+
163
+ const res = runCLI(['list'], tempProdDir);
164
+ expect(res.exitCode).toBe(2);
165
+ expect(res.stderr).toContain('Unexpected Error');
166
+ });
167
+ });
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { execSync } from 'child_process';
5
+
6
+ interface CLIResult {
7
+ stdout: string;
8
+ stderr: string;
9
+ exitCode: number;
10
+ }
11
+
12
+ function runCLI(args: string[], cwd: string): CLIResult {
13
+ const cliPath = path.resolve(process.cwd(), 'dist', 'index.js');
14
+ try {
15
+ const stdout = execSync(`node ${cliPath} ${args.join(' ')}`, {
16
+ cwd,
17
+ stdio: 'pipe',
18
+ });
19
+ return {
20
+ stdout: stdout.toString(),
21
+ stderr: '',
22
+ exitCode: 0,
23
+ };
24
+ } catch (error: any) {
25
+ return {
26
+ stdout: error.stdout ? error.stdout.toString() : '',
27
+ stderr: error.stderr ? error.stderr.toString() : '',
28
+ exitCode: error.status !== undefined ? error.status : 1,
29
+ };
30
+ }
31
+ }
32
+
33
+ function sanitizeStdout(stdout: string): string {
34
+ return stdout
35
+ .replace(/(Created|Updated):\s+.*/g, (_, label) => `${label}: [DATE]`)
36
+ .replace(/\b[A-Za-z]{3}\s+\d{1,2}\b/g, '[DATE]');
37
+ }
38
+
39
+ describe('WorkLog CLI Integration', () => {
40
+ const tempCLIDir = path.join(process.cwd(), 'src', 'tests', 'temp-cli-test');
41
+
42
+ beforeAll(() => {
43
+ if (fs.existsSync(tempCLIDir)) {
44
+ fs.rmSync(tempCLIDir, { recursive: true, force: true });
45
+ }
46
+ fs.mkdirSync(tempCLIDir, { recursive: true });
47
+ });
48
+
49
+ afterAll(() => {
50
+ if (fs.existsSync(tempCLIDir)) {
51
+ fs.rmSync(tempCLIDir, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ it('should fail when running list outside a initialized repository', () => {
56
+ const res = runCLI(['list'], tempCLIDir);
57
+ expect(res.exitCode).toBe(1);
58
+ expect(res.stderr).toContain('Not a WorkLog repository');
59
+ });
60
+
61
+ it('should initialize a empty repository successfully', () => {
62
+ const res = runCLI(['init'], tempCLIDir);
63
+ expect(res.exitCode).toBe(0);
64
+ expect(res.stdout).toContain('Initialized empty WorkLog repository');
65
+
66
+ // Check files are created
67
+ expect(fs.existsSync(path.join(tempCLIDir, '.worklog'))).toBe(true);
68
+ expect(fs.existsSync(path.join(tempCLIDir, '.worklog', 'config.json'))).toBe(true);
69
+ expect(fs.existsSync(path.join(tempCLIDir, '.worklog', 'worklog.db'))).toBe(true);
70
+ });
71
+
72
+ it('should list empty results when database is empty', () => {
73
+ const res = runCLI(['list'], tempCLIDir);
74
+ expect(res.exitCode).toBe(0);
75
+ expect(res.stdout.trim()).toBe('No work units found.');
76
+
77
+ const resJson = runCLI(['list', '--json'], tempCLIDir);
78
+ expect(resJson.exitCode).toBe(0);
79
+ expect(JSON.parse(resJson.stdout)).toEqual([]);
80
+ });
81
+
82
+ it('should create a work unit with flags', () => {
83
+ const res = runCLI(
84
+ [
85
+ 'add',
86
+ '--title',
87
+ '"First Task"',
88
+ '--type',
89
+ 'feature',
90
+ '--status',
91
+ 'planned',
92
+ '--module',
93
+ 'auth',
94
+ '--tags',
95
+ 'login,security',
96
+ ],
97
+ tempCLIDir,
98
+ );
99
+ expect(res.exitCode).toBe(0);
100
+ expect(res.stdout).toContain('Created work unit #1: First Task');
101
+ });
102
+
103
+ it('should support json flag on add', () => {
104
+ const res = runCLI(
105
+ ['add', '--title', '"Second Task"', '--type', 'bug', '--status', 'in-progress', '--json'],
106
+ tempCLIDir,
107
+ );
108
+ expect(res.exitCode).toBe(0);
109
+ const data = JSON.parse(res.stdout);
110
+ expect(data.id).toBe(2);
111
+ expect(data.title).toBe('Second Task');
112
+ expect(data.type).toBe('Bug');
113
+ expect(data.status).toBe('In Progress');
114
+ });
115
+
116
+ it('should output validation errors cleanly and exit with 1', () => {
117
+ // Title too long
118
+ const longTitle = 'a'.repeat(250);
119
+ const res = runCLI(['add', '--title', `"${longTitle}"`], tempCLIDir);
120
+ expect(res.exitCode).toBe(1);
121
+ expect(res.stderr).toContain('Title cannot exceed 200 characters');
122
+ });
123
+
124
+ it('should list work units in a beautiful table format (snapshot)', () => {
125
+ const res = runCLI(['list'], tempCLIDir);
126
+ expect(res.exitCode).toBe(0);
127
+ // Snapshot checking of the table representation
128
+ expect(sanitizeStdout(res.stdout)).toMatchSnapshot();
129
+ });
130
+
131
+ it('should list work units in JSON format', () => {
132
+ const res = runCLI(['list', '--json'], tempCLIDir);
133
+ expect(res.exitCode).toBe(0);
134
+ const data = JSON.parse(res.stdout);
135
+ expect(data.length).toBe(2);
136
+ expect(data[0].id).toBe(2);
137
+ expect(data[1].id).toBe(1);
138
+ });
139
+
140
+ it('should show card details for a specific unit (snapshot)', () => {
141
+ const res = runCLI(['show', '1'], tempCLIDir);
142
+ expect(res.exitCode).toBe(0);
143
+ expect(sanitizeStdout(res.stdout)).toMatchSnapshot();
144
+ });
145
+
146
+ it('should show JSON details for a specific unit', () => {
147
+ const res = runCLI(['show', '2', '--json'], tempCLIDir);
148
+ expect(res.exitCode).toBe(0);
149
+ const data = JSON.parse(res.stdout);
150
+ expect(data.id).toBe(2);
151
+ expect(data.title).toBe('Second Task');
152
+ });
153
+
154
+ it('should show error and exit with 1 if unit does not exist', () => {
155
+ const res = runCLI(['show', '999'], tempCLIDir);
156
+ expect(res.exitCode).toBe(1);
157
+ expect(res.stderr).toContain('Work unit #999 not found');
158
+ });
159
+
160
+ it('should handle unknown command routing', () => {
161
+ const res = runCLI(['hello-unknown'], tempCLIDir);
162
+ expect(res.exitCode).toBe(1);
163
+ expect(res.stderr).toContain('Unknown command: hello-unknown');
164
+ });
165
+
166
+ it('should cancel add if title is missing in non-TTY mode', () => {
167
+ // Since title is missing and stdin is closed in runCLI subprocess, it should exit with cancel code 1
168
+ const res = runCLI(['add'], tempCLIDir);
169
+ expect(res.exitCode).toBe(1);
170
+ });
171
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { loadConfig, saveConfig, initConfig } from '../../core/config.js';
5
+ import { ConfigError } from '../../errors/index.js';
6
+
7
+ describe('config', () => {
8
+ const tempTestDir = path.join(process.cwd(), 'src', 'tests', 'temp-config-test');
9
+
10
+ beforeAll(() => {
11
+ if (fs.existsSync(tempTestDir)) {
12
+ fs.rmSync(tempTestDir, { recursive: true, force: true });
13
+ }
14
+ fs.mkdirSync(tempTestDir, { recursive: true });
15
+ });
16
+
17
+ afterAll(() => {
18
+ if (fs.existsSync(tempTestDir)) {
19
+ fs.rmSync(tempTestDir, { recursive: true, force: true });
20
+ }
21
+ });
22
+
23
+ it('should initialize config with default values', () => {
24
+ const config = initConfig(tempTestDir, 'test-project');
25
+ expect(config.projectName).toBe('test-project');
26
+ expect(config.schemaVersion).toBe(1);
27
+ expect(config.createdAt).toBeDefined();
28
+
29
+ // Verify it wrote the file
30
+ const fileExists = fs.existsSync(path.join(tempTestDir, '.worklog', 'config.json'));
31
+ expect(fileExists).toBe(true);
32
+ });
33
+
34
+ it('should load config that has been saved', () => {
35
+ const loaded = loadConfig(tempTestDir);
36
+ expect(loaded.projectName).toBe('test-project');
37
+ });
38
+
39
+ it('should throw ConfigError if config is missing', () => {
40
+ const emptyDir = path.join(tempTestDir, 'empty');
41
+ fs.mkdirSync(emptyDir, { recursive: true });
42
+ expect(() => loadConfig(emptyDir)).toThrow(ConfigError);
43
+ });
44
+
45
+ it('should throw ConfigError if config format is invalid', () => {
46
+ const invalidDir = path.join(tempTestDir, 'invalid');
47
+ const dotWorklog = path.join(invalidDir, '.worklog');
48
+ fs.mkdirSync(dotWorklog, { recursive: true });
49
+ fs.writeFileSync(path.join(dotWorklog, 'config.json'), '{"invalid": "json"}', 'utf8');
50
+
51
+ expect(() => loadConfig(invalidDir)).toThrow(ConfigError);
52
+ });
53
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { findWorkspaceRoot, getWorkspaceRoot } from '../../core/discovery.js';
5
+ import { NotInitializedError } from '../../errors/index.js';
6
+
7
+ describe('discovery', () => {
8
+ const tempTestDir = path.join(process.cwd(), 'src', 'tests', 'temp-discovery-test');
9
+
10
+ beforeAll(() => {
11
+ if (fs.existsSync(tempTestDir)) {
12
+ fs.rmSync(tempTestDir, { recursive: true, force: true });
13
+ }
14
+ fs.mkdirSync(tempTestDir, { recursive: true });
15
+ });
16
+
17
+ afterAll(() => {
18
+ if (fs.existsSync(tempTestDir)) {
19
+ fs.rmSync(tempTestDir, { recursive: true, force: true });
20
+ }
21
+ });
22
+
23
+ it('should return null when .worklog is not present in parent chain', () => {
24
+ const subDir = path.join(tempTestDir, 'a', 'b', 'c');
25
+ fs.mkdirSync(subDir, { recursive: true });
26
+ expect(findWorkspaceRoot(subDir)).toBeNull();
27
+ });
28
+
29
+ it('should find the .worklog root when it exists in a parent directory', () => {
30
+ const worklogRoot = path.join(tempTestDir, 'project-root');
31
+ const dotWorklog = path.join(worklogRoot, '.worklog');
32
+ const subDir = path.join(worklogRoot, 'src', 'core');
33
+
34
+ fs.mkdirSync(dotWorklog, { recursive: true });
35
+ fs.mkdirSync(subDir, { recursive: true });
36
+
37
+ const discovered = findWorkspaceRoot(subDir);
38
+ expect(discovered).toBe(worklogRoot);
39
+ });
40
+
41
+ it('should throw NotInitializedError if not initialized', () => {
42
+ const nonExistentDir = path.join(tempTestDir, 'non-existent');
43
+ fs.mkdirSync(nonExistentDir, { recursive: true });
44
+ expect(() => getWorkspaceRoot(nonExistentDir)).toThrow(NotInitializedError);
45
+ });
46
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import Database from 'better-sqlite3';
5
+ import { runMigrations } from '../../database/migrations.js';
6
+
7
+ describe('migrations', () => {
8
+ const tempTestDir = path.join(process.cwd(), 'src', 'tests', 'temp-migrations-test');
9
+ let db: Database.Database;
10
+
11
+ beforeAll(() => {
12
+ if (fs.existsSync(tempTestDir)) {
13
+ fs.rmSync(tempTestDir, { recursive: true, force: true });
14
+ }
15
+ fs.mkdirSync(tempTestDir, { recursive: true });
16
+
17
+ const dbPath = path.join(tempTestDir, 'test.db');
18
+ db = new Database(dbPath);
19
+ });
20
+
21
+ afterAll(() => {
22
+ if (db) {
23
+ db.close();
24
+ }
25
+ if (fs.existsSync(tempTestDir)) {
26
+ fs.rmSync(tempTestDir, { recursive: true, force: true });
27
+ }
28
+ });
29
+
30
+ it('should run migrations successfully and create tables', () => {
31
+ runMigrations(db);
32
+
33
+ // Verify migration tracking table has records for both files
34
+ const migrations = db.prepare('SELECT version FROM schema_migrations ORDER BY version ASC').all();
35
+ expect(migrations.length).toBe(2);
36
+ expect(migrations[0]).toEqual({ version: '001_initial.sql' });
37
+ expect(migrations[1]).toEqual({ version: '002_relational_tables.sql' });
38
+
39
+ // Verify work_units table exists and we can insert a row
40
+ const insertStmt = db.prepare(`
41
+ INSERT INTO work_units (title, type, status, created_at, updated_at)
42
+ VALUES (?, ?, ?, ?, ?)
43
+ `);
44
+ const info = insertStmt.run(
45
+ 'Test Work Unit',
46
+ 'Feature',
47
+ 'In Progress',
48
+ new Date().toISOString(),
49
+ new Date().toISOString(),
50
+ );
51
+ expect(info.changes).toBe(1);
52
+ const workUnitId = info.lastInsertRowid;
53
+
54
+ // Verify work_unit_tags table exists and works
55
+ const tagInsert = db.prepare('INSERT INTO work_unit_tags (work_unit_id, tag) VALUES (?, ?)');
56
+ const tagInfo = tagInsert.run(workUnitId, 'cli');
57
+ expect(tagInfo.changes).toBe(1);
58
+
59
+ // Verify work_unit_relations table exists and works
60
+ const relInsert = db.prepare('INSERT INTO work_unit_relations (work_unit_id, related_work_unit_id) VALUES (?, ?)');
61
+ const relInfo = relInsert.run(workUnitId, workUnitId); // self-referencing relationship for testing
62
+ expect(relInfo.changes).toBe(1);
63
+
64
+ // Verify FTS table matches and can be queried
65
+ const ftsResults = db.prepare(`
66
+ SELECT rowid, title FROM work_units_fts WHERE work_units_fts MATCH 'Test'
67
+ `).all();
68
+ expect(ftsResults.length).toBe(1);
69
+ expect((ftsResults[0] as any).title).toBe('Test Work Unit');
70
+ });
71
+
72
+ it('should not run migrations twice if already applied', () => {
73
+ expect(() => runMigrations(db)).not.toThrow();
74
+ });
75
+ });