@mastra/clickhouse 0.0.0-trigger-playground-ui-package-20250506151043 → 0.0.0-tsconfig-compile-20250703214351

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,10 +1,17 @@
1
1
  import { randomUUID } from 'crypto';
2
- import type { WorkflowRunState } from '@mastra/core';
3
- import type { MessageType } from '@mastra/core/memory';
2
+ import {
3
+ createSampleMessageV1,
4
+ createSampleThread,
5
+ createSampleWorkflowSnapshot,
6
+ checkWorkflowSnapshot,
7
+ createSampleMessageV2,
8
+ } from '@internal/storage-test-utils';
9
+ import type { MastraMessageV1, MastraMessageV2, StorageColumn, WorkflowRunState } from '@mastra/core';
10
+ import type { TABLE_NAMES } from '@mastra/core/storage';
4
11
  import { TABLE_THREADS, TABLE_MESSAGES, TABLE_WORKFLOW_SNAPSHOT } from '@mastra/core/storage';
5
12
  import { describe, it, expect, beforeAll, beforeEach, afterAll, vi, afterEach } from 'vitest';
6
13
 
7
- import { ClickhouseStore } from '.';
14
+ import { ClickhouseStore, TABLE_ENGINES } from '.';
8
15
  import type { ClickhouseConfig } from '.';
9
16
 
10
17
  vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
@@ -25,26 +32,6 @@ const TEST_CONFIG: ClickhouseConfig = {
25
32
  },
26
33
  };
27
34
 
