langgraph-checkpoint-postgres 3.1.0a4__tar.gz → 3.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/Makefile +2 -5
  2. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/PKG-INFO +35 -5
  3. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/README.md +33 -3
  4. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/base.py +2 -2
  5. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/store/postgres/base.py +89 -18
  6. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/pyproject.toml +12 -12
  7. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/test_async_store.py +132 -0
  8. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/test_store.py +265 -0
  9. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/uv.lock +332 -398
  10. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/.gitignore +0 -0
  11. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/LICENSE +0 -0
  12. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/__init__.py +0 -0
  13. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
  14. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/_internal.py +0 -0
  15. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/aio.py +0 -0
  16. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/py.typed +0 -0
  17. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/shallow.py +0 -0
  18. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/store/postgres/__init__.py +0 -0
  19. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/store/postgres/aio.py +0 -0
  20. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/store/postgres/py.typed +0 -0
  21. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/__init__.py +0 -0
  22. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/compose-postgres.yml +0 -0
  23. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/conftest.py +0 -0
  24. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/embed_test_utils.py +0 -0
  25. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/test_async.py +0 -0
  26. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/test_sync.py +0 -0
@@ -47,22 +47,19 @@ test_watch:
47
47
 
48
48
  # Define a variable for Python and notebook files.
49
49
  PYTHON_FILES=.
50
- MYPY_CACHE=.mypy_cache
51
50
  lint format: PYTHON_FILES=.
52
51
  lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$')
53
52
  lint_package: PYTHON_FILES=langgraph
54
53
  lint_tests: PYTHON_FILES=tests
55
- lint_tests: MYPY_CACHE=.mypy_cache_test
56
54
 
57
55
  lint lint_diff lint_package lint_tests:
58
56
  uv run ruff check .
