@lota-sdk/core 0.4.8 → 0.4.10

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 (272) hide show
  1. package/package.json +11 -12
  2. package/src/ai/embedding-cache.ts +96 -22
  3. package/src/ai-gateway/ai-gateway.ts +766 -223
  4. package/src/config/agent-defaults.ts +189 -75
  5. package/src/config/agent-types.ts +54 -4
  6. package/src/config/background-processing.ts +1 -1
  7. package/src/config/constants.ts +8 -2
  8. package/src/config/index.ts +0 -1
  9. package/src/config/logger.ts +299 -19
  10. package/src/config/thread-defaults.ts +40 -20
  11. package/src/create-runtime.ts +200 -449
  12. package/src/db/base.service.ts +52 -28
  13. package/src/db/cursor-pagination.ts +71 -30
  14. package/src/db/memory-query-builder.ts +2 -1
  15. package/src/db/memory-store.helpers.ts +4 -7
  16. package/src/db/memory-store.ts +868 -601
  17. package/src/db/memory.ts +396 -280
  18. package/src/db/record-id.ts +32 -10
  19. package/src/db/schema-fingerprint.ts +30 -12
  20. package/src/db/service-normalization.ts +288 -0
  21. package/src/db/service.ts +912 -779
  22. package/src/db/startup.ts +153 -68
  23. package/src/db/transaction-conflict.ts +15 -0
  24. package/src/effect/awaitable-effect.ts +96 -0
  25. package/src/effect/errors.ts +121 -0
  26. package/src/effect/helpers.ts +123 -0
  27. package/src/effect/index.ts +24 -0
  28. package/src/effect/layers.ts +238 -0
  29. package/src/effect/runtime-ref.ts +25 -0
  30. package/src/effect/runtime.ts +46 -0
  31. package/src/effect/services.ts +61 -0
  32. package/src/effect/zod.ts +43 -0
  33. package/src/embeddings/provider.ts +128 -83
  34. package/src/index.ts +48 -1
  35. package/src/openrouter/direct-provider.ts +11 -35
  36. package/src/queues/autonomous-job.queue.ts +117 -73
  37. package/src/queues/context-compaction.queue.ts +50 -17
  38. package/src/queues/delayed-node-promotion.queue.ts +46 -17
  39. package/src/queues/document-processor.queue.ts +52 -77
  40. package/src/queues/memory-consolidation.queue.ts +47 -32
  41. package/src/queues/organization-learning.queue.ts +26 -4
  42. package/src/queues/plan-agent-heartbeat.queue.ts +71 -24
  43. package/src/queues/plan-scheduler.queue.ts +97 -33
  44. package/src/queues/post-chat-memory.queue.ts +56 -26
  45. package/src/queues/queue-factory.ts +227 -59
  46. package/src/queues/standalone-worker.ts +39 -0
  47. package/src/queues/title-generation.queue.ts +45 -11
  48. package/src/redis/connection.ts +182 -113
  49. package/src/redis/index.ts +6 -8
  50. package/src/redis/org-memory-lock.ts +60 -27
  51. package/src/redis/redis-lease-lock.ts +200 -121
  52. package/src/redis/runtime-connection.ts +20 -0
  53. package/src/redis/stream-context.ts +92 -46
  54. package/src/runtime/agent-identity-overrides.ts +2 -2
  55. package/src/runtime/agent-runtime-policy.ts +5 -2
  56. package/src/runtime/agent-stream-helpers.ts +24 -9
  57. package/src/runtime/chat-run-orchestration.ts +102 -19
  58. package/src/runtime/chat-run-registry.ts +36 -2
  59. package/src/runtime/context-compaction/context-compaction-runtime.ts +107 -0
  60. package/src/runtime/{context-compaction.ts → context-compaction/context-compaction.ts} +161 -94
  61. package/src/runtime/domain-layer.ts +192 -0
  62. package/src/runtime/execution-plan-visibility.ts +2 -2
  63. package/src/runtime/execution-plan.ts +42 -15
  64. package/src/runtime/graph-designer.ts +16 -4
  65. package/src/runtime/helper-model.ts +139 -48
  66. package/src/runtime/index.ts +7 -8
  67. package/src/runtime/indexed-repositories-policy.ts +3 -3
  68. package/src/runtime/{memory-block.ts → memory/memory-block.ts} +50 -36
  69. package/src/runtime/{memory-digest-policy.ts → memory/memory-digest-policy.ts} +1 -1
  70. package/src/runtime/{memory-pipeline.ts → memory/memory-pipeline.ts} +54 -67
  71. package/src/runtime/{memory-prompts-fact.ts → memory/memory-prompts-fact.ts} +2 -2
  72. package/src/runtime/memory/memory-scope.ts +53 -0
  73. package/src/runtime/plugin-resolution.ts +124 -25
  74. package/src/runtime/plugin-types.ts +9 -1
  75. package/src/runtime/post-turn-side-effects.ts +177 -130
  76. package/src/runtime/retrieval-adapters.ts +40 -6
  77. package/src/runtime/runtime-accessors.ts +92 -0
  78. package/src/runtime/runtime-config.ts +150 -61
  79. package/src/runtime/runtime-extensions.ts +23 -25
  80. package/src/runtime/runtime-lifecycle.ts +124 -0
  81. package/src/runtime/runtime-services.ts +386 -0
  82. package/src/runtime/runtime-token.ts +47 -0
  83. package/src/runtime/social-chat/social-chat-agent-runner.ts +159 -0
  84. package/src/runtime/{social-chat-history.ts → social-chat/social-chat-history.ts} +51 -20
  85. package/src/runtime/social-chat/social-chat.ts +630 -0
  86. package/src/runtime/specialist-runner.ts +36 -10
  87. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +433 -0
  88. package/src/runtime/{team-consultation-prompts.ts → team-consultation/team-consultation-prompts.ts} +6 -2
  89. package/src/runtime/thread-chat-helpers.ts +2 -2
  90. package/src/runtime/thread-plan-turn.ts +2 -1
  91. package/src/runtime/thread-turn-context.ts +183 -111
  92. package/src/runtime/turn-lifecycle.ts +93 -27
  93. package/src/services/agent-activity.service.ts +287 -203
  94. package/src/services/agent-executor.service.ts +253 -149
  95. package/src/services/artifact.service.ts +231 -149
  96. package/src/services/attachment.service.ts +171 -115
  97. package/src/services/autonomous-job.service.ts +890 -491
  98. package/src/services/background-work.service.ts +54 -0
  99. package/src/services/chat-run-registry.service.ts +13 -1
  100. package/src/services/context-compaction.service.ts +136 -86
  101. package/src/services/document-chunk.service.ts +151 -88
  102. package/src/services/execution-plan/execution-plan-approval.ts +26 -0
  103. package/src/services/execution-plan/execution-plan-context.ts +29 -0
  104. package/src/services/execution-plan/execution-plan-graph.ts +278 -0
  105. package/src/services/execution-plan/execution-plan-schedule.ts +84 -0
  106. package/src/services/execution-plan/execution-plan-spec.ts +75 -0
  107. package/src/services/execution-plan/execution-plan.service.ts +1041 -0
  108. package/src/services/feedback-loop.service.ts +132 -76
  109. package/src/services/global-orchestrator.service.ts +101 -168
  110. package/src/services/graph-full-routing.ts +193 -0
  111. package/src/services/index.ts +19 -21
  112. package/src/services/institutional-memory.service.ts +213 -125
  113. package/src/services/learned-skill.service.ts +368 -260
  114. package/src/services/memory/memory-conversation.ts +95 -0
  115. package/src/services/memory/memory-errors.ts +27 -0
  116. package/src/services/memory/memory-org-memory.ts +50 -0
  117. package/src/services/memory/memory-preseeded.ts +86 -0
  118. package/src/services/memory/memory-rerank.ts +297 -0
  119. package/src/services/{memory-utils.ts → memory/memory-utils.ts} +6 -5
  120. package/src/services/memory/memory.service.ts +674 -0
  121. package/src/services/memory/rerank.service.ts +201 -0
  122. package/src/services/monitoring-window.service.ts +92 -70
  123. package/src/services/mutating-approval.service.ts +62 -53
  124. package/src/services/node-workspace.service.ts +141 -98
  125. package/src/services/notification.service.ts +29 -16
  126. package/src/services/organization-member.service.ts +120 -66
  127. package/src/services/organization.service.ts +153 -77
  128. package/src/services/ownership-dispatcher.service.ts +456 -263
  129. package/src/services/plan/plan-agent-heartbeat.service.ts +234 -0
  130. package/src/services/plan/plan-agent-query.service.ts +322 -0
  131. package/src/services/{plan-approval.service.ts → plan/plan-approval.service.ts} +45 -22
  132. package/src/services/plan/plan-artifact.service.ts +60 -0
  133. package/src/services/plan/plan-builder.service.ts +76 -0
  134. package/src/services/plan/plan-checkpoint.service.ts +103 -0
  135. package/src/services/{plan-compiler.service.ts → plan/plan-compiler.service.ts} +26 -9
  136. package/src/services/plan/plan-completion-side-effects.ts +169 -0
  137. package/src/services/plan/plan-coordination.service.ts +181 -0
  138. package/src/services/plan/plan-cycle.service.ts +405 -0
  139. package/src/services/plan/plan-deadline.service.ts +533 -0
  140. package/src/services/plan/plan-event-delivery.service.ts +266 -0
  141. package/src/services/plan/plan-executor-context.ts +35 -0
  142. package/src/services/plan/plan-executor-graph.ts +522 -0
  143. package/src/services/plan/plan-executor-helpers.ts +307 -0
  144. package/src/services/plan/plan-executor-persistence.ts +209 -0
  145. package/src/services/plan/plan-executor.service.ts +1737 -0
  146. package/src/services/{plan-helpers.ts → plan/plan-helpers.ts} +1 -1
  147. package/src/services/{plan-run-data.ts → plan/plan-run-data.ts} +4 -4
  148. package/src/services/plan/plan-run-serialization.ts +15 -0
  149. package/src/services/plan/plan-run.service.ts +637 -0
  150. package/src/services/plan/plan-scheduler.service.ts +379 -0
  151. package/src/services/plan/plan-template.service.ts +224 -0
  152. package/src/services/plan/plan-transaction-events.ts +36 -0
  153. package/src/services/plan/plan-validator.service.ts +907 -0
  154. package/src/services/plan/plan-workspace.service.ts +131 -0
  155. package/src/services/plugin-executor.service.ts +102 -68
  156. package/src/services/quality-metrics.service.ts +112 -94
  157. package/src/services/queue-job.service.ts +288 -231
  158. package/src/services/recent-activity-title.service.ts +73 -36
  159. package/src/services/recent-activity.service.ts +274 -259
  160. package/src/services/skill-resolver.service.ts +38 -12
  161. package/src/services/social-chat-history.service.ts +190 -122
  162. package/src/services/system-executor.service.ts +96 -61
  163. package/src/services/thread/thread-active-run.ts +203 -0
  164. package/src/services/thread/thread-bootstrap.ts +385 -0
  165. package/src/services/thread/thread-listing.ts +199 -0
  166. package/src/services/thread/thread-memory-block.ts +130 -0
  167. package/src/services/thread/thread-message.service.ts +379 -0
  168. package/src/services/thread/thread-record-store.ts +155 -0
  169. package/src/services/thread/thread-title.service.ts +74 -0
  170. package/src/services/thread/thread-turn-execution.ts +280 -0
  171. package/src/services/thread/thread-turn-message-context.ts +73 -0
  172. package/src/services/thread/thread-turn-preparation.service.ts +1148 -0
  173. package/src/services/thread/thread-turn-streaming.ts +403 -0
  174. package/src/services/thread/thread-turn-tracing.ts +35 -0
  175. package/src/services/thread/thread-turn.ts +376 -0
  176. package/src/services/thread/thread.service.ts +344 -0
  177. package/src/services/user.service.ts +82 -32
  178. package/src/services/write-intent-validator.service.ts +63 -51
  179. package/src/storage/attachment-parser.ts +69 -27
  180. package/src/storage/attachment-storage.service.ts +334 -275
  181. package/src/storage/generated-document-storage.service.ts +66 -34
  182. package/src/system-agents/agent-result.ts +3 -1
  183. package/src/system-agents/context-compaction.agent.ts +3 -3
  184. package/src/system-agents/delegated-agent-factory.ts +159 -90
  185. package/src/system-agents/helper-agent-options.ts +1 -1
  186. package/src/system-agents/memory-reranker.agent.ts +3 -3
  187. package/src/system-agents/memory.agent.ts +3 -3
  188. package/src/system-agents/recent-activity-title-refiner.agent.ts +3 -3
  189. package/src/system-agents/regular-chat-memory-digest.agent.ts +3 -3
  190. package/src/system-agents/skill-extractor.agent.ts +3 -3
  191. package/src/system-agents/skill-manager.agent.ts +3 -3
  192. package/src/system-agents/thread-router.agent.ts +157 -113
  193. package/src/system-agents/title-generator.agent.ts +3 -3
  194. package/src/tools/execution-plan.tool.ts +241 -171
  195. package/src/tools/fetch-webpage.tool.ts +29 -18
  196. package/src/tools/firecrawl-client.ts +26 -6
  197. package/src/tools/index.ts +1 -0
  198. package/src/tools/memory-block.tool.ts +14 -6
  199. package/src/tools/plan-approval.tool.ts +57 -47
  200. package/src/tools/read-file-parts.tool.ts +44 -33
  201. package/src/tools/remember-memory.tool.ts +65 -45
  202. package/src/tools/search-web.tool.ts +33 -22
  203. package/src/tools/search.tool.ts +41 -29
  204. package/src/tools/team-think.tool.ts +125 -84
  205. package/src/tools/user-questions.tool.ts +4 -3
  206. package/src/tools/web-tool-shared.ts +6 -0
  207. package/src/utils/async.ts +25 -22
  208. package/src/utils/crypto.ts +21 -0
  209. package/src/utils/date-time.ts +40 -1
  210. package/src/utils/errors.ts +111 -20
  211. package/src/utils/hono-error-handler.ts +24 -39
  212. package/src/utils/index.ts +2 -1
  213. package/src/utils/null-proto-record.ts +41 -0
  214. package/src/utils/sse-keepalive.ts +124 -21
  215. package/src/workers/bootstrap.ts +164 -52
  216. package/src/workers/memory-consolidation.worker.ts +325 -237
  217. package/src/workers/organization-learning.worker.ts +50 -16
  218. package/src/workers/regular-chat-memory-digest.helpers.ts +28 -27
  219. package/src/workers/regular-chat-memory-digest.runner.ts +185 -114
  220. package/src/workers/skill-extraction.runner.ts +176 -93
  221. package/src/workers/utils/file-section-chunker.ts +8 -10
  222. package/src/workers/utils/repo-structure-extractor.ts +349 -260
  223. package/src/workers/utils/repomix-file-sections.ts +2 -2
  224. package/src/workers/utils/thread-message-query.ts +97 -38
  225. package/src/workers/worker-utils.ts +74 -31
  226. package/src/config/debug-logger.ts +0 -47
  227. package/src/config/search.ts +0 -3
  228. package/src/redis/connection-accessor.ts +0 -26
  229. package/src/runtime/agent-types.ts +0 -1
  230. package/src/runtime/context-compaction-runtime.ts +0 -87
  231. package/src/runtime/memory-scope.ts +0 -43
  232. package/src/runtime/social-chat-agent-runner.ts +0 -118
  233. package/src/runtime/social-chat.ts +0 -516
  234. package/src/runtime/team-consultation-orchestrator.ts +0 -272
  235. package/src/services/adaptive-playbook.service.ts +0 -152
  236. package/src/services/artifact-provenance.service.ts +0 -172
  237. package/src/services/chat-attachments.service.ts +0 -17
  238. package/src/services/context-compaction-runtime.singleton.ts +0 -13
  239. package/src/services/execution-plan.service.ts +0 -1118
  240. package/src/services/memory.service.ts +0 -914
  241. package/src/services/plan-agent-heartbeat.service.ts +0 -136
  242. package/src/services/plan-agent-query.service.ts +0 -267
  243. package/src/services/plan-artifact.service.ts +0 -50
  244. package/src/services/plan-builder.service.ts +0 -67
  245. package/src/services/plan-checkpoint.service.ts +0 -81
  246. package/src/services/plan-completion-side-effects.ts +0 -80
  247. package/src/services/plan-coordination.service.ts +0 -157
  248. package/src/services/plan-cycle.service.ts +0 -284
  249. package/src/services/plan-deadline.service.ts +0 -430
  250. package/src/services/plan-event-delivery.service.ts +0 -166
  251. package/src/services/plan-executor.service.ts +0 -1950
  252. package/src/services/plan-run.service.ts +0 -515
  253. package/src/services/plan-scheduler.service.ts +0 -240
  254. package/src/services/plan-template.service.ts +0 -177
  255. package/src/services/plan-validator.service.ts +0 -818
  256. package/src/services/plan-workspace.service.ts +0 -83
  257. package/src/services/rerank.service.ts +0 -156
  258. package/src/services/thread-message.service.ts +0 -275
  259. package/src/services/thread-plan-registry.service.ts +0 -22
  260. package/src/services/thread-title.service.ts +0 -39
  261. package/src/services/thread-turn-preparation.service.ts +0 -1147
  262. package/src/services/thread-turn.ts +0 -172
  263. package/src/services/thread.service.ts +0 -869
  264. package/src/utils/env.ts +0 -8
  265. /package/src/runtime/{context-compaction-constants.ts → context-compaction/context-compaction-constants.ts} +0 -0
  266. /package/src/runtime/{memory-format.ts → memory/memory-format.ts} +0 -0
  267. /package/src/runtime/{memory-prompts-parse.ts → memory/memory-prompts-parse.ts} +0 -0
  268. /package/src/runtime/{memory-prompts-update.ts → memory/memory-prompts-update.ts} +0 -0
  269. /package/src/runtime/{social-chat-prompts.ts → social-chat/social-chat-prompts.ts} +0 -0
  270. /package/src/services/{plan-node-spec.ts → plan/plan-node-spec.ts} +0 -0
  271. /package/src/services/{thread-constants.ts → thread/thread-constants.ts} +0 -0
  272. /package/src/services/{thread.types.ts → thread/thread.types.ts} +0 -0
