langgraph-checkpoint-postgres 3.0.5__tar.gz → 3.1.0a3__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 (26) hide show
  1. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/.gitignore +1 -0
  2. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/PKG-INFO +8 -3
  3. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/README.md +5 -0
  4. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/__init__.py +67 -3
  5. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/aio.py +67 -3
  6. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/base.py +142 -1
  7. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/pyproject.toml +3 -3
  8. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/test_async.py +46 -2
  9. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/uv.lock +357 -214
  10. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/LICENSE +0 -0
  11. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/Makefile +0 -0
  12. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
  13. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/_internal.py +0 -0
  14. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/py.typed +0 -0
  15. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/shallow.py +0 -0
  16. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/store/postgres/__init__.py +0 -0
  17. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/store/postgres/aio.py +0 -0
  18. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/store/postgres/base.py +0 -0
  19. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/store/postgres/py.typed +0 -0
  20. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/__init__.py +0 -0
  21. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/compose-postgres.yml +0 -0
  22. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/conftest.py +0 -0
  23. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/embed_test_utils.py +0 -0
  24. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/test_async_store.py +0 -0
  25. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/test_store.py +0 -0
  26. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/test_sync.py +0 -0
@@ -100,3 +100,4 @@ dmypy.json
100
100
  .turbo
101
101
  .editorconfig
102
102
  .scratch
103
+ .worktrees/
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 3.0.5
3
+ Version: 3.1.0a3
4
4
  Summary: Library with a Postgres implementation of LangGraph checkpoint saver.
5
5
  Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres
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
- Requires-Dist: langgraph-checkpoint<5.0.0,>=2.1.2
12
+ Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.0a3
13
13
  Requires-Dist: orjson>=3.11.5
14
14
  Requires-Dist: psycopg-pool>=3.2.0
15
15
  Requires-Dist: psycopg>=3.2.0
@@ -23,6 +23,11 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
23
23
 
24
24
  By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
25
25
 
26
+ ## Security
27
+
28
+ > [!IMPORTANT]
29
+ > 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.
30
+
26
31
  ## Usage
27
32
 
28
33
  > [!IMPORTANT]
@@ -6,6 +6,11 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
6
6
 
7
7
  By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
8
8
 
9
+ ## Security
10
+
11
+ > [!IMPORTANT]
12
+ > 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.
13
+
9
14
  ## Usage
10
15
 
11
16
  > [!IMPORTANT]
@@ -4,26 +4,35 @@ import threading
4
4
  from collections import defaultdict
5
5
  from collections.abc import Iterator, Sequence
6
6
  from contextlib import contextmanager
7
- from typing import Any
7
+ from typing import Any, cast
8
8
 
9
9
  from langchain_core.runnables import RunnableConfig
10
10
  from langgraph.checkpoint.base import (
11
+ DELTA_SENTINEL,
11
12
  WRITES_IDX_MAP,
12
13
  ChannelVersions,
13
14
  Checkpoint,
14
15
  CheckpointMetadata,
15
16
  CheckpointTuple,
17
+ _ChannelWritesHistory,
16
18
  get_checkpoint_id,
17
19
  get_serializable_checkpoint_metadata,
18
20
  )
19
21
  from langgraph.checkpoint.serde.base import SerializerProtocol
22
+ from langgraph.checkpoint.serde.types import _DeltaSnapshot
20
23
  from psycopg import Capabilities, Connection, Cursor, Pipeline
21
24
  from psycopg.rows import DictRow, dict_row
22
25
  from psycopg.types.json import Jsonb
23
26
  from psycopg_pool import ConnectionPool
24
27
 
25
28
  from langgraph.checkpoint.postgres import _internal
26
- from langgraph.checkpoint.postgres.base import BasePostgresSaver
29
+ from langgraph.checkpoint.postgres.base import (
30
+ SELECT_DELTA_STAGE1_SQL,
31
+ SELECT_DELTA_STAGE2_SQL,
32
+ BasePostgresSaver,
33
+ _DeltaStage1Row,
34
+ _DeltaStage2Row,
35
+ )
27
36
  from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
28
37
 
29
38
  Conn = _internal.Conn # For backward compatibility
@@ -302,7 +311,12 @@ class PostgresSaver(BasePostgresSaver):
302
311
  # others are stored in blobs table
303
312
  blob_values = {}
304
313
  for k, v in checkpoint["channel_values"].items():
305
- if v is None or isinstance(v, (str, int, float, bool)):
314
+ if v is DELTA_SENTINEL:
315
+ copy["channel_values"].pop(k)
316
+ elif isinstance(v, _DeltaSnapshot):
317
+ blob_values[k] = copy["channel_values"].pop(k)
318
+ copy["channel_values"][k] = True
319
+ elif v is None or isinstance(v, (str, int, float, bool)):
306
320
  pass
