langgraph-checkpoint-postgres 1.0.9__tar.gz → 1.0.10__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 1.0.9
3
+ Version: 1.0.10
4
4
  Summary: Library with a Postgres implementation of LangGraph checkpoint saver.
5
5
  Home-page: https://www.github.com/langchain-ai/langgraph
6
6
  License: MIT
@@ -17,9 +17,7 @@ from langgraph.checkpoint.base import (
17
17
  CheckpointTuple,
18
18
  get_checkpoint_id,
19
19
  )
20
- from langgraph.checkpoint.postgres.base import (
21
- BasePostgresSaver,
22
- )
20
+ from langgraph.checkpoint.postgres.base import BasePostgresSaver
23
21
  from langgraph.checkpoint.serde.base import SerializerProtocol
24
22
 
25
23
  Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
@@ -167,15 +165,17 @@ class PostgresSaver(BasePostgresSaver):
167
165
  value["pending_sends"],
168
166
  ),
169
167
  self._load_metadata(value["metadata"]),
170
- {
171
- "configurable": {
172
- "thread_id": value["thread_id"],
173
- "checkpoint_ns": value["checkpoint_ns"],
174
- "checkpoint_id": value["parent_checkpoint_id"],
168
+ (
169
+ {
170
+ "configurable": {
171
+ "thread_id": value["thread_id"],
172
+ "checkpoint_ns": value["checkpoint_ns"],
173
+ "checkpoint_id": value["parent_checkpoint_id"],
174
+ }
175
175
  }
176
- }
177
- if value["parent_checkpoint_id"]
178
- else None,
176
+ if value["parent_checkpoint_id"]
177
+ else None
178
+ ),
179
179
  self._load_writes(value["pending_writes"]),
180
180
  )
181
181
 
@@ -246,15 +246,17 @@ class PostgresSaver(BasePostgresSaver):
246
246
  value["pending_sends"],
247
247
  ),
248
248
  self._load_metadata(value["metadata"]),
249
- {
250
- "configurable": {
251
- "thread_id": thread_id,
252
- "checkpoint_ns": checkpoint_ns,
253
- "checkpoint_id": value["parent_checkpoint_id"],
249
+ (
250
+ {
251
+ "configurable": {
252
+ "thread_id": thread_id,
253
+ "checkpoint_ns": checkpoint_ns,
254
+ "checkpoint_id": value["parent_checkpoint_id"],
255
+ }
254
256
  }
255
- }
256
- if value["parent_checkpoint_id"]
257
- else None,
257
+ if value["parent_checkpoint_id"]
258
+ else None
259
+ ),
258
260
  self._load_writes(value["pending_writes"]),
259
261
  )
260
262
 
@@ -384,3 +386,6 @@ class PostgresSaver(BasePostgresSaver):
384
386
  else:
385
387
  with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
386
388
  yield cur