package/src/db/service.ts CHANGED
@@ -1,34 +1,35 @@
1
- import {
2
- BoundQuery,
3
- RecordId,
4
- ServerError,
5
- StringRecordId,
6
- Surreal,
7
- Table,
8
- and,
9
- createRemoteEngines,
10
- eq,
11
- } from 'surrealdb'
12
- import type { ExprLike, Mutation, SurrealTransaction, Values } from 'surrealdb'
1
+ import { Duration, Effect, Schedule, Semaphore } from 'effect'
2
+ import { BoundQuery, ServerError, Surreal, Table, createRemoteEngines } from 'surrealdb'
3
+ import type { ExprLike, SurrealTransaction, Values } from 'surrealdb'
4
+ import { ZodError } from 'zod'
13
5
  import type { z } from 'zod'
14
6
 
15
7
  import { serverLogger } from '../config/logger'
16
- import { withTimeout } from '../utils/async'
17
- import { isRecord } from '../utils/string'
18
- import type { RecordIdInput } from './record-id'
19
- import { ensureRecordId, isSurrealRecordIdValue } from './record-id'
8
+ import type { AwaitableEffect } from '../effect/awaitable-effect'
9
+ import { toAwaitableEffect } from '../effect/awaitable-effect'
10
+ import { getErrorMessage } from '../utils/errors'
11
+ import type { RecordIdInput, ensureRecordId } from './record-id'
12
+ import { ensureRecordIdEffect } from './record-id'
13
+ import {
14
+ assertValidIdentifier,
15
+ buildBoundFilterClauses,
16
+ buildFilterExpression,
17
+ configureMutation,
18
+ describeInvalidValue,
19
+ normalizeBoundQuery,
20
+ normalizeCreateTarget,
21
+ normalizeMutationData,
22
+ normalizeQueryRows,
23
+ normalizeRecordIdForTable,
24
+ normalizeSurrealValue,
25
+ normalizeTableValue,
26
+ normalizeTransactionQuery,
27
+ normalizeTransactionRecordId,
28
+ SurrealDBError,
29
+ } from './service-normalization'
30
+ import type { RecordMutation } from './service-normalization'
20
31
  import type { DatabaseTable } from './tables'
21
-
22
- export class SurrealDBError extends Error {
23
- constructor(
24
- message: string,
25
- public readonly query?: string,
26
- options?: ErrorOptions,
27
- ) {
28
- super(message, options)
29
- this.name = 'SurrealDBError'
30
- }
31
- }
32
+ import { isRetriableTransactionConflict } from './transaction-conflict'
32
33
 
