@farukada/aws-langgraph-dynamodb-ts 1.0.0-rc.2 → 1.0.0
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 +390 -193
- package/dist/{backfill → cjs/backfill}/backfill.d.ts +15 -12
- package/dist/cjs/backfill/backfill.js +177 -0
- package/dist/{checkpointer → cjs/checkpointer}/actions/delete-thread.d.ts +1 -1
- package/dist/cjs/checkpointer/actions/delete-thread.js +26 -0
- package/dist/{checkpointer → cjs/checkpointer}/actions/get-tuple.d.ts +4 -3
- package/dist/cjs/checkpointer/actions/get-tuple.js +22 -0
- package/dist/{checkpointer → cjs/checkpointer}/actions/list.d.ts +7 -4
- package/dist/cjs/checkpointer/actions/list.js +70 -0
- package/dist/{checkpointer → cjs/checkpointer}/actions/put-writes.d.ts +8 -6
- package/dist/cjs/checkpointer/actions/put-writes.js +21 -0
- package/dist/{checkpointer → cjs/checkpointer}/actions/put.d.ts +21 -15
- package/dist/cjs/checkpointer/actions/put.js +79 -0
- package/dist/{checkpointer → cjs/checkpointer}/internal/delta-history.d.ts +38 -26
- package/dist/cjs/checkpointer/internal/delta-history.js +131 -0
- package/dist/{checkpointer → cjs/checkpointer}/internal/listing.d.ts +4 -4
- package/dist/cjs/checkpointer/internal/listing.js +104 -0
- package/dist/{checkpointer → cjs/checkpointer}/internal/parse.d.ts +51 -15
- package/dist/cjs/checkpointer/internal/parse.js +184 -0
- package/dist/{checkpointer → cjs/checkpointer}/internal/pending-writes.d.ts +64 -25
- package/dist/cjs/checkpointer/internal/pending-writes.js +241 -0
- package/dist/{checkpointer → cjs/checkpointer}/internal/read.d.ts +15 -10
- package/dist/cjs/checkpointer/internal/read.js +132 -0
- package/dist/{checkpointer → cjs/checkpointer}/internal/rows.d.ts +94 -21
- package/dist/cjs/checkpointer/internal/rows.js +356 -0
- package/dist/{checkpointer → cjs/checkpointer}/internal/setup.d.ts +13 -20
- package/dist/cjs/checkpointer/internal/setup.js +25 -0
- package/dist/{checkpointer → cjs/checkpointer}/saver.d.ts +72 -36
- package/dist/cjs/checkpointer/saver.js +56 -0
- package/dist/{checkpointer → cjs/checkpointer}/types.d.ts +1 -1
- package/dist/cjs/checkpointer/types.js +2 -0
- package/dist/{factory → cjs/factory}/factory.d.ts +25 -20
- package/dist/cjs/factory/factory.js +137 -0
- package/dist/{factory → cjs/factory}/types.d.ts +12 -12
- package/dist/cjs/factory/types.js +2 -0
- package/dist/{history → cjs/history}/actions/add-messages.d.ts +18 -10
- package/dist/cjs/history/actions/add-messages.js +17 -0
- package/dist/{history → cjs/history}/actions/clear.d.ts +1 -1
- package/dist/cjs/history/actions/clear.js +29 -0
- package/dist/{history → cjs/history}/actions/get-messages.d.ts +11 -9
- package/dist/cjs/history/actions/get-messages.js +58 -0
- package/dist/{history → cjs/history}/actions/list-sessions.d.ts +3 -3
- package/dist/cjs/history/actions/list-sessions.js +67 -0
- package/dist/{history → cjs/history}/actions/reconcile-count.d.ts +13 -9
- package/dist/cjs/history/actions/reconcile-count.js +8 -0
- package/dist/{history → cjs/history}/chat-message-history.d.ts +62 -30
- package/dist/cjs/history/chat-message-history.js +61 -0
- package/dist/{history → cjs/history}/internal/append.d.ts +64 -32
- package/dist/cjs/history/internal/append.js +267 -0
- package/dist/{history → cjs/history}/internal/message-read.d.ts +3 -3
- package/dist/cjs/history/internal/message-read.js +93 -0
- package/dist/{history → cjs/history}/internal/parse.d.ts +2 -2
- package/dist/cjs/history/internal/parse.js +113 -0
- package/dist/{history → cjs/history}/internal/rows.d.ts +11 -10
- package/dist/cjs/history/internal/rows.js +96 -0
- package/dist/{history → cjs/history}/internal/session.d.ts +41 -22
- package/dist/cjs/history/internal/session.js +296 -0
- package/dist/{history → cjs/history}/internal/setup.d.ts +2 -2
- package/dist/cjs/history/internal/setup.js +60 -0
- package/dist/{history → cjs/history}/session-adapter.d.ts +0 -6
- package/dist/cjs/history/session-adapter.js +38 -0
- package/dist/{history → cjs/history}/types.d.ts +18 -15
- package/dist/cjs/history/types.js +2 -0
- package/dist/{index.d.ts → cjs/index.d.ts} +27 -27
- package/dist/cjs/index.js +25 -0
- package/dist/cjs/package.json +1 -0
- package/dist/{shared → cjs/shared}/adapter.d.ts +27 -16
- package/dist/cjs/shared/adapter.js +70 -0
- package/dist/cjs/shared/clock.js +14 -0
- package/dist/{shared → cjs/shared}/codec/codec.d.ts +37 -20
- package/dist/cjs/shared/codec/codec.js +162 -0
- package/dist/{shared → cjs/shared}/codec/compression.d.ts +9 -4
- package/dist/cjs/shared/codec/compression.js +45 -0
- package/dist/cjs/shared/codec/json-serde.js +58 -0
- package/dist/{shared → cjs/shared}/codec/s3/client-types.d.ts +2 -1
- package/dist/cjs/shared/codec/s3/client-types.js +6 -0
- package/dist/{shared → cjs/shared}/codec/s3/client.d.ts +25 -7
- package/dist/cjs/shared/codec/s3/client.js +78 -0
- package/dist/{shared → cjs/shared}/codec/s3/config.d.ts +12 -7
- package/dist/cjs/shared/codec/s3/config.js +92 -0
- package/dist/{shared → cjs/shared}/codec/s3/lifecycle.d.ts +71 -17
- package/dist/cjs/shared/codec/s3/lifecycle.js +223 -0
- package/dist/{shared → cjs/shared}/codec/s3/offloader.d.ts +42 -29
- package/dist/cjs/shared/codec/s3/offloader.js +289 -0
- package/dist/cjs/shared/concurrency.js +31 -0
- package/dist/{shared → cjs/shared}/dynamodb/abort.d.ts +2 -2
- package/dist/cjs/shared/dynamodb/abort.js +16 -0
- package/dist/{shared → cjs/shared}/dynamodb/batch-write.d.ts +2 -2
- package/dist/cjs/shared/dynamodb/batch-write.js +79 -0
- package/dist/cjs/shared/dynamodb/cancellation.js +37 -0
- package/dist/{shared → cjs/shared}/dynamodb/client.d.ts +24 -22
- package/dist/cjs/shared/dynamodb/client.js +40 -0
- package/dist/{shared → cjs/shared}/dynamodb/idempotent-write.d.ts +64 -19
- package/dist/cjs/shared/dynamodb/idempotent-write.js +173 -0
- package/dist/{shared → cjs/shared}/dynamodb/paginate.d.ts +2 -2
- package/dist/cjs/shared/dynamodb/paginate.js +90 -0
- package/dist/{shared → cjs/shared}/dynamodb/partition-delete.d.ts +6 -6
- package/dist/cjs/shared/dynamodb/partition-delete.js +177 -0
- package/dist/{shared → cjs/shared}/dynamodb/recency-index.d.ts +27 -15
- package/dist/cjs/shared/dynamodb/recency-index.js +146 -0
- package/dist/{shared → cjs/shared}/dynamodb/retry.d.ts +37 -6
- package/dist/cjs/shared/dynamodb/retry.js +193 -0
- package/dist/cjs/shared/dynamodb/row-size.d.ts +33 -0
- package/dist/cjs/shared/dynamodb/row-size.js +30 -0
- package/dist/{shared → cjs/shared}/dynamodb/table-schema.d.ts +27 -18
- package/dist/cjs/shared/dynamodb/table-schema.js +52 -0
- package/dist/{shared → cjs/shared}/errors/base-error.d.ts +42 -7
- package/dist/cjs/shared/errors/base-error.js +73 -0
- package/dist/{shared → cjs/shared}/errors/boundary.d.ts +29 -14
- package/dist/cjs/shared/errors/boundary.js +61 -0
- package/dist/cjs/shared/errors/classify.d.ts +206 -0
- package/dist/cjs/shared/errors/classify.js +232 -0
- package/dist/{shared → cjs/shared}/errors/error-code.d.ts +17 -10
- package/dist/cjs/shared/errors/error-code.js +27 -0
- package/dist/{shared → cjs/shared}/errors/errors.d.ts +75 -9
- package/dist/cjs/shared/errors/errors.js +123 -0
- package/dist/cjs/shared/logging/logger.js +30 -0
- package/dist/{shared → cjs/shared}/logging/redaction.d.ts +8 -4
- package/dist/cjs/shared/logging/redaction.js +145 -0
- package/dist/{shared → cjs/shared}/logging/secret-patterns.d.ts +30 -7
- package/dist/cjs/shared/logging/secret-patterns.js +93 -0
- package/dist/cjs/shared/logging/truncate.js +31 -0
- package/dist/{shared → cjs/shared}/options.d.ts +27 -11
- package/dist/cjs/shared/options.js +2 -0
- package/dist/cjs/shared/ulid.js +83 -0
- package/dist/{shared → cjs/shared}/validation/collaborators.d.ts +21 -2
- package/dist/cjs/shared/validation/collaborators.js +79 -0
- package/dist/{shared → cjs/shared}/validation/option-shape.d.ts +7 -5
- package/dist/cjs/shared/validation/option-shape.js +31 -0
- package/dist/{shared → cjs/shared}/validation/options.d.ts +4 -4
- package/dist/cjs/shared/validation/options.js +166 -0
- package/dist/cjs/shared/validation/primitives.js +114 -0
- package/dist/{shared → cjs/shared}/validation/ttl.d.ts +26 -9
- package/dist/cjs/shared/validation/ttl.js +52 -0
- package/dist/{store → cjs/store}/actions/list-namespaces.d.ts +5 -5
- package/dist/cjs/store/actions/list-namespaces.js +92 -0
- package/dist/{store → cjs/store}/actions/put.d.ts +11 -4
- package/dist/cjs/store/actions/put.js +49 -0
- package/dist/{store → cjs/store}/actions/reconcile-vector-index.d.ts +5 -5
- package/dist/cjs/store/actions/reconcile-vector-index.js +13 -0
- package/dist/{store → cjs/store}/actions/search.d.ts +2 -2
- package/dist/cjs/store/actions/search.js +26 -0
- package/dist/cjs/store/internal/batch-plan.d.ts +56 -0
- package/dist/cjs/store/internal/batch-plan.js +32 -0
- package/dist/{store → cjs/store}/internal/filter.d.ts +3 -3
- package/dist/cjs/store/internal/filter.js +64 -0
- package/dist/cjs/store/internal/get-item.d.ts +77 -0
- package/dist/cjs/store/internal/get-item.js +58 -0
- package/dist/{store → cjs/store}/internal/item-write.d.ts +72 -32
- package/dist/cjs/store/internal/item-write.js +181 -0
- package/dist/{store → cjs/store}/internal/parse.d.ts +15 -10
- package/dist/cjs/store/internal/parse.js +180 -0
- package/dist/{store → cjs/store}/internal/rows.d.ts +38 -34
- package/dist/cjs/store/internal/rows.js +166 -0
- package/dist/{store → cjs/store}/internal/semantic-search.d.ts +2 -2
- package/dist/cjs/store/internal/semantic-search.js +211 -0
- package/dist/{store → cjs/store}/internal/setup.d.ts +10 -15
- package/dist/cjs/store/internal/setup.js +103 -0
- package/dist/{store → cjs/store}/internal/table-search.d.ts +5 -5
- package/dist/cjs/store/internal/table-search.js +123 -0
- package/dist/{store → cjs/store}/internal/vector-index.d.ts +26 -15
- package/dist/cjs/store/internal/vector-index.js +269 -0
- package/dist/{store → cjs/store}/store.d.ts +82 -39
- package/dist/cjs/store/store.js +97 -0
- package/dist/{store → cjs/store}/types.d.ts +25 -10
- package/dist/cjs/store/types.js +2 -0
- package/dist/cjs/store/vector-backend.js +4 -0
- package/dist/esm/backfill/backfill.d.ts +171 -0
- package/dist/esm/backfill/backfill.js +170 -0
- package/dist/{checkpointer/actions/delete-thread.js → esm/checkpointer/actions/delete-thread.d.ts} +4 -26
- package/dist/esm/checkpointer/actions/delete-thread.js +23 -0
- package/dist/{checkpointer/actions/get-tuple.js → esm/checkpointer/actions/get-tuple.d.ts} +7 -24
- package/dist/esm/checkpointer/actions/get-tuple.js +19 -0
- package/dist/esm/checkpointer/actions/list.d.ts +55 -0
- package/dist/esm/checkpointer/actions/list.js +67 -0
- package/dist/{checkpointer/actions/put-writes.js → esm/checkpointer/actions/put-writes.d.ts} +10 -37
- package/dist/esm/checkpointer/actions/put-writes.js +18 -0
- package/dist/esm/checkpointer/actions/put.d.ts +95 -0
- package/dist/esm/checkpointer/actions/put.js +75 -0
- package/dist/esm/checkpointer/internal/delta-history.d.ts +124 -0
- package/dist/esm/checkpointer/internal/delta-history.js +126 -0
- package/dist/{checkpointer/internal/listing.js → esm/checkpointer/internal/listing.d.ts} +22 -118
- package/dist/esm/checkpointer/internal/listing.js +95 -0
- package/dist/esm/checkpointer/internal/parse.d.ts +298 -0
- package/dist/esm/checkpointer/internal/parse.js +169 -0
- package/dist/esm/checkpointer/internal/pending-writes.d.ts +314 -0
- package/dist/esm/checkpointer/internal/pending-writes.js +229 -0
- package/dist/esm/checkpointer/internal/read.d.ts +135 -0
- package/dist/esm/checkpointer/internal/read.js +125 -0
- package/dist/esm/checkpointer/internal/rows.d.ts +644 -0
- package/dist/esm/checkpointer/internal/rows.js +323 -0
- package/dist/esm/checkpointer/internal/setup.d.ts +47 -0
- package/dist/esm/checkpointer/internal/setup.js +21 -0
- package/dist/{checkpointer/saver.js → esm/checkpointer/saver.d.ts} +84 -87
- package/dist/esm/checkpointer/saver.js +52 -0
- package/dist/esm/checkpointer/types.d.ts +51 -0
- package/dist/esm/checkpointer/types.js +1 -0
- package/dist/esm/factory/factory.d.ts +154 -0
- package/dist/esm/factory/factory.js +133 -0
- package/dist/esm/factory/types.d.ts +76 -0
- package/dist/esm/factory/types.js +1 -0
- package/dist/{history/actions/add-messages.js → esm/history/actions/add-messages.d.ts} +20 -26
- package/dist/esm/history/actions/add-messages.js +14 -0
- package/dist/{history/actions/clear.js → esm/history/actions/clear.d.ts} +4 -35
- package/dist/esm/history/actions/clear.js +26 -0
- package/dist/esm/history/actions/get-messages.d.ts +60 -0
- package/dist/esm/history/actions/get-messages.js +55 -0
- package/dist/esm/history/actions/list-sessions.d.ts +55 -0
- package/dist/esm/history/actions/list-sessions.js +64 -0
- package/dist/{history/actions/reconcile-count.js → esm/history/actions/reconcile-count.d.ts} +14 -16
- package/dist/esm/history/actions/reconcile-count.js +5 -0
- package/dist/{history/chat-message-history.js → esm/history/chat-message-history.d.ts} +76 -87
- package/dist/esm/history/chat-message-history.js +57 -0
- package/dist/esm/history/internal/append.d.ts +244 -0
- package/dist/esm/history/internal/append.js +259 -0
- package/dist/esm/history/internal/message-read.d.ts +84 -0
- package/dist/esm/history/internal/message-read.js +89 -0
- package/dist/esm/history/internal/parse.d.ts +153 -0
- package/dist/esm/history/internal/parse.js +105 -0
- package/dist/{history/internal/rows.js → esm/history/internal/rows.d.ts} +58 -112
- package/dist/esm/history/internal/rows.js +83 -0
- package/dist/esm/history/internal/session.d.ts +350 -0
- package/dist/esm/history/internal/session.js +285 -0
- package/dist/esm/history/internal/setup.d.ts +64 -0
- package/dist/esm/history/internal/setup.js +56 -0
- package/dist/{history/session-adapter.js → esm/history/session-adapter.d.ts} +25 -39
- package/dist/esm/history/session-adapter.js +34 -0
- package/dist/esm/history/types.d.ts +102 -0
- package/dist/esm/history/types.js +1 -0
- package/dist/esm/index.d.ts +53 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/shared/adapter.d.ts +146 -0
- package/dist/esm/shared/adapter.js +65 -0
- package/dist/{shared/clock.js → esm/shared/clock.d.ts} +3 -14
- package/dist/esm/shared/clock.js +9 -0
- package/dist/esm/shared/codec/codec.d.ts +336 -0
- package/dist/esm/shared/codec/codec.js +150 -0
- package/dist/esm/shared/codec/compression.d.ts +66 -0
- package/dist/esm/shared/codec/compression.js +40 -0
- package/dist/esm/shared/codec/json-serde.d.ts +80 -0
- package/dist/esm/shared/codec/json-serde.js +54 -0
- package/dist/esm/shared/codec/s3/client-types.d.ts +54 -0
- package/dist/esm/shared/codec/s3/client-types.js +3 -0
- package/dist/esm/shared/codec/s3/client.d.ts +63 -0
- package/dist/esm/shared/codec/s3/client.js +40 -0
- package/dist/{shared/codec/s3/config.js → esm/shared/codec/s3/config.d.ts} +45 -93
- package/dist/esm/shared/codec/s3/config.js +79 -0
- package/dist/esm/shared/codec/s3/lifecycle.d.ts +220 -0
- package/dist/esm/shared/codec/s3/lifecycle.js +214 -0
- package/dist/esm/shared/codec/s3/offloader.d.ts +428 -0
- package/dist/esm/shared/codec/s3/offloader.js +278 -0
- package/dist/{shared/concurrency.js → esm/shared/concurrency.d.ts} +2 -37
- package/dist/esm/shared/concurrency.js +27 -0
- package/dist/{shared/dynamodb/abort.js → esm/shared/dynamodb/abort.d.ts} +4 -16
- package/dist/esm/shared/dynamodb/abort.js +12 -0
- package/dist/esm/shared/dynamodb/batch-write.d.ts +86 -0
- package/dist/esm/shared/dynamodb/batch-write.js +74 -0
- package/dist/{shared/dynamodb/cancellation.js → esm/shared/dynamodb/cancellation.d.ts} +25 -53
- package/dist/esm/shared/dynamodb/cancellation.js +30 -0
- package/dist/{shared/dynamodb/client.js → esm/shared/dynamodb/client.d.ts} +74 -64
- package/dist/esm/shared/dynamodb/client.js +35 -0
- package/dist/{shared/dynamodb/idempotent-write.js → esm/shared/dynamodb/idempotent-write.d.ts} +172 -169
- package/dist/esm/shared/dynamodb/idempotent-write.js +153 -0
- package/dist/esm/shared/dynamodb/paginate.d.ts +111 -0
- package/dist/esm/shared/dynamodb/paginate.js +84 -0
- package/dist/esm/shared/dynamodb/partition-delete.d.ts +204 -0
- package/dist/esm/shared/dynamodb/partition-delete.js +171 -0
- package/dist/esm/shared/dynamodb/recency-index.d.ts +243 -0
- package/dist/esm/shared/dynamodb/recency-index.js +136 -0
- package/dist/esm/shared/dynamodb/retry.d.ts +317 -0
- package/dist/esm/shared/dynamodb/retry.js +180 -0
- package/dist/esm/shared/dynamodb/row-size.d.ts +33 -0
- package/dist/esm/shared/dynamodb/row-size.js +26 -0
- package/dist/{shared/dynamodb/table-schema.js → esm/shared/dynamodb/table-schema.d.ts} +57 -67
- package/dist/esm/shared/dynamodb/table-schema.js +43 -0
- package/dist/esm/shared/errors/base-error.d.ts +231 -0
- package/dist/esm/shared/errors/base-error.js +64 -0
- package/dist/esm/shared/errors/boundary.d.ts +86 -0
- package/dist/esm/shared/errors/boundary.js +54 -0
- package/dist/esm/shared/errors/classify.d.ts +206 -0
- package/dist/esm/shared/errors/classify.js +220 -0
- package/dist/{shared/errors/error-code.js → esm/shared/errors/error-code.d.ts} +39 -43
- package/dist/esm/shared/errors/error-code.js +24 -0
- package/dist/{shared/errors/errors.js → esm/shared/errors/errors.d.ts} +92 -98
- package/dist/esm/shared/errors/errors.js +110 -0
- package/dist/{shared/logging/logger.js → esm/shared/logging/logger.d.ts} +22 -52
- package/dist/esm/shared/logging/logger.js +25 -0
- package/dist/esm/shared/logging/redaction.d.ts +123 -0
- package/dist/esm/shared/logging/redaction.js +140 -0
- package/dist/{shared/logging/secret-patterns.js → esm/shared/logging/secret-patterns.d.ts} +46 -87
- package/dist/esm/shared/logging/secret-patterns.js +83 -0
- package/dist/{shared/logging/truncate.js → esm/shared/logging/truncate.d.ts} +6 -40
- package/dist/esm/shared/logging/truncate.js +25 -0
- package/dist/esm/shared/options.d.ts +100 -0
- package/dist/esm/shared/options.js +1 -0
- package/dist/{shared/ulid.js → esm/shared/ulid.d.ts} +4 -84
- package/dist/esm/shared/ulid.js +77 -0
- package/dist/{shared/validation/collaborators.js → esm/shared/validation/collaborators.d.ts} +37 -65
- package/dist/esm/shared/validation/collaborators.js +70 -0
- package/dist/{shared/validation/option-shape.js → esm/shared/validation/option-shape.d.ts} +14 -36
- package/dist/esm/shared/validation/option-shape.js +24 -0
- package/dist/esm/shared/validation/options.d.ts +145 -0
- package/dist/esm/shared/validation/options.js +156 -0
- package/dist/{shared/validation/primitives.js → esm/shared/validation/primitives.d.ts} +30 -125
- package/dist/esm/shared/validation/primitives.js +100 -0
- package/dist/esm/shared/validation/ttl.d.ts +94 -0
- package/dist/esm/shared/validation/ttl.js +46 -0
- package/dist/esm/store/actions/list-namespaces.d.ts +79 -0
- package/dist/esm/store/actions/list-namespaces.js +86 -0
- package/dist/esm/store/actions/put.d.ts +43 -0
- package/dist/esm/store/actions/put.js +46 -0
- package/dist/{store/actions/reconcile-vector-index.js → esm/store/actions/reconcile-vector-index.d.ts} +8 -16
- package/dist/esm/store/actions/reconcile-vector-index.js +10 -0
- package/dist/esm/store/actions/search.d.ts +38 -0
- package/dist/esm/store/actions/search.js +23 -0
- package/dist/esm/store/internal/batch-plan.d.ts +56 -0
- package/dist/esm/store/internal/batch-plan.js +28 -0
- package/dist/esm/store/internal/filter.d.ts +43 -0
- package/dist/esm/store/internal/filter.js +60 -0
- package/dist/esm/store/internal/get-item.d.ts +77 -0
- package/dist/esm/store/internal/get-item.js +54 -0
- package/dist/esm/store/internal/item-write.d.ts +270 -0
- package/dist/esm/store/internal/item-write.js +173 -0
- package/dist/esm/store/internal/parse.d.ts +230 -0
- package/dist/esm/store/internal/parse.js +169 -0
- package/dist/{store/internal/rows.js → esm/store/internal/rows.d.ts} +111 -199
- package/dist/esm/store/internal/rows.js +147 -0
- package/dist/esm/store/internal/semantic-search.d.ts +168 -0
- package/dist/esm/store/internal/semantic-search.js +200 -0
- package/dist/esm/store/internal/setup.d.ts +96 -0
- package/dist/esm/store/internal/setup.js +98 -0
- package/dist/esm/store/internal/table-search.d.ts +100 -0
- package/dist/esm/store/internal/table-search.js +119 -0
- package/dist/esm/store/internal/vector-index.d.ts +258 -0
- package/dist/esm/store/internal/vector-index.js +256 -0
- package/dist/{store/store.js → esm/store/store.d.ts} +99 -133
- package/dist/esm/store/store.js +93 -0
- package/dist/esm/store/types.d.ts +111 -0
- package/dist/esm/store/types.js +1 -0
- package/dist/esm/store/vector-backend.d.ts +104 -0
- package/dist/esm/store/vector-backend.js +1 -0
- package/package.json +50 -44
- package/dist/backfill/backfill.js +0 -393
- package/dist/checkpointer/actions/list.js +0 -142
- package/dist/checkpointer/actions/put.js +0 -195
- package/dist/checkpointer/internal/delta-history.js +0 -252
- package/dist/checkpointer/internal/parse.js +0 -372
- package/dist/checkpointer/internal/pending-writes.js +0 -588
- package/dist/checkpointer/internal/read.js +0 -264
- package/dist/checkpointer/internal/rows.js +0 -834
- package/dist/checkpointer/internal/setup.js +0 -72
- package/dist/checkpointer/types.js +0 -12
- package/dist/factory/factory.js +0 -266
- package/dist/factory/types.js +0 -10
- package/dist/history/actions/get-messages.js +0 -141
- package/dist/history/actions/list-sessions.js +0 -156
- package/dist/history/internal/append.js +0 -500
- package/dist/history/internal/message-read.js +0 -204
- package/dist/history/internal/parse.js +0 -252
- package/dist/history/internal/session.js +0 -628
- package/dist/history/internal/setup.js +0 -106
- package/dist/history/types.js +0 -12
- package/dist/index.js +0 -51
- package/dist/shared/adapter.js +0 -143
- package/dist/shared/codec/codec.js +0 -441
- package/dist/shared/codec/compression.js +0 -89
- package/dist/shared/codec/json-serde.js +0 -191
- package/dist/shared/codec/s3/client-types.js +0 -26
- package/dist/shared/codec/s3/client.js +0 -97
- package/dist/shared/codec/s3/lifecycle.js +0 -355
- package/dist/shared/codec/s3/offloader.js +0 -658
- package/dist/shared/dynamodb/batch-write.js +0 -173
- package/dist/shared/dynamodb/paginate.js +0 -188
- package/dist/shared/dynamodb/partition-delete.js +0 -340
- package/dist/shared/dynamodb/recency-index.js +0 -377
- package/dist/shared/dynamodb/retry.js +0 -463
- package/dist/shared/errors/base-error.js +0 -174
- package/dist/shared/errors/boundary.js +0 -143
- package/dist/shared/errors/classify.d.ts +0 -97
- package/dist/shared/errors/classify.js +0 -257
- package/dist/shared/logging/redaction.js +0 -316
- package/dist/shared/options.js +0 -11
- package/dist/shared/validation/options.js +0 -328
- package/dist/shared/validation/ttl.js +0 -132
- package/dist/store/actions/list-namespaces.js +0 -197
- package/dist/store/actions/put.js +0 -78
- package/dist/store/actions/search.js +0 -66
- package/dist/store/internal/batch-plan.d.ts +0 -26
- package/dist/store/internal/batch-plan.js +0 -109
- package/dist/store/internal/filter.js +0 -137
- package/dist/store/internal/get-item.d.ts +0 -45
- package/dist/store/internal/get-item.js +0 -115
- package/dist/store/internal/item-write.js +0 -463
- package/dist/store/internal/parse.js +0 -350
- package/dist/store/internal/semantic-search.js +0 -389
- package/dist/store/internal/setup.js +0 -223
- package/dist/store/internal/table-search.js +0 -213
- package/dist/store/internal/vector-index.js +0 -546
- package/dist/store/types.js +0 -15
- package/dist/store/vector-backend.js +0 -17
- /package/dist/{shared → cjs/shared}/clock.d.ts +0 -0
- /package/dist/{shared → cjs/shared}/codec/json-serde.d.ts +0 -0
- /package/dist/{shared → cjs/shared}/concurrency.d.ts +0 -0
- /package/dist/{shared → cjs/shared}/dynamodb/cancellation.d.ts +0 -0
- /package/dist/{shared → cjs/shared}/logging/logger.d.ts +0 -0
- /package/dist/{shared → cjs/shared}/logging/truncate.d.ts +0 -0
- /package/dist/{shared → cjs/shared}/ulid.d.ts +0 -0
- /package/dist/{shared → cjs/shared}/validation/primitives.d.ts +0 -0
- /package/dist/{store → cjs/store}/vector-backend.d.ts +0 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Built with [LangGraph](https://langchain-ai.github.io/langgraphjs/) · [LangChai
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
-
A DynamoDB persistence layer for [LangGraph](https://langchain-ai.github.io/langgraphjs/) in TypeScript (
|
|
19
|
+
A DynamoDB persistence layer for [LangGraph](https://langchain-ai.github.io/langgraphjs/) in TypeScript (an ES-module build and a CommonJS build; 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.
|
|
20
20
|
|
|
21
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.
|
|
22
22
|
|
|
@@ -27,9 +27,9 @@ Every adapter supports optional **gzip compression**, **S3 offloading** of paylo
|
|
|
27
27
|
| | |
|
|
28
28
|
| --- | --- |
|
|
29
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** |
|
|
30
|
+
| **Maturity** | `1.0.0` is the first stable release; the npm badge above shows the newest, and a prerelease, when there is one, is published under the `next` dist-tag. [Versioning and support](#versioning-and-support) says what each release promises and who maintains it. |
|
|
31
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). |
|
|
32
|
+
| **The limits that bite** | Without `s3`, a payload over 392 KB after compression is refused; `thread_id` and `sessionId` are at most 1024 bytes, `checkpoint_ns` at most 512 bytes, other key segments at most 256 bytes (a pending-write channel as much as its 1024-byte sort key allows), and no identifier may contain `#`. In-DynamoDB semantic search refuses more than 1000 candidates by default (`maxSearchCandidates`, ceiling 100 000); [full table](#limits). |
|
|
33
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
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
35
|
|
|
@@ -67,7 +67,7 @@ Every adapter supports optional **gzip compression**, **S3 offloading** of paylo
|
|
|
67
67
|
- [Advanced features](#advanced-features)
|
|
68
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
69
|
- [Known limitations](#known-limitations)
|
|
70
|
-
- [From DynamoDB and S3](#from-dynamodb-and-s3) · [From this package](#from-this-package)
|
|
70
|
+
- [From DynamoDB and S3](#from-dynamodb-and-s3) · [From this package](#from-this-package) · [From LangGraph](#from-langgraph)
|
|
71
71
|
- [Migrating](#migrating)
|
|
72
72
|
- [Migrating from another checkpointer or store](#migrating-from-another-checkpointer-or-store) · [Migrating from earlier versions](#migrating-from-earlier-versions)
|
|
73
73
|
- [API reference](#api-reference)
|
|
@@ -83,7 +83,7 @@ Every adapter supports optional **gzip compression**, **S3 offloading** of paylo
|
|
|
83
83
|
- [IAM permissions](#iam-permissions)
|
|
84
84
|
- [Multi-tenant deployments](#multi-tenant-deployments) · [Trust boundary](#trust-boundary)
|
|
85
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)
|
|
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) · [Finding objects no row names](#finding-objects-no-row-names) · [Lambda and other short-lived runtimes](#lambda-and-other-short-lived-runtimes) · [Multi-tenancy](#multi-tenancy)
|
|
87
87
|
- [Versioning and compatibility](#versioning-and-compatibility)
|
|
88
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)
|
|
89
89
|
- [Testing](#testing)
|
|
@@ -99,14 +99,14 @@ Every adapter supports optional **gzip compression**, **S3 offloading** of paylo
|
|
|
99
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
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
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
|
|
104
|
-
| **Semantic search, in DynamoDB or delegated** | With an `index`, the store embeds each
|
|
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), and a payload larger than that is stored uncompressed, so a reader configured like the writer never refuses a payload it wrote. [Gzip compression](#gzip-compression) |
|
|
103
|
+
| **TTL with matching S3 lifecycle rules** | `ttl: { days }` or `{ seconds }` stamps a `ttl` attribute and a row past its `ttl` is not served 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 extracted text 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
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
|
|
106
|
+
| **Cancellation** | The long-running methods take an `AbortSignal`, which reaches the AWS SDK on every DynamoDB request and S3 transfer the call makes for you — not on the verification reads and the cleanup after a failure, which must finish, so a cancel ends a request in flight and rejects with `ABORTED`. [Cancellation](#cancellation) |
|
|
107
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
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) |
|
|
109
|
+
| **Supply-chain provenance** | Published to npm with provenance attestations; each release's tarball and SBOMs also carry a GitHub build-provenance attestation (`gh attestation verify`). [npm provenance](https://www.npmjs.com/package/@farukada/aws-langgraph-dynamodb-ts#provenance) |
|
|
110
110
|
|
|
111
111
|
## Versioning and support
|
|
112
112
|
|
|
@@ -114,8 +114,8 @@ Every adapter supports optional **gzip compression**, **S3 offloading** of paylo
|
|
|
114
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
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
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
|
|
118
|
-
- **Production readiness.**
|
|
117
|
+
- **Runtimes and the Node floor.** `engines.node` requires Node ≥ 22, and CI runs the unit tier on Node 22, 24 and 26 across Linux, macOS and Windows, and the DynamoDB Local tiers on Linux with Node 22; consumers are checked against TypeScript 5.x and later. Node 22 is supported until its end of life, 2027-04-30; the floor is not raised before then, and raising it afterwards is a **major**. Raising a peer floor is a **major**; widening a range is a minor. A peer range is never narrowed in a patch. Peer ranges are in [Supported runtimes and peers](#supported-runtimes-and-peers).
|
|
118
|
+
- **Production readiness.** `1.x` is the stable line: `latest` is the newest stable release, the npm badge above shows it, and a prerelease, when there is one, is published under the `next` dist-tag. The package 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
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
120
|
|
|
121
121
|
Full detail: [Versioning and compatibility](#versioning-and-compatibility).
|
|
@@ -147,44 +147,7 @@ Four classes do the work:
|
|
|
147
147
|
- **`DynamoDBChatMessageHistory`** — multi-session chat history, with a single-session adapter (`forSession`) for `RunnableWithMessageHistory`.
|
|
148
148
|
- **`DynamoDBFactory`** — convenience constructors, including `createAll` (one shared client + a `destroy()`).
|
|
149
149
|
|
|
150
|
-
|
|
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).
|
|
150
|
+
How each request travels through the codec and the table — a checkpoint write and read, a store put and search, a chat-history append and read — is in [Guide → How a request flows](docs/guide.md#how-a-request-flows).
|
|
188
151
|
|
|
189
152
|
---
|
|
190
153
|
|
|
@@ -211,10 +174,10 @@ npm install @aws-sdk/client-s3
|
|
|
211
174
|
npm install @langchain/aws # e.g. Bedrock Titan embeddings
|
|
212
175
|
```
|
|
213
176
|
|
|
214
|
-
The build
|
|
177
|
+
The package ships two builds from one source: `import` loads an ES-module build and `require` a CommonJS one, each with its own declarations, so an ES-module application loads one copy of `@langchain/core` and `@langchain/langgraph-checkpoint` rather than a CommonJS copy beside its own ([decision record 29](docs/decisions/0029-publish-both-an-es-module-and-a-commonjs-build.md)). Use named imports; the package has no default export:
|
|
215
178
|
|
|
216
179
|
```typescript
|
|
217
|
-
import { DynamoDBSaver } from '@farukada/aws-langgraph-dynamodb-ts'; //
|
|
180
|
+
import { DynamoDBSaver } from '@farukada/aws-langgraph-dynamodb-ts'; // ES modules or TypeScript
|
|
218
181
|
```
|
|
219
182
|
|
|
220
183
|
```js
|
|
@@ -225,9 +188,9 @@ const { DynamoDBSaver } = require('@farukada/aws-langgraph-dynamodb-ts'); // Com
|
|
|
225
188
|
|
|
226
189
|
| Package | Range | Needed for |
|
|
227
190
|
| --- | --- | --- |
|
|
228
|
-
| `@langchain/core` | `^1.2.
|
|
229
|
-
| `@langchain/langgraph-checkpoint` | `^1.
|
|
230
|
-
| `@aws-sdk/client-s3` | `^3.
|
|
191
|
+
| `@langchain/core` | `^1.2.17` | every adapter: messages, `Embeddings`, `RunnableConfig` |
|
|
192
|
+
| `@langchain/langgraph-checkpoint` | `^1.2.0` | every adapter: `BaseCheckpointSaver`, `BaseStore`, the serializer protocol |
|
|
193
|
+
| `@aws-sdk/client-s3` | `^3.1151.0` | S3 offloading only; an optional peer |
|
|
231
194
|
| `@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 |
|
|
232
195
|
|
|
233
196
|
`@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).
|
|
@@ -235,10 +198,10 @@ const { DynamoDBSaver } = require('@farukada/aws-langgraph-dynamodb-ts'); // Com
|
|
|
235
198
|
### Runtime requirements
|
|
236
199
|
|
|
237
200
|
- **Node.js** 22 or later; CI runs 22, 24 and 26 on Linux, macOS and Windows.
|
|
238
|
-
- **Module format:**
|
|
201
|
+
- **Module format:** an ES-module build for `import` and a CommonJS build for `require`, each with its own declarations, as shown above.
|
|
239
202
|
- **TypeScript:** the shipped declarations target TypeScript 5.x and later.
|
|
240
203
|
- **Tree-shaking:** the package declares `"sideEffects": false`.
|
|
241
|
-
- **Bundling:** the optional `@aws-sdk/client-s3` peer is loaded lazily
|
|
204
|
+
- **Bundling:** the optional `@aws-sdk/client-s3` peer is loaded lazily on first use — a dynamic `import()`, which the CommonJS build compiles to `require` — so a bundler (esbuild, rollup, webpack) must either have it installed or mark `@aws-sdk/*` external — CDK's `NodejsFunction` does the latter when it is given a runtime that ships the SDK (`NODEJS_22_X`, say) and bundles everything under `NODEJS_LATEST`; a bare esbuild build does not.
|
|
242
205
|
- **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.
|
|
243
206
|
|
|
244
207
|
### Minimal agent
|
|
@@ -360,7 +323,7 @@ await store.listNamespaces({ prefix: ['library'], maxDepth: 1 });
|
|
|
360
323
|
store.destroy();
|
|
361
324
|
```
|
|
362
325
|
|
|
363
|
-
With an `index`, `put` embeds each
|
|
326
|
+
With an `index`, `put` embeds each extracted text 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
327
|
|
|
365
328
|
### Memory inside a graph
|
|
366
329
|
|
|
@@ -408,7 +371,7 @@ await agent.invoke(
|
|
|
408
371
|
);
|
|
409
372
|
```
|
|
410
373
|
|
|
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()
|
|
374
|
+
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()`: a `.` in a label is refused there as `put()` refuses it (from `@langchain/langgraph-checkpoint` 1.2.0 upstream refuses it on every operation, reads included), the `"langgraph"` root rule stays `put()`-only, 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
375
|
|
|
413
376
|
### Chat history
|
|
414
377
|
|
|
@@ -506,9 +469,9 @@ try {
|
|
|
506
469
|
|
|
507
470
|
`createAll` builds all three adapters on **one shared DynamoDB client** and returns a single `destroy()` that tears everything down.
|
|
508
471
|
|
|
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
|
|
472
|
+
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 with the same defaults — on the factory's `client` when it was given one, which the adapter never closes, and otherwise on a client of its own.
|
|
510
473
|
|
|
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
|
|
474
|
+
`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 as a `DynamoDBLangGraphError` carrying it as `cause`, so a client that refuses to close cannot strand the one behind it. A second `destroy()` does nothing. 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.
|
|
512
475
|
|
|
513
476
|
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.
|
|
514
477
|
|
|
@@ -532,7 +495,7 @@ await saver.ensureS3LifecycleRule(); // once, from a deployment step
|
|
|
532
495
|
saver.destroy();
|
|
533
496
|
```
|
|
534
497
|
|
|
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)).
|
|
498
|
+
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 after compression 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)).
|
|
536
499
|
|
|
537
500
|
### Expiry with TTL
|
|
538
501
|
|
|
@@ -556,7 +519,7 @@ store.destroy();
|
|
|
556
519
|
history.destroy();
|
|
557
520
|
```
|
|
558
521
|
|
|
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
|
|
522
|
+
`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 readers hide a row past its `ttl` in the meantime (a checkpoint's payload and pending-write rows follow its metadata row). 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)).
|
|
560
523
|
|
|
561
524
|
### Bring your own DynamoDB client
|
|
562
525
|
|
|
@@ -571,7 +534,7 @@ const client = DynamoDBDocument.from(
|
|
|
571
534
|
new DynamoDBClient({
|
|
572
535
|
region: 'eu-west-1',
|
|
573
536
|
maxAttempts: 1, // the library retries; SDK retries would stack inside its budget
|
|
574
|
-
requestHandler: { requestTimeout: 10_000, throwOnRequestTimeout: true },
|
|
537
|
+
requestHandler: { requestTimeout: 10_000, socketTimeout: 5_000, throwOnRequestTimeout: true },
|
|
575
538
|
}),
|
|
576
539
|
);
|
|
577
540
|
|
|
@@ -579,7 +542,7 @@ const saver = new DynamoDBSaver({ tableName: 'langgraph', client });
|
|
|
579
542
|
saver.destroy(); // does not close `client`: an injected client is yours
|
|
580
543
|
```
|
|
581
544
|
|
|
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)).
|
|
545
|
+
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, and the socket timeout bounds a response body that stalls after its headers, which the request timeout does not ([Retries and backoff](#retries-and-backoff)).
|
|
583
546
|
|
|
584
547
|
### Cancellation and timeouts
|
|
585
548
|
|
|
@@ -601,9 +564,8 @@ try {
|
|
|
601
564
|
const recent = await history.getMessages('session-1', { limit: 20, signal });
|
|
602
565
|
await agent.invoke({ messages: [{ role: 'user', content: 'Hello' }] }, { ...thread, signal });
|
|
603
566
|
} catch (error) {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
console.warn('cancelled during a DynamoDB or S3 call', e.context.operation);
|
|
567
|
+
if (isDynamoDBLangGraphError(error) && error.code === ErrorCode.ABORTED) {
|
|
568
|
+
console.warn('cancelled during a DynamoDB or S3 call', error.context.operation);
|
|
607
569
|
} else if (signal.aborted) {
|
|
608
570
|
console.warn('cancelled by LangGraph outside a saver call');
|
|
609
571
|
} else {
|
|
@@ -612,7 +574,7 @@ try {
|
|
|
612
574
|
}
|
|
613
575
|
```
|
|
614
576
|
|
|
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)).
|
|
577
|
+
The signal reaches the AWS SDK on every DynamoDB request and S3 transfer the call makes for you — not on the verification reads and cleanup that follow a failure, which run to completion ([Cancellation](#cancellation)) — 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)).
|
|
616
578
|
|
|
617
579
|
### Listing sessions, threads and namespaces
|
|
618
580
|
|
|
@@ -655,16 +617,16 @@ All adapters share a common base. Provide **either** a prebuilt `client` (which
|
|
|
655
617
|
|
|
656
618
|
Options are checked at construction, and a mistake raises `VALIDATION` naming the option:
|
|
657
619
|
|
|
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`.
|
|
620
|
+
- **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`. The four option objects LangGraph itself defines are the exception — `saver.list`'s, `getDeltaChannelHistory`'s, `store.search`'s and `store.listNamespaces`' — which ignore a key this version does not read, because LangGraph passes its own through and a key a later LangGraph adds must not break the call ([decision record 28](docs/decisions/0028-ignore-keys-langgraph-adds-to-option-objects-it-defines.md)). The keys they do read are validated as before.
|
|
659
621
|
- **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
622
|
- **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
623
|
- **Ceilings.** A numeric option above its ceiling is refused. The ceilings are in the tables below and in [Limits](#limits).
|
|
662
624
|
|
|
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.
|
|
625
|
+
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, and the three page caps that have no ceiling — the store's `maxIterations` and `listSessions`' `maxItems` and `maxIterations` — also take `Infinity`, which asks for no cap. 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
626
|
|
|
665
627
|
### Shared options
|
|
666
628
|
|
|
667
|
-
Every adapter — `DynamoDBSaver`, `DynamoDBStore` and `DynamoDBChatMessageHistory` — reads these.
|
|
629
|
+
Every adapter — `DynamoDBSaver`, `DynamoDBStore` and `DynamoDBChatMessageHistory` — reads these, except `indexName` and `indexShards`: only the saver and history read those two, and the store refuses them.
|
|
668
630
|
|
|
669
631
|
| Option | Type | Default | Ceiling | Notes |
|
|
670
632
|
| --- | --- | --- | --- | --- |
|
|
@@ -675,15 +637,15 @@ Every adapter — `DynamoDBSaver`, `DynamoDBStore` and `DynamoDBChatMessageHisto
|
|
|
675
637
|
| `logger` | `Logger` | silent | — | per-instance logger; all four methods — `debug`, `info`, `warn`, `error` — are required ([Logging](#logging)) |
|
|
676
638
|
| `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
639
|
| `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)) |
|
|
640
|
+
| `s3` | `S3OffloadConfig` | none: a payload over 392 KB after compression 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
641
|
| `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
|
|
642
|
+
| `indexName` | `string` | none: the listings that cross partitions scan the table | — | the name of the recency index (a GSI on `gsi1pk`/`gsi1sk`) that `saver.list()` without a `thread_id` and `history.listSessions()` read; a non-empty string. Not a store option: the store refuses it. See the note below the table |
|
|
643
|
+
| `indexShards` | `number` | 8 | 1024 | index partitions per adapter (saver and history; the store refuses it). Fixed for the table's life: each row keeps the shard it was written with, and the backfill writes keys only to rows that have none, so it cannot move a row. Raising it is safe, since old shards stay queried; lowering it hides the rows on dropped shards from the listings. One partition per adapter would concentrate every listing on one key, which is worse than the scan it replaces |
|
|
682
644
|
| `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
645
|
|
|
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))
|
|
646
|
+
**`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. It must translate the default way: one built with `unmarshallOptions.wrapNumbers` or `marshallOptions.convertEmptyValues: true` is refused, naming `client`, because a wrapped number and a NULL empty string change what every row reads back as. Construct it with `maxAttempts: 1` **and a request timeout of its own** (`DynamoDBDocument.from(new DynamoDBClient({ maxAttempts: 1, requestHandler: { requestTimeout: 10_000, socketTimeout: 5_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
647
|
|
|
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
|
|
648
|
+
**`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 sets its own `maxAttempts` or `requestHandler` — a key given as `undefined` sets nothing and keeps the default ([Retries and backoff](#retries-and-backoff)).
|
|
687
649
|
|
|
688
650
|
**`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
651
|
|
|
@@ -703,6 +665,7 @@ A serde that stamps a `serdeType` other than `json` is taken at its word: this p
|
|
|
703
665
|
| `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
666
|
| `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
667
|
| `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()` |
|
|
668
|
+
| `maxIterations` | `number` | 1000 | none; `Infinity` asks for no cap | DynamoDB pages one `search()`, `listNamespaces()` or `reconcileVectorIndex()` reads before `RESULT_TRUNCATED`; raise it for a rootless scan over a large table whose rows are mostly not store items |
|
|
706
669
|
| `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
670
|
|
|
708
671
|
**`DynamoDBChatMessageHistory`** also takes:
|
|
@@ -711,9 +674,9 @@ A serde that stamps a `serdeType` other than `json` is taken at its word: this p
|
|
|
711
674
|
| --- | --- | --- | --- | --- |
|
|
712
675
|
| `onCorruptMessage` | `'skip' \| 'throw'` | `'skip'` | — | see the note below the table |
|
|
713
676
|
|
|
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
|
|
677
|
+
**`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 — and a stored message LangChain cannot rebuild into a message. It does **not** cover a payload larger than this reader's `compression.maxDecompressedBytes` or `s3.maxDownloadBytes` (a limit of this reader's, not a lost payload), 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
678
|
|
|
716
|
-
**`DynamoDBFactory`** — `new DynamoDBFactory(base)` takes the defaults every adapter it builds inherits (`FactoryBaseOptions`); a per-adapter option wins.
|
|
679
|
+
**`DynamoDBFactory`** — `new DynamoDBFactory(base)` takes the defaults every adapter it builds inherits (`FactoryBaseOptions`); a per-adapter option wins, and a per-adapter key given as `undefined` is not given, so it leaves the default in place.
|
|
717
680
|
|
|
718
681
|
| Option | Type | Default | Ceiling | Notes |
|
|
719
682
|
| --- | --- | --- | --- | --- |
|
|
@@ -732,7 +695,7 @@ Anything else — `tableName`, `serde`, `indexName`, `indexShards`, `readConcurr
|
|
|
732
695
|
| `compression.enabled` | `boolean` | **required** | — | `false` stores every payload uncompressed |
|
|
733
696
|
| `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
697
|
| `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
|
|
698
|
+
| `compression.maxDecompressedBytes` | `number` | 50 MiB | 512 MiB | a read refuses to inflate a payload past it, with `COMPRESSION_LIMIT`; a write stores a payload larger than it uncompressed |
|
|
736
699
|
|
|
737
700
|
**`s3`** (`S3OffloadConfig`):
|
|
738
701
|
|
|
@@ -743,8 +706,8 @@ Anything else — `tableName`, `serde`, `indexName`, `indexShards`, `readConcurr
|
|
|
743
706
|
| `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
707
|
| `s3.serverSideEncryption` | `string` | `'AES256'` | — | one of `'AES256'`, `'aws:kms'` and `'aws:kms:dsse'` |
|
|
745
708
|
| `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
|
|
709
|
+
| `s3.maxDownloadBytes` | `number` | 50 MiB | 512 MiB | the largest offloaded object a read buffers, or `S3_OFFLOAD_FAILED`; an offloaded payload larger than it is refused at the write with `VALIDATION` naming `payload`, and a value below `thresholdBytes` is refused at construction |
|
|
710
|
+
| `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 sets its own `maxAttempts` or `requestHandler` |
|
|
748
711
|
|
|
749
712
|
**`retry`** (`RetryPolicy`):
|
|
750
713
|
|
|
@@ -752,7 +715,7 @@ Anything else — `tableName`, `serde`, `indexName`, `indexShards`, `readConcurr
|
|
|
752
715
|
| --- | --- | --- | --- | --- |
|
|
753
716
|
| `retry.maxAttempts` | `number` | 5 | 100 | attempts per DynamoDB call before `RETRY_EXHAUSTED`; a chat-history append never uses fewer than 18 |
|
|
754
717
|
| `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 |
|
|
718
|
+
| `retry.maxDelayMs` | `number` | 5 000, or `baseDelayMs` when that is given above it | 60 000 | the cap on one backoff delay, in milliseconds; at least `baseDelayMs` when that is given |
|
|
756
719
|
|
|
757
720
|
**`ttl`** (`TtlOption`) — exactly one of:
|
|
758
721
|
|
|
@@ -763,7 +726,7 @@ Anything else — `tableName`, `serde`, `indexName`, `indexShards`, `readConcurr
|
|
|
763
726
|
|
|
764
727
|
`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
728
|
|
|
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`.
|
|
729
|
+
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. The same cap binds the write, too: an offloaded payload larger than `maxDownloadBytes` is refused before it is uploaded, and a `maxDownloadBytes` below `thresholdBytes` — one that could offload a payload it could never read back — is refused at construction. For a customer-managed key, set `serverSideEncryption: 'aws:kms'` plus `sseKmsKeyId`.
|
|
767
730
|
|
|
768
731
|
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
732
|
|
|
@@ -771,7 +734,7 @@ A `keyPrefix` must be a string holding a non-empty path ending in `/`, and every
|
|
|
771
734
|
|
|
772
735
|
### Per-call options
|
|
773
736
|
|
|
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.
|
|
737
|
+
Every page `limit` in this package has a ceiling of 10 000 (`VALIDATION` naming `limit`, raised at the call), and every options object this package defines refuses a key it does not read, naming `options.<key>` (`window.<key>` for `forSession`), except `redactLogger`'s, below; the four LangGraph defines ignore one (see *Unknown keys* under [Configuration reference](#configuration-reference)).
|
|
775
738
|
|
|
776
739
|
**`history.getMessages(sessionId, options?)`** (`GetMessagesOptions`):
|
|
777
740
|
|
|
@@ -800,7 +763,7 @@ Every page `limit` in this package has a ceiling of 10 000 (`VALIDATION` naming
|
|
|
800
763
|
| `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
764
|
| `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
765
|
| `limit` | `number` | 10 | 10 000 | `0` returns an empty page without a read or an embedding |
|
|
803
|
-
| `offset` | `number` | 0 | — | items skipped first
|
|
766
|
+
| `offset` | `number` | 0 | — | items skipped first. `offset + limit` is how far a `vectorBackend` page may reach, against `maxSearchCandidates`; `maxScanItems` counts the rows a call reads, whatever the page |
|
|
804
767
|
| `signal` | `AbortSignal` | none | — | |
|
|
805
768
|
|
|
806
769
|
**`store.listNamespaces(options?)`** (`ListNamespacesOptions`) takes no signal:
|
|
@@ -841,9 +804,9 @@ Every page `limit` in this package has a ceiling of 10 000 (`VALIDATION` naming
|
|
|
841
804
|
|
|
842
805
|
| Option | Type | Default | Ceiling | Notes |
|
|
843
806
|
| --- | --- | --- | --- | --- |
|
|
844
|
-
| `client` | `DynamoDBDocument` | **required** | — | must provide `scan` and `update
|
|
807
|
+
| `client` | `DynamoDBDocument` | **required** | — | must provide `scan` and `update`, and translate the default way — see the note below the shared options table |
|
|
845
808
|
| `tableName` | `string` | **required** | — | the adapters' rule |
|
|
846
|
-
| `indexShards` | `number` | 8 | 1024 | must equal the
|
|
809
|
+
| `indexShards` | `number` | 8 | 1024 | must equal the saver's and the history's `indexShards`, or rows land on shards no listing queries |
|
|
847
810
|
| `pageSize` | `number` | 100 | — | rows per scan page |
|
|
848
811
|
| `maxPages` | `number` | none: the whole table | — | stops after this many pages and returns a `nextCursor` |
|
|
849
812
|
| `cursor` | `string` | none | — | the `nextCursor` of an earlier run, to resume it |
|
|
@@ -855,25 +818,27 @@ Every page `limit` in this package has a ceiling of 10 000 (`VALIDATION` naming
|
|
|
855
818
|
|
|
856
819
|
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
820
|
|
|
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
|
|
821
|
+
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 and a socket timeout of its own**. `maxAttempts: 1` is necessary but not 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
822
|
|
|
860
|
-
- **What is retried** — throttling and capacity errors, transaction conflicts (`ReplicatedWriteConflictException` included), `InternalFailure` and the other transient server errors, request timeouts, HTTP 429
|
|
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.
|
|
823
|
+
- **What is retried** — throttling and capacity errors, transaction conflicts (`ReplicatedWriteConflictException` included), `InternalFailure` and the other transient server errors, request timeouts, HTTP 429, 500, 502, 503 and 504 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.
|
|
824
|
+
- **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, or `baseDelayMs` when that is given above it; 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
825
|
- **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
826
|
- The request timeout covers socket acquisition, connect, the request write and the wait for response headers.
|
|
864
827
|
- 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
828
|
- **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
|
|
829
|
+
- 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 MiB default of `s3.maxDownloadBytes`, which an upload may not pass either, 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
830
|
- 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
831
|
- **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
832
|
- **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`
|
|
833
|
+
- **Visibility** — every retry of a DynamoDB request and of an S3 upload or download is logged at `debug` as `retrying after a transient error`, with the attempt number, the delay about to be slept and the error's name; an S3 transfer's line also names `operation` (`upload` or `download`). Two loops are not logged line by line: `BatchWriteItem`'s re-submission of `UnprocessedItems`, which ends in `BATCH_WRITE_INCOMPLETE` carrying the rounds it spent, and a best-effort S3 delete's retries, which end in the orphan `warn` when they fail. `RETRY_EXHAUSTED` carries the last error as `cause`, and `context.attempts` beside that error's `awsErrorName`, `httpStatusCode` and `requestId`; an `S3_OFFLOAD_FAILED` from an upload or download carries the same three AWS fields for its underlying failure when that failure was AWS's, with no `attempts` of its own.
|
|
871
834
|
|
|
872
835
|
## Error handling
|
|
873
836
|
|
|
874
|
-
Every error the library throws is a `DynamoDBLangGraphError` carrying a stable `code` from the `ErrorCode` enum, a structured `context` (`
|
|
837
|
+
Every error the library throws is a `DynamoDBLangGraphError` carrying a stable `code` from the `ErrorCode` enum, a structured `context` (`operation` — the public method the error surfaced through, or `upload`/`download` for an S3 transfer; `tableName` — the adapter's table, set by the saver, the store and the chat history; then `field`, `key`, `attempts`, `threadId`, `checkpointId`, and — when the failure underneath came from AWS — `awsErrorName`, `requestId` and `httpStatusCode`, which a `RETRY_EXHAUSTED` and an `S3_OFFLOAD_FAILED` carry for their last or underlying failure too; 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`. Construction — `new DynamoDBSaver(...)` and the like, and `DynamoDBFactory`'s `create*`/`createAll` — raises `VALIDATION` the same way but before any adapter method runs, so before any boundary does: that error's `context` carries `field`, never `operation`.
|
|
838
|
+
|
|
839
|
+
The innermost guarded method a call reaches names the `operation`: `saver.getDeltaChannelHistory`'s own ancestor reads report `saver.getTuple`, and a single-session adapter's calls report the multi-session method they delegate to (`history.getMessages`, and so on) rather than the session method that made the call. The same holds for an `AbortSignal` shared across more than one call whose `reason` is already one of this library's own `ABORTED` errors: whichever call's boundary reaches it first is the one its `context.operation` reports, and the first call's `context.tableName` sticks the same way.
|
|
875
840
|
|
|
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
|
|
841
|
+
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 takes the `unknown` a `catch` clause binds under `strict`, with no cast, and is safe on any value, including one that is not an object at all. `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
842
|
|
|
878
843
|
```typescript
|
|
879
844
|
import { ErrorCode, isDynamoDBLangGraphError } from '@farukada/aws-langgraph-dynamodb-ts';
|
|
@@ -881,47 +846,48 @@ import { ErrorCode, isDynamoDBLangGraphError } from '@farukada/aws-langgraph-dyn
|
|
|
881
846
|
try {
|
|
882
847
|
await store.put([''], 'k', { v: 1 });
|
|
883
848
|
} catch (error) {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
switch (e.code) {
|
|
849
|
+
if (isDynamoDBLangGraphError(error)) {
|
|
850
|
+
switch (error.code) {
|
|
887
851
|
case ErrorCode.VALIDATION:
|
|
888
|
-
console.error('bad input',
|
|
852
|
+
console.error('bad input', error.context.field); // names the offending option or argument
|
|
889
853
|
break;
|
|
890
854
|
case ErrorCode.THROTTLED:
|
|
891
|
-
console.warn('back off',
|
|
855
|
+
console.warn('back off', error.context.awsErrorName); // says which limit
|
|
892
856
|
break;
|
|
893
857
|
case ErrorCode.COMPENSATION_FAILED:
|
|
894
|
-
console.error(
|
|
858
|
+
console.error(error.details.rollbackError); // typed by the code, no cast; then run reconcileMessageCount
|
|
895
859
|
break;
|
|
896
860
|
}
|
|
897
861
|
}
|
|
898
862
|
}
|
|
899
863
|
```
|
|
900
864
|
|
|
865
|
+
An S3 failure during an upload or a download is always `S3_OFFLOAD_FAILED`, whatever S3 answered. S3's own error name is in `context.awsErrorName`, and the S3 error is the `cause` — or, once the transfer's retries are spent, one level below it, under the `RETRY_EXHAUSTED` error that is the `cause`. So the S3 names in the rows below reach a caller under their own code only from `ensureS3LifecycleRule()`.
|
|
866
|
+
|
|
901
867
|
| `ErrorCode` | Thrown by |
|
|
902
868
|
| --- | --- |
|
|
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 |
|
|
869
|
+
| `VALIDATION` | every constructor for a bad option, an option key it does not read, or a collaborator missing a method or a `client` whose translation would change how rows read back; 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; an offloaded payload larger than `s3.maxDownloadBytes`, at the write; a row-sourced `s3Key` outside the path the row's own identifiers produce, on every adapter and under every corruption policy; a store put whose built row — payload plus its inline vectors — would pass DynamoDB's 400 KB item limit, naming `index` when the vectors are what pushed it over and `value` otherwise, before anything is written; 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
870
|
| `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
|
|
906
|
-
| `CONTENTION` | the same as `THROTTLED`, for a request that collided with another on the same item or object: `TransactionConflictException`, `TransactionInProgressException`, `ReplicatedWriteConflictException`, S3 `ConditionalRequestConflict
|
|
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
|
|
909
|
-
| `AWS_REJECTED` | any method whose request AWS rejected as malformed (`ValidationException
|
|
871
|
+
| `SERVICE_UNAVAILABLE` | the same, for a transient AWS or network failure: `InternalServerError`, `InternalFailure`, `ServiceUnavailable`, S3 `InternalError`, a request timeout, HTTP 500, 502, 503 or 504, a reset or refused connection, a signing failure the SDK corrected its clock for or reports as expired (retried, as the SDK itself would) — 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 |
|
|
872
|
+
| `CONTENTION` | the same as `THROTTLED`, for a request that collided with another on the same item or object: `TransactionConflictException`, `TransactionInProgressException`, `ReplicatedWriteConflictException`, S3 `ConditionalRequestConflict`; and `ensureS3LifecycleRule()` when every one of the five rounds it polls needs a write — a competing writer replacing the bucket's lifecycle configuration on every single re-read. Retry soon; more capacity would not help |
|
|
873
|
+
| `ACCESS_DENIED` | any method whose credentials or IAM policy AWS refused (`AccessDeniedException`, S3 `AccessDenied`, an expired or unrecognised token, a bad or skewed signature), and any method whose request could not be signed at all because the SDK's credential chain found no credentials (`CredentialsProviderError`). Not retried: fix the credentials or the IAM policy; `context.awsErrorName` says which refusal it was |
|
|
874
|
+
| `NOT_FOUND` | any method whose table does not exist (`ResourceNotFoundException`), and `ensureS3LifecycleRule()` when the offload bucket does not (`NoSuchBucket`). A missing index is not this code: DynamoDB refuses a query naming one with a `ValidationException`, which is `AWS_REJECTED`. Not retried: create it, or fix the `tableName` or `s3.bucketName` the adapter was given |
|
|
875
|
+
| `AWS_REJECTED` | any method whose request AWS rejected as malformed (`ValidationException` — including a query naming an `indexName` the table does not have — `IdempotentParameterMismatchException`, …). Not retried: the same request fails the same way, so fix the request — `context.awsErrorName` and `cause` say what AWS objected to |
|
|
910
876
|
| `AWS_REQUEST_FAILED` | any method, for an AWS failure no narrower code fits; `context.awsErrorName` names it |
|
|
911
877
|
| `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
878
|
| `RETRY_EXHAUSTED` | every DynamoDB call after `retry.maxAttempts` transient failures (`context.attempts`, the last error as `cause`) |
|
|
913
879
|
| `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
880
|
| `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
|
|
881
|
+
| `COMPENSATION_FAILED` | `history.addMessages` / `addMessage` when a chunk failed — the only one, or a later one — and either the rollback of what committed failed too or the failing chunk's own outcome could not be established (`details.rollbackError`; run `reconcileMessageCount`) |
|
|
916
882
|
| `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
|
|
883
|
+
| `RESULT_TRUNCATED` | the paginated reads that keep rows in memory — `store.search`, `store.listNamespaces`, `store.reconcileVectorIndex`, `history.listSessions` — past `maxScanItems` / `maxItems` / `maxIterations` (the store's own `maxIterations` option, and `listSessions({ 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
884
|
| `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` |
|
|
885
|
+
| `COMPRESSION_LIMIT` | a payload whose decompressed size would exceed this reader's `maxDecompressedBytes`: a limit of the reader's, not a lost payload, so `getMessages` fails the read under either `onCorruptMessage` policy. This package never compresses a payload past its writer's own cap, so a reader configured like the writer never raises it; a payload a release before `1.0.0` compressed — which did not check the cap — can still raise it |
|
|
920
886
|
| `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
887
|
| `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 |
|
|
888
|
+
| `ANCESTOR_EXPIRED` | `saver.getDeltaChannelHistory` when a checkpoint a delta channel still needs has expired, or has since been removed by DynamoDB's sweep, or the chain of parents loops back on itself (`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
889
|
|
|
924
|
-
`COMPENSATION_FAILED` is the one error that carries
|
|
890
|
+
`COMPENSATION_FAILED` is the one error that carries a second error beside its `cause`: the append's original failure is `cause`, and `details.rollbackError` is why the append could not be undone or settled — the rollback's own failure (which can itself be a `BATCH_WRITE_INCOMPLETE`), or, when the rollback succeeded but the failing chunk's own outcome could not be established, that chunk's own read-back or write failure. The session's stored `messageCount` may be wrong at that point; `reconcileMessageCount` repairs it.
|
|
925
891
|
|
|
926
892
|
### Cancellation
|
|
927
893
|
|
|
@@ -929,21 +895,27 @@ Every long-running method takes an `AbortSignal`: the checkpointer reads `Runnab
|
|
|
929
895
|
|
|
930
896
|
- **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
897
|
- **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
|
|
898
|
+
- **It ends the request, not just the wait.** The signal is passed to the AWS SDK as `abortSignal` on every DynamoDB request and S3 transfer the call makes for you — not on the verification reads and cleanup that follow a failure (below): 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
899
|
- **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
900
|
- **`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.**
|
|
901
|
+
- **Cleanup is never cancelled, and a cut-short write is not assumed dead.** The verification reads and the cleanup that follow a failure run without the signal. A write the cancel cut short can still be applied by DynamoDB after the call returns, so its uploads are kept rather than released, and a write the cancel came before is never sent. An abort therefore never strands a live row pointing at a deleted object; what it can leave is an object no row names.
|
|
936
902
|
|
|
937
903
|
### Error ordering and partial progress
|
|
938
904
|
|
|
939
905
|
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
906
|
|
|
941
|
-
- **`store.batch`**
|
|
942
|
-
- **`saver.putWrites`** sends one write per pending write,
|
|
943
|
-
- **`history.addMessages`** is different: a large append is cut into chunks, each committed as its own transaction, and a chunk that fails
|
|
907
|
+
- **`store.batch`** is evaluated as the reference store evaluates one: every `get`, `search` and `listNamespaces` is answered first, concurrently, as the batch found things, and the puts and deletes land afterwards, concurrently, one per item — the last operation written for an item wins and the earlier ones are not sent. Any failure rejects the whole call, but a write that itself succeeded before the failure is already durably applied and is **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.
|
|
908
|
+
- **`saver.putWrites`** sends one write per pending write — regular writes at most 32 at a time, special-channel writes alongside them (a one-item transaction only for a write whose payload was offloaded) — and a rejection is 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 safe. A regular write is first-write-wins, so a re-sent one that already landed is turned away and the stored row stands. A special write (`__error__`, `__interrupt__`, `__resume__`, `__scheduled__`) overwrites, with the same content.
|
|
909
|
+
- **`history.addMessages`** is different: a large append is cut into chunks, each committed as its own transaction, and a chunk that fails is read back, then every already-committed chunk (and the session's message count) is rolled back before the 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 session is not known to be restored: either the rollback itself failed, or the failing chunk's own outcome could not be established — its read failed, or some attempt of it may still be applied: it got no answer, or DynamoDB answered that it was still in progress (`TransactionInProgressException`) or failed with a server error (5xx). 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 over-count them, and the remedy is `history.reconcileMessageCount(sessionId)` once the session is idle, not a blind retry. An **`ABORTED`** append rolled back what had committed, but a chunk that was in flight when the signal fired may still commit after the call returns: read the session back before re-sending those messages. Full detail: [Guide → Chat history semantics](docs/guide.md#chat-history-semantics).
|
|
944
910
|
- **`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
911
|
- **`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.
|
|
946
912
|
|
|
913
|
+
### Errors raised inside a graph run
|
|
914
|
+
|
|
915
|
+
LangGraph calls `put` and `putWrites` from inside a run, and how a rejection from them reaches you is decided by the run's `durability` option, not by this package. Under the default `'async'`, LangGraph does not await a `putWrites` when it issues one — it keeps the promise and awaits the whole set at the end of the run — so a rejection in between is an **unhandled promise rejection** first and an `invoke` rejection only afterwards. Any rejection qualifies: a `VALIDATION` refusal of a graph name (a `#` in a state key or node name, a `join:` channel over its cap), `RETRY_EXHAUSTED` after the backoff budget, `S3_OFFLOAD_FAILED`, `ABORTED`. With Node's default `--unhandled-rejections=throw` the process exits before `invoke` can reject, and on AWS Lambda the invocation fails with `Runtime.UnhandledPromiseRejection`. `MemorySaver` never rejects, so the hazard is invisible with it and live here.
|
|
916
|
+
|
|
917
|
+
LangGraph 1.5.0 closes this: it reports a saver write's failure as the run's own failure (langgraphjs #2969, in its 1.5.0 release notes), so on 1.5.0 and later the rejection reaches `invoke` or `stream` and nothing is unhandled. On 1.4.x, install a process-level `process.on('unhandledRejection', …)` handler, as a long-running Node service should anyway: the rejection is then logged, and the run's own failure arrives through `invoke` or `stream`. `durability: 'sync'` narrows the window — every write is awaited at the end of its superstep, so a rejection reaches `invoke` unless another task of the same superstep is still running when it fires — and is the right setting where a failed write must be reported before the next step runs. Neither makes the write itself safer; they decide only how its failure is reported.
|
|
918
|
+
|
|
947
919
|
## Logging
|
|
948
920
|
|
|
949
921
|
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`):
|
|
@@ -967,7 +939,7 @@ const store = new DynamoDBStore({ tableName: 'langgraph', logger: redactLogger(l
|
|
|
967
939
|
|
|
968
940
|
`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.
|
|
969
941
|
|
|
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', '
|
|
942
|
+
**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', 'checkpointId', 'checkpointNs', 'sessionId', 'namespace', 'namespacePrefix', 'prefix', 'key', 'sortKey', 'partitionKey', 'channel', 'expected', 'found', 'bucket']` — every field a log event carries an identifier, a channel name or a bucket name in — when your deployment treats identifiers as personal data.
|
|
971
943
|
|
|
972
944
|
**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:
|
|
973
945
|
|
|
@@ -987,19 +959,21 @@ const logger: Logger = {
|
|
|
987
959
|
};
|
|
988
960
|
```
|
|
989
961
|
|
|
990
|
-
**What the library logs.** Nothing until a logger is injected. Every `error` and `warn` below is actionable; the table is
|
|
962
|
+
**What the library logs.** Nothing until a logger is injected. Every `error` and `warn` below is actionable; the table is written by hand and checked against the code by a static test, which fails when an event is added without a row, when a row names a level or field the call does not emit, or when a row outlives its event. `debug` carries retries (`retrying after a transient error`, with the attempt, the delay and the error name), lost-response commits, duplicate pending writes that were skipped, and `ensureS3LifecycleRule`'s wait for a lifecycle write to propagate — one line while a re-read still shows the configuration from before the write, and one when a re-read shows a different configuration and the rules are written again.
|
|
991
963
|
|
|
992
964
|
| Level | Message | Fields | Meaning and what to do |
|
|
993
965
|
| --- | --- | --- | --- |
|
|
994
|
-
| `error` | `history.addMessages rollback failed; messageCount may have drifted` | `sessionId`, `committedChunks` | a
|
|
966
|
+
| `error` | `history.addMessages rollback failed; messageCount may have drifted` | `sessionId`, `committedChunks` | a chunk of an append failed and undoing the chunks that had committed failed too (`COMPENSATION_FAILED`) — their delete, or the session-row revert that follows it (the count, or the session itself when this append created it); run `reconcileMessageCount` for the session once it is idle |
|
|
967
|
+
| `error` | `history.addMessages could not tell whether a failed chunk committed; messageCount may have drifted` | `sessionId`, `committedChunks`, `reason` | a chunk's read-back failed, or some attempt of it may still be applied (no answer, `TransactionInProgressException`, or a 5xx) — `reason` names that failure; the other chunks were rolled back, this one's objects were kept, and the call fails with `COMPENSATION_FAILED` (or `ABORTED` on a cancel). Run `reconcileMessageCount` once the session is idle |
|
|
995
968
|
| `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 |
|
|
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
|
|
969
|
+
| `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 — reclaimed by the lifecycle rule with a `ttl` set, or reported by `scripts/find-orphaned-payloads.mjs` and removed with `--delete` without one |
|
|
997
970
|
| `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 |
|
|
998
971
|
| `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 |
|
|
972
|
+
| `warn` | `ensureS3LifecycleRule: wrote the lifecycle rules but a re-read did not show them within the polling window` | `bucket`, `prefix` | S3 documents that a lifecycle configuration can take a few minutes to propagate, so this is most likely lag rather than a lost write; the rules were written — call `ensureS3LifecycleRule()` again later to confirm |
|
|
999
973
|
| `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
974
|
| `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
975
|
| `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 |
|
|
1002
|
-
| `warn` | `Some orphaned S3 objects could not be deleted after` | `failedCount` | objects leaked after a failed write or a delete; `ensureS3LifecycleRule()`
|
|
976
|
+
| `warn` | `Some orphaned S3 objects could not be deleted after` | `failedCount` | objects leaked after a failed write or a delete; reclaimed by `ensureS3LifecycleRule()` when a `ttl` is set, and found by `scripts/find-orphaned-payloads.mjs` when none is |
|
|
1003
977
|
| `warn` | `Failed to clean up orphaned S3 objects after` | `reason` | the cleanup itself failed after retries; same remedy |
|
|
1004
978
|
| `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 |
|
|
1005
979
|
| `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 |
|
|
@@ -1019,12 +993,14 @@ const logger: Logger = {
|
|
|
1019
993
|
| `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 |
|
|
1020
994
|
| `warn` | `list: skipped a row that is not a checkpoint meta item` | `sortKey` | a foreign row shares the `META#` prefix on a shared table |
|
|
1021
995
|
| `warn` | `getTuple: skipped a row that is not a checkpoint meta item` | `sortKey` | same, on the read-your-writes path |
|
|
996
|
+
| `warn` | `getTuple: skipped a row that is not a pending-write item` | `sortKey` | a row under a checkpoint's `WRITE#` prefix lacks the task id, channel or payload this adapter writes, so something else put it there; it is left out of `pendingWrites`. Find and remove the row |
|
|
1022
997
|
| `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
998
|
| `warn` | `store.get: ignored a row that is not a store item` | `partitionKey`, `sortKey` | a foreign row at a store key |
|
|
1024
999
|
| `warn` | `reconcileVectorIndex: skipped a row that is not a store item` | `sortKey` | same, during reconciliation |
|
|
1025
1000
|
| `info` | `: deleted rows` | `deleted`, `skipped` | `deleteThread`/`clear` finished |
|
|
1026
1001
|
| `info` | `reconcileVectorIndex prune skipped: backend has no listKeys` | `prefix` | the backend cannot enumerate vectors, so stale ones were not pruned |
|
|
1027
1002
|
| `info` | `reconcileVectorIndex: kept a vector whose item reappeared` | `namespace`, `key` | an item was written while pruning; nothing to do |
|
|
1003
|
+
| `info` | `store.put: left the vector index to the write that superseded this put` | `namespace`, `key` | another put or a delete of the same item landed between this put's row and its vector sync, or the read that checks did not answer; the write that superseded it syncs its own vector, and `reconcileVectorIndex` repairs any gap |
|
|
1028
1004
|
| `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 |
|
|
1029
1005
|
|
|
1030
1006
|
## Tracing and metrics
|
|
@@ -1033,7 +1009,7 @@ This package has no tracing or metrics integration of its own: it depends on no
|
|
|
1033
1009
|
|
|
1034
1010
|
**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
1011
|
|
|
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.
|
|
1012
|
+
**The logger.** Every DynamoDB request retry and S3 transfer 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
1013
|
|
|
1038
1014
|
**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:
|
|
1039
1015
|
|
|
@@ -1047,7 +1023,7 @@ declare function recordLatency(command: string | undefined, ms: number): void;
|
|
|
1047
1023
|
const base = new DynamoDBClient({
|
|
1048
1024
|
region: 'eu-west-1',
|
|
1049
1025
|
maxAttempts: 1,
|
|
1050
|
-
requestHandler: { requestTimeout: 10_000, throwOnRequestTimeout: true },
|
|
1026
|
+
requestHandler: { requestTimeout: 10_000, socketTimeout: 5_000, throwOnRequestTimeout: true },
|
|
1051
1027
|
});
|
|
1052
1028
|
base.middlewareStack.add(
|
|
1053
1029
|
(next, context) => async (args) => {
|
|
@@ -1076,19 +1052,19 @@ Each section below describes one capability and what it promises; the options it
|
|
|
1076
1052
|
|
|
1077
1053
|
### Gzip compression
|
|
1078
1054
|
|
|
1079
|
-
Set `compression: { enabled: true }`. Payloads
|
|
1055
|
+
Set `compression: { enabled: true }`. Payloads from `minSizeBytes` up to `maxDecompressedBytes` (default 1 KB–50 MiB, ceiling 512 MiB each) are gzipped transparently when that saves more than 10%; a payload larger than `maxDecompressedBytes` is stored uncompressed instead, so a reader configured with the same cap never refuses a payload written with it. The stored descriptor records whether a payload was compressed, so reads never infer it from the bytes, and decompression itself is guarded against decompression-bomb expansion by that same `maxDecompressedBytes` cap.
|
|
1080
1056
|
|
|
1081
1057
|
### S3 offloading
|
|
1082
1058
|
|
|
1083
|
-
Set `s3: { bucketName }`. Any
|
|
1059
|
+
Set `s3: { bucketName }`. Any stored payload — serialized, then compressed when `compression` is on — 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, and a row they would take past it is refused with `VALIDATION` naming `index` before anything is written. 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 inline-vector budget and every reason it is opt-in: [Guide → S3 offloading](docs/guide.md#s3-offloading).
|
|
1084
1060
|
|
|
1085
1061
|
### Overwrite races and orphaned objects
|
|
1086
1062
|
|
|
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 —
|
|
1063
|
+
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 — reclaimed by the rule `ensureS3LifecycleRule()` writes when a `ttl` is set, and found by `scripts/find-orphaned-payloads.mjs` when none is ([Finding objects no row names](#finding-objects-no-row-names)). 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
1064
|
|
|
1089
1065
|
### Write idempotency
|
|
1090
1066
|
|
|
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).
|
|
1067
|
+
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 and the two session-row writes that roll a failed append back, `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
1068
|
|
|
1093
1069
|
### What a token guarantees, and what it does not
|
|
1094
1070
|
|
|
@@ -1096,7 +1072,7 @@ A write whose first attempt **committed** applies exactly once; a write **reject
|
|
|
1096
1072
|
|
|
1097
1073
|
### What a token costs
|
|
1098
1074
|
|
|
1099
|
-
A
|
|
1075
|
+
A transaction costs 2 write units per KB where a plain write costs 1, on every write this package sends as a transaction (`saver.put`, every `addMessages` chunk, `store.delete`, and an offloaded `store.put` or `putWrites`), 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
1076
|
|
|
1101
1077
|
### What a partition delete promises
|
|
1102
1078
|
|
|
@@ -1108,7 +1084,7 @@ One conditional `DeleteItem` per row — about 25× the requests an unconditiona
|
|
|
1108
1084
|
|
|
1109
1085
|
### TTL expiry
|
|
1110
1086
|
|
|
1111
|
-
Set `ttl: { days }` or `{ seconds }`;
|
|
1087
|
+
Set `ttl: { days }` or `{ seconds }`; readers hide a row past its `ttl` (a checkpoint's payload and pending-write rows are served only while its metadata row is live), so nothing expired comes back during DynamoDB's sweep lag, and a store `put` over such a row creates the item afresh rather than inheriting the dead item's `createdAt`. 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 and anchored when the session is created — an active conversation expires `ttl` after it began, not after its last message. That is the contract for 1.x: a session's messages expire together, `ttl` after the session was created. A sliding expiry would arrive as an opt-in option, never as a change of the default. 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
1088
|
|
|
1113
1089
|
### Plain (metadata) search
|
|
1114
1090
|
|
|
@@ -1116,7 +1092,7 @@ A `search()` call with no `query` (or with neither `index` nor `vectorBackend` c
|
|
|
1116
1092
|
|
|
1117
1093
|
### Semantic search
|
|
1118
1094
|
|
|
1119
|
-
Give the store an `index` with a LangChain `Embeddings` implementation: each
|
|
1095
|
+
Give the store an `index` with a LangChain `Embeddings` implementation: each extracted text 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
1096
|
|
|
1121
1097
|
### Vector index consistency
|
|
1122
1098
|
|
|
@@ -1124,7 +1100,7 @@ When a `vectorBackend` is configured, DynamoDB holds the canonical item and the
|
|
|
1124
1100
|
|
|
1125
1101
|
### Checkpointer semantics
|
|
1126
1102
|
|
|
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,
|
|
1103
|
+
`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, regular writes at most 32 at a time and special-channel writes alongside them, 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
1104
|
|
|
1129
1105
|
### Chat history semantics
|
|
1130
1106
|
|
|
@@ -1138,7 +1114,7 @@ The store follows the reference semantics, and every observable difference is li
|
|
|
1138
1114
|
|
|
1139
1115
|
### Strong consistency
|
|
1140
1116
|
|
|
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.
|
|
1117
|
+
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`; `store.search` too) stay eventually consistent for lower cost — so `graph.getStateHistory()` or a replay driven by `saver.list` right after a run can, for a moment, miss its newest checkpoint, while `graph.getState()` and a resumed run, which read through `getTuple`, never do.
|
|
1142
1118
|
|
|
1143
1119
|
## Known limitations
|
|
1144
1120
|
|
|
@@ -1146,9 +1122,9 @@ Each item below is a deliberate limit, not a known defect, and each links to the
|
|
|
1146
1122
|
|
|
1147
1123
|
### From DynamoDB and S3
|
|
1148
1124
|
|
|
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
|
|
1125
|
+
- **400 KB items.** A DynamoDB item holds at most 400 KB, so without `s3` a payload over 392 KB after compression 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; a row they would push over is refused with a `VALIDATION` error naming `index`. ([Limits](#limits), [S3 offloading](#s3-offloading))
|
|
1150
1126
|
- **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.
|
|
1127
|
+
- **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. Readers hide a metadata, store or history row past its own `ttl` in the meantime, and a checkpoint's payload and pending-write rows follow its metadata row; the storage is reclaimed later. ([TTL expiry](#ttl-expiry), [S3 lifecycle rules](#s3-lifecycle-rules))
|
|
1152
1128
|
- **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
1129
|
- **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
1130
|
- **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))
|
|
@@ -1164,11 +1140,14 @@ Each item below is a deliberate limit, not a known defect, and each links to the
|
|
|
1164
1140
|
- **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
1141
|
- **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
1142
|
- **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`
|
|
1143
|
+
- **Identifiers follow this package's key rules.** No `#` and no control character; at most 1024 bytes for `thread_id` and `sessionId`, 512 bytes for `checkpoint_ns`, 256 bytes for every other segment but a pending-write channel, which may fill what its 1024-byte sort key leaves; 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. The rules reach a graph's own names, because LangGraph hands them to the saver as identifiers: a state key, and the node a step hands over to, are pending-write channels, and the nodes on the path to a subgraph are its `checkpoint_ns` — so a graph whose state key or node name holds `#` is refused on its first step, naming `channel` or `checkpoint_ns`. ([Production notes](#production-notes), [Limits](#limits))
|
|
1168
1144
|
- **`getDeltaChannelHistory()` tracks an upstream beta API,** so a change there can reach a minor release of this package. ([Not covered](#not-covered))
|
|
1169
1145
|
- **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
|
-
-
|
|
1171
|
-
|
|
1146
|
+
- **Maintained by one person.** Response times are best effort. ([Versioning and support](#versioning-and-support))
|
|
1147
|
+
|
|
1148
|
+
### From LangGraph
|
|
1149
|
+
|
|
1150
|
+
- **On LangGraph 1.4.x, a write that fails mid-run is an unhandled rejection under the default `durability`.** LangGraph awaits the saver's writes at the end of the run (`'async'`, the default) or of the superstep (`'sync'`), so a `put` or `putWrites` that rejects before then rejects a promise nothing is yet listening to; with Node's defaults that ends the process. Handle `unhandledRejection` at the process level, and prefer `durability: 'sync'` where a failed write must stop the run. LangGraph 1.5.0 reports the failure as the run's own instead. ([Errors raised inside a graph run](#errors-raised-inside-a-graph-run))
|
|
1172
1151
|
|
|
1173
1152
|
## Migrating
|
|
1174
1153
|
|
|
@@ -1211,7 +1190,7 @@ const after = graph.compile({
|
|
|
1211
1190
|
const sessionAfter = history.forSession('session-1');
|
|
1212
1191
|
```
|
|
1213
1192
|
|
|
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.
|
|
1193
|
+
**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, and so is a graph whose state keys or node names hold `#` ([Known limitations](#known-limitations)) — `store.put()` refusing a `null` value, where the reference treats it as a delete, and `$gt`/`$gte`/`$lt`/`$lte` comparing like types only.
|
|
1215
1194
|
|
|
1216
1195
|
**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
1196
|
|
|
@@ -1246,10 +1225,118 @@ The walk is paged because `BaseStore`'s own `search` answers ten items when no `
|
|
|
1246
1225
|
|
|
1247
1226
|
`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
1227
|
|
|
1249
|
-
The items are written with `batch` rather than `put`: `put()` alone applies upstream's
|
|
1228
|
+
The items are written with `batch` rather than `put`: `put()` alone applies upstream's `"langgraph"` root rule, which a graph's own writes through `batch()` do not meet. A `.` in a label is refused on every write, so a source namespace such as `['memories', 'jane.doe@example.com']` fails its batch with a `VALIDATION` error naming `namespace element` — as it would with every reference store from `@langchain/langgraph-checkpoint` 1.2.0 — and has to be renamed before it is copied. 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
1229
|
|
|
1251
1230
|
### Migrating from earlier versions
|
|
1252
1231
|
|
|
1232
|
+
**0.9.x → 1.0.0.** No data migration: `1.0.0` reads a table `0.9.x` wrote as it is. The attributes `1.0.0` adds are additive:
|
|
1233
|
+
- the row format version `v`;
|
|
1234
|
+
- `writeId`;
|
|
1235
|
+
- `schemaVersion` inside payload descriptors;
|
|
1236
|
+
- the recency-index keys on checkpoint `META` and history `SESSION` rows.
|
|
1237
|
+
|
|
1238
|
+
Before upgrading a live table, take a backup (on-demand or point-in-time recovery). `0.9.x` has not been tested against rows `1.0.0` writes, so a rollback should restore the table, not read it with the older release. Run one version against a table at a time.
|
|
1239
|
+
|
|
1240
|
+
What your code has to change, most common first:
|
|
1241
|
+
|
|
1242
|
+
- **Errors are one class, branched on by `code`.**
|
|
1243
|
+
- `DynamoDbLangGraphError` is now `DynamoDBLangGraphError` (note the capital *B*), with no alias.
|
|
1244
|
+
- The subclasses `AbortError`, `BatchWriteAllIncompleteError`, `BatchWriteIncompleteError`, `CompensationFailedError`, `ConflictError`, `ResultTruncatedError`, `RetryExhaustedError` and `ValidationError` are gone. Test with `isDynamoDBLangGraphError(error)` and branch on `error.code`; `error.name` is always `'DynamoDBLangGraphError'`.
|
|
1245
|
+
- A validation error names the offending input in `context.field`, not in `context.operation`.
|
|
1246
|
+
- The counts a batch error and a compensation error carried (`succeededCount`, `unprocessed`, `failedChunks`, `rollbackError` and the rest) are under `details`, and `JSON.stringify(error)` nests them there.
|
|
1247
|
+
- Raw AWS SDK errors no longer escape. Each is wrapped with its classified code (`THROTTLED`, `SERVICE_UNAVAILABLE`, `CONTENTION`, `ACCESS_DENIED`, `NOT_FOUND`, `AWS_REJECTED`, `AWS_REQUEST_FAILED`), and `context.awsErrorName`, `requestId` and `httpStatusCode` are lifted off the SDK error. Code that matched `error.name === 'AccessDeniedException'` reads `error.code` or `error.context.awsErrorName`.
|
|
1248
|
+
- `FORMAT_UNSUPPORTED`, `ANCESTOR_EXPIRED`, `PAYLOAD_CORRUPT` and `UNEXPECTED_ERROR` are new codes too (`S3_OFFLOAD_FAILED` already existed in `0.9`).
|
|
1249
|
+
- `history.addMessages` fails with `COMPENSATION_FAILED` whenever a chunk's outcome cannot be established, a single-message append included, since it is one chunk. It used to roll back and rethrow the chunk's own error, which tells a caller retrying on ordinary errors that the session is back where it began. Do not retry on `COMPENSATION_FAILED`; run `reconcileMessageCount` ([decision record 25](docs/decisions/0025-treat-a-write-that-got-no-answer-as-one-that-may-still-land.md)).
|
|
1250
|
+
- `destroy()` on an adapter is idempotent and raises a `DynamoDBLangGraphError` (`UNEXPECTED_ERROR`, or the AWS code) when a client fails to close, with the client's error as `cause`; the `destroy` that `createAll` returns is idempotent too, but logs an adapter that failed to release at `warn` and never throws. Earlier releases let the raw error escape.
|
|
1251
|
+
- [Error handling](#error-handling) lists every code.
|
|
1252
|
+
|
|
1253
|
+
```ts
|
|
1254
|
+
import {
|
|
1255
|
+
DynamoDBStore,
|
|
1256
|
+
ErrorCode,
|
|
1257
|
+
isDynamoDBLangGraphError,
|
|
1258
|
+
} from '@farukada/aws-langgraph-dynamodb-ts';
|
|
1259
|
+
|
|
1260
|
+
const store = new DynamoDBStore({ tableName: 'langgraph' });
|
|
1261
|
+
|
|
1262
|
+
try {
|
|
1263
|
+
await store.put(['users', 'u1'], 'profile', { name: 'Ada' });
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
// 0.9 tested `error instanceof ValidationError` and read `error.context.operation`.
|
|
1266
|
+
if (isDynamoDBLangGraphError(error) && error.code === ErrorCode.VALIDATION) {
|
|
1267
|
+
console.warn(error.context.field);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
- **Dependencies.**
|
|
1273
|
+
- `@langchain/langgraph` is no longer a peer, so depend on it yourself.
|
|
1274
|
+
- The peer floors are `@langchain/core` `^1.2.17` (was `^1.2.9`) and `@langchain/langgraph-checkpoint` `^1.2.0` (was `^1.1.5`), the packages LangGraph 1.5 ships with — a LangGraph 1.4 application keeps working once it takes `@langchain/langgraph-checkpoint` 1.2.0, which LangGraph 1.4's own range admits — and the optional `@aws-sdk/client-s3` `^3.1151.0` (was `^3.900.0`).
|
|
1275
|
+
- The dependencies are `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` `^3.1151.0` (was `^3.1116.0`), and the new `@aws-sdk/util-dynamodb`.
|
|
1276
|
+
- **Inputs that are now refused with `VALIDATION`**, naming what is wrong. Each was accepted, ignored or reported as an AWS failure before:
|
|
1277
|
+
- an option key the adapter does not read (`options.<key>`) on every options object this package defines — the constructors', every `{ signal }`, `getMessages`' and the history window — and a non-object options value. The option objects LangGraph defines (`saver.list`'s, `getDeltaChannelHistory`'s, `store.search`'s and `store.listNamespaces`') ignore a key they do not read, so a LangGraph release that adds one cannot break the call ([decision record 28](docs/decisions/0028-ignore-keys-langgraph-adds-to-option-objects-it-defines.md));
|
|
1278
|
+
- a number past its ceiling (see [Limits](#limits)), and a read cap (`maxItems`, `maxIterations`) that is not an integer of at least 1;
|
|
1279
|
+
- an identifier over its byte cap (1024 bytes for `thread_id`/`sessionId`, 512 for `checkpoint_ns`, 256 for every other segment) or holding a control character. `0.9` already refused C0 characters and DEL; the byte caps and the C1 range (U+0080 to U+009F) are new, so a row already stored under a longer identifier can no longer be addressed;
|
|
1280
|
+
- a `ttl` that is not exactly one unit of at most five years;
|
|
1281
|
+
- an `s3.keyPrefix` that is not a real path ending in `/` (no empty, `.` or `..` segment), also from `ensureS3LifecycleRule()`, which refuses an empty or root prefix too;
|
|
1282
|
+
- an `s3.maxDownloadBytes` below `s3.thresholdBytes`, refused at construction, and an offloaded payload larger than `s3.maxDownloadBytes`, refused at the write (naming `payload`) before it is uploaded. Both used to fail only at the first read. A payload larger than `compression.maxDecompressedBytes` is now stored uncompressed instead of compressed past the cap;
|
|
1283
|
+
- a collaborator missing a method it must provide (`client`, `logger`, `serde`, `index.embeddings`, `vectorBackend`), and an injected `client` built with `unmarshallOptions.wrapNumbers` or `marshallOptions.convertEmptyValues: true`, naming `client`. Such a client silently misread or erased rows, so its translation config must stay at the defaults, and every adapter and `backfillRecencyIndex` refuse it;
|
|
1284
|
+
- a `limit` above 10 000, `saver.list(config, { limit: -1 })`, and `limit: 0` on `getMessages` and the `forSession` window;
|
|
1285
|
+
- on the saver, a non-object `config` or `configurable`, a checkpoint id of `0`, `false` or `NaN`, and a malformed `before`, `filter`, `checkpoint`, `writes` or `getDeltaChannelHistory` argument;
|
|
1286
|
+
- `store.put(namespace, key, null)` (call `delete` instead), an empty namespace or a `"langgraph"` root, a `.` in a namespace label on every write — a `batch()` put included, as upstream refuses one from `@langchain/langgraph-checkpoint` 1.2.0; a row under such a label stays readable and removable through the store's own methods, so rename it before moving to LangGraph 1.5 — and a non-string `query` or a non-object `filter` on `search`;
|
|
1287
|
+
- a store row over DynamoDB's 400 KB item limit, refused before anything is written and its upload released, naming `index` when the vectors pushed it over (a wildcard `index.fields` path yields one vector per element);
|
|
1288
|
+
- a `serde` that encodes a value to zero bytes, and `getMessages({ before })` with a date before the epoch or at or after 2^50 ms;
|
|
1289
|
+
- on the store, `indexName` and `indexShards`, which are unknown options now: drop them. No store read uses the recency index, and store rows no longer carry its keys.
|
|
1290
|
+
- **Behaviour you may observe:**
|
|
1291
|
+
- `history.listSessions()` returns `{ sessions, nextCursor? }`; read `.sessions`.
|
|
1292
|
+
- `history.forSession()` checks its arguments at the call, not inside the promise a runnable awaits.
|
|
1293
|
+
- `saver.list()` without a `thread_id` lists every thread (it threw).
|
|
1294
|
+
- `getTuple` for a config naming no thread answers `undefined`.
|
|
1295
|
+
- `saver.put()` of an existing `checkpoint_id` keeps the write that committed last, and a retry of an already-committed write is answered from DynamoDB's idempotency cache instead of landing again.
|
|
1296
|
+
- `store.search`, and a search inside `store.batch`, refuse a `null` `offset` or `limit`, which read as 0 and then the default.
|
|
1297
|
+
- `store.batch()` answers a put or a delete with `null`.
|
|
1298
|
+
- `store.delete()` reads before it deletes, and can resolve with the item still there ([V-29](#differences-from-the-reference-implementations)).
|
|
1299
|
+
- `deleteThread()` and `clear()` skip a row rewritten since their read.
|
|
1300
|
+
- A read that meets a row a newer release wrote raises `FORMAT_UNSUPPORTED` instead of skipping it.
|
|
1301
|
+
- `history.getMessages` reports a row this adapter did not write, an out-of-scope `s3Key` and a payload the serializer refuses (`VALIDATION`, `context.field` `'serde'`) under both `onCorruptMessage` policies, and fails on `COMPRESSION_LIMIT` under `'skip'` too (next item).
|
|
1302
|
+
- A `vectorBackend` search fails when it cannot re-read a match.
|
|
1303
|
+
- An offloaded write is a one-item transaction (two write units per KB), and a tokened write stops retrying 300 s in.
|
|
1304
|
+
- A cancel through an `AbortSignal` ends the request in flight and rejects with `ABORTED`.
|
|
1305
|
+
- **Rows written by an earlier release past a custom `compression.maxDecompressedBytes`.** Releases before `1.0.0` ignored that cap when compressing, so a payload could be stored compressed past a reader's custom cap. `COMPRESSION_LIMIT` is now a refusal, not payload loss: a history row written that way makes `getMessages` fail under the default `onCorruptMessage: 'skip'` as well, where it used to drop the message with an `error` log. Raise `compression.maxDecompressedBytes` on the reader to read such rows.
|
|
1306
|
+
- **Only if you ran `1.0.0-rc.2`** (a `0.9.x` table has none of this):
|
|
1307
|
+
- Store rows it wrote keep their `STORE`-tagged `gsi1pk`/`gsi1sk` until the item's next put or delete, or its TTL expiry. No reader sees them, but each keeps an `ALL`-projected copy in the index, billed as index storage. A one-off `UpdateItem` with `REMOVE gsi1pk, gsi1sk` over the `STORE#` rows reclaims it.
|
|
1308
|
+
- A session row it wrote with an id of 1000 to 1024 bytes, on a table without the index, has a `gsi1sk` over 1024 bytes. DynamoDB's index backfill leaves it out, and `backfillRecencyIndex()` skips it because it already has keys. Until its next `addMessages` rewrites the key, the session is missing from indexed listings and `reconcileMessageCount` on it is refused.
|
|
1309
|
+
- A row with a `checkpoint_ns` of 257 to 512 bytes, which `1.0.0` accepts, cannot be read by `rc.2`.
|
|
1310
|
+
- `SessionBackend`, the deprecated alias `rc.1` and `rc.2` exported, is removed (`0.9` never exported it): use `MultiSessionHistory`.
|
|
1311
|
+
- **`ensureS3LifecycleRule()` writes a different rule.** `0.9` set `Expiration.Days` to the `ttl` in whole days. `1.0.0` sets it to the `ttl` rounded up to whole days plus 2 (plain headroom: an object already expires at or after the `ttl` of every row naming it, so DynamoDB's TTL sweep lag does not matter), so a bucket you re-run it on has its rule rewritten and its objects expire two days or more later than before. It also requires a `keyPrefix` ending in `/`, adds a `NoncurrentVersionExpiration` (one day, or your longer existing value) and a delete-marker reclaim rule, and keeps a longer noncurrent-version retention the bucket already carries. Re-running it after upgrading applies all of this; see [S3 lifecycle rules](#s3-lifecycle-rules). If you lower the `ttl` in the same upgrade, lower it at once but re-run `ensureS3LifecycleRule()` only once the old `ttl` has elapsed since — the rewritten rule expires by age every object under the prefix, including those that rows written under the old value still name. Raising the `ttl` is safe at once.
|
|
1312
|
+
- **IAM:**
|
|
1313
|
+
- `dynamodb:Scan` is needed only by the table-wide reads (`saver.list()` without a `thread_id`, `history.listSessions()` without `indexName`, a rootless `store.search([])` or `listNamespaces()`, `backfillRecencyIndex()`).
|
|
1314
|
+
- `ensureS3LifecycleRule()` also reads `s3:GetBucketVersioning`.
|
|
1315
|
+
- `s3:ListBucket` is recommended ([IAM permissions](#iam-permissions)).
|
|
1316
|
+
- **Packaging:**
|
|
1317
|
+
- `import` loads an ES-module build and `require` a CommonJS one. Use named imports: a default import (`import pkg from …`) no longer resolves to the package's exports.
|
|
1318
|
+
- The tarball ships no source maps and declares `sideEffects: false`.
|
|
1319
|
+
- The `createClient` and `createS3Client` hooks are internal.
|
|
1320
|
+
- `package.json` is exported for tooling.
|
|
1321
|
+
|
|
1322
|
+
Worth adopting once upgraded:
|
|
1323
|
+
- the recency index (`indexName`, `indexShards`, `backfillRecencyIndex()` — run the backfill first) for the saver and the history;
|
|
1324
|
+
- `readConcurrency`, `retry` and `{ signal }` cancellation;
|
|
1325
|
+
- `getMessages({ limit, before })` and the `forSession` window;
|
|
1326
|
+
- `JSON_SERDE` for a checkpointer whose table's writers you do not trust ([Trust boundary](#trust-boundary));
|
|
1327
|
+
- the two operator sweeps ([Finding rows whose payload was released](#finding-rows-whose-payload-was-released), [Finding objects no row names](#finding-objects-no-row-names)); the second has a precondition (every object under its `--prefix` must belong to `--table`) and its `--delete` needs an explicit `--prefix`.
|
|
1328
|
+
|
|
1329
|
+
The CHANGELOG's sections from `1.0.0-rc.1` on hold every change in full.
|
|
1330
|
+
|
|
1331
|
+
**0.8.x → 0.9.0.** No migration: `0.8.0` data stays readable. The new `occurrence` attribute on checkpointer WRITE rows and `rev` on store rows are additive. The observable changes:
|
|
1332
|
+
- a malformed store `index` is refused at construction;
|
|
1333
|
+
- `vectorScoreDirection` is new, for a distance-native `vectorBackend`;
|
|
1334
|
+
- a stored `NaN` no longer satisfies `$lt`/`$lte`;
|
|
1335
|
+
- an unquoted credential value is redacted to the end of its line;
|
|
1336
|
+
- `list()` warns once past 10 000 rows scanned.
|
|
1337
|
+
|
|
1338
|
+
See the CHANGELOG's `0.9.0` section.
|
|
1339
|
+
|
|
1253
1340
|
**0.7.x → 0.8.0**: **every adapter's partition key is now adapter-tagged** —
|
|
1254
1341
|
`PK = <thread_id>` → `CHKPT#<thread_id>`, `PK = <namespace[0]>` →
|
|
1255
1342
|
`STORE#<namespace[0]>`, `PK = <sessionId>` → `HIST#<sessionId>` — and the
|
|
@@ -1311,12 +1398,12 @@ A LangGraph `BaseCheckpointSaver`. [Class page](docs/api/classes/DynamoDBSaver.m
|
|
|
1311
1398
|
| --- | --- | --- |
|
|
1312
1399
|
| [`new DynamoDBSaver(options)`](docs/api/classes/DynamoDBSaver.md#constructor) | `DynamoDBSaver` | Validates `options`, and builds its own client unless given a `client`. |
|
|
1313
1400
|
| [`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
|
|
1401
|
+
| [`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 reads the recency index when `indexName` is set, newest first across threads; without one it scans the table and yields in the table's own order — oldest first within a thread, threads unordered. A `VALIDATION` error surfaces from the first `.next()`. |
|
|
1315
1402
|
| [`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. |
|
|
1403
|
+
| [`putWrites(config, writes, taskId, taskPath?)`](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. `taskPath`, which LangGraph 1.5 passes, is stored and leads the order `getTuple` and `list` read the writes back in (V-33). |
|
|
1317
1404
|
| [`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
1405
|
| [`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
|
|
1406
|
+
| [`ensureS3LifecycleRule()`](docs/api/classes/DynamoDBSaver.md#ensures3lifecyclerule) | `Promise<void>` | Installs the two S3 lifecycle rules 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
1407
|
| [`destroy()`](docs/api/classes/DynamoDBSaver.md#destroy) | `void` | Releases the clients the saver built. Idempotent, and never closes an injected `client`. |
|
|
1321
1408
|
|
|
1322
1409
|
### DynamoDBStore
|
|
@@ -1331,7 +1418,7 @@ A LangGraph `BaseStore`. [Class page](docs/api/classes/DynamoDBStore.md).
|
|
|
1331
1418
|
| [`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
1419
|
| [`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
1420
|
| [`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. |
|
|
1421
|
+
| [`batch(operations)`](docs/api/classes/DynamoDBStore.md#batch) | `Promise<OperationResults<Op>>` | Runs operations in the order written, concurrently where they address different items, sharing one `readConcurrency` budget of payload decodes between them. Every operation is validated before any runs. |
|
|
1335
1422
|
| [`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
1423
|
| [`ensureS3LifecycleRule()`](docs/api/classes/DynamoDBStore.md#ensures3lifecyclerule) | `Promise<void>` | As on the saver. |
|
|
1337
1424
|
| [`destroy()`](docs/api/classes/DynamoDBStore.md#destroy) | `void` | As on the saver. |
|
|
@@ -1345,7 +1432,7 @@ Every session through one adapter: each method takes the `sessionId`. [Class pag
|
|
|
1345
1432
|
| --- | --- | --- |
|
|
1346
1433
|
| [`new DynamoDBChatMessageHistory(options)`](docs/api/classes/DynamoDBChatMessageHistory.md#constructor) | `DynamoDBChatMessageHistory` | Validates `options`, and builds its own client unless given a `client`. |
|
|
1347
1434
|
| [`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
|
|
1435
|
+
| [`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 chunk fails and either the rollback fails too or that chunk's own outcome could not be established. |
|
|
1349
1436
|
| [`addMessage(sessionId, message, options?)`](docs/api/classes/DynamoDBChatMessageHistory.md#addmessage) | `Promise<void>` | Appends one message. |
|
|
1350
1437
|
| [`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
1438
|
| [`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. |
|
|
@@ -1420,7 +1507,7 @@ aws dynamodb update-time-to-live \
|
|
|
1420
1507
|
--time-to-live-specification "Enabled=true,AttributeName=ttl"
|
|
1421
1508
|
```
|
|
1422
1509
|
|
|
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
|
|
1510
|
+
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 saver and the history. 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
1511
|
|
|
1425
1512
|
</details>
|
|
1426
1513
|
|
|
@@ -1441,9 +1528,10 @@ const saver = new DynamoDBSaver({
|
|
|
1441
1528
|
|
|
1442
1529
|
### S3 lifecycle rules
|
|
1443
1530
|
|
|
1444
|
-
`ensureS3LifecycleRule()` writes **two** rules, both scoped to the adapter's `keyPrefix
|
|
1445
|
-
|
|
1446
|
-
|
|
1531
|
+
`ensureS3LifecycleRule()` writes **two** rules, both scoped to the adapter's `keyPrefix`, when both
|
|
1532
|
+
`s3` and a `ttl` are configured. They are given verbatim here so **a deployment with a `ttl`** that
|
|
1533
|
+
manages its own lifecycle can reproduce them. **A deployment without a `ttl` must not copy these
|
|
1534
|
+
verbatim** — see the warning below the rules for why, and the safe shape to use instead.
|
|
1447
1535
|
|
|
1448
1536
|
The example below is the checkpointer's **default** `keyPrefix` — no adapter writes to the bare
|
|
1449
1537
|
`langgraph-checkpoints/` base by default; each defaults to its own sub-prefix
|
|
@@ -1470,7 +1558,7 @@ you set explicitly:
|
|
|
1470
1558
|
```
|
|
1471
1559
|
|
|
1472
1560
|
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
|
|
1561
|
+
`ttl` rounded up to whole days plus two days of headroom (not a bound on DynamoDB's TTL sweep lag) — 32 above is
|
|
1474
1562
|
`ttl: { days: 30 }` — and it governs the **current** version only. `NoncurrentDays` is the grace a
|
|
1475
1563
|
**released** payload gets: on a versioned bucket, releasing an object does not erase it, it becomes
|
|
1476
1564
|
a noncurrent version behind a delete marker for this window (24–48 h at the one-day floor), and the
|
|
@@ -1480,6 +1568,65 @@ reclaim, and a release is an ordinary delete with no recovery window at all —
|
|
|
1480
1568
|
reports the bucket's versioning state at `warn` rather than enforcing it, because versioning is the
|
|
1481
1569
|
operator's to enable, not this library's to require.
|
|
1482
1570
|
|
|
1571
|
+
**Lowering a `ttl` needs care; raising one does not.** The `Expiration.Days` clause expires
|
|
1572
|
+
**every** object under the prefix by its age, not only the objects written after the rule was: a
|
|
1573
|
+
re-run of `ensureS3LifecycleRule()` with a smaller `ttl` replaces `Days` with the smaller value, and
|
|
1574
|
+
S3 then expires, on the new schedule, objects that rows written under the old, longer `ttl` still
|
|
1575
|
+
name. Those rows are still live, and a read that needs their payloads fails.
|
|
1576
|
+
|
|
1577
|
+
Lowering the `ttl` is safe for new rows at once; the rule is not. Lower `ttl` now, and re-run
|
|
1578
|
+
`ensureS3LifecycleRule()` only once the old `ttl` has elapsed since, because until then rows
|
|
1579
|
+
written under the old value still name objects the shorter rule would expire. "Since" counts from
|
|
1580
|
+
when the last adapter configured with the old value stopped writing. That covers history too: a
|
|
1581
|
+
session keeps the expiry it was created with, so a message appended to it after the change still
|
|
1582
|
+
carries an expiry from the old value, and that expiry falls within the same window. Raising the
|
|
1583
|
+
`ttl` is safe at once: the rewritten rule keeps every object at least as long as before.
|
|
1584
|
+
|
|
1585
|
+
**Without a `ttl`, never write the `Expiration` clause above.** `ensureS3LifecycleRule()` is then a
|
|
1586
|
+
no-op — no row ever expires — so that clause has no row's `ttl` to correlate with: it deletes every
|
|
1587
|
+
live payload's **current** version once it turns that many days old, whether or not a row still
|
|
1588
|
+
names it. A deployment with `s3` and no `ttl` that wants
|
|
1589
|
+
[`scripts/find-orphaned-payloads.mjs`](#finding-objects-no-row-names)'s `--delete` to actually free
|
|
1590
|
+
storage — rather than only leave a delete marker that nothing then reclaims — adds just the
|
|
1591
|
+
noncurrent half, never `Expiration`, plus the marker-reclaim rule unchanged:
|
|
1592
|
+
|
|
1593
|
+
```json
|
|
1594
|
+
{
|
|
1595
|
+
"ID": "langgraph-ttl-langgraph-checkpoints-checkpointer",
|
|
1596
|
+
"Filter": { "Prefix": "langgraph-checkpoints/checkpointer/" },
|
|
1597
|
+
"Status": "Enabled",
|
|
1598
|
+
"NoncurrentVersionExpiration": { "NoncurrentDays": 1 }
|
|
1599
|
+
}
|
|
1600
|
+
```
|
|
1601
|
+
|
|
1602
|
+
```json
|
|
1603
|
+
{
|
|
1604
|
+
"ID": "langgraph-ttl-langgraph-checkpoints-checkpointer-markers",
|
|
1605
|
+
"Filter": { "Prefix": "langgraph-checkpoints/checkpointer/" },
|
|
1606
|
+
"Status": "Enabled",
|
|
1607
|
+
"Expiration": { "ExpiredObjectDeleteMarker": true }
|
|
1608
|
+
}
|
|
1609
|
+
```
|
|
1610
|
+
|
|
1611
|
+
The marker-reclaim rule is identical either way: `ExpiredObjectDeleteMarker` only ever reclaims a
|
|
1612
|
+
delete marker once its last noncurrent version has expired, never a current, live object, so it
|
|
1613
|
+
carries no risk with or without a `ttl`.
|
|
1614
|
+
|
|
1615
|
+
**Turning a `ttl` off does not remove the rule `ensureS3LifecycleRule()` already wrote.**
|
|
1616
|
+
`ensureS3LifecycleRule()` does nothing without a `ttl`, so a later call — with the `ttl` option simply
|
|
1617
|
+
dropped — is a no-op: it neither adds nor removes anything, and the `Expiration.Days` rule an
|
|
1618
|
+
earlier, `ttl`-configured call wrote keeps expiring every live payload's current version on its own
|
|
1619
|
+
schedule, unaware the `ttl` is gone. Identify it by its id — `langgraph-ttl-<slug of keyPrefix>`,
|
|
1620
|
+
where the slug is the `keyPrefix` with its trailing `/` trimmed and every character outside
|
|
1621
|
+
`[A-Za-z0-9-]` turned into `-`, or `default` when nothing usable is left — **and** a `Filter.Prefix` equal to your `keyPrefix`. Neither alone
|
|
1622
|
+
is enough: the slug is not injective (`a/b/` and `a-b/` share one id), and a rule with your prefix
|
|
1623
|
+
may be an operator's own. Remove the `Expiration` clause of that rule (or the whole rule, if the
|
|
1624
|
+
noncurrent-version grace beside it is no longer wanted either) from the bucket's lifecycle
|
|
1625
|
+
configuration yourself. Delete-marker reclaim lives in the paired `langgraph-ttl-<slug>-markers` rule
|
|
1626
|
+
(same test), which holds no `Expiration.Days`: leave it while released objects should still be
|
|
1627
|
+
reclaimed. If you still want a released object's storage reclaimed, replace it with
|
|
1628
|
+
the safe noncurrent-only shape above rather than leaving nothing at all.
|
|
1629
|
+
|
|
1483
1630
|
Full detail — exactly which existing lifecycle rules a floor is measured against and why it never
|
|
1484
1631
|
lowers, which fields survive a rewrite, and what "reported, never enforced" costs an operator who
|
|
1485
1632
|
does not read the `warn`: [Guide → S3 lifecycle rules in depth](docs/guide.md#s3-lifecycle-rules-in-depth).
|
|
@@ -1493,18 +1640,24 @@ Every adapter uses the **same simple key schema**: a string partition key `PK`,
|
|
|
1493
1640
|
| `PK` | String (HASH) | partition key |
|
|
1494
1641
|
| `SK` | String (RANGE) | sort key |
|
|
1495
1642
|
| `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
|
|
1497
|
-
| `gsi1sk` | String | (optional) recency-index sort key
|
|
1498
|
-
|
|
1499
|
-
Those two index attributes are always written
|
|
1500
|
-
carries a global secondary index on them and an
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
[Infrastructure setup](#infrastructure-setup) for the
|
|
1504
|
-
[Maintenance operations](#maintenance-operations) for the
|
|
1505
|
-
first.
|
|
1506
|
-
|
|
1507
|
-
|
|
1643
|
+
| `gsi1pk` | String | (optional) recency-index partition key; written to the rows the recency listings read — checkpointer `META` and history `SESSION`; store rows carry none |
|
|
1644
|
+
| `gsi1sk` | String | (optional) recency-index sort key: `<updatedAt>#<id>`, or `<updatedAt>#sha256:<hex of id>` when the verbatim form would pass DynamoDB's 1024-byte sort-key cap |
|
|
1645
|
+
|
|
1646
|
+
Those two index attributes are always written on checkpoint `META` and history
|
|
1647
|
+
`SESSION` rows. Until the table carries a global secondary index on them and an
|
|
1648
|
+
adapter names it with `indexName`, they cost only their own bytes, and every
|
|
1649
|
+
listing behaves exactly as before. So upgrading changes nothing until you
|
|
1650
|
+
create the index; see [Infrastructure setup](#infrastructure-setup) for the
|
|
1651
|
+
definition and [Maintenance operations](#maintenance-operations) for the
|
|
1652
|
+
backfill that must run first. A store row written by `1.0.0-rc.2` may still
|
|
1653
|
+
carry the two attributes. Nothing reads them, and the next put of that item
|
|
1654
|
+
drops them. On a table that already carries the GSI, those leftover keys hold
|
|
1655
|
+
an `ALL`-projected copy of the row in the index meanwhile, billed as index
|
|
1656
|
+
storage, until that put, a delete, or the row's TTL expiry; a one-off
|
|
1657
|
+
`UpdateItem` (`REMOVE gsi1pk, gsi1sk`) over the store's `STORE#` rows reclaims
|
|
1658
|
+
it sooner.
|
|
1659
|
+
|
|
1660
|
+
Payloads live under one reserved attribute per row kind (`checkpoint`, `metadata`, `value`, `message`) as a **payload descriptor**: `{ schemaVersion: 1, location: 'INLINE' | 'S3', serdeType, compressed, writeId, bytes | s3Key }`, where `writeId`, the id of the write that stored it, is absent on descriptors written before `1.0.0-rc.2`. This shape is a compatibility contract. Unknown fields are ignored, and a missing `schemaVersion` reads as 1. A higher `schemaVersion` is refused as `FORMAT_UNSUPPORTED` (field `schemaVersion`), and an unknown `location` as `VALIDATION` (field `descriptor`), rather than misread.
|
|
1508
1661
|
|
|
1509
1662
|
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
1663
|
|
|
@@ -1514,6 +1667,7 @@ Offloaded S3 keys are `<keyPrefix><the row's identifiers, each base64url-encoded
|
|
|
1514
1667
|
| --- | --- | --- |
|
|
1515
1668
|
| `Map`, `Set` | stored as `{}`; every entry is gone | round-trips as a real `Map`/`Set` |
|
|
1516
1669
|
| `Uint8Array` | an index-keyed object, `{"0":1,"1":2}` | round-trips as a `Uint8Array` |
|
|
1670
|
+
| a Node `Buffer` | `{ type: 'Buffer', data: [1, 2] }` — its own `toJSON` runs first | `{ type: 'Buffer', data: [1, 2] }` too — only a plain `Uint8Array` round-trips |
|
|
1517
1671
|
| `Date` | an ISO string | an ISO string — **neither** default round-trips one |
|
|
1518
1672
|
| `NaN`, `Infinity` | `null` | `null` |
|
|
1519
1673
|
| `-0` | `0` | `0` |
|
|
@@ -1601,6 +1755,12 @@ When S3 offloading is enabled, the role also needs the object actions under the
|
|
|
1601
1755
|
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
|
1602
1756
|
"Resource": "arn:aws:s3:::<bucket>/langgraph-checkpoints/*"
|
|
1603
1757
|
},
|
|
1758
|
+
{
|
|
1759
|
+
"Sid": "LangGraphS3List",
|
|
1760
|
+
"Effect": "Allow",
|
|
1761
|
+
"Action": ["s3:ListBucket"],
|
|
1762
|
+
"Resource": "arn:aws:s3:::<bucket>"
|
|
1763
|
+
},
|
|
1604
1764
|
{
|
|
1605
1765
|
"Sid": "LangGraphS3Lifecycle",
|
|
1606
1766
|
"Effect": "Allow",
|
|
@@ -1613,13 +1773,19 @@ When S3 offloading is enabled, the role also needs the object actions under the
|
|
|
1613
1773
|
}
|
|
1614
1774
|
```
|
|
1615
1775
|
|
|
1776
|
+
`s3:ListBucket` is recommended rather than required. Without it, S3 answers a download of a key that no longer exists with 403 `AccessDenied` instead of 404 `NoSuchKey` ([GetObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html)). The library then cannot tell a released object from a refused one:
|
|
1777
|
+
- `store.get`, `store.search` and `store.reconcileVectorIndex` still recover from a concurrent overwrite of a row they read, by re-reading it;
|
|
1778
|
+
- chat history's `onCorruptMessage: 'skip'` cannot recognise a gone object, so the read fails instead of skipping it.
|
|
1779
|
+
|
|
1780
|
+
The action lets the role list every key in the bucket, and those keys carry base64url identifiers. Where that matters, use a bucket dedicated to this package.
|
|
1781
|
+
|
|
1616
1782
|
`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
1783
|
|
|
1618
1784
|
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.
|
|
1619
1785
|
|
|
1620
1786
|
### Multi-tenant deployments
|
|
1621
1787
|
|
|
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
|
|
1788
|
+
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.
|
|
1623
1789
|
|
|
1624
1790
|
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.
|
|
1625
1791
|
|
|
@@ -1665,7 +1831,7 @@ Four measured facts bound what that means:
|
|
|
1665
1831
|
- 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
1832
|
- `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
1833
|
- **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
|
|
1834
|
+
- Two further `lc: 2` shapes are not constructor records at all and hand back no data: `{"lc":2,"type":"undefined"}` reads back as `undefined` — the key stays, holding `undefined` in place of whatever the row claimed — and `{"lc":2,"type":"delta_snapshot","value":…}` builds a LangGraph `DeltaSnapshot` around whatever the row put in `value`.
|
|
1669
1835
|
- 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
1836
|
- **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
1837
|
- **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.
|
|
@@ -1686,24 +1852,28 @@ Four measured facts bound what that means:
|
|
|
1686
1852
|
| Limit | Value | Ceiling | Where it bites |
|
|
1687
1853
|
| --- | --- | --- | --- |
|
|
1688
1854
|
| 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
|
|
1855
|
+
| DynamoDB item size | 400 KB | fixed | a payload over `thresholdBytes` (default 350 KB, ceiling 392 KB) must offload to S3; without `s3` a payload over 392 KB after compression is refused with a `VALIDATION` error before the write |
|
|
1856
|
+
| Inline vectors on a store row | up to about 10 bytes per dimension per extracted text | the 400 KB item | `VALIDATION` naming `index` at the write; index fewer fields, embed with fewer dimensions, or configure a `vectorBackend` |
|
|
1690
1857
|
| Partition identifiers (`thread_id`, `sessionId`) | 1024 bytes UTF-8 | fixed | `VALIDATION` |
|
|
1691
|
-
|
|
|
1858
|
+
| Checkpoint namespace (`checkpoint_ns`) | 512 bytes UTF-8 | fixed | `VALIDATION`; LangGraph grows it by about forty bytes plus the node name per subgraph level, so this admits roughly eight to twelve levels |
|
|
1859
|
+
| Sort-key segments (`checkpoint_id`, `taskId`, store namespace element, store `key`) | 256 bytes each, 1024 bytes composed | fixed | `VALIDATION`; a search `namespacePrefix` or listing `prefix`/`suffix` whose labels past the first fill the composed cap before any key is refused the same way, since no stored namespace can match it |
|
|
1860
|
+
| Pending-write channel | 1024 bytes, within the 1024-byte composed sort key; LangGraph names a fan-in's channel after every source node (`join:<a>+<b>+…:<end>`), so it outgrows a single segment | fixed | `VALIDATION` |
|
|
1692
1861
|
| S3 object key | 1024 bytes | fixed | identifiers are base64url-encoded into it, so long ids reach it first |
|
|
1693
1862
|
| `ttl` | none (no expiry) | 5 years | `VALIDATION` at construction |
|
|
1694
1863
|
| Chat-history append transaction | 99 messages or 3.5 MB per chunk | fixed | larger batches are split into chunks with caller-observed atomicity |
|
|
1695
1864
|
| 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
1865
|
| 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
1866
|
| Rows one store read holds in memory (`maxScanItems`) | 10 000 | 1 000 000 | `RESULT_TRUNCATED` |
|
|
1867
|
+
| Pages one store scan reads (`maxIterations`) | 1000 | none; `Infinity` asks for no cap | `RESULT_TRUNCATED` |
|
|
1698
1868
|
| Rows held in memory by `listSessions({ maxItems })` | 10 000 | none; `Infinity` asks for no cap | `RESULT_TRUNCATED` |
|
|
1699
1869
|
| Pages walked by `listSessions({ maxIterations })` | 1000 | none; `Infinity` asks for no cap | `RESULT_TRUNCATED` |
|
|
1700
1870
|
| 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` |
|
|
1871
|
+
| Decompressed payload (`compression.maxDecompressedBytes`) and buffered S3 object (`s3.maxDownloadBytes`) | 50 MiB each | 512 MiB each | `COMPRESSION_LIMIT` / `S3_OFFLOAD_FAILED` on a read; a write never produces a payload over either (see the nested options) |
|
|
1702
1872
|
| Smallest payload compressed (`compression.minSizeBytes`) | 1 KB | 512 MiB | a smaller payload is stored uncompressed; not an error |
|
|
1703
1873
|
| 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
1874
|
| Backoff delay (`retry.baseDelayMs`, `retry.maxDelayMs`) | 100 ms base, 5 s cap | 60 s each | latency, not an error |
|
|
1705
1875
|
| 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 |
|
|
1876
|
+
| Index shards per adapter (`indexShards`) | 8 | 1024 | an indexed listing issues at least one `Query` per shard, `readConcurrency` at a time; the saver and history only; `backfillRecencyIndex` takes the same ceiling and must be given the same value, which stays fixed for the table's life |
|
|
1707
1877
|
|
|
1708
1878
|
### What each operation costs
|
|
1709
1879
|
|
|
@@ -1711,7 +1881,7 @@ Requests per call, before retries, for every public method; "consistent" reads c
|
|
|
1711
1881
|
|
|
1712
1882
|
### Monitoring
|
|
1713
1883
|
|
|
1714
|
-
Alert on the
|
|
1884
|
+
Alert on the three `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`, `context.tableName` and `context.httpStatusCode`. For AWS Support, the `requestId` of the last AWS failure is in `context.requestId`, on a `RETRY_EXHAUSTED` as on a wrapped AWS error. 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
1885
|
|
|
1716
1886
|
### Production notes
|
|
1717
1887
|
|
|
@@ -1719,29 +1889,29 @@ Alert on the two `error` events and the five `warn` events that name an orphan o
|
|
|
1719
1889
|
- **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
1890
|
- **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
1891
|
- **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`)
|
|
1723
|
-
-
|
|
1892
|
+
- **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`), 512 bytes for `checkpoint_ns`, 256 bytes for every other sort-key segment, and for a pending-write channel whatever its 1024-byte sort key leaves — LangGraph names a fan-in's channel after every source node, `join:<a>+<b>+…:<end>`, so it is as long as the graph's names together (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. A pending-write channel is a name from the graph — a state key, or the node a step hands over to — and a subgraph's `checkpoint_ns` holds the node names on the path to it, so a graph's state keys and node names follow them too.
|
|
1893
|
+
- Of upstream's two further namespace rules, a `.` in a label is refused on every write — `store.put()` and a `batch()` put, which is how LangGraph reaches a store inside a graph — as `@langchain/langgraph-checkpoint` 1.2.0 refuses it on every route; `get`, `delete`, `search`, `listNamespaces` and a `batch()` read or delete still accept one, so a namespace such as `['memories', 'jane.doe@example.com']` that a graph wrote through `batch()` under `0.9.x` or a release candidate can be read, searched, listed and deleted through the store's own methods, and only not written to again (V-42); inside a graph on LangGraph 1.5, whose store wrapper refuses the label on every operation, such a row cannot be reached at all, so rename such namespaces before moving to it. The `"langgraph"` root is refused by `store.put()` alone, as `BaseStore.put` refuses it.
|
|
1724
1894
|
- 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
1895
|
- 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
1896
|
- 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
1897
|
- 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
1898
|
- 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
1899
|
- **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
|
|
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
|
|
1900
|
+
- **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 expiry timestamp, and readers hide a row past it, but it cannot make DynamoDB delete the row sooner. Readers hide metadata, store and history rows by their own `ttl`; a checkpoint's payload and pending-write rows are not checked against their own `ttl`, only served while the checkpoint's metadata row is live, and they carry the same or a later `ttl`, so they are not served past the checkpoint's expiry (a pre-v4 checkpoint's migration is the exception: it reads its parent's pending writes under the child's metadata row).
|
|
1901
|
+
- The matching S3 lifecycle rule is not written automatically: it is installed only when you call `ensureS3LifecycleRule()`. That rule expires objects by age, at or after `ceil(ttl in days) + 2` days after creation, which is at or after the `ttl` of any row naming the object, so DynamoDB's physical-deletion lag does not matter. The two days are headroom, not a bound on that lag.
|
|
1732
1902
|
- 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
|
|
1903
|
+
- 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 with a `ttl` that manages its own lifecycle.
|
|
1904
|
+
- `ensureS3LifecycleRule()` is a read-modify-write of the bucket's whole lifecycle configuration, which S3 serves eventually consistently — and AWS documents specifically that a lifecycle configuration can take a few minutes to fully propagate ([S3 docs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/how-to-set-lifecycle-configuration-intro.html)). It re-reads after writing: a re-read that still shows the same rules — by id and the fields this call manages — as the read behind the last write is that propagation lag, and prompts another wait rather than another write; a re-read that instead shows a *different* configuration, still without its rules, is a competing writer, and it rewrites, merged with whatever that read now holds. `CONTENTION` fires only when every one of the five rounds this polls for needs a write — a competing writer replacing the configuration on every single re-read, never once leaving it long enough for lag alone to explain what was seen. A last round that rewrites without reaching that count, or that finds only lag, ends the same way instead: a `warn` and a normal return — the rules were written, only their visibility could not be confirmed in the few seconds this polls for. The 1/2/4/8 s schedule and the five-round limit are this library's own policy, not an AWS-documented bound. None of this protects a call whose own *first* read already raced a different call's write, so call it sequentially all the same — one adapter or process at a time — and, when more than one shares a bucket, run each again after a few minutes once every one of them has run.
|
|
1735
1905
|
|
|
1736
1906
|
### Maintenance operations
|
|
1737
1907
|
|
|
1738
1908
|
Four tools repair or provision state and are meant for deployment scripts and operators, not request paths:
|
|
1739
1909
|
|
|
1740
|
-
- **`ensureS3LifecycleRule()`** (all three adapters) — installs the S3 lifecycle
|
|
1910
|
+
- **`ensureS3LifecycleRule()`** (all three adapters) — installs the two S3 lifecycle rules that match 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 — and `CONTENTION` when every one of the five rounds it polls needs a write, a competing writer replacing the configuration on every single re-read — so call it once at deployment time, from a role that holds the two lifecycle actions, and treat a failure as a deployment failure. When it writes the rules but a re-read never shows them within its polling window, that is more likely propagation lag than a lost write — S3 documents that a lifecycle configuration can take minutes — so it logs a `warn` and returns instead of throwing; run it again later to confirm. 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
1911
|
- **`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
|
|
1912
|
+
- **`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 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. A session `1.0.0-rc.2` or earlier wrote with an id long enough that `<updatedAt>#<id>` alone would pass 1024 bytes keeps that over-length `gsi1sk` after the upgrade; this tool leaves it alone too, since it already carries `gsi1pk`, so the row stays excluded from the index — and `reconcileMessageCount` on it keeps failing — until its own next `history.addMessages` call rewrites the key under the new digest form.
|
|
1743
1913
|
- 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
|
|
1914
|
+
- `indexShards` must match what the saver and the history use. The backfill writes keys only to rows that have none, so it cannot re-shard a table written with another count. 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` or whose translation would change how a row reads back, 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
1915
|
- `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
1916
|
- 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
1917
|
- 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.
|
|
@@ -1749,15 +1919,39 @@ Four tools repair or provision state and are meant for deployment scripts and op
|
|
|
1749
1919
|
|
|
1750
1920
|
### What can still go wrong
|
|
1751
1921
|
|
|
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
|
|
1922
|
+
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 specific shape 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
1923
|
|
|
1754
1924
|
### Finding rows whose payload was released
|
|
1755
1925
|
|
|
1756
1926
|
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).
|
|
1757
1927
|
|
|
1928
|
+
### Finding objects no row names
|
|
1929
|
+
|
|
1930
|
+
Without a `ttl` nothing reclaims an orphaned object. Such objects come from:
|
|
1931
|
+
- a write that may still land after failing — no answer, or DynamoDB answering `TransactionInProgressException` or a server error — and kept its upload;
|
|
1932
|
+
- a write whose read-back itself failed, so its own uploads could not be confirmed unreferenced;
|
|
1933
|
+
- a best-effort delete that failed;
|
|
1934
|
+
- an exhausted compare-and-swap.
|
|
1935
|
+
|
|
1936
|
+
`scripts/find-orphaned-payloads.mjs` **in the repository** finds them. Like the stranded-row sweep, it is deliberately not in the npm tarball. It lists the offload prefix, reads each object's backlink and then its row, and reports every object older than `--min-age-hours` (at least 1, default 24) as one of: `row-gone` or `row-names-another-object` (deletable orphans), or `row-expired` (the row's `ttl` has passed but DynamoDB has not yet removed it — a checkpoint's PAYLOAD and pending-WRITE rows are served without checking their own `ttl`, so such a row may still be read; **never deleted** here).
|
|
1937
|
+
|
|
1938
|
+
**Precondition: every object under `--prefix` must belong to `--table`.** Neither a key nor a backlink carries a table name.
|
|
1939
|
+
- The default prefix is the parent of every adapter's own default `keyPrefix` (`langgraph-checkpoints/{checkpointer,store,history}/`), so it is safe only when **one** table backs every adapter under it.
|
|
1940
|
+
- With a table per adapter, sweep each adapter's own `keyPrefix` against its own table, not the shared base prefix.
|
|
1941
|
+
- Tables that share a bucket need `keyPrefix` values that are non-overlapping — neither one a prefix of the other — regardless: `app/` for one table and `app/store/` for another still lists the nested adapter's live objects as the first table's orphans.
|
|
1942
|
+
|
|
1943
|
+
`--delete` **requires an explicit `--prefix`**; the default applies to report-only runs only. It also refuses to run when there is something to delete but no checked object had evidence of the right table — a live row, or an expired row that still names *that exact object* — in `--table`. A row found at the backlinked key that names a *different* object is not evidence: object ids are unique per write, so a table written independently of this one never names this bucket's exact key, while the right table's rows do; a `--table` that merely collides with one unrelated row at a backlinked key (ordinary, since store keys are deterministic) is still refused. A table restored from a point-in-time or backup copy of this one, or seeded from it, is not independent and passes: every object written after the copy was taken would be reported as an orphan, so never point `--table` at one. This is the signature of a `--table` or `--prefix` that matches nothing here; it cannot catch a prefix shared with another table's live objects (only an explicit, adapter-scoped `--prefix` does that), and it cannot tell that apart from a `--table` that is genuinely correct where every one of these objects really is an orphan (for example, after every thread under this prefix was deleted) — the remedy there is to confirm from the report and delete the flagged keys directly, with the AWS CLI or console.
|
|
1944
|
+
|
|
1945
|
+
On a versioned bucket, `--delete` leaves a delete marker rather than erasing an object outright; freeing that storage still needs a `NoncurrentVersionExpiration` rule plus the delete-marker-reclaim rule — **never** the `Expiration.Days` clause `ensureS3LifecycleRule()` writes, which deletes live payloads outright without a `ttl` ([the safe shape](#s3-lifecycle-rules)). An object from before `1.0.0-rc.2` carries no backlink at all, so it is always reported `UNREADABLE`; a flood of those on an upgraded bucket is expected, not a fault. It needs:
|
|
1946
|
+
- `s3:ListBucket` and `s3:GetObject` on the bucket;
|
|
1947
|
+
- `dynamodb:GetItem` on the table;
|
|
1948
|
+
- `s3:DeleteObject` as well, to delete.
|
|
1949
|
+
|
|
1950
|
+
These already sit on the documented application role too, but attribute them to whoever runs this script — an operator's own session, not the always-running application. Full detail: [Guide → Finding objects no row names](docs/guide.md#finding-objects-no-row-names).
|
|
1951
|
+
|
|
1758
1952
|
### Lambda and other short-lived runtimes
|
|
1759
1953
|
|
|
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).
|
|
1954
|
+
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): each chunk of 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).
|
|
1761
1955
|
|
|
1762
1956
|
### Multi-tenancy
|
|
1763
1957
|
|
|
@@ -1769,7 +1963,7 @@ This package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
1769
1963
|
|
|
1770
1964
|
### The public API
|
|
1771
1965
|
|
|
1772
|
-
The public API is everything exported from the package entry point (`dist/index.js`
|
|
1966
|
+
The public API is everything exported from the package entry point (`dist/esm/index.js` for `import` and `dist/cjs/index.js` for `require`, each beside its `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
1967
|
|
|
1774
1968
|
- A **minor** may add exports, add optional options and parameters, add optional fields to returned objects, and widen accepted inputs.
|
|
1775
1969
|
- A **patch** changes behaviour only to fix a defect against the documented behaviour.
|
|
@@ -1790,12 +1984,12 @@ Every row carries `v`, its format version; a row whose `v` is higher than the re
|
|
|
1790
1984
|
| --- | --- | --- |
|
|
1791
1985
|
| Node.js | 22, 24 and 26 | the unit tier on Linux, macOS and Windows |
|
|
1792
1986
|
| 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.
|
|
1987
|
+
| `@langchain/langgraph-checkpoint` | `^1.2.0` | the conformance tier against the floor and the latest release, including LangChain's checkpointer validation suite |
|
|
1794
1988
|
| `@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.
|
|
1989
|
+
| `@langchain/core` | `^1.2.17` | the differential and history tests |
|
|
1796
1990
|
| AWS SDK v3 (`@aws-sdk/client-dynamodb`, `lib-dynamodb`, optional `client-s3`) | the ranges in `package.json` | every tier |
|
|
1797
1991
|
|
|
1798
|
-
|
|
1992
|
+
Node 22 is supported until its end of life, 2027-04-30; the floor is not raised before then, and raising it afterwards is a **major**. Raising a peer floor is a **major**; widening a range is a minor. A peer range is never narrowed in a patch. Each is announced in the CHANGELOG.
|
|
1799
1993
|
|
|
1800
1994
|
### Deprecation
|
|
1801
1995
|
|
|
@@ -1811,7 +2005,7 @@ Also not covered: the wording of error messages and log lines, the order of rows
|
|
|
1811
2005
|
|
|
1812
2006
|
`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
2007
|
|
|
1814
|
-
Full table (V-1 through V-
|
|
2008
|
+
Full table (V-1 through V-41) and the note on V-7's withdrawal: [Guide → Differences from the reference implementations](docs/guide.md#differences-from-the-reference-implementations).
|
|
1815
2009
|
|
|
1816
2010
|
## Testing
|
|
1817
2011
|
|
|
@@ -1856,7 +2050,7 @@ The `examples/live-*.mjs` scripts are demos against real AWS, not a test tier. [
|
|
|
1856
2050
|
### Documentation checks
|
|
1857
2051
|
|
|
1858
2052
|
```bash
|
|
1859
|
-
npm run check:docs # type-check every TypeScript sample in README.md, CONTRIBUTING.md and CHANGELOG.md against src
|
|
2053
|
+
npm run check:docs # type-check every TypeScript sample in README.md, CONTRIBUTING.md, docs/guide.md and CHANGELOG.md against src
|
|
1860
2054
|
npm run check:links # resolve every relative link and #anchor across the hand-written documents
|
|
1861
2055
|
```
|
|
1862
2056
|
|
|
@@ -1866,13 +2060,13 @@ npm run check:links # resolve every relative link and #anchor across the hand-w
|
|
|
1866
2060
|
|
|
1867
2061
|
| Tier | Runs | Proves |
|
|
1868
2062
|
| --- | --- | --- |
|
|
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) |
|
|
2063
|
+
| 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](docs/decisions/0023-write-interface-docs-as-jsdoc-and-other-comments-as-line-comments.md); 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) |
|
|
1870
2064
|
| 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 |
|
|
1871
2065
|
| 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 |
|
|
1872
2066
|
| 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 |
|
|
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) |
|
|
2067
|
+
| Real AWS (`npm run test:aws`) | every release tag, gating publish; on demand locally | S3 offload, lifecycle rules, bucket versioning, the stranded-row sweep and the S3 error taxonomy against the real services; real 30-way append contention; Bedrock embeddings (skipped with a reason, and a warning annotation, when the model is not enabled; `AWS_TEST_REQUIRE_BEDROCK=1` makes that a failure) |
|
|
1874
2068
|
|
|
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),
|
|
2069
|
+
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), 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.
|
|
1876
2070
|
|
|
1877
2071
|
## Project structure
|
|
1878
2072
|
|
|
@@ -1931,6 +2125,7 @@ scripts/
|
|
|
1931
2125
|
├── check-doc-samples.mjs # Type-checks every TypeScript sample in the documents a reader copies from
|
|
1932
2126
|
├── check-doc-links.mjs # Resolves every relative link and #anchor in the hand-written documents
|
|
1933
2127
|
├── find-stranded-payloads.mjs # Reports rows whose offloaded payload was released (not in the tarball)
|
|
2128
|
+
├── find-orphaned-payloads.mjs # Reports objects no live row names (not in the tarball)
|
|
1934
2129
|
├── pack-check.mjs # The tarball holds exactly dist, the licence, the README and the manifest
|
|
1935
2130
|
├── peer-floors.mjs # The lowest version each peer range admits, for the peer-floor CI job
|
|
1936
2131
|
├── require-green-ci.mjs # The release gate: every required check present and successful
|
|
@@ -1940,6 +2135,7 @@ scripts/
|
|
|
1940
2135
|
├── run-with-timeout.mjs # Runs a command and kills its process tree past a timeout
|
|
1941
2136
|
├── update-surface-baseline.mjs # Regenerates test/surface/baseline.txt
|
|
1942
2137
|
├── clean.mjs # Removes dist/ before a build
|
|
2138
|
+
├── mark-cjs.mjs # Marks dist/cjs as CommonJS, under a package whose own type is module
|
|
1943
2139
|
└── is-main.mjs # Whether a script is the program being run, whatever path reached it
|
|
1944
2140
|
|
|
1945
2141
|
examples/ # live-*.mjs demos against real AWS; see examples/README.md
|
|
@@ -1953,20 +2149,21 @@ docs/
|
|
|
1953
2149
|
└── README.md # The documentation index
|
|
1954
2150
|
|
|
1955
2151
|
.github/workflows/
|
|
1956
|
-
├── ci.yml # Every push and PR to main: three OSes × Node 22/24/26,
|
|
1957
|
-
│ # peer floors, docs drift, package smoke, hygiene, npm audit
|
|
2152
|
+
├── ci.yml # Every push and PR to main: three OSes × Node 22/24/26, the full type check,
|
|
2153
|
+
│ # integration, conformance, peer floors, docs drift, package smoke, hygiene, npm audit
|
|
1958
2154
|
├── codeql.yml # Static analysis of the source and the workflows; push, PR and weekly
|
|
1959
2155
|
├── dependency-review.yml # Fails a PR that adds a dependency with a high or critical advisory
|
|
1960
2156
|
├── scorecard.yml # OpenSSF Scorecard; push to main, branch-protection changes and weekly
|
|
2157
|
+
├── latest-peers.yml # Weekly and on demand, against the newest LangChain and LangGraph releases; a warning, not a gate
|
|
1961
2158
|
├── 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
|
|
2159
|
+
└── release.yml # Tag-triggered publish from main with npm provenance and attested SBOMs, gated on green CI
|
|
1963
2160
|
```
|
|
1964
2161
|
|
|
1965
2162
|
## Design decisions and evidence
|
|
1966
2163
|
|
|
1967
2164
|
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
2165
|
|
|
1969
|
-
**[`docs/decisions/`](docs/decisions/README.md) — the choices that are expensive to reverse.** Twenty-
|
|
2166
|
+
**[`docs/decisions/`](docs/decisions/README.md) — the choices that are expensive to reverse.** Twenty-nine 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
2167
|
|
|
1971
2168
|
**[`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
2169
|
|