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.
@@ -0,0 +1,16 @@
1
+ /target
2
+ **/*.rs.bk
3
+ *.swp
4
+ *.swo
5
+ *~
6
+ .DS_Store
7
+ site/public/
8
+ site/static/wasm/*.wasm
9
+ site/static/wasm/*.js
10
+ /notes/
11
+ __pycache__/
12
+ *.py[cod]
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ /dist/
16
+ packaging/*/dist/
@@ -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())