mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
Merge remote-tracking branch 'origin/master' into kemal/depr-639-follow-up-on-token-summary-table
This commit is contained in:
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 (
|
||||
|
||||
+16
-12
@@ -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 })
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"imports": {
|
||||
"@supabase/functions-js": "jsr:@supabase/functions-js@^2",
|
||||
"@supabase/server": "npm:@supabase/server@^1"
|
||||
}
|
||||
}
|
||||
@@ -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>'
|
||||
|
||||
@@ -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]])
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ allow_list = [
|
||||
"Datadog",
|
||||
"Deadpool",
|
||||
"Dependabot",
|
||||
"DepthFirst"
|
||||
"DepthFirst",
|
||||
"DDoS",
|
||||
"Deno",
|
||||
"Dependabot",
|
||||
|
||||
Reference in new issue
Block a user