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 + +
+ + +
+ ( + + + + + + )} + /> + +
+
Parameters
+
+ {fields.map((f, idx) => ( +
+ ( + + + + + + )} + /> + +
+ ))} + +
+
+ + ( + + +
+ +
+
+
+ )} + /> + +
+
+ + {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