307
321
  else:
308
322
  blob_values[k] = copy["channel_values"].pop(k)
@@ -430,6 +444,56 @@ class PostgresSaver(BasePostgresSaver):
430
444
  with conn.cursor(binary=True, row_factory=dict_row) as cur:
431
445
  yield cur
432
446
 
447
+ def _get_channel_writes_history(
448
+ self, config: RunnableConfig, channel: str
449
+ ) -> _ChannelWritesHistory:
450
+ """Fast-path override of `BaseCheckpointSaver._get_channel_writes_history`.
451
+
452
+ Two-stage query: stage 1 scans checkpoint metadata to walk the parent
453
+ chain and locate the nearest snapshot; stage 2 fetches only the
454
+ chain-limited writes and single seed blob.
455
+ """
456
+ thread_id = config["configurable"]["thread_id"]
457
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
458
+ checkpoint_id = get_checkpoint_id(config)
459
+ if checkpoint_id is None:
460
+ target = self.get_tuple(config)
461
+ if target is None:
462
+ return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
463
+ checkpoint_id = target.config["configurable"]["checkpoint_id"]
464
+
465
+ with self._cursor() as cur:
466
+ cur.execute(
467
+ SELECT_DELTA_STAGE1_SQL,
468
+ (channel, channel, thread_id, checkpoint_ns),
469
+ )
470
+ stage1_rows = cur.fetchall()
471
+ chain_cids, seed_version = self._walk_stage1(
472
+ cast("list[_DeltaStage1Row]", stage1_rows), checkpoint_id
473
+ )
474
+ seed_versions = [seed_version] if seed_version else []
475
+ with self._cursor() as cur:
476
+ cur.execute(
477
+ SELECT_DELTA_STAGE2_SQL,
478
+ (
479
+ thread_id,
480
+ checkpoint_ns,
481
+ channel,
482
+ chain_cids,
483
+ thread_id,
484
+ checkpoint_ns,
485
+ channel,
486
+ seed_versions,
487
+ ),
488
+ )
489
+ stage2_rows = cur.fetchall()
490
+ return self._build_delta_channel_writes_history(
491
+ channel=channel,
492
+ chain_cids=chain_cids,
493
+ seed_version=seed_version,
494
+ stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
495
+ )
496
+
433
497
  def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
