coco-framework 0.1.0__py3-none-any.whl

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 (400) hide show
  1. coco/__init__.py +3 -0
  2. coco/agents/__init__.py +9 -0
  3. coco/agents/_subagent_transformer.py +307 -0
  4. coco/agents/factory.py +2164 -0
  5. coco/agents/middleware/__init__.py +100 -0
  6. coco/agents/middleware/_execution.py +385 -0
  7. coco/agents/middleware/_redaction.py +454 -0
  8. coco/agents/middleware/_retry.py +134 -0
  9. coco/agents/middleware/_trace_policy.py +61 -0
  10. coco/agents/middleware/context_editing.py +306 -0
  11. coco/agents/middleware/file_search.py +433 -0
  12. coco/agents/middleware/human_in_the_loop.py +680 -0
  13. coco/agents/middleware/internal_call_transformer.py +108 -0
  14. coco/agents/middleware/model_call_limit.py +267 -0
  15. coco/agents/middleware/model_fallback.py +417 -0
  16. coco/agents/middleware/model_retry.py +327 -0
  17. coco/agents/middleware/pii.py +878 -0
  18. coco/agents/middleware/provider_tool_search.py +307 -0
  19. coco/agents/middleware/shell_tool.py +949 -0
  20. coco/agents/middleware/summarization.py +910 -0
  21. coco/agents/middleware/todo.py +357 -0
  22. coco/agents/middleware/tool_call_limit.py +495 -0
  23. coco/agents/middleware/tool_emulator.py +251 -0
  24. coco/agents/middleware/tool_error.py +209 -0
  25. coco/agents/middleware/tool_retry.py +421 -0
  26. coco/agents/middleware/tool_selection.py +515 -0
  27. coco/agents/middleware/types.py +2201 -0
  28. coco/agents/structured_output.py +472 -0
  29. coco/chat_models/__init__.py +7 -0
  30. coco/chat_models/base.py +1082 -0
  31. coco/embeddings/__init__.py +18 -0
  32. coco/embeddings/base.py +279 -0
  33. coco/mcp/__init__.py +36 -0
  34. coco/mcp/adapter.py +245 -0
  35. coco/mcp/elicitation.py +394 -0
  36. coco/mcp/tools.py +307 -0
  37. coco/messages/__init__.py +73 -0
  38. coco/py.typed +0 -0
  39. coco/rate_limiters/__init__.py +13 -0
  40. coco/tools/__init__.py +22 -0
  41. coco/tools/tool_node.py +20 -0
  42. coco_anthropic/__init__.py +15 -0
  43. coco_anthropic/_client_utils.py +81 -0
  44. coco_anthropic/_compat.py +228 -0
  45. coco_anthropic/_sdk_compat.py +124 -0
  46. coco_anthropic/_version.py +3 -0
  47. coco_anthropic/chat_models.py +3349 -0
  48. coco_anthropic/data/__init__.py +1 -0
  49. coco_anthropic/data/_profiles.py +491 -0
  50. coco_anthropic/data/profile_augmentations.toml +69 -0
  51. coco_anthropic/experimental.py +140 -0
  52. coco_anthropic/llms.py +428 -0
  53. coco_anthropic/middleware/__init__.py +25 -0
  54. coco_anthropic/middleware/anthropic_tools.py +1197 -0
  55. coco_anthropic/middleware/bash.py +84 -0
  56. coco_anthropic/middleware/file_search.py +360 -0
  57. coco_anthropic/middleware/prompt_caching.py +276 -0
  58. coco_anthropic/output_parsers.py +93 -0
  59. coco_anthropic/py.typed +0 -0
  60. coco_core/__init__.py +20 -0
  61. coco_core/_api/__init__.py +87 -0
  62. coco_core/_api/beta_decorator.py +266 -0
  63. coco_core/_api/deprecation.py +637 -0
  64. coco_core/_api/internal.py +23 -0
  65. coco_core/_api/path.py +50 -0
  66. coco_core/_import_utils.py +41 -0
  67. coco_core/_protocol.py +625 -0
  68. coco_core/_security/__init__.py +36 -0
  69. coco_core/_security/_exceptions.py +9 -0
  70. coco_core/_security/_policy.py +313 -0
  71. coco_core/_security/_ssrf_protection.py +155 -0
  72. coco_core/_security/_transport.py +254 -0
  73. coco_core/agents.py +258 -0
  74. coco_core/caches.py +272 -0
  75. coco_core/callbacks/__init__.py +132 -0
  76. coco_core/callbacks/base.py +1229 -0
  77. coco_core/callbacks/file.py +266 -0
  78. coco_core/callbacks/manager.py +2826 -0
  79. coco_core/callbacks/stdout.py +123 -0
  80. coco_core/callbacks/streaming_stdout.py +154 -0
  81. coco_core/callbacks/usage.py +124 -0
  82. coco_core/chat_history.py +267 -0
  83. coco_core/chat_loaders.py +26 -0
  84. coco_core/chat_sessions.py +19 -0
  85. coco_core/cross_encoders.py +18 -0
  86. coco_core/document_loaders/__init__.py +36 -0
  87. coco_core/document_loaders/base.py +155 -0
  88. coco_core/document_loaders/blob_loaders.py +38 -0
  89. coco_core/documents/__init__.py +55 -0
  90. coco_core/documents/base.py +347 -0
  91. coco_core/documents/compressor.py +74 -0
  92. coco_core/documents/transformers.py +79 -0
  93. coco_core/embeddings/__init__.py +31 -0
  94. coco_core/embeddings/embeddings.py +78 -0
  95. coco_core/embeddings/fake.py +129 -0
  96. coco_core/env.py +22 -0
  97. coco_core/example_selectors/__init__.py +47 -0
  98. coco_core/example_selectors/base.py +60 -0
  99. coco_core/example_selectors/length_based.py +131 -0
  100. coco_core/example_selectors/semantic_similarity.py +366 -0
  101. coco_core/exceptions.py +166 -0
  102. coco_core/globals.py +72 -0
  103. coco_core/indexing/__init__.py +53 -0
  104. coco_core/indexing/api.py +954 -0
  105. coco_core/indexing/base.py +661 -0
  106. coco_core/indexing/in_memory.py +104 -0
  107. coco_core/language_models/__init__.py +116 -0
  108. coco_core/language_models/_compat_bridge.py +844 -0
  109. coco_core/language_models/_utils.py +343 -0
  110. coco_core/language_models/base.py +500 -0
  111. coco_core/language_models/chat_model_stream.py +1525 -0
  112. coco_core/language_models/chat_models.py +2748 -0
  113. coco_core/language_models/fake.py +137 -0
  114. coco_core/language_models/fake_chat_models.py +396 -0
  115. coco_core/language_models/llms.py +1569 -0
  116. coco_core/language_models/model_profile.py +183 -0
  117. coco_core/load/__init__.py +44 -0
  118. coco_core/load/_validation.py +191 -0
  119. coco_core/load/dump.py +120 -0
  120. coco_core/load/load.py +828 -0
  121. coco_core/load/mapping.py +1097 -0
  122. coco_core/load/serializable.py +406 -0
  123. coco_core/messages/__init__.py +198 -0
  124. coco_core/messages/ai.py +854 -0
  125. coco_core/messages/base.py +518 -0
  126. coco_core/messages/block_translators/__init__.py +112 -0
  127. coco_core/messages/block_translators/anthropic.py +534 -0
  128. coco_core/messages/block_translators/bedrock.py +108 -0
  129. coco_core/messages/block_translators/bedrock_converse.py +329 -0
  130. coco_core/messages/block_translators/coco_v0.py +303 -0
  131. coco_core/messages/block_translators/google_genai.py +610 -0
  132. coco_core/messages/block_translators/google_vertexai.py +21 -0
  133. coco_core/messages/block_translators/groq.py +157 -0
  134. coco_core/messages/block_translators/openai.py +1089 -0
  135. coco_core/messages/chat.py +64 -0
  136. coco_core/messages/content.py +1488 -0
  137. coco_core/messages/function.py +62 -0
  138. coco_core/messages/human.py +70 -0
  139. coco_core/messages/modifier.py +33 -0
  140. coco_core/messages/system.py +70 -0
  141. coco_core/messages/tool.py +418 -0
  142. coco_core/messages/utils.py +2442 -0
  143. coco_core/output_parsers/__init__.py +101 -0
  144. coco_core/output_parsers/base.py +361 -0
  145. coco_core/output_parsers/format_instructions.py +16 -0
  146. coco_core/output_parsers/json.py +139 -0
  147. coco_core/output_parsers/list.py +249 -0
  148. coco_core/output_parsers/openai_functions.py +324 -0
  149. coco_core/output_parsers/openai_tools.py +383 -0
  150. coco_core/output_parsers/pydantic.py +141 -0
  151. coco_core/output_parsers/string.py +63 -0
  152. coco_core/output_parsers/transform.py +175 -0
  153. coco_core/output_parsers/xml.py +301 -0
  154. coco_core/outputs/__init__.py +66 -0
  155. coco_core/outputs/chat_generation.py +147 -0
  156. coco_core/outputs/chat_result.py +39 -0
  157. coco_core/outputs/generation.py +80 -0
  158. coco_core/outputs/llm_result.py +112 -0
  159. coco_core/outputs/run_info.py +22 -0
  160. coco_core/prompt_values.py +161 -0
  161. coco_core/prompts/__init__.py +101 -0
  162. coco_core/prompts/base.py +508 -0
  163. coco_core/prompts/chat.py +1495 -0
  164. coco_core/prompts/dict.py +177 -0
  165. coco_core/prompts/few_shot.py +483 -0
  166. coco_core/prompts/few_shot_with_templates.py +239 -0
  167. coco_core/prompts/image.py +177 -0
  168. coco_core/prompts/loading.py +296 -0
  169. coco_core/prompts/message.py +97 -0
  170. coco_core/prompts/prompt.py +312 -0
  171. coco_core/prompts/string.py +399 -0
  172. coco_core/prompts/structured.py +214 -0
  173. coco_core/py.typed +0 -0
  174. coco_core/rate_limiters.py +254 -0
  175. coco_core/retrievers.py +328 -0
  176. coco_core/runnables/__init__.py +136 -0
  177. coco_core/runnables/base.py +6725 -0
  178. coco_core/runnables/branch.py +457 -0
  179. coco_core/runnables/config.py +712 -0
  180. coco_core/runnables/configurable.py +710 -0
  181. coco_core/runnables/fallbacks.py +663 -0
  182. coco_core/runnables/graph.py +741 -0
  183. coco_core/runnables/graph_ascii.py +366 -0
  184. coco_core/runnables/graph_mermaid.py +503 -0
  185. coco_core/runnables/graph_png.py +215 -0
  186. coco_core/runnables/history.py +631 -0
  187. coco_core/runnables/passthrough.py +849 -0
  188. coco_core/runnables/retry.py +379 -0
  189. coco_core/runnables/router.py +239 -0
  190. coco_core/runnables/schema.py +188 -0
  191. coco_core/runnables/utils.py +798 -0
  192. coco_core/stores.py +291 -0
  193. coco_core/structured_query.py +203 -0
  194. coco_core/sys_info.py +154 -0
  195. coco_core/tools/__init__.py +95 -0
  196. coco_core/tools/base.py +1948 -0
  197. coco_core/tools/convert.py +497 -0
  198. coco_core/tools/render.py +67 -0
  199. coco_core/tools/retriever.py +94 -0
  200. coco_core/tools/simple.py +208 -0
  201. coco_core/tools/structured.py +322 -0
  202. coco_core/tracers/__init__.py +47 -0
  203. coco_core/tracers/_compat.py +95 -0
  204. coco_core/tracers/_context.py +337 -0
  205. coco_core/tracers/_streaming.py +53 -0
  206. coco_core/tracers/base.py +955 -0
  207. coco_core/tracers/coco.py +279 -0
  208. coco_core/tracers/context.py +205 -0
  209. coco_core/tracers/core.py +766 -0
  210. coco_core/tracers/event_stream.py +1101 -0
  211. coco_core/tracers/log_stream.py +765 -0
  212. coco_core/tracers/memory_stream.py +200 -0
  213. coco_core/tracers/root_listeners.py +130 -0
  214. coco_core/tracers/run_collector.py +40 -0
  215. coco_core/tracers/schemas.py +14 -0
  216. coco_core/tracers/stdout.py +205 -0
  217. coco_core/utils/__init__.py +111 -0
  218. coco_core/utils/_gateway.py +274 -0
  219. coco_core/utils/_merge.py +218 -0
  220. coco_core/utils/aiter.py +357 -0
  221. coco_core/utils/env.py +86 -0
  222. coco_core/utils/formatting.py +80 -0
  223. coco_core/utils/function_calling.py +869 -0
  224. coco_core/utils/html.py +132 -0
  225. coco_core/utils/image.py +15 -0
  226. coco_core/utils/input.py +82 -0
  227. coco_core/utils/interactive_env.py +12 -0
  228. coco_core/utils/iter.py +229 -0
  229. coco_core/utils/json.py +230 -0
  230. coco_core/utils/json_schema.py +273 -0
  231. coco_core/utils/mustache.py +706 -0
  232. coco_core/utils/pydantic.py +631 -0
  233. coco_core/utils/strings.py +69 -0
  234. coco_core/utils/usage.py +61 -0
  235. coco_core/utils/utils.py +528 -0
  236. coco_core/utils/uuid.py +57 -0
  237. coco_core/vectorstores/__init__.py +53 -0
  238. coco_core/vectorstores/base.py +1111 -0
  239. coco_core/vectorstores/in_memory.py +546 -0
  240. coco_core/vectorstores/utils.py +163 -0
  241. coco_core/version.py +3 -0
  242. coco_framework-0.1.0.dist-info/METADATA +184 -0
  243. coco_framework-0.1.0.dist-info/RECORD +400 -0
  244. coco_framework-0.1.0.dist-info/WHEEL +4 -0
  245. coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
  246. coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
  247. coco_graph/_internal/__init__.py +4 -0
  248. coco_graph/_internal/_cache.py +31 -0
  249. coco_graph/_internal/_config.py +474 -0
  250. coco_graph/_internal/_constants.py +140 -0
  251. coco_graph/_internal/_fields.py +213 -0
  252. coco_graph/_internal/_future.py +220 -0
  253. coco_graph/_internal/_pydantic.py +275 -0
  254. coco_graph/_internal/_queue.py +124 -0
  255. coco_graph/_internal/_replay.py +90 -0
  256. coco_graph/_internal/_retry.py +29 -0
  257. coco_graph/_internal/_runnable.py +969 -0
  258. coco_graph/_internal/_scratchpad.py +19 -0
  259. coco_graph/_internal/_serde.py +253 -0
  260. coco_graph/_internal/_timeout.py +25 -0
  261. coco_graph/_internal/_typing.py +54 -0
  262. coco_graph/cache/base/__init__.py +48 -0
  263. coco_graph/cache/base/py.typed +0 -0
  264. coco_graph/cache/memory/__init__.py +73 -0
  265. coco_graph/cache/redis/__init__.py +144 -0
  266. coco_graph/cache/sqlite/__init__.py +120 -0
  267. coco_graph/callbacks.py +394 -0
  268. coco_graph/channels/__init__.py +29 -0
  269. coco_graph/channels/any_value.py +72 -0
  270. coco_graph/channels/base.py +121 -0
  271. coco_graph/channels/binop.py +155 -0
  272. coco_graph/channels/delta.py +202 -0
  273. coco_graph/channels/ephemeral_value.py +79 -0
  274. coco_graph/channels/last_value.py +151 -0
  275. coco_graph/channels/named_barrier_value.py +167 -0
  276. coco_graph/channels/topic.py +94 -0
  277. coco_graph/channels/untracked_value.py +73 -0
  278. coco_graph/checkpoint/base/__init__.py +861 -0
  279. coco_graph/checkpoint/base/id.py +109 -0
  280. coco_graph/checkpoint/base/py.typed +0 -0
  281. coco_graph/checkpoint/memory/__init__.py +698 -0
  282. coco_graph/checkpoint/memory/py.typed +0 -0
  283. coco_graph/checkpoint/postgres/__init__.py +602 -0
  284. coco_graph/checkpoint/postgres/_ainternal.py +23 -0
  285. coco_graph/checkpoint/postgres/_internal.py +21 -0
  286. coco_graph/checkpoint/postgres/aio.py +691 -0
  287. coco_graph/checkpoint/postgres/base.py +666 -0
  288. coco_graph/checkpoint/postgres/py.typed +0 -0
  289. coco_graph/checkpoint/postgres/shallow.py +967 -0
  290. coco_graph/checkpoint/serde/__init__.py +0 -0
  291. coco_graph/checkpoint/serde/_msgpack.py +97 -0
  292. coco_graph/checkpoint/serde/base.py +64 -0
  293. coco_graph/checkpoint/serde/encrypted.py +80 -0
  294. coco_graph/checkpoint/serde/event_hooks.py +52 -0
  295. coco_graph/checkpoint/serde/jsonplus.py +883 -0
  296. coco_graph/checkpoint/serde/py.typed +0 -0
  297. coco_graph/checkpoint/serde/types.py +68 -0
  298. coco_graph/checkpoint/sqlite/__init__.py +645 -0
  299. coco_graph/checkpoint/sqlite/_delta.py +172 -0
  300. coco_graph/checkpoint/sqlite/aio.py +738 -0
  301. coco_graph/checkpoint/sqlite/py.typed +0 -0
  302. coco_graph/checkpoint/sqlite/utils.py +116 -0
  303. coco_graph/config.py +196 -0
  304. coco_graph/constants.py +64 -0
  305. coco_graph/errors.py +241 -0
  306. coco_graph/func/__init__.py +620 -0
  307. coco_graph/graph/__init__.py +12 -0
  308. coco_graph/graph/_branch.py +225 -0
  309. coco_graph/graph/_node.py +103 -0
  310. coco_graph/graph/message.py +437 -0
  311. coco_graph/graph/state.py +1978 -0
  312. coco_graph/graph/ui.py +227 -0
  313. coco_graph/managed/__init__.py +3 -0
  314. coco_graph/managed/base.py +31 -0
  315. coco_graph/managed/is_last_step.py +24 -0
  316. coco_graph/prebuilt/__init__.py +23 -0
  317. coco_graph/prebuilt/_tool_call_stream.py +117 -0
  318. coco_graph/prebuilt/_tool_call_transformer.py +165 -0
  319. coco_graph/prebuilt/chat_agent_executor.py +1015 -0
  320. coco_graph/prebuilt/interrupt.py +105 -0
  321. coco_graph/prebuilt/py.typed +0 -0
  322. coco_graph/prebuilt/tool_node.py +2030 -0
  323. coco_graph/prebuilt/tool_validator.py +221 -0
  324. coco_graph/pregel/__init__.py +3 -0
  325. coco_graph/pregel/_algo.py +1460 -0
  326. coco_graph/pregel/_call.py +298 -0
  327. coco_graph/pregel/_checkpoint.py +331 -0
  328. coco_graph/pregel/_config.py +0 -0
  329. coco_graph/pregel/_draw.py +294 -0
  330. coco_graph/pregel/_executor.py +223 -0
  331. coco_graph/pregel/_io.py +174 -0
  332. coco_graph/pregel/_log.py +3 -0
  333. coco_graph/pregel/_loop.py +1988 -0
  334. coco_graph/pregel/_messages.py +461 -0
  335. coco_graph/pregel/_read.py +312 -0
  336. coco_graph/pregel/_retry.py +854 -0
  337. coco_graph/pregel/_runner.py +941 -0
  338. coco_graph/pregel/_tools.py +268 -0
  339. coco_graph/pregel/_utils.py +221 -0
  340. coco_graph/pregel/_validate.py +120 -0
  341. coco_graph/pregel/_write.py +192 -0
  342. coco_graph/pregel/debug.py +302 -0
  343. coco_graph/pregel/main.py +4364 -0
  344. coco_graph/pregel/protocol.py +288 -0
  345. coco_graph/pregel/types.py +38 -0
  346. coco_graph/py.typed +0 -0
  347. coco_graph/runtime.py +350 -0
  348. coco_graph/store/base/__init__.py +1322 -0
  349. coco_graph/store/base/batch.py +371 -0
  350. coco_graph/store/base/embed.py +436 -0
  351. coco_graph/store/base/py.typed +0 -0
  352. coco_graph/store/memory/__init__.py +592 -0
  353. coco_graph/store/memory/py.typed +0 -0
  354. coco_graph/store/postgres/__init__.py +4 -0
  355. coco_graph/store/postgres/aio.py +592 -0
  356. coco_graph/store/postgres/base.py +1480 -0
  357. coco_graph/store/postgres/py.typed +0 -0
  358. coco_graph/store/sqlite/__init__.py +4 -0
  359. coco_graph/store/sqlite/aio.py +632 -0
  360. coco_graph/store/sqlite/base.py +1514 -0
  361. coco_graph/stream/__init__.py +45 -0
  362. coco_graph/stream/_convert.py +32 -0
  363. coco_graph/stream/_mux.py +523 -0
  364. coco_graph/stream/_types.py +330 -0
  365. coco_graph/stream/run_stream.py +735 -0
  366. coco_graph/stream/stream_channel.py +341 -0
  367. coco_graph/stream/transformers.py +1039 -0
  368. coco_graph/types.py +1079 -0
  369. coco_graph/typing.py +48 -0
  370. coco_graph/utils/__init__.py +1 -0
  371. coco_graph/utils/config.py +4 -0
  372. coco_graph/utils/runnable.py +3 -0
  373. coco_graph/version.py +13 -0
  374. coco_graph/warnings.py +69 -0
  375. coco_openai/__init__.py +20 -0
  376. coco_openai/_compat.py +45 -0
  377. coco_openai/_version.py +3 -0
  378. coco_openai/chat_models/__init__.py +6 -0
  379. coco_openai/chat_models/_client_utils.py +684 -0
  380. coco_openai/chat_models/_compat.py +525 -0
  381. coco_openai/chat_models/azure.py +1227 -0
  382. coco_openai/chat_models/base.py +5654 -0
  383. coco_openai/chat_models/codex.py +557 -0
  384. coco_openai/chatgpt_oauth.py +1063 -0
  385. coco_openai/data/__init__.py +1 -0
  386. coco_openai/data/_profiles.py +1655 -0
  387. coco_openai/data/profile_augmentations.toml +127 -0
  388. coco_openai/embeddings/__init__.py +6 -0
  389. coco_openai/embeddings/azure.py +240 -0
  390. coco_openai/embeddings/base.py +820 -0
  391. coco_openai/llms/__init__.py +6 -0
  392. coco_openai/llms/azure.py +249 -0
  393. coco_openai/llms/base.py +919 -0
  394. coco_openai/middleware/__init__.py +11 -0
  395. coco_openai/middleware/openai_moderation.py +484 -0
  396. coco_openai/output_parsers/__init__.py +9 -0
  397. coco_openai/output_parsers/tools.py +9 -0
  398. coco_openai/py.typed +0 -0
  399. coco_openai/tools/__init__.py +5 -0
  400. coco_openai/tools/custom_tool.py +111 -0
