import { z } from 'zod' import { StructuredTool } from '@langchain/core/tools' import { ICommonObject, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../src/Interface' import { getBaseClasses } from '../../../src/utils' import { AWS_REGIONS, DEFAULT_AWS_REGION, AWSCredentials, getAWSCredentials } from '../../../src/awsToolsUtils' import { DynamoDBClient, DescribeTableCommand, ListTablesCommand, PutItemCommand, QueryCommand } from '@aws-sdk/client-dynamodb' // Operation enum enum Operation { STORE = 'store', RETRIEVE = 'retrieve' } // Constants const ERROR_PLACEHOLDER = 'error' const KEY_SEPARATOR = '#' const MAX_KEY_LENGTH = 2048 // DynamoDB limit for partition key // Helper function to create DynamoDB client function createDynamoDBClient(credentials: AWSCredentials, region: string): DynamoDBClient { return new DynamoDBClient({ region, credentials: { accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, ...(credentials.sessionToken && { sessionToken: credentials.sessionToken }) } }) } // Helper function to build full key with optional prefix function buildFullKey(key: string, keyPrefix: string): string { const fullKey = keyPrefix ? `${keyPrefix}${KEY_SEPARATOR}${key}` : key // Validate key length (DynamoDB limit) if (fullKey.length > MAX_KEY_LENGTH) { throw new Error(`Key too long. Maximum length is ${MAX_KEY_LENGTH} characters, got ${fullKey.length}`) } return fullKey } // Helper function to validate and sanitize input function validateKey(key: string): void { if (!key || key.trim().length === 0) { throw new Error('Key must be a non-empty string') } } /** * Tool for storing key-value pairs in DynamoDB with automatic versioning */ class DynamoDBStoreTool extends StructuredTool { name = 'dynamodb_kv_store' description = 'Store a text value with a key in DynamoDB. Input must be an object with "key" and "value" properties.' schema = z.object({ key: z.string().min(1).describe('The key to store the value under'), value: z.string().describe('The text value to store') }) private readonly dynamoClient: DynamoDBClient private readonly tableName: string private readonly keyPrefix: string constructor(dynamoClient: DynamoDBClient, tableName: string, keyPrefix: string = '') { super() this.dynamoClient = dynamoClient this.tableName = tableName this.keyPrefix = keyPrefix } async _call({ key, value }: z.infer): Promise { try { validateKey(key) const fullKey = buildFullKey(key, this.keyPrefix) const timestamp = Date.now() const isoTimestamp = new Date(timestamp).toISOString() const putCommand = new PutItemCommand({ TableName: this.tableName, Item: { pk: { S: fullKey }, sk: { S: timestamp.toString() }, value: { S: value }, timestamp: { S: isoTimestamp } } }) await this.dynamoClient.send(putCommand) return `Successfully stored value with key "${key}" at ${isoTimestamp}` } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) throw new Error(`Failed to store value: ${errorMessage}`) } } } /** * Tool for retrieving key-value pairs from DynamoDB with version control */ class DynamoDBRetrieveTool extends StructuredTool { name = 'dynamodb_kv_retrieve' description = 'Retrieve a value by key from DynamoDB. Returns JSON with value and timestamp. Specify which version to get (1=latest, 2=2nd latest, etc).' schema = z.object({ key: z.string().min(1).describe('The key to retrieve the value for'), nthLatest: z .string() .regex(/^\d+$/, 'Must be a positive number') .describe( 'Which version to retrieve: "1" for latest, "2" for 2nd latest, "3" for 3rd latest, etc. Use "1" to get the most recent value.' ) .optional() .default('1') }) private readonly dynamoClient: DynamoDBClient private readonly tableName: string private readonly keyPrefix: string constructor(dynamoClient: DynamoDBClient, tableName: string, keyPrefix: string = '') { super() this.dynamoClient = dynamoClient this.tableName = tableName this.keyPrefix = keyPrefix } async _call(input: z.infer): Promise { try { const { key, nthLatest = '1' } = input validateKey(key) const fullKey = buildFullKey(key, this.keyPrefix) // Convert string to number and validate const nthLatestNum = parseInt(nthLatest, 10) if (isNaN(nthLatestNum) || nthLatestNum < 1) { throw new Error('nthLatest must be a positive number (1 or greater)') } const queryCommand = new QueryCommand({ TableName: this.tableName, KeyConditionExpression: 'pk = :pk', ExpressionAttributeValues: { ':pk': { S: fullKey } }, ScanIndexForward: false, // Sort descending (newest first) Limit: nthLatestNum }) const result = await this.dynamoClient.send(queryCommand) if (!result.Items || result.Items.length === 0) { return JSON.stringify({ value: null, timestamp: null }) } if (result.Items.length < nthLatestNum) { return JSON.stringify({ value: null, timestamp: null }) } const item = result.Items[nthLatestNum - 1] const value = item.value?.S || null const timestamp = item.timestamp?.S || item.sk?.S || null // Return JSON with value and timestamp return JSON.stringify({ value: value, timestamp: timestamp }) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) throw new Error(`Failed to retrieve value: ${errorMessage}`) } } } /** * Node implementation for AWS DynamoDB KV Storage tools */ class AWSDynamoDBKVStorage_Tools implements INode { label: string name: string version: number type: string icon: string category: string description: string baseClasses: string[] credential: INodeParams inputs: INodeParams[] constructor() { this.label = 'AWS DynamoDB KV Storage' this.name = 'awsDynamoDBKVStorage' this.version = 1.0 this.type = 'AWSDynamoDBKVStorage' this.icon = 'dynamodbkvstorage.svg' this.category = 'Tools' this.description = 'Store and retrieve versioned text values in AWS DynamoDB' this.baseClasses = [this.type, ...getBaseClasses(DynamoDBStoreTool)] this.credential = { label: 'AWS Credentials', name: 'credential', type: 'credential', credentialNames: ['awsApi'] } this.inputs = [ { label: 'AWS Region', name: 'region', type: 'options', options: AWS_REGIONS, default: DEFAULT_AWS_REGION, description: 'AWS Region where your DynamoDB tables are located' }, { label: 'DynamoDB Table', name: 'tableName', type: 'asyncOptions', loadMethod: 'listTables', description: 'Select a DynamoDB table with partition key "pk" and sort key "sk"', refresh: true }, { label: 'Key Prefix', name: 'keyPrefix', type: 'string', description: 'Optional prefix to add to all keys (e.g., "myapp" would make keys like "myapp#userdata")', optional: true, additionalParams: true }, { label: 'Operation', name: 'operation', type: 'options', options: [ { label: 'Store', name: Operation.STORE }, { label: 'Retrieve', name: Operation.RETRIEVE } ], default: Operation.STORE, description: 'Choose whether to store or retrieve data' } ] } loadMethods: Record Promise> = { listTables: async (nodeData: INodeData, options?: ICommonObject): Promise => { try { const credentials = await getAWSCredentials(nodeData, options ?? {}) const region = (nodeData.inputs?.region as string) || DEFAULT_AWS_REGION const dynamoClient = createDynamoDBClient(credentials, region) const listCommand = new ListTablesCommand({}) const listResponse = await dynamoClient.send(listCommand) if (!listResponse.TableNames || listResponse.TableNames.length === 0) { return [ { label: 'No tables found', name: ERROR_PLACEHOLDER, description: 'No DynamoDB tables found in this region' } ] } const validTables: INodeOptionsValue[] = [] const invalidTables: string[] = [] // Check tables in parallel for better performance const tableChecks = await Promise.allSettled( listResponse.TableNames.map(async (tableName) => { const describeCommand = new DescribeTableCommand({ TableName: tableName }) const describeResponse = await dynamoClient.send(describeCommand) const keySchema = describeResponse.Table?.KeySchema if (keySchema) { const hasPk = keySchema.some((key) => key.AttributeName === 'pk' && key.KeyType === 'HASH') const hasSk = keySchema.some((key) => key.AttributeName === 'sk' && key.KeyType === 'RANGE') if (hasPk && hasSk) { return { valid: true, table: { label: tableName, name: tableName, description: `Table with pk (partition) and sk (sort) keys` } } } } return { valid: false, tableName } }) ) tableChecks.forEach((result) => { if (result.status === 'fulfilled') { if (result.value.valid) { validTables.push(result.value.table!) } else if (result.value.tableName) { invalidTables.push(result.value.tableName) } } }) if (validTables.length === 0) { return [ { label: 'No compatible tables found', name: ERROR_PLACEHOLDER, description: `No tables with partition key "pk" and sort key "sk" found. ${ invalidTables.length > 0 ? `Found ${invalidTables.length} table(s) with different schema.` : '' } Please create a table with these keys.` } ] } // Sort tables alphabetically validTables.sort((a, b) => a.label.localeCompare(b.label)) return validTables } catch (error) { if (error instanceof Error && error.message.includes('AWS Access Key')) { return [ { label: 'AWS Credentials Required', name: ERROR_PLACEHOLDER, description: 'Enter AWS Access Key ID and Secret Access Key' } ] } console.error('Error loading DynamoDB tables:', error) return [ { label: 'Error Loading Tables', name: ERROR_PLACEHOLDER, description: `Failed to load tables: ${error instanceof Error ? error.message : String(error)}` } ] } } } async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const credentials = await getAWSCredentials(nodeData, options) const region = (nodeData.inputs?.region as string) || DEFAULT_AWS_REGION const tableName = nodeData.inputs?.tableName as string const keyPrefix = (nodeData.inputs?.keyPrefix as string) || '' const operation = (nodeData.inputs?.operation as string) || Operation.STORE if (!tableName || tableName === ERROR_PLACEHOLDER) { throw new Error('Valid DynamoDB Table selection is required') } // Validate key prefix doesn't contain separator if (keyPrefix && keyPrefix.includes(KEY_SEPARATOR)) { throw new Error(`Key prefix cannot contain "${KEY_SEPARATOR}" character`) } const dynamoClient = createDynamoDBClient(credentials, region) if (operation === Operation.STORE) { return new DynamoDBStoreTool(dynamoClient, tableName, keyPrefix) } else { return new DynamoDBRetrieveTool(dynamoClient, tableName, keyPrefix) } } } module.exports = { nodeClass: AWSDynamoDBKVStorage_Tools }