@mastra/upstash 0.10.0 → 0.10.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.
@@ -25,15 +25,189 @@ export interface UpstashConfig {
25
25
  }
26
26
 
27
27
  export class UpstashStore extends MastraStorage {
28
- batchInsert(_input: { tableName: TABLE_NAMES; records: Record<string, any>[] }): Promise<void> {
29
- throw new Error('Method not implemented.');
28
+ private redis: Redis;
29
+
30
+ constructor(config: UpstashConfig) {
31
+ super({ name: 'Upstash' });
32
+ this.redis = new Redis({
33
+ url: config.url,
34
+ token: config.token,
35
+ });
36
+ }
37
+
38
+ private transformEvalRecord(record: Record<string, any>): EvalRow {
39
+ // Parse JSON strings if needed
40
+ let result = record.result;
41
+ if (typeof result === 'string') {
42
+ try {
43
+ result = JSON.parse(result);
44
+ } catch {
45
+ console.warn('Failed to parse result JSON:');
46
+ }
47
+ }
48
+
49
+ let testInfo = record.test_info;
50
+ if (typeof testInfo === 'string') {
51
+ try {
52
+ testInfo = JSON.parse(testInfo);
53
+ } catch {
54
+ console.warn('Failed to parse test_info JSON:');
55
+ }
56
+ }
57
+
58
+ return {
59
+ agentName: record.agent_name,
60
+ input: record.input,
61
+ output: record.output,
62
+ result: result as MetricResult,
63
+ metricName: record.metric_name,
64
+ instructions: record.instructions,
65
+ testInfo: testInfo as TestInfo | undefined,
66
+ globalRunId: record.global_run_id,
67
+ runId: record.run_id,
68
+ createdAt:
69
+ typeof record.created_at === 'string'
70
+ ? record.created_at
71
+ : record.created_at instanceof Date
72
+ ? record.created_at.toISOString()
73
+ : new Date().toISOString(),
74
+ };
75
+ }
76
+
77
+ private parseJSON(value: any): any {
78
+ if (typeof value === 'string') {
79
+ try {
80
+ return JSON.parse(value);
81
+ } catch {
82
+ return value;
83
+ }
84
+ }
85
+ return value;
86
+ }
87
+
88
+ private getKey(tableName: TABLE_NAMES, keys: Record<string, any>): string {
89
+ const keyParts = Object.entries(keys)
90
+ .filter(([_, value]) => value !== undefined)
91
+ .map(([key, value]) => `${key}:${value}`);
92
+ return `${tableName}:${keyParts.join(':')}`;
93
+ }
94
+
95
+ private ensureDate(date: Date | string | undefined): Date | undefined {
96
+ if (!date) return undefined;
97
+ return date instanceof Date ? date : new Date(date);
98
+ }
99
+
100
+ private serializeDate(date: Date | string | undefined): string | undefined {
101
+ if (!date) return undefined;
102
+ const dateObj = this.ensureDate(date);
103
+ return dateObj?.toISOString();
104
+ }
105
+
106
+ /**
107
+ * Scans for keys matching the given pattern using SCAN and returns them as an array.
108
+ * @param pattern Redis key pattern, e.g. "table:*"
109
+ * @param batchSize Number of keys to scan per batch (default: 1000)
110
+ */
111
+ private async scanKeys(pattern: string, batchSize = 10000): Promise<string[]> {
112
+ let cursor = '0';
113
+ let keys: string[] = [];
114
+ do {
115
+ // Upstash: scan(cursor, { match, count })
116
+ const [nextCursor, batch] = await this.redis.scan(cursor, {
117
+ match: pattern,
118
+ count: batchSize,
119
+ });
120
+ keys.push(...batch);
121
+ cursor = nextCursor;
122
+ } while (cursor !== '0');
123
+ return keys;
124
+ }
125
+
126
+ /**
127
+ * Deletes all keys matching the given pattern using SCAN and DEL in batches.
128
+ * @param pattern Redis key pattern, e.g. "table:*"
129
+ * @param batchSize Number of keys to delete per batch (default: 1000)
130
+ */
131
+ private async scanAndDelete(pattern: string, batchSize = 10000): Promise<number> {
132
+ let cursor = '0';
133
+ let totalDeleted = 0;
134
+ do {
135
+ const [nextCursor, keys] = await this.redis.scan(cursor, {
136
+ match: pattern,
137
+ count: batchSize,
138
+ });
139
+ if (keys.length > 0) {
140
+ await this.redis.del(...keys);
141
+ totalDeleted += keys.length;
142
+ }
143
+ cursor = nextCursor;
144
+ } while (cursor !== '0');
145
+ return totalDeleted;
146
+ }
147
+
148
+ private getMessageKey(threadId: string, messageId: string): string {
149
+ return this.getKey(TABLE_MESSAGES, { threadId, id: messageId });
150
+ }
151
+
152
+ private getThreadMessagesKey(threadId: string): string {
153
+ return `thread:${threadId}:messages`;
154
+ }
155
+
156
+ private parseWorkflowRun(row: any): WorkflowRun {
157
+ let parsedSnapshot: WorkflowRunState | string = row.snapshot as string;
158
+ if (typeof parsedSnapshot === 'string') {
159
+ try {
160
+ parsedSnapshot = JSON.parse(row.snapshot as string) as WorkflowRunState;
161
+ } catch (e) {
162
+ // If parsing fails, return the raw snapshot string
163
+ console.warn(`Failed to parse snapshot for workflow ${row.workflow_name}: ${e}`);
164
+ }
165
+ }
166
+
167
+ return {
168
+ workflowName: row.workflow_name,
169
+ runId: row.run_id,
170
+ snapshot: parsedSnapshot,
171
+ createdAt: this.ensureDate(row.createdAt)!,
172
+ updatedAt: this.ensureDate(row.updatedAt)!,
173
+ resourceId: row.resourceId,
174
+ };
175
+ }
176
+
177
+ private processRecord(tableName: TABLE_NAMES, record: Record<string, any>) {
178
+ let key: string;
179
+
180
+ if (tableName === TABLE_MESSAGES) {
181
+ // For messages, use threadId as the primary key component
182
+ key = this.getKey(tableName, { threadId: record.threadId, id: record.id });
183
+ } else if (tableName === TABLE_WORKFLOW_SNAPSHOT) {
184
+ key = this.getKey(tableName, {
185
+ namespace: record.namespace || 'workflows',
186
+ workflow_name: record.workflow_name,
187
+ run_id: record.run_id,
188
+ ...(record.resourceId ? { resourceId: record.resourceId } : {}),
189
+ });
190
+ } else if (tableName === TABLE_EVALS) {
191
+ key = this.getKey(tableName, { id: record.run_id });
192
+ } else {
193
+ key = this.getKey(tableName, { id: record.id });
194
+ }
195
+
196
+ // Convert dates to ISO strings before storing
197
+ const processedRecord = {
198
+ ...record,
199
+ createdAt: this.serializeDate(record.createdAt),
200
+ updatedAt: this.serializeDate(record.updatedAt),
201
+ };
202
+
203
+ return { key, processedRecord };
30
204
  }
31
205
 
32
206
  async getEvalsByAgentName(agentName: string, type?: 'test' | 'live'): Promise<EvalRow[]> {
33
207
  try {
34
208
  // Get all keys that match the evals table pattern
35
209
  const pattern = `${TABLE_EVALS}:*`;
36
- const keys = await this.redis.keys(pattern);
210
+ const keys = await this.scanKeys(pattern);
37
211
 
38
212
  // Fetch all eval records
39
213
  const evalRecords = await Promise.all(
@@ -96,45 +270,6 @@ export class UpstashStore extends MastraStorage {
96
270
  }
97
271
  }
98
272
 
99
- private transformEvalRecord(record: Record<string, any>): EvalRow {
100
- // Parse JSON strings if needed
101
- let result = record.result;
102
- if (typeof result === 'string') {
103
- try {
104
- result = JSON.parse(result);
105
- } catch {
106
- console.warn('Failed to parse result JSON:');
107
- }
108
- }
109
-
110
- let testInfo = record.test_info;
111
- if (typeof testInfo === 'string') {
112
- try {
113
- testInfo = JSON.parse(testInfo);
114
- } catch {
115
- console.warn('Failed to parse test_info JSON:');
116
- }
117
- }
118
-
119
- return {
120
- agentName: record.agent_name,
121
- input: record.input,
122
- output: record.output,
123
- result: result as MetricResult,
124
- metricName: record.metric_name,
125
- instructions: record.instructions,
126
- testInfo: testInfo as TestInfo | undefined,
127
- globalRunId: record.global_run_id,
128
- runId: record.run_id,
129
- createdAt:
130
- typeof record.created_at === 'string'
131
- ? record.created_at
132
- : record.created_at instanceof Date
133
- ? record.created_at.toISOString()
134
- : new Date().toISOString(),
135
- };
136
- }
137
-
138
273
  async getTraces(
139
274
  {
140
275
  name,
@@ -162,7 +297,7 @@ export class UpstashStore extends MastraStorage {
162
297
  try {
163
298
  // Get all keys that match the traces table pattern
164
299
  const pattern = `${TABLE_TRACES}:*`;
165
- const keys = await this.redis.keys(pattern);
300
+ const keys = await this.scanKeys(pattern);
166
301
 
167
302
  // Fetch all trace records
168
303
  const traceRecords = await Promise.all(
@@ -253,45 +388,6 @@ export class UpstashStore extends MastraStorage {
253
388
  }
254
389
  }
255
390
 
256
- private parseJSON(value: any): any {
257
- if (typeof value === 'string') {
258
- try {
259
- return JSON.parse(value);
260
- } catch {
261
- return value;
262
- }
263
- }
264
- return value;
265
- }
266
-
267
- private redis: Redis;
268
-
269
- constructor(config: UpstashConfig) {
270
- super({ name: 'Upstash' });
271
- this.redis = new Redis({
272
- url: config.url,
273
- token: config.token,
274
- });
275
- }
276
-
277
- private getKey(tableName: TABLE_NAMES, keys: Record<string, any>): string {
278
- const keyParts = Object.entries(keys)
279
- .filter(([_, value]) => value !== undefined)
280
- .map(([key, value]) => `${key}:${value}`);
281
- return `${tableName}:${keyParts.join(':')}`;
282
- }
283
-
284
- private ensureDate(date: Date | string | undefined): Date | undefined {
285
- if (!date) return undefined;
286
- return date instanceof Date ? date : new Date(date);
287
- }
288
-
289
- private serializeDate(date: Date | string | undefined): string | undefined {
290
- if (!date) return undefined;
291
- const dateObj = this.ensureDate(date);
292
- return dateObj?.toISOString();
293
- }
294
-
295
391
  async createTable({
296
392
  tableName,
297
393
  schema,
@@ -306,41 +402,31 @@ export class UpstashStore extends MastraStorage {
306
402
 
307
403
  async clearTable({ tableName }: { tableName: TABLE_NAMES }): Promise<void> {
308
404
  const pattern = `${tableName}:*`;
309
- const keys = await this.redis.keys(pattern);
310
- if (keys.length > 0) {
311
- await this.redis.del(...keys);
312
- }
405
+ await this.scanAndDelete(pattern);
313
406
  }
314
407
 
315
408
  async insert({ tableName, record }: { tableName: TABLE_NAMES; record: Record<string, any> }): Promise<void> {
316
- let key: string;
317
-
318
- if (tableName === TABLE_MESSAGES) {
319
- // For messages, use threadId as the primary key component
320
- key = this.getKey(tableName, { threadId: record.threadId, id: record.id });
321
- } else if (tableName === TABLE_WORKFLOW_SNAPSHOT) {
322
- key = this.getKey(tableName, {
323
- namespace: record.namespace || 'workflows',
324
- workflow_name: record.workflow_name,
325
- run_id: record.run_id,
326
- ...(record.resourceId ? { resourceId: record.resourceId } : {}),
327
- });
328
- } else if (tableName === TABLE_EVALS) {
329
- key = this.getKey(tableName, { id: record.run_id });
330
- } else {
331
- key = this.getKey(tableName, { id: record.id });
332
- }
333
-
334
- // Convert dates to ISO strings before storing
335
- const processedRecord = {
336
- ...record,
337
- createdAt: this.serializeDate(record.createdAt),
338
- updatedAt: this.serializeDate(record.updatedAt),
339
- };
409
+ const { key, processedRecord } = this.processRecord(tableName, record);
340
410
 
341
411
  await this.redis.set(key, processedRecord);
342
412
  }
343
413
 
414
+ async batchInsert(input: { tableName: TABLE_NAMES; records: Record<string, any>[] }): Promise<void> {
415
+ const { tableName, records } = input;
416
+ if (!records.length) return;
417
+
418
+ const batchSize = 1000;
419
+ for (let i = 0; i < records.length; i += batchSize) {
420
+ const batch = records.slice(i, i + batchSize);
421
+ const pipeline = this.redis.pipeline();
422
+ for (const record of batch) {
423
+ const { key, processedRecord } = this.processRecord(tableName, record);
424
+ pipeline.set(key, processedRecord);
425
+ }
426
+ await pipeline.exec();
427
+ }
428
+ }
429
+
344
430
  async load<R>({ tableName, keys }: { tableName: TABLE_NAMES; keys: Record<string, string> }): Promise<R | null> {
345
431
  const key = this.getKey(tableName, keys);
346
432
  const data = await this.redis.get<R>(key);
@@ -365,7 +451,7 @@ export class UpstashStore extends MastraStorage {
365
451
 
366
452
  async getThreadsByResourceId({ resourceId }: { resourceId: string }): Promise<StorageThreadType[]> {
367
453
  const pattern = `${TABLE_THREADS}:*`;
368
- const keys = await this.redis.keys(pattern);
454
+ const keys = await this.scanKeys(pattern);
369
455
  const threads = await Promise.all(
370
456
  keys.map(async key => {
371
457
  const data = await this.redis.get<StorageThreadType>(key);
@@ -423,40 +509,36 @@ export class UpstashStore extends MastraStorage {
423
509
  await this.redis.del(key);
424
510
  }
425
511
 
426
- private getMessageKey(threadId: string, messageId: string): string {
427
- return this.getKey(TABLE_MESSAGES, { threadId, id: messageId });
428
- }
429
-
430
- private getThreadMessagesKey(threadId: string): string {
431
- return `thread:${threadId}:messages`;
432
- }
433
-
434
512
  async saveMessages({ messages }: { messages: MessageType[] }): Promise<MessageType[]> {
435
513
  if (messages.length === 0) return [];
436
514
 
437
- const pipeline = this.redis.pipeline();
438
-
439
515
  // Add an index to each message to maintain order
440
516
  const messagesWithIndex = messages.map((message, index) => ({
441
517
  ...message,
442
518
  _index: index,
443
519
  }));
444
520
 
445
- for (const message of messagesWithIndex) {
446
- const key = this.getMessageKey(message.threadId, message.id);
447
- const score = message._index !== undefined ? message._index : new Date(message.createdAt).getTime();
448
-
449
- // Store the message data
450
- pipeline.set(key, message);
521
+ const batchSize = 1000;
522
+ for (let i = 0; i < messagesWithIndex.length; i += batchSize) {
523
+ const batch = messagesWithIndex.slice(i, i + batchSize);
524
+ const pipeline = this.redis.pipeline();
525
+ for (const message of batch) {
526
+ const key = this.getMessageKey(message.threadId, message.id);
527
+ const score = message._index !== undefined ? message._index : new Date(message.createdAt).getTime();
528
+
529
+ // Store the message data
530
+ pipeline.set(key, message);
531
+
532
+ // Add to sorted set for this thread
533
+ pipeline.zadd(this.getThreadMessagesKey(message.threadId), {
534
+ score,
535
+ member: message.id,
536
+ });
537
+ }
451
538
 
452
- // Add to sorted set for this thread
453
- pipeline.zadd(this.getThreadMessagesKey(message.threadId), {
454
- score,
455
- member: message.id,
456
- });
539
+ await pipeline.exec();
457
540
  }
458
541
 
459
- await pipeline.exec();
460
542
  return messages;
461
543
  }
462
544
 
@@ -557,27 +639,6 @@ export class UpstashStore extends MastraStorage {
557
639
  return data.snapshot;
558
640
  }
559
641
 
560
- private parseWorkflowRun(row: any): WorkflowRun {
561
- let parsedSnapshot: WorkflowRunState | string = row.snapshot as string;
562
- if (typeof parsedSnapshot === 'string') {
563
- try {
564
- parsedSnapshot = JSON.parse(row.snapshot as string) as WorkflowRunState;
565
- } catch (e) {
566
- // If parsing fails, return the raw snapshot string
567
- console.warn(`Failed to parse snapshot for workflow ${row.workflow_name}: ${e}`);
568
- }
569
- }
570
-
571
- return {
572
- workflowName: row.workflow_name,
573
- runId: row.run_id,
574
- snapshot: parsedSnapshot,
575
- createdAt: this.ensureDate(row.createdAt)!,
576
- updatedAt: this.ensureDate(row.updatedAt)!,
577
- resourceId: row.resourceId,
578
- };
579
- }
580
-
581
642
  async getWorkflowRuns(
582
643
  {
583
644
  namespace,
@@ -612,7 +673,7 @@ export class UpstashStore extends MastraStorage {
612
673
  } else if (resourceId) {
613
674
  pattern = this.getKey(TABLE_WORKFLOW_SNAPSHOT, { namespace, workflow_name: '*', run_id: '*', resourceId });
614
675
  }
615
- const keys = await this.redis.keys(pattern);
676
+ const keys = await this.scanKeys(pattern);
616
677
 
617
678
  // Get all workflow data
618
679
  const workflows = await Promise.all(
@@ -665,7 +726,7 @@ export class UpstashStore extends MastraStorage {
665
726
  }): Promise<WorkflowRun | null> {
666
727
  try {
667
728
  const key = this.getKey(TABLE_WORKFLOW_SNAPSHOT, { namespace, workflow_name: workflowName, run_id: runId }) + '*';
668
- const keys = await this.redis.keys(key);
729
+ const keys = await this.scanKeys(key);
669
730
  const workflows = await Promise.all(
670
731
  keys.map(async key => {
671
732
  const data = await this.redis.get<{
@@ -14,7 +14,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vites
14
14
  import { UpstashStore } from './index';
15
15
 
16
16
  // Increase timeout for all tests in this file to 30 seconds
17
- vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
17
+ vi.setConfig({ testTimeout: 200_000, hookTimeout: 200_000 });
18
18
 
19
19
  const createSampleThread = (date?: Date) => ({
20
20
  id: `thread-${randomUUID()}`,
@@ -42,16 +42,15 @@ const createSampleWorkflowSnapshot = (status: string, createdAt?: Date) => {
42
42
  const snapshot: WorkflowRunState = {
43
43
  value: {},
44
44
  context: {
45
- steps: {
46
- [stepId]: {
47
- status: status as WorkflowRunState['context']['steps'][string]['status'],
48
- payload: {},
49
- error: undefined,
50
- },
45
+ [stepId]: {
46
+ status: status,
47
+ payload: {},
48
+ error: undefined,
49
+ startedAt: timestamp.getTime(),
50
+ endedAt: new Date(timestamp.getTime() + 15000).getTime(),
51
51
  },
52
- triggerData: {},
53
- attempts: {},
54
- },
52
+ input: {},
53
+ } as WorkflowRunState['context'],
55
54
  activePaths: [],
56
55
  suspendedPaths: {},
57
56
  runId,
@@ -98,7 +97,7 @@ const checkWorkflowSnapshot = (snapshot: WorkflowRunState | string, stepId: stri
98
97
  if (typeof snapshot === 'string') {
99
98
  throw new Error('Expected WorkflowRunState, got string');
100
99
  }
101
- expect(snapshot.context?.steps[stepId]?.status).toBe(status);
100
+ expect(snapshot.context?.[stepId]?.status).toBe(status);
102
101
  };
103
102
 
104
103
  describe('UpstashStore', () => {
@@ -227,6 +226,16 @@ describe('UpstashStore', () => {
227
226
  updated: 'value',
228
227
  });
229
228
  });
229
+ it('should fetch >100000 threads by resource ID', async () => {
230
+ const resourceId = `resource-${randomUUID()}`;
231
+ const total = 100_000;
232
+ const threads = Array.from({ length: total }, () => ({ ...createSampleThread(), resourceId }));
233
+
234
+ await store.batchInsert({ tableName: TABLE_THREADS, records: threads });
235
+
236
+ const retrievedThreads = await store.getThreadsByResourceId({ resourceId });
237
+ expect(retrievedThreads).toHaveLength(total);
238
+ });
230
239
  });
231
240
 
232
241
  describe('Date Handling', () => {
@@ -455,13 +464,14 @@ describe('UpstashStore', () => {
455
464
  const mockSnapshot = {
456
465
  value: { step1: 'completed' },
457
466
  context: {
458
- stepResults: {
459
- step1: { status: 'success', payload: { result: 'done' } },
467
+ step1: {
468
+ status: 'success',
469
+ output: { result: 'done' },
470
+ payload: {},
471
+ startedAt: new Date().getTime(),
472
+ endedAt: new Date(Date.now() + 15000).getTime(),
460
473
  },
461
- steps: {},
462
- attempts: {},
463
- triggerData: {},
464
- },
474
+ } as WorkflowRunState['context'],
465
475
  runId: testRunId,
466
476
  activePaths: [],
467
477
  suspendedPaths: {},
@@ -619,10 +629,7 @@ describe('UpstashStore', () => {
619
629
  expect(total).toBe(1);
620
630
  expect(runs[0]!.workflowName).toBe(workflowName1);
621
631
  const snapshot = runs[0]!.snapshot;
622
- if (typeof snapshot === 'string') {
623
- throw new Error('Expected WorkflowRunState, got string');
624
- }
625
- expect(snapshot.context?.steps[stepId1]?.status).toBe('success');
632
+ checkWorkflowSnapshot(snapshot, stepId1, 'success');
626
633
  });
627
634
 
628
635
  it('filters by date range', async () => {