remdb 0.3.114__py3-none-any.whl → 0.3.172__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/agentic/agents/__init__.py +16 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/agents/sse_simulator.py +2 -0
- rem/agentic/context.py +103 -5
- rem/agentic/context_builder.py +36 -9
- rem/agentic/mcp/tool_wrapper.py +161 -18
- rem/agentic/otel/setup.py +1 -0
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +172 -30
- rem/agentic/schema.py +8 -4
- rem/api/deps.py +3 -5
- rem/api/main.py +26 -4
- rem/api/mcp_router/resources.py +15 -10
- rem/api/mcp_router/server.py +11 -3
- rem/api/mcp_router/tools.py +418 -4
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/admin.py +218 -1
- rem/api/routers/auth.py +349 -6
- rem/api/routers/chat/completions.py +255 -7
- rem/api/routers/chat/models.py +81 -7
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +17 -1
- rem/api/routers/chat/streaming.py +126 -19
- rem/api/routers/feedback.py +134 -14
- rem/api/routers/messages.py +24 -15
- rem/api/routers/query.py +6 -3
- rem/auth/__init__.py +13 -3
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +115 -10
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/README.md +42 -0
- rem/cli/commands/cluster.py +617 -168
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +66 -22
- rem/cli/commands/experiments.py +468 -76
- rem/cli/commands/schema.py +6 -5
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +2 -0
- rem/config.py +8 -1
- rem/models/core/experiment.py +58 -14
- rem/models/entities/__init__.py +4 -0
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/services/__init__.py +3 -1
- rem/services/content/service.py +4 -3
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +513 -0
- rem/services/email/templates.py +360 -0
- rem/services/phoenix/client.py +59 -18
- rem/services/postgres/README.md +38 -0
- rem/services/postgres/diff_service.py +127 -6
- rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
- rem/services/postgres/repository.py +5 -4
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/session/compression.py +120 -50
- rem/services/session/reload.py +14 -7
- rem/services/user_service.py +41 -9
- rem/settings.py +442 -23
- rem/sql/migrations/001_install.sql +156 -0
- rem/sql/migrations/002_install_models.sql +1951 -88
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/__init__.py +18 -0
- rem/utils/files.py +157 -1
- rem/utils/schema_loader.py +139 -10
- rem/utils/sql_paths.py +146 -0
- rem/utils/vision.py +1 -1
- rem/workers/__init__.py +3 -1
- rem/workers/db_listener.py +579 -0
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
|
@@ -1,13 +1,49 @@
|
|
|
1
1
|
"""Session message compression and rehydration for efficient context loading.
|
|
2
2
|
|
|
3
|
-
This module implements message compression to keep conversation history
|
|
4
|
-
context windows while preserving full content via REM LOOKUP.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
This module implements message storage and compression to keep conversation history
|
|
4
|
+
within context windows while preserving full content via REM LOOKUP.
|
|
5
|
+
|
|
6
|
+
Message Types and Storage Strategy
|
|
7
|
+
===================================
|
|
8
|
+
|
|
9
|
+
All messages are stored UNCOMPRESSED in the database for full audit/analysis.
|
|
10
|
+
Compression happens only on RELOAD when reconstructing context for the LLM.
|
|
11
|
+
|
|
12
|
+
Message Types:
|
|
13
|
+
- `user`: User messages - stored and reloaded as-is
|
|
14
|
+
- `tool`: Tool call messages (e.g., register_metadata) - stored and reloaded as-is
|
|
15
|
+
NEVER compressed - contains important structured metadata
|
|
16
|
+
- `assistant`: Assistant text responses - stored uncompressed, but MAY BE
|
|
17
|
+
compressed on reload if long (>400 chars) with REM LOOKUP hints
|
|
18
|
+
|
|
19
|
+
Example Session Flow:
|
|
20
|
+
```
|
|
21
|
+
Turn 1 (stored uncompressed):
|
|
22
|
+
- user: "I have a headache"
|
|
23
|
+
- tool: register_metadata({confidence: 0.3, collected_fields: {...}})
|
|
24
|
+
- assistant: "I'm sorry to hear that. How long has this been going on?"
|
|
25
|
+
|
|
26
|
+
Turn 2 (stored uncompressed):
|
|
27
|
+
- user: "About 3 days, really bad"
|
|
28
|
+
- tool: register_metadata({confidence: 0.6, collected_fields: {...}})
|
|
29
|
+
- assistant: "Got it - 3 days. On a scale of 1-10..."
|
|
30
|
+
|
|
31
|
+
On reload (for LLM context):
|
|
32
|
+
- user messages: returned as-is
|
|
33
|
+
- tool messages: returned as-is (never compressed)
|
|
34
|
+
- assistant messages: compressed if long, with REM LOOKUP hint for full retrieval
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
REM LOOKUP Pattern:
|
|
38
|
+
- Long assistant messages get truncated with hint: "... [REM LOOKUP session-{id}-msg-{idx}] ..."
|
|
39
|
+
- Agent can retrieve full content on-demand using the LOOKUP key
|
|
40
|
+
- Keeps context window efficient while preserving data integrity
|
|
41
|
+
|
|
42
|
+
Key Design Decisions:
|
|
43
|
+
1. Store everything uncompressed - full audit trail in database
|
|
44
|
+
2. Compress only on reload - optimize for LLM context window
|
|
45
|
+
3. Never compress tool messages - structured metadata must stay intact
|
|
46
|
+
4. REM LOOKUP enables on-demand retrieval of full assistant responses
|
|
11
47
|
"""
|
|
12
48
|
|
|
13
49
|
from typing import Any
|
|
@@ -170,12 +206,16 @@ class SessionMessageStore:
|
|
|
170
206
|
entity_key = truncate_key(f"session-{session_id}-msg-{message_index}")
|
|
171
207
|
|
|
172
208
|
# Create Message entity for assistant response
|
|
209
|
+
# Use pre-generated id from message dict if available (for frontend feedback)
|
|
173
210
|
msg = Message(
|
|
211
|
+
id=message.get("id"), # Use pre-generated ID if provided
|
|
174
212
|
content=message.get("content", ""),
|
|
175
213
|
message_type=message.get("role", "assistant"),
|
|
176
214
|
session_id=session_id,
|
|
177
215
|
tenant_id=self.user_id, # Set tenant_id to user_id (application scoped to user)
|
|
178
216
|
user_id=user_id or self.user_id,
|
|
217
|
+
trace_id=message.get("trace_id"),
|
|
218
|
+
span_id=message.get("span_id"),
|
|
179
219
|
metadata={
|
|
180
220
|
"message_index": message_index,
|
|
181
221
|
"entity_key": entity_key, # Store entity key for LOOKUP
|
|
@@ -281,18 +321,33 @@ class SessionMessageStore:
|
|
|
281
321
|
msg_copy["_entity_key"] = entity_key
|
|
282
322
|
compressed_messages.append(msg_copy)
|
|
283
323
|
else:
|
|
284
|
-
# Short assistant messages, user messages, and system messages stored as-is
|
|
324
|
+
# Short assistant messages, user messages, tool messages, and system messages stored as-is
|
|
285
325
|
# Store ALL messages in database for full audit trail
|
|
326
|
+
# Build metadata dict with standard fields
|
|
327
|
+
msg_metadata = {
|
|
328
|
+
"message_index": idx,
|
|
329
|
+
"timestamp": message.get("timestamp"),
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
# For tool messages, include tool call details in metadata
|
|
333
|
+
if message.get("role") == "tool":
|
|
334
|
+
if message.get("tool_call_id"):
|
|
335
|
+
msg_metadata["tool_call_id"] = message.get("tool_call_id")
|
|
336
|
+
if message.get("tool_name"):
|
|
337
|
+
msg_metadata["tool_name"] = message.get("tool_name")
|
|
338
|
+
if message.get("tool_arguments"):
|
|
339
|
+
msg_metadata["tool_arguments"] = message.get("tool_arguments")
|
|
340
|
+
|
|
286
341
|
msg = Message(
|
|
342
|
+
id=message.get("id"), # Use pre-generated ID if provided
|
|
287
343
|
content=content,
|
|
288
344
|
message_type=message.get("role", "user"),
|
|
289
345
|
session_id=session_id,
|
|
290
346
|
tenant_id=self.user_id, # Set tenant_id to user_id (application scoped to user)
|
|
291
347
|
user_id=user_id or self.user_id,
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
},
|
|
348
|
+
trace_id=message.get("trace_id"),
|
|
349
|
+
span_id=message.get("span_id"),
|
|
350
|
+
metadata=msg_metadata,
|
|
296
351
|
)
|
|
297
352
|
await self.repo.upsert(msg)
|
|
298
353
|
compressed_messages.append(message.copy())
|
|
@@ -300,18 +355,24 @@ class SessionMessageStore:
|
|
|
300
355
|
return compressed_messages
|
|
301
356
|
|
|
302
357
|
async def load_session_messages(
|
|
303
|
-
self, session_id: str, user_id: str | None = None,
|
|
358
|
+
self, session_id: str, user_id: str | None = None, compress_on_load: bool = True
|
|
304
359
|
) -> list[dict[str, Any]]:
|
|
305
360
|
"""
|
|
306
|
-
Load session messages from database.
|
|
361
|
+
Load session messages from database, optionally compressing long assistant messages.
|
|
362
|
+
|
|
363
|
+
Compression on Load:
|
|
364
|
+
- Tool messages (role: "tool") are NEVER compressed - they contain structured metadata
|
|
365
|
+
- User messages are returned as-is
|
|
366
|
+
- Assistant messages MAY be compressed if long (>400 chars) with REM LOOKUP hints
|
|
307
367
|
|
|
308
368
|
Args:
|
|
309
369
|
session_id: Session identifier
|
|
310
370
|
user_id: Optional user identifier for filtering
|
|
311
|
-
|
|
371
|
+
compress_on_load: Whether to compress long assistant messages (default: True)
|
|
312
372
|
|
|
313
373
|
Returns:
|
|
314
|
-
List of session messages in chronological order
|
|
374
|
+
List of session messages in chronological order, with long assistant
|
|
375
|
+
messages optionally compressed with REM LOOKUP hints
|
|
315
376
|
"""
|
|
316
377
|
if not settings.postgres.enabled:
|
|
317
378
|
logger.debug("Postgres disabled, returning empty message list")
|
|
@@ -328,49 +389,58 @@ class SessionMessageStore:
|
|
|
328
389
|
|
|
329
390
|
# Convert Message entities to dict format
|
|
330
391
|
message_dicts = []
|
|
331
|
-
for msg in messages:
|
|
392
|
+
for idx, msg in enumerate(messages):
|
|
393
|
+
role = msg.message_type or "assistant"
|
|
332
394
|
msg_dict = {
|
|
333
|
-
"role":
|
|
395
|
+
"role": role,
|
|
334
396
|
"content": msg.content,
|
|
335
397
|
"timestamp": msg.created_at.isoformat() if msg.created_at else None,
|
|
336
398
|
}
|
|
337
399
|
|
|
338
|
-
#
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
400
|
+
# For tool messages, reconstruct tool call metadata
|
|
401
|
+
if role == "tool" and msg.metadata:
|
|
402
|
+
if msg.metadata.get("tool_call_id"):
|
|
403
|
+
msg_dict["tool_call_id"] = msg.metadata["tool_call_id"]
|
|
404
|
+
if msg.metadata.get("tool_name"):
|
|
405
|
+
msg_dict["tool_name"] = msg.metadata["tool_name"]
|
|
406
|
+
if msg.metadata.get("tool_arguments"):
|
|
407
|
+
msg_dict["tool_arguments"] = msg.metadata["tool_arguments"]
|
|
408
|
+
|
|
409
|
+
# Compress long ASSISTANT messages on load (never tool messages)
|
|
410
|
+
if (
|
|
411
|
+
compress_on_load
|
|
412
|
+
and role == "assistant"
|
|
413
|
+
and len(msg.content) > self.compressor.min_length_for_compression
|
|
414
|
+
):
|
|
415
|
+
# Generate entity key for REM LOOKUP
|
|
416
|
+
entity_key = truncate_key(f"session-{session_id}-msg-{idx}")
|
|
417
|
+
msg_dict = self.compressor.compress_message(msg_dict, entity_key)
|
|
345
418
|
|
|
346
419
|
message_dicts.append(msg_dict)
|
|
347
420
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if self.compressor.is_compressed(message):
|
|
353
|
-
entity_key = self.compressor.get_entity_key(message)
|
|
354
|
-
if entity_key:
|
|
355
|
-
full_content = await self.retrieve_message(entity_key)
|
|
356
|
-
if full_content:
|
|
357
|
-
decompressed_messages.append(
|
|
358
|
-
self.compressor.decompress_message(
|
|
359
|
-
message, full_content
|
|
360
|
-
)
|
|
361
|
-
)
|
|
362
|
-
else:
|
|
363
|
-
# Fallback to compressed version if retrieval fails
|
|
364
|
-
decompressed_messages.append(message)
|
|
365
|
-
else:
|
|
366
|
-
decompressed_messages.append(message)
|
|
367
|
-
else:
|
|
368
|
-
decompressed_messages.append(message)
|
|
369
|
-
|
|
370
|
-
return decompressed_messages
|
|
371
|
-
|
|
421
|
+
logger.debug(
|
|
422
|
+
f"Loaded {len(message_dicts)} messages for session {session_id} "
|
|
423
|
+
f"(compress_on_load={compress_on_load})"
|
|
424
|
+
)
|
|
372
425
|
return message_dicts
|
|
373
426
|
|
|
374
427
|
except Exception as e:
|
|
375
428
|
logger.error(f"Failed to load session messages: {e}")
|
|
376
429
|
return []
|
|
430
|
+
|
|
431
|
+
async def retrieve_full_message(self, session_id: str, message_index: int) -> str | None:
|
|
432
|
+
"""
|
|
433
|
+
Retrieve full message content by session and message index (for REM LOOKUP).
|
|
434
|
+
|
|
435
|
+
This is used when an agent needs to recover full content from a compressed
|
|
436
|
+
message that has a REM LOOKUP hint.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
session_id: Session identifier
|
|
440
|
+
message_index: Index of message in session (from REM LOOKUP key)
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
Full message content or None if not found
|
|
444
|
+
"""
|
|
445
|
+
entity_key = truncate_key(f"session-{session_id}-msg-{message_index}")
|
|
446
|
+
return await self.retrieve_message(entity_key)
|
rem/services/session/reload.py
CHANGED
|
@@ -6,8 +6,14 @@ allowing conversations to be resumed across multiple API calls.
|
|
|
6
6
|
Design Pattern:
|
|
7
7
|
- Session identified by session_id from X-Session-Id header
|
|
8
8
|
- All messages for session loaded in chronological order
|
|
9
|
-
-
|
|
9
|
+
- Long assistant messages compressed on load with REM LOOKUP hints
|
|
10
|
+
- Tool messages (register_metadata, etc.) are NEVER compressed
|
|
10
11
|
- Gracefully handles missing database (returns empty history)
|
|
12
|
+
|
|
13
|
+
Message Types on Reload:
|
|
14
|
+
- user: Returned as-is
|
|
15
|
+
- tool: Returned as-is with metadata (tool_call_id, tool_name, tool_arguments)
|
|
16
|
+
- assistant: Compressed on load if long (>400 chars), with REM LOOKUP for recovery
|
|
11
17
|
"""
|
|
12
18
|
|
|
13
19
|
from loguru import logger
|
|
@@ -19,7 +25,7 @@ from rem.settings import settings
|
|
|
19
25
|
async def reload_session(
|
|
20
26
|
session_id: str,
|
|
21
27
|
user_id: str,
|
|
22
|
-
|
|
28
|
+
compress_on_load: bool = True,
|
|
23
29
|
) -> list[dict]:
|
|
24
30
|
"""
|
|
25
31
|
Reload all messages for a session from the database.
|
|
@@ -27,7 +33,8 @@ async def reload_session(
|
|
|
27
33
|
Args:
|
|
28
34
|
session_id: Session/conversation identifier
|
|
29
35
|
user_id: User identifier for data isolation
|
|
30
|
-
|
|
36
|
+
compress_on_load: Whether to compress long assistant messages (default: True)
|
|
37
|
+
Tool messages are NEVER compressed.
|
|
31
38
|
|
|
32
39
|
Returns:
|
|
33
40
|
List of message dicts in chronological order (oldest first)
|
|
@@ -41,7 +48,7 @@ async def reload_session(
|
|
|
41
48
|
history = await reload_session(
|
|
42
49
|
session_id=context.session_id,
|
|
43
50
|
user_id=context.user_id,
|
|
44
|
-
|
|
51
|
+
compress_on_load=True, # Compress long assistant messages
|
|
45
52
|
)
|
|
46
53
|
|
|
47
54
|
# Combine with new user message
|
|
@@ -60,14 +67,14 @@ async def reload_session(
|
|
|
60
67
|
# Create message store for this session
|
|
61
68
|
store = SessionMessageStore(user_id=user_id)
|
|
62
69
|
|
|
63
|
-
# Load messages (
|
|
70
|
+
# Load messages (assistant messages compressed on load, tool messages never compressed)
|
|
64
71
|
messages = await store.load_session_messages(
|
|
65
|
-
session_id=session_id, user_id=user_id,
|
|
72
|
+
session_id=session_id, user_id=user_id, compress_on_load=compress_on_load
|
|
66
73
|
)
|
|
67
74
|
|
|
68
75
|
logger.debug(
|
|
69
76
|
f"Reloaded {len(messages)} messages for session {session_id} "
|
|
70
|
-
f"(
|
|
77
|
+
f"(compress_on_load={compress_on_load})"
|
|
71
78
|
)
|
|
72
79
|
|
|
73
80
|
return messages
|
rem/services/user_service.py
CHANGED
|
@@ -4,7 +4,8 @@ User Service - User account management.
|
|
|
4
4
|
Handles user creation, profile updates, and session linking.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from rem.utils.date_utils import utc_now
|
|
8
|
+
from rem.utils.user_id import email_to_user_id
|
|
8
9
|
from typing import Optional
|
|
9
10
|
|
|
10
11
|
from loguru import logger
|
|
@@ -51,28 +52,59 @@ class UserService:
|
|
|
51
52
|
updated = True
|
|
52
53
|
|
|
53
54
|
if updated:
|
|
54
|
-
user.updated_at =
|
|
55
|
+
user.updated_at = utc_now()
|
|
55
56
|
await self.repo.upsert(user)
|
|
56
57
|
|
|
57
58
|
return user
|
|
58
59
|
|
|
59
60
|
# Create new user
|
|
61
|
+
# id and user_id = UUID5 hash of email (deterministic bijection)
|
|
62
|
+
# name = email (entity_key for LOOKUP by email in KV store)
|
|
63
|
+
hashed_id = email_to_user_id(email)
|
|
60
64
|
user = User(
|
|
65
|
+
id=hashed_id, # Database id = hash of email
|
|
61
66
|
tenant_id=tenant_id,
|
|
62
|
-
user_id=
|
|
63
|
-
#
|
|
64
|
-
# Usually user_id is the external ID or email.
|
|
65
|
-
name=name,
|
|
67
|
+
user_id=hashed_id, # user_id = hash of email (same as id)
|
|
68
|
+
name=email, # Email as entity_key for REM LOOKUP
|
|
66
69
|
email=email,
|
|
67
70
|
tier=UserTier.FREE,
|
|
68
|
-
created_at=
|
|
69
|
-
updated_at=
|
|
71
|
+
created_at=utc_now(),
|
|
72
|
+
updated_at=utc_now(),
|
|
70
73
|
metadata={"avatar_url": avatar_url} if avatar_url else {},
|
|
71
74
|
)
|
|
72
75
|
await self.repo.upsert(user)
|
|
73
76
|
logger.info(f"Created new user: {email}")
|
|
74
77
|
return user
|
|
75
78
|
|
|
79
|
+
async def get_user_by_id(self, user_id: str) -> Optional[User]:
|
|
80
|
+
"""
|
|
81
|
+
Get a user by their UUID.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
user_id: The user's UUID
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
User if found, None otherwise
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
return await self.repo.get_by_id(user_id)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.warning(f"Could not find user by id {user_id}: {e}")
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
async def get_user_by_email(self, email: str) -> Optional[User]:
|
|
96
|
+
"""
|
|
97
|
+
Get a user by their email address.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
email: The user's email
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
User if found, None otherwise
|
|
104
|
+
"""
|
|
105
|
+
users = await self.repo.find(filters={"email": email}, limit=1)
|
|
106
|
+
return users[0] if users else None
|
|
107
|
+
|
|
76
108
|
async def link_anonymous_session(self, user: User, anon_id: str) -> None:
|
|
77
109
|
"""
|
|
78
110
|
Link an anonymous session ID to a user account.
|
|
@@ -88,7 +120,7 @@ class UserService:
|
|
|
88
120
|
|
|
89
121
|
# Add to list
|
|
90
122
|
user.anonymous_ids.append(anon_id)
|
|
91
|
-
user.updated_at =
|
|
123
|
+
user.updated_at = utc_now()
|
|
92
124
|
|
|
93
125
|
# Save
|
|
94
126
|
await self.repo.upsert(user)
|