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.
- rem/__init__.py +129 -0
- rem/agentic/README.md +760 -0
- rem/agentic/__init__.py +54 -0
- rem/agentic/agents/README.md +155 -0
- rem/agentic/agents/__init__.py +38 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/agents/sse_simulator.py +502 -0
- rem/agentic/context.py +425 -0
- rem/agentic/context_builder.py +360 -0
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/mcp/__init__.py +0 -0
- rem/agentic/mcp/tool_wrapper.py +273 -0
- rem/agentic/otel/__init__.py +5 -0
- rem/agentic/otel/setup.py +240 -0
- rem/agentic/providers/phoenix.py +926 -0
- rem/agentic/providers/pydantic_ai.py +854 -0
- rem/agentic/query.py +117 -0
- rem/agentic/query_helper.py +89 -0
- rem/agentic/schema.py +737 -0
- rem/agentic/serialization.py +245 -0
- rem/agentic/tools/__init__.py +5 -0
- rem/agentic/tools/rem_tools.py +242 -0
- rem/api/README.md +657 -0
- rem/api/deps.py +253 -0
- rem/api/main.py +460 -0
- rem/api/mcp_router/prompts.py +182 -0
- rem/api/mcp_router/resources.py +820 -0
- rem/api/mcp_router/server.py +243 -0
- rem/api/mcp_router/tools.py +1605 -0
- rem/api/middleware/tracking.py +172 -0
- rem/api/routers/admin.py +520 -0
- rem/api/routers/auth.py +898 -0
- rem/api/routers/chat/__init__.py +5 -0
- rem/api/routers/chat/child_streaming.py +394 -0
- rem/api/routers/chat/completions.py +702 -0
- rem/api/routers/chat/json_utils.py +76 -0
- rem/api/routers/chat/models.py +202 -0
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +546 -0
- rem/api/routers/chat/streaming.py +950 -0
- rem/api/routers/chat/streaming_utils.py +327 -0
- rem/api/routers/common.py +18 -0
- rem/api/routers/dev.py +87 -0
- rem/api/routers/feedback.py +276 -0
- rem/api/routers/messages.py +620 -0
- rem/api/routers/models.py +86 -0
- rem/api/routers/query.py +362 -0
- rem/api/routers/shared_sessions.py +422 -0
- rem/auth/README.md +258 -0
- rem/auth/__init__.py +36 -0
- rem/auth/jwt.py +367 -0
- rem/auth/middleware.py +318 -0
- rem/auth/providers/__init__.py +16 -0
- rem/auth/providers/base.py +376 -0
- rem/auth/providers/email.py +215 -0
- rem/auth/providers/google.py +163 -0
- rem/auth/providers/microsoft.py +237 -0
- rem/cli/README.md +517 -0
- rem/cli/__init__.py +8 -0
- rem/cli/commands/README.md +299 -0
- rem/cli/commands/__init__.py +3 -0
- rem/cli/commands/ask.py +549 -0
- rem/cli/commands/cluster.py +1808 -0
- rem/cli/commands/configure.py +495 -0
- rem/cli/commands/db.py +828 -0
- rem/cli/commands/dreaming.py +324 -0
- rem/cli/commands/experiments.py +1698 -0
- rem/cli/commands/mcp.py +66 -0
- rem/cli/commands/process.py +388 -0
- rem/cli/commands/query.py +109 -0
- rem/cli/commands/scaffold.py +47 -0
- rem/cli/commands/schema.py +230 -0
- rem/cli/commands/serve.py +106 -0
- rem/cli/commands/session.py +453 -0
- rem/cli/dreaming.py +363 -0
- rem/cli/main.py +123 -0
- rem/config.py +244 -0
- rem/mcp_server.py +41 -0
- rem/models/core/__init__.py +49 -0
- rem/models/core/core_model.py +70 -0
- rem/models/core/engram.py +333 -0
- rem/models/core/experiment.py +672 -0
- rem/models/core/inline_edge.py +132 -0
- rem/models/core/rem_query.py +246 -0
- rem/models/entities/__init__.py +68 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/file.py +57 -0
- rem/models/entities/image_resource.py +88 -0
- rem/models/entities/message.py +64 -0
- rem/models/entities/moment.py +123 -0
- rem/models/entities/ontology.py +181 -0
- rem/models/entities/ontology_config.py +131 -0
- rem/models/entities/resource.py +95 -0
- rem/models/entities/schema.py +87 -0
- rem/models/entities/session.py +84 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +93 -0
- rem/py.typed +0 -0
- rem/registry.py +373 -0
- rem/schemas/README.md +507 -0
- rem/schemas/__init__.py +6 -0
- rem/schemas/agents/README.md +92 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- rem/schemas/agents/core/moment-builder.yaml +178 -0
- rem/schemas/agents/core/rem-query-agent.yaml +226 -0
- rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
- rem/schemas/agents/core/simple-assistant.yaml +19 -0
- rem/schemas/agents/core/user-profile-builder.yaml +163 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
- rem/schemas/agents/examples/contract-extractor.yaml +134 -0
- rem/schemas/agents/examples/cv-parser.yaml +263 -0
- rem/schemas/agents/examples/hello-world.yaml +37 -0
- rem/schemas/agents/examples/query.yaml +54 -0
- rem/schemas/agents/examples/simple.yaml +21 -0
- rem/schemas/agents/examples/test.yaml +29 -0
- rem/schemas/agents/rem.yaml +132 -0
- rem/schemas/evaluators/hello-world/default.yaml +77 -0
- rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
- rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
- rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
- rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
- rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
- rem/services/__init__.py +18 -0
- rem/services/audio/INTEGRATION.md +308 -0
- rem/services/audio/README.md +376 -0
- rem/services/audio/__init__.py +15 -0
- rem/services/audio/chunker.py +354 -0
- rem/services/audio/transcriber.py +259 -0
- rem/services/content/README.md +1269 -0
- rem/services/content/__init__.py +5 -0
- rem/services/content/providers.py +760 -0
- rem/services/content/service.py +762 -0
- rem/services/dreaming/README.md +230 -0
- rem/services/dreaming/__init__.py +53 -0
- rem/services/dreaming/affinity_service.py +322 -0
- rem/services/dreaming/moment_service.py +251 -0
- rem/services/dreaming/ontology_service.py +54 -0
- rem/services/dreaming/user_model_service.py +297 -0
- rem/services/dreaming/utils.py +39 -0
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +522 -0
- rem/services/email/templates.py +360 -0
- rem/services/embeddings/__init__.py +11 -0
- rem/services/embeddings/api.py +127 -0
- rem/services/embeddings/worker.py +435 -0
- rem/services/fs/README.md +662 -0
- rem/services/fs/__init__.py +62 -0
- rem/services/fs/examples.py +206 -0
- rem/services/fs/examples_paths.py +204 -0
- rem/services/fs/git_provider.py +935 -0
- rem/services/fs/local_provider.py +760 -0
- rem/services/fs/parsing-hooks-examples.md +172 -0
- rem/services/fs/paths.py +276 -0
- rem/services/fs/provider.py +460 -0
- rem/services/fs/s3_provider.py +1042 -0
- rem/services/fs/service.py +186 -0
- rem/services/git/README.md +1075 -0
- rem/services/git/__init__.py +17 -0
- rem/services/git/service.py +469 -0
- rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
- rem/services/phoenix/README.md +453 -0
- rem/services/phoenix/__init__.py +46 -0
- rem/services/phoenix/client.py +960 -0
- rem/services/phoenix/config.py +88 -0
- rem/services/phoenix/prompt_labels.py +477 -0
- rem/services/postgres/README.md +757 -0
- rem/services/postgres/__init__.py +49 -0
- rem/services/postgres/diff_service.py +599 -0
- rem/services/postgres/migration_service.py +427 -0
- rem/services/postgres/programmable_diff_service.py +635 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
- rem/services/postgres/register_type.py +353 -0
- rem/services/postgres/repository.py +481 -0
- rem/services/postgres/schema_generator.py +661 -0
- rem/services/postgres/service.py +802 -0
- rem/services/postgres/sql_builder.py +355 -0
- rem/services/rate_limit.py +113 -0
- rem/services/rem/README.md +318 -0
- rem/services/rem/__init__.py +23 -0
- rem/services/rem/exceptions.py +71 -0
- rem/services/rem/executor.py +293 -0
- rem/services/rem/parser.py +180 -0
- rem/services/rem/queries.py +196 -0
- rem/services/rem/query.py +371 -0
- rem/services/rem/service.py +608 -0
- rem/services/session/README.md +374 -0
- rem/services/session/__init__.py +13 -0
- rem/services/session/compression.py +488 -0
- rem/services/session/pydantic_messages.py +310 -0
- rem/services/session/reload.py +85 -0
- rem/services/user_service.py +130 -0
- rem/settings.py +1877 -0
- rem/sql/background_indexes.sql +52 -0
- rem/sql/migrations/001_install.sql +983 -0
- rem/sql/migrations/002_install_models.sql +3157 -0
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +282 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
- rem/utils/AGENTIC_CHUNKING.md +597 -0
- rem/utils/README.md +628 -0
- rem/utils/__init__.py +61 -0
- rem/utils/agentic_chunking.py +622 -0
- rem/utils/batch_ops.py +343 -0
- rem/utils/chunking.py +108 -0
- rem/utils/clip_embeddings.py +276 -0
- rem/utils/constants.py +97 -0
- rem/utils/date_utils.py +228 -0
- rem/utils/dict_utils.py +98 -0
- rem/utils/embeddings.py +436 -0
- rem/utils/examples/embeddings_example.py +305 -0
- rem/utils/examples/sql_types_example.py +202 -0
- rem/utils/files.py +323 -0
- rem/utils/markdown.py +16 -0
- rem/utils/mime_types.py +158 -0
- rem/utils/model_helpers.py +492 -0
- rem/utils/schema_loader.py +649 -0
- rem/utils/sql_paths.py +146 -0
- rem/utils/sql_types.py +350 -0
- rem/utils/user_id.py +81 -0
- rem/utils/vision.py +325 -0
- rem/workers/README.md +506 -0
- rem/workers/__init__.py +7 -0
- rem/workers/db_listener.py +579 -0
- rem/workers/db_maintainer.py +74 -0
- rem/workers/dreaming.py +502 -0
- rem/workers/engram_processor.py +312 -0
- rem/workers/sqs_file_processor.py +193 -0
- rem/workers/unlogged_maintainer.py +463 -0
- remdb-0.3.242.dist-info/METADATA +1632 -0
- remdb-0.3.242.dist-info/RECORD +235 -0
- remdb-0.3.242.dist-info/WHEEL +4 -0
- 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
|
+
}
|