Files
supabase/apps/studio/hooks/analytics/useLegacyLogsRewrite.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

163 lines
5.9 KiB
TypeScript

import { useReducer } from 'react'
import { constructHeaders } from '@/data/fetchers'
import { rewriteLogsSqlWithAI } from '@/data/logs/logs-sql-rewrite'
import { useLogsAttributeKeys } from '@/hooks/analytics/useLogsAttributeKeys'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { getErrorMessage } from '@/lib/get-error-message'
export type LegacyLogsRewriteState =
| { status: 'offered' }
| { status: 'rewriting' }
| { status: 'failed'; message: string }
| { status: 'noRewriteNeeded' }
| { status: 'dismissed' }
export type LegacyLogsRewriteEvent =
| { type: 'rewriteRequested' }
| { type: 'rewriteProposed' }
| { type: 'rewriteFailed'; message: string }
| { type: 'rewriteNoop' }
| { type: 'dismissed' }
export const INITIAL_LEGACY_LOGS_REWRITE_STATE: LegacyLogsRewriteState = { status: 'offered' }
/**
* The events each state accepts. Anything absent is an invalid transition and
* leaves the state untouched — notably `dismissed` is terminal, and the offer
* can't be dismissed mid-rewrite.
*/
const VALID_EVENTS: {
[S in LegacyLogsRewriteState['status']]: readonly LegacyLogsRewriteEvent['type'][]
} = {
offered: ['rewriteRequested', 'dismissed'],
rewriting: ['rewriteProposed', 'rewriteFailed', 'rewriteNoop'],
// A failure is recoverable: the same Rewrite control retries it.
failed: ['rewriteRequested', 'dismissed'],
noRewriteNeeded: ['dismissed'],
dismissed: [],
}
function targetState(event: LegacyLogsRewriteEvent): LegacyLogsRewriteState {
switch (event.type) {
case 'rewriteRequested':
return { status: 'rewriting' }
// The proposal is handed to the caller; the offer returns to idle behind it so
// it's ready again if the user discards the proposal.
case 'rewriteProposed':
return { status: 'offered' }
case 'rewriteFailed':
return { status: 'failed', message: event.message }
case 'rewriteNoop':
return { status: 'noRewriteNeeded' }
case 'dismissed':
return { status: 'dismissed' }
}
}
export function legacyLogsRewriteReducer(
state: LegacyLogsRewriteState,
event: LegacyLogsRewriteEvent
): LegacyLogsRewriteState {
if (!VALID_EVENTS[state.status].includes(event.type)) return state
return targetState(event)
}
const CHANGED_WHILE_REWRITING_MESSAGE =
'The query changed while the Assistant was working, so the rewrite no longer matches it.'
const NO_RESPONSE_MESSAGE = 'The Assistant did not respond. Try again.'
export type LegacyLogsRewriteProposal = { original: string; modified: string }
type UseLegacyLogsRewriteArgs = {
/**
* Reads the query to rewrite at the moment the user asks. A callback rather than
* a value so the rewrite operates on exactly what the user sees, not on whatever
* a surface last rendered.
*/
readSql: () => string
/** Receives a rewrite worth reviewing. Each surface routes this to its own diff. */
onProposal: (proposal: LegacyLogsRewriteProposal) => void
/**
* Called when the offer is dismissed, for surfaces that persist that. The
* machine covers the current session only — a surface that remembers dismissals
* across sessions layers that on top of its own visibility check, since a value
* read from storage isn't available in time to seed the machine.
*/
onDismissed?: () => void
}
/**
* Owns the BigQuery → ClickHouse rewrite request end to end: key discovery, the
* completion call, the stale-edit guard, no-op detection, and the resulting state.
*/
export function useLegacyLogsRewrite({
readSql,
onProposal,
onDismissed,
}: UseLegacyLogsRewriteArgs) {
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const projectRef = project?.ref
const [state, dispatch] = useReducer(legacyLogsRewriteReducer, INITIAL_LEGACY_LOGS_REWRITE_STATE)
const { fetchAttributeKeys } = useLogsAttributeKeys()
const requestRewrite = async () => {
if (!projectRef) return console.error('[useLegacyLogsRewrite] Project ref is required')
const currentSql = readSql()
if (currentSql.trim().length === 0) return
dispatch({ type: 'rewriteRequested' })
try {
const [headerData, availableKeys] = await Promise.all([
constructHeaders(),
fetchAttributeKeys(currentSql),
])
const rewritten = await rewriteLogsSqlWithAI({
sql: currentSql,
projectRef,
connectionString: project?.connectionString,
orgSlug: organization?.slug,
authorizationHeader: headerData.get('Authorization'),
availableKeys,
})
// The user may have kept typing while the model worked; a proposal built from
// stale text would clobber those edits when accepted.
if (readSql() !== currentSql) {
dispatch({ type: 'rewriteFailed', message: CHANGED_WHILE_REWRITING_MESSAGE })
return
}
// An unchanged response means the query already runs on ClickHouse and the
// dialect heuristic was over-eager. Proposing it would show an empty diff.
if (rewritten.trim() === currentSql.trim()) {
dispatch({ type: 'rewriteNoop' })
return
}
onProposal({ original: currentSql, modified: rewritten })
dispatch({ type: 'rewriteProposed' })
} catch (error) {
dispatch({ type: 'rewriteFailed', message: getErrorMessage(error, NO_RESPONSE_MESSAGE) })
}
}
const dismiss = () => {
const dismissed: LegacyLogsRewriteEvent = { type: 'dismissed' }
// The transition table is the contract, not the UI that happens to disable the
// control: never report a dismissal the machine rejected (mid-rewrite, say),
// or a surface that persists it would suppress an offer that's still live.
if (legacyLogsRewriteReducer(state, dismissed) === state) return
dispatch(dismissed)
onDismissed?.()
}
return { state, requestRewrite, dismiss }
}