mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03: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 — final PR (9/9) of the SQL editor logs-source stack. **Base branch:** `charislam/sql-editor-inline-ai-clickhouse-dialect` (PR 8). Nothing here is user-visible: entry points stay behind `sqlEditorLogsSource` + `otelLegacyLogs`, and flag rollout happens after the whole stack merges. ## What is the current behavior? - The Assistant has no idea a SQL editor snippet targets the logs backend. Ask it about a logs snippet and it answers in Postgres, because the attached query is fenced as ` ```sql ` and nothing tells the model otherwise. - Because the `sql` fence is what `MessageMarkdown` treats as runnable Postgres, an attached ClickHouse query is rendered with a Run-against-Postgres affordance and branded with `untrustedSql`. - "Debug with Assistant" on a failed logs query produces a dialect-less prompt, so both the in-app assistant and the copyable version get debugged as Postgres. - A report referencing a `log_sql` snippet runs its ClickHouse SQL against the user's Postgres database and surfaces the resulting error. ## What is the new behavior? **Assistant panel.** The "Current Query" chip records which backend the attached query targets. That reaches the model two ways: each attachment is fenced with its own dialect (` ```clickhouse ` vs ` ```sql `), and a `containsLogsSnippets` flag rides on the user message as AI SDK `metadata`. The server reads the flag off the conversation and prepends the ClickHouse dialect rules plus the logs schema reference as a non-cached context message. Two design points worth calling out in review: - The flag lives on the **message**, not the request body, so Retry and the tool-approval continuation reproduce the context a message was originally asked in — neither of those passes a per-call body. - It's derived from **what's actually attached**, so detaching the chip drops the claim rather than leaving the two able to disagree. The `clickhouse` fence also keeps a logs query out of `MessageMarkdown`'s `sql` branch, so it's no longer offered as runnable Postgres or branded with `untrustedSql` — a boundary this stack's distinct brands exist to prevent crossing. **Debug flow.** `buildDebugChatArgs` attaches its query with a source for the same reason, and names the dialect in the prompt text so the copyable version stands on its own outside the app. **Reports.** A report only stores a snippet id, so whether it queries the logs backend is only knowable once the content loads. `ReportBlock` guards on the fetched type and renders a `LogsSnippetReportBlock` placeholder instead of executing. Double-guarded: no `sql` for a logs snippet (so it's out of the query key and `queryFn` short-circuits even on an explicit `refetch`) and `enabled` excludes it. **Incidental cleanups.** `buildAssistantContextMessages` extracted out of `generate-assistant-response`; a schema-access sentinel that was duplicated as a string literal across two files (and compared against) replaced with one exported constant; `SqlSnippet` deduplicated to a single declaration; `resolveSnippetSource` / `isLogsSource` shared instead of re-implemented per surface. **Tests.** 4 new/extended suites. Notable cases pinned: a message with no metadata must validate (`safeValidateUIMessages` applies `metadataSchema` to *every* message, so a required schema would 400 every existing conversation); only *user* messages count, so a model reply can't talk the server into a different dialect; a mixed-attachment message is flagged without overclaiming a single source; and `ReportBlock` registers no pg-meta mock for the logs cases, so an unhandled request failing the test *is* the assertion that logs SQL never reaches Postgres. Verified: `pnpm typecheck`, `lint:ratchet` (no regression), Prettier, and the full Studio suite (459 files / 4969 tests). ## Additional context <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for recognizing log snippets in reports, with clear guidance to open them in the SQL editor or remove them. - AI Assistant now understands log snippets and provides ClickHouse-specific context, formatting, and troubleshooting guidance. - Snippets retain their source information when shared with the AI Assistant. - **Bug Fixes** - Prevented unsupported log snippets from being executed as regular database queries. - Improved source detection when opening snippets directly from links. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
205 lines
7.1 KiB
TypeScript
205 lines
7.1 KiB
TypeScript
import { isToolUIPart, type UIMessage } from 'ai'
|
|
import { toast } from 'sonner'
|
|
|
|
import { SAFE_FUNCTIONS } from './AiAssistant.constants'
|
|
import {
|
|
isLogsSource,
|
|
sqlSourceToFenceLanguage,
|
|
} from '@/components/interfaces/SQLEditor/querySource'
|
|
import { authKeys } from '@/data/auth/keys'
|
|
import { databaseExtensionsKeys } from '@/data/database-extensions/keys'
|
|
import { databaseIndexesKeys } from '@/data/database-indexes/keys'
|
|
import { databasePoliciesKeys } from '@/data/database-policies/keys'
|
|
import { databaseTriggerKeys } from '@/data/database-triggers/keys'
|
|
import { databaseKeys } from '@/data/database/keys'
|
|
import { enumeratedTypesKeys } from '@/data/enumerated-types/keys'
|
|
import { handleError } from '@/data/fetchers'
|
|
import { tableKeys } from '@/data/tables/keys'
|
|
import { tryParseJson } from '@/lib/helpers'
|
|
import type { SqlSnippet } from '@/state/ai-assistant-state'
|
|
import { ResponseError } from '@/types'
|
|
|
|
export type MutationCategory = 'functions' | 'rls-policies'
|
|
|
|
// [Joshen] This is just very basic identification, but possible can extend perhaps
|
|
export const identifyQueryType = (query: string): MutationCategory | undefined => {
|
|
const formattedQuery = query.toLowerCase().replaceAll('\n', ' ')
|
|
if (
|
|
formattedQuery.includes('create function') ||
|
|
formattedQuery.includes('create or replace function')
|
|
) {
|
|
return 'functions'
|
|
} else if (formattedQuery.includes('create policy') || formattedQuery.includes('alter policy')) {
|
|
return 'rls-policies'
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// Check for function calls that aren't in the safe list
|
|
/** @deprecated [Joshen] Ideally we move away from this as this isn't a scalable way to deduce */
|
|
export const containsUnknownFunction = (query: string) => {
|
|
const normalizedQuery = query.trim().toLowerCase()
|
|
const functionCallRegex = /\w+\s*\(/g
|
|
const functionCalls = normalizedQuery.match(functionCallRegex) || []
|
|
|
|
return functionCalls.some((func) => {
|
|
const isReadOnlyFunc = SAFE_FUNCTIONS.some((safeFunc) => func.trim().toLowerCase() === safeFunc)
|
|
return !isReadOnlyFunc
|
|
})
|
|
}
|
|
|
|
/** @deprecated
|
|
* [Joshen] This isn't really a scalable way to reduce this behaviour, we now have support
|
|
* for a readonly connection string which we can use this to run queries, and is a much
|
|
* clearer way to deduce if the query is read only or not
|
|
*/
|
|
export const isReadOnlySelect = (query: string): boolean => {
|
|
const normalizedQuery = query.trim().toLowerCase()
|
|
|
|
// Check if it starts with SELECT
|
|
if (!normalizedQuery.startsWith('select')) return false
|
|
|
|
// List of keywords that indicate write operations
|
|
const writeOperations = ['insert', 'update', 'delete', 'alter', 'drop', 'create', 'replace']
|
|
|
|
// Words that may appear in column names etc
|
|
const allowedPatterns = ['created', 'inserted', 'updated', 'deleted', 'truncate']
|
|
|
|
// Check for any write operations
|
|
const hasWriteOperation = writeOperations.some((op) => {
|
|
// Ignore if part of allowed pattern
|
|
const isAllowed = allowedPatterns.some(
|
|
(allowed) => normalizedQuery.includes(allowed) && allowed.includes(op)
|
|
)
|
|
return !isAllowed && normalizedQuery.includes(op)
|
|
})
|
|
if (hasWriteOperation) return false
|
|
|
|
const hasUnknownFunction = containsUnknownFunction(normalizedQuery)
|
|
if (hasUnknownFunction) return false
|
|
|
|
return true
|
|
}
|
|
|
|
export const hasPendingToolApproval = (messages: Pick<UIMessage, 'role' | 'parts'>[]) => {
|
|
return messages.some((message) => {
|
|
if (message.role !== 'assistant') return false
|
|
|
|
return message.parts?.some((part) => isToolUIPart(part) && part.state === 'approval-requested')
|
|
})
|
|
}
|
|
|
|
export const resolvePendingToolApprovalsAsDenied = (messages: UIMessage[]): UIMessage[] => {
|
|
return messages.map((message) => {
|
|
if (message.role !== 'assistant') return message
|
|
|
|
const parts = message.parts?.map((part) => {
|
|
if (!isToolUIPart(part) || part.state !== 'approval-requested') return part
|
|
|
|
return {
|
|
...part,
|
|
state: 'output-denied',
|
|
approval: {
|
|
id: part.approval.id,
|
|
approved: false,
|
|
reason: 'Skipped because the user sent a follow-up message.',
|
|
},
|
|
} as UIMessage['parts'][number]
|
|
})
|
|
|
|
return { ...message, parts } as UIMessage
|
|
})
|
|
}
|
|
|
|
const getContextKey = (pathname: string) => {
|
|
const [, , , ...rest] = pathname.split('/')
|
|
const key = rest.join('/')
|
|
return key
|
|
}
|
|
|
|
export const getContextualInvalidationKeys = ({
|
|
ref,
|
|
pathname,
|
|
schema = 'public',
|
|
}: {
|
|
ref: string
|
|
pathname: string
|
|
schema?: string
|
|
}) => {
|
|
const key = getContextKey(pathname)
|
|
|
|
return (
|
|
(
|
|
{
|
|
'auth/users': [authKeys.usersInfinite(ref)],
|
|
'database/policies': [databasePoliciesKeys.list(ref)],
|
|
'database/functions': [databaseKeys.databaseFunctions(ref)],
|
|
'database/tables': [
|
|
tableKeys.list(ref, schema, { includeColumns: true }),
|
|
tableKeys.list(ref, schema, { includeColumns: false }),
|
|
],
|
|
'database/triggers': [databaseTriggerKeys.list(ref)],
|
|
'database/types': [enumeratedTypesKeys.list(ref)],
|
|
'database/extensions': [databaseExtensionsKeys.list(ref)],
|
|
'database/indexes': [databaseIndexesKeys.list(ref, schema)],
|
|
} as const
|
|
)[key] ?? []
|
|
)
|
|
}
|
|
|
|
export const onErrorChat = (error: Error) => {
|
|
const parsedError = error ? tryParseJson(error.message) : undefined
|
|
|
|
try {
|
|
handleError(parsedError?.error || parsedError || error)
|
|
} catch (e: any) {
|
|
if (e instanceof ResponseError) {
|
|
toast.error(e.message)
|
|
} else if (e instanceof Error) {
|
|
toast.error(e.message)
|
|
} else if (typeof e === 'string') {
|
|
toast.error(e)
|
|
} else {
|
|
toast.error('An unknown error occurred')
|
|
}
|
|
}
|
|
}
|
|
|
|
export function containsLogsSnippets(snippets: readonly SqlSnippet[] | undefined): boolean {
|
|
return (snippets ?? []).some(
|
|
(snippet) => typeof snippet !== 'string' && isLogsSource(snippet.source)
|
|
)
|
|
}
|
|
|
|
export const getSnippetLabel = (snippet: SqlSnippet, index: number): string =>
|
|
typeof snippet === 'string' ? `Snippet ${index + 1}` : snippet.label
|
|
|
|
export const getSnippetContent = (snippet: SqlSnippet): string =>
|
|
typeof snippet === 'string' ? snippet : snippet.content
|
|
|
|
/**
|
|
* The fence language an attached query is written into the message with. A logs query
|
|
* is fenced as `clickhouse` so the model can tell which attachment is ClickHouse
|
|
* against the `logs` table — a single message can carry both dialects.
|
|
*
|
|
* It also keeps the two apart in the rendered message: MessageMarkdown treats a `sql`
|
|
* fence as runnable Postgres (`DisplayBlockRenderer`, branded with `untrustedSql`),
|
|
* which a ClickHouse query must never be offered as.
|
|
*/
|
|
function getSnippetFenceLanguage(snippet: SqlSnippet): 'sql' | 'clickhouse' {
|
|
return sqlSourceToFenceLanguage(typeof snippet === 'string' ? undefined : snippet.source)
|
|
}
|
|
|
|
/**
|
|
* Renders attached queries as the fenced code blocks appended to the message text,
|
|
* each labelled with its own dialect.
|
|
*/
|
|
export function formatAttachedSnippets(snippets: readonly SqlSnippet[]): string {
|
|
return snippets
|
|
.map(
|
|
(snippet) =>
|
|
'```' + getSnippetFenceLanguage(snippet) + '\n' + getSnippetContent(snippet) + '\n```'
|
|
)
|
|
.join('\n')
|
|
}
|