@mastra/clickhouse 1.0.0-beta.9 → 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
  }));
@@ -1066,26 +1280,68 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1066
1280
  );
1067
1281
  }
1068
1282
  }
1069
- async listThreadsByResourceId(args) {
1070
- 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
+ }
1071
1298
  const perPage = normalizePerPage(perPageInput, 100);
1072
- if (page < 0) {
1299
+ try {
1300
+ this.validateMetadataKeys(filter?.metadata);
1301
+ } catch (error) {
1073
1302
  throw new MastraError(
1074
1303
  {
1075
- id: createStorageErrorId("CLICKHOUSE", "LIST_THREADS_BY_RESOURCE_ID", "INVALID_PAGE"),
1304
+ id: createStorageErrorId("CLICKHOUSE", "LIST_THREADS", "INVALID_METADATA_KEY"),
1076
1305
  domain: ErrorDomain.STORAGE,
1077
1306
  category: ErrorCategory.USER,
1078
- details: { page }
1307
+ details: { metadataKeys: filter?.metadata ? Object.keys(filter.metadata).join(", ") : "" }
1079
1308
  },
1080
- new Error("page must be >= 0")
1309
+ error instanceof Error ? error : new Error("Invalid metadata key")
1081
1310
  );
1082
1311
  }
1083
1312
  const { offset, perPage: perPageForResponse } = calculatePagination(page, perPageInput, perPage);
1084
1313
  const { field, direction } = this.parseOrderBy(orderBy);
1085
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
+ }
1086
1330
  const countResult = await this.client.query({
1087
- query: `SELECT count(DISTINCT id) as total FROM ${TABLE_THREADS} WHERE resourceId = {resourceId:String}`,
1088
- 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,
1089
1345
  clickhouse_settings: {
1090
1346
  date_time_input_format: "best_effort",
1091
1347
  date_time_output_format: "iso",
@@ -1116,7 +1372,6 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1116
1372
  toDateTime64(updatedAt, 3) as updatedAt,
1117
1373
  ROW_NUMBER() OVER (PARTITION BY id ORDER BY updatedAt DESC) as row_num
1118
1374
  FROM ${TABLE_THREADS}
1119
- WHERE resourceId = {resourceId:String}
1120
1375
  )
1121
1376
  SELECT
1122
1377
  id,
@@ -1126,12 +1381,12 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1126
1381
  createdAt,
1127
1382
  updatedAt
1128
1383
  FROM ranked_threads
1129
- WHERE row_num = 1
1384
+ WHERE row_num = 1 ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
1130
1385
  ORDER BY "${field}" ${direction === "DESC" ? "DESC" : "ASC"}
1131
1386
  LIMIT {perPage:Int64} OFFSET {offset:Int64}
1132
1387
  `,
1133
1388
  query_params: {
1134
- resourceId,
1389
+ ...queryParams,
1135
1390
  perPage,
1136
1391
  offset
1137
1392
  },
@@ -1157,10 +1412,14 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1157
1412
  } catch (error) {
1158
1413
  throw new MastraError(
1159
1414
  {
1160
- id: createStorageErrorId("CLICKHOUSE", "LIST_THREADS_BY_RESOURCE_ID", "FAILED"),
1415
+ id: createStorageErrorId("CLICKHOUSE", "LIST_THREADS", "FAILED"),
1161
1416
  domain: ErrorDomain.STORAGE,
1162
1417
  category: ErrorCategory.THIRD_PARTY,
1163
- details: { resourceId, page }
1418
+ details: {
1419
+ ...filter?.resourceId && { resourceId: filter.resourceId },
1420
+ hasMetadataFilter: !!filter?.metadata,
1421
+ page
1422
+ }
1164
1423
  },
1165
1424
  error
1166
1425
  );
@@ -1468,7 +1727,7 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1468
1727
  return {
1469
1728
  id: resource.id,
1470
1729
  workingMemory: resource.workingMemory && typeof resource.workingMemory === "object" ? JSON.stringify(resource.workingMemory) : resource.workingMemory,
1471
- metadata: resource.metadata && typeof resource.metadata === "string" ? JSON.parse(resource.metadata) : resource.metadata,
1730
+ metadata: parseMetadata(resource.metadata),
1472
1731
  createdAt: new Date(resource.createdAt),
1473
1732
  updatedAt: new Date(resource.updatedAt)
1474
1733
  };
@@ -1493,7 +1752,7 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1493
1752
  {
1494
1753
  id: resource.id,
1495
1754
  workingMemory: resource.workingMemory,
1496
- metadata: JSON.stringify(resource.metadata),
1755
+ metadata: serializeMetadata(resource.metadata),
1497
1756
  createdAt: resource.createdAt.toISOString(),
1498
1757
  updatedAt: resource.updatedAt.toISOString()
1499
1758
  }
@@ -1504,7 +1763,10 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1504
1763
  output_format_json_quote_64bit_integers: 0
1505
1764
  }
1506
1765
  });
1507
- return resource;
1766
+ return {
1767
+ ...resource,
1768
+ metadata: resource.metadata || {}
1769
+ };
1508
1770
  } catch (error) {
1509
1771
  throw new MastraError(
1510
1772
  {
@@ -1594,11 +1856,101 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
1594
1856
  this.#db = new ClickhouseDB({ client, ttl });
1595
1857
  }
1596
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
+ }
1597
1893
  await this.#db.createTable({ tableName: TABLE_SPANS, schema: SPAN_SCHEMA });
1598
1894
  }
1599
1895
  async dangerouslyClearAll() {
1600
1896
  await this.#db.clearTable({ tableName: TABLE_SPANS });
1601
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
+ }
1602
1954
  get tracingStrategy() {
1603
1955
  return {
1604
1956
  preferred: "insert-only",
@@ -1932,10 +2284,10 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
1932
2284
  conditions.push(`(error IS NOT NULL AND error != '')`);
1933
2285
  break;
1934
2286
  case TraceStatus.RUNNING:
1935
- 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 = '')`);
1936
2288
  break;
1937
2289
  case TraceStatus.SUCCESS:
1938
- 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 = '')`);
1939
2291
  break;
1940
2292
  }
1941
2293
  }
@@ -1964,7 +2316,7 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
1964
2316
  if (sortField === "endedAt") {
1965
2317
  const nullSortValue = sortDirection === "DESC" ? 0 : 1;
1966
2318
  const nonNullSortValue = sortDirection === "DESC" ? 1 : 0;
1967
- 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}`;
1968
2320
  } else {
1969
2321
  orderClause = `ORDER BY ${sortField} ${sortDirection}`;
1970
2322
  }
