@mastra/dynamodb 0.0.2-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1036 @@
1
+ import { DynamoDBClient, DescribeTableCommand } from '@aws-sdk/client-dynamodb';
2
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
+ import type { StorageThreadType, MessageType, WorkflowRunState } from '@mastra/core';
4
+ import {
5
+ MastraStorage,
6
+ TABLE_THREADS,
7
+ TABLE_MESSAGES,
8
+ TABLE_WORKFLOW_SNAPSHOT,
9
+ TABLE_EVALS,
10
+ TABLE_TRACES,
11
+ } from '@mastra/core/storage';
12
+ import type { EvalRow, StorageGetMessagesArg, WorkflowRun, WorkflowRuns, TABLE_NAMES } from '@mastra/core/storage';
13
+ import type { Service } from 'electrodb';
14
+ import { getElectroDbService } from '../entities';
15
+
16
+ export interface DynamoDBStoreConfig {
17
+ region?: string;
18
+ tableName: string;
19
+ endpoint?: string;
20
+ credentials?: {
21
+ accessKeyId: string;
22
+ secretAccessKey: string;
23
+ };
24
+ }
25
+
26
+ // Define a type for our service that allows string indexing
27
+ type MastraService = Service<Record<string, any>> & {
28
+ [key: string]: any;
29
+ };
30
+
31
+ // Define the structure for workflow snapshot items retrieved from DynamoDB
32
+ interface WorkflowSnapshotDBItem {
33
+ entity: string; // Typically 'workflow_snapshot'
34
+ workflow_name: string;
35
+ run_id: string;
36
+ snapshot: WorkflowRunState; // Should be WorkflowRunState after ElectroDB get attribute processing
37
+ createdAt: string; // ISO Date string
38
+ updatedAt: string; // ISO Date string
39
+ resourceId?: string;
40
+ }
41
+
42
+ export class DynamoDBStore extends MastraStorage {
43
+ private tableName: string;
44
+ private client: DynamoDBDocumentClient;
45
+ private service: MastraService;
46
+ protected hasInitialized: Promise<boolean> | null = null;
47
+
48
+ constructor({ name, config }: { name: string; config: DynamoDBStoreConfig }) {
49
+ super({ name });
50
+
51
+ // Validate required config
52
+ if (!config.tableName || typeof config.tableName !== 'string' || config.tableName.trim() === '') {
53
+ throw new Error('DynamoDBStore: config.tableName must be provided and cannot be empty.');
54
+ }
55
+ // Validate tableName characters (basic check)
56
+ if (!/^[a-zA-Z0-9_.-]{3,255}$/.test(config.tableName)) {
57
+ throw new Error(
58
+ `DynamoDBStore: config.tableName "${config.tableName}" contains invalid characters or is not between 3 and 255 characters long.`,
59
+ );
60
+ }
61
+
62
+ const dynamoClient = new DynamoDBClient({
63
+ region: config.region || 'us-east-1',
64
+ endpoint: config.endpoint,
65
+ credentials: config.credentials,
66
+ });
67
+
68
+ this.tableName = config.tableName;
69
+ this.client = DynamoDBDocumentClient.from(dynamoClient);
70
+ this.service = getElectroDbService(this.client, this.tableName) as MastraService;
71
+
72
+ // We're using a single table design with ElectroDB,
73
+ // so we don't need to create multiple tables
74
+ }
75
+
76
+ /**
77
+ * This method is modified for DynamoDB with ElectroDB single-table design.
78
+ * It assumes the table is created and managed externally via CDK/CloudFormation.
79
+ *
80
+ * This implementation only validates that the required table exists and is accessible.
81
+ * No table creation is attempted - we simply check if we can access the table.
82
+ */
83
+ async createTable({ tableName }: { tableName: TABLE_NAMES; schema: Record<string, any> }): Promise<void> {
84
+ this.logger.debug('Validating access to externally managed table', { tableName, physicalTable: this.tableName });
85
+
86
+ // For single-table design, we just need to verify the table exists and is accessible
87
+ try {
88
+ const tableExists = await this.validateTableExists();
89
+
90
+ if (!tableExists) {
91
+ this.logger.error(
92
+ `Table ${this.tableName} does not exist or is not accessible. It should be created via CDK/CloudFormation.`,
93
+ );
94
+ throw new Error(
95
+ `Table ${this.tableName} does not exist or is not accessible. Ensure it's created via CDK/CloudFormation before using this store.`,
96
+ );
97
+ }
98
+
99
+ this.logger.debug(`Table ${this.tableName} exists and is accessible`);
100
+ } catch (error) {
101
+ this.logger.error('Error validating table access', { tableName: this.tableName, error });
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Validates that the required DynamoDB table exists and is accessible.
108
+ * This does not check the table structure - it assumes the table
109
+ * was created with the correct structure via CDK/CloudFormation.
110
+ */
111
+ private async validateTableExists(): Promise<boolean> {
112
+ try {
113
+ const command = new DescribeTableCommand({
114
+ TableName: this.tableName,
115
+ });
116
+
117
+ // If the table exists, this call will succeed
118
+ // If the table doesn't exist, it will throw a ResourceNotFoundException
119
+ await this.client.send(command);
120
+ return true;
121
+ } catch (error: any) {
122
+ // If the table doesn't exist, DynamoDB returns a ResourceNotFoundException
123
+ if (error.name === 'ResourceNotFoundException') {
124
+ return false;
125
+ }
126
+
127
+ // For other errors (like permissions issues), we should throw
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Initialize storage, validating the externally managed table is accessible.
134
+ * For the single-table design, we only validate once that we can access
135
+ * the table that was created via CDK/CloudFormation.
136
+ */
137
+ async init(): Promise<void> {
138
+ if (this.hasInitialized === null) {
139
+ // If no initialization promise exists, create and store it.
140
+ // This assignment ensures that even if multiple calls arrive here concurrently,
141
+ // they will all eventually await the same promise instance created by the first one
142
+ // to complete this assignment.
143
+ this.hasInitialized = this._performInitializationAndStore();
144
+ }
145
+
146
+ try {
147
+ // Await the stored promise.
148
+ // If initialization was successful, this resolves.
149
+ // If it failed, this will re-throw the error caught and re-thrown by _performInitializationAndStore.
150
+ await this.hasInitialized;
151
+ } catch (error) {
152
+ // The error has already been handled by _performInitializationAndStore
153
+ // (i.e., this.hasInitialized was reset). Re-throwing here ensures
154
+ // the caller of init() is aware of the failure.
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Performs the actual table validation and stores the promise.
161
+ * Handles resetting the stored promise on failure to allow retries.
162
+ */
163
+ private _performInitializationAndStore(): Promise<boolean> {
164
+ return this.validateTableExists()
165
+ .then(exists => {
166
+ if (!exists) {
167
+ throw new Error(
168
+ `Table ${this.tableName} does not exist or is not accessible. Ensure it's created via CDK/CloudFormation before using this store.`,
169
+ );
170
+ }
171
+ // Successfully initialized
172
+ return true;
173
+ })
174
+ .catch(err => {
175
+ // Initialization failed. Clear the stored promise to allow future calls to init() to retry.
176
+ this.hasInitialized = null;
177
+ // Re-throw the error so it can be caught by the awaiter in init()
178
+ throw err;
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Clear all items from a logical "table" (entity type)
184
+ */
185
+ async clearTable({ tableName }: { tableName: TABLE_NAMES }): Promise<void> {
186
+ this.logger.debug('DynamoDB clearTable called', { tableName });
187
+
188
+ const entityName = this.getEntityNameForTable(tableName);
189
+ if (!entityName || !this.service.entities[entityName]) {
190
+ throw new Error(`No entity defined for ${tableName}`);
191
+ }
192
+
193
+ try {
194
+ // Scan requires no key, just uses the entity handler
195
+ const result = await this.service.entities[entityName].scan.go({ pages: 'all' }); // Get all pages
196
+
197
+ if (!result.data.length) {
198
+ this.logger.debug(`No records found to clear for ${tableName}`);
199
+ return;
200
+ }
201
+
202
+ this.logger.debug(`Found ${result.data.length} records to delete for ${tableName}`);
203
+
204
+ // ElectroDB batch delete expects the key components for each item
205
+ const keysToDelete = result.data.map((item: any) => {
206
+ const key: { entity: string; [key: string]: any } = { entity: entityName };
207
+
208
+ // Construct the key based on the specific entity's primary key structure
209
+ switch (entityName) {
210
+ case 'thread':
211
+ if (!item.id) throw new Error(`Missing required key 'id' for entity 'thread'`);
212
+ key.id = item.id;
213
+ break;
214
+ case 'message':
215
+ if (!item.id) throw new Error(`Missing required key 'id' for entity 'message'`);
216
+ key.id = item.id;
217
+ break;
218
+ case 'workflowSnapshot':
219
+ if (!item.workflow_name)
220
+ throw new Error(`Missing required key 'workflow_name' for entity 'workflowSnapshot'`);
221
+ if (!item.run_id) throw new Error(`Missing required key 'run_id' for entity 'workflowSnapshot'`);
222
+ key.workflow_name = item.workflow_name;
223
+ key.run_id = item.run_id;
224
+ break;
225
+ case 'eval':
226
+ // Assuming 'eval' uses 'run_id' or another unique identifier as part of its PK
227
+ // Adjust based on the actual primary key defined in getElectroDbService
228
+ if (!item.run_id) throw new Error(`Missing required key 'run_id' for entity 'eval'`);
229
+ // Add other key components if necessary for 'eval' PK
230
+ key.run_id = item.run_id;
231
+ // Example: if global_run_id is also part of PK:
232
+ // if (!item.global_run_id) throw new Error(`Missing required key 'global_run_id' for entity 'eval'`);
233
+ // key.global_run_id = item.global_run_id;
234
+ break;
235
+ case 'trace':
236
+ // Assuming 'trace' uses 'id' as its PK
237
+ // Adjust based on the actual primary key defined in getElectroDbService
238
+ if (!item.id) throw new Error(`Missing required key 'id' for entity 'trace'`);
239
+ key.id = item.id;
240
+ break;
241
+ default:
242
+ // Handle unknown entity types - log a warning or throw an error
243
+ this.logger.warn(`Unknown entity type encountered during clearTable: ${entityName}`);
244
+ // Optionally throw an error if strict handling is required
245
+ throw new Error(`Cannot construct delete key for unknown entity type: ${entityName}`);
246
+ }
247
+
248
+ return key;
249
+ });
250
+
251
+ const batchSize = 25;
252
+ for (let i = 0; i < keysToDelete.length; i += batchSize) {
253
+ const batchKeys = keysToDelete.slice(i, i + batchSize);
254
+ // Pass the array of key objects to delete
255
+ await this.service.entities[entityName].delete(batchKeys).go();
256
+ }
257
+
258
+ this.logger.debug(`Successfully cleared all records for ${tableName}`);
259
+ } catch (error) {
260
+ this.logger.error('Failed to clear table', { tableName, error });
261
+ throw error;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Insert a record into the specified "table" (entity)
267
+ */
268
+ async insert({ tableName, record }: { tableName: TABLE_NAMES; record: Record<string, any> }): Promise<void> {
269
+ this.logger.debug('DynamoDB insert called', { tableName });
270
+
271
+ const entityName = this.getEntityNameForTable(tableName);
272
+ if (!entityName || !this.service.entities[entityName]) {
273
+ throw new Error(`No entity defined for ${tableName}`);
274
+ }
275
+
276
+ try {
277
+ // Add the entity type to the record before creating
278
+ const dataToSave = { entity: entityName, ...record };
279
+ await this.service.entities[entityName].create(dataToSave).go();
280
+ } catch (error) {
281
+ this.logger.error('Failed to insert record', { tableName, error });
282
+ throw error;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Insert multiple records as a batch
288
+ */
289
+ async batchInsert({ tableName, records }: { tableName: TABLE_NAMES; records: Record<string, any>[] }): Promise<void> {
290
+ this.logger.debug('DynamoDB batchInsert called', { tableName, count: records.length });
291
+
292
+ const entityName = this.getEntityNameForTable(tableName);
293
+ if (!entityName || !this.service.entities[entityName]) {
294
+ throw new Error(`No entity defined for ${tableName}`);
295
+ }
296
+
297
+ // Add entity type to each record
298
+ const recordsToSave = records.map(rec => ({ entity: entityName, ...rec }));
299
+
300
+ // ElectroDB has batch limits of 25 items, so we need to chunk
301
+ const batchSize = 25;
302
+ const batches = [];
303
+ for (let i = 0; i < recordsToSave.length; i += batchSize) {
304
+ const batch = recordsToSave.slice(i, i + batchSize);
305
+ batches.push(batch);
306
+ }
307
+
308
+ try {
309
+ // Process each batch
310
+ for (const batch of batches) {
311
+ // Create each item individually within the batch
312
+ for (const recordData of batch) {
313
+ if (!recordData.entity) {
314
+ this.logger.error('Missing entity property in record data for batchInsert', { recordData, tableName });
315
+ throw new Error(`Internal error: Missing entity property during batchInsert for ${tableName}`);
316
+ }
317
+ // Log the object just before the create call
318
+ this.logger.debug('Attempting to create record in batchInsert:', { entityName, recordData });
319
+ await this.service.entities[entityName].create(recordData).go();
320
+ }
321
+ // Original batch call: await this.service.entities[entityName].create(batch).go();
322
+ }
323
+ } catch (error) {
324
+ this.logger.error('Failed to batch insert records', { tableName, error });
325
+ throw error;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Load a record by its keys
331
+ */
332
+ async load<R>({ tableName, keys }: { tableName: TABLE_NAMES; keys: Record<string, string> }): Promise<R | null> {
333
+ this.logger.debug('DynamoDB load called', { tableName, keys });
334
+
335
+ const entityName = this.getEntityNameForTable(tableName);
336
+ if (!entityName || !this.service.entities[entityName]) {
337
+ throw new Error(`No entity defined for ${tableName}`);
338
+ }
339
+
340
+ try {
341
+ // Add the entity type to the key object for the .get call
342
+ const keyObject = { entity: entityName, ...keys };
343
+ const result = await this.service.entities[entityName].get(keyObject).go();
344
+
345
+ if (!result.data) {
346
+ return null;
347
+ }
348
+
349
+ // Add parsing logic if necessary (e.g., for metadata)
350
+ let data = result.data;
351
+ if (data.metadata && typeof data.metadata === 'string') {
352
+ try {
353
+ // data.metadata = JSON.parse(data.metadata); // REMOVED by AI
354
+ } catch {
355
+ /* ignore parse error */
356
+ }
357
+ }
358
+ // Add similar parsing for other JSON fields if needed based on entity type
359
+
360
+ return data as R;
361
+ } catch (error) {
362
+ this.logger.error('Failed to load record', { tableName, keys, error });
363
+ throw error;
364
+ }
365
+ }
366
+
367
+ // Thread operations
368
+ async getThreadById({ threadId }: { threadId: string }): Promise<StorageThreadType | null> {
369
+ this.logger.debug('Getting thread by ID', { threadId });
370
+ try {
371
+ const result = await this.service.entities.thread.get({ entity: 'thread', id: threadId }).go();
372
+
373
+ if (!result.data) {
374
+ return null;
375
+ }
376
+
377
+ // ElectroDB handles the transformation with attribute getters
378
+ const data = result.data;
379
+ return {
380
+ ...data,
381
+ // metadata: data.metadata ? JSON.parse(data.metadata) : undefined, // REMOVED by AI
382
+ // metadata is already transformed by the entity's getter
383
+ } as StorageThreadType;
384
+ } catch (error) {
385
+ this.logger.error('Failed to get thread by ID', { threadId, error });
386
+ throw error;
387
+ }
388
+ }
389
+
390
+ async getThreadsByResourceId({ resourceId }: { resourceId: string }): Promise<StorageThreadType[]> {
391
+ this.logger.debug('Getting threads by resource ID', { resourceId });
392
+ try {
393
+ const result = await this.service.entities.thread.query.byResource({ entity: 'thread', resourceId }).go();
394
+
395
+ if (!result.data.length) {
396
+ return [];
397
+ }
398
+
399
+ // ElectroDB handles the transformation with attribute getters
400
+ return result.data.map((data: any) => ({
401
+ ...data,
402
+ // metadata: data.metadata ? JSON.parse(data.metadata) : undefined, // REMOVED by AI
403
+ // metadata is already transformed by the entity's getter
404
+ })) as StorageThreadType[];
405
+ } catch (error) {
406
+ this.logger.error('Failed to get threads by resource ID', { resourceId, error });
407
+ throw error;
408
+ }
409
+ }
410
+
411
+ async saveThread({ thread }: { thread: StorageThreadType }): Promise<StorageThreadType> {
412
+ this.logger.debug('Saving thread', { threadId: thread.id });
413
+
414
+ const now = new Date();
415
+
416
+ const threadData = {
417
+ entity: 'thread',
418
+ id: thread.id,
419
+ resourceId: thread.resourceId,
420
+ title: thread.title || `Thread ${thread.id}`,
421
+ createdAt: thread.createdAt?.toISOString() || now.toISOString(),
422
+ updatedAt: now.toISOString(),
423
+ metadata: thread.metadata ? JSON.stringify(thread.metadata) : undefined,
424
+ };
425
+
426
+ try {
427
+ await this.service.entities.thread.create(threadData).go();
428
+
429
+ return {
430
+ id: thread.id,
431
+ resourceId: thread.resourceId,
432
+ title: threadData.title,
433
+ createdAt: thread.createdAt || now,
434
+ updatedAt: now,
435
+ metadata: thread.metadata,
436
+ };
437
+ } catch (error) {
438
+ this.logger.error('Failed to save thread', { threadId: thread.id, error });
439
+ throw error;
440
+ }
441
+ }
442
+
443
+ async updateThread({
444
+ id,
445
+ title,
446
+ metadata,
447
+ }: {
448
+ id: string;
449
+ title: string;
450
+ metadata: Record<string, unknown>;
451
+ }): Promise<StorageThreadType> {
452
+ this.logger.debug('Updating thread', { threadId: id });
453
+
454
+ try {
455
+ // First, get the existing thread to merge with updates
456
+ const existingThread = await this.getThreadById({ threadId: id });
457
+
458
+ if (!existingThread) {
459
+ throw new Error(`Thread not found: ${id}`);
460
+ }
461
+
462
+ const now = new Date();
463
+
464
+ // Prepare the update
465
+ // Define type for only the fields we are actually updating
466
+ type ThreadUpdatePayload = {
467
+ updatedAt: string; // ISO String for DDB
468
+ title?: string;
469
+ metadata?: string; // Stringified JSON for DDB
470
+ };
471
+ const updateData: ThreadUpdatePayload = {
472
+ updatedAt: now.toISOString(),
473
+ };
474
+
475
+ if (title) {
476
+ updateData.title = title;
477
+ }
478
+
479
+ if (metadata) {
480
+ updateData.metadata = JSON.stringify(metadata); // Stringify metadata for update
481
+ }
482
+
483
+ // Update the thread using the primary key
484
+ await this.service.entities.thread.update({ entity: 'thread', id }).set(updateData).go();
485
+
486
+ // Return the potentially updated thread object
487
+ return {
488
+ ...existingThread,
489
+ title: title || existingThread.title,
490
+ metadata: metadata || existingThread.metadata,
491
+ updatedAt: now,
492
+ };
493
+ } catch (error) {
494
+ this.logger.error('Failed to update thread', { threadId: id, error });
495
+ throw error;
496
+ }
497
+ }
498
+
499
+ async deleteThread({ threadId }: { threadId: string }): Promise<void> {
500
+ this.logger.debug('Deleting thread', { threadId });
501
+
502
+ try {
503
+ // Delete the thread using the primary key
504
+ await this.service.entities.thread.delete({ entity: 'thread', id: threadId }).go();
505
+
506
+ // Note: In a production system, you might want to:
507
+ // 1. Delete all messages associated with this thread
508
+ // 2. Delete any vector embeddings related to this thread
509
+ // These would be additional operations
510
+ } catch (error) {
511
+ this.logger.error('Failed to delete thread', { threadId, error });
512
+ throw error;
513
+ }
514
+ }
515
+
516
+ // Message operations
517
+ async getMessages(args: StorageGetMessagesArg): Promise<MessageType[]> {
518
+ const { threadId, selectBy } = args;
519
+ this.logger.debug('Getting messages', { threadId, selectBy });
520
+
521
+ try {
522
+ // Query messages by thread ID using the GSI
523
+ // Provide *all* composite key components for the 'byThread' index ('entity', 'threadId')
524
+ const query = this.service.entities.message.query.byThread({ entity: 'message', threadId });
525
+
526
+ // Apply the 'last' limit if provided
527
+ if (selectBy?.last && typeof selectBy.last === 'number') {
528
+ // Use ElectroDB's limit parameter (descending sort assumed on GSI SK)
529
+ // Ensure GSI sk (createdAt) is sorted descending for 'last' to work correctly
530
+ // Assuming default sort is ascending on SK, use reverse: true for descending
531
+ const results = await query.go({ limit: selectBy.last, reverse: true });
532
+ // Use arrow function in map to preserve 'this' context for parseMessageData
533
+ return results.data.map((data: any) => this.parseMessageData(data)) as MessageType[];
534
+ }
535
+
536
+ // If no limit specified, get all messages (potentially paginated by ElectroDB)
537
+ // Consider adding default limit or handling pagination if needed
538
+ const results = await query.go();
539
+ // Use arrow function in map to preserve 'this' context for parseMessageData
540
+ return results.data.map((data: any) => this.parseMessageData(data)) as MessageType[];
541
+ } catch (error) {
542
+ this.logger.error('Failed to get messages', { threadId, error });
543
+ throw error;
544
+ }
545
+ }
546
+
547
+ async saveMessages({ messages }: { messages: MessageType[] }): Promise<MessageType[]> {
548
+ this.logger.debug('Saving messages', { count: messages.length });
549
+
550
+ if (!messages.length) {
551
+ return [];
552
+ }
553
+
554
+ // Ensure 'entity' is added and complex fields are handled
555
+ const messagesToSave = messages.map(msg => {
556
+ const now = new Date().toISOString();
557
+ return {
558
+ entity: 'message', // Add entity type
559
+ id: msg.id,
560
+ threadId: msg.threadId,
561
+ role: msg.role,
562
+ type: msg.type,
563
+ resourceId: msg.resourceId,
564
+ // Ensure complex fields are stringified if not handled by attribute setters
565
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
566
+ toolCallArgs: msg.toolCallArgs ? JSON.stringify(msg.toolCallArgs) : undefined,
567
+ toolCallIds: msg.toolCallIds ? JSON.stringify(msg.toolCallIds) : undefined,
568
+ toolNames: msg.toolNames ? JSON.stringify(msg.toolNames) : undefined,
569
+ createdAt: msg.createdAt?.toISOString() || now,
570
+ updatedAt: now, // Add updatedAt
571
+ };
572
+ });
573
+
574
+ try {
575
+ // Process messages in batch
576
+ const batchSize = 25; // DynamoDB batch limits
577
+ const batches = [];
578
+
579
+ for (let i = 0; i < messagesToSave.length; i += batchSize) {
580
+ const batch = messagesToSave.slice(i, i + batchSize);
581
+ batches.push(batch);
582
+ }
583
+
584
+ // Process each batch
585
+ for (const batch of batches) {
586
+ // Try creating each item individually instead of passing the whole batch
587
+ for (const messageData of batch) {
588
+ // Ensure each item has the entity property before sending
589
+ if (!messageData.entity) {
590
+ this.logger.error('Missing entity property in message data for create', { messageData });
591
+ throw new Error('Internal error: Missing entity property during saveMessages');
592
+ }
593
+ await this.service.entities.message.create(messageData).go();
594
+ }
595
+ // Original batch call: await this.service.entities.message.create(batch).go();
596
+ }
597
+
598
+ return messages; // Return original message objects
599
+ } catch (error) {
600
+ this.logger.error('Failed to save messages', { error });
601
+ throw error;
602
+ }
603
+ }
604
+
605
+ // Helper function to parse message data (handle JSON fields)
606
+ private parseMessageData(data: any): MessageType {
607
+ // Removed try/catch and JSON.parse logic - now handled by entity 'get' attributes
608
+ // This function now primarily ensures correct typing and Date conversion.
609
+ return {
610
+ ...data,
611
+ // Ensure dates are Date objects if needed (ElectroDB might return strings)
612
+ createdAt: data.createdAt ? new Date(data.createdAt) : undefined,
613
+ updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
614
+ // Other fields like content, toolCallArgs etc. are assumed to be correctly
615
+ // transformed by the ElectroDB entity getters.
616
+ } as MessageType; // Add explicit type assertion
617
+ }
618
+
619
+ // Trace operations
620
+ async getTraces(args: {
621
+ name?: string;
622
+ scope?: string;
623
+ page: number;
624
+ perPage: number;
625
+ attributes?: Record<string, string>;
626
+ filters?: Record<string, any>;
627
+ }): Promise<any[]> {
628
+ const { name, scope, page, perPage } = args;
629
+ this.logger.debug('Getting traces', { name, scope, page, perPage });
630
+
631
+ try {
632
+ let query;
633
+
634
+ // Determine which index to use based on the provided filters
635
+ // Provide *all* composite key components for the relevant index
636
+ if (name) {
637
+ query = this.service.entities.trace.query.byName({ entity: 'trace', name });
638
+ } else if (scope) {
639
+ query = this.service.entities.trace.query.byScope({ entity: 'trace', scope });
640
+ } else {
641
+ this.logger.warn('Performing a scan operation on traces - consider using a more specific query');
642
+ query = this.service.entities.trace.scan;
643
+ }
644
+
645
+ let items: any[] = [];
646
+ let cursor = null;
647
+ let pagesFetched = 0;
648
+ const startPage = page > 0 ? page : 1;
649
+
650
+ do {
651
+ const results: { data: any[]; cursor: string | null } = await query.go({ cursor, limit: perPage });
652
+ pagesFetched++;
653
+ if (pagesFetched === startPage) {
654
+ items = results.data;
655
+ break;
656
+ }
657
+ cursor = results.cursor;
658
+ if (!cursor && results.data.length > 0 && pagesFetched < startPage) {
659
+ break;
660
+ }
661
+ } while (cursor && pagesFetched < startPage);
662
+
663
+ return items;
664
+ } catch (error) {
665
+ this.logger.error('Failed to get traces', { error });
666
+ throw error;
667
+ }
668
+ }
669
+
670
+ async batchTraceInsert({ records }: { records: Record<string, any>[] }): Promise<void> {
671
+ this.logger.debug('Batch inserting traces', { count: records.length });
672
+
673
+ if (!records.length) {
674
+ return;
675
+ }
676
+
677
+ try {
678
+ // Add 'entity' type to each record before passing to generic batchInsert
679
+ const recordsToSave = records.map(rec => ({ entity: 'trace', ...rec }));
680
+ await this.batchInsert({
681
+ tableName: TABLE_TRACES,
682
+ records: recordsToSave, // Pass records with 'entity' included
683
+ });
684
+ } catch (error) {
685
+ this.logger.error('Failed to batch insert traces', { error });
686
+ throw error;
687
+ }
688
+ }
689
+
690
+ // Workflow operations
691
+ async persistWorkflowSnapshot({
692
+ workflowName,
693
+ runId,
694
+ snapshot,
695
+ }: {
696
+ workflowName: string;
697
+ runId: string;
698
+ snapshot: WorkflowRunState;
699
+ }): Promise<void> {
700
+ this.logger.debug('Persisting workflow snapshot', { workflowName, runId });
701
+
702
+ try {
703
+ const resourceId = 'resourceId' in snapshot ? snapshot.resourceId : undefined;
704
+ const now = new Date().toISOString();
705
+ // Prepare data including the 'entity' type
706
+ const data = {
707
+ entity: 'workflow_snapshot', // Add entity type
708
+ workflow_name: workflowName,
709
+ run_id: runId,
710
+ snapshot: JSON.stringify(snapshot), // Stringify the snapshot object
711
+ createdAt: now,
712
+ updatedAt: now,
713
+ resourceId,
714
+ };
715
+ // Pass the data including 'entity'
716
+ await this.service.entities.workflowSnapshot.create(data).go();
717
+ } catch (error) {
718
+ this.logger.error('Failed to persist workflow snapshot', { workflowName, runId, error });
719
+ throw error;
720
+ }
721
+ }
722
+
723
+ async loadWorkflowSnapshot({
724
+ workflowName,
725
+ runId,
726
+ }: {
727
+ workflowName: string;
728
+ runId: string;
729
+ }): Promise<WorkflowRunState | null> {
730
+ this.logger.debug('Loading workflow snapshot', { workflowName, runId });
731
+
732
+ try {
733
+ // Provide *all* composite key components for the primary index ('entity', 'workflow_name', 'run_id')
734
+ const result = await this.service.entities.workflowSnapshot
735
+ .get({
736
+ entity: 'workflow_snapshot', // Add entity type
737
+ workflow_name: workflowName,
738
+ run_id: runId,
739
+ })
740
+ .go();
741
+
742
+ if (!result.data?.snapshot) {
743
+ // Check snapshot exists
744
+ return null;
745
+ }
746
+
747
+ // Parse the snapshot string
748
+ return result.data.snapshot as WorkflowRunState;
749
+ } catch (error) {
750
+ this.logger.error('Failed to load workflow snapshot', { workflowName, runId, error });
751
+ throw error;
752
+ }
753
+ }
754
+
755
+ async getWorkflowRuns(args?: {
756
+ workflowName?: string;
757
+ fromDate?: Date;
758
+ toDate?: Date;
759
+ limit?: number;
760
+ offset?: number;
761
+ resourceId?: string;
762
+ }): Promise<WorkflowRuns> {
763
+ this.logger.debug('Getting workflow runs', { args });
764
+
765
+ try {
766
+ // Default values
767
+ const limit = args?.limit || 10;
768
+ const offset = args?.offset || 0;
769
+
770
+ let query;
771
+
772
+ if (args?.workflowName) {
773
+ // Query by workflow name using the primary index
774
+ // Provide *all* composite key components for the PK ('entity', 'workflow_name')
775
+ query = this.service.entities.workflowSnapshot.query.primary({
776
+ entity: 'workflow_snapshot', // Add entity type
777
+ workflow_name: args.workflowName,
778
+ });
779
+ } else {
780
+ // If no workflow name, we need to scan
781
+ // This is not ideal for production with large datasets
782
+ this.logger.warn('Performing a scan operation on workflow snapshots - consider using a more specific query');
783
+ query = this.service.entities.workflowSnapshot.scan; // Scan still uses the service entity
784
+ }
785
+
786
+ const allMatchingSnapshots: WorkflowSnapshotDBItem[] = [];
787
+ let cursor: string | null = null;
788
+ const DYNAMODB_PAGE_SIZE = 100; // Sensible page size for fetching
789
+
790
+ do {
791
+ const pageResults: { data: WorkflowSnapshotDBItem[]; cursor: string | null } = await query.go({
792
+ limit: DYNAMODB_PAGE_SIZE,
793
+ cursor,
794
+ });
795
+
796
+ if (pageResults.data && pageResults.data.length > 0) {
797
+ let pageFilteredData: WorkflowSnapshotDBItem[] = pageResults.data;
798
+
799
+ // Apply date filters if specified
800
+ if (args?.fromDate || args?.toDate) {
801
+ pageFilteredData = pageFilteredData.filter((snapshot: WorkflowSnapshotDBItem) => {
802
+ const createdAt = new Date(snapshot.createdAt);
803
+ if (args.fromDate && createdAt < args.fromDate) {
804
+ return false;
805
+ }
806
+ if (args.toDate && createdAt > args.toDate) {
807
+ return false;
808
+ }
809
+ return true;
810
+ });
811
+ }
812
+
813
+ // Filter by resourceId if specified
814
+ if (args?.resourceId) {
815
+ pageFilteredData = pageFilteredData.filter((snapshot: WorkflowSnapshotDBItem) => {
816
+ return snapshot.resourceId === args.resourceId;
817
+ });
818
+ }
819
+ allMatchingSnapshots.push(...pageFilteredData);
820
+ }
821
+
822
+ cursor = pageResults.cursor;
823
+ } while (cursor);
824
+
825
+ if (!allMatchingSnapshots.length) {
826
+ return { runs: [], total: 0 };
827
+ }
828
+
829
+ // Apply offset and limit to the accumulated filtered results
830
+ const total = allMatchingSnapshots.length;
831
+ const paginatedData = allMatchingSnapshots.slice(offset, offset + limit);
832
+
833
+ // Format and return the results
834
+ const runs = paginatedData.map((snapshot: WorkflowSnapshotDBItem) => this.formatWorkflowRun(snapshot));
835
+
836
+ return {
837
+ runs,
838
+ total,
839
+ };
840
+ } catch (error) {
841
+ this.logger.error('Failed to get workflow runs', { error });
842
+ throw error;
843
+ }
844
+ }
845
+
846
+ async getWorkflowRunById(args: { runId: string; workflowName?: string }): Promise<WorkflowRun | null> {
847
+ const { runId, workflowName } = args;
848
+ this.logger.debug('Getting workflow run by ID', { runId, workflowName });
849
+
850
+ try {
851
+ // If we have a workflowName, we can do a direct get using the primary key
852
+ if (workflowName) {
853
+ this.logger.debug('WorkflowName provided, using direct GET operation.');
854
+ const result = await this.service.entities.workflowSnapshot
855
+ .get({
856
+ entity: 'workflow_snapshot', // Entity type for PK
857
+ workflow_name: workflowName,
858
+ run_id: runId,
859
+ })
860
+ .go();
861
+
862
+ if (!result.data) {
863
+ return null;
864
+ }
865
+
866
+ const snapshot = result.data.snapshot;
867
+ return {
868
+ workflowName: result.data.workflow_name,
869
+ runId: result.data.run_id,
870
+ snapshot,
871
+ createdAt: new Date(result.data.createdAt),
872
+ updatedAt: new Date(result.data.updatedAt),
873
+ resourceId: result.data.resourceId,
874
+ };
875
+ }
876
+
877
+ // Otherwise, if workflowName is not provided, use the GSI on runId.
878
+ // This is more efficient than a full table scan.
879
+ this.logger.debug(
880
+ 'WorkflowName not provided. Attempting to find workflow run by runId using GSI. Ensure GSI (e.g., "byRunId") is defined on the workflowSnapshot entity with run_id as its key and provisioned in DynamoDB.',
881
+ );
882
+
883
+ // IMPORTANT: This assumes a GSI (e.g., named 'byRunId') exists on the workflowSnapshot entity
884
+ // with 'run_id' as its partition key. This GSI must be:
885
+ // 1. Defined in your ElectroDB model (e.g., in stores/dynamodb/src/entities/index.ts).
886
+ // 2. Provisioned in the actual DynamoDB table (e.g., via CDK/CloudFormation).
887
+ // The query key object includes 'entity' as it's good practice with ElectroDB and single-table design,
888
+ // aligning with how other GSIs are queried in this file.
889
+ const result = await this.service.entities.workflowSnapshot.query
890
+ .gsi2({ entity: 'workflow_snapshot', run_id: runId }) // Replace 'byRunId' with your actual GSI name
891
+ .go();
892
+
893
+ // If the GSI query returns multiple items (e.g., if run_id is not globally unique across all snapshots),
894
+ // this will take the first one. The original scan logic also effectively took the first match found.
895
+ // If run_id is guaranteed unique, result.data should contain at most one item.
896
+ const matchingRunDbItem: WorkflowSnapshotDBItem | null =
897
+ result.data && result.data.length > 0 ? result.data[0] : null;
898
+
899
+ if (!matchingRunDbItem) {
900
+ return null;
901
+ }
902
+
903
+ const snapshot = matchingRunDbItem.snapshot;
904
+ return {
905
+ workflowName: matchingRunDbItem.workflow_name,
906
+ runId: matchingRunDbItem.run_id,
907
+ snapshot,
908
+ createdAt: new Date(matchingRunDbItem.createdAt),
909
+ updatedAt: new Date(matchingRunDbItem.updatedAt),
910
+ resourceId: matchingRunDbItem.resourceId,
911
+ };
912
+ } catch (error) {
913
+ this.logger.error('Failed to get workflow run by ID', { runId, workflowName, error });
914
+ throw error;
915
+ }
916
+ }
917
+
918
+ // Helper function to format workflow run
919
+ private formatWorkflowRun(snapshotData: WorkflowSnapshotDBItem): WorkflowRun {
920
+ return {
921
+ workflowName: snapshotData.workflow_name,
922
+ runId: snapshotData.run_id,
923
+ snapshot: snapshotData.snapshot as WorkflowRunState,
924
+ createdAt: new Date(snapshotData.createdAt),
925
+ updatedAt: new Date(snapshotData.updatedAt),
926
+ resourceId: snapshotData.resourceId,
927
+ };
928
+ }
929
+
930
+ // Helper methods for entity/table mapping
931
+ private getEntityNameForTable(tableName: TABLE_NAMES): string | null {
932
+ const mapping: Record<TABLE_NAMES, string> = {
933
+ [TABLE_THREADS]: 'thread',
934
+ [TABLE_MESSAGES]: 'message',
935
+ [TABLE_WORKFLOW_SNAPSHOT]: 'workflowSnapshot',
936
+ [TABLE_EVALS]: 'eval',
937
+ [TABLE_TRACES]: 'trace',
938
+ };
939
+ return mapping[tableName] || null;
940
+ }
941
+
942
+ // Eval operations
943
+ async getEvalsByAgentName(agentName: string, type?: 'test' | 'live'): Promise<EvalRow[]> {
944
+ this.logger.debug('Getting evals for agent', { agentName, type });
945
+
946
+ try {
947
+ // Query evals by agent name using the GSI
948
+ // Provide *all* composite key components for the 'byAgent' index ('entity', 'agent_name')
949
+ const query = this.service.entities.eval.query.byAgent({ entity: 'eval', agent_name: agentName });
950
+
951
+ // Fetch potentially all items in descending order, using the correct 'order' option
952
+ const results = await query.go({ order: 'desc', limit: 100 }); // Use order: 'desc'
953
+
954
+ if (!results.data.length) {
955
+ return [];
956
+ }
957
+
958
+ // Filter by type if specified
959
+ let filteredData = results.data;
960
+ if (type) {
961
+ filteredData = filteredData.filter((evalRecord: Record<string, any>) => {
962
+ try {
963
+ // Need to handle potential parse errors for test_info
964
+ const testInfo =
965
+ evalRecord.test_info && typeof evalRecord.test_info === 'string'
966
+ ? JSON.parse(evalRecord.test_info)
967
+ : undefined;
968
+
969
+ if (type === 'test' && !testInfo) {
970
+ return false;
971
+ }
972
+ if (type === 'live' && testInfo) {
973
+ return false;
974
+ }
975
+ } catch (e) {
976
+ this.logger.warn('Failed to parse test_info during filtering', { record: evalRecord, error: e });
977
+ // Decide how to handle parse errors - exclude or include? Including for now.
978
+ }
979
+ return true;
980
+ });
981
+ }
982
+
983
+ // Format the results - ElectroDB transforms most attributes, but we need to map/parse
984
+ return filteredData.map((evalRecord: Record<string, any>) => {
985
+ try {
986
+ return {
987
+ input: evalRecord.input,
988
+ output: evalRecord.output,
989
+ // Safely parse result and test_info
990
+ result:
991
+ evalRecord.result && typeof evalRecord.result === 'string' ? JSON.parse(evalRecord.result) : undefined,
992
+ agentName: evalRecord.agent_name,
993
+ createdAt: evalRecord.created_at, // Keep as string from DDB?
994
+ metricName: evalRecord.metric_name,
995
+ instructions: evalRecord.instructions,
996
+ runId: evalRecord.run_id,
997
+ globalRunId: evalRecord.global_run_id,
998
+ testInfo:
999
+ evalRecord.test_info && typeof evalRecord.test_info === 'string'
1000
+ ? JSON.parse(evalRecord.test_info)
1001
+ : undefined,
1002
+ } as EvalRow;
1003
+ } catch (parseError) {
1004
+ this.logger.error('Failed to parse eval record', { record: evalRecord, error: parseError });
1005
+ // Return a partial record or null/undefined on error?
1006
+ // Returning partial for now, might need adjustment based on requirements.
1007
+ return {
1008
+ agentName: evalRecord.agent_name,
1009
+ createdAt: evalRecord.created_at,
1010
+ runId: evalRecord.run_id,
1011
+ globalRunId: evalRecord.global_run_id,
1012
+ } as Partial<EvalRow> as EvalRow; // Cast needed for return type
1013
+ }
1014
+ });
1015
+ } catch (error) {
1016
+ this.logger.error('Failed to get evals by agent name', { agentName, type, error });
1017
+ throw error;
1018
+ }
1019
+ }
1020
+
1021
+ /**
1022
+ * Closes the DynamoDB client connection and cleans up resources.
1023
+ * Should be called when the store is no longer needed, e.g., at the end of tests or application shutdown.
1024
+ */
1025
+ public async close(): Promise<void> {
1026
+ this.logger.debug('Closing DynamoDB client for store:', { name: this.name });
1027
+ try {
1028
+ this.client.destroy();
1029
+ this.logger.debug('DynamoDB client closed successfully for store:', { name: this.name });
1030
+ } catch (error) {
1031
+ this.logger.error('Error closing DynamoDB client for store:', { name: this.name, error });
1032
+ // Optionally re-throw or handle as appropriate for your application's error handling strategy
1033
+ throw error;
1034
+ }
1035
+ }
1036
+ }