langgraph-checkpoint-sqlite 3.0.3__tar.gz → 3.1.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.
Files changed (25) hide show
  1. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/.gitignore +1 -0
  2. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/Makefile +4 -1
  3. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/PKG-INFO +8 -3
  4. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/README.md +5 -0
  5. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/checkpoint/sqlite/__init__.py +90 -1
  6. langgraph_checkpoint_sqlite-3.1.0/langgraph/checkpoint/sqlite/_delta.py +172 -0
  7. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/checkpoint/sqlite/aio.py +108 -1
  8. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/pyproject.toml +3 -3
  9. langgraph_checkpoint_sqlite-3.1.0/tests/test_conformance_delta.py +35 -0
  10. langgraph_checkpoint_sqlite-3.1.0/tests/test_delta_channel_migration.py +170 -0
  11. langgraph_checkpoint_sqlite-3.1.0/tests/test_get_delta_channel_history.py +271 -0
  12. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/uv.lock +384 -224
  13. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/LICENSE +0 -0
  14. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/cache/sqlite/__init__.py +0 -0
  15. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/checkpoint/sqlite/py.typed +0 -0
  16. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/checkpoint/sqlite/utils.py +0 -0
  17. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/store/sqlite/__init__.py +0 -0
  18. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/store/sqlite/aio.py +0 -0
  19. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/store/sqlite/base.py +0 -0
  20. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/__init__.py +0 -0
  21. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_aiosqlite.py +0 -0
  22. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_async_store.py +0 -0
  23. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_sqlite.py +0 -0
  24. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_store.py +0 -0
  25. {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_ttl.py +0 -0
@@ -100,3 +100,4 @@ dmypy.json
100
100
  .turbo
101
101
  .editorconfig
102
102
  .scratch
103
+ .worktrees/
@@ -1,4 +1,4 @@
1
- .PHONY: test test_watch lint format
1
+ .PHONY: test test_watch lint type format
2
2
 
3
3
  ######################
4
4
  # TESTING AND COVERAGE
@@ -32,6 +32,9 @@ lint lint_diff lint_package lint_tests:
32
32
  [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
33
33
  [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
34
34
 
35
+ type:
36
+ mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
37
+
35
38
  format format_diff:
36
39
  uv run ruff format $(PYTHON_FILES)
37
40
  uv run ruff check --select I --fix $(PYTHON_FILES)
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-checkpoint-sqlite
3
- Version: 3.0.3
3
+ Version: 3.1.0
4
4
  Summary: Library with a SQLite implementation of LangGraph checkpoint saver.
5
5
  Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite
6
- Project-URL: Twitter, https://x.com/LangChain
6
+ Project-URL: Twitter, https://x.com/langchain_oss
7
7
  Project-URL: Slack, https://www.langchain.com/join-community
8
8
  Project-URL: Reddit, https://www.reddit.com/r/LangChain/
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.10
12
12
  Requires-Dist: aiosqlite>=0.20
13
- Requires-Dist: langgraph-checkpoint<5.0.0,>=3
13
+ Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.0
14
14
  Requires-Dist: sqlite-vec>=0.1.6
15
15
  Description-Content-Type: text/markdown
16
16
 
@@ -18,6 +18,11 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  Implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via `aiosqlite`)
20
20
 
21
+ ## Security
22
+
23
+ > [!IMPORTANT]
24
+ > 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.
25
+
21
26
  ## Usage
22
27
 
23
28
  ```python
@@ -2,6 +2,11 @@
2
2
 
3
3
  Implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via `aiosqlite`)
4
4
 
5
+ ## Security
6
+
7
+ > [!IMPORTANT]
8
+ > 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.
9
+
5
10
  ## Usage
6
11
 
7
12
  ```python
@@ -4,7 +4,7 @@ import json
4
4
  import random
5
5
  import sqlite3
6
6
  import threading
7
- from collections.abc import AsyncIterator, Iterator, Sequence
7
+ from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
8
8
  from contextlib import closing, contextmanager
9
9
  from typing import Any, cast
10
10
 
@@ -16,12 +16,19 @@ from langgraph.checkpoint.base import (
16
16
  Checkpoint,
17
17
  CheckpointMetadata,
18
18
  CheckpointTuple,
19
+ DeltaChannelHistory,
19
20
  SerializerProtocol,
20
21
  get_checkpoint_id,
21
22
  get_checkpoint_metadata,
22
23
  )
23
24
  from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
24
25
 
26
+ from langgraph.checkpoint.sqlite._delta import (
27
+ DELTA_STAGE1_SQL,
28
+ build_delta_channels_writes_history,
29
+ build_delta_stage2_sql,
30
+ step_walk_with_row,
31
+ )
25
32
  from langgraph.checkpoint.sqlite.utils import search_where
26
33
 
27
34
  _AIO_ERROR_MSG = (
@@ -493,6 +500,88 @@ class SqliteSaver(BaseCheckpointSaver[str]):
493
500
  (str(thread_id),),
494
501
  )
495
502
 
503
+ def get_delta_channel_history(
504
+ self, *, config: RunnableConfig, channels: Sequence[str]
505
+ ) -> Mapping[str, DeltaChannelHistory]:
506
+ """Fast-path override of `BaseCheckpointSaver.get_delta_channel_history`.
507
+
508
+ Two-stage query:
509
+
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.
517
+
518
+ * Stage 2 (per-channel UNION ALL): one branch per channel reading
519
+ `writes` filtered to that channel's specific `chain_cids`. No
520
+ separate seed-blob fetch — sqlite stores `channel_values` inline
521
+ in the checkpoint blob, so seeds come back from stage 1.
522
+ """
523
+ if not channels:
524
+ return {}
525
+ channels = list(channels)
526
+ thread_id = str(config["configurable"]["thread_id"])
527
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
528
+ checkpoint_id = get_checkpoint_id(config)
529
+ if checkpoint_id is None:
530
+ target = self.get_tuple(config)
531
+ if target is None:
532
+ return {ch: {"writes": []} for ch in channels}
533
+ checkpoint_id = target.config["configurable"]["checkpoint_id"]
534
+
535
+ chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
536
+ seed_val_by_ch: dict[str, Any] = {}
537
+ walk_state: dict[str, Any] = {}
538
+ seeded: set[str] = set()
539
+
540
+ with self.cursor(transaction=False) as cur:
541
+ cur.execute(DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id))
542
+ for row in cur:
543
+ cid, parent_cid, type_tag, blob = row
544
+ if step_walk_with_row(
545
+ cid=cid,
546
+ parent_cid=parent_cid,
547
+ type_tag=type_tag,
548
+ blob=blob,
549
+ target_id=checkpoint_id,
550
+ serde=self.serde,
551
+ chain_by_ch=chain_by_ch,
552
+ seed_val_by_ch=seed_val_by_ch,
553
+ walk_state=walk_state,
554
+ seeded=seeded,
555
+ channels=channels,
556
+ ):
557
+ break
558
+
559
+ channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
560
+ stage2_sql = build_delta_stage2_sql(
561
+ chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
562
+ )
563
+ if stage2_sql:
564
+ stage2_params: list[Any] = []
565
+ for ch in channels_with_chain:
566
+ stage2_params.extend(
567
+ [thread_id, checkpoint_ns, ch, *chain_by_ch[ch]]
568
+ )
569
+ cur.execute(stage2_sql, stage2_params)
570
+ stage2_rows = cast(
571
+ "list[tuple[str, str, str, int, str, bytes]]", cur.fetchall()
572
+ )
573
+ else:
574
+ stage2_rows = []
575
+
576
+ return build_delta_channels_writes_history(
577
+ channels=channels,
578
+ chain_by_ch=chain_by_ch,
579
+ seed_val_by_ch=seed_val_by_ch,
580
+ seeded=seeded,
581
+ stage2_rows=stage2_rows,
582
+ serde=self.serde,
583
+ )
584
+
496
585
  async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
497
586
  """Get a checkpoint tuple from the database asynchronously.
498
587
 
@@ -0,0 +1,172 @@
1
+ """Shared helpers for `get_delta_channel_history` on sqlite savers.
2
+
3
+ Mirrors the two-stage shape of `BasePostgresSaver` (ancestor walk +
4
+ per-channel UNION ALL writes fetch), but adapted for sqlite's
5
+ constraints. The structural differences:
6
+
7
+ * No JSONB — to inspect `channel_values` for a checkpoint we must
8
+ deserialize the full blob. Stage 1 streams the cursor row-by-row and
9
+ deserializes only the rows the merged walk visits, freeing each blob
10
+ before advancing.
11
+ * No separate blob table — `channel_values` lives inline in the
12
+ checkpoint, so seeds come back from stage 1 with no second fetch.
13
+ * Single merged walk (not K independent walks): each visited cid is
14
+ deserialized exactly once, regardless of how many channels are still
15
+ seeking their seed.
16
+
17
+ The streaming design keeps peak in-flight memory at roughly one
18
+ deserialized checkpoint at a time, instead of holding the entire
19
+ ancestor chain's worth of raw blobs as a `fetchall()`-materialized list.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Mapping, Sequence
25
+ from typing import Any
26
+
27
+ from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite
28
+
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).
34
+ DELTA_STAGE1_SQL = (
35
+ "SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
36
+ "FROM checkpoints "
37
+ "WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id <= ? "
38
+ "ORDER BY checkpoint_id DESC"
39
+ )
40
+
41
+
42
+ def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
43
+ """Stage-2 per-channel UNION ALL fetching writes from `writes`.
44
+
45
+ One branch per channel with a non-empty chain. Each branch inlines its
46
+ own `IN (?, ?, ...)` placeholder list because sqlite has no array-bind
47
+ equivalent of postgres's `= ANY(%s)`. Caller passes parameters in
48
+ matching order: `[thread_id, checkpoint_ns, channel, *chain_cids]` per
49
+ branch.
50
+
51
+ Returns an empty string when no channel has a chain (caller skips
52
+ executing in that case). Per-channel UNION ALL avoids the over-fetch
53
+ of a single `channel = ANY(channels)` filter when channels have
54
+ different chain depths — same rationale as postgres.
55
+ """
56
+ branches: list[str] = []
57
+ for n in chain_lens:
58
+ cid_placeholders = ",".join("?" * n)
59
+ branches.append(
60
+ "SELECT checkpoint_id, channel, task_id, idx, type, value "
61
+ "FROM writes "
62
+ "WHERE thread_id = ? AND checkpoint_ns = ? AND channel = ? "
63
+ f"AND checkpoint_id IN ({cid_placeholders})"
64
+ )
65
+ return " UNION ALL ".join(branches)
66
+
67
+
68
+ def step_walk_with_row(
69
+ *,
70
+ cid: str,
71
+ parent_cid: str | None,
72
+ type_tag: str,
73
+ blob: bytes,
74
+ target_id: str,
75
+ serde: Any,
76
+ chain_by_ch: dict[str, list[str]],
77
+ seed_val_by_ch: dict[str, Any],
78
+ walk_state: dict[str, Any],
79
+ seeded: set[str],
80
+ channels: Sequence[str],
81
+ ) -> bool:
82
+ """Process one streamed stage-1 row in the merged ancestor walk.
83
+
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).
88
+
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
92
+ seeds. The deserialized checkpoint is dropped before advancing — no
93
+ cross-row cache, so peak in-flight is one deserialized checkpoint.
94
+
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.
100
+ """
101
+ if "started" not in walk_state:
102
+ if cid == target_id:
103
+ walk_state["started"] = True
104
+ walk_state["cur_cid"] = parent_cid
105
+ walk_state["active"] = {ch for ch in channels if ch not in seeded}
106
+ # Not target yet (or target not present): keep streaming.
107
+ return False
108
+ active: set[str] = walk_state["active"]
109
+ if not active:
110
+ return True
111
+ if cid != walk_state["cur_cid"]:
112
+ # Off-path row from a sibling branch — skip without deserializing.
113
+ return False
114
+ for ch in active:
115
+ chain_by_ch[ch].append(cid)
116
+ ckpt = serde.loads_typed((type_tag, blob))
117
+ channel_values: Mapping[str, Any] = ckpt.get("channel_values") or {}
118
+ for ch in [ch for ch in active if ch in channel_values]:
119
+ seed_val_by_ch[ch] = channel_values[ch]
120
+ seeded.add(ch)
121
+ active.discard(ch)
122
+ del ckpt, channel_values
123
+ walk_state["cur_cid"] = parent_cid
124
+ return not active
125
+
126
+
127
+ def build_delta_channels_writes_history(
128
+ *,
129
+ channels: Sequence[str],
130
+ chain_by_ch: Mapping[str, list[str]],
131
+ seed_val_by_ch: Mapping[str, Any],
132
+ seeded: set[str],
133
+ stage2_rows: Sequence[tuple[str, str, str, int, str, bytes]],
134
+ serde: Any,
135
+ ) -> dict[str, DeltaChannelHistory]:
136
+ """Demux stage-2 rows per channel; produce per-channel histories.
137
+
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`.
141
+
142
+ `seed` is omitted when the walk reached a true root with no snapshot
143
+ found (channel never entered `seeded`); consumers treat absence as
144
+ "start empty".
145
+ """
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:
150
+ writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append(
151
+ (type_tag, value_blob, task_id, idx)
152
+ )
153
+ for cid_map in writes_by_ch_by_cid.values():
154
+ for ws in cid_map.values():
155
+ ws.sort(key=lambda w: (w[2], w[3]))
156
+
157
+ result: dict[str, DeltaChannelHistory] = {}
158
+ for ch in channels:
159
+ chain_cids = chain_by_ch.get(ch, [])
160
+ cid_writes = writes_by_ch_by_cid.get(ch, {})
161
+ collected: list[PendingWrite] = []
162
+ # Chain is newest-first; iterate oldest-first for the public order.
163
+ for cid in reversed(chain_cids):
164
+ for type_tag, value_blob, task_id, _idx in cid_writes.get(cid, []):
165
+ collected.append(
166
+ (task_id, ch, serde.loads_typed((type_tag, value_blob)))
167
+ )
168
+ entry: DeltaChannelHistory = {"writes": collected}
169
+ if ch in seeded:
170
+ entry["seed"] = seed_val_by_ch[ch]
171
+ result[ch] = entry
172
+ return result
@@ -4,7 +4,7 @@ import asyncio
4
4
  import json
