diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
index 97907d4edb3ac..8fdbec57e03f1 100644
--- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
+++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
@@ -9,6 +9,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { ButtonTooltip } from '../ButtonTooltip'
import { CHART_COLORS } from '../Charts/Charts.constants'
import { PortalChartTooltip } from '../Charts/PortalChartTooltip'
+import { DataGridResults } from '../DataGridResults'
import { SqlWarningAdmonition } from '../SqlWarningAdmonition'
import { BlockViewConfiguration } from './BlockViewConfiguration'
import { EditQueryButton } from './EditQueryButton'
@@ -21,7 +22,6 @@ import {
} from './QueryBlock.utils'
import { ReportBlockContainer } from '@/components/interfaces/Reports/ReportBlock/ReportBlockContainer'
import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
-import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results'
export const DEFAULT_CHART_CONFIG: ChartConfig = {
type: 'bar',
@@ -391,7 +391,7 @@ export const QueryBlock = ({
'flex flex-col flex-1 w-full overflow-auto overscroll-contain relative max-h-64'
)}
>
-
+
{autoLimit && (
Limited to only 100 rows
diff --git a/apps/studio/data/content/notebooks/notebook-schema.test.ts b/apps/studio/data/content/notebooks/notebook-schema.test.ts
index c6fc30abbaa22..2f1615ba0e867 100644
--- a/apps/studio/data/content/notebooks/notebook-schema.test.ts
+++ b/apps/studio/data/content/notebooks/notebook-schema.test.ts
@@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest'
import {
agentNotebookSchema,
+ isQueryCell,
notebookDomainSchema,
notebookSchema,
+ timeRangeSchema,
writableNotebookSchema,
} from './notebook-schema'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
@@ -114,6 +116,116 @@ describe('notebookSchema', () => {
expect(result.success).toBe(false)
})
+
+ it('accepts an optional database_identifier on a database_cell', () => {
+ const result = notebookSchema.safeParse({
+ schema_version: 1,
+ cells: [
+ {
+ _tag: 'database_cell',
+ id: '1',
+ sql: 'select 1',
+ row_limit: 100,
+ database_identifier: 'replica-1',
+ },
+ ],
+ })
+
+ expect(result.success).toBe(true)
+ })
+
+ it('keeps a chart configured while the table view is selected', () => {
+ const result = notebookSchema.safeParse({
+ schema_version: 1,
+ cells: [
+ {
+ _tag: 'database_cell',
+ id: '1',
+ sql: 'select 1',
+ row_limit: 100,
+ view: 'table',
+ chart: {
+ type: 'bar',
+ x_column: 'day',
+ y_columns: ['signups'],
+ cumulative: false,
+ show_labels: true,
+ },
+ },
+ ],
+ })
+
+ expect(result.success).toBe(true)
+ if (!result.success) return
+ expect(result.data.cells[0]).toMatchObject({ view: 'table', chart: { x_column: 'day' } })
+ })
+})
+
+describe('timeRangeSchema', () => {
+ const logCell = (time_range: unknown) => ({
+ schema_version: 1,
+ cells: [{ _tag: 'log_cell', id: '1', sql: 'select 1', time_range }],
+ })
+
+ it('rejects a relative_time_range with a non-positive or fractional amount', () => {
+ expect(
+ timeRangeSchema.safeParse({ _tag: 'relative_time_range', unit: 'hour', amount: 0 }).success
+ ).toBe(false)
+ expect(
+ timeRangeSchema.safeParse({ _tag: 'relative_time_range', unit: 'hour', amount: -1 }).success
+ ).toBe(false)
+ expect(
+ timeRangeSchema.safeParse({ _tag: 'relative_time_range', unit: 'hour', amount: 1.5 }).success
+ ).toBe(false)
+ })
+
+ it('accepts every relative unit the wire schema allows', () => {
+ for (const unit of ['minute', 'hour', 'day', 'week', 'month', 'year']) {
+ expect(
+ timeRangeSchema.safeParse({ _tag: 'relative_time_range', unit, amount: 2 }).success
+ ).toBe(true)
+ }
+ })
+
+ it('rejects an absolute_time_range that does not move forward in time', () => {
+ const equal = timeRangeSchema.safeParse({
+ _tag: 'absolute_time_range',
+ start: '2025-01-01T00:00:00.000Z',
+ end: '2025-01-01T00:00:00.000Z',
+ })
+ expect(equal.success).toBe(false)
+ expect(equal.error?.issues[0].path).toEqual(['end'])
+
+ expect(
+ timeRangeSchema.safeParse({
+ _tag: 'absolute_time_range',
+ start: '2025-01-02T00:00:00.000Z',
+ end: '2025-01-01T00:00:00.000Z',
+ }).success
+ ).toBe(false)
+
+ expect(
+ notebookSchema.safeParse(
+ logCell({
+ _tag: 'absolute_time_range',
+ start: '2025-01-02T00:00:00.000Z',
+ end: '2025-01-01T00:00:00.000Z',
+ })
+ ).success
+ ).toBe(false)
+ })
+
+ it('reports an invalid bound against its own field rather than the ordering rule', () => {
+ const result = timeRangeSchema.safeParse({
+ _tag: 'absolute_time_range',
+ start: 'not-a-date',
+ end: '2025-01-01T00:00:00.000Z',
+ })
+
+ expect(result.success).toBe(false)
+ expect(result.error?.issues).toHaveLength(1)
+ expect(result.error?.issues[0].path).toEqual(['start'])
+ })
})
describe('agentNotebookSchema', () => {
@@ -212,6 +324,20 @@ describe('writableNotebookSchema', () => {
})
})
+describe('isQueryCell', () => {
+ it('narrows every runnable cell and excludes content cells', () => {
+ const result = notebookDomainSchema.safeParse(FULL_NOTEBOOK)
+ expect(result.success).toBe(true)
+ if (!result.success) return
+
+ expect(result.data.cells.map(isQueryCell)).toEqual([false, true, true])
+ expect(result.data.cells.filter(isQueryCell).map((cell) => cell._tag)).toEqual([
+ 'database_cell',
+ 'log_cell',
+ ])
+ })
+})
+
describe('notebookDomainSchema', () => {
it('brands database_cell and log_cell sql as unchecked_sql, leaving markdown_cell untouched', () => {
const result = notebookDomainSchema.safeParse(FULL_NOTEBOOK)
diff --git a/apps/studio/data/content/notebooks/notebook-schema.ts b/apps/studio/data/content/notebooks/notebook-schema.ts
index c326011f3b7c4..6e563a6ae1510 100644
--- a/apps/studio/data/content/notebooks/notebook-schema.ts
+++ b/apps/studio/data/content/notebooks/notebook-schema.ts
@@ -1,4 +1,5 @@
import { untrustedSql, type SafeSqlFragment } from '@supabase/pg-meta'
+import dayjs from 'dayjs'
import * as z from 'zod'
import { untrustedLogSql, type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql'
@@ -31,38 +32,76 @@ const absoluteTimeRangeSchema = z.object({
const relativeTimeRangeSchema = z.object({
_tag: z.literal('relative_time_range'),
unit: z.enum(['minute', 'hour', 'day', 'week', 'month', 'year']),
- amount: z.number(),
+ amount: z.number().int().positive(),
})
-const timeRangeSchema = z.discriminatedUnion('_tag', [
- absoluteTimeRangeSchema,
- relativeTimeRangeSchema,
-])
+export const timeRangeSchema = z
+ .discriminatedUnion('_tag', [absoluteTimeRangeSchema, relativeTimeRangeSchema])
+ .refine(
+ (range) => {
+ if (range._tag !== 'absolute_time_range') return true
+
+ const start = dayjs(range.start)
+ const end = dayjs(range.end)
+ // An unparseable bound is already reported against its own field; the ordering
+ // rule stays quiet so it doesn't add a second, misleading issue.
+ if (!start.isValid() || !end.isValid()) return true
+
+ return end.isAfter(start)
+ },
+ { message: 'must be later than the start of the range', path: ['end'] }
+ )
+
+// Source parameters — the per-backend values a query needs beyond its SQL. Declared
+// here, in the wire contract, because this schema is the shape shared with the API and
+// the agent tool surface, so it is where the validation has to be authoritative. The
+// runtime registry (data/query-sources/query-source-registry.ts) borrows these rather
+// than redeclaring them. Each is spread flat into its cell — no `source` wrapper — to
+// keep the JSON an agent has to author as shallow as possible.
+export const databaseSourceSchema = z.object({
+ /**
+ * Which database the query runs against: the read-replica `identifier`, or absent
+ * for the project's primary. Named `database_identifier` rather than `identifier`
+ * because every cell already carries an `id`.
+ */
+ database_identifier: z.string().optional(),
+})
-const markdownCellSchema = z.object({
- _tag: z.literal('markdown_cell'),
+export const logsSourceSchema = z.object({
+ time_range: timeRangeSchema,
+})
+
+const cellBaseSchema = z.object({
id: z.string(),
+})
+
+// Fields every runnable cell shares, regardless of backend. `sql` is deliberately NOT
+// here: it stays on each member so the domain transform can brand it per dialect and
+// generic code holding a `QueryCell` can't hand it to the wrong wire boundary.
+const queryCellBaseSchema = cellBaseSchema.extend({
+ title: z.string().optional(),
+ view: z.enum(['table', 'chart']).optional(),
+ // Persisted independently of `view` so a user who switches to the table and back
+ // gets their chart configuration returned rather than rebuilt.
+ chart: chartConfigSchema.optional(),
+})
+
+const markdownCellSchema = cellBaseSchema.extend({
+ _tag: z.literal('markdown_cell'),
text: z.string(),
})
-const databaseCellSchema = z.object({
+const databaseCellSchema = queryCellBaseSchema.extend({
_tag: z.literal('database_cell'),
- id: z.string(),
- title: z.string().optional(),
sql: z.string(),
row_limit: z.number(),
- view: z.enum(['table', 'chart']).optional(),
- chart: chartConfigSchema.optional(),
+ ...databaseSourceSchema.shape,
})
-const logCellSchema = z.object({
+const logCellSchema = queryCellBaseSchema.extend({
_tag: z.literal('log_cell'),
- id: z.string(),
- title: z.string().optional(),
sql: z.string(),
- time_range: timeRangeSchema,
- view: z.enum(['table', 'chart']).optional(),
- chart: chartConfigSchema.optional(),
+ ...logsSourceSchema.shape,
})
const cellSchema = z.discriminatedUnion('_tag', [
@@ -156,3 +195,33 @@ export type DatabaseCell = Extract
export type LogCell = Extract
export type TimeRange = z.infer
export type ChartConfig = z.infer
+export type DatabaseSourceParameters = z.infer
+export type LogsSourceParameters = z.infer
+
+type CellKind = 'content' | 'query'
+
+/**
+ * Classifies every cell tag as content or query. The `satisfies` clause makes this the
+ * registration point for a new backend: adding a member to `cellSchema` fails to compile
+ * here until it is classified, and `QueryCell` / `isQueryCell` widen automatically once
+ * it is — so a new cell type can never be silently left out of query-generic UI.
+ */
+const CELL_KINDS = {
+ markdown_cell: 'content',
+ database_cell: 'query',
+ log_cell: 'query',
+} as const satisfies Record
+
+type QueryCellTag = {
+ [K in keyof typeof CELL_KINDS]: (typeof CELL_KINDS)[K] extends 'query' ? K : never
+}[keyof typeof CELL_KINDS]
+
+export type QueryCell = Extract
+
+/**
+ * Narrows any cell-shaped value to its query members. Generic over the input so it works
+ * on domain cells and on the deep-readonly `Snapshot` values valtio hands the UI.
+ */
+export const isQueryCell = (
+ cell: C
+): cell is Extract => CELL_KINDS[cell._tag] === 'query'
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 03c2045655bcf..cf9ac7082c10a 100644
--- a/apps/studio/data/query-sources/query-source-registry.test.ts
+++ b/apps/studio/data/query-sources/query-source-registry.test.ts
@@ -1,116 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
- cellSourceSchema,
- createDefaultCellSource,
+ createDefaultSourceBinding,
getQuerySource,
- logTimeRangeSchema,
+ getQuerySourceBinding,
QUERY_SOURCES,
+ querySourceBindingSchema,
+ toQuerySourceBinding,
} from './query-source-registry'
+import { timeRangeSchema } from '@/data/content/notebooks/notebook-schema'
describe('query source registry', () => {
it('registers database and logs sources with their execution endpoints', () => {
- expect(QUERY_SOURCES.map(({ id }) => id)).toEqual(['database', 'logs'])
+ expect(QUERY_SOURCES.map(({ _tag }) => _tag)).toEqual(['database', 'logs'])
expect(getQuerySource('database').endpoint).toBe('/platform/pg-meta/{ref}/query')
expect(getQuerySource('logs').endpoint).toBe(
'/platform/projects/{ref}/analytics/endpoints/logs.all.otel'
)
})
- it('creates independent, valid default cell bindings', () => {
- const first = createDefaultCellSource('logs')
- const second = createDefaultCellSource('logs')
+ it('creates independent, valid default bindings', () => {
+ const first = createDefaultSourceBinding('logs')
+ const second = createDefaultSourceBinding('logs')
- expect(cellSourceSchema.parse(first)).toEqual({
- id: 'logs',
- type: 'logs',
- parameters: {
- time_range: { type: 'relative', amount: 1, unit: 'hour' },
- },
+ expect(querySourceBindingSchema.parse(first)).toEqual({
+ _tag: 'logs',
+ time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' },
})
- expect(first.parameters.time_range).not.toBe(second.parameters.time_range)
- expect(cellSourceSchema.parse(createDefaultCellSource('database'))).toEqual({
- id: 'database',
- type: 'database',
- parameters: {},
+ expect(first.time_range).not.toBe(second.time_range)
+ expect(querySourceBindingSchema.parse(createDefaultSourceBinding('database'))).toEqual({
+ _tag: 'database',
})
})
- it('rejects parameters that do not match the selected source type', () => {
+ it('rejects parameters that do not match the selected source', () => {
expect(() =>
- cellSourceSchema.parse({
- id: 'logs',
- type: 'logs',
- parameters: { identifier: 'replica-1' },
- })
+ querySourceBindingSchema.parse({ _tag: 'logs', database_identifier: 'replica-1' })
).toThrow()
expect(() =>
- cellSourceSchema.parse({
- id: 'database',
- type: 'database',
- parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } },
+ querySourceBindingSchema.parse({
+ _tag: 'database',
+ time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' },
})
).toThrow()
expect(() =>
- cellSourceSchema.parse({
- id: 'logs',
- type: 'logs',
- parameters: { time_range: { type: 'relative', amount: 2, unit: 'week' } },
+ querySourceBindingSchema.parse({
+ _tag: 'logs',
+ time_range: { _tag: 'relative_time_range', amount: 2, unit: 'fortnight' },
})
).toThrow()
})
+})
- it('rejects absolute ranges that do not move forward in time', () => {
+describe('getQuerySourceBinding', () => {
+ it('projects a database cell onto its binding', () => {
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'])
+ getQuerySourceBinding({ _tag: 'database_cell', database_identifier: 'replica-1' })
+ ).toEqual({ _tag: 'database', database_identifier: 'replica-1' })
+ })
+ it('projects a log cell onto its binding', () => {
expect(
- logTimeRangeSchema.safeParse({
- type: 'absolute',
- from: '2025-01-02T00:00:00.000Z',
- to: '2025-01-01T00:00:00.000Z',
- }).success
- ).toBe(false)
+ getQuerySourceBinding({
+ _tag: 'log_cell',
+ time_range: { _tag: 'relative_time_range', unit: 'day', amount: 3 },
+ })
+ ).toEqual({ _tag: 'logs', time_range: { _tag: 'relative_time_range', unit: 'day', amount: 3 } })
+ })
- 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',
- },
- },
+ it('copies the time range rather than aliasing the cell it came from', () => {
+ const time_range = { _tag: 'relative_time_range', unit: 'hour', amount: 6 } as const
+ const binding = getQuerySourceBinding({ _tag: 'log_cell', time_range })
+
+ expect(binding).toEqual({ _tag: 'logs', time_range })
+ if (binding._tag !== 'logs') throw new Error('expected a logs binding')
+ expect(binding.time_range).not.toBe(time_range)
+ })
+
+ it('accepts the coarser relative units the wire schema allows', () => {
+ expect(
+ getQuerySourceBinding({
+ _tag: 'log_cell',
+ time_range: { _tag: 'relative_time_range', unit: 'month', amount: 2 },
})
- ).toThrow()
+ ).toEqual({
+ _tag: 'logs',
+ time_range: { _tag: 'relative_time_range', unit: 'month', amount: 2 },
+ })
})
+})
- 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',
+describe('toQuerySourceBinding', () => {
+ it('projects a backend-tagged carrier such as a query draft', () => {
+ expect(toQuerySourceBinding({ _tag: 'database', database_identifier: 'replica-1' })).toEqual({
+ _tag: 'database',
+ database_identifier: 'replica-1',
})
- expect(result.success).toBe(false)
- expect(result.error?.issues).toHaveLength(1)
- expect(result.error?.issues[0].path).toEqual(['from'])
+ const time_range = timeRangeSchema.parse({
+ _tag: 'absolute_time_range',
+ start: '2025-01-01T00:00:00.000Z',
+ end: '2025-01-02T00:00:00.000Z',
+ })
+ expect(toQuerySourceBinding({ _tag: 'logs', time_range })).toEqual({ _tag: 'logs', time_range })
})
})
diff --git a/apps/studio/data/query-sources/query-source-registry.ts b/apps/studio/data/query-sources/query-source-registry.ts
index eb8124a4f5830..5d1bbd35914c6 100644
--- a/apps/studio/data/query-sources/query-source-registry.ts
+++ b/apps/studio/data/query-sources/query-source-registry.ts
@@ -1,152 +1,153 @@
-import dayjs from 'dayjs'
import * as z from 'zod'
+import {
+ databaseSourceSchema,
+ logsSourceSchema,
+ type DatabaseSourceParameters,
+ type LogsSourceParameters,
+ 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
- }
+/**
+ * The backend a query runs against. A closed set, not a runtime-extensible one: each tag
+ * is a distinct SQL dialect with its own escaping rules, wire boundary, and safe-SQL
+ * brand, so picking the wrong one is a security bug rather than a configuration error.
+ * What this registry *does* enumerate at runtime is everything downstream of that choice
+ * — endpoints, labels, icons, availability, and default parameters.
+ *
+ * The parameter shapes themselves live in the notebook wire schema
+ * (data/content/notebooks/notebook-schema.ts), which is the contract shared with the API
+ * and the agent tool surface; this module borrows them so there is exactly one definition.
+ */
+export type QuerySourceTag = 'database' | 'logs'
export type DatabaseSource = {
- id: 'database'
- type: 'database'
+ _tag: 'database'
endpoint: '/platform/pg-meta/{ref}/query'
- parameters: {
- /**
- * Query-owned database selection. The SQL editor still adapts its legacy
- * global/local-storage selector into this shape; new consumers persist the
- * identifier directly with their query.
- */
- identifier?: string
- }
+ parameters: DatabaseSourceParameters
}
export type LogsSource = {
- id: 'logs'
- type: 'logs'
+ _tag: 'logs'
endpoint: ReturnType
- parameters: {
- time_range: LogTimeRange
- }
+ parameters: LogsSourceParameters
}
-/** 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
+/**
+ * A query's source selection as the UI passes it around: the backend tag with that
+ * backend's parameters spread flat alongside it. Carriers (notebook cells, standalone
+ * Explorer query drafts) store these fields inline rather than under a `source` key; this
+ * type is the portable value the shared source menu reads and emits.
+ */
+export type QuerySourceBinding =
+ | ({ _tag: 'database' } & DatabaseSourceParameters)
+ | ({ _tag: 'logs' } & LogsSourceParameters)
+
+export const querySourceBindingSchema = z.discriminatedUnion('_tag', [
+ z.object({ _tag: z.literal('database'), ...databaseSourceSchema.shape }).strict(),
+ z.object({ _tag: z.literal('logs'), ...logsSourceSchema.shape }).strict(),
+])
-export const DEFAULT_LOG_TIME_RANGE: LogTimeRange = {
- type: 'relative',
- amount: 1,
+export const DEFAULT_LOG_TIME_RANGE: TimeRange = {
+ _tag: 'relative_time_range',
unit: 'hour',
+ amount: 1,
}
export const QUERY_SOURCE_REGISTRY = {
database: {
- id: 'database',
- type: 'database',
+ _tag: 'database',
endpoint: '/platform/pg-meta/{ref}/query',
parameters: {},
},
logs: {
- id: 'logs',
- type: 'logs',
+ _tag: 'logs',
endpoint: logsAllEndpointUrl(true),
parameters: { time_range: DEFAULT_LOG_TIME_RANGE },
},
-} as const satisfies Record
-
-export type QuerySourceId = keyof typeof QUERY_SOURCE_REGISTRY
+} as const satisfies Record
export const QUERY_SOURCES = Object.values(QUERY_SOURCE_REGISTRY) satisfies Source[]
-export const QUERY_SOURCE_LABELS: Record = {
+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') {
+export const getQuerySource = (tag: QuerySourceTag): Source => QUERY_SOURCE_REGISTRY[tag]
+
+/** Defensive copy so a valtio-proxied range never leaks into a freshly built binding. */
+const cloneTimeRange = (range: Readonly): TimeRange =>
+ range._tag === 'relative_time_range'
+ ? { _tag: range._tag, unit: range.unit, amount: range.amount }
+ : { _tag: range._tag, start: range.start, end: range.end }
+
+export function createDefaultSourceBinding(
+ tag: 'database'
+): { _tag: 'database' } & DatabaseSourceParameters
+export function createDefaultSourceBinding(tag: 'logs'): { _tag: 'logs' } & LogsSourceParameters
+export function createDefaultSourceBinding(tag: QuerySourceTag): QuerySourceBinding
+export function createDefaultSourceBinding(tag: QuerySourceTag): QuerySourceBinding {
+ if (tag === 'logs') {
return {
- id: source.id,
- type: source.type,
- parameters: { time_range: { ...source.parameters.time_range } },
+ _tag: 'logs',
+ time_range: cloneTimeRange(QUERY_SOURCE_REGISTRY.logs.parameters.time_range),
}
}
+ return { _tag: 'database' }
+}
- return { id: source.id, type: source.type, parameters: { ...source.parameters } }
+export const getQuerySourceLabel = (tag: QuerySourceTag): string => QUERY_SOURCE_LABELS[tag]
+
+/**
+ * Source parameters as any carrier stores them: spread flat alongside a tag. Stated
+ * structurally, and readonly throughout, so one helper serves wire cells, domain cells
+ * (whose `sql` has been rebranded to `unchecked_sql`), standalone query drafts, and the
+ * deep-readonly `Snapshot` values valtio hands the UI.
+ */
+type SourceTagged =
+ | { readonly _tag: DatabaseTag; readonly database_identifier?: string }
+ | { readonly _tag: LogsTag; readonly time_range: Readonly }
+
+type DatabaseBinding = { _tag: 'database' } & DatabaseSourceParameters
+type LogsBinding = { _tag: 'logs' } & LogsSourceParameters
+
+/**
+ * Projects any backend-tagged carrier onto the binding the shared source menu reads, so a
+ * caller never reaches into per-backend fields itself. Used by standalone query drafts and
+ * by the query editor's own model; notebook cells go through `getQuerySourceBinding`,
+ * which maps their cell tags first.
+ *
+ * Overloaded so a caller that has already narrowed its carrier gets the matching binding
+ * back rather than the whole union — that keeps the result spreadable into a narrowed
+ * result type without re-narrowing.
+ */
+export function toQuerySourceBinding(value: SourceTagged<'database', never>): DatabaseBinding
+export function toQuerySourceBinding(value: SourceTagged): LogsBinding
+export function toQuerySourceBinding(value: SourceTagged<'database', 'logs'>): QuerySourceBinding
+export function toQuerySourceBinding(value: SourceTagged<'database', 'logs'>): QuerySourceBinding {
+ if (value._tag === 'logs') return { _tag: 'logs', time_range: cloneTimeRange(value.time_range) }
+ return { _tag: 'database', database_identifier: value.database_identifier }
}
-export const getQuerySource = (id: QuerySourceId): Source => QUERY_SOURCE_REGISTRY[id]
+/**
+ * Projects a notebook query cell onto its source binding. The inverse — applying a binding
+ * back onto a cell — is `changeCellSource`, which additionally has to decide what happens
+ * to the SQL body when the backend changes.
+ */
+export function getQuerySourceBinding(cell: SourceTagged<'database_cell', never>): DatabaseBinding
+export function getQuerySourceBinding(cell: SourceTagged): LogsBinding
+export function getQuerySourceBinding(
+ cell: SourceTagged<'database_cell', 'log_cell'>
+): QuerySourceBinding
+export function getQuerySourceBinding(
+ cell: SourceTagged<'database_cell', 'log_cell'>
+): QuerySourceBinding {
+ if (cell._tag === 'log_cell') {
+ return toQuerySourceBinding({ _tag: 'logs', time_range: cell.time_range })
+ }
+ return toQuerySourceBinding({ _tag: 'database', database_identifier: cell.database_identifier })
+}
diff --git a/apps/studio/data/sql/__tests__/utils.test.ts b/apps/studio/data/sql/__tests__/utils.test.ts
new file mode 100644
index 0000000000000..2a7e0714cf83c
--- /dev/null
+++ b/apps/studio/data/sql/__tests__/utils.test.ts
@@ -0,0 +1,241 @@
+import { safeSql } from '@supabase/pg-meta'
+import { describe, expect, it, test } from 'vitest'
+
+import { applyAutoLimit, getSqlErrorLines, trimTrailingSemicolons } from '../utils'
+
+describe('getSqlErrorLines', () => {
+ it('returns formattedError lines when present', () => {
+ const lines = getSqlErrorLines({
+ message: 'permission denied for table users',
+ formattedError:
+ 'ERROR: 42501: permission denied for table users\n' +
+ 'HINT: To grant access to anon on a specific table:\n' +
+ ' GRANT SELECT ON TABLE public.users TO anon;',
+ })
+
+ expect(lines).toEqual([
+ 'ERROR: 42501: permission denied for table users',
+ 'HINT: To grant access to anon on a specific table:',
+ ' GRANT SELECT ON TABLE public.users TO anon;',
+ ])
+ })
+
+ it('strips empty lines from formattedError', () => {
+ const lines = getSqlErrorLines({
+ formattedError: 'ERROR: boom\n\nHINT: retry\n',
+ })
+
+ expect(lines).toEqual(['ERROR: boom', 'HINT: retry'])
+ })
+
+ it('falls back to message lines when formattedError is missing and message is multi-line', () => {
+ const lines = getSqlErrorLines({
+ message:
+ 'ERROR: 42501: permission denied for table users\n' +
+ 'HINT: To grant access to anon on a specific table:\n' +
+ ' GRANT SELECT ON TABLE public.users TO anon;',
+ })
+
+ expect(lines).toEqual([
+ 'ERROR: 42501: permission denied for table users',
+ 'HINT: To grant access to anon on a specific table:',
+ ' GRANT SELECT ON TABLE public.users TO anon;',
+ ])
+ })
+
+ it('returns empty array for a single-line message so callers render the fallback', () => {
+ const lines = getSqlErrorLines({ message: 'permission denied for table users' })
+ expect(lines).toEqual([])
+ })
+
+ it('returns empty array when both fields are missing', () => {
+ expect(getSqlErrorLines({})).toEqual([])
+ })
+
+ it('returns empty array when message is an empty string', () => {
+ expect(getSqlErrorLines({ message: '' })).toEqual([])
+ })
+
+ it('returns empty array when message only contains whitespace newlines', () => {
+ // Only empty segments after filtering — treated as single-line
+ expect(getSqlErrorLines({ message: '\n\n' })).toEqual([])
+ })
+
+ it('prefers formattedError even when message is also multi-line', () => {
+ const lines = getSqlErrorLines({
+ message: 'message line 1\nmessage line 2',
+ formattedError: 'formatted line 1\nformatted line 2',
+ })
+
+ expect(lines).toEqual(['formatted line 1', 'formatted line 2'])
+ })
+
+ it('falls through to message when formattedError is empty string', () => {
+ const lines = getSqlErrorLines({
+ message: 'ERROR: line 1\nHINT: line 2',
+ formattedError: '',
+ })
+
+ expect(lines).toEqual(['ERROR: line 1', 'HINT: line 2'])
+ })
+})
+
+describe('trimTrailingSemicolons', () => {
+ test('removes a single trailing semicolon', () => {
+ const sql = safeSql`select * from countries;`
+ expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
+ })
+ test('removes multiple trailing semicolons', () => {
+ const sql = safeSql`select * from countries;;;;;;;`
+ expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
+ })
+ test('leaves a fragment with no trailing semicolon unchanged', () => {
+ const sql = safeSql`select * from countries`
+ expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
+ })
+ test('does not touch semicolons that are not trailing', () => {
+ const sql = safeSql`select 1; select 2`
+ expect(trimTrailingSemicolons(sql)).toBe('select 1; select 2')
+ })
+})
+
+describe('applyAutoLimit', () => {
+ test('Should return false if limit passed is <= 0', () => {
+ const sql = safeSql`select * from countries;`
+ const limit = -1
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return true if limit passed is > 0', () => {
+ const sql = safeSql`select * from countries;`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(true)
+ })
+ test('Should return false if query already has a limit', () => {
+ const sql = safeSql`select * from countries limit 10;`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query already has a limit (check for case-insensitiveness)', () => {
+ const sql = safeSql`SELECT * FROM countries LIMIT 10;`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query already has a limit with whitespace before the semi colon', () => {
+ const sql = safeSql`select * from countries limit 10 ;`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query already has a limit and offset', () => {
+ const sql = safeSql`select * from countries limit 10 offset 0;`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query already has a limit and offset with whitespace before the semi colon', () => {
+ const sql = safeSql`select * from countries limit 10 offset 0 ;`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => {
+ const sql = safeSql`select * from countries offset 0 limit 1;`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query already has a limit, even if no value provided for limit', () => {
+ const sql = safeSql`select * from countries limit`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query uses `FETCH FIRST` instead of limit ', () => {
+ const sql = safeSql`select * from countries FETCH FIRST 5 rows only`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query uses `fetch first` instead of limit ', () => {
+ const sql = safeSql`select * from countries fetch first 5 rows only`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query uses `fetch first` (with random spaces) instead of limit ', () => {
+ const sql = safeSql`select * from countries FETCH FIRST 5 rows only`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query is not a select statement', () => {
+ const sql = safeSql`create table test (id int8 primary key, name varchar);`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if there are multiple queries I', () => {
+ const sql1 = safeSql`select * from countries;
+select * from cities;`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql1, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if there are multiple queries II', () => {
+ const sql1 = safeSql`select * from countries;
+select * from cities`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql1, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ // [Joshen] Opting to just avoid appending in this case to prevent making the logic overly complex atm
+ test('Should return false if query has with a comment I', () => {
+ const sql = safeSql`-- This is a comment
+select * from cities`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+ test('Should return false if query has with a comment II', () => {
+ const sql = safeSql`select * from cities
+-- This is a comment`
+ const limit = 100
+ const { appendAutoLimit } = applyAutoLimit(sql, limit)
+ expect(appendAutoLimit).toBe(false)
+ })
+
+ // [Joshen] These will just need to test the cases when appendAutoLimit returns true then
+ test('Should add the limit param properly if query ends without a semi colon', () => {
+ const sql = safeSql`select * from countries`
+ const limit = 100
+ const { sql: formattedSql } = applyAutoLimit(sql, limit)
+ expect(formattedSql).toBe('select * from countries limit 100;')
+ })
+ test('Should add the limit param properly if query ends with a semi colon', () => {
+ const sql = safeSql`select * from countries;`
+ const limit = 100
+ const { sql: formattedSql } = applyAutoLimit(sql, limit)
+ expect(formattedSql).toBe('select * from countries limit 100;')
+ })
+ test('Should add the limit param properly if query ends with multiple semi colon', () => {
+ const sql = safeSql`select * from countries;;;;;;;`
+ const limit = 100
+ const { sql: formattedSql } = applyAutoLimit(sql, limit)
+ expect(formattedSql).toBe('select * from countries limit 100;')
+ })
+ test('Should not append a limit if query already has one with whitespace before the semi colon', () => {
+ const sql = safeSql`select * from countries limit 10 ;`
+ const limit = 100
+ const { sql: formattedSql } = applyAutoLimit(sql, limit)
+ expect(formattedSql).toBe('select * from countries limit 10 ;')
+ })
+ test('returns the SafeSqlFragment result unchanged when no limit is appended', () => {
+ const sql = safeSql`select * from countries limit 10;`
+ const { sql: formattedSql } = applyAutoLimit(sql, 100)
+ expect(formattedSql).toBe(sql)
+ })
+})
diff --git a/apps/studio/data/sql/utils.ts b/apps/studio/data/sql/utils.ts
new file mode 100644
index 0000000000000..1f23256fd1ce9
--- /dev/null
+++ b/apps/studio/data/sql/utils.ts
@@ -0,0 +1,80 @@
+import { literal, safeSql, type SafeSqlFragment } from '@supabase/pg-meta'
+
+/**
+ * Pick which lines to render for a SQL editor error.
+ *
+ * pg-meta returns `formattedError` with multi-line ERROR/HINT/LINE output from Postgres.
+ * Historically only `message` was reliably populated end-to-end, which is why the UI also
+ * falls back to splitting `message` on newlines — e.g. the enhanced permission-denied HINT
+ * added by supabase/postgres#2084 arrives in the message body on some paths.
+ *
+ * Returns an empty array when the error is single-line (message only) — callers fall back to
+ * a plain "Error: {message}" rendering in that case.
+ */
+export function getSqlErrorLines(error: { message?: string; formattedError?: string }): string[] {
+ const formattedLines = (error.formattedError?.split('\n') ?? []).filter((x) => x.length > 0)
+ if (formattedLines.length > 0) return formattedLines
+
+ const messageLines = (error.message?.split('\n') ?? []).filter((x) => x.length > 0)
+ return messageLines.length > 1 ? messageLines : []
+}
+
+/**
+ * Removes trailing `;` characters from a safe SQL fragment. Only ever removes
+ * existing terminators — never adds text — so the result is exactly as safe
+ * as the input; the brand carries over intentionally. This is the one place
+ * in the file allowed to reassert `SafeSqlFragment` on a derived string —
+ * every other function composes new fragments through `safeSql`/`literal`.
+ */
+export function trimTrailingSemicolons(sql: SafeSqlFragment): SafeSqlFragment {
+ return sql.replace(/;+\s*$/, '') as SafeSqlFragment
+}
+
+// [Joshen] Just FYI as well the checks here on whether to append limit is quite restricted
+// This is to prevent dashboard from accidentally appending limit to the end of a query
+// thats not supposed to have any, since there's too many cases to cover.
+// We can however look into making this logic better in the future
+// i.e It's harder to append the limit param, than just leaving the query as it is
+// Otherwise we'd need a full on parser to do this properly
+//
+// Only accepts `SafeSqlFragment`: this decides whether to build (and builds)
+// a new SQL fragment that gets executed, so every caller — including ones
+// that only want the `appendAutoLimit` flag for a display hint — must already
+// hold safe SQL. Composes the ` limit N;` suffix through `safeSql`/`literal`
+// rather than gluing raw template-literal text onto the fragment and casting
+// the result, so the only new content this function ever stamps safe is an
+// internally-generated integer literal, never arbitrary concatenated text.
+export function applyAutoLimit(
+ sql: SafeSqlFragment,
+ limit: number = 0
+): { sql: SafeSqlFragment; appendAutoLimit: boolean } {
+ // Remove lines and whitespaces to use for checking
+ const cleanedSql = sql.trim().replaceAll('\n', ' ').replaceAll(/\s+/g, ' ')
+
+ // Check how many queries
+ const regMatch = cleanedSql.matchAll(/[a-zA-Z]*[0-9]*[;]+/g)
+ const queries = new Array(...regMatch)
+ const indexSemiColon = cleanedSql.lastIndexOf(';')
+ const hasComments = cleanedSql.includes('--')
+ const hasMultipleQueries =
+ queries.length > 1 || (indexSemiColon > 0 && indexSemiColon !== cleanedSql.length - 1)
+
+ // Check if need to auto limit rows
+ const appendAutoLimit =
+ limit > 0 &&
+ !hasComments &&
+ !hasMultipleQueries &&
+ cleanedSql.toLowerCase().startsWith('select') &&
+ !cleanedSql.toLowerCase().match(/fetch\s+first/i) &&
+ !cleanedSql.match(/limit$/i) &&
+ !cleanedSql.match(/limit;$/i) &&
+ !cleanedSql.match(/limit [0-9]* offset [0-9]*\s*[;]?$/i) &&
+ !cleanedSql.match(/limit [0-9]*\s*[;]?$/i)
+
+ if (!appendAutoLimit) return { sql, appendAutoLimit: false }
+
+ const core = cleanedSql.endsWith(';') ? trimTrailingSemicolons(sql) : sql
+ const suffixed = safeSql`${core} limit ${literal(limit)};`
+
+ return { sql: suffixed, appendAutoLimit: true }
+}
diff --git a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx
index ffabcbeed2064..e129249b1e3c4 100644
--- a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx
+++ b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx
@@ -8,14 +8,14 @@ import { AWS_REGIONS } from 'shared-data'
import { Badge, Button } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
-import { DropReplicaConfirmationModal } from '@/components/interfaces/Database/Replication/ReadReplicas/DropReplicaConfirmationModal'
-import { ReadReplicaDetails } from '@/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails'
+import { DropReplicaConfirmationModal } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal'
+import { ReadReplicaDetails } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaDetails'
+import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import {
getIsInTransition,
getStatusLabel,
-} from '@/components/interfaces/Database/Replication/ReadReplicas/ReadReplicas.utils'
-import { RestartReplicaConfirmationModal } from '@/components/interfaces/Database/Replication/ReadReplicas/RestartReplicaConfirmationModal'
-import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants'
+} from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils'
+import { RestartReplicaConfirmationModal } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/RestartReplicaConfirmationModal'
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { PageLayout } from '@/components/layouts/PageLayout/PageLayout'
diff --git a/apps/studio/state/explorer-query.test.ts b/apps/studio/state/explorer-query.test.ts
index e63364b756c58..de2ccae4f207f 100644
--- a/apps/studio/state/explorer-query.test.ts
+++ b/apps/studio/state/explorer-query.test.ts
@@ -1,19 +1,26 @@
import { LOCAL_STORAGE_KEYS } from 'common'
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { createExplorerQueryState } from './explorer-query'
+import {
+ createExplorerQueryState,
+ EXPLORER_QUERY_PERSIST_DELAY,
+ MAX_PERSISTED_EXPLORER_QUERY_DRAFTS,
+} from './explorer-query'
const createMemoryStorage = () => {
const values = new Map()
return {
getItem: (key: string) => values.get(key) ?? null,
- setItem: (key: string, value: string) => values.set(key, value),
+ setItem: vi.fn((key: string, value: string) => values.set(key, value)),
removeItem: (key: string) => values.delete(key),
}
}
describe('explorer query drafts', () => {
+ beforeEach(() => vi.useFakeTimers())
+ afterEach(() => vi.useRealTimers())
+
it('persists and restores drafts within their project', () => {
const storage = createMemoryStorage()
const firstState = createExplorerQueryState(storage)
@@ -26,7 +33,7 @@ describe('explorer query drafts', () => {
expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(secondState.drafts['query-1']).toMatchObject({
name: 'Active users',
- source: { id: 'database', type: 'database', parameters: {} },
+ source: { _tag: 'database' },
uncheckedSql: 'select * from users',
projectRef: 'project-a',
})
@@ -42,9 +49,8 @@ describe('explorer query drafts', () => {
state.updateDraft({
id: 'query-1',
source: {
- id: 'logs',
- type: 'logs',
- parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } },
+ _tag: 'logs',
+ time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' },
},
})
@@ -53,9 +59,8 @@ describe('explorer query drafts', () => {
const restored = createExplorerQueryState(storage)
expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(restored.drafts['query-1'].source).toEqual({
- id: 'logs',
- type: 'logs',
- parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } },
+ _tag: 'logs',
+ time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' },
})
})
@@ -70,11 +75,100 @@ describe('explorer query drafts', () => {
const state = createExplorerQueryState(storage)
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
- expect(state.drafts['query-1'].source).toEqual({
- id: 'database',
- type: 'database',
- parameters: {},
- })
+ expect(state.drafts['query-1'].source).toEqual({ _tag: 'database' })
+ })
+
+ it('ignores a malformed root value', () => {
+ const storage = createMemoryStorage()
+ storage.setItem(LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'), JSON.stringify([]))
+
+ const state = createExplorerQueryState(storage)
+ expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(false)
+ })
+
+ it('drops entries with malformed draft fields', () => {
+ const storage = createMemoryStorage()
+ storage.setItem(
+ LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
+ JSON.stringify({
+ 'query-1': { name: 'Invalid query', sql: 123, updatedAt: 1 },
+ })
+ )
+
+ const state = createExplorerQueryState(storage)
+ expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(false)
+ })
+
+ it('falls back to the database source when persisted source data is invalid', () => {
+ const storage = createMemoryStorage()
+ storage.setItem(
+ LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
+ JSON.stringify({
+ 'query-1': {
+ name: 'Recoverable query',
+ sql: 'select 1',
+ updatedAt: 1,
+ source: { id: 'logs', type: 'logs', parameters: {} },
+ },
+ })
+ )
+
+ const state = createExplorerQueryState(storage)
+ expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
+ expect(state.drafts['query-1'].source).toEqual({ _tag: 'database' })
+ })
+
+ it('debounces SQL persistence while updating in-memory state immediately', () => {
+ const storage = createMemoryStorage()
+ const state = createExplorerQueryState(storage)
+ const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a')
+ state.createDraft({ id: 'query-1', projectRef: 'project-a' })
+ storage.setItem.mockClear()
+
+ state.updateDraft({ id: 'query-1', sql: 's' })
+ state.updateDraft({ id: 'query-1', sql: 'se' })
+ state.updateDraft({ id: 'query-1', sql: 'select 1' })
+
+ expect(state.drafts['query-1'].uncheckedSql).toBe('select 1')
+ expect(storage.setItem).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(EXPLORER_QUERY_PERSIST_DELAY)
+
+ expect(storage.setItem).toHaveBeenCalledOnce()
+ expect(JSON.parse(storage.getItem(key)!)['query-1'].sql).toBe('select 1')
+ })
+
+ it('flushes pending SQL persistence before the debounce elapses', () => {
+ const storage = createMemoryStorage()
+ const state = createExplorerQueryState(storage)
+ const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a')
+ state.createDraft({ id: 'query-1', projectRef: 'project-a' })
+ storage.setItem.mockClear()
+
+ state.updateDraft({ id: 'query-1', sql: 'select 1' })
+ state.flushPendingPersistence({ projectRef: 'project-a' })
+
+ expect(storage.setItem).toHaveBeenCalledOnce()
+ expect(JSON.parse(storage.getItem(key)!)['query-1'].sql).toBe('select 1')
+
+ vi.advanceTimersByTime(EXPLORER_QUERY_PERSIST_DELAY)
+ expect(storage.setItem).toHaveBeenCalledOnce()
+ })
+
+ it('retains only the most recently updated persisted drafts', () => {
+ const storage = createMemoryStorage()
+ const state = createExplorerQueryState(storage)
+ const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a')
+
+ for (let index = 0; index <= MAX_PERSISTED_EXPLORER_QUERY_DRAFTS; index++) {
+ vi.setSystemTime(index)
+ state.createDraft({ id: `query-${index}`, projectRef: 'project-a' })
+ }
+
+ const persisted = JSON.parse(storage.getItem(key)!)
+ expect(Object.keys(persisted)).toHaveLength(MAX_PERSISTED_EXPLORER_QUERY_DRAFTS)
+ expect(persisted['query-0']).toBeUndefined()
+ expect(persisted[`query-${MAX_PERSISTED_EXPLORER_QUERY_DRAFTS}`]).toBeDefined()
})
it('removes the persisted draft and its session result when its tab closes', () => {
@@ -82,8 +176,10 @@ describe('explorer query drafts', () => {
const state = createExplorerQueryState(storage)
state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select 1' })
+ state.updateDraft({ id: 'query-1', sql: 'select 2' })
state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } })
state.removeDraft({ id: 'query-1', projectRef: 'project-a' })
+ vi.advanceTimersByTime(EXPLORER_QUERY_PERSIST_DELAY)
expect(state.drafts['query-1']).toBeUndefined()
expect(state.results['query-1']).toBeUndefined()
diff --git a/apps/studio/state/explorer-query.ts b/apps/studio/state/explorer-query.ts
index 7a10a41f5df88..805e7efd8eb83 100644
--- a/apps/studio/state/explorer-query.ts
+++ b/apps/studio/state/explorer-query.ts
@@ -1,19 +1,20 @@
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 { type QueryResult } from '@/components/interfaces/Explorer/types'
import {
- cellSourceSchema,
- createDefaultCellSource,
- type CellSource,
+ createDefaultSourceBinding,
+ querySourceBindingSchema,
+ type QuerySourceBinding,
} from '@/data/query-sources/query-source-registry'
export type ExplorerQueryDraft = {
id: string
projectRef: string
name: string
- source: CellSource
+ source: QuerySourceBinding
uncheckedSql: UntrustedSqlFragment
updatedAt: number
}
@@ -24,7 +25,7 @@ export type ExplorerQueryResult = QueryResult & {
type PersistedExplorerQueryDraft = {
name: string
- source: CellSource
+ source: QuerySourceBinding
sql: string
updatedAt: number
}
@@ -33,36 +34,46 @@ type PersistedExplorerQueryDrafts = Record
type StorageLike = Pick
+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(),
+})
+
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 = JSON.parse(raw)
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+ const parsed = persistedDraftsSchema.safeParse(JSON.parse(raw))
+ if (!parsed.success) return {}
return Object.fromEntries(
- Object.entries(parsed).flatMap(([id, value]) => {
- if (
- value === null ||
- typeof value !== 'object' ||
- !('name' in value) ||
- typeof value.name !== 'string' ||
- !('sql' in value) ||
- typeof value.sql !== 'string' ||
- !('updatedAt' in value) ||
- typeof value.updatedAt !== 'number'
- ) {
- return []
- }
+ Object.entries(parsed.data).flatMap(([id, value]) => {
+ const draft = persistedDraftSchema.safeParse(value)
+ if (!draft.success) return []
- const parsedSource =
- 'source' in value ? cellSourceSchema.safeParse(value.source) : { success: false as const }
+ const parsedSource = querySourceBindingSchema.safeParse(draft.data.source)
const source = parsedSource.success
? parsedSource.data
- : createDefaultCellSource('database')
-
- return [[id, { name: value.name, source, sql: value.sql, updatedAt: value.updatedAt }]]
+ : createDefaultSourceBinding('database')
+
+ return [
+ [
+ id,
+ {
+ name: draft.data.name,
+ source,
+ sql: draft.data.sql,
+ updatedAt: draft.data.updatedAt,
+ },
+ ],
+ ]
})
)
} catch {
@@ -76,11 +87,22 @@ const writePersistedDrafts = (
drafts: PersistedExplorerQueryDrafts
) => {
const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS(projectRef)
- if (Object.keys(drafts).length === 0) storage.removeItem(key)
- else storage.setItem(key, JSON.stringify(drafts))
+ 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; persist: () => void }
+ >()
+
const state = proxy({
drafts: {} as Record,
results: {} as Record,
@@ -90,19 +112,19 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
projectRef,
name = 'Untitled query',
sql = '',
- source = createDefaultCellSource('database'),
+ source = createDefaultSourceBinding('database'),
}: {
id: string
projectRef: string
name?: string
sql?: string
- source?: CellSource
+ source?: QuerySourceBinding
}) => {
const draft: ExplorerQueryDraft = {
id,
projectRef,
name,
- source: cellSourceSchema.parse(source),
+ source: querySourceBindingSchema.parse(source),
uncheckedSql: untrustedSql(sql),
updatedAt: Date.now(),
}
@@ -140,7 +162,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
}: {
id: string
name?: string
- source?: CellSource
+ source?: QuerySourceBinding
sql?: string
}) => {
const draft = state.drafts[id]
@@ -148,23 +170,51 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
if (name !== undefined) draft.name = name
if (source !== undefined) {
- draft.source = cellSourceSchema.parse(source)
+ draft.source = querySourceBindingSchema.parse(source)
delete state.results[id]
}
if (sql !== undefined) draft.uncheckedSql = untrustedSql(sql)
draft.updatedAt = Date.now()
- const persisted = readPersistedDrafts(storage, draft.projectRef)
- persisted[id] = {
- name: draft.name,
- source: draft.source,
- sql: draft.uncheckedSql,
- updatedAt: draft.updatedAt,
+ const persist = () => {
+ const pending = pendingPersistence.get(id)
+ if (pending) clearTimeout(pending.timeout)
+ pendingPersistence.delete(id)
+ const currentDraft = state.drafts[id]
+ if (!currentDraft) return
+
+ const persisted = readPersistedDrafts(storage, currentDraft.projectRef)
+ persisted[id] = {
+ name: currentDraft.name,
+ source: currentDraft.source,
+ sql: currentDraft.uncheckedSql,
+ updatedAt: currentDraft.updatedAt,
+ }
+ writePersistedDrafts(storage, currentDraft.projectRef, persisted)
+ }
+
+ const pending = pendingPersistence.get(id)
+ if (pending) clearTimeout(pending.timeout)
+
+ if (name !== undefined || source !== 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()
}
- writePersistedDrafts(storage, draft.projectRef, persisted)
},
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]
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 b7813f58333ab..af8ea33948219 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
},
diff --git a/apps/studio/state/tabs.test.ts b/apps/studio/state/tabs.test.ts
index c333833ca6006..0957e11474c54 100644
--- a/apps/studio/state/tabs.test.ts
+++ b/apps/studio/state/tabs.test.ts
@@ -374,4 +374,57 @@ describe('explorer query tabs', () => {
expect(router.push).toHaveBeenCalledWith('/project/default/explorer/query/query-1')
})
+
+ it('keeps Explorer tabs open when closing all table editor tabs', () => {
+ const store = createTabsState('default')
+ const router = fakeRouter()
+ store.addTab({
+ id: 'r-1',
+ type: ENTITY_TYPE.TABLE,
+ label: 'users',
+ metadata: { tableId: 1, schema: 'public' },
+ isPreview: false,
+ })
+ store.addTab({
+ id: 'query-query-1',
+ type: 'query',
+ label: 'Untitled query',
+ metadata: { queryId: 'query-1' },
+ isPreview: false,
+ })
+
+ store.handleTabCloseAll({
+ editor: 'table',
+ router,
+ onClearDashboardHistory: vi.fn(),
+ })
+
+ expect(store.openTabs).toEqual(['query-query-1'])
+ expect(store.tabsMap['query-query-1']).toBeDefined()
+ })
+
+ it('runs query cleanup for every query closed in bulk', () => {
+ const store = createTabsState('default')
+ store.addTab({
+ id: 'query-query-1',
+ type: 'query',
+ label: 'Query 1',
+ metadata: { queryId: 'query-1' },
+ isPreview: false,
+ })
+ store.addTab({
+ id: 'query-query-2',
+ type: 'query',
+ label: 'Query 2',
+ metadata: { queryId: 'query-2' },
+ isPreview: false,
+ })
+ const onClose = vi.fn()
+ store.registerTabTypeHandler('query', { onClose })
+
+ store.closeTabs(['query-query-1', 'query-query-2'])
+
+ expect(onClose).toHaveBeenCalledTimes(2)
+ expect(onClose.mock.calls.map(([tab]) => tab.metadata?.queryId)).toEqual(['query-1', 'query-2'])
+ })
})
diff --git a/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx
index d1ef963eb6342..d0842b0fe4430 100644
--- a/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx
+++ b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx
@@ -43,7 +43,7 @@ describe('QuerySourceMenu', () => {
customRender(
)
@@ -60,7 +60,7 @@ describe('QuerySourceMenu', () => {
// that navigation, so the test observes exactly what the user does: does the
// dropdown have to be reopened to see the newly-available controls?
const { rerender } = customRender(
-
+
)
await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' }))
@@ -73,7 +73,7 @@ describe('QuerySourceMenu', () => {
rerender(
)
diff --git a/apps/studio/tests/components/Database/Replication/ReadReplicaEligibilityWarnings.test.tsx b/apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx
similarity index 91%
rename from apps/studio/tests/components/Database/Replication/ReadReplicaEligibilityWarnings.test.tsx
rename to apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx
index 10bce0f7d1ca0..c9471145d670e 100644
--- a/apps/studio/tests/components/Database/Replication/ReadReplicaEligibilityWarnings.test.tsx
+++ b/apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx
@@ -1,13 +1,13 @@
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
-import { ReadReplicaEligibilityWarnings } from '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings'
-import { useCheckEligibilityDeployReplica } from '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useCheckEligibilityDeployReplica'
+import { ReadReplicaEligibilityWarnings } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings'
+import { useCheckEligibilityDeployReplica } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useCheckEligibilityDeployReplica'
import { READ_REPLICAS_MAX_COUNT } from '@/data/read-replicas/replicas-query'
import { customRender } from '@/tests/lib/custom-render'
vi.mock(
- '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useCheckEligibilityDeployReplica'
+ '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useCheckEligibilityDeployReplica'
)
vi.mock('@/data/projects/project-detail-query', () => ({
useProjectDetailQuery: () => ({ data: undefined, isSuccess: false }),
diff --git a/apps/www/app/api-v2/ticket-og/route.tsx b/apps/www/app/api-v2/ticket-og/route.tsx
index d3f62abbd6afd..f7172d1464edc 100644
--- a/apps/www/app/api-v2/ticket-og/route.tsx
+++ b/apps/www/app/api-v2/ticket-og/route.tsx
@@ -27,7 +27,6 @@ const LW_MATERIALIZED_VIEW = 'tickets_view'
export async function GET(req: Request) {
const url = new URL(req.url)
- // Just here to silence snyk false positives
// Verify that req.url is from an allowed domain
const username = url.searchParams.get('username') ?? url.searchParams.get('amp;username')
const userAgent = req.headers.get('user-agent')
diff --git a/apps/www/lib/events.ts b/apps/www/lib/events.ts
index d5cda293d0009..c73cb7ec80e2b 100644
--- a/apps/www/lib/events.ts
+++ b/apps/www/lib/events.ts
@@ -12,7 +12,8 @@
* URL -> rich_text (event URL)
* Book Meeting Link -> url
* Location -> rich_text
- * Category -> multi_select
+ * Type -> multi_select (drives the site's category filter)
+ * Category -> multi_select (audience taxonomy)
* Are you speaking at this event? -> multi_select
* Participation -> multi_select
*/
@@ -26,6 +27,19 @@ import { EventHost, SUPABASE_HOST, SupabaseEvent } from './eventsTypes'
// The actual DB ID (child database inside the page)
const NOTION_EVENTS_DB_ID_FALLBACK = '21b5004b775f8058872fe8fa81e2c7ac'
+// Maps Notion "Type" options to the app's category vocabulary (the values in
+// CATEGORIES_FILTERS). Keys are lowercased for case-insensitive matching.
+// "Supabase Event", "Party" and "Sales Event" have no filter bucket and fall
+// through to DEFAULT_NOTION_CATEGORY.
+const NOTION_TYPE_CATEGORY_MAP: Record = {
+ conference: 'conference',
+ hackathon: 'hackathon',
+ meetup: 'meetup',
+ 'meetup - third party': 'meetup',
+}
+
+const DEFAULT_NOTION_CATEGORY = 'conference'
+
// ─── Helpers ────────────────────────────────────────────────────────────────
function isSafeHttpUrl(url: string): boolean {
@@ -73,6 +87,27 @@ function getMultiSelect(page: any, name: string): string[] {
return prop.multi_select.map((s: any) => s.name)
}
+/**
+ * Derive the site's category values for a Notion event.
+ *
+ * "Type" is the primary signal. "Category" is an audience taxonomy (AI / ML,
+ * Postgres / Databases, …) that also carries a "Hackathon" option, so it's read
+ * as a secondary signal for events typed as something else — e.g. a third-party
+ * meetup that is really a hackathon.
+ */
+function getNotionCategories(page: any): string[] {
+ const mapped = getMultiSelect(page, 'Type')
+ .map((type) => NOTION_TYPE_CATEGORY_MAP[type.trim().toLowerCase()])
+ .filter(Boolean)
+
+ const isHackathonCategory = getMultiSelect(page, 'Category').some(
+ (category) => category.trim().toLowerCase() === 'hackathon'
+ )
+ if (isHackathonCategory) mapped.push('hackathon')
+
+ return mapped.length > 0 ? Array.from(new Set(mapped)) : [DEFAULT_NOTION_CATEGORY]
+}
+
function getFormulaString(page: any, name: string): string {
const prop = page.properties[name]
if (!prop || prop.type !== 'formula' || prop.formula?.type !== 'string') return ''
@@ -116,7 +151,7 @@ export const getNotionEvents = async (): Promise => {
const rawMeetingLink = getUrl(page, 'Book Meeting Link')
const meetingLink = isSafeHttpUrl(rawMeetingLink) ? rawMeetingLink : ''
const location = getRichText(page, 'Location')
- const categories = ['conference']
+ const categories = getNotionCategories(page)
const speakingAnswers = getMultiSelect(page, 'Are you speaking at this event?')
const isSpeaking = speakingAnswers.includes('Yes')
diff --git a/apps/www/pages/security.mdx b/apps/www/pages/security.mdx
index 3192ebfa4c663..1908c5f8e63cc 100644
--- a/apps/www/pages/security.mdx
+++ b/apps/www/pages/security.mdx
@@ -194,7 +194,7 @@ Read more about [fine-grained access controls](/docs/guides/platform/access-cont
Supabase works with industry experts to conduct regular penetration tests.
-In addition to internal security reviews, we use various tools to scan our code for vulnerabilities including [GitHub](https://github.com), [Vanta](https://www.vanta.com/), and [Snyk](https://snyk.io/).
+In addition to internal security reviews, we use various tools to scan our code for vulnerabilities including [GitHub](https://github.com), [Vanta](https://www.vanta.com/), and [DepthFirst](https://depthfirst.com/).
diff --git a/docker/.gitignore b/docker/.gitignore
index bed55270caa80..1f9c3436196b0 100644
--- a/docker/.gitignore
+++ b/docker/.gitignore
@@ -2,11 +2,15 @@ volumes/db/data
volumes/storage
volumes/snippets
volumes/functions/**
+!volumes/functions/deno.json*
!volumes/functions/main/
volumes/functions/main/**
!volumes/functions/main/index.ts
+!volumes/functions/hello/
+volumes/functions/hello/**
+!volumes/functions/hello/index.ts
.env
test.http
docker-compose.override.yml
.supabase-version
-backups
\ No newline at end of file
+backups
diff --git a/docker/tests/test-self-hosted.sh b/docker/tests/test-self-hosted.sh
index c4e6a4765fc0c..216a5cdcbf218 100644
--- a/docker/tests/test-self-hosted.sh
+++ b/docker/tests/test-self-hosted.sh
@@ -43,6 +43,8 @@ fi
# Read keys from .env
ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-)
+SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-)
+SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-)
DASHBOARD_USERNAME=$(grep '^DASHBOARD_USERNAME=' .env | cut -d= -f2-)
DASHBOARD_PASSWORD=$(grep '^DASHBOARD_PASSWORD=' .env | cut -d= -f2-)
@@ -440,10 +442,10 @@ echo ""
echo "--- Edge Functions ---"
fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \
-X POST \
- -H "Authorization: Bearer $ANON_KEY" \
+ -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{}')
-check "Call hello function" '"Hello from Edge Functions!"' "$fn_resp"
+check "Call hello function" '{"message":"Hello from Edge Functions!"}' "$fn_resp"
# ---------------------------------------------
# 8. pg-meta (Studio backend)
diff --git a/docker/volumes/functions/deno.jsonc b/docker/volumes/functions/deno.jsonc
new file mode 100644
index 0000000000000..db206e8f4672a
--- /dev/null
+++ b/docker/volumes/functions/deno.jsonc
@@ -0,0 +1,6 @@
+{
+ "imports": {
+ "@supabase/functions-js": "jsr:@supabase/functions-js@^2",
+ "@supabase/server": "npm:@supabase/server@^1"
+ }
+}
diff --git a/docker/volumes/functions/hello/index.ts b/docker/volumes/functions/hello/index.ts
index e3f138b5ecafc..50d0e6be8c46d 100644
--- a/docker/volumes/functions/hello/index.ts
+++ b/docker/volumes/functions/hello/index.ts
@@ -2,13 +2,36 @@
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
-Deno.serve(async () => {
- return new Response(
- `"Hello from Edge Functions!"`,
- { headers: { "Content-Type": "application/json" } },
- )
-})
+// Setup type definitions for built-in Supabase Runtime APIs
+import "@supabase/functions-js/edge-runtime.d.ts"
+import { withSupabase } from "@supabase/server"
+
+// Logs are visible from 'functions' container inspector
+console.log("Hello from Functions!");
+
+// This endpoint uses 'publishable' | 'secret' access, apiKey is required.
+// Use publishable for Client-facing, key-validated endpoints
+// Use secret for Server-to-server, internal calls
+export default {
+ fetch: withSupabase({ auth: ["publishable", "secret"] }, async (req, ctx) => {
+ // Called by another service with a secret key
+ // ctx.supabaseAdmin bypasses RLS — use for privileged operations
+ /*
+ if (ctx.authMode === "secret") {
+ const { user_id } = await req.json();
+ const { data } = await ctx.supabaseAdmin.auth.admin.getUserById(user_id);
+
+ return Response.json({
+ email: data?.user?.email,
+ });
+ }
+ */
+
+ return Response.json({ message: "Hello from Edge Functions!" });
+ }),
+};
// To invoke:
-// curl 'http://localhost:/functions/v1/hello' \
-// --header 'Authorization: Bearer '
+// curl 'http://localhost:/functions/v1/hello' \
+// --header 'apiKey: '
+
diff --git a/docker/volumes/functions/main/index.ts b/docker/volumes/functions/main/index.ts
index 4761a27665704..8d00102c0ecb0 100644
--- a/docker/volumes/functions/main/index.ts
+++ b/docker/volumes/functions/main/index.ts
@@ -148,7 +148,9 @@ Deno.serve(async (req: Request) => {
const memoryLimitMb = 150
const workerTimeoutMs = 1 * 60 * 1000
const noModuleCache = false
- const importMapPath = null
+ // Using a common Import Map for all functions
+ // to use a scope 'deno.json' it must be dinamically resolved base on the 'service_name'
+ const importMapPath = `/home/deno/functions/deno.jsonc`
const envVarsObj = Deno.env.toObject()
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]])
diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml
index 96390b1d7853d..b1c3750cf6160 100644
--- a/supa-mdx-lint/Rule003Spelling.toml
+++ b/supa-mdx-lint/Rule003Spelling.toml
@@ -217,6 +217,7 @@ allow_list = [
"Datadog",
"Deadpool",
"Dependabot",
+ "DepthFirst",
"DDoS",
"Deno",
"Dependabot",
| | | | |