mem0-postgres-store 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.
- mem0_postgres_store/__init__.py +17 -0
- mem0_postgres_store/context.py +68 -0
- mem0_postgres_store/database.py +153 -0
- mem0_postgres_store/history.py +173 -0
- mem0_postgres_store/integration.py +125 -0
- mem0_postgres_store/migrate.py +35 -0
- mem0_postgres_store/migrations/env.py +27 -0
- mem0_postgres_store/migrations/versions/0001_storage.py +88 -0
- mem0_postgres_store/migrations/versions/0002_append_only_history.py +29 -0
- mem0_postgres_store/py.typed +0 -0
- mem0_postgres_store/schema.py +51 -0
- mem0_postgres_store/vector.py +87 -0
- mem0_postgres_store-0.1.0.dist-info/METADATA +438 -0
- mem0_postgres_store-0.1.0.dist-info/RECORD +16 -0
- mem0_postgres_store-0.1.0.dist-info/WHEEL +4 -0
- mem0_postgres_store-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""PostgreSQL persistence for Mem0. Importing this package never patches Mem0."""
|
|
2
|
+
|
|
3
|
+
from .context import StorageOperationError, TenantContextError, tenant_scope
|
|
4
|
+
from .database import Database
|
|
5
|
+
from .history import PostgresHistoryStore
|
|
6
|
+
from .integration import CompatibilityError, database_vector_config, install
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CompatibilityError",
|
|
10
|
+
"Database",
|
|
11
|
+
"PostgresHistoryStore",
|
|
12
|
+
"StorageOperationError",
|
|
13
|
+
"TenantContextError",
|
|
14
|
+
"database_vector_config",
|
|
15
|
+
"install",
|
|
16
|
+
"tenant_scope",
|
|
17
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Authenticated request context, shared across asyncio and copied worker contexts."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from threading import Lock
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TenantContextError(RuntimeError):
|
|
11
|
+
"""An authenticated tenant scope is required."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StorageOperationError(RuntimeError):
|
|
15
|
+
"""The SDK caught a persistence error; the operation may be partially committed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TenantContext:
|
|
20
|
+
tenant_id: str
|
|
21
|
+
allow_reset: bool = False
|
|
22
|
+
failures: list[Exception] = field(default_factory=list)
|
|
23
|
+
lock: Lock = field(default_factory=Lock)
|
|
24
|
+
active: bool = True
|
|
25
|
+
|
|
26
|
+
def record(self, error: Exception) -> None:
|
|
27
|
+
with self.lock:
|
|
28
|
+
self.failures.append(error)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_current: ContextVar[TenantContext | None] = ContextVar("mem0_postgres_tenant", default=None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def current_tenant() -> TenantContext:
|
|
35
|
+
context = _current.get()
|
|
36
|
+
if context is None or not context.active:
|
|
37
|
+
raise TenantContextError("Establish tenant_scope() using an authenticated tenant identity")
|
|
38
|
+
return context
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def require_reset() -> None:
|
|
42
|
+
context = current_tenant()
|
|
43
|
+
if not context.allow_reset:
|
|
44
|
+
error = PermissionError("Reset requires tenant_scope(..., allow_reset=True)")
|
|
45
|
+
context.record(error)
|
|
46
|
+
raise error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def tenant_scope(tenant_id: str, *, allow_reset: bool = False) -> Iterator[None]:
|
|
51
|
+
"""Trust boundary: callers must authenticate/authorize tenant_id before entering.
|
|
52
|
+
|
|
53
|
+
Keep all work inside the scope; do not detach background tasks. Errors caught by
|
|
54
|
+
upstream Mem0 are surfaced at scope exit. This is not a database transaction.
|
|
55
|
+
"""
|
|
56
|
+
if not isinstance(tenant_id, str) or not tenant_id.strip() or "\x00" in tenant_id:
|
|
57
|
+
raise TenantContextError("tenant_id must be a nonempty text identity without NUL")
|
|
58
|
+
context = TenantContext(tenant_id, allow_reset)
|
|
59
|
+
token = _current.set(context)
|
|
60
|
+
try:
|
|
61
|
+
yield
|
|
62
|
+
if context.failures:
|
|
63
|
+
raise StorageOperationError(
|
|
64
|
+
"A persistence operation failed inside tenant_scope; partial writes are possible"
|
|
65
|
+
) from context.failures[0]
|
|
66
|
+
finally:
|
|
67
|
+
context.active = False
|
|
68
|
+
_current.reset(token)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Explicit infrastructure ownership; no environment or runtime DDL discovery."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
|
|
6
|
+
import sqlalchemy as sa
|
|
7
|
+
from psycopg.conninfo import make_conninfo
|
|
8
|
+
from psycopg_pool import ConnectionPool
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from .context import current_tenant
|
|
12
|
+
from .schema import APPEND_ONLY_TABLES, SCHEMA, TABLE_NAMES, VECTOR_COLLECTIONS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Database:
|
|
16
|
+
"""Application-owned pools. Construct after migrations, close during shutdown."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
url: str,
|
|
21
|
+
*,
|
|
22
|
+
embedding_dimensions: int,
|
|
23
|
+
pool_size: int = 5,
|
|
24
|
+
timeout: float = 10,
|
|
25
|
+
) -> None:
|
|
26
|
+
parsed = sa.make_url(url)
|
|
27
|
+
if parsed.drivername != "postgresql+psycopg":
|
|
28
|
+
raise ValueError("Use a postgresql+psycopg:// runtime URL")
|
|
29
|
+
if embedding_dimensions < 1 or pool_size < 1 or timeout <= 0:
|
|
30
|
+
raise ValueError("Dimensions, pool size and timeout must be positive")
|
|
31
|
+
self.embedding_dimensions = embedding_dimensions
|
|
32
|
+
self.closed = False
|
|
33
|
+
self.engine = sa.create_engine(
|
|
34
|
+
parsed,
|
|
35
|
+
pool_size=pool_size,
|
|
36
|
+
max_overflow=0,
|
|
37
|
+
pool_timeout=timeout,
|
|
38
|
+
pool_pre_ping=True,
|
|
39
|
+
connect_args={"connect_timeout": max(1, int(timeout))},
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
self._validate_runtime()
|
|
43
|
+
except Exception:
|
|
44
|
+
self.engine.dispose()
|
|
45
|
+
raise
|
|
46
|
+
if any(not isinstance(value, str) for value in parsed.query.values()):
|
|
47
|
+
self.engine.dispose()
|
|
48
|
+
raise ValueError("Repeated URL query parameters are not supported")
|
|
49
|
+
conninfo = make_conninfo(
|
|
50
|
+
dbname=parsed.database,
|
|
51
|
+
user=parsed.username,
|
|
52
|
+
password=parsed.password,
|
|
53
|
+
host=parsed.host,
|
|
54
|
+
port=parsed.port,
|
|
55
|
+
**{key: str(value) for key, value in parsed.query.items()},
|
|
56
|
+
)
|
|
57
|
+
self.vector_pool = ConnectionPool(
|
|
58
|
+
conninfo,
|
|
59
|
+
min_size=0,
|
|
60
|
+
max_size=pool_size,
|
|
61
|
+
timeout=timeout,
|
|
62
|
+
check=ConnectionPool.check_connection,
|
|
63
|
+
kwargs={"connect_timeout": max(1, int(timeout))},
|
|
64
|
+
open=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _validate_runtime(self) -> None:
|
|
68
|
+
with self.engine.connect() as connection:
|
|
69
|
+
unsafe = connection.execute(
|
|
70
|
+
sa.text("""
|
|
71
|
+
SELECT rolsuper OR rolbypassrls OR rolcreaterole OR rolcreatedb
|
|
72
|
+
FROM pg_roles WHERE rolname = current_user
|
|
73
|
+
""")
|
|
74
|
+
).scalar_one()
|
|
75
|
+
if unsafe:
|
|
76
|
+
raise PermissionError("Runtime role must not have privileged role attributes")
|
|
77
|
+
for table in TABLE_NAMES:
|
|
78
|
+
row = connection.execute(
|
|
79
|
+
sa.text("""
|
|
80
|
+
SELECT c.relrowsecurity, c.relforcerowsecurity,
|
|
81
|
+
pg_has_role(current_user, c.relowner, 'MEMBER') AS owns
|
|
82
|
+
FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
83
|
+
WHERE n.nspname = :schema AND c.relname = :table
|
|
84
|
+
"""),
|
|
85
|
+
{"schema": SCHEMA, "table": table},
|
|
86
|
+
).one_or_none()
|
|
87
|
+
if row is None or not row[0] or not row[1] or row[2]:
|
|
88
|
+
raise PermissionError(
|
|
89
|
+
f"{SCHEMA}.{table} requires migrations, forced RLS, and a separate owner"
|
|
90
|
+
)
|
|
91
|
+
if connection.execute(
|
|
92
|
+
sa.text("SELECT has_table_privilege(current_user, :table, 'TRUNCATE')"),
|
|
93
|
+
{"table": f"{SCHEMA}.{table}"},
|
|
94
|
+
).scalar_one():
|
|
95
|
+
raise PermissionError("Runtime role must not have TRUNCATE privileges")
|
|
96
|
+
if (
|
|
97
|
+
table in APPEND_ONLY_TABLES
|
|
98
|
+
and connection.execute(
|
|
99
|
+
sa.text("SELECT has_any_column_privilege(current_user, :table, 'UPDATE')"),
|
|
100
|
+
{"table": f"{SCHEMA}.{table}"},
|
|
101
|
+
).scalar_one()
|
|
102
|
+
):
|
|
103
|
+
raise PermissionError(
|
|
104
|
+
f"Runtime role must not UPDATE {SCHEMA}.{table}; apply all migrations"
|
|
105
|
+
)
|
|
106
|
+
if connection.execute(
|
|
107
|
+
sa.text("SELECT has_schema_privilege(current_user, :schema, 'CREATE')"),
|
|
108
|
+
{"schema": SCHEMA},
|
|
109
|
+
).scalar_one():
|
|
110
|
+
raise PermissionError("Runtime role must not have CREATE on the storage schema")
|
|
111
|
+
for table in VECTOR_COLLECTIONS:
|
|
112
|
+
vector_type = connection.execute(
|
|
113
|
+
sa.text("""
|
|
114
|
+
SELECT format_type(a.atttypid, a.atttypmod)
|
|
115
|
+
FROM pg_attribute a
|
|
116
|
+
WHERE a.attrelid = to_regclass(:table) AND a.attname = 'vector'
|
|
117
|
+
"""),
|
|
118
|
+
{"table": f"{SCHEMA}.{table}"},
|
|
119
|
+
).scalar_one()
|
|
120
|
+
if vector_type != f"vector({self.embedding_dimensions})":
|
|
121
|
+
raise ValueError("Embedding dimensions do not match the migrated vector tables")
|
|
122
|
+
|
|
123
|
+
@contextmanager
|
|
124
|
+
def session(self) -> Iterator[Session]:
|
|
125
|
+
context = current_tenant()
|
|
126
|
+
try:
|
|
127
|
+
self.check_open()
|
|
128
|
+
with Session(self.engine) as session, session.begin():
|
|
129
|
+
session.execute(
|
|
130
|
+
sa.text("SELECT set_config('app.tenant_id', :tenant, true)"),
|
|
131
|
+
{"tenant": context.tenant_id},
|
|
132
|
+
)
|
|
133
|
+
yield session
|
|
134
|
+
except Exception as error:
|
|
135
|
+
context.record(error)
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
def check_open(self) -> None:
|
|
139
|
+
if self.closed:
|
|
140
|
+
raise RuntimeError("Database is closed")
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
if not self.closed:
|
|
144
|
+
self.closed = True
|
|
145
|
+
self.vector_pool.close()
|
|
146
|
+
self.engine.dispose()
|
|
147
|
+
|
|
148
|
+
def __enter__(self) -> "Database":
|
|
149
|
+
self.check_open()
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def __exit__(self, *args: object) -> None:
|
|
153
|
+
self.close()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Mem0 SQLiteManager method contract backed by tenant-scoped transactions."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import sqlalchemy as sa
|
|
9
|
+
|
|
10
|
+
from .context import current_tenant, require_reset
|
|
11
|
+
from .database import Database
|
|
12
|
+
from .schema import HISTORY_FIELDS, history
|
|
13
|
+
from .schema import messages as message_table
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _updated_order(value: str | None) -> datetime | None:
|
|
17
|
+
# SQLite DATETIME() orders ISO values in UTC to whole seconds; keep the
|
|
18
|
+
# original text independently for exact round trips, including None.
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
parsed = datetime.fromisoformat(value)
|
|
23
|
+
except ValueError:
|
|
24
|
+
return None
|
|
25
|
+
if parsed.tzinfo is None:
|
|
26
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
27
|
+
return parsed.astimezone(UTC).replace(microsecond=0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PostgresHistoryStore:
|
|
31
|
+
def __init__(self, database: Database) -> None:
|
|
32
|
+
self.database = database
|
|
33
|
+
self._closed = False
|
|
34
|
+
|
|
35
|
+
def _check_open(self) -> None:
|
|
36
|
+
if self._closed:
|
|
37
|
+
raise RuntimeError("PostgresHistoryStore is closed")
|
|
38
|
+
|
|
39
|
+
def add_history(
|
|
40
|
+
self,
|
|
41
|
+
memory_id: str,
|
|
42
|
+
old_memory: str | None,
|
|
43
|
+
new_memory: str | None,
|
|
44
|
+
event: str,
|
|
45
|
+
*,
|
|
46
|
+
created_at: str | None = None,
|
|
47
|
+
updated_at: str | None = None,
|
|
48
|
+
is_deleted: int = 0,
|
|
49
|
+
actor_id: str | None = None,
|
|
50
|
+
role: str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.batch_add_history(
|
|
53
|
+
[
|
|
54
|
+
{
|
|
55
|
+
"memory_id": memory_id,
|
|
56
|
+
"old_memory": old_memory,
|
|
57
|
+
"new_memory": new_memory,
|
|
58
|
+
"event": event,
|
|
59
|
+
"created_at": created_at,
|
|
60
|
+
"updated_at": updated_at,
|
|
61
|
+
"is_deleted": is_deleted,
|
|
62
|
+
"actor_id": actor_id,
|
|
63
|
+
"role": role,
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def batch_add_history(self, records: list[dict[str, Any]]) -> None:
|
|
69
|
+
self._check_open()
|
|
70
|
+
with self.database.session() as session:
|
|
71
|
+
tenant = current_tenant().tenant_id
|
|
72
|
+
rows = []
|
|
73
|
+
for record in records:
|
|
74
|
+
row = {key: record.get(key) for key in HISTORY_FIELDS}
|
|
75
|
+
row.update(
|
|
76
|
+
id=str(uuid.uuid4()),
|
|
77
|
+
tenant_id=tenant,
|
|
78
|
+
is_deleted=record.get("is_deleted", 0),
|
|
79
|
+
updated_order=_updated_order(record.get("updated_at")),
|
|
80
|
+
)
|
|
81
|
+
rows.append(row)
|
|
82
|
+
if rows:
|
|
83
|
+
session.execute(history.insert(), rows)
|
|
84
|
+
|
|
85
|
+
def get_history(self, memory_id: str) -> list[dict[str, Any]]:
|
|
86
|
+
self._check_open()
|
|
87
|
+
with self.database.session() as session:
|
|
88
|
+
rows = session.execute(
|
|
89
|
+
sa.select(*(history.c[key] for key in HISTORY_FIELDS))
|
|
90
|
+
.where(history.c.memory_id == memory_id)
|
|
91
|
+
.order_by(
|
|
92
|
+
history.c.created_at.collate("C").asc().nulls_first(),
|
|
93
|
+
history.c.updated_order.asc().nulls_first(),
|
|
94
|
+
history.c.sequence,
|
|
95
|
+
)
|
|
96
|
+
).mappings()
|
|
97
|
+
return [dict(row, is_deleted=bool(row["is_deleted"])) for row in rows]
|
|
98
|
+
|
|
99
|
+
def save_messages(self, messages: list[dict[str, Any]], session_scope: str) -> None:
|
|
100
|
+
self._check_open()
|
|
101
|
+
with self.database.session() as session:
|
|
102
|
+
if not messages:
|
|
103
|
+
return
|
|
104
|
+
tenant = current_tenant().tenant_id
|
|
105
|
+
# Across processes, serialize insert + pruning for exactly this tenant/scope.
|
|
106
|
+
# Hash collisions only cause extra serialization; RLS still authorizes rows.
|
|
107
|
+
lock_key = int.from_bytes(
|
|
108
|
+
hashlib.sha256((tenant + "\x00" + session_scope).encode()).digest()[:8], signed=True
|
|
109
|
+
)
|
|
110
|
+
session.execute(sa.text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key})
|
|
111
|
+
now = datetime.now(UTC).isoformat()
|
|
112
|
+
table = message_table
|
|
113
|
+
session.execute(
|
|
114
|
+
table.insert(),
|
|
115
|
+
[
|
|
116
|
+
{
|
|
117
|
+
"id": str(uuid.uuid4()),
|
|
118
|
+
"tenant_id": tenant,
|
|
119
|
+
"session_scope": session_scope,
|
|
120
|
+
"role": message.get("role"),
|
|
121
|
+
"content": message.get("content"),
|
|
122
|
+
"name": message.get("name"),
|
|
123
|
+
"created_at": now,
|
|
124
|
+
}
|
|
125
|
+
for message in messages
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
keep = (
|
|
129
|
+
sa.select(table.c.sequence)
|
|
130
|
+
.where(table.c.session_scope == session_scope)
|
|
131
|
+
.order_by(table.c.created_at.desc(), table.c.sequence.desc())
|
|
132
|
+
.limit(10)
|
|
133
|
+
)
|
|
134
|
+
session.execute(
|
|
135
|
+
table.delete().where(
|
|
136
|
+
table.c.session_scope == session_scope, table.c.sequence.not_in(keep)
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def get_last_messages(self, session_scope: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
141
|
+
self._check_open()
|
|
142
|
+
with self.database.session() as session:
|
|
143
|
+
query = (
|
|
144
|
+
sa.select(
|
|
145
|
+
message_table.c.role,
|
|
146
|
+
message_table.c.content,
|
|
147
|
+
message_table.c.name,
|
|
148
|
+
message_table.c.created_at,
|
|
149
|
+
message_table.c.sequence,
|
|
150
|
+
)
|
|
151
|
+
.where(message_table.c.session_scope == session_scope)
|
|
152
|
+
.order_by(message_table.c.created_at.desc(), message_table.c.sequence.desc())
|
|
153
|
+
)
|
|
154
|
+
if limit >= 0: # SQLite LIMIT -1 means all rows.
|
|
155
|
+
query = query.limit(limit)
|
|
156
|
+
latest = query.subquery()
|
|
157
|
+
rows = session.execute(
|
|
158
|
+
sa.select(
|
|
159
|
+
latest.c.role, latest.c.content, latest.c.name, latest.c.created_at
|
|
160
|
+
).order_by(latest.c.created_at, latest.c.sequence)
|
|
161
|
+
).mappings()
|
|
162
|
+
return [dict(row) for row in rows]
|
|
163
|
+
|
|
164
|
+
def reset(self) -> None:
|
|
165
|
+
self._check_open()
|
|
166
|
+
require_reset()
|
|
167
|
+
with self.database.session() as session:
|
|
168
|
+
session.execute(history.delete())
|
|
169
|
+
session.execute(message_table.delete())
|
|
170
|
+
|
|
171
|
+
def close(self) -> None:
|
|
172
|
+
# A manager owns its lifecycle, not the application's shared pools.
|
|
173
|
+
self._closed = True
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The only module that mutates Mem0 bindings; install() is explicit and process-global."""
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
import inspect
|
|
8
|
+
import threading
|
|
9
|
+
from contextvars import copy_context
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .database import Database
|
|
15
|
+
from .history import PostgresHistoryStore
|
|
16
|
+
|
|
17
|
+
_LOCK = threading.Lock()
|
|
18
|
+
_database: Database | None = None
|
|
19
|
+
_PROVIDER = "mem0_postgres_store.vector.TenantPGVector"
|
|
20
|
+
# Official mem0ai 2.2.1 wheel; content equals v2.2.1 sources (ignoring trailing newlines).
|
|
21
|
+
_FINGERPRINTS = {
|
|
22
|
+
"mem0.memory.main": "b2788e8be9f1cc81b0a5dbe8f1e485bc4934b81c464bdfe2ed72330c089f1dcb",
|
|
23
|
+
"mem0.memory.storage": "5e7a994e311d8d8cc8fb8e6447cbb7ef85a52fcea4dd7fa9b03bd1b596a1b5e5",
|
|
24
|
+
"mem0.vector_stores.pgvector": (
|
|
25
|
+
"3c21398a91eab7846936bd175bf3849975e0086f5bdae18a755d370b2bdb2c4f"
|
|
26
|
+
),
|
|
27
|
+
"mem0.utils.factory": "b1f0fb9edfd220d05a5e0f9c7aac5674339f9525291634eacc5dae26ab4c967b",
|
|
28
|
+
"mem0.configs.base": "cddb03d76a2266ce0e845c0625d922434e862411ad26fd1c1837d737c02f3655",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CompatibilityError(RuntimeError):
|
|
33
|
+
"""The installed SDK or another patch does not match the audited integration."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _ContextExecutor(concurrent.futures.ThreadPoolExecutor):
|
|
37
|
+
def submit(self, fn: Any, /, *args: Any, **kwargs: Any) -> Any:
|
|
38
|
+
return super().submit(copy_context().run, fn, *args, **kwargs)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_concurrent = SimpleNamespace(
|
|
42
|
+
futures=SimpleNamespace(
|
|
43
|
+
ThreadPoolExecutor=_ContextExecutor,
|
|
44
|
+
as_completed=concurrent.futures.as_completed,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def installed_database() -> Database:
|
|
50
|
+
if _database is None:
|
|
51
|
+
raise RuntimeError("Call install(database) before constructing Mem0")
|
|
52
|
+
_database.check_open()
|
|
53
|
+
return _database
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _history_factory(db_path: str = ":memory:") -> PostgresHistoryStore:
|
|
57
|
+
if db_path != "postgresql://mem0-postgres-store":
|
|
58
|
+
raise ValueError("Set history_db_path='postgresql://mem0-postgres-store'")
|
|
59
|
+
return PostgresHistoryStore(installed_database())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_compatibility() -> None:
|
|
63
|
+
if importlib.metadata.version("mem0ai") != "2.2.1":
|
|
64
|
+
raise CompatibilityError("This integration requires exactly mem0ai==2.2.1")
|
|
65
|
+
for name, expected in _FINGERPRINTS.items():
|
|
66
|
+
module = importlib.import_module(name)
|
|
67
|
+
source = inspect.getsourcefile(module)
|
|
68
|
+
if (
|
|
69
|
+
source is None
|
|
70
|
+
or hashlib.sha256(Path(source).read_bytes().rstrip()).hexdigest() != expected
|
|
71
|
+
):
|
|
72
|
+
raise CompatibilityError(f"Unaudited Mem0 source: {name}; review before upgrading")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def install(database: Database) -> None:
|
|
76
|
+
"""Call once at process startup, before any Memory/AsyncMemory instances exist.
|
|
77
|
+
|
|
78
|
+
Repeated calls with the same Database are harmless. A different Database may
|
|
79
|
+
replace the installed one only after that one is closed (for example, repeated
|
|
80
|
+
application lifespans in one test process); rebinding an open Database is rejected.
|
|
81
|
+
SDK instances created against a closed Database keep failing closed.
|
|
82
|
+
"""
|
|
83
|
+
global _database
|
|
84
|
+
with _LOCK:
|
|
85
|
+
_check_compatibility()
|
|
86
|
+
from mem0.memory import main, storage
|
|
87
|
+
from mem0.utils.factory import VectorStoreFactory
|
|
88
|
+
|
|
89
|
+
database.check_open()
|
|
90
|
+
if main.MEM0_TELEMETRY:
|
|
91
|
+
raise CompatibilityError("Set MEM0_TELEMETRY=false before importing Mem0")
|
|
92
|
+
if _database is not None:
|
|
93
|
+
if (
|
|
94
|
+
(_database is not database and not _database.closed)
|
|
95
|
+
or main.SQLiteManager is not _history_factory
|
|
96
|
+
or VectorStoreFactory.provider_to_class.get("pgvector") != _PROVIDER
|
|
97
|
+
or main.concurrent is not _concurrent
|
|
98
|
+
):
|
|
99
|
+
raise CompatibilityError("Already installed with a different database or bindings")
|
|
100
|
+
_database = database
|
|
101
|
+
return
|
|
102
|
+
if (
|
|
103
|
+
main.SQLiteManager is not storage.SQLiteManager
|
|
104
|
+
or VectorStoreFactory.provider_to_class.get("pgvector")
|
|
105
|
+
!= "mem0.vector_stores.pgvector.PGVector"
|
|
106
|
+
or main.concurrent is not concurrent
|
|
107
|
+
):
|
|
108
|
+
raise CompatibilityError("Another integration has already patched Mem0")
|
|
109
|
+
_database = database
|
|
110
|
+
main.SQLiteManager = _history_factory
|
|
111
|
+
VectorStoreFactory.provider_to_class["pgvector"] = _PROVIDER
|
|
112
|
+
main.concurrent = _concurrent
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def database_vector_config(database: Database) -> dict[str, Any]:
|
|
116
|
+
"""Vector config shared by Memory and AsyncMemory; credentials stay in Database."""
|
|
117
|
+
return {
|
|
118
|
+
"provider": "pgvector",
|
|
119
|
+
"config": {
|
|
120
|
+
"collection_name": "mem0_vectors",
|
|
121
|
+
"embedding_model_dims": database.embedding_dimensions,
|
|
122
|
+
"connection_string": "postgresql://mem0-postgres-store",
|
|
123
|
+
"hnsw": False,
|
|
124
|
+
},
|
|
125
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""uv run python -m mem0_postgres_store.migrate --dimensions 1536"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from alembic import command
|
|
7
|
+
from alembic.config import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def migration_config(url: str, *, dimensions: int, runtime_role: str) -> Config:
|
|
11
|
+
config = Config()
|
|
12
|
+
config.set_main_option("script_location", "mem0_postgres_store:migrations")
|
|
13
|
+
config.attributes["url"] = url
|
|
14
|
+
config.cmd_opts = argparse.Namespace(
|
|
15
|
+
x=[
|
|
16
|
+
f"dimensions={dimensions}",
|
|
17
|
+
f"runtime_role={runtime_role}",
|
|
18
|
+
]
|
|
19
|
+
)
|
|
20
|
+
return config
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
parser = argparse.ArgumentParser(description="Apply packaged Alembic migrations")
|
|
25
|
+
parser.add_argument("--dimensions", type=int, required=True)
|
|
26
|
+
parser.add_argument("--runtime-role", default="mem0_runtime")
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
url = os.environ["MEM0_MIGRATION_URL"]
|
|
29
|
+
command.upgrade(
|
|
30
|
+
migration_config(url, dimensions=args.dimensions, runtime_role=args.runtime_role), "head"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Migration credentials come only from the Alembic composition boundary."""
|
|
2
|
+
|
|
3
|
+
from alembic import context
|
|
4
|
+
from sqlalchemy import create_engine, pool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run() -> None:
|
|
8
|
+
config = context.config
|
|
9
|
+
url = config.attributes.get("url") or config.get_main_option("sqlalchemy.url")
|
|
10
|
+
if not url:
|
|
11
|
+
raise ValueError("Pass migration URL through Config.attributes['url'] or sqlalchemy.url")
|
|
12
|
+
if context.is_offline_mode():
|
|
13
|
+
context.configure(url=url, literal_binds=True)
|
|
14
|
+
with context.begin_transaction():
|
|
15
|
+
context.run_migrations()
|
|
16
|
+
return
|
|
17
|
+
engine = create_engine(url, poolclass=pool.NullPool)
|
|
18
|
+
try:
|
|
19
|
+
with engine.connect() as connection:
|
|
20
|
+
context.configure(connection=connection)
|
|
21
|
+
with context.begin_transaction():
|
|
22
|
+
context.run_migrations()
|
|
23
|
+
finally:
|
|
24
|
+
engine.dispose()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
run()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Tenant-isolated history, extraction context, vectors and entity vectors."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from alembic import context, op
|
|
7
|
+
|
|
8
|
+
revision = "0001"
|
|
9
|
+
down_revision = None
|
|
10
|
+
branch_labels = None
|
|
11
|
+
depends_on = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def upgrade() -> None:
|
|
15
|
+
args: dict[str, Any] = context.get_x_argument(as_dictionary=True)
|
|
16
|
+
dimensions = int(args.get("dimensions", 1536))
|
|
17
|
+
runtime_role = args.get("runtime_role", "mem0_runtime")
|
|
18
|
+
if not 1 <= dimensions <= 16000:
|
|
19
|
+
raise ValueError("dimensions must be between 1 and 16000")
|
|
20
|
+
quote = op.get_bind().dialect.identifier_preparer.quote_identifier
|
|
21
|
+
role = quote(runtime_role)
|
|
22
|
+
# The DBA must install the vector extension before migrations.
|
|
23
|
+
op.execute("CREATE SCHEMA mem0_store")
|
|
24
|
+
op.execute("REVOKE ALL ON SCHEMA mem0_store FROM PUBLIC")
|
|
25
|
+
op.execute(f"GRANT USAGE ON SCHEMA mem0_store TO {role}")
|
|
26
|
+
op.execute("""
|
|
27
|
+
CREATE TABLE mem0_store.history (
|
|
28
|
+
sequence BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
29
|
+
tenant_id TEXT NOT NULL CHECK (tenant_id <> ''),
|
|
30
|
+
id TEXT NOT NULL,
|
|
31
|
+
memory_id TEXT, old_memory TEXT, new_memory TEXT, event TEXT,
|
|
32
|
+
created_at TEXT, updated_at TEXT, updated_order TIMESTAMPTZ,
|
|
33
|
+
is_deleted INTEGER, actor_id TEXT, role TEXT,
|
|
34
|
+
UNIQUE (tenant_id, id)
|
|
35
|
+
)
|
|
36
|
+
""")
|
|
37
|
+
op.execute("""
|
|
38
|
+
CREATE INDEX history_tenant_memory_order ON mem0_store.history
|
|
39
|
+
(tenant_id, memory_id, created_at COLLATE "C", updated_order, sequence)
|
|
40
|
+
""")
|
|
41
|
+
op.execute("""
|
|
42
|
+
CREATE TABLE mem0_store.messages (
|
|
43
|
+
sequence BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
44
|
+
tenant_id TEXT NOT NULL CHECK (tenant_id <> ''),
|
|
45
|
+
id TEXT NOT NULL, session_scope TEXT, role TEXT, content TEXT, name TEXT,
|
|
46
|
+
created_at TEXT, UNIQUE (tenant_id, id)
|
|
47
|
+
)
|
|
48
|
+
""")
|
|
49
|
+
op.execute("""
|
|
50
|
+
CREATE INDEX messages_tenant_scope_order ON mem0_store.messages
|
|
51
|
+
(tenant_id, session_scope, created_at DESC, sequence DESC)
|
|
52
|
+
""")
|
|
53
|
+
for table in ("mem0_vectors", "mem0_vectors_entities"):
|
|
54
|
+
op.execute(f"""
|
|
55
|
+
CREATE TABLE mem0_store.{table} (
|
|
56
|
+
tenant_id TEXT NOT NULL
|
|
57
|
+
DEFAULT nullif(current_setting('app.tenant_id', true), '')
|
|
58
|
+
CHECK (tenant_id <> ''),
|
|
59
|
+
id UUID NOT NULL,
|
|
60
|
+
vector public.vector({dimensions}),
|
|
61
|
+
payload JSONB,
|
|
62
|
+
PRIMARY KEY (tenant_id, id)
|
|
63
|
+
)
|
|
64
|
+
""")
|
|
65
|
+
op.execute(f"""
|
|
66
|
+
CREATE INDEX {table}_text_idx ON mem0_store.{table}
|
|
67
|
+
USING gin(to_tsvector('simple', payload->>'text_lemmatized'))
|
|
68
|
+
""")
|
|
69
|
+
for table in ("history", "messages", "mem0_vectors", "mem0_vectors_entities"):
|
|
70
|
+
qualified = f"mem0_store.{table}"
|
|
71
|
+
op.execute(f"ALTER TABLE {qualified} ENABLE ROW LEVEL SECURITY")
|
|
72
|
+
op.execute(f"ALTER TABLE {qualified} FORCE ROW LEVEL SECURITY")
|
|
73
|
+
# A restrictive guard prevents a later permissive policy widening tenancy.
|
|
74
|
+
for name, mode in (("tenant_access", "PERMISSIVE"), ("tenant_guard", "RESTRICTIVE")):
|
|
75
|
+
op.execute(f"""
|
|
76
|
+
CREATE POLICY {name} ON {qualified} AS {mode} FOR ALL TO PUBLIC
|
|
77
|
+
USING (tenant_id = nullif(current_setting('app.tenant_id', true), ''))
|
|
78
|
+
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), ''))
|
|
79
|
+
""")
|
|
80
|
+
op.execute(f"REVOKE ALL ON {qualified} FROM PUBLIC")
|
|
81
|
+
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON {qualified} TO {role}")
|
|
82
|
+
op.execute(f"GRANT USAGE ON ALL SEQUENCES IN SCHEMA mem0_store TO {role}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def downgrade() -> None:
|
|
86
|
+
for table in ("mem0_vectors_entities", "mem0_vectors", "messages", "history"):
|
|
87
|
+
op.drop_table(table, schema="mem0_store")
|
|
88
|
+
op.execute(sa.text("DROP SCHEMA mem0_store"))
|