@mastra/clickhouse 1.21.0-alpha.1 → 1.21.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.
@@ -3,7 +3,7 @@ name: mastra-clickhouse
3
3
  description: Documentation for @mastra/clickhouse. Use when working with @mastra/clickhouse APIs, configuration, or implementation.
4
4
  metadata:
5
5
  package: "@mastra/clickhouse"
6
- version: "1.21.0-alpha.1"
6
+ version: "1.21.0-alpha.3"
7
7
  ---
8
8
 
9
9
  ## When to use
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.21.0-alpha.1",
2
+ "version": "1.21.0-alpha.3",
3
3
  "package": "@mastra/clickhouse",
4
4
  "exports": {},
5
5
  "modules": {}
@@ -93,6 +93,10 @@ Lightweight deletion is a hide-only operation that marks rows with ClickHouse's
93
93
 
94
94
  When all five observability signals have finite retention, Mastra also applies a TTL to deletion requests so they outlive the signal rows they protect. If any signal is unbounded, deletion requests remain unbounded. See [storage retention](https://mastra.ai/reference/storage/retention) for how the deletion-request TTL is calculated.
95
95
 
96
+ Feedback review-status updates require `ALTER UPDATE` permission on `mastra_feedback_events` and, when delta polling is enabled, `INSERT` permission on `mastra_feedback_events_delta`. These updates wait for the ClickHouse mutation to finish on the server that receives the write, so latency depends on that server's mutation queue. Other replicas apply the mutation through the replication log, so a reader on a lagging replica can briefly see the previous status, and an inactive replica doesn't block the update. They modify the existing feedback row and preserve deletion masks: a concurrent review update cannot recreate deleted feedback. Successful updates remain available through delta polling. If a newer version of the same feedback event is ingested while a review update is in flight, the update is re-applied to that newer version; after repeated conflicts it fails with a conflict error (HTTP 409) and the caller retries.
97
+
98
+ The status mutation and the delta insert are separate operations. If the delta insert fails, the API returns an error even though the status may have changed, and continued delta polling doesn't recover that notification: later polls from the same cursor never return it, and starting delta mode without a cursor subscribes at the current head. To recover, retry the review update after resolving the error, or reread the feedback with a regular list query.
99
+
96
100
  ### Observability with the legacy domain
97
101
 
98
102
  `ObservabilityStorageClickhouse` is the original observability adapter and remains supported for projects that haven't migrated to the vNext schema. The configuration shape is the same as the vNext class.
@@ -368,7 +372,16 @@ const observability = new ObservabilityStorageClickhouseVNext({
368
372
  await observability.init()
369
373
  ```
370
374
 
371
- In CI/CD pipelines, set `disableInit: true` on `ClickhouseStore` and run `init()` from a deployment step that uses elevated credentials. Runtime application credentials can then be limited to read and insert.
375
+ In CI/CD pipelines, set `disableInit: true` on `ClickhouseStore` and run `init()` from a deployment step that uses elevated credentials. Runtime application credentials still need more than read and insert:
376
+
377
+ - `SELECT` and `INSERT` on the Mastra tables.
378
+ - `ALTER DELETE` on the observability tables you delete from. On ClickHouse 26.6 and earlier, lightweight deletes also require `ALTER UPDATE` on those tables; ClickHouse 26.7 removed that requirement.
379
+ - For feedback review updates, `ALTER UPDATE(reviewStatus)` on `mastra_feedback_events` and `INSERT` on `mastra_feedback_events_delta`.
380
+
381
+ ```sql
382
+ GRANT ALTER UPDATE(reviewStatus) ON <database>.mastra_feedback_events TO <runtime_user>;
383
+ GRANT INSERT ON <database>.mastra_feedback_events_delta TO <runtime_user>;
384
+ ```
372
385
 
373
386
  ## Observability
374
387
 
package/dist/index.cjs CHANGED
@@ -4295,17 +4295,46 @@ async function recordDeletionRequest(client, args) {
4295
4295
  purgeVerifiedAt: EPOCH,
4296
4296
  updatedAt: args.requestedAt
4297
4297
  };
4298
+ await insertDeletionRequest(client, row, args.replication);
4299
+ return row;
4300
+ }
4301
+ /**
4302
+ * Marks a recorded deletion request as applied after its lightweight DELETEs
4303
+ * succeeded. The table is `ReplacingMergeTree(updatedAt)`, so re-inserting the
4304
+ * row with a newer `updatedAt` supersedes the pending version on merge and
4305
+ * under `FINAL`. Requests whose DELETE failed keep `lastAppliedAt` at the
4306
+ * epoch; mutation guards ignore them, and re-invoking the delete API records a
4307
+ * new request and converges.
4308
+ */
4309
+ async function markDeletionRequestApplied(client, row, replication) {
4310
+ const pendingAt = Date.parse(row.updatedAt);
4311
+ const appliedAt = new Date(Number.isFinite(pendingAt) ? Math.max(Date.now(), pendingAt + 1) : Date.now()).toISOString();
4312
+ const applied = {
4313
+ ...row,
4314
+ lastAppliedAt: appliedAt,
4315
+ updatedAt: appliedAt
4316
+ };
4317
+ await insertDeletionRequest(client, applied, replication);
4318
+ return applied;
4319
+ }
4320
+ /**
4321
+ * Replicated clusters insert with parallel quorum, the same as every other
4322
+ * deletion-request write on main, so concurrent deletes never reject each other.
4323
+ * The guard that reads these rows is advisory: review updates mutate feedback
4324
+ * in place and preserve the delete mask, so a replica that has not yet received
4325
+ * a marker can only mis-report a status, never bring deleted feedback back.
4326
+ */
4327
+ async function insertDeletionRequest(client, row, replication) {
4298
4328
  await client.insert({
4299
4329
  table: TABLE_DELETION_REQUESTS,
4300
4330
  values: [row],
4301
4331
  format: "JSONEachRow",
4302
- clickhouse_settings: isReplicationConfigured(args.replication) ? {
4332
+ clickhouse_settings: isReplicationConfigured(replication) ? {
4303
4333
  ...CH_INSERT_SETTINGS,
4304
4334
  insert_quorum: "auto",
4305
4335
  insert_quorum_parallel: 1
4306
4336
  } : CH_INSERT_SETTINGS
4307
4337
  });
4308
- return row;
4309
4338
  }
4310
4339
  //#endregion
4311
4340
  //#region src/storage/domains/observability/v-next/discovery.ts
@@ -4797,14 +4826,18 @@ async function batchCreateFeedback(client, args) {
4797
4826
  * `organizationId` and `resourceId` values are ANDed into the predicate to
4798
4827
  * restrict deletion to records with matching scope fields.
4799
4828
  *
4800
- * A durable deletion request is recorded before the lightweight delete. The
4801
- * delete is immediately visible to subsequent reads; physical purge depends on
4802
- * the table's configured retention TTL. The delta table is intentionally not
4829
+ * A durable deletion request is recorded before the lightweight delete and
4830
+ * marked applied once the delete succeeds. If the delete fails, the request
4831
+ * stays unapplied and does not block updates to the still-visible rows; retry
4832
+ * by calling this function again.
4833
+ *
4834
+ * The delete is immediately visible to subsequent reads; physical purge depends
4835
+ * on the table's configured retention TTL. The delta table is intentionally not
4803
4836
  * touched and expires through its fixed two-day TTL.
4804
4837
  */
4805
4838
  async function deleteFeedback(client, args, replication) {
4806
4839
  if (args.feedbackIds.length === 0) return;
4807
- await recordDeletionRequest(client, {
4840
+ const request = await recordDeletionRequest(client, {
4808
4841
  requestId: (0, crypto$1.randomUUID)(),
4809
4842
  organizationId: args.organizationId,
4810
4843
  resourceId: args.resourceId,
@@ -4835,6 +4868,7 @@ async function deleteFeedback(client, args, replication) {
4835
4868
  query_params: params,
4836
4869
  clickhouse_settings: { lightweight_deletes_sync: isReplicationConfigured(replication) ? "2" : "1" }
4837
4870
  });
4871
+ await markDeletionRequestApplied(client, request, replication);
4838
4872
  }
4839
4873
  function feedbackNotFoundError(feedbackId) {
4840
4874
  return new _mastra_core_error.MastraError({
@@ -4845,11 +4879,29 @@ function feedbackNotFoundError(feedbackId) {
4845
4879
  details: { feedbackId }
4846
4880
  });
4847
4881
  }
4882
+ function feedbackConflictError(feedbackId) {
4883
+ return new _mastra_core_error.MastraError({
4884
+ id: "OBSERVABILITY_UPDATE_FEEDBACK_REVIEW_STATUS_CONFLICT",
4885
+ domain: _mastra_core_error.ErrorDomain.MASTRA_OBSERVABILITY,
4886
+ category: _mastra_core_error.ErrorCategory.USER,
4887
+ text: "Feedback record changed while its review status was being updated; retry the update",
4888
+ details: { feedbackId }
4889
+ });
4890
+ }
4891
+ /** Re-reads before giving up when ingestion keeps superseding the observed row. */
4892
+ const REVIEW_UPDATE_ATTEMPTS = 3;
4893
+ /**
4894
+ * Only applied requests block a review update. The guard decides the reply,
4895
+ * not data safety: the update mutates the row in place and preserves the
4896
+ * delete mask, so a replica that has not received a marker yet can only
4897
+ * mis-report a status, never bring deleted feedback back.
4898
+ */
4848
4899
  async function hasFeedbackDeletionRequest(client, feedbackId, organizationId, resourceId) {
4849
4900
  return (await queryJson$2(client, `SELECT 1 AS found FROM ${TABLE_DELETION_REQUESTS} FINAL
4850
4901
  WHERE signal = 'feedback'
4851
4902
  AND predicateType = 'itemIds'
4852
4903
  AND has(predicateValues, {feedbackId:String})
4904
+ AND lastAppliedAt > toDateTime64(0, 3)
4853
4905
  AND (organizationId = '' OR organizationId = {organizationId:String})
4854
4906
  AND (resourceId = '' OR resourceId = {resourceId:String})
4855
4907
  LIMIT 1`, {
@@ -4858,28 +4910,63 @@ async function hasFeedbackDeletionRequest(client, feedbackId, organizationId, re
4858
4910
  resourceId: resourceId ?? ""
4859
4911
  })).length > 0;
4860
4912
  }
4861
- async function updateFeedbackReviewStatus(client, args, replication) {
4913
+ async function updateFeedbackReviewStatus(client, args, strategy = null) {
4862
4914
  const { feedbackId, reviewStatus } = parseUpdateFeedbackReviewStatusArgs(args);
4863
- const existingRow = (await queryJson$2(client, `SELECT * FROM ${TABLE_FEEDBACK_EVENTS} FINAL
4915
+ for (let attempt = 1;; attempt++) {
4916
+ const updated = await applyReviewStatus(client, feedbackId, reviewStatus, strategy);
4917
+ if (updated) return updated;
4918
+ if (attempt >= REVIEW_UPDATE_ATTEMPTS) throw feedbackConflictError(feedbackId);
4919
+ }
4920
+ }
4921
+ /**
4922
+ * One read-guard-mutate-verify pass. Returns `null` when the read-back does not
4923
+ * show the new status: a newer version superseded the observed row, a delete
4924
+ * hid it after the guard read (the next pass then reports not found), or the
4925
+ * mutation left the row untouched.
4926
+ */
4927
+ async function applyReviewStatus(client, feedbackId, reviewStatus, strategy) {
4928
+ const existingRow = (await queryJson$2(client, `SELECT *, toString(writeVersion) AS reviewWriteVersion FROM ${TABLE_FEEDBACK_EVENTS} FINAL
4864
4929
  WHERE feedbackId = {feedbackId:String}
4865
4930
  ORDER BY writeVersion DESC, timestamp DESC
4866
4931
  LIMIT 1`, { feedbackId }))[0];
4867
4932
  if (!existingRow) throw feedbackNotFoundError(feedbackId);
4868
4933
  if (await hasFeedbackDeletionRequest(client, feedbackId, existingRow.organizationId, existingRow.resourceId)) throw feedbackNotFoundError(feedbackId);
4869
- const updated = rowToFeedbackRecord({
4870
- ...existingRow,
4934
+ const identity = `feedbackId = {feedbackId:String}
4935
+ AND timestamp = parseDateTime64BestEffort({timestamp:String}, 3, 'UTC')
4936
+ AND (traceId = {traceId:Nullable(String)} OR (isNull(traceId) AND isNull({traceId:Nullable(String)})))
4937
+ AND writeVersion = {writeVersion:UInt64}`;
4938
+ const params = {
4939
+ feedbackId,
4940
+ timestamp: existingRow.timestamp,
4941
+ traceId: existingRow.traceId,
4942
+ writeVersion: existingRow.reviewWriteVersion,
4871
4943
  reviewStatus
4944
+ };
4945
+ await client.command({
4946
+ query: `ALTER TABLE ${TABLE_FEEDBACK_EVENTS} UPDATE reviewStatus = {reviewStatus:String} WHERE ${identity}`,
4947
+ query_params: params,
4948
+ clickhouse_settings: {
4949
+ ...CH_SETTINGS,
4950
+ mutations_sync: "1"
4951
+ }
4872
4952
  });
4873
- await batchCreateFeedback(client, { feedbacks: [updated] });
4874
- if (await hasFeedbackDeletionRequest(client, feedbackId, existingRow.organizationId, existingRow.resourceId)) {
4875
- await deleteFeedback(client, {
4876
- feedbackIds: [feedbackId],
4877
- organizationId: existingRow.organizationId ?? void 0,
4878
- resourceId: existingRow.resourceId ?? void 0
4879
- }, replication);
4880
- throw feedbackNotFoundError(feedbackId);
4881
- }
4882
- return updated;
4953
+ if (strategy !== null) await client.command({
4954
+ query: `INSERT INTO ${TABLE_FEEDBACK_EVENTS_DELTA}
4955
+ SELECT ${buildDeltaCursorExpr(strategy, "mastra_feedback_events_delta_cursor", "feedbackId")} AS cursorId,
4956
+ ingestedAt, traceId, timestamp, feedbackId
4957
+ FROM (
4958
+ SELECT now64(9, 'UTC') AS ingestedAt, traceId, timestamp, feedbackId
4959
+ FROM ${TABLE_FEEDBACK_EVENTS} FINAL WHERE ${identity}
4960
+ )`,
4961
+ query_params: params,
4962
+ clickhouse_settings: {
4963
+ ...CH_INSERT_SETTINGS,
4964
+ async_insert: 0
4965
+ }
4966
+ });
4967
+ if (await hasFeedbackDeletionRequest(client, feedbackId, existingRow.organizationId, existingRow.resourceId)) throw feedbackNotFoundError(feedbackId);
4968
+ const current = await queryJson$2(client, `SELECT * FROM ${TABLE_FEEDBACK_EVENTS} FINAL WHERE ${identity} LIMIT 1`, params);
4969
+ return current[0]?.reviewStatus === reviewStatus ? rowToFeedbackRecord(current[0]) : null;
4883
4970
  }
4884
4971
  async function listFeedback(client, args, strategy) {
4885
4972
  const parsed = _mastra_core_storage.listFeedbackArgsSchema.parse(args);
@@ -6228,14 +6315,16 @@ async function batchCreateScores(client, args) {
6228
6315
  * `organizationId` and `resourceId` values are ANDed into the predicate to
6229
6316
  * restrict deletion to records with matching scope fields.
6230
6317
  *
6231
- * A durable deletion request is recorded before the lightweight delete. The
6232
- * delete is immediately visible to subsequent reads; physical purge depends on
6233
- * the table's configured retention TTL. The delta table is intentionally not
6318
+ * A durable deletion request is recorded before the lightweight delete and
6319
+ * marked applied once the delete succeeds. If the delete fails, the request
6320
+ * stays unapplied; retry by calling this function again. The delete is
6321
+ * immediately visible to subsequent reads; physical purge depends on the
6322
+ * table's configured retention TTL. The delta table is intentionally not
6234
6323
  * touched and expires through its fixed two-day TTL.
6235
6324
  */
6236
6325
  async function deleteScores(client, args, replication) {
6237
6326
  if (args.scoreIds.length === 0) return;
6238
- await recordDeletionRequest(client, {
6327
+ const request = await recordDeletionRequest(client, {
6239
6328
  requestId: (0, crypto$1.randomUUID)(),
6240
6329
  organizationId: args.organizationId,
6241
6330
  resourceId: args.resourceId,
@@ -6267,6 +6356,7 @@ async function deleteScores(client, args, replication) {
6267
6356
  query_params: params,
6268
6357
  clickhouse_settings
6269
6358
  });
6359
+ await markDeletionRequestApplied(client, request, replication);
6270
6360
  }
6271
6361
  async function listScores(client, args, strategy) {
6272
6362
  const parsed = _mastra_core_storage.listScoresArgsSchema.parse(args);
@@ -6516,6 +6606,9 @@ async function getScorePercentiles(client, args) {
6516
6606
  //#endregion
6517
6607
  //#region src/storage/domains/observability/v-next/trace-query.ts
6518
6608
  const TRACE_STATUS_SQL = `if(isNotNull(r.error), 'error', 'success')`;
6609
+ function durationMsSql(startedAt, endedAt) {
6610
+ return `dateDiff('millisecond', ${startedAt}, ${endedAt})`;
6611
+ }
6519
6612
  const TRACE_FIELDS = {
6520
6613
  traceId: {
6521
6614
  sql: "r.traceId",
@@ -6537,6 +6630,10 @@ const TRACE_FIELDS = {
6537
6630
  sql: "r.endedAt",
6538
6631
  parameterType: "DateTime64(3, 'UTC')"
6539
6632
  },
6633
+ durationMs: {
6634
+ sql: durationMsSql("r.startedAt", "r.endedAt"),
6635
+ parameterType: "Float64"
6636
+ },
6540
6637
  entityName: {
6541
6638
  sql: "r.entityName",
6542
6639
  parameterType: "String"
@@ -6861,7 +6958,7 @@ function compileClickHouseTraceScope(selection, relationCollections, parameters,
6861
6958
  if(JSONType(attributes, 'provider') = 'String', JSONExtractString(attributes, 'provider'), NULL) AS provider,
6862
6959
  startedAt,
6863
6960
  endedAt,
6864
- dateDiff('millisecond', startedAt, endedAt) AS durationMs,
6961
+ ${durationMsSql("startedAt", "endedAt")} AS durationMs,
6865
6962
  if(isNotNull(error), 'error', 'success') AS status,
6866
6963
  error,
6867
6964
  entityType,
@@ -7725,7 +7822,9 @@ async function getTraceLight(client, args) {
7725
7822
  * so span deletes never propagate to it. Delta tables self-expire via TTL and
7726
7823
  * discovery tables self-heal, so neither needs explicit deletes.
7727
7824
  *
7728
- * Records the predicate before using lightweight DELETE FROM on every table.
7825
+ * Records the predicate before using lightweight DELETE FROM on every table
7826
+ * and marks the request applied once every delete succeeds. If any delete
7827
+ * fails, the request stays unapplied; retry by calling this function again.
7729
7828
  * Lightweight deletes hide rows through ClickHouse's delete mask; physical
7730
7829
  * removal depends on the deployment's configured retention and merge policy.
7731
7830
  *
@@ -7734,7 +7833,7 @@ async function getTraceLight(client, args) {
7734
7833
  */
7735
7834
  async function batchDeleteTraces(client, args, replication) {
7736
7835
  if (args.traceIds.length === 0) return;
7737
- await recordDeletionRequest(client, {
7836
+ const request = await recordDeletionRequest(client, {
7738
7837
  requestId: (0, crypto$1.randomUUID)(),
7739
7838
  organizationId: args.organizationId,
7740
7839
  resourceId: args.resourceId,
@@ -7787,6 +7886,7 @@ async function batchDeleteTraces(client, args, replication) {
7787
7886
  query_params: params,
7788
7887
  clickhouse_settings: { lightweight_deletes_sync: "2" }
7789
7888
  }))]);
7889
+ await markDeletionRequestApplied(client, request, replication);
7790
7890
  }
7791
7891
  /**
7792
7892
  * List trace branches with optional filtering, pagination, and ordering.
@@ -8566,6 +8666,7 @@ var ObservabilityStorageClickhouseVNext = class extends _mastra_core_storage.Obs
8566
8666
  "metrics",
8567
8667
  "logs",
8568
8668
  "trace-query",
8669
+ "trace-query-root-duration",
8569
8670
  "trace-query-discovery",
8570
8671
  "thread-query",
8571
8672
  "trace-query-tenant-scope"
@@ -8575,6 +8676,7 @@ var ObservabilityStorageClickhouseVNext = class extends _mastra_core_storage.Obs
8575
8676
  "logs",
8576
8677
  "delta-polling",
8577
8678
  "trace-query",
8679
+ "trace-query-root-duration",
8578
8680
  "trace-query-discovery",
8579
8681
  "thread-query",
8580
8682
  "trace-query-tenant-scope"
@@ -8917,7 +9019,7 @@ var ObservabilityStorageClickhouseVNext = class extends _mastra_core_storage.Obs
8917
9019
  }
8918
9020
  async updateFeedbackReviewStatus(args) {
8919
9021
  try {
8920
- return await updateFeedbackReviewStatus(this.#client, args, this.#replication);
9022
+ return await updateFeedbackReviewStatus(this.#client, args, deltaPollingSupported(this.#deltaCursorStrategy) ? this.#deltaCursorStrategy : null);
8921
9023
  } catch (error) {
8922
9024
  if (error instanceof _mastra_core_error.MastraError) throw error;
8923
9025
  throw new _mastra_core_error.MastraError({
@@ -10067,6 +10169,7 @@ exports.TABLE_DELETION_REQUESTS = TABLE_DELETION_REQUESTS;
10067
10169
  exports.TABLE_ENGINES = TABLE_ENGINES;
10068
10170
  exports.WorkflowsStorageClickhouse = WorkflowsStorageClickhouse;
10069
10171
  exports.applyClickHouseRetention = applyClickHouseRetention;
10172
+ exports.markDeletionRequestApplied = markDeletionRequestApplied;
10070
10173
  exports.recordDeletionRequest = recordDeletionRequest;
10071
10174
 
10072
10175
  //# sourceMappingURL=index.cjs.map