@mastra/clickhouse 1.2.3 → 1.3.0

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createClient } from '@clickhouse/client';
2
2
  import { MastraError, ErrorCategory, ErrorDomain } from '@mastra/core/error';
3
- import { TABLE_SKILL_BLOBS, TABLE_SKILL_VERSIONS, TABLE_SKILLS, TABLE_WORKSPACE_VERSIONS, TABLE_WORKSPACES, TABLE_MCP_SERVER_VERSIONS, TABLE_MCP_SERVERS, TABLE_MCP_CLIENT_VERSIONS, TABLE_MCP_CLIENTS, TABLE_SCORER_DEFINITION_VERSIONS, TABLE_SCORER_DEFINITIONS, TABLE_PROMPT_BLOCK_VERSIONS, TABLE_PROMPT_BLOCKS, TABLE_EXPERIMENT_RESULTS, TABLE_EXPERIMENTS, TABLE_DATASET_VERSIONS, TABLE_DATASET_ITEMS, TABLE_DATASETS, TABLE_AGENT_VERSIONS, TABLE_SPANS, TABLE_RESOURCES, TABLE_SCORERS, TABLE_THREADS, TABLE_TRACES, TABLE_WORKFLOW_SNAPSHOT, TABLE_MESSAGES, MemoryStorage, TABLE_SCHEMAS, createStorageErrorId, normalizePerPage, calculatePagination, ObservabilityStorage, SPAN_SCHEMA, listTracesArgsSchema, toTraceSpans, ScoresStorage, transformScoreRow, SCORERS_SCHEMA, WorkflowsStorage, MastraCompositeStore, TraceStatus, getSqlType, getDefaultValue, safelyParseJSON } from '@mastra/core/storage';
3
+ import { TABLE_SKILL_BLOBS, TABLE_SKILL_VERSIONS, TABLE_SKILLS, TABLE_WORKSPACE_VERSIONS, TABLE_WORKSPACES, TABLE_MCP_SERVER_VERSIONS, TABLE_MCP_SERVERS, TABLE_MCP_CLIENT_VERSIONS, TABLE_MCP_CLIENTS, TABLE_SCORER_DEFINITION_VERSIONS, TABLE_SCORER_DEFINITIONS, TABLE_PROMPT_BLOCK_VERSIONS, TABLE_PROMPT_BLOCKS, TABLE_EXPERIMENT_RESULTS, TABLE_EXPERIMENTS, TABLE_DATASET_VERSIONS, TABLE_DATASET_ITEMS, TABLE_DATASETS, TABLE_AGENT_VERSIONS, TABLE_SPANS, TABLE_RESOURCES, TABLE_SCORERS, TABLE_THREADS, TABLE_TRACES, TABLE_WORKFLOW_SNAPSHOT, TABLE_MESSAGES, MemoryStorage, TABLE_SCHEMAS, createStorageErrorId, normalizePerPage, calculatePagination, ObservabilityStorage, SPAN_SCHEMA, listTracesArgsSchema, toTraceSpans, ScoresStorage, transformScoreRow, SCORERS_SCHEMA, WorkflowsStorage, MastraCompositeStore, getSqlType, getDefaultValue, safelyParseJSON, TraceStatus } from '@mastra/core/storage';
4
4
  import { MessageList } from '@mastra/core/agent';
5
5
  import { MastraBase } from '@mastra/core/base';
6
6
  import { saveScorePayloadSchema } from '@mastra/core/evals';
