tinkerfin-langgraph-store 0.1.1__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.
Files changed (23) hide show
  1. tinkerfin_langgraph_store-0.1.1/LICENSE +21 -0
  2. tinkerfin_langgraph_store-0.1.1/NOTICE +5 -0
  3. tinkerfin_langgraph_store-0.1.1/PKG-INFO +112 -0
  4. tinkerfin_langgraph_store-0.1.1/README.md +86 -0
  5. tinkerfin_langgraph_store-0.1.1/pyproject.toml +36 -0
  6. tinkerfin_langgraph_store-0.1.1/setup.cfg +4 -0
  7. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/__init__.py +42 -0
  8. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/_lifetime.py +61 -0
  9. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/_operations.py +286 -0
  10. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/_sql_schema.py +294 -0
  11. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/_tasks.py +157 -0
  12. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/errors.py +52 -0
  13. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/py.typed +0 -0
  14. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/sqlalchemy.py +792 -0
  15. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/PKG-INFO +112 -0
  16. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/SOURCES.txt +21 -0
  17. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/dependency_links.txt +1 -0
  18. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/requires.txt +6 -0
  19. tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/top_level.txt +1 -0
  20. tinkerfin_langgraph_store-0.1.1/tests/test_conditional_documents.py +178 -0
  21. tinkerfin_langgraph_store-0.1.1/tests/test_langgraph_store_operations.py +293 -0
  22. tinkerfin_langgraph_store-0.1.1/tests/test_langgraph_store_transactions.py +419 -0
  23. tinkerfin_langgraph_store-0.1.1/tests/test_store_cleanup.py +99 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Theodore Ni
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,5 @@
1
+ Portions of the LangGraph Store namespace validation and batch semantics are
2
+ adapted from langgraph-checkpoint-mysql 3.0.0 by Theodore Ni:
3
+ https://github.com/tjni/langgraph-checkpoint-mysql
4
+
5
+ The applicable MIT License and copyright notice are included in LICENSE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinkerfin-langgraph-store
3
+ Version: 0.1.1
4
+ Summary: Async persistent LangGraph memory with exact namespaces and structured search.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/tinkerfin-ai/tinkerfin-harness
7
+ Project-URL: Repository, https://github.com/tinkerfin-ai/tinkerfin-harness
8
+ Project-URL: Issues, https://github.com/tinkerfin-ai/tinkerfin-harness/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ License-File: NOTICE
20
+ Requires-Dist: langgraph-checkpoint==4.2.0
21
+ Provides-Extra: sqlalchemy
22
+ Requires-Dist: sqlalchemy[asyncio]==2.0.52; extra == "sqlalchemy"
23
+ Requires-Dist: tinkerfin-sqlalchemy==0.1.1; extra == "sqlalchemy"
24
+ Requires-Dist: tinkerfin-contracts==0.1.1; extra == "sqlalchemy"
25
+ Dynamic: license-file
26
+
27
+ # TinkerFin LangGraph Store
28
+
29
+ Persistent LangGraph memory with asynchronous reads, atomic batches, structured
30
+ search, and namespace listing. SQLite, PostgreSQL, and MySQL use the same
31
+ query and batch API.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install "tinkerfin-langgraph-store[sqlalchemy]" aiosqlite
37
+ ```
38
+
39
+ Choose `asyncpg` for PostgreSQL or `asyncmy` for MySQL. Supply a SQLAlchemy
40
+ `AsyncEngine` configured with that driver.
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ import asyncio
46
+
47
+ from sqlalchemy.ext.asyncio import create_async_engine
48
+ from tinkerfin_langgraph_store import SqlAlchemyStore
49
+
50
+
51
+ async def main() -> None:
52
+ engine = create_async_engine("sqlite+aiosqlite:///memory.db")
53
+ try:
54
+ async with SqlAlchemyStore(engine) as store:
55
+ await store.aput(("users", "alice"), "preferences", {"language": "en"})
56
+ item = await store.aget(("users", "alice"), "preferences")
57
+ print(item.value if item else None)
58
+ matches = await store.asearch(("users",), filter={"language": "en"})
59
+ print([item.namespace for item in matches])
60
+ finally:
61
+ await engine.dispose()
62
+
63
+
64
+ asyncio.run(main())
65
+ ```
66
+
67
+ Pass the Store to `TinkerFin(store=store).with_namespace(...).build(model=...)` to
68
+ isolate memory by Runtime namespace. The caller owns the Engine. Closing the Store
69
+ waits for accepted operations and does not close the Engine. Setup is automatic;
70
+ `await store.setup()` can validate storage at application startup.
71
+
72
+ ## Stored data and queries
73
+
74
+ - `aget`, `aput`, `adelete`, and `abatch` use complete namespace/key identities.
75
+ A batch reads one snapshot before writing; the final write to each key wins.
76
+ Updates preserve `created_at`.
77
+ - `acompare_and_set(namespace, key, expected=observed, value=replacement)` commits
78
+ only when the complete JSON value still matches. `expected=None` requires
79
+ absence; `value=None` deletes. A conflict returns `False` without changing
80
+ data. Conditions are checked in the write transaction across Store instances.
81
+ - `asearch_exact(namespace, limit=100, offset=0)` pages one collection and
82
+ excludes descendant namespaces before pagination. It returns independent
83
+ `DocumentSnapshot` values from `tinkerfin_contracts.storage`, ordered by update
84
+ time descending and then key ascending.
85
+ - `asearch` matches a literal namespace prefix. Results sort by `updated_at`
86
+ descending, then namespace and key ascending. `limit` and `offset` apply after
87
+ filtering.
88
+ - Filters use literal top-level field names. `$eq` and `$ne` compare typed JSON
89
+ values; use `$eq` for an object value. Missing fields match neither operator.
90
+ Null differs from missing, booleans differ from numbers, and object key order
91
+ does not affect equality. Integral floats equal their corresponding integers.
92
+ - `$gt`, `$gte`, `$lt`, and `$lte` compare numbers at binary64 precision. Operands
93
+ must be finite numbers within that range. Multiple fields and operators combine
94
+ with AND.
95
+ - `alist_namespaces` matches complete paths before `max_depth`, deduplication,
96
+ sorting, and pagination. Its `*` matches one label; search treats `*` literally.
97
+ Only namespaces containing documents appear.
98
+
99
+ Namespaces follow LangGraph's label rules. Namespace, key, and document sizes are
100
+ subject to the database's payload and resource limits. Document values must be
101
+ finite JSON objects. TTL, semantic search, vector indexing, and synchronous
102
+ database calls are unsupported.
103
+
104
+ Use a pool with exclusive checkouts. File SQLite Engines use one by default;
105
+ in-memory SQLite needs `AsyncAdaptedQueuePool(pool_size=1, max_overflow=0)`.
106
+ Existing tables and indexes must match the complete current structure.
107
+
108
+ ## Documentation and license
109
+
110
+ [TinkerFin documentation](https://github.com/tinkerfin-ai/tinkerfin-harness/tree/main/docs/en)
111
+ · [MIT License](https://github.com/tinkerfin-ai/tinkerfin-harness/blob/main/packages/tinkerfin-langgraph-store/LICENSE)
112
+ · [NOTICE](https://github.com/tinkerfin-ai/tinkerfin-harness/blob/main/packages/tinkerfin-langgraph-store/NOTICE)
@@ -0,0 +1,86 @@
1
+ # TinkerFin LangGraph Store
2
+
3
+ Persistent LangGraph memory with asynchronous reads, atomic batches, structured
4
+ search, and namespace listing. SQLite, PostgreSQL, and MySQL use the same
5
+ query and batch API.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install "tinkerfin-langgraph-store[sqlalchemy]" aiosqlite
11
+ ```
12
+
13
+ Choose `asyncpg` for PostgreSQL or `asyncmy` for MySQL. Supply a SQLAlchemy
14
+ `AsyncEngine` configured with that driver.
15
+
16
+ ## Quick start
17
+
18
+ ```python
19
+ import asyncio
20
+
21
+ from sqlalchemy.ext.asyncio import create_async_engine
22
+ from tinkerfin_langgraph_store import SqlAlchemyStore
23
+
24
+
25
+ async def main() -> None:
26
+ engine = create_async_engine("sqlite+aiosqlite:///memory.db")
27
+ try:
28
+ async with SqlAlchemyStore(engine) as store:
29
+ await store.aput(("users", "alice"), "preferences", {"language": "en"})
30
+ item = await store.aget(("users", "alice"), "preferences")
31
+ print(item.value if item else None)
32
+ matches = await store.asearch(("users",), filter={"language": "en"})
33
+ print([item.namespace for item in matches])
34
+ finally:
35
+ await engine.dispose()
36
+
37
+
38
+ asyncio.run(main())
39
+ ```
40
+
41
+ Pass the Store to `TinkerFin(store=store).with_namespace(...).build(model=...)` to
42
+ isolate memory by Runtime namespace. The caller owns the Engine. Closing the Store
43
+ waits for accepted operations and does not close the Engine. Setup is automatic;
44
+ `await store.setup()` can validate storage at application startup.
45
+
46
+ ## Stored data and queries
47
+
48
+ - `aget`, `aput`, `adelete`, and `abatch` use complete namespace/key identities.
49
+ A batch reads one snapshot before writing; the final write to each key wins.
50
+ Updates preserve `created_at`.
51
+ - `acompare_and_set(namespace, key, expected=observed, value=replacement)` commits
52
+ only when the complete JSON value still matches. `expected=None` requires
53
+ absence; `value=None` deletes. A conflict returns `False` without changing
54
+ data. Conditions are checked in the write transaction across Store instances.
55
+ - `asearch_exact(namespace, limit=100, offset=0)` pages one collection and
56
+ excludes descendant namespaces before pagination. It returns independent
57
+ `DocumentSnapshot` values from `tinkerfin_contracts.storage`, ordered by update
58
+ time descending and then key ascending.
59
+ - `asearch` matches a literal namespace prefix. Results sort by `updated_at`
60
+ descending, then namespace and key ascending. `limit` and `offset` apply after
61
+ filtering.
62
+ - Filters use literal top-level field names. `$eq` and `$ne` compare typed JSON
63
+ values; use `$eq` for an object value. Missing fields match neither operator.
64
+ Null differs from missing, booleans differ from numbers, and object key order
65
+ does not affect equality. Integral floats equal their corresponding integers.
66
+ - `$gt`, `$gte`, `$lt`, and `$lte` compare numbers at binary64 precision. Operands
67
+ must be finite numbers within that range. Multiple fields and operators combine
68
+ with AND.
69
+ - `alist_namespaces` matches complete paths before `max_depth`, deduplication,
70
+ sorting, and pagination. Its `*` matches one label; search treats `*` literally.
71
+ Only namespaces containing documents appear.
72
+
73
+ Namespaces follow LangGraph's label rules. Namespace, key, and document sizes are
74
+ subject to the database's payload and resource limits. Document values must be
75
+ finite JSON objects. TTL, semantic search, vector indexing, and synchronous
76
+ database calls are unsupported.
77
+
78
+ Use a pool with exclusive checkouts. File SQLite Engines use one by default;
79
+ in-memory SQLite needs `AsyncAdaptedQueuePool(pool_size=1, max_overflow=0)`.
80
+ Existing tables and indexes must match the complete current structure.
81
+
82
+ ## Documentation and license
83
+
84
+ [TinkerFin documentation](https://github.com/tinkerfin-ai/tinkerfin-harness/tree/main/docs/en)
85
+ · [MIT License](https://github.com/tinkerfin-ai/tinkerfin-harness/blob/main/packages/tinkerfin-langgraph-store/LICENSE)
86
+ · [NOTICE](https://github.com/tinkerfin-ai/tinkerfin-harness/blob/main/packages/tinkerfin-langgraph-store/NOTICE)
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "tinkerfin-langgraph-store"
3
+ version = "0.1.1"
4
+ description = "Async persistent LangGraph memory with exact namespaces and structured search."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE", "NOTICE"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Programming Language :: Python :: 3",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Typing :: Typed",
17
+ ]
18
+ dependencies = ["langgraph-checkpoint==4.2.0"]
19
+
20
+ [project.optional-dependencies]
21
+ sqlalchemy = ["sqlalchemy[asyncio]==2.0.52", "tinkerfin-sqlalchemy==0.1.1", "tinkerfin-contracts==0.1.1"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/tinkerfin-ai/tinkerfin-harness"
25
+ Repository = "https://github.com/tinkerfin-ai/tinkerfin-harness"
26
+ Issues = "https://github.com/tinkerfin-ai/tinkerfin-harness/issues"
27
+
28
+ [build-system]
29
+ requires = ["setuptools>=77"]
30
+ build-backend = "setuptools.build_meta"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.setuptools.package-data]
36
+ tinkerfin_langgraph_store = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,42 @@
1
+ """Persistent, asynchronous LangGraph memory stores."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .errors import LangGraphStoreError as LangGraphStoreError
6
+ from .errors import StoreClosedError as StoreClosedError
7
+ from .errors import StoreCorruptionError as StoreCorruptionError
8
+ from .errors import StoreDriverError as StoreDriverError
9
+ from .errors import StoreSchemaError as StoreSchemaError
10
+
11
+ if TYPE_CHECKING:
12
+ from .sqlalchemy import SqlAlchemyStore as SqlAlchemyStore
13
+
14
+ __all__ = [
15
+ "LangGraphStoreError",
16
+ "SqlAlchemyStore",
17
+ "StoreClosedError",
18
+ "StoreCorruptionError",
19
+ "StoreDriverError",
20
+ "StoreSchemaError",
21
+ ]
22
+
23
+
24
+ def __getattr__(name: str) -> object:
25
+ """Load a requested database integration without making it a core dependency."""
26
+
27
+ if name == "SqlAlchemyStore":
28
+ try:
29
+ from .sqlalchemy import SqlAlchemyStore
30
+ except ModuleNotFoundError as error:
31
+ if error.name not in {
32
+ "sqlalchemy",
33
+ "tinkerfin_sqlalchemy",
34
+ "tinkerfin_contracts",
35
+ }:
36
+ raise
37
+ raise ImportError(
38
+ 'Install "tinkerfin-langgraph-store[sqlalchemy]" to use SqlAlchemyStore'
39
+ ) from error
40
+ globals()[name] = SqlAlchemyStore
41
+ return SqlAlchemyStore
42
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,61 @@
1
+ """Track accepted operations without owning the caller's database or task."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+
9
+ from .errors import LangGraphStoreError, StoreClosedError
10
+
11
+
12
+ class StoreLifetime:
13
+ """Close admission immediately, then join every accepted operation."""
14
+
15
+ def __init__(self) -> None:
16
+ self._loop: asyncio.AbstractEventLoop | None = None
17
+ self._closed = False
18
+ self._active = 0
19
+ self._idle = asyncio.Event()
20
+ self._idle.set()
21
+
22
+ def _check_loop(self) -> None:
23
+ current = asyncio.get_running_loop()
24
+ if self._loop is None:
25
+ self._loop = current
26
+ elif self._loop is not current:
27
+ raise LangGraphStoreError(
28
+ "A Store must be used and closed on one event loop"
29
+ )
30
+
31
+ @contextmanager
32
+ def operation(self) -> Generator[None]:
33
+ """Account for an operation, including time waiting for setup or a connection."""
34
+
35
+ self._check_loop()
36
+ if self._closed:
37
+ raise StoreClosedError("The Store is closed")
38
+ self._active += 1
39
+ self._idle.clear()
40
+ try:
41
+ yield
42
+ finally:
43
+ self._active -= 1
44
+ if not self._active:
45
+ self._idle.set()
46
+
47
+ async def close(self) -> None:
48
+ """Wait for accepted work before delivering repeated close-waiter cancellation."""
49
+
50
+ self._check_loop()
51
+ self._closed = True
52
+ cancellation: asyncio.CancelledError | None = None
53
+ # Event.wait cancellation only removes this waiter. Accepted operations are
54
+ # caller-owned and keep their outcome; there is no background shutdown task.
55
+ while self._active:
56
+ try:
57
+ await self._idle.wait()
58
+ except asyncio.CancelledError as error:
59
+ cancellation = cancellation or error
60
+ if cancellation is not None:
61
+ raise cancellation
@@ -0,0 +1,286 @@
1
+ """Validate and snapshot LangGraph operations before any database work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass
10
+ from typing import Literal, TypeAlias, TypeGuard
11
+
12
+ from langgraph.store.base import (
13
+ GetOp,
14
+ InvalidNamespaceError,
15
+ ListNamespacesOp,
16
+ Op,
17
+ PutOp,
18
+ SearchOp,
19
+ )
20
+
21
+ JsonValue: TypeAlias = (
22
+ None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
23
+ )
24
+ Comparison: TypeAlias = Literal["$eq", "$ne", "$gt", "$gte", "$lt", "$lte"]
25
+
26
+
27
+ def _is_dict(value: object) -> TypeGuard[dict[object, object]]:
28
+ return isinstance(value, dict)
29
+
30
+
31
+ def _is_list(value: object) -> TypeGuard[list[object]]:
32
+ return isinstance(value, list)
33
+
34
+
35
+ def json_value(value: object, ancestors: frozenset[int] = frozenset()) -> JsonValue:
36
+ """Copy finite JSON without coercing keys, tuples, bytes, or custom objects."""
37
+
38
+ if value is None or isinstance(value, bool | int):
39
+ return value
40
+ if isinstance(value, str):
41
+ value.encode("utf-8")
42
+ return value
43
+ if isinstance(value, float):
44
+ if not math.isfinite(value):
45
+ raise ValueError("Store JSON numbers must be finite")
46
+ return value
47
+ if id(value) in ancestors:
48
+ raise ValueError("Store JSON must not contain cycles")
49
+ parents = ancestors | {id(value)}
50
+ if _is_list(value):
51
+ return [json_value(item, parents) for item in value]
52
+ if _is_dict(value):
53
+ result: dict[str, JsonValue] = {}
54
+ for key, item in value.items():
55
+ if not isinstance(key, str):
56
+ raise TypeError("Store JSON object keys must be strings")
57
+ key.encode("utf-8")
58
+ result[key] = json_value(item, parents)
59
+ return result
60
+ raise TypeError("Store values must contain only JSON types")
61
+
62
+
63
+ def json_object(value: object) -> dict[str, JsonValue]:
64
+ """Require a JSON object while keeping null available as a delete operation."""
65
+
66
+ copied = json_value(value)
67
+ if not isinstance(copied, dict):
68
+ raise TypeError("Store document values must be JSON objects")
69
+ return copied
70
+
71
+
72
+ def encode_json(value: JsonValue) -> str:
73
+ """Serialize validated JSON, preserving Unicode and integer precision."""
74
+
75
+ return json.dumps(value, ensure_ascii=True, allow_nan=False, separators=(",", ":"))
76
+
77
+
78
+ def _equality_value(value: JsonValue) -> JsonValue:
79
+ if value is None:
80
+ return ["null"]
81
+ if isinstance(value, bool):
82
+ return ["bool", value]
83
+ if isinstance(value, int | float):
84
+ numerator, denominator = (
85
+ (value, 1) if isinstance(value, int) else value.as_integer_ratio()
86
+ )
87
+ return ["number", str(numerator), str(denominator)]
88
+ if isinstance(value, str):
89
+ return ["string", value]
90
+ if isinstance(value, list):
91
+ return ["array", [_equality_value(item) for item in value]]
92
+ return ["object", [[key, _equality_value(value[key])] for key in sorted(value)]]
93
+
94
+
95
+ def equality_key(value: JsonValue) -> str:
96
+ """Encode exact typed equality, including equal integral floats and integers."""
97
+
98
+ return encode_json(_equality_value(value)).encode("ascii").hex()
99
+
100
+
101
+ def digest(value: str) -> bytes:
102
+ """Build a compact index key whose full identity is stored and checked separately."""
103
+
104
+ return hashlib.sha256(value.encode("utf-8")).digest()
105
+
106
+
107
+ def namespace_text(namespace: tuple[str, ...]) -> str:
108
+ """Encode tuple ordering and complete labels without database collation rules."""
109
+
110
+ return ".".join(label.encode("utf-8").hex() for label in namespace)
111
+
112
+
113
+ def decode_namespace(value: str) -> tuple[str, ...]:
114
+ """Recover complete labels, including the empty tuple used by max_depth=0."""
115
+
116
+ return (
117
+ tuple(bytes.fromhex(label).decode("utf-8") for label in value.split("."))
118
+ if value
119
+ else ()
120
+ )
121
+
122
+
123
+ def _namespace(value: tuple[str, ...], *, document: bool) -> tuple[str, ...]:
124
+ if not isinstance(value, tuple):
125
+ raise InvalidNamespaceError("Namespace must be a tuple of labels")
126
+ if document and not value:
127
+ raise InvalidNamespaceError("Document namespace cannot be empty")
128
+ for label in value:
129
+ if not isinstance(label, str) or not label or "." in label:
130
+ raise InvalidNamespaceError(
131
+ "Namespace labels must be non-empty strings without periods"
132
+ )
133
+ label.encode("utf-8")
134
+ if document and value[0] == "langgraph":
135
+ raise InvalidNamespaceError("The langgraph root namespace is reserved")
136
+ return value
137
+
138
+
139
+ def _nonnegative(value: int, name: str) -> int:
140
+ if isinstance(value, bool) or not isinstance(value, int):
141
+ raise TypeError(f"{name} must be an integer")
142
+ if value < 0:
143
+ raise ValueError(f"{name} must be non-negative")
144
+ return value
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class Get:
149
+ """A validated exact document lookup."""
150
+
151
+ namespace: tuple[str, ...]
152
+ key: str
153
+
154
+
155
+ @dataclass(frozen=True, slots=True)
156
+ class Put:
157
+ """A copied JSON document or an explicit deletion."""
158
+
159
+ namespace: tuple[str, ...]
160
+ key: str
161
+ value: dict[str, JsonValue] | None
162
+
163
+
164
+ @dataclass(frozen=True, slots=True)
165
+ class Filter:
166
+ """One typed comparison against a literal top-level field."""
167
+
168
+ field: str
169
+ comparison: Comparison
170
+ value: JsonValue
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class Search:
175
+ """A prefix-scoped query whose filters precede pagination."""
176
+
177
+ prefix: tuple[str, ...]
178
+ filters: tuple[Filter, ...]
179
+ limit: int
180
+ offset: int
181
+
182
+
183
+ @dataclass(frozen=True, slots=True)
184
+ class ListNamespaces:
185
+ """Full-path conditions with truncation applied only after matching."""
186
+
187
+ conditions: tuple[tuple[Literal["prefix", "suffix"], tuple[str, ...]], ...]
188
+ max_depth: int | None
189
+ limit: int
190
+ offset: int
191
+
192
+
193
+ Operation: TypeAlias = Get | Put | Search | ListNamespaces
194
+
195
+
196
+ def number_operand(value: JsonValue) -> float:
197
+ """Require a finite binary64 ordering operand without bool or string coercion."""
198
+
199
+ if isinstance(value, bool) or not isinstance(value, int | float):
200
+ raise TypeError("Store ordering operands must be numbers")
201
+ try:
202
+ operand = float(value)
203
+ except OverflowError as error:
204
+ raise ValueError(
205
+ "Store ordering operands must fit finite binary64 precision"
206
+ ) from error
207
+ if not math.isfinite(operand):
208
+ raise ValueError("Store ordering operands must be finite")
209
+ return operand
210
+
211
+
212
+ def _filters(value: object) -> tuple[Filter, ...]:
213
+ if value is None:
214
+ return ()
215
+ copied = json_object(value)
216
+ result: list[Filter] = []
217
+ for field, condition in copied.items():
218
+ comparisons = condition if isinstance(condition, dict) else {"$eq": condition}
219
+ if not comparisons:
220
+ raise ValueError("Use $eq for an object-valued filter")
221
+ for operator, operand in comparisons.items():
222
+ if operator not in {"$eq", "$ne", "$gt", "$gte", "$lt", "$lte"}:
223
+ raise ValueError("Unsupported Store filter; use $eq for object values")
224
+ if operator in {"$gt", "$gte", "$lt", "$lte"}:
225
+ number_operand(operand)
226
+ match operator:
227
+ case "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte":
228
+ result.append(Filter(field, operator, operand))
229
+ case _:
230
+ raise ValueError("Unsupported Store filter operator")
231
+ return tuple(result)
232
+
233
+
234
+ def prepare_operations(operations: Iterable[Op]) -> tuple[Operation, ...]:
235
+ """Consume inputs once and reject the entire invalid batch before I/O."""
236
+
237
+ result: list[Operation] = []
238
+ for op in operations:
239
+ if isinstance(op, GetOp | PutOp):
240
+ namespace = _namespace(op.namespace, document=True)
241
+ if not isinstance(op.key, str):
242
+ raise TypeError("Store document keys must be strings")
243
+ op.key.encode("utf-8")
244
+ if isinstance(op, PutOp):
245
+ if op.ttl is not None:
246
+ raise NotImplementedError("This Store does not support TTL")
247
+ if op.index is not None and op.index is not False:
248
+ raise NotImplementedError(
249
+ "This Store does not support vector indexing"
250
+ )
251
+ value = None if op.value is None else json_object(op.value)
252
+ result.append(Put(namespace, op.key, value))
253
+ else:
254
+ result.append(Get(namespace, op.key))
255
+ elif isinstance(op, SearchOp):
256
+ if op.query is not None:
257
+ raise NotImplementedError("This Store does not support semantic search")
258
+ result.append(
259
+ Search(
260
+ _namespace(op.namespace_prefix, document=False),
261
+ _filters(op.filter),
262
+ _nonnegative(op.limit, "limit"),
263
+ _nonnegative(op.offset, "offset"),
264
+ )
265
+ )
266
+ elif isinstance(op, ListNamespacesOp):
267
+ conditions: list[tuple[Literal["prefix", "suffix"], tuple[str, ...]]] = []
268
+ for condition in op.match_conditions or ():
269
+ if condition.match_type not in {"prefix", "suffix"}:
270
+ raise ValueError("Namespace match type must be prefix or suffix")
271
+ conditions.append(
272
+ (condition.match_type, _namespace(condition.path, document=False))
273
+ )
274
+ result.append(
275
+ ListNamespaces(
276
+ tuple(conditions),
277
+ None
278
+ if op.max_depth is None
279
+ else _nonnegative(op.max_depth, "max_depth"),
280
+ _nonnegative(op.limit, "limit"),
281
+ _nonnegative(op.offset, "offset"),
282
+ )
283
+ )
284
+ else:
285
+ raise TypeError("Unsupported LangGraph Store operation")
286
+ return tuple(result)