434
498
  """
435
499
  Convert a database row into a CheckpointTuple object.
@@ -4,26 +4,35 @@ import asyncio
4
4
  from collections import defaultdict
5
5
  from collections.abc import AsyncIterator, Iterator, Sequence
6
6
  from contextlib import asynccontextmanager
7
- from typing import Any
7
+ from typing import Any, cast
8
8
 
9
9
  from langchain_core.runnables import RunnableConfig
10
10
  from langgraph.checkpoint.base import (
11
+ DELTA_SENTINEL,
11
12
  WRITES_IDX_MAP,
12
13
  ChannelVersions,
13
14
  Checkpoint,
14
15
  CheckpointMetadata,
15
16
  CheckpointTuple,
17
+ _ChannelWritesHistory,
16
18
  get_checkpoint_id,
17
19
  get_serializable_checkpoint_metadata,
18
20
  )
19
21
  from langgraph.checkpoint.serde.base import SerializerProtocol
22
+ from langgraph.checkpoint.serde.types import _DeltaSnapshot
20
23
  from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
21
24
  from psycopg.rows import DictRow, dict_row
22
25
  from psycopg.types.json import Jsonb
23
26
  from psycopg_pool import AsyncConnectionPool
24
27
 
25
28
  from langgraph.checkpoint.postgres import _ainternal
26
- from langgraph.checkpoint.postgres.base import BasePostgresSaver
29
+ from langgraph.checkpoint.postgres.base import (
30
+ SELECT_DELTA_STAGE1_SQL,
31
+ SELECT_DELTA_STAGE2_SQL,
32
+ BasePostgresSaver,
33
+ _DeltaStage1Row,
34
+ _DeltaStage2Row,
35
+ )
27
36
  from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
28
37
 
29
38
  Conn = _ainternal.Conn # For backward compatibility
@@ -261,7 +270,12 @@ class AsyncPostgresSaver(BasePostgresSaver):
261
270
  # others are stored in blobs table
262
271
  blob_values = {}
263
272
  for k, v in checkpoint["channel_values"].items():
264
- if v is None or isinstance(v, (str, int, float, bool)):
273
+ if v is DELTA_SENTINEL:
274
+ copy["channel_values"].pop(k)
275
+ elif isinstance(v, _DeltaSnapshot):
276
+ blob_values[k] = copy["channel_values"].pop(k)
277
+ copy["channel_values"][k] = True
278
+ elif v is None or isinstance(v, (str, int, float, bool)):
265
279
  pass
266
280
  else:
267
281
  blob_values[k] = copy["channel_values"].pop(k)
@@ -391,6 +405,56 @@ class AsyncPostgresSaver(BasePostgresSaver):
391
405
  async with conn.cursor(binary=True, row_factory=dict_row) as cur:
392
406
  yield cur
393
407
 
408
+ async def _aget_channel_writes_history(
409
+ self, config: RunnableConfig, channel: str
410
+ ) -> _ChannelWritesHistory:
411
+ """Fast-path override of `BaseCheckpointSaver._aget_channel_writes_history`.
412
+
413
+ Two-stage query: stage 1 scans checkpoint metadata to walk the parent
414
+ chain and locate the nearest snapshot; stage 2 fetches only the
415
+ chain-limited writes and single seed blob.
416
+ """
417
+ thread_id = config["configurable"]["thread_id"]
418
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
419
+ checkpoint_id = get_checkpoint_id(config)
420
+ if checkpoint_id is None:
421
+ target = await self.aget_tuple(config)
422
+ if target is None:
423
+ return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
424
+ checkpoint_id = target.config["configurable"]["checkpoint_id"]
425
+
426
+ async with self._cursor() as cur:
427
+ await cur.execute(
428
+ SELECT_DELTA_STAGE1_SQL,
429
+ (channel, channel, thread_id, checkpoint_ns),
430
+ )
431
+ stage1_rows = await cur.fetchall()
432
+ chain_cids, seed_version = self._walk_stage1(
433
+ cast("list[_DeltaStage1Row]", stage1_rows), checkpoint_id
434
+ )
435
+ seed_versions = [seed_version] if seed_version else []
436
+ async with self._cursor() as cur:
437
+ await cur.execute(
438
+ SELECT_DELTA_STAGE2_SQL,
439
+ (
440
+ thread_id,
441
+ checkpoint_ns,
442
+ channel,
443
+ chain_cids,
444
+ thread_id,
445
+ checkpoint_ns,
446
+ channel,
447
+ seed_versions,
448
+ ),
449
+ )
450
+ stage2_rows = await cur.fetchall()
451
+ return self._build_delta_channel_writes_history(
452
+ channel=channel,
453
+ chain_cids=chain_cids,
454
+ seed_version=seed_version,
455
+ stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
456
+ )
457
+
394
458
  async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
395
459
  """
396
460
  Convert a database row into a CheckpointTuple object.
@@ -4,13 +4,16 @@ import random
4
4
  import warnings
5
5
  from collections.abc import Sequence
6
6
  from importlib.metadata import version as get_version
7
- from typing import Any, cast
7
+ from typing import Any, TypedDict, cast
8
8
 
9
9
  from langchain_core.runnables import RunnableConfig
10
10
  from langgraph.checkpoint.base import (
11
+ DELTA_SENTINEL,
11
12
  WRITES_IDX_MAP,
12
13
  BaseCheckpointSaver,
13
14
  ChannelVersions,
15
+ PendingWrite,
16
+ _ChannelWritesHistory,
14
17
  get_checkpoint_id,
15
18
  )
16
19
  from langgraph.checkpoint.serde.types import TASKS
@@ -153,6 +156,62 @@ INSERT_CHECKPOINT_WRITES_SQL = """
153
156
  """
154
157
 
155
158
 
