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.
- tinkerfin_langgraph_store-0.1.1/LICENSE +21 -0
- tinkerfin_langgraph_store-0.1.1/NOTICE +5 -0
- tinkerfin_langgraph_store-0.1.1/PKG-INFO +112 -0
- tinkerfin_langgraph_store-0.1.1/README.md +86 -0
- tinkerfin_langgraph_store-0.1.1/pyproject.toml +36 -0
- tinkerfin_langgraph_store-0.1.1/setup.cfg +4 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/__init__.py +42 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/_lifetime.py +61 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/_operations.py +286 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/_sql_schema.py +294 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/_tasks.py +157 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/errors.py +52 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/py.typed +0 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store/sqlalchemy.py +792 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/PKG-INFO +112 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/SOURCES.txt +21 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/dependency_links.txt +1 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/requires.txt +6 -0
- tinkerfin_langgraph_store-0.1.1/src/tinkerfin_langgraph_store.egg-info/top_level.txt +1 -0
- tinkerfin_langgraph_store-0.1.1/tests/test_conditional_documents.py +178 -0
- tinkerfin_langgraph_store-0.1.1/tests/test_langgraph_store_operations.py +293 -0
- tinkerfin_langgraph_store-0.1.1/tests/test_langgraph_store_transactions.py +419 -0
- 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,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,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)
|