{topic}
+{description}
+ { + guides.length === 0 ? ( +No guides for this topic
+ ) : ( +-
+ {guides.map((guide) => (
+
- + {guide.data.title} + + ))} +
From 9f5b5ea6a758efe1ca085bbd13b9a89cd2b41e4f Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:59:40 +1000
Subject: [PATCH 001/333] fix(ui): standardise disclosure focus rings (#49660)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What kind of change does this PR introduce?
Bug fix and design-system accessibility update.
## What is the current behavior?
Accordion and collapsible triggers do not consistently use the shared
focus treatment. Accordion focus can animate from the browser outline,
while call sites need to manage radius and focus geometry themselves.
## What is the new behavior?
Bare accordion and collapsible triggers use the rounded `focus-inset`
treatment by default, positioned 2px inside the control. Interactive
children composed with `asChild`, such as a standard `Button`, retain
their own focus treatment. Accordion triggers include a small default
horizontal hit-area padding (`px-2`) and retain an explicit outer-ring
option for standalone controls. The focus indicator is not animated.
Focused tests cover the default and opt-out accordion variants.
| Before | After |
| --- | --- |
| |
|
|
|
|
## Visual impact
Most call sites should look the same at rest. The exceptions are
accordion triggers that did not already set their own horizontal
padding: they pick up the new default `px-2`, `rounded-md`, and lose the
old blanket `transition-all` on the trigger (chevron rotation is
unchanged).
| Resting layout change | Why |
| --- | --- |
| Accordion triggers without an explicit `px-*` | Inherit new default
`px-2` and `rounded-md` |
| Accordion triggers with their own `px-*` | Unchanged (call-site
padding wins) |
| Collapsible triggers | Focus ring only; spacing comes from the child
element |
| Direct `focus-inset` users (table rows, etc.) | Focus ring only |
## To test
Use Tab to focus each disclosure trigger. Confirm the focus outline is
rounded, sits 2px inside the trigger, and appears immediately without a
dark flash.
Previews:
[design-system](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app)
·
[studio-staging](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app)
·
[docs](https://docs-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app)
·
[www](https://zone-www-dot-com-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app)
### Design system
-
[Accordion](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/design-system/docs/components/accordion)
-
[Collapsible](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/design-system/docs/components/collapsible)
### Studio (focus-only unless noted)
- [Account → Access tokens → **New
token**](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/account/tokens):
permission category accordions (already `px-4`)
- [Database →
Roles](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/database/roles):
role row collapsibles
- [Observability → Query
performance](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/observability/query-performance):
index suggestion accordions (already `px-4`)
- [Settings → JWT
(legacy)](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/settings/jwt/legacy):
migration help collapsible
### Docs
- Docs mobile nav (narrow viewport): accordion sections **(resting
layout: +px-2)**
### WWW
- [Pricing →
FAQs](https://zone-www-dot-com-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/pricing):
FAQ accordions **(resting layout: +px-2)**
- WWW mobile nav (narrow viewport): menu accordions **(resting layout:
+px-2)**
### Not worth opening manually
Sheets, modals, error fallbacks, and replication/cron/integration
advanced-settings accordions inherit the focus fix but need specific
flows or failure states to reach. Error-state troubleshooting accordions
(`TroubleshootingSections`) also gain **+px-2** when visible.
## Summary by CodeRabbit
## New Features
- Accordion triggers now use an inset focus style by default, with an
option to use the standard ring style.
- Accordion and collapsible trigger styling can be customized more
flexibly.
## Accessibility
- Focus indicators appear consistently without flashing browser
defaults.
- Focus transitions are limited to visual properties that preserve
immediate keyboard focus visibility.
## Documentation
- Updated accessibility guidance explains inset focus behavior and
transition considerations.
---
.../content/docs/accessibility.mdx | 9 ++-
packages/config/css/utilities.css | 11 ++-
.../components/shadcn/ui/accordion.test.tsx | 37 +++++++++
.../ui/src/components/shadcn/ui/accordion.tsx | 78 +++++++++++--------
.../src/components/shadcn/ui/collapsible.tsx | 4 +-
5 files changed, 97 insertions(+), 42 deletions(-)
create mode 100644 packages/ui/src/components/shadcn/ui/accordion.test.tsx
diff --git a/apps/design-system/content/docs/accessibility.mdx b/apps/design-system/content/docs/accessibility.mdx
index efdede1da8f..c1660c5b963 100644
--- a/apps/design-system/content/docs/accessibility.mdx
+++ b/apps/design-system/content/docs/accessibility.mdx
@@ -59,16 +59,17 @@ focus-visible:ring-offset-background
Uses `outline` (not `ring`) so it paints reliably on interactive `
+ Attach this project's Warehouse directly from DuckDB. The script reads both passwords from + environment variables — set these before running it: +
+ fallback to the syntax
+ highlighter — without it the SQL renders unhighlighted and the blank lines between steps
+ collapse.
+ */}
+
+ + Warehouse speaks the Arrow FlightSQL protocol. Any FlightSQL-compatible client can connect — + for example, using the usql CLI: +
++ Enable catalog access to attach this project's Warehouse directly from DuckDB. +
+ ++ No tables are being copied yet. +
+ )} ++ {isEditing ? 'Edit replicated tables' : 'Enable Warehouse'} +
++ {isEditing + ? 'Choose which schemas or tables to replicate to your Warehouse. Tables already replicating are selected.' + : 'Replicate your database to a low-latency analytical endpoint over FlightSQL. Choose which schemas or tables to replicate — you can change this later.'} +
++ Schemas and tables to replicate +
+ ++ No tables in this schema. +
+ )} +` with no wrapper we can add classes to.
+ transformers: [
+ {
+ name: 'kb-code-block-classes',
+ pre(node) {
+ this.addClassToHast(node, 'border border-default rounded-lg')
+ },
+ code(node) {
+ this.addClassToHast(node, 'inline-block p-6')
+ },
+ },
+ ],
+ },
+ },
})
diff --git a/apps/kb/src/styles/globals.css b/apps/kb/src/styles/globals.css
index b15951345ec..efb8df5e7bb 100644
--- a/apps/kb/src/styles/globals.css
+++ b/apps/kb/src/styles/globals.css
@@ -73,8 +73,35 @@ h6:not(.font-mono),
border-color: var(--border-default, currentColor);
}
-/* TEMPORARY UNTIL WE IMPLEMENT OUR OWN CODE COMPONENT */
-.prose pre code {
- padding: 1rem;
- display: inline-block;
+/* Code block theme colors for use with Supabase Theme */
+[data-theme='dark'] {
+ --code-token-keyword: #bda4ff;
+ --code-foreground: #ffffff;
+ --code-token-constant: #3ecf8e;
+ --code-token-string: #ffcda1;
+ --code-token-comment: #949494;
+ --code-token-parameter: #ffffff;
+ --code-token-function: #3ecf8e;
+ --code-token-string-expression: #ffcda1;
+ --code-token-punctuation: #ffffff;
+ --code-token-link: #ffffff;
+ --code-token-number: #ffffff;
+ --code-token-property: #3ecf8e;
+ --code-highlight-color: #232323;
+}
+
+[data-theme='light'] {
+ --code-token-keyword: #5f2fc4;
+ --code-foreground: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-constant: #15593b;
+ --code-token-string: #9a5200;
+ --code-token-comment: #6a6a6a;
+ --code-token-parameter: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-function: #15593b;
+ --code-token-string-expression: #9a5200;
+ --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-link: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-number: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-property: #15593b;
+ --code-highlight-color: #1c1c1c;
}
From 7e19cbe1409907643cede39f723067378602e2ac Mon Sep 17 00:00:00 2001
From: Jeremias Menichelli
Date: Mon, 7 Sep 2026 12:40:25 +0200
Subject: [PATCH 006/333] feat(kb): Add topics pages support (#49959)
---
apps/kb/src/components/Nav.tsx | 13 ++++---
apps/kb/src/content.config.ts | 8 +++-
apps/kb/src/content/guides/sample-guide.md | 2 +-
apps/kb/src/layouts/GuideLayout.astro | 12 +++++-
apps/kb/src/lib/topics.ts | 31 ++++++++++++++++
apps/kb/src/pages/topics/[topic].astro | 43 ++++++++++++++++++++++
6 files changed, 98 insertions(+), 11 deletions(-)
create mode 100644 apps/kb/src/lib/topics.ts
create mode 100644 apps/kb/src/pages/topics/[topic].astro
diff --git a/apps/kb/src/components/Nav.tsx b/apps/kb/src/components/Nav.tsx
index d28f765a059..16ff6977028 100644
--- a/apps/kb/src/components/Nav.tsx
+++ b/apps/kb/src/components/Nav.tsx
@@ -9,15 +9,16 @@ import {
navigationMenuTriggerStyle,
} from 'ui'
+import { TOPICS, topicToSlug } from '../lib/topics'
+
+const topics = TOPICS.map((topic) => ({
+ label: topic.name,
+ href: `${import.meta.env.BASE_URL}/topics/${topicToSlug(topic.name)}`,
+}))
+
/**
* Hard-codding links in here for now until we have actual content. Might be worth putting these arrays in their on data file too.
*/
-const topics = [
- { label: 'Troubleshooting', href: '#' },
- { label: 'Migrations', href: '#' },
- { label: 'Comparisons', href: '#' },
-]
-
const resources = [
{ label: 'Status', href: 'https://status.supabase.com' },
{ label: 'Changelog', href: 'https://supabase.com/changelog' },
diff --git a/apps/kb/src/content.config.ts b/apps/kb/src/content.config.ts
index 92612a16b79..fec50c46fb6 100644
--- a/apps/kb/src/content.config.ts
+++ b/apps/kb/src/content.config.ts
@@ -2,6 +2,8 @@ import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'
+import { TOPIC_NAMES } from './lib/topics'
+
// Every entry here is rendered through GuideLayout by
// src/pages/guides/[...slug].astro — dropping a new file in
// src/content/guides doesn't need any per-file layout wiring.
@@ -9,8 +11,10 @@ const guides = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/guides' }),
schema: z.object({
title: z.string(),
- description: z.string().optional(),
- topics: z.array(z.string()).optional(),
+ description: z.string(),
+ // z.enum (not z.string) so a guide referencing a topic outside TOPICS
+ // fails content validation instead of silently rendering an orphaned tag.
+ topics: z.array(z.enum(TOPIC_NAMES)),
github_url: z.string().optional(),
}),
})
diff --git a/apps/kb/src/content/guides/sample-guide.md b/apps/kb/src/content/guides/sample-guide.md
index 6a69b4987bc..f1af5d55b00 100644
--- a/apps/kb/src/content/guides/sample-guide.md
+++ b/apps/kb/src/content/guides/sample-guide.md
@@ -1,7 +1,7 @@
---
title: 'Markdown elements sample'
description: 'A lorem ipsum sample guide exercising every base Markdown element supported by the guide layout.'
-topics: ['example', 'markdown']
+topics: ['Tutorial', 'Database']
github_url: 'https://github.com/supabase/supabase/discussions/0000000'
---
diff --git a/apps/kb/src/layouts/GuideLayout.astro b/apps/kb/src/layouts/GuideLayout.astro
index 49b82d04b1c..0362e842cfa 100644
--- a/apps/kb/src/layouts/GuideLayout.astro
+++ b/apps/kb/src/layouts/GuideLayout.astro
@@ -3,11 +3,12 @@ import { Github } from 'lucide-react'
import { Badge } from 'ui'
import Layout from './Layout.astro'
+import { getTopicDescription, type Topic, topicToSlug } from '../lib/topics'
interface Props {
title: string
description?: string
- topics?: string[]
+ topics?: Topic[]
github_url?: string
}
@@ -21,7 +22,14 @@ const { title, description, topics = [], github_url } = Astro.props
{
topics.length > 0 && (
- {topics.map((topic) => {topic} )}
+ {topics.map((topic) => (
+
+ {topic}
+
+ ))}
)
}
diff --git a/apps/kb/src/lib/topics.ts b/apps/kb/src/lib/topics.ts
new file mode 100644
index 00000000000..c0725d4a303
--- /dev/null
+++ b/apps/kb/src/lib/topics.ts
@@ -0,0 +1,31 @@
+// Canonical list of guide topics. Single source of truth for the `topics`
+// field in src/content.config.ts (so a guide with an unsupported topic fails
+// content validation), the "Topics" nav menu, and the /topics/[topic] pages.
+export const TOPICS = [
+ { name: 'Migration', description: 'Moving data, schemas, or projects onto Supabase' },
+ { name: 'Comparison', description: 'How Supabase compares to other databases and platforms' },
+ { name: 'Troubleshooting', description: 'Common errors and how to resolve them' },
+ { name: 'Tutorial', description: 'Step-by-step walkthroughs for building with Supabase' },
+ { name: 'Storage', description: 'Uploading, managing, and serving files' },
+ { name: 'Auth', description: 'Authentication, authorization, and user management' },
+ { name: 'Database', description: 'Postgres schemas, queries, and performance' },
+ { name: 'Edge Functions', description: 'Deploying and running serverless functions' },
+ { name: 'Queues', description: 'Background jobs and message processing' },
+ { name: 'Realtime', description: 'Broadcast, presence, and database changes' },
+ { name: 'Supabase Platform', description: 'Project settings, billing, and infrastructure' },
+] as const
+
+export type Topic = (typeof TOPICS)[number]['name']
+
+// zod's `enum()` needs a literal non-empty tuple of strings, which `.map()`
+// can't preserve on its own — this cast is safe because TOPICS is `as const`.
+export const TOPIC_NAMES = TOPICS.map((topic) => topic.name) as [Topic, ...Topic[]]
+
+export function getTopicDescription(topic: Topic): string {
+ return TOPICS.find((t) => t.name === topic)!.description
+}
+
+// URL-safe slug for a topic, e.g. "Edge Functions" -> "edge-functions".
+export function topicToSlug(topic: Topic): string {
+ return topic.toLowerCase().replace(/\s+/g, '-')
+}
diff --git a/apps/kb/src/pages/topics/[topic].astro b/apps/kb/src/pages/topics/[topic].astro
new file mode 100644
index 00000000000..aa741118dc0
--- /dev/null
+++ b/apps/kb/src/pages/topics/[topic].astro
@@ -0,0 +1,43 @@
+---
+import { getCollection } from 'astro:content'
+
+import Layout from '../../layouts/Layout.astro'
+import { TOPICS, topicToSlug } from '../../lib/topics'
+
+export async function getStaticPaths() {
+ const guides = await getCollection('guides')
+
+ // One page per topic in TOPICS — not just topics a guide already uses —
+ // so every link on the "Topics" nav resolves instead of 404ing.
+ return TOPICS.map((topic) => ({
+ params: { topic: topicToSlug(topic.name) },
+ props: {
+ topic: topic.name,
+ description: topic.description,
+ guides: guides.filter((guide) => guide.data.topics?.includes(topic.name)),
+ },
+ }))
+}
+
+const { topic, description, guides } = Astro.props
+---
+
+
+
+ {topic}
+ {description}
+ {
+ guides.length === 0 ? (
+ No guides for this topic
+ ) : (
+
+ {guides.map((guide) => (
+ -
+ {guide.data.title}
+
+ ))}
+
+ )
+ }
+
+
From 16046a0139c9d574c1f89ec5941664a39d35c705 Mon Sep 17 00:00:00 2001
From: Pedro Rodrigues <44656907+Rodriguespn@users.noreply.github.com>
Date: Mon, 7 Sep 2026 11:43:00 +0100
Subject: [PATCH 007/333] docs: add omp (oh-my-pi) to supported clients in MCP
and plugin guides (#50045)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this does
Adds **omp** (oh-my-pi) across the Supabase AI-tools docs.
- **Plugin docs** (`AgentPluginsPanel`) — omp client + Anthropic
marketplace install: `omp plugin marketplace add
anthropics/claude-plugins-official`, then `omp plugin install
supabase@claude-plugins-official`.
- **MCP docs** (`McpUrlBuilder`) — omp under "AI Agent CLI":
`.omp/mcp.json` / `~/.omp/agent/mcp.json` (JSON), `/mcp add` guided
setup, project vs user scope.
- **"Pick your agent" grid** — add the omp logo and tagline ("A coding
agent with the IDE wired in.").
No CLI command mapping entry — omp has no `omp mcp …` subcommand, so the
page documents file-based setup (`/mcp add` wizard or direct JSON edit).
Matches other file-configured clients like kiro.
## Testing
Verified against omp 18.1.11: written `.omp/mcp.json` parsed by runtime,
`/mcp list` shows server connected; `omp plugin marketplace add/install
supabase@claude-plugins-official` works; `omp plugin list` confirms
installed.
## Preview
### Agent Plugin page
### MCP page
### AI Tools main page
Closes AI-1184
## Summary by CodeRabbit
- **New Features**
- Added OMP as a supported AI coding agent across documentation and
integrations.
- Added OMP installation guidance, including marketplace setup, scope
options, session reloads, and authentication.
- Added support for configuring the Supabase MCP server through OMP.
- Added OMP branding, icon, tagline, repository, and documentation
links.
---
.../data/content-listings/ai-tools.data.ts | 2 ++
.../features/ui/AgentPluginsPanel.data.ts | 8 +++++
apps/docs/features/ui/AgentPluginsPanel.tsx | 32 +++++++++++++++++++
apps/docs/public/img/icons/agent-omp-icon.svg | 11 +++++++
.../src/McpUrlBuilder/assets/omp-icon.svg | 11 +++++++
.../src/McpUrlBuilder/clients.data.ts | 19 +++++++++++
.../McpUrlBuilder/clients.instructions.md.tsx | 26 +++++++++++++++
.../ui-patterns/src/McpUrlBuilder/types.ts | 14 ++++++++
.../src/McpUrlBuilder/utils/mcpIconAssets.ts | 2 ++
9 files changed, 125 insertions(+)
create mode 100644 apps/docs/public/img/icons/agent-omp-icon.svg
create mode 100644 packages/ui-patterns/src/McpUrlBuilder/assets/omp-icon.svg
diff --git a/apps/docs/data/content-listings/ai-tools.data.ts b/apps/docs/data/content-listings/ai-tools.data.ts
index 4b555dc87bc..6517e8a909e 100644
--- a/apps/docs/data/content-listings/ai-tools.data.ts
+++ b/apps/docs/data/content-listings/ai-tools.data.ts
@@ -19,6 +19,7 @@ const ICON_ASSETS: Record = {
'gemini-cli': { icon: '/docs/img/icons/agent-gemini-cli-icon', hasLightIcon: false },
'github-copilot': { icon: '/docs/img/icons/agent-copilot-icon', hasLightIcon: true },
kimi: { icon: '/docs/img/icons/agent-kimi-icon', hasLightIcon: true },
+ omp: { icon: '/docs/img/icons/agent-omp-icon', hasLightIcon: false },
vscode: { icon: '/docs/img/icons/agent-vscode-icon', hasLightIcon: false },
antigravity: { icon: '/docs/img/icons/agent-antigravity-icon', hasLightIcon: false },
windsurf: { icon: '/docs/img/icons/agent-devin-icon', hasLightIcon: true },
@@ -46,6 +47,7 @@ const TAGLINES: Record = {
goose: 'Your native open source AI agent — desktop app, CLI, and API.',
kimi: 'Engineered to drop into any dev workflow and get programming tasks done fast.',
kiro: 'Move beyond AI coding to agentic engineering.',
+ omp: 'A coding agent with the IDE wired in.',
opencode: 'The open source AI coding agent.',
vscode: 'The open source AI code editor — your home for multi-agent development.',
// Pre-acquisition tagline (Cognition/Devin acquired Windsurf in 2025) — see "Open questions".
diff --git a/apps/docs/features/ui/AgentPluginsPanel.data.ts b/apps/docs/features/ui/AgentPluginsPanel.data.ts
index 91902ae6412..883baae6939 100644
--- a/apps/docs/features/ui/AgentPluginsPanel.data.ts
+++ b/apps/docs/features/ui/AgentPluginsPanel.data.ts
@@ -63,6 +63,14 @@ export const PLUGIN_CLIENTS: PluginClient[] = [
repoUrl: 'https://github.com/supabase-community/supabase-plugin',
docsUrl: 'https://www.kimi.com/code/docs/en/kimi-code-cli/customization/plugins.html',
},
+ {
+ key: 'omp',
+ label: 'omp',
+ icon: 'omp',
+ repoUrl: 'https://github.com/supabase-community/supabase-plugin',
+ docsUrl: 'https://github.com/can1357/oh-my-pi/blob/main/docs/marketplace.md',
+ docsLinkText: 'View omp plugin docs',
+ },
{
key: 'vscode',
label: 'VS Code',
diff --git a/apps/docs/features/ui/AgentPluginsPanel.tsx b/apps/docs/features/ui/AgentPluginsPanel.tsx
index 7df6f067d13..bf509160047 100644
--- a/apps/docs/features/ui/AgentPluginsPanel.tsx
+++ b/apps/docs/features/ui/AgentPluginsPanel.tsx
@@ -169,6 +169,38 @@ function PluginInstructions({ client }: { client: PluginClient }) {
)
}
+ if (client.key === 'omp') {
+ return (
+
+
+ omp reads the Claude Code plugin format, so install the Supabase plugin from the{' '}
+
+ official Anthropic marketplace
+
+ :
+
+
+
+ Installs with --scope user by default, making it available across all your
+ projects. Use --scope project to install it for the current project only.
+
+
+ Inside a session, run /marketplace to browse plugins, then{' '}
+ /reload-plugins after installing to load the skills and MCP server.
+
+
+ )
+ }
if (client.key === 'vscode') {
return (
diff --git a/apps/docs/public/img/icons/agent-omp-icon.svg b/apps/docs/public/img/icons/agent-omp-icon.svg
new file mode 100644
index 00000000000..379eff88719
--- /dev/null
+++ b/apps/docs/public/img/icons/agent-omp-icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/packages/ui-patterns/src/McpUrlBuilder/assets/omp-icon.svg b/packages/ui-patterns/src/McpUrlBuilder/assets/omp-icon.svg
new file mode 100644
index 00000000000..379eff88719
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/assets/omp-icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts b/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts
index c73510e5023..fe7c79f3dc4 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts
+++ b/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts
@@ -13,6 +13,7 @@ import type {
McpClientConfig,
McpClientDeepLinkOptions,
McpFeatureGroup,
+ OmpMcpConfig,
OpenCodeMcpConfig,
VSCodeMcpConfig,
WindsurfMcpConfig,
@@ -360,6 +361,23 @@ export const MCP_CLIENT_DATA: McpClientData[] = [
}
},
},
+ {
+ key: 'omp',
+ label: 'omp',
+ icon: 'omp',
+ configFile: '.omp/mcp.json',
+ externalDocsUrl: 'https://github.com/can1357/oh-my-pi/blob/main/docs/mcp-config.md',
+ transformConfig: (config): OmpMcpConfig => {
+ return {
+ mcpServers: {
+ supabase: {
+ type: 'http',
+ url: config.mcpServers.supabase.url,
+ },
+ },
+ }
+ },
+ },
{
key: 'kiro',
label: 'Kiro',
@@ -449,6 +467,7 @@ export const MCP_CLIENT_GROUPS = [
'opencode',
'factory',
'fx',
+ 'omp',
],
},
{
diff --git a/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx b/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx
index 77ac73995d7..a73704c4797 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx
+++ b/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx
@@ -275,6 +275,32 @@ export const MCP_CLIENT_INSTRUCTIONS: Record = {
>
),
},
+ omp: {
+ primary: () => (
+ <>
+
+ Start and add the Supabase MCP server with the guided setup:
+
+
+ >
+ ),
+ alternate: () => (
+ <>
+
+ That path is project-scoped. To use the server in every project, add the same entry to{' '}
+ instead.
+
+
+ If a session is already open, pick up the change with .
+ omp opens your browser to complete the Supabase OAuth flow the first time it connects.
+
+
+ Confirm the server is connected with , or authorize again
+ with a different account using .
+
+ >
+ ),
+ },
kiro: {
deepLinkDescription: (
diff --git a/packages/ui-patterns/src/McpUrlBuilder/types.ts b/packages/ui-patterns/src/McpUrlBuilder/types.ts
index 570d09de794..89daf506c3d 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/types.ts
+++ b/packages/ui-patterns/src/McpUrlBuilder/types.ts
@@ -214,6 +214,19 @@ export interface KimiMcpConfig extends McpClientBaseConfig {
}
}
}
+/**
+ * Configuration format for the omp CLI MCP client.
+ * omp keys servers under `mcpServers` and requires an explicit `type: "http"`:
+ * an entry with a `url` but no `type` is validated as stdio and rejected.
+ */
+export interface OmpMcpConfig extends McpClientBaseConfig {
+ mcpServers: {
+ supabase: {
+ type: 'http'
+ url: string
+ }
+ }
+}
// Union of all possible config types
export type McpClientConfig =
@@ -230,6 +243,7 @@ export type McpClientConfig =
| GrokMcpConfig
| KimiMcpConfig
| McpClientBaseConfig
+ | OmpMcpConfig
| OpenCodeMcpConfig
| OtherMcpConfig
| VSCodeMcpConfig
diff --git a/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts b/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts
index a5fe3f63eb6..6b7938c2665 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts
+++ b/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts
@@ -20,6 +20,7 @@ import grokIcon from '../assets/grok-icon.svg'
import kimiDarkIcon from '../assets/kimi-icon-dark.svg'
import kimiIcon from '../assets/kimi-icon.svg'
import kiroIcon from '../assets/kiro-icon.svg'
+import ompIcon from '../assets/omp-icon.svg'
import openaiDarkIcon from '../assets/openai-icon-dark.svg'
import openaiIcon from '../assets/openai-icon.svg'
import opencodeDarkIcon from '../assets/opencode-icon-dark.svg'
@@ -52,6 +53,7 @@ const MCP_CLIENT_ICON_ASSETS = {
grok: { light: grokIcon, dark: grokDarkIcon },
kimi: { light: kimiIcon, dark: kimiDarkIcon },
kiro: { light: kiroIcon, dark: kiroIcon },
+ omp: { light: ompIcon, dark: ompIcon },
openai: { light: openaiIcon, dark: openaiDarkIcon },
opencode: { light: opencodeIcon, dark: opencodeDarkIcon },
perplexity: { light: perplexityIcon, dark: perplexityDarkIcon },
From b0601f9ab723a7fd416bf178f30a1bd20d4d0d60 Mon Sep 17 00:00:00 2001
From: Francesco Sansalvadore
Date: Mon, 7 Sep 2026 12:53:01 +0200
Subject: [PATCH 008/333] feat(storage): add object versioning feature flag and
feature preview (#49202)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
| # | Branch | Base |
| - | ------ | ---- |
| 1 | `feat/storage-versioning-private-alpha` ◀ | `master` |
| 2 | `feat/storage-versioning/002-bucket-form-fields` | 1 |
| 3 | `feat/storage-versioning/003-bucket-modals` | 2 |
| 4 | `feat/storage-versioning/004-object-versions-data` | 3 |
| 5 | `feat/storage-versioning/005-file-preview-versions` | 4 |
| 6 | `feat/storage-versioning/006-billing-storage-retention` | 5 |
### PR 01
Adding feature flag for versioning.
- `UI_PREVIEW_STORAGE_VERSIONING` local storage key, allowlisted
- Feature preview entry gated on
`useFlag('storageVersioningPrivateAlpha')`
- `useIsStorageVersioningEnabled()`, following the existing
`useIsSqlEditorManualSaveEnabled` shape
Note: will improve the feature preview with an image once we approach
release.
## Summary by CodeRabbit
- **New Features**
- Added a Storage Versioning feature preview in the dashboard.
- Preview access is controlled by feature flags and is disabled by
default.
- Added descriptive information about Storage Versioning and its Private
Alpha availability.
- Added support for remembering the Storage Versioning preview setting.
---------
Co-authored-by: Claude Sonnet 5
---
.../FeaturePreview/FeaturePreviewContext.tsx | 6 +++++
.../FeaturePreview/FeaturePreviewModal.tsx | 2 ++
.../StorageVersioningPreview.tsx | 23 +++++++++++++++++++
.../App/FeaturePreview/useFeaturePreviews.ts | 12 ++++++++++
packages/common/constants/local-storage.ts | 2 ++
5 files changed, 45 insertions(+)
create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/StorageVersioningPreview.tsx
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
index 739b178761e..66de34d7abb 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
+++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
@@ -171,6 +171,12 @@ export const useIsExplorerEnabled = () => {
return isExplorerEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER]
}
+export const useIsStorageVersioningEnabled = () => {
+ const { flags } = useFeaturePreviewContext()
+ const isStorageVersioningEnabled = useFlag('storageVersioningPrivateAlpha')
+ return isStorageVersioningEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING]
+}
+
export const useFeaturePreviewModal = () => {
const featurePreviews = useFeaturePreviews()
const [featurePreviewModal, setFeaturePreviewModal] = useQueryState('featurePreviewModal')
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
index 794d865cd2f..bd8f5ae2a19 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
+++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
@@ -39,6 +39,7 @@ import { JitDbAccessPreview } from './JitDbAccessPreview'
import { PgDeltaDiffPreview } from './PgDeltaDiffPreview'
import { PlatformWebhooksPreview } from './PlatformWebhooksPreview'
import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview'
+import { StorageVersioningPreview } from './StorageVersioningPreview'
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
import { FeaturePreview, useFeaturePreviews } from './useFeaturePreviews'
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
@@ -59,6 +60,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER]: ,
+ [LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING]: ,
}
export const FeaturePreviewModal = () => {
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/StorageVersioningPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/StorageVersioningPreview.tsx
new file mode 100644
index 00000000000..dd526321c90
--- /dev/null
+++ b/apps/studio/components/interfaces/App/FeaturePreview/StorageVersioningPreview.tsx
@@ -0,0 +1,23 @@
+export const StorageVersioningPreview = () => {
+ return (
+
+
+ Keep previous copies of a file when it's overwritten or deleted, and set a lifecycle policy
+ that expires them automatically.
+
+
+ Enabling this preview will:
+
+ - Add object versioning and lifecycle policy settings to the bucket settings
+ - Show a version history for each file in the file preview panel
+ - Allow soft-deleting and restoring files
+ - Break down retained version storage on the organization usage page
+
+
+
+ Versioning is in Private Alpha and is off for every bucket by default until you
+ turn it on.
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
index 5b3483ac31b..5c8e9d8e8ac 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
+++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
@@ -36,6 +36,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isDatabaseConnectionsEnabled = useFlag('topForPostgres')
const isExplorerEnabled = useFlag('explorer')
+ const isStorageVersioningEnabled = useFlag('storageVersioningPrivateAlpha')
const isSqlEditorManualSaveForced = useFlag('sqlEditorManualSaveForced')
@@ -150,6 +151,16 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
getRoute: (ref?: string) => `/project/${ref}/observability/connections`,
bannerId: 'database-connections-banner',
},
+ {
+ key: LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING,
+ name: 'Storage versioning',
+ discussionsUrl: undefined,
+ isNew: true,
+ isPlatformOnly: true,
+ isDefaultOptIn: true,
+ enabled: isStorageVersioningEnabled,
+ getRoute: (ref?: string) => `/project/${ref}/storage/files`,
+ },
]
return previews.sort((a, b) => Number(b.isNew) - Number(a.isNew))
@@ -160,5 +171,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
isMarketplaceEnabled,
isDatabaseConnectionsEnabled,
isExplorerEnabled,
+ isStorageVersioningEnabled,
])
}
diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts
index f5041ea185c..abb6311eb66 100644
--- a/packages/common/constants/local-storage.ts
+++ b/packages/common/constants/local-storage.ts
@@ -29,6 +29,7 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_MARKETPLACE: 'supabase-ui-marketplace',
UI_PREVIEW_DATABASE_CONNECTIONS: 'preview-database-connections',
UI_PREVIEW_EXPLORER: 'preview-explorer',
+ UI_PREVIEW_STORAGE_VERSIONING: 'supabase-ui-storage-versioning',
AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in',
@@ -172,6 +173,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE,
LOCAL_STORAGE_KEYS.SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED,
LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE,
+ LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING,
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
LOCAL_STORAGE_KEYS.BLOG_VIEW,
From ec1029dff03d2adda95045d2bf3102acb9f3db3e Mon Sep 17 00:00:00 2001
From: Alaister Young
Date: Mon, 7 Sep 2026 21:35:06 +0800
Subject: [PATCH 009/333] chore: migrate from clsx + tailwind-merge to
shadcn-ui/cn (#49938)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migrates the repo off `clsx` + `tailwind-merge` to
[shadcn-ui/cn](https://github.com/shadcn-ui/cn). Every app and package
already gets `cn` from `packages/ui`, so the swap happens in that one
helper and flows through to Studio, docs, www, and the rest.
**Changed:**
- `packages/ui` `cn` helper now uses `createCn` from `cn/config`,
keeping the custom `card`/`content` spacing scale so `p-card` still
overrides `p-4`. It has an explicit signature and re-exports
`ClassValue`.
- The four www Launch Week files that imported the `ClassValue` type
from `clsx` now import it from `ui`.
- `blocks/vue` local `lib/utils.ts` re-exports `cn` from the package.
- Comments/README that referenced tailwind-merge.
**Removed:**
- Direct `clsx` and `tailwind-merge` deps from `ui`, `ui-patterns`,
`www`, and `blocks/vue`. `ui-patterns` and `www` declared them without
importing.
**Added:**
- `packages/ui/src/lib/utils/cn.test.ts` covering clsx-style joining,
conflict resolution, the custom spacing scale, and variant handling.
Not migrated: the standalone apps under `examples/`. They're outside the
workspace and mostly on Tailwind v3, which `cn` doesn't support.
Lockfile note: after merging master, the lockfile diff is only the
intended swap (`clsx` and `tailwind-merge` out, `cn@0.2.5` in).
`tailwind-merge` stays in the lockfile as a transitive dep of a
third-party package.
Release-age note: this sat in draft with a temporary
`minimumReleaseAgeExclude` entry for `cn` while `cn` was inside the
workspace's 3-day `minimumReleaseAge` window. That window has closed, so
the exclusion is gone and nothing bypasses the release-age gate.
## To test
- `pnpm install --frozen-lockfile` succeeds with no
`minimumReleaseAgeExclude` entry for `cn`.
- `pnpm --filter ui test` – new `cn.test.ts` passes, including
`cn('p-4', 'p-card')` → `p-card`.
- Typecheck passes for studio, ui, ui-patterns, vue-blocks. www
typecheck panics under tsgo on master already (pre-existing, unrelated);
it passes with the JS `tsc` binary.
- Spot-check Studio locally: class overrides still win in the usual
places (e.g. `CodeEditor` height, `Button` variants with a custom
`className`).
https://claude.ai/code/session_01MkAt16tsPRDTm9oB5Jr8Ub
## Summary by CodeRabbit
* **Improvements**
* Standardized Tailwind class merging across shared UI utilities while
preserving conditional classes, custom spacing classes, and variant
behavior.
* Updated related components and examples to use the standardized
class-merging utility.
* **Tests**
* Added coverage for conditional class handling, conflicting utility
resolution, custom spacing classes, and variant separation.
* **Documentation**
* Updated usage guidance to reflect the standardized Tailwind
class-merging approach.
---------
Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
---
.../ui/CodeEditor/CodeEditor.test.tsx | 2 +-
.../components/ui/CodeEditor/CodeEditor.tsx | 2 +-
.../12/Releases/data/lw12_build_stage.tsx | 2 +-
.../13/Releases/data/lw13_build_stage.tsx | 2 +-
.../14/Releases/data/lw14_build_stage.tsx | 2 +-
.../LaunchWeek/15/data/lw15_build_stage.tsx | 2 +-
apps/www/package.json | 1 -
blocks/vue/package.json | 3 +-
blocks/vue/registry/default/lib/utils.ts | 8 +----
packages/ui-patterns/package.json | 1 -
packages/ui/README.md | 2 +-
packages/ui/package.json | 3 +-
packages/ui/src/lib/utils/cn.test.ts | 26 ++++++++++++++++
packages/ui/src/lib/utils/cn.ts | 12 +++----
pnpm-lock.yaml | 31 ++++++++-----------
15 files changed, 54 insertions(+), 45 deletions(-)
create mode 100644 packages/ui/src/lib/utils/cn.test.ts
diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx
index d4f6d10fea7..a6e5fcc0bf6 100644
--- a/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx
+++ b/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx
@@ -9,7 +9,7 @@ import { render } from '@/tests/helpers'
* win — otherwise the editor collapses to a single line.
*
* This regressed once already: #47339 appended the default as `cn(className, 'monaco-editor',
- * 'h-full')`, and tailwind-merge keeps the *last* conflicting height utility, so the trailing
+ * 'h-full')`, and `cn` keeps the *last* conflicting height utility, so the trailing
* `h-full` clobbered caller heights. #47350 fixed it by passing `className` last. These tests
* lock that ordering in.
*
diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx
index cf36124858a..ad3e522f2c9 100644
--- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx
+++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx
@@ -260,7 +260,7 @@ export const CodeEditor = ({
// injects a second copy of Monaco's CSS onto the shared instance). Without an explicit
// height, an absolutely-positioned wrapper collapses to 0 and Monaco lays out at ~5px.
// Order matters: `h-full` is a default, so a caller-supplied height in `className`
- // (e.g. `h-96`) wins via tailwind-merge instead of being clobbered.
+ // (e.g. `h-96`) wins via `cn` class merging instead of being clobbered.
className={cn('monaco-editor', 'h-full', className)}
wrapperProps={{ className: wrapperClassName }}
value={value ?? undefined}
diff --git a/apps/www/components/LaunchWeek/12/Releases/data/lw12_build_stage.tsx b/apps/www/components/LaunchWeek/12/Releases/data/lw12_build_stage.tsx
index 83a20c6f45d..13b763cc696 100644
--- a/apps/www/components/LaunchWeek/12/Releases/data/lw12_build_stage.tsx
+++ b/apps/www/components/LaunchWeek/12/Releases/data/lw12_build_stage.tsx
@@ -1,7 +1,7 @@
// see apps/www/components/LaunchWeek/X/Releases/data/lwx_advent_days.tsx for reference
-import { type ClassValue } from 'clsx'
import { ReactNode } from 'react'
+import type { ClassValue } from 'ui'
export interface AdventDay {
icon?: ReactNode // use svg jsx with 34x34px viewport
diff --git a/apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx b/apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx
index 5b3d6e4313d..dcf210466af 100644
--- a/apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx
+++ b/apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx
@@ -1,8 +1,8 @@
// see apps/www/components/LaunchWeek/X/Releases/data/lwx_advent_days.tsx for reference
-import { type ClassValue } from 'clsx'
import { BookCopy, Flag, GitBranch, HardDrive } from 'lucide-react'
import { ReactNode } from 'react'
+import type { ClassValue } from 'ui'
export interface AdventDay {
icon?: ReactNode // use svg jsx with 34x34px viewport
diff --git a/apps/www/components/LaunchWeek/14/Releases/data/lw14_build_stage.tsx b/apps/www/components/LaunchWeek/14/Releases/data/lw14_build_stage.tsx
index 50435fa5c6f..59ab5636e6b 100644
--- a/apps/www/components/LaunchWeek/14/Releases/data/lw14_build_stage.tsx
+++ b/apps/www/components/LaunchWeek/14/Releases/data/lw14_build_stage.tsx
@@ -1,9 +1,9 @@
// see apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx for reference
-import { type ClassValue } from 'clsx'
import { AppWindow, Database, Globe } from 'lucide-react'
import { ReactNode } from 'react'
import { PRODUCT_MODULES } from 'shared-data/products'
+import type { ClassValue } from 'ui'
export interface AdventDay {
icon?: ReactNode // use svg jsx with 34x34px viewport
diff --git a/apps/www/components/LaunchWeek/15/data/lw15_build_stage.tsx b/apps/www/components/LaunchWeek/15/data/lw15_build_stage.tsx
index ced89da85d5..39d6d2406fa 100644
--- a/apps/www/components/LaunchWeek/15/data/lw15_build_stage.tsx
+++ b/apps/www/components/LaunchWeek/15/data/lw15_build_stage.tsx
@@ -1,5 +1,5 @@
-import type { ClassValue } from 'clsx'
import type { ReactNode } from 'react'
+import type { ClassValue } from 'ui'
export interface BuildDay {
icon?: ReactNode // use svg jsx with 34x34px viewport
diff --git a/apps/www/package.json b/apps/www/package.json
index 2dbec8f9552..bdc93fa0681 100644
--- a/apps/www/package.json
+++ b/apps/www/package.json
@@ -44,7 +44,6 @@
"animejs": "^4.0.2",
"class-variance-authority": "^0.7.1",
"classnames": "^2.3.1",
- "clsx": "^2.1.1",
"cobe": "^0.6.5",
"common": "workspace:*",
"common-tags": "^1.8.2",
diff --git a/blocks/vue/package.json b/blocks/vue/package.json
index 4ca3008fd4c..fa47f83d062 100644
--- a/blocks/vue/package.json
+++ b/blocks/vue/package.json
@@ -15,11 +15,10 @@
"@supabase/supabase-js": "catalog:",
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
+ "cn": "^0.2.4",
"h3": "^1.15.10",
"lucide-vue-next": "^0.562.0",
"nuxt": "^4.4.6",
- "tailwind-merge": "^3.5.0",
"vue": "^3.5.35",
"vue-router": "^4.5.1"
},
diff --git a/blocks/vue/registry/default/lib/utils.ts b/blocks/vue/registry/default/lib/utils.ts
index abba253f04c..ae9e5a7c376 100644
--- a/blocks/vue/registry/default/lib/utils.ts
+++ b/blocks/vue/registry/default/lib/utils.ts
@@ -1,7 +1 @@
-import type { ClassValue } from 'clsx'
-import { clsx } from 'clsx'
-import { twMerge } from 'tailwind-merge'
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
-}
+export { cn } from 'cn'
diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json
index 9acb4eed9a0..dd40894f18c 100644
--- a/packages/ui-patterns/package.json
+++ b/packages/ui-patterns/package.json
@@ -774,7 +774,6 @@
"@supabase/supabase-js": "catalog:",
"@tanstack/react-table": "catalog:",
"class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"common": "workspace:*",
"common-tags": "^1.8.2",
diff --git a/packages/ui/README.md b/packages/ui/README.md
index 02a7da1ac4a..999e7ad9dd2 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -16,7 +16,7 @@ Some of the components have the `_Shadcn_` suffix. These components should be pr
```tsx
// deep object merge (used for themes)
-import { clipboard, cn, mergeDeep } from 'ui' // clsx + tailwind-merge
+import { clipboard, cn, mergeDeep } from 'ui' // Tailwind class merging (shadcn-ui/cn)
// copy-to-clipboard helper
```
diff --git a/packages/ui/package.json b/packages/ui/package.json
index f87bfb5a4a5..676ce5ee370 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -18,7 +18,7 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
+ "cn": "^0.2.4",
"cmdk": "^1.1.1",
"color": "^4.2.3",
"date-fns": "^2.30.0",
@@ -38,7 +38,6 @@
"react-resizable-panels": "^4.6.5",
"recharts": "catalog:",
"sonner": "^1.5.0",
- "tailwind-merge": "^3.5.0",
"tailwindcss": "catalog:",
"vaul": "^1.1.2"
},
diff --git a/packages/ui/src/lib/utils/cn.test.ts b/packages/ui/src/lib/utils/cn.test.ts
new file mode 100644
index 00000000000..eb625823025
--- /dev/null
+++ b/packages/ui/src/lib/utils/cn.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest'
+
+import { cn } from './cn'
+
+describe('cn', () => {
+ it('joins conditional inputs like clsx', () => {
+ expect(cn('a', ['b', { c: true, d: false }], undefined, null, false && 'e')).toBe('a b c')
+ })
+
+ it('resolves conflicting Tailwind utilities, last one wins', () => {
+ expect(cn('p-2 text-foreground', 'p-4', 'text-foreground-light')).toBe(
+ 'p-4 text-foreground-light'
+ )
+ expect(cn('h-full', 'h-96')).toBe('h-96')
+ })
+
+ it('treats the custom card/content spacing scale as spacing utilities', () => {
+ expect(cn('p-4', 'p-card')).toBe('p-card')
+ expect(cn('px-content', 'px-6')).toBe('px-6')
+ expect(cn('gap-2', 'gap-card')).toBe('gap-card')
+ })
+
+ it('keeps variants separate from base utilities', () => {
+ expect(cn('p-2 hover:p-4', 'md:p-6')).toBe('p-2 hover:p-4 md:p-6')
+ })
+})
diff --git a/packages/ui/src/lib/utils/cn.ts b/packages/ui/src/lib/utils/cn.ts
index a2a546b06c5..bab7d6b4646 100644
--- a/packages/ui/src/lib/utils/cn.ts
+++ b/packages/ui/src/lib/utils/cn.ts
@@ -1,14 +1,12 @@
-import { ClassValue, clsx } from 'clsx'
-import { extendTailwindMerge } from 'tailwind-merge'
+import type { ClassValue } from 'cn'
+import { createCn } from 'cn/config'
-const twMerge = extendTailwindMerge({
+export type { ClassValue } from 'cn'
+
+export const cn: (...inputs: ClassValue[]) => string = createCn({
extend: {
theme: {
spacing: ['card', 'content'],
},
},
})
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
-}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5176ef80536..619b39d6aa7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1787,9 +1787,6 @@ importers:
classnames:
specifier: ^2.3.1
version: 2.3.2
- clsx:
- specifier: ^2.1.1
- version: 2.1.1
cobe:
specifier: ^0.6.5
version: 0.6.5
@@ -2058,9 +2055,9 @@ importers:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
- clsx:
- specifier: ^2.1.1
- version: 2.1.1
+ cn:
+ specifier: ^0.2.4
+ version: 0.2.5
h3:
specifier: 1.15.11
version: 1.15.11
@@ -2070,9 +2067,6 @@ importers:
nuxt:
specifier: ^4.4.6
version: 4.5.2(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7(supports-color@8.1.1)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.41)(aws4fetch@1.0.20)(cac@6.7.14)(commander@14.0.1)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(esbuild@0.28.1)(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.48.0)(tsx@4.22.4)(typescript@6.0.2)(vite@7.3.5(@types/node@22.13.14)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.77.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1))(yaml@2.9.0)
- tailwind-merge:
- specifier: ^3.5.0
- version: 3.5.0
vue:
specifier: ^3.5.35
version: 3.5.35(typescript@6.0.2)
@@ -2619,12 +2613,12 @@ importers:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
- clsx:
- specifier: ^2.1.1
- version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ cn:
+ specifier: ^0.2.4
+ version: 0.2.5
color:
specifier: ^4.2.3
version: 4.2.3
@@ -2679,9 +2673,6 @@ importers:
sonner:
specifier: ^1.5.0
version: 1.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- tailwind-merge:
- specifier: ^3.5.0
- version: 3.5.0
tailwindcss:
specifier: 'catalog:'
version: 4.2.4
@@ -2761,9 +2752,6 @@ importers:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
- clsx:
- specifier: ^2.1.1
- version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -10388,6 +10376,11 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
+ cn@0.2.5:
+ resolution: {integrity: sha512-OCjZtMeQfXbI4Es1+EIjkd77gvWzaE689gD8KhfexlqjClC06qR1MQBR+Z35ZMSPNEBWyHiItW1Soy0UvwNv9w==}
+ engines: {node: '>=20'}
+ hasBin: true
+
cobe@0.6.5:
resolution: {integrity: sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg==}
@@ -27335,6 +27328,8 @@ snapshots:
- '@types/react'
- '@types/react-dom'
+ cn@0.2.5: {}
+
cobe@0.6.5:
dependencies:
phenomenon: 1.6.0
From be030229bf849c9fcfc0f671871f3f36b7232039 Mon Sep 17 00:00:00 2001
From: Jordi Enric <37541088+jordienr@users.noreply.github.com>
Date: Mon, 7 Sep 2026 15:53:36 +0200
Subject: [PATCH 010/333] feat(studio): add workers to unified logs FE-4281
(#49682)
## Problem
Unified Logs does not expose Workers logs, so users cannot search
Workers ingress, runtime, or build events alongside other services.
## Fix
Add a Workers log type that classifies all three Workers OTEL streams.
Gate the option and any persisted Workers filters with the existing
Workers feature flag.
## How to test
- Enable the Workers feature flag and open Unified Logs.
- Select Workers from the Log Type filter.
- Expected result: Unified Logs shows ingress, runtime, and build events
with the Workers icon.
- Disable the Workers feature flag and load a URL containing
`log_type:eq:workers`.
- Expected result: the Workers option and filter are removed, and
Workers logs are not queried.
- Run `./node_modules/.bin/vitest --run
components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts
components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts
data/workers/worker-logs-query.test.ts` from `apps/studio`.
## Summary by CodeRabbit
* **New Features**
* Added Workers as a selectable log type in Unified Logs.
* Unified Logs now combines worker ingress, guest, and API streams under
the Workers category.
* Added a dedicated Workers icon and worker log filtering.
* **Improvements**
* Worker filters and URL parameters respect feature availability.
* Worker details show relevant metadata while omitting unavailable HTTP
fields.
* Improved handling of worker log levels, statuses, and raw data.
* Added stronger validation for unified log data.
* **Tests**
* Added coverage for worker routing, filtering, feature visibility,
parsing, and metadata redaction.
---
.../components/shared/DetailRow.tsx | 2 +-
.../UnifiedLogs/ServiceFlowPanel.test.ts | 27 +++
.../UnifiedLogs/ServiceFlowPanel.tsx | 26 ++-
.../UnifiedLogs/UnifiedLogs.constants.tsx | 7 +-
.../UnifiedLogs/UnifiedLogs.hooks.test.tsx | 40 +++++
.../UnifiedLogs/UnifiedLogs.hooks.ts | 21 +++
.../UnifiedLogs/UnifiedLogs.queries.test.ts | 44 +++++
.../UnifiedLogs/UnifiedLogs.queries.ts | 40 +++--
.../UnifiedLogs/UnifiedLogs.schema.ts | 11 +-
.../interfaces/UnifiedLogs/UnifiedLogs.tsx | 90 ++++++----
.../UnifiedLogs/UnifiedLogs.utils.test.ts | 152 ++++++++++++++++-
.../UnifiedLogs/UnifiedLogs.utils.ts | 87 ++++++++--
.../UnifiedLogs/components/Columns.tsx | 10 +-
.../UnifiedLogs/components/LogTypeIcon.tsx | 9 +-
apps/studio/data/logs/get-unified-logs.ts | 31 +---
.../data/logs/unified-logs-infinite-query.ts | 33 +---
.../data/logs/unified-logs.utils.test.ts | 156 ++++++++++++++++--
apps/studio/data/logs/unified-logs.utils.ts | 68 +++++++-
apps/studio/data/workers/worker-logs-query.ts | 7 +-
apps/studio/lib/constants/workers.ts | 6 +
packages/common/telemetry-constants.ts | 1 +
21 files changed, 695 insertions(+), 173 deletions(-)
create mode 100644 apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.test.ts
create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.test.tsx
diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx
index 1d463bc161c..92051cbf998 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx
@@ -52,7 +52,7 @@ export const DetailRow = ({
const valueEl = isLoading ? (
) : (
-
+
)
const rowClass = cn(
diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.test.ts b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.test.ts
new file mode 100644
index 00000000000..0023602d7b8
--- /dev/null
+++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+
+import { getLogDataForMetadataVisibility } from './ServiceFlowPanel'
+
+describe('getLogDataForMetadataVisibility', () => {
+ const logData = {
+ id: 'worker-log',
+ metadata: { source: 'worker_guest_logs', worker: 'api' },
+ raw_log_data: {
+ event_message: 'Worker failed',
+ metadata: { request_id: 'request-id' },
+ },
+ }
+
+ it('redacts top-level and nested metadata when metadata is hidden', () => {
+ const visibleData = JSON.parse(JSON.stringify(getLogDataForMetadataVisibility(logData, false)))
+
+ expect(visibleData).toEqual({
+ id: 'worker-log',
+ raw_log_data: { event_message: 'Worker failed' },
+ })
+ })
+
+ it('preserves metadata when metadata is visible', () => {
+ expect(getLogDataForMetadataVisibility(logData, true)).toBe(logData)
+ })
+})
diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx
index f2727a762ea..1f982d76e35 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx
@@ -27,7 +27,7 @@ import { ServiceFlowPanelControls } from './ServiceFlow/components/ServiceFlowPa
import { DetailSectionHeader } from './ServiceFlow/components/shared/DetailSection'
import { ColumnSchema } from './UnifiedLogs.schema'
import { QuerySearchParamsType } from './UnifiedLogs.types'
-import { getRowTimestampMs } from './UnifiedLogs.utils'
+import { getRawLogData, getRowTimestampMs } from './UnifiedLogs.utils'
import { useDataTable } from '@/components/ui/DataTable/providers/DataTableProvider'
import {
SERVICE_FLOW_TYPES,
@@ -44,6 +44,20 @@ interface ServiceFlowPanelProps {
searchParameters: QuerySearchParamsType
}
+export function getLogDataForMetadataVisibility(data: unknown, metadataVisible: boolean) {
+ if (metadataVisible || typeof data !== 'object' || data === null) return data
+
+ const redactedData = { ...data, metadata: undefined }
+ const rawLogData = 'raw_log_data' in data ? data.raw_log_data : undefined
+
+ if (typeof rawLogData !== 'object' || rawLogData === null) return redactedData
+
+ return {
+ ...redactedData,
+ raw_log_data: { ...rawLogData, metadata: undefined },
+ }
+}
+
export function ServiceFlowPanel({
dock,
setDock,
@@ -105,14 +119,8 @@ export function ServiceFlowPanel({
// Prepare JSON data for Raw JSON tab
const jsonData =
shouldShowServiceFlow && serviceFlowData?.result?.[0] ? serviceFlowData.result[0] : selectedRow
-
- const formattedJsonData =
- !logsMetadata && 'raw_log_data' in jsonData && 'metadata' in jsonData.raw_log_data
- ? {
- ...jsonData,
- raw_log_data: { ...jsonData.raw_log_data, metadata: undefined },
- }
- : jsonData
+ const rawLogData = getRawLogData(jsonData)
+ const formattedJsonData = getLogDataForMetadataVisibility(rawLogData, logsMetadata)
return (
<>
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx
index cda74b29f94..7b968ec97d5 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx
@@ -27,17 +27,14 @@ export const LOG_TYPES_LABELS = {
supavisor: 'Supavisor',
pgbouncer: 'PgBouncer',
multigres: 'Multigres',
+ workers: 'Workers',
}
type LogType = keyof typeof LOG_TYPES_LABELS
export const LOG_TYPES = Object.keys(LOG_TYPES_LABELS) as [LogType, ...LogType[]]
export const DEFAULT_LOG_TYPES = ['postgres', 'edge'] as const
-// ClickHouse `source` value for each unified log type. Single source of truth
-// consumed by both the unified logs list query (LOG_TYPE_CONDITION in
-// UnifiedLogs.queries.ts) and the single-log inspection point lookup
-// (unified-log-inspection-query.ts).
-export const LOG_TYPE_TO_SOURCE: Record = {
+export const LOG_TYPE_TO_SOURCE: Record, string> = {
edge: 'edge_logs',
postgrest: 'postgrest_logs',
storage: 'storage_logs',
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.test.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.test.tsx
new file mode 100644
index 00000000000..cff48cae578
--- /dev/null
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.test.tsx
@@ -0,0 +1,40 @@
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { useFilterSearchSync } from './UnifiedLogs.hooks'
+
+describe('useFilterSearchSync', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('defers platform filter synchronization until feature flags load', () => {
+ vi.useFakeTimers()
+ const applyFilterSearch = vi.fn()
+ const workersFilter = [{ id: 'log_type', value: ['workers'] }]
+ const { rerender } = renderHook(
+ ({ enabled }) =>
+ useFilterSearchSync({ applyFilterSearch, columnFilters: workersFilter, enabled }),
+ { initialProps: { enabled: false } }
+ )
+
+ act(() => vi.advanceTimersByTime(1_000))
+ expect(applyFilterSearch).not.toHaveBeenCalled()
+
+ rerender({ enabled: true })
+ act(() => vi.advanceTimersByTime(250))
+
+ expect(applyFilterSearch).toHaveBeenCalledOnce()
+ })
+
+ it('synchronizes self-hosted filters without waiting for feature flags', () => {
+ vi.useFakeTimers()
+ const applyFilterSearch = vi.fn()
+
+ renderHook(() => useFilterSearchSync({ applyFilterSearch, columnFilters: [], enabled: true }))
+
+ act(() => vi.advanceTimersByTime(250))
+
+ expect(applyFilterSearch).toHaveBeenCalledOnce()
+ })
+})
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts
index af67d64f4f0..5828d552d6a 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts
@@ -1,3 +1,5 @@
+import type { ColumnFiltersState } from '@tanstack/react-table'
+import { useDebounce } from 'common'
import { useQueryState } from 'nuqs'
import { useEffect, useMemo, useRef } from 'react'
@@ -5,6 +7,25 @@ import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
+export const useFilterSearchSync = ({
+ applyFilterSearch,
+ columnFilters,
+ enabled,
+}: {
+ applyFilterSearch: () => void
+ columnFilters: ColumnFiltersState
+ enabled: boolean
+}) => {
+ const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 250)
+
+ useEffect(() => {
+ if (!enabled) return
+
+ debouncedApplyFilterSearch()
+ return () => debouncedApplyFilterSearch.cancel()
+ }, [columnFilters, debouncedApplyFilterSearch, enabled])
+}
+
export const useResetFocus = () => {
useShortcut(SHORTCUT_IDS.UNIFIED_LOGS_RESET_FOCUS, () => {
// FIXME: some dedicated div[tabindex="0"] do not auto-unblur (e.g. the DataTableFilterResetButton)
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts
index a9b62e271bd..c593da987f4 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts
@@ -46,6 +46,41 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
expect(where).not.toContain(`log_attributes['request.path'] LIKE '%/storage/%'`)
})
+ it('routes the `workers` log type to every worker OTEL stream', () => {
+ const sql = getUnifiedLogsQuery(withFilters('log_type:eq:workers'))
+ const where = sql.split(/\bWHERE\b/)[1] ?? ''
+ expect(where).toContain(
+ `log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs')`
+ )
+ expect(where).not.toContain(`source = 'workers'`)
+ })
+
+ it('classifies every worker OTEL stream as workers in the projected log type', () => {
+ const sql = getUnifiedLogsQuery(baseSearch)
+ expect(sql).toContain(
+ `WHEN log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs') THEN 'workers'`
+ )
+ })
+
+ it('excludes every worker OTEL stream when the workers log type is negated', () => {
+ const sql = getUnifiedLogsQuery(withFilters('log_type:neq:workers'))
+ const where = sql.split(/\bWHERE\b/)[1] ?? ''
+ expect(where).toContain(
+ `NOT (log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs'))`
+ )
+ })
+
+ it('projects only Workers fields that exist on worker logs', () => {
+ const sql = getUnifiedLogsQuery(withFilters('log_type:eq:workers'))
+ const workerCondition =
+ "log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs')"
+
+ expect(sql).toContain(`WHEN ${workerCondition} THEN null`)
+ expect(sql).toContain(`if(${workerCondition}, null, log_attributes['request.method'])`)
+ expect(sql).toContain(`if(${workerCondition}, null, log_attributes['request.path'])`)
+ expect(sql).toContain(`if(${workerCondition}, log_attributes, map()) AS metadata`)
+ })
+
it('escapes single quotes in filter values to prevent SQL injection', () => {
const sql = getUnifiedLogsQuery(
withFilters(`method:eq:G'ET`, `pathname:eq:/customers'; DROP TABLE logs --`)
@@ -262,6 +297,15 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
`if(source = 'auth_logs', log_attributes['status'], log_attributes['response.status_code'])`
)
})
+
+ it('does not classify Workers rows into a severity bucket', () => {
+ const sql = getLogsChartQuery(withFilters('log_type:eq:workers'))
+ const workerCondition =
+ "log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs')"
+
+ expect(sql).toContain(`WHEN ${workerCondition} THEN null`)
+ expect(sql).not.toContain(`WHEN ${workerCondition} THEN 'success'`)
+ })
})
describe('getFacetCountQuery', () => {
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
index 619fd33ceef..e9f3ecba3e6 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
@@ -13,6 +13,7 @@ import {
safeSql,
type SafeLogSqlFragment,
} from '@/data/logs/safe-analytics-sql'
+import { WORKER_LOG_SOURCES } from '@/lib/constants/workers'
// Operator fragments for SQL emission. `safeSql` rejects plain strings, so we
// pre-brand the keywords we want to switch between.
@@ -54,12 +55,21 @@ const HTTP_STATUS_EXPR: SafeLogSqlFragment = safeSql`if(source = 'auth_logs', lo
* logs from postgREST / storage-api and are intentionally not part of unified
* logs; the UI surfaces gateway HTTP traffic for those buckets.
*/
-const LOG_TYPE_CONDITION: Record = Object.fromEntries(
- Object.entries(LOG_TYPE_TO_SOURCE).map(([type, source]) => [
- type,
- safeSql`source = ${lit(source)}`,
- ])
-)
+const WORKER_LOG_SOURCE_VALUES = Object.values(WORKER_LOG_SOURCES)
+const WORKER_LOG_SOURCE_CONDITION = safeSql`log_attributes['source'] IN (${joinSqlFragments(
+ WORKER_LOG_SOURCE_VALUES.map((source) => lit(source)),
+ ','
+)})`
+
+const LOG_TYPE_CONDITION: Record = {
+ ...Object.fromEntries(
+ Object.entries(LOG_TYPE_TO_SOURCE).map(([type, source]) => [
+ type,
+ safeSql`source = ${lit(source)}`,
+ ])
+ ),
+ workers: WORKER_LOG_SOURCE_CONDITION,
+}
// Derived `log_type` column for SELECT / GROUP BY / countIf use.
// WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest'
@@ -75,6 +85,7 @@ const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE
WHEN source = 'supavisor_logs' THEN 'supavisor'
WHEN source = 'pgbouncer_logs' THEN 'pgbouncer'
WHEN source = 'multigres_logs' THEN 'multigres'
+ WHEN ${WORKER_LOG_SOURCE_CONDITION} THEN 'workers'
ELSE source
END`
@@ -82,10 +93,15 @@ const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE
// auth-service `status` attribute for auth rows, and the Postgres
// `parsed.sql_state_code` (e.g. `42P01`) for postgres rows.
const STATUS_EXPR: SafeLogSqlFragment = safeSql`CASE
+ WHEN ${WORKER_LOG_SOURCE_CONDITION} THEN null
WHEN source = 'postgres_logs' THEN toString(log_attributes['parsed.sql_state_code'])
ELSE toString((${HTTP_STATUS_EXPR}))
END`
+const METHOD_EXPR: SafeLogSqlFragment = safeSql`if(${WORKER_LOG_SOURCE_CONDITION}, null, ${ATTR.method})`
+const PATHNAME_EXPR: SafeLogSqlFragment = safeSql`if(${WORKER_LOG_SOURCE_CONDITION}, null, ${ATTR.path})`
+const METADATA_EXPR: SafeLogSqlFragment = safeSql`if(${WORKER_LOG_SOURCE_CONDITION}, log_attributes, map())`
+
// SQL expression for derived `level`. Used inline (not as alias reference)
// because the OTEL endpoint can't resolve aliases inside countIf when the
// alias is not in GROUP BY.
@@ -95,6 +111,7 @@ const STATUS_EXPR: SafeLogSqlFragment = safeSql`CASE
// success/warning/error by status. Postgres-style severity is the
// fallback for rows without a status code.
const LEVEL_EXPR: SafeLogSqlFragment = safeSql`CASE
+ WHEN ${WORKER_LOG_SOURCE_CONDITION} THEN null
WHEN (${HTTP_STATUS_EXPR}) != '' AND toInt32OrZero((${HTTP_STATUS_EXPR})) >= 500 THEN 'error'
WHEN (${HTTP_STATUS_EXPR}) != '' AND toInt32OrZero((${HTTP_STATUS_EXPR})) BETWEEN 400 AND 499 THEN 'warning'
WHEN (${HTTP_STATUS_EXPR}) != '' AND toInt32OrZero((${HTTP_STATUS_EXPR})) BETWEEN 200 AND 299 THEN 'success'
@@ -262,10 +279,11 @@ const ROW_PROJECTION: SafeLogSqlFragment = safeSql`
${LOG_TYPE_EXPR} AS log_type,
${STATUS_EXPR} AS status,
${LEVEL_EXPR} AS level,
- ${ATTR.path} AS pathname,
+ ${PATHNAME_EXPR} AS pathname,
event_message,
- ${ATTR.method} AS method,
+ ${METHOD_EXPR} AS method,
${AUTH_USER_EXPR} AS auth_user,
+ ${METADATA_EXPR} AS metadata,
null AS log_count,
null AS logs
`
@@ -446,11 +464,11 @@ export const getFacetCountQuery = ({
: facet === 'level'
? LEVEL_EXPR
: facet === 'method'
- ? ATTR.method
+ ? METHOD_EXPR
: facet === 'status'
? STATUS_EXPR
: facet === 'pathname'
- ? ATTR.path
+ ? PATHNAME_EXPR
: safeSql`log_attributes[${lit(facet)}]`
const conditions: SafeLogSqlFragment[] = [
@@ -488,7 +506,7 @@ export const getLogsCountQuery = (search: QuerySearchParamsType): SafeLogSqlFrag
total: safeSql`'all'`,
log_type: LOG_TYPE_EXPR,
level: LEVEL_EXPR,
- method: ATTR.method,
+ method: METHOD_EXPR,
status: STATUS_EXPR,
}
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts
index 53ea85afe0e..b5726f6e7e5 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts
@@ -10,16 +10,17 @@ import {
export const columnSchema = z.object({
id: z.string(),
log_type: z.enum(LOG_TYPES),
- method: z.enum(METHODS),
- pathname: z.string(),
- level: z.enum(LEVELS),
- status: z.number(),
+ method: z.enum(METHODS).nullable(),
+ pathname: z.string().nullable(),
+ level: z.enum(LEVELS).nullable(),
+ status: z.number().nullable(),
date: z.date(),
timestamp: z.number(),
event_message: z.string().optional(),
log_count: z.number().optional(), // used to count function logs for a given execution_id
logs: z.array(z.any()).optional(), // array of function logs
- auth_user: z.string().optional(),
+ auth_user: z.string().nullable().optional(),
+ metadata: z.record(z.string(), z.unknown()).nullable().optional(),
})
export type ColumnSchema = z.infer
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
index d9f4ed0f001..d39a55acd5c 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
@@ -13,7 +13,7 @@ import {
useReactTable,
VisibilityState,
} from '@tanstack/react-table'
-import { LOCAL_STORAGE_KEYS, useDebounce, useParams } from 'common'
+import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFeatureFlags, useFlag, useParams } from 'common'
import { Loader2, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import { useQueryStates } from 'nuqs'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -42,14 +42,16 @@ import {
buildFilterSearchUpdate,
parseLogsFilterUrlParams,
} from './UnifiedLogs.filters'
-import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks'
+import { useFilterSearchSync, useLiveMode, useResetFocus } from './UnifiedLogs.hooks'
import { isUserFilterUnreachable } from './UnifiedLogs.queries'
import { ColumnSchema } from './UnifiedLogs.schema'
import { QuerySearchParamsType } from './UnifiedLogs.types'
import {
- gateMultigresLogType,
+ gateLogTypeFilters,
+ gateLogTypeOptions,
getFacetedUniqueValues,
getLevelRowClassName,
+ getWorkersLogsAvailability,
} from './UnifiedLogs.utils'
import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
import { Option } from '@/components/ui/DataTable/DataTable.types'
@@ -93,10 +95,25 @@ export const UnifiedLogs = () => {
const { ref: projectRef } = useParams()
const track = useTrack()
const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER)
+ const showMultigresLogs = useShowMultigresLogs()
+ const { hasLoaded: flagsLoaded } = useFeatureFlags()
+ const workersEnabled = !!useFlag('workers')
+ const workersAvailability = getWorkersLogsAvailability({
+ isPlatform: IS_PLATFORM,
+ flagsLoaded,
+ workersEnabled,
+ })
+ const visibleSearchFilters = gateLogTypeFilters(search.filter, {
+ multigres: showMultigresLogs,
+ workers: workersAvailability.preserveWorkersFilter,
+ })
const defaultColumnSorting = search.sort ? [search.sort] : []
const defaultColumnVisibility = { uuid: false }
- const defaultColumnFilters = buildDefaultColumnFilters(search)
+ const defaultColumnFilters = buildDefaultColumnFilters({
+ ...search,
+ filter: visibleSearchFilters,
+ })
const [topBarHeight, setTopBarHeight] = useState(0)
const topBarRef = useRef(null)
@@ -112,8 +129,6 @@ export const UnifiedLogs = () => {
return () => observer.unobserve(topBar)
}, [])
- const showMultigresLogs = useShowMultigresLogs()
-
const [sorting, setSorting] = useState(defaultColumnSorting)
const [columnFilters, setColumnFilters] = useState(defaultColumnFilters)
const [rowSelection, setRowSelection] = useState({})
@@ -135,19 +150,26 @@ export const UnifiedLogs = () => {
// Create a stable query key object by removing nulls/undefined, id, and live
// Mainly to prevent the react queries from unnecessarily re-fetching
- const searchParameters = useMemo(
- () =>
- Object.entries(search).reduce(
- (acc, [key, value]) => {
- if (!['id', 'live'].includes(key) && value !== null && value !== undefined) {
- acc[key] = value
- }
- return acc
- },
- {} as Record
- ) as QuerySearchParamsType,
- [search]
- )
+ const searchParameters = useMemo(() => {
+ const parameters = Object.entries(search).reduce(
+ (acc, [key, value]) => {
+ if (!['id', 'live'].includes(key) && value !== null && value !== undefined) {
+ acc[key] = value
+ }
+ return acc
+ },
+ {} as Record
+ ) as QuerySearchParamsType
+
+ if (parameters.filter) {
+ parameters.filter =
+ gateLogTypeFilters(parameters.filter, {
+ multigres: showMultigresLogs,
+ workers: workersAvailability.canQueryWorkers,
+ }) ?? null
+ }
+ return parameters
+ }, [search, showMultigresLogs, workersAvailability.canQueryWorkers])
const {
data: unifiedLogsData,
@@ -223,7 +245,7 @@ export const UnifiedLogs = () => {
}, [search.filter])
const getRowClassName = <
- TData extends { date: Date; level: (typeof LEVELS)[number]; timestamp: number },
+ TData extends { date: Date; level: (typeof LEVELS)[number] | null; timestamp: number },
>(
row: Row
) => {
@@ -274,7 +296,10 @@ export const UnifiedLogs = () => {
// Will need to refactor this bit
// - Each facet just handles its own state, rather than getting passed down like this
const filterFields = useMemo(() => {
- const gatedFields = gateMultigresLogType(defaultFilterFields, showMultigresLogs)
+ const gatedFields = gateLogTypeOptions(defaultFilterFields, {
+ multigres: showMultigresLogs,
+ workers: workersAvailability.canQueryWorkers,
+ })
return gatedFields.map((field) => {
const facetsField = facets?.[field.value]
@@ -302,17 +327,24 @@ export const UnifiedLogs = () => {
return { ...field, options }
})
- }, [facets, showMultigresLogs])
+ }, [facets, showMultigresLogs, workersAvailability.canQueryWorkers])
const applyFilterSearch = () => {
- setSearch(buildFilterSearchUpdate(columnFilters, filterFields))
+ const update = buildFilterSearchUpdate(columnFilters, filterFields)
+ if (Array.isArray(update.filter)) {
+ update.filter = gateLogTypeFilters(update.filter.map(String), {
+ multigres: showMultigresLogs,
+ workers: workersAvailability.canQueryWorkers,
+ })
+ }
+ setSearch(update)
}
- const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 250)
-
- useEffect(() => {
- debouncedApplyFilterSearch()
- }, [columnFilters, debouncedApplyFilterSearch])
+ useFilterSearchSync({
+ applyFilterSearch,
+ columnFilters,
+ enabled: workersAvailability.readyToSyncFilters,
+ })
useEffect(() => {
setSearch({ sort: sorting?.[0] || null })
@@ -485,7 +517,7 @@ export const UnifiedLogs = () => {
setColumnVisibility={setColumnVisibility}
searchParamsParser={SEARCH_PARAMS_PARSER}
emptyStateMessage={
- isUserFilterUnreachable(search) ? (
+ isUserFilterUnreachable(searchParameters) ? (
No results found
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts
index c31bb69b9b2..12b7f79b467 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts
@@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest'
import {
buildUnifiedLogsUrl,
- gateMultigresLogType,
+ gateLogTypeFilters,
+ gateLogTypeOptions,
getEventMessageDisplay,
+ getRawLogData,
+ getWorkersLogsAvailability,
parseMultigresEventMessage,
} from './UnifiedLogs.utils'
@@ -94,7 +97,55 @@ describe('getEventMessageDisplay', () => {
})
})
-describe('gateMultigresLogType', () => {
+describe('getRawLogData', () => {
+ it('returns only the real Workers payload fields', () => {
+ const row = {
+ event_message: 'Error: Dynamic require of "path" is not supported',
+ id: '51a29911-9293-4616-8984-743cc548b629',
+ metadata: {
+ cw_event_id: '39883203917805946105278943454814281535421893832620638214',
+ launch_id: '1788424715503435269',
+ log_group: '/aws/lambda-microvms/workers/cxkpapyhaaywrtudnqpl/api',
+ log_stream: 'launch-1788424715503435269',
+ source: 'worker_guest_logs',
+ worker: 'api',
+ },
+ project: 'cxkpapyhaaywrtudnqpl',
+ timestamp: 1788424716876000,
+ log_type: 'workers' as const,
+ status: null,
+ level: null,
+ method: null,
+ pathname: null,
+ auth_user: null,
+ date: new Date(1788424716876),
+ }
+
+ expect(getRawLogData(row)).toEqual({
+ id: '51a29911-9293-4616-8984-743cc548b629',
+ timestamp: 1788424716876000,
+ event_message: 'Error: Dynamic require of "path" is not supported',
+ metadata: row.metadata,
+ })
+ })
+
+ it('returns non-Workers rows unchanged', () => {
+ const row = {
+ id: 'edge-log',
+ timestamp: 1788424716876000,
+ log_type: 'edge' as const,
+ status: 200,
+ method: 'GET' as const,
+ pathname: '/rest/v1',
+ level: 'success' as const,
+ date: new Date(1788424716876),
+ }
+
+ expect(getRawLogData(row)).toBe(row)
+ })
+})
+
+describe('gateLogTypeOptions', () => {
const fields = [
{ value: 'date' },
{
@@ -102,23 +153,110 @@ describe('gateMultigresLogType', () => {
options: [
{ label: 'Postgres', value: 'postgres' },
{ label: 'Multigres', value: 'multigres' },
+ { label: 'Workers', value: 'workers' },
],
},
]
- it('drops the multigres log_type option when the flag is disabled', () => {
- const gated = gateMultigresLogType(fields, false)
+ it('drops log_type options whose flags are disabled', () => {
+ const gated = gateLogTypeOptions(fields, { multigres: false, workers: false })
const logType = gated.find((field) => field.value === 'log_type')
expect(logType?.options?.map((option) => option.value)).toEqual(['postgres'])
})
- it('keeps the multigres option when the flag is enabled', () => {
- const gated = gateMultigresLogType(fields, true)
+ it('keeps independently enabled log types', () => {
+ const gated = gateLogTypeOptions(fields, { multigres: false, workers: true })
+ const logType = gated.find((field) => field.value === 'log_type')
+ expect(logType?.options?.map((option) => option.value)).toEqual(['postgres', 'workers'])
+ })
+
+ it('returns the original fields when every gated log type is enabled', () => {
+ const gated = gateLogTypeOptions(fields, { multigres: true, workers: true })
expect(gated).toBe(fields)
})
it('leaves non log_type fields untouched', () => {
- const gated = gateMultigresLogType(fields, false)
+ const gated = gateLogTypeOptions(fields, { workers: false })
expect(gated.find((field) => field.value === 'date')).toEqual({ value: 'date' })
})
})
+
+describe('gateLogTypeFilters', () => {
+ it('removes disabled log types from equality and inequality filters', () => {
+ expect(
+ gateLogTypeFilters(
+ ['log_type:eq:workers', 'log_type:neq:multigres', 'log_type:eq:postgres', 'method:eq:GET'],
+ { workers: false, multigres: false }
+ )
+ ).toEqual(['log_type:eq:postgres', 'method:eq:GET'])
+ })
+
+ it('keeps enabled log types and unrelated filters unchanged', () => {
+ const filters = ['log_type:eq:workers', 'method:eq:GET']
+ expect(gateLogTypeFilters(filters, { workers: true })).toBe(filters)
+ })
+
+ it('preserves absent filter values', () => {
+ expect(gateLogTypeFilters(undefined, { workers: false })).toBeUndefined()
+ expect(gateLogTypeFilters(null, { workers: false })).toBeNull()
+ })
+})
+
+describe('getWorkersLogsAvailability', () => {
+ const workersFilter = ['log_type:eq:workers']
+
+ it('preserves an unresolved platform filter without allowing it into queries or sync', () => {
+ const availability = getWorkersLogsAvailability({
+ isPlatform: true,
+ flagsLoaded: false,
+ workersEnabled: false,
+ })
+
+ expect(gateLogTypeFilters(workersFilter, { workers: availability.preserveWorkersFilter })).toBe(
+ workersFilter
+ )
+ expect(gateLogTypeFilters(workersFilter, { workers: availability.canQueryWorkers })).toEqual([])
+ expect(availability.readyToSyncFilters).toBe(false)
+ })
+
+ it('allows Workers filters and queries when the platform flag is enabled', () => {
+ const availability = getWorkersLogsAvailability({
+ isPlatform: true,
+ flagsLoaded: true,
+ workersEnabled: true,
+ })
+
+ expect(availability).toEqual({
+ canQueryWorkers: true,
+ preserveWorkersFilter: true,
+ readyToSyncFilters: true,
+ })
+ })
+
+ it('removes Workers filters and queries when the platform flag is disabled', () => {
+ const availability = getWorkersLogsAvailability({
+ isPlatform: true,
+ flagsLoaded: true,
+ workersEnabled: false,
+ })
+
+ expect(availability).toEqual({
+ canQueryWorkers: false,
+ preserveWorkersFilter: false,
+ readyToSyncFilters: true,
+ })
+ })
+
+ it('syncs generic filters immediately while keeping Workers unavailable on self-hosted', () => {
+ const availability = getWorkersLogsAvailability({
+ isPlatform: false,
+ workersEnabled: false,
+ })
+
+ expect(availability).toEqual({
+ canQueryWorkers: false,
+ preserveWorkersFilter: false,
+ readyToSyncFilters: true,
+ })
+ })
+})
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts
index 9d4f493efc0..c51518ffb94 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts
@@ -2,12 +2,32 @@ import { type Table as TTable } from '@tanstack/react-table'
import { cn } from 'ui'
import { LOG_TYPES_LABELS } from './UnifiedLogs.constants'
-import { FacetMetadataSchema } from './UnifiedLogs.schema'
+import { parseLogsFilterUrlParams } from './UnifiedLogs.filters'
+import { ColumnSchema, FacetMetadataSchema } from './UnifiedLogs.schema'
import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
import { Option } from '@/components/ui/DataTable/DataTable.types'
+import type { UnifiedLogInspectionEntry } from '@/data/logs/unified-log-inspection-query'
export type UnifiedLogType = keyof typeof LOG_TYPES_LABELS
+export function getWorkersLogsAvailability({
+ isPlatform,
+ flagsLoaded,
+ workersEnabled,
+}: {
+ isPlatform: boolean
+ flagsLoaded?: boolean
+ workersEnabled: boolean
+}) {
+ const flagsReady = flagsLoaded === true
+
+ return {
+ canQueryWorkers: isPlatform && flagsReady && workersEnabled,
+ preserveWorkersFilter: isPlatform && (!flagsReady || workersEnabled),
+ readyToSyncFilters: !isPlatform || flagsReady,
+ }
+}
+
export const buildUnifiedLogsUrl = ({
projectRef,
logType,
@@ -63,6 +83,21 @@ export function getRowTimestampMs(
return null
}
+type WorkersRawLogData = Pick
+
+export function getRawLogData(
+ row: ColumnSchema | UnifiedLogInspectionEntry
+): ColumnSchema | UnifiedLogInspectionEntry | WorkersRawLogData {
+ if (!('log_type' in row) || row.log_type !== 'workers') return row
+
+ return {
+ id: row.id,
+ timestamp: row.timestamp,
+ event_message: row.event_message,
+ metadata: row.metadata,
+ }
+}
+
export const getLevelLabel = (value: (typeof LEVELS)[number]): string => {
switch (value) {
case 'success':
@@ -86,7 +121,7 @@ export const getStatusLevel = (status?: number | string): string => {
return 'success'
}
-export function getLevelRowClassName(value: (typeof LEVELS)[number]): string {
+export function getLevelRowClassName(value: (typeof LEVELS)[number] | null | undefined): string {
switch (value) {
case 'success':
return ''
@@ -205,19 +240,43 @@ export function getEventMessageDisplay(
return { message: value, capitalize: false }
}
-/**
- * Multigres logs are gated behind the `showMultigresLogs` flag, so the multigres
- * log_type option is removed from the filter fields when the flag is disabled.
- */
-export function gateMultigresLogType(
+export function gateLogTypeOptions(
fields: T[],
- showMultigresLogs: boolean
+ visibility: Partial>
): T[] {
- if (showMultigresLogs) return fields
-
- return fields.map((field) =>
- field.value === 'log_type' && field.options
- ? ({ ...field, options: field.options.filter((option) => option.value !== 'multigres') } as T)
- : field
+ const hiddenLogTypes = new Set(
+ Object.entries(visibility)
+ .filter(([, visible]) => !visible)
+ .map(([logType]) => logType)
)
+
+ if (hiddenLogTypes.size === 0) return fields
+
+ return fields.map((field) => {
+ if (field.value !== 'log_type' || !field.options) return field
+ return {
+ ...field,
+ options: field.options.filter((option) => !hiddenLogTypes.has(option.value)),
+ }
+ })
+}
+
+export function gateLogTypeFilters(
+ filters: string[] | null | undefined,
+ visibility: Partial>
+): string[] | null | undefined {
+ if (!filters) return filters
+
+ const hiddenLogTypes = new Set(
+ Object.entries(visibility)
+ .filter(([, visible]) => !visible)
+ .map(([logType]) => logType)
+ )
+
+ if (hiddenLogTypes.size === 0) return filters
+
+ return filters.filter((filter) => {
+ const parsed = parseLogsFilterUrlParams([filter])[0]
+ return parsed?.column !== 'log_type' || !hiddenLogTypes.has(parsed.value)
+ })
}
diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx
index c6a6fc16046..a53b23bcfdd 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx
@@ -71,7 +71,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): {
header: '',
cell: ({ row }) => {
const level = row.getValue('level')
- return
+ return level ? : null
},
enableHiding: false,
enableResizing: false,
@@ -153,8 +153,8 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): {
('level')}
+ value={value ?? undefined}
+ level={row.getValue('level') ?? undefined}
/>
@@ -162,8 +162,8 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): {
) : (
('level')}
+ value={value ?? undefined}
+ level={row.getValue('level') ?? undefined}
/>
)}
diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx
index 437607e219a..105a6c263b9 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx
@@ -1,4 +1,4 @@
-import { Auth, EdgeFunctions, Realtime, Storage } from 'icons'
+import { Auth, EdgeFunctions, Realtime, Storage, Workers } from 'icons'
import { Box, Cable, Code2, Database, Network } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
@@ -11,11 +11,7 @@ interface LogTypeIconProps {
className?: string
}
-type IconComponent = React.ComponentType<{
- size?: number
- strokeWidth?: number
- className?: string
-}>
+type IconComponent = typeof Box | typeof Auth
// [Alaister]: commented out types coming in the future
// edge: Globe,
@@ -29,6 +25,7 @@ const ICON_MAP: Partial> = {
supavisor: Cable,
pgbouncer: Cable,
multigres: Network,
+ workers: Workers,
}
export const LogTypeIcon = ({
diff --git a/apps/studio/data/logs/get-unified-logs.ts b/apps/studio/data/logs/get-unified-logs.ts
index ef077ae76f6..fbcc0c4dc71 100644
--- a/apps/studio/data/logs/get-unified-logs.ts
+++ b/apps/studio/data/logs/get-unified-logs.ts
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { logsAllEndpointUrl, pickLogsQueryBuilder } from './logs-endpoint'
import { getUnifiedLogsISOStartEnd } from './unified-logs-infinite-query'
+import { mapUnifiedLogRow, parseUnifiedLogsQueryRows } from './unified-logs.utils'
import { getUnifiedLogsQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries'
import { getUnifiedLogsQuery as getUnifiedLogsQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq'
import { QuerySearchParamsType } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.types'
@@ -41,34 +42,8 @@ export async function retrieveUnifiedLogs({
if (error) handleError(error)
- const resultData = data?.result ?? []
-
- const result = resultData.map((row: any) => {
- const ts = String(row.timestamp ?? '')
- const looksLikeIso = /[T-]/.test(ts)
- const date = looksLikeIso
- ? new Date(/Z$|[+-]\d{2}:?\d{2}$/.test(ts) ? ts : `${ts}Z`)
- : new Date(Number(ts) / 1000)
- return {
- id: row.id,
- date,
- timestamp: row.timestamp,
- level: row.level,
- status: row.status || 200,
- method: row.method,
- host: row.host,
- pathname: (row.url || '').replace(/^https?:\/\/[^\/]+/, '') || row.pathname || '',
- event_message: row.event_message || row.body || '',
- headers:
- typeof row.headers === 'string' ? JSON.parse(row.headers || '{}') : row.headers || {},
- regions: row.region ? [row.region] : [],
- log_type: row.log_type || '',
- latency: row.latency || 0,
- log_count: row.log_count || null,
- logs: row.logs || [],
- auth_user: row.auth_user || null,
- }
- })
+ const resultData = parseUnifiedLogsQueryRows(data?.result)
+ const result = resultData.map(mapUnifiedLogRow)
return result
}
diff --git a/apps/studio/data/logs/unified-logs-infinite-query.ts b/apps/studio/data/logs/unified-logs-infinite-query.ts
index 61af3c1b476..cf564c9c81f 100644
--- a/apps/studio/data/logs/unified-logs-infinite-query.ts
+++ b/apps/studio/data/logs/unified-logs-infinite-query.ts
@@ -4,9 +4,8 @@ import { useFlag } from 'common'
import { executeAnalyticsSql } from './execute-analytics-sql'
import { logsKeys } from './keys'
import { logsAllEndpointUrl, pickLogsQueryBuilder } from './logs-endpoint'
-import { parseOtelTimestamp } from './otel-inspection.utils'
import { analyticsLiteral, safeSql } from './safe-analytics-sql'
-import { extractLogMetadata } from './unified-logs.utils'
+import { mapUnifiedLogRow, parseUnifiedLogsQueryRows } from './unified-logs.utils'
import { getUnifiedLogsQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries'
import { getUnifiedLogsQuery as getUnifiedLogsQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq'
import {
@@ -17,7 +16,6 @@ import { handleError } from '@/data/fetchers'
import type { ResponseError, UseCustomInfiniteQueryOptions } from '@/types'
const LOGS_PAGE_LIMIT = 50
-type LogLevel = 'success' | 'warning' | 'error'
export const UNIFIED_LOGS_QUERY_OPTIONS = {
refetchOnWindowFocus: false,
@@ -122,33 +120,8 @@ export async function getUnifiedLogs(
if (data.error) handleError(new Error(data.error as string))
- const resultData = data?.result ?? []
-
- const result = resultData.map((row: any) => {
- const date = parseOtelTimestamp(row.timestamp)
-
- const { status, method, pathname } = extractLogMetadata(row)
-
- return {
- id: row.id,
- date,
- method,
- pathname,
- status,
- timestamp: row.timestamp,
- level: row.level as LogLevel,
- host: row.host,
- event_message: row.event_message || row.body || '',
- headers:
- typeof row.headers === 'string' ? JSON.parse(row.headers || '{}') : row.headers || {},
- regions: row.region ? [row.region] : [],
- log_type: row.log_type || '',
- latency: row.latency || 0,
- log_count: row.log_count || null,
- logs: row.logs || [],
- auth_user: row.auth_user || null,
- }
- })
+ const resultData = parseUnifiedLogsQueryRows(data?.result)
+ const result = resultData.map(mapUnifiedLogRow)
const firstRow = result.length > 0 ? result[0] : null
const lastRow = result.length > 0 ? result[result.length - 1] : null
diff --git a/apps/studio/data/logs/unified-logs.utils.test.ts b/apps/studio/data/logs/unified-logs.utils.test.ts
index 1246c6e4c17..ce158451453 100644
--- a/apps/studio/data/logs/unified-logs.utils.test.ts
+++ b/apps/studio/data/logs/unified-logs.utils.test.ts
@@ -1,16 +1,19 @@
import { describe, expect, it } from 'vitest'
-import { extractLogMetadata } from './unified-logs.utils'
+import {
+ extractLogMetadata,
+ mapUnifiedLogRow,
+ parseUnifiedLogsQueryRows,
+} from './unified-logs.utils'
describe('extractLogMetadata', () => {
describe('non-auth logs', () => {
- it('returns the row status, method, and url-derived pathname', () => {
+ it('returns projected status, method, and pathname', () => {
const row = {
log_type: 'api',
status: 404,
method: 'GET',
- url: 'https://example.supabase.co/rest/v1/users?select=id',
- pathname: '/ignored',
+ pathname: '/rest/v1/users?select=id',
event_message: 'irrelevant',
}
@@ -21,12 +24,11 @@ describe('extractLogMetadata', () => {
})
})
- it('falls back to row.pathname when url is missing', () => {
+ it('returns the projected pathname', () => {
const row = {
log_type: 'api',
status: 500,
method: 'POST',
- url: '',
pathname: '/fallback',
event_message: '',
}
@@ -34,7 +36,7 @@ describe('extractLogMetadata', () => {
expect(extractLogMetadata(row).pathname).toBe('/fallback')
})
- it('returns empty string for pathname when both url and pathname are missing', () => {
+ it('returns an empty string when pathname is missing', () => {
const row = {
log_type: 'api',
status: 200,
@@ -49,7 +51,6 @@ describe('extractLogMetadata', () => {
const row = {
log_type: 'api',
method: 'GET',
- url: 'https://example.supabase.co/health',
event_message: '',
}
@@ -63,7 +64,6 @@ describe('extractLogMetadata', () => {
log_type: 'auth',
status: 999,
method: 'IGNORED',
- url: 'https://ignored',
event_message: JSON.stringify({
status: 400,
method: 'POST',
@@ -146,7 +146,6 @@ describe('extractLogMetadata', () => {
log_type: 'auth',
status: 200,
method: 'GET',
- url: 'https://example.supabase.co/token',
event_message: 'not json',
}
@@ -170,4 +169,141 @@ describe('extractLogMetadata', () => {
expect(extractLogMetadata(row).status).toBe(200)
})
})
+
+ describe('workers logs', () => {
+ it('does not synthesize unsupported request metadata', () => {
+ const row = {
+ event_message: 'Error: Dynamic require of "path" is not supported',
+ id: '51a29911-9293-4616-8984-743cc548b629',
+ log_type: 'workers',
+ metadata: {
+ cw_event_id: '39883203917805946105278943454814281535421893832620638214',
+ launch_id: '1788424715503435269',
+ log_group: '/aws/lambda-microvms/workers/cxkpapyhaaywrtudnqpl/api',
+ log_stream: 'launch-1788424715503435269',
+ source: 'worker_guest_logs',
+ worker: 'api',
+ },
+ project: 'cxkpapyhaaywrtudnqpl',
+ timestamp: 1788424716876000,
+ }
+
+ expect(extractLogMetadata(row)).toEqual({ status: null, method: null, pathname: null })
+ })
+
+ it('preserves only Workers metadata while keeping unsupported fields null', () => {
+ const metadata = {
+ cw_event_id: '39883203917805946105278943454814281535421893832620638214',
+ launch_id: '1788424715503435269',
+ log_group: '/aws/lambda-microvms/workers/cxkpapyhaaywrtudnqpl/api',
+ log_stream: 'launch-1788424715503435269',
+ source: 'worker_guest_logs',
+ worker: 'api',
+ }
+ const mapped = mapUnifiedLogRow({
+ event_message: 'Error: Dynamic require of "path" is not supported',
+ id: '51a29911-9293-4616-8984-743cc548b629',
+ log_type: 'workers',
+ metadata,
+ timestamp: 1788424716876000,
+ status: 200,
+ level: 'success',
+ method: 'GET',
+ pathname: '/invented',
+ auth_user: 'invented-user',
+ log_count: null,
+ logs: null,
+ })
+
+ expect(mapped).toMatchObject({
+ id: '51a29911-9293-4616-8984-743cc548b629',
+ timestamp: 1788424716876000,
+ event_message: 'Error: Dynamic require of "path" is not supported',
+ metadata,
+ status: null,
+ level: null,
+ method: null,
+ pathname: null,
+ auth_user: null,
+ })
+ expect(mapped).not.toHaveProperty('project')
+ })
+
+ it('does not add metadata to non-Workers rows', () => {
+ const mapped = mapUnifiedLogRow({
+ id: 'edge-log',
+ timestamp: 1788424716876000,
+ log_type: 'edge',
+ metadata: { request: 'existing metadata' },
+ status: 200,
+ level: 'success',
+ method: 'GET',
+ pathname: '/rest/v1',
+ event_message: null,
+ log_count: 0,
+ logs: null,
+ })
+
+ expect(mapped).not.toHaveProperty('metadata')
+ expect(mapped.log_count).toBe(0)
+ })
+ })
+})
+
+describe('parseUnifiedLogsQueryRows', () => {
+ const workersRow = {
+ event_message: 'Error: Dynamic require of "path" is not supported',
+ id: '51a29911-9293-4616-8984-743cc548b629',
+ metadata: {
+ cw_event_id: '39883203917805946105278943454814281535421893832620638214',
+ launch_id: '1788424715503435269',
+ log_group: '/aws/lambda-microvms/workers/cxkpapyhaaywrtudnqpl/api',
+ log_stream: 'launch-1788424715503435269',
+ source: 'worker_guest_logs',
+ worker: 'api',
+ },
+ project: 'cxkpapyhaaywrtudnqpl',
+ timestamp: 1788424716876000,
+ log_type: 'workers',
+ status: null,
+ level: null,
+ pathname: null,
+ method: null,
+ log_count: null,
+ logs: null,
+ auth_user: null,
+ }
+
+ it('parses the Workers projection and strips project', () => {
+ const [parsed] = parseUnifiedLogsQueryRows([workersRow])
+
+ expect(parsed).toMatchObject({
+ id: workersRow.id,
+ timestamp: workersRow.timestamp,
+ event_message: workersRow.event_message,
+ metadata: workersRow.metadata,
+ log_type: 'workers',
+ status: null,
+ level: null,
+ pathname: null,
+ method: null,
+ })
+ expect(parsed).not.toHaveProperty('project')
+ })
+
+ it('returns an empty array for undefined results', () => {
+ expect(parseUnifiedLogsQueryRows(undefined)).toEqual([])
+ })
+
+ it('rejects invalid metadata', () => {
+ expect(() => parseUnifiedLogsQueryRows([{ ...workersRow, metadata: 'invalid' }])).toThrow()
+ })
+
+ it.each([{ id: 42 }, { timestamp: true }])('rejects invalid identity fields', (invalidFields) => {
+ expect(() => parseUnifiedLogsQueryRows([{ ...workersRow, ...invalidFields }])).toThrow()
+ })
+
+ it('rejects invalid projected field types', () => {
+ expect(() => parseUnifiedLogsQueryRows([{ ...workersRow, level: 'info' }])).toThrow()
+ })
})
diff --git a/apps/studio/data/logs/unified-logs.utils.ts b/apps/studio/data/logs/unified-logs.utils.ts
index 4a4a290e7e9..8a71ea06124 100644
--- a/apps/studio/data/logs/unified-logs.utils.ts
+++ b/apps/studio/data/logs/unified-logs.utils.ts
@@ -1,13 +1,47 @@
+import { z } from 'zod'
+
+import { parseOtelTimestamp } from './otel-inspection.utils'
+import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
import { tryParseJson } from '@/lib/helpers'
+type UnifiedLogMetadataRow = {
+ log_type?: string | null
+ status?: string | number | null
+ method?: string | null
+ pathname?: string | null
+ event_message?: string | null
+}
+
+const unifiedLogsQueryRowSchema = z.object({
+ id: z.string(),
+ timestamp: z.union([z.string(), z.number()]),
+ log_type: z.string(),
+ status: z.union([z.string(), z.number()]).nullable(),
+ level: z.enum(LEVELS).nullable(),
+ pathname: z.string().nullable(),
+ event_message: z.string().nullable(),
+ method: z.string().nullable(),
+ log_count: z.number().nullable(),
+ logs: z.array(z.unknown()).nullable(),
+ auth_user: z.string().nullish(),
+ metadata: z.record(z.string(), z.unknown()).nullish(),
+})
+
+export type UnifiedLogsQueryRow = z.infer
+
+export const parseUnifiedLogsQueryRows = (value: unknown): UnifiedLogsQueryRow[] =>
+ z.array(unifiedLogsQueryRowSchema).parse(value ?? [])
+
const extractLeadingStatus = (s?: string) => {
const m = typeof s === 'string' ? s.match(/^(\d{3})\b/) : null
return m ? Number(m[1]) : undefined
}
-// [Joshen] Row has an unknown type in this case so `any` is accurate
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const extractLogMetadata = (row: any) => {
+export const extractLogMetadata = (row: UnifiedLogMetadataRow) => {
+ if (row.log_type === 'workers') {
+ return { status: null, method: null, pathname: null }
+ }
+
// [Joshen] For auth logs, these metadata are nested within event_message,
// so opting to bring them out at the query level
const eventMessage = tryParseJson(row.event_message)
@@ -18,10 +52,30 @@ export const extractLogMetadata = (row: any) => {
extractLeadingStatus(eventMessage?.error))
: (row.status ?? 200)
const method = row.log_type === 'auth' ? eventMessage?.method : row.method
- const pathname =
- row.log_type === 'auth'
- ? eventMessage?.path
- : (row.url || '').replace(/^https?:\/\/[^\/]+/, '') || row.pathname || ''
+ const pathname = row.log_type === 'auth' ? eventMessage?.path : row.pathname || ''
return { status, method, pathname }
}
+
+export const mapUnifiedLogRow = (row: UnifiedLogsQueryRow) => {
+ const isWorkersLog = row.log_type === 'workers'
+ const { status, method, pathname } = extractLogMetadata(row)
+
+ const mappedRow = {
+ id: row.id,
+ date: parseOtelTimestamp(row.timestamp),
+ method,
+ pathname,
+ status,
+ timestamp: row.timestamp,
+ level: isWorkersLog ? null : row.level,
+ event_message: row.event_message ?? '',
+ log_type: row.log_type,
+ log_count: row.log_count ?? null,
+ logs: row.logs ?? [],
+ auth_user: isWorkersLog ? null : row.auth_user || null,
+ }
+
+ if (isWorkersLog) return { ...mappedRow, metadata: row.metadata ?? null }
+ return mappedRow
+}
diff --git a/apps/studio/data/workers/worker-logs-query.ts b/apps/studio/data/workers/worker-logs-query.ts
index 7fb4cc10ca8..4ec0b1cdbd1 100644
--- a/apps/studio/data/workers/worker-logs-query.ts
+++ b/apps/studio/data/workers/worker-logs-query.ts
@@ -8,12 +8,7 @@ import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql'
import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint'
import { analyticsLiteral, safeSql } from '@/data/logs/safe-analytics-sql'
import { IS_PLATFORM } from '@/lib/constants'
-
-export const WORKER_LOG_SOURCES = {
- requests: 'worker_ingress_logs',
- output: 'worker_guest_logs',
- builds: 'worker_api_logs',
-} as const
+import { WORKER_LOG_SOURCES } from '@/lib/constants/workers'
export type WorkerLogStream = keyof typeof WORKER_LOG_SOURCES
diff --git a/apps/studio/lib/constants/workers.ts b/apps/studio/lib/constants/workers.ts
index 887f13c712a..0c331f5b925 100644
--- a/apps/studio/lib/constants/workers.ts
+++ b/apps/studio/lib/constants/workers.ts
@@ -1,2 +1,8 @@
export const PRODUCT_NAME = 'Workers'
export const CLI_NAME = 'workers'
+
+export const WORKER_LOG_SOURCES = {
+ requests: 'worker_ingress_logs',
+ output: 'worker_guest_logs',
+ builds: 'worker_api_logs',
+} as const
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index 6492cccb0bd..1137c2b6deb 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -3708,6 +3708,7 @@ export interface UnifiedLogsRowClickedEvent {
| 'supavisor'
| 'pgbouncer'
| 'multigres'
+ | 'workers'
}
groups: TelemetryGroups
}
From 10df98f2d5028000343d616da4dfd67cecbf9bc8 Mon Sep 17 00:00:00 2001
From: Jeremias Menichelli
Date: Mon, 7 Sep 2026 16:00:29 +0200
Subject: [PATCH 011/333] feat(kb): Create homepage and pinned topics and
guides (#49999)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
Adding the ability to pin guides or specific topics to homepage.
_To test:_
- Enter the preview of the project
- Go to the homepage
- Make sure pinned topics, pinned guides and the rest of the links work
correctly.
_Screenshot_
## Summary by CodeRabbit
- **New Features**
- Added homepage sections for pinned topics, featured guides, all
guides, and all topics.
- Added reusable cards with titles, descriptions, topic badges, and
optional links.
- Added support for GitHub-style Markdown alerts, including notes, tips,
warnings, and cautions.
- Added the ability to mark guides and topics as pinned for homepage
featuring.
- Added a guide demonstrating supported alert styles.
- **Style**
- Improved header, hero, and card borders for a more consistent visual
treatment.
---
apps/kb/src/components/Card.astro | 45 ++++++++++++++
apps/kb/src/components/Header.tsx | 2 +-
apps/kb/src/components/Hero.tsx | 2 +-
apps/kb/src/content.config.ts | 2 +
apps/kb/src/content/guides/sample-guide.md | 1 +
apps/kb/src/lib/topics.ts | 46 ++++++++++----
apps/kb/src/pages/index.astro | 71 ++++++++++++++++++++++
7 files changed, 156 insertions(+), 13 deletions(-)
create mode 100644 apps/kb/src/components/Card.astro
diff --git a/apps/kb/src/components/Card.astro b/apps/kb/src/components/Card.astro
new file mode 100644
index 00000000000..8784427734e
--- /dev/null
+++ b/apps/kb/src/components/Card.astro
@@ -0,0 +1,45 @@
+---
+// Small "glass panel" card, adapted from apps/docs' GlassPanel
+// (packages/ui-patterns/src/GlassPanel/index.tsx) — same surface treatment
+// (bg-surface-75, hover:border-strong), trimmed to what kb needs: no
+// logo/icon-picker machinery, which pulled in next/image and next-themes
+// that don't apply to a plain Astro app.
+//
+// Unlike GlassPanel (always a plain , link-agnostic — every call site
+// in docs wraps it in a Next.js ), this renders as an itself when
+// `href` is given. That's only safe because `topics` render as plain,
+// non-linked badges — nothing inside the card is itself a link, so there's
+// no nested- problem.
+import { Badge } from 'ui'
+
+interface Props {
+ href?: string
+ title: string
+ description?: string
+ topics?: string[]
+ class?: string
+}
+
+const { href, title, description, topics = [], class: className } = Astro.props
+const Tag = href ? 'a' : 'div'
+---
+
+
+ {title}
+ {description && {description}
}
+ {
+ topics.length > 0 && (
+
+ {topics.map((topic) => (
+ {topic}
+ ))}
+
+ )
+ }
+
diff --git a/apps/kb/src/components/Header.tsx b/apps/kb/src/components/Header.tsx
index 5f7c344da07..f1b9c746a54 100644
--- a/apps/kb/src/components/Header.tsx
+++ b/apps/kb/src/components/Header.tsx
@@ -7,7 +7,7 @@ import { ThemeToggle } from './ThemeToggle'
// search, no user menu, no dashboard link — kb doesn't have any of that yet.
function Header() {
return (
-
+
diff --git a/apps/kb/src/components/Hero.tsx b/apps/kb/src/components/Hero.tsx
index 8da35007604..779eac3e08b 100644
--- a/apps/kb/src/components/Hero.tsx
+++ b/apps/kb/src/components/Hero.tsx
@@ -3,7 +3,7 @@
// prompt panel (docs-specific, feature-flagged, and not something kb has yet).
function Hero() {
return (
-
+
Supabase Knowledge Base
diff --git a/apps/kb/src/content.config.ts b/apps/kb/src/content.config.ts
index fec50c46fb6..245e1163529 100644
--- a/apps/kb/src/content.config.ts
+++ b/apps/kb/src/content.config.ts
@@ -15,6 +15,8 @@ const guides = defineCollection({
// z.enum (not z.string) so a guide referencing a topic outside TOPICS
// fails content validation instead of silently rendering an orphaned tag.
topics: z.array(z.enum(TOPIC_NAMES)),
+ // Surfaces the guide in the homepage's "Featured Guides" section.
+ pinned: z.boolean().default(false),
github_url: z.string().optional(),
}),
})
diff --git a/apps/kb/src/content/guides/sample-guide.md b/apps/kb/src/content/guides/sample-guide.md
index f1af5d55b00..7aa9a1f9a8c 100644
--- a/apps/kb/src/content/guides/sample-guide.md
+++ b/apps/kb/src/content/guides/sample-guide.md
@@ -2,6 +2,7 @@
title: 'Markdown elements sample'
description: 'A lorem ipsum sample guide exercising every base Markdown element supported by the guide layout.'
topics: ['Tutorial', 'Database']
+pinned: true
github_url: 'https://github.com/supabase/supabase/discussions/0000000'
---
diff --git a/apps/kb/src/lib/topics.ts b/apps/kb/src/lib/topics.ts
index c0725d4a303..ecfe5e9c764 100644
--- a/apps/kb/src/lib/topics.ts
+++ b/apps/kb/src/lib/topics.ts
@@ -1,18 +1,42 @@
// Canonical list of guide topics. Single source of truth for the `topics`
// field in src/content.config.ts (so a guide with an unsupported topic fails
// content validation), the "Topics" nav menu, and the /topics/[topic] pages.
+//
+// `pinned` topics surface as cards on the homepage (see src/pages/index.astro)
+// — keep this to a handful so that section stays a highlights row, not a
+// second copy of the full topic list.
export const TOPICS = [
- { name: 'Migration', description: 'Moving data, schemas, or projects onto Supabase' },
- { name: 'Comparison', description: 'How Supabase compares to other databases and platforms' },
- { name: 'Troubleshooting', description: 'Common errors and how to resolve them' },
- { name: 'Tutorial', description: 'Step-by-step walkthroughs for building with Supabase' },
- { name: 'Storage', description: 'Uploading, managing, and serving files' },
- { name: 'Auth', description: 'Authentication, authorization, and user management' },
- { name: 'Database', description: 'Postgres schemas, queries, and performance' },
- { name: 'Edge Functions', description: 'Deploying and running serverless functions' },
- { name: 'Queues', description: 'Background jobs and message processing' },
- { name: 'Realtime', description: 'Broadcast, presence, and database changes' },
- { name: 'Supabase Platform', description: 'Project settings, billing, and infrastructure' },
+ {
+ name: 'Migration',
+ description: 'Moving data, schemas, or projects onto Supabase',
+ pinned: false,
+ },
+ {
+ name: 'Comparison',
+ description: 'How Supabase compares to other databases and platforms',
+ pinned: false,
+ },
+ { name: 'Troubleshooting', description: 'Common errors and how to resolve them', pinned: false },
+ {
+ name: 'Tutorial',
+ description: 'Step-by-step walkthroughs for building with Supabase',
+ pinned: true,
+ },
+ { name: 'Storage', description: 'Uploading, managing, and serving files', pinned: false },
+ { name: 'Auth', description: 'Authentication, authorization, and user management', pinned: true },
+ { name: 'Database', description: 'Postgres schemas, queries, and performance', pinned: true },
+ {
+ name: 'Edge Functions',
+ description: 'Deploying and running serverless functions',
+ pinned: false,
+ },
+ { name: 'Queues', description: 'Background jobs and message processing', pinned: false },
+ { name: 'Realtime', description: 'Broadcast, presence, and database changes', pinned: false },
+ {
+ name: 'Supabase Platform',
+ description: 'Project settings, billing, and infrastructure',
+ pinned: false,
+ },
] as const
export type Topic = (typeof TOPICS)[number]['name']
diff --git a/apps/kb/src/pages/index.astro b/apps/kb/src/pages/index.astro
index fe909f38c65..0ad77523149 100644
--- a/apps/kb/src/pages/index.astro
+++ b/apps/kb/src/pages/index.astro
@@ -1,8 +1,79 @@
---
+import { getCollection } from 'astro:content'
+
+import Card from '../components/Card.astro'
import { Hero } from '../components/Hero'
import Layout from '../layouts/Layout.astro'
+import { TOPICS, topicToSlug } from '../lib/topics'
+
+const guides = await getCollection('guides')
+const pinnedTopics = TOPICS.filter((topic) => topic.pinned)
+const featuredGuides = guides.filter((guide) => guide.data.pinned)
---
+
+
+ {
+ pinnedTopics.length > 0 && (
+
+ {pinnedTopics.map((topic) => (
+
+ ))}
+
+ )
+ }
+
+ {
+ featuredGuides.length > 0 && (
+
+ Featured Guides
+
+ {featuredGuides.map((guide) => (
+
+ ))}
+
+
+ )
+ }
+
+
+ All Guides
+
+ {guides.map((guide) => (
+ -
+
+ {guide.data.title}
+
+
+ ))}
+
+
+
+
+ All Topics
+
+ {TOPICS.map((topic) => (
+ -
+
+ {topic.name}
+
+
+ ))}
+
+
+
From c2ebfebb39ef6c9579c6a30abbf64221e00675a1 Mon Sep 17 00:00:00 2001
From: Nik Richers
Date: Mon, 7 Sep 2026 08:24:07 -0700
Subject: [PATCH 012/333] docs: restore Troubleshooting nav link under
Resources dropdown (#50028)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
This restores the "Troubleshooting" quick link to the docs top
navigation. It was dropped from the "Manage" dropdown as a side effect
of a broader information-architecture rework, and this PR re-adds it
under the "Resources" dropdown instead, since the underlying
troubleshooting guide page was never removed, only its top-nav shortcut.
## What is the current behavior?
The "Troubleshooting" entry was removed from the "Manage" dropdown in
the top nav during a prior IA restructuring commit. The
`/guides/troubleshooting` page itself still exists and is still linked
from within the Monitoring & Debugging sidebar, but there is no longer a
top-level nav shortcut to it.
Note that we might need to make some additional updates as our
observability docs changes wrap up.
## What is the new behavior?
- Adds a "Troubleshooting" item (with its existing icon) to the end of
the "Resources" dropdown, after Glossary, Changelog, Status, and
Contributing.
- Links to the existing `/guides/troubleshooting` route.
- No changes to the "Manage" dropdown or any other nav group.
## Additional context
**Verification:**
| Check | Result |
| --- | --- |
| `pnpm typecheck --filter=docs` | pass |
| `pnpm lint --filter=docs` | pre-existing 1138 warnings/errors
unrelated to this file; edited file has zero lint issues (lint is not
hard-gated for docs) |
| Manual check: dev server, opened "Resources" dropdown |
Troubleshooting appears with icon, links to `/guides/troubleshooting`
(200) |
| Manual check: "Manage" dropdown unchanged | confirmed |
| Manual check: mobile nav (shares same config) | confirmed same entry
renders |
### Proof:
Screenshots of the "Resources" dropdown, before and after this change:
| [Before
(production)](https://supabase.com/docs/guides/getting-started) | [After
(PR
preview)](https://docs-git-docs-restore-troubleshooting-nav-resources-supabase.vercel.app/docs/guides/getting-started)
|
| --- | --- |
|

|

|
### Test plan
- [ ] Open the "Resources" dropdown in the top nav and confirm
"Troubleshooting" appears after "Contributing"
- [ ] Click "Troubleshooting" and confirm it navigates to
`/guides/troubleshooting`
- [ ] Confirm the "Manage" dropdown is unchanged
- [ ] Check the mobile nav shows the same entry under "Resources"
Co-authored-by: Nik Richers
---
.../Navigation/NavigationMenu/NavigationMenu.constants.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
index c0982f9a99d..7be23d9e1a3 100644
--- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
+++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
@@ -337,6 +337,12 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
href: '/contributing' as `/${string}`,
enabled: contributionEnabled,
},
+ {
+ label: 'Troubleshooting',
+ icon: 'troubleshooting',
+ href: '/guides/troubleshooting' as `/${string}`,
+ level: 'troubleshooting',
+ },
],
],
},
From 0e35cbf4a46eafe62a95d74fb7224fcc2e0050a9 Mon Sep 17 00:00:00 2001
From: Marouane Souda <61951643+marsou001@users.noreply.github.com>
Date: Mon, 7 Sep 2026 22:56:57 +0100
Subject: [PATCH 013/333] Security definer materialized view (#40800)
Fixes #40799
Now, the correct error will be shown for materialized views accessible
to `anon` and `authenticated` roles via Data API, instead of the
unrelated "Security Definer view" error.
For convenience, users can immediately fix the issue by running the
correct SQL query to revoke select from `anon` and `authenticated`, just
by clicking on "confirm" on the confirmation modal I just created.
https://github.com/user-attachments/assets/f3ce9353-4ad0-4063-bf33-0b403f5fa87a
Before
Edit:
Thanks to the review by @saltcod, I now realise that revoking access
from `anon` and `authenticated` users might not be the optimal solution
since it would break many projects relying on the access to materialized
views.
After pondering on the possible solutions, I figured there isn't an easy
one, so I did away with the autofix button, and instead created a dialog
explaining three possible options for the user, with a sample query
under each one for convenience.
## Summary by CodeRabbit
* **New Features**
* Added clearer warnings when materialized views are accessible through
the API.
* Added a dialog explaining how to review and revoke API access for
materialized views.
* Added guidance and code examples for restricting access to API roles.
* Added a link to the Security Advisor for additional information.
* **Improvements**
* Updated materialized view tooltips to accurately describe API
accessibility.
---------
Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
---
.../ExposedMaterializedViewDialog.tsx | 83 +++++++++++++++++++
.../TableGridEditor/GridHeaderActions.tsx | 49 ++++++++++-
.../TableEditorLayout/EntityListItem.tsx | 2 +-
3 files changed, 132 insertions(+), 2 deletions(-)
create mode 100644 apps/studio/components/interfaces/TableGridEditor/ExposedMaterializedViewDialog.tsx
diff --git a/apps/studio/components/interfaces/TableGridEditor/ExposedMaterializedViewDialog.tsx b/apps/studio/components/interfaces/TableGridEditor/ExposedMaterializedViewDialog.tsx
new file mode 100644
index 00000000000..5971e1f37a5
--- /dev/null
+++ b/apps/studio/components/interfaces/TableGridEditor/ExposedMaterializedViewDialog.tsx
@@ -0,0 +1,83 @@
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogSection,
+ DialogSectionSeparator,
+ DialogTitle,
+ DialogTrigger,
+} from 'ui'
+import { SimpleCodeBlock } from 'ui-patterns/SimpleCodeBlock'
+
+import { Entity } from '@/data/table-editor/table-editor-types'
+
+interface ExposedMaterializedViewDialogProps {
+ table: Entity
+ isExposedMaterializedViewDialogOpen: boolean
+ setIsExposedMaterializedViewDialogOpen: (isExposedMaterializedViewDialogOpen: boolean) => void
+}
+
+export function ExposedMaterializedViewDialog({
+ table,
+ isExposedMaterializedViewDialogOpen,
+ setIsExposedMaterializedViewDialogOpen,
+}: ExposedMaterializedViewDialogProps) {
+ return (
+
+ )
+}
diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx
index 58b7588cab0..03662e9e1a1 100644
--- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx
@@ -1,4 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { useParams } from 'common'
import { Realtime } from 'icons'
import { BookOpenText, Lightbulb, Lock, MoreVertical, PlusCircle, Unlock } from 'lucide-react'
import Link from 'next/link'
@@ -23,6 +24,7 @@ import {
import { EnableIndexAdvisorDialog } from '../QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton'
import { RoleImpersonationPopover } from '../RoleImpersonationSelector/RoleImpersonationPopover'
+import { ExposedMaterializedViewDialog } from './ExposedMaterializedViewDialog'
import { InsertButton } from './InsertButton'
import { RealtimeToggleDialog } from './RealtimeToggleDialog'
import { SecurityDefinerViewPopover } from './SecurityDefinerViewPopover'
@@ -60,6 +62,7 @@ export interface GridHeaderActionsProps {
isRefetching: boolean
}
export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProps) => {
+ const { ref } = useParams()
const track = useTrack()
const appSnap = useAppStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
@@ -69,6 +72,8 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
const [realtimeDialogOpen, setRealtimeDialogOpen] = useState(false)
const [indexAdvisorDialogOpen, setIndexAdvisorDialogOpen] = useState(false)
const [isAutofixViewSecurityModalOpen, setIsAutofixViewSecurityModalOpen] = useState(false)
+ const [isExposedMaterializedViewDialogOpen, setIsExposedMaterializedViewDialogOpen] =
+ useState(false)
const [showWarning, setShowWarning] = useQueryState(
'showWarning',
@@ -293,7 +298,49 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
)}
{isMaterializedView && materializedViewHasLints && (
-
+
+
+ }>
+ Materialized View in API
+
+
+
+
+ Secure your Materialized View
+
+
+
+ Because materialized views do not automatically enforce RLS policies from their
+ source tables, exposing them through the API may reveal data that would
+ otherwise be protected.
+
+
+
+ Since this materialized view is in a schema accessible to API roles, it can be
+ queried through your project's APIs.
+
+
+
+
+
+
+
+
+
+
)}
{isForeignTable && table.schema === 'public' && (
diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
index e3e000216d6..96a5db87d82 100644
--- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
+++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
@@ -591,7 +591,7 @@ const EntityTooltipTrigger = ({
if (materializedViewHasLints) {
tooltipContent = (
<>
- {accessWarning} as this is a Security definer view {learnMoreCTA}.
+ {accessWarning} as materialized view is accessible via API. {learnMoreCTA}.
>
)
}
From 282a2a22cfc8d6d23e805186b1a5c1a4fcc9ce93 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Tue, 8 Sep 2026 11:32:46 +1000
Subject: [PATCH 014/333] fix(studio): allow pipeline number inputs to be
cleared (#50078)
## What kind of change does this PR introduce?
Bug fix for the pipeline destination form.
## What is the current behavior?
Clearing a numeric field under Advanced settings restores its previous
value immediately, so users cannot replace the value normally.
## What is the new behavior?
Numeric fields use an empty string as their controlled blank value while
editing, so users can clear an existing value before entering its
replacement. Existing field-specific validation messages are preserved
for invalid or empty required values. Maximum staleness remains optional
and can be left blank.
Adds component and schema coverage for all five numeric Advanced
settings fields, including the BigQuery-only fields.
## To test
1. Open Database > Replication and start a new pipeline.
2. Select BigQuery and expand Advanced settings.
3. Clear each numeric field and confirm it can be edited without
reverting to its previous value. Required fields may show their existing
validation message while empty.
4. Enter a replacement value such as `5` and confirm the field accepts
it.
5. Leave a required numeric field blank and confirm the existing
field-specific validation message appears.
## Summary by CodeRabbit
- **Bug Fixes**
- Improved validation for replication destination settings, with clearer
errors when required numeric fields are left empty.
- Validation now updates immediately when numeric values are cleared or
corrected.
- BigQuery connection pool size must be greater than zero.
- Empty maximum staleness values are accepted and handled correctly.
---------
Co-authored-by: Joshen Lim
---
.../DestinationForm/AdvancedSettings.tsx | 4 +-
.../BigQuery/BigQuery.schema.ts | 32 ++++---
.../DestinationForm.schema.test.ts | 42 +++++++++
.../DestinationForm/DestinationForm.schema.ts | 47 ++++++----
.../DestinationForm.schema.utils.ts | 7 ++
.../DestinationForm.utils.test.ts | 24 +++---
.../DestinationForm/AdvancedSettings.test.tsx | 86 +++++++++++++++++++
7 files changed, 203 insertions(+), 39 deletions(-)
create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.utils.ts
create mode 100644 apps/studio/tests/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.test.tsx
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx
index 23b147e9699..13cfcbae2ac 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx
@@ -40,9 +40,9 @@ export const AdvancedSettings = ({
form: UseFormReturn
}) => {
const handleNumberChange =
- (field: { onChange: (value?: number) => void }) => (e: ChangeEvent) => {
+ (field: { onChange: (value: number | '') => void }) => (e: ChangeEvent) => {
const parsed = e.target.valueAsNumber
- field.onChange(e.target.value === '' || Number.isNaN(parsed) ? undefined : parsed)
+ field.onChange(e.target.value === '' || Number.isNaN(parsed) ? '' : parsed)
}
return (
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.schema.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.schema.ts
index b6fa2279c49..1777eac3546 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.schema.ts
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.schema.ts
@@ -1,17 +1,29 @@
import * as z from 'zod'
+import {
+ optionalNumberInputSchema,
+ requiredNumberInputSchema,
+} from '../DestinationForm.schema.utils'
+
+const CONNECTION_POOL_SIZE_MIN_ERROR = 'Connection pool size must be greater than 0.'
+
export const BigQueryFormSchema = z.object({
projectId: z.string().optional(),
datasetId: z.string().optional(),
serviceAccountKey: z.string().optional(),
- connectionPoolSize: z
- .number()
- .int()
- .min(1, 'Connection pool size must be greater than 0.')
- .optional(),
- maxStalenessMins: z
- .number()
- .int('Maximum staleness must be a whole number of minutes.')
- .min(0, 'Maximum staleness must be 0 or greater.')
- .optional(),
+ connectionPoolSize: requiredNumberInputSchema(
+ z
+ .number({
+ required_error: CONNECTION_POOL_SIZE_MIN_ERROR,
+ invalid_type_error: CONNECTION_POOL_SIZE_MIN_ERROR,
+ })
+ .int()
+ .min(1, CONNECTION_POOL_SIZE_MIN_ERROR)
+ ),
+ maxStalenessMins: optionalNumberInputSchema(
+ z
+ .number()
+ .int('Maximum staleness must be a whole number of minutes.')
+ .min(0, 'Maximum staleness must be 0 or greater.')
+ ),
})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.test.ts
index 374f82c22c2..7d9733fb255 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.test.ts
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.test.ts
@@ -7,9 +7,51 @@ const requiredFields = {
publicationName: 'analytics_publication',
tableSyncCopyMode: 'include_all_tables' as const,
tableSyncCopyTableIds: [],
+ maxFillMs: 10_000,
+ maxTableSyncWorkers: 4,
+ maxCopyConnectionsPerTable: 4,
+ connectionPoolSize: 4,
}
+const requiredNumberFields = [
+ { field: 'maxFillMs', message: 'Batch wait time must be 0 or greater.' },
+ { field: 'maxTableSyncWorkers', message: 'Max table sync workers must be greater than 0.' },
+ {
+ field: 'maxCopyConnectionsPerTable',
+ message: 'Max copy connections per table must be greater than 0.',
+ },
+ { field: 'connectionPoolSize', message: 'Connection pool size must be greater than 0.' },
+] as const
+
describe('DestinationPanelFormSchema', () => {
+ it.each(requiredNumberFields)('uses the existing error for an empty $field field', (testCase) => {
+ const result = DestinationPanelFormSchema.safeParse({
+ ...requiredFields,
+ [testCase.field]: '',
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) expect(result.error.issues[0]?.message).toBe(testCase.message)
+ })
+
+ it.each(requiredNumberFields)('rejects a missing $field field', (testCase) => {
+ const { [testCase.field]: _omitted, ...rest } = requiredFields
+
+ const result = DestinationPanelFormSchema.safeParse(rest)
+
+ expect(result.success).toBe(false)
+ })
+
+ it('normalizes an empty maximum staleness to undefined', () => {
+ const result = DestinationPanelFormSchema.safeParse({
+ ...requiredFields,
+ maxStalenessMins: '',
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) expect(result.data.maxStalenessMins).toBeUndefined()
+ })
+
it.each([0, 1])('accepts a batch wait time of %i milliseconds', (value) => {
const result = DestinationPanelFormSchema.safeParse({
...requiredFields,
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts
index dd260642b0f..0ecadb896d0 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts
@@ -3,9 +3,14 @@ import * as z from 'zod'
import { AnalyticsBucketFormSchema } from './AnalyticsBucket/AnalyticsBucket.schema'
import { BigQueryFormSchema } from './BigQuery/BigQuery.schema'
import { ClickHouseFormSchema } from './ClickHouse/ClickHouse.schema'
+import { requiredNumberInputSchema } from './DestinationForm.schema.utils'
import { DuckLakeFormSchema } from './DuckLake/DuckLake.schema'
import { SnowflakeFormSchema } from './Snowflake/Snowflake.schema'
+const BATCH_WAIT_TIME_MIN_ERROR = 'Batch wait time must be 0 or greater.'
+const MAX_TABLE_SYNC_WORKERS_MIN_ERROR = 'Max table sync workers must be greater than 0.'
+const MAX_COPY_CONNECTIONS_MIN_ERROR = 'Max copy connections per table must be greater than 0.'
+
const CommonFormSchema = z.object({
name: z.string().min(1, 'Name is required.'),
publicationName: z.string().min(1, 'Publication is required.'),
@@ -16,21 +21,33 @@ const CommonFormSchema = z.object({
'skip_tables',
]),
tableSyncCopyTableIds: z.array(z.string()),
- maxFillMs: z
- .number()
- .int('Batch wait time must be a whole number of milliseconds.')
- .min(0, 'Batch wait time must be 0 or greater.')
- .optional(),
- maxTableSyncWorkers: z
- .number()
- .min(1, 'Max table sync workers must be greater than 0.')
- .int('Max table sync workers must be a whole number.')
- .optional(),
- maxCopyConnectionsPerTable: z
- .number()
- .int()
- .min(1, 'Max copy connections per table must be greater than 0.')
- .optional(),
+ maxFillMs: requiredNumberInputSchema(
+ z
+ .number({
+ required_error: BATCH_WAIT_TIME_MIN_ERROR,
+ invalid_type_error: BATCH_WAIT_TIME_MIN_ERROR,
+ })
+ .int('Batch wait time must be a whole number of milliseconds.')
+ .min(0, BATCH_WAIT_TIME_MIN_ERROR)
+ ),
+ maxTableSyncWorkers: requiredNumberInputSchema(
+ z
+ .number({
+ required_error: MAX_TABLE_SYNC_WORKERS_MIN_ERROR,
+ invalid_type_error: MAX_TABLE_SYNC_WORKERS_MIN_ERROR,
+ })
+ .min(1, MAX_TABLE_SYNC_WORKERS_MIN_ERROR)
+ .int('Max table sync workers must be a whole number.')
+ ),
+ maxCopyConnectionsPerTable: requiredNumberInputSchema(
+ z
+ .number({
+ required_error: MAX_COPY_CONNECTIONS_MIN_ERROR,
+ invalid_type_error: MAX_COPY_CONNECTIONS_MIN_ERROR,
+ })
+ .int()
+ .min(1, MAX_COPY_CONNECTIONS_MIN_ERROR)
+ ),
invalidatedSlotBehavior: z.enum(['error', 'recreate']).optional(),
})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.utils.ts
new file mode 100644
index 00000000000..68bc432320a
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.utils.ts
@@ -0,0 +1,7 @@
+import * as z from 'zod'
+
+export const requiredNumberInputSchema = (schema: z.ZodNumber) =>
+ z.preprocess((value) => (value === '' ? Number.NaN : value), schema)
+
+export const optionalNumberInputSchema = (schema: z.ZodNumber) =>
+ z.preprocess((value) => (value === '' ? undefined : value), schema.optional())
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts
index 1e69cba9d97..1d5cf152e3a 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts
@@ -22,14 +22,14 @@ const baseDucklakeFormData = {
publicationName: 'pub',
tableSyncCopyMode: 'include_all_tables' as const,
tableSyncCopyTableIds: [],
- maxFillMs: undefined,
- maxTableSyncWorkers: undefined,
- maxCopyConnectionsPerTable: undefined,
+ maxFillMs: 500,
+ maxTableSyncWorkers: 1,
+ maxCopyConnectionsPerTable: 1,
invalidatedSlotBehavior: undefined,
projectId: undefined,
datasetId: undefined,
serviceAccountKey: undefined,
- connectionPoolSize: undefined,
+ connectionPoolSize: 1,
maxStalenessMins: undefined,
warehouseName: undefined,
namespace: undefined,
@@ -55,14 +55,14 @@ const baseSnowflakeFormData = {
publicationName: 'pub',
tableSyncCopyMode: 'include_all_tables' as const,
tableSyncCopyTableIds: [],
- maxFillMs: undefined,
- maxTableSyncWorkers: undefined,
- maxCopyConnectionsPerTable: undefined,
+ maxFillMs: 500,
+ maxTableSyncWorkers: 1,
+ maxCopyConnectionsPerTable: 1,
invalidatedSlotBehavior: undefined,
projectId: undefined,
datasetId: undefined,
serviceAccountKey: undefined,
- connectionPoolSize: undefined,
+ connectionPoolSize: 1,
maxStalenessMins: undefined,
warehouseName: undefined,
namespace: undefined,
@@ -204,14 +204,14 @@ const baseClickHouseFormData = {
publicationName: 'pub',
tableSyncCopyMode: 'include_all_tables' as const,
tableSyncCopyTableIds: [],
- maxFillMs: undefined,
- maxTableSyncWorkers: undefined,
- maxCopyConnectionsPerTable: undefined,
+ maxFillMs: 500,
+ maxTableSyncWorkers: 1,
+ maxCopyConnectionsPerTable: 1,
invalidatedSlotBehavior: undefined,
projectId: undefined,
datasetId: undefined,
serviceAccountKey: undefined,
- connectionPoolSize: undefined,
+ connectionPoolSize: 1,
maxStalenessMins: undefined,
warehouseName: undefined,
namespace: undefined,
diff --git a/apps/studio/tests/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.test.tsx b/apps/studio/tests/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.test.tsx
new file mode 100644
index 00000000000..ea786aad869
--- /dev/null
+++ b/apps/studio/tests/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.test.tsx
@@ -0,0 +1,86 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useForm } from 'react-hook-form'
+import { Form } from 'ui'
+import { describe, expect, it } from 'vitest'
+
+import { AdvancedSettings } from '@/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings'
+import {
+ DestinationPanelFormSchema,
+ type DestinationPanelSchemaType,
+} from '@/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema'
+import { customRender } from '@/tests/lib/custom-render'
+
+const numericFields = [
+ { label: 'Batch wait time', initialValue: 10_000 },
+ { label: 'Table sync workers', initialValue: 4 },
+ { label: 'Initial sync connections per table', initialValue: 4 },
+ { label: 'Connection pool size', initialValue: 4 },
+ { label: 'Maximum staleness', initialValue: 10 },
+]
+
+const TestForm = () => {
+ const form = useForm({
+ resolver: zodResolver(DestinationPanelFormSchema),
+ mode: 'onChange',
+ reValidateMode: 'onChange',
+ defaultValues: {
+ name: 'Warehouse',
+ publicationName: 'publication',
+ tableSyncCopyMode: 'include_all_tables',
+ tableSyncCopyTableIds: [],
+ maxFillMs: 10_000,
+ maxTableSyncWorkers: 4,
+ maxCopyConnectionsPerTable: 4,
+ connectionPoolSize: 4,
+ maxStalenessMins: 10,
+ },
+ })
+ return (
+
+ )
+}
+
+describe('AdvancedSettings', () => {
+ it.each(numericFields)(
+ 'allows $label to be cleared and replaced',
+ async ({ label, initialValue }) => {
+ const user = userEvent.setup()
+ customRender( )
+
+ await user.click(screen.getByRole('button', { name: /Advanced settings/ }))
+
+ const input = screen.getByRole('spinbutton', { name: label })
+ expect(input).toHaveValue(initialValue)
+
+ await user.clear(input)
+ expect(input).toHaveValue(null)
+
+ await user.type(input, '5')
+ expect(input).toHaveValue(5)
+ }
+ )
+
+ it('uses the existing error while an empty required number is being edited', async () => {
+ const user = userEvent.setup()
+ customRender( )
+
+ await user.click(screen.getByRole('button', { name: /Advanced settings/ }))
+ const input = screen.getByRole('spinbutton', { name: 'Table sync workers' })
+
+ await user.clear(input)
+ expect(
+ await screen.findByText('Max table sync workers must be greater than 0.')
+ ).toBeInTheDocument()
+
+ await user.type(input, '5')
+ await waitFor(() => {
+ expect(
+ screen.queryByText('Max table sync workers must be greater than 0.')
+ ).not.toBeInTheDocument()
+ })
+ })
+})
From 8dd97d75aaba247d6dea10eb3aa65845624b39a6 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Tue, 8 Sep 2026 11:37:59 +1000
Subject: [PATCH 015/333] refactor(studio): use v2 replication publication APIs
(#49844)
## What kind of change does this PR introduce?
Studio data-layer migration.
## What is the current behavior?
Studio loads complete publication details through the original bulk
endpoint and creates publications by executing SQL against the source
database. Publication and source-table data use names where stable table
IDs are available.
## What is the new behavior?
Uses the v2 publication-name, publication-detail, publication mutation,
and source-table endpoints. The existing creation sheet continues to
behave the same, including publishing partition changes through the
parent table by default. Initial-sync selection and Analytics Bucket
associations now consume the selected publication detail. Generated
platform API types and their required nullability updates are included.
The generated Platform contract accounts for roughly 10,000 changed
lines in this PR.
## Dependency
Depends on the v2 source table, table column, and publication endpoints
from
[supabase/platform#37505](https://github.com/supabase/platform/pull/37505),
which are deployed to production.
## To test
1. Open the pipeline creation sheet and select an existing publication.
2. Create a publication with mixed-case schema and table names, then
confirm the table names are shown while stable IDs are submitted.
3. Exercise all four initial-sync policies, including selecting
individual tables.
4. Reopen the publication and table selectors and confirm they refresh
without replacing populated options.
5. Edit and delete a publication.
6. Open an Analytics Bucket associated with a pipeline and confirm its
publication tables resolve correctly.
7. Confirm unlimited WAL retention renders as Unlimited on pipeline
status.
## Summary by CodeRabbit
- **New Features**
- Improved replication publication setup with on-demand table loading,
refresh controls, clearer table labels, and streamlined publication
selection.
- Publication creation and updates now use the latest replication API
and table-based configurations.
- Added clearer handling for tables removed from publications, including
stale-selection warnings.
- **Bug Fixes**
- Prevented table selections from carrying over when switching
publications.
- Improved replication status displays when lag or WAL metrics are
unavailable.
- Updated replication deletion and table management for the latest API
behavior.
- **Tests**
- Expanded coverage for publication creation, table selection, stale
tables, loading states, and replication metrics.
---------
Co-authored-by: Joshen Lim
---
.../Replication/BatchRestartDialog.tsx | 2 +-
.../DestinationForm.utils.test.ts | 46 +-
.../DestinationForm/DestinationForm.utils.ts | 20 +-
.../NewPublicationPanel.test.tsx | 118 +
.../DestinationForm/NewPublicationPanel.tsx | 112 +-
.../PipelineCostDialog.test.tsx | 4 +-
.../DestinationForm/PublicationSelection.tsx | 22 +-
.../DestinationForm/PublicationsComboBox.tsx | 130 +-
.../TableCopySelection.test.tsx | 98 +-
.../DestinationForm/TableCopySelection.tsx | 129 +-
.../DestinationForm/index.test.tsx | 309 +-
.../DestinationForm/index.tsx | 64 +-
.../DestinationTypeSelection.test.tsx | 6 +-
.../DestinationTypeSelection.tsx | 2 +-
.../Replication/DestinationRow.test.tsx | 13 +-
.../Database/Replication/Destinations.tsx | 27 +-
.../ReplicationPipelineStatus.tsx | 4 +-
.../ReplicationPipelineStatus.types.ts | 14 +-
.../ReplicationPipelineStatus.utils.tsx | 4 +-
.../SlotLagMetrics.test.tsx | 27 +
.../ReplicationPipelineStatus/SlotStatus.tsx | 2 +-
.../RestartCostEstimate.render.test.tsx | 2 +-
.../Replication/RestartTableDialog.tsx | 2 +-
.../NamespaceWithTables.utils.ts | 4 +-
.../NamespaceWithTables/TableRowComponent.tsx | 31 +-
.../useAnalyticsBucketAssociatedEntities.tsx | 16 +-
apps/studio/data/replication/constants.ts | 1 +
.../replication/destination-by-id-query.ts | 2 +-
apps/studio/data/replication/keys.ts | 18 +
.../data/replication/pipeline-by-id-query.ts | 2 +-
.../pipeline-replication-status-query.ts | 2 +-
.../data/replication/pipeline-status-query.ts | 2 +-
.../replication/pipeline-version-query.ts | 3 +-
.../publication-create-mutation.ts | 28 +-
.../publication-delete-mutation.ts | 2 +-
.../replication/publication-names-query.ts | 55 +
.../data/replication/publication-query.ts | 66 +
.../publication-update-mutation.ts | 13 +-
.../data/replication/publications-query.ts | 57 -
apps/studio/data/replication/tables-query.ts | 25 +-
packages/api-types/types/platform.d.ts | 6330 ++++++++++++++++-
41 files changed, 7247 insertions(+), 567 deletions(-)
create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.test.tsx
create mode 100644 apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.test.tsx
create mode 100644 apps/studio/data/replication/constants.ts
create mode 100644 apps/studio/data/replication/publication-names-query.ts
create mode 100644 apps/studio/data/replication/publication-query.ts
delete mode 100644 apps/studio/data/replication/publications-query.ts
diff --git a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx
index 1c9948cb6e0..765b929e4c7 100644
--- a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx
@@ -26,7 +26,7 @@ interface BatchRestartDialogProps {
tables: ReplicationPipelineTableStatus[]
sourceId?: number
publicationName?: string
- tableSyncCopy?: TableSyncCopyConfig
+ tableSyncCopy?: TableSyncCopyConfig | null
pipelineStatusName?: PipelineStatusName
onRestartStart?: (tableIds: number[]) => void
onRestartComplete?: (tableIds: number[]) => void
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts
index 1d5cf152e3a..2b5b503f439 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts
@@ -15,7 +15,7 @@ import {
import { getDucklakeValidationIssues } from './DuckLake/DuckLake.utils'
import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils'
import type { ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query'
-import type { ReplicationPublication } from '@/data/replication/publications-query'
+import type { ReplicationPublicationData } from '@/data/replication/publication-query'
const baseDucklakeFormData = {
name: 'DuckLake Destination',
@@ -169,30 +169,58 @@ describe('DestinationForm.utils table copy selection', () => {
})
it('drops selected ids that are no longer in the publication', () => {
- const publications = [
- { name: 'analytics', tables: [{ id: 101, schema: 'public', name: 'orders' }] },
- ] as ReplicationPublication[]
+ const publication: ReplicationPublicationData = {
+ name: 'analytics',
+ config: {
+ type: 'all_tables',
+ operations: ['insert'],
+ publish_via_partition_root: false,
+ },
+ tables: [
+ {
+ id: 101,
+ schema: 'public',
+ name: 'orders',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ],
+ }
expect(
pruneStaleSelectedTableIds({
mode: 'include_tables',
selectedTableIds: ['101', '202'],
- publications,
+ publication,
publicationName: 'analytics',
})
).toEqual(['101'])
})
it('leaves selected ids untouched for non-selective modes', () => {
- const publications = [
- { name: 'analytics', tables: [{ id: 101, schema: 'public', name: 'orders' }] },
- ] as ReplicationPublication[]
+ const publication: ReplicationPublicationData = {
+ name: 'analytics',
+ config: {
+ type: 'all_tables',
+ operations: ['insert'],
+ publish_via_partition_root: false,
+ },
+ tables: [
+ {
+ id: 101,
+ schema: 'public',
+ name: 'orders',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ],
+ }
expect(
pruneStaleSelectedTableIds({
mode: 'include_all_tables',
selectedTableIds: ['202'],
- publications,
+ publication,
publicationName: 'analytics',
})
).toEqual(['202'])
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts
index 3ed2cb9a00b..4c7da8776cf 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts
@@ -26,7 +26,7 @@ import { type DucklakeApiConfig } from './DuckLake/DuckLake.utils'
import { type SnowflakeApiConfig } from './Snowflake/Snowflake.utils'
import { type ReplicationDestinationByIdData } from '@/data/replication/destination-by-id-query'
import { type ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query'
-import { type ReplicationPublication } from '@/data/replication/publications-query'
+import { type ReplicationPublicationData } from '@/data/replication/publication-query'
import type {
BatchConfig,
BigQueryDestinationConfig,
@@ -203,15 +203,15 @@ export const buildBatchConfig = ({
}
// The set of table ids (as strings, matching form state) currently in the
-// selected publication. Selective table-copy only ever offers/keeps ids that
-// are in this set; ids selected previously that fall out of it are dropped at
-// submit time rather than resolved or displayed.
+// selected publication. Selective table-copy only ever offers ids in this set;
+// previously selected ids that fall out of it are highlighted in the form and
+// dropped at submit time.
export const getPublicationTableIds = (
- publications: ReplicationPublication[],
+ publication: ReplicationPublicationData,
publicationName: string
): Set => {
- const publication = publications.find(({ name }) => name === publicationName)
- return new Set((publication?.tables ?? []).map(({ id }) => String(id)))
+ if (publication.name !== publicationName) return new Set()
+ return new Set(publication.tables.map(({ id }) => String(id)))
}
// Drops selected table ids that are no longer in the current publication.
@@ -220,17 +220,17 @@ export const getPublicationTableIds = (
export const pruneStaleSelectedTableIds = ({
mode,
selectedTableIds,
- publications,
+ publication,
publicationName,
}: {
mode: DestinationPanelSchemaType['tableSyncCopyMode']
selectedTableIds: string[]
- publications: ReplicationPublication[]
+ publication: ReplicationPublicationData
publicationName: string
}): string[] => {
if (mode === 'include_all_tables' || mode === 'skip_all_tables') return selectedTableIds
- const publicationTableIds = getPublicationTableIds(publications, publicationName)
+ const publicationTableIds = getPublicationTableIds(publication, publicationName)
return selectedTableIds.filter((id) => publicationTableIds.has(id))
}
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.test.tsx
new file mode 100644
index 00000000000..0268d7180cb
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.test.tsx
@@ -0,0 +1,118 @@
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import type { components } from 'api-types'
+import { HttpResponse } from 'msw'
+import { describe, expect, it, vi } from 'vitest'
+
+import { NewPublicationPanel } from './NewPublicationPanel'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+
+type ReplicationSourcesResponse = components['schemas']['SourcesResponse_Output']
+type ReadTablesResponse = components['schemas']['ReadTablesResponse_Output']
+type PublicationDetailsResponse = components['schemas']['PublicationDetailsResponse_Output']
+
+const mockSources: ReplicationSourcesResponse = {
+ sources: [
+ {
+ id: 1,
+ name: 'default',
+ tenant_id: 'tenant',
+ config: { host: 'db.internal', name: 'main-db', port: 5432, username: 'etl_user' },
+ },
+ ],
+}
+
+const mockTables: ReadTablesResponse = {
+ tables: [
+ {
+ id: 17487,
+ schema: 'CamelSchema',
+ name: 'MixedCaseTable',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ],
+}
+
+const mockPublication: PublicationDetailsResponse = {
+ name: 'MixedCasePublication',
+ config: {
+ type: 'tables',
+ tables: [
+ {
+ id: 17487,
+ schema: 'CamelSchema',
+ name: 'MixedCaseTable',
+ columns: null,
+ row_filter: null,
+ },
+ ],
+ operations: ['insert', 'update', 'delete', 'truncate'],
+ publish_via_partition_root: true,
+ },
+ tables: [
+ {
+ id: 17487,
+ schema: 'CamelSchema',
+ name: 'MixedCaseTable',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ],
+}
+
+const mockQueries = () => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/sources',
+ response: mockSources,
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/v2/:ref/sources/:source_id/tables',
+ response: mockTables,
+ })
+}
+
+describe('NewPublicationPanel', () => {
+ it('shows table names but submits table ids and hides empty-state guidance after selection', async () => {
+ mockQueries()
+ let requestBody: unknown
+ addAPIMock({
+ method: 'put',
+ path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name',
+ response: async ({ request }) => {
+ requestBody = await request.json()
+ return HttpResponse.json(mockPublication)
+ },
+ })
+
+ customRender( )
+
+ expect(
+ await screen.findByText('Select at least one table to include in the publication.')
+ ).toBeInTheDocument()
+
+ const trigger = screen.getAllByRole('combobox').find((element) => element.tagName === 'BUTTON')!
+ fireEvent.click(trigger)
+ fireEvent.click(await screen.findByText('CamelSchema.MixedCaseTable'))
+
+ expect(within(trigger).getByText('CamelSchema.MixedCaseTable')).toBeInTheDocument()
+ expect(trigger).not.toHaveTextContent('17487')
+ expect(
+ screen.queryByText('Select at least one table to include in the publication.')
+ ).not.toBeInTheDocument()
+
+ fireEvent.change(screen.getByPlaceholderText('Name'), {
+ target: { value: 'MixedCasePublication' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Create publication' }))
+
+ await waitFor(() =>
+ expect(requestBody).toMatchObject({
+ tables: [{ id: 17487 }],
+ publish_via_partition_root: true,
+ })
+ )
+ })
+})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx
index 57991eda2e6..5d2335cbe2f 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx
@@ -1,5 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useParams } from 'common'
+import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
@@ -20,11 +21,15 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelector } from 'ui-patterns/multi-select'
import { z } from 'zod'
+import {
+ isMetadataListErrorVisible,
+ isMetadataListLoading,
+ useRefreshOnOpen,
+} from './useRefreshOnOpen'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { useCreatePublicationMutation } from '@/data/replication/publication-create-mutation'
import { useReplicationSourceId } from '@/data/replication/sources-query'
import { useReplicationTablesQuery } from '@/data/replication/tables-query'
-import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
interface NewPublicationPanelProps {
@@ -32,23 +37,41 @@ interface NewPublicationPanelProps {
onClose: (newPublication?: string) => void
}
+const FORM_ID = 'publication-editor'
+
+const FormSchema = z.object({
+ name: z.string().min(1, 'Name is required'),
+ tableIds: z.array(z.string()).min(1, 'At least one table is required'),
+})
+type FormValues = z.infer
+
+const defaultValues: FormValues = {
+ name: '',
+ tableIds: [],
+}
+
export const NewPublicationPanel = ({ visible, onClose }: NewPublicationPanelProps) => {
const { ref: projectRef } = useParams()
- const { data: project } = useSelectedProjectQuery()
const sourceId = useReplicationSourceId({ projectRef })
+ const [shouldLoadTables, setShouldLoadTables] = useState(false)
- const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId }, { enabled: visible })
-
- const formId = 'publication-editor'
- const FormSchema = z.object({
- name: z.string().min(1, 'Name is required'),
- tables: z.array(z.string()).min(1, 'At least one table is required'),
+ const {
+ data: tables = [],
+ isPending,
+ isFetching,
+ isError,
+ refetch: refetchTables,
+ } = useReplicationTablesQuery({ projectRef, sourceId }, { enabled: visible && shouldLoadTables })
+ const isLoadingTables = isMetadataListLoading(isPending || isFetching, tables.length)
+ const { handleOpenChange: handleRefreshTablesOnOpen } = useRefreshOnOpen({
+ isEnabled: shouldLoadTables,
+ refetch: refetchTables,
})
- const defaultValues: z.infer = {
- name: '',
- tables: [],
- }
- const form = useForm>({
+ const tableLabelsById = new Map(
+ tables.map((table) => [String(table.id), `${table.schema}.${table.name}`] as const)
+ )
+
+ const form = useForm({
mode: 'onBlur',
reValidateMode: 'onBlur',
resolver: zodResolver(FormSchema),
@@ -60,6 +83,7 @@ export const NewPublicationPanel = ({ visible, onClose }: NewPublicationPanelPro
const { isDirty } = form.formState
const closePanel = (newPublication?: string) => {
+ setShouldLoadTables(false)
form.reset(defaultValues)
onClose(newPublication)
}
@@ -69,7 +93,7 @@ export const NewPublicationPanel = ({ visible, onClose }: NewPublicationPanelPro
onClose: () => closePanel(),
})
- const { mutate: createPublication, isPending: creatingPublication } =
+ const { mutate: createPublication, isPending: isCreatingPublication } =
useCreatePublicationMutation({
onSuccess: (_, vars) => {
toast.success('Successfully created publication')
@@ -77,22 +101,15 @@ export const NewPublicationPanel = ({ visible, onClose }: NewPublicationPanelPro
},
})
- const onSubmit = async (data: z.infer) => {
+ const onSubmit = async (data: FormValues) => {
if (!projectRef) return console.error('Project ref is required')
- if (!project) return console.error('Project is required')
if (!sourceId) return console.error('Source id is required')
- const tables = data.tables.map((table) => {
- const [schema, name] = table.split('.')
- return { schema, name }
- })
-
createPublication({
projectRef,
sourceId,
name: data.name,
- tables,
- connectionString: project.connectionString,
+ tableIds: data.tableIds.map(Number),
})
}
@@ -108,7 +125,7 @@ export const NewPublicationPanel = ({ visible, onClose }: NewPublicationPanelPro
-
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.test.tsx
index d7020cc3ec4..a050d8128ca 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.test.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.test.tsx
@@ -7,8 +7,8 @@ import { PipelineCostDialog } from './PipelineCostDialog'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
-type CostEstimateResponse = components['schemas']['CostEstimateResponse']
-type ReplicationSourcesResponse = components['schemas']['ReplicationSourcesResponse']
+type CostEstimateResponse = components['schemas']['CostEstimateResponse_Output']
+type ReplicationSourcesResponse = components['schemas']['SourcesResponse_Output']
const tables = [
{
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx
index e0ac6818d71..d8005623ba0 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx
@@ -1,5 +1,4 @@
import { useParams } from 'common'
-import { useMemo } from 'react'
import { useWatch, type UseFormReturn } from 'react-hook-form'
import { FormControl, FormField } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
@@ -7,7 +6,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import type { DestinationPanelSchemaType } from './DestinationForm.schema'
import { PublicationsComboBox } from './PublicationsComboBox'
-import { useReplicationPublicationsQuery } from '@/data/replication/publications-query'
+import { useReplicationPublicationNamesQuery } from '@/data/replication/publication-names-query'
import { useReplicationSourceId } from '@/data/replication/sources-query'
type PublicationSelectionProps = {
@@ -24,14 +23,13 @@ export const PublicationSelection = ({
const sourceId = useReplicationSourceId({ projectRef })
- const { data: publications, isSuccess: isSuccessPublications } = useReplicationPublicationsQuery({
- projectRef,
- sourceId,
- })
+ const { data: publications, isSuccess: isSuccessPublications } =
+ useReplicationPublicationNamesQuery({ projectRef, sourceId })
- const publicationNames = useMemo(() => publications?.map((pub) => pub.name) ?? [], [publications])
const isSelectedPublicationMissing =
- isSuccessPublications && !!publicationName && !publicationNames.includes(publicationName)
+ isSuccessPublications &&
+ !!publicationName &&
+ !(publications ?? []).some((publication) => publication.name === publicationName)
return (
{
if (value !== field.value) {
+ // Every per-table selection is scoped to the previously selected
+ // publication's table list, so none of it carries over cleanly to a
+ // different publication — reset it all rather than risk a stale or
+ // coincidentally-matching table id sticking around.
+ form.setValue('tableSyncCopyMode', 'include_all_tables', {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
form.setValue('tableSyncCopyTableIds', [], {
shouldDirty: true,
shouldValidate: true,
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationsComboBox.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationsComboBox.tsx
index 2e6e8f70144..85ce0544d12 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationsComboBox.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationsComboBox.tsx
@@ -1,9 +1,8 @@
import { useParams } from 'common'
-import { Check, ChevronsUpDown, Loader2, Plus } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { Check, ChevronsUpDown, Plus } from 'lucide-react'
+import { useState } from 'react'
import { ControllerRenderProps } from 'react-hook-form'
import {
- Badge,
Button,
cn,
Command,
@@ -18,10 +17,15 @@ import {
PopoverTrigger,
ScrollArea,
} from 'ui'
-import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+import { SelectionListState } from 'ui-patterns/SelectionListState'
import type { DestinationPanelSchemaType } from './DestinationForm.schema'
-import { useReplicationPublicationsQuery } from '@/data/replication/publications-query'
+import {
+ isMetadataListErrorVisible,
+ isMetadataListLoading,
+ useRefreshOnOpen,
+} from './useRefreshOnOpen'
+import { useReplicationPublicationNamesQuery } from '@/data/replication/publication-names-query'
interface PublicationsComboBoxProps {
sourceId?: number
@@ -37,41 +41,38 @@ export const PublicationsComboBox = ({
const { ref: projectRef } = useParams()
const [searchTerm, setSearchTerm] = useState('')
- const [dropdownOpen, setDropdownOpen] = useState(false)
- const [selectedPublication, setSelectedPublication] = useState(field?.value || '')
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false)
+ const selectedPublication = field.value || ''
const {
data: publications = [],
isPending,
isFetching,
+ isError,
refetch: refetchPublications,
- } = useReplicationPublicationsQuery({ projectRef, sourceId })
- const isLoadingPublications = isPending || isFetching
- const showLoadingState = isLoadingPublications && publications.length === 0
+ } = useReplicationPublicationNamesQuery({ projectRef, sourceId })
+ const isLoadingStateVisible = isMetadataListLoading(isPending || isFetching, publications.length)
+ const isErrorStateVisible = isMetadataListErrorVisible(isError, publications.length)
+ const { handleOpenChange: handleRefreshPublicationsOnOpen } = useRefreshOnOpen({
+ isEnabled: projectRef !== undefined && sourceId !== undefined,
+ refetch: refetchPublications,
+ })
function handlePublicationSelect(pub: string) {
- setSelectedPublication(pub)
- setDropdownOpen(false)
+ setIsDropdownOpen(false)
field.onChange(pub)
}
- useEffect(() => {
- setSelectedPublication(field?.value || '')
- }, [field?.value])
-
return (
{
- setDropdownOpen(open)
- if (open) {
- if (typeof projectRef !== 'undefined' && typeof sourceId !== 'undefined') {
- refetchPublications()
- }
- }
+ setIsDropdownOpen(open)
+ handleRefreshPublicationsOnOpen(open)
if (!open && field?.onBlur) {
+ setSearchTerm('')
field.onBlur()
}
}}
@@ -79,19 +80,25 @@ export const PublicationsComboBox = ({
span]:w-full text-left',
!selectedPublication && 'text-foreground-muted'
)}
- iconRight={showLoadingState ? : }
+ iconRight={ }
name={field.name}
onBlur={field.onBlur}
>
{selectedPublication || 'Select publication'}
-
+
- No publications found
-
- {showLoadingState && (
-
-
-
+ {!isLoadingStateVisible && !isErrorStateVisible && publications.length > 0 && (
+ No publications found
)}
-
- {publications.length === 0 && !showLoadingState && (
-
- No publications available
- Publications with no tables are hidden
-
- )}
- 7 ? 'h-[210px]' : ''}
- onWheel={(e) => e.stopPropagation()}
- >
- {publications.map((pub) => (
- {
- handlePublicationSelect(pub.name)
- }}
- onClick={() => {
- handlePublicationSelect(pub.name)
- }}
- >
- {pub.name}
-
-
- {pub.tables.length} {pub.tables.length === 1 ? 'table' : 'tables'}
-
+
+
+ {publications.length > 0 && (
+
+ 7 ? 'h-[210px]' : ''}
+ onWheel={(e) => e.stopPropagation()}
+ >
+ {publications.map((pub) => (
+ handlePublicationSelect(pub.name)}
+ onClick={() => handlePublicationSelect(pub.name)}
+ >
+ {pub.name}
{selectedPublication === pub.name && (
)}
-
-
- ))}
-
-
+
+ ))}
+
+
+ )}
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.test.tsx
index 4fdb27d3371..d02cb02066d 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.test.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.test.tsx
@@ -1,4 +1,4 @@
-import { screen } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
import type { components } from 'api-types'
import { HttpResponse } from 'msw'
import { useForm } from 'react-hook-form'
@@ -10,8 +10,9 @@ import { TableCopySelection } from './TableCopySelection'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
-type ReplicationSourcesResponse = components['schemas']['ReplicationSourcesResponse']
-type ReplicationPublicationsResponse = components['schemas']['ReplicationPublicationsResponse']
+type ReplicationSourcesResponse = components['schemas']['SourcesResponse_Output']
+type PublicationDetailsResponse = components['schemas']['PublicationDetailsResponse_Output']
+type ReadTablesResponse = components['schemas']['ReadTablesResponse_Output']
const mockSources: ReplicationSourcesResponse = {
sources: [
@@ -24,15 +25,20 @@ const mockSources: ReplicationSourcesResponse = {
],
}
-const mockPublications: ReplicationPublicationsResponse = {
- publications: [
- {
- name: 'analytics',
- tables: [
- { id: 101, schema: 'public', name: 'orders' },
- { id: 202, schema: 'billing', name: 'invoices' },
- ],
- },
+const mockPublicationDetails: PublicationDetailsResponse = {
+ name: 'analytics',
+ config: {
+ type: 'tables',
+ tables: [
+ { id: 101, schema: 'public', name: 'orders', columns: null, row_filter: null },
+ { id: 202, schema: 'billing', name: 'invoices', columns: null, row_filter: null },
+ ],
+ operations: ['insert'],
+ publish_via_partition_root: false,
+ },
+ tables: [
+ { id: 101, schema: 'public', name: 'orders', kind: 'table', partition_parent_id: null },
+ { id: 202, schema: 'billing', name: 'invoices', kind: 'table', partition_parent_id: null },
],
}
@@ -47,15 +53,15 @@ const mockSourcesEndpoint = () => {
const mockPublicationsSuccess = () => {
addAPIMock({
method: 'get',
- path: '/platform/replication/:ref/sources/:source_id/publications',
- response: () => HttpResponse.json(mockPublications),
+ path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name',
+ response: () => HttpResponse.json(mockPublicationDetails),
})
}
const mockPublicationsError = () => {
addAPIMock({
method: 'get',
- path: '/platform/replication/:ref/sources/:source_id/publications',
+ path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name',
response: () => HttpResponse.json({ message: 'Boom' }, { status: 500 }),
})
}
@@ -63,11 +69,19 @@ const mockPublicationsError = () => {
const mockPublicationsPending = () => {
addAPIMock({
method: 'get',
- path: '/platform/replication/:ref/sources/:source_id/publications',
+ path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name',
response: () => new Promise(() => {}),
})
}
+const mockTables = (tables: ReadTablesResponse['tables']) => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/v2/:ref/sources/:source_id/tables',
+ response: () => HttpResponse.json({ tables }),
+ })
+}
+
const TableCopySelectionHarness = ({
editMode,
mode = 'include_all_tables',
@@ -98,11 +112,14 @@ describe('TableCopySelection', () => {
mockSourcesEndpoint()
mockPublicationsSuccess()
- customRender( )
+ customRender(
+
+ )
expect(
- await screen.findByText(/will not sync existing rows again unless you restart them/)
+ await screen.findByText(/Changes only affect tables whose initial sync/)
).toBeInTheDocument()
+ expect(screen.getByText('Tables to include*')).toBeInTheDocument()
})
it('does not show the edit-mode explanation while creating a pipeline', async () => {
@@ -113,7 +130,7 @@ describe('TableCopySelection', () => {
await screen.findByText('All tables')
expect(
- screen.queryByText(/will not sync existing rows again unless you restart them/)
+ screen.queryByText(/Changes only affect tables whose initial sync/)
).not.toBeInTheDocument()
})
@@ -134,6 +151,38 @@ describe('TableCopySelection', () => {
'1 of 2 publication tables will run initial sync. Ongoing replication will still include every publication table.'
)
).toBeInTheDocument()
+ const trigger = screen
+ .getAllByRole('combobox')
+ .find((element) => element.textContent?.includes('public.orders'))!
+ expect(trigger).toHaveTextContent('public.orders')
+ expect(trigger).not.toHaveTextContent('101')
+ })
+
+ it('highlights configured initial-sync tables that left the publication without showing ids', async () => {
+ mockSourcesEndpoint()
+ mockPublicationsSuccess()
+ mockTables([
+ ...mockPublicationDetails.tables,
+ {
+ id: 999,
+ schema: 'Legacy',
+ name: 'ArchivedOrders',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ])
+
+ customRender(
+
+ )
+
+ expect(await screen.findByText('Legacy.ArchivedOrders')).toBeInTheDocument()
+ expect(screen.getByText('Legacy.ArchivedOrders')).toHaveClass('text-destructive-600')
+ expect(screen.getByText('Some tables are no longer in the publication.')).toHaveClass(
+ 'text-destructive-600'
+ )
+ expect(screen.queryByText('No longer in publication')).not.toBeInTheDocument()
+ expect(document.body).not.toHaveTextContent('999')
})
it('blocks selection and explains when publication tables cannot be loaded', async () => {
@@ -148,7 +197,7 @@ describe('TableCopySelection', () => {
expect(screen.queryByText(/previously selected table/)).not.toBeInTheDocument()
})
- it('disables selective table choices while publication tables are loading', async () => {
+ it('shows a skeleton in the open selector while publication tables are loading', async () => {
mockSourcesEndpoint()
mockPublicationsPending()
@@ -156,9 +205,12 @@ describe('TableCopySelection', () => {
)
- const loadingLabel = await screen.findByText('Loading publication tables...')
- expect(loadingLabel).toBeInTheDocument()
- expect(loadingLabel.closest('button')).toBeDisabled()
+ const trigger = screen.getByRole('combobox', { name: 'Select initial sync tables' })
+ expect(trigger).toBeEnabled()
+ expect(trigger).not.toHaveTextContent(/loading/i)
+
+ fireEvent.click(trigger)
+ await waitFor(() => expect(document.querySelector('.shimmering-loader')).toBeInTheDocument())
expect(screen.queryByText(/previously selected table/)).not.toBeInTheDocument()
})
})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.tsx
index 3aff0d660c3..1bd47525563 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.tsx
@@ -1,5 +1,4 @@
import { useParams } from 'common'
-import { useMemo } from 'react'
import { useWatch, type UseFormReturn } from 'react-hook-form'
import { FormControl, FormField, Select, SelectContent, SelectItem, SelectTrigger } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
@@ -7,9 +6,15 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelector } from 'ui-patterns/multi-select'
import type { DestinationPanelSchemaType } from './DestinationForm.schema'
-import { getPublicationTableIds } from './DestinationForm.utils'
-import { useReplicationPublicationsQuery } from '@/data/replication/publications-query'
+import {
+ isMetadataListErrorVisible,
+ isMetadataListLoading,
+ isMetadataValueLoading,
+ useRefreshOnOpen,
+} from './useRefreshOnOpen'
+import { useReplicationPublicationQuery } from '@/data/replication/publication-query'
import { useReplicationSourceId } from '@/data/replication/sources-query'
+import { useReplicationTablesQuery } from '@/data/replication/tables-query'
interface TableCopySelectionProps {
form: UseFormReturn
@@ -38,25 +43,25 @@ export const TableCopySelection = ({ form, editMode }: TableCopySelectionProps)
})
const {
- data: publications = [],
- isPending: isLoadingPublications,
+ data: selectedPublication,
+ isPending,
+ isFetching,
isError: isErrorPublications,
- } = useReplicationPublicationsQuery({ projectRef, sourceId })
+ isSuccess: isSuccessPublication,
+ refetch: refetchPublication,
+ } = useReplicationPublicationQuery({ projectRef, sourceId, publicationName })
+ const isLoadingPublicationTables =
+ !!publicationName && isMetadataValueLoading(isPending || isFetching, selectedPublication)
+ const { handleOpenChange: handleRefreshPublicationOnOpen } = useRefreshOnOpen({
+ isEnabled: !!publicationName,
+ refetch: refetchPublication,
+ })
- // Only publication tables are ever selectable or displayed by name. A table
- // id is never resolved outside of the publication response — there's no
- // other source for a name to come from without a separate, source-wide
- // catalog fetch, which selective table-copy doesn't need.
- const publicationTables = useMemo(() => {
- const publication = publications.find(({ name }) => name === publicationName)
- return [...(publication?.tables ?? [])].sort((a, b) =>
- tableLabel(a).localeCompare(tableLabel(b))
- )
- }, [publicationName, publications])
-
- const publicationTableIds = useMemo(
- () => getPublicationTableIds(publications, publicationName),
- [publications, publicationName]
+ const publicationTables = [...(selectedPublication?.tables ?? [])].sort((a, b) =>
+ tableLabel(a).localeCompare(tableLabel(b))
+ )
+ const publicationTableIds = new Set(
+ (selectedPublication?.tables ?? []).map(({ id }) => String(id))
)
const tableLabelsById = new Map(
publicationTables.map((table) => [String(table.id), tableLabel(table)])
@@ -65,9 +70,16 @@ export const TableCopySelection = ({ form, editMode }: TableCopySelectionProps)
publicationTableIds.has(id)
).length
const staleSelectedCount =
- isLoadingPublications || isErrorPublications
+ !isSuccessPublication || isLoadingPublicationTables || isErrorPublications
? 0
: tableSyncCopyTableIds.filter((id) => !publicationTableIds.has(id)).length
+ const { data: sourceTables = [] } = useReplicationTablesQuery(
+ { projectRef, sourceId },
+ { enabled: staleSelectedCount > 0 }
+ )
+ const sourceTableLabelsById = new Map(
+ sourceTables.map((table) => [String(table.id), tableLabel(table)])
+ )
const tableCount = publicationTables.length
return (
@@ -84,7 +96,7 @@ export const TableCopySelection = ({ form, editMode }: TableCopySelectionProps)
{staleSelectedCount > 0 && (
-
-
- {staleSelectedCount === 1 ? 'It' : 'They'} will be excluded from this pipeline's
- initial sync settings when you save.
-
-
+
+ Some tables are no longer in the publication.
+
)}
)}
/>
)}
+
+ {editMode && isSelectiveMode(tableSyncCopyMode) && (
+
+ * Changes only affect tables whose initial sync has not completed. Use Reset table to sync
+ existing rows again.
+
+ )}
)
}
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx
index d6313a5772d..df407d369c8 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx
@@ -1,13 +1,23 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import type { components } from 'api-types'
+import { HttpResponse } from 'msw'
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DestinationPanelSchemaType } from './DestinationForm.schema'
import { DestinationForm } from './index'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+
+type DestinationResponse = components['schemas']['DestinationResponse_Output']
+type PipelineResponse = components['schemas']['PipelineResponse_Output']
+type PublicationDetailsResponse = components['schemas']['PublicationDetailsResponse_Output']
+type PublicationNamesResponse = components['schemas']['ReadPublicationsResponse_Output']
+type ProjectSettingsResponse = components['schemas']['ProjectSettingsResponse']
+type SourcesResponse = components['schemas']['SourcesResponse_Output']
const mocks = vi.hoisted(() => ({
- refetchPublications: vi.fn(),
resetValidation: vi.fn(),
submitPipeline: vi.fn(),
validateConfiguration: vi.fn(),
@@ -26,14 +36,55 @@ const existingDestination = {
statusName: 'started',
}
-const sourcesData = { sources: [{ id: 42, name: 'project-ref' }] }
-const publications = [
- {
- name: 'analytics',
- tables: [{ id: 101, schema: 'public', name: 'orders' }],
+const sourcesData: SourcesResponse = {
+ sources: [
+ {
+ id: 42,
+ name: 'project-ref',
+ tenant_id: 'tenant',
+ config: { host: 'db.internal', name: 'postgres', port: 5432, username: 'postgres' },
+ },
+ ],
+}
+const publication: PublicationDetailsResponse = {
+ name: 'analytics',
+ config: {
+ type: 'tables',
+ tables: [{ id: 101, schema: 'public', name: 'orders', columns: null, row_filter: null }],
+ operations: ['insert', 'update', 'delete', 'truncate'],
+ publish_via_partition_root: false,
},
-]
-const destinationData = {
+ tables: [
+ {
+ id: 101,
+ schema: 'public',
+ name: 'orders',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ],
+}
+const secondaryPublication: PublicationDetailsResponse = {
+ name: 'reporting',
+ config: {
+ type: 'tables',
+ tables: [{ id: 202, schema: 'public', name: 'invoices', columns: null, row_filter: null }],
+ operations: ['insert'],
+ publish_via_partition_root: false,
+ },
+ tables: [
+ {
+ id: 202,
+ schema: 'public',
+ name: 'invoices',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ],
+}
+const destinationData: DestinationResponse = {
+ id: 7,
+ tenant_id: 'tenant',
name: 'Analytics',
config: {
big_query: {
@@ -43,48 +94,82 @@ const destinationData = {
},
},
}
-const pipelineData = {
+let pipelineTableIds = [101, 999]
+const getPipelineData = (): PipelineResponse => ({
+ id: 8,
+ tenant_id: 'tenant',
+ source_id: 42,
+ source_name: 'postgres',
+ destination_id: 7,
+ destination_name: 'Analytics',
+ replicator_id: 9,
config: {
publication_name: 'analytics',
- table_sync_copy: { type: 'include_tables' as const, table_ids: [101, 999] },
+ table_sync_copy: { type: 'include_tables', table_ids: pipelineTableIds },
batch: existingBatch,
max_table_sync_workers: 4,
max_copy_connections_per_table: 1,
},
+})
+const projectSettings: ProjectSettingsResponse = {
+ app_config: {
+ db_schema: 'public',
+ endpoint: 'project-ref.supabase.co',
+ storage_endpoint: 'project-ref.supabase.co/storage/v1',
+ },
+ cloud_provider: 'AWS',
+ db_dns_name: 'db.project-ref.supabase.co',
+ db_host: 'db.project-ref.supabase.co',
+ db_ip_addr_config: 'ipv4',
+ db_name: 'postgres',
+ db_port: 5432,
+ db_user: 'postgres',
+ inserted_at: '2026-08-28T00:00:00Z',
+ name: 'project-ref',
+ ref: 'project-ref',
+ region: 'ap-southeast-1',
+ ssl_enforced: true,
+ status: 'ACTIVE_HEALTHY',
}
-const projectSettings = { region: 'ap-southeast-1' }
-vi.mock('common', () => ({ useParams: () => ({ ref: 'project-ref' }) }))
+vi.mock('common', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, useParams: () => ({ ref: 'project-ref' }) }
+})
vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: PropsWithChildren) => children,
motion: { div: ({ children }: PropsWithChildren) => {children} },
}))
-vi.mock('ui', () => ({
- Button: ({
- children,
- loading: _loading,
- ...props
- }: ButtonHTMLAttributes & { loading?: boolean }) => (
-
- {children}
-
- ),
- cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
- DialogSectionSeparator: () => null,
- Form: ({ children }: PropsWithChildren) => children,
- Select: ({ children }: PropsWithChildren) => {children},
- SelectContent: ({ children }: PropsWithChildren) => {children},
- SelectItem: ({ children }: PropsWithChildren) => {children},
- SelectTrigger: ({ children }: PropsWithChildren) => {children},
- SelectValue: () => null,
- SheetFooter: ({ children }: PropsWithChildren) => {children},
- SheetSection: ({ children }: PropsWithChildren) => {children},
- Tooltip: ({ children }: PropsWithChildren) => {children},
- TooltipContent: ({ children }: PropsWithChildren) => {children},
- TooltipTrigger: ({ children }: PropsWithChildren) => {children},
-}))
+vi.mock('ui', async (importOriginal) => {
+ const actual = await importOriginal()
+
+ return {
+ ...actual,
+ Button: ({
+ children,
+ loading: _loading,
+ ...props
+ }: ButtonHTMLAttributes & { loading?: boolean }) => (
+
+ {children}
+
+ ),
+ DialogSectionSeparator: () => null,
+ Form: ({ children }: PropsWithChildren) => children,
+ Select: ({ children }: PropsWithChildren) => {children},
+ SelectContent: ({ children }: PropsWithChildren) => {children},
+ SelectItem: ({ children }: PropsWithChildren) => {children},
+ SelectTrigger: ({ children }: PropsWithChildren) => {children},
+ SelectValue: () => null,
+ SheetFooter: ({ children }: PropsWithChildren) => {children},
+ SheetSection: ({ children }: PropsWithChildren) => {children},
+ Tooltip: ({ children }: PropsWithChildren) => {children},
+ TooltipContent: ({ children }: PropsWithChildren) => {children},
+ TooltipTrigger: ({ children }: PropsWithChildren) => {children},
+ }
+})
vi.mock('ui-patterns/Admonition', () => ({
Admonition: ({ children }: PropsWithChildren) => {children},
@@ -119,45 +204,31 @@ vi.mock('@/hooks/misc/useCheckPermissions', () => ({
useAsyncCheckPermissions: () => ({ can: false }),
}))
-vi.mock('@/data/api-keys/api-keys-query', () => ({ useAPIKeys: () => ({ data: undefined }) }))
-
-vi.mock('@/data/config/project-settings-v2-query', () => ({
- useProjectSettingsV2Query: () => ({ data: projectSettings }),
-}))
-
-vi.mock('@/data/replication/sources-query', () => ({
- useReplicationSourcesQuery: () => ({ data: sourcesData }),
- useReplicationSourceId: () => sourcesData.sources[0]?.id,
-}))
-
-vi.mock('@/data/replication/publications-query', () => ({
- useReplicationPublicationsQuery: () => ({
- data: publications,
- isPending: false,
- isError: false,
- isSuccess: true,
- refetch: mocks.refetchPublications,
- }),
-}))
-
-vi.mock('@/data/replication/destination-by-id-query', () => ({
- useReplicationDestinationByIdQuery: () => ({
- data: destinationData,
- isError: false,
- isSuccess: true,
- }),
-}))
-
-vi.mock('@/data/replication/pipeline-by-id-query', () => ({
- useReplicationPipelineByIdQuery: () => ({
- data: pipelineData,
- isError: false,
- isSuccess: true,
- }),
-}))
-
vi.mock('./DestinationNameInput', () => ({ DestinationNameInput: () => null }))
-vi.mock('./PublicationSelection', () => ({ PublicationSelection: () => null }))
+vi.mock('./PublicationSelection', () => ({
+ PublicationSelection: ({ form }: { form: UseFormReturn }) => (
+ {
+ form.setValue('publicationName', 'reporting', {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
+ form.setValue('tableSyncCopyMode', 'include_tables', {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
+ form.setValue('tableSyncCopyTableIds', ['202'], {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
+ }}
+ >
+ Switch publication and select table
+
+ ),
+}))
vi.mock('./TableCopySelection', () => ({
TableCopySelection: ({ form }: { form: UseFormReturn }) => (
{form.formState.errors.tableSyncCopyTableIds?.message}
@@ -181,15 +252,52 @@ vi.mock('@/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketS
describe('DestinationForm edit submission', () => {
beforeEach(() => {
vi.clearAllMocks()
- pipelineData.config.table_sync_copy.table_ids = [101, 999]
+ pipelineTableIds = [101, 999]
mocks.submitPipeline.mockResolvedValue(undefined)
mocks.validateConfiguration.mockResolvedValue({ canContinue: true, warnings: [] })
+
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/sources',
+ response: () => HttpResponse.json(sourcesData),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/v2/:ref/sources/:source_id/publications',
+ response: () =>
+ HttpResponse.json({
+ publications: [{ name: publication.name }, { name: secondaryPublication.name }],
+ }),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name',
+ response: ({ params }) =>
+ HttpResponse.json(
+ params.publication_name === secondaryPublication.name ? secondaryPublication : publication
+ ),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/destinations/:destination_id',
+ response: () => HttpResponse.json(destinationData),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/pipelines/:pipeline_id',
+ response: () => HttpResponse.json(getPipelineData()),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/projects/:ref/settings',
+ response: () => HttpResponse.json(projectSettings),
+ })
})
it('bypasses create validation and submits the pruned table policy with the existing batch', async () => {
const onClose = vi.fn()
- render(
+ customRender(
{
/>
)
- fireEvent.click(screen.getByRole('button', { name: 'Apply and restart pipeline' }))
+ const submitButton = screen.getByRole('button', { name: 'Apply and restart pipeline' })
+ await waitFor(() => expect(submitButton).toBeEnabled())
+ fireEvent.click(submitButton)
await waitFor(() => expect(mocks.submitPipeline).toHaveBeenCalledOnce())
@@ -216,9 +326,9 @@ describe('DestinationForm edit submission', () => {
})
it('rejects an edit when every selected table has left the publication', async () => {
- pipelineData.config.table_sync_copy.table_ids = [999]
+ pipelineTableIds = [999]
- render(
+ customRender(
{
/>
)
- fireEvent.click(screen.getByRole('button', { name: 'Apply and restart pipeline' }))
+ const submitButton = screen.getByRole('button', { name: 'Apply and restart pipeline' })
+ await waitFor(() => expect(submitButton).toBeEnabled())
+ fireEvent.click(submitButton)
expect(await screen.findByText('Select at least one table')).toBeInTheDocument()
expect(mocks.validateConfiguration).not.toHaveBeenCalled()
expect(mocks.submitPipeline).not.toHaveBeenCalled()
})
+
+ it('submits against the newly loaded publication immediately after switching', async () => {
+ const onClose = vi.fn()
+
+ customRender(
+
+ )
+
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Apply and restart pipeline' })).toBeEnabled()
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Switch publication and select table' }))
+
+ const submitButton = screen.getByRole('button', { name: 'Apply and restart pipeline' })
+ await waitFor(() => expect(submitButton).toBeEnabled())
+ fireEvent.click(submitButton)
+
+ await waitFor(() => expect(mocks.submitPipeline).toHaveBeenCalledOnce())
+ expect(mocks.submitPipeline).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ publicationName: 'reporting',
+ tableSyncCopyMode: 'include_tables',
+ tableSyncCopyTableIds: ['202'],
+ }),
+ existingDestination,
+ existingBatch,
+ onSuccess: expect.any(Function),
+ onClose,
+ })
+ })
})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx
index b4f8f25a13f..7f6202b1423 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx
@@ -54,7 +54,8 @@ import { useAPIKeys } from '@/data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
import { useReplicationDestinationByIdQuery } from '@/data/replication/destination-by-id-query'
import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by-id-query'
-import { useReplicationPublicationsQuery } from '@/data/replication/publications-query'
+import { useReplicationPublicationNamesQuery } from '@/data/replication/publication-names-query'
+import { useReplicationPublicationQuery } from '@/data/replication/publication-query'
import { useReplicationSourceId } from '@/data/replication/sources-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
@@ -121,11 +122,8 @@ export const DestinationForm = ({
const sourceId = useReplicationSourceId({ projectRef })
- const {
- data: publications = [],
- isSuccess: isSuccessPublications,
- refetch: refetchPublications,
- } = useReplicationPublicationsQuery({ projectRef, sourceId })
+ const { data: publicationNames = [], isSuccess: isSuccessPublicationNames } =
+ useReplicationPublicationNamesQuery({ projectRef, sourceId })
const {
data: destinationData,
@@ -182,9 +180,8 @@ export const DestinationForm = ({
}),
[destinationData, pipelineData, catalogToken, projectSettings, projectRef, editMode]
)
-
const form = useForm>({
- mode: 'onChange',
+ mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(
FormSchema.superRefine((data, ctx) => {
@@ -196,18 +193,10 @@ export const DestinationForm = ({
})
}
- const selectedPublicationTableIds = pruneStaleSelectedTableIds({
- mode: data.tableSyncCopyMode,
- selectedTableIds: data.tableSyncCopyTableIds,
- publications,
- publicationName: data.publicationName,
- })
-
if (
- isSuccessPublications &&
(data.tableSyncCopyMode === 'include_tables' ||
data.tableSyncCopyMode === 'skip_tables') &&
- selectedPublicationTableIds.length === 0
+ data.tableSyncCopyTableIds.length === 0
) {
addRequiredFieldError('tableSyncCopyTableIds', 'Select at least one table')
}
@@ -253,10 +242,13 @@ export const DestinationForm = ({
const { isDirty } = form.formState
const publicationName = useWatch({ control: form.control, name: 'publicationName' })
+ const { data: selectedPublication, isSuccess: isSuccessPublication } =
+ useReplicationPublicationQuery({ projectRef, sourceId, publicationName })
- const publicationNames = useMemo(() => publications?.map((pub) => pub.name) ?? [], [publications])
const isSelectedPublicationMissing =
- isSuccessPublications && !!publicationName && !publicationNames.includes(publicationName)
+ isSuccessPublicationNames &&
+ !!publicationName &&
+ !publicationNames.some(({ name }) => name === publicationName)
const allValidationFailures = [...destinationValidationFailures, ...pipelineValidationFailures]
const hasValidationFailures = allValidationFailures.some((f) => f.failure_type === 'critical')
@@ -272,16 +264,16 @@ export const DestinationForm = ({
}),
[pendingFormValues]
)
- const pendingPublicationTables = useMemo(
- () =>
- publications.find(({ name }) => name === pendingFormValues?.publicationName)?.tables ?? [],
- [pendingFormValues?.publicationName, publications]
- )
+ const pendingPublicationTables =
+ selectedPublication && selectedPublication.name === pendingFormValues?.publicationName
+ ? selectedPublication.tables
+ : []
const isSubmitDisabled =
isSaving ||
!isExistingConfigReady ||
- !isSuccessPublications ||
+ !isSuccessPublicationNames ||
+ (!!publicationName && !isSuccessPublication) ||
isSelectedPublicationMissing ||
(!editMode && hasNoAvailableDestinations)
@@ -307,7 +299,7 @@ export const DestinationForm = ({
}
const onSubmit = async (rawData: z.infer) => {
- if (!isSuccessPublications) {
+ if (!isSuccessPublication || !selectedPublication) {
toast.error('Publication tables are unavailable. Refresh and try again.')
return
}
@@ -319,11 +311,19 @@ export const DestinationForm = ({
tableSyncCopyTableIds: pruneStaleSelectedTableIds({
mode: rawData.tableSyncCopyMode,
selectedTableIds: rawData.tableSyncCopyTableIds,
- publications,
+ publication: selectedPublication,
publicationName: rawData.publicationName,
}),
}
+ if (
+ (data.tableSyncCopyMode === 'include_tables' || data.tableSyncCopyMode === 'skip_tables') &&
+ data.tableSyncCopyTableIds.length === 0
+ ) {
+ form.setError('tableSyncCopyTableIds', { message: 'Select at least one table' })
+ return
+ }
+
if (selectedType === 'BigQuery') {
const jsonIssue = getBigQueryValidationIssues(data, { secretsOptional: editMode }).find(
(issue) => issue.message === BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE
@@ -427,12 +427,6 @@ export const DestinationForm = ({
}
}, [visible, defaultValues, form, isDirty, resetValidation])
- useEffect(() => {
- if (visible && projectRef && sourceId) {
- refetchPublications()
- }
- }, [visible, projectRef, sourceId, refetchPublications])
-
return (
<>
@@ -544,6 +538,10 @@ export const DestinationForm = ({
visible={publicationPanelVisible}
onClose={(newPublication?: string) => {
if (newPublication) {
+ form.setValue('tableSyncCopyMode', 'include_all_tables', {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
form.setValue('tableSyncCopyTableIds', [], {
shouldDirty: true,
shouldValidate: true,
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx
index a84bf5b1ae4..178ddfbab7d 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx
@@ -8,9 +8,9 @@ import { DestinationTypeSelection } from './DestinationTypeSelection'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
-type ReplicationSourcesResponse = components['schemas']['ReplicationSourcesResponse']
-type ReplicationPipelinesResponse = components['schemas']['ReplicationPipelinesResponse']
-type ReplicationDestinationResponse = components['schemas']['ReplicationDestinationResponse']
+type ReplicationSourcesResponse = components['schemas']['SourcesResponse_Output']
+type ReplicationPipelinesResponse = components['schemas']['PipelinesResponse_Output']
+type ReplicationDestinationResponse = components['schemas']['DestinationResponse_Output']
mockAnimationsApi()
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx
index 9c7c902b7b7..8ce8a4b5167 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx
@@ -157,7 +157,7 @@ export const DestinationTypeSelection = () => {
>
diff --git a/apps/studio/data/replication/publication-create-mutation.ts b/apps/studio/data/replication/publication-create-mutation.ts
index 8095d2382c8..bba8eab6d24 100644
--- a/apps/studio/data/replication/publication-create-mutation.ts
+++ b/apps/studio/data/replication/publication-create-mutation.ts
@@ -10,10 +10,11 @@ export type CreatePublicationParams = {
sourceId: number
name: string
tableIds: number[]
+ publishViaPartitionRoot?: boolean
}
async function createPublication(
- { projectRef, sourceId, name, tableIds }: CreatePublicationParams,
+ { projectRef, sourceId, name, tableIds, publishViaPartitionRoot = true }: CreatePublicationParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
@@ -26,7 +27,7 @@ async function createPublication(
type: 'tables',
tables: tableIds.map((id) => ({ id })),
operations: ['insert', 'update', 'delete', 'truncate'],
- publish_via_partition_root: true,
+ publish_via_partition_root: publishViaPartitionRoot,
},
signal,
}
From ac0d4563d657e1399e948178bec6c49dbe41caae Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Tue, 8 Sep 2026 13:28:32 +0800
Subject: [PATCH 018/333] Add GH token for fetchAgentSkills to mitigate rate
limits (#50106)
## Context
As per PR title - just a nice to have as our `www` preview builds
occasionally fail, likely due to rate limits on the GH API
Env var `AGENT_SKILLS_GITHUB_TOKEN` has been added to the `www` app on
Vercel
## Summary by CodeRabbit
* **Chores**
* Added optional GitHub authentication support for retrieving agent
skills.
* Preserved existing request behavior when no authentication token is
provided.
---
apps/www/scripts/fetchAgentSkills.mjs | 9 +++++++--
apps/www/turbo.jsonc | 1 +
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/apps/www/scripts/fetchAgentSkills.mjs b/apps/www/scripts/fetchAgentSkills.mjs
index 36a70cf87f0..ff09f5c350c 100644
--- a/apps/www/scripts/fetchAgentSkills.mjs
+++ b/apps/www/scripts/fetchAgentSkills.mjs
@@ -8,7 +8,8 @@
* URLs — no rewriting needed on this side.
*
* Spec: https://github.com/agentskills/agentskills/pull/254
- * Runs unauthenticated — public repo, build-time only.
+ * Uses AGENT_SKILLS_GITHUB_TOKEN if set to avoid GitHub's unauthenticated
+ * rate limit (60 req/hr per IP, shared across Vercel build machines).
*/
import { promises as fs } from 'node:fs'
@@ -20,7 +21,11 @@ const OUT_DIR = join(__dirname, '..', 'public', '.well-known', 'agent-skills')
const REPO = 'supabase/agent-skills'
async function fetchJson(url) {
- const res = await fetch(url, { headers: { 'User-Agent': 'supabase-www-build' } })
+ const headers = { 'User-Agent': 'supabase-www-build' }
+ if (process.env.AGENT_SKILLS_GITHUB_TOKEN) {
+ headers['Authorization'] = `Bearer ${process.env.AGENT_SKILLS_GITHUB_TOKEN}`
+ }
+ const res = await fetch(url, { headers })
if (!res.ok) throw new Error(`GET ${url} → ${res.status}`)
return res.json()
}
diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc
index 09078f17514..a42001db674 100644
--- a/apps/www/turbo.jsonc
+++ b/apps/www/turbo.jsonc
@@ -76,6 +76,7 @@
"FORCE_ASSET_CDN",
"ASSET_CDN_S3_ENDPOINT",
"SITE_NAME",
+ "AGENT_SKILLS_GITHUB_TOKEN",
"LUMA_API_KEY",
"LUMA_HACKATHONS_API_KEY",
"NEXT_RUNTIME",
From fb28b70fe729973d5043bd8695a7b8088489dc71 Mon Sep 17 00:00:00 2001
From: Wendie Cheung <49030266+wendeh@users.noreply.github.com>
Date: Tue, 8 Sep 2026 15:49:10 +1000
Subject: [PATCH 019/333] Update PostgresConf SJC 2026 contest page content
(#50112)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
Content update to a `/go` landing page.
## What is the current behavior?
The PostgresConf San Jose 2026 contest page
(`supabase.com/go/postgresconf-sjc-2026/contest`) shows a "Learn about
Multigres" CTA linking to multigres.com, a "Conference Talk: Thursday,
April 23, 2026 2:30pm PDT" line, and a contest entry deadline of Monday,
May 4, 2026.
## What is the new behavior?
- Removed the outdated "Conference Talk" date/time line
- Changed the CTA to "Watch the webinar," linking to the recording
(https://www.youtube.com/watch?v=ahhQ0n1SHiQ)
- Updated the contest entry deadline to Monday, September 14, 2026 at
12:00 PM PST
## Additional context
Content-only change to
`apps/www/_go/events/postgresconf-sjc-2026/contest.tsx`.
## Summary by CodeRabbit
- **Content Updates**
- Updated the event page to link to the webinar recording with a “Watch
the webinar” label.
- Changed the contest deadline to September 14, 2026, at 12:00 PM PST.
- Removed the talk description from the event page.
Co-authored-by: Wendie Cheung
Co-authored-by: Claude Sonnet 5
---
apps/www/_go/events/postgresconf-sjc-2026/contest.tsx | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx
index 89a8176ed74..671ba223c64 100644
--- a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx
+++ b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx
@@ -39,7 +39,6 @@ const page: GoPageInput = {
{
type: 'single-column',
title: 'Multigres: One stop PostgreSQL Management and Scaling',
- description: 'Conference Talk: Thursday, April 23, 2026 2:30pm PDT',
children: (
@@ -86,8 +85,12 @@ const page: GoPageInput = {
-
- Learn about Multigres
+
+ Watch the webinar
@@ -113,7 +116,7 @@ const page: GoPageInput = {
Create a Supabase account and note the email address you used
Load data into a Supabase database
Fill out the entry form below
- Complete these steps by Monday, May 4, 2026 at 12:00 PM PST
+ Complete these steps by Monday, September 14, 2026 at 12:00 PM PST
Create your account
From 66a748cdd154b0e1c547834c0931d7f9a3e4e27d Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:24:49 +1000
Subject: [PATCH 020/333] fix(ui): restore accordion trigger layout (#50114)
## What kind of change does this PR introduce?
Bug fix. Follow-up to #49660.
## What is the current behavior?
`AccordionTrigger` adds horizontal padding at the shared primitive
level. This shifts content at call sites that already own their spacing.
In the Studio Advanced settings sections, padding around the whole
accordion also constrains the trigger and its hit area instead of
letting it fill the row.
## What is the new behavior?
The shared trigger no longer adds horizontal padding and its header
explicitly fills its container. Both Studio Advanced settings sections
now apply their padding inside the full-width trigger and content,
keeping the text aligned while extending the focus outline and hit area
across the row.
The inset focus treatment from #49660 is unchanged.
| Before | After |
| --- | --- |
|
|
|
## To test
### Studio replication destination
1. Open the [Studio staging
preview](https://studio-staging-git-dnywh-fixaccordion-trigger-width-supabase.vercel.app)
and select a project with Pipelines enabled.
2. Go to **Database > Replication**. Under **Destinations**, click **Add
destination**.
3. Tab to **Advanced settings** and press Enter to expand it.
4. Confirm the text aligns with the form content, the focus outline
spans the full row between the sheet edges, and clicking near the far
right of the row toggles it.
### Studio access-token permissions
1. In the [Studio staging
preview](https://studio-staging-git-dnywh-fixaccordion-trigger-width-supabase.vercel.app),
go to **Account > Access Tokens**.
2. Click **Generate new token** and scroll to **Permissions**.
3. Tab to any permission category, then click near the far right of its
row.
4. Confirm the focus outline fills the category row, the label keeps its
existing inset, and the full row toggles the category.
### Pricing FAQs
1. Open the [Pricing
preview](https://zone-www-dot-com-git-dnywh-fixaccordion-trigger-width-supabase.vercel.app/pricing)
and scroll to **Frequently asked questions**.
2. Tab to any question, then click near the far right of its row.
3. Confirm the question has not gained extra horizontal inset and the
full row remains interactive.
### Design-system Accordion
1. Open the [design-system Accordion
preview](https://design-system-git-dnywh-fixaccordion-trigger-width-supabase.vercel.app/docs/components/accordion).
2. Tab through the three accordion triggers and toggle each one.
3. Confirm each focus outline fills its row, with no extra horizontal
padding around the label.
---
.../DestinationPanel/DestinationForm/AdvancedSettings.tsx | 6 +++---
.../InstallIntegrationSheet/AdvancedSettings.tsx | 6 +++---
packages/ui/src/components/shadcn/ui/accordion.test.tsx | 6 ++++--
packages/ui/src/components/shadcn/ui/accordion.tsx | 4 ++--
4 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx
index 13cfcbae2ac..c3c4ae5b85e 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx
@@ -46,10 +46,10 @@ export const AdvancedSettings = ({
}
return (
-
+
-
+
Advanced settings
@@ -57,7 +57,7 @@ export const AdvancedSettings = ({
-
+
+
-
+
Advanced settings
-
+
Select which schemas to install the database extensions under
diff --git a/packages/ui/src/components/shadcn/ui/accordion.test.tsx b/packages/ui/src/components/shadcn/ui/accordion.test.tsx
index 734c3e813ec..aa9d12bdb9b 100644
--- a/packages/ui/src/components/shadcn/ui/accordion.test.tsx
+++ b/packages/ui/src/components/shadcn/ui/accordion.test.tsx
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { Accordion, AccordionItem, AccordionTrigger } from './accordion'
describe('AccordionTrigger', () => {
- it('provides rounded geometry and the shared inset focus ring', () => {
+ it('fills its container without adding horizontal padding', () => {
render(
@@ -14,7 +14,9 @@ describe('AccordionTrigger', () => {
)
const trigger = screen.getByRole('button', { name: 'Advanced settings' })
- expect(trigger).toHaveClass('px-2', 'relative', 'focus-inset')
+ expect(trigger.parentElement).toHaveClass('flex', 'w-full')
+ expect(trigger).toHaveClass('flex-1', 'relative', 'focus-inset')
+ expect(trigger).not.toHaveClass('px-2')
expect(trigger).not.toHaveClass('rounded-md')
expect(trigger).not.toHaveClass('focus-ring')
expect(trigger).not.toHaveClass('transition-colors')
diff --git a/packages/ui/src/components/shadcn/ui/accordion.tsx b/packages/ui/src/components/shadcn/ui/accordion.tsx
index cbfe1d8bf66..374993d1462 100644
--- a/packages/ui/src/components/shadcn/ui/accordion.tsx
+++ b/packages/ui/src/components/shadcn/ui/accordion.tsx
@@ -32,11 +32,11 @@ const AccordionTrigger = React.forwardRef<
return (
-
+
svg]:rotate-180',
focusVariant === 'ring' && 'rounded-md',
From bfb0737d1447e37d65e7cae3f13e33841a01a2bb Mon Sep 17 00:00:00 2001
From: Gildas Garcia <1122076+djhi@users.noreply.github.com>
Date: Tue, 8 Sep 2026 09:47:32 +0200
Subject: [PATCH 021/333] Fix to ensure labels, descriptions and validation
errors are correctly linked to their inputs (#50080)
## Problem
`FormItemLayout` does not correctly binds inputs descriptions and
validation messages to their inputs. This is because the input ids are
generated and not correctly propagated to the `FormMessage` and
`FormDescription` components. Besides, we still pass `name` or `id`
directly to the inputs or `FormItemLayout` in some places.
## Solution
- Fix `FormItemLayout` to correctly binds inputs descriptions and
validation messages to their inputs
- Fix incorrect usages
- Fix Design System documentation
## How to test
The issue is visible in production:
- Open https://supabase.com/design-system/docs/ui-patterns/forms
- Open the devtool and check the labels `for`, the description `id` and
the input `id` or `aria-describedby` attributes. You'll see they often
don't match
Do the same on staging:
- Open
https://design-system-git-fix-a11y-form-input-descriptions-supabase.vercel.app/design-system/docs/ui-patterns/forms
- Open the devtool and check the labels `for`, the description `id` and
the input `id` or `aria-describedby` attributes. They now match
Dashboard fixes:
-
https://studio-staging-git-fix-a11y-form-input-descriptions-supabase.vercel.app/dashboard/account/tokens:
_Expires in_ select button is now correctly linked to its label
-
https://studio-staging-git-fix-a11y-form-input-descriptions-supabase.vercel.app/dashboard/account/me:
the switches are now correctly linked to their label
- In Database/Indexes: the select buttons when creating an index are now
correctly linked to their label
- All other changes are the same things
---
.../default/example/date-picker-form.tsx | 13 +-
.../example/form-patterns-pagelayout.tsx | 42 +--
.../example/form-patterns-sidepanel.tsx | 42 +--
.../AccessTokens/Scoped/Form/TokenDetails.tsx | 32 +--
.../Account/Preferences/DashboardToggle.tsx | 8 +-
.../Auth/AuthProvidersForm/FormField.tsx | 109 ++++----
.../Database/Indexes/CreateIndexSidePanel.tsx | 243 +++++++++---------
.../Integrations/Wrappers/ColumnType.tsx | 50 ++--
.../ProjectCreation/OrganizationSelector.tsx | 53 ++--
.../ProjectCreation/ProjectNameInput.tsx | 4 +-
.../src/form/Layout/FormLayout.tsx | 1 -
11 files changed, 291 insertions(+), 306 deletions(-)
diff --git a/apps/design-system/registry/default/example/date-picker-form.tsx b/apps/design-system/registry/default/example/date-picker-form.tsx
index 52aa14fc345..129fc91412f 100644
--- a/apps/design-system/registry/default/example/date-picker-form.tsx
+++ b/apps/design-system/registry/default/example/date-picker-form.tsx
@@ -8,6 +8,7 @@ import {
Button,
Calendar,
Form,
+ FormControl,
FormDescription,
FormField,
FormItem,
@@ -53,11 +54,13 @@ export default function DatePickerForm() {
Date of birth
-
-
- {field.value ? format(field.value, 'PPP') : 'Pick a date'}
-
-
+
+
+
+ {field.value ? format(field.value, 'PPP') : 'Pick a date'}
+
+
+
-
+
+
+
public
@@ -675,23 +677,23 @@ export default function FormPatternsPageLayout() {
label="Date Picker"
description="Date selection with calendar popover"
>
-
-
+
+
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
-
-
-
-
-
+
+
+
+
+
)}
/>
diff --git a/apps/design-system/registry/default/example/form-patterns-sidepanel.tsx b/apps/design-system/registry/default/example/form-patterns-sidepanel.tsx
index c3e734a6d7e..090dce8da98 100644
--- a/apps/design-system/registry/default/example/form-patterns-sidepanel.tsx
+++ b/apps/design-system/registry/default/example/form-patterns-sidepanel.tsx
@@ -610,14 +610,16 @@ export default function FormPatternsSidePanel() {
size="small"
className="w-full"
>
-
+
+
+
public
@@ -677,23 +679,23 @@ export default function FormPatternsSidePanel() {
label="Date Picker"
description="Date selection with calendar popover"
>
-
-
+
+
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
-
-
-
-
-
+
+
+
+
+
)}
/>
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx
index 7ef6accc758..48d2897c301 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx
@@ -72,25 +72,25 @@ export const TokenDetails = ({ control, setValue }: TokenDetailsProps) => {
name="expiresAt"
control={control}
render={({ field }) => (
-
+
-
-
+
+
+ {EXPIRY_OPTIONS.map((option) => (
+
+
+ {option.label}
+ {option.recommended && Recommended }
+
+
+ ))}
+
+
{field.value === 'custom' && (
({
control={form.control}
name={name}
render={({ field }) => (
-
+
{
field.onChange(value)
diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx
index f6520fc841d..8f480f2d05d 100644
--- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx
+++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx
@@ -118,8 +118,8 @@ const FormField = ({
) : null
}
>
-
-
+
+
-
- {
- field.onChange(date?.toISOString())
- }}
- initialFocus
- />
-
-
-
+
+
+ {
+ field.onChange(date?.toISOString())
+ }}
+ initialFocus
+ />
+
+
)}
/>
@@ -170,16 +170,9 @@ const FormField = ({
>
{properties.isSecret ? (
-
+
) : (
-
+
)}
@@ -211,7 +204,6 @@ const FormField = ({
+
+ {properties.enum.map((option: Enum) => (
+
+
+ {option.icon ? (
+
+ ) : null}
+ {option.label}
+
+
+ ))}
+
+
)}
/>
diff --git a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx
index 934d7e178a7..954f42ac690 100644
--- a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx
+++ b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx
@@ -201,16 +201,15 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
control={form.control}
name="schema"
render={({ field }) => (
-
-
-
+
+
+
span]:w-full text-left`}
@@ -225,56 +224,56 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
{field.value || 'Choose a schema'}
-
-
-
- 7 && 'max-h-[210px]! overflow-y-auto'
- )}
- onWheel={(event) => event.stopPropagation()}
- >
- No schemas found
-
- {(schemas ?? []).map((schema) => (
- {
- field.onChange(schema.name)
- form.setValue('table', '')
- form.setValue('columns', [])
- form.setValue('type', INDEX_TYPES[0].value)
- setSearchTerm('')
- }}
- >
-
- {schema.name}
-
- ))}
-
-
-
-
-
-
+
+
+
+
+ 7 && 'max-h-[210px]! overflow-y-auto'
+ )}
+ onWheel={(event) => event.stopPropagation()}
+ >
+ No schemas found
+
+ {(schemas ?? []).map((schema) => (
+ {
+ field.onChange(schema.name)
+ form.setValue('table', '')
+ form.setValue('columns', [])
+ form.setValue('type', INDEX_TYPES[0].value)
+ setSearchTerm('')
+ }}
+ >
+
+ {schema.name}
+
+ ))}
+
+
+
+
+
)}
/>
@@ -290,25 +289,23 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
-
-
+
+
-
- {/* [Terry] shouldFilter context:
+
+
+ {/* [Terry] shouldFilter context:
https://github.com/pacocoursey/cmdk/issues/267#issuecomment-2252717107 */}
-
-
- 7 && 'max-h-[210px]! overflow-y-auto'
+
+
+ 7 && 'max-h-[210px]! overflow-y-auto'
+ )}
+ onWheel={(event) => event.stopPropagation()}
+ >
+
+ {isLoadingEntities ? (
+
+
+ Loading...
+
+ ) : (
+ 'No tables found'
)}
- onWheel={(event) => event.stopPropagation()}
- >
-
- {isLoadingEntities ? (
-
-
- Loading...
-
- ) : (
- 'No tables found'
- )}
-
-
- {entityTypes.map((entity) => (
- {
- field.onChange(entity.name)
- setTableDropdownOpen(false)
- form.setValue('columns', [])
- form.setValue('type', INDEX_TYPES[0].value)
- }}
- >
-
- {entity.name}
-
- ))}
-
-
-
-
-
-
+
+
+ {entityTypes.map((entity) => (
+ {
+ field.onChange(entity.name)
+ setTableDropdownOpen(false)
+ form.setValue('columns', [])
+ form.setValue('type', INDEX_TYPES[0].value)
+ }}
+ >
+
+ {entity.name}
+
+ ))}
+
+
+
+
+
)}
/>
diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/ColumnType.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/ColumnType.tsx
index e317a199c98..6038d82f155 100644
--- a/apps/studio/components/interfaces/Integrations/Wrappers/ColumnType.tsx
+++ b/apps/studio/components/interfaces/Integrations/Wrappers/ColumnType.tsx
@@ -124,7 +124,7 @@ export const ColumnType = ({
className={className}
>
-
+
)
@@ -135,29 +135,31 @@ export const ColumnType = ({
Type
-
- }
- title={field.value && field.value.replaceAll('"', '')}
- >
- {field.value ? (
-
- {inferIcon(getOptionByName(field.value)?.type ?? '')}
- {field.value.replaceAll('"', '')}
-
- ) : (
- 'Choose a column type...'
- )}
-
-
+
+
+ }
+ title={field.value && field.value.replaceAll('"', '')}
+ >
+ {field.value ? (
+
+ {inferIcon(getOptionByName(field.value)?.type ?? '')}
+ {field.value.replaceAll('"', '')}
+
+ ) : (
+ 'Choose a column type...'
+ )}
+
+
+
(
-
+
{(organizations?.length ?? 0) > 0 && (
-
- {
- field.onChange(slug)
- router.push(`/new/${slug}`)
- }}
- value={field.value}
- defaultValue={field.value}
- disabled={disableOrganizationSelection}
- >
-
+ {
+ field.onChange(slug)
+ router.push(`/new/${slug}`)
+ }}
+ value={field.value}
+ defaultValue={field.value}
+ disabled={disableOrganizationSelection}
+ >
+
+
-
-
- {organizations?.map((x) => (
-
-
- {x.name}
- {x.plan.name}
-
-
- ))}
-
-
-
-
+
+
+
+ {organizations?.map((x) => (
+
+
+ {x.name}
+ {x.plan.name}
+
+
+ ))}
+
+
+
)}
)}
diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectNameInput.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectNameInput.tsx
index c415cd64d6f..a8f53ec33af 100644
--- a/apps/studio/components/interfaces/ProjectCreation/ProjectNameInput.tsx
+++ b/apps/studio/components/interfaces/ProjectCreation/ProjectNameInput.tsx
@@ -16,9 +16,9 @@ export const ProjectNameInput = ({ form }: ProjectNameInputProps) => {
control={form.control}
name="projectName"
render={({ field }) => (
-
+
-
+
)}
diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx
index 284e6a9faaf..a9973bed9a8 100644
--- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx
+++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx
@@ -333,7 +333,6 @@ export const FormLayout = React.forwardRef<
{description}
From 2ac052752a1e3b9fef6315ffa14b6a46327bb284 Mon Sep 17 00:00:00 2001
From: Charis <26616127+charislam@users.noreply.github.com>
Date: Tue, 8 Sep 2026 04:08:24 -0400
Subject: [PATCH 022/333] fix(pg-meta): fix invalid SQL when setting column
default to NULL (#50097)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Fixes invalid SQL generation when setting a column's default value to
NULL through Studio's Table Editor
- The bug occurred because raw `null` values bypassed literal
serialization in the SQL builder
- Added special case handling in both `create()` and `update()`
functions to use `literal(null)` regardless of format
## Details
Setting a column's default to NULL via the "Set as NULL" suggestion in
Studio generated invalid SQL: `ALTER TABLE ... ALTER COLUMN ... SET
DEFAULT ;` (missing the NULL keyword). This was caused by
`default_value` being JS `null` and bypassing the literal()
serialization function when `default_value_format === 'expression'`.
The fix ensures that when `default_value === null`, we always use
`literal(null)` to produce the correct `SET DEFAULT NULL` clause.
## Test plan
- New regression test in `packages/pg-meta/test/columns.test.ts`
("update column default value to NULL via expression format") verifies
the fix by:
- Creating a text column with a literal default
- Updating it using `default_value_format: 'expression', default_value:
null` (exact payload from Studio)
- Asserting the column's default value is null
- All 58 tests in `packages/pg-meta/test/columns.test.ts` pass
- TypeScript check (`tsc --noEmit`) passes
Fixes FE-4343
## Summary by CodeRabbit
- **Bug Fixes**
- Fixed column creation and updates so explicitly setting a default
value to `NULL` correctly preserves the `DEFAULT NULL` clause.
- Ensured setting a column default to `NULL` through expression-based
editing is reflected accurately when column details are retrieved.
- **Style**
- Improved the column editor’s suggestion menu layout and spacing for
clearer readability.
- **Tests**
- Added coverage for updating a column default to `NULL`.
---------
Co-authored-by: Joshen Lim
---
.../ColumnEditor/InputWithSuggestions.tsx | 7 ++--
packages/pg-meta/src/pg-meta-columns.ts | 12 +++++-
packages/pg-meta/test/columns.test.ts | 37 +++++++++++++++++++
3 files changed, 52 insertions(+), 4 deletions(-)
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx
index 61617e3e0f5..b7bf2586727 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx
@@ -106,6 +106,7 @@ const InputWithSuggestions = ({
{filteredSuggestions.map((suggestion: Suggestion) => (
onSelectSuggestion(suggestion)}
>
- {suggestion.name}
- {suggestion.description}
+ {suggestion.name}
+ {suggestion.description}
))}
diff --git a/packages/pg-meta/src/pg-meta-columns.ts b/packages/pg-meta/src/pg-meta-columns.ts
index 17a0fb35d27..8f7cbaf695e 100644
--- a/packages/pg-meta/src/pg-meta-columns.ts
+++ b/packages/pg-meta/src/pg-meta-columns.ts
@@ -168,6 +168,10 @@ function create({
} else {
if (defaultOptions.default_value === undefined) {
// skip
+ } else if (defaultOptions.default_value === null) {
+ // NULL is the same literal regardless of format, and safeSql would
+ // otherwise silently drop a raw `null` expression fragment.
+ defaultValueClause = safeSql`DEFAULT ${literal(null)}`
} else if (defaultOptions.default_value_format === 'expression') {
defaultValueClause = safeSql`DEFAULT ${defaultOptions.default_value}`
} else {
@@ -262,8 +266,14 @@ function update(
} else if (default_value === undefined) {
defaultValueSql = safeSql``
} else {
+ // NULL is the same literal regardless of format, and safeSql would
+ // otherwise silently drop a raw `null` expression fragment.
const defaultValue: SafeSqlFragment =
- default_value_format === 'expression' ? default_value : literal(default_value)
+ default_value === null
+ ? literal(null)
+ : default_value_format === 'expression'
+ ? default_value
+ : literal(default_value)
defaultValueSql = safeSql`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(old.name)} SET DEFAULT ${defaultValue};`
}
// What identitySql does vary depending on the old and new values of
diff --git a/packages/pg-meta/test/columns.test.ts b/packages/pg-meta/test/columns.test.ts
index 0b3ed983d1e..1ee0721722d 100644
--- a/packages/pg-meta/test/columns.test.ts
+++ b/packages/pg-meta/test/columns.test.ts
@@ -294,6 +294,43 @@ withTestDatabase('retrieve, create, update, delete column', async ({ executeQuer
expect(removedColumn).toBeUndefined()
})
+withTestDatabase(
+ 'update column default value to NULL via expression format',
+ async ({ executeQuery }) => {
+ // Create test table using pure SQL
+ await executeQuery('CREATE TABLE t ()')
+
+ // Create column with a non-null default
+ const { sql: createColumnSql } = pgMeta.columns.create({
+ schema: 'public',
+ table: 't',
+ name: 'c',
+ type: { name: 'text' },
+ default_value_format: 'literal',
+ default_value: 'a',
+ })
+ await executeQuery(createColumnSql)
+
+ const { sql: retrieveSql, zod: retrieveZod } = pgMeta.columns.retrieve({
+ schema: 'public',
+ table: 't',
+ name: 'c',
+ })
+ const column = retrieveZod.parse((await executeQuery(retrieveSql))[0])
+
+ // Mirrors the Studio Table Editor "Set as NULL" default value suggestion,
+ // which sets defaultValueFormat to 'expression' with a null value.
+ const { sql: updateSql } = pgMeta.columns.update(column!, {
+ default_value_format: 'expression',
+ default_value: null,
+ })
+ await executeQuery(updateSql)
+
+ const updated = retrieveZod.parse((await executeQuery(retrieveSql))[0])
+ expect(updated!.default_value).toBeNull()
+ }
+)
+
withTestDatabase('enum column with quoted name', async ({ executeQuery }) => {
await executeQuery('CREATE TYPE "T" AS ENUM (\'v\'); CREATE TABLE t ( c "T" );')
From e5f2b296254aed15e49544b9bef25f8d5bd84758 Mon Sep 17 00:00:00 2001
From: Pedro Rodrigues <44656907+Rodriguespn@users.noreply.github.com>
Date: Tue, 8 Sep 2026 09:37:13 +0100
Subject: [PATCH 023/333] refactor(studio): remove `USE_REMOTE_MCP` gate,
always use remote MCP server (#50089)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Removes the `USE_REMOTE_MCP` env-var gate from the dashboard
assistant: `getMcpTools` now always connects to the remote MCP server
(the rollout from #47479 has been stable ~2 months and is enabled in
prod).
- Drops the var from `apps/studio/turbo.jsonc` and deletes the
now-obsolete transport-selection tests.
- The legacy in-process client (`createInProcessSupabaseMCPClient`)
stays, re-scoped to the hermetic eval harness (`mock-tools.ts`,
`evals/preflight.ts`); removing it is tracked by AI-897.
## Verification
- `pnpm exec tsc --noEmit` in apps/studio: no errors in any changed file
(one pre-existing unrelated error in
`packages/ui-patterns/.../InstructionBlocks.tsx`).
- `mcp-tools.test.ts` (7), `mock-tools.test.ts` (15),
`tools/index.test.ts` (6), `supabase-mcp.test.ts` (11) all pass.
## Risk
Low. Remote failure already degrades to non-MCP tools in `getTools`;
rollback = revert this PR (or re-add the gate).
## Follow-up
After this lands in prod, `USE_REMOTE_MCP` can be removed from the
Vercel env vars — nothing in the repo reads it anymore.
## Summary by CodeRabbit
* **Improvements**
* AI tools now consistently use the remote service when retrieving
available tools.
* If the remote service is unavailable, times out, or cannot
authenticate, the assistant continues operating with the tools that
remain available.
* Evaluation and development behavior now more closely reflects the
remote service experience.
* **Maintenance**
* Updated supporting documentation and automated coverage to reflect the
streamlined tool connection behavior.
Co-authored-by: Claude Sonnet 5
---
apps/studio/lib/ai/supabase-mcp.ts | 18 ++++----
apps/studio/lib/ai/tools/index.ts | 7 ++-
apps/studio/lib/ai/tools/mcp-tools.test.ts | 53 +---------------------
apps/studio/lib/ai/tools/mcp-tools.ts | 24 +++-------
apps/studio/lib/ai/tools/mock-tools.ts | 8 ++--
apps/studio/turbo.jsonc | 3 --
6 files changed, 24 insertions(+), 89 deletions(-)
diff --git a/apps/studio/lib/ai/supabase-mcp.ts b/apps/studio/lib/ai/supabase-mcp.ts
index bbc65500f7b..f29f4d07356 100644
--- a/apps/studio/lib/ai/supabase-mcp.ts
+++ b/apps/studio/lib/ai/supabase-mcp.ts
@@ -81,17 +81,16 @@ export async function createSupabaseMCPClient({
}
/**
- * Legacy in-process MCP client — the pre-migration behavior, kept as a fallback
- * behind the `USE_REMOTE_MCP` gate (see `tools/mcp-tools.ts`).
+ * In-process MCP client used by the eval harness (`getMockTools`,
+ * `evals/preflight.ts`) so evals stay hermetic — no live remote endpoint or
+ * real access token needed. Not used by the production assistant, which always
+ * talks to the remote MCP server (`createSupabaseMCPClient`).
*
* Instantiates `@supabase/mcp-server-supabase` in-process and connects to it over
* an in-memory transport. The heavy server package is imported dynamically so it
- * is code-split into its own chunk and stays out of the (default, post-migration)
- * remote path's bundle.
+ * is code-split into its own chunk and stays out of the remote path's bundle.
*
- * TODO(AI-897): remove in process mcp — delete this once every environment has
- * been flipped to the remote MCP server and has been stable. Tracked alongside
- * the `USE_REMOTE_MCP` rollout.
+ * TODO(AI-897): point evals at the remote MCP server instead and delete this.
*/
export async function createInProcessSupabaseMCPClient({
accessToken,
@@ -100,8 +99,9 @@ export async function createInProcessSupabaseMCPClient({
accessToken: string
projectRef: string
}) {
- // Dynamic imports keep the in-process server + its transport out of the remote
- // path's bundle (loaded only when this fallback is actually taken).
+ // Dynamic imports keep the in-process server + its transport out of the
+ // production assistant bundle, so they're loaded only when the eval harness
+ // actually calls this function.
// `.js` is required for esbuild ESM resolution.
const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js')
const { createSupabaseMcpServer } = await import('@supabase/mcp-server-supabase')
diff --git a/apps/studio/lib/ai/tools/index.ts b/apps/studio/lib/ai/tools/index.ts
index e70d7cdd7ba..b4b041a78c6 100644
--- a/apps/studio/lib/ai/tools/index.ts
+++ b/apps/studio/lib/ai/tools/index.ts
@@ -54,10 +54,9 @@ export const getTools = async ({
}
} else if (accessToken) {
// If platform, fetch MCP and other platform specific tools. The MCP tools
- // may be fetched from the remote MCP server over the network (see
- // `USE_REMOTE_MCP`), so a failure there (outage, timeout, auth) should
- // degrade gracefully to the remaining tools rather than break the entire
- // assistant.
+ // are fetched from the remote MCP server over the network, so a failure
+ // there (outage, timeout, auth) should degrade gracefully to the remaining
+ // tools rather than break the entire assistant.
let mcpTools: ToolSet = {}
try {
mcpTools = await getMcpTools({
diff --git a/apps/studio/lib/ai/tools/mcp-tools.test.ts b/apps/studio/lib/ai/tools/mcp-tools.test.ts
index afd17d9f18a..f3d03e2736e 100644
--- a/apps/studio/lib/ai/tools/mcp-tools.test.ts
+++ b/apps/studio/lib/ai/tools/mcp-tools.test.ts
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { createInProcessSupabaseMCPClient, createSupabaseMCPClient } from '../supabase-mcp'
+import { createSupabaseMCPClient } from '../supabase-mcp'
import { getMcpTools } from './mcp-tools'
vi.mock('../supabase-mcp', () => ({
createSupabaseMCPClient: vi.fn(),
- createInProcessSupabaseMCPClient: vi.fn(),
}))
const BASE_PARAMS = {
@@ -36,8 +35,6 @@ describe('ai/tools/mcp-tools getMcpTools', () => {
beforeEach(() => {
vi.clearAllMocks()
- // These tests exercise the remote transport; pin the migration gate to it.
- process.env.USE_REMOTE_MCP = 'true'
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
close = vi.fn().mockResolvedValue(undefined)
tools = vi.fn().mockResolvedValue({ ...FULL_REMOTE_TOOLS })
@@ -45,7 +42,6 @@ describe('ai/tools/mcp-tools getMcpTools', () => {
})
afterEach(() => {
- delete process.env.USE_REMOTE_MCP
consoleErrorSpy.mockRestore()
})
@@ -123,50 +119,3 @@ describe('ai/tools/mcp-tools getMcpTools', () => {
expect(close).toHaveBeenCalledTimes(1)
})
})
-
-describe('ai/tools/mcp-tools getMcpTools transport selection', () => {
- let close: ReturnType
- let tools: ReturnType
- let consoleErrorSpy: ReturnType
-
- beforeEach(() => {
- vi.clearAllMocks()
- consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
- close = vi.fn().mockResolvedValue(undefined)
- tools = vi.fn().mockResolvedValue({ ...FULL_REMOTE_TOOLS })
- vi.mocked(createSupabaseMCPClient).mockResolvedValue({ tools, close } as any)
- vi.mocked(createInProcessSupabaseMCPClient).mockResolvedValue({ tools, close } as any)
- })
-
- afterEach(() => {
- delete process.env.USE_REMOTE_MCP
- consoleErrorSpy.mockRestore()
- })
-
- it('uses the remote client when USE_REMOTE_MCP is "true"', async () => {
- process.env.USE_REMOTE_MCP = 'true'
-
- await getMcpTools(BASE_PARAMS)
-
- expect(createSupabaseMCPClient).toHaveBeenCalledTimes(1)
- expect(createInProcessSupabaseMCPClient).not.toHaveBeenCalled()
- })
-
- it('falls back to the in-process client when USE_REMOTE_MCP is unset (default)', async () => {
- delete process.env.USE_REMOTE_MCP
-
- await getMcpTools(BASE_PARAMS)
-
- expect(createInProcessSupabaseMCPClient).toHaveBeenCalledTimes(1)
- expect(createSupabaseMCPClient).not.toHaveBeenCalled()
- })
-
- it('uses the in-process client for any non-"true" value', async () => {
- process.env.USE_REMOTE_MCP = 'false'
-
- await getMcpTools(BASE_PARAMS)
-
- expect(createInProcessSupabaseMCPClient).toHaveBeenCalledTimes(1)
- expect(createSupabaseMCPClient).not.toHaveBeenCalled()
- })
-})
diff --git a/apps/studio/lib/ai/tools/mcp-tools.ts b/apps/studio/lib/ai/tools/mcp-tools.ts
index 407ad3a40cf..8435e3ecbd4 100644
--- a/apps/studio/lib/ai/tools/mcp-tools.ts
+++ b/apps/studio/lib/ai/tools/mcp-tools.ts
@@ -2,7 +2,7 @@
import type * as SupabaseMcp from '@supabase/mcp-server-supabase'
import type { ToolSet } from 'ai'
-import { createInProcessSupabaseMCPClient, createSupabaseMCPClient } from '../supabase-mcp'
+import { createSupabaseMCPClient } from '../supabase-mcp'
import { filterToolsByOptInLevel, toolSetValidationSchema } from '../tool-filter'
import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
@@ -50,22 +50,12 @@ export const getMcpTools = async ({
// when the request ends. The caller owns that lifecycle via this signal.
signal: AbortSignal
}) => {
- // Connect to the MCP server and fetch its tools, which replace the old local
- // tools. `USE_REMOTE_MCP` gates the transport: the remote HTTP server (target
- // state) or the legacy in-process server (fallback during the migration),
- // defaulting to in-process until an environment opts in. Flip it per
- // environment (staging → prod → Nimbus) once each one's prerequisites are met
- // (dashboard-token support on the MCP API, remote MCP enabled for Nimbus);
- // unset to roll back on the next deploy. Both transports expose the same tool
- // surface, so the filtering, drift detection, and lifecycle handling below are
- // transport-agnostic.
- //
- // TODO(AI-897): remove in process mcp — once every environment has been
- // flipped and is stable, delete `createInProcessSupabaseMCPClient` and this
- // fallback branch.
- const useRemoteMcp = process.env.USE_REMOTE_MCP === 'true'
- const createClient = useRemoteMcp ? createSupabaseMCPClient : createInProcessSupabaseMCPClient
- const mcpClient = await createClient({
+ // Connect to the remote MCP server over HTTP and fetch its tools, which
+ // replace the old local tools. The legacy in-process server is no longer a
+ // production transport (eval-only now, see `createInProcessSupabaseMCPClient`),
+ // so this is unconditional. A remote failure (outage, timeout, auth) degrades
+ // to the remaining tools in `getTools` rather than breaking the assistant.
+ const mcpClient = await createSupabaseMCPClient({
accessToken,
projectRef,
})
diff --git a/apps/studio/lib/ai/tools/mock-tools.ts b/apps/studio/lib/ai/tools/mock-tools.ts
index 4ab6899066c..e6c81a02448 100644
--- a/apps/studio/lib/ai/tools/mock-tools.ts
+++ b/apps/studio/lib/ai/tools/mock-tools.ts
@@ -599,10 +599,10 @@ export async function getMockTools(overrides: MockToolOverrides | undefined, sig
// Every tool here is a deterministic mock except `search_docs`, which uses the
// real implementation. We source it from an in-process MCP server directly
- // (rather than `getMcpTools`) so the eval harness stays hermetic and decoupled
- // from the assistant's transport gate (`USE_REMOTE_MCP`): the in-process server
- // needs no live remote endpoint or real access token. See AI-897 for how to
- // point evals at the remote MCP server instead.
+ // (rather than `getMcpTools`, which always talks to the remote server) so the
+ // eval harness stays hermetic: the in-process server needs no live remote
+ // endpoint or real access token. See AI-897 for how to point evals at the
+ // remote MCP server instead.
const mcpClient = await createInProcessSupabaseMCPClient({
accessToken: 'mock-access-token',
projectRef: 'mock-project-ref',
diff --git a/apps/studio/turbo.jsonc b/apps/studio/turbo.jsonc
index 3a085d47704..4d1c0e9538f 100644
--- a/apps/studio/turbo.jsonc
+++ b/apps/studio/turbo.jsonc
@@ -71,9 +71,6 @@
"OPENAI_API_KEY",
"BRAINTRUST_API_KEY",
"BRAINTRUST_PROJECT_ID",
- // Gates the dashboard assistant between the remote MCP server and the
- // legacy in-process one (see lib/ai/tools/mcp-tools.ts).
- "USE_REMOTE_MCP",
"AUTH_JWT_SECRET",
"LOGFLARE_API_KEY",
"LOGFLARE_PUBLIC_ACCESS_TOKEN",
From db0e6b761b37c750936b7ad929d0ea97b3ae2c16 Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Tue, 8 Sep 2026 17:26:24 +0800
Subject: [PATCH 024/333] Joshenlim/fe 4304 bring database connections out of
feature preview (#50107)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Context
As per PR title - we're bringing Database Connections out of feature
preview and it'll live on the dashboard by default 🙂
Also deprecating the existing Ongoing queries panel which Database
Connections now supercedes.
## Summary by CodeRabbit
* **New Features**
* Database Connections is now available without feature-preview
activation.
* The SQL editor’s “View running queries” option now links directly to
Database Connections.
* **Bug Fixes**
* Query cancellation and session termination now refresh database
activity data.
* **Removed**
* Removed the in-editor ongoing queries panel and its termination
controls.
* Removed the Database Connections promotional banner, preview
messaging, settings, and related telemetry.
---
.../DatabaseConnectionsPreview.tsx | 41 ----
.../FeaturePreview/FeaturePreviewContext.tsx | 16 --
.../FeaturePreview/FeaturePreviewModal.tsx | 2 -
.../App/FeaturePreview/useFeaturePreviews.ts | 14 --
.../SQLEditor/OngoingQueriesPanel.tsx | 195 ------------------
.../ObservabilityLayout.tsx | 30 ---
.../ObservabilityMenu.utils.tsx | 18 +-
.../SQLEditorLayout/SQLEditorLayout.tsx | 2 -
.../layouts/SQLEditorLayout/SQLEditorMenu.tsx | 23 +--
.../ui/BannerStack/BannerStackProvider.tsx | 1 -
.../Banners/BannerDatabaseConnections.tsx | 112 ----------
apps/studio/data/sql/cancel-query-mutation.ts | 4 +-
apps/studio/data/sql/keys.ts | 2 -
apps/studio/data/sql/ongoing-queries-query.ts | 48 -----
.../data/sql/terminate-session-mutation.ts | 4 +-
.../[ref]/observability/connections.tsx | 93 +++------
.../previews/database-connections-preview.png | Bin 95375 -> 0 bytes
apps/studio/state/app-state.ts | 6 -
packages/common/constants/local-storage.ts | 3 -
packages/common/telemetry-constants.ts | 29 ---
.../src/sql/studio/sql-editor/index.ts | 1 -
.../sql/studio/sql-editor/ongoing-queries.ts | 5 -
22 files changed, 47 insertions(+), 602 deletions(-)
delete mode 100644 apps/studio/components/interfaces/App/FeaturePreview/DatabaseConnectionsPreview.tsx
delete mode 100644 apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx
delete mode 100644 apps/studio/components/ui/BannerStack/Banners/BannerDatabaseConnections.tsx
delete mode 100644 apps/studio/data/sql/ongoing-queries-query.ts
delete mode 100644 apps/studio/public/img/previews/database-connections-preview.png
delete mode 100644 packages/pg-meta/src/sql/studio/sql-editor/ongoing-queries.ts
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/DatabaseConnectionsPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/DatabaseConnectionsPreview.tsx
deleted file mode 100644
index 41965bf19b8..00000000000
--- a/apps/studio/components/interfaces/App/FeaturePreview/DatabaseConnectionsPreview.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { useParams } from 'common'
-import Image from 'next/image'
-
-import { InlineLink } from '@/components/ui/InlineLink'
-import { BASE_PATH } from '@/lib/constants'
-
-export const DatabaseConnectionsPreview = () => {
- const { ref = '_' } = useParams()
-
- return (
-
-
- See every session on your database in real time, spot the ones that are slow, idle in a
- transaction, or blocked, and terminate the one holding things up - all from{' '}
-
- Database Connections
-
- .
-
-
-
-
-
- Enabling this preview will:
-
- - Show live connection, query, and locking activity for your database
- - Flag queries that are idle in transaction, long-running, or blocked
- - Trace a blocked query back to the session actually holding the lock
- - Let you terminate a stuck session directly from the table
- - Summarize current activity on demand using the AI Assistant
-
-
-
- )
-}
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
index 66de34d7abb..0feabf99355 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
+++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
@@ -13,7 +13,6 @@ import {
} from 'react'
import { useFeaturePreviews } from './useFeaturePreviews'
-import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { IS_PLATFORM } from '@/lib/constants'
import { EMPTY_OBJ } from '@/lib/void'
@@ -150,21 +149,6 @@ export const useIsMarketplaceEnabled = () => {
return isMarketplaceEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]
}
-export const useIsDatabaseConnectionsEnabled = () => {
- const { flags, isInitialized } = useFeaturePreviewContext()
- const [localStorageFlag] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS,
- null
- )
- const previouslyToggled = localStorageFlag !== null
-
- return {
- enabled: flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS],
- isInitialized,
- previouslyToggled,
- }
-}
-
export const useIsExplorerEnabled = () => {
const { flags } = useFeaturePreviewContext()
const isExplorerEnabled = useFlag('explorer')
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
index bd8f5ae2a19..21ed56045ee 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
+++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
@@ -31,7 +31,6 @@ import {
import { AdvisorRulesPreview } from './AdvisorRulesPreview'
import { CLSPreview } from './CLSPreview'
-import { DatabaseConnectionsPreview } from './DatabaseConnectionsPreview'
import { ExplorerPreview } from './ExplorerPreview'
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
import { IntegrationsLayoutPreview } from './IntegrationsLayoutPreview'
@@ -58,7 +57,6 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: ,
- [LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING]: ,
}
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
index 5c8e9d8e8ac..f73863054e2 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
+++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
@@ -34,7 +34,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
const isPlatformWebhooksEnabled = useFlag('platformWebhooks')
const jitDbAccessEnabled = useFlag('jitDbAccess')
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
- const isDatabaseConnectionsEnabled = useFlag('topForPostgres')
const isExplorerEnabled = useFlag('explorer')
const isStorageVersioningEnabled = useFlag('storageVersioningPrivateAlpha')
@@ -139,18 +138,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
// before the rollout reaches them.
isForced: isSqlEditorManualSaveForced,
},
- {
- key: LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS,
- name: 'Diagnose blocked queries',
- category: 'observability',
- discussionsUrl: 'https://github.com/orgs/supabase/discussions/48639',
- isNew: true,
- isPlatformOnly: false,
- isDefaultOptIn: isDatabaseConnectionsEnabled,
- enabled: true,
- getRoute: (ref?: string) => `/project/${ref}/observability/connections`,
- bannerId: 'database-connections-banner',
- },
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING,
name: 'Storage versioning',
@@ -169,7 +156,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
isPlatformWebhooksEnabled,
jitDbAccessEnabled,
isMarketplaceEnabled,
- isDatabaseConnectionsEnabled,
isExplorerEnabled,
isStorageVersioningEnabled,
])
diff --git a/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx b/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx
deleted file mode 100644
index 18a0ff6ce42..00000000000
--- a/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-import { useParams } from 'common'
-import dayjs from 'dayjs'
-import { RefreshCw, StopCircle } from 'lucide-react'
-import { useEffect, useState } from 'react'
-import { toast } from 'sonner'
-import {
- Button,
- cn,
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetSection,
- SheetTitle,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from 'ui'
-import { CodeBlock } from 'ui-patterns/CodeBlock'
-import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
-
-import { AlertError } from '@/components/ui/AlertError'
-import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
-import { useOngoingQueriesQuery } from '@/data/sql/ongoing-queries-query'
-import { useSessionTerminateMutation } from '@/data/sql/terminate-session-mutation'
-import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
-import { useUrlState } from '@/hooks/ui/useUrlState'
-import { IS_PLATFORM } from '@/lib/constants'
-import { useAppStateSnapshot } from '@/state/app-state'
-import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
-import type { ResponseError } from '@/types'
-
-export const OngoingQueriesPanel = () => {
- const [_, setParams] = useUrlState({ replace: true })
- const { viewOngoingQueries } = useParams()
- const { data: project } = useSelectedProjectQuery()
- const state = useDatabaseSelectorStateSnapshot()
- const appState = useAppStateSnapshot()
- const [selectedId, setSelectedId] = useState()
-
- const { data: databases } = useReadReplicasQuery({ projectRef: project?.ref })
- const database = (databases ?? []).find((db) => db.identifier === state.selectedDatabaseId)
-
- const {
- data,
- error,
- isError,
- isPending: isLoadingOngoingQueries,
- isFetching: isFetchingOngoingQueries,
- refetch,
- } = useOngoingQueriesQuery(
- {
- projectRef: project?.ref,
- connectionString: database?.connectionString,
- },
- {
- enabled: !IS_PLATFORM || (IS_PLATFORM && database?.connectionString !== undefined),
- staleTime: 5000,
- }
- )
- const queries = data ?? []
-
- useEffect(() => {
- if (viewOngoingQueries) {
- appState.setOnGoingQueriesPanelOpen(true)
- setParams({ viewOngoingQueries: undefined })
- }
- }, [viewOngoingQueries])
-
- const { mutate: terminateSession, isPending } = useSessionTerminateMutation({
- onSuccess: () => {
- toast.success(`Successfully terminated session (ID: ${selectedId})`)
- setSelectedId(undefined)
- },
- })
-
- const closePanel = () => {
- setParams({ viewOngoingQueries: undefined })
- appState.setOnGoingQueriesPanelOpen(false)
- }
-
- return (
- <>
- closePanel()}>
-
-
-
- Running queries on{' '}
- {database?.identifier === project?.ref ? 'primary database' : 'read replica'}
- }
- onClick={() => refetch()}
- />
-
-
- There {queries.length === 1 ? 'is' : 'are'}{' '}
- {queries.length} quer
- {queries.length === 1 ? 'y' : 'ies'} currently running{' '}
- {database?.identifier !== project?.ref ? `on replica ${database?.identifier}` : ''}
-
-
-
- {isError && (
-
-
-
- )}
- {queries.length === 0 && (
-
-
- No queries are currently running on the{' '}
- {database?.identifier !== project?.ref
- ? `read replica ${database?.identifier}`
- : (databases ?? []).length > 1
- ? 'primary database'
- : 'database'}
-
- }
- onClick={() => refetch()}
- >
- Refresh
-
-
- )}
- {queries.map((query) => (
-
-
- code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
- )}
- />
-
- PID: {query.pid}
- •
-
- Started since: {dayjs(query.query_start).format('DD MMM YYYY HH:mm (ZZ)')}
-
-
-
-
-
-
- }
- onClick={() => setSelectedId(query.pid)}
- />
-
- Abort query
-
-
- ))}
-
-
-
-
- setSelectedId(undefined)}
- onConfirm={() => {
- if (selectedId !== undefined)
- terminateSession({
- pid: selectedId,
- projectRef: project?.ref,
- connectionString: database?.connectionString,
- })
- }}
- >
-
- Terminating this session will close its connection and roll back any open transaction. The
- application will need to reconnect.
-
-
- >
- )
-}
diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx
index 8283641f5df..4f23e2a584a 100644
--- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx
+++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx
@@ -4,9 +4,7 @@ import { PropsWithChildren, useEffect, useRef } from 'react'
import { ProjectLayout } from '../ProjectLayout'
import { ObservabilityMenu } from './ObservabilityMenu'
-import { useIsDatabaseConnectionsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIndexAdvisorStatus } from '@/components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus'
-import { BannerDatabaseConnections } from '@/components/ui/BannerStack/Banners/BannerDatabaseConnections'
import { BannerIndexAdvisor } from '@/components/ui/BannerStack/Banners/BannerIndexAdvisor'
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
import { UnknownInterface } from '@/components/ui/UnknownInterface'
@@ -32,36 +30,8 @@ const ObservabilityLayoutContent = ({
false
)
- const { isInitialized, previouslyToggled } = useIsDatabaseConnectionsEnabled()
-
- const [isDatabaseConnectionsBannerDismissed, , { isSuccess: isLocalStorageReady }] =
- useLocalStorageQuery(LOCAL_STORAGE_KEYS.DATABASE_CONNECTIONS_BANNER_DISMISSED(ref ?? ''), false)
-
const prevPathnameRef = useRef(pathname)
- useEffect(() => {
- if (
- !isInitialized ||
- !isLocalStorageReady ||
- isDatabaseConnectionsBannerDismissed ||
- previouslyToggled
- )
- return
-
- addBanner({
- id: 'database-connections-banner',
- priority: 2,
- isDismissed: false,
- content: ,
- })
- }, [
- addBanner,
- isInitialized,
- isDatabaseConnectionsBannerDismissed,
- previouslyToggled,
- isLocalStorageReady,
- ])
-
useEffect(() => {
const isQueryPerformancePage = pathname?.includes('/query-performance')
diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx
index 7301943bd0a..049d90b1d71 100644
--- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx
+++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx
@@ -2,7 +2,6 @@ import { useFlag, useParams } from 'common'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
-import { useIsDatabaseConnectionsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus'
import { useContentQuery, type Content, type ContentBase } from '@/data/content/content-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
@@ -49,7 +48,6 @@ export const useGenerateObservabilityMenu = () => {
const { isSupamonitorEnabled } = useSupamonitorStatus()
const showOverview = useFlag('observabilityOverview')
- const { enabled: isDatabaseConnectionsEnabled } = useIsDatabaseConnectionsEnabled()
const storageSupported = useIsFeatureEnabled('project_storage:all')
const baseUrl = `/project/${ref}/observability`
@@ -92,16 +90,12 @@ export const useGenerateObservabilityMenu = () => {
},
]
: []),
- ...(isDatabaseConnectionsEnabled
- ? [
- {
- name: 'Database Connections',
- key: 'connections',
- url: `${baseUrl}/connections`,
- shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_API_GATEWAY,
- },
- ]
- : []),
+ {
+ name: 'Database Connections',
+ key: 'connections',
+ url: `${baseUrl}/connections`,
+ shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_CONNECTIONS,
+ },
]
const productItems: ObservabilityMenuItem[] = [
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx
index f97145a1a25..997db595287 100644
--- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx
+++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx
@@ -1,6 +1,5 @@
import { ReactNode } from 'react'
-import { OngoingQueriesPanel } from '@/components/interfaces/SQLEditor/OngoingQueriesPanel'
import { SqlEditorManualSaveNoticeDialog } from '@/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog'
import { withAuth } from '@/hooks/misc/withAuth'
@@ -12,7 +11,6 @@ const SQLEditorLayout = ({ children }: SQLEditorLayoutProps) => {
return (
<>
{children}
-
>
)
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx
index 6938b6ce9f8..1b21a5fcf3e 100644
--- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx
+++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx
@@ -25,13 +25,12 @@ import {
import { SearchList } from './SQLEditorNavV2/SearchList'
import { SQLEditorNav } from './SQLEditorNavV2/SQLEditorNav'
-import { useIsDatabaseConnectionsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { type SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { getErrorMessage } from '@/lib/get-error-message'
import { useProfile } from '@/lib/profile'
-import { getAppStateSnapshot } from '@/state/app-state'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
export const SQLEditorMenu = () => {
@@ -41,7 +40,6 @@ export const SQLEditorMenu = () => {
const { data: project } = useSelectedProjectQuery()
const snapV2 = useSqlEditorV2StateSnapshot()
- const { enabled: isDatabaseConnectionsEnabled } = useIsDatabaseConnectionsEnabled()
const sqlEditorLogsSource = useFlag('sqlEditorLogsSource')
const otelLegacyLogs = useFlag('otelLegacyLogs')
const canCreateLogsSnippet = sqlEditorLogsSource && otelLegacyLogs
@@ -53,7 +51,6 @@ export const SQLEditorMenu = () => {
'inserted_at'
)
- const appState = getAppStateSnapshot()
const debouncedSearch = useDebounce(search, 500)
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
@@ -84,8 +81,8 @@ export const SQLEditorMenu = () => {
router.push(`/project/${ref}/sql/new?skip=true${suffix}`)
setSearch('')
setShowSearch(false)
- } catch (error: any) {
- toast.error(`Failed to create new query: ${error.message}`)
+ } catch (error) {
+ toast.error(`Failed to create new query: ${getErrorMessage(error)}`)
}
}
@@ -131,7 +128,7 @@ export const SQLEditorMenu = () => {
) : (
setSort(value)}
+ onValueChange={(value) => setSort(value as 'name' | 'inserted_at')}
>
Alphabetical
@@ -181,15 +178,9 @@ export const SQLEditorMenu = () => {
- {isDatabaseConnectionsEnabled ? (
-
- View running queries
-
- ) : (
- appState.setOnGoingQueriesPanelOpen(true)}>
- View running queries
-
- )}
+
+ View running queries
+
)
diff --git a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx
index 90413237e61..2a2aac2a315 100644
--- a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx
+++ b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx
@@ -1,7 +1,6 @@
import { createContext, useCallback, useContext, useState } from 'react'
export const BANNER_ID = {
- DATABASE_CONNECTIONS: 'database-connections-banner',
INDEX_ADVISOR: 'index-advisor-banner',
TABLE_EDITOR_QUEUE_OPERATIONS: 'table-editor-queue-operations-banner',
RLS_EVENT_TRIGGER: 'rls-event-trigger-banner',
diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerDatabaseConnections.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerDatabaseConnections.tsx
deleted file mode 100644
index c80f67615ea..00000000000
--- a/apps/studio/components/ui/BannerStack/Banners/BannerDatabaseConnections.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { LOCAL_STORAGE_KEYS } from 'common'
-import { useParams } from 'common/hooks'
-import Link from 'next/link'
-import { Badge, Button, WarningIcon } from 'ui'
-
-import { BannerCard } from '../BannerCard'
-import { useBannerStack } from '../BannerStackProvider'
-import {
- useFeaturePreviewModal,
- useIsDatabaseConnectionsEnabled,
-} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
-import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
-import { useTrack } from '@/lib/telemetry/track'
-
-export const BannerDatabaseConnections = () => {
- const track = useTrack()
- const { ref } = useParams()
- const { dismissBanner } = useBannerStack()
- const { selectFeaturePreview } = useFeaturePreviewModal()
-
- const [, setIsDismissed] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.DATABASE_CONNECTIONS_BANNER_DISMISSED(ref ?? ''),
- false
- )
-
- const { enabled: isEnabled } = useIsDatabaseConnectionsEnabled()
-
- return (
- {
- setIsDismissed(true)
- dismissBanner('database-connections-banner')
- track('database_connections_banner_dismiss_button_clicked')
- }}
- >
-
-
-
- Preview
-
-
-
-
-
-
- Blocking
-
- update orders set status = $1 where id = $2;
-
-
-
-
-
-
-
- Waiting
-
-
- select * from orders where id = $1 for update
-
-
-
-
-
- Waiting
-
-
- update orders set status = $1 where id = $2;
-
-
-
-
-
-
- Diagnose blocked queries
-
- See what's blocking your database and terminate the session causing it.
-
-
-
- {isEnabled ? (
- {
- setIsDismissed(true)
- dismissBanner('database-connections-banner')
- track('database_connections_banner_cta_button_clicked', { isEnabled })
- }}
- >
-
- Explore Database Connections
-
-
- ) : (
- {
- selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS)
- track('database_connections_banner_cta_button_clicked', { isEnabled: false })
- }}
- >
- Enable Database Connections
-
- )}
-
-
-
- )
-}
diff --git a/apps/studio/data/sql/cancel-query-mutation.ts b/apps/studio/data/sql/cancel-query-mutation.ts
index ba23e590d41..f3584bec932 100644
--- a/apps/studio/data/sql/cancel-query-mutation.ts
+++ b/apps/studio/data/sql/cancel-query-mutation.ts
@@ -2,7 +2,7 @@ import { getCancelQuerySQL } from '@supabase/pg-meta'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
-import { sqlKeys } from './keys'
+import { databaseKeys } from '@/data/database/keys'
import { executeSql } from '@/data/sql/execute-sql-mutation'
import { ResponseError, type UseCustomMutationOptions } from '@/types'
@@ -50,7 +50,7 @@ export const useQueryCancelMutation = ({
mutationFn: (vars) => cancelQuery(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
- await queryClient.invalidateQueries({ queryKey: sqlKeys.ongoingQueries(projectRef) })
+ await queryClient.invalidateQueries({ queryKey: databaseKeys.databaseActivity(projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
diff --git a/apps/studio/data/sql/keys.ts b/apps/studio/data/sql/keys.ts
index b622822f1cb..06fc4f4c803 100644
--- a/apps/studio/data/sql/keys.ts
+++ b/apps/studio/data/sql/keys.ts
@@ -3,6 +3,4 @@ import type { QueryKey } from '@tanstack/react-query'
export const sqlKeys = {
query: (projectRef: string | undefined, queryKey: QueryKey) =>
['projects', projectRef, 'query', ...queryKey] as const,
- ongoingQueries: (projectRef: string | undefined) =>
- ['projects', projectRef, 'ongoing-queries'] as const,
}
diff --git a/apps/studio/data/sql/ongoing-queries-query.ts b/apps/studio/data/sql/ongoing-queries-query.ts
deleted file mode 100644
index 3205b494a9f..00000000000
--- a/apps/studio/data/sql/ongoing-queries-query.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { getOngoingQueriesSql } from '@supabase/pg-meta'
-import { useQuery } from '@tanstack/react-query'
-
-import { sqlKeys } from './keys'
-import { executeSql } from '@/data/sql/execute-sql-mutation'
-import { ResponseError, UseCustomQueryOptions } from '@/types'
-
-type OngoingQuery = {
- pid: number
- query: string
- query_start: string
-}
-
-export type OngoingQueriesVariables = {
- projectRef?: string
- connectionString?: string | null
-}
-
-export async function getOngoingQueries(
- { projectRef, connectionString }: OngoingQueriesVariables,
- signal?: AbortSignal
-) {
- const sql = getOngoingQueriesSql()
-
- const { result } = await executeSql(
- { projectRef, connectionString, sql, queryKey: ['ongoing-queries'] },
- signal
- )
-
- return (result ?? []).filter((x: OngoingQuery) => !x.query.startsWith(sql)) as OngoingQuery[]
-}
-
-export type OngoingQueriesData = Awaited>
-export type OngoingQueriesError = ResponseError
-
-export const useOngoingQueriesQuery = (
- { projectRef, connectionString }: OngoingQueriesVariables,
- {
- enabled = true,
- ...options
- }: UseCustomQueryOptions = {}
-) =>
- useQuery({
- queryKey: sqlKeys.ongoingQueries(projectRef),
- queryFn: ({ signal }) => getOngoingQueries({ projectRef, connectionString }, signal),
- enabled: enabled && typeof projectRef !== 'undefined',
- ...options,
- })
diff --git a/apps/studio/data/sql/terminate-session-mutation.ts b/apps/studio/data/sql/terminate-session-mutation.ts
index e5daf093fd1..a2eaecc18dd 100644
--- a/apps/studio/data/sql/terminate-session-mutation.ts
+++ b/apps/studio/data/sql/terminate-session-mutation.ts
@@ -2,7 +2,7 @@ import { getTerminateSessionSQL } from '@supabase/pg-meta'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
-import { sqlKeys } from './keys'
+import { databaseKeys } from '@/data/database/keys'
import { executeSql } from '@/data/sql/execute-sql-mutation'
import { ResponseError, type UseCustomMutationOptions } from '@/types'
@@ -50,7 +50,7 @@ export const useSessionTerminateMutation = ({
mutationFn: (vars) => terminateSession(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
- await queryClient.invalidateQueries({ queryKey: sqlKeys.ongoingQueries(projectRef) })
+ await queryClient.invalidateQueries({ queryKey: databaseKeys.databaseActivity(projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
diff --git a/apps/studio/pages/project/[ref]/observability/connections.tsx b/apps/studio/pages/project/[ref]/observability/connections.tsx
index ad85042cac9..32fa7903510 100644
--- a/apps/studio/pages/project/[ref]/observability/connections.tsx
+++ b/apps/studio/pages/project/[ref]/observability/connections.tsx
@@ -1,14 +1,8 @@
-import { LOCAL_STORAGE_KEYS } from 'common'
import dayjs from 'dayjs'
-import { CirclePause, CirclePlay, TextSearch } from 'lucide-react'
+import { CirclePause, CirclePlay } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
-import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
-import {
- useFeaturePreviewModal,
- useIsDatabaseConnectionsEnabled,
-} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { Activity } from '@/components/interfaces/Observability/DatabaseConnections/Activity'
import { buildDatabaseConnectionsSummaryPrompt } from '@/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.ai'
import { Overview } from '@/components/interfaces/Observability/DatabaseConnections/Overview'
@@ -17,7 +11,6 @@ import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
-import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { useDatabaseActivityQuery } from '@/data/database/activity-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -33,14 +26,11 @@ export const DatabaseConnections: NextPageWithLayout = () => {
const { data: project } = useSelectedProjectQuery()
const { openSidebar } = useSidebarManagerSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
- const { enabled: isDatabaseConnectionsEnabled } = useIsDatabaseConnectionsEnabled()
- const { selectFeaturePreview } = useFeaturePreviewModal()
const [live, setLive] = useState(true)
const [now, setNow] = useState(() => dayjs.utc())
useShortcut(SHORTCUT_IDS.DATA_TABLE_TOGGLE_LIVE, handleToggleLive, {
- enabled: isDatabaseConnectionsEnabled,
registerInCommandMenu: false,
})
@@ -54,7 +44,6 @@ export const DatabaseConnections: NextPageWithLayout = () => {
connectionString: project?.connectionString,
},
{
- enabled: isDatabaseConnectionsEnabled,
refetchOnWindowFocus: live,
refetchInterval: live ? 3000 : false,
}
@@ -92,18 +81,17 @@ export const DatabaseConnections: NextPageWithLayout = () => {
// [Joshen] Just to trigger a UI re-render for the duration to be "live"
useEffect(() => {
- if (!live || !isDatabaseConnectionsEnabled) return
+ if (!live) return
const interval = setInterval(() => setNow(dayjs.utc()), 1000)
return () => clearInterval(interval)
- }, [live, isDatabaseConnectionsEnabled])
+ }, [live])
return (
Database Connections
-
- {isDatabaseConnectionsEnabled && live && (
+ {live && (
@@ -116,56 +104,35 @@ export const DatabaseConnections: NextPageWithLayout = () => {
)}
- {isDatabaseConnectionsEnabled && (
-
-
+
+ : }
>
- : }
- >
- {live ? 'Pause' : 'Live'}
-
-
-
-
- )}
+ {live ? 'Pause' : 'Live'}
+
+
+
+
- {!isDatabaseConnectionsEnabled && (
- }
- title="Diagnose stuck and blocked queries"
- description="See every session on your database in real time, spot the ones that are slow, idle in a transaction, or blocked, and terminate the one holding things up"
- >
- selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS)}
- >
- Enable feature preview
-
-
- )}
-
- {isDatabaseConnectionsEnabled && (
- <>
-
-
- >
- )}
+
+
)
}
diff --git a/apps/studio/public/img/previews/database-connections-preview.png b/apps/studio/public/img/previews/database-connections-preview.png
deleted file mode 100644
index c2736bfba82329e5e5b46563d23e8ef92c954670..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 95375
zcmeFZWmFtp*DXpy0>J`=0KtQk#x*!W0>Rxq(6~1e8cT3OfZz_n-Q6vLq>;w0acJD#
zIhFT$zH{%NGsb)W-g|2d2BT|t6}$G{YpuEGoJE+5k_-+eDJBXE3XbdtDK!+7CzU8D
zk6oUj0cXZX1mu8U7)~E_At)%%761M`lAf@_08T!FsL4p6ln;?_0Uw@NiYtnvpj1U+
z-I_iHK0kMTuj8ujXyNK%>HVsmQb
z$oxDjedyPFLTo#1yQ-EdR3TMP{P-A$+!i79fG)ntwmUy~OS$(1^pi47Z3<*XlQ6`Zcgb43;la+uE@*F#|8#I35$pzr19!O3ExGN
zD6yF+PF~#F+R8svP*NhI`OndOIsBz_ety38S9W&x+S;F3yV&S?r^Aa2I9~R{_mGf~
z@lxHfC)&}LwDeqDYA*-~6tiUiS!a3Z)Cm<86;CeFLlYAdfpZ+cX}l@}9}(RZfV9i)
zpoY|ym;#jD64C$Z(Ql>Kzl&Adg3Zm%f8XET%oyc7yg+5SK6u^O4KJ!xe?dgV@Xu|{
zjfZJVvNmxJ$CINd
zXM06X&SKdO=V<-s$v~(a$nb!G0PBAIJK|R+szq4X*gQ{-KYeQaC(NSnB$Z3esQZ4x
z;qY&T)F6B8%EEE#&hBCH$aIGP+yW%Dva&LOn;-B`qXu>=98s
z=%ve_H@iOn1hs}1Jsm^R5XOI+{tazHU8r6&UOeA_6W@cnp)d|*Wo7G$;`s4mL;aYT
zn6=Gl+G?*02U(^21IDivT4gD&+vBp6<%Y}!O6f2D87dkO37U?E1vNPoIia2BFyFkg
zv}BU798*p<>G-|npk4ms$B%0rNKsMBl5f&97Y?Isjij+aC3bFu8;R}Wmk4~m>=@unYCf`sA#_@%k+c(GoPWMazlUS
z=kK0UgXn?AgvLvLSp|i-7zxJ`#!PQD|DEyZn}ZHz;QNe6M@RX66TvuSblbbSP444N
z0+cUkEYA*r9_n}Z^dJY4xn~fs_`Dl~RMJ>I#}xcdkob#0t1vf8tboBZ07SL
z&_qhWH}!i04wBS@KG&|YN=h-5BG9C!%4^(B_8gw!>c*y~Kj~(Day1r;gRQz#29OBv
zyTK{ak8X*VfuDeRuk$`T({|aaG_~;Y^YbGRRQzP--+Fu8@A9Yf3q#}fcvH=s*P_qL
z@^WBHlI$KbJiMbCxE
z;qA>4GqoHG#gmIUffMte$VAIvshAo0V%61NF2{Rs0+7h*VNbQqgiX`=tnFI6*Fo%#
zbkwAiU77IZ66(svMq=B%pxd_1p<980pljh4@7V^G-v_gKrNnZ>!M#V19*wricqDUM
zD!E57>u=6$)@Ug<8B3NR9NIK{7dW*jy-roP=35$0cczRlO!K6qrRAbBLX*|axUn&*bc#>Q?%*O_#AkL_Ug
zJb5HiE}@+E-OYANu17k8j)Oy`TPdAOeDrX+Cr>$3G#_2l$;l9siagM*F~7S$W6^j?q9CM4y}o$ca51&$+uqJ)xt}I{QB;CvsH!?*@rs@jUF=dwK;XTg+ddtmat2Qi
z&In{+WJDFs$R$i6T~JdZl7jnYeQ%F-PpCH0^JE?KpCqCBzPkiZEjeI8G3lUKyeAkK
z^uq$^8ELiWn1$?z58vloJmf!o2*pTBU^4{Wp<{D&4R4JVfv7;G01>
zIlJ?IQENssO+ZD*O<98ZFNgFeu*Mp-L6a-(mO8&w`#n6Gsj-9$+ywSLdxp&+(%c(K
zk!seWs%lBk$=P+=_VLPv1Tvgml2+;Hjl~Gk`a$!<{S9(NhARG$y8wK1zQ+*QLwD@(wy}Sq
z&1>L<@t@unfB2i<$OFX}eNQ4Vii|Qj#8cinI;=Nz!|4+ZXp}|nzNPUyOArlYX8L4i
z`rYe#dwV07WhkR*Wg?S|bSv!B4v?=sWLqt!meiXtF+
zrYr}o_C|2`v%GrVsM|xO&?vfvaIEoqXIfs;umDvz@;T$Z+C70I$BWd}THNgkII`Yq
z7`2t#nF?VMvv71xSLexN?Nadi%<^zQx?NQkrq(v_%)_X}ODNN8QsZ-&OKPKBpRt*#
z&KpQg6__G~e!0upd2Oy=^)cvQc&o{yG#lzG9x8{yz#0`E75nq2lz{PO1dr{yf-L+u
z9{Sxy@>l^fN-Q5%0~clv!KDziyjx{d`hD~rFPRJc_2;i&VD`lZkIaEV%y2w^`-N6r
zBi}9h2m6*95c1pfAh~$0eRU>727M-n_<4pR;vz#{J#LSrv(*$gE%?i
z5o;4WJGt)xwBbVK0Cjfv
zj{#>0B=+>1tNMCwZtgQx&n}d37@fv85Q_r`ftOFTHbV=i4Gawx1pol6o{NWtqk@Zz
z3&4yuT}2uY5y5BMc1w6heC=ti#FUI@zzrQ8U0iD)6TYSnzi9|m$-pj+j-C-i
z)w5HevT&dZYV-)?hFuX_NH^R@2@Z{_h~gI{x^)Ycrh}z7Hu48+OKI3`)`gh{VuWF=
z>>TJ@0lAsr+2DwL+%h44{1d2A7WUEk5no$%C=3;-Gwb{ULdm^!Ab%;T4b(@UY~+eR
zeS)ra!~`T|;1xb`N#ep!$9=N2WxbV88A}zY&ji6sA^=W}Lxv&bV^nzY=m2&d=%ULn
zM5(5hl_GvTq>`QL<10dwmY${vS3dWDosIRVO^nme(pQBtE3yar9N+Mn)!VE)4IQ|0
zcJ=4Xk06NC8T)fGx@@ec5mGWCw;j(_*DL?t3Uix7De?+;qf3PfZC%PWOXmxJ2)>$U
zCosU|r-3GBF`;ZPON8HS9-p#6LN_JelnR@QgoLv3#Jzbs0SSd+{x_)g|M4~!fVrW^
zOWW%V?V_SF2?-2;C+^!fY-*Zn50;kDLK>&B>LHo_=J9cju&-Y~>dHtFK1OAsfpv|I
zsb8P(FuDPl64)C#0}Gqo(J@)fuYGr+PChhr7avwGkM=w+h1XW@qt;9ihwYyj`oA}v
z1Ik@oyU&f>nau>9sHjm|dio$dO#re6)OraijSSAtY|K8ONVVoh8G71va*XcILpwqq{Q5m_>YGSyHHQ9An#)Su_IVZdM>hn&(
zpKH+F@6d4fB{zhhndUeI$+D??N_8Jz~;9~c`k4WdB2>WV_
zK~mj1mpnZDcx@nlB#ur_Vx3oeWldDR7pasPHz?&>=j5vwD7owvs^%YmM%hWN=-?y#
zR*(UeyT8A2)UCCOo`wwe_m>OqE$k>-BaCm-Q%}Zi{=_x6vE>apWb5*uAQ=tIPM=
z@qykkTrqFub=L8
zO_s!Mj|pR(+pcgLSZ#U$77$S=%GkN2wHC!rv-UV3%^X(o7$gTb^DUYG@yH!0uKv;rg+n^VGg!9RyRz44R
z7cM;!Z{+}dvex+pRYL0hSg|H^6LL}?3CvfSAW+(k90Y0QR#e
zO`2ry%2#$)nep+LW3nC;Kl%6zB$Q~BvjExo1&Om(!&$gMZwNL8jbqzYg7qo(D~@;D
zlV!}A-p65cK2Z)r93K)HllyK~ql&7*{RzSsxHvWX4sGxCcpRy>X7LcMo;A1E{p|S$
zyVF&%3Tc;F)&ru4O996d+QzbJ!KM}M=P4u^iREBD7nc}se&=i+oAD10O;;8eSVF9n
z8I;w|J!r{-ZpE%UlZt2tT<;#=qSifnMUVcRGOof+$j#NbZypkr&}2F2OD^QD(LPz-
zP%Ww)XJ+**TSVA#UwAAWBxYrfNHFBJ8UMa70*dHxWHZIa=ilNlUhbrv8
zJtKV5G?>E6eU=s)`t0gzBNKUfv}!Q}PBdg-@3?h|+`}g4`EY%{M_a|2%-B)yF#j=f
zr~eh;%tTV$9%xBV(h)_z+w966Y=rE^NrgeNui>Rl(eu)7HMqqy)8
zkSWA40cHi*e{$lGH0J_}RTgz+l@xf4e^x#N<*HOnKNhcGUP6s>F=hDNFcY%#fT7~a~~y6um?
z*DBMC_dHi4(iNivzpem#2#ssx&&*ItGtnrJoD3YmRhQXp4p7?3%PB_
z4WuT{FiG|hWsyLi|DX5baq{<^f7Rj|NkxfI!XbN`)OT@l
z5nI1En+zj7C(x_6E5ecX^91($F1u#F{?!s98f;Z;q&?3z4|S#|0h(cuqkCEJ&-HE%
zoPX-ED*g{|@y|8yCA0WQ_4FSR!Dz^*92JsGhR$6Y!pQ$)PCjDZqG7I0FQtNyi#z5tztu6W!;(;j>yRxgd4scw`3d5H3
ze=to3E(|?z-`E;2su~MGzKY2yKW?t~TMm*I4B%GZ
z+dmzBRQIL1^gD0bP&9MSOyFE9MTCt@Q^JI>VDUrgLYA2yIw8!ZG7{;f%R@{pc+lnh
z)H=J~6Ny#tsdB^o-R-h3x8gW(9d}jF;MZorj`kQ>gZ;{4_jEFmx84=num
zY4%Viq{T$I)5qt?0V>jUoi@agV;lw(u8Nq1NhA~l*2Yn$p5+jfkz_fP#(5J-_2lCx
z5fyfcMUE%hgDHHx6*y4y
z*>>f*Zute7p1o<`zm4Nz-2~nJ3R6uxywy;q`#~<*6!zI?`=F!0-X3B8En!cRy-g$k
z_B?e)W_?v1gHXMMj{NQ_vyi0hvEEZh}J4^}8Nv=Q5wLahmr}*=ID_
z$D!KPxtOTS$~n>zgEJf&ASK&rf)_77f}|<+n#6l>EeG)Ac!z8Bme$tnK+J{S%XUA0
zZ68Nu5Y|UfYe0v|itQ2g>vohNX@1FIBkgB>!*}E&C@Lde_S1Yrzq9;MiXM
zlty&Y>OvToniP|{e({_;CE!JTs8JNT37dks^W)(oaU&9EySnl(XrbiPh273ytF4y1
zgYv6_T+qSz-Jm#BEt7zbBUC2z6^`2X0kpRfZYq&kA&ieLczgj~$N}o2YcpAB4Wo%u
zua_i-NRohgzdqkQSCO88gHTTbdO0YG5i&XZ#w6=kS#Ozs2-m;_-wg*+VUZ3^qxl}{
zJ%XzJXc+%2&&CO+g0=da3^AANcAup2+G@Ga1Z9+duI^W-3I`L%drZDWElggTu1Oc|
z-1Z52*I*hTz5dfk9AwQDXhtB?izS2++{f7y{JwgdBiU5J_pGp02mBPOJUbcAQfNl)
zD)Ac3dQj9gCWx~;WaBw4(Zh(@Kjgw
z!s(oI!J<7ZMgql>_g01TXR=^sspqR9t32#4#6xU)=esjzL*?0B=erPiGNt}g=P~I5
zds@)vILbquBu?@=wpVuGMw&5<|Io?WQ@rhT>l0WR?bF!Dx6whtNqsJ*#m(r|1*y
z=3r)ejO{3{yHYx^~$#!>43nS;>lp2-`U>qopim{@H@&&T+492NTJkw
zwt?w?jDhIHg*rVV{YIsjW|cuuAC$%W)S30;40!LY>U&EG7xXtgamSM?GlHV>uzJe)
zxHuMiHsBcv40Krn5oI^m(yZuoyg@>ohse*K`PD_i{CjDXkSR*=)V5}c
z`S1^Mf%PF6ZZNAJxUP=%bYJ2scmwmYrSc*^w2;M2F%pEWVZb3(5Rsu+3*s--`JvAb
zCD4Eingy}VGTK9#msu#+U5ixPq|dqL9=>a3;L$uqN4I2`39??!4$3FH5_`BcOUSZB
z1xY8VW0wATAC7es)?}!{DIUc{MddhVPf70P$MNI^#8~H$KZ*VE3GWYt&%^y4rrl3U
zEDTPO_jb!Htn2=j=}s@})5s52wcd{Sjmr4$ELA$ZyZTwh63ORBKW`hUsNk#;7yf3guUKXRqnwtWkcz5+5or|fwlfJ<{M{E`oP(UxKpDlP7;PL5
zwekGAxH%J+p{sRe5aU8BvcPdbxSjAh%eSzFnx%#zxNo>Se=voxxeU;QQWoy&eKWa!
z4RBhtC2H1|lSK2@riK;@uaE-><41LXEZni>jPEaVdre-)#eEe*r674wTvEXY$^Suf
zI-e^$b0NtTB!D)2IDlhGc+kF)vFo?0u-f@0K;ajjR)wyaKjT1{Q{Qxzg%PI4#{?E5
zI|d&()?>~Aw}1tlB)>-y0z5A{=QjWHiMADOTH9}}UIWq2*%QgKspa5>Bscij6tF-A
zfsdEyYcqep!Ln1x7)9eXBIY?Q<&Jzs6NssHBD
zE!8UCTXIHWpv3=>h>&f6sE?7s-ZFRhJ)QxR?gV2W##!O
zqI!Kwk^*(P@@ZQWgev4SW$xthnBF9delyQvUz_VRhxKf2kw91Q&1Z5wwR>blwk7K!oH-t`YI
zCmLF;Z_bv3*)xRJ+h?0>6O#$>35F2~XP=%YH57Ar)s?pIl(!6cf7FWd7GCG`AulQI
z`3{e7#iYiMH&cRYt^Ad;tQ1UrKX)@AS)W0Pmcu)K$aim`w^7zRAZ&)Rm$6?lypn$Z
zgdp{!!;`eG8(V-%-Eu6}iW`0>g+!-*xgXwy=~RzB4V8aN;|M7{_AAheR9Wsm={k`)z~3FC*Ta{-
z49<#8dy)(z3skhYPY@PG@Dn&Pu(6x_&(En0E$K+Hqmg^@_m2FK5cuK(?g>7#pkR@C
zXV3e!k}3%u^P=t?eVMDfSj5XcwfbqFkUxzbF&IxGZGN^<7LIA*NlqfZ(j0Y9fC(Ejk#S0B-L|M5l#_a-8vU>dDH;tc{Dy3-sJvhFTFn}&xer`yYgVur}2i<1-CFZZ(|c0%Q(Z$yw2+I
z-KVg$+ii(P9B~iX$J*m&X*H2=<4E|=IT;zY+4%2O{KR$42J-@a-uXQ1Q#^N0BHaCP
zZ%UDQd846KanQOX!O=*;I9Mrq-+N@QA0;vKSs*pLraR$w>7KDxA*^;w))iE;5VYZ9
zu%p1AaP*e#`xh<(BuahMolJ$=c++80VdnA?=OW>@-sN5%80-FiY<)%GRiyo(Sy12C
zQJQqZ%qYF
z^qGW?gq`p)cXwtULzPV*4%-cK7`Z%b1j05V`ebxNj$M9+;^*IoHna8%!f>+qL?wxj
zrJi@|c~7c{;>E}GJlC794$M&tz!>UL3PBdey(Df9$*ESE>!l;aVSx0daTktf9S48q
zVUU%^eUWxGa4DC49NEr!@>*Z7N%4DA
z_s7rH*`yLEOg_aAmFs!|Yzm8CnZ)DpBSMEU326q1ztp7g$HL_PL)36BW3vXM=(e%J
zNCTZ-`GzjMW@-8Tmx4R_!toev__?Elq&UpT6<>Eu8xI)-DC`9C{OCmZ>8KHHIl6}X
zVTCIkJx4=n(bbreHXKLC(WU+K1o-hX&uBQj(?Scg0l&dN9*WS=-{qX0|7un=G%dX-
z;r~j5p}V92o%rujZJYKp9TW!$=37%8ei;ox8T`~I>w^R|*;r+!J;4B7K!t&g8DrKL
z2`f#mp<`fRSUf{CII^b;y2TJOYyFO-5DE+}yYwP8oX43-p(0mM}0Uuv-H57UH?3t^njnA7%V$t?OUTBhgq?qP*25_gmX)
zq%c{WW{FmO!-6m3R_TL+Iq>~of7P7i%MqEC{e6dW(AQ&&&l#;=%R$7o*q8(~G}+Z(
z9}#IX_4DSE3R|f}f^kTBz8Cr3QkQ5|5CPBf=Jo4$8B@}$qj~a3fLj7E+Dq&C<~r~5
zs|}YO`-VSW2kl-%KKp)4zRoVLak;s4)HI;yjEsz)*U6T2th|~I?OO~2p2xYhwu?p!
z-Y1GpTkEMvdz?P*(J{aWP{{JPOfN@}y&ZC`$TWU;P}14?j>O}DAK+;DH~0Xuo`m1&
z2R0d>La|1X%g~*)ky|`Ag`fsN@CSy1hyqpMHfArx3A>r6+q@e1K+3Xp#hP$ksvfkP
z3#!)y3<}?;lXU)e=#-}qZ!OdJekM@65}@sLTR&C(Wu;o%S^;6cu=L~S_k?=DS5s-h0Y+Bs)kdQ09ZQv~(m=Jl=mTD};(q(uV{4sQu<3Q7#^oj8F;lKCSAL
zI-siy2tWeN3x_E&z9u=~SEzPc?%p*OVq;@tI4n16OG#dY5jl8mArrx~wbp+LNT65c
z0N?zzV3HS4Z)UdDI(EEJHJxz`=#IiFoK9-Q^|!)lNkRra^;jEQ2Q>6Qb6E
z*14*R%k$X$C;0UdXv(dTq?WChTYJ8o}l01`AXv!f$4AOO(E*L5QOgaieX5z1-&)PGkY
zzL$w=&_p#@zG?q@2btcm$k|4Byt=bvTfP!tDz;&lqsc4)JuUo8vU)CkL0>D?!fUWc
zSUA`MW!w(YK7SjLCE4P!7X!FBFz_`Myv}zyA$t@eP$_9^orx^TkX?kHl_!tGT$@D<
zvL^%=3;!>m75*)P{1Q-S07M;(4M+*pSsy(H>!#!YA)91D9q+VI0tGy1Y}UUBR|dBI
zrp?nCYxK+m9hrp>6ff~fNJtnx9j>O)(}fK5ju
z!RZ;b<_ADaj0GeC*xN;KLz4sMLI53EYpl})mJ_Wq1NxoGGVX`+l8B+_6hbgxLoqhC
zh0}%V%j?Sz3M`CZhT;#~<7Le0LT)inv53sylILV+n;$H+Sv(hw!yg$L8C9KYU#R>WvAF!tK0<=rq44UIt@12y$R96OUG3gaHIj&d0HA^|b$8=$YoYyM@j
zoA1sM$kVN{G(JdTqJ3GDh7P-Py6
zQK&na6-Zhr5*~o2Khli}~#5tm4~w^L)`x-1Rjn+#~C;{8TPr3jc{Nr4p^OT(g2m
zuA`nXXaZMOryIVor!6WEaKI58gQ<+TqL)ABJf{p-wpVC0OEhCI7QNte^;XLgp6>ws
zUXS<*pPDlG;^Z}%rX!`n2@t1}3_dqya{#L4a^1|!KYE3SXY#lx(HJT_IyyQ474q00
zaP9z-NgJ!CgmOk4D*E$h_^PUairG&_!0;P4*6+I`qaqh^e34q-SG)OoMbVpGb!r+K
zP(S(Y-5qyeVcZXSuj#GIlazo*JvJP!xMK7p_4fKj6yz-Oi7cFw9+>`N3nS_)J-A{#
zKuGv!yhs=HNWd=R`S|$a@bGXKaeI543up-W+LOTA$MO-TxprLQ?;RFpR;dokh%YZM
zpJ`*3&sNQm9<8(0CNR(0Sf8!4r3Y%u;#rl^fvC@b)7ot?2Z8Dg5{Zm=G>OrR@Re
zuW@lv8Qzj4YKF?CSq&Rs37IiPtYxH-i3J4(0-o-oNI8c(+X=obfo;HLW{!c%0@6Ik
zGa*EW+6uHZCdtl*72Y;a1Lo1_vTzDOrk(*3AM)pm=hSo{6sX3(zOxa(@G0a!MmV55
z|L5|DUHLy{@xQE>|K$ZB2p1RE&yi>N?o5zCds&+1cPb&ANI6j*ICVA_P8R>R
zpcI$_7?HnfwZgAROUAHG2`4~4URzsxj9LoF($4PgK*c-x(i{#PfS$b8@*GB9+J_e&
z%Jw8qfJqxL4FwgJ0uDIvbIird%TGs#hj@R#FP9!$6N}M{^@5m~5zrsVro#W^WMs
zW)DddLbYC4vYDBER1E!<0W5ZdXB~fK@^pFzhWz0st6^pyYHIP;)>fT$Jk_eKjDSvZ
zq>_RwwwJMLQoH~pP73b&gnrMqpCaORBo~%c`
z3wY5*30mTR_0KEv1SRc#l!R@~l#2{RbmaKRFEuRFVOd
z(hUfe3)d8&qXFRnt)ZcRaoNAejKF`T6vsBICx`4D@e7&zCVARZ(txnpF1}}rZPB;#
zM`^C}W56Hdt9Gdjm~5Y&fFLRh{N`37A=)o#v%9l$Km2hT<_ck@1^NAbi1jk0i)7ij
zCs=!1M}({`K
zxwO%|dYRTsL3&%0j4m3$LZzkR0!Hvc_@9VhVo@5tdjUnxWe
zx7=&$@36+79#H5xzL9g{#P0Hy~TalbiCCe_w-HG1m!
zp182h?%!!*?25u^Nnh1x0Cz8poQF%@lY|mTCc{-QS=8cNIl?#rYha=_A9uZ{HNb3~
zpHcnlYKlRVoh`xI8>UGDLjR8kpYZdU2n|T_Kdb%t|G0zrFEir*wdMG~UmyUWr8l+<
z#qr-7uWgD9^1e#Ry#G@S?l&K~^^8f00V?{@Cr_{bO~3$aI>rDvdzo6?cfTk5Da3P|
z76buxxd`#23cJarcD#7(T};di^VMQ-wbLY3Oj6Qo0PVN{f2(l()77VbLtIfNYinyR
zNwNY)<-o5U9UZ5X9PctR4}-lQkcrif7Y@|fUvnP5Q*-TpWnMezxR2=ocB@Fqa6bFB
zF2;;FUcmahYwgE5$B@m{H8bJtDUfF
zpD{gZn(P=;0F=W5lttg*kn+Y{HuCFsSVSm1+x(3h_Av6%G?+I)=2w{^+op^X*VfEE
zw*fr9JSR{MzC-iej!*mrhl_c&5M=lnr5PHS-*Ur0$k`O{F1E=t_}xWFrHkk<-A9p&
z=#`W5%1J1bzm0<2PIHm~rrH&jZ>)=OIuZL#j5qCBfP1Y(S&UWN
z@U7l84!JpLm<%pe%t8C5+E8H7pT?urJl$}jnH}~FG3EUG(;Dn1??CL171%D4Ie{(O
zn8Al}qF6gozb_(|2;c*I-E5KJVN|jT8X8}2{7$BYkkiYw#|T`pP@w!|WZAv8q@_V3
z;G(hh6hbLe*PQbfY3*WCU_+I7*s(uaO_Rvop=5sI2(7XheMBnXn3;XMH$b4p6ob1i
zfqh-`*-mQBDWbtHzDVpLw@=eG1935Sc!AF<`S>v^y`Z3;7T_SQxxXEob6?$S=m(s7
zytl~-tD=U`^EkwQJ3{zA7cLg-cPfgfQxr4je9$)9Y$!C-Y8v2u_lHrfPTGy62sv1y
zEW#RJtP$;uE5;(`cae?;XI!hhScETF7+)DeZY5vck~w~86pf14y^)oAYCBzY3ssCv={r&!hTHCnE84f6}GrsS%&D
z@54RBdZIntKy-VO{HDt9g73;=g)Al}&2^|=Ym!lNLv(?suuC
z#gaygD}2}7dcG#|f{cO{NCVz!sNtYE=?Jp^>xq&&)yol6NWzsadQU8yrEp
zp)-IMeg&>YRvr8r2%`c`wNOaoR(vSWkxmmTN8nO
zw-t_u;wA^L@4!V0;EV>Sf{Xq}Q0pHRZQ8!Y~hP*+m)<7xIU=^E-9aabOedn
z{+L03pndAw{Iu`IW!
z^W)!G2g%5@)Y|V45l#9#FL3AzT{Nv{${~~2QSws@0c(gThTmVG*3>Gkf)l2S5yOGX
z&uR~AJPj=jm%A(%#*uH17s6Ku9jQ7{W~w~XF(M-==NRwB;|CY9EEMBw$K!-A=H&xQ
zdRb@xjQ=rCT9A)w<5CrLJ76?25*;mdlW}%}+11zXjOy>mi>uW^S+_)-0u8vlbNQ`3
zr|Z>mMFiGMzj$kIahf!qQt}wg9YS5VtYHXAFOOuO=i@#z&CYeVXZ=%plzsAbq+KpS
zX!fiKw^6QsV?ozFGZ@f+>EDfgM)A7p41d@c6~@00$Yc6LP0nWBkS)rKoZ|PW@b)*T
zA;z>GV%OPd-Vq9T{6GV;w}lcP*jbXC18zY^E-neB;9#3g7wM0f{HR)zJ(-<9USFNg
zU(|9Xlztg<*&5CZCl_%2(0sAQ?D36~-phSI2FI3b%5k=uUeDZR=66YMO|X6rI~f}W
z1_t1Y{pb)i+$ZF@(rDev@Y8bW%lDYL()bRyYO@`!t&x@}*Uh>2L^hH<>>_~ctBpQ^
zP3JYD?u(Gsv{R9$kPaqf+q`hb@6@$AZDI-~!dzpx+4aBy8eqC<1l3E?^+2q%z96B@
zXcL7>nJ$ivaj%^gsaueM!uWH@fcI%)MQ=)n@80!5ck(0F9fjE>x=_6?V0p7T_K2(i
z7UR}w;SR69s3{ctYP@(i{+)K&sDt8(l2mG^O;xv*X-q+mv8;drlPt
znwtB*o1FdFr@vz}gZk_a9P&eU_BOnW%HMdME`(69@;
zhL_%0{t-Y(nD}fLEO%}0-eD;(;BU6x4UAA
z3j&e!=lkCuR{(rCE3Up0ifZWlE{FGlLfW`Xz1qW1Z!s
z;DJG?p)|2R-D^`8-p=qF(P0^4;I-36TIfl5*%}(kifD0R0a9j)F)88|`-z
zyr(29N}cRq(F}hXUS(9EWh;~;#L0-MU3Pac0|7j7dB>U7m{H7+n6W+$~?V6ivrqCnPyM@(Eot&xi{gXzCnq-urWQu=Q_z=;db4&NWG(7*WxVY@_fn|IjAg}fP!-aTprAUbNK05
z1sXQRN2uYCs^?WzoGUvUj8e$tg2copHkAR?olcv>;sjbvP0cBO7u$=A*ahz$bGJH_
zSzWz0kFM5#hPhZRijGwd;KIJw;N#D}Q{cpb6ZA>u`u0exwP`8Z2WD_CYdW~?Oe986
zMG`qWI*y&d{HaIS+wKzH5IeNp?C&B0&MNVu{Z`F_Wv~^{8O&r(=Be0OX;mY2-5n4X
zFr`zNL7iPwZl1-E7op2((v!=LMWOgm%=J95(mRvy8KE#1$WUPVs2Qt@fkE0p$5gov
z6Gijch?5AU7**`yH9=<@rU={o&GQ(Kjyv_6JuVc7$eMGt-W5YDjG=;*Ajc~ah
z$6y`n=HcHiK>YdMZD-h_XER~{T2a5Q-v}_IRqig-t3Ez!YCSolI$mh-BWjF*P1sl`
zyZ7FvCnVT(muOKm|DG`0kD-SYq#`$uf}r%!Yyy1MqrcYZjfg~}m=ZLcm^>_E7^I>-
z-;&hu#DJZTK^ml4Y#t0pSx=NOV^$;c+|S6kLSwBEz4=yZ)NUX>?;1-2we-Ag_X0~T
z`W|QVDL^C>ALQbT*Q^GRMDE*h$>CTWvW37)sA;3%FboWfM%+fLpl1-*ygVJ84qBJ9
zCF*tO5lFv_)q}4tzgML7?ofu&q4#r+hl7qmzH$<15&5_@R~jY`uLxJ~f>Xfd7Rjh{
zl(4}PAIEN$8ZwP=woS<64|kMs%sj!6vhf(8c~FDk!(;d&38kIQT4l2_KO$1rN-Kp0
zw@dBjYh@{0E=0MNjZG^Qci`83)j4OL$6F|2`#UzFo0uO+xUgMTv)|le&ht;XYO^71g
z!w*jf{mIjHonVta3f4qzHvNRQQlvd`P`;4&LVj75%kMP%GS6zo|A*Z
zQ0{8?D+!}qD`ph5-OwS*a_hxVUSP(zR08i+l+5_WUHyfg0DaWmrFr1LGr=93W;S{{
zUfTKOMAkHe;Z~zWSMq#!&f0;>r=NASUEY$r;Uxp1D4;Xh%{O)_)X(LXXYCDG6)e*a>BH@LE-BsDpdGAbNnj5R4>
zC(772`p)b8_d|Kd0!xWjO@49*+R2A;dzD3nclJl;XPk&m<%rq3fohr4E8}dOS-|cO
z$sZvlN~RhRnaq=cllR+9mBr>=L6huKx
z#R#z+_}-DPFv77{MNaMOFrqp#gN%53GX8_HLG
zy(%M+@+2UygvPeC++ay)$o+lAos|EXh+J{878hPT9pOXyI!HIk77d~i6d3rMm_Tft
zJEBTWKOmK1Q2#?zyU$=$M`p*?qe@A#WZLp}z3Wq|*Nc94bu1~|QyK#R9%EwFYe--&
zzt0>m)YK;$N@m@Hy4OI<^s2o_Xt);%8Jzp@+6@s6wjU-8W&uxg+g-YTLrP6n(%TZB
zf}s~BmO_h}99;?-X;i1)61W%&ty}U-s*m|Zva|y1|da@J8x82
zQxl#yul)#@*P`wA$uHXg>Hyku(l?Sa25bnoHn`X=hgT!mXt=*vwx4KSq*H2mGsqvU
zW#R_&M!@RQ>B?nAN^2Le6&PSl5GmMDHe7AnTlq>6iel0$J2*J#8P&!-1>RaQZP=u5
zvc41KKA5j$mPzrf9@sGIParBoST)s5kRyV`r^lqC3ikE#>!yyuJt-4hTPN%T?kdte
zjuW7pM#b^Zp7X_;E=~_dByOv(SPxSAe>N=g5G!|HmuQ!AAev5L%s)rEMxLxsJNn_o
zCRisEk-E&0w`@|o8T1b+=>@OAvL??}gH4o#<5c^J*mxk@XB3~pc4zC`Iy3TKjfOH4
zXO~8dxnNb`NZ3EIX@^!X`?7BM-ma641;iE#7iG^le2`usW<2zeCA5@{&gFT-M;?Nc
zy6NGUDLz_v@$PQHp3zpDg(C}ZJ-c}~MSs9RVbSk8;ipu3ytJbJmdCNyOpQ-47U#mbWMM3|;63~ublL@o1NE*&kRR%vY3j!evG!M7SbFP6VC(%FB{
zco$;y#x?<-*x`FUdwTnLE|}7IN1MpoA2Z50$K6o3&OBpxwpljC_D4cCqLZV&X4%x`
zs;)uqWRe0I+mOKvQ8W#uf_;myP1xBhsC%v@v#y+({l$k+R#q{3kkrmp6lCY|E4w34
z^@NY>JZnSYE=TFEcl~R`rl(epy9oE&$$k6O+Hdll#1T4GhmHcZjtv-VY%S{++)a1i
z-YotZVkJAC_lq-p6c3Vz-%El|^wQ!!jC~$Z?O*)V#;S_?VBvB{f&06Gs;%p)qHx@E
z$1}gCXg?;-clo-)^GCAS#_gIv^e`CL=z#)rb{GV@3w#{DfD!UwxabUvZ;Dz7pSMooHguhYf;<%aPES)f2LzW$IBq-j>mk
z?)D_xiNjcX
zTN5cYG*JAP=Kn+J$j9^mPVK;e%PM7k?`7!*^J
zJzbnMe^#SOqN5uL4xD<2kJq&AXqP3_{V(R;DlW>djoUUsltw^WT3R{>q#Fcj>29Q9
zD3wx>?(UAEJCyE}l9Cu|=x)BneLv6h?Y17?xTm@=T1SoW|PSAh>4v%Y%^OOq!eVwch;Kbn)716S9^L=Da4V5
zg$z1+>?$ZMZ*EbrHYn0rZ1SdB$&=X~U74#?Wv`aEhBn}EHZ^xhrtKN%(2~cflTtT?
zC#Q%t&50MclLQ)AXU02p#0KKYLhEGZzCH`0oa&6;0hV7S-2DiZvu
z%kZ-hE$y!r0>|L?Rr%V|w+(BB#Qn+~RhkZ~`2sEG-^EOyuHYc+QN##9;1zm4+;_Lu
z8~U4SjgzgeV|Z&{dZZGXYy#yLS}HXR)Y^YONN}95UT4&E+W0odb+otb6J|TA-QUT)
zoU?>20#haLhW~PZ0eA9jswY_*A3-`TUbyEAWQYo$|PAsF~jd3;!L
zMBvBNC&X?2k;&$0`1a1!!AT5DAVvhz!!LxkITn;tN`gAI!h+hZWeAW2*HwTYSAfs2ZwMy_2gLBI{`{*I)HjI!306CPpODe
z`P-??1V(-bxd1N9@O8dwPcOO0*v(a3sXPUDr{G*V5^6Wdr|EI+SI5UEUD@~L@ZGwC
zRu^gnB6l#n%Cjx0-7IT02hKo6%_RSb0|i_xE&N&Veox!GF1ZyRkFwxa}^4UnPZ088a@@vdVSBaUq$
zv#SqOf3apu`sIKB@b^>?}i``>?0Q)xz0Yv~lm
z4yRSZ!-{zL_<9hnY2jY9xAH+lUAMDdg$lT4IC3_;$*&g&_)CT3d(Vj2etvSxb44xnCD1I&2RpX;#wy
zdg4kppYtmYRwTemCl=~QEgdMKd)R|JLB&NbN;C8fA2j$pz~bsZPW9&f?g)~moQzD%
zLp2>CAznNZrZ8r0ieW3?h=*!3f75YIuj7Umh>gu$QUxiCIDdXM=^lCf>zAJf+Rcrz
zqL^{&+4l*ytu&+V!S_P(<$Is4g}k&*l{wY}G@qb5J%kyaNduN
zb)L(nYV!01Q4Jl6_V!gza1m|QTzAMT%5`*!$r^gj@gnLPJ%R#j!xp^Nlp}fkpJGLT
zT&XHgt+`uFc1TmKD*bxnkd}>K2`aBGlqVTOU0t2PZRXPvX!n05o~u<9K_`oNY4f^f
z^S6?Mg)i6g00G#^;YMa;$VRFui-Z!#_EwR>4tk2ntL5$AGUH>XxmI2vi56i5%*(0r`p{3PYw^2c)>LDZ7
zaJgl*<(OR$<^`fwZ}5~!VW>`zOtac^fB?a{Sz%ga)V#}3+OlAgG9}%Y&4Feu?B2
zbvRx_Leo@{PW)r!3N2rK%1=R&S<`f_xiBvysYGXKiEVpo(QyE0V=dV|B%U250p9ba
z=MBXEEp7MBTm0Cxq$I!2E@`DDo~@bI*7GgF*ZG3Z%YNGnEuzbuZ;MoEHJYlw)O%dH
z(1pre>b$R1AepJK^*Uv9MMHvtgI-hl6#7vmB?mz;OGBY^bsDqLBQ;ZH8X|^YZf!qV
zL7MSi%d+rysk-7>ug>Yt9QEl&A(3r3XbmUr@l+^ww@1mtvguno8@ajr>w4@3=L~i`-7o^Jnu5v>j(DwWgkh*QPbdW>O4GNjq=yYg}S9sv+c2
zmN!-zTfL4-i5HTNrgwiMunF9cZrBcTkgF9mn%F|XNX?4svw}8hdJ*%_0s5Cud6Kqs
zL(+f6g^YwtFnDPp8tu0=K({HqzN9jM_)82!k~26=Ah-YXt(`AU*FEQl$2M!p?^OW-
z9S&4P)>()kQI6Ri1r-HF+Y8M7-!4QAW$3;OxYh5CpgW#MH%9|eftEHeA1Tb
zgEe{a^QmFBU8@^@mUul{<*CX(IeezZ-KlT_oO+m0MCHTpC<~IDR`e{#yX1PPttykm
z{G}Ol3Uu^-n_kEmc=%@cXt9|&_pP77c-CA=(dK}VY=bBoWo=F?WZ8Z$8%IXj$taXr
zZo8o)F%kRUr5`1`1dqczES?3hT?|Q*(j9uSG+xqMjOJEfG`bh5Dd%~6m8)O5>_qOf
zQ`?P
zS-rs0xpF`gr-7c+GUY?q<0tyff!a($p4W*@ch_DO=X;CBJv~W|#Vh!jR4JFJ5z`x$
zG*z{5GL>EdQYtiT%&r|oO2xG_0=9RB7J?Luy{d5c5Nu;
ziX&V^u>dz1&jjZCmu~r(K(p2%2SpsaVhv_7-_i_Pcm3O~w`_M{lqjcaZ%p$PVdQny
z+s*dL%@pG;ZOMBQUblB^3Q6E3ZDJ}ww~XssXcm5~2#?FVJscI@yHty2mU^+oYFiU%
z#9$KdxB&OZXXb>QcBYSNo>rPlpKp%m8qC=B=-X&;#q961=emyfu5?%Ktt@SltN$Rh
zNj)Rb@!9`|MWfN~&6LRAeO&z0k<6hJH&=4@7ScF8!*bkXzF1!g;@naj-*Iwjf4)b1
zWHnX7kS8i?@@spB@H!<%n9!lKvkNo;O5kT?KG9N1Nchkauk%a9;dC&|+Vp8zkwoGM
zCR(q~Y>jNRZJyPhE_Nk%@7CS3s%C^~~KTOYu
zI`DLd``60bd7)!-QM)<=b#1y-K|w;>omlt_yb3clUXDy9QIyjoEDSW=w@dDe4ZjW%
zL-P()h3Almm{j$al+U|kUoKU$nXEIj-{CQ<*$eP#A#KqzU*uF)
zZSR@a;z~zew~M;RBKmklJ0r#=xk_{xieg#fChm&E!GV6l*`P~2d)w}kx(@Yyj3S&;
z()ib6rQGyH`A1iax#IfGK@`CSL5H0|?%<@-R)=0}rWa@NI7E*FP$T#n1p5Zymnm4d
z1awo2-i^c%C#ggZJ7zuk~UH}@bHn$xjh
z62vC4!^(Z8#Y%m1q&m1`^ZTpl-VAc`++>Np85$<79u+281gG_zJjE2Yu=#p342#+F
zD6W<)lcnSt2N_X0J2Y9PwTxu`!bQ;yI^wutRp!mMjk#%^YYq<=XP0Q2YJqG$20c(|
z$%Ljb-)_V0zgsMCsvWN!534FwFi;6k?uNus8qkeNpTWx+ta`xKDDI;WkKtOkgQIg~
zcC@47OPZYQU|66p%J(2B=|Q5dphNdx`oqDmZ+VGbc8{zyr^Fk$4q~^2OW7RfHSHOH
zZC=?ecq|ND#ms)j%F4D~!83iMAM)qKWTj9XD!|1l`ox~m>tJnMEAaem^_`qbcGJPM
z&6-{G(}f0I=%3cBAK|xuX31hzUzkY^3#*&5oV`GeChCSzTNGHV2_n>o;yT&$o`bcc
z{eJCuK=>LhiHnn5Nq8h*M(#~rw|F5J1(sXJfpL*cbs%!EkOO1yZ#+XV>{OK>Pr+JcRK&8iT}~zv(Cm7*
zPn^f+T@eXa=#uujv^r>Jt5$DZ@GDtblcCeoNo3G)_9^;NgA-^v^6Pjs*qB66qyW=SE9Qv==k$RZdT%xz$T!)zz*k36!r>Q>#xZ
zaiXHNvlp`8?)^2ma2UtqedVsU7sc#D#*?2hX}hUX8Q_LseHE2_QIGLycqR%P(lnQn
zJQ&`d`EJ}+<%q_?2oZbOcj#k0x|FqE9F+|p!FdtagWD<}!#Zx-BgMo`wmU{Q96&BY
z5MAOfJUv$K;Y#3zV5(3P_3~rD!%$SjIZl`RWj|YO#)9*Q$Ey9)@4?!h??&Bn8($qV
z%Uj6sQ1`^c{XRYIW@sbhpea%*F%>x+L`?R#%s0Zfh-}}Q;MW8@m`?6}kjI=aaV4{N
z7@YB_bciV={4^0R$@$9XFh*U@m+Oq!OI^n&1tsTp?644Y{AphDH{E?kL8x2pJHN5g
z&;|L)0qKw^!ZE!P_>0sqI(Dm@kEZBEylfuxAV<-Eyy$o;CH~G8L_$C^jgIh7S~NMK
z*wZIZEiOon`?!t-w;Uy31uktd9kb6$Dr%=|ccY+3=R6zSZy^7j$Zr(Dd+>D%Jwv}N
zw%iLDJu>04fo$Za_Sf&lGC$eZO}J1m3bVVSJe=mB4XlotiP#%|6_BvPT?2v>-?Y
zNxJGmaKmpdtfss^bJ)yM56@yRAChe)W8yQkdfvufAI~7z^v~ZUTp*?nZynDpQYciv
z60OcNRG9?Y9&RAgWV=%z!>3>igSrbfvUX^(Iwm`Z7rc{pzl%%iBbV3h1n9_GZ{t&?
z1VH2!7fd^0={zuhqwR4d&t~67l$#gEq_VZ=wXI}7()BuEO&+jbL~7%blRXt)1uWUy
z30xIhG^tDktb+h0JEURco3zYztzpd9E>qGF;TMEB+zY&FYD{{jgbwD}b8lUWuUXwI
zHd$lR{QTS!PP)m~?5?}Q8*`5CO7zKpioh8B*f&!AW?7Nd5iUD}0&MX`rnld5W)NBA
zuE7D?p#R_Bw8>tjv}(BajW07p3)v=q+1+6J2Q0wm+1yAwDkSRm_DHebc)E=ED&ReO
zbK-I}xqs`Z<3lvQasaW3RAW&*)Z^>Mqrh-w0fP^~-<{oNUq=9H1Po6>_>SAVX4?Kb)MG5!m^n{KisI
zIXn)h9390b#b=qfQj3%RSZe}X+1s^UEBND;Yp#wUoXIX1SZs`IYPPJtu7a$4GRLoy
z2$^By47}fbG9M?O*Jk`ZU|WZ?>eR`eOO&4JnG@#N^~LybZgM}Gqke{DOl2^jQE7=2
z+d%Cz@>J3T*ksoSxkVr152ltpR1req$FiIhWub8U(Er{%3y$7q{$zO3^EiSI*%2dn
zq1Gb~B^!f;M?XL)ORh~IAQ;qxI9jP&c&LIYm~D
zmQNAHQ#R9E`Lxd{su`^cXxYnryuELicMwKUtCM=;^i6|+y%2W7)A7!eNiqXAN1fg^
zhbmRhpVCl^jY>~5Q#&Qkrmiqlwx}wfOIPDmb@Jj>M7Du*Ku5=&hmo;$VjU0vx3s)U
zzhr4nNDd#zr~c9LzV2~hOLdp1n8xRec5^UU99Q?NSp5r=3j#;gUoVs77Uw&Kn;^v0
z0yKku2IsuQvGgC*C3-|D3zHTslX=cd;w5^smTwuilg~I?7`%ApVx&cf&3yHh
z`|59%}92(;FAWT<}!5v8-^S}7S=96cpm-76s
zhEQFDtlbez^V$+7g`$V9$+6JL31$tWnO)KPoJ_Q;*Yq{$DP0!*Wvw{t91va#pD&h^
zWmwoWI}R^zZcvket}SXr^V5iB;e8>Z@x9%RQiRtb`Mu)%w(e)y#3_J-$jLU{?f$CC
z4zC51ccN}@oj6=!%oHz5PtP0{7qAP^au!+rQ=}1FQLA4pR)ogpH;NQ7k#f
zqvPKdYmJrue@(k5nM~(L!dFb=D_zz0WGi`@6s`CCZPr~mg^C8=ormaZ*O8H8Lo`a*
z9D|VbCy)9Nmc`SOvpZ%mtG0LeM|SMpijxR4!liOn)2s6frT*3Y;=TH&n!W6^_TDGO
z!8lYx-Z#|&M-ha%Oc8d1w>_S%A>cTb&)i(`6c!1~3Q$!Yc(NIVZ-z8;-f?VKA`Hc`
zvv=e@V-JJP;g=TClg;Vs%vF{uE#*%5KeTBYq}rt&&NXkAGKTMJSg6f@6fO2ycWh{A
zggt@s_a2h?W3cQ@kBI6L>wH+VF912=5$4y
z8l$EQyQt>6seI>RqfLz;1@lSgupCwJT@vGk>W2*^TsHr<`7QUTNF_=w1u7jw`0KEB
zmNfb*)l2>HN`nJD#8tN4w1i54QDwnv&Gy1;EKqBqu180)$$jV=C?aql-p}$bmxla6
z<}*u`E?tIOc;CU?ao95s4N9Vo6bLtziUN**zka5rT13%8crCC1scgFeVU!A5f%W}L
z)T9N@dTMH__M@Uz!~l{yfS@@%B)*0E|Ld@MNgt
zK>2kt`dIntyJ!JIyZCjZj>YYX_v|6P|^@CxS(SWB4&Y~NoM>8{-|%V
zP*9zn&h|MVX`f7*h1&!BOKoNy=2}$ajLy-9-f@}#5i`unl!s-ee5^^l%1{@@85o|Z
zkMcb3O3IC>bXpKVa}VuQMn_)<3NKavTHRk*5X_ATCPk{VcD80+H~)1+%C_u)
zw$SohBE8Z*O^s2@d}v^Iw)%sFwnxckNI-pEjolw(`m{Jb#}W%3nC3wk$CyMN5F}te
z40x_E^8T6TdsRD@&BE?&X|!b0gxdG$DT7UH3o{*51n1*0ng=qyW&?w+Q0?Z)R+t=t
zdp+a$enT9Rsyu^wWbK`1j8~c+mt71w+E(vW99onR0L-W}6OU$>d)AqzB-0sZUf>Z9
z7+xJff4JAxTlP#cT-AWwdJo2|YoCMHTo&P~x96sQLbY9Ma%gbW?a7w34q92NUd5c;
zleCYPGPeefoM