@@ -2692,7 +3044,7 @@ var WorkflowsStorageClickhouse = class extends WorkflowsStorage {
2692
3044
  try {
2693
3045
  parsedSnapshot = JSON.parse(row.snapshot);
2694
3046
  } catch (e) {
2695
- 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}`);
2696
3048
  }
2697
3049
  }
2698
3050
  return {
@@ -2730,7 +3082,7 @@ var WorkflowsStorageClickhouse = class extends WorkflowsStorage {
2730
3082
  conditions.push(`resourceId = {var_resourceId:String}`);
2731
3083
  values.var_resourceId = resourceId;
2732
3084
  } else {
2733
- 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.`);
2734
3086
  }
2735
3087
  }
2736
3088
  if (fromDate) {
@@ -2870,7 +3222,7 @@ var WorkflowsStorageClickhouse = class extends WorkflowsStorage {
2870
3222
  var isClientConfig = (config) => {
2871
3223
  return "client" in config;
2872
3224
  };
2873
- var ClickhouseStore = class extends MastraStorage {
3225
+ var ClickhouseStore = class extends MastraCompositeStore {
2874
3226
  db;
2875
3227
  ttl = {};
2876
3228
  stores;
@@ -2888,11 +3240,11 @@ var ClickhouseStore = class extends MastraStorage {
2888
3240
  if (typeof config.password !== "string") {
2889
3241
  throw new Error("ClickhouseStore: password must be a string.");
2890
3242
  }
3243
+ const { id, ttl, disableInit, clickhouse_settings, ...clientOptions } = config;
2891
3244
  this.db = createClient({
2892
- url: config.url,
2893
- username: config.username,
2894
- password: config.password,
3245
+ ...clientOptions,
2895
3246
  clickhouse_settings: {
3247
+ ...clickhouse_settings,
2896
3248
  date_time_input_format: "best_effort",
2897
3249
  date_time_output_format: "iso",
2898
3250
  // This is crucial