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(
)
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')
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]])
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",