@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.
Files changed (330) hide show
  1. package/README.md +480 -0
  2. package/dist/db.d.ts +28 -14
  3. package/dist/db.d.ts.map +1 -1
  4. package/dist/db.js +102 -14
  5. package/dist/db.js.map +1 -1
  6. package/dist/errors.d.ts +178 -34
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +119 -26
  9. package/dist/errors.js.map +1 -1
  10. package/dist/id.d.ts +10 -0
  11. package/dist/id.d.ts.map +1 -1
  12. package/dist/id.js +17 -1
  13. package/dist/id.js.map +1 -1
  14. package/dist/index.d.ts +15 -7
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +62 -8
  17. package/dist/index.js.map +1 -1
  18. package/dist/layer.d.ts +23 -14
  19. package/dist/layer.d.ts.map +1 -1
  20. package/dist/layer.js +75 -76
  21. package/dist/layer.js.map +1 -1
  22. package/dist/mappers/anchor.d.ts +15 -1
  23. package/dist/mappers/anchor.d.ts.map +1 -1
  24. package/dist/mappers/anchor.js +95 -28
  25. package/dist/mappers/anchor.js.map +1 -1
  26. package/dist/mappers/attempt.d.ts +3 -1
  27. package/dist/mappers/attempt.d.ts.map +1 -1
  28. package/dist/mappers/attempt.js +23 -9
  29. package/dist/mappers/attempt.js.map +1 -1
  30. package/dist/mappers/candidate.d.ts +3 -1
  31. package/dist/mappers/candidate.d.ts.map +1 -1
  32. package/dist/mappers/candidate.js +46 -16
  33. package/dist/mappers/candidate.js.map +1 -1
  34. package/dist/mappers/claim.d.ts +1 -1
  35. package/dist/mappers/claim.d.ts.map +1 -1
  36. package/dist/mappers/claim.js +11 -4
  37. package/dist/mappers/claim.js.map +1 -1
  38. package/dist/mappers/deduplication.d.ts +13 -1
  39. package/dist/mappers/deduplication.d.ts.map +1 -1
  40. package/dist/mappers/deduplication.js +22 -3
  41. package/dist/mappers/deduplication.js.map +1 -1
  42. package/dist/mappers/doc.d.ts +24 -0
  43. package/dist/mappers/doc.d.ts.map +1 -0
  44. package/dist/mappers/doc.js +161 -0
  45. package/dist/mappers/doc.js.map +1 -0
  46. package/dist/mappers/edge.d.ts +10 -1
  47. package/dist/mappers/edge.d.ts.map +1 -1
  48. package/dist/mappers/edge.js +74 -12
  49. package/dist/mappers/edge.js.map +1 -1
  50. package/dist/mappers/file-learning.d.ts.map +1 -1
  51. package/dist/mappers/file-learning.js +2 -1
  52. package/dist/mappers/file-learning.js.map +1 -1
  53. package/dist/mappers/index.d.ts +6 -7
  54. package/dist/mappers/index.d.ts.map +1 -1
  55. package/dist/mappers/index.js +10 -12
  56. package/dist/mappers/index.js.map +1 -1
  57. package/dist/mappers/learning.d.ts +9 -1
  58. package/dist/mappers/learning.d.ts.map +1 -1
  59. package/dist/mappers/learning.js +94 -14
  60. package/dist/mappers/learning.js.map +1 -1
  61. package/dist/mappers/orchestrator-state.d.ts +1 -1
  62. package/dist/mappers/orchestrator-state.d.ts.map +1 -1
  63. package/dist/mappers/orchestrator-state.js +31 -5
  64. package/dist/mappers/orchestrator-state.js.map +1 -1
  65. package/dist/mappers/parse-date.d.ts +11 -0
  66. package/dist/mappers/parse-date.d.ts.map +1 -0
  67. package/dist/mappers/parse-date.js +18 -0
  68. package/dist/mappers/parse-date.js.map +1 -0
  69. package/dist/mappers/run.d.ts +14 -4
  70. package/dist/mappers/run.d.ts.map +1 -1
  71. package/dist/mappers/run.js +49 -18
  72. package/dist/mappers/run.js.map +1 -1
  73. package/dist/mappers/task.d.ts +5 -1
  74. package/dist/mappers/task.d.ts.map +1 -1
  75. package/dist/mappers/task.js +66 -16
  76. package/dist/mappers/task.js.map +1 -1
  77. package/dist/mappers/tracked-project.d.ts +3 -1
  78. package/dist/mappers/tracked-project.d.ts.map +1 -1
  79. package/dist/mappers/tracked-project.js +23 -9
  80. package/dist/mappers/tracked-project.js.map +1 -1
  81. package/dist/mappers/worker.d.ts +1 -1
  82. package/dist/mappers/worker.d.ts.map +1 -1
  83. package/dist/mappers/worker.js +44 -6
  84. package/dist/mappers/worker.js.map +1 -1
  85. package/dist/repo/anchor-repo.d.ts +2 -2
  86. package/dist/repo/anchor-repo.d.ts.map +1 -1
  87. package/dist/repo/anchor-repo.js +46 -5
  88. package/dist/repo/anchor-repo.js.map +1 -1
  89. package/dist/repo/attempt-repo.d.ts +2 -2
  90. package/dist/repo/attempt-repo.d.ts.map +1 -1
  91. package/dist/repo/attempt-repo.js +16 -6
  92. package/dist/repo/attempt-repo.js.map +1 -1
  93. package/dist/repo/candidate-repo.d.ts.map +1 -1
  94. package/dist/repo/candidate-repo.js +22 -1
  95. package/dist/repo/candidate-repo.js.map +1 -1
  96. package/dist/repo/claim-repo.d.ts +46 -2
  97. package/dist/repo/claim-repo.d.ts.map +1 -1
  98. package/dist/repo/claim-repo.js +113 -6
  99. package/dist/repo/claim-repo.js.map +1 -1
  100. package/dist/repo/compaction-repo.d.ts +41 -0
  101. package/dist/repo/compaction-repo.d.ts.map +1 -0
  102. package/dist/repo/compaction-repo.js +84 -0
  103. package/dist/repo/compaction-repo.js.map +1 -0
  104. package/dist/repo/deduplication-repo.d.ts +9 -1
  105. package/dist/repo/deduplication-repo.d.ts.map +1 -1
  106. package/dist/repo/deduplication-repo.js +46 -9
  107. package/dist/repo/deduplication-repo.js.map +1 -1
  108. package/dist/repo/dep-repo.d.ts +27 -3
  109. package/dist/repo/dep-repo.d.ts.map +1 -1
  110. package/dist/repo/dep-repo.js +166 -39
  111. package/dist/repo/dep-repo.js.map +1 -1
  112. package/dist/repo/doc-repo.d.ts +59 -0
  113. package/dist/repo/doc-repo.d.ts.map +1 -0
  114. package/dist/repo/doc-repo.js +276 -0
  115. package/dist/repo/doc-repo.js.map +1 -0
  116. package/dist/repo/edge-repo.d.ts +1 -1
  117. package/dist/repo/edge-repo.d.ts.map +1 -1
  118. package/dist/repo/edge-repo.js +65 -34
  119. package/dist/repo/edge-repo.js.map +1 -1
  120. package/dist/repo/file-learning-repo.d.ts +3 -3
  121. package/dist/repo/file-learning-repo.d.ts.map +1 -1
  122. package/dist/repo/file-learning-repo.js +19 -8
  123. package/dist/repo/file-learning-repo.js.map +1 -1
  124. package/dist/repo/index.d.ts +4 -6
  125. package/dist/repo/index.d.ts.map +1 -1
  126. package/dist/repo/index.js +3 -5
  127. package/dist/repo/index.js.map +1 -1
  128. package/dist/repo/learning-repo.d.ts +10 -3
  129. package/dist/repo/learning-repo.d.ts.map +1 -1
  130. package/dist/repo/learning-repo.js +68 -11
  131. package/dist/repo/learning-repo.js.map +1 -1
  132. package/dist/repo/orchestrator-state-repo.d.ts.map +1 -1
  133. package/dist/repo/orchestrator-state-repo.js +8 -1
  134. package/dist/repo/orchestrator-state-repo.js.map +1 -1
  135. package/dist/repo/run-repo.d.ts +3 -3
  136. package/dist/repo/run-repo.d.ts.map +1 -1
  137. package/dist/repo/run-repo.js +40 -19
  138. package/dist/repo/run-repo.js.map +1 -1
  139. package/dist/repo/task-repo.d.ts +14 -3
  140. package/dist/repo/task-repo.d.ts.map +1 -1
  141. package/dist/repo/task-repo.js +194 -20
  142. package/dist/repo/task-repo.js.map +1 -1
  143. package/dist/repo/tracked-project-repo.d.ts.map +1 -1
  144. package/dist/repo/tracked-project-repo.js +15 -1
  145. package/dist/repo/tracked-project-repo.js.map +1 -1
  146. package/dist/repo/worker-repo.d.ts +3 -2
  147. package/dist/repo/worker-repo.d.ts.map +1 -1
  148. package/dist/repo/worker-repo.js +54 -8
  149. package/dist/repo/worker-repo.js.map +1 -1
  150. package/dist/schemas/sync.js +2 -2
  151. package/dist/schemas/sync.js.map +1 -1
  152. package/dist/schemas/worker.d.ts +1 -0
  153. package/dist/schemas/worker.d.ts.map +1 -1
  154. package/dist/schemas/worker.js +1 -0
  155. package/dist/schemas/worker.js.map +1 -1
  156. package/dist/services/agent-service.d.ts +57 -0
  157. package/dist/services/agent-service.d.ts.map +1 -0
  158. package/dist/services/agent-service.js +81 -0
  159. package/dist/services/agent-service.js.map +1 -0
  160. package/dist/services/anchor-service.js +1 -1
  161. package/dist/services/anchor-service.js.map +1 -1
  162. package/dist/services/anchor-verification.d.ts +8 -0
  163. package/dist/services/anchor-verification.d.ts.map +1 -1
  164. package/dist/services/anchor-verification.js +237 -37
  165. package/dist/services/anchor-verification.js.map +1 -1
  166. package/dist/services/ast-grep-service.d.ts.map +1 -1
  167. package/dist/services/ast-grep-service.js +93 -22
  168. package/dist/services/ast-grep-service.js.map +1 -1
  169. package/dist/services/attempt-service.d.ts.map +1 -1
  170. package/dist/services/attempt-service.js +1 -4
  171. package/dist/services/attempt-service.js.map +1 -1
  172. package/dist/services/auto-sync-service.d.ts +1 -1
  173. package/dist/services/auto-sync-service.d.ts.map +1 -1
  174. package/dist/services/auto-sync-service.js +18 -10
  175. package/dist/services/auto-sync-service.js.map +1 -1
  176. package/dist/services/claim-service.d.ts +8 -2
  177. package/dist/services/claim-service.d.ts.map +1 -1
  178. package/dist/services/claim-service.js +37 -23
  179. package/dist/services/claim-service.js.map +1 -1
  180. package/dist/services/compaction-service.d.ts +105 -0
  181. package/dist/services/compaction-service.d.ts.map +1 -0
  182. package/dist/services/compaction-service.js +369 -0
  183. package/dist/services/compaction-service.js.map +1 -0
  184. package/dist/services/cycle-scan-service.d.ts +32 -0
  185. package/dist/services/cycle-scan-service.d.ts.map +1 -0
  186. package/dist/services/cycle-scan-service.js +542 -0
  187. package/dist/services/cycle-scan-service.js.map +1 -0
  188. package/dist/services/daemon-service.d.ts +40 -2
  189. package/dist/services/daemon-service.d.ts.map +1 -1
  190. package/dist/services/daemon-service.js +199 -52
  191. package/dist/services/daemon-service.js.map +1 -1
  192. package/dist/services/deduplication-service.d.ts +8 -4
  193. package/dist/services/deduplication-service.d.ts.map +1 -1
  194. package/dist/services/deduplication-service.js +79 -25
  195. package/dist/services/deduplication-service.js.map +1 -1
  196. package/dist/services/dep-service.d.ts +2 -2
  197. package/dist/services/dep-service.d.ts.map +1 -1
  198. package/dist/services/dep-service.js +9 -5
  199. package/dist/services/dep-service.js.map +1 -1
  200. package/dist/services/diversifier-service.d.ts +2 -1
  201. package/dist/services/diversifier-service.d.ts.map +1 -1
  202. package/dist/services/diversifier-service.js +37 -43
  203. package/dist/services/diversifier-service.js.map +1 -1
  204. package/dist/services/doc-service.d.ts +49 -0
  205. package/dist/services/doc-service.d.ts.map +1 -0
  206. package/dist/services/doc-service.js +605 -0
  207. package/dist/services/doc-service.js.map +1 -0
  208. package/dist/services/edge-service.js +2 -2
  209. package/dist/services/edge-service.js.map +1 -1
  210. package/dist/services/embedding-service.d.ts +66 -2
  211. package/dist/services/embedding-service.d.ts.map +1 -1
  212. package/dist/services/embedding-service.js +138 -24
  213. package/dist/services/embedding-service.js.map +1 -1
  214. package/dist/services/file-learning-service.d.ts.map +1 -1
  215. package/dist/services/file-learning-service.js +8 -7
  216. package/dist/services/file-learning-service.js.map +1 -1
  217. package/dist/services/file-watcher-service.d.ts.map +1 -1
  218. package/dist/services/file-watcher-service.js +58 -11
  219. package/dist/services/file-watcher-service.js.map +1 -1
  220. package/dist/services/graph-expansion.d.ts +3 -0
  221. package/dist/services/graph-expansion.d.ts.map +1 -1
  222. package/dist/services/graph-expansion.js +28 -7
  223. package/dist/services/graph-expansion.js.map +1 -1
  224. package/dist/services/hierarchy-service.d.ts +1 -1
  225. package/dist/services/hierarchy-service.d.ts.map +1 -1
  226. package/dist/services/hierarchy-service.js +50 -32
  227. package/dist/services/hierarchy-service.js.map +1 -1
  228. package/dist/services/index.d.ts +13 -15
  229. package/dist/services/index.d.ts.map +1 -1
  230. package/dist/services/index.js +13 -15
  231. package/dist/services/index.js.map +1 -1
  232. package/dist/services/learning-service.d.ts +4 -4
  233. package/dist/services/learning-service.d.ts.map +1 -1
  234. package/dist/services/learning-service.js +75 -42
  235. package/dist/services/learning-service.js.map +1 -1
  236. package/dist/services/llm-service.d.ts +62 -0
  237. package/dist/services/llm-service.d.ts.map +1 -0
  238. package/dist/services/llm-service.js +172 -0
  239. package/dist/services/llm-service.js.map +1 -0
  240. package/dist/services/migration-service.d.ts +1 -1
  241. package/dist/services/migration-service.d.ts.map +1 -1
  242. package/dist/services/migration-service.js +18 -7
  243. package/dist/services/migration-service.js.map +1 -1
  244. package/dist/services/orchestrator-service.d.ts +4 -3
  245. package/dist/services/orchestrator-service.d.ts.map +1 -1
  246. package/dist/services/orchestrator-service.js +67 -29
  247. package/dist/services/orchestrator-service.js.map +1 -1
  248. package/dist/services/promotion-service.d.ts +1 -1
  249. package/dist/services/promotion-service.js +1 -1
  250. package/dist/services/promotion-service.js.map +1 -1
  251. package/dist/services/query-expansion-service.d.ts +30 -9
  252. package/dist/services/query-expansion-service.d.ts.map +1 -1
  253. package/dist/services/query-expansion-service.js +54 -63
  254. package/dist/services/query-expansion-service.js.map +1 -1
  255. package/dist/services/ready-service.d.ts +21 -1
  256. package/dist/services/ready-service.d.ts.map +1 -1
  257. package/dist/services/ready-service.js +44 -21
  258. package/dist/services/ready-service.js.map +1 -1
  259. package/dist/services/retriever-service.d.ts +10 -10
  260. package/dist/services/retriever-service.d.ts.map +1 -1
  261. package/dist/services/retriever-service.js +53 -161
  262. package/dist/services/retriever-service.js.map +1 -1
  263. package/dist/services/swarm-verification.d.ts +2 -2
  264. package/dist/services/swarm-verification.d.ts.map +1 -1
  265. package/dist/services/swarm-verification.js +12 -6
  266. package/dist/services/swarm-verification.js.map +1 -1
  267. package/dist/services/sync-service.d.ts +17 -4
  268. package/dist/services/sync-service.d.ts.map +1 -1
  269. package/dist/services/sync-service.js +378 -114
  270. package/dist/services/sync-service.js.map +1 -1
  271. package/dist/services/task-service.d.ts +6 -4
  272. package/dist/services/task-service.d.ts.map +1 -1
  273. package/dist/services/task-service.js +162 -33
  274. package/dist/services/task-service.js.map +1 -1
  275. package/dist/services/tracing-service.d.ts +55 -0
  276. package/dist/services/tracing-service.d.ts.map +1 -0
  277. package/dist/services/tracing-service.js +99 -0
  278. package/dist/services/tracing-service.js.map +1 -0
  279. package/dist/services/transcript-adapter.d.ts +99 -0
  280. package/dist/services/transcript-adapter.d.ts.map +1 -0
  281. package/dist/services/transcript-adapter.js +283 -0
  282. package/dist/services/transcript-adapter.js.map +1 -0
  283. package/dist/services/validation-service.d.ts +85 -0
  284. package/dist/services/validation-service.d.ts.map +1 -0
  285. package/dist/services/validation-service.js +289 -0
  286. package/dist/services/validation-service.js.map +1 -0
  287. package/dist/services/worker-process.d.ts +23 -4
  288. package/dist/services/worker-process.d.ts.map +1 -1
  289. package/dist/services/worker-process.js +159 -70
  290. package/dist/services/worker-process.js.map +1 -1
  291. package/dist/services/worker-service.d.ts.map +1 -1
  292. package/dist/services/worker-service.js +7 -12
  293. package/dist/services/worker-service.js.map +1 -1
  294. package/dist/sync/claude-task-writer.d.ts +49 -0
  295. package/dist/sync/claude-task-writer.d.ts.map +1 -0
  296. package/dist/sync/claude-task-writer.js +135 -0
  297. package/dist/sync/claude-task-writer.js.map +1 -0
  298. package/dist/utils/doc-hash.d.ts +10 -0
  299. package/dist/utils/doc-hash.d.ts.map +1 -0
  300. package/dist/utils/doc-hash.js +14 -0
  301. package/dist/utils/doc-hash.js.map +1 -0
  302. package/dist/utils/doc-renderer.d.ts +44 -0
  303. package/dist/utils/doc-renderer.d.ts.map +1 -0
  304. package/dist/utils/doc-renderer.js +202 -0
  305. package/dist/utils/doc-renderer.js.map +1 -0
  306. package/dist/utils/math.d.ts +5 -1
  307. package/dist/utils/math.d.ts.map +1 -1
  308. package/dist/utils/math.js +12 -4
  309. package/dist/utils/math.js.map +1 -1
  310. package/dist/utils/sql.d.ts +9 -0
  311. package/dist/utils/sql.d.ts.map +1 -0
  312. package/dist/utils/sql.js +9 -0
  313. package/dist/utils/sql.js.map +1 -0
  314. package/dist/utils/toml-config.d.ts +22 -0
  315. package/dist/utils/toml-config.d.ts.map +1 -0
  316. package/dist/utils/toml-config.js +75 -0
  317. package/dist/utils/toml-config.js.map +1 -0
  318. package/dist/worker/hooks.d.ts +102 -0
  319. package/dist/worker/hooks.d.ts.map +1 -0
  320. package/dist/worker/hooks.js +11 -0
  321. package/dist/worker/hooks.js.map +1 -0
  322. package/dist/worker/index.d.ts +9 -0
  323. package/dist/worker/index.d.ts.map +1 -0
  324. package/dist/worker/index.js +8 -0
  325. package/dist/worker/index.js.map +1 -0
  326. package/dist/worker/run-worker.d.ts +33 -0
  327. package/dist/worker/run-worker.d.ts.map +1 -0
  328. package/dist/worker/run-worker.js +265 -0
  329. package/dist/worker/run-worker.js.map +1 -0
  330. package/package.json +14 -12
