citadeldb-langgraph 1.15.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.
- citadeldb_langgraph-1.15.0/.gitignore +16 -0
- citadeldb_langgraph-1.15.0/PKG-INFO +105 -0
- citadeldb_langgraph-1.15.0/README.md +84 -0
- citadeldb_langgraph-1.15.0/pyproject.toml +36 -0
- citadeldb_langgraph-1.15.0/src/citadeldb_langgraph/__init__.py +12 -0
- citadeldb_langgraph-1.15.0/src/citadeldb_langgraph/store.py +330 -0
- citadeldb_langgraph-1.15.0/tests/test_store.py +170 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: citadeldb-langgraph
|
|
3
|
+
Version: 1.15.0
|
|
4
|
+
Summary: LangGraph store backed by Citadel: encrypted at rest, semantic search, cryptographic deletes
|
|
5
|
+
Project-URL: Homepage, https://citadeldb.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/yp3y5akh0v/citadel
|
|
7
|
+
Author: Yuriy Peysakhov
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: agent,encryption,langchain,langgraph,memory,store
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Database
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: citadeldb>=1.14
|
|
17
|
+
Requires-Dist: langgraph>=0.2
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# citadeldb-langgraph
|
|
23
|
+
|
|
24
|
+
A [LangGraph](https://github.com/langchain-ai/langgraph) `BaseStore` backed by
|
|
25
|
+
[Citadel](https://citadeldb.dev). Encrypted at rest, embedded in your process, and deletes
|
|
26
|
+
that destroy the key rather than the row.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pip install citadeldb-langgraph
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from citadeldb_langgraph import CitadelStore
|
|
34
|
+
|
|
35
|
+
store = CitadelStore("memory.cdl", key="your-passphrase")
|
|
36
|
+
|
|
37
|
+
store.put(("users", "alice"), "profile", {"city": "Berlin", "pet": "Mochi"})
|
|
38
|
+
print(store.get(("users", "alice"), "profile").value)
|
|
39
|
+
# {'city': 'Berlin', 'pet': 'Mochi'}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Pass it to a graph the same way as any other store:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
graph = builder.compile(store=store)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Search is semantic
|
|
49
|
+
|
|
50
|
+
`search` runs Citadel's hybrid recall (vector + keyword + recency), not a `LIKE`. The query
|
|
51
|
+
below shares no words with the document it finds:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
store.put(("notes",), "n1", {"text": "the deployment failed because the disk was full"})
|
|
55
|
+
store.put(("notes",), "n2", {"text": "lunch plans for friday"})
|
|
56
|
+
|
|
57
|
+
store.search(("notes",), query="why did the release break?", limit=1)
|
|
58
|
+
# [SearchItem(value={'text': 'the deployment failed because the disk was full'}, ...)]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Deletes destroy the key
|
|
62
|
+
|
|
63
|
+
Every value is sealed under its own key. Deleting destroys that key, so the bytes on disk
|
|
64
|
+
stay unreadable instead of being marked deleted and living on in backups.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
store.delete(("users", "alice"), "profile")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`forget_namespace` does the same for a whole subtree, which is what a data-deletion request
|
|
71
|
+
usually needs:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
store.forget_namespace(("users", "alice")) # returns the number of values erased
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## TTL
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
store.put(("session",), "token", {"v": 1}, ttl=60.0) # minutes
|
|
81
|
+
store.get(("session",), "token", refresh_ttl=True) # extends the lifetime
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A refresh preserves both `created_at` and `updated_at`, so reading never looks like a write.
|
|
85
|
+
|
|
86
|
+
## Notes
|
|
87
|
+
|
|
88
|
+
Citadel is embedded and takes an exclusive lock on the file, so build **one** store per
|
|
89
|
+
database and share it. Separate concerns with namespaces rather than with a second store.
|
|
90
|
+
|
|
91
|
+
`MockEmbedder` is the default and needs no download, which is enough to build and test a
|
|
92
|
+
graph. For production recall quality pass a real embedder:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import citadeldb
|
|
96
|
+
store = CitadelStore(
|
|
97
|
+
"memory.cdl",
|
|
98
|
+
key="your-passphrase",
|
|
99
|
+
embedder=citadeldb.CandleEmbedder("/path/to/e5-large", preset="e5_large"),
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
Apache-2.0
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# citadeldb-langgraph
|
|
2
|
+
|
|
3
|
+
A [LangGraph](https://github.com/langchain-ai/langgraph) `BaseStore` backed by
|
|
4
|
+
[Citadel](https://citadeldb.dev). Encrypted at rest, embedded in your process, and deletes
|
|
5
|
+
that destroy the key rather than the row.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install citadeldb-langgraph
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from citadeldb_langgraph import CitadelStore
|
|
13
|
+
|
|
14
|
+
store = CitadelStore("memory.cdl", key="your-passphrase")
|
|
15
|
+
|
|
16
|
+
store.put(("users", "alice"), "profile", {"city": "Berlin", "pet": "Mochi"})
|
|
17
|
+
print(store.get(("users", "alice"), "profile").value)
|
|
18
|
+
# {'city': 'Berlin', 'pet': 'Mochi'}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Pass it to a graph the same way as any other store:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
graph = builder.compile(store=store)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Search is semantic
|
|
28
|
+
|
|
29
|
+
`search` runs Citadel's hybrid recall (vector + keyword + recency), not a `LIKE`. The query
|
|
30
|
+
below shares no words with the document it finds:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
store.put(("notes",), "n1", {"text": "the deployment failed because the disk was full"})
|
|
34
|
+
store.put(("notes",), "n2", {"text": "lunch plans for friday"})
|
|
35
|
+
|
|
36
|
+
store.search(("notes",), query="why did the release break?", limit=1)
|
|
37
|
+
# [SearchItem(value={'text': 'the deployment failed because the disk was full'}, ...)]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Deletes destroy the key
|
|
41
|
+
|
|
42
|
+
Every value is sealed under its own key. Deleting destroys that key, so the bytes on disk
|
|
43
|
+
stay unreadable instead of being marked deleted and living on in backups.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
store.delete(("users", "alice"), "profile")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`forget_namespace` does the same for a whole subtree, which is what a data-deletion request
|
|
50
|
+
usually needs:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
store.forget_namespace(("users", "alice")) # returns the number of values erased
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## TTL
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
store.put(("session",), "token", {"v": 1}, ttl=60.0) # minutes
|
|
60
|
+
store.get(("session",), "token", refresh_ttl=True) # extends the lifetime
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A refresh preserves both `created_at` and `updated_at`, so reading never looks like a write.
|
|
64
|
+
|
|
65
|
+
## Notes
|
|
66
|
+
|
|
67
|
+
Citadel is embedded and takes an exclusive lock on the file, so build **one** store per
|
|
68
|
+
database and share it. Separate concerns with namespaces rather than with a second store.
|
|
69
|
+
|
|
70
|
+
`MockEmbedder` is the default and needs no download, which is enough to build and test a
|
|
71
|
+
graph. For production recall quality pass a real embedder:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import citadeldb
|
|
75
|
+
store = CitadelStore(
|
|
76
|
+
"memory.cdl",
|
|
77
|
+
key="your-passphrase",
|
|
78
|
+
embedder=citadeldb.CandleEmbedder("/path/to/e5-large", preset="e5_large"),
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
Apache-2.0
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "citadeldb-langgraph"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "LangGraph store backed by Citadel: encrypted at rest, semantic search, cryptographic deletes"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "Yuriy Peysakhov" }]
|
|
13
|
+
keywords = ["langgraph", "langchain", "memory", "agent", "store", "encryption"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Database",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["citadeldb>=1.14", "langgraph>=0.2"]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
test = ["pytest>=8"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://citadeldb.dev"
|
|
28
|
+
Repository = "https://github.com/yp3y5akh0v/citadel"
|
|
29
|
+
|
|
30
|
+
# The version comes from the release tag, so it tracks the workspace with nothing to bump.
|
|
31
|
+
[tool.hatch.version]
|
|
32
|
+
source = "vcs"
|
|
33
|
+
raw-options = { root = "../..", tag_regex = '^v(?P<version>\d+\.\d+\.\d+)$' }
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/citadeldb_langgraph"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""A LangGraph store backed by Citadel: encrypted at rest, semantic search, real deletes."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .store import CitadelStore
|
|
6
|
+
|
|
7
|
+
__all__ = ["CitadelStore", "__version__"]
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("citadeldb-langgraph")
|
|
11
|
+
except PackageNotFoundError: # running from a source tree, never installed
|
|
12
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""CitadelStore: LangGraph's BaseStore over an encrypted Citadel region.
|
|
2
|
+
|
|
3
|
+
`batch` and `abatch` are the only abstract methods; put/get/delete/search/list_namespaces are
|
|
4
|
+
concrete helpers that build Ops and dispatch through them. A PutOp carrying `value=None` is
|
|
5
|
+
LangGraph's delete.
|
|
6
|
+
|
|
7
|
+
Two operations behave differently here than in any other store:
|
|
8
|
+
SearchOp with a query hybrid vector + keyword recall rather than a SQL LIKE.
|
|
9
|
+
PutOp with value=None the atom's key is destroyed, leaving unreadable ciphertext instead
|
|
10
|
+
of a removed row.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, Iterable
|
|
19
|
+
|
|
20
|
+
import citadeldb
|
|
21
|
+
from langgraph.store.base import (
|
|
22
|
+
BaseStore,
|
|
23
|
+
GetOp,
|
|
24
|
+
Item,
|
|
25
|
+
ListNamespacesOp,
|
|
26
|
+
Op,
|
|
27
|
+
PutOp,
|
|
28
|
+
SearchItem,
|
|
29
|
+
SearchOp,
|
|
30
|
+
TTLConfig,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
KIND = "kv"
|
|
34
|
+
NS_KIND = "ns"
|
|
35
|
+
_SEP = "\x1f" # unit separator: illegal in a namespace element, so joins stay reversible
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _join(namespace: tuple[str, ...]) -> str:
|
|
39
|
+
return _SEP.join(namespace)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _split(joined: str) -> tuple[str, ...]:
|
|
43
|
+
return tuple(joined.split(_SEP)) if joined else ()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ancestors(namespace: tuple[str, ...]) -> list[str]:
|
|
47
|
+
"""Every prefix of `namespace`, itself included.
|
|
48
|
+
|
|
49
|
+
JSONB containment tests array membership, so storing the prefixes makes
|
|
50
|
+
"everything under this namespace" an indexed lookup instead of a scan.
|
|
51
|
+
"""
|
|
52
|
+
return [_join(namespace[: i + 1]) for i in range(len(namespace))]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _now() -> int:
|
|
56
|
+
return int(time.time() * 1_000_000)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _when(micros: int | None) -> datetime | None:
|
|
60
|
+
return datetime.fromtimestamp(micros / 1_000_000, tz=timezone.utc) if micros else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CitadelStore(BaseStore):
|
|
64
|
+
"""LangGraph store over one encrypted Citadel region.
|
|
65
|
+
|
|
66
|
+
Namespaces live in the atom payload rather than in separate regions, so a search can span
|
|
67
|
+
a namespace prefix in a single recall. Payload lookups ride Citadel's GIN index on
|
|
68
|
+
`payload`, so key access is indexed rather than scanned.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
supports_ttl = True
|
|
72
|
+
ttl_config = TTLConfig(refresh_on_read=True, omit_expired=True)
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
path: str,
|
|
77
|
+
key: str,
|
|
78
|
+
*,
|
|
79
|
+
region: str = "store",
|
|
80
|
+
embedder: Any | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
try:
|
|
83
|
+
self._db = citadeldb.connect(path, key=key, region_keys=True)
|
|
84
|
+
except citadeldb.OperationalError as e:
|
|
85
|
+
if "locked" not in str(e):
|
|
86
|
+
raise
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
f"{path} is already open in this process or another one. Citadel is embedded, "
|
|
89
|
+
f"so one handle owns the file: build a single CitadelStore and share it, and "
|
|
90
|
+
f"separate concerns with namespaces rather than with a second store."
|
|
91
|
+
) from e
|
|
92
|
+
self._mem = self._db.memory()
|
|
93
|
+
self._region = region
|
|
94
|
+
try:
|
|
95
|
+
self._mem.create_encrypted_region(
|
|
96
|
+
region, embedder or citadeldb.MockEmbedder(dim=64)
|
|
97
|
+
)
|
|
98
|
+
except citadeldb.CitadelError:
|
|
99
|
+
pass # region already exists from an earlier open
|
|
100
|
+
|
|
101
|
+
# ---- storage helpers --------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _find(self, namespace: tuple[str, ...], key: str):
|
|
104
|
+
"""The atom holding (namespace, key). Served by the payload GIN index."""
|
|
105
|
+
hits = self._mem.fetch(
|
|
106
|
+
self._region,
|
|
107
|
+
KIND,
|
|
108
|
+
payload_filter={"ns": _join(namespace), "key": key},
|
|
109
|
+
limit=1,
|
|
110
|
+
)
|
|
111
|
+
return hits[0] if hits else None
|
|
112
|
+
|
|
113
|
+
def _namespace_has_keys(self, joined: str) -> bool:
|
|
114
|
+
return bool(
|
|
115
|
+
self._mem.fetch(self._region, KIND, payload_filter={"ns": joined}, limit=1)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _register_namespace(self, joined: str) -> None:
|
|
119
|
+
"""One marker atom per distinct namespace, so listing is O(namespaces)."""
|
|
120
|
+
if self._mem.fetch(
|
|
121
|
+
self._region, NS_KIND, payload_filter={"ns": joined}, limit=1
|
|
122
|
+
):
|
|
123
|
+
return
|
|
124
|
+
self._mem.remember(
|
|
125
|
+
self._region, {"kind": NS_KIND, "text": joined or "/", "payload": {"ns": joined}}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _retire_namespace(self, joined: str) -> None:
|
|
129
|
+
"""Drop the marker once the namespace holds no keys."""
|
|
130
|
+
if self._namespace_has_keys(joined):
|
|
131
|
+
return
|
|
132
|
+
stale = self._mem.fetch(
|
|
133
|
+
self._region, NS_KIND, payload_filter={"ns": joined}, limit=8
|
|
134
|
+
)
|
|
135
|
+
if stale:
|
|
136
|
+
self._mem.forget(self._region, [h.id for h in stale])
|
|
137
|
+
|
|
138
|
+
def _write(
|
|
139
|
+
self,
|
|
140
|
+
namespace: tuple[str, ...],
|
|
141
|
+
key: str,
|
|
142
|
+
value: dict[str, Any],
|
|
143
|
+
ttl: float | None,
|
|
144
|
+
created_at: int,
|
|
145
|
+
updated_at: int,
|
|
146
|
+
) -> None:
|
|
147
|
+
joined = _join(namespace)
|
|
148
|
+
atom: dict[str, Any] = {
|
|
149
|
+
"kind": KIND,
|
|
150
|
+
# The value is the searchable text; keys and namespaces are not content.
|
|
151
|
+
"text": " ".join(str(v) for v in value.values()) or json.dumps(value),
|
|
152
|
+
"payload": {
|
|
153
|
+
"ns": joined,
|
|
154
|
+
# Prefix erasure and prefix search both filter on this.
|
|
155
|
+
"anc": _ancestors(namespace),
|
|
156
|
+
"key": key,
|
|
157
|
+
"value": value,
|
|
158
|
+
"created_at": created_at,
|
|
159
|
+
"updated_at": updated_at,
|
|
160
|
+
# Kept so a read can re-apply the same lifetime on refresh.
|
|
161
|
+
"ttl": ttl,
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
if ttl is not None:
|
|
165
|
+
atom["expires_at"] = int(time.time() * 1_000_000 + ttl * 60_000_000)
|
|
166
|
+
self._mem.remember(self._region, atom)
|
|
167
|
+
self._register_namespace(joined)
|
|
168
|
+
|
|
169
|
+
def _refresh_ttl(self, hit) -> None:
|
|
170
|
+
"""Re-apply the stored lifetime. Expiry cannot be moved in place, so the atom is
|
|
171
|
+
rewritten; both timestamps are carried over because a read must not look like a write.
|
|
172
|
+
"""
|
|
173
|
+
p = hit.payload
|
|
174
|
+
if p.get("ttl") is None:
|
|
175
|
+
return
|
|
176
|
+
self._mem.forget(self._region, [hit.id])
|
|
177
|
+
self._write(
|
|
178
|
+
_split(p["ns"]),
|
|
179
|
+
p["key"],
|
|
180
|
+
p["value"],
|
|
181
|
+
p["ttl"],
|
|
182
|
+
p["created_at"],
|
|
183
|
+
p["updated_at"],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# ---- projection -------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _item(hit) -> Item:
|
|
190
|
+
p = hit.payload
|
|
191
|
+
return Item(
|
|
192
|
+
value=p["value"],
|
|
193
|
+
key=p["key"],
|
|
194
|
+
namespace=_split(p["ns"]),
|
|
195
|
+
created_at=_when(p.get("created_at")),
|
|
196
|
+
updated_at=_when(p.get("updated_at")),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _search_item(hit) -> SearchItem:
|
|
201
|
+
p = hit.payload
|
|
202
|
+
return SearchItem(
|
|
203
|
+
namespace=_split(p["ns"]),
|
|
204
|
+
key=p["key"],
|
|
205
|
+
value=p["value"],
|
|
206
|
+
created_at=_when(p.get("created_at")),
|
|
207
|
+
updated_at=_when(p.get("updated_at")),
|
|
208
|
+
score=getattr(hit, "score", None),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _matches(ns: tuple[str, ...], conditions) -> bool:
|
|
213
|
+
for c in conditions or ():
|
|
214
|
+
path = tuple(c.path)
|
|
215
|
+
if c.match_type == "prefix":
|
|
216
|
+
seg = ns[: len(path)]
|
|
217
|
+
else:
|
|
218
|
+
seg = ns[-len(path) :] if len(path) <= len(ns) else ns
|
|
219
|
+
if len(seg) != len(path):
|
|
220
|
+
return False
|
|
221
|
+
# "*" matches any single element.
|
|
222
|
+
if any(p != "*" and p != s for p, s in zip(path, seg)):
|
|
223
|
+
return False
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def _under(self, ns: str, prefix: str) -> bool:
|
|
227
|
+
return not prefix or ns == prefix or ns.startswith(prefix + _SEP)
|
|
228
|
+
|
|
229
|
+
# ---- the abstract surface --------------------------------------------
|
|
230
|
+
|
|
231
|
+
def batch(self, ops: Iterable[Op]) -> list[Any]:
|
|
232
|
+
results: list[Any] = []
|
|
233
|
+
for op in ops:
|
|
234
|
+
if isinstance(op, GetOp):
|
|
235
|
+
hit = self._find(op.namespace, op.key)
|
|
236
|
+
if hit and op.refresh_ttl:
|
|
237
|
+
self._refresh_ttl(hit)
|
|
238
|
+
results.append(self._item(hit) if hit else None)
|
|
239
|
+
|
|
240
|
+
elif isinstance(op, PutOp):
|
|
241
|
+
existing = self._find(op.namespace, op.key)
|
|
242
|
+
joined = _join(op.namespace)
|
|
243
|
+
if op.value is None:
|
|
244
|
+
if existing:
|
|
245
|
+
self._mem.forget(self._region, [existing.id])
|
|
246
|
+
self._retire_namespace(joined)
|
|
247
|
+
results.append(None)
|
|
248
|
+
continue
|
|
249
|
+
now = _now()
|
|
250
|
+
created = existing.payload["created_at"] if existing else now
|
|
251
|
+
if existing:
|
|
252
|
+
self._mem.forget(self._region, [existing.id])
|
|
253
|
+
self._write(op.namespace, op.key, op.value, op.ttl, created, now)
|
|
254
|
+
results.append(None)
|
|
255
|
+
|
|
256
|
+
elif isinstance(op, SearchOp):
|
|
257
|
+
prefix = _join(op.namespace_prefix)
|
|
258
|
+
want = op.limit + op.offset
|
|
259
|
+
if op.query:
|
|
260
|
+
# Recall ranks the whole region, so the namespace pass happens below;
|
|
261
|
+
# over-fetch because that pass and the filter both discard rows.
|
|
262
|
+
hits = self._mem.recall(
|
|
263
|
+
self._region, text=op.query, k=max(want * 4, 32), kinds=[KIND]
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
# No query: the index can do the namespace restriction directly.
|
|
267
|
+
hits = self._mem.fetch(
|
|
268
|
+
self._region,
|
|
269
|
+
KIND,
|
|
270
|
+
payload_filter={"anc": [prefix]} if prefix else None,
|
|
271
|
+
limit=max(want * 4, 32),
|
|
272
|
+
)
|
|
273
|
+
out: list[SearchItem] = []
|
|
274
|
+
for h in hits:
|
|
275
|
+
p = h.payload
|
|
276
|
+
if not self._under(p["ns"], prefix):
|
|
277
|
+
continue
|
|
278
|
+
if op.filter and any(
|
|
279
|
+
p["value"].get(k) != v for k, v in op.filter.items()
|
|
280
|
+
):
|
|
281
|
+
continue
|
|
282
|
+
if op.refresh_ttl:
|
|
283
|
+
self._refresh_ttl(h)
|
|
284
|
+
out.append(self._search_item(h))
|
|
285
|
+
results.append(out[op.offset : op.offset + op.limit])
|
|
286
|
+
|
|
287
|
+
elif isinstance(op, ListNamespacesOp):
|
|
288
|
+
seen: set[tuple[str, ...]] = set()
|
|
289
|
+
# Marker atoms only, so this is O(namespaces) rather than O(keys). Each is
|
|
290
|
+
# verified against a live key, so a crash mid-delete self-heals on read.
|
|
291
|
+
for marker in self._mem.fetch(self._region, NS_KIND, limit=100_000):
|
|
292
|
+
joined = marker.payload["ns"]
|
|
293
|
+
if not self._namespace_has_keys(joined):
|
|
294
|
+
continue
|
|
295
|
+
ns = _split(joined)
|
|
296
|
+
if not self._matches(ns, op.match_conditions):
|
|
297
|
+
continue
|
|
298
|
+
seen.add(ns[: op.max_depth] if op.max_depth is not None else ns)
|
|
299
|
+
ordered = sorted(seen)
|
|
300
|
+
results.append(ordered[op.offset : op.offset + op.limit])
|
|
301
|
+
|
|
302
|
+
else:
|
|
303
|
+
raise NotImplementedError(f"unsupported op: {type(op).__name__}")
|
|
304
|
+
return results
|
|
305
|
+
|
|
306
|
+
async def abatch(self, ops: Iterable[Op]) -> list[Any]:
|
|
307
|
+
# The bindings are sync; a worker thread keeps the event loop free.
|
|
308
|
+
return await asyncio.to_thread(self.batch, list(ops))
|
|
309
|
+
|
|
310
|
+
# ---- beyond BaseStore -------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def forget_namespace(self, namespace: tuple[str, ...], *, prefix: bool = True) -> int:
|
|
313
|
+
"""Destroy every key under `namespace`, returning the number of atoms erased.
|
|
314
|
+
|
|
315
|
+
The point of a per-user namespace: one call makes that user's memories unreadable
|
|
316
|
+
rather than merely unlisted. Selection rides the payload index in both modes.
|
|
317
|
+
"""
|
|
318
|
+
joined = _join(namespace)
|
|
319
|
+
# Containment on the ancestor array selects the whole subtree; on `ns` it selects
|
|
320
|
+
# exactly one namespace. Either way the index does the work.
|
|
321
|
+
criterion = {"anc": [joined]} if prefix else {"ns": joined}
|
|
322
|
+
doomed = self._mem.fetch(
|
|
323
|
+
self._region, KIND, payload_filter=criterion, limit=100_000
|
|
324
|
+
)
|
|
325
|
+
if not doomed:
|
|
326
|
+
return 0
|
|
327
|
+
erased = self._mem.forget(self._region, [h.id for h in doomed]).erased_count
|
|
328
|
+
for gone in {h.payload["ns"] for h in doomed}:
|
|
329
|
+
self._retire_namespace(gone)
|
|
330
|
+
return erased
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from langgraph.store.base import ListNamespacesOp, MatchCondition
|
|
5
|
+
|
|
6
|
+
from citadeldb_langgraph import CitadelStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(scope="module")
|
|
10
|
+
def store(tmp_path_factory):
|
|
11
|
+
# Citadel takes an exclusive lock, so the whole module shares one handle.
|
|
12
|
+
path = tmp_path_factory.mktemp("store") / "s.cdl"
|
|
13
|
+
return CitadelStore(str(path), key="test-passphrase")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_put_get_roundtrip(store):
|
|
17
|
+
store.put(("users", "alice"), "profile", {"city": "Berlin", "pet": "Mochi"})
|
|
18
|
+
item = store.get(("users", "alice"), "profile")
|
|
19
|
+
assert item.value == {"city": "Berlin", "pet": "Mochi"}
|
|
20
|
+
assert item.namespace == ("users", "alice")
|
|
21
|
+
assert item.key == "profile"
|
|
22
|
+
assert item.created_at is not None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_missing_key_is_none(store):
|
|
26
|
+
assert store.get(("users", "nobody"), "profile") is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_overwrite_preserves_created_at(store):
|
|
30
|
+
store.put(("ow",), "k", {"n": 1})
|
|
31
|
+
first = store.get(("ow",), "k")
|
|
32
|
+
time.sleep(0.01)
|
|
33
|
+
store.put(("ow",), "k", {"n": 2})
|
|
34
|
+
second = store.get(("ow",), "k")
|
|
35
|
+
assert second.value == {"n": 2}
|
|
36
|
+
assert second.created_at == first.created_at
|
|
37
|
+
assert second.updated_at >= first.updated_at
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_overwrite_does_not_duplicate(store):
|
|
41
|
+
store.put(("dup",), "k", {"n": 1})
|
|
42
|
+
store.put(("dup",), "k", {"n": 2})
|
|
43
|
+
assert len(store.search(("dup",), limit=10)) == 1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_search_is_semantic(store):
|
|
47
|
+
store.put(("notes",), "n1", {"text": "the deployment failed because the disk was full"})
|
|
48
|
+
store.put(("notes",), "n2", {"text": "lunch plans for friday"})
|
|
49
|
+
# The query shares no words with the match, so a LIKE would return nothing.
|
|
50
|
+
hits = store.search(("notes",), query="why did the release break?", limit=2)
|
|
51
|
+
assert hits[0].value["text"].startswith("the deployment failed")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_search_descends_into_child_namespaces(store):
|
|
55
|
+
store.put(("tree",), "a", {"v": 1})
|
|
56
|
+
store.put(("tree", "child"), "b", {"v": 2})
|
|
57
|
+
assert len(store.search(("tree",), limit=10)) == 2
|
|
58
|
+
assert len(store.search(("tree", "child"), limit=10)) == 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_search_prefix_is_not_a_string_prefix(store):
|
|
62
|
+
"""('org',) must not match 'orgX', which a string-prefix filter would."""
|
|
63
|
+
store.put(("org", "acme"), "k", {"v": 1})
|
|
64
|
+
store.put(("orgX",), "k", {"v": 2})
|
|
65
|
+
found = {h.namespace for h in store.search(("org",), limit=10)}
|
|
66
|
+
assert ("org", "acme") in found
|
|
67
|
+
assert ("orgX",) not in found
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_search_filter(store):
|
|
71
|
+
store.put(("filt",), "a", {"kind": "x", "n": 1})
|
|
72
|
+
store.put(("filt",), "b", {"kind": "y", "n": 2})
|
|
73
|
+
hits = store.search(("filt",), filter={"kind": "y"}, limit=5)
|
|
74
|
+
assert [h.value["n"] for h in hits] == [2]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_namespace_element_may_contain_a_slash(store):
|
|
78
|
+
store.put(("a/b",), "k", {"v": "slash"})
|
|
79
|
+
assert store.get(("a/b",), "k").value["v"] == "slash"
|
|
80
|
+
assert store.get(("a", "b"), "k") is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_list_namespaces(store):
|
|
84
|
+
store.put(("ln", "x"), "k", {"v": 1})
|
|
85
|
+
assert ("ln", "x") in store.list_namespaces()
|
|
86
|
+
assert ("ln",) in store.list_namespaces(max_depth=1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_list_namespaces_prefix_match(store):
|
|
90
|
+
store.put(("mc", "one"), "k", {"v": 1})
|
|
91
|
+
got = store.batch(
|
|
92
|
+
[
|
|
93
|
+
ListNamespacesOp(
|
|
94
|
+
match_conditions=(MatchCondition(match_type="prefix", path=("mc",)),),
|
|
95
|
+
max_depth=None,
|
|
96
|
+
limit=10,
|
|
97
|
+
offset=0,
|
|
98
|
+
)
|
|
99
|
+
]
|
|
100
|
+
)[0]
|
|
101
|
+
assert got and all(ns[0] == "mc" for ns in got)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_delete_removes_only_its_key(store):
|
|
105
|
+
store.put(("del", "keep"), "k", {"v": 1})
|
|
106
|
+
store.put(("del", "drop"), "k", {"v": 2})
|
|
107
|
+
store.delete(("del", "drop"), "k")
|
|
108
|
+
assert store.get(("del", "drop"), "k") is None
|
|
109
|
+
assert store.get(("del", "keep"), "k") is not None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_emptied_namespace_stops_being_listed(store):
|
|
113
|
+
store.put(("gone",), "k", {"v": 1})
|
|
114
|
+
assert ("gone",) in store.list_namespaces()
|
|
115
|
+
store.delete(("gone",), "k")
|
|
116
|
+
assert ("gone",) not in store.list_namespaces()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_ttl_refresh_is_not_a_write(store):
|
|
120
|
+
store.put(("ttl",), "k", {"v": 1}, ttl=60.0)
|
|
121
|
+
before = store.get(("ttl",), "k")
|
|
122
|
+
time.sleep(0.02)
|
|
123
|
+
after = store.get(("ttl",), "k", refresh_ttl=True)
|
|
124
|
+
assert after.created_at == before.created_at
|
|
125
|
+
assert after.updated_at == before.updated_at
|
|
126
|
+
assert after.value == {"v": 1}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_ttl_config_declares_refresh(store):
|
|
130
|
+
assert CitadelStore.supports_ttl is True
|
|
131
|
+
assert CitadelStore.ttl_config["refresh_on_read"] is True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_forget_namespace_erases_the_subtree(store):
|
|
135
|
+
store.put(("gdpr", "u9"), "a", {"v": 1})
|
|
136
|
+
store.put(("gdpr", "u9"), "b", {"v": 2})
|
|
137
|
+
store.put(("gdpr", "u8"), "a", {"v": 3})
|
|
138
|
+
assert store.forget_namespace(("gdpr", "u9")) == 2
|
|
139
|
+
assert store.get(("gdpr", "u9"), "a") is None
|
|
140
|
+
assert store.get(("gdpr", "u8"), "a") is not None
|
|
141
|
+
assert ("gdpr", "u9") not in store.list_namespaces()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_forget_namespace_exact_spares_children(store):
|
|
145
|
+
store.put(("ex",), "a", {"v": 1})
|
|
146
|
+
store.put(("ex", "child"), "b", {"v": 2})
|
|
147
|
+
assert store.forget_namespace(("ex",), prefix=False) == 1
|
|
148
|
+
assert store.get(("ex", "child"), "b") is not None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_second_handle_explains_the_lock(store, tmp_path):
|
|
152
|
+
path = str(tmp_path / "locked.cdl")
|
|
153
|
+
first = CitadelStore(path, key="pw")
|
|
154
|
+
with pytest.raises(RuntimeError, match="one handle owns the file"):
|
|
155
|
+
CitadelStore(path, key="pw")
|
|
156
|
+
assert first is not None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_async_surface(store):
|
|
160
|
+
"""abatch runs the sync batch on a worker thread, so the loop is never blocked."""
|
|
161
|
+
import asyncio
|
|
162
|
+
|
|
163
|
+
async def main():
|
|
164
|
+
await store.aput(("async",), "k", {"v": 1})
|
|
165
|
+
assert (await store.aget(("async",), "k")).value == {"v": 1}
|
|
166
|
+
assert len(await store.asearch(("async",), query="v", limit=1)) == 1
|
|
167
|
+
await store.adelete(("async",), "k")
|
|
168
|
+
assert await store.aget(("async",), "k") is None
|
|
169
|
+
|
|
170
|
+
asyncio.run(main())
|