5
5
  import random
6
6
  import threading
7
- from collections.abc import AsyncIterator, Callable, Iterator, Sequence
7
+ from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
8
8
  from contextlib import asynccontextmanager
9
9
  from typing import Any, TypeVar, cast
10
10
 
@@ -17,12 +17,19 @@ from langgraph.checkpoint.base import (
17
17
  Checkpoint,
18
18
  CheckpointMetadata,
19
19
  CheckpointTuple,
20
+ DeltaChannelHistory,
20
21
  SerializerProtocol,
21
22
  get_checkpoint_id,
22
23
  get_checkpoint_metadata,
23
24
  )
24
25
  from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
25
26
 
27
+ from langgraph.checkpoint.sqlite._delta import (
28
+ DELTA_STAGE1_SQL,
29
+ build_delta_channels_writes_history,
30
+ build_delta_stage2_sql,
31
+ step_walk_with_row,
32
+ )
26
33
  from langgraph.checkpoint.sqlite.utils import search_where
27
34
 
28
35
  T = TypeVar("T", bound=Callable)
@@ -272,6 +279,29 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
272
279
  self.adelete_thread(thread_id), self.loop
273
280
  ).result()
274
281
 
282
+ def get_delta_channel_history(
283
+ self, *, config: RunnableConfig, channels: Sequence[str]
284
+ ) -> Mapping[str, DeltaChannelHistory]:
285
+ """Sync bridge to `aget_delta_channel_history`.
286
+
287
+ Mirrors the same cross-thread guard as `get_tuple` /
288
+ `delete_thread` — calling from the loop thread raises rather than
289
+ deadlocking.
290
+ """
291
+ try:
292
+ if asyncio.get_running_loop() is self.loop:
293
+ raise asyncio.InvalidStateError(
294
+ "Synchronous calls to AsyncSqliteSaver are only allowed from a "
295
+ "different thread. From the main thread, use the async interface. "
296
+ "For example, use `await checkpointer.aget_delta_channel_history(...)`."
297
+ )
298
+ except RuntimeError:
299
+ pass
300
+ return asyncio.run_coroutine_threadsafe(
301
+ self.aget_delta_channel_history(config=config, channels=channels),
302
+ self.loop,
303
+ ).result()
304
+
275
305
  async def setup(self) -> None:
