@farukada/aws-langgraph-dynamodb-ts 1.0.0-rc.1 → 1.0.0-rc.2
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.
- package/README.md +1621 -288
- package/dist/backfill/backfill.d.ts +168 -0
- package/dist/backfill/backfill.js +393 -0
- package/dist/checkpointer/actions/delete-thread.d.ts +44 -4
- package/dist/checkpointer/actions/delete-thread.js +56 -22
- package/dist/checkpointer/actions/get-tuple.d.ts +29 -5
- package/dist/checkpointer/actions/get-tuple.js +41 -13
- package/dist/checkpointer/actions/list.d.ts +42 -10
- package/dist/checkpointer/actions/list.js +86 -57
- package/dist/checkpointer/actions/put-writes.d.ts +41 -9
- package/dist/checkpointer/actions/put-writes.js +62 -48
- package/dist/checkpointer/actions/put.d.ts +82 -10
- package/dist/checkpointer/actions/put.js +166 -44
- package/dist/checkpointer/internal/delta-history.d.ts +112 -0
- package/dist/checkpointer/internal/delta-history.js +252 -0
- package/dist/checkpointer/internal/listing.d.ts +149 -0
- package/dist/checkpointer/internal/listing.js +245 -0
- package/dist/checkpointer/internal/parse.d.ts +262 -0
- package/dist/checkpointer/internal/parse.js +372 -0
- package/dist/checkpointer/internal/pending-writes.d.ts +275 -0
- package/dist/checkpointer/internal/pending-writes.js +588 -0
- package/dist/checkpointer/internal/read.d.ts +130 -0
- package/dist/checkpointer/internal/read.js +264 -0
- package/dist/checkpointer/internal/rows.d.ts +571 -0
- package/dist/checkpointer/internal/rows.js +834 -0
- package/dist/checkpointer/internal/setup.d.ts +42 -21
- package/dist/checkpointer/internal/setup.js +65 -31
- package/dist/checkpointer/saver.d.ts +242 -42
- package/dist/checkpointer/saver.js +257 -53
- package/dist/checkpointer/types.d.ts +37 -44
- package/dist/checkpointer/types.js +10 -0
- package/dist/factory/factory.d.ts +120 -11
- package/dist/factory/factory.js +195 -16
- package/dist/factory/types.d.ts +23 -4
- package/dist/factory/types.js +8 -0
- package/dist/history/actions/add-messages.d.ts +30 -2
- package/dist/history/actions/add-messages.js +37 -57
- package/dist/history/actions/clear.d.ts +46 -4
- package/dist/history/actions/clear.js +64 -15
- package/dist/history/actions/get-messages.d.ts +41 -0
- package/dist/history/actions/get-messages.js +94 -32
- package/dist/history/actions/list-sessions.d.ts +52 -10
- package/dist/history/actions/list-sessions.js +141 -38
- package/dist/history/actions/reconcile-count.d.ts +41 -8
- package/dist/history/actions/reconcile-count.js +44 -57
- package/dist/history/chat-message-history.d.ts +200 -38
- package/dist/history/chat-message-history.js +218 -51
- package/dist/history/internal/append.d.ts +212 -0
- package/dist/history/internal/append.js +500 -0
- package/dist/history/internal/message-read.d.ts +84 -0
- package/dist/history/internal/message-read.js +204 -0
- package/dist/history/internal/parse.d.ts +153 -0
- package/dist/history/internal/parse.js +252 -0
- package/dist/history/internal/rows.d.ts +195 -0
- package/dist/history/internal/rows.js +250 -0
- package/dist/history/internal/session.d.ts +331 -0
- package/dist/history/internal/session.js +628 -0
- package/dist/history/internal/setup.d.ts +52 -19
- package/dist/history/internal/setup.js +90 -27
- package/dist/history/session-adapter.d.ts +95 -6
- package/dist/history/session-adapter.js +99 -8
- package/dist/history/types.d.ts +61 -31
- package/dist/history/types.js +10 -0
- package/dist/index.d.ts +38 -14
- package/dist/index.js +31 -12
- package/dist/shared/adapter.d.ts +135 -0
- package/dist/shared/adapter.js +143 -0
- package/dist/shared/clock.d.ts +48 -4
- package/dist/shared/clock.js +51 -4
- package/dist/shared/codec/codec.d.ts +275 -9
- package/dist/shared/codec/codec.js +394 -19
- package/dist/shared/codec/compression.d.ts +43 -6
- package/dist/shared/codec/compression.js +52 -11
- package/dist/shared/codec/json-serde.d.ts +76 -3
- package/dist/shared/codec/json-serde.js +181 -7
- package/dist/shared/codec/s3/client-types.d.ts +20 -1
- package/dist/shared/codec/s3/client-types.js +20 -1
- package/dist/shared/codec/s3/client.d.ts +35 -8
- package/dist/shared/codec/s3/client.js +61 -10
- package/dist/shared/codec/s3/config.d.ts +226 -22
- package/dist/shared/codec/s3/config.js +274 -24
- package/dist/shared/codec/s3/lifecycle.d.ts +163 -11
- package/dist/shared/codec/s3/lifecycle.js +328 -36
- package/dist/shared/codec/s3/offloader.d.ts +390 -19
- package/dist/shared/codec/s3/offloader.js +590 -50
- package/dist/shared/concurrency.d.ts +40 -3
- package/dist/shared/concurrency.js +55 -7
- package/dist/shared/dynamodb/abort.d.ts +45 -7
- package/dist/shared/dynamodb/abort.js +46 -14
- package/dist/shared/dynamodb/batch-write.d.ts +77 -13
- package/dist/shared/dynamodb/batch-write.js +146 -26
- package/dist/shared/dynamodb/cancellation.d.ts +121 -3
- package/dist/shared/dynamodb/cancellation.js +147 -2
- package/dist/shared/dynamodb/client.d.ts +159 -12
- package/dist/shared/dynamodb/client.js +135 -10
- package/dist/shared/dynamodb/idempotent-write.d.ts +551 -0
- package/dist/shared/dynamodb/idempotent-write.js +593 -0
- package/dist/shared/dynamodb/paginate.d.ts +105 -8
- package/dist/shared/dynamodb/paginate.js +175 -6
- package/dist/shared/dynamodb/partition-delete.d.ts +181 -16
- package/dist/shared/dynamodb/partition-delete.js +312 -43
- package/dist/shared/dynamodb/recency-index.d.ts +231 -0
- package/dist/shared/dynamodb/recency-index.js +377 -0
- package/dist/shared/dynamodb/retry.d.ts +265 -10
- package/dist/shared/dynamodb/retry.js +426 -20
- package/dist/shared/dynamodb/table-schema.d.ts +190 -0
- package/dist/shared/dynamodb/table-schema.js +209 -0
- package/dist/shared/errors/base-error.d.ts +174 -8
- package/dist/shared/errors/base-error.js +154 -7
- package/dist/shared/errors/boundary.d.ts +63 -10
- package/dist/shared/errors/boundary.js +110 -13
- package/dist/shared/errors/classify.d.ts +97 -0
- package/dist/shared/errors/classify.js +257 -0
- package/dist/shared/errors/error-code.d.ts +76 -1
- package/dist/shared/errors/error-code.js +83 -1
- package/dist/shared/errors/errors.d.ts +157 -58
- package/dist/shared/errors/errors.js +218 -93
- package/dist/shared/logging/logger.d.ts +63 -1
- package/dist/shared/logging/logger.js +97 -2
- package/dist/shared/logging/redaction.d.ts +86 -6
- package/dist/shared/logging/redaction.js +265 -24
- package/dist/shared/logging/secret-patterns.d.ts +136 -24
- package/dist/shared/logging/secret-patterns.js +165 -30
- package/dist/shared/logging/truncate.d.ts +197 -0
- package/dist/shared/logging/truncate.js +231 -0
- package/dist/shared/options.d.ts +52 -5
- package/dist/shared/options.js +9 -0
- package/dist/shared/ulid.d.ts +71 -14
- package/dist/shared/ulid.js +77 -14
- package/dist/shared/validation/collaborators.d.ts +141 -0
- package/dist/shared/validation/collaborators.js +188 -0
- package/dist/shared/validation/option-shape.d.ts +89 -0
- package/dist/shared/validation/option-shape.js +113 -0
- package/dist/shared/validation/options.d.ts +142 -5
- package/dist/shared/validation/options.js +284 -46
- package/dist/shared/validation/primitives.d.ts +287 -24
- package/dist/shared/validation/primitives.js +347 -42
- package/dist/shared/validation/ttl.d.ts +61 -12
- package/dist/shared/validation/ttl.js +102 -22
- package/dist/store/actions/list-namespaces.d.ts +76 -5
- package/dist/store/actions/list-namespaces.js +160 -22
- package/dist/store/actions/put.d.ts +33 -7
- package/dist/store/actions/put.js +53 -71
- package/dist/store/actions/reconcile-vector-index.d.ts +28 -8
- package/dist/store/actions/reconcile-vector-index.js +33 -13
- package/dist/store/actions/search.d.ts +34 -5
- package/dist/store/actions/search.js +52 -20
- package/dist/store/internal/batch-plan.d.ts +24 -9
- package/dist/store/internal/batch-plan.js +91 -34
- package/dist/store/internal/filter.d.ts +36 -2
- package/dist/store/internal/filter.js +66 -14
- package/dist/store/internal/get-item.d.ts +45 -0
- package/dist/store/internal/get-item.js +115 -0
- package/dist/store/internal/item-write.d.ts +230 -0
- package/dist/store/internal/item-write.js +463 -0
- package/dist/store/internal/parse.d.ts +225 -0
- package/dist/store/internal/parse.js +350 -0
- package/dist/store/internal/rows.d.ts +355 -0
- package/dist/store/internal/rows.js +447 -0
- package/dist/store/internal/semantic-search.d.ts +146 -12
- package/dist/store/internal/semantic-search.js +317 -15
- package/dist/store/internal/setup.d.ts +86 -21
- package/dist/store/internal/setup.js +209 -26
- package/dist/store/internal/table-search.d.ts +100 -0
- package/dist/store/internal/table-search.js +213 -0
- package/dist/store/internal/vector-index.d.ts +247 -0
- package/dist/store/internal/vector-index.js +546 -0
- package/dist/store/store.d.ts +258 -33
- package/dist/store/store.js +309 -53
- package/dist/store/types.d.ts +74 -28
- package/dist/store/types.js +13 -0
- package/dist/store/vector-backend.d.ts +64 -3
- package/dist/store/vector-backend.js +15 -0
- package/package.json +41 -25
- package/dist/checkpointer/internal/assemble.d.ts +0 -17
- package/dist/checkpointer/internal/assemble.js +0 -38
- package/dist/checkpointer/internal/checkpoint-write-verify.d.ts +0 -24
- package/dist/checkpointer/internal/checkpoint-write-verify.js +0 -67
- package/dist/checkpointer/internal/configurable.d.ts +0 -12
- package/dist/checkpointer/internal/configurable.js +0 -24
- package/dist/checkpointer/internal/fetch.d.ts +0 -19
- package/dist/checkpointer/internal/fetch.js +0 -88
- package/dist/checkpointer/internal/filter-match.d.ts +0 -13
- package/dist/checkpointer/internal/filter-match.js +0 -15
- package/dist/checkpointer/internal/item-reader.d.ts +0 -66
- package/dist/checkpointer/internal/item-reader.js +0 -113
- package/dist/checkpointer/internal/item-writer.d.ts +0 -35
- package/dist/checkpointer/internal/item-writer.js +0 -107
- package/dist/checkpointer/internal/keys.d.ts +0 -41
- package/dist/checkpointer/internal/keys.js +0 -117
- package/dist/checkpointer/internal/list-scope.d.ts +0 -61
- package/dist/checkpointer/internal/list-scope.js +0 -108
- package/dist/checkpointer/internal/pending-sends.d.ts +0 -13
- package/dist/checkpointer/internal/pending-sends.js +0 -29
- package/dist/checkpointer/internal/query.d.ts +0 -21
- package/dist/checkpointer/internal/query.js +0 -42
- package/dist/checkpointer/internal/regular-write.d.ts +0 -28
- package/dist/checkpointer/internal/regular-write.js +0 -66
- package/dist/checkpointer/internal/special-write-cas.d.ts +0 -29
- package/dist/checkpointer/internal/special-write-cas.js +0 -104
- package/dist/checkpointer/internal/special-write-cleanup.d.ts +0 -23
- package/dist/checkpointer/internal/special-write-cleanup.js +0 -50
- package/dist/checkpointer/internal/special-write-verify.d.ts +0 -53
- package/dist/checkpointer/internal/special-write-verify.js +0 -80
- package/dist/checkpointer/internal/stored-channels.d.ts +0 -31
- package/dist/checkpointer/internal/stored-channels.js +0 -70
- package/dist/checkpointer/internal/validation.d.ts +0 -18
- package/dist/checkpointer/internal/validation.js +0 -40
- package/dist/checkpointer/internal/write-guard.d.ts +0 -20
- package/dist/checkpointer/internal/write-guard.js +0 -48
- package/dist/checkpointer/internal/write-index.d.ts +0 -36
- package/dist/checkpointer/internal/write-index.js +0 -41
- package/dist/history/internal/append-saga.d.ts +0 -25
- package/dist/history/internal/append-saga.js +0 -79
- package/dist/history/internal/compensation.d.ts +0 -25
- package/dist/history/internal/compensation.js +0 -92
- package/dist/history/internal/item-mapper.d.ts +0 -9
- package/dist/history/internal/item-mapper.js +0 -27
- package/dist/history/internal/keys.d.ts +0 -16
- package/dist/history/internal/keys.js +0 -48
- package/dist/history/internal/message-chunker.d.ts +0 -13
- package/dist/history/internal/message-chunker.js +0 -67
- package/dist/history/internal/message-transaction.d.ts +0 -25
- package/dist/history/internal/message-transaction.js +0 -61
- package/dist/history/internal/message-window.d.ts +0 -17
- package/dist/history/internal/message-window.js +0 -49
- package/dist/history/internal/query.d.ts +0 -23
- package/dist/history/internal/query.js +0 -39
- package/dist/history/internal/session-count.d.ts +0 -47
- package/dist/history/internal/session-count.js +0 -115
- package/dist/history/internal/session-title.d.ts +0 -19
- package/dist/history/internal/session-title.js +0 -43
- package/dist/history/internal/session-update.d.ts +0 -27
- package/dist/history/internal/session-update.js +0 -69
- package/dist/history/internal/title-generator.d.ts +0 -12
- package/dist/history/internal/title-generator.js +0 -41
- package/dist/history/internal/ttl-anchor.d.ts +0 -24
- package/dist/history/internal/ttl-anchor.js +0 -38
- package/dist/history/internal/validation.d.ts +0 -25
- package/dist/history/internal/validation.js +0 -53
- package/dist/shared/codec/descriptor-keys.d.ts +0 -12
- package/dist/shared/codec/descriptor-keys.js +0 -14
- package/dist/shared/codec/encode.d.ts +0 -13
- package/dist/shared/codec/encode.js +0 -43
- package/dist/shared/codec/payload-loss.d.ts +0 -12
- package/dist/shared/codec/payload-loss.js +0 -38
- package/dist/shared/codec/s3/adapter-config.d.ts +0 -13
- package/dist/shared/codec/s3/adapter-config.js +0 -22
- package/dist/shared/codec/s3/bounded-body.d.ts +0 -14
- package/dist/shared/codec/s3/bounded-body.js +0 -47
- package/dist/shared/codec/s3/delete.d.ts +0 -7
- package/dist/shared/codec/s3/delete.js +0 -28
- package/dist/shared/codec/s3/key-scope.d.ts +0 -23
- package/dist/shared/codec/s3/key-scope.js +0 -44
- package/dist/shared/codec/s3/orphans.d.ts +0 -23
- package/dist/shared/codec/s3/orphans.js +0 -71
- package/dist/shared/codec/s3/read-write.d.ts +0 -18
- package/dist/shared/codec/s3/read-write.js +0 -51
- package/dist/shared/codec/s3/retry.d.ts +0 -5
- package/dist/shared/codec/s3/retry.js +0 -22
- package/dist/shared/constants.d.ts +0 -112
- package/dist/shared/constants.js +0 -115
- package/dist/shared/dynamodb/backoff.d.ts +0 -15
- package/dist/shared/dynamodb/backoff.js +0 -48
- package/dist/shared/dynamodb/conditional-put.d.ts +0 -67
- package/dist/shared/dynamodb/conditional-put.js +0 -76
- package/dist/shared/dynamodb/drain-unprocessed.d.ts +0 -21
- package/dist/shared/dynamodb/drain-unprocessed.js +0 -43
- package/dist/shared/dynamodb/expiry.d.ts +0 -16
- package/dist/shared/dynamodb/expiry.js +0 -29
- package/dist/shared/dynamodb/paginate-core.d.ts +0 -24
- package/dist/shared/dynamodb/paginate-core.js +0 -82
- package/dist/shared/dynamodb/retry-classifier.d.ts +0 -17
- package/dist/shared/dynamodb/retry-classifier.js +0 -118
- package/dist/shared/dynamodb/retry-policy.d.ts +0 -26
- package/dist/shared/dynamodb/retry-policy.js +0 -22
- package/dist/shared/dynamodb/scan.d.ts +0 -14
- package/dist/shared/dynamodb/scan.js +0 -19
- package/dist/shared/dynamodb/types.d.ts +0 -23
- package/dist/shared/dynamodb/types.js +0 -2
- package/dist/shared/errors/upstream-error.d.ts +0 -16
- package/dist/shared/errors/upstream-error.js +0 -27
- package/dist/shared/errors/wrap-error.d.ts +0 -7
- package/dist/shared/errors/wrap-error.js +0 -15
- package/dist/shared/logging/redaction-walk.d.ts +0 -23
- package/dist/shared/logging/redaction-walk.js +0 -112
- package/dist/store/actions/get.d.ts +0 -13
- package/dist/store/actions/get.js +0 -73
- package/dist/store/internal/backend-search.d.ts +0 -4
- package/dist/store/internal/backend-search.js +0 -74
- package/dist/store/internal/candidates.d.ts +0 -23
- package/dist/store/internal/candidates.js +0 -94
- package/dist/store/internal/index-reconcile.d.ts +0 -21
- package/dist/store/internal/index-reconcile.js +0 -118
- package/dist/store/internal/index-sync.d.ts +0 -10
- package/dist/store/internal/index-sync.js +0 -25
- package/dist/store/internal/item-mapper.d.ts +0 -27
- package/dist/store/internal/item-mapper.js +0 -61
- package/dist/store/internal/keys.d.ts +0 -17
- package/dist/store/internal/keys.js +0 -41
- package/dist/store/internal/namespace-match.d.ts +0 -11
- package/dist/store/internal/namespace-match.js +0 -40
- package/dist/store/internal/option-validation.d.ts +0 -13
- package/dist/store/internal/option-validation.js +0 -77
- package/dist/store/internal/overwrite-swap.d.ts +0 -32
- package/dist/store/internal/overwrite-swap.js +0 -68
- package/dist/store/internal/persist.d.ts +0 -28
- package/dist/store/internal/persist.js +0 -64
- package/dist/store/internal/query.d.ts +0 -11
- package/dist/store/internal/query.js +0 -48
- package/dist/store/internal/ranker.d.ts +0 -18
- package/dist/store/internal/ranker.js +0 -39
- package/dist/store/internal/read-existing.d.ts +0 -23
- package/dist/store/internal/read-existing.js +0 -41
- package/dist/store/internal/score-direction.d.ts +0 -31
- package/dist/store/internal/score-direction.js +0 -38
- package/dist/store/internal/search-filter.d.ts +0 -3
- package/dist/store/internal/search-filter.js +0 -10
- package/dist/store/internal/text-path.d.ts +0 -17
- package/dist/store/internal/text-path.js +0 -147
- package/dist/store/internal/validation.d.ts +0 -18
- package/dist/store/internal/validation.js +0 -51
- package/dist/store/internal/write-verify.d.ts +0 -42
- package/dist/store/internal/write-verify.js +0 -71
package/README.md
CHANGED
|
@@ -1,53 +1,206 @@
|
|
|
1
1
|
# @farukada/aws-langgraph-dynamodb-ts
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@farukada/aws-langgraph-dynamodb-ts)
|
|
4
|
-
[](https://github.com/FarukAda/aws-langgraph-dynamodb-ts/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/FarukAda/aws-langgraph-dynamodb-ts/actions/workflows/codeql.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/FarukAda/aws-langgraph-dynamodb-ts)
|
|
5
7
|

|
|
6
8
|

|
|
7
9
|

|
|
8
10
|

|
|
11
|
+
[](https://www.npmjs.com/package/@farukada/aws-langgraph-dynamodb-ts#provenance)
|
|
12
|
+
[](#testing)
|
|
13
|
+
[](https://github.com/sponsors/FarukAda)
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
Built with [LangGraph](https://langchain-ai.github.io/langgraphjs/) · [LangChain](https://github.com/langchain-ai/langchainjs) · [AWS SDK v3](https://aws.amazon.com/sdk-for-javascript/) — [npm](https://www.npmjs.com/package/@farukada/aws-langgraph-dynamodb-ts) · [GitHub](https://github.com/FarukAda/aws-langgraph-dynamodb-ts) · [Issues](https://github.com/FarukAda/aws-langgraph-dynamodb-ts/issues)
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
- **`DynamoDBFactory`** — convenience constructors, including `createAll` (one shared client + a `destroy()`).
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
A DynamoDB persistence layer for [LangGraph](https://langchain-ai.github.io/langgraphjs/) in TypeScript (CommonJS build, consumable from both ESM and CommonJS; Node ≥ 22). It provides three LangGraph/LangChain adapters — a checkpoint saver, a long-term memory store and a chat message history — plus a factory, and all three can live in a single DynamoDB table.
|
|
16
20
|
|
|
17
21
|
Every adapter supports optional **gzip compression**, **S3 offloading** of payloads over DynamoDB's 400 KB item limit, and **TTL-based expiry**. The store additionally supports **vector semantic search** — in-DynamoDB by default, or delegated to a **pluggable `VectorBackend`** (e.g. OpenSearch / pgvector) for large corpora — via any LangChain `Embeddings` implementation.
|
|
18
22
|
|
|
23
|
+
> **Independent project.** Maintained by [Faruk Ada](https://github.com/FarukAda), one person, in their own time — see [SUPPORT.md](SUPPORT.md) for what that means for response times. It is **not affiliated with, endorsed by, or sponsored by** Amazon Web Services, Inc. or LangChain, Inc. "AWS", "Amazon DynamoDB" and "Amazon S3" are trademarks of Amazon.com, Inc. or its affiliates; "LangChain" and "LangGraph" are trademarks of LangChain, Inc. They are used here only to name the service this package talks to and the framework it plugs into.
|
|
24
|
+
|
|
25
|
+
## At a glance
|
|
26
|
+
|
|
27
|
+
| | |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| **What it is** | Three LangGraph/LangChain adapters — a checkpoint saver, a memory store and a chat message history — plus a factory, over one DynamoDB table. [Architecture](#architecture) lists them; [Table schema](#table-schema) shows the layout. |
|
|
30
|
+
| **Maturity** | Release candidates of `1.0` come before `1.0.0`; the npm badge above shows the current version. [Versioning and support](#versioning-and-support) says what each release promises and who maintains it. |
|
|
31
|
+
| **What it costs** | Nothing for the package: you pay for DynamoDB, for S3 when payloads offload, and for your embeddings provider and `VectorBackend` if you use them. [What each operation costs](#what-each-operation-costs) gives the requests per call. |
|
|
32
|
+
| **The limits that bite** | Without `s3`, a payload over 392 KB is refused; `thread_id` and `sessionId` are at most 1024 bytes, other key segments at most 256 bytes, and no identifier may contain `#`. In-DynamoDB semantic search refuses more than 1000 candidates by default (`maxSearchCandidates`, ceiling 100 000); [full table](#limits). |
|
|
33
|
+
| **When it breaks** | One error class, `DynamoDBLangGraphError`, with a `code` from the 20 `ErrorCode` members, a structured `context` and the AWS error as `cause`. [Error handling](#error-handling) is the section to read first. |
|
|
34
|
+
| **When *not* to use it** | A large vector corpus with no external `VectorBackend`, writes to one `thread_id` or `sessionId` beyond one partition's throughput ([known limitations](#known-limitations)), or checkpoints to import from another saver, for which the package has [no importer](#migrating-from-another-checkpointer-or-store). Chat turns written to one session by several processes are ordered by their wall clocks ([chat history semantics](#chat-history-semantics)). |
|
|
35
|
+
|
|
19
36
|
## Table of Contents
|
|
20
37
|
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
38
|
+
- [Key features](#key-features)
|
|
39
|
+
- [Versioning and support](#versioning-and-support)
|
|
40
|
+
- [Architecture](#architecture)
|
|
23
41
|
- [Quick start](#quick-start)
|
|
24
|
-
- [
|
|
25
|
-
- [
|
|
42
|
+
- [Installation](#installation)
|
|
43
|
+
- [Peer dependencies](#peer-dependencies)
|
|
44
|
+
- [Runtime requirements](#runtime-requirements)
|
|
45
|
+
- [Minimal agent](#minimal-agent)
|
|
46
|
+
- [Usage examples](#usage-examples)
|
|
47
|
+
- [Resume a thread and read its history](#resume-a-thread-and-read-its-history)
|
|
48
|
+
- [Long-term memory with semantic search](#long-term-memory-with-semantic-search)
|
|
49
|
+
- [Memory inside a graph](#memory-inside-a-graph)
|
|
26
50
|
- [Chat history](#chat-history)
|
|
27
|
-
- [
|
|
28
|
-
- [
|
|
29
|
-
- [
|
|
51
|
+
- [RunnableWithMessageHistory](#runnablewithmessagehistory)
|
|
52
|
+
- [One client for all three adapters](#one-client-for-all-three-adapters)
|
|
53
|
+
- [Large payloads: S3 offload and compression](#large-payloads-s3-offload-and-compression)
|
|
54
|
+
- [Expiry with TTL](#expiry-with-ttl)
|
|
55
|
+
- [Bring your own DynamoDB client](#bring-your-own-dynamodb-client)
|
|
56
|
+
- [Cancellation and timeouts](#cancellation-and-timeouts)
|
|
57
|
+
- [Listing sessions, threads and namespaces](#listing-sessions-threads-and-namespaces)
|
|
58
|
+
- [Configuration reference](#configuration-reference)
|
|
59
|
+
- [Shared options](#shared-options)
|
|
60
|
+
- [Adapter options](#adapter-options)
|
|
61
|
+
- [Nested options](#nested-options)
|
|
62
|
+
- [Per-call options](#per-call-options)
|
|
63
|
+
- [Retries and backoff](#retries-and-backoff)
|
|
30
64
|
- [Error handling](#error-handling)
|
|
31
65
|
- [Logging](#logging)
|
|
66
|
+
- [Tracing and metrics](#tracing-and-metrics)
|
|
67
|
+
- [Advanced features](#advanced-features)
|
|
68
|
+
- [Gzip compression](#gzip-compression) · [S3 offloading](#s3-offloading) · [Overwrite races and orphaned objects](#overwrite-races-and-orphaned-objects) · [Write idempotency](#write-idempotency) · [What a token guarantees, and what it does not](#what-a-token-guarantees-and-what-it-does-not) · [What a token costs](#what-a-token-costs) · [What a partition delete promises](#what-a-partition-delete-promises) · [What a partition delete costs](#what-a-partition-delete-costs) · [TTL expiry](#ttl-expiry) · [Plain (metadata) search](#plain-metadata-search) · [Semantic search](#semantic-search) · [Vector index consistency](#vector-index-consistency) · [Checkpointer semantics](#checkpointer-semantics) · [Chat history semantics](#chat-history-semantics) · [Differences from `InMemoryStore`](#differences-from-inmemorystore) · [Strong consistency](#strong-consistency)
|
|
69
|
+
- [Known limitations](#known-limitations)
|
|
70
|
+
- [From DynamoDB and S3](#from-dynamodb-and-s3) · [From this package](#from-this-package)
|
|
71
|
+
- [Migrating](#migrating)
|
|
72
|
+
- [Migrating from another checkpointer or store](#migrating-from-another-checkpointer-or-store) · [Migrating from earlier versions](#migrating-from-earlier-versions)
|
|
73
|
+
- [API reference](#api-reference)
|
|
74
|
+
- [DynamoDBSaver](#dynamodbsaver)
|
|
75
|
+
- [DynamoDBStore](#dynamodbstore)
|
|
76
|
+
- [DynamoDBChatMessageHistory](#dynamodbchatmessagehistory)
|
|
77
|
+
- [DynamoDBSessionChatMessageHistory](#dynamodbsessionchatmessagehistory)
|
|
78
|
+
- [DynamoDBFactory](#dynamodbfactory)
|
|
79
|
+
- [Functions and values](#functions-and-values)
|
|
32
80
|
- [Infrastructure setup](#infrastructure-setup)
|
|
81
|
+
- [S3 lifecycle rules](#s3-lifecycle-rules)
|
|
82
|
+
- [Table schema](#table-schema)
|
|
33
83
|
- [IAM permissions](#iam-permissions)
|
|
34
|
-
- [
|
|
84
|
+
- [Multi-tenant deployments](#multi-tenant-deployments) · [Trust boundary](#trust-boundary)
|
|
35
85
|
- [Operations](#operations)
|
|
86
|
+
- [Limits](#limits) · [What each operation costs](#what-each-operation-costs) · [Monitoring](#monitoring) · [Production notes](#production-notes) · [Maintenance operations](#maintenance-operations) · [What can still go wrong](#what-can-still-go-wrong) · [Finding rows whose payload was released](#finding-rows-whose-payload-was-released) · [Lambda and other short-lived runtimes](#lambda-and-other-short-lived-runtimes) · [Multi-tenancy](#multi-tenancy)
|
|
87
|
+
- [Versioning and compatibility](#versioning-and-compatibility)
|
|
88
|
+
- [The public API](#the-public-api) · [The on-disk layout](#the-on-disk-layout) · [Errors, logs and row versions](#errors-logs-and-row-versions) · [Supported runtimes and peers](#supported-runtimes-and-peers) · [Deprecation](#deprecation) · [Not covered](#not-covered) · [Differences from the reference implementations](#differences-from-the-reference-implementations)
|
|
36
89
|
- [Testing](#testing)
|
|
37
|
-
- [
|
|
90
|
+
- [Project structure](#project-structure)
|
|
91
|
+
- [Design decisions and evidence](#design-decisions-and-evidence)
|
|
92
|
+
- [Contributing](#contributing)
|
|
38
93
|
- [License](#license)
|
|
39
94
|
|
|
95
|
+
## Key features
|
|
96
|
+
|
|
97
|
+
| Feature | Description |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| **One table, disjoint key spaces** | All three adapters share one `PK`/`SK` table. Each tags its partition keys with its own prefix — `CHKPT#`, `STORE#`, `HIST#` — which differ in their first character, so one id reused as a `thread_id` and a `sessionId` can never touch the other adapter's rows. [Table schema](#table-schema) |
|
|
100
|
+
| **Tested against LangGraph itself** | The conformance tier runs LangChain's official checkpointer validation suite and a compiled LangGraph graph (interrupt and resume, subgraph namespaces, forks, `Send` fan-out) over the saver; the integration tier checks parity with `InMemoryStore` and `InMemoryChatMessageHistory`. [What the suite proves](#what-the-suite-does-and-does-not-prove) |
|
|
101
|
+
| **S3 offload behind a descriptor** | A payload at or above `s3.thresholdBytes` (default 350 KB) goes to S3 and the row keeps a small versioned descriptor; every write uploads under an id of its own, conditionally, so no two writes share an object. [S3 offloading](#s3-offloading) |
|
|
102
|
+
| **Gzip with a decompression guard** | `compression: { enabled: true }` gzips payloads of at least `minSizeBytes` (default 1 KB) when that saves more than 10%; reads refuse to inflate past `maxDecompressedBytes` (default 50 MiB). [Gzip compression](#gzip-compression) |
|
|
103
|
+
| **TTL with matching S3 lifecycle rules** | `ttl: { days }` or `{ seconds }` stamps a `ttl` attribute and every read hides expired rows during DynamoDB's sweep lag; `ensureS3LifecycleRule()` installs the lifecycle rules that expire the offloaded objects to match. [S3 lifecycle rules](#s3-lifecycle-rules) |
|
|
104
|
+
| **Semantic search, in DynamoDB or delegated** | With an `index`, the store embeds each configured field and ranks by the best-matching vector in process; with a `vectorBackend` it hands similarity search to OpenSearch, pgvector or anything else, and DynamoDB stays the canonical copy. [Semantic search](#semantic-search) |
|
|
105
|
+
| **Listings without table scans** | An opt-in recency index (`indexName`, a GSI on `gsi1pk`/`gsi1sk`) turns `history.listSessions()` and a thread-less `saver.list()` from a `Scan` into sharded `Query`s, newest first; `backfillRecencyIndex` prepares existing rows. [Maintenance operations](#maintenance-operations) |
|
|
106
|
+
| **Cancellation** | The long-running methods take an `AbortSignal`, which reaches the AWS SDK on every DynamoDB request and on both S3 transfers, so a cancel ends a request in flight and rejects with `ABORTED`. [Cancellation](#cancellation) |
|
|
107
|
+
| **One error class, stable codes, validated input** | Every failure is a `DynamoDBLangGraphError` with a branchable `code`; no raw AWS error escapes a public method. Options and identifiers are checked before any request, and a mistake is a `VALIDATION` error naming the field. [Error handling](#error-handling) |
|
|
108
|
+
| **Silent by default, redactable logging** | Nothing is written to your console unless you pass a `logger`; `redactLogger` replaces secret-looking fields with `[REDACTED]` in what you do log. [Logging](#logging) |
|
|
109
|
+
| **Supply-chain provenance** | Published to npm with provenance attestations. [npm provenance](https://www.npmjs.com/package/@farukada/aws-langgraph-dynamodb-ts#provenance) |
|
|
110
|
+
|
|
111
|
+
## Versioning and support
|
|
112
|
+
|
|
113
|
+
- **Semantic versioning.** A **minor** may add exports, optional options and parameters, optional fields on returned objects, and widen accepted inputs. A **patch** only fixes behaviour against what is documented. Removing or renaming an export, making an option required, narrowing an input or changing a return type needs a **major**, preceded by a deprecation.
|
|
114
|
+
- **Which versions get fixes.** Only the current major, `1.x`, is supported, and fixes ship in the latest minor; `0.x` releases are not patched — upgrade to `1.x`. ([SECURITY.md](SECURITY.md))
|
|
115
|
+
- **The data on disk.** Every `1.x` release reads every row a `1.0` release wrote; key formats, required attributes and the payload descriptor change only in a major, with a migration note.
|
|
116
|
+
- **Errors.** `ErrorCode` values are append-only in `1.x`. Error and log *messages* are not covered — branch on `code` and the structured fields, never on text.
|
|
117
|
+
- **Runtimes and the Node floor.** `engines.node` requires Node ≥ 22, and CI runs the full suite on 22, 24 and 26 across Linux, macOS and Windows; consumers are checked against TypeScript 5.x and later. Dropping a Node major after its end of life is a **minor**, announced in the CHANGELOG; a peer range is never narrowed in a patch. Peer ranges are in [Supported runtimes and peers](#supported-runtimes-and-peers).
|
|
118
|
+
- **Production readiness.** The package is presently a release candidate of `1.0` — the npm badge above shows the exact version — and carries 100% branch coverage enforced on every commit; its behaviour is specified against AWS's own documentation and against [recorded live probes](docs/evidence/README.md) where AWS does not say, rather than against assumption. Read [the decision records](docs/decisions/README.md) and this section before depending on it in production.
|
|
119
|
+
- **Who maintains it.** One person, in their own time; response times are best effort ([SUPPORT.md](SUPPORT.md)). Security reports go through [SECURITY.md](SECURITY.md), which commits to an acknowledgement within three business days.
|
|
120
|
+
|
|
121
|
+
Full detail: [Versioning and compatibility](#versioning-and-compatibility).
|
|
122
|
+
|
|
123
|
+
## Architecture
|
|
124
|
+
|
|
125
|
+
```mermaid
|
|
126
|
+
graph LR
|
|
127
|
+
App["Your LangGraph / LangChain app"] --> Saver["DynamoDBSaver"]
|
|
128
|
+
App --> Store["DynamoDBStore"]
|
|
129
|
+
App --> History["DynamoDBChatMessageHistory"]
|
|
130
|
+
Factory["DynamoDBFactory.createAll()"] -. "one shared client" .-> Saver
|
|
131
|
+
Factory -.-> Store
|
|
132
|
+
Factory -.-> History
|
|
133
|
+
Saver --> Table[("DynamoDB table<br/>PK / SK, optional gsi1")]
|
|
134
|
+
Store --> Table
|
|
135
|
+
History --> Table
|
|
136
|
+
Saver -. "payload at or above s3.thresholdBytes" .-> Bucket[("S3 bucket, optional")]
|
|
137
|
+
Store -.-> Bucket
|
|
138
|
+
History -.-> Bucket
|
|
139
|
+
Store -. "embedDocuments / embedQuery" .-> Embeddings["LangChain Embeddings, optional"]
|
|
140
|
+
Store -. "similarity search" .-> Backend["VectorBackend, optional"]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Four classes do the work:
|
|
144
|
+
|
|
145
|
+
- **`DynamoDBSaver`** — checkpoint + pending-writes persistence (`extends BaseCheckpointSaver`).
|
|
146
|
+
- **`DynamoDBStore`** — long-term memory with optional semantic search (`extends BaseStore`).
|
|
147
|
+
- **`DynamoDBChatMessageHistory`** — multi-session chat history, with a single-session adapter (`forSession`) for `RunnableWithMessageHistory`.
|
|
148
|
+
- **`DynamoDBFactory`** — convenience constructors, including `createAll` (one shared client + a `destroy()`).
|
|
149
|
+
|
|
150
|
+
Every payload — a checkpoint, its metadata, a pending write, a store value, a chat message — goes through the same codec.
|
|
151
|
+
|
|
152
|
+
- **On the way in**, the adapter's `serde` serializes it, and zero bytes are refused. With `compression` enabled, bytes of at least `minSizeBytes` are gzipped, and kept gzipped only when that saves more than 10%. With `s3` configured, stored bytes at or above `thresholdBytes` are uploaded with `If-None-Match: *`, under a key made of the row's identifiers and the write's id, with the row's key as S3 metadata. The row keeps a descriptor saying where the payload is.
|
|
153
|
+
- **On the way out**, an offloaded key must lie under the row's own path before it is downloaded. The download is capped at `s3.maxDownloadBytes` and the gunzip at `compression.maxDecompressedBytes`, 50 MiB each by default. The configured `serde` then decodes the bytes.
|
|
154
|
+
|
|
155
|
+
**Checkpoint write — `saver.put`:**
|
|
156
|
+
|
|
157
|
+
1. The config and every identifier are validated before anything is encoded.
|
|
158
|
+
2. The checkpoint, then its metadata, are encoded as above; the saver's `serde` defaults to LangGraph's `JsonPlusSerializer`. If the metadata cannot be encoded, the checkpoint's upload is released at once.
|
|
159
|
+
3. Without `s3`, a payload over 392 KB is refused with a `VALIDATION` error naming `payload` before any write.
|
|
160
|
+
4. One `TransactWriteItems` writes the `META` row (the metadata descriptor, the parent checkpoint id, the recency-index keys) and the `PAYLOAD` row (the checkpoint descriptor), both stamped with the `ttl` when one is configured. It carries a client request token drawn once, so every retry re-sends the identical request and a retry after a lost acknowledgement is not applied twice.
|
|
161
|
+
5. If the transaction fails with `s3` configured and a payload was offloaded, a consistent `GetItem` of the row carrying the offloaded descriptor decides the outcome: the transaction committed after all (success), did not commit (this call's uploads are deleted, the error is thrown), or cannot be told (nothing is deleted, the error is thrown). With nothing offloaded there is nothing to protect: no read is spent and the error is thrown as it came.
|
|
162
|
+
|
|
163
|
+
**Checkpoint read — `saver.getTuple`:**
|
|
164
|
+
|
|
165
|
+
1. A config naming no thread answers `undefined`. Otherwise the `META` row is a consistent `GetItem` when `checkpoint_id` is given, or a consistent newest-first `Query` of the namespace's `META#` rows, 50 per page, keeping the first live one.
|
|
166
|
+
2. A consistent `GetItem` reads the `PAYLOAD` row; when it is not there the answer is `undefined`.
|
|
167
|
+
3. The checkpoint and the metadata are decoded while a consistent `Query` reads every pending `WRITE` row of the checkpoint, uncapped; superseded writes are dropped and the rest decoded `readConcurrency` at a time (8 by default).
|
|
168
|
+
4. A row whose format version `v` is newer than this release understands fails with `FORMAT_UNSUPPORTED`. A `META` row past its `ttl` is treated as absent, however long DynamoDB's sweep lags.
|
|
169
|
+
|
|
170
|
+
**Store — `store.put` and `store.search`:**
|
|
171
|
+
|
|
172
|
+
1. `put` reads the row it replaces with a consistent `GetItem` for its `createdAt`, revision and descriptor.
|
|
173
|
+
2. It embeds with `embedDocuments`: one vector per configured field onto the row, or — with a `vectorBackend` — one vector over the joined fields for the backend instead, never both. `index: false` embeds nothing.
|
|
174
|
+
3. It encodes the value (plain-JSON `JSON_SERDE` by default) under this put's own revision id and writes the row: a `PutItem`, or with `s3` a compare-and-swap on the revision it read — a one-item `TransactWriteItems` with a request token when the payload was offloaded. A failed write is read back before this put's upload is released.
|
|
175
|
+
4. Once the row is committed it releases the object the old row named, then syncs the `vectorBackend` best-effort: it upserts the new vector, or deletes the item's vector when the put has nothing to embed (`index: false`, or no indexable text). A backend failure is logged at `warn`, not thrown, and `reconcileVectorIndex` repairs it.
|
|
176
|
+
5. `search` with a `query` and a `vectorBackend` embeds the query, asks the backend for the top `offset + limit` matches, reads each canonical item from DynamoDB and applies `filter`, doubling the number it asks for until the page is full. It is capped too: a page with `offset + limit` over `maxSearchCandidates`, or one the filter still leaves short at that cap, is refused with a `VALIDATION` error.
|
|
177
|
+
6. Any other `search` — no `query`, or no `vectorBackend` — runs an eventually consistent `Query` of the `STORE#<namespace[0]>` partition (a `Scan` only for the empty prefix `[]`), decodes rows `readConcurrency` at a time and applies `filter` in process — stopping as soon as the page is full when there is nothing to rank, and otherwise refusing more than `maxSearchCandidates` rows before any decode, then ranking every candidate by cosine similarity to the embedded query.
|
|
178
|
+
|
|
179
|
+
**Chat history — `history.addMessages` and `history.getMessages`:**
|
|
180
|
+
|
|
181
|
+
1. `addMessages` validates the session id and every message before anything is sent; with a `ttl`, a consistent `GetItem` of the session row reads the conversation's expiry anchor, which every message then shares.
|
|
182
|
+
2. Each message is encoded under its own ULID (plain-JSON `JSON_SERDE` by default); if one fails, the uploads before it are released.
|
|
183
|
+
3. The messages are cut into chunks of at most 99 messages or 3.5 MB. Each chunk is one `TransactWriteItems` of its message rows plus the update of the `HISTORY#SESSION` row — the message count, `updatedAt`, the title and the TTL anchor — so the count never disagrees with the messages. If a later chunk fails, the chunks already committed are deleted and the session row reverted; a rollback that cannot finish is `COMPENSATION_FAILED`.
|
|
184
|
+
4. `getMessages` is a consistent `Query` of the session's `HISTORY#MSG#` rows — the whole session oldest first, or newest first up to `limit` — skipping expired rows and refusing a row this adapter did not write.
|
|
185
|
+
5. Messages are decoded `readConcurrency` at a time and returned oldest first. A message whose payload is permanently lost is handled by `onCorruptMessage` — `'skip'`, the default, logs it at `error` and leaves it out; `'throw'` fails the read — and every other failure fails the read under either setting.
|
|
186
|
+
|
|
187
|
+
The key each row is stored under is in [Table schema](#table-schema).
|
|
188
|
+
|
|
40
189
|
---
|
|
41
190
|
|
|
42
|
-
##
|
|
191
|
+
## Quick start
|
|
192
|
+
|
|
193
|
+
### Installation
|
|
43
194
|
|
|
44
195
|
```bash
|
|
45
196
|
npm install @farukada/aws-langgraph-dynamodb-ts \
|
|
46
197
|
@aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb \
|
|
47
|
-
@langchain/core @langchain/langgraph-checkpoint
|
|
48
|
-
|
|
198
|
+
@langchain/core @langchain/langgraph-checkpoint \
|
|
199
|
+
@langchain/langgraph
|
|
49
200
|
```
|
|
50
201
|
|
|
202
|
+
`@langchain/langgraph` is your application's own dependency rather than this package's: the minimal agent below imports it to build the graph. The two DynamoDB SDK packages already ship with this package as dependencies; they are listed so that your own code can import them, as [Bring your own DynamoDB client](#bring-your-own-dynamodb-client) does.
|
|
203
|
+
|
|
51
204
|
Optional peer dependencies, installed only if you use the matching feature:
|
|
52
205
|
|
|
53
206
|
```bash
|
|
@@ -62,61 +215,114 @@ The build is CommonJS and works from both module systems:
|
|
|
62
215
|
|
|
63
216
|
```typescript
|
|
64
217
|
import { DynamoDBSaver } from '@farukada/aws-langgraph-dynamodb-ts'; // ESM or TypeScript
|
|
65
|
-
const { DynamoDBSaver } = require('@farukada/aws-langgraph-dynamodb-ts'); // CommonJS
|
|
66
218
|
```
|
|
67
219
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
220
|
+
```js
|
|
221
|
+
const { DynamoDBSaver } = require('@farukada/aws-langgraph-dynamodb-ts'); // CommonJS
|
|
222
|
+
```
|
|
71
223
|
|
|
72
|
-
|
|
224
|
+
### Peer dependencies
|
|
73
225
|
|
|
74
|
-
|
|
|
226
|
+
| Package | Range | Needed for |
|
|
75
227
|
| --- | --- | --- |
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
|
|
80
|
-
Payloads live under one reserved attribute per row kind (`checkpoint`, `metadata`, `value`, `message`) as a **payload descriptor**: `{ schemaVersion: 1, location: 'INLINE' | 'S3', serdeType, compressed, bytes | s3Key }`. This shape is a compatibility contract: unknown fields are ignored, a missing `schemaVersion` reads as 1, and a higher `schemaVersion` or an unknown `location` is refused with a `ValidationError` (field `descriptor`) rather than misread. Offloaded S3 keys embed the row's identifiers base64url-encoded (`<keyPrefix><enc(thread_id)>/<enc(checkpoint_ns)>/…`), which is trivially reversible — treat S3 keys and `S3_OFFLOAD_FAILED` error context as identifier-bearing in your log-redaction policy.
|
|
228
|
+
| `@langchain/core` | `^1.2.11` | every adapter: messages, `Embeddings`, `RunnableConfig` |
|
|
229
|
+
| `@langchain/langgraph-checkpoint` | `^1.1.5` | every adapter: `BaseCheckpointSaver`, `BaseStore`, the serializer protocol |
|
|
230
|
+
| `@aws-sdk/client-s3` | `^3.1132.0` | S3 offloading only; an optional peer |
|
|
231
|
+
| `@langchain/langgraph` | any 1.x release depending on a supported `@langchain/langgraph-checkpoint` | your application's, not a peer: the graphs in the examples below |
|
|
81
232
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
- **Checkpointer** — `PK = CHKPT#<thread_id>`; `SK` = `META#<ns>#<checkpoint_id>` (metadata), `PAYLOAD#<ns>#<checkpoint_id>` (checkpoint), `WRITE#<ns>#<checkpoint_id>#<task>#<idx>#<channel>` (pending writes).
|
|
85
|
-
- **Store** — `PK = STORE#<namespace[0]>` (the scope root); `SK = <namespace[1..]>#<key>`. This makes a scoped prefix search a native `Query` (`PK = root AND begins_with(SK, …)`); only a rootless "search everything" falls back to a `Scan`.
|
|
86
|
-
- **Chat history** — `PK = HIST#<sessionId>`; one item per message at `SK = HISTORY#MSG#<ULID>` (ordered, append-only) plus one `SK = HISTORY#SESSION` metadata item.
|
|
233
|
+
`@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` and `@aws-sdk/util-dynamodb` are regular dependencies and install with the package. What each range is tested against is in [Supported runtimes and peers](#supported-runtimes-and-peers).
|
|
87
234
|
|
|
88
|
-
|
|
235
|
+
### Runtime requirements
|
|
89
236
|
|
|
90
|
-
|
|
237
|
+
- **Node.js** 22 or later; CI runs 22, 24 and 26 on Linux, macOS and Windows.
|
|
238
|
+
- **Module format:** one CommonJS build, usable from both `import` and `require`, as shown above.
|
|
239
|
+
- **TypeScript:** the shipped declarations target TypeScript 5.x and later.
|
|
240
|
+
- **Tree-shaking:** the package declares `"sideEffects": false`.
|
|
241
|
+
- **Bundling:** the optional `@aws-sdk/client-s3` peer is loaded lazily through a dynamic `import()`, so a bundler (esbuild, rollup, webpack) must either have it installed or mark `@aws-sdk/*` external — CDK's `NodejsFunction` does the latter by default, a bare esbuild build does not.
|
|
242
|
+
- **Top-level `await`:** the samples in this README use it, which needs an ES module (a `.mjs` file, `"type": "module"`, or TypeScript emitting ES modules). In CommonJS, wrap a sample's body in an `async` function and call it.
|
|
91
243
|
|
|
92
|
-
|
|
244
|
+
### Minimal agent
|
|
93
245
|
|
|
94
|
-
|
|
246
|
+
The table must exist before the first call: [Infrastructure setup](#infrastructure-setup) creates it, and [IAM permissions](#iam-permissions) lists the actions the adapters call. This is a complete LangGraph agent whose conversation lives in DynamoDB:
|
|
95
247
|
|
|
96
248
|
```typescript
|
|
249
|
+
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
|
250
|
+
import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph';
|
|
97
251
|
import { DynamoDBSaver } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
98
252
|
|
|
253
|
+
declare const model: BaseChatModel; // any LangChain chat model, e.g. ChatBedrockConverse
|
|
254
|
+
|
|
99
255
|
const checkpointer = new DynamoDBSaver({
|
|
100
256
|
tableName: 'langgraph',
|
|
101
257
|
clientConfig: { region: 'eu-west-1' },
|
|
102
258
|
});
|
|
103
259
|
|
|
104
|
-
const
|
|
260
|
+
const agent = new StateGraph(MessagesAnnotation)
|
|
261
|
+
.addNode('model', async (state) => ({ messages: [await model.invoke(state.messages)] }))
|
|
262
|
+
.addEdge(START, 'model')
|
|
263
|
+
.addEdge('model', END)
|
|
264
|
+
.compile({ checkpointer });
|
|
105
265
|
|
|
106
|
-
const
|
|
107
|
-
await
|
|
266
|
+
const thread = { configurable: { thread_id: 'user-42' } };
|
|
267
|
+
await agent.invoke({ messages: [{ role: 'user', content: 'My name is Ada.' }] }, thread);
|
|
108
268
|
|
|
109
|
-
//
|
|
110
|
-
const
|
|
269
|
+
// A later request, even from another process: the conversation is read back from DynamoDB.
|
|
270
|
+
const { messages } = await agent.invoke(
|
|
271
|
+
{ messages: [{ role: 'user', content: 'What is my name?' }] },
|
|
272
|
+
thread,
|
|
273
|
+
);
|
|
274
|
+
console.log(messages.at(-1)?.content);
|
|
111
275
|
|
|
112
|
-
checkpointer.destroy(); // releases the client this
|
|
276
|
+
checkpointer.destroy(); // releases the DynamoDB client this saver created
|
|
113
277
|
```
|
|
114
278
|
|
|
115
|
-
|
|
279
|
+
Every step of the graph writes a checkpoint under `thread_id`, and the second `invoke` starts from the newest one, so the model sees both turns. The saver built its own client from `clientConfig`, which is why `destroy()` closes it; an injected `client` is never closed ([Bring your own DynamoDB client](#bring-your-own-dynamodb-client)). The [examples](examples/README.md) directory has runnable scripts against real AWS, including an agent on a Bedrock chat model whose only memory is `DynamoDBSaver`.
|
|
280
|
+
|
|
281
|
+
## Usage examples
|
|
282
|
+
|
|
283
|
+
Each example below compiles in CI against the package's source. A sample that uses `saver`, `store`, `history`, `model` or `embeddings` without constructing it assumes an adapter built as in the examples around it, a LangChain chat model and a LangChain `Embeddings`. The [examples](examples/README.md) directory holds scripts that run against real AWS.
|
|
284
|
+
|
|
285
|
+
### Resume a thread and read its history
|
|
286
|
+
|
|
287
|
+
Use this when a user comes back to a conversation, or when you need to show, audit or delete what a thread did.
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph';
|
|
291
|
+
|
|
292
|
+
const agent = new StateGraph(MessagesAnnotation)
|
|
293
|
+
.addNode('model', async (state) => ({ messages: [await model.invoke(state.messages)] }))
|
|
294
|
+
.addEdge(START, 'model')
|
|
295
|
+
.addEdge('model', END)
|
|
296
|
+
.compile({ checkpointer: saver });
|
|
297
|
+
|
|
298
|
+
const thread = { configurable: { thread_id: 'user-42' } };
|
|
299
|
+
|
|
300
|
+
// Resume: the graph starts from the thread's newest checkpoint in DynamoDB.
|
|
301
|
+
await agent.invoke({ messages: [{ role: 'user', content: 'Where were we?' }] }, thread);
|
|
302
|
+
|
|
303
|
+
// The newest checkpoint of the thread. A thread with no checkpoint reads as
|
|
304
|
+
// { values: {}, next: [] }, so `messages` may be absent.
|
|
305
|
+
const current = await agent.getState(thread);
|
|
306
|
+
console.log(current.values.messages?.length ?? 0, current.next);
|
|
307
|
+
|
|
308
|
+
// Every checkpoint of the thread, newest first.
|
|
309
|
+
for await (const snapshot of agent.getStateHistory(thread)) {
|
|
310
|
+
console.log(snapshot.config.configurable?.checkpoint_id, snapshot.metadata?.step);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Remove the thread: its checkpoints and pending writes, then (best-effort) their offloaded payloads.
|
|
314
|
+
await saver.deleteThread('user-42');
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`invoke` on an existing `thread_id` continues from its newest checkpoint; on an unknown one it starts empty, and `getState` of an unknown thread returns empty `values` rather than throwing. `getState` reads through `saver.getTuple`, which is strongly consistent, so a checkpoint just written is always seen; `getStateHistory` reads through `saver.list`, which is eventually consistent. `deleteThread` reads the thread's partition once and deletes what it saw, so run it when no graph is still writing to the thread ([Checkpointer semantics](#checkpointer-semantics)).
|
|
318
|
+
|
|
319
|
+
### Long-term memory with semantic search
|
|
320
|
+
|
|
321
|
+
Use the store for facts that outlive one thread — a user's preferences, notes, documents — and search them by meaning, by field values, or both.
|
|
116
322
|
|
|
117
323
|
```typescript
|
|
118
|
-
import { DynamoDBStore } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
119
324
|
import { BedrockEmbeddings } from '@langchain/aws';
|
|
325
|
+
import { DynamoDBStore } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
120
326
|
|
|
121
327
|
const store = new DynamoDBStore({
|
|
122
328
|
tableName: 'langgraph',
|
|
@@ -128,26 +334,89 @@ const store = new DynamoDBStore({
|
|
|
128
334
|
},
|
|
129
335
|
});
|
|
130
336
|
|
|
131
|
-
await store.put(['library'], 'doc-1', {
|
|
132
|
-
|
|
337
|
+
await store.put(['library'], 'doc-1', {
|
|
338
|
+
text: 'Amazon DynamoDB is a serverless NoSQL database',
|
|
339
|
+
kind: 'note',
|
|
340
|
+
stars: 5,
|
|
341
|
+
});
|
|
342
|
+
await store.put(['library'], 'doc-2', {
|
|
343
|
+
text: 'Espresso is a concentrated coffee',
|
|
344
|
+
kind: 'recipe',
|
|
345
|
+
stars: 3,
|
|
346
|
+
});
|
|
133
347
|
|
|
134
|
-
// Metadata filtering (operators: $eq, $ne, $gt, $gte, $lt, $lte)
|
|
135
|
-
await store.search(['library'], { filter: {
|
|
348
|
+
// Metadata filtering (operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin)
|
|
349
|
+
const notes = await store.search(['library'], { filter: { kind: 'note', stars: { $gte: 4 } } });
|
|
350
|
+
const either = await store.search(['library'], { filter: { kind: { $in: ['note', 'recipe'] } } });
|
|
136
351
|
|
|
137
352
|
// Semantic search — ranked by cosine similarity to the query embedding
|
|
138
353
|
const hits = await store.search(['library'], { query: 'cloud database', limit: 5 });
|
|
139
|
-
//=> doc-1
|
|
354
|
+
//=> doc-1 should rank first, with a `score` on each SearchItem
|
|
140
355
|
|
|
141
356
|
await store.get(['library'], 'doc-1');
|
|
142
357
|
await store.delete(['library'], 'doc-1');
|
|
143
358
|
await store.listNamespaces({ prefix: ['library'], maxDepth: 1 });
|
|
359
|
+
|
|
360
|
+
store.destroy();
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
With an `index`, `put` embeds each configured field separately and `search` ranks an item by its best-matching vector, in process; a prefix holding more than `maxSearchCandidates` candidates (default 1000) is refused with a `VALIDATION` error rather than ranked. A filter names top-level fields of the stored value, every condition must hold, and `$gt`/`$gte`/`$lt`/`$lte` compare like types only — numbers with numbers, strings with strings — where `InMemoryStore` converts both sides with `Number()` ([Differences from the reference implementations](#differences-from-the-reference-implementations)). For a corpus larger than that cap, configure a `vectorBackend` ([Semantic search](#semantic-search), [Vector index consistency](#vector-index-consistency)).
|
|
364
|
+
|
|
365
|
+
### Memory inside a graph
|
|
366
|
+
|
|
367
|
+
Use this when a node should recall what it learned about a user in earlier threads, and remember new facts for later ones.
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
import { SystemMessage } from '@langchain/core/messages';
|
|
371
|
+
import {
|
|
372
|
+
END,
|
|
373
|
+
type LangGraphRunnableConfig,
|
|
374
|
+
MessagesAnnotation,
|
|
375
|
+
START,
|
|
376
|
+
StateGraph,
|
|
377
|
+
} from '@langchain/langgraph';
|
|
378
|
+
import { randomUUID } from 'node:crypto';
|
|
379
|
+
|
|
380
|
+
async function respond(state: typeof MessagesAnnotation.State, config: LangGraphRunnableConfig) {
|
|
381
|
+
const userId: unknown = config.configurable?.user_id;
|
|
382
|
+
if (typeof userId !== 'string' || userId === '') {
|
|
383
|
+
throw new Error('configurable.user_id is required: memories are namespaced per user');
|
|
384
|
+
}
|
|
385
|
+
const query = String(state.messages.at(-1)?.content ?? '');
|
|
386
|
+
|
|
387
|
+
const memories = (await config.store?.search(['memories', userId], { query, limit: 3 })) ?? [];
|
|
388
|
+
const recalled = memories.map((memory) => String(memory.value.text)).join('\n');
|
|
389
|
+
|
|
390
|
+
const reply = await model.invoke([
|
|
391
|
+
new SystemMessage(`What you know about this user:\n${recalled}`),
|
|
392
|
+
...state.messages,
|
|
393
|
+
]);
|
|
394
|
+
// Stored verbatim to keep the sample short; a real app would extract facts first.
|
|
395
|
+
await config.store?.put(['memories', userId], randomUUID(), { text: query });
|
|
396
|
+
return { messages: [reply] };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const agent = new StateGraph(MessagesAnnotation)
|
|
400
|
+
.addNode('respond', respond)
|
|
401
|
+
.addEdge(START, 'respond')
|
|
402
|
+
.addEdge('respond', END)
|
|
403
|
+
.compile({ checkpointer: saver, store });
|
|
404
|
+
|
|
405
|
+
await agent.invoke(
|
|
406
|
+
{ messages: [{ role: 'user', content: 'I prefer answers in French.' }] },
|
|
407
|
+
{ configurable: { thread_id: 'thread-7', user_id: 'user-42' } },
|
|
408
|
+
);
|
|
144
409
|
```
|
|
145
410
|
|
|
411
|
+
The checkpointer keeps this thread; the store keeps what outlives it, keyed by user rather than by thread. Inside a graph LangGraph reaches the store through `batch()`, so upstream's `put()`-only namespace rules (no `.` in a label, no `"langgraph"` root) do not apply there, and the per-item `index` argument of `put` is not forwarded ([Production notes](#production-notes), [Differences from `InMemoryStore`](#differences-from-inmemorystore)). Without an `index` on the store, a `search` with a `query` falls back to a plain, unranked search.
|
|
412
|
+
|
|
146
413
|
### Chat history
|
|
147
414
|
|
|
415
|
+
Use this when your application stores a plain message list per session — a chat UI, a support transcript — rather than a LangGraph state.
|
|
416
|
+
|
|
148
417
|
```typescript
|
|
149
|
-
import { DynamoDBChatMessageHistory } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
150
418
|
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
419
|
+
import { DynamoDBChatMessageHistory } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
151
420
|
|
|
152
421
|
const history = new DynamoDBChatMessageHistory({
|
|
153
422
|
tableName: 'langgraph',
|
|
@@ -158,33 +427,67 @@ await history.addMessages('session-1', [new HumanMessage('Hello!')]);
|
|
|
158
427
|
await history.addMessage('session-1', new AIMessage('Hi!'));
|
|
159
428
|
const messages = await history.getMessages('session-1');
|
|
160
429
|
const recent = await history.getMessages('session-1', { limit: 20 }); // newest 20, chronological
|
|
161
|
-
const sessions = await history.listSessions({ maxItems: 500 }); // [{ sessionId, title, messageCount, expiresAt?, ... }]
|
|
162
430
|
await history.clear('session-1');
|
|
431
|
+
|
|
432
|
+
history.destroy();
|
|
163
433
|
```
|
|
164
434
|
|
|
165
|
-
|
|
435
|
+
By default a read returns the whole session. `getMessages(sessionId, { limit, before })` returns a window instead — the newest `limit` messages, or only those appended before `before` — and `history.forSession(sessionId, { limit: 50 })` bounds what the adapter feeds the chain to the newest fifty, so a long-lived session does not grow the prompt without limit.
|
|
436
|
+
|
|
437
|
+
`forSession` checks its arguments when it is called: a malformed session id, a window naming a key other than `limit`, or a `limit` that is not an integer of at least 1 and at most 10,000 throws `VALIDATION` synchronously, rather than returning an adapter that fails on first use. `RunnableWithMessageHistory` calls `getMessageHistory` from inside an async method, so there the throw surfaces as a rejected invocation.
|
|
438
|
+
|
|
439
|
+
Listing sessions is in [Listing sessions, threads and namespaces](#listing-sessions-threads-and-namespaces).
|
|
440
|
+
|
|
441
|
+
### RunnableWithMessageHistory
|
|
442
|
+
|
|
443
|
+
Use this to give a LangChain chain (not a graph) a memory, through the single-session adapter `forSession`.
|
|
166
444
|
|
|
167
445
|
```typescript
|
|
446
|
+
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts';
|
|
168
447
|
import { RunnableWithMessageHistory } from '@langchain/core/runnables';
|
|
448
|
+
import { DynamoDBChatMessageHistory } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
449
|
+
|
|
450
|
+
const history = new DynamoDBChatMessageHistory({
|
|
451
|
+
tableName: 'langgraph',
|
|
452
|
+
clientConfig: { region: 'eu-west-1' },
|
|
453
|
+
});
|
|
169
454
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
455
|
+
const prompt = ChatPromptTemplate.fromMessages([
|
|
456
|
+
['system', 'You are a helpful assistant.'],
|
|
457
|
+
new MessagesPlaceholder('history'),
|
|
458
|
+
['human', '{input}'],
|
|
459
|
+
]);
|
|
460
|
+
|
|
461
|
+
const chat = new RunnableWithMessageHistory({
|
|
462
|
+
runnable: prompt.pipe(model),
|
|
463
|
+
getMessageHistory: (sessionId) => history.forSession(sessionId, { limit: 50 }),
|
|
173
464
|
inputMessagesKey: 'input',
|
|
174
465
|
historyMessagesKey: 'history',
|
|
175
466
|
});
|
|
467
|
+
|
|
468
|
+
const reply = await chat.invoke(
|
|
469
|
+
{ input: 'Hi, I am Ada.' },
|
|
470
|
+
{ configurable: { sessionId: 'session-1' } },
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
history.destroy();
|
|
176
474
|
```
|
|
177
475
|
|
|
178
|
-
|
|
476
|
+
Before each call the chain reads the newest fifty messages of the session into `{history}`; after it, the input and the reply are appended to the session. The window only bounds what is read — every message stays stored until `clear()` or its `ttl`.
|
|
179
477
|
|
|
180
|
-
###
|
|
478
|
+
### One client for all three adapters
|
|
181
479
|
|
|
182
|
-
|
|
480
|
+
Use the factory when one process runs the checkpointer, the store and the history together and should hold one DynamoDB client and one set of defaults.
|
|
183
481
|
|
|
184
482
|
```typescript
|
|
483
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
185
484
|
import { DynamoDBFactory } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
186
485
|
|
|
187
|
-
const factory = new DynamoDBFactory({
|
|
486
|
+
const factory = new DynamoDBFactory({
|
|
487
|
+
clientConfig: { region: 'eu-west-1' },
|
|
488
|
+
ttl: { days: 30 },
|
|
489
|
+
compression: { enabled: true },
|
|
490
|
+
});
|
|
188
491
|
|
|
189
492
|
const { saver, store, history, destroy } = factory.createAll({
|
|
190
493
|
saver: { tableName: 'langgraph' },
|
|
@@ -192,117 +495,461 @@ const { saver, store, history, destroy } = factory.createAll({
|
|
|
192
495
|
history: { tableName: 'langgraph' },
|
|
193
496
|
});
|
|
194
497
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
498
|
+
try {
|
|
499
|
+
await store.put(['users', 'user-42'], 'profile', { text: 'Prefers French' });
|
|
500
|
+
await history.addMessage('session-1', new HumanMessage('Bonjour'));
|
|
501
|
+
// ... compile graphs with saver and store ...
|
|
502
|
+
} finally {
|
|
503
|
+
destroy(); // closes the one shared client
|
|
504
|
+
}
|
|
198
505
|
```
|
|
199
506
|
|
|
507
|
+
`createAll` builds all three adapters on **one shared DynamoDB client** and returns a single `destroy()` that tears everything down.
|
|
508
|
+
|
|
200
509
|
Any section may be omitted (`createAll({ store: { tableName } })` returns `saver` and `history` as `undefined`), the factory's own `ttl`, `compression`, `s3`, `retry` and `logger` apply to every adapter unless a section overrides them, and `createSaver`, `createStore` and `createChatMessageHistory` build one adapter each on its own client with the same defaults.
|
|
201
510
|
|
|
202
|
-
|
|
511
|
+
`destroy()` on an adapter — `DynamoDBSaver`, `DynamoDBStore` (also `stop()`) or `DynamoDBChatMessageHistory` — offers **every** resource it owns its release before it reports anything, and then raises the first failure, so a client that refuses to close can no longer strand the one behind it. A `client` you injected is yours and is never destroyed. The factory's `destroy()` is the deliberate exception: it tears down three adapters at once, so it releases them all, logs any that failed and never throws.
|
|
203
512
|
|
|
204
|
-
|
|
513
|
+
The argument of each `create*` method, and each `createAll` section, is one adapter's options, and a mistake in it is named the way that adapter's constructor names it: `options` for a value that is not an object — `null` included, so a `null` section is refused rather than skipped — and `options.<key>`, `tableName` and so on for one inside it. `createAll` also refuses a key other than `saver`, `store` and `history`, naming `options.<key>`. The factory's own options are checked when it is constructed: options that are not an object, an unknown key, a `client` beside a `clientConfig`, a `clientConfig` that is not an object, and a `logger` missing one of its four methods, since `createAll` logs its own teardown failures through it. Its `ttl`, `compression`, `s3` and `retry` are checked by each adapter that inherits them, since an adapter's own options may replace them.
|
|
205
514
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
| `logger` | `Logger` | all | per-instance logger (default: silent) |
|
|
213
|
-
| `retry` | `{ maxAttempts?, baseDelayMs?, maxDelayMs? }` | all | retry budget and backoff for every DynamoDB call (default 5 attempts, 100 ms base, 5 s cap — see [Retries and backoff](#retries-and-backoff)) |
|
|
214
|
-
| `compression` | `CompressionConfig` | all | `{ enabled, minSizeBytes?, level?, maxDecompressedBytes? }` |
|
|
215
|
-
| `s3` | `S3OffloadConfig` | all | offload large payloads to S3 (see below) |
|
|
216
|
-
| `serde` | `SerializerProtocol` | all | serializer override (checkpointer defaults to LangGraph's; store/history to JSON) |
|
|
217
|
-
| `onCorruptMessage` | `'skip' \| 'throw'` | history only | what `getMessages` does with an item it cannot decode (default `skip`: drop it, log at `error`, return the rest) |
|
|
218
|
-
| `index` | `IndexConfig` | store only | `{ dims, embeddings, fields? }` for semantic search |
|
|
219
|
-
| `vectorBackend` | `VectorBackend` | store only | delegate similarity search to an external index; DynamoDB keeps the canonical item. **Requires `index`** — constructing a store with one and not the other throws |
|
|
220
|
-
| `maxSearchCandidates` | `number` | store only | cap for the in-DB ranker before it errors (default 1000) |
|
|
221
|
-
| `maxScanItems` | `number` | store only | cap on rows read for one call before it errors (default 10000; counts rows, not namespaces). Gates a plain `search()` page only when the page cannot be filled from fewer rows, semantic candidate collection, `listNamespaces()` and `reconcileVectorIndex()` |
|
|
222
|
-
| `vectorScoreDirection` | `'relevance' \| 'distance'` | store only | the direction of the score a `vectorBackend` returns (default `relevance`, higher is better); `distance` negates and re-sorts so a distance-native backend ranks correctly; any other value throws at construction |
|
|
515
|
+
### Large payloads: S3 offload and compression
|
|
516
|
+
|
|
517
|
+
Use this when a checkpoint, a stored value or a message can approach DynamoDB's 400 KB item limit — long tool outputs, documents in state, many messages in one checkpoint.
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
import { DynamoDBSaver } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
223
521
|
|
|
224
|
-
|
|
522
|
+
const saver = new DynamoDBSaver({
|
|
523
|
+
tableName: 'langgraph',
|
|
524
|
+
clientConfig: { region: 'eu-west-1' }, // the S3 client uses this region too
|
|
525
|
+
compression: { enabled: true },
|
|
526
|
+
s3: { bucketName: 'my-langgraph-payloads' },
|
|
527
|
+
ttl: { days: 30 },
|
|
528
|
+
});
|
|
225
529
|
|
|
226
|
-
|
|
530
|
+
await saver.ensureS3LifecycleRule(); // once, from a deployment step
|
|
227
531
|
|
|
228
|
-
|
|
532
|
+
saver.destroy();
|
|
533
|
+
```
|
|
229
534
|
|
|
230
|
-
|
|
535
|
+
Compression gzips a payload of at least 1024 bytes at level 6 and keeps the gzipped form only when it is more than 10% smaller. A stored payload of at least 350 KB goes to S3 under the saver's own prefix, `langgraph-checkpoints/checkpointer/`, and the row keeps a descriptor pointing at it; without `s3`, a payload over 392 KB is refused. The S3 client inherits the DynamoDB `clientConfig.region` unless `s3.clientConfig.region` names another, and it needs the optional `@aws-sdk/client-s3` peer. `ensureS3LifecycleRule()` installs the rules that expire offloaded objects to match the `ttl`; it throws when it cannot write them, so it belongs in a deployment step ([S3 lifecycle rules](#s3-lifecycle-rules), [S3 offloading](#s3-offloading)).
|
|
231
536
|
|
|
232
|
-
|
|
537
|
+
### Expiry with TTL
|
|
233
538
|
|
|
234
|
-
|
|
539
|
+
Use this when conversations and memories should disappear on their own after a retention period.
|
|
235
540
|
|
|
236
|
-
|
|
541
|
+
```typescript
|
|
542
|
+
import {
|
|
543
|
+
DynamoDBChatMessageHistory,
|
|
544
|
+
DynamoDBSaver,
|
|
545
|
+
DynamoDBStore,
|
|
546
|
+
} from '@farukada/aws-langgraph-dynamodb-ts';
|
|
237
547
|
|
|
238
|
-
|
|
548
|
+
const table = { tableName: 'langgraph', clientConfig: { region: 'eu-west-1' } };
|
|
239
549
|
|
|
240
|
-
|
|
550
|
+
const saver = new DynamoDBSaver({ ...table, ttl: { days: 30 } });
|
|
551
|
+
const store = new DynamoDBStore({ ...table, ttl: { days: 365 } });
|
|
552
|
+
const history = new DynamoDBChatMessageHistory({ ...table, ttl: { seconds: 86_400 } });
|
|
241
553
|
|
|
242
|
-
|
|
554
|
+
saver.destroy();
|
|
555
|
+
store.destroy();
|
|
556
|
+
history.destroy();
|
|
557
|
+
```
|
|
243
558
|
|
|
244
|
-
|
|
559
|
+
`ttl` takes one form, `{ days }` or `{ seconds }`, capped at five years, and is written to the `ttl` attribute as Unix-epoch seconds; enable DynamoDB TTL on that attribute for rows to be deleted. DynamoDB deletes an expired row **within a few days of its expiry — it gives no fixed bound** ([DynamoDB TTL docs](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html)), so every read filters rows past their `ttl` in the meantime. Chat history anchors one TTL for the whole conversation on its session row, set when the session is created and shared by every message. Turning `ttl` on for a table that already holds sessions stamps the session row and every *new* message only: message rows written before keep no `ttl` and outlive their session, so clear or backfill those sessions ([TTL expiry](#ttl-expiry)).
|
|
245
560
|
|
|
246
|
-
|
|
561
|
+
### Bring your own DynamoDB client
|
|
247
562
|
|
|
248
|
-
|
|
563
|
+
Use this when your application already configures a DynamoDB client — credentials, a VPC endpoint, tracing middleware — and the adapters should share it.
|
|
249
564
|
|
|
250
|
-
|
|
565
|
+
```typescript
|
|
566
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
567
|
+
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
|
|
568
|
+
import { DynamoDBSaver } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
251
569
|
|
|
252
|
-
|
|
570
|
+
const client = DynamoDBDocument.from(
|
|
571
|
+
new DynamoDBClient({
|
|
572
|
+
region: 'eu-west-1',
|
|
573
|
+
maxAttempts: 1, // the library retries; SDK retries would stack inside its budget
|
|
574
|
+
requestHandler: { requestTimeout: 10_000, throwOnRequestTimeout: true },
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
253
577
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
578
|
+
const saver = new DynamoDBSaver({ tableName: 'langgraph', client });
|
|
579
|
+
saver.destroy(); // does not close `client`: an injected client is yours
|
|
580
|
+
```
|
|
257
581
|
|
|
258
|
-
|
|
582
|
+
The adapter takes a `DynamoDBDocument`, not a raw `DynamoDBClient`, and uses it exactly as handed over. `maxAttempts: 1` keeps the library's retry layer the only one — an injected client whose SDK retries are on logs a `warn` at construction — and the request timeout bounds a single attempt, which `maxAttempts: 1` alone does not ([Retries and backoff](#retries-and-backoff)).
|
|
259
583
|
|
|
260
|
-
|
|
584
|
+
### Cancellation and timeouts
|
|
585
|
+
|
|
586
|
+
Use this to bound how long a request may wait on DynamoDB, or to stop work when the caller has gone away. A signal passed to `invoke` bounds the whole run, the model calls included, so size it for the slowest turn you accept rather than for one DynamoDB request.
|
|
261
587
|
|
|
262
588
|
```typescript
|
|
589
|
+
import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph';
|
|
263
590
|
import { ErrorCode, isDynamoDBLangGraphError } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
264
591
|
|
|
592
|
+
const agent = new StateGraph(MessagesAnnotation)
|
|
593
|
+
.addNode('model', async (state) => ({ messages: [await model.invoke(state.messages)] }))
|
|
594
|
+
.addEdge(START, 'model')
|
|
595
|
+
.addEdge('model', END)
|
|
596
|
+
.compile({ checkpointer: saver });
|
|
597
|
+
const thread = { configurable: { thread_id: 'user-42' } };
|
|
598
|
+
|
|
599
|
+
const signal = AbortSignal.timeout(30_000); // the whole turn: DynamoDB, S3 and the model
|
|
265
600
|
try {
|
|
266
|
-
await
|
|
601
|
+
const recent = await history.getMessages('session-1', { limit: 20, signal });
|
|
602
|
+
await agent.invoke({ messages: [{ role: 'user', content: 'Hello' }] }, { ...thread, signal });
|
|
267
603
|
} catch (error) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
604
|
+
const e = error as Error;
|
|
605
|
+
if (isDynamoDBLangGraphError(e) && e.code === ErrorCode.ABORTED) {
|
|
606
|
+
console.warn('cancelled during a DynamoDB or S3 call', e.context.operation);
|
|
607
|
+
} else if (signal.aborted) {
|
|
608
|
+
console.warn('cancelled by LangGraph outside a saver call');
|
|
609
|
+
} else {
|
|
610
|
+
throw error;
|
|
271
611
|
}
|
|
272
612
|
}
|
|
273
613
|
```
|
|
274
614
|
|
|
275
|
-
|
|
276
|
-
| --- | --- | --- |
|
|
277
|
-
| `VALIDATION` | `ValidationError` | every constructor for a bad option; every method for a bad identifier, key, window or value; S3 offload configured without the `@aws-sdk/client-s3` peer; a descriptor the reader cannot honour |
|
|
278
|
-
| `UPSTREAM` | `UpstreamError` | every public method, wrapping an AWS SDK error that was not retryable or that the library does not classify (`AccessDeniedException`, `ResourceNotFoundException`, `ValidationException`, …) |
|
|
279
|
-
| `RETRY_EXHAUSTED` | `RetryExhaustedError` | every DynamoDB call after `retry.maxAttempts` transient failures (`context.attempts`, the last error as `cause`) |
|
|
280
|
-
| `ABORTED` | `AbortError` | any cancellable method whose `AbortSignal` fired |
|
|
281
|
-
| `CONDITION_CONFLICT` | `ConflictError` | `history.reconcileMessageCount` when the session changed while it counted |
|
|
282
|
-
| `COMPENSATION_FAILED` | `CompensationFailedError` | `history.addMessages` / `addMessage` when a multi-chunk append failed and the rollback of the committed chunks failed too (`rollbackError`; run `reconcileMessageCount`) |
|
|
283
|
-
| `BATCH_WRITE_INCOMPLETE` | `BatchWriteAllIncompleteError` | `saver.deleteThread`, `history.clear` when a multi-chunk delete does not fully drain (`succeededCount`, `failedChunks`); `BatchWriteIncompleteError` is the per-chunk error inside it |
|
|
284
|
-
| `RESULT_TRUNCATED` | `ResultTruncatedError` | the paginated reads that keep rows in memory — `store.search`, `store.listNamespaces`, `store.reconcileVectorIndex`, `history.listSessions` — past `maxScanItems` / `maxItems` / `maxIterations` |
|
|
285
|
-
| `S3_OFFLOAD_FAILED` | `DynamoDBLangGraphError` | an upload, download or delete of an offloaded object that failed after the S3 retries, an object over `maxDownloadBytes`, or an object that no longer exists (`context.key`) |
|
|
286
|
-
| `COMPRESSION_LIMIT` | `DynamoDBLangGraphError` | a payload whose decompressed size would exceed `maxDecompressedBytes` |
|
|
615
|
+
The signal reaches the AWS SDK on every DynamoDB request and S3 transfer, so a cancel ends a request in flight and rejects with `ABORTED`. LangGraph passes `config.signal` to the saver, and it also checks the signal itself: a timeout that fires while a node runs rejects `invoke` with LangGraph's own error rather than `ABORTED`, which is why the sample tests `signal.aborted` as well. `store.get`, `store.put` and `store.delete` take no signal, because upstream's `BaseStore` gives them no parameter for one ([Cancellation](#cancellation)).
|
|
287
616
|
|
|
288
|
-
|
|
617
|
+
### Listing sessions, threads and namespaces
|
|
289
618
|
|
|
290
|
-
|
|
619
|
+
Use this for an admin view or a "your conversations" page. Paging sessions by cursor needs the recency index, so the adapters here are built with `indexName`.
|
|
291
620
|
|
|
292
|
-
|
|
621
|
+
```typescript
|
|
622
|
+
import { DynamoDBChatMessageHistory, DynamoDBSaver } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
623
|
+
|
|
624
|
+
const table = { tableName: 'langgraph', clientConfig: { region: 'eu-west-1' }, indexName: 'gsi1' };
|
|
625
|
+
const history = new DynamoDBChatMessageHistory(table);
|
|
626
|
+
const saver = new DynamoDBSaver(table);
|
|
627
|
+
|
|
628
|
+
// With `indexName`: newest-updated first, paged by cursor.
|
|
629
|
+
// Each page is { sessions: [{ sessionId, title, messageCount, expiresAt?, ... }], nextCursor?: string }
|
|
630
|
+
let cursor: string | undefined;
|
|
631
|
+
do {
|
|
632
|
+
const page = await history.listSessions({ limit: 50, cursor });
|
|
633
|
+
for (const session of page.sessions) {
|
|
634
|
+
console.log(session.sessionId, session.title, session.messageCount, session.updatedAt);
|
|
635
|
+
}
|
|
636
|
+
cursor = page.nextCursor;
|
|
637
|
+
} while (cursor);
|
|
638
|
+
|
|
639
|
+
// Checkpoints of every thread in the table, newest first, through the recency index.
|
|
640
|
+
for await (const tuple of saver.list({}, { limit: 20 })) {
|
|
641
|
+
console.log(tuple.config.configurable?.thread_id);
|
|
642
|
+
}
|
|
293
643
|
|
|
294
|
-
|
|
644
|
+
await store.listNamespaces({ prefix: ['memories'], maxDepth: 2 });
|
|
295
645
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
646
|
+
history.destroy();
|
|
647
|
+
saver.destroy();
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
A `cursor` requires `indexName` and is refused without it. Without `indexName`, `listSessions` is a table scan that cannot be paged: `{ limit: 50 }` returns the newest fifty, and omitting `limit` returns every session. That unpaged scan is still capped: past `maxItems` rows (default 10 000) or `maxIterations` pages (default 1000) it fails with `RESULT_TRUNCATED` rather than returning a partial list. Stop when `nextCursor` is absent, not when a page looks short — expired rows are dropped after the read. These listings cross tenants ([Multi-tenant deployments](#multi-tenant-deployments)), and `backfillRecencyIndex()` must run before `indexName` is set on a table that already holds rows ([Maintenance operations](#maintenance-operations)); the index definition is in [Infrastructure setup](#infrastructure-setup).
|
|
651
|
+
|
|
652
|
+
## Configuration reference
|
|
653
|
+
|
|
654
|
+
All adapters share a common base. Provide **either** a prebuilt `client` (which the adapter will not own/close) **or** `clientConfig` (the adapter builds and owns the client).
|
|
655
|
+
|
|
656
|
+
Options are checked at construction, and a mistake raises `VALIDATION` naming the option:
|
|
657
|
+
|
|
658
|
+
- **Unknown keys.** An option key the adapter does not read — a misspelling such as `readConcurency`, or an option that belongs to another adapter, such as `vectorBackend` on a saver — is refused, naming `options.<key>`, including in a `DynamoDBFactory` section. So is a key `ttl`, `retry`, `compression`, `s3` or `index` does not read (`ttl.<key>`, `index.<key>`, …): `ttl` takes only `days` or `seconds`, and `index` only `dims`, `embeddings` and `fields`.
|
|
659
|
+
- **AWS SDK configuration.** `clientConfig` and `s3.clientConfig` must be objects when given, so a string, `null` or an array is refused, naming `clientConfig` or `s3.clientConfig`. The keys inside them are passed to the AWS SDK unchecked: they belong to the SDK's `DynamoDBClientConfig` and `S3ClientConfig`, which gain keys between SDK releases, and your application may install a newer SDK than the one this package was built against, so a key list checked here would refuse valid configuration.
|
|
660
|
+
- **Collaborators.** `client`, `logger`, `serde`, `index.embeddings` and `vectorBackend` are checked by shape, not by class, so the check holds when two copies of a dependency are installed. A value that is not an object (`null` included) names the option; an object missing a method this package calls names the first one missing, such as `client.get` or `logger.debug`.
|
|
661
|
+
- **Ceilings.** A numeric option above its ceiling is refused. The ceilings are in the tables below and in [Limits](#limits).
|
|
662
|
+
|
|
663
|
+
In the tables below, *Default* is what an omitted option means and *Ceiling* is the largest value accepted; "—" means there is none. Every numeric option takes an integer. A constructor option takes one of at least 1, except `compression.level` and `compression.minSizeBytes`, which also accept `0`; a per-call `limit` or `offset` accepts `0` too, as its row says. `index.dims` is the one exception: it is not checked at construction at all, and only a positive integer is compared with the vectors the embeddings return — any other value turns that comparison off.
|
|
664
|
+
|
|
665
|
+
### Shared options
|
|
666
|
+
|
|
667
|
+
Every adapter — `DynamoDBSaver`, `DynamoDBStore` and `DynamoDBChatMessageHistory` — reads these.
|
|
668
|
+
|
|
669
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
670
|
+
| --- | --- | --- | --- | --- |
|
|
671
|
+
| `tableName` | `string` | **required** | — | 3–255 characters from `[A-Za-z0-9_.-]`, the rule DynamoDB applies |
|
|
672
|
+
| `client` | `DynamoDBDocument` | — | — | a client you built and keep; see the note below the table |
|
|
673
|
+
| `clientConfig` | `DynamoDBClientConfig` | none: the SDK resolves the region and credentials itself | — | used to build a client when `client` is omitted; see the note below the table |
|
|
674
|
+
| `ttl` | `{ days: number }` \| `{ seconds: number }` | none: rows never expire | five years ([nested options](#nested-options)) | expiry written to the `ttl` attribute; one form only and no other key |
|
|
675
|
+
| `logger` | `Logger` | silent | — | per-instance logger; all four methods — `debug`, `info`, `warn`, `error` — are required ([Logging](#logging)) |
|
|
676
|
+
| `retry` | `RetryPolicy`: `{ maxAttempts?, baseDelayMs?, maxDelayMs? }` | 5 attempts, 100 ms base, 5 000 ms cap | 100 attempts, 60 000 ms for either delay | retry budget and backoff for every DynamoDB call ([nested options](#nested-options), [Retries and backoff](#retries-and-backoff)) |
|
|
677
|
+
| `compression` | `CompressionConfig` | none: payloads are stored uncompressed | per field ([nested options](#nested-options)) | gzip for payloads of at least `minSizeBytes` ([Gzip compression](#gzip-compression)) |
|
|
678
|
+
| `s3` | `S3OffloadConfig` | none: a payload over 392 KB is refused | per field ([nested options](#nested-options)) | offload large payloads to S3; needs the optional `@aws-sdk/client-s3` peer ([S3 offloading](#s3-offloading)) |
|
|
679
|
+
| `serde` | `SerializerProtocol` | saver: LangGraph's `JsonPlusSerializer`; store and history: the exported `JSON_SERDE` (plain JSON) | — | serializer override; must provide `dumpsTyped` and `loadsTyped`. See the note below the table |
|
|
680
|
+
| `indexName` | `string` | none: the listings that cross partitions scan the table | — | the name of the recency index (a GSI on `gsi1pk`/`gsi1sk`); a non-empty string. See the note below the table |
|
|
681
|
+
| `indexShards` | `number` | 8 | 1024 | index partitions per adapter. Fixed when the table is created: changing it changes every row's shard and requires another backfill. One partition per adapter would concentrate every listing on one key, which is worse than the scan it replaces |
|
|
682
|
+
| `readConcurrency` | `number` | 8 | 128 | payloads decoded at once by a single call. It is the multiplier on this package's memory ceiling — `readConcurrency × (s3.maxDownloadBytes + compression.maxDecompressedBytes)`, 800 MiB at the defaults — so lower it on a small container. It also bounds how many recency-index shards one listing queries at once |
|
|
683
|
+
|
|
684
|
+
**`client`** — reuse an existing client; not closed by `destroy()`. Passing it together with `clientConfig` is refused, naming `client`. It must provide `get`, `put`, `delete`, `update`, `query`, `scan`, `batchWrite` and `transactWrite`, so a raw `DynamoDBClient` is refused. Construct it with `maxAttempts: 1` **and a request timeout of its own** (`DynamoDBDocument.from(new DynamoDBClient({ maxAttempts: 1, requestHandler: { requestTimeout: 10_000, throwOnRequestTimeout: true }, … }))`): the SDK's own retries are not disabled on an injected client and would stack inside the library's retry budget — a `warn` is logged at construction when they would — and an injected client is used exactly as handed over, so one with no handler timeout leaves a single attempt unbounded, which `maxAttempts: 1` does not fix (see [Retries and backoff](#retries-and-backoff))
|
|
685
|
+
|
|
686
|
+
**`clientConfig`** — used to build a client when `client` is omitted; it must be an object, and its keys go to the AWS SDK unchecked. The client built from it gets `maxAttempts: 1` and a request handler with a 10 s request timeout and a 5 s socket timeout, unless the config names its own `maxAttempts` or `requestHandler` ([Retries and backoff](#retries-and-backoff)).
|
|
687
|
+
|
|
688
|
+
**`serde`** — serializer override; must provide `dumpsTyped` and `loadsTyped`. The checkpointer defaults to LangGraph's `JsonPlusSerializer`, the store and history adapters to the exported `JSON_SERDE` (plain JSON), and the two disagree in both directions — what each silently substitutes, and the one output no serializer may produce, is tabulated in [Table schema](#table-schema); what each does on the *read* is in [Trust boundary](#trust-boundary).
|
|
689
|
+
|
|
690
|
+
A serde that stamps a `serdeType` other than `json` is taken at its word: this package has no grammar for that form, so it cannot tell a payload that rotted from one the serializer declined to rebuild, and every failure that serde raises is reported as the `serde` `VALIDATION` — never quarantined as `PAYLOAD_CORRUPT`, and so never dropped by `onCorruptMessage: 'skip'`. `JSON_SERDE` holds itself to the same rule from the other side: it reads only the `json` form it writes and refuses any other with that same `VALIDATION` error, before a byte is parsed
|
|
691
|
+
|
|
692
|
+
**`indexName`** — the name of the recency index (a GSI on `gsi1pk`/`gsi1sk`) on this table. Naming it turns `history.listSessions()` and a thread-less `saver.list()` from a table scan into a read of the index: each shard is read newest-first one DynamoDB page at a time, and its next page whenever it has no row buffered and the page being built still needs one — which can be a query whose rows that page never takes — with at most `readConcurrency` shards queried at once. A listing holds the page it is building, up to `limit` rows for `listSessions` (whose `limit` is capped at 10,000) and 100 rows at a time for `saver.list`, plus at most one DynamoDB page (up to 1 MB) per shard. Opt-in: whether the table has the index is your deployment fact, not something this package probes for. **Run `backfillRecencyIndex()` before setting it** — a row written before the index carries no keys, so the listings that read it would not find rows that are still there
|
|
693
|
+
|
|
694
|
+
### Adapter options
|
|
695
|
+
|
|
696
|
+
**`DynamoDBSaver`** takes nothing beyond the shared options. Its `serde` defaults to LangGraph's `JsonPlusSerializer`, the base class's default, and not to the `JSON_SERDE` the other two adapters use.
|
|
697
|
+
|
|
698
|
+
**`DynamoDBStore`** also takes:
|
|
699
|
+
|
|
700
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
701
|
+
| --- | --- | --- | --- | --- |
|
|
702
|
+
| `index` | `IndexConfig`: `{ dims, embeddings, fields? }` | none: no semantic search | — | semantic search; `embeddings` must provide `embedQuery` and `embedDocuments`, and `fields`, when given, is an array of strings. Any other key is refused, and so is a value that is not an object, `null` included, rather than read as no index. `fields` defaults to `['$']`, the whole value; `dims`, when it is a positive integer, is checked against the length of every vector the embeddings return, and a mismatch is a `VALIDATION` error naming `index.dims` |
|
|
703
|
+
| `vectorBackend` | `VectorBackend` | none: ranking happens in process | — | delegate similarity search to an external index; DynamoDB keeps the canonical item. It must provide `upsert`, `query` and `delete`; `listKeys` is optional (see [Vector index consistency](#vector-index-consistency)). **Requires `index`** — constructing a store with a `vectorBackend` and no `index` throws |
|
|
704
|
+
| `maxSearchCandidates` | `number` | 1000 | 100 000 | cap for the in-DB ranker before it errors, and the furthest a `vectorBackend` page may reach (`offset + limit`) |
|
|
705
|
+
| `maxScanItems` | `number` | 10 000 | 1 000 000 | cap on rows read for one call before it errors; it counts rows, not namespaces. Gates a plain `search()` page only when the page cannot be filled from fewer rows, semantic candidate collection, `listNamespaces()` and `reconcileVectorIndex()` |
|
|
706
|
+
| `vectorScoreDirection` | `'relevance' \| 'distance'` | `'relevance'` | — | the direction of the score a `vectorBackend` returns (`relevance`: higher is better); `distance` negates and re-sorts so a distance-native backend ranks correctly; any other value throws at construction |
|
|
707
|
+
|
|
708
|
+
**`DynamoDBChatMessageHistory`** also takes:
|
|
709
|
+
|
|
710
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
711
|
+
| --- | --- | --- | --- | --- |
|
|
712
|
+
| `onCorruptMessage` | `'skip' \| 'throw'` | `'skip'` | — | see the note below the table |
|
|
713
|
+
|
|
714
|
+
**`onCorruptMessage`** — what `getMessages` does with an item it cannot decode (default `skip`: drop it, log at `error`, return the rest). It covers a payload nobody can read — bytes that are no longer the form the row declares, a gone S3 object, a descriptor that is not one, a decompression-guard trip. It does **not** cover a row or a payload a newer release wrote (`FORMAT_UNSUPPORTED`), a row whose `s3Key` lies outside its own path, a payload whose bytes are intact and whose serializer merely refuses to rebuild the value they name (`VALIDATION`, field `serde`), nor any infrastructure failure (a throttle, a permission, a transport error): every one of them rejects the read under either policy, because a silently shorter conversation is what the chain re-persists as the truth.
|
|
715
|
+
|
|
716
|
+
**`DynamoDBFactory`** — `new DynamoDBFactory(base)` takes the defaults every adapter it builds inherits (`FactoryBaseOptions`); a per-adapter option wins.
|
|
717
|
+
|
|
718
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
719
|
+
| --- | --- | --- | --- | --- |
|
|
720
|
+
| `client` | `DynamoDBDocument` | — | — | reused as-is by every adapter the factory builds, and never destroyed by it; construct it with `maxAttempts: 1`, as for an adapter's own `client` |
|
|
721
|
+
| `clientConfig` | `DynamoDBClientConfig` | none | — | what each `create*` call builds its own client from, and what `createAll` builds its one shared client from; its `region` is also given to an `s3` config that names none |
|
|
722
|
+
| `logger`, `ttl`, `compression`, `s3`, `retry` | as in [Shared options](#shared-options) | as there | as there | applied to every adapter the factory builds |
|
|
723
|
+
|
|
724
|
+
Anything else — `tableName`, `serde`, `indexName`, `indexShards`, `readConcurrency` and each adapter's own options — is given per adapter. `createSaver(options)`, `createStore(options)` and `createChatMessageHistory(options)` each take one adapter's full options, laid over the factory's defaults, and each builds a client of its own from `clientConfig`, or reuses the factory's `client`; a `client` or `clientConfig` given there replaces the factory's client choice as a unit, while the shared `logger`, `ttl`, `compression`, `s3` and `retry` still apply. `createAll({ saver?, store?, history? })` builds one shared client, or uses the factory's `client`, and takes a section per adapter: that adapter's options without `client` and `clientConfig` (`AdapterSection`). It builds only the adapters whose sections are given, refuses any other key, and returns them with one `destroy()` that releases all of them and the client it built — never a `client` the factory was given.
|
|
725
|
+
|
|
726
|
+
### Nested options
|
|
727
|
+
|
|
728
|
+
**`compression`** (`CompressionConfig`):
|
|
729
|
+
|
|
730
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
731
|
+
| --- | --- | --- | --- | --- |
|
|
732
|
+
| `compression.enabled` | `boolean` | **required** | — | `false` stores every payload uncompressed |
|
|
733
|
+
| `compression.minSizeBytes` | `number` | 1024 (1 KB) | 512 MiB | a smaller payload is not gzipped, and `0` tries every payload; a gzipped one is kept only when it is more than 10% smaller |
|
|
734
|
+
| `compression.level` | `number` | 6 | 9 | the zlib level, 0–9 |
|
|
735
|
+
| `compression.maxDecompressedBytes` | `number` | 50 MiB | 512 MiB | a read refuses to inflate a payload past it, with `COMPRESSION_LIMIT` |
|
|
736
|
+
|
|
737
|
+
**`s3`** (`S3OffloadConfig`):
|
|
738
|
+
|
|
739
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
740
|
+
| --- | --- | --- | --- | --- |
|
|
741
|
+
| `s3.bucketName` | `string` | **required** | — | a non-empty string |
|
|
742
|
+
| `s3.keyPrefix` | `string` | the adapter's own: `langgraph-checkpoints/checkpointer/`, `langgraph-checkpoints/store/` or `langgraph-checkpoints/history/` | — | a path ending in `/`; the rules are in the paragraph below |
|
|
743
|
+
| `s3.thresholdBytes` | `number` | 350 KB (358 400 bytes) | 392 KB (401 408 bytes), the largest payload stored inline | a stored payload at or above it is uploaded to S3 |
|
|
744
|
+
| `s3.serverSideEncryption` | `string` | `'AES256'` | — | one of `'AES256'`, `'aws:kms'` and `'aws:kms:dsse'` |
|
|
745
|
+
| `s3.sseKmsKeyId` | `string` | none | — | the KMS key for `'aws:kms'`; a non-empty string |
|
|
746
|
+
| `s3.maxDownloadBytes` | `number` | 50 MiB | 512 MiB | the largest offloaded object a read buffers, or `S3_OFFLOAD_FAILED` |
|
|
747
|
+
| `s3.clientConfig` | `S3ClientConfig` (typed `S3ClientConfigLike`) | none, but for the region the DynamoDB `clientConfig` names | — | the S3 client is built from it with `maxAttempts: 1` and a 5 s socket timeout, unless it names its own `maxAttempts` or `requestHandler` |
|
|
748
|
+
|
|
749
|
+
**`retry`** (`RetryPolicy`):
|
|
750
|
+
|
|
751
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
752
|
+
| --- | --- | --- | --- | --- |
|
|
753
|
+
| `retry.maxAttempts` | `number` | 5 | 100 | attempts per DynamoDB call before `RETRY_EXHAUSTED`; a chat-history append never uses fewer than 18 |
|
|
754
|
+
| `retry.baseDelayMs` | `number` | 100 | 60 000 | the first backoff delay, in milliseconds |
|
|
755
|
+
| `retry.maxDelayMs` | `number` | 5 000 | 60 000 | the cap on one backoff delay, in milliseconds; at least `baseDelayMs` when that is given |
|
|
756
|
+
|
|
757
|
+
**`ttl`** (`TtlOption`) — exactly one of:
|
|
758
|
+
|
|
759
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
760
|
+
| --- | --- | --- | --- | --- |
|
|
761
|
+
| `ttl.days` | `number` | — | 1825 (five years) | whole days |
|
|
762
|
+
| `ttl.seconds` | `number` | — | 157 680 000 (five years) | whole seconds |
|
|
763
|
+
|
|
764
|
+
`S3OffloadConfig`: `{ bucketName, keyPrefix?, thresholdBytes?, serverSideEncryption?, sseKmsKeyId?, maxDownloadBytes?, clientConfig? }`. `clientConfig` takes an `S3ClientConfig`; it is typed structurally (`S3ClientConfigLike`), so the shipped declarations compile whether or not `@aws-sdk/client-s3` is installed. Like the adapter's own `clientConfig`, it must be an object, and its keys go to the SDK unchecked. `sseKmsKeyId`, when given, must be a non-empty string; whether it names a key you can use is for S3 to answer.
|
|
765
|
+
|
|
766
|
+
When `clientConfig.region` is omitted here, the S3 client inherits the adapter's DynamoDB `clientConfig.region` (the S3 SDK does not follow region redirects, so a cross-region bucket otherwise fails with `PermanentRedirect`). `maxDownloadBytes` caps the size of an offloaded object the adapter will buffer from S3 — checked against `ContentLength` before the body is read, and while streaming when the length is unknown — so together with `maxDecompressedBytes` no single payload can claim more memory than you allow. For a customer-managed key, set `serverSideEncryption: 'aws:kms'` plus `sseKmsKeyId`.
|
|
767
|
+
|
|
768
|
+
When `keyPrefix` is omitted, each adapter defaults to its own sub-prefix under the shared base (`langgraph-checkpoints/store/`, `langgraph-checkpoints/checkpointer/`, `langgraph-checkpoints/history/`) so that multiple adapters can safely share one bucket — their offloaded object keys and `ensureS3LifecycleRule()` TTL rules never collide. An explicit `keyPrefix` is always honored verbatim, including across adapters if you want them to share one; at that point avoiding a lifecycle-rule collision (e.g. by giving them the same TTL) is your responsibility, same as with any other explicit override.
|
|
769
|
+
|
|
770
|
+
A `keyPrefix` must be a string holding a non-empty path ending in `/`, and every segment before that `/` must be a real name — not empty, not `.`, not `..` — with no control character and no unpaired surrogate anywhere in it. It is also the lifecycle rule's `Filter.Prefix` and the path an IAM object-key condition is written against, so an empty or root prefix would expire the whole bucket, a slash-less one would match sibling prefixes, and one carrying `..`, `.` or an empty segment would address keys outside the path you granted and the rule sweeps — an S3 key is a byte string rather than a path, so `a/../b/x.bin` and `b/x.bin` are two different objects, and the console, a lifecycle filter and anything that normalises a path first disagree about which. All of them are rejected at construction and again by `ensureS3LifecycleRule()`.
|
|
771
|
+
|
|
772
|
+
### Per-call options
|
|
773
|
+
|
|
774
|
+
Every page `limit` in this package has a ceiling of 10 000 (`VALIDATION` naming `limit`, raised at the call), and every options object refuses a key it does not read, naming `options.<key>` (`window.<key>` for `forSession`), except `redactLogger`'s, below.
|
|
775
|
+
|
|
776
|
+
**`history.getMessages(sessionId, options?)`** (`GetMessagesOptions`):
|
|
777
|
+
|
|
778
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
779
|
+
| --- | --- | --- | --- | --- |
|
|
780
|
+
| `limit` | `number` | none: the whole session | 10 000 | only the newest `limit` messages, still returned oldest first; `0` is refused, since an empty window is what a chain reads as the whole session |
|
|
781
|
+
| `before` | `Date` | none | — | only messages appended before this instant, at millisecond precision; combines with `limit` |
|
|
782
|
+
| `signal` | `AbortSignal` | none | — | see [Cancellation](#cancellation) |
|
|
783
|
+
|
|
784
|
+
**`history.forSession(sessionId, window?)`** takes `{ limit? }`: the same `limit` as `getMessages` (none by default, ceiling 10 000, `0` refused), bounding what the single-session adapter feeds the chain to the newest that many messages.
|
|
785
|
+
|
|
786
|
+
**`history.listSessions(options?)`** (`ListSessionsOptions`):
|
|
787
|
+
|
|
788
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
789
|
+
| --- | --- | --- | --- | --- |
|
|
790
|
+
| `limit` | `number` | 100 with `indexName`; without it, every session | 10 000 | newest-updated first; `0` returns an empty page and reads nothing |
|
|
791
|
+
| `cursor` | `string` | none | — | the `nextCursor` of the previous page; **requires `indexName`**, and is refused without it |
|
|
792
|
+
| `maxIterations` | `number` | 1000 | none; `Infinity` asks for no cap | scan pages read before `RESULT_TRUNCATED`; the scan path only |
|
|
793
|
+
| `maxItems` | `number` | 10 000 | none; `Infinity` asks for no cap | rows held in memory before `RESULT_TRUNCATED`; the scan path only |
|
|
794
|
+
| `signal` | `AbortSignal` | none | — | |
|
|
795
|
+
|
|
796
|
+
**`store.search(namespacePrefix, options?)`** (`SearchOptions`):
|
|
797
|
+
|
|
798
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
799
|
+
| --- | --- | --- | --- | --- |
|
|
800
|
+
| `query` | `string` | none: a plain search | — | ranked by similarity when the store has an `index`; an empty string is no query ([Semantic search](#semantic-search)) |
|
|
801
|
+
| `filter` | `Record<string, any>` | none | — | conditions on top-level fields of the stored value, all of which must hold ([Differences from `InMemoryStore`](#differences-from-inmemorystore)) |
|
|
802
|
+
| `limit` | `number` | 10 | 10 000 | `0` returns an empty page without a read or an embedding |
|
|
803
|
+
| `offset` | `number` | 0 | — | items skipped first; `offset + limit` is what `maxScanItems` and `maxSearchCandidates` are measured against |
|
|
804
|
+
| `signal` | `AbortSignal` | none | — | |
|
|
805
|
+
|
|
806
|
+
**`store.listNamespaces(options?)`** (`ListNamespacesOptions`) takes no signal:
|
|
807
|
+
|
|
808
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
809
|
+
| --- | --- | --- | --- | --- |
|
|
810
|
+
| `prefix` | `string[]` | none | — | only namespaces starting with these labels; `'*'` matches any one label |
|
|
811
|
+
| `suffix` | `string[]` | none | — | only namespaces ending with these labels; `'*'` matches any one label |
|
|
812
|
+
| `maxDepth` | `number` | none | — | truncates each namespace to this many labels, listing the ones that become equal once |
|
|
813
|
+
| `limit` | `number` | 100 | 10 000 | `0` returns an empty array without a read |
|
|
814
|
+
| `offset` | `number` | 0 | — | |
|
|
815
|
+
|
|
816
|
+
**`saver.list(config, options?)`** (upstream's `CheckpointListOptions`) reads its signal from `config.signal`:
|
|
817
|
+
|
|
818
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
819
|
+
| --- | --- | --- | --- | --- |
|
|
820
|
+
| `limit` | `number` | none: every matching checkpoint | 10 000 | `0` yields nothing |
|
|
821
|
+
| `before` | `RunnableConfig` | none | — | only checkpoints older than the `checkpoint_id` it names |
|
|
822
|
+
| `filter` | `Record<string, any>` | none | — | metadata fields every yielded checkpoint must match |
|
|
823
|
+
|
|
824
|
+
**`saver.getDeltaChannelHistory(options)`** (`DeltaChannelHistoryOptions`, the shape upstream's `BaseCheckpointSaver` declares) takes exactly two keys:
|
|
825
|
+
|
|
826
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
827
|
+
| --- | --- | --- | --- | --- |
|
|
828
|
+
| `config` | `RunnableConfig` | **required** | — | the checkpoint to walk back from, shaped as `getTuple` requires; its `signal` aborts the whole walk, every ancestor read included |
|
|
829
|
+
| `channels` | `string[]` | **required** | — | the delta channels to rebuild; `[]` reads nothing and returns `{}` |
|
|
830
|
+
|
|
831
|
+
**`redactLogger(logger, options?)`** (`RedactLoggerOptions`, see [Logging](#logging)) checks that `options` is an object and each list's element type, but does not refuse a key it does not read:
|
|
832
|
+
|
|
833
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
834
|
+
| --- | --- | --- | --- | --- |
|
|
835
|
+
| `extraKeys` | `readonly string[]` | none: the built-in key names only | — | further key names to redact, matched like the built-in ones: a key is redacted when its lower-cased form with punctuation removed equals or ends with the name, so `'ssn'` covers `SSN` and `user_ssn` |
|
|
836
|
+
| `extraValuePatterns` | `readonly RegExp[]` | none: the built-in shapes only | — | further secret shapes, redacted wherever they appear inside a string and applied globally with or without the `g` flag; a pattern's first capture group, if it has one, is kept verbatim |
|
|
837
|
+
|
|
838
|
+
**A trailing `{ signal }`** (`CancelOptions`) is the only option of `saver.deleteThread`, `store.reconcileVectorIndex`, `history.addMessages`, `history.addMessage`, `history.clear` and `history.reconcileMessageCount`. The checkpointer's `getTuple`, `list`, `put`, `putWrites` and `getDeltaChannelHistory` read `config.signal` instead, and `store.get`, `store.put`, `store.delete`, `store.batch` and `store.listNamespaces` take none ([Cancellation](#cancellation)).
|
|
839
|
+
|
|
840
|
+
**`backfillRecencyIndex(options)`** (`BackfillOptions`), the [maintenance tool](#maintenance-operations) that prepares rows for the recency index:
|
|
841
|
+
|
|
842
|
+
| Option | Type | Default | Ceiling | Notes |
|
|
843
|
+
| --- | --- | --- | --- | --- |
|
|
844
|
+
| `client` | `DynamoDBDocument` | **required** | — | must provide `scan` and `update` |
|
|
845
|
+
| `tableName` | `string` | **required** | — | the adapters' rule |
|
|
846
|
+
| `indexShards` | `number` | 8 | 1024 | must equal the adapters' `indexShards`, or rows land on shards no listing queries |
|
|
847
|
+
| `pageSize` | `number` | 100 | — | rows per scan page |
|
|
848
|
+
| `maxPages` | `number` | none: the whole table | — | stops after this many pages and returns a `nextCursor` |
|
|
849
|
+
| `cursor` | `string` | none | — | the `nextCursor` of an earlier run, to resume it |
|
|
850
|
+
| `dryRun` | `boolean` | `false` | — | reports what would change without writing |
|
|
851
|
+
| `retry` | `RetryOptions` | 5 attempts, 100 ms base, 5 000 ms cap, the default retryable errors | 100 attempts, 60 000 ms for either delay | the full retry surface, not the adapters' `RetryPolicy`: also `retryableErrors` (`string[]`, the error names to retry), `isRetryable` (`(error) => boolean`, which replaces `retryableErrors`), `onRetry` (called before each backoff with `{ attempt, delayMs, error }` — the only way to watch a backfill's retries, since it takes no `logger`), `rng` (`() => number`, the jitter source, default `Math.random`) and `signal` |
|
|
852
|
+
| `signal` | `AbortSignal` | none | — | cancels the run; `retry.signal` does when this is absent, and this one wins when both are given |
|
|
853
|
+
|
|
854
|
+
## Retries and backoff
|
|
855
|
+
|
|
856
|
+
Every DynamoDB call the library makes runs inside its own retry layer, and that layer is the only one: clients the library constructs disable the SDK's retries (`maxAttempts: 1`) and hand the SDK's request handler a timeout, so the attempt counts below are exact and each of those attempts is bounded. `list()` without a `checkpoint_ns` covers every namespace of the thread (rows come grouped by namespace, newest first within each); with an explicit namespace, `before` is applied in the key condition so newer rows are never read, and a `checkpoint_id` is fetched directly instead of scanning.
|
|
857
|
+
|
|
858
|
+
An injected `client` that keeps SDK retries stacks them inside each attempt — construct it with `maxAttempts: 1` (a `warn` is logged at construction otherwise) **and give it a request timeout of its own**. `maxAttempts: 1` is necessary and no longer sufficient: an injected client is used exactly as it was handed over, so one without a handler timeout leaves a single attempt unbounded, and the write-lifetime deadline below cannot shorten an attempt that has already started.
|
|
859
|
+
|
|
860
|
+
- **What is retried** — throttling and capacity errors, transaction conflicts (`ReplicatedWriteConflictException` included), `InternalFailure` and the other transient server errors, request timeouts, HTTP 429/5xx responses (including ones the SDK cannot map to a modeled exception), errors carrying the SDK's `$retryable` trait, and Node socket errors. Everything else — `ValidationException`, `ConditionalCheckFailedException`, `ResourceNotFoundException`, `AccessDeniedException`, a `TransactionCanceledException` with a permanent reason — is thrown on the first attempt. DynamoDB and S3 share one list, derived from the error table under [Error handling](#error-handling): every name it gives `THROTTLED`, `SERVICE_UNAVAILABLE` or `CONTENTION`, plus the Node network error codes.
|
|
861
|
+
- **Schedule** — `retry.maxAttempts` (default 5, ceiling 100) attempts with full-jitter exponential backoff from `retry.baseDelayMs` (default 100 ms), doubling per attempt and capped at `retry.maxDelayMs` (default 5 s; a value below a given `baseDelayMs` is refused, and both delays have a ceiling of 60 s): about 1.5 s worst case and 0.75 s expected before `RETRY_EXHAUSTED`. `addMessages` never uses fewer than 18 attempts (about 61 s worst case), because every concurrent append to one session contends on the same metadata row. Those are the figures for *sleeping*; a budget's worst-case wall time adds the attempts themselves, which the per-attempt bound below caps at 10 s each — so about 51.5 s for a five-attempt budget and about 4 minutes for `addMessages`, and a tokened write is cut at 300 s whichever way it gets there. `BatchWriteItem` `UnprocessedItems` are re-submitted for up to 10 rounds with the same backoff.
|
|
862
|
+
- **What bounds one attempt** — a client this library builds is given a **10 s request timeout** and a **5 s socket timeout** on the SDK's request handler, so a hung request fails with a retryable `TimeoutError` and is retried instead of hanging forever; `maxAttempts: 1` on its own bounds nothing.
|
|
863
|
+
- The request timeout covers socket acquisition, connect, the request write and the wait for response headers.
|
|
864
|
+
- The socket timeout is an idle timer that activity in either direction resets, so it also covers a response body that stalls mid-stream.
|
|
865
|
+
- **No connect timeout is set, deliberately.** That timer starts when the request is created and is cleared only when the agent assigns one of its sockets (50 by default), so the whole time a request spends queued behind a wide fan-out counts against it. With a one-socket agent, a connect timeout of 800 ms killed 14 of 100 healthy requests and one of 2 500 ms killed 226 of 400 — every one of which succeeded when it was left unset, and this library's own retry layer re-sends each one it kills. A value long enough to be safe bounds nothing the request timeout does not.
|
|
866
|
+
- The **S3 client** gets the idle timeout **only**: a `PutObject`'s response headers do not arrive until the whole body has been uploaded, so a total bound there would be a bound on upload speed. At the 50 MB ceiling this path carries, 10 s would demand a sustained 5 MB/s for the whole upload, and anything slower would have its upload destroyed *and* re-sent.
|
|
867
|
+
- A `requestHandler` in `clientConfig` or `s3.clientConfig` replaces the defaults whole: the documented way to tune them, and equally the documented way to give them up.
|
|
868
|
+
- **The S3 retry budget** — an S3 upload or download retries transient failures up to **3 attempts total**, fixed inside the offloader (`offloader.ts`'s `uploadObject`/`downloadObject`) and independent of the adapter's `retry` option. It uses the same full-jitter backoff (100 ms base, 5 s cap) but never `retry.maxAttempts`; exhausting it is `S3_OFFLOAD_FAILED`, not `RETRY_EXHAUSTED`.
|
|
869
|
+
- **The write lifetime** — a write that carries a token (see [Write idempotency](#write-idempotency)) stops starting new attempts **300 s** in, whatever `retry` says: that is half the ten minutes DynamoDB honours the token for, and the other half absorbs the attempt still in flight. The deadline is tested before each backoff, so it can refuse to begin the next wait and can never shorten an attempt already running, which is what the per-attempt bound above is for. A `retry` policy whose nominal worst case is longer logs one `warn` at construction naming both numbers instead of being refused: `retry: { maxDelayMs: 60000 }` alone is already 8.7 minutes of sleep on the `addMessages` path, and such a call ends in `RETRY_EXHAUSTED` where a shorter policy might eventually succeed. Nothing else carries the deadline: a read keeps the full configured budget, `store.delete`'s pre-read included.
|
|
870
|
+
- **Visibility** — every retry is logged at `debug` with the attempt number, the delay about to be slept and the error name; `RETRY_EXHAUSTED` carries the last error as `cause` (with the SDK's `$metadata.requestId`) and `context.attempts`.
|
|
871
|
+
|
|
872
|
+
## Error handling
|
|
873
|
+
|
|
874
|
+
Every error the library throws is a `DynamoDBLangGraphError` carrying a stable `code` from the `ErrorCode` enum, a structured `context` (`tableName`, `operation`, `field`, `key`, `attempts`, `threadId`, `checkpointId`, and — when the failure underneath came from AWS — `awsErrorName`, `requestId` and `httpStatusCode`; identifiers and counts, never a payload), `details` for the two codes that carry more, and a native `cause` chain. Raw AWS SDK errors never escape a public method: each one is given the code the classifier assigns (the table below) and keeps the SDK error as `cause`.
|
|
875
|
+
|
|
876
|
+
Branch on `code` and detect library errors with the exported brand check rather than `instanceof`, which breaks when a bundler duplicates the package. Earlier releases set the same brand, so an error from an older copy installed beside this one is recognised too — in that release's shape: no `details`, its counts as flat properties, and possibly `code: 'UPSTREAM'`. The check is safe on any caught value, including one that is not an object at all — which is what a `catch` clause can actually hold. `ErrorCode` is frozen: a member cannot be reassigned by anything sharing the process, so `error.code === ErrorCode.X` means the same thing to every consumer:
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
import { ErrorCode, isDynamoDBLangGraphError } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
await store.put([''], 'k', { v: 1 });
|
|
883
|
+
} catch (error) {
|
|
884
|
+
const e = error as Error; // guard a variable: a guard on `error as Error` leaves `error` itself unknown
|
|
885
|
+
if (isDynamoDBLangGraphError(e)) {
|
|
886
|
+
switch (e.code) {
|
|
887
|
+
case ErrorCode.VALIDATION:
|
|
888
|
+
console.error('bad input', e.context.field); // names the offending option or argument
|
|
889
|
+
break;
|
|
890
|
+
case ErrorCode.THROTTLED:
|
|
891
|
+
console.warn('back off', e.context.awsErrorName); // says which limit
|
|
892
|
+
break;
|
|
893
|
+
case ErrorCode.COMPENSATION_FAILED:
|
|
894
|
+
console.error(e.details.rollbackError); // typed by the code, no cast; then run reconcileMessageCount
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
| `ErrorCode` | Thrown by |
|
|
902
|
+
| --- | --- |
|
|
903
|
+
| `VALIDATION` | every constructor for a bad option, an option key it does not read, or a collaborator missing a method; every method for a bad identifier, key, window, value, `config` or options object; `backfillRecencyIndex` for a bad option; S3 offload configured without the `@aws-sdk/client-s3` peer; a descriptor the reader cannot honour; a row-sourced `s3Key` outside the path the row's own identifiers produce, on every adapter and under every corruption policy; a stored payload the configured serializer refuses to reconstruct — an `lc` constructor record naming a class outside its allow-list, or any other refusal the serializer raises — with the serializer's own error as `cause`, except where that refusal is already one of this library's errors and is passed through whole, as `JSON_SERDE`'s refusal of a `serdeType` it does not write is: that one carries no `cause`, because nothing raised it but itself |
|
|
904
|
+
| `THROTTLED` | any method, for a throttle no retry layer retried: `ProvisionedThroughputExceededException`, `ThrottlingException`, `RequestLimitExceeded`, S3 `SlowDown`, HTTP 429. An adapter's `retry` takes no list of errors — `retry.retryableErrors` is an unknown key there and is refused — and its retry layer retries every one of these, so on an adapter's DynamoDB call a throttle that outlasts `retry.maxAttempts` is `RETRY_EXHAUSTED`, with the throttle as `cause`, and an S3 transfer that ran out of its retries is `S3_OFFLOAD_FAILED`. `THROTTLED` itself comes from the paths with no retry layer — the S3 lifecycle calls `ensureS3LifecycleRule` makes, and whatever your own `vectorBackend` or `index.embeddings` throws — and from `backfillRecencyIndex` when its own `retry` leaves a throttle out: a `retryableErrors` list without the throttle's name, for an error that carries neither HTTP 429 nor the SDK's `$retryable` trait (both are retried whatever the list says), or an `isRetryable` that returns `false` for it — `isRetryable` replaces the whole decision, so it can leave out even an HTTP 429. Back off and retry later, or raise the table's capacity or the account quota |
|
|
905
|
+
| `SERVICE_UNAVAILABLE` | the same, for a transient AWS or network failure: `InternalServerError`, `InternalFailure`, `ServiceUnavailable`, S3 `InternalError`, a request timeout, HTTP 5xx, a reset or refused connection — and for a network failure raised by your own `vectorBackend` or `index.embeddings`, which the boundary cannot tell from AWS's (no `awsErrorName` is set on that one). Retry after a backoff; a write that failed this way may have been applied, so read it back before writing it again where that matters |
|
|
906
|
+
| `CONTENTION` | the same as `THROTTLED`, for a request that collided with another on the same item or object: `TransactionConflictException`, `TransactionInProgressException`, `ReplicatedWriteConflictException`, S3 `ConditionalRequestConflict`. Retry soon; more capacity would not help |
|
|
907
|
+
| `ACCESS_DENIED` | any method whose credentials or IAM policy AWS refused (`AccessDeniedException`, S3 `AccessDenied`, an expired or unrecognised token, a bad signature). Not retried: fix the credentials or the IAM policy; `context.awsErrorName` says which refusal it was |
|
|
908
|
+
| `NOT_FOUND` | any method whose table or index does not exist (`ResourceNotFoundException`), or whose offload bucket does not (`NoSuchBucket`). Not retried: create it, or fix the `tableName`, `indexName` or `s3.bucketName` the adapter was given |
|
|
909
|
+
| `AWS_REJECTED` | any method whose request AWS rejected as malformed (`ValidationException`, `IdempotentParameterMismatchException`, …). Not retried: the same request fails the same way, so fix the request — `context.awsErrorName` and `cause` say what AWS objected to |
|
|
910
|
+
| `AWS_REQUEST_FAILED` | any method, for an AWS failure no narrower code fits; `context.awsErrorName` names it |
|
|
911
|
+
| `UNEXPECTED_ERROR` | any method, for a failure that is neither this library's check nor AWS's: what your `vectorBackend`, `index.embeddings`, `serde` or the history a single-session adapter wraps threw, as `cause` |
|
|
912
|
+
| `RETRY_EXHAUSTED` | every DynamoDB call after `retry.maxAttempts` transient failures (`context.attempts`, the last error as `cause`) |
|
|
913
|
+
| `ABORTED` | any cancellable method whose `AbortSignal` fired, including `saver.deleteThread` and `history.clear` when it fires part-way through the delete, and `saver.getDeltaChannelHistory` when it fires part-way through the ancestor walk — the hop it fires on is the last read the call makes — a cancel is reported as a cancel, unwrapped, and no further row is issued after it. A collaborator's own abort is reported the same way: a `vectorBackend` or `index.embeddings` rejecting with an `AbortError` — from a timeout of its own, say — surfaces as `ABORTED` even though the caller's signal never fired, with that `AbortError` as `cause` |
|
|
914
|
+
| `CONDITION_CONFLICT` | `history.reconcileMessageCount` when the session changed while it counted, and when the session does not exist — repairing one that is not there would mean creating a permanent, TTL-less metadata row |
|
|
915
|
+
| `COMPENSATION_FAILED` | `history.addMessages` / `addMessage` when a multi-chunk append failed and the rollback of the committed chunks failed too (`details.rollbackError`; run `reconcileMessageCount`) |
|
|
916
|
+
| `BATCH_WRITE_INCOMPLETE` | `saver.deleteThread`, `history.clear` when a row's delete fails — a cancelled pass raises `ABORTED` instead, and never this. `details.kind` says which shape the error carries: `'drain'` for one `BatchWriteItem` sequence that ran out of `UnprocessedItems` rounds (`details.succeededCount`, `details.unprocessed` — the requests to re-submit — and `details.retries`), `'pass'` for a pass that attempted every chunk or row (`details.unit`, `details.succeededChunks`, `details.totalChunks`, `details.failedChunks`, `details.succeededCount`). A partition delete sends one conditional request per row, so its counts are **rows** (`details.unit: 'row'`): `details.succeededChunks`/`details.totalChunks` are rows deleted and rows attempted across the whole pass, `details.succeededCount` repeats the first, `details.failedChunks` holds each failing row's own error, and the message names the row unit. A row the pin refused is not a failure and is in neither count. The chunked form — `details.unit: 'chunk'`, counts in 25-row `BatchWriteItem` chunks, with a `'drain'` error per failing chunk inside it — is raised only by the rollback of a failed multi-chunk `history.addMessages`, where it reaches a caller as the `COMPENSATION_FAILED` error's `details.rollbackError`; there `details.succeededCount` is the individual writes confirmed persisted across every chunk |
|
|
917
|
+
| `RESULT_TRUNCATED` | the paginated reads that keep rows in memory — `store.search`, `store.listNamespaces`, `store.reconcileVectorIndex`, `history.listSessions` — past `maxScanItems` / `maxItems` / `maxIterations`; and a listing through the recency index — `history.listSessions`, or `saver.list` without a `thread_id` — for an index shard that needs more than 1000 DynamoDB pages while one page of the listing is built |
|
|
918
|
+
| `S3_OFFLOAD_FAILED` | an upload or a download of an offloaded object that failed after the S3 retries (`context.operation` says which), an object over `maxDownloadBytes`, or an object that no longer exists (`context.key`). **Never a delete**: releasing an object is best-effort, so a failed delete is logged as an orphan at `warn` and the call carries on |
|
|
919
|
+
| `COMPRESSION_LIMIT` | a payload whose decompressed size would exceed `maxDecompressedBytes` |
|
|
920
|
+
| `PAYLOAD_CORRUPT` | a stored payload that can never be read: bytes marked compressed that are not gzip, or bytes that are no longer the form the row declares. The check is this package's own re-derivation of that form, not the serializer's word for it, so which `serde` the adapter carries does not change the verdict. Classified permanent, so a caller reports it instead of retrying |
|
|
921
|
+
| `FORMAT_UNSUPPORTED` | a row, or a payload inside one, written by a newer release of this package than the one reading it — `context.field` is `v` for the row and `schemaVersion` for the payload. Raised rather than skipped, on every adapter and whatever `onCorruptMessage` is set to: hiding a row that exists is worse than failing, and a newer reader reads it, so dropping it during a rollback or a canary loses turns that are not lost |
|
|
922
|
+
| `ANCESTOR_EXPIRED` | `saver.getDeltaChannelHistory` when a checkpoint a delta channel still needs has expired (`context.threadId`, `context.checkpointId`). Lower `snapshotFrequency`, or do not put a `ttl` on threads that use delta channels. A walk cancelled just as it reached the expired ancestor reports `ABORTED` instead: the caller had stopped waiting for the diagnosis |
|
|
923
|
+
|
|
924
|
+
`COMPENSATION_FAILED` is the one error that carries another: the append's original failure is `cause` and the rollback failure is `details.rollbackError`, which can itself be a `BATCH_WRITE_INCOMPLETE`. The session's stored `messageCount` may be wrong at that point; `reconcileMessageCount` repairs it.
|
|
925
|
+
|
|
926
|
+
### Cancellation
|
|
927
|
+
|
|
928
|
+
Every long-running method takes an `AbortSignal`: the checkpointer reads `RunnableConfig.signal` (which LangGraph propagates) on `getTuple`, `list`, `put` and `putWrites`, and `deleteThread`, `search`, `reconcileVectorIndex`, `getMessages`, `addMessages`, `addMessage`, `clear`, `listSessions` and `reconcileMessageCount` take a trailing `{ signal }`.
|
|
929
|
+
|
|
930
|
+
- **Validation.** A signal that is not an `AbortSignal` — an object with a boolean `aborted` and callable `addEventListener` and `removeEventListener` — is refused with `VALIDATION` naming `signal`, before any request, wherever it is passed: in a trailing `{ signal }`, or as `config.signal` to the checkpointer's `getTuple`, `list`, `put`, `putWrites` and `getDeltaChannelHistory`, which check it the same way.
|
|
931
|
+
- **Firing.** A signal that is already aborted, that aborts while the library waits (a retry backoff, the next page of a paginated read), or that aborts **while a request is in flight**, rejects the call with an `ABORTED` error whatever the abort reason was — the raw reason (a `DOMException` for a bare `controller.abort()`) is kept as `cause`.
|
|
932
|
+
- **It ends the request, not just the wait.** The signal is passed to the AWS SDK as `abortSignal` on every DynamoDB request and on both S3 transfers: a `getTuple` against a server that never answers returns in about the time it takes to call `abort()` rather than at the five-second socket timeout, and an S3 body that stalls after its headers — which no handler timeout releases — ends at the abort too.
|
|
933
|
+
- **Never re-sent.** A cancelled request's signal is read before the transport's own rejection is classified, so the socket error a cut request produces is reported as `ABORTED` instead of being retried as transient.
|
|
934
|
+
- **`store.get`, `store.put` and `store.delete` take no signal** — upstream's `BaseStore` gives those three no parameter for one, and adding one would change their signatures — so neither the S3 upload a large value costs nor the download reading one back is cancellable; `store.search` and `store.reconcileVectorIndex` do take one.
|
|
935
|
+
- **Cleanup is never cancelled.** Cleanup and verification reads that run after a failure are not cancelled, so an abort never strands a live row pointing at a deleted object.
|
|
936
|
+
|
|
937
|
+
### Error ordering and partial progress
|
|
938
|
+
|
|
939
|
+
Several public methods send more than one request under one call. This is what has already happened when each one raises partway through, and what to do next.
|
|
940
|
+
|
|
941
|
+
- **`store.batch`** groups independent operations — different items, or reads of the same item — into concurrent runs and issues the runs in the caller's order. Any failure rejects the whole call, but operations in an earlier run, and any operation in the same run as the failure that itself succeeded, are already durably applied and are **not rolled back**: a batch is not a transaction across items. Retrying a `put` or a `delete` from it is safe (both are naturally idempotent), but re-running the whole batch can repeat a `get` or a `search` against state a partial write already changed.
|
|
942
|
+
- **`saver.putWrites`** sends one write per pending write, all in parallel (a one-item transaction only for a write whose payload was offloaded), and a rejection is likewise not a rollback of the others: sibling writes in the same call that landed stay landed. A pending write's identity is `(taskId, channel, occurrence)`, so re-sending the same `writes` array is naturally idempotent — a row that already landed is simply overwritten with the same content.
|
|
943
|
+
- **`history.addMessages`** is different: a large append is cut into chunks, each committed as its own transaction, and a chunk that fails triggers a rollback of every already-committed chunk (and the session's message count) before the original error reaches you. An **ordinary error** means that rollback succeeded and the session is back to its pre-call state, so retrying the whole call is safe. A **`COMPENSATION_FAILED`** error means the rollback itself failed: some already-committed chunks — and their offloaded objects, deliberately left in place rather than risk deleting one a surviving row still names — may still be in the table, `messageCount` may undercount them, and the remedy is `history.reconcileMessageCount(sessionId)` once the session is idle, not a blind retry of the append. Full detail: [Guide → Chat history semantics](docs/guide.md#chat-history-semantics).
|
|
944
|
+
- **`saver.deleteThread` and `history.clear`** (a partition delete) never roll anything back, because they only ever delete: each row is removed under a condition pinning the state the partition's one read observed, so a row rewritten since that read is **skipped, not failed** — the call still resolves, and rows already deleted stay deleted. A `BATCH_WRITE_INCOMPLETE` error means a delete request itself failed after its retries, not that a row was skipped; re-running the call once the partition is idle is always safe and is the documented remedy either way. Full detail: [Guide → What a partition delete promises](docs/guide.md#what-a-partition-delete-promises).
|
|
945
|
+
- **`backfillRecencyIndex`** writes are conditional on each row's own state (present, and not yet indexed); a refused write is counted as `skipped`, not a failure, and the run continues. An AWS error stops the run and discards its result, but resuming from the returned `nextCursor`, or restarting from scratch, is always safe — the scan's own filter skips whatever a stopped run had already indexed.
|
|
299
946
|
|
|
300
947
|
## Logging
|
|
301
948
|
|
|
302
|
-
Logging is **per-instance and silent by default** — the library never writes to your console uninvited. Pass any object matching the `Logger` interface
|
|
949
|
+
Logging is **per-instance and silent by default** — the library never writes to your console uninvited. Pass any object matching the `Logger` interface — all four of `info`, `warn`, `error` and `debug` are required, and a logger missing one is refused at construction, naming it (`logger.debug`):
|
|
303
950
|
|
|
304
951
|
```typescript
|
|
305
|
-
import { redactLogger, type Logger } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
952
|
+
import { DynamoDBStore, redactLogger, type Logger } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
306
953
|
|
|
307
954
|
const logger: Logger = {
|
|
308
955
|
info: (m, ...a) => console.info(m, ...a),
|
|
@@ -314,12 +961,17 @@ const logger: Logger = {
|
|
|
314
961
|
const store = new DynamoDBStore({ tableName: 'langgraph', logger: redactLogger(logger) });
|
|
315
962
|
```
|
|
316
963
|
|
|
317
|
-
`
|
|
964
|
+
**A logger you inject is wrapped, not used as given.** This package calls your `Logger` almost entirely from inside the paths that report or repair a failure, so each of its four methods is delegated through a wrapper that absorbs anything the method throws: nothing your logger does can stop a delete pass reporting what it could not delete, end a retry budget early, or replace the error you actually needed with one about logging. The message and arguments reach your method unchanged, and only a throw out of it is swallowed — so if you want to see your own logger's failures, handle them inside your own methods. One consequence worth knowing: the object the adapters hold is not the object you passed, so identity comparison against it will not match.
|
|
965
|
+
|
|
966
|
+
`redactLogger` wraps a logger so secret-looking fields (access keys, tokens, passwords, …) are replaced with `[REDACTED]` in structured log arguments. It also scans **string values, including an error's `message` and `stack`**, for recognisable credential shapes — AWS access key ids, `Bearer` tokens, JWTs, and `password=`/`token=` assignments — replacing just the matched substring so the text stays readable. Pass `extraKeys` to add field names and `extraValuePatterns` to add shapes.
|
|
967
|
+
|
|
968
|
+
`redactSecrets` exposes the same redaction for arbitrary objects. Both helpers refuse what they cannot apply: `redactLogger` names `logger` (or `logger.<method>`) for a logger it cannot delegate to, and `options`/`extraKeys`/`extraValuePatterns` for an option of the wrong type; `redactSecrets` names `patterns`/`valuePatterns` for a list that is not an array of strings or of `RegExp` — a skipped pattern protects nothing while its caller believes it does. Past the wrap call nothing escapes a log call, the wrapped logger's own failure included.
|
|
318
969
|
|
|
319
|
-
**What is logged.** Identifiers and counts only: thread, namespace, checkpoint, session and task ids, store namespaces and keys, sort keys, channel names, S3 object keys, attempt and row counts, and the *name* of an underlying error. Never a payload, an embedding, a message body or a credential. `redactLogger` therefore matters most for the application logs around the library; it does not redact identifiers — pass `extraKeys: ['threadId', 'sessionId', 'namespace', 'key', 'sortKey', 's3Key']` when your deployment treats identifiers as personal data.
|
|
970
|
+
**What is logged.** Identifiers and counts only: thread, namespace, checkpoint, session and task ids, store namespaces and keys, sort keys, channel names, S3 object keys, attempt and row counts, and the *name* of an underlying error — or, for one of this library's own, its `code`, since they all share one name. Never a payload, an embedding, a message body or a credential. `redactLogger` therefore matters most for the application logs around the library; it does not redact identifiers — pass `extraKeys: ['threadId', 'sessionId', 'namespace', 'key', 'sortKey', 's3Key']` when your deployment treats identifiers as personal data.
|
|
320
971
|
|
|
321
972
|
**Using pino or winston.** `Logger` methods take a message and then structured arguments — at most one plain object per call. winston and `console` accept that shape directly. pino treats a leading string as a format string and drops trailing objects, so merge the arguments into its first parameter:
|
|
322
973
|
|
|
974
|
+
<!-- sample:skip pino is not a dependency of this package -->
|
|
323
975
|
```typescript
|
|
324
976
|
import pino from 'pino';
|
|
325
977
|
import type { LogArgument, Logger } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
@@ -339,96 +991,587 @@ const logger: Logger = {
|
|
|
339
991
|
|
|
340
992
|
| Level | Message | Fields | Meaning and what to do |
|
|
341
993
|
| --- | --- | --- | --- |
|
|
342
|
-
| `error` | `history.addMessages rollback failed; messageCount may have drifted` | `sessionId`, `committedChunks
|
|
994
|
+
| `error` | `history.addMessages rollback failed; messageCount may have drifted` | `sessionId`, `committedChunks` | a multi-chunk append failed and its rollback failed too (`COMPENSATION_FAILED`); run `reconcileMessageCount` for the session once it is idle |
|
|
343
995
|
| `error` | `getMessages: skipped a corrupt message item` | `sessionId`, `sortKey`, `reason` | a message row could not be decoded (or its S3 object is gone) and was dropped under `onCorruptMessage: 'skip'`; inspect or delete the row |
|
|
344
996
|
| `warn` | `store.put: compare-and-swap exhausted; overwriting unconditionally` | `namespace`, `key`, `attempts` | three concurrent overwrites of one item; the put succeeded but one S3 object may be orphaned until the lifecycle rule sweeps it |
|
|
997
|
+
| `warn` | `store.delete: compare-and-swap exhausted; the item was not deleted` | `namespace`, `key`, `attempts` | three writes landed at one item between this delete's read and its attempt, each time; the item is still there and nothing was released, because the live row names it — re-run the delete once the key is idle |
|
|
345
998
|
| `warn` | `putWrites: special-write compare-and-swap exhausted; overwriting unconditionally` | `sortKey`, `channel`, `attempts` | same, for an interrupt/resume/error write written concurrently for one task |
|
|
999
|
+
| `warn` | `ensureS3LifecycleRule: versioning is off on the offload bucket, so releasing a payload deletes it outright with no recovery window` | `bucket` | the bucket keeps no versions, so releasing an offloaded payload erases it and no lifecycle rule can hold anything back; enable bucket versioning if you want a mistaken release to be recoverable |
|
|
1000
|
+
| `warn` | `ensureS3LifecycleRule: versioning is suspended on the offload bucket, so releasing a payload deletes it outright` | `bucket` | same exposure, and not the same remedy: re-enable versioning to restore it from here on, and treat the payloads released during the suspension as gone — nothing brings those back |
|
|
1001
|
+
| `warn` | `ensureS3LifecycleRule: could not read the offload bucket versioning state, so whether a released payload is recoverable is unknown` | `bucket`, `reason` | the lifecycle rules were written; only the versioning check failed, most often `AccessDenied` on a role without `s3:GetBucketVersioning`. Grant it, or check the state yourself |
|
|
346
1002
|
| `warn` | `Some orphaned S3 objects could not be deleted after` | `failedCount` | objects leaked after a failed write or a delete; `ensureS3LifecycleRule()` reclaims them, otherwise clean up by prefix |
|
|
347
|
-
| `warn` | `Failed to clean up orphaned S3 objects after` | `
|
|
1003
|
+
| `warn` | `Failed to clean up orphaned S3 objects after` | `reason` | the cleanup itself failed after retries; same remedy |
|
|
348
1004
|
| `warn` | `: refusing to delete an S3 object outside this row's scope` | `key` | a row referenced an object outside its own key path — a tampered or foreign row; the object was left alone, investigate the writer |
|
|
349
|
-
| `warn` | `store
|
|
1005
|
+
| `warn` | `store vector-index sync failed; reconcileVectorIndex will repair` | `namespace`, `key`, `operation`, `reason` | the `vectorBackend` rejected the `operation` named in the fields, an upsert or a delete; the canonical item is fine, run `reconcileVectorIndex` when convenient |
|
|
1006
|
+
| `warn` | `factory.destroy: an adapter did not release its resources` | `reason` | one adapter's teardown failed; the rest were released anyway and the process may hold that adapter's sockets until it exits |
|
|
350
1007
|
| `warn` | `injected DynamoDB client keeps the SDK's own retries` | `maxAttempts` | construct the injected client with `maxAttempts: 1` unless you want the SDK's retries to stack inside the library's budget |
|
|
1008
|
+
| `warn` | `retry policy outlives the write lifetime; the budget will be cut short` | `budgetMs`, `maxWriteLifetimeMs` | the `retry` policy you configured would nominally back off for `budgetMs`, longer than the 5-minute deadline every write carrying a request token runs under, so such a write gives up at the deadline rather than after its last attempt; the write itself is never at risk, but lower `maxAttempts` or `maxDelayMs` if you expect the whole budget to be spent |
|
|
351
1009
|
| `warn` | `putWrites: write row held by an unexpected channel; write not persisted` | `sortKey`, `expected`, `found` | another writer holds this task's row for a different channel; only this library should write the key space |
|
|
352
1010
|
| `warn` | `history.addMessages compensating committed chunks after a chunk failed` | `sessionId`, `committedChunks` | a large append is being rolled back; the caller receives the original error |
|
|
353
1011
|
| `warn` | `list: scanned a large number of rows without the caller stopping` | `threadId`, `checkpointNs`, `scanned` | a `list()` walked over 10 000 rows; pass `limit` or narrow the filter |
|
|
1012
|
+
| `warn` | `getMessages: a session holds very many messages; the read is complete but slow. Pass a ` | `sessionId`, `messages` | over 10 000 messages read in one call; pass `limit` to read only the newest turns |
|
|
354
1013
|
| `warn` | `getTuple: a checkpoint carries very many pending-write rows; the read is complete but slow` | `threadId`, `checkpointId`, `rows` | over 10 000 pending writes on one checkpoint (a huge fan-out or many retried tasks); the read is correct |
|
|
355
1014
|
| `warn` | `search: some candidates carry an embedding of a different dimension than the query` | `namespacePrefix`, `count` | items embedded with another model or `dims`; re-put them or run `reconcileVectorIndex` |
|
|
356
1015
|
| `warn` | `search: vectorBackend returned ascending scores; VectorMatch.score must be a relevance` | `namespacePrefix` | the backend reports distances; set `vectorScoreDirection: 'distance'` |
|
|
357
|
-
| `warn` | `search: skipped an unusable vectorBackend match` | `namespace`, `key` | the backend returned a key this store cannot address; run `reconcileVectorIndex
|
|
1016
|
+
| `warn` | `search: skipped an unusable vectorBackend match` | `namespace`, `key`, `reason` | the backend returned a key this store cannot address — `reason` is always `VALIDATION`, the only failure a match is dropped for; run `reconcileVectorIndex`. A read that *fails* (throttling, an outage, a cancel, an unreadable payload) fails the search instead of being logged here |
|
|
358
1017
|
| `warn` | `: left a foreign row in place` | `sortKey` | `deleteThread`/`clear` found a row another adapter owns in the partition and kept it |
|
|
1018
|
+
| `warn` | `: left a row rewritten since the read` | `sortKey` | `deleteThread`/`clear` found the row changed under it: another write landed after the partition was read, so the row and the object it names were kept. Re-run the call once the thread or session is idle |
|
|
1019
|
+
| `warn` | `: skipped a row whose unit was refused` | `sortKey` | a `deleteThread` kept a checkpoint's payload or pending-write row because the same checkpoint's earlier row was rewritten and kept; re-run once the thread is idle |
|
|
359
1020
|
| `warn` | `list: skipped a row that is not a checkpoint meta item` | `sortKey` | a foreign row shares the `META#` prefix on a shared table |
|
|
360
1021
|
| `warn` | `getTuple: skipped a row that is not a checkpoint meta item` | `sortKey` | same, on the read-your-writes path |
|
|
361
|
-
| `warn` | `
|
|
1022
|
+
| `warn` | `getMessages: refused a row that is not a chat message item` | `sessionId`, `sortKey` | a foreign row shares the `HISTORY#MSG#` prefix in this session's partition, or claims another session; the read is refused rather than answered with a conversation that quietly skips it, whatever `onCorruptMessage` is set to. Inspect or remove the row |
|
|
1023
|
+
| `warn` | `store.get: ignored a row that is not a store item` | `partitionKey`, `sortKey` | a foreign row at a store key |
|
|
362
1024
|
| `warn` | `reconcileVectorIndex: skipped a row that is not a store item` | `sortKey` | same, during reconciliation |
|
|
363
1025
|
| `info` | `: deleted rows` | `deleted`, `skipped` | `deleteThread`/`clear` finished |
|
|
364
1026
|
| `info` | `reconcileVectorIndex prune skipped: backend has no listKeys` | `prefix` | the backend cannot enumerate vectors, so stale ones were not pruned |
|
|
365
1027
|
| `info` | `reconcileVectorIndex: kept a vector whose item reappeared` | `namespace`, `key` | an item was written while pruning; nothing to do |
|
|
1028
|
+
| `info` | `store.delete: kept a vector whose item was not confirmed gone` | `namespace`, `key` | the delete's confirmation did not establish that the key is empty — a row is there because a put recreated it or the compare-and-swap was exhausted, **or the read itself failed and answered nothing** — so the vector was left alone; nothing to do, and `reconcileVectorIndex` clears it if the row really is gone |
|
|
366
1029
|
|
|
367
|
-
##
|
|
1030
|
+
## Tracing and metrics
|
|
368
1031
|
|
|
369
|
-
|
|
1032
|
+
This package has no tracing or metrics integration of its own: it depends on no OpenTelemetry or metrics library, and it writes nothing to the console. What it offers is a place for yours to attach.
|
|
370
1033
|
|
|
371
|
-
|
|
372
|
-
|
|
1034
|
+
**LangSmith and LangChain callbacks.** The adapters are persistence that LangGraph and LangChain call; they register no callbacks and start no runs. A graph or chain you trace is traced exactly as it would be over any other checkpointer, store or chat history, and the package itself emits nothing but what the injected `logger` receives.
|
|
1035
|
+
|
|
1036
|
+
**The logger.** Every retry is a `debug` line, `retrying after a transient error`, carrying the attempt, the delay about to be slept and the error's name, and every `warn` and `error` event is listed under [Logging](#logging). Counting those lines is the cheapest metric this package can give you: retries by error name, orphaned objects, exhausted compare-and-swaps.
|
|
1037
|
+
|
|
1038
|
+
**The DynamoDB client.** AWS-level latency, request counts and traces belong to the SDK client, and an injected client is used exactly as it is handed over. So build your own `DynamoDBClient`, add middleware to it (or instrument it the way your tracing setup instruments AWS SDK clients), wrap it with `DynamoDBDocument.from` and pass it as `client`. The wrapper shares the client's middleware stack, so the middleware sees every request the adapters send:
|
|
373
1039
|
|
|
374
1040
|
```typescript
|
|
375
|
-
import
|
|
1041
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
1042
|
+
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
|
|
1043
|
+
import { DynamoDBStore } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
376
1044
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
1045
|
+
declare function recordLatency(command: string | undefined, ms: number): void;
|
|
1046
|
+
|
|
1047
|
+
const base = new DynamoDBClient({
|
|
1048
|
+
region: 'eu-west-1',
|
|
1049
|
+
maxAttempts: 1,
|
|
1050
|
+
requestHandler: { requestTimeout: 10_000, throwOnRequestTimeout: true },
|
|
383
1051
|
});
|
|
1052
|
+
base.middlewareStack.add(
|
|
1053
|
+
(next, context) => async (args) => {
|
|
1054
|
+
const started = performance.now();
|
|
1055
|
+
try {
|
|
1056
|
+
return await next(args);
|
|
1057
|
+
} finally {
|
|
1058
|
+
recordLatency(context.commandName, performance.now() - started);
|
|
1059
|
+
}
|
|
1060
|
+
},
|
|
1061
|
+
{ step: 'deserialize', name: 'latency' },
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
const store = new DynamoDBStore({ tableName: 'langgraph', client: DynamoDBDocument.from(base) });
|
|
384
1065
|
```
|
|
385
1066
|
|
|
386
|
-
|
|
1067
|
+
`maxAttempts: 1` and the request timeout are there for the reasons under [Retries and backoff](#retries-and-backoff): the library's own retry layer is then the only one, and each attempt is bounded. With `DynamoDBFactory.createAll`, pass the instrumented client as the factory's `client` and all three adapters share it.
|
|
387
1068
|
|
|
388
|
-
|
|
389
|
-
<summary><strong>Terraform</strong></summary>
|
|
1069
|
+
**The S3 client.** S3 offload builds its own S3 client, one per adapter, from `s3.clientConfig`, and takes no client of yours. Its requests can be observed only through what an `S3ClientConfig` itself accepts — a `requestHandler` of your own, for instance, which replaces the default one and its 5 s idle timeout whole.
|
|
390
1070
|
|
|
391
|
-
|
|
392
|
-
resource "aws_dynamodb_table" "langgraph" {
|
|
393
|
-
name = "langgraph"
|
|
394
|
-
billing_mode = "PAY_PER_REQUEST"
|
|
395
|
-
hash_key = "PK"
|
|
396
|
-
range_key = "SK"
|
|
1071
|
+
**Audit logs.** DynamoDB item-level calls (`GetItem`, `Query`, `TransactWriteItems`, …) can be recorded as CloudTrail data events on the table, and S3 object calls as data events on the bucket. Neither is recorded by default, and CloudTrail bills data events separately. The table's CloudWatch metrics are covered under [Monitoring](#monitoring).
|
|
397
1072
|
|
|
398
|
-
|
|
399
|
-
attribute { name = "SK" type = "S" }
|
|
1073
|
+
## Advanced features
|
|
400
1074
|
|
|
401
|
-
|
|
402
|
-
attribute_name = "ttl"
|
|
403
|
-
enabled = true
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
```
|
|
1075
|
+
Each section below describes one capability and what it promises; the options it names are tabulated in the [Configuration reference](#configuration-reference).
|
|
407
1076
|
|
|
408
|
-
|
|
1077
|
+
### Gzip compression
|
|
409
1078
|
|
|
410
|
-
|
|
1079
|
+
Set `compression: { enabled: true }`. Payloads at or above `minSizeBytes` (default 1 KB, ceiling 512 MiB) are gzipped transparently; the stored descriptor records whether a payload was compressed, so reads never infer it from the bytes, and decompression is guarded against decompression-bomb expansion (`maxDecompressedBytes`, default 50 MiB, ceiling 512 MiB).
|
|
411
1080
|
|
|
412
|
-
|
|
1081
|
+
### S3 offloading
|
|
413
1082
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
1083
|
+
Set `s3: { bucketName }`. Any serialized payload at or above `thresholdBytes` (default 350 KB) is written to S3, with only a reference stored in DynamoDB and reads rehydrating it transparently; the store's inline vectors share the item's 400 KB ceiling and are not weighed against the threshold. It needs the optional `@aws-sdk/client-s3` peer, and deleting a checkpoint thread or chat session also best-effort deletes its offloaded objects. When a `ttl` is also configured, call `ensureS3LifecycleRule()` once (during deployment, say) to install the matching [S3 lifecycle rules](#s3-lifecycle-rules) — it is opt-in because it needs a broader bucket-level permission, and it **throws**, rather than logging, when a rule cannot be written. Full detail — the per-field vector budget and every reason it is opt-in: [Guide → S3 offloading](docs/guide.md#s3-offloading).
|
|
1084
|
+
|
|
1085
|
+
### Overwrite races and orphaned objects
|
|
1086
|
+
|
|
1087
|
+
Both the store's concurrent-`put` overwrite race and the checkpointer's *special*-write overwrite race (`__error__`, `__interrupt__`, `__resume__`, `__scheduled__`) are held by two mechanisms answering different questions: a **compare-and-swap** decides *which* payload a write supersedes, and a **client request token** decides that a re-sent request lands *once*. Every write uploads under an id of its own, so no row another write commits ever names its objects, and a leak remains possible only in a handful of backstopped cases — an exhausted compare-and-swap, a best-effort delete that genuinely fails, or a write that cannot be verified — all reclaimed by `ensureS3LifecycleRule()`. Full detail — every leak case, and the one race the compare-and-swap alone does not close: [Guide → Overwrite races and orphaned objects](docs/guide.md#overwrite-races-and-orphaned-objects).
|
|
1088
|
+
|
|
1089
|
+
### Write idempotency
|
|
1090
|
+
|
|
1091
|
+
Some writes carry a **client request token**, which DynamoDB honours for ten minutes: every write that references an offloaded S3 object, every `TransactWriteItems` write regardless of offloading (`saver.put`'s two rows, an `addMessages` chunk with its session row, `store.delete`'s row removal), and nothing else. This library's own retry budget stops a tokened write from starting new attempts at 300 s — half that window — so its retries never outlive the token. Full detail — exactly which writes carry one, and why the rest deliberately do not: [Guide → Write idempotency](docs/guide.md#write-idempotency).
|
|
1092
|
+
|
|
1093
|
+
### What a token guarantees, and what it does not
|
|
1094
|
+
|
|
1095
|
+
A write whose first attempt **committed** applies exactly once; a write **rejected by its condition** carries no idempotency at all, because a cancelled transaction never completes and a retry with the same token is a fresh evaluation, not a replay. "A retried write lands once" is therefore true only of writes this library sends with a token — never of a `BatchWriteItem` — and says nothing about ordering. Full detail: [Guide → What a token guarantees, and what it does not](docs/guide.md#what-a-token-guarantees-and-what-it-does-not).
|
|
1096
|
+
|
|
1097
|
+
### What a token costs
|
|
1098
|
+
|
|
1099
|
+
A one-item transaction costs 2 write units per KB where the plain `PutItem` it replaces cost 1, and on a contended row it also costs about 2.6 requests per logical write once retried transaction conflicts are counted. A [worked example in request units](docs/guide.md#cost-in-request-units-a-worked-example) makes the 2× concrete for a real `saver.put`. Full detail — the measured conflict rates at two, five and twenty concurrent writers: [Guide → What a token costs](docs/guide.md#what-a-token-costs).
|
|
1100
|
+
|
|
1101
|
+
### What a partition delete promises
|
|
1102
|
+
|
|
1103
|
+
`deleteThread()` and `clear()` remove exactly the rows their one partition read observed: each row is deleted under the per-write id that read saw on it, so a row **rewritten between the read and its delete is refused rather than removed** — left exactly as its writer left it, logged at `warn` and counted as skipped, while the call itself still resolves. Re-running the call once the partition is idle is the remedy for whatever a pass leaves behind. Full detail — the two limits the pin does not soften, and the temporal caveat for rows older than `1.0.0-rc.2`: [Guide → What a partition delete promises](docs/guide.md#what-a-partition-delete-promises).
|
|
1104
|
+
|
|
1105
|
+
### What a partition delete costs
|
|
1106
|
+
|
|
1107
|
+
One conditional `DeleteItem` per row — about 25× the requests an unconditional `BatchWriteItem` delete would need, because `BatchWriteItem` cannot carry the condition each row is pinned with — buffered 25 at a time with at most 8 requests in flight. Write capacity for the rows actually deleted is unchanged; what a *refused* delete costs is not a figure this project has measured. Full detail: [Guide → What a partition delete costs](docs/guide.md#what-a-partition-delete-costs).
|
|
1108
|
+
|
|
1109
|
+
### TTL expiry
|
|
1110
|
+
|
|
1111
|
+
Set `ttl: { days }` or `{ seconds }`; every adapter filters rows past their `ttl` on read, so nothing expired comes back during DynamoDB's sweep lag. The checkpointer reads a thread whose head expired as its newest live checkpoint; chat history keeps one uniform, self-healing whole-conversation TTL on the session row, shared by every message. Full detail — the self-healing anchor, and what enabling `ttl` retroactively does and does not cover: [Guide → TTL expiry](docs/guide.md#ttl-expiry).
|
|
1112
|
+
|
|
1113
|
+
### Plain (metadata) search
|
|
1114
|
+
|
|
1115
|
+
A `search()` call with no `query` (or with neither `index` nor `vectorBackend` configured) reads rows under the `namespacePrefix` and decodes them `readConcurrency` at a time until `offset + limit` matches are in hand, then stops — the page is the complete answer. Only a page that cannot be filled from fewer rows is bounded by `maxScanItems` (default 10,000), a different cap from the semantic ranker's `maxSearchCandidates`. Full detail: [Guide → Plain (metadata) search](docs/guide.md#plain-metadata-search).
|
|
1116
|
+
|
|
1117
|
+
### Semantic search
|
|
1118
|
+
|
|
1119
|
+
Give the store an `index` with a LangChain `Embeddings` implementation: each configured field is embedded separately, and `search` with a `query` ranks an item by its **best-matching** vector. By default those vectors live on the item and ranking happens in-process, bounded by `maxSearchCandidates` (default 1000, ceiling 100,000); for a larger corpus, pass a `vectorBackend` and DynamoDB stays the canonical copy. Full detail — how a `vectorBackend` search and the in-DynamoDB path answer alike under throttling, and what gets dropped: [Guide → Semantic search](docs/guide.md#semantic-search).
|
|
1120
|
+
|
|
1121
|
+
### Vector index consistency
|
|
1122
|
+
|
|
1123
|
+
When a `vectorBackend` is configured, DynamoDB holds the canonical item and the embedding is synced to the backend best-effort after each write — a backend failure is logged, never thrown. `store.reconcileVectorIndex(namespacePrefix)` repairs drift by re-pushing every live embedding and, when the backend implements `listKeys`, pruning vectors whose item is gone. Full detail — the two-statement window a delete's confirmation read narrows but does not close: [Guide → Vector index consistency](docs/guide.md#vector-index-consistency).
|
|
1124
|
+
|
|
1125
|
+
### Checkpointer semantics
|
|
1126
|
+
|
|
1127
|
+
`put()` of an existing `checkpoint_id` is last-writer-wins by commit order, not by retry order — a retry can never overtake a call that committed after it, because each `put()` draws its own token. `putWrites` issues one guarded write per pending write, all in parallel, and `deleteThread()` reads the partition once and deletes what it saw, so call it when the thread is quiescent. Full detail: [Guide → Checkpointer semantics](docs/guide.md#checkpointer-semantics).
|
|
1128
|
+
|
|
1129
|
+
### Chat history semantics
|
|
1130
|
+
|
|
1131
|
+
Message order is strict within one adapter instance and follows the writers' wall clocks across instances, so a lagging process clock can sort a later turn before an earlier one. A batch over 99 messages or 3.5 MB commits in chunks and is atomic from the writer's perspective only — [Error ordering and partial progress](#error-ordering-and-partial-progress) says what a caller sees when a chunk fails partway. Full detail — serialization defaults and the per-chunk retry cost under contention: [Guide → Chat history semantics](docs/guide.md#chat-history-semantics).
|
|
1132
|
+
|
|
1133
|
+
### Differences from `InMemoryStore`
|
|
1134
|
+
|
|
1135
|
+
The store follows the reference semantics, and every observable difference is listed under [Versioning and compatibility](#differences-from-the-reference-implementations). The ones a caller meets first: `$gt`/`$gte`/`$lt`/`$lte` compare like types only, where the reference reduces both sides with `Number()` (a stored `'10'` does not match `{ $gt: 5 }` here, and two ISO-8601 date strings compare as dates rather than as `NaN`); results come back in key order, not insertion order; and the per-item `index` argument of `put` is honoured only on direct `DynamoDBStore` calls — LangGraph's `AsyncBatchedStore`, which wraps the store inside a graph, does not forward it.
|
|
1136
|
+
|
|
1137
|
+
`$eq`/`$ne`/`$in`/`$nin` and a plain field condition compare by deep equality, where upstream compares with `===`, so there an object- or array-valued field never equals a condition, even an identical one. An empty field condition `{}` constrains nothing, as upstream does. `put()` refuses a `null` value, which the reference treats as a delete; call `delete()` instead.
|
|
1138
|
+
|
|
1139
|
+
### Strong consistency
|
|
1140
|
+
|
|
1141
|
+
Checkpointer read-your-writes (`getTuple`) and every `store.get` use `ConsistentRead`, so a value written and immediately read back is never served a stale replica. Bulk reads (`list`, `listNamespaces`, `listSessions`) stay eventually consistent for lower cost.
|
|
1142
|
+
|
|
1143
|
+
## Known limitations
|
|
1144
|
+
|
|
1145
|
+
Each item below is a deliberate limit, not a known defect, and each links to the section with the detail. [What can still go wrong](#what-can-still-go-wrong) lists the narrower cases in which a row and its S3 payload can disagree.
|
|
1146
|
+
|
|
1147
|
+
### From DynamoDB and S3
|
|
1148
|
+
|
|
1149
|
+
- **400 KB items.** A DynamoDB item holds at most 400 KB, so without `s3` a payload over 392 KB is refused with a `VALIDATION` error before the write; with `s3`, a payload at or above `thresholdBytes` (default 350 KB) is offloaded. The store's inline vectors share the item and are not weighed against the threshold. ([Limits](#limits), [S3 offloading](#s3-offloading))
|
|
1150
|
+
- **One partition's throughput.** A thread's rows share `CHKPT#<thread_id>`, a session's `HIST#<sessionId>` and a store scope's `STORE#<namespace[0]>`, so the writes to one identifier are bounded by what one DynamoDB partition sustains. ([Production notes](#production-notes))
|
|
1151
|
+
- **TTL deletion lags.** DynamoDB deletes an expired row within a few days of its expiry, with no fixed bound ([DynamoDB TTL docs](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html)), and S3 lifecycle expiry counts whole days. Every read hides an expired row in the meantime; the storage is reclaimed later. ([TTL expiry](#ttl-expiry), [S3 lifecycle rules](#s3-lifecycle-rules))
|
|
1152
|
+
- **Cross-thread and cross-session listings scan the table.** `saver.list()` without a `thread_id` and `history.listSessions()` are `Scan`s until the recency index is created, backfilled with `backfillRecencyIndex()` and named with `indexName`; `store.search([])` and `store.listNamespaces()` without a prefix root stay `Scan`s. Each of them returns every tenant's rows. ([Production notes](#production-notes), [Maintenance operations](#maintenance-operations))
|
|
1153
|
+
- **Bulk reads are eventually consistent.** `list`, `store.search`, `listNamespaces` and `listSessions` can miss a write that has just returned; `getTuple` and `store.get` are consistent reads. ([Strong consistency](#strong-consistency))
|
|
1154
|
+
- **A request token deduplicates for ten minutes only.** DynamoDB honours a client request token for ten minutes, and a tokened write stops starting new attempts 300 s in so that its retries stay inside that window; a re-send after it is a new request and is applied. ([Write idempotency](#write-idempotency))
|
|
1155
|
+
|
|
1156
|
+
### From this package
|
|
1157
|
+
|
|
1158
|
+
- **In-DynamoDB semantic search ranks a bounded candidate set.** Ranking runs in process over at most `maxSearchCandidates` rows (default 1000, ceiling 100 000), and a prefix holding more is refused with a `VALIDATION` error, so a large corpus needs a `vectorBackend`. ([Semantic search](#semantic-search))
|
|
1159
|
+
- **`vectorBackend` sync is best-effort.** A backend failure after a committed put or delete is logged at `warn`, not thrown, and `store.reconcileVectorIndex()` repairs the drift. ([Vector index consistency](#vector-index-consistency))
|
|
1160
|
+
- **`deleteThread()` and `clear()` are single-pass.** They delete what one read of the partition saw, so a write that starts during the pass survives it: run them on an idle thread or session, and run them again to clear what a pass left. ([What a partition delete promises](#what-a-partition-delete-promises))
|
|
1161
|
+
- **`store.get`, `store.put` and `store.delete` take no `AbortSignal`,** because upstream's `BaseStore` gives those three no parameter for one. ([Cancellation](#cancellation))
|
|
1162
|
+
- **`store.delete()` can resolve with the item still there** when three of its attempts in a row are each turned away by a write that landed since the read that attempt pinned on, which exhausts its compare-and-swap; it logs one `warn`, and a re-run once the key is idle removes the item. ([V-29](#differences-from-the-reference-implementations))
|
|
1163
|
+
- **Chat order across processes follows the writers' clocks.** Within one adapter instance message ids are strictly monotonic; across processes they are ordered by wall clock at millisecond precision, so a lagging clock can sort a later turn before an earlier one. ([Chat history semantics](#chat-history-semantics))
|
|
1164
|
+
- **A large append is visible part-way.** An `addMessages` batch over 99 messages or 3.5 MB is committed in chunks, so a concurrent reader can see the first chunks before the append settles. ([Chat history semantics](#chat-history-semantics))
|
|
1165
|
+
- **Neither default serializer round-trips a `Date`,** and `JSON_SERDE`, the store's and chat history's default, loses the entries of a `Map` or `Set` and stores a `Uint8Array` as an index-keyed object. ([Table schema](#table-schema))
|
|
1166
|
+
- **The checkpointer's default serializer builds classes named by the row.** `JsonPlusSerializer` reconstructs allow-listed LangChain classes from stored records, so write access to the table is trusted access; `serde: JSON_SERDE` removes that, at the cost of the fidelity above. ([Trust boundary](#trust-boundary))
|
|
1167
|
+
- **Identifiers follow this package's key rules.** No `#` and no control character; at most 1024 bytes for `thread_id` and `sessionId` and 256 bytes for every other segment; and `store.put()` also applies upstream's rules of no `.` in a namespace label and no `"langgraph"` root. An id another saver accepts can be refused here with a `VALIDATION` error. ([Production notes](#production-notes), [Limits](#limits))
|
|
1168
|
+
- **`getDeltaChannelHistory()` tracks an upstream beta API,** so a change there can reach a minor release of this package. ([Not covered](#not-covered))
|
|
1169
|
+
- **A `listSessions` cursor needs `indexName`.** Without the recency index the listing is an unpaged scan, capped by `maxItems` and `maxIterations`. ([Listing sessions, threads and namespaces](#listing-sessions-threads-and-namespaces))
|
|
1170
|
+
- **`isDynamoDBLangGraphError` takes an `Error`,** so a caught `unknown` is cast before the check, as the samples do. ([Error handling](#error-handling))
|
|
1171
|
+
- **A release candidate, maintained by one person.** Release candidates of `1.0` come before `1.0.0`, and response times are best effort. ([Versioning and support](#versioning-and-support))
|
|
1172
|
+
|
|
1173
|
+
## Migrating
|
|
1174
|
+
|
|
1175
|
+
Two moves are covered here: onto this package from another saver or store, and from an earlier release of this package.
|
|
1176
|
+
|
|
1177
|
+
### Migrating from another checkpointer or store
|
|
1178
|
+
|
|
1179
|
+
There is **no importer**. Nothing in this package reads data written by `MemorySaver`, `InMemoryStore`, `InMemoryChatMessageHistory`, or a Postgres, SQLite or other saver, and the rows it writes are in its own format ([Table schema](#table-schema)). Moving is a code change, plus a decision about the data you already hold.
|
|
1180
|
+
|
|
1181
|
+
**The code change is the constructor.** What consumes the saver and the store — `compile({ checkpointer, store })`, `getState`, a node reading the store — stays as it is.
|
|
1182
|
+
|
|
1183
|
+
| Before | After |
|
|
1184
|
+
| --- | --- |
|
|
1185
|
+
| `new MemorySaver()` | `new DynamoDBSaver({ tableName, clientConfig })` |
|
|
1186
|
+
| `new InMemoryStore({ index })` | `new DynamoDBStore({ tableName, clientConfig, index })`, with the same `IndexConfig`: `dims`, `embeddings`, `fields` |
|
|
1187
|
+
| `new InMemoryChatMessageHistory()` | `history.forSession(sessionId)` on a `DynamoDBChatMessageHistory` ([RunnableWithMessageHistory](#runnablewithmessagehistory)) |
|
|
1188
|
+
|
|
1189
|
+
```typescript
|
|
1190
|
+
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
|
|
1191
|
+
import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph';
|
|
1192
|
+
import { InMemoryStore, MemorySaver } from '@langchain/langgraph-checkpoint';
|
|
1193
|
+
import { DynamoDBSaver, DynamoDBStore } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
1194
|
+
|
|
1195
|
+
const graph = new StateGraph(MessagesAnnotation)
|
|
1196
|
+
.addNode('model', async (state) => ({ messages: [await model.invoke(state.messages)] }))
|
|
1197
|
+
.addEdge(START, 'model')
|
|
1198
|
+
.addEdge('model', END);
|
|
1199
|
+
const index = { dims: 1024, embeddings, fields: ['text'] }; // dims: what your embeddings return
|
|
1200
|
+
|
|
1201
|
+
// Before: everything lives in the process and is gone when it exits.
|
|
1202
|
+
const before = graph.compile({ checkpointer: new MemorySaver(), store: new InMemoryStore({ index }) });
|
|
1203
|
+
const sessionBefore = new InMemoryChatMessageHistory();
|
|
1204
|
+
|
|
1205
|
+
// After: the same graph, persisted in one DynamoDB table.
|
|
1206
|
+
const table = { tableName: 'langgraph', clientConfig: { region: 'eu-west-1' } };
|
|
1207
|
+
const after = graph.compile({
|
|
1208
|
+
checkpointer: new DynamoDBSaver(table),
|
|
1209
|
+
store: new DynamoDBStore({ ...table, index }),
|
|
1210
|
+
});
|
|
1211
|
+
const sessionAfter = history.forSession('session-1');
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
**Behaviour that differs.** Every observable difference from `MemorySaver` and `InMemoryStore` is a row of [Differences from the reference implementations](#differences-from-the-reference-implementations); read it before switching. The ones a caller meets first are the identifier rules — an id valid elsewhere may be refused here with a `VALIDATION` error — `store.put()` refusing a `null` value, where the reference treats it as a delete, and `$gt`/`$gte`/`$lt`/`$lte` comparing like types only.
|
|
1215
|
+
|
|
1216
|
+
**Checkpoints.** This package has no tested way to copy them, so it offers none. Two strategies need no copy: let the threads that already exist finish on the old saver while new threads start on `DynamoDBSaver`, compiling the graph once per saver and choosing between them by `thread_id`; or start fresh, which is what a `MemorySaver` deployment does at every restart anyway.
|
|
1217
|
+
|
|
1218
|
+
**Store items.** A `BaseStore` can be copied through the public store API. When the target has an `index`, the copy embeds every item it writes with the target's `index` fields, whatever the source indexed it with — an item put there with `index: false` is embedded here — and each copied item's `createdAt` and `updatedAt` are the time of the copy:
|
|
1219
|
+
|
|
1220
|
+
```typescript
|
|
1221
|
+
import type { BaseStore, PutOperation } from '@langchain/langgraph-checkpoint';
|
|
1222
|
+
import type { DynamoDBStore } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
1223
|
+
|
|
1224
|
+
declare const source: BaseStore; // the store you are leaving
|
|
1225
|
+
declare const target: DynamoDBStore;
|
|
1226
|
+
|
|
1227
|
+
const PAGE = 100;
|
|
1228
|
+
const copied = new Set<string>();
|
|
1229
|
+
|
|
1230
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
1231
|
+
// The empty prefix matches every namespace, so one paged walk reaches every item.
|
|
1232
|
+
const items = await source.search([], { limit: PAGE, offset });
|
|
1233
|
+
const puts: PutOperation[] = [];
|
|
1234
|
+
for (const item of items) {
|
|
1235
|
+
const id = JSON.stringify([item.namespace, item.key]); // a safety net: each item is written once
|
|
1236
|
+
if (copied.has(id)) continue;
|
|
1237
|
+
copied.add(id);
|
|
1238
|
+
puts.push({ namespace: item.namespace, key: item.key, value: item.value });
|
|
1239
|
+
}
|
|
1240
|
+
await target.batch(puts);
|
|
1241
|
+
if (items.length < PAGE) break;
|
|
1242
|
+
}
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
The walk is paged because `BaseStore`'s own `search` answers ten items when no `limit` is given, and it pages over the whole store rather than over `listNamespaces()` because `InMemoryStore` keys a namespace by its labels joined with `:` and lists it by splitting on `:` again, so `['user:42', 'memories']` is listed as `['user', '42', 'memories']` — a per-namespace walk would miss that item.
|
|
1246
|
+
|
|
1247
|
+
`InMemoryStore` answers `search([])` with every item once, in an order that stays fixed while nothing writes to it — namespaces in the order each was first written, and items within one in the order they were first written — so offset paging is stable only with the source idle: run the copy then. That is the one source this recipe has been checked against. Another backend's `search([])` may order or cap differently — a `DynamoDBStore` source, for one, scans the table for it and refuses with `RESULT_TRUNCATED` a page whose `offset + limit` passes its `maxScanItems` ([Plain (metadata) search](#plain-metadata-search)) — so check how yours pages before relying on this.
|
|
1248
|
+
|
|
1249
|
+
The items are written with `batch` rather than `put`: `put()` alone applies upstream's rules of no `.` in a namespace label and no `"langgraph"` root, while a graph writes through `batch()`, which accepts both, so a namespace such as `['memories', 'jane.doe@example.com']` that a graph wrote copies too. An item whose address this package refuses — a label or key holding `#`, say — fails its batch with a `VALIDATION` error naming the field before any of that batch is written; a value it cannot store, or embeddings whose length disagrees with `index.dims`, can fail the batch after other operations in it have run. Either way the batches before it are already in the table, and running the copy again rewrites them.
|
|
1250
|
+
|
|
1251
|
+
### Migrating from earlier versions
|
|
1252
|
+
|
|
1253
|
+
**0.7.x → 0.8.0**: **every adapter's partition key is now adapter-tagged** —
|
|
1254
|
+
`PK = <thread_id>` → `CHKPT#<thread_id>`, `PK = <namespace[0]>` →
|
|
1255
|
+
`STORE#<namespace[0]>`, `PK = <sessionId>` → `HIST#<sessionId>` — and the
|
|
1256
|
+
checkpointer's pending-write sort key gains a trailing channel segment
|
|
1257
|
+
(`WRITE#<ns>#<id>#<task>#<idx>` → `…#<idx>#<channel>`).
|
|
1258
|
+
|
|
1259
|
+
This closes two real defects on a table shared via `createAll()`. Reusing one
|
|
1260
|
+
identifier across adapters — a "conversation id" used as both a `thread_id`
|
|
1261
|
+
and a `sessionId`, an entirely ordinary choice — put unrelated adapters' rows
|
|
1262
|
+
in a single partition, where `deleteThread()` silently deleted the chat
|
|
1263
|
+
history (and vice versa), and identically-composed sort keys let one adapter
|
|
1264
|
+
overwrite another's item or hand its payload back on read. The channel segment
|
|
1265
|
+
separately stops a retried task whose write mix changed from silently losing a
|
|
1266
|
+
write; a per-call write group, also stored on each row, keeps such a retry from
|
|
1267
|
+
replaying a channel twice. See the CHANGELOG for the full mechanism.
|
|
1268
|
+
|
|
1269
|
+
**Data written by 0.7.x is not found after upgrading**, for all three
|
|
1270
|
+
adapters. Back up and recreate the table before upgrading.
|
|
1271
|
+
|
|
1272
|
+
Three behaviour changes are also worth checking before you upgrade:
|
|
1273
|
+
constructing a `DynamoDBStore` with a `vectorBackend` but no `index` now
|
|
1274
|
+
throws instead of silently returning unranked results; `getMessages` skips and
|
|
1275
|
+
logs an undecodable message instead of failing the whole read (pass
|
|
1276
|
+
`onCorruptMessage: 'throw'` to keep the old behaviour); and the store's
|
|
1277
|
+
`$gt`/`$gte`/`$lt`/`$lte` filters no longer coerce across types, so a stored
|
|
1278
|
+
`'10'` no longer matches `{ $gt: 5 }`.
|
|
1279
|
+
|
|
1280
|
+
**0.6.x → 0.7.0**: chat-history's sort keys now carry a `HISTORY#` item-kind
|
|
1281
|
+
tag — `SK = SESSION` → `SK = HISTORY#SESSION`, `SK = MSG#<ULID>` →
|
|
1282
|
+
`SK = HISTORY#MSG#<ULID>`. This closes a real key collision on a table
|
|
1283
|
+
shared via `createAll()`: an unprefixed `SESSION` sort key was reachable by
|
|
1284
|
+
an ordinary store call (`store.put([sessionId], 'SESSION', …)`), silently
|
|
1285
|
+
corrupting both adapters' items. Existing chat history data written before
|
|
1286
|
+
this change will not be found by `getMessages`/`listSessions`/`clear` after
|
|
1287
|
+
upgrading — back up and migrate (or recreate) any table with real
|
|
1288
|
+
chat-history data before upgrading. Checkpointer and store keys are
|
|
1289
|
+
unaffected.
|
|
1290
|
+
|
|
1291
|
+
**0.2.x → 0.3.0** is a complete, ground-up rewrite. The public API is similar, but the table schema, on-disk layout, and several options changed, so existing data is **not compatible** — create a new table.
|
|
1292
|
+
|
|
1293
|
+
- **Table schema is now `PK`/`SK` strings** (one table for all adapters) instead of per-adapter custom key names. The key attribute names changed, so CDK/Terraform definitions need updating.
|
|
1294
|
+
- **Store keys** are `PK = namespace[0]`, `SK = namespace[1..]#key` (was `PK = full namespace`, `SK = key`).
|
|
1295
|
+
- **Chat history is one item per message** (`SK = MSG#<ULID>`) plus a `SESSION` metadata item, replacing the single per-session item.
|
|
1296
|
+
- **Single `tableName` option** per adapter (was `checkpointsTableName`/`writesTableName`, etc.).
|
|
1297
|
+
- **One `ttl` option** — `{ days }` or `{ seconds }` — replaces `ttlDays`/`ttlSeconds`.
|
|
1298
|
+
- **S3 config option renamed** `s3OffloadConfig` → `s3`.
|
|
1299
|
+
- **Per-instance `logger` option** replaces the global `setGlobalLogger` singleton; default logging is now silent.
|
|
1300
|
+
- **Unified error model** — every error is a `DynamoDBLangGraphError`, distinguished by its `ErrorCode`.
|
|
1301
|
+
|
|
1302
|
+
## API reference
|
|
1303
|
+
|
|
1304
|
+
The full generated reference is [`docs/api`](docs/api/README.md), regenerated from the `src` doc comments by `npm run docs` and checked for drift in CI. The tables below list every method this package declares, with its signature shortened to parameter names and an optional parameter marked `?`, and link to its entry there, which states what it accepts, returns, throws and guarantees. A method a class inherits without overriding keeps LangGraph's or LangChain's own behaviour and is documented in their packages, not repeated here — notably `DynamoDBSaver`'s `get` and `getNextVersion`, and `DynamoDBStore`'s `start`. Every method that returns a promise rejects only with a `DynamoDBLangGraphError` ([Error handling](#error-handling)), and [What each operation costs](#what-each-operation-costs) gives the requests behind each call. No constructor issues a request.
|
|
1305
|
+
|
|
1306
|
+
### DynamoDBSaver
|
|
1307
|
+
|
|
1308
|
+
A LangGraph `BaseCheckpointSaver`. [Class page](docs/api/classes/DynamoDBSaver.md).
|
|
1309
|
+
|
|
1310
|
+
| Method | Returns | Description |
|
|
1311
|
+
| --- | --- | --- |
|
|
1312
|
+
| [`new DynamoDBSaver(options)`](docs/api/classes/DynamoDBSaver.md#constructor) | `DynamoDBSaver` | Validates `options`, and builds its own client unless given a `client`. |
|
|
1313
|
+
| [`getTuple(config)`](docs/api/classes/DynamoDBSaver.md#gettuple) | `Promise<CheckpointTuple \| undefined>` | The checkpoint `config` names, or the newest in its namespace when it names none; strongly consistent. `undefined` for an unknown thread or checkpoint. |
|
|
1314
|
+
| [`list(config, options?)`](docs/api/classes/DynamoDBSaver.md#list) | `AsyncGenerator<CheckpointTuple>` | Checkpoints newest first, narrowed by `before`, `filter` and `limit`. Without a `thread_id` it scans the table, or reads the recency index when `indexName` is set. A `VALIDATION` error surfaces from the first `.next()`. |
|
|
1315
|
+
| [`put(config, checkpoint, metadata, newVersions?)`](docs/api/classes/DynamoDBSaver.md#put) | `Promise<RunnableConfig>` | Stores a checkpoint and its metadata in one transaction and returns the config addressing it. `newVersions` is accepted and ignored: every channel value is stored. |
|
|
1316
|
+
| [`putWrites(config, writes, taskId)`](docs/api/classes/DynamoDBSaver.md#putwrites) | `Promise<void>` | Stores a task's pending writes, one row each, first-write-wins; the special channels (`__interrupt__`, `__resume__`, `__error__`, `__scheduled__`) overwrite. |
|
|
1317
|
+
| [`deleteThread(threadId, options?)`](docs/api/classes/DynamoDBSaver.md#deletethread) | `Promise<void>` | Deletes every checkpoint, payload and pending write of a thread in one pass, so call it when the thread is quiescent. `BATCH_WRITE_INCOMPLETE` when a row's delete fails. |
|
|
1318
|
+
| [`getDeltaChannelHistory(options)`](docs/api/classes/DynamoDBSaver.md#getdeltachannelhistory) | `Promise<Record<string, DeltaChannelHistory>>` | Walks a checkpoint's ancestors for the delta channels named; `ANCESTOR_EXPIRED` when an ancestor a channel still needs has expired. |
|
|
1319
|
+
| [`ensureS3LifecycleRule()`](docs/api/classes/DynamoDBSaver.md#ensures3lifecyclerule) | `Promise<void>` | Installs the S3 lifecycle rule matching `ttl`, and does nothing without both `s3` and `ttl`. Needs bucket-level permissions, so call it once at deployment ([S3 lifecycle rules](#s3-lifecycle-rules)). |
|
|
1320
|
+
| [`destroy()`](docs/api/classes/DynamoDBSaver.md#destroy) | `void` | Releases the clients the saver built. Idempotent, and never closes an injected `client`. |
|
|
1321
|
+
|
|
1322
|
+
### DynamoDBStore
|
|
1323
|
+
|
|
1324
|
+
A LangGraph `BaseStore`. [Class page](docs/api/classes/DynamoDBStore.md).
|
|
1325
|
+
|
|
1326
|
+
| Method | Returns | Description |
|
|
1327
|
+
| --- | --- | --- |
|
|
1328
|
+
| [`new DynamoDBStore(options)`](docs/api/classes/DynamoDBStore.md#constructor) | `DynamoDBStore` | Validates `options`, `index` and `vectorBackend` included, and builds its own client unless given a `client`. |
|
|
1329
|
+
| [`get(namespace, key)`](docs/api/classes/DynamoDBStore.md#get) | `Promise<Item \| null>` | One item, or `null` for one that does not exist or has expired. Takes no signal, since upstream's `BaseStore.get` declares none. |
|
|
1330
|
+
| [`put(namespace, key, value, index?)`](docs/api/classes/DynamoDBStore.md#put) | `Promise<void>` | Stores or replaces an item, embedding its indexed fields when an `index` is configured. Refuses a `null` value, a label holding `.` and a `"langgraph"` root. |
|
|
1331
|
+
| [`delete(namespace, key)`](docs/api/classes/DynamoDBStore.md#delete) | `Promise<void>` | Removes an item; deleting one that is not there is not an error. The row is read before it is removed. |
|
|
1332
|
+
| [`search(namespacePrefix, options?)`](docs/api/classes/DynamoDBStore.md#search) | `Promise<SearchItem[]>` | Items under a prefix, narrowed by `filter`, and ranked by `query` when an `index` is configured. In-DynamoDB ranking refuses more than `maxSearchCandidates` candidates. Takes a `signal`. |
|
|
1333
|
+
| [`listNamespaces(options?)`](docs/api/classes/DynamoDBStore.md#listnamespaces) | `Promise<string[][]>` | Distinct namespaces, sorted, narrowed by `prefix`, `suffix` and `maxDepth` and paged by `limit` and `offset`. Reads one partition when the prefix opens with concrete labels and the whole table otherwise; `RESULT_TRUNCATED` past `maxScanItems`. |
|
|
1334
|
+
| [`batch(operations)`](docs/api/classes/DynamoDBStore.md#batch) | `Promise<OperationResults<Op>>` | Runs operations in the order written, concurrently where they address different items. Every operation is validated before any runs. |
|
|
1335
|
+
| [`reconcileVectorIndex(namespacePrefix, options?)`](docs/api/classes/DynamoDBStore.md#reconcilevectorindex) | `Promise<VectorReconcileResult>` | Repairs the `vectorBackend` from the items under a prefix, and never writes DynamoDB ([Vector index consistency](#vector-index-consistency)). |
|
|
1336
|
+
| [`ensureS3LifecycleRule()`](docs/api/classes/DynamoDBStore.md#ensures3lifecyclerule) | `Promise<void>` | As on the saver. |
|
|
1337
|
+
| [`destroy()`](docs/api/classes/DynamoDBStore.md#destroy) | `void` | As on the saver. |
|
|
1338
|
+
| [`stop()`](docs/api/classes/DynamoDBStore.md#stop) | `void` | LangGraph's lifecycle hook, and the same call as `destroy()`. |
|
|
1339
|
+
|
|
1340
|
+
### DynamoDBChatMessageHistory
|
|
1341
|
+
|
|
1342
|
+
Every session through one adapter: each method takes the `sessionId`. [Class page](docs/api/classes/DynamoDBChatMessageHistory.md).
|
|
1343
|
+
|
|
1344
|
+
| Method | Returns | Description |
|
|
1345
|
+
| --- | --- | --- |
|
|
1346
|
+
| [`new DynamoDBChatMessageHistory(options)`](docs/api/classes/DynamoDBChatMessageHistory.md#constructor) | `DynamoDBChatMessageHistory` | Validates `options`, and builds its own client unless given a `client`. |
|
|
1347
|
+
| [`getMessages(sessionId, options?)`](docs/api/classes/DynamoDBChatMessageHistory.md#getmessages) | `Promise<BaseMessage[]>` | A session's messages, oldest first, optionally only the newest `limit` or those appended `before` an instant; strongly consistent. |
|
|
1348
|
+
| [`addMessages(sessionId, messages, options?)`](docs/api/classes/DynamoDBChatMessageHistory.md#addmessages) | `Promise<void>` | Appends messages all or nothing, one transaction per chunk of up to 99, and is safe under concurrent appends. `COMPENSATION_FAILED` when a later chunk fails and the rollback fails too. |
|
|
1349
|
+
| [`addMessage(sessionId, message, options?)`](docs/api/classes/DynamoDBChatMessageHistory.md#addmessage) | `Promise<void>` | Appends one message. |
|
|
1350
|
+
| [`clear(sessionId, options?)`](docs/api/classes/DynamoDBChatMessageHistory.md#clear) | `Promise<void>` | Deletes a session's messages, metadata and offloaded objects in one pass, so call it when the session is quiescent. |
|
|
1351
|
+
| [`listSessions(options?)`](docs/api/classes/DynamoDBChatMessageHistory.md#listsessions) | `Promise<SessionPage>` | Session summaries, most recently updated first. Pages by `cursor` through the recency index when `indexName` is set; otherwise a table scan across every tenant, bounded by `maxItems` and `maxIterations`, with no cursor. |
|
|
1352
|
+
| [`reconcileMessageCount(sessionId, options?)`](docs/api/classes/DynamoDBChatMessageHistory.md#reconcilemessagecount) | `Promise<number>` | Recounts a session's messages and repairs its `messageCount`. |
|
|
1353
|
+
| [`forSession(sessionId, window?)`](docs/api/classes/DynamoDBChatMessageHistory.md#forsession) | `DynamoDBSessionChatMessageHistory` | A LangChain single-session adapter bound to one session, for `RunnableWithMessageHistory`. |
|
|
1354
|
+
| [`ensureS3LifecycleRule()`](docs/api/classes/DynamoDBChatMessageHistory.md#ensures3lifecyclerule) | `Promise<void>` | As on the saver. |
|
|
1355
|
+
| [`destroy()`](docs/api/classes/DynamoDBChatMessageHistory.md#destroy) | `void` | As on the saver. |
|
|
1356
|
+
|
|
1357
|
+
### DynamoDBSessionChatMessageHistory
|
|
1358
|
+
|
|
1359
|
+
A LangChain `BaseListChatMessageHistory` bound to one session and an optional read window. Build it with `history.forSession(sessionId, window?)` ([RunnableWithMessageHistory](#runnablewithmessagehistory)). [Class page](docs/api/classes/DynamoDBSessionChatMessageHistory.md).
|
|
1360
|
+
|
|
1361
|
+
| Method | Returns | Description |
|
|
1362
|
+
| --- | --- | --- |
|
|
1363
|
+
| [`new DynamoDBSessionChatMessageHistory(backend, sessionId, window?)`](docs/api/classes/DynamoDBSessionChatMessageHistory.md#constructor) | `DynamoDBSessionChatMessageHistory` | Validates `backend`, `sessionId` and `window`. Normally built through [`forSession`](docs/api/classes/DynamoDBChatMessageHistory.md#forsession), the supported route. |
|
|
1364
|
+
| [`getMessages()`](docs/api/classes/DynamoDBSessionChatMessageHistory.md#getmessages) | `Promise<BaseMessage[]>` | The session's messages, bounded by the window, which is what keeps a long session from growing the prompt without limit. |
|
|
1365
|
+
| [`addMessages(messages)`](docs/api/classes/DynamoDBSessionChatMessageHistory.md#addmessages) | `Promise<void>` | Appends messages. The window bounds what is read, never what is written. |
|
|
1366
|
+
| [`addMessage(message)`](docs/api/classes/DynamoDBSessionChatMessageHistory.md#addmessage) | `Promise<void>` | Appends one message. |
|
|
1367
|
+
| [`clear()`](docs/api/classes/DynamoDBSessionChatMessageHistory.md#clear) | `Promise<void>` | Deletes the whole session, not only the window. |
|
|
1368
|
+
|
|
1369
|
+
### DynamoDBFactory
|
|
1370
|
+
|
|
1371
|
+
Builds the adapters over one set of defaults. [Class page](docs/api/classes/DynamoDBFactory.md).
|
|
1372
|
+
|
|
1373
|
+
| Method | Returns | Description |
|
|
1374
|
+
| --- | --- | --- |
|
|
1375
|
+
| [`new DynamoDBFactory(base?)`](docs/api/classes/DynamoDBFactory.md#constructor) | `DynamoDBFactory` | Checks `base`'s own keys, the client choice and `logger`; each adapter validates the rest of `base` for itself when it is built. Opens nothing. |
|
|
1376
|
+
| [`createSaver(options)`](docs/api/classes/DynamoDBFactory.md#createsaver) | `DynamoDBSaver` | A saver with `options` laid over the defaults; a per-adapter value wins. |
|
|
1377
|
+
| [`createStore(options)`](docs/api/classes/DynamoDBFactory.md#createstore) | `DynamoDBStore` | A store, likewise. |
|
|
1378
|
+
| [`createChatMessageHistory(options)`](docs/api/classes/DynamoDBFactory.md#createchatmessagehistory) | `DynamoDBChatMessageHistory` | A chat history, likewise. |
|
|
1379
|
+
| [`createAll(options)`](docs/api/classes/DynamoDBFactory.md#createall) | `CreatedAdapters<O>` | The adapters whose sections are given, on one shared DynamoDB client, and one `destroy` that releases them all ([One client for all three adapters](#one-client-for-all-three-adapters)). |
|
|
1380
|
+
|
|
1381
|
+
### Functions and values
|
|
1382
|
+
|
|
1383
|
+
| Export | Signature | Description |
|
|
1384
|
+
| --- | --- | --- |
|
|
1385
|
+
| [`backfillRecencyIndex`](docs/api/functions/backfillRecencyIndex.md) | `(options) => Promise<BackfillResult>` | Gives rows written before the recency index its keys; run it before setting `indexName`. It scans the table, in bounded slices with `maxPages` and `cursor` ([Maintenance operations](#maintenance-operations)). |
|
|
1386
|
+
| [`isDynamoDBLangGraphError`](docs/api/functions/isDynamoDBLangGraphError.md) | `(value) => value is AnyDynamoDBLangGraphError` | Whether a caught value is this package's error. Recognised by a brand rather than `instanceof`, so it holds across realms and across two copies of the package; never throws. |
|
|
1387
|
+
| [`redactLogger`](docs/api/functions/redactLogger.md) | `(inner, options?) => Logger` | Wraps a logger so every argument after the message is redacted before it reaches `inner` ([Logging](#logging)). |
|
|
1388
|
+
| [`redactSecrets`](docs/api/functions/redactSecrets.md) | `(value, patterns?, valuePatterns?) => Redactable` | A redacted clone of one value; the input is never mutated. |
|
|
1389
|
+
| [`JSON_SERDE`](docs/api/variables/JSON_SERDE.md) | `SerializerProtocol` | The plain JSON serializer the store and chat history use by default, which a saver can be given in place of LangGraph's `JsonPlusSerializer` ([Trust boundary](#trust-boundary)). |
|
|
1390
|
+
| [`ErrorCode`](docs/api/enumerations/ErrorCode.md) | `enum` | The 20 codes a `DynamoDBLangGraphError` can carry. |
|
|
1391
|
+
| [`DynamoDBLangGraphError`](docs/api/classes/DynamoDBLangGraphError.md) | `class` | The one error class, with `code`, `context`, `details` and the native `cause`. |
|
|
1392
|
+
|
|
1393
|
+
**Exported types.** Every option, result and collaborator type — `DynamoDBSaverOptions`, `SearchOptions`, `SessionPage`, `VectorBackend`, `Logger`, `RetryOptions` and the rest — is listed in [the reference index](docs/api/README.md). The package's exports map admits no deep import, so what the package root exports is the whole surface.
|
|
1394
|
+
|
|
1395
|
+
## Infrastructure setup
|
|
1396
|
+
|
|
1397
|
+
One table backs all three adapters. Create it with the **AWS CLI**, or from a **DynamoDB Local** endpoint for development; an **AWS CDK** and a **Terraform** definition of the same table are in the guide, for deployments already using one of those tools: [Guide → Infrastructure as code](docs/guide.md#infrastructure-as-code).
|
|
1398
|
+
|
|
1399
|
+
<details>
|
|
1400
|
+
<summary><strong>AWS CLI</strong></summary>
|
|
1401
|
+
|
|
1402
|
+
```bash
|
|
1403
|
+
aws dynamodb create-table \
|
|
1404
|
+
--table-name langgraph \
|
|
1405
|
+
--attribute-definitions \
|
|
1406
|
+
AttributeName=PK,AttributeType=S \
|
|
1407
|
+
AttributeName=SK,AttributeType=S \
|
|
1408
|
+
AttributeName=gsi1pk,AttributeType=S \
|
|
1409
|
+
AttributeName=gsi1sk,AttributeType=S \
|
|
1410
|
+
--key-schema \
|
|
1411
|
+
AttributeName=PK,KeyType=HASH \
|
|
1412
|
+
AttributeName=SK,KeyType=RANGE \
|
|
1413
|
+
--billing-mode PAY_PER_REQUEST \
|
|
1414
|
+
--global-secondary-indexes \
|
|
1415
|
+
'IndexName=gsi1,KeySchema=[{AttributeName=gsi1pk,KeyType=HASH},{AttributeName=gsi1sk,KeyType=RANGE}],Projection={ProjectionType=ALL}'
|
|
1416
|
+
|
|
1417
|
+
# Optional; only needed if you use the `ttl` option
|
|
1418
|
+
aws dynamodb update-time-to-live \
|
|
1419
|
+
--table-name langgraph \
|
|
1420
|
+
--time-to-live-specification "Enabled=true,AttributeName=ttl"
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
The recency index (the last two `attribute-definitions` and the `--global-secondary-indexes` flag) is optional, exactly as in the CDK and Terraform definitions in the guide: drop them, run `backfillRecencyIndex()` and add the index later, then set `indexName: 'gsi1'` on the adapters. The GSI's projection must be `ALL` — the recency-index reads listed under [Maintenance operations](#maintenance-operations) read the row straight off the index, not through a follow-up `GetItem`.
|
|
1424
|
+
|
|
1425
|
+
</details>
|
|
1426
|
+
|
|
1427
|
+
**DynamoDB Local**, for development without an AWS account: point `clientConfig` at it, with any non-empty region and credentials (the emulator does not check them):
|
|
1428
|
+
|
|
1429
|
+
```typescript
|
|
1430
|
+
import { DynamoDBSaver } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
1431
|
+
|
|
1432
|
+
const saver = new DynamoDBSaver({
|
|
1433
|
+
tableName: 'langgraph',
|
|
1434
|
+
clientConfig: {
|
|
1435
|
+
endpoint: 'http://localhost:8000',
|
|
1436
|
+
region: 'local',
|
|
1437
|
+
credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
|
|
1438
|
+
},
|
|
1439
|
+
});
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
### S3 lifecycle rules
|
|
1443
|
+
|
|
1444
|
+
`ensureS3LifecycleRule()` writes **two** rules, both scoped to the adapter's `keyPrefix`. They are
|
|
1445
|
+
given verbatim here so a deployment that manages its own lifecycle can reproduce them — one that
|
|
1446
|
+
configures `s3` without a `ttl` (where the call is a no-op), or one that never calls it at all.
|
|
1447
|
+
|
|
1448
|
+
The example below is the checkpointer's **default** `keyPrefix` — no adapter writes to the bare
|
|
1449
|
+
`langgraph-checkpoints/` base by default; each defaults to its own sub-prefix
|
|
1450
|
+
(`langgraph-checkpoints/checkpointer/`, `.../store/`, `.../history/`), or to whatever `s3.keyPrefix`
|
|
1451
|
+
you set explicitly:
|
|
1452
|
+
|
|
1453
|
+
```json
|
|
1454
|
+
{
|
|
1455
|
+
"ID": "langgraph-ttl-langgraph-checkpoints-checkpointer",
|
|
1456
|
+
"Filter": { "Prefix": "langgraph-checkpoints/checkpointer/" },
|
|
1457
|
+
"Status": "Enabled",
|
|
1458
|
+
"Expiration": { "Days": 32 },
|
|
1459
|
+
"NoncurrentVersionExpiration": { "NoncurrentDays": 1 }
|
|
1460
|
+
}
|
|
1461
|
+
```
|
|
1462
|
+
|
|
1463
|
+
```json
|
|
1464
|
+
{
|
|
1465
|
+
"ID": "langgraph-ttl-langgraph-checkpoints-checkpointer-markers",
|
|
1466
|
+
"Filter": { "Prefix": "langgraph-checkpoints/checkpointer/" },
|
|
1467
|
+
"Status": "Enabled",
|
|
1468
|
+
"Expiration": { "ExpiredObjectDeleteMarker": true }
|
|
1469
|
+
}
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1472
|
+
Both ids are slugs of the `keyPrefix`, so each adapter's prefix gets its own pair. `Days` is the
|
|
1473
|
+
`ttl` rounded up to whole days plus a two-day margin for DynamoDB's TTL sweep lag — 32 above is
|
|
1474
|
+
`ttl: { days: 30 }` — and it governs the **current** version only. `NoncurrentDays` is the grace a
|
|
1475
|
+
**released** payload gets: on a versioned bucket, releasing an object does not erase it, it becomes
|
|
1476
|
+
a noncurrent version behind a delete marker for this window (24–48 h at the one-day floor), and the
|
|
1477
|
+
second rule reclaims the delete marker itself once that window has passed. Both clauses do nothing
|
|
1478
|
+
on a bucket **without versioning**: there are no noncurrent versions to keep and no markers to
|
|
1479
|
+
reclaim, and a release is an ordinary delete with no recovery window at all — `ensureS3LifecycleRule()`
|
|
1480
|
+
reports the bucket's versioning state at `warn` rather than enforcing it, because versioning is the
|
|
1481
|
+
operator's to enable, not this library's to require.
|
|
1482
|
+
|
|
1483
|
+
Full detail — exactly which existing lifecycle rules a floor is measured against and why it never
|
|
1484
|
+
lowers, which fields survive a rewrite, and what "reported, never enforced" costs an operator who
|
|
1485
|
+
does not read the `warn`: [Guide → S3 lifecycle rules in depth](docs/guide.md#s3-lifecycle-rules-in-depth).
|
|
1486
|
+
|
|
1487
|
+
## Table schema
|
|
1488
|
+
|
|
1489
|
+
Every adapter uses the **same simple key schema**: a string partition key `PK`, a string sort key `SK`, and an optional Number `ttl` attribute for expiry. **A single table can back all three adapters**, or you can use a separate table per adapter — your choice via the `tableName` option.
|
|
1490
|
+
|
|
1491
|
+
| Attribute | Type | Role |
|
|
1492
|
+
| --- | --- | --- |
|
|
1493
|
+
| `PK` | String (HASH) | partition key |
|
|
1494
|
+
| `SK` | String (RANGE) | sort key |
|
|
1495
|
+
| `ttl` | Number | (optional) Unix-epoch-seconds expiry; enable DynamoDB TTL on this attribute |
|
|
1496
|
+
| `gsi1pk` | String | (optional) recency-index partition key; written to the rows that listings cross partitions for — checkpointer `META`, store items, history `SESSION` |
|
|
1497
|
+
| `gsi1sk` | String | (optional) recency-index sort key, `<updatedAt>#<id>` |
|
|
1498
|
+
|
|
1499
|
+
Those two index attributes are always written; they cost nothing until the table
|
|
1500
|
+
carries a global secondary index on them and an adapter is told its name with
|
|
1501
|
+
`indexName`. Without it every listing behaves exactly as before, so upgrading
|
|
1502
|
+
changes nothing until you create the index — see
|
|
1503
|
+
[Infrastructure setup](#infrastructure-setup) for the definition and
|
|
1504
|
+
[Maintenance operations](#maintenance-operations) for the backfill that must run
|
|
1505
|
+
first.
|
|
1506
|
+
|
|
1507
|
+
Payloads live under one reserved attribute per row kind (`checkpoint`, `metadata`, `value`, `message`) as a **payload descriptor**: `{ schemaVersion: 1, location: 'INLINE' | 'S3', serdeType, compressed, bytes | s3Key }`. This shape is a compatibility contract: unknown fields are ignored, a missing `schemaVersion` reads as 1, and a higher `schemaVersion` or an unknown `location` is refused with a `VALIDATION` error (field `descriptor`) rather than misread.
|
|
1508
|
+
|
|
1509
|
+
Offloaded S3 keys are `<keyPrefix><the row's identifiers, each base64url-encoded>/<write id>.bin` (for a history message, whose own ULID is the write id, the identifiers above it are its session) — the row above the id, so an object belongs to exactly one row, and below it the id of the write that uploaded it, so two writes never share an object, even when they store the same bytes. The identifier segments are trivially reversible — treat S3 keys and `S3_OFFLOAD_FAILED` error context as identifier-bearing in your log-redaction policy.
|
|
1510
|
+
|
|
1511
|
+
**What the default serializers do with a value JavaScript can hold and JSON cannot.** The checkpointer defaults to LangGraph's `JsonPlusSerializer`; the store and history adapters default to this package's plain-JSON `JSON_SERDE`, which is exported, so a checkpointer can be given it too (see [Trust boundary](#trust-boundary) for why you might). They are two different serializers and they disagree, in both directions, and nothing is recorded anywhere to say a value was substituted — so the table below belongs in the decision of whether to pass a `serde` of your own. Each row was measured, not inferred from the implementations.
|
|
1512
|
+
|
|
1513
|
+
| A value JavaScript can hold | `JSON_SERDE` (store, history) | `JsonPlusSerializer` (checkpointer) |
|
|
1514
|
+
| --- | --- | --- |
|
|
1515
|
+
| `Map`, `Set` | stored as `{}`; every entry is gone | round-trips as a real `Map`/`Set` |
|
|
1516
|
+
| `Uint8Array` | an index-keyed object, `{"0":1,"1":2}` | round-trips as a `Uint8Array` |
|
|
1517
|
+
| `Date` | an ISO string | an ISO string — **neither** default round-trips one |
|
|
1518
|
+
| `NaN`, `Infinity` | `null` | `null` |
|
|
1519
|
+
| `-0` | `0` | `0` |
|
|
1520
|
+
| `undefined` as an object's value | the key is dropped | the key survives, still holding `undefined` |
|
|
1521
|
+
| `undefined` as an array element | `null` | `undefined` |
|
|
1522
|
+
| a function or symbol *inside* an object or array | dropped from the object, `null` in an array | dropped from the object, `null` in an array |
|
|
1523
|
+
| a `BigInt` anywhere in the value | refused at the write: `VALIDATION` naming `value` | the **whole payload** becomes the string `"[unable to serialize, circular reference is too complex to analyze]"` |
|
|
1524
|
+
| a circular reference | refused at the write: `VALIDATION` naming `value` | the object is kept, with `"[Circular]"` written at the cycle |
|
|
1525
|
+
| a value that *is* `undefined` | refused at the write | a 27-byte marker; reads back as `undefined` |
|
|
1526
|
+
| a value that *is* a function or a symbol | refused at the write | zero bytes, refused by this package before the write |
|
|
1527
|
+
|
|
1528
|
+
Two rows deserve reading twice. The `BigInt` row is the worst outcome either default produces: one `BigInt` in one nested field of a checkpoint reads back as that placeholder string **instead of your entire state**, not instead of the field that held it, under a message naming a cause it does not have. And the `Map`/`Set`/`Uint8Array` rows are why moving an adapter from one default to the other is not free in either direction: plain JSON loses them, and the checkpointer default restores them by instantiating the class a stored record names.
|
|
1529
|
+
|
|
1530
|
+
What no serializer may do is produce **no bytes at all**: zero bytes is not a document in any format, and every later read of such a row fails to parse it, so it is refused at the write with a `VALIDATION` error naming `value`, before anything is stored and before any object is uploaded. The refusal binds whichever `serde` is configured, so a `serde` of your own may not encode any value it accepts to zero bytes; an encoding that legitimately produces an empty buffer needs a byte of framing of its own.
|
|
1531
|
+
|
|
1532
|
+
How each adapter lays out keys (informational — you don't manage this):
|
|
1533
|
+
|
|
1534
|
+
- **Checkpointer** — `PK = CHKPT#<thread_id>`; `SK` = `META#<ns>#<checkpoint_id>` (metadata), `PAYLOAD#<ns>#<checkpoint_id>` (checkpoint), `WRITE#<ns>#<checkpoint_id>#<task>#<idx>#<channel>` (pending writes).
|
|
1535
|
+
- **Store** — `PK = STORE#<namespace[0]>` (the scope root); `SK = <namespace[1..]>#<key>`. This makes a scoped prefix search a native `Query` (`PK = root AND begins_with(SK, …)`); only a rootless "search everything" falls back to a `Scan`.
|
|
1536
|
+
- **Chat history** — `PK = HIST#<sessionId>`; one item per message at `SK = HISTORY#MSG#<ULID>` (ordered, append-only) plus one `SK = HISTORY#SESSION` metadata item.
|
|
1537
|
+
|
|
1538
|
+
**Why the key spaces cannot collide.** Each adapter tags its partition key with its own prefix, and those three tags differ in their very first character, so no `CHKPT#…` can ever equal a `STORE#…` or `HIST#…` — whatever identifiers you pass. That matters because reusing one id across adapters (a "conversation id" used as both a `thread_id` and a `sessionId`) is an entirely ordinary design: without the tags it put unrelated adapters' rows in one partition, where `deleteThread()`/`history.clear()` would delete each other's data and identically-composed sort keys could silently overwrite one another.
|
|
1539
|
+
|
|
1540
|
+
Two further guards back that up, for a table holding hand-written rows or rows written before an upgrade. First, `deleteThread()`/`clear()` delete only rows whose sort key belongs to the calling adapter and log anything they leave in place. Second, every read tests a row against the attributes its kind must carry before decoding it, rather than trusting the key it was found at: the checkpointer's `META#` rows, the store's items and the chat history's session and message rows are each bound to the key they were found at as well, and a checkpoint's payload and pending-write rows are refused by the descriptor guard, which is the attribute a narrow would have tested.
|
|
1541
|
+
|
|
1542
|
+
What a read does with a row that fails differs by read:
|
|
1543
|
+
|
|
1544
|
+
- the checkpointer's `getTuple` and `list` skip it and say so at `warn`, as does `store.reconcileVectorIndex`;
|
|
1545
|
+
- `store.get` answers `null` and warns;
|
|
1546
|
+
- `store.search` and `history.listSessions` drop it silently, because those two walk a whole prefix or table and one line per foreign row would fill a log rather than inform anyone; and
|
|
1547
|
+
- a chat-history message read **reports** it whatever `onCorruptMessage` is set to — a conversation that quietly skips a row it cannot account for is the one outcome worse than a failed read. `history.reconcileMessageCount` refuses the same row for the same reason: a repaired count that disagreed with the read would describe a session nobody can open.
|
|
1548
|
+
|
|
1549
|
+
Every one of those reads checks the row's own format version **before** its shape, so none of that applies to a row a newer release wrote: attribute names are this release's names rather than a later one's, and a row whose `v` is ahead of this reader is reported as `FORMAT_UNSUPPORTED` (field `v`) instead of being skipped as foreign because its attributes are no longer recognised.
|
|
1550
|
+
|
|
1551
|
+
An offloaded payload's `s3Key` is bound the same way: before it is downloaded or deleted it must lie under the adapter's `keyPrefix` *and* the S3 path the row's own identifiers produce (`enc(thread_id)/…`, `enc(namespace…)/enc(key)`, `enc(sessionId)/…`), and a store row's `namespace`/`key` must agree with the partition and sort key it was found at — so a row planted in one partition can never make the library read or delete another tenant's object. A read of such a row fails with a `VALIDATION` error (field `s3Key`) on all three adapters — chat history included, whatever `onCorruptMessage` is set to, because a key outside the row's own path is a configuration or tenancy fault to report rather than a payload to write off — and a delete skips the object with a warning.
|
|
1552
|
+
|
|
1553
|
+
## IAM permissions
|
|
1554
|
+
|
|
1555
|
+
Transactional writes are authorised by the item-level actions they carry — there is no `TransactWriteItems` action to grant — and the library never calls `BatchGetItem`. A least-privilege policy for one table:
|
|
1556
|
+
|
|
1557
|
+
```json
|
|
1558
|
+
{
|
|
1559
|
+
"Version": "2012-10-17",
|
|
1560
|
+
"Statement": [
|
|
1561
|
+
{
|
|
1562
|
+
"Sid": "LangGraphItems",
|
|
1563
|
+
"Effect": "Allow",
|
|
1564
|
+
"Action": [
|
|
1565
|
+
"dynamodb:GetItem",
|
|
1566
|
+
"dynamodb:PutItem",
|
|
1567
|
+
"dynamodb:UpdateItem",
|
|
1568
|
+
"dynamodb:DeleteItem",
|
|
1569
|
+
"dynamodb:Query",
|
|
1570
|
+
"dynamodb:BatchWriteItem"
|
|
1571
|
+
],
|
|
1572
|
+
"Resource": "arn:aws:dynamodb:<region>:<account>:table/langgraph"
|
|
1573
|
+
},
|
|
1574
|
+
{
|
|
432
1575
|
"Sid": "LangGraphTableScans",
|
|
433
1576
|
"Effect": "Allow",
|
|
434
1577
|
"Action": ["dynamodb:Scan"],
|
|
@@ -438,9 +1581,18 @@ Transactional writes are authorised by the item-level actions they carry — the
|
|
|
438
1581
|
}
|
|
439
1582
|
```
|
|
440
1583
|
|
|
441
|
-
|
|
1584
|
+
When the table carries the recency index and an adapter names it with `indexName`, the `Query` action also needs the index's own ARN — a permission on the table alone does not cover its indexes:
|
|
1585
|
+
|
|
1586
|
+
```json
|
|
1587
|
+
"Resource": [
|
|
1588
|
+
"arn:aws:dynamodb:<region>:<account>:table/langgraph",
|
|
1589
|
+
"arn:aws:dynamodb:<region>:<account>:table/langgraph/index/gsi1"
|
|
1590
|
+
]
|
|
1591
|
+
```
|
|
1592
|
+
|
|
1593
|
+
`LangGraphTableScans` is needed only by the table-wide reads — a rootless `store.search([])`, `store.listNamespaces()` without a concrete prefix root, `backfillRecencyIndex()`, and, on an adapter without `indexName`, `history.listSessions()` and `saver.list()` without a `thread_id` (with `indexName` those two `Query` the index instead). Every other operation is a `GetItem`, a `Query` or a write. Leave the statement out of any role that must not read across tenants (see below).
|
|
442
1594
|
|
|
443
|
-
When S3 offloading is enabled, the role also needs the object actions under the configured key prefix (`langgraph-checkpoints/` by default; adjust when `keyPrefix` is set) and, only for the deployment-time `ensureS3LifecycleRule()` call, the two lifecycle actions on the bucket itself:
|
|
1595
|
+
When S3 offloading is enabled, the role also needs the object actions under the configured key prefix (`langgraph-checkpoints/` by default; adjust when `keyPrefix` is set) and, only for the deployment-time `ensureS3LifecycleRule()` call, the two lifecycle actions plus the versioning read on the bucket itself:
|
|
444
1596
|
|
|
445
1597
|
```json
|
|
446
1598
|
{
|
|
@@ -452,18 +1604,24 @@ When S3 offloading is enabled, the role also needs the object actions under the
|
|
|
452
1604
|
{
|
|
453
1605
|
"Sid": "LangGraphS3Lifecycle",
|
|
454
1606
|
"Effect": "Allow",
|
|
455
|
-
"Action": [
|
|
1607
|
+
"Action": [
|
|
1608
|
+
"s3:GetLifecycleConfiguration",
|
|
1609
|
+
"s3:PutLifecycleConfiguration",
|
|
1610
|
+
"s3:GetBucketVersioning"
|
|
1611
|
+
],
|
|
456
1612
|
"Resource": "arn:aws:s3:::<bucket>"
|
|
457
1613
|
}
|
|
458
1614
|
```
|
|
459
1615
|
|
|
1616
|
+
`s3:GetBucketVersioning` is the one action there whose absence is **not** fatal: the call reports the bucket's versioning state and logs a `warn` it cannot read it, so a role provisioned before this action existed keeps working and simply learns nothing about its recovery window.
|
|
1617
|
+
|
|
460
1618
|
With `serverSideEncryption: 'aws:kms'` the role additionally needs `kms:GenerateDataKey` (uploads) and `kms:Decrypt` (downloads) on the key. Semantic search through Bedrock embeddings needs `bedrock:InvokeModel` on the model. A static test (`test/static/iam-actions.test.ts`) keeps the DynamoDB and S3 actions above equal to the calls the code makes.
|
|
461
1619
|
|
|
462
1620
|
### Multi-tenant deployments
|
|
463
1621
|
|
|
464
1622
|
Isolation is anchored on the identifiers you choose. The library composes keys safely and never lets one adapter's rows collide with another's, but it does nothing to scope a read to a tenant: put the tenant first in every `thread_id`, `sessionId` and store namespace (`namespace[0]`), with a delimiter other than the reserved `#` (`acme/thread-7`, `acme:session-1`, `['acme', 'users', 'u1']`). Every checkpointer and chat-history operation, and every store operation with a concrete namespace prefix, then touches only that tenant's partitions.
|
|
465
1623
|
|
|
466
|
-
Four operations
|
|
1624
|
+
Four operations read the whole table and return **every tenant's** rows by construction: `store.search([])`, `store.listNamespaces()` without a prefix root, `history.listSessions()` and `saver.list()` without a `thread_id` — the first two as table scans, the last two as table scans or, with `indexName`, as reads of the recency index. Treat them as administrative. `listSessions()` also returns each session's `title`, which is derived from the first human message — user content.
|
|
467
1625
|
|
|
468
1626
|
Tenancy can be enforced at the IAM layer with `dynamodb:LeadingKeys`, because every partition key starts with the adapter tag and then the identifier. A role for tenant `acme` grants the item actions with a key condition and omits `dynamodb:Scan` entirely (`LeadingKeys` does not apply to scans, so a role that may scan can read every tenant):
|
|
469
1627
|
|
|
@@ -488,166 +1646,341 @@ Tenancy can be enforced at the IAM layer with `dynamodb:LeadingKeys`, because ev
|
|
|
488
1646
|
}
|
|
489
1647
|
```
|
|
490
1648
|
|
|
491
|
-
For the store the tenant must be the whole first namespace element (`STORE#acme`), since the partition key is exactly `STORE#<namespace[0]>`; the checkpointer and history patterns match any identifier under the tenant prefix.
|
|
1649
|
+
For the store the tenant must be the whole first namespace element (`STORE#acme`), since the partition key is exactly `STORE#<namespace[0]>`; the checkpointer and history patterns match any identifier under the tenant prefix.
|
|
492
1650
|
|
|
493
|
-
|
|
1651
|
+
Offloaded S3 objects are harder to scope by tenant, because each identifier is base64url-encoded **whole** into the object key (`<keyPrefix><enc(part)>/…/<write id>.bin`):
|
|
494
1652
|
|
|
495
|
-
**
|
|
496
|
-
|
|
497
|
-
`STORE#<namespace[0]>`, `PK = <sessionId>` → `HIST#<sessionId>` — and the
|
|
498
|
-
checkpointer's pending-write sort key gains a trailing channel segment
|
|
499
|
-
(`WRITE#<ns>#<id>#<task>#<idx>` → `…#<idx>#<channel>`).
|
|
1653
|
+
- **Store.** The tenant is a whole namespace element, so its encoding is a whole key segment: `arn:aws:s3:::<bucket>/langgraph-checkpoints/store/<enc(tenant)>/*` (for `acme`, `…/store/YWNtZQ/*`) scopes exactly that tenant's objects.
|
|
1654
|
+
- **Checkpointer and chat history.** The tenant is only the start of a `thread_id` or `sessionId`, and base64url encodes three bytes at a time, so the encoding of a prefix is a prefix of the encoded id only when the prefix is a multiple of 3 bytes of UTF-8. `acme/` is 5 bytes and encodes to `YWNtZS8`, while `acme/thread-7` encodes to `YWNtZS90aHJlYWQtNw`: a condition on `YWNtZS8*` matches none of that tenant's objects. A 12-byte prefix such as `acme-tenant/` does work (`YWNtZS10ZW5hbnQv*`).
|
|
500
1655
|
|
|
501
|
-
|
|
502
|
-
identifier across adapters — a "conversation id" used as both a `thread_id`
|
|
503
|
-
and a `sessionId`, an entirely ordinary choice — put unrelated adapters' rows
|
|
504
|
-
in a single partition, where `deleteThread()` silently deleted the chat
|
|
505
|
-
history (and vice versa), and identically-composed sort keys let one adapter
|
|
506
|
-
overwrite another's item or hand its payload back on read. The channel segment
|
|
507
|
-
separately stops a retried task whose write mix changed from silently losing a
|
|
508
|
-
write; a per-call write group, also stored on each row, keeps such a retry from
|
|
509
|
-
replaying a channel twice. See the CHANGELOG for the full mechanism.
|
|
1656
|
+
The simpler control is an adapter per tenant with its own `s3.keyPrefix` (or its own bucket), and an S3 policy scoped to that prefix.
|
|
510
1657
|
|
|
511
|
-
|
|
512
|
-
adapters. Back up and recreate the table before upgrading.
|
|
1658
|
+
### Trust boundary
|
|
513
1659
|
|
|
514
|
-
|
|
515
|
-
constructing a `DynamoDBStore` with a `vectorBackend` but no `index` now
|
|
516
|
-
throws instead of silently returning unranked results; `getMessages` skips and
|
|
517
|
-
logs an undecodable message instead of failing the whole read (pass
|
|
518
|
-
`onCorruptMessage: 'throw'` to keep the old behaviour); and the store's
|
|
519
|
-
`$gt`/`$gte`/`$lt`/`$lte` filters no longer coerce across types, so a stored
|
|
520
|
-
`'10'` no longer matches `{ $gt: 5 }`.
|
|
1660
|
+
**Whoever can write a row chooses a code path in whichever process reads it.** A payload is bytes plus a serializer, and the checkpointer's default serializer — LangGraph's `JsonPlusSerializer` — does more than parse them. A stored record carrying an `lc` marker is a *constructor* record: `{"lc":1,"type":"constructor","id":["langchain_core","messages","HumanMessage"],"kwargs":{…}}` reads back as a real `HumanMessage`, built by calling that class with the stored arguments. The `Map`, `Set` and `Uint8Array` it restores (see [Table schema](#table-schema)), and a `RegExp` and an `Error` besides, come from a second record shape, `{"lc":2,…}`, rebuilt from a fixed list of those five names that never consults the allow-list. The class is chosen by the row, not by your code.
|
|
521
1661
|
|
|
522
|
-
|
|
523
|
-
tag — `SK = SESSION` → `SK = HISTORY#SESSION`, `SK = MSG#<ULID>` →
|
|
524
|
-
`SK = HISTORY#MSG#<ULID>`. This closes a real key collision on a table
|
|
525
|
-
shared via `createAll()`: an unprefixed `SESSION` sort key was reachable by
|
|
526
|
-
an ordinary store call (`store.put([sessionId], 'SESSION', …)`), silently
|
|
527
|
-
corrupting both adapters' items. Existing chat history data written before
|
|
528
|
-
this change will not be found by `getMessages`/`listSessions`/`clear` after
|
|
529
|
-
upgrading — back up and migrate (or recreate) any table with real
|
|
530
|
-
chat-history data before upgrading. Checkpointer and store keys are
|
|
531
|
-
unaffected.
|
|
1662
|
+
Four measured facts bound what that means:
|
|
532
1663
|
|
|
533
|
-
**
|
|
1664
|
+
- **The set of constructible classes is an allow-list, not the module graph** — for the one record shape that reaches it. A record that is `lc: 1`, `type: "constructor"` and carries an array `id` is resolved through LangChain's `load()`, and an `id` that allow-list does not contain — `["evil","Thing"]`, `["node","child_process","exec"]`, and equally `["langchain_core","messages","NoSuchMessage"]` — **fails the read** rather than resolving to anything: a `VALIDATION` error naming `serde`, with LangChain's own resolution failure as `cause`, on all three adapters and under every corruption policy.
|
|
1665
|
+
- So this is not a path to arbitrary code — no module outside the import maps `load()` consults can be named — but it is wider than a list of classes. The name is looked up across everything a resolved namespace exports and then invoked with `new`, so an ordinary exported *function* resolves exactly as a class does, and many of the reachable exports are ordinary functions.
|
|
1666
|
+
- `load()` then renames what it built, `Object.defineProperty(instance.constructor, "name", …)`, so a name whose function returns a plain object renames the **global `Object`** for the life of the process and every plain object in it reports `constructor.name` as whatever the row chose — the one effect of such a read that is not confined to the value returned.
|
|
1667
|
+
- **Every other record shape is returned without a word, and two of them are not data.** The second shape, `{"lc":2,"type":"constructor",…}`, is what restores a `Map`, a `Set`, a `RegExp`, an `Error` or a `Uint8Array`, from a fixed list of those five names that never consults the allow-list; an `id` outside it — `{"lc":2,"type":"constructor","id":["child_process"],"method":"exec","args":["…"]}` — reads back as the plain object it is, with nothing resolved, nothing invoked and **nothing raised**. The same holds for an `lc: 1` record whose `id` is not an array, one whose `type` is not `"constructor"`, and an `lc` value that is neither 1 nor 2.
|
|
1668
|
+
- Two further `lc: 2` shapes are not constructor records at all and hand back no data: `{"lc":2,"type":"undefined"}` reads back as `undefined`, removing the key from the object that held it, and `{"lc":2,"type":"delta_snapshot","value":…}` builds a LangGraph `DeltaSnapshot` around whatever the row put in `value`.
|
|
1669
|
+
- The *refusal* covers only the shape above, and inertness covers every shape but those two. Read the refusal as containment and not as detection: a planted row of any other shape is neutralised in silence, and the reader is handed a plain object, or nothing at all, where it expected a value.
|
|
1670
|
+
- **A stored `{"__proto__": {…}}` becomes the revived object's own prototype.** Under `JsonPlusSerializer`, reading those bytes yields an object where `o.isAdmin` is `true` while `Object.hasOwn(o, 'isAdmin')` is `false` — so a `hasOwnProperty` check says the field is absent and a plain read says it is there. It is confined to that object: the process-wide `Object.prototype` is **not** touched. Under `JSON_SERDE` the same bytes parse to an ordinary own key called `__proto__`, and the object's prototype is unchanged.
|
|
1671
|
+
- **Everything else on the read path is already bounded** and does not depend on this choice: an offloaded object must live under the row's own identifiers, downloads and decompression are capped, and a descriptor the reader does not understand is refused rather than guessed at.
|
|
534
1672
|
|
|
535
|
-
|
|
536
|
-
- **Store keys** are `PK = namespace[0]`, `SK = namespace[1..]#key` (was `PK = full namespace`, `SK = key`).
|
|
537
|
-
- **Chat history is one item per message** (`SK = MSG#<ULID>`) plus a `SESSION` metadata item, replacing the single per-session item.
|
|
538
|
-
- **Single `tableName` option** per adapter (was `checkpointsTableName`/`writesTableName`, etc.).
|
|
539
|
-
- **One `ttl` option** — `{ days }` or `{ seconds }` — replaces `ttlDays`/`ttlSeconds`.
|
|
540
|
-
- **S3 config option renamed** `s3OffloadConfig` → `s3`.
|
|
541
|
-
- **Per-instance `logger` option** replaces the global `setGlobalLogger` singleton; default logging is now silent.
|
|
542
|
-
- **Unified error model** — all errors extend `DynamoDBLangGraphError` with an `ErrorCode`.
|
|
1673
|
+
**The control is that table write access is trusted access.** Scope it the way you scope the data: the `dynamodb:LeadingKeys` policy above is what keeps one tenant from writing into another's partitions, and it is the same control, since a row planted in your partition is read by your process. A role that may write the table should be treated as a role that may invoke allow-listed `langchain_core` exports inside every reader of it.
|
|
543
1674
|
|
|
544
|
-
|
|
1675
|
+
**If that is more trust than you want to grant, pass `serde: JSON_SERDE`** — the plain-JSON serializer this package exports, and the one the store and history adapters already use. It runs `JSON.parse` and reconstructs nothing, so no `lc` record and no `__proto__` key changes what a read produces. Two costs, both real:
|
|
545
1676
|
|
|
546
|
-
-
|
|
547
|
-
- **
|
|
548
|
-
- **One S3 GET per offloaded payload per read.** Every offloaded row a read touches (`getTuple` pending writes, a plain `search()` applying its filter, `getMessages`) costs one S3 GET, and the returned bytes are decoded in memory. Reads decode up to 8 offloaded payloads at a time rather than one after another, but the request count is still linear in the offloaded row count — keep `thresholdBytes` high and compression on so that few payloads offload, and prefer a `vectorBackend` over the in-DB ranker for large semantic corpora.
|
|
549
|
-
- **Hot partitions.** The store's partition key is `STORE#<namespace[0]>` and chat history's is `HIST#<sessionId>` — the adapter tag is constant, so throughput still concentrates on the identifier you choose. A single partition tops out around ~1000 WCU / 3000 RCU, so avoid funneling very high write throughput through one tenant/session id; spread load across scope roots (e.g. include a tenant id as `namespace[0]`).
|
|
550
|
-
- **Identifier rules.** Every caller-supplied identifier (thread_id, checkpoint_ns, checkpoint_id, taskId, sessionId, store namespace elements and keys, pending-write channels) is validated before it reaches DynamoDB: it must be a non-blank string with no control characters and no reserved `#`, at most 1024 bytes of UTF-8 for the partition identifiers (`thread_id`, `sessionId`) and 256 bytes for every sort-key segment (an empty `checkpoint_ns` is legal, it is the root namespace). Composed keys are checked too: a store namespace + key, or a checkpointer pending-write key, may not exceed DynamoDB's 1024-byte sort-key cap, and an offloaded S3 object key may not exceed S3's 1024 bytes. A violation is a `ValidationError` whose `context.field` names the offending value, thrown before any request is sent.
|
|
551
|
-
- **Very large vector corpora** outgrow the in-DB ranker (`maxSearchCandidates`). Configure a `vectorBackend` (OpenSearch, pgvector, …) — the library keeps DynamoDB as the source of truth and only delegates similarity ranking.
|
|
552
|
-
- **TTL deletion timing** is governed by DynamoDB (typically within 48 h of expiry) and S3 lifecycle expiry is day-granular — the library writes the correct expiry timestamp (and filters expired chat messages on read) but does not guarantee instant deletion. The matching S3 lifecycle rule is not written automatically: it is installed only when you call `ensureS3LifecycleRule()`. That rule expires objects `ceil(ttl in days) + 2` days after creation — the two-day margin covers DynamoDB's sweep lag so an object never disappears before its row — and also expires noncurrent versions after the same number of days, so a versioned bucket does not retain every superseded payload forever (on such buckets the library's best-effort deletes only add delete markers). `ensureS3LifecycleRule()` is a read-modify-write of the bucket's whole lifecycle configuration: call it sequentially across adapters and deployers, never concurrently.
|
|
1677
|
+
- What it stores is the JSON projection recorded in [Table schema](#table-schema): no `Map`, no `Set`, no `Uint8Array`, and a `BigInt` or a cycle refused at the write instead of substituted.
|
|
1678
|
+
- **It applies to every row it reads, including rows the other serializer wrote**, and almost nothing on the row distinguishes them: both defaults record `serdeType: "json"` for every value but a raw `Uint8Array`, which only `JsonPlusSerializer` writes and which it stamps `"bytes"`. A `HumanMessage` or a `Map` written under `JsonPlusSerializer` reads back as its `lc` record, a plain object, not as the class; a payload that *is* a raw `Uint8Array` is refused outright, as the `serde` `VALIDATION`, because `JSON_SERDE` reads only the `json` form it writes. Choose it for a new deployment, or migrate by rewriting the rows; do not switch it under a live thread and expect the old rows to read as they did.
|
|
553
1679
|
|
|
554
1680
|
## Operations
|
|
555
1681
|
|
|
556
1682
|
### Limits
|
|
557
1683
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
|
561
|
-
|
|
|
562
|
-
|
|
|
563
|
-
|
|
|
564
|
-
| `
|
|
565
|
-
|
|
|
566
|
-
|
|
|
567
|
-
|
|
|
568
|
-
|
|
|
569
|
-
|
|
|
570
|
-
|
|
|
571
|
-
|
|
|
572
|
-
|
|
|
1684
|
+
*Value* is the limit in force: for an option, its default. *Ceiling* is the largest value that option accepts; a larger one is refused at construction with a `VALIDATION` error naming the option. *Fixed* marks a limit no option changes.
|
|
1685
|
+
|
|
1686
|
+
| Limit | Value | Ceiling | Where it bites |
|
|
1687
|
+
| --- | --- | --- | --- |
|
|
1688
|
+
| Page size (`limit` on `saver.list`, `store.search`, `store.listNamespaces`, `history.getMessages`, `history.listSessions`) | none — each method's own default | 10 000 | `VALIDATION` naming `limit`, **at the call** rather than at construction. `limit: 0` asks for an empty result and is answered without a read; a negative one is refused. The exception is `history.getMessages` and the `forSession` window, which refuse `0` too — an empty conversation window is what a chain reads as the whole session |
|
|
1689
|
+
| DynamoDB item size | 400 KB | fixed | a payload over `thresholdBytes` (default 350 KB, ceiling 392 KB) must offload to S3; without `s3` a serialized payload over 392 KB is refused with a `VALIDATION` error before the write |
|
|
1690
|
+
| Partition identifiers (`thread_id`, `sessionId`) | 1024 bytes UTF-8 | fixed | `VALIDATION` |
|
|
1691
|
+
| Sort-key segments (`checkpoint_ns`, `checkpoint_id`, `taskId`, channel, store namespace element, store `key`) | 256 bytes each, 1024 bytes composed | fixed | `VALIDATION` |
|
|
1692
|
+
| S3 object key | 1024 bytes | fixed | identifiers are base64url-encoded into it, so long ids reach it first |
|
|
1693
|
+
| `ttl` | none (no expiry) | 5 years | `VALIDATION` at construction |
|
|
1694
|
+
| Chat-history append transaction | 99 messages or 3.5 MB per chunk | fixed | larger batches are split into chunks with caller-observed atomicity |
|
|
1695
|
+
| Append-rollback delete batches | 25 rows per `BatchWriteItem`, `UnprocessedItems` re-driven up to 10 times | fixed | `BATCH_WRITE_INCOMPLETE`, counted in chunks. Rolling back a failed multi-chunk `history.addMessages` is the only path left that deletes in batches |
|
|
1696
|
+
| Partition delete | 25 rows buffered at a time, 8 requests in flight, one conditional `DeleteItem` per row | fixed | `BATCH_WRITE_INCOMPLETE`, counted in rows. `BatchWriteItem` cannot carry the condition each row is pinned with, so `deleteThread()`/`clear()` trade ~25× the requests for the pin |
|
|
1697
|
+
| Rows one store read holds in memory (`maxScanItems`) | 10 000 | 1 000 000 | `RESULT_TRUNCATED` |
|
|
1698
|
+
| Rows held in memory by `listSessions({ maxItems })` | 10 000 | none; `Infinity` asks for no cap | `RESULT_TRUNCATED` |
|
|
1699
|
+
| Pages walked by `listSessions({ maxIterations })` | 1000 | none; `Infinity` asks for no cap | `RESULT_TRUNCATED` |
|
|
1700
|
+
| In-DB semantic candidates (`maxSearchCandidates`) | 1000 | 100 000 | `VALIDATION` |
|
|
1701
|
+
| Decompressed payload (`compression.maxDecompressedBytes`) and buffered S3 object (`s3.maxDownloadBytes`) | 50 MiB each | 512 MiB each | `COMPRESSION_LIMIT` / `S3_OFFLOAD_FAILED` |
|
|
1702
|
+
| Smallest payload compressed (`compression.minSizeBytes`) | 1 KB | 512 MiB | a smaller payload is stored uncompressed; not an error |
|
|
1703
|
+
| Retries per DynamoDB call (`retry.maxAttempts`) | 5 (about 1.5 s of sleep, about 51.5 s of wall time); message appends 18 (about 61 s of sleep, about 4 minutes) | 100 | `RETRY_EXHAUSTED` |
|
|
1704
|
+
| Backoff delay (`retry.baseDelayMs`, `retry.maxDelayMs`) | 100 ms base, 5 s cap | 60 s each | latency, not an error |
|
|
1705
|
+
| Offloaded payloads decoded concurrently by one read, and recency-index shards queried at once by one listing (`readConcurrency`) | 8 | 128 | latency and memory, not an error |
|
|
1706
|
+
| Index shards per adapter (`indexShards`) | 8 | 1024 | an indexed listing issues at least one `Query` per shard, `readConcurrency` at a time; `backfillRecencyIndex` takes the same ceiling and must be given the same value |
|
|
573
1707
|
|
|
574
1708
|
### What each operation costs
|
|
575
1709
|
|
|
576
|
-
Requests per call, before retries
|
|
577
|
-
|
|
578
|
-
| Operation | DynamoDB | S3 |
|
|
579
|
-
| --- | --- | --- |
|
|
580
|
-
| `saver.getTuple` | 1 consistent `GetItem` (by id) or `Query` (newest) for the META row, 1 consistent `GetItem` for the payload, 1 consistent `Query` for the pending writes; a pre-v4 checkpoint adds a `Query` of its parent's writes | 1 `GET` per offloaded payload, 8 at a time |
|
|
581
|
-
| `saver.put` | 1 `TransactWriteItems` (META + PAYLOAD); 1 consistent `GetItem` of the parent META when `newVersions` leaves channels to carry over; verification reads only after a failure | 1 `PUT` per offloaded payload |
|
|
582
|
-
| `saver.putWrites` | 1 guarded `PutItem` per write, all in parallel; with `s3` each special write adds 1 consistent `GetItem` and up to 3 compare-and-swap attempts | 1 `PUT` per offloaded write, `DELETE` of a superseded special write |
|
|
583
|
-
| `saver.list` | 1 eventually consistent `Query` per page (or `Scan` without a `thread_id`); per yielded tuple 1 `GetItem` and 1 `Query` for its writes | `GET` per offloaded payload and metadata |
|
|
584
|
-
| `saver.deleteThread`, `history.clear` | 1 consistent `Query` per page, 1 `BatchWriteItem` per 25 rows | 1 `DeleteObjects` per 1000 keys |
|
|
585
|
-
| `store.get` | 1 consistent `GetItem` | 1 `GET` |
|
|
586
|
-
| `store.put` | 1 consistent `GetItem` (previous descriptor and revision), 1 guarded `PutItem` (up to 3 attempts under contention, each re-reading from the rejection), plus the `vectorBackend` upsert | 1 `PUT`, then `DELETE` of the superseded object |
|
|
587
|
-
| `store.delete` | 1 `DeleteItem` returning the old row, plus the `vectorBackend` delete | `DELETE` of the removed object |
|
|
588
|
-
| `store.search` | 1 eventually consistent `Query` per page (`Scan` for `[]`), reading rows in batches of 8 until the page is full; a `query` adds one embedding call | 1 `GET` per offloaded candidate |
|
|
589
|
-
| `store.listNamespaces` | key-only `Query` (`Scan` without a prefix root) per page | none |
|
|
590
|
-
| `history.addMessages` | 1 consistent `GetItem` of the session row when `ttl` is set, then 1 `TransactWriteItems` per chunk (up to 99 messages plus the session update); a rollback costs 1 `BatchWriteItem` per 25 rows plus a session update | 1 `PUT` per offloaded message |
|
|
591
|
-
| `history.getMessages` | 1 consistent `Query` per page (newest-first with a page cap under `limit`) | 1 `GET` per offloaded message, 8 at a time |
|
|
592
|
-
| `history.listSessions` | 1 `Scan` per page | none |
|
|
593
|
-
| `history.reconcileMessageCount` | `Query` (`Select: COUNT`) per page, 1 guarded `UpdateItem` | none |
|
|
594
|
-
| `store.reconcileVectorIndex` | 1 `Query` per page, embedding calls in batches, backend upserts and deletes | `GET` per offloaded item |
|
|
1710
|
+
Requests per call, before retries, for every public method; "consistent" reads cost twice an eventually consistent one, and S3 requests apply only to offloaded payloads. Full table, plus a worked request-unit example (one checkpoint put, one `getTuple`, one `addMessages` chunk, one `store.get`): [Guide → What each operation costs](docs/guide.md#what-each-operation-costs) and [Guide → Cost in request units: a worked example](docs/guide.md#cost-in-request-units-a-worked-example). What a request unit costs in your account is on [DynamoDB pricing](https://aws.amazon.com/dynamodb/pricing/) and [S3 pricing](https://aws.amazon.com/s3/pricing/) — this package has no opinion on either.
|
|
595
1711
|
|
|
596
1712
|
### Monitoring
|
|
597
1713
|
|
|
598
|
-
Alert on the two `error` events
|
|
1714
|
+
Alert on the two `error` events and the five `warn` events that name an orphan or an exhausted compare-and-swap ([Logging](#logging)), and count `RETRY_EXHAUSTED` and the AWS codes by `context.operation` and `context.httpStatusCode`. For AWS Support, the `requestId` of the last failure is on the error's **cause**, not on the error itself. Full detail — exactly which field carries the request id for which error shape, and which CloudWatch metrics to watch per partition key prefix: [Guide → Monitoring](docs/guide.md#monitoring).
|
|
1715
|
+
|
|
1716
|
+
### Production notes
|
|
1717
|
+
|
|
1718
|
+
- **Sharing one table** across all three adapters is supported — adapter-tagged partition keys make the key spaces provably disjoint (see [Table schema](#table-schema)), and table-wide reads filter to their own items. Checkpointer, chat-history, and *scoped* store reads are all partition-scoped (`Query`/`GetItem`).
|
|
1719
|
+
- **Scoped reads are `Query`s.** `store.search`/`store.listNamespaces` with a concrete namespace prefix and `history.getMessages` are native `Query`s. Only a rootless `store.search([])` / unprefixed `listNamespaces`, `history.listSessions` and a `saver.list()` called without a `thread_id` (which, like the reference savers, lists every thread in the table) fall back to `Scan` (cost scales with table size, and the result spans every tenant); with `indexName` the last two read the recency index instead, whose result still spans every tenant — keep those rare or use a dedicated table. `listSessions` accepts an optional `{ maxIterations }` override for tables where non-session rows dominate the scan.
|
|
1720
|
+
- **One S3 GET per offloaded payload per read.** Every offloaded row a read touches (`getTuple` pending writes, a plain `search()` applying its filter, `getMessages`) costs one S3 GET, and the returned bytes are decoded in memory. Reads decode up to 8 offloaded payloads at a time rather than one after another, but the request count is still linear in the offloaded row count — keep `thresholdBytes` high and compression on so that few payloads offload, and prefer a `vectorBackend` over the in-DB ranker for large semantic corpora.
|
|
1721
|
+
- **Hot partitions.** The store's partition key is `STORE#<namespace[0]>` and chat history's is `HIST#<sessionId>` — the adapter tag is constant, so throughput still concentrates on the identifier you choose. A single partition tops out around ~1000 WCU / 3000 RCU, so avoid funneling very high write throughput through one tenant/session id; spread load across scope roots (e.g. include a tenant id as `namespace[0]`).
|
|
1722
|
+
- **Identifier rules.** Every caller-supplied identifier (thread_id, checkpoint_ns, checkpoint_id, taskId, sessionId, store namespace elements and keys, pending-write channels) is validated before it reaches DynamoDB: it must be a non-blank, well-formed string (no unpaired surrogate) with no control characters and no reserved `#`, at most 1024 bytes of UTF-8 for the partition identifiers (`thread_id`, `sessionId`) and 256 bytes for every sort-key segment (an empty `checkpoint_ns` is legal, it is the root namespace). The same rules hold for every label of a `search` namespace prefix and of a `listNamespaces` prefix or suffix (where `'*'` still matches any label), and for `list()`'s `before` checkpoint id.
|
|
1723
|
+
- Upstream's two further namespace rules — no `.` in a label, and a root other than `"langgraph"` — are applied by `store.put()` alone, exactly as `BaseStore.put` applies them: `get`, `delete`, `search`, `listNamespaces` and every `batch()` operation, which is how LangGraph reaches a store inside a graph, accept both, so a namespace such as `['memories', 'jane.doe@example.com']` that a graph writes through `batch()` can be read, searched, listed and deleted.
|
|
1724
|
+
- Composed keys are checked too: a store namespace + key, or a checkpointer pending-write key, may not exceed DynamoDB's 1024-byte sort-key cap, and an offloaded S3 object key may not exceed S3's 1024 bytes. A violation is a `VALIDATION` error whose `context.field` names the offending value, thrown before any request is sent.
|
|
1725
|
+
- Identifiers are stored and compared **as given**: this package does not normalise them, and it does not refuse Unicode format characters. `U+200B`, the zero-width non-joiner and joiner `U+200C` / `U+200D`, `U+FEFF`, the right-to-left override `U+202E` and the separators `U+2028` / `U+2029` are all accepted, and two identifiers differing only in Unicode composition — `café` written with `U+00E9` against `e` + `U+0301` — address two different rows.
|
|
1726
|
+
- Both are deliberate: none of them can collide, since DynamoDB compares strings by their UTF-8 bytes, the well-formedness rule above already makes that mapping injective, and an identifier that reaches an S3 key is base64url-encoded on the way, so the character never appears in a key at all.
|
|
1727
|
+
- Refusing them would refuse ordinary text rather than hostile text — `U+200C` and `U+200D` carry meaning in Persian, Hindi and the Indic scripts, and `U+200D` is what joins the code points of a multi-person emoji. Normalising would be worse than refusing: it would fold two forms onto one key, so a row written before an upgrade would stop being found after it.
|
|
1728
|
+
- What such a character *can* do is make two distinct identifiers render alike in a log line, a terminal or a dashboard; terminal escapes and line breaks, which are an injection rather than a rendering, are refused by the control-character rule above. If you want a normal form or a narrower alphabet, apply it to your own identifiers before you pass them.
|
|
1729
|
+
- **Very large vector corpora** outgrow the in-DB ranker (`maxSearchCandidates`). Configure a `vectorBackend` (OpenSearch, pgvector, …) — the library keeps DynamoDB as the source of truth and only delegates similarity ranking.
|
|
1730
|
+
- **TTL deletion timing** is governed by DynamoDB, which deletes an expired row **within a few days of its expiry — it gives no fixed bound** ([DynamoDB TTL docs](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html)), and S3 lifecycle expiry is day-granular. The library writes the correct expiry timestamp (and filters expired chat messages on read) but does not guarantee instant deletion.
|
|
1731
|
+
- The matching S3 lifecycle rule is not written automatically: it is installed only when you call `ensureS3LifecycleRule()`. That rule expires objects `ceil(ttl in days) + 2` days after creation — the two-day margin covers DynamoDB's sweep lag so an object never disappears before its row.
|
|
1732
|
+
- It also expires **noncurrent** versions after the longer of one day and whatever `NoncurrentDays` already governs these keys, so a versioned bucket keeps a recovery window for a released payload without this library ever shortening a retention you chose (on such buckets the library's best-effort deletes only add delete markers).
|
|
1733
|
+
- A **second** rule reclaims those delete markers once the last noncurrent version under a key has expired; without it every payload release leaves a marker that never goes away. [Both shapes are given verbatim](#s3-lifecycle-rules), for a deployment that manages its own lifecycle.
|
|
1734
|
+
- `ensureS3LifecycleRule()` is a read-modify-write of the bucket's whole lifecycle configuration: call it sequentially across adapters and deployers, never concurrently.
|
|
1735
|
+
|
|
1736
|
+
### Maintenance operations
|
|
1737
|
+
|
|
1738
|
+
Four tools repair or provision state and are meant for deployment scripts and operators, not request paths:
|
|
1739
|
+
|
|
1740
|
+
- **`ensureS3LifecycleRule()`** (all three adapters) — installs the S3 lifecycle expiration rule that matches the configured `ttl` under the adapter's key prefix, idempotently. It **throws** when the bucket's lifecycle configuration cannot be read or written (`AccessDenied`, `NoSuchBucket`, throttling) — that part swallows nothing — so call it once at deployment time, from a role that holds the two lifecycle actions, and treat a failure as a deployment failure. One thing it does not raise: the bucket-versioning probe that runs **after** the rules are written is best-effort and reports at `warn` (see [Logging](#logging)), because a role that provisioned rules yesterday without `s3:GetBucketVersioning` must not start failing today. A bucket with no lifecycle configuration at all is not an error either; the rules are written onto an empty set. It is a no-op when `s3` or `ttl` is not configured.
|
|
1741
|
+
- **`store.reconcileVectorIndex(namespacePrefix)`** — re-pushes every live item's embedding to the configured `vectorBackend` and, when the backend implements `listKeys`, prunes vectors whose item is gone; returns `{ upserted, pruned }`. Run it when the namespace is idle; it reads every row under the prefix (bounded by `maxScanItems`).
|
|
1742
|
+
- **`backfillRecencyIndex({ tableName, client, … })`** — gives rows written before the recency index their `gsi1pk`/`gsi1sk`. **Run it before setting `indexName` on any adapter**: a row without the keys is not in the index, so enabling the index first makes every pre-existing session, item and checkpoint vanish from the listings that read it — the rows are still there, and every other read still returns them, but a listing would not.
|
|
1743
|
+
- Resumable by passing back the `nextCursor` it returns as `cursor`, re-runnable, and safe against a live table: every write is conditional on the row still being there **and** having no keys yet. That first half is not decoration: `UpdateItem` upserts, so a condition naming only the index attribute is satisfied by a key holding nothing at all, and a row deleted between the scan that found it and the update that backfilled it would otherwise come back as a stub of `PK`, `SK` and the two index keys — and therefore *inside* the index, where a thread-less `saver.list()` logged one `warn` for it on every listing thereafter and `history.listSessions()` dropped it silently, one row short of the `limit` its page had asked for.
|
|
1744
|
+
- `indexShards` must match what the adapters use. Every option is checked before the first read, with `VALIDATION` naming it: an unknown key, a `tableName` DynamoDB would refuse, a `client` without `scan` and `update`, an `indexShards` outside 1–1024, a `pageSize` or `maxPages` that is not an integer of at least 1, a `dryRun` that is not a boolean, a `retry` whose numbers break the adapters' bounds or whose hooks are not functions, a `signal` that is not an `AbortSignal`, and a `cursor` the tool did not issue.
|
|
1745
|
+
- `signal` cancels the run; so does `retry.signal` when no top-level `signal` is given, and when both are given the top-level one wins.
|
|
1746
|
+
- A refused write is not a failure and does not stop the run. Both halves of the condition refuse exactly the rows this run has nothing to do for — one that already carries keys a live adapter gave it, one that is no longer there — so the row is counted in the `skipped` of the `BackfillResult` and the walk carries on; on a table with adapters writing to it, which is the only kind a backfill is ever run against, the already-indexed refusal is the normal case rather than an edge one.
|
|
1747
|
+
- Any *other* AWS SDK error it does not retry reaches the caller with the code the classifier assigns and the SDK error as `cause`, and the run ends there with its result discarded — re-run it, and the scan's own filter skips whatever the stopped run had already indexed.
|
|
1748
|
+
- **`history.reconcileMessageCount(sessionId)`** — recounts a session's live messages and rewrites the stored `messageCount`; returns the count. Run it after a `COMPENSATION_FAILED` error or the `rollback failed` log event, when the session is idle; it throws `CONDITION_CONFLICT` if an append lands through every one of its three attempts, and for a session that does not exist rather than creating one. It also refuses, with a `VALIDATION` error naming `message`, a session whose message key space holds a row this adapter did not write — the same row `getMessages` refuses — because a count written back for a session no read can open repairs nothing. It reads each row's identity, format version and ttl only; no message payload is transferred.
|
|
1749
|
+
|
|
1750
|
+
### What can still go wrong
|
|
1751
|
+
|
|
1752
|
+
A row in DynamoDB and its payload in S3 are two writes with no transaction across them: a compare-and-swap and a request token prevent the losses they can, S3 versioning contains what slips through, and the sweep below finds the rest — no layer is total, and none of what follows is a known defect rather than a deliberate, backstopped limit. Full detail — every one of the fourteen specific shapes this can take, from a write that outlives the token window to a partition delete split from its pending writes: [Guide → What can still go wrong](docs/guide.md#what-can-still-go-wrong).
|
|
1753
|
+
|
|
1754
|
+
### Finding rows whose payload was released
|
|
1755
|
+
|
|
1756
|
+
On a versioned bucket a released payload becomes a noncurrent version behind a delete marker for the grace window the [lifecycle rules](#s3-lifecycle-rules) set, and `scripts/find-stranded-payloads.mjs` **in the repository** — deliberately not in the npm tarball — sweeps that window for a **stranded row**: one still live and still naming an object whose payload was released. It needs `s3:ListBucketVersions`/`s3:GetObjectVersion` on the bucket and `dynamodb:GetItem` on the table — permissions the operator running it holds, not the application role — and costs about one to two cents in AWS requests per sweep at realistic volumes. Full detail — what it reads, what it cannot find, and what to do with a finding: [Guide → Finding rows whose payload was released](docs/guide.md#finding-rows-whose-payload-was-released).
|
|
599
1757
|
|
|
600
1758
|
### Lambda and other short-lived runtimes
|
|
601
1759
|
|
|
602
|
-
Construct the adapters once at module scope (or one `DynamoDBFactory.createAll()`), reuse them across invocations, and pass a `client` you own if the function also uses DynamoDB elsewhere
|
|
1760
|
+
Construct the adapters once at module scope (or one `DynamoDBFactory.createAll()`), reuse them across invocations, and pass a `client` you own if the function also uses DynamoDB elsewhere. Size the function timeout against the retry budgets under [Retries and backoff](#retries-and-backoff): a heavily contended chat append can spend about four minutes across its attempts. Full detail: [Guide → Lambda and other short-lived runtimes](docs/guide.md#lambda-and-other-short-lived-runtimes).
|
|
603
1761
|
|
|
604
1762
|
### Multi-tenancy
|
|
605
1763
|
|
|
606
1764
|
See [Multi-tenant deployments](#multi-tenant-deployments) under IAM permissions for the identifier convention, the table-scan operations that are cross-tenant by construction, and the `dynamodb:LeadingKeys` policy.
|
|
607
1765
|
|
|
1766
|
+
## Versioning and compatibility
|
|
1767
|
+
|
|
1768
|
+
This package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). For a persistence adapter the storage layout is as much a contract as the TypeScript API, so both are stated here: what a `1.x` release promises to keep, what a minor may add, and what only a `2.0` may change.
|
|
1769
|
+
|
|
1770
|
+
### The public API
|
|
1771
|
+
|
|
1772
|
+
The public API is everything exported from the package entry point (`dist/index.js` / `dist/index.d.ts`): the five classes `DynamoDBSaver`, `DynamoDBStore`, `DynamoDBChatMessageHistory`, `DynamoDBSessionChatMessageHistory` and `DynamoDBFactory`; the error model (`DynamoDBLangGraphError`, `ErrorCode`, `isDynamoDBLangGraphError`); the operator tool `backfillRecencyIndex`; the logging helpers (`redactLogger`, `redactSecrets`); the `JSON_SERDE` serializer; and every exported type. A test (`test/types/public-surface.test.ts`) enumerates the set and pins the adapter method signatures.
|
|
1773
|
+
|
|
1774
|
+
- A **minor** may add exports, add optional options and parameters, add optional fields to returned objects, and widen accepted inputs.
|
|
1775
|
+
- A **patch** changes behaviour only to fix a defect against the documented behaviour.
|
|
1776
|
+
- Removing or renaming an export, making an option required, narrowing an input, or changing a return type requires a **major**, preceded by a deprecation.
|
|
1777
|
+
- Deep imports (`@farukada/aws-langgraph-dynamodb-ts/dist/...`) are blocked by the `exports` map and are not part of the API. The `createClient` / `createS3Client` seams are test hooks stripped from the shipped declarations and are not supported.
|
|
1778
|
+
|
|
1779
|
+
### The on-disk layout
|
|
1780
|
+
|
|
1781
|
+
Every `1.x` release reads every row a `1.0` release wrote; new attributes may be added in a minor, and the key formats, required attributes and payload descriptor change only in a major, with a migration note. [Table schema](#table-schema) shows the current attributes per adapter, and offloaded objects live at a stable `<keyPrefix><base64url(part)/...>/<write id>.bin` path carrying their row's key as S3 user metadata. Full detail — the full per-adapter attribute table, and what `gsi1pk`, `embeddings` and the retired `storedChannels` mean for a row written by an earlier release: [Guide → The on-disk layout](docs/guide.md#the-on-disk-layout).
|
|
1782
|
+
|
|
1783
|
+
### Errors, logs and row versions
|
|
1784
|
+
|
|
1785
|
+
Every row carries `v`, its format version; a row whose `v` is higher than the reader understands fails with `FORMAT_UNSUPPORTED` rather than being read as though its unknown attributes did not matter, and `ErrorCode` values are append-only in `1.x`. Text this library did not length-check is cut before it is logged or quoted in an error — 256 characters for an identifier-adjacent string, 1024 for a relayed cause's own text — but the structured `context` you branch on is never cut. Full detail — exactly which fields are capped, and the one field deliberately left alone: [Guide → Errors, logs and row versions](docs/guide.md#errors-logs-and-row-versions).
|
|
1786
|
+
|
|
1787
|
+
### Supported runtimes and peers
|
|
1788
|
+
|
|
1789
|
+
| Dependency | Supported | Verified by |
|
|
1790
|
+
| --- | --- | --- |
|
|
1791
|
+
| Node.js | 22, 24 and 26 | the unit tier on Linux, macOS and Windows |
|
|
1792
|
+
| TypeScript (consumers) | 5.x and later | the package smoke type-checks the shipped declarations with both the 5.x floor and the newest release |
|
|
1793
|
+
| `@langchain/langgraph-checkpoint` | `^1.1.5` | the conformance tier against the floor and the latest release, including LangChain's checkpointer validation suite |
|
|
1794
|
+
| `@langchain/langgraph` | any 1.x release depending on a supported `@langchain/langgraph-checkpoint` (not a peer of this package) | the compiled-graph conformance tests |
|
|
1795
|
+
| `@langchain/core` | `^1.2.11` | the differential and history tests |
|
|
1796
|
+
| AWS SDK v3 (`@aws-sdk/client-dynamodb`, `lib-dynamodb`, optional `client-s3`) | the ranges in `package.json` | every tier |
|
|
1797
|
+
|
|
1798
|
+
Raising a floor (dropping a Node major after its end of life, requiring a newer LangChain minor) is a **minor** and is announced in the CHANGELOG. A peer range is never narrowed in a patch.
|
|
1799
|
+
|
|
1800
|
+
### Deprecation
|
|
1801
|
+
|
|
1802
|
+
Anything scheduled for removal is marked `@deprecated` in its JSDoc and listed in the CHANGELOG for at least one minor before the major that removes it. Deprecated members keep working until then.
|
|
1803
|
+
|
|
1804
|
+
### Not covered
|
|
1805
|
+
|
|
1806
|
+
`saver.getDeltaChannelHistory()` tracks an upstream API that `@langchain/langgraph-checkpoint` marks beta: its signature and return shape follow that contract, so a change there can reach a minor of this package. The `ANCESTOR_EXPIRED` code it raises is covered like every other code.
|
|
1807
|
+
|
|
1808
|
+
Also not covered: the wording of error messages and log lines, the order of rows returned by table scans, the exact request counts in the [cost table](#what-each-operation-costs), the layout of `docs/api`, timing characteristics, and the internal module structure.
|
|
1809
|
+
|
|
1810
|
+
### Differences from the reference implementations
|
|
1811
|
+
|
|
1812
|
+
`MemorySaver` and `InMemoryStore` are the behaviour this package matches. Every observable difference is listed here; anything not in this table is a defect, not a choice, and the differential tests are what enforce that. From `1.0.0`, adding a row is a **minor** at most, and only when the reference itself is the defect or this package's storage and key rules require the difference; changing one a caller may already rely on is a **major**.
|
|
1813
|
+
|
|
1814
|
+
Full table (V-1 through V-30) and the note on V-7's withdrawal: [Guide → Differences from the reference implementations](docs/guide.md#differences-from-the-reference-implementations).
|
|
1815
|
+
|
|
608
1816
|
## Testing
|
|
609
1817
|
|
|
610
1818
|
```bash
|
|
611
|
-
npm test # unit + static-guard + type tests, 100% coverage
|
|
1819
|
+
npm test # unit + static-guard + property + type tests, 100% coverage
|
|
1820
|
+
npm run test:static # the static guards alone
|
|
612
1821
|
npm run typecheck
|
|
613
1822
|
npm run lint
|
|
1823
|
+
npm run build # removes dist/ first, so no output outlives its source
|
|
1824
|
+
npm run test:scripts # node --test suites for the maintenance scripts
|
|
1825
|
+
npm run test:package-smoke # pack, install and import the tarball (needs network)
|
|
1826
|
+
npm run test:consumer-types # type-check a consumer pinned to an older AWS SDK against the tarball (needs network)
|
|
1827
|
+
```
|
|
1828
|
+
|
|
1829
|
+
The surface tier runs the public API against a large table of malformed inputs and compares the result — one line per case — to a committed baseline, so any change to what the package accepts or rejects shows up as a reviewed diff. It runs against the built package, so build first:
|
|
1830
|
+
|
|
1831
|
+
```bash
|
|
614
1832
|
npm run build
|
|
1833
|
+
npm run test:surface # compare against test/surface/baseline.txt
|
|
1834
|
+
npm run test:surface:update # accept the current behaviour as the new baseline
|
|
615
1835
|
```
|
|
616
1836
|
|
|
1837
|
+
Only run `test:surface:update` after reading the diff `test:surface` printed. A line that changed for a reason you cannot name is a regression, not a baseline to refresh.
|
|
1838
|
+
|
|
617
1839
|
Integration and contract tiers run against DynamoDB Local (Docker) and are kept out of the default `npm test`:
|
|
618
1840
|
|
|
619
1841
|
```bash
|
|
620
1842
|
npm run test:integration:up # docker compose up -d (DynamoDB Local)
|
|
621
|
-
npm run test:integration # integration flows
|
|
1843
|
+
npm run test:integration # integration flows and the adapter contract suites
|
|
1844
|
+
npm run test:conformance # LangChain's checkpointer validation suite and a compiled LangGraph graph
|
|
622
1845
|
npm run test:integration:down
|
|
623
1846
|
```
|
|
624
1847
|
|
|
625
|
-
The real-AWS tier runs the same adapters against real DynamoDB, S3 and Bedrock. Every suite creates and tears down its own uniquely named table and bucket (`aws-langgraph-<suite>test-<uuid>`) in the account of the default credential chain
|
|
1848
|
+
The real-AWS tier runs the same adapters against real DynamoDB, S3 and Bedrock. Every suite creates and tears down its own uniquely named table and bucket (`aws-langgraph-<suite>test-<uuid>`) in the account of the default credential chain. It runs on every release tag, assuming the OIDC role named by the repository variable or secret `AWS_TEST_ROLE_ARN` in the region `AWS_TEST_REGION` names, and the release does not publish unless it passed; it runs on no schedule, so no job bills the account between releases. A maintainer can also run it locally.
|
|
1849
|
+
|
|
1850
|
+
```bash
|
|
1851
|
+
npm run test:aws # needs AWS credentials and AWS_REGION; refuses to run without a region
|
|
1852
|
+
```
|
|
1853
|
+
|
|
1854
|
+
The `examples/live-*.mjs` scripts are demos against real AWS, not a test tier. [`examples/README.md`](examples/README.md) says what each one does, which services it calls, which leave a table behind and how to delete it.
|
|
1855
|
+
|
|
1856
|
+
### Documentation checks
|
|
626
1857
|
|
|
627
1858
|
```bash
|
|
628
|
-
npm run
|
|
1859
|
+
npm run check:docs # type-check every TypeScript sample in README.md, CONTRIBUTING.md and CHANGELOG.md against src
|
|
1860
|
+
npm run check:links # resolve every relative link and #anchor across the hand-written documents
|
|
629
1861
|
```
|
|
630
1862
|
|
|
631
|
-
|
|
1863
|
+
`check:docs` compiles each `ts` and `typescript` block as an ES module with bundler resolution, so a documented call whose signature changed fails the build instead of the reader; a block that cannot compile carries a `<!-- sample:skip … -->` marker with its reason, and the number of skips is asserted. `check:links` is offline: it fetches no URL, and checks that every linked file exists and every `#anchor` matches a heading by GitHub's rule, which is what a renamed heading or a moved file breaks. CI runs both, and a separate job regenerates [`docs/api`](docs/api/README.md) with `npm run docs` and fails when the committed copy differs.
|
|
632
1864
|
|
|
633
1865
|
### What the suite does and does not prove
|
|
634
1866
|
|
|
635
1867
|
| Tier | Runs | Proves |
|
|
636
1868
|
| --- | --- | --- |
|
|
637
|
-
| Unit, static guards, type locks, property tests (`npm test`) | every push and PR, three OSes × Node 22 and
|
|
1869
|
+
| Unit, static guards, type locks, property tests (`npm test`) | every push and PR, three OSes × Node 22, 24 and 26 | every code path (100 % coverage), the repository rules (`/** */` only for interface documentation and `//` for every other comment, with no block comments and no lint or TypeScript directives, per decision record 23; no `any`/`unknown`/`instanceof`, no re-exports, no import cycles, no dead error codes, every public async method behind the error boundary, no planning references or raw control characters in committed code), the exact public export set and adapter signatures, the stated invariants (sort-key order, item-size estimate, write resolution, redaction, backoff) |
|
|
638
1870
|
| Integration (`npm run test:integration`, DynamoDB Local) | every push and PR | end-to-end adapter flows and fault injection; the write races the compare-and-swap exists for, with an in-memory S3 in the loop; the DynamoDB semantics the unit mocks assume; parity with `InMemoryStore` and `InMemoryChatMessageHistory` under `RunnableWithMessageHistory`; a 30-way single-session append storm |
|
|
639
1871
|
| Conformance (`npm run test:conformance`, DynamoDB Local) | every push and PR, against the declared floor and the latest `@langchain/langgraph-checkpoint` | a compiled LangGraph graph over the saver (interrupt/resume, subgraph namespaces, forks, history windows, crash-and-resume, `Send` fan-out) and LangChain's official checkpointer validation suite |
|
|
640
1872
|
| Package smoke (`npm run test:package-smoke`) | every push and PR | the packed tarball installs and imports without the optional S3 peer, and its declarations type-check without it |
|
|
641
|
-
| Real AWS (`npm run test:aws`) |
|
|
1873
|
+
| Real AWS (`npm run test:aws`) | every release tag, gating publish; on demand locally | S3 offload, lifecycle rules and the S3 error taxonomy against the real services; real 30-way append contention; Bedrock embeddings (skipped with a reason when the model is not enabled) |
|
|
642
1874
|
|
|
643
1875
|
Nothing in the suite provokes real throttling or `ProvisionedThroughputExceededException` (only its classification is tested), receives `UnprocessedItems` from a batch write (DynamoDB Local and on-demand tables never return them), observes DynamoDB's TTL sweep (only the stamped attribute is asserted), uses a versioned bucket, exercises a hot partition, or measures the write capacity the compare-and-swap fallback consumes. An injected `client` that keeps the SDK's own retries multiplies the library's attempt budget; the integration tier pins that count once and every adapter warns about it at construction.
|
|
644
1876
|
|
|
645
|
-
##
|
|
1877
|
+
## Project structure
|
|
1878
|
+
|
|
1879
|
+
Each module under `src/` opens with a header naming the one decision it hides; the comments below are those headers, shortened.
|
|
1880
|
+
|
|
1881
|
+
```text
|
|
1882
|
+
src/
|
|
1883
|
+
├── index.ts # The public surface: re-exports only, so no module's location is part of the API
|
|
1884
|
+
├── checkpointer/ # DynamoDBSaver
|
|
1885
|
+
│ ├── saver.ts # The saver behind LangGraph's BaseCheckpointSaver contract
|
|
1886
|
+
│ ├── types.ts # The option shapes a caller types against
|
|
1887
|
+
│ ├── actions/ # getTuple, list, put, putWrites and deleteThread, one module each
|
|
1888
|
+
│ └── internal/ # Input parsing, the row format, reads, listings, pending writes, delta history, setup
|
|
1889
|
+
├── store/ # DynamoDBStore
|
|
1890
|
+
│ ├── store.ts # Which public methods share one guarded dispatch
|
|
1891
|
+
│ ├── vector-backend.ts # The VectorBackend contract: which vector index the store talks to
|
|
1892
|
+
│ ├── types.ts # The store's option and result shapes
|
|
1893
|
+
│ ├── actions/ # put, search, listNamespaces and reconcileVectorIndex
|
|
1894
|
+
│ └── internal/ # Operation parsing, the row format, batch ordering, filters, table and semantic search
|
|
1895
|
+
├── history/ # DynamoDBChatMessageHistory
|
|
1896
|
+
│ ├── chat-message-history.ts # Chat history as a set of actions behind one error boundary
|
|
1897
|
+
│ ├── session-adapter.ts # DynamoDBSessionChatMessageHistory: one session behind LangChain's interface
|
|
1898
|
+
│ ├── types.ts # The types a caller names
|
|
1899
|
+
│ ├── actions/ # addMessages, getMessages, clear, listSessions, reconcileMessageCount
|
|
1900
|
+
│ └── internal/ # Input parsing, the key layout, the SESSION row, all-or-nothing appends, reads
|
|
1901
|
+
├── factory/ # DynamoDBFactory: several adapters on one client and one set of defaults
|
|
1902
|
+
├── backfill/ # backfillRecencyIndex: index keys for rows written before the index
|
|
1903
|
+
└── shared/ # What every adapter shares; reached by a caller only through index.ts
|
|
1904
|
+
├── adapter.ts # What an adapter owns for its lifetime, and how it lets go of it
|
|
1905
|
+
├── options.ts # The options every adapter shares, declared once
|
|
1906
|
+
├── clock.ts, ulid.ts # The current time; sortable unique ids
|
|
1907
|
+
├── concurrency.ts # How many calls run at once, and which failure a fan-out reports
|
|
1908
|
+
├── codec/ # A value to a stored payload and back: JSON form, gzip
|
|
1909
|
+
│ └── s3/ # S3 offload: the key layout, the lazily loaded client, lifecycle rules
|
|
1910
|
+
├── dynamodb/ # The client, retries, pagination, batch writes, idempotent writes,
|
|
1911
|
+
│ # partition deletes, the recency index and the table's row conventions
|
|
1912
|
+
├── errors/ # The one error class, the codes, AWS failure classification, the public boundary
|
|
1913
|
+
├── logging/ # The caller's logger as foreign code, redaction, secret patterns, truncation
|
|
1914
|
+
└── validation/ # The rules every option, primitive, collaborator and ttl must pass
|
|
1915
|
+
|
|
1916
|
+
test/
|
|
1917
|
+
├── unit/ # Mirrors src; 100 % coverage over mocked AWS clients
|
|
1918
|
+
├── static/ # The repository rules and the README-reading guards, as tests
|
|
1919
|
+
├── types/ # Compile-time locks on the public API
|
|
1920
|
+
├── property/ # fast-check invariants (sort keys, item size, redaction, backoff, …)
|
|
1921
|
+
├── integration/ # End-to-end flows, races and fault injection on DynamoDB Local
|
|
1922
|
+
├── contract/ # Adapter contracts against DynamoDB Local, run with the integration tier
|
|
1923
|
+
├── conformance/ # LangChain's checkpointer validation suite and a compiled LangGraph graph
|
|
1924
|
+
├── aws/ # The real-AWS tier (DynamoDB, S3, Bedrock); gates a release
|
|
1925
|
+
├── surface/ # Malformed-input fuzzing of the built package against a committed baseline
|
|
1926
|
+
├── package-smoke/ # Packs, installs and imports the tarball; type-checks it as a consumer would
|
|
1927
|
+
├── scripts/ # node --test suites for scripts/
|
|
1928
|
+
└── shared/ # Helpers and fixtures the tiers share
|
|
1929
|
+
|
|
1930
|
+
scripts/
|
|
1931
|
+
├── check-doc-samples.mjs # Type-checks every TypeScript sample in the documents a reader copies from
|
|
1932
|
+
├── check-doc-links.mjs # Resolves every relative link and #anchor in the hand-written documents
|
|
1933
|
+
├── find-stranded-payloads.mjs # Reports rows whose offloaded payload was released (not in the tarball)
|
|
1934
|
+
├── pack-check.mjs # The tarball holds exactly dist, the licence, the README and the manifest
|
|
1935
|
+
├── peer-floors.mjs # The lowest version each peer range admits, for the peer-floor CI job
|
|
1936
|
+
├── require-green-ci.mjs # The release gate: every required check present and successful
|
|
1937
|
+
├── required-checks.json # The check names that gate reads
|
|
1938
|
+
├── changelog-section.mjs # One release's CHANGELOG section, for the GitHub Release body
|
|
1939
|
+
├── generate-sbom.mjs # The runtime and build SBOMs a release attaches
|
|
1940
|
+
├── run-with-timeout.mjs # Runs a command and kills its process tree past a timeout
|
|
1941
|
+
├── update-surface-baseline.mjs # Regenerates test/surface/baseline.txt
|
|
1942
|
+
├── clean.mjs # Removes dist/ before a build
|
|
1943
|
+
└── is-main.mjs # Whether a script is the program being run, whatever path reached it
|
|
1944
|
+
|
|
1945
|
+
examples/ # live-*.mjs demos against real AWS; see examples/README.md
|
|
1946
|
+
|
|
1947
|
+
docs/
|
|
1948
|
+
├── api/ # The generated API reference (npm run docs), checked for drift in CI
|
|
1949
|
+
├── decisions/ # Architecture decision records
|
|
1950
|
+
├── evidence/ # Live-AWS probes of behaviour AWS does not document
|
|
1951
|
+
├── guide.md # In-depth guide the README's summaries link out to
|
|
1952
|
+
├── coding-guidelines.md # The standard the source is held to
|
|
1953
|
+
└── README.md # The documentation index
|
|
1954
|
+
|
|
1955
|
+
.github/workflows/
|
|
1956
|
+
├── ci.yml # Every push and PR to main: three OSes × Node 22/24/26, integration, conformance,
|
|
1957
|
+
│ # peer floors, docs drift, package smoke, hygiene, npm audit
|
|
1958
|
+
├── codeql.yml # Static analysis of the source and the workflows; push, PR and weekly
|
|
1959
|
+
├── dependency-review.yml # Fails a PR that adds a dependency with a high or critical advisory
|
|
1960
|
+
├── scorecard.yml # OpenSSF Scorecard; push to main, branch-protection changes and weekly
|
|
1961
|
+
├── integration-live.yml # The real-AWS tier, on every v* tag and never on a schedule
|
|
1962
|
+
└── release.yml # Tag-triggered publish with npm provenance and SBOMs, gated on green CI
|
|
1963
|
+
```
|
|
1964
|
+
|
|
1965
|
+
## Design decisions and evidence
|
|
1966
|
+
|
|
1967
|
+
Two directories worth reading before depending on this, and two guides worth reading before touching the source or going deeper than this README does. [The documentation index](docs/README.md) links all four, the API reference and the examples.
|
|
1968
|
+
|
|
1969
|
+
**[`docs/decisions/`](docs/decisions/README.md) — the choices that are expensive to reverse.** Twenty-four architecture decision records, each stating the context, the decision and the consequences including the negative ones: why the DynamoDB SDK ships as a dependency while LangChain and S3 are peers, why every adapter shares one table under a structured key, why a large payload offloads to S3 behind a descriptor instead of being written inline, why `MemorySaver` and `InMemoryStore` are treated as the behavioural oracle, why file length and function complexity are not capped, why the live-AWS tier gates a release rather than running on a schedule, why every failure is one error class classified in one place, and why caller input is parsed once at the boundary into types only a parser can build. If a constraint you have hit looks arbitrary, this is where the answer is.
|
|
1970
|
+
|
|
1971
|
+
**[`docs/evidence/`](docs/evidence/README.md) — what DynamoDB and S3 actually do, where AWS does not say.** Seventeen claims across nine files, established by probing the live services: how the idempotency cache treats a cancelled transaction's replay, that `BatchWriteItem` accepts a condition on a `DeleteRequest` and silently ignores it, what a conditional delete against an already-gone row reports, how a versioned bucket's delete markers and lifecycle rules behave. Each claim is paired with a named live test that fails if the service's answer ever changes, and the file records the date, Region and SDK version each probe ran under — a claim is only as fresh as the last run that checked it.
|
|
1972
|
+
|
|
1973
|
+
**[`docs/guide.md`](docs/guide.md) — the in-depth guide.** Longer-form than this README on the mechanism behind a promise summarised above: S3 offload's compare-and-swap and request-token machinery, what a partition delete promises and costs, search and vector-index consistency, checkpointer and chat-history semantics, the request-unit cost of every call with a worked example, what can still go wrong between a row and its payload, and the on-disk layout and error/version guarantees behind [Versioning and compatibility](#versioning-and-compatibility). Its samples are compiled against `src` on every CI run, like this document's.
|
|
1974
|
+
|
|
1975
|
+
**[`docs/coding-guidelines.md`](docs/coding-guidelines.md)** is the standard the source is held to, if you are contributing or auditing.
|
|
1976
|
+
|
|
1977
|
+
## Contributing
|
|
1978
|
+
|
|
1979
|
+
Contributions are welcome; please open an issue to discuss a non-trivial change before submitting a pull request. [CONTRIBUTING.md](CONTRIBUTING.md) covers the setup, the rules the guards enforce, the test tiers, the toolchain, commits and releases, and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) sets the expectations for the project's spaces. [SUPPORT.md](SUPPORT.md) says where to ask and what to include.
|
|
1980
|
+
|
|
1981
|
+
Found a security issue? Report it privately as [SECURITY.md](SECURITY.md) describes, never in a public issue; it also sets the response targets and says what the library does and does not do.
|
|
646
1982
|
|
|
647
|
-
|
|
648
|
-
- [Security policy](SECURITY.md) — private reporting, response targets, what the library does and does not do.
|
|
649
|
-
- [Support](SUPPORT.md) — where to ask, what to include.
|
|
650
|
-
- [Contributing](CONTRIBUTING.md) — setup, the guards, the test tiers, the toolchain, commits and releases.
|
|
1983
|
+
[Versioning and compatibility](#versioning-and-compatibility) says what `1.x` promises for the API, the on-disk layout, error codes and peer ranges.
|
|
651
1984
|
|
|
652
1985
|
## License
|
|
653
1986
|
|