Files
supabase/apps/studio/data/logs/logs-sql-rewrite.ts
Charis 8b38e0d1ed feat(studio): ClickHouse dialect for logs snippet AI + rewrite to ClickHouse (#48501)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature, plus a refactor of the shared logs-rewrite flow.

PR 8 of the SQL editor query-source series. Stacked on #48457 — review
that one first, and merge this after it.

## What is the current behavior?

A `log_sql` snippet runs against the ClickHouse-backed analytics
endpoint, but the SQL editor's AI still writes Postgres: inline edits
get Postgres system prompts, and the result is run through
`sql-formatter`, which mangles ClickHouse backticks and `log_attributes`
map lookups.

Legacy Logs Explorer saved queries open in the editor as `log_sql`
snippets. Those are BigQuery dialect and error against the ClickHouse
endpoint the editor runs them on, with no in-editor way out — only the
Logs Explorer offered a rewrite.

The completion route was also asymmetric. It assembled a
schema/code/instruction message for Postgres but forwarded `prompt`
verbatim for ClickHouse, so a client wanting ClickHouse had to
hand-build the equivalent string.

## What is the new behavior?

**Inline AI speaks ClickHouse for logs snippets.** `sqlSourceToDialect`
maps a snippet's source to `postgres`/`clickhouse` and
`buildCompletionRequestBody` threads it through. For ClickHouse,
`useSqlEditorAi` strips code fences from the response and skips
`formatSql`. Execution and dialect both follow the snippet type, so a
snippet's valid dialect never flips.

**Rewrite to ClickHouse in the editor.** A banner offers the rewrite for
a logs snippet whose text trips `looksLikeLegacyLogsQuery`, and proposes
the result through the editor's existing AI diff view rather than
replacing the snippet, so it's accepted or discarded like any other AI
edit. Gated on `otelLegacyLogs`: on a non-migrated org the BigQuery text
is still correct, so rewriting it would break a working query.

The offer is a state machine (`offered` / `rewriting` / `failed` /
`noRewriteNeeded` / `dismissed`) with a declarative table of valid
transitions, so the states are mutually exclusive by construction and
dismissal is terminal. A failure keeps its message and offers a retry; a
response identical to the input is reported rather than opening an empty
diff.

**One place assembles completion prompts.** The route now uses a single
template for both dialects, branching only the schema section and — for
`intent: 'rewrite'` — the instruction. `lib/ai/clickhouse-logs.ts` is
the single home for ClickHouse-logs prompt content, replacing two
independently maintained descriptions of the same table. Clients carry
no prompt text.

**The rewrite flow is shared with the Logs Explorer.** Both surfaces
previously hand-rolled the same sequence and had drifted: only one
detected a no-op rewrite, they sourced `log_attributes` keys
differently, and the Explorer formatted errors with an `as Error` cast.
Both now use `useLegacyLogsRewrite` and the same state-driven banner, so
the Explorer picks up no-op detection and typed error extraction.

**Attribute keys are fetched on submit, not while typing.** The detected
source would otherwise feed a reactive query key, making every edit that
changed it cost another network call. `useLogsAttributeKeys` is
imperative and goes through `queryClient.fetchQuery`, so a source
already cached — including by the Explorer header and query panel, which
subscribe reactively — is reused. This also closes a gap where inline
edits never received keys at all, unlike full rewrites.

`getErrorMessage` gains an optional typed fallback and no longer
stringifies a bare object into `'[object Object]'`; every existing
caller already hand-rolled a fallback, except `QueueSettings`, which
interpolated the raw result and now passes one.

Nothing here is user-visible until the `sqlEditorLogsSource` flag is
enabled.

Tests: dialect selection and request-body shape, the ClickHouse prompt
content (including that the schema section does not restate the dialect
rules), the reducer's valid and invalid transitions,
`shouldOfferLegacyLogsRewrite`, on-submit key discovery with cache
reuse, and `getErrorMessage`.

## Additional context

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added an Assistant banner to help rewrite legacy BigQuery-style logs
queries into ClickHouse SQL.
* SQL assistance now adapts to the selected query type, including
relevant log attribute context.
* Rewrite suggestions can be reviewed as editor diffs before being
applied.

* **Bug Fixes**
* Improved rewrite failure handling, retry options, dismissal behavior,
and “no rewrite needed” messaging.
* Error notifications now provide a clearer fallback message when
details are unavailable.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 09:02:40 -04:00

114 lines
3.7 KiB
TypeScript

import { BASE_PATH } from '@/lib/constants'
export function stripSqlCodeFences(text: string): string {
const trimmed = text.trim()
const fenced = trimmed.match(/```(?:sql)?\s*\n?([\s\S]*?)\n?```/i)
return (fenced ? fenced[1] : trimmed).trim()
}
const SOURCE_ALIASES: Record<string, string> = {
pg_cron_logs: 'postgres_logs',
}
export function detectLogSource(sql: string): string | undefined {
// `\b` so only a standalone `source` column counts — an unanchored match reads
// the value out of `resource = '...'` or `datasource = '...'` too.
const bySource = sql.match(/\bsource\s*=\s*'([^']+)'/i)
if (bySource) {
const source = bySource[1].toLowerCase()
return SOURCE_ALIASES[source] ?? source
}
const byFrom = sql.match(/\bfrom\s+([a-z_][a-z0-9_]*)/i)
if (byFrom) {
const table = byFrom[1].toLowerCase()
if (table === 'logs') return undefined
return SOURCE_ALIASES[table] ?? table
}
return undefined
}
export function looksLikeLegacyLogsQuery(sql: string): boolean {
const lower = sql.toLowerCase()
if (/\bunnest\s*\(/.test(lower)) return true
if (/cast\s*\(\s*timestamp\s+as\s+datetime\s*\)/.test(lower)) return true
const byFrom = lower.match(/\bfrom\s+([a-z_][a-z0-9_]*)/)
return byFrom ? byFrom[1] !== 'logs' : false
}
/**
* How long to let the query text settle before re-running the dialect check.
* Shared so every surface offering the rewrite reacts on the same cadence.
*/
export const LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS = 500
/**
* Whether to offer the ClickHouse rewrite for a query. Both the flag and the
* dialect check matter: on an org whose logs haven't moved to ClickHouse the
* BigQuery text is still *correct*, so offering to rewrite it would break a
* working query. Callers layer their own dismissal state on top.
*/
export function shouldOfferLegacyLogsRewrite({
sql,
isClickhouseLogsEnabled,
}: {
sql: string
isClickhouseLogsEnabled: boolean
}): boolean {
return isClickhouseLogsEnabled && looksLikeLegacyLogsQuery(sql)
}
export interface RewriteLogsSqlArgs {
sql: string
projectRef: string
connectionString?: string | null
orgSlug?: string
authorizationHeader?: string | null
availableKeys?: string[]
}
/**
* Asks the completion route to rewrite a whole BigQuery logs query as ClickHouse
* SQL. The prompt itself lives server-side (`lib/ai/clickhouse-logs.ts`) — this
* only declares the intent and hands the query over as the selection, the same
* shape an inline edit uses, so exactly one place knows how a completion prompt
* is assembled.
*/
export async function rewriteLogsSqlWithAI(args: RewriteLogsSqlArgs) {
const { sql, projectRef, connectionString, orgSlug, authorizationHeader, availableKeys } = args
const response = await fetch(`${BASE_PATH}/api/ai/code/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authorizationHeader ? { Authorization: authorizationHeader } : {}),
},
body: JSON.stringify({
projectRef,
connectionString,
language: 'sql',
dialect: 'clickhouse',
intent: 'rewrite',
orgSlug,
completionMetadata: {
// The whole query is the selection, so the rewrite replaces all of it and
// the route supplies the instruction for the `rewrite` intent.
textBeforeCursor: '',
textAfterCursor: '',
prompt: '',
selection: sql,
availableKeys,
},
}),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || 'Failed to rewrite the query')
}
const raw = await response.json()
const rewritten = stripSqlCodeFences(typeof raw === 'string' ? raw : String(raw))
if (!rewritten) throw new Error('The assistant returned an empty query')
return rewritten
}