From afda8461a39a151486c273340f4f5ca45e76563a Mon Sep 17 00:00:00 2001 From: Shardul Borhade Date: Tue, 4 Aug 2026 16:02:50 +0530 Subject: [PATCH 01/13] Adding new joiner Shardul Borhade in human.txt (#48675) ## 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? update humans.txt ## What is the current behavior? NA ## What is the new behavior? NA ## Additional context Part of Onboarding ## Summary by CodeRabbit * **Documentation** * Added Shardul Borhade to the project team listing. Co-authored-by: Shardul Borhade Co-authored-by: Ivan Vasilov --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 990fd6c989ee7..144db29f2ae6c 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -271,6 +271,7 @@ Shaii O Shane Adams Shane E Shaun Newman +Shardul Borhade Shreekar Shetty Sreyas Udayavarman Stephanie Jackson (stejacks) From 2ba2c37163b528ee23da48cd1d0655a5291871dc Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:37:59 +0200 Subject: [PATCH 02/13] fix(studio): preserve deep-linked date range in Unified Logs FE-4020 (#48685) ## Problem Clicking a bar in a usage chart (e.g. the Postgres activity chart on the project dashboard) navigates to Unified Logs with the log_type filter applied correctly, but the clicked bar's time range is silently dropped: the page falls back to the default last-hour window. If the actual matching logs are outside that window, the main list shows "No results found" even though the sidebar facet count (computed from the correct deep-linked range) shows a nonzero count. Root cause: the table's initial `columnFilters` state was seeded only from the `filter` URL param, never from `date`. A debounced effect syncs `columnFilters` back into the URL shortly after mount, and for the `date` field it treats a missing `columnFilters` entry as a cleared brush, overwriting the deep-linked `date` param with null. ## Fix Added `buildDefaultColumnFilters` in `UnifiedLogs.filters.ts`, which seeds a `date` entry into the initial `columnFilters` from `search.date` when present, alongside the existing filter-param seeding. `UnifiedLogs.tsx` now uses this helper instead of building `defaultColumnFilters` inline, so a deep-linked range survives the debounced round-trip instead of getting nulled out. ## How to test - On the project dashboard, click a bar in a usage chart (e.g. Postgres activity) for a time period further back than the last hour. - Expected result: Unified Logs opens with both the log_type filter and the clicked bar's date range applied, and the row list matches the sidebar facet count instead of showing "No results found". - `UnifiedLogs.filters.test.ts` has unit tests covering the new seeding behavior. ## Summary by CodeRabbit * **Bug Fixes** * Improved log filtering from URL parameters. * Preserved valid date ranges when opening deep-linked log views. * Prevented malformed or duplicate date filters from appearing in the logs table. * **Tests** * Added coverage for valid, missing, malformed, and duplicate date filter scenarios. --- .../UnifiedLogs/UnifiedLogs.filters.test.ts | 44 +++++++++++++++++++ .../UnifiedLogs/UnifiedLogs.filters.ts | 13 ++++++ .../interfaces/UnifiedLogs/UnifiedLogs.tsx | 4 +- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.filters.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.filters.test.ts index 68538d1680df6..d78b2f75b8607 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.filters.test.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.filters.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + buildDefaultColumnFilters, buildFilterSearchUpdate, columnFiltersToLogsFilters, logsFiltersToColumnFilters, @@ -122,3 +123,46 @@ describe('buildFilterSearchUpdate', () => { expect(update.date).toBeNull() }) }) + +describe('buildDefaultColumnFilters', () => { + const fields = [ + { value: 'date', type: 'timerange' }, + { value: 'log_type', type: 'checkbox' }, + ] + + it('seeds a deep-linked `date` range so it survives the debounced sync back to the URL', () => { + const range = [new Date('2026-05-08T00:00:00Z'), new Date('2026-05-08T01:00:00Z')] + const columnFilters = buildDefaultColumnFilters({ + filter: ['log_type:eq:postgres'], + date: range, + }) + expect(columnFilters).toEqual([ + { id: 'log_type', value: ['postgres'] }, + { id: 'date', value: range }, + ]) + + // Regression guard: without the `date` entry above, this would null out the range. + const update = buildFilterSearchUpdate(columnFilters, fields) + expect(update.date).toBe(range) + }) + + it('omits `date` when no range is present, matching the pre-existing no-filter case', () => { + expect(buildDefaultColumnFilters({ filter: ['log_type:eq:postgres'], date: null })).toEqual([ + { id: 'log_type', value: ['postgres'] }, + ]) + }) + + it('omits `date` for a malformed single-element range', () => { + const columnFilters = buildDefaultColumnFilters({ + filter: ['log_type:eq:postgres'], + date: [new Date('2026-05-08T00:00:00Z')], + }) + expect(columnFilters).toEqual([{ id: 'log_type', value: ['postgres'] }]) + }) + + it('does not duplicate the `date` id when a hand-crafted `filter` param also targets it', () => { + const range = [new Date('2026-05-08T00:00:00Z'), new Date('2026-05-08T01:00:00Z')] + const columnFilters = buildDefaultColumnFilters({ filter: ['date:eq:123'], date: range }) + expect(columnFilters).toEqual([{ id: 'date', value: range }]) + }) +}) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.filters.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.filters.ts index 32614bf908836..92a4549d20845 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.filters.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.filters.ts @@ -89,6 +89,19 @@ export const logsFiltersToColumnFilters = ( ) } +// Seeds `date` too, so `logsFiltersToColumnFilters` (which only covers the `filter` +// param) doesn't leave it out and get nulled by the debounced sync back to `search`. +export const buildDefaultColumnFilters = (search: { + filter?: string[] | null + date?: Date[] | null +}): { id: string; value: unknown }[] => { + const filters: { id: string; value: unknown }[] = logsFiltersToColumnFilters( + parseLogsFilterUrlParams(search.filter) + ).filter((f) => f.id !== 'date') + if (search.date?.length === 2) filters.push({ id: 'date', value: search.date }) + return filters +} + export const columnFiltersToLogsFilters = ( columnFilters: { id: string; value: unknown }[], filterableNames?: Set diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index 7981955ec7571..d9f4ed0f0019c 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -38,8 +38,8 @@ import { ServiceFlowPanel } from './ServiceFlowPanel' import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' import { filterFields as defaultFilterFields } from './UnifiedLogs.fields' import { + buildDefaultColumnFilters, buildFilterSearchUpdate, - logsFiltersToColumnFilters, parseLogsFilterUrlParams, } from './UnifiedLogs.filters' import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks' @@ -96,7 +96,7 @@ export const UnifiedLogs = () => { const defaultColumnSorting = search.sort ? [search.sort] : [] const defaultColumnVisibility = { uuid: false } - const defaultColumnFilters = logsFiltersToColumnFilters(parseLogsFilterUrlParams(search.filter)) + const defaultColumnFilters = buildDefaultColumnFilters(search) const [topBarHeight, setTopBarHeight] = useState(0) const topBarRef = useRef(null) From a66dae48f27ee7023483c1846b492b6ff05b277f Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:36:06 +0200 Subject: [PATCH 03/13] fix(studio): restart action for table editor load errors FE-4054 (#48687) ## Problem When the table editor showed a "Failed to load tables" or "Failed to load schemas" error (for example, when the underlying database or API gateway is unhealthy), there was no working way to restart the project from that error state. Restarting only worked by navigating to Project Settings. ## Fix "Failed to load tables" goes through the existing `ErrorMatcher` classification system, which only showed troubleshooting steps (including a restart action) for connection-timeout errors. Added an `ERROR_MAPPINGS` entry for the unclassified/generic API error case, reusing the existing `RestartDatabaseTroubleshootingSection` and `RestartProjectDialog` components already used for connection timeouts. "Failed to load schemas" (in the shared `SchemaSelector`, used across the table editor and several Database pages) only offered a retry. Added a "Restart database" button next to it, wired to the same `RestartProjectDialog`. ## How to test - In the table editor, trigger a table-load failure that isn't a connection timeout (any generic API error). The error card should now show a "Try restarting your project" step with a working restart action. - Open the schema selector while schemas fail to load (e.g. mock a 503 from the schemas query). A "Restart database" button should appear next to "Reload schemas" and open the restart confirmation dialog. - `apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx` and `apps/studio/components/ui/SchemaSelector.test.tsx` cover both cases. FE-4054 ## Summary by CodeRabbit * **New Features** * Added database restart guidance when schema loading fails. * Added options to reload schemas or restart the database, including a confirmation prompt. * Added troubleshooting guidance for unclassified table-loading errors. * **Bug Fixes** * Improved error handling by displaying relevant fallback guidance for unknown errors while preserving classified troubleshooting instructions. --- .../ErrorHandling/ErrorMatcher.test.tsx | 26 +++++++++++++ .../interfaces/ErrorHandling/ErrorMatcher.tsx | 13 ++++++- .../RestartTroubleshootingFallback.tsx | 15 ++++++++ .../TableEditorLayout/TableEditorMenu.tsx | 2 + .../components/ui/SchemaSelector.test.tsx | 38 ++++++++++++++++++- apps/studio/components/ui/SchemaSelector.tsx | 18 +++++++-- 6 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 apps/studio/components/interfaces/ErrorHandling/RestartTroubleshootingFallback.tsx diff --git a/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx index 01e58b27988bf..d3f551ce4d50f 100644 --- a/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx +++ b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx @@ -66,6 +66,32 @@ describe('ErrorMatcher', () => { expect(screen.getByText('UNKNOWN ERROR')).toBeInTheDocument() }) + it('renders the caller-provided fallback when the error is unclassified', () => { + render( + Custom fallback} + /> + ) + expect(screen.getByText('Custom fallback')).toBeInTheDocument() + }) + + it('ignores the caller-provided fallback when the error is classified', () => { + const error = new ConnectionTimeoutError('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT') + render( + Custom fallback} + /> + ) + expect(screen.queryByText('Custom fallback')).not.toBeInTheDocument() + expect(screen.getByText('Try restarting your project')).toBeInTheDocument() + }) + it('accepts error as object with message property', () => { render( - {Troubleshooting && } + {Troubleshooting ? : fallback} ) } diff --git a/apps/studio/components/interfaces/ErrorHandling/RestartTroubleshootingFallback.tsx b/apps/studio/components/interfaces/ErrorHandling/RestartTroubleshootingFallback.tsx new file mode 100644 index 0000000000000..6d715d98ff663 --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/RestartTroubleshootingFallback.tsx @@ -0,0 +1,15 @@ +import { TroubleshootingAccordion } from './TroubleshootingAccordion' +import { RestartDatabaseTroubleshootingSection } from './TroubleshootingSections' + +const ERROR_TYPE = 'unknown' + +export function RestartTroubleshootingFallback() { + return ( + + + + ) +} diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 80710c9213655..7dcf8969319da 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -21,6 +21,7 @@ import { parseSupaTable } from '@/components/grid/SupabaseGrid.utils' import { SupaTable } from '@/components/grid/types' import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning' import { ErrorMatcher } from '@/components/interfaces/ErrorHandling/ErrorMatcher' +import { RestartTroubleshootingFallback } from '@/components/interfaces/ErrorHandling/RestartTroubleshootingFallback' import { EditorMenuListSkeleton } from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList' @@ -305,6 +306,7 @@ export const TableEditorMenu = () => { error={error ?? 'Failed to load tables'} supportFormParams={{ projectRef: project?.ref }} className="mx-4 mt-3" + fallback={} /> )} diff --git a/apps/studio/components/ui/SchemaSelector.test.tsx b/apps/studio/components/ui/SchemaSelector.test.tsx index 0cff282ba8b20..20500671f373c 100644 --- a/apps/studio/components/ui/SchemaSelector.test.tsx +++ b/apps/studio/components/ui/SchemaSelector.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { SchemaSelector } from './SchemaSelector' import { customRender } from '@/tests/lib/custom-render' -import { addAPIMock } from '@/tests/lib/msw' +import { addAPIMock, APIErrorBody } from '@/tests/lib/msw' mockAnimationsApi() @@ -41,6 +41,30 @@ const mockProjectAndSchemas = ({ highAvailability }: { highAvailability: boolean }) } +const mockProjectAndFailingSchemas = () => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + // @ts-expect-error partial project response + response: { + cloud_provider: 'localhost', + id: 1, + inserted_at: '2021-08-02T06:40:40.646Z', + name: 'Default Project', + organization_id: 1, + ref: 'default', + region: 'local', + status: 'ACTIVE_HEALTHY', + }, + }) + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => + HttpResponse.json({ message: 'Service unavailable' }, { status: 503 }), + }) +} + const renderAndOpenSelector = async () => { customRender() @@ -66,4 +90,16 @@ describe('SchemaSelector', () => { expect(screen.getByRole('option', { name: 'multigres' })).toBeInTheDocument() }) + + it('offers to restart the database when schemas fail to load', async () => { + mockProjectAndFailingSchemas() + + customRender() + + await userEvent.click(await screen.findByRole('button', { name: 'Restart database' })) + + expect( + await screen.findByText(/are you sure you want to restart your database/i) + ).toBeInTheDocument() + }) }) diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx index a051ce4c6f4cc..d7cd9d1a0c37b 100644 --- a/apps/studio/components/ui/SchemaSelector.tsx +++ b/apps/studio/components/ui/SchemaSelector.tsx @@ -20,6 +20,7 @@ import { Skeleton, } from 'ui' +import { RestartProjectDialog } from '@/components/interfaces/ErrorHandling/RestartProjectDialog' import { useSchemasQuery } from '@/data/database/schemas-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSchemasFilteredForHighAvailability } from '@/hooks/misc/useHighAvailability' @@ -65,6 +66,7 @@ export const SchemaSelector = forwardRef( ref ) => { const [internalOpen, setInternalOpen] = useState(false) + const [isRestartDialogVisible, setIsRestartDialogVisible] = useState(false) const isControlled = openProp !== undefined const open = isControlled ? openProp : internalOpen const setOpen = (next: boolean) => { @@ -119,9 +121,19 @@ export const SchemaSelector = forwardRef( Error: {(schemasError as any)?.message} - +
+ + +
+ setIsRestartDialogVisible(false)} + restartType="database" + /> )} From 31d1a639c0aa386b61732598fa782e39dbaa621d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 4 Aug 2026 09:58:13 -0300 Subject: [PATCH 04/13] docs: Update SDK references from recent releases (#47830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Updates SDK reference docs and the client-side tracing guide based on recent releases across all six Supabase SDKs. ## SDKs analyzed | SDK | Repo | Latest commit | Latest tag | |-----|------|--------------|------------| | js | supabase/supabase-js | `e4e8864` | v3.0.0-next.29 | | dart | supabase/supabase-flutter | `c3e3602` | yet_another_json_isolate-v2.1.1 | | py | supabase/supabase-py | `6570638` | v3.0.0a1 | | swift | supabase/supabase-swift | `ebef170` | v2.51.0 | | kt | supabase-community/supabase-kt | `e23df20` | 3.7.0-beta-1 | | csharp | supabase-community/supabase-csharp | `3fad62f` | v1.1.2 | ## Documentation changes ### `apps/docs/spec/supabase_dart_v2.yml` - **OAuth Server API** ([supabase-flutter#1561](https://github.com/supabase/supabase-flutter/pull/1561)): Added `oauth-server-api` group stub and `listGrants()` / `revokeGrant()` method entries, matching the existing `common-client-libs-sections.json` nav IDs. - **`listBuckets()` options** ([supabase-flutter#1557](https://github.com/supabase/supabase-flutter/pull/1557)): Added example showing `ListBucketsOptions` with `search`, `limit`, `offset`, `sortColumn`, and `sortOrder`. ### `apps/docs/spec/supabase_py_v2.yml` - **`on_postgres_changes` `select` param** ([supabase-py#1524](https://github.com/supabase/supabase-py/pull/1524)): Added `listening-to-selected-columns` example for the new `select=["id", "name"]` parameter. - **Expanded filter operators** ([supabase-py#1524](https://github.com/supabase/supabase-py/pull/1524)): Updated `listening-to-row-level-changes` note to list all supported operators (`eq`, `neq`, `lt`, `lte`, `gt`, `gte`, `in`, `like`, `ilike`, `is`, `match`, `imatch`, `isdistinct`) plus `not.` prefix and comma-AND. ### `apps/docs/spec/supabase_swift_v2.yml` - **OpenTelemetry tracing setup** ([supabase-swift#1101](https://github.com/supabase/supabase-swift/pull/1101)): Added `initialize-client-with-opentelemetry` example under the `initializing` section documenting the `OpenTelemetry` SwiftPM package trait, provider wiring, and known `_invokeWithStreamedResponse` limitation. ### `apps/docs/content/guides/telemetry/client-side-tracing.mdx` - **Merged Swift and Dart tracing docs** into the existing JS guide ([supabase-swift#1101](https://github.com/supabase/supabase-swift/pull/1101), [supabase-flutter#1564](https://github.com/supabase/supabase-flutter/pull/1564)). - **Converted to tabbed layout** (``) with JavaScript / Swift / Dart tabs, matching the pattern used across other multi-SDK guides. - Updated title to "Client-side tracing" and nav label accordingly. ## SDKs with no doc-worthy changes - **js**: Bug fixes only (auth session clearing, realtime heartbeat suppression) β€” no new API surface. - **kt**: PKCE for `resend()` β€” behavioral enhancement, no new spec entry needed. - **csharp**: Chore/compliance/maintenance only. --- πŸ€– Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Documentation** * Added Dart β€œOAuth Server” API docs for listing OAuth grants and revoking grants (including signed-in context and the `clientId` parameter), with examples. * Extended Dart Storage docs with a new `listBuckets` example using `ListBucketsOptions` for filtering, pagination, and sorting. * Updated Python Realtime docs with generalized PostgREST-style row filter operators and added examples for listening to selected columns. * Reworked the β€œClient-side tracing” guide across JS, Swift, and Dart, including expanded configuration and troubleshooting (trace propagation and `traceparent` details). * Renamed the telemetry navigation label to β€œClient-side tracing.” --------- Co-authored-by: Claude Sonnet 4.6 --- .../NavigationMenu.constants.ts | 2 +- .../client-side-tracing.mdx | 142 ++++++++++++++++-- apps/docs/scripts/generate-dart-reference.ts | 2 +- apps/docs/spec/supabase_py_v2.yml | 26 +++- apps/docs/spec/supabase_swift_v2.yml | 20 +++ supa-mdx-lint/Rule003Spelling.toml | 1 + 6 files changed, 173 insertions(+), 20 deletions(-) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 08c99b5eed010..77cbb1c8ef808 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -3062,7 +3062,7 @@ export const telemetry: NavMenuConstant = { url: '/guides/monitoring-and-debugging/sentry-monitoring' as `/${string}`, }, { - name: 'Tracing with the JS SDK', + name: 'Tracing with the client SDKs', url: '/guides/monitoring-and-debugging/client-side-tracing' as `/${string}`, }, ], diff --git a/apps/docs/content/guides/monitoring-and-debugging/client-side-tracing.mdx b/apps/docs/content/guides/monitoring-and-debugging/client-side-tracing.mdx index 901017e3b2938..44b8a378ac486 100644 --- a/apps/docs/content/guides/monitoring-and-debugging/client-side-tracing.mdx +++ b/apps/docs/content/guides/monitoring-and-debugging/client-side-tracing.mdx @@ -1,12 +1,22 @@ --- id: 'client-side-tracing' -title: 'Tracing with the JS SDK' -description: 'Propagate W3C trace context from the Supabase JS SDK through Supabase services' +title: 'Client-side tracing' +description: 'Propagate W3C trace context from the Supabase JS, Swift, and Dart SDKs through Supabase services' --- -The Supabase JS SDK can attach [W3C Trace Context](https://www.w3.org/TR/trace-context/) headers (`traceparent`, `tracestate`, `baggage`) to outgoing requests. The resulting `trace_id` flows through Supabase services and appears in API Gateway and Edge Function logs, so you can correlate client-side spans with the server-side logs they produced β€” end-to-end, across the network boundary. +The Supabase JS, Swift, and Dart SDKs can attach [W3C Trace Context](https://www.w3.org/TR/trace-context/) headers (`traceparent`, `tracestate`, `baggage`) to outgoing requests. The resulting `trace_id` flows through Supabase services and appears in API Gateway and Edge Function logs, so you can correlate client-side spans with the server-side logs they produced β€” end-to-end, across the network boundary. -Because the headers follow the W3C standard, any compliant tracing SDK (OpenTelemetry, Sentry, Datadog, Honeycomb, etc.) can pick up the trace on the server side, including in self-hosted collectors. +Because the headers follow the W3C standard, any compliant tracing SDK (such as OpenTelemetry, Sentry, Datadog, or Honeycomb) can pick up the trace on the server side, including in self-hosted collectors. + + + + ## Requirements @@ -34,7 +44,7 @@ Trace propagation isn't available through the CDN (UMD) build β€” there's no way The SDK reads from whatever `TracerProvider` you register globally β€” it doesn't configure one for you. If you haven't instrumented your app yet, follow the [OpenTelemetry JavaScript getting started guide](https://opentelemetry.io/docs/languages/js/getting-started/) to install an SDK (`@opentelemetry/sdk-trace-node` for Node, `@opentelemetry/sdk-trace-web` for browsers) and an exporter for your backend (OTLP, Jaeger, Zipkin, or a vendor-specific one). -The Supabase SDK only takes care of propagating the trace context that's already active when a request is made. +The Supabase SDK only propagates the trace context that's already active when a request is made. ## Enable trace propagation @@ -84,18 +94,9 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { | `enabled` | `boolean` | `false` | Enable trace propagation. | | `respectSamplingDecision` | `boolean` | `true` | If `true`, skip propagation when the upstream trace is not sampled. | -## Correlating with Supabase logs - -Once trace context is flowing through, the `trace_id` appears in: - -- **API Gateway logs** β€” every request to PostgREST, Auth, Storage, and Realtime -- **Edge Function logs** β€” invocations and any structured logs emitted from within the function - -If you forward Supabase logs to a third-party backend via [Log Drains](/docs/guides/monitoring-and-debugging/log-drains), you can join Supabase logs to your own client and server traces using the shared `trace_id`. This is especially useful for self-hosted setups where you already operate your own OpenTelemetry collector β€” Supabase logs become first-class citizens in your existing tracing UI. - ## Using a vendor tracing SDK -Many tracing SDKs are built on top of OpenTelemetry. They work with this guide as long as a W3C-compliant propagator is registered β€” but propagator behavior varies. Some vendor SDKs inject only their proprietary headers by default and need extra configuration to also emit the standard `traceparent` header. Check your vendor's OTel integration docs for the exact setup. +Many tracing SDKs are built on top of OpenTelemetry. They work with this guide as long as a W3C-compliant propagator is registered. Some vendor SDKs inject only their proprietary headers by default and need extra configuration to also emit the standard `traceparent` header. Check your vendor's OTel integration docs for the exact setup. ## Troubleshooting @@ -104,7 +105,116 @@ The SDK never throws when it can't propagate, which keeps it safe to enable but - **The tracing runtime isn't loaded** (version `2.112.0` and later). `tracePropagation` is enabled but your entry point never imports `@supabase/supabase-js/tracing`. The SDK logs a one-time console warning and sends requests without trace headers β€” look for that warning in your console. - **No active span at request time.** The SDK reads the _current_ context. If `supabase.from(...)` is called outside `tracer.startActiveSpan(...)` (or equivalent), there's nothing to propagate. Wrap the call in a span or use OpenTelemetry's automatic instrumentation. - **`@opentelemetry/api` is not installed** in the app making the request. On `2.112.0` and later the tracing subpath imports it directly, so a missing package surfaces as a module resolution error. On `2.106.0`–`2.111.x` it's loaded dynamically and the SDK silently no-ops. -- **No `TracerProvider` registered.** `@opentelemetry/api` defaults to a noop provider that produces non-recorded spans. Make sure your app calls `provider.register()` (or your vendor SDK's equivalent) before making requests. +- **No `TracerProvider` registered.** `@opentelemetry/api` defaults to a noop provider that produces non-recorded spans. Ensure your app calls `provider.register()` (or your vendor SDK's equivalent) before making requests. - **The upstream trace is not sampled.** By default the SDK respects upstream sampling decisions. Set `respectSamplingDecision: false` to propagate every request regardless of sampling. - **You're calling a non-Supabase host through a custom `fetch`.** Trace headers are only attached to Supabase domains (`*.supabase.co`, `*.supabase.in`, `localhost`). - **You're using the CDN (UMD) build.** Trace propagation isn't available there β€” the tracing runtime can't be loaded from a script tag. + + + + + +Requires `supabase-swift` `2.51.0` or later and `swift-tools-version: 6.1` or later (SwiftPM trait support). + +1. **Add the `OpenTelemetry` trait** to your dependency declaration in `Package.swift`: + + ```swift + // Package.swift + .package( + url: "https://github.com/supabase/supabase-swift.git", + from: "2.51.0", + traits: ["OpenTelemetry"] + ) + ``` + + No changes to `SupabaseClient` are required. After enabling the trait, the active OpenTelemetry span's trace context is automatically injected as a `traceparent` header on every outgoing request across PostgREST, Storage, Auth, Functions, and Realtime. When there is no active span, the header is not added. + +2. **Register a `TracerProvider`** at app start. The SDK reads from whatever provider you register globally: + + ```swift + import Supabase + import OpenTelemetryApi + import OpenTelemetrySdk + + let exporter = /* your OTLP / Jaeger / Zipkin exporter */ + let spanProcessor = SimpleSpanProcessor(spanExporter: exporter) + let provider = TracerProviderBuilder() + .add(spanProcessor: spanProcessor) + .build() + OpenTelemetry.registerTracerProvider(tracerProvider: provider) + ``` + +3. **Create your `SupabaseClient`**. Any active span is now propagated automatically: + + ```swift + let supabase = SupabaseClient( + supabaseURL: URL(string: "https://xyzcompany.supabase.co")!, + supabaseKey: "your-publishable-key" + ) + ``` + + + + + +Requires `supabase` `2.x` or later (Flutter or Dart-only). + +1. **Implement a `traceContextProvider`** that returns the current `TraceContext` from your tracing library. Return `null` when there is no active span. + +2. **Pass `TracePropagationOptions`** when creating the client: + + ```dart + import 'package:supabase/supabase.dart'; + + final supabase = SupabaseClient( + 'https://xyzcompany.supabase.co', + 'your-publishable-key', + tracePropagationOptions: TracePropagationOptions( + enabled: true, + traceContextProvider: () { + final span = YourTracer.activeSpan; + if (span == null) return null; + return TraceContext( + traceparent: span.traceparent, + tracestate: span.tracestate, + ); + }, + ), + ); + ``` + + For `supabase_flutter`, pass the same option through `Supabase.initialize`: + + ```dart + await Supabase.initialize( + url: 'https://xyzcompany.supabase.co', + anonKey: 'your-publishable-key', + tracePropagationOptions: TracePropagationOptions( + enabled: true, + traceContextProvider: () => yourTraceContextProvider(), + ), + ); + ``` + +## Options + +| Option | Type | Default | Description | +| ------------------------- | ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `bool` | `false` | Enable trace propagation. | +| `respectSamplingDecision` | `bool` | `true` | When `true`, skips propagation if the upstream trace is not sampled. Set to `false` to always attach a `trace_id` β€” useful for log correlation even when traces are not exported. | +| `traceContextProvider` | `TraceContextProvider?` | `null` | Callback returning the current `TraceContext`. Return `null` when there is no active span. | + +Headers are only injected on requests targeting Supabase hosts (`*.supabase.co`, `*.supabase.in`, your project host, and loopback addresses for local development). Third-party hosts never receive trace headers. + + + + + +## Correlating with Supabase logs + +After trace context is flowing through, the `trace_id` appears in: + +- **API Gateway logs** β€” every request to PostgREST, Auth, Storage, and Realtime +- **Edge Function logs** β€” invocations and any structured logs emitted from within the function + +If you forward Supabase logs to a third-party backend via [Log Drains](/docs/guides/monitoring-and-debugging/log-drains), you can join Supabase logs to your own client and server traces using the shared `trace_id`. This is especially useful for self-hosted setups where you already operate your own OpenTelemetry collector β€” Supabase logs become first-class citizens in your existing tracing UI. diff --git a/apps/docs/scripts/generate-dart-reference.ts b/apps/docs/scripts/generate-dart-reference.ts index 6171c1b47f9b6..f57cfc8db0765 100644 --- a/apps/docs/scripts/generate-dart-reference.ts +++ b/apps/docs/scripts/generate-dart-reference.ts @@ -58,10 +58,10 @@ const OUT_PATH = join(VERSION_DIR, 'supabase_flutter.json') const HEADER_IDS = new Set([ 'auth-api', 'auth-mfa-api', + 'oauth-server-api', 'passkey-api', 'admin-api', 'admin-passkey-api', - 'oauth-server-api', 'admin-custom-providers-api', 'functions-api', 'database-api', diff --git a/apps/docs/spec/supabase_py_v2.yml b/apps/docs/spec/supabase_py_v2.yml index a75006f813502..5b77ff8d10e44 100644 --- a/apps/docs/spec/supabase_py_v2.yml +++ b/apps/docs/spec/supabase_py_v2.yml @@ -7517,9 +7517,9 @@ functions: ``` - id: listening-to-row-level-changes name: Listen to row level changes - description: You can listen to individual rows using the format `{table}:{col}=eq.{val}` - where `{col}` is the column name, and `{val}` is the value which you want to match. + description: You can listen to individual rows using the format `{table}:{col}=op.{val}` - where `{col}` is the column name, `{op}` is the filter operator, and `{val}` is the value to match. notes: | - - ``eq`` filter works with all database types as under the hood, it's casting both the filter value and the database value to the correct type and then comparing them. + - Supported operators: ``eq``, ``neq``, ``lt``, ``lte``, ``gt``, ``gte``, ``in`` (e.g. ``"status=in.(active,pending)"``), ``like``, ``ilike``, ``is`` (e.g. ``"deleted_at=is.null"``), ``match``, ``imatch`` (POSIX regex), ``isdistinct`` (NULL-safe inequality). Prefix any operator with ``not.`` to negate it (e.g. ``"status=not.in.(draft,archived)"``). Combine multiple conditions with commas for an implicit ``AND`` (e.g. ``"amount=gt.100,status=in.(open,pending)"``). code: | ```python response = ( @@ -7528,6 +7528,28 @@ functions: .subscribe() ) ``` + - id: listening-to-selected-columns + name: Listen to selected columns only + description: | + Use the `select` parameter to receive only specific columns instead of the full row. + This reduces payload size, which is especially useful for tables with large `bytea` or `jsonb` columns. + code: | + ```python + def handle_record_updated(payload): + print("Updated country:", payload) + + response = ( + await supabase.channel("room1") + .on_postgres_changes( + "UPDATE", + schema="public", + table="countries", + select=["id", "name"], + callback=handle_record_updated, + ) + .subscribe() + ) + ``` - id: broadcast-message title: broadcastMessage() description: | diff --git a/apps/docs/spec/supabase_swift_v2.yml b/apps/docs/spec/supabase_swift_v2.yml index 921e5bde9d5dd..3c56ac8bc6857 100644 --- a/apps/docs/spec/supabase_swift_v2.yml +++ b/apps/docs/spec/supabase_swift_v2.yml @@ -116,6 +116,26 @@ functions: Go to [Settings > API > Exposed schemas](/dashboard/project/_/settings/api) and add the schema which you want to expose to the API. Note: each client connection can only access a single schema, so the code above can access the `other_schema` schema but cannot access the `public` schema. + - id: initialize-client-with-opentelemetry + name: Initialize Client with OpenTelemetry tracing + description: | + Supabase Swift supports W3C `traceparent` header propagation via an opt-in SwiftPM package trait. + When enabled, the active OpenTelemetry span's trace context is automatically injected into every outgoing request across PostgREST, Storage, Auth, Functions, and Realtime β€” no additional runtime configuration needed. + + Enable the `OpenTelemetry` trait in your `Package.swift` dependency declaration: + code: | + ```swift + // Package.swift + .package( + url: "https://github.com/supabase/supabase-swift.git", + from: "2.51.0", + traits: ["OpenTelemetry"] + ) + ``` + notes: | + - Requires swift-tools-version 6.1 or later (trait support). + - The trait is **off by default** β€” no OTel dependency is linked unless you opt in. + - When no span is active, the header is not added; it is always safe to call unconditionally. - id: auth-api title: 'Overview' notes: | diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 2a830738c9504..309dd05509100 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -404,6 +404,7 @@ allow_list = [ "mTLS", "Supavisor", "SvelteKit", + "SwiftPM", "SwiftUI", "Reddit", "Remapper", From de5d3cd115d88ec21b2542e486b618196c95c781 Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:02:30 +0200 Subject: [PATCH 05/13] docs(self-hosted): update manual setup instructions (#48692) --- apps/docs/content/guides/self-hosting/docker.mdx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index ab5dc066cf4e1..a471d36948411 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -80,6 +80,7 @@ The script supports Linux only (Debian/Ubuntu and RHEL/CentOS/Fedora) and will: - Install prerequisites (`git`, `openssl`, `jq`) and Docker Engine if not already present - Sparse-clone the `docker/` directory from the main Supabase [repository](https://github.com/supabase/supabase/) - Create a project directory (`supabase-project` by default) and copy the configuration files into it +- Record the installed release version in `.supabase-version` for future `update.sh` upgrades - Prompt for the main URLs (`SUPABASE_PUBLIC_URL`, `API_EXTERNAL_URL`, `SITE_URL`, `PROXY_DOMAIN`) and write them to `.env` - Generate all secrets, including a random `DASHBOARD_PASSWORD`, and the asymmetric JWT signing key pair (runs `generate-keys.sh` and `add-new-auth-keys.sh`, and enables the matching entries in `docker-compose.yml`) - Pull the Docker images @@ -105,7 +106,7 @@ Not on Linux, or want to do it manually? See [Manual installation](#manual-insta ### Manual installation -This path gets the Docker Compose configuration onto your server; you'll configure secrets, keys, and URLs in the [next section](#configuring-and-securing-supabase). +This path gets the Docker Compose configuration onto your server, pinned to a specific tag. You'll set up secrets, keys, and URLs in the [next section](#configuring-and-securing-supabase). For a newer release, use the [latest tag](https://github.com/supabase/supabase/tags). .supabase-version + # Pull the latest images docker compose pull ``` @@ -149,7 +153,7 @@ Only downloads the `docker/` directory from the repository, saving bandwidth and ```sh # Get the code using git sparse checkout -git clone --filter=blob:none --no-checkout --depth=1 --quiet https://github.com/supabase/supabase +git clone --filter=blob:none --no-checkout --depth=1 --quiet --branch self-hosted/v0.7.1 https://github.com/supabase/supabase cd supabase git sparse-checkout init --cone git sparse-checkout set docker @@ -170,6 +174,9 @@ cp -rf supabase/docker/. supabase-project # Switch to the project directory and create a .env from the example cd supabase-project && cp .env.example .env +# Record the base version so update.sh can upgrade this install later +printf 'ref=self-hosted/v0.7.1\n' > .supabase-version + # Pull the latest images docker compose pull ``` From b3c5c9fc049051ca656d732872497f41521e7364 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:02:39 -0400 Subject: [PATCH 06/13] feat(studio): logs snippets in SQL editor nav, search, and tabs (#48457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What PR 7 of the SQL-editor query-source (Database vs Logs) stack. Surfaces `log_sql` snippets as a distinct query source across the SQL editor sidebar. Stacked on **`charislam/toolbar-ui-creation-flow`** (PR 6 β€” toolbar UI + creation flow); review/merge that first. Nothing is user-visible until the flags roll out β€” every entry point requires **both** `sqlEditorLogsSource` **and** `otelLegacyLogs`. ## Changes - **Nav** β€” a flag-gated **Logs** section (`LogsSnippetsSection`) backed by its own single-type `log_sql` query. The active snippet is injected only into the section it belongs to, via a shared `withActiveSnippet(snippets, active, belongsPredicate)` helper (also DRYs the private/favorites/shared injections). - **Search** (`SearchList`) β€” a **Logs** result group with a shared, extracted `SqlSnippetTree`; the "N results found" count now sums database + logs, with loading/empty states covering both queries. - **Tabs** β€” an immutable `sqlSource` field on tab/recent-item metadata (set at tab creation, lazily backfilled once the snippet loads via `useEffectEvent`), and a distinct `ScrollText` icon via a shared `LogsSnippetIcon`. Tab cleanup treats `log_sql` tabs as live and only prunes them when logs data is authoritative (`canPruneLogsTabs`), so a disabled/erroring logs query never wrongly deletes logs tabs or blocks database-tab cleanup. - **Data layer** β€” `useSqlSnippetsQuery` gains an optional `type` param so logs reuse the same `SnippetWithContent` shape as the other sections (no casts). ## Tests - `state/tabs.test.ts` β€” `sqlSource` backfill + creation-time carry-through. - `components/layouts/Tabs/Tabs.utils.test.tsx` β€” cleanup prunes stale database/logs snippets, keeps live ones, and preserves logs tabs when logs data isn't authoritative. ## Verification - `pnpm --filter studio typecheck` βœ“ - `pnpm --filter studio run lint:ratchet` βœ“ - `pnpm test:studio` (affected suites) βœ“ ## Summary by CodeRabbit * **New Features** * Added a collapsible Logs section to the SQL editor sidebar for browsing, sorting, selecting, renaming, and deleting log queries. * Expanded SQL search with separate, paginated results for database and log queries. * Added dedicated log-query icons across navigation, tabs, previews, and recent items. * **Bug Fixes** * Improved tab and recent-item cleanup while preserving active log queries and accurate source metadata. * **Tests** * Added coverage for log tab cleanup and SQL source metadata synchronization. --- .../SQLEditorNavV2/LogsSnippetsSection.tsx | 136 ++++++++++++ .../SQLEditorNavV2/SQLEditorNav.constants.ts | 2 + .../SQLEditorNavV2/SQLEditorNav.tsx | 115 ++++++++-- .../SQLEditorNavV2/SQLEditorNav.utils.ts | 21 ++ .../SQLEditorNavV2/SQLEditorTreeViewItem.tsx | 11 + .../SQLEditorNavV2/SearchList.tsx | 203 ++++++++++-------- .../SQLEditorNavV2/SqlSnippetTree.tsx | 122 +++++++++++ .../components/layouts/Tabs/RecentItems.tsx | 2 +- .../components/layouts/Tabs/SortableTab.tsx | 2 +- .../components/layouts/Tabs/TabPreview.tsx | 2 +- .../layouts/Tabs/Tabs.utils.test.tsx | 91 ++++++++ .../components/layouts/Tabs/Tabs.utils.ts | 101 +++++---- apps/studio/components/ui/EntityTypeIcon.tsx | 41 +++- apps/studio/data/content/keys.ts | 1 + .../studio/data/content/sql-snippets-query.ts | 22 +- apps/studio/pages/project/[ref]/sql/[id].tsx | 25 ++- apps/studio/state/tabs.test.ts | 40 ++++ apps/studio/state/tabs.tsx | 51 +++-- 18 files changed, 809 insertions(+), 179 deletions(-) create mode 100644 apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx create mode 100644 apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx create mode 100644 apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx new file mode 100644 index 0000000000000..1caf2bff1f99b --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx @@ -0,0 +1,136 @@ +import { keepPreviousData } from '@tanstack/react-query' +import { useParams } from 'common' +import { useEffect, useMemo } from 'react' +import { + InnerSideBarEmptyPanel, + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleContent, + InnerSideMenuCollapsibleTrigger, +} from 'ui-patterns/InnerSideMenu' + +import { SQLEditorLoadingSnippets } from './SQLEditorLoadingSnippets' +import { + formatFolderResponseForTreeView, + getLastItemIds, + ROOT_NODE, + withActiveSnippet, +} from './SQLEditorNav.utils' +import { SqlSnippetTree } from './SqlSnippetTree' +import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' +import { useContentCountQuery } from '@/data/content/content-count-query' +import { Snippet } from '@/data/content/sql-folders-query' +import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query' +import { useLatest } from '@/hooks/misc/useLatest' + +interface LogsSnippetsSectionProps { + open: boolean + onOpenChange: (open: boolean) => void + sort: 'inserted_at' | 'name' + /** The currently open snippet, surfaced in the list before the query fetches it. */ + activeSnippet?: Snippet + selectedSnippetIds: string[] + /** Bubbles loaded logs snippets up for tab cleanup, mirroring onFolderContentsChange. */ + onSnippetsLoaded: (info: { + snippets: Snippet[] + /** + * Whether `snippets` is the complete set of logs snippets. Only true once every + * page has been fetched β€” cleanup prunes tabs whose snippet is absent from the + * list, and a snippet on an unfetched page would otherwise look deleted. + */ + isComplete: boolean + isSettled: boolean + }) => void + onSelectDelete: (snippet: Snippet) => void + onSelectRename: (snippet: Snippet) => void +} + +/** + * A separate single-type `log_sql` query β€” logs snippets are a distinct backend and + * don't participate in folders, so they get their own flat section rather than merging + * with the cursor-paginated `sql` sections. + */ +export const LogsSnippetsSection = ({ + open, + onOpenChange, + sort, + activeSnippet, + selectedSnippetIds, + onSnippetsLoaded, + onSelectDelete, + onSelectRename, +}: LogsSnippetsSectionProps) => { + const { ref: projectRef } = useParams() + + const { data, isLoading, isSuccess, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = + useSqlSnippetsQuery( + { projectRef, type: 'log_sql', sort }, + { placeholderData: keepPreviousData } + ) + + const { data: countData } = useContentCountQuery({ projectRef, type: 'log_sql' }) + const numSnippets = (countData?.private ?? 0) + (countData?.shared ?? 0) + + const snippets = useMemo(() => { + const pageSnippets = data?.pages.flatMap((page) => page.contents ?? []) ?? [] + + return withActiveSnippet( + pageSnippets, + activeSnippet, + (s) => getSnippetSource(s) === 'logs' + ) + .map((x) => ({ ...x, folder_id: null })) + .sort((a, b) => { + if (sort === 'name') return a.name.localeCompare(b.name) + return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() + }) + }, [data?.pages, activeSnippet, sort]) + + const treeState = useMemo( + () => + snippets.length === 0 + ? [ROOT_NODE] + : formatFolderResponseForTreeView({ contents: snippets, folders: [] }), + [snippets] + ) + const lastItemIds = useMemo(() => getLastItemIds(treeState), [treeState]) + + const onSnippetsLoadedRef = useLatest(onSnippetsLoaded) + useEffect(() => { + onSnippetsLoadedRef.current({ + snippets, + isComplete: isSuccess && !hasNextPage, + isSettled: isSuccess || isError, + }) + }, [snippets, isSuccess, isError, hasNextPage]) + + return ( + + 0 ? ` (${numSnippets})` : ''}`} + /> + + {isLoading && } + {!isLoading && snippets.length === 0 && ( + + )} + {!isLoading && snippets.length > 0 && ( + + )} + + + ) +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts index 72f80c44725a1..623bc4119c432 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts @@ -2,6 +2,7 @@ export type SectionState = { shared: boolean favorite: boolean private: boolean + logs: boolean community: boolean } @@ -9,5 +10,6 @@ export const DEFAULT_SECTION_STATE: SectionState = { shared: false, favorite: false, private: true, + logs: false, community: true, } diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 9a536480ccb85..57ef4715aea22 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -1,5 +1,5 @@ import { keepPreviousData } from '@tanstack/react-query' -import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import { Heart } from 'lucide-react' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' @@ -15,15 +15,22 @@ import { } from 'ui-patterns/InnerSideMenu' import { DeleteSnippetsModal } from './DeleteSnippetsModal' +import { LogsSnippetsSection } from './LogsSnippetsSection' import { ReferenceSnippetsSection } from './ReferenceSnippetsSection' import { ShareSnippetModal } from './ShareSnippetModal' import { SQLEditorLoadingSnippets } from './SQLEditorLoadingSnippets' import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants' -import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils' +import { + formatFolderResponseForTreeView, + getLastItemIds, + ROOT_NODE, + withActiveSnippet, +} from './SQLEditorNav.utils' import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' import { UnshareSnippetModal } from './UnshareSnippetModal' import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal' import { MoveQueryModal } from '@/components/interfaces/SQLEditor/MoveQueryModal' +import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal' import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils' @@ -65,8 +72,16 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { shared: showSharedSnippets, favorite: showFavoriteSnippets, private: showPrivateSnippets, + logs: showLogsSnippets, } = sectionVisibility + // Both flags gate the entry point: `sqlEditorLogsSource` enables the feature and + // `otelLegacyLogs` confirms the org's logs live in the ClickHouse backend a logs + // snippet queries. + const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + const canShowLogsSection = isLogsSourceEnabled && isOtelLogsEnabled + const [showMoveModal, setShowMoveModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false) @@ -131,10 +146,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { } ) - if (snippet && snippet.visibility === 'user' && !snippetInfo.snippetIds.has(snippet.id)) { - snippetInfo.snippetIds.add(snippet.id) - snippetInfo.snippets = [...snippetInfo.snippets, snippet] - } + snippetInfo.snippets = withActiveSnippet( + snippetInfo.snippets, + snippet, + (s) => s.visibility === 'user' && getSnippetSource(s) !== 'logs' + ) return snippetInfo }, [privateSnippetsPages?.pages, subResults, isLoading, isPlaceholderData, isFetching, snippet]) @@ -204,11 +220,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { ) const favoriteSnippets = useMemo(() => { - let snippets = favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [] - - if (snippet && snippet.favorite && !snippets.find((x) => x.id === snippet.id)) { - snippets.push(snippet) - } + const snippets = withActiveSnippet( + favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [], + snippet, + (s) => !!s.favorite && getSnippetSource(s) !== 'logs' + ) return ( snippets @@ -255,11 +271,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { ) const sharedSnippets = useMemo(() => { - let snippets = sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [] - - if (snippet && snippet.visibility === 'project' && !snippets.find((x) => x.id === snippet.id)) { - snippets.push(snippet) - } + const snippets = withActiveSnippet( + sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [], + snippet, + (s) => s.visibility === 'project' && getSnippetSource(s) !== 'logs' + ) return ( snippets.sort((a, b) => { @@ -284,12 +300,21 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { [projectSnippetsTreeState] ) + // The Logs section owns its own query and bubbles loaded snippets here so tab + // cleanup treats them as live (and prunes stale ones). + const [logsSnippetsInView, setLogsSnippetsInView] = useState<{ + snippets: Snippet[] + isComplete: boolean + isSettled: boolean + }>({ snippets: [], isComplete: false, isSettled: false }) + const allSnippetsInView = useMemo( () => [ - ...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []), - ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []), + ...(privateSnippetsPages?.pages.flatMap((x) => x.contents ?? []) ?? []), + ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents ?? []) ?? []), + ...logsSnippetsInView.snippets, ], - [privateSnippetsPages, sharedSqlSnippetsData] + [privateSnippetsPages, sharedSqlSnippetsData, logsSnippetsInView.snippets] ) // ========================== @@ -404,7 +429,12 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { useEffect(() => { if (snippet !== undefined && isSuccess) { - if (snippet.visibility === 'project') { + // Source is checked before visibility: a logs snippet lives in the Logs section + // whatever its visibility, so branching on visibility first would open Private + // (or Shared) and leave the section the snippet is actually in collapsed. + if (getSnippetSource(snippet) === 'logs') { + setSectionVisibility({ ...sectionVisibility, logs: true }) + } else if (snippet.visibility === 'project') { setSectionVisibility({ ...sectionVisibility, shared: true }) } else if (snippet.visibility === 'user') { setSectionVisibility({ ...sectionVisibility, private: true }) @@ -457,10 +487,24 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const sqlEditorTabsCleanup = useSqlEditorTabsCleanup() useEffect(() => { - if (isSuccess) { - sqlEditorTabsCleanup({ snippets: allSnippetsInView as any }) + // Wait for the logs query to settle (when enabled) so a logs failure doesn't + // freeze database-tab cleanup. Logs tabs are only prunable once every logs page + // has been fetched β€” until then the list is partial and a tab whose snippet sits + // on an unfetched page would be pruned as stale, so they're preserved instead. + if (isSuccess && (!canShowLogsSection || logsSnippetsInView.isSettled)) { + sqlEditorTabsCleanup({ + snippets: allSnippetsInView, + canPruneLogsTabs: canShowLogsSection && logsSnippetsInView.isComplete, + }) } - }, [allSnippetsInView, isSuccess, sqlEditorTabsCleanup]) + }, [ + allSnippetsInView, + isSuccess, + canShowLogsSection, + logsSnippetsInView.isComplete, + logsSnippetsInView.isSettled, + sqlEditorTabsCleanup, + ]) return ( <> @@ -757,6 +801,31 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { + {canShowLogsSection && ( + <> + + setSectionVisibility({ ...(sectionVisibility ?? DEFAULT_SECTION_STATE), logs: value }) + } + sort={sort} + activeSnippet={snippet} + selectedSnippetIds={selectedSnippets.map((x) => x.id)} + onSnippetsLoaded={setLogsSnippetsInView} + onSelectDelete={(snippet) => { + setShowDeleteModal(true) + setSelectedSnippets([snippet]) + }} + onSelectRename={(snippet) => { + setShowRenameModal(true) + setSelectedSnippetToRename(snippet) + }} + /> + + + + )} + diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts index 65f8dbd286110..f0f12761928b5 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts @@ -53,6 +53,27 @@ export const formatFolderResponseForTreeView = ( return [root, ...formattedFolders, ...formattedContents] } +/** + * Append the active snippet to a section's list when it belongs there (per the + * section's predicate) and isn't already present. Each nav section lists + * server-filtered pages, so a just-opened or just-created snippet may not appear + * until a refetch β€” this surfaces it immediately, in the one section it belongs to. + */ +export function withActiveSnippet( + snippets: T[], + activeSnippet: T | undefined, + belongsInSection: (snippet: T) => boolean +): T[] { + if ( + activeSnippet !== undefined && + belongsInSection(activeSnippet) && + !snippets.some((snippet) => snippet.id === activeSnippet.id) + ) { + return [...snippets, activeSnippet] + } + return snippets +} + export function getLastItemIds(items: TreeViewItemProps[]) { let lastItemIds = new Set() diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 21ae2a5e129db..615eecc6aadc5 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -30,6 +30,7 @@ import { import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils' +import { LogsSnippetIcon } from '@/components/ui/EntityTypeIcon' import { getContentById, getSqlSnippetById } from '@/data/content/content-id-query' import { useSQLSnippetFolderContentsQuery } from '@/data/content/sql-folder-contents-query' import { Snippet } from '@/data/content/sql-folders-query' @@ -111,6 +112,8 @@ export const SQLEditorTreeViewItem = ({ const isSharedSnippet = element.metadata.visibility === 'project' const isFavorite = element.metadata.favorite + const isLogsSnippet = getSnippetSource(element.metadata) === 'logs' + const isEditing = isFolderEditing(status) const isSaving = isFolderSaving(status) @@ -247,6 +250,14 @@ export const SQLEditorTreeViewItem = ({ isPreview={props.isPreview} isEditing={isEditing} isLoading={(isEnabled && isLoading) || isSaving} + icon={ + isLogsSnippet ? ( + + ) : undefined + } onEditSubmit={(value) => { if (onEditSave !== undefined) onEditSave(value) }} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx index 9d6a349b73010..a9a9c89f33644 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx @@ -1,29 +1,45 @@ import { keepPreviousData } from '@tanstack/react-query' -import { useParams } from 'common' +import { useFlag, useParams } from 'common' import { Loader2 } from 'lucide-react' import { useMemo, useState } from 'react' -import { TreeView } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { DeleteSnippetsModal } from './DeleteSnippetsModal' import { ShareSnippetModal } from './ShareSnippetModal' import { formatFolderResponseForTreeView, getLastItemIds } from './SQLEditorNav.utils' -import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' +import { SqlSnippetTree } from './SqlSnippetTree' import { UnshareSnippetModal } from './UnshareSnippetModal' import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal' import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal' import { useContentCountQuery } from '@/data/content/content-count-query' -import { useContentInfiniteQuery } from '@/data/content/content-infinite-query' -import { Snippet, SNIPPET_PAGE_LIMIT } from '@/data/content/sql-folders-query' -import { createTabId, useTabsStateSnapshot } from '@/state/tabs' +import { Snippet } from '@/data/content/sql-folders-query' +import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query' interface SearchListProps { search: string } +/** Uppercase section heading shown above the Database and Logs result groups. */ +const SearchGroupHeading = ({ children }: { children: string }) => ( +

{children}

+) + +/** + * Flatten paginated snippet results into the flat (folderless) tree the search view + * renders. Keyed on `pages` (a stable reference from React Query) so the flatMap and + * tree build run once per data change rather than on every render. + */ +function useSnippetSearchTree(pages: { contents: Snippet[] }[] | undefined) { + return useMemo(() => { + const flat = (pages ?? []) + .flatMap((page) => page.contents ?? []) + .map((snippet) => ({ ...snippet, folder_id: null })) + const treeState = formatFolderResponseForTreeView({ folders: [], contents: flat }) + return { treeState, lastItemIds: getLastItemIds(treeState), count: flat.length } + }, [pages]) +} + export const SearchList = ({ search }: SearchListProps) => { - const { id } = useParams() - const tabs = useTabsStateSnapshot() const { ref: projectRef } = useParams() const [selectedSnippetToShare, setSelectedSnippetToShare] = useState() @@ -32,120 +48,121 @@ export const SearchList = ({ search }: SearchListProps) => { const [selectedSnippetToRename, setSelectedSnippetToRename] = useState() const [selectedSnippetToDelete, setSelectedSnippetToDelete] = useState() + // Logs snippets are a separate backend and don't share the `sql` cursor, so the + // search runs a second single-type query and renders them under their own group. + // Gated by both flags, mirroring the nav's Logs section. + const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + const canShowLogsSection = isLogsSourceEnabled && isOtelLogsEnabled + + const searchName = search.length === 0 ? undefined : search + const { data, isPending: isLoading, hasNextPage, fetchNextPage, isFetchingNextPage, - } = useContentInfiniteQuery( - { - projectRef, - type: 'sql', - limit: SNIPPET_PAGE_LIMIT, - name: search.length === 0 ? undefined : search, - }, + } = useSqlSnippetsQuery( + { projectRef, type: 'sql', name: searchName }, { placeholderData: keepPreviousData } ) const { data: count, isPending: isLoadingCount } = useContentCountQuery( - { - projectRef, - type: 'sql', - name: search, - }, + { projectRef, type: 'sql', name: search }, { placeholderData: keepPreviousData } ) - const totalNumber = count ? count.private + count.shared : 0 - const snippets = useMemo( - // [Joshen] Set folder_id to null to ensure flat list - () => data?.pages.flatMap((page) => page.content.map((x) => ({ ...x, folder_id: null }))), - [data?.pages] + const { data: logsCount, isPending: isLoadingLogsCount } = useContentCountQuery( + { projectRef, type: 'log_sql', name: search }, + { enabled: canShowLogsSection, placeholderData: keepPreviousData } + ) + + const databaseCount = count ? count.private + count.shared : 0 + const logsResultCount = canShowLogsSection && logsCount ? logsCount.private + logsCount.shared : 0 + const totalNumber = databaseCount + logsResultCount + const isLoadingCounts = isLoadingCount || (canShowLogsSection && isLoadingLogsCount) + const hasCounts = count !== undefined || (canShowLogsSection && logsCount !== undefined) + + const { + data: logsData, + isPending: isLoadingLogs, + hasNextPage: hasNextLogsPage, + fetchNextPage: fetchNextLogsPage, + isFetchingNextPage: isFetchingNextLogsPage, + } = useSqlSnippetsQuery( + { projectRef, type: 'log_sql', name: searchName }, + { enabled: canShowLogsSection, placeholderData: keepPreviousData } ) - const treeState = formatFolderResponseForTreeView({ folders: [], contents: snippets as any }) - const snippetsLastItemIds = useMemo(() => getLastItemIds(treeState), [treeState]) + const databaseTree = useSnippetSearchTree(data?.pages) + const logsTree = useSnippetSearchTree(logsData?.pages) + const hasDatabaseResults = databaseTree.count > 0 + const hasLogsResults = logsTree.count > 0 + // Label the groups only when both are present; a single group needs no heading. + const showGroupHeadings = hasDatabaseResults && hasLogsResults + // The results body is loading until both source queries (when enabled) settle. + const isSearchLoading = isLoading || (canShowLogsSection && isLoadingLogs) return ( <>
- {isLoadingCount ? ( + {isLoadingCounts && (
- ) : !!count ? ( + )} + {!isLoadingCounts && hasCounts && (

- {totalNumber} result{totalNumber > 1 ? 's' : ''} found + {totalNumber} result{totalNumber === 1 ? '' : 's'} found

- ) : null} - {isLoading ? ( + )} + {isSearchLoading && (
- ) : ( - { - const isOpened = Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) - const tabId = createTabId('sql', { - id: element?.metadata?.id as unknown as Snippet['id'], - }) - const isPreview = tabs.previewTabId === tabId - const isActive = !isPreview && element.metadata?.id === id - const visibility = - element.metadata?.visibility === 'user' - ? 'Private' - : element.metadata?.visibility === 'project' - ? 'Shared' - : undefined - - return ( - - {element.name} - {!!visibility && ( - {visibility} - )} - - ), - }} - nameForTitle={element.name} - isBranch={false} - isOpened={isOpened && !isPreview} - isSelected={isActive} - isPreview={isPreview} - isLastItem={snippetsLastItemIds.has(element.id as string)} - status="idle" - className="items-start h-[40px] [&>svg]:translate-y-0.5" - onSelectDelete={() => setSelectedSnippetToDelete(element.metadata as Snippet)} - onSelectRename={() => setSelectedSnippetToRename(element.metadata as Snippet)} - onSelectDownload={() => setSelectedSnippetToDownload(element.metadata as Snippet)} - onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)} - onSelectUnshare={() => setSelectedSnippetToUnshare(element.metadata as Snippet)} - hasNextPage={hasNextPage} - fetchNextPage={fetchNextPage} - isFetchingNextPage={isFetchingNextPage} - onDoubleClick={(e) => { - e.preventDefault() - tabs.makeTabPermanent(tabId) - }} - /> - ) - }} - /> + )} + {!isSearchLoading && !hasDatabaseResults && !hasLogsResults && ( +

No queries found

+ )} + {!isSearchLoading && hasDatabaseResults && ( + <> + {showGroupHeadings && Database} + + + )} + {!isSearchLoading && canShowLogsSection && hasLogsResults && ( + <> + {showGroupHeadings && Logs} + + )}
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx new file mode 100644 index 0000000000000..4ee68e623c2a2 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx @@ -0,0 +1,122 @@ +import { useParams } from 'common' +import { TreeView } from 'ui' + +import type { TreeViewItemProps } from './SQLEditorNav.utils' +import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' +import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' +import { Snippet } from '@/data/content/sql-folders-query' +import { createTabId, useTabsStateSnapshot } from '@/state/tabs' + +interface SqlSnippetTreeProps { + ariaLabel: string + data: TreeViewItemProps[] + lastItemIds: Set + /** Additional (multi-)selected snippet ids to highlight beyond the active one. */ + selectedSnippetIds?: string[] + /** Search results show a source/visibility sublabel under each snippet name. */ + showVisibility?: boolean + itemClassName?: string + hasNextPage?: boolean + fetchNextPage?: () => void + isFetchingNextPage?: boolean + onSelectDelete?: (snippet: Snippet) => void + onSelectRename?: (snippet: Snippet) => void + onSelectDownload?: (snippet: Snippet) => void + onSelectShare?: (snippet: Snippet) => void + onSelectUnshare?: (snippet: Snippet) => void +} + +/** + * The sublabel under a snippet's name. Logs snippets are labeled by source rather + * than visibility: they have no share action, so "Private" says nothing that + * distinguishes them from the database query sitting right above them in the + * same result list, while "Logs" does. + */ +const snippetSublabel = (snippet: Snippet) => { + if (getSnippetSource(snippet) === 'logs') return 'Logs' + if (snippet.visibility === 'user') return 'Private' + if (snippet.visibility === 'project') return 'Shared' + return undefined +} + +export const SqlSnippetTree = ({ + ariaLabel, + data, + lastItemIds, + selectedSnippetIds, + showVisibility = false, + itemClassName, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + onSelectDelete, + onSelectRename, + onSelectDownload, + onSelectShare, + onSelectUnshare, +}: SqlSnippetTreeProps) => { + const { id } = useParams() + const tabs = useTabsStateSnapshot() + + return ( + { + const snippet = element.metadata as Snippet + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === snippet.id + ) + const tabId = createTabId('sql', { id: snippet.id }) + const isPreview = tabs.previewTabId === tabId + const isActive = !isPreview && snippet.id === id + const isSelected = isActive || (selectedSnippetIds?.includes(snippet.id) ?? false) + const sublabel = showVisibility ? snippetSublabel(snippet) : undefined + + return ( + + {element.name} + {!!sublabel && ( + {sublabel} + )} + + ), + } + : element + } + nameForTitle={showVisibility ? (element.name as string) : undefined} + isBranch={false} + isOpened={isOpened && !isPreview} + isSelected={isSelected} + isPreview={isPreview} + isLastItem={lastItemIds.has(element.id as string)} + status="idle" + className={itemClassName} + onSelectDelete={onSelectDelete ? () => onSelectDelete(snippet) : undefined} + onSelectRename={onSelectRename ? () => onSelectRename(snippet) : undefined} + onSelectDownload={onSelectDownload ? () => onSelectDownload(snippet) : undefined} + onSelectShare={onSelectShare ? () => onSelectShare(snippet) : undefined} + onSelectUnshare={onSelectUnshare ? () => onSelectUnshare(snippet) : undefined} + hasNextPage={hasNextPage} + fetchNextPage={fetchNextPage} + isFetchingNextPage={isFetchingNextPage} + onDoubleClick={(e) => { + e.preventDefault() + tabs.makeTabPermanent(tabId) + }} + /> + ) + }} + /> + ) +} diff --git a/apps/studio/components/layouts/Tabs/RecentItems.tsx b/apps/studio/components/layouts/Tabs/RecentItems.tsx index 165dd48043637..2bae3f19acb8b 100644 --- a/apps/studio/components/layouts/Tabs/RecentItems.tsx +++ b/apps/studio/components/layouts/Tabs/RecentItems.tsx @@ -71,7 +71,7 @@ export function RecentItems() { className="flex items-center gap-4 rounded-lg bg-surface-100 py-2 transition-colors hover:bg-surface-200" >
- +
diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index c66a3a003a1f7..8c3eccdd38c2a 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -104,7 +104,7 @@ export const SortableTab = ({ )} {...listeners} > - +
{shouldShowSchema && ( diff --git a/apps/studio/components/layouts/Tabs/TabPreview.tsx b/apps/studio/components/layouts/Tabs/TabPreview.tsx index f2fa1073a0cdd..ef5825f49ba49 100644 --- a/apps/studio/components/layouts/Tabs/TabPreview.tsx +++ b/apps/studio/components/layouts/Tabs/TabPreview.tsx @@ -17,7 +17,7 @@ export const TabPreview = ({ tab }: { tab: string }) => { animate={{ opacity: 0.7 }} className="flex relative items-center gap-2 px-3 text-xs bg-dash-sidebar dark:bg-surface-100 shadow-lg rounded-xs h-10" > - + {tabData.label || 'Untitled'}
diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx b/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx new file mode 100644 index 0000000000000..8bb694233201f --- /dev/null +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx @@ -0,0 +1,91 @@ +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' + +import { useSqlEditorTabsCleanup } from './Tabs.utils' +import { createTabsState, TabsStateContext, type Tab } from '@/state/tabs' + +const dbTab = (id: string): Tab => ({ + id: `sql-${id}`, + type: 'sql', + label: id, + isPreview: false, + metadata: { sqlId: id, sqlSource: 'database' }, +}) + +const logsTab = (id: string): Tab => ({ + id: `sql-${id}`, + type: 'sql', + label: id, + isPreview: false, + metadata: { sqlId: id, sqlSource: 'logs' }, +}) + +function renderCleanup(store: ReturnType) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return renderHook(() => useSqlEditorTabsCleanup(), { wrapper }).result.current +} + +describe('useSqlEditorTabsCleanup', () => { + it('prunes tabs for deleted snippets (database and logs) while keeping live ones', () => { + const store = createTabsState('default') + store.addTab(dbTab('db-stale')) + store.addTab(logsTab('logs-stale')) + store.addTab(logsTab('logs-live')) + + const cleanup = renderCleanup(store) + + // The caller passes both `sql` and `log_sql` snippets it knows to be live; only + // `logs-live` is present, so the two stale tabs are pruned and it is kept. + act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] })) + + expect(store.openTabs).toEqual(['sql-logs-live']) + expect(store.tabsMap['sql-db-stale']).toBeUndefined() + expect(store.tabsMap['sql-logs-stale']).toBeUndefined() + expect(store.tabsMap['sql-logs-live']).toBeDefined() + }) + + it('keeps a logs tab that still exists in the snippet list', () => { + const store = createTabsState('default') + store.addTab(logsTab('logs')) + + const cleanup = renderCleanup(store) + act(() => cleanup({ snippets: [{ id: 'logs', type: 'log_sql', name: 'logs' }] })) + + expect(store.openTabs).toEqual(['sql-logs']) + }) + + it('preserves logs tabs when logs snippets are not authoritative (canPruneLogsTabs false)', () => { + const store = createTabsState('default') + store.addTab(dbTab('db-stale')) + store.addTab(logsTab('logs')) + + const cleanup = renderCleanup(store) + // Logs section disabled / query errored: no logs snippets passed, and logs tabs + // must not be pruned β€” but stale database tabs still are. + act(() => cleanup({ snippets: [], canPruneLogsTabs: false })) + + expect(store.openTabs).toEqual(['sql-logs']) + expect(store.tabsMap['sql-db-stale']).toBeUndefined() + + // Recent items carry their own logs-source condition, so assert them separately. + const recentIds = store.recentItems.map((item) => item.id) + expect(recentIds).toContain('sql-logs') + expect(recentIds).not.toContain('sql-db-stale') + }) + + it('prunes recent items for deleted logs snippets while keeping live ones', () => { + const store = createTabsState('default') + store.addTab(logsTab('logs-stale')) + store.addTab(logsTab('logs-live')) + + const cleanup = renderCleanup(store) + act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] })) + + const recentIds = store.recentItems.map((item) => item.id) + expect(recentIds).toContain('sql-logs-live') + expect(recentIds).not.toContain('sql-logs-stale') + }) +}) diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts index eae4578697f48..ad95bf407847c 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts @@ -65,46 +65,69 @@ export function useSqlEditorTabsCleanup() { const tabMapRef = useLatest(tabs.tabsMap) const openTabsRef = useLatest(tabs.openTabs) - return useCallback(({ snippets }: { snippets: { id: string; type: string; name: string }[] }) => { - // these are tabs that are static content - // these canot be removed from localstorage based on this query request - const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts'] - - // Identify all SQL snippets / content by their tab ids - const currentContentIds = [ - ...snippets - .filter((content) => content.type === 'sql') - .map((content) => createTabId('sql', { id: content.id })), - // append ignored tab IDs - ...IGNORED_TAB_IDS, - ] - - // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session) - const snippetTabsToBeCleaned = openTabsRef.current.filter( - (id: string) => id.startsWith('sql') && !currentContentIds.includes(id) - ) - tabs.removeTabs(snippetTabsToBeCleaned) - - // Remove any recent items that might no longer be existing (removed outside of the dashboard session) - const recentItems = tabs.getRecentItemsByType('sql') - tabs.removeRecentItems( - recentItems - ? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id) - : [] - ) - - // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not - // As the snippets name could've been updated outside of the SQL Editor session - // e.g for a shared snippet, the owner could've updated the name of the snippet - const openSqlTabs = openTabsRef.current - .map((id) => tabMapRef.current[id]) - .filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type)) + return useCallback( + ({ + snippets, + canPruneLogsTabs = true, + }: { + snippets: { id: string; type: string; name: string }[] + // Whether `log_sql` snippets in `snippets` are authoritative. When false (the + // logs section is disabled or its query errored) we can't know which logs + // snippets exist, so logs tabs are preserved rather than pruned as stale. + canPruneLogsTabs?: boolean + }) => { + // these are tabs that are static content + // these canot be removed from localstorage based on this query request + const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts'] + + // Identify all SQL snippets / content by their tab ids. Both database (`sql`) and + // logs (`log_sql`) snippets live in the `sql-` tab id space, so both are counted as + // live. Anything not in this set is treated as removed outside the session and pruned. + const currentContentIds = [ + ...snippets + .filter((content) => content.type === 'sql' || content.type === 'log_sql') + .map((content) => createTabId('sql', { id: content.id })), + // append ignored tab IDs + ...IGNORED_TAB_IDS, + ] + + const isPrunable = (id: string) => + id.startsWith('sql') && + !currentContentIds.includes(id) && + (canPruneLogsTabs || tabMapRef.current[id]?.metadata?.sqlSource !== 'logs') + + // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session) + const snippetTabsToBeCleaned = openTabsRef.current.filter(isPrunable) + tabs.removeTabs(snippetTabsToBeCleaned) + + // Remove any recent items that might no longer be existing (removed outside of the dashboard session) + const recentItems = tabs.getRecentItemsByType('sql') + tabs.removeRecentItems( + recentItems + ? recentItems + .filter( + (item) => + !currentContentIds.includes(item.id) && + (canPruneLogsTabs || item.metadata?.sqlSource !== 'logs') + ) + .map((item) => item.id) + : [] + ) - openSqlTabs.forEach((tab) => { - const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id) - if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name }) - }) - }, []) + // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not + // As the snippets name could've been updated outside of the SQL Editor session + // e.g for a shared snippet, the owner could've updated the name of the snippet + const openSqlTabs = openTabsRef.current + .map((id) => tabMapRef.current[id]) + .filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type)) + + openSqlTabs.forEach((tab) => { + const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id) + if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name }) + }) + }, + [] + ) } interface UseTabsScrollOptions { diff --git a/apps/studio/components/ui/EntityTypeIcon.tsx b/apps/studio/components/ui/EntityTypeIcon.tsx index f62f616ba10ae..2dc05a6df5803 100644 --- a/apps/studio/components/ui/EntityTypeIcon.tsx +++ b/apps/studio/components/ui/EntityTypeIcon.tsx @@ -1,13 +1,36 @@ -import { Eye, GitBranch, Table2 } from 'lucide-react' +import { Eye, GitBranch, ScrollText, Table2 } from 'lucide-react' import { cn, SQL_ICON } from 'ui' +import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' +/** + * The single icon representing a logs (`log_sql`) snippet β€” reused by the tabs + * (via EntityTypeIcon) and the nav tree so the two can't drift. Callers pass the + * context-appropriate size/className; the icon and stroke stay the same. + */ +export const LogsSnippetIcon = ({ + size = 15, + strokeWidth = 1.5, + className, +}: { + size?: number + strokeWidth?: number + className?: string +}) => ( + +) + interface EntityTypeIconProps { type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' size?: number strokeWidth?: number isActive?: boolean + sqlSource?: SqlSnippetSource } export const EntityTypeIcon = ({ @@ -15,7 +38,23 @@ export const EntityTypeIcon = ({ size = 15, strokeWidth = 1.5, isActive, + sqlSource, }: EntityTypeIconProps) => { + if (type === 'sql' && sqlSource === 'logs') { + return ( + + ) + } + if (type === 'sql') { return ( +/** The snippet content types this query can list. Defaults to `'sql'`; the nav's + * Logs section passes `'log_sql'` to list logs snippets through the same shape. */ +type SqlSnippetType = 'sql' | 'log_sql' + interface GetSqlSnippetsVariables { projectRef?: string cursor?: string + type?: SqlSnippetType visibility?: SqlSnippet['visibility'] favorite?: boolean name?: string @@ -19,7 +24,7 @@ interface GetSqlSnippetsVariables { } export async function getSqlSnippets( - { projectRef, cursor, visibility, favorite, name, sort }: GetSqlSnippetsVariables, + { projectRef, cursor, type = 'sql', visibility, favorite, name, sort }: GetSqlSnippetsVariables, signal?: AbortSignal ) { if (typeof projectRef === 'undefined') { @@ -32,7 +37,7 @@ export async function getSqlSnippets( params: { path: { ref: projectRef }, query: { - type: 'sql', + type, cursor, visibility, favorite, @@ -60,7 +65,14 @@ export type SqlSnippetsData = Awaited> export type SqlSnippetsError = unknown export const useSqlSnippetsQuery = ( - { projectRef, sort, name, visibility, favorite }: Omit, + { + projectRef, + type = 'sql', + sort, + name, + visibility, + favorite, + }: Omit, { enabled = true, ...options @@ -73,9 +85,9 @@ export const useSqlSnippetsQuery = ( > = {} ) => useInfiniteQuery({ - queryKey: contentKeys.sqlSnippets(projectRef, { sort, name, visibility, favorite }), + queryKey: contentKeys.sqlSnippets(projectRef, { type, sort, name, visibility, favorite }), queryFn: ({ signal, pageParam: cursor }) => - getSqlSnippets({ projectRef, cursor, sort, name, visibility, favorite }, signal), + getSqlSnippets({ projectRef, cursor, type, sort, name, visibility, favorite }, signal), enabled: enabled && typeof projectRef !== 'undefined', initialPageParam: undefined, getNextPageParam(lastPage) { diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index f40ea989fbfd9..0f1514f93f274 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -2,11 +2,12 @@ import { usePrevious } from '@uidotdev/usehooks' import { useParams } from 'common/hooks/useParams' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect } from 'react' +import { useEffect, useEffectEvent } from 'react' import { toast } from 'sonner' import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' +import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor' import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' import { DefaultLayout } from '@/components/layouts/DefaultLayout' @@ -107,11 +108,33 @@ const SqlEditor: NextPageWithLayout = () => { metadata: { sqlId: id, name: snippet?.name, + // The snippet may not be loaded yet at tab-creation time; the effect + // below backfills the source once it is. Source is immutable, so once + // set it never needs updating again. + ...(snippet !== undefined && { sqlSource: getSnippetSource(snippet) }), }, }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.isReady, id]) + // Backfill the tab's source once the snippet loads. Covers both a freshly + // created tab whose snippet arrived after creation and tabs persisted before + // `sqlSource` existed (absent β†’ filled from the loaded snippet type). Reads the + // latest tabs snapshot via useEffectEvent so the effect only re-runs on id/snippet. + const backfillTabSource = useEffectEvent(() => { + if (!id || id === 'new' || snippet === undefined) return + + const tabId = createTabId('sql', { id }) + const tab = tabs.tabsMap[tabId] + if (tab !== undefined && tab.metadata?.sqlSource === undefined) { + tabs.updateTab(tabId, { sqlSource: getSnippetSource(snippet) }) + } + }) + useEffect(() => { + backfillTabSource() + // eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't correctly ignore useEffectEvent yet) + }, [id, snippet]) + // The snippet no longer exists (e.g. deleted from another tab or session): clean up // any stale tab and dashboard history references so navigation doesn't resurrect it, // then fall back to a new snippet instead of rendering a dead state diff --git a/apps/studio/state/tabs.test.ts b/apps/studio/state/tabs.test.ts index 9cbfa766a10d3..000814297f771 100644 --- a/apps/studio/state/tabs.test.ts +++ b/apps/studio/state/tabs.test.ts @@ -72,6 +72,46 @@ describe('tabs recent items', () => { }) }) +describe('tabs sql source metadata', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('backfills sqlSource onto a tab and its recent item', () => { + const store = createTabsState('default') + + store.addTab({ + id: 'sql-a', + type: 'sql', + label: 'Logs query', + metadata: { sqlId: 'a', name: 'Logs query' }, + isPreview: false, + }) + + // Persisted before the field existed β†’ absent until backfilled from the snippet. + expect(store.tabsMap['sql-a'].metadata?.sqlSource).toBeUndefined() + + store.updateTab('sql-a', { sqlSource: 'logs' }) + + expect(store.tabsMap['sql-a'].metadata?.sqlSource).toBe('logs') + expect(store.recentItems[0].metadata?.sqlSource).toBe('logs') + }) + + it('carries sqlSource from a tab into its recent item on creation', () => { + const store = createTabsState('default') + + store.addTab({ + id: 'sql-a', + type: 'sql', + label: 'Logs query', + metadata: { sqlId: 'a', name: 'Logs query', sqlSource: 'logs' }, + isPreview: false, + }) + + expect(store.recentItems[0].metadata?.sqlSource).toBe('logs') + }) +}) + describe('tabs removal', () => { beforeEach(() => { localStorage.clear() diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index c0ef9b95fe2d0..85c1dc9d56a67 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -12,6 +12,7 @@ import { import { proxy, subscribe, useSnapshot } from 'valtio' import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils' +import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' export const editorEntityTypes = { @@ -44,6 +45,14 @@ export interface Tab { tableId?: number sqlId?: string scrollTop?: number + /** + * For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`), + * so the tab can show the matching icon without re-fetching the snippet. + * Absent on tabs persisted before this field existed β€” treat absent as + * `'database'` and backfill from the loaded snippet (source is immutable, so + * it never goes stale once set). + */ + sqlSource?: SqlSnippetSource } isPreview?: boolean createdAt?: Date @@ -98,6 +107,7 @@ export interface RecentItem { name?: string tableId?: number sqlId?: string + sqlSource?: SqlSnippetSource } } @@ -270,22 +280,35 @@ export function createTabsState(projectRef: string) { store.previewTabId = tab.id store.activeTab = tab.id }, - updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => { - if (!!store.tabsMap[id]) { - if ('label' in updates) { - store.tabsMap[id].label = updates.label - // Keep the persisted name aligned with the visible label so browser titles - // and tab state recover cleanly after entity renames. - if (typeof updates.label === 'string' && store.tabsMap[id].metadata) { - store.tabsMap[id].metadata.name = updates.label - } + updateTab: ( + id: string, + updates: { label?: string; scrollTop?: number; sqlSource?: SqlSnippetSource } + ) => { + const tab = store.tabsMap[id] + if (!tab) return - const recentItem = store.recentItems.find((item) => item.id === id) - if (recentItem) syncRecentItemWithTab(recentItem, store.tabsMap[id]) - } - if ('scrollTop' in updates && store.tabsMap[id].metadata) { - store.tabsMap[id].metadata.scrollTop = updates.scrollTop + if ('label' in updates) { + tab.label = updates.label + // Keep the persisted name aligned with the visible label so browser titles + // and tab state recover cleanly after entity renames. + if (typeof updates.label === 'string' && tab.metadata) { + tab.metadata.name = updates.label } + + const recentItem = store.recentItems.find((item) => item.id === id) + if (recentItem) syncRecentItemWithTab(recentItem, tab) + } + if ('scrollTop' in updates && tab.metadata) { + tab.metadata.scrollTop = updates.scrollTop + } + // Backfill the immutable source onto a tab (and its recent item) that + // predates the field, so its icon resolves correctly once the snippet loads. + if (updates.sqlSource !== undefined) { + if (tab.metadata) tab.metadata.sqlSource = updates.sqlSource + else tab.metadata = { sqlSource: updates.sqlSource } + + const recentItem = store.recentItems.find((item) => item.id === id) + if (recentItem) syncRecentItemWithTab(recentItem, tab) } }, // Function to remove a tab from the store From 8b38e0d1ed92c2259d882ec30e16d756a2427de7 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:02:40 -0400 Subject: [PATCH 07/13] feat(studio): ClickHouse dialect for logs snippet AI + rewrite to ClickHouse (#48501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ## 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. --- .../Queues/SingleQueue/QueueSettings.tsx | 2 +- .../SQLEditor/LegacyLogsRewriteBanner.tsx | 77 +++++++++ .../SQLEditor/SQLEditor.utils.test.ts | 26 +++ .../interfaces/SQLEditor/SQLEditor.utils.ts | 28 ++- .../SQLEditor/SQLEditorControllers.tsx | 2 +- .../SQLEditor/SQLEditorEditorPanel.tsx | 34 ++-- .../SQLEditor/useSqlEditorAi.test.tsx | 114 +++++++++++- .../interfaces/SQLEditor/useSqlEditorAi.ts | 57 +++++- .../Logs/LegacyLogsRewriteAdmonition.tsx | 107 ++++++++++++ .../Settings/Logs/LogsExplorerOtelBanner.tsx | 34 ---- apps/studio/data/logs/keys.ts | 2 + .../logs}/logs-sql-rewrite.test.ts | 73 ++++---- apps/studio/data/logs/logs-sql-rewrite.ts | 113 ++++++++++++ apps/studio/data/logs/otel-log-keys-query.ts | 29 +++- .../analytics/useLegacyLogsRewrite.test.tsx | 163 ++++++++++++++++++ .../hooks/analytics/useLegacyLogsRewrite.ts | 162 +++++++++++++++++ .../analytics/useLogsAttributeKeys.test.tsx | 124 +++++++++++++ .../hooks/analytics/useLogsAttributeKeys.ts | 43 +++++ .../ai/clickhouse-logs.ts} | 140 +++++---------- apps/studio/lib/ai/prompts.ts | 9 - apps/studio/lib/get-error-message.test.ts | 56 +++--- apps/studio/lib/get-error-message.ts | 24 ++- apps/studio/pages/api/ai/code/complete.ts | 61 +++++-- .../project/[ref]/logs/explorer/index.tsx | 108 +++++------- 24 files changed, 1280 insertions(+), 308 deletions(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx create mode 100644 apps/studio/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition.tsx delete mode 100644 apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx rename apps/studio/{components/interfaces/Settings/Logs => data/logs}/logs-sql-rewrite.test.ts (63%) create mode 100644 apps/studio/data/logs/logs-sql-rewrite.ts create mode 100644 apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx create mode 100644 apps/studio/hooks/analytics/useLegacyLogsRewrite.ts create mode 100644 apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx create mode 100644 apps/studio/hooks/analytics/useLogsAttributeKeys.ts rename apps/studio/{components/interfaces/Settings/Logs/logs-sql-rewrite.ts => lib/ai/clickhouse-logs.ts} (50%) diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx index b1496a1dc3c0b..05cfc78cf3737 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx @@ -214,7 +214,7 @@ export const QueueSettings = ({}: QueueSettingsProps) => { toast.success('Successfully updated permissions') setOpen(false) } catch (error: unknown) { - toast.error(`Failed to update permissions: ${getErrorMessage(error)}`) + toast.error(`Failed to update permissions: ${getErrorMessage(error, 'unknown error')}`) } finally { setIsSaving(false) } diff --git a/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx b/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx new file mode 100644 index 0000000000000..9bb41fe5430d9 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx @@ -0,0 +1,77 @@ +import { useDebounce } from '@uidotdev/usehooks' +import { useFlag } from 'common' +import { useMemo } from 'react' + +import { DiffType } from './SQLEditor.types' +import { useSqlEditorAssistant, useSqlEditorRun, useSqlEditorSnippet } from './SQLEditorControllers' +import { LegacyLogsRewriteAdmonition } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition' +import { + LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS, + shouldOfferLegacyLogsRewrite, +} from '@/data/logs/logs-sql-rewrite' +import { useLegacyLogsRewrite } from '@/hooks/analytics/useLegacyLogsRewrite' +import { + getSqlEditorV2StateSnapshot, + useSqlEditorV2StateSnapshot, +} from '@/state/sql-editor/sql-editor-state' + +/** + * Offers to rewrite a logs snippet still written in the old BigQuery dialect + * (per-service `FROM` tables, `unnest(metadata)` joins) to ClickHouse SQL. Legacy + * Logs Explorer saved queries open in the SQL editor as `log_sql` snippets, and + * those queries error against the ClickHouse-backed endpoint the editor runs them + * on β€” this is the in-editor path out. + * + * The request itself is `useLegacyLogsRewrite` (shared with the Logs Explorer); + * this decides when to offer it and routes the result into the editor's existing + * AI diff view, so the user accepts or discards it the same way as any other AI + * edit rather than having the snippet rewritten under them. + * + * Mount with `key={id}` so the offer resets when the user switches snippets. This + * component must NOT be conditionally mounted by its parent β€” it hides itself, so + * that opening a diff doesn't unmount it and throw away a dismissal. + */ +export const LegacyLogsRewriteBanner = () => { + const { id } = useSqlEditorSnippet() + const { runSource } = useSqlEditorRun() + const { + diff: { isDiffOpen, setSourceSqlDiff, setSelectedDiffType }, + } = useSqlEditorAssistant() + + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + const snapV2 = useSqlEditorV2StateSnapshot() + + // The store is written on every keystroke, so debounce before running the + // dialect heuristics β€” the banner's visibility doesn't need per-character + // precision, and a settled value avoids flapping mid-edit. + const liveSql = snapV2.snippets[id]?.snippet.content?.unchecked_sql ?? '' + const settledSql = useDebounce(liveSql, LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS) + + const isLogsSnippetNeedingRewrite = useMemo( + () => + runSource.type === 'logs' && + shouldOfferLegacyLogsRewrite({ sql: settledSql, isClickhouseLogsEnabled: isOtelLogsEnabled }), + [runSource.type, settledSql, isOtelLogsEnabled] + ) + + const { state, requestRewrite, dismiss } = useLegacyLogsRewrite({ + // Rewrite exactly what's in the editor now, not the debounced value the + // visibility check used β€” they differ if the user clicked mid-edit. + readSql: () => getSqlEditorV2StateSnapshot().snippets[id]?.snippet.content?.unchecked_sql ?? '', + onProposal: ({ original, modified }) => { + setSourceSqlDiff({ original, modified }) + setSelectedDiffType(DiffType.Modification) + }, + }) + + // An outcome the user hasn't acknowledged stays up even once the query no longer + // looks legacy β€” otherwise a successful proposal would yank its own result away. + const hasUnacknowledgedOutcome = state.status === 'failed' || state.status === 'noRewriteNeeded' + const canShowBanner = !isDiffOpen && (isLogsSnippetNeedingRewrite || hasUnacknowledgedOutcome) + + if (!canShowBanner) return null + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index 982ef10bb1606..aeefbdcfe32f7 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -29,6 +29,7 @@ import { resolveConnectionString, resolveDiffKeyAction, shouldAutoGenerateTitle, + sqlSourceToDialect, trimTrailingSemicolons, } from './SQLEditor.utils' import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' @@ -432,6 +433,31 @@ describe('SQLEditor.utils.ts:buildCompletionRequestBody', () => { completionMetadata: { prompt: 'add a where clause' }, }) }) + test('omits dialect when not provided, so the route keeps its Postgres default', () => { + const body = buildCompletionRequestBody({ + projectRef: 'default', + connectionString: null, + orgSlug: 'acme', + }) + expect(body).not.toHaveProperty('dialect') + }) + test('includes the dialect when provided', () => { + expect( + buildCompletionRequestBody({ + projectRef: 'default', + connectionString: null, + orgSlug: 'acme', + dialect: 'clickhouse', + }).dialect + ).toBe('clickhouse') + }) +}) + +describe('SQLEditor.utils.ts:sqlSourceToDialect', () => { + test('logs snippets get ClickHouse, database snippets get Postgres', () => { + expect(sqlSourceToDialect('logs')).toBe('clickhouse') + expect(sqlSourceToDialect('database')).toBe('postgres') + }) }) describe('SQLEditor.utils.ts:createSqlSnippetSkeletonV2', () => { diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index aa1ba9120804b..33ec043398d7e 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -497,25 +497,46 @@ export function assembleCompletionDiff( } /** - * Builds the request body sent to the AI completion endpoint. `options` is - * the caller-provided extra fields (e.g. `completionMetadata`), merged in - * last so it can override the defaults if it ever needs to. + * The SQL dialect the AI writes. Mirrors the `dialect` enum the completion API + * route accepts β€” a snippet's dialect follows its source and never flips, so a + * logs snippet always gets ClickHouse SQL and a database snippet Postgres. + */ +export type SqlDialect = 'postgres' | 'clickhouse' + +/** + * Maps a snippet's query source to the dialect the AI should write in. Logs + * snippets run against the ClickHouse-backed analytics endpoint; everything + * else runs against the user's Postgres database. + */ +export function sqlSourceToDialect(source: SqlSnippetSource): SqlDialect { + return source === 'logs' ? 'clickhouse' : 'postgres' +} + +/** + * Builds the request body sent to the AI completion endpoint. `dialect` is + * omitted when undefined so callers that don't care keep the route's Postgres + * default. `options` is the caller-provided extra fields (e.g. + * `completionMetadata`), merged in last so it can override the defaults if it + * ever needs to. */ export function buildCompletionRequestBody({ projectRef, connectionString, orgSlug, + dialect, options, }: { projectRef: string | undefined connectionString: string | undefined | null orgSlug: string | undefined + dialect?: SqlDialect options?: { completionMetadata?: unknown } }): { projectRef: string | undefined connectionString: string | undefined | null language: 'sql' orgSlug: string | undefined + dialect?: SqlDialect completionMetadata?: unknown } { return { @@ -523,6 +544,7 @@ export function buildCompletionRequestBody({ connectionString, language: 'sql', orgSlug, + ...(dialect !== undefined && { dialect }), ...(options ?? {}), } } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx index 0aa70a05313fc..f4738c230a98e 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx @@ -191,7 +191,7 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => const isExecuting = isExecutingDb || isExecutingLogs - const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt }) + const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt, sqlSource: runSource.type }) const { acceptAiHandler, discardAiHandler } = ai useSqlEditorShortcuts({ diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx index 149a713f4e540..6215f6580d67a 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx @@ -4,6 +4,7 @@ import dynamic from 'next/dynamic' import { useCallback } from 'react' import { cn } from 'ui' +import { LegacyLogsRewriteBanner } from './LegacyLogsRewriteBanner' import { useSQLEditorContext } from './SQLEditorContext' import { useSqlEditorAssistant, @@ -171,23 +172,32 @@ const SQLEditorMainView = () => { ) } +function LoadingSpinner() { + return ( +
+
+ +
+
+ ) +} + /** The top (editor) resizable panel: loading state, diff view, and main editor. */ export const SQLEditorEditorPanel = () => { - const { isLoading } = useSqlEditorSnippet() + const { id, isLoading } = useSqlEditorSnippet() const { diff } = useSqlEditorAssistant() + if (isLoading) { + return + } + return ( -
- {isLoading ? ( -
- -
- ) : ( - <> - {diff.isDiffOpen && } - - - )} +
+ +
+ {diff.isDiffOpen && } + +
) } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx index 244f4fc443148..60f2a399f3cff 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx @@ -1,13 +1,17 @@ import { act, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' +import type { SqlSnippetSource } from './querySource' import { DiffType } from './SQLEditor.types' import { useSqlEditorAi } from './useSqlEditorAi' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { API_URL } from '@/lib/constants' import { sidebarManagerState } from '@/state/sidebar-manager-state' import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request' import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { mswServer } from '@/tests/lib/msw' import { createInMemoryEditor, renderSqlEditorHook, @@ -18,15 +22,17 @@ import { const SNIPPET_ID = 'ai-snippet' +type HarnessProps = { editorMountCount?: number; sqlSource?: SqlSnippetSource } + /** * Composes the diff + prompt state hooks the AI hook depends on (production * wires these together in `SQLEditorControllers`), so tests drive the real * accept/discard/drain flows end to end. */ -function useAiHarness({ editorMountCount = 1 }: { editorMountCount?: number } = {}) { +function useAiHarness({ editorMountCount = 1, sqlSource = 'database' }: HarnessProps = {}) { const diff = useSqlEditorDiff() const prompt = useSqlEditorPrompt() - const ai = useSqlEditorAi({ id: SNIPPET_ID, editorMountCount, diff, prompt }) + const ai = useSqlEditorAi({ id: SNIPPET_ID, editorMountCount, diff, prompt, sqlSource }) return { ai, diff, prompt } } @@ -119,6 +125,110 @@ describe('useSqlEditorAi β€” accept / discard diff', () => { }) }) +describe('useSqlEditorAi β€” completion dialect', () => { + type CompletionRequestBody = { + dialect?: string + intent?: string + completionMetadata: { + prompt: string + selection: string + textBeforeCursor: string + textAfterCursor: string + availableKeys?: string[] + } + } + + /** Replaces the default completion mock so we can read what was posted. */ + function captureCompletionRequests(response: string) { + const bodies: CompletionRequestBody[] = [] + mswServer.use( + http.post(`${API_URL}/ai/code/complete`, async ({ request }) => { + bodies.push((await request.json()) as CompletionRequestBody) + return HttpResponse.json(response) + }) + ) + return bodies + } + + const context = { + beforeSelection: "select timestamp from logs where source = 'edge_logs'\n", + selection: 'limit 5', + afterSelection: '', + } + + it('posts the clickhouse dialect with the raw instruction and cursor context', async () => { + const bodies = captureCompletionRequests('limit 10') + const { result } = renderSqlEditorHook(useAiHarness, { + initialProps: { sqlSource: 'logs' }, + }) + + await act(async () => { + await result.current.ai.handlePrompt('only keep 5xx responses', context) + }) + + expect(bodies).toHaveLength(1) + expect(bodies[0].dialect).toBe('clickhouse') + // The route assembles the schema section and the selection-wrapped code around + // the instruction, so the client posts the instruction verbatim β€” the same + // shape as the Postgres path β€” and never hand-builds prompt text. + expect(bodies[0].completionMetadata.prompt).toBe('only keep 5xx responses') + expect(bodies[0].completionMetadata.selection).toBe('limit 5') + expect(bodies[0].completionMetadata.textBeforeCursor).toBe(context.beforeSelection) + // An inline edit is not a rewrite. + expect(bodies[0].intent).toBeUndefined() + }) + + it('posts the postgres dialect with the raw instruction for a database snippet', async () => { + const bodies = captureCompletionRequests('limit 10') + const { result } = renderSqlEditorHook(useAiHarness, { + initialProps: { sqlSource: 'database' }, + }) + + await act(async () => { + await result.current.ai.handlePrompt('bump the limit', context) + }) + + expect(bodies).toHaveLength(1) + expect(bodies[0].dialect).toBe('postgres') + expect(bodies[0].completionMetadata.prompt).toBe('bump the limit') + }) + + it('strips code fences and leaves clickhouse output unformatted', async () => { + captureCompletionRequests('```sql\nlimit 10\n```') + const { result } = renderSqlEditorHook(useAiHarness, { + initialProps: { sqlSource: 'logs' }, + }) + + await act(async () => { + await result.current.ai.handlePrompt('bump the limit', context) + }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(true)) + // sql-formatter is Postgres-only, so the ClickHouse diff is the reassembled + // query verbatim β€” fences stripped, nothing else touched. + expect(result.current.diff.sourceSqlDiff).toEqual({ + original: `${context.beforeSelection}limit 5`, + modified: `${context.beforeSelection}limit 10`, + }) + }) + + it('still formats database output through sql-formatter', async () => { + captureCompletionRequests('limit 10') + const { result } = renderSqlEditorHook(useAiHarness, { + initialProps: { sqlSource: 'database' }, + }) + + await act(async () => { + await result.current.ai.handlePrompt('bump the limit', context) + }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(true)) + expect(result.current.diff.sourceSqlDiff?.modified).not.toBe( + `${context.beforeSelection}limit 10` + ) + }) +}) + describe('useSqlEditorAi β€” debug', () => { it('onDebug opens the assistant sidebar and starts a debug chat from the failing snippet', async () => { seedSnippet({ id: SNIPPET_ID, name: 'Broken query', sql: 'selct 1;' }) diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts index 97c5e589d6bcf..240d346240447 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react import { toast } from 'sonner' import type { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' +import type { SqlSnippetSource } from './querySource' import { DiffType, type IStandaloneDiffEditor } from './SQLEditor.types' import { assembleCompletionDiff, @@ -13,12 +14,15 @@ import { createSqlSnippetSkeletonV2, extractDebugContext, planDiffRequestApplication, + sqlSourceToDialect, } from './SQLEditor.utils' import { useSQLEditorContext } from './SQLEditorContext' import { useSnippetTitleGenerator } from './useSnippetTitleGenerator' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { constructHeaders } from '@/data/fetchers' +import { stripSqlCodeFences } from '@/data/logs/logs-sql-rewrite' import { isError } from '@/data/utils/error-check' +import { useLogsAttributeKeys } from '@/hooks/analytics/useLogsAttributeKeys' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { BASE_PATH } from '@/lib/constants' @@ -37,6 +41,11 @@ type UseSqlEditorAiArgs = { editorMountCount: number diff: ReturnType prompt: ReturnType + /** + * Where the snippet runs. Selects the dialect the AI writes in β€” logs snippets + * get ClickHouse SQL for the `logs` table, database snippets get Postgres. + */ + sqlSource: SqlSnippetSource } /** @@ -45,7 +54,13 @@ type UseSqlEditorAiArgs = { * lifecycle effects (one-shot diff-request drain, diff-editor value sync, and the * ask-AI widget visibility). */ -export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEditorAiArgs) { +export function useSqlEditorAi({ + id, + editorMountCount, + diff, + prompt, + sqlSource, +}: UseSqlEditorAiArgs) { const { sourceSqlDiff, setSourceSqlDiff, @@ -77,6 +92,14 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false) const [showWidget, setShowWidget] = useState(false) + const dialect = sqlSourceToDialect(sqlSource) + const isClickhouse = dialect === 'clickhouse' + + // Grounds ClickHouse edits in the source's real log_attributes keys, the same way + // the whole-query rewrite does β€” otherwise inline edits invent dotted paths. + // Looked up when the user submits, not while they type. + const { fetchAttributeKeys } = useLogsAttributeKeys() + const handleNewQuery = useCallback( async (sql: string, name: string) => { if (!ref) return console.error('Project ref is required') @@ -196,6 +219,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi projectRef: project?.ref, connectionString: project?.connectionString, orgSlug: org?.slug, + dialect, options: options?.body, }) ), @@ -210,9 +234,16 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const text: string = await response.json() const meta = options?.body?.completionMetadata ?? {} - const { original, modified } = assembleCompletionDiff(meta, text) + // The clickhouse system prompt forbids fences, but strip them defensively + // so a chatty model can't leak backticks into the snippet. + const { original, modified } = assembleCompletionDiff( + meta, + isClickhouse ? stripSqlCodeFences(text) : text + ) - const formattedModified = formatSql(modified) + // sql-formatter is Postgres-only β€” it mangles ClickHouse backticks and + // map lookups β€” so ClickHouse output goes into the diff unformatted. + const formattedModified = isClickhouse ? modified : formatSql(modified) setSourceSqlDiff({ original, modified: formattedModified }) setSelectedDiffType(DiffType.Modification) setPromptState((prev) => ({ ...prev, isLoading: false })) @@ -224,6 +255,8 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi } }, [ + dialect, + isClickhouse, org?.slug, project?.connectionString, project?.ref, @@ -249,10 +282,23 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi beforeSelection: context.beforeSelection, afterSelection: context.afterSelection, })) - const headerData = await constructHeaders() + // ClickHouse only: there's no server-side schema to fetch for the logs + // table, so the real log_attributes keys travel with the request. Detected + // from the whole document, which is what the three context fields spell. + const [headerData, availableKeys] = await Promise.all([ + constructHeaders(), + isClickhouse + ? fetchAttributeKeys( + context.beforeSelection + context.selection + context.afterSelection + ) + : undefined, + ]) const authorizationHeader = headerData.get('Authorization') + // The instruction goes over as-is for both dialects β€” the route assembles + // the schema section and the cursor context around it, so there's exactly + // one place that knows how a completion prompt is built. await complete(prompt, { ...(authorizationHeader ? { headers: { Authorization: authorizationHeader } } @@ -264,6 +310,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi language: 'pgsql', prompt, selection: context.selection, + ...(availableKeys ? { availableKeys } : {}), }, }, }) @@ -271,7 +318,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi setPromptState((prev) => ({ ...prev, isLoading: false })) } }, - [complete, setPromptState] + [complete, fetchAttributeKeys, isClickhouse, setPromptState] ) const handleDiffEditorMount = useCallback( diff --git a/apps/studio/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition.tsx b/apps/studio/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition.tsx new file mode 100644 index 0000000000000..581c0b0233425 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition.tsx @@ -0,0 +1,107 @@ +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import type { LegacyLogsRewriteState } from '@/hooks/analytics/useLegacyLogsRewrite' + +interface LegacyLogsRewriteAdmonitionProps { + state: LegacyLogsRewriteState + onRewrite: () => void + onDismiss: () => void +} + +const BANNER_CLASSES = 'mb-0 rounded-none border-x-0 border-t-0' + +/** + * Presentation for the BigQuery β†’ ClickHouse rewrite offer, covering every state + * of `useLegacyLogsRewrite`. Shared by the Logs Explorer and the SQL editor so the + * copy and the outcome handling live in one place β€” a surface only decides *when* + * to show this, not what each state says. + * + * Renders nothing once dismissed; callers can also hide it earlier if they have + * their own reasons to (the SQL editor hides it while a diff is open). + */ +export const LegacyLogsRewriteAdmonition = ({ + state, + onRewrite, + onDismiss, +}: LegacyLogsRewriteAdmonitionProps) => { + if (state.status === 'dismissed') return null + + if (state.status === 'failed') { + return ( + + + +
+ } + /> + ) + } + + // The dialect check is a heuristic, so an unchanged rewrite is the Assistant + // disagreeing with it. Say so and let the user close it, rather than proposing + // an empty diff or silently giving up. + if (state.status === 'noRewriteNeeded') { + return ( + + Dismiss + + } + /> + ) + } + + const isRewriting = state.status === 'rewriting' + + return ( + + + {/* Disabled mid-rewrite so the offer can't be dismissed out from under + an in-flight request β€” the two states stay mutually exclusive. */} + + Dismiss + +
+ } + /> + ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx deleted file mode 100644 index 4f9bac9cd3460..0000000000000 --- a/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button } from 'ui' -import { Admonition } from 'ui-patterns/Admonition' - -interface LogsExplorerOtelBannerProps { - isRewriting: boolean - onRewrite: () => void - onDismiss: () => void -} - -export const LogsExplorerOtelBanner = ({ - isRewriting, - onRewrite, - onDismiss, -}: LogsExplorerOtelBannerProps) => { - return ( - - - -
- } - /> - ) -} diff --git a/apps/studio/data/logs/keys.ts b/apps/studio/data/logs/keys.ts index 9aff8c9c58b62..f2f8de82ddb05 100644 --- a/apps/studio/data/logs/keys.ts +++ b/apps/studio/data/logs/keys.ts @@ -1,6 +1,8 @@ import { QuerySearchParamsType } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.types' export const logsKeys = { + otelLogKeys: (projectRef: string | undefined, source: string | undefined) => + ['projects', projectRef, 'otel-log-keys', source] as const, unifiedLogsInfinite: ( projectRef: string | undefined, searchParams: QuerySearchParamsType | undefined diff --git a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts b/apps/studio/data/logs/logs-sql-rewrite.test.ts similarity index 63% rename from apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts rename to apps/studio/data/logs/logs-sql-rewrite.test.ts index 1b4f064641094..6e7a80c94ad3d 100644 --- a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts +++ b/apps/studio/data/logs/logs-sql-rewrite.test.ts @@ -1,45 +1,36 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - buildClickhouseRewritePrompt, detectLogSource, looksLikeLegacyLogsQuery, rewriteLogsSqlWithAI, + shouldOfferLegacyLogsRewrite, stripSqlCodeFences, } from './logs-sql-rewrite' -describe('buildClickhouseRewritePrompt', () => { - it('includes the query, the schema, and a reply-with-only-SQL instruction', () => { - const prompt = buildClickhouseRewritePrompt('select count(*) from edge_logs') - expect(prompt).toContain('select count(*) from edge_logs') - expect(prompt).toContain('log_attributes') - expect(prompt).toContain("source = 'edge_logs'") - expect(prompt.toLowerCase()).toContain('reply with only') - }) +describe('shouldOfferLegacyLogsRewrite', () => { + const legacySql = 'select 1 from edge_logs cross join unnest(metadata) as m' - it('spells out the FROM-to-logs conversion and shows a worked example', () => { - const prompt = buildClickhouseRewritePrompt('select 1 from postgres_logs') - expect(prompt).toContain("from logs where source = 'postgres_logs'") - expect(prompt.toLowerCase()).toContain('remove every') - expect(prompt).toContain('cross join unnest') - expect(prompt).toContain('BigQuery:') - expect(prompt).toContain('ClickHouse:') - expect(prompt).toContain("log_attributes['parsed.error_severity']") + it('offers the rewrite for BigQuery-dialect SQL once logs run on ClickHouse', () => { + expect(shouldOfferLegacyLogsRewrite({ sql: legacySql, isClickhouseLogsEnabled: true })).toBe( + true + ) }) - it('lists the real log_attributes keys when provided and demands exact paths', () => { - const prompt = buildClickhouseRewritePrompt('select 1 from edge_logs', [ - 'request.headers.x_real_ip', - 'request.cf.country', - ]) - expect(prompt).toContain("log_attributes['request.headers.x_real_ip']") - expect(prompt).toContain("log_attributes['request.cf.country']") - expect(prompt.toLowerCase()).toContain('exact') + it('never offers it on a non-migrated org, where the BigQuery SQL is still correct', () => { + expect(shouldOfferLegacyLogsRewrite({ sql: legacySql, isClickhouseLogsEnabled: false })).toBe( + false + ) }) - it('omits the keys section when none are provided', () => { - const prompt = buildClickhouseRewritePrompt('select 1 from edge_logs') - expect(prompt).not.toContain('actual log_attributes keys present') + it('does not offer it for SQL that is already ClickHouse, or for empty SQL', () => { + expect( + shouldOfferLegacyLogsRewrite({ + sql: "select timestamp from logs where source = 'edge_logs' limit 5", + isClickhouseLogsEnabled: true, + }) + ).toBe(false) + expect(shouldOfferLegacyLogsRewrite({ sql: '', isClickhouseLogsEnabled: true })).toBe(false) }) }) @@ -66,6 +57,21 @@ describe('detectLogSource', () => { it('returns undefined when nothing matches', () => { expect(detectLogSource('select 1')).toBeUndefined() }) + + it('ignores a column that merely ends in "source"', () => { + expect(detectLogSource("select 1 from logs where resource = 'nope'")).toBeUndefined() + expect(detectLogSource("select 1 from logs where datasource = 'nope'")).toBeUndefined() + }) + + it('still reads a qualified source column', () => { + expect(detectLogSource("select 1 from logs t where t.source = 'auth_logs'")).toBe('auth_logs') + }) + + it('prefers the real source column over a lookalike earlier in the query', () => { + expect(detectLogSource("select resource = 'nope' from logs where source = 'edge_logs'")).toBe( + 'edge_logs' + ) + }) }) describe('looksLikeLegacyLogsQuery', () => { @@ -112,7 +118,7 @@ describe('rewriteLogsSqlWithAI', () => { vi.unstubAllGlobals() }) - it('posts to the completion endpoint and returns the cleaned query', async () => { + it('declares the rewrite intent and sends the query as the selection', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => '```sql\nselect 1 from logs\n```', @@ -122,6 +128,7 @@ describe('rewriteLogsSqlWithAI', () => { const result = await rewriteLogsSqlWithAI({ sql: 'select 1 from edge_logs', projectRef: 'abc', + availableKeys: ['request.method'], }) expect(result).toBe('select 1 from logs') @@ -129,8 +136,14 @@ describe('rewriteLogsSqlWithAI', () => { expect(url).toContain('/api/ai/code/complete') const body = JSON.parse(init.body) expect(body.dialect).toBe('clickhouse') + expect(body.intent).toBe('rewrite') + // The whole query is the selection, so the rewrite replaces all of it. expect(body.completionMetadata.selection).toBe('select 1 from edge_logs') - expect(body.completionMetadata.prompt.toLowerCase()).toContain('reply with only') + expect(body.completionMetadata.textBeforeCursor).toBe('') + expect(body.completionMetadata.textAfterCursor).toBe('') + expect(body.completionMetadata.availableKeys).toEqual(['request.method']) + // No prompt text is carried client-side β€” the route owns the instruction. + expect(body.completionMetadata.prompt).toBe('') }) it('throws when the request fails', async () => { diff --git a/apps/studio/data/logs/logs-sql-rewrite.ts b/apps/studio/data/logs/logs-sql-rewrite.ts new file mode 100644 index 0000000000000..f9c21d798bd61 --- /dev/null +++ b/apps/studio/data/logs/logs-sql-rewrite.ts @@ -0,0 +1,113 @@ +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 = { + 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 +} diff --git a/apps/studio/data/logs/otel-log-keys-query.ts b/apps/studio/data/logs/otel-log-keys-query.ts index c4918af0af835..33118f09a4ca5 100644 --- a/apps/studio/data/logs/otel-log-keys-query.ts +++ b/apps/studio/data/logs/otel-log-keys-query.ts @@ -1,11 +1,14 @@ -import { useQuery } from '@tanstack/react-query' +import { queryOptions, useQuery } from '@tanstack/react-query' import { executeAnalyticsSql } from './execute-analytics-sql' +import { logsKeys } from './keys' import { logsAllEndpointUrl } from './logs-endpoint' import { analyticsLiteral, safeSql } from './safe-analytics-sql' const LOOKBACK_HOURS = 24 * 7 +const KEYS_STALE_TIME = 5 * 60 * 1000 + export async function fetchOtelLogKeys({ projectRef, source, @@ -31,15 +34,31 @@ export async function fetchOtelLogKeys({ return rows.map((r) => r.key).filter(Boolean) } +/** + * Shared by the reactive hook and imperative `queryClient.fetchQuery` callers, so + * a lookup triggered on submit reuses whatever a subscribed component already + * cached for the same source (and vice versa). + */ +export function otelLogKeysQueryOptions({ + projectRef, + source, +}: { + projectRef: string + source: string +}) { + return queryOptions({ + queryKey: logsKeys.otelLogKeys(projectRef, source), + queryFn: ({ signal }) => fetchOtelLogKeys({ projectRef, source, signal }), + staleTime: KEYS_STALE_TIME, + }) +} + export function useOtelLogKeysQuery( { projectRef, source }: { projectRef?: string; source?: string }, { enabled = true }: { enabled?: boolean } = {} ) { return useQuery({ - queryKey: ['projects', projectRef, 'otel-log-keys', source], - queryFn: ({ signal }) => - fetchOtelLogKeys({ projectRef: projectRef ?? '', source: source ?? '', signal }), + ...otelLogKeysQueryOptions({ projectRef: projectRef ?? '', source: source ?? '' }), enabled: enabled && Boolean(projectRef) && Boolean(source), - staleTime: 5 * 60 * 1000, }) } diff --git a/apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx b/apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx new file mode 100644 index 0000000000000..0dd6b1d443840 --- /dev/null +++ b/apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx @@ -0,0 +1,163 @@ +import { act, waitFor } from '@testing-library/react' +import { delay, http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + INITIAL_LEGACY_LOGS_REWRITE_STATE, + legacyLogsRewriteReducer as reduce, + useLegacyLogsRewrite, + type LegacyLogsRewriteEvent, + type LegacyLogsRewriteState, +} from './useLegacyLogsRewrite' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { API_URL } from '@/lib/constants' +import { mswServer } from '@/tests/lib/msw' +import { renderSqlEditorHook, setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' + +const run = ( + events: LegacyLogsRewriteEvent[], + from: LegacyLogsRewriteState = INITIAL_LEGACY_LOGS_REWRITE_STATE +) => events.reduce(reduce, from) + +const FAILED: LegacyLogsRewriteEvent = { type: 'rewriteFailed', message: 'boom' } + +describe('legacyLogsRewriteReducer', () => { + it('starts out offering the rewrite', () => { + expect(INITIAL_LEGACY_LOGS_REWRITE_STATE).toEqual({ status: 'offered' }) + }) + + it('requesting a rewrite moves to rewriting, and a proposal returns to offered', () => { + expect(run([{ type: 'rewriteRequested' }])).toEqual({ status: 'rewriting' }) + expect(run([{ type: 'rewriteRequested' }, { type: 'rewriteProposed' }])).toEqual({ + status: 'offered', + }) + }) + + it('a failure lands in failed and keeps its message for the UI', () => { + expect(run([{ type: 'rewriteRequested' }, FAILED])).toEqual({ + status: 'failed', + message: 'boom', + }) + }) + + it('a failure is recoverable β€” the same request retries it', () => { + expect(run([{ type: 'rewriteRequested' }, FAILED, { type: 'rewriteRequested' }])).toEqual({ + status: 'rewriting', + }) + }) + + it('an unchanged response waits for acknowledgement instead of retiring silently', () => { + const noop = run([{ type: 'rewriteRequested' }, { type: 'rewriteNoop' }]) + expect(noop).toEqual({ status: 'noRewriteNeeded' }) + expect(run([{ type: 'dismissed' }], noop)).toEqual({ status: 'dismissed' }) + }) + + it('both outcomes can be dismissed, and neither can be retried into a new outcome', () => { + const failed = run([{ type: 'rewriteRequested' }, FAILED]) + expect(run([{ type: 'dismissed' }], failed)).toEqual({ status: 'dismissed' }) + // noRewriteNeeded only accepts dismissal β€” no silent retry. + const noop = run([{ type: 'rewriteRequested' }, { type: 'rewriteNoop' }]) + expect(run([{ type: 'rewriteRequested' }], noop)).toEqual({ status: 'noRewriteNeeded' }) + }) + + it('dismissal is terminal β€” nothing resurrects the offer', () => { + const dismissed = run([{ type: 'dismissed' }]) + expect(dismissed).toEqual({ status: 'dismissed' }) + expect( + run( + [ + { type: 'rewriteRequested' }, + { type: 'rewriteProposed' }, + FAILED, + { type: 'rewriteNoop' }, + ], + dismissed + ) + ).toEqual({ status: 'dismissed' }) + }) + + it('cannot be dismissed mid-rewrite, so a settling request never resurrects it', () => { + expect(run([{ type: 'rewriteRequested' }, { type: 'dismissed' }])).toEqual({ + status: 'rewriting', + }) + }) + + it('ignores events that are invalid for the current state', () => { + // No rewrite in flight to settle. + expect(run([{ type: 'rewriteProposed' }])).toEqual({ status: 'offered' }) + expect(run([{ type: 'rewriteNoop' }])).toEqual({ status: 'offered' }) + expect(run([FAILED])).toEqual({ status: 'offered' }) + // Already rewriting; a second request is a no-op rather than a restart. + expect(run([{ type: 'rewriteRequested' }, { type: 'rewriteRequested' }])).toEqual({ + status: 'rewriting', + }) + }) +}) + +describe('useLegacyLogsRewrite β€” dismiss', () => { + // No detectable source, so key discovery is skipped and the only outbound + // request is the completion call we control below. + const SQL_WITHOUT_SOURCE = 'select 1 from logs limit 5' + + /** Keeps a requested rewrite in flight for the duration of the test. */ + function stallTheRewrite() { + mswServer.use( + http.post(`${API_URL}/ai/code/complete`, async () => { + await delay(10_000) + return HttpResponse.json('select 1 from logs') + }) + ) + } + + /** + * Exposes the resolved project alongside the hook: `requestRewrite` no-ops + * without a project ref, so tests must wait for that query before asking. + */ + async function renderDismissHarness() { + const onDismissed = vi.fn() + const utils = renderSqlEditorHook(() => { + const { data: project } = useSelectedProjectQuery() + const rewrite = useLegacyLogsRewrite({ + readSql: () => SQL_WITHOUT_SOURCE, + onProposal: vi.fn(), + onDismissed, + }) + return { ...rewrite, projectRef: project?.ref } + }) + await waitFor(() => expect(utils.result.current.projectRef).toBe('default')) + return { ...utils, onDismissed } + } + + beforeEach(() => { + setupSqlEditorMocks() + }) + + it('dismisses from the offer and reports it', async () => { + const { result, onDismissed } = await renderDismissHarness() + + await act(async () => { + result.current.dismiss() + }) + + expect(result.current.state.status).toBe('dismissed') + expect(onDismissed).toHaveBeenCalledTimes(1) + }) + + it('does not report a dismissal the machine rejects mid-rewrite', async () => { + stallTheRewrite() + const { result, onDismissed } = await renderDismissHarness() + + act(() => { + void result.current.requestRewrite() + }) + await waitFor(() => expect(result.current.state.status).toBe('rewriting')) + + await act(async () => { + result.current.dismiss() + }) + + // Persisting this would suppress an offer that's still live. + expect(onDismissed).not.toHaveBeenCalled() + expect(result.current.state.status).toBe('rewriting') + }) +}) diff --git a/apps/studio/hooks/analytics/useLegacyLogsRewrite.ts b/apps/studio/hooks/analytics/useLegacyLogsRewrite.ts new file mode 100644 index 0000000000000..9b0ac2aa2a508 --- /dev/null +++ b/apps/studio/hooks/analytics/useLegacyLogsRewrite.ts @@ -0,0 +1,162 @@ +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 } +} diff --git a/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx b/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx new file mode 100644 index 0000000000000..8b9577015b183 --- /dev/null +++ b/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx @@ -0,0 +1,124 @@ +import { act, waitFor } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useLogsAttributeKeys } from './useLogsAttributeKeys' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { addAPIMock } from '@/tests/lib/msw' +import { renderSqlEditorHook, setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' + +const OTEL_ENDPOINT = '/platform/projects/:ref/analytics/endpoints/logs.all.otel' + +const queryFor = (source: string) => `select 1 from logs where source = '${source}'` + +/** Records the SQL of every key-discovery request so we can count them. */ +function mockKeyDiscovery({ fails = false }: { fails?: boolean } = {}) { + const requests: string[] = [] + addAPIMock({ + method: 'post', + path: OTEL_ENDPOINT, + response: async ({ request }) => { + const body = (await request.clone().json()) as { sql: string } + requests.push(body.sql) + if (fails) return HttpResponse.json({ message: 'boom' }, { status: 500 }) + return HttpResponse.json({ result: [{ key: 'request.method' }] }) + }, + }) + return requests +} + +/** + * Exposes the resolved project alongside the hook. Discovery needs a project ref, + * so tests must wait for that query before asking β€” otherwise a lookup no-ops and + * the request counts below would pass for the wrong reason. + */ +function useKeysHarness() { + const { data: project } = useSelectedProjectQuery() + const { fetchAttributeKeys } = useLogsAttributeKeys() + return { projectRef: project?.ref, fetchAttributeKeys } +} + +async function renderReadyHarness() { + const utils = renderSqlEditorHook(useKeysHarness) + await waitFor(() => expect(utils.result.current.projectRef).toBe('default')) + return utils +} + +beforeEach(() => { + setupSqlEditorMocks() +}) + +describe('useLogsAttributeKeys', () => { + it('makes no request until asked', async () => { + const requests = mockKeyDiscovery() + + await renderReadyHarness() + + expect(requests).toHaveLength(0) + }) + + it('returns the discovered keys for the query source when asked', async () => { + const requests = mockKeyDiscovery() + const { result } = await renderReadyHarness() + + let keys: string[] | undefined + await act(async () => { + keys = await result.current.fetchAttributeKeys(queryFor('edge_logs')) + }) + + expect(keys).toEqual(['request.method']) + expect(requests).toHaveLength(1) + expect(requests[0]).toContain("source = 'edge_logs'") + }) + + it('reuses the cached result for a source already looked up', async () => { + const requests = mockKeyDiscovery() + const { result } = await renderReadyHarness() + + await act(async () => { + await result.current.fetchAttributeKeys(queryFor('edge_logs')) + await result.current.fetchAttributeKeys(queryFor('edge_logs')) + }) + + expect(requests).toHaveLength(1) + }) + + it('looks up a different source separately', async () => { + const requests = mockKeyDiscovery() + const { result } = await renderReadyHarness() + + await act(async () => { + await result.current.fetchAttributeKeys(queryFor('edge_logs')) + await result.current.fetchAttributeKeys(queryFor('postgres_logs')) + }) + + expect(requests).toHaveLength(2) + expect(requests[1]).toContain("source = 'postgres_logs'") + }) + + it('resolves undefined without a request when no source is detectable', async () => { + const requests = mockKeyDiscovery() + const { result } = await renderReadyHarness() + + let keys: string[] | undefined + await act(async () => { + keys = await result.current.fetchAttributeKeys('select 1 from logs limit 5') + }) + + expect(keys).toBeUndefined() + expect(requests).toHaveLength(0) + }) + + it('resolves undefined rather than throwing when discovery fails', async () => { + mockKeyDiscovery({ fails: true }) + const { result } = await renderReadyHarness() + + let keys: string[] | undefined + await act(async () => { + keys = await result.current.fetchAttributeKeys(queryFor('edge_logs')) + }) + + // Keys are an enhancement β€” a failed lookup must not block the caller. + expect(keys).toBeUndefined() + }) +}) diff --git a/apps/studio/hooks/analytics/useLogsAttributeKeys.ts b/apps/studio/hooks/analytics/useLogsAttributeKeys.ts new file mode 100644 index 0000000000000..52a20be89856b --- /dev/null +++ b/apps/studio/hooks/analytics/useLogsAttributeKeys.ts @@ -0,0 +1,43 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' + +import { detectLogSource } from '@/data/logs/logs-sql-rewrite' +import { otelLogKeysQueryOptions } from '@/data/logs/otel-log-keys-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +/** + * Looks up the real `log_attributes` keys for whichever source a logs query + * targets. The AI flows pass these along so the model uses exact dotted paths + * instead of inventing them. + * + * Deliberately imperative: discovery aggregates a week of logs, and the source is + * derived from query text the user is editing, so anything reactive fires requests + * for half-typed source names. Fetching at submit time means one request per + * action the user actually took. It still goes through the query client, so a + * result already cached for that source (by an earlier submit, or by a component + * subscribed via `useOtelLogKeysQuery`) is reused rather than refetched. + * + * Keys are an enhancement, never a requirement β€” a failed or impossible lookup + * resolves to `undefined` and the caller proceeds without them. + */ +export function useLogsAttributeKeys() { + const queryClient = useQueryClient() + const { data: project } = useSelectedProjectQuery() + const projectRef = project?.ref + + const fetchAttributeKeys = useCallback( + async (sql: string): Promise => { + const source = detectLogSource(sql) + if (!projectRef || source === undefined) return undefined + + try { + return await queryClient.fetchQuery(otelLogKeysQueryOptions({ projectRef, source })) + } catch { + return undefined + } + }, + [projectRef, queryClient] + ) + + return { fetchAttributeKeys } +} diff --git a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts b/apps/studio/lib/ai/clickhouse-logs.ts similarity index 50% rename from apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts rename to apps/studio/lib/ai/clickhouse-logs.ts index ca532f204a42c..b50e17e05e26c 100644 --- a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts +++ b/apps/studio/lib/ai/clickhouse-logs.ts @@ -1,12 +1,34 @@ -import { BASE_PATH } from '@/lib/constants' - -export const LOGS_SCHEMA_REFERENCE = `The logs table (ClickHouse) has these columns: +/** + * Everything the model needs to know about the ClickHouse-backed Supabase `logs` + * table, in one place. Both ClickHouse completion flows β€” the inline "edit this + * query" path and the whole-query BigQuery rewrite β€” are assembled from these, + * server-side in `pages/api/ai/code/complete.ts`, so there is exactly one + * description of the schema to keep current. + */ + +/** System-prompt half: the dialect rules that hold for every ClickHouse request. */ +export const CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS = ` +# Supabase logs SQL (ClickHouse) +You are writing SQL for Supabase logs, which run on a ClickHouse-backed engine. This is NOT Postgres and NOT BigQuery. Output valid ClickHouse SQL only. +- All logs are in a single table named \`logs\`, keyed by a \`source\` column. There are no per-service tables (no \`edge_logs\`, \`postgres_logs\`, and so on) and no \`unnest\` joins. +- Per-source fields live in the \`log_attributes\` Map(String, String), read as \`log_attributes['key']\`. Map values are strings, so wrap numeric ones in \`toInt32OrZero(...)\`. +- Use ClickHouse functions, not Postgres or BigQuery ones. Use \`match(col, 'regex')\` or \`col ILIKE '%text%'\` instead of \`regexp_contains\`, \`count()\` instead of \`count(*)\`, and select the \`timestamp\` column directly instead of \`cast(timestamp as datetime)\`. +- Do not quote identifiers with double quotes and do not append a trailing semicolon. +- Do not use \`select *\`, this is disallowed by the backend. +` + +/** + * User-message half: the concrete shape of the table. Complements the dialect + * rules above β€” this is the part that changes when a log source or its fields + * change, and it is the ClickHouse counterpart to the Postgres DDL section. + */ +const CLICKHOUSE_LOGS_COLUMN_REFERENCE = `The logs table has these columns: - id (String) - timestamp (DateTime64, UTC) formatted like 2026-06-22T09:34:06.215000 (ISO 8601, microsecond precision, no trailing Z) - event_message (String): the raw log line - severity_text (String): log level when present - source (String): the service the log belongs to. Always filter by it, e.g. where source = 'edge_logs'. -- log_attributes (Map(String, String)): structured per-source fields, read as log_attributes['key']. Values are strings, so wrap numeric ones in toInt32OrZero(...) for comparisons. +- log_attributes (Map(String, String)): structured per-source fields, read as log_attributes['key'] Sources and their common log_attributes keys: - edge_logs: request.method, request.path, request.search, response.status_code, identifier @@ -17,7 +39,7 @@ Sources and their common log_attributes keys: - function_logs: event_type, function_id, execution_id, level - storage_logs, realtime_logs, postgrest_logs, supavisor_logs, pgbouncer_logs: mostly id, timestamp, event_message, with extra fields in log_attributes -Rules: always filter by source; the editor applies the selected time range so a timestamp filter is usually unnecessary; the old BigQuery unnest joins become log_attributes['key'] lookups (drop the metadata root).` +The editor applies the user's selected time range as a request parameter, so an explicit timestamp filter is usually unnecessary.` function renderAvailableKeys(availableKeys?: string[]): string { if (!availableKeys || availableKeys.length === 0) return '' @@ -26,10 +48,25 @@ function renderAvailableKeys(availableKeys?: string[]): string { ${list}\n` } -export function buildClickhouseRewritePrompt(sql: string, availableKeys?: string[]): string { - return `${LOGS_SCHEMA_REFERENCE} -${renderAvailableKeys(availableKeys)} -Convert the BigQuery logs query below to ClickHouse SQL for the logs table. There are no per-service tables and no unnest joins in ClickHouse. Follow these rules exactly: +/** + * The ClickHouse schema section of the user message β€” the counterpart to + * `buildDatabaseSchemaSection` for Postgres. `availableKeys` are the real + * `log_attributes` keys observed for the query's source, when the caller + * discovered them. + */ +export function buildClickhouseLogsSchemaSection(availableKeys?: string[]): string { + return `${CLICKHOUSE_LOGS_COLUMN_REFERENCE}\n${renderAvailableKeys(availableKeys)}` +} + +/** + * The instruction for the whole-query BigQuery β†’ ClickHouse rewrite. Used in + * place of a user instruction when the request's intent is `rewrite`. + * + * It states that a rewrite is REQUIRED: the system prompt covers writing and + * editing ClickHouse SQL generally, and without an explicit demand here the model + * echoes the input back, which surfaces to the user as an empty diff. + */ +export const CLICKHOUSE_LOGS_REWRITE_INSTRUCTION = `Your task is to REWRITE the selected query. It is BigQuery SQL and will not run on ClickHouse, so returning it unchanged is likely wrong β€” every rule below that applies must be applied. 1. Replace the FROM table with the single logs table and filter by source. The old table name is the source value: "from postgres_logs as t" becomes "from logs where source = 'postgres_logs'". This is required, never select from a table like postgres_logs or edge_logs. 2. Remove every join that unnests metadata or its structs. This includes "cross join unnest(...)" and "left join unnest(...) on true". @@ -56,87 +93,4 @@ where source = 'postgres_logs' and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') group by log_attributes['parsed.error_severity'] order by count desc -limit 100 - -Reply with ONLY the rewritten SQL query: no explanation, no comments, and no markdown code fences. - -${sql}` -} - -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 = { - pg_cron_logs: 'postgres_logs', -} - -export function detectLogSource(sql: string): string | undefined { - const bySource = sql.match(/source\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 -} - -export interface RewriteLogsSqlArgs { - sql: string - projectRef: string - connectionString?: string | null - orgSlug?: string - authorizationHeader?: string | null - availableKeys?: string[] -} - -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', - orgSlug, - completionMetadata: { - textBeforeCursor: '', - textAfterCursor: '', - language: 'pgsql', - prompt: buildClickhouseRewritePrompt(sql, availableKeys), - selection: sql, - }, - }), - }) - - 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 -} +limit 100` diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index 3a74b8b6dccb5..81c6f0435f605 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -786,15 +786,6 @@ export const SQL_COMPLETION_INSTRUCTIONS = ` Do not quote identifiers unless they actually require it (uppercase letters, reserved words, or special characters). Plain lowercase identifiers should not be quoted. ` -export const CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS = ` -# Supabase logs SQL (ClickHouse) -You are writing SQL for Supabase logs, which run on a ClickHouse-backed engine. This is NOT Postgres and NOT BigQuery. Output valid ClickHouse SQL only. -- All logs are in a single table named \`logs\`, keyed by a \`source\` column. There are no per-service tables (no \`edge_logs\`, \`postgres_logs\`, and so on) and no \`unnest\` joins. -- Per-source fields live in the \`log_attributes\` Map(String, String), read as \`log_attributes['key']\`. Map values are strings, so wrap numeric ones in \`toInt32OrZero(...)\`. -- Use ClickHouse functions, not Postgres or BigQuery ones. Use \`match(col, 'regex')\` or \`col ILIKE '%text%'\` instead of \`regexp_contains\`, \`count()\` instead of \`count(*)\`, and select the \`timestamp\` column directly instead of \`cast(timestamp as datetime)\`. -- Do not quote identifiers with double quotes and do not append a trailing semicolon. -` - export const LIMITATIONS_PROMPT = ` # Limitations - You are to only answer Supabase, database, or edge function related questions. All other questions should be declined with a polite message. diff --git a/apps/studio/lib/get-error-message.test.ts b/apps/studio/lib/get-error-message.test.ts index 57f12e24cbddb..93cabc4b3abce 100644 --- a/apps/studio/lib/get-error-message.test.ts +++ b/apps/studio/lib/get-error-message.test.ts @@ -3,44 +3,48 @@ import { describe, expect, it } from 'vitest' import { getErrorMessage } from './get-error-message' describe('getErrorMessage', () => { - it('returns null for null', () => { - expect(getErrorMessage(null)).toBe(null) - }) - - it('returns null for undefined', () => { - expect(getErrorMessage(undefined)).toBe(null) + it('returns the message for Error instances', () => { + expect(getErrorMessage(new Error('Failed to load'))).toBe('Failed to load') + expect(getErrorMessage(new TypeError('Invalid type'))).toBe('Invalid type') }) - it('returns the string for string errors', () => { + it('returns a string throw, trimmed', () => { expect(getErrorMessage('Something went wrong')).toBe('Something went wrong') - expect(getErrorMessage('')).toBe('') + expect(getErrorMessage(' boom ')).toBe('boom') }) - it('returns the message for Error instances', () => { - expect(getErrorMessage(new Error('Failed to load'))).toBe('Failed to load') - expect(getErrorMessage(new TypeError('Invalid type'))).toBe('Invalid type') + it('reads a string message off a plain object', () => { + expect(getErrorMessage({ message: 'Custom error' })).toBe('Custom error') }) - it('returns the message property for objects with message', () => { - expect(getErrorMessage({ message: 'Custom error' })).toBe('Custom error') - expect(getErrorMessage({ message: 123 })).toBe('123') - expect(getErrorMessage({ message: null })).toBe('null') + it('returns null when there is no usable message', () => { + expect(getErrorMessage(null)).toBe(null) + expect(getErrorMessage(undefined)).toBe(null) + expect(getErrorMessage('')).toBe(null) + expect(getErrorMessage(' ')).toBe(null) + expect(getErrorMessage(123)).toBe(null) + expect(getErrorMessage(true)).toBe(null) + expect(getErrorMessage([])).toBe(null) }) - it('converts other types to string', () => { - expect(getErrorMessage(123)).toBe('123') - expect(getErrorMessage(true)).toBe('true') - expect(getErrorMessage(false)).toBe('false') - expect(getErrorMessage({})).toBe('[object Object]') - expect(getErrorMessage([])).toBe('') + it('never surfaces a stringified object as the message', () => { + expect(getErrorMessage({})).toBe(null) + expect(getErrorMessage({ code: 500 })).toBe(null) + expect(getErrorMessage({ error: 'test' })).toBe(null) + expect(getErrorMessage({ message: 123 })).toBe(null) + expect(getErrorMessage({ message: null })).toBe(null) + expect(getErrorMessage({ message: { nested: 'error' } })).toBe(null) }) - it('handles objects without message property', () => { - expect(getErrorMessage({ code: 500 })).toBe('[object Object]') - expect(getErrorMessage({ error: 'test' })).toBe('[object Object]') + it('returns the fallback instead of null when one is given', () => { + expect(getErrorMessage(null, 'fallback')).toBe('fallback') + expect(getErrorMessage({}, 'fallback')).toBe('fallback') + expect(getErrorMessage({ message: ' ' }, 'fallback')).toBe('fallback') + expect(getErrorMessage(123, 'fallback')).toBe('fallback') }) - it('handles nested error objects', () => { - expect(getErrorMessage({ message: { nested: 'error' } })).toBe('[object Object]') + it('prefers a real message over the fallback', () => { + expect(getErrorMessage(new Error('Rewrite failed'), 'fallback')).toBe('Rewrite failed') + expect(getErrorMessage({ message: 'Bad request' }, 'fallback')).toBe('Bad request') }) }) diff --git a/apps/studio/lib/get-error-message.ts b/apps/studio/lib/get-error-message.ts index 87e7b4ace56b8..e88851c999232 100644 --- a/apps/studio/lib/get-error-message.ts +++ b/apps/studio/lib/get-error-message.ts @@ -1,12 +1,22 @@ /** - * Extracts a human-readable error message from various error types. + * Extracts a human-readable message from an unknown thrown value, optionally + * falling back when there isn't one. */ -export function getErrorMessage(error: unknown): string | null { - if (error === null || error === undefined) return null - if (typeof error === 'string') return error - if (error instanceof Error) return error.message +export function getErrorMessage(error: unknown): string | null +export function getErrorMessage(error: unknown, fallback: string): string +export function getErrorMessage(error: unknown, fallback?: string): string | null { + if (typeof error === 'string') { + const trimmed = error.trim() + if (trimmed.length > 0) return trimmed + } + if (typeof error === 'object' && error !== null && 'message' in error) { - return String(error.message) + const { message } = error + if (typeof message === 'string') { + const trimmed = message.trim() + if (trimmed.length > 0) return trimmed + } } - return String(error) + + return fallback ?? null } diff --git a/apps/studio/pages/api/ai/code/complete.ts b/apps/studio/pages/api/ai/code/complete.ts index 326400a381ec2..84b9eff76b655 100644 --- a/apps/studio/pages/api/ai/code/complete.ts +++ b/apps/studio/pages/api/ai/code/complete.ts @@ -8,10 +8,14 @@ import z from 'zod' import { executeSql } from '@/data/sql/execute-sql-mutation' import { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { getOrgAIDetails } from '@/lib/ai/ai-details' +import { + buildClickhouseLogsSchemaSection, + CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS, + CLICKHOUSE_LOGS_REWRITE_INSTRUCTION, +} from '@/lib/ai/clickhouse-logs' import { getModel } from '@/lib/ai/model' import { DEFAULT_COMPLETION_MODEL, LOGS_REWRITE_MODEL } from '@/lib/ai/model.utils' import { - CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS, COMPLETION_PROMPT, EDGE_FUNCTION_PROMPT, PG_BEST_PRACTICES, @@ -124,12 +128,24 @@ const requestBodySchema = z.object({ textAfterCursor: z.string(), prompt: z.string(), selection: z.string(), + /** + * The real `log_attributes` keys observed for the query's source, when the + * client discovered them. ClickHouse-only β€” there is no schema to fetch + * server-side for the logs table the way there is for Postgres DDL. + */ + availableKeys: z.array(z.string()).optional(), }), projectRef: z.string(), connectionString: z.string().nullish(), orgSlug: z.string().optional(), language: z.string().optional(), dialect: z.enum(['postgres', 'clickhouse']).optional(), + /** + * What the caller wants done. `rewrite` swaps the user instruction for the + * canonical BigQuery β†’ ClickHouse rewrite instruction, so the client never has + * to carry prompt text. ClickHouse-only; defaults to `edit`. + */ + intent: z.enum(['edit', 'rewrite']).optional(), }) async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -150,8 +166,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues }) } - const { completionMetadata, projectRef, connectionString, orgSlug, language, dialect } = data - const { textBeforeCursor, textAfterCursor, prompt, selection } = completionMetadata + const { completionMetadata, projectRef, connectionString, orgSlug, language, dialect, intent } = + data + const { textBeforeCursor, textAfterCursor, prompt, selection, availableKeys } = + completionMetadata const isClickhouse = dialect === 'clickhouse' const authorization = req.headers.authorization @@ -232,8 +250,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const system = isClickhouse ? source` - You rewrite SQL queries to ClickHouse SQL for the Supabase logs table. - Output only the rewritten SQL query: no explanation, no markdown, and no code fences. + You write and edit ClickHouse SQL for the Supabase logs table. + Reply with ONLY the SQL that replaces the block below, keeping the + surrounding query valid: no explanation, no comments, and no markdown code fences. ${CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS} ${SECURITY_PROMPT} ` @@ -243,23 +262,31 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ${SECURITY_PROMPT} ` - const userMessage = isClickhouse - ? prompt - : source` - ## Database Schema + const schemaSection = isClickhouse + ? { heading: 'Logs Schema', body: buildClickhouseLogsSchemaSection(availableKeys) } + : { + heading: 'Database Schema', + body: buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult }), + } - ${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })} + const instruction = + isClickhouse && intent === 'rewrite' ? CLICKHOUSE_LOGS_REWRITE_INSTRUCTION : prompt - ## Code + const userMessage = source` + ## ${schemaSection.heading} - \`\`\`${language ?? ''} - ${textBeforeCursor}${selection}${textAfterCursor} - \`\`\` + ${schemaSection.body} - ## Instruction + ## Code - ${prompt} - ` + \`\`\`${language ?? ''} + ${textBeforeCursor}${selection}${textAfterCursor} + \`\`\` + + ## Instruction + + ${instruction} + ` // Note: these must be of type `CoreMessage` to prevent AI SDK from stripping `providerOptions` // https://github.com/vercel/ai/blob/81ef2511311e8af34d75e37fc8204a82e775e8c3/packages/ai/core/prompt/standardize-prompt.ts#L83-L88 diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index 4d736497fc4a6..65153b9cb3c51 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -1,5 +1,5 @@ import { useMonaco } from '@monaco-editor/react' -import { useLocalStorage } from '@uidotdev/usehooks' +import { useDebounce, useLocalStorage } from '@uidotdev/usehooks' import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import dayjs from 'dayjs' import type { editor } from 'monaco-editor' @@ -8,11 +8,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' -import { - detectLogSource, - looksLikeLegacyLogsQuery, - rewriteLogsSqlWithAI, -} from '@/components/interfaces/Settings/Logs/logs-sql-rewrite' +import { LegacyLogsRewriteAdmonition } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition' import { EXPLORER_DATEPICKER_HELPERS, getDefaultHelper, @@ -36,7 +32,6 @@ import { buildLogQueryParams, resolveLogDateRange, } from '@/components/interfaces/Settings/Logs/logsDateRange' -import { LogsExplorerOtelBanner } from '@/components/interfaces/Settings/Logs/LogsExplorerOtelBanner' import { LogsQueryPanel } from '@/components/interfaces/Settings/Logs/LogsQueryPanel' import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' @@ -52,16 +47,20 @@ import { UpsertContentPayload, useContentUpsertMutation, } from '@/data/content/content-upsert-mutation' -import { constructHeaders } from '@/data/fetchers' -import { fetchOtelLogKeys } from '@/data/logs/otel-log-keys-query' +import { + LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS, + shouldOfferLegacyLogsRewrite, +} from '@/data/logs/logs-sql-rewrite' import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { + useLegacyLogsRewrite, + type LegacyLogsRewriteProposal, +} from '@/hooks/analytics/useLegacyLogsRewrite' import { useLogsQuery } from '@/hooks/analytics/useLogsQuery' import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState' import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt' import { uuidv4 } from '@/lib/helpers' import { useProfile } from '@/lib/profile' @@ -101,9 +100,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const useOtelEndpoint = useFlag('otelLegacyLogs') const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template']) - const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const allTemplates = useMemo(() => { const templates = getLogsTemplates(useOtelEndpoint) if (logsShowMetadataIpTemplate) return templates @@ -142,11 +138,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const [warnings, setWarnings] = useState([]) const [showMissingLimitError, setShowMissingLimitError] = useState(false) const [selectedLog, setSelectedLog] = useState(null) - const [rewriteProposal, setRewriteProposal] = useState<{ - original: string - modified: string - } | null>(null) - const [isRewriting, setIsRewriting] = useState(false) + const [rewriteProposal, setRewriteProposal] = useState(null) const [rewriteBannerDismissed, setRewriteBannerDismissed] = useLocalStorage( `project-${projectRef}-logs-rewrite-banner-dismissed`, false @@ -189,7 +181,32 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const results = logData const isLoading = logsLoading - const showRewriteCTA = useOtelEndpoint && looksLikeLegacyLogsQuery(editorValue) + // Debounced so the dialect heuristics don't run on every keystroke, matching the + // SQL editor's rewrite banner. + const settledEditorValue = useDebounce(editorValue, LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS) + const shouldShowRewriteCTA = useMemo( + () => + shouldOfferLegacyLogsRewrite({ + sql: settledEditorValue, + isClickhouseLogsEnabled: useOtelEndpoint, + }), + [settledEditorValue, useOtelEndpoint] + ) + + const { + state: rewriteState, + requestRewrite, + dismiss: dismissRewriteBanner, + } = useLegacyLogsRewrite({ + // Read straight from the editor instance β€” `editorValue` state can lag the + // most recent keystroke. + readSql: () => editorRef.current?.getValue() ?? editorValue, + onProposal: setRewriteProposal, + onDismissed: () => setRewriteBannerDismissed(true), + }) + const isRewriting = rewriteState.status === 'rewriting' + const hasUnacknowledgedRewriteOutcome = + rewriteState.status === 'failed' || rewriteState.status === 'noRewriteNeeded' const { mutateAsync: upsertContent, isPending: isUpsertingContent } = useContentUpsertMutation({ onError: (e) => { @@ -243,42 +260,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => { addRecentLogSqlSnippet({ unchecked_sql: untrustedLogSql(template.searchString) }) } - const handleRewrite = async () => { - const currentSql = editorRef.current?.getValue() ?? editorValue - if (!currentSql.trim()) { - toast.info('Write a query to rewrite first') - return - } - setIsRewriting(true) - try { - const headerData = await constructHeaders() - const source = detectLogSource(currentSql) - const availableKeys = source - ? await fetchOtelLogKeys({ projectRef: projectRef!, source }).catch(() => undefined) - : undefined - const rewritten = await rewriteLogsSqlWithAI({ - sql: currentSql, - projectRef: projectRef!, - connectionString: project?.connectionString, - orgSlug: organization?.slug, - authorizationHeader: headerData.get('Authorization'), - availableKeys, - }) - // The editor may have changed while awaiting key discovery and the AI call; - // don't offer a proposal that would clobber intervening edits. - const latestSql = editorRef.current?.getValue() ?? editorValue - if (latestSql !== currentSql) { - toast.info('The query changed while rewriting. Please try again.') - return - } - setRewriteProposal({ original: currentSql, modified: rewritten }) - } catch (error) { - toast.error(`Couldn't rewrite the query: ${(error as Error).message}`) - } finally { - setIsRewriting(false) - } - } - const acceptRewrite = () => { if (!rewriteProposal) return editorRef.current?.setValue(rewriteProposal.modified) @@ -504,15 +485,16 @@ export const LogsExplorerPage: NextPageWithLayout = () => { templates={allTemplates.filter((template) => template.mode === 'custom')} onSelectTemplate={onSelectTemplate} warnings={warnings} - showRewriteAction={showRewriteCTA && rewriteBannerDismissed} + showRewriteAction={shouldShowRewriteCTA && rewriteBannerDismissed} isRewriting={isRewriting} - onRewrite={handleRewrite} + onRewrite={requestRewrite} /> - {showRewriteCTA && !rewriteBannerDismissed && ( - setRewriteBannerDismissed(true)} + {(hasUnacknowledgedRewriteOutcome || + (shouldShowRewriteCTA && !rewriteBannerDismissed)) && ( + )} From 21511042a3d1b2c3fb825c21dd5ef1a50bae4e09 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:02:40 -0400 Subject: [PATCH 08/13] feat(studio): assistant logs context and reports guard (#48514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ## 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. --- .../ReportBlock/LogsSnippetReportBlock.tsx | 37 ++++++++ .../Reports/ReportBlock/ReportBlock.tsx | 27 +++++- .../SQLEditor/SQLEditor.utils.test.ts | 31 ++++++- .../interfaces/SQLEditor/SQLEditor.utils.ts | 45 +++++++--- .../interfaces/SQLEditor/querySource.test.ts | 42 +++++++++ .../interfaces/SQLEditor/querySource.ts | 27 +++++- .../interfaces/SQLEditor/useRunSource.ts | 10 +-- .../SQLEditor/useSqlEditorAi.test.tsx | 6 +- .../interfaces/SQLEditor/useSqlEditorAi.ts | 8 +- .../ui/AIAssistantPanel/AIAssistant.tsx | 36 ++++++-- .../ui/AIAssistantPanel/AIAssistant.types.ts | 2 - .../AIAssistant.utils.test.ts | 71 +++++++++++++++ .../ui/AIAssistantPanel/AIAssistant.utils.ts | 43 +++++++++ .../ui/AIAssistantPanel/AIOnboarding.tsx | 2 +- .../ui/AIAssistantPanel/AssistantChatForm.tsx | 10 +-- .../ui/AIAssistantPanel/SnippetRow.tsx | 17 +--- apps/studio/lib/ai/assistant-context.test.ts | 66 ++++++++++++++ apps/studio/lib/ai/assistant-context.ts | 79 ++++++++++++++++ .../lib/ai/assistant-message-metadata.test.ts | 90 +++++++++++++++++++ .../lib/ai/assistant-message-metadata.ts | 30 +++++++ .../lib/ai/generate-assistant-response.ts | 39 +++----- apps/studio/pages/api/ai/sql/generate-v4.ts | 11 ++- apps/studio/state/ai-assistant-state.tsx | 8 +- 23 files changed, 651 insertions(+), 86 deletions(-) create mode 100644 apps/studio/components/interfaces/Reports/ReportBlock/LogsSnippetReportBlock.tsx create mode 100644 apps/studio/lib/ai/assistant-context.test.ts create mode 100644 apps/studio/lib/ai/assistant-context.ts create mode 100644 apps/studio/lib/ai/assistant-message-metadata.test.ts create mode 100644 apps/studio/lib/ai/assistant-message-metadata.ts diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/LogsSnippetReportBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/LogsSnippetReportBlock.tsx new file mode 100644 index 0000000000000..c6114a476139d --- /dev/null +++ b/apps/studio/components/interfaces/Reports/ReportBlock/LogsSnippetReportBlock.tsx @@ -0,0 +1,37 @@ +import { ScrollText } from 'lucide-react' +import { ReactNode } from 'react' + +import { ReportBlockContainer } from './ReportBlockContainer' + +interface LogsSnippetReportBlockProps { + label: string + actions?: ReactNode +} + +/** + * Stands in for a snippet that queries the logs backend. Reports only run SQL + * against the user's database, so a `log_sql` snippet reference renders this + * instead of executing β€” see ReportBlock, which never issues a query for one. + */ +export const LogsSnippetReportBlock = ({ label, actions }: LogsSnippetReportBlockProps) => { + return ( + } + label={label} + actions={actions} + > +
+

+ Logs snippets aren't supported in reports yet +

+

+ Reports can't run queries against the logs backend yet. Open this snippet in the SQL + editor to view results, or remove it from this report. +

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx index ba68a57a12723..2fea6cc8265ac 100644 --- a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx +++ b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx @@ -9,6 +9,7 @@ import { applyAutoLimit } from '../../SQLEditor/SQLEditor.utils' import { BURSTABLE_IO_METRIC_KEYS, DEPRECATED_REPORTS } from '../Reports.constants' import { ChartBlock } from './ChartBlock' import { DeprecatedChartBlock } from './DeprecatedChartBlock' +import { LogsSnippetReportBlock } from './LogsSnippetReportBlock' import { UnavailableChartBlock } from './UnavailableChartBlock' import { hasBurstableIO } from '@/components/interfaces/DiskManagement/DiskManagement.utils' import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' @@ -79,8 +80,11 @@ export const ReportBlock = ({ } ) + const isLogsSnippet = data?.type === 'log_sql' + const autoLimit = 100 - const sql = isSnippet ? (data?.content as SqlSnippets.Content)?.unchecked_sql : undefined + const sql = + isSnippet && !isLogsSnippet ? (data?.content as SqlSnippets.Content)?.unchecked_sql : undefined // acceptUntrustedSql is usually not allowed outside a user-action event // handler, but it's explicitly fine here: adding this block to a report is // itself the user action that approves running its SQL. @@ -125,7 +129,7 @@ export const ReportBlock = ({ sql: formattedSql, }) }, - enabled: !isLoadingContent && contentError == null, + enabled: !isLoadingContent && contentError == null && !isLogsSnippet, refetchOnWindowFocus: false, }) @@ -151,6 +155,25 @@ export const ReportBlock = ({ } }, [isRefreshing, refetch]) + if (isLogsSnippet) { + return ( + } + className="w-7 h-7" + onClick={() => onRemoveChart({ metric: { key: item.attribute } })} + tooltip={{ content: { side: 'bottom', text: 'Remove chart' } }} + /> + ) : null + } + /> + ) + } + return ( <> {isSnippet ? ( diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index aeefbdcfe32f7..d981a2da927dd 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -393,13 +393,26 @@ describe('SQLEditor.utils.ts:buildDebugChatArgs', () => { test('builds the newChat payload from the snippet sql and error message', () => { const snippet = buildDebugSnippet('select 1;') const result = { error: { message: 'relation does not exist' } } - expect(buildDebugChatArgs(snippet, result)).toEqual({ + expect(buildDebugChatArgs(snippet, result, 'database')).toEqual({ name: 'Debug SQL snippet', - sqlSnippets: ['select 1;'], + sqlSnippets: [{ label: 'Current Query', content: 'select 1;', source: 'database' }], initialInput: 'Help me to debug the attached sql snippet which gives the following error: \n\nrelation does not exist', }) }) + + // The attachment is what puts sqlSource on the message the user then submits, so + // the debug flow has to attach a sourced snippet, not a bare string. + test('attaches the query with its source and names the dialect', () => { + const snippet = buildDebugSnippet('select count(*) from logs;') + const result = { error: { message: 'Unknown expression identifier' } } + expect(buildDebugChatArgs(snippet, result, 'logs').sqlSnippets).toEqual([ + { label: 'Current Query', content: 'select count(*) from logs;', source: 'logs' }, + ]) + expect(buildDebugChatArgs(snippet, result, 'logs').initialInput).toEqual( + 'Help me to debug the attached sql snippet which gives the following error: \n\nUnknown expression identifier\n\nThis query runs against the Supabase logs table on a ClickHouse-backed engine, not Postgres.' + ) + }) }) describe('SQLEditor.utils.ts:buildCompletionRequestBody', () => { @@ -1464,9 +1477,21 @@ describe('SQLEditor.utils:assembleCompletionDiff', () => { describe('SQLEditor.utils:buildDebugPromptText', () => { it('builds the debug prompt with the error message and SQL block', () => { - const result = buildDebugPromptText('select 1;', 'relation does not exist') + const result = buildDebugPromptText('select 1;', 'relation does not exist', 'database') expect(result).toContain('relation does not exist') expect(result).toContain('```sql\nselect 1;\n```') + expect(result).not.toContain('ClickHouse') + }) + + // This text is copyable and gets pasted into external models, so it has to name + // the dialect itself rather than relying on the message metadata. + it('names the dialect for a logs snippet', () => { + const sql = "select count() from logs where source = 'edge_logs'" + const result = buildDebugPromptText(sql, 'Unknown expression identifier', 'logs') + expect(result).toContain('Unknown expression identifier') + expect(result).toContain('ClickHouse') + expect(result).toContain('not Postgres') + expect(result).toContain('```clickhouse\n' + sql + '\n```') }) }) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index 33ec043398d7e..188c8c0fdcef3 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -7,7 +7,7 @@ import { } from '@supabase/pg-meta' import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants' -import type { SqlSnippetSource } from './querySource' +import { isLogsSource, sqlSourceToFenceLanguage, type SqlSnippetSource } from './querySource' import { alterDatabasePreventConnectionStatements, destructiveSqlRegex, @@ -509,7 +509,7 @@ export type SqlDialect = 'postgres' | 'clickhouse' * else runs against the user's Postgres database. */ export function sqlSourceToDialect(source: SqlSnippetSource): SqlDialect { - return source === 'logs' ? 'clickhouse' : 'postgres' + return isLogsSource(source) ? 'clickhouse' : 'postgres' } /** @@ -550,11 +550,31 @@ export function buildCompletionRequestBody({ } /** - * Builds the prompt text used to ask the assistant to debug a failing snippet. + * Names the dialect for a logs snippet, whose SQL is ClickHouse against the `logs` + * table. The in-app assistant also learns this from the message's `sqlSource` + * metadata, but the same text is offered as "Copy prompt" and pasted into external + * models, so it has to stand on its own β€” otherwise a logs error gets Postgres advice. */ -export function buildDebugPromptText(sql: string, errorMessage: string): string { - const prompt = `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}` - return `${prompt}\n\nSQL Query:\n\`\`\`sql\n${sql}\n\`\`\`` +const CLICKHOUSE_LOGS_DEBUG_HINT = + 'This query runs against the Supabase logs table on a ClickHouse-backed engine, not Postgres.' + +/** The shared ask + error + dialect preamble behind both debug entry points. */ +function buildDebugRequestText(errorMessage: string, source: SqlSnippetSource): string { + const ask = `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}` + return isLogsSource(source) ? `${ask}\n\n${CLICKHOUSE_LOGS_DEBUG_HINT}` : ask +} + +/** + * Builds the prompt text used to ask the assistant to debug a failing snippet, and + * offered verbatim as the dropdown's copyable prompt. + */ +export function buildDebugPromptText( + sql: string, + errorMessage: string, + source: SqlSnippetSource +): string { + const fence = sqlSourceToFenceLanguage(source) + return `${buildDebugRequestText(errorMessage, source)}\n\nSQL Query:\n\`\`\`${fence}\n${sql}\n\`\`\`` } // Accepts either brand: the debug flow only reads the SQL as text (it's stripped @@ -586,13 +606,18 @@ export function extractDebugContext( */ export function buildDebugChatArgs( snippet: DebugSnippet, - result: DebugResult -): { name: string; sqlSnippets: string[]; initialInput: string } { + result: DebugResult, + source: SqlSnippetSource +): { + name: string + sqlSnippets: Array<{ label: string; content: string; source: SqlSnippetSource }> + initialInput: string +} { const { sql, errorMessage } = extractDebugContext(snippet, result) return { name: 'Debug SQL snippet', - sqlSnippets: [sql], - initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}`, + sqlSnippets: [{ label: 'Current Query', content: sql, source }], + initialInput: buildDebugRequestText(errorMessage, source), } } diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts index 5b79d3b77683a..d0e4659d0dac7 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts @@ -5,10 +5,13 @@ import { datePickerValueToLogDateRange, DEFAULT_LOG_DATE_RANGE, getSnippetSource, + isLogsSource, isoDateTimeString, logDateRangesEqual, logDateRangeToDatePickerValue, resolveLogRunRange, + resolveSnippetSource, + sqlSourceToFenceLanguage, type LogDateRange, } from './querySource' import { @@ -50,6 +53,45 @@ describe('querySource.ts:getSnippetSource', () => { }) }) +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' diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.ts b/apps/studio/components/interfaces/SQLEditor/querySource.ts index 1ba1c4fd1ea75..32e84bd78a30e 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs' -import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' import type { Unit } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange' import type { Snippet } from '@/data/content/sql-folders-query' @@ -25,6 +25,19 @@ export function getSnippetSource(snippet: Pick): SqlSnippetSour return snippet.type === 'log_sql' ? 'logs' : 'database' } +export function isLogsSource(source: SqlSnippetSource | undefined): boolean { + return source === 'logs' +} + +/** + * The markdown fence language a source's SQL is written into a prompt with, so the model + * can tell a ClickHouse logs query from Postgres SQL. */ +export function sqlSourceToFenceLanguage( + source: SqlSnippetSource | undefined +): 'sql' | 'clickhouse' { + return isLogsSource(source) ? 'clickhouse' : 'sql' +} + /** * Parse a raw `source` value (e.g. the `?source=` query param a creation entry * threads through `/sql/new`) into a `SqlSnippetSource`. Only the explicit @@ -35,6 +48,18 @@ export function parseSqlSnippetSource(raw: string | undefined): SqlSnippetSource return raw === 'logs' ? 'logs' : 'database' } +/** + * Resolve where an open snippet's query runs, falling back to the `?source=` URL param + * when the snippet isn't in the store yet β€” a fresh `/sql/new` tab is materialized + * lazily on the first keystroke, and until then the param is the only signal. + */ +export function resolveSnippetSource( + snippet: Pick | undefined, + sourceParam: string | undefined +): SqlSnippetSource { + return snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam) +} + /** * An ISO-8601 datetime proven valid at construction via a dayjs parse. Absolute * log ranges carry these instead of raw strings so an unvalidated datetime can diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts index aa7cbf05b6f0d..95c83d782a597 100644 --- a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts @@ -3,10 +3,9 @@ import { useMemo } from 'react' import { DEFAULT_LOG_DATE_RANGE, - getSnippetSource, - parseSqlSnippetSource, + isLogsSource, + resolveSnippetSource, type QuerySource, - type SqlSnippetSource, } from './querySource' import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' @@ -29,12 +28,11 @@ export function useRunSource(id: string): QuerySource { const sessionSnap = useSqlEditorSessionSnapshot() const snippet = snapV2.snippets[id]?.snippet - const source: SqlSnippetSource = - snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam) + const source = resolveSnippetSource(snippet, sourceParam) const logRange = sessionSnap.logRange[id] return useMemo(() => { - if (source === 'logs') { + if (isLogsSource(source)) { return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_DATE_RANGE } } return { type: 'database' } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx index 60f2a399f3cff..b62fb4f45ec91 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx @@ -245,7 +245,11 @@ describe('useSqlEditorAi β€” debug', () => { const activeChat = aiAssistantState.chats[aiAssistantState.activeChatId ?? ''] expect(activeChat?.name).toBe('Debug SQL snippet') - expect(aiAssistantState.sqlSnippets).toEqual(['selct 1;']) + // Attached with its source, which is what carries sqlSource onto the message the + // user submits from the prefilled composer. + expect(aiAssistantState.sqlSnippets).toEqual([ + { label: 'Current Query', content: 'selct 1;', source: 'database' }, + ]) expect(aiAssistantState.initialInput).toContain('syntax error at or near "selct"') }) diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts index 240d346240447..36359ffe49afe 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts @@ -128,15 +128,15 @@ export function useSqlEditorAi({ const result = sessionSnap.results[id]?.[0] const { sql, errorMessage } = extractDebugContext(snippet, result) - return buildDebugPromptText(sql, errorMessage) - }, [id, sessionSnap.results, snapV2.snippets]) + return buildDebugPromptText(sql, errorMessage, sqlSource) + }, [id, sessionSnap.results, snapV2.snippets, sqlSource]) const onDebug = useCallback(async () => { try { const snippet = snapV2.snippets[id] const result = sessionSnap.results[id]?.[0] openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) - aiSnap.newChat(buildDebugChatArgs(snippet, result)) + aiSnap.newChat(buildDebugChatArgs(snippet, result, sqlSource)) } catch (error: unknown) { // [Joshen] There's a tendency for the SQL debug to chuck a lengthy error message // that's not relevant for the user - so we prettify it here by avoiding to return the @@ -147,7 +147,7 @@ export function useSqlEditorAi({ ) } } - }, [id, sessionSnap.results, snapV2.snippets, aiSnap, openSidebar]) + }, [id, sessionSnap.results, snapV2.snippets, aiSnap, openSidebar, sqlSource]) const acceptAiHandler = useCallback(async () => { try { diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index ca13118c5bcf1..02c2844a13950 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -15,8 +15,8 @@ import { ButtonTooltip } from '../ButtonTooltip' import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary' import { InlineLinkClassName } from '../InlineLink' import { ASSISTANT_ERRORS } from './AiAssistant.constants' -import type { SqlSnippet } from './AIAssistant.types' import { + containsLogsSnippets, hasPendingToolApproval, onErrorChat, resolvePendingToolApprovalsAsDenied, @@ -31,6 +31,7 @@ import { } from './elements/Conversation' import { Message } from './Message' import { Markdown } from '@/components/interfaces/Markdown' +import { resolveSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useCheckOpenAIKeyQuery } from '@/data/ai/check-api-key-query' import { useRateMessageMutation } from '@/data/ai/rate-message-mutation' @@ -40,6 +41,7 @@ import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import type { AssistantMessageMetadata } from '@/lib/ai/assistant-message-metadata' import { getParallelApprovalIdsToReject } from '@/lib/ai/message-utils' import { DEFAULT_ASSISTANT_BASE_MODEL_ID, @@ -50,7 +52,7 @@ import { import { IS_PLATFORM } from '@/lib/constants' import { uuidv4 } from '@/lib/helpers' import { useTrack } from '@/lib/telemetry/track' -import type { AssistantModel } from '@/state/ai-assistant-state' +import type { AssistantModel, SqlSnippet } from '@/state/ai-assistant-state' import { useAiAssistantState, useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' @@ -64,7 +66,7 @@ interface AIAssistantProps { export const AIAssistant = ({ className }: AIAssistantProps) => { const router = useRouter() - const { id: entityId } = useParams() + const { id: entityId, source: sourceParam } = useParams() const { data: project } = useSelectedProjectQuery() const searchParams = useSearchParamsShallow() @@ -108,6 +110,10 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const inputRef = useRef(null) const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel() + // Whether attached queries are sent at all. One definition, shared by the chat form + // (which folds them into the message text) and the message metadata (which states + // whether any of them was a logs query), so the two can't disagree. + const includeSnippetsInMessage = aiOptInLevel !== 'disabled' const showMetadataWarning = IS_PLATFORM && !!selectedOrganization && @@ -136,6 +142,10 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const snippet = snippets[entityId ?? ''] const snippetContent = snippet?.snippet?.content?.unchecked_sql + const openSnippetSource = isInSQLEditor + ? resolveSnippetSource(snippet?.snippet, sourceParam) + : undefined + const { data: tables } = useTablesQuery( { projectRef: project?.ref, @@ -355,11 +365,23 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { setEditingMessageId(null) } + // Read off the attachments this message actually carries, so detaching the + // "Current Query" chip also drops the claim. Gated on the same condition that + // decides whether attachments make it into the text at all: with AI opt-in + // disabled the chip is shown but no query is sent, and claiming otherwise would + // have the server prepend ClickHouse context for a message holding no query. + // Rides on the message rather than the request, so a Retry reproduces the context + // the message was asked in. + const metadata: AssistantMessageMetadata = { + containsLogsSnippets: includeSnippetsInMessage && containsLogsSnippets(snap.sqlSnippets), + } + const payload = { role: 'user', createdAt: new Date(), parts: [{ type: 'text', text: finalContent }], id: uuidv4(), + metadata, } as MessageType snap.clearSqlSnippets() @@ -421,10 +443,12 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { useEffect(() => { const isOpen = activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT if (isOpen && isInSQLEditor && !!snippetContent) { - snap.setSqlSnippets([{ label: 'Current Query', content: snippetContent }]) + snap.setSqlSnippets([ + { label: 'Current Query', content: snippetContent, source: openSnippetSource }, + ]) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeSidebar?.id, isInSQLEditor, snippetContent]) + }, [activeSidebar?.id, isInSQLEditor, snippetContent, openSnippetSource]) return ( { newSnippets.splice(index, 1) snap.setSqlSnippets(newSnippets) }} - includeSnippetsInMessage={aiOptInLevel !== 'disabled'} + includeSnippetsInMessage={includeSnippetsInMessage} selectedModel={selectedModel} onSelectModel={(model) => snap.setModel(model)} /> diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts index b87d413d76ac2..ea3611c4133bc 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts @@ -10,5 +10,3 @@ export interface AssistantSnippetProps { yAxis?: string name?: string } - -export type SqlSnippet = string | { label: string; content: string } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts index 49648982aaaa6..2f28dda6bdb94 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts @@ -2,6 +2,8 @@ import type { UIMessage } from 'ai' import { describe, expect, test } from 'vitest' import { + containsLogsSnippets, + formatAttachedSnippets, hasPendingToolApproval, isReadOnlySelect, resolvePendingToolApprovalsAsDenied, @@ -157,3 +159,72 @@ describe('AIAssistant.utils.ts:resolvePendingToolApprovalsAsDenied', () => { expect(resolvePendingToolApprovalsAsDenied(messages)).toEqual(messages) }) }) + +describe('containsLogsSnippets', () => { + test('is true when an attached query is a logs query', () => { + expect( + containsLogsSnippets([{ label: 'Current Query', content: 'select 1', source: 'logs' }]) + ).toBe(true) + }) + + test('is false for a database query', () => { + expect( + containsLogsSnippets([{ label: 'Current Query', content: 'select 1', source: 'database' }]) + ).toBe(false) + }) + + test('is false once nothing is attached', () => { + expect(containsLogsSnippets([])).toBe(false) + expect(containsLogsSnippets(undefined)).toBe(false) + }) + + test('ignores plain string attachments, which carry no source', () => { + expect(containsLogsSnippets(['select 1'])).toBe(false) + }) + + test('is true when only some of several attachments are logs queries', () => { + expect( + containsLogsSnippets([ + 'select 1', + { label: 'Current Query', content: 'select 2', source: 'database' }, + { label: 'Other', content: 'select 3', source: 'logs' }, + ]) + ).toBe(true) + }) +}) + +describe('formatAttachedSnippets', () => { + test('fences a database query as sql', () => { + expect( + formatAttachedSnippets([{ label: 'Current Query', content: 'select 1', source: 'database' }]) + ).toBe('```sql\nselect 1\n```') + }) + + // The fence is how the model tells which attachment is ClickHouse. It also keeps a + // logs query out of MessageMarkdown's `sql` branch, which offers to run the block + // against Postgres and brands it with untrustedSql. + test('fences a logs query as clickhouse', () => { + expect( + formatAttachedSnippets([ + { + label: 'Current Query', + content: "select count() from logs where source = 'edge_logs'", + source: 'logs', + }, + ]) + ).toBe("```clickhouse\nselect count() from logs where source = 'edge_logs'\n```") + }) + + test('labels each attachment with its own dialect', () => { + expect( + formatAttachedSnippets([ + { label: 'A', content: 'select 1', source: 'database' }, + { label: 'B', content: 'select 2', source: 'logs' }, + ]) + ).toBe('```sql\nselect 1\n```\n```clickhouse\nselect 2\n```') + }) + + test('falls back to sql for a plain string attachment', () => { + expect(formatAttachedSnippets(['select 1'])).toBe('```sql\nselect 1\n```') + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts index 801f35fb939c2..92d4b1d694eaa 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -2,6 +2,10 @@ 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' @@ -12,6 +16,7 @@ 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' @@ -159,3 +164,41 @@ export const onErrorChat = (error: Error) => { } } } + +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') +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx index 928312db5d3ef..83df5deb1a17f 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx @@ -4,10 +4,10 @@ import { BarChart, FileText, Shield } from 'lucide-react' import { AiIconAnimation, Button, Skeleton } from 'ui' import { codeSnippetPrompts, defaultPrompts } from './AIAssistant.prompts' -import type { SqlSnippet } from './AIAssistant.types' import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants' import { createLintSummaryPrompt } from '@/components/interfaces/Linter/Linter.utils' import { useProjectLintsQuery, type Lint } from '@/data/lint/lint-query' +import type { SqlSnippet } from '@/state/ai-assistant-state' interface AIOnboardingProps { sqlSnippets?: SqlSnippet[] diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx index 41bec723181f8..12913d7bdfa24 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx @@ -5,10 +5,11 @@ import { ExpandingTextArea } from 'ui' import { cn } from 'ui/src/lib/utils' import { ButtonTooltip } from '../ButtonTooltip' -import { type SqlSnippet } from './AIAssistant.types' +import { formatAttachedSnippets } from './AIAssistant.utils' import { ModelSelector } from './ModelSelector' -import { getSnippetContent, SnippetRow } from './SnippetRow' +import { SnippetRow } from './SnippetRow' import type { AssistantModelId } from '@/lib/ai/model.utils' +import { type SqlSnippet } from '@/state/ai-assistant-state' export interface FormProps { /* The ref for the textarea, optional. Exposed for the CommandsPopover to attach events. */ @@ -83,10 +84,7 @@ const AssistantChatFormComponent = forwardRef( let finalMessage = value if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) { - const sqlSnippetsString = sqlSnippets - .map((snippet: SqlSnippet) => '```sql\n' + getSnippetContent(snippet) + '\n```') - .join('\n') - finalMessage = [value, sqlSnippetsString].filter(Boolean).join('\n\n') + finalMessage = [value, formatAttachedSnippets(sqlSnippets)].filter(Boolean).join('\n\n') } onSubmit(finalMessage) diff --git a/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx b/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx index 581b7fa92b6a7..2f68ab7cf021f 100644 --- a/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx @@ -3,21 +3,8 @@ import React from 'react' import { Button, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' -import { type SqlSnippet } from './AIAssistant.types' - -export const getSnippetLabel = (snippet: SqlSnippet, index: number): string => { - if (typeof snippet === 'string') { - return `Snippet ${index + 1}` - } - return snippet.label -} - -export const getSnippetContent = (snippet: SqlSnippet): string => { - if (typeof snippet === 'string') { - return snippet - } - return snippet.content -} +import { getSnippetContent, getSnippetLabel } from './AIAssistant.utils' +import { type SqlSnippet } from '@/state/ai-assistant-state' interface SnippetRowProps { snippets: SqlSnippet[] diff --git a/apps/studio/lib/ai/assistant-context.test.ts b/apps/studio/lib/ai/assistant-context.test.ts new file mode 100644 index 0000000000000..8f641d015771f --- /dev/null +++ b/apps/studio/lib/ai/assistant-context.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { buildAssistantContextMessages, NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context' + +const SCHEMAS = 'The available database schema names are: ["public"]' + +describe('buildAssistantContextMessages', () => { + it('describes the project when there is project context', () => { + const messages = buildAssistantContextMessages({ + projectRef: 'abcdefghijklmnopqrst', + chatName: 'Slow queries', + schemasString: SCHEMAS, + }) + + expect(messages).toHaveLength(1) + expect(messages[0].role).toBe('assistant') + expect(messages[0].content).toContain('abcdefghijklmnopqrst') + expect(messages[0].content).toContain(SCHEMAS) + expect(messages[0].content).toContain('Slow queries') + }) + + it('omits the project message when there is nothing to say', () => { + const messages = buildAssistantContextMessages({ + schemasString: NO_SCHEMA_ACCESS_MESSAGE, + }) + + expect(messages).toEqual([]) + }) + + it('adds the support instructions in support mode', () => { + const messages = buildAssistantContextMessages({ + schemasString: NO_SCHEMA_ACCESS_MESSAGE, + supportMode: true, + }) + + expect(messages).toHaveLength(1) + expect(messages[0].content).toContain('escalate_to_human') + }) + + it('adds ClickHouse instructions when the conversation attached a logs query', () => { + const messages = buildAssistantContextMessages({ + projectRef: 'abcdefghijklmnopqrst', + schemasString: SCHEMAS, + includesLogsSnippets: true, + }) + + expect(messages).toHaveLength(2) + const logsContext = messages[1].content + // The dialect rules, so it doesn't answer in Postgres... + expect(logsContext).toContain('ClickHouse') + // ...and the table reference, so it doesn't invent BigQuery-style unnests. + expect(logsContext).toContain('log_attributes') + expect(logsContext).toContain("where source = 'edge_logs'") + }) + + it('adds nothing extra for a database-only conversation', () => { + const messages = buildAssistantContextMessages({ + projectRef: 'abcdefghijklmnopqrst', + schemasString: SCHEMAS, + includesLogsSnippets: false, + }) + + expect(messages).toHaveLength(1) + expect(messages[0].content).not.toContain('ClickHouse') + }) +}) diff --git a/apps/studio/lib/ai/assistant-context.ts b/apps/studio/lib/ai/assistant-context.ts new file mode 100644 index 0000000000000..1c4032b77a31f --- /dev/null +++ b/apps/studio/lib/ai/assistant-context.ts @@ -0,0 +1,79 @@ +import { + buildClickhouseLogsSchemaSection, + CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS, +} from '@/lib/ai/clickhouse-logs' + +/** + * Stands in for the schema list when the org hasn't opted into sharing schemas. + * Doubles as a sentinel β€” "no project context worth sending" is decided by + * comparing against this exact sentence β€” so the producer and the comparison have + * to agree on it, hence one exported constant instead of copies per call site. + */ +export const NO_SCHEMA_ACCESS_MESSAGE = "You don't have access to any schemas." + +/** + * A request-scoped context message, sent as an assistant turn ahead of the + * conversation. Deliberately NOT part of the system prompt: the system prompt is + * static so Bedrock can cache it, and anything derived from the current project, + * chat, or open editor tab would break that cache. + */ +export type AssistantContextMessage = { role: 'assistant'; content: string } + +/** + * Tells the model that the snippet in the SQL editor targets the logs backend, so + * SQL it writes for that snippet comes back as ClickHouse for the `logs` table + * rather than Postgres. Carries the same dialect rules and table reference the + * inline editor completions use, so there's one description of the logs schema. + */ +function buildLogsSnippetContext(): string { + return [ + "Some SQL snippets are marked with the dialect 'clickhouse', which means they query the Supabase logs backend, not the Postgres database. Any SQL you write, edit, or debug for that snippet must be ClickHouse SQL against the logs table described below β€” the database schema and the Postgres tools don't apply to it. You can help a user iterate on their ClickHouse SQL query, but you cannot run it for them (the execute_query tool does not run log queries). Postgres SQL is still the right answer for anything else the user asks about their database, or for non-ClickHouse marked queries.", + CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS.trim(), + buildClickhouseLogsSchemaSection().trim(), + ].join('\n\n') +} + +/** + * Assemble the context messages that precede the conversation: what project and + * chat this is, whether it's a support chat, and which backend the open SQL editor + * snippet targets. Pure and per-request β€” see {@link AssistantContextMessage} for + * why none of this belongs in the system prompt. + */ +export function buildAssistantContextMessages({ + projectRef, + chatName, + schemasString, + supportMode, + includesLogsSnippets, +}: { + projectRef?: string + chatName?: string + schemasString: string + supportMode?: boolean + /** Whether any user message in the conversation attached a logs (ClickHouse) query. */ + includesLogsSnippets?: boolean +}): AssistantContextMessage[] { + const messages: AssistantContextMessage[] = [] + + const hasProjectContext = !!projectRef || !!chatName || schemasString !== NO_SCHEMA_ACCESS_MESSAGE + if (hasProjectContext) { + messages.push({ + role: 'assistant', + content: `The user's current project is ${projectRef || 'unknown'}. Their available schemas are: ${schemasString}. The current chat name is: ${chatName || 'unnamed'}.`, + }) + } + + if (supportMode) { + messages.push({ + role: 'assistant', + content: + 'This is an active support chat. Help the user while they wait for a human agent. Keep guidance practical and concise. If the user asks for a human, or if the issue cannot be safely resolved, call escalate_to_human with a short reason. Only call resolve_support_conversation after the user explicitly confirms the issue is resolved; otherwise keep helping.', + }) + } + + if (includesLogsSnippets) { + messages.push({ role: 'assistant', content: buildLogsSnippetContext() }) + } + + return messages +} diff --git a/apps/studio/lib/ai/assistant-message-metadata.test.ts b/apps/studio/lib/ai/assistant-message-metadata.test.ts new file mode 100644 index 0000000000000..2206121e900fd --- /dev/null +++ b/apps/studio/lib/ai/assistant-message-metadata.test.ts @@ -0,0 +1,90 @@ +import type { UIMessage } from 'ai' +import { describe, expect, it } from 'vitest' + +import { + assistantMessageMetadataSchema, + messagesIncludeLogsSnippets, +} from '@/lib/ai/assistant-message-metadata' + +function userMessage(id: string, text: string, metadata?: unknown): UIMessage { + return { id, role: 'user', parts: [{ type: 'text', text }], metadata } as UIMessage +} + +function assistantMessage(id: string, text: string): UIMessage { + return { id, role: 'assistant', parts: [{ type: 'text', text }] } as UIMessage +} + +describe('assistantMessageMetadataSchema', () => { + // safeValidateUIMessages applies this schema to EVERY message's metadata, so a + // message with none (i.e. every message written before this field existed) has to + // pass β€” otherwise an existing conversation 400s on its next turn. + it('accepts a message with no metadata', () => { + expect(assistantMessageMetadataSchema.safeParse(undefined).success).toBe(true) + }) + + it('accepts metadata flagging attached logs queries', () => { + const result = assistantMessageMetadataSchema.safeParse({ containsLogsSnippets: true }) + expect(result.success).toBe(true) + expect(result.data?.containsLogsSnippets).toBe(true) + }) + + it('rejects a non-boolean flag', () => { + expect(assistantMessageMetadataSchema.safeParse({ containsLogsSnippets: 'yes' }).success).toBe( + false + ) + }) +}) + +describe('messagesIncludeLogsSnippets', () => { + it('detects a message that attached a logs query', () => { + expect( + messagesIncludeLogsSnippets([ + userMessage('1', 'show me 500s', { containsLogsSnippets: true }), + ]) + ).toBe(true) + }) + + it('stays true once any earlier message attached a logs query', () => { + expect( + messagesIncludeLogsSnippets([ + userMessage('1', 'show me 500s', { containsLogsSnippets: true }), + assistantMessage('2', 'select ...'), + userMessage('3', 'now count my users', { containsLogsSnippets: false }), + ]) + ).toBe(true) + }) + + it('is false for a conversation that only attached database queries', () => { + expect( + messagesIncludeLogsSnippets([ + userMessage('1', 'count my users', { containsLogsSnippets: false }), + assistantMessage('2', 'select ...'), + ]) + ).toBe(false) + }) + + it('is false when no message carries metadata', () => { + expect(messagesIncludeLogsSnippets([userMessage('1', 'hello')])).toBe(false) + expect(messagesIncludeLogsSnippets([userMessage('1', 'hello', {})])).toBe(false) + expect(messagesIncludeLogsSnippets([])).toBe(false) + }) + + // Only the user states which query they attached; an assistant message must not be + // able to talk the server into a different dialect. + it('ignores metadata on assistant messages', () => { + const assistantWithMetadata = { + id: '1', + role: 'assistant', + parts: [{ type: 'text', text: 'hi' }], + metadata: { containsLogsSnippets: true }, + } as UIMessage + expect(messagesIncludeLogsSnippets([assistantWithMetadata])).toBe(false) + }) + + it('is false rather than throwing on malformed persisted metadata', () => { + expect( + messagesIncludeLogsSnippets([userMessage('1', 'hi', { containsLogsSnippets: 'yes' })]) + ).toBe(false) + expect(messagesIncludeLogsSnippets([userMessage('1', 'hi', 'not an object')])).toBe(false) + }) +}) diff --git a/apps/studio/lib/ai/assistant-message-metadata.ts b/apps/studio/lib/ai/assistant-message-metadata.ts new file mode 100644 index 0000000000000..d30af08387eae --- /dev/null +++ b/apps/studio/lib/ai/assistant-message-metadata.ts @@ -0,0 +1,30 @@ +import type { UIMessage } from 'ai' +import z from 'zod' + +export const assistantMessageMetadataSchema = z + .object({ + /** + * Whether any query attached to this message is a logs (ClickHouse) query. A boolean + * rather than a single source, because one message can attach several queries and + * only some of them may target the logs backend β€” the per-attachment dialect is + * carried by each snippet's own fence in the message text. + */ + containsLogsSnippets: z.boolean().optional(), + }) + .optional() + +export type AssistantMessageMetadata = z.infer + +/** + * Whether any user message in the conversation attached a logs query. + * + * Parsed rather than cast β€” `UIMessage['metadata']` is `unknown`, and metadata can come + * from a chat persisted by an older build. + */ +export function messagesIncludeLogsSnippets(messages: UIMessage[]): boolean { + return messages.some((message) => { + if (message.role !== 'user') return false + const metadata = assistantMessageMetadataSchema.safeParse(message.metadata) + return metadata.success && metadata.data?.containsLogsSnippets === true + }) +} diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index 9679be6fb9baf..f131119b549db 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -14,6 +14,7 @@ import { source } from 'common-tags' import type { AssistantEvalInput } from '@/evals/scorer' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' +import { buildAssistantContextMessages, NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context' import { IS_TRACING_ENABLED } from '@/lib/ai/braintrust-logger' import { CHAT_PROMPT, GENERAL_PROMPT, LIMITATIONS_PROMPT, SECURITY_PROMPT } from '@/lib/ai/prompts' import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' @@ -34,6 +35,7 @@ export async function generateAssistantResponse({ userId, orgId, planId, + includesLogsSnippets, systemProviderOptions, providerOptions, requestedModel, @@ -53,6 +55,8 @@ export async function generateAssistantResponse({ userId?: string orgId?: number planId?: string + /** Whether any user message in the conversation attached a logs (ClickHouse) query. */ + includesLogsSnippets?: boolean requestedModel?: string systemProviderOptions?: Record providerOptions?: Record @@ -98,7 +102,7 @@ export async function generateAssistantResponse({ ? shouldTrace ? await traced(async () => getSchemas(), { name: 'getSchemas', type: 'function' }) : await getSchemas() - : "You don't have access to any schemas." + : NO_SCHEMA_ACCESS_MESSAGE // Important: do not use dynamic content in the system prompt or Bedrock will not cache it const system = source` @@ -117,16 +121,6 @@ export async function generateAssistantResponse({ - \`realtime\` β€” Supabase Realtime ` - const hasProjectContext = - projectRef || chatName || schemasString !== "You don't have access to any schemas." - - const assistantContent = hasProjectContext - ? `The user's current project is ${projectRef || 'unknown'}. Their available schemas are: ${schemasString}. The current chat name is: ${chatName || 'unnamed'}.` - : undefined - const supportAssistantContent = supportMode - ? `This is an active support chat. Help the user while they wait for a human agent. Keep guidance practical and concise. If the user asks for a human, or if the issue cannot be safely resolved, call escalate_to_human with a short reason. Only call resolve_support_conversation after the user explicitly confirms the issue is resolved; otherwise keep helping.` - : undefined - const systemMessage: SystemModelMessage = { role: 'system', content: system, @@ -134,22 +128,13 @@ export async function generateAssistantResponse({ } const coreMessages: ModelMessage[] = [ - ...(assistantContent - ? [ - { - role: 'assistant' as const, - content: assistantContent, - }, - ] - : []), - ...(supportAssistantContent - ? [ - { - role: 'assistant' as const, - content: supportAssistantContent, - }, - ] - : []), + ...buildAssistantContextMessages({ + projectRef, + chatName, + schemasString, + supportMode, + includesLogsSnippets, + }), ...(await convertToModelMessages(messages)), ] diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 3bb67ff91b104..5535ce02c607b 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -8,6 +8,11 @@ import z from 'zod' import { executeSql } from '@/data/sql/execute-sql-mutation' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { getOrgAIDetails, getProjectAIDetails } from '@/lib/ai/ai-details' +import { NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context' +import { + assistantMessageMetadataSchema, + messagesIncludeLogsSnippets, +} from '@/lib/ai/assistant-message-metadata' import { isTracingAllowed } from '@/lib/ai/braintrust-logger' import { generateAssistantResponse } from '@/lib/ai/generate-assistant-response' import { getModel } from '@/lib/ai/model' @@ -100,6 +105,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw const messagesValidation = await safeValidateUIMessages({ messages: rawMessages, + metadataSchema: assistantMessageMetadataSchema, }) if (!messagesValidation.success) { return res.status(400).json({ @@ -109,6 +115,8 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw } const messages = messagesValidation.data + const includesLogsSnippets = messagesIncludeLogsSnippets(messages) + let aiOptInLevel: AiOptInLevel = 'disabled' let hasAccessToAdvanceModel = false let orgHasHipaaAddon: boolean | undefined @@ -203,7 +211,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw return schemas?.length > 0 ? `The available database schema names are: ${JSON.stringify(schemas)}` - : "You don't have access to any schemas." + : NO_SCHEMA_ACCESS_MESSAGE } const result = await generateAssistantResponse({ @@ -224,6 +232,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw userId, orgId, planId, + includesLogsSnippets, requestedModel, systemProviderOptions, abortSignal: abortController.signal, diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index 9e51958f1ab39..5355074cf655c 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -7,6 +7,7 @@ import { createContext, PropsWithChildren, useContext, useEffect, useState } fro import { v4 as uuidv4 } from 'uuid' import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' +import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import type { AiSupportStatus } from '@/data/feedback/ai-chat-front-sync' import { constructHeaders } from '@/data/fetchers' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -22,7 +23,12 @@ type SuggestionsType = { export type AssistantMessageType = MessageType -export type SqlSnippet = string | { label: string; content: string } +/** + * A query attached to the composer (the "Current Query" chip). `source` records which + * backend the attached query runs against, so the dialect travels with the query it + * describes. + */ +export type SqlSnippet = string | { label: string; content: string; source?: SqlSnippetSource } export type AssistantModel = AssistantModelId From 9be46e674a50af59f6f8d83ee4e8f84fa7d8317c Mon Sep 17 00:00:00 2001 From: Tobias Pfeiffer Date: Tue, 4 Aug 2026 15:02:43 +0200 Subject: [PATCH 09/13] Add me to humans.txt! (#48695) It's a me. ## 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? adds me to the humans.txt ## What is the current behavior? Tobi isn't in the list. ## What is the new behavior? Tobi is in the list. ## Additional context IMG_20210220_105358_Bokeh ## Summary by CodeRabbit * **Documentation** * Added Tobias Pfeiffer to the remote team member list. --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 144db29f2ae6c..6ff0ca046316b 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -295,6 +295,7 @@ Tim Frietas Tim Palmer Timothy Lim Tina Ha +Tobias Pfeiffer Tom Ashley Tom G TomΓ‘s Pozo From 469da990b665acc5147e62f32270e87815162f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Tue, 4 Aug 2026 14:28:15 +0100 Subject: [PATCH 10/13] chore: Add Filipe to humans (#48698) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 6ff0ca046316b..de32ff1b9424f 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -104,6 +104,7 @@ Fady A Fatuma Abdullahi Felipe Stival Ferhat Elmas +Filipe Cabaco Firas El Rachidi Francesco Sansalvadore Gabriel Claudino From ceace2e90b93aa4126018bf657b2ab1b55c05d85 Mon Sep 17 00:00:00 2001 From: Hoon <67215423+eejihoon@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:31:59 +0900 Subject: [PATCH 11/13] fix(studio): account for SQL result column headers (#48676) ## 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? Bug fix ## What is the current behavior? Fixes #48672. The SQL Editor results grid calculates each column's initial width from cell values only. When a long final column contains a short value such as `NULL`, scrolling to the end of a wide result set reveals a truncated header even though the full column name is needed to identify the result. ### Before Before: final SQL result column
header is truncated ## What is the new behavior? The initial width now accounts for both the column name and its cell values while preserving the existing minimum and maximum width constraints. ### After After: full SQL result column header
is visible ## Additional context The width calculation was extracted into a utility and covered for: - short headers and values - headers longer than their values - values longer than their headers - empty result sets - maximum-width capping Verification: - `pnpm --filter studio exec vitest --run components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts tests/components/SQLEditor/Results.test.tsx` - `pnpm --filter studio run typecheck` - `pnpm --filter studio run lint:ratchet` - `pnpm run test:prettier` - `SKIP_ASSET_UPLOAD=1 pnpm run build:studio` ## Summary by CodeRabbit - **Improvements** - SQL query results now automatically size columns based on their headers and content. - Column widths remain within practical minimum and maximum limits for improved readability and usability. - **Tests** - Added coverage for minimum and maximum widths, content-based sizing, and empty result sets. --- .../SQLEditor/UtilityPanel/Results.tsx | 23 ++++------- .../UtilityPanel/Results.utils.test.ts | 41 +++++++++++++++++++ .../SQLEditor/UtilityPanel/Results.utils.ts | 16 ++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx index 49f6883ea8eb8..393f243f8e032 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx @@ -11,7 +11,11 @@ import { import { CellDetailPanel } from './CellDetailPanel' import { ResultCell } from './ResultCell' -import { formatClipboardValue } from './Results.utils' +import { + calculateResultColumnWidth, + formatClipboardValue, + RESULT_COLUMN_MIN_WIDTH, +} from './Results.utils' import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils' export const Results = ({ rows }: { rows: readonly any[] }) => { @@ -41,22 +45,9 @@ export const Results = ({ rows }: { rows: readonly any[] }) => { return
{name}
} - const EST_CHAR_WIDTH = 8.25 - const MIN_COLUMN_WIDTH = 100 - const MAX_COLUMN_WIDTH = 500 - const columns: CalculatedColumn[] = useMemo( () => Object.keys(rows?.[0] ?? []).map((key, idx) => { - const maxColumnValueLength = rows - .map((row) => String(row[key]).length) - .reduce((a, b) => Math.max(a, b), 0) - - const columnWidth = Math.max( - Math.min(maxColumnValueLength * EST_CHAR_WIDTH, MAX_COLUMN_WIDTH), - MIN_COLUMN_WIDTH - ) - return { idx, key, @@ -64,8 +55,8 @@ export const Results = ({ rows }: { rows: readonly any[] }) => { resizable: true, parent: undefined, level: 0, - width: columnWidth, - minWidth: MIN_COLUMN_WIDTH, + width: calculateResultColumnWidth(key, rows), + minWidth: RESULT_COLUMN_MIN_WIDTH, maxWidth: undefined, draggable: false, frozen: false, diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts index 6006cde9feb5b..88224d9076478 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + calculateResultColumnWidth, convertResultsToCSV, convertResultsToJSON, convertResultsToMarkdown, @@ -12,6 +13,46 @@ import { } from './Results.utils' describe('Results.utils', () => { + describe('calculateResultColumnWidth', () => { + it('uses the minimum width when the column name and values are short', () => { + expect(calculateResultColumnWidth('id', [{ id: 1 }])).toBe(100) + }) + + it('accounts for a column name that is longer than its values', () => { + expect(calculateResultColumnWidth('source_campaign_id', [{ source_campaign_id: null }])).toBe( + 148.5 + ) + }) + + it('accounts for a value that is longer than the column name', () => { + expect(calculateResultColumnWidth('name', [{ name: 'a'.repeat(20) }])).toBe(165) + }) + + it('accounts for the formatted JSON representation of an object value', () => { + expect( + calculateResultColumnWidth('metadata', [{ metadata: { campaign: 'a'.repeat(20) } }]) + ).toBe(288.75) + }) + + it('accounts for the formatted JSON representation of an array value', () => { + expect(calculateResultColumnWidth('tags', [{ tags: ['a'.repeat(10), 'b'.repeat(10)] }])).toBe( + 222.75 + ) + }) + + it('caps the width when the column name exceeds the maximum', () => { + expect(calculateResultColumnWidth('a'.repeat(100), [])).toBe(500) + }) + + it('caps the width when a value exceeds the maximum', () => { + expect(calculateResultColumnWidth('value', [{ value: 'a'.repeat(100) }])).toBe(500) + }) + + it('uses the minimum width when there are no rows', () => { + expect(calculateResultColumnWidth('id', [])).toBe(100) + }) + }) + describe('formatClipboardValue', () => { it('returns empty string for null', () => { expect(formatClipboardValue(null)).toBe('') diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts index ca19a5763ec67..fb03dc9faf259 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts @@ -3,6 +3,22 @@ import Papa from 'papaparse' type ResultRow = Record +const ESTIMATED_CHARACTER_WIDTH = 8.25 +export const RESULT_COLUMN_MIN_WIDTH = 100 +const MAX_COLUMN_WIDTH = 500 + +export function calculateResultColumnWidth(columnName: string, rows: readonly ResultRow[]) { + const maxContentLength = rows.reduce( + (maxLength, row) => Math.max(maxLength, (formatCellValue(row[columnName]) ?? '').length), + columnName.length + ) + + return Math.min( + Math.max(maxContentLength * ESTIMATED_CHARACTER_WIDTH, RESULT_COLUMN_MIN_WIDTH), + MAX_COLUMN_WIDTH + ) +} + export function formatClipboardValue(value: unknown) { if (value === null) return '' if (typeof value == 'object' || Array.isArray(value)) { From 0e71933ce3fad9b1dc8595207950cc9754f54a77 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 4 Aug 2026 23:16:49 +0800 Subject: [PATCH 12/13] [FE-4070] fix(studio): allow adding expressions to RLS policies (#48700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table RLS policy created via SQL without a `USING`/`WITH CHECK` clause stores `null` for that field, and the policy editor's payload diff skipped `null` fields entirely β€” so adding an expression later through the dashboard closed the panel as if saved but persisted nothing. This fixes the diff so those policies are editable, and cleans up adjacent issues in the same code path. **Changed:** - Extracted the update-payload diff from `PolicyEditorPanel`'s submit handler into a pure `generateUpdatePolicyPayload()` in `PolicyEditorPanel.utils.ts`. A stored `null` definition/check now counts as empty, so typing an expression into a previously empty editor produces a payload field. The diff is branched by command so INSERT policies only ever emit `WITH CHECK`, never an invalid `USING` clause. - The required-expression validation ("Please provide a SQL expression…") now applies only when creating a policy. When updating, a `null` clause is valid, so rename-only and role-only saves on such policies work; the update path instead rejects attempts to clear an existing `USING`/`WITH CHECK` expression with an inline error (`ALTER POLICY` can only replace an expression, not remove it). - Saving with no changes now closes the panel without a round trip β€” previously a null-vs-undefined comparison injected a present-but-`undefined` payload key, which sent a literal `BEGIN; COMMIT;` to the user's database. - Fixed the unsaved-changes check comparing the form's lowercase command against `'INSERT'` (never matched), which made closing an untouched INSERT policy editor prompt about unsaved changes. It now compares `selectedPolicy.command`. **Added:** - `PolicyEditorPanel.utils.test.ts` β€” 11 unit tests covering nullβ†’value transitions for definition and check, INSERT command mapping, valueβ†’value updates, no-op saves, and empty-value handling. ## To test - Run in the SQL editor: `create policy "p1" on for delete to authenticated;` (no `USING` clause), then edit `p1` in Database β†’ Policies, add a `USING` expression, and save. Confirm via `select pg_get_expr(polqual, polrelid) from pg_policy where polname = 'p1'` that the expression persisted. - Same for INSERT: `create policy "p2" on
for insert to authenticated;`, then add a `WITH CHECK` expression via the editor and confirm `polwithcheck` is set (and `polqual` stays null). - On `p1` (still without a `USING` expression? recreate it if you added one), rename the policy without touching the expression editors β€” the rename should save successfully. - Edit a policy that already has a `USING` expression, change it, and confirm the new expression persists (regression). - Open a policy and save without changing anything β€” the panel should close with no `policy-update` network request. - On a policy with an existing `USING` (or `WITH CHECK`) expression, clear that editor and save β€” an inline error should appear and no request should fire. - Open an INSERT policy that has a `WITH CHECK` expression, change nothing, and close the panel β€” it should close without an "Unsaved changes" prompt. ## Summary by CodeRabbit * **Bug Fixes** * Policy updates now submit only changed fields. * Improved handling of policy expressions, including INSERT-specific mappings. * Prevented removal of existing `USING` or `WITH CHECK` expressions where unsupported. * Empty expressions are omitted from update requests. * Updates are canceled when no changes are detected. * **Tests** * Added coverage for unchanged policies, expression updates, name and role changes, and INSERT policy behavior. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../PolicyEditorPanel.utils.test.ts | 102 ++++++++++++++++++ .../PolicyEditorPanel.utils.ts | 58 +++++++++- .../Policies/PolicyEditorPanel/index.tsx | 86 +++++++++------ 3 files changed, 210 insertions(+), 36 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.test.ts diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.test.ts b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.test.ts new file mode 100644 index 0000000000000..65aae4e19d1ed --- /dev/null +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.test.ts @@ -0,0 +1,102 @@ +import type { PGPolicy } from '@supabase/pg-meta' +import { describe, expect, it } from 'vitest' + +import { generateUpdatePolicyPayload } from './PolicyEditorPanel.utils' + +type PolicyFixture = Pick + +const mockPolicy = (overrides: Partial = {}): PolicyFixture => ({ + name: 'my_policy', + roles: ['authenticated'], + command: 'DELETE', + definition: 'user_id = auth.uid()', + check: null, + ...overrides, +}) + +const baseForm = { + name: 'my_policy', + roles: ['authenticated'], + // The panel prefills editors with two leading spaces of indentation + using: ' user_id = auth.uid()', + check: undefined, +} + +describe('generateUpdatePolicyPayload', () => { + it('returns an empty payload when nothing changed', () => { + expect(generateUpdatePolicyPayload(mockPolicy(), baseForm)).toEqual({}) + }) + + it('includes the definition when a policy with a null definition gains a using expression', () => { + const payload = generateUpdatePolicyPayload(mockPolicy({ definition: null }), { + ...baseForm, + using: 'true', + }) + expect(payload).toEqual({ definition: 'true' }) + }) + + it('includes the definition when the using expression changed', () => { + const payload = generateUpdatePolicyPayload(mockPolicy(), { ...baseForm, using: 'true' }) + expect(payload).toEqual({ definition: 'true' }) + }) + + it('omits the definition when the using expression is empty', () => { + expect(generateUpdatePolicyPayload(mockPolicy(), { ...baseForm, using: '' })).toEqual({}) + expect(generateUpdatePolicyPayload(mockPolicy(), { ...baseForm, using: ' ' })).toEqual({}) + expect(generateUpdatePolicyPayload(mockPolicy(), { ...baseForm, using: undefined })).toEqual({}) + }) + + it('includes the check when a policy with a null check gains a check expression', () => { + const payload = generateUpdatePolicyPayload(mockPolicy({ command: 'UPDATE' }), { + ...baseForm, + check: 'is_admin()', + }) + expect(payload).toEqual({ check: 'is_admin()' }) + }) + + it('includes the check when the check expression changed', () => { + const payload = generateUpdatePolicyPayload( + mockPolicy({ command: 'UPDATE', check: 'is_admin()' }), + { ...baseForm, check: 'is_owner()' } + ) + expect(payload).toEqual({ check: 'is_owner()' }) + }) + + it('omits the check when the check expression is unchanged or empty', () => { + const withCheck = mockPolicy({ command: 'UPDATE', check: 'is_admin()' }) + expect(generateUpdatePolicyPayload(withCheck, { ...baseForm, check: ' is_admin()' })).toEqual( + {} + ) + expect(generateUpdatePolicyPayload(withCheck, { ...baseForm, check: '' })).toEqual({}) + }) + + it('maps the using editor to the check field for INSERT policies', () => { + const insertPolicy = mockPolicy({ command: 'INSERT', definition: null, check: null }) + const payload = generateUpdatePolicyPayload(insertPolicy, { ...baseForm, using: 'true' }) + expect(payload).toEqual({ check: 'true' }) + }) + + it('never includes a definition for INSERT policies', () => { + const insertPolicy = mockPolicy({ command: 'INSERT', definition: null, check: 'true' }) + const payload = generateUpdatePolicyPayload(insertPolicy, { + ...baseForm, + using: 'is_admin()', + check: 'ignored', + }) + expect(payload).toEqual({ check: 'is_admin()' }) + }) + + it('omits the check for INSERT policies when the expression is unchanged', () => { + const insertPolicy = mockPolicy({ command: 'INSERT', definition: null, check: 'true' }) + expect(generateUpdatePolicyPayload(insertPolicy, { ...baseForm, using: ' true' })).toEqual({}) + }) + + it('includes the name and roles when they changed', () => { + const payload = generateUpdatePolicyPayload(mockPolicy(), { + ...baseForm, + name: 'renamed_policy', + roles: ['anon', 'authenticated'], + }) + expect(payload).toEqual({ name: 'renamed_policy', roles: ['anon', 'authenticated'] }) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts index f0341e8a06b8c..6e483e8a36f6f 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts @@ -1,5 +1,12 @@ import type { PGPolicy } from '@supabase/pg-meta' -import { ident, keyword, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' +import { + ident, + keyword, + safeSql, + untrustedSql, + type SafeSqlFragment, + type UntrustedSqlFragment, +} from '@supabase/pg-meta/src/pg-format' import { isEqual } from 'lodash' // [Joshen] Not used but keeping this for now in case we do an inline editor @@ -77,6 +84,55 @@ export const generateCreatePolicyQuery = ({ return safeSql`${withUsing};` } +/** + * Diffs the editor form against the stored policy and returns only the fields + * that changed β€” an empty result means there is nothing to save. A stored + * `null` definition/check (policy created without that clause) counts as + * empty, so typing an expression into a previously empty editor is a change. + * Empty form values never produce a payload field, because ALTER POLICY can + * only replace an expression, not remove it. + * + * Expressions are returned as UntrustedSqlFragment β€” the caller promotes them + * with acceptUntrustedSql in the submit handler. + */ +export const generateUpdatePolicyPayload = ( + selectedPolicy: Pick, + policyForm: { + name: string + roles: string[] + using?: string + check?: string + } +): { + name?: string + roles?: string[] + definition?: UntrustedSqlFragment + check?: UntrustedSqlFragment +} => { + const payload: { + name?: string + roles?: string[] + definition?: UntrustedSqlFragment + check?: UntrustedSqlFragment + } = {} + const usingVal = policyForm.using?.trim() + const checkVal = policyForm.check?.trim() + + if (policyForm.name !== selectedPolicy.name) payload.name = policyForm.name + if (!isEqual(selectedPolicy.roles, policyForm.roles)) payload.roles = policyForm.roles + + if (selectedPolicy.command === 'INSERT') { + // For INSERT policies editor one holds the with check expression + if (!!usingVal && usingVal !== selectedPolicy.check) payload.check = untrustedSql(usingVal) + } else { + if (!!usingVal && usingVal !== selectedPolicy.definition) + payload.definition = untrustedSql(usingVal) + if (!!checkVal && checkVal !== selectedPolicy.check) payload.check = untrustedSql(checkVal) + } + + return payload +} + export const checkIfPolicyHasChanged = ( selectedPolicy: PGPolicy, policyForm: { diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx index e24db3b43745e..a7688b1fdae15 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx @@ -12,7 +12,6 @@ import { import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' -import { isEqual } from 'lodash' import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' @@ -35,7 +34,11 @@ import * as z from 'zod' import { LockedCreateQuerySection, LockedRenameQuerySection } from './LockedQuerySection' import { PolicyDetailsV2 } from './PolicyDetailsV2' -import { checkIfPolicyHasChanged, generateCreatePolicyQuery } from './PolicyEditorPanel.utils' +import { + checkIfPolicyHasChanged, + generateCreatePolicyQuery, + generateUpdatePolicyPayload, +} from './PolicyEditorPanel.utils' import { PolicyEditorPanelHeader } from './PolicyEditorPanelHeader' import { PolicyTemplates } from './PolicyTemplates' import { QueryError } from './QueryError' @@ -172,12 +175,15 @@ export const PolicyEditorPanel = memo(function ({ name, roles: roles.length === 0 ? ['public'] : roles.split(', '), definition: editorOneFormattedValue, - check: command === 'INSERT' ? editorOneFormattedValue : editorTwoFormattedValue, + check: + selectedPolicy.command === 'INSERT' + ? editorOneFormattedValue + : editorTwoFormattedValue, }) : false return policyCreateUnsaved || policyUpdateUnsaved - }, [command, name, roles, selectedPolicy]) + }, [name, roles, selectedPolicy]) const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: hasUnsavedChanges, @@ -192,15 +198,14 @@ export const PolicyEditorPanel = memo(function ({ const usingExpr = command !== 'insert' ? using : undefined const checkExpr = command === 'insert' ? using : check - if (command === 'insert' && !checkExpr?.trim()) { - return setFieldError('Please provide a SQL expression for the WITH CHECK statement') - } else if (command !== 'insert' && !usingExpr?.trim()) { - return setFieldError('Please provide a SQL expression for the USING statement') - } else { + if (selectedPolicy === undefined) { + if (command === 'insert' && !checkExpr?.trim()) { + return setFieldError('Please provide a SQL expression for the WITH CHECK statement') + } else if (command !== 'insert' && !usingExpr?.trim()) { + return setFieldError('Please provide a SQL expression for the USING statement') + } setFieldError(undefined) - } - if (selectedPolicy === undefined) { const sql = generateCreatePolicyQuery({ name, schema, @@ -222,34 +227,37 @@ export const PolicyEditorPanel = memo(function ({ }, }) } else if (selectedProject !== undefined) { - const payload: { - name?: string - definition?: SafeSqlFragment - check?: SafeSqlFragment - roles?: Array - } = {} const updatedRoles = roles.length === 0 ? ['public'] : roles.split(', ') - // Trim for string comparison against the stored policy values. The Save click is the - // explicit user gesture that promotes editor content to executable SQL. - const usingVal = using?.trim() - const checkVal = check?.trim() - - if (name !== selectedPolicy.name) payload.name = name - if (!isEqual(selectedPolicy.roles, updatedRoles)) payload.roles = updatedRoles - if (selectedPolicy.definition !== null && selectedPolicy.definition !== usingVal) - payload.definition = - usingVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(usingVal)) + // A null definition/check is valid (the policy was created without that clause), so + // updates only require an expression where the policy already has one β€” ALTER POLICY + // can only replace an expression, not remove it. if (selectedPolicy.command === 'INSERT') { - // [Joshen] Cause editor one will be the check statement in this scenario - if (selectedPolicy.check !== usingVal) - payload.check = - usingVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(usingVal)) + if (selectedPolicy.check !== null && !using?.trim()) { + return setFieldError( + 'The WITH CHECK expression cannot be removed. Provide a new expression, or delete and recreate the policy without it.' + ) + } } else { - if (selectedPolicy.check !== checkVal) - payload.check = - checkVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(checkVal)) + if (selectedPolicy.definition !== null && !using?.trim()) { + return setFieldError( + 'The USING expression cannot be removed. Provide a new expression, or delete and recreate the policy without it.' + ) + } + if (selectedPolicy.check !== null && !check?.trim()) { + return setFieldError( + 'The WITH CHECK expression cannot be removed. Provide a new expression, or delete and recreate the policy without it.' + ) + } } + setFieldError(undefined) + + const payload = generateUpdatePolicyPayload(selectedPolicy, { + name, + roles: updatedRoles, + using, + check, + }) if (Object.keys(payload).length === 0) return onSelectCancel() @@ -257,7 +265,15 @@ export const PolicyEditorPanel = memo(function ({ projectRef: selectedProject.ref, connectionString: selectedProject?.connectionString, originalPolicy: selectedPolicy, - payload, + // The Save click is the explicit user gesture that promotes editor content + // to executable SQL. + payload: { + name: payload.name, + roles: payload.roles, + definition: + payload.definition === undefined ? undefined : acceptUntrustedSql(payload.definition), + check: payload.check === undefined ? undefined : acceptUntrustedSql(payload.check), + }, }) } } From e7d9c88cbc0aef6ed66f1a85cc0a41906bd6030b Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Tue, 4 Aug 2026 09:00:29 -0700 Subject: [PATCH 13/13] fix(docs): resolve remaining heading-order issues found in Pass 2 diagnostic (#48664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem After merging [#48456](https://github.com/supabase/supabase/pull/48456) (shared components) and [#48459](https://github.com/supabase/supabase/pull/48459) (per-page content fixes), a follow-up diagnostic pass found 22 remaining heading-order violations, logged as Pass 2 in the [triage report](https://app.notion.com/p/supabase/Playwright-E2E-Triage-Reports-3ab5004b775f81e3bc60d058fa5a02c1). None of them were caught by the earlier fixes because they came from places that scan didn't check: shared partials, raw HTML heading tags written directly in MDX, and a couple of shared/interactive components rendering hardcoded heading levels. ## Solution - `_partials/social_provider_setup.mdx`: `#### Local development` β†’ `###`, matching the `##` that always precedes it on all 14 social-login pages. - `guides/database/functions.mdx` and `guides/integrations/vercel-marketplace.mdx`: replaced raw `

`/`

` tags with correctly-nested real headings (`### Planets`/`### People`; `#### Deploy a Next.js app...`) β€” no styling workarounds needed since they nest naturally one level below their parent section. - `auth/quickstarts/{nextjs,react-native,react,astrojs}.mdx`: these 4 pages had no heading at all before the embedded `_partials/api_settings.mdx` partial's own `### Get API details` heading, so added a `## Quickstart` heading above the walkthrough to give it a valid parent. - `packages/ui`'s `Accordion` component: Radix's `AccordionPrimitive.Header` renders as an unconditional `

` regardless of where the accordion is used. That's shared across Studio, www, and design-system, not just docs, and surfaced on docs' vendor-agnostic telemetry page. Now rendered via `asChild` onto a plain `div` instead, since a generic accordion has no way to know what heading level (if any) is valid in a given page. - SQL-to-REST translator tool (`/docs/guides/api/sql-to-rest`): its `Assumptions`/`FAQs` section labels were hardcoded `

` with no `h2` anywhere on the page. Converted to styled spans rather than promoting to a real `

`, because real h1/h2/h3 tags in this codebase force a prose font-size that utility classes can't override β€” promoting the tag would have visibly changed its size. - `RealtimeLimitsEstimator` (embedded on both `postgres-changes` and `benchmarks`): its 3 section headings were hardcoded `

`, but the two embedding pages need different levels (h3 vs h4) for that spot to be valid β€” no single correct heading level. Converted to styled spans, same pattern used throughout this project for components embedded at varying heading depths. ## Manual testing 1. Check out this branch and run `pnpm dev:docs`. 2. Visit `/docs/guides/auth/social-login/auth-github` (or any other provider page) and confirm the "Local development" callout under "Find your callback URL" still looks and reads the same. 3. Visit `/docs/guides/database/functions` β†’ "Returning data sets" tab and confirm the "Planets" / "People" table captions still look the same. 4. Visit `/docs/guides/integrations/vercel-marketplace` β†’ "Quickstart" β†’ "Via template" and confirm the CTA card title still looks the same. 5. Visit `/docs/guides/auth/quickstarts/nextjs` (or react-native/react/astrojs) and confirm a "Quickstart" heading now appears above the walkthrough, and "Get API details" still renders correctly further down. 6. Run `pnpm dev:design-system` and open `/design-system/docs/components/accordion` β€” expand/collapse an item and confirm it still animates and looks identical; inspect the DOM and confirm the trigger's wrapper is a `div`, not an `h3`. 7. Visit `/docs/guides/api/sql-to-rest`, translate any query, and confirm the "Assumptions"/"FAQs" section labels still look the same. 8. Visit `/docs/guides/realtime/postgres-changes` and `/docs/guides/realtime/benchmarks`, scroll to the connection-limits calculator, and confirm its section labels still look the same on both pages. 9. (Optional, for a full re-check) Run `pnpm e2e:docs:a11y --all` against a deployed preview of this branch β€” only `/docs/guides/cli` (pre-existing 404, unrelated to headings) should fail; every other page should pass. Verified with a full Playwright run against a real preview deployment: **756 passed, 1 failed** (`/docs/guides/cli`, the pre-existing unrelated 404). Zero heading-order violations remain. ## Summary by CodeRabbit - **Documentation** - Added clearly labeled Quickstart sections to Astro, Next.js, React Native, and React authentication guides. - Improved heading hierarchy and formatting across social provider setup, database functions, and deployment documentation. - Updated estimator and SQL-to-REST section presentation for more consistent content structure. - **Bug Fixes** - Improved accordion trigger layout while preserving existing behavior, styling, accessibility, and icon display. --------- Co-authored-by: Claude Sonnet 5 --- .../RealtimeLimitsEstimator.tsx | 12 +++-- .../_partials/social_provider_setup.mdx | 6 +-- .../guides/auth/quickstarts/astrojs.mdx | 2 + .../guides/auth/quickstarts/nextjs.mdx | 2 + .../guides/auth/quickstarts/react-native.mdx | 2 + .../content/guides/auth/quickstarts/react.mdx | 2 + .../content/guides/database/functions.mdx | 4 +- .../integrations/vercel-marketplace.mdx | 2 +- .../ui-patterns/src/SqlToRest/sql-to-rest.tsx | 12 +++-- .../ui/src/components/shadcn/ui/accordion.tsx | 52 ++++++++++--------- 10 files changed, 56 insertions(+), 40 deletions(-) diff --git a/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx b/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx index 7f13c8bb837bf..660566d03f5aa 100644 --- a/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx +++ b/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx @@ -57,7 +57,9 @@ export default function RealtimeLimitsEstimater({}) { return (
-

Set your expected parameters

+ + Set your expected parameters +
@@ -107,7 +109,9 @@ export default function RealtimeLimitsEstimater({}) { {limits && (
-

Current maximum possible throughput

+ + Current maximum possible throughput +

@@ -158,7 +162,9 @@ export default function RealtimeLimitsEstimater({}) { .filter((v, i, a) => a.indexOf(v) === i) .map((computeAddOn) => (
-

{COMPUTE_LABELS[computeAddOn]}

+ + {COMPUTE_LABELS[computeAddOn]} +
diff --git a/apps/docs/content/_partials/social_provider_setup.mdx b/apps/docs/content/_partials/social_provider_setup.mdx index 399d31eda5707..aa87b887f57dc 100644 --- a/apps/docs/content/_partials/social_provider_setup.mdx +++ b/apps/docs/content/_partials/social_provider_setup.mdx @@ -5,9 +5,7 @@ The next step requires a callback URL, which looks like this: `https:// - -#### Local development +### Local development When testing OAuth locally with the Supabase CLI, ensure your OAuth provider is configured with the local Supabase Auth callback URL: @@ -19,5 +17,3 @@ If this callback URL is missing or misconfigured, OAuth sign-in may fail or not See the [local development docs](/docs/guides/local-development) for more details. For testing OAuth locally with the Supabase CLI see the [local development docs](/docs/guides/local-development). - - diff --git a/apps/docs/content/guides/auth/quickstarts/astrojs.mdx b/apps/docs/content/guides/auth/quickstarts/astrojs.mdx index 1baa7521c4394..c43992fc6ce0d 100644 --- a/apps/docs/content/guides/auth/quickstarts/astrojs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/astrojs.mdx @@ -5,6 +5,8 @@ breadcrumb: 'Auth Quickstarts' hideToc: true --- +## Quickstart + diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx index 30912b80e7e9f..eb56a6c7710a7 100644 --- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx @@ -5,6 +5,8 @@ breadcrumb: 'Auth Quickstarts' hideToc: true --- +## Quickstart + diff --git a/apps/docs/content/guides/auth/quickstarts/react-native.mdx b/apps/docs/content/guides/auth/quickstarts/react-native.mdx index d8ed113a8840c..46c2c79c3395e 100644 --- a/apps/docs/content/guides/auth/quickstarts/react-native.mdx +++ b/apps/docs/content/guides/auth/quickstarts/react-native.mdx @@ -5,6 +5,8 @@ breadcrumb: 'Auth Quickstarts' hideToc: true --- +## Quickstart + diff --git a/apps/docs/content/guides/auth/quickstarts/react.mdx b/apps/docs/content/guides/auth/quickstarts/react.mdx index 63cc239e11740..f624e8d03eced 100644 --- a/apps/docs/content/guides/auth/quickstarts/react.mdx +++ b/apps/docs/content/guides/auth/quickstarts/react.mdx @@ -5,6 +5,8 @@ breadcrumb: 'Auth Quickstarts' hideToc: true --- +## Quickstart + diff --git a/apps/docs/content/guides/database/functions.mdx b/apps/docs/content/guides/database/functions.mdx index e02dfed95a62d..f7a3520362096 100644 --- a/apps/docs/content/guides/database/functions.mdx +++ b/apps/docs/content/guides/database/functions.mdx @@ -164,7 +164,7 @@ For example, if we had a database with some Star Wars data inside: > -

Planets

+### Planets ``` | id | name | @@ -174,7 +174,7 @@ For example, if we had a database with some Star Wars data inside: | 3 | Kashyyyk | ``` -

People

+### People ``` | id | name | planet_id | diff --git a/apps/docs/content/guides/integrations/vercel-marketplace.mdx b/apps/docs/content/guides/integrations/vercel-marketplace.mdx index ac3c21bbf610e..26c4be43088f0 100644 --- a/apps/docs/content/guides/integrations/vercel-marketplace.mdx +++ b/apps/docs/content/guides/integrations/vercel-marketplace.mdx @@ -23,7 +23,7 @@ Vercel Marketplace is currently in Public Alpha. If you encounter any issues or ### Via template
-
Deploy a Next.js app with Supabase Vercel Storage now
+

Deploy a Next.js app with Supabase Vercel Storage now

Uses the Next.js Supabase Starter Template

Deploy with Vercel diff --git a/packages/ui-patterns/src/SqlToRest/sql-to-rest.tsx b/packages/ui-patterns/src/SqlToRest/sql-to-rest.tsx index 23b57101cf086..b41ba063678d5 100644 --- a/packages/ui-patterns/src/SqlToRest/sql-to-rest.tsx +++ b/packages/ui-patterns/src/SqlToRest/sql-to-rest.tsx @@ -427,10 +427,12 @@ export default function SqlToRest({ > {relevantAssumptions.length > 0 && (
-

Assumptions

+ + Assumptions +
    {relevantAssumptions.map((assumption) => ( -
  1. +
  2. {assumption}
  3. ))} @@ -440,7 +442,9 @@ export default function SqlToRest({ {relevantFaqs.length > 0 && ( <> -

    FAQs

    + + FAQs + {relevantFaqs.map((faq) => ( -
    +
    , diff --git a/packages/ui/src/components/shadcn/ui/accordion.tsx b/packages/ui/src/components/shadcn/ui/accordion.tsx index ee0f3c73b7afe..0585a052ac567 100644 --- a/packages/ui/src/components/shadcn/ui/accordion.tsx +++ b/packages/ui/src/components/shadcn/ui/accordion.tsx @@ -24,31 +24,33 @@ const AccordionTrigger = React.forwardRef< const computedTabIndex = getExplicitTabIndex(tabIndex, disabled) return ( - - svg]:rotate-180', - className - )} - {...props} - disabled={disabled} - tabIndex={computedTabIndex} - > - {children} - {!hideIcon && ( - + +
    + svg]:rotate-180', + className + )} + {...props} + disabled={disabled} + tabIndex={computedTabIndex} + > + {children} + {!hideIcon && ( + +
    ) })