mirror of
https://github.com/supabase/supabase.git
synced 2026-09-08 02:49:48 +08:00
## 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 -->
163 lines
5.9 KiB
TypeScript
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 }
|
|
}
|