@mastra/clickhouse 0.11.1-alpha.1 → 0.12.0-alpha.3

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,23 +1,23 @@
1
1
 
2
- > @mastra/clickhouse@0.11.1-alpha.1 build /home/runner/work/mastra/mastra/stores/clickhouse
2
+ > @mastra/clickhouse@0.12.0-alpha.3 build /home/runner/work/mastra/mastra/stores/clickhouse
3
3
  > tsup src/index.ts --format esm,cjs --experimental-dts --clean --treeshake=smallest --splitting
4
4
 
5
5
  CLI Building entry: src/index.ts
6
6
  CLI Using tsconfig: tsconfig.json
7
7
  CLI tsup v8.5.0
8
8
  TSC Build start
9
- TSC ⚡️ Build success in 8767ms
9
+ TSC ⚡️ Build success in 11027ms
10
10
  DTS Build start
11
11
  CLI Target: es2022
12
12
  Analysis will use the bundled TypeScript version 5.8.3
13
13
  Writing package typings: /home/runner/work/mastra/mastra/stores/clickhouse/dist/_tsup-dts-rollup.d.ts
14
14
  Analysis will use the bundled TypeScript version 5.8.3
15
15
  Writing package typings: /home/runner/work/mastra/mastra/stores/clickhouse/dist/_tsup-dts-rollup.d.cts
16
- DTS ⚡️ Build success in 11882ms
16
+ DTS ⚡️ Build success in 12403ms
17
17
  CLI Cleaning output folder
18
18
  ESM Build start
19
19
  CJS Build start
20
- ESM dist/index.js 36.43 KB
21
- ESM ⚡️ Build success in 1044ms
22
- CJS dist/index.cjs 37.19 KB
23
- CJS ⚡️ Build success in 1046ms
20
+ ESM dist/index.js 38.52 KB
21
+ ESM ⚡️ Build success in 986ms
22
+ CJS dist/index.cjs 39.29 KB
23
+ CJS ⚡️ Build success in 988ms
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @mastra/clickhouse
2
2
 
3
+ ## 0.12.0-alpha.3
4
+
5
+ ### Minor Changes
6
+
7
+ - 8a3bfd2: Update peerdeps to latest core
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [792c4c0]
12
+ - Updated dependencies [502fe05]
13
+ - Updated dependencies [4efcfa0]
14
+ - @mastra/core@0.10.7-alpha.3
15
+
16
+ ## 0.11.1-alpha.2
17
+
18
+ ### Patch Changes
19
+
20
+ - 15e9d26: Added per-resource working memory for LibSQL, Upstash, and PG
21
+ - 0fb9d64: [MASTRA-4018] Update saveMessages in storage adapters to upsert messages
22
+ - Updated dependencies [15e9d26]
23
+ - Updated dependencies [07d6d88]
24
+ - Updated dependencies [5d74aab]
25
+ - Updated dependencies [144eb0b]
26
+ - @mastra/core@0.10.7-alpha.2
27
+
3
28
  ## 0.11.1-alpha.1
4
29
 
5
30
  ### Patch Changes
@@ -10,6 +10,7 @@ import type { StorageGetMessagesArg } from '@mastra/core/storage';
10
10
  import type { StorageGetTracesArg } from '@mastra/core/storage';
11
11
  import type { StorageThreadType } from '@mastra/core/memory';
12
12
  import type { TABLE_NAMES } from '@mastra/core/storage';
13
+ import type { TABLE_RESOURCES } from '@mastra/core/storage';
13
14
  import { TABLE_SCHEMAS } from '@mastra/core/storage';
14
15
  import type { Trace } from '@mastra/core/telemetry';
15
16
  import type { WorkflowRun } from '@mastra/core/storage';