276
306
  """Set up the checkpoint database asynchronously.
277
307
 
@@ -589,6 +619,83 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
589
619
  )
590
620
  await self.conn.commit()
591
621
 
622
+ async def aget_delta_channel_history(
623
+ self, *, config: RunnableConfig, channels: Sequence[str]
624
+ ) -> Mapping[str, DeltaChannelHistory]:
625
+ """Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
626
+
627
+ See `SqliteSaver.get_delta_channel_history` for design notes; this
628
+ is the async equivalent using `aiosqlite` cursors. Stage 1 pages
629
+ the parent chain newest-first and Python-deserializes each
630
+ checkpoint blob to find per-channel snapshots; stage 2 fetches
631
+ only the relevant writes via per-channel UNION ALL.
632
+ """
633
+ if not channels:
634
+ return {}
635
+ channels = list(channels)
636
+ await self.setup()
637
+ thread_id = str(config["configurable"]["thread_id"])
638
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
639
+ checkpoint_id = get_checkpoint_id(config)
640
+ if checkpoint_id is None:
641
+ target = await self.aget_tuple(config)
642
+ if target is None:
643
+ return {ch: {"writes": []} for ch in channels}
644
+ checkpoint_id = target.config["configurable"]["checkpoint_id"]
645
+
646
+ chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
647
+ seed_val_by_ch: dict[str, Any] = {}
648
+ walk_state: dict[str, Any] = {}
649
+ seeded: set[str] = set()
650
+
651
+ async with self.lock, self.conn.cursor() as cur:
652
+ await cur.execute(
653
+ DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id)
654
+ )
655
+ async for row in cur:
656
+ cid, parent_cid, type_tag, blob = row
657
+ if step_walk_with_row(
658
+ cid=cid,
659
+ parent_cid=parent_cid,
660
+ type_tag=type_tag,
661
+ blob=blob,
662
+ target_id=checkpoint_id,
663
+ serde=self.serde,
664
+ chain_by_ch=chain_by_ch,
665
+ seed_val_by_ch=seed_val_by_ch,
666
+ walk_state=walk_state,
667
+ seeded=seeded,
668
+ channels=channels,
669
+ ):
670
+ break
671
+
672
+ channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
673
+ stage2_sql = build_delta_stage2_sql(
674
+ chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
675
+ )
676
+ if stage2_sql:
677
+ stage2_params: list[Any] = []
678
+ for ch in channels_with_chain:
679
+ stage2_params.extend(
680
+ [thread_id, checkpoint_ns, ch, *chain_by_ch[ch]]
681
+ )
682
+ await cur.execute(stage2_sql, stage2_params)
683
+ stage2_rows = cast(
684
+ "list[tuple[str, str, str, int, str, bytes]]",
685
+ await cur.fetchall(),
686
+ )
687
+ else:
688
+ stage2_rows = []
689
+
690
+ return build_delta_channels_writes_history(
691
+ channels=channels,
692
+ chain_by_ch=chain_by_ch,
693
+ seed_val_by_ch=seed_val_by_ch,
694
+ seeded=seeded,
695
+ stage2_rows=stage2_rows,
696
+ serde=self.serde,
697
+ )
698
+
592
699
  def get_next_version(self, current: str | None, channel: None) -> str:
593
700
  """Generate the next version ID for a channel.
