remdb 0.3.242__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.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (235) hide show
  1. rem/__init__.py +129 -0
  2. rem/agentic/README.md +760 -0
  3. rem/agentic/__init__.py +54 -0
  4. rem/agentic/agents/README.md +155 -0
  5. rem/agentic/agents/__init__.py +38 -0
  6. rem/agentic/agents/agent_manager.py +311 -0
  7. rem/agentic/agents/sse_simulator.py +502 -0
  8. rem/agentic/context.py +425 -0
  9. rem/agentic/context_builder.py +360 -0
  10. rem/agentic/llm_provider_models.py +301 -0
  11. rem/agentic/mcp/__init__.py +0 -0
  12. rem/agentic/mcp/tool_wrapper.py +273 -0
  13. rem/agentic/otel/__init__.py +5 -0
  14. rem/agentic/otel/setup.py +240 -0
  15. rem/agentic/providers/phoenix.py +926 -0
  16. rem/agentic/providers/pydantic_ai.py +854 -0
  17. rem/agentic/query.py +117 -0
  18. rem/agentic/query_helper.py +89 -0
  19. rem/agentic/schema.py +737 -0
  20. rem/agentic/serialization.py +245 -0
  21. rem/agentic/tools/__init__.py +5 -0
  22. rem/agentic/tools/rem_tools.py +242 -0
  23. rem/api/README.md +657 -0
  24. rem/api/deps.py +253 -0
  25. rem/api/main.py +460 -0
  26. rem/api/mcp_router/prompts.py +182 -0
  27. rem/api/mcp_router/resources.py +820 -0
  28. rem/api/mcp_router/server.py +243 -0
  29. rem/api/mcp_router/tools.py +1605 -0
  30. rem/api/middleware/tracking.py +172 -0
  31. rem/api/routers/admin.py +520 -0
  32. rem/api/routers/auth.py +898 -0
  33. rem/api/routers/chat/__init__.py +5 -0
  34. rem/api/routers/chat/child_streaming.py +394 -0
  35. rem/api/routers/chat/completions.py +702 -0
  36. rem/api/routers/chat/json_utils.py +76 -0
  37. rem/api/routers/chat/models.py +202 -0
  38. rem/api/routers/chat/otel_utils.py +33 -0
  39. rem/api/routers/chat/sse_events.py +546 -0
  40. rem/api/routers/chat/streaming.py +950 -0
  41. rem/api/routers/chat/streaming_utils.py +327 -0
  42. rem/api/routers/common.py +18 -0
  43. rem/api/routers/dev.py +87 -0
  44. rem/api/routers/feedback.py +276 -0
  45. rem/api/routers/messages.py +620 -0
  46. rem/api/routers/models.py +86 -0
  47. rem/api/routers/query.py +362 -0
  48. rem/api/routers/shared_sessions.py +422 -0
  49. rem/auth/README.md +258 -0
  50. rem/auth/__init__.py +36 -0
  51. rem/auth/jwt.py +367 -0
  52. rem/auth/middleware.py +318 -0
  53. rem/auth/providers/__init__.py +16 -0
  54. rem/auth/providers/base.py +376 -0
  55. rem/auth/providers/email.py +215 -0
  56. rem/auth/providers/google.py +163 -0
  57. rem/auth/providers/microsoft.py +237 -0
  58. rem/cli/README.md +517 -0
  59. rem/cli/__init__.py +8 -0
  60. rem/cli/commands/README.md +299 -0
  61. rem/cli/commands/__init__.py +3 -0
  62. rem/cli/commands/ask.py +549 -0
  63. rem/cli/commands/cluster.py +1808 -0
  64. rem/cli/commands/configure.py +495 -0
  65. rem/cli/commands/db.py +828 -0
  66. rem/cli/commands/dreaming.py +324 -0
  67. rem/cli/commands/experiments.py +1698 -0
  68. rem/cli/commands/mcp.py +66 -0
  69. rem/cli/commands/process.py +388 -0
  70. rem/cli/commands/query.py +109 -0
  71. rem/cli/commands/scaffold.py +47 -0
  72. rem/cli/commands/schema.py +230 -0
  73. rem/cli/commands/serve.py +106 -0
  74. rem/cli/commands/session.py +453 -0
  75. rem/cli/dreaming.py +363 -0
  76. rem/cli/main.py +123 -0
  77. rem/config.py +244 -0
  78. rem/mcp_server.py +41 -0
  79. rem/models/core/__init__.py +49 -0
  80. rem/models/core/core_model.py +70 -0
  81. rem/models/core/engram.py +333 -0
  82. rem/models/core/experiment.py +672 -0
  83. rem/models/core/inline_edge.py +132 -0
  84. rem/models/core/rem_query.py +246 -0
  85. rem/models/entities/__init__.py +68 -0
  86. rem/models/entities/domain_resource.py +38 -0
  87. rem/models/entities/feedback.py +123 -0
  88. rem/models/entities/file.py +57 -0
  89. rem/models/entities/image_resource.py +88 -0
  90. rem/models/entities/message.py +64 -0
  91. rem/models/entities/moment.py +123 -0
  92. rem/models/entities/ontology.py +181 -0
  93. rem/models/entities/ontology_config.py +131 -0
  94. rem/models/entities/resource.py +95 -0
  95. rem/models/entities/schema.py +87 -0
  96. rem/models/entities/session.py +84 -0
  97. rem/models/entities/shared_session.py +180 -0
  98. rem/models/entities/subscriber.py +175 -0
  99. rem/models/entities/user.py +93 -0
  100. rem/py.typed +0 -0
  101. rem/registry.py +373 -0
  102. rem/schemas/README.md +507 -0
  103. rem/schemas/__init__.py +6 -0
  104. rem/schemas/agents/README.md +92 -0
  105. rem/schemas/agents/core/agent-builder.yaml +235 -0
  106. rem/schemas/agents/core/moment-builder.yaml +178 -0
  107. rem/schemas/agents/core/rem-query-agent.yaml +226 -0
  108. rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
  109. rem/schemas/agents/core/simple-assistant.yaml +19 -0
  110. rem/schemas/agents/core/user-profile-builder.yaml +163 -0
  111. rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
  112. rem/schemas/agents/examples/contract-extractor.yaml +134 -0
  113. rem/schemas/agents/examples/cv-parser.yaml +263 -0
  114. rem/schemas/agents/examples/hello-world.yaml +37 -0
  115. rem/schemas/agents/examples/query.yaml +54 -0
  116. rem/schemas/agents/examples/simple.yaml +21 -0
  117. rem/schemas/agents/examples/test.yaml +29 -0
  118. rem/schemas/agents/rem.yaml +132 -0
  119. rem/schemas/evaluators/hello-world/default.yaml +77 -0
  120. rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
  121. rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
  122. rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
  123. rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
  124. rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
  125. rem/services/__init__.py +18 -0
  126. rem/services/audio/INTEGRATION.md +308 -0
  127. rem/services/audio/README.md +376 -0
  128. rem/services/audio/__init__.py +15 -0
  129. rem/services/audio/chunker.py +354 -0
  130. rem/services/audio/transcriber.py +259 -0
  131. rem/services/content/README.md +1269 -0
  132. rem/services/content/__init__.py +5 -0
  133. rem/services/content/providers.py +760 -0
  134. rem/services/content/service.py +762 -0
  135. rem/services/dreaming/README.md +230 -0
  136. rem/services/dreaming/__init__.py +53 -0
  137. rem/services/dreaming/affinity_service.py +322 -0
  138. rem/services/dreaming/moment_service.py +251 -0
  139. rem/services/dreaming/ontology_service.py +54 -0
  140. rem/services/dreaming/user_model_service.py +297 -0
  141. rem/services/dreaming/utils.py +39 -0
  142. rem/services/email/__init__.py +10 -0
  143. rem/services/email/service.py +522 -0
  144. rem/services/email/templates.py +360 -0
  145. rem/services/embeddings/__init__.py +11 -0
  146. rem/services/embeddings/api.py +127 -0
  147. rem/services/embeddings/worker.py +435 -0
  148. rem/services/fs/README.md +662 -0
  149. rem/services/fs/__init__.py +62 -0
  150. rem/services/fs/examples.py +206 -0
  151. rem/services/fs/examples_paths.py +204 -0
  152. rem/services/fs/git_provider.py +935 -0
  153. rem/services/fs/local_provider.py +760 -0
  154. rem/services/fs/parsing-hooks-examples.md +172 -0
  155. rem/services/fs/paths.py +276 -0
  156. rem/services/fs/provider.py +460 -0
  157. rem/services/fs/s3_provider.py +1042 -0
  158. rem/services/fs/service.py +186 -0
  159. rem/services/git/README.md +1075 -0
  160. rem/services/git/__init__.py +17 -0
  161. rem/services/git/service.py +469 -0
  162. rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
  163. rem/services/phoenix/README.md +453 -0
  164. rem/services/phoenix/__init__.py +46 -0
  165. rem/services/phoenix/client.py +960 -0
  166. rem/services/phoenix/config.py +88 -0
  167. rem/services/phoenix/prompt_labels.py +477 -0
  168. rem/services/postgres/README.md +757 -0
  169. rem/services/postgres/__init__.py +49 -0
  170. rem/services/postgres/diff_service.py +599 -0
  171. rem/services/postgres/migration_service.py +427 -0
  172. rem/services/postgres/programmable_diff_service.py +635 -0
  173. rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
  174. rem/services/postgres/register_type.py +353 -0
  175. rem/services/postgres/repository.py +481 -0
  176. rem/services/postgres/schema_generator.py +661 -0
  177. rem/services/postgres/service.py +802 -0
  178. rem/services/postgres/sql_builder.py +355 -0
  179. rem/services/rate_limit.py +113 -0
  180. rem/services/rem/README.md +318 -0
  181. rem/services/rem/__init__.py +23 -0
  182. rem/services/rem/exceptions.py +71 -0
  183. rem/services/rem/executor.py +293 -0
  184. rem/services/rem/parser.py +180 -0
  185. rem/services/rem/queries.py +196 -0
  186. rem/services/rem/query.py +371 -0
  187. rem/services/rem/service.py +608 -0
  188. rem/services/session/README.md +374 -0
  189. rem/services/session/__init__.py +13 -0
  190. rem/services/session/compression.py +488 -0
  191. rem/services/session/pydantic_messages.py +310 -0
  192. rem/services/session/reload.py +85 -0
  193. rem/services/user_service.py +130 -0
  194. rem/settings.py +1877 -0
  195. rem/sql/background_indexes.sql +52 -0
  196. rem/sql/migrations/001_install.sql +983 -0
  197. rem/sql/migrations/002_install_models.sql +3157 -0
  198. rem/sql/migrations/003_optional_extensions.sql +326 -0
  199. rem/sql/migrations/004_cache_system.sql +282 -0
  200. rem/sql/migrations/005_schema_update.sql +145 -0
  201. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  202. rem/utils/AGENTIC_CHUNKING.md +597 -0
  203. rem/utils/README.md +628 -0
  204. rem/utils/__init__.py +61 -0
  205. rem/utils/agentic_chunking.py +622 -0
  206. rem/utils/batch_ops.py +343 -0
  207. rem/utils/chunking.py +108 -0
  208. rem/utils/clip_embeddings.py +276 -0
  209. rem/utils/constants.py +97 -0
  210. rem/utils/date_utils.py +228 -0
  211. rem/utils/dict_utils.py +98 -0
  212. rem/utils/embeddings.py +436 -0
  213. rem/utils/examples/embeddings_example.py +305 -0
  214. rem/utils/examples/sql_types_example.py +202 -0
  215. rem/utils/files.py +323 -0
  216. rem/utils/markdown.py +16 -0
  217. rem/utils/mime_types.py +158 -0
  218. rem/utils/model_helpers.py +492 -0
  219. rem/utils/schema_loader.py +649 -0
  220. rem/utils/sql_paths.py +146 -0
  221. rem/utils/sql_types.py +350 -0
  222. rem/utils/user_id.py +81 -0
  223. rem/utils/vision.py +325 -0
  224. rem/workers/README.md +506 -0
  225. rem/workers/__init__.py +7 -0
  226. rem/workers/db_listener.py +579 -0
  227. rem/workers/db_maintainer.py +74 -0
  228. rem/workers/dreaming.py +502 -0
  229. rem/workers/engram_processor.py +312 -0
  230. rem/workers/sqs_file_processor.py +193 -0
  231. rem/workers/unlogged_maintainer.py +463 -0
  232. remdb-0.3.242.dist-info/METADATA +1632 -0
  233. remdb-0.3.242.dist-info/RECORD +235 -0
  234. remdb-0.3.242.dist-info/WHEEL +4 -0
  235. remdb-0.3.242.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,481 @@
