mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature ## What is the current behavior? Standalone queries in the Explorer "Quick Query" surface have editable titles (defaulting to "Untitled query") without clear indication that they are temporary and not saved, similar to the old SQL Editor snippet model. ## What is the new behavior? The Quick Query tab now clearly indicates that standalone queries are temporary. The title is no longer editable and displays "Temporary space for one-off queries" as static muted italic text. The default query name has been changed from "Untitled query" to "Quick query" for clarity. ## Additional context Resolves FE-4297. Notebook cells (variant === 'embedded') remain unchanged with editable titles. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **UI Improvements** * Renamed new one-off queries from **“Quick query”** to **“Run SQL”** when no title is provided. * Updated viewport query editor toolbars to display the fixed **“Run SQL”** label. * Preserved editable titles for embedded query editors. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
import { untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
|
|
import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common'
|
|
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
|
|
import { z } from 'zod'
|
|
|
|
import { DEFAULT_CELL_ROW_LIMIT } from '@/components/interfaces/Explorer/QueryCell/QueryCell.utils'
|
|
import { type QueryDisplay, type QueryResult } from '@/components/interfaces/Explorer/types'
|
|
import { ROWS_PER_PAGE_OPTIONS } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
|
|
import {
|
|
chartConfigSchema,
|
|
type DatabaseSourceParameters,
|
|
type LogsSourceParameters,
|
|
} from '@/data/content/notebooks/notebook-schema'
|
|
import { untrustedLogSql, type UntrustedLogSqlFragment } from '@/data/logs/safe-analytics-sql'
|
|
import {
|
|
createDefaultSourceBinding,
|
|
querySourceBindingSchema,
|
|
toQuerySourceBinding,
|
|
type QuerySourceBinding,
|
|
} from '@/data/query-sources/query-source-registry'
|
|
import { impersonationRoleSchema, type ImpersonationRole } from '@/lib/role-impersonation'
|
|
|
|
type ExplorerQueryDraftBase = {
|
|
id: string
|
|
projectRef: string
|
|
name: string
|
|
updatedAt: number
|
|
view: QueryDisplay['view']
|
|
chart?: QueryDisplay['chart']
|
|
}
|
|
|
|
/**
|
|
* A standalone Explorer query draft. Tagged by backend rather than carrying a separate
|
|
* `source` object, mirroring how notebook cells store their binding: the tag narrows
|
|
* `uncheckedSql` to that backend's brand, so a Postgres draft's text can never be handed
|
|
* to the analytics wire boundary (or vice versa) without failing to compile.
|
|
*/
|
|
export type DatabaseQueryDraft = ExplorerQueryDraftBase &
|
|
DatabaseSourceParameters & {
|
|
_tag: 'database'
|
|
uncheckedSql: UntrustedSqlFragment
|
|
rowLimit: number
|
|
role?: ImpersonationRole
|
|
}
|
|
|
|
export type LogsQueryDraft = ExplorerQueryDraftBase &
|
|
LogsSourceParameters & {
|
|
_tag: 'logs'
|
|
uncheckedSql: UntrustedLogSqlFragment
|
|
}
|
|
|
|
export type ExplorerQueryDraft = DatabaseQueryDraft | LogsQueryDraft
|
|
|
|
export type ExplorerQueryResult = QueryResult & {
|
|
executedAt: number
|
|
}
|
|
|
|
/**
|
|
* Drafts persist their binding under a single `source` key. This is browser-local storage,
|
|
* not the notebook wire contract, so nesting costs nothing here and lets the whole binding
|
|
* be validated in one `safeParse`.
|
|
*/
|
|
type PersistedExplorerQueryDraft = {
|
|
name: string
|
|
source: QuerySourceBinding
|
|
sql: string
|
|
updatedAt: number
|
|
rowLimit?: number
|
|
role?: ImpersonationRole
|
|
} & QueryDisplay
|
|
|
|
type PersistedExplorerQueryDrafts = Record<string, PersistedExplorerQueryDraft>
|
|
|
|
type StorageLike = Pick<typeof safeLocalStorage, 'getItem' | 'setItem' | 'removeItem'>
|
|
|
|
export const EXPLORER_QUERY_PERSIST_DELAY = 300
|
|
export const MAX_PERSISTED_EXPLORER_QUERY_DRAFTS = 50
|
|
|
|
const persistedDraftsSchema = z.record(z.string(), z.unknown())
|
|
const persistedDraftSchema = z.object({
|
|
name: z.string(),
|
|
sql: z.string(),
|
|
updatedAt: z.number(),
|
|
source: z.unknown().optional(),
|
|
rowLimit: z.unknown().optional(),
|
|
role: z.unknown().optional(),
|
|
view: z.unknown().optional(),
|
|
chart: z.unknown().optional(),
|
|
})
|
|
|
|
const VALID_ROW_LIMITS = ROWS_PER_PAGE_OPTIONS.map((option) => option.value)
|
|
const queryViewSchema = z.enum(['table', 'chart'])
|
|
|
|
/**
|
|
* Falls back to the default whenever a persisted row limit isn't one of the values the row
|
|
* limit menu can actually produce — e.g. a fractional or out-of-range number from corrupted
|
|
* or hand-edited storage. `undefined` (never persisted) passes through unchanged; `toDraft`
|
|
* applies the default for that case.
|
|
*/
|
|
const rowLimitSchema = z
|
|
.number()
|
|
.refine((value) => VALID_ROW_LIMITS.includes(value))
|
|
.catch(DEFAULT_CELL_ROW_LIMIT)
|
|
.optional()
|
|
|
|
/**
|
|
* Rebuilds a draft from its persisted form, branding the SQL for the backend the binding
|
|
* names. The single place a stored string re-enters the type system as untrusted SQL, which
|
|
* is what keeps the brand correlated with the backend rather than assumed.
|
|
*/
|
|
const toDraft = ({
|
|
id,
|
|
projectRef,
|
|
persisted,
|
|
}: {
|
|
id: string
|
|
projectRef: string
|
|
persisted: PersistedExplorerQueryDraft
|
|
}): ExplorerQueryDraft => {
|
|
const base = {
|
|
id,
|
|
projectRef,
|
|
name: persisted.name,
|
|
updatedAt: persisted.updatedAt,
|
|
view: persisted.view,
|
|
chart: persisted.chart,
|
|
}
|
|
|
|
if (persisted.source._tag === 'logs') {
|
|
return {
|
|
...base,
|
|
_tag: 'logs',
|
|
time_range: persisted.source.time_range,
|
|
uncheckedSql: untrustedLogSql(persisted.sql),
|
|
}
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
_tag: 'database',
|
|
database_identifier: persisted.source.database_identifier,
|
|
uncheckedSql: untrustedSql(persisted.sql),
|
|
rowLimit: persisted.rowLimit ?? DEFAULT_CELL_ROW_LIMIT,
|
|
role: persisted.role,
|
|
}
|
|
}
|
|
|
|
const readPersistedDrafts = (storage: StorageLike, projectRef: string) => {
|
|
const raw = storage.getItem(LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS(projectRef))
|
|
if (!raw) return {} as PersistedExplorerQueryDrafts
|
|
|
|
try {
|
|
const parsed = persistedDraftsSchema.safeParse(JSON.parse(raw))
|
|
if (!parsed.success) return {}
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(parsed.data).flatMap(([id, value]) => {
|
|
const draft = persistedDraftSchema.safeParse(value)
|
|
if (!draft.success) return []
|
|
|
|
const parsedSource = querySourceBindingSchema.safeParse(draft.data.source)
|
|
const source = parsedSource.success
|
|
? parsedSource.data
|
|
: createDefaultSourceBinding('database')
|
|
|
|
const parsedRole = impersonationRoleSchema.safeParse(draft.data.role)
|
|
const role = parsedRole.success ? parsedRole.data : undefined
|
|
const rowLimit = rowLimitSchema.parse(draft.data.rowLimit)
|
|
const parsedView = queryViewSchema.safeParse(draft.data.view)
|
|
const view = parsedView.success ? parsedView.data : 'table'
|
|
const parsedChart = chartConfigSchema.safeParse(draft.data.chart)
|
|
const chart = parsedChart.success ? parsedChart.data : undefined
|
|
|
|
return [
|
|
[
|
|
id,
|
|
{
|
|
name: draft.data.name,
|
|
source,
|
|
sql: draft.data.sql,
|
|
updatedAt: draft.data.updatedAt,
|
|
role,
|
|
rowLimit,
|
|
view,
|
|
chart,
|
|
},
|
|
],
|
|
]
|
|
})
|
|
)
|
|
} catch {
|
|
return {} as PersistedExplorerQueryDrafts
|
|
}
|
|
}
|
|
|
|
const writePersistedDrafts = (
|
|
storage: StorageLike,
|
|
projectRef: string,
|
|
drafts: PersistedExplorerQueryDrafts
|
|
) => {
|
|
const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS(projectRef)
|
|
const retainedDrafts = Object.fromEntries(
|
|
Object.entries(drafts)
|
|
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
|
|
.slice(0, MAX_PERSISTED_EXPLORER_QUERY_DRAFTS)
|
|
)
|
|
|
|
if (Object.keys(retainedDrafts).length === 0) storage.removeItem(key)
|
|
else storage.setItem(key, JSON.stringify(retainedDrafts))
|
|
}
|
|
|
|
export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage) => {
|
|
const pendingPersistence = new Map<
|
|
string,
|
|
{ timeout: ReturnType<typeof setTimeout>; persist: () => void }
|
|
>()
|
|
|
|
const persistDraft = (draft: ExplorerQueryDraft) => {
|
|
const persisted = readPersistedDrafts(storage, draft.projectRef)
|
|
persisted[draft.id] = {
|
|
name: draft.name,
|
|
source: toQuerySourceBinding(draft),
|
|
sql: draft.uncheckedSql,
|
|
updatedAt: draft.updatedAt,
|
|
rowLimit: draft._tag === 'database' ? draft.rowLimit : undefined,
|
|
role: draft._tag === 'database' ? draft.role : undefined,
|
|
view: draft.view,
|
|
chart: draft.chart,
|
|
}
|
|
writePersistedDrafts(storage, draft.projectRef, persisted)
|
|
}
|
|
|
|
const state = proxy({
|
|
drafts: {} as Record<string, ExplorerQueryDraft>,
|
|
results: {} as Record<string, ExplorerQueryResult>,
|
|
|
|
createDraft: ({
|
|
id,
|
|
projectRef,
|
|
name = 'Run SQL',
|
|
sql = '',
|
|
source = createDefaultSourceBinding('database'),
|
|
rowLimit = DEFAULT_CELL_ROW_LIMIT,
|
|
}: {
|
|
id: string
|
|
projectRef: string
|
|
name?: string
|
|
sql?: string
|
|
source?: QuerySourceBinding
|
|
rowLimit?: number
|
|
}) => {
|
|
const draft = toDraft({
|
|
id,
|
|
projectRef,
|
|
persisted: {
|
|
name,
|
|
source: querySourceBindingSchema.parse(source),
|
|
sql,
|
|
updatedAt: Date.now(),
|
|
rowLimit,
|
|
view: 'table',
|
|
},
|
|
})
|
|
|
|
state.drafts[id] = draft
|
|
persistDraft(draft)
|
|
|
|
return id
|
|
},
|
|
|
|
restoreDraft: ({ id, projectRef }: { id: string; projectRef: string }) => {
|
|
if (state.drafts[id]?.projectRef === projectRef) return true
|
|
|
|
const persisted = readPersistedDrafts(storage, projectRef)[id]
|
|
if (!persisted) return false
|
|
|
|
state.drafts[id] = toDraft({ id, projectRef, persisted })
|
|
return true
|
|
},
|
|
|
|
/**
|
|
* Applies an edit to a draft. The draft is rebuilt rather than mutated in place, since
|
|
* a backend change changes which brand its SQL carries; a stale result from the old
|
|
* backend is dropped, because another engine returns unrelated columns.
|
|
*
|
|
* NOTE — see `changeCellSource`: keeping the query text across a backend change is very
|
|
* likely not what the user wants, since the dialects differ, and is kept for now only
|
|
* because it destroys nothing. Worth revisiting alongside the notebook-cell behavior.
|
|
*
|
|
* A rename or a source change is a discrete action, so it writes through immediately;
|
|
* SQL keystrokes are debounced by `EXPLORER_QUERY_PERSIST_DELAY`.
|
|
*/
|
|
updateDraft: ({
|
|
id,
|
|
name,
|
|
source,
|
|
sql,
|
|
rowLimit,
|
|
}: {
|
|
id: string
|
|
name?: string
|
|
source?: QuerySourceBinding
|
|
sql?: string
|
|
rowLimit?: number
|
|
}) => {
|
|
const draft = state.drafts[id]
|
|
if (!draft) return
|
|
|
|
const nextSource = source === undefined ? undefined : querySourceBindingSchema.parse(source)
|
|
if (nextSource !== undefined && nextSource._tag !== draft._tag) delete state.results[id]
|
|
|
|
const currentRowLimit = draft._tag === 'database' ? draft.rowLimit : undefined
|
|
const currentRole = draft._tag === 'database' ? draft.role : undefined
|
|
|
|
state.drafts[id] = toDraft({
|
|
id,
|
|
projectRef: draft.projectRef,
|
|
persisted: {
|
|
name: name ?? draft.name,
|
|
source: nextSource ?? toQuerySourceBinding(draft),
|
|
sql: sql ?? draft.uncheckedSql,
|
|
updatedAt: Date.now(),
|
|
rowLimit: rowLimit ?? currentRowLimit,
|
|
role: currentRole,
|
|
view: draft.view,
|
|
chart: draft.chart,
|
|
},
|
|
})
|
|
|
|
const persist = () => {
|
|
const pending = pendingPersistence.get(id)
|
|
if (pending) clearTimeout(pending.timeout)
|
|
pendingPersistence.delete(id)
|
|
|
|
const currentDraft = state.drafts[id]
|
|
if (!currentDraft) return
|
|
persistDraft(currentDraft)
|
|
}
|
|
|
|
const pending = pendingPersistence.get(id)
|
|
if (pending) clearTimeout(pending.timeout)
|
|
|
|
if (name !== undefined || source !== undefined || rowLimit !== undefined) persist()
|
|
else {
|
|
const timeout = setTimeout(persist, EXPLORER_QUERY_PERSIST_DELAY)
|
|
pendingPersistence.set(id, { timeout, persist })
|
|
}
|
|
},
|
|
|
|
flushPendingPersistence: ({ projectRef }: { projectRef?: string } = {}) => {
|
|
for (const [id, pending] of [...pendingPersistence]) {
|
|
if (projectRef !== undefined && state.drafts[id]?.projectRef !== projectRef) continue
|
|
pending.persist()
|
|
}
|
|
},
|
|
|
|
removeDraft: ({ id, projectRef }: { id: string; projectRef: string }) => {
|
|
const pending = pendingPersistence.get(id)
|
|
if (pending) clearTimeout(pending.timeout)
|
|
pendingPersistence.delete(id)
|
|
|
|
if (state.drafts[id]?.projectRef === projectRef) {
|
|
delete state.drafts[id]
|
|
delete state.results[id]
|
|
}
|
|
|
|
const persisted = readPersistedDrafts(storage, projectRef)
|
|
delete persisted[id]
|
|
writePersistedDrafts(storage, projectRef, persisted)
|
|
},
|
|
|
|
setResult: ({ id, result }: { id: string; result: ExplorerQueryResult }) => {
|
|
state.results[id] = ref(result)
|
|
},
|
|
|
|
setDisplay: ({ id, display }: { id: string; display: QueryDisplay }) => {
|
|
const draft = state.drafts[id]
|
|
if (!draft) return
|
|
|
|
const parsedChart = chartConfigSchema.safeParse(display.chart)
|
|
const updatedDraft: ExplorerQueryDraft = {
|
|
...draft,
|
|
view: queryViewSchema.parse(display.view),
|
|
chart: parsedChart.success ? parsedChart.data : undefined,
|
|
updatedAt: Date.now(),
|
|
}
|
|
state.drafts[id] = updatedDraft
|
|
persistDraft(updatedDraft)
|
|
},
|
|
|
|
/**
|
|
* Separate from `updateDraft` because `undefined` is a meaningful value here (clearing
|
|
* the impersonated role), whereas `updateDraft`'s optional fields all use `undefined`
|
|
* to mean "leave unchanged." Logs drafts have no impersonation concept, so this is a
|
|
* no-op for them.
|
|
*/
|
|
setRole: ({ id, role }: { id: string; role: ImpersonationRole | undefined }) => {
|
|
const draft = state.drafts[id]
|
|
if (!draft || draft._tag !== 'database') return
|
|
|
|
const updatedDraft: DatabaseQueryDraft = { ...draft, role, updatedAt: Date.now() }
|
|
state.drafts[id] = updatedDraft
|
|
persistDraft(updatedDraft)
|
|
},
|
|
})
|
|
|
|
return state
|
|
}
|
|
|
|
export const explorerQueryState = createExplorerQueryState()
|
|
|
|
export const getExplorerQueryStateSnapshot = () => snapshot(explorerQueryState)
|
|
|
|
export const useExplorerQueryStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
|
|
useSnapshot(explorerQueryState, options)
|