@mastra/upstash 0.0.0-vnextWorkflows-20250422142014 → 0.0.0-workflow-deno-20250616130925

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.
@@ -1,5 +1,11 @@
1
1
  import { randomUUID } from 'crypto';
2
- import type { MessageType } from '@mastra/core/memory';
2
+ import {
3
+ checkWorkflowSnapshot,
4
+ createSampleMessageV2,
5
+ createSampleThread,
6
+ createSampleWorkflowSnapshot,
7
+ } from '@internal/storage-test-utils';
8
+ import type { MastraMessageV2 } from '@mastra/core';
3
9
  import type { TABLE_NAMES } from '@mastra/core/storage';
4
10
  import {
5
11
  TABLE_MESSAGES,
@@ -9,58 +15,19 @@ import {
9
15
  TABLE_TRACES,
10
16
  } from '@mastra/core/storage';
11
17
  import type { WorkflowRunState } from '@mastra/core/workflows';
12
- import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
18
+ import { describe, it, expect, beforeAll, beforeEach, afterAll, vi, afterEach } from 'vitest';
13
19
 
14
20
  import { UpstashStore } from './index';
15
21
 
16
22
  // Increase timeout for all tests in this file to 30 seconds
17
- vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
18
-
19
- const createSampleThread = (date?: Date) => ({
20
- id: `thread-${randomUUID()}`,
21
- resourceId: `resource-${randomUUID()}`,
22
- title: 'Test Thread',
23
- createdAt: date || new Date(),
24
- updatedAt: date || new Date(),
25
- metadata: { key: 'value' },
26
- });
27
-
28
- const createSampleMessage = (threadId: string, content: string = 'Hello') =>
29
- ({
30
- id: `msg-${randomUUID()}`,
31
- role: 'user',
32
- type: 'text',
33
- threadId,
34
- content: [{ type: 'text', text: content }],
35
- createdAt: new Date(),
36
- }) as any;
37
-
38
- const createSampleWorkflowSnapshot = (status: string, createdAt?: Date) => {
39
- const runId = `run-${randomUUID()}`;
40
- const stepId = `step-${randomUUID()}`;
41
- const timestamp = createdAt || new Date();
42
- const snapshot = {
43
- result: { success: true },
44
- value: {},
45
- context: {
46
- steps: {
47
- [stepId]: {
48
- status,
49
- payload: {},
50
- error: undefined,
51
- },
52
- },
53
- triggerData: {},
54
- attempts: {},
55
- },
56
- activePaths: [],
57
- runId,
58
- timestamp: timestamp.getTime(),
59
- } as WorkflowRunState;
60
- return { snapshot, runId, stepId };
61
- };
62
-
63
- const createSampleTrace = (name: string, scope?: string, attributes?: Record<string, string>) => ({
23
+ vi.setConfig({ testTimeout: 200_000, hookTimeout: 200_000 });
24
+
25
+ const createSampleTrace = (
26
+ name: string,
27
+ scope?: string,
28
+ attributes?: Record<string, string>,
29
+ createdAt: Date = new Date(),
30
+ ) => ({
64
31
  id: `trace-${randomUUID()}`,
65
32
  parentSpanId: `span-${randomUUID()}`,
66
33
  traceId: `trace-${randomUUID()}`,
@@ -68,16 +35,16 @@ const createSampleTrace = (name: string, scope?: string, attributes?: Record<str
68
35
  scope,
69
36
  kind: 'internal',
70
37
  status: JSON.stringify({ code: 'success' }),
71
- events: JSON.stringify([{ name: 'start', timestamp: Date.now() }]),
38
+ events: JSON.stringify([{ name: 'start', timestamp: createdAt.getTime() }]),
72
39
  links: JSON.stringify([]),
73
40
  attributes: attributes ? JSON.stringify(attributes) : undefined,
74
- startTime: new Date().toISOString(),
75
- endTime: new Date().toISOString(),
41
+ startTime: createdAt.toISOString(),
42
+ endTime: new Date(createdAt.getTime() + 1000).toISOString(),
76
43
  other: JSON.stringify({ custom: 'data' }),
77
- createdAt: new Date().toISOString(),
44
+ createdAt: createdAt.toISOString(),
78
45
  });
79
46
 
80
- const createSampleEval = (agentName: string, isTest = false) => {
47
+ const createSampleEval = (agentName: string, isTest = false, createdAt: Date = new Date()) => {
81
48
  const testInfo = isTest ? { testPath: 'test/path.ts', testName: 'Test Name' } : undefined;
82
49
 
83
50
  return {
@@ -90,7 +57,7 @@ const createSampleEval = (agentName: string, isTest = false) => {
90
57
  test_info: testInfo ? JSON.stringify(testInfo) : undefined,
91
58
  global_run_id: `global-${randomUUID()}`,
92
59
  run_id: `run-${randomUUID()}`,
93
- created_at: new Date().toISOString(),
60
+ created_at: createdAt.toISOString(),
94
61
  };
95
62
  };
96
63
 
@@ -170,7 +137,7 @@ describe('UpstashStore', () => {
170
137
 
171
138
  it('should create and retrieve a thread', async () => {
172
139
  const now = new Date();
173
- const thread = createSampleThread(now);
140
+ const thread = createSampleThread({ date: now });
174
141
 
175
142
  const savedThread = await store.saveThread({ thread });
176
143
  expect(savedThread).toEqual(thread);
@@ -190,7 +157,7 @@ describe('UpstashStore', () => {
190
157
 
191
158
  it('should get threads by resource ID', async () => {
192
159
  const thread1 = createSampleThread();
193
- const thread2 = { ...createSampleThread(), resourceId: thread1.resourceId };
160
+ const thread2 = createSampleThread({ resourceId: thread1.resourceId });
194
161
  const threads = [thread1, thread2];
195
162
 
196
163
  const resourceId = threads[0].resourceId;
@@ -220,6 +187,56 @@ describe('UpstashStore', () => {
220
187
  updated: 'value',
221
188
  });
222
189
  });
190
+
191
+ it('should update thread updatedAt when a message is saved to it', async () => {
192
+ const thread = createSampleThread();
193
+ await store.saveThread({ thread });
194
+
195
+ // Get the initial thread to capture the original updatedAt
196
+ const initialThread = await store.getThreadById({ threadId: thread.id });
197
+ expect(initialThread).toBeDefined();
198
+ const originalUpdatedAt = initialThread!.updatedAt;
199
+
200
+ // Wait a small amount to ensure different timestamp
201
+ await new Promise(resolve => setTimeout(resolve, 10));
202
+
203
+ // Create and save a message to the thread
204
+ const message = createSampleMessageV2({ threadId: thread.id });
205
+ await store.saveMessages({ messages: [message], format: 'v2' });
206
+
207
+ // Retrieve the thread again and check that updatedAt was updated
208
+ const updatedThread = await store.getThreadById({ threadId: thread.id });
209
+ expect(updatedThread).toBeDefined();
210
+ expect(updatedThread!.updatedAt.getTime()).toBeGreaterThan(originalUpdatedAt.getTime());
211
+ });
212
+
213
+ it('should fetch >100000 threads by resource ID', async () => {
214
+ const resourceId = `resource-${randomUUID()}`;
215
+ const total = 100_000;
216
+ const threads = Array.from({ length: total }, () => createSampleThread({ resourceId }));
217
+
218
+ await store.batchInsert({ tableName: TABLE_THREADS, records: threads });
219
+
220
+ const retrievedThreads = await store.getThreadsByResourceId({ resourceId });
221
+ expect(retrievedThreads).toHaveLength(total);
222
+ });
223
+ it('should delete thread and its messages', async () => {
224
+ const thread = createSampleThread();
225
+ await store.saveThread({ thread });
226
+
227
+ // Add some messages
228
+ const messages = [createSampleMessageV2({ threadId: thread.id }), createSampleMessageV2({ threadId: thread.id })];
229
+ await store.saveMessages({ messages, format: 'v2' });
230
+
231
+ await store.deleteThread({ threadId: thread.id });
232
+
233
+ const retrievedThread = await store.getThreadById({ threadId: thread.id });
234
+ expect(retrievedThread).toBeNull();
235
+
236
+ // Verify messages were also deleted
237
+ const retrievedMessages = await store.getMessages({ threadId: thread.id });
238
+ expect(retrievedMessages).toHaveLength(0);
239
+ });
223
240
  });
224
241
 
225
242
  describe('Date Handling', () => {
@@ -229,7 +246,7 @@ describe('UpstashStore', () => {
229
246
 
230
247
  it('should handle Date objects in thread operations', async () => {
231
248
  const now = new Date();
232
- const thread = createSampleThread(now);
249
+ const thread = createSampleThread({ date: now });
233
250
 
234
251
  await store.saveThread({ thread });
235
252
  const retrievedThread = await store.getThreadById({ threadId: thread.id });
@@ -241,7 +258,7 @@ describe('UpstashStore', () => {
241
258
 
242
259
  it('should handle ISO string dates in thread operations', async () => {
243
260
  const now = new Date();
244
- const thread = createSampleThread(now);
261
+ const thread = createSampleThread({ date: now });
245
262
 
246
263
  await store.saveThread({ thread });
247
264
  const retrievedThread = await store.getThreadById({ threadId: thread.id });
@@ -253,7 +270,7 @@ describe('UpstashStore', () => {
253
270
 
254
271
  it('should handle mixed date formats in thread operations', async () => {
255
272
  const now = new Date();
256
- const thread = createSampleThread(now);
273
+ const thread = createSampleThread({ date: now });
257
274
 
258
275
  await store.saveThread({ thread });
259
276
  const retrievedThread = await store.getThreadById({ threadId: thread.id });
@@ -265,8 +282,8 @@ describe('UpstashStore', () => {
265
282
 
266
283
  it('should handle date serialization in getThreadsByResourceId', async () => {
267
284
  const now = new Date();
268
- const thread1 = createSampleThread(now);
269
- const thread2 = { ...createSampleThread(now), resourceId: thread1.resourceId };
285
+ const thread1 = createSampleThread({ date: now });
286
+ const thread2 = { ...createSampleThread({ date: now }), resourceId: thread1.resourceId };
270
287
  const threads = [thread1, thread2];
271
288
 
272
289
  await Promise.all(threads.map(thread => store.saveThread({ thread })));
@@ -303,17 +320,121 @@ describe('UpstashStore', () => {
303
320
  });
304
321
 
305
322
  it('should save and retrieve messages in order', async () => {
306
- const messages = [
307
- createSampleMessage(threadId, 'First'),
308
- createSampleMessage(threadId, 'Second'),
309
- createSampleMessage(threadId, 'Third'),
323
+ const messages: MastraMessageV2[] = [
324
+ createSampleMessageV2({ threadId, content: 'First' }),
325
+ createSampleMessageV2({ threadId, content: 'Second' }),
326
+ createSampleMessageV2({ threadId, content: 'Third' }),
327
+ ];
328
+
329
+ await store.saveMessages({ messages, format: 'v2' });
330
+
331
+ const retrievedMessages = await store.getMessages({ threadId, format: 'v2' });
332
+ expect(retrievedMessages).toHaveLength(3);
333
+ expect(retrievedMessages.map((m: any) => m.content.parts[0].text)).toEqual(['First', 'Second', 'Third']);
334
+ });
335
+
336
+ it('should retrieve messages w/ next/prev messages by message id + resource id', async () => {
337
+ const thread = createSampleThread({ id: 'thread-one' });
338
+ await store.saveThread({ thread });
339
+
340
+ const thread2 = createSampleThread({ id: 'thread-two' });
341
+ await store.saveThread({ thread: thread2 });
342
+
343
+ const thread3 = createSampleThread({ id: 'thread-three' });
344
+ await store.saveThread({ thread: thread3 });
345
+
346
+ const messages: MastraMessageV2[] = [
347
+ createSampleMessageV2({ threadId: 'thread-one', content: 'First', resourceId: 'cross-thread-resource' }),
348
+ createSampleMessageV2({ threadId: 'thread-one', content: 'Second', resourceId: 'cross-thread-resource' }),
349
+ createSampleMessageV2({ threadId: 'thread-one', content: 'Third', resourceId: 'cross-thread-resource' }),
350
+
351
+ createSampleMessageV2({ threadId: 'thread-two', content: 'Fourth', resourceId: 'cross-thread-resource' }),
352
+ createSampleMessageV2({ threadId: 'thread-two', content: 'Fifth', resourceId: 'cross-thread-resource' }),
353
+ createSampleMessageV2({ threadId: 'thread-two', content: 'Sixth', resourceId: 'cross-thread-resource' }),
354
+
355
+ createSampleMessageV2({ threadId: 'thread-three', content: 'Seventh', resourceId: 'other-resource' }),
356
+ createSampleMessageV2({ threadId: 'thread-three', content: 'Eighth', resourceId: 'other-resource' }),
310
357
  ];
311
358
 
312
- await store.saveMessages({ messages: messages as MessageType[] });
359
+ await store.saveMessages({ messages: messages, format: 'v2' });
313
360
 
314
- const retrievedMessages = await store.getMessages({ threadId });
361
+ const retrievedMessages = await store.getMessages({ threadId: 'thread-one', format: 'v2' });
315
362
  expect(retrievedMessages).toHaveLength(3);
316
- expect(retrievedMessages.map(m => m.content[0].text)).toEqual(['First', 'Second', 'Third']);
363
+ expect(retrievedMessages.map((m: any) => m.content.parts[0].text)).toEqual(['First', 'Second', 'Third']);
364
+
365
+ const retrievedMessages2 = await store.getMessages({ threadId: 'thread-two', format: 'v2' });
366
+ expect(retrievedMessages2).toHaveLength(3);
367
+ expect(retrievedMessages2.map((m: any) => m.content.parts[0].text)).toEqual(['Fourth', 'Fifth', 'Sixth']);
368
+
369
+ const retrievedMessages3 = await store.getMessages({ threadId: 'thread-three', format: 'v2' });
370
+ expect(retrievedMessages3).toHaveLength(2);
371
+ expect(retrievedMessages3.map((m: any) => m.content.parts[0].text)).toEqual(['Seventh', 'Eighth']);
372
+
373
+ const crossThreadMessages = await store.getMessages({
374
+ threadId: 'thread-doesnt-exist',
375
+ format: 'v2',
376
+ selectBy: {
377
+ last: 0,
378
+ include: [
379
+ {
380
+ id: messages[1].id,
381
+ threadId: 'thread-one',
382
+ withNextMessages: 2,
383
+ withPreviousMessages: 2,
384
+ },
385
+ {
386
+ id: messages[4].id,
387
+ threadId: 'thread-two',
388
+ withPreviousMessages: 2,
389
+ withNextMessages: 2,
390
+ },
391
+ ],
392
+ },
393
+ });
394
+
395
+ expect(crossThreadMessages).toHaveLength(6);
396
+ expect(crossThreadMessages.filter(m => m.threadId === `thread-one`)).toHaveLength(3);
397
+ expect(crossThreadMessages.filter(m => m.threadId === `thread-two`)).toHaveLength(3);
398
+
399
+ const crossThreadMessages2 = await store.getMessages({
400
+ threadId: 'thread-one',
401
+ format: 'v2',
402
+ selectBy: {
403
+ last: 0,
404
+ include: [
405
+ {
406
+ id: messages[4].id,
407
+ threadId: 'thread-two',
408
+ withPreviousMessages: 1,
409
+ withNextMessages: 1,
410
+ },
411
+ ],
412
+ },
413
+ });
414
+
415
+ expect(crossThreadMessages2).toHaveLength(3);
416
+ expect(crossThreadMessages2.filter(m => m.threadId === `thread-one`)).toHaveLength(0);
417
+ expect(crossThreadMessages2.filter(m => m.threadId === `thread-two`)).toHaveLength(3);
418
+
419
+ const crossThreadMessages3 = await store.getMessages({
420
+ threadId: 'thread-two',
421
+ format: 'v2',
422
+ selectBy: {
423
+ last: 0,
424
+ include: [
425
+ {
426
+ id: messages[1].id,
427
+ threadId: 'thread-one',
428
+ withNextMessages: 1,
429
+ withPreviousMessages: 1,
430
+ },
431
+ ],
432
+ },
433
+ });
434
+
435
+ expect(crossThreadMessages3).toHaveLength(3);
436
+ expect(crossThreadMessages3.filter(m => m.threadId === `thread-one`)).toHaveLength(3);
437
+ expect(crossThreadMessages3.filter(m => m.threadId === `thread-two`)).toHaveLength(0);
317
438
  });
318
439
 
319
440
  it('should handle empty message array', async () => {
@@ -327,21 +448,131 @@ describe('UpstashStore', () => {
327
448
  id: 'msg-1',
328
449
  threadId,
329
450
  role: 'user',
330
- type: 'text',
331
- content: [
332
- { type: 'text', text: 'Message with' },
333
- { type: 'code', text: 'code block', language: 'typescript' },
334
- { type: 'text', text: 'and more text' },
335
- ],
451
+ content: {
452
+ format: 2,
453
+ parts: [
454
+ { type: 'text', text: 'Message with' },
455
+ { type: 'code', text: 'code block', language: 'typescript' },
456
+ { type: 'text', text: 'and more text' },
457
+ ],
458
+ },
336
459
  createdAt: new Date(),
337
460
  },
338
- ];
461
+ ] as MastraMessageV2[];
339
462
 
340
- await store.saveMessages({ messages: messages as MessageType[] });
463
+ await store.saveMessages({ messages, format: 'v2' });
341
464
 
342
- const retrievedMessages = await store.getMessages({ threadId });
465
+ const retrievedMessages = await store.getMessages({ threadId, format: 'v2' });
343
466
  expect(retrievedMessages[0].content).toEqual(messages[0].content);
344
467
  });
468
+
469
+ describe('getMessagesPaginated', () => {
470
+ it('should return paginated messages with total count', async () => {
471
+ const thread = createSampleThread();
472
+ await store.saveThread({ thread });
473
+
474
+ const messages = Array.from({ length: 15 }, (_, i) =>
475
+ createSampleMessageV2({ threadId: thread.id, content: `Message ${i + 1}` }),
476
+ );
477
+
478
+ await store.saveMessages({ messages, format: 'v2' });
479
+
480
+ const page1 = await store.getMessagesPaginated({
481
+ threadId: thread.id,
482
+ selectBy: { pagination: { page: 0, perPage: 5 } },
483
+ format: 'v2',
484
+ });
485
+ expect(page1.messages).toHaveLength(5);
486
+ expect(page1.total).toBe(15);
487
+ expect(page1.page).toBe(0);
488
+ expect(page1.perPage).toBe(5);
489
+ expect(page1.hasMore).toBe(true);
490
+
491
+ const page3 = await store.getMessagesPaginated({
492
+ threadId: thread.id,
493
+ selectBy: { pagination: { page: 2, perPage: 5 } },
494
+ format: 'v2',
495
+ });
496
+ expect(page3.messages).toHaveLength(5);
497
+ expect(page3.total).toBe(15);
498
+ expect(page3.hasMore).toBe(false);
499
+
500
+ const page4 = await store.getMessagesPaginated({
501
+ threadId: thread.id,
502
+ selectBy: { pagination: { page: 3, perPage: 5 } },
503
+ format: 'v2',
504
+ });
505
+ expect(page4.messages).toHaveLength(0);
506
+ expect(page4.total).toBe(15);
507
+ expect(page4.hasMore).toBe(false);
508
+ });
509
+
510
+ it('should maintain chronological order in pagination', async () => {
511
+ const thread = createSampleThread();
512
+ await store.saveThread({ thread });
513
+
514
+ const messages = Array.from({ length: 10 }, (_, i) => {
515
+ const message = createSampleMessageV2({ threadId: thread.id, content: `Message ${i + 1}` });
516
+ // Ensure different timestamps
517
+ message.createdAt = new Date(Date.now() + i * 1000);
518
+ return message;
519
+ });
520
+
521
+ await store.saveMessages({ messages, format: 'v2' });
522
+
523
+ const page1 = await store.getMessagesPaginated({
524
+ threadId: thread.id,
525
+ selectBy: { pagination: { page: 0, perPage: 3 } },
526
+ format: 'v2',
527
+ });
528
+
529
+ // Check that messages are in chronological order
530
+ for (let i = 1; i < page1.messages.length; i++) {
531
+ const prevMessage = page1.messages[i - 1] as MastraMessageV2;
532
+ const currentMessage = page1.messages[i] as MastraMessageV2;
533
+ expect(new Date(prevMessage.createdAt).getTime()).toBeLessThanOrEqual(
534
+ new Date(currentMessage.createdAt).getTime(),
535
+ );
536
+ }
537
+ });
538
+
539
+ it('should support date filtering with pagination', async () => {
540
+ const thread = createSampleThread();
541
+ await store.saveThread({ thread });
542
+
543
+ const now = new Date();
544
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
545
+ const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
546
+
547
+ const oldMessages = Array.from({ length: 3 }, (_, i) => {
548
+ const message = createSampleMessageV2({ threadId: thread.id, content: `Old Message ${i + 1}` });
549
+ message.createdAt = yesterday;
550
+ return message;
551
+ });
552
+
553
+ const newMessages = Array.from({ length: 4 }, (_, i) => {
554
+ const message = createSampleMessageV2({ threadId: thread.id, content: `New Message ${i + 1}` });
555
+ message.createdAt = tomorrow;
556
+ return message;
557
+ });
558
+
559
+ await store.saveMessages({ messages: [...oldMessages, ...newMessages], format: 'v2' });
560
+
561
+ const recentMessages = await store.getMessagesPaginated({
562
+ threadId: thread.id,
563
+ selectBy: {
564
+ pagination: {
565
+ page: 0,
566
+ perPage: 10,
567
+ dateRange: { start: now },
568
+ },
569
+ },
570
+ format: 'v2',
571
+ });
572
+ expect(recentMessages.messages).toHaveLength(4);
573
+ expect(recentMessages.total).toBe(4);
574
+ });
575
+ });
345
576
  });
346
577
 
347
578
  describe('Trace Operations', () => {
@@ -448,22 +679,27 @@ describe('UpstashStore', () => {
448
679
  const mockSnapshot = {
449
680
  value: { step1: 'completed' },
450
681
  context: {
451
- stepResults: {
452
- step1: { status: 'success', payload: { result: 'done' } },
682
+ step1: {
683
+ status: 'success',
684
+ output: { result: 'done' },
685
+ payload: {},
686
+ startedAt: new Date().getTime(),
687
+ endedAt: new Date(Date.now() + 15000).getTime(),
453
688
  },
454
- attempts: {},
455
- triggerData: {},
456
- },
689
+ } as WorkflowRunState['context'],
690
+ serializedStepGraph: [],
457
691
  runId: testRunId,
458
692
  activePaths: [],
693
+ suspendedPaths: {},
459
694
  timestamp: Date.now(),
460
- } as unknown as WorkflowRunState;
695
+ status: 'success',
696
+ };
461
697
 
462
698
  await store.persistWorkflowSnapshot({
463
699
  namespace: testNamespace,
464
700
  workflowName: testWorkflow,
465
701
  runId: testRunId,
466
- snapshot: mockSnapshot,
702
+ snapshot: mockSnapshot as WorkflowRunState,
467
703
  });
468
704
 
469
705
  const loadedSnapshot = await store.loadWorkflowSnapshot({
@@ -556,8 +792,8 @@ describe('UpstashStore', () => {
556
792
  const workflowName1 = 'default_test_1';
557
793
  const workflowName2 = 'default_test_2';
558
794
 
559
- const { snapshot: workflow1, runId: runId1, stepId: stepId1 } = createSampleWorkflowSnapshot('completed');
560
- const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('running');
795
+ const { snapshot: workflow1, runId: runId1, stepId: stepId1 } = createSampleWorkflowSnapshot('success');
796
+ const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('waiting');
561
797
 
562
798
  await store.persistWorkflowSnapshot({
563
799
  namespace: testNamespace,
@@ -578,17 +814,17 @@ describe('UpstashStore', () => {
578
814
  expect(total).toBe(2);
579
815
  expect(runs[0]!.workflowName).toBe(workflowName2); // Most recent first
580
816
  expect(runs[1]!.workflowName).toBe(workflowName1);
581
- const firstSnapshot = runs[0]!.snapshot as WorkflowRunState;
582
- const secondSnapshot = runs[1]!.snapshot as WorkflowRunState;
583
- expect(firstSnapshot.context?.steps[stepId2]?.status).toBe('running');
584
- expect(secondSnapshot.context?.steps[stepId1]?.status).toBe('completed');
817
+ const firstSnapshot = runs[0]!.snapshot;
818
+ const secondSnapshot = runs[1]!.snapshot;
819
+ checkWorkflowSnapshot(firstSnapshot, stepId2, 'waiting');
820
+ checkWorkflowSnapshot(secondSnapshot, stepId1, 'success');
585
821
  });
586
822
 
587
823
  it('filters by workflow name', async () => {
588
824
  const workflowName1 = 'filter_test_1';
589
825
  const workflowName2 = 'filter_test_2';
590
826
 
591
- const { snapshot: workflow1, runId: runId1, stepId: stepId1 } = createSampleWorkflowSnapshot('completed');
827
+ const { snapshot: workflow1, runId: runId1, stepId: stepId1 } = createSampleWorkflowSnapshot('success');
592
828
  const { snapshot: workflow2, runId: runId2 } = createSampleWorkflowSnapshot('failed');
593
829
 
594
830
  await store.persistWorkflowSnapshot({
@@ -609,8 +845,8 @@ describe('UpstashStore', () => {
609
845
  expect(runs).toHaveLength(1);
610
846
  expect(total).toBe(1);
611
847
  expect(runs[0]!.workflowName).toBe(workflowName1);
612
- const snapshot = runs[0]!.snapshot as WorkflowRunState;
613
- expect(snapshot.context?.steps[stepId1]?.status).toBe('completed');
848
+ const snapshot = runs[0]!.snapshot;
849
+ checkWorkflowSnapshot(snapshot, stepId1, 'success');
614
850
  });
615
851
 
616
852
  it('filters by date range', async () => {
@@ -621,9 +857,9 @@ describe('UpstashStore', () => {
621
857
  const workflowName2 = 'date_test_2';
622
858
  const workflowName3 = 'date_test_3';
623
859
 
624
- const { snapshot: workflow1, runId: runId1 } = createSampleWorkflowSnapshot('completed');
625
- const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('running');
626
- const { snapshot: workflow3, runId: runId3, stepId: stepId3 } = createSampleWorkflowSnapshot('waiting');
860
+ const { snapshot: workflow1, runId: runId1 } = createSampleWorkflowSnapshot('success');
861
+ const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('waiting');
862
+ const { snapshot: workflow3, runId: runId3, stepId: stepId3 } = createSampleWorkflowSnapshot('skipped');
627
863
 
628
864
  await store.insert({
629
865
  tableName: TABLE_WORKFLOW_SNAPSHOT,
@@ -668,10 +904,10 @@ describe('UpstashStore', () => {
668
904
  expect(runs).toHaveLength(2);
669
905
  expect(runs[0]!.workflowName).toBe(workflowName3);
670
906
  expect(runs[1]!.workflowName).toBe(workflowName2);
671
- const firstSnapshot = runs[0]!.snapshot as WorkflowRunState;
672
- const secondSnapshot = runs[1]!.snapshot as WorkflowRunState;
673
- expect(firstSnapshot.context?.steps[stepId3]?.status).toBe('waiting');
674
- expect(secondSnapshot.context?.steps[stepId2]?.status).toBe('running');
907
+ const firstSnapshot = runs[0]!.snapshot;
908
+ const secondSnapshot = runs[1]!.snapshot;
909
+ checkWorkflowSnapshot(firstSnapshot, stepId3, 'skipped');
910
+ checkWorkflowSnapshot(secondSnapshot, stepId2, 'waiting');
675
911
  });
676
912
 
677
913
  it('handles pagination', async () => {
@@ -679,9 +915,9 @@ describe('UpstashStore', () => {
679
915
  const workflowName2 = 'page_test_2';
680
916
  const workflowName3 = 'page_test_3';
681
917
 
682
- const { snapshot: workflow1, runId: runId1, stepId: stepId1 } = createSampleWorkflowSnapshot('completed');
683
- const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('running');
684
- const { snapshot: workflow3, runId: runId3, stepId: stepId3 } = createSampleWorkflowSnapshot('waiting');
918
+ const { snapshot: workflow1, runId: runId1, stepId: stepId1 } = createSampleWorkflowSnapshot('success');
919
+ const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('waiting');
920
+ const { snapshot: workflow3, runId: runId3, stepId: stepId3 } = createSampleWorkflowSnapshot('skipped');
685
921
 
686
922
  await store.persistWorkflowSnapshot({
687
923
  namespace: testNamespace,
@@ -714,10 +950,10 @@ describe('UpstashStore', () => {
714
950
  expect(page1.total).toBe(3); // Total count of all records
715
951
  expect(page1.runs[0]!.workflowName).toBe(workflowName3);
716
952
  expect(page1.runs[1]!.workflowName).toBe(workflowName2);
717
- const firstSnapshot = page1.runs[0]!.snapshot as WorkflowRunState;
718
- const secondSnapshot = page1.runs[1]!.snapshot as WorkflowRunState;
719
- expect(firstSnapshot.context?.steps[stepId3]?.status).toBe('waiting');
720
- expect(secondSnapshot.context?.steps[stepId2]?.status).toBe('running');
953
+ const firstSnapshot = page1.runs[0]!.snapshot;
954
+ const secondSnapshot = page1.runs[1]!.snapshot;
955
+ checkWorkflowSnapshot(firstSnapshot, stepId3, 'skipped');
956
+ checkWorkflowSnapshot(secondSnapshot, stepId2, 'waiting');
721
957
 
722
958
  // Get second page
723
959
  const page2 = await store.getWorkflowRuns({
@@ -728,8 +964,391 @@ describe('UpstashStore', () => {
728
964
  expect(page2.runs).toHaveLength(1);
729
965
  expect(page2.total).toBe(3);
730
966
  expect(page2.runs[0]!.workflowName).toBe(workflowName1);
731
- const snapshot = page2.runs[0]!.snapshot as WorkflowRunState;
732
- expect(snapshot.context?.steps[stepId1]?.status).toBe('completed');
967
+ const snapshot = page2.runs[0]!.snapshot;
968
+ checkWorkflowSnapshot(snapshot, stepId1, 'success');
969
+ });
970
+ });
971
+ describe('getWorkflowRunById', () => {
972
+ const testNamespace = 'test-workflows-id';
973
+ const workflowName = 'workflow-id-test';
974
+ let runId: string;
975
+ let stepId: string;
976
+
977
+ beforeAll(async () => {
978
+ // Insert a workflow run for positive test
979
+ const sample = createSampleWorkflowSnapshot('success');
980
+ runId = sample.runId;
981
+ stepId = sample.stepId;
982
+ await store.insert({
983
+ tableName: TABLE_WORKFLOW_SNAPSHOT,
984
+ record: {
985
+ namespace: testNamespace,
986
+ workflow_name: workflowName,
987
+ run_id: runId,
988
+ resourceId: 'resource-abc',
989
+ snapshot: sample.snapshot,
990
+ createdAt: new Date(),
991
+ updatedAt: new Date(),
992
+ },
993
+ });
994
+ });
995
+
996
+ it('should retrieve a workflow run by ID', async () => {
997
+ const found = await store.getWorkflowRunById({
998
+ namespace: testNamespace,
999
+ runId,
1000
+ workflowName,
1001
+ });
1002
+ expect(found).not.toBeNull();
1003
+ expect(found?.runId).toBe(runId);
1004
+ const snapshot = found?.snapshot;
1005
+ checkWorkflowSnapshot(snapshot!, stepId, 'success');
1006
+ });
1007
+
1008
+ it('should return null for non-existent workflow run ID', async () => {
1009
+ const notFound = await store.getWorkflowRunById({
1010
+ namespace: testNamespace,
1011
+ runId: 'non-existent-id',
1012
+ workflowName,
1013
+ });
1014
+ expect(notFound).toBeNull();
1015
+ });
1016
+ });
1017
+ describe('getWorkflowRuns with resourceId', () => {
1018
+ const testNamespace = 'test-workflows-id';
1019
+ const workflowName = 'workflow-id-test';
1020
+ let resourceId: string;
1021
+ let runIds: string[] = [];
1022
+
1023
+ beforeAll(async () => {
1024
+ // Insert multiple workflow runs for the same resourceId
1025
+ resourceId = 'resource-shared';
1026
+ for (const status of ['success', 'waiting']) {
1027
+ const sample = createSampleWorkflowSnapshot(status);
1028
+ runIds.push(sample.runId);
1029
+ await store.insert({
1030
+ tableName: TABLE_WORKFLOW_SNAPSHOT,
1031
+ record: {
1032
+ namespace: testNamespace,
1033
+ workflow_name: workflowName,
1034
+ run_id: sample.runId,
1035
+ resourceId,
1036
+ snapshot: sample.snapshot,
1037
+ createdAt: new Date(),
1038
+ updatedAt: new Date(),
1039
+ },
1040
+ });
1041
+ }
1042
+ // Insert a run with a different resourceId
1043
+ const other = createSampleWorkflowSnapshot('waiting');
1044
+ await store.insert({
1045
+ tableName: TABLE_WORKFLOW_SNAPSHOT,
1046
+ record: {
1047
+ namespace: testNamespace,
1048
+ workflow_name: workflowName,
1049
+ run_id: other.runId,
1050
+ resourceId: 'resource-other',
1051
+ snapshot: other.snapshot,
1052
+ createdAt: new Date(),
1053
+ updatedAt: new Date(),
1054
+ },
1055
+ });
1056
+ });
1057
+
1058
+ it('should retrieve all workflow runs by resourceId', async () => {
1059
+ const { runs } = await store.getWorkflowRuns({
1060
+ namespace: testNamespace,
1061
+ resourceId,
1062
+ workflowName,
1063
+ });
1064
+ expect(Array.isArray(runs)).toBe(true);
1065
+ expect(runs.length).toBeGreaterThanOrEqual(2);
1066
+ for (const run of runs) {
1067
+ expect(run.resourceId).toBe(resourceId);
1068
+ }
1069
+ });
1070
+
1071
+ it('should return an empty array if no workflow runs match resourceId', async () => {
1072
+ const { runs } = await store.getWorkflowRuns({
1073
+ namespace: testNamespace,
1074
+ resourceId: 'non-existent-resource',
1075
+ workflowName,
1076
+ });
1077
+ expect(Array.isArray(runs)).toBe(true);
1078
+ expect(runs.length).toBe(0);
1079
+ });
1080
+ });
1081
+
1082
+ describe('alterTable (no-op/schemaless)', () => {
1083
+ const TEST_TABLE = 'test_alter_table'; // Use "table" or "collection" as appropriate
1084
+ beforeEach(async () => {
1085
+ await store.clearTable({ tableName: TEST_TABLE as TABLE_NAMES });
1086
+ });
1087
+
1088
+ afterEach(async () => {
1089
+ await store.clearTable({ tableName: TEST_TABLE as TABLE_NAMES });
1090
+ });
1091
+
1092
+ it('allows inserting records with new fields without alterTable', async () => {
1093
+ await store.insert({
1094
+ tableName: TEST_TABLE as TABLE_NAMES,
1095
+ record: { id: '1', name: 'Alice' },
1096
+ });
1097
+ await store.insert({
1098
+ tableName: TEST_TABLE as TABLE_NAMES,
1099
+ record: { id: '2', name: 'Bob', newField: 123 },
1100
+ });
1101
+
1102
+ const row = await store.load<{ id: string; name: string; newField?: number }>({
1103
+ tableName: TEST_TABLE as TABLE_NAMES,
1104
+ keys: { id: '2' },
1105
+ });
1106
+ expect(row?.newField).toBe(123);
1107
+ });
1108
+
1109
+ it('does not throw when calling alterTable (no-op)', async () => {
1110
+ await expect(
1111
+ store.alterTable({
1112
+ tableName: TEST_TABLE as TABLE_NAMES,
1113
+ schema: {
1114
+ id: { type: 'text', primaryKey: true, nullable: false },
1115
+ name: { type: 'text', nullable: true },
1116
+ extra: { type: 'integer', nullable: true },
1117
+ },
1118
+ ifNotExists: [],
1119
+ }),
1120
+ ).resolves.not.toThrow();
1121
+ });
1122
+
1123
+ it('can add multiple new fields at write time', async () => {
1124
+ await store.insert({
1125
+ tableName: TEST_TABLE as TABLE_NAMES,
1126
+ record: { id: '3', name: 'Charlie', age: 30, city: 'Paris' },
1127
+ });
1128
+ const row = await store.load<{ id: string; name: string; age?: number; city?: string }>({
1129
+ tableName: TEST_TABLE as TABLE_NAMES,
1130
+ keys: { id: '3' },
1131
+ });
1132
+ expect(row?.age).toBe(30);
1133
+ expect(row?.city).toBe('Paris');
1134
+ });
1135
+
1136
+ it('can retrieve all fields, including dynamically added ones', async () => {
1137
+ await store.insert({
1138
+ tableName: TEST_TABLE as TABLE_NAMES,
1139
+ record: { id: '4', name: 'Dana', hobby: 'skiing' },
1140
+ });
1141
+ const row = await store.load<{ id: string; name: string; hobby?: string }>({
1142
+ tableName: TEST_TABLE as TABLE_NAMES,
1143
+ keys: { id: '4' },
1144
+ });
1145
+ expect(row?.hobby).toBe('skiing');
1146
+ });
1147
+
1148
+ it('does not restrict or error on arbitrary new fields', async () => {
1149
+ await expect(
1150
+ store.insert({
1151
+ tableName: TEST_TABLE as TABLE_NAMES,
1152
+ record: { id: '5', weirdField: { nested: true }, another: [1, 2, 3] },
1153
+ }),
1154
+ ).resolves.not.toThrow();
1155
+
1156
+ const row = await store.load<{ id: string; weirdField?: any; another?: any }>({
1157
+ tableName: TEST_TABLE as TABLE_NAMES,
1158
+ keys: { id: '5' },
1159
+ });
1160
+ expect(row?.weirdField).toEqual({ nested: true });
1161
+ expect(row?.another).toEqual([1, 2, 3]);
1162
+ });
1163
+ });
1164
+
1165
+ describe('Pagination Features', () => {
1166
+ beforeEach(async () => {
1167
+ // Clear all test data
1168
+ await store.clearTable({ tableName: TABLE_THREADS });
1169
+ await store.clearTable({ tableName: TABLE_MESSAGES });
1170
+ await store.clearTable({ tableName: TABLE_EVALS });
1171
+ await store.clearTable({ tableName: TABLE_TRACES });
1172
+ });
1173
+
1174
+ describe('getEvals with pagination', () => {
1175
+ it('should return paginated evals with total count', async () => {
1176
+ const agentName = 'test-agent';
1177
+ const evals = Array.from({ length: 25 }, (_, i) => createSampleEval(agentName, i % 2 === 0));
1178
+
1179
+ // Insert all evals
1180
+ for (const evalRecord of evals) {
1181
+ await store.insert({
1182
+ tableName: TABLE_EVALS,
1183
+ record: evalRecord,
1184
+ });
1185
+ }
1186
+
1187
+ // Test page-based pagination
1188
+ const page1 = await store.getEvals({ agentName, page: 0, perPage: 10 });
1189
+ expect(page1.evals).toHaveLength(10);
1190
+ expect(page1.total).toBe(25);
1191
+ expect(page1.page).toBe(0);
1192
+ expect(page1.perPage).toBe(10);
1193
+ expect(page1.hasMore).toBe(true);
1194
+
1195
+ const page2 = await store.getEvals({ agentName, page: 1, perPage: 10 });
1196
+ expect(page2.evals).toHaveLength(10);
1197
+ expect(page2.total).toBe(25);
1198
+ expect(page2.hasMore).toBe(true);
1199
+
1200
+ const page3 = await store.getEvals({ agentName, page: 2, perPage: 10 });
1201
+ expect(page3.evals).toHaveLength(5);
1202
+ expect(page3.total).toBe(25);
1203
+ expect(page3.hasMore).toBe(false);
1204
+ });
1205
+
1206
+ it('should support page/perPage pagination', async () => {
1207
+ const agentName = 'test-agent-2';
1208
+ const evals = Array.from({ length: 15 }, () => createSampleEval(agentName));
1209
+
1210
+ for (const evalRecord of evals) {
1211
+ await store.insert({
1212
+ tableName: TABLE_EVALS,
1213
+ record: evalRecord,
1214
+ });
1215
+ }
1216
+
1217
+ // Test offset-based pagination
1218
+ const result1 = await store.getEvals({ agentName, page: 0, perPage: 5 });
1219
+ expect(result1.evals).toHaveLength(5);
1220
+ expect(result1.total).toBe(15);
1221
+ expect(result1.hasMore).toBe(true);
1222
+
1223
+ const result2 = await store.getEvals({ agentName, page: 2, perPage: 5 });
1224
+ expect(result2.evals).toHaveLength(5);
1225
+ expect(result2.total).toBe(15);
1226
+ expect(result2.hasMore).toBe(false);
1227
+ });
1228
+
1229
+ it('should filter by type with pagination', async () => {
1230
+ const agentName = 'test-agent-3';
1231
+ const testEvals = Array.from({ length: 10 }, () => createSampleEval(agentName, true));
1232
+ const liveEvals = Array.from({ length: 8 }, () => createSampleEval(agentName, false));
1233
+
1234
+ for (const evalRecord of [...testEvals, ...liveEvals]) {
1235
+ await store.insert({
1236
+ tableName: TABLE_EVALS,
1237
+ record: evalRecord,
1238
+ });
1239
+ }
1240
+
1241
+ const testResults = await store.getEvals({ agentName, type: 'test', page: 0, perPage: 5 });
1242
+ expect(testResults.evals).toHaveLength(5);
1243
+ expect(testResults.total).toBe(10);
1244
+
1245
+ const liveResults = await store.getEvals({ agentName, type: 'live', page: 0, perPage: 5 });
1246
+ expect(liveResults.evals).toHaveLength(5);
1247
+ expect(liveResults.total).toBe(8);
1248
+ });
1249
+
1250
+ it('should filter by date with pagination', async () => {
1251
+ const agentName = 'test-agent-date';
1252
+ const now = new Date();
1253
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1254
+ const evals = [createSampleEval(agentName, false, now), createSampleEval(agentName, false, yesterday)];
1255
+ for (const evalRecord of evals) {
1256
+ await store.insert({
1257
+ tableName: TABLE_EVALS,
1258
+ record: evalRecord,
1259
+ });
1260
+ }
1261
+ const result = await store.getEvals({
1262
+ agentName,
1263
+ page: 0,
1264
+ perPage: 10,
1265
+ dateRange: { start: now },
1266
+ });
1267
+ expect(result.evals).toHaveLength(1);
1268
+ expect(result.total).toBe(1);
1269
+ });
1270
+ });
1271
+
1272
+ describe('getTraces with pagination', () => {
1273
+ it('should return paginated traces with total count', async () => {
1274
+ const traces = Array.from({ length: 18 }, (_, i) => createSampleTrace(`test-trace-${i}`, 'test-scope'));
1275
+
1276
+ for (const trace of traces) {
1277
+ await store.insert({
1278
+ tableName: TABLE_TRACES,
1279
+ record: trace,
1280
+ });
1281
+ }
1282
+
1283
+ const page1 = await store.getTracesPaginated({
1284
+ scope: 'test-scope',
1285
+ page: 0,
1286
+ perPage: 8,
1287
+ });
1288
+ expect(page1.traces).toHaveLength(8);
1289
+ expect(page1.total).toBe(18);
1290
+ expect(page1.page).toBe(0);
1291
+ expect(page1.perPage).toBe(8);
1292
+ expect(page1.hasMore).toBe(true);
1293
+
1294
+ const page3 = await store.getTracesPaginated({
1295
+ scope: 'test-scope',
1296
+ page: 2,
1297
+ perPage: 8,
1298
+ });
1299
+ expect(page3.traces).toHaveLength(2);
1300
+ expect(page3.total).toBe(18);
1301
+ expect(page3.hasMore).toBe(false);
1302
+ });
1303
+
1304
+ it('should filter by date with pagination', async () => {
1305
+ const scope = 'test-scope-date';
1306
+ const now = new Date();
1307
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1308
+
1309
+ const traces = [
1310
+ createSampleTrace(`test-trace-now`, scope, undefined, now),
1311
+ createSampleTrace(`test-trace-yesterday`, scope, undefined, yesterday),
1312
+ ];
1313
+
1314
+ for (const trace of traces) {
1315
+ await store.insert({
1316
+ tableName: TABLE_TRACES,
1317
+ record: trace,
1318
+ });
1319
+ }
1320
+
1321
+ const result = await store.getTracesPaginated({
1322
+ scope,
1323
+ page: 0,
1324
+ perPage: 10,
1325
+ dateRange: { start: now },
1326
+ });
1327
+
1328
+ expect(result.traces).toHaveLength(1);
1329
+ expect(result.traces[0].name).toBe('test-trace-now');
1330
+ expect(result.total).toBe(1);
1331
+ });
1332
+ });
1333
+
1334
+ describe('Enhanced existing methods with pagination', () => {
1335
+ it('should support pagination in getThreadsByResourceId', async () => {
1336
+ const resourceId = 'enhanced-resource';
1337
+ const threads = Array.from({ length: 17 }, () => createSampleThread({ resourceId }));
1338
+
1339
+ for (const thread of threads) {
1340
+ await store.saveThread({ thread });
1341
+ }
1342
+
1343
+ const page1 = await store.getThreadsByResourceIdPaginated({ resourceId, page: 0, perPage: 7 });
1344
+ expect(page1.threads).toHaveLength(7);
1345
+
1346
+ const page3 = await store.getThreadsByResourceIdPaginated({ resourceId, page: 2, perPage: 7 });
1347
+ expect(page3.threads).toHaveLength(3);
1348
+
1349
+ const limited = await store.getThreadsByResourceIdPaginated({ resourceId, page: 1, perPage: 5 });
1350
+ expect(limited.threads).toHaveLength(5);
1351
+ });
733
1352
  });
734
1353
  });
735
1354
  });