From 7bfc45cc7b435e07f154719ed5cb82a34d67d444 Mon Sep 17 00:00:00 2001 From: Kostas Botsas Date: Fri, 14 Aug 2026 10:38:21 +0300 Subject: [PATCH 1/5] Update pg_net schema (#48694) ## 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? Docs update ## What is the current behavior? The create extension snippet defaults to public which trips the Security Advisor check "0014_extension_in_public". The extension either way creates its own "net" schema. ## What is the new behavior? Register pg_net in the extensions schema. This is also the default when installing the extension from the dashboard. image ## Additional context When no schema is specified, defaults to public which trips the Security Advisor check: image ## Summary by CodeRabbit * **Documentation** * Updated the pg_net installation example to place the extension in the `extensions` schema. * Clarified that this configuration keeps pg_net out of `public` and satisfies the Security Advisor check. --- apps/docs/content/guides/database/extensions/pg_net.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/guides/database/extensions/pg_net.mdx b/apps/docs/content/guides/database/extensions/pg_net.mdx index 7d0a3a399c9..4897f21c5d7 100644 --- a/apps/docs/content/guides/database/extensions/pg_net.mdx +++ b/apps/docs/content/guides/database/extensions/pg_net.mdx @@ -34,8 +34,8 @@ It eliminates the need for servers to continuously poll for database changes and ```sql -- Example: enable the "pg_net" extension. -create extension pg_net; --- Note: The extension creates its own schema/namespace named "net" to avoid naming conflicts. +create extension pg_net with schema "extensions"; +-- Note: The extension creates its own schema/namespace named "net" to avoid naming conflicts. Registering it in the extensions schema avoids exposing it in public and satisfies the Security Advisor check. -- Example: disable the "pg_net" extension drop extension if exists pg_net; From 0ed49231b781d92a78dd09847b7706a75a29a1f9 Mon Sep 17 00:00:00 2001 From: Charis Date: Fri, 14 Aug 2026 04:09:29 -0400 Subject: [PATCH 2/5] refactor(studio): unify CellSource and the SQL editor's QuerySource into QuerySourceBinding (#49072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of a stack. **Stacked on #49070** (which is stacked on #49069) — review those first. Base retargets automatically as each merges. Mechanical throughout; no behavior change. ## The problem Three types described where a query runs, and no two agreed: | | shape | |---|---| | `CellSource` (registry) | `{ id, type, parameters: { … } }` — `id` and `type` always held the same literal | | `QuerySource` (SQL editor) | `{ type: 'database' } \| { type: 'logs', dateRange }` | | notebook cells | flat per-backend fields, neither of the above | Anything crossing between them needed a translation that dropped fields on the way — which is how a notebook cell's replica selection had nowhere to go. ## What changed One `QuerySourceBinding`: a backend `_tag` with that backend's parameters spread flat beside it, borrowed from the wire schema (#49069) so the binding and the persisted cell agree by construction. - **`QuerySource` is deleted.** `useRunSource` returns the shared binding, so `runSource.type`/`dateRange` become `_tag`/`time_range` across the SQL editor — that is most of the file count here. - **`getQuerySourceBinding`** projects a notebook cell onto a binding; **`toQuerySourceBinding`** does the same for any backend-tagged carrier. Both overloaded so an already-narrowed caller gets the matching binding back rather than the union, which keeps the result spreadable without re-narrowing. - **`ExplorerQuerySourceMenu`** drops its inline copy of the custom-range and upgrade-prompt logic in favor of `useLogsCustomRange`, which the SQL editor menu already used. The registry keeps only what is genuinely runtime: endpoints, labels, icons, availability, defaults. What a query *is* stays in the wire schema. ## Verification Typecheck, Prettier, and the lint ratchet clean. 405 tests pass across the notebook schema, query sources, the logs components, the SQL editor, and the Explorer surfaces. ## Summary by CodeRabbit * **Improvements** * Updated query source handling across Explorer and SQL Editor for a more consistent selection experience. * Database and log sources now preserve identifiers and time ranges more reliably when switching or editing queries. * Source menus, labels, icons, validation, and query execution now reflect the selected source more accurately. * **Bug Fixes** * Invalid or outdated saved source settings now safely fall back to a database source. * Improved log-source detection and time-range handling throughout query editing and execution. --------- Co-authored-by: Joshen Lim --- .../Explorer/ExplorerQuerySourceMenu.test.tsx | 31 ++-- .../Explorer/ExplorerQuerySourceMenu.tsx | 63 +++---- .../interfaces/Explorer/QueryCell/index.tsx | 32 ++-- .../interfaces/Explorer/QueryEditor.tsx | 16 +- .../interfaces/Explorer/QueryTab.test.tsx | 25 +-- .../LogsTimeRangeSubMenu.test.tsx | 2 +- .../QuerySources/QuerySourceIcon.tsx | 4 +- .../SQLEditor/LegacyLogsRewriteBanner.tsx | 4 +- .../SQLEditor/SQLEditorControllers.tsx | 8 +- .../SQLEditor/SQLEditorEditorPanel.tsx | 4 +- .../interfaces/SQLEditor/SQLEditorLayout.tsx | 4 +- .../QuerySourceMenu/QuerySourceMenu.tsx | 28 +-- .../SQLEditor/UtilityPanel/UtilityActions.tsx | 6 +- .../interfaces/SQLEditor/querySource.ts | 13 +- .../SQLEditor/useRunSource.test.tsx | 8 +- .../interfaces/SQLEditor/useRunSource.ts | 15 +- .../query-source-registry.test.ts | 138 +++++++++------ .../query-sources/query-source-registry.ts | 165 ++++++++++++------ apps/studio/state/explorer-query.test.ts | 24 +-- apps/studio/state/explorer-query.ts | 24 +-- .../SQLEditor/QuerySourceMenu.test.tsx | 6 +- 21 files changed, 333 insertions(+), 287 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx index 4ab83597eb5..0f6ed7b2be2 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx @@ -54,9 +54,8 @@ describe('ExplorerQuerySourceMenu', () => { customRender( @@ -65,11 +64,7 @@ describe('ExplorerQuerySourceMenu', () => { await userEvent.click(screen.getByRole('button', { name: 'Query source: Logs' })) await userEvent.click(screen.getByText('Database')) - expect(onSourceChange).toHaveBeenCalledWith({ - id: 'database', - type: 'database', - parameters: {}, - }) + expect(onSourceChange).toHaveBeenCalledWith({ _tag: 'database' }) }) it('emits the selected log time range as source parameters', async () => { @@ -78,9 +73,8 @@ describe('ExplorerQuerySourceMenu', () => { customRender( @@ -91,17 +85,13 @@ describe('ExplorerQuerySourceMenu', () => { await userEvent.click(await screen.findByText('Last 3 hours')) expect(onSourceChange).toHaveBeenCalledWith({ - id: 'logs', - type: 'logs', - parameters: { time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' } }, + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' }, }) }) it('does not offer logs when source flags are disabled for a database query', async () => { - renderWithFlags( - { id: 'database', type: 'database', parameters: {} }, - { sqlEditorLogsSource: false, otelLegacyLogs: false } - ) + renderWithFlags({ _tag: 'database' }, { sqlEditorLogsSource: false, otelLegacyLogs: false }) await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' })) @@ -111,9 +101,8 @@ describe('ExplorerQuerySourceMenu', () => { it('keeps logs available when an existing query already uses it', async () => { renderWithFlags( { - id: 'logs', - type: 'logs', - parameters: { time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' } }, + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' }, }, { sqlEditorLogsSource: false, otelLegacyLogs: false } ) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx index eaccc9a7257..534de0c0e6c 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx @@ -16,21 +16,25 @@ import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourc import { useLogsCustomRange } from '@/components/interfaces/QuerySources/useLogsCustomRange' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' import { - createDefaultCellSource, + createDefaultSourceBinding, QUERY_SOURCE_LABELS, QUERY_SOURCES, - type CellSource, + type QuerySourceBinding, } from '@/data/query-sources/query-source-registry' export type ExplorerQuerySourceMenuProps = { - source: CellSource - onSourceChange: (source: CellSource) => void + source: QuerySourceBinding + onSourceChange: (source: QuerySourceBinding) => void } /** * Source binding and parameter controls shared by standalone Explorer queries * and notebook query-cell toolbars. The consumer owns the binding; this menu - * only emits complete, validated-by-construction `CellSource` values. + * only emits complete, validated-by-construction `QuerySourceBinding` values. + * + * Selecting a different backend emits that backend's default binding — deciding + * what happens to the query body is the consumer's call, since a notebook cell + * has SQL to preserve or discard and a fresh draft does not. */ export const ExplorerQuerySourceMenu = ({ source, @@ -46,15 +50,14 @@ export const ExplorerQuerySourceMenu = ({ setShowUpgradePrompt, handleApplyCustomRange, } = useLogsCustomRange({ - onRangeChange: (timeRange) => - onSourceChange({ id: 'logs', type: 'logs', parameters: { time_range: timeRange } }), + onRangeChange: (time_range) => onSourceChange({ _tag: 'logs', time_range }), }) const availableSources = QUERY_SOURCES.filter( (candidate) => - candidate.type !== 'logs' || + candidate._tag !== 'logs' || (isLogsSourceEnabled && isOtelLogsEnabled) || - source.type === 'logs' + source._tag === 'logs' ) return ( @@ -64,56 +67,46 @@ export const ExplorerQuerySourceMenu = ({ {availableSources.map((candidate) => ( { event.preventDefault() - if (candidate.id !== source.id) { - onSourceChange(createDefaultCellSource(candidate.id)) + if (candidate._tag !== source._tag) { + onSourceChange(createDefaultSourceBinding(candidate._tag)) } }} > - - {QUERY_SOURCE_LABELS[candidate.id]} + + {QUERY_SOURCE_LABELS[candidate._tag]} - {source.id === candidate.id && } + {source._tag === candidate._tag && } ))} - {source.type === 'database' ? ( + {source._tag === 'database' ? ( - onSourceChange({ - id: 'database', - type: 'database', - parameters: { identifier }, - }) + identifier={source.database_identifier ?? ref} + onIdentifierChange={(database_identifier) => + onSourceChange({ _tag: 'database', database_identifier }) } /> ) : ( - onSourceChange({ - id: 'logs', - type: 'logs', - parameters: { time_range: timeRange }, - }) - } + range={source.time_range} + onRangeChange={(time_range) => onSourceChange({ _tag: 'logs', time_range })} onOpenCustomRange={() => setIsCustomRangeOpen(true)} onShowUpgrade={() => setShowUpgradePrompt(true)} /> @@ -121,7 +114,7 @@ export const ExplorerQuerySourceMenu = ({ - {source.type === 'logs' && ( + {source._tag === 'logs' && ( <> { const { id, title: cellTitle, view, chart, unchecked_sql } = cell const rowLimit = 'row_limit' in cell ? cell.row_limit : undefined - const source = - cell._tag === 'database_cell' - ? createDefaultCellSource('database') - : createDefaultCellSource('logs') + const source = getQuerySourceBinding(cell) const [sql, setSql] = useState(unchecked_sql) const [result, setResult] = useState() @@ -53,7 +50,7 @@ export const QueryCell = ({ cell }: QueryCellProps) => { chart: chart ? { ...chart, y_columns: [...chart.y_columns] } : undefined, } - const handleSourceChange = (source: CellSource) => { + const handleSourceChange = (source: QuerySourceBinding) => { const notebookId = currentNotebook?.notebook.id if (!notebookId) return @@ -61,30 +58,35 @@ export const QueryCell = ({ cell }: QueryCellProps) => { id: notebookId, cellId: id, updater: (candidate) => { - if (source.type === 'database' && candidate._tag === 'log_cell') { + if (source._tag === 'database' && candidate._tag === 'log_cell') { const { _tag, time_range, unchecked_sql, ...rest } = candidate return { ...rest, _tag: 'database_cell' as const, row_limit: 100, + database_identifier: source.database_identifier, unchecked_sql: untrustedSql(unchecked_sql), } } - if (source.type === 'logs' && candidate._tag === 'database_cell') { - const { _tag, row_limit, unchecked_sql, ...rest } = candidate + if (source._tag === 'logs' && candidate._tag === 'database_cell') { + const { _tag, row_limit, database_identifier, unchecked_sql, ...rest } = candidate return { ...rest, _tag: 'log_cell' as const, - time_range: { - _tag: 'relative_time_range' as const, - unit: 'hour' as const, - amount: 1, - }, + time_range: source.time_range, unchecked_sql: untrustedLogSql(unchecked_sql), } } + if (source._tag === 'database' && candidate._tag === 'database_cell') { + return { ...candidate, database_identifier: source.database_identifier } + } + + if (source._tag === 'logs' && candidate._tag === 'log_cell') { + return { ...candidate, time_range: source.time_range } + } + return candidate }, }) diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx index 1a134b9dd7c..fba1aaa5503 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -29,9 +29,9 @@ import { isValidConnString } from '@/data/fetchers' import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql' import { - createDefaultCellSource, + createDefaultSourceBinding, QUERY_SOURCE_REGISTRY, - type CellSource, + type QuerySourceBinding, } from '@/data/query-sources/query-source-registry' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' @@ -44,7 +44,7 @@ export type QueryEditorProps = { variant: 'embedded' | 'viewport' title: string sql: string - source?: CellSource + source?: QuerySourceBinding result?: QueryResult rowLimit?: number display?: QueryDisplay @@ -52,7 +52,7 @@ export type QueryEditorProps = { onTitleChange: (title: string) => void onSqlChange: (sql: string) => void onSqlCommit?: (sql: string) => void - onSourceChange?: (source: CellSource) => void + onSourceChange?: (source: QuerySourceBinding) => void onResultChange: (result: QueryResult) => void onDisplayChange?: (display: QueryDisplay) => void } @@ -87,12 +87,12 @@ export const QueryEditor = ({ const view = display?.view ?? 'table' const columns = Object.keys(result?.rows?.[0] ?? {}) - const sourceBinding = source ?? createDefaultCellSource('database') + const sourceBinding = source ?? createDefaultSourceBinding('database') const [showQuery, setShowQuery] = useState(true) const databaseIdentifier = - sourceBinding.type === 'database' ? sourceBinding.parameters.identifier : undefined + sourceBinding._tag === 'database' ? sourceBinding.database_identifier : undefined const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery( { projectRef: project?.ref }, @@ -124,7 +124,7 @@ export const QueryEditor = ({ onSqlCommit?.(sql) - if (sourceBinding.type === 'logs') { + if (sourceBinding._tag === 'logs') { if (!isOtelLogsEnabled) { onResultChange({ error: { message: "Querying logs isn't available for this project yet." }, @@ -135,7 +135,7 @@ export const QueryEditor = ({ executeLogsSql({ projectRef: project.ref, sql: acceptUntrustedLogsSql(untrustedLogSql(sqlToRun)), - range: resolveLogTimeRange(sourceBinding.parameters.time_range), + range: resolveLogTimeRange(sourceBinding.time_range), endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint, }) return diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx index aa791710436..61c303017ad 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx @@ -43,13 +43,10 @@ const renderQueryTab = () => const createDraft = ( source: - | { id: 'database'; type: 'database'; parameters: { identifier?: string } } + | { _tag: 'database'; database_identifier?: string } | { - id: 'logs' - type: 'logs' - parameters: { - time_range: { _tag: 'relative_time_range'; amount: number; unit: 'hour' } - } + _tag: 'logs' + time_range: { _tag: 'relative_time_range'; amount: number; unit: 'hour' } } ) => { explorerQueryState.removeDraft({ id: 'query-test', projectRef: 'default' }) @@ -83,9 +80,8 @@ describe('QueryTab execution', () => { it('records an unavailable error and skips the logs endpoint when the flag is off', async () => { testContext.flags.otelLegacyLogs = false createDraft({ - id: 'logs', - type: 'logs', - parameters: { time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' } }, + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' }, }) const requests: Request[] = [] addAPIMock({ @@ -109,11 +105,7 @@ describe('QueryTab execution', () => { }) it('waits for replicas, then fails closed when the selected database is absent', async () => { - createDraft({ - id: 'database', - type: 'database', - parameters: { identifier: 'missing-replica' }, - }) + createDraft({ _tag: 'database', database_identifier: 'missing-replica' }) let releaseReplicas: () => void = () => undefined const replicasPending = new Promise((resolve) => { releaseReplicas = resolve @@ -152,9 +144,8 @@ describe('QueryTab execution', () => { it('resolves a relative logs range before sending the request', async () => { createDraft({ - id: 'logs', - type: 'logs', - parameters: { time_range: { _tag: 'relative_time_range', amount: 2, unit: 'hour' } }, + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 2, unit: 'hour' }, }) const bodies: Array<{ iso_timestamp_start: string; iso_timestamp_end: string }> = [] addAPIMock({ diff --git a/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx index d1acdc39df3..78d5a196a8b 100644 --- a/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx +++ b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx @@ -18,7 +18,7 @@ const renderSubMenu = ({ onRangeChange = vi.fn<(range: TimeRange) => void>(), onOpenCustomRange = vi.fn<() => void>(), onShowUpgrade = vi.fn<() => void>(), - range = { _tag: 'relative_time_range', amount: 1, unit: 'hour' } as TimeRange, + range = { _tag: 'relative_time_range', amount: 1, unit: 'hour' }, }: { onRangeChange?: Mock<(range: TimeRange) => void> onOpenCustomRange?: Mock<() => void> diff --git a/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx b/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx index a1d635866ba..1ecc039e09d 100644 --- a/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx +++ b/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx @@ -1,12 +1,12 @@ import { Database, ScrollText } from 'lucide-react' -import type { QuerySourceId } from '@/data/query-sources/query-source-registry' +import type { QuerySourceTag } from '@/data/query-sources/query-source-registry' export const QuerySourceIcon = ({ source, className, }: { - source: QuerySourceId + source: QuerySourceTag className?: string }) => { const props = { className, size: 16, strokeWidth: 2 } diff --git a/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx b/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx index 9bb41fe5430..84a5b1152bd 100644 --- a/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx +++ b/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx @@ -49,9 +49,9 @@ export const LegacyLogsRewriteBanner = () => { const isLogsSnippetNeedingRewrite = useMemo( () => - runSource.type === 'logs' && + runSource._tag === 'logs' && shouldOfferLegacyLogsRewrite({ sql: settledSql, isClickhouseLogsEnabled: isOtelLogsEnabled }), - [runSource.type, settledSql, isOtelLogsEnabled] + [runSource._tag, settledSql, isOtelLogsEnabled] ) const { state, requestRewrite, dismiss } = useLegacyLogsRewrite({ diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx index f4738c230a9..e73d167d803 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx @@ -15,7 +15,6 @@ import { } from 'react' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' -import { type QuerySource } from './querySource' import type { UtilityTab } from './SQLEditor.types' import { useSQLEditorContext } from './SQLEditorContext' import { useAddDefinitions } from './useAddDefinitions' @@ -34,6 +33,7 @@ import { type SafeLogSqlFragment, type UntrustedLogSqlFragment, } from '@/data/logs/safe-analytics-sql' +import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' @@ -81,7 +81,7 @@ type RunContextValue = { potentialIssues: SqlEditorExecution['potentialIssues'] resetPotentialIssues: () => void prettifyQuery: () => void - runSource: QuerySource + runSource: QuerySourceBinding executeLogsQuery: (sql: SafeLogSqlFragment) => void readEditorLogsSql: () => UntrustedLogSqlFragment | undefined } @@ -144,7 +144,7 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => const runSource = useRunSource(id) - useAddDefinitions(id, monacoRef.current, { enabled: runSource.type !== 'logs' }) + useAddDefinitions(id, monacoRef.current, { enabled: runSource._tag !== 'logs' }) const { data: databases, isSuccess: isSuccessReadReplicas } = useReadReplicasQuery( { @@ -191,7 +191,7 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => const isExecuting = isExecutingDb || isExecutingLogs - const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt, sqlSource: runSource.type }) + const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt, sqlSource: runSource._tag }) const { acceptAiHandler, discardAiHandler } = ai useSqlEditorShortcuts({ diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx index 6215f6580d6..b42c6b97785 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx @@ -140,14 +140,14 @@ const SQLEditorMainView = () => { // Run gesture from the editor — promote here, at the user action. const runQuery = useCallback(() => { - if (runSource.type === 'logs') { + if (runSource._tag === 'logs') { const sql = readEditorLogsSql() if (sql !== undefined) void executeLogsQuery(acceptUntrustedLogsSql(sql)) } else { const sql = readEditorSql() if (sql !== undefined) void executeQuery(acceptUntrustedSql(sql)) } - }, [executeLogsQuery, executeQuery, readEditorLogsSql, readEditorSql, runSource.type]) + }, [executeLogsQuery, executeQuery, readEditorLogsSql, readEditorSql, runSource._tag]) return (
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx index f229f200768..e3a3c0d6253 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx @@ -73,7 +73,7 @@ const SQLEditorToolbar = () => { // Run gesture from the toolbar button — promote here, at the user action. const runQuery = useCallback(() => { markRefocusAfterRun() - if (runSource.type === 'logs') { + if (runSource._tag === 'logs') { const sql = readEditorLogsSql() if (sql === undefined) return clearPendingRunRefocus() void executeLogsQuery(acceptUntrustedLogsSql(sql)) @@ -89,7 +89,7 @@ const SQLEditorToolbar = () => { markRefocusAfterRun, readEditorLogsSql, readEditorSql, - runSource.type, + runSource._tag, ]) return ( diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx index d741dc3535e..2bb4f249667 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx @@ -10,7 +10,7 @@ import { DropdownMenuTrigger, } from 'ui' -import { type QuerySource, type SqlSnippetSource } from '../../querySource' +import { type SqlSnippetSource } from '../../querySource' import { resolveSourceSwitch } from './QuerySourceMenu.utils' import { RowLimitSubMenu } from './RowLimitSubMenu' import { RunAsSubMenu } from './RunAsSubMenu' @@ -20,7 +20,11 @@ import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsT import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourceIcon' import { useLogsCustomRange } from '@/components/interfaces/QuerySources/useLogsCustomRange' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import { QUERY_SOURCE_LABELS, QUERY_SOURCES } from '@/data/query-sources/query-source-registry' +import { + QUERY_SOURCE_LABELS, + QUERY_SOURCES, + type QuerySourceBinding, +} from '@/data/query-sources/query-source-registry' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { IS_PLATFORM } from '@/lib/constants' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' @@ -29,7 +33,7 @@ import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state type QuerySourceMenuProps = { id: string - runSource: QuerySource + runSource: QuerySourceBinding /** Whether creating a logs snippet is available (feature-flagged). */ canCreateLogsSnippet: boolean } @@ -70,7 +74,7 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo handleApplyCustomRange, } = useLogsCustomRange({ onRangeChange: (range) => sessionSnap.setLogRange(id, range) }) - const currentSource = runSource.type + const currentSource = runSource._tag const isLogs = currentSource === 'logs' // A snippet materializes in the store on its first keystroke; until then a // `/sql/new` tab is a blank scaffold with nothing to preserve. @@ -81,7 +85,7 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo : (databaseSelector.selectedDatabaseId ?? ref) const selectableSources = QUERY_SOURCES.filter( - (source) => source.type !== 'logs' || canCreateLogsSnippet || isLogs + (source) => source._tag !== 'logs' || canCreateLogsSnippet || isLogs ) const switchSource = (target: SqlSnippetSource) => { @@ -112,26 +116,26 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo {selectableSources.map((source) => ( { e.preventDefault() - switchSource(source.id) + switchSource(source._tag) }} > - - {QUERY_SOURCE_LABELS[source.id]} + + {QUERY_SOURCE_LABELS[source._tag]} - {currentSource === source.id && } + {currentSource === source._tag && } ))} - {runSource.type === 'logs' ? ( + {runSource._tag === 'logs' ? ( sessionSnap.setLogRange(id, range)} onOpenCustomRange={() => setIsCustomRangeOpen(true)} onShowUpgrade={() => setShowUpgradePrompt(true)} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 1803004efee..283832f11b7 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -18,7 +18,6 @@ import { TooltipTrigger, } from 'ui' -import { type QuerySource } from '../querySource' import { ROWS_PER_PAGE_OPTIONS } from '../SQLEditor.constants' import { AutosaveStatus } from './AutosaveStatus' import { QuerySourceMenu } from './QuerySourceMenu/QuerySourceMenu' @@ -30,6 +29,7 @@ import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonat import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DatabaseSelector } from '@/components/ui/DatabaseSelector' import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' +import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { IS_PLATFORM } from '@/lib/constants' import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' @@ -39,7 +39,7 @@ import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state export type UtilityActionsProps = { id: string - runSource: QuerySource + runSource: QuerySourceBinding isExecuting?: boolean isDisabled?: boolean hasSelection?: boolean @@ -66,7 +66,7 @@ export const UtilityActions = ({ const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') const isOtelLogsEnabled = useFlag('otelLegacyLogs') - const isLogs = runSource.type === 'logs' + const isLogs = runSource._tag === 'logs' const canCreateLogsSnippet = isLogsSourceEnabled && isOtelLogsEnabled const canShowSourceIndicator = isLogs || canCreateLogsSnippet const isLogsRunBlocked = isLogs && !isOtelLogsEnabled diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.ts b/apps/studio/components/interfaces/SQLEditor/querySource.ts index eeb66ad1374..db26f8f9bb1 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.ts @@ -1,6 +1,5 @@ -import { type TimeRange } from '@/data/content/notebooks/notebook-schema' import type { Snippet } from '@/data/content/sql-folders-query' -import { type QuerySourceId } from '@/data/query-sources/query-source-registry' +import { type QuerySourceTag } from '@/data/query-sources/query-source-registry' /** * Domain view of where a snippet's query runs. Derived from the content TYPE: @@ -9,7 +8,7 @@ import { type QuerySourceId } from '@/data/query-sources/query-source-registry' * immutable — switching backends means creating a new snippet, not toggling this * value. */ -export type SqlSnippetSource = QuerySourceId +export type SqlSnippetSource = QuerySourceTag /** * The single reader every surface (AI, reports, tabs, nav, execution) uses to @@ -55,11 +54,3 @@ export function resolveSnippetSource( ): SqlSnippetSource { return snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam) } - -/** - * The runtime query source for a snippet, pairing the database/logs discriminant - * with the extra state each backend needs to run. A logs run carries the active - * time range (session state, re-resolved at every run); a database run needs - * nothing beyond the connection the execution pipeline already resolves. - */ -export type QuerySource = { type: 'database' } | { type: 'logs'; dateRange: TimeRange } diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx index a507d85e868..ea76e8e2822 100644 --- a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx @@ -22,7 +22,7 @@ describe('useRunSource', () => { const { result } = renderSqlEditorHook(() => useRunSource(id)) - expect(result.current).toEqual({ type: 'database' }) + expect(result.current).toEqual({ _tag: 'database' }) }) it('resolves a logs snippet with no session range to the default range', () => { @@ -31,7 +31,7 @@ describe('useRunSource', () => { const { result } = renderSqlEditorHook(() => useRunSource(id)) - expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_TIME_RANGE }) + expect(result.current).toEqual({ _tag: 'logs', time_range: DEFAULT_LOG_TIME_RANGE }) }) it('resolves a logs snippet to its session-stored range when one is set', () => { @@ -42,8 +42,8 @@ describe('useRunSource', () => { const { result } = renderSqlEditorHook(() => useRunSource(id)) expect(result.current).toEqual({ - type: 'logs', - dateRange: { _tag: 'relative_time_range', amount: 2, unit: 'hour' }, + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 2, unit: 'hour' }, }) }) }) diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts index b9362d48279..4d9ab388cf4 100644 --- a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts @@ -1,8 +1,11 @@ import { useParams } from 'common' import { useMemo } from 'react' -import { isLogsSource, resolveSnippetSource, type QuerySource } from './querySource' -import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry' +import { isLogsSource, resolveSnippetSource } from './querySource' +import { + DEFAULT_LOG_TIME_RANGE, + type QuerySourceBinding, +} from '@/data/query-sources/query-source-registry' import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' @@ -18,7 +21,7 @@ import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state * param, so a "New logs query" tab shows the logs toolbar and range picker * immediately rather than flashing the database controls until the user types. */ -export function useRunSource(id: string): QuerySource { +export function useRunSource(id: string): QuerySourceBinding { const { source: sourceParam } = useParams() const snapV2 = useSqlEditorV2StateSnapshot() const sessionSnap = useSqlEditorSessionSnapshot() @@ -27,10 +30,10 @@ export function useRunSource(id: string): QuerySource { const source = resolveSnippetSource(snippet, sourceParam) const logRange = sessionSnap.logRange[id] - return useMemo(() => { + return useMemo(() => { if (isLogsSource(source)) { - return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_TIME_RANGE } + return { _tag: 'logs', time_range: logRange ?? DEFAULT_LOG_TIME_RANGE } } - return { type: 'database' } + return { _tag: 'database' } }, [source, logRange]) } diff --git a/apps/studio/data/query-sources/query-source-registry.test.ts b/apps/studio/data/query-sources/query-source-registry.test.ts index dc09b62cccf..cf9ac7082c1 100644 --- a/apps/studio/data/query-sources/query-source-registry.test.ts +++ b/apps/studio/data/query-sources/query-source-registry.test.ts @@ -1,79 +1,109 @@ import { describe, expect, it } from 'vitest' import { - cellSourceSchema, - createDefaultCellSource, + createDefaultSourceBinding, getQuerySource, + getQuerySourceBinding, QUERY_SOURCES, + querySourceBindingSchema, + toQuerySourceBinding, } from './query-source-registry' +import { timeRangeSchema } from '@/data/content/notebooks/notebook-schema' describe('query source registry', () => { it('registers database and logs sources with their execution endpoints', () => { - expect(QUERY_SOURCES.map(({ id }) => id)).toEqual(['database', 'logs']) + expect(QUERY_SOURCES.map(({ _tag }) => _tag)).toEqual(['database', 'logs']) expect(getQuerySource('database').endpoint).toBe('/platform/pg-meta/{ref}/query') expect(getQuerySource('logs').endpoint).toBe( '/platform/projects/{ref}/analytics/endpoints/logs.all.otel' ) }) - it('creates independent, valid default cell bindings', () => { - const first = createDefaultCellSource('logs') - const second = createDefaultCellSource('logs') + it('creates independent, valid default bindings', () => { + const first = createDefaultSourceBinding('logs') + const second = createDefaultSourceBinding('logs') - expect(cellSourceSchema.parse(first)).toEqual({ - id: 'logs', - type: 'logs', - parameters: { + expect(querySourceBindingSchema.parse(first)).toEqual({ + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' }, + }) + expect(first.time_range).not.toBe(second.time_range) + expect(querySourceBindingSchema.parse(createDefaultSourceBinding('database'))).toEqual({ + _tag: 'database', + }) + }) + + it('rejects parameters that do not match the selected source', () => { + expect(() => + querySourceBindingSchema.parse({ _tag: 'logs', database_identifier: 'replica-1' }) + ).toThrow() + + expect(() => + querySourceBindingSchema.parse({ + _tag: 'database', time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' }, - }, - }) - expect(first.parameters.time_range).not.toBe(second.parameters.time_range) - expect(cellSourceSchema.parse(createDefaultCellSource('database'))).toEqual({ - id: 'database', - type: 'database', - parameters: {}, - }) - }) - - it('rejects parameters that do not match the selected source type', () => { - expect(() => - cellSourceSchema.parse({ - id: 'logs', - type: 'logs', - parameters: { identifier: 'replica-1' }, }) ).toThrow() expect(() => - cellSourceSchema.parse({ - id: 'database', - type: 'database', - parameters: { time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' } }, - }) - ).toThrow() - - expect(() => - cellSourceSchema.parse({ - id: 'logs', - type: 'logs', - parameters: { time_range: { _tag: 'relative_time_range', amount: 2, unit: 'fortnight' } }, - }) - ).toThrow() - }) - - it('refuses a cell source carrying an invalid absolute range', () => { - expect(() => - cellSourceSchema.parse({ - id: 'logs', - type: 'logs', - parameters: { - time_range: { - _tag: 'absolute_time_range', - start: '2025-01-02T00:00:00.000Z', - end: '2025-01-01T00:00:00.000Z', - }, - }, + querySourceBindingSchema.parse({ + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 2, unit: 'fortnight' }, }) ).toThrow() }) }) + +describe('getQuerySourceBinding', () => { + it('projects a database cell onto its binding', () => { + expect( + getQuerySourceBinding({ _tag: 'database_cell', database_identifier: 'replica-1' }) + ).toEqual({ _tag: 'database', database_identifier: 'replica-1' }) + }) + + it('projects a log cell onto its binding', () => { + expect( + getQuerySourceBinding({ + _tag: 'log_cell', + time_range: { _tag: 'relative_time_range', unit: 'day', amount: 3 }, + }) + ).toEqual({ _tag: 'logs', time_range: { _tag: 'relative_time_range', unit: 'day', amount: 3 } }) + }) + + it('copies the time range rather than aliasing the cell it came from', () => { + const time_range = { _tag: 'relative_time_range', unit: 'hour', amount: 6 } as const + const binding = getQuerySourceBinding({ _tag: 'log_cell', time_range }) + + expect(binding).toEqual({ _tag: 'logs', time_range }) + if (binding._tag !== 'logs') throw new Error('expected a logs binding') + expect(binding.time_range).not.toBe(time_range) + }) + + it('accepts the coarser relative units the wire schema allows', () => { + expect( + getQuerySourceBinding({ + _tag: 'log_cell', + time_range: { _tag: 'relative_time_range', unit: 'month', amount: 2 }, + }) + ).toEqual({ + _tag: 'logs', + time_range: { _tag: 'relative_time_range', unit: 'month', amount: 2 }, + }) + }) +}) + +describe('toQuerySourceBinding', () => { + it('projects a backend-tagged carrier such as a query draft', () => { + expect(toQuerySourceBinding({ _tag: 'database', database_identifier: 'replica-1' })).toEqual({ + _tag: 'database', + database_identifier: 'replica-1', + }) + + const time_range = timeRangeSchema.parse({ + _tag: 'absolute_time_range', + start: '2025-01-01T00:00:00.000Z', + end: '2025-01-02T00:00:00.000Z', + }) + expect(toQuerySourceBinding({ _tag: 'logs', time_range })).toEqual({ _tag: 'logs', time_range }) + }) +}) diff --git a/apps/studio/data/query-sources/query-source-registry.ts b/apps/studio/data/query-sources/query-source-registry.ts index ce75a3bb742..5d1bbd35914 100644 --- a/apps/studio/data/query-sources/query-source-registry.ts +++ b/apps/studio/data/query-sources/query-source-registry.ts @@ -1,100 +1,153 @@ import * as z from 'zod' -import { timeRangeSchema, type TimeRange } from '@/data/content/notebooks/notebook-schema' +import { + databaseSourceSchema, + logsSourceSchema, + type DatabaseSourceParameters, + type LogsSourceParameters, + type TimeRange, +} from '@/data/content/notebooks/notebook-schema' import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' +/** + * The backend a query runs against. A closed set, not a runtime-extensible one: each tag + * is a distinct SQL dialect with its own escaping rules, wire boundary, and safe-SQL + * brand, so picking the wrong one is a security bug rather than a configuration error. + * What this registry *does* enumerate at runtime is everything downstream of that choice + * — endpoints, labels, icons, availability, and default parameters. + * + * The parameter shapes themselves live in the notebook wire schema + * (data/content/notebooks/notebook-schema.ts), which is the contract shared with the API + * and the agent tool surface; this module borrows them so there is exactly one definition. + */ +export type QuerySourceTag = 'database' | 'logs' + export type DatabaseSource = { - id: 'database' - type: 'database' + _tag: 'database' endpoint: '/platform/pg-meta/{ref}/query' - parameters: { - /** - * Query-owned database selection. The SQL editor still adapts its legacy - * global/local-storage selector into this shape; new consumers persist the - * identifier directly with their query. - */ - identifier?: string - } + parameters: DatabaseSourceParameters } export type LogsSource = { - id: 'logs' - type: 'logs' + _tag: 'logs' endpoint: ReturnType - parameters: { - time_range: TimeRange - } + parameters: LogsSourceParameters } -/** Sources are registered by Studio; query surfaces only store a source binding. */ export type Source = DatabaseSource | LogsSource -export type CellSourceOf = Pick +/** + * A query's source selection as the UI passes it around: the backend tag with that + * backend's parameters spread flat alongside it. Carriers (notebook cells, standalone + * Explorer query drafts) store these fields inline rather than under a `source` key; this + * type is the portable value the shared source menu reads and emits. + */ +export type QuerySourceBinding = + | ({ _tag: 'database' } & DatabaseSourceParameters) + | ({ _tag: 'logs' } & LogsSourceParameters) -export type CellSource = CellSourceOf | CellSourceOf +export const querySourceBindingSchema = z.discriminatedUnion('_tag', [ + z.object({ _tag: z.literal('database'), ...databaseSourceSchema.shape }).strict(), + z.object({ _tag: z.literal('logs'), ...logsSourceSchema.shape }).strict(), +]) export const DEFAULT_LOG_TIME_RANGE: TimeRange = { _tag: 'relative_time_range', - amount: 1, unit: 'hour', + amount: 1, } export const QUERY_SOURCE_REGISTRY = { database: { - id: 'database', - type: 'database', + _tag: 'database', endpoint: '/platform/pg-meta/{ref}/query', parameters: {}, }, logs: { - id: 'logs', - type: 'logs', + _tag: 'logs', endpoint: logsAllEndpointUrl(true), parameters: { time_range: DEFAULT_LOG_TIME_RANGE }, }, -} as const satisfies Record - -export type QuerySourceId = keyof typeof QUERY_SOURCE_REGISTRY +} as const satisfies Record export const QUERY_SOURCES = Object.values(QUERY_SOURCE_REGISTRY) satisfies Source[] -export const QUERY_SOURCE_LABELS: Record = { +export const QUERY_SOURCE_LABELS: Record = { database: 'Database', logs: 'Logs', } -export const cellSourceSchema = z.discriminatedUnion('type', [ - z - .object({ - id: z.literal('database'), - type: z.literal('database'), - parameters: z.object({ identifier: z.string().optional() }).strict(), - }) - .strict(), - z - .object({ - id: z.literal('logs'), - type: z.literal('logs'), - parameters: z.object({ time_range: timeRangeSchema }).strict(), - }) - .strict(), -]) +export const getQuerySource = (tag: QuerySourceTag): Source => QUERY_SOURCE_REGISTRY[tag] -export function createDefaultCellSource(id: 'database'): CellSourceOf -export function createDefaultCellSource(id: 'logs'): CellSourceOf -export function createDefaultCellSource(id: QuerySourceId): CellSource -export function createDefaultCellSource(id: QuerySourceId): CellSource { - const source = QUERY_SOURCE_REGISTRY[id] +/** Defensive copy so a valtio-proxied range never leaks into a freshly built binding. */ +const cloneTimeRange = (range: Readonly): TimeRange => + range._tag === 'relative_time_range' + ? { _tag: range._tag, unit: range.unit, amount: range.amount } + : { _tag: range._tag, start: range.start, end: range.end } - if (source.type === 'logs') { +export function createDefaultSourceBinding( + tag: 'database' +): { _tag: 'database' } & DatabaseSourceParameters +export function createDefaultSourceBinding(tag: 'logs'): { _tag: 'logs' } & LogsSourceParameters +export function createDefaultSourceBinding(tag: QuerySourceTag): QuerySourceBinding +export function createDefaultSourceBinding(tag: QuerySourceTag): QuerySourceBinding { + if (tag === 'logs') { return { - id: source.id, - type: source.type, - parameters: { time_range: { ...source.parameters.time_range } }, + _tag: 'logs', + time_range: cloneTimeRange(QUERY_SOURCE_REGISTRY.logs.parameters.time_range), } } - - return { id: source.id, type: source.type, parameters: { ...source.parameters } } + return { _tag: 'database' } } -export const getQuerySource = (id: QuerySourceId): Source => QUERY_SOURCE_REGISTRY[id] +export const getQuerySourceLabel = (tag: QuerySourceTag): string => QUERY_SOURCE_LABELS[tag] + +/** + * Source parameters as any carrier stores them: spread flat alongside a tag. Stated + * structurally, and readonly throughout, so one helper serves wire cells, domain cells + * (whose `sql` has been rebranded to `unchecked_sql`), standalone query drafts, and the + * deep-readonly `Snapshot` values valtio hands the UI. + */ +type SourceTagged = + | { readonly _tag: DatabaseTag; readonly database_identifier?: string } + | { readonly _tag: LogsTag; readonly time_range: Readonly } + +type DatabaseBinding = { _tag: 'database' } & DatabaseSourceParameters +type LogsBinding = { _tag: 'logs' } & LogsSourceParameters + +/** + * Projects any backend-tagged carrier onto the binding the shared source menu reads, so a + * caller never reaches into per-backend fields itself. Used by standalone query drafts and + * by the query editor's own model; notebook cells go through `getQuerySourceBinding`, + * which maps their cell tags first. + * + * Overloaded so a caller that has already narrowed its carrier gets the matching binding + * back rather than the whole union — that keeps the result spreadable into a narrowed + * result type without re-narrowing. + */ +export function toQuerySourceBinding(value: SourceTagged<'database', never>): DatabaseBinding +export function toQuerySourceBinding(value: SourceTagged): LogsBinding +export function toQuerySourceBinding(value: SourceTagged<'database', 'logs'>): QuerySourceBinding +export function toQuerySourceBinding(value: SourceTagged<'database', 'logs'>): QuerySourceBinding { + if (value._tag === 'logs') return { _tag: 'logs', time_range: cloneTimeRange(value.time_range) } + return { _tag: 'database', database_identifier: value.database_identifier } +} + +/** + * Projects a notebook query cell onto its source binding. The inverse — applying a binding + * back onto a cell — is `changeCellSource`, which additionally has to decide what happens + * to the SQL body when the backend changes. + */ +export function getQuerySourceBinding(cell: SourceTagged<'database_cell', never>): DatabaseBinding +export function getQuerySourceBinding(cell: SourceTagged): LogsBinding +export function getQuerySourceBinding( + cell: SourceTagged<'database_cell', 'log_cell'> +): QuerySourceBinding +export function getQuerySourceBinding( + cell: SourceTagged<'database_cell', 'log_cell'> +): QuerySourceBinding { + if (cell._tag === 'log_cell') { + return toQuerySourceBinding({ _tag: 'logs', time_range: cell.time_range }) + } + return toQuerySourceBinding({ _tag: 'database', database_identifier: cell.database_identifier }) +} diff --git a/apps/studio/state/explorer-query.test.ts b/apps/studio/state/explorer-query.test.ts index 6cf64c5b807..de2ccae4f20 100644 --- a/apps/studio/state/explorer-query.test.ts +++ b/apps/studio/state/explorer-query.test.ts @@ -33,7 +33,7 @@ describe('explorer query drafts', () => { expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) expect(secondState.drafts['query-1']).toMatchObject({ name: 'Active users', - source: { id: 'database', type: 'database', parameters: {} }, + source: { _tag: 'database' }, uncheckedSql: 'select * from users', projectRef: 'project-a', }) @@ -49,9 +49,8 @@ describe('explorer query drafts', () => { state.updateDraft({ id: 'query-1', source: { - id: 'logs', - type: 'logs', - parameters: { time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' } }, + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' }, }, }) @@ -60,9 +59,8 @@ describe('explorer query drafts', () => { const restored = createExplorerQueryState(storage) expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) expect(restored.drafts['query-1'].source).toEqual({ - id: 'logs', - type: 'logs', - parameters: { time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' } }, + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' }, }) }) @@ -77,11 +75,7 @@ describe('explorer query drafts', () => { const state = createExplorerQueryState(storage) expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) - expect(state.drafts['query-1'].source).toEqual({ - id: 'database', - type: 'database', - parameters: {}, - }) + expect(state.drafts['query-1'].source).toEqual({ _tag: 'database' }) }) it('ignores a malformed root value', () => { @@ -121,11 +115,7 @@ describe('explorer query drafts', () => { const state = createExplorerQueryState(storage) expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) - expect(state.drafts['query-1'].source).toEqual({ - id: 'database', - type: 'database', - parameters: {}, - }) + expect(state.drafts['query-1'].source).toEqual({ _tag: 'database' }) }) it('debounces SQL persistence while updating in-memory state immediately', () => { diff --git a/apps/studio/state/explorer-query.ts b/apps/studio/state/explorer-query.ts index b091b8efd64..805e7efd8eb 100644 --- a/apps/studio/state/explorer-query.ts +++ b/apps/studio/state/explorer-query.ts @@ -5,16 +5,16 @@ import { z } from 'zod' import { type QueryResult } from '@/components/interfaces/Explorer/types' import { - cellSourceSchema, - createDefaultCellSource, - type CellSource, + createDefaultSourceBinding, + querySourceBindingSchema, + type QuerySourceBinding, } from '@/data/query-sources/query-source-registry' export type ExplorerQueryDraft = { id: string projectRef: string name: string - source: CellSource + source: QuerySourceBinding uncheckedSql: UntrustedSqlFragment updatedAt: number } @@ -25,7 +25,7 @@ export type ExplorerQueryResult = QueryResult & { type PersistedExplorerQueryDraft = { name: string - source: CellSource + source: QuerySourceBinding sql: string updatedAt: number } @@ -58,10 +58,10 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => { const draft = persistedDraftSchema.safeParse(value) if (!draft.success) return [] - const parsedSource = cellSourceSchema.safeParse(draft.data.source) + const parsedSource = querySourceBindingSchema.safeParse(draft.data.source) const source = parsedSource.success ? parsedSource.data - : createDefaultCellSource('database') + : createDefaultSourceBinding('database') return [ [ @@ -112,19 +112,19 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage projectRef, name = 'Untitled query', sql = '', - source = createDefaultCellSource('database'), + source = createDefaultSourceBinding('database'), }: { id: string projectRef: string name?: string sql?: string - source?: CellSource + source?: QuerySourceBinding }) => { const draft: ExplorerQueryDraft = { id, projectRef, name, - source: cellSourceSchema.parse(source), + source: querySourceBindingSchema.parse(source), uncheckedSql: untrustedSql(sql), updatedAt: Date.now(), } @@ -162,7 +162,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage }: { id: string name?: string - source?: CellSource + source?: QuerySourceBinding sql?: string }) => { const draft = state.drafts[id] @@ -170,7 +170,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage if (name !== undefined) draft.name = name if (source !== undefined) { - draft.source = cellSourceSchema.parse(source) + draft.source = querySourceBindingSchema.parse(source) delete state.results[id] } if (sql !== undefined) draft.uncheckedSql = untrustedSql(sql) diff --git a/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx index d1ef963eb63..d0842b0fe44 100644 --- a/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx +++ b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx @@ -43,7 +43,7 @@ describe('QuerySourceMenu', () => { customRender( ) @@ -60,7 +60,7 @@ describe('QuerySourceMenu', () => { // that navigation, so the test observes exactly what the user does: does the // dropdown have to be reopened to see the newly-available controls? const { rerender } = customRender( - + ) await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' })) @@ -73,7 +73,7 @@ describe('QuerySourceMenu', () => { rerender( ) From 9ef9f1b8c153825362261815ab182c26b1cb51bb Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 14 Aug 2026 12:00:11 +0300 Subject: [PATCH 3/5] feat(self-host): use @supabase/server in functions template and docs (#48996) Updates the self-host Edge Functions template to use `@supabase/server`, matching the CLI's `supabase functions new` templates (part of SDK-1150, follows up on #45635 which exposed `SUPABASE_JWKS` to the functions container). The `hello` example function now wraps its handler in `withSupabase({ auth: 'none' })` and resolves the package through a per-function `deno.json` import map, which the runtime auto-discovers, so no dispatcher changes are needed. The self-hosted functions guide is updated to match: the create-a-function snippet, a `ctx.supabaseAdmin` example replacing the manual esm.sh `createClient` wiring, and a note that `auth: 'user'` requires `SUPABASE_JWKS`. Verified on `supabase/edge-runtime:v1.74.0` with the compose environment variables: `curl /functions/v1/hello` returns the same response body as before, so existing docs and troubleshooting pages stay accurate. The `docker/.gitignore` change: `volumes/functions/**` ignores self-hosters' own functions, but it also hid the new `deno.json`, which must ship with the repo for the `hello` import to resolve. The allowlist entries follow the existing `main/index.ts` pattern. ## Summary by CodeRabbit * **New Features** * Edge Functions now support authenticated invocation with publishable or secret API keys. * Function handlers can access authenticated and administrative Supabase clients through the request context. * Added automatic environment configuration and JWT verification support. * **Documentation** * Updated the self-hosting guide with the new function setup and authentication workflow. * Improved local function examples for supported access patterns and privileged operations. * **Tests** * Updated self-hosted smoke tests to validate publishable-key function access. --------- Co-authored-by: Kalleby Santos Co-authored-by: Kalleby Santos <105971119+kallebysantos@users.noreply.github.com> --- .../self-hosting/self-hosted-functions.mdx | 58 ++++++++++++------- docker/.gitignore | 6 +- docker/tests/test-self-hosted.sh | 6 +- docker/volumes/functions/deno.jsonc | 6 ++ docker/volumes/functions/hello/index.ts | 39 ++++++++++--- docker/volumes/functions/main/index.ts | 4 +- 6 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 docker/volumes/functions/deno.jsonc diff --git a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx index 1a806cb00dc..00a2e74f641 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx @@ -17,10 +17,15 @@ On managed Supabase platform, Edge Functions are deployed across multiple region The default `hello` function is located at `volumes/functions/hello/index.ts`. You can invoke it immediately after starting your stack: ```sh -curl http:///functions/v1/hello +curl http:///functions/v1/hello \ + --header 'apiKey: ' ``` -This returns `"Hello from Edge Functions!"`. +This returns: + +```json +{ "message": "Hello from Edge Functions!" } +``` ## Create a new function @@ -34,16 +39,20 @@ touch volumes/functions/my-function/index.ts Add the following code to `index.ts`: ```typescript -Deno.serve(async (req: Request) => { - const { name } = await req.json() - const message = `Hello, ${name}!` +import { withSupabase } from '@supabase/server' - return new Response(JSON.stringify({ message }), { - headers: { 'Content-Type': 'application/json' }, - }) -}) +export default { + fetch: withSupabase({ auth: 'none' }, async (req) => { + const { name } = await req.json() + const message = `Hello, ${name}!` + + return Response.json({ message }) + }), +} ``` +The `auth` option controls who can call the function: `'none'` accepts every request, `'user'` requires a valid user JWT, and `'publishable'` / `'secret'` require an API key. See the [Edge Functions auth guide](/docs/guides/functions/auth) for details. + ### Step 2: Restart the functions service to pick up the new function ```sh @@ -134,25 +143,30 @@ The functions service is pre-configured with the following environment variables | `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_...}` | New secret API key | | `SUPABASE_JWKS` | `{"keys":[{...}]}` | JWKS used to verify JWTs issued by Auth | -Here's an example function that queries a table using `@supabase/supabase-js`: +Here's an example function that queries a table using the admin client provided by `@supabase/server`: ```typescript -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' +import { withSupabase } from '@supabase/server' -Deno.serve(async () => { - const supabase = createClient( - Deno.env.get('SUPABASE_URL')!, - Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! - ) +export default { + fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => { + // ctx.supabaseAdmin bypasses RLS. This function requires a secret + // API key, so only server-to-server callers can reach it. + const { data, error } = await ctx.supabaseAdmin.from('todos').select('*') - const { data, error } = await supabase.from('todos').select('*') - - return new Response(JSON.stringify({ data, error }), { - headers: { 'Content-Type': 'application/json' }, - }) -}) + return Response.json({ data, error }) + }), +} ``` +`withSupabase` reads `SUPABASE_URL`, the API keys, and `SUPABASE_JWKS` from the environment variables above. You don't need to wire up `createClient` yourself. + + + +`auth: 'user'` verifies caller JWTs against `SUPABASE_JWKS`. If you're on a legacy setup without it configured, see [New API Keys and Asymmetric Authentication](/docs/guides/self-hosting/self-hosted-auth-keys). + + + ### Internal vs external URLs This is a key distinction that affects how you build URLs in your functions: diff --git a/docker/.gitignore b/docker/.gitignore index bed55270caa..1f9c3436196 100644 --- a/docker/.gitignore +++ b/docker/.gitignore @@ -2,11 +2,15 @@ volumes/db/data volumes/storage volumes/snippets volumes/functions/** +!volumes/functions/deno.json* !volumes/functions/main/ volumes/functions/main/** !volumes/functions/main/index.ts +!volumes/functions/hello/ +volumes/functions/hello/** +!volumes/functions/hello/index.ts .env test.http docker-compose.override.yml .supabase-version -backups \ No newline at end of file +backups diff --git a/docker/tests/test-self-hosted.sh b/docker/tests/test-self-hosted.sh index c4e6a4765fc..216a5cdcbf2 100644 --- a/docker/tests/test-self-hosted.sh +++ b/docker/tests/test-self-hosted.sh @@ -43,6 +43,8 @@ fi # Read keys from .env ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-) SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-) +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-) DASHBOARD_USERNAME=$(grep '^DASHBOARD_USERNAME=' .env | cut -d= -f2-) DASHBOARD_PASSWORD=$(grep '^DASHBOARD_PASSWORD=' .env | cut -d= -f2-) @@ -440,10 +442,10 @@ echo "" echo "--- Edge Functions ---" fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \ -X POST \ - -H "Authorization: Bearer $ANON_KEY" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ -H "Content-Type: application/json" \ -d '{}') -check "Call hello function" '"Hello from Edge Functions!"' "$fn_resp" +check "Call hello function" '{"message":"Hello from Edge Functions!"}' "$fn_resp" # --------------------------------------------- # 8. pg-meta (Studio backend) diff --git a/docker/volumes/functions/deno.jsonc b/docker/volumes/functions/deno.jsonc new file mode 100644 index 00000000000..db206e8f467 --- /dev/null +++ b/docker/volumes/functions/deno.jsonc @@ -0,0 +1,6 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2", + "@supabase/server": "npm:@supabase/server@^1" + } +} diff --git a/docker/volumes/functions/hello/index.ts b/docker/volumes/functions/hello/index.ts index e3f138b5eca..50d0e6be8c4 100644 --- a/docker/volumes/functions/hello/index.ts +++ b/docker/volumes/functions/hello/index.ts @@ -2,13 +2,36 @@ // https://deno.land/manual/getting_started/setup_your_environment // This enables autocomplete, go to definition, etc. -Deno.serve(async () => { - return new Response( - `"Hello from Edge Functions!"`, - { headers: { "Content-Type": "application/json" } }, - ) -}) +// Setup type definitions for built-in Supabase Runtime APIs +import "@supabase/functions-js/edge-runtime.d.ts" +import { withSupabase } from "@supabase/server" + +// Logs are visible from 'functions' container inspector +console.log("Hello from Functions!"); + +// This endpoint uses 'publishable' | 'secret' access, apiKey is required. +// Use publishable for Client-facing, key-validated endpoints +// Use secret for Server-to-server, internal calls +export default { + fetch: withSupabase({ auth: ["publishable", "secret"] }, async (req, ctx) => { + // Called by another service with a secret key + // ctx.supabaseAdmin bypasses RLS — use for privileged operations + /* + if (ctx.authMode === "secret") { + const { user_id } = await req.json(); + const { data } = await ctx.supabaseAdmin.auth.admin.getUserById(user_id); + + return Response.json({ + email: data?.user?.email, + }); + } + */ + + return Response.json({ message: "Hello from Edge Functions!" }); + }), +}; // To invoke: -// curl 'http://localhost:/functions/v1/hello' \ -// --header 'Authorization: Bearer ' +// curl 'http://localhost:/functions/v1/hello' \ +// --header 'apiKey: ' + diff --git a/docker/volumes/functions/main/index.ts b/docker/volumes/functions/main/index.ts index 4761a276657..8d00102c0ec 100644 --- a/docker/volumes/functions/main/index.ts +++ b/docker/volumes/functions/main/index.ts @@ -148,7 +148,9 @@ Deno.serve(async (req: Request) => { const memoryLimitMb = 150 const workerTimeoutMs = 1 * 60 * 1000 const noModuleCache = false - const importMapPath = null + // Using a common Import Map for all functions + // to use a scope 'deno.json' it must be dinamically resolved base on the 'service_name' + const importMapPath = `/home/deno/functions/deno.jsonc` const envVarsObj = Deno.env.toObject() const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) From 773b388f251fd17f7a25d88e9a9d6edb022a2e80 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 14 Aug 2026 11:37:38 +0200 Subject: [PATCH 4/5] chore(docs): correct api for temporary access (#48741) ## 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? Docs update ## Summary by CodeRabbit * **Documentation** * Updated temporary access guidance to require SSL-enforced incoming connections. * Updated Management API examples to use the `/jit-access` endpoint for checking, enabling, and disabling temporary access. --- .../content/guides/platform/temporary-access.mdx | 12 +++++++++--- supa-mdx-lint/Rule003Spelling.toml | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/docs/content/guides/platform/temporary-access.mdx b/apps/docs/content/guides/platform/temporary-access.mdx index 2862e3bdcba..0c9eec50002 100644 --- a/apps/docs/content/guides/platform/temporary-access.mdx +++ b/apps/docs/content/guides/platform/temporary-access.mdx @@ -9,6 +9,12 @@ Enabling temporary access only applies to connections to Postgres and Supavisor +[Enforce SSL](/docs/guides/platform/ssl-enforcement) on incoming connections must be enabled before temporary access can be used. + + + + + Projects need to be at least on Postgres 17.6.1.081 (or higher) to enable temporary access. You can find the Postgres version of your project on the [General Settings](/dashboard/project/_/settings/general) page. If your project is on an older version, you will need to [upgrade](/docs/guides/platform/upgrading) to use this feature. @@ -27,11 +33,11 @@ export SUPABASE_MANAGEMENT_API_TOKEN="your-access-token" export PROJECT_REF="your-project-ref" # Get current temporary access status -curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit-access" \ +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/jit-access" \ -H "Authorization: Bearer $SUPABASE_MANAGEMENT_API_TOKEN" # Enable temporary access -curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit-access" \ +curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/jit-access" \ -H "Authorization: Bearer $SUPABASE_MANAGEMENT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -39,7 +45,7 @@ curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit-acce }' # Disable temporary access -curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit-access" \ +curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/jit-access" \ -H "Authorization: Bearer $SUPABASE_MANAGEMENT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 6bd06da8c4b..b1c3750cf61 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -217,7 +217,7 @@ allow_list = [ "Datadog", "Deadpool", "Dependabot", - "DepthFirst" + "DepthFirst", "DDoS", "Deno", "Dependabot", From 344dc26a5eea6f2a544ad4c904c3e9a0f7ab400f Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:43:17 +0100 Subject: [PATCH 5/5] Fixed the event classifier for the events page (#49095) ## 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? Modified the events page so that it reads the correct category from the Notion database and displays if it's a hackathon, meetup, etc. ## Summary by CodeRabbit * **New Features** * Events now display categories based on their Notion type and category information. * Hackathon events can be identified through category data. * Duplicate categories are automatically removed. * **Bug Fixes** * Events with unrecognized types now default to the conference category for consistent display. --- apps/www/lib/events.ts | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/www/lib/events.ts b/apps/www/lib/events.ts index d5cda293d00..c73cb7ec80e 100644 --- a/apps/www/lib/events.ts +++ b/apps/www/lib/events.ts @@ -12,7 +12,8 @@ * URL -> rich_text (event URL) * Book Meeting Link -> url * Location -> rich_text - * Category -> multi_select + * Type -> multi_select (drives the site's category filter) + * Category -> multi_select (audience taxonomy) * Are you speaking at this event? -> multi_select * Participation -> multi_select */ @@ -26,6 +27,19 @@ import { EventHost, SUPABASE_HOST, SupabaseEvent } from './eventsTypes' // The actual DB ID (child database inside the page) const NOTION_EVENTS_DB_ID_FALLBACK = '21b5004b775f8058872fe8fa81e2c7ac' +// Maps Notion "Type" options to the app's category vocabulary (the values in +// CATEGORIES_FILTERS). Keys are lowercased for case-insensitive matching. +// "Supabase Event", "Party" and "Sales Event" have no filter bucket and fall +// through to DEFAULT_NOTION_CATEGORY. +const NOTION_TYPE_CATEGORY_MAP: Record = { + conference: 'conference', + hackathon: 'hackathon', + meetup: 'meetup', + 'meetup - third party': 'meetup', +} + +const DEFAULT_NOTION_CATEGORY = 'conference' + // ─── Helpers ──────────────────────────────────────────────────────────────── function isSafeHttpUrl(url: string): boolean { @@ -73,6 +87,27 @@ function getMultiSelect(page: any, name: string): string[] { return prop.multi_select.map((s: any) => s.name) } +/** + * Derive the site's category values for a Notion event. + * + * "Type" is the primary signal. "Category" is an audience taxonomy (AI / ML, + * Postgres / Databases, …) that also carries a "Hackathon" option, so it's read + * as a secondary signal for events typed as something else — e.g. a third-party + * meetup that is really a hackathon. + */ +function getNotionCategories(page: any): string[] { + const mapped = getMultiSelect(page, 'Type') + .map((type) => NOTION_TYPE_CATEGORY_MAP[type.trim().toLowerCase()]) + .filter(Boolean) + + const isHackathonCategory = getMultiSelect(page, 'Category').some( + (category) => category.trim().toLowerCase() === 'hackathon' + ) + if (isHackathonCategory) mapped.push('hackathon') + + return mapped.length > 0 ? Array.from(new Set(mapped)) : [DEFAULT_NOTION_CATEGORY] +} + function getFormulaString(page: any, name: string): string { const prop = page.properties[name] if (!prop || prop.type !== 'formula' || prop.formula?.type !== 'string') return '' @@ -116,7 +151,7 @@ export const getNotionEvents = async (): Promise => { const rawMeetingLink = getUrl(page, 'Book Meeting Link') const meetingLink = isSafeHttpUrl(rawMeetingLink) ? rawMeetingLink : '' const location = getRichText(page, 'Location') - const categories = ['conference'] + const categories = getNotionCategories(page) const speakingAnswers = getMultiSelect(page, 'Are you speaking at this event?') const isSpeaking = speakingAnswers.includes('Yes')