diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/DatabaseSubMenu.tsx b/apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx similarity index 74% rename from apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/DatabaseSubMenu.tsx rename to apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx index 59458d69a99..f946f4e03f8 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/DatabaseSubMenu.tsx +++ b/apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useParams } from 'common' import { Check, Plus } from 'lucide-react' import Link from 'next/link' import { @@ -12,9 +12,6 @@ import { import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' -import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' /** The label a database row/summary shows: the primary, or a replica by region + id. */ function databaseLabel(identifier: string, region: string, projectRef: string | undefined) { @@ -22,35 +19,27 @@ function databaseLabel(identifier: string, region: string, projectRef: string | return `Read replica (${formatDatabaseRegion(region)} - ${formatDatabaseID(identifier)})` } -export const DatabaseSubMenu = ({ id }: { id: string }) => { +export const DatabaseParametersSubMenu = ({ + identifier, + onIdentifierChange, +}: { + identifier?: string + onIdentifierChange: (identifier: string) => void +}) => { const { ref: projectRef } = useParams() - const sessionSnap = useSqlEditorSessionSnapshot() - const dbSelector = useDatabaseSelectorStateSnapshot() const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas']) - const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(projectRef ?? ''), - '' - ) - const { data } = useReadReplicasQuery({ projectRef }) const databases = (data ?? []) .slice() .sort((a, b) => (a.inserted_at > b.inserted_at ? 1 : 0)) .sort((database) => (database.identifier === projectRef ? -1 : 0)) - const selectedDatabaseId = - lastSelectedDb.length > 0 ? lastSelectedDb : (dbSelector.selectedDatabaseId ?? projectRef) + const selectedDatabaseId = identifier ?? projectRef const selectedDatabase = databases.find((db) => db.identifier === selectedDatabaseId) const newReplicaURL = `/project/${projectRef}/database/replication?destinationType=Read+Replica` - const handleSelect = (databaseId: string) => { - dbSelector.setSelectedDatabaseId(databaseId) - setLastSelectedDb(databaseId) - sessionSnap.resetResult(id) - } - return ( @@ -71,7 +60,7 @@ export const DatabaseSubMenu = ({ id }: { id: string }) => { key={database.identifier} className="justify-between" disabled={isUnhealthy} - onClick={() => handleSelect(database.identifier)} + onClick={() => onIdentifierChange(database.identifier)} > {databaseLabel(database.identifier, database.region, projectRef)} {database.identifier === selectedDatabaseId && } diff --git a/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts new file mode 100644 index 00000000000..f12ec9cef66 --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts @@ -0,0 +1,114 @@ +import dayjs from 'dayjs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + customDateRangeToLogTimeRange, + datePickerValueToLogTimeRange, + logTimeRangesEqual, + logTimeRangeToDatePickerValue, + resolveLogTimeRange, +} from './LogTimeRange.utils' +import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import { + DEFAULT_LOG_TIME_RANGE, + type LogTimeRange, +} from '@/data/query-sources/query-source-registry' + +describe('LogTimeRange.utils', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-08T12:00:00.000Z')) + }) + + afterEach(() => vi.useRealTimers()) + + it.each([ + ['30m', 30, 'minute'], + ['2h', 2, 'hour'], + ['7d', 7, 'day'], + ] as const)('round-trips the picker helper for %s', (_, amount, unit) => { + const helper = generateDynamicHelper(amount, unit) + const pickerValue = { + from: helper.calcFrom(), + to: helper.calcTo(), + isHelper: true, + text: helper.text, + } + const range: LogTimeRange = { type: 'relative', amount, unit } + + expect(datePickerValueToLogTimeRange(pickerValue)).toEqual(range) + expect(logTimeRangeToDatePickerValue(range)).toEqual(pickerValue) + }) + + it('falls back to the default when a custom value has no valid start', () => { + expect(datePickerValueToLogTimeRange({ from: '', to: '', isHelper: false })).toEqual( + DEFAULT_LOG_TIME_RANGE + ) + }) + + it('uses now when an absolute helper has an empty end', () => { + expect( + datePickerValueToLogTimeRange({ + from: '2025-01-01T00:00:00.000Z', + to: '', + isHelper: true, + text: 'Custom', + }) + ).toEqual({ + type: 'absolute', + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-08T12:00:00.000Z', + }) + }) + + it('compares relative and absolute ranges structurally', () => { + expect( + logTimeRangesEqual( + { type: 'relative', amount: 1, unit: 'hour' }, + { type: 'relative', amount: 1, unit: 'hour' } + ) + ).toBe(true) + expect( + logTimeRangesEqual( + { type: 'relative', amount: 1, unit: 'hour' }, + { type: 'relative', amount: 1, unit: 'day' } + ) + ).toBe(false) + expect( + logTimeRangesEqual( + { type: 'absolute', from: '2025-01-01T00:00:00.000Z', to: '2025-01-02T00:00:00.000Z' }, + { type: 'absolute', from: '2025-01-01T00:00:00.000Z', to: '2025-01-02T00:00:00.000Z' } + ) + ).toBe(true) + }) + + it('resolves a relative range against the current time', () => { + expect(resolveLogTimeRange({ type: 'relative', amount: 2, unit: 'day' })).toEqual({ + from: dayjs().subtract(2, 'day').toISOString(), + to: dayjs().toISOString(), + }) + }) + + it('passes an absolute range through unchanged', () => { + const range: LogTimeRange = { + type: 'absolute', + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-02T00:00:00.000Z', + } + expect(resolveLogTimeRange(range)).toEqual({ from: range.from, to: range.to }) + }) + + it('clamps a custom range ending today to now', () => { + const from = new Date('2025-01-07T09:00:00.000Z') + expect( + customDateRangeToLogTimeRange({ + from, + to: new Date('2025-01-08T09:00:00.000Z'), + }) + ).toEqual({ + type: 'absolute', + from: dayjs(from).startOf('day').toISOString(), + to: '2025-01-08T12:00:00.000Z', + }) + }) +}) diff --git a/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.ts b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.ts new file mode 100644 index 00000000000..4dd9f9ff100 --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.ts @@ -0,0 +1,97 @@ +import dayjs from 'dayjs' + +import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' +import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange' +import { + DEFAULT_LOG_TIME_RANGE, + type LogTimeRange, +} from '@/data/query-sources/query-source-registry' +import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime' + +export type RelativeTimeUnit = Extract['unit'] + +const nowIsoDateTime = (): IsoDateTimeString => dayjs().toISOString() as IsoDateTimeString + +function parseRelativeHelperLabel( + text: string | undefined +): { amount: number; unit: RelativeTimeUnit } | null { + if (!text) return null + const match = text + .trim() + .toLowerCase() + .match(/^last\s+(?:(\d+)\s+)?(minute|hour|day)s?$/) + if (!match) return null + + const amount = match[1] ? parseInt(match[1], 10) : 1 + if (!Number.isFinite(amount) || amount <= 0) return null + + const unit = match[2] + if (unit !== 'minute' && unit !== 'hour' && unit !== 'day') return null + return { amount, unit } +} + +export function datePickerValueToLogTimeRange(value: DatePickerValue): LogTimeRange { + if (value.isHelper) { + const relative = parseRelativeHelperLabel(value.text) + if (relative) return { type: 'relative', ...relative } + } + + const from = isoDateTimeString(value.from) + if (from === null) return DEFAULT_LOG_TIME_RANGE + const to = isoDateTimeString(value.to) ?? nowIsoDateTime() + return { type: 'absolute', from, to } +} + +export function logTimeRangeToDatePickerValue(range: LogTimeRange): DatePickerValue { + if (range.type === 'relative') { + const helper = generateDynamicHelper(range.amount, range.unit) + return { + from: helper.calcFrom(), + to: helper.calcTo(), + isHelper: true, + text: helper.text, + } + } + return { from: range.from, to: range.to, isHelper: false } +} + +export function customDateRangeToLogTimeRange({ + from, + to, + now = new Date(), +}: { + from: Date + to: Date + now?: Date +}): Extract { + const nowValue = dayjs(now) + const requestedTo = dayjs(to).endOf('day') + + return { + type: 'absolute', + from: dayjs(from).startOf('day').toISOString(), + to: requestedTo.isAfter(nowValue) ? nowValue.toISOString() : requestedTo.toISOString(), + } +} + +export function logTimeRangesEqual(a: LogTimeRange, b: LogTimeRange): boolean { + if (a.type === 'relative' && b.type === 'relative') { + return a.amount === b.amount && a.unit === b.unit + } + if (a.type === 'absolute' && b.type === 'absolute') { + return a.from === b.from && a.to === b.to + } + return false +} + +export function resolveLogTimeRange(range: LogTimeRange): ResolvedLogDateRange { + if (range.type === 'relative') { + const now = dayjs() + return { + from: now.subtract(range.amount, range.unit).toISOString(), + to: now.toISOString(), + } + } + return { from: range.from, to: range.to } +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/LogsCustomRangeDialog.tsx b/apps/studio/components/interfaces/QuerySources/LogsCustomRangeDialog.tsx similarity index 100% rename from apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/LogsCustomRangeDialog.tsx rename to apps/studio/components/interfaces/QuerySources/LogsCustomRangeDialog.tsx diff --git a/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx new file mode 100644 index 00000000000..80d4c0fac1c --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx @@ -0,0 +1,57 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' +import { describe, expect, it, vi } from 'vitest' + +import { LogsTimeRangeSubMenu } from './LogsTimeRangeSubMenu' +import { customRender } from '@/tests/lib/custom-render' + +mockAnimationsApi() + +vi.mock('@/hooks/misc/useCheckEntitlements', () => ({ + useCheckEntitlements: () => ({ getEntitlementNumericValue: () => 1 }), +})) + +const renderSubMenu = ({ + onRangeChange = vi.fn(), + onOpenCustomRange = vi.fn(), + onShowUpgrade = vi.fn(), +} = {}) => { + customRender( + + Open + + + + + ) + + return { onRangeChange, onOpenCustomRange, onShowUpgrade } +} + +describe('LogsTimeRangeSubMenu', () => { + it('opens the upgrade prompt instead of applying a range beyond retention', async () => { + const { onRangeChange, onShowUpgrade } = renderSubMenu() + + await userEvent.hover(await screen.findByText('Time range')) + await userEvent.click(await screen.findByText('Last 7 days')) + + expect(onShowUpgrade).toHaveBeenCalledOnce() + expect(onRangeChange).not.toHaveBeenCalled() + }) + + it('exposes the custom-range action', async () => { + const { onOpenCustomRange } = renderSubMenu() + + await userEvent.hover(await screen.findByText('Time range')) + await userEvent.click(await screen.findByText('Custom range…')) + + expect(onOpenCustomRange).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx similarity index 80% rename from apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx rename to apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx index f633d4a8a58..9830e206a14 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx +++ b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx @@ -8,42 +8,37 @@ import { DropdownMenuSubTrigger, } from 'ui' -import { - datePickerValueToLogDateRange, - logDateRangesEqual, - type LogDateRange, -} from '../../querySource' +import { datePickerValueToLogTimeRange, logTimeRangesEqual } from './LogTimeRange.utils' import { EXPLORER_DATEPICKER_HELPERS } from '@/components/interfaces/Settings/Logs/Logs.constants' import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import type { LogTimeRange } from '@/data/query-sources/query-source-registry' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' -import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' -export const TimeRangeSubMenu = ({ - id, +export const LogsTimeRangeSubMenu = ({ range, + onRangeChange, onOpenCustomRange, onShowUpgrade, }: { - id: string - range: LogDateRange + range: LogTimeRange + onRangeChange: (range: LogTimeRange) => void onOpenCustomRange: () => void onShowUpgrade: () => void }) => { - const sessionSnap = useSqlEditorSessionSnapshot() const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') const entitledToLogDays = getEntitlementNumericValue() - const isCustomRange = range.kind === 'absolute' + const isCustomRange = range.type === 'absolute' const presets = EXPLORER_DATEPICKER_HELPERS.map((helper) => ({ helper, - range: datePickerValueToLogDateRange({ + range: datePickerValueToLogTimeRange({ from: helper.calcFrom(), to: helper.calcTo(), isHelper: true, text: helper.text, }), })) - const selectedPreset = presets.find((preset) => logDateRangesEqual(range, preset.range)) + const selectedPreset = presets.find((preset) => logTimeRangesEqual(range, preset.range)) return ( @@ -59,7 +54,7 @@ export const TimeRangeSubMenu = ({ {presets.map(({ helper, range: presetRange }) => { - const isSelected = !isCustomRange && logDateRangesEqual(range, presetRange) + const isSelected = !isCustomRange && logTimeRangesEqual(range, presetRange) const isLocked = maybeShowUpgradePromptIfNotEntitled(helper.calcFrom(), entitledToLogDays) return ( @@ -68,7 +63,7 @@ export const TimeRangeSubMenu = ({ className="justify-between" onClick={() => { if (isLocked) return onShowUpgrade() - sessionSnap.setLogRange(id, presetRange) + onRangeChange(presetRange) }} > {helper.text} diff --git a/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx b/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx new file mode 100644 index 00000000000..a1d635866ba --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx @@ -0,0 +1,15 @@ +import { Database, ScrollText } from 'lucide-react' + +import type { QuerySourceId } from '@/data/query-sources/query-source-registry' + +export const QuerySourceIcon = ({ + source, + className, +}: { + source: QuerySourceId + className?: string +}) => { + const props = { className, size: 16, strokeWidth: 2 } + + return source === 'logs' ? : +} diff --git a/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts b/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts new file mode 100644 index 00000000000..43e103d82c2 --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts @@ -0,0 +1,35 @@ +import { useState } from 'react' + +import { customDateRangeToLogTimeRange } from './LogTimeRange.utils' +import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import type { LogTimeRange } from '@/data/query-sources/query-source-registry' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' + +export function useLogsCustomRange({ + onRangeChange, +}: { + onRangeChange: (range: LogTimeRange) => void +}) { + const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false) + const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) + const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') + const entitledToLogDays = getEntitlementNumericValue() + + const handleApplyCustomRange = ({ from, to }: { from: Date; to: Date }) => { + const range = customDateRangeToLogTimeRange({ from, to }) + if (maybeShowUpgradePromptIfNotEntitled(range.from, entitledToLogDays)) { + setShowUpgradePrompt(true) + return + } + + onRangeChange(range) + } + + return { + isCustomRangeOpen, + setIsCustomRangeOpen, + showUpgradePrompt, + setShowUpgradePrompt, + handleApplyCustomRange, + } +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx index 9e08a904d50..d741dc3535e 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx @@ -1,8 +1,6 @@ -import { useParams } from 'common' -import dayjs from 'dayjs' -import { Check, ChevronDown, Database, ScrollText } from 'lucide-react' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { Check, ChevronDown } from 'lucide-react' import { useRouter } from 'next/router' -import { useState } from 'react' import { Button, DropdownMenu, @@ -12,32 +10,23 @@ import { DropdownMenuTrigger, } from 'ui' -import { - datePickerValueToLogDateRange, - type QuerySource, - type SqlSnippetSource, -} from '../../querySource' -import { DatabaseSubMenu } from './DatabaseSubMenu' -import { LogsCustomRangeDialog } from './LogsCustomRangeDialog' +import { type QuerySource, type SqlSnippetSource } from '../../querySource' import { resolveSourceSwitch } from './QuerySourceMenu.utils' import { RowLimitSubMenu } from './RowLimitSubMenu' import { RunAsSubMenu } from './RunAsSubMenu' -import { TimeRangeSubMenu } from './TimeRangeSubMenu' -import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/DatabaseParametersSubMenu' +import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog' +import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu' +import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourceIcon' +import { useLogsCustomRange } from '@/components/interfaces/QuerySources/useLogsCustomRange' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { QUERY_SOURCE_LABELS, QUERY_SOURCES } 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' import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' -const SOURCE_LABEL: Record = { - database: 'Database', - logs: 'Logs', -} - -const SourceIcon = ({ source, ...props }: { source: SqlSnippetSource; className?: string }) => - source === 'logs' ? : - type QuerySourceMenuProps = { id: string runSource: QuerySource @@ -67,18 +56,33 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo const router = useRouter() const snapV2 = useSqlEditorV2StateSnapshot() const sessionSnap = useSqlEditorSessionSnapshot() + const databaseSelector = useDatabaseSelectorStateSnapshot() + const [lastSelectedDatabase, setLastSelectedDatabase] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref ?? ''), + '' + ) - const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false) - const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) - - const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') - const entitledToLogDays = getEntitlementNumericValue() + const { + isCustomRangeOpen, + setIsCustomRangeOpen, + showUpgradePrompt, + setShowUpgradePrompt, + handleApplyCustomRange, + } = useLogsCustomRange({ onRangeChange: (range) => sessionSnap.setLogRange(id, range) }) const currentSource = runSource.type 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. const isBlankNewTab = snapV2.snippets[id] === undefined + const databaseIdentifier = + lastSelectedDatabase.length > 0 + ? lastSelectedDatabase + : (databaseSelector.selectedDatabaseId ?? ref) + + const selectableSources = QUERY_SOURCES.filter( + (source) => source.type !== 'logs' || canCreateLogsSnippet || isLogs + ) const switchSource = (target: SqlSnippetSource) => { const next = resolveSourceSwitch({ ref, target, currentSource, isBlankNewTab }) @@ -86,20 +90,10 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo router[next.method](next.url) } - const applyCustomRange = ({ from, to }: { from: Date; to: Date }) => { - const fromIso = dayjs(from).startOf('day').toISOString() - if (maybeShowUpgradePromptIfNotEntitled(fromIso, entitledToLogDays)) { - setShowUpgradePrompt(true) - return - } - sessionSnap.setLogRange( - id, - datePickerValueToLogDateRange({ - from: fromIso, - to: dayjs(to).endOf('day').toISOString(), - isHelper: false, - }) - ) + const updateDatabaseIdentifier = (identifier: string) => { + databaseSelector.setSelectedDatabaseId(identifier) + setLastSelectedDatabase(identifier) + sessionSnap.resetResult(id) } return ( @@ -108,55 +102,48 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo - { - e.preventDefault() - switchSource('database') - }} - > - - - Database - - {!isLogs && } - - {(canCreateLogsSnippet || isLogs) && ( + {selectableSources.map((source) => ( { e.preventDefault() - switchSource('logs') + switchSource(source.id) }} > - - Logs + + {QUERY_SOURCE_LABELS[source.id]} - {isLogs && } + {currentSource === source.id && } - )} + ))} {runSource.type === 'logs' ? ( - sessionSnap.setLogRange(id, range)} onOpenCustomRange={() => setIsCustomRangeOpen(true)} onShowUpgrade={() => setShowUpgradePrompt(true)} /> ) : ( <> - {IS_PLATFORM && } + {IS_PLATFORM && ( + + )} @@ -169,7 +156,7 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts index 6c68f56e2db..93f38dcfa72 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts @@ -1,43 +1,11 @@ -import dayjs from 'dayjs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { - datePickerValueToLogDateRange, - DEFAULT_LOG_DATE_RANGE, getSnippetSource, isLogsSource, - logDateRangesEqual, - logDateRangeToDatePickerValue, - resolveLogRunRange, resolveSnippetSource, sqlSourceToFenceLanguage, - type LogDateRange, } from './querySource' -import { - EXPLORER_DATEPICKER_HELPERS, - getDefaultHelper, -} from '@/components/interfaces/Settings/Logs/Logs.constants' -import { generateHelpersFromInput } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' -import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' -import type { DatetimeHelper } from '@/components/interfaces/Settings/Logs/Logs.types' -import { isoDateTimeString } from '@/lib/iso-datetime' - -/** Build the `DatePickerValue` the Logs picker submits when a helper is selected. */ -const valueFromHelper = (helper: DatetimeHelper): DatePickerValue => ({ - from: helper.calcFrom(), - to: helper.calcTo(), - isHelper: true, - text: helper.text, -}) - -/** The single dynamic helper produced from typed input like "2h" / "30m". */ -const dynamicHelper = (input: string): DatetimeHelper => { - const generated = generateHelpersFromInput(input) - if (!generated || generated.length !== 1) { - throw new Error(`Expected a single dynamic helper for "${input}"`) - } - return generated[0] -} describe('querySource.ts:getSnippetSource', () => { it('maps log_sql to the logs source', () => { @@ -91,221 +59,3 @@ describe('querySource.ts:resolveSnippetSource', () => { expect(resolveSnippetSource(undefined, 'nonsense')).toBe('database') }) }) - -describe('querySource.ts:datePickerValueToLogDateRange', () => { - it('parses every static preset into a relative range', () => { - const cases: Array<[string, { amount: number; unit: 'minute' | 'hour' | 'day' }]> = [ - ['Last hour', { amount: 1, unit: 'hour' }], - ['Last 3 hours', { amount: 3, unit: 'hour' }], - ['Last 24 hours', { amount: 24, unit: 'hour' }], - ['Last 3 days', { amount: 3, unit: 'day' }], - ['Last 7 days', { amount: 7, unit: 'day' }], - ] - - for (const [text, last] of cases) { - const helper = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === text) - expect(helper, `preset "${text}" exists`).toBeDefined() - expect(datePickerValueToLogDateRange(valueFromHelper(helper!))).toEqual({ - kind: 'relative', - last, - }) - } - }) - - it('parses dynamic helpers ("2h", "30m", "7d") into relative ranges', () => { - expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('2h')))).toEqual({ - kind: 'relative', - last: { amount: 2, unit: 'hour' }, - }) - expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('30m')))).toEqual({ - kind: 'relative', - last: { amount: 30, unit: 'minute' }, - }) - expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('7d')))).toEqual({ - kind: 'relative', - last: { amount: 7, unit: 'day' }, - }) - }) - - it('treats a preset (calcTo() === "") as relative — the empty end means "now"', () => { - const lastHour = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS) - const value = valueFromHelper(lastHour) - expect(value.to).toBe('') - expect(datePickerValueToLogDateRange(value)).toEqual({ - kind: 'relative', - last: { amount: 1, unit: 'hour' }, - }) - }) - - it('degrades an unparseable helper to an absolute range using from/now (never empty)', () => { - vi.useFakeTimers() - const now = new Date('2025-01-01T12:00:00.000Z') - vi.setSystemTime(now) - - const from = '2024-12-31T00:00:00.000Z' - const result = datePickerValueToLogDateRange({ - from, - to: '', - isHelper: true, - text: 'Some custom label', - }) - - expect(result).toEqual({ - kind: 'absolute', - from, - to: dayjs(now).toISOString(), - }) - }) - - it('maps a custom (non-helper) pick to an absolute range with both endpoints', () => { - const from = '2025-01-01T00:00:00.000Z' - const to = '2025-01-02T00:00:00.000Z' - expect(datePickerValueToLogDateRange({ from, to, isHelper: false })).toEqual({ - kind: 'absolute', - from, - to, - }) - }) - - it('falls back to the default range when the value has no usable from', () => { - expect(datePickerValueToLogDateRange({ from: '', to: '', isHelper: false })).toEqual( - DEFAULT_LOG_DATE_RANGE - ) - }) -}) - -describe('querySource.ts:logDateRangeToDatePickerValue', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z')) - }) - - it('renders a relative range exactly as the picker would emit the helper', () => { - const value = logDateRangeToDatePickerValue({ - kind: 'relative', - last: { amount: 1, unit: 'hour' }, - }) - expect(value).toEqual({ - from: dayjs().subtract(1, 'hour').toISOString(), - to: dayjs().toISOString(), - isHelper: true, - text: 'Last 1 hour', - }) - }) - - it('pluralizes the label for amounts greater than one', () => { - expect( - logDateRangeToDatePickerValue({ kind: 'relative', last: { amount: 3, unit: 'day' } }).text - ).toBe('Last 3 days') - }) - - it('round-trips through datePickerValueToLogDateRange', () => { - const range: LogDateRange = { kind: 'relative', last: { amount: 30, unit: 'minute' } } - expect(datePickerValueToLogDateRange(logDateRangeToDatePickerValue(range))).toEqual(range) - }) - - it('passes an absolute range through as a non-helper value', () => { - const range: LogDateRange = { - kind: 'absolute', - from: isoDateTimeString('2025-01-01T00:00:00.000Z')!, - to: isoDateTimeString('2025-01-02T00:00:00.000Z')!, - } - expect(logDateRangeToDatePickerValue(range)).toEqual({ - from: '2025-01-01T00:00:00.000Z', - to: '2025-01-02T00:00:00.000Z', - isHelper: false, - }) - }) -}) - -describe('querySource.ts:resolveLogRunRange', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('re-resolves a relative range against the current time', () => { - vi.useFakeTimers() - const now = new Date('2025-06-15T08:30:00.000Z') - vi.setSystemTime(now) - - expect(resolveLogRunRange({ kind: 'relative', last: { amount: 2, unit: 'hour' } })).toEqual({ - from: dayjs(now).subtract(2, 'hour').toISOString(), - to: dayjs(now).toISOString(), - }) - }) - - it('re-resolves the same relative range differently as time advances', () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2025-06-15T08:00:00.000Z')) - const first = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } }) - - vi.setSystemTime(new Date('2025-06-15T10:00:00.000Z')) - const second = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } }) - - expect(first).not.toEqual(second) - expect(second.to).toBe(dayjs('2025-06-15T10:00:00.000Z').toISOString()) - }) - - it('passes an absolute range through unchanged', () => { - const from = isoDateTimeString('2025-01-01T00:00:00.000Z')! - const to = isoDateTimeString('2025-01-02T00:00:00.000Z')! - expect(resolveLogRunRange({ kind: 'absolute', from, to })).toEqual({ - from: '2025-01-01T00:00:00.000Z', - to: '2025-01-02T00:00:00.000Z', - }) - }) -}) - -describe('querySource.ts:logDateRangesEqual', () => { - it('matches relative ranges on amount + unit regardless of label formatting', () => { - const lastHourPreset = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === 'Last hour')! - const presetRange = datePickerValueToLogDateRange({ - from: lastHourPreset.calcFrom(), - to: lastHourPreset.calcTo(), - isHelper: true, - text: lastHourPreset.text, - }) - - expect( - logDateRangesEqual(presetRange, { kind: 'relative', last: { amount: 1, unit: 'hour' } }) - ).toBe(true) - }) - - it('does not match relative ranges with a different amount or unit', () => { - expect( - logDateRangesEqual( - { kind: 'relative', last: { amount: 1, unit: 'hour' } }, - { kind: 'relative', last: { amount: 3, unit: 'hour' } } - ) - ).toBe(false) - expect( - logDateRangesEqual( - { kind: 'relative', last: { amount: 1, unit: 'hour' } }, - { kind: 'relative', last: { amount: 1, unit: 'day' } } - ) - ).toBe(false) - }) - - it('matches absolute ranges on their ISO endpoints', () => { - const from = isoDateTimeString('2025-01-01T00:00:00.000Z')! - const to = isoDateTimeString('2025-01-02T00:00:00.000Z')! - expect(logDateRangesEqual({ kind: 'absolute', from, to }, { kind: 'absolute', from, to })).toBe( - true - ) - }) - - it('never matches a relative range against an absolute one', () => { - const from = isoDateTimeString('2025-01-01T00:00:00.000Z')! - const to = isoDateTimeString('2025-01-02T00:00:00.000Z')! - expect( - logDateRangesEqual( - { kind: 'relative', last: { amount: 1, unit: 'hour' } }, - { - kind: 'absolute', - from, - to, - } - ) - ).toBe(false) - }) -}) diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.ts b/apps/studio/components/interfaces/SQLEditor/querySource.ts index 4a1174f8375..49f57612446 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.ts @@ -1,11 +1,5 @@ -import dayjs from 'dayjs' - -import type { Unit } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' -import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' -import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' -import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange' import type { Snippet } from '@/data/content/sql-folders-query' -import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime' +import { type LogTimeRange, type QuerySourceId } from '@/data/query-sources/query-source-registry' /** * Domain view of where a snippet's query runs. Derived from the content TYPE: @@ -14,7 +8,7 @@ import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime' * immutable — switching backends means creating a new snippet, not toggling this * value. */ -export type SqlSnippetSource = 'database' | 'logs' +export type SqlSnippetSource = QuerySourceId /** * The single reader every surface (AI, reports, tabs, nav, execution) uses to @@ -61,128 +55,10 @@ export function resolveSnippetSource( return snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam) } -/** `now` as a branded ISO datetime — `toISOString()` is always valid ISO-8601. */ -function nowIsoDateTime(): IsoDateTimeString { - return dayjs().toISOString() as IsoDateTimeString -} - -/** - * The units a relative log range is expressed in. Aliases the Logs date picker's - * `Unit` so the two stay in lockstep rather than drifting as parallel unions. - */ -export type RelativeTimeUnit = Unit - -/** - * A log query's time range. Relative ranges are structural (amount + unit) and - * re-resolve against `now` at every run — so a saved "last hour" always means the - * hour before the run, not the hour before the snippet was opened. Absolute ranges - * carry validated ISO datetimes and pass through unchanged. - */ -export type LogDateRange = - | { kind: 'relative'; last: { amount: number; unit: RelativeTimeUnit } } - | { kind: 'absolute'; from: IsoDateTimeString; to: IsoDateTimeString } - -/** The range a freshly opened logs snippet starts with: the last hour. */ -export const DEFAULT_LOG_DATE_RANGE: LogDateRange = { - kind: 'relative', - last: { amount: 1, unit: 'hour' }, -} - /** * 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: LogDateRange } - -/** - * Parse a date-picker helper's label (e.g. "Last hour", "Last 3 hours", "Last 30 - * minutes") into a relative amount/unit. Covers both the static presets in - * `EXPLORER_DATEPICKER_HELPERS` and the dynamic helpers `generateHelpersFromInput` - * produces from typed input like "2h"/"30m". A label with no number means one unit - * ("Last hour"). Returns null for any other label. - */ -function parseRelativeHelperLabel( - text: string | undefined -): { amount: number; unit: RelativeTimeUnit } | null { - if (!text) return null - const match = text - .trim() - .toLowerCase() - .match(/^last\s+(?:(\d+)\s+)?(minute|hour|day)s?$/) - if (!match) return null - const amount = match[1] ? parseInt(match[1], 10) : 1 - if (!Number.isFinite(amount) || amount <= 0) return null - const unit = match[2] - if (unit !== 'minute' && unit !== 'hour' && unit !== 'day') return null - return { amount, unit } -} - -/** - * Convert a Logs date-picker value into a `LogDateRange`. Helper picks (presets and - * dynamic "2h"/"30m" helpers) become relative ranges by parsing the helper label; - * a preset's `calcTo()` resolves to `''` (meaning "now"), which the relative variant - * models implicitly. Everything else — custom calendar picks, or a helper whose label - * we can't parse — becomes an absolute range with validated ISO datetimes, degrading - * via `from`/now rather than rejecting an empty string. A value with no usable `from` - * falls back to the default range. - */ -export function datePickerValueToLogDateRange(value: DatePickerValue): LogDateRange { - if (value.isHelper) { - const relative = parseRelativeHelperLabel(value.text) - if (relative) return { kind: 'relative', last: relative } - } - - const from = isoDateTimeString(value.from) - if (from === null) return DEFAULT_LOG_DATE_RANGE - const to = isoDateTimeString(value.to) ?? nowIsoDateTime() - return { kind: 'absolute', from, to } -} - -/** - * Render a `LogDateRange` back into a Logs date-picker value for display. Relative - * ranges reuse the picker's own `generateDynamicHelper` to derive the resolved - * `from`/`to` and matching "Last N unit(s)" label, so the value is byte-for-byte - * what the picker itself would emit for that helper. Absolute ranges pass their - * datetimes through. - */ -export function logDateRangeToDatePickerValue(range: LogDateRange): DatePickerValue { - if (range.kind === 'relative') { - const helper = generateDynamicHelper(range.last.amount, range.last.unit) - return { from: helper.calcFrom(), to: helper.calcTo(), isHelper: true, text: helper.text } - } - return { from: range.from, to: range.to, isHelper: false } -} - -/** - * Structural equality for two log date ranges. Relative ranges match on amount + - * unit, NOT display text — "Last hour" and "Last 1 hour" render differently but - * are the same range, so comparing labels is unreliable. Absolute ranges match on - * their (validated) ISO endpoints. - */ -export function logDateRangesEqual(a: LogDateRange, b: LogDateRange): boolean { - if (a.kind === 'relative' && b.kind === 'relative') { - return a.last.amount === b.last.amount && a.last.unit === b.last.unit - } - if (a.kind === 'absolute' && b.kind === 'absolute') { - return a.from === b.from && a.to === b.to - } - return false -} - -/** - * Resolve a `LogDateRange` to concrete ISO endpoints for a run. Relative ranges - * re-resolve against `now` (so "last hour" is always the hour before the run); - * absolute ranges pass through. Reuses the Logs `ResolvedLogDateRange` shape. - */ -export function resolveLogRunRange(range: LogDateRange): ResolvedLogDateRange { - if (range.kind === 'relative') { - const now = dayjs() - return { - from: now.subtract(range.last.amount, range.last.unit).toISOString(), - to: now.toISOString(), - } - } - return { from: range.from, to: range.to } -} +export type QuerySource = { type: 'database' } | { type: 'logs'; dateRange: LogTimeRange } diff --git a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx index 38e3fb8634b..97bd6bdd789 100644 --- a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx @@ -99,8 +99,9 @@ describe('useLogsSqlExecution', () => { it('resolves a relative session range to a from/to window around now', async () => { const captured = mockLogsAllOtel([]) sqlEditorSessionState.setLogRange(SNIPPET_ID, { - kind: 'relative', - last: { amount: 2, unit: 'hour' }, + type: 'relative', + amount: 2, + unit: 'hour', }) const { result } = renderLogsExecution() diff --git a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts index 4c1c006bfd7..ddbd7fd3175 100644 --- a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts +++ b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts @@ -1,10 +1,13 @@ import { useFlag, useParams } from 'common' import { useCallback } from 'react' -import { DEFAULT_LOG_DATE_RANGE, resolveLogRunRange } from './querySource' +import { resolveLogTimeRange } from '@/components/interfaces/QuerySources/LogTimeRange.utils' import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' -import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' import { type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' +import { + DEFAULT_LOG_TIME_RANGE, + QUERY_SOURCE_REGISTRY, +} from '@/data/query-sources/query-source-registry' import { useTrack } from '@/lib/telemetry/track' import { getSqlEditorSessionSnapshot, @@ -46,11 +49,11 @@ export function useLogsSqlExecution({ id }: UseLogsSqlExecutionArgs) { // Re-read imperatively so a range picked immediately before the run is // honored; relative ranges re-resolve against `now` here. - const range = resolveLogRunRange( - getSqlEditorSessionSnapshot().logRange[id] ?? DEFAULT_LOG_DATE_RANGE + const range = resolveLogTimeRange( + getSqlEditorSessionSnapshot().logRange[id] ?? DEFAULT_LOG_TIME_RANGE ) - mutate({ projectRef, sql, range, endpoint: logsAllEndpointUrl(true) }) + mutate({ projectRef, sql, range, endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint }) track('sql_editor_query_run_button_clicked', { source: 'logs' }) }, diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx index 820caa97c00..3d63dece309 100644 --- a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { DEFAULT_LOG_DATE_RANGE } from './querySource' import { useRunSource } from './useRunSource' +import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry' import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' import { renderSqlEditorHook, @@ -31,22 +31,23 @@ describe('useRunSource', () => { const { result } = renderSqlEditorHook(() => useRunSource(id)) - expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_DATE_RANGE }) + expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_TIME_RANGE }) }) it('resolves a logs snippet to its session-stored range when one is set', () => { const id = 'logs-snippet-custom-range' seedSnippet({ id, source: 'logs' }) sqlEditorSessionState.setLogRange(id, { - kind: 'relative', - last: { amount: 2, unit: 'hour' }, + type: 'relative', + amount: 2, + unit: 'hour', }) const { result } = renderSqlEditorHook(() => useRunSource(id)) expect(result.current).toEqual({ type: 'logs', - dateRange: { kind: 'relative', last: { amount: 2, unit: 'hour' } }, + dateRange: { type: 'relative', amount: 2, unit: 'hour' }, }) }) }) diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts index 95c83d782a5..b9362d48279 100644 --- a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts @@ -1,12 +1,8 @@ import { useParams } from 'common' import { useMemo } from 'react' -import { - DEFAULT_LOG_DATE_RANGE, - isLogsSource, - resolveSnippetSource, - type QuerySource, -} from './querySource' +import { isLogsSource, resolveSnippetSource, type QuerySource } from './querySource' +import { DEFAULT_LOG_TIME_RANGE } 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' @@ -33,7 +29,7 @@ export function useRunSource(id: string): QuerySource { return useMemo(() => { if (isLogsSource(source)) { - return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_DATE_RANGE } + return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_TIME_RANGE } } return { type: 'database' } }, [source, logRange]) diff --git a/apps/studio/data/query-sources/query-source-registry.test.ts b/apps/studio/data/query-sources/query-source-registry.test.ts new file mode 100644 index 00000000000..03c2045655b --- /dev/null +++ b/apps/studio/data/query-sources/query-source-registry.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' + +import { + cellSourceSchema, + createDefaultCellSource, + getQuerySource, + logTimeRangeSchema, + QUERY_SOURCES, +} from './query-source-registry' + +describe('query source registry', () => { + it('registers database and logs sources with their execution endpoints', () => { + expect(QUERY_SOURCES.map(({ id }) => id)).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') + + expect(cellSourceSchema.parse(first)).toEqual({ + id: 'logs', + type: 'logs', + parameters: { + time_range: { type: 'relative', 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: { type: 'relative', amount: 1, unit: 'hour' } }, + }) + ).toThrow() + + expect(() => + cellSourceSchema.parse({ + id: 'logs', + type: 'logs', + parameters: { time_range: { type: 'relative', amount: 2, unit: 'week' } }, + }) + ).toThrow() + }) + + it('rejects absolute ranges that do not move forward in time', () => { + expect( + logTimeRangeSchema.safeParse({ + type: 'absolute', + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-02T00:00:00.000Z', + }).success + ).toBe(true) + + const equal = logTimeRangeSchema.safeParse({ + type: 'absolute', + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-01T00:00:00.000Z', + }) + expect(equal.success).toBe(false) + expect(equal.error?.issues[0].path).toEqual(['to']) + + expect( + logTimeRangeSchema.safeParse({ + type: 'absolute', + from: '2025-01-02T00:00:00.000Z', + to: '2025-01-01T00:00:00.000Z', + }).success + ).toBe(false) + + expect(() => + cellSourceSchema.parse({ + id: 'logs', + type: 'logs', + parameters: { + time_range: { + type: 'absolute', + from: '2025-01-02T00:00:00.000Z', + to: '2025-01-01T00:00:00.000Z', + }, + }, + }) + ).toThrow() + }) + + it('reports an invalid endpoint against its own field rather than the ordering rule', () => { + const result = logTimeRangeSchema.safeParse({ + type: 'absolute', + from: 'not-a-date', + to: '2025-01-01T00:00:00.000Z', + }) + + expect(result.success).toBe(false) + expect(result.error?.issues).toHaveLength(1) + expect(result.error?.issues[0].path).toEqual(['from']) + }) +}) diff --git a/apps/studio/data/query-sources/query-source-registry.ts b/apps/studio/data/query-sources/query-source-registry.ts new file mode 100644 index 00000000000..eb8124a4f58 --- /dev/null +++ b/apps/studio/data/query-sources/query-source-registry.ts @@ -0,0 +1,152 @@ +import dayjs from 'dayjs' +import * as z from 'zod' + +import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' +import { isoDateTimeString } from '@/lib/iso-datetime' + +export type LogTimeRange = + | { + type: 'relative' + amount: number + unit: 'minute' | 'hour' | 'day' + } + | { + type: 'absolute' + from: string + to: string + } + +export type DatabaseSource = { + id: 'database' + type: '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 + } +} + +export type LogsSource = { + id: 'logs' + type: 'logs' + endpoint: ReturnType + parameters: { + time_range: LogTimeRange + } +} + +/** Sources are registered by Studio; query surfaces only store a source binding. */ +export type Source = DatabaseSource | LogsSource + +export type CellSourceOf = Pick + +export type CellSource = CellSourceOf | CellSourceOf + +export const DEFAULT_LOG_TIME_RANGE: LogTimeRange = { + type: 'relative', + amount: 1, + unit: 'hour', +} + +export const QUERY_SOURCE_REGISTRY = { + database: { + id: 'database', + type: 'database', + endpoint: '/platform/pg-meta/{ref}/query', + parameters: {}, + }, + logs: { + id: 'logs', + type: 'logs', + endpoint: logsAllEndpointUrl(true), + parameters: { time_range: DEFAULT_LOG_TIME_RANGE }, + }, +} as const satisfies Record + +export type QuerySourceId = keyof typeof QUERY_SOURCE_REGISTRY + +export const QUERY_SOURCES = Object.values(QUERY_SOURCE_REGISTRY) satisfies Source[] + +export const QUERY_SOURCE_LABELS: Record = { + database: 'Database', + logs: 'Logs', +} + +const isoDateTimeSchema = z.string().refine((value) => isoDateTimeString(value) !== null, { + message: 'must be a valid ISO-8601 datetime', +}) + +export const logTimeRangeSchema = z + .discriminatedUnion('type', [ + z + .object({ + type: z.literal('relative'), + amount: z.number().int().positive(), + unit: z.enum(['minute', 'hour', 'day']), + }) + .strict(), + z + .object({ + type: z.literal('absolute'), + from: isoDateTimeSchema, + to: isoDateTimeSchema, + }) + .strict(), + ]) + .refine( + (range) => { + if (range.type !== 'absolute') return true + + const from = dayjs(range.from) + const to = dayjs(range.to) + // An unparseable endpoint is already reported against its own field; the + // ordering rule stays quiet so it doesn't add a second, misleading issue. + if (!from.isValid() || !to.isValid()) return true + + return to.isAfter(from) + }, + { + message: 'must be later than the start of the range', + path: ['to'], + } + ) + +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: logTimeRangeSchema }).strict(), + }) + .strict(), +]) + +export function createDefaultCellSource(id: 'database'): CellSourceOf +export function createDefaultCellSource(id: 'logs'): CellSourceOf +export function createDefaultCellSource(id: QuerySourceId): CellSource +export function createDefaultCellSource(id: QuerySourceId): CellSource { + const source = QUERY_SOURCE_REGISTRY[id] + + if (source.type === 'logs') { + return { + id: source.id, + type: source.type, + parameters: { time_range: { ...source.parameters.time_range } }, + } + } + + return { id: source.id, type: source.type, parameters: { ...source.parameters } } +} + +export const getQuerySource = (id: QuerySourceId): Source => QUERY_SOURCE_REGISTRY[id] diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts index 3718fac1f59..d1c2122cbf6 100644 --- a/apps/studio/data/sql/execute-sql-mutation.ts +++ b/apps/studio/data/sql/execute-sql-mutation.ts @@ -12,6 +12,7 @@ import { createNodeTree, } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser' import { handleError as handleErrorFetchers, post } from '@/data/fetchers' +import { QUERY_SOURCE_REGISTRY } from '@/data/query-sources/query-source-registry' import { MB } from '@/lib/constants' import { sqlEventParser } from '@/lib/sql-event-parser' import { useTrack } from '@/lib/telemetry/track' @@ -159,7 +160,7 @@ export async function executeSql( const key = queryKey?.filter((seg) => typeof seg === 'string' || typeof seg === 'number').join('-') ?? '' - const result = await post('/platform/pg-meta/{ref}/query', { + const result = await post(QUERY_SOURCE_REGISTRY.database.endpoint, { ...options, body: { query: sql, disable_statement_timeout: isStatementTimeoutDisabled }, params: { diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.ts b/apps/studio/state/sql-editor/sql-editor-session-state.ts index 38aeafc030c..b7813f58333 100644 --- a/apps/studio/state/sql-editor/sql-editor-session-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-session-state.ts @@ -1,6 +1,6 @@ import { proxy, ref, snapshot, useSnapshot } from 'valtio' -import type { LogDateRange } from '@/components/interfaces/SQLEditor/querySource' +import type { LogTimeRange } from '@/data/query-sources/query-source-registry' /** * Ephemeral, per-session SQL editor state that is NOT persisted: query results, @@ -35,11 +35,11 @@ export const sqlEditorSessionState = proxy({ * The logs time range for a logs snippet, keyed by snippet id. Session state — * never written to snippet content — so it works on read-only shared snippets * and resets on reload. An unset snippet has no entry; read sites fall back to - * `DEFAULT_LOG_DATE_RANGE`. + * `DEFAULT_LOG_TIME_RANGE`. */ - logRange: {} as { [snippetId: string]: LogDateRange }, + logRange: {} as { [snippetId: string]: LogTimeRange }, - setLogRange: (id: string, range: LogDateRange) => { + setLogRange: (id: string, range: LogTimeRange) => { sqlEditorSessionState.logRange[id] = range }, diff --git a/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx index 6985f0ad822..d1ef963eb63 100644 --- a/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx +++ b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx @@ -1,42 +1,58 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { mockAnimationsApi } from 'jsdom-testing-mocks' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' -import { DEFAULT_LOG_DATE_RANGE } from '@/components/interfaces/SQLEditor/querySource' import { QuerySourceMenu } from '@/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu' +import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock } from '@/tests/lib/msw' // QuerySourceMenu renders a Radix dropdown (+ nested dialog), both of which use Web Animations. mockAnimationsApi() -addAPIMock({ - method: 'get', - path: '/platform/projects/:ref', - response: { - id: 1, - ref: 'default', - organization_id: 1, - name: 'Test Project', - status: 'ACTIVE_HEALTHY', - cloud_provider: 'AWS', - region: 'us-east-1', - db_host: 'db.default.supabase.co', - restUrl: 'https://default.supabase.co/rest/v1/', - inserted_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - subscription_id: 'sub_123', - is_branch_enabled: false, - is_physical_backups_enabled: false, - high_availability: false, - integration_source: null, - connectionString: 'postgresql://postgres@localhost:5432/postgres', - is_hibernating: false, - }, +beforeEach(() => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: { + id: 1, + ref: 'default', + organization_id: 1, + name: 'Test Project', + status: 'ACTIVE_HEALTHY', + cloud_provider: 'AWS', + region: 'us-east-1', + db_host: 'db.default.supabase.co', + restUrl: 'https://default.supabase.co/rest/v1/', + inserted_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + subscription_id: 'sub_123', + is_branch_enabled: false, + is_physical_backups_enabled: false, + high_availability: false, + integration_source: null, + connectionString: 'postgresql://postgres@localhost:5432/postgres', + is_hibernating: false, + }, + }) }) describe('QuerySourceMenu', () => { + it('hides logs when creating logs queries is unavailable', async () => { + customRender( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' })) + + expect(screen.queryByText('Logs')).not.toBeInTheDocument() + }) + it('keeps the dropdown open across a source switch, so the new source’s controls appear without reopening it', async () => { // Selecting a source doesn't mutate `runSource` in place — it navigates to a // fresh tab, and the parent re-renders this component with the new source once @@ -57,7 +73,7 @@ describe('QuerySourceMenu', () => { rerender( ) diff --git a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx index 09e328d5f4e..c9bff95574f 100644 --- a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx +++ b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx @@ -96,7 +96,7 @@ describe('generateDynamicHelper', () => { }) describe('generateDynamicHelpers', () => { - test('generates 3 helpers for minutes, hours, days', () => { + test('generates helpers for every supported relative unit', () => { const helpers = generateDynamicHelpers(5) expect(helpers).toHaveLength(3) expect(helpers[0].text).toBe('Last 5 minutes') @@ -112,7 +112,7 @@ describe('generateHelpersFromInput', () => { expect(generateHelpersFromInput('2yoie')).toBeNull() }) - test('returns 3 helpers for number only input', () => { + test('returns a helper for every unit for number only input', () => { const helpers = generateHelpersFromInput('25') expect(helpers).toHaveLength(3) expect(helpers![0].text).toBe('Last 25 minutes')