Files
supabase/apps/studio/state/explorer-query.ts
T
Joshen Lim cfde341c31 QueryTab: Scope row limit to each tab instead of global (#49138)
## Context

Previous PR [here](https://github.com/supabase/supabase/pull/49098)
introduced row limits to the Explorer -> Query Tab, but the setting was
global (e.g selected row limit value would be the same despite switching
query tabs)

Changes here shifts the scope of row limit into the query draft so that
the value is tied to each individual query tab instead

Also added a logic as CodeRabbit suggested
[here](https://github.com/supabase/supabase/pull/49138#discussion_r3795525802)
- to default invalid row limit values to 100 if the persisted data is
mutated incorrectly

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Query tabs now retain row-limit settings independently for each
database draft.
* Row-limit preferences are restored when reopening Studio, with older
drafts defaulting to 100 rows.

* **Bug Fixes**
* Changing the row limit now persists immediately and no longer affects
other query drafts.
  * Invalid saved row limits are safely normalized to a supported value.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-18 10:44:32 +08:00

351 lines
11 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 QueryResult } from '@/components/interfaces/Explorer/types'
import { ROWS_PER_PAGE_OPTIONS } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
import {
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'
type ExplorerQueryDraftBase = {
id: string
projectRef: string
name: string
updatedAt: number
}
/**
* 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
}
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
}
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(),
})
const VALID_ROW_LIMITS = ROWS_PER_PAGE_OPTIONS.map((option) => option.value)
/**
* 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 }
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,
}
}
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 rowLimit = rowLimitSchema.parse(draft.data.rowLimit)
return [
[
id,
{
name: draft.data.name,
source,
sql: draft.data.sql,
updatedAt: draft.data.updatedAt,
rowLimit,
},
],
]
})
)
} 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,
}
writePersistedDrafts(storage, draft.projectRef, persisted)
}
const state = proxy({
drafts: {} as Record<string, ExplorerQueryDraft>,
results: {} as Record<string, ExplorerQueryResult>,
createDraft: ({
id,
projectRef,
name = 'Untitled query',
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,
},
})
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
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,
},
})
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)
},
})
return state
}
export const explorerQueryState = createExplorerQueryState()
export const getExplorerQueryStateSnapshot = () => snapshot(explorerQueryState)
export const useExplorerQueryStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
useSnapshot(explorerQueryState, options)