@mastra/upstash 0.10.2 → 0.10.3-alpha.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.
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'crypto';
2
+ import { createSampleMessageV2, createSampleThread, createSampleWorkflowSnapshot } from '@internal/storage-test-utils';
2
3
  import type { MastraMessageV2 } from '@mastra/core';
3
4
  import type { TABLE_NAMES } from '@mastra/core/storage';
4
5
  import {
@@ -9,56 +10,13 @@ import {
9
10
  TABLE_TRACES,
10
11
  } from '@mastra/core/storage';
11
12
  import type { WorkflowRunState } from '@mastra/core/workflows';
12
- import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
13
+ import { describe, it, expect, beforeAll, beforeEach, afterAll, vi, afterEach } from 'vitest';
13
14
 
14
15
  import { UpstashStore } from './index';
15
16
 
16
17
  // Increase timeout for all tests in this file to 30 seconds
17
18
  vi.setConfig({ testTimeout: 200_000, hookTimeout: 200_000 });
18
19
 
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'): MastraMessageV2 => ({
29
- id: `msg-${randomUUID()}`,
30
- role: 'user',
31
- threadId,
32
- content: { format: 2, parts: [{ type: 'text', text: content }] },
33
- createdAt: new Date(),
34
- resourceId: `resource-${randomUUID()}`,
35
- });
36
-
37
- const createSampleWorkflowSnapshot = (status: string, createdAt?: Date) => {
38
- const runId = `run-${randomUUID()}`;
39
- const stepId = `step-${randomUUID()}`;
40
- const timestamp = createdAt || new Date();
41
- const snapshot: WorkflowRunState = {
42
- value: {},
43
- context: {
44
- [stepId]: {
45
- status: status,
46
- payload: {},
47
- error: undefined,
48
- startedAt: timestamp.getTime(),
49
- endedAt: new Date(timestamp.getTime() + 15000).getTime(),
50
- },
51
- input: {},
52
- } as WorkflowRunState['context'],
53
- serializedStepGraph: [],
54
- activePaths: [],
55
- suspendedPaths: {},
56
- runId,
57
- timestamp: timestamp.getTime(),
58
- };
59
- return { snapshot, runId, stepId };
60
- };
61
-
62
20
  const createSampleTrace = (name: string, scope?: string, attributes?: Record<string, string>) => ({
63
21
  id: `trace-${randomUUID()}`,
64
22
  parentSpanId: `span-${randomUUID()}`,
@@ -176,7 +134,7 @@ describe('UpstashStore', () => {
176
134
 
177
135
  it('should create and retrieve a thread', async () => {
178
136
  const now = new Date();
179
- const thread = createSampleThread(now);
137
+ const thread = createSampleThread({ date: now });
180
138
 
181
139
  const savedThread = await store.saveThread({ thread });
182
140
  expect(savedThread).toEqual(thread);
@@ -236,6 +194,23 @@ describe('UpstashStore', () => {
236
194
  const retrievedThreads = await store.getThreadsByResourceId({ resourceId });
237
195
  expect(retrievedThreads).toHaveLength(total);
238
196
  });
197
+ it('should delete thread and its messages', async () => {
198
+ const thread = createSampleThread();
199
+ await store.saveThread({ thread });
200
+
201
+ // Add some messages
202
+ const messages = [createSampleMessageV2({ threadId: thread.id }), createSampleMessageV2({ threadId: thread.id })];
203
+ await store.saveMessages({ messages, format: 'v2' });
204
+
205
+ await store.deleteThread({ threadId: thread.id });
206
+
207
+ const retrievedThread = await store.getThreadById({ threadId: thread.id });
208
+ expect(retrievedThread).toBeNull();
209
+
210
+ // Verify messages were also deleted
211
+ const retrievedMessages = await store.getMessages({ threadId: thread.id });
212
+ expect(retrievedMessages).toHaveLength(0);
213
+ });
239
214
  });
240
215
 
241
216
  describe('Date Handling', () => {
@@ -245,7 +220,7 @@ describe('UpstashStore', () => {
245
220
 
246
221
  it('should handle Date objects in thread operations', async () => {
247
222
  const now = new Date();
248
- const thread = createSampleThread(now);
223
+ const thread = createSampleThread({ date: now });
249
224
 
250
225
  await store.saveThread({ thread });
251
226
  const retrievedThread = await store.getThreadById({ threadId: thread.id });
@@ -257,7 +232,7 @@ describe('UpstashStore', () => {
257
232
 
258
233
  it('should handle ISO string dates in thread operations', async () => {
259
234
  const now = new Date();
260
- const thread = createSampleThread(now);
235
+ const thread = createSampleThread({ date: now });
261
236
 
262
237
  await store.saveThread({ thread });
263
238
  const retrievedThread = await store.getThreadById({ threadId: thread.id });
@@ -269,7 +244,7 @@ describe('UpstashStore', () => {
269
244
 
270
245
  it('should handle mixed date formats in thread operations', async () => {
271
246
  const now = new Date();
272
- const thread = createSampleThread(now);
247
+ const thread = createSampleThread({ date: now });
273
248
 
274
249
  await store.saveThread({ thread });
275
250
  const retrievedThread = await store.getThreadById({ threadId: thread.id });
@@ -281,8 +256,8 @@ describe('UpstashStore', () => {
281
256
 
282
257
  it('should handle date serialization in getThreadsByResourceId', async () => {
283
258
  const now = new Date();
284
- const thread1 = createSampleThread(now);
285
- const thread2 = { ...createSampleThread(now), resourceId: thread1.resourceId };
259
+ const thread1 = createSampleThread({ date: now });
260
+ const thread2 = { ...createSampleThread({ date: now }), resourceId: thread1.resourceId };
286
261
  const threads = [thread1, thread2];
287
262
 
288
263
  await Promise.all(threads.map(thread => store.saveThread({ thread })));
@@ -320,9 +295,9 @@ describe('UpstashStore', () => {
320
295
 
321
296
  it('should save and retrieve messages in order', async () => {
322
297
  const messages: MastraMessageV2[] = [
323
- createSampleMessage(threadId, 'First'),
324
- createSampleMessage(threadId, 'Second'),
325
- createSampleMessage(threadId, 'Third'),
298
+ createSampleMessageV2({ threadId, content: 'First' }),
299
+ createSampleMessageV2({ threadId, content: 'Second' }),
300
+ createSampleMessageV2({ threadId, content: 'Third' }),
326
301
  ];
327
302
 
328
303
  await store.saveMessages({ messages: messages, format: 'v2' });
@@ -360,6 +335,140 @@ describe('UpstashStore', () => {
360
335
  const retrievedMessages = await store.getMessages({ threadId, format: 'v2' });
361
336
  expect(retrievedMessages[0].content).toEqual(messages[0].content);
362
337
  });
338
+
339
+ describe('getPaginatedMessages', () => {
340
+ it('should return paginated messages with total count', async () => {
341
+ const thread = createSampleThread();
342
+ await store.saveThread({ thread });
343
+
344
+ const messages = Array.from({ length: 15 }, (_, i) =>
345
+ createSampleMessageV2({ threadId: thread.id, content: `Message ${i + 1}` }),
346
+ );
347
+
348
+ await store.saveMessages({ messages, format: 'v2' });
349
+
350
+ const page1 = await store.getMessages({
351
+ threadId: thread.id,
352
+ page: 0,
353
+ perPage: 5,
354
+ format: 'v2',
355
+ });
356
+ expect(page1.messages).toHaveLength(5);
357
+ expect(page1.total).toBe(15);
358
+ expect(page1.page).toBe(0);
359
+ expect(page1.perPage).toBe(5);
360
+ expect(page1.hasMore).toBe(true);
361
+
362
+ const page3 = await store.getMessages({
363
+ threadId: thread.id,
364
+ page: 2,
365
+ perPage: 5,
366
+ format: 'v2',
367
+ });
368
+ expect(page3.messages).toHaveLength(5);
369
+ expect(page3.total).toBe(15);
370
+ expect(page3.hasMore).toBe(false);
371
+
372
+ const page4 = await store.getMessages({
373
+ threadId: thread.id,
374
+ page: 3,
375
+ perPage: 5,
376
+ format: 'v2',
377
+ });
378
+ expect(page4.messages).toHaveLength(0);
379
+ expect(page4.total).toBe(15);
380
+ expect(page4.hasMore).toBe(false);
381
+ });
382
+
383
+ it('should maintain chronological order in pagination', async () => {
384
+ const thread = createSampleThread();
385
+ await store.saveThread({ thread });
386
+
387
+ const messages = Array.from({ length: 10 }, (_, i) => {
388
+ const message = createSampleMessageV2({ threadId: thread.id, content: `Message ${i + 1}` });
389
+ // Ensure different timestamps
390
+ message.createdAt = new Date(Date.now() + i * 1000);
391
+ return message;
392
+ });
393
+
394
+ await store.saveMessages({ messages, format: 'v2' });
395
+
396
+ const page1 = await store.getMessages({
397
+ threadId: thread.id,
398
+ page: 0,
399
+ perPage: 3,
400
+ format: 'v2',
401
+ });
402
+
403
+ // Check that messages are in chronological order
404
+ for (let i = 1; i < page1.messages.length; i++) {
405
+ const prevMessage = page1.messages[i - 1] as MastraMessageV2;
406
+ const currentMessage = page1.messages[i] as MastraMessageV2;
407
+ expect(new Date(prevMessage.createdAt).getTime()).toBeLessThanOrEqual(
408
+ new Date(currentMessage.createdAt).getTime(),
409
+ );
410
+ }
411
+ });
412
+
413
+ it('should maintain backward compatibility when no pagination params provided', async () => {
414
+ const thread = createSampleThread();
415
+ await store.saveThread({ thread });
416
+
417
+ const messages = Array.from({ length: 5 }, (_, i) =>
418
+ createSampleMessageV2({ threadId: thread.id, content: `Message ${i + 1}` }),
419
+ );
420
+
421
+ await store.saveMessages({ messages, format: 'v2' });
422
+
423
+ // Test original format without pagination - should return array
424
+ const messagesV1 = await store.getMessages({
425
+ threadId: thread.id,
426
+ format: 'v1',
427
+ });
428
+ expect(Array.isArray(messagesV1)).toBe(true);
429
+ expect(messagesV1).toHaveLength(5);
430
+
431
+ const messagesV2 = await store.getMessages({
432
+ threadId: thread.id,
433
+ format: 'v2',
434
+ });
435
+ expect(Array.isArray(messagesV2)).toBe(true);
436
+ expect(messagesV2).toHaveLength(5);
437
+ });
438
+
439
+ it('should support date filtering with pagination', async () => {
440
+ const thread = createSampleThread();
441
+ await store.saveThread({ thread });
442
+
443
+ const now = new Date();
444
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
445
+ const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
446
+
447
+ const oldMessages = Array.from({ length: 3 }, (_, i) => {
448
+ const message = createSampleMessageV2({ threadId: thread.id, content: `Old Message ${i + 1}` });
449
+ message.createdAt = yesterday;
450
+ return message;
451
+ });
452
+
453
+ const newMessages = Array.from({ length: 4 }, (_, i) => {
454
+ const message = createSampleMessageV2({ threadId: thread.id, content: `New Message ${i + 1}` });
455
+ message.createdAt = tomorrow;
456
+ return message;
457
+ });
458
+
459
+ await store.saveMessages({ messages: [...oldMessages, ...newMessages], format: 'v2' });
460
+
461
+ const recentMessages = await store.getMessages({
462
+ threadId: thread.id,
463
+ page: 0,
464
+ perPage: 10,
465
+ fromDate: now,
466
+ format: 'v2',
467
+ });
468
+ expect(recentMessages.messages).toHaveLength(4);
469
+ expect(recentMessages.total).toBe(4);
470
+ });
471
+ });
363
472
  });
364
473
 
365
474
  describe('Trace Operations', () => {
@@ -864,4 +973,270 @@ describe('UpstashStore', () => {
864
973
  expect(runs.length).toBe(0);
865
974
  });
866
975
  });
976
+
977
+ describe('alterTable (no-op/schemaless)', () => {
978
+ const TEST_TABLE = 'test_alter_table'; // Use "table" or "collection" as appropriate
979
+ beforeEach(async () => {
980
+ await store.clearTable({ tableName: TEST_TABLE as TABLE_NAMES });
981
+ });
982
+
983
+ afterEach(async () => {
984
+ await store.clearTable({ tableName: TEST_TABLE as TABLE_NAMES });
985
+ });
986
+
987
+ it('allows inserting records with new fields without alterTable', async () => {
988
+ await store.insert({
989
+ tableName: TEST_TABLE as TABLE_NAMES,
990
+ record: { id: '1', name: 'Alice' },
991
+ });
992
+ await store.insert({
993
+ tableName: TEST_TABLE as TABLE_NAMES,
994
+ record: { id: '2', name: 'Bob', newField: 123 },
995
+ });
996
+
997
+ const row = await store.load<{ id: string; name: string; newField?: number }>({
998
+ tableName: TEST_TABLE as TABLE_NAMES,
999
+ keys: { id: '2' },
1000
+ });
1001
+ expect(row?.newField).toBe(123);
1002
+ });
1003
+
1004
+ it('does not throw when calling alterTable (no-op)', async () => {
1005
+ await expect(
1006
+ store.alterTable({
1007
+ tableName: TEST_TABLE as TABLE_NAMES,
1008
+ schema: {
1009
+ id: { type: 'text', primaryKey: true, nullable: false },
1010
+ name: { type: 'text', nullable: true },
1011
+ extra: { type: 'integer', nullable: true },
1012
+ },
1013
+ ifNotExists: [],
1014
+ }),
1015
+ ).resolves.not.toThrow();
1016
+ });
1017
+
1018
+ it('can add multiple new fields at write time', async () => {
1019
+ await store.insert({
1020
+ tableName: TEST_TABLE as TABLE_NAMES,
1021
+ record: { id: '3', name: 'Charlie', age: 30, city: 'Paris' },
1022
+ });
1023
+ const row = await store.load<{ id: string; name: string; age?: number; city?: string }>({
1024
+ tableName: TEST_TABLE as TABLE_NAMES,
1025
+ keys: { id: '3' },
1026
+ });
1027
+ expect(row?.age).toBe(30);
1028
+ expect(row?.city).toBe('Paris');
1029
+ });
1030
+
1031
+ it('can retrieve all fields, including dynamically added ones', async () => {
1032
+ await store.insert({
1033
+ tableName: TEST_TABLE as TABLE_NAMES,
1034
+ record: { id: '4', name: 'Dana', hobby: 'skiing' },
1035
+ });
1036
+ const row = await store.load<{ id: string; name: string; hobby?: string }>({
1037
+ tableName: TEST_TABLE as TABLE_NAMES,
1038
+ keys: { id: '4' },
1039
+ });
1040
+ expect(row?.hobby).toBe('skiing');
1041
+ });
1042
+
1043
+ it('does not restrict or error on arbitrary new fields', async () => {
1044
+ await expect(
1045
+ store.insert({
1046
+ tableName: TEST_TABLE as TABLE_NAMES,
1047
+ record: { id: '5', weirdField: { nested: true }, another: [1, 2, 3] },
1048
+ }),
1049
+ ).resolves.not.toThrow();
1050
+
1051
+ const row = await store.load<{ id: string; weirdField?: any; another?: any }>({
1052
+ tableName: TEST_TABLE as TABLE_NAMES,
1053
+ keys: { id: '5' },
1054
+ });
1055
+ expect(row?.weirdField).toEqual({ nested: true });
1056
+ expect(row?.another).toEqual([1, 2, 3]);
1057
+ });
1058
+ });
1059
+
1060
+ describe('Pagination Features', () => {
1061
+ beforeEach(async () => {
1062
+ // Clear all test data
1063
+ await store.clearTable({ tableName: TABLE_THREADS });
1064
+ await store.clearTable({ tableName: TABLE_MESSAGES });
1065
+ await store.clearTable({ tableName: TABLE_EVALS });
1066
+ await store.clearTable({ tableName: TABLE_TRACES });
1067
+ });
1068
+
1069
+ describe('getEvals with pagination', () => {
1070
+ it('should return paginated evals with total count', async () => {
1071
+ const agentName = 'test-agent';
1072
+ const evals = Array.from({ length: 25 }, (_, i) => createSampleEval(agentName, i % 2 === 0));
1073
+
1074
+ // Insert all evals
1075
+ for (const evalRecord of evals) {
1076
+ await store.insert({
1077
+ tableName: TABLE_EVALS,
1078
+ record: evalRecord,
1079
+ });
1080
+ }
1081
+
1082
+ // Test page-based pagination
1083
+ const page1 = await store.getEvals({ agentName, page: 0, perPage: 10 });
1084
+ expect(page1.evals).toHaveLength(10);
1085
+ expect(page1.total).toBe(25);
1086
+ expect(page1.page).toBe(0);
1087
+ expect(page1.perPage).toBe(10);
1088
+ expect(page1.hasMore).toBe(true);
1089
+
1090
+ const page2 = await store.getEvals({ agentName, page: 1, perPage: 10 });
1091
+ expect(page2.evals).toHaveLength(10);
1092
+ expect(page2.total).toBe(25);
1093
+ expect(page2.hasMore).toBe(true);
1094
+
1095
+ const page3 = await store.getEvals({ agentName, page: 2, perPage: 10 });
1096
+ expect(page3.evals).toHaveLength(5);
1097
+ expect(page3.total).toBe(25);
1098
+ expect(page3.hasMore).toBe(false);
1099
+ });
1100
+
1101
+ it('should support limit/offset pagination', async () => {
1102
+ const agentName = 'test-agent-2';
1103
+ const evals = Array.from({ length: 15 }, () => createSampleEval(agentName));
1104
+
1105
+ for (const evalRecord of evals) {
1106
+ await store.insert({
1107
+ tableName: TABLE_EVALS,
1108
+ record: evalRecord,
1109
+ });
1110
+ }
1111
+
1112
+ // Test offset-based pagination
1113
+ const result1 = await store.getEvals({ agentName, limit: 5, offset: 0 });
1114
+ expect(result1.evals).toHaveLength(5);
1115
+ expect(result1.total).toBe(15);
1116
+ expect(result1.hasMore).toBe(true);
1117
+
1118
+ const result2 = await store.getEvals({ agentName, limit: 5, offset: 10 });
1119
+ expect(result2.evals).toHaveLength(5);
1120
+ expect(result2.total).toBe(15);
1121
+ expect(result2.hasMore).toBe(false);
1122
+ });
1123
+
1124
+ it('should filter by type with pagination', async () => {
1125
+ const agentName = 'test-agent-3';
1126
+ const testEvals = Array.from({ length: 10 }, () => createSampleEval(agentName, true));
1127
+ const liveEvals = Array.from({ length: 8 }, () => createSampleEval(agentName, false));
1128
+
1129
+ for (const evalRecord of [...testEvals, ...liveEvals]) {
1130
+ await store.insert({
1131
+ tableName: TABLE_EVALS,
1132
+ record: evalRecord,
1133
+ });
1134
+ }
1135
+
1136
+ const testResults = await store.getEvals({ agentName, type: 'test', page: 0, perPage: 5 });
1137
+ expect(testResults.evals).toHaveLength(5);
1138
+ expect(testResults.total).toBe(10);
1139
+
1140
+ const liveResults = await store.getEvals({ agentName, type: 'live', page: 0, perPage: 5 });
1141
+ expect(liveResults.evals).toHaveLength(5);
1142
+ expect(liveResults.total).toBe(8);
1143
+ });
1144
+ });
1145
+
1146
+ describe('getTracesPaginated', () => {
1147
+ it('should return paginated traces with total count', async () => {
1148
+ const traces = Array.from({ length: 18 }, (_, i) => createSampleTrace(`test-trace-${i}`, 'test-scope'));
1149
+
1150
+ for (const trace of traces) {
1151
+ await store.insert({
1152
+ tableName: TABLE_TRACES,
1153
+ record: trace,
1154
+ });
1155
+ }
1156
+
1157
+ const page1 = await store.getTraces({
1158
+ scope: 'test-scope',
1159
+ page: 0,
1160
+ perPage: 8,
1161
+ returnPaginationResults: true,
1162
+ });
1163
+ expect(page1.traces).toHaveLength(8);
1164
+ expect(page1.total).toBe(18);
1165
+ expect(page1.page).toBe(0);
1166
+ expect(page1.perPage).toBe(8);
1167
+ expect(page1.hasMore).toBe(true);
1168
+
1169
+ const page3 = await store.getTraces({
1170
+ scope: 'test-scope',
1171
+ page: 2,
1172
+ perPage: 8,
1173
+ returnPaginationResults: true,
1174
+ });
1175
+ expect(page3.traces).toHaveLength(2);
1176
+ expect(page3.total).toBe(18);
1177
+ expect(page3.hasMore).toBe(false);
1178
+ });
1179
+
1180
+ it('should filter by attributes with pagination', async () => {
1181
+ const tracesWithAttr = Array.from({ length: 8 }, (_, i) =>
1182
+ createSampleTrace(`trace-${i}`, 'test-scope', { environment: 'prod' }),
1183
+ );
1184
+ const tracesWithoutAttr = Array.from({ length: 5 }, (_, i) =>
1185
+ createSampleTrace(`trace-other-${i}`, 'test-scope', { environment: 'dev' }),
1186
+ );
1187
+
1188
+ for (const trace of [...tracesWithAttr, ...tracesWithoutAttr]) {
1189
+ await store.insert({
1190
+ tableName: TABLE_TRACES,
1191
+ record: trace,
1192
+ });
1193
+ }
1194
+
1195
+ const prodTraces = await store.getTraces({
1196
+ scope: 'test-scope',
1197
+ attributes: { environment: 'prod' },
1198
+ page: 0,
1199
+ perPage: 5,
1200
+ returnPaginationResults: true,
1201
+ });
1202
+ expect(prodTraces.traces).toHaveLength(5);
1203
+ expect(prodTraces.total).toBe(8);
1204
+ expect(prodTraces.hasMore).toBe(true);
1205
+
1206
+ const devTraces = await store.getTraces({
1207
+ scope: 'test-scope',
1208
+ attributes: { environment: 'dev' },
1209
+ page: 0,
1210
+ perPage: 10,
1211
+ returnPaginationResults: true,
1212
+ });
1213
+ expect(devTraces.traces).toHaveLength(5);
1214
+ expect(devTraces.total).toBe(5);
1215
+ expect(devTraces.hasMore).toBe(false);
1216
+ });
1217
+ });
1218
+
1219
+ describe('Enhanced existing methods with pagination', () => {
1220
+ it('should support pagination in getThreadsByResourceId', async () => {
1221
+ const resourceId = 'enhanced-resource';
1222
+ const threads = Array.from({ length: 17 }, () => ({
1223
+ ...createSampleThread(),
1224
+ resourceId,
1225
+ }));
1226
+
1227
+ for (const thread of threads) {
1228
+ await store.saveThread({ thread });
1229
+ }
1230
+
1231
+ const page1 = await store.getThreadsByResourceId({ resourceId, page: 0, perPage: 7 });
1232
+ expect(page1.threads).toHaveLength(7);
1233
+
1234
+ const page3 = await store.getThreadsByResourceId({ resourceId, page: 2, perPage: 7 });
1235
+ expect(page3.threads).toHaveLength(3);
1236
+
1237
+ const limited = await store.getThreadsByResourceId({ resourceId, page: 1, perPage: 5 });
1238
+ expect(limited.threads).toHaveLength(5);
1239
+ });
1240
+ });
1241
+ });
867
1242
  });