389
+
390
+
391
+ __all__ = ["PostgresSaver", "Conn"]
@@ -0,0 +1,4 @@
1
+ from langgraph.store.postgres.aio import AsyncPostgresStore
2
+ from langgraph.store.postgres.base import PostgresStore
3
+
4
+ __all__ = ["AsyncPostgresStore", "PostgresStore"]
@@ -0,0 +1,190 @@
1
+ import asyncio
2
+ import logging
3
+ from contextlib import asynccontextmanager
4
+ from typing import Any, AsyncIterator, Iterable, Sequence, cast
5
+
6
+ from psycopg import AsyncConnection, AsyncCursor
7
+ from psycopg.errors import UndefinedTable
8
+ from psycopg.rows import dict_row
9
+
10
+ from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
11
+ from langgraph.store.postgres.base import (
12
+ BasePostgresStore,
13
+ Row,
14
+ _group_ops,
15
+ _row_to_item,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
22
+ def __init__(self, conn: AsyncConnection[Any]) -> None:
23
+ self.conn = conn
24
+ self.loop = asyncio.get_running_loop()
25
+
26
+ async def abatch(self, ops: Iterable[Op]) -> list[Result]:
27
+ grouped_ops, num_ops = _group_ops(ops)
28
+ results: list[Result] = [None] * num_ops
29
+
30
+ async with self.conn.pipeline():
31
+ tasks = []
32
+
33
+ if GetOp in grouped_ops:
34
+ tasks.append(
35
+ self._batch_get_ops(
36
+ cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
37
+ )
38
+ )
39
+
40
+ if PutOp in grouped_ops:
41
+ tasks.append(
42
+ self._batch_put_ops(
43
+ cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
44
+ )
45
+ )
46
+
47
+ if SearchOp in grouped_ops:
48
+ tasks.append(
49
+ self._batch_search_ops(
50
+ cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
51
+ results,
52
+ )
53
+ )
54
+
55
+ if ListNamespacesOp in grouped_ops:
56
+ tasks.append(
57
+ self._batch_list_namespaces_ops(
58
+ cast(
59
+ Sequence[tuple[int, ListNamespacesOp]],
60
+ grouped_ops[ListNamespacesOp],
61
+ ),
62
+ results,
63
+ )
64
+ )
65
+
66
+ await asyncio.gather(*tasks)
67
+
68
+ return results
69
+
70
+ def batch(self, ops: Iterable[Op]) -> list[Result]:
71
+ return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
72
+
73
+ async def _batch_get_ops(
74
+ self,
75
+ get_ops: Sequence[tuple[int, GetOp]],
76
+ results: list[Result],
77
+ ) -> None:
78
+ cursors = []
79
+ for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
80
+ cur = self.conn.cursor(binary=True)
81
+ await cur.execute(query, params)
82
+ cursors.append((cur, namespace, items))
83
+
84
+ for cur, namespace, items in cursors:
85
+ rows = cast(list[Row], await cur.fetchall())
86
+ key_to_row = {row["key"]: row for row in rows}
87
+ for idx, key in items:
88
+ row = key_to_row.get(key)
89
+ if row:
90
+ results[idx] = _row_to_item(namespace, row)
91
+ else:
92
+ results[idx] = None
93
+
94
+ async def _batch_put_ops(
95
+ self,
96
+ put_ops: Sequence[tuple[int, PutOp]],
97
+ ) -> None:
98
+ queries = self._get_batch_PUT_queries(put_ops)
99
+ for query, params in queries:
100
+ cur = self.conn.cursor(binary=True)
101
+ await cur.execute(query, params)
102
+
103
+ async def _batch_search_ops(
104
+ self,
105
+ search_ops: Sequence[tuple[int, SearchOp]],
106
+ results: list[Result],
107
+ ) -> None:
108
+ queries = self._get_batch_search_queries(search_ops)
109
+ cursors: list[tuple[AsyncCursor[Any], int, SearchOp]] = []
110
+
111
+ for (query, params), (idx, op) in zip(queries, search_ops):
112
+ cur = self.conn.cursor(binary=True)
113
+ await cur.execute(query, params)
114
+ cursors.append((cur, idx, op))
115
+
116
+ for cur, idx, op in cursors:
117
+ rows = cast(list[Row], await cur.fetchall())
118
+ items = [_row_to_item(op.namespace_prefix, row) for row in rows]
119
+ results[idx] = items
120
+
121
+ async def _batch_list_namespaces_ops(
122
+ self,
123
+ list_ops: Sequence[tuple[int, ListNamespacesOp]],
124
+ results: list[Result],
125
+ ) -> None:
126
+ queries = self._get_batch_list_namespaces_queries(list_ops)
127
+ cursors: list[tuple[AsyncCursor[Any], int]] = []
128
+ for (query, params), (idx, _) in zip(queries, list_ops):
129
+ cur = self.conn.cursor(binary=True)
130
+ await cur.execute(query, params)
131
+ cursors.append((cur, idx))
132
+
133
+ for cur, idx in cursors:
134
+ rows = cast(list[dict], await cur.fetchall())
135
+ namespaces = [
136
+ tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
137
+ ]
138
+ results[idx] = namespaces
139
+
140
+ @classmethod
141
+ @asynccontextmanager
142
+ async def from_conn_string(
143
+ cls,
144
+ conn_string: str,
145
+ ) -> AsyncIterator["AsyncPostgresStore"]:
146
+ """Create a new AsyncPostgresStore instance from a connection string.
147
+
148
+ Args:
149
+ conn_string (str): The Postgres connection info string.
150
+
151
+ Returns:
152
+ AsyncPostgresStore: A new AsyncPostgresStore instance.
153
+ """
154
+ async with await AsyncConnection.connect(
155
+ conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
156
+ ) as conn:
157
+ yield cls(conn=conn)
158
+
159
+ async def setup(self) -> None:
160
+ """Set up the store database asynchronously.
161
+
162
+ This method creates the necessary tables in the Postgres database if they don't
163
+ already exist and runs database migrations. It MUST be called directly by the user
164
+ the first time the store is used.
165
+ """
166
+ async with self.conn.cursor() as cur:
167
+ try:
168
+ await cur.execute(
169
+ "SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
170
+ )
171
+ row = cast(dict, await cur.fetchone())
172
+ if row is None:
173
+ version = -1
174
+ else:
175
+ version = row["v"]
176
+ except UndefinedTable:
177
+ version = -1
178
+ # Create store_migrations table if it doesn't exist
179
+ await cur.execute(
180
+ """
181
+ CREATE TABLE IF NOT EXISTS store_migrations (
182
+ v INTEGER PRIMARY KEY
183
+ )
184
+ """
185
+ )
186
+ for v, migration in enumerate(
187
+ self.MIGRATIONS[version + 1 :], start=version + 1
188
+ ):
189
+ await cur.execute(migration)
190
+ await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
@@ -0,0 +1,394 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from collections import defaultdict
5
+ from contextlib import contextmanager
6
+ from datetime import datetime
7
+ from typing import Any, Generic, Iterable, Iterator, Sequence, TypeVar, Union, cast
8
+
9
+ import orjson
10
+ from psycopg import BaseConnection, Connection, Cursor
11
+ from psycopg.errors import UndefinedTable
12
+ from psycopg.rows import dict_row
13
+ from psycopg.types.json import Jsonb
14
+ from typing_extensions import TypedDict
15
+
16
+ from langgraph.store.base import (
17
+ BaseStore,
18
+ GetOp,
19
+ Item,
20
+ ListNamespacesOp,
21
+ Op,
22
+ PutOp,
23
+ Result,
24
+ SearchOp,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ MIGRATIONS = [
31
+ """
32
+ CREATE EXTENSION IF NOT EXISTS ltree;
33
+ """,
34
+ """
35
+ CREATE TABLE IF NOT EXISTS store (
36
+ -- 'prefix' represents the doc's 'namespace'
37
+ prefix ltree NOT NULL,
38
+ key text NOT NULL,
39
+ value jsonb NOT NULL,
40
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
41
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
42
+ PRIMARY KEY (prefix, key)
43
+ );
44
+ """,
45
+ """
46
+ -- For faster listing of namespaces & lookups by namespace with prefix/suffix matching
47
+ CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING gist (prefix);
48
+ """,
49
+ ]
50
+
51
+ C = TypeVar("C", bound=BaseConnection)
52
+
53
+
54
+ class BasePostgresStore(BaseStore, Generic[C]):
55
+ MIGRATIONS = MIGRATIONS
56
+ conn: C
57
+
58
+ def _get_batch_GET_ops_queries(
59
+ self,
60
+ get_ops: Sequence[tuple[int, GetOp]],
61
+ ) -> list[tuple[str, tuple, tuple[str, ...], list]]:
62
+ namespace_groups = defaultdict(list)
63
+ for idx, op in get_ops:
64
+ namespace_groups[op.namespace].append((idx, op.key))
65
+ results = []
66
+ for namespace, items in namespace_groups.items():
67
+ _, keys = zip(*items)
68
+ keys_to_query = ",".join(["%s"] * len(keys))
69
+ query = f"""
70
+ SELECT key, value, created_at, updated_at
71
+ FROM store
72
+ WHERE prefix = %s AND key IN ({keys_to_query})
73
+ """
74
+ params = (_namespace_to_ltree(namespace), *keys)
75
+ results.append((query, params, namespace, items))
76
+ return results
77
+
78
+ def _get_batch_PUT_queries(
79
+ self,
80
+ put_ops: Sequence[tuple[int, PutOp]],
81
+ ) -> list[tuple[str, Sequence]]:
82
+ inserts: list[PutOp] = []
83
+ deletes: list[PutOp] = []
84
+ for _, op in put_ops:
85
+ if op.value is None:
86
+ deletes.append(op)
87
+ else:
88
+ inserts.append(op)
89
+
90
+ queries: list[tuple[str, Sequence]] = []
91
+
92
+ if deletes:
93
+ namespace_groups: dict[tuple[str, ...], list[str]] = defaultdict(list)
94
+ for op in deletes:
95
+ namespace_groups[op.namespace].append(op.key)
96
+ for namespace, keys in namespace_groups.items():
97
+ placeholders = ",".join(["%s"] * len(keys))
98
+ query = (
99
+ f"DELETE FROM store WHERE prefix = %s AND key IN ({placeholders})"
100
+ )
101
+ params = (_namespace_to_ltree(namespace), *keys)
102
+ queries.append((query, params))
103
+ if inserts:
104
+ values = []
105
+ insertion_params = []
106
+ for op in inserts:
107
+ values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
108
+ insertion_params.extend(
109
+ [
110
+ _namespace_to_ltree(op.namespace),
111
+ op.key,
112
+ Jsonb(op.value),
113
+ ]
114
+ )
115
+ values_str = ",".join(values)
116
+ query = f"""
117
+ INSERT INTO store (prefix, key, value, created_at, updated_at)
118
+ VALUES {values_str}
119
+ ON CONFLICT (prefix, key) DO UPDATE
120
+ SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
121
+ """
122
+ queries.append((query, insertion_params))
123
+
124
+ return queries
125
+
126
+ def _get_batch_search_queries(
127
+ self,
128
+ search_ops: Sequence[tuple[int, SearchOp]],
129
+ ) -> list[tuple[str, Sequence]]:
130
+ queries: list[tuple[str, Sequence]] = []
131
+ for _, op in search_ops:
132
+ query = """
133
+ SELECT key, value, created_at, updated_at
134
+ FROM store
135
+ WHERE prefix <@ %s
136
+ """
137
+ params: list = [_namespace_to_ltree(op.namespace_prefix)]
138
+
139
+ if op.filter:
140
+ filter_conditions = []
141
+ for key, value in op.filter.items():
142
+ if isinstance(value, list):
143
+ filter_conditions.append("value->%s @> %s::jsonb")
144
+ params.extend([key, json.dumps(value)])
145
+ else:
146
+ filter_conditions.append("value->%s = %s::jsonb")
147
+ params.extend([key, json.dumps(value)])
148
+ query += " AND " + " AND ".join(filter_conditions)
149
+
150
+ query += " LIMIT %s OFFSET %s"
151
+ params.extend([op.limit, op.offset])
152
+
153
+ queries.append((query, params))
154
+ return queries
155
+
156
+ def _get_batch_list_namespaces_queries(
157
+ self,
158
+ list_ops: Sequence[tuple[int, ListNamespacesOp]],
159
+ ) -> list[tuple[str, Sequence]]:
160
+ queries: list[tuple[str, Sequence]] = []
161
+ for _, op in list_ops:
162
+ query = "SELECT DISTINCT subltree(prefix, 0, LEAST(nlevel(prefix), %s)) AS truncated_prefix FROM store"
163
+ # https://www.postgresql.org/docs/current/ltree.html
164
+ # The length of a label path cannot exceed 65535 labels.
165
+ params: list[Any] = [op.max_depth if op.max_depth is not None else 65536]
166
+
167
+ conditions = []
168
+ if op.match_conditions:
169
+ for condition in op.match_conditions:
170
+ if condition.match_type == "prefix":
171
+ conditions.append("prefix ~ %s::lquery")
172
+ lquery_pattern = f"{_namespace_to_ltree(condition.path)}.*"
173
+ params.append(lquery_pattern)
174
+ elif condition.match_type == "suffix":
175
+ conditions.append("prefix ~ %s::lquery")
176
+ lquery_pattern = f"*.{_namespace_to_ltree(condition.path)}"
177
+ params.append(lquery_pattern)
178
+ else:
179
+ logger.warning(
180
+ f"Unknown match_type in list_namespaces: {condition.match_type}"
181
+ )
182
+
183
+ if conditions:
184
+ query += " WHERE " + " AND ".join(conditions)
185
+
186
+ query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s"
187
+ params.extend([op.limit, op.offset])
188
+
189
+ queries.append((query, params))
190
+ return queries
191
+
192
+
193
+ class PostgresStore(BasePostgresStore[Connection]):
194
+ def __init__(self, conn: Connection[Any]) -> None:
195
+ self.conn = conn
196
+
197
+ def batch(self, ops: Iterable[Op]) -> list[Result]:
198
+ grouped_ops, num_ops = _group_ops(ops)
199
+ results: list[Result] = [None] * num_ops
200
+
201
+ with self.conn.pipeline():
202
+ if GetOp in grouped_ops:
203
+ self._batch_get_ops(
204
+ cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
205
+ )
206
+
207
+ if PutOp in grouped_ops:
208
+ self._batch_put_ops(
209
+ cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
210
+ )
211
+
212
+ if SearchOp in grouped_ops:
213
+ self._batch_search_ops(
214
+ cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
215
+ results,
216
+ )
217
+
218
+ if ListNamespacesOp in grouped_ops:
219
+ self._batch_list_namespaces_ops(
220
+ cast(
221
+ Sequence[tuple[int, ListNamespacesOp]],
222
+ grouped_ops[ListNamespacesOp],
223
+ ),
224
+ results,
225
+ )
226
+
227
+ return results
228
+
229
+ async def abatch(self, ops: Iterable[Op]) -> list[Result]:
230
+ return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
231
+
232
+ def _batch_get_ops(
233
+ self,
234
+ get_ops: Sequence[tuple[int, GetOp]],
235
+ results: list[Result],
236
+ ) -> None:
237
+ cursors = []
238
+ for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
239
+ cur = self.conn.cursor(binary=True)
240
+ cur.execute(query, params)
241
+ cursors.append((cur, namespace, items))
242
+
243
+ for cur, namespace, items in cursors:
244
+ rows = cast(list[Row], cur.fetchall())
245
+ key_to_row = {row["key"]: row for row in rows}
246
+ for idx, key in items:
247
+ row = key_to_row.get(key)
248
+ if row:
249
+ results[idx] = _row_to_item(namespace, row)
250
+ else:
251
+ results[idx] = None
252
+
253
+ def _batch_put_ops(
254
+ self,
255
+ put_ops: Sequence[tuple[int, PutOp]],
256
+ ) -> None:
257
+ queries = self._get_batch_PUT_queries(put_ops)
258
+ for query, params in queries:
259
+ cur = self.conn.cursor(binary=True)
260
+ cur.execute(query, params)
261
+
262
+ def _batch_search_ops(
263
+ self,
264
+ search_ops: Sequence[tuple[int, SearchOp]],
265
+ results: list[Result],
266
+ ) -> None:
267
+ queries = self._get_batch_search_queries(search_ops)
268
+ cursors: list[tuple[Cursor[Any], int, SearchOp]] = []
269
+
270
+ for (query, params), (idx, op) in zip(queries, search_ops):
271
+ cur = self.conn.cursor(binary=True)
272
+ cur.execute(query, params)
273
+ cursors.append((cur, idx, op))
274
+
275
+ for cur, idx, op in cursors:
276
+ rows = cast(list[Row], cur.fetchall())
277
+ items = [_row_to_item(op.namespace_prefix, row) for row in rows]
278
+ results[idx] = items
279
+
280
+ def _batch_list_namespaces_ops(
281
+ self,
282
+ list_ops: Sequence[tuple[int, ListNamespacesOp]],
283
+ results: list[Result],
284
+ ) -> None:
285
+ queries = self._get_batch_list_namespaces_queries(list_ops)
286
+ cursors: list[tuple[Cursor[Any], int]] = []
287
+ for (query, params), (idx, _) in zip(queries, list_ops):
288
+ cur = self.conn.cursor(binary=True)
289
+ cur.execute(query, params)
290
+ cursors.append((cur, idx))
291
+
292
+ for cur, idx in cursors:
293
+ rows = cast(list[dict], cur.fetchall())
294
+ namespaces = [
295
+ tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
296
+ ]
297
+ results[idx] = namespaces
298
+
299
+ @classmethod
300
+ @contextmanager
301
+ def from_conn_string(
302
+ cls,
303
+ conn_string: str,
304
+ ) -> Iterator["PostgresStore"]:
305
+ """Create a new BasePostgresStore instance from a connection string.
306
+
307
+ Args:
308
+ conn_string (str): The Postgres connection info string.
309
+
310
+ Returns:
311
+ BasePostgresStore: A new BasePostgresStore instance.
312
+ """
313
+ with Connection.connect(
314
+ conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
315
+ ) as conn:
316
+ yield cls(conn=conn)
317
+
318
+ def setup(self) -> None:
319
+ """Set up the store database.
320
+
321
+ This method creates the necessary tables in the Postgres database if they don't
322
+ already exist and runs database migrations. It MUST be called directly by the user
323
+ the first time the store is used.
324
+ """
325
+ with self.conn.cursor(binary=True) as cur:
326
+ try:
327
+ cur.execute("SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1")
328
+ row = cast(dict, cur.fetchone())
329
+ if row is None:
330
+ version = -1
331
+ else:
332
+ version = row["v"]
333
+ except UndefinedTable:
334
+ self.conn.rollback()
335
+ version = -1
336
+ # Create store_migrations table if it doesn't exist
337
+ cur.execute(
338
+ """
339
+ CREATE TABLE IF NOT EXISTS store_migrations (
340
+ v INTEGER PRIMARY KEY
341
+ )
342
+ """
343
+ )
344
+ for v, migration in enumerate(
345
+ self.MIGRATIONS[version + 1 :], start=version + 1
346
+ ):
347
+ cur.execute(migration)
348
+ cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
349
+
350
+
351
+ class Row(TypedDict):
352
+ key: str
353
+ value: Any
354
+ prefix: bytes
355
+ created_at: datetime
356
+ updated_at: datetime
357
+
358
+
359
+ def _namespace_to_ltree(namespace: tuple[str, ...]) -> str:
360
+ """Convert namespace tuple to ltree-compatible string."""
361
+ return ".".join(namespace)
362
+
363
+
364
+ def _row_to_item(namespace: tuple[str, ...], row: Row) -> Item:
365
+ """Convert a row from the database into an Item."""
366
+ val = row["value"]
367
+ return Item(
368
+ value=val if isinstance(val, dict) else _json_loads(val),
369
+ key=row["key"],
370
+ namespace=namespace,
371
+ created_at=row["created_at"],
372
+ updated_at=row["updated_at"],
373
+ )
374
+
375
+
376
+ def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]:
377
+ grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
378
+ tot = 0
379
+ for idx, op in enumerate(ops):
380
+ grouped_ops[type(op)].append((idx, op))
381
+ tot += 1
382
+ return grouped_ops, tot
383
+
384
+
385
+ def _json_loads(content: Union[bytes, orjson.Fragment]) -> Any:
386
+ if isinstance(content, orjson.Fragment):
387
+ if hasattr(content, "buf"):
388
+ content = content.buf
389
+ else:
390
+ if isinstance(content.contents, bytes):
391
+ content = content.contents
392
+ else:
393
+ content = content.contents.encode()
394
+ return orjson.loads(cast(bytes, content))
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "langgraph-checkpoint-postgres"
3
- version = "1.0.9"
3
+ version = "1.0.10"
4
4
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
5
5
  authors = []
6
6
  license = "MIT"