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,1480 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import concurrent.futures
5
+ import json
6
+ import logging
7
+ import re
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 datetime import datetime
13
+ from typing import (
14
+ TYPE_CHECKING,
15
+ Any,
16
+ Generic,
17
+ Literal,
18
+ NamedTuple,
19
+ TypeVar,
20
+ cast,
21
+ )
22
+
23
+ import orjson
24
+ from coco_graph.store.base import (
25
+ BaseStore,
26
+ GetOp,
27
+ IndexConfig,
28
+ Item,
29
+ ListNamespacesOp,
30
+ Op,
31
+ PutOp,
32
+ Result,
33
+ SearchItem,
34
+ SearchOp,
35
+ TTLConfig,
36
+ ensure_embeddings,
37
+ get_text_at_path,
38
+ tokenize_path,
39
+ )
40
+ from psycopg import Capabilities, Connection, Cursor, Pipeline
41
+ from psycopg.rows import DictRow, dict_row
42
+ from psycopg.types.json import Jsonb
43
+ from psycopg_pool import ConnectionPool
44
+ from typing_extensions import TypedDict
45
+
46
+ from coco_graph.checkpoint.postgres import _ainternal as _ainternal
47
+ from coco_graph.checkpoint.postgres import _internal as _pg_internal
48
+
49
+ if TYPE_CHECKING:
50
+ from coco_core.embeddings import Embeddings
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ class Migration(NamedTuple):
56
+ """A database migration with optional conditions and parameters."""
57
+
58
+ sql: str
59
+ params: dict[str, Any] | None = None
60
+ condition: Callable[[BasePostgresStore], bool] | None = None
61
+
62
+
63
+ MIGRATIONS: Sequence[str] = [
64
+ """
65
+ CREATE TABLE IF NOT EXISTS store (
66
+ -- 'prefix' represents the doc's 'namespace'
67
+ prefix text NOT NULL,
68
+ key text NOT NULL,
69
+ value jsonb NOT NULL,
70
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
71
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
72
+ PRIMARY KEY (prefix, key)
73
+ );
74
+ """,
75
+ """
76
+ -- For faster lookups by prefix
77
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops);
78
+ """,
79
+ """
80
+ -- Add expires_at column to store table
81
+ ALTER TABLE store
82
+ ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP WITH TIME ZONE,
83
+ ADD COLUMN IF NOT EXISTS ttl_minutes INT;
84
+ """,
85
+ """
86
+ -- Add indexes for efficient TTL sweeping
87
+ CREATE INDEX IF NOT EXISTS idx_store_expires_at ON store (expires_at)
88
+ WHERE expires_at IS NOT NULL;
89
+ """,
90
+ ]
91
+
92
+ VECTOR_MIGRATIONS: Sequence[Migration] = [
93
+ Migration(
94
+ """
95
+ DO $$
96
+ BEGIN
97
+ IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
98
+ CREATE EXTENSION vector;
99
+ END IF;
100
+ END $$;
101
+ """,
102
+ ),
103
+ Migration(
104
+ """
105
+ CREATE TABLE IF NOT EXISTS store_vectors (
106
+ prefix text NOT NULL,
107
+ key text NOT NULL,
108
+ field_name text NOT NULL,
109
+ embedding %(vector_type)s(%(dims)s),
110
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
111
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
112
+ PRIMARY KEY (prefix, key, field_name),
113
+ FOREIGN KEY (prefix, key) REFERENCES store(prefix, key) ON DELETE CASCADE
114
+ );
115
+ """,
116
+ params={
117
+ "dims": lambda store: store.index_config["dims"],
118
+ "vector_type": lambda store: (
119
+ cast(PostgresIndexConfig, store.index_config)
120
+ .get("ann_index_config", {})
121
+ .get("vector_type", "vector")
122
+ ),
123
+ },
124
+ ),
125
+ Migration(
126
+ """
127
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS store_vectors_embedding_idx ON store_vectors
128
+ USING %(index_type)s (embedding %(ops)s)%(index_params)s;
129
+ """,
130
+ condition=lambda store: bool(
131
+ store.index_config and _get_index_params(store)[0] != "flat"
132
+ ),
133
+ params={
134
+ "index_type": lambda store: _get_index_params(store)[0],
135
+ "ops": lambda store: _get_vector_type_ops(store),
136
+ "index_params": lambda store: (
137
+ " WITH ("
138
+ + ", ".join(f"{k}={v}" for k, v in _get_index_params(store)[1].items())
139
+ + ")"
140
+ if _get_index_params(store)[1]
141
+ else ""
142
+ ),
143
+ },
144
+ ),
145
+ ]
146
+
147
+
148
+ C = TypeVar("C", bound=_pg_internal.Conn | _ainternal.Conn)
149
+
150
+
151
+ class PoolConfig(TypedDict, total=False):
152
+ """Connection pool settings for PostgreSQL connections.
153
+
154
+ Controls connection lifecycle and resource utilization:
155
+
156
+ - Small pools (1-5) suit low-concurrency workloads
157
+ - Larger pools handle concurrent requests but consume more resources
158
+ - Setting max_size prevents resource exhaustion under load
159
+ """
160
+
161
+ min_size: int
162
+ """Minimum number of connections maintained in the pool. Defaults to 1."""
163
+
164
+ max_size: int | None
165
+ """Maximum number of connections allowed in the pool. None means unlimited."""
166
+
167
+ kwargs: dict
168
+ """Additional connection arguments passed to each connection in the pool.
169
+
170
+ Default kwargs set automatically:
171
+
172
+ - autocommit: True
173
+ - prepare_threshold: 0
174
+ - row_factory: dict_row
175
+ """
176
+
177
+
178
+ class ANNIndexConfig(TypedDict, total=False):
179
+ """Configuration for vector index in PostgreSQL store."""
180
+
181
+ kind: Literal["hnsw", "ivfflat", "flat"]
182
+ """Type of index to use: 'hnsw' for Hierarchical Navigable Small World, or 'ivfflat' for Inverted File Flat."""
183
+ vector_type: Literal["vector", "halfvec"]
184
+ """Type of vector storage to use.
185
+ Options:
186
+ - 'vector': Regular vectors (default)
187
+ - 'halfvec': Half-precision vectors for reduced memory usage
188
+ """
189
+
190
+
191
+ class HNSWConfig(ANNIndexConfig, total=False):
192
+ """Configuration for HNSW (Hierarchical Navigable Small World) index."""
193
+
194
+ kind: Literal["hnsw"] # type: ignore[misc]
195
+ m: int
196
+ """Maximum number of connections per layer. Default is 16."""
197
+ ef_construction: int
198
+ """Size of dynamic candidate list for index construction. Default is 64."""
199
+
200
+
201
+ class IVFFlatConfig(ANNIndexConfig, total=False):
202
+ """IVFFlat index divides vectors into lists, and then searches a subset of those lists that are closest to the query vector. It has faster build times and uses less memory than HNSW, but has lower query performance (in terms of speed-recall tradeoff).
203
+
204
+ Three keys to achieving good recall are:
205
+ 1. Create the index after the table has some data
206
+ 2. Choose an appropriate number of lists - a good place to start is rows / 1000 for up to 1M rows and sqrt(rows) for over 1M rows
207
+ 3. When querying, specify an appropriate number of probes (higher is better for recall, lower is better for speed) - a good place to start is sqrt(lists)
208
+ """
209
+
210
+ kind: Literal["ivfflat"] # type: ignore[misc]
211
+ nlist: int
212
+ """Number of inverted lists (clusters) for IVF index.
213
+
214
+ Determines the number of clusters used in the index structure.
215
+ Higher values can improve search speed but increase index size and build time.
216
+ Typically set to the square root of the number of vectors in the index.
217
+ """
218
+
219
+
220
+ class PostgresIndexConfig(IndexConfig, total=False):
221
+ """Configuration for vector embeddings in PostgreSQL store with pgvector-specific options.
222
+
223
+ Extends EmbeddingConfig with additional configuration for pgvector index and vector types.
224
+ """
225
+
226
+ ann_index_config: ANNIndexConfig
227
+ """Specific configuration for the chosen index type (HNSW or IVF Flat)."""
228
+ distance_type: Literal["l2", "inner_product", "cosine"]
229
+ """Distance metric to use for vector similarity search:
230
+ - 'l2': Euclidean distance
231
+ - 'inner_product': Dot product
232
+ - 'cosine': Cosine similarity
233
+ """
234
+
235
+
236
+ class BasePostgresStore(Generic[C]):
237
+ MIGRATIONS = MIGRATIONS
238
+ VECTOR_MIGRATIONS = VECTOR_MIGRATIONS
239
+ conn: C
240
+ _deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None
241
+ index_config: PostgresIndexConfig | None
242
+ ttl_config: TTLConfig | None
243
+
244
+ @property
245
+ def _omit_expired(self) -> bool:
246
+ """Whether expired-but-unswept rows should be filtered from reads."""
247
+ return bool(self.ttl_config and self.ttl_config.get("omit_expired"))
248
+
249
+ def _get_batch_GET_ops_queries(
250
+ self,
251
+ get_ops: Sequence[tuple[int, GetOp]],
252
+ ) -> list[tuple[str, tuple, tuple[str, ...], list]]:
253
+ """
254
+ Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace.
255
+
256
+ Each returned element is a tuple of:
257
+ (sql_query_string, sql_params, namespace, items_for_this_namespace)
258
+
259
+ where items_for_this_namespace is the original list of (idx, key, refresh_ttl).
260
+ """
261
+
262
+ namespace_groups = defaultdict(list)
263
+ refresh_ttls = defaultdict(list)
264
+ for idx, op in get_ops:
265
+ namespace_groups[op.namespace].append((idx, op.key))
266
+ refresh_ttls[op.namespace].append(op.refresh_ttl)
267
+
268
+ omit_expired = self._omit_expired
269
+ expiry_clause = (
270
+ "AND (s.expires_at IS NULL OR s.expires_at > NOW())" if omit_expired else ""
271
+ )
272
+
273
+ results = []
274
+ for namespace, items in namespace_groups.items():
275
+ _, keys = zip(*items, strict=False)
276
+ this_refresh_ttls = refresh_ttls[namespace]
277
+
278
+ query = f"""
279
+ WITH passed_in AS (
280
+ SELECT unnest(%s::text[]) AS key,
281
+ unnest(%s::bool[]) AS do_refresh
282
+ ),
283
+ updated AS (
284
+ UPDATE store s
285
+ SET expires_at = NOW() + (s.ttl_minutes || ' minutes')::interval
286
+ FROM passed_in p
287
+ WHERE s.prefix = %s
288
+ AND s.key = p.key
289
+ AND p.do_refresh = TRUE
290
+ AND s.ttl_minutes IS NOT NULL
291
+ {expiry_clause}
292
+ RETURNING s.key
293
+ )
294
+ SELECT s.key, s.value, s.created_at, s.updated_at
295
+ FROM store s
296
+ JOIN passed_in p ON s.key = p.key
297
+ WHERE s.prefix = %s
298
+ {expiry_clause}
299
+ """
300
+ ns_text = _namespace_to_text(namespace)
301
+ params = (
302
+ list(keys), # -> unnest(%s::text[])
303
+ list(this_refresh_ttls), # -> unnest(%s::bool[])
304
+ ns_text, # -> prefix = %s (for UPDATE)
305
+ ns_text, # -> prefix = %s (for final SELECT)
306
+ )
307
+ results.append((query, params, namespace, items))
308
+
309
+ return results
310
+
311
+ def _prepare_batch_PUT_queries(
312
+ self,
313
+ put_ops: Sequence[tuple[int, PutOp]],
314
+ ) -> tuple[
315
+ list[tuple[str, Sequence]],
316
+ tuple[str, Sequence[tuple[str, str, str, str]]] | None,
317
+ ]:
318
+ dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
319
+ for _, op in put_ops:
320
+ dedupped_ops[(op.namespace, op.key)] = op
321
+
322
+ inserts: list[PutOp] = []
323
+ deletes: list[PutOp] = []
324
+ for op in dedupped_ops.values():
325
+ if op.value is None:
326
+ deletes.append(op)
327
+ else:
328
+ inserts.append(op)
329
+
330
+ queries: list[tuple[str, Sequence]] = []
331
+
332
+ if deletes:
333
+ namespace_groups: dict[tuple[str, ...], list[str]] = defaultdict(list)
334
+ for op in deletes:
335
+ namespace_groups[op.namespace].append(op.key)
336
+ for namespace, keys in namespace_groups.items():
337
+ placeholders = ",".join(["%s"] * len(keys))
338
+ query = (
339
+ f"DELETE FROM store WHERE prefix = %s AND key IN ({placeholders})"
340
+ )
341
+ params = (_namespace_to_text(namespace), *keys)
342
+ queries.append((query, params))
343
+ embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None
344
+ if inserts:
345
+ values = []
346
+ insertion_params: list[Any] = []
347
+ vector_values = []
348
+ embedding_request_params = []
349
+ # Handle TTL expiration
350
+
351
+ # First handle main store insertions
352
+ for op in inserts:
353
+ insertion_params.extend(
354
+ (
355
+ _namespace_to_text(op.namespace),
356
+ op.key,
357
+ Jsonb(dict(cast(Mapping[str, Any], op.value))),
358
+ )
359
+ )
360
+ if op.ttl is not None:
361
+ values.append(
362
+ "(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NOW() + %s::interval, %s)"
363
+ )
364
+ ttl_minutes = float(op.ttl)
365
+ insertion_params.extend(
366
+ (
367
+ f"{ttl_minutes * 60} seconds",
368
+ ttl_minutes,
369
+ )
370
+ )
371
+ else:
372
+ values.append(
373
+ "(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, %s)"
374
+ )
375
+ insertion_params.append(None)
376
+
377
+ # Then handle embeddings if configured
378
+ if self.index_config:
379
+ for op in inserts:
380
+ if op.index is False:
381
+ continue
382
+ value = op.value
383
+ ns = _namespace_to_text(op.namespace)
384
+ k = op.key
385
+
386
+ if op.index is None:
387
+ paths = cast(dict, self.index_config)["__tokenized_fields"]
388
+ else:
389
+ paths = [(ix, tokenize_path(ix)) for ix in op.index]
390
+
391
+ for path, tokenized_path in paths:
392
+ texts = get_text_at_path(value, tokenized_path)
393
+ for i, text in enumerate(texts):
394
+ pathname = f"{path}.{i}" if len(texts) > 1 else path
395
+ vector_values.append(
396
+ "(%s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
397
+ )
398
+ embedding_request_params.append((ns, k, pathname, text))
399
+
400
+ values_str = ",".join(values)
401
+ query = f"""
402
+ INSERT INTO store (prefix, key, value, created_at, updated_at, expires_at, ttl_minutes)
403
+ VALUES {values_str}
404
+ ON CONFLICT (prefix, key) DO UPDATE
405
+ SET value = EXCLUDED.value,
406
+ updated_at = CURRENT_TIMESTAMP,
407
+ expires_at = EXCLUDED.expires_at,
408
+ ttl_minutes = EXCLUDED.ttl_minutes
409
+ """
410
+ queries.append((query, insertion_params))
411
+
412
+ if vector_values:
413
+ values_str = ",".join(vector_values)
414
+ query = f"""
415
+ INSERT INTO store_vectors (prefix, key, field_name, embedding, created_at, updated_at)
416
+ VALUES {values_str}
417
+ ON CONFLICT (prefix, key, field_name) DO UPDATE
418
+ SET embedding = EXCLUDED.embedding,
419
+ updated_at = CURRENT_TIMESTAMP
420
+ """
421
+ embedding_request = (query, embedding_request_params)
422
+
423
+ return queries, embedding_request
424
+
425
+ def _prepare_batch_search_queries(
426
+ self,
427
+ search_ops: Sequence[tuple[int, SearchOp]],
428
+ ) -> tuple[
429
+ list[tuple[str, list[None | str | list[float]]]], # queries, params
430
+ list[tuple[int, str]], # idx, query_text pairs to embed
431
+ ]:
432
+ """
433
+ Build per-SearchOp SQL queries (with optional TTL refresh) plus embedding requests.
434
+ Returns:
435
+ - queries: list of (SQL, param_list)
436
+ - embedding_requests: list of (original_index_in_search_ops, text_query)
437
+ """
438
+
439
+ omit_expired = self._omit_expired
440
+ search_expiry_clause = (
441
+ "AND (store.expires_at IS NULL OR store.expires_at > NOW())"
442
+ if omit_expired
443
+ else ""
444
+ )
445
+
446
+ queries = []
447
+ embedding_requests = []
448
+ for idx, (_, op) in enumerate(search_ops):
449
+ filter_params = []
450
+ filter_clauses = []
451
+ if op.filter:
452
+ for key, value in op.filter.items():
453
+ if isinstance(value, dict):
454
+ for op_name, val in value.items():
455
+ condition, params_ = self._get_filter_condition(
456
+ key, op_name, val
457
+ )
458
+ filter_clauses.append(condition)
459
+ filter_params.extend(params_)
460
+ else:
461
+ filter_clauses.append("value->%s = %s::jsonb")
462
+ filter_params.extend([key, orjson.dumps(value).decode("utf-8")])
463
+
464
+ ns_condition = "TRUE"
465
+ ns_param: Sequence[str] | None = None
466
+ if op.namespace_prefix:
467
+ ns_condition, ns_param = _namespace_prefix_condition(
468
+ op.namespace_prefix
469
+ )
470
+ else:
471
+ ns_param = ()
472
+
473
+ extra_filters = (
474
+ " AND " + " AND ".join(filter_clauses) if filter_clauses else ""
475
+ )
476
+
477
+ if op.query and self.index_config:
478
+ # We'll embed the text later, so record the request.
479
+ embedding_requests.append((idx, op.query))
480
+
481
+ score_operator, post_operator = get_distance_operator(self)
482
+ post_operator = post_operator.replace("scored", "uniq")
483
+ vector_type = self.index_config.get("ann_index_config", {}).get(
484
+ "vector_type", "vector"
485
+ )
486
+
487
+ # For hamming bit vectors, or “regular” vectors
488
+ if (
489
+ vector_type == "bit"
490
+ and cast(dict, self.index_config).get("distance_type") == "hamming"
491
+ ):
492
+ score_operator = score_operator % (
493
+ "%s",
494
+ cast(dict, self.index_config)["dims"],
495
+ )
496
+ else:
497
+ if vector_type not in ("vector", "halfvec"):
498
+ raise ValueError(
499
+ f"Invalid vector_type for pgvector: {vector_type}"
500
+ )
501
+ score_operator = score_operator % ("%s", vector_type)
502
+
503
+ vectors_per_doc_estimate = cast(dict, self.index_config)[
504
+ "__estimated_num_vectors"
505
+ ]
506
+ expanded_limit = (op.limit * vectors_per_doc_estimate * 2) + 1
507
+
508
+ # “sub_scored” does the main vector search
509
+ # Then we do DISTINCT ON to drop duplicates if your store can have them
510
+ # Finally we limit & offset
511
+ vector_search_cte = f"""
512
+ SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at,
513
+ {score_operator} AS neg_score
514
+ FROM store
515
+ JOIN store_vectors sv ON store.prefix = sv.prefix AND store.key = sv.key
516
+ WHERE {ns_condition} {extra_filters} {search_expiry_clause}
517
+ ORDER BY {score_operator} ASC
518
+ LIMIT %s
519
+ """
520
+
521
+ search_results_sql = f"""
522
+ WITH scored AS (
523
+ {vector_search_cte}
524
+ )
525
+ SELECT uniq.prefix, uniq.key, uniq.value, uniq.created_at, uniq.updated_at,
526
+ {post_operator} AS score
527
+ FROM (
528
+ SELECT DISTINCT ON (scored.prefix, scored.key)
529
+ scored.prefix, scored.key, scored.value, scored.created_at, scored.updated_at, scored.neg_score
530
+ FROM scored
531
+ ORDER BY scored.prefix, scored.key, scored.neg_score ASC
532
+ ) uniq
533
+ ORDER BY score DESC
534
+ LIMIT %s
535
+ OFFSET %s
536
+ """
537
+
538
+ search_results_params = [
539
+ PLACEHOLDER,
540
+ *ns_param,
541
+ *filter_params,
542
+ PLACEHOLDER,
543
+ expanded_limit,
544
+ op.limit,
545
+ op.offset,
546
+ ]
547
+
548
+ else:
549
+ base_query = f"""
550
+ SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at, NULL AS score
551
+ FROM store
552
+ WHERE {ns_condition} {extra_filters} {search_expiry_clause}
553
+ ORDER BY store.updated_at DESC
554
+ LIMIT %s
555
+ OFFSET %s
556
+ """
557
+ search_results_sql = base_query
558
+ search_results_params = [
559
+ *ns_param,
560
+ *filter_params,
561
+ op.limit,
562
+ op.offset,
563
+ ]
564
+
565
+ if op.refresh_ttl:
566
+ # Wrap entire primary query in a CTE, then perform "update_at"
567
+ final_sql = f"""
568
+ WITH search_results AS (
569
+ {search_results_sql}
570
+ ),
571
+ updated AS (
572
+ UPDATE store s
573
+ SET expires_at = NOW() + (s.ttl_minutes || ' minutes')::interval
574
+ FROM search_results sr
575
+ WHERE s.prefix = sr.prefix
576
+ AND s.key = sr.key
577
+ AND s.ttl_minutes IS NOT NULL
578
+ )
579
+ SELECT sr.prefix, sr.key, sr.value, sr.created_at, sr.updated_at, sr.score
580
+ FROM search_results sr
581
+ """
582
+ final_params = search_results_params[:] # copy
583
+ else:
584
+ final_sql = search_results_sql
585
+ final_params = search_results_params
586
+ queries.append((final_sql, final_params))
587
+
588
+ return queries, embedding_requests
589
+
590
+ def _get_batch_list_namespaces_queries(
591
+ self,
592
+ list_ops: Sequence[tuple[int, ListNamespacesOp]],
593
+ ) -> list[tuple[str, Sequence]]:
594
+ queries: list[tuple[str, Sequence]] = []
595
+ for _, op in list_ops:
596
+ query = r"""
597
+ SELECT DISTINCT ON (truncated_prefix) truncated_prefix, prefix
598
+ FROM (
599
+ SELECT
600
+ prefix,
601
+ CASE
602
+ WHEN %s::integer IS NOT NULL THEN
603
+ (SELECT STRING_AGG(part, '.' ORDER BY idx)
604
+ FROM (
605
+ SELECT part, ROW_NUMBER() OVER () AS idx
606
+ FROM UNNEST(REGEXP_SPLIT_TO_ARRAY(prefix, '\.')) AS part
607
+ LIMIT %s::integer
608
+ ) subquery
609
+ )
610
+ ELSE prefix
611
+ END AS truncated_prefix
612
+ FROM store
613
+ """
614
+ params: list[Any] = [op.max_depth, op.max_depth]
615
+
616
+ omit_expired = self._omit_expired
617
+ conditions = []
618
+ if omit_expired:
619
+ conditions.append("(expires_at IS NULL OR expires_at > NOW())")
620
+ if op.match_conditions:
621
+ for condition in op.match_conditions:
622
+ if condition.match_type in ("prefix", "suffix"):
623
+ if not condition.path:
624
+ # An empty path constrains nothing; skipping keeps it a
625
+ # no-op rather than emitting a pattern that matches no
626
+ # namespace at all.
627
+ continue
628
+ conditions.append("prefix ~ %s")
629
+ params.append(
630
+ _namespace_match_pattern(
631
+ condition.path, condition.match_type
632
+ )
633
+ )
634
+ else:
635
+ logger.warning(
636
+ f"Unknown match_type in list_namespaces: {condition.match_type}"
637
+ )
638
+
639
+ if conditions:
640
+ query += " WHERE " + " AND ".join(conditions)
641
+ query += ") AS subquery "
642
+
643
+ query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s"
644
+ params.extend([op.limit, op.offset])
645
+ queries.append((query, tuple(params)))
646
+
647
+ return queries
648
+
649
+ def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]:
650
+ """Helper to generate filter conditions."""
651
+ if op == "$eq":
652
+ return "value->%s = %s::jsonb", [key, json.dumps(value)]
653
+ elif op == "$gt":
654
+ return "value->>%s > %s", [key, str(value)]
655
+ elif op == "$gte":
656
+ return "value->>%s >= %s", [key, str(value)]
657
+ elif op == "$lt":
658
+ return "value->>%s < %s", [key, str(value)]
659
+ elif op == "$lte":
660
+ return "value->>%s <= %s", [key, str(value)]
661
+ elif op == "$ne":
662
+ return "value->%s != %s::jsonb", [key, json.dumps(value)]
663
+ else:
664
+ raise ValueError(f"Unsupported operator: {op}")
665
+
666
+
667
+ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
668
+ """Postgres-backed store with optional vector search using pgvector.
669
+
670
+ !!! example "Examples"
671
+ Basic setup and usage:
672
+ ```python
673
+ from coco_graph.store.postgres import PostgresStore
674
+ from psycopg import Connection
675
+
676
+ conn_string = "postgresql://user:pass@localhost:5432/dbname"
677
+
678
+ # Using direct connection
679
+ with Connection.connect(conn_string) as conn:
680
+ store = PostgresStore(conn)
681
+ store.setup() # Run migrations. Done once
682
+
683
+ # Store and retrieve data
684
+ store.put(("users", "123"), "prefs", {"theme": "dark"})
685
+ item = store.get(("users", "123"), "prefs")
686
+ ```
687
+
688
+ Or using the convenient `from_conn_string` helper:
689
+
690
+ ```python
691
+ from coco_graph.store.postgres import PostgresStore
692
+
693
+ conn_string = "postgresql://user:pass@localhost:5432/dbname"
694
+
695
+ with PostgresStore.from_conn_string(conn_string) as store:
696
+ store.setup()
697
+
698
+ # Store and retrieve data
699
+ store.put(("users", "123"), "prefs", {"theme": "dark"})
700
+ item = store.get(("users", "123"), "prefs")
701
+ ```
702
+
703
+ Vector search using Coco embeddings:
704
+ ```python
705
+ from coco.embeddings import init_embeddings
706
+ from coco_graph.store.postgres import PostgresStore
707
+
708
+ conn_string = "postgresql://user:pass@localhost:5432/dbname"
709
+
710
+ with PostgresStore.from_conn_string(
711
+ conn_string,
712
+ index={
713
+ "dims": 1536,
714
+ "embed": init_embeddings("openai:text-embedding-3-small"),
715
+ "fields": ["text"] # specify which fields to embed. Default is the whole serialized value
716
+ }
717
+ ) as store:
718
+ store.setup() # Do this once to run migrations
719
+
720
+ # Store documents
721
+ store.put(("docs",), "doc1", {"text": "Python tutorial"})
722
+ store.put(("docs",), "doc2", {"text": "TypeScript guide"})
723
+ store.put(("docs",), "doc2", {"text": "Other guide"}, index=False) # don't index
724
+
725
+ # Search by similarity
726
+ results = store.search(("docs",), query="programming guides", limit=2)
727
+ ```
728
+
729
+ Note:
730
+ Semantic search is disabled by default. You can enable it by providing an `index` configuration
731
+ when creating the store. Without this configuration, all `index` arguments passed to
732
+ `put` or `aput`will have no effect.
733
+
734
+ Warning:
735
+ Make sure to call `setup()` before first use to create necessary tables and indexes.
736
+ The pgvector extension must be available to use vector search.
737
+
738
+ Note:
739
+ If you provide a TTL configuration, you must explicitly call `start_ttl_sweeper()` to begin
740
+ the background thread that removes expired items. Call `stop_ttl_sweeper()` to properly
741
+ clean up resources when you're done with the store.
742
+
743
+ """
744
+
745
+ __slots__ = (
746
+ "_deserializer",
747
+ "pipe",
748
+ "lock",
749
+ "supports_pipeline",
750
+ "index_config",
751
+ "embeddings",
752
+ "_ttl_sweeper_thread",
753
+ "_ttl_stop_event",
754
+ )
755
+ supports_ttl: bool = True
756
+
757
+ def __init__(
758
+ self,
759
+ conn: _pg_internal.Conn,
760
+ *,
761
+ pipe: Pipeline | None = None,
762
+ deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None,
763
+ index: PostgresIndexConfig | None = None,
764
+ ttl: TTLConfig | None = None,
765
+ ) -> None:
766
+ super().__init__()
767
+ self._deserializer = deserializer
768
+ self.conn = conn
769
+ self.pipe = pipe
770
+ self.supports_pipeline = Capabilities().has_pipeline()
771
+ self.lock = threading.Lock()
772
+ self.index_config = index
773
+ if self.index_config:
774
+ self.embeddings, self.index_config = _ensure_index_config(self.index_config)
775
+ else:
776
+ self.embeddings = None
777
+ self.ttl_config = ttl
778
+ self._ttl_sweeper_thread: threading.Thread | None = None
779
+ self._ttl_stop_event = threading.Event()
780
+
781
+ @classmethod
782
+ @contextmanager
783
+ def from_conn_string(
784
+ cls,
785
+ conn_string: str,
786
+ *,
787
+ pipeline: bool = False,
788
+ pool_config: PoolConfig | None = None,
789
+ index: PostgresIndexConfig | None = None,
790
+ ttl: TTLConfig | None = None,
791
+ ) -> Iterator[PostgresStore]:
792
+ """Create a new PostgresStore instance from a connection string.
793
+
794
+ Args:
795
+ conn_string: The Postgres connection info string.
796
+ pipeline: whether to use Pipeline
797
+ pool_config: Configuration for the connection pool.
798
+ If provided, will create a connection pool and use it instead of a single connection.
799
+ This overrides the `pipeline` argument.
800
+ index: The index configuration for the store.
801
+ ttl: The TTL configuration for the store.
802
+
803
+ Returns:
804
+ PostgresStore: A new PostgresStore instance.
805
+ """
806
+ if pool_config is not None:
807
+ pc = pool_config.copy()
808
+ with cast(
809
+ ConnectionPool[Connection[DictRow]],
810
+ ConnectionPool(
811
+ conn_string,
812
+ min_size=pc.pop("min_size", 1),
813
+ max_size=pc.pop("max_size", None),
814
+ kwargs={
815
+ "autocommit": True,
816
+ "prepare_threshold": 0,
817
+ "row_factory": dict_row,
818
+ **(pc.pop("kwargs", None) or {}),
819
+ },
820
+ **cast(dict, pc),
821
+ ),
822
+ ) as pool:
823
+ yield cls(conn=pool, index=index, ttl=ttl)
824
+ else:
825
+ with Connection.connect(
826
+ conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
827
+ ) as conn:
828
+ if pipeline:
829
+ with conn.pipeline() as pipe:
830
+ yield cls(conn, pipe=pipe, index=index, ttl=ttl)
831
+ else:
832
+ yield cls(conn, index=index, ttl=ttl)
833
+
834
+ def sweep_ttl(self) -> int:
835
+ """Delete expired store items based on TTL.
836
+
837
+ Returns:
838
+ int: The number of deleted items.
839
+ """
840
+ with self._cursor() as cur:
841
+ cur.execute(
842
+ """
843
+ DELETE FROM store
844
+ WHERE expires_at IS NOT NULL AND expires_at < NOW()
845
+ """
846
+ )
847
+ deleted_count = cur.rowcount
848
+ return deleted_count
849
+
850
+ def start_ttl_sweeper(
851
+ self, sweep_interval_minutes: int | None = None
852
+ ) -> concurrent.futures.Future[None]:
853
+ """Periodically delete expired store items based on TTL.
854
+
855
+ Returns:
856
+ Future that can be waited on or cancelled.
857
+ """
858
+ if not self.ttl_config:
859
+ future: concurrent.futures.Future[None] = concurrent.futures.Future()
860
+ future.set_result(None)
861
+ return future
862
+
863
+ if self._ttl_sweeper_thread and self._ttl_sweeper_thread.is_alive():
864
+ logger.info("TTL sweeper thread is already running")
865
+ # Return a future that can be used to cancel the existing thread
866
+ future = concurrent.futures.Future()
867
+ future.add_done_callback(
868
+ lambda f: self._ttl_stop_event.set() if f.cancelled() else None
869
+ )
870
+ return future
871
+
872
+ self._ttl_stop_event.clear()
873
+
874
+ interval = float(
875
+ sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5
876
+ )
877
+ logger.info(f"Starting store TTL sweeper with interval {interval} minutes")
878
+
879
+ future = concurrent.futures.Future()
880
+
881
+ def _sweep_loop() -> None:
882
+ try:
883
+ while not self._ttl_stop_event.is_set():
884
+ if self._ttl_stop_event.wait(interval * 60):
885
+ break
886
+
887
+ try:
888
+ expired_items = self.sweep_ttl()
889
+ if expired_items > 0:
890
+ logger.info(f"Store swept {expired_items} expired items")
891
+ except Exception as exc:
892
+ logger.exception(
893
+ "Store TTL sweep iteration failed", exc_info=exc
894
+ )
895
+ future.set_result(None)
896
+ except Exception as exc:
897
+ future.set_exception(exc)
898
+
899
+ thread = threading.Thread(target=_sweep_loop, daemon=True, name="ttl-sweeper")
900
+ self._ttl_sweeper_thread = thread
901
+ thread.start()
902
+
903
+ future.add_done_callback(
904
+ lambda f: self._ttl_stop_event.set() if f.cancelled() else None
905
+ )
906
+ return future
907
+
908
+ def stop_ttl_sweeper(self, timeout: float | None = None) -> bool:
909
+ """Stop the TTL sweeper thread if it's running.
910
+
911
+ Args:
912
+ timeout: Maximum time to wait for the thread to stop, in seconds.
913
+ If `None`, wait indefinitely.
914
+
915
+ Returns:
916
+ bool: True if the thread was successfully stopped or wasn't running,
917
+ False if the timeout was reached before the thread stopped.
918
+ """
919
+ if not self._ttl_sweeper_thread or not self._ttl_sweeper_thread.is_alive():
920
+ return True
921
+
922
+ logger.info("Stopping TTL sweeper thread")
923
+ self._ttl_stop_event.set()
924
+
925
+ self._ttl_sweeper_thread.join(timeout)
926
+ success = not self._ttl_sweeper_thread.is_alive()
927
+
928
+ if success:
929
+ self._ttl_sweeper_thread = None
930
+ logger.info("TTL sweeper thread stopped")
931
+ else:
932
+ logger.warning("Timed out waiting for TTL sweeper thread to stop")
933
+
934
+ return success
935
+
936
+ def __del__(self) -> None:
937
+ """Ensure the TTL sweeper thread is stopped when the object is garbage collected."""
938
+ if hasattr(self, "_ttl_stop_event") and hasattr(self, "_ttl_sweeper_thread"):
939
+ self.stop_ttl_sweeper(timeout=0.1)
940
+
941
+ @contextmanager
942
+ def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
943
+ """Create a database cursor as a context manager.
944
+
945
+ Args:
946
+ pipeline: whether to use pipeline for the DB operations inside the context manager.
947
+ Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline.
948
+ If pipeline mode is not supported, will fall back to using transaction context manager.
949
+ """
950
+ with _pg_internal.get_connection(self.conn) as conn:
951
+ if self.pipe:
952
+ # a connection in pipeline mode can be used concurrently
953
+ # in multiple threads/coroutines, but only one cursor can be
954
+ # used at a time
955
+ try:
956
+ with conn.cursor(binary=True, row_factory=dict_row) as cur:
957
+ yield cur
958
+ finally:
959
+ if pipeline:
960
+ self.pipe.sync()
961
+ elif pipeline:
962
+ # a connection not in pipeline mode can only be used by one
963
+ # thread/coroutine at a time, so we acquire a lock
964
+ if self.supports_pipeline:
965
+ with (
966
+ self.lock,
967
+ conn.pipeline(),
968
+ conn.cursor(binary=True, row_factory=dict_row) as cur,
969
+ ):
970
+ yield cur
971
+ else:
972
+ with (
973
+ self.lock,
974
+ conn.transaction(),
975
+ conn.cursor(binary=True, row_factory=dict_row) as cur,
976
+ ):
977
+ yield cur
978
+ else:
979
+ with conn.cursor(binary=True, row_factory=dict_row) as cur:
980
+ yield cur
981
+
982
+ def batch(self, ops: Iterable[Op]) -> list[Result]:
983
+ grouped_ops, num_ops = _group_ops(ops)
984
+ results: list[Result] = [None] * num_ops
985
+
986
+ with self._cursor(pipeline=True) as cur:
987
+ if GetOp in grouped_ops:
988
+ self._batch_get_ops(
989
+ cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results, cur
990
+ )
991
+
992
+ if SearchOp in grouped_ops:
993
+ self._batch_search_ops(
994
+ cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
995
+ results,
996
+ cur,
997
+ )
998
+
999
+ if ListNamespacesOp in grouped_ops:
1000
+ self._batch_list_namespaces_ops(
1001
+ cast(
1002
+ Sequence[tuple[int, ListNamespacesOp]],
1003
+ grouped_ops[ListNamespacesOp],
1004
+ ),
1005
+ results,
1006
+ cur,
1007
+ )
1008
+ if PutOp in grouped_ops:
1009
+ self._batch_put_ops(
1010
+ cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), cur
1011
+ )
1012
+
1013
+ return results
1014
+
1015
+ def _batch_get_ops(
1016
+ self,
1017
+ get_ops: Sequence[tuple[int, GetOp]],
1018
+ results: list[Result],
1019
+ cur: Cursor[DictRow],
1020
+ ) -> None:
1021
+ for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
1022
+ cur.execute(query, params)
1023
+ rows = cast(list[Row], cur.fetchall())
1024
+ key_to_row = {row["key"]: row for row in rows}
1025
+ for idx, key in items:
1026
+ row = key_to_row.get(key)
1027
+ if row:
1028
+ results[idx] = _row_to_item(
1029
+ namespace, row, loader=self._deserializer
1030
+ )
1031
+ else:
1032
+ results[idx] = None
1033
+
1034
+ def _batch_put_ops(
1035
+ self,
1036
+ put_ops: Sequence[tuple[int, PutOp]],
1037
+ cur: Cursor[DictRow],
1038
+ ) -> None:
1039
+ queries, embedding_request = self._prepare_batch_PUT_queries(put_ops)
1040
+ if embedding_request:
1041
+ if self.embeddings is None:
1042
+ # Should not get here since the embedding config is required
1043
+ # to return an embedding_request above
1044
+ raise ValueError(
1045
+ "Embedding configuration is required for vector operations "
1046
+ f"(for semantic search). "
1047
+ f"Please provide an Embeddings when initializing the {self.__class__.__name__}."
1048
+ )
1049
+ query, txt_params = embedding_request
1050
+ # Update the params to replace the raw text with the vectors
1051
+ vectors = self.embeddings.embed_documents(
1052
+ [param[-1] for param in txt_params]
1053
+ )
1054
+ queries.append(
1055
+ (
1056
+ query,
1057
+ [
1058
+ p
1059
+ for (ns, k, pathname, _), vector in zip(
1060
+ txt_params, vectors, strict=False
1061
+ )
1062
+ for p in (ns, k, pathname, vector)
1063
+ ],
1064
+ )
1065
+ )
1066
+
1067
+ for query, params in queries:
1068
+ cur.execute(query, params)
1069
+
1070
+ def _batch_search_ops(
1071
+ self,
1072
+ search_ops: Sequence[tuple[int, SearchOp]],
1073
+ results: list[Result],
1074
+ cur: Cursor[DictRow],
1075
+ ) -> None:
1076
+ queries, embedding_requests = self._prepare_batch_search_queries(search_ops)
1077
+
1078
+ if embedding_requests and self.embeddings:
1079
+ embeddings = self.embeddings.embed_documents(
1080
+ [query for _, query in embedding_requests]
1081
+ )
1082
+ for (idx, _), embedding in zip(
1083
+ embedding_requests, embeddings, strict=False
1084
+ ):
1085
+ _paramslist = queries[idx][1]
1086
+ for i in range(len(_paramslist)):
1087
+ if _paramslist[i] is PLACEHOLDER:
1088
+ _paramslist[i] = embedding
1089
+
1090
+ for (idx, _), (query, params) in zip(search_ops, queries, strict=False):
1091
+ cur.execute(query, params)
1092
+ rows = cast(list[Row], cur.fetchall())
1093
+ results[idx] = [
1094
+ _row_to_search_item(
1095
+ _decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
1096
+ )
1097
+ for row in rows
1098
+ ]
1099
+
1100
+ def _batch_list_namespaces_ops(
1101
+ self,
1102
+ list_ops: Sequence[tuple[int, ListNamespacesOp]],
1103
+ results: list[Result],
1104
+ cur: Cursor[DictRow],
1105
+ ) -> None:
1106
+ for (query, params), (idx, _) in zip(
1107
+ self._get_batch_list_namespaces_queries(list_ops), list_ops, strict=False
1108
+ ):
1109
+ cur.execute(query, params)
1110
+ results[idx] = [_decode_ns_bytes(row["truncated_prefix"]) for row in cur]
1111
+
1112
+ async def abatch(self, ops: Iterable[Op]) -> list[Result]:
1113
+ return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
1114
+
1115
+ def setup(self) -> None:
1116
+ """Set up the store database.
1117
+
1118
+ This method creates the necessary tables in the Postgres database if they don't
1119
+ already exist and runs database migrations. It MUST be called directly by the user
1120
+ the first time the store is used.
1121
+ """
1122
+
1123
+ def _get_version(cur: Cursor[dict[str, Any]], table: str) -> int:
1124
+ cur.execute(
1125
+ f"""
1126
+ CREATE TABLE IF NOT EXISTS {table} (
1127
+ v INTEGER PRIMARY KEY
1128
+ )
1129
+ """
1130
+ )
1131
+ cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
1132
+ row = cast(dict, cur.fetchone())
1133
+ if row is None:
1134
+ version = -1
1135
+ else:
1136
+ version = row["v"]
1137
+ return version
1138
+
1139
+ with self._cursor() as cur:
1140
+ version = _get_version(cur, table="store_migrations")
1141
+ for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1):
1142
+ try:
1143
+ cur.execute(sql)
1144
+ cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
1145
+ except Exception as e:
1146
+ logger.error(
1147
+ f"Failed to apply migration {v}.\nSql={sql}\nError={e}"
1148
+ )
1149
+ raise
1150
+
1151
+ if self.index_config:
1152
+ version = _get_version(cur, table="vector_migrations")
1153
+ for v, migration in enumerate(
1154
+ self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1
1155
+ ):
1156
+ if migration.condition and not migration.condition(self):
1157
+ continue
1158
+ sql = migration.sql
1159
+ if migration.params:
1160
+ params = {
1161
+ k: v(self) if v is not None and callable(v) else v
1162
+ for k, v in migration.params.items()
1163
+ }
1164
+ if "dims" in params:
1165
+ try:
1166
+ params["dims"] = int(params["dims"])
1167
+ except Exception as e:
1168
+ raise ValueError(
1169
+ f"Invalid dims for vector index: {params['dims']}"
1170
+ ) from e
1171
+ if "vector_type" in params:
1172
+ vt = str(params["vector_type"])
1173
+ if vt not in ("vector", "halfvec"):
1174
+ raise ValueError(
1175
+ f"Invalid vector_type for pgvector: {vt}"
1176
+ )
1177
+ params["vector_type"] = vt
1178
+ if "index_type" in params:
1179
+ it = str(params["index_type"])
1180
+ if it not in ("hnsw", "ivfflat"):
1181
+ raise ValueError(
1182
+ f"Invalid index_type for pgvector: {it}"
1183
+ )
1184
+ params["index_type"] = it
1185
+ sql = sql % params
1186
+ cur.execute(sql)
1187
+ cur.execute("INSERT INTO vector_migrations (v) VALUES (%s)", (v,))
1188
+
1189
+
1190
+ class Row(TypedDict):
1191
+ key: str
1192
+ value: Any
1193
+ prefix: str
1194
+ created_at: datetime
1195
+ updated_at: datetime
1196
+
1197
+
1198
+ # Private utilities
1199
+
1200
+ _DEFAULT_ANN_CONFIG = ANNIndexConfig(
1201
+ vector_type="vector",
1202
+ )
1203
+
1204
+
1205
+ def _get_vector_type_ops(store: BasePostgresStore) -> str:
1206
+ """Get the vector type operator class based on config."""
1207
+ if not store.index_config:
1208
+ return "vector_cosine_ops"
1209
+
1210
+ config = store.index_config
1211
+ index_config = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy()
1212
+ vector_type = cast(str, index_config.get("vector_type", "vector"))
1213
+ if vector_type not in ("vector", "halfvec"):
1214
+ raise ValueError(
1215
+ f"Vector type must be 'vector' or 'halfvec', got {vector_type}"
1216
+ )
1217
+
1218
+ distance_type = config.get("distance_type", "cosine")
1219
+
1220
+ # For regular vectors
1221
+ type_prefix = {"vector": "vector", "halfvec": "halfvec"}[vector_type]
1222
+
1223
+ if distance_type not in ("l2", "inner_product", "cosine"):
1224
+ raise ValueError(
1225
+ f"Vector type {vector_type} only supports 'l2', 'inner_product', or 'cosine' distance, got {distance_type}"
1226
+ )
1227
+
1228
+ distance_suffix = {
1229
+ "l2": "l2_ops",
1230
+ "inner_product": "ip_ops",
1231
+ "cosine": "cosine_ops",
1232
+ }[distance_type]
1233
+
1234
+ return f"{type_prefix}_{distance_suffix}"
1235
+
1236
+
1237
+ def _get_index_params(store: Any) -> tuple[str, dict[str, Any]]:
1238
+ """Get a sanitized index type and configuration based on config.
1239
+
1240
+ Only allow known-safe kinds and integer parameters to avoid SQL injection
1241
+ when constructing DDL strings for index creation.
1242
+ """
1243
+ if not store.index_config:
1244
+ return "hnsw", {}
1245
+
1246
+ config = cast(PostgresIndexConfig, store.index_config)
1247
+ raw = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy()
1248
+
1249
+ kind = str(raw.pop("kind", "hnsw"))
1250
+ if kind not in ("hnsw", "ivfflat", "flat"):
1251
+ raise ValueError(
1252
+ f"Invalid index kind for pgvector: {kind}. Expected 'hnsw', 'ivfflat', or 'flat'."
1253
+ )
1254
+
1255
+ raw.pop("vector_type", None)
1256
+
1257
+ if kind == "hnsw":
1258
+ allowed_keys = {"m", "ef_construction"}
1259
+ else: # ivfflat/flat
1260
+ allowed_keys = {"lists", "nlist"}
1261
+
1262
+ sanitized: dict[str, int] = {}
1263
+ for k, v in list(raw.items()):
1264
+ if k not in allowed_keys:
1265
+ continue
1266
+ key = "lists" if k == "nlist" else k
1267
+ try:
1268
+ ivalue = int(v) # type: ignore[call-overload]
1269
+ except Exception as e:
1270
+ raise ValueError(f"Invalid index parameter value for {k}: {v}") from e
1271
+ if ivalue <= 0:
1272
+ continue
1273
+ sanitized[key] = ivalue
1274
+
1275
+ return kind, sanitized
1276
+
1277
+
1278
+ def _namespace_to_text(namespace: tuple[str, ...]) -> str:
1279
+ """Convert namespace tuple to text string."""
1280
+ return ".".join(namespace)
1281
+
1282
+
1283
+ def _escape_like_literal(text: str) -> str:
1284
+ """Escape LIKE metacharacters so `text` is matched literally.
1285
+
1286
+ Namespace labels may contain `_` and `%`, which would otherwise act as
1287
+ wildcards: `("user_1",)` would match `("userX1",)`. Backslash is escaped
1288
+ first so it cannot escape the following character. Requires an explicit
1289
+ `ESCAPE '\\'` clause on the pattern.
1290
+ """
1291
+ return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
1292
+
1293
+
1294
+ def _namespace_prefix_condition(namespace_prefix: tuple[str, ...]) -> tuple[str, tuple]:
1295
+ """Build the SQL scoping a search to a namespace and its descendants.
1296
+
1297
+ Matches the namespace exactly or requires the `.` separator before any
1298
+ remainder, so a prefix of `("foo",)` does not also match `("foobar",)`.
1299
+
1300
+ Both arms stay index-friendly: equality on the `(prefix, key)` primary key,
1301
+ the anchored LIKE on the `prefix text_pattern_ops` index.
1302
+
1303
+ Only the LIKE arm is escaped -- equality does not interpret metacharacters,
1304
+ so escaping it would stop `("user_1",)` from matching itself.
1305
+ """
1306
+ path = _namespace_to_text(namespace_prefix)
1307
+ condition = r"(store.prefix = %s OR store.prefix LIKE %s ESCAPE '\')"
1308
+ return condition, (path, f"{_escape_like_literal(path)}.%")
1309
+
1310
+
1311
+ def _namespace_match_pattern(path: tuple[str, ...], match_type: str) -> str:
1312
+ """Build a POSIX regex matching the dot-joined prefix on whole segments.
1313
+
1314
+ Needed because `LIKE` cannot express "any character except the separator".
1315
+ Matches how `InMemoryStore` compares namespaces element-wise.
1316
+
1317
+ `*` matches exactly one segment. Prefix matches stay open-ended but must end
1318
+ on a separator; suffix matches anchor at the end and begin on one.
1319
+
1320
+ Examples:
1321
+ prefix ("uid", "*", "alice") -> ^uid\\.[^.]+\\.alice(\\.|\\Z)
1322
+ suffix ("alice",) -> (^|\\.)alice\\Z
1323
+ """
1324
+ segments = ("[^.]+" if part == "*" else re.escape(part) for part in path)
1325
+ body = r"\.".join(segments)
1326
+ if match_type == "suffix":
1327
+ return rf"(^|\.){body}\Z"
1328
+ return rf"^{body}(\.|\Z)"
1329
+
1330
+
1331
+ def _row_to_item(
1332
+ namespace: tuple[str, ...],
1333
+ row: Row,
1334
+ *,
1335
+ loader: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None,
1336
+ ) -> Item:
1337
+ """Convert a row from the database into an Item.
1338
+
1339
+ Args:
1340
+ namespace: Item namespace
1341
+ row: Database row
1342
+ loader: Optional value loader for non-dict values
1343
+ """
1344
+ val = row["value"]
1345
+ if not isinstance(val, dict):
1346
+ val = (loader or _json_loads)(val)
1347
+
1348
+ kwargs = {
1349
+ "key": row["key"],
1350
+ "namespace": namespace,
1351
+ "value": val,
1352
+ "created_at": row["created_at"],
1353
+ "updated_at": row["updated_at"],
1354
+ }
1355
+
1356
+ return Item(**kwargs)
1357
+
1358
+
1359
+ def _row_to_search_item(
1360
+ namespace: tuple[str, ...],
1361
+ row: Row,
1362
+ *,
1363
+ loader: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None,
1364
+ ) -> SearchItem:
1365
+ """Convert a row from the database into an Item."""
1366
+ loader = loader or _json_loads
1367
+ val = row["value"]
1368
+ score = row.get("score")
1369
+ if score is not None:
1370
+ try:
1371
+ score = float(score) # type: ignore[arg-type]
1372
+ except ValueError:
1373
+ logger.warning("Invalid score: %s", score)
1374
+ score = None
1375
+ return SearchItem(
1376
+ value=val if isinstance(val, dict) else loader(val),
1377
+ key=row["key"],
1378
+ namespace=namespace,
1379
+ created_at=row["created_at"],
1380
+ updated_at=row["updated_at"],
1381
+ score=score,
1382
+ )
1383
+
1384
+
1385
+ def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]:
1386
+ grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
1387
+ tot = 0
1388
+ for idx, op in enumerate(ops):
1389
+ grouped_ops[type(op)].append((idx, op))
1390
+ tot += 1
1391
+ return grouped_ops, tot
1392
+
1393
+
1394
+ def _json_loads(content: bytes | orjson.Fragment) -> Any:
1395
+ if isinstance(content, orjson.Fragment):
1396
+ if hasattr(content, "buf"):
1397
+ content = content.buf
1398
+ else:
1399
+ if isinstance(content.contents, bytes):
1400
+ content = content.contents
1401
+ else:
1402
+ content = content.contents.encode()
1403
+ return orjson.loads(cast(bytes, content))
1404
+
1405
+
1406
+ def _decode_ns_bytes(namespace: str | bytes | list) -> tuple[str, ...]:
1407
+ if isinstance(namespace, list):
1408
+ return tuple(namespace)
1409
+ if isinstance(namespace, bytes):
1410
+ namespace = namespace.decode()[1:]
1411
+ return tuple(namespace.split("."))
1412
+
1413
+
1414
+ def get_distance_operator(store: Any) -> tuple[str, str]:
1415
+ """Get the distance operator and score expression based on config."""
1416
+ # Note: Today, we are not using ANN indices due to restrictions
1417
+ # on PGVector's support for mixing vector and non-vector filters
1418
+ # To use the index, PGVector expects:
1419
+ # - ORDER BY the operator NOT an expression (even negation blocks it)
1420
+ # - ASCENDING order
1421
+ # - Any WHERE clause should be over a partial index.
1422
+ # If we violate any of these, it will use a sequential scan
1423
+ # See https://github.com/pgvector/pgvector/issues/216 and the
1424
+ # pgvector documentation for more details.
1425
+ if not store.index_config:
1426
+ raise ValueError(
1427
+ "Embedding configuration is required for vector operations "
1428
+ f"(for semantic search). "
1429
+ f"Please provide an Embeddings when initializing the {store.__class__.__name__}."
1430
+ )
1431
+
1432
+ config = cast(PostgresIndexConfig, store.index_config)
1433
+ distance_type = config.get("distance_type", "cosine")
1434
+
1435
+ # Return the operator and the score expression
1436
+ # The operator is used in the CTE and will be compatible with an ASCENDING ORDER
1437
+ # sort clause.
1438
+ # The score expression is used in the final query and will be compatible with
1439
+ # a DESCENDING ORDER sort clause and the user's expectations of what the similarity score
1440
+ # should be.
1441
+ if distance_type == "l2":
1442
+ # Final: "-(sv.embedding <-> %s::%s)"
1443
+ # We return the "l2 similarity" so that the sorting order is the same
1444
+ return "sv.embedding <-> %s::%s", "-scored.neg_score"
1445
+ elif distance_type == "inner_product":
1446
+ # Final: "-(sv.embedding <#> %s::%s)"
1447
+ return "sv.embedding <#> %s::%s", "-(scored.neg_score)"
1448
+ else: # cosine similarity
1449
+ # Final: "1 - (sv.embedding <=> %s::%s)"
1450
+ return "sv.embedding <=> %s::%s", "1 - scored.neg_score"
1451
+
1452
+
1453
+ def _ensure_index_config(
1454
+ index_config: PostgresIndexConfig,
1455
+ ) -> tuple[Embeddings | None, PostgresIndexConfig]:
1456
+ index_config = index_config.copy()
1457
+ tokenized: list[tuple[str, Literal["$"] | list[str]]] = []
1458
+ tot = 0
1459
+ fields = index_config.get("fields") or ["$"]
1460
+ if isinstance(fields, str):
1461
+ fields = [fields]
1462
+ if not isinstance(fields, list):
1463
+ raise ValueError(f"Text fields must be a list or a string. Got {fields}")
1464
+ for p in fields:
1465
+ if p == "$":
1466
+ tokenized.append((p, "$"))
1467
+ tot += 1
1468
+ else:
1469
+ toks = tokenize_path(p)
1470
+ tokenized.append((p, toks))
1471
+ tot += len(toks)
1472
+ index_config["__tokenized_fields"] = tokenized
1473
+ index_config["__estimated_num_vectors"] = tot
1474
+ embeddings = ensure_embeddings(
1475
+ index_config.get("embed"),
1476
+ )
1477
+ return embeddings, index_config
1478
+
1479
+
1480
+ PLACEHOLDER = object()