@jamesaphoenix/tx-core 0.4.2 → 0.4.3
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 +102 -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/anchor.d.ts +15 -1
- package/dist/mappers/anchor.d.ts.map +1 -1
- package/dist/mappers/anchor.js +95 -28
- package/dist/mappers/anchor.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/candidate.d.ts +3 -1
- package/dist/mappers/candidate.d.ts.map +1 -1
- package/dist/mappers/candidate.js +46 -16
- package/dist/mappers/candidate.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/edge.d.ts +10 -1
- package/dist/mappers/edge.d.ts.map +1 -1
- package/dist/mappers/edge.js +74 -12
- package/dist/mappers/edge.js.map +1 -1
- 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/anchor-repo.d.ts +2 -2
- package/dist/repo/anchor-repo.d.ts.map +1 -1
- package/dist/repo/anchor-repo.js +46 -5
- package/dist/repo/anchor-repo.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/candidate-repo.d.ts.map +1 -1
- package/dist/repo/candidate-repo.js +22 -1
- package/dist/repo/candidate-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/compaction-repo.d.ts +41 -0
- package/dist/repo/compaction-repo.d.ts.map +1 -0
- package/dist/repo/compaction-repo.js +84 -0
- package/dist/repo/compaction-repo.js.map +1 -0
- 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/edge-repo.d.ts +1 -1
- package/dist/repo/edge-repo.d.ts.map +1 -1
- package/dist/repo/edge-repo.js +65 -34
- package/dist/repo/edge-repo.js.map +1 -1
- 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/anchor-service.js +1 -1
- package/dist/services/anchor-service.js.map +1 -1
- package/dist/services/anchor-verification.d.ts +8 -0
- package/dist/services/anchor-verification.d.ts.map +1 -1
- package/dist/services/anchor-verification.js +237 -37
- package/dist/services/anchor-verification.js.map +1 -1
- package/dist/services/ast-grep-service.d.ts.map +1 -1
- package/dist/services/ast-grep-service.js +93 -22
- package/dist/services/ast-grep-service.js.map +1 -1
- 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 +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/compaction-service.d.ts +105 -0
- package/dist/services/compaction-service.d.ts.map +1 -0
- package/dist/services/compaction-service.js +369 -0
- package/dist/services/compaction-service.js.map +1 -0
- 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 +542 -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/edge-service.js +2 -2
- package/dist/services/edge-service.js.map +1 -1
- 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/graph-expansion.d.ts +3 -0
- package/dist/services/graph-expansion.d.ts.map +1 -1
- package/dist/services/graph-expansion.js +28 -7
- package/dist/services/graph-expansion.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 +4 -4
- 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 +67 -29
- package/dist/services/orchestrator-service.js.map +1 -1
- package/dist/services/promotion-service.d.ts +1 -1
- package/dist/services/promotion-service.js +1 -1
- package/dist/services/promotion-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/swarm-verification.d.ts +2 -2
- package/dist/services/swarm-verification.d.ts.map +1 -1
- package/dist/services/swarm-verification.js +12 -6
- package/dist/services/swarm-verification.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 +378 -114
- 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 +162 -33
- 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 +159 -70
- 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
|
@@ -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,248 @@ 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
|
+
}
|
|
189
309
|
}
|
|
190
|
-
else if (op.
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
|
193
329
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
}
|
|
353
|
+
}
|
|
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
|
}
|
|
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) => Effect.sync(() => {
|
|
402
|
+
if (Exit.isSuccess(exit)) {
|
|
403
|
+
db.exec("COMMIT");
|
|
206
404
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
for (const { op } of depStates.values()) {
|
|
210
|
-
if (op.op === "dep_add") {
|
|
211
|
-
// Add dependency, ignore if already exists
|
|
212
|
-
yield* depRepo.insert(op.blockerId, op.blockedId).pipe(Effect.catchAll(() => Effect.void));
|
|
213
|
-
}
|
|
214
|
-
else if (op.op === "dep_remove") {
|
|
215
|
-
// Remove dependency, ignore if doesn't exist
|
|
216
|
-
yield* depRepo.remove(op.blockerId, op.blockedId).pipe(Effect.catchAll(() => Effect.void));
|
|
405
|
+
else {
|
|
406
|
+
db.exec("ROLLBACK");
|
|
217
407
|
}
|
|
218
|
-
}
|
|
219
|
-
// Record import time
|
|
220
|
-
yield* setConfig("last_import", new Date().toISOString());
|
|
221
|
-
return { imported, skipped, conflicts };
|
|
408
|
+
}));
|
|
222
409
|
}),
|
|
223
410
|
status: () => Effect.gen(function* () {
|
|
224
411
|
const filePath = resolve(DEFAULT_JSONL_PATH);
|
|
225
|
-
// Count tasks in database
|
|
226
|
-
const
|
|
227
|
-
|
|
412
|
+
// Count tasks in database (SQL COUNT instead of loading all rows)
|
|
413
|
+
const dbTaskCount = yield* taskService.count();
|
|
414
|
+
// Count dependencies in database (SQL COUNT instead of loading all rows)
|
|
415
|
+
const dbDepCount = yield* Effect.try({
|
|
416
|
+
try: () => {
|
|
417
|
+
const row = db.prepare("SELECT COUNT(*) as cnt FROM task_dependencies").get();
|
|
418
|
+
return row.cnt;
|
|
419
|
+
},
|
|
420
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
421
|
+
});
|
|
228
422
|
// Count operations in JSONL file and get file info
|
|
229
423
|
let jsonlOpCount = 0;
|
|
424
|
+
let jsonlTaskCount = 0;
|
|
425
|
+
let jsonlDepCount = 0;
|
|
230
426
|
let lastExport = null;
|
|
231
|
-
|
|
427
|
+
const jsonlFileExists = yield* fileExists(filePath);
|
|
428
|
+
if (jsonlFileExists) {
|
|
232
429
|
// Get file modification time as lastExport
|
|
233
|
-
const stats = yield* Effect.
|
|
234
|
-
try: () =>
|
|
430
|
+
const stats = yield* Effect.tryPromise({
|
|
431
|
+
try: () => stat(filePath),
|
|
235
432
|
catch: (cause) => new DatabaseError({ cause })
|
|
236
433
|
});
|
|
237
434
|
lastExport = stats.mtime;
|
|
238
435
|
// Count non-empty lines (each line is one operation)
|
|
239
|
-
const content = yield* Effect.
|
|
240
|
-
try: () =>
|
|
436
|
+
const content = yield* Effect.tryPromise({
|
|
437
|
+
try: () => readFile(filePath, "utf-8"),
|
|
241
438
|
catch: (cause) => new DatabaseError({ cause })
|
|
242
439
|
});
|
|
243
440
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
244
441
|
jsonlOpCount = lines.length;
|
|
442
|
+
// Parse JSONL to count EFFECTIVE task and dependency states
|
|
443
|
+
// After git merges, the file may have multiple operations for the same entity
|
|
444
|
+
// We need to deduplicate by ID and track the latest operation (timestamp wins)
|
|
445
|
+
// to get accurate counts that match what the DB state should be after import
|
|
446
|
+
const taskStates = new Map();
|
|
447
|
+
const depStates = new Map();
|
|
448
|
+
for (const line of lines) {
|
|
449
|
+
try {
|
|
450
|
+
const op = JSON.parse(line);
|
|
451
|
+
if (op.op === "upsert" || op.op === "delete") {
|
|
452
|
+
const existing = taskStates.get(op.id);
|
|
453
|
+
if (!existing || op.ts > existing.ts) {
|
|
454
|
+
taskStates.set(op.id, { op: op.op, ts: op.ts });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else if (op.op === "dep_add" || op.op === "dep_remove") {
|
|
458
|
+
const key = `${op.blockerId}:${op.blockedId}`;
|
|
459
|
+
const existing = depStates.get(key);
|
|
460
|
+
if (!existing || op.ts > existing.ts) {
|
|
461
|
+
depStates.set(key, { op: op.op, ts: op.ts });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// Skip malformed lines for counting purposes
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Count only entities whose latest operation is an "add" operation
|
|
470
|
+
// (upsert for tasks, dep_add for dependencies)
|
|
471
|
+
for (const state of taskStates.values()) {
|
|
472
|
+
if (state.op === "upsert") {
|
|
473
|
+
jsonlTaskCount++;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
for (const state of depStates.values()) {
|
|
477
|
+
if (state.op === "dep_add") {
|
|
478
|
+
jsonlDepCount++;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
245
481
|
}
|
|
246
482
|
// Get last export/import timestamps from config
|
|
247
483
|
const lastExportConfig = yield* getConfig("last_export");
|
|
@@ -252,13 +488,43 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
252
488
|
const autoSyncConfig = yield* getConfig("auto_sync");
|
|
253
489
|
const autoSyncEnabled = autoSyncConfig === "true";
|
|
254
490
|
// Determine if dirty: DB has changes not in JSONL
|
|
491
|
+
// Per DD-009: dirty if tasks exist AND (no lastExport OR any task/dep updated after lastExport)
|
|
492
|
+
// Additionally: dirty if counts differ (indicates deletions/removals)
|
|
255
493
|
let isDirty = false;
|
|
256
|
-
if (dbTaskCount > 0 && !
|
|
494
|
+
if (dbTaskCount > 0 && !jsonlFileExists) {
|
|
495
|
+
// No JSONL file but tasks exist → dirty
|
|
257
496
|
isDirty = true;
|
|
258
497
|
}
|
|
259
|
-
else if (
|
|
260
|
-
|
|
261
|
-
|
|
498
|
+
else if (dbTaskCount > 0 || dbDepCount > 0) {
|
|
499
|
+
if (lastExportDate === null) {
|
|
500
|
+
// Tasks/deps exist but never exported → dirty
|
|
501
|
+
isDirty = true;
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
// Check if any task was updated after the last export (uses idx_tasks_updated index)
|
|
505
|
+
const lastExportIso = lastExportDate.toISOString();
|
|
506
|
+
const tasksDirty = yield* Effect.try({
|
|
507
|
+
try: () => {
|
|
508
|
+
const row = db.prepare("SELECT COUNT(*) as cnt FROM tasks WHERE updated_at > ?").get(lastExportIso);
|
|
509
|
+
return row.cnt > 0;
|
|
510
|
+
},
|
|
511
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
512
|
+
});
|
|
513
|
+
// Check if any dependency was created after the last export
|
|
514
|
+
const depsDirty = yield* Effect.try({
|
|
515
|
+
try: () => {
|
|
516
|
+
const row = db.prepare("SELECT COUNT(*) as cnt FROM task_dependencies WHERE created_at > ?").get(lastExportIso);
|
|
517
|
+
return row.cnt > 0;
|
|
518
|
+
},
|
|
519
|
+
catch: (cause) => new DatabaseError({ cause })
|
|
520
|
+
});
|
|
521
|
+
// Check if counts differ (indicates deletions occurred since export)
|
|
522
|
+
// DB count < JSONL count means tasks/deps were deleted
|
|
523
|
+
// DB count > JSONL count means tasks/deps were added (also caught by timestamp check)
|
|
524
|
+
const taskCountMismatch = dbTaskCount !== jsonlTaskCount;
|
|
525
|
+
const depCountMismatch = dbDepCount !== jsonlDepCount;
|
|
526
|
+
isDirty = tasksDirty || depsDirty || taskCountMismatch || depCountMismatch;
|
|
527
|
+
}
|
|
262
528
|
}
|
|
263
529
|
return {
|
|
264
530
|
dbTaskCount,
|
|
@@ -278,12 +544,13 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
278
544
|
compact: (path) => Effect.gen(function* () {
|
|
279
545
|
const filePath = resolve(path ?? DEFAULT_JSONL_PATH);
|
|
280
546
|
// Check if file exists
|
|
281
|
-
|
|
547
|
+
const compactFileExists = yield* fileExists(filePath);
|
|
548
|
+
if (!compactFileExists) {
|
|
282
549
|
return { before: 0, after: 0 };
|
|
283
550
|
}
|
|
284
551
|
// Read and parse JSONL file
|
|
285
|
-
const content = yield* Effect.
|
|
286
|
-
try: () =>
|
|
552
|
+
const content = yield* Effect.tryPromise({
|
|
553
|
+
try: () => readFile(filePath, "utf-8"),
|
|
287
554
|
catch: (cause) => new DatabaseError({ cause })
|
|
288
555
|
});
|
|
289
556
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
@@ -337,10 +604,7 @@ export const SyncServiceLive = Layer.effect(SyncService, Effect.gen(function* ()
|
|
|
337
604
|
compacted.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
338
605
|
// Write compacted JSONL atomically
|
|
339
606
|
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
|
-
});
|
|
607
|
+
yield* atomicWrite(filePath, newContent + (newContent.length > 0 ? "\n" : ""));
|
|
344
608
|
return { before, after: compacted.length };
|
|
345
609
|
}),
|
|
346
610
|
setLastExport: (timestamp) => setConfig("last_export", timestamp.toISOString()),
|