@mastra/clickhouse 0.2.7-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.
- package/.turbo/turbo-build.log +23 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +44 -0
- package/README.md +122 -0
- package/dist/_tsup-dts-rollup.d.cts +83 -0
- package/dist/_tsup-dts-rollup.d.ts +83 -0
- package/dist/index.cjs +635 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +633 -0
- package/docker-compose.yaml +15 -0
- package/eslint.config.js +6 -0
- package/package.json +45 -0
- package/src/index.ts +1 -0
- package/src/storage/index.test.ts +391 -0
- package/src/storage/index.ts +751 -0
- package/tsconfig.json +5 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import type { WorkflowRunState } from '@mastra/core/workflows';
|
|
3
|
+
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { ClickhouseStore } from '.';
|
|
6
|
+
import type { ClickhouseConfig } from '.';
|
|
7
|
+
|
|
8
|
+
const TEST_CONFIG: ClickhouseConfig = {
|
|
9
|
+
url: process.env.CLICKHOUSE_URL || 'http://localhost:8123',
|
|
10
|
+
username: process.env.CLICKHOUSE_USERNAME || 'default',
|
|
11
|
+
password: process.env.CLICKHOUSE_PASSWORD || 'password',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Sample test data factory functions
|
|
15
|
+
const createSampleThread = () => ({
|
|
16
|
+
id: `thread-${randomUUID()}`,
|
|
17
|
+
resourceId: `resource-${randomUUID()}`,
|
|
18
|
+
title: 'Test Thread',
|
|
19
|
+
createdAt: new Date(),
|
|
20
|
+
updatedAt: new Date(),
|
|
21
|
+
metadata: { key: 'value' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const createSampleMessage = (threadId: string, createdAt: Date = new Date()) =>
|
|
25
|
+
({
|
|
26
|
+
id: `msg-${randomUUID()}`,
|
|
27
|
+
role: 'user',
|
|
28
|
+
type: 'text',
|
|
29
|
+
threadId,
|
|
30
|
+
content: [{ type: 'text', text: 'Hello' }],
|
|
31
|
+
createdAt,
|
|
32
|
+
}) as any;
|
|
33
|
+
|
|
34
|
+
describe('ClickhouseStore', () => {
|
|
35
|
+
let store: ClickhouseStore;
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
store = new ClickhouseStore(TEST_CONFIG);
|
|
39
|
+
await store.init();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
// Clear tables before each test
|
|
44
|
+
await store.clearTable({ tableName: 'mastra_workflow_snapshot' });
|
|
45
|
+
await store.clearTable({ tableName: 'mastra_messages' });
|
|
46
|
+
await store.clearTable({ tableName: 'mastra_threads' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('Thread Operations', () => {
|
|
50
|
+
it('should create and retrieve a thread', async () => {
|
|
51
|
+
const thread = createSampleThread();
|
|
52
|
+
console.log('Saving thread:', thread);
|
|
53
|
+
|
|
54
|
+
// Save thread
|
|
55
|
+
const savedThread = await store.__saveThread({ thread });
|
|
56
|
+
expect(savedThread).toEqual(thread);
|
|
57
|
+
|
|
58
|
+
// Retrieve thread
|
|
59
|
+
const retrievedThread = await store.__getThreadById({ threadId: thread.id });
|
|
60
|
+
expect(retrievedThread?.title).toEqual(thread.title);
|
|
61
|
+
}, 10e3);
|
|
62
|
+
|
|
63
|
+
it('should return null for non-existent thread', async () => {
|
|
64
|
+
const result = await store.__getThreadById({ threadId: 'non-existent' });
|
|
65
|
+
expect(result).toBeNull();
|
|
66
|
+
}, 10e3);
|
|
67
|
+
|
|
68
|
+
it('should get threads by resource ID', async () => {
|
|
69
|
+
const thread1 = createSampleThread();
|
|
70
|
+
const thread2 = { ...createSampleThread(), resourceId: thread1.resourceId };
|
|
71
|
+
|
|
72
|
+
await store.__saveThread({ thread: thread1 });
|
|
73
|
+
await store.__saveThread({ thread: thread2 });
|
|
74
|
+
|
|
75
|
+
const threads = await store.__getThreadsByResourceId({ resourceId: thread1.resourceId });
|
|
76
|
+
expect(threads).toHaveLength(2);
|
|
77
|
+
expect(threads.map(t => t.id)).toEqual(expect.arrayContaining([thread1.id, thread2.id]));
|
|
78
|
+
}, 10e3);
|
|
79
|
+
|
|
80
|
+
it('should update thread title and metadata', async () => {
|
|
81
|
+
const thread = createSampleThread();
|
|
82
|
+
await store.__saveThread({ thread });
|
|
83
|
+
|
|
84
|
+
const newMetadata = { newKey: 'newValue' };
|
|
85
|
+
const updatedThread = await store.__updateThread({
|
|
86
|
+
id: thread.id,
|
|
87
|
+
title: 'Updated Title',
|
|
88
|
+
metadata: newMetadata,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(updatedThread.title).toBe('Updated Title');
|
|
92
|
+
expect(updatedThread.metadata).toEqual({
|
|
93
|
+
...thread.metadata,
|
|
94
|
+
...newMetadata,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Verify persistence
|
|
98
|
+
const retrievedThread = await store.__getThreadById({ threadId: thread.id });
|
|
99
|
+
expect(retrievedThread).toEqual(updatedThread);
|
|
100
|
+
}, 10e3);
|
|
101
|
+
|
|
102
|
+
it('should delete thread and its messages', async () => {
|
|
103
|
+
const thread = createSampleThread();
|
|
104
|
+
await store.__saveThread({ thread });
|
|
105
|
+
|
|
106
|
+
// Add some messages
|
|
107
|
+
const messages = [createSampleMessage(thread.id), createSampleMessage(thread.id)];
|
|
108
|
+
await store.__saveMessages({ messages });
|
|
109
|
+
|
|
110
|
+
await store.__deleteThread({ threadId: thread.id });
|
|
111
|
+
|
|
112
|
+
const retrievedThread = await store.__getThreadById({ threadId: thread.id });
|
|
113
|
+
expect(retrievedThread).toBeNull();
|
|
114
|
+
|
|
115
|
+
// Verify messages were also deleted
|
|
116
|
+
const retrievedMessages = await store.__getMessages({ threadId: thread.id });
|
|
117
|
+
expect(retrievedMessages).toHaveLength(0);
|
|
118
|
+
}, 10e3);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('Message Operations', () => {
|
|
122
|
+
it('should save and retrieve messages', async () => {
|
|
123
|
+
const thread = createSampleThread();
|
|
124
|
+
await store.__saveThread({ thread });
|
|
125
|
+
|
|
126
|
+
const messages = [
|
|
127
|
+
createSampleMessage(thread.id, new Date(Date.now() - 1000 * 60 * 60 * 24)),
|
|
128
|
+
createSampleMessage(thread.id),
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
// Save messages
|
|
132
|
+
const savedMessages = await store.__saveMessages({ messages });
|
|
133
|
+
expect(savedMessages).toEqual(messages);
|
|
134
|
+
|
|
135
|
+
// Retrieve messages
|
|
136
|
+
const retrievedMessages = await store.__getMessages({ threadId: thread.id });
|
|
137
|
+
expect(retrievedMessages).toHaveLength(2);
|
|
138
|
+
console.log('Messages:', messages);
|
|
139
|
+
console.log('Retrieved messages:', retrievedMessages);
|
|
140
|
+
expect(retrievedMessages).toEqual(expect.arrayContaining(messages));
|
|
141
|
+
}, 10e3);
|
|
142
|
+
|
|
143
|
+
it('should handle empty message array', async () => {
|
|
144
|
+
const result = await store.__saveMessages({ messages: [] });
|
|
145
|
+
expect(result).toEqual([]);
|
|
146
|
+
}, 10e3);
|
|
147
|
+
|
|
148
|
+
it('should maintain message order', async () => {
|
|
149
|
+
const thread = createSampleThread();
|
|
150
|
+
await store.__saveThread({ thread });
|
|
151
|
+
|
|
152
|
+
const messages = [
|
|
153
|
+
{
|
|
154
|
+
...createSampleMessage(thread.id, new Date(Date.now() - 1000 * 3)),
|
|
155
|
+
content: [{ type: 'text', text: 'First' }],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
...createSampleMessage(thread.id, new Date(Date.now() - 1000 * 2)),
|
|
159
|
+
content: [{ type: 'text', text: 'Second' }],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
...createSampleMessage(thread.id, new Date(Date.now() - 1000 * 1)),
|
|
163
|
+
content: [{ type: 'text', text: 'Third' }],
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
await store.__saveMessages({ messages });
|
|
168
|
+
|
|
169
|
+
const retrievedMessages = await store.__getMessages({ threadId: thread.id });
|
|
170
|
+
expect(retrievedMessages).toHaveLength(3);
|
|
171
|
+
|
|
172
|
+
// Verify order is maintained
|
|
173
|
+
retrievedMessages.forEach((msg, idx) => {
|
|
174
|
+
expect(msg.content[0].text).toBe(messages[idx].content[0].text);
|
|
175
|
+
});
|
|
176
|
+
}, 10e3);
|
|
177
|
+
|
|
178
|
+
// it('should rollback on error during message save', async () => {
|
|
179
|
+
// const thread = createSampleThread();
|
|
180
|
+
// await store.__saveThread({ thread });
|
|
181
|
+
|
|
182
|
+
// const messages = [
|
|
183
|
+
// createSampleMessage(thread.id),
|
|
184
|
+
// { ...createSampleMessage(thread.id), id: null }, // This will cause an error
|
|
185
|
+
// ];
|
|
186
|
+
|
|
187
|
+
// await expect(store.__saveMessages({ messages })).rejects.toThrow();
|
|
188
|
+
|
|
189
|
+
// // Verify no messages were saved
|
|
190
|
+
// const savedMessages = await store.__getMessages({ threadId: thread.id });
|
|
191
|
+
// expect(savedMessages).toHaveLength(0);
|
|
192
|
+
// });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('Edge Cases and Error Handling', () => {
|
|
196
|
+
it('should handle large metadata objects', async () => {
|
|
197
|
+
const thread = createSampleThread();
|
|
198
|
+
const largeMetadata = {
|
|
199
|
+
...thread.metadata,
|
|
200
|
+
largeArray: Array.from({ length: 1000 }, (_, i) => ({ index: i, data: 'test'.repeat(100) })),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const threadWithLargeMetadata = {
|
|
204
|
+
...thread,
|
|
205
|
+
metadata: largeMetadata,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
await store.__saveThread({ thread: threadWithLargeMetadata });
|
|
209
|
+
const retrieved = await store.__getThreadById({ threadId: thread.id });
|
|
210
|
+
|
|
211
|
+
expect(retrieved?.metadata).toEqual(largeMetadata);
|
|
212
|
+
}, 10e3);
|
|
213
|
+
|
|
214
|
+
it('should handle special characters in thread titles', async () => {
|
|
215
|
+
const thread = {
|
|
216
|
+
...createSampleThread(),
|
|
217
|
+
title: 'Special \'quotes\' and "double quotes" and emoji 🎉',
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
await store.__saveThread({ thread });
|
|
221
|
+
const retrieved = await store.__getThreadById({ threadId: thread.id });
|
|
222
|
+
|
|
223
|
+
expect(retrieved?.title).toBe(thread.title);
|
|
224
|
+
}, 10e3);
|
|
225
|
+
|
|
226
|
+
it('should handle concurrent thread updates', async () => {
|
|
227
|
+
const thread = createSampleThread();
|
|
228
|
+
await store.__saveThread({ thread });
|
|
229
|
+
|
|
230
|
+
// Perform multiple updates concurrently
|
|
231
|
+
const updates = Array.from({ length: 5 }, (_, i) =>
|
|
232
|
+
store.__updateThread({
|
|
233
|
+
id: thread.id,
|
|
234
|
+
title: `Update ${i}`,
|
|
235
|
+
metadata: { update: i },
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
await expect(Promise.all(updates)).resolves.toBeDefined();
|
|
240
|
+
|
|
241
|
+
// Verify final state
|
|
242
|
+
const finalThread = await store.__getThreadById({ threadId: thread.id });
|
|
243
|
+
expect(finalThread).toBeDefined();
|
|
244
|
+
}, 10e3);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('Workflow Snapshots', () => {
|
|
248
|
+
it('should persist and load workflow snapshots', async () => {
|
|
249
|
+
const workflowName = 'test-workflow';
|
|
250
|
+
const runId = `run-${randomUUID()}`;
|
|
251
|
+
const snapshot = {
|
|
252
|
+
status: 'running',
|
|
253
|
+
context: {
|
|
254
|
+
stepResults: {},
|
|
255
|
+
attempts: {},
|
|
256
|
+
triggerData: { type: 'manual' },
|
|
257
|
+
},
|
|
258
|
+
} as any;
|
|
259
|
+
|
|
260
|
+
await store.persistWorkflowSnapshot({
|
|
261
|
+
workflowName,
|
|
262
|
+
runId,
|
|
263
|
+
snapshot,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const loadedSnapshot = await store.loadWorkflowSnapshot({
|
|
267
|
+
workflowName,
|
|
268
|
+
runId,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(loadedSnapshot).toEqual(snapshot);
|
|
272
|
+
}, 10e3);
|
|
273
|
+
|
|
274
|
+
it('should return null for non-existent workflow snapshot', async () => {
|
|
275
|
+
const result = await store.loadWorkflowSnapshot({
|
|
276
|
+
workflowName: 'non-existent',
|
|
277
|
+
runId: 'non-existent',
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(result).toBeNull();
|
|
281
|
+
}, 10e3);
|
|
282
|
+
|
|
283
|
+
it('should update existing workflow snapshot', async () => {
|
|
284
|
+
const workflowName = 'test-workflow';
|
|
285
|
+
const runId = `run-${randomUUID()}`;
|
|
286
|
+
const initialSnapshot = {
|
|
287
|
+
status: 'running',
|
|
288
|
+
context: {
|
|
289
|
+
stepResults: {},
|
|
290
|
+
attempts: {},
|
|
291
|
+
triggerData: { type: 'manual' },
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
await store.persistWorkflowSnapshot({
|
|
296
|
+
workflowName,
|
|
297
|
+
runId,
|
|
298
|
+
snapshot: initialSnapshot as any,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const updatedSnapshot = {
|
|
302
|
+
status: 'completed',
|
|
303
|
+
context: {
|
|
304
|
+
stepResults: {
|
|
305
|
+
'step-1': { status: 'success', result: { data: 'test' } },
|
|
306
|
+
},
|
|
307
|
+
attempts: { 'step-1': 1 },
|
|
308
|
+
triggerData: { type: 'manual' },
|
|
309
|
+
},
|
|
310
|
+
} as any;
|
|
311
|
+
|
|
312
|
+
await store.persistWorkflowSnapshot({
|
|
313
|
+
workflowName,
|
|
314
|
+
runId,
|
|
315
|
+
snapshot: updatedSnapshot,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const loadedSnapshot = await store.loadWorkflowSnapshot({
|
|
319
|
+
workflowName,
|
|
320
|
+
runId,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(loadedSnapshot).toEqual(updatedSnapshot);
|
|
324
|
+
}, 10e3);
|
|
325
|
+
|
|
326
|
+
it('should handle complex workflow state', async () => {
|
|
327
|
+
const workflowName = 'complex-workflow';
|
|
328
|
+
const runId = `run-${randomUUID()}`;
|
|
329
|
+
const complexSnapshot = {
|
|
330
|
+
value: { currentState: 'running' },
|
|
331
|
+
context: {
|
|
332
|
+
stepResults: {
|
|
333
|
+
'step-1': {
|
|
334
|
+
status: 'success',
|
|
335
|
+
result: {
|
|
336
|
+
nestedData: {
|
|
337
|
+
array: [1, 2, 3],
|
|
338
|
+
object: { key: 'value' },
|
|
339
|
+
date: new Date().toISOString(),
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
'step-2': {
|
|
344
|
+
status: 'waiting',
|
|
345
|
+
dependencies: ['step-3', 'step-4'],
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
attempts: { 'step-1': 1, 'step-2': 0 },
|
|
349
|
+
triggerData: {
|
|
350
|
+
type: 'scheduled',
|
|
351
|
+
metadata: {
|
|
352
|
+
schedule: '0 0 * * *',
|
|
353
|
+
timezone: 'UTC',
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
activePaths: [
|
|
358
|
+
{
|
|
359
|
+
stepPath: ['step-1'],
|
|
360
|
+
stepId: 'step-1',
|
|
361
|
+
status: 'success',
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
stepPath: ['step-2'],
|
|
365
|
+
stepId: 'step-2',
|
|
366
|
+
status: 'waiting',
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
runId: runId,
|
|
370
|
+
timestamp: Date.now(),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
await store.persistWorkflowSnapshot({
|
|
374
|
+
workflowName,
|
|
375
|
+
runId,
|
|
376
|
+
snapshot: complexSnapshot as WorkflowRunState,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const loadedSnapshot = await store.loadWorkflowSnapshot({
|
|
380
|
+
workflowName,
|
|
381
|
+
runId,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(loadedSnapshot).toEqual(complexSnapshot);
|
|
385
|
+
}, 10e3);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
afterAll(async () => {
|
|
389
|
+
await store.close();
|
|
390
|
+
});
|
|
391
|
+
});
|