Merge remote-tracking branch 'origin/master' into kemal/depr-639-follow-up-on-token-summary-table

This commit is contained in:
kemal committed 2026-08-14 12:10:50 +01:00
commit dff13984e8
31 files changed
+467 -329

No files matched your search

@@ -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;
@@ -9,6 +9,12 @@ Enabling temporary access only applies to connections to Postgres and Supavisor
<Admonition type="note">
[Enforce SSL](/docs/guides/platform/ssl-enforcement) on incoming connections must be enabled before temporary access can be used.
</Admonition>
<Admonition type="note">
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.
</Admonition>
@@ -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 '{
@@ -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://<your-domain>/functions/v1/hello
curl http://<your-domain>/functions/v1/hello \
--header 'apiKey: <sb_publishable/sb_secret key>'
```
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.
<Admonition type="note">
`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).
</Admonition>
### Internal vs external URLs
This is a key distinction that affects how you build URLs in your functions:
@@ -54,9 +54,8 @@ describe('ExplorerQuerySourceMenu', () => {
customRender(
<ExplorerQuerySourceMenu
source={{
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' },
}}
onSourceChange={onSourceChange}
/>
@@ -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(
<ExplorerQuerySourceMenu
source={{
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' },
}}
onSourceChange={onSourceChange}
/>
@@ -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 }
)
@@ -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 = ({
<Button
variant="text"
size="tiny"
aria-label={`Query source: ${QUERY_SOURCE_LABELS[source.id]}`}
icon={<QuerySourceIcon source={source.id} className="text-foreground-light" />}
aria-label={`Query source: ${QUERY_SOURCE_LABELS[source._tag]}`}
icon={<QuerySourceIcon source={source._tag} className="text-foreground-light" />}
iconRight={<ChevronDown className="text-foreground-light" />}
>
{QUERY_SOURCE_LABELS[source.id]}
{QUERY_SOURCE_LABELS[source._tag]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
{availableSources.map((candidate) => (
<DropdownMenuItem
key={candidate.id}
key={candidate._tag}
className="justify-between"
onSelect={(event) => {
event.preventDefault()
if (candidate.id !== source.id) {
onSourceChange(createDefaultCellSource(candidate.id))
if (candidate._tag !== source._tag) {
onSourceChange(createDefaultSourceBinding(candidate._tag))
}
}}
>
<span className="flex items-center gap-x-2">
<QuerySourceIcon source={candidate.id} className="text-foreground-light" />
{QUERY_SOURCE_LABELS[candidate.id]}
<QuerySourceIcon source={candidate._tag} className="text-foreground-light" />
{QUERY_SOURCE_LABELS[candidate._tag]}
</span>
{source.id === candidate.id && <Check size={14} />}
{source._tag === candidate._tag && <Check size={14} />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{source.type === 'database' ? (
{source._tag === 'database' ? (
<DatabaseParametersSubMenu
identifier={source.parameters.identifier ?? ref}
onIdentifierChange={(identifier) =>
onSourceChange({
id: 'database',
type: 'database',
parameters: { identifier },
})
identifier={source.database_identifier ?? ref}
onIdentifierChange={(database_identifier) =>
onSourceChange({ _tag: 'database', database_identifier })
}
/>
) : (
<LogsTimeRangeSubMenu
range={source.parameters.time_range}
onRangeChange={(timeRange) =>
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 = ({
</DropdownMenuContent>
</DropdownMenu>
{source.type === 'logs' && (
{source._tag === 'logs' && (
<>
<LogsCustomRangeDialog
open={isCustomRangeOpen}
@@ -13,8 +13,8 @@ import {
} from '@/data/content/notebooks/notebook-schema'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
import {
createDefaultCellSource,
type CellSource,
getQuerySourceBinding,
type QuerySourceBinding,
} from '@/data/query-sources/query-source-registry'
import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
@@ -39,10 +39,7 @@ export const QueryCell = ({ cell }: QueryCellProps) => {
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<string>(unchecked_sql)
const [result, setResult] = useState<QueryResult>()
@@ -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
},
})
@@ -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
@@ -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<void>((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({
@@ -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>
@@ -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 }
@@ -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({
@@ -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({
@@ -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 (
<div key={id} className="w-full h-full relative">
@@ -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 (
@@ -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
<DropdownMenuContent align="end" className="w-60">
{selectableSources.map((source) => (
<DropdownMenuItem
key={source.id}
key={source._tag}
className="justify-between"
onSelect={(e) => {
e.preventDefault()
switchSource(source.id)
switchSource(source._tag)
}}
>
<span className="flex items-center gap-x-2">
<QuerySourceIcon source={source.id} className="text-foreground-light" />
{QUERY_SOURCE_LABELS[source.id]}
<QuerySourceIcon source={source._tag} className="text-foreground-light" />
{QUERY_SOURCE_LABELS[source._tag]}
</span>
{currentSource === source.id && <Check size={14} />}
{currentSource === source._tag && <Check size={14} />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{runSource.type === 'logs' ? (
{runSource._tag === 'logs' ? (
<LogsTimeRangeSubMenu
range={runSource.dateRange}
range={runSource.time_range}
onRangeChange={(range) => sessionSnap.setLogRange(id, range)}
onOpenCustomRange={() => setIsCustomRangeOpen(true)}
onShowUpgrade={() => setShowUpgradePrompt(true)}
@@ -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
@@ -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 }
@@ -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' },
})
})
})
@@ -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<QuerySource>(() => {
return useMemo<QuerySourceBinding>(() => {
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])
}
@@ -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 })
})
})
@@ -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<typeof logsAllEndpointUrl>
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<S extends Source> = Pick<S, 'type' | 'id' | 'parameters'>
/**
* 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<DatabaseSource> | CellSourceOf<LogsSource>
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<Source['id'], Source>
export type QuerySourceId = keyof typeof QUERY_SOURCE_REGISTRY
} as const satisfies Record<QuerySourceTag, Source>
export const QUERY_SOURCES = Object.values(QUERY_SOURCE_REGISTRY) satisfies Source[]
export const QUERY_SOURCE_LABELS: Record<QuerySourceId, string> = {
export const QUERY_SOURCE_LABELS: Record<QuerySourceTag, string> = {
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<DatabaseSource>
export function createDefaultCellSource(id: 'logs'): CellSourceOf<LogsSource>
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>): 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<DatabaseTag extends string, LogsTag extends string> =
| { readonly _tag: DatabaseTag; readonly database_identifier?: string }
| { readonly _tag: LogsTag; readonly time_range: Readonly<TimeRange> }
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<never, 'logs'>): 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<never, 'log_cell'>): 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 })
}
+7 -17
View File
@@ -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', () => {
+12 -12
View File
@@ -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)
@@ -43,7 +43,7 @@ describe('QuerySourceMenu', () => {
customRender(
<QuerySourceMenu
id="database-snippet"
runSource={{ type: 'database' }}
runSource={{ _tag: 'database' }}
canCreateLogsSnippet={false}
/>
)
@@ -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(
<QuerySourceMenu id="new-snippet" runSource={{ type: 'database' }} canCreateLogsSnippet />
<QuerySourceMenu id="new-snippet" runSource={{ _tag: 'database' }} canCreateLogsSnippet />
)
await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' }))
@@ -73,7 +73,7 @@ describe('QuerySourceMenu', () => {
rerender(
<QuerySourceMenu
id="new-snippet"
runSource={{ type: 'logs', dateRange: DEFAULT_LOG_TIME_RANGE }}
runSource={{ _tag: 'logs', time_range: DEFAULT_LOG_TIME_RANGE }}
canCreateLogsSnippet
/>
)
+37 -2
View File
@@ -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<string, string> = {
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<SupabaseEvent[]> => {
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')
+5 -1
View File
@@ -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
backups
+4 -2
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2",
"@supabase/server": "npm:@supabase/server@^1"
}
}
+31 -8
View File
@@ -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:<KONG_HTTP_PORT>/functions/v1/hello' \
// --header 'Authorization: Bearer <anon/service_role API key>'
// curl 'http://localhost:<API_GW_HTTP_PORT>/functions/v1/hello' \
// --header 'apiKey: <sb_publishable/sb_secret key>'
+3 -1
View File
@@ -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]])
+1 -1
View File
@@ -217,7 +217,7 @@ allow_list = [
"Datadog",
"Deadpool",
"Dependabot",
"DepthFirst"
"DepthFirst",
"DDoS",
"Deno",
"Dependabot",