diff --git a/apps/studio/components/interfaces/Realtime/LiveQueries/LiveQueriesPageContent.tsx b/apps/studio/components/interfaces/Realtime/LiveQueries/LiveQueriesPageContent.tsx
new file mode 100644
index 00000000000..83e6dcff98b
--- /dev/null
+++ b/apps/studio/components/interfaces/Realtime/LiveQueries/LiveQueriesPageContent.tsx
@@ -0,0 +1,33 @@
+import { useState } from 'react'
+
+import { useParams } from 'common'
+import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import LiveQueriesTable from './LiveQueriesTable'
+import { LiveQuerySheet } from './LiveQuerySheet'
+import { Button } from 'ui'
+
+export const LiveQueriesPageContent = () => {
+ const { ref } = useParams()
+ const [open, setOpen] = useState(false)
+
+ return (
+ setOpen(true)}>
+ Add Query
+
+ }
+ >
+
+
+
+ )
+}
+
+export default LiveQueriesPageContent
diff --git a/apps/studio/components/interfaces/Realtime/LiveQueries/LiveQueriesTable.tsx b/apps/studio/components/interfaces/Realtime/LiveQueries/LiveQueriesTable.tsx
new file mode 100644
index 00000000000..7005dab5634
--- /dev/null
+++ b/apps/studio/components/interfaces/Realtime/LiveQueries/LiveQueriesTable.tsx
@@ -0,0 +1,55 @@
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Card,
+ CardContent,
+} from 'ui'
+
+const LiveQueriesTable = () => {
+ // Placeholder rows
+ const data = [] as Array<{ id: string; name: string; params: string[]; createdAt: string }>
+
+ return (
+
+
+
+
+
+
+ Name
+ Parameters
+ Created
+ Actions
+
+
+
+ {data.length === 0 ? (
+
+
+ No queries created yet
+ Click Add Query to create one
+
+
+ ) : (
+ data.map((row) => (
+
+ {row.name}
+ {row.params.join(', ')}
+ {row.createdAt}
+ -
+
+ ))
+ )}
+
+
+
+
+
+ )
+}
+
+export default LiveQueriesTable
diff --git a/apps/studio/components/interfaces/Realtime/LiveQueries/LiveQuerySheet.tsx b/apps/studio/components/interfaces/Realtime/LiveQueries/LiveQuerySheet.tsx
new file mode 100644
index 00000000000..680ed181c83
--- /dev/null
+++ b/apps/studio/components/interfaces/Realtime/LiveQueries/LiveQuerySheet.tsx
@@ -0,0 +1,399 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useForm, useFieldArray, type FieldArrayPath } from 'react-hook-form'
+import { yupResolver } from '@hookform/resolvers/yup'
+import { array, object, string } from 'yup'
+import { toast } from 'sonner'
+
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetFooter,
+ SheetSection,
+ Form_Shadcn_,
+ FormField_Shadcn_,
+ FormControl_Shadcn_,
+ Input_Shadcn_,
+ TextArea_Shadcn_,
+ Button,
+ SimpleCodeBlock,
+ Card,
+ CardContent,
+} from 'ui'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import AIEditor from 'components/ui/AIEditor'
+
+interface LiveQuerySheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+const schema = object({
+ name: string().required('Name is required').matches(/^\S+$/, 'No spaces allowed'),
+ parameters: array().of(string().required('Parameter cannot be empty')).default([]),
+ sql: string().required('SQL is required'),
+})
+
+type FormValues = {
+ name: string
+ parameters: string[]
+ sql: string
+}
+
+export const LiveQuerySheet = ({ open, onOpenChange }: LiveQuerySheetProps) => {
+ const form = useForm({
+ resolver: yupResolver(schema),
+ defaultValues: { name: '', parameters: [''], sql: '' },
+ })
+
+ const { fields, append, remove } = useFieldArray>({
+ control: form.control,
+ name: 'parameters' as FieldArrayPath,
+ })
+
+ const [submitted, setSubmitted] = useState(null)
+
+ useEffect(() => {
+ if (!open) {
+ form.reset({ name: '', parameters: [''], sql: '' })
+ setSubmitted(null)
+ }
+ }, [open])
+
+ const onSubmit = (values: FormValues) => {
+ // fake submit: generate SQL outputs and keep sheet open
+ setSubmitted({ ...values })
+ toast.success('Generated SQL (not executed)')
+ }
+
+ const affectedTable = useMemo(() => {
+ const sql = submitted?.sql || form.getValues('sql') || ''
+ const lowered = sql.toLowerCase()
+ const fromMatch = /\bfrom\s+([a-zA-Z0-9_\.\"']+)/.exec(lowered)
+ const insertMatch = /\binsert\s+into\s+([a-zA-Z0-9_\.\"']+)/.exec(lowered)
+ const updateMatch = /\bupdate\s+([a-zA-Z0-9_\.\"']+)/.exec(lowered)
+ const deleteMatch = /\bdelete\s+from\s+([a-zA-Z0-9_\.\"']+)/.exec(lowered)
+ const raw = (fromMatch || insertMatch || updateMatch || deleteMatch)?.[1] || 'public.example'
+ // strip quotes and split schema.table
+ const cleaned = raw.replace(/[\"']/g, '')
+ const parts = cleaned.split('.')
+ return parts.length === 2
+ ? { schema: parts[0], table: parts[1] }
+ : { schema: 'public', table: parts[0] }
+ }, [submitted])
+
+ const topicPattern = useMemo(() => {
+ const name = (submitted?.name || form.getValues('name') || 'topic').trim()
+ const params = submitted?.parameters || form.getValues('parameters') || []
+ const placeholders = params
+ .filter(Boolean)
+ .map((p) => `{${p}}`)
+ .join('-')
+ return placeholders ? `${name}-${placeholders}` : name
+ }, [submitted])
+
+ const functionSQL = useMemo(() => {
+ if (!submitted) return ''
+ const { table } = affectedTable
+ const safeName = submitted.name.replace(/[^a-zA-Z0-9_]/g, '_')
+ const fnName = `notify_${table}_${safeName}`
+
+ // Build topic from row values
+ const concatTopic = submitted.parameters
+ .filter(Boolean)
+ .map((p) => `'-' || coalesce(coalesce(NEW.${p}::text, OLD.${p}::text), '')`)
+ .join(' || ')
+ const computedTopic = concatTopic
+ ? `('${submitted.name}' || ${concatTopic})`
+ : `'${submitted.name}'`
+
+ // Parse SELECT list to determine exactly which fields to include in the payload
+ const original = submitted.sql
+ const lower = original.toLowerCase()
+ const selIdx = lower.indexOf('select')
+ let fromIdx = -1
+ if (selIdx >= 0) {
+ const afterSel = lower.slice(selIdx + 6)
+ const idx = afterSel.indexOf('from')
+ fromIdx = idx >= 0 ? selIdx + 6 + idx : -1
+ }
+ const selectListRaw =
+ selIdx >= 0 ? original.slice(selIdx + 6, fromIdx > -1 ? fromIdx : original.length) : ''
+ const containsWildcard = /\*/.test(selectListRaw)
+
+ type SelectedField = { key: string; col: string }
+ const selected: SelectedField[] = []
+
+ const items = selectListRaw
+ .split(',')
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0)
+
+ for (const part of items) {
+ const partTrim = part.trim()
+ // Capture alias via AS
+ const aliasMatch = partTrim.match(/\bas\s+"?([a-zA-Z_][a-zA-Z0-9_]*)"?\s*$/i)
+ let expr = partTrim
+ let key: string | null = null
+ if (aliasMatch && aliasMatch.index !== undefined) {
+ key = aliasMatch[1]
+ expr = partTrim.slice(0, aliasMatch.index).trim()
+ }
+
+ // Remove quotes for expr analysis
+ const exprClean = expr.replace(/\"/g, '')
+
+ // Handle wildcard selects, e.g., *, t.*
+ if (/^(\w+\.)?\*$/.test(exprClean)) {
+ // '*' covered by containsWildcard; skip here
+ continue
+ }
+
+ // Try to extract base column identifier
+ const tokens = exprClean.split(/\s+/)
+ const lastToken = tokens[tokens.length - 1]
+ let baseCol = (lastToken.split('.').pop() || '').replace(/[^a-zA-Z0-9_]/g, '')
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(baseCol)) {
+ selected.push({ key: key || baseCol, col: baseCol })
+ }
+ }
+
+ // Deduplicate by key while keeping first occurrence
+ const deduped: SelectedField[] = []
+ const seen = new Set()
+ for (const f of selected) {
+ if (!seen.has(f.key)) {
+ seen.add(f.key)
+ deduped.push(f)
+ }
+ }
+
+ const payloadExpr = containsWildcard
+ ? `to_jsonb(coalesce(NEW, OLD))`
+ : deduped.length > 0
+ ? `jsonb_build_object(${deduped.map((f) => `'${f.key}', coalesce(NEW.${f.col}, OLD.${f.col})`).join(', ')})`
+ : `'{}'::jsonb`
+
+ return `create or replace function realtime.${fnName}()
+returns trigger
+language plpgsql
+security definer
+as $$
+declare
+ _payload jsonb;
+ _topic text;
+begin
+ _payload := ${payloadExpr};
+ _topic := ${computedTopic};
+ perform realtime.send(_payload, 'event', _topic, false);
+ if (TG_OP = 'DELETE') then
+ return OLD;
+ end if;
+ return NEW;
+end;
+$$;`
+ }, [submitted, affectedTable])
+
+ const triggerSQL = useMemo(() => {
+ if (!submitted) return ''
+ const { schema, table } = affectedTable
+ const safeName = submitted.name.replace(/[^a-zA-Z0-9_]/g, '_')
+ const fnName = `realtime.notify_${table}_${safeName}`
+ const trgName = `trg_notify_${table}_${safeName}`
+ return `drop trigger if exists ${trgName} on ${schema}.${table};
+create trigger ${trgName}
+after insert or update or delete on ${schema}.${table}
+for each row execute function ${fnName}();`
+ }, [submitted, affectedTable])
+
+ const rlsPolicySQL = useMemo(() => {
+ if (!submitted) return ''
+
+ const paramNames = submitted.parameters.filter(Boolean)
+ const nameSegments = submitted.name.split('-').length
+
+ const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ let injectedSql = submitted.sql
+ for (const p of paramNames) {
+ const re = new RegExp(`\\{${escapeRegExp(p)}\\}`, 'g')
+ injectedSql = injectedSql.replace(re, `(select ${p} from params)`)
+ }
+
+ const policyParamsSelect = paramNames.length
+ ? paramNames
+ .map((p, i) => `split_part(realtime.topic(), '-', ${nameSegments + i + 1}) as ${p}`)
+ .join(',\n ')
+ : 'null as _'
+
+ return `create policy "${submitted.name} - adhoc policy"
+on "realtime"."messages"
+for insert
+to authenticated
+with check (
+ left(realtime.topic(), length('${submitted.name}')) = '${submitted.name}'
+ and realtime.messages.extension in ('broadcast')
+ and exists (
+ with params as (
+ select
+ ${policyParamsSelect}
+ ),
+ q as (
+ ${injectedSql}
+ )
+ select 1 from q
+ )
+);`
+ }, [submitted])
+
+ const clientCode = useMemo(() => {
+ const name = submitted?.name || form.getValues('name') || 'my_live_query'
+ const params = (submitted?.parameters || form.getValues('parameters') || []).filter(Boolean)
+ const paramLines = params.map((p) => ` ${p}: ${p}`).join('\n')
+ const paramBlock = params.length ? `, {\n${paramLines}\n}` : ''
+ return `const { data } = supabase.from('${name}'${paramBlock})`
+ }, [submitted])
+
+ const clientChannelCode = useMemo(() => {
+ const name = submitted?.name || form.getValues('name') || 'my_live_query'
+ const params = (submitted?.parameters || form.getValues('parameters') || []).filter(Boolean)
+ const templates = params.map((p) => `\$\{${p}\}`).join('-')
+ const topic = templates ? `${name}-\`${templates}\`` : `${name}`
+ // Build backticked template literal correctly
+ return templates
+ ? `const channel = supabase.channel(\`${name}-${templates}\`)`
+ : `const channel = supabase.channel(\`${name}\`)`
+ }, [submitted])
+
+ return (
+
+
+
+ Create live query
+
+
+
+
+
+
+
+
+ {submitted && (
+
+
+
+
Trigger function
+
+
+ {functionSQL}
+
+
+
+
+
Trigger
+
+
+ {triggerSQL}
+
+
+
+
+
RLS policy
+
+
+ {rlsPolicySQL}
+
+
+
+
+
Client code
+
+
+ {clientCode}
+
+
+
+
+ {clientChannelCode}
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+export default LiveQuerySheet
diff --git a/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts b/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts
index 1aa33aeb64a..3d601d2dad5 100644
--- a/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts
+++ b/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts
@@ -20,6 +20,12 @@ export const generateRealtimeMenu = (
url: `/project/${ref}/realtime/inspector`,
items: [],
},
+ {
+ name: 'Live Queries',
+ key: 'live-queries',
+ url: `/project/${ref}/realtime/live-queries`,
+ items: [],
+ },
],
},
{
diff --git a/apps/studio/pages/project/[ref]/realtime/live-queries.tsx b/apps/studio/pages/project/[ref]/realtime/live-queries.tsx
new file mode 100644
index 00000000000..c1ef09ec949
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/realtime/live-queries.tsx
@@ -0,0 +1,17 @@
+import type { NextPageWithLayout } from 'types'
+
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import RealtimeLayout from 'components/layouts/RealtimeLayout/RealtimeLayout'
+import { LiveQueriesPageContent } from 'components/interfaces/Realtime/LiveQueries/LiveQueriesPageContent'
+
+const LiveQueriesPage: NextPageWithLayout = () => {
+ return
+}
+
+LiveQueriesPage.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default LiveQueriesPage