swiftpy-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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,496 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ from collections.abc import AsyncIterator, Sequence
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any
9
+
10
+ import asyncpg
11
+
12
+ from swiftpy.ai.pool import AIHttpPool
13
+ from swiftpy.ai.vectorstore.base import (
14
+ SearchResult,
15
+ VectorRecord,
16
+ VectorStore,
17
+ VectorStoreCapabilities,
18
+ dedupe_preserve_order,
19
+ normalize_record,
20
+ validate_collection_name,
21
+ validate_record_id,
22
+ validate_vector,
23
+ )
24
+ from swiftpy.core.config import Config
25
+ from swiftpy.core.container import get_container
26
+ from swiftpy.database.identifiers import (
27
+ MAX_IDENTIFIER_LENGTH,
28
+ quote_identifier,
29
+ validate_identifier,
30
+ )
31
+ from swiftpy.database.pool import DatabasePool
32
+ from swiftpy.database.transaction import get_active_connection
33
+
34
+ METRIC_CONFIG: dict[str, dict[str, str]] = {
35
+ "cosine": {
36
+ "operator": "<=>",
37
+ "opclass": "vector_cosine_ops",
38
+ "score_sql": "(1.0 - (embedding <=> $1::vector))",
39
+ },
40
+ "l2": {
41
+ "operator": "<->",
42
+ "opclass": "vector_l2_ops",
43
+ "score_sql": "(-(embedding <-> $1::vector))",
44
+ },
45
+ "inner_product": {
46
+ "operator": "<#>",
47
+ "opclass": "vector_ip_ops",
48
+ "score_sql": "(-(embedding <#> $1::vector))",
49
+ },
50
+ }
51
+
52
+
53
+ def _format_vector(vector: Sequence[float]) -> str:
54
+ """Format a numeric sequence into a pgvector string representation: '[0.1,0.2,...]'."""
55
+ return f"[{','.join(str(float(x)) for x in vector)}]"
56
+
57
+
58
+ def _bounded_pg_identifier(full_name: str, compact_prefix: str, suffix: str, hash_source: str) -> str:
59
+ """Keep PostgreSQL identifier within MAX_IDENTIFIER_LENGTH (63B) deterministically."""
60
+ if len(full_name.encode("utf-8")) <= MAX_IDENTIFIER_LENGTH:
61
+ validate_identifier(full_name)
62
+ return full_name
63
+ digest = hashlib.sha256(hash_source.encode("utf-8")).hexdigest()[:10]
64
+ safe_prefix = "".join(ch if (ch.isalnum() or ch == "_") else "_" for ch in compact_prefix)
65
+ head = "swiftpy_v_"
66
+ tail = f"_{digest}{suffix}"
67
+ max_body = MAX_IDENTIFIER_LENGTH - len(head) - len(tail)
68
+ candidate = f"{head}{safe_prefix[:max(0, max_body)]}{tail}"
69
+ validate_identifier(candidate)
70
+ return candidate
71
+
72
+
73
+ class PgVectorAdapter(VectorStore):
74
+ """
75
+ PostgreSQL pgvector adapter for SwiftPY.
76
+
77
+ Supports pgvector extension, HNSW indexing across declared distance metrics
78
+ (cosine, l2, inner_product), JSONB metadata containment queries, and seamless
79
+ participation in active database transactions.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ collection_name: str = "default",
85
+ dimensions: int = 1536,
86
+ metric: str = "cosine",
87
+ pool: DatabasePool | asyncpg.Pool | None = None,
88
+ config: Config | None = None,
89
+ http_pool: AIHttpPool | None = None,
90
+ ) -> None:
91
+ validate_collection_name(collection_name)
92
+ if isinstance(dimensions, bool) or not isinstance(dimensions, int) or dimensions <= 0:
93
+ raise ValueError(f"Dimensions must be a positive integer, got {dimensions}")
94
+ norm_metric = metric.lower().strip()
95
+ if norm_metric not in METRIC_CONFIG:
96
+ raise ValueError(f"Unsupported metric '{metric}'. Expected one of {sorted(METRIC_CONFIG.keys())}")
97
+
98
+ self._collection_name = collection_name
99
+ self._dimensions = dimensions
100
+ self._metric = norm_metric
101
+ self._pool = pool
102
+ self._config = config
103
+ self._http_pool = http_pool
104
+ self._owned_db_pool: DatabasePool | None = None
105
+ self._initialized = False
106
+ self._lock = asyncio.Lock()
107
+ self._pool_lock = asyncio.Lock()
108
+
109
+ @property
110
+ def metric(self) -> str:
111
+ return self._metric
112
+
113
+ @property
114
+ def dimensions(self) -> int:
115
+ return self._dimensions
116
+
117
+ @property
118
+ def capabilities(self) -> VectorStoreCapabilities:
119
+ """Return the declared capabilities supported by PgVectorAdapter."""
120
+ return VectorStoreCapabilities(
121
+ metadata_filtering=True,
122
+ nested_metadata_filtering=True,
123
+ namespaces=False,
124
+ pagination=False,
125
+ batch_upsert=True,
126
+ native_distance_metrics=("cosine", "l2", "inner_product"),
127
+ )
128
+
129
+ def _get_table_name(self, name: str | None = None) -> str:
130
+ coll = self._collection_name if name is None else validate_collection_name(name)
131
+ table = f"swiftpy_vectors_{coll}"
132
+ return _bounded_pg_identifier(table, coll, "", coll)
133
+
134
+ def _get_index_name(self, name: str | None = None, metric: str | None = None) -> str:
135
+ coll = self._collection_name if name is None else validate_collection_name(name)
136
+ active_metric = metric or self._metric
137
+ idx = f"swiftpy_vectors_{coll}_{active_metric}_hnsw_idx"
138
+ return _bounded_pg_identifier(idx, coll, f"_{active_metric}_idx", f"{coll}:{active_metric}")
139
+
140
+ async def aclose(self) -> None:
141
+ """Close any internally created DatabasePool owned by this adapter instance."""
142
+ async with self._pool_lock:
143
+ if self._owned_db_pool is not None:
144
+ await self._owned_db_pool.close()
145
+ self._owned_db_pool = None
146
+
147
+ @asynccontextmanager
148
+ async def _connection(self) -> AsyncIterator[asyncpg.Connection]:
149
+ active = get_active_connection()
150
+ if active is not None:
151
+ yield active
152
+ return
153
+
154
+ pool: asyncpg.Pool
155
+ if self._pool is not None:
156
+ if isinstance(self._pool, DatabasePool):
157
+ try:
158
+ pool = self._pool.get_pool()
159
+ except RuntimeError:
160
+ pool = await self._pool.init()
161
+ else:
162
+ pool = self._pool
163
+ else:
164
+ container = get_container()
165
+ if container.has(DatabasePool):
166
+ db_pool = container.resolve(DatabasePool)
167
+ try:
168
+ pool = db_pool.get_pool()
169
+ except RuntimeError:
170
+ pool = await db_pool.init()
171
+ else:
172
+ async with self._pool_lock:
173
+ if self._owned_db_pool is None:
174
+ cfg = self._config
175
+ if cfg is None:
176
+ if container.has(Config):
177
+ cfg = container.resolve(Config)
178
+ else:
179
+ try:
180
+ cfg = Config.get_instance()
181
+ except RuntimeError:
182
+ cfg = Config()
183
+ self._owned_db_pool = DatabasePool(cfg)
184
+ pool = await self._owned_db_pool.init()
185
+ else:
186
+ try:
187
+ pool = self._owned_db_pool.get_pool()
188
+ except RuntimeError:
189
+ pool = await self._owned_db_pool.init()
190
+
191
+ conn = await pool.acquire()
192
+ try:
193
+ yield conn
194
+ finally:
195
+ await pool.release(conn)
196
+
197
+ async def create_collection(
198
+ self,
199
+ name: str,
200
+ dimensions: int,
201
+ metric: str = "cosine",
202
+ ) -> None:
203
+ """Create or initialize the collection table with pgvector extension and HNSW index."""
204
+ validate_collection_name(name)
205
+ if isinstance(dimensions, bool) or not isinstance(dimensions, int) or dimensions <= 0:
206
+ raise ValueError(f"Dimensions must be a positive integer, got {dimensions}")
207
+ norm_metric = metric.lower().strip()
208
+ if norm_metric not in METRIC_CONFIG:
209
+ raise ValueError(f"Unsupported metric '{metric}'. Expected one of {sorted(METRIC_CONFIG.keys())}")
210
+
211
+ table = self._get_table_name(name)
212
+ quoted_table = quote_identifier(table)
213
+ idx_name = self._get_index_name(name, metric=norm_metric)
214
+ quoted_idx = quote_identifier(idx_name)
215
+ opclass = METRIC_CONFIG[norm_metric]["opclass"]
216
+
217
+ async with self._connection() as conn:
218
+ # 1. Ensure pgvector extension exists
219
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
220
+
221
+ # 2. Check for dimension mismatch on pre-existing collection in active schema
222
+ dim_sql = """
223
+ SELECT pg_attribute.atttypmod
224
+ FROM pg_attribute
225
+ JOIN pg_class ON pg_class.oid = pg_attribute.attrelid
226
+ JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
227
+ WHERE pg_class.relname = $1
228
+ AND pg_namespace.nspname = current_schema()
229
+ AND pg_attribute.attname = 'embedding';
230
+ """
231
+ existing_dim = await conn.fetchval(dim_sql, table)
232
+ if existing_dim is not None and existing_dim > 0 and existing_dim != dimensions:
233
+ raise ValueError(
234
+ f"Collection table '{table}' already exists with {existing_dim} dimensions, "
235
+ f"cannot reinitialize with {dimensions} dimensions."
236
+ )
237
+
238
+ # 3. Create vector storage table
239
+ create_table_sql = f"""
240
+ CREATE TABLE IF NOT EXISTS {quoted_table} (
241
+ id VARCHAR(255) PRIMARY KEY,
242
+ embedding vector({dimensions}) NOT NULL,
243
+ content TEXT NOT NULL DEFAULT '',
244
+ metadata JSONB NOT NULL DEFAULT '{{}}'::jsonb,
245
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
246
+ );
247
+ """
248
+ await conn.execute(create_table_sql)
249
+
250
+ # 4. Create HNSW index for the chosen distance metric
251
+ create_index_sql = f"""
252
+ CREATE INDEX IF NOT EXISTS {quoted_idx}
253
+ ON {quoted_table} USING hnsw (embedding {opclass});
254
+ """
255
+ await conn.execute(create_index_sql)
256
+
257
+ self._collection_name = name
258
+ self._dimensions = dimensions
259
+ self._metric = norm_metric
260
+ self._initialized = True
261
+
262
+ async def _ensure_collection(self) -> None:
263
+ if self._initialized:
264
+ return
265
+ async with self._lock:
266
+ if self._initialized:
267
+ return
268
+ await self.create_collection(self._collection_name, self._dimensions, self._metric)
269
+ self._initialized = True
270
+
271
+ async def upsert(
272
+ self,
273
+ id: str,
274
+ vector: list[float],
275
+ metadata: dict[str, Any] | None = None,
276
+ content: str = "",
277
+ ) -> None:
278
+ """Insert or update a single vector record."""
279
+ rec_id = validate_record_id(id)
280
+ parsed_vec = validate_vector(vector, self._dimensions, self._metric)
281
+ await self._ensure_collection()
282
+ table = quote_identifier(self._get_table_name())
283
+
284
+ sql = f"""
285
+ INSERT INTO {table} (id, embedding, content, metadata)
286
+ VALUES ($1, $2::vector, $3, $4::jsonb)
287
+ ON CONFLICT (id) DO UPDATE SET
288
+ embedding = EXCLUDED.embedding,
289
+ content = EXCLUDED.content,
290
+ metadata = EXCLUDED.metadata;
291
+ """
292
+
293
+ meta_json = json.dumps(metadata or {})
294
+ vec_str = _format_vector(parsed_vec)
295
+
296
+ async with self._connection() as conn:
297
+ await conn.execute(sql, rec_id, vec_str, content, meta_json)
298
+
299
+ async def upsert_batch(
300
+ self,
301
+ records: Sequence[
302
+ VectorRecord
303
+ | dict[str, Any]
304
+ | tuple[str, list[float], dict[str, Any] | None]
305
+ | tuple[str, list[float], dict[str, Any] | None, str]
306
+ ],
307
+ ) -> None:
308
+ """Insert or update multiple vector records atomically in batch."""
309
+ if not records:
310
+ return
311
+
312
+ formatted_rows: list[tuple[str, str, str, str]] = []
313
+ for rec in records:
314
+ norm_rec = normalize_record(rec, self._dimensions, self._metric)
315
+ formatted_rows.append(
316
+ (
317
+ norm_rec.id,
318
+ _format_vector(norm_rec.vector),
319
+ norm_rec.content,
320
+ json.dumps(norm_rec.metadata),
321
+ )
322
+ )
323
+
324
+ await self._ensure_collection()
325
+ table = quote_identifier(self._get_table_name())
326
+
327
+ sql = f"""
328
+ INSERT INTO {table} (id, embedding, content, metadata)
329
+ VALUES ($1, $2::vector, $3, $4::jsonb)
330
+ ON CONFLICT (id) DO UPDATE SET
331
+ embedding = EXCLUDED.embedding,
332
+ content = EXCLUDED.content,
333
+ metadata = EXCLUDED.metadata;
334
+ """
335
+
336
+ async with self._connection() as conn:
337
+ # Execute batch within an explicit transaction boundary for atomicity
338
+ if hasattr(conn, "transaction"):
339
+ async with conn.transaction():
340
+ await conn.executemany(sql, formatted_rows)
341
+ else:
342
+ await conn.executemany(sql, formatted_rows)
343
+
344
+ async def search(
345
+ self,
346
+ vector: list[float],
347
+ limit: int = 10,
348
+ filter: dict[str, Any] | None = None,
349
+ ) -> list[SearchResult]:
350
+ """Search nearest vectors using pgvector distance operator and HNSW indexing."""
351
+ if limit <= 0:
352
+ raise ValueError(f"Limit must be a positive integer, got {limit}")
353
+
354
+ parsed_vec = validate_vector(vector, self._dimensions, self._metric)
355
+ await self._ensure_collection()
356
+
357
+ table = quote_identifier(self._get_table_name())
358
+ vec_str = _format_vector(parsed_vec)
359
+ metric_cfg = METRIC_CONFIG[self._metric]
360
+ op = metric_cfg["operator"]
361
+ score_sql = metric_cfg["score_sql"]
362
+
363
+ if filter:
364
+ sql = f"""
365
+ SELECT id, content, metadata, {score_sql} AS score
366
+ FROM {table}
367
+ WHERE metadata @> $2::jsonb
368
+ ORDER BY embedding {op} $1::vector
369
+ LIMIT $3;
370
+ """
371
+ args: tuple[Any, ...] = (vec_str, json.dumps(filter), limit)
372
+ else:
373
+ sql = f"""
374
+ SELECT id, content, metadata, {score_sql} AS score
375
+ FROM {table}
376
+ ORDER BY embedding {op} $1::vector
377
+ LIMIT $2;
378
+ """
379
+ args = (vec_str, limit)
380
+
381
+ async with self._connection() as conn:
382
+ rows = await conn.fetch(sql, *args)
383
+
384
+ results: list[SearchResult] = []
385
+ for row in rows:
386
+ raw_meta = row["metadata"]
387
+ if isinstance(raw_meta, str):
388
+ try:
389
+ meta = json.loads(raw_meta)
390
+ except (ValueError, TypeError):
391
+ meta = {}
392
+ elif isinstance(raw_meta, dict):
393
+ meta = dict(raw_meta)
394
+ else:
395
+ meta = {}
396
+
397
+ results.append(
398
+ SearchResult(
399
+ id=row["id"],
400
+ score=float(row["score"]),
401
+ metadata=meta,
402
+ content=row["content"],
403
+ )
404
+ )
405
+
406
+ return results
407
+
408
+ async def delete(self, id: str) -> bool:
409
+ """Delete a vector record by id."""
410
+ await self._ensure_collection()
411
+ table = quote_identifier(self._get_table_name())
412
+ sql = f"DELETE FROM {table} WHERE id = $1;"
413
+
414
+ async with self._connection() as conn:
415
+ result = await conn.execute(sql, id)
416
+
417
+ return bool(str(result).strip() != "DELETE 0")
418
+
419
+ async def delete_batch(self, ids: Sequence[str]) -> int:
420
+ """Delete multiple deduplicated vector records by ids."""
421
+ unique_ids = dedupe_preserve_order(ids)
422
+ if not unique_ids:
423
+ return 0
424
+
425
+ await self._ensure_collection()
426
+ table = quote_identifier(self._get_table_name())
427
+ sql = f"DELETE FROM {table} WHERE id = ANY($1::varchar[]);"
428
+
429
+ async with self._connection() as conn:
430
+ result = await conn.execute(sql, unique_ids)
431
+
432
+ try:
433
+ return int(str(result).strip().split()[-1])
434
+ except (ValueError, IndexError):
435
+ return 0
436
+
437
+ async def get(self, id: str) -> SearchResult | None:
438
+ """Retrieve a single vector record by id."""
439
+ await self._ensure_collection()
440
+ table = quote_identifier(self._get_table_name())
441
+ sql = f"SELECT id, content, metadata, embedding::text AS embedding FROM {table} WHERE id = $1;"
442
+
443
+ async with self._connection() as conn:
444
+ row = await conn.fetchrow(sql, id)
445
+
446
+ if row is None:
447
+ return None
448
+
449
+ raw_meta = row["metadata"]
450
+ if isinstance(raw_meta, str):
451
+ try:
452
+ meta = json.loads(raw_meta)
453
+ except (ValueError, TypeError):
454
+ meta = {}
455
+ elif isinstance(raw_meta, dict):
456
+ meta = dict(raw_meta)
457
+ else:
458
+ meta = {}
459
+
460
+ vec: list[float] | None = None
461
+ if row.get("embedding"):
462
+ try:
463
+ raw_s = str(row["embedding"]).strip("[] ")
464
+ vec = [float(x.strip()) for x in raw_s.split(",") if x.strip()]
465
+ except (ValueError, TypeError):
466
+ vec = None
467
+
468
+ return SearchResult(
469
+ id=row["id"],
470
+ score=1.0,
471
+ metadata=meta,
472
+ content=row["content"],
473
+ vector=vec,
474
+ )
475
+
476
+ async def count(self) -> int:
477
+ """Return the count of vector records in the active collection table."""
478
+ await self._ensure_collection()
479
+ table = quote_identifier(self._get_table_name())
480
+ sql = f"SELECT COUNT(*) FROM {table};"
481
+
482
+ async with self._connection() as conn:
483
+ val = await conn.fetchval(sql)
484
+
485
+ return int(val) if val is not None else 0
486
+
487
+ async def drop_collection(self, name: str) -> None:
488
+ """Drop a collection table and its associated index."""
489
+ table = quote_identifier(self._get_table_name(name))
490
+ sql = f"DROP TABLE IF EXISTS {table} CASCADE;"
491
+
492
+ async with self._connection() as conn:
493
+ await conn.execute(sql)
494
+
495
+ if name == self._collection_name:
496
+ self._initialized = False