From c0e109f6623adc4afb26fadbfa4d86eea8cdd7b4 Mon Sep 17 00:00:00 2001 From: Charis Date: Fri, 14 Aug 2026 02:45:41 -0400 Subject: [PATCH] refactor(studio): borrow the wire schema's time range in the query-source registry (#49070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of a stack. **Stacked on #49069** — review that one first; this PR's diff only makes sense on top of it. Base will retarget to `master` automatically when #49069 merges. Net `-72` lines. No behavior change beyond the one noted at the bottom. ## The problem The query-source registry carried its own `LogTimeRange` type and `logTimeRangeSchema`, which had drifted from the notebook wire schema's copy in four ways: | | wire schema | registry | |---|---|---| | discriminant | `_tag: 'relative_time_range'` | `type: 'relative'` | | absolute bounds | `start` / `end` | `from` / `to` | | relative units | minute…year | minute, hour, day | | validation | none | positive int, end-after-start | Two definitions of one concept, neither convertible to the other without a lossy mapping — and the notebook query cell was papering over it by discarding a log cell's persisted range and substituting a default. ## What changed #49069 moved the validations onto the wire schema's `timeRangeSchema` and exported it. This PR deletes the registry's copy and points every consumer at `TimeRange`. The registry keeps what is genuinely runtime: endpoints, labels, availability, defaults. The field renames ripple mechanically through the logs date-picker helpers, the time-range submenu, `useLogsCustomRange`, the SQL editor's session state, and their tests. Coverage for the absolute-range and unit rules moved to `notebook-schema.test.ts` in #49069, alongside the schema that now owns them. `ExplorerQuerySourceMenu` also drops its hand-rolled custom-range construction in favor of `customDateRangeToLogTimeRange`, which already existed and does the same clamping. ## One behavior change `logTimeRangeToDatePickerValue` now renders a range whose unit has no picker preset (week, month, year — allowed by the wire schema, not offered in the UI) as a resolved absolute range, instead of trying and failing to build a helper for it. Previously unreachable, since the registry's narrower unit set made those ranges unrepresentable. ## Verification Typecheck, Prettier, and the lint ratchet clean. 401 tests pass across the notebook schema, query sources, the logs source components, the SQL editor, and the Explorer surfaces. ## Summary by CodeRabbit * **Bug Fixes** * Improved log time-range handling across Explorer and SQL Editor. * Custom date ranges now display and resolve correctly, including clamping invalid ranges. * Unsupported relative time units are converted to compatible absolute date-picker values. * **Refactor** * Standardized log queries on a shared time-range format for more consistent validation and behavior. --------- Co-authored-by: Joshen Lim --- .../Explorer/ExplorerQuerySourceMenu.test.tsx | 8 +- .../interfaces/Explorer/QueryTab.test.tsx | 6 +- .../QuerySources/LogTimeRange.utils.test.ts | 55 ++++++++------ .../QuerySources/LogTimeRange.utils.ts | 75 ++++++++++++------- .../LogsTimeRangeSubMenu.test.tsx | 12 +-- .../QuerySources/LogsTimeRangeSubMenu.tsx | 10 +-- .../QuerySources/useLogsCustomRange.ts | 6 +- .../interfaces/SQLEditor/querySource.ts | 5 +- .../SQLEditor/useLogsSqlExecution.test.tsx | 2 +- .../SQLEditor/useRunSource.test.tsx | 8 +- .../query-source-registry.test.ts | 51 ++----------- .../query-sources/query-source-registry.ts | 62 ++------------- apps/studio/state/explorer-query.test.ts | 4 +- .../sql-editor/sql-editor-session-state.ts | 6 +- 14 files changed, 124 insertions(+), 186 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx index 18f0e01c17c..4ab83597eb5 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx @@ -56,7 +56,7 @@ describe('ExplorerQuerySourceMenu', () => { source={{ id: 'logs', type: 'logs', - parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' } }, }} onSourceChange={onSourceChange} /> @@ -80,7 +80,7 @@ describe('ExplorerQuerySourceMenu', () => { source={{ id: 'logs', type: 'logs', - parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' } }, }} onSourceChange={onSourceChange} /> @@ -93,7 +93,7 @@ describe('ExplorerQuerySourceMenu', () => { expect(onSourceChange).toHaveBeenCalledWith({ id: 'logs', type: 'logs', - parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' } }, }) }) @@ -113,7 +113,7 @@ describe('ExplorerQuerySourceMenu', () => { { id: 'logs', type: 'logs', - parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' } }, }, { sqlEditorLogsSource: false, otelLegacyLogs: false } ) diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx index b732645d8a5..aa791710436 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx @@ -48,7 +48,7 @@ const createDraft = ( id: 'logs' type: 'logs' parameters: { - time_range: { type: 'relative'; amount: number; unit: 'hour' } + time_range: { _tag: 'relative_time_range'; amount: number; unit: 'hour' } } } ) => { @@ -85,7 +85,7 @@ describe('QueryTab execution', () => { createDraft({ id: 'logs', type: 'logs', - parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' } }, }) const requests: Request[] = [] addAPIMock({ @@ -154,7 +154,7 @@ describe('QueryTab execution', () => { createDraft({ id: 'logs', type: 'logs', - parameters: { time_range: { type: 'relative', amount: 2, unit: 'hour' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 2, unit: 'hour' } }, }) const bodies: Array<{ iso_timestamp_start: string; iso_timestamp_end: string }> = [] addAPIMock({ diff --git a/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts index f12ec9cef66..3f54ab9fbc9 100644 --- a/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts +++ b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts @@ -9,10 +9,12 @@ import { 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' +import { timeRangeSchema, type TimeRange } from '@/data/content/notebooks/notebook-schema' +import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry' + +/** Absolute bounds are branded ISO strings, so build them through the schema. */ +const absolute = (start: string, end: string): TimeRange => + timeRangeSchema.parse({ _tag: 'absolute_time_range', start, end }) describe('LogTimeRange.utils', () => { beforeEach(() => { @@ -34,12 +36,22 @@ describe('LogTimeRange.utils', () => { isHelper: true, text: helper.text, } - const range: LogTimeRange = { type: 'relative', amount, unit } + const range: TimeRange = { _tag: 'relative_time_range', amount, unit } expect(datePickerValueToLogTimeRange(pickerValue)).toEqual(range) expect(logTimeRangeToDatePickerValue(range)).toEqual(pickerValue) }) + it('renders a relative unit the picker has no preset for as a resolved absolute range', () => { + expect( + logTimeRangeToDatePickerValue({ _tag: 'relative_time_range', amount: 2, unit: 'month' }) + ).toEqual({ + from: dayjs().subtract(2, 'month').toISOString(), + to: dayjs().toISOString(), + isHelper: false, + }) + }) + 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 @@ -55,47 +67,46 @@ describe('LogTimeRange.utils', () => { text: 'Custom', }) ).toEqual({ - type: 'absolute', - from: '2025-01-01T00:00:00.000Z', - to: '2025-01-08T12:00:00.000Z', + _tag: 'absolute_time_range', + start: '2025-01-01T00:00:00.000Z', + end: '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' } + { _tag: 'relative_time_range', amount: 1, unit: 'hour' }, + { _tag: 'relative_time_range', amount: 1, unit: 'hour' } ) ).toBe(true) expect( logTimeRangesEqual( - { type: 'relative', amount: 1, unit: 'hour' }, - { type: 'relative', amount: 1, unit: 'day' } + { _tag: 'relative_time_range', amount: 1, unit: 'hour' }, + { _tag: 'relative_time_range', 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' } + absolute('2025-01-01T00:00:00.000Z', '2025-01-02T00:00:00.000Z'), + absolute('2025-01-01T00:00:00.000Z', '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({ + expect(resolveLogTimeRange({ _tag: 'relative_time_range', 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', + const range = absolute('2025-01-01T00:00:00.000Z', '2025-01-02T00:00:00.000Z') + expect(resolveLogTimeRange(range)).toEqual({ 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', () => { @@ -106,9 +117,9 @@ describe('LogTimeRange.utils', () => { 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', + _tag: 'absolute_time_range', + start: dayjs(from).startOf('day').toISOString(), + end: '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 index 4dd9f9ff100..910291b44dd 100644 --- a/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.ts +++ b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.ts @@ -1,17 +1,32 @@ import dayjs from 'dayjs' -import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import { + generateDynamicHelper, + type Unit, +} 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 { type TimeRange } from '@/data/content/notebooks/notebook-schema' +import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry' import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime' -export type RelativeTimeUnit = Extract['unit'] +export type RelativeTimeUnit = Extract['unit'] -const nowIsoDateTime = (): IsoDateTimeString => dayjs().toISOString() as IsoDateTimeString +/** + * The relative units the logs date picker can render as a helper preset. The wire schema + * allows coarser units (week, month, year) that the picker has no preset for; a range + * using one of those is shown as a resolved absolute range instead. + */ +const PICKER_TIME_UNITS: readonly Unit[] = ['minute', 'hour', 'day'] + +const isPickerUnit = (unit: RelativeTimeUnit): unit is Unit => + (PICKER_TIME_UNITS as readonly RelativeTimeUnit[]).includes(unit) + +/** `toISOString()` is valid ISO-8601 by construction, so it needs no re-validation. */ +const toIsoDateTime = (value: dayjs.Dayjs): IsoDateTimeString => + value.toISOString() as IsoDateTimeString + +const nowIsoDateTime = (): IsoDateTimeString => toIsoDateTime(dayjs()) function parseRelativeHelperLabel( text: string | undefined @@ -31,20 +46,22 @@ function parseRelativeHelperLabel( return { amount, unit } } -export function datePickerValueToLogTimeRange(value: DatePickerValue): LogTimeRange { +export function datePickerValueToLogTimeRange(value: DatePickerValue): TimeRange { if (value.isHelper) { const relative = parseRelativeHelperLabel(value.text) - if (relative) return { type: 'relative', ...relative } + if (relative) { + return { _tag: 'relative_time_range', amount: relative.amount, unit: relative.unit } + } } - const from = isoDateTimeString(value.from) - if (from === null) return DEFAULT_LOG_TIME_RANGE - const to = isoDateTimeString(value.to) ?? nowIsoDateTime() - return { type: 'absolute', from, to } + const start = isoDateTimeString(value.from) + if (start === null) return DEFAULT_LOG_TIME_RANGE + const end = isoDateTimeString(value.to) ?? nowIsoDateTime() + return { _tag: 'absolute_time_range', start, end } } -export function logTimeRangeToDatePickerValue(range: LogTimeRange): DatePickerValue { - if (range.type === 'relative') { +export function logTimeRangeToDatePickerValue(range: TimeRange): DatePickerValue { + if (range._tag === 'relative_time_range' && isPickerUnit(range.unit)) { const helper = generateDynamicHelper(range.amount, range.unit) return { from: helper.calcFrom(), @@ -53,7 +70,9 @@ export function logTimeRangeToDatePickerValue(range: LogTimeRange): DatePickerVa text: helper.text, } } - return { from: range.from, to: range.to, isHelper: false } + + const resolved = resolveLogTimeRange(range) + return { from: resolved.from, to: resolved.to, isHelper: false } } export function customDateRangeToLogTimeRange({ @@ -64,34 +83,34 @@ export function customDateRangeToLogTimeRange({ from: Date to: Date now?: Date -}): Extract { +}): Extract { const nowValue = dayjs(now) - const requestedTo = dayjs(to).endOf('day') + const requestedEnd = dayjs(to).endOf('day') return { - type: 'absolute', - from: dayjs(from).startOf('day').toISOString(), - to: requestedTo.isAfter(nowValue) ? nowValue.toISOString() : requestedTo.toISOString(), + _tag: 'absolute_time_range', + start: toIsoDateTime(dayjs(from).startOf('day')), + end: toIsoDateTime(requestedEnd.isAfter(nowValue) ? nowValue : requestedEnd), } } -export function logTimeRangesEqual(a: LogTimeRange, b: LogTimeRange): boolean { - if (a.type === 'relative' && b.type === 'relative') { +export function logTimeRangesEqual(a: TimeRange, b: TimeRange): boolean { + if (a._tag === 'relative_time_range' && b._tag === 'relative_time_range') { 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 + if (a._tag === 'absolute_time_range' && b._tag === 'absolute_time_range') { + return a.start === b.start && a.end === b.end } return false } -export function resolveLogTimeRange(range: LogTimeRange): ResolvedLogDateRange { - if (range.type === 'relative') { +export function resolveLogTimeRange(range: TimeRange): ResolvedLogDateRange { + if (range._tag === 'relative_time_range') { const now = dayjs() return { from: now.subtract(range.amount, range.unit).toISOString(), to: now.toISOString(), } } - return { from: range.from, to: range.to } + return { from: range.start, to: range.end } } diff --git a/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx index 7d93fdc0133..d1acdc39df3 100644 --- a/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx +++ b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx @@ -5,7 +5,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' import { describe, expect, it, vi, type Mock } from 'vitest' import { LogsTimeRangeSubMenu } from './LogsTimeRangeSubMenu' -import type { LogTimeRange } from '@/data/query-sources/query-source-registry' +import { type TimeRange } from '@/data/content/notebooks/notebook-schema' import { customRender } from '@/tests/lib/custom-render' mockAnimationsApi() @@ -15,15 +15,15 @@ vi.mock('@/hooks/misc/useCheckEntitlements', () => ({ })) const renderSubMenu = ({ - onRangeChange = vi.fn<(range: LogTimeRange) => void>(), + onRangeChange = vi.fn<(range: TimeRange) => void>(), onOpenCustomRange = vi.fn<() => void>(), onShowUpgrade = vi.fn<() => void>(), - range = { type: 'relative', amount: 1, unit: 'hour' } as LogTimeRange, + range = { _tag: 'relative_time_range', amount: 1, unit: 'hour' } as TimeRange, }: { - onRangeChange?: Mock<(range: LogTimeRange) => void> + onRangeChange?: Mock<(range: TimeRange) => void> onOpenCustomRange?: Mock<() => void> onShowUpgrade?: Mock<() => void> - range?: LogTimeRange + range?: TimeRange } = {}) => { customRender( @@ -63,7 +63,7 @@ describe('LogsTimeRangeSubMenu', () => { }) it('marks the structurally matching preset as selected', async () => { - renderSubMenu({ range: { type: 'relative', amount: 3, unit: 'hour' } }) + renderSubMenu({ range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' } }) await userEvent.hover(await screen.findByText('Time range')) diff --git a/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx index 9830e206a14..ebbd12a185a 100644 --- a/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx +++ b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx @@ -11,7 +11,7 @@ import { 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 type { TimeRange } from '@/data/content/notebooks/notebook-schema' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' export const LogsTimeRangeSubMenu = ({ @@ -20,15 +20,15 @@ export const LogsTimeRangeSubMenu = ({ onOpenCustomRange, onShowUpgrade, }: { - range: LogTimeRange - onRangeChange: (range: LogTimeRange) => void + range: TimeRange + onRangeChange: (range: TimeRange) => void onOpenCustomRange: () => void onShowUpgrade: () => void }) => { const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') const entitledToLogDays = getEntitlementNumericValue() - const isCustomRange = range.type === 'absolute' + const isCustomRange = range._tag === 'absolute_time_range' const presets = EXPLORER_DATEPICKER_HELPERS.map((helper) => ({ helper, range: datePickerValueToLogTimeRange({ @@ -47,7 +47,7 @@ export const LogsTimeRangeSubMenu = ({ Time range {isCustomRange - ? `${dayjs(range.from).format('DD MMM, HH:mm')} - ${dayjs(range.to).format('DD MMM, HH:mm')}` + ? `${dayjs(range.start).format('DD MMM, HH:mm')} - ${dayjs(range.end).format('DD MMM, HH:mm')}` : (selectedPreset?.helper.text ?? 'Custom range')} diff --git a/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts b/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts index 43e103d82c2..187e24d7989 100644 --- a/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts +++ b/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts @@ -2,13 +2,13 @@ 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 type { TimeRange } from '@/data/content/notebooks/notebook-schema' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' export function useLogsCustomRange({ onRangeChange, }: { - onRangeChange: (range: LogTimeRange) => void + onRangeChange: (range: TimeRange) => void }) { const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false) const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) @@ -17,7 +17,7 @@ export function useLogsCustomRange({ const handleApplyCustomRange = ({ from, to }: { from: Date; to: Date }) => { const range = customDateRangeToLogTimeRange({ from, to }) - if (maybeShowUpgradePromptIfNotEntitled(range.from, entitledToLogDays)) { + if (maybeShowUpgradePromptIfNotEntitled(range.start, entitledToLogDays)) { setShowUpgradePrompt(true) return } diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.ts b/apps/studio/components/interfaces/SQLEditor/querySource.ts index 49f57612446..eeb66ad1374 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.ts @@ -1,5 +1,6 @@ +import { type TimeRange } from '@/data/content/notebooks/notebook-schema' import type { Snippet } from '@/data/content/sql-folders-query' -import { type LogTimeRange, type QuerySourceId } from '@/data/query-sources/query-source-registry' +import { type QuerySourceId } from '@/data/query-sources/query-source-registry' /** * Domain view of where a snippet's query runs. Derived from the content TYPE: @@ -61,4 +62,4 @@ export function resolveSnippetSource( * 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: LogTimeRange } +export type QuerySource = { type: 'database' } | { type: 'logs'; dateRange: TimeRange } diff --git a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx index 97bd6bdd789..8293e49ba0e 100644 --- a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx @@ -99,7 +99,7 @@ describe('useLogsSqlExecution', () => { it('resolves a relative session range to a from/to window around now', async () => { const captured = mockLogsAllOtel([]) sqlEditorSessionState.setLogRange(SNIPPET_ID, { - type: 'relative', + _tag: 'relative_time_range', amount: 2, unit: 'hour', }) diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx index 3d63dece309..a507d85e868 100644 --- a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx @@ -37,17 +37,13 @@ describe('useRunSource', () => { 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, { - type: 'relative', - amount: 2, - unit: 'hour', - }) + sqlEditorSessionState.setLogRange(id, { _tag: 'relative_time_range', amount: 2, unit: 'hour' }) const { result } = renderSqlEditorHook(() => useRunSource(id)) expect(result.current).toEqual({ type: 'logs', - dateRange: { type: 'relative', amount: 2, unit: 'hour' }, + dateRange: { _tag: 'relative_time_range', amount: 2, unit: 'hour' }, }) }) }) diff --git a/apps/studio/data/query-sources/query-source-registry.test.ts b/apps/studio/data/query-sources/query-source-registry.test.ts index 03c2045655b..dc09b62cccf 100644 --- a/apps/studio/data/query-sources/query-source-registry.test.ts +++ b/apps/studio/data/query-sources/query-source-registry.test.ts @@ -4,7 +4,6 @@ import { cellSourceSchema, createDefaultCellSource, getQuerySource, - logTimeRangeSchema, QUERY_SOURCES, } from './query-source-registry' @@ -25,7 +24,7 @@ describe('query source registry', () => { id: 'logs', type: 'logs', parameters: { - time_range: { type: 'relative', amount: 1, unit: 'hour' }, + time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' }, }, }) expect(first.parameters.time_range).not.toBe(second.parameters.time_range) @@ -49,7 +48,7 @@ describe('query source registry', () => { cellSourceSchema.parse({ id: 'database', type: 'database', - parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' } }, }) ).toThrow() @@ -57,60 +56,24 @@ describe('query source registry', () => { cellSourceSchema.parse({ id: 'logs', type: 'logs', - parameters: { time_range: { type: 'relative', amount: 2, unit: 'week' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 2, unit: 'fortnight' } }, }) ).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) - + it('refuses a cell source carrying an invalid absolute range', () => { 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', + _tag: 'absolute_time_range', + start: '2025-01-02T00:00:00.000Z', + end: '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 index eb8124a4f58..ce75a3bb742 100644 --- a/apps/studio/data/query-sources/query-source-registry.ts +++ b/apps/studio/data/query-sources/query-source-registry.ts @@ -1,20 +1,7 @@ -import dayjs from 'dayjs' import * as z from 'zod' +import { timeRangeSchema, type TimeRange } from '@/data/content/notebooks/notebook-schema' 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' @@ -35,7 +22,7 @@ export type LogsSource = { type: 'logs' endpoint: ReturnType parameters: { - time_range: LogTimeRange + time_range: TimeRange } } @@ -46,8 +33,8 @@ export type CellSourceOf = Pick | CellSourceOf -export const DEFAULT_LOG_TIME_RANGE: LogTimeRange = { - type: 'relative', +export const DEFAULT_LOG_TIME_RANGE: TimeRange = { + _tag: 'relative_time_range', amount: 1, unit: 'hour', } @@ -76,45 +63,6 @@ export const QUERY_SOURCE_LABELS: Record = { 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({ @@ -127,7 +75,7 @@ export const cellSourceSchema = z.discriminatedUnion('type', [ .object({ id: z.literal('logs'), type: z.literal('logs'), - parameters: z.object({ time_range: logTimeRangeSchema }).strict(), + parameters: z.object({ time_range: timeRangeSchema }).strict(), }) .strict(), ]) diff --git a/apps/studio/state/explorer-query.test.ts b/apps/studio/state/explorer-query.test.ts index f179e29ebba..6cf64c5b807 100644 --- a/apps/studio/state/explorer-query.test.ts +++ b/apps/studio/state/explorer-query.test.ts @@ -51,7 +51,7 @@ describe('explorer query drafts', () => { source: { id: 'logs', type: 'logs', - parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' } }, }, }) @@ -62,7 +62,7 @@ describe('explorer query drafts', () => { expect(restored.drafts['query-1'].source).toEqual({ id: 'logs', type: 'logs', - parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } }, + parameters: { time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' } }, }) }) 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 b7813f58333..af8ea339482 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 { LogTimeRange } from '@/data/query-sources/query-source-registry' +import type { TimeRange } from '@/data/content/notebooks/notebook-schema' /** * Ephemeral, per-session SQL editor state that is NOT persisted: query results, @@ -37,9 +37,9 @@ export const sqlEditorSessionState = proxy({ * and resets on reload. An unset snippet has no entry; read sites fall back to * `DEFAULT_LOG_TIME_RANGE`. */ - logRange: {} as { [snippetId: string]: LogTimeRange }, + logRange: {} as { [snippetId: string]: TimeRange }, - setLogRange: (id: string, range: LogTimeRange) => { + setLogRange: (id: string, range: TimeRange) => { sqlEditorSessionState.logRange[id] = range },