From 49fe5d92eb9508be6dd5067df9948d534dc75a89 Mon Sep 17 00:00:00 2001 From: Anatol Zakrividoroga <53095479+anatolzak@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:19:33 +0200 Subject: [PATCH 1/4] Add DynamoDB state adapter Adds @chat-adapter/state-dynamodb, a serverless-native state adapter using DynamoDB single-table design with DynamoDBDocument client. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/add-dynamodb-state-adapter.md | 5 + packages/state-dynamodb/package.json | 58 ++ packages/state-dynamodb/src/index.test.ts | 619 ++++++++++++++++++++++ packages/state-dynamodb/src/index.ts | 522 ++++++++++++++++++ packages/state-dynamodb/tsconfig.json | 10 + packages/state-dynamodb/tsup.config.ts | 10 + packages/state-dynamodb/vitest.config.ts | 14 + 7 files changed, 1238 insertions(+) create mode 100644 .changeset/add-dynamodb-state-adapter.md create mode 100644 packages/state-dynamodb/package.json create mode 100644 packages/state-dynamodb/src/index.test.ts create mode 100644 packages/state-dynamodb/src/index.ts create mode 100644 packages/state-dynamodb/tsconfig.json create mode 100644 packages/state-dynamodb/tsup.config.ts create mode 100644 packages/state-dynamodb/vitest.config.ts diff --git a/.changeset/add-dynamodb-state-adapter.md b/.changeset/add-dynamodb-state-adapter.md new file mode 100644 index 00000000..a463183e --- /dev/null +++ b/.changeset/add-dynamodb-state-adapter.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/state-dynamodb": minor +--- + +Add DynamoDB state adapter for serverless deployments diff --git a/packages/state-dynamodb/package.json b/packages/state-dynamodb/package.json new file mode 100644 index 00000000..af73cb58 --- /dev/null +++ b/packages/state-dynamodb/package.json @@ -0,0 +1,58 @@ +{ + "name": "@chat-adapter/state-dynamodb", + "version": "0.0.0", + "description": "DynamoDB state adapter for chat (production)", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.750.0", + "@aws-sdk/lib-dynamodb": "^3.750.0", + "chat": "workspace:*" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/state-dynamodb" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "@vitest/coverage-v8": "^4.0.18", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "keywords": [ + "chat", + "state", + "dynamodb", + "production", + "serverless" + ], + "license": "MIT" +} diff --git a/packages/state-dynamodb/src/index.test.ts b/packages/state-dynamodb/src/index.test.ts new file mode 100644 index 00000000..ba690708 --- /dev/null +++ b/packages/state-dynamodb/src/index.test.ts @@ -0,0 +1,619 @@ +import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb"; +import type { DynamoDBDocument } from "@aws-sdk/lib-dynamodb"; +import type { Lock, Logger } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDynamoDBState, DynamoDBStateAdapter } from "./index"; + +function createMockDocClient(): DynamoDBDocument { + return { + put: vi.fn().mockResolvedValue({}), + get: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + query: vi.fn().mockResolvedValue({ Items: [] }), + batchWrite: vi.fn().mockResolvedValue({}), + destroy: vi.fn(), + } as unknown as DynamoDBDocument; +} + +function createMockLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), + } as unknown as Logger; +} + +describe("DynamoDBStateAdapter", () => { + it("should export createDynamoDBState function", () => { + expect(typeof createDynamoDBState).toBe("function"); + }); + + it("should export DynamoDBStateAdapter class", () => { + expect(typeof DynamoDBStateAdapter).toBe("function"); + }); + + describe("createDynamoDBState", () => { + it("should create an adapter with default options", () => { + const adapter = createDynamoDBState({ client: createMockDocClient() }); + expect(adapter).toBeInstanceOf(DynamoDBStateAdapter); + }); + + it("should create an adapter with custom options", () => { + const adapter = createDynamoDBState({ + client: createMockDocClient(), + tableName: "custom-table", + keyPrefix: "custom-prefix", + }); + expect(adapter).toBeInstanceOf(DynamoDBStateAdapter); + }); + + it("should create an adapter without a client", () => { + const adapter = createDynamoDBState({ region: "us-east-1" }); + expect(adapter).toBeInstanceOf(DynamoDBStateAdapter); + }); + }); + + describe("ensureConnected", () => { + it("should throw when calling subscribe before connect", async () => { + const adapter = new DynamoDBStateAdapter({ + client: createMockDocClient(), + }); + await expect(adapter.subscribe("thread1")).rejects.toThrow( + "not connected" + ); + }); + + it("should throw when calling acquireLock before connect", async () => { + const adapter = new DynamoDBStateAdapter({ + client: createMockDocClient(), + }); + await expect(adapter.acquireLock("thread1", 5000)).rejects.toThrow( + "not connected" + ); + }); + + it("should throw when calling get before connect", async () => { + const adapter = new DynamoDBStateAdapter({ + client: createMockDocClient(), + }); + await expect(adapter.get("key")).rejects.toThrow("not connected"); + }); + + it("should throw when calling set before connect", async () => { + const adapter = new DynamoDBStateAdapter({ + client: createMockDocClient(), + }); + await expect(adapter.set("key", "value")).rejects.toThrow( + "not connected" + ); + }); + + it("should throw when calling delete before connect", async () => { + const adapter = new DynamoDBStateAdapter({ + client: createMockDocClient(), + }); + await expect(adapter.delete("key")).rejects.toThrow("not connected"); + }); + }); + + describe("with mock client", () => { + let adapter: DynamoDBStateAdapter; + let client: DynamoDBDocument; + let logger: Logger; + + beforeEach(async () => { + client = createMockDocClient(); + logger = createMockLogger(); + adapter = new DynamoDBStateAdapter({ client, logger }); + await adapter.connect(); + }); + + afterEach(async () => { + await adapter.disconnect(); + }); + + describe("connect / disconnect", () => { + it("should be idempotent on connect", async () => { + await adapter.connect(); + await adapter.connect(); + }); + + it("should be idempotent on disconnect", async () => { + await adapter.disconnect(); + await adapter.disconnect(); + }); + }); + + describe("subscriptions", () => { + it("should subscribe by calling put", async () => { + await adapter.subscribe("slack:C123:1234.5678"); + + expect(client.put).toHaveBeenCalledWith( + expect.objectContaining({ + Item: { pk: "chat-sdk#sub#slack:C123:1234.5678", sk: "_" }, + }) + ); + }); + + it("should unsubscribe by calling delete", async () => { + await adapter.unsubscribe("slack:C123:1234.5678"); + + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { pk: "chat-sdk#sub#slack:C123:1234.5678", sk: "_" }, + }) + ); + }); + + it("should return true when subscribed", async () => { + vi.mocked(client.get).mockResolvedValue({ + Item: { pk: "chat-sdk#sub#thread1", sk: "_" }, + $metadata: {}, + }); + + const result = await adapter.isSubscribed("thread1"); + expect(result).toBe(true); + }); + + it("should return false when not subscribed", async () => { + vi.mocked(client.get).mockResolvedValue({ $metadata: {} }); + + const result = await adapter.isSubscribed("thread1"); + expect(result).toBe(false); + }); + }); + + describe("locking", () => { + it("should acquire a lock successfully", async () => { + const lock = await adapter.acquireLock("thread1", 5000); + expect(lock).not.toBeNull(); + expect(lock?.threadId).toBe("thread1"); + expect(lock?.token?.startsWith("ddb_")).toBe(true); + expect(lock?.expiresAt).toBeGreaterThan(Date.now()); + }); + + it("should return null when lock is already held", async () => { + vi.mocked(client.put).mockRejectedValue( + new ConditionalCheckFailedException({ + message: "Condition not met", + $metadata: {}, + }) + ); + + const lock = await adapter.acquireLock("thread1", 5000); + expect(lock).toBeNull(); + }); + + it("should release a lock with token check", async () => { + const lock: Lock = { + threadId: "thread1", + token: "ddb_test-token", + expiresAt: Date.now() + 5000, + }; + await adapter.releaseLock(lock); + + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + ConditionExpression: "#t = :token", + ExpressionAttributeValues: { ":token": "ddb_test-token" }, + }) + ); + }); + + it("should no-op when releasing with wrong token", async () => { + vi.mocked(client.delete).mockRejectedValue( + new ConditionalCheckFailedException({ + message: "Condition not met", + $metadata: {}, + }) + ); + + const lock: Lock = { + threadId: "thread1", + token: "wrong-token", + expiresAt: Date.now() + 5000, + }; + await adapter.releaseLock(lock); + }); + + it("should force-release a lock without token check", async () => { + await adapter.forceReleaseLock("thread1"); + + expect(client.delete).toHaveBeenCalledWith( + expect.not.objectContaining({ + ConditionExpression: expect.anything(), + }) + ); + }); + + it("should return true when lock is extended", async () => { + const lock: Lock = { + threadId: "thread1", + token: "ddb_test-token", + expiresAt: Date.now() + 5000, + }; + const result = await adapter.extendLock(lock, 5000); + expect(result).toBe(true); + }); + + it("should return false when lock extension fails", async () => { + vi.mocked(client.update).mockRejectedValue( + new ConditionalCheckFailedException({ + message: "Condition not met", + $metadata: {}, + }) + ); + + const lock: Lock = { + threadId: "thread1", + token: "ddb_test-token", + expiresAt: Date.now() + 5000, + }; + const result = await adapter.extendLock(lock, 5000); + expect(result).toBe(false); + }); + }); + + describe("cache", () => { + it("should return value on cache hit", async () => { + vi.mocked(client.get).mockResolvedValue({ + Item: { pk: "x", sk: "_", value: { foo: "bar" } }, + $metadata: {}, + }); + + const result = await adapter.get("key"); + expect(result).toEqual({ foo: "bar" }); + }); + + it("should return raw value for non-object types", async () => { + vi.mocked(client.get).mockResolvedValue({ + Item: { pk: "x", sk: "_", value: "plain-string" }, + $metadata: {}, + }); + + const result = await adapter.get("key"); + expect(result).toBe("plain-string"); + }); + + it("should return null on cache miss", async () => { + vi.mocked(client.get).mockResolvedValue({ $metadata: {} }); + + const result = await adapter.get("key"); + expect(result).toBeNull(); + }); + + it("should return null for expired items", async () => { + vi.mocked(client.get).mockResolvedValue({ + Item: { + pk: "x", + sk: "_", + value: { foo: "bar" }, + expiresAtMs: Date.now() - 1000, + }, + $metadata: {}, + }); + + const result = await adapter.get("key"); + expect(result).toBeNull(); + }); + + it("should set a value with correct key format", async () => { + await adapter.set("my-key", { foo: "bar" }); + + expect(client.put).toHaveBeenCalledWith( + expect.objectContaining({ + Item: expect.objectContaining({ + pk: "chat-sdk#cache#my-key", + value: { foo: "bar" }, + }), + }) + ); + }); + + it("should set a value with TTL", async () => { + await adapter.set("key", "value", 5000); + + expect(client.put).toHaveBeenCalledWith( + expect.objectContaining({ + Item: expect.objectContaining({ + expiresAtMs: expect.any(Number), + expiresAt: expect.any(Number), + }), + }) + ); + }); + + it("should return true when setIfNotExists succeeds", async () => { + const result = await adapter.setIfNotExists("key", "value"); + expect(result).toBe(true); + }); + + it("should return false when setIfNotExists finds existing key", async () => { + vi.mocked(client.put).mockRejectedValue( + new ConditionalCheckFailedException({ + message: "Condition not met", + $metadata: {}, + }) + ); + + const result = await adapter.setIfNotExists("key", "value"); + expect(result).toBe(false); + }); + + it("should support setIfNotExists with TTL", async () => { + const result = await adapter.setIfNotExists("key", "value", 5000); + expect(result).toBe(true); + }); + + it("should delete a value without throwing", async () => { + await adapter.delete("key"); + expect(client.delete).toHaveBeenCalled(); + }); + }); + + describe("appendToList / getList", () => { + it("should increment counter and write list entry", async () => { + vi.mocked(client.update).mockResolvedValue({ + Attributes: { seq: 1 }, + $metadata: {}, + }); + + await adapter.appendToList("mylist", { foo: "bar" }); + + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + Key: expect.objectContaining({ + pk: "chat-sdk#list-counter#mylist", + }), + UpdateExpression: "ADD seq :one", + }) + ); + + expect(client.put).toHaveBeenCalledWith( + expect.objectContaining({ + Item: expect.objectContaining({ + pk: "chat-sdk#list#mylist", + sk: "0000000000000001", + value: { foo: "bar" }, + }), + }) + ); + }); + + it("should trim overflow when maxLength is specified", async () => { + vi.mocked(client.update).mockResolvedValue({ + Attributes: { seq: 4 }, + $metadata: {}, + }); + vi.mocked(client.query).mockResolvedValue({ + Items: [ + { sk: "0000000000000001" }, + { sk: "0000000000000002" }, + { sk: "0000000000000003" }, + { sk: "0000000000000004" }, + ], + $metadata: {}, + }); + + await adapter.appendToList("mylist", { id: 4 }, { maxLength: 2 }); + + expect(client.batchWrite).toHaveBeenCalledWith( + expect.objectContaining({ + RequestItems: { + "chat-state": expect.arrayContaining([ + { + DeleteRequest: { + Key: { pk: "chat-sdk#list#mylist", sk: "0000000000000001" }, + }, + }, + { + DeleteRequest: { + Key: { pk: "chat-sdk#list#mylist", sk: "0000000000000002" }, + }, + }, + ]), + }, + }) + ); + }); + + it("should set TTL on list entries when ttlMs is provided", async () => { + vi.mocked(client.update).mockResolvedValue({ + Attributes: { seq: 1 }, + $metadata: {}, + }); + + await adapter.appendToList("mylist", { id: 1 }, { ttlMs: 60000 }); + + expect(client.put).toHaveBeenCalledWith( + expect.objectContaining({ + Item: expect.objectContaining({ + expiresAtMs: expect.any(Number), + expiresAt: expect.any(Number), + }), + }) + ); + }); + + it("should return list items from getList in order", async () => { + vi.mocked(client.query).mockResolvedValue({ + Items: [ + { sk: "0000000000000001", value: { id: 1 } }, + { sk: "0000000000000002", value: { id: 2 } }, + ], + $metadata: {}, + }); + + const result = await adapter.getList("mylist"); + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it("should filter expired items in getList", async () => { + vi.mocked(client.query).mockResolvedValue({ + Items: [ + { + sk: "0000000000000001", + value: { id: 1 }, + expiresAtMs: Date.now() - 1000, + }, + { sk: "0000000000000002", value: { id: 2 } }, + ], + $metadata: {}, + }); + + const result = await adapter.getList("mylist"); + expect(result).toEqual([{ id: 2 }]); + }); + + it("should return empty array when no items exist", async () => { + vi.mocked(client.query).mockResolvedValue({ Items: [], $metadata: {} }); + + const result = await adapter.getList("mylist"); + expect(result).toEqual([]); + }); + + it("should handle paginated query results in getList", async () => { + vi.mocked(client.query) + .mockResolvedValueOnce({ + Items: [{ sk: "0000000000000001", value: { id: 1 } }], + LastEvaluatedKey: { pk: "x", sk: "0000000000000001" }, + $metadata: {}, + }) + .mockResolvedValueOnce({ + Items: [{ sk: "0000000000000002", value: { id: 2 } }], + $metadata: {}, + }); + + const result = await adapter.getList("mylist"); + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + }); + }); + + describe("disconnect", () => { + it("should call destroy on owned client", async () => { + const ownedAdapter = createDynamoDBState({ + region: "us-east-1", + logger, + }); + await ownedAdapter.connect(); + const ownedClient = ownedAdapter.getClient(); + vi.spyOn(ownedClient, "destroy").mockImplementation(() => {}); + await ownedAdapter.disconnect(); + expect(ownedClient.destroy).toHaveBeenCalled(); + }); + + it("should not call destroy on externally provided client", async () => { + await adapter.disconnect(); + expect(client.destroy).not.toHaveBeenCalled(); + }); + }); + + describe("trimList logging", () => { + it("should log a warning when batchWrite has unprocessed items", async () => { + vi.mocked(client.update).mockResolvedValue({ + Attributes: { seq: 4 }, + $metadata: {}, + }); + vi.mocked(client.query).mockResolvedValue({ + Items: [ + { sk: "0000000000000001" }, + { sk: "0000000000000002" }, + { sk: "0000000000000003" }, + { sk: "0000000000000004" }, + ], + $metadata: {}, + }); + vi.mocked(client.batchWrite).mockResolvedValue({ + UnprocessedItems: { + "chat-state": [ + { + DeleteRequest: { + Key: { + pk: "chat-sdk#list#mylist", + sk: "0000000000000001", + }, + }, + }, + ], + }, + $metadata: {}, + }); + + await adapter.appendToList("mylist", { id: 4 }, { maxLength: 2 }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("unprocessed deletes") + ); + }); + }); + + describe("appendToList TTL refresh", () => { + it("should refresh TTL on all existing list items via batchWrite", async () => { + vi.mocked(client.update).mockResolvedValue({ + Attributes: { seq: 2 }, + $metadata: {}, + }); + vi.mocked(client.query).mockResolvedValue({ + Items: [ + { sk: "0000000000000001", value: { id: 1 } }, + { sk: "0000000000000002", value: { id: 2 } }, + ], + $metadata: {}, + }); + + await adapter.appendToList("mylist", { id: 2 }, { ttlMs: 60000 }); + + expect(client.batchWrite).toHaveBeenCalledWith( + expect.objectContaining({ + RequestItems: { + "chat-state": expect.arrayContaining([ + { + PutRequest: { + Item: expect.objectContaining({ + sk: "0000000000000001", + value: { id: 1 }, + expiresAtMs: expect.any(Number), + expiresAt: expect.any(Number), + }), + }, + }, + { + PutRequest: { + Item: expect.objectContaining({ + sk: "0000000000000002", + value: { id: 2 }, + expiresAtMs: expect.any(Number), + expiresAt: expect.any(Number), + }), + }, + }, + ]), + }, + }) + ); + }); + + it("should not refresh TTL when ttlMs is not provided", async () => { + vi.mocked(client.update).mockResolvedValue({ + Attributes: { seq: 1 }, + $metadata: {}, + }); + + await adapter.appendToList("mylist", { id: 1 }); + + // Only the counter update, no batchWrite for TTL refresh + expect(client.update).toHaveBeenCalledTimes(1); + expect(client.batchWrite).not.toHaveBeenCalled(); + }); + }); + + describe("getClient", () => { + it("should return the underlying DynamoDB Document client", () => { + expect(adapter.getClient()).toBe(client); + }); + }); + }); +}); diff --git a/packages/state-dynamodb/src/index.ts b/packages/state-dynamodb/src/index.ts new file mode 100644 index 00000000..1d647d9d --- /dev/null +++ b/packages/state-dynamodb/src/index.ts @@ -0,0 +1,522 @@ +import { + ConditionalCheckFailedException, + DynamoDBClient, +} from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb"; +import type { Lock, Logger, StateAdapter } from "chat"; +import { ConsoleLogger } from "chat"; + +const DEFAULT_TABLE_NAME = "chat-state"; +const DEFAULT_KEY_PREFIX = "chat-sdk"; + +const SEQ_PAD_LENGTH = 16; +const BATCH_WRITE_LIMIT = 25; + +export interface DynamoDBStateAdapterOptions { + /** Custom DynamoDB endpoint (for DynamoDB Local development) */ + endpoint?: string; + /** Key prefix for multi-tenancy (default: "chat-sdk") */ + keyPrefix?: string; + /** Logger instance for error reporting */ + logger?: Logger; + /** AWS region (default: from environment) */ + region?: string; + /** DynamoDB table name (default: "chat-state") */ + tableName?: string; +} + +export interface DynamoDBStateClientOptions { + /** Existing DynamoDBDocument instance */ + client: DynamoDBDocument; + /** Key prefix for multi-tenancy (default: "chat-sdk") */ + keyPrefix?: string; + /** Logger instance for error reporting */ + logger?: Logger; + /** DynamoDB table name (default: "chat-state") */ + tableName?: string; +} + +export type CreateDynamoDBStateOptions = + | (Partial & { client?: never }) + | (Partial> & { + client: DynamoDBDocument; + }); + +export class DynamoDBStateAdapter implements StateAdapter { + private readonly docClient: DynamoDBDocument; + private readonly tableName: string; + private readonly keyPrefix: string; + private readonly logger: Logger; + private readonly ownsClient: boolean; + private connected = false; + + constructor( + options: DynamoDBStateAdapterOptions | DynamoDBStateClientOptions + ) { + if ("client" in options && options.client) { + this.docClient = options.client; + this.ownsClient = false; + } else { + const opts = options as DynamoDBStateAdapterOptions; + this.docClient = DynamoDBDocument.from( + new DynamoDBClient({ + region: opts.region, + endpoint: opts.endpoint, + }) + ); + this.ownsClient = true; + } + + this.tableName = options.tableName ?? DEFAULT_TABLE_NAME; + this.keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX; + this.logger = options.logger ?? new ConsoleLogger("info").child("dynamodb"); + } + + async connect(): Promise { + this.connected = true; + } + + async disconnect(): Promise { + if (!this.connected) { + return; + } + + if (this.ownsClient) { + this.docClient.destroy(); + } + + this.connected = false; + } + + async subscribe(threadId: string): Promise { + this.ensureConnected(); + + await this.docClient.put({ + TableName: this.tableName, + Item: { + pk: this.subKey(threadId), + sk: "_", + }, + }); + } + + async unsubscribe(threadId: string): Promise { + this.ensureConnected(); + + await this.docClient.delete({ + TableName: this.tableName, + Key: { pk: this.subKey(threadId), sk: "_" }, + }); + } + + async isSubscribed(threadId: string): Promise { + this.ensureConnected(); + + const result = await this.docClient.get({ + TableName: this.tableName, + Key: { pk: this.subKey(threadId), sk: "_" }, + ProjectionExpression: "pk", + }); + + return result.Item !== undefined; + } + + async acquireLock(threadId: string, ttlMs: number): Promise { + this.ensureConnected(); + + const token = generateToken(); + const now = Date.now(); + const expiresAtMs = now + ttlMs; + + try { + await this.docClient.put({ + TableName: this.tableName, + Item: { + pk: this.lockKey(threadId), + sk: "_", + token, + expiresAtMs, + expiresAt: msToSeconds(expiresAtMs), + }, + ConditionExpression: "attribute_not_exists(pk) OR expiresAtMs <= :now", + ExpressionAttributeValues: { ":now": now }, + }); + + return { threadId, token, expiresAt: expiresAtMs }; + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + return null; + } + throw error; + } + } + + async releaseLock(lock: Lock): Promise { + this.ensureConnected(); + + try { + await this.docClient.delete({ + TableName: this.tableName, + Key: { pk: this.lockKey(lock.threadId), sk: "_" }, + ConditionExpression: "#t = :token", + ExpressionAttributeNames: { "#t": "token" }, + ExpressionAttributeValues: { ":token": lock.token }, + }); + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + return; + } + throw error; + } + } + + async forceReleaseLock(threadId: string): Promise { + this.ensureConnected(); + + await this.docClient.delete({ + TableName: this.tableName, + Key: { pk: this.lockKey(threadId), sk: "_" }, + }); + } + + async extendLock(lock: Lock, ttlMs: number): Promise { + this.ensureConnected(); + + const now = Date.now(); + const newExpiresAtMs = now + ttlMs; + + try { + await this.docClient.update({ + TableName: this.tableName, + Key: { pk: this.lockKey(lock.threadId), sk: "_" }, + UpdateExpression: "SET expiresAtMs = :newMs, expiresAt = :newSec", + ConditionExpression: "#t = :token AND expiresAtMs > :now", + ExpressionAttributeNames: { "#t": "token" }, + ExpressionAttributeValues: { + ":token": lock.token, + ":now": now, + ":newMs": newExpiresAtMs, + ":newSec": msToSeconds(newExpiresAtMs), + }, + }); + + return true; + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + return false; + } + throw error; + } + } + + async get(key: string): Promise { + this.ensureConnected(); + + const result = await this.docClient.get({ + TableName: this.tableName, + Key: { pk: this.cacheKey(key), sk: "_" }, + }); + + if (!result.Item) { + return null; + } + + if ( + result.Item.expiresAtMs !== undefined && + (result.Item.expiresAtMs as number) <= Date.now() + ) { + return null; + } + + return result.Item.value as T; + } + + async set(key: string, value: T, ttlMs?: number): Promise { + this.ensureConnected(); + + const item: Record = { + pk: this.cacheKey(key), + sk: "_", + value, + }; + + if (ttlMs !== undefined) { + const expiresAtMs = Date.now() + ttlMs; + item.expiresAtMs = expiresAtMs; + item.expiresAt = msToSeconds(expiresAtMs); + } + + await this.docClient.put({ TableName: this.tableName, Item: item }); + } + + async setIfNotExists( + key: string, + value: unknown, + ttlMs?: number + ): Promise { + this.ensureConnected(); + + const now = Date.now(); + const item: Record = { + pk: this.cacheKey(key), + sk: "_", + value, + }; + + if (ttlMs !== undefined) { + const expiresAtMs = now + ttlMs; + item.expiresAtMs = expiresAtMs; + item.expiresAt = msToSeconds(expiresAtMs); + } + + try { + await this.docClient.put({ + TableName: this.tableName, + Item: item, + ConditionExpression: "attribute_not_exists(pk) OR expiresAtMs <= :now", + ExpressionAttributeValues: { ":now": now }, + }); + return true; + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + return false; + } + throw error; + } + } + + async delete(key: string): Promise { + this.ensureConnected(); + + await this.docClient.delete({ + TableName: this.tableName, + Key: { pk: this.cacheKey(key), sk: "_" }, + }); + } + + async appendToList( + key: string, + value: unknown, + options?: { maxLength?: number; ttlMs?: number } + ): Promise { + this.ensureConnected(); + + const counterResult = await this.docClient.update({ + TableName: this.tableName, + Key: { pk: this.listCounterKey(key), sk: "_" }, + UpdateExpression: "ADD seq :one", + ExpressionAttributeValues: { ":one": 1 }, + ReturnValues: "ALL_NEW", + }); + + const seq = counterResult.Attributes?.seq as number; + const sk = String(seq).padStart(SEQ_PAD_LENGTH, "0"); + + const item: Record = { + pk: this.listKey(key), + sk, + value, + }; + + if (options?.ttlMs !== undefined) { + const expiresAtMs = Date.now() + options.ttlMs; + item.expiresAtMs = expiresAtMs; + item.expiresAt = msToSeconds(expiresAtMs); + } + + await this.docClient.put({ TableName: this.tableName, Item: item }); + + if (options?.maxLength) { + await this.trimList(key, options.maxLength); + } + + if (options?.ttlMs !== undefined) { + await this.refreshListTtl(key, options.ttlMs); + } + } + + async getList(key: string): Promise { + this.ensureConnected(); + + const items = await this.queryAllListItems(key); + const now = Date.now(); + + const results: T[] = []; + for (const item of items) { + if ( + item.expiresAtMs !== undefined && + (item.expiresAtMs as number) <= now + ) { + continue; + } + + results.push(item.value as T); + } + + return results; + } + + getClient(): DynamoDBDocument { + return this.docClient; + } + + private subKey(threadId: string): string { + return `${this.keyPrefix}#sub#${threadId}`; + } + + private lockKey(threadId: string): string { + return `${this.keyPrefix}#lock#${threadId}`; + } + + private cacheKey(key: string): string { + return `${this.keyPrefix}#cache#${key}`; + } + + private listKey(key: string): string { + return `${this.keyPrefix}#list#${key}`; + } + + private listCounterKey(key: string): string { + return `${this.keyPrefix}#list-counter#${key}`; + } + + private async queryAllListItems( + key: string + ): Promise[]> { + const items: Record[] = []; + let exclusiveStartKey: Record | undefined; + + do { + const result = await this.docClient.query({ + TableName: this.tableName, + KeyConditionExpression: "pk = :pk", + ExpressionAttributeValues: { ":pk": this.listKey(key) }, + ScanIndexForward: true, + ExclusiveStartKey: exclusiveStartKey, + }); + + if (result.Items) { + items.push(...result.Items); + } + exclusiveStartKey = result.LastEvaluatedKey; + } while (exclusiveStartKey); + + return items; + } + + private async trimList(key: string, maxLength: number): Promise { + const allKeys: string[] = []; + let exclusiveStartKey: Record | undefined; + + do { + const result = await this.docClient.query({ + TableName: this.tableName, + KeyConditionExpression: "pk = :pk", + ExpressionAttributeValues: { ":pk": this.listKey(key) }, + ProjectionExpression: "sk", + ScanIndexForward: true, + ExclusiveStartKey: exclusiveStartKey, + }); + + if (result.Items) { + for (const item of result.Items) { + allKeys.push(item.sk as string); + } + } + exclusiveStartKey = result.LastEvaluatedKey; + } while (exclusiveStartKey); + + const overflow = allKeys.length - maxLength; + if (overflow <= 0) { + return; + } + + const keysToDelete = allKeys.slice(0, overflow); + const pk = this.listKey(key); + + for (let i = 0; i < keysToDelete.length; i += BATCH_WRITE_LIMIT) { + const batch = keysToDelete.slice(i, i + BATCH_WRITE_LIMIT); + + const result = await this.docClient.batchWrite({ + RequestItems: { + [this.tableName]: batch.map((sk) => ({ + DeleteRequest: { Key: { pk, sk } }, + })), + }, + }); + + const unprocessed = result.UnprocessedItems?.[this.tableName]; + if (unprocessed?.length) { + this.logger.warn( + `trimList: ${unprocessed.length} unprocessed deletes for list "${key}"` + ); + } + } + } + + private async refreshListTtl(key: string, ttlMs: number): Promise { + const items = await this.queryAllListItems(key); + const now = Date.now(); + const expiresAtMs = now + ttlMs; + const expiresAt = msToSeconds(expiresAtMs); + + for (let i = 0; i < items.length; i += BATCH_WRITE_LIMIT) { + const batch = items.slice(i, i + BATCH_WRITE_LIMIT); + + const result = await this.docClient.batchWrite({ + RequestItems: { + [this.tableName]: batch.map((item) => ({ + PutRequest: { + Item: { ...item, expiresAtMs, expiresAt }, + }, + })), + }, + }); + + const unprocessed = result.UnprocessedItems?.[this.tableName]; + if (unprocessed?.length) { + this.logger.warn( + `refreshListTtl: ${unprocessed.length} unprocessed writes for list "${key}"` + ); + } + } + } + + private ensureConnected(): void { + if (!this.connected) { + throw new Error( + "DynamoDBStateAdapter is not connected. Call connect() first." + ); + } + } +} + +function generateToken(): string { + return `ddb_${crypto.randomUUID()}`; +} + +function msToSeconds(ms: number): number { + return Math.floor(ms / 1000); +} + +export function createDynamoDBState( + options: CreateDynamoDBStateOptions = {} +): DynamoDBStateAdapter { + if ("client" in options && options.client) { + return new DynamoDBStateAdapter({ + client: options.client, + tableName: options.tableName, + keyPrefix: options.keyPrefix, + logger: options.logger, + }); + } + + const opts = options as Partial; + return new DynamoDBStateAdapter({ + tableName: opts.tableName, + keyPrefix: opts.keyPrefix, + region: opts.region, + endpoint: opts.endpoint, + logger: opts.logger, + }); +} diff --git a/packages/state-dynamodb/tsconfig.json b/packages/state-dynamodb/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/state-dynamodb/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/state-dynamodb/tsup.config.ts b/packages/state-dynamodb/tsup.config.ts new file mode 100644 index 00000000..0cd92c2e --- /dev/null +++ b/packages/state-dynamodb/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + external: ["@aws-sdk/client-dynamodb", "@aws-sdk/lib-dynamodb"], +}); diff --git a/packages/state-dynamodb/vitest.config.ts b/packages/state-dynamodb/vitest.config.ts new file mode 100644 index 00000000..edc2d946 --- /dev/null +++ b/packages/state-dynamodb/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); From 284207032bfa22ea346265aa3c031461d053c8fa Mon Sep 17 00:00:00 2001 From: Anatol Zakrividoroga <53095479+anatolzak@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:20:32 +0200 Subject: [PATCH 2/4] Move AWS SDK packages to peer dependencies Users typically already have @aws-sdk in their project. Peer deps avoid version conflicts and reduce bundle duplication. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/state-dynamodb/package.json | 8 +- pnpm-lock.yaml | 1034 ++++++++++++++++++++++++++ 2 files changed, 1040 insertions(+), 2 deletions(-) diff --git a/packages/state-dynamodb/package.json b/packages/state-dynamodb/package.json index af73cb58..1e57a27b 100644 --- a/packages/state-dynamodb/package.json +++ b/packages/state-dynamodb/package.json @@ -24,10 +24,12 @@ "clean": "rm -rf dist" }, "dependencies": { - "@aws-sdk/client-dynamodb": "^3.750.0", - "@aws-sdk/lib-dynamodb": "^3.750.0", "chat": "workspace:*" }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/lib-dynamodb": "^3.0.0" + }, "repository": { "type": "git", "url": "git+https://github.com/vercel/chat.git", @@ -41,6 +43,8 @@ "access": "public" }, "devDependencies": { + "@aws-sdk/client-dynamodb": "^3.750.0", + "@aws-sdk/lib-dynamodb": "^3.750.0", "@types/node": "^25.3.2", "@vitest/coverage-v8": "^4.0.18", "tsup": "^8.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e123c248..641446f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -559,6 +559,34 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/state-dynamodb: + dependencies: + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@aws-sdk/client-dynamodb': + specifier: ^3.750.0 + version: 3.1014.0 + '@aws-sdk/lib-dynamodb': + specifier: ^3.750.0 + version: 3.1014.0(@aws-sdk/client-dynamodb@3.1014.0) + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/state-ioredis: dependencies: chat: @@ -701,6 +729,143 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-dynamodb@3.1014.0': + resolution: {integrity: sha512-AFqO74mg9UITN+H5CdK7ULwPrvty6mlbDT2kwY3HI/piI6DjiwA7Y7wKWtJAFjCa1OLyRRV2/jy1DKBb80Qv8Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.973.23': + resolution: {integrity: sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.21': + resolution: {integrity: sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.23': + resolution: {integrity: sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.23': + resolution: {integrity: sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.23': + resolution: {integrity: sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.24': + resolution: {integrity: sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.21': + resolution: {integrity: sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.23': + resolution: {integrity: sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.23': + resolution: {integrity: sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/dynamodb-codec@3.972.24': + resolution: {integrity: sha512-J4qDdBAV8Gq87B2jnX1y4brRlnlta2lIZma7HfQDlkNYo7abSWF0n8quzK9a0wG7UOMfBDzL5jP+1lt3ufggOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/endpoint-cache@3.972.4': + resolution: {integrity: sha512-GdASDnWanLnHxKK0hqV97xz23QmfA/C8yGe0PiuEmWiHSe+x+x+mFEj4sXqx9IbfyPncWz8f4EhNwBSG9cgYCg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/lib-dynamodb@3.1014.0': + resolution: {integrity: sha512-erDzDJk1tNPkvTbuWJi/8yelI9M4dA//gOOwpsseZbnzV2OOvgcIExmTxeMpFPWYHXXCJuwo+nOY31V6ba/BVA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-dynamodb': ^3.1014.0 + + '@aws-sdk/middleware-endpoint-discovery@3.972.8': + resolution: {integrity: sha512-S0oXx1QbSpMDBMJn4P0hOxW8ieGAdRT+G9NbL+ESWkkoCGf9D++fKYD2fyBGtIy88OrP7wgECpXgGLAcGpIj0A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.8': + resolution: {integrity: sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.8': + resolution: {integrity: sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.8': + resolution: {integrity: sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.24': + resolution: {integrity: sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.996.13': + resolution: {integrity: sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.9': + resolution: {integrity: sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1014.0': + resolution: {integrity: sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.6': + resolution: {integrity: sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-dynamodb@3.996.2': + resolution: {integrity: sha512-ddpwaZmjBzcApYN7lgtAXjk+u+GO8fiPsxzuc59UqP+zqdxI1gsenPvkyiHiF9LnYnyRGijz6oN2JylnN561qQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-dynamodb': ^3.1003.0 + + '@aws-sdk/util-endpoints@3.996.5': + resolution: {integrity: sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.8': + resolution: {integrity: sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==} + + '@aws-sdk/util-user-agent-node@3.973.10': + resolution: {integrity: sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.15': + resolution: {integrity: sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@azure/abort-controller@2.1.2': resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} engines: {node: '>=18.0.0'} @@ -2588,6 +2753,182 @@ packages: resolution: {integrity: sha512-RoygyteJeFswxDPJjUMESn9dldWVMD2xUcHHd9DenVavSfVC6FeVnSdDerOO7m8LLvw4Q132nQM4hX8JiF7dng==} engines: {node: '>= 18', npm: '>= 8.6.0'} + '@smithy/abort-controller@4.2.12': + resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.13': + resolution: {integrity: sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.12': + resolution: {integrity: sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.12': + resolution: {integrity: sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.15': + resolution: {integrity: sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.12': + resolution: {integrity: sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.12': + resolution: {integrity: sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.12': + resolution: {integrity: sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.27': + resolution: {integrity: sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.44': + resolution: {integrity: sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.15': + resolution: {integrity: sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.12': + resolution: {integrity: sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.12': + resolution: {integrity: sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.5.0': + resolution: {integrity: sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.12': + resolution: {integrity: sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.12': + resolution: {integrity: sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.12': + resolution: {integrity: sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.12': + resolution: {integrity: sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.12': + resolution: {integrity: sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.7': + resolution: {integrity: sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.12': + resolution: {integrity: sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.7': + resolution: {integrity: sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.13.1': + resolution: {integrity: sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.12': + resolution: {integrity: sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.43': + resolution: {integrity: sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.47': + resolution: {integrity: sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.3.3': + resolution: {integrity: sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.12': + resolution: {integrity: sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.12': + resolution: {integrity: sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.20': + resolution: {integrity: sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.13': + resolution: {integrity: sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3195,6 +3536,9 @@ packages: botframework-streaming@4.23.3: resolution: {integrity: sha512-GMtciQGfZXtAW6syUqFpFJQ2vDyVbpxL3T1DqFzq/GmmkAu7KTZ1zvo7PTww6+IT1kMW0lmL/XZJVq3Rhg4PQA==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -3786,6 +4130,13 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.8: + resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4770,6 +5121,9 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + mnemonist@0.38.3: + resolution: {integrity: sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==} + motion-dom@12.34.0: resolution: {integrity: sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==} @@ -4885,6 +5239,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obliterator@1.6.1: + resolution: {integrity: sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -4968,6 +5325,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.2.0: + resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -5492,6 +5853,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strnum@2.2.1: + resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -6052,6 +6416,373 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.6 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-dynamodb@3.1014.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.23 + '@aws-sdk/credential-provider-node': 3.972.24 + '@aws-sdk/dynamodb-codec': 3.972.24 + '@aws-sdk/middleware-endpoint-discovery': 3.972.8 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.24 + '@aws-sdk/region-config-resolver': 3.972.9 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.10 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.23': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws-sdk/xml-builder': 3.972.15 + '@smithy/core': 3.23.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.21': + dependencies: + '@aws-sdk/core': 3.973.23 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.23': + dependencies: + '@aws-sdk/core': 3.973.23 + '@aws-sdk/types': 3.973.6 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.5.0 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.20 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.23': + dependencies: + '@aws-sdk/core': 3.973.23 + '@aws-sdk/credential-provider-env': 3.972.21 + '@aws-sdk/credential-provider-http': 3.972.23 + '@aws-sdk/credential-provider-login': 3.972.23 + '@aws-sdk/credential-provider-process': 3.972.21 + '@aws-sdk/credential-provider-sso': 3.972.23 + '@aws-sdk/credential-provider-web-identity': 3.972.23 + '@aws-sdk/nested-clients': 3.996.13 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.23': + dependencies: + '@aws-sdk/core': 3.973.23 + '@aws-sdk/nested-clients': 3.996.13 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.24': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.21 + '@aws-sdk/credential-provider-http': 3.972.23 + '@aws-sdk/credential-provider-ini': 3.972.23 + '@aws-sdk/credential-provider-process': 3.972.21 + '@aws-sdk/credential-provider-sso': 3.972.23 + '@aws-sdk/credential-provider-web-identity': 3.972.23 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.21': + dependencies: + '@aws-sdk/core': 3.973.23 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.23': + dependencies: + '@aws-sdk/core': 3.973.23 + '@aws-sdk/nested-clients': 3.996.13 + '@aws-sdk/token-providers': 3.1014.0 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.23': + dependencies: + '@aws-sdk/core': 3.973.23 + '@aws-sdk/nested-clients': 3.996.13 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/dynamodb-codec@3.972.24': + dependencies: + '@aws-sdk/core': 3.973.23 + '@smithy/core': 3.23.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@aws-sdk/endpoint-cache@3.972.4': + dependencies: + mnemonist: 0.38.3 + tslib: 2.8.1 + + '@aws-sdk/lib-dynamodb@3.1014.0(@aws-sdk/client-dynamodb@3.1014.0)': + dependencies: + '@aws-sdk/client-dynamodb': 3.1014.0 + '@aws-sdk/core': 3.973.23 + '@aws-sdk/util-dynamodb': 3.996.2(@aws-sdk/client-dynamodb@3.1014.0) + '@smithy/core': 3.23.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-endpoint-discovery@3.972.8': + dependencies: + '@aws-sdk/endpoint-cache': 3.972.4 + '@aws-sdk/types': 3.973.6 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.24': + dependencies: + '@aws-sdk/core': 3.973.23 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@smithy/core': 3.23.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-retry': 4.2.12 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.996.13': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.23 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.24 + '@aws-sdk/region-config-resolver': 3.972.9 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.10 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/config-resolver': 4.4.13 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1014.0': + dependencies: + '@aws-sdk/core': 3.973.23 + '@aws-sdk/nested-clients': 3.996.13 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.6': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1014.0)': + dependencies: + '@aws-sdk/client-dynamodb': 3.1014.0 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.5': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-endpoints': 3.3.3 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.10': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.24 + '@aws-sdk/types': 3.973.6 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.15': + dependencies: + '@smithy/types': 4.13.1 + fast-xml-parser: 5.5.8 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@azure/abort-controller@2.1.2': dependencies: tslib: 2.8.1 @@ -7960,6 +8691,287 @@ snapshots: transitivePeerDependencies: - debug + '@smithy/abort-controller@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.13': + dependencies: + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + tslib: 2.8.1 + + '@smithy/core@3.23.12': + dependencies: + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.12': + dependencies: + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.15': + dependencies: + '@smithy/protocol-http': 5.3.12 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.12': + dependencies: + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.27': + dependencies: + '@smithy/core': 3.23.12 + '@smithy/middleware-serde': 4.2.15 + '@smithy/node-config-provider': 4.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-middleware': 4.2.12 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.44': + dependencies: + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/service-error-classification': 4.2.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.15': + dependencies: + '@smithy/core': 3.23.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.12': + dependencies: + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.5.0': + dependencies: + '@smithy/abort-controller': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + + '@smithy/shared-ini-file-loader@4.4.7': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.12': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.7': + dependencies: + '@smithy/core': 3.23.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-stack': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.20 + tslib: 2.8.1 + + '@smithy/types@4.13.1': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.12': + dependencies: + '@smithy/querystring-parser': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.43': + dependencies: + '@smithy/property-provider': 4.2.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.47': + dependencies: + '@smithy/config-resolver': 4.4.13 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.3.3': + dependencies: + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.12': + dependencies: + '@smithy/service-error-classification': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.20': + dependencies: + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.5.0 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.13': + dependencies: + '@smithy/abort-controller': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@streamdown/cjk@1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.3)(unified@11.0.5)': @@ -8592,6 +9604,8 @@ snapshots: - bufferutil - utf-8-validate + bowser@2.14.1: {} + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -9240,6 +10254,16 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.2.0 + + fast-xml-parser@5.5.8: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.2.0 + strnum: 2.2.1 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -10629,6 +11653,10 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 + mnemonist@0.38.3: + dependencies: + obliterator: 1.6.1 + motion-dom@12.34.0: dependencies: motion-utils: 12.29.2 @@ -10719,6 +11747,8 @@ snapshots: object-inspect@1.13.4: {} + obliterator@1.6.1: {} + obug@2.1.1: {} oniguruma-parser@0.12.1: {} @@ -10823,6 +11853,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.2.0: {} + path-key@3.1.1: {} path-scurry@1.11.1: @@ -11508,6 +12540,8 @@ snapshots: strip-json-comments@5.0.3: {} + strnum@2.2.1: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 From 8245f18076e47dccbaae67fa656a3adec5186f82 Mon Sep 17 00:00:00 2001 From: Anatol Zakrividoroga <53095479+anatolzak@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:45:23 +0200 Subject: [PATCH 3/4] Add configurable pk, sk, and TTL attribute names Extracts DynamoDBStateSharedOptions to avoid duplicating options across interfaces. Adds pkName, skName, and ttlName options so users can map to existing table schemas. Includes tests for all custom attribute name paths (subscribe, get, lock, cache, list trim, getList, refreshListTtl). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/state-dynamodb/src/index.test.ts | 169 ++++++++++++++++++++++ packages/state-dynamodb/src/index.ts | 118 ++++++++------- 2 files changed, 238 insertions(+), 49 deletions(-) diff --git a/packages/state-dynamodb/src/index.test.ts b/packages/state-dynamodb/src/index.test.ts index ba690708..11c9fd46 100644 --- a/packages/state-dynamodb/src/index.test.ts +++ b/packages/state-dynamodb/src/index.test.ts @@ -616,4 +616,173 @@ describe("DynamoDBStateAdapter", () => { }); }); }); + + describe("custom attribute names", () => { + let adapter: DynamoDBStateAdapter; + let client: DynamoDBDocument; + + beforeEach(async () => { + client = createMockDocClient(); + adapter = new DynamoDBStateAdapter({ + client, + pkName: "PK", + skName: "SK", + ttlName: "ttl", + logger: createMockLogger(), + }); + await adapter.connect(); + }); + + afterEach(async () => { + await adapter.disconnect(); + }); + + it("should use custom pk and sk names in subscribe", async () => { + await adapter.subscribe("thread1"); + + expect(client.put).toHaveBeenCalledWith( + expect.objectContaining({ + Item: { PK: "chat-sdk#sub#thread1", SK: "_" }, + }) + ); + }); + + it("should use custom pk and sk names in key lookups", async () => { + vi.mocked(client.get).mockResolvedValue({ + Item: { PK: "chat-sdk#sub#thread1", SK: "_" }, + $metadata: {}, + }); + + const result = await adapter.isSubscribed("thread1"); + expect(result).toBe(true); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { PK: "chat-sdk#sub#thread1", SK: "_" }, + ProjectionExpression: "PK", + }) + ); + }); + + it("should use custom ttl name in acquireLock", async () => { + const lock = await adapter.acquireLock("thread1", 5000); + expect(lock).not.toBeNull(); + + expect(client.put).toHaveBeenCalledWith( + expect.objectContaining({ + Item: expect.objectContaining({ + PK: expect.any(String), + SK: "_", + ttl: expect.any(Number), + }), + }) + ); + }); + + it("should use custom ttl name in cache set", async () => { + await adapter.set("key", "value", 5000); + + expect(client.put).toHaveBeenCalledWith( + expect.objectContaining({ + Item: expect.objectContaining({ + PK: "chat-sdk#cache#key", + SK: "_", + ttl: expect.any(Number), + }), + }) + ); + }); + + it("should use custom sk name in trimList", async () => { + vi.mocked(client.update).mockResolvedValue({ + Attributes: { seq: 3 }, + $metadata: {}, + }); + vi.mocked(client.query).mockResolvedValue({ + Items: [ + { SK: "0000000000000001" }, + { SK: "0000000000000002" }, + { SK: "0000000000000003" }, + ], + $metadata: {}, + }); + + await adapter.appendToList("mylist", { id: 3 }, { maxLength: 1 }); + + expect(client.batchWrite).toHaveBeenCalledWith( + expect.objectContaining({ + RequestItems: { + "chat-state": expect.arrayContaining([ + { + DeleteRequest: { + Key: { PK: "chat-sdk#list#mylist", SK: "0000000000000001" }, + }, + }, + { + DeleteRequest: { + Key: { PK: "chat-sdk#list#mylist", SK: "0000000000000002" }, + }, + }, + ]), + }, + }) + ); + }); + + it("should return list items with custom key names in getList", async () => { + vi.mocked(client.query).mockResolvedValue({ + Items: [ + { + PK: "chat-sdk#list#mylist", + SK: "0000000000000001", + value: { id: 1 }, + }, + { + PK: "chat-sdk#list#mylist", + SK: "0000000000000002", + value: { id: 2 }, + }, + ], + $metadata: {}, + }); + + const result = await adapter.getList("mylist"); + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it("should use custom ttl name in refreshListTtl", async () => { + vi.mocked(client.update).mockResolvedValue({ + Attributes: { seq: 1 }, + $metadata: {}, + }); + vi.mocked(client.query).mockResolvedValue({ + Items: [ + { + PK: "chat-sdk#list#mylist", + SK: "0000000000000001", + value: { id: 1 }, + }, + ], + $metadata: {}, + }); + + await adapter.appendToList("mylist", { id: 1 }, { ttlMs: 60000 }); + + expect(client.batchWrite).toHaveBeenCalledWith( + expect.objectContaining({ + RequestItems: { + "chat-state": expect.arrayContaining([ + { + PutRequest: { + Item: expect.objectContaining({ + ttl: expect.any(Number), + expiresAtMs: expect.any(Number), + }), + }, + }, + ]), + }, + }) + ); + }); + }); }); diff --git a/packages/state-dynamodb/src/index.ts b/packages/state-dynamodb/src/index.ts index 1d647d9d..197337bd 100644 --- a/packages/state-dynamodb/src/index.ts +++ b/packages/state-dynamodb/src/index.ts @@ -8,32 +8,39 @@ import { ConsoleLogger } from "chat"; const DEFAULT_TABLE_NAME = "chat-state"; const DEFAULT_KEY_PREFIX = "chat-sdk"; +const DEFAULT_PK_NAME = "pk"; +const DEFAULT_SK_NAME = "sk"; +const DEFAULT_TTL_NAME = "expiresAt"; const SEQ_PAD_LENGTH = 16; const BATCH_WRITE_LIMIT = 25; -export interface DynamoDBStateAdapterOptions { - /** Custom DynamoDB endpoint (for DynamoDB Local development) */ - endpoint?: string; +export interface DynamoDBStateSharedOptions { /** Key prefix for multi-tenancy (default: "chat-sdk") */ keyPrefix?: string; /** Logger instance for error reporting */ logger?: Logger; - /** AWS region (default: from environment) */ - region?: string; + /** Partition key attribute name (default: "pk") */ + pkName?: string; + /** Sort key attribute name (default: "sk") */ + skName?: string; /** DynamoDB table name (default: "chat-state") */ tableName?: string; + /** TTL attribute name (default: "expiresAt") */ + ttlName?: string; +} + +export interface DynamoDBStateAdapterOptions + extends DynamoDBStateSharedOptions { + /** Custom DynamoDB endpoint (for DynamoDB Local development) */ + endpoint?: string; + /** AWS region (default: from environment) */ + region?: string; } -export interface DynamoDBStateClientOptions { +export interface DynamoDBStateClientOptions extends DynamoDBStateSharedOptions { /** Existing DynamoDBDocument instance */ client: DynamoDBDocument; - /** Key prefix for multi-tenancy (default: "chat-sdk") */ - keyPrefix?: string; - /** Logger instance for error reporting */ - logger?: Logger; - /** DynamoDB table name (default: "chat-state") */ - tableName?: string; } export type CreateDynamoDBStateOptions = @@ -46,6 +53,9 @@ export class DynamoDBStateAdapter implements StateAdapter { private readonly docClient: DynamoDBDocument; private readonly tableName: string; private readonly keyPrefix: string; + private readonly pkName: string; + private readonly skName: string; + private readonly ttlName: string; private readonly logger: Logger; private readonly ownsClient: boolean; private connected = false; @@ -69,6 +79,9 @@ export class DynamoDBStateAdapter implements StateAdapter { this.tableName = options.tableName ?? DEFAULT_TABLE_NAME; this.keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX; + this.pkName = options.pkName ?? DEFAULT_PK_NAME; + this.skName = options.skName ?? DEFAULT_SK_NAME; + this.ttlName = options.ttlName ?? DEFAULT_TTL_NAME; this.logger = options.logger ?? new ConsoleLogger("info").child("dynamodb"); } @@ -93,10 +106,7 @@ export class DynamoDBStateAdapter implements StateAdapter { await this.docClient.put({ TableName: this.tableName, - Item: { - pk: this.subKey(threadId), - sk: "_", - }, + Item: this.key(this.subKey(threadId), "_"), }); } @@ -105,7 +115,7 @@ export class DynamoDBStateAdapter implements StateAdapter { await this.docClient.delete({ TableName: this.tableName, - Key: { pk: this.subKey(threadId), sk: "_" }, + Key: this.key(this.subKey(threadId), "_"), }); } @@ -114,8 +124,8 @@ export class DynamoDBStateAdapter implements StateAdapter { const result = await this.docClient.get({ TableName: this.tableName, - Key: { pk: this.subKey(threadId), sk: "_" }, - ProjectionExpression: "pk", + Key: this.key(this.subKey(threadId), "_"), + ProjectionExpression: this.pkName, }); return result.Item !== undefined; @@ -132,13 +142,13 @@ export class DynamoDBStateAdapter implements StateAdapter { await this.docClient.put({ TableName: this.tableName, Item: { - pk: this.lockKey(threadId), - sk: "_", + ...this.key(this.lockKey(threadId), "_"), token, expiresAtMs, - expiresAt: msToSeconds(expiresAtMs), + [this.ttlName]: msToSeconds(expiresAtMs), }, - ConditionExpression: "attribute_not_exists(pk) OR expiresAtMs <= :now", + ConditionExpression: "attribute_not_exists(#pk) OR expiresAtMs <= :now", + ExpressionAttributeNames: { "#pk": this.pkName }, ExpressionAttributeValues: { ":now": now }, }); @@ -157,7 +167,7 @@ export class DynamoDBStateAdapter implements StateAdapter { try { await this.docClient.delete({ TableName: this.tableName, - Key: { pk: this.lockKey(lock.threadId), sk: "_" }, + Key: this.key(this.lockKey(lock.threadId), "_"), ConditionExpression: "#t = :token", ExpressionAttributeNames: { "#t": "token" }, ExpressionAttributeValues: { ":token": lock.token }, @@ -175,7 +185,7 @@ export class DynamoDBStateAdapter implements StateAdapter { await this.docClient.delete({ TableName: this.tableName, - Key: { pk: this.lockKey(threadId), sk: "_" }, + Key: this.key(this.lockKey(threadId), "_"), }); } @@ -188,10 +198,10 @@ export class DynamoDBStateAdapter implements StateAdapter { try { await this.docClient.update({ TableName: this.tableName, - Key: { pk: this.lockKey(lock.threadId), sk: "_" }, - UpdateExpression: "SET expiresAtMs = :newMs, expiresAt = :newSec", + Key: this.key(this.lockKey(lock.threadId), "_"), + UpdateExpression: "SET expiresAtMs = :newMs, #ttl = :newSec", ConditionExpression: "#t = :token AND expiresAtMs > :now", - ExpressionAttributeNames: { "#t": "token" }, + ExpressionAttributeNames: { "#t": "token", "#ttl": this.ttlName }, ExpressionAttributeValues: { ":token": lock.token, ":now": now, @@ -214,7 +224,7 @@ export class DynamoDBStateAdapter implements StateAdapter { const result = await this.docClient.get({ TableName: this.tableName, - Key: { pk: this.cacheKey(key), sk: "_" }, + Key: this.key(this.cacheKey(key), "_"), }); if (!result.Item) { @@ -235,15 +245,14 @@ export class DynamoDBStateAdapter implements StateAdapter { this.ensureConnected(); const item: Record = { - pk: this.cacheKey(key), - sk: "_", + ...this.key(this.cacheKey(key), "_"), value, }; if (ttlMs !== undefined) { const expiresAtMs = Date.now() + ttlMs; item.expiresAtMs = expiresAtMs; - item.expiresAt = msToSeconds(expiresAtMs); + item[this.ttlName] = msToSeconds(expiresAtMs); } await this.docClient.put({ TableName: this.tableName, Item: item }); @@ -258,22 +267,22 @@ export class DynamoDBStateAdapter implements StateAdapter { const now = Date.now(); const item: Record = { - pk: this.cacheKey(key), - sk: "_", + ...this.key(this.cacheKey(key), "_"), value, }; if (ttlMs !== undefined) { const expiresAtMs = now + ttlMs; item.expiresAtMs = expiresAtMs; - item.expiresAt = msToSeconds(expiresAtMs); + item[this.ttlName] = msToSeconds(expiresAtMs); } try { await this.docClient.put({ TableName: this.tableName, Item: item, - ConditionExpression: "attribute_not_exists(pk) OR expiresAtMs <= :now", + ConditionExpression: "attribute_not_exists(#pk) OR expiresAtMs <= :now", + ExpressionAttributeNames: { "#pk": this.pkName }, ExpressionAttributeValues: { ":now": now }, }); return true; @@ -290,7 +299,7 @@ export class DynamoDBStateAdapter implements StateAdapter { await this.docClient.delete({ TableName: this.tableName, - Key: { pk: this.cacheKey(key), sk: "_" }, + Key: this.key(this.cacheKey(key), "_"), }); } @@ -303,7 +312,7 @@ export class DynamoDBStateAdapter implements StateAdapter { const counterResult = await this.docClient.update({ TableName: this.tableName, - Key: { pk: this.listCounterKey(key), sk: "_" }, + Key: this.key(this.listCounterKey(key), "_"), UpdateExpression: "ADD seq :one", ExpressionAttributeValues: { ":one": 1 }, ReturnValues: "ALL_NEW", @@ -313,15 +322,14 @@ export class DynamoDBStateAdapter implements StateAdapter { const sk = String(seq).padStart(SEQ_PAD_LENGTH, "0"); const item: Record = { - pk: this.listKey(key), - sk, + ...this.key(this.listKey(key), sk), value, }; if (options?.ttlMs !== undefined) { const expiresAtMs = Date.now() + options.ttlMs; item.expiresAtMs = expiresAtMs; - item.expiresAt = msToSeconds(expiresAtMs); + item[this.ttlName] = msToSeconds(expiresAtMs); } await this.docClient.put({ TableName: this.tableName, Item: item }); @@ -389,7 +397,8 @@ export class DynamoDBStateAdapter implements StateAdapter { do { const result = await this.docClient.query({ TableName: this.tableName, - KeyConditionExpression: "pk = :pk", + KeyConditionExpression: "#pk = :pk", + ExpressionAttributeNames: { "#pk": this.pkName }, ExpressionAttributeValues: { ":pk": this.listKey(key) }, ScanIndexForward: true, ExclusiveStartKey: exclusiveStartKey, @@ -411,16 +420,17 @@ export class DynamoDBStateAdapter implements StateAdapter { do { const result = await this.docClient.query({ TableName: this.tableName, - KeyConditionExpression: "pk = :pk", + KeyConditionExpression: "#pk = :pk", + ExpressionAttributeNames: { "#pk": this.pkName }, ExpressionAttributeValues: { ":pk": this.listKey(key) }, - ProjectionExpression: "sk", + ProjectionExpression: this.skName, ScanIndexForward: true, ExclusiveStartKey: exclusiveStartKey, }); if (result.Items) { for (const item of result.Items) { - allKeys.push(item.sk as string); + allKeys.push(item[this.skName] as string); } } exclusiveStartKey = result.LastEvaluatedKey; @@ -432,7 +442,7 @@ export class DynamoDBStateAdapter implements StateAdapter { } const keysToDelete = allKeys.slice(0, overflow); - const pk = this.listKey(key); + const pkValue = this.listKey(key); for (let i = 0; i < keysToDelete.length; i += BATCH_WRITE_LIMIT) { const batch = keysToDelete.slice(i, i + BATCH_WRITE_LIMIT); @@ -440,7 +450,7 @@ export class DynamoDBStateAdapter implements StateAdapter { const result = await this.docClient.batchWrite({ RequestItems: { [this.tableName]: batch.map((sk) => ({ - DeleteRequest: { Key: { pk, sk } }, + DeleteRequest: { Key: this.key(pkValue, sk) }, })), }, }); @@ -458,7 +468,7 @@ export class DynamoDBStateAdapter implements StateAdapter { const items = await this.queryAllListItems(key); const now = Date.now(); const expiresAtMs = now + ttlMs; - const expiresAt = msToSeconds(expiresAtMs); + const ttlSeconds = msToSeconds(expiresAtMs); for (let i = 0; i < items.length; i += BATCH_WRITE_LIMIT) { const batch = items.slice(i, i + BATCH_WRITE_LIMIT); @@ -467,7 +477,7 @@ export class DynamoDBStateAdapter implements StateAdapter { RequestItems: { [this.tableName]: batch.map((item) => ({ PutRequest: { - Item: { ...item, expiresAtMs, expiresAt }, + Item: { ...item, expiresAtMs, [this.ttlName]: ttlSeconds }, }, })), }, @@ -482,6 +492,10 @@ export class DynamoDBStateAdapter implements StateAdapter { } } + private key(pk: string, sk: string): Record { + return { [this.pkName]: pk, [this.skName]: sk }; + } + private ensureConnected(): void { if (!this.connected) { throw new Error( @@ -507,6 +521,9 @@ export function createDynamoDBState( client: options.client, tableName: options.tableName, keyPrefix: options.keyPrefix, + pkName: options.pkName, + skName: options.skName, + ttlName: options.ttlName, logger: options.logger, }); } @@ -515,6 +532,9 @@ export function createDynamoDBState( return new DynamoDBStateAdapter({ tableName: opts.tableName, keyPrefix: opts.keyPrefix, + pkName: opts.pkName, + skName: opts.skName, + ttlName: opts.ttlName, region: opts.region, endpoint: opts.endpoint, logger: opts.logger, From 956f5540f171ab1ec341d5e33d26213de5079303 Mon Sep 17 00:00:00 2001 From: Anatol Zakrividoroga <53095479+anatolzak@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:57:04 +0200 Subject: [PATCH 4/4] Use ExpressionAttributeNames for all configurable attribute names ProjectionExpression in isSubscribed and trimList used raw attribute names which would fail if pkName or skName is a DynamoDB reserved word. Now uses #pk and #sk placeholders everywhere. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/state-dynamodb/src/index.test.ts | 3 ++- packages/state-dynamodb/src/index.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/state-dynamodb/src/index.test.ts b/packages/state-dynamodb/src/index.test.ts index 11c9fd46..524f62eb 100644 --- a/packages/state-dynamodb/src/index.test.ts +++ b/packages/state-dynamodb/src/index.test.ts @@ -658,7 +658,8 @@ describe("DynamoDBStateAdapter", () => { expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ Key: { PK: "chat-sdk#sub#thread1", SK: "_" }, - ProjectionExpression: "PK", + ProjectionExpression: "#pk", + ExpressionAttributeNames: { "#pk": "PK" }, }) ); }); diff --git a/packages/state-dynamodb/src/index.ts b/packages/state-dynamodb/src/index.ts index 197337bd..2f0292d3 100644 --- a/packages/state-dynamodb/src/index.ts +++ b/packages/state-dynamodb/src/index.ts @@ -125,7 +125,8 @@ export class DynamoDBStateAdapter implements StateAdapter { const result = await this.docClient.get({ TableName: this.tableName, Key: this.key(this.subKey(threadId), "_"), - ProjectionExpression: this.pkName, + ProjectionExpression: "#pk", + ExpressionAttributeNames: { "#pk": this.pkName }, }); return result.Item !== undefined; @@ -421,9 +422,9 @@ export class DynamoDBStateAdapter implements StateAdapter { const result = await this.docClient.query({ TableName: this.tableName, KeyConditionExpression: "#pk = :pk", - ExpressionAttributeNames: { "#pk": this.pkName }, + ExpressionAttributeNames: { "#pk": this.pkName, "#sk": this.skName }, ExpressionAttributeValues: { ":pk": this.listKey(key) }, - ProjectionExpression: this.skName, + ProjectionExpression: "#sk", ScanIndexForward: true, ExclusiveStartKey: exclusiveStartKey, });