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,3 +1,5 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { EventType, TaskStatus, } from '../events/types.js';
2
4
  import { withWriteTransaction } from '../db/transaction.js';
3
5
  import { generateId } from '../utils/id.js';
@@ -11,11 +13,6 @@ export class TaskNotClaimableError extends Error {
11
13
  super(`Task ${taskId} is not claimable: ${reason}`);
12
14
  }
13
15
  }
14
- export class DependenciesNotDoneError extends Error {
15
- constructor(taskId, pendingDeps) {
16
- super(`Task ${taskId} has dependencies not done: ${pendingDeps.join(', ')}`);
17
- }
18
- }
19
16
  export class AmbiguousPrefixError extends Error {
20
17
  matches;
21
18
  constructor(prefix, matches) {
@@ -55,18 +52,26 @@ function validateProgress(progress) {
55
52
  throw new InvalidProgressError();
56
53
  }
57
54
  }
55
+ function arraysEqual(left, right) {
56
+ if (left.length !== right.length) {
57
+ return false;
58
+ }
59
+ return left.every((value, index) => value === right[index]);
60
+ }
58
61
  export class TaskService {
59
62
  db;
60
63
  eventStore;
61
64
  projectionEngine;
62
65
  projectService;
63
66
  eventsDb;
64
- getIncompleteDepsStmt;
67
+ static PRUNE_JOURNAL_FILENAME = 'prune-journal.json';
68
+ static ATTACHED_EVENTS_SCHEMA = 'events_src';
65
69
  getSubtasksStmt;
66
70
  getTaskByIdStmt;
67
71
  resolveTaskIdStmt;
68
72
  hookOutboxAvailable = null;
69
73
  onDoneHook;
74
+ pruneJournalPath;
70
75
  constructor(db, // cache database
71
76
  eventStore, projectionEngine, projectService, eventsDb, // events database for pruning
72
77
  options) {
@@ -76,13 +81,7 @@ export class TaskService {
76
81
  this.projectService = projectService;
77
82
  this.eventsDb = eventsDb;
78
83
  this.onDoneHook = options?.onDone;
79
- this.getIncompleteDepsStmt = db.prepare(`
80
- SELECT td.depends_on_id
81
- FROM task_dependencies td
82
- LEFT JOIN tasks_current tc ON tc.task_id = td.depends_on_id
83
- WHERE td.task_id = ?
84
- AND (tc.status IS NULL OR tc.status != 'done')
85
- `);
84
+ this.pruneJournalPath = this.resolvePruneJournalPath(options?.pruneJournalPath);
86
85
  this.getSubtasksStmt = db.prepare(`
87
86
  SELECT task_id, title, project, status, parent_id, description,
88
87
  links, tags, priority, due_at, metadata,
@@ -103,6 +102,7 @@ export class TaskService {
103
102
  this.resolveTaskIdStmt = db.prepare(`
104
103
  SELECT task_id, title FROM tasks_current WHERE task_id LIKE ? || '%' ESCAPE '\\'
105
104
  `);
105
+ this.recoverPruneJournalIfNeeded();
106
106
  }
107
107
  emitComment(taskId, text, ctx) {
108
108
  // Validate comment text - same validation as addComment
@@ -316,15 +316,11 @@ export class TaskService {
316
316
  const task = this.getTaskById(taskId);
317
317
  if (!task)
318
318
  throw new TaskNotFoundError(taskId);
319
- if (task.status !== TaskStatus.Ready) {
320
- throw new TaskNotClaimableError(taskId, `status is ${task.status}, must be ready`);
321
- }
322
- const incompleteDeps = this.getIncompleteDepsStmt.all(taskId);
323
- if (incompleteDeps.length > 0) {
324
- throw new DependenciesNotDoneError(taskId, incompleteDeps.map(d => d.depends_on_id));
319
+ if (task.status === TaskStatus.Done || task.status === TaskStatus.Archived) {
320
+ throw new TaskNotClaimableError(taskId, `status is ${task.status}, must not be done or archived`);
325
321
  }
326
322
  const eventData = {
327
- from: TaskStatus.Ready,
323
+ from: task.status,
328
324
  to: TaskStatus.InProgress,
329
325
  };
330
326
  if (opts?.lease_until)
@@ -345,6 +341,13 @@ export class TaskService {
345
341
  const task = this.getTaskById(taskId);
346
342
  if (!task)
347
343
  throw new TaskNotFoundError(taskId);
344
+ // No-op on self-transition
345
+ if (task.status === toStatus) {
346
+ return task;
347
+ }
348
+ if (task.status === TaskStatus.Archived) {
349
+ throw new InvalidStatusTransitionError(`Cannot change status: task is archived`);
350
+ }
348
351
  const event = this.eventStore.append({
349
352
  task_id: taskId,
350
353
  type: EventType.StatusChanged,
@@ -850,7 +853,7 @@ export class TaskService {
850
853
  * Used by the web dashboard.
851
854
  */
852
855
  listTasks(opts = {}) {
853
- const { sinceDays = 3, project, dueMonth } = opts;
856
+ const { sinceDays = 3, project, dueMonth, tag } = opts;
854
857
  const conditions = ["status != 'archived'"];
855
858
  const params = [];
856
859
  if (dueMonth) {
@@ -883,10 +886,14 @@ export class TaskService {
883
886
  conditions.push('project = ?');
884
887
  params.push(project);
885
888
  }
889
+ if (tag) {
890
+ conditions.push('EXISTS (SELECT 1 FROM task_tags WHERE task_id = tasks_current.task_id AND tag = ?)');
891
+ params.push(tag);
892
+ }
886
893
  const sql = `
887
894
  SELECT task_id, title, project, status, priority,
888
895
  agent, progress, lease_until, updated_at,
889
- parent_id, due_at
896
+ parent_id, due_at, tags
890
897
  FROM tasks_current
891
898
  WHERE ${conditions.join(' AND ')}
892
899
  ORDER BY priority DESC, updated_at DESC
@@ -905,8 +912,22 @@ export class TaskService {
905
912
  parent_id: row.parent_id,
906
913
  progress: row.progress,
907
914
  due_at: row.due_at,
915
+ tags: JSON.parse(row.tags),
908
916
  }));
909
917
  }
918
+ /**
919
+ * Get distinct tags with their task counts, excluding archived tasks.
920
+ */
921
+ getTagCounts() {
922
+ return this.db.prepare(`
923
+ SELECT tt.tag, COUNT(*) as count
924
+ FROM task_tags tt
925
+ JOIN tasks_current tc ON tt.task_id = tc.task_id
926
+ WHERE tc.status != 'archived'
927
+ GROUP BY tt.tag
928
+ ORDER BY tt.tag
929
+ `).all();
930
+ }
910
931
  /**
911
932
  * Get a map of task_id -> array of blocking task ids.
912
933
  * A task is blocked if it's in 'ready' status but has incomplete dependencies.
@@ -1110,6 +1131,42 @@ export class TaskService {
1110
1131
  return this.getTaskById(taskId);
1111
1132
  });
1112
1133
  }
1134
+ updateTask(taskId, updates, ctx) {
1135
+ return withWriteTransaction(this.db, () => {
1136
+ const task = this.getTaskById(taskId);
1137
+ if (!task)
1138
+ throw new TaskNotFoundError(taskId);
1139
+ const emitFieldUpdate = (field, oldValue, newValue) => {
1140
+ const event = this.eventStore.append({
1141
+ task_id: taskId,
1142
+ type: EventType.TaskUpdated,
1143
+ data: { field, old_value: oldValue, new_value: newValue },
1144
+ author: ctx?.author,
1145
+ agent_id: ctx?.agent_id,
1146
+ session_id: ctx?.session_id,
1147
+ correlation_id: ctx?.correlation_id,
1148
+ causation_id: ctx?.causation_id,
1149
+ });
1150
+ this.projectionEngine.applyEvent(event);
1151
+ };
1152
+ if (updates.title !== undefined && updates.title !== task.title) {
1153
+ emitFieldUpdate('title', task.title, updates.title);
1154
+ }
1155
+ if (updates.description !== undefined && updates.description !== task.description) {
1156
+ emitFieldUpdate('description', task.description, updates.description);
1157
+ }
1158
+ if (updates.priority !== undefined && updates.priority !== task.priority) {
1159
+ emitFieldUpdate('priority', task.priority, updates.priority);
1160
+ }
1161
+ if (updates.tags !== undefined && !arraysEqual(updates.tags, task.tags)) {
1162
+ emitFieldUpdate('tags', task.tags, updates.tags);
1163
+ }
1164
+ if (updates.links !== undefined && !arraysEqual(updates.links, task.links)) {
1165
+ emitFieldUpdate('links', task.links, updates.links);
1166
+ }
1167
+ return this.getTaskById(taskId);
1168
+ });
1169
+ }
1113
1170
  orphanSubtasks(parentTaskId, opts) {
1114
1171
  const subtasks = this.getSubtasks(parentTaskId);
1115
1172
  for (const subtask of subtasks) {
@@ -1287,36 +1344,85 @@ export class TaskService {
1287
1344
  * Recomputes eligibility inside the prune transaction to avoid TOCTOU.
1288
1345
  */
1289
1346
  pruneEligible(opts) {
1347
+ // Single DB mode (combined events+cache database) retains existing behavior.
1348
+ if (!this.eventsDb || this.eventsDb === this.db) {
1349
+ return withWriteTransaction(this.db, () => this.pruneEligibleWithinTransaction(opts, this.db, 'main'));
1350
+ }
1351
+ const eventsDbPath = this.getDatabasePath(this.eventsDb);
1352
+ if (!eventsDbPath) {
1353
+ return this.pruneEligibleWithJournalFallback(opts);
1354
+ }
1355
+ this.db.prepare(`ATTACH DATABASE ? AS ${TaskService.ATTACHED_EVENTS_SCHEMA}`).run(eventsDbPath);
1356
+ try {
1357
+ return withWriteTransaction(this.db, () => this.pruneEligibleWithinTransaction(opts, this.db, TaskService.ATTACHED_EVENTS_SCHEMA));
1358
+ }
1359
+ finally {
1360
+ this.db.exec(`DETACH DATABASE ${TaskService.ATTACHED_EVENTS_SCHEMA}`);
1361
+ }
1362
+ }
1363
+ pruneEligibleWithinTransaction(opts, txDb, eventsSchema) {
1364
+ // Recompute inside transaction to avoid TOCTOU races.
1365
+ const eligibleTasks = this.previewPrunableTasks(opts);
1366
+ if (eligibleTasks.length === 0) {
1367
+ return {
1368
+ pruned: [],
1369
+ count: 0,
1370
+ eventsDeleted: 0,
1371
+ };
1372
+ }
1373
+ const taskIds = eligibleTasks.map(t => t.task_id);
1374
+ // Delete events first (source of truth), then projections.
1375
+ const eventsDeleted = this.deleteTasksFromEvents(txDb, taskIds, eventsSchema);
1376
+ this.deleteTasksFromProjections(txDb, taskIds);
1377
+ return {
1378
+ pruned: eligibleTasks,
1379
+ count: eligibleTasks.length,
1380
+ eventsDeleted,
1381
+ };
1382
+ }
1383
+ pruneEligibleWithJournalFallback(opts) {
1290
1384
  if (!this.eventsDb) {
1291
1385
  throw new Error('TaskService: eventsDb not provided, cannot prune tasks');
1292
1386
  }
1293
- return withWriteTransaction(this.eventsDb, () => {
1294
- // First, get eligible tasks (recompute to avoid TOCTOU race)
1295
- const eligibleTasks = this.previewPrunableTasks(opts);
1296
- if (eligibleTasks.length === 0) {
1297
- return {
1298
- pruned: [],
1299
- count: 0,
1300
- eventsDeleted: 0,
1301
- };
1302
- }
1303
- const taskIds = eligibleTasks.map(t => t.task_id);
1304
- // Delete events first (source of truth) - requires trigger bypass
1305
- // This ordering is intentional: if projection deletion fails after events
1306
- // are deleted, the projections can be rebuilt from remaining events.
1307
- // The reverse (projections deleted, events remaining) would leave orphan
1308
- // events that recreate projections on rebuild.
1309
- const eventsDeleted = this.deleteTasksFromEvents(taskIds);
1310
- // Delete from projections (cache.db) - derived state, recoverable
1311
- this.deleteTasksFromProjections(taskIds);
1387
+ if (!this.pruneJournalPath) {
1388
+ throw new Error('TaskService: prune journal path unavailable for cross-db fallback');
1389
+ }
1390
+ const eligibleTasks = this.previewPrunableTasks(opts);
1391
+ if (eligibleTasks.length === 0) {
1312
1392
  return {
1313
- pruned: eligibleTasks,
1314
- count: eligibleTasks.length,
1315
- eventsDeleted,
1393
+ pruned: [],
1394
+ count: 0,
1395
+ eventsDeleted: 0,
1316
1396
  };
1317
- });
1397
+ }
1398
+ const taskIds = eligibleTasks.map(t => t.task_id);
1399
+ this.writePruneJournal(taskIds);
1400
+ let eventsDeleted = 0;
1401
+ try {
1402
+ eventsDeleted = withWriteTransaction(this.eventsDb, () => this.deleteTasksFromEvents(this.eventsDb, taskIds, 'main'));
1403
+ }
1404
+ catch (err) {
1405
+ // Events prune did not complete; do not keep stale recovery journal.
1406
+ this.deletePruneJournal();
1407
+ throw err;
1408
+ }
1409
+ try {
1410
+ withWriteTransaction(this.db, () => {
1411
+ this.deleteTasksFromProjections(this.db, taskIds);
1412
+ });
1413
+ this.deletePruneJournal();
1414
+ }
1415
+ catch (err) {
1416
+ // Keep journal for constructor-time recovery on next startup.
1417
+ throw err;
1418
+ }
1419
+ return {
1420
+ pruned: eligibleTasks,
1421
+ count: eligibleTasks.length,
1422
+ eventsDeleted,
1423
+ };
1318
1424
  }
1319
- deleteTasksFromProjections(taskIds) {
1425
+ deleteTasksFromProjections(db, taskIds) {
1320
1426
  // Delete from all projection tables in order
1321
1427
  const tables = [
1322
1428
  'task_comments',
@@ -1327,9 +1433,10 @@ export class TaskService {
1327
1433
  'tasks_current',
1328
1434
  ];
1329
1435
  // Create temp table once to avoid SQLite parameter limits
1330
- this.db.exec('CREATE TEMP TABLE IF NOT EXISTS prune_targets (task_id TEXT PRIMARY KEY)');
1331
- this.db.exec('DELETE FROM prune_targets'); // Clear any previous data
1332
- const insert = this.db.prepare('INSERT INTO prune_targets (task_id) VALUES (?)');
1436
+ const tempTable = 'prune_targets_projection';
1437
+ db.exec(`CREATE TEMP TABLE IF NOT EXISTS ${tempTable} (task_id TEXT PRIMARY KEY)`);
1438
+ db.exec(`DELETE FROM ${tempTable}`); // Clear any previous data
1439
+ const insert = db.prepare(`INSERT INTO ${tempTable} (task_id) VALUES (?)`);
1333
1440
  for (const id of taskIds) {
1334
1441
  insert.run(id);
1335
1442
  }
@@ -1337,62 +1444,158 @@ export class TaskService {
1337
1444
  for (const table of tables) {
1338
1445
  if (table === 'task_dependencies') {
1339
1446
  // Delete both directions
1340
- this.db.exec('DELETE FROM task_dependencies WHERE task_id IN (SELECT task_id FROM prune_targets) OR depends_on_id IN (SELECT task_id FROM prune_targets)');
1447
+ db.exec(`DELETE FROM task_dependencies WHERE task_id IN (SELECT task_id FROM ${tempTable}) OR depends_on_id IN (SELECT task_id FROM ${tempTable})`);
1341
1448
  }
1342
1449
  else if (table === 'task_search') {
1343
1450
  // FTS table uses different syntax
1344
- this.db.exec('DELETE FROM task_search WHERE task_id IN (SELECT task_id FROM prune_targets)');
1451
+ db.exec(`DELETE FROM task_search WHERE task_id IN (SELECT task_id FROM ${tempTable})`);
1345
1452
  }
1346
1453
  else {
1347
- this.db.exec(`DELETE FROM ${table} WHERE task_id IN (SELECT task_id FROM prune_targets)`);
1454
+ db.exec(`DELETE FROM ${table} WHERE task_id IN (SELECT task_id FROM ${tempTable})`);
1348
1455
  }
1349
1456
  }
1350
- this.db.exec('DROP TABLE prune_targets');
1457
+ db.exec(`DROP TABLE IF EXISTS ${tempTable}`);
1351
1458
  }
1352
- deleteTasksFromEvents(taskIds) {
1353
- const eventsDb = this.eventsDb; // Non-null assertion safe: checked in pruneEligible
1459
+ deleteTasksFromEvents(db, taskIds, eventsSchema) {
1460
+ const triggerPrefix = eventsSchema === 'main' ? '' : `${eventsSchema}.`;
1461
+ const eventsTable = eventsSchema === 'main' ? 'events' : `${eventsSchema}.events`;
1462
+ const tempTable = 'prune_targets_events';
1354
1463
  // Disable triggers
1355
- eventsDb.exec('DROP TRIGGER IF EXISTS events_no_delete');
1356
- eventsDb.exec('DROP TRIGGER IF EXISTS events_no_update');
1464
+ db.exec(`DROP TRIGGER IF EXISTS ${triggerPrefix}events_no_delete`);
1465
+ db.exec(`DROP TRIGGER IF EXISTS ${triggerPrefix}events_no_update`);
1357
1466
  try {
1358
1467
  // Create temp table to avoid SQLite parameter limits on large prune sets
1359
- eventsDb.exec('CREATE TEMP TABLE prune_targets (task_id TEXT PRIMARY KEY)');
1360
- const insert = eventsDb.prepare('INSERT INTO prune_targets (task_id) VALUES (?)');
1468
+ db.exec(`CREATE TEMP TABLE IF NOT EXISTS ${tempTable} (task_id TEXT PRIMARY KEY)`);
1469
+ db.exec(`DELETE FROM ${tempTable}`);
1470
+ const insert = db.prepare(`INSERT INTO ${tempTable} (task_id) VALUES (?)`);
1361
1471
  for (const id of taskIds) {
1362
1472
  insert.run(id);
1363
1473
  }
1364
- const result = eventsDb
1365
- .prepare('DELETE FROM events WHERE task_id IN (SELECT task_id FROM prune_targets)')
1474
+ const result = db
1475
+ .prepare(`DELETE FROM ${eventsTable} WHERE task_id IN (SELECT task_id FROM ${tempTable})`)
1366
1476
  .run();
1367
- eventsDb.exec('DROP TABLE prune_targets');
1368
1477
  // Re-enable triggers
1369
- this.recreateEventTriggers();
1478
+ this.recreateEventTriggers(db, eventsSchema);
1479
+ db.exec(`DROP TABLE IF EXISTS ${tempTable}`);
1370
1480
  return result.changes;
1371
1481
  }
1372
1482
  catch (err) {
1373
1483
  // Re-enable triggers even on error
1374
- this.recreateEventTriggers();
1484
+ this.recreateEventTriggers(db, eventsSchema);
1485
+ db.exec(`DROP TABLE IF EXISTS ${tempTable}`);
1375
1486
  throw err;
1376
1487
  }
1377
1488
  }
1378
- recreateEventTriggers() {
1379
- const eventsDb = this.eventsDb; // Non-null assertion safe: checked in pruneEligible
1489
+ recreateEventTriggers(db, eventsSchema) {
1490
+ // In SQLite, `CREATE TRIGGER schema.name BEFORE UPDATE ON table` resolves
1491
+ // `table` within the schema specified by the trigger name prefix.
1492
+ // So `events_src.events_no_update BEFORE UPDATE ON events` correctly targets
1493
+ // the `events` table inside the attached `events_src` database.
1494
+ // Note: the table name in the ON clause MUST be unqualified — SQLite does not
1495
+ // allow schema-qualified table names there (it would be a syntax error).
1496
+ const triggerPrefix = eventsSchema === 'main' ? '' : `${eventsSchema}.`;
1380
1497
  const EVENTS_TRIGGERS_SQL = `
1381
1498
  -- Append-only enforcement: prevent UPDATE on events
1382
- CREATE TRIGGER IF NOT EXISTS events_no_update
1499
+ CREATE TRIGGER IF NOT EXISTS ${triggerPrefix}events_no_update
1383
1500
  BEFORE UPDATE ON events
1384
1501
  BEGIN
1385
1502
  SELECT RAISE(ABORT, 'Events table is append-only: cannot UPDATE');
1386
1503
  END;
1387
1504
 
1388
1505
  -- Append-only enforcement: prevent DELETE on events
1389
- CREATE TRIGGER IF NOT EXISTS events_no_delete
1506
+ CREATE TRIGGER IF NOT EXISTS ${triggerPrefix}events_no_delete
1390
1507
  BEFORE DELETE ON events
1391
1508
  BEGIN
1392
1509
  SELECT RAISE(ABORT, 'Events table is append-only: cannot DELETE');
1393
1510
  END;
1394
1511
  `;
1395
- eventsDb.exec(EVENTS_TRIGGERS_SQL);
1512
+ db.exec(EVENTS_TRIGGERS_SQL);
1513
+ }
1514
+ getDatabasePath(db) {
1515
+ const rows = db.prepare('PRAGMA database_list').all();
1516
+ const mainRow = rows.find(row => row.name === 'main');
1517
+ const filePath = mainRow?.file;
1518
+ if (!filePath || filePath === ':memory:') {
1519
+ return null;
1520
+ }
1521
+ return filePath;
1522
+ }
1523
+ resolvePruneJournalPath(explicitPath) {
1524
+ if (explicitPath) {
1525
+ return explicitPath;
1526
+ }
1527
+ const cachePath = this.getDatabasePath(this.db);
1528
+ if (cachePath) {
1529
+ return path.join(path.dirname(cachePath), TaskService.PRUNE_JOURNAL_FILENAME);
1530
+ }
1531
+ if (this.eventsDb) {
1532
+ const eventsPath = this.getDatabasePath(this.eventsDb);
1533
+ if (eventsPath) {
1534
+ return path.join(path.dirname(eventsPath), TaskService.PRUNE_JOURNAL_FILENAME);
1535
+ }
1536
+ }
1537
+ return null;
1538
+ }
1539
+ writePruneJournal(taskIds) {
1540
+ if (!this.pruneJournalPath) {
1541
+ throw new Error('TaskService: prune journal path unavailable');
1542
+ }
1543
+ fs.mkdirSync(path.dirname(this.pruneJournalPath), { recursive: true });
1544
+ fs.writeFileSync(this.pruneJournalPath, JSON.stringify({
1545
+ taskIds,
1546
+ createdAt: new Date().toISOString(),
1547
+ }, null, 2), 'utf8');
1548
+ }
1549
+ deletePruneJournal() {
1550
+ if (!this.pruneJournalPath) {
1551
+ return;
1552
+ }
1553
+ if (fs.existsSync(this.pruneJournalPath)) {
1554
+ fs.unlinkSync(this.pruneJournalPath);
1555
+ }
1556
+ }
1557
+ readPruneJournalTaskIds() {
1558
+ if (!this.pruneJournalPath || !fs.existsSync(this.pruneJournalPath)) {
1559
+ return null;
1560
+ }
1561
+ const raw = fs.readFileSync(this.pruneJournalPath, 'utf8');
1562
+ const parsed = JSON.parse(raw);
1563
+ if (!Array.isArray(parsed.taskIds)) {
1564
+ throw new Error('Invalid prune journal payload');
1565
+ }
1566
+ const taskIds = parsed.taskIds.filter((value) => typeof value === 'string');
1567
+ if (taskIds.length === 0) {
1568
+ throw new Error('Invalid prune journal payload');
1569
+ }
1570
+ return taskIds;
1571
+ }
1572
+ recoverPruneJournalIfNeeded() {
1573
+ if (!this.pruneJournalPath) {
1574
+ return;
1575
+ }
1576
+ let taskIds = null;
1577
+ try {
1578
+ taskIds = this.readPruneJournalTaskIds();
1579
+ }
1580
+ catch {
1581
+ // Invalid journal payload should not block startup.
1582
+ this.deletePruneJournal();
1583
+ return;
1584
+ }
1585
+ if (!taskIds || taskIds.length === 0) {
1586
+ this.deletePruneJournal();
1587
+ return;
1588
+ }
1589
+ try {
1590
+ const recoveryTaskIds = taskIds;
1591
+ withWriteTransaction(this.db, () => {
1592
+ this.deleteTasksFromProjections(this.db, recoveryTaskIds);
1593
+ });
1594
+ this.deletePruneJournal();
1595
+ }
1596
+ catch {
1597
+ // Keep the journal for next startup retry.
1598
+ }
1396
1599
  }
1397
1600
  rowToTask(row) {
1398
1601
  return {