@@ -1,10 +1,11 @@
1
- import { Context, Effect, Layer, Schema } from "effect";
2
- import { writeFileSync, renameSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
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
- const dir = dirname(filePath);
49
- if (!existsSync(dir)) {
50
- mkdirSync(dir, { recursive: true });
51
- }
52
- const tempPath = `${filePath}.tmp.${Date.now()}`;
53
- writeFileSync(tempPath, content, "utf-8");
54
- renameSync(tempPath, filePath);
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* Effect.try({
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
- if (!existsSync(filePath)) {
105
- return { imported: 0, skipped: 0, conflicts: 0 };
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.try({
109
- try: () => readFileSync(filePath, "utf-8"),
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 { imported: 0, skipped: 0, conflicts: 0 };
204
+ return EMPTY_IMPORT_RESULT;
115
205
  }
116
- // Parse all operations with Schema validation
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
- let imported = 0;
148
- let skipped = 0;
149
- let conflicts = 0;
150
- // Apply task operations
151
- for (const [id, { op }] of taskStates) {
152
- if (op.op === "upsert") {
153
- const existing = yield* taskRepo.findById(id);
154
- if (!existing) {
155
- // Create new task with the specified ID
156
- const now = new Date();
157
- const task = {
158
- id: id,
159
- title: op.data.title,
160
- description: op.data.description,
161
- status: op.data.status,
162
- parentId: op.data.parentId,
163
- score: op.data.score,
164
- createdAt: new Date(op.ts),
165
- updatedAt: new Date(op.ts),
166
- completedAt: op.data.status === "done" ? now : null,
167
- metadata: op.data.metadata
168
- };
169
- yield* taskRepo.insert(task);
170
- imported++;
171
- }
172
- else {
173
- // Update if JSONL timestamp is newer than existing
174
- const existingTs = existing.updatedAt.toISOString();
175
- if (op.ts > existingTs) {
176
- const updated = {
177
- ...existing,
178
- title: op.data.title,
179
- description: op.data.description,
180
- status: op.data.status,
181
- parentId: op.data.parentId,
182
- score: op.data.score,
183
- updatedAt: new Date(op.ts),
184
- completedAt: op.data.status === "done" ? (existing.completedAt ?? new Date()) : null,
185
- metadata: op.data.metadata
186
- };
187
- yield* taskRepo.update(updated);
188
- imported++;
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.ts === existingTs) {
191
- // Same timestamp - skip
192
- skipped++;
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
- else {
195
- // Local is newer - conflict
196
- conflicts++;
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
- else if (op.op === "delete") {
201
- const existing = yield* taskRepo.findById(id);
202
- if (existing) {
203
- yield* taskRepo.remove(id);
204
- imported++;
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
- // Apply dependency operations
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 tasks = yield* taskService.list();
227
- const dbTaskCount = tasks.length;
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
- if (existsSync(filePath)) {
427
+ const jsonlFileExists = yield* fileExists(filePath);
428
+ if (jsonlFileExists) {
232
429
  // Get file modification time as lastExport
233
- const stats = yield* Effect.try({
234
- try: () => statSync(filePath),
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.try({
240
- try: () => readFileSync(filePath, "utf-8"),
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 && !existsSync(filePath)) {
494
+ if (dbTaskCount > 0 && !jsonlFileExists) {
495
+ // No JSONL file but tasks exist → dirty
257
496
  isDirty = true;
258
497
  }
259
- else if (lastExportDate !== null && tasks.length > 0) {
260
- // Check if any task was updated after the last export
261
- isDirty = tasks.some(task => task.updatedAt > lastExportDate);
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
- if (!existsSync(filePath)) {
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.try({
286
- try: () => readFileSync(filePath, "utf-8"),
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* Effect.try({
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()),