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