@stoneforge/quarry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +160 -0
  3. package/dist/api/index.d.ts +8 -0
  4. package/dist/api/index.d.ts.map +1 -0
  5. package/dist/api/index.js +8 -0
  6. package/dist/api/index.js.map +1 -0
  7. package/dist/api/quarry-api.d.ts +268 -0
  8. package/dist/api/quarry-api.d.ts.map +1 -0
  9. package/dist/api/quarry-api.js +3905 -0
  10. package/dist/api/quarry-api.js.map +1 -0
  11. package/dist/api/types.d.ts +1359 -0
  12. package/dist/api/types.d.ts.map +1 -0
  13. package/dist/api/types.js +204 -0
  14. package/dist/api/types.js.map +1 -0
  15. package/dist/bin/sf.d.ts +3 -0
  16. package/dist/bin/sf.d.ts.map +1 -0
  17. package/dist/bin/sf.js +9 -0
  18. package/dist/bin/sf.js.map +1 -0
  19. package/dist/cli/commands/admin.d.ts +11 -0
  20. package/dist/cli/commands/admin.d.ts.map +1 -0
  21. package/dist/cli/commands/admin.js +465 -0
  22. package/dist/cli/commands/admin.js.map +1 -0
  23. package/dist/cli/commands/alias.d.ts +8 -0
  24. package/dist/cli/commands/alias.d.ts.map +1 -0
  25. package/dist/cli/commands/alias.js +70 -0
  26. package/dist/cli/commands/alias.js.map +1 -0
  27. package/dist/cli/commands/channel.d.ts +13 -0
  28. package/dist/cli/commands/channel.d.ts.map +1 -0
  29. package/dist/cli/commands/channel.js +680 -0
  30. package/dist/cli/commands/channel.js.map +1 -0
  31. package/dist/cli/commands/completion.d.ts +8 -0
  32. package/dist/cli/commands/completion.d.ts.map +1 -0
  33. package/dist/cli/commands/completion.js +87 -0
  34. package/dist/cli/commands/completion.js.map +1 -0
  35. package/dist/cli/commands/config.d.ts +12 -0
  36. package/dist/cli/commands/config.d.ts.map +1 -0
  37. package/dist/cli/commands/config.js +242 -0
  38. package/dist/cli/commands/config.js.map +1 -0
  39. package/dist/cli/commands/crud.d.ts +64 -0
  40. package/dist/cli/commands/crud.d.ts.map +1 -0
  41. package/dist/cli/commands/crud.js +805 -0
  42. package/dist/cli/commands/crud.js.map +1 -0
  43. package/dist/cli/commands/dep.d.ts +16 -0
  44. package/dist/cli/commands/dep.d.ts.map +1 -0
  45. package/dist/cli/commands/dep.js +499 -0
  46. package/dist/cli/commands/dep.js.map +1 -0
  47. package/dist/cli/commands/document.d.ts +12 -0
  48. package/dist/cli/commands/document.d.ts.map +1 -0
  49. package/dist/cli/commands/document.js +1039 -0
  50. package/dist/cli/commands/document.js.map +1 -0
  51. package/dist/cli/commands/embeddings.d.ts +12 -0
  52. package/dist/cli/commands/embeddings.d.ts.map +1 -0
  53. package/dist/cli/commands/embeddings.js +273 -0
  54. package/dist/cli/commands/embeddings.js.map +1 -0
  55. package/dist/cli/commands/entity.d.ts +16 -0
  56. package/dist/cli/commands/entity.d.ts.map +1 -0
  57. package/dist/cli/commands/entity.js +522 -0
  58. package/dist/cli/commands/entity.js.map +1 -0
  59. package/dist/cli/commands/gc.d.ts +10 -0
  60. package/dist/cli/commands/gc.d.ts.map +1 -0
  61. package/dist/cli/commands/gc.js +257 -0
  62. package/dist/cli/commands/gc.js.map +1 -0
  63. package/dist/cli/commands/help.d.ts +11 -0
  64. package/dist/cli/commands/help.d.ts.map +1 -0
  65. package/dist/cli/commands/help.js +169 -0
  66. package/dist/cli/commands/help.js.map +1 -0
  67. package/dist/cli/commands/history.d.ts +9 -0
  68. package/dist/cli/commands/history.d.ts.map +1 -0
  69. package/dist/cli/commands/history.js +160 -0
  70. package/dist/cli/commands/history.js.map +1 -0
  71. package/dist/cli/commands/identity.d.ts +18 -0
  72. package/dist/cli/commands/identity.d.ts.map +1 -0
  73. package/dist/cli/commands/identity.js +698 -0
  74. package/dist/cli/commands/identity.js.map +1 -0
  75. package/dist/cli/commands/inbox.d.ts +20 -0
  76. package/dist/cli/commands/inbox.d.ts.map +1 -0
  77. package/dist/cli/commands/inbox.js +493 -0
  78. package/dist/cli/commands/inbox.js.map +1 -0
  79. package/dist/cli/commands/init.d.ts +20 -0
  80. package/dist/cli/commands/init.d.ts.map +1 -0
  81. package/dist/cli/commands/init.js +144 -0
  82. package/dist/cli/commands/init.js.map +1 -0
  83. package/dist/cli/commands/install.d.ts +9 -0
  84. package/dist/cli/commands/install.d.ts.map +1 -0
  85. package/dist/cli/commands/install.js +200 -0
  86. package/dist/cli/commands/install.js.map +1 -0
  87. package/dist/cli/commands/library.d.ts +12 -0
  88. package/dist/cli/commands/library.d.ts.map +1 -0
  89. package/dist/cli/commands/library.js +665 -0
  90. package/dist/cli/commands/library.js.map +1 -0
  91. package/dist/cli/commands/message.d.ts +11 -0
  92. package/dist/cli/commands/message.d.ts.map +1 -0
  93. package/dist/cli/commands/message.js +608 -0
  94. package/dist/cli/commands/message.js.map +1 -0
  95. package/dist/cli/commands/plan.d.ts +17 -0
  96. package/dist/cli/commands/plan.d.ts.map +1 -0
  97. package/dist/cli/commands/plan.js +698 -0
  98. package/dist/cli/commands/plan.js.map +1 -0
  99. package/dist/cli/commands/playbook.d.ts +12 -0
  100. package/dist/cli/commands/playbook.d.ts.map +1 -0
  101. package/dist/cli/commands/playbook.js +730 -0
  102. package/dist/cli/commands/playbook.js.map +1 -0
  103. package/dist/cli/commands/reset.d.ts +12 -0
  104. package/dist/cli/commands/reset.d.ts.map +1 -0
  105. package/dist/cli/commands/reset.js +306 -0
  106. package/dist/cli/commands/reset.js.map +1 -0
  107. package/dist/cli/commands/serve.d.ts +11 -0
  108. package/dist/cli/commands/serve.d.ts.map +1 -0
  109. package/dist/cli/commands/serve.js +106 -0
  110. package/dist/cli/commands/serve.js.map +1 -0
  111. package/dist/cli/commands/stats.d.ts +8 -0
  112. package/dist/cli/commands/stats.d.ts.map +1 -0
  113. package/dist/cli/commands/stats.js +82 -0
  114. package/dist/cli/commands/stats.js.map +1 -0
  115. package/dist/cli/commands/sync.d.ts +14 -0
  116. package/dist/cli/commands/sync.d.ts.map +1 -0
  117. package/dist/cli/commands/sync.js +370 -0
  118. package/dist/cli/commands/sync.js.map +1 -0
  119. package/dist/cli/commands/task.d.ts +25 -0
  120. package/dist/cli/commands/task.d.ts.map +1 -0
  121. package/dist/cli/commands/task.js +1153 -0
  122. package/dist/cli/commands/task.js.map +1 -0
  123. package/dist/cli/commands/team.d.ts +13 -0
  124. package/dist/cli/commands/team.d.ts.map +1 -0
  125. package/dist/cli/commands/team.js +471 -0
  126. package/dist/cli/commands/team.js.map +1 -0
  127. package/dist/cli/commands/workflow.d.ts +16 -0
  128. package/dist/cli/commands/workflow.d.ts.map +1 -0
  129. package/dist/cli/commands/workflow.js +753 -0
  130. package/dist/cli/commands/workflow.js.map +1 -0
  131. package/dist/cli/completion.d.ts +28 -0
  132. package/dist/cli/completion.d.ts.map +1 -0
  133. package/dist/cli/completion.js +295 -0
  134. package/dist/cli/completion.js.map +1 -0
  135. package/dist/cli/db.d.ts +38 -0
  136. package/dist/cli/db.d.ts.map +1 -0
  137. package/dist/cli/db.js +90 -0
  138. package/dist/cli/db.js.map +1 -0
  139. package/dist/cli/formatter.d.ts +87 -0
  140. package/dist/cli/formatter.d.ts.map +1 -0
  141. package/dist/cli/formatter.js +464 -0
  142. package/dist/cli/formatter.js.map +1 -0
  143. package/dist/cli/index.d.ts +33 -0
  144. package/dist/cli/index.d.ts.map +1 -0
  145. package/dist/cli/index.js +38 -0
  146. package/dist/cli/index.js.map +1 -0
  147. package/dist/cli/parser.d.ts +45 -0
  148. package/dist/cli/parser.d.ts.map +1 -0
  149. package/dist/cli/parser.js +256 -0
  150. package/dist/cli/parser.js.map +1 -0
  151. package/dist/cli/plugin-loader.d.ts +39 -0
  152. package/dist/cli/plugin-loader.d.ts.map +1 -0
  153. package/dist/cli/plugin-loader.js +165 -0
  154. package/dist/cli/plugin-loader.js.map +1 -0
  155. package/dist/cli/plugin-registry.d.ts +50 -0
  156. package/dist/cli/plugin-registry.d.ts.map +1 -0
  157. package/dist/cli/plugin-registry.js +206 -0
  158. package/dist/cli/plugin-registry.js.map +1 -0
  159. package/dist/cli/plugin-types.d.ts +106 -0
  160. package/dist/cli/plugin-types.d.ts.map +1 -0
  161. package/dist/cli/plugin-types.js +103 -0
  162. package/dist/cli/plugin-types.js.map +1 -0
  163. package/dist/cli/runner.d.ts +35 -0
  164. package/dist/cli/runner.d.ts.map +1 -0
  165. package/dist/cli/runner.js +340 -0
  166. package/dist/cli/runner.js.map +1 -0
  167. package/dist/cli/suggest.d.ts +15 -0
  168. package/dist/cli/suggest.d.ts.map +1 -0
  169. package/dist/cli/suggest.js +49 -0
  170. package/dist/cli/suggest.js.map +1 -0
  171. package/dist/cli/types.d.ts +138 -0
  172. package/dist/cli/types.d.ts.map +1 -0
  173. package/dist/cli/types.js +63 -0
  174. package/dist/cli/types.js.map +1 -0
  175. package/dist/config/config.d.ts +86 -0
  176. package/dist/config/config.d.ts.map +1 -0
  177. package/dist/config/config.js +348 -0
  178. package/dist/config/config.js.map +1 -0
  179. package/dist/config/defaults.d.ts +66 -0
  180. package/dist/config/defaults.d.ts.map +1 -0
  181. package/dist/config/defaults.js +114 -0
  182. package/dist/config/defaults.js.map +1 -0
  183. package/dist/config/duration.d.ts +75 -0
  184. package/dist/config/duration.d.ts.map +1 -0
  185. package/dist/config/duration.js +190 -0
  186. package/dist/config/duration.js.map +1 -0
  187. package/dist/config/env.d.ts +67 -0
  188. package/dist/config/env.d.ts.map +1 -0
  189. package/dist/config/env.js +207 -0
  190. package/dist/config/env.js.map +1 -0
  191. package/dist/config/file.d.ts +97 -0
  192. package/dist/config/file.d.ts.map +1 -0
  193. package/dist/config/file.js +365 -0
  194. package/dist/config/file.js.map +1 -0
  195. package/dist/config/index.d.ts +35 -0
  196. package/dist/config/index.d.ts.map +1 -0
  197. package/dist/config/index.js +41 -0
  198. package/dist/config/index.js.map +1 -0
  199. package/dist/config/merge.d.ts +53 -0
  200. package/dist/config/merge.d.ts.map +1 -0
  201. package/dist/config/merge.js +226 -0
  202. package/dist/config/merge.js.map +1 -0
  203. package/dist/config/types.d.ts +257 -0
  204. package/dist/config/types.d.ts.map +1 -0
  205. package/dist/config/types.js +72 -0
  206. package/dist/config/types.js.map +1 -0
  207. package/dist/config/validation.d.ts +55 -0
  208. package/dist/config/validation.d.ts.map +1 -0
  209. package/dist/config/validation.js +251 -0
  210. package/dist/config/validation.js.map +1 -0
  211. package/dist/http/index.d.ts +8 -0
  212. package/dist/http/index.d.ts.map +1 -0
  213. package/dist/http/index.js +12 -0
  214. package/dist/http/index.js.map +1 -0
  215. package/dist/http/sync-handlers.d.ts +162 -0
  216. package/dist/http/sync-handlers.d.ts.map +1 -0
  217. package/dist/http/sync-handlers.js +271 -0
  218. package/dist/http/sync-handlers.js.map +1 -0
  219. package/dist/index.d.ts +25 -0
  220. package/dist/index.d.ts.map +1 -0
  221. package/dist/index.js +69 -0
  222. package/dist/index.js.map +1 -0
  223. package/dist/server/index.d.ts +34 -0
  224. package/dist/server/index.d.ts.map +1 -0
  225. package/dist/server/index.js +3329 -0
  226. package/dist/server/index.js.map +1 -0
  227. package/dist/server/static.d.ts +18 -0
  228. package/dist/server/static.d.ts.map +1 -0
  229. package/dist/server/static.js +71 -0
  230. package/dist/server/static.js.map +1 -0
  231. package/dist/server/ws/broadcaster.d.ts +8 -0
  232. package/dist/server/ws/broadcaster.d.ts.map +1 -0
  233. package/dist/server/ws/broadcaster.js +7 -0
  234. package/dist/server/ws/broadcaster.js.map +1 -0
  235. package/dist/server/ws/handler.d.ts +55 -0
  236. package/dist/server/ws/handler.d.ts.map +1 -0
  237. package/dist/server/ws/handler.js +160 -0
  238. package/dist/server/ws/handler.js.map +1 -0
  239. package/dist/services/blocked-cache.d.ts +297 -0
  240. package/dist/services/blocked-cache.d.ts.map +1 -0
  241. package/dist/services/blocked-cache.js +755 -0
  242. package/dist/services/blocked-cache.js.map +1 -0
  243. package/dist/services/dependency.d.ts +205 -0
  244. package/dist/services/dependency.d.ts.map +1 -0
  245. package/dist/services/dependency.js +566 -0
  246. package/dist/services/dependency.js.map +1 -0
  247. package/dist/services/embeddings/fusion.d.ts +33 -0
  248. package/dist/services/embeddings/fusion.d.ts.map +1 -0
  249. package/dist/services/embeddings/fusion.js +34 -0
  250. package/dist/services/embeddings/fusion.js.map +1 -0
  251. package/dist/services/embeddings/index.d.ts +12 -0
  252. package/dist/services/embeddings/index.d.ts.map +1 -0
  253. package/dist/services/embeddings/index.js +10 -0
  254. package/dist/services/embeddings/index.js.map +1 -0
  255. package/dist/services/embeddings/local-provider.d.ts +31 -0
  256. package/dist/services/embeddings/local-provider.d.ts.map +1 -0
  257. package/dist/services/embeddings/local-provider.js +80 -0
  258. package/dist/services/embeddings/local-provider.js.map +1 -0
  259. package/dist/services/embeddings/service.d.ts +76 -0
  260. package/dist/services/embeddings/service.d.ts.map +1 -0
  261. package/dist/services/embeddings/service.js +153 -0
  262. package/dist/services/embeddings/service.js.map +1 -0
  263. package/dist/services/embeddings/types.d.ts +70 -0
  264. package/dist/services/embeddings/types.d.ts.map +1 -0
  265. package/dist/services/embeddings/types.js +8 -0
  266. package/dist/services/embeddings/types.js.map +1 -0
  267. package/dist/services/id-length-cache.d.ts +156 -0
  268. package/dist/services/id-length-cache.d.ts.map +1 -0
  269. package/dist/services/id-length-cache.js +197 -0
  270. package/dist/services/id-length-cache.js.map +1 -0
  271. package/dist/services/inbox.d.ts +147 -0
  272. package/dist/services/inbox.d.ts.map +1 -0
  273. package/dist/services/inbox.js +428 -0
  274. package/dist/services/inbox.js.map +1 -0
  275. package/dist/services/index.d.ts +10 -0
  276. package/dist/services/index.d.ts.map +1 -0
  277. package/dist/services/index.js +10 -0
  278. package/dist/services/index.js.map +1 -0
  279. package/dist/services/priority-service.d.ts +145 -0
  280. package/dist/services/priority-service.d.ts.map +1 -0
  281. package/dist/services/priority-service.js +272 -0
  282. package/dist/services/priority-service.js.map +1 -0
  283. package/dist/services/search-utils.d.ts +47 -0
  284. package/dist/services/search-utils.d.ts.map +1 -0
  285. package/dist/services/search-utils.js +83 -0
  286. package/dist/services/search-utils.js.map +1 -0
  287. package/dist/sync/hash.d.ts +48 -0
  288. package/dist/sync/hash.d.ts.map +1 -0
  289. package/dist/sync/hash.js +136 -0
  290. package/dist/sync/hash.js.map +1 -0
  291. package/dist/sync/index.d.ts +11 -0
  292. package/dist/sync/index.d.ts.map +1 -0
  293. package/dist/sync/index.js +16 -0
  294. package/dist/sync/index.js.map +1 -0
  295. package/dist/sync/merge.d.ts +80 -0
  296. package/dist/sync/merge.d.ts.map +1 -0
  297. package/dist/sync/merge.js +310 -0
  298. package/dist/sync/merge.js.map +1 -0
  299. package/dist/sync/serialization.d.ts +132 -0
  300. package/dist/sync/serialization.d.ts.map +1 -0
  301. package/dist/sync/serialization.js +306 -0
  302. package/dist/sync/serialization.js.map +1 -0
  303. package/dist/sync/service.d.ts +102 -0
  304. package/dist/sync/service.d.ts.map +1 -0
  305. package/dist/sync/service.js +493 -0
  306. package/dist/sync/service.js.map +1 -0
  307. package/dist/sync/types.d.ts +275 -0
  308. package/dist/sync/types.d.ts.map +1 -0
  309. package/dist/sync/types.js +76 -0
  310. package/dist/sync/types.js.map +1 -0
  311. package/dist/systems/identity.d.ts +479 -0
  312. package/dist/systems/identity.d.ts.map +1 -0
  313. package/dist/systems/identity.js +817 -0
  314. package/dist/systems/identity.js.map +1 -0
  315. package/dist/systems/index.d.ts +8 -0
  316. package/dist/systems/index.d.ts.map +1 -0
  317. package/dist/systems/index.js +29 -0
  318. package/dist/systems/index.js.map +1 -0
  319. package/package.json +121 -0
  320. package/web/assets/charts-vendor-D1YcbGux.js +55 -0
  321. package/web/assets/dnd-vendor-DmxE-_ZH.js +5 -0
  322. package/web/assets/editor-vendor-BxraAWts.js +279 -0
  323. package/web/assets/index-B77vv208.js +341 -0
  324. package/web/assets/index-CF_XnVLh.css +1 -0
  325. package/web/assets/router-vendor-BCKpRBrB.js +41 -0
  326. package/web/assets/ui-vendor-DUahGnbT.js +45 -0
  327. package/web/assets/utils-vendor-CfYKiENT.js +813 -0
  328. package/web/favicon.ico +0 -0
  329. package/web/index.html +23 -0
  330. package/web/logo.png +0 -0
