Files
9af6e65df4 fix(studio): DOM-nesting hydration errors, ghost deleted-snippet nav, and migrations query 400s (#47667)
App-level fixes that reproduce on BOTH the Next and TanStack builds —
split out of #47657 (which stays TanStack-only) for reviewability. All
were found by a full-site click-through of the dashboard.

## Invalid HTML nesting (React 19 "will cause a hydration error" console
errors)

- **FormLayout description rendered in a `<p>`**
(`packages/ui-patterns`): consumers pass arbitrary JSX (the RowEditor's
`created_at` timezone note passes a `<div>` with `<p>`s) →
`<p>`-in-`<p>` / `<div>`-in-`<p>`. Container is now a `<div>` with
identical classes (Tailwind preflight makes them render the same).
- **Switch toggles nested inside Tooltip trigger buttons**
(button-in-button) in ColumnEditor ("Allow Nullable" + "Is Unique"),
ExtensionRow, and PublicationsTableItem → repo-standard `TooltipTrigger
asChild` + `<div>` wrapper.
- **Saved log queries rendered a `<div>` directly inside `<tbody>`**
(`/logs/explorer/saved`) → rows are now proper `<tr><td colSpan>`
wrappers; the component itself is untouched (it's valid in its sidebar
usage).
- **Nested anchors in observability metric cards**: a card-level
`<Link>` wrapped MetricCard's "More information" `<Link>` (identical
URLs) → the chevron affordance renders as a `<span>` when no `href` is
passed; clicks bubble to the card link, tooltips preserved.
Design-system standalone usage unaffected.
- **`objectFit="cover"` passed to modern `next/image`** on the featured
integration card (unknown-prop warning) — the className already had
`object-cover`; prop dropped.

## Ghost dead-snippet after deletion

Deleting the active SQL snippet left its id in `useDashboardHistory`
(`history.sql`), so the "SQL Editor" nav item navigated to
`/sql/<deleted-id>` — content fetch 404s, no editor pane renders, and a
phantom tab reappears. Fixed both ends: delete flows now purge dashboard
history (and the tabs store clears a stale `previewTabId`), and
`/sql/[id]` treats a snippet 404 as "clean up + `router.replace` to
`/sql/new` + toast" instead of rendering the dead state. Unit tests for
the store/history cleanup.

## `pg-meta` migrations query 400s on every project load

`ActivityStats` on project home runs the migrations list query, whose
SQL was a bare `select * from supabase_migrations.schema_migrations` —
that table only exists once a migration has run, so every other project
logged a failed `?key=migrations` request on every load (visible in
production consoles too). The SQL is now guarded with `to_regclass` +
`query_to_xml` (same pattern as the advisor lints' `storage.buckets`
guard), returning zero rows instead of erroring; legacy version-only
tables still work. Tested against real dockerized Postgres (absent
table, populated ordering, special chars, legacy schema) + MSW hook
tests.

Found and verified via /test-supabase-local (browser click-through +
console audit on both builds).

## To test