@@ -103,6 +103,8 @@ function resolveClickhouseConfig(config) {
103
103
  var ClickhouseDB = class extends MastraBase {
104
104
  ttl;
105
105
  client;
106
+ /** Cache of actual table columns: tableName -> Promise<Set<columnName>> (stores in-flight promise to coalesce concurrent calls) */
107
+ tableColumnsCache = /* @__PURE__ */ new Map();
106
108
  constructor({ client, ttl }) {
107
109
  super({
108
110
  name: "CLICKHOUSE_DB"
@@ -110,6 +112,48 @@ var ClickhouseDB = class extends MastraBase {
110
112
  this.ttl = ttl;
111
113
  this.client = client;
112
114
  }
115
+ /**
116
+ * Gets the set of column names that actually exist in the database table.
117
+ * Results are cached; the cache is invalidated when alterTable() adds new columns.
118
+ */
119
+ async getTableColumns(tableName) {
120
+ const cached = this.tableColumnsCache.get(tableName);
121
+ if (cached) return cached;
122
+ const promise = (async () => {
123
+ try {
124
+ const result = await this.client.query({
125
+ query: `DESCRIBE TABLE ${tableName}`,
126
+ format: "JSONEachRow"
127
+ });
128
+ const rows = await result.json();
129
+ const columns = new Set(rows.map((r) => r.name));
130
+ if (columns.size === 0) {
131
+ this.tableColumnsCache.delete(tableName);
132
+ }
133
+ return columns;
134
+ } catch {
135
+ this.tableColumnsCache.delete(tableName);
136
+ return /* @__PURE__ */ new Set();
137
+ }
138
+ })();
139
+ this.tableColumnsCache.set(tableName, promise);
140
+ return promise;
141
+ }
142
+ /**
143
+ * Filters a record to only include columns that exist in the actual database table.
144
+ * Unknown columns are silently dropped to ensure forward compatibility.
145
+ */
146
+ async filterRecordToKnownColumns(tableName, record) {
147
+ const knownColumns = await this.getTableColumns(tableName);
148
+ if (knownColumns.size === 0) return record;
149
+ const filtered = {};
150
+ for (const [key, value] of Object.entries(record)) {
151
+ if (knownColumns.has(key)) {
152
+ filtered[key] = value;
153
+ }
154
+ }
155
+ return filtered;
156
+ }
113
157
  async hasColumn(table, column) {
114
158
  const result = await this.client.query({
115
159
  query: `DESCRIBE TABLE ${table}`,
@@ -421,6 +465,8 @@ var ClickhouseDB = class extends MastraBase {
421
465
  },
422
466
  error
423
467
  );
468
+ } finally {
469
+ this.tableColumnsCache.delete(tableName);
424
470
  }
425
471
  }
426
472
  async alterTable({
@@ -460,6 +506,8 @@ var ClickhouseDB = class extends MastraBase {
460
506
  },
461
507
  error
462
508
  );
509
+ } finally {
510
+ this.tableColumnsCache.delete(tableName);
463
511
  }
464
512
  }
465
513
  async clearTable({ tableName }) {
@@ -487,25 +535,39 @@ var ClickhouseDB = class extends MastraBase {
487
535
  }
488
536
  }
489
537
  async dropTable({ tableName }) {
490
- await this.client.query({
491
- query: `DROP TABLE IF EXISTS ${tableName}`
492
- });
538
+ try {
539
+ await this.client.query({
540
+ query: `DROP TABLE IF EXISTS ${tableName}`
541
+ });
542
+ } catch (error) {
543
+ throw new MastraError(
544
+ {
545
+ id: createStorageErrorId("CLICKHOUSE", "DROP_TABLE", "FAILED"),
546
+ domain: ErrorDomain.STORAGE,
547
+ category: ErrorCategory.THIRD_PARTY,
548
+ details: { tableName }
549
+ },
550
+ error
551
+ );
552
+ } finally {
553
+ this.tableColumnsCache.delete(tableName);
554
+ }
493
555
  }
494
556
  async insert({ tableName, record }) {
495
- const rawCreatedAt = record.createdAt || record.created_at || /* @__PURE__ */ new Date();
496
- const rawUpdatedAt = record.updatedAt || /* @__PURE__ */ new Date();
497
- const createdAt = rawCreatedAt instanceof Date ? rawCreatedAt.toISOString() : rawCreatedAt;
498
- const updatedAt = rawUpdatedAt instanceof Date ? rawUpdatedAt.toISOString() : rawUpdatedAt;
499
557
  try {
558
+ const filteredRecord = await this.filterRecordToKnownColumns(tableName, record);
559
+ if (Object.keys(filteredRecord).length === 0) return;
560
+ const rawCreatedAt = filteredRecord.createdAt || filteredRecord.created_at || /* @__PURE__ */ new Date();
561
+ const rawUpdatedAt = filteredRecord.updatedAt || /* @__PURE__ */ new Date();
562
+ if ("createdAt" in filteredRecord || (await this.getTableColumns(tableName)).has("createdAt")) {
563
+ filteredRecord.createdAt = rawCreatedAt instanceof Date ? rawCreatedAt.toISOString() : rawCreatedAt;
564
+ }
565
+ if ("updatedAt" in filteredRecord || (await this.getTableColumns(tableName)).has("updatedAt")) {
566
+ filteredRecord.updatedAt = rawUpdatedAt instanceof Date ? rawUpdatedAt.toISOString() : rawUpdatedAt;
567
+ }
500
568
  await this.client.insert({
501
569
  table: tableName,
502
- values: [
503
- {
504
- ...record,
505
- createdAt,
506
- updatedAt
507
- }
508
- ],
570
+ values: [filteredRecord],
509
571
  format: "JSONEachRow",
510
572
  clickhouse_settings: {
511
573
  // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z')
@@ -527,7 +589,7 @@ var ClickhouseDB = class extends MastraBase {
527
589
  }
528
590
  }
529
591
  async batchInsert({ tableName, records }) {
530
- const recordsToBeInserted = records.map((record) => ({
592
+ const processedRecords = records.map((record) => ({
531
593
  ...Object.fromEntries(
532
594
  Object.entries(record).map(([key, value]) => [
533
595
  key,
@@ -537,10 +599,15 @@ var ClickhouseDB = class extends MastraBase {
537
599
  ])
538
600
  )
539
601
  }));
602
+ const recordsToBeInserted = await Promise.all(
603
+ processedRecords.map((r) => this.filterRecordToKnownColumns(tableName, r))
604
+ );
605
+ const nonEmptyRecords = recordsToBeInserted.filter((r) => Object.keys(r).length > 0);
606
+ if (nonEmptyRecords.length === 0) return;
540
607
  try {
541
608
  await this.client.insert({
542
609
  table: tableName,
543
- values: recordsToBeInserted,
610
+ values: nonEmptyRecords,
544
611
  format: "JSONEachRow",
545
612
  clickhouse_settings: {
546
613
  // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z')
@@ -806,6 +873,20 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
806
873
  }
807
874
  const { field, direction } = this.parseOrderBy(orderBy, "ASC");
808
875
  dataQuery += ` ORDER BY "${field}" ${direction}`;
876
+ if (perPageForQuery === 0 && (!include || include.length === 0)) {
877
+ return { messages: [], total: 0, page, perPage: perPageForResponse, hasMore: false };
878
+ }
879
+ if (perPageForQuery === 0 && include && include.length > 0) {
880
+ const includeResult = await this._getIncludedMessages({ include });
881
+ const list2 = new MessageList().add(includeResult, "memory");
882
+ return {
883
+ messages: this._sortMessages(list2.get.all.db(), field, direction),
884
+ total: 0,
885
+ page,
886
+ perPage: perPageForResponse,
887
+ hasMore: false
888
+ };
889
+ }
809
890
  if (perPageForResponse === false) ; else {
810
891
  dataQuery += ` LIMIT {limit:Int64} OFFSET {offset:Int64}`;
811
892
  dataParams.limit = perPageForQuery;
@@ -867,92 +948,17 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
867
948
  };
868
949
  }
869
950
  const messageIds = new Set(paginatedMessages.map((m) => m.id));
870
- let includeMessages = [];
871
951
  if (include && include.length > 0) {
872
- const includesNeedingThread = include.filter((inc) => !inc.threadId);
873
- const threadByMessageId = /* @__PURE__ */ new Map();
874
- if (includesNeedingThread.length > 0) {
875
- const { messages: includeLookup } = await this.listMessagesById({
876
- messageIds: includesNeedingThread.map((inc) => inc.id)
877
- });
878
- for (const msg of includeLookup) {
879
- if (msg.threadId) {
880
- threadByMessageId.set(msg.id, msg.threadId);
881
- }
882
- }
883
- }
884
- const unionQueries = [];
885
- const params = [];
886
- let paramIdx = 1;
887
- for (const inc of include) {
888
- const { id, withPreviousMessages = 0, withNextMessages = 0 } = inc;
889
- const searchThreadId = inc.threadId ?? threadByMessageId.get(id);
890
- if (!searchThreadId) continue;
891
- unionQueries.push(`
892
- SELECT * FROM (
893
- WITH numbered_messages AS (
894
- SELECT
895
- id, content, role, type, "createdAt", thread_id, "resourceId",
896
- ROW_NUMBER() OVER (ORDER BY "createdAt" ASC) as row_num
897
- FROM "${TABLE_MESSAGES}"
898
- WHERE thread_id = {var_thread_id_${paramIdx}:String}
899
- ),
900
- target_positions AS (
901
- SELECT row_num as target_pos
902
- FROM numbered_messages
903
- WHERE id = {var_include_id_${paramIdx}:String}
904
- )
905
- SELECT DISTINCT m.id, m.content, m.role, m.type, m."createdAt", m.thread_id AS "threadId", m."resourceId"
906
- FROM numbered_messages m
907
- CROSS JOIN target_positions t
908
- WHERE m.row_num BETWEEN (t.target_pos - {var_withPreviousMessages_${paramIdx}:Int64}) AND (t.target_pos + {var_withNextMessages_${paramIdx}:Int64})
909
- ) AS query_${paramIdx}
910
- `);
911
- params.push(
912
- { [`var_thread_id_${paramIdx}`]: searchThreadId },
913
- { [`var_include_id_${paramIdx}`]: id },
914
- { [`var_withPreviousMessages_${paramIdx}`]: withPreviousMessages },
915
- { [`var_withNextMessages_${paramIdx}`]: withNextMessages }
916
- );
917
- paramIdx++;
918
- }
919
- if (unionQueries.length > 0) {
920
- const finalQuery = unionQueries.join(" UNION ALL ") + ' ORDER BY "createdAt" ASC';
921
- const mergedParams = params.reduce((acc, paramObj) => ({ ...acc, ...paramObj }), {});
922
- const includeResult = await this.client.query({
923
- query: finalQuery,
924
- query_params: mergedParams,
925
- clickhouse_settings: {
926
- date_time_input_format: "best_effort",
927
- date_time_output_format: "iso",
928
- use_client_time_zone: 1,
929
- output_format_json_quote_64bit_integers: 0
930
- }
931
- });
932
- const includeRows = await includeResult.json();
933
- includeMessages = transformRows(includeRows.data);
934
- for (const includeMsg of includeMessages) {
935
- if (!messageIds.has(includeMsg.id)) {
936
- paginatedMessages.push(includeMsg);
937
- messageIds.add(includeMsg.id);
938
- }
952
+ const includeMessages = await this._getIncludedMessages({ include });
953
+ for (const includeMsg of includeMessages) {
954
+ if (!messageIds.has(includeMsg.id)) {
955
+ paginatedMessages.push(includeMsg);
956
+ messageIds.add(includeMsg.id);
939
957
  }
940
958
  }
941
959
  }
942
960
  const list = new MessageList().add(paginatedMessages, "memory");
943
- let finalMessages = list.get.all.db();
944
- finalMessages = finalMessages.sort((a, b) => {
945
- const isDateField = field === "createdAt" || field === "updatedAt";
946
- const aValue = isDateField ? new Date(a[field]).getTime() : a[field];
947
- const bValue = isDateField ? new Date(b[field]).getTime() : b[field];
948
- if (aValue === bValue) {
949
- return a.id.localeCompare(b.id);
950
- }
951
- if (typeof aValue === "number" && typeof bValue === "number") {
952
- return direction === "ASC" ? aValue - bValue : bValue - aValue;
953
- }
954
- return direction === "ASC" ? String(aValue).localeCompare(String(bValue)) : String(bValue).localeCompare(String(aValue));
955
- });
961
+ const finalMessages = this._sortMessages(list.get.all.db(), field, direction);
956
962
  const threadIdSet = new Set(threadIds);
957
963
  const returnedThreadMessageIds = new Set(
958
964
  finalMessages.filter((m) => m.threadId && threadIdSet.has(m.threadId)).map((m) => m.id)
@@ -990,6 +996,95 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
990
996
  };
991
997
  }
992
998
  }
999
+ _sortMessages(messages, field, direction) {
1000
+ return messages.sort((a, b) => {
1001
+ const isDateField = field === "createdAt" || field === "updatedAt";
1002
+ const aValue = isDateField ? new Date(a[field]).getTime() : a[field];
1003
+ const bValue = isDateField ? new Date(b[field]).getTime() : b[field];
1004
+ if (aValue === bValue) {
1005
+ return a.id.localeCompare(b.id);
1006
+ }
1007
+ if (typeof aValue === "number" && typeof bValue === "number") {
1008
+ return direction === "ASC" ? aValue - bValue : bValue - aValue;
1009
+ }
1010
+ return direction === "ASC" ? String(aValue).localeCompare(String(bValue)) : String(bValue).localeCompare(String(aValue));
1011
+ });
1012
+ }
1013
+ async _getIncludedMessages({
1014
+ include
1015
+ }) {
1016
+ if (!include || include.length === 0) return [];
1017
+ const targetIds = include.map((inc) => inc.id).filter(Boolean);
1018
+ if (targetIds.length === 0) return [];
1019
+ const { messages: targetDocs } = await this.listMessagesById({ messageIds: targetIds });
1020
+ const targetMap = new Map(
1021
+ targetDocs.map((msg) => [msg.id, { threadId: msg.threadId, createdAt: msg.createdAt }])
1022
+ );
1023
+ const unionQueries = [];
1024
+ const params = {};
1025
+ let paramIdx = 1;
1026
+ for (const inc of include) {
1027
+ const { id, withPreviousMessages = 0, withNextMessages = 0 } = inc;
1028
+ const target = targetMap.get(id);
1029
+ if (!target) continue;
1030
+ const threadParam = `var_thread_${paramIdx}`;
1031
+ const createdAtParam = `var_createdAt_${paramIdx}`;
1032
+ const limitParam = `var_limit_${paramIdx}`;
1033
+ unionQueries.push(`
1034
+ SELECT id, content, role, type, "createdAt", thread_id AS "threadId", "resourceId"
1035
+ FROM "${TABLE_MESSAGES}"
1036
+ WHERE thread_id = {${threadParam}:String}
1037
+ AND createdAt <= parseDateTime64BestEffort({${createdAtParam}:String}, 3)
1038
+ ORDER BY createdAt DESC, id DESC
1039
+ LIMIT {${limitParam}:Int64}
1040
+ `);
1041
+ params[threadParam] = target.threadId;
1042
+ params[createdAtParam] = target.createdAt;
1043
+ params[limitParam] = withPreviousMessages + 1;
1044
+ paramIdx++;
1045
+ if (withNextMessages > 0) {
1046
+ const threadParam2 = `var_thread_${paramIdx}`;
1047
+ const createdAtParam2 = `var_createdAt_${paramIdx}`;
1048
+ const limitParam2 = `var_limit_${paramIdx}`;
1049
+ unionQueries.push(`
1050
+ SELECT id, content, role, type, "createdAt", thread_id AS "threadId", "resourceId"
1051
+ FROM "${TABLE_MESSAGES}"
1052
+ WHERE thread_id = {${threadParam2}:String}
1053
+ AND createdAt > parseDateTime64BestEffort({${createdAtParam2}:String}, 3)
1054
+ ORDER BY createdAt ASC, id ASC
1055
+ LIMIT {${limitParam2}:Int64}
1056
+ `);
1057
+ params[threadParam2] = target.threadId;
1058
+ params[createdAtParam2] = target.createdAt;
1059
+ params[limitParam2] = withNextMessages;
1060
+ paramIdx++;
1061
+ }
1062
+ }
1063
+ if (unionQueries.length === 0) return [];
1064
+ let finalQuery;
1065
+ if (unionQueries.length === 1) {
1066
+ finalQuery = unionQueries[0];
1067
+ } else {
1068
+ finalQuery = `SELECT * FROM (${unionQueries.join(" UNION ALL ")}) ORDER BY "createdAt" ASC, id ASC`;
1069
+ }
1070
+ const includeResult = await this.client.query({
1071
+ query: finalQuery,
1072
+ query_params: params,
1073
+ clickhouse_settings: {
1074
+ date_time_input_format: "best_effort",
1075
+ date_time_output_format: "iso",
1076
+ use_client_time_zone: 1,
1077
+ output_format_json_quote_64bit_integers: 0
1078
+ }
1079
+ });
1080
+ const includeRows = await includeResult.json();
1081
+ const seen = /* @__PURE__ */ new Set();
1082
+ return transformRows(includeRows.data).filter((row) => {
1083
+ if (seen.has(row.id)) return false;
1084
+ seen.add(row.id);
1085
+ return true;
1086
+ });
1087
+ }
993
1088
  async saveMessages(args) {
994
1089
  const { messages } = args;
995
1090
  if (messages.length === 0) return { messages };
@@ -1910,6 +2005,11 @@ time for large tables. Please ensure you have a backup before proceeding.
1910
2005
  });
1911
2006
  }
1912
2007
  await this.#db.createTable({ tableName: TABLE_SPANS, schema: SPAN_SCHEMA });
2008
+ await this.#db.alterTable({
2009
+ tableName: TABLE_SPANS,
2010
+ schema: SPAN_SCHEMA,
2011
+ ifNotExists: ["requestContext"]
2012
+ });
1913
2013
  }
1914
2014
  async dangerouslyClearAll() {
1915
2015
  await this.#db.clearTable({ tableName: TABLE_SPANS });
@@ -2182,7 +2282,8 @@ time for large tables. Please ensure you have a backup before proceeding.
2182
2282
  }
2183
2283
  async listTraces(args) {
2184
2284
  const { filters, pagination, orderBy } = listTracesArgsSchema.parse(args);
2185
- const { page, perPage } = pagination;
2285
+ const page = pagination?.page ?? 0;
2286
+ const perPage = pagination?.perPage ?? 10;
2186
2287
  try {
2187
2288
  const conditions = [`(parentSpanId IS NULL OR parentSpanId = '')`];
2188
2289
  const values = {};
@@ -2329,8 +2430,8 @@ time for large tables. Please ensure you have a backup before proceeding.
2329
2430
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2330
2431
  const engine = TABLE_ENGINES[TABLE_SPANS] ?? "MergeTree()";
2331
2432
  const finalClause = engine.startsWith("ReplacingMergeTree") ? "FINAL" : "";
2332
- const sortField = orderBy.field;
2333
- const sortDirection = orderBy.direction;
2433
+ const sortField = orderBy?.field ?? "startedAt";
2434
+ const sortDirection = orderBy?.direction ?? "DESC";
2334
2435
  let orderClause;
2335
2436
  if (sortField === "endedAt") {
2336
2437
  const nullSortValue = sortDirection === "DESC" ? 0 : 1;