@@ -0,0 +1,1514 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import datetime
5
+ import logging
6
+ import re
7
+ import sqlite3
8
+ import threading
9
+ from collections import defaultdict
10
+ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
11
+ from contextlib import contextmanager
12
+ from typing import Any, Literal, NamedTuple, cast
13
+
14
+ import orjson
15
+ import sqlite_vec # type: ignore[import-untyped]
16
+ from coco_graph.store.base import (
17
+ BaseStore,
18
+ GetOp,
19
+ IndexConfig,
20
+ Item,
21
+ ListNamespacesOp,
22
+ Op,
23
+ PutOp,
24
+ Result,
25
+ SearchItem,
26
+ SearchOp,
27
+ TTLConfig,
28
+ ensure_embeddings,
29
+ get_text_at_path,
30
+ tokenize_path,
31
+ )
32
+
33
+ _AIO_ERROR_MSG = (
34
+ "The SqliteStore does not support async methods. "
35
+ "Consider using AsyncSqliteStore instead.\n"
36
+ "from coco_graph.store.sqlite.aio import AsyncSqliteStore\n"
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ MIGRATIONS = [
42
+ """
43
+ CREATE TABLE IF NOT EXISTS store (
44
+ -- 'prefix' represents the doc's 'namespace'
45
+ prefix text NOT NULL,
46
+ key text NOT NULL,
47
+ value text NOT NULL,
48
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
49
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
50
+ PRIMARY KEY (prefix, key)
51
+ );
52
+ """,
53
+ """
54
+ -- For faster lookups by prefix
55
+ CREATE INDEX IF NOT EXISTS store_prefix_idx ON store (prefix);
56
+ """,
57
+ """
58
+ -- Add expires_at column to store table
59
+ ALTER TABLE store
60
+ ADD COLUMN expires_at TIMESTAMP;
61
+ """,
62
+ """
63
+ -- Add ttl_minutes column to store table
64
+ ALTER TABLE store
65
+ ADD COLUMN ttl_minutes REAL;
66
+ """,
67
+ """
68
+ -- Add index for efficient TTL sweeping
69
+ CREATE INDEX IF NOT EXISTS idx_store_expires_at ON store (expires_at)
70
+ WHERE expires_at IS NOT NULL;
71
+ """,
72
+ ]
73
+
74
+ VECTOR_MIGRATIONS = [
75
+ """
76
+ CREATE TABLE IF NOT EXISTS store_vectors (
77
+ prefix text NOT NULL,
78
+ key text NOT NULL,
79
+ field_name text NOT NULL,
80
+ embedding BLOB,
81
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
82
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
83
+ PRIMARY KEY (prefix, key, field_name),
84
+ FOREIGN KEY (prefix, key) REFERENCES store(prefix, key) ON DELETE CASCADE
85
+ );
86
+ """,
87
+ ]
88
+
89
+
90
+ class SqliteIndexConfig(IndexConfig):
91
+ """Configuration for vector embeddings in SQLite store."""
92
+
93
+ pass
94
+
95
+
96
+ NS_MATCH_FUNCTION = "_coco_graph_namespace_match"
97
+ """SQLite user function backing segment-aware namespace matching.
98
+
99
+ Registered under a private name rather than overriding `REGEXP`, so a caller's
100
+ own `REGEXP` is left untouched.
101
+ """
102
+
103
+
104
+ def _namespace_match(prefix: str | None, pattern: str) -> int:
105
+ """Backing implementation of `NS_MATCH_FUNCTION`."""
106
+ if prefix is None:
107
+ return 0
108
+ return 1 if re.search(pattern, prefix) else 0
109
+
110
+
111
+ def _escape_glob_literal(text: str) -> str:
112
+ """Escape GLOB metacharacters so `text` is matched literally.
113
+
114
+ GLOB has no `ESCAPE` clause, so metacharacters are wrapped in a character
115
+ class instead. `]` is literal outside a class and needs no escaping.
116
+ """
117
+ return text.replace("[", "[[]").replace("*", "[*]").replace("?", "[?]")
118
+
119
+
120
+ def _namespace_prefix_condition(
121
+ namespace_prefix: tuple[str, ...], column: str = "prefix"
122
+ ) -> tuple[str, tuple[str, ...]]:
123
+ """Build the SQL scoping a search to a namespace and its descendants.
124
+
125
+ Matches the namespace exactly or requires the `.` separator before any
126
+ remainder, so a prefix of `("foo",)` does not also match `("foobar",)`.
127
+
128
+ Uses GLOB rather than LIKE because SQLite's LIKE is case-insensitive for
129
+ ASCII, which would match `("FOO",)` for a prefix of `("foo",)` even though
130
+ `get`/`put`/`delete` compare with `=` and treat those as distinct.
131
+
132
+ An empty prefix is unconstrained and matches every namespace.
133
+ """
134
+ if not namespace_prefix:
135
+ return "TRUE", ()
136
+ path = _namespace_to_text(namespace_prefix)
137
+ condition = f"({column} = ? OR {column} GLOB ?)"
138
+ return condition, (path, f"{_escape_glob_literal(path)}.*")
139
+
140
+
141
+ def _namespace_match_pattern(path: tuple[str, ...], match_type: str) -> str:
142
+ """Build a regex matching the dot-joined prefix on whole namespace segments.
143
+
144
+ Needed because neither LIKE nor GLOB can express "any character except the
145
+ separator": GLOB has character classes but no quantifier, so `[^.]*` still
146
+ crosses `.`. Matches how `InMemoryStore` compares namespaces element-wise.
147
+
148
+ `*` matches exactly one segment. Prefix matches stay open-ended but must end
149
+ on a separator; suffix matches anchor at the end and begin on one.
150
+
151
+ Examples:
152
+ prefix ("uid", "*", "alice") -> ^uid\\.[^.]+\\.alice(\\.|\\Z)
153
+ suffix ("alice",) -> (^|\\.)alice\\Z
154
+ """
155
+ segments = ("[^.]+" if part == "*" else re.escape(part) for part in path)
156
+ body = r"\.".join(segments)
157
+ if match_type == "suffix":
158
+ return rf"(^|\.){body}\Z"
159
+ return rf"^{body}(\.|\Z)"
160
+
161
+
162
+ def _namespace_to_text(namespace: tuple[str, ...]) -> str:
163
+ """Convert namespace tuple to text string."""
164
+ return ".".join(namespace)
165
+
166
+
167
+ def _decode_ns_text(namespace: str) -> tuple[str, ...]:
168
+ """Convert namespace string to tuple."""
169
+ return tuple(namespace.split("."))
170
+
171
+
172
+ _FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
173
+
174
+
175
+ def _validate_filter_key(key: str) -> None:
176
+ """Validate that a filter key is safe for use in SQL queries.
177
+
178
+ Args:
179
+ key: The filter key to validate
180
+
181
+ Raises:
182
+ ValueError: If the key contains invalid characters that could enable SQL injection
183
+ """
184
+ # Allow alphanumeric characters, underscores, dots, and hyphens
185
+ # This covers typical JSON property names while preventing SQL injection
186
+ if not _FILTER_PATTERN.match(key):
187
+ raise ValueError(
188
+ f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
189
+ )
190
+
191
+
192
+ def _json_loads(content: bytes | str | orjson.Fragment) -> Any:
193
+ if isinstance(content, orjson.Fragment):
194
+ if hasattr(content, "buf"):
195
+ content = content.buf
196
+ else:
197
+ if isinstance(content.contents, bytes):
198
+ content = content.contents
199
+ else:
200
+ content = content.contents.encode()
201
+ return orjson.loads(cast(bytes, content))
202
+ elif isinstance(content, bytes):
203
+ return orjson.loads(content)
204
+ else:
205
+ return orjson.loads(content)
206
+
207
+
208
+ def _row_to_item(
209
+ namespace: tuple[str, ...],
210
+ row: dict[str, Any],
211
+ *,
212
+ loader: Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None = None,
213
+ ) -> Item:
214
+ """Convert a row from the database into an Item."""
215
+ val = row["value"]
216
+ if not isinstance(val, dict):
217
+ val = (loader or _json_loads)(val)
218
+
219
+ kwargs = {
220
+ "key": row["key"],
221
+ "namespace": namespace,
222
+ "value": val,
223
+ "created_at": row["created_at"],
224
+ "updated_at": row["updated_at"],
225
+ }
226
+
227
+ return Item(**kwargs)
228
+
229
+
230
+ def _row_to_search_item(
231
+ namespace: tuple[str, ...],
232
+ row: dict[str, Any],
233
+ *,
234
+ loader: Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None = None,
235
+ ) -> SearchItem:
236
+ """Convert a row from the database into a SearchItem."""
237
+ loader = loader or _json_loads
238
+ val = row["value"]
239
+ score = row.get("score")
240
+ if score is not None:
241
+ try:
242
+ score = float(score)
243
+ except ValueError:
244
+ logger.warning("Invalid score: %s", score)
245
+ score = None
246
+ return SearchItem(
247
+ value=val if isinstance(val, dict) else loader(val),
248
+ key=row["key"],
249
+ namespace=namespace,
250
+ created_at=row["created_at"],
251
+ updated_at=row["updated_at"],
252
+ score=score,
253
+ )
254
+
255
+
256
+ def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]:
257
+ grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
258
+ tot = 0
259
+ for idx, op in enumerate(ops):
260
+ grouped_ops[type(op)].append((idx, op))
261
+ tot += 1
262
+ return grouped_ops, tot
263
+
264
+
265
+ class PreparedGetQuery(NamedTuple):
266
+ query: str # Main query to execute
267
+ params: tuple # Parameters for the main query
268
+ namespace: tuple[str, ...] # Namespace info
269
+ items: list # List of items this query is for
270
+ kind: Literal["get", "refresh"]
271
+
272
+
273
+ class BaseSqliteStore:
274
+ """Shared base class for SQLite stores."""
275
+
276
+ MIGRATIONS = MIGRATIONS
277
+ VECTOR_MIGRATIONS = VECTOR_MIGRATIONS
278
+ supports_ttl = True
279
+ index_config: SqliteIndexConfig | None = None
280
+ ttl_config: TTLConfig | None = None
281
+
282
+ def _get_batch_GET_ops_queries(
283
+ self, get_ops: Sequence[tuple[int, GetOp]]
284
+ ) -> list[PreparedGetQuery]:
285
+ """
286
+ Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace.
287
+
288
+ Returns a list of PreparedGetQuery objects, which may include:
289
+ - Queries with kind='refresh' for TTL refresh operations
290
+ - Queries with kind='get' for data retrieval operations
291
+ """
292
+ namespace_groups = defaultdict(list)
293
+ refresh_ttls = defaultdict(list)
294
+ for idx, op in get_ops:
295
+ namespace_groups[op.namespace].append((idx, op.key))
296
+ refresh_ttls[op.namespace].append(getattr(op, "refresh_ttl", False))
297
+
298
+ results = []
299
+ for namespace, items in namespace_groups.items():
300
+ _, keys = zip(*items, strict=False)
301
+ this_refresh_ttls = refresh_ttls[namespace]
302
+ refresh_ttl_any = any(this_refresh_ttls)
303
+
304
+ # Always add the main query to get the data
305
+ select_query = f"""
306
+ SELECT key, value, created_at, updated_at, expires_at, ttl_minutes
307
+ FROM store
308
+ WHERE prefix = ? AND key IN ({",".join(["?"] * len(keys))})
309
+ """
310
+ select_params = (_namespace_to_text(namespace), *keys)
311
+ results.append(
312
+ PreparedGetQuery(select_query, select_params, namespace, items, "get")
313
+ )
314
+
315
+ # Add a TTL refresh query if needed
316
+ if (
317
+ refresh_ttl_any
318
+ and self.ttl_config
319
+ and self.ttl_config.get("refresh_on_read", False)
320
+ ):
321
+ placeholders = ",".join(["?"] * len(keys))
322
+ update_query = f"""
323
+ UPDATE store
324
+ SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
325
+ WHERE prefix = ?
326
+ AND key IN ({placeholders})
327
+ AND ttl_minutes IS NOT NULL
328
+ """
329
+ update_params = (_namespace_to_text(namespace), *keys)
330
+ results.append(
331
+ PreparedGetQuery(
332
+ update_query, update_params, namespace, items, "refresh"
333
+ )
334
+ )
335
+
336
+ return results
337
+
338
+ def _prepare_batch_PUT_queries(
339
+ self, put_ops: Sequence[tuple[int, PutOp]]
340
+ ) -> tuple[
341
+ list[tuple[str, Sequence]],
342
+ tuple[str, Sequence[tuple[str, str, str, str]]] | None,
343
+ ]:
344
+ # Last-write wins
345
+ dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
346
+ for _, op in put_ops:
347
+ dedupped_ops[(op.namespace, op.key)] = op
348
+
349
+ inserts: list[PutOp] = []
350
+ deletes: list[PutOp] = []
351
+ for op in dedupped_ops.values():
352
+ if op.value is None:
353
+ deletes.append(op)
354
+ else:
355
+ inserts.append(op)
356
+
357
+ queries: list[tuple[str, Sequence]] = []
358
+
359
+ if deletes:
360
+ namespace_groups: dict[tuple[str, ...], list[str]] = defaultdict(list)
361
+ for op in deletes:
362
+ namespace_groups[op.namespace].append(op.key)
363
+ for namespace, keys in namespace_groups.items():
364
+ placeholders = ",".join(["?" for _ in keys])
365
+ query = (
366
+ f"DELETE FROM store WHERE prefix = ? AND key IN ({placeholders})"
367
+ )
368
+ params = (_namespace_to_text(namespace), *keys)
369
+ queries.append((query, params))
370
+
371
+ embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None
372
+ if inserts:
373
+ values = []
374
+ insertion_params = []
375
+ vector_values = []
376
+ embedding_request_params = []
377
+ now = datetime.datetime.now(datetime.timezone.utc)
378
+
379
+ # First handle main store insertions
380
+ for op in inserts:
381
+ if op.ttl is None:
382
+ expires_at = None
383
+ else:
384
+ expires_at = now + datetime.timedelta(minutes=op.ttl)
385
+ values.append("(?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?)")
386
+ insertion_params.extend(
387
+ [
388
+ _namespace_to_text(op.namespace),
389
+ op.key,
390
+ orjson.dumps(dict(cast(Mapping[str, Any], op.value))),
391
+ expires_at,
392
+ op.ttl,
393
+ ]
394
+ )
395
+
396
+ # Then handle embeddings if configured
397
+ if self.index_config:
398
+ for op in inserts:
399
+ if op.index is False:
400
+ continue
401
+ value = op.value
402
+ ns = _namespace_to_text(op.namespace)
403
+ k = op.key
404
+
405
+ if op.index is None:
406
+ paths = self.index_config["__tokenized_fields"]
407
+ else:
408
+ paths = [(ix, tokenize_path(ix)) for ix in op.index]
409
+
410
+ for path, tokenized_path in paths:
411
+ texts = get_text_at_path(value, tokenized_path)
412
+ for i, text in enumerate(texts):
413
+ pathname = f"{path}.{i}" if len(texts) > 1 else path
414
+ vector_values.append(
415
+ "(?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
416
+ )
417
+ embedding_request_params.append((ns, k, pathname, text))
418
+
419
+ values_str = ",".join(values)
420
+ query = f"""
421
+ INSERT OR REPLACE INTO store (prefix, key, value, created_at, updated_at, expires_at, ttl_minutes)
422
+ VALUES {values_str}
423
+ """
424
+ queries.append((query, insertion_params))
425
+
426
+ if vector_values:
427
+ values_str = ",".join(vector_values)
428
+ query = f"""
429
+ INSERT OR REPLACE INTO store_vectors (prefix, key, field_name, embedding, created_at, updated_at)
430
+ VALUES {values_str}
431
+ """
432
+ embedding_request = (query, embedding_request_params)
433
+
434
+ return queries, embedding_request
435
+
436
+ def _prepare_batch_search_queries(
437
+ self, search_ops: Sequence[tuple[int, SearchOp]]
438
+ ) -> tuple[
439
+ list[
440
+ tuple[str, list[None | str | list[float]], bool]
441
+ ], # queries, params, needs_refresh
442
+ list[tuple[int, str]], # idx, query_text pairs to embed
443
+ ]:
444
+ """
445
+ Build per-SearchOp SQL queries (with optional TTL refresh flag) plus embedding requests.
446
+ Returns:
447
+ - queries: list of (SQL, param_list, needs_ttl_refresh_flag)
448
+ - embedding_requests: list of (original_index_in_search_ops, text_query)
449
+ """
450
+ queries = []
451
+ embedding_requests = []
452
+
453
+ for idx, (_, op) in enumerate(search_ops):
454
+ # Build filter conditions first
455
+ filter_params = []
456
+ filter_conditions = []
457
+ if op.filter:
458
+ for key, value in op.filter.items():
459
+ _validate_filter_key(key)
460
+
461
+ if isinstance(value, dict):
462
+ for op_name, val in value.items():
463
+ condition, filter_params_ = self._get_filter_condition(
464
+ key, op_name, val
465
+ )
466
+ filter_conditions.append(condition)
467
+ filter_params.extend(filter_params_)
468
+ else:
469
+ # SQLite json_extract returns unquoted string values
470
+ if isinstance(value, str):
471
+ filter_conditions.append(
472
+ "json_extract(value, '$." + key + "') = ?"
473
+ )
474
+ filter_params.append(value)
475
+ elif value is None:
476
+ filter_conditions.append(
477
+ "json_extract(value, '$." + key + "') IS NULL"
478
+ )
479
+ elif isinstance(value, bool):
480
+ # SQLite JSON stores booleans as integers
481
+ filter_conditions.append(
482
+ "json_extract(value, '$."
483
+ + key
484
+ + "') = "
485
+ + ("1" if value else "0")
486
+ )
487
+ elif isinstance(value, (int, float)):
488
+ # Use parameterized query to handle special floats and large integers
489
+ filter_conditions.append(
490
+ "json_extract(value, '$." + key + "') = ?"
491
+ )
492
+ filter_params.append(float(value))
493
+ else:
494
+ # Complex objects (list, dict, …) – compare JSON text
495
+ filter_conditions.append(
496
+ "json_extract(value, '$." + key + "') = ?"
497
+ )
498
+ # orjson.dumps returns bytes → decode to str so SQLite sees TEXT
499
+ filter_params.append(orjson.dumps(value).decode())
500
+
501
+ # Vector search branch
502
+ if op.query and self.index_config:
503
+ embedding_requests.append((idx, op.query))
504
+
505
+ # Choose the similarity function and score expression based on distance type
506
+ distance_type = self.index_config.get("distance_type", "cosine")
507
+
508
+ if distance_type == "cosine":
509
+ score_expr = "1.0 - vec_distance_cosine(sv.embedding, ?)"
510
+ elif distance_type == "l2":
511
+ score_expr = "vec_distance_L2(sv.embedding, ?)"
512
+ elif distance_type == "inner_product":
513
+ # For inner product, we want higher values to be better, so negate the result
514
+ # since inner product similarity is higher when vectors are more similar
515
+ score_expr = "-1 * vec_distance_L1(sv.embedding, ?)"
516
+ else:
517
+ # Default to cosine similarity
518
+ score_expr = "1.0 - vec_distance_cosine(sv.embedding, ?)"
519
+
520
+ filter_str = (
521
+ ""
522
+ if not filter_conditions
523
+ else " AND " + " AND ".join(filter_conditions)
524
+ )
525
+ if op.namespace_prefix:
526
+ ns_condition, ns_args_tuple = _namespace_prefix_condition(
527
+ op.namespace_prefix, column="s.prefix"
528
+ )
529
+ prefix_filter_str = f"WHERE {ns_condition} {filter_str} "
530
+ ns_args: Sequence = ns_args_tuple
531
+ else:
532
+ ns_args = ()
533
+ if filter_str:
534
+ prefix_filter_str = f"WHERE {filter_str[5:]} "
535
+ else:
536
+ prefix_filter_str = ""
537
+
538
+ # We use a CTE to compute scores, with a SQLite-compatible approach for distinct results
539
+ base_query = f"""
540
+ WITH scored AS (
541
+ SELECT s.prefix, s.key, s.value, s.created_at, s.updated_at, s.expires_at, s.ttl_minutes,
542
+ {score_expr} AS score
543
+ FROM store s
544
+ JOIN store_vectors sv ON s.prefix = sv.prefix AND s.key = sv.key
545
+ {prefix_filter_str}
546
+ ORDER BY score DESC
547
+ LIMIT ?
548
+ ),
549
+ ranked AS (
550
+ SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, score,
551
+ ROW_NUMBER() OVER (PARTITION BY prefix, key ORDER BY score DESC) as rn
552
+ FROM scored
553
+ )
554
+ SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, score
555
+ FROM ranked
556
+ WHERE rn = 1
557
+ ORDER BY score DESC
558
+ LIMIT ?
559
+ OFFSET ?
560
+ """
561
+ params = [
562
+ _PLACEHOLDER, # Vector placeholder
563
+ *ns_args,
564
+ *filter_params,
565
+ op.limit * 2, # Expanded limit for better results
566
+ op.limit,
567
+ op.offset,
568
+ ]
569
+ # Regular search branch (no vector search)
570
+ else:
571
+ ns_condition, ns_args_tuple = _namespace_prefix_condition(
572
+ op.namespace_prefix
573
+ )
574
+ base_query = f"""
575
+ SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, NULL as score
576
+ FROM store
577
+ WHERE {ns_condition}
578
+ """
579
+ params = list(ns_args_tuple)
580
+
581
+ if filter_conditions:
582
+ params.extend(filter_params)
583
+ base_query += " AND " + " AND ".join(filter_conditions)
584
+
585
+ base_query += " ORDER BY updated_at DESC"
586
+ base_query += " LIMIT ? OFFSET ?"
587
+ params.extend([op.limit, op.offset])
588
+
589
+ # Debug the query
590
+ logger.debug(f"Search query: {base_query}")
591
+ logger.debug(f"Search params: {params}")
592
+
593
+ # Determine if TTL refresh is needed
594
+ needs_ttl_refresh = bool(
595
+ op.refresh_ttl
596
+ and self.ttl_config
597
+ and self.ttl_config.get("refresh_on_read", False)
598
+ )
599
+
600
+ # The base_query is now the final_sql, and we pass the refresh flag
601
+ final_sql = base_query
602
+ final_params = params
603
+
604
+ queries.append((final_sql, final_params, needs_ttl_refresh))
605
+
606
+ return queries, embedding_requests
607
+
608
+ def _get_batch_list_namespaces_queries(
609
+ self,
610
+ list_ops: Sequence[tuple[int, ListNamespacesOp]],
611
+ ) -> list[tuple[str, Sequence]]:
612
+ queries: list[tuple[str, Sequence]] = []
613
+
614
+ for _, op in list_ops:
615
+ where_clauses: list[str] = []
616
+ params: list[Any] = []
617
+
618
+ if op.match_conditions:
619
+ for cond in op.match_conditions:
620
+ if cond.match_type in ("prefix", "suffix"):
621
+ if not cond.path:
622
+ # An empty path constrains nothing; skipping keeps it a
623
+ # no-op rather than emitting a pattern that matches no
624
+ # namespace at all.
625
+ continue
626
+ if cond.match_type == "prefix" and "*" not in cond.path:
627
+ # Equivalent to the anchored pattern, but SQLite can
628
+ # satisfy `=` and a trailing-wildcard GLOB from
629
+ # store_prefix_idx. The user function is opaque to the
630
+ # planner, so it would scan every row and call back
631
+ # into Python for each one.
632
+ condition, args = _namespace_prefix_condition(
633
+ tuple(cond.path)
634
+ )
635
+ where_clauses.append(condition)
636
+ params.extend(args)
637
+ else:
638
+ where_clauses.append(f"{NS_MATCH_FUNCTION}(prefix, ?) = 1")
639
+ params.append(
640
+ _namespace_match_pattern(cond.path, cond.match_type)
641
+ )
642
+ else:
643
+ logger.warning(
644
+ "Unknown match_type in list_namespaces: %s", cond.match_type
645
+ )
646
+
647
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
648
+
649
+ if op.max_depth is not None:
650
+ query = f"""
651
+ WITH RECURSIVE split(original, truncated, remainder, depth) AS (
652
+ SELECT
653
+ prefix AS original,
654
+ '' AS truncated,
655
+ prefix AS remainder,
656
+ 0 AS depth
657
+ FROM (SELECT DISTINCT prefix FROM store {where_sql})
658
+
659
+ UNION ALL
660
+
661
+ SELECT
662
+ original,
663
+ CASE
664
+ WHEN depth = 0
665
+ THEN substr(remainder,
666
+ 1,
667
+ CASE
668
+ WHEN instr(remainder, '.') > 0
669
+ THEN instr(remainder, '.') - 1
670
+ ELSE length(remainder)
671
+ END)
672
+ ELSE
673
+ truncated || '.' ||
674
+ substr(remainder,
675
+ 1,
676
+ CASE
677
+ WHEN instr(remainder, '.') > 0
678
+ THEN instr(remainder, '.') - 1
679
+ ELSE length(remainder)
680
+ END)
681
+ END AS truncated,
682
+ CASE
683
+ WHEN instr(remainder, '.') > 0
684
+ THEN substr(remainder, instr(remainder, '.') + 1)
685
+ ELSE ''
686
+ END AS remainder,
687
+ depth + 1 AS depth
688
+ FROM split
689
+ WHERE remainder <> ''
690
+ AND depth < ?
691
+ )
692
+ SELECT DISTINCT truncated AS prefix
693
+ FROM split
694
+ WHERE depth = ? OR remainder = ''
695
+ ORDER BY prefix
696
+ LIMIT ? OFFSET ?
697
+ """
698
+ params.extend([op.max_depth, op.max_depth, op.limit, op.offset])
699
+
700
+ else:
701
+ query = f"""
702
+ SELECT DISTINCT prefix
703
+ FROM store
704
+ {where_sql}
705
+ ORDER BY prefix
706
+ LIMIT ? OFFSET ?
707
+ """
708
+ params.extend([op.limit, op.offset])
709
+
710
+ queries.append((query, tuple(params)))
711
+
712
+ return queries
713
+
714
+ def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]:
715
+ """Helper to generate filter conditions."""
716
+ _validate_filter_key(key)
717
+
718
+ # We need to properly format values for SQLite JSON extraction comparison
719
+ if op == "$eq":
720
+ if isinstance(value, str):
721
+ return f"json_extract(value, '$.{key}') = ?", [value]
722
+ elif value is None:
723
+ return f"json_extract(value, '$.{key}') IS NULL", []
724
+ elif isinstance(value, bool):
725
+ # SQLite JSON stores booleans as integers
726
+ return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
727
+ elif isinstance(value, (int, float)):
728
+ # Convert to float to handle inf, -inf, nan, and very large integers
729
+ # SQLite REAL can handle these cases better than INTEGER
730
+ return f"json_extract(value, '$.{key}') = ?", [float(value)]
731
+ else:
732
+ return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
733
+ elif op == "$gt":
734
+ # For numeric values, SQLite needs to compare as numbers, not strings
735
+ if isinstance(value, (int, float)):
736
+ # Convert to float to handle special values and very large integers
737
+ return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
738
+ float(value)
739
+ ]
740
+ elif isinstance(value, str):
741
+ return f"json_extract(value, '$.{key}') > ?", [value]
742
+ else:
743
+ return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
744
+ elif op == "$gte":
745
+ if isinstance(value, (int, float)):
746
+ return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
747
+ float(value)
748
+ ]
749
+ elif isinstance(value, str):
750
+ return f"json_extract(value, '$.{key}') >= ?", [value]
751
+ else:
752
+ return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
753
+ elif op == "$lt":
754
+ if isinstance(value, (int, float)):
755
+ return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
756
+ float(value)
757
+ ]
758
+ elif isinstance(value, str):
759
+ return f"json_extract(value, '$.{key}') < ?", [value]
760
+ else:
761
+ return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
762
+ elif op == "$lte":
763
+ if isinstance(value, (int, float)):
764
+ return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
765
+ float(value)
766
+ ]
767
+ elif isinstance(value, str):
768
+ return f"json_extract(value, '$.{key}') <= ?", [value]
769
+ else:
770
+ return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
771
+ elif op == "$ne":
772
+ if isinstance(value, str):
773
+ return f"json_extract(value, '$.{key}') != ?", [value]
774
+ elif value is None:
775
+ return f"json_extract(value, '$.{key}') IS NOT NULL", []
776
+ elif isinstance(value, bool):
777
+ return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
778
+ elif isinstance(value, (int, float)):
779
+ # Convert to float for consistency
780
+ return f"json_extract(value, '$.{key}') != ?", [float(value)]
781
+ else:
782
+ return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
783
+ else:
784
+ raise ValueError(f"Unsupported operator: {op}")
785
+
786
+
787
+ class SqliteStore(BaseSqliteStore, BaseStore):
788
+ """SQLite-backed store with optional vector search capabilities.
789
+
790
+ Examples:
791
+ Basic setup and usage:
792
+ ```python
793
+ from coco_graph.store.sqlite import SqliteStore
794
+ import sqlite3
795
+
796
+ conn = sqlite3.connect(":memory:")
797
+ store = SqliteStore(conn)
798
+ store.setup() # Run migrations. Done once
799
+
800
+ # Store and retrieve data
801
+ store.put(("users", "123"), "prefs", {"theme": "dark"})
802
+ item = store.get(("users", "123"), "prefs")
803
+ ```
804
+
805
+ Or using the convenient `from_conn_string` helper:
806
+
807
+ ```python
808
+ from coco_graph.store.sqlite import SqliteStore
809
+
810
+ with SqliteStore.from_conn_string(":memory:") as store:
811
+ store.setup()
812
+
813
+ # Store and retrieve data
814
+ store.put(("users", "123"), "prefs", {"theme": "dark"})
815
+ item = store.get(("users", "123"), "prefs")
816
+ ```
817
+
818
+ Vector search using Coco embeddings:
819
+ ```python
820
+ from coco.embeddings import OpenAIEmbeddings
821
+ from coco_graph.store.sqlite import SqliteStore
822
+
823
+ with SqliteStore.from_conn_string(
824
+ ":memory:",
825
+ index={
826
+ "dims": 1536,
827
+ "embed": OpenAIEmbeddings(),
828
+ "fields": ["text"] # specify which fields to embed
829
+ }
830
+ ) as store:
831
+ store.setup() # Run migrations
832
+
833
+ # Store documents
834
+ store.put(("docs",), "doc1", {"text": "Python tutorial"})
835
+ store.put(("docs",), "doc2", {"text": "TypeScript guide"})
836
+ store.put(("docs",), "doc3", {"text": "Other guide"}, index=False) # don't index
837
+
838
+ # Search by similarity
839
+ results = store.search(("docs",), query="programming guides", limit=2)
840
+ ```
841
+
842
+ Note:
843
+ Semantic search is disabled by default. You can enable it by providing an `index` configuration
844
+ when creating the store. Without this configuration, all `index` arguments passed to
845
+ `put` or `aput` will have no effect.
846
+
847
+ Warning:
848
+ Make sure to call `setup()` before first use to create necessary tables and indexes.
849
+ """
850
+
851
+ MIGRATIONS = MIGRATIONS
852
+ VECTOR_MIGRATIONS = VECTOR_MIGRATIONS
853
+ supports_ttl = True
854
+
855
+ def __init__(
856
+ self,
857
+ conn: sqlite3.Connection,
858
+ *,
859
+ deserializer: (
860
+ Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None
861
+ ) = None,
862
+ index: SqliteIndexConfig | None = None,
863
+ ttl: TTLConfig | None = None,
864
+ ):
865
+ super().__init__()
866
+ self._deserializer = deserializer
867
+ self.conn = conn
868
+ # Registered here rather than in from_conn_string so a caller-supplied
869
+ # connection also gets it.
870
+ conn.create_function(NS_MATCH_FUNCTION, 2, _namespace_match, deterministic=True)
871
+ self.lock = threading.Lock()
872
+ self.is_setup = False
873
+ self.index_config = index
874
+ if self.index_config:
875
+ self.embeddings, self.index_config = _ensure_index_config(self.index_config)
876
+ else:
877
+ self.embeddings = None
878
+ self.ttl_config = ttl
879
+ self._ttl_sweeper_thread: threading.Thread | None = None
880
+ self._ttl_stop_event = threading.Event()
881
+
882
+ def _get_batch_GET_ops_queries(
883
+ self, get_ops: Sequence[tuple[int, GetOp]]
884
+ ) -> list[PreparedGetQuery]:
885
+ """
886
+ Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace.
887
+
888
+ Returns a list of PreparedGetQuery objects, which may include:
889
+ - Queries with kind='refresh' for TTL refresh operations
890
+ - Queries with kind='get' for data retrieval operations
891
+ """
892
+ namespace_groups = defaultdict(list)
893
+ refresh_ttls = defaultdict(list)
894
+ for idx, op in get_ops:
895
+ namespace_groups[op.namespace].append((idx, op.key))
896
+ refresh_ttls[op.namespace].append(getattr(op, "refresh_ttl", False))
897
+
898
+ results = []
899
+ for namespace, items in namespace_groups.items():
900
+ _, keys = zip(*items, strict=False)
901
+ this_refresh_ttls = refresh_ttls[namespace]
902
+ refresh_ttl_any = any(this_refresh_ttls)
903
+
904
+ # Always add the main query to get the data
905
+ select_query = f"""
906
+ SELECT key, value, created_at, updated_at, expires_at, ttl_minutes
907
+ FROM store
908
+ WHERE prefix = ? AND key IN ({",".join(["?"] * len(keys))})
909
+ """
910
+ select_params = (_namespace_to_text(namespace), *keys)
911
+ results.append(
912
+ PreparedGetQuery(select_query, select_params, namespace, items, "get")
913
+ )
914
+
915
+ # Add a TTL refresh query if needed
916
+ if (
917
+ refresh_ttl_any
918
+ and self.ttl_config
919
+ and self.ttl_config.get("refresh_on_read", False)
920
+ ):
921
+ placeholders = ",".join(["?"] * len(keys))
922
+ update_query = f"""
923
+ UPDATE store
924
+ SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
925
+ WHERE prefix = ?
926
+ AND key IN ({placeholders})
927
+ AND ttl_minutes IS NOT NULL
928
+ """
929
+ update_params = (_namespace_to_text(namespace), *keys)
930
+ results.append(
931
+ PreparedGetQuery(
932
+ update_query, update_params, namespace, items, "refresh"
933
+ )
934
+ )
935
+
936
+ return results
937
+
938
+ def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]:
939
+ """Helper to generate filter conditions."""
940
+ _validate_filter_key(key)
941
+
942
+ # We need to properly format values for SQLite JSON extraction comparison
943
+ if op == "$eq":
944
+ if isinstance(value, str):
945
+ return f"json_extract(value, '$.{key}') = ?", [value]
946
+ elif value is None:
947
+ return f"json_extract(value, '$.{key}') IS NULL", []
948
+ elif isinstance(value, bool):
949
+ # SQLite JSON stores booleans as integers
950
+ return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
951
+ elif isinstance(value, (int, float)):
952
+ # Convert to float to handle inf, -inf, nan, and very large integers
953
+ # SQLite REAL can handle these cases better than INTEGER
954
+ return f"json_extract(value, '$.{key}') = ?", [float(value)]
955
+ else:
956
+ return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
957
+ elif op == "$gt":
958
+ # For numeric values, SQLite needs to compare as numbers, not strings
959
+ if isinstance(value, (int, float)):
960
+ # Convert to float to handle special values and very large integers
961
+ return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
962
+ float(value)
963
+ ]
964
+ elif isinstance(value, str):
965
+ return f"json_extract(value, '$.{key}') > ?", [value]
966
+ else:
967
+ return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
968
+ elif op == "$gte":
969
+ if isinstance(value, (int, float)):
970
+ return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
971
+ float(value)
972
+ ]
973
+ elif isinstance(value, str):
974
+ return f"json_extract(value, '$.{key}') >= ?", [value]
975
+ else:
976
+ return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
977
+ elif op == "$lt":
978
+ if isinstance(value, (int, float)):
979
+ return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
980
+ float(value)
981
+ ]
982
+ elif isinstance(value, str):
983
+ return f"json_extract(value, '$.{key}') < ?", [value]
984
+ else:
985
+ return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
986
+ elif op == "$lte":
987
+ if isinstance(value, (int, float)):
988
+ return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
989
+ float(value)
990
+ ]
991
+ elif isinstance(value, str):
992
+ return f"json_extract(value, '$.{key}') <= ?", [value]
993
+ else:
994
+ return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
995
+ elif op == "$ne":
996
+ if isinstance(value, str):
997
+ return f"json_extract(value, '$.{key}') != ?", [value]
998
+ elif value is None:
999
+ return f"json_extract(value, '$.{key}') IS NOT NULL", []
1000
+ elif isinstance(value, bool):
1001
+ return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
1002
+ elif isinstance(value, (int, float)):
1003
+ # Convert to float for consistency
1004
+ return f"json_extract(value, '$.{key}') != ?", [float(value)]
1005
+ else:
1006
+ return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
1007
+ else:
1008
+ raise ValueError(f"Unsupported operator: {op}")
1009
+
1010
+ @classmethod
1011
+ @contextmanager
1012
+ def from_conn_string(
1013
+ cls,
1014
+ conn_string: str,
1015
+ *,
1016
+ index: SqliteIndexConfig | None = None,
1017
+ ttl: TTLConfig | None = None,
1018
+ ) -> Iterator[SqliteStore]:
1019
+ """Create a new SqliteStore instance from a connection string.
1020
+
1021
+ Args:
1022
+ conn_string (str): The SQLite connection string.
1023
+ index (Optional[SqliteIndexConfig]): The index configuration for the store.
1024
+ ttl (Optional[TTLConfig]): The time-to-live configuration for the store.
1025
+
1026
+ Returns:
1027
+ SqliteStore: A new SqliteStore instance.
1028
+ """
1029
+ conn = sqlite3.connect(
1030
+ conn_string,
1031
+ check_same_thread=False,
1032
+ isolation_level=None, # autocommit mode
1033
+ )
1034
+ try:
1035
+ yield cls(conn, index=index, ttl=ttl)
1036
+ finally:
1037
+ conn.close()
1038
+
1039
+ @contextmanager
1040
+ def _cursor(self, *, transaction: bool = True) -> Iterator[sqlite3.Cursor]:
1041
+ """Create a database cursor as a context manager.
1042
+
1043
+ Args:
1044
+ transaction (bool): whether to use transaction for the DB operations
1045
+ """
1046
+ if not self.is_setup:
1047
+ self.setup()
1048
+ with self.lock:
1049
+ if transaction:
1050
+ self.conn.execute("BEGIN")
1051
+
1052
+ cur = self.conn.cursor()
1053
+ try:
1054
+ yield cur
1055
+ finally:
1056
+ if transaction:
1057
+ self.conn.execute("COMMIT")
1058
+ cur.close()
1059
+
1060
+ def setup(self) -> None:
1061
+ """Set up the store database.
1062
+
1063
+ This method creates the necessary tables in the SQLite database if they don't
1064
+ already exist and runs database migrations. It should be called before first use.
1065
+ """
1066
+
1067
+ with self.lock:
1068
+ if self.is_setup:
1069
+ return
1070
+ # Create migrations table if it doesn't exist
1071
+ self.conn.executescript(
1072
+ """
1073
+ CREATE TABLE IF NOT EXISTS store_migrations (
1074
+ v INTEGER PRIMARY KEY
1075
+ )
1076
+ """
1077
+ )
1078
+
1079
+ # Check current migration version
1080
+ cur = self.conn.execute(
1081
+ "SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
1082
+ )
1083
+ row = cur.fetchone()
1084
+ if row is None:
1085
+ version = -1
1086
+ else:
1087
+ version = row[0]
1088
+
1089
+ # Apply migrations
1090
+ for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1):
1091
+ self.conn.executescript(sql)
1092
+ self.conn.execute("INSERT INTO store_migrations (v) VALUES (?)", (v,))
1093
+
1094
+ # Apply vector migrations if index config is provided
1095
+ if self.index_config:
1096
+ # Create vector migrations table if it doesn't exist
1097
+ self.conn.enable_load_extension(True)
1098
+ sqlite_vec.load(self.conn)
1099
+ self.conn.enable_load_extension(False)
1100
+ self.conn.executescript(
1101
+ """
1102
+ CREATE TABLE IF NOT EXISTS vector_migrations (
1103
+ v INTEGER PRIMARY KEY
1104
+ )
1105
+ """
1106
+ )
1107
+
1108
+ # Check current vector migration version
1109
+ cur = self.conn.execute(
1110
+ "SELECT v FROM vector_migrations ORDER BY v DESC LIMIT 1"
1111
+ )
1112
+ row = cur.fetchone()
1113
+ if row is None:
1114
+ version = -1
1115
+ else:
1116
+ version = row[0]
1117
+
1118
+ # Apply vector migrations
1119
+ for v, sql in enumerate(
1120
+ self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1
1121
+ ):
1122
+ self.conn.executescript(sql)
1123
+ self.conn.execute(
1124
+ "INSERT INTO vector_migrations (v) VALUES (?)", (v,)
1125
+ )
1126
+
1127
+ self.is_setup = True
1128
+
1129
+ def sweep_ttl(self) -> int:
1130
+ """Delete expired store items based on TTL.
1131
+
1132
+ Returns:
1133
+ int: The number of deleted items.
1134
+ """
1135
+ with self._cursor() as cur:
1136
+ cur.execute(
1137
+ """
1138
+ DELETE FROM store
1139
+ WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP
1140
+ """
1141
+ )
1142
+ deleted_count = cur.rowcount
1143
+ return deleted_count
1144
+
1145
+ def start_ttl_sweeper(
1146
+ self, sweep_interval_minutes: int | None = None
1147
+ ) -> concurrent.futures.Future[None]:
1148
+ """Periodically delete expired store items based on TTL.
1149
+
1150
+ Returns:
1151
+ Future that can be waited on or cancelled.
1152
+ """
1153
+ if not self.ttl_config:
1154
+ future: concurrent.futures.Future[None] = concurrent.futures.Future()
1155
+ future.set_result(None)
1156
+ return future
1157
+
1158
+ if self._ttl_sweeper_thread and self._ttl_sweeper_thread.is_alive():
1159
+ logger.info("TTL sweeper thread is already running")
1160
+ # Return a future that can be used to cancel the existing thread
1161
+ future = concurrent.futures.Future()
1162
+ future.add_done_callback(
1163
+ lambda f: self._ttl_stop_event.set() if f.cancelled() else None
1164
+ )
1165
+ return future
1166
+
1167
+ self._ttl_stop_event.clear()
1168
+
1169
+ interval = float(
1170
+ sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5
1171
+ )
1172
+ logger.info(f"Starting store TTL sweeper with interval {interval} minutes")
1173
+
1174
+ future = concurrent.futures.Future()
1175
+
1176
+ def _sweep_loop() -> None:
1177
+ try:
1178
+ while not self._ttl_stop_event.is_set():
1179
+ if self._ttl_stop_event.wait(interval * 60):
1180
+ break
1181
+
1182
+ try:
1183
+ expired_items = self.sweep_ttl()
1184
+ if expired_items > 0:
1185
+ logger.info(f"Store swept {expired_items} expired items")
1186
+ except Exception as exc:
1187
+ logger.exception(
1188
+ "Store TTL sweep iteration failed", exc_info=exc
1189
+ )
1190
+ future.set_result(None)
1191
+ except Exception as exc:
1192
+ future.set_exception(exc)
1193
+
1194
+ thread = threading.Thread(target=_sweep_loop, daemon=True, name="ttl-sweeper")
1195
+ self._ttl_sweeper_thread = thread
1196
+ thread.start()
1197
+
1198
+ future.add_done_callback(
1199
+ lambda f: self._ttl_stop_event.set() if f.cancelled() else None
1200
+ )
1201
+ return future
1202
+
1203
+ def stop_ttl_sweeper(self, timeout: float | None = None) -> bool:
1204
+ """Stop the TTL sweeper thread if it's running.
1205
+
1206
+ Args:
1207
+ timeout: Maximum time to wait for the thread to stop, in seconds.
1208
+ If `None`, wait indefinitely.
1209
+
1210
+ Returns:
1211
+ bool: True if the thread was successfully stopped or wasn't running,
1212
+ False if the timeout was reached before the thread stopped.
1213
+ """
1214
+ if not self._ttl_sweeper_thread or not self._ttl_sweeper_thread.is_alive():
1215
+ return True
1216
+
1217
+ logger.info("Stopping TTL sweeper thread")
1218
+ self._ttl_stop_event.set()
1219
+
1220
+ self._ttl_sweeper_thread.join(timeout)
1221
+ success = not self._ttl_sweeper_thread.is_alive()
1222
+
1223
+ if success:
1224
+ self._ttl_sweeper_thread = None
1225
+ logger.info("TTL sweeper thread stopped")
1226
+ else:
1227
+ logger.warning("Timed out waiting for TTL sweeper thread to stop")
1228
+
1229
+ return success
1230
+
1231
+ def __del__(self) -> None:
1232
+ """Ensure the TTL sweeper thread is stopped when the object is garbage collected."""
1233
+ if hasattr(self, "_ttl_stop_event") and hasattr(self, "_ttl_sweeper_thread"):
1234
+ self.stop_ttl_sweeper(timeout=0.1)
1235
+
1236
+ def batch(self, ops: Iterable[Op]) -> list[Result]:
1237
+ """Execute a batch of operations.
1238
+
1239
+ Args:
1240
+ ops (Iterable[Op]): List of operations to execute
1241
+
1242
+ Returns:
1243
+ list[Result]: Results of the operations
1244
+ """
1245
+ grouped_ops, num_ops = _group_ops(ops)
1246
+ results: list[Result] = [None] * num_ops
1247
+
1248
+ with self._cursor(transaction=True) as cur:
1249
+ if GetOp in grouped_ops:
1250
+ self._batch_get_ops(
1251
+ cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results, cur
1252
+ )
1253
+
1254
+ if SearchOp in grouped_ops:
1255
+ self._batch_search_ops(
1256
+ cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
1257
+ results,
1258
+ cur,
1259
+ )
1260
+
1261
+ if ListNamespacesOp in grouped_ops:
1262
+ self._batch_list_namespaces_ops(
1263
+ cast(
1264
+ Sequence[tuple[int, ListNamespacesOp]],
1265
+ grouped_ops[ListNamespacesOp],
1266
+ ),
1267
+ results,
1268
+ cur,
1269
+ )
1270
+ if PutOp in grouped_ops:
1271
+ self._batch_put_ops(
1272
+ cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), cur
1273
+ )
1274
+
1275
+ return results
1276
+
1277
+ def _batch_get_ops(
1278
+ self,
1279
+ get_ops: Sequence[tuple[int, GetOp]],
1280
+ results: list[Result],
1281
+ cur: sqlite3.Cursor,
1282
+ ) -> None:
1283
+ # Group all queries by namespace to execute all operations for each namespace together
1284
+ namespace_queries = defaultdict(list)
1285
+ for prepared_query in self._get_batch_GET_ops_queries(get_ops):
1286
+ namespace_queries[prepared_query.namespace].append(prepared_query)
1287
+
1288
+ # Process each namespace's operations
1289
+ for namespace, queries in namespace_queries.items():
1290
+ # Execute TTL refresh queries first
1291
+ for query in queries:
1292
+ if query.kind == "refresh":
1293
+ try:
1294
+ cur.execute(query.query, query.params)
1295
+ except Exception as e:
1296
+ raise ValueError(
1297
+ f"Error executing TTL refresh: \n{query.query}\n{query.params}\n{e}"
1298
+ ) from e
1299
+
1300
+ # Then execute GET queries and process results
1301
+ for query in queries:
1302
+ if query.kind == "get":
1303
+ try:
1304
+ cur.execute(query.query, query.params)
1305
+ except Exception as e:
1306
+ raise ValueError(
1307
+ f"Error executing GET query: \n{query.query}\n{query.params}\n{e}"
1308
+ ) from e
1309
+
1310
+ rows = cur.fetchall()
1311
+ key_to_row = {
1312
+ row[0]: {
1313
+ "key": row[0],
1314
+ "value": row[1],
1315
+ "created_at": row[2],
1316
+ "updated_at": row[3],
1317
+ "expires_at": row[4] if len(row) > 4 else None,
1318
+ "ttl_minutes": row[5] if len(row) > 5 else None,
1319
+ }
1320
+ for row in rows
1321
+ }
1322
+
1323
+ # Process results for this query
1324
+ for idx, key in query.items:
1325
+ row = key_to_row.get(key)
1326
+ if row:
1327
+ results[idx] = _row_to_item(
1328
+ namespace, row, loader=self._deserializer
1329
+ )
1330
+ else:
1331
+ results[idx] = None
1332
+
1333
+ def _batch_put_ops(
1334
+ self,
1335
+ put_ops: Sequence[tuple[int, PutOp]],
1336
+ cur: sqlite3.Cursor,
1337
+ ) -> None:
1338
+ queries, embedding_request = self._prepare_batch_PUT_queries(put_ops)
1339
+ if embedding_request:
1340
+ if self.embeddings is None:
1341
+ # Should not get here since the embedding config is required
1342
+ # to return an embedding_request above
1343
+ raise ValueError(
1344
+ "Embedding configuration is required for vector operations "
1345
+ f"(for semantic search). "
1346
+ f"Please provide an Embeddings when initializing the {self.__class__.__name__}."
1347
+ )
1348
+ query, txt_params = embedding_request
1349
+ # Update the params to replace the raw text with the vectors
1350
+ vectors = self.embeddings.embed_documents(
1351
+ [param[-1] for param in txt_params]
1352
+ )
1353
+
1354
+ # Convert vectors to SQLite-friendly format
1355
+ vector_params = []
1356
+ for (ns, k, pathname, _), vector in zip(txt_params, vectors, strict=False):
1357
+ vector_params.extend(
1358
+ [ns, k, pathname, sqlite_vec.serialize_float32(vector)]
1359
+ )
1360
+
1361
+ queries.append((query, vector_params))
1362
+
1363
+ for query, params in queries:
1364
+ cur.execute(query, params)
1365
+
1366
+ def _batch_search_ops(
1367
+ self,
1368
+ search_ops: Sequence[tuple[int, SearchOp]],
1369
+ results: list[Result],
1370
+ cur: sqlite3.Cursor,
1371
+ ) -> None:
1372
+ prepared_queries, embedding_requests = self._prepare_batch_search_queries(
1373
+ search_ops
1374
+ )
1375
+
1376
+ # Setup similarity functions if they don't exist
1377
+ if embedding_requests and self.embeddings:
1378
+ # Generate embeddings for search queries
1379
+ embeddings = self.embeddings.embed_documents(
1380
+ [query for _, query in embedding_requests]
1381
+ )
1382
+
1383
+ # Replace placeholders with actual embeddings
1384
+ for (embed_req_idx, _), embedding in zip(
1385
+ embedding_requests, embeddings, strict=False
1386
+ ):
1387
+ if embed_req_idx < len(prepared_queries):
1388
+ _params_list: list = prepared_queries[embed_req_idx][1]
1389
+ for i, param in enumerate(_params_list):
1390
+ if param is _PLACEHOLDER:
1391
+ _params_list[i] = sqlite_vec.serialize_float32(embedding)
1392
+ else:
1393
+ logger.warning(
1394
+ f"Embedding request index {embed_req_idx} out of bounds for prepared_queries."
1395
+ )
1396
+
1397
+ for (original_op_idx, _), (query, params, needs_refresh) in zip(
1398
+ search_ops, prepared_queries, strict=False
1399
+ ):
1400
+ cur.execute(query, params)
1401
+ rows = cur.fetchall()
1402
+
1403
+ if needs_refresh and rows and self.ttl_config:
1404
+ keys_to_refresh = []
1405
+ for row_data in rows:
1406
+ keys_to_refresh.append((row_data[0], row_data[1]))
1407
+
1408
+ if keys_to_refresh:
1409
+ updates_by_prefix = defaultdict(list)
1410
+ for prefix_text, key_text in keys_to_refresh:
1411
+ updates_by_prefix[prefix_text].append(key_text)
1412
+
1413
+ for prefix_text, key_list in updates_by_prefix.items():
1414
+ placeholders = ",".join(["?"] * len(key_list))
1415
+ update_query = f"""
1416
+ UPDATE store
1417
+ SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
1418
+ WHERE prefix = ? AND key IN ({placeholders}) AND ttl_minutes IS NOT NULL
1419
+ """
1420
+ update_params = (prefix_text, *key_list)
1421
+ try:
1422
+ cur.execute(update_query, update_params)
1423
+ except Exception as e:
1424
+ logger.error(
1425
+ f"Error during TTL refresh update for search: {e}"
1426
+ )
1427
+
1428
+ if "score" in query: # Vector search query
1429
+ items = [
1430
+ _row_to_search_item(
1431
+ _decode_ns_text(row[0]),
1432
+ {
1433
+ "key": row[1],
1434
+ "value": row[2],
1435
+ "created_at": row[3],
1436
+ "updated_at": row[4],
1437
+ "expires_at": row[5] if len(row) > 5 else None,
1438
+ "ttl_minutes": row[6] if len(row) > 6 else None,
1439
+ "score": row[7] if len(row) > 7 else None,
1440
+ },
1441
+ loader=self._deserializer,
1442
+ )
1443
+ for row in rows
1444
+ ]
1445
+ else: # Regular search query
1446
+ items = [
1447
+ _row_to_search_item(
1448
+ _decode_ns_text(row[0]),
1449
+ {
1450
+ "key": row[1],
1451
+ "value": row[2],
1452
+ "created_at": row[3],
1453
+ "updated_at": row[4],
1454
+ "expires_at": row[5] if len(row) > 5 else None,
1455
+ "ttl_minutes": row[6] if len(row) > 6 else None,
1456
+ },
1457
+ loader=self._deserializer,
1458
+ )
1459
+ for row in rows
1460
+ ]
1461
+
1462
+ results[original_op_idx] = items
1463
+
1464
+ def _batch_list_namespaces_ops(
1465
+ self,
1466
+ list_ops: Sequence[tuple[int, ListNamespacesOp]],
1467
+ results: list[Result],
1468
+ cur: sqlite3.Cursor,
1469
+ ) -> None:
1470
+ queries = self._get_batch_list_namespaces_queries(list_ops)
1471
+ for (query, params), (idx, _) in zip(queries, list_ops, strict=False):
1472
+ cur.execute(query, params)
1473
+ results[idx] = [_decode_ns_text(row[0]) for row in cur.fetchall()]
1474
+
1475
+ async def abatch(self, ops: Iterable[Op]) -> list[Result]:
1476
+ """Async batch operation - not supported in SqliteStore.
1477
+
1478
+ Use AsyncSqliteStore for async operations.
1479
+ """
1480
+ raise NotImplementedError(_AIO_ERROR_MSG)
1481
+
1482
+
1483
+ # Helper functions
1484
+
1485
+
1486
+ def _ensure_index_config(
1487
+ index_config: SqliteIndexConfig,
1488
+ ) -> tuple[Any, SqliteIndexConfig]:
1489
+ """Process and validate index configuration."""
1490
+ index_config = index_config.copy()
1491
+ tokenized: list[tuple[str, Literal["$"] | list[str]]] = []
1492
+ tot = 0
1493
+ text_fields = index_config.get("text_fields") or ["$"]
1494
+ if isinstance(text_fields, str):
1495
+ text_fields = [text_fields]
1496
+ if not isinstance(text_fields, list):
1497
+ raise ValueError(f"Text fields must be a list or a string. Got {text_fields}")
1498
+ for p in text_fields:
1499
+ if p == "$":
1500
+ tokenized.append((p, "$"))
1501
+ tot += 1
1502
+ else:
1503
+ toks = tokenize_path(p)
1504
+ tokenized.append((p, toks))
1505
+ tot += len(toks)
1506
+ index_config["__tokenized_fields"] = tokenized
1507
+ index_config["__estimated_num_vectors"] = tot
1508
+ embeddings = ensure_embeddings(
1509
+ index_config.get("embed"),
1510
+ )
1511
+ return embeddings, index_config
1512
+
1513
+
1514
+ _PLACEHOLDER = object()