@mastra/dynamodb 0.13.0 → 0.13.1

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,894 @@
1
+ import { MessageList } from '@mastra/core/agent';
2
+ import type { MastraMessageContentV2 } from '@mastra/core/agent';
3
+ import { ErrorCategory, ErrorDomain, MastraError } from '@mastra/core/error';
4
+ import type { StorageThreadType, MastraMessageV1, MastraMessageV2 } from '@mastra/core/memory';
5
+ import { MemoryStorage, resolveMessageLimit } from '@mastra/core/storage';
6
+ import type { PaginationInfo, StorageGetMessagesArg, StorageResourceType } from '@mastra/core/storage';
7
+ import type { Service } from 'electrodb';
8
+
9
+ export class MemoryStorageDynamoDB extends MemoryStorage {
10
+ private service: Service<Record<string, any>>;
11
+ constructor({ service }: { service: Service<Record<string, any>> }) {
12
+ super();
13
+ this.service = service;
14
+ }
15
+
16
+ // Helper function to parse message data (handle JSON fields)
17
+ private parseMessageData(data: any): MastraMessageV2 | MastraMessageV1 {
18
+ // Removed try/catch and JSON.parse logic - now handled by entity 'get' attributes
19
+ // This function now primarily ensures correct typing and Date conversion.
20
+ return {
21
+ ...data,
22
+ // Ensure dates are Date objects if needed (ElectroDB might return strings)
23
+ createdAt: data.createdAt ? new Date(data.createdAt) : undefined,
24
+ updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
25
+ // Other fields like content, toolCallArgs etc. are assumed to be correctly
26
+ // transformed by the ElectroDB entity getters.
27
+ };
28
+ }
29
+
30
+ async getThreadById({ threadId }: { threadId: string }): Promise<StorageThreadType | null> {
31
+ this.logger.debug('Getting thread by ID', { threadId });
32
+ try {
33
+ const result = await this.service.entities.thread.get({ entity: 'thread', id: threadId }).go();
34
+
35
+ if (!result.data) {
36
+ return null;
37
+ }
38
+
39
+ // ElectroDB handles the transformation with attribute getters
40
+ const data = result.data;
41
+ return {
42
+ ...data,
43
+ // Convert date strings back to Date objects for consistency
44
+ createdAt: typeof data.createdAt === 'string' ? new Date(data.createdAt) : data.createdAt,
45
+ updatedAt: typeof data.updatedAt === 'string' ? new Date(data.updatedAt) : data.updatedAt,
46
+ // metadata: data.metadata ? JSON.parse(data.metadata) : undefined, // REMOVED by AI
47
+ // metadata is already transformed by the entity's getter
48
+ } as StorageThreadType;
49
+ } catch (error) {
50
+ throw new MastraError(
51
+ {
52
+ id: 'STORAGE_DYNAMODB_STORE_GET_THREAD_BY_ID_FAILED',
53
+ domain: ErrorDomain.STORAGE,
54
+ category: ErrorCategory.THIRD_PARTY,
55
+ details: { threadId },
56
+ },
57
+ error,
58
+ );
59
+ }
60
+ }
61
+
62
+ async getThreadsByResourceId({ resourceId }: { resourceId: string }): Promise<StorageThreadType[]> {
63
+ this.logger.debug('Getting threads by resource ID', { resourceId });
64
+ try {
65
+ const result = await this.service.entities.thread.query.byResource({ entity: 'thread', resourceId }).go();
66
+
67
+ if (!result.data.length) {
68
+ return [];
69
+ }
70
+
71
+ // ElectroDB handles the transformation with attribute getters
72
+ return result.data.map((data: any) => ({
73
+ ...data,
74
+ // Convert date strings back to Date objects for consistency
75
+ createdAt: typeof data.createdAt === 'string' ? new Date(data.createdAt) : data.createdAt,
76
+ updatedAt: typeof data.updatedAt === 'string' ? new Date(data.updatedAt) : data.updatedAt,
77
+ // metadata: data.metadata ? JSON.parse(data.metadata) : undefined, // REMOVED by AI
78
+ // metadata is already transformed by the entity's getter
79
+ })) as StorageThreadType[];
80
+ } catch (error) {
81
+ throw new MastraError(
82
+ {
83
+ id: 'STORAGE_DYNAMODB_STORE_GET_THREADS_BY_RESOURCE_ID_FAILED',
84
+ domain: ErrorDomain.STORAGE,
85
+ category: ErrorCategory.THIRD_PARTY,
86
+ details: { resourceId },
87
+ },
88
+ error,
89
+ );
90
+ }
91
+ }
92
+
93
+ async saveThread({ thread }: { thread: StorageThreadType }): Promise<StorageThreadType> {
94
+ this.logger.debug('Saving thread', { threadId: thread.id });
95
+
96
+ const now = new Date();
97
+
98
+ const threadData = {
99
+ entity: 'thread',
100
+ id: thread.id,
101
+ resourceId: thread.resourceId,
102
+ title: thread.title || `Thread ${thread.id}`,
103
+ createdAt: thread.createdAt?.toISOString() || now.toISOString(),
104
+ updatedAt: now.toISOString(),
105
+ metadata: thread.metadata ? JSON.stringify(thread.metadata) : undefined,
106
+ };
107
+
108
+ try {
109
+ await this.service.entities.thread.upsert(threadData).go();
110
+
111
+ return {
112
+ id: thread.id,
113
+ resourceId: thread.resourceId,
114
+ title: threadData.title,
115
+ createdAt: thread.createdAt || now,
116
+ updatedAt: now,
117
+ metadata: thread.metadata,
118
+ };
119
+ } catch (error) {
120
+ throw new MastraError(
121
+ {
122
+ id: 'STORAGE_DYNAMODB_STORE_SAVE_THREAD_FAILED',
123
+ domain: ErrorDomain.STORAGE,
124
+ category: ErrorCategory.THIRD_PARTY,
125
+ details: { threadId: thread.id },
126
+ },
127
+ error,
128
+ );
129
+ }
130
+ }
131
+
132
+ async updateThread({
133
+ id,
134
+ title,
135
+ metadata,
136
+ }: {
137
+ id: string;
138
+ title: string;
139
+ metadata: Record<string, unknown>;
140
+ }): Promise<StorageThreadType> {
141
+ this.logger.debug('Updating thread', { threadId: id });
142
+
143
+ try {
144
+ // First, get the existing thread to merge with updates
145
+ const existingThread = await this.getThreadById({ threadId: id });
146
+
147
+ if (!existingThread) {
148
+ throw new Error(`Thread not found: ${id}`);
149
+ }
150
+
151
+ const now = new Date();
152
+
153
+ // Prepare the update
154
+ // Define type for only the fields we are actually updating
155
+ type ThreadUpdatePayload = {
156
+ updatedAt: string; // ISO String for DDB
157
+ title?: string;
158
+ metadata?: string; // Stringified JSON for DDB
159
+ };
160
+ const updateData: ThreadUpdatePayload = {
161
+ updatedAt: now.toISOString(),
162
+ };
163
+
164
+ if (title) {
165
+ updateData.title = title;
166
+ }
167
+
168
+ if (metadata) {
169
+ // Merge with existing metadata instead of overwriting
170
+ const existingMetadata = existingThread.metadata
171
+ ? typeof existingThread.metadata === 'string'
172
+ ? JSON.parse(existingThread.metadata)
173
+ : existingThread.metadata
174
+ : {};
175
+ const mergedMetadata = { ...existingMetadata, ...metadata };
176
+ updateData.metadata = JSON.stringify(mergedMetadata); // Stringify merged metadata for update
177
+ }
178
+
179
+ // Update the thread using the primary key
180
+ await this.service.entities.thread.update({ entity: 'thread', id }).set(updateData).go();
181
+
182
+ // Return the potentially updated thread object
183
+ return {
184
+ ...existingThread,
185
+ title: title || existingThread.title,
186
+ metadata: metadata ? { ...existingThread.metadata, ...metadata } : existingThread.metadata,
187
+ updatedAt: now,
188
+ };
189
+ } catch (error) {
190
+ throw new MastraError(
191
+ {
192
+ id: 'STORAGE_DYNAMODB_STORE_UPDATE_THREAD_FAILED',
193
+ domain: ErrorDomain.STORAGE,
194
+ category: ErrorCategory.THIRD_PARTY,
195
+ details: { threadId: id },
196
+ },
197
+ error,
198
+ );
199
+ }
200
+ }
201
+
202
+ async deleteThread({ threadId }: { threadId: string }): Promise<void> {
203
+ this.logger.debug('Deleting thread', { threadId });
204
+
205
+ try {
206
+ // First, delete all messages associated with this thread
207
+ const messages = await this.getMessages({ threadId });
208
+ if (messages.length > 0) {
209
+ // Delete messages in batches
210
+ const batchSize = 25; // DynamoDB batch limits
211
+ for (let i = 0; i < messages.length; i += batchSize) {
212
+ const batch = messages.slice(i, i + batchSize);
213
+ await Promise.all(
214
+ batch.map(message =>
215
+ this.service.entities.message
216
+ .delete({
217
+ entity: 'message',
218
+ id: message.id,
219
+ threadId: message.threadId,
220
+ })
221
+ .go(),
222
+ ),
223
+ );
224
+ }
225
+ }
226
+
227
+ // Then delete the thread using the primary key
228
+ await this.service.entities.thread.delete({ entity: 'thread', id: threadId }).go();
229
+ } catch (error) {
230
+ throw new MastraError(
231
+ {
232
+ id: 'STORAGE_DYNAMODB_STORE_DELETE_THREAD_FAILED',
233
+ domain: ErrorDomain.STORAGE,
234
+ category: ErrorCategory.THIRD_PARTY,
235
+ details: { threadId },
236
+ },
237
+ error,
238
+ );
239
+ }
240
+ }
241
+
242
+ public async getMessages(args: StorageGetMessagesArg & { format?: 'v1' }): Promise<MastraMessageV1[]>;
243
+ public async getMessages(args: StorageGetMessagesArg & { format: 'v2' }): Promise<MastraMessageV2[]>;
244
+ public async getMessages({
245
+ threadId,
246
+ resourceId,
247
+ selectBy,
248
+ format,
249
+ }: StorageGetMessagesArg & { format?: 'v1' | 'v2' }): Promise<MastraMessageV1[] | MastraMessageV2[]> {
250
+ this.logger.debug('Getting messages', { threadId, selectBy });
251
+
252
+ try {
253
+ const messages: MastraMessageV2[] = [];
254
+ const limit = resolveMessageLimit({ last: selectBy?.last, defaultLimit: Number.MAX_SAFE_INTEGER });
255
+
256
+ // Handle included messages first (like libsql)
257
+ if (selectBy?.include?.length) {
258
+ const includeMessages = await this._getIncludedMessages(threadId, selectBy);
259
+ if (includeMessages) {
260
+ messages.push(...includeMessages);
261
+ }
262
+ }
263
+
264
+ // Get remaining messages only if limit is not 0
265
+ if (limit !== 0) {
266
+ // Query messages by thread ID using the GSI
267
+ const query = this.service.entities.message.query.byThread({ entity: 'message', threadId });
268
+
269
+ // Get messages from the main thread
270
+ let results;
271
+ if (limit !== Number.MAX_SAFE_INTEGER && limit > 0) {
272
+ // Use limit in query to get only the last N messages
273
+ results = await query.go({ limit, order: 'desc' });
274
+ // Reverse the results since we want ascending order
275
+ results.data = results.data.reverse();
276
+ } else {
277
+ // Get all messages
278
+ results = await query.go();
279
+ }
280
+
281
+ let allThreadMessages = results.data
282
+ .map((data: any) => this.parseMessageData(data))
283
+ .filter((msg: any): msg is MastraMessageV2 => 'content' in msg);
284
+
285
+ // Sort by createdAt ASC to get proper order
286
+ allThreadMessages.sort((a: MastraMessageV2, b: MastraMessageV2) => {
287
+ const timeA = a.createdAt.getTime();
288
+ const timeB = b.createdAt.getTime();
289
+ if (timeA === timeB) {
290
+ return a.id.localeCompare(b.id);
291
+ }
292
+ return timeA - timeB;
293
+ });
294
+
295
+ messages.push(...allThreadMessages);
296
+ }
297
+
298
+ // Sort by createdAt ASC to match libsql behavior, with ID tiebreaker for stable ordering
299
+ messages.sort((a, b) => {
300
+ const timeA = a.createdAt.getTime();
301
+ const timeB = b.createdAt.getTime();
302
+ if (timeA === timeB) {
303
+ return a.id.localeCompare(b.id);
304
+ }
305
+ return timeA - timeB;
306
+ });
307
+
308
+ // Deduplicate messages by ID (like libsql)
309
+ const uniqueMessages = messages.filter(
310
+ (message, index, self) => index === self.findIndex(m => m.id === message.id),
311
+ );
312
+
313
+ const list = new MessageList({ threadId, resourceId }).add(uniqueMessages, 'memory');
314
+ if (format === `v2`) return list.get.all.v2();
315
+ return list.get.all.v1();
316
+ } catch (error) {
317
+ throw new MastraError(
318
+ {
319
+ id: 'STORAGE_DYNAMODB_STORE_GET_MESSAGES_FAILED',
320
+ domain: ErrorDomain.STORAGE,
321
+ category: ErrorCategory.THIRD_PARTY,
322
+ details: { threadId },
323
+ },
324
+ error,
325
+ );
326
+ }
327
+ }
328
+
329
+ async saveMessages(args: { messages: MastraMessageV1[]; format?: undefined | 'v1' }): Promise<MastraMessageV1[]>;
330
+ async saveMessages(args: { messages: MastraMessageV2[]; format: 'v2' }): Promise<MastraMessageV2[]>;
331
+ async saveMessages(
332
+ args: { messages: MastraMessageV1[]; format?: undefined | 'v1' } | { messages: MastraMessageV2[]; format: 'v2' },
333
+ ): Promise<MastraMessageV2[] | MastraMessageV1[]> {
334
+ const { messages, format = 'v1' } = args;
335
+ this.logger.debug('Saving messages', { count: messages.length });
336
+
337
+ if (!messages.length) {
338
+ return [];
339
+ }
340
+
341
+ const threadId = messages[0]?.threadId;
342
+ if (!threadId) {
343
+ throw new Error('Thread ID is required');
344
+ }
345
+
346
+ // Ensure 'entity' is added and complex fields are handled
347
+ const messagesToSave = messages.map(msg => {
348
+ const now = new Date().toISOString();
349
+ return {
350
+ entity: 'message', // Add entity type
351
+ id: msg.id,
352
+ threadId: msg.threadId,
353
+ role: msg.role,
354
+ type: msg.type,
355
+ resourceId: msg.resourceId,
356
+ // Ensure complex fields are stringified if not handled by attribute setters
357
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
358
+ toolCallArgs: `toolCallArgs` in msg && msg.toolCallArgs ? JSON.stringify(msg.toolCallArgs) : undefined,
359
+ toolCallIds: `toolCallIds` in msg && msg.toolCallIds ? JSON.stringify(msg.toolCallIds) : undefined,
360
+ toolNames: `toolNames` in msg && msg.toolNames ? JSON.stringify(msg.toolNames) : undefined,
361
+ createdAt: msg.createdAt instanceof Date ? msg.createdAt.toISOString() : msg.createdAt || now,
362
+ updatedAt: now, // Add updatedAt
363
+ };
364
+ });
365
+
366
+ try {
367
+ // Process messages sequentially to enable rollback on error
368
+ const savedMessageIds: string[] = [];
369
+
370
+ for (const messageData of messagesToSave) {
371
+ // Ensure each item has the entity property before sending
372
+ if (!messageData.entity) {
373
+ this.logger.error('Missing entity property in message data for create', { messageData });
374
+ throw new Error('Internal error: Missing entity property during saveMessages');
375
+ }
376
+
377
+ try {
378
+ await this.service.entities.message.put(messageData).go();
379
+ savedMessageIds.push(messageData.id);
380
+ } catch (error) {
381
+ // Rollback: delete all previously saved messages
382
+ for (const savedId of savedMessageIds) {
383
+ try {
384
+ await this.service.entities.message.delete({ entity: 'message', id: savedId }).go();
385
+ } catch (rollbackError) {
386
+ this.logger.error('Failed to rollback message during save error', {
387
+ messageId: savedId,
388
+ error: rollbackError,
389
+ });
390
+ }
391
+ }
392
+ throw error;
393
+ }
394
+ }
395
+
396
+ // Update thread's updatedAt timestamp
397
+ await this.service.entities.thread
398
+ .update({ entity: 'thread', id: threadId })
399
+ .set({
400
+ updatedAt: new Date().toISOString(),
401
+ })
402
+ .go();
403
+
404
+ const list = new MessageList().add(messages, 'memory');
405
+ if (format === `v1`) return list.get.all.v1();
406
+ return list.get.all.v2();
407
+ } catch (error) {
408
+ throw new MastraError(
409
+ {
410
+ id: 'STORAGE_DYNAMODB_STORE_SAVE_MESSAGES_FAILED',
411
+ domain: ErrorDomain.STORAGE,
412
+ category: ErrorCategory.THIRD_PARTY,
413
+ details: { count: messages.length },
414
+ },
415
+ error,
416
+ );
417
+ }
418
+ }
419
+
420
+ async getThreadsByResourceIdPaginated(args: {
421
+ resourceId: string;
422
+ page?: number;
423
+ perPage?: number;
424
+ }): Promise<PaginationInfo & { threads: StorageThreadType[] }> {
425
+ const { resourceId, page = 0, perPage = 100 } = args;
426
+ this.logger.debug('Getting threads by resource ID with pagination', { resourceId, page, perPage });
427
+
428
+ try {
429
+ // Query threads by resource ID using the GSI
430
+ const query = this.service.entities.thread.query.byResource({ entity: 'thread', resourceId });
431
+
432
+ // Get all threads for this resource ID (DynamoDB doesn't support OFFSET/LIMIT)
433
+ const results = await query.go();
434
+ const allThreads = results.data;
435
+
436
+ // Apply pagination in memory
437
+ const startIndex = page * perPage;
438
+ const endIndex = startIndex + perPage;
439
+ const paginatedThreads = allThreads.slice(startIndex, endIndex);
440
+
441
+ // Calculate pagination info
442
+ const total = allThreads.length;
443
+ const hasMore = endIndex < total;
444
+
445
+ return {
446
+ threads: paginatedThreads,
447
+ total,
448
+ page,
449
+ perPage,
450
+ hasMore,
451
+ };
452
+ } catch (error) {
453
+ throw new MastraError(
454
+ {
455
+ id: 'STORAGE_DYNAMODB_STORE_GET_THREADS_BY_RESOURCE_ID_PAGINATED_FAILED',
456
+ domain: ErrorDomain.STORAGE,
457
+ category: ErrorCategory.THIRD_PARTY,
458
+ details: { resourceId, page, perPage },
459
+ },
460
+ error,
461
+ );
462
+ }
463
+ }
464
+
465
+ async getMessagesPaginated(
466
+ args: StorageGetMessagesArg & { format?: 'v1' | 'v2' },
467
+ ): Promise<PaginationInfo & { messages: MastraMessageV1[] | MastraMessageV2[] }> {
468
+ const { threadId, resourceId, selectBy, format = 'v1' } = args;
469
+ const { page = 0, perPage = 40, dateRange } = selectBy?.pagination || {};
470
+ const fromDate = dateRange?.start;
471
+ const toDate = dateRange?.end;
472
+ const limit = resolveMessageLimit({ last: selectBy?.last, defaultLimit: Number.MAX_SAFE_INTEGER });
473
+
474
+ this.logger.debug('Getting messages with pagination', { threadId, page, perPage, fromDate, toDate, limit });
475
+
476
+ try {
477
+ let messages: MastraMessageV2[] = [];
478
+
479
+ // Handle include messages first
480
+ if (selectBy?.include?.length) {
481
+ const includeMessages = await this._getIncludedMessages(threadId, selectBy);
482
+ if (includeMessages) {
483
+ messages.push(...includeMessages);
484
+ }
485
+ }
486
+
487
+ // Get remaining messages only if limit is not 0
488
+ if (limit !== 0) {
489
+ // Query messages by thread ID using the GSI
490
+ const query = this.service.entities.message.query.byThread({ entity: 'message', threadId });
491
+
492
+ // Get messages from the main thread
493
+ let results;
494
+ if (limit !== Number.MAX_SAFE_INTEGER && limit > 0) {
495
+ // Use limit in query to get only the last N messages
496
+ results = await query.go({ limit, order: 'desc' });
497
+ // Reverse the results since we want ascending order
498
+ results.data = results.data.reverse();
499
+ } else {
500
+ // Get all messages
501
+ results = await query.go();
502
+ }
503
+
504
+ let allThreadMessages = results.data
505
+ .map((data: any) => this.parseMessageData(data))
506
+ .filter((msg: any): msg is MastraMessageV2 => 'content' in msg);
507
+
508
+ // Sort by createdAt ASC to get proper order
509
+ allThreadMessages.sort((a: MastraMessageV2, b: MastraMessageV2) => {
510
+ const timeA = a.createdAt.getTime();
511
+ const timeB = b.createdAt.getTime();
512
+ if (timeA === timeB) {
513
+ return a.id.localeCompare(b.id);
514
+ }
515
+ return timeA - timeB;
516
+ });
517
+
518
+ // Exclude already included messages
519
+ const excludeIds = messages.map(m => m.id);
520
+ if (excludeIds.length > 0) {
521
+ allThreadMessages = allThreadMessages.filter((msg: MastraMessageV2) => !excludeIds.includes(msg.id));
522
+ }
523
+
524
+ messages.push(...allThreadMessages);
525
+ }
526
+
527
+ // Sort all messages by createdAt (oldest first for final result)
528
+ messages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
529
+
530
+ // Apply date filtering if needed
531
+ if (fromDate || toDate) {
532
+ messages = messages.filter(msg => {
533
+ const createdAt = new Date(msg.createdAt).getTime();
534
+ if (fromDate && createdAt < new Date(fromDate).getTime()) return false;
535
+ if (toDate && createdAt > new Date(toDate).getTime()) return false;
536
+ return true;
537
+ });
538
+ }
539
+
540
+ // Save total before pagination
541
+ const total = messages.length;
542
+
543
+ // Apply offset-based pagination in memory
544
+ const start = page * perPage;
545
+ const end = start + perPage;
546
+ const paginatedMessages = messages.slice(start, end);
547
+ const hasMore = end < total;
548
+
549
+ const list = new MessageList({ threadId, resourceId }).add(paginatedMessages as MastraMessageV2[], 'memory');
550
+ const finalMessages = format === 'v2' ? list.get.all.v2() : list.get.all.v1();
551
+
552
+ return {
553
+ messages: finalMessages,
554
+ total,
555
+ page,
556
+ perPage,
557
+ hasMore,
558
+ };
559
+ } catch (error) {
560
+ throw new MastraError(
561
+ {
562
+ id: 'STORAGE_DYNAMODB_STORE_GET_MESSAGES_PAGINATED_FAILED',
563
+ domain: ErrorDomain.STORAGE,
564
+ category: ErrorCategory.THIRD_PARTY,
565
+ details: { threadId },
566
+ },
567
+ error,
568
+ );
569
+ }
570
+ }
571
+
572
+ // Helper method to get included messages with context
573
+ private async _getIncludedMessages(threadId: string, selectBy: any): Promise<MastraMessageV2[]> {
574
+ if (!selectBy?.include?.length) {
575
+ return [];
576
+ }
577
+
578
+ const includeMessages: MastraMessageV2[] = [];
579
+
580
+ for (const includeItem of selectBy.include) {
581
+ try {
582
+ const { id, threadId: targetThreadId, withPreviousMessages = 0, withNextMessages = 0 } = includeItem;
583
+ const searchThreadId = targetThreadId || threadId;
584
+
585
+ this.logger.debug('Getting included messages for', {
586
+ id,
587
+ targetThreadId,
588
+ searchThreadId,
589
+ withPreviousMessages,
590
+ withNextMessages,
591
+ });
592
+
593
+ // Get all messages for the target thread
594
+ const query = this.service.entities.message.query.byThread({ entity: 'message', threadId: searchThreadId });
595
+ const results = await query.go();
596
+ const allMessages = results.data
597
+ .map((data: any) => this.parseMessageData(data))
598
+ .filter((msg: any): msg is MastraMessageV2 => 'content' in msg && typeof msg.content === 'object');
599
+
600
+ this.logger.debug('Found messages in thread', {
601
+ threadId: searchThreadId,
602
+ messageCount: allMessages.length,
603
+ messageIds: allMessages.map((m: MastraMessageV2) => m.id),
604
+ });
605
+
606
+ // Sort by createdAt ASC to get proper order, with ID tiebreaker for stable ordering
607
+ allMessages.sort((a: MastraMessageV2, b: MastraMessageV2) => {
608
+ const timeA = a.createdAt.getTime();
609
+ const timeB = b.createdAt.getTime();
610
+ if (timeA === timeB) {
611
+ return a.id.localeCompare(b.id);
612
+ }
613
+ return timeA - timeB;
614
+ });
615
+
616
+ // Find the target message
617
+ const targetIndex = allMessages.findIndex((msg: MastraMessageV2) => msg.id === id);
618
+ if (targetIndex === -1) {
619
+ this.logger.warn('Target message not found', { id, threadId: searchThreadId });
620
+ continue;
621
+ }
622
+
623
+ this.logger.debug('Found target message at index', { id, targetIndex, totalMessages: allMessages.length });
624
+
625
+ // Get context messages (previous and next)
626
+ const startIndex = Math.max(0, targetIndex - withPreviousMessages);
627
+ const endIndex = Math.min(allMessages.length, targetIndex + withNextMessages + 1);
628
+ const contextMessages = allMessages.slice(startIndex, endIndex);
629
+
630
+ this.logger.debug('Context messages', {
631
+ startIndex,
632
+ endIndex,
633
+ contextCount: contextMessages.length,
634
+ contextIds: contextMessages.map((m: MastraMessageV2) => m.id),
635
+ });
636
+
637
+ includeMessages.push(...contextMessages);
638
+ } catch (error) {
639
+ this.logger.warn('Failed to get included message', { messageId: includeItem.id, error });
640
+ }
641
+ }
642
+
643
+ this.logger.debug('Total included messages', {
644
+ count: includeMessages.length,
645
+ ids: includeMessages.map((m: MastraMessageV2) => m.id),
646
+ });
647
+
648
+ return includeMessages;
649
+ }
650
+
651
+ async updateMessages(args: {
652
+ messages: Partial<Omit<MastraMessageV2, 'createdAt'>> &
653
+ {
654
+ id: string;
655
+ content?: { metadata?: MastraMessageContentV2['metadata']; content?: MastraMessageContentV2['content'] };
656
+ }[];
657
+ }): Promise<MastraMessageV2[]> {
658
+ const { messages } = args;
659
+ this.logger.debug('Updating messages', { count: messages.length });
660
+
661
+ if (!messages.length) {
662
+ return [];
663
+ }
664
+
665
+ const updatedMessages: MastraMessageV2[] = [];
666
+ const affectedThreadIds = new Set<string>();
667
+
668
+ try {
669
+ for (const updateData of messages) {
670
+ const { id, ...updates } = updateData;
671
+
672
+ // Get the existing message
673
+ const existingMessage = await this.service.entities.message.get({ entity: 'message', id }).go();
674
+ if (!existingMessage.data) {
675
+ this.logger.warn('Message not found for update', { id });
676
+ continue;
677
+ }
678
+
679
+ const existingMsg = this.parseMessageData(existingMessage.data) as MastraMessageV2;
680
+ const originalThreadId = existingMsg.threadId;
681
+ affectedThreadIds.add(originalThreadId!);
682
+
683
+ // Prepare the update payload
684
+ const updatePayload: any = {
685
+ updatedAt: new Date().toISOString(),
686
+ };
687
+
688
+ // Handle basic field updates
689
+ if ('role' in updates && updates.role !== undefined) updatePayload.role = updates.role;
690
+ if ('type' in updates && updates.type !== undefined) updatePayload.type = updates.type;
691
+ if ('resourceId' in updates && updates.resourceId !== undefined) updatePayload.resourceId = updates.resourceId;
692
+ if ('threadId' in updates && updates.threadId !== undefined && updates.threadId !== null) {
693
+ updatePayload.threadId = updates.threadId;
694
+ affectedThreadIds.add(updates.threadId as string);
695
+ }
696
+
697
+ // Handle content updates
698
+ if (updates.content) {
699
+ const existingContent = existingMsg.content;
700
+ let newContent = { ...existingContent };
701
+
702
+ // Deep merge metadata if provided
703
+ if (updates.content.metadata !== undefined) {
704
+ newContent.metadata = {
705
+ ...(existingContent.metadata || {}),
706
+ ...(updates.content.metadata || {}),
707
+ };
708
+ }
709
+
710
+ // Update content string if provided
711
+ if (updates.content.content !== undefined) {
712
+ newContent.content = updates.content.content;
713
+ }
714
+
715
+ // Update parts if provided (only if it exists in the content type)
716
+ if ('parts' in updates.content && updates.content.parts !== undefined) {
717
+ (newContent as any).parts = updates.content.parts;
718
+ }
719
+
720
+ updatePayload.content = JSON.stringify(newContent);
721
+ }
722
+
723
+ // Update the message
724
+ await this.service.entities.message.update({ entity: 'message', id }).set(updatePayload).go();
725
+
726
+ // Get the updated message
727
+ const updatedMessage = await this.service.entities.message.get({ entity: 'message', id }).go();
728
+ if (updatedMessage.data) {
729
+ updatedMessages.push(this.parseMessageData(updatedMessage.data) as MastraMessageV2);
730
+ }
731
+ }
732
+
733
+ // Update timestamps for all affected threads
734
+ for (const threadId of affectedThreadIds) {
735
+ await this.service.entities.thread
736
+ .update({ entity: 'thread', id: threadId })
737
+ .set({
738
+ updatedAt: new Date().toISOString(),
739
+ })
740
+ .go();
741
+ }
742
+
743
+ return updatedMessages;
744
+ } catch (error) {
745
+ throw new MastraError(
746
+ {
747
+ id: 'STORAGE_DYNAMODB_STORE_UPDATE_MESSAGES_FAILED',
748
+ domain: ErrorDomain.STORAGE,
749
+ category: ErrorCategory.THIRD_PARTY,
750
+ details: { count: messages.length },
751
+ },
752
+ error,
753
+ );
754
+ }
755
+ }
756
+
757
+ async getResourceById({ resourceId }: { resourceId: string }): Promise<StorageResourceType | null> {
758
+ this.logger.debug('Getting resource by ID', { resourceId });
759
+ try {
760
+ const result = await this.service.entities.resource.get({ entity: 'resource', id: resourceId }).go();
761
+
762
+ if (!result.data) {
763
+ return null;
764
+ }
765
+
766
+ // ElectroDB handles the transformation with attribute getters
767
+ const data = result.data;
768
+ return {
769
+ ...data,
770
+ // Convert date strings back to Date objects for consistency
771
+ createdAt: typeof data.createdAt === 'string' ? new Date(data.createdAt) : data.createdAt,
772
+ updatedAt: typeof data.updatedAt === 'string' ? new Date(data.updatedAt) : data.updatedAt,
773
+ // Ensure workingMemory is always returned as a string, regardless of automatic parsing
774
+ workingMemory: typeof data.workingMemory === 'object' ? JSON.stringify(data.workingMemory) : data.workingMemory,
775
+ // metadata is already transformed by the entity's getter
776
+ } as StorageResourceType;
777
+ } catch (error) {
778
+ throw new MastraError(
779
+ {
780
+ id: 'STORAGE_DYNAMODB_STORE_GET_RESOURCE_BY_ID_FAILED',
781
+ domain: ErrorDomain.STORAGE,
782
+ category: ErrorCategory.THIRD_PARTY,
783
+ details: { resourceId },
784
+ },
785
+ error,
786
+ );
787
+ }
788
+ }
789
+
790
+ async saveResource({ resource }: { resource: StorageResourceType }): Promise<StorageResourceType> {
791
+ this.logger.debug('Saving resource', { resourceId: resource.id });
792
+
793
+ const now = new Date();
794
+
795
+ const resourceData = {
796
+ entity: 'resource',
797
+ id: resource.id,
798
+ workingMemory: resource.workingMemory,
799
+ metadata: resource.metadata ? JSON.stringify(resource.metadata) : undefined,
800
+ createdAt: resource.createdAt?.toISOString() || now.toISOString(),
801
+ updatedAt: now.toISOString(),
802
+ };
803
+
804
+ try {
805
+ await this.service.entities.resource.upsert(resourceData).go();
806
+
807
+ return {
808
+ id: resource.id,
809
+ workingMemory: resource.workingMemory,
810
+ metadata: resource.metadata,
811
+ createdAt: resource.createdAt || now,
812
+ updatedAt: now,
813
+ };
814
+ } catch (error) {
815
+ throw new MastraError(
816
+ {
817
+ id: 'STORAGE_DYNAMODB_STORE_SAVE_RESOURCE_FAILED',
818
+ domain: ErrorDomain.STORAGE,
819
+ category: ErrorCategory.THIRD_PARTY,
820
+ details: { resourceId: resource.id },
821
+ },
822
+ error,
823
+ );
824
+ }
825
+ }
826
+
827
+ async updateResource({
828
+ resourceId,
829
+ workingMemory,
830
+ metadata,
831
+ }: {
832
+ resourceId: string;
833
+ workingMemory?: string;
834
+ metadata?: Record<string, unknown>;
835
+ }): Promise<StorageResourceType> {
836
+ this.logger.debug('Updating resource', { resourceId });
837
+
838
+ try {
839
+ // First, get the existing resource to merge with updates
840
+ const existingResource = await this.getResourceById({ resourceId });
841
+
842
+ if (!existingResource) {
843
+ // Create new resource if it doesn't exist
844
+ const newResource: StorageResourceType = {
845
+ id: resourceId,
846
+ workingMemory,
847
+ metadata: metadata || {},
848
+ createdAt: new Date(),
849
+ updatedAt: new Date(),
850
+ };
851
+ return this.saveResource({ resource: newResource });
852
+ }
853
+
854
+ const now = new Date();
855
+
856
+ // Prepare the update
857
+ const updateData: any = {
858
+ updatedAt: now.toISOString(),
859
+ };
860
+
861
+ if (workingMemory !== undefined) {
862
+ updateData.workingMemory = workingMemory;
863
+ }
864
+
865
+ if (metadata) {
866
+ // Merge with existing metadata instead of overwriting
867
+ const existingMetadata = existingResource.metadata || {};
868
+ const mergedMetadata = { ...existingMetadata, ...metadata };
869
+ updateData.metadata = JSON.stringify(mergedMetadata);
870
+ }
871
+
872
+ // Update the resource using the primary key
873
+ await this.service.entities.resource.update({ entity: 'resource', id: resourceId }).set(updateData).go();
874
+
875
+ // Return the updated resource object
876
+ return {
877
+ ...existingResource,
878
+ workingMemory: workingMemory !== undefined ? workingMemory : existingResource.workingMemory,
879
+ metadata: metadata ? { ...existingResource.metadata, ...metadata } : existingResource.metadata,
880
+ updatedAt: now,
881
+ };
882
+ } catch (error) {
883
+ throw new MastraError(
884
+ {
885
+ id: 'STORAGE_DYNAMODB_STORE_UPDATE_RESOURCE_FAILED',
886
+ domain: ErrorDomain.STORAGE,
887
+ category: ErrorCategory.THIRD_PARTY,
888
+ details: { resourceId },
889
+ },
890
+ error,
891
+ );
892
+ }
893
+ }
894
+ }