@@ -45,6 +46,7 @@ declare class ClickhouseStore extends MastraStorage {
45
46
  protected ttl: ClickhouseConfig['ttl'];
46
47
  constructor(config: ClickhouseConfig);
47
48
  private transformEvalRow;
49
+ private escape;
48
50
  getEvalsByAgentName(agentName: string, type?: 'test' | 'live'): Promise<EvalRow[]>;
49
51
  batchInsert({ tableName, records }: {
50
52
  tableName: TABLE_NAMES;
@@ -67,7 +69,7 @@ declare class ClickhouseStore extends MastraStorage {
67
69
  tableName: TABLE_NAMES;
68
70
  }): Promise<void>;
69
71
  createTable({ tableName, schema, }: {
70
- tableName: TABLE_NAMES;
72
+ tableName: SUPPORTED_TABLE_NAMES;
71
73
  schema: Record<string, StorageColumn>;
72
74
  }): Promise<void>;
73
75
  protected getSqlType(type: StorageColumn['type']): string;
@@ -89,8 +91,8 @@ declare class ClickhouseStore extends MastraStorage {
89
91
  tableName: TABLE_NAMES;
90
92
  record: Record<string, any>;
91
93
  }): Promise<void>;
92
- load<R>({ tableName, keys }: {
93
- tableName: TABLE_NAMES;
94
+ load<R>({ tableName, keys, }: {
95
+ tableName: SUPPORTED_TABLE_NAMES;
94
96
  keys: Record<string, string>;
95
97
  }): Promise<R | null>;
96
98
  getThreadById({ threadId }: {
@@ -180,7 +182,9 @@ export { COLUMN_TYPES as COLUMN_TYPES_alias_1 }
180
182
 
181
183
  declare type IntervalUnit = 'NANOSECOND' | 'MICROSECOND' | 'MILLISECOND' | 'SECOND' | 'MINUTE' | 'HOUR' | 'DAY' | 'WEEK' | 'MONTH' | 'QUARTER' | 'YEAR';
182
184
 
183
- declare const TABLE_ENGINES: Record<TABLE_NAMES, string>;
185
+ declare type SUPPORTED_TABLE_NAMES = Exclude<TABLE_NAMES, typeof TABLE_RESOURCES>;
186
+
187
+ declare const TABLE_ENGINES: Record<SUPPORTED_TABLE_NAMES, string>;
184
188
  export { TABLE_ENGINES }
185
189
  export { TABLE_ENGINES as TABLE_ENGINES_alias_1 }
186
190
 
@@ -10,6 +10,7 @@ import type { StorageGetMessagesArg } from '@mastra/core/storage';
10
10
  import type { StorageGetTracesArg } from '@mastra/core/storage';
11
11
  import type { StorageThreadType } from '@mastra/core/memory';
12
12
  import type { TABLE_NAMES } from '@mastra/core/storage';
13
+ import type { TABLE_RESOURCES } from '@mastra/core/storage';
13
14
  import { TABLE_SCHEMAS } from '@mastra/core/storage';
14
15
  import type { Trace } from '@mastra/core/telemetry';
15
16
  import type { WorkflowRun } from '@mastra/core/storage';
@@ -45,6 +46,7 @@ declare class ClickhouseStore extends MastraStorage {
45
46
  protected ttl: ClickhouseConfig['ttl'];
46
47
  constructor(config: ClickhouseConfig);
47
48
  private transformEvalRow;
49
+ private escape;
48
50
  getEvalsByAgentName(agentName: string, type?: 'test' | 'live'): Promise<EvalRow[]>;
49
51
  batchInsert({ tableName, records }: {
50
52
  tableName: TABLE_NAMES;
@@ -67,7 +69,7 @@ declare class ClickhouseStore extends MastraStorage {
67
69
  tableName: TABLE_NAMES;
68
70
  }): Promise<void>;
69
71
  createTable({ tableName, schema, }: {
70
- tableName: TABLE_NAMES;
72
+ tableName: SUPPORTED_TABLE_NAMES;
71
73
  schema: Record<string, StorageColumn>;
72
74
  }): Promise<void>;
73
75
  protected getSqlType(type: StorageColumn['type']): string;
@@ -89,8 +91,8 @@ declare class ClickhouseStore extends MastraStorage {
89
91
  tableName: TABLE_NAMES;
90
92
  record: Record<string, any>;
91
93
  }): Promise<void>;
92
- load<R>({ tableName, keys }: {
93
- tableName: TABLE_NAMES;
94
+ load<R>({ tableName, keys, }: {
95
+ tableName: SUPPORTED_TABLE_NAMES;
94
96
  keys: Record<string, string>;
95
97
  }): Promise<R | null>;
96
98
  getThreadById({ threadId }: {
@@ -180,7 +182,9 @@ export { COLUMN_TYPES as COLUMN_TYPES_alias_1 }
180
182
 
181
183
  declare type IntervalUnit = 'NANOSECOND' | 'MICROSECOND' | 'MILLISECOND' | 'SECOND' | 'MINUTE' | 'HOUR' | 'DAY' | 'WEEK' | 'MONTH' | 'QUARTER' | 'YEAR';
182
184
 
183
- declare const TABLE_ENGINES: Record<TABLE_NAMES, string>;
185
+ declare type SUPPORTED_TABLE_NAMES = Exclude<TABLE_NAMES, typeof TABLE_RESOURCES>;
186
+
187
+ declare const TABLE_ENGINES: Record<SUPPORTED_TABLE_NAMES, string>;
184
188
  export { TABLE_ENGINES }
185
189
  export { TABLE_ENGINES as TABLE_ENGINES_alias_1 }
186
190
 
package/dist/index.cjs CHANGED
@@ -87,6 +87,18 @@ var ClickhouseStore = class extends storage.MastraStorage {
87
87
  createdAt: row.created_at
88
88
  };
89
89
  }
90
+ escape(value) {
91
+ if (typeof value === "string") {
92
+ return `'${value.replace(/'/g, "''")}'`;
93
+ }
94
+ if (value instanceof Date) {
95
+ return `'${value.toISOString()}'`;
96
+ }
97
+ if (value === null || value === void 0) {
98
+ return "NULL";
99
+ }
100
+ return value.toString();
101
+ }
90
102
  async getEvalsByAgentName(agentName, type) {
91
103
  try {
92
104
  const baseQuery = `SELECT *, toDateTime64(createdAt, 3) as createdAt FROM ${storage.TABLE_EVALS} WHERE agent_name = {var_agent_name:String}`;
@@ -457,7 +469,10 @@ var ClickhouseStore = class extends storage.MastraStorage {
457
469
  );
458
470
  }
459
471
  }
460
- async load({ tableName, keys }) {
472
+ async load({
473
+ tableName,
474
+ keys
475
+ }) {
461
476
  try {
462
477
  const keyEntries = Object.entries(keys);
463
478
  const conditions = keyEntries.map(
@@ -832,12 +847,52 @@ var ClickhouseStore = class extends storage.MastraStorage {
832
847
  if (!thread) {
833
848
  throw new Error(`Thread ${threadId} not found`);
834
849
  }
850
+ const existingResult = await this.db.query({
851
+ query: `SELECT id, thread_id FROM ${storage.TABLE_MESSAGES} WHERE id IN ({ids:Array(String)})`,
852
+ query_params: {
853
+ ids: messages.map((m) => m.id)
854
+ },
855
+ clickhouse_settings: {
856
+ // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z')
857
+ date_time_input_format: "best_effort",
858
+ date_time_output_format: "iso",
859
+ use_client_time_zone: 1,
860
+ output_format_json_quote_64bit_integers: 0
861
+ },
862
+ format: "JSONEachRow"
863
+ });
864
+ const existingRows = await existingResult.json();
865
+ const existingSet = new Set(existingRows.map((row) => `${row.id}::${row.thread_id}`));
866
+ const toInsert = messages.filter((m) => !existingSet.has(`${m.id}::${threadId}`));
867
+ const toUpdate = messages.filter((m) => existingSet.has(`${m.id}::${threadId}`));
868
+ const updatePromises = toUpdate.map(
869
+ (message) => this.db.command({
870
+ query: `
871
+ ALTER TABLE ${storage.TABLE_MESSAGES}
872
+ UPDATE content = {var_content:String}, role = {var_role:String}, type = {var_type:String}
873
+ WHERE id = {var_id:String} AND thread_id = {var_thread_id:String}
874
+ `,
875
+ query_params: {
876
+ var_content: typeof message.content === "string" ? message.content : JSON.stringify(message.content),
877
+ var_role: message.role,
878
+ var_type: message.type || "v2",
879
+ var_id: message.id,
880
+ var_thread_id: threadId
881
+ },
882
+ clickhouse_settings: {
883
+ // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z')
884
+ date_time_input_format: "best_effort",
885
+ use_client_time_zone: 1,
886
+ output_format_json_quote_64bit_integers: 0
887
+ }
888
+ })
889
+ );
835
890
  await Promise.all([
836
891
  // Insert messages
837
892
  this.db.insert({
838
893
  table: storage.TABLE_MESSAGES,
839
894
  format: "JSONEachRow",
840
- values: messages.map((message) => ({
895
+ values: toInsert.map((message) => ({
841
896
  id: message.id,
842
897
  thread_id: threadId,
843
898
  content: typeof message.content === "string" ? message.content : JSON.stringify(message.content),
@@ -852,6 +907,7 @@ var ClickhouseStore = class extends storage.MastraStorage {
852
907
  output_format_json_quote_64bit_integers: 0
853
908
  }
854
909
  }),
910
+ ...updatePromises,
855
911
  // Update thread's updatedAt timestamp
856
912
  this.db.insert({
857
913
  table: storage.TABLE_THREADS,
package/dist/index.js CHANGED
@@ -85,6 +85,18 @@ var ClickhouseStore = class extends MastraStorage {
85
85
  createdAt: row.created_at
86
86
  };
87
87
  }
88
+ escape(value) {
89
+ if (typeof value === "string") {
90
+ return `'${value.replace(/'/g, "''")}'`;
91
+ }
92
+ if (value instanceof Date) {
93
+ return `'${value.toISOString()}'`;
94
+ }
95
+ if (value === null || value === void 0) {
96
+ return "NULL";
97
+ }
98
+ return value.toString();
99
+ }
88
100
  async getEvalsByAgentName(agentName, type) {
89
101
  try {
90
102
  const baseQuery = `SELECT *, toDateTime64(createdAt, 3) as createdAt FROM ${TABLE_EVALS} WHERE agent_name = {var_agent_name:String}`;
@@ -455,7 +467,10 @@ var ClickhouseStore = class extends MastraStorage {
455
467
  );
456
468
  }
457
469
  }
458
- async load({ tableName, keys }) {
470
+ async load({
471
+ tableName,
472
+ keys
473
+ }) {
459
474
  try {
460
475
  const keyEntries = Object.entries(keys);
461
476
  const conditions = keyEntries.map(
@@ -830,12 +845,52 @@ var ClickhouseStore = class extends MastraStorage {
830
845
  if (!thread) {
831
846
  throw new Error(`Thread ${threadId} not found`);
832
847
  }
848
+ const existingResult = await this.db.query({
849
+ query: `SELECT id, thread_id FROM ${TABLE_MESSAGES} WHERE id IN ({ids:Array(String)})`,
850
+ query_params: {
851
+ ids: messages.map((m) => m.id)
852
+ },
853
+ clickhouse_settings: {
854
+ // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z')
855
+ date_time_input_format: "best_effort",
856
+ date_time_output_format: "iso",
857
+ use_client_time_zone: 1,
858
+ output_format_json_quote_64bit_integers: 0
859
+ },
860
+ format: "JSONEachRow"
861
+ });
862
+ const existingRows = await existingResult.json();
863
+ const existingSet = new Set(existingRows.map((row) => `${row.id}::${row.thread_id}`));
864
+ const toInsert = messages.filter((m) => !existingSet.has(`${m.id}::${threadId}`));
865
+ const toUpdate = messages.filter((m) => existingSet.has(`${m.id}::${threadId}`));
866
+ const updatePromises = toUpdate.map(
867
+ (message) => this.db.command({
868
+ query: `
869
+ ALTER TABLE ${TABLE_MESSAGES}
870
+ UPDATE content = {var_content:String}, role = {var_role:String}, type = {var_type:String}
871
+ WHERE id = {var_id:String} AND thread_id = {var_thread_id:String}
872
+ `,
873
+ query_params: {
874
+ var_content: typeof message.content === "string" ? message.content : JSON.stringify(message.content),
875
+ var_role: message.role,
876
+ var_type: message.type || "v2",
877
+ var_id: message.id,
878
+ var_thread_id: threadId
879
+ },
880
+ clickhouse_settings: {
881
+ // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z')
882
+ date_time_input_format: "best_effort",
883
+ use_client_time_zone: 1,
884
+ output_format_json_quote_64bit_integers: 0
885
+ }
886
+ })
887
+ );
833
888
  await Promise.all([
834
889
  // Insert messages
835
890
  this.db.insert({
836
891
  table: TABLE_MESSAGES,
837
892
  format: "JSONEachRow",
838
- values: messages.map((message) => ({
893
+ values: toInsert.map((message) => ({
839
894
  id: message.id,
840
895
  thread_id: threadId,
841
896
  content: typeof message.content === "string" ? message.content : JSON.stringify(message.content),
@@ -850,6 +905,7 @@ var ClickhouseStore = class extends MastraStorage {
850
905
  output_format_json_quote_64bit_integers: 0
851
906
  }
852
907
  }),
908
+ ...updatePromises,
853
909
  // Update thread's updatedAt timestamp
854
910
  this.db.insert({
855
911
  table: TABLE_THREADS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mastra/clickhouse",
3
- "version": "0.11.1-alpha.1",
3
+ "version": "0.12.0-alpha.3",
4
4
  "description": "Clickhouse provider for Mastra - includes db storage capabilities",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,16 +25,16 @@
25
25
  "devDependencies": {
26
26
  "@microsoft/api-extractor": "^7.52.8",
27
27
  "@types/node": "^20.19.0",
28
- "eslint": "^9.28.0",
28
+ "eslint": "^9.29.0",
29
29
  "tsup": "^8.5.0",
30
30
  "typescript": "^5.8.3",
31
31
  "vitest": "^3.2.3",
32
32
  "@internal/lint": "0.0.13",
33
33
  "@internal/storage-test-utils": "0.0.9",
34
- "@mastra/core": "0.10.7-alpha.1"
34
+ "@mastra/core": "0.10.7-alpha.3"
35
35
  },
36
36
  "peerDependencies": {
37
- "@mastra/core": ">=0.10.4-0 <0.11.0"
37
+ "@mastra/core": ">=0.10.7-0 <0.11.0-0"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsup src/index.ts --format esm,cjs --experimental-dts --clean --treeshake=smallest --splitting",
@@ -4,6 +4,7 @@ import {
4
4
  createSampleThread,
5
5
  createSampleWorkflowSnapshot,
6
6
  checkWorkflowSnapshot,
7
+ createSampleMessageV2,
7
8
  } from '@internal/storage-test-utils';
8
9
  import type { MastraMessageV1, StorageColumn, WorkflowRunState } from '@mastra/core';
9
10
  import type { TABLE_NAMES } from '@mastra/core/storage';
@@ -236,6 +237,83 @@ describe('ClickhouseStore', () => {
236
237
  });
237
238
  }, 10e3);
238
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
+
239
317
  // it('should retrieve messages w/ next/prev messages by message id + resource id', async () => {
240
318
  // const messages: MastraMessageV2[] = [
241
319
  // createSampleMessageV2({ threadId: 'thread-one', content: 'First', resourceId: 'cross-thread-resource' }),
@@ -23,10 +23,13 @@ import type {
23
23
  WorkflowRun,
24
24
  WorkflowRuns,
25
25
  StorageGetTracesArg,
26
+ TABLE_RESOURCES,
26
27
  } from '@mastra/core/storage';
27
28
  import type { Trace } from '@mastra/core/telemetry';
28
29
  import type { WorkflowRunState } from '@mastra/core/workflows';
29
30
 
31
+ type SUPPORTED_TABLE_NAMES = Exclude<TABLE_NAMES, typeof TABLE_RESOURCES>;
32
+
30
33
  function safelyParseJSON(jsonString: string): any {
31
34
  try {
32
35
  return JSON.parse(jsonString);
@@ -66,7 +69,7 @@ export type ClickhouseConfig = {
66
69
  };
67
70
  };
68
71
 
69
- export const TABLE_ENGINES: Record<TABLE_NAMES, string> = {
72
+ export const TABLE_ENGINES: Record<SUPPORTED_TABLE_NAMES, string> = {
70
73
  [TABLE_MESSAGES]: `MergeTree()`,
71
74
  [TABLE_WORKFLOW_SNAPSHOT]: `ReplacingMergeTree()`,
72
75
  [TABLE_TRACES]: `MergeTree()`,
@@ -149,6 +152,19 @@ export class ClickhouseStore extends MastraStorage {
149
152
  };
150
153
  }
151
154
 
155
+ private escape(value: any): string {
156
+ if (typeof value === 'string') {
157
+ return `'${value.replace(/'/g, "''")}'`;
158
+ }
159
+ if (value instanceof Date) {
160
+ return `'${value.toISOString()}'`;
161
+ }
162
+ if (value === null || value === undefined) {
163
+ return 'NULL';
164
+ }
165
+ return value.toString();
166
+ }
167
+
152
168
  async getEvalsByAgentName(agentName: string, type?: 'test' | 'live'): Promise<EvalRow[]> {
153
169
  try {
154
170
  const baseQuery = `SELECT *, toDateTime64(createdAt, 3) as createdAt FROM ${TABLE_EVALS} WHERE agent_name = {var_agent_name:String}`;
@@ -388,7 +404,7 @@ export class ClickhouseStore extends MastraStorage {
388
404
  tableName,
389
405
  schema,
390
406
  }: {
391
- tableName: TABLE_NAMES;
407
+ tableName: SUPPORTED_TABLE_NAMES;
392
408
  schema: Record<string, StorageColumn>;
393
409
  }): Promise<void> {
394
410
  try {
@@ -577,7 +593,13 @@ export class ClickhouseStore extends MastraStorage {
577
593
  }
578
594
  }
579
595
 
580
- async load<R>({ tableName, keys }: { tableName: TABLE_NAMES; keys: Record<string, string> }): Promise<R | null> {
596
+ async load<R>({
597
+ tableName,
598
+ keys,
599
+ }: {
600
+ tableName: SUPPORTED_TABLE_NAMES;
601
+ keys: Record<string, string>;
602
+ }): Promise<R | null> {
581
603
  try {
582
604
  const keyEntries = Object.entries(keys);
583
605
  const conditions = keyEntries
@@ -591,7 +613,7 @@ export class ClickhouseStore extends MastraStorage {
591
613
  }, {});
592
614
 
593
615
  const result = await this.db.query({
594
- query: `SELECT *, toDateTime64(createdAt, 3) as createdAt, toDateTime64(updatedAt, 3) as updatedAt FROM ${tableName} ${TABLE_ENGINES[tableName as TABLE_NAMES].startsWith('ReplacingMergeTree') ? 'FINAL' : ''} WHERE ${conditions}`,
616
+ query: `SELECT *, toDateTime64(createdAt, 3) as createdAt, toDateTime64(updatedAt, 3) as updatedAt FROM ${tableName} ${TABLE_ENGINES[tableName as SUPPORTED_TABLE_NAMES].startsWith('ReplacingMergeTree') ? 'FINAL' : ''} WHERE ${conditions}`,
595
617
  query_params: values,
596
618
  clickhouse_settings: {
597
619
  // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z')
@@ -1007,13 +1029,62 @@ export class ClickhouseStore extends MastraStorage {
1007
1029
  throw new Error(`Thread ${threadId} not found`);
1008
1030
  }
1009
1031
 
1032
+ // Clickhouse's MergeTree engine does not support native upserts or unique constraints on (id, thread_id).
1033
+ // Note: We cannot switch to ReplacingMergeTree without a schema migration,
1034
+ // as it would require altering the table engine.
1035
+ // To ensure correct upsert behavior, we first fetch existing (id, thread_id) pairs for the incoming messages.
1036
+ const existingResult = await this.db.query({
1037
+ query: `SELECT id, thread_id FROM ${TABLE_MESSAGES} WHERE id IN ({ids:Array(String)})`,
1038
+ query_params: {
1039
+ ids: messages.map(m => m.id),
1040
+ },
1041
+ clickhouse_settings: {
1042
+ // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z')
1043
+ date_time_input_format: 'best_effort',
1044
+ date_time_output_format: 'iso',
1045
+ use_client_time_zone: 1,
1046
+ output_format_json_quote_64bit_integers: 0,
1047
+ },
1048
+ format: 'JSONEachRow',
1049
+ });
1050
+ const existingRows: Array<{ id: string; thread_id: string }> = await existingResult.json();
1051
+
1052
+ const existingSet = new Set(existingRows.map(row => `${row.id}::${row.thread_id}`));
1053
+ // Partition the batch into new inserts and updates:
1054
+ // New messages are inserted in bulk.
1055
+ const toInsert = messages.filter(m => !existingSet.has(`${m.id}::${threadId}`));
1056
+ // Existing messages are updated via ALTER TABLE ... UPDATE.
1057
+ const toUpdate = messages.filter(m => existingSet.has(`${m.id}::${threadId}`));
1058
+ const updatePromises = toUpdate.map(message =>
1059
+ this.db.command({
1060
+ query: `
1061
+ ALTER TABLE ${TABLE_MESSAGES}
1062
+ UPDATE content = {var_content:String}, role = {var_role:String}, type = {var_type:String}
1063
+ WHERE id = {var_id:String} AND thread_id = {var_thread_id:String}
1064
+ `,
1065
+ query_params: {
1066
+ var_content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
1067
+ var_role: message.role,
1068
+ var_type: message.type || 'v2',
1069
+ var_id: message.id,
1070
+ var_thread_id: threadId,
1071
+ },
1072
+ clickhouse_settings: {
1073
+ // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z')
1074
+ date_time_input_format: 'best_effort',
1075
+ use_client_time_zone: 1,
1076
+ output_format_json_quote_64bit_integers: 0,
1077
+ },
1078
+ }),
1079
+ );
1080
+
1010
1081
  // Execute message inserts and thread update in parallel for better performance
1011
1082
  await Promise.all([
1012
1083
  // Insert messages
1013
1084
  this.db.insert({
1014
1085
  table: TABLE_MESSAGES,
1015
1086
  format: 'JSONEachRow',
1016
- values: messages.map(message => ({
1087
+ values: toInsert.map(message => ({
1017
1088
  id: message.id,
1018
1089
  thread_id: threadId,
1019
1090
  content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
@@ -1028,6 +1099,7 @@ export class ClickhouseStore extends MastraStorage {
1028
1099
  output_format_json_quote_64bit_integers: 0,
1029
1100
  },
1030
1101
  }),
1102
+ ...updatePromises,
1031
1103
  // Update thread's updatedAt timestamp
1032
1104
  this.db.insert({
1033
1105
  table: TABLE_THREADS,