Console must stay free of React DOM-nesting errors ("cannot be a
descendant of" / "cannot contain a nested") on each surface:

1. Table editor → Insert row panel (`created_at` field renders its
timezone note) and Edit column panel ("Allow Nullable"/"Is Unique"
tooltips still hover).
2. `/database/extensions` and `/database/publications` → toggle switches
render, tooltips hover.
3. `/logs/explorer/saved` (with ≥1 saved query) → rows render full-width
inside the table, hover shows Actions.
4. `/observability` → no nested-anchor error on load; card body click
and the chevron both navigate; label help-icons still show tooltips.
5. `/integrations` → no `objectFit` unknown-prop warning; featured card
images still cover.
6. **Ghost snippet**: open a SQL snippet → delete it via the sidebar →
click the "SQL Editor" nav item → lands on `/sql/new` (no phantom tab,
no 404 content fetch). Direct-load `/sql/<random-uuid>` → toast +
redirect to `/sql/new`.
7. **Migrations 400**: load project home with a project that has never
run a migration → the `pg-meta/<ref>/query?key=migrations` request
returns **200** with `[]` (previously a 400 on every load). Database →
Migrations still lists real migrations when they exist.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Deleted SQL snippets are fully removed from dashboard history and
stale editor/tab state; users are redirected with a toast.
  * Closing preview tabs no longer leaves stale references.
* Improved toggle/tooltip/dialog interactions to avoid broken UI,
including metric headers showing tooltips even without direct links.
* Migrations display safely when migration tables/relations are missing.

* **UI Improvements**
* Refreshed layout for saved queries, form descriptions, and integration
imagery.

* **Tests**
* Added coverage for snippet history cleanup, tab removal, migrations
SQL behavior, and query edge cases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


---

### Review feedback: `query_to_xml` breaks on Multigres (Ivan)

The defensive migrations query (added here to stop the `?key=migrations`
400 when the table doesn't exist yet) originally guarded with
`query_to_xml`, which is forbidden through Multigres's pooler (MUL-736 /
PSQL-1318). Rewritten without `query_to_xml`/`xmltable` using the
splinter#170 pattern: a PL/pgSQL `do` block guarded by `to_regclass`
(PL/pgSQL defers planning, so a missing table never errors) stashes the
rows into a transaction-local GUC via `set_config`, and a trailing
`select` reads them back with `jsonb_array_elements`. Verified that
postgres-meta sends the whole SQL as one simple-query string → single
implicit transaction → the local GUC survives to the `select` and
doesn't leak into the pooled connection. 6/6 dockerized-Postgres tests
(absent table → `[]`, populated/ordered/special-chars, legacy
version-only table, full pg-meta-shaped multi-statement string, GUC
non-leakage).

Note (out of scope, pre-existing):
`packages/pg-meta/src/sql/studio/advisor/lints.ts` still uses
`query_to_xml` — a separate pre-existing Multigres risk that should get
its own splinter-pattern sync.

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Saxon Fletcher <saxonafletcher@gmail.com>
2026-07-08 12:32:11 +08:00

359 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import parser from 'cron-parser'
import dayjs from 'dayjs'
import { Copy, Edit, Minus, MoreVertical, Play, Trash } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import { useState } from 'react'
import { toast } from 'sonner'
import {
Badge,
Button,
cn,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
copyToClipboard,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
HoverCard,
HoverCardContent,
HoverCardTrigger,
Switch,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { type CronTableColumn } from './CronJobs.constants'
import { useDatabaseCronJobRunCommandMutation } from '@/data/database-cron-jobs/database-cron-job-run-mutation'
import { type CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
import { useDatabaseCronJobToggleMutation } from '@/data/database-cron-jobs/database-cron-jobs-toggle-mutation'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const getNextRun = (schedule: string, lastRun?: string) => {
// cron-parser can only deal with the traditional cron syntax but technically users can also
// use strings like "30 seconds" now, For the latter case, we try our best to parse the next run
// (can't guarantee as scope is quite big)
if (schedule.includes('*') || schedule.includes('$')) {
try {
// pg_cron uses '$' for "last day of month", but cron-parser uses 'L'
// Convert pg_cron syntax to cron-parser syntax before parsing
const normalizedSchedule = schedule.replace(/\$/g, 'L')
const interval = parser.parseExpression(normalizedSchedule, { tz: 'UTC' })
return interval.next().getTime()
} catch (error) {
return undefined
}
} else {
// [Joshen] Only going to attempt to parse if the schedule is as simple as "n second" or "n seconds"
// Returned undefined otherwise - we can revisit this perhaps if we get feedback about this
const [value, unit] = schedule.toLocaleLowerCase().split(' ')
if (
['second', 'seconds'].includes(unit) &&
!Number.isNaN(Number(value)) &&
lastRun !== undefined
) {
const parsedLastRun = dayjs(lastRun).add(Number(value), unit as dayjs.ManipulateType)
return parsedLastRun.valueOf()
} else {
return undefined
}
}
}
interface CronJobTableCellProps {
col: CronTableColumn
row: CronJob
onSelectEdit: (job: CronJob) => void
onSelectDelete: (job: CronJob) => void
}
export const CronJobTableCell = ({
col,
row,
onSelectEdit,
onSelectDelete,
}: CronJobTableCellProps) => {
const { data: project } = useSelectedProjectQuery()
const [searchQuery] = useQueryState('search', parseAsString.withDefault(''))
const [showToggleModal, setShowToggleModal] = useState(false)
const value = row?.[col.id as keyof typeof row]
const { jobid, schedule, latest_run, status, active, jobname } = row
const formattedValue = (
col.id === 'jobname' && !jobname
? 'No name provided'
: col.id === 'lastest_run'
? !!value
? dayjs(value as string).valueOf()
: undefined
: col.id === 'next_run'
? getNextRun(schedule, latest_run)
: value
) as string
const hasValue = col.id === 'next_run' ? !!formattedValue : col.id in row
const { mutate: runCronJob, isPending: isRunning } = useDatabaseCronJobRunCommandMutation({
onSuccess: () => {
toast.success(`Command from "${jobname}" ran successfully`)
},
})
const { mutate: toggleDatabaseCronJob, isPending: isToggling } = useDatabaseCronJobToggleMutation(
{
onSuccess: (_, vars) => {
toast.success(`Successfully ${vars.active ? 'enabled' : 'disabled'} "${jobname}"`)
setShowToggleModal(false)
},
}
)
const onRunCronJob = () => {
runCronJob({
projectRef: project?.ref!,
connectionString: project?.connectionString,
jobId: jobid,
})
}
const onConfirmToggle = () => {
toggleDatabaseCronJob({
projectRef: project?.ref!,
connectionString: project?.connectionString,
jobId: jobid,
active: !active,
searchTerm: searchQuery,
})
}
if (col.id === 'actions') {
return (
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="text"
aria-label="More actions"
loading={isRunning}
className="h-6 w-6"
icon={<MoreVertical />}
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 space-y-1">
<Tooltip>
<TooltipTrigger className="w-full">
<DropdownMenuItem
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
onRunCronJob()
}}
>
<Play size={12} />
Run command
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent>
Manual runs execute the command immediately and will not appear in the cron jobs
table.
</TooltipContent>
</Tooltip>
<DropdownMenuItem
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
onSelectEdit(row)
}}
>
<Edit size={12} />
Edit job
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
onSelectDelete(row)
}}
>
<Trash size={12} />
Delete job
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
if (col.id === 'active') {
return (
<Dialog open={showToggleModal} onOpenChange={setShowToggleModal}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
<div className="flex items-center">
<Switch
id={`cron-job-active-${jobid}`}
aria-label={`${active ? 'Disable' : 'Enable'} cron job`}
size="medium"
disabled={isToggling}
checked={active}
/>
</div>
</DialogTrigger>
<DialogContent
onClick={(e) => e.stopPropagation()}
dialogOverlayProps={{ onClick: (e) => e.stopPropagation() }}
>
<DialogHeader>
<DialogTitle>{active ? 'Disable' : 'Enable'} cron job</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection>
<p className="text-sm">
Are you sure you want to {active ? 'disable' : 'enable'} the cron job "{jobname}
"?{' '}
</p>
</DialogSection>
<DialogFooter>
<Button variant="default" onClick={() => setShowToggleModal(false)}>
Cancel
</Button>
<Button
variant={active ? 'warning' : 'primary'}
loading={isToggling}
onClick={onConfirmToggle}
>
{active ? 'Disable' : 'Enable'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className={cn('w-full flex items-center text-xs')}>
{['latest_run', 'next_run'].includes(col.id) ? (
!hasValue ? (
<Minus size={14} className="text-foreground-lighter" />
) : col.id === 'latest_run' && formattedValue === null ? (
<p className="text-foreground-lighter">Job has not been run yet</p>
) : col.id === 'next_run' && !formattedValue ? (
<p className="text-foreground-lighter">Unable to parse next run for job</p>
) : (
<TimestampInfo
utcTimestamp={formattedValue}
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
className="font-sans text-xs"
/>
)
) : col.id === 'command' ? (
<HoverCard openDelay={0} closeDelay={0}>
<HoverCardTrigger asChild>
<div className="text-xs font-mono w-full h-full flex items-center">
{formattedValue}
</div>
</HoverCardTrigger>
<HoverCardContent
align="end"
className="p-0 w-[400px]"
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs font-mono px-2 py-1 border-b">Command</p>
<CodeBlock
hideLineNumbers
language="sql"
value={formattedValue.trim()}
className={cn(
'py-0 px-3.5 max-w-full prose dark:prose-dark border-0 rounded-t-none',
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11',
'[&>code]:text-xs'
)}
/>
</HoverCardContent>
</HoverCard>
) : (
<p
className={cn(
col.id === 'jobname' && !jobname && 'text-foreground-lighter',
col.id === 'command' && 'font-mono'
)}
>
{formattedValue}
</p>
)}
{col.id === 'latest_run' && !!status && (
<Badge
variant={status === 'failed' ? 'destructive' : 'success'}
className="capitalize ml-2"
>
{status}
</Badge>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent onClick={(e) => e.stopPropagation()}>
<ContextMenuItem
className="gap-x-2"
onFocusCapture={(e) => e.stopPropagation()}
onSelect={() => copyToClipboard(formattedValue)}
>
<Copy size={12} />
<span>Copy {col.name.toLowerCase()}</span>
</ContextMenuItem>
<ContextMenuItem
disabled={!jobname}
onFocusCapture={(e) => e.stopPropagation()}
onSelect={() => onSelectEdit(row)}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 w-full">
<Edit size={12} />
<span>Edit job</span>
</div>
</TooltipTrigger>
{!jobname && (
<TooltipContent side="right" className="w-56">
This cron job doesn’t have a name and can’t be edited. Create a new one and delete
this job.
</TooltipContent>
)}
</Tooltip>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="gap-x-2"
onFocusCapture={(e) => e.stopPropagation()}
onSelect={() => onSelectDelete(row)}
>
<Trash size={12} />
<span>Delete job</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}