@mastra/clickhouse 1.0.0-beta.8 → 1.0.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_SPANS, TABLE_RESOURCES, TABLE_SCORERS, TABLE_THREADS, TABLE_TRACES, TABLE_WORKFLOW_SNAPSHOT, TABLE_MESSAGES, MemoryStorage, TABLE_SCHEMAS, createStorageErrorId, normalizePerPage, calculatePagination, ObservabilityStorage, SPAN_SCHEMA, listTracesArgsSchema, ScoresStorage, transformScoreRow, SCORERS_SCHEMA, WorkflowsStorage, MastraStorage, TraceStatus, getSqlType, getDefaultValue, safelyParseJSON } from '@mastra/core/storage';
3
+ import { 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, ScoresStorage, transformScoreRow, SCORERS_SCHEMA, WorkflowsStorage, MastraCompositeStore, TraceStatus, getSqlType, getDefaultValue, safelyParseJSON } 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';
@@ -13,8 +13,10 @@ var TABLE_ENGINES = {
13
13
  [TABLE_THREADS]: `ReplacingMergeTree()`,
14
14
  [TABLE_SCORERS]: `MergeTree()`,
15
15
  [TABLE_RESOURCES]: `ReplacingMergeTree()`,
16
- // TODO: verify this is the correct engine for Spans when implementing clickhouse storage
17
- [TABLE_SPANS]: `ReplacingMergeTree()`,
16
+ // ReplacingMergeTree(updatedAt) deduplicates rows with the same (traceId, spanId) sorting key,
17
+ // keeping the row with the highest updatedAt value. Combined with ORDER BY (traceId, spanId),
18
+ // this provides eventual uniqueness for the (traceId, spanId) composite key.
19
+ [TABLE_SPANS]: `ReplacingMergeTree(updatedAt)`,
18
20
  mastra_agents: `ReplacingMergeTree()`
19
21
  };
