|
| 1 | +import { getClickhouseExternalClient, getQueryTimingStats, isClickhouseConfigured } from "@/lib/clickhouse"; |
| 2 | +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; |
| 3 | +import { KnownErrors } from "@stackframe/stack-shared"; |
| 4 | +import { adaptSchema, adminAuthTypeSchema, jsonSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; |
| 5 | +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; |
| 6 | +import { Result } from "@stackframe/stack-shared/dist/utils/results"; |
| 7 | +import { randomUUID } from "crypto"; |
| 8 | + |
| 9 | +export const POST = createSmartRouteHandler({ |
| 10 | + metadata: { hidden: true }, |
| 11 | + request: yupObject({ |
| 12 | + auth: yupObject({ |
| 13 | + type: adminAuthTypeSchema, |
| 14 | + tenancy: adaptSchema, |
| 15 | + }).defined(), |
| 16 | + body: yupObject({ |
| 17 | + include_all_branches: yupBoolean().default(false), |
| 18 | + query: yupString().defined().nonEmpty(), |
| 19 | + params: yupRecord(yupString().defined(), yupMixed().defined()).default({}), |
| 20 | + timeout_ms: yupNumber().integer().min(1_000).default(10_000), |
| 21 | + }).defined(), |
| 22 | + }), |
| 23 | + response: yupObject({ |
| 24 | + statusCode: yupNumber().oneOf([200]).defined(), |
| 25 | + bodyType: yupString().oneOf(["json"]).defined(), |
| 26 | + body: yupObject({ |
| 27 | + result: jsonSchema.defined(), |
| 28 | + stats: yupObject({ |
| 29 | + cpu_time: yupNumber().defined(), |
| 30 | + wall_clock_time: yupNumber().defined(), |
| 31 | + }).defined(), |
| 32 | + }).defined(), |
| 33 | + }), |
| 34 | + async handler({ body, auth }) { |
| 35 | + if (body.include_all_branches) { |
| 36 | + throw new StackAssertionError("include_all_branches is not supported yet"); |
| 37 | + } |
| 38 | + if (!isClickhouseConfigured()) { |
| 39 | + throw new StackAssertionError("ClickHouse is not configured"); |
| 40 | + } |
| 41 | + const client = getClickhouseExternalClient(); |
| 42 | + const queryId = randomUUID(); |
| 43 | + const resultSet = await Result.fromPromise(client.query({ |
| 44 | + query: body.query, |
| 45 | + query_id: queryId, |
| 46 | + query_params: body.params, |
| 47 | + clickhouse_settings: { |
| 48 | + SQL_project_id: auth.tenancy.project.id, |
| 49 | + SQL_branch_id: auth.tenancy.branchId, |
| 50 | + max_execution_time: body.timeout_ms / 1000, |
| 51 | + readonly: "1", |
| 52 | + allow_ddl: 0, |
| 53 | + max_result_rows: MAX_RESULT_ROWS.toString(), |
| 54 | + max_result_bytes: MAX_RESULT_BYTES.toString(), |
| 55 | + result_overflow_mode: "throw", |
| 56 | + }, |
| 57 | + format: "JSONEachRow", |
| 58 | + })); |
| 59 | + |
| 60 | + if (resultSet.status === "error") { |
| 61 | + const message = getSafeClickhouseErrorMessage(resultSet.error); |
| 62 | + if (message === null) { |
| 63 | + throw new StackAssertionError("Unknown Clickhouse error", { cause: resultSet.error }); |
| 64 | + } |
| 65 | + throw new KnownErrors.AnalyticsQueryError(message); |
| 66 | + } |
| 67 | + |
| 68 | + const rows = await resultSet.data.json<Record<string, unknown>[]>(); |
| 69 | + const stats = await getQueryTimingStats(client, queryId); |
| 70 | + |
| 71 | + return { |
| 72 | + statusCode: 200, |
| 73 | + bodyType: "json", |
| 74 | + body: { |
| 75 | + result: rows, |
| 76 | + stats: { |
| 77 | + cpu_time: stats.cpu_time_ms, |
| 78 | + wall_clock_time: stats.wall_clock_time_ms, |
| 79 | + }, |
| 80 | + }, |
| 81 | + }; |
| 82 | + }, |
| 83 | +}); |
| 84 | + |
| 85 | +const SAFE_CLICKHOUSE_ERROR_CODES = [ |
| 86 | + 62, // SYNTAX_ERROR |
| 87 | + 159, // TIMEOUT_EXCEEDED |
| 88 | + 164, // READONLY |
| 89 | + 158, // TOO_MANY_ROWS |
| 90 | + 396, // TOO_MANY_ROWS_OR_BYTES |
| 91 | + 636, // CANNOT_EXTRACT_TABLE_STRUCTURE |
| 92 | +]; |
| 93 | + |
| 94 | +const UNSAFE_CLICKHOUSE_ERROR_CODES = [ |
| 95 | + 36, // BAD_ARGUMENTS |
| 96 | + 60, // UNKNOWN_TABLE |
| 97 | + 497, // ACCESS_DENIED |
| 98 | +]; |
| 99 | + |
| 100 | +const DEFAULT_CLICKHOUSE_ERROR_MESSAGE = "Error during execution of this query."; |
| 101 | +const MAX_RESULT_ROWS = 10_000; |
| 102 | +const MAX_RESULT_BYTES = 10 * 1024 * 1024; |
| 103 | + |
| 104 | +function getSafeClickhouseErrorMessage(error: unknown): string | null { |
| 105 | + if (typeof error !== "object" || error === null || !("code" in error) || typeof error.code !== "string") { |
| 106 | + return null; |
| 107 | + } |
| 108 | + const errorCode = Number(error.code); |
| 109 | + if (isNaN(errorCode)) { |
| 110 | + return null; |
| 111 | + } |
| 112 | + const message = "message" in error && typeof error.message === "string" ? error.message : null; |
| 113 | + if (SAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode)) { |
| 114 | + return message; |
| 115 | + } |
| 116 | + if (UNSAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode)) { |
| 117 | + return DEFAULT_CLICKHOUSE_ERROR_MESSAGE; |
| 118 | + } |
| 119 | + return null; |
| 120 | +} |
0 commit comments