langgraph-store-postgres 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ uv.lock
9
+ .env
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.5
2
+ Name: langgraph-store-postgres
3
+ Version: 0.1.0
4
+ Summary: PostgreSQL long-term-memory store (BaseStore) for LangGraph — namespaced key/value memory with filters, list_namespaces and native semantic (pgvector) search
5
+ Project-URL: Homepage, https://github.com/skamalj/langgraph-store
6
+ Project-URL: Repository, https://github.com/skamalj/langgraph-store.git
7
+ Author-email: Kamal <skamalj@gmail.com>
8
+ Keywords: agent-memory,basestore,langgraph,long-term-memory,postgres,postgresql,store
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: langgraph-store-core>=0.1.0
15
+ Requires-Dist: pgvector>=0.2
16
+ Requires-Dist: psycopg2-binary
17
+ Requires-Dist: sqlalchemy>=2.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # langgraph-store-postgres
21
+
22
+ A **PostgreSQL** long-term-memory store (`BaseStore`) for [LangGraph](https://langchain-ai.github.io/langgraph/) — namespaced key/value memory with prefix search, filters, `list_namespaces`, and **native semantic search via pgvector**.
23
+
24
+ ```bash
25
+ pip install langgraph-store-postgres
26
+ ```
27
+
28
+ ```python
29
+ from langgraph_store_postgres import PostgresStore
30
+
31
+ store = PostgresStore("postgresql://user:pass@localhost:5432/db") # or engine=<Engine>
32
+
33
+ store.put(("users", "1", "memories"), "food", {"text": "loves sushi", "kind": "pref"})
34
+ item = store.get(("users", "1", "memories"), "food")
35
+ hits = store.search(("users", "1"), filter={"kind": "pref"}, limit=10)
36
+ spaces = store.list_namespaces(prefix=("users",))
37
+ ```
38
+
39
+ Use it as a LangGraph store: `graph.compile(store=PostgresStore(...))`. Async methods (`aget`/`aput`/`asearch`/…) work too — the sync calls run in a thread.
40
+
41
+ ## Semantic search (pgvector)
42
+
43
+ Pass a LangGraph `IndexConfig` and the store embeds the configured fields on `put` and ranks `search(query=...)` by cosine similarity using pgvector (`<=>`, HNSW index):
44
+
45
+ ```python
46
+ from langgraph_store_core import bedrock_titan_embeddings
47
+ from langgraph_store_postgres import PostgresStore
48
+
49
+ store = PostgresStore(url, table_name="memory",
50
+ index={"dims": 1024, "embed": bedrock_titan_embeddings(dimensions=1024), "fields": ["text"]})
51
+ store.put(("memories", "kamal"), "k1", {"text": "the user loves sushi", "kind": "pref"})
52
+ hits = store.search(("memories", "kamal"), query="what food does the user like?", filter={"kind": "pref"})
53
+ print(hits[0].score, hits[0].value)
54
+ ```
55
+
56
+ `embed` may be any LangChain `Embeddings`, a `list[str] -> list[list[float]]` callable, or a provider string. `fields` defaults to `["$"]` (whole value as JSON). `put(..., index=False)` skips embedding for one item.
57
+
58
+ Requires the [pgvector](https://github.com/pgvector/pgvector) extension on the server (`apt install postgresql-16-pgvector`, or built in on RDS / Cloud SQL / Azure Database). The store runs `CREATE EXTENSION IF NOT EXISTS vector`, adds an `embedding vector(dims)` column and an HNSW cosine index. Namespace prefix and plain-equality filters run inside the SQL query (JSONB containment); operator filters (`$gt`, `$in`, …) are applied on the returned candidates.
59
+
60
+ ## Data model
61
+
62
+ A single table `(prefix, key, value JSONB, created_at, updated_at[, embedding vector])` with primary key `(prefix, key)`, created automatically. Namespace tuples are joined with a unit separator into `prefix`; search is a prefix scan, filters and namespace matching are evaluated in the [core](https://pypi.org/project/langgraph-store-core/).
63
+
64
+ Docs: <https://skamalj.github.io/agentstate-reducer/>
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,49 @@
1
+ # langgraph-store-postgres
2
+
3
+ A **PostgreSQL** long-term-memory store (`BaseStore`) for [LangGraph](https://langchain-ai.github.io/langgraph/) — namespaced key/value memory with prefix search, filters, `list_namespaces`, and **native semantic search via pgvector**.
4
+
5
+ ```bash
6
+ pip install langgraph-store-postgres
7
+ ```
8
+
9
+ ```python
10
+ from langgraph_store_postgres import PostgresStore
11
+
12
+ store = PostgresStore("postgresql://user:pass@localhost:5432/db") # or engine=<Engine>
13
+
14
+ store.put(("users", "1", "memories"), "food", {"text": "loves sushi", "kind": "pref"})
15
+ item = store.get(("users", "1", "memories"), "food")
16
+ hits = store.search(("users", "1"), filter={"kind": "pref"}, limit=10)
17
+ spaces = store.list_namespaces(prefix=("users",))
18
+ ```
19
+
20
+ Use it as a LangGraph store: `graph.compile(store=PostgresStore(...))`. Async methods (`aget`/`aput`/`asearch`/…) work too — the sync calls run in a thread.
21
+
22
+ ## Semantic search (pgvector)
23
+
24
+ Pass a LangGraph `IndexConfig` and the store embeds the configured fields on `put` and ranks `search(query=...)` by cosine similarity using pgvector (`<=>`, HNSW index):
25
+
26
+ ```python
27
+ from langgraph_store_core import bedrock_titan_embeddings
28
+ from langgraph_store_postgres import PostgresStore
29
+
30
+ store = PostgresStore(url, table_name="memory",
31
+ index={"dims": 1024, "embed": bedrock_titan_embeddings(dimensions=1024), "fields": ["text"]})
32
+ store.put(("memories", "kamal"), "k1", {"text": "the user loves sushi", "kind": "pref"})
33
+ hits = store.search(("memories", "kamal"), query="what food does the user like?", filter={"kind": "pref"})
34
+ print(hits[0].score, hits[0].value)
35
+ ```
36
+
37
+ `embed` may be any LangChain `Embeddings`, a `list[str] -> list[list[float]]` callable, or a provider string. `fields` defaults to `["$"]` (whole value as JSON). `put(..., index=False)` skips embedding for one item.
38
+
39
+ Requires the [pgvector](https://github.com/pgvector/pgvector) extension on the server (`apt install postgresql-16-pgvector`, or built in on RDS / Cloud SQL / Azure Database). The store runs `CREATE EXTENSION IF NOT EXISTS vector`, adds an `embedding vector(dims)` column and an HNSW cosine index. Namespace prefix and plain-equality filters run inside the SQL query (JSONB containment); operator filters (`$gt`, `$in`, …) are applied on the returned candidates.
40
+
41
+ ## Data model
42
+
43
+ A single table `(prefix, key, value JSONB, created_at, updated_at[, embedding vector])` with primary key `(prefix, key)`, created automatically. Namespace tuples are joined with a unit separator into `prefix`; search is a prefix scan, filters and namespace matching are evaluated in the [core](https://pypi.org/project/langgraph-store-core/).
44
+
45
+ Docs: <https://skamalj.github.io/agentstate-reducer/>
46
+
47
+ ## License
48
+
49
+ MIT
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langgraph-store-postgres"
7
+ version = "0.1.0"
8
+ description = "PostgreSQL long-term-memory store (BaseStore) for LangGraph — namespaced key/value memory with filters, list_namespaces and native semantic (pgvector) search"
9
+ authors = [{name = "Kamal", email = "skamalj@gmail.com"}]
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "langgraph-store-core>=0.1.0",
14
+ "sqlalchemy>=2.0",
15
+ "psycopg2-binary",
16
+ "pgvector>=0.2",
17
+ ]
18
+ keywords = ["langgraph", "store", "basestore", "postgres", "postgresql", "long-term-memory", "agent-memory"]
19
+ classifiers = [
20
+ "Programming Language :: Python :: 3",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/skamalj/langgraph-store"
28
+ Repository = "https://github.com/skamalj/langgraph-store.git"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=7.0", "pytest-asyncio"]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/langgraph_store_postgres"]
@@ -0,0 +1,164 @@
1
+ """PostgreSQL implementation of the LangGraph long-term-memory ``BaseStore``.
2
+
3
+ ``PostgresStore`` persists namespaced key/value memory in a single table and
4
+ supports get / put / delete / search (namespace-prefix + filter) and
5
+ ``list_namespaces``. With an ``IndexConfig`` it also does **semantic search**
6
+ natively via `pgvector <https://github.com/pgvector/pgvector>`_ (cosine
7
+ distance, HNSW index). Built on ``langgraph-store-core``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import List, Optional, Tuple
13
+
14
+ from sqlalchemy import (
15
+ Column,
16
+ Index,
17
+ MetaData,
18
+ String,
19
+ Table,
20
+ create_engine,
21
+ delete as sa_delete,
22
+ select,
23
+ text,
24
+ )
25
+ from sqlalchemy.dialects.postgresql import JSONB, insert as pg_insert
26
+ from sqlalchemy.engine import Engine
27
+
28
+ from langgraph_store_core import IndexConfig, KVStore
29
+
30
+ __all__ = ["PostgresStore"]
31
+
32
+
33
+ def _escape_like(value: str) -> str:
34
+ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
35
+
36
+
37
+ class PostgresStore(KVStore):
38
+ """LangGraph ``BaseStore`` backed by PostgreSQL.
39
+
40
+ Example:
41
+ ```python
42
+ from langgraph_store_postgres import PostgresStore
43
+
44
+ store = PostgresStore("postgresql://user:pass@localhost:5432/db")
45
+ store.put(("users", "1"), "profile", {"name": "Kamal"})
46
+ item = store.get(("users", "1"), "profile")
47
+
48
+ # semantic search (needs the pgvector extension on the server)
49
+ store = PostgresStore(url, index={"dims": 1024, "embed": my_embedder, "fields": ["text"]})
50
+ hits = store.search(("users", "1"), query="what does the user like?")
51
+ ```
52
+
53
+ When ``index`` is given the table gains an ``embedding vector(dims)`` column
54
+ and an HNSW cosine index; ``CREATE EXTENSION IF NOT EXISTS vector`` is run
55
+ (requires the extension to be installed on the server).
56
+ """
57
+
58
+ supports_native_vector_search = True
59
+
60
+ def __init__(
61
+ self,
62
+ url: Optional[str] = None,
63
+ *,
64
+ table_name: str = "langgraph_store",
65
+ engine: Optional[Engine] = None,
66
+ index: Optional[IndexConfig] = None,
67
+ ) -> None:
68
+ super().__init__(index=index)
69
+ if engine is None and url is None:
70
+ raise ValueError("Provide either 'url' or 'engine'")
71
+ self._engine = engine or create_engine(url) # type: ignore[arg-type]
72
+ self._metadata = MetaData()
73
+ columns = [
74
+ Column("prefix", String, primary_key=True),
75
+ Column("key", String, primary_key=True),
76
+ Column("value", JSONB, nullable=False),
77
+ Column("created_at", String, nullable=False),
78
+ Column("updated_at", String, nullable=False),
79
+ ]
80
+ if self.dims:
81
+ from pgvector.sqlalchemy import Vector # optional dependency
82
+
83
+ columns.append(Column("embedding", Vector(self.dims), nullable=True))
84
+ with self._engine.begin() as conn:
85
+ conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
86
+ self._table = Table(table_name, self._metadata, *columns)
87
+ if self.dims:
88
+ Index(
89
+ f"{table_name}_embedding_hnsw",
90
+ self._table.c.embedding,
91
+ postgresql_using="hnsw",
92
+ postgresql_with={"m": 16, "ef_construction": 64},
93
+ postgresql_ops={"embedding": "vector_cosine_ops"},
94
+ )
95
+ self._metadata.create_all(self._engine)
96
+
97
+ def _row(self, r) -> dict:
98
+ emb = getattr(r, "embedding", None) if self.dims else None
99
+ return {
100
+ "prefix": r.prefix, "key": r.key, "value": r.value,
101
+ "created_at": r.created_at, "updated_at": r.updated_at,
102
+ "embedding": list(emb) if emb is not None else None,
103
+ }
104
+
105
+ def _read(self, prefix: str, key: str) -> Optional[dict]:
106
+ t = self._table
107
+ with self._engine.connect() as conn:
108
+ r = conn.execute(
109
+ select(t).where(t.c.prefix == prefix, t.c.key == key)
110
+ ).first()
111
+ return self._row(r) if r else None
112
+
113
+ def _write(self, row: dict) -> None:
114
+ t = self._table
115
+ values = {k: row[k] for k in ("prefix", "key", "value", "created_at", "updated_at")}
116
+ if self.dims:
117
+ values["embedding"] = row.get("embedding")
118
+ stmt = pg_insert(t).values(**values).on_conflict_do_update(
119
+ index_elements=[t.c.prefix, t.c.key],
120
+ set_={k: v for k, v in values.items() if k not in ("prefix", "key")},
121
+ )
122
+ with self._engine.begin() as conn:
123
+ conn.execute(stmt)
124
+
125
+ def _remove(self, prefix: str, key: str) -> None:
126
+ t = self._table
127
+ with self._engine.begin() as conn:
128
+ conn.execute(sa_delete(t).where(t.c.prefix == prefix, t.c.key == key))
129
+
130
+ def _prefix_clause(self, prefix: str):
131
+ t = self._table
132
+ if prefix == "":
133
+ return None
134
+ return t.c.prefix.like(f"{_escape_like(prefix)}%", escape="\\")
135
+
136
+ def _scan(self, prefix: str) -> List[dict]:
137
+ t = self._table
138
+ stmt = select(t)
139
+ clause = self._prefix_clause(prefix)
140
+ if clause is not None:
141
+ stmt = stmt.where(clause)
142
+ with self._engine.connect() as conn:
143
+ rows = conn.execute(stmt).all()
144
+ return [self._row(r) for r in rows]
145
+
146
+ def _vector_search(
147
+ self, prefix: str, vector: List[float], filter: Optional[dict], limit: int
148
+ ) -> List[Tuple[dict, float]]:
149
+ """Native pgvector ANN: cosine distance ordered, prefix + equality filters in SQL."""
150
+ t = self._table
151
+ distance = t.c.embedding.cosine_distance(vector).label("distance")
152
+ stmt = select(t, distance).where(t.c.embedding.isnot(None))
153
+ clause = self._prefix_clause(prefix)
154
+ if clause is not None:
155
+ stmt = stmt.where(clause)
156
+ # Push plain-equality filters into SQL as JSONB containment (type-safe);
157
+ # operator filters ($gt, $in, ...) are applied by core on the returned rows.
158
+ equality = {f: v for f, v in (filter or {}).items() if not isinstance(v, dict)}
159
+ if equality:
160
+ stmt = stmt.where(t.c.value.contains(equality))
161
+ stmt = stmt.order_by(distance).limit(limit)
162
+ with self._engine.connect() as conn:
163
+ rows = conn.execute(stmt).all()
164
+ return [(self._row(r), 1.0 - float(r.distance)) for r in rows]
@@ -0,0 +1,103 @@
1
+ """Semantic-search tests for PostgresStore (native pgvector path) against a real PostgreSQL with pgvector."""
2
+ import os
3
+ import uuid
4
+
5
+ import pytest
6
+ from sqlalchemy import create_engine, text
7
+
8
+ from langgraph_store_core.testing import FakeEmbeddings
9
+ from langgraph_store_postgres import PostgresStore
10
+
11
+ URL = os.environ.get("SQL_TEST_URL", "postgresql+psycopg2://postgres:postgres@localhost:5433/postgres")
12
+ DIMS = 64
13
+
14
+
15
+ def _pgvector_available() -> bool:
16
+ try:
17
+ with create_engine(URL).begin() as c:
18
+ c.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
19
+ return True
20
+ except Exception:
21
+ return False
22
+
23
+
24
+ pytestmark = pytest.mark.skipif(not _pgvector_available(), reason=f"PostgreSQL+pgvector not reachable: {URL}")
25
+
26
+
27
+ @pytest.fixture()
28
+ def store():
29
+ tname = f"lg_vstore_test_{uuid.uuid4().hex[:8]}"
30
+ s = PostgresStore(url=URL, table_name=tname, index={"dims": DIMS, "embed": FakeEmbeddings(DIMS), "fields": ["text"]})
31
+ yield s
32
+ with s._engine.begin() as c:
33
+ c.execute(text(f'DROP TABLE IF EXISTS "{tname}"'))
34
+ s._engine.dispose()
35
+
36
+
37
+ def _seed(s):
38
+ s.put(("memories", "kamal"), "food", {"text": "the user loves sushi and japanese food", "kind": "pref"})
39
+ s.put(("memories", "kamal"), "lang", {"text": "the user writes python every day", "kind": "fact"})
40
+ s.put(("memories", "kamal"), "city", {"text": "the user lives by the sea in a big city", "kind": "fact"})
41
+ s.put(("memories", "priya"), "food", {"text": "loves sushi", "kind": "pref"})
42
+
43
+
44
+ def test_table_has_vector_column_and_index(store):
45
+ with store._engine.connect() as c:
46
+ cols = {r[0]: r[1] for r in c.execute(text(
47
+ f"select column_name, udt_name from information_schema.columns where table_name='{store._table.name}'"))}
48
+ assert cols.get("embedding") == "vector"
49
+ idx = c.execute(text(f"select indexdef from pg_indexes where tablename='{store._table.name}'")).all()
50
+ assert any("hnsw" in r[0] for r in idx)
51
+
52
+
53
+ def test_put_embeds_and_index_false_skips(store):
54
+ store.put(("n",), "k", {"text": "hello world"})
55
+ assert len(store._read("n", "k")["embedding"]) == DIMS
56
+ store.put(("n",), "k2", {"text": "hello"}, index=False)
57
+ assert store._read("n", "k2")["embedding"] is None
58
+
59
+
60
+ def test_query_ranks_and_scores(store):
61
+ _seed(store)
62
+ hits = store.search(("memories", "kamal"), query="sushi and japanese food")
63
+ assert hits[0].key == "food" and hits[0].score is not None
64
+ assert hits[0].score >= hits[-1].score
65
+ assert all(h.namespace == ("memories", "kamal") for h in hits)
66
+
67
+
68
+ def test_query_prefix_children_and_filter(store):
69
+ _seed(store)
70
+ keys = {(h.namespace, h.key) for h in store.search(("memories",), query="sushi")}
71
+ assert (("memories", "kamal"), "food") in keys and (("memories", "priya"), "food") in keys
72
+ facts = store.search(("memories", "kamal"), query="user", filter={"kind": "fact"})
73
+ assert facts and all(h.value["kind"] == "fact" for h in facts)
74
+ gt = store.search(("memories", "kamal"), query="user", filter={"kind": {"$in": ["pref"]}})
75
+ assert [h.key for h in gt] == ["food"]
76
+
77
+
78
+ def test_query_limit_offset(store):
79
+ _seed(store)
80
+ first = store.search(("memories", "kamal"), query="user", limit=2)
81
+ rest = store.search(("memories", "kamal"), query="user", limit=2, offset=2)
82
+ assert len(first) == 2 and len(rest) == 1 and not {h.key for h in first} & {h.key for h in rest}
83
+
84
+
85
+ def test_no_query_is_filter_only(store):
86
+ _seed(store)
87
+ hits = store.search(("memories", "kamal"))
88
+ assert [h.key for h in hits] == ["city", "food", "lang"] and all(h.score is None for h in hits)
89
+
90
+
91
+ def test_native_path_used(store, monkeypatch):
92
+ _seed(store)
93
+ called = []
94
+ orig = store._vector_search
95
+ monkeypatch.setattr(store, "_vector_search", lambda *a, **k: (called.append(1), orig(*a, **k))[1])
96
+ store.search(("memories",), query="sushi")
97
+ assert called
98
+
99
+
100
+ async def test_async_query(store):
101
+ _seed(store)
102
+ hits = await store.asearch(("memories", "kamal"), query="sushi")
103
+ assert hits[0].key == "food"
@@ -0,0 +1,119 @@
1
+ """Tests for PostgresStore (LangGraph BaseStore) against real local PostgreSQL."""
2
+ import os
3
+ import uuid
4
+
5
+ import pytest
6
+ from sqlalchemy import create_engine, text
7
+
8
+ from langgraph_store_postgres import PostgresStore
9
+
10
+ URL = os.environ.get(
11
+ "SQL_TEST_URL", "postgresql+psycopg2://postgres:postgres@localhost:5432/postgres"
12
+ )
13
+
14
+
15
+ def _db_available() -> bool:
16
+ try:
17
+ with create_engine(URL).connect() as c:
18
+ c.execute(text("select 1"))
19
+ return True
20
+ except Exception:
21
+ return False
22
+
23
+
24
+ pytestmark = pytest.mark.skipif(not _db_available(), reason=f"PostgreSQL not reachable: {URL}")
25
+
26
+
27
+ @pytest.fixture()
28
+ def store():
29
+ tname = f"lg_store_test_{uuid.uuid4().hex[:8]}"
30
+ s = PostgresStore(url=URL, table_name=tname)
31
+ yield s
32
+ with s._engine.begin() as c:
33
+ c.execute(text(f'DROP TABLE IF EXISTS "{tname}"'))
34
+ s._engine.dispose()
35
+
36
+
37
+ def test_put_get(store):
38
+ store.put(("users", "1"), "food", {"text": "sushi"})
39
+ it = store.get(("users", "1"), "food")
40
+ assert it is not None
41
+ assert it.value == {"text": "sushi"}
42
+ assert it.namespace == ("users", "1")
43
+ assert it.key == "food"
44
+
45
+
46
+ def test_get_missing_is_none(store):
47
+ assert store.get(("nope",), "x") is None
48
+
49
+
50
+ def test_update_preserves_created_at(store):
51
+ store.put(("u",), "k", {"v": 1})
52
+ a = store.get(("u",), "k")
53
+ store.put(("u",), "k", {"v": 2})
54
+ b = store.get(("u",), "k")
55
+ assert b.value == {"v": 2}
56
+ assert b.created_at == a.created_at # created_at preserved across updates
57
+ assert b.updated_at >= a.updated_at
58
+
59
+
60
+ def test_delete_both_ways(store):
61
+ store.put(("u",), "k", {"v": 1})
62
+ store.delete(("u",), "k")
63
+ assert store.get(("u",), "k") is None
64
+ store.put(("u",), "k2", {"v": 1})
65
+ store.put(("u",), "k2", None) # put None == delete
66
+ assert store.get(("u",), "k2") is None
67
+
68
+
69
+ def test_search_prefix_and_filter(store):
70
+ store.put(("users", "1", "mem"), "a", {"kind": "pref"})
71
+ store.put(("users", "1", "mem"), "b", {"kind": "fact"})
72
+ store.put(("users", "2", "mem"), "c", {"kind": "pref"})
73
+ assert sorted(i.key for i in store.search(("users", "1"))) == ["a", "b"]
74
+ assert [i.key for i in store.search(("users", "1"), filter={"kind": "pref"})] == ["a"]
75
+ assert store.search(("users", "1"))[0].namespace == ("users", "1", "mem")
76
+
77
+
78
+ def test_search_operator_filter(store):
79
+ store.put(("n",), "a", {"score": 5})
80
+ store.put(("n",), "b", {"score": 15})
81
+ hits = store.search(("n",), filter={"score": {"$gt": 10}})
82
+ assert [i.key for i in hits] == ["b"]
83
+
84
+
85
+ def test_search_limit_offset(store):
86
+ for i in range(5):
87
+ store.put(("n",), f"k{i}", {"i": i})
88
+ assert [i.key for i in store.search(("n",), limit=2)] == ["k0", "k1"]
89
+ assert [i.key for i in store.search(("n",), limit=2, offset=2)] == ["k2", "k3"]
90
+
91
+
92
+ def test_list_namespaces(store):
93
+ store.put(("users", "1", "mem"), "a", {})
94
+ store.put(("users", "2", "mem"), "b", {})
95
+ store.put(("orgs", "x"), "c", {})
96
+ allns = store.list_namespaces()
97
+ assert ("users", "1", "mem") in allns and ("orgs", "x") in allns
98
+ assert all(n[0] == "users" for n in store.list_namespaces(prefix=("users",)))
99
+ assert all(n[-1] == "mem" for n in store.list_namespaces(suffix=("mem",)))
100
+ depth1 = store.list_namespaces(max_depth=1)
101
+ assert ("users",) in depth1 and ("orgs",) in depth1
102
+
103
+
104
+ def test_list_namespaces_wildcard(store):
105
+ store.put(("a", "1", "x"), "k", {})
106
+ store.put(("a", "2", "x"), "k", {})
107
+ store.put(("a", "1", "y"), "k", {})
108
+ hits = store.list_namespaces(prefix=("a", "*", "x"))
109
+ assert set(hits) == {("a", "1", "x"), ("a", "2", "x")}
110
+
111
+
112
+ async def test_async_surface(store):
113
+ await store.aput(("u",), "k", {"v": 1})
114
+ it = await store.aget(("u",), "k")
115
+ assert it.value == {"v": 1}
116
+ res = await store.asearch(("u",))
117
+ assert [i.key for i in res] == ["k"]
118
+ await store.adelete(("u",), "k")
119
+ assert await store.aget(("u",), "k") is None