20
22
  var COLUMN_TYPES = {
@@ -97,6 +99,213 @@ var ClickhouseDB = class extends MastraBase {
97
99
  const columns = await result.json();
98
100
  return columns.some((c) => c.name === column);
99
101
  }
102
+ /**
103
+ * Checks if a table exists in the database.
104
+ */
105
+ async tableExists(tableName) {
106
+ try {
107
+ const result = await this.client.query({
108
+ query: `EXISTS TABLE ${tableName}`,
109
+ format: "JSONEachRow"
110
+ });
111
+ const rows = await result.json();
112
+ return rows[0]?.result === 1;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+ /**
118
+ * Gets the sorting key (ORDER BY columns) for a table.
119
+ * Returns null if the table doesn't exist.
120
+ */
121
+ async getTableSortingKey(tableName) {
122
+ try {
123
+ const result = await this.client.query({
124
+ query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {tableName:String}`,
125
+ query_params: { tableName },
126
+ format: "JSONEachRow"
127
+ });
128
+ const rows = await result.json();
129
+ return rows[0]?.sorting_key ?? null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ /**
135
+ * Checks if migration is needed for the spans table.
136
+ * Returns information about the current state.
137
+ */
138
+ async checkSpansMigrationStatus(tableName) {
139
+ const exists = await this.tableExists(tableName);
140
+ if (!exists) {
141
+ return { needsMigration: false, currentSortingKey: null };
142
+ }
143
+ const currentSortingKey = await this.getTableSortingKey(tableName);
144
+ if (!currentSortingKey) {
145
+ return { needsMigration: false, currentSortingKey: null };
146
+ }
147
+ const needsMigration = currentSortingKey.toLowerCase().startsWith("createdat");
148
+ return { needsMigration, currentSortingKey };
149
+ }
150
+ /**
151
+ * Checks for duplicate (traceId, spanId) combinations in the spans table.
152
+ * Returns information about duplicates for logging/CLI purposes.
153
+ */
154
+ async checkForDuplicateSpans(tableName) {
155
+ try {
156
+ const result = await this.client.query({
157
+ query: `
158
+ SELECT count() as duplicate_count
159
+ FROM (
160
+ SELECT traceId, spanId
161
+ FROM ${tableName}
162
+ GROUP BY traceId, spanId
163
+ HAVING count() > 1
164
+ )
165
+ `,
166
+ format: "JSONEachRow"
167
+ });
168
+ const rows = await result.json();
169
+ const duplicateCount = parseInt(rows[0]?.duplicate_count ?? "0", 10);
170
+ return {
171
+ hasDuplicates: duplicateCount > 0,
172
+ duplicateCount
173
+ };
174
+ } catch (error) {
175
+ this.logger?.debug?.(`Could not check for duplicates: ${error}`);
176
+ return { hasDuplicates: false, duplicateCount: 0 };
177
+ }
178
+ }
179
+ /**
180
+ * Migrates the spans table from the old sorting key (createdAt, traceId, spanId)
181
+ * to the new sorting key (traceId, spanId) for proper uniqueness enforcement.
182
+ *
183
+ * This migration:
184
+ * 1. Renames the old table to a backup
185
+ * 2. Creates a new table with the correct sorting key
186
+ * 3. Copies all data from the backup to the new table, deduplicating by (traceId, spanId)
187
+ * using priority-based selection:
188
+ * - First, prefer completed spans (those with endedAt set)
189
+ * - Then prefer the most recently updated span (highest updatedAt)
190
+ * - Finally use creation time as tiebreaker (highest createdAt)
191
+ * 4. Drops the backup table
192
+ *
193
+ * The deduplication strategy matches the PostgreSQL migration (PR #12073) to ensure
194
+ * consistent behavior across storage backends.
195
+ *
196
+ * The migration is idempotent - it only runs if the old sorting key is detected.
197
+ *
198
+ * @returns true if migration was performed, false if not needed
199
+ */
200
+ async migrateSpansTableSortingKey({
201
+ tableName,
202
+ schema
203
+ }) {
204
+ if (tableName !== TABLE_SPANS) {
205
+ return false;
206
+ }
207
+ const exists = await this.tableExists(tableName);
208
+ if (!exists) {
209
+ return false;
210
+ }
211
+ const currentSortingKey = await this.getTableSortingKey(tableName);
212
+ if (!currentSortingKey) {
213
+ return false;
214
+ }
215
+ const needsMigration = currentSortingKey.toLowerCase().startsWith("createdat");
216
+ if (!needsMigration) {
217
+ this.logger?.debug?.(`Spans table already has correct sorting key: ${currentSortingKey}`);
218
+ return false;
219
+ }
220
+ this.logger?.info?.(`Migrating spans table from sorting key "${currentSortingKey}" to "(traceId, spanId)"`);
221
+ const backupTableName = `${tableName}_backup_${Date.now()}`;
222
+ const rowTtl = this.ttl?.[tableName]?.row;
223
+ try {
224
+ await this.client.command({
225
+ query: `RENAME TABLE ${tableName} TO ${backupTableName}`
226
+ });
227
+ const columns = Object.entries(schema).map(([name, def]) => {
228
+ let sqlType = this.getSqlType(def.type);
229
+ let isNullable = def.nullable === true;
230
+ if (tableName === TABLE_SPANS && name === "updatedAt") {
231
+ isNullable = false;
232
+ }
233
+ if (isNullable) {
234
+ sqlType = `Nullable(${sqlType})`;
235
+ }
236
+ const constraints = [];
237
+ if (name === "metadata" && (def.type === "text" || def.type === "jsonb") && isNullable) {
238
+ constraints.push("DEFAULT '{}'");
239
+ }
240
+ const columnTtl = this.ttl?.[tableName]?.columns?.[name];
241
+ return `"${name}" ${sqlType} ${constraints.join(" ")} ${columnTtl ? `TTL toDateTime(${columnTtl.ttlKey ?? "createdAt"}) + INTERVAL ${columnTtl.interval} ${columnTtl.unit}` : ""}`;
242
+ }).join(",\n");
243
+ const createSql = `
244
+ CREATE TABLE ${tableName} (
245
+ ${columns}
246
+ )
247
+ ENGINE = ${TABLE_ENGINES[tableName] ?? "MergeTree()"}
248
+ PRIMARY KEY (traceId, spanId)
249
+ ORDER BY (traceId, spanId)
250
+ ${rowTtl ? `TTL toDateTime(${rowTtl.ttlKey ?? "createdAt"}) + INTERVAL ${rowTtl.interval} ${rowTtl.unit}` : ""}
251
+ SETTINGS index_granularity = 8192
252
+ `;
253
+ await this.client.command({
254
+ query: createSql
255
+ });
256
+ const describeResult = await this.client.query({
257
+ query: `DESCRIBE TABLE ${backupTableName}`,
258
+ format: "JSONEachRow"
259
+ });
260
+ const backupColumns = await describeResult.json();
261
+ const backupColumnNames = new Set(backupColumns.map((c) => c.name));
262
+ const columnsToInsert = Object.keys(schema).filter((col) => backupColumnNames.has(col));
263
+ const columnList = columnsToInsert.map((c) => `"${c}"`).join(", ");
264
+ const selectExpressions = columnsToInsert.map((c) => c === "updatedAt" ? `COALESCE("updatedAt", "createdAt") as "updatedAt"` : `"${c}"`).join(", ");
265
+ await this.client.command({
266
+ query: `INSERT INTO ${tableName} (${columnList})
267
+ SELECT ${selectExpressions}
268
+ FROM ${backupTableName}
269
+ ORDER BY traceId, spanId,
270
+ (endedAt IS NOT NULL AND endedAt != '') DESC,
271
+ COALESCE(updatedAt, createdAt) DESC,
272
+ createdAt DESC
273
+ LIMIT 1 BY traceId, spanId`
274
+ });
275
+ await this.client.command({
276
+ query: `DROP TABLE ${backupTableName}`
277
+ });
278
+ this.logger?.info?.(`Successfully migrated spans table to new sorting key`);
279
+ return true;
280
+ } catch (error) {
281
+ this.logger?.error?.(`Migration failed: ${error.message}`);
282
+ try {
283
+ const originalExists = await this.tableExists(tableName);
284
+ const backupExists = await this.tableExists(backupTableName);
285
+ if (!originalExists && backupExists) {
286
+ this.logger?.info?.(`Restoring spans table from backup`);
287
+ await this.client.command({
288
+ query: `RENAME TABLE ${backupTableName} TO ${tableName}`
289
+ });
290
+ } else if (originalExists && backupExists) {
291
+ await this.client.command({
292
+ query: `DROP TABLE IF EXISTS ${backupTableName}`
293
+ });
294
+ }
295
+ } catch (restoreError) {
296
+ this.logger?.error?.(`Failed to restore from backup: ${restoreError}`);
297
+ }
298
+ throw new MastraError(
299
+ {
300
+ id: createStorageErrorId("CLICKHOUSE", "MIGRATE_SPANS_SORTING_KEY", "FAILED"),
301
+ domain: ErrorDomain.STORAGE,
302
+ category: ErrorCategory.THIRD_PARTY,
303
+ details: { tableName, currentSortingKey }
304
+ },
305
+ error
306
+ );
307
+ }
308
+ }
100
309
  getSqlType(type) {
101
310
  switch (type) {
102
311
  case "text":
@@ -123,12 +332,15 @@ var ClickhouseDB = class extends MastraBase {
123
332
  try {
124
333
  const columns = Object.entries(schema).map(([name, def]) => {
125
334
  let sqlType = this.getSqlType(def.type);
126
- const isNullable = def.nullable === true;
335
+ let isNullable = def.nullable === true;
336
+ if (tableName === TABLE_SPANS && name === "updatedAt") {
337
+ isNullable = false;
338
+ }
127
339
  if (isNullable) {
128
340
  sqlType = `Nullable(${sqlType})`;
129
341
  }
130
342
  const constraints = [];
131
- if (name === "metadata" && def.type === "text" && isNullable) {
343
+ if (name === "metadata" && (def.type === "text" || def.type === "jsonb") && isNullable) {
132
344
  constraints.push("DEFAULT '{}'");
133
345
  }
134
346
  const columnTtl = this.ttl?.[tableName]?.columns?.[name];
@@ -153,8 +365,8 @@ var ClickhouseDB = class extends MastraBase {
153
365
  ${columns}
154
366
  )
155
367
  ENGINE = ${TABLE_ENGINES[tableName] ?? "MergeTree()"}
156
- PRIMARY KEY (createdAt, traceId, spanId)
157
- ORDER BY (createdAt, traceId, spanId)
368
+ PRIMARY KEY (traceId, spanId)
369
+ ORDER BY (traceId, spanId)
158
370
  ${rowTtl ? `TTL toDateTime(${rowTtl.ttlKey ?? "createdAt"}) + INTERVAL ${rowTtl.interval} ${rowTtl.unit}` : ""}
159
371
  SETTINGS index_granularity = 8192
160
372
  `;
@@ -300,7 +512,9 @@ var ClickhouseDB = class extends MastraBase {
300
512
  ...Object.fromEntries(
301
513
  Object.entries(record).map(([key, value]) => [
302
514
  key,
303
- TABLE_SCHEMAS[tableName]?.[key]?.type === "timestamp" ? new Date(value).toISOString() : value
515
+ // Only convert to Date if it's a timestamp column AND value is not null/undefined
516
+ // new Date(null) returns epoch date, not null, so we must check first
517
+ TABLE_SCHEMAS[tableName]?.[key]?.type === "timestamp" && value != null ? new Date(value).toISOString() : value
304
518
  ])
305
519
  )
306
520
  }));
@@ -561,12 +775,14 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
561
775
  }
562
776
  if (filter?.dateRange?.start) {
563
777
  const startDate = filter.dateRange.start instanceof Date ? filter.dateRange.start.toISOString() : new Date(filter.dateRange.start).toISOString();
564
- dataQuery += ` AND createdAt >= parseDateTime64BestEffort({fromDate:String}, 3)`;
778
+ const startOp = filter.dateRange.startExclusive ? ">" : ">=";
779
+ dataQuery += ` AND createdAt ${startOp} parseDateTime64BestEffort({fromDate:String}, 3)`;
565
780
  dataParams.fromDate = startDate;
566
781
  }
567
782
  if (filter?.dateRange?.end) {
568
783
  const endDate = filter.dateRange.end instanceof Date ? filter.dateRange.end.toISOString() : new Date(filter.dateRange.end).toISOString();
569
- dataQuery += ` AND createdAt <= parseDateTime64BestEffort({toDate:String}, 3)`;
784
+ const endOp = filter.dateRange.endExclusive ? "<" : "<=";
785
+ dataQuery += ` AND createdAt ${endOp} parseDateTime64BestEffort({toDate:String}, 3)`;
570
786
  dataParams.toDate = endDate;
571
787
  }
572
788
  const { field, direction } = this.parseOrderBy(orderBy, "ASC");
@@ -600,12 +816,14 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
600
816
  }
601
817
  if (filter?.dateRange?.start) {
602
818
  const startDate = filter.dateRange.start instanceof Date ? filter.dateRange.start.toISOString() : new Date(filter.dateRange.start).toISOString();
603
- countQuery += ` AND createdAt >= parseDateTime64BestEffort({fromDate:String}, 3)`;
819
+ const startOp = filter.dateRange.startExclusive ? ">" : ">=";
820
+ countQuery += ` AND createdAt ${startOp} parseDateTime64BestEffort({fromDate:String}, 3)`;
604
821
  countParams.fromDate = startDate;
605
822
  }
606
823
  if (filter?.dateRange?.end) {
607
824
  const endDate = filter.dateRange.end instanceof Date ? filter.dateRange.end.toISOString() : new Date(filter.dateRange.end).toISOString();
608
- countQuery += ` AND createdAt <= parseDateTime64BestEffort({toDate:String}, 3)`;
825
+ const endOp = filter.dateRange.endExclusive ? "<" : "<=";
826
+ countQuery += ` AND createdAt ${endOp} parseDateTime64BestEffort({toDate:String}, 3)`;
609
827
  countParams.toDate = endDate;
610
828
  }
611
829
  const countResult = await this.client.query({
@@ -1062,26 +1280,68 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1062
1280
  );
1063
1281
  }
1064
1282
  }
1065
- async listThreadsByResourceId(args) {
1066
- const { resourceId, page = 0, perPage: perPageInput, orderBy } = args;
1283
+ async listThreads(args) {
1284
+ const { page = 0, perPage: perPageInput, orderBy, filter } = args;
1285
+ try {
1286
+ this.validatePaginationInput(page, perPageInput ?? 100);
1287
+ } catch (error) {
1288
+ throw new MastraError(
1289
+ {
1290
+ id: createStorageErrorId("CLICKHOUSE", "LIST_THREADS", "INVALID_PAGE"),
1291
+ domain: ErrorDomain.STORAGE,
1292
+ category: ErrorCategory.USER,
1293
+ details: { page, ...perPageInput !== void 0 && { perPage: perPageInput } }
1294
+ },
1295
+ error instanceof Error ? error : new Error("Invalid pagination parameters")
1296
+ );
1297
+ }
1067
1298
  const perPage = normalizePerPage(perPageInput, 100);
1068
- if (page < 0) {
1299
+ try {
1300
+ this.validateMetadataKeys(filter?.metadata);
1301
+ } catch (error) {
1069
1302
  throw new MastraError(
1070
1303
  {
1071
- id: createStorageErrorId("CLICKHOUSE", "LIST_THREADS_BY_RESOURCE_ID", "INVALID_PAGE"),
1304
+ id: createStorageErrorId("CLICKHOUSE", "LIST_THREADS", "INVALID_METADATA_KEY"),
1072
1305
  domain: ErrorDomain.STORAGE,
1073
1306
  category: ErrorCategory.USER,
1074
- details: { page }
1307
+ details: { metadataKeys: filter?.metadata ? Object.keys(filter.metadata).join(", ") : "" }
1075
1308
  },
1076
- new Error("page must be >= 0")
1309
+ error instanceof Error ? error : new Error("Invalid metadata key")
1077
1310
  );
1078
1311
  }
1079
1312
  const { offset, perPage: perPageForResponse } = calculatePagination(page, perPageInput, perPage);
1080
1313
  const { field, direction } = this.parseOrderBy(orderBy);
1081
1314
  try {
1315
+ const whereClauses = [];
1316
+ const queryParams = {};
1317
+ if (filter?.resourceId) {
1318
+ whereClauses.push("resourceId = {resourceId:String}");
1319
+ queryParams.resourceId = filter.resourceId;
1320
+ }
1321
+ if (filter?.metadata && Object.keys(filter.metadata).length > 0) {
1322
+ let metadataIndex = 0;
1323
+ for (const [key, value] of Object.entries(filter.metadata)) {
1324
+ const paramName = `metadata${metadataIndex}`;
1325
+ whereClauses.push(`JSONExtractRaw(metadata, '${key}') = {${paramName}:String}`);
1326
+ queryParams[paramName] = JSON.stringify(value);
1327
+ metadataIndex++;
1328
+ }
1329
+ }
1082
1330
  const countResult = await this.client.query({
1083
- query: `SELECT count(DISTINCT id) as total FROM ${TABLE_THREADS} WHERE resourceId = {resourceId:String}`,
1084
- query_params: { resourceId },
1331
+ query: `
1332
+ WITH ranked_threads AS (
1333
+ SELECT
1334
+ id,
1335
+ resourceId,
1336
+ metadata,
1337
+ ROW_NUMBER() OVER (PARTITION BY id ORDER BY updatedAt DESC) as row_num
1338
+ FROM ${TABLE_THREADS}
1339
+ )
1340
+ SELECT count(*) as total
1341
+ FROM ranked_threads
1342
+ WHERE row_num = 1 ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
1343
+ `,
1344
+ query_params: queryParams,
1085
1345
  clickhouse_settings: {
1086
1346
  date_time_input_format: "best_effort",
1087
1347
  date_time_output_format: "iso",
@@ -1112,7 +1372,6 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1112
1372
  toDateTime64(updatedAt, 3) as updatedAt,
1113
1373
  ROW_NUMBER() OVER (PARTITION BY id ORDER BY updatedAt DESC) as row_num
1114
1374
  FROM ${TABLE_THREADS}
1115
- WHERE resourceId = {resourceId:String}
1116
1375
  )
1117
1376
  SELECT
1118
1377
  id,
@@ -1122,12 +1381,12 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1122
1381
  createdAt,
1123
1382
  updatedAt
1124
1383
  FROM ranked_threads
1125
- WHERE row_num = 1
1384
+ WHERE row_num = 1 ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
1126
1385
  ORDER BY "${field}" ${direction === "DESC" ? "DESC" : "ASC"}
1127
1386
  LIMIT {perPage:Int64} OFFSET {offset:Int64}
1128
1387
  `,
1129
1388
  query_params: {
1130
- resourceId,
1389
+ ...queryParams,
1131
1390
  perPage,
1132
1391
  offset
1133
1392
  },
@@ -1153,10 +1412,14 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1153
1412
  } catch (error) {
1154
1413
  throw new MastraError(
1155
1414
  {
1156
- id: createStorageErrorId("CLICKHOUSE", "LIST_THREADS_BY_RESOURCE_ID", "FAILED"),
1415
+ id: createStorageErrorId("CLICKHOUSE", "LIST_THREADS", "FAILED"),
1157
1416
  domain: ErrorDomain.STORAGE,
1158
1417
  category: ErrorCategory.THIRD_PARTY,
1159
- details: { resourceId, page }
1418
+ details: {
1419
+ ...filter?.resourceId && { resourceId: filter.resourceId },
1420
+ hasMetadataFilter: !!filter?.metadata,
1421
+ page
1422
+ }
1160
1423
  },
1161
1424
  error
1162
1425
  );
@@ -1464,7 +1727,7 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1464
1727
  return {
1465
1728
  id: resource.id,
1466
1729
  workingMemory: resource.workingMemory && typeof resource.workingMemory === "object" ? JSON.stringify(resource.workingMemory) : resource.workingMemory,
1467
- metadata: resource.metadata && typeof resource.metadata === "string" ? JSON.parse(resource.metadata) : resource.metadata,
1730
+ metadata: parseMetadata(resource.metadata),
1468
1731
  createdAt: new Date(resource.createdAt),
1469
1732
  updatedAt: new Date(resource.updatedAt)
1470
1733
  };
@@ -1489,7 +1752,7 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1489
1752
  {
1490
1753
  id: resource.id,
1491
1754
  workingMemory: resource.workingMemory,
1492
- metadata: JSON.stringify(resource.metadata),
1755
+ metadata: serializeMetadata(resource.metadata),
1493
1756
  createdAt: resource.createdAt.toISOString(),
1494
1757
  updatedAt: resource.updatedAt.toISOString()
1495
1758
  }
@@ -1500,7 +1763,10 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1500
1763
  output_format_json_quote_64bit_integers: 0
1501
1764
  }
1502
1765
  });
1503
- return resource;
1766
+ return {
1767
+ ...resource,
1768
+ metadata: resource.metadata || {}
1769
+ };
1504
1770
  } catch (error) {
1505
1771
  throw new MastraError(
1506
1772
  {
@@ -1590,11 +1856,101 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
1590
1856
  this.#db = new ClickhouseDB({ client, ttl });
1591
1857
  }
1592
1858
  async init() {
1859
+ const migrationStatus = await this.#db.checkSpansMigrationStatus(TABLE_SPANS);
1860
+ if (migrationStatus.needsMigration) {
1861
+ const duplicateInfo = await this.#db.checkForDuplicateSpans(TABLE_SPANS);
1862
+ const duplicateMessage = duplicateInfo.hasDuplicates ? `
1863
+ Found ${duplicateInfo.duplicateCount} duplicate (traceId, spanId) combinations that will be removed.
1864
+ ` : "";
1865
+ const errorMessage = `
1866
+ ===========================================================================
1867
+ MIGRATION REQUIRED: ClickHouse spans table needs sorting key update
1868
+ ===========================================================================
1869
+
1870
+ The spans table structure has changed. ClickHouse requires a table recreation
1871
+ to update the sorting key from (traceId) to (traceId, spanId).
1872
+ ` + duplicateMessage + `
1873
+ To fix this, run the manual migration command:
1874
+
1875
+ npx mastra migrate
1876
+
1877
+ This command will:
1878
+ 1. Create a new table with the correct sorting key
1879
+ 2. Copy data from the old table (deduplicating if needed)
1880
+ 3. Replace the old table with the new one
1881
+
1882
+ WARNING: This migration involves table recreation and may take significant
1883
+ time for large tables. Please ensure you have a backup before proceeding.
1884
+ ===========================================================================
1885
+ `;
1886
+ throw new MastraError({
1887
+ id: createStorageErrorId("CLICKHOUSE", "MIGRATION_REQUIRED", "SORTING_KEY_CHANGE"),
1888
+ domain: ErrorDomain.STORAGE,
1889
+ category: ErrorCategory.USER,
1890
+ text: errorMessage
1891
+ });
1892
+ }
1593
1893
  await this.#db.createTable({ tableName: TABLE_SPANS, schema: SPAN_SCHEMA });
1594
1894
  }
1595
1895
  async dangerouslyClearAll() {
1596
1896
  await this.#db.clearTable({ tableName: TABLE_SPANS });
1597
1897
  }
1898
+ /**
1899
+ * Manually run the spans migration to deduplicate and update the sorting key.
1900
+ * This is intended to be called from the CLI when duplicates are detected.
1901
+ *
1902
+ * @returns Migration result with status and details
1903
+ */
1904
+ async migrateSpans() {
1905
+ const migrationStatus = await this.#db.checkSpansMigrationStatus(TABLE_SPANS);
1906
+ if (!migrationStatus.needsMigration) {
1907
+ return {
1908
+ success: true,
1909
+ alreadyMigrated: true,
1910
+ duplicatesRemoved: 0,
1911
+ message: `Migration already complete. Spans table has correct sorting key.`
1912
+ };
1913
+ }
1914
+ const duplicateInfo = await this.#db.checkForDuplicateSpans(TABLE_SPANS);
1915
+ if (duplicateInfo.hasDuplicates) {
1916
+ this.logger?.info?.(
1917
+ `Found ${duplicateInfo.duplicateCount} duplicate (traceId, spanId) combinations. Starting migration with deduplication...`
1918
+ );
1919
+ } else {
1920
+ this.logger?.info?.(`No duplicate spans found. Starting sorting key migration...`);
1921
+ }
1922
+ await this.#db.migrateSpansTableSortingKey({ tableName: TABLE_SPANS, schema: SPAN_SCHEMA });
1923
+ return {
1924
+ success: true,
1925
+ alreadyMigrated: false,
1926
+ duplicatesRemoved: duplicateInfo.duplicateCount,
1927
+ message: duplicateInfo.hasDuplicates ? `Migration complete. Removed duplicates and updated sorting key for ${TABLE_SPANS}.` : `Migration complete. Updated sorting key for ${TABLE_SPANS}.`
1928
+ };
1929
+ }
1930
+ /**
1931
+ * Check migration status for the spans table.
1932
+ * Returns information about whether migration is needed.
1933
+ */
1934
+ async checkSpansMigrationStatus() {
1935
+ const migrationStatus = await this.#db.checkSpansMigrationStatus(TABLE_SPANS);
1936
+ if (!migrationStatus.needsMigration) {
1937
+ return {
1938
+ needsMigration: false,
1939
+ hasDuplicates: false,
1940
+ duplicateCount: 0,
1941
+ constraintExists: true,
1942
+ tableName: TABLE_SPANS
1943
+ };
1944
+ }
1945
+ const duplicateInfo = await this.#db.checkForDuplicateSpans(TABLE_SPANS);
1946
+ return {
1947
+ needsMigration: true,
1948
+ hasDuplicates: duplicateInfo.hasDuplicates,
1949
+ duplicateCount: duplicateInfo.duplicateCount,
1950
+ constraintExists: false,
1951
+ tableName: TABLE_SPANS
1952
+ };
1953
+ }
1598
1954
  get tracingStrategy() {
1599
1955
  return {
1600
1956
  preferred: "insert-only",
@@ -1928,10 +2284,10 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
1928
2284
  conditions.push(`(error IS NOT NULL AND error != '')`);
1929
2285
  break;
1930
2286
  case TraceStatus.RUNNING:
1931
- conditions.push(`(endedAt IS NULL OR endedAt = '') AND (error IS NULL OR error = '')`);
2287
+ conditions.push(`endedAt IS NULL AND (error IS NULL OR error = '')`);
1932
2288
  break;
1933
2289
  case TraceStatus.SUCCESS:
1934
- conditions.push(`(endedAt IS NOT NULL AND endedAt != '') AND (error IS NULL OR error = '')`);
2290
+ conditions.push(`endedAt IS NOT NULL AND (error IS NULL OR error = '')`);
1935
2291
  break;
1936
2292
  }
1937
2293
  }
@@ -1960,7 +2316,7 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
1960
2316
  if (sortField === "endedAt") {
1961
2317
  const nullSortValue = sortDirection === "DESC" ? 0 : 1;
1962
2318
  const nonNullSortValue = sortDirection === "DESC" ? 1 : 0;
1963
- orderClause = `ORDER BY CASE WHEN ${sortField} IS NULL OR ${sortField} = '' THEN ${nullSortValue} ELSE ${nonNullSortValue} END, ${sortField} ${sortDirection}`;
2319
+ orderClause = `ORDER BY CASE WHEN ${sortField} IS NULL THEN ${nullSortValue} ELSE ${nonNullSortValue} END, ${sortField} ${sortDirection}`;
1964
2320
  } else {
1965
2321
  orderClause = `ORDER BY ${sortField} ${sortDirection}`;
1966
2322
  }
@@ -2688,7 +3044,7 @@ var WorkflowsStorageClickhouse = class extends WorkflowsStorage {
2688
3044
  try {
2689
3045
  parsedSnapshot = JSON.parse(row.snapshot);
2690
3046
  } catch (e) {
2691
- console.warn(`Failed to parse snapshot for workflow ${row.workflow_name}: ${e}`);
3047
+ this.logger.warn(`Failed to parse snapshot for workflow ${row.workflow_name}: ${e}`);
2692
3048
  }
2693
3049
  }
2694
3050
  return {
@@ -2726,7 +3082,7 @@ var WorkflowsStorageClickhouse = class extends WorkflowsStorage {
2726
3082
  conditions.push(`resourceId = {var_resourceId:String}`);
2727
3083
  values.var_resourceId = resourceId;
2728
3084
  } else {
2729
- console.warn(`[${TABLE_WORKFLOW_SNAPSHOT}] resourceId column not found. Skipping resourceId filter.`);
3085
+ this.logger.warn(`[${TABLE_WORKFLOW_SNAPSHOT}] resourceId column not found. Skipping resourceId filter.`);
2730
3086
  }
2731
3087
  }
2732
3088
  if (fromDate) {
@@ -2866,7 +3222,7 @@ var WorkflowsStorageClickhouse = class extends WorkflowsStorage {
2866
3222
  var isClientConfig = (config) => {
2867
3223
  return "client" in config;
2868
3224
  };
2869
- var ClickhouseStore = class extends MastraStorage {
3225
+ var ClickhouseStore = class extends MastraCompositeStore {
2870
3226
  db;
2871
3227
  ttl = {};
2872
3228
  stores;
@@ -2884,11 +3240,11 @@ var ClickhouseStore = class extends MastraStorage {
2884
3240
  if (typeof config.password !== "string") {
2885
3241
  throw new Error("ClickhouseStore: password must be a string.");
2886
3242
  }
3243
+ const { id, ttl, disableInit, clickhouse_settings, ...clientOptions } = config;
2887
3244
  this.db = createClient({
2888
- url: config.url,
2889
- username: config.username,
2890
- password: config.password,
3245
+ ...clientOptions,
2891
3246
  clickhouse_settings: {
3247
+ ...clickhouse_settings,
2892
3248
  date_time_input_format: "best_effort",
2893
3249
  date_time_output_format: "iso",
2894
3250
  // This is crucial