1
+ """Generic repository for entity persistence.
2
+
3
+ Single repository class that works with any Pydantic model type.
4
+ No need for model-specific repository classes.
5
+
6
+ Usage:
7
+ from rem.models.entities import Message
8
+ from rem.services.repositories import Repository
9
+
10
+ repo = Repository(db, Message, table_name="messages")
11
+ message = await repo.upsert(message_instance)
12
+ messages = await repo.find({"session_id": "abc", "tenant_id": "xyz"})
13
+ """
14
+
15
+ import json
16
+ from typing import Any, Generic, Type, TypeVar, TYPE_CHECKING
17
+
18
+ from loguru import logger
19
+ from pydantic import BaseModel
20
+
21
+ from .sql_builder import (
22
+ build_count,
23
+ build_delete,
24
+ build_insert,
25
+ build_select,
26
+ build_upsert,
27
+ )
28
+ from ...settings import settings
29
+
30
+ if TYPE_CHECKING:
31
+ from .service import PostgresService
32
+
33
+
34
+ def get_postgres_service() -> "PostgresService | None":
35
+ """
36
+ Get PostgresService singleton from parent module.
37
+
38
+ Uses late import to avoid circular import issues.
39
+ Previously had a separate _postgres_instance here which caused
40
+ "pool not connected" errors due to duplicate connection pools.
41
+ """
42
+ # Late import to avoid circular import (repository.py imported by __init__.py)
43
+ from rem.services.postgres import get_postgres_service as _get_singleton
44
+ return _get_singleton()
45
+
46
+ T = TypeVar("T", bound=BaseModel)
47
+
48
+ # Known JSONB fields from CoreModel that need deserialization
49
+ JSONB_FIELDS = {"graph_edges", "metadata"}
50
+
51
+
52
+ class Repository(Generic[T]):
53
+ """Generic repository for any Pydantic model type."""
54
+
55
+ def __init__(
56
+ self,
57
+ model_class: Type[T],
58
+ table_name: str | None = None,
59
+ db: "PostgresService | None" = None,
60
+ ):
61
+ """
62
+ Initialize repository.
63
+
64
+ Args:
65
+ model_class: Pydantic model class (e.g., Message, Resource)
66
+ table_name: Optional table name (defaults to lowercase model name + 's')
67
+ db: Optional PostgresService instance (creates from settings if None)
68
+ """
69
+ self.db = db or get_postgres_service()
70
+ self.model_class = model_class
71
+ self.table_name = table_name or f"{model_class.__name__.lower()}s"
72
+
73
+ async def upsert(
74
+ self,
75
+ records: T | list[T],
76
+ embeddable_fields: list[str] | None = None,
77
+ generate_embeddings: bool = True,
78
+ ) -> T | list[T]:
79
+ """
80
+ Upsert single record or list of records (create or update on ID conflict).
81
+
82
+ Accepts both single items and lists - no need to distinguish batch vs non-batch.
83
+ Single items are coerced to lists internally for processing.
84
+
85
+ Args:
86
+ records: Single model instance or list of model instances
87
+ embeddable_fields: Optional list of fields to generate embeddings for.
88
+ If None, auto-detects 'content' field if present.
89
+ generate_embeddings: Whether to queue embedding generation tasks (default: True)
90
+
91
+ Returns:
92
+ Single record or list of records with generated IDs (matches input type)
93
+ """
94
+ # Coerce single item to list for uniform processing
95
+ is_single = not isinstance(records, list)
96
+ records_list: list[T]
97
+ if is_single:
98
+ records_list = [records] # type: ignore[list-item]
99
+ else:
100
+ records_list = records # Type narrowed by isinstance check
101
+
102
+ if not settings.postgres.enabled or not self.db:
103
+ logger.debug(f"Postgres disabled, skipping {self.model_class.__name__} upsert")
104
+ return records
105
+
106
+ # Ensure connection
107
+ if not self.db.pool:
108
+ await self.db.connect()
109
+
110
+ # Type guard: ensure pool is not None after connect
111
+ if not self.db.pool:
112
+ raise RuntimeError("Failed to establish database connection")
113
+
114
+ for record in records_list:
115
+ sql, params = build_upsert(record, self.table_name, conflict_field="id", return_id=True)
116
+ async with self.db.pool.acquire() as conn:
117
+ row = await conn.fetchrow(sql, *params)
118
+ if row and "id" in row:
119
+ record.id = row["id"] # type: ignore[attr-defined]
120
+
121
+ # Queue embedding generation if requested and worker is available
122
+ if generate_embeddings and self.db.embedding_worker:
123
+ from rem.services.embeddings import EmbeddingTask
124
+ from .register_type import should_embed_field
125
+
126
+ # Auto-detect embeddable fields if not specified
127
+ if embeddable_fields is None:
128
+ embeddable_fields = [
129
+ field_name
130
+ for field_name, field_info in self.model_class.model_fields.items()
131
+ if should_embed_field(field_name, field_info)
132
+ ]
133
+
134
+ if embeddable_fields:
135
+ for record in records_list:
136
+ for field_name in embeddable_fields:
137
+ content = getattr(record, field_name, None)
138
+ if content and isinstance(content, str):
139
+ task = EmbeddingTask(
140
+ task_id=f"{record.id}-{field_name}", # type: ignore[attr-defined]
141
+ entity_id=str(record.id), # type: ignore[attr-defined]
142
+ table_name=self.table_name,
143
+ field_name=field_name,
144
+ content=content,
145
+ provider="openai", # Default provider
146
+ model="text-embedding-3-small", # Default model
147
+ )
148
+ await self.db.embedding_worker.queue_task(task)
149
+
150
+ logger.debug(f"Queued {len(records_list) * len(embeddable_fields)} embedding tasks")
151
+
152
+ # Return single item or list to match input type
153
+ return records_list[0] if is_single else records_list
154
+
155
+ async def get_by_id(self, record_id: str, tenant_id: str | None = None) -> T | None:
156
+ """
157
+ Get a single record by ID.
158
+
159
+ Args:
160
+ record_id: Record identifier
161
+ tenant_id: Optional tenant identifier (deprecated, not used for filtering)
162
+
163
+ Returns:
164
+ Model instance or None if not found
165
+ """
166
+ if not settings.postgres.enabled or not self.db:
167
+ logger.debug(f"Postgres disabled, returning None for {self.model_class.__name__} get")
168
+ return None
169
+
170
+ # Ensure connection
171
+ if not self.db.pool:
172
+ await self.db.connect()
173
+
174
+ # Type guard: ensure pool is not None after connect
175
+ if not self.db.pool:
176
+ raise RuntimeError("Failed to establish database connection")
177
+
178
+ # Note: tenant_id filtering removed - use user_id for access control instead
179
+ query = f"""
180
+ SELECT * FROM {self.table_name}
181
+ WHERE id = $1 AND deleted_at IS NULL
182
+ """
183
+
184
+ async with self.db.pool.acquire() as conn:
185
+ row = await conn.fetchrow(query, record_id)
186
+
187
+ if not row:
188
+ return None
189
+
190
+ # PostgreSQL JSONB columns come back as strings, need to parse them
191
+ row_dict = dict(row)
192
+ return self.model_class.model_validate(row_dict)
193
+
194
+ async def find(
195
+ self,
196
+ filters: dict[str, Any],
197
+ order_by: str = "created_at ASC",
198
+ limit: int | None = None,
199
+ offset: int = 0,
200
+ ) -> list[T]:
201
+ """
202
+ Find records matching filters.
203
+
204
+ Args:
205
+ filters: Dict of field -> value filters (AND-ed together)
206
+ order_by: ORDER BY clause (default: "created_at ASC")
207
+ limit: Optional limit on number of records
208
+ offset: Offset for pagination
209
+
210
+ Returns:
211
+ List of model instances
212
+
213
+ Example:
214
+ messages = await repo.find({
215
+ "session_id": "abc-123",
216
+ "tenant_id": "acme-corp",
217
+ "user_id": "alice"
218
+ })
219
+ """
220
+ if not settings.postgres.enabled or not self.db:
221
+ logger.debug(f"Postgres disabled, returning empty {self.model_class.__name__} list")
222
+ return []
223
+
224
+ # Ensure connection
225
+ if not self.db.pool:
226
+ await self.db.connect()
227
+
228
+ # Type guard: ensure pool is not None after connect
229
+ if not self.db.pool:
230
+ raise RuntimeError("Failed to establish database connection")
231
+
232
+ sql, params = build_select(
233
+ self.model_class,
234
+ self.table_name,
235
+ filters,
236
+ order_by=order_by,
237
+ limit=limit,
238
+ offset=offset,
239
+ )
240
+
241
+ async with self.db.pool.acquire() as conn:
242
+ rows = await conn.fetch(sql, *params)
243
+
244
+ return [self.model_class.model_validate(dict(row)) for row in rows]
245
+
246
+ async def find_one(self, filters: dict[str, Any]) -> T | None:
247
+ """
248
+ Find single record matching filters.
249
+
250
+ Args:
251
+ filters: Dict of field -> value filters
252
+
253
+ Returns:
254
+ Model instance or None if not found
255
+ """
256
+ results = await self.find(filters, limit=1)
257
+ return results[0] if results else None
258
+
259
+ async def get_by_session(
260
+ self, session_id: str, tenant_id: str, user_id: str | None = None
261
+ ) -> list[T]:
262
+ """
263
+ Get all records for a session (convenience method for Message model).
264
+
265
+ Args:
266
+ session_id: Session identifier
267
+ tenant_id: Tenant identifier
268
+ user_id: Optional user identifier
269
+
270
+ Returns:
271
+ List of model instances ordered by created_at
272
+ """
273
+ filters = {"session_id": session_id, "tenant_id": tenant_id}
274
+ if user_id:
275
+ filters["user_id"] = user_id
276
+
277
+ return await self.find(filters, order_by="created_at ASC")
278
+
279
+ async def update(self, record: T) -> T:
280
+ """
281
+ Update a record (upsert).
282
+
283
+ Args:
284
+ record: Model instance to update
285
+
286
+ Returns:
287
+ Updated record
288
+ """
289
+ result = await self.upsert(record)
290
+ # upsert with single record returns single record
291
+ return result # type: ignore[return-value]
292
+
293
+ async def delete(self, record_id: str, tenant_id: str) -> bool:
294
+ """
295
+ Soft delete a record (sets deleted_at).
296
+
297
+ Args:
298
+ record_id: Record identifier
299
+ tenant_id: Tenant identifier for multi-tenancy isolation
300
+
301
+ Returns:
302
+ True if deleted, False if not found
303
+ """
304
+ if not settings.postgres.enabled or not self.db:
305
+ logger.debug(f"Postgres disabled, skipping {self.model_class.__name__} deletion")
306
+ return False
307
+
308
+ # Ensure connection
309
+ if not self.db.pool:
310
+ await self.db.connect()
311
+
312
+ # Type guard: ensure pool is not None after connect
313
+ if not self.db.pool:
314
+ raise RuntimeError("Failed to establish database connection")
315
+
316
+ sql, params = build_delete(self.table_name, record_id, tenant_id)
317
+
318
+ async with self.db.pool.acquire() as conn:
319
+ row = await conn.fetchrow(sql, *params)
320
+
321
+ return row is not None
322
+
323
+ async def count(self, filters: dict[str, Any]) -> int:
324
+ """
325
+ Count records matching filters.
326
+
327
+ Args:
328
+ filters: Dict of field -> value filters
329
+
330
+ Returns:
331
+ Count of matching records
332
+ """
333
+ if not settings.postgres.enabled or not self.db:
334
+ return 0
335
+
336
+ # Ensure connection
337
+ if not self.db.pool:
338
+ await self.db.connect()
339
+
340
+ # Type guard: ensure pool is not None after connect
341
+ if not self.db.pool:
342
+ raise RuntimeError("Failed to establish database connection")
343
+
344
+ sql, params = build_count(self.table_name, filters)
345
+
346
+ async with self.db.pool.acquire() as conn:
347
+ row = await conn.fetchrow(sql, *params)
348
+
349
+ return row[0] if row else 0
350
+
351
+ async def find_paginated(
352
+ self,
353
+ filters: dict[str, Any],
354
+ page: int = 1,
355
+ page_size: int = 50,
356
+ order_by: str = "created_at DESC",
357
+ partition_by: str | None = None,
358
+ ) -> dict[str, Any]:
359
+ """
360
+ Find records with page-based pagination using CTE with ROW_NUMBER().
361
+
362
+ Uses a CTE with ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) for
363
+ efficient pagination with total count in a single query.
364
+
365
+ Args:
366
+ filters: Dict of field -> value filters (AND-ed together)
367
+ page: Page number (1-indexed)
368
+ page_size: Number of records per page
369
+ order_by: ORDER BY clause for row numbering (default: "created_at DESC")
370
+ partition_by: Optional field to partition by (e.g., "user_id").
371
+ If None, uses global row numbering.
372
+
373
+ Returns:
374
+ Dict containing:
375
+ - data: List of model instances for the page
376
+ - total: Total count of records matching filters
377
+ - page: Current page number
378
+ - page_size: Records per page
379
+ - total_pages: Total number of pages
380
+ - has_next: Whether there are more pages
381
+ - has_previous: Whether there are previous pages
382
+
383
+ Example:
384
+ result = await repo.find_paginated(
385
+ {"tenant_id": "acme", "user_id": "alice"},
386
+ page=2,
387
+ page_size=20,
388
+ order_by="created_at DESC",
389
+ partition_by="user_id"
390
+ )
391
+ # result = {
392
+ # "data": [...],
393
+ # "total": 150,
394
+ # "page": 2,
395
+ # "page_size": 20,
396
+ # "total_pages": 8,
397
+ # "has_next": True,
398
+ # "has_previous": True
399
+ # }
400
+ """
401
+ if not settings.postgres.enabled or not self.db:
402
+ logger.debug(f"Postgres disabled, returning empty {self.model_class.__name__} pagination")
403
+ return {
404
+ "data": [],
405
+ "total": 0,
406
+ "page": page,
407
+ "page_size": page_size,
408
+ "total_pages": 0,
409
+ "has_next": False,
410
+ "has_previous": False,
411
+ }
412
+
413
+ # Ensure connection
414
+ if not self.db.pool:
415
+ await self.db.connect()
416
+
417
+ # Type guard: ensure pool is not None after connect
418
+ if not self.db.pool:
419
+ raise RuntimeError("Failed to establish database connection")
420
+
421
+ # Build WHERE clause from filters
422
+ where_conditions = ["deleted_at IS NULL"]
423
+ params: list[Any] = []
424
+ param_idx = 1
425
+
426
+ for field, value in filters.items():
427
+ where_conditions.append(f"{field} = ${param_idx}")
428
+ params.append(value)
429
+ param_idx += 1
430
+
431
+ where_clause = " AND ".join(where_conditions)
432
+
433
+ # Build PARTITION BY clause
434
+ partition_clause = f"PARTITION BY {partition_by}" if partition_by else ""
435
+
436
+ # Build the CTE query with ROW_NUMBER() and COUNT() window functions
437
+ # This gives us pagination + total count in a single query
438
+ sql = f"""
439
+ WITH numbered AS (
440
+ SELECT *,
441
+ ROW_NUMBER() OVER ({partition_clause} ORDER BY {order_by}) as _row_num,
442
+ COUNT(*) OVER ({partition_clause}) as _total_count
443
+ FROM {self.table_name}
444
+ WHERE {where_clause}
445
+ )
446
+ SELECT * FROM numbered
447
+ WHERE _row_num > ${param_idx} AND _row_num <= ${param_idx + 1}
448
+ ORDER BY _row_num
449
+ """
450
+
451
+ # Calculate row range for the page
452
+ start_row = (page - 1) * page_size
453
+ end_row = page * page_size
454
+ params.extend([start_row, end_row])
455
+
456
+ async with self.db.pool.acquire() as conn:
457
+ rows = await conn.fetch(sql, *params)
458
+
459
+ # Extract total from first row (all rows have the same _total_count)
460
+ total = rows[0]["_total_count"] if rows else 0
461
+
462
+ # Remove internal columns and convert to models
463
+ data = []
464
+ for row in rows:
465
+ row_dict = dict(row)
466
+ row_dict.pop("_row_num", None)
467
+ row_dict.pop("_total_count", None)
468
+ data.append(self.model_class.model_validate(row_dict))
469
+
470
+ # Calculate pagination metadata
471
+ total_pages = (total + page_size - 1) // page_size if total > 0 else 0
472
+
473
+ return {
474
+ "data": data,
475
+ "total": total,
476
+ "page": page,
477
+ "page_size": page_size,
478
+ "total_pages": total_pages,
479
+ "has_next": page < total_pages,
480
+ "has_previous": page > 1,
481
+ }