From bfe07a593736d956c7ea53743d486baa98647f9b Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:14:46 +1000 Subject: [PATCH 01/85] fix(ui-patterns): stabilise multi-select trigger height (#50163) ## What kind of change does this PR introduce? Bug fix and design-system documentation update. ## What is the current behaviour? Multi-select examples inconsistently hide the default trigger icon, which makes the component default difficult to understand from the design-system page. The empty trigger label and selected badges also use different content heights. Selecting or removing the first item causes the default 34px trigger to grow or shrink slightly. ## What is the new behaviour? - Shows the default trigger icon in every existing multi-select example - Adds a dedicated **Without trigger icon** example for `showIcon={false}` - Normalises the label and badge content rows to 20px so the default trigger remains 34px when the first item is selected or removed - Adds regression coverage for the default trigger height classes This follows the 34px trigger sizing introduced in [#48696](https://github.com/supabase/supabase/pull/48696). ## To test 1. Open [Design System > Fragment Components > Multi Select](https://design-system-git-dnywh-fixmulti-select-icon-ex-772e13-supabase.vercel.app/design-system/docs/fragments/multi-select). 2. Confirm every existing example shows the default trigger icon. 3. Confirm the new **Without trigger icon** example is the only example without it. 4. In the first example, select and remove the first fruit. The trigger should remain the same height throughout. ## Summary by CodeRabbit * **New Features** * Added a multi-select example that hides the trigger icon. * Added documentation and a preview for using multi-select without a trigger icon. * **Bug Fixes** * Improved multi-select badge and label alignment. * Preserved consistent trigger height when selecting the first item. * Updated examples to display the trigger icon by default. --- .../content/docs/fragments/multi-select.mdx | 6 +++++ .../example/multi-select-badge-limit-wrap.tsx | 1 - .../example/multi-select-deletable-badge.tsx | 1 - .../default/example/multi-select-demo.tsx | 7 +----- .../example/multi-select-in-dialog.tsx | 2 +- .../multi-select-inline-search-input.tsx | 1 - .../example/multi-select-without-icon.tsx | 25 +++++++++++++++++++ apps/design-system/registry/examples.ts | 5 ++++ .../src/multi-select/multi-select.test.tsx | 24 ++++++++++++++++++ .../src/multi-select/multi-select.tsx | 12 ++++----- 10 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 apps/design-system/registry/default/example/multi-select-without-icon.tsx diff --git a/apps/design-system/content/docs/fragments/multi-select.mdx b/apps/design-system/content/docs/fragments/multi-select.mdx index 1dcb568f23e..73e09df3899 100644 --- a/apps/design-system/content/docs/fragments/multi-select.mdx +++ b/apps/design-system/content/docs/fragments/multi-select.mdx @@ -41,6 +41,12 @@ import { +### Without trigger icon + +Set `showIcon={false}` to hide the trigger icon. + + + ### Badge Combo Box Use `MultiSelectorInput`to add a search input. diff --git a/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx b/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx index a16ac5b47d4..7b1b8b283ad 100644 --- a/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx +++ b/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx @@ -23,7 +23,6 @@ export default function MultiSelectDemo() { label="Select fruits" persistLabel badgeLimit="wrap" - showIcon={false} deletableBadge={false} /> diff --git a/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx b/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx index daf693d3edf..21950f05e61 100644 --- a/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx +++ b/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx @@ -17,7 +17,6 @@ export default function MultiSelectDemo() { label="Select fruits" deletableBadge badgeLimit="wrap" - showIcon={false} /> diff --git a/apps/design-system/registry/default/example/multi-select-demo.tsx b/apps/design-system/registry/default/example/multi-select-demo.tsx index fafed2a1c4c..65668b5e314 100644 --- a/apps/design-system/registry/default/example/multi-select-demo.tsx +++ b/apps/design-system/registry/default/example/multi-select-demo.tsx @@ -25,12 +25,7 @@ export default function MultiSelectDemo() { return ( - + {fruits.map(({ value, isDisabled }) => ( diff --git a/apps/design-system/registry/default/example/multi-select-in-dialog.tsx b/apps/design-system/registry/default/example/multi-select-in-dialog.tsx index a5975fa3aa1..4e44a6ad574 100644 --- a/apps/design-system/registry/default/example/multi-select-in-dialog.tsx +++ b/apps/design-system/registry/default/example/multi-select-in-dialog.tsx @@ -49,7 +49,7 @@ export default function MultiSelectDemo() {
- + {fruits.map(({ value, isDisabled }) => ( diff --git a/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx b/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx index 775762a39f5..08efdb9d7f0 100644 --- a/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx +++ b/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx @@ -18,7 +18,6 @@ export default function MultiSelectDemo() { label="Select fruits" deletableBadge badgeLimit="wrap" - showIcon={false} /> diff --git a/apps/design-system/registry/default/example/multi-select-without-icon.tsx b/apps/design-system/registry/default/example/multi-select-without-icon.tsx new file mode 100644 index 00000000000..64ff5fd4180 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-without-icon.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectWithoutIcon() { + const [selectedValues, setSelectedValues] = useState([]) + + return ( + + + + + Apple + Banana + Cherry + + + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 04b6a3ec5b6..3f5025f384f 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1513,6 +1513,11 @@ export const examples: Registry = [ type: 'components:example', files: ['example/multi-select-disabled.tsx'], }, + { + name: 'multi-select-without-icon', + type: 'components:example', + files: ['example/multi-select-without-icon.tsx'], + }, { name: 'multi-select-badge-limit-wrap', type: 'components:example', diff --git a/packages/ui-patterns/src/multi-select/multi-select.test.tsx b/packages/ui-patterns/src/multi-select/multi-select.test.tsx index 0cb12015c6b..a3278b7d6f6 100644 --- a/packages/ui-patterns/src/multi-select/multi-select.test.tsx +++ b/packages/ui-patterns/src/multi-select/multi-select.test.tsx @@ -61,6 +61,30 @@ describe('multi-select', () => { expect(trigger.querySelector('.lucide-chevron-down')).toHaveAttribute('aria-hidden', 'true') }) + it('keeps the default trigger height stable when the first value is selected', () => { + const { rerender } = render( + undefined}> + + + ) + + const trigger = screen.getByRole('combobox') + expect(trigger).toHaveClass('min-h-[34px]', 'py-1.5') + expect(screen.getByText('Select fruits')).toHaveClass('leading-5') + + rerender( + undefined}> + + + ) + + expect(screen.getByText('Apple').closest('[class*=rounded]')).toHaveClass( + 'text-xs/none', + 'py-[3px]' + ) + expect(trigger).toHaveClass('min-h-[34px]', 'py-1.5') + }) + it('renders selected values with a custom label', () => { render( undefined}> diff --git a/packages/ui-patterns/src/multi-select/multi-select.tsx b/packages/ui-patterns/src/multi-select/multi-select.tsx index 01de5c3354a..6771530488d 100644 --- a/packages/ui-patterns/src/multi-select/multi-select.tsx +++ b/packages/ui-patterns/src/multi-select/multi-select.tsx @@ -262,11 +262,11 @@ const MultiSelectorBadgesVariants = cva('flex overflow-hidden flex-1 min-w-0', { }) const MultiSelectorBadgeVariants = cva( - 'rounded-sm shrink-0 px-1.5 bg-surface-75 dark:bg-white/5 normal-case tracking-normal text-xs', + 'rounded-sm shrink-0 px-1.5 bg-surface-75 dark:bg-white/5 normal-case tracking-normal text-xs/none', { variants: { size: { - tiny: 'h-full py-0 leading-none', + tiny: 'h-full py-0', small: '', medium: '', large: '', @@ -285,10 +285,10 @@ const MultiSelectorLabelVariants = cva( variants: { size: { tiny: 'leading-none', - small: 'ml-1 leading-5.5', - medium: 'ml-1 leading-5.5', - large: 'ml-1 leading-5.5', - xlarge: 'ml-1 leading-5.5', + small: 'ml-1 leading-5', + medium: 'ml-1 leading-5', + large: 'ml-1 leading-5', + xlarge: 'ml-1 leading-5', }, }, defaultVariants: { From e351e8a1a44541c8e7e6dd8e778fc87092b0b2c5 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:07:35 +1000 Subject: [PATCH 02/85] test(studio): scope realtime broadcast helper (#50164) 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 ## What is the current behavior? The Realtime Inspector has two buttons named “Broadcast a message” after joining a channel: one in the listening status toolbar and one in the empty grid state. The shared E2E helper matches both and fails with a Playwright strict mode violation. ## What is the new behavior? The helper scopes the action to the listening status region, where the persistent broadcast control lives. ## To test Run the Realtime Inspector E2E tests: ```bash pnpm --prefix e2e/studio run e2e -- features/realtime-inspector.spec.ts ## Summary by CodeRabbit * **Tests** * Improved end-to-end test reliability when opening the broadcast message modal. --- e2e/studio/utils/realtime-helpers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/studio/utils/realtime-helpers.ts b/e2e/studio/utils/realtime-helpers.ts index e1729f2c8f7..d58dba5397f 100644 --- a/e2e/studio/utils/realtime-helpers.ts +++ b/e2e/studio/utils/realtime-helpers.ts @@ -36,7 +36,9 @@ export async function stopListening(page: Page) { } export async function openBroadcastModal(page: Page) { - const broadcastButton = page.getByRole('button', { name: 'Broadcast a message' }) + const broadcastButton = page + .getByRole('status') + .getByRole('button', { name: 'Broadcast a message' }) await expect(broadcastButton).toBeVisible({ timeout: 5000 }) await broadcastButton.click() await expect(page.getByText('Broadcast a message to all clients')).toBeVisible({ timeout: 5000 }) From 957f84b17ca118e8fdf27e78760188bbd0b0544a Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 9 Sep 2026 14:37:32 +0800 Subject: [PATCH 03/85] Joshenlim/fe 4348 find an alternative to the jsr stdpath dependency (#50111) ## Context Drops the `@std/path` dependency which is used in `EdgeFunctions.utils` as `npm.jsr.io` was putting up a Cloudflare bot challenge on some connections which blocks `pnpm install`. Instead, opting to directly port the exact required methods as self-contained functions. Also added some unit tests to check that UI behaviour remains status quo. ## To test: Important to test that everything in the edge functions UI remains status quo - [ ] Open an existing edge function with a single root-level file - should load as expected image - [ ] Open (or create) an edge function with nested folders - confirm file paths in the editor are shown correctly - Can create nested folders by using `../` as such image - [ ] Similarly, download the edge function as ZIP to verify that the nested folders are all correctly located - [ ] Open a function with `import_map.json` - confirm still detected as import map through the network tab GET request for the edge function code (Examples here with and without import map) image image - [ ] Deploy a new function via the editor - [ ] Update an existing function via the editor (Test adding new files etc) ## Summary by CodeRabbit - **Bug Fixes** - Improved Edge Functions file path handling across supported application environments. - Nested entrypoints, URL-based entrypoints, root-level files, and unmatched paths are now handled consistently. - Generated files retain their content and receive sequential identifiers reliably. - Improved compatibility when processing and displaying files in different application environments. - **Tests** - Added coverage for entrypoint path formatting, relative paths, fallback behavior, unchanged paths, and identifier assignment. --- .npmrc | 1 - .../EdgeFunctions/EdgeFunctions.utils.test.ts | 72 +++++++ .../EdgeFunctions/EdgeFunctions.utils.ts | 198 +++++++++++++++++- apps/studio/package.json | 1 - pnpm-lock.yaml | 8 - 5 files changed, 267 insertions(+), 13 deletions(-) delete mode 100644 .npmrc create mode 100644 apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.test.ts diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 41583e36ca8..00000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@jsr:registry=https://npm.jsr.io diff --git a/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.test.ts b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.test.ts new file mode 100644 index 00000000000..89d299dd0d4 --- /dev/null +++ b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import { formatFunctionBodyToFiles } from './EdgeFunctions.utils' + +describe('formatFunctionBodyToFiles', () => { + it('returns no files when there is no entrypoint path', () => { + const files = [{ name: 'index.ts', content: 'a' }] + const result = formatFunctionBodyToFiles({ functionBody: { files, metadata: {} } }) + expect(result).toEqual([]) + }) + + it('rewrites nested file paths relative to a nested entrypoint', () => { + const files = [ + { name: 'functions/hello/index.ts', content: 'a' }, + { name: 'functions/hello/utils/helper.ts', content: 'b' }, + ] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: { deno2_entrypoint_path: 'functions/hello/index.ts' } }, + }) + expect(result.map((f) => f.name)).toEqual(['index.ts', 'utils/helper.ts']) + }) + + it('leaves file paths unmodified when the entrypoint is a bare filename at the root', () => { + const files = [{ name: 'index.ts', content: 'a' }] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: { deno2_entrypoint_path: 'index.ts' } }, + }) + expect(result.map((f) => f.name)).toEqual(['index.ts']) + }) + + it('falls back to parsing a URL entrypoint when no file name matches', () => { + const files = [ + { name: 'functions/hello/index.ts', content: 'a' }, + { name: 'functions/hello/utils/helper.ts', content: 'b' }, + ] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: {} }, + entrypointPath: 'https://edge.supabase.com/deploy/abc123/main.ts', + }) + // the URL's parsed base path ('/deploy/abc123') shares no common prefix with + // the relative file names, so they're left unmodified (per commonPath) + expect(result.map((f) => f.name)).toEqual([ + 'functions/hello/index.ts', + 'functions/hello/utils/helper.ts', + ]) + }) + + it('leaves a file unmodified when it shares no common path with the base path', () => { + const files = [ + { name: 'functions/hello/index.ts', content: 'a' }, + { name: 'unrelated/other.ts', content: 'b' }, + ] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: { deno2_entrypoint_path: 'functions/hello/index.ts' } }, + }) + expect(result.map((f) => f.name)).toEqual(['index.ts', 'unrelated/other.ts']) + }) + + it('assigns sequential ids and preserves content', () => { + const files = [ + { name: 'a.ts', content: 'foo' }, + { name: 'b.ts', content: 'bar' }, + ] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: { deno2_entrypoint_path: 'a.ts' } }, + }) + expect(result).toEqual([ + { id: 1, name: 'a.ts', content: 'foo', state: 'unchanged' }, + { id: 2, name: 'b.ts', content: 'bar', state: 'unchanged' }, + ]) + }) +}) diff --git a/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts index 0eb953d73cf..ddba382e9d8 100644 --- a/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts +++ b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts @@ -1,8 +1,200 @@ -import { common, dirname, relative } from '@std/path/posix' - import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types' import { EdgeFunctionBodyData } from '@/data/edge-functions/edge-function-body-query' +// This file is bundled for both the browser (Next.js/webpack) and the TanStack Start (Vite) client, +// which handle Node.js builtins differently — Vite stubs `path` out entirely for the browser, so +// `path.posix` is undefined at runtime there even though it type-checks and builds fine under webpack. +// These are self-contained posix path helpers (no `path` import) so behavior is identical everywhere. +// Ported from the POSIX implementations in @std/path (JSR), which this file used to depend on directly. + +function isPosixSeparator(code: number | undefined): boolean { + return code === 47 /* '/' */ +} + +function stripTrailingSeparators(segment: string): string { + if (segment.length <= 1) return segment + let end = segment.length + for (let i = segment.length - 1; i > 0; i--) { + if (isPosixSeparator(segment.charCodeAt(i))) end = i + else break + } + return segment.slice(0, end) +} + +// Resolves "." and ".." segments in a posix path. +function normalizeString(path: string, allowAboveRoot: boolean): string { + let res = '' + let lastSegmentLength = 0 + let lastSlash = -1 + let dots = 0 + let code: number | undefined + + for (let i = 0; i <= path.length; ++i) { + if (i < path.length) code = path.charCodeAt(i) + else if (isPosixSeparator(code)) break + else code = 47 + + if (isPosixSeparator(code)) { + if (lastSlash === i - 1 || dots === 1) { + // noop + } else if (lastSlash !== i - 1 && dots === 2) { + if ( + res.length < 2 || + lastSegmentLength !== 2 || + res.charCodeAt(res.length - 1) !== 46 /* '.' */ || + res.charCodeAt(res.length - 2) !== 46 /* '.' */ + ) { + if (res.length > 2) { + const lastSlashIndex = res.lastIndexOf('/') + if (lastSlashIndex === -1) { + res = '' + lastSegmentLength = 0 + } else { + res = res.slice(0, lastSlashIndex) + lastSegmentLength = res.length - 1 - res.lastIndexOf('/') + } + lastSlash = i + dots = 0 + continue + } else if (res.length === 2 || res.length === 1) { + res = '' + lastSegmentLength = 0 + lastSlash = i + dots = 0 + continue + } + } + if (allowAboveRoot) { + res += res.length > 0 ? '/..' : '..' + lastSegmentLength = 2 + } + } else { + res += res.length > 0 ? '/' + path.slice(lastSlash + 1, i) : path.slice(lastSlash + 1, i) + lastSegmentLength = i - lastSlash - 1 + } + lastSlash = i + dots = 0 + } else if (code === 46 /* '.' */ && dots !== -1) { + ++dots + } else { + dots = -1 + } + } + + return res +} + +// Normalizes an absolute posix path (resolves "." / ".."). Assumes `path` starts with "/". +function resolveAbsolute(path: string): string { + const normalized = normalizeString(path, false) + return normalized.length > 0 ? '/' + normalized : '/' +} + +function dirname(path: string): string { + if (path.length === 0) return '.' + + let end = -1 + let matchedNonSeparator = false + for (let i = path.length - 1; i >= 1; --i) { + if (isPosixSeparator(path.charCodeAt(i))) { + if (matchedNonSeparator) { + end = i + break + } + } else { + matchedNonSeparator = true + } + } + + if (end === -1) return isPosixSeparator(path.charCodeAt(0)) ? '/' : '.' + return stripTrailingSeparators(path.slice(0, end)) +} + +// `from`/`to` must be absolute (leading "/"). Returns the relative path from `from` to `to`. +function relative(from: string, to: string): string { + from = resolveAbsolute(from) + to = resolveAbsolute(to) + if (from === to) return '' + + let fromStart = 1 + const fromEnd = from.length + for (; fromStart < fromEnd; ++fromStart) { + if (!isPosixSeparator(from.charCodeAt(fromStart))) break + } + const fromLen = fromEnd - fromStart + + let toStart = 1 + const toEnd = to.length + for (; toStart < toEnd; ++toStart) { + if (!isPosixSeparator(to.charCodeAt(toStart))) break + } + const toLen = toEnd - toStart + + const length = fromLen < toLen ? fromLen : toLen + let lastCommonSep = -1 + let i = 0 + for (; i <= length; ++i) { + if (i === length) { + if (toLen > length) { + if (isPosixSeparator(to.charCodeAt(toStart + i))) { + return to.slice(toStart + i + 1) + } else if (i === 0) { + return to.slice(toStart + i) + } + } else if (fromLen > length) { + if (isPosixSeparator(from.charCodeAt(fromStart + i))) { + lastCommonSep = i + } else if (i === 0) { + lastCommonSep = 0 + } + } + break + } + const fromCode = from.charCodeAt(fromStart + i) + const toCode = to.charCodeAt(toStart + i) + if (fromCode !== toCode) break + else if (isPosixSeparator(fromCode)) lastCommonSep = i + } + + let out = '' + for (i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i) { + if (i === fromEnd || isPosixSeparator(from.charCodeAt(i))) { + out += out.length === 0 ? '..' : '/..' + } + } + + if (out.length > 0) return out + to.slice(toStart + lastCommonSep) + + let start = toStart + lastCommonSep + if (isPosixSeparator(to.charCodeAt(start))) ++start + return to.slice(start) +} + +// Longest common path prefix across `paths`, comparing "/"-separated segments. +function commonPath(paths: string[]): string { + const [first = '', ...rest] = paths + const parts = first.split('/') + let endOfPrefix = parts.length + let append = '' + + for (const p of rest) { + const compare = p.split('/') + if (compare.length <= endOfPrefix) { + endOfPrefix = compare.length + append = '' + } + for (let i = 0; i < endOfPrefix; i++) { + if (compare[i] !== parts[i]) { + endOfPrefix = i + append = i === 0 ? '' : '/' + break + } + } + } + + return parts.slice(0, endOfPrefix).join('/') + append +} + export const getFallbackImportMapPath = (files: Omit[]) => { // try to find a deno.json or import_map.json file const regex = /^.*?(deno|import_map).json*$/i @@ -77,7 +269,7 @@ export const formatFunctionBodyToFiles = ({ try { // if the current file and base path doesn't share a common path, // return unmodified file - const common_path = common([base_path, file.name]) + const common_path = commonPath([base_path, file.name]) if (common_path === '' || common_path === '/tmp/') { return file } diff --git a/apps/studio/package.json b/apps/studio/package.json index 4603dac8d13..269958acd38 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -66,7 +66,6 @@ "@sentry/react": "^10.27.0", "@sentry/tanstackstart-react": "catalog:", "@smithy/property-provider": "^4.0.4", - "@std/path": "npm:@jsr/std__path@^1.0.8", "@stripe/react-stripe-js": "6.1.0", "@stripe/stripe-js": "9.1.0", "@stripe/sync-engine": "1.0.32", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 619b39d6aa7..12f82101721 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1008,9 +1008,6 @@ importers: '@smithy/property-provider': specifier: ^4.0.4 version: 4.0.4 - '@std/path': - specifier: npm:@jsr/std__path@^1.0.8 - version: '@jsr/std__path@1.0.8' '@stripe/react-stripe-js': specifier: 6.1.0 version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -5175,9 +5172,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jsr/std__path@1.0.8': - resolution: {integrity: sha512-eNBGlh/8ZVkMxtFH4bwIzlAeKoHYk5in4wrBZhi20zMdOiuX4QozP4+19mIXBT2lzHDjhuVLyECbhFeR304iDg==, tarball: https://npm.jsr.io/~/11/@jsr/std__path/1.0.8.tgz} - '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -21295,8 +21289,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jsr/std__path@1.0.8': {} - '@kwsites/file-exists@1.1.1(supports-color@8.1.1)': dependencies: debug: 4.4.3(supports-color@8.1.1) From dc16371a4736cc0c1e76349b17fe2ab6a9368d26 Mon Sep 17 00:00:00 2001 From: Riccardo Busetti Date: Wed, 9 Sep 2026 08:56:12 +0200 Subject: [PATCH 04/85] feat(studio): configure BigQuery table layout (#49535) ## What kind of change does this PR introduce? Feature. The last remaining piece of this PR's original scope, rebased onto current `master`. ## What is the current behavior? A BigQuery pipeline replicates every published table into a flat destination table. There is no way to say how those tables should be laid out in BigQuery, so partitioning and clustering have to be applied by hand after the fact, and are lost whenever a destination table is reset. The rest of this PR's original scope has since merged separately: #49841, #49842, #49843, #49844 and #49845. The branch now carries only the table layout work, and adds nothing to `packages/api-types`. It consumes the `table_options` contract that #49844 already brought in. ## What is the new behavior? The BigQuery destination form gains a "Table layout" section under Advanced settings. Every table in the selected publication appears as a row, and expanding one reveals optional partitioning and clustering: - Time-column partitioning, by hour, day, month or year - Integer-range partitioning, with start, end and interval - Ingestion-time partitioning - Clustering, up to four columns Rows are always present, so nothing implies a table can be excluded here. A collapsed row summarises what is applied: `Not configured`, `Daily by created_at`, `Integer range by id`, plus clustering counts. `Not configured` is dimmed a step further so configured rows are what the eye lands on in a long publication. Notes on behavior: - Partition columns are resolved from the published table's real columns, filtered to the types BigQuery accepts for each partition mode. - A row you expand but leave empty is dropped from the payload rather than saved half-configured. Choosing a partition mode without a column is a validation error, not a silent drop. - Clear returns a row to `Not configured` and keeps it in the list. Remove is reserved for stale configuration whose table has left the publication. - Updates send `null` to clear previously stored table options, since omitting the property leaves the stored value unchanged. - Layout applies when a destination table is first created or reset, matching the backend. Roughly 40% of the diff is tests. The bulk of the rest is four new files under `DestinationForm/BigQuery`, which are all one feature. The three commits are readable in order: types and payload builders, then the columns query, then the UI. ## To test Open a project's Database > Replication, then create or edit a BigQuery pipeline. 1. Expand Advanced settings. "Table layout" lists every table in the selected publication. 2. Expand a row, set Partition by to Time column and pick a column. Collapse. The row reads `Daily by `. Hit Clear. The row returns to `Not configured` and stays in the list. 3. Set Partition by to Time column, leave Partition column empty, collapse, and Save. The row explains "Select a partition column" in red rather than saving and silently dropping it. 4. Expand a row and add clustering columns. The fifth is refused. 5. Switch publications and watch the loading state. The row list should not jump size when it resolves. 6. Narrow the sheet. The integer range Start, End and Interval fields should reflow rather than stay in three columns. ## Summary by CodeRabbit * **New Features** * BigQuery replication destinations now support per-table layout settings. * Configure partitioning by time column, integer range, or ingestion time. * Configure up to four clustering columns per table. * View available columns and validation feedback while editing table layouts. * Table settings are preserved when editing destinations and cleaned up when publications change. * **Bug Fixes** * Improved handling of invalid, unavailable, or removed table and column configurations. --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com> Co-authored-by: Joshen Lim --- .../DestinationForm/AdvancedSettings.tsx | 12 + .../BigQuery/BigQuery.schema.test.ts | 100 ++++ .../BigQuery/BigQuery.schema.ts | 75 +++ .../BigQuery/BigQuery.utils.test.ts | 109 ++++ .../BigQuery/BigQuery.utils.ts | 50 +- .../BigQuery/TableOptionFields.tsx | 332 ++++++++++++ .../BigQuery/TableOptionRow.tsx | 259 +++++++++ .../BigQuery/TableOptions.test.tsx | 505 ++++++++++++++++++ .../DestinationForm/BigQuery/TableOptions.tsx | 304 +++++++++++ .../BigQuery/TableOptions.utils.test.ts | 116 ++++ .../BigQuery/TableOptions.utils.ts | 110 ++++ .../DestinationForm.utils.test.ts | 45 ++ .../DestinationForm/DestinationForm.utils.ts | 56 ++ .../DestinationForm/PublicationSelection.tsx | 4 + .../DestinationForm/index.tsx | 10 + ...eate-destination-pipeline-mutation.test.ts | 120 ++++- .../create-destination-pipeline-mutation.ts | 16 +- apps/studio/data/replication/keys.ts | 5 + .../data/replication/table-columns-query.ts | 59 ++ apps/studio/data/replication/types.ts | 37 ++ .../update-destination-pipeline-mutation.ts | 18 +- apps/studio/data/replication/utils.ts | 55 ++ 22 files changed, 2390 insertions(+), 7 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.schema.test.ts create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.test.ts create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionFields.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionRow.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.test.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.test.ts create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.ts create mode 100644 apps/studio/data/replication/table-columns-query.ts 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 c3c4ae5b85e..472e5b427ef 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx @@ -19,6 +19,7 @@ import { import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { DestinationType } from '../DestinationPanel.types' +import { TableOptions } from './BigQuery/TableOptions' import { DEFAULT_CONNECTION_POOL_SIZE, DEFAULT_MAX_COPY_CONNECTIONS_PER_TABLE, @@ -237,6 +238,17 @@ export const AdvancedSettings = ({ )} /> + +
+
+ Table layout +

+ Partitioning and clustering for each BigQuery table. Applied when a + destination table is first created or reset. +

+
+ +
)} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.schema.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.schema.test.ts new file mode 100644 index 00000000000..a5690c79acd --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.schema.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' + +import { BigQueryPartitionBySchema, BigQueryTableOptionSchema } from './BigQuery.schema' + +describe('BigQuery replication schemas', () => { + it('accepts integer ranges at the JavaScript safe-integer boundary', () => { + expect( + BigQueryPartitionBySchema.safeParse({ + kind: 'integer_range', + column: 'account_id', + start: Number.MIN_SAFE_INTEGER, + end: Number.MAX_SAFE_INTEGER, + interval: 1, + }).success + ).toBe(true) + }) + + it('rejects integer ranges that cannot be represented exactly by JavaScript', () => { + expect( + BigQueryPartitionBySchema.safeParse({ + kind: 'integer_range', + column: 'account_id', + start: 0, + end: Number.MAX_SAFE_INTEGER + 1, + interval: 1, + }).success + ).toBe(false) + }) + + it('rejects table ids outside the PostgreSQL OID range', () => { + expect(BigQueryTableOptionSchema.safeParse({ tableId: 4_294_967_296 }).success).toBe(false) + }) + + it('allows a selected table with no partitioning or clustering', () => { + expect(BigQueryTableOptionSchema.safeParse({ tableId: 1 }).success).toBe(true) + }) + + it('rejects a column-based partition with no column instead of dropping it on save', () => { + for (const partitionBy of [ + { kind: 'time_column', column: '', granularity: 'day' }, + { kind: 'integer_range', column: '', start: 0, end: 10, interval: 1 }, + ]) { + const result = BigQueryTableOptionSchema.safeParse({ tableId: 1, partitionBy }) + + expect(result.success).toBe(false) + expect(result.error?.issues.map(({ message }) => message)).toContain( + 'Select a partition column' + ) + } + }) + + it('requires the integer range end to be greater than the start', () => { + const result = BigQueryTableOptionSchema.safeParse({ + tableId: 1, + partitionBy: { kind: 'integer_range', column: 'shard', start: 10, end: 10, interval: 1 }, + }) + + expect(result.error?.issues).toContainEqual( + expect.objectContaining({ + path: ['partitionBy', 'end'], + message: 'End must be greater than start.', + }) + ) + }) + + it('requires a positive integer range interval', () => { + const result = BigQueryTableOptionSchema.safeParse({ + tableId: 1, + partitionBy: { kind: 'integer_range', column: 'shard', start: 0, end: 100, interval: 0 }, + }) + + expect(result.error?.issues).toContainEqual( + expect.objectContaining({ + path: ['partitionBy', 'interval'], + message: 'Interval must be greater than 0.', + }) + ) + }) + + it('allows at most four clustering columns', () => { + expect( + BigQueryTableOptionSchema.safeParse({ + tableId: 1, + clusterBy: ['one', 'two', 'three', 'four'], + }).success + ).toBe(true) + + const result = BigQueryTableOptionSchema.safeParse({ + tableId: 1, + clusterBy: ['one', 'two', 'three', 'four', 'five'], + }) + + expect(result.error?.issues).toContainEqual( + expect.objectContaining({ + path: ['clusterBy'], + message: 'Select up to 4 clustering columns', + }) + ) + }) +}) 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 e7dcc7a3570..6756095fa15 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,9 +1,83 @@ import * as z from 'zod' +import { + BIGQUERY_MAX_CLUSTERING_COLUMNS, + BIGQUERY_TIME_PARTITION_GRANULARITIES, +} from '@/data/replication/types' import { optionalNumberInputSchema, requiredNumberInputSchema } from '@/lib/forms/zod-number-input' const CONNECTION_POOL_SIZE_MIN_ERROR = 'Connection pool size must be greater than 0.' +const BigQueryTimePartitionGranularitySchema = z.enum(BIGQUERY_TIME_PARTITION_GRANULARITIES) +const integerRangeValue = (label: string) => + z + .union([ + z.literal(''), + z.number().int(`${label} must be a whole number`).safe(`${label} must be a safe integer`), + ]) + .refine((value): boolean => value !== '', `${label} is required`) + +// A column-based partition is dropped from the save payload when its column is missing, so +// require one here rather than letting the form accept a layout it will silently discard. +const partitionColumn = z.string().min(1, 'Select a partition column') + +export const BigQueryPartitionBySchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('time_column'), + column: partitionColumn, + granularity: BigQueryTimePartitionGranularitySchema.optional(), + }), + z.object({ + kind: z.literal('integer_range'), + column: partitionColumn, + start: integerRangeValue('Start'), + end: integerRangeValue('End'), + interval: integerRangeValue('Interval'), + }), + z.object({ + kind: z.literal('ingestion_time'), + granularity: BigQueryTimePartitionGranularitySchema.optional(), + }), +]) + +export const BigQueryTableOptionSchema = z + .object({ + tableId: z.number().int().nonnegative().max(4_294_967_295), + partitionBy: BigQueryPartitionBySchema.optional(), + clusterBy: z + .array(z.string()) + .max( + BIGQUERY_MAX_CLUSTERING_COLUMNS, + `Select up to ${BIGQUERY_MAX_CLUSTERING_COLUMNS} clustering columns` + ) + .optional(), + }) + .superRefine((option, ctx) => { + if (option.partitionBy?.kind !== 'integer_range') return + if (option.partitionBy.column.trim().length === 0) return + + const { start, end, interval } = option.partitionBy + if (typeof start !== 'number' || typeof end !== 'number' || typeof interval !== 'number') { + return + } + + if (start >= end) { + ctx.addIssue({ + code: 'custom', + path: ['partitionBy', 'end'], + message: 'End must be greater than start.', + }) + } + + if (interval <= 0) { + ctx.addIssue({ + code: 'custom', + path: ['partitionBy', 'interval'], + message: 'Interval must be greater than 0.', + }) + } + }) + export const BigQueryFormSchema = z.object({ projectId: z.string().optional(), datasetId: z.string().optional(), @@ -23,4 +97,5 @@ export const BigQueryFormSchema = z.object({ .int('Maximum staleness must be a whole number of minutes.') .min(0, 'Maximum staleness must be 0 or greater.') ), + tableOptions: z.array(BigQueryTableOptionSchema).optional(), }) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.test.ts new file mode 100644 index 00000000000..321f805152b --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { + defaultPartitionByForKind, + getBigQueryValidationIssues, + parseIntegerInput, + shortenPgType, +} from './BigQuery.utils' + +describe('getBigQueryValidationIssues', () => { + const validData = { + projectId: 'my-project', + datasetId: 'analytics', + serviceAccountKey: '{}', + } + + it('returns no issues when all required fields are set', () => { + expect(getBigQueryValidationIssues(validData)).toEqual([]) + }) + + it('flags blank required fields', () => { + expect( + getBigQueryValidationIssues({ projectId: '', datasetId: ' ', serviceAccountKey: '{}' }) + ).toEqual([ + { path: 'projectId', message: 'Project ID is required.' }, + { path: 'datasetId', message: 'Dataset ID is required.' }, + ]) + }) + + it('skips the service account key when secrets are optional (edit mode)', () => { + expect( + getBigQueryValidationIssues( + { ...validData, serviceAccountKey: '' }, + { secretsOptional: true } + ) + ).toEqual([]) + }) +}) + +describe('shortenPgType', () => { + it('shortens known verbose type names to their standard alias', () => { + expect(shortenPgType('timestamp with time zone')).toBe('timestamptz') + expect(shortenPgType('timestamp without time zone')).toBe('timestamp') + expect(shortenPgType('character varying')).toBe('varchar') + expect(shortenPgType('double precision')).toBe('float8') + }) + + it('returns unrecognized types unchanged', () => { + expect(shortenPgType('bigint')).toBe('bigint') + expect(shortenPgType('jsonb')).toBe('jsonb') + }) +}) + +describe('defaultPartitionByForKind', () => { + it('returns undefined for "none"', () => { + expect(defaultPartitionByForKind('none')).toBeUndefined() + }) + + it('defaults time_column with an empty column and day granularity', () => { + expect(defaultPartitionByForKind('time_column')).toEqual({ + kind: 'time_column', + column: '', + granularity: 'day', + }) + }) + + it('defaults integer_range with a 0-100 range and interval of 10', () => { + expect(defaultPartitionByForKind('integer_range')).toEqual({ + kind: 'integer_range', + column: '', + start: 0, + end: 100, + interval: 10, + }) + }) + + it('defaults ingestion_time with day granularity', () => { + expect(defaultPartitionByForKind('ingestion_time')).toEqual({ + kind: 'ingestion_time', + granularity: 'day', + }) + }) +}) + +describe('parseIntegerInput', () => { + it('parses a valid integer string', () => { + expect(parseIntegerInput('42', 0)).toBe(42) + }) + + it('parses a negative number', () => { + expect(parseIntegerInput('-5', 0)).toBe(-5) + }) + + it('uses the empty sentinel for an empty string', () => { + expect(parseIntegerInput('', 7)).toBe('') + }) + + it('uses the empty sentinel for whitespace-only input', () => { + expect(parseIntegerInput(' ', 7)).toBe('') + }) + + it('keeps the previous value for non-numeric input', () => { + expect(parseIntegerInput('abc', 7)).toBe(7) + }) + + it('uses the empty sentinel while a negative sign is typed alone', () => { + expect(parseIntegerInput('-', 7)).toBe('') + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts index 423632f5c4f..51bb86c3f32 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts @@ -1,9 +1,51 @@ import { type DestinationPanelSchemaType } from '../DestinationForm.schema' +import { BigQueryPartitionBy } from '@/data/replication/types' type BigQueryFieldPath = 'projectId' | 'datasetId' | 'serviceAccountKey' -export type BigQueryValidationIssue = { - path: BigQueryFieldPath +export type BigQueryPartitionKind = BigQueryPartitionBy['kind'] | 'none' + +// Postgres' verbose spelling for a handful of common types, shown here as their much shorter, +// equally standard aliases so a column's type doesn't crowd out its name in a narrow picker. +const PG_TYPE_ALIASES: Record = { + 'timestamp with time zone': 'timestamptz', + 'timestamp without time zone': 'timestamp', + 'time with time zone': 'timetz', + 'time without time zone': 'time', + 'character varying': 'varchar', + 'double precision': 'float8', +} + +export const shortenPgType = (type: string) => PG_TYPE_ALIASES[type] ?? type + +export const defaultPartitionByForKind = ( + kind: BigQueryPartitionKind +): BigQueryPartitionBy | undefined => { + switch (kind) { + case 'none': + return undefined + case 'time_column': + return { kind: 'time_column', column: '', granularity: 'day' } + case 'integer_range': + return { kind: 'integer_range', column: '', start: 0, end: 100, interval: 10 } + case 'ingestion_time': + return { kind: 'ingestion_time', granularity: 'day' } + } +} + +// Parses a partition start/end/interval input. Empty and a lone minus stay as the empty +// sentinel so the field remains controlled while the user is still typing. Other invalid +// drafts keep the previous committed value. +export const parseIntegerInput = (value: string, previous: number | ''): number | '' => { + const trimmed = value.trim() + if (trimmed === '' || trimmed === '-') return '' + if (!/^-?\d+$/.test(trimmed)) return previous + const parsed = Number(trimmed) + return Number.isSafeInteger(parsed) ? parsed : previous +} + +export type BigQueryValidationIssue = { + path: Path message: string } @@ -29,9 +71,9 @@ const isValidJsonString = (value: string) => { export const getBigQueryValidationIssues = ( data: Pick, options: { secretsOptional?: boolean; validateJson?: boolean } = {} -): BigQueryValidationIssue[] => { +): BigQueryValidationIssue[] => { const { secretsOptional = false, validateJson = true } = options - const issues: BigQueryValidationIssue[] = BIGQUERY_REQUIRED_FIELDS.filter( + const issues: BigQueryValidationIssue[] = BIGQUERY_REQUIRED_FIELDS.filter( ({ path }) => !data[path]?.trim().length ).map(({ path, message }) => ({ path, message })) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionFields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionFields.tsx new file mode 100644 index 00000000000..40860b2acbd --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionFields.tsx @@ -0,0 +1,332 @@ +import type { ChangeEvent } from 'react' +import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { MultiSelector } from 'ui-patterns/multi-select' +import { SelectionListState } from 'ui-patterns/SelectionListState' + +import { defaultPartitionByForKind, parseIntegerInput, shortenPgType } from './BigQuery.utils' +import type { BigQueryPartitionKind } from './BigQuery.utils' +import { isPartitionColumnTypeCompatible } from './TableOptions.utils' +import { + BIGQUERY_MAX_CLUSTERING_COLUMNS, + BIGQUERY_TIME_PARTITION_GRANULARITIES, + type BigQueryPartitionBy, + type BigQueryTimePartitionGranularity, +} from '@/data/replication/types' + +const PARTITION_KIND_LABELS: Record = { + none: 'None', + time_column: 'Time column', + integer_range: 'Integer range', + ingestion_time: 'Ingestion time', +} + +const GRANULARITY_LABELS: Record = { + hour: 'Hour', + day: 'Day', + month: 'Month', + year: 'Year', +} + +const ColumnOption = ({ + name, + type, + isUnavailable = false, +}: { + name: string + type?: string + isUnavailable?: boolean +}) => ( + + {name} + {isUnavailable && (no longer available)} + {type && ( + + {shortenPgType(type)} + + )} + +) + +export interface ColumnSelectionState { + availableColumnNames: string[] + columnTypeByName: ReadonlyMap + isError: boolean + isLoading: boolean + onOpenChange: (isOpen: boolean) => void + unavailableColumnSet: ReadonlySet +} + +interface PartitioningFieldsProps { + columnSelection: ColumnSelectionState + errors: { + column?: string + end?: string + interval?: string + start?: string + } + onChange: (partitionBy: BigQueryPartitionBy | undefined) => void + onNeedsColumns: () => void + partitionBy: BigQueryPartitionBy | undefined +} + +export const PartitioningFields = ({ + columnSelection, + errors, + onChange, + onNeedsColumns, + partitionBy, +}: PartitioningFieldsProps) => { + const partitionKind: BigQueryPartitionKind = partitionBy?.kind ?? 'none' + + const compatibleColumnNames = + partitionBy && (partitionBy.kind === 'time_column' || partitionBy.kind === 'integer_range') + ? columnSelection.availableColumnNames.filter((column) => + isPartitionColumnTypeCompatible( + partitionBy.kind, + columnSelection.columnTypeByName.get(column) + ) + ) + : columnSelection.availableColumnNames + const compatibleColumnEmptyLabel = + partitionBy?.kind === 'time_column' + ? 'No published date or timestamp columns' + : 'No published integer columns' + + return ( + <> + + + + + {partitionBy && 'column' in partitionBy && ( + + + + )} + + {partitionBy && 'granularity' in partitionBy && ( + + + + )} + + {partitionBy?.kind === 'integer_range' && ( +
+ + ) => + onChange({ + ...partitionBy, + start: parseIntegerInput(event.target.value, partitionBy.start), + }) + } + /> + + + ) => + onChange({ + ...partitionBy, + end: parseIntegerInput(event.target.value, partitionBy.end), + }) + } + /> + + + ) => + onChange({ + ...partitionBy, + interval: parseIntegerInput(event.target.value, partitionBy.interval), + }) + } + /> + +
+ )} + + ) +} + +interface ClusteringFieldsProps { + clusterBy: string[] + columnSelection: ColumnSelectionState + error?: string + onChange: (columns: string[]) => void +} + +export const ClusteringFields = ({ + clusterBy, + columnSelection, + error, + onChange, +}: ClusteringFieldsProps) => { + const hasReachedColumnLimit = clusterBy.length >= BIGQUERY_MAX_CLUSTERING_COLUMNS + + return ( + + { + if (columns.length <= BIGQUERY_MAX_CLUSTERING_COLUMNS) onChange(columns) + }} + onOpenChange={columnSelection.onOpenChange} + > + ( + + )} + /> + + + {columnSelection.availableColumnNames.map((column) => ( + + + + ))} + + + + + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionRow.tsx new file mode 100644 index 00000000000..f1f7d91caec --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionRow.tsx @@ -0,0 +1,259 @@ +import { useParams } from 'common' +import { RotateCcw } from 'lucide-react' +import { useState } from 'react' +import { get, useController, useFormState, type Control, type FieldErrors } from 'react-hook-form' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' + +import type { DestinationPanelSchemaType } from '../DestinationForm.schema' +import { + isMetadataListErrorVisible, + isMetadataListLoading, + useRefreshOnOpen, +} from '../useRefreshOnOpen' +import { + ClusteringFields, + PartitioningFields, + type ColumnSelectionState, +} from './TableOptionFields' +import { useReplicationSourceId } from '@/data/replication/sources-query' +import { useReplicationTableColumnsQuery } from '@/data/replication/table-columns-query' + +type TableOption = NonNullable[number] +type PartitionBy = TableOption['partitionBy'] + +const getFieldErrorMessage = (errors: FieldErrors, path: string) => { + const error = get(errors, path) + return typeof error?.message === 'string' ? error.message : undefined +} + +interface TableOptionErrors { + partitionBy?: string + column?: string + start?: string + end?: string + interval?: string + clusterBy?: string +} + +interface TableOptionEditorProps { + partitionBy: PartitionBy + clusterBy: string[] + onPartitionByChange: (partitionBy: PartitionBy) => void + onClusterByChange: (clusterBy: string[]) => void + errors?: TableOptionErrors + isPublicationColumnsError?: boolean + isPublicationColumnsPending?: boolean + publishedColumnNames?: ReadonlySet | null + tableId: number + onClear?: () => void +} + +const TableOptionEditor = ({ + partitionBy, + clusterBy, + onPartitionByChange, + onClusterByChange, + errors = {}, + isPublicationColumnsError = false, + isPublicationColumnsPending = false, + publishedColumnNames, + tableId, + onClear, +}: TableOptionEditorProps) => { + const { ref: projectRef } = useParams() + const sourceId = useReplicationSourceId({ projectRef }) + + const shouldInitiallyLoadColumns = + partitionBy?.kind === 'time_column' || + partitionBy?.kind === 'integer_range' || + clusterBy.length > 0 + const [shouldLoadColumns, setShouldLoadColumns] = useState(shouldInitiallyLoadColumns) + const { + data: columns = [], + isPending, + isFetching, + isError, + isSuccess, + refetch: refetchColumns, + } = useReplicationTableColumnsQuery( + { projectRef, sourceId, tableId }, + { enabled: shouldLoadColumns } + ) + + const isLoadingColumns = + (shouldLoadColumns && isMetadataListLoading(isPending || isFetching, columns.length)) || + isPublicationColumnsPending + const sourceColumnNames = columns.map((column) => column.name) + const sourceColumnNameSet = new Set(sourceColumnNames) + const canUseColumns = !isPublicationColumnsPending && !isPublicationColumnsError + const availableColumnNames = canUseColumns + ? sourceColumnNames.filter( + (column) => + publishedColumnNames === undefined || + (publishedColumnNames !== null && publishedColumnNames.has(column)) + ) + : [] + const columnTypeByName = new Map(columns.map((column) => [column.name, column.type])) + const areColumnsVerified = isSuccess && canUseColumns + const partitionColumn = partitionBy && 'column' in partitionBy ? partitionBy.column : undefined + const configuredColumns = [partitionColumn, ...clusterBy].filter( + (column, columnIndex, allConfiguredColumns): column is string => + typeof column === 'string' && + column.trim().length > 0 && + allConfiguredColumns.indexOf(column) === columnIndex + ) + const unavailableColumns = configuredColumns.filter( + (column) => + areColumnsVerified && + (!sourceColumnNameSet.has(column) || + (publishedColumnNames != null && !publishedColumnNames.has(column))) + ) + const unavailableColumnSet = new Set(unavailableColumns) + const isColumnSelectionError = + isPublicationColumnsError || isMetadataListErrorVisible(isError, columns.length) + + const { handleOpenChange: handleRefreshColumnsOnOpen } = useRefreshOnOpen({ + isEnabled: shouldLoadColumns, + refetch: refetchColumns, + }) + const handleColumnPickerOpenChange = (isOpen: boolean) => { + if (isOpen && !shouldLoadColumns) { + setShouldLoadColumns(true) + return + } + handleRefreshColumnsOnOpen(isOpen) + } + + const columnSelection: ColumnSelectionState = { + availableColumnNames, + columnTypeByName, + isError: isColumnSelectionError, + isLoading: isLoadingColumns, + onOpenChange: handleColumnPickerOpenChange, + unavailableColumnSet, + } + + return ( +
+ setShouldLoadColumns(true)} + columnSelection={columnSelection} + errors={{ + column: errors.column, + start: errors.start, + end: errors.end, + interval: errors.interval, + }} + /> + + {errors.partitionBy &&

{errors.partitionBy}

} + + + + {shouldLoadColumns && isColumnSelectionError && ( + + )} + + {unavailableColumns.length > 0 && ( + + )} + + {onClear !== undefined && ( + + )} +
+ ) +} + +type SharedRowProps = Pick< + TableOptionEditorProps, + 'isPublicationColumnsError' | 'isPublicationColumnsPending' | 'publishedColumnNames' | 'tableId' +> + +interface TableOptionRowProps extends SharedRowProps { + control: Control + index: number + onClear: () => void +} + +export const TableOptionRow = ({ + control, + index, + onClear, + ...sharedProps +}: TableOptionRowProps) => { + const { errors } = useFormState({ control }) + + const { field: partitionByField } = useController({ + control, + name: `tableOptions.${index}.partitionBy`, + }) + const { field: clusterByField } = useController({ + control, + name: `tableOptions.${index}.clusterBy`, + }) + + const fieldPath = `tableOptions.${index}` + + return ( + + ) +} + +interface TableOptionDraftRowProps extends SharedRowProps { + onCreate: (option: Pick) => void +} + +/** + * Rendered while a table has no entry in the tableOptions field array. Expanding a row must not + * touch form state, otherwise merely looking at a table leaves the form dirty and triggers the + * unsaved-changes prompt. The first edit creates the entry, after which TableOptionRow takes over. + */ +export const TableOptionDraftRow = ({ onCreate, ...sharedProps }: TableOptionDraftRowProps) => ( + onCreate({ partitionBy, clusterBy: [] })} + onClusterByChange={(clusterBy) => onCreate({ partitionBy: undefined, clusterBy })} + {...sharedProps} + /> +) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.test.tsx new file mode 100644 index 00000000000..711757428c3 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.test.tsx @@ -0,0 +1,505 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { fireEvent, screen } from '@testing-library/react' +import type { components } from 'api-types' +import { HttpResponse } from 'msw' +import { useForm } from 'react-hook-form' +import { Form } from 'ui' +import { describe, expect, it } from 'vitest' + +import { + DestinationPanelFormSchema, + type DestinationPanelSchemaType, +} from '../DestinationForm.schema' +import { TableOptions } from './TableOptions' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +type PublicationDetailsResponse = components['schemas']['PublicationDetailsResponse_Output'] +type ReadColumnsResponse = components['schemas']['ReadColumnsResponse_Output'] +type ReadTablesResponse = components['schemas']['ReadTablesResponse_Output'] +type SourcesResponse = components['schemas']['SourcesResponse_Output'] + +const publicationTable = { + id: 101, + schema: 'public', + name: 'Orders', + kind: 'table' as const, + partition_parent_id: null, +} + +const publication: PublicationDetailsResponse = { + name: 'analytics', + config: { + type: 'tables', + tables: [ + { + id: publicationTable.id, + schema: publicationTable.schema, + name: publicationTable.name, + columns: null, + row_filter: null, + }, + ], + operations: ['insert'], + publish_via_partition_root: false, + }, + tables: [publicationTable], +} + +const mockSources = () => { + const response: SourcesResponse = { + sources: [ + { + id: 1, + name: 'default', + tenant_id: 'tenant', + config: { host: 'db.internal', name: 'main-db', port: 5432, username: 'etl_user' }, + }, + ], + } + + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources', + response: () => HttpResponse.json(response), + }) +} + +const mockPublication = (response = publication) => { + addAPIMock({ + method: 'get', + path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name', + response: () => HttpResponse.json(response), + }) +} + +const mockColumns = (tableId: number, columns: ReadColumnsResponse['columns']) => { + addAPIMock({ + method: 'get', + path: '/platform/replication/v2/:ref/sources/:source_id/tables/:table_id/columns', + response: ({ params }) => { + if (Number(params.table_id) !== tableId) { + return HttpResponse.json({ columns: [] }) + } + return HttpResponse.json({ columns }) + }, + }) +} + +const mockTables = (tables: ReadTablesResponse['tables']) => { + addAPIMock({ + method: 'get', + path: '/platform/replication/v2/:ref/sources/:source_id/tables', + response: () => HttpResponse.json({ tables }), + }) +} + +const TableOptionsHarness = ({ + tableOptions, + publicationName = 'analytics', +}: { + tableOptions: NonNullable + publicationName?: string +}) => { + const form = useForm({ + mode: 'onChange', + resolver: zodResolver(DestinationPanelFormSchema), + defaultValues: { + name: 'Warehouse', + publicationName, + tableSyncCopyMode: 'include_all_tables', + tableSyncCopyTableIds: [], + tableOptions, + }, + }) + + return ( +
+ +

{form.formState.isDirty ? 'Form is dirty' : 'Form is pristine'}

+ + + ) +} + +describe('TableOptions source reconciliation', () => { + it('explains why a publication is required before showing table settings', async () => { + mockSources() + + customRender() + + expect(await screen.findByText('Select a publication')).toBeInTheDocument() + expect( + screen.getByText( + 'Choose the publication whose destination tables you want to partition or cluster.' + ) + ).toBeInTheDocument() + }) + + it('discloses table settings without implying that the table is excluded', async () => { + mockSources() + mockPublication() + + customRender() + + const tableTrigger = await screen.findByRole('button', { + name: 'public.Orders: Not configured', + }) + expect(screen.queryByText('Partition by')).not.toBeInTheDocument() + + fireEvent.click(tableTrigger) + expect(await screen.findByText('Partition by')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument() + + fireEvent.click(tableTrigger) + expect(screen.queryByText('Partition by')).not.toBeInTheDocument() + expect(screen.getByText('Not configured')).toBeInTheDocument() + }) + + it('keeps the form pristine when a table row is only expanded', async () => { + mockSources() + mockPublication() + + customRender() + + const tableTrigger = await screen.findByRole('button', { + name: 'public.Orders: Not configured', + }) + expect(screen.getByText('Form is pristine')).toBeInTheDocument() + + fireEvent.click(tableTrigger) + expect(await screen.findByText('Partition by')).toBeInTheDocument() + expect(screen.getByText('Form is pristine')).toBeInTheDocument() + + fireEvent.click(tableTrigger) + expect(screen.getByText('Form is pristine')).toBeInTheDocument() + }) + + it('clears a configured layout without removing its table row', async () => { + mockSources() + mockPublication() + mockColumns(101, [{ name: 'region', type: 'text', nullable: false, primary_key: false }]) + + customRender() + + fireEvent.click( + await screen.findByRole('button', { name: 'public.Orders: 1 clustering column' }) + ) + fireEvent.click(await screen.findByRole('button', { name: 'Clear' })) + + expect( + screen.getByRole('button', { name: 'public.Orders: Not configured' }) + ).toBeInTheDocument() + expect(screen.queryByText('Partition by')).not.toBeInTheDocument() + }) + + it('shows integer range validation errors beside the invalid fields', async () => { + mockSources() + mockPublication() + mockColumns(101, [{ name: 'id', type: 'int8', nullable: false, primary_key: true }]) + + customRender( + + ) + + fireEvent.click(await screen.findByRole('button', { name: /public\.Orders: Integer range/ })) + fireEvent.click(screen.getByRole('button', { name: 'Validate form' })) + expect(await screen.findByText('Interval must be greater than 0.')).toBeInTheDocument() + // Once beside the End field, and once on the trigger so a collapsed row still explains itself. + expect(screen.getAllByText('End must be greater than start.')).toHaveLength(2) + expect( + screen.getByRole('button', { name: 'public.Orders: End must be greater than start.' }) + ).toBeInTheDocument() + }) + + it('blocks an incomplete partition instead of dropping it silently on save', async () => { + mockSources() + mockPublication() + mockColumns(101, [ + { name: 'CreatedAt', type: 'timestamptz', nullable: false, primary_key: true }, + ]) + + customRender( + + ) + + const tableTrigger = await screen.findByRole('button', { + name: 'public.Orders: Time column partitioning', + }) + + fireEvent.click(screen.getByRole('button', { name: 'Validate form' })) + expect(await screen.findByText('Select a partition column')).toBeInTheDocument() + + // The row can be collapsed when the save is attempted, so the trigger has to explain it too. + expect( + await screen.findByRole('button', { name: 'public.Orders: Select a partition column' }) + ).toBe(tableTrigger) + }) + + it('marks configured columns that no longer exist while preserving valid names and types', async () => { + mockSources() + mockPublication() + mockColumns(101, [ + { name: 'region', type: 'text', nullable: false, primary_key: false }, + { name: 'CreatedAt', type: 'timestamptz', nullable: false, primary_key: false }, + ]) + + customRender( + + ) + + fireEvent.click( + await screen.findByRole('button', { name: /public\.Orders: Daily by DroppedAt/ }) + ) + expect( + await screen.findByText('Some selected columns are no longer available') + ).toBeInTheDocument() + expect(screen.getAllByText('region')[0]).toBeInTheDocument() + expect(screen.getByText('text')).toBeInTheDocument() + expect(screen.getByText('DroppedAt').parentElement).toHaveClass('text-destructive-600') + expect(screen.getByText('LegacySegment').parentElement).toHaveClass('text-destructive-600') + }) + + it('marks existing columns that are no longer included in the publication', async () => { + mockSources() + mockPublication({ + ...publication, + config: { + type: 'tables', + tables: [ + { + id: publicationTable.id, + schema: publicationTable.schema, + name: publicationTable.name, + columns: ['region'], + row_filter: null, + }, + ], + operations: ['insert'], + publish_via_partition_root: false, + }, + }) + mockColumns(101, [ + { name: 'region', type: 'text', nullable: false, primary_key: false }, + { name: 'PrivateNote', type: 'text', nullable: true, primary_key: false }, + ]) + + customRender( + + ) + + fireEvent.click( + await screen.findByRole('button', { name: /public\.Orders: 1 clustering column/ }) + ) + expect( + await screen.findByText('Some selected columns are no longer available') + ).toBeInTheDocument() + expect(screen.getByText('PrivateNote').parentElement).toHaveClass('text-destructive-600') + }) + + it('inherits explicit column filters through nested partition ancestors', async () => { + mockSources() + mockPublication({ + ...publication, + config: { + type: 'tables', + tables: [ + { + id: 10, + schema: 'public', + name: 'Events', + columns: ['AllowedColumn'], + row_filter: null, + }, + ], + operations: ['insert'], + publish_via_partition_root: false, + }, + tables: [ + { + id: 12, + schema: 'public', + name: 'Events2026August', + kind: 'table', + partition_parent_id: 11, + }, + ], + }) + mockTables([ + { + id: 10, + schema: 'public', + name: 'Events', + kind: 'partitioned_table', + partition_parent_id: null, + }, + { + id: 11, + schema: 'public', + name: 'Events2026', + kind: 'partitioned_table', + partition_parent_id: 10, + }, + { + id: 12, + schema: 'public', + name: 'Events2026August', + kind: 'table', + partition_parent_id: 11, + }, + ]) + mockColumns(12, [ + { name: 'AllowedColumn', type: 'text', nullable: false, primary_key: false }, + { name: 'ExcludedColumn', type: 'text', nullable: false, primary_key: false }, + ]) + + customRender( + + ) + + fireEvent.click( + await screen.findByRole('button', { + name: /public\.Events2026August: 1 clustering column/, + }) + ) + expect( + await screen.findByText('Some selected columns are no longer available') + ).toBeInTheDocument() + expect(screen.getByText('ExcludedColumn').parentElement).toHaveClass('text-destructive-600') + }) + + it('does not treat an unresolved partition ancestry as publishing every column', async () => { + mockSources() + mockPublication({ + ...publication, + config: { + type: 'tables', + tables: [ + { + id: 10, + schema: 'public', + name: 'Events', + columns: ['AllowedColumn'], + row_filter: null, + }, + ], + operations: ['insert'], + publish_via_partition_root: false, + }, + tables: [ + { + id: 12, + schema: 'public', + name: 'Events2026August', + kind: 'table', + partition_parent_id: 11, + }, + ], + }) + mockTables([ + { + id: 12, + schema: 'public', + name: 'Events2026August', + kind: 'table', + partition_parent_id: 11, + }, + ]) + mockColumns(12, [ + { name: 'AllowedColumn', type: 'text', nullable: false, primary_key: false }, + { name: 'UnverifiedColumn', type: 'text', nullable: false, primary_key: false }, + ]) + + customRender( + + ) + + fireEvent.click( + await screen.findByRole('button', { + name: /public\.Events2026August: 1 clustering column/, + }) + ) + expect(await screen.findByText('Columns could not be verified')).toBeInTheDocument() + expect( + screen.queryByText('Some selected columns are no longer available') + ).not.toBeInTheDocument() + }) + + it('hides settings for a table removed from the publication without exposing its id', async () => { + mockSources() + mockPublication() + mockTables([ + publicationTable, + { + id: 202, + schema: 'Billing', + name: 'Invoices', + kind: 'table', + partition_parent_id: null, + }, + ]) + customRender() + + expect(await screen.findByText('Billing.Invoices')).toBeInTheDocument() + expect(screen.getByText('No longer in publication')).toHaveClass('text-destructive-600') + expect(screen.getByText('Some tables are no longer in the publication')).toBeInTheDocument() + expect(screen.queryByText('Region')).not.toBeInTheDocument() + expect(document.body).not.toHaveTextContent('202') + }) + + it('does not match a dropped and recreated table by name instead of id', async () => { + mockSources() + mockPublication() + mockTables([ + publicationTable, + { + id: 1000, + schema: 'public', + name: 'PreviouslyConfigured', + kind: 'table', + partition_parent_id: null, + }, + ]) + + customRender( + + ) + + expect(await screen.findByText('Previously configured table')).toBeInTheDocument() + expect(screen.queryByText('OccurredAt')).not.toBeInTheDocument() + expect(screen.getByText('Some tables are no longer in the publication')).toBeInTheDocument() + expect(document.body).not.toHaveTextContent('999') + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.tsx new file mode 100644 index 00000000000..13316f87ab9 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.tsx @@ -0,0 +1,304 @@ +import { useParams } from 'common' +import { Table2, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { useFieldArray, useFormState, useWatch, type Control } from 'react-hook-form' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, cn } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import type { DestinationPanelSchemaType } from '../DestinationForm.schema' +import { isMetadataValueLoading } from '../useRefreshOnOpen' +import { TableOptionDraftRow, TableOptionRow } from './TableOptionRow' +import { findFirstErrorMessage, resolvePublishedColumnNames } from './TableOptions.utils' +import { useReplicationPublicationQuery } from '@/data/replication/publication-query' +import { useReplicationSourceId } from '@/data/replication/sources-query' +import { useReplicationTablesQuery } from '@/data/replication/tables-query' + +const tableLabel = (table: { schema: string; name: string }) => `${table.schema}.${table.name}` + +const GRANULARITY_SUMMARIES = { + hour: 'Hourly', + day: 'Daily', + month: 'Monthly', + year: 'Yearly', +} as const + +// Returns undefined when a table has nothing applied, so the caller can both render and +// de-emphasise the placeholder rather than comparing against its wording. +const tableOptionSummary = ( + option: NonNullable[number] | undefined +): string | undefined => { + if (option === undefined) return undefined + + const parts: string[] = [] + const partitionBy = option.partitionBy + + if (partitionBy?.kind === 'time_column') { + parts.push( + partitionBy.column + ? `${GRANULARITY_SUMMARIES[partitionBy.granularity ?? 'day']} by ${partitionBy.column}` + : 'Time column partitioning' + ) + } else if (partitionBy?.kind === 'integer_range') { + parts.push( + partitionBy.column ? `Integer range by ${partitionBy.column}` : 'Integer range partitioning' + ) + } else if (partitionBy?.kind === 'ingestion_time') { + parts.push( + `${GRANULARITY_SUMMARIES[partitionBy.granularity ?? 'day']} ingestion-time partitioning` + ) + } + + const clusteringColumnCount = option.clusterBy?.length ?? 0 + if (clusteringColumnCount > 0) { + parts.push( + `${clusteringColumnCount} clustering ${clusteringColumnCount === 1 ? 'column' : 'columns'}` + ) + } + + return parts.length > 0 ? parts.join(' · ') : undefined +} + +// Mirrors the row list so the list does not resize when the publication resolves. +const TableOptionsSkeleton = () => ( + +) + +interface TableOptionsProps { + control: Control +} + +export const TableOptions = ({ control }: TableOptionsProps) => { + const { ref: projectRef } = useParams() + const sourceId = useReplicationSourceId({ projectRef }) + const publicationName = useWatch({ control, name: 'publicationName' }) + const tableOptions = useWatch({ control, name: 'tableOptions' }) ?? [] + const [expandedTableId, setExpandedTableId] = useState() + + const { + data: selectedPublication, + isPending, + isFetching, + isError: isPublicationError, + isSuccess: isPublicationSuccess, + } = useReplicationPublicationQuery({ + projectRef, + sourceId, + publicationName, + }) + const isLoadingPublicationTables = isMetadataValueLoading( + isPending || isFetching, + selectedPublication + ) + const publicationTables = [...(selectedPublication?.tables ?? [])].sort((a, b) => + tableLabel(a).localeCompare(tableLabel(b)) + ) + + const { fields, append, remove } = useFieldArray({ control, name: 'tableOptions' }) + const fieldIndexByTableId = new Map(fields.map((field, index) => [field.tableId, index])) + const { errors } = useFormState({ control, name: 'tableOptions' }) + const publicationTableIds = new Set(publicationTables.map(({ id }) => id)) + const publicationTablesById = new Map(publicationTables.map((table) => [table.id, table])) + const configuredTables = + selectedPublication?.config.type === 'tables' ? selectedPublication.config.tables : [] + const configuredTablesById = new Map(configuredTables.map((table) => [table.id, table])) + const unavailableTableOptions = isPublicationSuccess + ? fields + .map((field, index) => ({ field, index })) + .filter(({ field }) => !publicationTableIds.has(field.tableId)) + : [] + // An expanded row needs its published columns resolved even before it has an entry, so that + // the first column picker it opens is already filtered correctly. + const activeTableIds = new Set([ + ...fields.map(({ tableId }) => tableId), + ...(expandedTableId === undefined ? [] : [expandedTableId]), + ]) + const tablesNeedingPartitionAncestry = new Set( + [...activeTableIds].flatMap((tableId) => { + if (!publicationTableIds.has(tableId) || configuredTablesById.has(tableId)) return [] + + const parentId = publicationTablesById.get(tableId)?.partition_parent_id + return parentId !== null && parentId !== undefined && !configuredTablesById.has(parentId) + ? [tableId] + : [] + }) + ) + const shouldResolvePartitionAncestry = + selectedPublication?.config.type === 'tables' && tablesNeedingPartitionAncestry.size > 0 + const { data: sourceTables = [], isPending: isSourceTablesPending } = useReplicationTablesQuery( + { projectRef, sourceId }, + { + enabled: unavailableTableOptions.length > 0 || shouldResolvePartitionAncestry, + } + ) + const sourceTablesById = new Map(sourceTables.map((table) => [table.id, table])) + const knownSourceTablesById = new Map( + [...publicationTables, ...sourceTables].map((table) => [table.id, table]) + ) + + if (!publicationName) { + return ( + + ) + } + + if (isLoadingPublicationTables) { + return + } + + if (isPublicationError) { + return ( + + ) + } + + if (publicationTables.length === 0 && unavailableTableOptions.length === 0) { + return

This publication has no tables.

+ } + + return ( +
+
+ setExpandedTableId(value === '' ? undefined : Number(value))} + > + {publicationTables.map((table) => { + const index = fieldIndexByTableId.get(table.id) + const summary = tableOptionSummary( + index === undefined ? undefined : tableOptions[index] + ) + // Errors live inside the collapsed row, so the trigger has to carry them or a + // blocked save looks like nothing happened at all. + const rowError = + index === undefined ? undefined : findFirstErrorMessage(errors.tableOptions?.[index]) + const summaryLabel = rowError ?? summary ?? 'Not configured' + const publishedColumnNames = + selectedPublication?.config.type === 'tables' + ? resolvePublishedColumnNames(table.id, configuredTablesById, knownSourceTablesById) + : undefined + const isPublicationColumnsPending = + tablesNeedingPartitionAncestry.has(table.id) && isSourceTablesPending + const sharedProps = { + isPublicationColumnsError: + selectedPublication?.config.type === 'tables' && + publishedColumnNames === null && + !isPublicationColumnsPending, + isPublicationColumnsPending, + publishedColumnNames, + tableId: table.id, + } + + return ( + + + + + + + {tableLabel(table)} + + + {summaryLabel} + + + + + {/* Radix unmounts closed content after its exit animation, so this must not be + gated on the expanded state as well or the row collapses as an empty box. */} + + {index !== undefined ? ( + { + remove(index) + setExpandedTableId(undefined) + }} + {...sharedProps} + /> + ) : ( + append({ tableId: table.id, ...option })} + {...sharedProps} + /> + )} + + + ) + })} + + + {unavailableTableOptions.map(({ field, index }) => { + const sourceTable = sourceTablesById.get(field.tableId) + + return ( +
+ + + + + {sourceTable ? tableLabel(sourceTable) : 'Previously configured table'} + + No longer in publication + + + +
+ ) + })} +
+ + {unavailableTableOptions.length > 0 && ( + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.test.ts new file mode 100644 index 00000000000..ca6c8415bf3 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' + +import { + findFirstErrorMessage, + isIntegerPartitionColumnType, + isPartitionColumnTypeCompatible, + isTimePartitionColumnType, + resolvePublishedColumnNames, +} from './TableOptions.utils' + +describe('resolvePublishedColumnNames', () => { + it('returns the columns from the nearest configured partition ancestor', () => { + const configuredTables = new Map([ + [1, { id: 1, columns: ['root_column'] }], + [2, { id: 2, columns: ['middle_column'] }], + ]) + const sourceTables = new Map([ + [1, { id: 1, partition_parent_id: null }], + [2, { id: 2, partition_parent_id: 1 }], + [3, { id: 3, partition_parent_id: 2 }], + ]) + + expect(resolvePublishedColumnNames(3, configuredTables, sourceTables)).toEqual( + new Set(['middle_column']) + ) + }) + + it('returns undefined when the nearest configured ancestor publishes every column', () => { + const configuredTables = new Map([[1, { id: 1, columns: null }]]) + const sourceTables = new Map([ + [1, { id: 1, partition_parent_id: null }], + [2, { id: 2, partition_parent_id: 1 }], + ]) + + expect(resolvePublishedColumnNames(2, configuredTables, sourceTables)).toBeUndefined() + }) + + it('returns null when an ancestry link cannot be resolved', () => { + expect( + resolvePublishedColumnNames( + 3, + new Map([[1, { id: 1, columns: ['id'] }]]), + new Map([[3, { id: 3, partition_parent_id: 2 }]]) + ) + ).toBeNull() + }) + + it('returns null for a cyclic partition ancestry', () => { + const sourceTables = new Map([ + [2, { id: 2, partition_parent_id: 3 }], + [3, { id: 3, partition_parent_id: 2 }], + ]) + + expect(resolvePublishedColumnNames(3, new Map(), sourceTables)).toBeNull() + }) + + it('returns null when no configured ancestor exists', () => { + const sourceTables = new Map([[1, { id: 1, partition_parent_id: null }]]) + + expect(resolvePublishedColumnNames(1, new Map(), sourceTables)).toBeNull() + }) +}) + +describe('partition column type compatibility', () => { + it('accepts Postgres date and timestamp types for time-column partitioning', () => { + expect(isTimePartitionColumnType('date')).toBe(true) + expect(isTimePartitionColumnType('timestamp with time zone')).toBe(true) + expect(isTimePartitionColumnType('timestamptz')).toBe(true) + expect(isTimePartitionColumnType('integer')).toBe(false) + }) + + it('accepts integer family types for integer-range partitioning', () => { + expect(isIntegerPartitionColumnType('bigint')).toBe(true) + expect(isIntegerPartitionColumnType('int4')).toBe(true) + expect(isIntegerPartitionColumnType('date')).toBe(false) + }) + + it('treats an unknown type as compatible so columns can still be chosen while loading', () => { + expect(isPartitionColumnTypeCompatible('time_column')).toBe(true) + expect(isPartitionColumnTypeCompatible('integer_range', 'text')).toBe(false) + }) +}) + +describe('findFirstErrorMessage', () => { + it('returns the first message nested anywhere inside a field-array error', () => { + expect( + findFirstErrorMessage({ + partitionBy: { column: { type: 'too_small', message: 'Select a partition column' } }, + }) + ).toBe('Select a partition column') + }) + + it('ignores branches without a message rather than reporting an empty error', () => { + expect(findFirstErrorMessage(undefined)).toBeUndefined() + expect( + findFirstErrorMessage({ partitionBy: { column: { type: 'too_small' } } }) + ).toBeUndefined() + expect(findFirstErrorMessage({ clusterBy: { message: '' } })).toBeUndefined() + }) + + it('does not walk into the DOM node that react-hook-form attaches to every field error', () => { + const input = document.createElement('input') + input.setAttribute('message', 'not an error message') + + expect( + findFirstErrorMessage({ partitionBy: { column: { type: 'too_small', ref: input } } }) + ).toBeUndefined() + }) + + it('survives a cyclic error graph', () => { + const partitionBy: Record = { column: { type: 'too_small' } } + partitionBy.self = partitionBy + + expect(findFirstErrorMessage({ partitionBy })).toBeUndefined() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.ts new file mode 100644 index 00000000000..200db448dbd --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.ts @@ -0,0 +1,110 @@ +const TIME_PARTITION_COLUMN_TYPES = new Set([ + 'date', + 'timestamp', + 'timestamptz', + 'timestamp with time zone', + 'timestamp without time zone', +]) + +const INTEGER_PARTITION_COLUMN_TYPES = new Set([ + 'smallint', + 'integer', + 'bigint', + 'int2', + 'int4', + 'int8', + 'serial', + 'serial2', + 'serial4', + 'serial8', + 'smallserial', + 'bigserial', +]) + +export const isTimePartitionColumnType = (type?: string) => + type !== undefined && TIME_PARTITION_COLUMN_TYPES.has(type.toLowerCase()) + +export const isIntegerPartitionColumnType = (type?: string) => + type !== undefined && INTEGER_PARTITION_COLUMN_TYPES.has(type.toLowerCase()) + +export const isPartitionColumnTypeCompatible = ( + kind: 'time_column' | 'integer_range', + type?: string +) => { + if (type === undefined) return true + return kind === 'time_column' + ? isTimePartitionColumnType(type) + : isIntegerPartitionColumnType(type) +} + +export type PublicationTableConfig = { + id: number + columns?: string[] | null +} + +export type SourceTableWithPartitionParent = { + id: number + partition_parent_id?: number | null +} + +// `undefined` means the nearest configured publication entry includes every column. `null` +// means the ancestry could not be resolved safely, so the UI must not claim any configured +// column is still valid. +export const resolvePublishedColumnNames = ( + tableId: number, + configuredTablesById: ReadonlyMap, + sourceTablesById: ReadonlyMap +): Set | null | undefined => { + const visitedTableIds = new Set() + let currentTableId: number | null | undefined = tableId + + while (currentTableId !== null && currentTableId !== undefined) { + if (visitedTableIds.has(currentTableId)) return null + visitedTableIds.add(currentTableId) + + const configuredTable = configuredTablesById.get(currentTableId) + if (configuredTable) { + return configuredTable.columns === null || configuredTable.columns === undefined + ? undefined + : new Set(configuredTable.columns) + } + + const sourceTable = sourceTablesById.get(currentTableId) + if (!sourceTable) return null + currentTableId = sourceTable.partition_parent_id + } + + return null +} + +// React Hook Form nests field-array errors by field name. A collapsed row can only show one +// line, and any error there blocks the save, so the first message is enough to explain why. +// Only plain objects and arrays are worth walking. React Hook Form hangs a `ref` on every +// field error, which points at a DOM node whose graph is deep and cyclic. +const isTraversable = (value: unknown): value is Record => { + if (typeof value !== 'object' || value === null) return false + if (Array.isArray(value)) return true + + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +export const findFirstErrorMessage = ( + error: unknown, + visited = new WeakSet() +): string | undefined => { + if (!isTraversable(error) || visited.has(error)) return undefined + visited.add(error) + + const { message } = error + if (typeof message === 'string' && message.length > 0) return message + + for (const [key, nested] of Object.entries(error)) { + if (key === 'ref') continue + + const nestedMessage = findFirstErrorMessage(nested, visited) + if (nestedMessage !== undefined) return nestedMessage + } + + return undefined +} 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 2b5b503f439..85d29ea363a 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 @@ -11,6 +11,7 @@ import { buildTableSyncCopyConfig, generateDefaultValues, pruneStaleSelectedTableIds, + pruneStaleTableOptions, } from './DestinationForm.utils' import { getDucklakeValidationIssues } from './DuckLake/DuckLake.utils' import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils' @@ -227,6 +228,50 @@ describe('DestinationForm.utils table copy selection', () => { }) }) +describe('pruneStaleTableOptions', () => { + const publication: ReplicationPublicationData = { + name: 'analytics', + config: { + type: 'tables', + tables: [{ id: 101, schema: 'public', name: 'orders', 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, + }, + ], + } + + it('drops table options whose ids are no longer in the publication', () => { + expect( + pruneStaleTableOptions({ + tableOptions: [ + { tableId: 101, clusterBy: ['region'] }, + { tableId: 202, clusterBy: ['unused'] }, + ], + publication, + publicationName: 'analytics', + }) + ).toEqual([{ tableId: 101, clusterBy: ['region'] }]) + }) + + it('returns undefined when table options were never set', () => { + expect( + pruneStaleTableOptions({ + tableOptions: undefined, + publication, + publicationName: 'analytics', + }) + ).toBeUndefined() + }) +}) + const baseClickHouseFormData = { name: 'ClickHouse Destination', publicationName: 'pub', 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 4c7da8776cf..4f3fb97e1e2 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 @@ -30,6 +30,8 @@ import { type ReplicationPublicationData } from '@/data/replication/publication- import type { BatchConfig, BigQueryDestinationConfig, + BigQueryPartitionBy, + BigQueryTableOption, ClickHouseDestinationConfig, DestinationConfig, DucklakeDestinationConfig, @@ -119,6 +121,7 @@ export const generateDefaultValues = ({ (config as { big_query?: { connection_pool_size?: number } } | undefined)?.big_query ?.connection_pool_size ?? DEFAULT_CONNECTION_POOL_SIZE, maxStalenessMins: bigQueryConfig?.max_staleness_mins ?? undefined, // Default: null + tableOptions: (bigQueryConfig?.table_options?.tables ?? []).map(parseBigQueryTableOption), // Analytics Bucket fields warehouseName: icebergConfig?.warehouse_name ?? '', namespace: icebergConfig?.namespace ?? '', @@ -234,6 +237,58 @@ export const pruneStaleSelectedTableIds = ({ return selectedTableIds.filter((id) => publicationTableIds.has(id)) } +export const pruneStaleTableOptions = ({ + tableOptions, + publication, + publicationName, +}: { + tableOptions?: BigQueryTableOption[] + publication: ReplicationPublicationData + publicationName: string +}): BigQueryTableOption[] | undefined => { + if (tableOptions === undefined) return undefined + + const publicationTableIds = getPublicationTableIds(publication, publicationName) + return tableOptions.filter((option) => publicationTableIds.has(String(option.tableId))) +} + +// Derived from the generated response type rather than hand-typed, so `granularity` stays the +// same literal union the API actually returns instead of a widened `string` needing a cast. +type BigQueryConfigResponse = Extract< + NonNullable, + { big_query: unknown } +>['big_query'] +type BigQueryTableOptionResponse = NonNullable< + NonNullable['tables'] +>[number] +type BigQueryPartitionByResponse = NonNullable + +const parseBigQueryPartitionBy = ( + partitionBy: BigQueryPartitionByResponse +): BigQueryPartitionBy => { + switch (partitionBy.kind) { + case 'time_column': + return { + kind: 'time_column', + column: partitionBy.column, + granularity: partitionBy.granularity ?? 'day', + } + case 'integer_range': + return { ...partitionBy } + case 'ingestion_time': + return { + kind: 'ingestion_time', + granularity: partitionBy.granularity ?? 'day', + } + } +} + +const parseBigQueryTableOption = (option: BigQueryTableOptionResponse): BigQueryTableOption => ({ + tableId: option.table_id, + partitionBy: option.partition_by ? parseBigQueryPartitionBy(option.partition_by) : undefined, + clusterBy: option.cluster_by ?? [], +}) + const buildBigQueryConfig = ( data: z.infer ): BigQueryDestinationConfig => ({ @@ -242,6 +297,7 @@ const buildBigQueryConfig = ( serviceAccountKey: data.serviceAccountKey ?? '', connectionPoolSize: data.connectionPoolSize, maxStalenessMins: data.maxStalenessMins, + tableOptions: data.tableOptions, }) const buildSnowflakeConfig = ( 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 d8005623ba0..4b732594581 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx @@ -59,6 +59,10 @@ export const PublicationSelection = ({ shouldDirty: true, shouldValidate: true, }) + form.setValue('tableOptions', [], { + shouldDirty: true, + shouldValidate: true, + }) } field.onChange(value) }, 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 7f6202b1423..80d904bab70 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -34,6 +34,7 @@ import { buildTableSyncCopyConfig, generateDefaultValues, pruneStaleSelectedTableIds, + pruneStaleTableOptions, } from './DestinationForm.utils' import { DestinationNameInput } from './DestinationNameInput' import { getDucklakeValidationIssues } from './DuckLake/DuckLake.utils' @@ -314,6 +315,11 @@ export const DestinationForm = ({ publication: selectedPublication, publicationName: rawData.publicationName, }), + tableOptions: pruneStaleTableOptions({ + tableOptions: rawData.tableOptions, + publication: selectedPublication, + publicationName: rawData.publicationName, + }), } if ( @@ -546,6 +552,10 @@ export const DestinationForm = ({ shouldDirty: true, shouldValidate: true, }) + form.setValue('tableOptions', [], { + shouldDirty: true, + shouldValidate: true, + }) form.setValue('publicationName', newPublication, { shouldDirty: true, shouldValidate: true, diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts index d112d7cc56a..a37f8b8b8b9 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts @@ -39,7 +39,7 @@ describe('buildBigQueryApiConfig', () => { serviceAccountKey: '{}', } - it('maps the destination config to the API shape', () => { + it('omits table_options when none are configured', () => { expect(buildBigQueryApiConfig(baseConfig)).toEqual({ big_query: { project_id: 'my-project', @@ -47,10 +47,128 @@ describe('buildBigQueryApiConfig', () => { service_account_key: '{}', connection_pool_size: undefined, max_staleness_mins: undefined, + table_options: undefined, }, }) }) + it('maps per-table partitioning and clustering to the API shape', () => { + expect( + buildBigQueryApiConfig({ + ...baseConfig, + tableOptions: [ + { + tableId: 16_408, + partitionBy: { kind: 'time_column', column: 'created_at', granularity: 'day' }, + clusterBy: ['customer_id', 'region'], + }, + { + tableId: 16_409, + partitionBy: { + kind: 'integer_range', + column: 'shard', + start: 0, + end: 100, + interval: 10, + }, + }, + ], + }) + ).toMatchObject({ + big_query: { + table_options: { + tables: [ + { + table_id: 16_408, + partition_by: { kind: 'time_column', column: 'created_at', granularity: 'day' }, + cluster_by: ['customer_id', 'region'], + }, + { + table_id: 16_409, + partition_by: { + kind: 'integer_range', + column: 'shard', + start: 0, + end: 100, + interval: 10, + }, + cluster_by: undefined, + }, + ], + }, + }, + }) + }) + + it('omits a time-column partition that has no column selected', () => { + expect( + buildBigQueryApiConfig({ + ...baseConfig, + tableOptions: [ + { tableId: 16_408, partitionBy: { kind: 'time_column', column: '', granularity: 'day' } }, + { + tableId: 16_409, + partitionBy: { kind: 'time_column', column: '', granularity: 'day' }, + clusterBy: ['customer_id'], + }, + ], + }) + ).toMatchObject({ + big_query: { + table_options: { + tables: [ + { + table_id: 16_409, + partition_by: undefined, + cluster_by: ['customer_id'], + }, + ], + }, + }, + }) + }) + + it('omits a table entry that has neither partitioning nor clustering set', () => { + expect( + buildBigQueryApiConfig({ + ...baseConfig, + tableOptions: [ + { tableId: 16_408, partitionBy: undefined, clusterBy: [] }, + { + tableId: 16_409, + partitionBy: { kind: 'ingestion_time', granularity: 'day' }, + }, + ], + }) + ).toMatchObject({ + big_query: { + table_options: { + tables: [ + { + table_id: 16_409, + partition_by: { kind: 'ingestion_time', granularity: 'day' }, + }, + ], + }, + }, + }) + }) + + it('sends table_options: null on update when every configured table is removed or blank', () => { + expect( + buildBigQueryUpdateApiConfig({ + ...baseConfig, + tableOptions: [{ tableId: 16_408, clusterBy: [] }], + }) + ).toMatchObject({ big_query: { table_options: null } }) + }) + + it('sends table_options: null on update when every configured table is removed', () => { + expect(buildBigQueryUpdateApiConfig({ ...baseConfig, tableOptions: [] })).toMatchObject({ + big_query: { table_options: null }, + }) + }) + it('omits blank service_account_key on update, but not on create', () => { const config = { ...baseConfig, serviceAccountKey: '' } diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.ts index b583ab6fd77..7c03be4f426 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.ts @@ -5,11 +5,17 @@ import { toast } from 'sonner' import { replicationKeys } from './keys' import type { BigQueryDestinationConfig, + BigQueryTableOption, DestinationConfig, DucklakeDestinationConfig, PipelineConfig, } from './types' -import { buildPipelineApiConfig, isDucklakeSupabaseConfig } from './utils' +import { + buildBigQueryTableOptionApiConfig, + buildPipelineApiConfig, + getConfiguredBigQueryTableOptions, + isDucklakeSupabaseConfig, +} from './utils' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -20,6 +26,13 @@ type CreateDestinationApiConfig = CreateDestinationPipelineBody['destination_con type CreateBigQueryApiConfig = Extract type CreateDucklakeApiConfig = Extract +const buildBigQueryTableOptionsApiConfig = (tableOptions: BigQueryTableOption[] | undefined) => { + const configuredTableOptions = getConfiguredBigQueryTableOptions(tableOptions) + + if (tableOptions === undefined || configuredTableOptions.length === 0) return undefined + return { tables: configuredTableOptions.map(buildBigQueryTableOptionApiConfig) } +} + // Maps the studio-side BigQuery config to the snake_case `{ big_query: ... }` payload accepted // by the platform API. Shared by the create and validate mutations. export function buildBigQueryApiConfig(config: BigQueryDestinationConfig): CreateBigQueryApiConfig { @@ -30,6 +43,7 @@ export function buildBigQueryApiConfig(config: BigQueryDestinationConfig): Creat service_account_key: config.serviceAccountKey, connection_pool_size: config.connectionPoolSize, max_staleness_mins: config.maxStalenessMins, + table_options: buildBigQueryTableOptionsApiConfig(config.tableOptions), }, } } diff --git a/apps/studio/data/replication/keys.ts b/apps/studio/data/replication/keys.ts index 172d161b336..e2625a78243 100644 --- a/apps/studio/data/replication/keys.ts +++ b/apps/studio/data/replication/keys.ts @@ -26,6 +26,11 @@ export const replicationKeys = { ] as const, tables: (projectRef: string | undefined, source_id: number | undefined) => ['projects', projectRef, 'sources', source_id, 'tables'] as const, + tableColumns: ( + projectRef: string | undefined, + source_id: number | undefined, + tableId: number | undefined + ) => ['projects', projectRef, 'sources', source_id, 'tables', tableId, 'columns'] as const, costEstimate: ( projectRef: string | undefined, source_id: number | undefined, diff --git a/apps/studio/data/replication/table-columns-query.ts b/apps/studio/data/replication/table-columns-query.ts new file mode 100644 index 00000000000..f8980abef47 --- /dev/null +++ b/apps/studio/data/replication/table-columns-query.ts @@ -0,0 +1,59 @@ +import { queryOptions, useQuery } from '@tanstack/react-query' + +import { REPLICATION_METADATA_FRESHNESS_MS } from './constants' +import { replicationKeys } from './keys' +import { get, handleError } from '@/data/fetchers' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +type ReplicationTableColumnsParams = { projectRef?: string; sourceId?: number; tableId?: number } + +async function fetchReplicationTableColumns( + { projectRef, sourceId, tableId }: ReplicationTableColumnsParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!sourceId) throw new Error('sourceId is required') + if (tableId === undefined) throw new Error('tableId is required') + + const { data, error } = await get( + '/platform/replication/v2/{ref}/sources/{source_id}/tables/{table_id}/columns', + { + params: { path: { ref: projectRef, source_id: sourceId, table_id: tableId } }, + signal, + } + ) + if (error) { + handleError(error) + } + + return data.columns +} + +export type ReplicationTableColumnsData = Awaited> + +type ReplicationTableColumnsQueryOptions = Omit< + UseCustomQueryOptions, + 'enabled' +> & { enabled?: boolean } + +export const replicationTableColumnsQueryOptions = ( + { projectRef, sourceId, tableId }: ReplicationTableColumnsParams, + { enabled = true, ...options }: ReplicationTableColumnsQueryOptions = {} +) => + queryOptions({ + queryKey: replicationKeys.tableColumns(projectRef, sourceId, tableId), + queryFn: ({ signal }) => + fetchReplicationTableColumns({ projectRef, sourceId, tableId }, signal), + enabled: + enabled && + typeof projectRef !== 'undefined' && + typeof sourceId !== 'undefined' && + typeof tableId !== 'undefined', + staleTime: REPLICATION_METADATA_FRESHNESS_MS, + ...options, + }) + +export const useReplicationTableColumnsQuery = ( + { projectRef, sourceId, tableId }: ReplicationTableColumnsParams, + options: ReplicationTableColumnsQueryOptions = {} +) => useQuery(replicationTableColumnsQueryOptions({ projectRef, sourceId, tableId }, options)) diff --git a/apps/studio/data/replication/types.ts b/apps/studio/data/replication/types.ts index 7b6d875de52..3d4bb8751a6 100644 --- a/apps/studio/data/replication/types.ts +++ b/apps/studio/data/replication/types.ts @@ -64,6 +64,43 @@ export type BigQueryDestinationConfig = { serviceAccountKey: string connectionPoolSize?: number maxStalenessMins?: number + tableOptions?: BigQueryTableOption[] +} + +export const BIGQUERY_TIME_PARTITION_GRANULARITIES = ['hour', 'day', 'month', 'year'] as const +export const BIGQUERY_MAX_CLUSTERING_COLUMNS = 4 + +export type BigQueryTimePartitionGranularity = + (typeof BIGQUERY_TIME_PARTITION_GRANULARITIES)[number] + +export type BigQueryPartitionBy = + | { kind: 'time_column'; column: string; granularity?: BigQueryTimePartitionGranularity } + | { + kind: 'integer_range' + column: string + start: number | '' + end: number | '' + interval: number | '' + } + | { kind: 'ingestion_time'; granularity?: BigQueryTimePartitionGranularity } + +export type CompleteBigQueryPartitionBy = + | Exclude + | { + kind: 'integer_range' + column: string + start: number + end: number + interval: number + } + +// A single source table's BigQuery partitioning/clustering configuration. `tableId` is the +// source Postgres table OID, stable across renames, matching the id used by the replication +// tables/columns endpoints. +export type BigQueryTableOption = { + tableId: number + partitionBy?: BigQueryPartitionBy + clusterBy?: string[] } export type IcebergDestinationConfig = { diff --git a/apps/studio/data/replication/update-destination-pipeline-mutation.ts b/apps/studio/data/replication/update-destination-pipeline-mutation.ts index 033a315584f..d54dd8a413c 100644 --- a/apps/studio/data/replication/update-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/update-destination-pipeline-mutation.ts @@ -6,11 +6,17 @@ import { optionalSecret } from './destination-secret-utils' import { replicationKeys } from './keys' import type { BigQueryDestinationConfig, + BigQueryTableOption, DestinationConfig, DucklakeDestinationConfig, PipelineConfig, } from './types' -import { buildPipelineApiConfig, isDucklakeSupabaseConfig } from './utils' +import { + buildBigQueryTableOptionApiConfig, + buildPipelineApiConfig, + getConfiguredBigQueryTableOptions, + isDucklakeSupabaseConfig, +} from './utils' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -21,6 +27,15 @@ type UpdateDestinationApiConfig = UpdateDestinationPipelineBody['destination_con type UpdateBigQueryApiConfig = Extract type UpdateDucklakeApiConfig = Extract +const buildBigQueryTableOptionsUpdateApiConfig = ( + tableOptions: BigQueryTableOption[] | undefined +) => { + const configuredTableOptions = getConfiguredBigQueryTableOptions(tableOptions) + if (tableOptions === undefined) return undefined + if (configuredTableOptions.length === 0) return null + return { tables: configuredTableOptions.map(buildBigQueryTableOptionApiConfig) } +} + export function buildBigQueryUpdateApiConfig( config: BigQueryDestinationConfig ): UpdateBigQueryApiConfig { @@ -31,6 +46,7 @@ export function buildBigQueryUpdateApiConfig( service_account_key: optionalSecret(config.serviceAccountKey), connection_pool_size: config.connectionPoolSize, max_staleness_mins: config.maxStalenessMins, + table_options: buildBigQueryTableOptionsUpdateApiConfig(config.tableOptions), }, } } diff --git a/apps/studio/data/replication/utils.ts b/apps/studio/data/replication/utils.ts index 6ad7c043055..e5df9f2a7e7 100644 --- a/apps/studio/data/replication/utils.ts +++ b/apps/studio/data/replication/utils.ts @@ -1,4 +1,7 @@ import type { + BigQueryPartitionBy, + BigQueryTableOption, + CompleteBigQueryPartitionBy, CreatePipelineApiConfig, DucklakeDestinationConfig, DucklakeSupabaseDestinationConfig, @@ -70,3 +73,55 @@ export const buildPipelineApiConfig = ({ } : undefined, }) + +const hasClusteringColumns = (clusterBy: string[] | undefined) => (clusterBy?.length ?? 0) > 0 + +const isCompleteBigQueryPartition = ( + partitionBy: BigQueryPartitionBy | undefined +): partitionBy is CompleteBigQueryPartitionBy => { + if (!partitionBy) return false + if (partitionBy.kind === 'ingestion_time') return true + if (!('column' in partitionBy) || partitionBy.column.trim().length === 0) return false + if (partitionBy.kind !== 'integer_range') return true + return ( + typeof partitionBy.start === 'number' && + typeof partitionBy.end === 'number' && + typeof partitionBy.interval === 'number' + ) +} + +const buildBigQueryPartitionByApiConfig = (partitionBy: CompleteBigQueryPartitionBy) => { + switch (partitionBy.kind) { + case 'time_column': + return { + kind: partitionBy.kind, + column: partitionBy.column, + granularity: partitionBy.granularity, + } + case 'integer_range': + return { + kind: partitionBy.kind, + column: partitionBy.column, + start: partitionBy.start, + end: partitionBy.end, + interval: partitionBy.interval, + } + case 'ingestion_time': + return { kind: partitionBy.kind, granularity: partitionBy.granularity } + } +} + +export const buildBigQueryTableOptionApiConfig = (option: BigQueryTableOption) => ({ + table_id: option.tableId, + partition_by: isCompleteBigQueryPartition(option.partitionBy) + ? buildBigQueryPartitionByApiConfig(option.partitionBy) + : undefined, + cluster_by: hasClusteringColumns(option.clusterBy) ? option.clusterBy : undefined, +}) + +const isBigQueryTableOptionConfigured = (option: BigQueryTableOption) => + isCompleteBigQueryPartition(option.partitionBy) || hasClusteringColumns(option.clusterBy) + +export const getConfiguredBigQueryTableOptions = ( + tableOptions: BigQueryTableOption[] | undefined +) => (tableOptions ?? []).filter(isBigQueryTableOptionConfigured) From 58e77483b49a2f7c16b766c6f190985d8ebc84ee Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Wed, 9 Sep 2026 09:10:28 +0200 Subject: [PATCH 05/85] chore(www): add open position to careers btn (#50139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add number of open position on the main cta button in the careers page. ## Before Screenshot 2026-09-08 at 16 57 24 ## After Screenshot 2026-09-08 at 16 57 05 ## Summary by CodeRabbit * **New Features** * The careers page now displays the current number of open positions. * The “Open positions” call-to-action includes the position count and uses a medium-sized button style. --- apps/www/pages/careers.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/www/pages/careers.tsx b/apps/www/pages/careers.tsx index c3aac86b559..b1788318a12 100644 --- a/apps/www/pages/careers.tsx +++ b/apps/www/pages/careers.tsx @@ -1,3 +1,4 @@ +import staticContent from '.generated/staticContent/_index.json' import { GlobeAltIcon } from '@heroicons/react/outline' import Globe from '~/components/Globe' import DefaultLayout from '~/components/Layouts/Default' @@ -97,6 +98,7 @@ interface CareersPageProps { const CareerPage = ({ jobs, placeholderJob, contributors }: CareersPageProps) => { const { basePath } = useRouter() + const { jobsCount } = staticContent const meta_title = 'Careers | Supabase' const meta_description = 'Help build software developers love' @@ -137,8 +139,8 @@ const CareerPage = ({ jobs, placeholderJob, contributors }: CareersPageProps) =>

Explore remote opportunities and join our team to help us achieve it.

- From fe0b18efb861d7538b9c2f61b80894647bb9d705 Mon Sep 17 00:00:00 2001 From: shaziya <99940835+shaziyabandukia@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:13:01 -0700 Subject: [PATCH 06/85] Add blog post: Supabase is now available in Gemini Enterprise (#50154) 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? New blog post announcing the Supabase connector in Google Cloud Gemini Enterprise, publishing 2026-09-09. ## What is the current behavior? N/A — new content. ## What is the new behavior? - Adds `apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx` (live at `/blog/supabase-is-now-available-in-gemini-enterprise`) - Adds og/thumb images from the Notion draft under `apps/www/public/images/blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise/` - Adds two new author entries to `authors.json`: `shaziya_bandukia` and `elsa_heffernan` (plus Elsa's avatar image) - Embeds the launch video (YouTube `IUb6W60S9Wg`) Verified locally: post page renders with both authors, TOC, tags, and video embed; post appears as the featured card on `/blog`; og:image meta resolves. ## Additional context **Before merging:** - [x] ~Swap the two "Get started" links~ — CTA now points to the partner catalog listing (`/partners/catalog/google-gemini`) - [ ] Marketing +1 in #team-marketing ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Content** - Added a blog post announcing Supabase’s prebuilt connector for Google Cloud Gemini Enterprise. - Covers natural-language queries, real-time data retrieval, multi-tool responses, access controls, connection steps, and edition-specific setup. - Includes a product video, partnership details, and links to connect or start a Supabase project. - Added author profiles for Elsa Heffernan and Shaziya Bandukia. --- ...-is-now-available-in-gemini-enterprise.mdx | 57 ++++++++++++++++++ apps/www/lib/authors.json | 15 +++++ .../og.png | Bin 0 -> 119791 bytes .../thumb.png | Bin 0 -> 82670 bytes .../images/blog/avatars/elsa-heffernan.png | Bin 0 -> 337732 bytes 5 files changed, 72 insertions(+) create mode 100644 apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx create mode 100644 apps/www/public/images/blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise/og.png create mode 100644 apps/www/public/images/blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise/thumb.png create mode 100644 apps/www/public/images/blog/avatars/elsa-heffernan.png diff --git a/apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx b/apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx new file mode 100644 index 00000000000..a2bbb881fb0 --- /dev/null +++ b/apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx @@ -0,0 +1,57 @@ +--- +title: 'Supabase is now available in Gemini Enterprise' +description: 'Connect Supabase to Gemini Enterprise and query your Supabase projects with natural language, right inside the platform.' +author: shaziya_bandukia, elsa_heffernan +date: '2026-09-09' +categories: + - product +tags: + - ai + - gemini + - integrations +imgSocial: '2026-09-09-supabase-is-now-available-in-gemini-enterprise/og.png' +imgThumb: '2026-09-09-supabase-is-now-available-in-gemini-enterprise/thumb.png' +toc_depth: 2 +--- + +Supabase is now available as a prebuilt connector in Google Cloud [Gemini Enterprise](https://cloud.google.com/gemini-enterprise). Connect your Supabase organization once, and your team can query and take action on Supabase projects using natural language, directly inside Gemini Enterprise, alongside the tools you already use, such as GitHub, Linear, Notion, Slack, and Salesforce. + +
+ -
+ diff --git a/apps/docs/content/guides/ai/examples/openai.mdx b/apps/docs/content/guides/ai/examples/openai.mdx index 88c13277cef..a7f05edede7 100644 --- a/apps/docs/content/guides/ai/examples/openai.mdx +++ b/apps/docs/content/guides/ai/examples/openai.mdx @@ -102,11 +102,4 @@ supabase secrets set --env-file ./supabase/.env.local If you're interesting in learning how to use this to build your own ChatGPT, read [the blog post](/blog/chatgpt-supabase-docs) and check out the video: -
- -
+ diff --git a/apps/docs/content/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx index a8d40070e14..b011bdc1028 100644 --- a/apps/docs/content/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx @@ -565,14 +565,7 @@ Future _nativeGoogleSignIn() async { ... ``` -
- -
+ @@ -593,14 +586,7 @@ await supabase.auth.signInWithOAuth( This call takes the user to Google's consent screen. Once the flow ends, the user's profile information is exchanged and validated with Supabase Auth before it redirects back to your Flutter application with an access and refresh token representing the user's session. -
- -
+ @@ -755,14 +741,7 @@ Button(onClick = { authState.startFlow() }) { } ``` -
- -
+ diff --git a/apps/docs/content/guides/database/extensions/http.mdx b/apps/docs/content/guides/database/extensions/http.mdx index 5cd72c01538..6ba13bc935a 100644 --- a/apps/docs/content/guides/database/extensions/http.mdx +++ b/apps/docs/content/guides/database/extensions/http.mdx @@ -9,14 +9,7 @@ The `http` extension allows you to call RESTful endpoints within Postgres. ## Quick demo -
- -
+ ## Overview diff --git a/apps/docs/content/guides/database/functions.mdx b/apps/docs/content/guides/database/functions.mdx index f7a35203620..1ae88d1d1dc 100644 --- a/apps/docs/content/guides/database/functions.mdx +++ b/apps/docs/content/guides/database/functions.mdx @@ -10,14 +10,7 @@ These functions live inside your database, and they can be [used with the API](. ## Quick demo -
- -
+ ## Getting started @@ -657,33 +650,12 @@ select advanced_example(); ### Create Database Functions -
- -
+ ### Call Database Functions using JavaScript -
- -
+ ### Using Database Functions to call an external API -
- -
+ diff --git a/apps/docs/content/guides/database/tables.mdx b/apps/docs/content/guides/database/tables.mdx index 22b348dbfc7..6efd8782674 100644 --- a/apps/docs/content/guides/database/tables.mdx +++ b/apps/docs/content/guides/database/tables.mdx @@ -423,14 +423,7 @@ For example if you had the following situations: > -
- -
+
diff --git a/apps/docs/content/guides/database/vault.mdx b/apps/docs/content/guides/database/vault.mdx index 3d129d73579..3cfba9f5740 100644 --- a/apps/docs/content/guides/database/vault.mdx +++ b/apps/docs/content/guides/database/vault.mdx @@ -163,15 +163,7 @@ updated_at | 2022-12-14 02:51:13.938396+00 ## Deep dive -
- -
+ As we mentioned, Vault stores secrets in an authenticated encrypted form. There are some details around that you may be curious about. What does authenticated mean? Where is the encryption key stored? This section explains those details. diff --git a/apps/docs/content/guides/database/webhooks.mdx b/apps/docs/content/guides/database/webhooks.mdx index 4c3087b3c4d..37063fd01f6 100644 --- a/apps/docs/content/guides/database/webhooks.mdx +++ b/apps/docs/content/guides/database/webhooks.mdx @@ -16,14 +16,7 @@ Database Webhooks are very similar to triggers, and that's because Database Webh This video demonstrates how you can create a new customer in Stripe each time a row is inserted into a `profiles` table: -
- -
+ ## Creating a webhook diff --git a/apps/docs/content/guides/functions/examples/discord-bot.mdx b/apps/docs/content/guides/functions/examples/discord-bot.mdx index 75b1eaeca92..de609341dd5 100644 --- a/apps/docs/content/guides/functions/examples/discord-bot.mdx +++ b/apps/docs/content/guides/functions/examples/discord-bot.mdx @@ -5,14 +5,7 @@ description: 'Building a Slash Command Discord Bot with Edge Functions.' video: 'https://www.youtube.com/v/J24Bvo_m7DM' --- -
- -
+ ## Create an application on Discord Developer portal diff --git a/apps/docs/content/guides/functions/examples/github-actions.mdx b/apps/docs/content/guides/functions/examples/github-actions.mdx index 54952b728b5..7bd2bdd5c46 100644 --- a/apps/docs/content/guides/functions/examples/github-actions.mdx +++ b/apps/docs/content/guides/functions/examples/github-actions.mdx @@ -5,14 +5,7 @@ description: 'Deploying Edge Functions with GitHub Actions.' video: 'https://www.youtube.com/v/l2KlzGrhB6w' --- -
- -
+ Use the Supabase CLI together with GitHub Actions to automatically deploy our Supabase Edge Functions. [View on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/github-action-deploy). diff --git a/apps/docs/content/guides/functions/examples/og-image.mdx b/apps/docs/content/guides/functions/examples/og-image.mdx index 4a19c24f374..e827cb22dc7 100644 --- a/apps/docs/content/guides/functions/examples/og-image.mdx +++ b/apps/docs/content/guides/functions/examples/og-image.mdx @@ -5,14 +5,7 @@ description: 'Generate Open Graph images with Deno and Supabase Edge Functions.' video: 'https://www.youtube.com/v/jZgyOJGWayQ' --- -
- -
+ Generate Open Graph images with Deno and Supabase Edge Functions. [View on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/opengraph). diff --git a/apps/docs/content/guides/functions/examples/push-notifications.mdx b/apps/docs/content/guides/functions/examples/push-notifications.mdx index f6a80ac2aea..9787522b862 100644 --- a/apps/docs/content/guides/functions/examples/push-notifications.mdx +++ b/apps/docs/content/guides/functions/examples/push-notifications.mdx @@ -286,14 +286,7 @@ Push notifications are an important part of any mobile app. They allow you to se 1. In your `notifications` table, insert a new row. 1. Watch the magic happen 🪄 -
- -
+
diff --git a/apps/docs/content/guides/functions/examples/rate-limiting.mdx b/apps/docs/content/guides/functions/examples/rate-limiting.mdx index 094328f5936..d24133e2f8e 100644 --- a/apps/docs/content/guides/functions/examples/rate-limiting.mdx +++ b/apps/docs/content/guides/functions/examples/rate-limiting.mdx @@ -3,14 +3,7 @@ title: 'Rate Limiting Edge Functions' description: 'Rate Limiting Edge Functions with Upstash Redis.' --- -
- -
+ [Redis](https://redis.io/about/) is an open source (BSD licensed), in-memory data structure store used as a database, cache, message broker, and streaming engine. It is optimized for atomic operations like incrementing a value, for example for a view counter or rate limiting. We can even rate limit based on the user ID from Supabase Auth! diff --git a/apps/docs/content/guides/functions/examples/screenshots.mdx b/apps/docs/content/guides/functions/examples/screenshots.mdx index 2e346f9ec9e..bcb93fd4d3e 100644 --- a/apps/docs/content/guides/functions/examples/screenshots.mdx +++ b/apps/docs/content/guides/functions/examples/screenshots.mdx @@ -3,14 +3,7 @@ title: 'Taking Screenshots with Puppeteer' description: 'Take screenshots in Edge Functions with Puppeteer and Browserless.io.' --- -
- -
+ [Puppeteer](https://pptr.dev/) is a handy tool to programmatically take screenshots and generate PDFs. However, trying to do so in Edge Functions can be challenging due to the size restrictions. Luckily there is a [serverless browser offering available](https://www.browserless.io/) that we can connect to via WebSockets. diff --git a/apps/docs/content/guides/functions/examples/stripe-webhooks.mdx b/apps/docs/content/guides/functions/examples/stripe-webhooks.mdx index 60bcf64cf3d..9bb1a115ada 100644 --- a/apps/docs/content/guides/functions/examples/stripe-webhooks.mdx +++ b/apps/docs/content/guides/functions/examples/stripe-webhooks.mdx @@ -3,14 +3,7 @@ title: 'Handling Stripe Webhooks' description: 'Handling signed Stripe Webhooks with Edge Functions.' --- -
- -
+ Handling signed Stripe Webhooks with Edge Functions. [View on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/index.ts). diff --git a/apps/docs/content/guides/functions/examples/telegram-bot.mdx b/apps/docs/content/guides/functions/examples/telegram-bot.mdx index 331ce642981..e6176450f97 100644 --- a/apps/docs/content/guides/functions/examples/telegram-bot.mdx +++ b/apps/docs/content/guides/functions/examples/telegram-bot.mdx @@ -5,13 +5,6 @@ description: 'Building a Telegram Bot with Edge Functions.' video: 'https://www.youtube.com/v/AWfE3a9J_uo' --- -
- -
+ Handle Telegram Bot Webhooks with the [grammY framework](https://grammy.dev/). grammY is an open source Telegram Bot Framework which makes it easy to handle and respond to incoming messages. [View on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/telegram-bot). diff --git a/apps/docs/content/guides/functions/examples/upstash-redis.mdx b/apps/docs/content/guides/functions/examples/upstash-redis.mdx index 69324662c1b..1db36766aa1 100644 --- a/apps/docs/content/guides/functions/examples/upstash-redis.mdx +++ b/apps/docs/content/guides/functions/examples/upstash-redis.mdx @@ -3,14 +3,7 @@ title: 'Upstash Redis' description: 'Build an Edge Functions Counter with Upstash Redis.' --- -
- -
+ A Redis counter example that stores a [hash](https://redis.io/commands/hincrby/) of function invocation count per region. Find the code on [GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/upstash-redis-counter). diff --git a/apps/docs/content/guides/functions/kysely-postgres.mdx b/apps/docs/content/guides/functions/kysely-postgres.mdx index 5bca2ba1c46..e9afabc7131 100644 --- a/apps/docs/content/guides/functions/kysely-postgres.mdx +++ b/apps/docs/content/guides/functions/kysely-postgres.mdx @@ -4,14 +4,7 @@ title: 'Type-Safe SQL with Kysely' description: 'Combining Kysely with Deno Postgres gives you a convenient developer experience for interacting directly with your Postgres database.' --- -
- -
+ Supabase Edge Functions can [connect directly to your Postgres database](/docs/guides/functions/connect-to-postgres) to execute SQL queries. [Kysely](https://github.com/kysely-org/kysely#kysely) is a type-safe and autocompletion-friendly typescript SQL query builder. diff --git a/apps/docs/content/guides/functions/schedule-functions.mdx b/apps/docs/content/guides/functions/schedule-functions.mdx index c5d33138795..6da4d090149 100644 --- a/apps/docs/content/guides/functions/schedule-functions.mdx +++ b/apps/docs/content/guides/functions/schedule-functions.mdx @@ -4,14 +4,7 @@ title: 'Scheduling Edge Functions' description: 'Schedule Edge Functions with pg_cron.' --- -
- -
+ The hosted Supabase Platform supports the [`pg_cron` extension](/docs/guides/database/extensions/pg_cron), a recurring job scheduler in Postgres. diff --git a/apps/docs/content/guides/local-development/database-migrations.mdx b/apps/docs/content/guides/local-development/database-migrations.mdx index d1acbc83519..6312ed41c2a 100644 --- a/apps/docs/content/guides/local-development/database-migrations.mdx +++ b/apps/docs/content/guides/local-development/database-migrations.mdx @@ -23,14 +23,7 @@ This page is a focused tutorial on migrations. If you want to move an existing p Database changes are managed through "migrations." Database migrations are a common way of tracking changes to your database over time. -
- -
+ For this guide, we'll create a table called `employees` and see how we can make changes to it. diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/heroku.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/heroku.mdx index 99209b9e86d..03666853f82 100644 --- a/apps/docs/content/guides/platform/migrating-to-supabase/heroku.mdx +++ b/apps/docs/content/guides/platform/migrating-to-supabase/heroku.mdx @@ -12,14 +12,7 @@ Alternatively, use the [Heroku to Supabase migration tool](https://migrate.supab ## Quick demo -
- -
+ ## Retrieve your Heroku database credentials [#retrieve-heroku-credentials] diff --git a/apps/docs/content/guides/realtime/realtime-listening-flutter.mdx b/apps/docs/content/guides/realtime/realtime-listening-flutter.mdx index cc7eb48b947..0123d5f47a4 100644 --- a/apps/docs/content/guides/realtime/realtime-listening-flutter.mdx +++ b/apps/docs/content/guides/realtime/realtime-listening-flutter.mdx @@ -7,11 +7,4 @@ sidebar_label: 'Videos' The Postgres Changes extension listens for database changes and sends them to clients which enables you to receive database changes in real-time. -
- -
+ diff --git a/apps/docs/content/guides/realtime/realtime-user-presence.mdx b/apps/docs/content/guides/realtime/realtime-user-presence.mdx index 8821ddfa9c1..324270cf9d3 100644 --- a/apps/docs/content/guides/realtime/realtime-user-presence.mdx +++ b/apps/docs/content/guides/realtime/realtime-user-presence.mdx @@ -9,11 +9,4 @@ Use Supabase Presence to display the currently online users on your Flutter appl Displaying the list of currently online users is a common feature for real-time collaborative applications. Supabase Presence makes it easy to track users joining and leaving the session so that you can make a collaborative app. -
- -
+ diff --git a/apps/docs/content/guides/realtime/realtime-with-nextjs.mdx b/apps/docs/content/guides/realtime/realtime-with-nextjs.mdx index 9aacabb0916..954cfc70093 100644 --- a/apps/docs/content/guides/realtime/realtime-with-nextjs.mdx +++ b/apps/docs/content/guides/realtime/realtime-with-nextjs.mdx @@ -8,11 +8,4 @@ sidebar_label: 'Videos' In this guide, we explore the best ways to receive real-time Postgres changes with your Next.js application. We'll show both client and server side updates, and explore which option is best. -
- -
+ diff --git a/apps/docs/content/guides/realtime/subscribing-to-database-changes.mdx b/apps/docs/content/guides/realtime/subscribing-to-database-changes.mdx index 6afbb77b6ac..a30fdce0212 100644 --- a/apps/docs/content/guides/realtime/subscribing-to-database-changes.mdx +++ b/apps/docs/content/guides/realtime/subscribing-to-database-changes.mdx @@ -93,13 +93,7 @@ const changes = supabase Postgres Changes require minimal setup, but have some [limitations](/docs/guides/realtime/postgres-changes#limitations) as your application scales. We recommend using Broadcast for most use cases. -
- -
+ ### Enable Postgres Changes diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index 94ceab3be31..5c1b8dc142f 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -609,14 +609,7 @@ Some suggested systems include: ## Demo -
- -
+ 1. The VPS instance is a DigitalOcean droplet. (For server requirements refer to [System requirements](#system-requirements)) 2. To access Studio, use the IPv4 IP address of your Droplet. diff --git a/apps/docs/content/guides/storage/quickstart.mdx b/apps/docs/content/guides/storage/quickstart.mdx index 7e3664283d1..e3a39dc60f0 100644 --- a/apps/docs/content/guides/storage/quickstart.mdx +++ b/apps/docs/content/guides/storage/quickstart.mdx @@ -314,11 +314,4 @@ create policy "Public Access" {/* Finish with a video. This also appears in the Sidebar via the "tocVideo" metadata */} -
- -
+ diff --git a/apps/docs/content/guides/storage/security/access-control.mdx b/apps/docs/content/guides/storage/security/access-control.mdx index f56c3c5ae7b..c3ec9d7c99b 100644 --- a/apps/docs/content/guides/storage/security/access-control.mdx +++ b/apps/docs/content/guides/storage/security/access-control.mdx @@ -102,14 +102,7 @@ create policy "Avatar images are publicly accessible." on storage.objects {/* Finish with a video. This also appears in the Sidebar via the "tocVideo" metadata */} -
- -
+ ## Bypassing access controls diff --git a/apps/docs/content/guides/storage/serving/image-transformations.mdx b/apps/docs/content/guides/storage/serving/image-transformations.mdx index 59362f6d45f..d7372e02d7a 100644 --- a/apps/docs/content/guides/storage/serving/image-transformations.mdx +++ b/apps/docs/content/guides/storage/serving/image-transformations.mdx @@ -801,11 +801,4 @@ IMGPROXY_URL=yourinternalimgproxyurl.internal.com {/* Finish with a video. This also appears in the Sidebar via the "tocVideo" metadata */} -
- -
+ diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index 592f8826926..ee38ff457ab 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -30,6 +30,7 @@ import { Accordion, AccordionItem } from '~/features/ui/Accordion' import { CodeBlock } from '~/features/ui/CodeBlock/CodeBlock' import { ShowUntil } from '~/features/ui/ShowUntil' import { TabPanel, Tabs } from '~/features/ui/Tabs' +import { YouTube } from '~/features/ui/YouTube' import { ArrowDown, Check, X } from 'lucide-react' import Link from 'next/link' import { type ComponentPropsWithoutRef } from 'react' @@ -115,6 +116,7 @@ const components = { TabPanel, TerraformProviderSchema, WrapperDashboardIntegration, + YouTube, a: MdxAnchor, h2: (props: ComponentPropsWithoutRef<'h2'>) => ( diff --git a/apps/docs/features/ui/YouTube.tsx b/apps/docs/features/ui/YouTube.tsx new file mode 100644 index 00000000000..773538b2801 --- /dev/null +++ b/apps/docs/features/ui/YouTube.tsx @@ -0,0 +1,20 @@ +import { cn } from 'ui' + +export const YouTube = ({ + id, + title, + className, +}: { + id: string + title: string + className?: string +}) => ( +
+