594
701
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langgraph-checkpoint-sqlite"
7
- version = "3.0.3"
7
+ version = "3.1.0"
8
8
  description = "Library with a SQLite implementation of LangGraph checkpoint saver."
9
9
  authors = []
10
10
  requires-python = ">=3.10"
@@ -12,14 +12,14 @@ readme = "README.md"
12
12
  license = "MIT"
13
13
  license-files = ['LICENSE']
14
14
  dependencies = [
15
- "langgraph-checkpoint>=3,<5.0.0",
15
+ "langgraph-checkpoint>=4.1.0,<5.0.0",
16
16
  "aiosqlite>=0.20",
17
17
  "sqlite-vec>=0.1.6",
18
18
  ]
19
19
 
20
20
  [project.urls]
21
21
  Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite"
22
- Twitter = "https://x.com/LangChain"
22
+ Twitter = "https://x.com/langchain_oss"
23
23
  Slack = "https://www.langchain.com/join-community"
24
24
  Reddit = "https://www.reddit.com/r/LangChain/"
25
25
 
@@ -0,0 +1,35 @@
1
+ """Run delta-channel conformance capabilities against AsyncSqliteSaver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ pytest.importorskip(
8
+ "langgraph.checkpoint.conformance",
9
+ reason="langgraph-checkpoint-conformance not installed",
10
+ )
11
+ pytest.importorskip("aiosqlite", reason="aiosqlite not installed")
12
+
13
+
14
+ @pytest.mark.asyncio
15
+ async def test_delta_channel_conformance():
16
+ from langgraph.checkpoint.conformance import validate
17
+ from langgraph.checkpoint.conformance.initializer import checkpointer_test
18
+
19
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
20
+
21
+ @checkpointer_test(name="AsyncSqliteSaver")
22
+ async def sqlite_saver():
23
+ async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
24
+ yield saver
25
+
26
+ report = await validate(
27
+ sqlite_saver,
28
+ capabilities={
29
+ "delta_channel_history",
30
+ },
31
+ )
32
+ for cap, result in report.results.items():
33
+ if result.passed is False:
34
+ details = "\n".join(result.failures or [])
35
+ pytest.fail(f"Capability {cap} failed:\n{details}")