hzl-core 2.3.0 → 2.4.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 (100) hide show
  1. package/dist/__tests__/concurrency/stress.test.js +13 -9
  2. package/dist/__tests__/concurrency/stress.test.js.map +1 -1
  3. package/dist/__tests__/properties/invariants.test.js +31 -10
  4. package/dist/__tests__/properties/invariants.test.js.map +1 -1
  5. package/dist/db/__tests__/transaction.test.d.ts +2 -0
  6. package/dist/db/__tests__/transaction.test.d.ts.map +1 -0
  7. package/dist/db/__tests__/transaction.test.js +67 -0
  8. package/dist/db/__tests__/transaction.test.js.map +1 -0
  9. package/dist/db/migrations/index.d.ts +8 -3
  10. package/dist/db/migrations/index.d.ts.map +1 -1
  11. package/dist/db/migrations/index.js +14 -3
  12. package/dist/db/migrations/index.js.map +1 -1
  13. package/dist/db/migrations/index.test.d.ts +2 -0
  14. package/dist/db/migrations/index.test.d.ts.map +1 -0
  15. package/dist/db/migrations/index.test.js +53 -0
  16. package/dist/db/migrations/index.test.js.map +1 -0
  17. package/dist/db/schema.d.ts +2 -2
  18. package/dist/db/schema.d.ts.map +1 -1
  19. package/dist/db/schema.js +3 -1
  20. package/dist/db/schema.js.map +1 -1
  21. package/dist/db/transaction.d.ts.map +1 -1
  22. package/dist/db/transaction.js +16 -4
  23. package/dist/db/transaction.js.map +1 -1
  24. package/dist/events/store.d.ts +3 -1
  25. package/dist/events/store.d.ts.map +1 -1
  26. package/dist/events/store.js +23 -9
  27. package/dist/events/store.js.map +1 -1
  28. package/dist/events/store.test.js +49 -2
  29. package/dist/events/store.test.js.map +1 -1
  30. package/dist/events/types.d.ts +1 -0
  31. package/dist/events/types.d.ts.map +1 -1
  32. package/dist/events/types.js +1 -0
  33. package/dist/events/types.js.map +1 -1
  34. package/dist/events/upcasters.d.ts +16 -0
  35. package/dist/events/upcasters.d.ts.map +1 -0
  36. package/dist/events/upcasters.js +35 -0
  37. package/dist/events/upcasters.js.map +1 -0
  38. package/dist/events/upcasters.test.d.ts +2 -0
  39. package/dist/events/upcasters.test.d.ts.map +1 -0
  40. package/dist/events/upcasters.test.js +47 -0
  41. package/dist/events/upcasters.test.js.map +1 -0
  42. package/dist/index.d.ts +2 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +2 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/index.test.js +0 -1
  47. package/dist/index.test.js.map +1 -1
  48. package/dist/projections/comments-checkpoints.d.ts +2 -2
  49. package/dist/projections/comments-checkpoints.d.ts.map +1 -1
  50. package/dist/projections/comments-checkpoints.js +4 -3
  51. package/dist/projections/comments-checkpoints.js.map +1 -1
  52. package/dist/projections/comments-checkpoints.test.js +30 -0
  53. package/dist/projections/comments-checkpoints.test.js.map +1 -1
  54. package/dist/projections/dependencies.d.ts +2 -2
  55. package/dist/projections/dependencies.d.ts.map +1 -1
  56. package/dist/projections/dependencies.js +5 -4
  57. package/dist/projections/dependencies.js.map +1 -1
  58. package/dist/projections/dependencies.test.js +29 -0
  59. package/dist/projections/dependencies.test.js.map +1 -1
  60. package/dist/projections/projects.d.ts +2 -2
  61. package/dist/projections/projects.d.ts.map +1 -1
  62. package/dist/projections/projects.js +8 -9
  63. package/dist/projections/projects.js.map +1 -1
  64. package/dist/projections/projects.test.js +34 -0
  65. package/dist/projections/projects.test.js.map +1 -1
  66. package/dist/projections/search.d.ts +2 -2
  67. package/dist/projections/search.d.ts.map +1 -1
  68. package/dist/projections/search.js +15 -12
  69. package/dist/projections/search.js.map +1 -1
  70. package/dist/projections/search.test.js +37 -0
  71. package/dist/projections/search.test.js.map +1 -1
  72. package/dist/projections/tags.d.ts +2 -2
  73. package/dist/projections/tags.d.ts.map +1 -1
  74. package/dist/projections/tags.js +4 -3
  75. package/dist/projections/tags.js.map +1 -1
  76. package/dist/projections/tags.test.js +29 -0
  77. package/dist/projections/tags.test.js.map +1 -1
  78. package/dist/projections/tasks-current.d.ts +2 -2
  79. package/dist/projections/tasks-current.d.ts.map +1 -1
  80. package/dist/projections/tasks-current.js +19 -14
  81. package/dist/projections/tasks-current.js.map +1 -1
  82. package/dist/projections/tasks-current.test.js +87 -0
  83. package/dist/projections/tasks-current.test.js.map +1 -1
  84. package/dist/projections/types.d.ts +14 -0
  85. package/dist/projections/types.d.ts.map +1 -1
  86. package/dist/projections/types.js +23 -1
  87. package/dist/projections/types.js.map +1 -1
  88. package/dist/services/search-service.js +1 -1
  89. package/dist/services/search-service.js.map +1 -1
  90. package/dist/services/search-service.test.js +23 -0
  91. package/dist/services/search-service.test.js.map +1 -1
  92. package/dist/services/task-service.d.ts +29 -4
  93. package/dist/services/task-service.d.ts.map +1 -1
  94. package/dist/services/task-service.js +272 -69
  95. package/dist/services/task-service.js.map +1 -1
  96. package/dist/services/task-service.test.js +313 -15
  97. package/dist/services/task-service.test.js.map +1 -1
  98. package/dist/services/workflow-service.test.js +525 -161
  99. package/dist/services/workflow-service.test.js.map +1 -1
  100. package/package.json +1 -1