59
57
  [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
60
58
  [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
61
- [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
62
- [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
59
+ [ "$(PYTHON_FILES)" = "" ] || uv run ty check $(PYTHON_FILES)
63
60
 
64
61
  type:
65
- mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
62
+ uv run ty check $(PYTHON_FILES)
66
63
 
67
64
  format format_diff:
68
65
  uv run ruff format $(PYTHON_FILES)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 3.1.0a4
3
+ Version: 3.1.1
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
6
  Project-URL: Twitter, https://x.com/langchain_oss
@@ -9,7 +9,7 @@ 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,>=4.1.0a4
12
+ Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.0
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
@@ -17,11 +17,29 @@ Description-Content-Type: text/markdown
17
17
 
18
18
  # LangGraph Checkpoint Postgres
19
19
 
20
- Implementation of LangGraph CheckpointSaver that uses Postgres.
20
+ [![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-postgres?label=%20)](https://pypi.org/project/langgraph-checkpoint-postgres/#history)
21
+ [![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-postgres)](https://opensource.org/licenses/MIT)
22
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-postgres)](https://pypistats.org/packages/langgraph-checkpoint-postgres)
23
+ [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss)
21
24
 
22
- ## Dependencies
25
+ To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
26
+ [LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
23
27
 
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]`).
28
+ ## Quick Install
29
+
30
+ ```bash
31
+ uv add langgraph-checkpoint-postgres
32
+ ```
33
+
34
+ ## 🤔 What is this?
35
+
36
+ This library provides a Postgres implementation of LangGraph's checkpoint saver. Use it when you want LangGraph state persistence backed by Postgres for durable, long-running workflows and agents.
37
+
38
+ By default, `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. You can choose a specific installation that best suits your needs in the [Psycopg installation docs](https://www.psycopg.org/psycopg3/docs/basic/install.html), for example `psycopg[binary]`.
39
+
40
+ ## 📖 Documentation
41
+
42
+ For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.postgres). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
25
43
 
26
44
  ## Security
27
45
 
@@ -37,10 +55,12 @@ By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) withou
37
55
  > When manually creating Postgres connections and passing them to `PostgresSaver` or `AsyncPostgresSaver`, make sure to include `autocommit=True` and `row_factory=dict_row` (`from psycopg.rows import dict_row`). See a full example in this [how-to guide](https://langchain-ai.github.io/langgraph/how-tos/persistence_postgres/).
38
56
  >
39
57
  > **Why these parameters are required:**
58
+ >
40
59
  > - `autocommit=True`: Required for the `.setup()` method to properly commit the checkpoint tables to the database. Without this, table creation may not be persisted.
41
60
  > - `row_factory=dict_row`: Required because the PostgresSaver implementation accesses database rows using dictionary-style syntax (e.g., `row["column_name"]`). The default `tuple_row` factory returns tuples that only support index-based access (e.g., `row[0]`), which will cause `TypeError` exceptions when the checkpointer tries to access columns by name.
42
61
  >
43
62
  > **Example of incorrect usage:**
63
+ >
44
64
  > ```python
45
65
  > # ❌ This will fail with TypeError during checkpointer operations
46
66
  > with psycopg.connect(DB_URI) as conn: # Missing autocommit=True and row_factory=dict_row
@@ -135,3 +155,13 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
135
155
  # list checkpoints
136
156
  [c async for c in checkpointer.alist(read_config)]
137
157
  ```
158
+
159
+ ## 📕 Releases & Versioning
160
+
161
+ See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
162
+
163
+ ## 💁 Contributing
164
+
165
+ 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.
166
+
167
+ For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
@@ -1,10 +1,28 @@
1
1
  # LangGraph Checkpoint Postgres
2
2
 
3
- Implementation of LangGraph CheckpointSaver that uses Postgres.
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-postgres?label=%20)](https://pypi.org/project/langgraph-checkpoint-postgres/#history)
4
+ [![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-postgres)](https://opensource.org/licenses/MIT)
5
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-postgres)](https://pypistats.org/packages/langgraph-checkpoint-postgres)
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)
4
7
 
5
- ## Dependencies
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.
6
10
 
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]`).
11
+ ## Quick Install
12
+
13
+ ```bash
14
+ uv add langgraph-checkpoint-postgres
15
+ ```
16
+
17
+ ## 🤔 What is this?
18
+
19
+ This library provides a Postgres implementation of LangGraph's checkpoint saver. Use it when you want LangGraph state persistence backed by Postgres for durable, long-running workflows and agents.
20
+
21
+ By default, `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. You can choose a specific installation that best suits your needs in the [Psycopg installation docs](https://www.psycopg.org/psycopg3/docs/basic/install.html), for example `psycopg[binary]`.
22
+
23
+ ## 📖 Documentation
24
+
25
+ For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.postgres). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
8
26
 
9
27
  ## Security
10
28
 
@@ -20,10 +38,12 @@ By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) withou
20
38
  > When manually creating Postgres connections and passing them to `PostgresSaver` or `AsyncPostgresSaver`, make sure to include `autocommit=True` and `row_factory=dict_row` (`from psycopg.rows import dict_row`). See a full example in this [how-to guide](https://langchain-ai.github.io/langgraph/how-tos/persistence_postgres/).
21
39
  >
22
40
  > **Why these parameters are required:**
41
+ >
23
42
  > - `autocommit=True`: Required for the `.setup()` method to properly commit the checkpoint tables to the database. Without this, table creation may not be persisted.
24
43
  > - `row_factory=dict_row`: Required because the PostgresSaver implementation accesses database rows using dictionary-style syntax (e.g., `row["column_name"]`). The default `tuple_row` factory returns tuples that only support index-based access (e.g., `row[0]`), which will cause `TypeError` exceptions when the checkpointer tries to access columns by name.
25
44
  >
26
45
  > **Example of incorrect usage:**
46
+ >
27
47
  > ```python
28
48
  > # ❌ This will fail with TypeError during checkpointer operations
29
49
  > with psycopg.connect(DB_URI) as conn: # Missing autocommit=True and row_factory=dict_row
@@ -118,3 +138,13 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
118
138
  # list checkpoints
119
139
  [c async for c in checkpointer.alist(read_config)]
120
140
  ```
141
+
142
+ ## 📕 Releases & Versioning
143
+
144
+ See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
145
+
146
+ ## 💁 Contributing
147
+
148
+ 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.
149
+
150
+ For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
@@ -279,8 +279,8 @@ def _build_delta_stage2_sql(
279
279
  )
280
280
  for _ in channels_with_seed:
281
281
  branches.append(
282
- "SELECT 'b'::text, NULL, channel, "
283
- "type, blob, NULL, NULL, version "
282
+ "SELECT 'b'::text AS _kind, NULL::text AS checkpoint_id, channel, "
283
+ "type, blob, NULL::text AS task_id, NULL::int AS idx, version "
284
284
  "FROM checkpoint_blobs "
285
285
  "WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
286
286
  "AND version = %s"
@@ -4,6 +4,7 @@ import asyncio
4
4
  import concurrent.futures
5
5
  import json
6
6
  import logging
7
+ import re
7
8
  import threading
8
9
  from collections import defaultdict
9
10
  from collections.abc import Callable, Iterable, Iterator, Sequence
@@ -238,6 +239,12 @@ class BasePostgresStore(Generic[C]):
238
239
  conn: C
239
240
  _deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None
240
241
  index_config: PostgresIndexConfig | None
242
+ ttl_config: TTLConfig | None
243
+
244
+ @property
245
+ def _omit_expired(self) -> bool:
246
+ """Whether expired-but-unswept rows should be filtered from reads."""
247
+ return bool(self.ttl_config and self.ttl_config.get("omit_expired"))
241
248
 
242
249
  def _get_batch_GET_ops_queries(
243
250
  self,
@@ -258,12 +265,17 @@ class BasePostgresStore(Generic[C]):
258
265
  namespace_groups[op.namespace].append((idx, op.key))
259
266
  refresh_ttls[op.namespace].append(op.refresh_ttl)
260
267
 
268
+ omit_expired = self._omit_expired
269
+ expiry_clause = (
270
+ "AND (s.expires_at IS NULL OR s.expires_at > NOW())" if omit_expired else ""
271
+ )
272
+
261
273
  results = []
262
274
  for namespace, items in namespace_groups.items():
263
275
  _, keys = zip(*items, strict=False)
264
276
  this_refresh_ttls = refresh_ttls[namespace]
265
277
 
266
- query = """
278
+ query = f"""
267
279
  WITH passed_in AS (
268
280
  SELECT unnest(%s::text[]) AS key,
269
281
  unnest(%s::bool[]) AS do_refresh
@@ -276,12 +288,14 @@ class BasePostgresStore(Generic[C]):
276
288
  AND s.key = p.key
277
289
  AND p.do_refresh = TRUE
278
290
  AND s.ttl_minutes IS NOT NULL
291
+ {expiry_clause}
279
292
  RETURNING s.key
280
293
  )
281
294
  SELECT s.key, s.value, s.created_at, s.updated_at
282
295
  FROM store s
283
296
  JOIN passed_in p ON s.key = p.key
284
297
  WHERE s.prefix = %s
298
+ {expiry_clause}
285
299
  """
286
300
  ns_text = _namespace_to_text(namespace)
287
301
  params = (
@@ -422,6 +436,13 @@ class BasePostgresStore(Generic[C]):
422
436
  - embedding_requests: list of (original_index_in_search_ops, text_query)
423
437
  """
424
438
 
439
+ omit_expired = self._omit_expired
440
+ search_expiry_clause = (
441
+ "AND (store.expires_at IS NULL OR store.expires_at > NOW())"
442
+ if omit_expired
443
+ else ""
444
+ )
445
+
425
446
  queries = []
426
447
  embedding_requests = []
427
448
  for idx, (_, op) in enumerate(search_ops):
@@ -443,8 +464,9 @@ class BasePostgresStore(Generic[C]):
443
464
  ns_condition = "TRUE"
444
465
  ns_param: Sequence[str] | None = None
445
466
  if op.namespace_prefix:
446
- ns_condition = "store.prefix LIKE %s"
447
- ns_param = (f"{_namespace_to_text(op.namespace_prefix)}%",)
467
+ ns_condition, ns_param = _namespace_prefix_condition(
468
+ op.namespace_prefix
469
+ )
448
470
  else:
449
471
  ns_param = ()
450
472
 
@@ -491,7 +513,7 @@ class BasePostgresStore(Generic[C]):
491
513
  {score_operator} AS neg_score
492
514
  FROM store
493
515
  JOIN store_vectors sv ON store.prefix = sv.prefix AND store.key = sv.key
494
- WHERE {ns_condition} {extra_filters}
516
+ WHERE {ns_condition} {extra_filters} {search_expiry_clause}
495
517
  ORDER BY {score_operator} ASC
496
518
  LIMIT %s
497
519
  """
@@ -527,7 +549,7 @@ class BasePostgresStore(Generic[C]):
527
549
  base_query = f"""
528
550
  SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at, NULL AS score
529
551
  FROM store
530
- WHERE {ns_condition} {extra_filters}
552
+ WHERE {ns_condition} {extra_filters} {search_expiry_clause}
531
553
  ORDER BY store.updated_at DESC
532
554
  LIMIT %s
533
555
  OFFSET %s
@@ -591,18 +613,23 @@ class BasePostgresStore(Generic[C]):
591
613
  """
592
614
  params: list[Any] = [op.max_depth, op.max_depth]
593
615
 
616
+ omit_expired = self._omit_expired
594
617
  conditions = []
618
+ if omit_expired:
619
+ conditions.append("(expires_at IS NULL OR expires_at > NOW())")
595
620
  if op.match_conditions:
596
621
  for condition in op.match_conditions:
597
- if condition.match_type == "prefix":
598
- conditions.append("prefix LIKE %s")
622
+ if condition.match_type in ("prefix", "suffix"):
623
+ if not condition.path:
624
+ # An empty path constrains nothing; skipping keeps it a
625
+ # no-op rather than emitting a pattern that matches no
626
+ # namespace at all.
627
+ continue
628
+ conditions.append("prefix ~ %s")
599
629
  params.append(
600
- f"{_namespace_to_text(condition.path, handle_wildcards=True)}%"
601
- )
602
- elif condition.match_type == "suffix":
603
- conditions.append("prefix LIKE %s")
604
- params.append(
605
- f"%{_namespace_to_text(condition.path, handle_wildcards=True)}"
630
+ _namespace_match_pattern(
631
+ condition.path, condition.match_type
632
+ )
606
633
  )
607
634
  else:
608
635
  logger.warning(
@@ -1248,15 +1275,59 @@ def _get_index_params(store: Any) -> tuple[str, dict[str, Any]]:
1248
1275
  return kind, sanitized
1249
1276
 
1250
1277
 
1251
- def _namespace_to_text(
1252
- namespace: tuple[str, ...], handle_wildcards: bool = False
1253
- ) -> str:
1278
+ def _namespace_to_text(namespace: tuple[str, ...]) -> str:
1254
1279
  """Convert namespace tuple to text string."""
1255
- if handle_wildcards:
1256
- namespace = tuple("%" if val == "*" else val for val in namespace)
1257
1280
  return ".".join(namespace)
1258
1281
 
1259
1282
 
1283
+ def _escape_like_literal(text: str) -> str:
1284
+ """Escape LIKE metacharacters so `text` is matched literally.
1285
+
1286
+ Namespace labels may contain `_` and `%`, which would otherwise act as
1287
+ wildcards: `("user_1",)` would match `("userX1",)`. Backslash is escaped
1288
+ first so it cannot escape the following character. Requires an explicit
1289
+ `ESCAPE '\\'` clause on the pattern.
1290
+ """
1291
+ return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
1292
+
1293
+
1294
+ def _namespace_prefix_condition(namespace_prefix: tuple[str, ...]) -> tuple[str, tuple]:
1295
+ """Build the SQL scoping a search to a namespace and its descendants.
1296
+
1297
+ Matches the namespace exactly or requires the `.` separator before any
1298
+ remainder, so a prefix of `("foo",)` does not also match `("foobar",)`.
1299
+
1300
+ Both arms stay index-friendly: equality on the `(prefix, key)` primary key,
1301
+ the anchored LIKE on the `prefix text_pattern_ops` index.
1302
+
1303
+ Only the LIKE arm is escaped -- equality does not interpret metacharacters,
1304
+ so escaping it would stop `("user_1",)` from matching itself.
1305
+ """
1306
+ path = _namespace_to_text(namespace_prefix)
1307
+ condition = r"(store.prefix = %s OR store.prefix LIKE %s ESCAPE '\')"
1308
+ return condition, (path, f"{_escape_like_literal(path)}.%")
1309
+
1310
+
1311
+ def _namespace_match_pattern(path: tuple[str, ...], match_type: str) -> str:
1312
+ """Build a POSIX regex matching the dot-joined prefix on whole segments.
1313
+
1314
+ Needed because `LIKE` cannot express "any character except the separator".
1315
+ Matches how `InMemoryStore` compares namespaces element-wise.
1316
+
1317
+ `*` matches exactly one segment. Prefix matches stay open-ended but must end
1318
+ on a separator; suffix matches anchor at the end and begin on one.
1319
+
1320
+ Examples:
1321
+ prefix ("uid", "*", "alice") -> ^uid\\.[^.]+\\.alice(\\.|\\Z)
1322
+ suffix ("alice",) -> (^|\\.)alice\\Z
1323
+ """
1324
+ segments = ("[^.]+" if part == "*" else re.escape(part) for part in path)
1325
+ body = r"\.".join(segments)
1326
+ if match_type == "suffix":
1327
+ return rf"(^|\.){body}\Z"
1328
+ return rf"^{body}(\.|\Z)"
1329
+
1330
+
1260
1331
  def _row_to_item(
1261
1332
  namespace: tuple[str, ...],
1262
1333
  row: Row,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langgraph-checkpoint-postgres"
7
- version = "3.1.0a4"
7
+ version = "3.1.1"
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>=4.1.0a4,<5.0.0",
15
+ "langgraph-checkpoint>=4.1.0,<5.0.0",
16
16
  "orjson>=3.11.5",
17
17
  "psycopg>=3.2.0",
18
18
  "psycopg-pool>=3.2.0",
@@ -37,7 +37,7 @@ test = [
37
37
  lint = [
38
38
  "ruff",
39
39
  "codespell",
40
- "mypy",
40
+ "ty",
41
41
  ]
42
42
  dev = [
43
43
  {include-group = "test"},
@@ -69,15 +69,15 @@ lint.select = [
69
69
  lint.ignore = ["E501", "B008"]
70
70
  target-version = "py310"
71
71
 
72
- [tool.mypy]
73
- # https://mypy.readthedocs.io/en/stable/config_file.html
74
- disallow_untyped_defs = "True"
75
- explicit_package_bases = "True"
76
- warn_no_return = "False"
77
- warn_unused_ignores = "True"
78
- warn_redundant_casts = "True"
79
- allow_redefinition = "True"
80
- disable_error_code = "typeddict-item, return-value"
72
+ [tool.ty.rules]
73
+ invalid-argument-type = "ignore"
74
+ invalid-assignment = "ignore"
75
+ invalid-key = "ignore"
76
+ invalid-return-type = "ignore"
77
+ invalid-typed-dict-field = "ignore"
78
+ invalid-yield = "ignore"
79
+ no-matching-overload = "ignore"
80
+ unused-type-ignore-comment = "ignore"
81
81
 
82
82
  [tool.pytest-watcher]
83
83
  now = true
@@ -739,3 +739,135 @@ async def test_store_ttl(store):
739
739
  # Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2
740
740
  results = await store.asearch(ns, query="bar", refresh_ttl=False)
741
741
  assert len(results) == 0
742
+
743
+
744
+ async def _aexpire_now(
745
+ store: AsyncPostgresStore, ns: tuple[str, ...], key: str
746
+ ) -> None:
747
+ """Backdate a row's expires_at into the past without deleting it (unswept)."""
748
+ async with store._cursor() as cur:
749
+ await cur.execute(
750
+ "UPDATE store SET expires_at = NOW() - INTERVAL '1 minute' "
751
+ "WHERE prefix = %s AND key = %s",
752
+ (".".join(ns), key),
753
+ )
754
+
755
+
756
+ async def _arow_exists(
757
+ store: AsyncPostgresStore, ns: tuple[str, ...], key: str
758
+ ) -> bool:
759
+ async with store._cursor() as cur:
760
+ await cur.execute(
761
+ "SELECT COUNT(*) AS n FROM store WHERE prefix = %s AND key = %s",
762
+ (".".join(ns), key),
763
+ )
764
+ return (await cur.fetchone())["n"] == 1
765
+
766
+
767
+ async def _astored_expires_at(store: AsyncPostgresStore, ns: tuple[str, ...], key: str):
768
+ async with store._cursor() as cur:
769
+ await cur.execute(
770
+ "SELECT expires_at FROM store WHERE prefix = %s AND key = %s",
771
+ (".".join(ns), key),
772
+ )
773
+ return (await cur.fetchone())["expires_at"]
774
+
775
+
776
+ async def test_omit_expired_filters_read_paths(store: AsyncPostgresStore) -> None:
777
+ await store.stop_ttl_sweeper() # deterministic: no background deletion
778
+ store.ttl_config["omit_expired"] = True
779
+
780
+ expired_ns = ("omit", "expired")
781
+ control_ns = ("omit", "control")
782
+ await store.aput(expired_ns, "e", {"data": "gone"}, ttl=TTL_MINUTES)
783
+ await store.aput(control_ns, "c", {"data": "keep"}, ttl=None)
784
+ await _aexpire_now(store, expired_ns, "e")
785
+
786
+ # The row is expired but physically still present (unswept).
787
+ assert await _arow_exists(store, expired_ns, "e")
788
+
789
+ # aget omits it; the never-expiring control is still returned.
790
+ assert await store.aget(expired_ns, "e") is None
791
+ assert await store.aget(control_ns, "c") is not None
792
+
793
+ # asearch omits it but returns the control.
794
+ assert await store.asearch(expired_ns) == []
795
+ assert [i.key for i in await store.asearch(control_ns)] == ["c"]
796
+
797
+ # alist_namespaces drops the expired-only namespace, keeps the control.
798
+ namespaces = await store.alist_namespaces(prefix=("omit",))
799
+ assert expired_ns not in namespaces
800
+ assert control_ns in namespaces
801
+
802
+
803
+ @pytest.mark.parametrize("omit", [None, False], ids=["default", "explicit-false"])
804
+ async def test_omit_expired_disabled_preserves_expired_rows(
805
+ store: AsyncPostgresStore, omit
806
+ ) -> None:
807
+ await store.stop_ttl_sweeper()
808
+ if omit is not None:
809
+ store.ttl_config["omit_expired"] = omit
810
+
811
+ ns = ("keep",)
812
+ await store.aput(ns, "k", {"data": "still-here"}, ttl=TTL_MINUTES)
813
+ await _aexpire_now(store, ns, "k")
814
+
815
+ assert await store.aget(ns, "k", refresh_ttl=False) is not None
816
+ assert [i.key for i in await store.asearch(ns, refresh_ttl=False)] == ["k"]
817
+ assert ns in await store.alist_namespaces(prefix=("keep",))
818
+
819
+
820
+ async def test_omit_expired_refresh_ttl_only_refreshes_live_rows(
821
+ store: AsyncPostgresStore,
822
+ ) -> None:
823
+ await store.stop_ttl_sweeper()
824
+ store.ttl_config["omit_expired"] = True
825
+
826
+ ns = ("refresh",)
827
+ await store.aput(ns, "expired", {"n": 0}, ttl=TTL_MINUTES)
828
+ await store.aput(ns, "live_get", {"n": 1}, ttl=TTL_MINUTES)
829
+ await store.aput(ns, "live_search", {"n": 2}, ttl=TTL_MINUTES)
830
+ await _aexpire_now(store, ns, "expired")
831
+
832
+ expired_before = await _astored_expires_at(store, ns, "expired")
833
+ get_before = await _astored_expires_at(store, ns, "live_get")
834
+ search_before = await _astored_expires_at(store, ns, "live_search")
835
+
836
+ # refresh_ttl=True must NOT resurrect the expired row (via aget or asearch)...
837
+ assert await store.aget(ns, "expired", refresh_ttl=True) is None
838
+ live_keys = [i.key for i in await store.asearch(ns, refresh_ttl=True)]
839
+ assert "expired" not in live_keys
840
+ assert await _astored_expires_at(store, ns, "expired") == expired_before
841
+
842
+ # ...but must still extend the live rows that were read.
843
+ assert await store.aget(ns, "live_get", refresh_ttl=True) is not None
844
+ assert await _astored_expires_at(store, ns, "live_get") > get_before
845
+ assert await _astored_expires_at(store, ns, "live_search") > search_before
846
+
847
+
848
+ async def test_omit_expired_search_pagination(store: AsyncPostgresStore) -> None:
849
+ await store.stop_ttl_sweeper()
850
+ store.ttl_config["omit_expired"] = True
851
+
852
+ ns = ("page",)
853
+ for k in ("a", "b", "c"):
854
+ await store.aput(ns, k, {"k": k}, ttl=TTL_MINUTES)
855
+ await store.aput(ns, "expired", {"k": "x"}, ttl=TTL_MINUTES)
856
+ await _aexpire_now(store, ns, "expired")
857
+
858
+ seconds_ago = {"a": 1, "expired": 2, "b": 3, "c": 4}
859
+ # updated_at DESC orders these a, expired, b, c, so the expired row sits inside
860
+ # the first limit=2 window. Correct (pre-LIMIT) filtering yields live pages
861
+ # [a, b] then [c]; post-LIMIT filtering would underfill page 1 to just [a].
862
+ async with store._cursor() as cur:
863
+ for key, secs in seconds_ago.items():
864
+ await cur.execute(
865
+ "UPDATE store SET updated_at = NOW() - (%s * INTERVAL '1 second') "
866
+ "WHERE prefix = %s AND key = %s",
867
+ (secs, ".".join(ns), key),
868
+ )
869
+
870
+ page1 = await store.asearch(ns, limit=2, offset=0)
871
+ page2 = await store.asearch(ns, limit=2, offset=2)
872
+ assert [i.key for i in page1] == ["a", "b"]
873
+ assert [i.key for i in page2] == ["c"]