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.
- package/dist/__tests__/concurrency/stress.test.js +13 -9
- package/dist/__tests__/concurrency/stress.test.js.map +1 -1
- package/dist/__tests__/properties/invariants.test.js +31 -10
- package/dist/__tests__/properties/invariants.test.js.map +1 -1
- package/dist/db/__tests__/transaction.test.d.ts +2 -0
- package/dist/db/__tests__/transaction.test.d.ts.map +1 -0
- package/dist/db/__tests__/transaction.test.js +67 -0
- package/dist/db/__tests__/transaction.test.js.map +1 -0
- package/dist/db/migrations/index.d.ts +8 -3
- package/dist/db/migrations/index.d.ts.map +1 -1
- package/dist/db/migrations/index.js +14 -3
- package/dist/db/migrations/index.js.map +1 -1
- package/dist/db/migrations/index.test.d.ts +2 -0
- package/dist/db/migrations/index.test.d.ts.map +1 -0
- package/dist/db/migrations/index.test.js +53 -0
- package/dist/db/migrations/index.test.js.map +1 -0
- package/dist/db/schema.d.ts +2 -2
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +3 -1
- package/dist/db/schema.js.map +1 -1
- package/dist/db/transaction.d.ts.map +1 -1
- package/dist/db/transaction.js +16 -4
- package/dist/db/transaction.js.map +1 -1
- package/dist/events/store.d.ts +3 -1
- package/dist/events/store.d.ts.map +1 -1
- package/dist/events/store.js +23 -9
- package/dist/events/store.js.map +1 -1
- package/dist/events/store.test.js +49 -2
- package/dist/events/store.test.js.map +1 -1
- package/dist/events/types.d.ts +1 -0
- package/dist/events/types.d.ts.map +1 -1
- package/dist/events/types.js +1 -0
- package/dist/events/types.js.map +1 -1
- package/dist/events/upcasters.d.ts +16 -0
- package/dist/events/upcasters.d.ts.map +1 -0
- package/dist/events/upcasters.js +35 -0
- package/dist/events/upcasters.js.map +1 -0
- package/dist/events/upcasters.test.d.ts +2 -0
- package/dist/events/upcasters.test.d.ts.map +1 -0
- package/dist/events/upcasters.test.js +47 -0
- package/dist/events/upcasters.test.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +0 -1
- package/dist/index.test.js.map +1 -1
- package/dist/projections/comments-checkpoints.d.ts +2 -2
- package/dist/projections/comments-checkpoints.d.ts.map +1 -1
- package/dist/projections/comments-checkpoints.js +4 -3
- package/dist/projections/comments-checkpoints.js.map +1 -1
- package/dist/projections/comments-checkpoints.test.js +30 -0
- package/dist/projections/comments-checkpoints.test.js.map +1 -1
- package/dist/projections/dependencies.d.ts +2 -2
- package/dist/projections/dependencies.d.ts.map +1 -1
- package/dist/projections/dependencies.js +5 -4
- package/dist/projections/dependencies.js.map +1 -1
- package/dist/projections/dependencies.test.js +29 -0
- package/dist/projections/dependencies.test.js.map +1 -1
- package/dist/projections/projects.d.ts +2 -2
- package/dist/projections/projects.d.ts.map +1 -1
- package/dist/projections/projects.js +8 -9
- package/dist/projections/projects.js.map +1 -1
- package/dist/projections/projects.test.js +34 -0
- package/dist/projections/projects.test.js.map +1 -1
- package/dist/projections/search.d.ts +2 -2
- package/dist/projections/search.d.ts.map +1 -1
- package/dist/projections/search.js +15 -12
- package/dist/projections/search.js.map +1 -1
- package/dist/projections/search.test.js +37 -0
- package/dist/projections/search.test.js.map +1 -1
- package/dist/projections/tags.d.ts +2 -2
- package/dist/projections/tags.d.ts.map +1 -1
- package/dist/projections/tags.js +4 -3
- package/dist/projections/tags.js.map +1 -1
- package/dist/projections/tags.test.js +29 -0
- package/dist/projections/tags.test.js.map +1 -1
- package/dist/projections/tasks-current.d.ts +2 -2
- package/dist/projections/tasks-current.d.ts.map +1 -1
- package/dist/projections/tasks-current.js +19 -14
- package/dist/projections/tasks-current.js.map +1 -1
- package/dist/projections/tasks-current.test.js +87 -0
- package/dist/projections/tasks-current.test.js.map +1 -1
- package/dist/projections/types.d.ts +14 -0
- package/dist/projections/types.d.ts.map +1 -1
- package/dist/projections/types.js +23 -1
- package/dist/projections/types.js.map +1 -1
- package/dist/services/search-service.js +1 -1
- package/dist/services/search-service.js.map +1 -1
- package/dist/services/search-service.test.js +23 -0
- package/dist/services/search-service.test.js.map +1 -1
- package/dist/services/task-service.d.ts +29 -4
- package/dist/services/task-service.d.ts.map +1 -1
- package/dist/services/task-service.js +272 -69
- package/dist/services/task-service.js.map +1 -1
- package/dist/services/task-service.test.js +313 -15
- package/dist/services/task-service.test.js.map +1 -1
- package/dist/services/workflow-service.test.js +525 -161
- package/dist/services/workflow-service.test.js.map +1 -1
- 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
|
-
|
|
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.
|
|
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
|
|
320
|
-
throw new TaskNotClaimableError(taskId, `status is ${task.status}, must be
|
|
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:
|
|
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
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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:
|
|
1314
|
-
count:
|
|
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
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1451
|
+
db.exec(`DELETE FROM task_search WHERE task_id IN (SELECT task_id FROM ${tempTable})`);
|
|
1345
1452
|
}
|
|
1346
1453
|
else {
|
|
1347
|
-
|
|
1454
|
+
db.exec(`DELETE FROM ${table} WHERE task_id IN (SELECT task_id FROM ${tempTable})`);
|
|
1348
1455
|
}
|
|
1349
1456
|
}
|
|
1350
|
-
|
|
1457
|
+
db.exec(`DROP TABLE IF EXISTS ${tempTable}`);
|
|
1351
1458
|
}
|
|
1352
|
-
deleteTasksFromEvents(taskIds) {
|
|
1353
|
-
const
|
|
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
|
-
|
|
1356
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
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 =
|
|
1365
|
-
.prepare(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|