@@ -0,0 +1,3905 @@
1
+ /**
2
+ * Stoneforge API Implementation
3
+ *
4
+ * This module provides the concrete implementation of the QuarryAPI interface,
5
+ * connecting the type system to the storage layer with full CRUD operations.
6
+ */
7
+ import { isDocument, reconstructStateAtTime, generateTimelineSnapshots, createTimestamp, isTask, TaskStatus as TaskStatusEnum, isPlan, PlanStatus as PlanStatusEnum, calculatePlanProgress, createEvent, LifecycleEventType, MembershipEventType, NotFoundError, ConflictError, ConstraintError, StorageError, ValidationError, ErrorCode, ChannelTypeValue, createDirectChannel, isMember, canModifyMembers, isDirectChannel, DirectChannelMembershipError, NotAMemberError, CannotModifyMembersError, createMessage, isMessage, isLibrary, isTeamDeleted, isTeamMember, extractMentionedNames, validateMentions, InboxSourceType, isWorkflow, WorkflowStatus as WorkflowStatusEnum, generateChildId, createTask, validateManager, getManagementChain as getManagementChainUtil, buildOrgChart, updateEntity, isEntityActive, } from '@stoneforge/core';
8
+ import { createBlockedCacheService } from '../services/blocked-cache.js';
9
+ import { createPriorityService } from '../services/priority-service.js';
10
+ import { createInboxService } from '../services/inbox.js';
11
+ import { SyncService } from '../sync/service.js';
12
+ import { computeContentHashSync } from '../sync/hash.js';
13
+ import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, } from './types.js';
14
+ import { applyAdaptiveTopK, escapeFts5Query } from '../services/search-utils.js';
15
+ // ============================================================================
16
+ // Helper Functions
17
+ // ============================================================================
18
+ /**
19
+ * Serialize an element to database format
20
+ */
21
+ function serializeElement(element) {
22
+ // Extract base element fields and type-specific data
23
+ const { id, type, createdAt, updatedAt, createdBy, tags, metadata, ...typeData } = element;
24
+ // Store type-specific fields in data JSON
25
+ const data = JSON.stringify({
26
+ ...typeData,
27
+ tags,
28
+ metadata,
29
+ });
30
+ // Check for deletedAt (tombstone status)
31
+ const deletedAt = 'deletedAt' in element ? element.deletedAt : null;
32
+ // Compute content hash for conflict detection
33
+ const { hash: contentHash } = computeContentHashSync(element);
34
+ return {
35
+ id,
36
+ type,
37
+ data,
38
+ content_hash: contentHash,
39
+ created_at: createdAt,
40
+ updated_at: updatedAt,
41
+ created_by: createdBy,
42
+ deleted_at: deletedAt ?? null,
43
+ };
44
+ }
45
+ /**
46
+ * Deserialize a database row to an element
47
+ */
48
+ function deserializeElement(row, tags) {
49
+ let data;
50
+ try {
51
+ data = JSON.parse(row.data);
52
+ }
53
+ catch (error) {
54
+ console.warn(`[stoneforge] Corrupt data for element ${row.id}, skipping:`, error);
55
+ return null;
56
+ }
57
+ return {
58
+ id: row.id,
59
+ type: row.type,
60
+ createdAt: row.created_at,
61
+ updatedAt: row.updated_at,
62
+ createdBy: row.created_by,
63
+ tags,
64
+ metadata: data.metadata ?? {},
65
+ ...data,
66
+ };
67
+ }
68
+ /**
69
+ * Build WHERE clause from ElementFilter
70
+ */
71
+ function buildWhereClause(filter, params) {
72
+ const conditions = [];
73
+ // Type filter
74
+ if (filter.type !== undefined) {
75
+ const types = Array.isArray(filter.type) ? filter.type : [filter.type];
76
+ const placeholders = types.map(() => '?').join(', ');
77
+ conditions.push(`e.type IN (${placeholders})`);
78
+ params.push(...types);
79
+ }
80
+ // Creator filter
81
+ if (filter.createdBy !== undefined) {
82
+ conditions.push('e.created_by = ?');
83
+ params.push(filter.createdBy);
84
+ }
85
+ // Created date filters
86
+ if (filter.createdAfter !== undefined) {
87
+ conditions.push('e.created_at >= ?');
88
+ params.push(filter.createdAfter);
89
+ }
90
+ if (filter.createdBefore !== undefined) {
91
+ conditions.push('e.created_at < ?');
92
+ params.push(filter.createdBefore);
93
+ }
94
+ // Updated date filters
95
+ if (filter.updatedAfter !== undefined) {
96
+ conditions.push('e.updated_at >= ?');
97
+ params.push(filter.updatedAfter);
98
+ }
99
+ if (filter.updatedBefore !== undefined) {
100
+ conditions.push('e.updated_at < ?');
101
+ params.push(filter.updatedBefore);
102
+ }
103
+ // Include deleted filter
104
+ if (!filter.includeDeleted) {
105
+ conditions.push('e.deleted_at IS NULL');
106
+ }
107
+ const where = conditions.length > 0 ? conditions.join(' AND ') : '1=1';
108
+ return { where, params };
109
+ }
110
+ /**
111
+ * Build task-specific WHERE clause additions
112
+ */
113
+ function buildTaskWhereClause(filter, params) {
114
+ const conditions = [];
115
+ // Status filter
116
+ if (filter.status !== undefined) {
117
+ const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
118
+ // Status is stored in data JSON, use JSON_EXTRACT
119
+ const statusConditions = statuses.map(() => "JSON_EXTRACT(e.data, '$.status') = ?").join(' OR ');
120
+ conditions.push(`(${statusConditions})`);
121
+ params.push(...statuses);
122
+ }
123
+ // Priority filter
124
+ if (filter.priority !== undefined) {
125
+ const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
126
+ const priorityConditions = priorities.map(() => "JSON_EXTRACT(e.data, '$.priority') = ?").join(' OR ');
127
+ conditions.push(`(${priorityConditions})`);
128
+ params.push(...priorities);
129
+ }
130
+ // Complexity filter
131
+ if (filter.complexity !== undefined) {
132
+ const complexities = Array.isArray(filter.complexity) ? filter.complexity : [filter.complexity];
133
+ const complexityConditions = complexities.map(() => "JSON_EXTRACT(e.data, '$.complexity') = ?").join(' OR ');
134
+ conditions.push(`(${complexityConditions})`);
135
+ params.push(...complexities);
136
+ }
137
+ // Assignee filter
138
+ if (filter.assignee !== undefined) {
139
+ conditions.push("JSON_EXTRACT(e.data, '$.assignee') = ?");
140
+ params.push(filter.assignee);
141
+ }
142
+ // Owner filter
143
+ if (filter.owner !== undefined) {
144
+ conditions.push("JSON_EXTRACT(e.data, '$.owner') = ?");
145
+ params.push(filter.owner);
146
+ }
147
+ // Task type filter
148
+ if (filter.taskType !== undefined) {
149
+ const taskTypes = Array.isArray(filter.taskType) ? filter.taskType : [filter.taskType];
150
+ const typeConditions = taskTypes.map(() => "JSON_EXTRACT(e.data, '$.taskType') = ?").join(' OR ');
151
+ conditions.push(`(${typeConditions})`);
152
+ params.push(...taskTypes);
153
+ }
154
+ // Deadline filters
155
+ if (filter.hasDeadline !== undefined) {
156
+ if (filter.hasDeadline) {
157
+ conditions.push("JSON_EXTRACT(e.data, '$.deadline') IS NOT NULL");
158
+ }
159
+ else {
160
+ conditions.push("JSON_EXTRACT(e.data, '$.deadline') IS NULL");
161
+ }
162
+ }
163
+ if (filter.deadlineBefore !== undefined) {
164
+ conditions.push("JSON_EXTRACT(e.data, '$.deadline') < ?");
165
+ params.push(filter.deadlineBefore);
166
+ }
167
+ const where = conditions.length > 0 ? conditions.join(' AND ') : '';
168
+ return { where, params };
169
+ }
170
+ /**
171
+ * Build channel-specific WHERE clause additions
172
+ */
173
+ function buildChannelWhereClause(filter, params) {
174
+ const conditions = [];
175
+ // Channel type filter (direct or group)
176
+ if (filter.channelType !== undefined) {
177
+ conditions.push("JSON_EXTRACT(e.data, '$.channelType') = ?");
178
+ params.push(filter.channelType);
179
+ }
180
+ // Visibility filter
181
+ if (filter.visibility !== undefined) {
182
+ conditions.push("JSON_EXTRACT(e.data, '$.permissions.visibility') = ?");
183
+ params.push(filter.visibility);
184
+ }
185
+ // Join policy filter
186
+ if (filter.joinPolicy !== undefined) {
187
+ conditions.push("JSON_EXTRACT(e.data, '$.permissions.joinPolicy') = ?");
188
+ params.push(filter.joinPolicy);
189
+ }
190
+ // Member filter - check if entity is in members array
191
+ if (filter.member !== undefined) {
192
+ // Using LIKE for JSON array membership check
193
+ conditions.push("JSON_EXTRACT(e.data, '$.members') LIKE ?");
194
+ params.push(`%"${filter.member}"%`);
195
+ }
196
+ const where = conditions.length > 0 ? conditions.join(' AND ') : '';
197
+ return { where, params };
198
+ }
199
+ /**
200
+ * Build document-specific WHERE clause additions
201
+ */
202
+ function buildDocumentWhereClause(filter, params) {
203
+ const conditions = [];
204
+ // Content type filter
205
+ if (filter.contentType !== undefined) {
206
+ const contentTypes = Array.isArray(filter.contentType) ? filter.contentType : [filter.contentType];
207
+ const typeConditions = contentTypes.map(() => "JSON_EXTRACT(e.data, '$.contentType') = ?").join(' OR ');
208
+ conditions.push(`(${typeConditions})`);
209
+ params.push(...contentTypes);
210
+ }
211
+ // Exact version filter
212
+ if (filter.version !== undefined) {
213
+ conditions.push("JSON_EXTRACT(e.data, '$.version') = ?");
214
+ params.push(filter.version);
215
+ }
216
+ // Minimum version filter (inclusive)
217
+ if (filter.minVersion !== undefined) {
218
+ conditions.push("JSON_EXTRACT(e.data, '$.version') >= ?");
219
+ params.push(filter.minVersion);
220
+ }
221
+ // Maximum version filter (inclusive)
222
+ if (filter.maxVersion !== undefined) {
223
+ conditions.push("JSON_EXTRACT(e.data, '$.version') <= ?");
224
+ params.push(filter.maxVersion);
225
+ }
226
+ // Category filter
227
+ if (filter.category !== undefined) {
228
+ const categories = Array.isArray(filter.category) ? filter.category : [filter.category];
229
+ const catConditions = categories.map(() => "JSON_EXTRACT(e.data, '$.category') = ?").join(' OR ');
230
+ conditions.push(`(${catConditions})`);
231
+ params.push(...categories);
232
+ }
233
+ // Status filter (default: active only)
234
+ if (filter.status !== undefined) {
235
+ const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
236
+ const statusConditions = statuses.map(() => "JSON_EXTRACT(e.data, '$.status') = ?").join(' OR ');
237
+ conditions.push(`(${statusConditions})`);
238
+ params.push(...statuses);
239
+ }
240
+ else {
241
+ // Default: only show active documents
242
+ conditions.push("JSON_EXTRACT(e.data, '$.status') = ?");
243
+ params.push('active');
244
+ }
245
+ const where = conditions.length > 0 ? conditions.join(' AND ') : '';
246
+ return { where, params };
247
+ }
248
+ /**
249
+ * Build message-specific WHERE clause additions
250
+ */
251
+ function buildMessageWhereClause(filter, params) {
252
+ const conditions = [];
253
+ // Channel filter
254
+ if (filter.channelId !== undefined) {
255
+ const channelIds = Array.isArray(filter.channelId) ? filter.channelId : [filter.channelId];
256
+ const channelConditions = channelIds.map(() => "JSON_EXTRACT(e.data, '$.channelId') = ?").join(' OR ');
257
+ conditions.push(`(${channelConditions})`);
258
+ params.push(...channelIds);
259
+ }
260
+ // Sender filter
261
+ if (filter.sender !== undefined) {
262
+ const senders = Array.isArray(filter.sender) ? filter.sender : [filter.sender];
263
+ const senderConditions = senders.map(() => "JSON_EXTRACT(e.data, '$.sender') = ?").join(' OR ');
264
+ conditions.push(`(${senderConditions})`);
265
+ params.push(...senders);
266
+ }
267
+ // Thread filter
268
+ if (filter.threadId !== undefined) {
269
+ if (filter.threadId === null) {
270
+ // Root messages only
271
+ conditions.push("JSON_EXTRACT(e.data, '$.threadId') IS NULL");
272
+ }
273
+ else {
274
+ // Messages in a specific thread
275
+ conditions.push("JSON_EXTRACT(e.data, '$.threadId') = ?");
276
+ params.push(filter.threadId);
277
+ }
278
+ }
279
+ // Has attachments filter
280
+ if (filter.hasAttachments !== undefined) {
281
+ if (filter.hasAttachments) {
282
+ // Has at least one attachment
283
+ conditions.push("JSON_ARRAY_LENGTH(JSON_EXTRACT(e.data, '$.attachments')) > 0");
284
+ }
285
+ else {
286
+ // No attachments
287
+ conditions.push("(JSON_EXTRACT(e.data, '$.attachments') IS NULL OR JSON_ARRAY_LENGTH(JSON_EXTRACT(e.data, '$.attachments')) = 0)");
288
+ }
289
+ }
290
+ const where = conditions.length > 0 ? conditions.join(' AND ') : '';
291
+ return { where, params };
292
+ }
293
+ // ============================================================================
294
+ // QuarryAPI Implementation
295
+ // ============================================================================
296
+ /**
297
+ * Implementation of the QuarryAPI interface
298
+ */
299
+ export class QuarryAPIImpl {
300
+ backend;
301
+ blockedCache;
302
+ priorityService;
303
+ syncService;
304
+ inboxService;
305
+ embeddingService;
306
+ constructor(backend) {
307
+ this.backend = backend;
308
+ this.blockedCache = createBlockedCacheService(backend);
309
+ this.priorityService = createPriorityService(backend);
310
+ this.syncService = new SyncService(backend);
311
+ this.inboxService = createInboxService(backend);
312
+ // Set up automatic status transitions for blocked/unblocked states
313
+ this.blockedCache.setStatusTransitionCallback({
314
+ onBlock: (elementId, previousStatus) => {
315
+ this.updateTaskStatusInternal(elementId, TaskStatusEnum.BLOCKED, previousStatus);
316
+ },
317
+ onUnblock: (elementId, statusToRestore) => {
318
+ this.updateTaskStatusInternal(elementId, statusToRestore, null);
319
+ },
320
+ });
321
+ }
322
+ /**
323
+ * Internal method to update task status without triggering additional blocked cache updates.
324
+ * Used for automatic blocked/unblocked status transitions.
325
+ */
326
+ updateTaskStatusInternal(elementId, newStatus, _previousStatus) {
327
+ // Get current element
328
+ const row = this.backend.queryOne('SELECT * FROM elements WHERE id = ?', [elementId]);
329
+ if (!row || row.type !== 'task') {
330
+ return;
331
+ }
332
+ // Parse current data
333
+ const data = JSON.parse(row.data);
334
+ const oldStatus = data.status;
335
+ // Don't update if already at target status
336
+ if (oldStatus === newStatus) {
337
+ return;
338
+ }
339
+ // Update status in data
340
+ data.status = newStatus;
341
+ // Update timestamps based on transition
342
+ const now = createTimestamp();
343
+ if (newStatus === TaskStatusEnum.CLOSED && !data.closedAt) {
344
+ data.closedAt = now;
345
+ }
346
+ else if (newStatus !== TaskStatusEnum.CLOSED && data.closedAt) {
347
+ data.closedAt = null;
348
+ }
349
+ // Update in database
350
+ this.backend.run(`UPDATE elements SET data = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), now, elementId]);
351
+ // Record event for automatic status transition
352
+ const eventType = newStatus === TaskStatusEnum.BLOCKED
353
+ ? 'auto_blocked'
354
+ : 'auto_unblocked';
355
+ const event = createEvent({
356
+ elementId,
357
+ eventType,
358
+ actor: 'system:blocked-cache',
359
+ oldValue: { status: oldStatus },
360
+ newValue: { status: newStatus },
361
+ });
362
+ this.backend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
363
+ VALUES (?, ?, ?, ?, ?, ?)`, [
364
+ event.elementId,
365
+ event.eventType,
366
+ event.actor,
367
+ JSON.stringify(event.oldValue),
368
+ JSON.stringify(event.newValue),
369
+ event.createdAt,
370
+ ]);
371
+ // Mark as dirty for sync
372
+ this.backend.markDirty(elementId);
373
+ }
374
+ // --------------------------------------------------------------------------
375
+ // CRUD Operations
376
+ // --------------------------------------------------------------------------
377
+ async get(id, options) {
378
+ // Query the element
379
+ const row = this.backend.queryOne('SELECT * FROM elements WHERE id = ?', [id]);
380
+ if (!row) {
381
+ return null;
382
+ }
383
+ // Get tags for this element
384
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [id]);
385
+ const tags = tagRows.map((r) => r.tag);
386
+ // Deserialize the element
387
+ let element = deserializeElement(row, tags);
388
+ if (!element)
389
+ return null;
390
+ // Handle hydration if requested
391
+ if (options?.hydrate) {
392
+ if (isTask(element)) {
393
+ element = await this.hydrateTask(element, options.hydrate);
394
+ }
395
+ else if (isMessage(element)) {
396
+ element = await this.hydrateMessage(element, options.hydrate);
397
+ }
398
+ else if (isLibrary(element)) {
399
+ element = await this.hydrateLibrary(element, options.hydrate);
400
+ }
401
+ }
402
+ return element;
403
+ }
404
+ async list(filter) {
405
+ const result = await this.listPaginated(filter);
406
+ return result.items;
407
+ }
408
+ async listPaginated(filter) {
409
+ const effectiveFilter = filter ?? {};
410
+ // Build base WHERE clause (params will be accumulated here)
411
+ const params = [];
412
+ const { where: baseWhere } = buildWhereClause(effectiveFilter, params);
413
+ // Build task-specific WHERE clause if filtering tasks
414
+ let taskWhere = '';
415
+ if (effectiveFilter.type === 'task' || (Array.isArray(effectiveFilter.type) && effectiveFilter.type.includes('task'))) {
416
+ const taskFilter = effectiveFilter;
417
+ const { where: tw } = buildTaskWhereClause(taskFilter, params);
418
+ if (tw) {
419
+ taskWhere = ` AND ${tw}`;
420
+ }
421
+ }
422
+ // Build document-specific WHERE clause if filtering documents
423
+ let documentWhere = '';
424
+ if (effectiveFilter.type === 'document' || (Array.isArray(effectiveFilter.type) && effectiveFilter.type.includes('document'))) {
425
+ const documentFilter = effectiveFilter;
426
+ const { where: dw } = buildDocumentWhereClause(documentFilter, params);
427
+ if (dw) {
428
+ // When filtering multiple types, scope document clauses to document rows only
429
+ const isMultiType = Array.isArray(effectiveFilter.type) && effectiveFilter.type.length > 1;
430
+ documentWhere = isMultiType ? ` AND (e.type != 'document' OR (${dw}))` : ` AND ${dw}`;
431
+ }
432
+ }
433
+ // Build message-specific WHERE clause if filtering messages
434
+ let messageWhere = '';
435
+ if (effectiveFilter.type === 'message' || (Array.isArray(effectiveFilter.type) && effectiveFilter.type.includes('message'))) {
436
+ const messageFilter = effectiveFilter;
437
+ const { where: mw } = buildMessageWhereClause(messageFilter, params);
438
+ if (mw) {
439
+ messageWhere = ` AND ${mw}`;
440
+ }
441
+ }
442
+ // Handle tag filtering
443
+ let tagJoin = '';
444
+ let tagWhere = '';
445
+ if (effectiveFilter.tags && effectiveFilter.tags.length > 0) {
446
+ // Must have ALL tags - use GROUP BY with HAVING COUNT
447
+ tagJoin = ' JOIN tags t ON e.id = t.element_id';
448
+ const placeholders = effectiveFilter.tags.map(() => '?').join(', ');
449
+ tagWhere = ` AND t.tag IN (${placeholders})`;
450
+ params.push(...effectiveFilter.tags);
451
+ }
452
+ if (effectiveFilter.tagsAny && effectiveFilter.tagsAny.length > 0) {
453
+ // Must have ANY tag
454
+ if (!tagJoin) {
455
+ tagJoin = ' JOIN tags t ON e.id = t.element_id';
456
+ }
457
+ const placeholders = effectiveFilter.tagsAny.map(() => '?').join(', ');
458
+ tagWhere += ` AND t.tag IN (${placeholders})`;
459
+ params.push(...effectiveFilter.tagsAny);
460
+ }
461
+ // Count total matching elements
462
+ const countSql = `
463
+ SELECT COUNT(DISTINCT e.id) as count
464
+ FROM elements e${tagJoin}
465
+ WHERE ${baseWhere}${taskWhere}${documentWhere}${messageWhere}${tagWhere}
466
+ `;
467
+ const countRow = this.backend.queryOne(countSql, params);
468
+ const total = countRow?.count ?? 0;
469
+ // Build ORDER BY
470
+ const orderBy = effectiveFilter.orderBy ?? 'created_at';
471
+ const orderDir = effectiveFilter.orderDir ?? 'desc';
472
+ // Map field names to SQL expressions
473
+ // Fields on the elements table can be referenced directly
474
+ // Fields stored in JSON data need JSON_EXTRACT
475
+ const columnMap = {
476
+ created_at: 'e.created_at',
477
+ updated_at: 'e.updated_at',
478
+ type: 'e.type',
479
+ id: 'e.id',
480
+ // Task-specific JSON fields
481
+ title: "JSON_EXTRACT(e.data, '$.title')",
482
+ status: "JSON_EXTRACT(e.data, '$.status')",
483
+ priority: "JSON_EXTRACT(e.data, '$.priority')",
484
+ complexity: "JSON_EXTRACT(e.data, '$.complexity')",
485
+ taskType: "JSON_EXTRACT(e.data, '$.taskType')",
486
+ assignee: "JSON_EXTRACT(e.data, '$.assignee')",
487
+ owner: "JSON_EXTRACT(e.data, '$.owner')",
488
+ // Document-specific JSON fields
489
+ name: "JSON_EXTRACT(e.data, '$.name')",
490
+ contentType: "JSON_EXTRACT(e.data, '$.contentType')",
491
+ version: "JSON_EXTRACT(e.data, '$.version')",
492
+ };
493
+ const orderColumn = columnMap[orderBy] ?? `e.${orderBy}`;
494
+ const orderClause = `ORDER BY ${orderColumn} ${orderDir.toUpperCase()}`;
495
+ // Apply pagination
496
+ const limit = Math.min(effectiveFilter.limit ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
497
+ const offset = effectiveFilter.offset ?? 0;
498
+ // Query elements
499
+ const sql = `
500
+ SELECT DISTINCT e.*
501
+ FROM elements e${tagJoin}
502
+ WHERE ${baseWhere}${taskWhere}${documentWhere}${messageWhere}${tagWhere}
503
+ ${orderClause}
504
+ LIMIT ? OFFSET ?
505
+ `;
506
+ const rows = this.backend.query(sql, [...params, limit, offset]);
507
+ // Batch fetch tags for all returned elements (eliminates N+1 query issue)
508
+ const elementIds = rows.map((row) => row.id);
509
+ const tagsMap = this.batchFetchTags(elementIds);
510
+ // Deserialize elements with their tags
511
+ const items = rows.map((row) => {
512
+ const tags = tagsMap.get(row.id) ?? [];
513
+ return deserializeElement(row, tags);
514
+ }).filter((el) => el !== null);
515
+ // Check if tags filter requires all tags
516
+ let filteredItems = items;
517
+ if (effectiveFilter.tags && effectiveFilter.tags.length > 1) {
518
+ // Filter to elements that have ALL tags
519
+ filteredItems = items.filter((item) => effectiveFilter.tags.every((tag) => item.tags.includes(tag)));
520
+ }
521
+ // Apply hydration if requested
522
+ let finalItems = filteredItems;
523
+ if (effectiveFilter.hydrate) {
524
+ // Hydrate tasks
525
+ const tasks = filteredItems.filter((item) => isTask(item));
526
+ if (tasks.length > 0) {
527
+ const hydratedTasks = this.hydrateTasks(tasks, effectiveFilter.hydrate);
528
+ // Create a map for efficient lookup
529
+ const hydratedMap = new Map(hydratedTasks.map((t) => [t.id, t]));
530
+ // Replace tasks with hydrated versions, keeping non-tasks as-is
531
+ finalItems = filteredItems.map((item) => {
532
+ const hydrated = hydratedMap.get(item.id);
533
+ return hydrated ? hydrated : item;
534
+ });
535
+ }
536
+ // Hydrate messages
537
+ const messages = filteredItems.filter((item) => isMessage(item));
538
+ if (messages.length > 0) {
539
+ const hydratedMessages = this.hydrateMessages(messages, effectiveFilter.hydrate);
540
+ // Create a map for efficient lookup
541
+ const hydratedMsgMap = new Map(hydratedMessages.map((m) => [m.id, m]));
542
+ // Replace messages with hydrated versions
543
+ finalItems = finalItems.map((item) => {
544
+ const hydrated = hydratedMsgMap.get(item.id);
545
+ return hydrated ? hydrated : item;
546
+ });
547
+ }
548
+ // Hydrate libraries
549
+ const libraries = finalItems.filter((item) => isLibrary(item));
550
+ if (libraries.length > 0) {
551
+ const hydratedLibraries = this.hydrateLibraries(libraries, effectiveFilter.hydrate);
552
+ // Create a map for efficient lookup
553
+ const hydratedLibMap = new Map(hydratedLibraries.map((l) => [l.id, l]));
554
+ // Replace libraries with hydrated versions
555
+ finalItems = finalItems.map((item) => {
556
+ const hydrated = hydratedLibMap.get(item.id);
557
+ return hydrated ? hydrated : item;
558
+ });
559
+ }
560
+ }
561
+ return {
562
+ items: finalItems,
563
+ total,
564
+ offset,
565
+ limit,
566
+ hasMore: offset + finalItems.length < total,
567
+ };
568
+ }
569
+ async create(input) {
570
+ // The input should already be a validated element from the factory functions
571
+ // We just need to persist it
572
+ const element = input;
573
+ // Entity name uniqueness validation
574
+ if (element.type === 'entity') {
575
+ const entityData = element;
576
+ if (entityData.name) {
577
+ const existing = await this.lookupEntityByName(entityData.name);
578
+ if (existing) {
579
+ throw new ConflictError(`Entity with name "${entityData.name}" already exists`, ErrorCode.DUPLICATE_NAME, { name: entityData.name, existingId: existing.id });
580
+ }
581
+ }
582
+ }
583
+ // Channel name uniqueness validation (group channels only)
584
+ if (element.type === 'channel') {
585
+ const channelData = element;
586
+ // Only validate group channels (direct channels have deterministic names)
587
+ if (channelData.channelType === ChannelTypeValue.GROUP && channelData.name) {
588
+ const visibility = channelData.permissions?.visibility ?? 'private';
589
+ // Check for existing channel with same name and visibility scope
590
+ const existingRow = this.backend.queryOne(`SELECT * FROM elements
591
+ WHERE type = 'channel'
592
+ AND JSON_EXTRACT(data, '$.channelType') = 'group'
593
+ AND JSON_EXTRACT(data, '$.name') = ?
594
+ AND JSON_EXTRACT(data, '$.permissions.visibility') = ?
595
+ AND deleted_at IS NULL`, [channelData.name, visibility]);
596
+ if (existingRow) {
597
+ throw new ConflictError(`Channel with name "${channelData.name}" already exists in ${visibility} scope`, ErrorCode.DUPLICATE_NAME, { name: channelData.name, visibility, existingId: existingRow.id });
598
+ }
599
+ }
600
+ }
601
+ // Message validation (sender membership, document refs, thread integrity)
602
+ if (element.type === 'message') {
603
+ const messageData = element;
604
+ // 1. Validate channel exists and sender is a member
605
+ const channelRow = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND deleted_at IS NULL`, [messageData.channelId]);
606
+ if (!channelRow) {
607
+ throw new NotFoundError(`Channel not found: ${messageData.channelId}`, ErrorCode.NOT_FOUND, { elementId: messageData.channelId });
608
+ }
609
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [channelRow.id]);
610
+ const tags = tagRows.map((r) => r.tag);
611
+ const channel = deserializeElement(channelRow, tags);
612
+ if (!channel) {
613
+ throw new NotFoundError(`Channel data corrupt: ${messageData.channelId}`);
614
+ }
615
+ // Validate sender is a channel member
616
+ if (!isMember(channel, messageData.sender)) {
617
+ throw new NotAMemberError(channel.id, messageData.sender);
618
+ }
619
+ // 2. Validate contentRef points to a valid Document
620
+ const contentDoc = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'document' AND deleted_at IS NULL`, [messageData.contentRef]);
621
+ if (!contentDoc) {
622
+ throw new NotFoundError(`Content document not found: ${messageData.contentRef}`, ErrorCode.DOCUMENT_NOT_FOUND, { elementId: messageData.contentRef, field: 'contentRef' });
623
+ }
624
+ // 3. Validate all attachments point to valid Documents
625
+ if (messageData.attachments && messageData.attachments.length > 0) {
626
+ for (const attachmentId of messageData.attachments) {
627
+ const attachmentDoc = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'document' AND deleted_at IS NULL`, [attachmentId]);
628
+ if (!attachmentDoc) {
629
+ throw new NotFoundError(`Attachment document not found: ${attachmentId}`, ErrorCode.DOCUMENT_NOT_FOUND, { elementId: attachmentId, field: 'attachments' });
630
+ }
631
+ }
632
+ }
633
+ // 4. Validate threadId (if present) points to a message in the same channel
634
+ if (messageData.threadId !== null) {
635
+ const threadParent = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'message' AND deleted_at IS NULL`, [messageData.threadId]);
636
+ if (!threadParent) {
637
+ throw new NotFoundError(`Thread parent message not found: ${messageData.threadId}`, ErrorCode.NOT_FOUND, { elementId: messageData.threadId, field: 'threadId' });
638
+ }
639
+ // Deserialize to check channel
640
+ const parentTags = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [threadParent.id]);
641
+ const parentMessage = deserializeElement(threadParent, parentTags.map(r => r.tag));
642
+ if (!parentMessage) {
643
+ throw new NotFoundError(`Thread parent message data corrupt: ${messageData.threadId}`);
644
+ }
645
+ if (parentMessage.channelId !== messageData.channelId) {
646
+ throw new ConstraintError(`Thread parent message is in a different channel`, ErrorCode.INVALID_PARENT, {
647
+ field: 'threadId',
648
+ threadId: messageData.threadId,
649
+ threadChannelId: parentMessage.channelId,
650
+ messageChannelId: messageData.channelId,
651
+ });
652
+ }
653
+ }
654
+ }
655
+ // Serialize for storage
656
+ const serialized = serializeElement(element);
657
+ // Insert in a transaction
658
+ this.backend.transaction((tx) => {
659
+ // Insert the element
660
+ tx.run(`INSERT INTO elements (id, type, data, content_hash, created_at, updated_at, created_by, deleted_at)
661
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
662
+ serialized.id,
663
+ serialized.type,
664
+ serialized.data,
665
+ serialized.content_hash,
666
+ serialized.created_at,
667
+ serialized.updated_at,
668
+ serialized.created_by,
669
+ serialized.deleted_at,
670
+ ]);
671
+ // Insert tags
672
+ if (element.tags.length > 0) {
673
+ for (const tag of element.tags) {
674
+ tx.run('INSERT INTO tags (element_id, tag) VALUES (?, ?)', [element.id, tag]);
675
+ }
676
+ }
677
+ // Record creation event
678
+ const event = createEvent({
679
+ elementId: element.id,
680
+ eventType: 'created',
681
+ actor: element.createdBy,
682
+ oldValue: null,
683
+ newValue: element,
684
+ });
685
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
686
+ VALUES (?, ?, ?, ?, ?, ?)`, [
687
+ event.elementId,
688
+ event.eventType,
689
+ event.actor,
690
+ null,
691
+ JSON.stringify(event.newValue),
692
+ event.createdAt,
693
+ ]);
694
+ // For messages with threadId, create a replies-to dependency
695
+ if (isMessage(element) && element.threadId !== null) {
696
+ const now = createTimestamp();
697
+ tx.run(`INSERT INTO dependencies (blocked_id, blocker_id, type, created_at, created_by, metadata)
698
+ VALUES (?, ?, ?, ?, ?, ?)`, [
699
+ element.id,
700
+ element.threadId,
701
+ 'replies-to',
702
+ now,
703
+ element.sender,
704
+ null,
705
+ ]);
706
+ // Record dependency_added event
707
+ const depEvent = createEvent({
708
+ elementId: element.id,
709
+ eventType: 'dependency_added',
710
+ actor: element.sender,
711
+ oldValue: null,
712
+ newValue: {
713
+ blockedId: element.id,
714
+ blockerId: element.threadId,
715
+ type: 'replies-to',
716
+ metadata: {},
717
+ },
718
+ });
719
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
720
+ VALUES (?, ?, ?, ?, ?, ?)`, [
721
+ depEvent.elementId,
722
+ depEvent.eventType,
723
+ depEvent.actor,
724
+ null,
725
+ JSON.stringify(depEvent.newValue),
726
+ depEvent.createdAt,
727
+ ]);
728
+ }
729
+ });
730
+ // Process mentions and inbox for messages
731
+ if (isMessage(element)) {
732
+ const messageData = element;
733
+ const messageMetadata = messageData.metadata;
734
+ // Skip inbox item creation if the message has suppressInbox flag set.
735
+ // This is used by dispatch notifications (task-assignment, task-reassignment)
736
+ // to prevent cluttering the operator/director's inbox.
737
+ const suppressInbox = messageMetadata?.suppressInbox === true;
738
+ // Get the channel to determine type and members
739
+ const channelRow = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND deleted_at IS NULL`, [messageData.channelId]);
740
+ if (channelRow) {
741
+ const channelTags = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [channelRow.id]);
742
+ const channel = deserializeElement(channelRow, channelTags.map((r) => r.tag));
743
+ if (!suppressInbox) {
744
+ // For direct channels: Create inbox item for the OTHER member (not the sender)
745
+ if (channel && isDirectChannel(channel)) {
746
+ for (const memberId of channel.members) {
747
+ // Skip the sender - they don't need an inbox item for their own message
748
+ if (memberId !== messageData.sender) {
749
+ try {
750
+ this.inboxService.addToInbox({
751
+ recipientId: memberId,
752
+ messageId: messageData.id,
753
+ channelId: messageData.channelId,
754
+ sourceType: InboxSourceType.DIRECT,
755
+ createdBy: messageData.sender,
756
+ });
757
+ }
758
+ catch {
759
+ // Ignore errors (e.g., duplicate inbox item)
760
+ }
761
+ }
762
+ }
763
+ }
764
+ }
765
+ // Parse and process @mentions from the content document
766
+ const contentDocRow = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'document' AND deleted_at IS NULL`, [messageData.contentRef]);
767
+ if (contentDocRow) {
768
+ const contentDoc = deserializeElement(contentDocRow, []);
769
+ const mentionedNames = contentDoc ? extractMentionedNames(contentDoc.content) : [];
770
+ if (mentionedNames.length > 0) {
771
+ // Get all entities to validate mentions against
772
+ const entityRows = this.backend.query(`SELECT * FROM elements WHERE type = 'entity' AND deleted_at IS NULL`, []);
773
+ const entities = [];
774
+ for (const row of entityRows) {
775
+ const entityTags = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
776
+ const entity = deserializeElement(row, entityTags.map((r) => r.tag));
777
+ if (entity)
778
+ entities.push(entity);
779
+ }
780
+ const { valid: validMentionIds } = validateMentions(mentionedNames, entities);
781
+ // Create mentions dependencies and inbox items for each valid mention
782
+ const now = createTimestamp();
783
+ for (const mentionedEntityId of validMentionIds) {
784
+ // Create 'mentions' dependency: message -> entity
785
+ try {
786
+ this.backend.run(`INSERT INTO dependencies (blocked_id, blocker_id, type, created_at, created_by, metadata)
787
+ VALUES (?, ?, ?, ?, ?, ?)`, [
788
+ messageData.id,
789
+ mentionedEntityId,
790
+ 'mentions',
791
+ now,
792
+ messageData.sender,
793
+ null,
794
+ ]);
795
+ }
796
+ catch {
797
+ // Ignore duplicate dependency errors
798
+ }
799
+ // Create inbox item for the mentioned entity (if not the sender)
800
+ if (!suppressInbox && mentionedEntityId !== messageData.sender) {
801
+ try {
802
+ this.inboxService.addToInbox({
803
+ recipientId: mentionedEntityId,
804
+ messageId: messageData.id,
805
+ channelId: messageData.channelId,
806
+ sourceType: InboxSourceType.MENTION,
807
+ createdBy: messageData.sender,
808
+ });
809
+ }
810
+ catch {
811
+ // Ignore errors (e.g., duplicate inbox item if already added for direct message)
812
+ }
813
+ }
814
+ }
815
+ }
816
+ }
817
+ // For thread replies: Notify the parent message sender
818
+ if (!suppressInbox && messageData.threadId) {
819
+ const parentMessageRow = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'message' AND deleted_at IS NULL`, [messageData.threadId]);
820
+ if (parentMessageRow) {
821
+ const parentMessage = deserializeElement(parentMessageRow, []);
822
+ // Notify parent message sender (if not replying to yourself)
823
+ if (parentMessage && parentMessage.sender !== messageData.sender) {
824
+ try {
825
+ this.inboxService.addToInbox({
826
+ recipientId: parentMessage.sender,
827
+ messageId: messageData.id,
828
+ channelId: messageData.channelId,
829
+ sourceType: InboxSourceType.THREAD_REPLY,
830
+ createdBy: messageData.sender,
831
+ });
832
+ }
833
+ catch {
834
+ // Ignore errors (e.g., duplicate inbox item)
835
+ }
836
+ }
837
+ }
838
+ }
839
+ }
840
+ }
841
+ // Mark as dirty for sync
842
+ this.backend.markDirty(element.id);
843
+ // Index document for FTS
844
+ if (isDocument(element)) {
845
+ this.indexDocumentForFTS(element);
846
+ }
847
+ return element;
848
+ }
849
+ async update(id, updates, options) {
850
+ // Get the existing element
851
+ const existing = await this.get(id);
852
+ if (!existing) {
853
+ throw new NotFoundError(`Element not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
854
+ }
855
+ // Optimistic concurrency check - fail if element was modified since it was read
856
+ if (options?.expectedUpdatedAt && existing.updatedAt !== options.expectedUpdatedAt) {
857
+ throw new ConflictError(`Element was modified by another process: ${id}. Expected updatedAt: ${options.expectedUpdatedAt}, actual: ${existing.updatedAt}`, ErrorCode.CONCURRENT_MODIFICATION, { elementId: id, expectedUpdatedAt: options.expectedUpdatedAt, actualUpdatedAt: existing.updatedAt });
858
+ }
859
+ // Check if element is immutable (Messages cannot be updated)
860
+ if (existing.type === 'message') {
861
+ throw new ConstraintError('Messages are immutable and cannot be updated', ErrorCode.IMMUTABLE, { elementId: id, type: 'message' });
862
+ }
863
+ // Check if document is immutable and content is being updated
864
+ if (isDocument(existing)) {
865
+ const doc = existing;
866
+ if (doc.immutable && updates.content !== undefined) {
867
+ throw new ConstraintError('Cannot update content of immutable document', ErrorCode.IMMUTABLE, { elementId: id, type: 'document' });
868
+ }
869
+ }
870
+ // Resolve actor - use provided actor or fall back to element's creator
871
+ const actor = options?.actor ?? existing.createdBy;
872
+ // Apply updates
873
+ const now = createTimestamp();
874
+ let updated = {
875
+ ...existing,
876
+ ...updates,
877
+ id: existing.id, // Cannot change ID
878
+ type: existing.type, // Cannot change type
879
+ createdAt: existing.createdAt, // Cannot change creation time
880
+ createdBy: existing.createdBy, // Cannot change creator
881
+ updatedAt: now,
882
+ };
883
+ // For documents, auto-increment version and link to previous version (only on content changes)
884
+ if (isDocument(existing)) {
885
+ const doc = existing;
886
+ const isContentUpdate = 'content' in updates || 'contentType' in updates;
887
+ if (isContentUpdate) {
888
+ updated = {
889
+ ...updated,
890
+ version: doc.version + 1,
891
+ previousVersionId: doc.id,
892
+ };
893
+ }
894
+ }
895
+ // Serialize for storage
896
+ const serialized = serializeElement(updated);
897
+ // Update in a transaction
898
+ this.backend.transaction((tx) => {
899
+ // For documents, save current version to version history before updating (only on content changes)
900
+ if (isDocument(existing) && ('content' in updates || 'contentType' in updates)) {
901
+ const doc = existing;
902
+ // Serialize the current document data for version storage
903
+ const versionData = JSON.stringify({
904
+ contentType: doc.contentType,
905
+ content: doc.content,
906
+ version: doc.version,
907
+ previousVersionId: doc.previousVersionId,
908
+ createdBy: doc.createdBy,
909
+ tags: doc.tags,
910
+ metadata: doc.metadata,
911
+ title: doc.title,
912
+ category: doc.category,
913
+ status: doc.status,
914
+ immutable: doc.immutable,
915
+ });
916
+ tx.run(`INSERT INTO document_versions (id, version, data, created_at) VALUES (?, ?, ?, ?)`, [doc.id, doc.version, versionData, doc.updatedAt]);
917
+ }
918
+ // Update the element
919
+ tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ?, deleted_at = ?
920
+ WHERE id = ?`, [serialized.data, serialized.content_hash, serialized.updated_at, serialized.deleted_at, id]);
921
+ // Update tags if they changed
922
+ if (updates.tags !== undefined) {
923
+ // Remove old tags
924
+ tx.run('DELETE FROM tags WHERE element_id = ?', [id]);
925
+ // Insert new tags
926
+ for (const tag of updated.tags) {
927
+ tx.run('INSERT INTO tags (element_id, tag) VALUES (?, ?)', [id, tag]);
928
+ }
929
+ }
930
+ // Determine the appropriate event type based on status changes
931
+ const existingData = existing;
932
+ const updatedData = updated;
933
+ const oldStatus = existingData.status;
934
+ const newStatus = updatedData.status;
935
+ let eventType = LifecycleEventType.UPDATED;
936
+ if (oldStatus !== newStatus && newStatus !== undefined) {
937
+ // Handle Task status changes
938
+ if (isTask(existing)) {
939
+ if (newStatus === TaskStatusEnum.CLOSED) {
940
+ // Transitioning TO closed status
941
+ eventType = LifecycleEventType.CLOSED;
942
+ }
943
+ else if (oldStatus === TaskStatusEnum.CLOSED) {
944
+ // Transitioning FROM closed status (reopening)
945
+ eventType = LifecycleEventType.REOPENED;
946
+ }
947
+ }
948
+ // Handle Plan status changes
949
+ else if (isPlan(existing)) {
950
+ if (newStatus === PlanStatusEnum.COMPLETED || newStatus === PlanStatusEnum.CANCELLED) {
951
+ // Transitioning TO completed or cancelled status (terminal states)
952
+ eventType = LifecycleEventType.CLOSED;
953
+ }
954
+ else if (oldStatus === PlanStatusEnum.COMPLETED || oldStatus === PlanStatusEnum.CANCELLED) {
955
+ // Transitioning FROM completed/cancelled status (reopening/restarting)
956
+ eventType = LifecycleEventType.REOPENED;
957
+ }
958
+ }
959
+ // Handle Workflow status changes
960
+ else if (isWorkflow(existing)) {
961
+ const terminalStatuses = [
962
+ WorkflowStatusEnum.COMPLETED,
963
+ WorkflowStatusEnum.FAILED,
964
+ WorkflowStatusEnum.CANCELLED,
965
+ ];
966
+ if (terminalStatuses.includes(newStatus)) {
967
+ // Transitioning TO completed, failed, or cancelled status (terminal states)
968
+ eventType = LifecycleEventType.CLOSED;
969
+ }
970
+ else if (terminalStatuses.includes(oldStatus)) {
971
+ // Transitioning FROM a terminal status (restarting - though not normally allowed by workflow transitions)
972
+ eventType = LifecycleEventType.REOPENED;
973
+ }
974
+ }
975
+ // Handle Document status changes
976
+ else if (isDocument(existing)) {
977
+ if (newStatus === 'archived') {
978
+ eventType = LifecycleEventType.CLOSED;
979
+ }
980
+ else if (oldStatus === 'archived' && newStatus === 'active') {
981
+ eventType = LifecycleEventType.REOPENED;
982
+ }
983
+ }
984
+ }
985
+ // Record the event with the determined type
986
+ const event = createEvent({
987
+ elementId: id,
988
+ eventType,
989
+ actor,
990
+ oldValue: existing,
991
+ newValue: updated,
992
+ });
993
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
994
+ VALUES (?, ?, ?, ?, ?, ?)`, [
995
+ event.elementId,
996
+ event.eventType,
997
+ event.actor,
998
+ JSON.stringify(event.oldValue),
999
+ JSON.stringify(event.newValue),
1000
+ event.createdAt,
1001
+ ]);
1002
+ });
1003
+ // Mark as dirty for sync
1004
+ this.backend.markDirty(id);
1005
+ // Check if status changed and update blocked cache
1006
+ const existingDataPost = existing;
1007
+ const updatedDataPost = updated;
1008
+ const oldStatusPost = existingDataPost.status;
1009
+ const newStatusPost = updatedDataPost.status;
1010
+ if (oldStatusPost !== newStatusPost && newStatusPost !== undefined) {
1011
+ this.blockedCache.onStatusChanged(id, oldStatusPost ?? null, newStatusPost);
1012
+ }
1013
+ // Re-index document for FTS only when content-relevant fields change
1014
+ if (isDocument(updated)) {
1015
+ const ftsRelevantUpdate = 'content' in updates || 'contentType' in updates ||
1016
+ 'tags' in updates || 'category' in updates || 'metadata' in updates || 'title' in updates;
1017
+ if (ftsRelevantUpdate) {
1018
+ this.indexDocumentForFTS(updated);
1019
+ }
1020
+ }
1021
+ return updated;
1022
+ }
1023
+ async delete(id, options) {
1024
+ // Get the existing element
1025
+ const existing = await this.get(id);
1026
+ if (!existing) {
1027
+ throw new NotFoundError(`Element not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
1028
+ }
1029
+ // Check if element is immutable (Messages cannot be deleted)
1030
+ if (existing.type === 'message') {
1031
+ throw new ConstraintError('Messages are immutable and cannot be deleted', ErrorCode.IMMUTABLE, { elementId: id, type: 'message' });
1032
+ }
1033
+ // Resolve actor - use provided actor or fall back to element's creator
1034
+ const actor = options?.actor ?? existing.createdBy;
1035
+ const reason = options?.reason;
1036
+ const now = createTimestamp();
1037
+ // Collect elements that will need cache updates BEFORE deleting dependencies
1038
+ // For `blocks` deps: when deleting the source (blocker), targets become unblocked
1039
+ const affectedTargets = this.backend.query(`SELECT DISTINCT blocked_id FROM dependencies WHERE blocker_id = ? AND type = 'blocks'`, [id]).map(row => row.blocked_id);
1040
+ // For `parent-child` and `awaits` deps: when deleting the target, sources need recheck
1041
+ const affectedSources = this.backend.query(`SELECT DISTINCT blocked_id FROM dependencies WHERE blocker_id = ? AND type IN ('parent-child', 'awaits')`, [id]).map(row => row.blocked_id);
1042
+ // Soft delete by setting deleted_at and updating status to tombstone
1043
+ this.backend.transaction((tx) => {
1044
+ // Get current data and update status
1045
+ const data = JSON.parse((this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [id]))?.data ?? '{}');
1046
+ data.status = 'tombstone';
1047
+ data.deletedAt = now;
1048
+ data.deleteReason = reason;
1049
+ tx.run(`UPDATE elements SET data = ?, updated_at = ?, deleted_at = ?
1050
+ WHERE id = ?`, [JSON.stringify(data), now, now, id]);
1051
+ // Cascade delete: Remove all dependencies where this element is the source or target
1052
+ // This prevents orphan dependency records pointing to/from deleted elements
1053
+ tx.run('DELETE FROM dependencies WHERE blocked_id = ?', [id]);
1054
+ tx.run('DELETE FROM dependencies WHERE blocker_id = ?', [id]);
1055
+ // Record delete event with the resolved actor
1056
+ const event = createEvent({
1057
+ elementId: id,
1058
+ eventType: 'deleted',
1059
+ actor,
1060
+ oldValue: existing,
1061
+ newValue: null,
1062
+ });
1063
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
1064
+ VALUES (?, ?, ?, ?, ?, ?)`, [
1065
+ event.elementId,
1066
+ event.eventType,
1067
+ event.actor,
1068
+ JSON.stringify(event.oldValue),
1069
+ reason ? JSON.stringify({ reason }) : null,
1070
+ event.createdAt,
1071
+ ]);
1072
+ // Clean up document-specific data (inside transaction for atomicity)
1073
+ if (existing.type === 'document') {
1074
+ tx.run('DELETE FROM document_versions WHERE id = ?', [id]);
1075
+ tx.run('DELETE FROM comments WHERE document_id = ?', [id]);
1076
+ if (this.checkFTSAvailable()) {
1077
+ try {
1078
+ tx.run('DELETE FROM documents_fts WHERE document_id = ?', [id]);
1079
+ }
1080
+ catch (error) {
1081
+ console.warn(`[stoneforge] FTS removal failed for ${id}:`, error);
1082
+ }
1083
+ }
1084
+ }
1085
+ });
1086
+ // Mark as dirty for sync
1087
+ this.backend.markDirty(id);
1088
+ // Remove embedding (outside transaction — async/best-effort, doesn't affect DB consistency)
1089
+ if (existing.type === 'document' && this.embeddingService) {
1090
+ try {
1091
+ this.embeddingService.removeDocument(id);
1092
+ }
1093
+ catch (error) {
1094
+ console.warn(`[stoneforge] Embedding removal failed for ${id}:`, error);
1095
+ }
1096
+ }
1097
+ // Update blocked cache for the deleted element and all affected elements
1098
+ // This must happen AFTER the transaction so the element is already tombstoned
1099
+ this.blockedCache.removeBlocked(id);
1100
+ for (const blockerId of affectedTargets) {
1101
+ this.blockedCache.invalidateElement(blockerId);
1102
+ }
1103
+ for (const blockedId of affectedSources) {
1104
+ this.blockedCache.invalidateElement(blockedId);
1105
+ }
1106
+ }
1107
+ // --------------------------------------------------------------------------
1108
+ // Entity Operations
1109
+ // --------------------------------------------------------------------------
1110
+ async lookupEntityByName(name) {
1111
+ // Query for entity with matching name in data JSON
1112
+ const row = this.backend.queryOne(`SELECT * FROM elements
1113
+ WHERE type = 'entity'
1114
+ AND JSON_EXTRACT(data, '$.name') = ?
1115
+ AND deleted_at IS NULL`, [name]);
1116
+ if (!row) {
1117
+ return null;
1118
+ }
1119
+ // Get tags for this element
1120
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
1121
+ const tags = tagRows.map((r) => r.tag);
1122
+ return deserializeElement(row, tags);
1123
+ }
1124
+ /**
1125
+ * Sets the manager (reportsTo) for an entity.
1126
+ *
1127
+ * Validates:
1128
+ * - Entity exists and is an entity type
1129
+ * - Manager entity exists and is active
1130
+ * - No self-reference (entity cannot report to itself)
1131
+ * - No circular chains
1132
+ *
1133
+ * @param entityId - The entity to set the manager for
1134
+ * @param managerId - The manager entity ID
1135
+ * @param actor - Entity performing this action (for audit trail)
1136
+ * @returns The updated entity
1137
+ */
1138
+ async setEntityManager(entityId, managerId, actor) {
1139
+ // Get the entity (cast through unknown since EntityId and ElementId are different branded types)
1140
+ const entity = await this.get(entityId);
1141
+ if (!entity) {
1142
+ throw new NotFoundError(`Entity not found: ${entityId}`, ErrorCode.ENTITY_NOT_FOUND, { elementId: entityId });
1143
+ }
1144
+ if (entity.type !== 'entity') {
1145
+ throw new ConstraintError(`Element is not an entity: ${entityId}`, ErrorCode.TYPE_MISMATCH, { elementId: entityId, actualType: entity.type, expectedType: 'entity' });
1146
+ }
1147
+ // Create a getEntity function for validation
1148
+ const getEntity = (id) => {
1149
+ const row = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'entity' AND deleted_at IS NULL`, [id]);
1150
+ if (!row)
1151
+ return null;
1152
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
1153
+ return deserializeElement(row, tagRows.map((r) => r.tag));
1154
+ };
1155
+ // Validate the manager assignment
1156
+ const validation = validateManager(entityId, managerId, getEntity);
1157
+ if (!validation.valid) {
1158
+ switch (validation.errorCode) {
1159
+ case 'SELF_REFERENCE':
1160
+ throw new ValidationError(validation.errorMessage, ErrorCode.INVALID_INPUT, { entityId, managerId });
1161
+ case 'ENTITY_NOT_FOUND':
1162
+ throw new NotFoundError(validation.errorMessage, ErrorCode.ENTITY_NOT_FOUND, { elementId: managerId });
1163
+ case 'ENTITY_DEACTIVATED':
1164
+ throw new ValidationError(validation.errorMessage, ErrorCode.INVALID_INPUT, { entityId, managerId, reason: 'manager_deactivated' });
1165
+ case 'CYCLE_DETECTED':
1166
+ throw new ConflictError(validation.errorMessage, ErrorCode.CYCLE_DETECTED, { entityId, managerId, cyclePath: validation.cyclePath });
1167
+ }
1168
+ }
1169
+ // Update the entity with the new reportsTo value
1170
+ const updatedEntity = updateEntity(entity, { reportsTo: managerId });
1171
+ // Save the updated entity
1172
+ await this.update(entityId, updatedEntity, { actor });
1173
+ return updatedEntity;
1174
+ }
1175
+ /**
1176
+ * Clears the manager (reportsTo) for an entity.
1177
+ *
1178
+ * @param entityId - The entity to clear the manager for
1179
+ * @param actor - Entity performing this action (for audit trail)
1180
+ * @returns The updated entity
1181
+ */
1182
+ async clearEntityManager(entityId, actor) {
1183
+ // Get the entity (cast through unknown since EntityId and ElementId are different branded types)
1184
+ const entity = await this.get(entityId);
1185
+ if (!entity) {
1186
+ throw new NotFoundError(`Entity not found: ${entityId}`, ErrorCode.ENTITY_NOT_FOUND, { elementId: entityId });
1187
+ }
1188
+ if (entity.type !== 'entity') {
1189
+ throw new ConstraintError(`Element is not an entity: ${entityId}`, ErrorCode.TYPE_MISMATCH, { elementId: entityId, actualType: entity.type, expectedType: 'entity' });
1190
+ }
1191
+ // Update the entity with null reportsTo (clears it)
1192
+ const updatedEntity = updateEntity(entity, { reportsTo: null });
1193
+ // Save the updated entity
1194
+ await this.update(entityId, updatedEntity, { actor });
1195
+ return updatedEntity;
1196
+ }
1197
+ /**
1198
+ * Gets all entities that report directly to a manager.
1199
+ *
1200
+ * @param managerId - The manager entity ID
1201
+ * @returns Array of entities that report to the manager
1202
+ */
1203
+ async getDirectReports(managerId) {
1204
+ // Query for entities where reportsTo matches the managerId
1205
+ const rows = this.backend.query(`SELECT * FROM elements
1206
+ WHERE type = 'entity'
1207
+ AND JSON_EXTRACT(data, '$.reportsTo') = ?
1208
+ AND deleted_at IS NULL`, [managerId]);
1209
+ // Get tags for each entity
1210
+ const entities = [];
1211
+ for (const row of rows) {
1212
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
1213
+ const entity = deserializeElement(row, tagRows.map((r) => r.tag));
1214
+ if (entity)
1215
+ entities.push(entity);
1216
+ }
1217
+ return entities;
1218
+ }
1219
+ /**
1220
+ * Gets the management chain for an entity (from entity up to root).
1221
+ *
1222
+ * Returns an ordered array starting with the entity's direct manager
1223
+ * and ending with the root entity (an entity with no reportsTo).
1224
+ *
1225
+ * @param entityId - The entity to get the management chain for
1226
+ * @returns Array of entities in the management chain (empty if no manager)
1227
+ */
1228
+ async getManagementChain(entityId) {
1229
+ // Get the entity (cast through unknown since EntityId and ElementId are different branded types)
1230
+ const entity = await this.get(entityId);
1231
+ if (!entity) {
1232
+ throw new NotFoundError(`Entity not found: ${entityId}`, ErrorCode.ENTITY_NOT_FOUND, { elementId: entityId });
1233
+ }
1234
+ if (entity.type !== 'entity') {
1235
+ throw new ConstraintError(`Element is not an entity: ${entityId}`, ErrorCode.TYPE_MISMATCH, { elementId: entityId, actualType: entity.type, expectedType: 'entity' });
1236
+ }
1237
+ // Create a getEntity function
1238
+ const getEntity = (id) => {
1239
+ const row = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'entity' AND deleted_at IS NULL`, [id]);
1240
+ if (!row)
1241
+ return null;
1242
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
1243
+ return deserializeElement(row, tagRows.map((r) => r.tag));
1244
+ };
1245
+ return getManagementChainUtil(entity, getEntity);
1246
+ }
1247
+ /**
1248
+ * Gets the organizational chart structure.
1249
+ *
1250
+ * @param rootId - Optional root entity ID (if not provided, returns all root entities)
1251
+ * @returns Array of org chart nodes (hierarchical structure)
1252
+ */
1253
+ async getOrgChart(rootId) {
1254
+ // Get all entities
1255
+ const rows = this.backend.query(`SELECT * FROM elements
1256
+ WHERE type = 'entity'
1257
+ AND deleted_at IS NULL`, []);
1258
+ // Get all entities with tags
1259
+ const entities = [];
1260
+ for (const row of rows) {
1261
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
1262
+ const entity = deserializeElement(row, tagRows.map((r) => r.tag));
1263
+ // Only include active entities
1264
+ if (entity && isEntityActive(entity)) {
1265
+ entities.push(entity);
1266
+ }
1267
+ }
1268
+ return buildOrgChart(entities, rootId);
1269
+ }
1270
+ // --------------------------------------------------------------------------
1271
+ // Plan Operations
1272
+ // --------------------------------------------------------------------------
1273
+ async addTaskToPlan(taskId, planId, options) {
1274
+ // Verify task exists and is a task
1275
+ const task = await this.get(taskId);
1276
+ if (!task) {
1277
+ throw new NotFoundError(`Task not found: ${taskId}`, ErrorCode.NOT_FOUND, { elementId: taskId });
1278
+ }
1279
+ if (task.type !== 'task') {
1280
+ throw new ConstraintError(`Element is not a task: ${taskId}`, ErrorCode.TYPE_MISMATCH, { elementId: taskId, actualType: task.type, expectedType: 'task' });
1281
+ }
1282
+ // Verify plan exists and is a plan
1283
+ const plan = await this.get(planId);
1284
+ if (!plan) {
1285
+ throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
1286
+ }
1287
+ if (plan.type !== 'plan') {
1288
+ throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
1289
+ }
1290
+ // Check if task is already in any plan
1291
+ const existingParentDeps = await this.getDependencies(taskId, ['parent-child']);
1292
+ if (existingParentDeps.length > 0) {
1293
+ const existingPlanId = existingParentDeps[0].blockerId;
1294
+ throw new ConstraintError(`Task is already in plan: ${existingPlanId}`, ErrorCode.ALREADY_IN_PLAN, { taskId, existingPlanId });
1295
+ }
1296
+ // Resolve actor
1297
+ const actor = options?.actor ?? task.createdBy;
1298
+ // Create parent-child dependency from task to plan
1299
+ const dependency = await this.addDependency({
1300
+ blockedId: taskId,
1301
+ blockerId: planId,
1302
+ type: 'parent-child',
1303
+ actor,
1304
+ });
1305
+ return dependency;
1306
+ }
1307
+ async removeTaskFromPlan(taskId, planId, actor) {
1308
+ // Check if the task-plan relationship exists
1309
+ const existingDeps = await this.getDependencies(taskId, ['parent-child']);
1310
+ const hasRelation = existingDeps.some((d) => d.blockerId === planId);
1311
+ if (!hasRelation) {
1312
+ throw new NotFoundError(`Task ${taskId} is not in plan ${planId}`, ErrorCode.DEPENDENCY_NOT_FOUND, { taskId, planId });
1313
+ }
1314
+ // Remove the parent-child dependency
1315
+ await this.removeDependency(taskId, planId, 'parent-child', actor);
1316
+ }
1317
+ async getTasksInPlan(planId, filter) {
1318
+ // Verify plan exists
1319
+ const plan = await this.get(planId);
1320
+ if (!plan) {
1321
+ throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
1322
+ }
1323
+ if (plan.type !== 'plan') {
1324
+ throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
1325
+ }
1326
+ // Get all elements that have parent-child dependency to this plan
1327
+ const dependents = await this.getDependents(planId, ['parent-child']);
1328
+ // If no dependents, return empty array
1329
+ if (dependents.length === 0) {
1330
+ return [];
1331
+ }
1332
+ // Fetch tasks by their IDs
1333
+ const taskIds = dependents.map((d) => d.blockedId);
1334
+ const tasks = [];
1335
+ for (const taskId of taskIds) {
1336
+ const task = await this.get(taskId);
1337
+ if (task && task.type === 'task') {
1338
+ tasks.push(task);
1339
+ }
1340
+ }
1341
+ // Apply filters if provided
1342
+ let filteredTasks = tasks;
1343
+ if (filter?.status) {
1344
+ const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
1345
+ filteredTasks = filteredTasks.filter((t) => statuses.includes(t.status));
1346
+ }
1347
+ if (filter?.priority) {
1348
+ const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
1349
+ filteredTasks = filteredTasks.filter((t) => priorities.includes(t.priority));
1350
+ }
1351
+ if (filter?.assignee) {
1352
+ filteredTasks = filteredTasks.filter((t) => t.assignee === filter.assignee);
1353
+ }
1354
+ if (filter?.owner) {
1355
+ filteredTasks = filteredTasks.filter((t) => t.owner === filter.owner);
1356
+ }
1357
+ if (filter?.tags && filter.tags.length > 0) {
1358
+ filteredTasks = filteredTasks.filter((t) => filter.tags.every((tag) => t.tags.includes(tag)));
1359
+ }
1360
+ if (filter?.includeDeleted !== true) {
1361
+ filteredTasks = filteredTasks.filter((t) => t.status !== 'tombstone');
1362
+ }
1363
+ return filteredTasks;
1364
+ }
1365
+ async getPlanProgress(planId) {
1366
+ // Verify plan exists
1367
+ const plan = await this.get(planId);
1368
+ if (!plan) {
1369
+ throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
1370
+ }
1371
+ if (plan.type !== 'plan') {
1372
+ throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
1373
+ }
1374
+ // Get all tasks in the plan (excluding tombstones)
1375
+ const tasks = await this.getTasksInPlan(planId, { includeDeleted: false });
1376
+ // Count tasks by status
1377
+ const statusCounts = {
1378
+ open: 0,
1379
+ in_progress: 0,
1380
+ blocked: 0,
1381
+ closed: 0,
1382
+ deferred: 0,
1383
+ tombstone: 0,
1384
+ };
1385
+ for (const task of tasks) {
1386
+ if (task.status in statusCounts) {
1387
+ statusCounts[task.status]++;
1388
+ }
1389
+ }
1390
+ // Use the calculatePlanProgress utility
1391
+ return calculatePlanProgress(statusCounts);
1392
+ }
1393
+ async createTaskInPlan(planId, taskInput, options) {
1394
+ // Verify plan exists
1395
+ const plan = await this.get(planId);
1396
+ if (!plan) {
1397
+ throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
1398
+ }
1399
+ if (plan.type !== 'plan') {
1400
+ throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
1401
+ }
1402
+ // Check plan is in valid status for adding tasks
1403
+ if (plan.status !== PlanStatusEnum.DRAFT && plan.status !== PlanStatusEnum.ACTIVE) {
1404
+ throw new ValidationError(`Cannot add tasks to plan in status: ${plan.status}`, ErrorCode.INVALID_STATUS, { planId, status: plan.status, allowedStatuses: ['draft', 'active'] });
1405
+ }
1406
+ // Generate hierarchical ID if requested (default: true)
1407
+ const useHierarchical = options?.useHierarchicalId !== false;
1408
+ let taskId;
1409
+ if (useHierarchical) {
1410
+ // Get next child number atomically
1411
+ const childNumber = this.backend.getNextChildNumber(planId);
1412
+ taskId = generateChildId(planId, childNumber);
1413
+ }
1414
+ // Create a properly-formed task using the createTask factory
1415
+ const taskElement = await createTask({
1416
+ ...taskInput,
1417
+ id: taskId,
1418
+ });
1419
+ const task = await this.create(taskElement);
1420
+ // Create parent-child dependency
1421
+ const actor = options?.actor ?? taskInput.createdBy;
1422
+ await this.addDependency({
1423
+ blockedId: task.id,
1424
+ blockerId: planId,
1425
+ type: 'parent-child',
1426
+ actor,
1427
+ });
1428
+ return task;
1429
+ }
1430
+ // --------------------------------------------------------------------------
1431
+ // Plan Bulk Operations
1432
+ // --------------------------------------------------------------------------
1433
+ async bulkClosePlanTasks(planId, options) {
1434
+ // Verify plan exists
1435
+ const plan = await this.get(planId);
1436
+ if (!plan) {
1437
+ throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
1438
+ }
1439
+ if (plan.type !== 'plan') {
1440
+ throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
1441
+ }
1442
+ // Get all tasks in the plan
1443
+ const tasks = await this.getTasksInPlan(planId, options?.filter);
1444
+ const result = {
1445
+ updated: 0,
1446
+ skipped: 0,
1447
+ updatedIds: [],
1448
+ skippedIds: [],
1449
+ errors: [],
1450
+ };
1451
+ const actor = options?.actor ?? plan.createdBy;
1452
+ const closeReason = options?.closeReason;
1453
+ for (const task of tasks) {
1454
+ // Skip tasks that are already closed or tombstoned
1455
+ if (task.status === TaskStatusEnum.CLOSED || task.status === TaskStatusEnum.TOMBSTONE) {
1456
+ result.skipped++;
1457
+ result.skippedIds.push(task.id);
1458
+ continue;
1459
+ }
1460
+ try {
1461
+ // Update task status to closed
1462
+ const updates = {
1463
+ status: TaskStatusEnum.CLOSED,
1464
+ closedAt: createTimestamp(),
1465
+ };
1466
+ if (closeReason) {
1467
+ updates.closeReason = closeReason;
1468
+ }
1469
+ await this.update(task.id, updates, { actor });
1470
+ result.updated++;
1471
+ result.updatedIds.push(task.id);
1472
+ }
1473
+ catch (error) {
1474
+ result.errors.push({
1475
+ taskId: task.id,
1476
+ message: error instanceof Error ? error.message : String(error),
1477
+ });
1478
+ }
1479
+ }
1480
+ return result;
1481
+ }
1482
+ async bulkDeferPlanTasks(planId, options) {
1483
+ // Verify plan exists
1484
+ const plan = await this.get(planId);
1485
+ if (!plan) {
1486
+ throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
1487
+ }
1488
+ if (plan.type !== 'plan') {
1489
+ throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
1490
+ }
1491
+ // Get all tasks in the plan
1492
+ const tasks = await this.getTasksInPlan(planId, options?.filter);
1493
+ const result = {
1494
+ updated: 0,
1495
+ skipped: 0,
1496
+ updatedIds: [],
1497
+ skippedIds: [],
1498
+ errors: [],
1499
+ };
1500
+ const actor = options?.actor ?? plan.createdBy;
1501
+ // Valid statuses for defer transition
1502
+ const deferableStatuses = [TaskStatusEnum.OPEN, TaskStatusEnum.IN_PROGRESS, TaskStatusEnum.BLOCKED];
1503
+ for (const task of tasks) {
1504
+ // Skip tasks that can't be deferred
1505
+ if (!deferableStatuses.includes(task.status)) {
1506
+ result.skipped++;
1507
+ result.skippedIds.push(task.id);
1508
+ continue;
1509
+ }
1510
+ try {
1511
+ await this.update(task.id, { status: TaskStatusEnum.DEFERRED }, { actor });
1512
+ result.updated++;
1513
+ result.updatedIds.push(task.id);
1514
+ }
1515
+ catch (error) {
1516
+ result.errors.push({
1517
+ taskId: task.id,
1518
+ message: error instanceof Error ? error.message : String(error),
1519
+ });
1520
+ }
1521
+ }
1522
+ return result;
1523
+ }
1524
+ async bulkReassignPlanTasks(planId, newAssignee, options) {
1525
+ // Verify plan exists
1526
+ const plan = await this.get(planId);
1527
+ if (!plan) {
1528
+ throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
1529
+ }
1530
+ if (plan.type !== 'plan') {
1531
+ throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
1532
+ }
1533
+ // Get all tasks in the plan
1534
+ const tasks = await this.getTasksInPlan(planId, options?.filter);
1535
+ const result = {
1536
+ updated: 0,
1537
+ skipped: 0,
1538
+ updatedIds: [],
1539
+ skippedIds: [],
1540
+ errors: [],
1541
+ };
1542
+ const actor = options?.actor ?? plan.createdBy;
1543
+ for (const task of tasks) {
1544
+ // Skip tasks that already have the same assignee
1545
+ if (task.assignee === newAssignee) {
1546
+ result.skipped++;
1547
+ result.skippedIds.push(task.id);
1548
+ continue;
1549
+ }
1550
+ // Skip tombstone tasks
1551
+ if (task.status === TaskStatusEnum.TOMBSTONE) {
1552
+ result.skipped++;
1553
+ result.skippedIds.push(task.id);
1554
+ continue;
1555
+ }
1556
+ try {
1557
+ await this.update(task.id, { assignee: newAssignee }, { actor });
1558
+ result.updated++;
1559
+ result.updatedIds.push(task.id);
1560
+ }
1561
+ catch (error) {
1562
+ result.errors.push({
1563
+ taskId: task.id,
1564
+ message: error instanceof Error ? error.message : String(error),
1565
+ });
1566
+ }
1567
+ }
1568
+ return result;
1569
+ }
1570
+ async bulkTagPlanTasks(planId, options) {
1571
+ // Verify plan exists
1572
+ const plan = await this.get(planId);
1573
+ if (!plan) {
1574
+ throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
1575
+ }
1576
+ if (plan.type !== 'plan') {
1577
+ throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
1578
+ }
1579
+ // Validate that at least one tag operation is specified
1580
+ if ((!options.addTags || options.addTags.length === 0) &&
1581
+ (!options.removeTags || options.removeTags.length === 0)) {
1582
+ throw new ValidationError('At least one of addTags or removeTags must be specified', ErrorCode.INVALID_INPUT, { addTags: options.addTags, removeTags: options.removeTags });
1583
+ }
1584
+ // Get all tasks in the plan
1585
+ const tasks = await this.getTasksInPlan(planId, options?.filter);
1586
+ const result = {
1587
+ updated: 0,
1588
+ skipped: 0,
1589
+ updatedIds: [],
1590
+ skippedIds: [],
1591
+ errors: [],
1592
+ };
1593
+ const actor = options?.actor ?? plan.createdBy;
1594
+ const tagsToAdd = options.addTags ?? [];
1595
+ const tagsToRemove = new Set(options.removeTags ?? []);
1596
+ for (const task of tasks) {
1597
+ // Skip tombstone tasks
1598
+ if (task.status === TaskStatusEnum.TOMBSTONE) {
1599
+ result.skipped++;
1600
+ result.skippedIds.push(task.id);
1601
+ continue;
1602
+ }
1603
+ // Calculate new tags
1604
+ const existingTags = new Set(task.tags);
1605
+ // Remove tags first
1606
+ for (const tag of tagsToRemove) {
1607
+ existingTags.delete(tag);
1608
+ }
1609
+ // Then add tags
1610
+ for (const tag of tagsToAdd) {
1611
+ existingTags.add(tag);
1612
+ }
1613
+ const newTags = Array.from(existingTags).sort();
1614
+ const oldTags = [...task.tags].sort();
1615
+ // Skip if tags haven't changed
1616
+ if (newTags.length === oldTags.length && newTags.every((t, i) => t === oldTags[i])) {
1617
+ result.skipped++;
1618
+ result.skippedIds.push(task.id);
1619
+ continue;
1620
+ }
1621
+ try {
1622
+ await this.update(task.id, { tags: newTags }, { actor });
1623
+ result.updated++;
1624
+ result.updatedIds.push(task.id);
1625
+ }
1626
+ catch (error) {
1627
+ result.errors.push({
1628
+ taskId: task.id,
1629
+ message: error instanceof Error ? error.message : String(error),
1630
+ });
1631
+ }
1632
+ }
1633
+ return result;
1634
+ }
1635
+ // --------------------------------------------------------------------------
1636
+ // Task Operations
1637
+ // --------------------------------------------------------------------------
1638
+ async ready(filter) {
1639
+ // Extract limit to apply after sorting
1640
+ const limit = filter?.limit;
1641
+ // For team-based assignee filtering:
1642
+ // If an assignee is specified, also find tasks assigned to teams the entity belongs to
1643
+ let teamIds = [];
1644
+ if (filter?.assignee) {
1645
+ // Find all teams the entity is a member of
1646
+ const teams = await this.list({ type: 'team' });
1647
+ teamIds = teams
1648
+ .filter((team) => isTeamMember(team, filter.assignee))
1649
+ .map((team) => team.id);
1650
+ }
1651
+ // Build effective filter - remove assignee since we'll handle it manually
1652
+ const effectiveFilter = {
1653
+ ...filter,
1654
+ type: 'task',
1655
+ status: [TaskStatusEnum.OPEN, TaskStatusEnum.IN_PROGRESS],
1656
+ limit: undefined, // Don't limit at DB level - we'll apply after sorting
1657
+ assignee: undefined, // Handle assignee filtering manually for team support
1658
+ };
1659
+ // Get tasks matching filter
1660
+ let tasks = await this.list(effectiveFilter);
1661
+ // Apply team-aware assignee filtering if specified
1662
+ if (filter?.assignee) {
1663
+ const validAssignees = new Set([filter.assignee, ...teamIds]);
1664
+ tasks = tasks.filter((task) => task.assignee && validAssignees.has(task.assignee));
1665
+ }
1666
+ // Filter out blocked tasks
1667
+ const blockedIds = new Set(this.backend.query('SELECT element_id FROM blocked_cache').map((r) => r.element_id));
1668
+ // Filter out tasks whose parent plan is in DRAFT status
1669
+ // Uses a single SQL join to find task IDs that are children of draft plans
1670
+ const draftPlanTaskIds = new Set(this.backend.query(`SELECT d.blocked_id FROM dependencies d
1671
+ JOIN elements e ON d.blocker_id = e.id
1672
+ WHERE d.type = 'parent-child'
1673
+ AND e.deleted_at IS NULL
1674
+ AND e.type = 'plan'
1675
+ AND JSON_EXTRACT(e.data, '$.status') = 'draft'`).map((r) => r.blocked_id));
1676
+ // Get tasks that are children of ephemeral workflows (to exclude from ready list)
1677
+ // Find all ephemeral workflows
1678
+ const workflows = await this.list({ type: 'workflow' });
1679
+ const ephemeralWorkflowIds = new Set(workflows.filter((w) => w.ephemeral).map((w) => w.id));
1680
+ // Find all tasks that are children of ephemeral workflows
1681
+ let ephemeralTaskIds = new Set();
1682
+ if (ephemeralWorkflowIds.size > 0) {
1683
+ const deps = await this.getAllDependencies();
1684
+ for (const dep of deps) {
1685
+ if (dep.type === 'parent-child' && ephemeralWorkflowIds.has(dep.blockerId)) {
1686
+ ephemeralTaskIds.add(dep.blockedId);
1687
+ }
1688
+ }
1689
+ }
1690
+ // Filter out scheduled-for-future tasks, tasks from ephemeral workflows, and draft plan tasks
1691
+ const now = new Date();
1692
+ const includeEphemeral = filter?.includeEphemeral ?? false;
1693
+ const readyTasks = tasks.filter((task) => {
1694
+ // Not blocked
1695
+ if (blockedIds.has(task.id)) {
1696
+ return false;
1697
+ }
1698
+ // Not in a draft plan
1699
+ if (draftPlanTaskIds.has(task.id)) {
1700
+ return false;
1701
+ }
1702
+ // Not scheduled for future
1703
+ if (task.scheduledFor && new Date(task.scheduledFor) > now) {
1704
+ return false;
1705
+ }
1706
+ // Not a child of an ephemeral workflow (unless includeEphemeral is true)
1707
+ if (!includeEphemeral && ephemeralTaskIds.has(task.id)) {
1708
+ return false;
1709
+ }
1710
+ return true;
1711
+ });
1712
+ // Calculate effective priorities based on dependency relationships
1713
+ // Tasks blocking high-priority work inherit that urgency
1714
+ const tasksWithPriority = this.priorityService.enhanceTasksWithEffectivePriority(readyTasks);
1715
+ // Sort by effective priority ascending (1 = highest/critical, 5 = lowest/minimal)
1716
+ // Secondary sort by base priority for ties
1717
+ this.priorityService.sortByEffectivePriority(tasksWithPriority);
1718
+ // Apply limit after sorting
1719
+ if (limit !== undefined) {
1720
+ return tasksWithPriority.slice(0, limit);
1721
+ }
1722
+ return tasksWithPriority;
1723
+ }
1724
+ /**
1725
+ * Get tasks in backlog (not ready for work, needs triage)
1726
+ */
1727
+ async backlog(filter) {
1728
+ const limit = filter?.limit;
1729
+ const effectiveFilter = {
1730
+ ...filter,
1731
+ type: 'task',
1732
+ status: TaskStatusEnum.BACKLOG,
1733
+ limit: undefined, // Don't limit at DB level
1734
+ };
1735
+ let tasks = await this.list(effectiveFilter);
1736
+ // Sort by priority (highest first), then by creation date (oldest first)
1737
+ tasks.sort((a, b) => {
1738
+ const priorityDiff = a.priority - b.priority;
1739
+ if (priorityDiff !== 0)
1740
+ return priorityDiff;
1741
+ return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
1742
+ });
1743
+ // Apply limit after sorting
1744
+ if (limit) {
1745
+ tasks = tasks.slice(0, limit);
1746
+ }
1747
+ return tasks;
1748
+ }
1749
+ async blocked(filter) {
1750
+ // Extract limit to apply after filtering
1751
+ const limit = filter?.limit;
1752
+ const effectiveFilter = {
1753
+ ...filter,
1754
+ type: 'task',
1755
+ limit: undefined, // Don't limit at DB level - we'll apply after filtering
1756
+ };
1757
+ // Get tasks matching filter
1758
+ const tasks = await this.list(effectiveFilter);
1759
+ // Get blocked cache entries
1760
+ const blockedRows = this.backend.query('SELECT * FROM blocked_cache');
1761
+ const blockedMap = new Map(blockedRows.map((r) => [r.element_id, r]));
1762
+ // Filter to blocked tasks and add blocking info
1763
+ const blockedTasks = [];
1764
+ for (const task of tasks) {
1765
+ const blockInfo = blockedMap.get(task.id);
1766
+ if (blockInfo) {
1767
+ blockedTasks.push({
1768
+ ...task,
1769
+ blockedBy: blockInfo.blocked_by,
1770
+ blockReason: blockInfo.reason ?? 'Blocked by dependency',
1771
+ });
1772
+ }
1773
+ }
1774
+ // Apply limit after filtering
1775
+ if (limit !== undefined) {
1776
+ return blockedTasks.slice(0, limit);
1777
+ }
1778
+ return blockedTasks;
1779
+ }
1780
+ // --------------------------------------------------------------------------
1781
+ // Dependency Operations
1782
+ // --------------------------------------------------------------------------
1783
+ async addDependency(dep) {
1784
+ // Verify blocked element exists
1785
+ const source = await this.get(dep.blockedId);
1786
+ if (!source) {
1787
+ throw new NotFoundError(`Source element not found: ${dep.blockedId}`, ErrorCode.NOT_FOUND, { elementId: dep.blockedId });
1788
+ }
1789
+ // Check for existing dependency
1790
+ const existing = this.backend.queryOne('SELECT * FROM dependencies WHERE blocked_id = ? AND blocker_id = ? AND type = ?', [dep.blockedId, dep.blockerId, dep.type]);
1791
+ if (existing) {
1792
+ throw new ConflictError('Dependency already exists', ErrorCode.DUPLICATE_DEPENDENCY, {
1793
+ blockedId: dep.blockedId,
1794
+ blockerId: dep.blockerId,
1795
+ dependencyType: dep.type,
1796
+ });
1797
+ }
1798
+ // TODO: Check for cycles (for blocking dependency types)
1799
+ // Resolve actor - use provided actor or fall back to source element's creator
1800
+ const actor = dep.actor ?? source.createdBy;
1801
+ const now = createTimestamp();
1802
+ const dependency = {
1803
+ blockedId: dep.blockedId,
1804
+ blockerId: dep.blockerId,
1805
+ type: dep.type,
1806
+ createdAt: now,
1807
+ createdBy: actor,
1808
+ metadata: dep.metadata ?? {},
1809
+ };
1810
+ // Insert dependency and record event in a transaction
1811
+ this.backend.transaction((tx) => {
1812
+ // Insert dependency
1813
+ tx.run(`INSERT INTO dependencies (blocked_id, blocker_id, type, created_at, created_by, metadata)
1814
+ VALUES (?, ?, ?, ?, ?, ?)`, [
1815
+ dependency.blockedId,
1816
+ dependency.blockerId,
1817
+ dependency.type,
1818
+ dependency.createdAt,
1819
+ dependency.createdBy,
1820
+ dependency.metadata ? JSON.stringify(dependency.metadata) : null,
1821
+ ]);
1822
+ // Record dependency_added event
1823
+ const event = createEvent({
1824
+ elementId: dependency.blockedId,
1825
+ eventType: 'dependency_added',
1826
+ actor: dependency.createdBy,
1827
+ oldValue: null,
1828
+ newValue: {
1829
+ blockedId: dependency.blockedId,
1830
+ blockerId: dependency.blockerId,
1831
+ type: dependency.type,
1832
+ metadata: dependency.metadata,
1833
+ },
1834
+ });
1835
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
1836
+ VALUES (?, ?, ?, ?, ?, ?)`, [
1837
+ event.elementId,
1838
+ event.eventType,
1839
+ event.actor,
1840
+ null,
1841
+ JSON.stringify(event.newValue),
1842
+ event.createdAt,
1843
+ ]);
1844
+ });
1845
+ // Update blocked cache using the service (handles transitive blocking, gate satisfaction, etc.)
1846
+ this.blockedCache.onDependencyAdded(dep.blockedId, dep.blockerId, dep.type, dep.metadata);
1847
+ // Mark source as dirty
1848
+ this.backend.markDirty(dep.blockedId);
1849
+ return dependency;
1850
+ }
1851
+ async removeDependency(blockedId, blockerId, type, actor) {
1852
+ // Check dependency exists and capture for event
1853
+ const existing = this.backend.queryOne('SELECT * FROM dependencies WHERE blocked_id = ? AND blocker_id = ? AND type = ?', [blockedId, blockerId, type]);
1854
+ if (!existing) {
1855
+ throw new NotFoundError('Dependency not found', ErrorCode.DEPENDENCY_NOT_FOUND, { blockedId, blockerId, dependencyType: type });
1856
+ }
1857
+ // Get actor for event - use provided actor or fall back to the dependency creator
1858
+ const eventActor = actor ?? existing.created_by;
1859
+ // Remove dependency and record event in a transaction
1860
+ this.backend.transaction((tx) => {
1861
+ // Remove dependency
1862
+ tx.run('DELETE FROM dependencies WHERE blocked_id = ? AND blocker_id = ? AND type = ?', [blockedId, blockerId, type]);
1863
+ // Record dependency_removed event
1864
+ const event = createEvent({
1865
+ elementId: blockedId,
1866
+ eventType: 'dependency_removed',
1867
+ actor: eventActor,
1868
+ oldValue: {
1869
+ blockedId: existing.blocked_id,
1870
+ blockerId: existing.blocker_id,
1871
+ type: existing.type,
1872
+ metadata: existing.metadata ? JSON.parse(existing.metadata) : {},
1873
+ },
1874
+ newValue: null,
1875
+ });
1876
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
1877
+ VALUES (?, ?, ?, ?, ?, ?)`, [
1878
+ event.elementId,
1879
+ event.eventType,
1880
+ event.actor,
1881
+ JSON.stringify(event.oldValue),
1882
+ null,
1883
+ event.createdAt,
1884
+ ]);
1885
+ });
1886
+ // Update blocked cache using the service (recomputes blocking state)
1887
+ this.blockedCache.onDependencyRemoved(blockedId, blockerId, type);
1888
+ // Mark source as dirty
1889
+ this.backend.markDirty(blockedId);
1890
+ }
1891
+ async getDependencies(id, types) {
1892
+ let sql = 'SELECT * FROM dependencies WHERE blocked_id = ?';
1893
+ const params = [id];
1894
+ if (types && types.length > 0) {
1895
+ const placeholders = types.map(() => '?').join(', ');
1896
+ sql += ` AND type IN (${placeholders})`;
1897
+ params.push(...types);
1898
+ }
1899
+ const rows = this.backend.query(sql, params);
1900
+ return rows.map((row) => ({
1901
+ blockedId: row.blocked_id,
1902
+ blockerId: row.blocker_id,
1903
+ type: row.type,
1904
+ createdAt: row.created_at,
1905
+ createdBy: row.created_by,
1906
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
1907
+ }));
1908
+ }
1909
+ async getDependents(id, types) {
1910
+ let sql = 'SELECT * FROM dependencies WHERE blocker_id = ?';
1911
+ const params = [id];
1912
+ if (types && types.length > 0) {
1913
+ const placeholders = types.map(() => '?').join(', ');
1914
+ sql += ` AND type IN (${placeholders})`;
1915
+ params.push(...types);
1916
+ }
1917
+ const rows = this.backend.query(sql, params);
1918
+ return rows.map((row) => ({
1919
+ blockedId: row.blocked_id,
1920
+ blockerId: row.blocker_id,
1921
+ type: row.type,
1922
+ createdAt: row.created_at,
1923
+ createdBy: row.created_by,
1924
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
1925
+ }));
1926
+ }
1927
+ async getDependencyTree(id) {
1928
+ const element = await this.get(id);
1929
+ if (!element) {
1930
+ throw new NotFoundError(`Element not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
1931
+ }
1932
+ // Build tree recursively (with depth limit to prevent infinite loops)
1933
+ const maxDepth = 10;
1934
+ const visited = new Set();
1935
+ const buildNode = async (elem, depth, direction) => {
1936
+ const node = {
1937
+ element: elem,
1938
+ dependencies: [],
1939
+ dependents: [],
1940
+ };
1941
+ if (depth >= maxDepth || visited.has(elem.id)) {
1942
+ return node;
1943
+ }
1944
+ visited.add(elem.id);
1945
+ if (direction === 'deps' || depth === 0) {
1946
+ const deps = await this.getDependencies(elem.id);
1947
+ for (const dep of deps) {
1948
+ const targetElem = await this.get(dep.blockerId);
1949
+ if (targetElem) {
1950
+ const childNode = await buildNode(targetElem, depth + 1, 'deps');
1951
+ node.dependencies.push(childNode);
1952
+ }
1953
+ }
1954
+ }
1955
+ if (direction === 'dependents' || depth === 0) {
1956
+ const dependents = await this.getDependents(elem.id);
1957
+ for (const dep of dependents) {
1958
+ const sourceElem = await this.get(dep.blockedId);
1959
+ if (sourceElem) {
1960
+ const parentNode = await buildNode(sourceElem, depth + 1, 'dependents');
1961
+ node.dependents.push(parentNode);
1962
+ }
1963
+ }
1964
+ }
1965
+ return node;
1966
+ };
1967
+ const root = await buildNode(element, 0, 'deps');
1968
+ // Calculate depths
1969
+ const countDepth = (node, direction) => {
1970
+ const children = direction === 'deps' ? node.dependencies : node.dependents;
1971
+ if (children.length === 0)
1972
+ return 0;
1973
+ return 1 + Math.max(...children.map((c) => countDepth(c, direction)));
1974
+ };
1975
+ const countNodes = (node, visited) => {
1976
+ if (visited.has(node.element.id))
1977
+ return 0;
1978
+ visited.add(node.element.id);
1979
+ let count = 1;
1980
+ for (const child of node.dependencies) {
1981
+ count += countNodes(child, visited);
1982
+ }
1983
+ for (const child of node.dependents) {
1984
+ count += countNodes(child, visited);
1985
+ }
1986
+ return count;
1987
+ };
1988
+ return {
1989
+ root,
1990
+ dependencyDepth: countDepth(root, 'deps'),
1991
+ dependentDepth: countDepth(root, 'dependents'),
1992
+ nodeCount: countNodes(root, new Set()),
1993
+ };
1994
+ }
1995
+ // --------------------------------------------------------------------------
1996
+ // Gate Satisfaction
1997
+ // --------------------------------------------------------------------------
1998
+ async satisfyGate(blockedId, blockerId, actor) {
1999
+ return this.blockedCache.satisfyGate(blockedId, blockerId, actor);
2000
+ }
2001
+ async recordApproval(blockedId, blockerId, approver) {
2002
+ return this.blockedCache.recordApproval(blockedId, blockerId, approver);
2003
+ }
2004
+ async removeApproval(blockedId, blockerId, approver) {
2005
+ return this.blockedCache.removeApproval(blockedId, blockerId, approver);
2006
+ }
2007
+ // --------------------------------------------------------------------------
2008
+ // Search
2009
+ // --------------------------------------------------------------------------
2010
+ async search(query, filter) {
2011
+ // Simple LIKE-based search for now
2012
+ const searchPattern = `%${query}%`;
2013
+ const params = [];
2014
+ // Build base WHERE clause from filter (params accumulates in place)
2015
+ const { where: filterWhere } = buildWhereClause(filter ?? {}, params);
2016
+ // Search in title (stored in data JSON)
2017
+ const sql = `
2018
+ SELECT DISTINCT e.*
2019
+ FROM elements e
2020
+ LEFT JOIN tags t ON e.id = t.element_id
2021
+ WHERE ${filterWhere}
2022
+ AND (
2023
+ JSON_EXTRACT(e.data, '$.title') LIKE ?
2024
+ OR JSON_EXTRACT(e.data, '$.content') LIKE ?
2025
+ OR t.tag LIKE ?
2026
+ )
2027
+ ORDER BY e.updated_at DESC
2028
+ LIMIT 100
2029
+ `;
2030
+ params.push(searchPattern, searchPattern, searchPattern);
2031
+ const rows = this.backend.query(sql, params);
2032
+ // Fetch tags and deserialize
2033
+ const results = [];
2034
+ for (const row of rows) {
2035
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
2036
+ const tags = tagRows.map((r) => r.tag);
2037
+ const el = deserializeElement(row, tags);
2038
+ if (el)
2039
+ results.push(el);
2040
+ }
2041
+ return results;
2042
+ }
2043
+ async searchChannels(query, filter) {
2044
+ const searchPattern = `%${query}%`;
2045
+ const params = [];
2046
+ // Build base WHERE clause from filter (params accumulates in place)
2047
+ // Force type to 'channel'
2048
+ const channelFilter = { ...filter, type: 'channel' };
2049
+ const { where: filterWhere } = buildWhereClause(channelFilter, params);
2050
+ // Build channel-specific WHERE clause
2051
+ const { where: channelWhere } = buildChannelWhereClause(filter ?? {}, params);
2052
+ // Combine base and channel-specific conditions
2053
+ let fullWhere = filterWhere;
2054
+ if (channelWhere) {
2055
+ fullWhere = `${filterWhere} AND ${channelWhere}`;
2056
+ }
2057
+ // Search in channel name
2058
+ const sql = `
2059
+ SELECT DISTINCT e.*
2060
+ FROM elements e
2061
+ LEFT JOIN tags t ON e.id = t.element_id
2062
+ WHERE ${fullWhere}
2063
+ AND (
2064
+ JSON_EXTRACT(e.data, '$.name') LIKE ?
2065
+ OR t.tag LIKE ?
2066
+ )
2067
+ ORDER BY e.updated_at DESC
2068
+ LIMIT 100
2069
+ `;
2070
+ params.push(searchPattern, searchPattern);
2071
+ const rows = this.backend.query(sql, params);
2072
+ // Fetch tags and deserialize
2073
+ const results = [];
2074
+ for (const row of rows) {
2075
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
2076
+ const tags = tagRows.map((r) => r.tag);
2077
+ const ch = deserializeElement(row, tags);
2078
+ if (ch)
2079
+ results.push(ch);
2080
+ }
2081
+ return results;
2082
+ }
2083
+ // --------------------------------------------------------------------------
2084
+ // History Operations
2085
+ // --------------------------------------------------------------------------
2086
+ async getEvents(id, filter) {
2087
+ let sql = 'SELECT * FROM events WHERE element_id = ?';
2088
+ const params = [id];
2089
+ if (filter?.eventType) {
2090
+ const types = Array.isArray(filter.eventType) ? filter.eventType : [filter.eventType];
2091
+ const placeholders = types.map(() => '?').join(', ');
2092
+ sql += ` AND event_type IN (${placeholders})`;
2093
+ params.push(...types);
2094
+ }
2095
+ if (filter?.actor) {
2096
+ sql += ' AND actor = ?';
2097
+ params.push(filter.actor);
2098
+ }
2099
+ if (filter?.after) {
2100
+ sql += ' AND created_at > ?';
2101
+ params.push(filter.after);
2102
+ }
2103
+ if (filter?.before) {
2104
+ sql += ' AND created_at < ?';
2105
+ params.push(filter.before);
2106
+ }
2107
+ sql += ' ORDER BY created_at DESC';
2108
+ if (filter?.limit) {
2109
+ sql += ' LIMIT ?';
2110
+ params.push(filter.limit);
2111
+ }
2112
+ const rows = this.backend.query(sql, params);
2113
+ return rows.map((row) => ({
2114
+ id: row.id,
2115
+ elementId: row.element_id,
2116
+ eventType: row.event_type,
2117
+ actor: row.actor,
2118
+ oldValue: row.old_value ? JSON.parse(row.old_value) : null,
2119
+ newValue: row.new_value ? JSON.parse(row.new_value) : null,
2120
+ createdAt: row.created_at,
2121
+ }));
2122
+ }
2123
+ async listEvents(filter) {
2124
+ let sql = 'SELECT * FROM events WHERE 1=1';
2125
+ const params = [];
2126
+ if (filter?.elementId) {
2127
+ sql += ' AND element_id = ?';
2128
+ params.push(filter.elementId);
2129
+ }
2130
+ if (filter?.eventType) {
2131
+ const types = Array.isArray(filter.eventType) ? filter.eventType : [filter.eventType];
2132
+ const placeholders = types.map(() => '?').join(', ');
2133
+ sql += ` AND event_type IN (${placeholders})`;
2134
+ params.push(...types);
2135
+ }
2136
+ if (filter?.actor) {
2137
+ sql += ' AND actor = ?';
2138
+ params.push(filter.actor);
2139
+ }
2140
+ if (filter?.after) {
2141
+ sql += ' AND created_at > ?';
2142
+ params.push(filter.after);
2143
+ }
2144
+ if (filter?.before) {
2145
+ sql += ' AND created_at < ?';
2146
+ params.push(filter.before);
2147
+ }
2148
+ sql += ' ORDER BY created_at DESC';
2149
+ if (filter?.limit) {
2150
+ sql += ' LIMIT ?';
2151
+ params.push(filter.limit);
2152
+ }
2153
+ if (filter?.offset) {
2154
+ sql += ' OFFSET ?';
2155
+ params.push(filter.offset);
2156
+ }
2157
+ const rows = this.backend.query(sql, params);
2158
+ return rows.map((row) => ({
2159
+ id: row.id,
2160
+ elementId: row.element_id,
2161
+ eventType: row.event_type,
2162
+ actor: row.actor,
2163
+ oldValue: row.old_value ? JSON.parse(row.old_value) : null,
2164
+ newValue: row.new_value ? JSON.parse(row.new_value) : null,
2165
+ createdAt: row.created_at,
2166
+ }));
2167
+ }
2168
+ async countEvents(filter) {
2169
+ let sql = 'SELECT COUNT(*) as count FROM events WHERE 1=1';
2170
+ const params = [];
2171
+ if (filter?.elementId) {
2172
+ sql += ' AND element_id = ?';
2173
+ params.push(filter.elementId);
2174
+ }
2175
+ if (filter?.eventType) {
2176
+ const types = Array.isArray(filter.eventType) ? filter.eventType : [filter.eventType];
2177
+ const placeholders = types.map(() => '?').join(', ');
2178
+ sql += ` AND event_type IN (${placeholders})`;
2179
+ params.push(...types);
2180
+ }
2181
+ if (filter?.actor) {
2182
+ sql += ' AND actor = ?';
2183
+ params.push(filter.actor);
2184
+ }
2185
+ if (filter?.after) {
2186
+ sql += ' AND created_at > ?';
2187
+ params.push(filter.after);
2188
+ }
2189
+ if (filter?.before) {
2190
+ sql += ' AND created_at < ?';
2191
+ params.push(filter.before);
2192
+ }
2193
+ const row = this.backend.queryOne(sql, params);
2194
+ return row?.count ?? 0;
2195
+ }
2196
+ async getDocumentVersion(id, version) {
2197
+ const current = await this.get(id);
2198
+ if (!current) {
2199
+ throw new NotFoundError(`Document not found: ${id}`, ErrorCode.NOT_FOUND, { documentId: id });
2200
+ }
2201
+ if (current.type !== 'document') {
2202
+ throw new ValidationError(`Element ${id} is not a document (type: ${current.type})`, ErrorCode.INVALID_INPUT, { elementId: id, actualType: current.type, expectedType: 'document' });
2203
+ }
2204
+ if (current.deletedAt) {
2205
+ throw new NotFoundError(`Document has been deleted: ${id}`, ErrorCode.NOT_FOUND, { documentId: id, deletedAt: current.deletedAt });
2206
+ }
2207
+ if (current.version === version) {
2208
+ return current;
2209
+ }
2210
+ // Look in version history
2211
+ const row = this.backend.queryOne('SELECT data, created_at FROM document_versions WHERE id = ? AND version = ?', [id, version]);
2212
+ if (!row) {
2213
+ return null;
2214
+ }
2215
+ const data = JSON.parse(row.data);
2216
+ return {
2217
+ id: id,
2218
+ type: 'document',
2219
+ createdAt: row.created_at,
2220
+ updatedAt: row.created_at,
2221
+ createdBy: data.createdBy,
2222
+ tags: data.tags ?? [],
2223
+ metadata: data.metadata ?? {},
2224
+ ...data,
2225
+ };
2226
+ }
2227
+ async getDocumentHistory(id) {
2228
+ // Get current version
2229
+ const current = await this.get(id);
2230
+ const results = [];
2231
+ if (current && current.type === 'document' && !current.deletedAt) {
2232
+ results.push(current);
2233
+ }
2234
+ // Get historical versions (exclude current version to avoid duplicates)
2235
+ const rows = this.backend.query('SELECT version, data, created_at FROM document_versions WHERE id = ? AND version != ? ORDER BY version DESC', [id, current?.version ?? -1]);
2236
+ for (const row of rows) {
2237
+ try {
2238
+ const data = JSON.parse(row.data);
2239
+ results.push({
2240
+ id: id,
2241
+ type: 'document',
2242
+ createdAt: row.created_at,
2243
+ updatedAt: row.created_at,
2244
+ createdBy: data.createdBy,
2245
+ tags: data.tags ?? [],
2246
+ metadata: data.metadata ?? {},
2247
+ ...data,
2248
+ version: row.version,
2249
+ });
2250
+ }
2251
+ catch (error) {
2252
+ console.warn(`[stoneforge] Skipping corrupt version ${row.version} for ${id}:`, error);
2253
+ }
2254
+ }
2255
+ return results;
2256
+ }
2257
+ async reconstructAtTime(id, asOf) {
2258
+ // Get all events for this element (we need them all for reconstruction)
2259
+ const events = await this.getEvents(id, {});
2260
+ if (events.length === 0) {
2261
+ throw new NotFoundError(`No events found for element: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
2262
+ }
2263
+ // Use the reconstruction utility
2264
+ const { state, eventsApplied, exists } = reconstructStateAtTime(events, asOf);
2265
+ // If the element didn't exist at that time, return null
2266
+ if (!exists || state === null) {
2267
+ return null;
2268
+ }
2269
+ // Return the reconstructed state
2270
+ return {
2271
+ element: state,
2272
+ asOf,
2273
+ eventsApplied,
2274
+ exists,
2275
+ };
2276
+ }
2277
+ async getElementTimeline(id, filter) {
2278
+ // Get all events for this element
2279
+ const events = await this.getEvents(id, filter);
2280
+ if (events.length === 0) {
2281
+ throw new NotFoundError(`No events found for element: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
2282
+ }
2283
+ // Get current state
2284
+ const currentState = await this.get(id);
2285
+ // Generate timeline snapshots
2286
+ const snapshotData = generateTimelineSnapshots(events);
2287
+ // Convert to the expected format
2288
+ const snapshots = snapshotData.map(({ event, state, summary }) => ({
2289
+ event,
2290
+ state,
2291
+ summary,
2292
+ }));
2293
+ return {
2294
+ elementId: id,
2295
+ currentState,
2296
+ snapshots,
2297
+ totalEvents: events.length,
2298
+ };
2299
+ }
2300
+ // --------------------------------------------------------------------------
2301
+ // Channel Operations
2302
+ // --------------------------------------------------------------------------
2303
+ async findOrCreateDirectChannel(entityA, entityB, actor) {
2304
+ // Validate actor is one of the entities
2305
+ if (actor !== entityA && actor !== entityB) {
2306
+ throw new ValidationError('Actor must be one of the channel entities', ErrorCode.INVALID_INPUT, { field: 'actor', value: actor, expected: 'entityA or entityB' });
2307
+ }
2308
+ // Search by members for backward compatibility with both ID-named and name-named channels
2309
+ const sortedMembers = [entityA, entityB].sort();
2310
+ const existingRow = this.backend.queryOne(`SELECT * FROM elements
2311
+ WHERE type = 'channel'
2312
+ AND JSON_EXTRACT(data, '$.channelType') = 'direct'
2313
+ AND JSON_EXTRACT(data, '$.members[0]') = ?
2314
+ AND JSON_EXTRACT(data, '$.members[1]') = ?
2315
+ AND deleted_at IS NULL`, [sortedMembers[0], sortedMembers[1]]);
2316
+ if (existingRow) {
2317
+ // Found existing channel, return it
2318
+ const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [existingRow.id]);
2319
+ const tags = tagRows.map((r) => r.tag);
2320
+ const channel = deserializeElement(existingRow, tags);
2321
+ if (!channel) {
2322
+ throw new StorageError(`Corrupt channel data: ${existingRow.id}`);
2323
+ }
2324
+ return { channel, created: false };
2325
+ }
2326
+ // Look up entity names for channel naming
2327
+ const entityAData = await this.get(entityA);
2328
+ const entityBData = await this.get(entityB);
2329
+ const entityAName = entityAData?.name;
2330
+ const entityBName = entityBData?.name;
2331
+ // No existing channel, create a new one with entity names
2332
+ const newChannel = await createDirectChannel({
2333
+ entityA,
2334
+ entityB,
2335
+ createdBy: actor,
2336
+ ...(entityAName && { entityAName }),
2337
+ ...(entityBName && { entityBName }),
2338
+ });
2339
+ const createdChannel = await this.create(newChannel);
2340
+ return { channel: createdChannel, created: true };
2341
+ }
2342
+ async addChannelMember(channelId, entityId, options) {
2343
+ // Get the channel
2344
+ const channel = await this.get(channelId);
2345
+ if (!channel) {
2346
+ throw new NotFoundError(`Channel not found: ${channelId}`, ErrorCode.NOT_FOUND, { elementId: channelId });
2347
+ }
2348
+ // Verify it's a channel
2349
+ if (channel.type !== 'channel') {
2350
+ throw new ConstraintError(`Element is not a channel: ${channelId}`, ErrorCode.TYPE_MISMATCH, { elementId: channelId, actualType: channel.type, expectedType: 'channel' });
2351
+ }
2352
+ // Cast to Channel type (type guard validated above)
2353
+ const typedChannel = channel;
2354
+ // Direct channels cannot have membership modified
2355
+ if (typedChannel.channelType === ChannelTypeValue.DIRECT) {
2356
+ throw new DirectChannelMembershipError(channelId, 'add');
2357
+ }
2358
+ // Get actor
2359
+ const actor = options?.actor ?? typedChannel.createdBy;
2360
+ // Check actor has permission to modify members
2361
+ if (!canModifyMembers(typedChannel, actor)) {
2362
+ throw new CannotModifyMembersError(channelId, actor);
2363
+ }
2364
+ // Check if entity is already a member
2365
+ if (isMember(typedChannel, entityId)) {
2366
+ // Already a member, return success without change
2367
+ return { success: true, channel: typedChannel, entityId };
2368
+ }
2369
+ // Add member
2370
+ const newMembers = [...typedChannel.members, entityId];
2371
+ const now = createTimestamp();
2372
+ // Update channel and record event in transaction
2373
+ this.backend.transaction((tx) => {
2374
+ // Get current data
2375
+ const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [channelId]);
2376
+ if (!row)
2377
+ return;
2378
+ const data = JSON.parse(row.data);
2379
+ data.members = newMembers;
2380
+ // Recompute content hash
2381
+ const updatedChannel = { ...typedChannel, members: newMembers, updatedAt: now };
2382
+ const { hash: contentHash } = computeContentHashSync(updatedChannel);
2383
+ // Update element
2384
+ tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, channelId]);
2385
+ // Record membership event
2386
+ const event = createEvent({
2387
+ elementId: channelId,
2388
+ eventType: MembershipEventType.MEMBER_ADDED,
2389
+ actor,
2390
+ oldValue: { members: typedChannel.members },
2391
+ newValue: { members: newMembers, addedMember: entityId },
2392
+ });
2393
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
2394
+ VALUES (?, ?, ?, ?, ?, ?)`, [
2395
+ event.elementId,
2396
+ event.eventType,
2397
+ event.actor,
2398
+ JSON.stringify(event.oldValue),
2399
+ JSON.stringify(event.newValue),
2400
+ event.createdAt,
2401
+ ]);
2402
+ });
2403
+ // Mark as dirty
2404
+ this.backend.markDirty(channelId);
2405
+ // Return updated channel
2406
+ const updatedChannel = await this.get(channelId);
2407
+ return {
2408
+ success: true,
2409
+ channel: updatedChannel,
2410
+ entityId,
2411
+ };
2412
+ }
2413
+ async removeChannelMember(channelId, entityId, options) {
2414
+ // Get the channel
2415
+ const channel = await this.get(channelId);
2416
+ if (!channel) {
2417
+ throw new NotFoundError(`Channel not found: ${channelId}`, ErrorCode.NOT_FOUND, { elementId: channelId });
2418
+ }
2419
+ // Verify it's a channel
2420
+ if (channel.type !== 'channel') {
2421
+ throw new ConstraintError(`Element is not a channel: ${channelId}`, ErrorCode.TYPE_MISMATCH, { elementId: channelId, actualType: channel.type, expectedType: 'channel' });
2422
+ }
2423
+ // Cast to Channel type (type guard validated above)
2424
+ const typedChannel = channel;
2425
+ // Direct channels cannot have membership modified
2426
+ if (typedChannel.channelType === ChannelTypeValue.DIRECT) {
2427
+ throw new DirectChannelMembershipError(channelId, 'remove');
2428
+ }
2429
+ // Get actor
2430
+ const actor = options?.actor ?? typedChannel.createdBy;
2431
+ // Check actor has permission to modify members
2432
+ if (!canModifyMembers(typedChannel, actor)) {
2433
+ throw new CannotModifyMembersError(channelId, actor);
2434
+ }
2435
+ // Check if entity is a member
2436
+ if (!isMember(typedChannel, entityId)) {
2437
+ throw new NotAMemberError(channelId, entityId);
2438
+ }
2439
+ // Remove member
2440
+ const newMembers = typedChannel.members.filter((m) => m !== entityId);
2441
+ // Also remove from modifyMembers if present
2442
+ const newModifyMembers = typedChannel.permissions.modifyMembers.filter((m) => m !== entityId);
2443
+ const now = createTimestamp();
2444
+ // Update channel and record event in transaction
2445
+ this.backend.transaction((tx) => {
2446
+ // Get current data
2447
+ const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [channelId]);
2448
+ if (!row)
2449
+ return;
2450
+ const data = JSON.parse(row.data);
2451
+ data.members = newMembers;
2452
+ data.permissions = {
2453
+ ...data.permissions,
2454
+ modifyMembers: newModifyMembers,
2455
+ };
2456
+ // Recompute content hash
2457
+ const updatedChannel = {
2458
+ ...typedChannel,
2459
+ members: newMembers,
2460
+ permissions: { ...typedChannel.permissions, modifyMembers: newModifyMembers },
2461
+ updatedAt: now,
2462
+ };
2463
+ const { hash: contentHash } = computeContentHashSync(updatedChannel);
2464
+ // Update element
2465
+ tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, channelId]);
2466
+ // Record membership event
2467
+ const event = createEvent({
2468
+ elementId: channelId,
2469
+ eventType: MembershipEventType.MEMBER_REMOVED,
2470
+ actor,
2471
+ oldValue: { members: typedChannel.members },
2472
+ newValue: {
2473
+ members: newMembers,
2474
+ removedMember: entityId,
2475
+ ...(options?.reason && { reason: options.reason }),
2476
+ },
2477
+ });
2478
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
2479
+ VALUES (?, ?, ?, ?, ?, ?)`, [
2480
+ event.elementId,
2481
+ event.eventType,
2482
+ event.actor,
2483
+ JSON.stringify(event.oldValue),
2484
+ JSON.stringify(event.newValue),
2485
+ event.createdAt,
2486
+ ]);
2487
+ });
2488
+ // Mark as dirty
2489
+ this.backend.markDirty(channelId);
2490
+ // Return updated channel
2491
+ const updatedChannel = await this.get(channelId);
2492
+ return {
2493
+ success: true,
2494
+ channel: updatedChannel,
2495
+ entityId,
2496
+ };
2497
+ }
2498
+ async leaveChannel(channelId, actor) {
2499
+ // Get the channel
2500
+ const channel = await this.get(channelId);
2501
+ if (!channel) {
2502
+ throw new NotFoundError(`Channel not found: ${channelId}`, ErrorCode.NOT_FOUND, { elementId: channelId });
2503
+ }
2504
+ // Verify it's a channel
2505
+ if (channel.type !== 'channel') {
2506
+ throw new ConstraintError(`Element is not a channel: ${channelId}`, ErrorCode.TYPE_MISMATCH, { elementId: channelId, actualType: channel.type, expectedType: 'channel' });
2507
+ }
2508
+ // Cast to Channel type (type guard validated above)
2509
+ const typedChannel = channel;
2510
+ // Direct channels cannot be left
2511
+ if (typedChannel.channelType === ChannelTypeValue.DIRECT) {
2512
+ throw new ConstraintError('Cannot leave a direct channel', ErrorCode.IMMUTABLE, { channelId, channelType: 'direct' });
2513
+ }
2514
+ // Check if actor is a member
2515
+ if (!isMember(typedChannel, actor)) {
2516
+ throw new NotAMemberError(channelId, actor);
2517
+ }
2518
+ // Remove actor from members
2519
+ const newMembers = typedChannel.members.filter((m) => m !== actor);
2520
+ // Also remove from modifyMembers if present
2521
+ const newModifyMembers = typedChannel.permissions.modifyMembers.filter((m) => m !== actor);
2522
+ const now = createTimestamp();
2523
+ // Update channel and record event in transaction
2524
+ this.backend.transaction((tx) => {
2525
+ // Get current data
2526
+ const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [channelId]);
2527
+ if (!row)
2528
+ return;
2529
+ const data = JSON.parse(row.data);
2530
+ data.members = newMembers;
2531
+ data.permissions = {
2532
+ ...data.permissions,
2533
+ modifyMembers: newModifyMembers,
2534
+ };
2535
+ // Recompute content hash
2536
+ const updatedChannelData = {
2537
+ ...typedChannel,
2538
+ members: newMembers,
2539
+ permissions: { ...typedChannel.permissions, modifyMembers: newModifyMembers },
2540
+ updatedAt: now,
2541
+ };
2542
+ const { hash: contentHash } = computeContentHashSync(updatedChannelData);
2543
+ // Update element
2544
+ tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, channelId]);
2545
+ // Record membership event (leaving is a special form of member_removed)
2546
+ const event = createEvent({
2547
+ elementId: channelId,
2548
+ eventType: MembershipEventType.MEMBER_REMOVED,
2549
+ actor,
2550
+ oldValue: { members: typedChannel.members },
2551
+ newValue: { members: newMembers, removedMember: actor, selfRemoval: true },
2552
+ });
2553
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
2554
+ VALUES (?, ?, ?, ?, ?, ?)`, [
2555
+ event.elementId,
2556
+ event.eventType,
2557
+ event.actor,
2558
+ JSON.stringify(event.oldValue),
2559
+ JSON.stringify(event.newValue),
2560
+ event.createdAt,
2561
+ ]);
2562
+ });
2563
+ // Mark as dirty
2564
+ this.backend.markDirty(channelId);
2565
+ // Return updated channel
2566
+ const updatedChannel = await this.get(channelId);
2567
+ return {
2568
+ success: true,
2569
+ channel: updatedChannel,
2570
+ entityId: actor,
2571
+ };
2572
+ }
2573
+ /**
2574
+ * Merge two group channels: move all messages from source to target,
2575
+ * merge member lists, and archive the source channel.
2576
+ *
2577
+ * Only group channels can be merged. Direct channels are rejected.
2578
+ */
2579
+ async mergeChannels(sourceId, targetId, options) {
2580
+ // Fetch both channels
2581
+ const source = await this.get(sourceId);
2582
+ if (!source || source.type !== 'channel') {
2583
+ throw new NotFoundError(`Source channel not found: ${sourceId}`, ErrorCode.NOT_FOUND, { elementId: sourceId });
2584
+ }
2585
+ const target = await this.get(targetId);
2586
+ if (!target || target.type !== 'channel') {
2587
+ throw new NotFoundError(`Target channel not found: ${targetId}`, ErrorCode.NOT_FOUND, { elementId: targetId });
2588
+ }
2589
+ const typedSource = source;
2590
+ const typedTarget = target;
2591
+ // Only group channels can be merged
2592
+ if (typedSource.channelType !== ChannelTypeValue.GROUP) {
2593
+ throw new ConstraintError('Cannot merge: source is not a group channel', ErrorCode.IMMUTABLE, { channelId: sourceId, channelType: typedSource.channelType });
2594
+ }
2595
+ if (typedTarget.channelType !== ChannelTypeValue.GROUP) {
2596
+ throw new ConstraintError('Cannot merge: target is not a group channel', ErrorCode.IMMUTABLE, { channelId: targetId, channelType: typedTarget.channelType });
2597
+ }
2598
+ const actor = options?.actor ?? typedTarget.createdBy;
2599
+ const now = createTimestamp();
2600
+ // Get all messages from source channel
2601
+ const sourceMessages = this.backend.query(`SELECT * FROM elements
2602
+ WHERE type = 'message'
2603
+ AND JSON_EXTRACT(data, '$.channelId') = ?
2604
+ AND deleted_at IS NULL`, [sourceId]);
2605
+ // Merge members: add source members not already in target
2606
+ const targetMemberSet = new Set(typedTarget.members);
2607
+ const newMembers = [...typedTarget.members];
2608
+ for (const member of typedSource.members) {
2609
+ if (!targetMemberSet.has(member)) {
2610
+ newMembers.push(member);
2611
+ }
2612
+ }
2613
+ // Merge modifyMembers similarly
2614
+ const targetModSet = new Set(typedTarget.permissions.modifyMembers);
2615
+ const newModifyMembers = [...typedTarget.permissions.modifyMembers];
2616
+ for (const mod of typedSource.permissions.modifyMembers) {
2617
+ if (!targetModSet.has(mod)) {
2618
+ newModifyMembers.push(mod);
2619
+ }
2620
+ }
2621
+ const newName = options?.newName ?? typedTarget.name;
2622
+ // Execute everything in a transaction
2623
+ this.backend.transaction((tx) => {
2624
+ // 1. Move messages: update channelId in each message's data
2625
+ for (const msgRow of sourceMessages) {
2626
+ const msgData = JSON.parse(msgRow.data);
2627
+ msgData.channelId = targetId;
2628
+ tx.run(`UPDATE elements SET data = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(msgData), now, msgRow.id]);
2629
+ }
2630
+ // 2. Update inbox_items channel_id for moved messages
2631
+ const messageIds = sourceMessages.map((m) => m.id);
2632
+ if (messageIds.length > 0) {
2633
+ const placeholders = messageIds.map(() => '?').join(',');
2634
+ tx.run(`UPDATE inbox_items SET channel_id = ? WHERE message_id IN (${placeholders})`, [targetId, ...messageIds]);
2635
+ }
2636
+ // 3. Update target channel: merged members, optional rename
2637
+ const targetRow = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [targetId]);
2638
+ if (targetRow) {
2639
+ const targetData = JSON.parse(targetRow.data);
2640
+ targetData.members = newMembers;
2641
+ targetData.permissions = {
2642
+ ...targetData.permissions,
2643
+ modifyMembers: newModifyMembers,
2644
+ };
2645
+ if (options?.newName) {
2646
+ targetData.name = newName;
2647
+ }
2648
+ const updatedTarget = {
2649
+ ...typedTarget,
2650
+ members: newMembers,
2651
+ permissions: { ...typedTarget.permissions, modifyMembers: newModifyMembers },
2652
+ name: newName,
2653
+ updatedAt: now,
2654
+ };
2655
+ const { hash: contentHash } = computeContentHashSync(updatedTarget);
2656
+ tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(targetData), contentHash, now, targetId]);
2657
+ }
2658
+ // 4. Archive source channel (soft delete)
2659
+ tx.run(`UPDATE elements SET deleted_at = ? WHERE id = ?`, [now, sourceId]);
2660
+ // 5. Record merge event on target
2661
+ const event = createEvent({
2662
+ elementId: targetId,
2663
+ eventType: LifecycleEventType.UPDATED,
2664
+ actor,
2665
+ oldValue: { members: typedTarget.members, name: typedTarget.name },
2666
+ newValue: {
2667
+ members: newMembers,
2668
+ name: newName,
2669
+ mergedFrom: sourceId,
2670
+ messagesMoved: sourceMessages.length,
2671
+ },
2672
+ });
2673
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
2674
+ VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, JSON.stringify(event.oldValue), JSON.stringify(event.newValue), event.createdAt]);
2675
+ });
2676
+ // Mark both as dirty
2677
+ this.backend.markDirty(sourceId);
2678
+ this.backend.markDirty(targetId);
2679
+ // Return updated target
2680
+ const updatedTarget = await this.get(targetId);
2681
+ return {
2682
+ target: updatedTarget,
2683
+ sourceArchived: true,
2684
+ messagesMoved: sourceMessages.length,
2685
+ };
2686
+ }
2687
+ /**
2688
+ * Send a direct message to another entity
2689
+ *
2690
+ * This is a convenience method that:
2691
+ * 1. Finds or creates the direct channel between sender and recipient
2692
+ * 2. Creates and sends the message in that channel
2693
+ *
2694
+ * @param sender - The entity sending the message
2695
+ * @param input - The message input including recipient, contentRef, etc.
2696
+ * @returns The created message and channel information
2697
+ */
2698
+ async sendDirectMessage(sender, input) {
2699
+ // Find or create the direct channel
2700
+ const { channel, created: channelCreated } = await this.findOrCreateDirectChannel(sender, input.recipient, sender);
2701
+ // Create the message
2702
+ const message = await createMessage({
2703
+ channelId: channel.id,
2704
+ sender,
2705
+ contentRef: input.contentRef,
2706
+ attachments: input.attachments,
2707
+ tags: input.tags,
2708
+ metadata: input.metadata,
2709
+ });
2710
+ // Persist the message (membership validation happens in create)
2711
+ const createdMessage = await this.create(message);
2712
+ return {
2713
+ message: createdMessage,
2714
+ channel,
2715
+ channelCreated,
2716
+ };
2717
+ }
2718
+ // --------------------------------------------------------------------------
2719
+ // Workflow Operations
2720
+ // --------------------------------------------------------------------------
2721
+ async deleteWorkflow(workflowId, options) {
2722
+ // Get the workflow
2723
+ const workflow = await this.get(workflowId);
2724
+ if (!workflow) {
2725
+ throw new NotFoundError(`Workflow not found: ${workflowId}`, ErrorCode.NOT_FOUND, { id: workflowId });
2726
+ }
2727
+ if (workflow.type !== 'workflow') {
2728
+ throw new ValidationError(`Element ${workflowId} is not a workflow (type: ${workflow.type})`, ErrorCode.INVALID_INPUT, { field: 'workflowId', value: workflowId });
2729
+ }
2730
+ const wasEphemeral = workflow.ephemeral ?? false;
2731
+ // Get all dependencies to find tasks
2732
+ const allDependencies = await this.getAllDependencies();
2733
+ // Find task IDs that are children of this workflow
2734
+ const taskIds = [];
2735
+ for (const dep of allDependencies) {
2736
+ if (dep.type === 'parent-child' && dep.blockerId === workflowId) {
2737
+ taskIds.push(dep.blockedId);
2738
+ }
2739
+ }
2740
+ // Find all dependencies involving the workflow or its tasks
2741
+ const elementIds = new Set([workflowId, ...taskIds]);
2742
+ const depsToDelete = allDependencies.filter((dep) => elementIds.has(dep.blockedId) || elementIds.has(dep.blockerId));
2743
+ // Delete dependencies first
2744
+ for (const dep of depsToDelete) {
2745
+ try {
2746
+ await this.removeDependency(dep.blockedId, dep.blockerId, dep.type, options?.actor);
2747
+ }
2748
+ catch {
2749
+ // Ignore errors for dependencies that don't exist
2750
+ }
2751
+ }
2752
+ // Delete tasks
2753
+ for (const taskId of taskIds) {
2754
+ try {
2755
+ // Hard delete via SQL since this is a destructive delete
2756
+ this.backend.run('DELETE FROM elements WHERE id = ?', [taskId]);
2757
+ this.backend.run('DELETE FROM tags WHERE element_id = ?', [taskId]);
2758
+ this.backend.run('DELETE FROM events WHERE element_id = ?', [taskId]);
2759
+ }
2760
+ catch {
2761
+ // Ignore errors for tasks that don't exist
2762
+ }
2763
+ }
2764
+ // Delete the workflow itself
2765
+ this.backend.run('DELETE FROM elements WHERE id = ?', [workflowId]);
2766
+ this.backend.run('DELETE FROM tags WHERE element_id = ?', [workflowId]);
2767
+ this.backend.run('DELETE FROM events WHERE element_id = ?', [workflowId]);
2768
+ return {
2769
+ workflowId,
2770
+ tasksDeleted: taskIds.length,
2771
+ dependenciesDeleted: depsToDelete.length,
2772
+ wasEphemeral,
2773
+ };
2774
+ }
2775
+ async garbageCollectWorkflows(options) {
2776
+ const now = Date.now();
2777
+ const result = {
2778
+ workflowsDeleted: 0,
2779
+ tasksDeleted: 0,
2780
+ dependenciesDeleted: 0,
2781
+ deletedWorkflowIds: [],
2782
+ };
2783
+ // Find all ephemeral workflows in terminal state
2784
+ const workflows = await this.list({ type: 'workflow' });
2785
+ const candidates = [];
2786
+ for (const workflow of workflows) {
2787
+ // Must be ephemeral
2788
+ if (!workflow.ephemeral)
2789
+ continue;
2790
+ // Must be in terminal state
2791
+ const terminalStatuses = ['completed', 'failed', 'cancelled'];
2792
+ if (!terminalStatuses.includes(workflow.status))
2793
+ continue;
2794
+ // Must have finished
2795
+ if (!workflow.finishedAt)
2796
+ continue;
2797
+ // Must be old enough
2798
+ const finishedTime = new Date(workflow.finishedAt).getTime();
2799
+ const age = now - finishedTime;
2800
+ if (age < options.maxAgeMs)
2801
+ continue;
2802
+ candidates.push(workflow);
2803
+ }
2804
+ // Apply limit if specified
2805
+ const toDelete = options.limit ? candidates.slice(0, options.limit) : candidates;
2806
+ // If dry run, just return what would be deleted
2807
+ if (options.dryRun) {
2808
+ // Count what would be deleted
2809
+ const allDeps = await this.getAllDependencies();
2810
+ for (const workflow of toDelete) {
2811
+ result.deletedWorkflowIds.push(workflow.id);
2812
+ result.workflowsDeleted++;
2813
+ // Count tasks
2814
+ for (const dep of allDeps) {
2815
+ if (dep.type === 'parent-child' && dep.blockerId === workflow.id) {
2816
+ result.tasksDeleted++;
2817
+ }
2818
+ }
2819
+ }
2820
+ return result;
2821
+ }
2822
+ // Actually delete
2823
+ for (const workflow of toDelete) {
2824
+ const deleteResult = await this.deleteWorkflow(workflow.id);
2825
+ result.workflowsDeleted++;
2826
+ result.tasksDeleted += deleteResult.tasksDeleted;
2827
+ result.dependenciesDeleted += deleteResult.dependenciesDeleted;
2828
+ result.deletedWorkflowIds.push(workflow.id);
2829
+ }
2830
+ return result;
2831
+ }
2832
+ async garbageCollectTasks(_options) {
2833
+ // Tasks no longer have an ephemeral property - only workflows can be ephemeral.
2834
+ // Tasks belonging to ephemeral workflows are garbage collected via garbageCollectWorkflows().
2835
+ // This method is now a no-op for backwards compatibility.
2836
+ return {
2837
+ tasksDeleted: 0,
2838
+ dependenciesDeleted: 0,
2839
+ deletedTaskIds: [],
2840
+ };
2841
+ }
2842
+ async getTasksInWorkflow(workflowId, filter) {
2843
+ // Verify workflow exists
2844
+ const workflow = await this.get(workflowId);
2845
+ if (!workflow) {
2846
+ throw new NotFoundError(`Workflow not found: ${workflowId}`, ErrorCode.NOT_FOUND, { elementId: workflowId });
2847
+ }
2848
+ if (workflow.type !== 'workflow') {
2849
+ throw new ConstraintError(`Element is not a workflow: ${workflowId}`, ErrorCode.TYPE_MISMATCH, { elementId: workflowId, actualType: workflow.type, expectedType: 'workflow' });
2850
+ }
2851
+ // Get all elements that have parent-child dependency to this workflow
2852
+ const dependents = await this.getDependents(workflowId, ['parent-child']);
2853
+ // If no dependents, return empty array
2854
+ if (dependents.length === 0) {
2855
+ return [];
2856
+ }
2857
+ // Fetch tasks by their IDs
2858
+ const taskIds = dependents.map((d) => d.blockedId);
2859
+ const tasks = [];
2860
+ for (const taskId of taskIds) {
2861
+ const task = await this.get(taskId);
2862
+ if (task && task.type === 'task') {
2863
+ tasks.push(task);
2864
+ }
2865
+ }
2866
+ // Apply filters if provided
2867
+ let filteredTasks = tasks;
2868
+ if (filter?.status) {
2869
+ const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
2870
+ filteredTasks = filteredTasks.filter((t) => statuses.includes(t.status));
2871
+ }
2872
+ if (filter?.priority) {
2873
+ const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
2874
+ filteredTasks = filteredTasks.filter((t) => priorities.includes(t.priority));
2875
+ }
2876
+ if (filter?.assignee) {
2877
+ filteredTasks = filteredTasks.filter((t) => t.assignee === filter.assignee);
2878
+ }
2879
+ if (filter?.owner) {
2880
+ filteredTasks = filteredTasks.filter((t) => t.owner === filter.owner);
2881
+ }
2882
+ if (filter?.tags && filter.tags.length > 0) {
2883
+ filteredTasks = filteredTasks.filter((t) => filter.tags.every((tag) => t.tags.includes(tag)));
2884
+ }
2885
+ if (filter?.includeDeleted !== true) {
2886
+ filteredTasks = filteredTasks.filter((t) => t.status !== 'tombstone');
2887
+ }
2888
+ return filteredTasks;
2889
+ }
2890
+ async getReadyTasksInWorkflow(workflowId, filter) {
2891
+ // Get all tasks in the workflow
2892
+ const tasks = await this.getTasksInWorkflow(workflowId, {
2893
+ ...filter,
2894
+ status: [TaskStatusEnum.OPEN, TaskStatusEnum.IN_PROGRESS],
2895
+ });
2896
+ // Filter out blocked tasks
2897
+ const blockedIds = new Set(this.backend.query('SELECT element_id FROM blocked_cache').map((r) => r.element_id));
2898
+ // Filter out scheduled-for-future tasks
2899
+ const now = new Date();
2900
+ const readyTasks = tasks.filter((task) => {
2901
+ // Not blocked
2902
+ if (blockedIds.has(task.id)) {
2903
+ return false;
2904
+ }
2905
+ // Not scheduled for future
2906
+ if (task.scheduledFor && new Date(task.scheduledFor) > now) {
2907
+ return false;
2908
+ }
2909
+ return true;
2910
+ });
2911
+ // Calculate effective priorities and sort
2912
+ const tasksWithPriority = this.priorityService.enhanceTasksWithEffectivePriority(readyTasks);
2913
+ this.priorityService.sortByEffectivePriority(tasksWithPriority);
2914
+ // Apply limit after sorting
2915
+ if (filter?.limit !== undefined) {
2916
+ return tasksWithPriority.slice(0, filter.limit);
2917
+ }
2918
+ return tasksWithPriority;
2919
+ }
2920
+ async getWorkflowProgress(workflowId) {
2921
+ // Verify workflow exists
2922
+ const workflow = await this.get(workflowId);
2923
+ if (!workflow) {
2924
+ throw new NotFoundError(`Workflow not found: ${workflowId}`, ErrorCode.NOT_FOUND, { elementId: workflowId });
2925
+ }
2926
+ if (workflow.type !== 'workflow') {
2927
+ throw new ConstraintError(`Element is not a workflow: ${workflowId}`, ErrorCode.TYPE_MISMATCH, { elementId: workflowId, actualType: workflow.type, expectedType: 'workflow' });
2928
+ }
2929
+ // Get all tasks in the workflow (excluding tombstones)
2930
+ const tasks = await this.getTasksInWorkflow(workflowId, { includeDeleted: false });
2931
+ // Count tasks by status
2932
+ const statusCounts = {
2933
+ open: 0,
2934
+ in_progress: 0,
2935
+ blocked: 0,
2936
+ closed: 0,
2937
+ deferred: 0,
2938
+ tombstone: 0,
2939
+ };
2940
+ for (const task of tasks) {
2941
+ if (task.status in statusCounts) {
2942
+ statusCounts[task.status]++;
2943
+ }
2944
+ }
2945
+ // Get blocked and ready counts
2946
+ const blockedIds = new Set(this.backend.query('SELECT element_id FROM blocked_cache').map((r) => r.element_id));
2947
+ const taskIds = new Set(tasks.map((t) => t.id));
2948
+ const blockedCount = [...blockedIds].filter((id) => taskIds.has(id)).length;
2949
+ // Ready = open/in_progress, not blocked, not scheduled for future
2950
+ const now = new Date();
2951
+ const readyCount = tasks.filter((task) => {
2952
+ if (task.status !== TaskStatusEnum.OPEN && task.status !== TaskStatusEnum.IN_PROGRESS) {
2953
+ return false;
2954
+ }
2955
+ if (blockedIds.has(task.id)) {
2956
+ return false;
2957
+ }
2958
+ if (task.scheduledFor && new Date(task.scheduledFor) > now) {
2959
+ return false;
2960
+ }
2961
+ return true;
2962
+ }).length;
2963
+ // Calculate completion percentage
2964
+ const total = tasks.length;
2965
+ const completed = statusCounts.closed;
2966
+ const completionPercentage = total > 0 ? Math.round((completed / total) * 100) : 0;
2967
+ return {
2968
+ workflowId,
2969
+ totalTasks: total,
2970
+ statusCounts,
2971
+ completionPercentage,
2972
+ readyTasks: readyCount,
2973
+ blockedTasks: blockedCount,
2974
+ };
2975
+ }
2976
+ /**
2977
+ * Get tasks in a workflow ordered by execution order (topological sort).
2978
+ *
2979
+ * Tasks are ordered such that blockers come before the tasks they block.
2980
+ * This represents the order in which tasks should be executed.
2981
+ *
2982
+ * @param workflowId - The workflow ID
2983
+ * @param filter - Optional filter to apply to tasks
2984
+ * @returns Tasks in execution order (topological sort based on blocks dependencies)
2985
+ */
2986
+ async getOrderedTasksInWorkflow(workflowId, filter) {
2987
+ // Get all tasks in the workflow
2988
+ const tasks = await this.getTasksInWorkflow(workflowId, filter);
2989
+ if (tasks.length === 0) {
2990
+ return [];
2991
+ }
2992
+ // Build task lookup
2993
+ const taskById = new Map();
2994
+ for (const task of tasks) {
2995
+ taskById.set(task.id, task);
2996
+ }
2997
+ // Get blocks dependencies between tasks in this workflow
2998
+ const taskIds = tasks.map((t) => t.id);
2999
+ const taskIdSet = new Set(taskIds);
3000
+ // Query blocks dependencies where both source and target are in this workflow
3001
+ const placeholders = taskIds.map(() => '?').join(', ');
3002
+ const deps = this.backend.query(`SELECT blocker_id, blocked_id FROM dependencies
3003
+ WHERE type = 'blocks'
3004
+ AND blocker_id IN (${placeholders})
3005
+ AND blocked_id IN (${placeholders})`, [...taskIds, ...taskIds]);
3006
+ // Build adjacency list: blockedBy[taskId] = list of tasks that block it
3007
+ const blockedBy = new Map();
3008
+ for (const task of tasks) {
3009
+ blockedBy.set(task.id, []);
3010
+ }
3011
+ for (const dep of deps) {
3012
+ // In blocks dependency: blocked_id = blocked, blocker_id = blocker (blocked waits for blocker)
3013
+ // So blocked_id is blocked by blocker_id
3014
+ if (taskIdSet.has(dep.blocker_id) && taskIdSet.has(dep.blocked_id)) {
3015
+ const current = blockedBy.get(dep.blocked_id) ?? [];
3016
+ current.push(dep.blocker_id);
3017
+ blockedBy.set(dep.blocked_id, current);
3018
+ }
3019
+ }
3020
+ // Kahn's algorithm for topological sort
3021
+ const inDegree = new Map();
3022
+ for (const task of tasks) {
3023
+ inDegree.set(task.id, (blockedBy.get(task.id) ?? []).length);
3024
+ }
3025
+ // Start with tasks that have no blockers
3026
+ const queue = [];
3027
+ for (const task of tasks) {
3028
+ if (inDegree.get(task.id) === 0) {
3029
+ queue.push(task.id);
3030
+ }
3031
+ }
3032
+ // Sort queue by priority for consistent ordering of tasks at same level
3033
+ queue.sort((a, b) => {
3034
+ const taskA = taskById.get(a);
3035
+ const taskB = taskById.get(b);
3036
+ return taskA.priority - taskB.priority;
3037
+ });
3038
+ const result = [];
3039
+ const processed = new Set();
3040
+ while (queue.length > 0) {
3041
+ const taskId = queue.shift();
3042
+ if (processed.has(taskId)) {
3043
+ continue;
3044
+ }
3045
+ processed.add(taskId);
3046
+ const task = taskById.get(taskId);
3047
+ if (task) {
3048
+ result.push(task);
3049
+ }
3050
+ // Find tasks that were blocked by this one (this task is blocker_id = blocker)
3051
+ // and reduce their in-degree
3052
+ for (const dep of deps) {
3053
+ // dep.blocker_id = blocker, dep.blocked_id = blocked (blocked waits for blocker)
3054
+ // If this task is the blocker (blocker_id), the blocked task (blocked_id) can progress
3055
+ if (dep.blocker_id === taskId && !processed.has(dep.blocked_id)) {
3056
+ const newDegree = (inDegree.get(dep.blocked_id) ?? 1) - 1;
3057
+ inDegree.set(dep.blocked_id, newDegree);
3058
+ if (newDegree === 0) {
3059
+ queue.push(dep.blocked_id);
3060
+ // Re-sort queue by priority
3061
+ queue.sort((a, b) => {
3062
+ const taskA = taskById.get(a);
3063
+ const taskB = taskById.get(b);
3064
+ return taskA.priority - taskB.priority;
3065
+ });
3066
+ }
3067
+ }
3068
+ }
3069
+ }
3070
+ // If there are remaining tasks (cycle detected or isolated), append them by priority
3071
+ for (const task of tasks) {
3072
+ if (!processed.has(task.id)) {
3073
+ result.push(task);
3074
+ }
3075
+ }
3076
+ return result;
3077
+ }
3078
+ /**
3079
+ * Get all dependencies from storage
3080
+ */
3081
+ async getAllDependencies() {
3082
+ const rows = this.backend.query('SELECT * FROM dependencies');
3083
+ return rows.map((row) => ({
3084
+ blockedId: row.blocked_id,
3085
+ blockerId: row.blocker_id,
3086
+ type: row.type,
3087
+ createdAt: row.created_at,
3088
+ createdBy: row.created_by,
3089
+ metadata: row.metadata ? JSON.parse(row.metadata) : {},
3090
+ }));
3091
+ }
3092
+ // --------------------------------------------------------------------------
3093
+ // Document Convenience Methods
3094
+ // --------------------------------------------------------------------------
3095
+ async archiveDocument(id) {
3096
+ const doc = await this.get(id);
3097
+ if (!doc || doc.type !== 'document') {
3098
+ throw new NotFoundError(`Document not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
3099
+ }
3100
+ return this.update(id, { status: 'archived' });
3101
+ }
3102
+ async unarchiveDocument(id) {
3103
+ const doc = await this.get(id);
3104
+ if (!doc || doc.type !== 'document') {
3105
+ throw new NotFoundError(`Document not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
3106
+ }
3107
+ return this.update(id, { status: 'active' });
3108
+ }
3109
+ // --------------------------------------------------------------------------
3110
+ // Embedding Service Registration
3111
+ // --------------------------------------------------------------------------
3112
+ registerEmbeddingService(service) {
3113
+ this.embeddingService = service;
3114
+ }
3115
+ // --------------------------------------------------------------------------
3116
+ // FTS Availability Check
3117
+ // --------------------------------------------------------------------------
3118
+ checkFTSAvailable() {
3119
+ try {
3120
+ const row = this.backend.queryOne(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'documents_fts'`);
3121
+ return !!row;
3122
+ }
3123
+ catch {
3124
+ return false;
3125
+ }
3126
+ }
3127
+ // --------------------------------------------------------------------------
3128
+ // FTS Index Maintenance
3129
+ // --------------------------------------------------------------------------
3130
+ /**
3131
+ * Index a document in the FTS5 virtual table for full-text search.
3132
+ * Called after document creation and update.
3133
+ */
3134
+ indexDocumentForFTS(doc) {
3135
+ if (!this.checkFTSAvailable())
3136
+ return;
3137
+ try {
3138
+ const title = doc.title ?? '';
3139
+ // Remove existing entry first (idempotent)
3140
+ this.backend.run(`DELETE FROM documents_fts WHERE document_id = ?`, [doc.id]);
3141
+ // Insert new entry
3142
+ this.backend.run(`INSERT INTO documents_fts (document_id, title, content, tags, category)
3143
+ VALUES (?, ?, ?, ?, ?)`, [
3144
+ doc.id,
3145
+ title,
3146
+ doc.content,
3147
+ doc.tags.join(' '),
3148
+ doc.category,
3149
+ ]);
3150
+ }
3151
+ catch (error) {
3152
+ console.warn(`[stoneforge] FTS index failed for ${doc.id}:`, error);
3153
+ }
3154
+ // Auto-embed if embedding service is registered
3155
+ if (this.embeddingService) {
3156
+ const text = `${doc.title ?? ''} ${doc.content}`.trim();
3157
+ this.embeddingService.indexDocument(doc.id, text).catch((error) => {
3158
+ console.warn(`[stoneforge] Embedding index failed for ${doc.id}:`, error);
3159
+ });
3160
+ }
3161
+ }
3162
+ // --------------------------------------------------------------------------
3163
+ // FTS Reindex
3164
+ // --------------------------------------------------------------------------
3165
+ /**
3166
+ * Reindex all documents in the FTS5 virtual table.
3167
+ * Does NOT create version history entries — safe for bulk reindex.
3168
+ */
3169
+ reindexAllDocumentsFTS() {
3170
+ const docs = this.backend.query(`SELECT * FROM elements WHERE type = 'document' AND deleted_at IS NULL`);
3171
+ let indexed = 0;
3172
+ let errors = 0;
3173
+ for (const row of docs) {
3174
+ try {
3175
+ const data = JSON.parse(row.data);
3176
+ const doc = {
3177
+ id: row.id,
3178
+ type: 'document',
3179
+ createdAt: row.created_at,
3180
+ updatedAt: row.updated_at,
3181
+ createdBy: row.created_by,
3182
+ tags: data.tags ?? [],
3183
+ metadata: data.metadata ?? {},
3184
+ content: data.content ?? '',
3185
+ contentType: data.contentType ?? 'text',
3186
+ version: data.version ?? 1,
3187
+ previousVersionId: data.previousVersionId ?? null,
3188
+ category: data.category ?? 'other',
3189
+ status: data.status ?? 'active',
3190
+ title: data.title,
3191
+ immutable: data.immutable ?? false,
3192
+ };
3193
+ this.indexDocumentForFTS(doc);
3194
+ indexed++;
3195
+ }
3196
+ catch {
3197
+ errors++;
3198
+ }
3199
+ }
3200
+ return { indexed, errors };
3201
+ }
3202
+ /**
3203
+ * Reindex imported documents after sync completes.
3204
+ * Called internally after import operations.
3205
+ */
3206
+ reindexDocumentsAfterImport() {
3207
+ this.reindexAllDocumentsFTS();
3208
+ }
3209
+ // --------------------------------------------------------------------------
3210
+ // FTS5 Full-Text Search
3211
+ // --------------------------------------------------------------------------
3212
+ async searchDocumentsFTS(query, options = {}) {
3213
+ const { category, status, hardCap = 50, elbowSensitivity = 1.5, minResults = 1, } = options;
3214
+ // Check FTS table availability
3215
+ if (!this.checkFTSAvailable()) {
3216
+ throw new StorageError('FTS5 search is unavailable: the documents_fts table does not exist. Run schema migrations to enable full-text search.', ErrorCode.DATABASE_ERROR);
3217
+ }
3218
+ const escaped = escapeFts5Query(query);
3219
+ if (!escaped)
3220
+ return [];
3221
+ try {
3222
+ // Build FTS5 query with BM25 ranking and snippet generation
3223
+ // BM25 returns negative scores (more negative = more relevant)
3224
+ let sql = `
3225
+ SELECT
3226
+ f.document_id,
3227
+ bm25(documents_fts) AS score,
3228
+ snippet(documents_fts, 2, '<mark>', '</mark>', '...', 40) AS snippet
3229
+ FROM documents_fts f
3230
+ JOIN elements e ON f.document_id = e.id
3231
+ WHERE documents_fts MATCH ?
3232
+ AND e.deleted_at IS NULL
3233
+ `;
3234
+ const params = [escaped];
3235
+ // Category filter
3236
+ if (category !== undefined) {
3237
+ const categories = Array.isArray(category) ? category : [category];
3238
+ sql += ` AND f.category IN (${categories.map(() => '?').join(',')})`;
3239
+ params.push(...categories);
3240
+ }
3241
+ // Status filter (default: active only)
3242
+ if (status !== undefined) {
3243
+ const statuses = Array.isArray(status) ? status : [status];
3244
+ sql += ` AND JSON_EXTRACT(e.data, '$.status') IN (${statuses.map(() => '?').join(',')})`;
3245
+ params.push(...statuses);
3246
+ }
3247
+ else {
3248
+ sql += ` AND JSON_EXTRACT(e.data, '$.status') = ?`;
3249
+ params.push('active');
3250
+ }
3251
+ sql += ` ORDER BY score LIMIT ?`;
3252
+ params.push(hardCap);
3253
+ const rows = this.backend.query(sql, params);
3254
+ if (rows.length === 0)
3255
+ return [];
3256
+ // Hydrate documents
3257
+ const results = [];
3258
+ for (const row of rows) {
3259
+ const doc = await this.get(row.document_id);
3260
+ if (doc) {
3261
+ results.push({
3262
+ document: doc,
3263
+ // Negate score so higher = more relevant for adaptive top-K
3264
+ score: -row.score,
3265
+ snippet: row.snippet,
3266
+ });
3267
+ }
3268
+ }
3269
+ // Apply adaptive top-K elbow detection
3270
+ const scored = results.map((r) => ({ item: r, score: r.score }));
3271
+ const filtered = applyAdaptiveTopK(scored, {
3272
+ sensitivity: elbowSensitivity,
3273
+ minResults,
3274
+ maxResults: hardCap,
3275
+ });
3276
+ return filtered.map((f) => f.item);
3277
+ }
3278
+ catch (error) {
3279
+ // Re-throw typed errors (e.g., StorageError from FTS check)
3280
+ if (error instanceof StorageError || error instanceof NotFoundError) {
3281
+ throw error;
3282
+ }
3283
+ // Other errors (e.g., malformed query syntax) — log and return empty
3284
+ console.warn('[stoneforge] FTS search error:', error);
3285
+ return [];
3286
+ }
3287
+ }
3288
+ // --------------------------------------------------------------------------
3289
+ // Sync Operations
3290
+ // --------------------------------------------------------------------------
3291
+ async export(options) {
3292
+ // Use SyncService for export functionality
3293
+ const { elements, dependencies } = this.syncService.exportToString({
3294
+ includeEphemeral: false, // API export excludes ephemeral by default
3295
+ includeDependencies: options?.includeDependencies ?? true,
3296
+ });
3297
+ // Build combined JSONL string
3298
+ let jsonl = elements;
3299
+ if (options?.includeDependencies !== false && dependencies) {
3300
+ jsonl = jsonl + (jsonl && dependencies ? '\n' : '') + dependencies;
3301
+ }
3302
+ if (options?.outputPath) {
3303
+ // Write to file using SyncService's file-based export
3304
+ const result = await this.syncService.export({
3305
+ outputDir: options.outputPath,
3306
+ full: true,
3307
+ includeEphemeral: false,
3308
+ });
3309
+ // Return void for file-based export
3310
+ return;
3311
+ }
3312
+ return jsonl;
3313
+ }
3314
+ async import(options) {
3315
+ // Use SyncService for import functionality
3316
+ let elementsContent = '';
3317
+ let dependenciesContent = '';
3318
+ // Handle input data - either from file path or raw data string
3319
+ if (options.data) {
3320
+ // Parse raw JSONL data - separate elements from dependencies
3321
+ // Elements have `id` and `type`, dependencies have `blockedId` and `blockerId`
3322
+ const lines = options.data.split('\n').filter((line) => line.trim());
3323
+ const elementLines = [];
3324
+ const dependencyLines = [];
3325
+ for (const line of lines) {
3326
+ try {
3327
+ const parsed = JSON.parse(line);
3328
+ if (parsed.blockedId && parsed.blockerId) {
3329
+ // This is a dependency
3330
+ dependencyLines.push(line);
3331
+ }
3332
+ else if (parsed.id) {
3333
+ // This is an element
3334
+ elementLines.push(line);
3335
+ }
3336
+ }
3337
+ catch {
3338
+ // Invalid JSON - add to elements to let SyncService report the error
3339
+ elementLines.push(line);
3340
+ }
3341
+ }
3342
+ elementsContent = elementLines.join('\n');
3343
+ dependenciesContent = dependencyLines.join('\n');
3344
+ }
3345
+ else if (options.inputPath) {
3346
+ // Use file-based import via SyncService
3347
+ const syncResult = await this.syncService.import({
3348
+ inputDir: options.inputPath,
3349
+ dryRun: options.dryRun ?? false,
3350
+ force: options.conflictStrategy === 'overwrite',
3351
+ });
3352
+ // Convert SyncService result to API ImportResult format
3353
+ const apiResult = this.convertSyncImportResult(syncResult, options.dryRun ?? false);
3354
+ if (!options.dryRun) {
3355
+ this.reindexDocumentsAfterImport();
3356
+ }
3357
+ return apiResult;
3358
+ }
3359
+ // For raw data import, use SyncService's string-based import
3360
+ const syncResult = this.syncService.importFromStrings(elementsContent, dependenciesContent, {
3361
+ dryRun: options.dryRun ?? false,
3362
+ force: options.conflictStrategy === 'overwrite',
3363
+ });
3364
+ const apiResult = this.convertSyncImportResult(syncResult, options.dryRun ?? false);
3365
+ if (!options.dryRun) {
3366
+ this.reindexDocumentsAfterImport();
3367
+ }
3368
+ return apiResult;
3369
+ }
3370
+ /**
3371
+ * Convert SyncService ImportResult to API ImportResult format
3372
+ */
3373
+ convertSyncImportResult(syncResult, dryRun) {
3374
+ // Convert conflicts to API format
3375
+ const conflicts = syncResult.conflicts.map((c) => ({
3376
+ elementId: c.elementId,
3377
+ conflictType: 'exists',
3378
+ details: `Resolved via ${c.resolution}`,
3379
+ }));
3380
+ // Convert errors to string format
3381
+ const errors = syncResult.errors.map((e) => `${e.file}:${e.line}: ${e.message}${e.content ? ` (${e.content.substring(0, 50)}...)` : ''}`);
3382
+ return {
3383
+ success: syncResult.errors.length === 0,
3384
+ elementsImported: syncResult.elementsImported,
3385
+ dependenciesImported: syncResult.dependenciesImported,
3386
+ eventsImported: 0, // Events are not imported via sync
3387
+ conflicts,
3388
+ errors,
3389
+ dryRun,
3390
+ };
3391
+ }
3392
+ // --------------------------------------------------------------------------
3393
+ // Team Operations
3394
+ // --------------------------------------------------------------------------
3395
+ async addTeamMember(teamId, entityId, options) {
3396
+ // Get the team
3397
+ const team = await this.get(teamId);
3398
+ if (!team) {
3399
+ throw new NotFoundError(`Team not found: ${teamId}`, ErrorCode.NOT_FOUND, { elementId: teamId });
3400
+ }
3401
+ // Verify it's a team
3402
+ if (team.type !== 'team') {
3403
+ throw new ConstraintError(`Element is not a team: ${teamId}`, ErrorCode.TYPE_MISMATCH, { elementId: teamId, actualType: team.type, expectedType: 'team' });
3404
+ }
3405
+ // Check if team is deleted
3406
+ if (isTeamDeleted(team)) {
3407
+ throw new ConstraintError('Cannot add member to a deleted team', ErrorCode.IMMUTABLE, { teamId, status: team.status });
3408
+ }
3409
+ // Check if entity is already a member
3410
+ if (isTeamMember(team, entityId)) {
3411
+ // Already a member, return success without change
3412
+ return { success: true, team, entityId };
3413
+ }
3414
+ // Add member
3415
+ const newMembers = [...team.members, entityId];
3416
+ const actor = options?.actor ?? team.createdBy;
3417
+ const now = createTimestamp();
3418
+ // Update team and record event in transaction
3419
+ this.backend.transaction((tx) => {
3420
+ // Get current data
3421
+ const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [teamId]);
3422
+ if (!row)
3423
+ return;
3424
+ const data = JSON.parse(row.data);
3425
+ data.members = newMembers;
3426
+ // Recompute content hash
3427
+ const updatedTeam = { ...team, members: newMembers, updatedAt: now };
3428
+ const { hash: contentHash } = computeContentHashSync(updatedTeam);
3429
+ // Update element
3430
+ tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, teamId]);
3431
+ // Record membership event
3432
+ const event = createEvent({
3433
+ elementId: teamId,
3434
+ eventType: MembershipEventType.MEMBER_ADDED,
3435
+ actor,
3436
+ oldValue: { members: team.members },
3437
+ newValue: { members: newMembers, addedMember: entityId },
3438
+ });
3439
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
3440
+ VALUES (?, ?, ?, ?, ?, ?)`, [
3441
+ event.elementId,
3442
+ event.eventType,
3443
+ event.actor,
3444
+ JSON.stringify(event.oldValue),
3445
+ JSON.stringify(event.newValue),
3446
+ event.createdAt,
3447
+ ]);
3448
+ });
3449
+ // Mark as dirty
3450
+ this.backend.markDirty(teamId);
3451
+ // Return updated team
3452
+ const updatedTeam = await this.get(teamId);
3453
+ return {
3454
+ success: true,
3455
+ team: updatedTeam,
3456
+ entityId,
3457
+ };
3458
+ }
3459
+ async removeTeamMember(teamId, entityId, options) {
3460
+ // Get the team
3461
+ const team = await this.get(teamId);
3462
+ if (!team) {
3463
+ throw new NotFoundError(`Team not found: ${teamId}`, ErrorCode.NOT_FOUND, { elementId: teamId });
3464
+ }
3465
+ // Verify it's a team
3466
+ if (team.type !== 'team') {
3467
+ throw new ConstraintError(`Element is not a team: ${teamId}`, ErrorCode.TYPE_MISMATCH, { elementId: teamId, actualType: team.type, expectedType: 'team' });
3468
+ }
3469
+ // Check if team is deleted
3470
+ if (isTeamDeleted(team)) {
3471
+ throw new ConstraintError('Cannot remove member from a deleted team', ErrorCode.IMMUTABLE, { teamId, status: team.status });
3472
+ }
3473
+ // Check if entity is a member
3474
+ if (!isTeamMember(team, entityId)) {
3475
+ throw new ConstraintError(`Entity is not a member of this team`, ErrorCode.MEMBER_REQUIRED, { teamId, entityId });
3476
+ }
3477
+ // Remove member
3478
+ const newMembers = team.members.filter((m) => m !== entityId);
3479
+ const actor = options?.actor ?? team.createdBy;
3480
+ const now = createTimestamp();
3481
+ // Update team and record event in transaction
3482
+ this.backend.transaction((tx) => {
3483
+ // Get current data
3484
+ const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [teamId]);
3485
+ if (!row)
3486
+ return;
3487
+ const data = JSON.parse(row.data);
3488
+ data.members = newMembers;
3489
+ // Recompute content hash
3490
+ const updatedTeam = { ...team, members: newMembers, updatedAt: now };
3491
+ const { hash: contentHash } = computeContentHashSync(updatedTeam);
3492
+ // Update element
3493
+ tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, teamId]);
3494
+ // Record membership event
3495
+ const event = createEvent({
3496
+ elementId: teamId,
3497
+ eventType: MembershipEventType.MEMBER_REMOVED,
3498
+ actor,
3499
+ oldValue: { members: team.members },
3500
+ newValue: {
3501
+ members: newMembers,
3502
+ removedMember: entityId,
3503
+ ...(options?.reason && { reason: options.reason }),
3504
+ },
3505
+ });
3506
+ tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
3507
+ VALUES (?, ?, ?, ?, ?, ?)`, [
3508
+ event.elementId,
3509
+ event.eventType,
3510
+ event.actor,
3511
+ JSON.stringify(event.oldValue),
3512
+ JSON.stringify(event.newValue),
3513
+ event.createdAt,
3514
+ ]);
3515
+ });
3516
+ // Mark as dirty
3517
+ this.backend.markDirty(teamId);
3518
+ // Return updated team
3519
+ const updatedTeam = await this.get(teamId);
3520
+ return {
3521
+ success: true,
3522
+ team: updatedTeam,
3523
+ entityId,
3524
+ };
3525
+ }
3526
+ async getTasksForTeam(teamId, options) {
3527
+ // Get the team
3528
+ const team = await this.get(teamId);
3529
+ if (!team) {
3530
+ throw new NotFoundError(`Team not found: ${teamId}`, ErrorCode.NOT_FOUND, { elementId: teamId });
3531
+ }
3532
+ // Verify it's a team
3533
+ if (team.type !== 'team') {
3534
+ throw new ConstraintError(`Element is not a team: ${teamId}`, ErrorCode.TYPE_MISMATCH, { elementId: teamId, actualType: team.type, expectedType: 'team' });
3535
+ }
3536
+ // Build assignee list: team ID + all member IDs
3537
+ const assignees = [teamId, ...team.members];
3538
+ // Get all tasks and filter by assignee
3539
+ const tasks = await this.list({ type: 'task', ...options });
3540
+ // Filter to tasks assigned to team or any member
3541
+ return tasks.filter((task) => task.assignee && assignees.includes(task.assignee));
3542
+ }
3543
+ async claimTaskFromTeam(taskId, entityId, options) {
3544
+ // Get the task
3545
+ const task = await this.get(taskId);
3546
+ if (!task) {
3547
+ throw new NotFoundError(`Task not found: ${taskId}`, ErrorCode.NOT_FOUND, { elementId: taskId });
3548
+ }
3549
+ // Verify it's a task
3550
+ if (task.type !== 'task') {
3551
+ throw new ConstraintError(`Element is not a task: ${taskId}`, ErrorCode.TYPE_MISMATCH, { elementId: taskId, actualType: task.type, expectedType: 'task' });
3552
+ }
3553
+ // Check if task is assigned to a team
3554
+ if (!task.assignee) {
3555
+ throw new ValidationError('Task has no assignee to claim from', ErrorCode.MISSING_REQUIRED_FIELD, { taskId, field: 'assignee' });
3556
+ }
3557
+ // Get the team to verify the task is team-assigned
3558
+ const team = await this.get(task.assignee);
3559
+ if (!team || team.type !== 'team') {
3560
+ throw new ConstraintError('Task is not assigned to a team', ErrorCode.TYPE_MISMATCH, { taskId, currentAssignee: task.assignee, expectedType: 'team' });
3561
+ }
3562
+ // Check if entity is a member of the team
3563
+ if (!isTeamMember(team, entityId)) {
3564
+ throw new ConstraintError('Entity is not a member of the assigned team', ErrorCode.MEMBER_REQUIRED, { taskId, teamId: team.id, entityId });
3565
+ }
3566
+ // Update task assignee to the claiming entity
3567
+ const actor = options?.actor ?? entityId;
3568
+ const updated = await this.update(taskId, {
3569
+ assignee: entityId,
3570
+ // Optionally preserve the team reference in metadata
3571
+ metadata: {
3572
+ ...task.metadata,
3573
+ claimedFromTeam: team.id,
3574
+ claimedAt: createTimestamp(),
3575
+ },
3576
+ }, { actor });
3577
+ return updated;
3578
+ }
3579
+ async getTeamMetrics(teamId) {
3580
+ // Get the team
3581
+ const team = await this.get(teamId);
3582
+ if (!team) {
3583
+ throw new NotFoundError(`Team not found: ${teamId}`, ErrorCode.NOT_FOUND, { elementId: teamId });
3584
+ }
3585
+ // Verify it's a team
3586
+ if (team.type !== 'team') {
3587
+ throw new ConstraintError(`Element is not a team: ${teamId}`, ErrorCode.TYPE_MISMATCH, { elementId: teamId, actualType: team.type, expectedType: 'team' });
3588
+ }
3589
+ // Get tasks for team
3590
+ const tasks = await this.getTasksForTeam(teamId);
3591
+ // Calculate metrics
3592
+ let tasksCompleted = 0;
3593
+ let tasksInProgress = 0;
3594
+ let tasksAssignedToTeam = 0;
3595
+ let totalCycleTimeMs = 0;
3596
+ let completedWithCycleTime = 0;
3597
+ for (const task of tasks) {
3598
+ if (task.assignee === teamId) {
3599
+ tasksAssignedToTeam++;
3600
+ }
3601
+ if (task.status === TaskStatusEnum.CLOSED) {
3602
+ tasksCompleted++;
3603
+ // Calculate cycle time if closedAt exists
3604
+ if (task.closedAt) {
3605
+ const createdAt = new Date(task.createdAt).getTime();
3606
+ const closedAt = new Date(task.closedAt).getTime();
3607
+ totalCycleTimeMs += closedAt - createdAt;
3608
+ completedWithCycleTime++;
3609
+ }
3610
+ }
3611
+ else if (task.status === TaskStatusEnum.IN_PROGRESS) {
3612
+ tasksInProgress++;
3613
+ }
3614
+ }
3615
+ const averageCycleTimeMs = completedWithCycleTime > 0 ? Math.round(totalCycleTimeMs / completedWithCycleTime) : null;
3616
+ return {
3617
+ teamId,
3618
+ tasksCompleted,
3619
+ tasksInProgress,
3620
+ totalTasks: tasks.length,
3621
+ tasksAssignedToTeam,
3622
+ averageCycleTimeMs,
3623
+ };
3624
+ }
3625
+ // --------------------------------------------------------------------------
3626
+ // Statistics
3627
+ // --------------------------------------------------------------------------
3628
+ async stats() {
3629
+ const now = createTimestamp();
3630
+ // Count elements by type
3631
+ const typeCounts = this.backend.query("SELECT type, COUNT(*) as count FROM elements WHERE deleted_at IS NULL GROUP BY type");
3632
+ const elementsByType = {};
3633
+ let totalElements = 0;
3634
+ for (const row of typeCounts) {
3635
+ elementsByType[row.type] = row.count;
3636
+ totalElements += row.count;
3637
+ }
3638
+ // Count dependencies
3639
+ const depCount = this.backend.queryOne('SELECT COUNT(*) as count FROM dependencies');
3640
+ const totalDependencies = depCount?.count ?? 0;
3641
+ // Count events
3642
+ const eventCount = this.backend.queryOne('SELECT COUNT(*) as count FROM events');
3643
+ const totalEvents = eventCount?.count ?? 0;
3644
+ // Count ready tasks
3645
+ const readyTasks = await this.ready();
3646
+ // Count blocked tasks
3647
+ const blockedCount = this.backend.queryOne('SELECT COUNT(*) as count FROM blocked_cache');
3648
+ const blockedTasks = blockedCount?.count ?? 0;
3649
+ // Get database size
3650
+ const stats = this.backend.getStats();
3651
+ return {
3652
+ totalElements,
3653
+ elementsByType,
3654
+ totalDependencies,
3655
+ totalEvents,
3656
+ readyTasks: readyTasks.length,
3657
+ blockedTasks,
3658
+ databaseSize: stats.fileSize,
3659
+ computedAt: now,
3660
+ };
3661
+ }
3662
+ // --------------------------------------------------------------------------
3663
+ // Batch Fetch Helpers
3664
+ // --------------------------------------------------------------------------
3665
+ /**
3666
+ * Batch fetch tags for multiple elements by their IDs.
3667
+ * Returns a map of element ID to array of tags for efficient lookup.
3668
+ * This eliminates N+1 query issues when fetching tags for multiple elements.
3669
+ */
3670
+ batchFetchTags(elementIds) {
3671
+ if (elementIds.length === 0) {
3672
+ return new Map();
3673
+ }
3674
+ // Deduplicate IDs
3675
+ const uniqueIds = [...new Set(elementIds)];
3676
+ // Build query with placeholders
3677
+ const placeholders = uniqueIds.map(() => '?').join(', ');
3678
+ const sql = `SELECT element_id, tag FROM tags WHERE element_id IN (${placeholders})`;
3679
+ const rows = this.backend.query(sql, uniqueIds);
3680
+ // Group tags by element ID
3681
+ const tagsMap = new Map();
3682
+ for (const id of uniqueIds) {
3683
+ tagsMap.set(id, []);
3684
+ }
3685
+ for (const row of rows) {
3686
+ const tags = tagsMap.get(row.element_id);
3687
+ if (tags) {
3688
+ tags.push(row.tag);
3689
+ }
3690
+ }
3691
+ return tagsMap;
3692
+ }
3693
+ // --------------------------------------------------------------------------
3694
+ // Hydration Helpers
3695
+ // --------------------------------------------------------------------------
3696
+ async hydrateTask(task, options) {
3697
+ const hydrated = { ...task };
3698
+ if (options.description && task.descriptionRef) {
3699
+ const doc = await this.get(task.descriptionRef);
3700
+ if (doc) {
3701
+ hydrated.description = doc.content;
3702
+ }
3703
+ }
3704
+ return hydrated;
3705
+ }
3706
+ /**
3707
+ * Batch fetch documents by their IDs.
3708
+ * Returns a map of document ID to document for efficient lookup.
3709
+ */
3710
+ batchFetchDocuments(documentIds) {
3711
+ if (documentIds.length === 0) {
3712
+ return new Map();
3713
+ }
3714
+ // Deduplicate IDs
3715
+ const uniqueIds = [...new Set(documentIds)];
3716
+ // Build query with placeholders
3717
+ const placeholders = uniqueIds.map(() => '?').join(', ');
3718
+ const sql = `SELECT * FROM elements WHERE id IN (${placeholders}) AND type = 'document'`;
3719
+ const rows = this.backend.query(sql, uniqueIds);
3720
+ // Batch fetch tags for all documents (eliminates N+1 query issue)
3721
+ const elementIds = rows.map((row) => row.id);
3722
+ const tagsMap = this.batchFetchTags(elementIds);
3723
+ // Convert to map
3724
+ const documentMap = new Map();
3725
+ for (const row of rows) {
3726
+ const tags = tagsMap.get(row.id) ?? [];
3727
+ const doc = deserializeElement(row, tags);
3728
+ if (doc)
3729
+ documentMap.set(doc.id, doc);
3730
+ }
3731
+ return documentMap;
3732
+ }
3733
+ /**
3734
+ * Batch hydrate tasks with their document references.
3735
+ * Collects all document IDs, fetches them in a single query, then populates.
3736
+ */
3737
+ hydrateTasks(tasks, options) {
3738
+ if (tasks.length === 0) {
3739
+ return [];
3740
+ }
3741
+ // Collect all document IDs to fetch
3742
+ const documentIds = [];
3743
+ for (const task of tasks) {
3744
+ if (options.description && task.descriptionRef) {
3745
+ documentIds.push(task.descriptionRef);
3746
+ }
3747
+ }
3748
+ // Batch fetch all documents
3749
+ const documentMap = this.batchFetchDocuments(documentIds);
3750
+ // Hydrate each task
3751
+ const hydrated = tasks.map((task) => {
3752
+ const result = { ...task };
3753
+ if (options.description && task.descriptionRef) {
3754
+ const doc = documentMap.get(task.descriptionRef);
3755
+ if (doc) {
3756
+ result.description = doc.content;
3757
+ }
3758
+ }
3759
+ return result;
3760
+ });
3761
+ return hydrated;
3762
+ }
3763
+ /**
3764
+ * Hydrate a single message with its document references.
3765
+ * Resolves contentRef -> content and attachments -> attachmentContents.
3766
+ */
3767
+ async hydrateMessage(message, options) {
3768
+ const hydrated = { ...message };
3769
+ if (options.content && message.contentRef) {
3770
+ const doc = await this.get(message.contentRef);
3771
+ if (doc) {
3772
+ hydrated.content = doc.content;
3773
+ }
3774
+ }
3775
+ if (options.attachments && message.attachments && message.attachments.length > 0) {
3776
+ const attachmentContents = [];
3777
+ for (const attachmentId of message.attachments) {
3778
+ const doc = await this.get(attachmentId);
3779
+ if (doc) {
3780
+ attachmentContents.push(doc.content);
3781
+ }
3782
+ }
3783
+ hydrated.attachmentContents = attachmentContents;
3784
+ }
3785
+ return hydrated;
3786
+ }
3787
+ /**
3788
+ * Batch hydrate messages with their document references.
3789
+ * Collects all document IDs, fetches them in a single query, then populates.
3790
+ */
3791
+ hydrateMessages(messages, options) {
3792
+ if (messages.length === 0) {
3793
+ return [];
3794
+ }
3795
+ // Collect all document IDs to fetch
3796
+ const documentIds = [];
3797
+ for (const message of messages) {
3798
+ if (options.content && message.contentRef) {
3799
+ documentIds.push(message.contentRef);
3800
+ }
3801
+ if (options.attachments && message.attachments) {
3802
+ for (const attachmentId of message.attachments) {
3803
+ documentIds.push(attachmentId);
3804
+ }
3805
+ }
3806
+ }
3807
+ // Batch fetch all documents
3808
+ const documentMap = this.batchFetchDocuments(documentIds);
3809
+ // Hydrate each message
3810
+ const hydrated = messages.map((message) => {
3811
+ const result = { ...message };
3812
+ if (options.content && message.contentRef) {
3813
+ const doc = documentMap.get(message.contentRef);
3814
+ if (doc) {
3815
+ result.content = doc.content;
3816
+ }
3817
+ }
3818
+ if (options.attachments && message.attachments && message.attachments.length > 0) {
3819
+ const attachmentContents = [];
3820
+ for (const attachmentId of message.attachments) {
3821
+ const doc = documentMap.get(attachmentId);
3822
+ if (doc) {
3823
+ attachmentContents.push(doc.content);
3824
+ }
3825
+ }
3826
+ result.attachmentContents = attachmentContents;
3827
+ }
3828
+ return result;
3829
+ });
3830
+ return hydrated;
3831
+ }
3832
+ /**
3833
+ * Hydrate a single library with its document references.
3834
+ * Resolves descriptionRef -> description.
3835
+ */
3836
+ async hydrateLibrary(library, options) {
3837
+ const hydrated = { ...library };
3838
+ if (options.description && library.descriptionRef) {
3839
+ const doc = await this.get(library.descriptionRef);
3840
+ if (doc) {
3841
+ hydrated.description = doc.content;
3842
+ }
3843
+ }
3844
+ return hydrated;
3845
+ }
3846
+ /**
3847
+ * Batch hydrate libraries with their document references.
3848
+ * Collects all document IDs, fetches them in a single query, then populates.
3849
+ */
3850
+ hydrateLibraries(libraries, options) {
3851
+ if (libraries.length === 0) {
3852
+ return [];
3853
+ }
3854
+ // Collect all document IDs to fetch
3855
+ const documentIds = [];
3856
+ for (const library of libraries) {
3857
+ if (options.description && library.descriptionRef) {
3858
+ documentIds.push(library.descriptionRef);
3859
+ }
3860
+ }
3861
+ // Batch fetch all documents
3862
+ const documentMap = this.batchFetchDocuments(documentIds);
3863
+ // Hydrate each library
3864
+ const hydrated = libraries.map((library) => {
3865
+ const result = { ...library };
3866
+ if (options.description && library.descriptionRef) {
3867
+ const doc = documentMap.get(library.descriptionRef);
3868
+ if (doc) {
3869
+ result.description = doc.content;
3870
+ }
3871
+ }
3872
+ return result;
3873
+ });
3874
+ return hydrated;
3875
+ }
3876
+ // --------------------------------------------------------------------------
3877
+ // Cache Management (Internal)
3878
+ // --------------------------------------------------------------------------
3879
+ /**
3880
+ * Rebuild the blocked cache from scratch.
3881
+ *
3882
+ * Use this for:
3883
+ * - Initial population after migration
3884
+ * - Recovery from cache corruption
3885
+ * - Periodic consistency checks
3886
+ *
3887
+ * @returns Statistics about the rebuild
3888
+ */
3889
+ rebuildBlockedCache() {
3890
+ return this.blockedCache.rebuild();
3891
+ }
3892
+ }
3893
+ // ============================================================================
3894
+ // Factory Function
3895
+ // ============================================================================
3896
+ /**
3897
+ * Create a new QuarryAPI instance
3898
+ *
3899
+ * @param backend - The storage backend to use
3900
+ * @returns A new QuarryAPI instance
3901
+ */
3902
+ export function createQuarryAPI(backend) {
3903
+ return new QuarryAPIImpl(backend);
3904
+ }
3905
+ //# sourceMappingURL=quarry-api.js.map