@mastra/lance 0.2.0 → 0.2.1-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,947 @@
1
+ import type { Connection } from '@lancedb/lancedb';
2
+ import { MessageList } from '@mastra/core/agent';
3
+ import type { MastraMessageContentV2 } from '@mastra/core/agent';
4
+ import { ErrorCategory, ErrorDomain, MastraError } from '@mastra/core/error';
5
+ import type { MastraMessageV1, MastraMessageV2, StorageThreadType } from '@mastra/core/memory';
6
+ import {
7
+ MemoryStorage,
8
+ resolveMessageLimit,
9
+ TABLE_MESSAGES,
10
+ TABLE_RESOURCES,
11
+ TABLE_THREADS,
12
+ } from '@mastra/core/storage';
13
+ import type { PaginationInfo, StorageGetMessagesArg, StorageResourceType } from '@mastra/core/storage';
14
+ import type { StoreOperationsLance } from '../operations';
15
+ import { getTableSchema, processResultWithTypeConversion } from '../utils';
16
+
17
+ export class StoreMemoryLance extends MemoryStorage {
18
+ private client: Connection;
19
+ private operations: StoreOperationsLance;
20
+ constructor({ client, operations }: { client: Connection; operations: StoreOperationsLance }) {
21
+ super();
22
+ this.client = client;
23
+ this.operations = operations;
24
+ }
25
+
26
+ async getThreadById({ threadId }: { threadId: string }): Promise<StorageThreadType | null> {
27
+ try {
28
+ const thread = await this.operations.load({ tableName: TABLE_THREADS, keys: { id: threadId } });
29
+
30
+ if (!thread) {
31
+ return null;
32
+ }
33
+
34
+ return {
35
+ ...thread,
36
+ createdAt: new Date(thread.createdAt),
37
+ updatedAt: new Date(thread.updatedAt),
38
+ };
39
+ } catch (error: any) {
40
+ throw new MastraError(
41
+ {
42
+ id: 'LANCE_STORE_GET_THREAD_BY_ID_FAILED',
43
+ domain: ErrorDomain.STORAGE,
44
+ category: ErrorCategory.THIRD_PARTY,
45
+ },
46
+ error,
47
+ );
48
+ }
49
+ }
50
+
51
+ async getThreadsByResourceId({ resourceId }: { resourceId: string }): Promise<StorageThreadType[]> {
52
+ try {
53
+ const table = await this.client.openTable(TABLE_THREADS);
54
+ // fetches all threads with the given resourceId
55
+ const query = table.query().where(`\`resourceId\` = '${resourceId}'`);
56
+
57
+ const records = await query.toArray();
58
+ return processResultWithTypeConversion(
59
+ records,
60
+ await getTableSchema({ tableName: TABLE_THREADS, client: this.client }),
61
+ ) as StorageThreadType[];
62
+ } catch (error: any) {
63
+ throw new MastraError(
64
+ {
65
+ id: 'LANCE_STORE_GET_THREADS_BY_RESOURCE_ID_FAILED',
66
+ domain: ErrorDomain.STORAGE,
67
+ category: ErrorCategory.THIRD_PARTY,
68
+ },
69
+ error,
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Saves a thread to the database. This function doesn't overwrite existing threads.
76
+ * @param thread - The thread to save
77
+ * @returns The saved thread
78
+ */
79
+ async saveThread({ thread }: { thread: StorageThreadType }): Promise<StorageThreadType> {
80
+ try {
81
+ const record = { ...thread, metadata: JSON.stringify(thread.metadata) };
82
+ const table = await this.client.openTable(TABLE_THREADS);
83
+ await table.add([record], { mode: 'append' });
84
+
85
+ return thread;
86
+ } catch (error: any) {
87
+ throw new MastraError(
88
+ {
89
+ id: 'LANCE_STORE_SAVE_THREAD_FAILED',
90
+ domain: ErrorDomain.STORAGE,
91
+ category: ErrorCategory.THIRD_PARTY,
92
+ },
93
+ error,
94
+ );
95
+ }
96
+ }
97
+
98
+ async updateThread({
99
+ id,
100
+ title,
101
+ metadata,
102
+ }: {
103
+ id: string;
104
+ title: string;
105
+ metadata: Record<string, unknown>;
106
+ }): Promise<StorageThreadType> {
107
+ const maxRetries = 5;
108
+
109
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
110
+ try {
111
+ // Get current state atomically
112
+ const current = await this.getThreadById({ threadId: id });
113
+ if (!current) {
114
+ throw new Error(`Thread with id ${id} not found`);
115
+ }
116
+
117
+ // Merge metadata
118
+ const mergedMetadata = { ...current.metadata, ...metadata };
119
+
120
+ // Update atomically
121
+ const record = {
122
+ id,
123
+ title,
124
+ metadata: JSON.stringify(mergedMetadata),
125
+ updatedAt: new Date().getTime(),
126
+ };
127
+
128
+ const table = await this.client.openTable(TABLE_THREADS);
129
+ await table.mergeInsert('id').whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([record]);
130
+
131
+ const updatedThread = await this.getThreadById({ threadId: id });
132
+ if (!updatedThread) {
133
+ throw new Error(`Failed to retrieve updated thread ${id}`);
134
+ }
135
+ return updatedThread;
136
+ } catch (error: any) {
137
+ if (error.message?.includes('Commit conflict') && attempt < maxRetries - 1) {
138
+ // Wait with exponential backoff before retrying
139
+ const delay = Math.pow(2, attempt) * 10; // 10ms, 20ms, 40ms
140
+ await new Promise(resolve => setTimeout(resolve, delay));
141
+ continue;
142
+ }
143
+
144
+ // If it's not a commit conflict or we've exhausted retries, throw the error
145
+ throw new MastraError(
146
+ {
147
+ id: 'LANCE_STORE_UPDATE_THREAD_FAILED',
148
+ domain: ErrorDomain.STORAGE,
149
+ category: ErrorCategory.THIRD_PARTY,
150
+ },
151
+ error,
152
+ );
153
+ }
154
+ }
155
+
156
+ // This should never be reached, but just in case
157
+ throw new MastraError(
158
+ {
159
+ id: 'LANCE_STORE_UPDATE_THREAD_FAILED',
160
+ domain: ErrorDomain.STORAGE,
161
+ category: ErrorCategory.THIRD_PARTY,
162
+ },
163
+ new Error('All retries exhausted'),
164
+ );
165
+ }
166
+
167
+ async deleteThread({ threadId }: { threadId: string }): Promise<void> {
168
+ try {
169
+ // Delete the thread
170
+ const table = await this.client.openTable(TABLE_THREADS);
171
+ await table.delete(`id = '${threadId}'`);
172
+
173
+ // Delete all messages with the matching thread_id
174
+ const messagesTable = await this.client.openTable(TABLE_MESSAGES);
175
+ await messagesTable.delete(`thread_id = '${threadId}'`);
176
+ } catch (error: any) {
177
+ throw new MastraError(
178
+ {
179
+ id: 'LANCE_STORE_DELETE_THREAD_FAILED',
180
+ domain: ErrorDomain.STORAGE,
181
+ category: ErrorCategory.THIRD_PARTY,
182
+ },
183
+ error,
184
+ );
185
+ }
186
+ }
187
+
188
+ public async getMessages(args: StorageGetMessagesArg & { format?: 'v1' }): Promise<MastraMessageV1[]>;
189
+ public async getMessages(args: StorageGetMessagesArg & { format: 'v2' }): Promise<MastraMessageV2[]>;
190
+ public async getMessages({
191
+ threadId,
192
+ resourceId,
193
+ selectBy,
194
+ format,
195
+ threadConfig,
196
+ }: StorageGetMessagesArg & { format?: 'v1' | 'v2' }): Promise<MastraMessageV1[] | MastraMessageV2[]> {
197
+ try {
198
+ if (threadConfig) {
199
+ throw new Error('ThreadConfig is not supported by LanceDB storage');
200
+ }
201
+ const limit = resolveMessageLimit({ last: selectBy?.last, defaultLimit: Number.MAX_SAFE_INTEGER });
202
+ const table = await this.client.openTable(TABLE_MESSAGES);
203
+
204
+ let allRecords: any[] = [];
205
+
206
+ // Handle selectBy.include for cross-thread context retrieval
207
+ if (selectBy?.include && selectBy.include.length > 0) {
208
+ // Get all unique thread IDs from include items
209
+ const threadIds = [...new Set(selectBy.include.map(item => item.threadId))];
210
+
211
+ // Fetch all messages from all relevant threads
212
+ for (const threadId of threadIds) {
213
+ const threadQuery = table.query().where(`thread_id = '${threadId}'`);
214
+ let threadRecords = await threadQuery.toArray();
215
+ allRecords.push(...threadRecords);
216
+ }
217
+ } else {
218
+ // Regular single-thread query
219
+ let query = table.query().where(`\`thread_id\` = '${threadId}'`);
220
+ allRecords = await query.toArray();
221
+ }
222
+
223
+ // Sort the records chronologically
224
+ allRecords.sort((a, b) => {
225
+ const dateA = new Date(a.createdAt).getTime();
226
+ const dateB = new Date(b.createdAt).getTime();
227
+ return dateA - dateB; // Ascending order
228
+ });
229
+
230
+ // Process the include.withPreviousMessages and include.withNextMessages if specified
231
+ if (selectBy?.include && selectBy.include.length > 0) {
232
+ allRecords = this.processMessagesWithContext(allRecords, selectBy.include);
233
+ }
234
+
235
+ // If we're fetching the last N messages, take only the last N after sorting
236
+ if (limit !== Number.MAX_SAFE_INTEGER) {
237
+ allRecords = allRecords.slice(-limit);
238
+ }
239
+
240
+ const messages = processResultWithTypeConversion(
241
+ allRecords,
242
+ await getTableSchema({ tableName: TABLE_MESSAGES, client: this.client }),
243
+ );
244
+ const normalized = messages.map((msg: any) => {
245
+ const { thread_id, ...rest } = msg;
246
+ return {
247
+ ...rest,
248
+ threadId: thread_id,
249
+ content:
250
+ typeof msg.content === 'string'
251
+ ? (() => {
252
+ try {
253
+ return JSON.parse(msg.content);
254
+ } catch {
255
+ return msg.content;
256
+ }
257
+ })()
258
+ : msg.content,
259
+ };
260
+ });
261
+ const list = new MessageList({ threadId, resourceId }).add(normalized, 'memory');
262
+ if (format === 'v2') return list.get.all.v2();
263
+ return list.get.all.v1();
264
+ } catch (error: any) {
265
+ throw new MastraError(
266
+ {
267
+ id: 'LANCE_STORE_GET_MESSAGES_FAILED',
268
+ domain: ErrorDomain.STORAGE,
269
+ category: ErrorCategory.THIRD_PARTY,
270
+ },
271
+ error,
272
+ );
273
+ }
274
+ }
275
+
276
+ async saveMessages(args: { messages: MastraMessageV1[]; format?: undefined | 'v1' }): Promise<MastraMessageV1[]>;
277
+ async saveMessages(args: { messages: MastraMessageV2[]; format: 'v2' }): Promise<MastraMessageV2[]>;
278
+ async saveMessages(
279
+ args: { messages: MastraMessageV1[]; format?: undefined | 'v1' } | { messages: MastraMessageV2[]; format: 'v2' },
280
+ ): Promise<MastraMessageV2[] | MastraMessageV1[]> {
281
+ try {
282
+ const { messages, format = 'v1' } = args;
283
+ if (messages.length === 0) {
284
+ return [];
285
+ }
286
+
287
+ const threadId = messages[0]?.threadId;
288
+
289
+ if (!threadId) {
290
+ throw new Error('Thread ID is required');
291
+ }
292
+
293
+ // Validate all messages before saving
294
+ for (const message of messages) {
295
+ if (!message.id) {
296
+ throw new Error('Message ID is required');
297
+ }
298
+ if (!message.threadId) {
299
+ throw new Error('Thread ID is required for all messages');
300
+ }
301
+ if (message.resourceId === null || message.resourceId === undefined) {
302
+ throw new Error('Resource ID cannot be null or undefined');
303
+ }
304
+ if (!message.content) {
305
+ throw new Error('Message content is required');
306
+ }
307
+ }
308
+
309
+ const transformedMessages = messages.map((message: MastraMessageV2 | MastraMessageV1) => {
310
+ const { threadId, type, ...rest } = message;
311
+ return {
312
+ ...rest,
313
+ thread_id: threadId,
314
+ type: type ?? 'v2',
315
+ content: JSON.stringify(message.content),
316
+ };
317
+ });
318
+
319
+ const table = await this.client.openTable(TABLE_MESSAGES);
320
+ await table.mergeInsert('id').whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(transformedMessages);
321
+
322
+ // Update the thread's updatedAt timestamp
323
+ const threadsTable = await this.client.openTable(TABLE_THREADS);
324
+ const currentTime = new Date().getTime();
325
+ const updateRecord = { id: threadId, updatedAt: currentTime };
326
+ await threadsTable.mergeInsert('id').whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([updateRecord]);
327
+
328
+ const list = new MessageList().add(messages, 'memory');
329
+ if (format === `v2`) return list.get.all.v2();
330
+ return list.get.all.v1();
331
+ } catch (error: any) {
332
+ throw new MastraError(
333
+ {
334
+ id: 'LANCE_STORE_SAVE_MESSAGES_FAILED',
335
+ domain: ErrorDomain.STORAGE,
336
+ category: ErrorCategory.THIRD_PARTY,
337
+ },
338
+ error,
339
+ );
340
+ }
341
+ }
342
+
343
+ async getThreadsByResourceIdPaginated(args: {
344
+ resourceId: string;
345
+ page?: number;
346
+ perPage?: number;
347
+ }): Promise<PaginationInfo & { threads: StorageThreadType[] }> {
348
+ try {
349
+ const { resourceId, page = 0, perPage = 10 } = args;
350
+ const table = await this.client.openTable(TABLE_THREADS);
351
+
352
+ // Get total count
353
+ const total = await table.countRows(`\`resourceId\` = '${resourceId}'`);
354
+
355
+ // Get paginated results
356
+ const query = table.query().where(`\`resourceId\` = '${resourceId}'`);
357
+ const offset = page * perPage;
358
+ query.limit(perPage);
359
+ if (offset > 0) {
360
+ query.offset(offset);
361
+ }
362
+
363
+ const records = await query.toArray();
364
+
365
+ // Sort by updatedAt descending (most recent first)
366
+ records.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
367
+
368
+ const schema = await getTableSchema({ tableName: TABLE_THREADS, client: this.client });
369
+ const threads = records.map(record => processResultWithTypeConversion(record, schema)) as StorageThreadType[];
370
+
371
+ return {
372
+ threads,
373
+ total,
374
+ page,
375
+ perPage,
376
+ hasMore: total > (page + 1) * perPage,
377
+ };
378
+ } catch (error: any) {
379
+ throw new MastraError(
380
+ {
381
+ id: 'LANCE_STORE_GET_THREADS_BY_RESOURCE_ID_PAGINATED_FAILED',
382
+ domain: ErrorDomain.STORAGE,
383
+ category: ErrorCategory.THIRD_PARTY,
384
+ },
385
+ error,
386
+ );
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Processes messages to include context messages based on withPreviousMessages and withNextMessages
392
+ * @param records - The sorted array of records to process
393
+ * @param include - The array of include specifications with context parameters
394
+ * @returns The processed array with context messages included
395
+ */
396
+ private processMessagesWithContext(
397
+ records: any[],
398
+ include: { id: string; withPreviousMessages?: number; withNextMessages?: number }[],
399
+ ): any[] {
400
+ const messagesWithContext = include.filter(item => item.withPreviousMessages || item.withNextMessages);
401
+
402
+ if (messagesWithContext.length === 0) {
403
+ return records;
404
+ }
405
+
406
+ // Create a map of message id to index in the sorted array for quick lookup
407
+ const messageIndexMap = new Map<string, number>();
408
+ records.forEach((message, index) => {
409
+ messageIndexMap.set(message.id, index);
410
+ });
411
+
412
+ // Keep track of additional indices to include
413
+ const additionalIndices = new Set<number>();
414
+
415
+ for (const item of messagesWithContext) {
416
+ const messageIndex = messageIndexMap.get(item.id);
417
+
418
+ if (messageIndex !== undefined) {
419
+ // Add previous messages if requested
420
+ if (item.withPreviousMessages) {
421
+ const startIdx = Math.max(0, messageIndex - item.withPreviousMessages);
422
+ for (let i = startIdx; i < messageIndex; i++) {
423
+ additionalIndices.add(i);
424
+ }
425
+ }
426
+
427
+ // Add next messages if requested
428
+ if (item.withNextMessages) {
429
+ const endIdx = Math.min(records.length - 1, messageIndex + item.withNextMessages);
430
+ for (let i = messageIndex + 1; i <= endIdx; i++) {
431
+ additionalIndices.add(i);
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ // If we need to include additional messages, create a new set of records
438
+ if (additionalIndices.size === 0) {
439
+ return records;
440
+ }
441
+
442
+ // Get IDs of the records that matched the original query
443
+ const originalMatchIds = new Set(include.map(item => item.id));
444
+
445
+ // Create a set of all indices we need to include
446
+ const allIndices = new Set<number>();
447
+
448
+ // Add indices of originally matched messages
449
+ records.forEach((record, index) => {
450
+ if (originalMatchIds.has(record.id)) {
451
+ allIndices.add(index);
452
+ }
453
+ });
454
+
455
+ // Add the additional context message indices
456
+ additionalIndices.forEach(index => {
457
+ allIndices.add(index);
458
+ });
459
+
460
+ // Create a new filtered array with only the required messages
461
+ // while maintaining chronological order
462
+ return Array.from(allIndices)
463
+ .sort((a, b) => a - b)
464
+ .map(index => records[index]);
465
+ }
466
+
467
+ async getMessagesPaginated(
468
+ args: StorageGetMessagesArg & { format?: 'v1' | 'v2' },
469
+ ): Promise<PaginationInfo & { messages: MastraMessageV1[] | MastraMessageV2[] }> {
470
+ try {
471
+ const { threadId, resourceId, selectBy, format = 'v1' } = args;
472
+
473
+ if (!threadId) {
474
+ throw new Error('Thread ID is required for getMessagesPaginated');
475
+ }
476
+
477
+ // Extract pagination and dateRange from selectBy.pagination
478
+ const page = selectBy?.pagination?.page ?? 0;
479
+ const perPage = selectBy?.pagination?.perPage ?? 10;
480
+ const dateRange = selectBy?.pagination?.dateRange;
481
+ const fromDate = dateRange?.start;
482
+ const toDate = dateRange?.end;
483
+
484
+ const table = await this.client.openTable(TABLE_MESSAGES);
485
+ const messages: any[] = [];
486
+
487
+ // Handle selectBy.include first (before pagination)
488
+ if (selectBy?.include && Array.isArray(selectBy.include)) {
489
+ // Get all unique thread IDs from include items
490
+ const threadIds = [...new Set(selectBy.include.map(item => item.threadId))];
491
+
492
+ // Fetch all messages from all relevant threads
493
+ const allThreadMessages: any[] = [];
494
+ for (const threadId of threadIds) {
495
+ const threadQuery = table.query().where(`thread_id = '${threadId}'`);
496
+ let threadRecords = await threadQuery.toArray();
497
+
498
+ // Apply date filtering in JS for context
499
+ if (fromDate) threadRecords = threadRecords.filter(m => m.createdAt >= fromDate.getTime());
500
+ if (toDate) threadRecords = threadRecords.filter(m => m.createdAt <= toDate.getTime());
501
+
502
+ allThreadMessages.push(...threadRecords);
503
+ }
504
+
505
+ // Sort all messages by createdAt
506
+ allThreadMessages.sort((a, b) => a.createdAt - b.createdAt);
507
+
508
+ // Apply processMessagesWithContext to the combined array
509
+ const contextMessages = this.processMessagesWithContext(allThreadMessages, selectBy.include);
510
+ messages.push(...contextMessages);
511
+ }
512
+
513
+ // Build query conditions for the main thread
514
+ const conditions: string[] = [`thread_id = '${threadId}'`];
515
+ if (resourceId) {
516
+ conditions.push(`\`resourceId\` = '${resourceId}'`);
517
+ }
518
+ if (fromDate) {
519
+ conditions.push(`\`createdAt\` >= ${fromDate.getTime()}`);
520
+ }
521
+ if (toDate) {
522
+ conditions.push(`\`createdAt\` <= ${toDate.getTime()}`);
523
+ }
524
+
525
+ // Get total count (excluding already included messages)
526
+ let total = 0;
527
+ if (conditions.length > 0) {
528
+ total = await table.countRows(conditions.join(' AND '));
529
+ } else {
530
+ total = await table.countRows();
531
+ }
532
+
533
+ // If no messages and no included messages, return empty result
534
+ if (total === 0 && messages.length === 0) {
535
+ return {
536
+ messages: [],
537
+ total: 0,
538
+ page,
539
+ perPage,
540
+ hasMore: false,
541
+ };
542
+ }
543
+
544
+ // Fetch paginated messages (excluding already included ones)
545
+ const excludeIds = messages.map(m => m.id);
546
+ let selectedMessages: any[] = [];
547
+
548
+ if (selectBy?.last && selectBy.last > 0) {
549
+ // Handle selectBy.last: get last N messages for the main thread
550
+ const query = table.query();
551
+ if (conditions.length > 0) {
552
+ query.where(conditions.join(' AND '));
553
+ }
554
+ let records = await query.toArray();
555
+ records = records.sort((a, b) => a.createdAt - b.createdAt);
556
+
557
+ // Exclude already included messages
558
+ if (excludeIds.length > 0) {
559
+ records = records.filter(m => !excludeIds.includes(m.id));
560
+ }
561
+
562
+ selectedMessages = records.slice(-selectBy.last);
563
+ } else {
564
+ // Regular pagination
565
+ const query = table.query();
566
+ if (conditions.length > 0) {
567
+ query.where(conditions.join(' AND '));
568
+ }
569
+ let records = await query.toArray();
570
+ records = records.sort((a, b) => a.createdAt - b.createdAt);
571
+
572
+ // Exclude already included messages
573
+ if (excludeIds.length > 0) {
574
+ records = records.filter(m => !excludeIds.includes(m.id));
575
+ }
576
+
577
+ selectedMessages = records.slice(page * perPage, (page + 1) * perPage);
578
+ }
579
+
580
+ // Merge all messages and deduplicate
581
+ const allMessages = [...messages, ...selectedMessages];
582
+ const seen = new Set();
583
+ const dedupedMessages = allMessages.filter(m => {
584
+ const key = `${m.id}:${m.thread_id}`;
585
+ if (seen.has(key)) return false;
586
+ seen.add(key);
587
+ return true;
588
+ });
589
+
590
+ // Convert to correct format (v1/v2)
591
+ const formattedMessages = dedupedMessages.map((msg: any) => {
592
+ const { thread_id, ...rest } = msg;
593
+ return {
594
+ ...rest,
595
+ threadId: thread_id,
596
+ content:
597
+ typeof msg.content === 'string'
598
+ ? (() => {
599
+ try {
600
+ return JSON.parse(msg.content);
601
+ } catch {
602
+ return msg.content;
603
+ }
604
+ })()
605
+ : msg.content,
606
+ };
607
+ });
608
+
609
+ const list = new MessageList().add(formattedMessages, 'memory');
610
+ return {
611
+ messages: format === 'v2' ? list.get.all.v2() : list.get.all.v1(),
612
+ total: total, // Total should be the count of messages matching the filters
613
+ page,
614
+ perPage,
615
+ hasMore: total > (page + 1) * perPage,
616
+ };
617
+ } catch (error: any) {
618
+ throw new MastraError(
619
+ {
620
+ id: 'LANCE_STORE_GET_MESSAGES_PAGINATED_FAILED',
621
+ domain: ErrorDomain.STORAGE,
622
+ category: ErrorCategory.THIRD_PARTY,
623
+ },
624
+ error,
625
+ );
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Parse message data from LanceDB record format to MastraMessageV2 format
631
+ */
632
+ private parseMessageData(data: any): MastraMessageV2 {
633
+ const { thread_id, ...rest } = data;
634
+ return {
635
+ ...rest,
636
+ threadId: thread_id,
637
+ content:
638
+ typeof data.content === 'string'
639
+ ? (() => {
640
+ try {
641
+ return JSON.parse(data.content);
642
+ } catch {
643
+ return data.content;
644
+ }
645
+ })()
646
+ : data.content,
647
+ createdAt: new Date(data.createdAt),
648
+ updatedAt: new Date(data.updatedAt),
649
+ } as MastraMessageV2;
650
+ }
651
+
652
+ async updateMessages(args: {
653
+ messages: Partial<Omit<MastraMessageV2, 'createdAt'>> &
654
+ {
655
+ id: string;
656
+ content?: { metadata?: MastraMessageContentV2['metadata']; content?: MastraMessageContentV2['content'] };
657
+ }[];
658
+ }): Promise<MastraMessageV2[]> {
659
+ const { messages } = args;
660
+ this.logger.debug('Updating messages', { count: messages.length });
661
+
662
+ if (!messages.length) {
663
+ return [];
664
+ }
665
+
666
+ const updatedMessages: MastraMessageV2[] = [];
667
+ const affectedThreadIds = new Set<string>();
668
+
669
+ try {
670
+ for (const updateData of messages) {
671
+ const { id, ...updates } = updateData;
672
+
673
+ // Get the existing message
674
+ const existingMessage = await this.operations.load({ tableName: TABLE_MESSAGES, keys: { id } });
675
+ if (!existingMessage) {
676
+ this.logger.warn('Message not found for update', { id });
677
+ continue;
678
+ }
679
+
680
+ const existingMsg = this.parseMessageData(existingMessage);
681
+ const originalThreadId = existingMsg.threadId;
682
+ affectedThreadIds.add(originalThreadId!);
683
+
684
+ // Prepare the update payload
685
+ const updatePayload: any = {};
686
+
687
+ // Handle basic field updates
688
+ if ('role' in updates && updates.role !== undefined) updatePayload.role = updates.role;
689
+ if ('type' in updates && updates.type !== undefined) updatePayload.type = updates.type;
690
+ if ('resourceId' in updates && updates.resourceId !== undefined) updatePayload.resourceId = updates.resourceId;
691
+ if ('threadId' in updates && updates.threadId !== undefined && updates.threadId !== null) {
692
+ updatePayload.thread_id = updates.threadId;
693
+ affectedThreadIds.add(updates.threadId as string);
694
+ }
695
+
696
+ // Handle content updates
697
+ if (updates.content) {
698
+ const existingContent = existingMsg.content;
699
+ let newContent = { ...existingContent };
700
+
701
+ // Deep merge metadata if provided
702
+ if (updates.content.metadata !== undefined) {
703
+ newContent.metadata = {
704
+ ...(existingContent.metadata || {}),
705
+ ...(updates.content.metadata || {}),
706
+ };
707
+ }
708
+
709
+ // Update content string if provided
710
+ if (updates.content.content !== undefined) {
711
+ newContent.content = updates.content.content;
712
+ }
713
+
714
+ // Update parts if provided (only if it exists in the content type)
715
+ if ('parts' in updates.content && updates.content.parts !== undefined) {
716
+ (newContent as any).parts = updates.content.parts;
717
+ }
718
+
719
+ updatePayload.content = JSON.stringify(newContent);
720
+ }
721
+
722
+ // Update the message using merge insert
723
+ await this.operations.insert({ tableName: TABLE_MESSAGES, record: { id, ...updatePayload } });
724
+
725
+ // Get the updated message
726
+ const updatedMessage = await this.operations.load({ tableName: TABLE_MESSAGES, keys: { id } });
727
+ if (updatedMessage) {
728
+ updatedMessages.push(this.parseMessageData(updatedMessage));
729
+ }
730
+ }
731
+
732
+ // Update timestamps for all affected threads
733
+ for (const threadId of affectedThreadIds) {
734
+ await this.operations.insert({
735
+ tableName: TABLE_THREADS,
736
+ record: { id: threadId, updatedAt: Date.now() },
737
+ });
738
+ }
739
+
740
+ return updatedMessages;
741
+ } catch (error: any) {
742
+ throw new MastraError(
743
+ {
744
+ id: 'LANCE_STORE_UPDATE_MESSAGES_FAILED',
745
+ domain: ErrorDomain.STORAGE,
746
+ category: ErrorCategory.THIRD_PARTY,
747
+ details: { count: messages.length },
748
+ },
749
+ error,
750
+ );
751
+ }
752
+ }
753
+
754
+ async getResourceById({ resourceId }: { resourceId: string }): Promise<StorageResourceType | null> {
755
+ try {
756
+ const resource = await this.operations.load({ tableName: TABLE_RESOURCES, keys: { id: resourceId } });
757
+
758
+ if (!resource) {
759
+ return null;
760
+ }
761
+
762
+ // Handle date conversion - LanceDB stores timestamps as numbers
763
+ let createdAt: Date;
764
+ let updatedAt: Date;
765
+
766
+ // Convert ISO strings back to Date objects with error handling
767
+ try {
768
+ // If createdAt is already a Date object, use it directly
769
+ if (resource.createdAt instanceof Date) {
770
+ createdAt = resource.createdAt;
771
+ } else if (typeof resource.createdAt === 'string') {
772
+ // If it's an ISO string, parse it
773
+ createdAt = new Date(resource.createdAt);
774
+ } else if (typeof resource.createdAt === 'number') {
775
+ // If it's a timestamp, convert it to Date
776
+ createdAt = new Date(resource.createdAt);
777
+ } else {
778
+ // If it's null or undefined, use current date
779
+ createdAt = new Date();
780
+ }
781
+ if (isNaN(createdAt.getTime())) {
782
+ createdAt = new Date(); // Fallback to current date if invalid
783
+ }
784
+ } catch {
785
+ createdAt = new Date(); // Fallback to current date if conversion fails
786
+ }
787
+
788
+ try {
789
+ // If updatedAt is already a Date object, use it directly
790
+ if (resource.updatedAt instanceof Date) {
791
+ updatedAt = resource.updatedAt;
792
+ } else if (typeof resource.updatedAt === 'string') {
793
+ // If it's an ISO string, parse it
794
+ updatedAt = new Date(resource.updatedAt);
795
+ } else if (typeof resource.updatedAt === 'number') {
796
+ // If it's a timestamp, convert it to Date
797
+ updatedAt = new Date(resource.updatedAt);
798
+ } else {
799
+ // If it's null or undefined, use current date
800
+ updatedAt = new Date();
801
+ }
802
+ if (isNaN(updatedAt.getTime())) {
803
+ updatedAt = new Date(); // Fallback to current date if invalid
804
+ }
805
+ } catch {
806
+ updatedAt = new Date(); // Fallback to current date if conversion fails
807
+ }
808
+
809
+ // Handle workingMemory - return undefined for null/undefined, empty string for empty string
810
+ let workingMemory = resource.workingMemory;
811
+ if (workingMemory === null || workingMemory === undefined) {
812
+ workingMemory = undefined;
813
+ } else if (workingMemory === '') {
814
+ workingMemory = ''; // Return empty string for empty strings to match test expectations
815
+ } else if (typeof workingMemory === 'object') {
816
+ workingMemory = JSON.stringify(workingMemory);
817
+ }
818
+
819
+ // Handle metadata - return undefined for empty strings, parse JSON safely
820
+ let metadata = resource.metadata;
821
+ if (metadata === '' || metadata === null || metadata === undefined) {
822
+ metadata = undefined;
823
+ } else if (typeof metadata === 'string') {
824
+ try {
825
+ metadata = JSON.parse(metadata);
826
+ } catch {
827
+ // If JSON parsing fails, return the original string
828
+ metadata = metadata;
829
+ }
830
+ }
831
+
832
+ return {
833
+ ...resource,
834
+ createdAt,
835
+ updatedAt,
836
+ workingMemory,
837
+ metadata,
838
+ } as StorageResourceType;
839
+ } catch (error: any) {
840
+ throw new MastraError(
841
+ {
842
+ id: 'LANCE_STORE_GET_RESOURCE_BY_ID_FAILED',
843
+ domain: ErrorDomain.STORAGE,
844
+ category: ErrorCategory.THIRD_PARTY,
845
+ },
846
+ error,
847
+ );
848
+ }
849
+ }
850
+
851
+ async saveResource({ resource }: { resource: StorageResourceType }): Promise<StorageResourceType> {
852
+ try {
853
+ const record = {
854
+ ...resource,
855
+ metadata: resource.metadata ? JSON.stringify(resource.metadata) : '',
856
+ createdAt: resource.createdAt.getTime(), // Store as timestamp (milliseconds)
857
+ updatedAt: resource.updatedAt.getTime(), // Store as timestamp (milliseconds)
858
+ };
859
+
860
+ const table = await this.client.openTable(TABLE_RESOURCES);
861
+ await table.add([record], { mode: 'append' });
862
+
863
+ return resource;
864
+ } catch (error: any) {
865
+ throw new MastraError(
866
+ {
867
+ id: 'LANCE_STORE_SAVE_RESOURCE_FAILED',
868
+ domain: ErrorDomain.STORAGE,
869
+ category: ErrorCategory.THIRD_PARTY,
870
+ },
871
+ error,
872
+ );
873
+ }
874
+ }
875
+
876
+ async updateResource({
877
+ resourceId,
878
+ workingMemory,
879
+ metadata,
880
+ }: {
881
+ resourceId: string;
882
+ workingMemory?: string;
883
+ metadata?: Record<string, unknown>;
884
+ }): Promise<StorageResourceType> {
885
+ const maxRetries = 3;
886
+
887
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
888
+ try {
889
+ const existingResource = await this.getResourceById({ resourceId });
890
+
891
+ if (!existingResource) {
892
+ // Create new resource if it doesn't exist
893
+ const newResource: StorageResourceType = {
894
+ id: resourceId,
895
+ workingMemory,
896
+ metadata: metadata || {},
897
+ createdAt: new Date(),
898
+ updatedAt: new Date(),
899
+ };
900
+ return this.saveResource({ resource: newResource });
901
+ }
902
+
903
+ const updatedResource = {
904
+ ...existingResource,
905
+ workingMemory: workingMemory !== undefined ? workingMemory : existingResource.workingMemory,
906
+ metadata: {
907
+ ...existingResource.metadata,
908
+ ...metadata,
909
+ },
910
+ updatedAt: new Date(),
911
+ };
912
+
913
+ const record = {
914
+ id: resourceId,
915
+ workingMemory: updatedResource.workingMemory || '',
916
+ metadata: updatedResource.metadata ? JSON.stringify(updatedResource.metadata) : '',
917
+ updatedAt: updatedResource.updatedAt.getTime(), // Store as timestamp (milliseconds)
918
+ };
919
+
920
+ const table = await this.client.openTable(TABLE_RESOURCES);
921
+ await table.mergeInsert('id').whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([record]);
922
+
923
+ return updatedResource;
924
+ } catch (error: any) {
925
+ if (error.message?.includes('Commit conflict') && attempt < maxRetries - 1) {
926
+ // Wait with exponential backoff before retrying
927
+ const delay = Math.pow(2, attempt) * 10; // 10ms, 20ms, 40ms
928
+ await new Promise(resolve => setTimeout(resolve, delay));
929
+ continue;
930
+ }
931
+
932
+ // If it's not a commit conflict or we've exhausted retries, throw the error
933
+ throw new MastraError(
934
+ {
935
+ id: 'LANCE_STORE_UPDATE_RESOURCE_FAILED',
936
+ domain: ErrorDomain.STORAGE,
937
+ category: ErrorCategory.THIRD_PARTY,
938
+ },
939
+ error,
940
+ );
941
+ }
942
+ }
943
+
944
+ // This should never be reached, but TypeScript requires it
945
+ throw new Error('Unexpected end of retry loop');
946
+ }
947
+ }