Files
supabase/apps/studio/components/interfaces/SQLEditor/querySource.test.ts
Charis 21511042a3 feat(studio): assistant logs context and reports guard (#48514)
## 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 -->
2026-08-04 09:02:40 -04:00

328 lines
11 KiB
TypeScript

import dayjs from 'dayjs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
datePickerValueToLogDateRange,
DEFAULT_LOG_DATE_RANGE,
getSnippetSource,
isLogsSource,
isoDateTimeString,
logDateRangesEqual,
logDateRangeToDatePickerValue,
resolveLogRunRange,
resolveSnippetSource,
sqlSourceToFenceLanguage,
type LogDateRange,
} from './querySource'
import {
EXPLORER_DATEPICKER_HELPERS,
getDefaultHelper,
} from '@/components/interfaces/Settings/Logs/Logs.constants'
import { generateHelpersFromInput } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers'
import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
import type { DatetimeHelper } from '@/components/interfaces/Settings/Logs/Logs.types'
/** Build the `DatePickerValue` the Logs picker submits when a helper is selected. */
const valueFromHelper = (helper: DatetimeHelper): DatePickerValue => ({
from: helper.calcFrom(),
to: helper.calcTo(),
isHelper: true,
text: helper.text,
})
/** The single dynamic helper produced from typed input like "2h" / "30m". */
const dynamicHelper = (input: string): DatetimeHelper => {
const generated = generateHelpersFromInput(input)
if (!generated || generated.length !== 1) {
throw new Error(`Expected a single dynamic helper for "${input}"`)
}
return generated[0]
}
describe('querySource.ts:getSnippetSource', () => {
it('maps log_sql to the logs source', () => {
expect(getSnippetSource({ type: 'log_sql' })).toBe('logs')
})
it('maps sql to the database source', () => {
expect(getSnippetSource({ type: 'sql' })).toBe('database')
})
it('maps report to the database source', () => {
expect(getSnippetSource({ type: 'report' })).toBe('database')
})
})
describe('querySource.ts:isLogsSource', () => {
it('is true only for the logs source', () => {
expect(isLogsSource('logs')).toBe(true)
expect(isLogsSource('database')).toBe(false)
})
it('is false for an absent source', () => {
expect(isLogsSource(undefined)).toBe(false)
})
})
describe('querySource.ts:sqlSourceToFenceLanguage', () => {
it('labels a logs query as clickhouse and everything else as sql', () => {
expect(sqlSourceToFenceLanguage('logs')).toBe('clickhouse')
expect(sqlSourceToFenceLanguage('database')).toBe('sql')
})
// Attachments can carry no source; those are Postgres SQL.
it('treats an absent source as sql', () => {
expect(sqlSourceToFenceLanguage(undefined)).toBe('sql')
})
})
describe('querySource.ts:resolveSnippetSource', () => {
it('prefers the snippet type over the URL param', () => {
expect(resolveSnippetSource({ type: 'log_sql' }, undefined)).toBe('logs')
// A stale/mismatched param must not override a snippet that already exists.
expect(resolveSnippetSource({ type: 'sql' }, 'logs')).toBe('database')
})
// A fresh `/sql/new` tab has no snippet until the first keystroke, so the param is
// the only signal that it is a logs tab.
it('falls back to the URL param before the snippet exists', () => {
expect(resolveSnippetSource(undefined, 'logs')).toBe('logs')
expect(resolveSnippetSource(undefined, undefined)).toBe('database')
expect(resolveSnippetSource(undefined, 'nonsense')).toBe('database')
})
})
describe('querySource.ts:isoDateTimeString', () => {
it('accepts a valid ISO datetime', () => {
const raw = '2025-01-01T12:00:00.000Z'
expect(isoDateTimeString(raw)).toBe(raw)
})
it('rejects an empty string', () => {
expect(isoDateTimeString('')).toBeNull()
})
it('rejects junk', () => {
expect(isoDateTimeString('not-a-date')).toBeNull()
expect(isoDateTimeString('2025-13-45T99:99:99Z')).toBeNull()
})
})
describe('querySource.ts:datePickerValueToLogDateRange', () => {
it('parses every static preset into a relative range', () => {
const cases: Array<[string, { amount: number; unit: 'minute' | 'hour' | 'day' }]> = [
['Last hour', { amount: 1, unit: 'hour' }],
['Last 3 hours', { amount: 3, unit: 'hour' }],
['Last 24 hours', { amount: 24, unit: 'hour' }],
['Last 3 days', { amount: 3, unit: 'day' }],
['Last 7 days', { amount: 7, unit: 'day' }],
]
for (const [text, last] of cases) {
const helper = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === text)
expect(helper, `preset "${text}" exists`).toBeDefined()
expect(datePickerValueToLogDateRange(valueFromHelper(helper!))).toEqual({
kind: 'relative',
last,
})
}
})
it('parses dynamic helpers ("2h", "30m", "7d") into relative ranges', () => {
expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('2h')))).toEqual({
kind: 'relative',
last: { amount: 2, unit: 'hour' },
})
expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('30m')))).toEqual({
kind: 'relative',
last: { amount: 30, unit: 'minute' },
})
expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('7d')))).toEqual({
kind: 'relative',
last: { amount: 7, unit: 'day' },
})
})
it('treats a preset (calcTo() === "") as relative — the empty end means "now"', () => {
const lastHour = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS)
const value = valueFromHelper(lastHour)
expect(value.to).toBe('')
expect(datePickerValueToLogDateRange(value)).toEqual({
kind: 'relative',
last: { amount: 1, unit: 'hour' },
})
})
it('degrades an unparseable helper to an absolute range using from/now (never empty)', () => {
vi.useFakeTimers()
const now = new Date('2025-01-01T12:00:00.000Z')
vi.setSystemTime(now)
const from = '2024-12-31T00:00:00.000Z'
const result = datePickerValueToLogDateRange({
from,
to: '',
isHelper: true,
text: 'Some custom label',
})
expect(result).toEqual({
kind: 'absolute',
from,
to: dayjs(now).toISOString(),
})
})
it('maps a custom (non-helper) pick to an absolute range with both endpoints', () => {
const from = '2025-01-01T00:00:00.000Z'
const to = '2025-01-02T00:00:00.000Z'
expect(datePickerValueToLogDateRange({ from, to, isHelper: false })).toEqual({
kind: 'absolute',
from,
to,
})
})
it('falls back to the default range when the value has no usable from', () => {
expect(datePickerValueToLogDateRange({ from: '', to: '', isHelper: false })).toEqual(
DEFAULT_LOG_DATE_RANGE
)
})
})
describe('querySource.ts:logDateRangeToDatePickerValue', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z'))
})
it('renders a relative range exactly as the picker would emit the helper', () => {
const value = logDateRangeToDatePickerValue({
kind: 'relative',
last: { amount: 1, unit: 'hour' },
})
expect(value).toEqual({
from: dayjs().subtract(1, 'hour').toISOString(),
to: dayjs().toISOString(),
isHelper: true,
text: 'Last 1 hour',
})
})
it('pluralizes the label for amounts greater than one', () => {
expect(
logDateRangeToDatePickerValue({ kind: 'relative', last: { amount: 3, unit: 'day' } }).text
).toBe('Last 3 days')
})
it('round-trips through datePickerValueToLogDateRange', () => {
const range: LogDateRange = { kind: 'relative', last: { amount: 30, unit: 'minute' } }
expect(datePickerValueToLogDateRange(logDateRangeToDatePickerValue(range))).toEqual(range)
})
it('passes an absolute range through as a non-helper value', () => {
const range: LogDateRange = {
kind: 'absolute',
from: isoDateTimeString('2025-01-01T00:00:00.000Z')!,
to: isoDateTimeString('2025-01-02T00:00:00.000Z')!,
}
expect(logDateRangeToDatePickerValue(range)).toEqual({
from: '2025-01-01T00:00:00.000Z',
to: '2025-01-02T00:00:00.000Z',
isHelper: false,
})
})
})
describe('querySource.ts:resolveLogRunRange', () => {
afterEach(() => {
vi.useRealTimers()
})
it('re-resolves a relative range against the current time', () => {
vi.useFakeTimers()
const now = new Date('2025-06-15T08:30:00.000Z')
vi.setSystemTime(now)
expect(resolveLogRunRange({ kind: 'relative', last: { amount: 2, unit: 'hour' } })).toEqual({
from: dayjs(now).subtract(2, 'hour').toISOString(),
to: dayjs(now).toISOString(),
})
})
it('re-resolves the same relative range differently as time advances', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-06-15T08:00:00.000Z'))
const first = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } })
vi.setSystemTime(new Date('2025-06-15T10:00:00.000Z'))
const second = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } })
expect(first).not.toEqual(second)
expect(second.to).toBe(dayjs('2025-06-15T10:00:00.000Z').toISOString())
})
it('passes an absolute range through unchanged', () => {
const from = isoDateTimeString('2025-01-01T00:00:00.000Z')!
const to = isoDateTimeString('2025-01-02T00:00:00.000Z')!
expect(resolveLogRunRange({ kind: 'absolute', from, to })).toEqual({
from: '2025-01-01T00:00:00.000Z',
to: '2025-01-02T00:00:00.000Z',
})
})
})
describe('querySource.ts:logDateRangesEqual', () => {
it('matches relative ranges on amount + unit regardless of label formatting', () => {
const lastHourPreset = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === 'Last hour')!
const presetRange = datePickerValueToLogDateRange({
from: lastHourPreset.calcFrom(),
to: lastHourPreset.calcTo(),
isHelper: true,
text: lastHourPreset.text,
})
expect(
logDateRangesEqual(presetRange, { kind: 'relative', last: { amount: 1, unit: 'hour' } })
).toBe(true)
})
it('does not match relative ranges with a different amount or unit', () => {
expect(
logDateRangesEqual(
{ kind: 'relative', last: { amount: 1, unit: 'hour' } },
{ kind: 'relative', last: { amount: 3, unit: 'hour' } }
)
).toBe(false)
expect(
logDateRangesEqual(
{ kind: 'relative', last: { amount: 1, unit: 'hour' } },
{ kind: 'relative', last: { amount: 1, unit: 'day' } }
)
).toBe(false)
})
it('matches absolute ranges on their ISO endpoints', () => {
const from = isoDateTimeString('2025-01-01T00:00:00.000Z')!
const to = isoDateTimeString('2025-01-02T00:00:00.000Z')!
expect(logDateRangesEqual({ kind: 'absolute', from, to }, { kind: 'absolute', from, to })).toBe(
true
)
})
it('never matches a relative range against an absolute one', () => {
const from = isoDateTimeString('2025-01-01T00:00:00.000Z')!
const to = isoDateTimeString('2025-01-02T00:00:00.000Z')!
expect(
logDateRangesEqual(
{ kind: 'relative', last: { amount: 1, unit: 'hour' } },
{
kind: 'absolute',
from,
to,
}
)
).toBe(false)
})
})