33
34
  export interface SurrealDatabaseConfig {
34
35
  url: string
@@ -51,75 +52,97 @@ interface FindManyOptions {
51
52
  orderDir?: 'ASC' | 'DESC'
52
53
  }
53
54
 
54
- type MutationBuilder = {
55
+ type MutationBuilderSource = {
56
+ content: (data: Record<string, unknown>) => MutationBuilderSource
57
+ replace: (data: Record<string, unknown>) => MutationBuilderSource
58
+ merge: (data: Record<string, unknown>) => MutationBuilderSource
59
+ output: (mode: 'after' | 'before') => unknown
60
+ }
61
+
62
+ export type MutationBuilder = {
55
63
  content: (data: Record<string, unknown>) => MutationBuilder
56
64
  replace: (data: Record<string, unknown>) => MutationBuilder
57
65
  merge: (data: Record<string, unknown>) => MutationBuilder
58
- output: (mode: 'after' | 'before') => Promise<unknown>
66
+ output: (mode: 'after' | 'before') => AwaitableEffect<unknown, SurrealDBError>
59
67
  }
60
68
 
61
- type RecordMutation = Extract<Mutation, 'content' | 'replace' | 'merge'>
69
+ type CreateBuilderSource = {
70
+ content: (data: Record<string, unknown>) => CreateBuilderSource
71
+ output: (mode: 'after' | 'before') => unknown
72
+ }
73
+
74
+ type PendingMutation = { mutation: RecordMutation; data: Record<string, unknown> }
62
75
 
63
76
  export type CreateMutationBuilder = {
64
77
  content: (data: Record<string, unknown>) => CreateMutationBuilder
65
- output: (mode: 'after' | 'before') => Promise<unknown>
78
+ output: (mode: 'after' | 'before') => AwaitableEffect<unknown, SurrealDBError>
66
79
  }
67
80
 
68
81
  export interface DatabaseTransaction {
69
- query: (query: unknown) => Promise<unknown>
82
+ query: (query: unknown) => AwaitableEffect<unknown, SurrealDBError>
70
83
  create: (target: unknown) => CreateMutationBuilder
71
84
  update: (target: unknown) => MutationBuilder
72
- delete: (target: unknown) => Promise<unknown>
73
- relate: (from: unknown, edgeTable: unknown, to: unknown, data?: Values<Record<string, unknown>>) => Promise<unknown>
74
- commit: () => Promise<void>
75
- cancel: () => Promise<void>
76
- }
77
-
78
- function configureMutation(
79
- builder: MutationBuilder,
80
- mutation: RecordMutation,
81
- data: Record<string, unknown>,
82
- ): MutationBuilder {
83
- if (mutation === 'content') {
84
- return builder.content(data)
85
- }
86
- if (mutation === 'replace') {
87
- return builder.replace(data)
88
- }
89
- return builder.merge(data)
85
+ delete: (target: unknown) => AwaitableEffect<unknown, SurrealDBError>
86
+ relate: (
87
+ from: unknown,
88
+ edgeTable: unknown,
89
+ to: unknown,
90
+ data?: Values<Record<string, unknown>>,
91
+ ) => AwaitableEffect<unknown, SurrealDBError>
92
+ commit: () => AwaitableEffect<void, SurrealDBError>
93
+ cancel: () => AwaitableEffect<void, SurrealDBError>
90
94
  }
91
95
 
92
96
  const CONNECT_MAX_ATTEMPTS = 5
93
97
  const CONNECT_RETRY_BASE_DELAY_MS = 100
94
- const CONNECT_RETRY_JITTER_MS = 50
95
98
  const CONNECT_ATTEMPT_TIMEOUT_MS = 5_000
96
99
 
100
+ function isRetriableConnectError(error: unknown): boolean {
101
+ if (isRetriableTransactionConflict(error)) {
102
+ return true
103
+ }
104
+
105
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
106
+ return /(timed out connecting to SurrealDB|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|network|socket|connection)/i.test(
107
+ message,
108
+ )
109
+ }
110
+
111
+ function isSurrealDBError(error: unknown): error is SurrealDBError {
112
+ return typeof error === 'object' && error !== null && (error as { _tag?: unknown })._tag === 'SurrealDBError'
113
+ }
114
+
97
115
  export class SurrealDBService {
98
116
  private client: Surreal | null = null
99
117
  private isConnected = false
100
- private connectPromise: Promise<void> | null = null
118
+ // Single-permit semaphore acts as a mutex so only one connect attempt runs at a time.
119
+ // Subsequent waiters re-check `isConnected` after acquiring the permit and become no-ops.
120
+ private readonly connectMutex = Semaphore.makeUnsafe(1)
101
121
 
102
122
  constructor(
103
123
  private readonly config: SurrealDatabaseConfig,
104
124
  private readonly logger?: SurrealDatabaseLogger,
105
125
  ) {}
106
126
 
107
- private toSurrealError(error: unknown, query?: string): never {
108
- if (error instanceof SurrealDBError) {
109
- throw error
127
+ private toSurrealError(error: unknown, query?: string): SurrealDBError {
128
+ if (isSurrealDBError(error)) {
129
+ return error
130
+ }
131
+
132
+ if (error instanceof ZodError) {
133
+ return new SurrealDBError({ message: error.message, query: query, cause: error })
110
134
  }
111
135
 
112
136
  if (error instanceof ServerError) {
113
- throw new SurrealDBError(`${error.name}: ${error.message}`, query, { cause: error })
137
+ return new SurrealDBError({ message: `${error.name}: ${error.message}`, query: query, cause: error })
114
138
  }
115
139
 
116
140
  if (error instanceof Error) {
117
- throw new SurrealDBError(error.message, query, { cause: error })
141
+ return new SurrealDBError({ message: error.message, query: query, cause: error })
118
142
  }
119
143
 
120
- throw new SurrealDBError(String(error), query)
144
+ return new SurrealDBError({ message: String(error), query })
121
145
  }
122
-
123
146
  private isEmbeddedEngine(url: string) {
124
147
  return (
125
148
  url === 'mem://' ||
@@ -130,433 +153,471 @@ export class SurrealDBService {
130
153
  )
131
154
  }
132
155
 
133
- private async getOrCreateClient(): Promise<Surreal> {
156
+ private getOrCreateClient(): Effect.Effect<Surreal, SurrealDBError> {
134
157
  if (this.client) {
135
- return this.client
158
+ return Effect.succeed(this.client)
136
159
  }
137
160
 
138
161
  const codecOptions = { useNativeDates: true }
139
162
 
140
- if (this.isEmbeddedEngine(this.config.url)) {
141
- const { createNodeEngines } = await import('@surrealdb/node')
142
- this.client = new Surreal({
143
- engines: { ...createRemoteEngines(), ...createNodeEngines() } as NonNullable<
144
- ConstructorParameters<typeof Surreal>[0]
145
- >['engines'],
146
- codecOptions,
147
- })
148
- return this.client
149
- }
163
+ return Effect.gen(
164
+ function* (this: SurrealDBService) {
165
+ if (this.client) {
166
+ return this.client
167
+ }
150
168
 
151
- this.client = new Surreal({ engines: createRemoteEngines(), codecOptions })
152
- return this.client
153
- }
169
+ if (this.isEmbeddedEngine(this.config.url)) {
170
+ const { createNodeEngines } = yield* Effect.tryPromise({
171
+ try: () => import('@surrealdb/node'),
172
+ catch: (error) =>
173
+ new SurrealDBError({
174
+ message: `Failed to load embedded SurrealDB engine: ${getErrorMessage(error)}`,
175
+ cause: error,
176
+ }),
177
+ })
178
+ this.client = new Surreal({
179
+ engines: { ...createRemoteEngines(), ...createNodeEngines() } as NonNullable<
180
+ ConstructorParameters<typeof Surreal>[0]
181
+ >['engines'],
182
+ codecOptions,
183
+ })
184
+ return this.client
185
+ }
154
186
 
155
- private async resetClient(): Promise<void> {
156
- if (!this.client) {
157
- return
158
- }
187
+ this.client = new Surreal({ engines: createRemoteEngines(), codecOptions })
188
+ return this.client
189
+ }.bind(this),
190
+ )
191
+ }
159
192
 
160
- try {
161
- await this.client.close()
162
- } catch (error) {
163
- serverLogger.warn`Failed to close database client: ${error}`
164
- } finally {
165
- this.client = null
166
- }
193
+ private resetClient(): Effect.Effect<void, never> {
194
+ if (this.client === null) {
195
+ return Effect.void
196
+ }
197
+
198
+ const client = this.client
199
+
200
+ return Effect.tryPromise({
201
+ try: () => client.close(),
202
+ catch: (error) =>
203
+ new SurrealDBError({ message: `Failed to close database client: ${getErrorMessage(error)}`, cause: error }),
204
+ }).pipe(
205
+ Effect.catch((error: SurrealDBError) =>
206
+ Effect.sync(() => {
207
+ serverLogger.warn`Failed to close database client: ${error.message}`
208
+ }),
209
+ ),
210
+ Effect.asVoid,
211
+ Effect.ensuring(
212
+ Effect.sync(() => {
213
+ this.client = null
214
+ }),
215
+ ),
216
+ )
167
217
  }
168
218
 
169
- private isRetriableConnectConflict(error: unknown): boolean {
170
- if (!(error instanceof Error)) {
171
- return false
172
- }
219
+ connect(): AwaitableEffect<void, SurrealDBError> {
220
+ return toAwaitableEffect(
221
+ Effect.gen(
222
+ function* (this: SurrealDBService) {
223
+ // Fast path: already connected. Cheap, runs inside the Effect so callers
224
+ // observe the same memory barrier the slow path relies on.
225
+ if (this.isConnected) {
226
+ return
227
+ }
173
228
 
174
- const message = error.message.toLowerCase()
175
- return (
176
- message.includes('transaction conflict') ||
177
- message.includes('transaction read conflict') ||
178
- message.includes('read or write conflict') ||
179
- message.includes('write conflict') ||
180
- message.includes('resource busy') ||
181
- message.includes('this transaction can be retried')
229
+ // Slow path: serialize concurrent connect attempts behind a single permit
230
+ // so only one client.connect() ever runs. Late waiters double-check
231
+ // isConnected after acquiring the permit and become no-ops.
232
+ yield* this.connectMutex.withPermits(1)(this.connectLockedEffect())
233
+ }.bind(this),
234
+ ),
182
235
  )
183
236
  }
184
237
 
185
- async connect(): Promise<void> {
186
- if (this.isConnected) {
187
- return
188
- }
189
-
190
- if (this.connectPromise) {
191
- await this.connectPromise
192
- return
193
- }
238
+ private connectLockedEffect(): Effect.Effect<void, SurrealDBError> {
239
+ return Effect.gen(
240
+ function* (this: SurrealDBService) {
241
+ if (this.isConnected) {
242
+ return
243
+ }
194
244
 
195
- this.connectPromise = (async () => {
196
- let lastError: unknown = null
245
+ const connectEffect = this.getOrCreateClient().pipe(
246
+ Effect.flatMap((client) =>
247
+ Effect.tryPromise({
248
+ try: () =>
249
+ client.connect(this.config.url, {
250
+ namespace: this.config.namespace,
251
+ database: this.config.database,
252
+ authentication: this.isEmbeddedEngine(this.config.url)
253
+ ? undefined
254
+ : { username: this.config.username ?? '', password: this.config.password ?? '' },
255
+ }),
256
+ catch: (error) =>
257
+ new SurrealDBError({
258
+ message: `Failed to connect to SurrealDB (${this.config.url}): ${getErrorMessage(error)}`,
259
+ cause: error,
260
+ }),
261
+ }).pipe(
262
+ Effect.timeout(Duration.millis(CONNECT_ATTEMPT_TIMEOUT_MS)),
263
+ Effect.catchTag('TimeoutError', () =>
264
+ Effect.fail(new SurrealDBError({ message: `Timed out connecting to SurrealDB (${this.config.url})` })),
265
+ ),
266
+ ),
267
+ ),
268
+ Effect.tap(() =>
269
+ Effect.sync(() => {
270
+ this.isConnected = true
271
+ this.logger?.info?.('Connected to SurrealDB')
272
+ }),
273
+ ),
274
+ Effect.tapError(() =>
275
+ Effect.sync(() => {
276
+ this.isConnected = false
277
+ }).pipe(Effect.andThen(this.resetClient())),
278
+ ),
279
+ Effect.retry({
280
+ times: CONNECT_MAX_ATTEMPTS - 1,
281
+ schedule: Schedule.jittered(Schedule.exponential(Duration.millis(CONNECT_RETRY_BASE_DELAY_MS), 2)),
282
+ while: isRetriableConnectError,
283
+ }),
284
+ Effect.asVoid,
285
+ )
197
286
 
198
- for (let attempt = 1; attempt <= CONNECT_MAX_ATTEMPTS; attempt += 1) {
199
- try {
200
- const client = await this.getOrCreateClient()
287
+ yield* connectEffect
288
+ }.bind(this),
289
+ )
290
+ }
201
291
 
202
- await withTimeout(
203
- client.connect(this.config.url, {
204
- namespace: this.config.namespace,
205
- database: this.config.database,
206
- authentication: this.isEmbeddedEngine(this.config.url)
207
- ? undefined
208
- : { username: this.config.username ?? '', password: this.config.password ?? '' },
209
- }),
210
- CONNECT_ATTEMPT_TIMEOUT_MS,
211
- `SurrealDB connect (${this.config.url})`,
212
- )
292
+ disconnect(): AwaitableEffect<void, SurrealDBError> {
293
+ return toAwaitableEffect(
294
+ Effect.gen(
295
+ function* (this: SurrealDBService) {
296
+ if (!this.isConnected) {
297
+ return
298
+ }
213
299
 
214
- this.isConnected = true
215
- this.logger?.info?.('Connected to SurrealDB')
216
- return
217
- } catch (error) {
218
- lastError = error
219
300
  this.isConnected = false
220
- await this.resetClient()
221
301
 
222
- const retriable = this.isRetriableConnectConflict(error)
223
- const hasMoreAttempts = attempt < CONNECT_MAX_ATTEMPTS
224
- if (!retriable || !hasMoreAttempts) {
225
- break
302
+ const client = this.client
303
+ if (!client) {
304
+ this.client = null
305
+ return
226
306
  }
227
307
 
228
- const backoffMs =
229
- CONNECT_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * CONNECT_RETRY_JITTER_MS)
230
- this.logger?.warn?.(
231
- `Retrying SurrealDB connect after retryable transaction conflict (${attempt + 1}/${CONNECT_MAX_ATTEMPTS})`,
308
+ yield* Effect.tryPromise({
309
+ try: () => client.close(),
310
+ catch: (error) =>
311
+ new SurrealDBError({
312
+ message: `Failed to close database client: ${getErrorMessage(error)}`,
313
+ cause: error,
314
+ }),
315
+ }).pipe(
316
+ Effect.ensuring(
317
+ Effect.sync(() => {
318
+ this.client = null
319
+ }),
320
+ ),
232
321
  )
233
- await Bun.sleep(backoffMs)
234
- }
235
- }
236
-
237
- return this.toSurrealError(lastError)
238
- })()
239
-
240
- try {
241
- await this.connectPromise
242
- } finally {
243
- this.connectPromise = null
244
- }
322
+ }.bind(this),
323
+ ),
324
+ )
245
325
  }
246
326
 
247
- async disconnect(): Promise<void> {
248
- if (this.connectPromise) {
249
- try {
250
- await this.connectPromise
251
- } catch (error) {
252
- this.logger?.warn?.(
253
- `Disconnect observed failed in-flight connect: ${error instanceof Error ? error.message : String(error)}`,
254
- )
255
- return
256
- }
257
- }
258
-
259
- if (!this.isConnected) {
260
- return
327
+ private ensureConnectedEffect(query?: string): Effect.Effect<Surreal, SurrealDBError, never> {
328
+ if (this.client === null || !this.isConnected) {
329
+ return this.connect().pipe(
330
+ Effect.flatMap(() => this.getOrCreateClient()),
331
+ Effect.mapError((error) =>
332
+ isSurrealDBError(error)
333
+ ? error
334
+ : new SurrealDBError({ message: 'Database not connected', query, cause: error }),
335
+ ),
336
+ )
261
337
  }
262
338
 
263
- this.isConnected = false
264
-
265
- try {
266
- await this.client?.close()
267
- } finally {
268
- this.client = null
269
- }
339
+ return Effect.succeed(this.client)
270
340
  }
271
341
 
272
- private async ensureConnected(): Promise<Surreal> {
273
- if (this.connectPromise) {
274
- await this.connectPromise
275
- }
276
-
277
- if (this.client === null) {
278
- throw new SurrealDBError('Database not connected')
279
- }
280
-
281
- return this.client
282
- }
283
-
284
- private normalizeRecordId(id: unknown, table: DatabaseTable): ReturnType<typeof ensureRecordId> {
285
- try {
286
- const recordId = ensureRecordId(id as RecordIdInput, table)
287
- const resolvedTable = String(recordId.table)
288
- if (resolvedTable !== table) {
289
- throw new SurrealDBError(`Record id table mismatch: expected "${table}" but got "${resolvedTable}"`)
290
- }
291
- return recordId
292
- } catch (error) {
293
- if (error instanceof SurrealDBError) {
294
- throw error
295
- }
296
- if (error instanceof Error) {
297
- throw new SurrealDBError(`Invalid record id for table "${table}": ${error.message}`, undefined, {
298
- cause: error,
299
- })
300
- }
301
- throw new SurrealDBError(`Invalid record id for table "${table}"`)
302
- }
342
+ private normalizeRecordIdEffect(
343
+ id: unknown,
344
+ table: DatabaseTable,
345
+ ): Effect.Effect<ReturnType<typeof ensureRecordId>, SurrealDBError> {
346
+ return normalizeRecordIdForTable(id, table)
303
347
  }
304
348
 
305
349
  private normalizeQueryRows(statement: unknown, schema?: z.ZodTypeAny): unknown[] {
306
- if (Array.isArray(statement)) {
307
- return schema ? statement.map((row) => this.parseSchema(schema, row)) : (statement as unknown[])
308
- }
309
- if (statement === null || statement === undefined) {
310
- return []
311
- }
312
- return schema ? [this.parseSchema(schema, statement)] : [statement]
350
+ return normalizeQueryRows(statement, schema, (schemaValue, value) => this.parseSchema(schemaValue, value))
313
351
  }
314
352
 
315
- private normalizeParseValue(value: unknown): unknown {
316
- if (
317
- value instanceof Date ||
318
- value instanceof RecordId ||
319
- value instanceof StringRecordId ||
320
- value instanceof Table
321
- ) {
322
- return value
323
- }
324
-
325
- if (Array.isArray(value)) {
326
- return value.map((entry) => this.normalizeParseValue(entry))
327
- }
328
-
329
- if (isSurrealRecordIdValue(value)) {
330
- return ensureRecordId(value as RecordIdInput)
331
- }
353
+ private normalizeQueryRowsEffect(
354
+ statement: unknown,
355
+ schema?: z.ZodTypeAny,
356
+ ): Effect.Effect<unknown[], SurrealDBError> {
357
+ return Effect.try({
358
+ try: () => this.normalizeQueryRows(statement, schema),
359
+ catch: (error) => this.toSurrealError(error),
360
+ })
361
+ }
332
362
 
333
- if (!isRecord(value)) {
334
- return value
335
- }
363
+ private normalizeParseValue(value: unknown): unknown {
364
+ return normalizeSurrealValue(value)
365
+ }
336
366
 
337
- return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, this.normalizeParseValue(entry)]))
367
+ private normalizeParseValueEffect(value: unknown): Effect.Effect<unknown, SurrealDBError> {
368
+ return Effect.try({ try: () => this.normalizeParseValue(value), catch: (error) => this.toSurrealError(error) })
338
369
  }
339
370
 
340
371
  private parseSchema<TSchema extends z.ZodTypeAny>(schema: TSchema, value: unknown): z.infer<TSchema> {
341
372
  return schema.parse(this.normalizeParseValue(value))
342
373
  }
343
374
 
344
- private buildFilterExpression(filter: Record<string, unknown>): ExprLike | undefined {
345
- const entries = Object.entries(filter)
346
- if (entries.length === 0) {
347
- return undefined
348
- }
349
-
350
- const expressions = entries.map(([key, value]) => eq(key, this.normalizeRuntimeValue(value)))
351
- if (expressions.length === 1) {
352
- return expressions[0]
353
- }
354
-
355
- return and(...expressions)
356
- }
357
-
358
- private assertValidIdentifier(name: string, context: string): void {
359
- if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(name)) {
360
- throw new SurrealDBError(`Invalid ${context}: "${name}"`)
361
- }
375
+ private parseSchemaEffect<TSchema extends z.ZodTypeAny>(
376
+ schema: TSchema,
377
+ value: unknown,
378
+ ): Effect.Effect<z.infer<TSchema>, SurrealDBError> {
379
+ return Effect.try({ try: () => this.parseSchema(schema, value), catch: (error) => this.toSurrealError(error) })
362
380
  }
363
381
 
364
- private buildBoundFilterClauses(filter: Record<string, unknown>): {
365
- clause: string
366
- bindings: Record<string, unknown>
367
- } {
368
- const entries = Object.entries(filter)
369
- if (entries.length === 0) {
370
- throw new SurrealDBError('Expected a non-empty filter')
382
+ private parseOptionalSchema<TSchema extends z.ZodTypeAny>(schema: TSchema, value: unknown): z.infer<TSchema> | null {
383
+ if (value === null || value === undefined) {
384
+ return null
371
385
  }
372
386
 
373
- const bindings: Record<string, unknown> = {}
374
- const clauses = entries.map(([field, value], index) => {
375
- this.assertValidIdentifier(field, 'filter field')
387
+ return this.parseSchema(schema, value)
388
+ }
376
389
 
377
- const bindingKey = `filter_${index}`
378
- bindings[bindingKey] = this.normalizeRuntimeValue(value)
379
- return `${field} = $${bindingKey}`
390
+ private parseOptionalSchemaEffect<TSchema extends z.ZodTypeAny>(
391
+ schema: TSchema,
392
+ value: unknown,
393
+ ): Effect.Effect<z.infer<TSchema> | null, SurrealDBError> {
394
+ return Effect.try({
395
+ try: () => this.parseOptionalSchema(schema, value),
396
+ catch: (error) => this.toSurrealError(error),
380
397
  })
381
-
382
- return { clause: clauses.join(' AND '), bindings }
383
398
  }
384
399
 
385
- private normalizeBoundQuery(query: BoundQuery): BoundQuery {
386
- return new BoundQuery(query.query, this.normalizeBindings(query.bindings))
400
+ private buildFilterExpression(filter: Record<string, unknown>): ExprLike | undefined {
401
+ return buildFilterExpression(filter)
387
402
  }
388
403
 
389
- private normalizeRuntimeValue(value: unknown): unknown {
390
- if (value === null || value === undefined) {
391
- return value
392
- }
393
-
394
- if (
395
- value instanceof Date ||
396
- value instanceof RecordId ||
397
- value instanceof StringRecordId ||
398
- value instanceof Table
399
- ) {
400
- return value
401
- }
402
-
403
- if (Array.isArray(value)) {
404
- return value.map((entry) => this.normalizeRuntimeValue(entry))
405
- }
406
-
407
- if (isSurrealRecordIdValue(value)) {
408
- return ensureRecordId(value as RecordIdInput)
409
- }
410
-
411
- if (!isRecord(value)) {
412
- return value
413
- }
414
-
415
- if ('tb' in value && 'id' in value && Object.keys(value).length === 2) {
416
- return ensureRecordId(value as unknown as RecordIdInput)
417
- }
418
-
419
- const entries = Object.entries(value)
420
- if (entries.length === 0) {
421
- return value
422
- }
423
-
424
- return Object.fromEntries(entries.map(([key, entryValue]) => [key, this.normalizeRuntimeValue(entryValue)]))
404
+ private buildFilterExpressionEffect(
405
+ filter: Record<string, unknown>,
406
+ ): Effect.Effect<ExprLike | undefined, SurrealDBError> {
407
+ return Effect.try({ try: () => this.buildFilterExpression(filter), catch: (error) => this.toSurrealError(error) })
425
408
  }
426
409
 
427
- // Cast is safe: normalizeRuntimeValue preserves Record shape when input is a Record
428
- // (non-array objects are mapped entry-by-entry and returned as Object.fromEntries)
429
- private normalizeBindings(bindings?: Record<string, unknown>): Record<string, unknown> | undefined {
430
- if (!bindings) {
431
- return undefined
432
- }
433
-
434
- return this.normalizeRuntimeValue(bindings) as Record<string, unknown>
410
+ private buildBoundFilterClausesEffect(
411
+ filter: Record<string, unknown>,
412
+ ): Effect.Effect<{ clause: string; bindings: Record<string, unknown> }, SurrealDBError> {
413
+ return buildBoundFilterClauses(filter)
435
414
  }
436
415
 
437
- private normalizeMutationFieldValue(value: unknown): unknown {
438
- if (value === undefined) {
439
- return undefined
440
- }
416
+ private assertValidIdentifierEffect(name: string, context: string): Effect.Effect<void, SurrealDBError> {
417
+ return assertValidIdentifier(name, context)
418
+ }
441
419
 
442
- if (value === null) {
443
- return null
444
- }
420
+ private normalizeBoundQueryEffect<T extends unknown[] = unknown[]>(
421
+ query: BoundQuery<T>,
422
+ ): Effect.Effect<BoundQuery<T>, SurrealDBError> {
423
+ return normalizeBoundQuery(query)
424
+ }
445
425
 
446
- return this.normalizeRuntimeValue(value)
426
+ private normalizeTransactionQueryEffect(query: unknown): Effect.Effect<BoundQuery, SurrealDBError> {
427
+ return normalizeTransactionQuery(query)
447
428
  }
448
429
 
449
- // Cast is safe: normalizeRuntimeValue preserves Record shape when input is a Record
450
430
  private normalizeMutationData(data: Record<string, unknown>): Record<string, unknown> {
451
- return Object.fromEntries(
452
- Object.entries(data)
453
- .map(([key, value]) => [key, this.normalizeMutationFieldValue(value)] as const)
454
- .filter((entry): entry is readonly [string, unknown] => entry[1] !== undefined),
455
- ) as Record<string, unknown>
431
+ return normalizeMutationData(data)
456
432
  }
457
433
 
458
- private normalizeTableValue(value: unknown): Table {
459
- if (value instanceof Table) {
460
- return value as Table<string>
461
- }
462
-
463
- if (typeof value === 'string' && value.length > 0) {
464
- return new Table(value)
465
- }
466
-
467
- throw new SurrealDBError('Invalid table value')
434
+ private normalizeMutationDataEffect(
435
+ data: Record<string, unknown>,
436
+ ): Effect.Effect<Record<string, unknown>, SurrealDBError> {
437
+ return Effect.try({ try: () => this.normalizeMutationData(data), catch: (error) => this.toSurrealError(error) })
468
438
  }
469
439
 
470
- private isRecordIdLike(value: unknown): boolean {
471
- if (value instanceof RecordId || value instanceof StringRecordId) {
472
- return true
473
- }
474
-
475
- if (isSurrealRecordIdValue(value)) {
476
- return true
477
- }
478
-
479
- if (typeof value === 'string') {
480
- return /^[a-zA-Z][a-zA-Z0-9_]*:/.test(value)
481
- }
482
-
483
- if (value && typeof value === 'object') {
484
- const record = value as { tb?: unknown; id?: unknown }
485
- return typeof record.tb === 'string' && record.id !== undefined && Object.keys(value).length === 2
486
- }
487
-
488
- return false
440
+ private normalizeTableValueEffect(value: unknown): Effect.Effect<Table<string>, SurrealDBError> {
441
+ return normalizeTableValue(value)
489
442
  }
490
443
 
491
- private normalizeCreateTarget(value: unknown): Table | RecordId {
492
- if (this.isRecordIdLike(value)) {
493
- return ensureRecordId(value as RecordIdInput)
494
- }
444
+ private normalizeCreateTargetEffect(
445
+ value: unknown,
446
+ ): Effect.Effect<Table<string> | ReturnType<typeof ensureRecordId>, SurrealDBError> {
447
+ return normalizeCreateTarget(value)
448
+ }
495
449
 
496
- return this.normalizeTableValue(value)
450
+ private normalizeTransactionRecordIdEffect(
451
+ value: unknown,
452
+ context: string,
453
+ ): Effect.Effect<ReturnType<typeof ensureRecordId>, SurrealDBError> {
454
+ return normalizeTransactionRecordId(value, context)
497
455
  }
498
456
 
499
- private wrapMutationBuilder(builder: MutationBuilder): MutationBuilder {
457
+ private wrapMutationBuilder(
458
+ builderEffect: Effect.Effect<MutationBuilderSource, SurrealDBError>,
459
+ pendingMutation?: PendingMutation,
460
+ ): MutationBuilder {
500
461
  return {
501
- content: (data) => this.wrapMutationBuilder(builder.content(this.normalizeMutationData(data))),
502
- replace: (data) => this.wrapMutationBuilder(builder.replace(this.normalizeMutationData(data))),
503
- merge: (data) => this.wrapMutationBuilder(builder.merge(this.normalizeMutationData(data))),
504
- output: async (mode) => this.normalizeParseValue(await builder.output(mode)),
462
+ content: (data) => this.wrapMutationBuilder(builderEffect, { mutation: 'content', data }),
463
+ replace: (data) => this.wrapMutationBuilder(builderEffect, { mutation: 'replace', data }),
464
+ merge: (data) => this.wrapMutationBuilder(builderEffect, { mutation: 'merge', data }),
465
+ output: (mode) =>
466
+ toAwaitableEffect(
467
+ Effect.gen(
468
+ function* (this: SurrealDBService) {
469
+ const builder = yield* builderEffect
470
+ let configuredBuilder = builder
471
+ if (pendingMutation) {
472
+ const normalizedData = yield* this.normalizeMutationDataEffect(pendingMutation.data)
473
+ configuredBuilder = configureMutation(builder, pendingMutation.mutation, normalizedData)
474
+ }
475
+ const value = yield* Effect.tryPromise({
476
+ try: () => Promise.resolve(configuredBuilder.output(mode)),
477
+ catch: (error) =>
478
+ new SurrealDBError({
479
+ message: `Failed to finish mutation output: ${getErrorMessage(error)}`,
480
+ cause: error,
481
+ }),
482
+ })
483
+ return yield* this.normalizeParseValueEffect(value)
484
+ }.bind(this),
485
+ ),
486
+ ),
505
487
  }
506
488
  }
507
489
 
508
- private wrapCreateBuilder(builder: CreateMutationBuilder): CreateMutationBuilder {
490
+ private wrapCreateBuilder(
491
+ builderEffect: Effect.Effect<CreateBuilderSource, SurrealDBError>,
492
+ pendingData?: Record<string, unknown>,
493
+ ): CreateMutationBuilder {
509
494
  return {
510
- content: (data) => this.wrapCreateBuilder(builder.content(this.normalizeMutationData(data))),
511
- output: async (mode) => this.normalizeParseValue(await builder.output(mode)),
495
+ content: (data) => this.wrapCreateBuilder(builderEffect, data),
496
+ output: (mode) =>
497
+ toAwaitableEffect(
498
+ Effect.gen(
499
+ function* (this: SurrealDBService) {
500
+ const builder = yield* builderEffect
501
+ const configuredBuilder = pendingData
502
+ ? builder.content(yield* this.normalizeMutationDataEffect(pendingData))
503
+ : builder
504
+ const value = yield* Effect.tryPromise({
505
+ try: () => Promise.resolve(configuredBuilder.output(mode)),
506
+ catch: (error) =>
507
+ new SurrealDBError({
508
+ message: `Failed to finish create output: ${getErrorMessage(error)}`,
509
+ cause: error,
510
+ }),
511
+ })
512
+ return yield* this.normalizeParseValueEffect(value)
513
+ }.bind(this),
514
+ ),
515
+ ),
512
516
  }
513
517
  }
514
518
 
515
519
  private wrapTransaction(tx: SurrealTransaction): DatabaseTransaction {
516
520
  return {
517
- query: async (query: unknown) => {
518
- const boundQuery = this.normalizeBoundQuery(query as BoundQuery)
519
- const queryText = this.resolveQueryText(boundQuery)
520
-
521
- try {
522
- const responses = await tx.query(boundQuery).responses()
523
- const first = responses.at(0)
524
- if (!first) {
525
- return []
526
- }
527
- if (!first.success) {
528
- throw new SurrealDBError(first.error.message, queryText, { cause: first.error })
529
- }
530
-
531
- return this.normalizeQueryRows(first.result)
532
- } catch (error) {
533
- return this.toSurrealError(error, queryText)
534
- }
535
- },
536
- create: (target: unknown) => {
537
- const normalizedTarget = this.normalizeCreateTarget(target)
538
- const builder = normalizedTarget instanceof Table ? tx.create(normalizedTarget) : tx.create(normalizedTarget)
539
- // Cast needed: SurrealDB SDK transaction builder type differs nominally from internal CreateMutationBuilder interface
540
- return this.wrapCreateBuilder(builder as unknown as CreateMutationBuilder)
541
- },
521
+ query: (query: unknown) =>
522
+ toAwaitableEffect(
523
+ Effect.gen(
524
+ function* (this: SurrealDBService) {
525
+ const boundQuery = yield* this.normalizeTransactionQueryEffect(query)
526
+ const queryText = this.resolveQueryText(boundQuery)
527
+ const responses = yield* Effect.tryPromise({
528
+ try: () => tx.query(boundQuery).responses(),
529
+ catch: (error) =>
530
+ new SurrealDBError({
531
+ message: `Failed to run transaction query: ${getErrorMessage(error)}`,
532
+ query: queryText,
533
+ cause: error,
534
+ }),
535
+ })
536
+ const first = responses.at(0)
537
+ if (!first) {
538
+ return []
539
+ }
540
+ if (!first.success) {
541
+ return yield* new SurrealDBError({ message: first.error.message, query: queryText, cause: first.error })
542
+ }
543
+
544
+ return yield* this.normalizeQueryRowsEffect(first.result)
545
+ }.bind(this),
546
+ ),
547
+ ),
548
+ create: (target: unknown) =>
549
+ this.wrapCreateBuilder(
550
+ Effect.gen(
551
+ function* (this: SurrealDBService) {
552
+ const normalizedTarget = yield* this.normalizeCreateTargetEffect(target)
553
+ return normalizedTarget instanceof Table ? tx.create(normalizedTarget) : tx.create(normalizedTarget)
554
+ }.bind(this),
555
+ ),
556
+ ),
542
557
  update: (target: unknown) =>
543
- // Cast needed: SurrealDB SDK transaction builder type differs nominally from internal MutationBuilder interface
544
- this.wrapMutationBuilder(tx.update(ensureRecordId(target as RecordIdInput)) as unknown as MutationBuilder),
545
- delete: async (target: unknown) =>
546
- this.normalizeParseValue(await tx.delete(ensureRecordId(target as RecordIdInput))),
547
- relate: async (from: unknown, edgeTable: unknown, to: unknown, data?: Values<Record<string, unknown>>) =>
548
- this.normalizeParseValue(
549
- await tx.relate(
550
- ensureRecordId(from as RecordIdInput),
551
- this.normalizeTableValue(edgeTable),
552
- ensureRecordId(to as RecordIdInput),
553
- data
554
- ? (this.normalizeMutationData(data as Record<string, unknown>) as Values<Record<string, unknown>>)
555
- : undefined,
558
+ this.wrapMutationBuilder(
559
+ Effect.gen(
560
+ function* (this: SurrealDBService) {
561
+ const recordId = yield* this.normalizeTransactionRecordIdEffect(target, 'transaction update')
562
+ return tx.update(recordId)
563
+ }.bind(this),
564
+ ),
565
+ ),
566
+ delete: (target: unknown) =>
567
+ toAwaitableEffect(
568
+ Effect.gen(
569
+ function* (this: SurrealDBService) {
570
+ const recordId = yield* this.normalizeTransactionRecordIdEffect(target, 'transaction delete')
571
+ const value = yield* Effect.tryPromise({
572
+ try: () => tx.delete(recordId),
573
+ catch: (error) =>
574
+ new SurrealDBError({
575
+ message: `Failed to delete transaction target: ${getErrorMessage(error)}`,
576
+ cause: error,
577
+ }),
578
+ })
579
+ return yield* this.normalizeParseValueEffect(value)
580
+ }.bind(this),
556
581
  ),
557
582
  ),
558
- commit: () => tx.commit(),
559
- cancel: () => tx.cancel(),
583
+ relate: (from: unknown, edgeTable: unknown, to: unknown, data?: Values<Record<string, unknown>>) =>
584
+ toAwaitableEffect(
585
+ Effect.gen(
586
+ function* (this: SurrealDBService) {
587
+ const fromRecordId = yield* this.normalizeTransactionRecordIdEffect(from, 'transaction relate source')
588
+ const normalizedEdgeTable = yield* this.normalizeTableValueEffect(edgeTable)
589
+ const toRecordId = yield* this.normalizeTransactionRecordIdEffect(to, 'transaction relate target')
590
+ const normalizedData = data
591
+ ? yield* this.normalizeMutationDataEffect(data as Record<string, unknown>)
592
+ : undefined
593
+ const value = yield* Effect.tryPromise({
594
+ try: () => tx.relate(fromRecordId, normalizedEdgeTable, toRecordId, normalizedData),
595
+ catch: (error) =>
596
+ new SurrealDBError({
597
+ message: `Failed to relate transaction records: ${getErrorMessage(error)}`,
598
+ cause: error,
599
+ }),
600
+ })
601
+ return yield* this.normalizeParseValueEffect(value)
602
+ }.bind(this),
603
+ ),
604
+ ),
605
+ commit: () =>
606
+ toAwaitableEffect(
607
+ Effect.tryPromise({
608
+ try: () => tx.commit(),
609
+ catch: (error) =>
610
+ new SurrealDBError({ message: `Failed to commit transaction: ${getErrorMessage(error)}`, cause: error }),
611
+ }),
612
+ ),
613
+ cancel: () =>
614
+ toAwaitableEffect(
615
+ Effect.tryPromise({
616
+ try: () => tx.cancel(),
617
+ catch: (error) =>
618
+ new SurrealDBError({ message: `Failed to cancel transaction: ${getErrorMessage(error)}`, cause: error }),
619
+ }),
620
+ ),
560
621
  }
561
622
  }
562
623
 
@@ -564,459 +625,531 @@ export class SurrealDBService {
564
625
  return query.query
565
626
  }
566
627
 
567
- async query<T>(query: BoundQuery): Promise<T[]> {
568
- const statements = await this.queryAll<T>(query)
569
- return statements.at(0) ?? []
628
+ query<T>(query: BoundQuery): AwaitableEffect<T[], SurrealDBError> {
629
+ return toAwaitableEffect(
630
+ Effect.gen(
631
+ function* (this: SurrealDBService) {
632
+ const boundQuery = yield* this.normalizeBoundQueryEffect(query)
633
+ const statements = yield* this.queryAll<T>(boundQuery)
634
+ return statements.at(0) ?? []
635
+ }.bind(this),
636
+ ),
637
+ )
570
638
  }
571
639
 
572
- async queryAll<T>(query: BoundQuery, schema?: z.ZodTypeAny): Promise<T[][]> {
573
- const client = await this.ensureConnected()
574
- const boundQuery = this.normalizeBoundQuery(query)
575
- const queryText = this.resolveQueryText(boundQuery)
576
-
577
- try {
578
- const responses = await client.query(boundQuery).responses()
579
-
580
- return responses.map((response, index) => {
581
- if (!response.success) {
582
- const failure = response.error
583
- throw new SurrealDBError(`Statement ${index + 1}: ${failure.message}`, queryText, { cause: failure })
584
- }
585
- return this.normalizeQueryRows(response.result, schema) as T[]
586
- })
587
- } catch (error) {
588
- return this.toSurrealError(error, queryText)
589
- }
640
+ queryAll<T>(query: BoundQuery, schema?: z.ZodTypeAny): AwaitableEffect<T[][], SurrealDBError> {
641
+ return toAwaitableEffect(
642
+ Effect.gen(
643
+ function* (this: SurrealDBService) {
644
+ const boundQuery = yield* this.normalizeBoundQueryEffect(query)
645
+ const queryText = this.resolveQueryText(boundQuery)
646
+ const client = yield* this.ensureConnectedEffect(queryText)
647
+ const responses = yield* Effect.tryPromise({
648
+ try: () => client.query(boundQuery).responses(),
649
+ catch: (error) =>
650
+ new SurrealDBError({
651
+ message: `Failed to run query: ${getErrorMessage(error)}`,
652
+ query: queryText,
653
+ cause: error,
654
+ }),
655
+ })
656
+ return yield* Effect.forEach(responses, (response, index) =>
657
+ Effect.gen(
658
+ function* (this: SurrealDBService) {
659
+ if (!response.success) {
660
+ const failure = response.error
661
+ return yield* new SurrealDBError({
662
+ message: `Statement ${index + 1}: ${failure.message}`,
663
+ query: queryText,
664
+ cause: failure,
665
+ })
666
+ }
667
+ return (yield* this.normalizeQueryRowsEffect(response.result, schema)) as T[]
668
+ }.bind(this),
669
+ ),
670
+ )
671
+ }.bind(this),
672
+ ),
673
+ )
590
674
  }
591
675
 
592
- async queryOne<T extends z.ZodTypeAny>(query: BoundQuery, schema: T): Promise<z.infer<T> | null> {
593
- const results = await this.query<unknown>(query)
594
- const first = results.at(0)
595
- return first ? this.parseSchema(schema, first) : null
676
+ queryOne<T extends z.ZodTypeAny>(query: BoundQuery, schema: T): AwaitableEffect<z.infer<T> | null, SurrealDBError> {
677
+ return toAwaitableEffect(
678
+ Effect.gen(
679
+ function* (this: SurrealDBService) {
680
+ const boundQuery = yield* this.normalizeBoundQueryEffect(query)
681
+ const results = yield* this.query<unknown>(boundQuery)
682
+ const first = results.at(0)
683
+ return first ? yield* this.parseSchemaEffect(schema, first) : null
684
+ }.bind(this),
685
+ ),
686
+ )
596
687
  }
597
688
 
598
- async queryMany<T extends z.ZodTypeAny>(query: BoundQuery, schema: T): Promise<z.infer<T>[]> {
599
- const results = await this.query<unknown>(query)
600
- return results.map((row) => this.parseSchema(schema, row))
689
+ queryMany<T extends z.ZodTypeAny>(query: BoundQuery, schema: T): AwaitableEffect<z.infer<T>[], SurrealDBError> {
690
+ return toAwaitableEffect(
691
+ Effect.gen(
692
+ function* (this: SurrealDBService) {
693
+ const boundQuery = yield* this.normalizeBoundQueryEffect(query)
694
+ const results = yield* this.query<unknown>(boundQuery)
695
+ return yield* Effect.forEach(results, (row) => this.parseSchemaEffect(schema, row))
696
+ }.bind(this),
697
+ ),
698
+ )
601
699
  }
602
700
 
603
- private async runSqlFile(file: Bun.BunFile): Promise<void> {
604
- const sql = (await file.text()).trim()
605
- if (!sql) {
606
- return
607
- }
701
+ private runSqlFile(file: Bun.BunFile): Effect.Effect<void, SurrealDBError> {
702
+ return Effect.gen(
703
+ function* (this: SurrealDBService) {
704
+ const sql = (yield* Effect.tryPromise({
705
+ try: () => file.text(),
706
+ catch: (error) =>
707
+ new SurrealDBError({ message: `Failed to read schema file: ${getErrorMessage(error)}`, cause: error }),
708
+ })).trim()
709
+ if (!sql) {
710
+ return
711
+ }
608
712
 
609
- await this.queryAll<unknown>(new BoundQuery(sql))
713
+ yield* this.queryAll<unknown>(new BoundQuery(sql))
714
+ }.bind(this),
715
+ )
610
716
  }
611
717
 
612
- async applySchema(schemaFiles: readonly Bun.BunFile[]): Promise<void> {
613
- for (const schemaFile of schemaFiles) {
614
- await this.runSqlFile(schemaFile)
615
- }
718
+ applySchema(schemaFiles: readonly Bun.BunFile[]): AwaitableEffect<void, SurrealDBError> {
719
+ return toAwaitableEffect(
720
+ Effect.forEach(schemaFiles, (schemaFile) => this.runSqlFile(schemaFile), { discard: true }),
721
+ )
616
722
  }
617
723
 
618
- async findOne<T extends z.ZodTypeAny>(
724
+ findOne<T extends z.ZodTypeAny>(
619
725
  table: DatabaseTable,
620
726
  filter: Record<string, unknown>,
621
727
  schema: T,
622
- ): Promise<z.infer<T> | null> {
623
- const client = await this.ensureConnected()
624
- const selection = this.buildFilterExpression(filter)
625
-
626
- try {
627
- let query = client.select<unknown>(new Table(table))
628
- if (selection) {
629
- query = query.where(selection)
630
- }
631
-
632
- const rows = await query.limit(1)
633
- const first = rows.at(0)
634
- return first ? this.parseSchema(schema, first) : null
635
- } catch (error) {
636
- return this.toSurrealError(error, `SELECT * FROM ${table} LIMIT 1`)
637
- }
728
+ ): AwaitableEffect<z.infer<T> | null, SurrealDBError> {
729
+ return toAwaitableEffect(
730
+ Effect.gen(
731
+ function* (this: SurrealDBService) {
732
+ const selection = yield* this.buildFilterExpressionEffect(filter)
733
+ const client = yield* this.ensureConnectedEffect(`SELECT * FROM ${table} LIMIT 1`)
734
+ let query = client.select<unknown>(new Table(table))
735
+ if (selection) {
736
+ query = query.where(selection)
737
+ }
738
+
739
+ const rows = yield* Effect.tryPromise({
740
+ try: () => query.limit(1),
741
+ catch: (error) =>
742
+ new SurrealDBError({
743
+ message: `Failed to find record in ${table}: ${getErrorMessage(error)}`,
744
+ cause: error,
745
+ }),
746
+ })
747
+ const first = rows.at(0)
748
+ return first ? yield* this.parseSchemaEffect(schema, first) : null
749
+ }.bind(this),
750
+ ),
751
+ )
638
752
  }
639
753
 
640
- async findMany<T extends z.ZodTypeAny>(
754
+ findMany<T extends z.ZodTypeAny>(
641
755
  table: DatabaseTable,
642
756
  filter: Record<string, unknown>,
643
757
  schema: T,
644
758
  options?: FindManyOptions,
645
- ): Promise<z.infer<T>[]> {
646
- const filterKeys = Object.keys(filter)
647
-
648
- const client = await this.ensureConnected()
649
- const selection = this.buildFilterExpression(filter)
650
- const orderBy = options?.orderBy
651
-
652
- if (orderBy !== undefined) {
653
- this.assertValidIdentifier(orderBy, 'orderBy field')
654
- this.assertValidIdentifier(table, 'table name')
655
- for (const key of filterKeys) {
656
- this.assertValidIdentifier(key, 'filter field')
657
- }
658
- const rawOrderDir: unknown = options?.orderDir
659
- if (rawOrderDir !== undefined && rawOrderDir !== 'ASC' && rawOrderDir !== 'DESC') {
660
- throw new SurrealDBError(`Invalid orderDir value: ${this.describeInvalidValue(rawOrderDir)}`)
661
- }
662
- const orderDir = rawOrderDir ?? 'ASC'
663
- const limit = options?.limit
664
- const offset = options?.offset
665
- const vars: Record<string, unknown> = this.normalizeMutationData(filter)
666
- let sql = `SELECT * FROM ${table}`
667
- if (filterKeys.length > 0) {
668
- const conditions = filterKeys.map((key) => `${key} = $${key}`).join(' AND ')
669
- sql += ` WHERE ${conditions}`
670
- }
671
- sql += ` ORDER BY ${orderBy} ${orderDir}`
672
- if (limit !== undefined) {
673
- sql += ' LIMIT $limitParam'
674
- vars.limitParam = limit
675
- }
676
- if (offset !== undefined) {
677
- sql += ' START $offsetParam'
678
- vars.offsetParam = offset
679
- }
680
- const rows = await this.query<unknown>(new BoundQuery(sql, vars))
681
- return rows.map((row) => this.parseSchema(schema, row))
682
- }
683
-
684
- try {
685
- let query = client.select<unknown>(new Table(table))
686
- if (selection) {
687
- query = query.where(selection)
688
- }
689
- if (options?.offset !== undefined) {
690
- query = query.start(options.offset)
691
- }
692
- if (options?.limit !== undefined) {
693
- query = query.limit(options.limit)
694
- }
695
-
696
- const rows = await query
697
- return rows.map((row) => this.parseSchema(schema, row))
698
- } catch (error) {
699
- return this.toSurrealError(error, `SELECT * FROM ${table}`)
700
- }
701
- }
759
+ ): AwaitableEffect<z.infer<T>[], SurrealDBError> {
760
+ return toAwaitableEffect(
761
+ Effect.gen(
762
+ function* (this: SurrealDBService) {
763
+ const filterKeys = Object.keys(filter)
764
+ const selection = yield* this.buildFilterExpressionEffect(filter)
765
+ const orderBy = options?.orderBy
766
+
767
+ if (orderBy !== undefined) {
768
+ yield* this.assertValidIdentifierEffect(orderBy, 'orderBy field')
769
+ yield* this.assertValidIdentifierEffect(table, 'table name')
770
+ for (const key of filterKeys) {
771
+ yield* this.assertValidIdentifierEffect(key, 'filter field')
772
+ }
773
+ const rawOrderDir: unknown = options?.orderDir
774
+ if (rawOrderDir !== undefined && rawOrderDir !== 'ASC' && rawOrderDir !== 'DESC') {
775
+ return yield* new SurrealDBError({
776
+ message: `Invalid orderDir value: ${describeInvalidValue(rawOrderDir)}`,
777
+ })
778
+ }
779
+ const orderDir = rawOrderDir ?? 'ASC'
780
+ const limit = options?.limit
781
+ const offset = options?.offset
782
+ const vars: Record<string, unknown> = yield* this.normalizeMutationDataEffect(filter)
783
+ let sql = `SELECT * FROM ${table}`
784
+ if (filterKeys.length > 0) {
785
+ const conditions = filterKeys.map((key) => `${key} = $${key}`).join(' AND ')
786
+ sql += ` WHERE ${conditions}`
787
+ }
788
+ sql += ` ORDER BY ${orderBy} ${orderDir}`
789
+ if (limit !== undefined) {
790
+ sql += ' LIMIT $limitParam'
791
+ vars.limitParam = limit
792
+ }
793
+ if (offset !== undefined) {
794
+ sql += ' START $offsetParam'
795
+ vars.offsetParam = offset
796
+ }
797
+ const rows = yield* this.query<unknown>(new BoundQuery(sql, vars))
798
+ return yield* Effect.forEach(rows, (row) => this.parseSchemaEffect(schema, row))
799
+ }
702
800
 
703
- private describeInvalidValue(value: unknown): string {
704
- if (typeof value === 'string') {
705
- return value
706
- }
801
+ const client = yield* this.ensureConnectedEffect(`SELECT * FROM ${table}`)
802
+ let query = client.select<unknown>(new Table(table))
803
+ if (selection) {
804
+ query = query.where(selection)
805
+ }
806
+ if (options?.offset !== undefined) {
807
+ query = query.start(options.offset)
808
+ }
809
+ if (options?.limit !== undefined) {
810
+ query = query.limit(options.limit)
811
+ }
707
812
 
708
- try {
709
- const serialized = JSON.stringify(value)
710
- if (typeof serialized === 'string') {
711
- return serialized
712
- }
713
- return Object.prototype.toString.call(value)
714
- } catch {
715
- return Object.prototype.toString.call(value)
716
- }
813
+ const rows = yield* Effect.tryPromise({
814
+ try: () => query,
815
+ catch: (error) =>
816
+ new SurrealDBError({
817
+ message: `Failed to run findMany query for ${table}: ${getErrorMessage(error)}`,
818
+ cause: error,
819
+ }),
820
+ })
821
+ return yield* Effect.forEach(rows, (row) => this.parseSchemaEffect(schema, row))
822
+ }.bind(this),
823
+ ),
824
+ )
717
825
  }
718
826
 
719
- async create<T extends z.ZodTypeAny>(
827
+ create<T extends z.ZodTypeAny>(
720
828
  table: DatabaseTable,
721
829
  data: Record<string, unknown>,
722
830
  schema: T,
723
- ): Promise<z.infer<T>> {
724
- const keys = Object.keys(data)
725
- if (keys.length === 0) {
726
- throw new SurrealDBError(`Cannot create record in ${table} with empty data`)
727
- }
728
-
729
- const client = await this.ensureConnected()
831
+ ): AwaitableEffect<z.infer<T>, SurrealDBError> {
832
+ return toAwaitableEffect(
833
+ Effect.gen(
834
+ function* (this: SurrealDBService) {
835
+ const keys = Object.keys(data)
836
+ if (keys.length === 0) {
837
+ return yield* new SurrealDBError({ message: `Cannot create record in ${table} with empty data` })
838
+ }
730
839
 
731
- try {
732
- const created = await client
733
- .create<unknown>(new Table(table))
734
- .content(this.normalizeMutationData(data))
735
- .output('after')
736
- const first = Array.isArray(created) ? created.at(0) : created
840
+ const client = yield* this.ensureConnectedEffect(`CREATE ${table}`)
841
+ const normalizedData = yield* this.normalizeMutationDataEffect(data)
842
+ const created = yield* Effect.tryPromise({
843
+ try: () => client.create<unknown>(new Table(table)).content(normalizedData).output('after'),
844
+ catch: (error) =>
845
+ new SurrealDBError({
846
+ message: `Failed to create record in ${table}: ${getErrorMessage(error)}`,
847
+ cause: error,
848
+ }),
849
+ })
850
+ const first = Array.isArray(created) ? created.at(0) : created
737
851
 
738
- if (!first) {
739
- throw new SurrealDBError(`Failed to create record in ${table}`)
740
- }
852
+ if (!first) {
853
+ return yield* new SurrealDBError({ message: `Failed to create record in ${table}` })
854
+ }
741
855
 
742
- return this.parseSchema(schema, first)
743
- } catch (error) {
744
- return this.toSurrealError(error, `CREATE ${table}`)
745
- }
856
+ return yield* this.parseSchemaEffect(schema, first)
857
+ }.bind(this),
858
+ ),
859
+ )
746
860
  }
747
861
 
748
- async createWithId<T extends z.ZodTypeAny>(
862
+ createWithId<T extends z.ZodTypeAny>(
749
863
  table: DatabaseTable,
750
864
  id: unknown,
751
865
  data: Record<string, unknown>,
752
866
  schema: T,
753
- ): Promise<z.infer<T>> {
754
- const keys = Object.keys(data)
755
- if (keys.length === 0) {
756
- throw new SurrealDBError(`Cannot create record in ${table} with empty data`)
757
- }
758
-
759
- const recordId = this.normalizeRecordId(id, table)
760
- const client = await this.ensureConnected()
867
+ ): AwaitableEffect<z.infer<T>, SurrealDBError> {
868
+ return toAwaitableEffect(
869
+ Effect.gen(
870
+ function* (this: SurrealDBService) {
871
+ const recordId = yield* this.normalizeRecordIdEffect(id, table)
872
+ const keys = Object.keys(data)
873
+ if (keys.length === 0) {
874
+ return yield* new SurrealDBError({ message: `Cannot create record in ${table} with empty data` })
875
+ }
761
876
 
762
- try {
763
- const created = await client.create<unknown>(recordId).content(this.normalizeMutationData(data)).output('after')
764
- return this.parseSchema(schema, created)
765
- } catch (error) {
766
- return this.toSurrealError(error, `CREATE ${recordId.toString()}`)
767
- }
877
+ const client = yield* this.ensureConnectedEffect(`CREATE ${recordId.toString()}`)
878
+ const normalizedData = yield* this.normalizeMutationDataEffect(data)
879
+ const created = yield* Effect.tryPromise({
880
+ try: () => client.create<unknown>(recordId).content(normalizedData).output('after'),
881
+ catch: (error) =>
882
+ new SurrealDBError({
883
+ message: `Failed to create record in ${table}: ${getErrorMessage(error)}`,
884
+ cause: error,
885
+ }),
886
+ })
887
+ return yield* this.parseSchemaEffect(schema, created)
888
+ }.bind(this),
889
+ ),
890
+ )
768
891
  }
769
892
 
770
- async update<T extends z.ZodTypeAny>(
893
+ update<T extends z.ZodTypeAny>(
771
894
  table: DatabaseTable,
772
895
  id: unknown,
773
896
  data: Record<string, unknown>,
774
897
  schema: T,
775
898
  options?: { mutation?: RecordMutation },
776
- ): Promise<z.infer<T> | null> {
777
- const recordId = this.normalizeRecordId(id, table)
778
-
779
- const keys = Object.keys(data)
780
- if (keys.length === 0) {
781
- throw new SurrealDBError('Cannot update record with empty data')
782
- }
783
-
784
- const client = await this.ensureConnected()
785
- const mutation = options?.mutation ?? 'merge'
899
+ ): AwaitableEffect<z.infer<T> | null, SurrealDBError> {
900
+ return toAwaitableEffect(
901
+ Effect.gen(
902
+ function* (this: SurrealDBService) {
903
+ const recordId = yield* this.normalizeRecordIdEffect(id, table)
904
+ const keys = Object.keys(data)
905
+ if (keys.length === 0) {
906
+ return yield* new SurrealDBError({ message: 'Cannot update record with empty data' })
907
+ }
786
908
 
787
- try {
788
- const builder = client.update<unknown>(recordId)
789
- const configured = configureMutation(builder, mutation, this.normalizeMutationData(data))
790
- const updated = await configured.output('after')
791
- return updated ? this.parseSchema(schema, updated) : null
792
- } catch (error) {
793
- return this.toSurrealError(error, `UPDATE ${recordId.toString()}`)
794
- }
909
+ const mutation = options?.mutation ?? 'merge'
910
+ const client = yield* this.ensureConnectedEffect(`UPDATE ${recordId.toString()}`)
911
+ const builder = client.update<unknown>(recordId)
912
+ const normalizedData = yield* this.normalizeMutationDataEffect(data)
913
+ const updated: Record<string, unknown> | null | undefined = yield* Effect.tryPromise({
914
+ try: () => configureMutation(builder, mutation, normalizedData).output('after'),
915
+ catch: (error) =>
916
+ new SurrealDBError({
917
+ message: `Failed to update record in ${table}: ${getErrorMessage(error)}`,
918
+ cause: error,
919
+ }),
920
+ })
921
+ return yield* this.parseOptionalSchemaEffect(schema, updated)
922
+ }.bind(this),
923
+ ),
924
+ )
795
925
  }
796
926
 
797
- async upsert<T extends z.ZodTypeAny>(
927
+ upsert<T extends z.ZodTypeAny>(
798
928
  table: DatabaseTable,
799
929
  id: unknown,
800
930
  data: Record<string, unknown>,
801
931
  schema: T,
802
932
  options?: { mutation?: RecordMutation },
803
- ): Promise<z.infer<T>> {
804
- const recordId = this.normalizeRecordId(id, table)
805
- const keys = Object.keys(data)
806
- if (keys.length === 0) {
807
- throw new SurrealDBError('Cannot upsert record with empty data')
808
- }
933
+ ): AwaitableEffect<z.infer<T>, SurrealDBError> {
934
+ return toAwaitableEffect(
935
+ Effect.gen(
936
+ function* (this: SurrealDBService) {
937
+ const recordId = yield* this.normalizeRecordIdEffect(id, table)
938
+ const keys = Object.keys(data)
939
+ if (keys.length === 0) {
940
+ return yield* new SurrealDBError({ message: 'Cannot upsert record with empty data' })
941
+ }
809
942
 
810
- const client = await this.ensureConnected()
811
- const mutation = options?.mutation ?? 'merge'
812
-
813
- try {
814
- const builder = client.upsert<unknown>(recordId)
815
- const configured = configureMutation(builder, mutation, this.normalizeMutationData(data))
816
- const upserted = await configured.output('after')
817
- if (!upserted) {
818
- throw new SurrealDBError(`Failed to upsert record in ${table}`)
819
- }
820
- return this.parseSchema(schema, upserted)
821
- } catch (error) {
822
- return this.toSurrealError(error, `UPSERT ${recordId.toString()}`)
823
- }
943
+ const mutation = options?.mutation ?? 'merge'
944
+ const client = yield* this.ensureConnectedEffect(`UPSERT ${recordId.toString()}`)
945
+ const builder = client.upsert<unknown>(recordId)
946
+ const normalizedData = yield* this.normalizeMutationDataEffect(data)
947
+ const upserted: Record<string, unknown> | null | undefined = yield* Effect.tryPromise({
948
+ try: () => configureMutation(builder, mutation, normalizedData).output('after'),
949
+ catch: (error) =>
950
+ new SurrealDBError({
951
+ message: `Failed to upsert record in ${table}: ${getErrorMessage(error)}`,
952
+ cause: error,
953
+ }),
954
+ })
955
+ const parsed = yield* this.parseOptionalSchemaEffect(schema, upserted)
956
+ if (parsed === null) {
957
+ return yield* new SurrealDBError({ message: `Failed to upsert record in ${table}` })
958
+ }
959
+ return parsed
960
+ }.bind(this),
961
+ ),
962
+ )
824
963
  }
825
964
 
826
- async deleteById(table: DatabaseTable, id: unknown): Promise<boolean> {
827
- const recordId = this.normalizeRecordId(id, table)
828
-
829
- try {
830
- const result = await this.query<unknown>(new BoundQuery(`DELETE $recordId RETURN BEFORE`, { recordId }))
831
- return result.length > 0
832
- } catch (error) {
833
- if (error instanceof Error && error.message.includes('does not exist')) {
834
- return false
835
- }
836
- return this.toSurrealError(error, `DELETE ${recordId.toString()}`)
837
- }
965
+ deleteById(table: DatabaseTable, id: unknown): AwaitableEffect<boolean, SurrealDBError> {
966
+ return toAwaitableEffect(
967
+ Effect.gen(
968
+ function* (this: SurrealDBService) {
969
+ const recordId = yield* this.normalizeRecordIdEffect(id, table)
970
+ return yield* this.query<unknown>(new BoundQuery(`DELETE $recordId RETURN BEFORE`, { recordId })).pipe(
971
+ Effect.map((result) => result.length > 0),
972
+ Effect.catchTag('SurrealDBError', (error) => {
973
+ if (error.message.includes('does not exist')) {
974
+ return Effect.succeed(false)
975
+ }
976
+
977
+ return Effect.fail(this.toSurrealError(error, `DELETE ${recordId.toString()}`))
978
+ }),
979
+ )
980
+ }.bind(this),
981
+ ),
982
+ )
838
983
  }
839
984
 
840
- async deleteWhere(table: DatabaseTable, filter: Record<string, unknown>): Promise<number> {
841
- this.assertValidIdentifier(table, 'table name')
842
- const filterKeys = Object.keys(filter)
843
- if (filterKeys.length === 0) {
844
- throw new SurrealDBError(`Refusing to delete all records in ${table} without a filter`)
845
- }
846
- const { clause, bindings } = this.buildBoundFilterClauses(filter)
847
-
848
- try {
849
- return await this.withTransaction(async (tx) => {
850
- const matched = (await tx.query(new BoundQuery(`SELECT id FROM ${table} WHERE ${clause}`, bindings))) as Array<{
851
- id: unknown
852
- }>
853
-
854
- if (matched.length === 0) {
855
- return 0
856
- }
857
-
858
- for (const row of matched) {
859
- await tx.delete(this.normalizeRecordId(row.id, table))
860
- }
861
-
862
- return matched.length
863
- })
864
- } catch (error) {
865
- return this.toSurrealError(error, `DELETE ${table} WHERE ...`)
866
- }
985
+ deleteWhere(table: DatabaseTable, filter: Record<string, unknown>): AwaitableEffect<number, SurrealDBError> {
986
+ return toAwaitableEffect(
987
+ Effect.gen(
988
+ function* (this: SurrealDBService) {
989
+ yield* this.assertValidIdentifierEffect(table, 'table name')
990
+ const filterKeys = Object.keys(filter)
991
+ if (filterKeys.length === 0) {
992
+ return yield* new SurrealDBError({ message: `Refusing to delete all records in ${table} without a filter` })
993
+ }
994
+ const { clause, bindings } = yield* this.buildBoundFilterClausesEffect(filter)
995
+ return yield* this.withTransaction((tx) =>
996
+ Effect.gen(
997
+ function* (this: SurrealDBService) {
998
+ const matchedRows = (yield* tx.query(
999
+ new BoundQuery(`SELECT id FROM ${table} WHERE ${clause}`, bindings),
1000
+ )) as Array<{ id: unknown }>
1001
+
1002
+ if (matchedRows.length === 0) {
1003
+ return 0
1004
+ }
1005
+
1006
+ for (const row of matchedRows) {
1007
+ const recordId = yield* this.normalizeRecordIdEffect(row.id, table)
1008
+ yield* tx.delete(recordId)
1009
+ }
1010
+
1011
+ return matchedRows.length
1012
+ }.bind(this),
1013
+ ),
1014
+ )
1015
+ }.bind(this),
1016
+ ),
1017
+ )
867
1018
  }
868
1019
 
869
- async updateWhere(table: DatabaseTable, where: ExprLike, data: Record<string, unknown>): Promise<number> {
870
- if (!where) {
871
- throw new SurrealDBError(`Refusing to update records in ${table} without a where clause`)
872
- }
873
- const keys = Object.keys(data)
874
- if (keys.length === 0) {
875
- throw new SurrealDBError(`Cannot update records in ${table} with empty data`)
876
- }
1020
+ updateWhere(
1021
+ table: DatabaseTable,
1022
+ where: ExprLike,
1023
+ data: Record<string, unknown>,
1024
+ ): AwaitableEffect<number, SurrealDBError> {
1025
+ return toAwaitableEffect(
1026
+ Effect.gen(
1027
+ function* (this: SurrealDBService) {
1028
+ if (!where) {
1029
+ return yield* new SurrealDBError({
1030
+ message: `Refusing to update records in ${table} without a where clause`,
1031
+ })
1032
+ }
1033
+ const keys = Object.keys(data)
1034
+ if (keys.length === 0) {
1035
+ return yield* new SurrealDBError({ message: `Cannot update records in ${table} with empty data` })
1036
+ }
877
1037
 
878
- const client = await this.ensureConnected()
879
-
880
- try {
881
- const updated = await client
882
- .update<unknown>(new Table(table))
883
- .where(where)
884
- .merge(this.normalizeMutationData(data))
885
- .output('after')
886
- if (Array.isArray(updated)) {
887
- return updated.length
888
- }
889
- return 1
890
- } catch (error) {
891
- return this.toSurrealError(error, `UPDATE ${table} WHERE ...`)
892
- }
1038
+ const client = yield* this.ensureConnectedEffect(`UPDATE ${table} WHERE ...`)
1039
+ const normalizedData = yield* this.normalizeMutationDataEffect(data)
1040
+ const updated = yield* Effect.tryPromise({
1041
+ try: () => client.update<unknown>(new Table(table)).where(where).merge(normalizedData).output('after'),
1042
+ catch: (error) =>
1043
+ new SurrealDBError({
1044
+ message: `Failed to update records in ${table}: ${getErrorMessage(error)}`,
1045
+ cause: error,
1046
+ }),
1047
+ })
1048
+ return Array.isArray(updated) ? updated.length : 1
1049
+ }.bind(this),
1050
+ ),
1051
+ )
893
1052
  }
894
1053
 
895
- async insert<T extends Record<string, unknown>>(
1054
+ insert<T extends Record<string, unknown>>(
896
1055
  table: DatabaseTable,
897
1056
  data: Values<T> | Array<Values<T>>,
898
- ): Promise<T[]> {
899
- const client = await this.ensureConnected()
900
-
901
- try {
902
- const normalized = Array.isArray(data)
903
- ? data.map((item) => this.normalizeMutationData(item as Record<string, unknown>))
904
- : this.normalizeMutationData(data as Record<string, unknown>)
905
- const inserted = await client
906
- .insert<T>(new Table(table), normalized as Values<T> | Array<Values<T>>)
907
- .output('after')
908
- return inserted as T[]
909
- } catch (error) {
910
- return this.toSurrealError(error, `INSERT ${table}`)
911
- }
1057
+ ): AwaitableEffect<T[], SurrealDBError> {
1058
+ return toAwaitableEffect(
1059
+ Effect.gen(
1060
+ function* (this: SurrealDBService) {
1061
+ const client = yield* this.ensureConnectedEffect(`INSERT ${table}`)
1062
+ const normalized = Array.isArray(data)
1063
+ ? yield* Effect.forEach(data, (item) => this.normalizeMutationDataEffect(item as Record<string, unknown>))
1064
+ : yield* this.normalizeMutationDataEffect(data as Record<string, unknown>)
1065
+ const inserted = yield* Effect.tryPromise({
1066
+ try: () => client.insert<T>(new Table(table), normalized as Values<T> | Array<Values<T>>).output('after'),
1067
+ catch: (error) =>
1068
+ new SurrealDBError({
1069
+ message: `Failed to insert rows into ${table}: ${getErrorMessage(error)}`,
1070
+ cause: error,
1071
+ }),
1072
+ })
1073
+ return inserted as T[]
1074
+ }.bind(this),
1075
+ ),
1076
+ )
912
1077
  }
913
1078
 
914
- async relate<T extends Record<string, unknown>>(
1079
+ relate<T extends Record<string, unknown>>(
915
1080
  from: RecordIdInput,
916
1081
  edgeTable: DatabaseTable,
917
1082
  to: RecordIdInput,
918
1083
  data?: Values<T>,
919
- ): Promise<T | null> {
920
- const client = await this.ensureConnected()
921
- const fromRef = ensureRecordId(from)
922
- const toRef = ensureRecordId(to)
923
-
924
- try {
925
- const normalizedData = data
926
- ? (this.normalizeMutationData(data as Record<string, unknown>) as Values<T>)
927
- : undefined
928
- const related = (await client.relate<T>(fromRef, new Table(edgeTable), toRef, normalizedData).output('after')) as
929
- | T
930
- | T[]
931
- | null
932
- if (related === null) {
933
- return null
934
- }
935
- if (Array.isArray(related)) {
936
- return related.at(0) ?? null
937
- }
938
- return related
939
- } catch (error) {
940
- return this.toSurrealError(error, `RELATE ${fromRef.toString()}->${edgeTable}->${toRef.toString()}`)
941
- }
1084
+ ): AwaitableEffect<T | null, SurrealDBError> {
1085
+ return toAwaitableEffect(
1086
+ Effect.gen(
1087
+ function* (this: SurrealDBService) {
1088
+ const fromRef = yield* ensureRecordIdEffect(from).pipe(Effect.mapError((error) => this.toSurrealError(error)))
1089
+ const toRef = yield* ensureRecordIdEffect(to).pipe(Effect.mapError((error) => this.toSurrealError(error)))
1090
+ const client = yield* this.ensureConnectedEffect(
1091
+ `RELATE ${fromRef.toString()}->${edgeTable}->${toRef.toString()}`,
1092
+ )
1093
+ const normalizedData = data
1094
+ ? ((yield* this.normalizeMutationDataEffect(data as Record<string, unknown>)) as Values<T>)
1095
+ : undefined
1096
+ const related = (yield* Effect.tryPromise({
1097
+ try: () => client.relate<T>(fromRef, new Table(edgeTable), toRef, normalizedData).output('after'),
1098
+ catch: (error) =>
1099
+ new SurrealDBError({
1100
+ message: `Failed to relate records ${fromRef.toString()} -> ${edgeTable} -> ${toRef.toString()}: ${getErrorMessage(error)}`,
1101
+ cause: error,
1102
+ }),
1103
+ })) as T | T[] | null
1104
+ if (related === null) {
1105
+ return null
1106
+ }
1107
+ if (Array.isArray(related)) {
1108
+ return related.at(0) ?? null
1109
+ }
1110
+ return related
1111
+ }.bind(this),
1112
+ ),
1113
+ )
942
1114
  }
943
1115
 
944
- async beginTransaction(): Promise<DatabaseTransaction> {
945
- const client = await this.ensureConnected()
946
- try {
947
- return this.wrapTransaction(await client.beginTransaction())
948
- } catch (error) {
949
- return this.toSurrealError(error, 'BEGIN TRANSACTION')
950
- }
1116
+ beginTransaction(): AwaitableEffect<DatabaseTransaction, SurrealDBError> {
1117
+ return toAwaitableEffect(
1118
+ Effect.gen(
1119
+ function* (this: SurrealDBService) {
1120
+ const client = yield* this.ensureConnectedEffect('BEGIN TRANSACTION')
1121
+ const tx = yield* Effect.tryPromise({
1122
+ try: () => client.beginTransaction(),
1123
+ catch: (error) =>
1124
+ new SurrealDBError({ message: `Failed to begin transaction: ${getErrorMessage(error)}`, cause: error }),
1125
+ })
1126
+ return this.wrapTransaction(tx)
1127
+ }.bind(this),
1128
+ ),
1129
+ )
951
1130
  }
952
1131
 
953
- async withTransaction<T>(work: (tx: DatabaseTransaction) => Promise<T>): Promise<T> {
954
- const tx = await this.beginTransaction()
955
- try {
956
- const result = await work(tx)
957
- await tx.commit()
958
- return result
959
- } catch (error) {
960
- try {
961
- await tx.cancel()
962
- } catch (cancelError) {
963
- this.logger?.warn?.(
964
- `Failed to cancel transaction after error: ${
965
- cancelError instanceof Error ? cancelError.message : String(cancelError)
966
- }`,
1132
+ withTransaction<T, E, R>(
1133
+ work: (tx: DatabaseTransaction) => Effect.Effect<T, E, R>,
1134
+ ): Effect.Effect<T, SurrealDBError | E, R> {
1135
+ return Effect.gen(
1136
+ function* (this: SurrealDBService) {
1137
+ const tx = yield* this.beginTransaction()
1138
+ const cancelEffect = tx.cancel().pipe(
1139
+ Effect.catch((cancelError: SurrealDBError) =>
1140
+ Effect.sync(() => {
1141
+ this.logger?.warn?.(`Failed to cancel transaction after error: ${cancelError.message}`)
1142
+ }),
1143
+ ),
1144
+ Effect.asVoid,
967
1145
  )
968
- }
969
- throw error
970
- }
971
- }
972
- }
973
1146
 
974
- let currentDatabaseService: SurrealDBService | undefined
975
- const databaseServiceOverrides = new Map<PropertyKey, unknown>()
976
-
977
- function bindTargetMethod(
978
- target: Record<PropertyKey, unknown>,
979
- value: (...args: unknown[]) => unknown,
980
- ): (...args: unknown[]) => unknown {
981
- return (...args) => Reflect.apply(value, target, args)
982
- }
983
-
984
- function resolveConfiguredDatabaseService(): SurrealDBService {
985
- if (!currentDatabaseService) {
986
- throw new SurrealDBError('Database service not configured')
987
- }
988
-
989
- return currentDatabaseService
990
- }
991
-
992
- export const databaseService = new Proxy({} as SurrealDBService, {
993
- get(_target, property) {
994
- if (databaseServiceOverrides.has(property)) {
995
- return databaseServiceOverrides.get(property)
996
- }
997
-
998
- const resolved = resolveConfiguredDatabaseService()
999
- const value: unknown = Reflect.get(resolved, property)
1000
- if (typeof value === 'function') {
1001
- // SurrealDB SDK type gap — Reflect.get returns unknown, but we know the resolved target shape
1002
- return bindTargetMethod(
1003
- resolved as unknown as Record<PropertyKey, unknown>,
1004
- value as (...args: unknown[]) => unknown,
1005
- )
1006
- }
1007
-
1008
- return value
1009
- },
1010
- set(_target, property, value) {
1011
- databaseServiceOverrides.set(property, value)
1012
- return true
1013
- },
1014
- })
1015
-
1016
- export function setDatabaseService(db: SurrealDBService): void {
1017
- if (db === databaseService) {
1018
- return
1147
+ return yield* Effect.gen(function* () {
1148
+ const result = yield* work(tx)
1149
+ yield* tx.commit()
1150
+ return result
1151
+ }).pipe(Effect.onError(() => cancelEffect))
1152
+ }.bind(this),
1153
+ )
1019
1154
  }
1020
-
1021
- currentDatabaseService = db
1022
1155
  }