@farukada/aws-langgraph-dynamodb-ts 0.9.0 → 1.0.0-rc.2

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