@@ -1,10 +1,15 @@
1
1
  // packages/hzl-core/src/services/task-service.test.ts
2
2
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
- import { TaskService, AmbiguousPrefixError, InvalidDueMonthError, InvalidProgressError, InvalidStatusTransitionError, } from './task-service.js';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import Database from 'libsql';
7
+ import { TaskService, AmbiguousPrefixError, InvalidDueMonthError, InvalidProgressError, InvalidStatusTransitionError, TaskNotFoundError, } from './task-service.js';
4
8
  import { ProjectService, ProjectNotFoundError } from './project-service.js';
5
9
  import { createTestDb } from '../db/test-utils.js';
6
10
  import { EventStore } from '../events/store.js';
7
11
  import { EventType, TaskStatus } from '../events/types.js';
12
+ import { CACHE_SCHEMA_V1, EVENTS_SCHEMA_V2, PRAGMAS } from '../db/schema.js';
8
13
  import { ProjectionEngine } from '../projections/engine.js';
9
14
  import { TasksCurrentProjector } from '../projections/tasks-current.js';
10
15
  import { DependenciesProjector } from '../projections/dependencies.js';
@@ -12,6 +17,14 @@ import { TagsProjector } from '../projections/tags.js';
12
17
  import { CommentsCheckpointsProjector } from '../projections/comments-checkpoints.js';
13
18
  import { SearchProjector } from '../projections/search.js';
14
19
  import { ProjectsProjector } from '../projections/projects.js';
