langgraph-checkpoint-sqlite 3.1.0a1__tar.gz → 3.1.2__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 (29) hide show
  1. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/.gitignore +6 -0
  2. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/Makefile +2 -5
  3. langgraph_checkpoint_sqlite-3.1.2/PKG-INFO +115 -0
  4. langgraph_checkpoint_sqlite-3.1.2/README.md +99 -0
  5. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/__init__.py +43 -27
  6. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/_delta.py +59 -39
  7. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/aio.py +38 -21
  8. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/utils.py +23 -2
  9. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/store/sqlite/aio.py +10 -1
  10. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/store/sqlite/base.py +107 -22
  11. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/pyproject.toml +21 -13
  12. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_async_store.py +93 -1
  13. langgraph_checkpoint_sqlite-3.1.2/tests/test_conformance_delta.py +28 -0
  14. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_delta_channel_migration.py +6 -6
  15. langgraph_checkpoint_sqlite-3.1.2/tests/test_delta_parent_walk.py +137 -0
  16. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_get_delta_channel_history.py +6 -6
  17. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_store.py +265 -11
  18. langgraph_checkpoint_sqlite-3.1.2/tests/test_writes_task_path_migration.py +151 -0
  19. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/uv.lock +193 -216
  20. langgraph_checkpoint_sqlite-3.1.0a1/PKG-INFO +0 -109
  21. langgraph_checkpoint_sqlite-3.1.0a1/README.md +0 -93
  22. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/LICENSE +0 -0
  23. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/cache/sqlite/__init__.py +0 -0
  24. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/py.typed +0 -0
  25. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/store/sqlite/__init__.py +0 -0
  26. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/tests/__init__.py +0 -0
  27. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_aiosqlite.py +0 -0
  28. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_sqlite.py +0 -0
  29. {langgraph_checkpoint_sqlite-3.1.0a1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_ttl.py +0 -0
@@ -76,7 +76,12 @@ __pypackages__/
76
76
 
77
77
  # Environments
78
78
  .env
79
+ .env.*
79
80
  .envrc
81
+ *.crt
82
+ *.key
83
+ *.pem
84
+ credentials.json
80
85
  .venv
81
86
  .venvs
82
87
  env/
@@ -98,6 +103,7 @@ dmypy.json
98
103
 
99
104
  .vercel
100
105
  .turbo
106
+ node_modules/
101
107
  .editorconfig
102
108
  .scratch
103
109
  .worktrees/
@@ -18,22 +18,19 @@ test_watch:
18
18
 
19
19
  # Define a variable for Python and notebook files.
20
20
  PYTHON_FILES=.
21
- MYPY_CACHE=.mypy_cache
22
21
  lint format: PYTHON_FILES=.
23
22
  lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$')
24
23
  lint_package: PYTHON_FILES=langgraph
25
24
  lint_tests: PYTHON_FILES=tests
26
- lint_tests: MYPY_CACHE=.mypy_cache_test
27
25
 
28
26
  lint lint_diff lint_package lint_tests:
29
27
  uv run ruff check .
30
28
  [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
31
29
  [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
32
- [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
33
- [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
30
+ [ "$(PYTHON_FILES)" = "" ] || uv run ty check $(PYTHON_FILES)
34
31
 
35
32
  type:
36
- mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
33
+ uv run ty check $(PYTHON_FILES)
37
34
 
38
35
  format format_diff:
39
36
  uv run ruff format $(PYTHON_FILES)
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: langgraph-checkpoint-sqlite
3
+ Version: 3.1.2
4
+ Summary: Library with a SQLite implementation of LangGraph checkpoint saver.
5
+ Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite
6
+ Project-URL: Twitter, https://x.com/langchain_oss
7
+ Project-URL: Slack, https://www.langchain.com/join-community
8
+ Project-URL: Reddit, https://www.reddit.com/r/LangChain/
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: aiosqlite>=0.20
13
+ Requires-Dist: langgraph-checkpoint<5.0.0,>=4.3.0
14
+ Requires-Dist: sqlite-vec>=0.1.6
15
+ Description-Content-Type: text/markdown
16
+
17
+ # LangGraph SQLite Checkpoint
18
+
19
+ [![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-sqlite?label=%20)](https://pypi.org/project/langgraph-checkpoint-sqlite/#history)
20
+ [![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-sqlite)](https://opensource.org/licenses/MIT)
21
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-sqlite)](https://pypistats.org/packages/langgraph-checkpoint-sqlite)
22
+ [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss)
23
+
24
+ To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
25
+ [LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
26
+
27
+ ## Quick Install
28
+
29
+ ```bash
30
+ uv add langgraph-checkpoint-sqlite
31
+ ```
32
+
33
+ ## 🤔 What is this?
34
+
35
+ This library provides a SQLite implementation of LangGraph's checkpoint saver, with both sync and async support via `aiosqlite`. Use it when you want LangGraph state persistence backed by SQLite for local development, testing, or lightweight deployments.
36
+
37
+ ## 📖 Documentation
38
+
39
+ For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.sqlite). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
40
+
41
+ ## Security
42
+
43
+ > [!IMPORTANT]
44
+ > Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from langgraph.checkpoint.sqlite import SqliteSaver
50
+
51
+ write_config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
52
+ read_config = {"configurable": {"thread_id": "1"}}
53
+
54
+ with SqliteSaver.from_conn_string(":memory:") as checkpointer:
55
+ checkpoint = {
56
+ "v": 4,
57
+ "ts": "2024-07-31T20:14:19.804150+00:00",
58
+ "id": "1ef4f797-8335-6428-8001-8a1503f9b875",
59
+ "channel_values": {"my_key": "meow", "node": "node"},
60
+ "channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
61
+ "versions_seen": {
62
+ "__input__": {},
63
+ "__start__": {"__start__": 1},
64
+ "node": {"start:node": 2},
65
+ },
66
+ }
67
+
68
+ # store checkpoint
69
+ checkpointer.put(write_config, checkpoint, {}, {})
70
+
71
+ # load checkpoint
72
+ checkpointer.get(read_config)
73
+
74
+ # list checkpoints
75
+ list(checkpointer.list(read_config))
76
+ ```
77
+
78
+ ### Async
79
+
80
+ ```python
81
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
82
+
83
+ async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
84
+ checkpoint = {
85
+ "v": 4,
86
+ "ts": "2024-07-31T20:14:19.804150+00:00",
87
+ "id": "1ef4f797-8335-6428-8001-8a1503f9b875",
88
+ "channel_values": {"my_key": "meow", "node": "node"},
89
+ "channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
90
+ "versions_seen": {
91
+ "__input__": {},
92
+ "__start__": {"__start__": 1},
93
+ "node": {"start:node": 2},
94
+ },
95
+ }
96
+
97
+ # store checkpoint
98
+ await checkpointer.aput(write_config, checkpoint, {}, {})
99
+
100
+ # load checkpoint
101
+ await checkpointer.aget(read_config)
102
+
103
+ # list checkpoints
104
+ [c async for c in checkpointer.alist(read_config)]
105
+ ```
106
+
107
+ ## 📕 Releases & Versioning
108
+
109
+ See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
110
+
111
+ ## 💁 Contributing
112
+
113
+ As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
114
+
115
+ For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
@@ -0,0 +1,99 @@
1
+ # LangGraph SQLite Checkpoint
2
+
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-sqlite?label=%20)](https://pypi.org/project/langgraph-checkpoint-sqlite/#history)
4
+ [![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-sqlite)](https://opensource.org/licenses/MIT)
5
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-sqlite)](https://pypistats.org/packages/langgraph-checkpoint-sqlite)
6
+ [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss)
7
+
8
+ To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
9
+ [LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
10
+
11
+ ## Quick Install
12
+
13
+ ```bash
14
+ uv add langgraph-checkpoint-sqlite
15
+ ```
16
+
17
+ ## 🤔 What is this?
18
+
19
+ This library provides a SQLite implementation of LangGraph's checkpoint saver, with both sync and async support via `aiosqlite`. Use it when you want LangGraph state persistence backed by SQLite for local development, testing, or lightweight deployments.
20
+
21
+ ## 📖 Documentation
22
+
23
+ For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.sqlite). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
24
+
25
+ ## Security
26
+
27
+ > [!IMPORTANT]
28
+ > Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ from langgraph.checkpoint.sqlite import SqliteSaver
34
+
35
+ write_config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
36
+ read_config = {"configurable": {"thread_id": "1"}}
37
+
38
+ with SqliteSaver.from_conn_string(":memory:") as checkpointer:
39
+ checkpoint = {
40
+ "v": 4,
41
+ "ts": "2024-07-31T20:14:19.804150+00:00",
42
+ "id": "1ef4f797-8335-6428-8001-8a1503f9b875",
43
+ "channel_values": {"my_key": "meow", "node": "node"},
44
+ "channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
45
+ "versions_seen": {
46
+ "__input__": {},
47
+ "__start__": {"__start__": 1},
48
+ "node": {"start:node": 2},
49
+ },
50
+ }
51
+
52
+ # store checkpoint
53
+ checkpointer.put(write_config, checkpoint, {}, {})
54
+
55
+ # load checkpoint
56
+ checkpointer.get(read_config)
57
+
58
+ # list checkpoints
59
+ list(checkpointer.list(read_config))
60
+ ```
61
+
62
+ ### Async
63
+
64
+ ```python
65
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
66
+
67
+ async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
68
+ checkpoint = {
69
+ "v": 4,
70
+ "ts": "2024-07-31T20:14:19.804150+00:00",
71
+ "id": "1ef4f797-8335-6428-8001-8a1503f9b875",
72
+ "channel_values": {"my_key": "meow", "node": "node"},
73
+ "channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
74
+ "versions_seen": {
75
+ "__input__": {},
76
+ "__start__": {"__start__": 1},
77
+ "node": {"start:node": 2},
78
+ },
79
+ }
80
+
81
+ # store checkpoint
82
+ await checkpointer.aput(write_config, checkpoint, {}, {})
83
+
84
+ # load checkpoint
85
+ await checkpointer.aget(read_config)
86
+
87
+ # list checkpoints
88
+ [c async for c in checkpointer.alist(read_config)]
89
+ ```
90
+
91
+ ## 📕 Releases & Versioning
92
+
93
+ See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
94
+
95
+ ## 💁 Contributing
96
+
97
+ As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
98
+
99
+ For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
@@ -29,7 +29,11 @@ from langgraph.checkpoint.sqlite._delta import (
29
29
  build_delta_stage2_sql,
30
30
  step_walk_with_row,
31
31
  )
32
- from langgraph.checkpoint.sqlite.utils import search_where
32
+ from langgraph.checkpoint.sqlite.utils import (
33
+ load_pending_writes,
34
+ pending_writes_sql,
35
+ search_where,
36
+ )
33
37
 
34
38
  _AIO_ERROR_MSG = (
35
39
  "The SqliteSaver does not support async methods. "
@@ -77,10 +81,11 @@ class SqliteSaver(BaseCheckpointSaver[str]):
77
81
  >>> result = graph.invoke(3, config)
78
82
  >>> graph.get_state(config)
79
83
  StateSnapshot(values=4, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '0c62ca34-ac19-445d-bbb0-5b4984975b2a'}}, parent_config=None)
80
- """ # noqa
84
+ """
81
85
 
82
86
  conn: sqlite3.Connection
83
87
  is_setup: bool
88
+ _has_task_path: bool = True
84
89
 
85
90
  def __init__(
86
91
  self,
@@ -154,6 +159,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
154
159
  checkpoint_ns TEXT NOT NULL DEFAULT '',
155
160
  checkpoint_id TEXT NOT NULL,
156
161
  task_id TEXT NOT NULL,
162
+ task_path TEXT NOT NULL DEFAULT '',
157
163
  idx INTEGER NOT NULL,
158
164
  channel TEXT NOT NULL,
159
165
  type TEXT,
@@ -162,6 +168,19 @@ class SqliteSaver(BaseCheckpointSaver[str]):
162
168
  );
163
169
  """
164
170
  )
171
+ # sqlite has no ADD COLUMN IF NOT EXISTS; this migrates databases
172
+ # created before `task_path` existed and is a no-op on the rest.
173
+ try:
174
+ self.conn.execute(
175
+ "ALTER TABLE writes ADD COLUMN task_path TEXT NOT NULL DEFAULT ''"
176
+ )
177
+ except sqlite3.OperationalError as e:
178
+ # A read-only database from before the column can still be read;
179
+ # its rows would all read back as '' anyway.
180
+ if "readonly database" in str(e):
181
+ self._has_task_path = False
182
+ elif "duplicate column name" not in str(e):
183
+ raise
165
184
 
166
185
  self.is_setup = True
167
186
 
@@ -222,7 +241,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
222
241
  >>> checkpoint_tuple = memory.get_tuple(config)
223
242
  >>> print(checkpoint_tuple)
224
243
  CheckpointTuple(...)
225
- """ # noqa
244
+ """
226
245
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
227
246
  with self.cursor(transaction=False) as cur:
228
247
  # find the latest checkpoint for the thread_id
@@ -260,7 +279,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
260
279
  }
261
280
  # find any pending writes
262
281
  cur.execute(
263
- "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx",
282
+ pending_writes_sql(self._has_task_path),
264
283
  (
265
284
  str(config["configurable"]["thread_id"]),
266
285
  checkpoint_ns,
@@ -286,10 +305,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
286
305
  if parent_checkpoint_id
287
306
  else None
288
307
  ),
289
- [
290
- (task_id, channel, self.serde.loads_typed((type, value)))
291
- for task_id, channel, type, value in cur
292
- ],
308
+ load_pending_writes(cur, self.serde),
293
309
  )
294
310
 
295
311
  def list(
@@ -351,7 +367,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
351
367
  metadata,
352
368
  ) in cur:
353
369
  wcur.execute(
354
- "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx",
370
+ pending_writes_sql(self._has_task_path),
355
371
  (thread_id, checkpoint_ns, checkpoint_id),
356
372
  )
357
373
  yield CheckpointTuple(
@@ -378,10 +394,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
378
394
  if parent_checkpoint_id
379
395
  else None
380
396
  ),
381
- [
382
- (task_id, channel, self.serde.loads_typed((type, value)))
383
- for task_id, channel, type, value in wcur
384
- ],
397
+ load_pending_writes(wcur, self.serde),
385
398
  )
386
399
 
387
400
  def put(
@@ -460,9 +473,9 @@ class SqliteSaver(BaseCheckpointSaver[str]):
460
473
  task_path: Path of the task creating the writes.
461
474
  """
462
475
  query = (
463
- "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
476
+ "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
464
477
  if all(w[0] in WRITES_IDX_MAP for w in writes)
465
- else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
478
+ else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
466
479
  )
467
480
  with self.cursor() as cur:
468
481
  cur.executemany(
@@ -473,6 +486,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
473
486
  str(config["configurable"]["checkpoint_ns"]),
474
487
  str(config["configurable"]["checkpoint_id"]),
475
488
  task_id,
489
+ task_path,
476
490
  WRITES_IDX_MAP.get(channel, idx),
477
491
  channel,
478
492
  *self.serde.dumps_typed(value),
@@ -507,13 +521,12 @@ class SqliteSaver(BaseCheckpointSaver[str]):
507
521
 
508
522
  Two-stage query:
509
523
 
510
- * Stage 1 (paged): newest-first slice of `checkpoints` returning
511
- `(checkpoint_id, parent_checkpoint_id, type, checkpoint)` per
512
- ancestor. Sqlite has no JSONB, so we ship the full serialized
513
- checkpoint blob and inspect `channel_values` in Python. Pages
514
- newest-first by `checkpoint_id` with a `< cursor` predicate;
515
- page size is `DELTA_PAGE_SIZE`. Stops paging when every channel
516
- has found its seed or the chain is exhausted.
524
+ * Stage 1 (streamed): recursive CTE over `checkpoints` following
525
+ `parent_checkpoint_id` from the target, returning
526
+ `(checkpoint_id, type, checkpoint)` per ancestor. Sqlite has no
527
+ JSONB, so we ship the full serialized checkpoint blob and inspect
528
+ `channel_values` in Python. Stops reading when every channel has
529
+ found its seed or the chain is exhausted.
517
530
 
518
531
  * Stage 2 (per-channel UNION ALL): one branch per channel reading
519
532
  `writes` filtered to that channel's specific `chain_cids`. No
@@ -526,7 +539,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
526
539
  thread_id = str(config["configurable"]["thread_id"])
527
540
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
528
541
  checkpoint_id = get_checkpoint_id(config)
529
- if checkpoint_id is None:
542
+ if not checkpoint_id:
530
543
  target = self.get_tuple(config)
531
544
  if target is None:
532
545
  return {ch: {"writes": []} for ch in channels}
@@ -538,12 +551,14 @@ class SqliteSaver(BaseCheckpointSaver[str]):
538
551
  seeded: set[str] = set()
539
552
 
540
553
  with self.cursor(transaction=False) as cur:
541
- cur.execute(DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id))
554
+ cur.execute(
555
+ DELTA_STAGE1_SQL,
556
+ (thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
557
+ )
542
558
  for row in cur:
543
- cid, parent_cid, type_tag, blob = row
559
+ cid, type_tag, blob = row
544
560
  if step_walk_with_row(
545
561
  cid=cid,
546
- parent_cid=parent_cid,
547
562
  type_tag=type_tag,
548
563
  blob=blob,
549
564
  target_id=checkpoint_id,
@@ -558,6 +573,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
558
573
 
559
574
  channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
560
575
  stage2_sql = build_delta_stage2_sql(
576
+ has_task_path=self._has_task_path,
561
577
  chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
562
578
  )
563
579
  if stage2_sql:
@@ -568,7 +584,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
568
584
  )
569
585
  cur.execute(stage2_sql, stage2_params)
570
586
  stage2_rows = cast(
571
- "list[tuple[str, str, str, int, str, bytes]]", cur.fetchall()
587
+ "list[tuple[str, str, str, int, str, bytes, str]]", cur.fetchall()
572
588
  )
573
589
  else:
574
590
  stage2_rows = []
@@ -24,22 +24,45 @@ from __future__ import annotations
24
24
  from collections.abc import Mapping, Sequence
25
25
  from typing import Any
26
26
 
27
- from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite
27
+ from langgraph.checkpoint.base import (
28
+ DeltaChannelHistory,
29
+ PendingWrite,
30
+ writes_sort_key,
31
+ )
28
32
 
29
- # Stage 1 streams ancestors of `target_cid` newest-first. The `<=`
30
- # predicate keeps target itself in the stream so we can read its
31
- # `parent_checkpoint_id` from the first row without a separate lookup;
32
- # the caller skips target's own writes/seed (matches the
33
- # `BaseCheckpointSaver` contract).
33
+ # Stage 1 streams target, then its ancestors nearest-first, by following
34
+ # `parent_checkpoint_id` rather than id order: ids are only monotonic within
35
+ # one process, so a range scan by id can miss a parent whose id sorts above
36
+ # its child's. Target is the anchor row; its own writes/seed are skipped
37
+ # (matches the `BaseCheckpointSaver` contract).
38
+ #
39
+ # `put` is `INSERT OR REPLACE`, so re-putting an existing id under a
40
+ # descendant's config makes the chain a loop. `step_walk_with_row` stops on a
41
+ # repeated id; sqlite yields recursive rows lazily, so abandoning the cursor
42
+ # ends the recursion.
43
+ #
44
+ # `CROSS JOIN` pins `ancestors` as the outer loop, so each step is one primary
45
+ # key lookup. With a plain `JOIN` and no `ANALYZE` stats, sqlite can put
46
+ # `checkpoints` outside and scan the whole thread per step.
34
47
  DELTA_STAGE1_SQL = (
48
+ "WITH RECURSIVE ancestors(checkpoint_id, parent_checkpoint_id, type, "
49
+ "checkpoint) AS ("
35
50
  "SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
36
51
  "FROM checkpoints "
37
- "WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id <= ? "
38
- "ORDER BY checkpoint_id DESC"
52
+ "WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? "
53
+ "UNION ALL "
54
+ "SELECT c.checkpoint_id, c.parent_checkpoint_id, c.type, c.checkpoint "
55
+ "FROM ancestors a CROSS JOIN checkpoints c "
56
+ "ON c.checkpoint_id = a.parent_checkpoint_id "
57
+ "WHERE c.thread_id = ? AND c.checkpoint_ns = ?"
58
+ ") "
59
+ "SELECT checkpoint_id, type, checkpoint FROM ancestors"
39
60
  )
40
61
 
41
62
 
42
- def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
63
+ def build_delta_stage2_sql(
64
+ *, chain_lens: Sequence[int], has_task_path: bool = True
65
+ ) -> str:
43
66
  """Stage-2 per-channel UNION ALL fetching writes from `writes`.
44
67
 
45
68
  One branch per channel with a non-empty chain. Each branch inlines its
@@ -53,11 +76,12 @@ def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
53
76
  of a single `channel = ANY(channels)` filter when channels have
54
77
  different chain depths — same rationale as postgres.
55
78
  """
79
+ task_path = "task_path" if has_task_path else "''"
56
80
  branches: list[str] = []
57
81
  for n in chain_lens:
58
82
  cid_placeholders = ",".join("?" * n)
59
83
  branches.append(
60
- "SELECT checkpoint_id, channel, task_id, idx, type, value "
84
+ f"SELECT checkpoint_id, channel, task_id, idx, type, value, {task_path} "
61
85
  "FROM writes "
62
86
  "WHERE thread_id = ? AND checkpoint_ns = ? AND channel = ? "
63
87
  f"AND checkpoint_id IN ({cid_placeholders})"
@@ -68,7 +92,6 @@ def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
68
92
  def step_walk_with_row(
69
93
  *,
70
94
  cid: str,
71
- parent_cid: str | None,
72
95
  type_tag: str,
73
96
  blob: bytes,
74
97
  target_id: str,
@@ -81,36 +104,32 @@ def step_walk_with_row(
81
104
  ) -> bool:
82
105
  """Process one streamed stage-1 row in the merged ancestor walk.
83
106
 
84
- The cursor returns (cid, parent_cid, type, blob) rows in
85
- `checkpoint_id` DESC order starting at target. The first row is
86
- target itself; we read its parent_cid to seed the walk and otherwise
87
- skip it (target's own writes/seed are not part of the contract).
107
+ The cursor returns (cid, type, blob) rows in walk order starting at
108
+ target. The first row is target itself and is skipped (target's own
109
+ writes/seed are not part of the contract).
88
110
 
89
- For each subsequent row, if `cid` matches the walk's current
90
- position, we deserialize the blob, append the cid to every
91
- not-yet-seeded channel's chain, and check `channel_values` for
111
+ For each subsequent row we deserialize the blob, append the cid to
112
+ every not-yet-seeded channel's chain, and check `channel_values` for
92
113
  seeds. The deserialized checkpoint is dropped before advancing — no
93
114
  cross-row cache, so peak in-flight is one deserialized checkpoint.
94
115
 
95
- Off-path rows (different branch on the same thread) advance the
96
- cursor without doing any work.
97
-
98
- Returns True when every requested channel is seeded — the caller
99
- can stop iterating and close the cursor.
116
+ Returns True when the caller can stop iterating and close the cursor:
117
+ every requested channel is seeded, or the chain revisited a checkpoint.
100
118
  """
101
119
  if "started" not in walk_state:
102
120
  if cid == target_id:
103
121
  walk_state["started"] = True
104
- walk_state["cur_cid"] = parent_cid
105
122
  walk_state["active"] = {ch for ch in channels if ch not in seeded}
123
+ walk_state["walked"] = {cid}
106
124
  # Not target yet (or target not present): keep streaming.
107
125
  return False
108
126
  active: set[str] = walk_state["active"]
109
127
  if not active:
110
128
  return True
111
- if cid != walk_state["cur_cid"]:
112
- # Off-path row from a sibling branch — skip without deserializing.
113
- return False
129
+ walked: set[str] = walk_state["walked"]
130
+ if cid in walked:
131
+ return True
132
+ walked.add(cid)
114
133
  for ch in active:
115
134
  chain_by_ch[ch].append(cid)
116
135
  ckpt = serde.loads_typed((type_tag, blob))
@@ -120,7 +139,6 @@ def step_walk_with_row(
120
139
  seeded.add(ch)
121
140
  active.discard(ch)
122
141
  del ckpt, channel_values
123
- walk_state["cur_cid"] = parent_cid
124
142
  return not active
125
143
 
126
144
 
@@ -130,29 +148,31 @@ def build_delta_channels_writes_history(
130
148
  chain_by_ch: Mapping[str, list[str]],
131
149
  seed_val_by_ch: Mapping[str, Any],
132
150
  seeded: set[str],
133
- stage2_rows: Sequence[tuple[str, str, str, int, str, bytes]],
151
+ stage2_rows: Sequence[tuple[str, str, str, int, str, bytes, str]],
134
152
  serde: Any,
135
153
  ) -> dict[str, DeltaChannelHistory]:
136
154
  """Demux stage-2 rows per channel; produce per-channel histories.
137
155
 
138
- Stage-2 rows are `(checkpoint_id, channel, task_id, idx, type, value)`.
139
- Final write order is oldest→newest globally and `(task_id, idx)` within
140
- a checkpoint, matching the contract on `DeltaChannelHistory.writes`.
156
+ Stage-2 rows are
157
+ `(checkpoint_id, channel, task_id, idx, type, value, task_path)`.
158
+ Final write order is oldest→newest globally and `writes_sort_key`
159
+ within a checkpoint, matching the contract on
160
+ `DeltaChannelHistory.writes`.
141
161
 
142
162
  `seed` is omitted when the walk reached a true root with no snapshot
143
163
  found (channel never entered `seeded`); consumers treat absence as
144
164
  "start empty".
145
165
  """
146
- writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = {
147
- ch: {} for ch in channels
148
- }
149
- for cid, ch, task_id, idx, type_tag, value_blob in stage2_rows:
166
+ writes_by_ch_by_cid: dict[
167
+ str, dict[str, list[tuple[str, bytes, str, int, str]]]
168
+ ] = {ch: {} for ch in channels}
169
+ for cid, ch, task_id, idx, type_tag, value_blob, task_path in stage2_rows:
150
170
  writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append(
151
- (type_tag, value_blob, task_id, idx)
171
+ (type_tag, value_blob, task_id, idx, task_path)
152
172
  )
153
173
  for cid_map in writes_by_ch_by_cid.values():
154
174
  for ws in cid_map.values():
155
- ws.sort(key=lambda w: (w[2], w[3]))
175
+ ws.sort(key=lambda w: writes_sort_key(w[4], w[2], w[3]))
156
176
 
157
177
  result: dict[str, DeltaChannelHistory] = {}
158
178
  for ch in channels:
@@ -161,7 +181,7 @@ def build_delta_channels_writes_history(
161
181
  collected: list[PendingWrite] = []
162
182
  # Chain is newest-first; iterate oldest-first for the public order.
163
183
  for cid in reversed(chain_cids):
164
- for type_tag, value_blob, task_id, _idx in cid_writes.get(cid, []):
184
+ for type_tag, value_blob, task_id, _idx, _path in cid_writes.get(cid, []):
165
185
  collected.append(
166
186
  (task_id, ch, serde.loads_typed((type_tag, value_blob)))
167
187
  )