159
+ class _DeltaStage2Row(TypedDict, total=False):
160
+ """One row from `SELECT_DELTA_STAGE2_SQL` (a UNION ALL of writes and blobs)."""
161
+
162
+ _kind: str # "w" or "b"
163
+ checkpoint_id: str | None # "w" rows only
164
+ type: str | None
165
+ blob: bytes | None
166
+ task_id: str | None # "w" rows only
167
+ idx: int | None # "w" rows only
168
+ version: str | None # "b" rows only
169
+
170
+
171
+ # Two-stage DeltaChannel reconstruction. Stage 1 scans checkpoint
172
+ # metadata (no blob bytes) to walk the parent chain and locate the
173
+ # nearest snapshot marker. Stage 2 fetches only the chain-limited
174
+ # writes and the single seed snapshot blob.
175
+ #
176
+ # Parameter order:
177
+ # stage1: (channel, channel, thread_id, checkpoint_ns)
178
+ # stage2: (thread_id, checkpoint_ns, channel, chain_cids[],
179
+ # thread_id, checkpoint_ns, channel, seed_versions[])
180
+
181
+ SELECT_DELTA_STAGE1_SQL = """
182
+ SELECT checkpoint_id,
183
+ parent_checkpoint_id,
184
+ checkpoint -> 'channel_versions' ->> %s AS ver,
185
+ (checkpoint -> 'channel_values' -> %s) IS NOT NULL AS has_snapshot
186
+ FROM checkpoints
187
+ WHERE thread_id = %s AND checkpoint_ns = %s
188
+ """
189
+
190
+ SELECT_DELTA_STAGE2_SQL = """
191
+ SELECT 'w'::text AS _kind,
192
+ checkpoint_id,
193
+ type, blob, task_id, idx, NULL::text AS version
194
+ FROM checkpoint_writes
195
+ WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
196
+ AND checkpoint_id = ANY(%s)
197
+ UNION ALL
198
+ SELECT 'b', NULL,
199
+ type, blob, NULL, NULL, version
200
+ FROM checkpoint_blobs
201
+ WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
202
+ AND version = ANY(%s)
203
+ """
204
+
205
+
206
+ class _DeltaStage1Row(TypedDict):
207
+ """One row from `SELECT_DELTA_STAGE1_SQL`."""
208
+
209
+ checkpoint_id: str
210
+ parent_checkpoint_id: str | None
211
+ ver: str | None
212
+ has_snapshot: bool
213
+
214
+
156
215
  class BasePostgresSaver(BaseCheckpointSaver[str]):
157
216
  SELECT_SQL = SELECT_SQL
158
217
  SELECT_PENDING_SENDS_SQL = SELECT_PENDING_SENDS_SQL
@@ -195,6 +254,88 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
195
254
  if t.decode() != "empty"
196
255
  }
197
256
 
