@jamesaphoenix/tx-core 0.4.2 → 0.4.4
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/README.md +480 -0
- package/dist/db.d.ts +28 -14
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +108 -14
- package/dist/db.js.map +1 -1
- package/dist/errors.d.ts +178 -34
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +119 -26
- package/dist/errors.js.map +1 -1
- package/dist/id.d.ts +10 -0
- package/dist/id.d.ts.map +1 -1
- package/dist/id.js +17 -1
- package/dist/id.js.map +1 -1
- package/dist/index.d.ts +15 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +62 -8
- package/dist/index.js.map +1 -1
- package/dist/layer.d.ts +23 -14
- package/dist/layer.d.ts.map +1 -1
- package/dist/layer.js +75 -76
- package/dist/layer.js.map +1 -1
- package/dist/mappers/attempt.d.ts +3 -1
- package/dist/mappers/attempt.d.ts.map +1 -1
- package/dist/mappers/attempt.js +23 -9
- package/dist/mappers/attempt.js.map +1 -1
- package/dist/mappers/claim.d.ts +1 -1
- package/dist/mappers/claim.d.ts.map +1 -1
- package/dist/mappers/claim.js +11 -4
- package/dist/mappers/claim.js.map +1 -1
- package/dist/mappers/deduplication.d.ts +13 -1
- package/dist/mappers/deduplication.d.ts.map +1 -1
- package/dist/mappers/deduplication.js +22 -3
- package/dist/mappers/deduplication.js.map +1 -1
- package/dist/mappers/doc.d.ts +24 -0
- package/dist/mappers/doc.d.ts.map +1 -0
- package/dist/mappers/doc.js +161 -0
- package/dist/mappers/doc.js.map +1 -0
- package/dist/mappers/file-learning.d.ts.map +1 -1
- package/dist/mappers/file-learning.js +2 -1
- package/dist/mappers/file-learning.js.map +1 -1
- package/dist/mappers/index.d.ts +6 -7
- package/dist/mappers/index.d.ts.map +1 -1
- package/dist/mappers/index.js +10 -12
- package/dist/mappers/index.js.map +1 -1
- package/dist/mappers/learning.d.ts +9 -1
- package/dist/mappers/learning.d.ts.map +1 -1
- package/dist/mappers/learning.js +94 -14
- package/dist/mappers/learning.js.map +1 -1
- package/dist/mappers/orchestrator-state.d.ts +1 -1
- package/dist/mappers/orchestrator-state.d.ts.map +1 -1
- package/dist/mappers/orchestrator-state.js +31 -5
- package/dist/mappers/orchestrator-state.js.map +1 -1
- package/dist/mappers/parse-date.d.ts +11 -0
- package/dist/mappers/parse-date.d.ts.map +1 -0
- package/dist/mappers/parse-date.js +18 -0
- package/dist/mappers/parse-date.js.map +1 -0
- package/dist/mappers/run.d.ts +14 -4
- package/dist/mappers/run.d.ts.map +1 -1
- package/dist/mappers/run.js +49 -18
- package/dist/mappers/run.js.map +1 -1
- package/dist/mappers/task.d.ts +5 -1
- package/dist/mappers/task.d.ts.map +1 -1
- package/dist/mappers/task.js +66 -16
- package/dist/mappers/task.js.map +1 -1
- package/dist/mappers/tracked-project.d.ts +3 -1
- package/dist/mappers/tracked-project.d.ts.map +1 -1
- package/dist/mappers/tracked-project.js +23 -9
- package/dist/mappers/tracked-project.js.map +1 -1
- package/dist/mappers/worker.d.ts +1 -1
- package/dist/mappers/worker.d.ts.map +1 -1
- package/dist/mappers/worker.js +44 -6
- package/dist/mappers/worker.js.map +1 -1
- package/dist/repo/attempt-repo.d.ts +2 -2
- package/dist/repo/attempt-repo.d.ts.map +1 -1
- package/dist/repo/attempt-repo.js +16 -6
- package/dist/repo/attempt-repo.js.map +1 -1
- package/dist/repo/claim-repo.d.ts +46 -2
- package/dist/repo/claim-repo.d.ts.map +1 -1
- package/dist/repo/claim-repo.js +113 -6
- package/dist/repo/claim-repo.js.map +1 -1
- package/dist/repo/deduplication-repo.d.ts +9 -1
- package/dist/repo/deduplication-repo.d.ts.map +1 -1
- package/dist/repo/deduplication-repo.js +46 -9
- package/dist/repo/deduplication-repo.js.map +1 -1
- package/dist/repo/dep-repo.d.ts +27 -3
- package/dist/repo/dep-repo.d.ts.map +1 -1
- package/dist/repo/dep-repo.js +166 -39
- package/dist/repo/dep-repo.js.map +1 -1
- package/dist/repo/doc-repo.d.ts +59 -0
- package/dist/repo/doc-repo.d.ts.map +1 -0
- package/dist/repo/doc-repo.js +276 -0
- package/dist/repo/doc-repo.js.map +1 -0
- package/dist/repo/file-learning-repo.d.ts +3 -3
- package/dist/repo/file-learning-repo.d.ts.map +1 -1
- package/dist/repo/file-learning-repo.js +19 -8
- package/dist/repo/file-learning-repo.js.map +1 -1
- package/dist/repo/index.d.ts +4 -6
- package/dist/repo/index.d.ts.map +1 -1
- package/dist/repo/index.js +3 -5
- package/dist/repo/index.js.map +1 -1
- package/dist/repo/learning-repo.d.ts +10 -3
- package/dist/repo/learning-repo.d.ts.map +1 -1
- package/dist/repo/learning-repo.js +68 -11
- package/dist/repo/learning-repo.js.map +1 -1
- package/dist/repo/orchestrator-state-repo.d.ts.map +1 -1
- package/dist/repo/orchestrator-state-repo.js +8 -1
- package/dist/repo/orchestrator-state-repo.js.map +1 -1
- package/dist/repo/run-repo.d.ts +3 -3
- package/dist/repo/run-repo.d.ts.map +1 -1
- package/dist/repo/run-repo.js +40 -19
- package/dist/repo/run-repo.js.map +1 -1
- package/dist/repo/task-repo.d.ts +14 -3
- package/dist/repo/task-repo.d.ts.map +1 -1
- package/dist/repo/task-repo.js +194 -20
- package/dist/repo/task-repo.js.map +1 -1
- package/dist/repo/tracked-project-repo.d.ts.map +1 -1
- package/dist/repo/tracked-project-repo.js +15 -1
- package/dist/repo/tracked-project-repo.js.map +1 -1
- package/dist/repo/worker-repo.d.ts +3 -2
- package/dist/repo/worker-repo.d.ts.map +1 -1
- package/dist/repo/worker-repo.js +54 -8
- package/dist/repo/worker-repo.js.map +1 -1
- package/dist/schemas/sync.js +2 -2
- package/dist/schemas/sync.js.map +1 -1
- package/dist/schemas/worker.d.ts +1 -0
- package/dist/schemas/worker.d.ts.map +1 -1
- package/dist/schemas/worker.js +1 -0
- package/dist/schemas/worker.js.map +1 -1
- package/dist/services/agent-service.d.ts +57 -0
- package/dist/services/agent-service.d.ts.map +1 -0
- package/dist/services/agent-service.js +81 -0
- package/dist/services/agent-service.js.map +1 -0
- package/dist/services/attempt-service.d.ts.map +1 -1
- package/dist/services/attempt-service.js +1 -4
- package/dist/services/attempt-service.js.map +1 -1
- package/dist/services/auto-sync-service.d.ts.map +1 -1
- package/dist/services/auto-sync-service.js +18 -10
- package/dist/services/auto-sync-service.js.map +1 -1
- package/dist/services/claim-service.d.ts +8 -2
- package/dist/services/claim-service.d.ts.map +1 -1
- package/dist/services/claim-service.js +37 -23
- package/dist/services/claim-service.js.map +1 -1
- package/dist/services/cycle-scan-service.d.ts +32 -0
- package/dist/services/cycle-scan-service.d.ts.map +1 -0
- package/dist/services/cycle-scan-service.js +546 -0
- package/dist/services/cycle-scan-service.js.map +1 -0
- package/dist/services/daemon-service.d.ts +40 -2
- package/dist/services/daemon-service.d.ts.map +1 -1
- package/dist/services/daemon-service.js +199 -52
- package/dist/services/daemon-service.js.map +1 -1
- package/dist/services/deduplication-service.d.ts +8 -4
- package/dist/services/deduplication-service.d.ts.map +1 -1
- package/dist/services/deduplication-service.js +79 -25
- package/dist/services/deduplication-service.js.map +1 -1
- package/dist/services/dep-service.d.ts +2 -2
- package/dist/services/dep-service.d.ts.map +1 -1
- package/dist/services/dep-service.js +9 -5
- package/dist/services/dep-service.js.map +1 -1
- package/dist/services/diversifier-service.d.ts +2 -1
- package/dist/services/diversifier-service.d.ts.map +1 -1
- package/dist/services/diversifier-service.js +37 -43
- package/dist/services/diversifier-service.js.map +1 -1
- package/dist/services/doc-service.d.ts +49 -0
- package/dist/services/doc-service.d.ts.map +1 -0
- package/dist/services/doc-service.js +605 -0
- package/dist/services/doc-service.js.map +1 -0
- package/dist/services/embedding-service.d.ts +66 -2
- package/dist/services/embedding-service.d.ts.map +1 -1
- package/dist/services/embedding-service.js +138 -24
- package/dist/services/embedding-service.js.map +1 -1
- package/dist/services/file-learning-service.d.ts.map +1 -1
- package/dist/services/file-learning-service.js +8 -7
- package/dist/services/file-learning-service.js.map +1 -1
- package/dist/services/file-watcher-service.d.ts.map +1 -1
- package/dist/services/file-watcher-service.js +58 -11
- package/dist/services/file-watcher-service.js.map +1 -1
- package/dist/services/hierarchy-service.d.ts +1 -1
- package/dist/services/hierarchy-service.d.ts.map +1 -1
- package/dist/services/hierarchy-service.js +50 -32
- package/dist/services/hierarchy-service.js.map +1 -1
- package/dist/services/index.d.ts +13 -15
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +13 -15
- package/dist/services/index.js.map +1 -1
- package/dist/services/learning-service.d.ts +3 -3
- package/dist/services/learning-service.d.ts.map +1 -1
- package/dist/services/learning-service.js +75 -42
- package/dist/services/learning-service.js.map +1 -1
- package/dist/services/llm-service.d.ts +62 -0
- package/dist/services/llm-service.d.ts.map +1 -0
- package/dist/services/llm-service.js +172 -0
- package/dist/services/llm-service.js.map +1 -0
- package/dist/services/migration-service.d.ts +1 -1
- package/dist/services/migration-service.d.ts.map +1 -1
- package/dist/services/migration-service.js +18 -7
- package/dist/services/migration-service.js.map +1 -1
- package/dist/services/orchestrator-service.d.ts +4 -3
- package/dist/services/orchestrator-service.d.ts.map +1 -1
- package/dist/services/orchestrator-service.js +66 -27
- package/dist/services/orchestrator-service.js.map +1 -1
- package/dist/services/query-expansion-service.d.ts +30 -9
- package/dist/services/query-expansion-service.d.ts.map +1 -1
- package/dist/services/query-expansion-service.js +54 -63
- package/dist/services/query-expansion-service.js.map +1 -1
- package/dist/services/ready-service.d.ts +21 -1
- package/dist/services/ready-service.d.ts.map +1 -1
- package/dist/services/ready-service.js +44 -21
- package/dist/services/ready-service.js.map +1 -1
- package/dist/services/retriever-service.d.ts +10 -10
- package/dist/services/retriever-service.d.ts.map +1 -1
- package/dist/services/retriever-service.js +53 -161
- package/dist/services/retriever-service.js.map +1 -1
- package/dist/services/sync-service.d.ts +17 -4
- package/dist/services/sync-service.d.ts.map +1 -1
- package/dist/services/sync-service.js +381 -116
- package/dist/services/sync-service.js.map +1 -1
- package/dist/services/task-service.d.ts +6 -4
- package/dist/services/task-service.d.ts.map +1 -1
- package/dist/services/task-service.js +165 -35
- package/dist/services/task-service.js.map +1 -1
- package/dist/services/tracing-service.d.ts +55 -0
- package/dist/services/tracing-service.d.ts.map +1 -0
- package/dist/services/tracing-service.js +99 -0
- package/dist/services/tracing-service.js.map +1 -0
- package/dist/services/transcript-adapter.d.ts +99 -0
- package/dist/services/transcript-adapter.d.ts.map +1 -0
- package/dist/services/transcript-adapter.js +283 -0
- package/dist/services/transcript-adapter.js.map +1 -0
- package/dist/services/validation-service.d.ts +85 -0
- package/dist/services/validation-service.d.ts.map +1 -0
- package/dist/services/validation-service.js +289 -0
- package/dist/services/validation-service.js.map +1 -0
- package/dist/services/worker-process.d.ts +23 -4
- package/dist/services/worker-process.d.ts.map +1 -1
- package/dist/services/worker-process.js +168 -72
- package/dist/services/worker-process.js.map +1 -1
- package/dist/services/worker-service.d.ts.map +1 -1
- package/dist/services/worker-service.js +7 -12
- package/dist/services/worker-service.js.map +1 -1
- package/dist/sync/claude-task-writer.d.ts +49 -0
- package/dist/sync/claude-task-writer.d.ts.map +1 -0
- package/dist/sync/claude-task-writer.js +135 -0
- package/dist/sync/claude-task-writer.js.map +1 -0
- package/dist/utils/doc-hash.d.ts +10 -0
- package/dist/utils/doc-hash.d.ts.map +1 -0
- package/dist/utils/doc-hash.js +14 -0
- package/dist/utils/doc-hash.js.map +1 -0
- package/dist/utils/doc-renderer.d.ts +44 -0
- package/dist/utils/doc-renderer.d.ts.map +1 -0
- package/dist/utils/doc-renderer.js +202 -0
- package/dist/utils/doc-renderer.js.map +1 -0
- package/dist/utils/math.d.ts +5 -1
- package/dist/utils/math.d.ts.map +1 -1
- package/dist/utils/math.js +12 -4
- package/dist/utils/math.js.map +1 -1
- package/dist/utils/sql.d.ts +9 -0
- package/dist/utils/sql.d.ts.map +1 -0
- package/dist/utils/sql.js +9 -0
- package/dist/utils/sql.js.map +1 -0
- package/dist/utils/toml-config.d.ts +22 -0
- package/dist/utils/toml-config.d.ts.map +1 -0
- package/dist/utils/toml-config.js +75 -0
- package/dist/utils/toml-config.js.map +1 -0
- package/dist/worker/hooks.d.ts +102 -0
- package/dist/worker/hooks.d.ts.map +1 -0
- package/dist/worker/hooks.js +11 -0
- package/dist/worker/hooks.js.map +1 -0
- package/dist/worker/index.d.ts +9 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +8 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/run-worker.d.ts +33 -0
- package/dist/worker/run-worker.d.ts.map +1 -0
- package/dist/worker/run-worker.js +265 -0
- package/dist/worker/run-worker.js.map +1 -0
- package/package.json +14 -12
- package/dist/mappers/anchor.d.ts +0 -14
- package/dist/mappers/anchor.d.ts.map +0 -1
- package/dist/mappers/anchor.js +0 -38
- package/dist/mappers/anchor.js.map +0 -1
- package/dist/mappers/candidate.d.ts +0 -23
- package/dist/mappers/candidate.d.ts.map +0 -1
- package/dist/mappers/candidate.js +0 -53
- package/dist/mappers/candidate.js.map +0 -1
- package/dist/mappers/edge.d.ts +0 -10
- package/dist/mappers/edge.d.ts.map +0 -1
- package/dist/mappers/edge.js +0 -19
- package/dist/mappers/edge.js.map +0 -1
- package/dist/repo/anchor-repo.d.ts +0 -52
- package/dist/repo/anchor-repo.d.ts.map +0 -1
- package/dist/repo/anchor-repo.js +0 -204
- package/dist/repo/anchor-repo.js.map +0 -1
- package/dist/repo/candidate-repo.d.ts +0 -16
- package/dist/repo/candidate-repo.d.ts.map +0 -1
- package/dist/repo/candidate-repo.js +0 -143
- package/dist/repo/candidate-repo.js.map +0 -1
- package/dist/repo/edge-repo.d.ts +0 -26
- package/dist/repo/edge-repo.d.ts.map +0 -1
- package/dist/repo/edge-repo.js +0 -227
- package/dist/repo/edge-repo.js.map +0 -1
- package/dist/services/anchor-service.d.ts +0 -147
- package/dist/services/anchor-service.d.ts.map +0 -1
- package/dist/services/anchor-service.js +0 -540
- package/dist/services/anchor-service.js.map +0 -1
- package/dist/services/anchor-verification.d.ts +0 -94
- package/dist/services/anchor-verification.d.ts.map +0 -1
- package/dist/services/anchor-verification.js +0 -617
- package/dist/services/anchor-verification.js.map +0 -1
- package/dist/services/ast-grep-service.d.ts +0 -58
- package/dist/services/ast-grep-service.d.ts.map +0 -1
- package/dist/services/ast-grep-service.js +0 -356
- package/dist/services/ast-grep-service.js.map +0 -1
- package/dist/services/candidate-extractor-service.d.ts +0 -56
- package/dist/services/candidate-extractor-service.d.ts.map +0 -1
- package/dist/services/candidate-extractor-service.js +0 -365
- package/dist/services/candidate-extractor-service.js.map +0 -1
- package/dist/services/edge-service.d.ts +0 -78
- package/dist/services/edge-service.d.ts.map +0 -1
- package/dist/services/edge-service.js +0 -158
- package/dist/services/edge-service.js.map +0 -1
- package/dist/services/feedback-tracker.d.ts +0 -64
- package/dist/services/feedback-tracker.d.ts.map +0 -1
- package/dist/services/feedback-tracker.js +0 -110
- package/dist/services/feedback-tracker.js.map +0 -1
- package/dist/services/graph-expansion.d.ts +0 -155
- package/dist/services/graph-expansion.d.ts.map +0 -1
- package/dist/services/graph-expansion.js +0 -466
- package/dist/services/graph-expansion.js.map +0 -1
- package/dist/services/promotion-service.d.ts +0 -67
- package/dist/services/promotion-service.d.ts.map +0 -1
- package/dist/services/promotion-service.js +0 -151
- package/dist/services/promotion-service.js.map +0 -1
- package/dist/services/swarm-verification.d.ts +0 -104
- package/dist/services/swarm-verification.d.ts.map +0 -1
- package/dist/services/swarm-verification.js +0 -400
- package/dist/services/swarm-verification.js.map +0 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { Context, Effect, Layer, Schema } from "effect";
|
|
2
|
-
import {
|
|
1
|
+
import { Context, Effect, Exit, Layer, Schema } from "effect";
|
|
2
|
+
import { writeFile, rename, readFile, stat, mkdir, access } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
3
5
|
import { dirname, resolve } from "node:path";
|
|
4
6
|
import { DatabaseError, ValidationError } from "../errors.js";
|
|
5
7
|
import { SqliteClient } from "../db.js";
|
|
6
8
|
import { TaskService } from "./task-service.js";
|
|
7
|
-
import { TaskRepository } from "../repo/task-repo.js";
|
|
8
9
|
import { DependencyRepository } from "../repo/dep-repo.js";
|
|
9
10
|
import { SyncOperation as SyncOperationSchema } from "../schemas/sync.js";
|
|
10
11
|
/**
|
|
@@ -14,6 +15,92 @@ import { SyncOperation as SyncOperationSchema } from "../schemas/sync.js";
|
|
|
14
15
|
export class SyncService extends Context.Tag("SyncService")() {
|
|
15
16
|
}
|
|
16
17
|
const DEFAULT_JSONL_PATH = ".tx/tasks.jsonl";
|
|
18
|
+
/**
|
|
19
|
+
* Empty import result for early returns.
|
|
20
|
+
*/
|
|
21
|
+
const EMPTY_IMPORT_RESULT = {
|
|
22
|
+
imported: 0,
|
|
23
|
+
skipped: 0,
|
|
24
|
+
conflicts: 0,
|
|
25
|
+
dependencies: { added: 0, removed: 0, skipped: 0, failures: [] }
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Topologically sort task operations so parents are processed before children.
|
|
29
|
+
* This ensures foreign key constraints are satisfied during import.
|
|
30
|
+
*
|
|
31
|
+
* Uses Kahn's algorithm:
|
|
32
|
+
* 1. Find all tasks with no parent (or parent not in import set) - these have no deps
|
|
33
|
+
* 2. Process them and mark as "done"
|
|
34
|
+
* 3. For remaining tasks, if their parent is "done", add them to the queue
|
|
35
|
+
* 4. Repeat until all tasks are processed
|
|
36
|
+
*
|
|
37
|
+
* @param entries Array of [taskId, { op, ts }] entries from taskStates Map
|
|
38
|
+
* @returns Sorted array with parents before children
|
|
39
|
+
*/
|
|
40
|
+
function topologicalSortTasks(entries) {
|
|
41
|
+
// Separate upserts from deletes - deletes don't have parent dependencies
|
|
42
|
+
const upsertEntries = entries.filter(([, { op }]) => op.op === "upsert");
|
|
43
|
+
const deleteEntries = entries.filter(([, { op }]) => op.op === "delete");
|
|
44
|
+
// Build set of task IDs being imported
|
|
45
|
+
const importingIds = new Set(upsertEntries.map(([id]) => id));
|
|
46
|
+
// Build parent→children adjacency list
|
|
47
|
+
const children = new Map();
|
|
48
|
+
for (const [id] of upsertEntries) {
|
|
49
|
+
children.set(id, []);
|
|
50
|
+
}
|
|
51
|
+
for (const [id, { op }] of upsertEntries) {
|
|
52
|
+
const parentId = op.data?.parentId;
|
|
53
|
+
if (parentId && importingIds.has(parentId)) {
|
|
54
|
+
const parentChildren = children.get(parentId);
|
|
55
|
+
if (parentChildren) {
|
|
56
|
+
parentChildren.push(id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Calculate in-degree (number of parents in import set)
|
|
61
|
+
const inDegree = new Map();
|
|
62
|
+
for (const [id, { op }] of upsertEntries) {
|
|
63
|
+
const parentId = op.data?.parentId;
|
|
64
|
+
// Only count parent as dependency if it's in the import set
|
|
65
|
+
const hasParentInSet = parentId && importingIds.has(parentId);
|
|
66
|
+
inDegree.set(id, hasParentInSet ? 1 : 0);
|
|
67
|
+
}
|
|
68
|
+
// Queue starts with tasks that have no parent in import set (in-degree 0)
|
|
69
|
+
const queue = [];
|
|
70
|
+
for (const [id, degree] of inDegree) {
|
|
71
|
+
if (degree === 0) {
|
|
72
|
+
queue.push(id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Build sorted result
|
|
76
|
+
const sorted = [];
|
|
77
|
+
const entryMap = new Map(upsertEntries);
|
|
78
|
+
while (queue.length > 0) {
|
|
79
|
+
const id = queue.shift();
|
|
80
|
+
const entry = entryMap.get(id);
|
|
81
|
+
if (entry) {
|
|
82
|
+
sorted.push([id, entry]);
|
|
83
|
+
}
|
|
84
|
+
// Decrement in-degree of children and add to queue if now 0
|
|
85
|
+
const childIds = children.get(id) ?? [];
|
|
86
|
+
for (const childId of childIds) {
|
|
87
|
+
const currentDegree = inDegree.get(childId) ?? 0;
|
|
88
|
+
const newDegree = currentDegree - 1;
|
|
89
|
+
inDegree.set(childId, newDegree);
|
|
90
|
+
if (newDegree === 0) {
|
|
91
|
+
queue.push(childId);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// If we didn't process all tasks, there's a cycle - fall back to original order
|
|
96
|
+
// (This shouldn't happen with valid data since parent-child can't be circular)
|
|
97
|
+
if (sorted.length < upsertEntries.length) {
|
|
98
|
+
// Return original upsert entries followed by deletes
|
|
99
|
+
return [...upsertEntries, ...deleteEntries];
|
|
100
|
+
}
|
|
101
|
+
// Return sorted upserts followed by deletes
|
|
102
|
+
return [...sorted, ...deleteEntries];
|
|
103
|
+
}
|
|
17
104
|
/**
|
|
18
105
|
* Convert a Task to a TaskUpsertOp for JSONL export.
|
|
19
106
|
*/
|
|
@@ -41,21 +128,26 @@ const depToAddOp = (dep) => ({
|
|
|
41
128
|
blockerId: dep.blockerId,
|
|
42
129
|
blockedId: dep.blockedId
|
|
43
130
|
});
|
|
131
|
+
/**
|
|
132
|
+
* Check if a file exists without blocking the event loop.
|
|
133
|
+
*/
|
|
134
|
+
const fileExists = (filePath) => Effect.promise(() => access(filePath).then(() => true).catch(() => false));
|
|
44
135
|
/**
|
|
45
136
|
* Write content to file atomically using temp file + rename.
|
|
137
|
+
* Uses async fs operations to avoid blocking the event loop.
|
|
46
138
|
*/
|
|
47
|
-
const atomicWrite = (filePath, content) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
139
|
+
const atomicWrite = (filePath, content) => Effect.tryPromise({
|
|
140
|
+
try: async () => {
|
|
141
|
+
const dir = dirname(filePath);
|
|
142
|
+
await mkdir(dir, { recursive: true });
|
|
143
|
+
const tempPath = `${filePath}.tmp.${Date.now()}.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
144
|
+
await writeFile(tempPath, content, "utf-8");
|
|
145
|
+
await rename(tempPath, filePath);
|
|
146
|
+
},
|
|
147
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
148
|
+
});
|
|
56
149
|
export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* () {
|
|
57
150
|
const taskService = yield* TaskService;
|
|
58
|
-
const taskRepo = yield* TaskRepository;
|
|
59
151
|
const depRepo = yield* DependencyRepository;
|
|
60
152
|
const db = yield* SqliteClient;
|
|
61
153
|
// Helper: Get config value from sync_config table
|
|
@@ -76,9 +168,9 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
76
168
|
return {
|
|
77
169
|
export: (path) => Effect.gen(function* () {
|
|
78
170
|
const filePath = resolve(path ?? DEFAULT_JSONL_PATH);
|
|
79
|
-
// Get all tasks and dependencies
|
|
171
|
+
// Get all tasks and dependencies (explicit high limit for full export)
|
|
80
172
|
const tasks = yield* taskService.list();
|
|
81
|
-
const deps = yield* depRepo.getAll();
|
|
173
|
+
const deps = yield* depRepo.getAll(100_000);
|
|
82
174
|
// Convert to sync operations
|
|
83
175
|
const taskOps = tasks.map(taskToUpsertOp);
|
|
84
176
|
const depOps = deps.map(depToAddOp);
|
|
@@ -87,10 +179,7 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
87
179
|
// Convert to JSONL format (one JSON object per line)
|
|
88
180
|
const jsonl = allOps.map(op => JSON.stringify(op)).join("\n");
|
|
89
181
|
// Write atomically
|
|
90
|
-
yield*
|
|
91
|
-
try: () => atomicWrite(filePath, jsonl + (jsonl.length > 0 ? "\n" : "")),
|
|
92
|
-
catch: (cause) => new DatabaseError({ cause })
|
|
93
|
-
});
|
|
182
|
+
yield* atomicWrite(filePath, jsonl + (jsonl.length > 0 ? "\n" : ""));
|
|
94
183
|
// Record export time
|
|
95
184
|
yield* setConfig("last_export", new Date().toISOString());
|
|
96
185
|
return {
|
|
@@ -100,20 +189,23 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
100
189
|
}),
|
|
101
190
|
import: (path) => Effect.gen(function* () {
|
|
102
191
|
const filePath = resolve(path ?? DEFAULT_JSONL_PATH);
|
|
103
|
-
// Check if file exists
|
|
104
|
-
|
|
105
|
-
|
|
192
|
+
// Check if file exists (outside transaction - no DB access)
|
|
193
|
+
const importFileExists = yield* fileExists(filePath);
|
|
194
|
+
if (!importFileExists) {
|
|
195
|
+
return EMPTY_IMPORT_RESULT;
|
|
106
196
|
}
|
|
107
|
-
// Read and parse JSONL file
|
|
108
|
-
const content = yield* Effect.
|
|
109
|
-
try: () =>
|
|
197
|
+
// Read and parse JSONL file (outside transaction - no DB access)
|
|
198
|
+
const content = yield* Effect.tryPromise({
|
|
199
|
+
try: () => readFile(filePath, "utf-8"),
|
|
110
200
|
catch: (cause) => new DatabaseError({ cause })
|
|
111
201
|
});
|
|
112
202
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
113
203
|
if (lines.length === 0) {
|
|
114
|
-
return
|
|
204
|
+
return EMPTY_IMPORT_RESULT;
|
|
115
205
|
}
|
|
116
|
-
//
|
|
206
|
+
// Compute hash of file content for concurrent modification detection (TOCTOU protection)
|
|
207
|
+
const fileHash = createHash("sha256").update(content).digest("hex");
|
|
208
|
+
// Parse all operations with Schema validation (outside transaction - no DB access)
|
|
117
209
|
const ops = [];
|
|
118
210
|
for (const line of lines) {
|
|
119
211
|
const parsed = yield* Effect.try({
|
|
@@ -144,104 +236,249 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
144
236
|
}
|
|
145
237
|
}
|
|
146
238
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
239
|
+
// Apply task operations in topological order (parents before children)
|
|
240
|
+
// This ensures foreign key constraints are satisfied when importing
|
|
241
|
+
// tasks where child timestamp < parent timestamp
|
|
242
|
+
const sortedTaskEntries = topologicalSortTasks([...taskStates.entries()]);
|
|
243
|
+
// Prepare statements outside transaction to minimize write lock duration.
|
|
244
|
+
// better-sqlite3 prepared statements are reusable across transactions.
|
|
245
|
+
const findTaskStmt = db.prepare("SELECT * FROM tasks WHERE id = ?");
|
|
246
|
+
const insertTaskStmt = db.prepare(`INSERT INTO tasks (id, title, description, status, parent_id, score, created_at, updated_at, completed_at, metadata)
|
|
247
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
248
|
+
const updateTaskStmt = db.prepare(`UPDATE tasks SET title = ?, description = ?, status = ?, parent_id = ?,
|
|
249
|
+
score = ?, updated_at = ?, completed_at = ?, metadata = ? WHERE id = ?`);
|
|
250
|
+
const deleteTaskStmt = db.prepare("DELETE FROM tasks WHERE id = ?");
|
|
251
|
+
const insertDepStmt = db.prepare("INSERT INTO task_dependencies (blocker_id, blocked_id, created_at) VALUES (?, ?, ?)");
|
|
252
|
+
const checkDepExistsStmt = db.prepare("SELECT 1 FROM task_dependencies WHERE blocker_id = ? AND blocked_id = ?");
|
|
253
|
+
const deleteDepStmt = db.prepare("DELETE FROM task_dependencies WHERE blocker_id = ? AND blocked_id = ?");
|
|
254
|
+
const setConfigStmt = db.prepare("INSERT OR REPLACE INTO sync_config (key, value, updated_at) VALUES (?, ?, datetime('now'))");
|
|
255
|
+
const checkParentExistsStmt = db.prepare("SELECT 1 FROM tasks WHERE id = ?");
|
|
256
|
+
// ALL database operations inside a single transaction for atomicity
|
|
257
|
+
// If any operation fails, the entire import is rolled back
|
|
258
|
+
return yield* Effect.acquireUseRelease(
|
|
259
|
+
// Acquire: Begin transaction
|
|
260
|
+
Effect.try({
|
|
261
|
+
try: () => db.exec("BEGIN IMMEDIATE"),
|
|
262
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
263
|
+
}),
|
|
264
|
+
// Use: Run all database operations
|
|
265
|
+
() => Effect.try({
|
|
266
|
+
try: () => {
|
|
267
|
+
let imported = 0;
|
|
268
|
+
let skipped = 0;
|
|
269
|
+
let conflicts = 0;
|
|
270
|
+
// Dependency tracking
|
|
271
|
+
let depsAdded = 0;
|
|
272
|
+
let depsRemoved = 0;
|
|
273
|
+
let depsSkipped = 0;
|
|
274
|
+
const depFailures = [];
|
|
275
|
+
// Apply task operations
|
|
276
|
+
for (const [id, { op }] of sortedTaskEntries) {
|
|
277
|
+
if (op.op === "upsert") {
|
|
278
|
+
const existingRow = findTaskStmt.get(id);
|
|
279
|
+
// Validate parentId: if it references a task that doesn't exist
|
|
280
|
+
// in the DB, set to null to avoid FK constraint violation.
|
|
281
|
+
// Topological sort ensures parents in the import set are already
|
|
282
|
+
// inserted by this point, so a missing parent is truly orphaned.
|
|
283
|
+
const parentId = op.data.parentId;
|
|
284
|
+
const effectiveParentId = parentId && checkParentExistsStmt.get(parentId)
|
|
285
|
+
? parentId
|
|
286
|
+
: null;
|
|
287
|
+
if (!existingRow) {
|
|
288
|
+
// Create new task with the specified ID
|
|
289
|
+
const now = new Date();
|
|
290
|
+
insertTaskStmt.run(id, op.data.title, op.data.description, op.data.status, effectiveParentId, op.data.score, op.ts, op.ts, op.data.status === "done" ? now.toISOString() : null, JSON.stringify(op.data.metadata));
|
|
291
|
+
imported++;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
// Update if JSONL timestamp is newer than existing
|
|
295
|
+
const existingTs = existingRow.updated_at;
|
|
296
|
+
if (op.ts > existingTs) {
|
|
297
|
+
updateTaskStmt.run(op.data.title, op.data.description, op.data.status, effectiveParentId, op.data.score, op.ts, op.data.status === "done" ? (existingRow.completed_at ?? new Date().toISOString()) : null, JSON.stringify(op.data.metadata), id);
|
|
298
|
+
imported++;
|
|
299
|
+
}
|
|
300
|
+
else if (op.ts === existingTs) {
|
|
301
|
+
// Same timestamp - skip
|
|
302
|
+
skipped++;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
// Local is newer - conflict
|
|
306
|
+
conflicts++;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else if (op.op === "delete") {
|
|
311
|
+
const existingRow = findTaskStmt.get(id);
|
|
312
|
+
if (existingRow) {
|
|
313
|
+
// Check timestamp - only delete if delete operation is newer
|
|
314
|
+
// Per DD-009 Scenario 2: delete wins if its timestamp > local update timestamp
|
|
315
|
+
const existingTs = existingRow.updated_at;
|
|
316
|
+
if (op.ts > existingTs) {
|
|
317
|
+
deleteTaskStmt.run(id);
|
|
318
|
+
imported++;
|
|
319
|
+
}
|
|
320
|
+
else if (op.ts === existingTs) {
|
|
321
|
+
// Same timestamp - skip (ambiguous state, but safe to keep local)
|
|
322
|
+
skipped++;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
// Local is newer - conflict (local update wins over older delete)
|
|
326
|
+
conflicts++;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
189
329
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
330
|
+
}
|
|
331
|
+
// Apply dependency operations with individual error tracking
|
|
332
|
+
for (const { op } of depStates.values()) {
|
|
333
|
+
if (op.op === "dep_add") {
|
|
334
|
+
// Check if dependency already exists
|
|
335
|
+
const exists = checkDepExistsStmt.get(op.blockerId, op.blockedId);
|
|
336
|
+
if (exists) {
|
|
337
|
+
depsSkipped++;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
// Try to add dependency, track failures individually
|
|
341
|
+
try {
|
|
342
|
+
insertDepStmt.run(op.blockerId, op.blockedId, op.ts);
|
|
343
|
+
depsAdded++;
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
// Dependency insert failed (e.g., foreign key constraint, circular dependency)
|
|
347
|
+
depFailures.push({
|
|
348
|
+
blockerId: op.blockerId,
|
|
349
|
+
blockedId: op.blockedId,
|
|
350
|
+
error: e instanceof Error ? e.message : String(e)
|
|
351
|
+
});
|
|
352
|
+
}
|
|
193
353
|
}
|
|
194
|
-
else {
|
|
195
|
-
//
|
|
196
|
-
|
|
354
|
+
else if (op.op === "dep_remove") {
|
|
355
|
+
// Remove dependency - track if it actually existed
|
|
356
|
+
const result = deleteDepStmt.run(op.blockerId, op.blockedId);
|
|
357
|
+
if (result.changes > 0) {
|
|
358
|
+
depsRemoved++;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
depsSkipped++;
|
|
362
|
+
}
|
|
197
363
|
}
|
|
198
364
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
365
|
+
// If any dependency inserts failed, abort the entire transaction.
|
|
366
|
+
// This ensures atomicity: tasks and their dependencies are imported
|
|
367
|
+
// together or not at all. A partial import (tasks without deps) would
|
|
368
|
+
// leave the dependency graph incomplete.
|
|
369
|
+
if (depFailures.length > 0) {
|
|
370
|
+
const details = depFailures
|
|
371
|
+
.map(f => `${f.blockerId} -> ${f.blockedId}: ${f.error}`)
|
|
372
|
+
.join("; ");
|
|
373
|
+
throw new Error(`Sync import rolled back: ${depFailures.length} dependency failure(s): ${details}`);
|
|
205
374
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
375
|
+
// Verify file hasn't been modified during import (TOCTOU protection).
|
|
376
|
+
// Re-read synchronously while holding the DB write lock (BEGIN IMMEDIATE).
|
|
377
|
+
// If another process exported between our initial read and now, the hash
|
|
378
|
+
// will differ and we roll back to avoid committing stale data.
|
|
379
|
+
const verifyContent = readFileSync(filePath, "utf-8");
|
|
380
|
+
const verifyHash = createHash("sha256").update(verifyContent).digest("hex");
|
|
381
|
+
if (verifyHash !== fileHash) {
|
|
382
|
+
throw new Error("Sync import rolled back: JSONL file was modified during import (concurrent export detected). Retry the import.");
|
|
383
|
+
}
|
|
384
|
+
// Record import time
|
|
385
|
+
setConfigStmt.run("last_import", new Date().toISOString());
|
|
386
|
+
return {
|
|
387
|
+
imported,
|
|
388
|
+
skipped,
|
|
389
|
+
conflicts,
|
|
390
|
+
dependencies: {
|
|
391
|
+
added: depsAdded,
|
|
392
|
+
removed: depsRemoved,
|
|
393
|
+
skipped: depsSkipped,
|
|
394
|
+
failures: depFailures
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
},
|
|
398
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
399
|
+
}),
|
|
400
|
+
// Release: Commit on success, rollback on failure
|
|
401
|
+
(_, exit) => Exit.isSuccess(exit)
|
|
402
|
+
? Effect.try({
|
|
403
|
+
try: () => db.exec("COMMIT"),
|
|
404
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
405
|
+
}).pipe(Effect.orDie)
|
|
406
|
+
: Effect.try({
|
|
407
|
+
try: () => db.exec("ROLLBACK"),
|
|
408
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
409
|
+
}).pipe(Effect.catchAll((rollbackError) => Effect.log(`ROLLBACK failed: ${rollbackError.cause}`))));
|
|
222
410
|
}),
|
|
223
411
|
status: () => Effect.gen(function* () {
|
|
224
412
|
const filePath = resolve(DEFAULT_JSONL_PATH);
|
|
225
|
-
// Count tasks in database
|
|
226
|
-
const
|
|
227
|
-
|
|
413
|
+
// Count tasks in database (SQL COUNT instead of loading all rows)
|
|
414
|
+
const dbTaskCount = yield* taskService.count();
|
|
415
|
+
// Count dependencies in database (SQL COUNT instead of loading all rows)
|
|
416
|
+
const dbDepCount = yield* Effect.try({
|
|
417
|
+
try: () => {
|
|
418
|
+
const row = db.prepare("SELECT COUNT(*) as cnt FROM task_dependencies").get();
|
|
419
|
+
return row.cnt;
|
|
420
|
+
},
|
|
421
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
422
|
+
});
|
|
228
423
|
// Count operations in JSONL file and get file info
|
|
229
424
|
let jsonlOpCount = 0;
|
|
425
|
+
let jsonlTaskCount = 0;
|
|
426
|
+
let jsonlDepCount = 0;
|
|
230
427
|
let lastExport = null;
|
|
231
|
-
|
|
428
|
+
const jsonlFileExists = yield* fileExists(filePath);
|
|
429
|
+
if (jsonlFileExists) {
|
|
232
430
|
// Get file modification time as lastExport
|
|
233
|
-
const stats = yield* Effect.
|
|
234
|
-
try: () =>
|
|
431
|
+
const stats = yield* Effect.tryPromise({
|
|
432
|
+
try: () => stat(filePath),
|
|
235
433
|
catch: (cause) => new DatabaseError({ cause })
|
|
236
434
|
});
|
|
237
435
|
lastExport = stats.mtime;
|
|
238
436
|
// Count non-empty lines (each line is one operation)
|
|
239
|
-
const content = yield* Effect.
|
|
240
|
-
try: () =>
|
|
437
|
+
const content = yield* Effect.tryPromise({
|
|
438
|
+
try: () => readFile(filePath, "utf-8"),
|
|
241
439
|
catch: (cause) => new DatabaseError({ cause })
|
|
242
440
|
});
|
|
243
441
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
244
442
|
jsonlOpCount = lines.length;
|
|
443
|
+
// Parse JSONL to count EFFECTIVE task and dependency states
|
|
444
|
+
// After git merges, the file may have multiple operations for the same entity
|
|
445
|
+
// We need to deduplicate by ID and track the latest operation (timestamp wins)
|
|
446
|
+
// to get accurate counts that match what the DB state should be after import
|
|
447
|
+
const taskStates = new Map();
|
|
448
|
+
const depStates = new Map();
|
|
449
|
+
for (const line of lines) {
|
|
450
|
+
try {
|
|
451
|
+
const op = JSON.parse(line);
|
|
452
|
+
if (op.op === "upsert" || op.op === "delete") {
|
|
453
|
+
const existing = taskStates.get(op.id);
|
|
454
|
+
if (!existing || op.ts > existing.ts) {
|
|
455
|
+
taskStates.set(op.id, { op: op.op, ts: op.ts });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else if (op.op === "dep_add" || op.op === "dep_remove") {
|
|
459
|
+
const key = `${op.blockerId}:${op.blockedId}`;
|
|
460
|
+
const existing = depStates.get(key);
|
|
461
|
+
if (!existing || op.ts > existing.ts) {
|
|
462
|
+
depStates.set(key, { op: op.op, ts: op.ts });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// Skip malformed lines for counting purposes
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// Count only entities whose latest operation is an "add" operation
|
|
471
|
+
// (upsert for tasks, dep_add for dependencies)
|
|
472
|
+
for (const state of taskStates.values()) {
|
|
473
|
+
if (state.op === "upsert") {
|
|
474
|
+
jsonlTaskCount++;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
for (const state of depStates.values()) {
|
|
478
|
+
if (state.op === "dep_add") {
|
|
479
|
+
jsonlDepCount++;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
245
482
|
}
|
|
246
483
|
// Get last export/import timestamps from config
|
|
247
484
|
const lastExportConfig = yield* getConfig("last_export");
|
|
@@ -252,13 +489,43 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
252
489
|
const autoSyncConfig = yield* getConfig("auto_sync");
|
|
253
490
|
const autoSyncEnabled = autoSyncConfig === "true";
|
|
254
491
|
// Determine if dirty: DB has changes not in JSONL
|
|
492
|
+
// Per DD-009: dirty if tasks exist AND (no lastExport OR any task/dep updated after lastExport)
|
|
493
|
+
// Additionally: dirty if counts differ (indicates deletions/removals)
|
|
255
494
|
let isDirty = false;
|
|
256
|
-
if (dbTaskCount > 0 && !
|
|
495
|
+
if (dbTaskCount > 0 && !jsonlFileExists) {
|
|
496
|
+
// No JSONL file but tasks exist → dirty
|
|
257
497
|
isDirty = true;
|
|
258
498
|
}
|
|
259
|
-
else if (
|
|
260
|
-
|
|
261
|
-
|
|
499
|
+
else if (dbTaskCount > 0 || dbDepCount > 0) {
|
|
500
|
+
if (lastExportDate === null) {
|
|
501
|
+
// Tasks/deps exist but never exported → dirty
|
|
502
|
+
isDirty = true;
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
// Check if any task was updated after the last export (uses idx_tasks_updated index)
|
|
506
|
+
const lastExportIso = lastExportDate.toISOString();
|
|
507
|
+
const tasksDirty = yield* Effect.try({
|
|
508
|
+
try: () => {
|
|
509
|
+
const row = db.prepare("SELECT COUNT(*) as cnt FROM tasks WHERE updated_at > ?").get(lastExportIso);
|
|
510
|
+
return row.cnt > 0;
|
|
511
|
+
},
|
|
512
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
513
|
+
});
|
|
514
|
+
// Check if any dependency was created after the last export
|
|
515
|
+
const depsDirty = yield* Effect.try({
|
|
516
|
+
try: () => {
|
|
517
|
+
const row = db.prepare("SELECT COUNT(*) as cnt FROM task_dependencies WHERE created_at > ?").get(lastExportIso);
|
|
518
|
+
return row.cnt > 0;
|
|
519
|
+
},
|
|
520
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
521
|
+
});
|
|
522
|
+
// Check if counts differ (indicates deletions occurred since export)
|
|
523
|
+
// DB count < JSONL count means tasks/deps were deleted
|
|
524
|
+
// DB count > JSONL count means tasks/deps were added (also caught by timestamp check)
|
|
525
|
+
const taskCountMismatch = dbTaskCount !== jsonlTaskCount;
|
|
526
|
+
const depCountMismatch = dbDepCount !== jsonlDepCount;
|
|
527
|
+
isDirty = tasksDirty || depsDirty || taskCountMismatch || depCountMismatch;
|
|
528
|
+
}
|
|
262
529
|
}
|
|
263
530
|
return {
|
|
264
531
|
dbTaskCount,
|
|
@@ -278,12 +545,13 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
278
545
|
compact: (path) => Effect.gen(function* () {
|
|
279
546
|
const filePath = resolve(path ?? DEFAULT_JSONL_PATH);
|
|
280
547
|
// Check if file exists
|
|
281
|
-
|
|
548
|
+
const compactFileExists = yield* fileExists(filePath);
|
|
549
|
+
if (!compactFileExists) {
|
|
282
550
|
return { before: 0, after: 0 };
|
|
283
551
|
}
|
|
284
552
|
// Read and parse JSONL file
|
|
285
|
-
const content = yield* Effect.
|
|
286
|
-
try: () =>
|
|
553
|
+
const content = yield* Effect.tryPromise({
|
|
554
|
+
try: () => readFile(filePath, "utf-8"),
|
|
287
555
|
catch: (cause) => new DatabaseError({ cause })
|
|
288
556
|
});
|
|
289
557
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
@@ -337,10 +605,7 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
337
605
|
compacted.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
338
606
|
// Write compacted JSONL atomically
|
|
339
607
|
const newContent = compacted.map(op => JSON.stringify(op)).join("\n");
|
|
340
|
-
yield*
|
|
341
|
-
try: () => atomicWrite(filePath, newContent + (newContent.length > 0 ? "\n" : "")),
|
|
342
|
-
catch: (cause) => new DatabaseError({ cause })
|
|
343
|
-
});
|
|
608
|
+
yield* atomicWrite(filePath, newContent + (newContent.length > 0 ? "\n" : ""));
|
|
344
609
|
return { before, after: compacted.length };
|
|
345
610
|
}),
|
|
346
611
|
setLastExport: (timestamp) => setConfig("last_export", timestamp.toISOString()),
|