20
+ function registerProjectors(engine) {
21
+ engine.register(new TasksCurrentProjector());
22
+ engine.register(new DependenciesProjector());
23
+ engine.register(new TagsProjector());
24
+ engine.register(new CommentsCheckpointsProjector());
25
+ engine.register(new SearchProjector());
26
+ engine.register(new ProjectsProjector());
27
+ }
15
28
  describe('TaskService', () => {
16
29
  let db;
17
30
  let eventStore;
@@ -23,12 +36,7 @@ describe('TaskService', () => {
23
36
  // Schema applied by createTestDb
24
37
  eventStore = new EventStore(db);
25
38
  projectionEngine = new ProjectionEngine(db);
26
- projectionEngine.register(new TasksCurrentProjector());
27
- projectionEngine.register(new DependenciesProjector());
28
- projectionEngine.register(new TagsProjector());
29
- projectionEngine.register(new CommentsCheckpointsProjector());
30
- projectionEngine.register(new SearchProjector());
31
- projectionEngine.register(new ProjectsProjector());
39
+ registerProjectors(projectionEngine);
32
40
  projectService = new ProjectService(db, eventStore, projectionEngine);
33
41
  projectService.ensureInboxExists();
34
42
  projectService.createProject('project-a');
@@ -264,6 +272,60 @@ describe('TaskService', () => {
264
272
  expect(moved.project).toBe('target');
265
273
  });
266
274
  });
275
+ describe('setStatus transition rules', () => {
276
+ const allStatuses = Object.values(TaskStatus);
277
+ const createTaskInStatus = (status) => {
278
+ const task = taskService.createTask({ title: `Status ${status}`, project: 'inbox' });
279
+ switch (status) {
280
+ case TaskStatus.Backlog:
281
+ return task;
282
+ case TaskStatus.Ready:
283
+ return taskService.setStatus(task.task_id, TaskStatus.Ready);
284
+ case TaskStatus.InProgress:
285
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
286
+ return taskService.claimTask(task.task_id, { author: 'agent-1' });
287
+ case TaskStatus.Blocked:
288
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
289
+ taskService.claimTask(task.task_id, { author: 'agent-1' });
290
+ return taskService.blockTask(task.task_id);
291
+ case TaskStatus.Done:
292
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
293
+ taskService.claimTask(task.task_id, { author: 'agent-1' });
294
+ return taskService.completeTask(task.task_id);
295
+ case TaskStatus.Archived:
296
+ return taskService.archiveTask(task.task_id);
297
+ }
298
+ };
299
+ it('allows any transition except from archived', () => {
300
+ const nonArchived = allStatuses.filter(s => s !== TaskStatus.Archived);
301
+ for (const fromStatus of nonArchived) {
302
+ for (const toStatus of allStatuses) {
303
+ if (fromStatus === toStatus)
304
+ continue; // skip self-transitions (tested separately)
305
+ const task = createTaskInStatus(fromStatus);
306
+ const updated = taskService.setStatus(task.task_id, toStatus);
307
+ expect(updated.status).toBe(toStatus);
308
+ }
309
+ }
310
+ });
311
+ it('throws InvalidStatusTransitionError when transitioning from archived', () => {
312
+ const nonArchived = allStatuses.filter(s => s !== TaskStatus.Archived);
313
+ for (const toStatus of nonArchived) {
314
+ const task = createTaskInStatus(TaskStatus.Archived);
315
+ expect(() => taskService.setStatus(task.task_id, toStatus)).toThrow(InvalidStatusTransitionError);
316
+ }
317
+ });
318
+ it('no-ops on self-transition (returns task, no new event)', () => {
319
+ for (const status of allStatuses) {
320
+ const task = createTaskInStatus(status);
321
+ const eventsBefore = eventStore.getByTaskId(task.task_id);
322
+ const result = taskService.setStatus(task.task_id, status);
323
+ const eventsAfter = eventStore.getByTaskId(task.task_id);
324
+ expect(result.status).toBe(status);
325
+ expect(eventsAfter.length).toBe(eventsBefore.length);
326
+ }
327
+ });
328
+ });
267
329
  describe('claimTask', () => {
268
330
  it('claims a ready task with no dependencies', () => {
269
331
  const task = taskService.createTask({ title: 'Ready task', project: 'inbox' });
@@ -299,19 +361,48 @@ describe('TaskService', () => {
299
361
  const claimed = taskService.claimTask(task.task_id, { lease_until: leaseUntil });
300
362
  expect(claimed.lease_until).toBe(leaseUntil);
301
363
  });
302
- it('throws if task is not in ready status', () => {
364
+ it('claims a task from backlog status', () => {
303
365
  const task = taskService.createTask({ title: 'Backlog task', project: 'inbox' });
366
+ const claimed = taskService.claimTask(task.task_id, { author: 'agent-1' });
367
+ expect(claimed.status).toBe(TaskStatus.InProgress);
368
+ });
369
+ it('claims a task from in_progress status (re-claim)', () => {
370
+ const task = taskService.createTask({ title: 'Test', project: 'inbox' });
371
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
372
+ taskService.claimTask(task.task_id, { author: 'agent-1' });
373
+ const reclaimed = taskService.claimTask(task.task_id, { author: 'agent-2' });
374
+ expect(reclaimed.status).toBe(TaskStatus.InProgress);
375
+ });
376
+ it('claims a task from blocked status', () => {
377
+ const task = taskService.createTask({ title: 'Test', project: 'inbox' });
378
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
379
+ taskService.claimTask(task.task_id, { author: 'agent-1' });
380
+ taskService.blockTask(task.task_id);
381
+ const claimed = taskService.claimTask(task.task_id, { author: 'agent-2' });
382
+ expect(claimed.status).toBe(TaskStatus.InProgress);
383
+ });
384
+ it('throws when claiming a done task', () => {
385
+ const task = taskService.createTask({ title: 'Test', project: 'inbox' });
386
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
387
+ taskService.claimTask(task.task_id);
388
+ taskService.completeTask(task.task_id);
304
389
  expect(() => taskService.claimTask(task.task_id)).toThrow(/not claimable/i);
305
390
  });
306
- it('throws if task has incomplete dependencies', () => {
391
+ it('throws when claiming an archived task', () => {
392
+ const task = taskService.createTask({ title: 'Test', project: 'inbox' });
393
+ taskService.archiveTask(task.task_id);
394
+ expect(() => taskService.claimTask(task.task_id)).toThrow(/not claimable/i);
395
+ });
396
+ it('allows claiming a task with incomplete dependencies', () => {
307
397
  const dep = taskService.createTask({ title: 'Incomplete dep', project: 'inbox' });
308
398
  const task = taskService.createTask({
309
- title: 'Blocked task',
399
+ title: 'Dependent task',
310
400
  project: 'inbox',
311
401
  depends_on: [dep.task_id],
312
402
  });
313
403
  taskService.setStatus(task.task_id, TaskStatus.Ready);
314
- expect(() => taskService.claimTask(task.task_id)).toThrow(/dependencies not done/i);
404
+ const claimed = taskService.claimTask(task.task_id, { author: 'agent-1' });
405
+ expect(claimed.status).toBe(TaskStatus.InProgress);
315
406
  });
316
407
  });
317
408
  describe('claimNext', () => {
@@ -837,6 +928,53 @@ describe('TaskService', () => {
837
928
  expect(() => taskService.listTasks({ dueMonth: 'bad-month' })).toThrow(InvalidDueMonthError);
838
929
  expect(() => taskService.listTasks({ dueMonth: '2026-00' })).toThrow(InvalidDueMonthError);
839
930
  });
931
+ it('listTasks includes tags', () => {
932
+ taskService.createTask({ title: 'Tagged task', project: 'project-a', tags: ['bug', 'urgent'] });
933
+ const tasks = taskService.listTasks({ sinceDays: 7 });
934
+ const found = tasks.find((t) => t.title === 'Tagged task');
935
+ expect(found).toBeDefined();
936
+ expect(found.tags).toEqual(['bug', 'urgent']);
937
+ });
938
+ it('listTasks returns empty tags array for untagged tasks', () => {
939
+ taskService.createTask({ title: 'No tags', project: 'project-a' });
940
+ const tasks = taskService.listTasks({ sinceDays: 7 });
941
+ const found = tasks.find((t) => t.title === 'No tags');
942
+ expect(found).toBeDefined();
943
+ expect(found.tags).toEqual([]);
944
+ });
945
+ it('listTasks filters by tag', () => {
946
+ projectService.createProject('test-project');
947
+ taskService.createTask({ title: 'Bug task', project: 'test-project', tags: ['bug'] });
948
+ taskService.createTask({ title: 'Feature task', project: 'test-project', tags: ['feature'] });
949
+ taskService.createTask({ title: 'Both', project: 'test-project', tags: ['bug', 'feature'] });
950
+ const bugTasks = taskService.listTasks({ sinceDays: 7, tag: 'bug' });
951
+ expect(bugTasks.map((t) => t.title).sort()).toEqual(['Both', 'Bug task']);
952
+ const featureTasks = taskService.listTasks({ sinceDays: 7, tag: 'feature' });
953
+ expect(featureTasks.map((t) => t.title).sort()).toEqual(['Both', 'Feature task']);
954
+ });
955
+ });
956
+ describe('getTagCounts', () => {
957
+ it('getTagCounts returns distinct tags with counts', () => {
958
+ projectService.createProject('test-project');
959
+ taskService.createTask({ title: 'A', project: 'test-project', tags: ['bug', 'urgent'] });
960
+ taskService.createTask({ title: 'B', project: 'test-project', tags: ['bug'] });
961
+ taskService.createTask({ title: 'C', project: 'test-project', tags: ['feature'] });
962
+ const counts = taskService.getTagCounts();
963
+ expect(counts).toEqual([
964
+ { tag: 'bug', count: 2 },
965
+ { tag: 'feature', count: 1 },
966
+ { tag: 'urgent', count: 1 },
967
+ ]);
968
+ });
969
+ it('getTagCounts excludes archived tasks', () => {
970
+ projectService.createProject('test-project');
971
+ const id = taskService.createTask({ title: 'A', project: 'test-project', tags: ['old'] });
972
+ taskService.claimTask(id.task_id);
973
+ taskService.completeTask(id.task_id);
974
+ taskService.archiveTask(id.task_id);
975
+ const counts = taskService.getTagCounts();
976
+ expect(counts.find((c) => c.tag === 'old')).toBeUndefined();
977
+ });
840
978
  });
841
979
  describe('getBlockedByMap', () => {
842
980
  it('returns empty map when no tasks are blocked', () => {
@@ -1024,6 +1162,60 @@ describe('TaskService', () => {
1024
1162
  expect(deps).toEqual([]);
1025
1163
  });
1026
1164
  });
1165
+ describe('updateTask', () => {
1166
+ it('updates multiple fields and persists task_updated events through service layer', () => {
1167
+ const task = taskService.createTask({
1168
+ title: 'Original',
1169
+ project: 'inbox',
1170
+ description: 'old',
1171
+ links: ['a.md'],
1172
+ tags: ['old'],
1173
+ priority: 0,
1174
+ });
1175
+ const updated = taskService.updateTask(task.task_id, {
1176
+ title: 'Updated',
1177
+ description: 'new',
1178
+ links: ['b.md'],
1179
+ tags: ['new'],
1180
+ priority: 2,
1181
+ }, { author: 'agent-x' });
1182
+ expect(updated.title).toBe('Updated');
1183
+ expect(updated.description).toBe('new');
1184
+ expect(updated.links).toEqual(['b.md']);
1185
+ expect(updated.tags).toEqual(['new']);
1186
+ expect(updated.priority).toBe(2);
1187
+ const updateEvents = eventStore
1188
+ .getByTaskId(task.task_id)
1189
+ .filter((event) => event.type === EventType.TaskUpdated);
1190
+ expect(updateEvents).toHaveLength(5);
1191
+ expect(updateEvents.every((event) => event.author === 'agent-x')).toBe(true);
1192
+ });
1193
+ it('skips unchanged values (including arrays)', () => {
1194
+ const task = taskService.createTask({
1195
+ title: 'No change',
1196
+ project: 'inbox',
1197
+ description: 'same',
1198
+ links: ['same.md'],
1199
+ tags: ['same'],
1200
+ priority: 1,
1201
+ });
1202
+ const updated = taskService.updateTask(task.task_id, {
1203
+ title: 'No change',
1204
+ description: 'same',
1205
+ links: ['same.md'],
1206
+ tags: ['same'],
1207
+ priority: 1,
1208
+ });
1209
+ expect(updated.title).toBe('No change');
1210
+ const updateEvents = eventStore
1211
+ .getByTaskId(task.task_id)
1212
+ .filter((event) => event.type === EventType.TaskUpdated);
1213
+ expect(updateEvents).toHaveLength(0);
1214
+ });
1215
+ it('throws TaskNotFoundError for unknown task', () => {
1216
+ expect(() => taskService.updateTask('TASK_UNKNOWN', { title: 'nope' })).toThrow(TaskNotFoundError);
1217
+ });
1218
+ });
1027
1219
  describe('getTaskTitlesByIds', () => {
1028
1220
  it('returns empty map for empty input', () => {
1029
1221
  const map = taskService.getTaskTitlesByIds([]);
@@ -1366,6 +1558,8 @@ describe('TaskService', () => {
1366
1558
  });
1367
1559
  it('sets progress to 100 when setStatus transitions task to done', () => {
1368
1560
  const task = taskService.createTask({ title: 'Test', project: 'inbox' });
1561
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
1562
+ taskService.setStatus(task.task_id, TaskStatus.InProgress);
1369
1563
  taskService.setProgress(task.task_id, 20);
1370
1564
  const completed = taskService.setStatus(task.task_id, TaskStatus.Done);
1371
1565
  expect(completed.status).toBe(TaskStatus.Done);
@@ -1602,9 +1796,19 @@ describe('TaskService', () => {
1602
1796
  taskServiceWithEventsDb = new TaskService(db, eventStore, projectionEngine, projectService, db // eventsDb
1603
1797
  );
1604
1798
  });
1605
- it('throws when eventsDb not provided', () => {
1606
- // taskService doesn't have eventsDb
1607
- expect(() => taskService.pruneEligible({ project: 'inbox', olderThanDays: 1 })).toThrow('eventsDb not provided');
1799
+ it('uses single-DB fallback when eventsDb is not provided', () => {
1800
+ const task = taskService.createTask({ title: 'Single DB prune', project: 'inbox' });
1801
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
1802
+ taskService.claimTask(task.task_id);
1803
+ taskService.completeTask(task.task_id);
1804
+ db.prepare('UPDATE tasks_current SET terminal_at = ? WHERE task_id = ?').run('2020-01-01T00:00:00Z', task.task_id);
1805
+ const result = taskService.pruneEligible({ project: 'inbox', olderThanDays: 30 });
1806
+ expect(result.count).toBe(1);
1807
+ const eventsAfter = db
1808
+ .prepare('SELECT COUNT(*) as cnt FROM events WHERE task_id = ?')
1809
+ .get(task.task_id);
1810
+ expect(eventsAfter.cnt).toBe(0);
1811
+ expect(taskService.getTaskById(task.task_id)).toBeNull();
1608
1812
  });
1609
1813
  it('returns empty result when no eligible tasks', () => {
1610
1814
  const result = taskServiceWithEventsDb.pruneEligible({
@@ -1779,6 +1983,95 @@ describe('TaskService', () => {
1779
1983
  expect(taskServiceWithEventsDb.getTaskById(taskB.task_id)).toBeNull();
1780
1984
  expect(taskServiceWithEventsDb.getTaskById(taskC.task_id)).toBeNull();
1781
1985
  });
1986
+ it('rolls back event deletion when projection deletion fails in split DB attach mode', () => {
1987
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hzl-prune-atomic-'));
1988
+ const cachePath = path.join(tempDir, 'cache.db');
1989
+ const eventsPath = path.join(tempDir, 'events.db');
1990
+ const cacheDb = new Database(cachePath);
1991
+ const eventsDb = new Database(eventsPath);
1992
+ try {
1993
+ cacheDb.exec(PRAGMAS);
1994
+ eventsDb.exec(PRAGMAS);
1995
+ cacheDb.exec(CACHE_SCHEMA_V1);
1996
+ eventsDb.exec(EVENTS_SCHEMA_V2);
1997
+ const splitEventStore = new EventStore(eventsDb);
1998
+ const splitProjectionEngine = new ProjectionEngine(cacheDb, eventsDb);
1999
+ registerProjectors(splitProjectionEngine);
2000
+ const splitProjectService = new ProjectService(cacheDb, splitEventStore, splitProjectionEngine);
2001
+ splitProjectService.ensureInboxExists();
2002
+ const splitTaskService = new TaskService(cacheDb, splitEventStore, splitProjectionEngine, splitProjectService, eventsDb);
2003
+ const task = splitTaskService.createTask({ title: 'Split DB task', project: 'inbox' });
2004
+ splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
2005
+ splitTaskService.claimTask(task.task_id);
2006
+ splitTaskService.completeTask(task.task_id);
2007
+ cacheDb.prepare('UPDATE tasks_current SET terminal_at = ? WHERE task_id = ?').run('2020-01-01T00:00:00Z', task.task_id);
2008
+ const eventsBefore = eventsDb
2009
+ .prepare('SELECT COUNT(*) as cnt FROM events WHERE task_id = ?')
2010
+ .get(task.task_id);
2011
+ expect(eventsBefore.cnt).toBeGreaterThan(0);
2012
+ const originalDeleteProjections = splitTaskService.deleteTasksFromProjections;
2013
+ splitTaskService.deleteTasksFromProjections = () => {
2014
+ throw new Error('simulated projection failure');
2015
+ };
2016
+ expect(() => splitTaskService.pruneEligible({ project: 'inbox', olderThanDays: 30 })).toThrow('simulated projection failure');
2017
+ splitTaskService.deleteTasksFromProjections = originalDeleteProjections;
2018
+ const eventsAfterFailure = eventsDb
2019
+ .prepare('SELECT COUNT(*) as cnt FROM events WHERE task_id = ?')
2020
+ .get(task.task_id);
2021
+ expect(eventsAfterFailure.cnt).toBe(eventsBefore.cnt);
2022
+ expect(cacheDb.prepare('SELECT 1 FROM tasks_current WHERE task_id = ?').get(task.task_id)).toBeDefined();
2023
+ }
2024
+ finally {
2025
+ cacheDb.close();
2026
+ eventsDb.close();
2027
+ fs.rmSync(tempDir, { recursive: true, force: true });
2028
+ }
2029
+ });
2030
+ it('recovers projection cleanup from prune journal when fallback path is interrupted', () => {
2031
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hzl-prune-journal-'));
2032
+ const cachePath = path.join(tempDir, 'cache.db');
2033
+ const cacheDb = new Database(cachePath);
2034
+ const eventsDb = new Database(':memory:');
2035
+ const journalPath = path.join(tempDir, 'prune-journal.json');
2036
+ try {
2037
+ cacheDb.exec(PRAGMAS);
2038
+ eventsDb.exec(PRAGMAS);
2039
+ cacheDb.exec(CACHE_SCHEMA_V1);
2040
+ eventsDb.exec(EVENTS_SCHEMA_V2);
2041
+ const splitEventStore = new EventStore(eventsDb);
2042
+ const splitProjectionEngine = new ProjectionEngine(cacheDb, eventsDb);
2043
+ registerProjectors(splitProjectionEngine);
2044
+ const splitProjectService = new ProjectService(cacheDb, splitEventStore, splitProjectionEngine);
2045
+ splitProjectService.ensureInboxExists();
2046
+ const splitTaskService = new TaskService(cacheDb, splitEventStore, splitProjectionEngine, splitProjectService, eventsDb);
2047
+ const task = splitTaskService.createTask({ title: 'Journal fallback task', project: 'inbox' });
2048
+ splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
2049
+ splitTaskService.claimTask(task.task_id);
2050
+ splitTaskService.completeTask(task.task_id);
2051
+ cacheDb.prepare('UPDATE tasks_current SET terminal_at = ? WHERE task_id = ?').run('2020-01-01T00:00:00Z', task.task_id);
2052
+ const originalDeleteProjections = splitTaskService.deleteTasksFromProjections;
2053
+ splitTaskService.deleteTasksFromProjections = () => {
2054
+ throw new Error('simulated crash after events delete');
2055
+ };
2056
+ expect(() => splitTaskService.pruneEligible({ project: 'inbox', olderThanDays: 30 })).toThrow('simulated crash after events delete');
2057
+ splitTaskService.deleteTasksFromProjections = originalDeleteProjections;
2058
+ const eventsAfterCrash = eventsDb
2059
+ .prepare('SELECT COUNT(*) as cnt FROM events WHERE task_id = ?')
2060
+ .get(task.task_id);
2061
+ expect(eventsAfterCrash.cnt).toBe(0);
2062
+ expect(cacheDb.prepare('SELECT 1 FROM tasks_current WHERE task_id = ?').get(task.task_id)).toBeDefined();
2063
+ expect(fs.existsSync(journalPath)).toBe(true);
2064
+ // Constructor-time recovery should finish projection cleanup.
2065
+ new TaskService(cacheDb, splitEventStore, splitProjectionEngine, splitProjectService, eventsDb);
2066
+ expect(cacheDb.prepare('SELECT 1 FROM tasks_current WHERE task_id = ?').get(task.task_id)).toBeUndefined();
2067
+ expect(fs.existsSync(journalPath)).toBe(false);
2068
+ }
2069
+ finally {
2070
+ cacheDb.close();
2071
+ eventsDb.close();
2072
+ fs.rmSync(tempDir, { recursive: true, force: true });
2073
+ }
2074
+ });
1782
2075
  });
1783
2076
  describe('getBlockedByForTasks', () => {
1784
2077
  it('returns empty map for empty input', () => {
@@ -1835,7 +2128,9 @@ describe('TaskService', () => {
1835
2128
  project: 'inbox',
1836
2129
  depends_on: [dep.task_id],
1837
2130
  });
1838
- // Force task to done status directly (bypassing dep check)
2131
+ // Force task to done status directly via setStatus (bypassing dep check)
2132
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
2133
+ taskService.setStatus(task.task_id, TaskStatus.InProgress);
1839
2134
  taskService.setStatus(task.task_id, TaskStatus.Done);
1840
2135
  const result = taskService.getBlockedByForTasks([task.task_id]);
1841
2136
  expect(result.has(task.task_id)).toBe(false);
@@ -1854,6 +2149,7 @@ describe('TaskService', () => {
1854
2149
  project: 'inbox',
1855
2150
  depends_on: [dep.task_id],
1856
2151
  });
2152
+ taskService.setStatus(inProgressTask.task_id, TaskStatus.Ready);
1857
2153
  taskService.setStatus(inProgressTask.task_id, TaskStatus.InProgress);
1858
2154
  const result = taskService.getBlockedByForTasks([readyTask.task_id, inProgressTask.task_id]);
1859
2155
  expect(result.has(readyTask.task_id)).toBe(true);
@@ -1996,6 +2292,8 @@ describe('TaskService', () => {
1996
2292
  onDone: { url: 'http://127.0.0.1:18789/events/inject' },
1997
2293
  });
1998
2294
  const task = hookTaskService.createTask({ title: 'Set status done', project: 'inbox' });
2295
+ hookTaskService.setStatus(task.task_id, TaskStatus.Ready);
2296
+ hookTaskService.setStatus(task.task_id, TaskStatus.InProgress);
1999
2297
  hookTaskService.setStatus(task.task_id, TaskStatus.Done);
2000
2298
  const count = db.prepare('SELECT COUNT(*) as count FROM hook_outbox WHERE payload LIKE ?')
2001
2299
  .get(`%"task_id":"${task.task_id}"%`).count;