257
+ @staticmethod
258
+ def _walk_stage1(
259
+ stage1_rows: Sequence[_DeltaStage1Row],
260
+ target_id: str,
261
+ ) -> tuple[list[str], str | None]:
262
+ """Walk the parent chain from stage 1 metadata rows.
263
+
264
+ Returns (chain_cids, seed_version):
265
+ chain_cids: ancestor checkpoint IDs from target's parent down to
266
+ the seed (or root), in newest-first order.
267
+ seed_version: the channel blob version at the nearest ancestor
268
+ with has_snapshot=True, or None if pure delta.
269
+ """
270
+ parent_of: dict[str, str | None] = {}
271
+ ver_of: dict[str, str | None] = {}
272
+ snapshot_of: dict[str, bool] = {}
273
+ for r in stage1_rows:
274
+ cid = r["checkpoint_id"]
275
+ parent_of[cid] = r["parent_checkpoint_id"]
276
+ ver_of[cid] = r["ver"]
277
+ snapshot_of[cid] = r["has_snapshot"]
278
+
279
+ chain_cids: list[str] = []
280
+ seed_version: str | None = None
281
+ cur_cid: str | None = parent_of.get(target_id)
282
+ while cur_cid is not None:
283
+ chain_cids.append(cur_cid)
284
+ if snapshot_of.get(cur_cid, False):
285
+ seed_version = ver_of.get(cur_cid)
286
+ break
287
+ cur_cid = parent_of.get(cur_cid)
288
+ return chain_cids, seed_version
289
+
290
+ def _build_delta_channel_writes_history(
291
+ self,
292
+ *,
293
+ channel: str,
294
+ chain_cids: list[str],
295
+ seed_version: str | None,
296
+ stage2_rows: Sequence[_DeltaStage2Row],
297
+ ) -> _ChannelWritesHistory:
298
+ """Reconstruct delta channel history from two-stage query results.
299
+
300
+ chain_cids are in newest-first order (target's parent first).
301
+ stage2_rows contain only writes for chain_cids and the single
302
+ seed blob at seed_version.
303
+ """
304
+ writes_by_cid: dict[str, list[tuple[str, bytes, str, int]]] = {}
305
+ seed_blob: tuple[str, bytes] | None = None
306
+
307
+ for r in stage2_rows:
308
+ kind = r["_kind"]
309
+ if kind == "w":
310
+ cid = cast(str, r["checkpoint_id"])
311
+ writes_by_cid.setdefault(cid, []).append(
312
+ cast(
313
+ "tuple[str, bytes, str, int]",
314
+ (r["type"], r["blob"], r["task_id"], r["idx"]),
315
+ )
316
+ )
317
+ else: # kind == "b"
318
+ seed_blob = cast("tuple[str, bytes]", (r["type"], r["blob"]))
319
+
320
+ for ws in writes_by_cid.values():
321
+ ws.sort(key=lambda w: (w[2], w[3]), reverse=True)
322
+
323
+ if not chain_cids:
324
+ return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
325
+
326
+ collected: list[PendingWrite] = []
327
+ for cid in chain_cids:
328
+ for type_tag, write_blob, task_id, _idx in writes_by_cid.get(cid, []):
329
+ val = self.serde.loads_typed((type_tag, write_blob))
330
+ collected.append((task_id, channel, val))
331
+
332
+ seed: Any = DELTA_SENTINEL
333
+ if seed_blob is not None and seed_blob[0] != "empty":
334
+ seed = self.serde.loads_typed(seed_blob)
335
+
336
+ collected.reverse()
337
+ return _ChannelWritesHistory(seed=seed, writes=collected)
338
+
198
339
  def _dump_blobs(
199
340
  self,
200
341
  thread_id: str,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langgraph-checkpoint-postgres"
7
- version = "3.0.5"
7
+ version = "3.1.0a3"
8
8
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
9
9
  authors = []
10
10
  requires-python = ">=3.10"
@@ -12,7 +12,7 @@ readme = "README.md"
12
12
  license = "MIT"
13
13
  license-files = ['LICENSE']
14
14
  dependencies = [
15
- "langgraph-checkpoint>=2.1.2,<5.0.0",
15
+ "langgraph-checkpoint>=4.1.0a3,<5.0.0",
16
16
  "orjson>=3.11.5",
17
17
  "psycopg>=3.2.0",
18
18
  "psycopg-pool>=3.2.0",
@@ -20,7 +20,7 @@ dependencies = [
20
20
 
21
21
  [project.urls]
22
22
  Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres"
23
- Twitter = "https://x.com/LangChain"
23
+ Twitter = "https://x.com/langchain_oss"
24
24
  Slack = "https://www.langchain.com/join-community"
25
25
  Reddit = "https://www.reddit.com/r/LangChain/"
26
26
 
@@ -361,9 +361,9 @@ async def test_get_checkpoint_no_channel_values(
361
361
 
362
362
  load_checkpoint_tuple = saver._load_checkpoint_tuple
363
363
 
364
- def patched_load_checkpoint_tuple(value):
364
+ async def patched_load_checkpoint_tuple(value):
365
365
  value["checkpoint"].pop("channel_values", None)
366
- return load_checkpoint_tuple(value)
366
+ return await load_checkpoint_tuple(value)
367
367
 
368
368
  monkeypatch.setattr(
369
369
  saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
@@ -371,3 +371,47 @@ async def test_get_checkpoint_no_channel_values(
371
371
 
372
372
  checkpoint = await saver.aget_tuple(config)
373
373
  assert checkpoint.checkpoint["channel_values"] == {}
374
+
375
+
376
+ @pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
377
+ async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
378
+ """AsyncPostgresSaver reconstructs DeltaChannel chain via point-lookup traversal."""
379
+ pytest.importorskip(
380
+ "langgraph.channels.delta", reason="langgraph core not installed"
381
+ )
382
+
383
+ from typing import Annotated
384
+
385
+ from langchain_core.messages import AIMessage, HumanMessage
386
+ from langgraph.channels.delta import DeltaChannel
387
+ from langgraph.graph import START, StateGraph
388
+ from langgraph.graph.message import _messages_delta_reducer
389
+ from typing_extensions import TypedDict
390
+
391
+ class State(TypedDict):
392
+ messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
393
+
394
+ def respond(state: State) -> dict:
395
+ n = len(state["messages"])
396
+ return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
397
+
398
+ builder = StateGraph(State)
399
+ builder.add_node("respond", respond)
400
+ builder.add_edge(START, "respond")
401
+
402
+ async with _saver(saver_name) as saver:
403
+ graph = builder.compile(checkpointer=saver)
404
+ config = {"configurable": {"thread_id": "diff-channel-test-1"}}
405
+
406
+ await graph.ainvoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
407
+ await graph.ainvoke(
408
+ {"messages": [HumanMessage(content="there", id="h2")]}, config
409
+ )
410
+
411
+ state = await graph.aget_state(config)
412
+ msgs = state.values["messages"]
413
+ assert len(msgs) == 4, f"expected 4, got {len(msgs)}: {msgs}"
414
+ assert msgs[0].content == "hi"
415
+ assert msgs[1].content == "reply-1"
416
+ assert msgs[2].content == "there"
417
+ assert msgs[3].content == "reply-3"