28
- // Sample test data factory functions
29
- const createSampleThread = () => ({
30
- id: `thread-${randomUUID()}`,
31
- resourceId: `resource-${randomUUID()}`,
32
- title: 'Test Thread',
33
- createdAt: new Date(),
34
- updatedAt: new Date(),
35
- metadata: { key: 'value' },
36
- });
37
-
38
- const createSampleMessage = (threadId: string, createdAt: Date = new Date()): MessageType => ({
39
- id: `msg-${randomUUID()}`,
40
- resourceId: `resource-${randomUUID()}`,
41
- role: 'user',
42
- type: 'text',
43
- threadId,
44
- content: [{ type: 'text', text: 'Hello' }] as MessageType['content'],
45
- createdAt,
46
- });
47
-
48
35
  const createSampleTrace = () => ({
49
36
  id: `trace-${randomUUID()}`,
50
37
  name: 'Test Trace',
@@ -60,42 +47,6 @@ const createSampleEval = () => ({
60
47
  createdAt: new Date(),
61
48
  });
62
49
 
63
- const createSampleWorkflowSnapshot = (
64
- status: WorkflowRunState['context']['steps'][string]['status'],
65
- createdAt?: Date,
66
- ) => {
67
- const runId = `run-${randomUUID()}`;
68
- const stepId = `step-${randomUUID()}`;
69
- const timestamp = createdAt || new Date();
70
- const snapshot = {
71
- result: { success: true },
72
- value: {},
73
- context: {
74
- steps: {
75
- [stepId]: {
76
- status,
77
- payload: {},
78
- error: undefined,
79
- },
80
- },
81
- triggerData: {},
82
- attempts: {},
83
- },
84
- activePaths: [],
85
- suspendedPaths: {},
86
- runId,
87
- timestamp: timestamp.getTime(),
88
- };
89
- return { snapshot, runId, stepId };
90
- };
91
-
92
- const checkWorkflowSnapshot = (snapshot: WorkflowRunState | string, stepId: string, status: string) => {
93
- if (typeof snapshot === 'string') {
94
- throw new Error('Expected WorkflowRunState, got string');
95
- }
96
- expect(snapshot.context?.steps[stepId]?.status).toBe(status);
97
- };
98
-
99
50
  describe('ClickhouseStore', () => {
100
51
  let store: ClickhouseStore;
101
52
 
@@ -168,7 +119,10 @@ describe('ClickhouseStore', () => {
168
119
  await store.saveThread({ thread });
169
120
 
170
121
  // Add some messages
171
- const messages = [createSampleMessage(thread.id), createSampleMessage(thread.id)];
122
+ const messages = [
123
+ createSampleMessageV1({ threadId: thread.id, resourceId: 'clickhouse-test' }),
124
+ createSampleMessageV1({ threadId: thread.id, resourceId: 'clickhouse-test' }),
125
+ ];
172
126
  await store.saveMessages({ messages });
173
127
 
174
128
  await store.deleteThread({ threadId: thread.id });
@@ -180,6 +134,28 @@ describe('ClickhouseStore', () => {
180
134
  const retrievedMessages = await store.getMessages({ threadId: thread.id });
181
135
  expect(retrievedMessages).toHaveLength(0);
182
136
  }, 10e3);
137
+
138
+ it('should update thread updatedAt when a message is saved to it', async () => {
139
+ const thread = createSampleThread();
140
+ await store.saveThread({ thread });
141
+
142
+ // Get the initial thread to capture the original updatedAt
143
+ const initialThread = await store.getThreadById({ threadId: thread.id });
144
+ expect(initialThread).toBeDefined();
145
+ const originalUpdatedAt = initialThread!.updatedAt;
146
+
147
+ // Wait a small amount to ensure different timestamp
148
+ await new Promise(resolve => setTimeout(resolve, 10));
149
+
150
+ // Create and save a message to the thread
151
+ const message = createSampleMessageV1({ threadId: thread.id });
152
+ await store.saveMessages({ messages: [message] });
153
+
154
+ // Retrieve the thread again and check that updatedAt was updated
155
+ const updatedThread = await store.getThreadById({ threadId: thread.id });
156
+ expect(updatedThread).toBeDefined();
157
+ expect(updatedThread!.updatedAt.getTime()).toBeGreaterThan(originalUpdatedAt.getTime());
158
+ }, 10e3);
183
159
  });
184
160
 
185
161
  describe('Message Operations', () => {
@@ -188,8 +164,12 @@ describe('ClickhouseStore', () => {
188
164
  await store.saveThread({ thread });
189
165
 
190
166
  const messages = [
191
- createSampleMessage(thread.id, new Date(Date.now() - 1000 * 60 * 60 * 24)),
192
- createSampleMessage(thread.id),
167
+ createSampleMessageV1({
168
+ threadId: thread.id,
169
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24),
170
+ resourceId: 'clickhouse-test',
171
+ }),
172
+ createSampleMessageV1({ threadId: thread.id, resourceId: 'clickhouse-test' }),
193
173
  ];
194
174
 
195
175
  // Save messages
@@ -215,24 +195,39 @@ describe('ClickhouseStore', () => {
215
195
  const thread = createSampleThread();
216
196
  await store.saveThread({ thread });
217
197
 
218
- const messages: MessageType[] = [
198
+ const messages = [
219
199
  {
220
- ...createSampleMessage(thread.id, new Date(Date.now() - 1000 * 3)),
221
- content: [{ type: 'text', text: 'First' }],
200
+ ...createSampleMessageV1({
201
+ threadId: thread.id,
202
+ createdAt: new Date(Date.now() - 1000 * 3),
203
+ content: 'First',
204
+ resourceId: 'clickhouse-test',
205
+ }),
206
+ role: 'user',
222
207
  },
223
208
  {
224
- ...createSampleMessage(thread.id, new Date(Date.now() - 1000 * 2)),
225
- content: [{ type: 'text', text: 'Second' }],
209
+ ...createSampleMessageV1({
210
+ threadId: thread.id,
211
+ createdAt: new Date(Date.now() - 1000 * 2),
212
+ content: 'Second',
213
+ resourceId: 'clickhouse-test',
214
+ }),
215
+ role: 'assistant',
226
216
  },
227
217
  {
228
- ...createSampleMessage(thread.id, new Date(Date.now() - 1000 * 1)),
229
- content: [{ type: 'text', text: 'Third' }],
218
+ ...createSampleMessageV1({
219
+ threadId: thread.id,
220
+ createdAt: new Date(Date.now() - 1000 * 1),
221
+ content: 'Third',
222
+ resourceId: 'clickhouse-test',
223
+ }),
224
+ role: 'user',
230
225
  },
231
- ];
226
+ ] as MastraMessageV1[];
232
227
 
233
228
  await store.saveMessages({ messages });
234
229
 
235
- const retrievedMessages = await store.getMessages<MessageType>({ threadId: thread.id });
230
+ const retrievedMessages = await store.getMessages({ threadId: thread.id });
236
231
  expect(retrievedMessages).toHaveLength(3);
237
232
 
238
233
  // Verify order is maintained
@@ -242,13 +237,184 @@ describe('ClickhouseStore', () => {
242
237
  });
243
238
  }, 10e3);
244
239
 
240
+ it('should upsert messages: duplicate id+threadId results in update, not duplicate row', async () => {
241
+ const thread = await createSampleThread({ resourceId: 'clickhouse-test' });
242
+ await store.saveThread({ thread });
243
+ const baseMessage = createSampleMessageV2({
244
+ threadId: thread.id,
245
+ createdAt: new Date(),
246
+ content: { content: 'Original' },
247
+ resourceId: 'clickhouse-test',
248
+ });
249
+
250
+ // Insert the message for the first time
251
+ await store.saveMessages({ messages: [baseMessage], format: 'v2' });
252
+
253
+ // Insert again with the same id and threadId but different content
254
+ const updatedMessage = {
255
+ ...createSampleMessageV2({
256
+ threadId: thread.id,
257
+ createdAt: new Date(),
258
+ content: { content: 'Updated' },
259
+ resourceId: 'clickhouse-test',
260
+ }),
261
+ id: baseMessage.id,
262
+ };
263
+ await store.saveMessages({ messages: [updatedMessage], format: 'v2' });
264
+ await new Promise(resolve => setTimeout(resolve, 500));
265
+
266
+ // Retrieve messages for the thread
267
+ const retrievedMessages = await store.getMessages({ threadId: thread.id, format: 'v2' });
268
+
269
+ // Only one message should exist for that id+threadId
270
+ expect(retrievedMessages.filter(m => m.id === baseMessage.id)).toHaveLength(1);
271
+
272
+ // The content should be the updated one
273
+ expect(retrievedMessages.find(m => m.id === baseMessage.id)?.content.content).toBe('Updated');
274
+ }, 10e3);
275
+
276
+ it('should upsert messages: duplicate id and different threadid', async () => {
277
+ const thread1 = await createSampleThread();
278
+ const thread2 = await createSampleThread();
279
+ await store.saveThread({ thread: thread1 });
280
+ await store.saveThread({ thread: thread2 });
281
+
282
+ const message = createSampleMessageV2({
283
+ threadId: thread1.id,
284
+ createdAt: new Date(),
285
+ content: { content: 'Thread1 Content' },
286
+ resourceId: thread1.resourceId,
287
+ });
288
+
289
+ // Insert message into thread1
290
+ await store.saveMessages({ messages: [message], format: 'v2' });
291
+
292
+ // Attempt to insert a message with the same id but different threadId
293
+ const conflictingMessage = {
294
+ ...createSampleMessageV2({
295
+ threadId: thread2.id,
296
+ createdAt: new Date(),
297
+ content: { content: 'Thread2 Content' },
298
+ resourceId: thread2.resourceId,
299
+ }),
300
+ id: message.id,
301
+ };
302
+
303
+ // Save should also save the message to the new thread
304
+ await store.saveMessages({ messages: [conflictingMessage], format: 'v2' });
305
+
306
+ // Retrieve messages for both threads
307
+ const thread1Messages = await store.getMessages({ threadId: thread1.id, format: 'v2' });
308
+ const thread2Messages = await store.getMessages({ threadId: thread2.id, format: 'v2' });
309
+
310
+ // Thread 1 should have the message with that id
311
+ expect(thread1Messages.find(m => m.id === message.id)?.content.content).toBe('Thread1 Content');
312
+
313
+ // Thread 2 should have the message with that id
314
+ expect(thread2Messages.find(m => m.id === message.id)?.content.content).toBe('Thread2 Content');
315
+ }, 10e3);
316
+
317
+ // it('should retrieve messages w/ next/prev messages by message id + resource id', async () => {
318
+ // const messages: MastraMessageV2[] = [
319
+ // createSampleMessageV2({ threadId: 'thread-one', content: 'First', resourceId: 'cross-thread-resource' }),
320
+ // createSampleMessageV2({ threadId: 'thread-one', content: 'Second', resourceId: 'cross-thread-resource' }),
321
+ // createSampleMessageV2({ threadId: 'thread-one', content: 'Third', resourceId: 'cross-thread-resource' }),
322
+
323
+ // createSampleMessageV2({ threadId: 'thread-two', content: 'Fourth', resourceId: 'cross-thread-resource' }),
324
+ // createSampleMessageV2({ threadId: 'thread-two', content: 'Fifth', resourceId: 'cross-thread-resource' }),
325
+ // createSampleMessageV2({ threadId: 'thread-two', content: 'Sixth', resourceId: 'cross-thread-resource' }),
326
+
327
+ // createSampleMessageV2({ threadId: 'thread-three', content: 'Seventh', resourceId: 'other-resource' }),
328
+ // createSampleMessageV2({ threadId: 'thread-three', content: 'Eighth', resourceId: 'other-resource' }),
329
+ // ];
330
+
331
+ // await store.saveMessages({ messages: messages, format: 'v2' });
332
+
333
+ // const retrievedMessages = await store.getMessages({ threadId: 'thread-one', format: 'v2' });
334
+ // expect(retrievedMessages).toHaveLength(3);
335
+ // expect(retrievedMessages.map((m: any) => m.content.parts[0].text)).toEqual(['First', 'Second', 'Third']);
336
+
337
+ // const retrievedMessages2 = await store.getMessages({ threadId: 'thread-two', format: 'v2' });
338
+ // expect(retrievedMessages2).toHaveLength(3);
339
+ // expect(retrievedMessages2.map((m: any) => m.content.parts[0].text)).toEqual(['Fourth', 'Fifth', 'Sixth']);
340
+
341
+ // const retrievedMessages3 = await store.getMessages({ threadId: 'thread-three', format: 'v2' });
342
+ // expect(retrievedMessages3).toHaveLength(2);
343
+ // expect(retrievedMessages3.map((m: any) => m.content.parts[0].text)).toEqual(['Seventh', 'Eighth']);
344
+
345
+ // const crossThreadMessages = await store.getMessages({
346
+ // threadId: 'thread-doesnt-exist',
347
+ // resourceId: 'cross-thread-resource',
348
+ // format: 'v2',
349
+ // selectBy: {
350
+ // last: 0,
351
+ // include: [
352
+ // {
353
+ // id: messages[1].id,
354
+ // withNextMessages: 2,
355
+ // withPreviousMessages: 2,
356
+ // },
357
+ // {
358
+ // id: messages[4].id,
359
+ // withPreviousMessages: 2,
360
+ // withNextMessages: 2,
361
+ // },
362
+ // ],
363
+ // },
364
+ // });
365
+
366
+ // expect(crossThreadMessages).toHaveLength(6);
367
+ // expect(crossThreadMessages.filter(m => m.threadId === `thread-one`)).toHaveLength(3);
368
+ // expect(crossThreadMessages.filter(m => m.threadId === `thread-two`)).toHaveLength(3);
369
+
370
+ // const crossThreadMessages2 = await store.getMessages({
371
+ // threadId: 'thread-one',
372
+ // resourceId: 'cross-thread-resource',
373
+ // format: 'v2',
374
+ // selectBy: {
375
+ // last: 0,
376
+ // include: [
377
+ // {
378
+ // id: messages[4].id,
379
+ // withPreviousMessages: 1,
380
+ // withNextMessages: 30,
381
+ // },
382
+ // ],
383
+ // },
384
+ // });
385
+
386
+ // expect(crossThreadMessages2).toHaveLength(3);
387
+ // expect(crossThreadMessages2.filter(m => m.threadId === `thread-one`)).toHaveLength(0);
388
+ // expect(crossThreadMessages2.filter(m => m.threadId === `thread-two`)).toHaveLength(3);
389
+
390
+ // const crossThreadMessages3 = await store.getMessages({
391
+ // threadId: 'thread-two',
392
+ // resourceId: 'cross-thread-resource',
393
+ // format: 'v2',
394
+ // selectBy: {
395
+ // last: 0,
396
+ // include: [
397
+ // {
398
+ // id: messages[1].id,
399
+ // withNextMessages: 1,
400
+ // withPreviousMessages: 1,
401
+ // },
402
+ // ],
403
+ // },
404
+ // });
405
+
406
+ // expect(crossThreadMessages3).toHaveLength(3);
407
+ // expect(crossThreadMessages3.filter(m => m.threadId === `thread-one`)).toHaveLength(3);
408
+ // expect(crossThreadMessages3.filter(m => m.threadId === `thread-two`)).toHaveLength(0);
409
+ // });
410
+
245
411
  // it('should rollback on error during message save', async () => {
246
412
  // const thread = createSampleThread();
247
413
  // await store.saveThread({ thread });
248
414
 
249
415
  // const messages = [
250
- // createSampleMessage(thread.id),
251
- // { ...createSampleMessage(thread.id), id: null }, // This will cause an error
416
+ // createSampleMessageV1({ threadId: thread.id }),
417
+ // { ...createSampleMessageV1({ threadId: thread.id }), id: null }, // This will cause an error
252
418
  // ];
253
419
 
254
420
  // await expect(store.saveMessages({ messages })).rejects.toThrow();
@@ -371,17 +537,14 @@ describe('ClickhouseStore', () => {
371
537
  const snapshot = {
372
538
  status: 'running',
373
539
  context: {
374
- steps: {},
375
- stepResults: {},
376
- attempts: {},
377
- triggerData: { type: 'manual' },
540
+ input: { type: 'manual' },
378
541
  },
379
542
  value: {},
380
543
  activePaths: [],
381
544
  suspendedPaths: {},
382
545
  runId,
383
546
  timestamp: new Date().getTime(),
384
- };
547
+ } as unknown as WorkflowRunState;
385
548
 
386
549
  await store.persistWorkflowSnapshot({
387
550
  workflowName,
@@ -412,17 +575,14 @@ describe('ClickhouseStore', () => {
412
575
  const initialSnapshot = {
413
576
  status: 'running',
414
577
  context: {
415
- steps: {},
416
- stepResults: {},
417
- attempts: {},
418
- triggerData: { type: 'manual' },
578
+ input: { type: 'manual' },
419
579
  },
420
580
  value: {},
421
581
  activePaths: [],
422
582
  suspendedPaths: {},
423
583
  runId,
424
584
  timestamp: new Date().getTime(),
425
- };
585
+ } as unknown as WorkflowRunState;
426
586
 
427
587
  await store.persistWorkflowSnapshot({
428
588
  workflowName,
@@ -433,19 +593,15 @@ describe('ClickhouseStore', () => {
433
593
  const updatedSnapshot = {
434
594
  status: 'completed',
435
595
  context: {
436
- steps: {},
437
- stepResults: {
438
- 'step-1': { status: 'success', result: { data: 'test' } },
439
- },
440
- attempts: { 'step-1': 1 },
441
- triggerData: { type: 'manual' },
596
+ input: { type: 'manual' },
597
+ 'step-1': { status: 'success', result: { data: 'test' } },
442
598
  },
443
599
  value: {},
444
600
  activePaths: [],
445
601
  suspendedPaths: {},
446
602
  runId,
447
603
  timestamp: new Date().getTime(),
448
- };
604
+ } as unknown as WorkflowRunState;
449
605
 
450
606
  await store.persistWorkflowSnapshot({
451
607
  workflowName,
@@ -467,25 +623,21 @@ describe('ClickhouseStore', () => {
467
623
  const complexSnapshot = {
468
624
  value: { currentState: 'running' },
469
625
  context: {
470
- stepResults: {
471
- 'step-1': {
472
- status: 'success',
473
- result: {
474
- nestedData: {
475
- array: [1, 2, 3],
476
- object: { key: 'value' },
477
- date: new Date().toISOString(),
478
- },
626
+ 'step-1': {
627
+ status: 'success',
628
+ output: {
629
+ nestedData: {
630
+ array: [1, 2, 3],
631
+ object: { key: 'value' },
632
+ date: new Date().toISOString(),
479
633
  },
480
634
  },
481
- 'step-2': {
482
- status: 'waiting',
483
- dependencies: ['step-3', 'step-4'],
484
- },
485
635
  },
486
- steps: {},
487
- attempts: { 'step-1': 1, 'step-2': 0 },
488
- triggerData: {
636
+ 'step-2': {
637
+ status: 'waiting',
638
+ dependencies: ['step-3', 'step-4'],
639
+ },
640
+ input: {
489
641
  type: 'scheduled',
490
642
  metadata: {
491
643
  schedule: '0 0 * * *',
@@ -508,7 +660,7 @@ describe('ClickhouseStore', () => {
508
660
  suspendedPaths: {},
509
661
  runId: runId,
510
662
  timestamp: Date.now(),
511
- };
663
+ } as unknown as WorkflowRunState;
512
664
 
513
665
  await store.persistWorkflowSnapshot({
514
666
  workflowName,
@@ -540,7 +692,7 @@ describe('ClickhouseStore', () => {
540
692
  const workflowName2 = 'default_test_2';
541
693
 
542
694
  const { snapshot: workflow1, runId: runId1, stepId: stepId1 } = createSampleWorkflowSnapshot('success');
543
- const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('waiting');
695
+ const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('suspended');
544
696
 
545
697
  await store.persistWorkflowSnapshot({
546
698
  workflowName: workflowName1,
@@ -561,7 +713,7 @@ describe('ClickhouseStore', () => {
561
713
  expect(runs[1]!.workflowName).toBe(workflowName1);
562
714
  const firstSnapshot = runs[0]!.snapshot;
563
715
  const secondSnapshot = runs[1]!.snapshot;
564
- checkWorkflowSnapshot(firstSnapshot, stepId2, 'waiting');
716
+ checkWorkflowSnapshot(firstSnapshot, stepId2, 'suspended');
565
717
  checkWorkflowSnapshot(secondSnapshot, stepId1, 'success');
566
718
  });
567
719
 
@@ -603,8 +755,8 @@ describe('ClickhouseStore', () => {
603
755
  const workflowName3 = 'date_test_3';
604
756
 
605
757
  const { snapshot: workflow1, runId: runId1 } = createSampleWorkflowSnapshot('success');
606
- const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('waiting');
607
- const { snapshot: workflow3, runId: runId3, stepId: stepId3 } = createSampleWorkflowSnapshot('skipped');
758
+ const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('suspended');
759
+ const { snapshot: workflow3, runId: runId3, stepId: stepId3 } = createSampleWorkflowSnapshot('failed');
608
760
 
609
761
  await store.insert({
610
762
  tableName: TABLE_WORKFLOW_SNAPSHOT,
@@ -647,8 +799,8 @@ describe('ClickhouseStore', () => {
647
799
  expect(runs[1]!.workflowName).toBe(workflowName2);
648
800
  const firstSnapshot = runs[0]!.snapshot;
649
801
  const secondSnapshot = runs[1]!.snapshot;
650
- checkWorkflowSnapshot(firstSnapshot, stepId3, 'skipped');
651
- checkWorkflowSnapshot(secondSnapshot, stepId2, 'waiting');
802
+ checkWorkflowSnapshot(firstSnapshot, stepId3, 'failed');
803
+ checkWorkflowSnapshot(secondSnapshot, stepId2, 'suspended');
652
804
  });
653
805
 
654
806
  it('handles pagination', async () => {
@@ -657,8 +809,8 @@ describe('ClickhouseStore', () => {
657
809
  const workflowName3 = 'page_test_3';
658
810
 
659
811
  const { snapshot: workflow1, runId: runId1, stepId: stepId1 } = createSampleWorkflowSnapshot('success');
660
- const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('waiting');
661
- const { snapshot: workflow3, runId: runId3, stepId: stepId3 } = createSampleWorkflowSnapshot('skipped');
812
+ const { snapshot: workflow2, runId: runId2, stepId: stepId2 } = createSampleWorkflowSnapshot('suspended');
813
+ const { snapshot: workflow3, runId: runId3, stepId: stepId3 } = createSampleWorkflowSnapshot('failed');
662
814
 
663
815
  await store.persistWorkflowSnapshot({
664
816
  workflowName: workflowName1,
@@ -689,8 +841,8 @@ describe('ClickhouseStore', () => {
689
841
  expect(page1.runs[1]!.workflowName).toBe(workflowName2);
690
842
  const firstSnapshot = page1.runs[0]!.snapshot;
691
843
  const secondSnapshot = page1.runs[1]!.snapshot;
692
- checkWorkflowSnapshot(firstSnapshot, stepId3, 'skipped');
693
- checkWorkflowSnapshot(secondSnapshot, stepId2, 'waiting');
844
+ checkWorkflowSnapshot(firstSnapshot, stepId3, 'failed');
845
+ checkWorkflowSnapshot(secondSnapshot, stepId2, 'suspended');
694
846
 
695
847
  // Get second page
696
848
  const page2 = await store.getWorkflowRuns({
@@ -754,7 +906,7 @@ describe('ClickhouseStore', () => {
754
906
  // Insert multiple workflow runs for the same resourceId
755
907
  resourceId = 'resource-shared';
756
908
  for (const status of ['completed', 'running']) {
757
- const sample = createSampleWorkflowSnapshot(status as WorkflowRunState['context']['steps'][string]['status']);
909
+ const sample = createSampleWorkflowSnapshot(status as WorkflowRunState['context']['steps']['status']);
758
910
  runIds.push(sample.runId);
759
911
  await store.insert({
760
912
  tableName: TABLE_WORKFLOW_SNAPSHOT,
@@ -769,7 +921,7 @@ describe('ClickhouseStore', () => {
769
921
  });
770
922
  }
771
923
  // Insert a run with a different resourceId
772
- const other = createSampleWorkflowSnapshot('waiting');
924
+ const other = createSampleWorkflowSnapshot('suspended');
773
925
  await store.insert({
774
926
  tableName: TABLE_WORKFLOW_SNAPSHOT,
775
927
  record: {
@@ -850,6 +1002,152 @@ describe('ClickhouseStore', () => {
850
1002
  });
851
1003
  });
852
1004
 
1005
+ describe('alterTable', () => {
1006
+ const TEST_TABLE = 'test_alter_table';
1007
+ const BASE_SCHEMA = {
1008
+ id: { type: 'integer', primaryKey: true, nullable: false },
1009
+ name: { type: 'text', nullable: true },
1010
+ createdAt: { type: 'timestamp', nullable: false },
1011
+ updatedAt: { type: 'timestamp', nullable: false },
1012
+ } as Record<string, StorageColumn>;
1013
+
1014
+ TABLE_ENGINES[TEST_TABLE] = 'MergeTree()';
1015
+
1016
+ beforeEach(async () => {
1017
+ await store.createTable({ tableName: TEST_TABLE as TABLE_NAMES, schema: BASE_SCHEMA });
1018
+ });
1019
+
1020
+ afterEach(async () => {
1021
+ await store.clearTable({ tableName: TEST_TABLE as TABLE_NAMES });
1022
+ });
1023
+
1024
+ it('adds a new column to an existing table', async () => {
1025
+ await store.alterTable({
1026
+ tableName: TEST_TABLE as TABLE_NAMES,
1027
+ schema: { ...BASE_SCHEMA, age: { type: 'integer', nullable: true } },
1028
+ ifNotExists: ['age'],
1029
+ });
1030
+
1031
+ await store.insert({
1032
+ tableName: TEST_TABLE as TABLE_NAMES,
1033
+ record: { id: 1, name: 'Alice', age: 42, createdAt: new Date(), updatedAt: new Date() },
1034
+ });
1035
+
1036
+ const row = await store.load<{ id: string; name: string; age?: number }>({
1037
+ tableName: TEST_TABLE as TABLE_NAMES,
1038
+ keys: { id: '1' },
1039
+ });
1040
+ expect(row?.age).toBe(42);
1041
+ });
1042
+
1043
+ it('is idempotent when adding an existing column', async () => {
1044
+ await store.alterTable({
1045
+ tableName: TEST_TABLE as TABLE_NAMES,
1046
+ schema: { ...BASE_SCHEMA, foo: { type: 'text', nullable: true } },
1047
+ ifNotExists: ['foo'],
1048
+ });
1049
+ // Add the column again (should not throw)
1050
+ await expect(
1051
+ store.alterTable({
1052
+ tableName: TEST_TABLE as TABLE_NAMES,
1053
+ schema: { ...BASE_SCHEMA, foo: { type: 'text', nullable: true } },
1054
+ ifNotExists: ['foo'],
1055
+ }),
1056
+ ).resolves.not.toThrow();
1057
+ });
1058
+
1059
+ it('should add a default value to a column when using not null', async () => {
1060
+ await store.insert({
1061
+ tableName: TEST_TABLE as TABLE_NAMES,
1062
+ record: { id: 1, name: 'Bob', createdAt: new Date(), updatedAt: new Date() },
1063
+ });
1064
+
1065
+ await expect(
1066
+ store.alterTable({
1067
+ tableName: TEST_TABLE as TABLE_NAMES,
1068
+ schema: { ...BASE_SCHEMA, text_column: { type: 'text', nullable: false } },
1069
+ ifNotExists: ['text_column'],
1070
+ }),
1071
+ ).resolves.not.toThrow();
1072
+
1073
+ await expect(
1074
+ store.alterTable({
1075
+ tableName: TEST_TABLE as TABLE_NAMES,
1076
+ schema: { ...BASE_SCHEMA, timestamp_column: { type: 'timestamp', nullable: false } },
1077
+ ifNotExists: ['timestamp_column'],
1078
+ }),
1079
+ ).resolves.not.toThrow();
1080
+
1081
+ await expect(
1082
+ store.alterTable({
1083
+ tableName: TEST_TABLE as TABLE_NAMES,
1084
+ schema: { ...BASE_SCHEMA, bigint_column: { type: 'bigint', nullable: false } },
1085
+ ifNotExists: ['bigint_column'],
1086
+ }),
1087
+ ).resolves.not.toThrow();
1088
+
1089
+ await expect(
1090
+ store.alterTable({
1091
+ tableName: TEST_TABLE as TABLE_NAMES,
1092
+ schema: { ...BASE_SCHEMA, jsonb_column: { type: 'jsonb', nullable: false } },
1093
+ ifNotExists: ['jsonb_column'],
1094
+ }),
1095
+ ).resolves.not.toThrow();
1096
+ });
1097
+ });
1098
+
1099
+ describe('ClickhouseStore Double-nesting Prevention', () => {
1100
+ beforeEach(async () => {
1101
+ await store.clearTable({ tableName: TABLE_MESSAGES });
1102
+ await store.clearTable({ tableName: TABLE_THREADS });
1103
+ });
1104
+
1105
+ it('should handle stringified JSON content without double-nesting', async () => {
1106
+ const threadData = createSampleThread();
1107
+ const thread = await store.saveThread({ thread: threadData });
1108
+
1109
+ // Simulate user passing stringified JSON as message content (like the original bug report)
1110
+ const stringifiedContent = JSON.stringify({ userInput: 'test data', metadata: { key: 'value' } });
1111
+ const message: MastraMessageV2 = {
1112
+ id: `msg-${randomUUID()}`,
1113
+ role: 'user',
1114
+ threadId: thread.id,
1115
+ resourceId: thread.resourceId,
1116
+ content: {
1117
+ format: 2,
1118
+ parts: [{ type: 'text', text: stringifiedContent }],
1119
+ content: stringifiedContent, // This is the stringified JSON that user passed
1120
+ },
1121
+ createdAt: new Date(),
1122
+ };
1123
+
1124
+ // Save the message - this should stringify the whole content object for storage
1125
+ await store.saveMessages({ messages: [message], format: 'v2' });
1126
+
1127
+ // Retrieve the message - this is where double-nesting could occur
1128
+ const retrievedMessages = await store.getMessages({ threadId: thread.id, format: 'v2' });
1129
+ expect(retrievedMessages).toHaveLength(1);
1130
+
1131
+ const retrievedMessage = retrievedMessages[0] as MastraMessageV2;
1132
+
1133
+ // Check that content is properly structured as a V2 message
1134
+ expect(typeof retrievedMessage.content).toBe('object');
1135
+ expect(retrievedMessage.content.format).toBe(2);
1136
+
1137
+ // CRITICAL: The content.content should still be the original stringified JSON
1138
+ // NOT double-nested like: { content: '{"format":2,"parts":[...],"content":"{\\"userInput\\":\\"test data\\"}"}' }
1139
+ expect(retrievedMessage.content.content).toBe(stringifiedContent);
1140
+
1141
+ // Verify the content can be parsed as the original JSON
1142
+ const parsedContent = JSON.parse(retrievedMessage.content.content as string);
1143
+ expect(parsedContent).toEqual({ userInput: 'test data', metadata: { key: 'value' } });
1144
+
1145
+ // Additional check: ensure the message doesn't have the "Found unhandled message" structure
1146
+ expect(retrievedMessage.content.parts).toBeDefined();
1147
+ expect(Array.isArray(retrievedMessage.content.parts)).toBe(true);
1148
+ });
1149
+ });
1150
+
853
1151
  afterAll(async () => {
854
1152
  await store.close();
855
1153
  });