From 3ae9f2d66355e40a358b6c9635a774446cfc821e Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 19 Mar 2026 18:31:13 +0800 Subject: [PATCH 01/56] Simplify SaveQueueActionBar positioning logic to just CSS (#43958) ## Context Noticed that the position of the Save queue action bar shifts off screen after switching tables image Changes here just simplifies the positioning logic to be purely css --- .../footer/operations/SaveQueueActionBar.tsx | 41 +------------------ 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx index 8975417d268..4d35fa9ce2e 100644 --- a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx +++ b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx @@ -5,7 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { createPortal } from 'react-dom' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button } from 'ui' -import { useEffect, useRef, useState } from 'react' + import { getModKeyLabel } from '@/lib/helpers' export const SaveQueueActionBar = () => { @@ -13,7 +13,6 @@ export const SaveQueueActionBar = () => { const snap = useTableEditorStateSnapshot() const isQueueOperationsEnabled = useIsQueueOperationsEnabled() const { handleSave } = useOperationQueueActions() - const [leftPosition, setLeftPosition] = useState('50%') useOperationQueueShortcuts() @@ -24,46 +23,10 @@ export const SaveQueueActionBar = () => { const isVisible = isQueueOperationsEnabled && snap.hasPendingOperations && !isOperationQueuePanelOpen - // Center position relative to grid container (viewport alignment) - useEffect(() => { - if (!isVisible) return - - const gridContainer = document.querySelector('.sb-grid') - const updatePosition = () => { - if (!gridContainer) { - setLeftPosition('50%') - return - } - - const gridRect = gridContainer.getBoundingClientRect() - const gridCenter = gridRect.left + gridRect.width / 2 - setLeftPosition(`${gridCenter}px`) - } - - updatePosition() - - if (!gridContainer) return - - const resizeObserver = new ResizeObserver(updatePosition) - resizeObserver.observe(gridContainer) - - window.addEventListener('resize', updatePosition) - window.addEventListener('scroll', updatePosition, true) - - return () => { - resizeObserver.disconnect() - window.removeEventListener('resize', updatePosition) - window.removeEventListener('scroll', updatePosition, true) - } - }, [isVisible]) - const content = ( {isVisible && ( -
+
Date: Thu, 19 Mar 2026 18:31:46 +0800 Subject: [PATCH 02/56] Chore/shift manual queries into pg meta 03 (#43951) ## Context Shifting more dashboard queries into pg-meta so that we centralize all manually written queries in one place Having them in packages/pg-meta also allows us to write tests for them ## To test Just needs a smoke test on - Table Editor - Fetching entities - Viewing definition - SQL Editor - View ongoing queries - Abort queries - Integrations - Queues - Database - Migrations -Triggers (Updating) --- .../footer/pagination/Pagination.tsx | 2 +- .../header/sort/SortPopoverPrimitive.tsx | 2 +- .../interfaces/Auth/Users/UsersFooter.tsx | 3 +- .../Integrations/Queues/QueuesSettings.tsx | 15 +- ...se-queues-expose-postgrest-status-query.ts | 13 +- ...tabase-queues-toggle-postgrest-mutation.ts | 227 +---- ...ase-trigger-update-transaction-mutation.ts | 24 +- .../studio/data/database/constraints-query.ts | 34 +- .../data/database/database-size-query.ts | 12 +- .../database/database-table-definition.ts | 708 ---------------- .../data/database/entity-definitions-query.ts | 77 +- .../database/foreign-key-constraints-query.ts | 58 +- apps/studio/data/database/keywords-query.ts | 12 +- .../data/database/max-connections-query.ts | 8 +- apps/studio/data/database/migrations-query.ts | 13 +- .../database/primary-keys-exists-query.ts | 55 -- .../data/database/table-columns-query.ts | 77 +- .../data/database/table-definition-query.ts | 33 +- .../database/table-index-advisor-query.ts | 60 +- .../data/database/view-definition-query.ts | 29 +- .../entity-types-infinite-query.ts | 60 +- .../data/read-replicas/replica-lag-query.ts | 17 +- apps/studio/data/sql/abort-query-mutation.ts | 7 +- apps/studio/data/sql/ongoing-queries-query.ts | 14 +- .../data/tables/tables-roles-access-query.ts | 4 +- apps/studio/hooks/useProtectedSchemas.ts | 2 +- apps/studio/lib/ai/tools/fallback-tools.ts | 7 +- packages/pg-meta/src/index.ts | 62 +- .../src/sql/studio/advisor/index-advisor.ts | 55 ++ .../pg-meta/src/sql/studio/advisor/index.ts | 1 + .../src/sql/studio/auth/get-users-count.ts | 2 +- .../sql/studio/check-primary-keys-exists.ts | 20 - .../check-tables-anon-authenticated-access.ts | 0 .../src/sql/studio/database/columns.ts | 74 ++ .../{ => database}/get-count-estimate.ts | 0 .../pg-meta/src/sql/studio/database/index.ts | 10 + .../src/sql/studio/database/migrations.ts | 10 + .../pg-meta/src/sql/studio/database/misc.ts | 39 + .../sql/studio/{ => database}/privileges.ts | 0 .../pg-meta/src/sql/studio/database/queues.ts | 215 +++++ .../pg-meta/src/sql/studio/database/rows.ts | 2 +- .../sql/studio/database/table-definition.ts | 802 ++++++++++++++++++ .../src/sql/studio/database/triggers.ts | 38 + .../pg-meta/src/sql/studio/database/views.ts | 21 + .../src/sql/studio/get-live-tuple-estimate.ts | 12 - .../src/sql/studio/sql-editor/abort-query.ts | 3 + .../src/sql/studio/sql-editor/index.ts | 3 + .../src/sql/studio/sql-editor/keywords.ts | 7 + .../sql/studio/sql-editor/ongoing-queries.ts | 7 + .../sql/studio/table-editor/constraints.ts | 28 + .../src/sql/studio/table-editor/entities.ts | 75 ++ .../sql/studio/table-editor/foreign-keys.ts | 55 ++ .../src/sql/studio/table-editor/index.ts | 1 + 53 files changed, 1537 insertions(+), 1578 deletions(-) delete mode 100644 apps/studio/data/database/database-table-definition.ts delete mode 100644 apps/studio/data/database/primary-keys-exists-query.ts create mode 100644 packages/pg-meta/src/sql/studio/advisor/index-advisor.ts create mode 100644 packages/pg-meta/src/sql/studio/advisor/index.ts delete mode 100644 packages/pg-meta/src/sql/studio/check-primary-keys-exists.ts rename packages/pg-meta/src/sql/studio/{ => database}/check-tables-anon-authenticated-access.ts (100%) create mode 100644 packages/pg-meta/src/sql/studio/database/columns.ts rename packages/pg-meta/src/sql/studio/{ => database}/get-count-estimate.ts (100%) create mode 100644 packages/pg-meta/src/sql/studio/database/migrations.ts create mode 100644 packages/pg-meta/src/sql/studio/database/misc.ts rename packages/pg-meta/src/sql/studio/{ => database}/privileges.ts (100%) create mode 100644 packages/pg-meta/src/sql/studio/database/queues.ts create mode 100644 packages/pg-meta/src/sql/studio/database/table-definition.ts create mode 100644 packages/pg-meta/src/sql/studio/database/triggers.ts create mode 100644 packages/pg-meta/src/sql/studio/database/views.ts delete mode 100644 packages/pg-meta/src/sql/studio/get-live-tuple-estimate.ts create mode 100644 packages/pg-meta/src/sql/studio/sql-editor/abort-query.ts create mode 100644 packages/pg-meta/src/sql/studio/sql-editor/index.ts create mode 100644 packages/pg-meta/src/sql/studio/sql-editor/keywords.ts create mode 100644 packages/pg-meta/src/sql/studio/sql-editor/ongoing-queries.ts create mode 100644 packages/pg-meta/src/sql/studio/table-editor/entities.ts diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index 4a4b2914742..4a1cade3260 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -1,4 +1,4 @@ -import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate' +import { THRESHOLD_COUNT } from '@supabase/pg-meta' import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' import { useTableFilter } from 'components/grid/hooks/useTableFilter' diff --git a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx index ee26db7e751..963e981f129 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx @@ -1,4 +1,4 @@ -import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/query/table-row-query' +import { THRESHOLD_COUNT } from '@supabase/pg-meta' import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' import { useTableFilter } from 'components/grid/hooks/useTableFilter' diff --git a/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx b/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx index 1edf69ca47d..38295c01e47 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx @@ -1,5 +1,4 @@ -import { OptimizedSearchColumns } from '@supabase/pg-meta' -import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate' +import { THRESHOLD_COUNT, type OptimizedSearchColumns } from '@supabase/pg-meta' import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' import { formatEstimatedCount } from 'components/grid/components/footer/pagination/Pagination.utils' diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx index e39d4506f02..65ae0420e1a 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx @@ -1,10 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { QUEUES_SCHEMA } from '@supabase/pg-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import { z } from 'zod' - import { DocsButton } from 'components/ui/DocsButton' import { FormHeader } from 'components/ui/Forms/FormHeader' import { @@ -16,16 +12,16 @@ import { InlineLink } from 'components/ui/InlineLink' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' import { useProjectPostgrestConfigUpdateMutation } from 'data/config/project-postgrest-config-update-mutation' import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query' -import { - QUEUES_SCHEMA, - useDatabaseQueueToggleExposeMutation, -} from 'data/database-queues/database-queues-toggle-postgrest-mutation' +import { useDatabaseQueueToggleExposeMutation } from 'data/database-queues/database-queues-toggle-postgrest-mutation' import { useDatabaseQueuesVersionQuery } from 'data/database-queues/database-queues-version-query' import { useTableUpdateMutation } from 'data/tables/table-update-mutation' import { useTablesQuery } from 'data/tables/tables-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL, IS_PLATFORM } from 'lib/constants' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' import { Button, Form_Shadcn_, @@ -37,6 +33,7 @@ import { import { Admonition } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { z } from 'zod' // [Joshen] Not convinced with the UI and layout but getting the functionality out first diff --git a/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts b/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts index 56515efef60..af1a4af7300 100644 --- a/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts +++ b/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts @@ -1,9 +1,8 @@ +import { getQueuesExposePostgrestStatusSQL } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' -import minify from 'pg-minify' - import { executeSql } from 'data/sql/execute-sql-query' import type { ResponseError, UseCustomQueryOptions } from 'types' -import { QUEUES_SCHEMA } from './database-queues-toggle-postgrest-mutation' + import { databaseQueuesKeys } from './keys' export type DatabaseQueuesVariables = { @@ -11,21 +10,17 @@ export type DatabaseQueuesVariables = { connectionString?: string | null } -// [Joshen] Check if all the relevant functions exist to indicate whether PGMQ has been exposed through PostgREST -const queueSqlQuery = minify(/**SQL */ ` - SELECT exists (select schema_name FROM information_schema.schemata WHERE schema_name = '${QUEUES_SCHEMA}'); -`) - export async function getDatabaseQueuesExposePostgrestStatus({ projectRef, connectionString, }: DatabaseQueuesVariables) { if (!projectRef) throw new Error('Project ref is required') + const sql = getQueuesExposePostgrestStatusSQL() const { result } = await executeSql({ projectRef, connectionString, - sql: queueSqlQuery, + sql, }) return result[0].exists as boolean } diff --git a/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts b/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts index bd637945550..049c3494e6c 100644 --- a/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts +++ b/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts @@ -1,11 +1,11 @@ +import { getExposeQueuesSQL, HIDE_QUEUES_FROM_POSTGREST_SQL } from '@supabase/pg-meta' import { useMutation, useQueryClient } from '@tanstack/react-query' -import minify from 'pg-minify' -import { toast } from 'sonner' - import { databaseKeys } from 'data/database/keys' import { executeSql } from 'data/sql/execute-sql-query' import { isGreaterThanOrEqual } from 'lib/semver' +import { toast } from 'sonner' import type { ResponseError, UseCustomMutationOptions } from 'types' + import { databaseQueuesKeys } from './keys' export type DatabaseQueueExposePostgrestVariables = { @@ -17,221 +17,6 @@ export type DatabaseQueueExposePostgrestVariables = { } const CONDITIONAL_READ_SIGNATURE_PGMQ_VERSION = '1.5.0' -export const QUEUES_SCHEMA = 'pgmq_public' - -const getExposeQueuesSQL = (pgmqVersion: string) => { - const conditionalJsonb = isGreaterThanOrEqual( - pgmqVersion, - CONDITIONAL_READ_SIGNATURE_PGMQ_VERSION - ) - ? `, conditional := '{}'::jsonb` - : '' - const jsonBArg = isGreaterThanOrEqual(pgmqVersion, CONDITIONAL_READ_SIGNATURE_PGMQ_VERSION) - ? `, jsonb` - : '' - - return minify(/* SQL */ ` - create schema if not exists ${QUEUES_SCHEMA}; - grant usage on schema ${QUEUES_SCHEMA} to postgres, anon, authenticated, service_role; - - create or replace function ${QUEUES_SCHEMA}.pop( - queue_name text - ) - returns setof pgmq.message_record - language plpgsql - set search_path = '' - as $$ - begin - return query - select * - from pgmq.pop( - queue_name := queue_name - ); - end; - $$; - - comment on function ${QUEUES_SCHEMA}.pop(queue_name text) is 'Retrieves and locks the next message from the specified queue.'; - - - create or replace function ${QUEUES_SCHEMA}.send( - queue_name text, - message jsonb, - sleep_seconds integer default 0 -- renamed from 'delay' - ) - returns setof bigint - language plpgsql - set search_path = '' - as $$ - begin - return query - select * - from pgmq.send( - queue_name := queue_name, - msg := message, - delay := sleep_seconds - ); - end; - $$; - - comment on function ${QUEUES_SCHEMA}.send(queue_name text, message jsonb, sleep_seconds integer) is 'Sends a message to the specified queue, optionally delaying its availability by a number of seconds.'; - - - create or replace function ${QUEUES_SCHEMA}.send_batch( - queue_name text, - messages jsonb[], - sleep_seconds integer default 0 -- renamed from 'delay' - ) - returns setof bigint - language plpgsql - set search_path = '' - as $$ - begin - return query - select * - from pgmq.send_batch( - queue_name := queue_name, - msgs := messages, - delay := sleep_seconds - ); - end; - $$; - - comment on function ${QUEUES_SCHEMA}.send_batch(queue_name text, messages jsonb[], sleep_seconds integer) is 'Sends a batch of messages to the specified queue, optionally delaying their availability by a number of seconds.'; - - - create or replace function ${QUEUES_SCHEMA}.archive( - queue_name text, - message_id bigint - ) - returns boolean - language plpgsql - set search_path = '' - as $$ - begin - return - pgmq.archive( - queue_name := queue_name, - msg_id := message_id - ); - end; - $$; - - comment on function ${QUEUES_SCHEMA}.archive(queue_name text, message_id bigint) is 'Archives a message by moving it from the queue to a permanent archive.'; - - - create or replace function ${QUEUES_SCHEMA}.delete( - queue_name text, - message_id bigint - ) - returns boolean - language plpgsql - set search_path = '' - as $$ - begin - return - pgmq.delete( - queue_name := queue_name, - msg_id := message_id - ); - end; - $$; - - comment on function ${QUEUES_SCHEMA}.delete(queue_name text, message_id bigint) is 'Permanently deletes a message from the specified queue.'; - - create or replace function ${QUEUES_SCHEMA}.read( - queue_name text, - sleep_seconds integer, - n integer - ) - returns setof pgmq.message_record - language plpgsql - set search_path = '' - as $$ - begin - return query - select * - from pgmq.read( - queue_name := queue_name, - vt := sleep_seconds, - qty := n ${conditionalJsonb} - ); - end; - $$; - - comment on function ${QUEUES_SCHEMA}.read(queue_name text, sleep_seconds integer, n integer) is 'Reads up to "n" messages from the specified queue with an optional "sleep_seconds" (visibility timeout).'; - - -- Grant execute permissions on wrapper functions to roles - grant execute on function ${QUEUES_SCHEMA}.pop(text) to postgres, service_role, anon, authenticated; - grant execute on function pgmq.pop(text) to postgres, service_role, anon, authenticated; - - grant execute on function ${QUEUES_SCHEMA}.send(text, jsonb, integer) to postgres, service_role, anon, authenticated; - grant execute on function pgmq.send(text, jsonb, integer) to postgres, service_role, anon, authenticated; - - grant execute on function ${QUEUES_SCHEMA}.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; - grant execute on function pgmq.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; - - grant execute on function ${QUEUES_SCHEMA}.archive(text, bigint) to postgres, service_role, anon, authenticated; - grant execute on function pgmq.archive(text, bigint) to postgres, service_role, anon, authenticated; - - grant execute on function ${QUEUES_SCHEMA}.delete(text, bigint) to postgres, service_role, anon, authenticated; - grant execute on function pgmq.delete(text, bigint) to postgres, service_role, anon, authenticated; - - grant execute on function ${QUEUES_SCHEMA}.read(text, integer, integer) to postgres, service_role, anon, authenticated; - grant execute on function pgmq.read(text, integer, integer ${jsonBArg}) to postgres, service_role, anon, authenticated; - - -- For the service role, we want full access - -- Grant permissions on existing tables - grant all privileges on all tables in schema pgmq to postgres, service_role; - - -- Ensure service_role has permissions on future tables - alter default privileges in schema pgmq grant all privileges on tables to postgres, service_role; - - grant usage on schema pgmq to postgres, anon, authenticated, service_role; - - - /* - Grant access to sequences to API roles by default. Existing table permissions - continue to enforce insert restrictions. This is necessary to accommodate the - on-backup hook that rebuild queue table primary keys to avoid a pg_dump segfault. - This can be removed once logical backups are completely retired. - */ - grant usage, select, update - on all sequences in schema pgmq - to anon, authenticated, service_role; - - alter default privileges in schema pgmq - grant usage, select, update - on sequences - to anon, authenticated, service_role; -`) -} - -const HIDE_QUEUES_FROM_POSTGREST_SQL = minify(/* SQL */ ` - drop function if exists - ${QUEUES_SCHEMA}.pop(queue_name text), - ${QUEUES_SCHEMA}.send(queue_name text, message jsonb, sleep_seconds integer), - ${QUEUES_SCHEMA}.send_batch(queue_name text, message jsonb[], sleep_seconds integer), - ${QUEUES_SCHEMA}.archive(queue_name text, message_id bigint), - ${QUEUES_SCHEMA}.delete(queue_name text, message_id bigint), - ${QUEUES_SCHEMA}.read(queue_name text, sleep integer, n integer) - ; - - -- Revoke execute permissions on inner pgmq functions to roles (inverse of enabling) - do $$ - begin - if exists (select 1 from pg_namespace where nspname = 'pgmq') then - -- Revoke privileges on the schema itself - revoke all on schema pgmq from anon, authenticated, service_role; - - -- Revoke default privileges for future objects - alter default privileges in schema pgmq revoke all on tables from anon, authenticated, service_role; - alter default privileges in schema pgmq revoke all on sequences from anon, authenticated, service_role; - alter default privileges in schema pgmq revoke all on functions from anon, authenticated, service_role; - end if; - end $$; - - drop schema if exists ${QUEUES_SCHEMA}; -`) export async function toggleQueuesExposurePostgrest({ projectRef, @@ -239,7 +24,11 @@ export async function toggleQueuesExposurePostgrest({ connectionString, enable, }: DatabaseQueueExposePostgrestVariables) { - const sql = enable ? getExposeQueuesSQL(pgmqVersion) : HIDE_QUEUES_FROM_POSTGREST_SQL + const isNewerPgmqversion = isGreaterThanOrEqual( + pgmqVersion, + CONDITIONAL_READ_SIGNATURE_PGMQ_VERSION + ) + const sql = enable ? getExposeQueuesSQL({ isNewerPgmqversion }) : HIDE_QUEUES_FROM_POSTGREST_SQL const { result } = await executeSql({ projectRef, diff --git a/apps/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts b/apps/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts index a71b0425932..4786808fc8e 100644 --- a/apps/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts +++ b/apps/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts @@ -1,11 +1,11 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - +import { getDatabaseTriggerUpdateSQL } from '@supabase/pg-meta' import { PGTrigger, PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers' import { PostgresTrigger } from '@supabase/postgres-meta' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { executeSql } from 'data/sql/execute-sql-query' -import { quoteLiteral } from 'lib/pg-format' +import { toast } from 'sonner' import type { ResponseError, UseCustomMutationOptions } from 'types' + import { databaseTriggerKeys } from './keys' // [Joshen] Writing this query within FE as the PATCH endpoint from pg-meta only supports updating @@ -19,22 +19,6 @@ export type DatabaseTriggerUpdateVariables = { updatedTrigger: PGTriggerCreate & Pick } -export function getDatabaseTriggerUpdateSQL({ - originalTrigger, - updatedTrigger, -}: Pick) { - const { name, activation, events, schema, table, function_schema, function_name, function_args } = - updatedTrigger - return /* SQL */ ` -BEGIN; -DROP TRIGGER "${originalTrigger.name}" ON "${originalTrigger.schema}"."${originalTrigger.table}"; -CREATE TRIGGER "${name}" ${activation} ${events.join(' OR ')} ON "${schema}"."${table}" - FOR EACH ROW EXECUTE FUNCTION - "${function_schema}"."${function_name}"(${function_args?.map(quoteLiteral).join(',') ?? ''}); -COMMIT; -`.trim() -} - export async function updateDatabaseTrigger({ projectRef, connectionString, diff --git a/apps/studio/data/database/constraints-query.ts b/apps/studio/data/database/constraints-query.ts index 22fc7c249ec..2be0ade1e49 100644 --- a/apps/studio/data/database/constraints-query.ts +++ b/apps/studio/data/database/constraints-query.ts @@ -1,7 +1,9 @@ +import { getTableConstraintsSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' +import { UseCustomQueryOptions } from 'types' + import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { databaseKeys } from './keys' -import { UseCustomQueryOptions } from 'types' type GetTableConstraintsVariables = { id?: number @@ -22,34 +24,6 @@ export enum CONSTRAINT_TYPE { EXCLUSION_CONSTRAINT = 'x', } -export const getTableConstraintsSql = ({ id }: GetTableConstraintsVariables) => { - const sql = /* SQL */ ` - with table_info as ( - select - n.nspname::text as schema, - c.relname::text as name, - to_regclass(concat('"', n.nspname, '"."', c.relname, '"')) as regclass - from pg_class c - join pg_namespace n on n.oid = c.relnamespace - where c.oid = ${id} -) -select - con.oid as id, - con.conname as name, - con.contype as type -from pg_catalog.pg_constraint con -inner join pg_catalog.pg_class rel - on rel.oid = con.conrelid -inner join pg_catalog.pg_namespace nsp - on nsp.oid = connamespace -inner join table_info ti - on ti.schema = nsp.nspname - and ti.name = rel.relname; -`.trim() - - return sql -} - export type TableConstraintsVariables = GetTableConstraintsVariables & { projectRef?: string connectionString?: string | null @@ -62,6 +36,8 @@ export async function getTableConstraints( { projectRef, connectionString, id }: TableConstraintsVariables, signal?: AbortSignal ) { + if (!id) throw new Error('Table ID is required') + const sql = getTableConstraintsSql({ id }) const { result } = await executeSql( { projectRef, connectionString, sql, queryKey: ['table-constraints', id] }, diff --git a/apps/studio/data/database/database-size-query.ts b/apps/studio/data/database/database-size-query.ts index e2ac985f56a..9601c6ed8e3 100644 --- a/apps/studio/data/database/database-size-query.ts +++ b/apps/studio/data/database/database-size-query.ts @@ -1,15 +1,9 @@ +import { getDatabaseSizeSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' -import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' -import { databaseKeys } from './keys' import { UseCustomQueryOptions } from 'types' -export const getDatabaseSizeSql = () => { - const sql = /* SQL */ ` -select sum(pg_database_size(pg_database.datname))::bigint as db_size from pg_database; -`.trim() - - return sql -} +import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' +import { databaseKeys } from './keys' export type DatabaseSizeVariables = { projectRef?: string diff --git a/apps/studio/data/database/database-table-definition.ts b/apps/studio/data/database/database-table-definition.ts deleted file mode 100644 index 59baf815eee..00000000000 --- a/apps/studio/data/database/database-table-definition.ts +++ /dev/null @@ -1,708 +0,0 @@ -import minify from 'pg-minify' - -// Derived from https://github.com/MichaelDBA/pg_get_tabledef -// NOTE: when updating, \n must be replaced with \\n in the SQL below -// public.x is replaced with pg_temp.x - -// REMOVE (as it breaks the encoding for some reason): -// SELECT REPLACE(REPLACE(setting, '"$user"', '$user'), '$user', '"$user"') INTO search_path_old -// FROM pg_settings -// WHERE name = 'search_path'; - -export const CREATE_PG_GET_TABLEDEF_SQL = minify( - /* SQL */ ` - DROP TYPE IF EXISTS pg_temp.tabledefs CASCADE; - CREATE TYPE pg_temp.tabledefs AS ENUM ('PKEY_INTERNAL','PKEY_EXTERNAL','FKEYS_INTERNAL', 'FKEYS_EXTERNAL', 'COMMENTS', 'FKEYS_NONE', 'INCLUDE_TRIGGERS', 'NO_TRIGGERS'); - - -- SELECT * FROM pg_temp.pg_get_coldef('sample','orders','id'); - -- DROP FUNCTION pg_temp.pg_get_coldef(text,text,text,boolean); - CREATE OR REPLACE FUNCTION pg_temp.pg_get_coldef( - in_schema text, - in_table text, - in_column text, - oldway boolean default False - ) - RETURNS text - LANGUAGE plpgsql VOLATILE - AS - $$ - DECLARE - v_coldef text; - v_dt1 text; - v_dt2 text; - v_dt3 text; - v_nullable boolean; - v_position int; - v_identity text; - v_generated text; - v_hasdflt boolean; - v_dfltexpr text; - - BEGIN - IF oldway THEN - SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) INTO v_coldef FROM pg_namespace n, pg_class c, pg_attribute a, pg_type t - WHERE n.nspname = in_schema AND n.oid = c.relnamespace AND c.relname = in_table AND a.attname = in_column and a.attnum > 0 AND a.attrelid = c.oid AND a.atttypid = t.oid ORDER BY a.attnum; - -- RAISE NOTICE 'DEBUG: oldway=%',v_coldef; - ELSE - -- a.attrelid::regclass::text, a.attname - SELECT CASE WHEN a.atttypid = ANY ('{int,int8,int2}'::regtype[]) AND EXISTS (SELECT FROM pg_attrdef ad WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum AND - pg_get_expr(ad.adbin, ad.adrelid) = 'nextval(''' || (pg_get_serial_sequence (a.attrelid::regclass::text, a.attname))::regclass || '''::regclass)') THEN CASE a.atttypid - WHEN 'int'::regtype THEN 'serial' WHEN 'int8'::regtype THEN 'bigserial' WHEN 'int2'::regtype THEN 'smallserial' END ELSE format_type(a.atttypid, a.atttypmod) END AS data_type - INTO v_coldef FROM pg_namespace n, pg_class c, pg_attribute a, pg_type t - WHERE n.nspname = in_schema AND n.oid = c.relnamespace AND c.relname = in_table AND a.attname = in_column and a.attnum > 0 AND a.attrelid = c.oid AND a.atttypid = t.oid ORDER BY a.attnum; - -- RAISE NOTICE 'DEBUG: newway=%',v_coldef; - - -- Issue#24: not implemented yet - -- might replace with this below to do more detailed parsing... - -- SELECT a.atttypid::regtype AS dt1, format_type(a.atttypid, a.atttypmod) as dt2, t.typname as dt3, CASE WHEN not(a.attnotnull) THEN True ELSE False END AS nullable, - -- a.attnum, a.attidentity, a.attgenerated, a.atthasdef, pg_get_expr(ad.adbin, ad.adrelid) dfltexpr - -- INTO v_dt1, v_dt2, v_dt3, v_nullable, v_position, v_identity, v_generated, v_hasdflt, v_dfltexpr - -- FROM pg_attribute a JOIN pg_class c ON (a.attrelid = c.oid) JOIN pg_type t ON (a.atttypid = t.oid) LEFT JOIN pg_attrdef ad ON (a.attrelid = ad.adrelid AND a.attnum = ad.adnum) - -- WHERE c.relkind in ('r','p') AND a.attnum > 0 AND NOT a.attisdropped AND c.relnamespace::regnamespace::text = in_schema AND c.relname = in_table AND a.attname = in_column; - -- RAISE NOTICE 'schema=% table=% column=% dt1=% dt2=% dt3=% nullable=% pos=% identity=% generated=% HasDefault=% DeftExpr=%', in_schema, in_table, in_column, v_dt1,v_dt2,v_dt3,v_nullable,v_position,v_identity,v_generated,v_hasdflt,v_dfltexpr; - END IF; - RETURN v_coldef; - END; - $$; - - -- SELECT * FROM pg_temp.pg_get_tabledef('sample', 'address', false); - DROP FUNCTION IF EXISTS pg_temp.pg_get_tabledef(character varying,character varying,boolean,tabledefs[]); - CREATE OR REPLACE FUNCTION pg_temp.pg_get_tabledef( - in_schema varchar, - in_table varchar, - _verbose boolean, - VARIADIC arr pg_temp.tabledefs[] DEFAULT '{}':: pg_temp.tabledefs[] - ) - RETURNS text - LANGUAGE plpgsql VOLATILE - AS - $$ - DECLARE - v_qualified text := ''; - v_table_ddl text; - v_table_oid int; - v_colrec record; - v_constraintrec record; - v_trigrec record; - v_indexrec record; - v_rec record; - v_constraint_name text; - v_constraint_def text; - v_pkey_def text := ''; - v_fkey_def text := ''; - v_fkey_defs text := ''; - v_trigger text := ''; - v_partition_key text := ''; - v_partbound text; - v_parent text; - v_parent_schema text; - v_persist text; - v_temp text := ''; - v_temp2 text; - v_relopts text; - v_tablespace text; - v_pgversion int; - bSerial boolean; - bPartition boolean; - bInheritance boolean; - bRelispartition boolean; - constraintarr text[] := '{}'; - constraintelement text; - bSkip boolean; - bVerbose boolean := False; - v_cnt1 integer; - v_cnt2 integer; - search_path_old text := ''; - search_path_new text := ''; - v_partial boolean; - v_pos integer; - - -- assume defaults for ENUMs at the getgo - pkcnt int := 0; - fkcnt int := 0; - trigcnt int := 0; - cmtcnt int := 0; - pktype pg_temp.tabledefs := 'PKEY_INTERNAL'; - fktype pg_temp.tabledefs := 'FKEYS_INTERNAL'; - trigtype pg_temp.tabledefs := 'NO_TRIGGERS'; - arglen integer; - vargs text; - avarg pg_temp.tabledefs; - - -- exception variables - v_ret text; - v_diag1 text; - v_diag2 text; - v_diag3 text; - v_diag4 text; - v_diag5 text; - v_diag6 text; - - BEGIN - SET client_min_messages = 'notice'; - IF _verbose THEN bVerbose = True; END IF; - - -- v17 fix: handle case-sensitive - -- v_qualified = in_schema || '.' || in_table; - - arglen := array_length($4, 1); - IF arglen IS NULL THEN - -- nothing to do, so assume defaults - NULL; - ELSE - -- loop thru args - -- IF 'NO_TRIGGERS' = ANY ($4) - -- select array_to_string($4, ',', '***') INTO vargs; - IF bVerbose THEN RAISE NOTICE 'arguments=%', $4; END IF; - FOREACH avarg IN ARRAY $4 LOOP - IF bVerbose THEN RAISE NOTICE 'arg=%', avarg; END IF; - IF avarg = 'FKEYS_INTERNAL' OR avarg = 'FKEYS_EXTERNAL' OR avarg = 'FKEYS_NONE' THEN - fkcnt = fkcnt + 1; - fktype = avarg; - ELSEIF avarg = 'INCLUDE_TRIGGERS' OR avarg = 'NO_TRIGGERS' THEN - trigcnt = trigcnt + 1; - trigtype = avarg; - ELSEIF avarg = 'PKEY_EXTERNAL' THEN - pkcnt = pkcnt + 1; - pktype = avarg; - ELSEIF avarg = 'COMMENTS' THEN - cmtcnt = cmtcnt + 1; - - END IF; - END LOOP; - IF fkcnt > 1 THEN - RAISE WARNING 'Only one foreign key option can be provided. You provided %', fkcnt; - RETURN ''; - ELSEIF trigcnt > 1 THEN - RAISE WARNING 'Only one trigger option can be provided. You provided %', trigcnt; - RETURN ''; - ELSEIF pkcnt > 1 THEN - RAISE WARNING 'Only one pkey option can be provided. You provided %', pkcnt; - RETURN ''; - ELSEIF cmtcnt > 1 THEN - RAISE WARNING 'Only one comments option can be provided. You provided %', cmtcnt; - RETURN ''; - - END IF; - END IF; - - SELECT c.oid, (select setting from pg_settings where name = 'server_version_num') INTO v_table_oid, v_pgversion FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind in ('r','p') AND c.relname = in_table AND n.nspname = in_schema; - - -- set search_path = public before we do anything to force explicit schema qualification but dont forget to set it back before exiting... - SELECT setting INTO search_path_old FROM pg_settings WHERE name = 'search_path'; - - -- RAISE NOTICE 'DEBUG tableddl: saving old search_path: ***%***', search_path_old; - EXECUTE 'SET search_path = "public"'; - SELECT setting INTO search_path_new FROM pg_settings WHERE name = 'search_path'; - -- RAISE NOTICE 'DEBUG tableddl: using new search path=***%***', search_path_new; - - -- throw an error if table was not found - IF (v_table_oid IS NULL) THEN - RAISE EXCEPTION 'table does not exist'; - END IF; - - -- get user-defined tablespaces if applicable - SELECT tablespace INTO v_temp FROM pg_tables WHERE schemaname = in_schema and tablename = in_table and tablespace IS NOT NULL; - IF v_temp IS NULL THEN - v_tablespace := 'TABLESPACE pg_default'; - ELSE - v_tablespace := 'TABLESPACE ' || v_temp; - END IF; - - -- also see if there are any SET commands for this table, ie, autovacuum_enabled=off, fillfactor=70 - WITH relopts AS (SELECT unnest(c.reloptions) relopts FROM pg_class c, pg_namespace n WHERE n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table) - SELECT string_agg(r.relopts, ', ') as relopts INTO v_temp from relopts r; - IF v_temp IS NULL THEN - v_relopts := ''; - ELSE - v_relopts := ' WITH (' || v_temp || ')'; - END IF; - - -- ----------------------------------------------------------------------------------- - -- Create table defs for partitions/children using inheritance or declarative methods. - -- inheritance: pg_class.relkind = 'r' pg_class.relispartition=false pg_class.relpartbound is NULL - -- declarative: pg_class.relkind = 'r' pg_class.relispartition=true pg_class.relpartbound is NOT NULL - -- ----------------------------------------------------------------------------------- - v_partbound := ''; - bPartition := False; - bInheritance := False; - IF v_pgversion < 100000 THEN - -- Issue#11: handle parent schema - SELECT c2.relname parent, c2.relnamespace::regnamespace INTO v_parent, v_parent_schema from pg_class c1, pg_namespace n, pg_inherits i, pg_class c2 - WHERE n.nspname = in_schema and n.oid = c1.relnamespace and c1.relname = in_table and c1.oid = i.inhrelid and i.inhparent = c2.oid and c1.relkind = 'r'; - IF (v_parent IS NOT NULL) THEN - bPartition := True; - bInheritance := True; - END IF; - ELSE - -- Issue#11: handle parent schema - SELECT c2.relname parent, c1.relispartition, pg_get_expr(c1.relpartbound, c1.oid, true), c2.relnamespace::regnamespace INTO v_parent, bRelispartition, v_partbound, v_parent_schema from pg_class c1, pg_namespace n, pg_inherits i, pg_class c2 - WHERE n.nspname = in_schema and n.oid = c1.relnamespace and c1.relname = in_table and c1.oid = i.inhrelid and i.inhparent = c2.oid and c1.relkind = 'r'; - IF (v_parent IS NOT NULL) THEN - bPartition := True; - IF bRelispartition THEN - bInheritance := False; - ELSE - bInheritance := True; - END IF; - END IF; - END IF; - IF bPartition THEN - --Issue#17 fix for case-sensitive tables - -- SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s - -- WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = quote_ident(in_schema) AND t.table_name = quote_ident(in_table) AND t.table_type = 'BASE TABLE'); - SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s - WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = in_schema AND t.table_name = in_table AND t.table_type = 'BASE TABLE'); - - --Issue#19 put double-quotes around SQL keyword column names - -- Issue#121: fix keyword lookup for table name not column name that does not apply here - -- SELECT COUNT(*) INTO v_cnt2 FROM pg_get_keywords() WHERE word = v_colrec.column_name AND catcode = 'R'; - SELECT COUNT(*) INTO v_cnt2 FROM pg_get_keywords() WHERE word = in_table AND catcode = 'R'; - - IF bInheritance THEN - -- inheritance-based - IF v_cnt1 > 0 OR v_cnt2 > 0 THEN - v_table_ddl := 'CREATE TABLE ' || in_schema || '."' || in_table || '"( '|| E'\\n'; - ELSE - v_table_ddl := 'CREATE TABLE ' || in_schema || '.' || in_table || '( '|| E'\\n'; - END IF; - - -- Jump to constraints section to add the check constraints - ELSE - -- declarative-based - IF v_relopts <> '' THEN - IF v_cnt1 > 0 OR v_cnt2 > 0 THEN - v_table_ddl := 'CREATE TABLE ' || in_schema || '."' || in_table || '" PARTITION OF ' || in_schema || '.' || v_parent || ' ' || v_partbound || v_relopts || ' ' || v_tablespace || '; ' || E'\\n'; - ELSE - v_table_ddl := 'CREATE TABLE ' || in_schema || '.' || in_table || ' PARTITION OF ' || in_schema || '.' || v_parent || ' ' || v_partbound || v_relopts || ' ' || v_tablespace || '; ' || E'\\n'; - END IF; - ELSE - IF v_cnt1 > 0 OR v_cnt2 > 0 THEN - v_table_ddl := 'CREATE TABLE ' || in_schema || '."' || in_table || '" PARTITION OF ' || in_schema || '.' || v_parent || ' ' || v_partbound || ' ' || v_tablespace || '; ' || E'\\n'; - ELSE - v_table_ddl := 'CREATE TABLE ' || in_schema || '.' || in_table || ' PARTITION OF ' || in_schema || '.' || v_parent || ' ' || v_partbound || ' ' || v_tablespace || '; ' || E'\\n'; - END IF; - END IF; - -- Jump to constraints and index section to add the check constraints and indexes and perhaps FKeys - END IF; - END IF; - IF bVerbose THEN RAISE NOTICE '(1)tabledef so far: %', v_table_ddl; END IF; - - IF NOT bPartition THEN - -- see if this is unlogged or temporary table - select c.relpersistence into v_persist from pg_class c, pg_namespace n where n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table and c.relkind = 'r'; - IF v_persist = 'u' THEN - v_temp := 'UNLOGGED'; - ELSIF v_persist = 't' THEN - v_temp := 'TEMPORARY'; - ELSE - v_temp := ''; - END IF; - END IF; - - -- start the create definition for regular tables unless we are in progress creating an inheritance-based child table - IF NOT bPartition THEN - --Issue#17 fix for case-sensitive tables - -- SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s - -- WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = quote_ident(in_schema) AND t.table_name = quote_ident(in_table) AND t.table_type = 'BASE TABLE'); - SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s - WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = in_schema AND t.table_name = in_table AND t.table_type = 'BASE TABLE'); - IF v_cnt1 > 0 THEN - v_table_ddl := 'CREATE ' || v_temp || ' TABLE ' || in_schema || '."' || in_table || '" (' || E'\\n'; - ELSE - v_table_ddl := 'CREATE ' || v_temp || ' TABLE ' || in_schema || '.' || in_table || ' (' || E'\\n'; - END IF; - END IF; - -- RAISE NOTICE 'DEBUG2: tabledef so far: %', v_table_ddl; - -- define all of the columns in the table unless we are in progress creating an inheritance-based child table - IF NOT bPartition THEN - FOR v_colrec IN - SELECT c.column_name, c.data_type, c.udt_name, c.udt_schema, c.character_maximum_length, c.is_nullable, c.column_default, c.numeric_precision, c.numeric_scale, c.is_identity, c.identity_generation, c.is_generated, c.generation_expression - FROM information_schema.columns c WHERE (table_schema, table_name) = (in_schema, in_table) ORDER BY ordinal_position - LOOP - IF bVerbose THEN RAISE NOTICE '(col loop) name=% type=% udt_name=% default=% is_generated=% gen_expr=%', v_colrec.column_name, v_colrec.data_type, v_colrec.udt_name, v_colrec.column_default, v_colrec.is_generated, v_colrec.generation_expression; END IF; - - -- v17 fix: handle case-sensitive for pg_get_serial_sequence that requires SQL Identifier handling - -- SELECT CASE WHEN pg_get_serial_sequence(v_qualified, v_colrec.column_name) IS NOT NULL THEN True ELSE False END into bSerial; - SELECT CASE WHEN pg_get_serial_sequence(quote_ident(in_schema) || '.' || quote_ident(in_table), v_colrec.column_name) IS NOT NULL THEN True ELSE False END into bSerial; - IF bVerbose THEN - -- v17 fix: handle case-sensitive for pg_get_serial_sequence that requires SQL Identifier handling - -- SELECT pg_get_serial_sequence(v_qualified, v_colrec.column_name) into v_temp; - SELECT pg_get_serial_sequence(quote_ident(in_schema) || '.' || quote_ident(in_table), v_colrec.column_name) into v_temp; - IF v_temp IS NULL THEN v_temp = 'NA'; END IF; - SELECT pg_temp.pg_get_coldef(in_schema, in_table,v_colrec.column_name) INTO v_diag1; - RAISE NOTICE 'DEBUG table: % Column: % datatype: % Serial=% serialval=% coldef=%', v_qualified, v_colrec.column_name, v_colrec.data_type, bSerial, v_temp, v_diag1; - RAISE NOTICE 'DEBUG tabledef: %', v_table_ddl; - END IF; - - --Issue#17 put double-quotes around case-sensitive column names - SELECT COUNT(*) INTO v_cnt1 FROM information_schema.columns t WHERE EXISTS (SELECT REGEXP_MATCHES(s.column_name, '([A-Z]+)','g') FROM information_schema.columns s - WHERE t.table_schema=s.table_schema and t.table_name=s.table_name and t.column_name=s.column_name AND t.table_schema = quote_ident(in_schema) AND column_name = v_colrec.column_name); - - --Issue#19 put double-quotes around SQL keyword column names - SELECT COUNT(*) INTO v_cnt2 FROM pg_get_keywords() WHERE word = v_colrec.column_name AND catcode = 'R'; - - IF v_cnt1 > 0 OR v_cnt2 > 0 THEN - v_table_ddl := v_table_ddl || ' "' || v_colrec.column_name || '" '; - ELSE - v_table_ddl := v_table_ddl || ' ' || v_colrec.column_name || ' '; - END IF; - - -- Issue#23: Handle autogenerated columns and rewrite as a simpler IF THEN ELSE branch instead of a much more complex embedded CASE STATEMENT - IF v_colrec.is_generated = 'ALWAYS' and v_colrec.generation_expression IS NOT NULL THEN - -- searchable tsvector GENERATED ALWAYS AS (to_tsvector('simple'::regconfig, COALESCE(translate(email, '@.-'::citext, ' '::text), ''::text)) ) STORED - v_temp = v_colrec.data_type || ' GENERATED ALWAYS AS (' || v_colrec.generation_expression || ') STORED '; - ELSEIF v_colrec.udt_name in ('geometry', 'box2d', 'box2df', 'box3d', 'geography', 'geometry_dump', 'gidx', 'spheroid', 'valid_detail') THEN - v_temp = v_colrec.udt_name; - ELSEIF v_colrec.data_type = 'USER-DEFINED' THEN - v_temp = v_colrec.udt_schema || '.' || v_colrec.udt_name; - ELSEIF v_colrec.data_type = 'ARRAY' THEN - -- Issue#6 fix: handle arrays - v_temp = pg_temp.pg_get_coldef(in_schema, in_table,v_colrec.column_name); - -- v17 fix: handle case-sensitive for pg_get_serial_sequence that requires SQL Identifier handling - -- WHEN pg_get_serial_sequence(v_qualified, v_colrec.column_name) IS NOT NULL - ELSEIF pg_get_serial_sequence(quote_ident(in_schema) || '.' || quote_ident(in_table), v_colrec.column_name) IS NOT NULL THEN - -- Issue#8 fix: handle serial. Note: NOT NULL is implied so no need to declare it explicitly - v_temp = pg_temp.pg_get_coldef(in_schema, in_table,v_colrec.column_name); - ELSE - v_temp = v_colrec.data_type; - END IF; - -- RAISE NOTICE 'column def1=%', v_temp; - - -- handle IDENTITY columns - IF v_colrec.is_identity = 'YES' THEN - IF v_colrec.identity_generation = 'ALWAYS' THEN - v_temp = v_temp || ' GENERATED ALWAYS AS IDENTITY'; - ELSE - v_temp = v_temp || ' GENERATED BY DEFAULT AS IDENTITY'; - END IF; - ELSEIF v_colrec.character_maximum_length IS NOT NULL THEN - v_temp = v_temp || ('(' || v_colrec.character_maximum_length || ')'); - ELSEIF v_colrec.numeric_precision > 0 AND v_colrec.numeric_scale > 0 THEN - v_temp = v_temp || '(' || v_colrec.numeric_precision || ',' || v_colrec.numeric_scale || ')'; - END IF; - - -- Handle NULL/NOT NULL - IF bSerial THEN - v_temp = v_temp || ' NOT NULL'; - ELSEIF v_colrec.is_nullable = 'NO' THEN - v_temp = v_temp || ' NOT NULL'; - ELSEIF v_colrec.is_nullable = 'YES' THEN - v_temp = v_temp || ' NULL'; - END IF; - - -- Handle defaults - IF v_colrec.column_default IS NOT null AND NOT bSerial THEN - -- RAISE NOTICE 'Setting default for column, %', v_colrec.column_name; - v_temp = v_temp || (' DEFAULT ' || v_colrec.column_default); - END IF; - v_temp = v_temp || ',' || E'\\n'; - -- RAISE NOTICE 'column def2=%', v_temp; - v_table_ddl := v_table_ddl || v_temp; - -- RAISE NOTICE 'tabledef=%', v_table_ddl; - - END LOOP; - END IF; - IF bVerbose THEN RAISE NOTICE '(2)tabledef so far: %', v_table_ddl; END IF; - - -- define all the constraints: conparentid does not exist pre PGv11 - IF v_pgversion < 110000 THEN - FOR v_constraintrec IN - SELECT con.conname as constraint_name, con.contype as constraint_type, - CASE - WHEN con.contype = 'p' THEN 1 -- primary key constraint - WHEN con.contype = 'u' THEN 2 -- unique constraint - WHEN con.contype = 'f' THEN 3 -- foreign key constraint - WHEN con.contype = 'c' THEN 4 - ELSE 5 - END as type_rank, - pg_get_constraintdef(con.oid) as constraint_definition - FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid JOIN pg_catalog.pg_namespace nsp ON nsp.oid = connamespace - WHERE nsp.nspname = in_schema AND rel.relname = in_table ORDER BY type_rank - LOOP - v_constraint_name := v_constraintrec.constraint_name; - v_constraint_def := v_constraintrec.constraint_definition; - IF v_constraintrec.type_rank = 1 THEN - IF pkcnt = 0 OR pktype = 'PKEY_INTERNAL' THEN - -- internal def - v_constraint_name := v_constraintrec.constraint_name; - v_constraint_def := v_constraintrec.constraint_definition; - v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column - || 'CONSTRAINT' || ' ' - || v_constraint_name || ' ' - || v_constraint_def - || ',' || E'\\n'; - ELSE - -- Issue#16 handle external PG def - SELECT 'ALTER TABLE ONLY ' || in_schema || '.' || c.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_pkey_def - FROM pg_catalog.pg_constraint r, pg_class c, pg_namespace n where r.conrelid = c.oid and r.contype = 'p' and n.oid = r.connamespace and n.nspname = in_schema AND c.relname = in_table and r.conname = v_constraint_name; - END IF; - IF bPartition THEN - continue; - END IF; - ELSIF v_constraintrec.type_rank = 3 THEN - -- handle foreign key constraints - --Issue#22 fix: added FKEY_NONE check - IF fktype = 'FKEYS_NONE' THEN - -- skip - continue; - ELSIF fkcnt = 0 OR fktype = 'FKEYS_INTERNAL' THEN - -- internal def - v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column - || 'CONSTRAINT' || ' ' - || v_constraint_name || ' ' - || v_constraint_def - || ',' || E'\\n'; - ELSE - -- external def - SELECT 'ALTER TABLE ONLY ' || n.nspname || '.' || c2.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_fkey_def - FROM pg_constraint r, pg_class c1, pg_namespace n, pg_class c2 where r.conrelid = c1.oid and r.contype = 'f' and n.nspname = in_schema and n.oid = r.connamespace and r.conrelid = c2.oid and c2.relname = in_table; - v_fkey_defs = v_fkey_defs || v_fkey_def || E'\\n'; - END IF; - ELSE - -- handle all other constraints besides PKEY and FKEYS as internal defs by default - v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column - || 'CONSTRAINT' || ' ' - || v_constraint_name || ' ' - || v_constraint_def - || ',' || E'\\n'; - END IF; - if bVerbose THEN RAISE NOTICE 'DEBUG4: constraint name=% constraint_def=%', v_constraint_name,v_constraint_def; END IF; - constraintarr := constraintarr || v_constraintrec.constraint_name:: text; - - END LOOP; - ELSE - -- handle PG versions 11 and up - -- Issue#20: Fix logic for external PKEY and FKEYS - FOR v_constraintrec IN - SELECT con.conname as constraint_name, con.contype as constraint_type, - CASE - WHEN con.contype = 'p' THEN 1 -- primary key constraint - WHEN con.contype = 'u' THEN 2 -- unique constraint - WHEN con.contype = 'f' THEN 3 -- foreign key constraint - WHEN con.contype = 'c' THEN 4 - ELSE 5 - END as type_rank, - pg_get_constraintdef(con.oid) as constraint_definition - FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid JOIN pg_catalog.pg_namespace nsp ON nsp.oid = connamespace - WHERE nsp.nspname = in_schema AND rel.relname = in_table - --Issue#13 added this condition: - AND con.conparentid = 0 - ORDER BY type_rank - LOOP - v_constraint_name := v_constraintrec.constraint_name; - v_constraint_def := v_constraintrec.constraint_definition; - IF v_constraintrec.type_rank = 1 THEN - IF pkcnt = 0 OR pktype = 'PKEY_INTERNAL' THEN - -- internal def - v_constraint_name := v_constraintrec.constraint_name; - v_constraint_def := v_constraintrec.constraint_definition; - v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column - || 'CONSTRAINT' || ' ' - || v_constraint_name || ' ' - || v_constraint_def - || ',' || E'\\n'; - ELSE - -- Issue#16 handle external PG def - SELECT 'ALTER TABLE ONLY ' || in_schema || '.' || c.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_pkey_def - FROM pg_catalog.pg_constraint r, pg_class c, pg_namespace n where r.conrelid = c.oid and r.contype = 'p' and n.oid = r.connamespace and n.nspname = in_schema AND c.relname = in_table; - END IF; - IF bPartition THEN - continue; - END IF; - ELSIF v_constraintrec.type_rank = 3 THEN - -- handle foreign key constraints - --Issue#22 fix: added FKEY_NONE check - IF fktype = 'FKEYS_NONE' THEN - -- skip - continue; - ELSIF fkcnt = 0 OR fktype = 'FKEYS_INTERNAL' THEN - -- internal def - v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column - || 'CONSTRAINT' || ' ' - || v_constraint_name || ' ' - || v_constraint_def - || ',' || E'\\n'; - ELSE - -- external def - SELECT 'ALTER TABLE ONLY ' || n.nspname || '.' || c2.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_fkey_def - FROM pg_constraint r, pg_class c1, pg_namespace n, pg_class c2 where r.conrelid = c1.oid and r.contype = 'f' and n.nspname = in_schema and n.oid = r.connamespace and r.conrelid = c2.oid and c2.relname = in_table and - r.conname = v_constraint_name and r.conparentid = 0; - v_fkey_defs = v_fkey_defs || v_fkey_def || E'\\n'; - END IF; - ELSE - -- handle all other constraints besides PKEY and FKEYS as internal defs by default - v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column - || 'CONSTRAINT' || ' ' - || v_constraint_name || ' ' - || v_constraint_def - || ',' || E'\\n'; - END IF; - if bVerbose THEN RAISE NOTICE 'DEBUG4: constraint name=% constraint_def=%', v_constraint_name,v_constraint_def; END IF; - constraintarr := constraintarr || v_constraintrec.constraint_name:: text; - - END LOOP; - END IF; - - -- drop the last comma before ending the create statement, which should be right before the carriage return character - -- Issue#24: make sure the comma is there before removing it - select substring(v_table_ddl, length(v_table_ddl) - 1, 1) INTO v_temp; - IF v_temp = ',' THEN - v_table_ddl = substr(v_table_ddl, 0, length(v_table_ddl) - 1) || E'\\n'; - END IF; - IF bVerbose THEN RAISE NOTICE '(3)tabledef so far: %', trim(v_table_ddl); END IF; - - -- --------------------------------------------------------------------------- - -- at this point we have everything up to the last table-enclosing parenthesis - -- --------------------------------------------------------------------------- - IF bVerbose THEN RAISE NOTICE '(4)tabledef so far: %', v_table_ddl; END IF; - - -- See if this is an inheritance-based child table and finish up the table create. - IF bPartition and bInheritance THEN - -- Issue#11: handle parent schema - -- v_table_ddl := v_table_ddl || ') INHERITS (' || in_schema || '.' || v_parent || ') ' || E'\\n' || v_relopts || ' ' || v_tablespace || ';' || E'\\n'; - IF v_parent_schema = '' OR v_parent_schema IS NULL THEN v_parent_schema = in_schema; END IF; - v_table_ddl := v_table_ddl || ') INHERITS (' || v_parent_schema || '.' || v_parent || ') ' || E'\\n' || v_relopts || ' ' || v_tablespace || ';' || E'\\n'; - END IF; - - IF v_pgversion >= 100000 AND NOT bPartition and NOT bInheritance THEN - -- See if this is a partitioned table (pg_class.relkind = 'p') and add the partitioned key - SELECT pg_get_partkeydef(c1.oid) as partition_key INTO v_partition_key FROM pg_class c1 JOIN pg_namespace n ON (n.oid = c1.relnamespace) LEFT JOIN pg_partitioned_table p ON (c1.oid = p.partrelid) - WHERE n.nspname = in_schema and n.oid = c1.relnamespace and c1.relname = in_table and c1.relkind = 'p'; - - IF v_partition_key IS NOT NULL AND v_partition_key <> '' THEN - -- add partition clause - -- NOTE: cannot specify default tablespace for partitioned relations - -- v_table_ddl := v_table_ddl || ') PARTITION BY ' || v_partition_key || ' ' || v_tablespace || ';' || E'\\n'; - v_table_ddl := v_table_ddl || ') PARTITION BY ' || v_partition_key || ';' || E'\\n'; - ELSEIF v_relopts <> '' THEN - v_table_ddl := v_table_ddl || ') ' || v_relopts || ' ' || v_tablespace || ';' || E'\\n'; - ELSE - -- end the create definition - v_table_ddl := v_table_ddl || ') ' || v_tablespace || ';' || E'\\n'; - END IF; - END IF; - - IF bVerbose THEN RAISE NOTICE '(5)tabledef so far: %', v_table_ddl; END IF; - - -- Add closing paren for regular tables - -- IF NOT bPartition THEN - -- v_table_ddl := v_table_ddl || ') ' || v_relopts || ' ' || v_tablespace || E';\\n'; - -- END IF; - -- RAISE NOTICE 'ddlsofar3: %', v_table_ddl; - - -- Issue#16 create the external PKEY def if indicated - IF v_pkey_def <> '' THEN - v_table_ddl := v_table_ddl || v_pkey_def || E'\\n'; - END IF; - - -- Issue#20 - IF v_fkey_defs <> '' THEN - v_table_ddl := v_table_ddl || v_fkey_defs || E'\\n'; - END IF; - - IF bVerbose THEN RAISE NOTICE '(6)tabledef so far: %', v_table_ddl; END IF; - - -- create indexes - FOR v_indexrec IN - SELECT indexdef, COALESCE(tablespace, 'pg_default') as tablespace, indexname FROM pg_indexes WHERE (schemaname, tablename) = (in_schema, in_table) - LOOP - -- RAISE NOTICE 'DEBUG6: indexname=% indexdef=%', v_indexrec.indexname, v_indexrec.indexdef; - -- loop through constraints and skip ones already defined - bSkip = False; - FOREACH constraintelement IN ARRAY constraintarr - LOOP - IF constraintelement = v_indexrec.indexname THEN - -- RAISE NOTICE 'DEBUG7: skipping index, %', v_indexrec.indexname; - bSkip = True; - EXIT; - END IF; - END LOOP; - if bSkip THEN CONTINUE; END IF; - - -- Add IF NOT EXISTS clause so partition index additions will not be created if declarative partition in effect and index already created on parent - v_indexrec.indexdef := REPLACE(v_indexrec.indexdef, 'CREATE INDEX', 'CREATE INDEX IF NOT EXISTS'); - -- Fix Issue#26: do it for unique/primary key indexes as well - v_indexrec.indexdef := REPLACE(v_indexrec.indexdef, 'CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX IF NOT EXISTS'); - -- RAISE NOTICE 'DEBUG8: adding index, %', v_indexrec.indexname; - - -- NOTE: cannot specify default tablespace for partitioned relations - IF v_partition_key IS NOT NULL AND v_partition_key <> '' THEN - v_table_ddl := v_table_ddl || v_indexrec.indexdef || ';' || E'\\n'; - ELSE - -- Issue#25: see if partial index or not - select CASE WHEN i.indpred IS NOT NULL THEN True ELSE False END INTO v_partial - FROM pg_index i JOIN pg_class c1 ON (i.indexrelid = c1.oid) JOIN pg_class c2 ON (i.indrelid = c2.oid) - WHERE c1.relnamespace::regnamespace::text = in_schema AND c2.relnamespace::regnamespace::text = in_schema AND c2.relname = in_table AND c1.relname = v_indexrec.indexname; - IF v_partial THEN - -- Put tablespace def before WHERE CLAUSE - v_temp = v_indexrec.indexdef; - v_pos = POSITION(' WHERE ' IN v_temp); - v_temp2 = SUBSTRING(v_temp, v_pos); - v_temp = SUBSTRING(v_temp, 1, v_pos); - v_table_ddl := v_table_ddl || v_temp || ' TABLESPACE ' || v_indexrec.tablespace || v_temp2 || ';' || E'\\n'; - ELSE - v_table_ddl := v_table_ddl || v_indexrec.indexdef || ' TABLESPACE ' || v_indexrec.tablespace || ';' || E'\\n'; - END IF; - END IF; - - END LOOP; - IF bVerbose THEN RAISE NOTICE '(7)tabledef so far: %', v_table_ddl; END IF; - - -- Issue#20: added logic for table and column comments - IF cmtcnt > 0 THEN - FOR v_rec IN - SELECT c.relname, 'COMMENT ON ' || CASE WHEN c.relkind in ('r','p') AND a.attname IS NULL THEN 'TABLE ' WHEN c.relkind in ('r','p') AND a.attname IS NOT NULL THEN 'COLUMN ' WHEN c.relkind = 'f' THEN 'FOREIGN TABLE ' - WHEN c.relkind = 'm' THEN 'MATERIALIZED VIEW ' WHEN c.relkind = 'v' THEN 'VIEW ' WHEN c.relkind = 'i' THEN 'INDEX ' WHEN c.relkind = 'S' THEN 'SEQUENCE ' ELSE 'XX' END || n.nspname || '.' || - CASE WHEN c.relkind in ('r','p') AND a.attname IS NOT NULL THEN quote_ident(c.relname) || '.' || a.attname ELSE quote_ident(c.relname) END || ' IS ' || quote_literal(d.description) || ';' as ddl - FROM pg_class c JOIN pg_namespace n ON (n.oid = c.relnamespace) LEFT JOIN pg_description d ON (c.oid = d.objoid) LEFT JOIN pg_attribute a ON (c.oid = a.attrelid AND a.attnum > 0 and a.attnum = d.objsubid) - WHERE d.description IS NOT NULL AND n.nspname = in_schema AND c.relname = in_table ORDER BY 2 desc, ddl - LOOP - --RAISE NOTICE 'comments:%', v_rec.ddl; - v_table_ddl = v_table_ddl || v_rec.ddl || E'\\n'; - END LOOP; - END IF; - IF bVerbose THEN RAISE NOTICE '(8)tabledef so far: %', v_table_ddl; END IF; - - IF trigtype = 'INCLUDE_TRIGGERS' THEN - -- Issue#14: handle multiple triggers for a table - FOR v_trigrec IN - select pg_get_triggerdef(t.oid, True) || ';' as triggerdef FROM pg_trigger t, pg_class c, pg_namespace n - WHERE n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table and c.relkind = 'r' and t.tgrelid = c.oid and NOT t.tgisinternal - LOOP - v_table_ddl := v_table_ddl || v_trigrec.triggerdef; - v_table_ddl := v_table_ddl || E'\\n'; - IF bVerbose THEN RAISE NOTICE 'triggerdef = %', v_trigrec.triggerdef; END IF; - END LOOP; - END IF; - - IF bVerbose THEN RAISE NOTICE '(9)tabledef so far: %', v_table_ddl; END IF; - -- add empty line - v_table_ddl := v_table_ddl || E'\\n'; - IF bVerbose THEN RAISE NOTICE '(10)tabledef so far: %', v_table_ddl; END IF; - - -- reset search_path back to what it was - IF search_path_old = '' THEN - SELECT set_config('search_path', '', false) into v_temp; - ELSE - EXECUTE 'SET search_path = ' || search_path_old; - END IF; - - RETURN v_table_ddl; - - EXCEPTION - WHEN others THEN - BEGIN - GET STACKED DIAGNOSTICS v_diag1 = MESSAGE_TEXT, v_diag2 = PG_EXCEPTION_DETAIL, v_diag3 = PG_EXCEPTION_HINT, v_diag4 = RETURNED_SQLSTATE, v_diag5 = PG_CONTEXT, v_diag6 = PG_EXCEPTION_CONTEXT; - -- v_ret := 'line=' || v_diag6 || '. '|| v_diag4 || '. ' || v_diag1 || ' .' || v_diag2 || ' .' || v_diag3; - v_ret := 'line=' || v_diag6 || '. '|| v_diag4 || '. ' || v_diag1; - RAISE EXCEPTION '%', v_ret; - -- put additional coding here if necessarY - RETURN ''; - END; - - END; - $$;`, - { compress: true, removeAll: true } -) diff --git a/apps/studio/data/database/entity-definitions-query.ts b/apps/studio/data/database/entity-definitions-query.ts index f32f87e3c6b..1a9dd14ba94 100644 --- a/apps/studio/data/database/entity-definitions-query.ts +++ b/apps/studio/data/database/entity-definitions-query.ts @@ -1,80 +1,9 @@ +import { getEntityDefinitionsSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' -import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' -import { CREATE_PG_GET_TABLEDEF_SQL } from './database-table-definition' -import { databaseKeys } from './keys' import { UseCustomQueryOptions } from 'types' -export const getEntityDefinitionsSql = ({ - schemas, - limit = 100, -}: { - schemas: string[] - limit?: number -}) => { - const sql = /* SQL */ ` -${CREATE_PG_GET_TABLEDEF_SQL} - -with records as ( - select - c.oid::int8 as "id", - case c.relkind - when 'r' then pg_temp.pg_get_tabledef( - concat(nc.nspname), - concat(c.relname), - false, - 'FKEYS_INTERNAL', - 'NO_TRIGGERS' - ) - when 'v' then concat( - 'create view ', concat(nc.nspname, '.', c.relname), ' as', - pg_get_viewdef(concat(nc.nspname, '.', c.relname), true) - ) - when 'm' then concat( - 'create materialized view ', concat(nc.nspname, '.', c.relname), ' as', - pg_get_viewdef(concat(nc.nspname, '.', c.relname), true) - ) - when 'f' then concat('create foreign table ', nc.nspname, '.', c.relname, ' ( ... )') - when 'p' then pg_temp.pg_get_tabledef( - concat(nc.nspname), - concat(c.relname), - false, - 'FKEYS_INTERNAL', - 'NO_TRIGGERS' - ) - end as "sql" - from - pg_namespace nc - join pg_class c on nc.oid = c.relnamespace - where - c.relkind in ('r', 'v', 'm', 'f', 'p') - and not pg_is_other_temp_schema(nc.oid) - and ( - pg_has_role(c.relowner, 'USAGE') - or has_table_privilege( - c.oid, - 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER' - ) - or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES') - ) - and nc.nspname IN (${schemas.map((schema) => `'${schema}'`).join(', ')}) - order by c.relname asc - limit ${limit} - offset 0 -) -select - jsonb_build_object( - 'definitions', coalesce(jsonb_agg( - jsonb_build_object( - 'id', r.id, - 'sql', r.sql - ) - ), '[]'::jsonb) - ) "data" -from records r; - `.trim() - - return sql -} +import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' +import { databaseKeys } from './keys' export type EntityDefinitionsVariables = { limit?: number diff --git a/apps/studio/data/database/foreign-key-constraints-query.ts b/apps/studio/data/database/foreign-key-constraints-query.ts index 5b6782a51b9..e68c5660e73 100644 --- a/apps/studio/data/database/foreign-key-constraints-query.ts +++ b/apps/studio/data/database/foreign-key-constraints-query.ts @@ -1,3 +1,4 @@ +import { getForeignKeyConstraintsSql } from '@supabase/pg-meta' import { QueryClient, useQuery } from '@tanstack/react-query' import { IS_PLATFORM } from 'common' import { UseCustomQueryOptions } from 'types' @@ -40,61 +41,6 @@ export type ForeignKeyConstraint = { target_columns: string[] } -export const getForeignKeyConstraintsSql = ({ schema }: GetForeignKeyConstraintsVariables) => { - if (!schema) throw new Error('schema is required') - - const sql = /* SQL */ ` -SELECT - con.oid as id, - con.conname as constraint_name, - con.confdeltype as deletion_action, - con.confupdtype as update_action, - rel.oid as source_id, - nsp.nspname as source_schema, - rel.relname as source_table, - ( - SELECT - array_agg( - att.attname - ORDER BY - un.ord - ) - FROM - unnest(con.conkey) WITH ORDINALITY un (attnum, ord) - INNER JOIN pg_attribute att ON att.attnum = un.attnum - WHERE - att.attrelid = rel.oid - ) source_columns, - frel.oid as target_id, - fnsp.nspname as target_schema, - frel.relname as target_table, - ( - SELECT - array_agg( - att.attname - ORDER BY - un.ord - ) - FROM - unnest(con.confkey) WITH ORDINALITY un (attnum, ord) - INNER JOIN pg_attribute att ON att.attnum = un.attnum - WHERE - att.attrelid = frel.oid - ) target_columns -FROM - pg_constraint con - INNER JOIN pg_class rel ON rel.oid = con.conrelid - INNER JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace - INNER JOIN pg_class frel ON frel.oid = con.confrelid - INNER JOIN pg_namespace fnsp ON fnsp.oid = frel.relnamespace -WHERE - con.contype = 'f' - AND nsp.nspname = '${schema}' -`.trim() - - return sql -} - export type ForeignKeyConstraintsVariables = GetForeignKeyConstraintsVariables & { projectRef?: string connectionString?: string | null @@ -104,6 +50,8 @@ export async function getForeignKeyConstraints( { projectRef, connectionString, schema }: ForeignKeyConstraintsVariables, signal?: AbortSignal ) { + if (!schema) throw new Error('Schema is required') + const sql = getForeignKeyConstraintsSql({ schema }) const { result } = await executeSql( diff --git a/apps/studio/data/database/keywords-query.ts b/apps/studio/data/database/keywords-query.ts index b2cb3e68af6..b1f2c3667c8 100644 --- a/apps/studio/data/database/keywords-query.ts +++ b/apps/studio/data/database/keywords-query.ts @@ -1,15 +1,9 @@ +import { getKeywordsSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' -import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' -import { databaseKeys } from './keys' import { UseCustomQueryOptions } from 'types' -export const getKeywordsSql = () => { - const sql = /* SQL */ ` -SELECT word FROM pg_get_keywords(); -`.trim() - - return sql -} +import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' +import { databaseKeys } from './keys' export type KeywordsVariables = { projectRef?: string diff --git a/apps/studio/data/database/max-connections-query.ts b/apps/studio/data/database/max-connections-query.ts index bf3a183fce7..1575cc4fe3e 100644 --- a/apps/studio/data/database/max-connections-query.ts +++ b/apps/studio/data/database/max-connections-query.ts @@ -1,14 +1,10 @@ +import { getMaxConnectionsSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' import { UseCustomQueryOptions } from 'types' + import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { databaseKeys } from './keys' -export const getMaxConnectionsSql = () => { - const sql = /* SQL */ `show max_connections` - - return sql -} - export type MaxConnectionsVariables = { projectRef?: string connectionString?: string | null diff --git a/apps/studio/data/database/migrations-query.ts b/apps/studio/data/database/migrations-query.ts index f15f44e8108..ae1b7209930 100644 --- a/apps/studio/data/database/migrations-query.ts +++ b/apps/studio/data/database/migrations-query.ts @@ -1,5 +1,7 @@ +import { getMigrationsSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' import { UseCustomQueryOptions } from 'types' + import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { databaseKeys } from './keys' @@ -9,17 +11,6 @@ export type DatabaseMigration = { statements?: string[] } -export const getMigrationsSql = () => { - const sql = /* SQL */ ` - select - * - from supabase_migrations.schema_migrations sm - order by sm.version desc - `.trim() - - return sql -} - export type MigrationsVariables = { projectRef?: string connectionString?: string | null diff --git a/apps/studio/data/database/primary-keys-exists-query.ts b/apps/studio/data/database/primary-keys-exists-query.ts deleted file mode 100644 index fb3091d0a76..00000000000 --- a/apps/studio/data/database/primary-keys-exists-query.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { getCheckPrimaryKeysExistsSQL } from '@supabase/pg-meta/src/sql/studio/check-primary-keys-exists' -import { useQuery } from '@tanstack/react-query' - -import { executeSql } from 'data/sql/execute-sql-query' -import type { ResponseError, UseCustomQueryOptions } from 'types' -import { databaseKeys } from './keys' - -type CheckPrimaryKeysExistsVariables = { - projectRef?: string - connectionString?: string | null - tables: { name: string; schema: string }[] -} - -type CheckPrimaryKeysExistResponse = { - id: string - name: string - schema: string - has_primary_key: boolean -}[] - -export async function checkPrimaryKeysExists({ - projectRef, - connectionString, - tables, -}: CheckPrimaryKeysExistsVariables) { - if (!projectRef) throw new Error('Project ref is required') - - const { result } = await executeSql({ - projectRef, - connectionString, - sql: getCheckPrimaryKeysExistsSQL(tables), - }) - - return { - offendingTables: (result as CheckPrimaryKeysExistResponse).filter((x) => !x.has_primary_key), - } -} - -export type CheckPrimaryKeysExistsData = Awaited> -export type CheckPrimaryKeysExistsError = ResponseError - -export const useCheckPrimaryKeysExists = ( - { projectRef, connectionString, tables }: CheckPrimaryKeysExistsVariables, - { - enabled = true, - ...options - }: UseCustomQueryOptions = {} -) => - useQuery({ - queryKey: databaseKeys.checkPrimaryKeysExists(projectRef, tables), - queryFn: () => checkPrimaryKeysExists({ projectRef, connectionString, tables }), - retry: false, - enabled: enabled && typeof projectRef !== 'undefined' && tables.length > 0, - ...options, - }) diff --git a/apps/studio/data/database/table-columns-query.ts b/apps/studio/data/database/table-columns-query.ts index fbd23edcded..55d614a7fb5 100644 --- a/apps/studio/data/database/table-columns-query.ts +++ b/apps/studio/data/database/table-columns-query.ts @@ -1,5 +1,7 @@ +import { getTableColumnsSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' import { UseCustomQueryOptions } from 'types' + import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { databaseKeys } from './keys' @@ -11,81 +13,6 @@ export type TableColumn = { columns: any[] } -export const getTableColumnsSql = ({ table, schema }: { table?: string; schema?: string }) => { - const conditions = [] - if (table) { - conditions.push(`tablename = '${table}'`) - } - if (schema) { - conditions.push(`schemaname = '${schema}'`) - } - - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' - - const sql = /* SQL */ ` - - SELECT - tbl.schemaname, - tbl.tablename, - tbl.quoted_name, - tbl.is_table, - json_agg(a) as columns - FROM - ( - SELECT - n.nspname as schemaname, - c.relname as tablename, - (quote_ident(n.nspname) || '.' || quote_ident(c.relname)) as quoted_name, - true as is_table - FROM - pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE - c.relkind = 'r' - AND n.nspname not in ('information_schema', 'pg_catalog', 'pg_toast') - AND n.nspname not like 'pg_temp_%' - AND n.nspname not like 'pg_toast_temp_%' - AND has_schema_privilege(n.oid, 'USAGE') = true - AND has_table_privilege(quote_ident(n.nspname) || '.' || quote_ident(c.relname), 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER') = true - union all - SELECT - n.nspname as schemaname, - c.relname as tablename, - (quote_ident(n.nspname) || '.' || quote_ident(c.relname)) as quoted_name, - false as is_table - FROM - pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE - c.relkind in ('v', 'm') - AND n.nspname not in ('information_schema', 'pg_catalog', 'pg_toast') - AND n.nspname not like 'pg_temp_%' - AND n.nspname not like 'pg_toast_temp_%' - AND has_schema_privilege(n.oid, 'USAGE') = true - AND has_table_privilege(quote_ident(n.nspname) || '.' || quote_ident(c.relname), 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER') = true - ) as tbl - LEFT JOIN ( - SELECT - attrelid, - attname, - format_type(atttypid, atttypmod) as data_type, - attnum, - attisdropped - FROM - pg_attribute - ) as a ON ( - a.attrelid = tbl.quoted_name::regclass - AND a.attnum > 0 - AND NOT a.attisdropped - AND has_column_privilege(tbl.quoted_name, a.attname, 'SELECT, INSERT, UPDATE, REFERENCES') - ) - ${whereClause} - GROUP BY schemaname, tablename, quoted_name, is_table; -`.trim() - - return sql -} - export type TableColumnsVariables = { projectRef?: string connectionString?: string | null diff --git a/apps/studio/data/database/table-definition-query.ts b/apps/studio/data/database/table-definition-query.ts index 4d0aaa6ab9d..0d472a75a98 100644 --- a/apps/studio/data/database/table-definition-query.ts +++ b/apps/studio/data/database/table-definition-query.ts @@ -1,39 +1,14 @@ +import { getTableDefinitionSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' import { UseCustomQueryOptions } from 'types' + import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' -import { CREATE_PG_GET_TABLEDEF_SQL } from './database-table-definition' import { databaseKeys } from './keys' type GetTableDefinitionArgs = { id?: number } -// [Joshen] Eventually move this into entity-definition-query -const getTableDefinitionSql = ({ id }: GetTableDefinitionArgs) => { - const sql = /* SQL */ ` - ${CREATE_PG_GET_TABLEDEF_SQL} - - with table_info as ( - select - n.nspname::text as schema, - c.relname::text as name - from pg_class c - join pg_namespace n on n.oid = c.relnamespace - where c.oid = ${id} - ) - select pg_temp.pg_get_tabledef ( - t.schema, - t.name, - false, - 'FKEYS_INTERNAL', - 'INCLUDE_TRIGGERS' - ) as definition - from table_info t; - `.trim() - - return sql -} - export type TableDefinitionVariables = GetTableDefinitionArgs & { projectRef?: string connectionString?: string | null @@ -43,9 +18,7 @@ export async function getTableDefinition( { projectRef, connectionString, id }: TableDefinitionVariables, signal?: AbortSignal ) { - if (!id) { - throw new Error('id is required') - } + if (!id) throw new Error('id is required') const sql = getTableDefinitionSql({ id }) const { result } = await executeSql( diff --git a/apps/studio/data/database/table-index-advisor-query.ts b/apps/studio/data/database/table-index-advisor-query.ts index c682f42b090..c57d683cdd7 100644 --- a/apps/studio/data/database/table-index-advisor-query.ts +++ b/apps/studio/data/database/table-index-advisor-query.ts @@ -1,10 +1,10 @@ +import { getTableIndexAdvisorSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' - +import { filterProtectedSchemaIndexStatements } from 'components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils' import { executeSql } from 'data/sql/execute-sql-query' import type { ResponseError, UseCustomQueryOptions } from 'types' -import { databaseKeys } from './keys' -import { filterProtectedSchemaIndexStatements } from 'components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils' +import { databaseKeys } from './keys' export type TableIndexAdvisorVariables = { projectRef?: string @@ -31,60 +31,6 @@ export type TableIndexAdvisorResponse = { columnsWithSuggestions: string[] } -//Generates SQL to find top 5 SELECT queries involving a table and run them through index_advisor -export function getTableIndexAdvisorSql(schema: string, table: string): string { - const escapedSchema = schema.replace(/'/g, "''") - const escapedTable = table.replace(/'/g, "''") - - // Escape regex metacharacters so schema/table names are matched literally - const regexSchema = escapedSchema.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&') - const regexTable = escapedTable.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&') - - return /* SQL */ ` --- Get top 5 SELECT queries involving this table and run through index_advisor -set search_path to public, extensions; - -with top_queries as ( - select - statements.query, - statements.calls, - statements.total_exec_time + statements.total_plan_time as total_time, - statements.mean_exec_time + statements.mean_plan_time as mean_time - from pg_stat_statements as statements - inner join pg_authid as auth on statements.userid = auth.oid - where - -- Filter for SELECT queries only (index_advisor only works with SELECT) - (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%') - -- Filter for queries involving our table. Use regex word boundaries so that e.g. - -- looking for table "orders" does not match queries on "orders_items". - and ( - lower(statements.query) ~ '(^|[^a-z0-9_$])${regexSchema}[.]${regexTable}($|[^a-z0-9_$])' - or lower(statements.query) ~ '(^|[^a-z0-9_$])from[[:space:]]+${regexTable}($|[^a-z0-9_$])' - or lower(statements.query) ~ '(^|[^a-z0-9_$])join[[:space:]]+${regexTable}($|[^a-z0-9_$])' - ) - -- Exclude system queries - and statements.query not like '%pg_catalog%' - and statements.query not like '%information_schema%' - order by statements.calls desc - limit 5 -) -select - tq.query, - tq.calls, - tq.total_time, - tq.mean_time, - coalesce(ia.index_statements, '{}') as index_statements, - coalesce((ia.startup_cost_before)::numeric, 0) as startup_cost_before, - coalesce((ia.startup_cost_after)::numeric, 0) as startup_cost_after, - coalesce((ia.total_cost_before)::numeric, 0) as total_cost_before, - coalesce((ia.total_cost_after)::numeric, 0) as total_cost_after -from top_queries tq -left join lateral ( - select * from index_advisor(tq.query) -) ia on true; -`.trim() -} - // Strips ordering modifiers and outer quotes from a raw column token from an index statement. // e.g. '"created_at" DESC NULLS LAST' -> 'created_at' export function cleanIndexColumnName(raw: string): string { diff --git a/apps/studio/data/database/view-definition-query.ts b/apps/studio/data/database/view-definition-query.ts index 411a7204825..4838e1d0a2c 100644 --- a/apps/studio/data/database/view-definition-query.ts +++ b/apps/studio/data/database/view-definition-query.ts @@ -1,35 +1,14 @@ +import { getViewDefinitionSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' +import { UseCustomQueryOptions } from 'types' + import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { databaseKeys } from './keys' -import { UseCustomQueryOptions } from 'types' type GetViewDefinitionArgs = { id?: number } -// [Joshen] Eventually move this into entity-definition-query -export const getViewDefinitionSql = ({ id }: GetViewDefinitionArgs) => { - if (!id) { - throw new Error('id is required') - } - - const sql = /* SQL */ ` - with table_info as ( - select - n.nspname::text as schema, - c.relname::text as name, - to_regclass(concat('"', n.nspname, '"."', c.relname, '"')) as regclass - from pg_class c - join pg_namespace n on n.oid = c.relnamespace - where c.oid = ${id} - ) - select pg_get_viewdef(t.regclass, true) as definition - from table_info t - `.trim() - - return sql -} - export type ViewDefinitionVariables = GetViewDefinitionArgs & { projectRef?: string connectionString?: string | null @@ -39,6 +18,8 @@ export async function getViewDefinition( { projectRef, connectionString, id }: ViewDefinitionVariables, signal?: AbortSignal ) { + if (!id) throw new Error('View ID is required') + const sql = getViewDefinitionSql({ id }) const { result } = await executeSql( { diff --git a/apps/studio/data/entity-types/entity-types-infinite-query.ts b/apps/studio/data/entity-types/entity-types-infinite-query.ts index 21a7306cb00..fbec33185cd 100644 --- a/apps/studio/data/entity-types/entity-types-infinite-query.ts +++ b/apps/studio/data/entity-types/entity-types-infinite-query.ts @@ -1,6 +1,8 @@ +import { getEntityTypesSQL } from '@supabase/pg-meta' import { InfiniteData, QueryClient, useInfiniteQuery } from '@tanstack/react-query' import { executeSql, ExecuteSqlVariables } from 'data/sql/execute-sql-query' import type { ResponseError, UseCustomInfiniteQueryOptions } from 'types' + import { ENTITY_TYPE } from './entity-type-constants' import { entityTypeKeys } from './keys' @@ -43,63 +45,7 @@ export async function getEntityTypes( }: EntityTypesVariables, signal?: AbortSignal ) { - const innerOrderBy = sort === 'alphabetical' ? `c.relname asc` : `"type_sort" asc, c.relname asc` - const outerOrderBy = sort === 'alphabetical' ? `r.name asc` : `r.type_sort asc, r.name asc` - - const sql = /* SQL */ ` - with records as ( - select - c.oid::int8 as "id", - nc.nspname as "schema", - c.relname as "name", - c.relkind as "type", - case c.relkind - when 'r' then 1 - when 'v' then 2 - when 'm' then 3 - when 'f' then 4 - when 'p' then 5 - end as "type_sort", - obj_description(c.oid) as "comment", - count(*) over() as "count", - c.relrowsecurity as "rls_enabled" - from - pg_namespace nc - join pg_class c on nc.oid = c.relnamespace - where - c.relkind in (${filterTypes.map((x) => `'${x}'`).join(', ')}) - and not pg_is_other_temp_schema(nc.oid) - and ( - pg_has_role(c.relowner, 'USAGE') - or has_table_privilege( - c.oid, - 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER' - ) - or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES') - ) - and nc.nspname in (${schemas.map((x) => `'${x}'`)}) - ${search ? `and c.relname ilike '%${search}%'` : ''} - order by ${innerOrderBy} - limit ${limit} - offset ${page * limit} - ) - select - jsonb_build_object( - 'entities', coalesce(jsonb_agg( - jsonb_build_object( - 'id', r.id, - 'schema', r.schema, - 'name', r.name, - 'type', r.type, - 'comment', r.comment, - 'rls_enabled', r.rls_enabled - ) - order by ${outerOrderBy} - ), '[]'::jsonb), - 'count', coalesce(min(r.count), 0) - ) "data" - from records r; - ` + const sql = getEntityTypesSQL({ schemas, sort, filterTypes, search, limit, page }) const { result } = await executeSql( { diff --git a/apps/studio/data/read-replicas/replica-lag-query.ts b/apps/studio/data/read-replicas/replica-lag-query.ts index dc026bbfb35..b4ce270d565 100644 --- a/apps/studio/data/read-replicas/replica-lag-query.ts +++ b/apps/studio/data/read-replicas/replica-lag-query.ts @@ -1,20 +1,9 @@ +import { replicationLagSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' -import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' -import { replicaKeys } from './keys' import { UseCustomQueryOptions } from 'types' -export const replicationLagSql = () => { - const sql = /* SQL */ ` -select - case - when (select count(*) from pg_stat_wal_receiver) = 1 and pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() - then 0 - else coalesce(extract(epoch from now() - pg_last_xact_replay_timestamp()),0) - end as physical_replica_lag_second - ` - - return sql -} +import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' +import { replicaKeys } from './keys' export type ReplicationLagVariables = { id: string diff --git a/apps/studio/data/sql/abort-query-mutation.ts b/apps/studio/data/sql/abort-query-mutation.ts index bbfc5b7d9e4..13f8a7e87de 100644 --- a/apps/studio/data/sql/abort-query-mutation.ts +++ b/apps/studio/data/sql/abort-query-mutation.ts @@ -1,8 +1,9 @@ +import { getAbortQuerySQL } from '@supabase/pg-meta' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - import { executeSql } from 'data/sql/execute-sql-query' +import { toast } from 'sonner' import type { ResponseError, UseCustomMutationOptions } from 'types' + import { sqlKeys } from './keys' export type QueryAbortVariables = { @@ -12,7 +13,7 @@ export type QueryAbortVariables = { } export async function abortQuery({ pid, projectRef, connectionString }: QueryAbortVariables) { - const sql = /* SQL */ `select pg_terminate_backend(${pid})` + const sql = getAbortQuerySQL({ pid }) const { result } = await executeSql({ projectRef, connectionString, sql }) return result } diff --git a/apps/studio/data/sql/ongoing-queries-query.ts b/apps/studio/data/sql/ongoing-queries-query.ts index 9c258aba9b7..9ca249d37e3 100644 --- a/apps/studio/data/sql/ongoing-queries-query.ts +++ b/apps/studio/data/sql/ongoing-queries-query.ts @@ -1,7 +1,9 @@ +import { getOngoingQueriesSql } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' +import { UseCustomQueryOptions } from 'types' + import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { sqlKeys } from './keys' -import { UseCustomQueryOptions } from 'types' type OngoingQuery = { pid: number @@ -9,14 +11,6 @@ type OngoingQuery = { query_start: string } -export const getOngoingQueriesSql = () => { - const sql = /* SQL */ ` -select pid, query, query_start from pg_stat_activity where state = 'active' and datname = 'postgres'; -`.trim() - - return sql -} - export type OngoingQueriesVariables = { projectRef?: string connectionString?: string | null @@ -26,7 +20,7 @@ export async function getOngoingQueries( { projectRef, connectionString }: OngoingQueriesVariables, signal?: AbortSignal ) { - const sql = getOngoingQueriesSql().trim() + const sql = getOngoingQueriesSql() const { result } = await executeSql( { projectRef, connectionString, sql, queryKey: ['ongoing-queries'] }, diff --git a/apps/studio/data/tables/tables-roles-access-query.ts b/apps/studio/data/tables/tables-roles-access-query.ts index 408fef212e3..dad21c73699 100644 --- a/apps/studio/data/tables/tables-roles-access-query.ts +++ b/apps/studio/data/tables/tables-roles-access-query.ts @@ -1,9 +1,9 @@ -import { getTablesWithAnonAuthenticatedAccessSQL } from '@supabase/pg-meta/src/sql/studio/check-tables-anon-authenticated-access' +import { getTablesWithAnonAuthenticatedAccessSQL } from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' +import { UseCustomQueryOptions } from 'types' import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { tableKeys } from './keys' -import { UseCustomQueryOptions } from 'types' type TablesRolesAccessArgs = { schema: string diff --git a/apps/studio/hooks/useProtectedSchemas.ts b/apps/studio/hooks/useProtectedSchemas.ts index 6597028711b..4d72705d697 100644 --- a/apps/studio/hooks/useProtectedSchemas.ts +++ b/apps/studio/hooks/useProtectedSchemas.ts @@ -1,3 +1,4 @@ +import { QUEUES_SCHEMA } from '@supabase/pg-meta' import { SUPABASE_TARGET_SCHEMA_OPTION, WRAPPERS, @@ -11,7 +12,6 @@ import { uniq, uniqBy } from 'lodash' import { useMemo } from 'react' import { useSelectedProjectQuery } from './misc/useSelectedProject' -import { QUEUES_SCHEMA } from '@/data/database-queues/database-queues-toggle-postgrest-mutation' /** * A list of system schemas that users should not interact with diff --git a/apps/studio/lib/ai/tools/fallback-tools.ts b/apps/studio/lib/ai/tools/fallback-tools.ts index 5c0b2b1c2fe..1c90cdfffb9 100644 --- a/apps/studio/lib/ai/tools/fallback-tools.ts +++ b/apps/studio/lib/ai/tools/fallback-tools.ts @@ -1,14 +1,13 @@ +import { getEntityDefinitionsSql } from '@supabase/pg-meta' import { tool } from 'ai' -import { stripIndent } from 'common-tags' -import { z } from 'zod' - // import { processSql, renderSupabaseJs } from '@supabase/sql-to-rest' import { IS_PLATFORM } from 'common' +import { stripIndent } from 'common-tags' import { getDatabaseFunctions } from 'data/database-functions/database-functions-query' import { getDatabasePolicies } from 'data/database-policies/database-policies-query' -import { getEntityDefinitionsSql } from 'data/database/entity-definitions-query' import { executeSql } from 'data/sql/execute-sql-query' import { executeQuery } from 'lib/api/self-hosted/query' +import { z } from 'zod' export const getFallbackTools = ({ projectRef, diff --git a/packages/pg-meta/src/index.ts b/packages/pg-meta/src/index.ts index c5692818611..6035d5789da 100644 --- a/packages/pg-meta/src/index.ts +++ b/packages/pg-meta/src/index.ts @@ -18,56 +18,18 @@ import version from './pg-meta-version' import views from './pg-meta-views' import * as query from './query/index' -// Studio SQL queries -export { - getUserSQL, - getIndexStatusesSQL, - getUsersCountSQL, - getPaginatedUsersSQL, - getIndexWorkerStatusSQL, - USER_SEARCH_INDEXES, - type OptimizedSearchColumns, - type UsersCursor, -} from './sql/studio/auth' -export { - getLargestSizeLimitBucketsSqlUnoptimized, - LARGEST_SIZE_LIMIT_BUCKETS_COUNT, -} from './sql/studio/storage' -export { - getExposedTablesSql, - getExposedTableCountsSql, - getExposedFunctionsSql, - getExposedFunctionCountsSql, - buildTablePrivilegesSql, - buildFunctionPrivilegesSql, - buildDefaultPrivilegesSql, - getDefaultPrivilegesStateSql, -} from './sql/studio/privileges' -export { - getIndexesSQL, - getDatabaseExtensionDefaultSchemaSQL, - getCronJobsMinimalSql, - getCronJobsSql, - getJobRunDetailsPageCountSql, - getDeleteOldCronJobRunDetailsByCtidSql, - getScheduleDeleteCronJobRunDetailsSql, - getTableRowsCountSql, -} from './sql/studio/database' -export { - type ForeignKey, - FOREIGN_KEY_CASCADE_ACTION, - getAddPrimaryKeySQL, - getDropConstraintSQL, - getAddForeignKeySQL, - getRemoveForeignKeySQL, - getUpdateIdentitySequenceSQL, - getDuplicateIdentitySequenceSQL, - getDuplicateTableSQL, - getDuplicateRowsSQL, - getEnableRLSSQL, -} from './sql/studio/table-editor' - -export { getLiveTupleEstimate } from './sql/studio/get-live-tuple-estimate' +/** + * Studio specific SQL queries + * [Joshen] If it gets cumbersome, we can also consider path exports for studio queries + * So consumption can look something like: + * import { ... } from '@supabase/pg-meta/table-editor' + */ +export * from './sql/studio/advisor' +export * from './sql/studio/auth' +export * from './sql/studio/storage' +export * from './sql/studio/database' +export * from './sql/studio/table-editor' +export * from './sql/studio/sql-editor' export default { roles, diff --git a/packages/pg-meta/src/sql/studio/advisor/index-advisor.ts b/packages/pg-meta/src/sql/studio/advisor/index-advisor.ts new file mode 100644 index 00000000000..3fe41522920 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/advisor/index-advisor.ts @@ -0,0 +1,55 @@ +/** + * Generates SQL to find top 5 SELECT queries involving a table and run them through index_advisor + */ +export function getTableIndexAdvisorSql(schema: string, table: string): string { + const escapedSchema = schema.replace(/'/g, "''") + const escapedTable = table.replace(/'/g, "''") + + // Escape regex metacharacters so schema/table names are matched literally + const regexSchema = escapedSchema.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&') + const regexTable = escapedTable.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&') + + return /* SQL */ ` +-- Get top 5 SELECT queries involving this table and run through index_advisor +set search_path to public, extensions; + +with top_queries as ( + select + statements.query, + statements.calls, + statements.total_exec_time + statements.total_plan_time as total_time, + statements.mean_exec_time + statements.mean_plan_time as mean_time + from pg_stat_statements as statements + inner join pg_authid as auth on statements.userid = auth.oid + where + -- Filter for SELECT queries only (index_advisor only works with SELECT) + (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%') + -- Filter for queries involving our table. Use regex word boundaries so that e.g. + -- looking for table "orders" does not match queries on "orders_items". + and ( + lower(statements.query) ~ '(^|[^a-z0-9_$])${regexSchema}[.]${regexTable}($|[^a-z0-9_$])' + or lower(statements.query) ~ '(^|[^a-z0-9_$])from[[:space:]]+${regexTable}($|[^a-z0-9_$])' + or lower(statements.query) ~ '(^|[^a-z0-9_$])join[[:space:]]+${regexTable}($|[^a-z0-9_$])' + ) + -- Exclude system queries + and statements.query not like '%pg_catalog%' + and statements.query not like '%information_schema%' + order by statements.calls desc + limit 5 +) +select + tq.query, + tq.calls, + tq.total_time, + tq.mean_time, + coalesce(ia.index_statements, '{}') as index_statements, + coalesce((ia.startup_cost_before)::numeric, 0) as startup_cost_before, + coalesce((ia.startup_cost_after)::numeric, 0) as startup_cost_after, + coalesce((ia.total_cost_before)::numeric, 0) as total_cost_before, + coalesce((ia.total_cost_after)::numeric, 0) as total_cost_after +from top_queries tq +left join lateral ( + select * from index_advisor(tq.query) +) ia on true; +`.trim() +} diff --git a/packages/pg-meta/src/sql/studio/advisor/index.ts b/packages/pg-meta/src/sql/studio/advisor/index.ts new file mode 100644 index 00000000000..1bc3b58c74e --- /dev/null +++ b/packages/pg-meta/src/sql/studio/advisor/index.ts @@ -0,0 +1 @@ +export * from './index-advisor' diff --git a/packages/pg-meta/src/sql/studio/auth/get-users-count.ts b/packages/pg-meta/src/sql/studio/auth/get-users-count.ts index 530664402ff..864dd2807c9 100644 --- a/packages/pg-meta/src/sql/studio/auth/get-users-count.ts +++ b/packages/pg-meta/src/sql/studio/auth/get-users-count.ts @@ -1,5 +1,5 @@ import { literal } from '../../../pg-format' -import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from '../get-count-estimate' +import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from '../database/get-count-estimate' import { prefixToUUID, stringRange } from './get-users-common' import type { OptimizedSearchColumns } from './get-users-types' diff --git a/packages/pg-meta/src/sql/studio/check-primary-keys-exists.ts b/packages/pg-meta/src/sql/studio/check-primary-keys-exists.ts deleted file mode 100644 index 203e72bf423..00000000000 --- a/packages/pg-meta/src/sql/studio/check-primary-keys-exists.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const getCheckPrimaryKeysExistsSQL = (tables: { name: string; schema: string }[]) => { - const formattedTables = tables.map((table) => `'${table.schema}.${table.name}'`).join(',') - - return /* SQL */ ` -WITH targets(rel) AS ( - SELECT unnest(ARRAY[${formattedTables}]::regclass[]) -) -SELECT - c.oid AS id, - n.nspname AS schema, - c.relname AS name, - (con.conrelid IS NOT NULL) AS has_primary_key -FROM targets t -JOIN pg_class c ON c.oid = t.rel -JOIN pg_namespace n ON n.oid = c.relnamespace -LEFT JOIN pg_constraint con - ON con.conrelid = c.oid AND con.contype = 'p' -ORDER BY n.nspname, c.relname; -` -} diff --git a/packages/pg-meta/src/sql/studio/check-tables-anon-authenticated-access.ts b/packages/pg-meta/src/sql/studio/database/check-tables-anon-authenticated-access.ts similarity index 100% rename from packages/pg-meta/src/sql/studio/check-tables-anon-authenticated-access.ts rename to packages/pg-meta/src/sql/studio/database/check-tables-anon-authenticated-access.ts diff --git a/packages/pg-meta/src/sql/studio/database/columns.ts b/packages/pg-meta/src/sql/studio/database/columns.ts new file mode 100644 index 00000000000..170071cf455 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/database/columns.ts @@ -0,0 +1,74 @@ +export const getTableColumnsSql = ({ table, schema }: { table?: string; schema?: string }) => { + const conditions = [] + if (table) { + conditions.push(`tablename = '${table}'`) + } + if (schema) { + conditions.push(`schemaname = '${schema}'`) + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + const sql = /* SQL */ ` + + SELECT + tbl.schemaname, + tbl.tablename, + tbl.quoted_name, + tbl.is_table, + json_agg(a) as columns + FROM + ( + SELECT + n.nspname as schemaname, + c.relname as tablename, + (quote_ident(n.nspname) || '.' || quote_ident(c.relname)) as quoted_name, + true as is_table + FROM + pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE + c.relkind = 'r' + AND n.nspname not in ('information_schema', 'pg_catalog', 'pg_toast') + AND n.nspname not like 'pg_temp_%' + AND n.nspname not like 'pg_toast_temp_%' + AND has_schema_privilege(n.oid, 'USAGE') = true + AND has_table_privilege(quote_ident(n.nspname) || '.' || quote_ident(c.relname), 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER') = true + union all + SELECT + n.nspname as schemaname, + c.relname as tablename, + (quote_ident(n.nspname) || '.' || quote_ident(c.relname)) as quoted_name, + false as is_table + FROM + pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE + c.relkind in ('v', 'm') + AND n.nspname not in ('information_schema', 'pg_catalog', 'pg_toast') + AND n.nspname not like 'pg_temp_%' + AND n.nspname not like 'pg_toast_temp_%' + AND has_schema_privilege(n.oid, 'USAGE') = true + AND has_table_privilege(quote_ident(n.nspname) || '.' || quote_ident(c.relname), 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER') = true + ) as tbl + LEFT JOIN ( + SELECT + attrelid, + attname, + format_type(atttypid, atttypmod) as data_type, + attnum, + attisdropped + FROM + pg_attribute + ) as a ON ( + a.attrelid = tbl.quoted_name::regclass + AND a.attnum > 0 + AND NOT a.attisdropped + AND has_column_privilege(tbl.quoted_name, a.attname, 'SELECT, INSERT, UPDATE, REFERENCES') + ) + ${whereClause} + GROUP BY schemaname, tablename, quoted_name, is_table; +`.trim() + + return sql +} diff --git a/packages/pg-meta/src/sql/studio/get-count-estimate.ts b/packages/pg-meta/src/sql/studio/database/get-count-estimate.ts similarity index 100% rename from packages/pg-meta/src/sql/studio/get-count-estimate.ts rename to packages/pg-meta/src/sql/studio/database/get-count-estimate.ts diff --git a/packages/pg-meta/src/sql/studio/database/index.ts b/packages/pg-meta/src/sql/studio/database/index.ts index ef3bfe8ad23..293e332cdff 100644 --- a/packages/pg-meta/src/sql/studio/database/index.ts +++ b/packages/pg-meta/src/sql/studio/database/index.ts @@ -1,4 +1,14 @@ +export * from './check-tables-anon-authenticated-access' +export * from './columns' export * from './cron-jobs' export * from './extensions' +export * from './get-count-estimate' export * from './indexes' +export * from './migrations' +export * from './misc' +export * from './privileges' +export * from './queues' export * from './rows' +export * from './table-definition' +export * from './triggers' +export * from './views' diff --git a/packages/pg-meta/src/sql/studio/database/migrations.ts b/packages/pg-meta/src/sql/studio/database/migrations.ts new file mode 100644 index 00000000000..2d0148f289d --- /dev/null +++ b/packages/pg-meta/src/sql/studio/database/migrations.ts @@ -0,0 +1,10 @@ +export const getMigrationsSql = () => { + const sql = /* SQL */ ` + select + * + from supabase_migrations.schema_migrations sm + order by sm.version desc + `.trim() + + return sql +} diff --git a/packages/pg-meta/src/sql/studio/database/misc.ts b/packages/pg-meta/src/sql/studio/database/misc.ts new file mode 100644 index 00000000000..e98e40e734f --- /dev/null +++ b/packages/pg-meta/src/sql/studio/database/misc.ts @@ -0,0 +1,39 @@ +import { literal } from '../../../pg-format' + +export const getDatabaseSizeSql = () => { + const sql = /* SQL */ ` +select sum(pg_database_size(pg_database.datname))::bigint as db_size from pg_database; +`.trim() + + return sql +} + +export const getLiveTupleEstimate = (table: string, schema: string = 'public') => { + const sql = /* SQL */ ` +SELECT n_live_tup AS live_tuple_estimate +FROM pg_stat_user_tables +WHERE schemaname = ${literal(schema)} +AND relname = ${literal(table)}; +`.trim() + + return sql +} + +export const getMaxConnectionsSql = () => { + const sql = /* SQL */ `show max_connections` + + return sql +} + +export const replicationLagSql = () => { + const sql = /* SQL */ ` +select + case + when (select count(*) from pg_stat_wal_receiver) = 1 and pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() + then 0 + else coalesce(extract(epoch from now() - pg_last_xact_replay_timestamp()),0) + end as physical_replica_lag_second + ` + + return sql +} diff --git a/packages/pg-meta/src/sql/studio/privileges.ts b/packages/pg-meta/src/sql/studio/database/privileges.ts similarity index 100% rename from packages/pg-meta/src/sql/studio/privileges.ts rename to packages/pg-meta/src/sql/studio/database/privileges.ts diff --git a/packages/pg-meta/src/sql/studio/database/queues.ts b/packages/pg-meta/src/sql/studio/database/queues.ts new file mode 100644 index 00000000000..a0aa3674ba2 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/database/queues.ts @@ -0,0 +1,215 @@ +export const QUEUES_SCHEMA = 'pgmq_public' + +export const HIDE_QUEUES_FROM_POSTGREST_SQL = /* SQL */ ` + drop function if exists + ${QUEUES_SCHEMA}.pop(queue_name text), + ${QUEUES_SCHEMA}.send(queue_name text, message jsonb, sleep_seconds integer), + ${QUEUES_SCHEMA}.send_batch(queue_name text, message jsonb[], sleep_seconds integer), + ${QUEUES_SCHEMA}.archive(queue_name text, message_id bigint), + ${QUEUES_SCHEMA}.delete(queue_name text, message_id bigint), + ${QUEUES_SCHEMA}.read(queue_name text, sleep integer, n integer) + ; + + -- Revoke execute permissions on inner pgmq functions to roles (inverse of enabling) + do $$ + begin + if exists (select 1 from pg_namespace where nspname = 'pgmq') then + -- Revoke privileges on the schema itself + revoke all on schema pgmq from anon, authenticated, service_role; + + -- Revoke default privileges for future objects + alter default privileges in schema pgmq revoke all on tables from anon, authenticated, service_role; + alter default privileges in schema pgmq revoke all on sequences from anon, authenticated, service_role; + alter default privileges in schema pgmq revoke all on functions from anon, authenticated, service_role; + end if; + end $$; + + drop schema if exists ${QUEUES_SCHEMA}; +` + +export const getExposeQueuesSQL = ({ isNewerPgmqversion }: { isNewerPgmqversion: boolean }) => { + const conditionalJsonb = isNewerPgmqversion ? `, conditional := '{}'::jsonb` : '' + const jsonBArg = isNewerPgmqversion ? `, jsonb` : '' + + return /* SQL */ ` + create schema if not exists ${QUEUES_SCHEMA}; + grant usage on schema ${QUEUES_SCHEMA} to postgres, anon, authenticated, service_role; + + create or replace function ${QUEUES_SCHEMA}.pop( + queue_name text + ) + returns setof pgmq.message_record + language plpgsql + set search_path = '' + as $$ + begin + return query + select * + from pgmq.pop( + queue_name := queue_name + ); + end; + $$; + + comment on function ${QUEUES_SCHEMA}.pop(queue_name text) is 'Retrieves and locks the next message from the specified queue.'; + + + create or replace function ${QUEUES_SCHEMA}.send( + queue_name text, + message jsonb, + sleep_seconds integer default 0 -- renamed from 'delay' + ) + returns setof bigint + language plpgsql + set search_path = '' + as $$ + begin + return query + select * + from pgmq.send( + queue_name := queue_name, + msg := message, + delay := sleep_seconds + ); + end; + $$; + + comment on function ${QUEUES_SCHEMA}.send(queue_name text, message jsonb, sleep_seconds integer) is 'Sends a message to the specified queue, optionally delaying its availability by a number of seconds.'; + + + create or replace function ${QUEUES_SCHEMA}.send_batch( + queue_name text, + messages jsonb[], + sleep_seconds integer default 0 -- renamed from 'delay' + ) + returns setof bigint + language plpgsql + set search_path = '' + as $$ + begin + return query + select * + from pgmq.send_batch( + queue_name := queue_name, + msgs := messages, + delay := sleep_seconds + ); + end; + $$; + + comment on function ${QUEUES_SCHEMA}.send_batch(queue_name text, messages jsonb[], sleep_seconds integer) is 'Sends a batch of messages to the specified queue, optionally delaying their availability by a number of seconds.'; + + + create or replace function ${QUEUES_SCHEMA}.archive( + queue_name text, + message_id bigint + ) + returns boolean + language plpgsql + set search_path = '' + as $$ + begin + return + pgmq.archive( + queue_name := queue_name, + msg_id := message_id + ); + end; + $$; + + comment on function ${QUEUES_SCHEMA}.archive(queue_name text, message_id bigint) is 'Archives a message by moving it from the queue to a permanent archive.'; + + + create or replace function ${QUEUES_SCHEMA}.delete( + queue_name text, + message_id bigint + ) + returns boolean + language plpgsql + set search_path = '' + as $$ + begin + return + pgmq.delete( + queue_name := queue_name, + msg_id := message_id + ); + end; + $$; + + comment on function ${QUEUES_SCHEMA}.delete(queue_name text, message_id bigint) is 'Permanently deletes a message from the specified queue.'; + + create or replace function ${QUEUES_SCHEMA}.read( + queue_name text, + sleep_seconds integer, + n integer + ) + returns setof pgmq.message_record + language plpgsql + set search_path = '' + as $$ + begin + return query + select * + from pgmq.read( + queue_name := queue_name, + vt := sleep_seconds, + qty := n ${conditionalJsonb} + ); + end; + $$; + + comment on function ${QUEUES_SCHEMA}.read(queue_name text, sleep_seconds integer, n integer) is 'Reads up to "n" messages from the specified queue with an optional "sleep_seconds" (visibility timeout).'; + + -- Grant execute permissions on wrapper functions to roles + grant execute on function ${QUEUES_SCHEMA}.pop(text) to postgres, service_role, anon, authenticated; + grant execute on function pgmq.pop(text) to postgres, service_role, anon, authenticated; + + grant execute on function ${QUEUES_SCHEMA}.send(text, jsonb, integer) to postgres, service_role, anon, authenticated; + grant execute on function pgmq.send(text, jsonb, integer) to postgres, service_role, anon, authenticated; + + grant execute on function ${QUEUES_SCHEMA}.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; + grant execute on function pgmq.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; + + grant execute on function ${QUEUES_SCHEMA}.archive(text, bigint) to postgres, service_role, anon, authenticated; + grant execute on function pgmq.archive(text, bigint) to postgres, service_role, anon, authenticated; + + grant execute on function ${QUEUES_SCHEMA}.delete(text, bigint) to postgres, service_role, anon, authenticated; + grant execute on function pgmq.delete(text, bigint) to postgres, service_role, anon, authenticated; + + grant execute on function ${QUEUES_SCHEMA}.read(text, integer, integer) to postgres, service_role, anon, authenticated; + grant execute on function pgmq.read(text, integer, integer ${jsonBArg}) to postgres, service_role, anon, authenticated; + + -- For the service role, we want full access + -- Grant permissions on existing tables + grant all privileges on all tables in schema pgmq to postgres, service_role; + + -- Ensure service_role has permissions on future tables + alter default privileges in schema pgmq grant all privileges on tables to postgres, service_role; + + grant usage on schema pgmq to postgres, anon, authenticated, service_role; + + + /* + Grant access to sequences to API roles by default. Existing table permissions + continue to enforce insert restrictions. This is necessary to accommodate the + on-backup hook that rebuild queue table primary keys to avoid a pg_dump segfault. + This can be removed once logical backups are completely retired. + */ + grant usage, select, update + on all sequences in schema pgmq + to anon, authenticated, service_role; + + alter default privileges in schema pgmq + grant usage, select, update + on sequences + to anon, authenticated, service_role; +`.trim() +} + +// [Joshen] Check if all the relevant functions exist to indicate whether PGMQ has been exposed through PostgREST +export const getQueuesExposePostgrestStatusSQL = () => { + return /**SQL */ ` + SELECT exists (select schema_name FROM information_schema.schemata WHERE schema_name = '${QUEUES_SCHEMA}'); + `.trim() +} diff --git a/packages/pg-meta/src/sql/studio/database/rows.ts b/packages/pg-meta/src/sql/studio/database/rows.ts index fd475b5ef7f..6de45ce52ec 100644 --- a/packages/pg-meta/src/sql/studio/database/rows.ts +++ b/packages/pg-meta/src/sql/studio/database/rows.ts @@ -1,5 +1,5 @@ import { Filter, Query } from '../../../query' -import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from '../get-count-estimate' +import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from './get-count-estimate' /** * [Joshen] Initially check reltuples from pg_class for an estimate of row count on the table diff --git a/packages/pg-meta/src/sql/studio/database/table-definition.ts b/packages/pg-meta/src/sql/studio/database/table-definition.ts new file mode 100644 index 00000000000..78e47afe139 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/database/table-definition.ts @@ -0,0 +1,802 @@ +/** + * Derived from https://github.com/MichaelDBA/pg_get_tabledef + * NOTE: when updating, \n must be replaced with \\n in the SQL below + * public.x is replaced with pg_temp.x + + * REMOVE (as it breaks the encoding for some reason): + * SELECT REPLACE(REPLACE(setting, '"$user"', '$user'), '$user', '"$user"') INTO search_path_old + * FROM pg_settings + * WHERE name = 'search_path'; + */ + +export const CREATE_PG_GET_TABLEDEF_SQL = /* SQL */ ` + DROP TYPE IF EXISTS pg_temp.tabledefs CASCADE; + CREATE TYPE pg_temp.tabledefs AS ENUM ('PKEY_INTERNAL','PKEY_EXTERNAL','FKEYS_INTERNAL', 'FKEYS_EXTERNAL', 'COMMENTS', 'FKEYS_NONE', 'INCLUDE_TRIGGERS', 'NO_TRIGGERS'); + + -- SELECT * FROM pg_temp.pg_get_coldef('sample','orders','id'); + -- DROP FUNCTION pg_temp.pg_get_coldef(text,text,text,boolean); + CREATE OR REPLACE FUNCTION pg_temp.pg_get_coldef( + in_schema text, + in_table text, + in_column text, + oldway boolean default False + ) + RETURNS text + LANGUAGE plpgsql VOLATILE + AS + $$ + DECLARE + v_coldef text; + v_dt1 text; + v_dt2 text; + v_dt3 text; + v_nullable boolean; + v_position int; + v_identity text; + v_generated text; + v_hasdflt boolean; + v_dfltexpr text; + + BEGIN + IF oldway THEN + SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) INTO v_coldef FROM pg_namespace n, pg_class c, pg_attribute a, pg_type t + WHERE n.nspname = in_schema AND n.oid = c.relnamespace AND c.relname = in_table AND a.attname = in_column and a.attnum > 0 AND a.attrelid = c.oid AND a.atttypid = t.oid ORDER BY a.attnum; + -- RAISE NOTICE 'DEBUG: oldway=%',v_coldef; + ELSE + -- a.attrelid::regclass::text, a.attname + SELECT CASE WHEN a.atttypid = ANY ('{int,int8,int2}'::regtype[]) AND EXISTS (SELECT FROM pg_attrdef ad WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum AND + pg_get_expr(ad.adbin, ad.adrelid) = 'nextval(''' || (pg_get_serial_sequence (a.attrelid::regclass::text, a.attname))::regclass || '''::regclass)') THEN CASE a.atttypid + WHEN 'int'::regtype THEN 'serial' WHEN 'int8'::regtype THEN 'bigserial' WHEN 'int2'::regtype THEN 'smallserial' END ELSE format_type(a.atttypid, a.atttypmod) END AS data_type + INTO v_coldef FROM pg_namespace n, pg_class c, pg_attribute a, pg_type t + WHERE n.nspname = in_schema AND n.oid = c.relnamespace AND c.relname = in_table AND a.attname = in_column and a.attnum > 0 AND a.attrelid = c.oid AND a.atttypid = t.oid ORDER BY a.attnum; + -- RAISE NOTICE 'DEBUG: newway=%',v_coldef; + + -- Issue#24: not implemented yet + -- might replace with this below to do more detailed parsing... + -- SELECT a.atttypid::regtype AS dt1, format_type(a.atttypid, a.atttypmod) as dt2, t.typname as dt3, CASE WHEN not(a.attnotnull) THEN True ELSE False END AS nullable, + -- a.attnum, a.attidentity, a.attgenerated, a.atthasdef, pg_get_expr(ad.adbin, ad.adrelid) dfltexpr + -- INTO v_dt1, v_dt2, v_dt3, v_nullable, v_position, v_identity, v_generated, v_hasdflt, v_dfltexpr + -- FROM pg_attribute a JOIN pg_class c ON (a.attrelid = c.oid) JOIN pg_type t ON (a.atttypid = t.oid) LEFT JOIN pg_attrdef ad ON (a.attrelid = ad.adrelid AND a.attnum = ad.adnum) + -- WHERE c.relkind in ('r','p') AND a.attnum > 0 AND NOT a.attisdropped AND c.relnamespace::regnamespace::text = in_schema AND c.relname = in_table AND a.attname = in_column; + -- RAISE NOTICE 'schema=% table=% column=% dt1=% dt2=% dt3=% nullable=% pos=% identity=% generated=% HasDefault=% DeftExpr=%', in_schema, in_table, in_column, v_dt1,v_dt2,v_dt3,v_nullable,v_position,v_identity,v_generated,v_hasdflt,v_dfltexpr; + END IF; + RETURN v_coldef; + END; + $$; + + -- SELECT * FROM pg_temp.pg_get_tabledef('sample', 'address', false); + DROP FUNCTION IF EXISTS pg_temp.pg_get_tabledef(character varying,character varying,boolean,tabledefs[]); + CREATE OR REPLACE FUNCTION pg_temp.pg_get_tabledef( + in_schema varchar, + in_table varchar, + _verbose boolean, + VARIADIC arr pg_temp.tabledefs[] DEFAULT '{}':: pg_temp.tabledefs[] + ) + RETURNS text + LANGUAGE plpgsql VOLATILE + AS + $$ + DECLARE + v_qualified text := ''; + v_table_ddl text; + v_table_oid int; + v_colrec record; + v_constraintrec record; + v_trigrec record; + v_indexrec record; + v_rec record; + v_constraint_name text; + v_constraint_def text; + v_pkey_def text := ''; + v_fkey_def text := ''; + v_fkey_defs text := ''; + v_trigger text := ''; + v_partition_key text := ''; + v_partbound text; + v_parent text; + v_parent_schema text; + v_persist text; + v_temp text := ''; + v_temp2 text; + v_relopts text; + v_tablespace text; + v_pgversion int; + bSerial boolean; + bPartition boolean; + bInheritance boolean; + bRelispartition boolean; + constraintarr text[] := '{}'; + constraintelement text; + bSkip boolean; + bVerbose boolean := False; + v_cnt1 integer; + v_cnt2 integer; + search_path_old text := ''; + search_path_new text := ''; + v_partial boolean; + v_pos integer; + + -- assume defaults for ENUMs at the getgo + pkcnt int := 0; + fkcnt int := 0; + trigcnt int := 0; + cmtcnt int := 0; + pktype pg_temp.tabledefs := 'PKEY_INTERNAL'; + fktype pg_temp.tabledefs := 'FKEYS_INTERNAL'; + trigtype pg_temp.tabledefs := 'NO_TRIGGERS'; + arglen integer; + vargs text; + avarg pg_temp.tabledefs; + + -- exception variables + v_ret text; + v_diag1 text; + v_diag2 text; + v_diag3 text; + v_diag4 text; + v_diag5 text; + v_diag6 text; + + BEGIN + SET client_min_messages = 'notice'; + IF _verbose THEN bVerbose = True; END IF; + + -- v17 fix: handle case-sensitive + -- v_qualified = in_schema || '.' || in_table; + + arglen := array_length($4, 1); + IF arglen IS NULL THEN + -- nothing to do, so assume defaults + NULL; + ELSE + -- loop thru args + -- IF 'NO_TRIGGERS' = ANY ($4) + -- select array_to_string($4, ',', '***') INTO vargs; + IF bVerbose THEN RAISE NOTICE 'arguments=%', $4; END IF; + FOREACH avarg IN ARRAY $4 LOOP + IF bVerbose THEN RAISE NOTICE 'arg=%', avarg; END IF; + IF avarg = 'FKEYS_INTERNAL' OR avarg = 'FKEYS_EXTERNAL' OR avarg = 'FKEYS_NONE' THEN + fkcnt = fkcnt + 1; + fktype = avarg; + ELSEIF avarg = 'INCLUDE_TRIGGERS' OR avarg = 'NO_TRIGGERS' THEN + trigcnt = trigcnt + 1; + trigtype = avarg; + ELSEIF avarg = 'PKEY_EXTERNAL' THEN + pkcnt = pkcnt + 1; + pktype = avarg; + ELSEIF avarg = 'COMMENTS' THEN + cmtcnt = cmtcnt + 1; + + END IF; + END LOOP; + IF fkcnt > 1 THEN + RAISE WARNING 'Only one foreign key option can be provided. You provided %', fkcnt; + RETURN ''; + ELSEIF trigcnt > 1 THEN + RAISE WARNING 'Only one trigger option can be provided. You provided %', trigcnt; + RETURN ''; + ELSEIF pkcnt > 1 THEN + RAISE WARNING 'Only one pkey option can be provided. You provided %', pkcnt; + RETURN ''; + ELSEIF cmtcnt > 1 THEN + RAISE WARNING 'Only one comments option can be provided. You provided %', cmtcnt; + RETURN ''; + + END IF; + END IF; + + SELECT c.oid, (select setting from pg_settings where name = 'server_version_num') INTO v_table_oid, v_pgversion FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind in ('r','p') AND c.relname = in_table AND n.nspname = in_schema; + + -- set search_path = public before we do anything to force explicit schema qualification but dont forget to set it back before exiting... + SELECT setting INTO search_path_old FROM pg_settings WHERE name = 'search_path'; + + -- RAISE NOTICE 'DEBUG tableddl: saving old search_path: ***%***', search_path_old; + EXECUTE 'SET search_path = "public"'; + SELECT setting INTO search_path_new FROM pg_settings WHERE name = 'search_path'; + -- RAISE NOTICE 'DEBUG tableddl: using new search path=***%***', search_path_new; + + -- throw an error if table was not found + IF (v_table_oid IS NULL) THEN + RAISE EXCEPTION 'table does not exist'; + END IF; + + -- get user-defined tablespaces if applicable + SELECT tablespace INTO v_temp FROM pg_tables WHERE schemaname = in_schema and tablename = in_table and tablespace IS NOT NULL; + IF v_temp IS NULL THEN + v_tablespace := 'TABLESPACE pg_default'; + ELSE + v_tablespace := 'TABLESPACE ' || v_temp; + END IF; + + -- also see if there are any SET commands for this table, ie, autovacuum_enabled=off, fillfactor=70 + WITH relopts AS (SELECT unnest(c.reloptions) relopts FROM pg_class c, pg_namespace n WHERE n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table) + SELECT string_agg(r.relopts, ', ') as relopts INTO v_temp from relopts r; + IF v_temp IS NULL THEN + v_relopts := ''; + ELSE + v_relopts := ' WITH (' || v_temp || ')'; + END IF; + + -- ----------------------------------------------------------------------------------- + -- Create table defs for partitions/children using inheritance or declarative methods. + -- inheritance: pg_class.relkind = 'r' pg_class.relispartition=false pg_class.relpartbound is NULL + -- declarative: pg_class.relkind = 'r' pg_class.relispartition=true pg_class.relpartbound is NOT NULL + -- ----------------------------------------------------------------------------------- + v_partbound := ''; + bPartition := False; + bInheritance := False; + IF v_pgversion < 100000 THEN + -- Issue#11: handle parent schema + SELECT c2.relname parent, c2.relnamespace::regnamespace INTO v_parent, v_parent_schema from pg_class c1, pg_namespace n, pg_inherits i, pg_class c2 + WHERE n.nspname = in_schema and n.oid = c1.relnamespace and c1.relname = in_table and c1.oid = i.inhrelid and i.inhparent = c2.oid and c1.relkind = 'r'; + IF (v_parent IS NOT NULL) THEN + bPartition := True; + bInheritance := True; + END IF; + ELSE + -- Issue#11: handle parent schema + SELECT c2.relname parent, c1.relispartition, pg_get_expr(c1.relpartbound, c1.oid, true), c2.relnamespace::regnamespace INTO v_parent, bRelispartition, v_partbound, v_parent_schema from pg_class c1, pg_namespace n, pg_inherits i, pg_class c2 + WHERE n.nspname = in_schema and n.oid = c1.relnamespace and c1.relname = in_table and c1.oid = i.inhrelid and i.inhparent = c2.oid and c1.relkind = 'r'; + IF (v_parent IS NOT NULL) THEN + bPartition := True; + IF bRelispartition THEN + bInheritance := False; + ELSE + bInheritance := True; + END IF; + END IF; + END IF; + IF bPartition THEN + --Issue#17 fix for case-sensitive tables + -- SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s + -- WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = quote_ident(in_schema) AND t.table_name = quote_ident(in_table) AND t.table_type = 'BASE TABLE'); + SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s + WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = in_schema AND t.table_name = in_table AND t.table_type = 'BASE TABLE'); + + --Issue#19 put double-quotes around SQL keyword column names + -- Issue#121: fix keyword lookup for table name not column name that does not apply here + -- SELECT COUNT(*) INTO v_cnt2 FROM pg_get_keywords() WHERE word = v_colrec.column_name AND catcode = 'R'; + SELECT COUNT(*) INTO v_cnt2 FROM pg_get_keywords() WHERE word = in_table AND catcode = 'R'; + + IF bInheritance THEN + -- inheritance-based + IF v_cnt1 > 0 OR v_cnt2 > 0 THEN + v_table_ddl := 'CREATE TABLE ' || in_schema || '."' || in_table || '"( '|| E'\\n'; + ELSE + v_table_ddl := 'CREATE TABLE ' || in_schema || '.' || in_table || '( '|| E'\\n'; + END IF; + + -- Jump to constraints section to add the check constraints + ELSE + -- declarative-based + IF v_relopts <> '' THEN + IF v_cnt1 > 0 OR v_cnt2 > 0 THEN + v_table_ddl := 'CREATE TABLE ' || in_schema || '."' || in_table || '" PARTITION OF ' || in_schema || '.' || v_parent || ' ' || v_partbound || v_relopts || ' ' || v_tablespace || '; ' || E'\\n'; + ELSE + v_table_ddl := 'CREATE TABLE ' || in_schema || '.' || in_table || ' PARTITION OF ' || in_schema || '.' || v_parent || ' ' || v_partbound || v_relopts || ' ' || v_tablespace || '; ' || E'\\n'; + END IF; + ELSE + IF v_cnt1 > 0 OR v_cnt2 > 0 THEN + v_table_ddl := 'CREATE TABLE ' || in_schema || '."' || in_table || '" PARTITION OF ' || in_schema || '.' || v_parent || ' ' || v_partbound || ' ' || v_tablespace || '; ' || E'\\n'; + ELSE + v_table_ddl := 'CREATE TABLE ' || in_schema || '.' || in_table || ' PARTITION OF ' || in_schema || '.' || v_parent || ' ' || v_partbound || ' ' || v_tablespace || '; ' || E'\\n'; + END IF; + END IF; + -- Jump to constraints and index section to add the check constraints and indexes and perhaps FKeys + END IF; + END IF; + IF bVerbose THEN RAISE NOTICE '(1)tabledef so far: %', v_table_ddl; END IF; + + IF NOT bPartition THEN + -- see if this is unlogged or temporary table + select c.relpersistence into v_persist from pg_class c, pg_namespace n where n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table and c.relkind = 'r'; + IF v_persist = 'u' THEN + v_temp := 'UNLOGGED'; + ELSIF v_persist = 't' THEN + v_temp := 'TEMPORARY'; + ELSE + v_temp := ''; + END IF; + END IF; + + -- start the create definition for regular tables unless we are in progress creating an inheritance-based child table + IF NOT bPartition THEN + --Issue#17 fix for case-sensitive tables + -- SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s + -- WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = quote_ident(in_schema) AND t.table_name = quote_ident(in_table) AND t.table_type = 'BASE TABLE'); + SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s + WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = in_schema AND t.table_name = in_table AND t.table_type = 'BASE TABLE'); + IF v_cnt1 > 0 THEN + v_table_ddl := 'CREATE ' || v_temp || ' TABLE ' || in_schema || '."' || in_table || '" (' || E'\\n'; + ELSE + v_table_ddl := 'CREATE ' || v_temp || ' TABLE ' || in_schema || '.' || in_table || ' (' || E'\\n'; + END IF; + END IF; + -- RAISE NOTICE 'DEBUG2: tabledef so far: %', v_table_ddl; + -- define all of the columns in the table unless we are in progress creating an inheritance-based child table + IF NOT bPartition THEN + FOR v_colrec IN + SELECT c.column_name, c.data_type, c.udt_name, c.udt_schema, c.character_maximum_length, c.is_nullable, c.column_default, c.numeric_precision, c.numeric_scale, c.is_identity, c.identity_generation, c.is_generated, c.generation_expression + FROM information_schema.columns c WHERE (table_schema, table_name) = (in_schema, in_table) ORDER BY ordinal_position + LOOP + IF bVerbose THEN RAISE NOTICE '(col loop) name=% type=% udt_name=% default=% is_generated=% gen_expr=%', v_colrec.column_name, v_colrec.data_type, v_colrec.udt_name, v_colrec.column_default, v_colrec.is_generated, v_colrec.generation_expression; END IF; + + -- v17 fix: handle case-sensitive for pg_get_serial_sequence that requires SQL Identifier handling + -- SELECT CASE WHEN pg_get_serial_sequence(v_qualified, v_colrec.column_name) IS NOT NULL THEN True ELSE False END into bSerial; + SELECT CASE WHEN pg_get_serial_sequence(quote_ident(in_schema) || '.' || quote_ident(in_table), v_colrec.column_name) IS NOT NULL THEN True ELSE False END into bSerial; + IF bVerbose THEN + -- v17 fix: handle case-sensitive for pg_get_serial_sequence that requires SQL Identifier handling + -- SELECT pg_get_serial_sequence(v_qualified, v_colrec.column_name) into v_temp; + SELECT pg_get_serial_sequence(quote_ident(in_schema) || '.' || quote_ident(in_table), v_colrec.column_name) into v_temp; + IF v_temp IS NULL THEN v_temp = 'NA'; END IF; + SELECT pg_temp.pg_get_coldef(in_schema, in_table,v_colrec.column_name) INTO v_diag1; + RAISE NOTICE 'DEBUG table: % Column: % datatype: % Serial=% serialval=% coldef=%', v_qualified, v_colrec.column_name, v_colrec.data_type, bSerial, v_temp, v_diag1; + RAISE NOTICE 'DEBUG tabledef: %', v_table_ddl; + END IF; + + --Issue#17 put double-quotes around case-sensitive column names + SELECT COUNT(*) INTO v_cnt1 FROM information_schema.columns t WHERE EXISTS (SELECT REGEXP_MATCHES(s.column_name, '([A-Z]+)','g') FROM information_schema.columns s + WHERE t.table_schema=s.table_schema and t.table_name=s.table_name and t.column_name=s.column_name AND t.table_schema = quote_ident(in_schema) AND column_name = v_colrec.column_name); + + --Issue#19 put double-quotes around SQL keyword column names + SELECT COUNT(*) INTO v_cnt2 FROM pg_get_keywords() WHERE word = v_colrec.column_name AND catcode = 'R'; + + IF v_cnt1 > 0 OR v_cnt2 > 0 THEN + v_table_ddl := v_table_ddl || ' "' || v_colrec.column_name || '" '; + ELSE + v_table_ddl := v_table_ddl || ' ' || v_colrec.column_name || ' '; + END IF; + + -- Issue#23: Handle autogenerated columns and rewrite as a simpler IF THEN ELSE branch instead of a much more complex embedded CASE STATEMENT + IF v_colrec.is_generated = 'ALWAYS' and v_colrec.generation_expression IS NOT NULL THEN + -- searchable tsvector GENERATED ALWAYS AS (to_tsvector('simple'::regconfig, COALESCE(translate(email, '@.-'::citext, ' '::text), ''::text)) ) STORED + v_temp = v_colrec.data_type || ' GENERATED ALWAYS AS (' || v_colrec.generation_expression || ') STORED '; + ELSEIF v_colrec.udt_name in ('geometry', 'box2d', 'box2df', 'box3d', 'geography', 'geometry_dump', 'gidx', 'spheroid', 'valid_detail') THEN + v_temp = v_colrec.udt_name; + ELSEIF v_colrec.data_type = 'USER-DEFINED' THEN + v_temp = v_colrec.udt_schema || '.' || v_colrec.udt_name; + ELSEIF v_colrec.data_type = 'ARRAY' THEN + -- Issue#6 fix: handle arrays + v_temp = pg_temp.pg_get_coldef(in_schema, in_table,v_colrec.column_name); + -- v17 fix: handle case-sensitive for pg_get_serial_sequence that requires SQL Identifier handling + -- WHEN pg_get_serial_sequence(v_qualified, v_colrec.column_name) IS NOT NULL + ELSEIF pg_get_serial_sequence(quote_ident(in_schema) || '.' || quote_ident(in_table), v_colrec.column_name) IS NOT NULL THEN + -- Issue#8 fix: handle serial. Note: NOT NULL is implied so no need to declare it explicitly + v_temp = pg_temp.pg_get_coldef(in_schema, in_table,v_colrec.column_name); + ELSE + v_temp = v_colrec.data_type; + END IF; + -- RAISE NOTICE 'column def1=%', v_temp; + + -- handle IDENTITY columns + IF v_colrec.is_identity = 'YES' THEN + IF v_colrec.identity_generation = 'ALWAYS' THEN + v_temp = v_temp || ' GENERATED ALWAYS AS IDENTITY'; + ELSE + v_temp = v_temp || ' GENERATED BY DEFAULT AS IDENTITY'; + END IF; + ELSEIF v_colrec.character_maximum_length IS NOT NULL THEN + v_temp = v_temp || ('(' || v_colrec.character_maximum_length || ')'); + ELSEIF v_colrec.numeric_precision > 0 AND v_colrec.numeric_scale > 0 THEN + v_temp = v_temp || '(' || v_colrec.numeric_precision || ',' || v_colrec.numeric_scale || ')'; + END IF; + + -- Handle NULL/NOT NULL + IF bSerial THEN + v_temp = v_temp || ' NOT NULL'; + ELSEIF v_colrec.is_nullable = 'NO' THEN + v_temp = v_temp || ' NOT NULL'; + ELSEIF v_colrec.is_nullable = 'YES' THEN + v_temp = v_temp || ' NULL'; + END IF; + + -- Handle defaults + IF v_colrec.column_default IS NOT null AND NOT bSerial THEN + -- RAISE NOTICE 'Setting default for column, %', v_colrec.column_name; + v_temp = v_temp || (' DEFAULT ' || v_colrec.column_default); + END IF; + v_temp = v_temp || ',' || E'\\n'; + -- RAISE NOTICE 'column def2=%', v_temp; + v_table_ddl := v_table_ddl || v_temp; + -- RAISE NOTICE 'tabledef=%', v_table_ddl; + + END LOOP; + END IF; + IF bVerbose THEN RAISE NOTICE '(2)tabledef so far: %', v_table_ddl; END IF; + + -- define all the constraints: conparentid does not exist pre PGv11 + IF v_pgversion < 110000 THEN + FOR v_constraintrec IN + SELECT con.conname as constraint_name, con.contype as constraint_type, + CASE + WHEN con.contype = 'p' THEN 1 -- primary key constraint + WHEN con.contype = 'u' THEN 2 -- unique constraint + WHEN con.contype = 'f' THEN 3 -- foreign key constraint + WHEN con.contype = 'c' THEN 4 + ELSE 5 + END as type_rank, + pg_get_constraintdef(con.oid) as constraint_definition + FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid JOIN pg_catalog.pg_namespace nsp ON nsp.oid = connamespace + WHERE nsp.nspname = in_schema AND rel.relname = in_table ORDER BY type_rank + LOOP + v_constraint_name := v_constraintrec.constraint_name; + v_constraint_def := v_constraintrec.constraint_definition; + IF v_constraintrec.type_rank = 1 THEN + IF pkcnt = 0 OR pktype = 'PKEY_INTERNAL' THEN + -- internal def + v_constraint_name := v_constraintrec.constraint_name; + v_constraint_def := v_constraintrec.constraint_definition; + v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column + || 'CONSTRAINT' || ' ' + || v_constraint_name || ' ' + || v_constraint_def + || ',' || E'\\n'; + ELSE + -- Issue#16 handle external PG def + SELECT 'ALTER TABLE ONLY ' || in_schema || '.' || c.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_pkey_def + FROM pg_catalog.pg_constraint r, pg_class c, pg_namespace n where r.conrelid = c.oid and r.contype = 'p' and n.oid = r.connamespace and n.nspname = in_schema AND c.relname = in_table and r.conname = v_constraint_name; + END IF; + IF bPartition THEN + continue; + END IF; + ELSIF v_constraintrec.type_rank = 3 THEN + -- handle foreign key constraints + --Issue#22 fix: added FKEY_NONE check + IF fktype = 'FKEYS_NONE' THEN + -- skip + continue; + ELSIF fkcnt = 0 OR fktype = 'FKEYS_INTERNAL' THEN + -- internal def + v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column + || 'CONSTRAINT' || ' ' + || v_constraint_name || ' ' + || v_constraint_def + || ',' || E'\\n'; + ELSE + -- external def + SELECT 'ALTER TABLE ONLY ' || n.nspname || '.' || c2.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_fkey_def + FROM pg_constraint r, pg_class c1, pg_namespace n, pg_class c2 where r.conrelid = c1.oid and r.contype = 'f' and n.nspname = in_schema and n.oid = r.connamespace and r.conrelid = c2.oid and c2.relname = in_table; + v_fkey_defs = v_fkey_defs || v_fkey_def || E'\\n'; + END IF; + ELSE + -- handle all other constraints besides PKEY and FKEYS as internal defs by default + v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column + || 'CONSTRAINT' || ' ' + || v_constraint_name || ' ' + || v_constraint_def + || ',' || E'\\n'; + END IF; + if bVerbose THEN RAISE NOTICE 'DEBUG4: constraint name=% constraint_def=%', v_constraint_name,v_constraint_def; END IF; + constraintarr := constraintarr || v_constraintrec.constraint_name:: text; + + END LOOP; + ELSE + -- handle PG versions 11 and up + -- Issue#20: Fix logic for external PKEY and FKEYS + FOR v_constraintrec IN + SELECT con.conname as constraint_name, con.contype as constraint_type, + CASE + WHEN con.contype = 'p' THEN 1 -- primary key constraint + WHEN con.contype = 'u' THEN 2 -- unique constraint + WHEN con.contype = 'f' THEN 3 -- foreign key constraint + WHEN con.contype = 'c' THEN 4 + ELSE 5 + END as type_rank, + pg_get_constraintdef(con.oid) as constraint_definition + FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid JOIN pg_catalog.pg_namespace nsp ON nsp.oid = connamespace + WHERE nsp.nspname = in_schema AND rel.relname = in_table + --Issue#13 added this condition: + AND con.conparentid = 0 + ORDER BY type_rank + LOOP + v_constraint_name := v_constraintrec.constraint_name; + v_constraint_def := v_constraintrec.constraint_definition; + IF v_constraintrec.type_rank = 1 THEN + IF pkcnt = 0 OR pktype = 'PKEY_INTERNAL' THEN + -- internal def + v_constraint_name := v_constraintrec.constraint_name; + v_constraint_def := v_constraintrec.constraint_definition; + v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column + || 'CONSTRAINT' || ' ' + || v_constraint_name || ' ' + || v_constraint_def + || ',' || E'\\n'; + ELSE + -- Issue#16 handle external PG def + SELECT 'ALTER TABLE ONLY ' || in_schema || '.' || c.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_pkey_def + FROM pg_catalog.pg_constraint r, pg_class c, pg_namespace n where r.conrelid = c.oid and r.contype = 'p' and n.oid = r.connamespace and n.nspname = in_schema AND c.relname = in_table; + END IF; + IF bPartition THEN + continue; + END IF; + ELSIF v_constraintrec.type_rank = 3 THEN + -- handle foreign key constraints + --Issue#22 fix: added FKEY_NONE check + IF fktype = 'FKEYS_NONE' THEN + -- skip + continue; + ELSIF fkcnt = 0 OR fktype = 'FKEYS_INTERNAL' THEN + -- internal def + v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column + || 'CONSTRAINT' || ' ' + || v_constraint_name || ' ' + || v_constraint_def + || ',' || E'\\n'; + ELSE + -- external def + SELECT 'ALTER TABLE ONLY ' || n.nspname || '.' || c2.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_fkey_def + FROM pg_constraint r, pg_class c1, pg_namespace n, pg_class c2 where r.conrelid = c1.oid and r.contype = 'f' and n.nspname = in_schema and n.oid = r.connamespace and r.conrelid = c2.oid and c2.relname = in_table and + r.conname = v_constraint_name and r.conparentid = 0; + v_fkey_defs = v_fkey_defs || v_fkey_def || E'\\n'; + END IF; + ELSE + -- handle all other constraints besides PKEY and FKEYS as internal defs by default + v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column + || 'CONSTRAINT' || ' ' + || v_constraint_name || ' ' + || v_constraint_def + || ',' || E'\\n'; + END IF; + if bVerbose THEN RAISE NOTICE 'DEBUG4: constraint name=% constraint_def=%', v_constraint_name,v_constraint_def; END IF; + constraintarr := constraintarr || v_constraintrec.constraint_name:: text; + + END LOOP; + END IF; + + -- drop the last comma before ending the create statement, which should be right before the carriage return character + -- Issue#24: make sure the comma is there before removing it + select substring(v_table_ddl, length(v_table_ddl) - 1, 1) INTO v_temp; + IF v_temp = ',' THEN + v_table_ddl = substr(v_table_ddl, 0, length(v_table_ddl) - 1) || E'\\n'; + END IF; + IF bVerbose THEN RAISE NOTICE '(3)tabledef so far: %', trim(v_table_ddl); END IF; + + -- --------------------------------------------------------------------------- + -- at this point we have everything up to the last table-enclosing parenthesis + -- --------------------------------------------------------------------------- + IF bVerbose THEN RAISE NOTICE '(4)tabledef so far: %', v_table_ddl; END IF; + + -- See if this is an inheritance-based child table and finish up the table create. + IF bPartition and bInheritance THEN + -- Issue#11: handle parent schema + -- v_table_ddl := v_table_ddl || ') INHERITS (' || in_schema || '.' || v_parent || ') ' || E'\\n' || v_relopts || ' ' || v_tablespace || ';' || E'\\n'; + IF v_parent_schema = '' OR v_parent_schema IS NULL THEN v_parent_schema = in_schema; END IF; + v_table_ddl := v_table_ddl || ') INHERITS (' || v_parent_schema || '.' || v_parent || ') ' || E'\\n' || v_relopts || ' ' || v_tablespace || ';' || E'\\n'; + END IF; + + IF v_pgversion >= 100000 AND NOT bPartition and NOT bInheritance THEN + -- See if this is a partitioned table (pg_class.relkind = 'p') and add the partitioned key + SELECT pg_get_partkeydef(c1.oid) as partition_key INTO v_partition_key FROM pg_class c1 JOIN pg_namespace n ON (n.oid = c1.relnamespace) LEFT JOIN pg_partitioned_table p ON (c1.oid = p.partrelid) + WHERE n.nspname = in_schema and n.oid = c1.relnamespace and c1.relname = in_table and c1.relkind = 'p'; + + IF v_partition_key IS NOT NULL AND v_partition_key <> '' THEN + -- add partition clause + -- NOTE: cannot specify default tablespace for partitioned relations + -- v_table_ddl := v_table_ddl || ') PARTITION BY ' || v_partition_key || ' ' || v_tablespace || ';' || E'\\n'; + v_table_ddl := v_table_ddl || ') PARTITION BY ' || v_partition_key || ';' || E'\\n'; + ELSEIF v_relopts <> '' THEN + v_table_ddl := v_table_ddl || ') ' || v_relopts || ' ' || v_tablespace || ';' || E'\\n'; + ELSE + -- end the create definition + v_table_ddl := v_table_ddl || ') ' || v_tablespace || ';' || E'\\n'; + END IF; + END IF; + + IF bVerbose THEN RAISE NOTICE '(5)tabledef so far: %', v_table_ddl; END IF; + + -- Add closing paren for regular tables + -- IF NOT bPartition THEN + -- v_table_ddl := v_table_ddl || ') ' || v_relopts || ' ' || v_tablespace || E';\\n'; + -- END IF; + -- RAISE NOTICE 'ddlsofar3: %', v_table_ddl; + + -- Issue#16 create the external PKEY def if indicated + IF v_pkey_def <> '' THEN + v_table_ddl := v_table_ddl || v_pkey_def || E'\\n'; + END IF; + + -- Issue#20 + IF v_fkey_defs <> '' THEN + v_table_ddl := v_table_ddl || v_fkey_defs || E'\\n'; + END IF; + + IF bVerbose THEN RAISE NOTICE '(6)tabledef so far: %', v_table_ddl; END IF; + + -- create indexes + FOR v_indexrec IN + SELECT indexdef, COALESCE(tablespace, 'pg_default') as tablespace, indexname FROM pg_indexes WHERE (schemaname, tablename) = (in_schema, in_table) + LOOP + -- RAISE NOTICE 'DEBUG6: indexname=% indexdef=%', v_indexrec.indexname, v_indexrec.indexdef; + -- loop through constraints and skip ones already defined + bSkip = False; + FOREACH constraintelement IN ARRAY constraintarr + LOOP + IF constraintelement = v_indexrec.indexname THEN + -- RAISE NOTICE 'DEBUG7: skipping index, %', v_indexrec.indexname; + bSkip = True; + EXIT; + END IF; + END LOOP; + if bSkip THEN CONTINUE; END IF; + + -- Add IF NOT EXISTS clause so partition index additions will not be created if declarative partition in effect and index already created on parent + v_indexrec.indexdef := REPLACE(v_indexrec.indexdef, 'CREATE INDEX', 'CREATE INDEX IF NOT EXISTS'); + -- Fix Issue#26: do it for unique/primary key indexes as well + v_indexrec.indexdef := REPLACE(v_indexrec.indexdef, 'CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX IF NOT EXISTS'); + -- RAISE NOTICE 'DEBUG8: adding index, %', v_indexrec.indexname; + + -- NOTE: cannot specify default tablespace for partitioned relations + IF v_partition_key IS NOT NULL AND v_partition_key <> '' THEN + v_table_ddl := v_table_ddl || v_indexrec.indexdef || ';' || E'\\n'; + ELSE + -- Issue#25: see if partial index or not + select CASE WHEN i.indpred IS NOT NULL THEN True ELSE False END INTO v_partial + FROM pg_index i JOIN pg_class c1 ON (i.indexrelid = c1.oid) JOIN pg_class c2 ON (i.indrelid = c2.oid) + WHERE c1.relnamespace::regnamespace::text = in_schema AND c2.relnamespace::regnamespace::text = in_schema AND c2.relname = in_table AND c1.relname = v_indexrec.indexname; + IF v_partial THEN + -- Put tablespace def before WHERE CLAUSE + v_temp = v_indexrec.indexdef; + v_pos = POSITION(' WHERE ' IN v_temp); + v_temp2 = SUBSTRING(v_temp, v_pos); + v_temp = SUBSTRING(v_temp, 1, v_pos); + v_table_ddl := v_table_ddl || v_temp || ' TABLESPACE ' || v_indexrec.tablespace || v_temp2 || ';' || E'\\n'; + ELSE + v_table_ddl := v_table_ddl || v_indexrec.indexdef || ' TABLESPACE ' || v_indexrec.tablespace || ';' || E'\\n'; + END IF; + END IF; + + END LOOP; + IF bVerbose THEN RAISE NOTICE '(7)tabledef so far: %', v_table_ddl; END IF; + + -- Issue#20: added logic for table and column comments + IF cmtcnt > 0 THEN + FOR v_rec IN + SELECT c.relname, 'COMMENT ON ' || CASE WHEN c.relkind in ('r','p') AND a.attname IS NULL THEN 'TABLE ' WHEN c.relkind in ('r','p') AND a.attname IS NOT NULL THEN 'COLUMN ' WHEN c.relkind = 'f' THEN 'FOREIGN TABLE ' + WHEN c.relkind = 'm' THEN 'MATERIALIZED VIEW ' WHEN c.relkind = 'v' THEN 'VIEW ' WHEN c.relkind = 'i' THEN 'INDEX ' WHEN c.relkind = 'S' THEN 'SEQUENCE ' ELSE 'XX' END || n.nspname || '.' || + CASE WHEN c.relkind in ('r','p') AND a.attname IS NOT NULL THEN quote_ident(c.relname) || '.' || a.attname ELSE quote_ident(c.relname) END || ' IS ' || quote_literal(d.description) || ';' as ddl + FROM pg_class c JOIN pg_namespace n ON (n.oid = c.relnamespace) LEFT JOIN pg_description d ON (c.oid = d.objoid) LEFT JOIN pg_attribute a ON (c.oid = a.attrelid AND a.attnum > 0 and a.attnum = d.objsubid) + WHERE d.description IS NOT NULL AND n.nspname = in_schema AND c.relname = in_table ORDER BY 2 desc, ddl + LOOP + --RAISE NOTICE 'comments:%', v_rec.ddl; + v_table_ddl = v_table_ddl || v_rec.ddl || E'\\n'; + END LOOP; + END IF; + IF bVerbose THEN RAISE NOTICE '(8)tabledef so far: %', v_table_ddl; END IF; + + IF trigtype = 'INCLUDE_TRIGGERS' THEN + -- Issue#14: handle multiple triggers for a table + FOR v_trigrec IN + select pg_get_triggerdef(t.oid, True) || ';' as triggerdef FROM pg_trigger t, pg_class c, pg_namespace n + WHERE n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table and c.relkind = 'r' and t.tgrelid = c.oid and NOT t.tgisinternal + LOOP + v_table_ddl := v_table_ddl || v_trigrec.triggerdef; + v_table_ddl := v_table_ddl || E'\\n'; + IF bVerbose THEN RAISE NOTICE 'triggerdef = %', v_trigrec.triggerdef; END IF; + END LOOP; + END IF; + + IF bVerbose THEN RAISE NOTICE '(9)tabledef so far: %', v_table_ddl; END IF; + -- add empty line + v_table_ddl := v_table_ddl || E'\\n'; + IF bVerbose THEN RAISE NOTICE '(10)tabledef so far: %', v_table_ddl; END IF; + + -- reset search_path back to what it was + IF search_path_old = '' THEN + SELECT set_config('search_path', '', false) into v_temp; + ELSE + EXECUTE 'SET search_path = ' || search_path_old; + END IF; + + RETURN v_table_ddl; + + EXCEPTION + WHEN others THEN + BEGIN + GET STACKED DIAGNOSTICS v_diag1 = MESSAGE_TEXT, v_diag2 = PG_EXCEPTION_DETAIL, v_diag3 = PG_EXCEPTION_HINT, v_diag4 = RETURNED_SQLSTATE, v_diag5 = PG_CONTEXT, v_diag6 = PG_EXCEPTION_CONTEXT; + -- v_ret := 'line=' || v_diag6 || '. '|| v_diag4 || '. ' || v_diag1 || ' .' || v_diag2 || ' .' || v_diag3; + v_ret := 'line=' || v_diag6 || '. '|| v_diag4 || '. ' || v_diag1; + RAISE EXCEPTION '%', v_ret; + -- put additional coding here if necessarY + RETURN ''; + END; + + END; + $$;`.trim() + +export const getTableDefinitionSql = ({ id }: { id: number }) => { + const sql = /* SQL */ ` + ${CREATE_PG_GET_TABLEDEF_SQL} + + with table_info as ( + select + n.nspname::text as schema, + c.relname::text as name + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where c.oid = ${id} + ) + select pg_temp.pg_get_tabledef ( + t.schema, + t.name, + false, + 'FKEYS_INTERNAL', + 'INCLUDE_TRIGGERS' + ) as definition + from table_info t; + `.trim() + + return sql +} + +export const getEntityDefinitionsSql = ({ + schemas, + limit = 100, +}: { + schemas: string[] + limit?: number +}) => { + const sql = /* SQL */ ` +${CREATE_PG_GET_TABLEDEF_SQL} + +with records as ( + select + c.oid::int8 as "id", + case c.relkind + when 'r' then pg_temp.pg_get_tabledef( + concat(nc.nspname), + concat(c.relname), + false, + 'FKEYS_INTERNAL', + 'NO_TRIGGERS' + ) + when 'v' then concat( + 'create view ', concat(nc.nspname, '.', c.relname), ' as', + pg_get_viewdef(concat(nc.nspname, '.', c.relname), true) + ) + when 'm' then concat( + 'create materialized view ', concat(nc.nspname, '.', c.relname), ' as', + pg_get_viewdef(concat(nc.nspname, '.', c.relname), true) + ) + when 'f' then concat('create foreign table ', nc.nspname, '.', c.relname, ' ( ... )') + when 'p' then pg_temp.pg_get_tabledef( + concat(nc.nspname), + concat(c.relname), + false, + 'FKEYS_INTERNAL', + 'NO_TRIGGERS' + ) + end as "sql" + from + pg_namespace nc + join pg_class c on nc.oid = c.relnamespace + where + c.relkind in ('r', 'v', 'm', 'f', 'p') + and not pg_is_other_temp_schema(nc.oid) + and ( + pg_has_role(c.relowner, 'USAGE') + or has_table_privilege( + c.oid, + 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER' + ) + or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES') + ) + and nc.nspname IN (${schemas.map((schema) => `'${schema}'`).join(', ')}) + order by c.relname asc + limit ${limit} + offset 0 +) +select + jsonb_build_object( + 'definitions', coalesce(jsonb_agg( + jsonb_build_object( + 'id', r.id, + 'sql', r.sql + ) + ), '[]'::jsonb) + ) "data" +from records r; + `.trim() + + return sql +} diff --git a/packages/pg-meta/src/sql/studio/database/triggers.ts b/packages/pg-meta/src/sql/studio/database/triggers.ts new file mode 100644 index 00000000000..7fba812d058 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/database/triggers.ts @@ -0,0 +1,38 @@ +import { ident } from '../../../pg-format' +import { PGTrigger, PGTriggerCreate } from '../../../pg-meta-triggers' + +// [Joshen] Writing this query within FE as the PATCH endpoint from pg-meta only supports updating +// trigger name and enabled mode. So we'll delete and create the trigger, within a single transaction +// Copying the SQL from https://github.com/supabase/postgres-meta/blob/master/src/lib/PostgresMetaTriggers.ts +export function getDatabaseTriggerUpdateSQL({ + originalTrigger, + updatedTrigger, +}: { + originalTrigger: { + id: number + table_id: number + enabled_mode: 'ORIGIN' | 'REPLICA' | 'ALWAYS' | 'DISABLED' + name: string + table: string + schema: string + condition: string | null + orientation: 'ROW' | 'STATEMENT' + activation: 'BEFORE' | 'AFTER' | 'INSTEAD OF' + events: string[] + function_schema: string + function_name: string + function_args: string[] + } + updatedTrigger: PGTriggerCreate & Pick +}) { + const { name, activation, events, schema, table, function_schema, function_name, function_args } = + updatedTrigger + return /* SQL */ ` +BEGIN; +DROP TRIGGER "${originalTrigger.name}" ON "${originalTrigger.schema}"."${originalTrigger.table}"; +CREATE TRIGGER "${name}" ${activation} ${events.join(' OR ')} ON "${schema}"."${table}" + FOR EACH ROW EXECUTE FUNCTION + "${function_schema}"."${function_name}"(${function_args?.map(ident).join(',') ?? ''}); +COMMIT; +`.trim() +} diff --git a/packages/pg-meta/src/sql/studio/database/views.ts b/packages/pg-meta/src/sql/studio/database/views.ts new file mode 100644 index 00000000000..0594e73fd04 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/database/views.ts @@ -0,0 +1,21 @@ +export const getViewDefinitionSql = ({ id }: { id: number }) => { + if (!id) { + throw new Error('id is required') + } + + const sql = /* SQL */ ` + with table_info as ( + select + n.nspname::text as schema, + c.relname::text as name, + to_regclass(concat('"', n.nspname, '"."', c.relname, '"')) as regclass + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where c.oid = ${id} + ) + select pg_get_viewdef(t.regclass, true) as definition + from table_info t + `.trim() + + return sql +} diff --git a/packages/pg-meta/src/sql/studio/get-live-tuple-estimate.ts b/packages/pg-meta/src/sql/studio/get-live-tuple-estimate.ts deleted file mode 100644 index cf8dcb40638..00000000000 --- a/packages/pg-meta/src/sql/studio/get-live-tuple-estimate.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { literal } from '../../pg-format' - -export const getLiveTupleEstimate = (table: string, schema: string = 'public') => { - const sql = /* SQL */ ` -SELECT n_live_tup AS live_tuple_estimate -FROM pg_stat_user_tables -WHERE schemaname = ${literal(schema)} -AND relname = ${literal(table)}; -`.trim() - - return sql -} diff --git a/packages/pg-meta/src/sql/studio/sql-editor/abort-query.ts b/packages/pg-meta/src/sql/studio/sql-editor/abort-query.ts new file mode 100644 index 00000000000..9909e25092d --- /dev/null +++ b/packages/pg-meta/src/sql/studio/sql-editor/abort-query.ts @@ -0,0 +1,3 @@ +export const getAbortQuerySQL = ({ pid }: { pid: number }) => { + return `select pg_terminate_backend(${pid})`.trim() +} diff --git a/packages/pg-meta/src/sql/studio/sql-editor/index.ts b/packages/pg-meta/src/sql/studio/sql-editor/index.ts new file mode 100644 index 00000000000..7f744430299 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/sql-editor/index.ts @@ -0,0 +1,3 @@ +export * from './abort-query' +export * from './keywords' +export * from './ongoing-queries' diff --git a/packages/pg-meta/src/sql/studio/sql-editor/keywords.ts b/packages/pg-meta/src/sql/studio/sql-editor/keywords.ts new file mode 100644 index 00000000000..56297ea1a0e --- /dev/null +++ b/packages/pg-meta/src/sql/studio/sql-editor/keywords.ts @@ -0,0 +1,7 @@ +export const getKeywordsSql = () => { + const sql = /* SQL */ ` +SELECT word FROM pg_get_keywords(); +`.trim() + + return sql +} diff --git a/packages/pg-meta/src/sql/studio/sql-editor/ongoing-queries.ts b/packages/pg-meta/src/sql/studio/sql-editor/ongoing-queries.ts new file mode 100644 index 00000000000..7fc0e22fc92 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/sql-editor/ongoing-queries.ts @@ -0,0 +1,7 @@ +export const getOngoingQueriesSql = () => { + const sql = /* SQL */ ` +select pid, query, query_start from pg_stat_activity where state = 'active' and datname = 'postgres'; +`.trim() + + return sql +} diff --git a/packages/pg-meta/src/sql/studio/table-editor/constraints.ts b/packages/pg-meta/src/sql/studio/table-editor/constraints.ts index 82354ee2474..98d2c8f6ef9 100644 --- a/packages/pg-meta/src/sql/studio/table-editor/constraints.ts +++ b/packages/pg-meta/src/sql/studio/table-editor/constraints.ts @@ -1,5 +1,33 @@ import { ident } from '../../../pg-format' +export const getTableConstraintsSql = ({ id }: { id: number }) => { + const sql = /* SQL */ ` + with table_info as ( + select + n.nspname::text as schema, + c.relname::text as name, + to_regclass(concat('"', n.nspname, '"."', c.relname, '"')) as regclass + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where c.oid = ${id} +) +select + con.oid as id, + con.conname as name, + con.contype as type +from pg_catalog.pg_constraint con +inner join pg_catalog.pg_class rel + on rel.oid = con.conrelid +inner join pg_catalog.pg_namespace nsp + on nsp.oid = connamespace +inner join table_info ti + on ti.schema = nsp.nspname + and ti.name = rel.relname; +`.trim() + + return sql +} + export const getDropConstraintSQL = ({ schema, table, diff --git a/packages/pg-meta/src/sql/studio/table-editor/entities.ts b/packages/pg-meta/src/sql/studio/table-editor/entities.ts new file mode 100644 index 00000000000..7b3dd619fd4 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/table-editor/entities.ts @@ -0,0 +1,75 @@ +export const getEntityTypesSQL = ({ + schemas, + search, + sort, + filterTypes, + limit, + page, +}: { + schemas: string[] + search?: string + sort: 'alphabetical' | 'grouped-alphabetical' + filterTypes: string[] + limit: number + page: number +}) => { + const innerOrderBy = sort === 'alphabetical' ? `c.relname asc` : `"type_sort" asc, c.relname asc` + const outerOrderBy = sort === 'alphabetical' ? `r.name asc` : `r.type_sort asc, r.name asc` + + const sql = /* SQL */ ` + with records as ( + select + c.oid::int8 as "id", + nc.nspname as "schema", + c.relname as "name", + c.relkind as "type", + case c.relkind + when 'r' then 1 + when 'v' then 2 + when 'm' then 3 + when 'f' then 4 + when 'p' then 5 + end as "type_sort", + obj_description(c.oid) as "comment", + count(*) over() as "count", + c.relrowsecurity as "rls_enabled" + from + pg_namespace nc + join pg_class c on nc.oid = c.relnamespace + where + c.relkind in (${filterTypes.map((x) => `'${x}'`).join(', ')}) + and not pg_is_other_temp_schema(nc.oid) + and ( + pg_has_role(c.relowner, 'USAGE') + or has_table_privilege( + c.oid, + 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER' + ) + or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES') + ) + and nc.nspname in (${schemas.map((x) => `'${x}'`)}) + ${search ? `and c.relname ilike '%${search}%'` : ''} + order by ${innerOrderBy} + limit ${limit} + offset ${page * limit} + ) + select + jsonb_build_object( + 'entities', coalesce(jsonb_agg( + jsonb_build_object( + 'id', r.id, + 'schema', r.schema, + 'name', r.name, + 'type', r.type, + 'comment', r.comment, + 'rls_enabled', r.rls_enabled + ) + order by ${outerOrderBy} + ), '[]'::jsonb), + 'count', coalesce(min(r.count), 0) + ) "data" + from records r; + `.trim() + + return sql +} diff --git a/packages/pg-meta/src/sql/studio/table-editor/foreign-keys.ts b/packages/pg-meta/src/sql/studio/table-editor/foreign-keys.ts index 617d0cde241..89162d11bf0 100644 --- a/packages/pg-meta/src/sql/studio/table-editor/foreign-keys.ts +++ b/packages/pg-meta/src/sql/studio/table-editor/foreign-keys.ts @@ -64,6 +64,61 @@ DROP CONSTRAINT IF EXISTS ${ident(relation.name)} ) } +export const getForeignKeyConstraintsSql = ({ schema }: { schema: string }) => { + if (!schema) throw new Error('schema is required') + + const sql = /* SQL */ ` +SELECT + con.oid as id, + con.conname as constraint_name, + con.confdeltype as deletion_action, + con.confupdtype as update_action, + rel.oid as source_id, + nsp.nspname as source_schema, + rel.relname as source_table, + ( + SELECT + array_agg( + att.attname + ORDER BY + un.ord + ) + FROM + unnest(con.conkey) WITH ORDINALITY un (attnum, ord) + INNER JOIN pg_attribute att ON att.attnum = un.attnum + WHERE + att.attrelid = rel.oid + ) source_columns, + frel.oid as target_id, + fnsp.nspname as target_schema, + frel.relname as target_table, + ( + SELECT + array_agg( + att.attname + ORDER BY + un.ord + ) + FROM + unnest(con.confkey) WITH ORDINALITY un (attnum, ord) + INNER JOIN pg_attribute att ON att.attnum = un.attnum + WHERE + att.attrelid = frel.oid + ) target_columns +FROM + pg_constraint con + INNER JOIN pg_class rel ON rel.oid = con.conrelid + INNER JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + INNER JOIN pg_class frel ON frel.oid = con.confrelid + INNER JOIN pg_namespace fnsp ON fnsp.oid = frel.relnamespace +WHERE + con.contype = 'f' + AND nsp.nspname = '${schema}' +`.trim() + + return sql +} + export interface ForeignKey { id?: number | string name?: string diff --git a/packages/pg-meta/src/sql/studio/table-editor/index.ts b/packages/pg-meta/src/sql/studio/table-editor/index.ts index 25e6f5d6c98..3fb5ea4a313 100644 --- a/packages/pg-meta/src/sql/studio/table-editor/index.ts +++ b/packages/pg-meta/src/sql/studio/table-editor/index.ts @@ -4,3 +4,4 @@ export * from './foreign-keys' export * from './primary-keys' export * from './rls' export * from './table' +export * from './entities' From 8cc77fc91dddf4adc4933eaed4e7abca3b3a63be Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Thu, 19 Mar 2026 12:14:08 +0100 Subject: [PATCH 03/56] fix(Docs): Broken graphql page and missing navigation (#43965) --- apps/docs/app/guides/graphql/[[...slug]]/page.tsx | 2 +- .../Navigation/NavigationMenu/NavigationMenu.constants.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx index e4bd3258f2c..57ca0b26eae 100644 --- a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx @@ -64,7 +64,7 @@ const pageMap = [ title: 'Computed Fields', subtitle: 'Using Postgres Computed Fields with GraphQL.', }, - remoteFile: 'computed-fields.md', + remoteFile: 'computed_fields.md', }, { slug: 'configuration', diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 32747e053dd..6b5a82aa503 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1547,6 +1547,11 @@ export const graphql: NavMenuConstant = { { name: 'API', url: '/guides/graphql/api', items: [] }, { name: 'Views', url: '/guides/graphql/views', items: [] }, { name: 'Functions', url: '/guides/graphql/functions', items: [] }, + { + name: 'Computed Fields', + url: '/guides/graphql/computed-fields', + items: [], + }, { name: 'Configuration & Customization', url: '/guides/graphql/configuration', From d29fbf6eb78deb642c7db9a48df10c6115b54b32 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:07:33 -0400 Subject: [PATCH 04/56] =?UTF-8?q?feat(assistant):=20upgrade=20AI=20SDK=20v?= =?UTF-8?q?5=20=E2=86=92=20v6=20(#43931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrades `ai` from v5 to v6 and all related packages. **Package bumps:** - `ai`: `5.0.52` → `^6.0.116` - `@ai-sdk/openai`: `2.0.32` → `^3.0.41` - `@ai-sdk/react`: `2.0.52` → `^3.0.118` - `@ai-sdk/provider`: `^2.0.0` → `^3.0.8` - `@ai-sdk/provider-utils`: `^3.0.0` → `^4.0.19` - `@ai-sdk/amazon-bedrock`: `^3.0.0` → `^4.0.81` - `@ai-sdk/mcp`: N/A → `^1.0.25` - `openai`: bumped to `^4.104.0` - `braintrust`: `3.0.x` → `^3.4.0` **Breaking change migrations:** - `generateObject` removed in v6 — migrated 5 API routes to `generateText` with `Output.object({ schema })`, returning `result.output` - `convertToModelMessages` is now async — added `await` - MCP import path changed: `experimental_createMCPClient` from `ai` → `createMCPClient` from `@ai-sdk/mcp` - `openai()` defaults to Responses API — added `store: false` to provider options for ZDR org compatibility **Streaming fix:** Added `Content-Encoding: none` header to `pipeUIMessageStreamToResponse` calls. Without it, proxy middleware buffers the entire SSE response before flushing, causing the full reply to appear at once. **Zero Data Retention fix:** In recent AI SDK versions, `openai()` default to Responses API instead of the legacy chat completions API. This produces a 404 from OpenAI with message `"Items are not persisted for Zero Data Retention organizations. Remove this item from your input and try again."` The Responses API is OpenAI's [recommended endpoint](https://developers.openai.com/api/docs/guides/migrate-to-responses). This PR adds `store: false` as mentioned in https://github.com/vercel/ai/issues/10060 to avoid incompatible persistence attempts. **References:** - https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0 - https://ai-sdk.dev/docs/troubleshooting/streaming-not-working-when-proxied - https://github.com/vercel/ai/issues/10060 Closes AI-514 Related AI-509 --- .../lib/ai/generate-assistant-response.ts | 2 +- apps/studio/lib/ai/model.utils.ts | 1 + apps/studio/lib/ai/supabase-mcp.ts | 2 +- apps/studio/lib/api/rate.test.ts | 5 +- apps/studio/package.json | 17 +- apps/studio/pages/api/ai/feedback/classify.ts | 12 +- apps/studio/pages/api/ai/feedback/rate.ts | 10 +- apps/studio/pages/api/ai/onboarding/design.ts | 2 +- apps/studio/pages/api/ai/sql/cron-v2.ts | 8 +- apps/studio/pages/api/ai/sql/filter-v1.ts | 8 +- apps/studio/pages/api/ai/sql/generate-v4.ts | 1 + apps/studio/pages/api/ai/sql/title-v2.ts | 8 +- pnpm-lock.yaml | 221 ++++++++++++------ 13 files changed, 193 insertions(+), 104 deletions(-) diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index d56256eadbe..0a740b37acc 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -137,7 +137,7 @@ export async function generateAssistantResponse({ }, ] : []), - ...convertToModelMessages(messages), + ...(await convertToModelMessages(messages)), ] const streamTextFn = shouldTrace ? tracedStreamText : ai.streamText diff --git a/apps/studio/lib/ai/model.utils.ts b/apps/studio/lib/ai/model.utils.ts index 4fdf7736377..54fc126d775 100644 --- a/apps/studio/lib/ai/model.utils.ts +++ b/apps/studio/lib/ai/model.utils.ts @@ -55,6 +55,7 @@ export const PROVIDERS: ProviderRegistry = { providerOptions: { openai: { reasoningEffort: 'minimal', + store: false, }, }, }, diff --git a/apps/studio/lib/ai/supabase-mcp.ts b/apps/studio/lib/ai/supabase-mcp.ts index 6ab34dc27db..4c22a24681d 100644 --- a/apps/studio/lib/ai/supabase-mcp.ts +++ b/apps/studio/lib/ai/supabase-mcp.ts @@ -1,7 +1,7 @@ import { createSupabaseMcpServer } from '@supabase/mcp-server-supabase' import { createSupabaseApiPlatform } from '@supabase/mcp-server-supabase/platform/api' import { StreamTransport } from '@supabase/mcp-utils' -import { experimental_createMCPClient as createMCPClient } from 'ai' +import { createMCPClient } from '@ai-sdk/mcp' import { API_URL } from 'lib/constants' diff --git a/apps/studio/lib/api/rate.test.ts b/apps/studio/lib/api/rate.test.ts index 499cfd5d0c3..e9d758aad92 100644 --- a/apps/studio/lib/api/rate.test.ts +++ b/apps/studio/lib/api/rate.test.ts @@ -57,11 +57,12 @@ test('rate calls the tool sanitizer', async () => { })) vi.mock('ai', () => ({ - generateObject: vi.fn().mockResolvedValue({ - object: { + generateText: vi.fn().mockResolvedValue({ + output: { category: 'sql_generation', }, }), + Output: { object: vi.fn() }, })) vi.mock('components/ui/AIAssistantPanel/Message.utils', () => ({ diff --git a/apps/studio/package.json b/apps/studio/package.json index 36d3624cf1f..b070359dfee 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -28,11 +28,12 @@ "scorers:deploy": "IS_BRAINTRUST_PUSH=true braintrust push evals/scorer-online.ts" }, "dependencies": { - "@ai-sdk/amazon-bedrock": "^3.0.0", - "@ai-sdk/openai": "2.0.32", - "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/provider-utils": "^3.0.0", - "@ai-sdk/react": "2.0.52", + "@ai-sdk/amazon-bedrock": "^4.0.81", + "@ai-sdk/mcp": "^1.0.25", + "@ai-sdk/openai": "^3.0.41", + "@ai-sdk/provider": "^3.0.8", + "@ai-sdk/provider-utils": "^4.0.19", + "@ai-sdk/react": "^3.0.118", "@aws-sdk/credential-providers": "^3.830.0", "@dagrejs/dagre": "^1.0.4", "@dnd-kit/core": "^6.1.0", @@ -74,7 +75,7 @@ "@vercel/functions": "^2.1.0", "@vitejs/plugin-react": "^4.3.4", "@zip.js/zip.js": "^2.7.29", - "ai": "5.0.52", + "ai": "^6.0.116", "ai-commands": "workspace:*", "awesome-debounce-promise": "^2.1.0", "common": "workspace:*", @@ -106,7 +107,7 @@ "next": "~16.1.7", "next-themes": "^0.3.0", "nuqs": "2.7.1", - "openai": "^4.75.1", + "openai": "^4.104.0", "openapi-fetch": "0.12.4", "papaparse": "^5.3.1", "path-to-regexp": "^8.0.0", @@ -185,7 +186,7 @@ "@vitest/ui": "^3.2.0", "api-types": "workspace:*", "autoevals": "^0.0.132", - "braintrust": "^3.0.0", + "braintrust": "^3.4.0", "common": "workspace:*", "config": "workspace:*", "date-fns": "^2.30.0", diff --git a/apps/studio/pages/api/ai/feedback/classify.ts b/apps/studio/pages/api/ai/feedback/classify.ts index bc673a9aa25..d4427b12d7d 100644 --- a/apps/studio/pages/api/ai/feedback/classify.ts +++ b/apps/studio/pages/api/ai/feedback/classify.ts @@ -1,4 +1,4 @@ -import { generateObject } from 'ai' +import { generateText, Output } from 'ai' import { getModel } from 'lib/ai/model' import apiWrapper from 'lib/api/apiWrapper' import { NextApiRequest, NextApiResponse } from 'next' @@ -41,11 +41,13 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.status(500).json({ error: modelError.message }) } - const { object } = await generateObject({ + const { output } = await generateText({ model, providerOptions, - schema: z.object({ - feedback_category: z.enum(['support', 'feedback', 'unknown']), + output: Output.object({ + schema: z.object({ + feedback_category: z.enum(['support', 'feedback', 'unknown']), + }), }), temperature: 0, prompt: ` @@ -97,7 +99,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { `, }) - return res.json({ feedback_category: object.feedback_category }) + return res.json({ feedback_category: output.feedback_category }) } catch (error) { if (error instanceof Error) { console.error(`Classifying this feedback failed`) diff --git a/apps/studio/pages/api/ai/feedback/rate.ts b/apps/studio/pages/api/ai/feedback/rate.ts index 641f11d469e..18a80e1476a 100644 --- a/apps/studio/pages/api/ai/feedback/rate.ts +++ b/apps/studio/pages/api/ai/feedback/rate.ts @@ -1,4 +1,4 @@ -import { generateObject } from 'ai' +import { generateText, Output } from 'ai' import { currentLogger } from 'braintrust' import { IS_PLATFORM } from 'common' import { rateMessageResponseSchema } from 'components/ui/AIAssistantPanel/Message.utils' @@ -110,10 +110,10 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.status(500).json({ error: modelError.message }) } - const { object } = await generateObject({ + const { output } = await generateText({ model, providerOptions, - schema: rateMessageResponseSchema, + output: Output.object({ schema: rateMessageResponseSchema }), prompt: ` Your job is to look at a Supabase Assistant conversation, which the user has given feedback on, and classify it. @@ -148,7 +148,7 @@ Instructions: }) logger?.updateSpan({ id: spanId, - metadata: { feedbackCategory: object.category }, + metadata: { feedbackCategory: output.category }, }) } catch (error) { console.error('Failed to log feedback to Braintrust:', error) @@ -156,7 +156,7 @@ Instructions: } return res.json({ - category: object.category, + category: output.category, }) } catch (error) { if (error instanceof Error) { diff --git a/apps/studio/pages/api/ai/onboarding/design.ts b/apps/studio/pages/api/ai/onboarding/design.ts index 9e12fb09939..465e9aae53c 100644 --- a/apps/studio/pages/api/ai/onboarding/design.ts +++ b/apps/studio/pages/api/ai/onboarding/design.ts @@ -102,5 +102,5 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { tools: getTools(), }) - result.pipeUIMessageStreamToResponse(res) + result.pipeUIMessageStreamToResponse(res, { headers: { 'Content-Encoding': 'none' } }) } diff --git a/apps/studio/pages/api/ai/sql/cron-v2.ts b/apps/studio/pages/api/ai/sql/cron-v2.ts index d9344c9a43f..14e435d6171 100644 --- a/apps/studio/pages/api/ai/sql/cron-v2.ts +++ b/apps/studio/pages/api/ai/sql/cron-v2.ts @@ -1,4 +1,4 @@ -import { generateObject } from 'ai' +import { generateText, Output } from 'ai' import { source } from 'common-tags' import { getModel } from 'lib/ai/model' import apiWrapper from 'lib/api/apiWrapper' @@ -46,10 +46,10 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.status(500).json({ error: modelError.message }) } - const result = await generateObject({ + const result = await generateText({ model, providerOptions, - schema: cronSchema, + output: Output.object({ schema: cronSchema }), prompt: source` You are a cron syntax expert. Your purpose is to convert natural language time descriptions into valid cron expressions for pg_cron. @@ -88,7 +88,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { `, }) - return res.json(result.object.cron_expression) + return res.json(result.output.cron_expression) } catch (error) { if (error instanceof Error) { console.error(`AI cron generation failed: ${error.message}`) diff --git a/apps/studio/pages/api/ai/sql/filter-v1.ts b/apps/studio/pages/api/ai/sql/filter-v1.ts index 1f296714c40..fd0ecb294b3 100644 --- a/apps/studio/pages/api/ai/sql/filter-v1.ts +++ b/apps/studio/pages/api/ai/sql/filter-v1.ts @@ -1,4 +1,4 @@ -import { generateObject } from 'ai' +import { generateText, Output } from 'ai' import { source } from 'common-tags' import { getModel } from 'lib/ai/model' import apiWrapper from 'lib/api/apiWrapper' @@ -62,10 +62,10 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { options: property.options, })) - const result = await generateObject({ + const result = await generateText({ model, providerOptions, - schema: filterGroupSchema, + output: Output.object({ schema: filterGroupSchema }), prompt: source` You are an expert Postgres filter builder. Convert the user's request into structured filters. @@ -84,7 +84,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { `, }) - const generatedFilters = result.object + const generatedFilters = result.output if (!validateFilterGroup(generatedFilters, normalizedFilterProperties)) { return res.status(400).json({ diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 27b5e08f592..c8a349421e8 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -206,6 +206,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw result.pipeUIMessageStreamToResponse(res, { sendReasoning: true, + headers: { 'Content-Encoding': 'none' }, onError: (error) => { if (error == null) { return 'unknown error' diff --git a/apps/studio/pages/api/ai/sql/title-v2.ts b/apps/studio/pages/api/ai/sql/title-v2.ts index 3c3a3cd6848..f415eeb1da7 100644 --- a/apps/studio/pages/api/ai/sql/title-v2.ts +++ b/apps/studio/pages/api/ai/sql/title-v2.ts @@ -1,4 +1,4 @@ -import { generateObject } from 'ai' +import { generateText, Output } from 'ai' import { source } from 'common-tags' import { getModel } from 'lib/ai/model' import apiWrapper from 'lib/api/apiWrapper' @@ -51,10 +51,10 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.status(500).json({ error: modelError.message }) } - const result = await generateObject({ + const result = await generateText({ model, providerOptions, - schema: titleSchema, + output: Output.object({ schema: titleSchema }), prompt: source` Generate a short title and summarized description for this Postgres SQL snippet: @@ -64,7 +64,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { `, }) - return res.json(result.object) + return res.json(result.output) } catch (error) { if (error instanceof Error) { console.error(`AI title generation failed: ${error.message}`) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc3f6b1da7b..11428831975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -757,20 +757,23 @@ importers: apps/studio: dependencies: '@ai-sdk/amazon-bedrock': - specifier: ^3.0.0 - version: 3.0.0(zod@3.25.76) + specifier: ^4.0.81 + version: 4.0.81(zod@3.25.76) + '@ai-sdk/mcp': + specifier: ^1.0.25 + version: 1.0.25(zod@3.25.76) '@ai-sdk/openai': - specifier: 2.0.32 - version: 2.0.32(zod@3.25.76) + specifier: ^3.0.41 + version: 3.0.41(zod@3.25.76) '@ai-sdk/provider': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^3.0.8 + version: 3.0.8 '@ai-sdk/provider-utils': - specifier: ^3.0.0 - version: 3.0.0(zod@3.25.76) + specifier: ^4.0.19 + version: 4.0.19(zod@3.25.76) '@ai-sdk/react': - specifier: 2.0.52 - version: 2.0.52(react@18.3.1)(zod@3.25.76) + specifier: ^3.0.118 + version: 3.0.118(react@18.3.1)(zod@3.25.76) '@aws-sdk/credential-providers': specifier: ^3.830.0 version: 3.830.0 @@ -895,8 +898,8 @@ importers: specifier: ^2.7.29 version: 2.7.30 ai: - specifier: 5.0.52 - version: 5.0.52(zod@3.25.76) + specifier: ^6.0.116 + version: 6.0.116(zod@3.25.76) ai-commands: specifier: workspace:* version: link:../../packages/ai-commands @@ -991,7 +994,7 @@ importers: specifier: 2.7.1 version: 2.7.1(@tanstack/react-router@1.158.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.1.7(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) openai: - specifier: ^4.75.1 + specifier: ^4.104.0 version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76) openapi-fetch: specifier: 0.12.4 @@ -1223,8 +1226,8 @@ importers: specifier: ^0.0.132 version: 0.0.132(ws@8.18.3) braintrust: - specifier: ^3.0.0 - version: 3.0.0(@aws-sdk/credential-provider-web-identity@3.830.0)(supports-color@8.1.1)(zod@3.25.76) + specifier: ^3.4.0 + version: 3.4.0(@aws-sdk/credential-provider-web-identity@3.830.0)(supports-color@8.1.1)(zod@3.25.76) date-fns: specifier: ^2.30.0 version: 2.30.0 @@ -2808,17 +2811,17 @@ packages: '@adobe/css-tools@4.4.0': resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} - '@ai-sdk/amazon-bedrock@3.0.0': - resolution: {integrity: sha512-OxNmyYRf7pY8NK69dAqpoewKtUhY/HUbpa6TDhp9zcgVmQJmhky8Q7HDetdiqopb2Pe3NB/f1nZVI73nin3qng==} + '@ai-sdk/amazon-bedrock@4.0.81': + resolution: {integrity: sha512-SP3LIpFt/JlK0sGs6E5jvjySvQqu/nqA942PehIGk3PZPJ5SMPsUNg+PYTs84PTUYEg8rr4XqkQOTNFq12zo2w==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/anthropic@2.0.0': - resolution: {integrity: sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w==} + '@ai-sdk/anthropic@3.0.62': + resolution: {integrity: sha512-CkShXR8tmNO7QQnvpKbSMe2Vr1zUUcpqlp69iR+DYrbHm+tDJO9u6zZsjEHjcoRU9/e9z++p0W6NiuLC3aZ4Bg==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4 + zod: ^3.25.76 || ^4.1.8 '@ai-sdk/gateway@1.0.29': resolution: {integrity: sha512-o9LtmBiG2WAgs3GAmL79F8idan/UupxHG8Tyr2gP4aUSOzflM0bsvfzozBp8x6WatQnOx+Pio7YNw45Y6I16iw==} @@ -2826,17 +2829,29 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + '@ai-sdk/gateway@3.0.66': + resolution: {integrity: sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/mcp@1.0.25': + resolution: {integrity: sha512-vMlXUPGHGDE2vzLcPR8sw7Dhz2OBjtPU5lB+lIuC1hNQo4REuUC08P0e96/hzBKf4oQYJ8Zo6uP8AG2qThyFbg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@2.0.2': resolution: {integrity: sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4 - '@ai-sdk/openai@2.0.32': - resolution: {integrity: sha512-p7giSkCs66Q1qYO/NPYI41CrSg65mcm8R2uAdF86+Y1D1/q4mUrWMyf5UTOJ0bx/z4jIPiNgGDCg2Kabi5zrKQ==} + '@ai-sdk/openai@3.0.41': + resolution: {integrity: sha512-IZ42A+FO+vuEQCVNqlnAPYQnnUpUfdJIwn1BEDOBywiEHa23fw7PahxVtlX9zm3/zMvTW4JKPzWyvAgDu+SQ2A==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4 + zod: ^3.25.76 || ^4.1.8 '@ai-sdk/provider-utils@3.0.0': resolution: {integrity: sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw==} @@ -2850,6 +2865,18 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + '@ai-sdk/provider-utils@4.0.19': + resolution: {integrity: sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.20': + resolution: {integrity: sha512-gpUIj9uDhIGbuo9afKEgQ074BWmhvK4THJAAeBjRnroTy2yQYo6rbtGD7pQDMZM8ouXPYmT/SCdkWVJ0KcpX8A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@1.1.3': resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} engines: {node: '>=18'} @@ -2858,15 +2885,15 @@ packages: resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} - '@ai-sdk/react@2.0.52': - resolution: {integrity: sha512-4/i40pykN4gTGH264+k1g4tMGdw4xN7vZ1qESFCIm/lhS/8YiJPYheBOk9c349hytOT1sGxp3UNPcOWzWS0H2A==} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + + '@ai-sdk/react@3.0.118': + resolution: {integrity: sha512-fBAix8Jftxse6/2YJnOFkwW1/O6EQK4DK68M9DlFmZGAzBmsaHXEPVS77sVIlkaOWCy11bE7434NAVXRY+3OsQ==} engines: {node: '>=18'} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.25.76 || ^4 - peerDependenciesMeta: - zod: - optional: true + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} @@ -2889,6 +2916,9 @@ packages: '@apm-js-collab/code-transformer@0.8.2': resolution: {integrity: sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==} + '@apm-js-collab/code-transformer@0.9.0': + resolution: {integrity: sha512-cfHtufVUBKJz6se/tQBJqizgoot5AOxhVy5B9Cuo493p6b7hXKBEIi6tcZEbr4XwN1VnV9JZOu52MMTCOCSBMw==} + '@apm-js-collab/tracing-hooks@0.3.1': resolution: {integrity: sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==} @@ -8321,6 +8351,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stitches/core@1.2.8': resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==} @@ -9291,6 +9324,10 @@ packages: resolution: {integrity: sha512-OTe0KE37F5Y2eTys6eMnfopC+P4qr2ooXUTFyFPTplYSPwowmFk/HLD1FXtbKLjqsIH0SgekcJWad+C5uX4nkg==} engines: {node: '>=16'} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@4.3.4': resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} engines: {node: ^14.18.0 || >=16.0.0} @@ -9619,6 +9656,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + ai@6.0.116: + resolution: {integrity: sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -10021,8 +10064,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - braintrust@3.0.0: - resolution: {integrity: sha512-mhpflunGgPhZC8wJxP6GPPcrFNIDgO0E77Gy/hkxSlIUaLpszfxLgQAIESccdbjH+x+PHUDiJiMIgDxNpagVHg==} + braintrust@3.4.0: + resolution: {integrity: sha512-LsyvraUPcW+EAiUd7N5iMePrHk4n0LJ3/S4DiBljj2pLsDSavic987RPStS9L6cJG6qXc6BKCwKaLQf/1JaPuQ==} hasBin: true peerDependencies: zod: ^3.25.34 || ^4.0 @@ -10987,6 +11030,9 @@ packages: sqlite3: optional: true + dc-browser@1.0.3: + resolution: {integrity: sha512-B2PJFzWouEPzc+jLo+yTgUXaVz0eZjVsfKX9CnUDzezsluxOKOjllBpw0EJJ1KNJGnNzKpZ4dDYtd+jVp0KRNA==} + de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -11827,10 +11873,6 @@ packages: resolution: {integrity: sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==} engines: {node: '>=14.18'} - eventsource-parser@3.0.3: - resolution: {integrity: sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==} - engines: {node: '>=20.0.0'} - eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -18173,11 +18215,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 - use-sync-external-store@1.5.0: - resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -18831,20 +18868,20 @@ snapshots: '@adobe/css-tools@4.4.0': {} - '@ai-sdk/amazon-bedrock@3.0.0(zod@3.25.76)': + '@ai-sdk/amazon-bedrock@4.0.81(zod@3.25.76)': dependencies: - '@ai-sdk/anthropic': 2.0.0(zod@3.25.76) - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.0(zod@3.25.76) + '@ai-sdk/anthropic': 3.0.62(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.20(zod@3.25.76) '@smithy/eventstream-codec': 4.0.4 '@smithy/util-utf8': 4.0.0 aws4fetch: 1.0.20 zod: 3.25.76 - '@ai-sdk/anthropic@2.0.0(zod@3.25.76)': + '@ai-sdk/anthropic@3.0.62(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.0(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.20(zod@3.25.76) zod: 3.25.76 '@ai-sdk/gateway@1.0.29(zod@3.25.76)': @@ -18853,30 +18890,58 @@ snapshots: '@ai-sdk/provider-utils': 3.0.9(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/gateway@3.0.66(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.19(zod@3.25.76) + '@vercel/oidc': 3.1.0 + zod: 3.25.76 + + '@ai-sdk/mcp@1.0.25(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.19(zod@3.25.76) + pkce-challenge: 5.0.0 + zod: 3.25.76 + '@ai-sdk/openai@2.0.2(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.0 '@ai-sdk/provider-utils': 3.0.0(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/openai@2.0.32(zod@3.25.76)': + '@ai-sdk/openai@3.0.41(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.9(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.19(zod@3.25.76) zod: 3.25.76 '@ai-sdk/provider-utils@3.0.0(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.0 - '@standard-schema/spec': 1.0.0 - eventsource-parser: 3.0.3 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 zod: 3.25.76 zod-to-json-schema: 3.24.5(zod@3.25.76) '@ai-sdk/provider-utils@3.0.9(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.0 - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + + '@ai-sdk/provider-utils@4.0.19(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + + '@ai-sdk/provider-utils@4.0.20(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 zod: 3.25.76 @@ -18888,15 +18953,19 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@2.0.52(react@18.3.1)(zod@3.25.76)': + '@ai-sdk/provider@3.0.8': dependencies: - '@ai-sdk/provider-utils': 3.0.9(zod@3.25.76) - ai: 5.0.52(zod@3.25.76) + json-schema: 0.4.0 + + '@ai-sdk/react@3.0.118(react@18.3.1)(zod@3.25.76)': + dependencies: + '@ai-sdk/provider-utils': 4.0.19(zod@3.25.76) + ai: 6.0.116(zod@3.25.76) react: 18.3.1 swr: 2.2.5(react@18.3.1) throttleit: 2.1.0 - optionalDependencies: - zod: 3.25.76 + transitivePeerDependencies: + - zod '@alloc/quick-lru@5.2.0': {} @@ -18921,6 +18990,8 @@ snapshots: '@apm-js-collab/code-transformer@0.8.2': {} + '@apm-js-collab/code-transformer@0.9.0': {} + '@apm-js-collab/tracing-hooks@0.3.1(supports-color@8.1.1)': dependencies: '@apm-js-collab/code-transformer': 0.8.2 @@ -25735,6 +25806,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@stitches/core@1.2.8': {} '@stripe/react-stripe-js@3.7.0(@stripe/stripe-js@7.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -26929,6 +27002,8 @@ snapshots: satori: 0.10.9 yoga-wasm-web: 0.3.3 + '@vercel/oidc@3.1.0': {} + '@vitejs/plugin-react@4.3.4(supports-color@8.1.1)(vite@7.1.11(@types/node@22.13.14)(jiti@2.5.1)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: '@babel/core': 7.29.0(supports-color@8.1.1) @@ -27382,6 +27457,14 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.76 + ai@6.0.116(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 3.0.66(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.19(zod@3.25.76) + '@opentelemetry/api': 1.9.0 + zod: 3.25.76 + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -27842,9 +27925,10 @@ snapshots: dependencies: fill-range: 7.1.1 - braintrust@3.0.0(@aws-sdk/credential-provider-web-identity@3.830.0)(supports-color@8.1.1)(zod@3.25.76): + braintrust@3.4.0(@aws-sdk/credential-provider-web-identity@3.830.0)(supports-color@8.1.1)(zod@3.25.76): dependencies: '@ai-sdk/provider': 1.1.3 + '@apm-js-collab/code-transformer': 0.9.0 '@next/env': 14.2.35 '@vercel/functions': 1.6.0(@aws-sdk/credential-provider-web-identity@3.830.0) ajv: 8.18.0 @@ -27854,6 +27938,7 @@ snapshots: cli-progress: 3.12.0 cli-table3: 0.6.5 cors: 2.8.5 + dc-browser: 1.0.3 dotenv: 16.5.0 esbuild: 0.25.2 eventsource-parser: 1.1.2 @@ -27861,14 +27946,16 @@ snapshots: graceful-fs: 4.2.11 http-errors: 2.0.1 minimatch: 9.0.7 + module-details-from-path: 1.0.4 mustache: 4.2.0 pluralize: 8.0.0 simple-git: 3.32.3(supports-color@8.1.1) source-map: 0.7.6 termi-link: 1.1.0 + unplugin: 2.3.10 uuid: 9.0.1 zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' - supports-color @@ -28969,6 +29056,8 @@ snapshots: '@electric-sql/pglite': 0.2.15 drizzle-orm: 0.44.7(@electric-sql/pglite@0.2.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(pg@8.16.3) + dc-browser@1.0.3: {} + de-indent@1.0.2: {} debounce-promise@3.1.2: {} @@ -29801,8 +29890,6 @@ snapshots: eventsource-parser@1.1.2: {} - eventsource-parser@3.0.3: {} - eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -36978,7 +37065,7 @@ snapshots: dependencies: client-only: 0.0.1 react: 18.3.1 - use-sync-external-store: 1.5.0(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) symbol-tree@3.2.4: {} @@ -37764,10 +37851,6 @@ snapshots: dependencies: react: 18.3.1 - use-sync-external-store@1.5.0(react@18.3.1): - dependencies: - react: 18.3.1 - use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 From 32da7388be7ae3b7df8bfd41d9cff22d066b2935 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 19 Mar 2026 21:37:09 +0800 Subject: [PATCH 05/56] Add support for cmd z undo in table editor for queue operations (#43957) ## Context Add support for undo-ing an operation via keyboard shortcut in Table Editor for the queue operations feature preview --- .../footer/operations/SaveQueueActionBar.tsx | 1 + .../grid/hooks/useOperationQueueShortcuts.ts | 25 +++++++++++++++++++ apps/studio/state/table-editor.tsx | 12 ++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx index 4d35fa9ce2e..ee5d6cf1f66 100644 --- a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx +++ b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx @@ -2,6 +2,7 @@ import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueu import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts' import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { AnimatePresence, motion } from 'framer-motion' +import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button } from 'ui' diff --git a/apps/studio/components/grid/hooks/useOperationQueueShortcuts.ts b/apps/studio/components/grid/hooks/useOperationQueueShortcuts.ts index d1503ea6750..1c1f3852df8 100644 --- a/apps/studio/components/grid/hooks/useOperationQueueShortcuts.ts +++ b/apps/studio/components/grid/hooks/useOperationQueueShortcuts.ts @@ -1,5 +1,9 @@ +import { useQueryClient } from '@tanstack/react-query' + import { useOperationQueueActions } from './useOperationQueueActions' import { useIsQueueOperationsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { tableRowKeys } from '@/data/table-rows/keys' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useHotKey } from '@/hooks/ui/useHotKey' import { useTableEditorStateSnapshot } from '@/state/table-editor' @@ -14,6 +18,8 @@ import { useTableEditorStateSnapshot } from '@/state/table-editor' * before the data grid handles the keyboard event. */ export function useOperationQueueShortcuts() { + const queryClient = useQueryClient() + const { data: project } = useSelectedProjectQuery() const isQueueOperationsEnabled = useIsQueueOperationsEnabled() const snap = useTableEditorStateSnapshot() const { handleSave } = useOperationQueueActions() @@ -43,4 +49,23 @@ export function useOperationQueueShortcuts() { '.', { enabled: isEnabled } ) + + useHotKey( + (event) => { + event.preventDefault() + event.stopPropagation() + + const tableIdLatestOperation = snap.operationQueue.operations.at(-1)?.tableId + snap.undoLatestOperation() + + // Invalidate the query to revert the optimistic update + if (project && tableIdLatestOperation) { + queryClient.invalidateQueries({ + queryKey: tableRowKeys.tableRowsAndCount(project.ref, tableIdLatestOperation), + }) + } + }, + 'z', + { enabled: isEnabled } + ) } diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index f8b2df8a3be..1a0453b0eca 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -1,5 +1,5 @@ -import type { PostgresColumn } from '@supabase/postgres-meta' import * as Sentry from '@sentry/nextjs' +import type { PostgresColumn } from '@supabase/postgres-meta' import { useConstant } from 'common' import type { SupaRow } from 'components/grid/types' import { @@ -308,6 +308,16 @@ export const createTableEditorState = () => { } }, + /** + * Undo the latest operation from the queue + */ + undoLatestOperation: () => { + state.operationQueue.operations = state.operationQueue.operations.slice(0, -1) + if (state.operationQueue.operations.length === 0) { + state.operationQueue.status = 'idle' + } + }, + /** * Update the queue status */ From e2a73aa461f02248edfb7087e3ccd61afbb3feb4 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 19 Mar 2026 22:08:38 +0800 Subject: [PATCH 06/56] chore(studio): temporarily disable default secure sql (#43973) To test: - make sure project creation works as expected Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- apps/studio/pages/new/[slug].tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index cefe4ac1267..18ad85561f3 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -46,10 +46,10 @@ import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useDataApiGrantTogglesEnabled } from 'hooks/misc/useDataApiGrantTogglesEnabled' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { usePHFlag } from 'hooks/ui/useFlag' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { withAuth } from 'hooks/misc/withAuth' +import { usePHFlag } from 'hooks/ui/useFlag' import { DOCS_URL, PROJECT_STATUS, PROVIDERS, useDefaultProvider } from 'lib/constants' import { buildStudioPageTitle } from 'lib/page-title' import { useProfile } from 'lib/profile' @@ -339,7 +339,9 @@ const Wizard: NextPageWithLayout = () => { dbSql: [ enableRlsEventTrigger && AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL, - isDataApiGrantTogglesEnabled && buildDefaultPrivilegesSql('revoke'), + // [Alaister]: temporarily disable the default secure sql + // To re-enable, remove the false && + false && isDataApiGrantTogglesEnabled && buildDefaultPrivilegesSql('revoke'), ] .filter(Boolean) .join('\n') || undefined, From 4b5038ef9e47c18304a402c8f7ccd93d9f44bcf1 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 19 Mar 2026 23:05:38 +0800 Subject: [PATCH 07/56] Refactor queue operations to not mutate the query client to simplify implementation (#43964) ## Context If you've got a queued operation on a table, then switch tables, the dashboard stalls for a few seconds before finally rendering the selected table. This was caused by an infinite loop in a useEffect calling `reapplyOptimisticUpdates` due to `dataUpdatedAt` being in the dependency array (which comes from react query) Removing `dataUpdatedAt` would resolve the issue, but then it won't solve the following comment in the code: `// This ensures pending changes remain visible when switching tabs or after data refresh` ## Changes involved Opting to refactor the logic for rendering data in the Grid instead - Avoid manipulating the data directly from the query client - Manual changes to the query client are hard to track when debugging - Ideally the data in the query client are exactly what is coming from the API - Instead just compute the row data if there's operations applied, and render that in the grid ``` const baseRows = data?.rows ?? EMPTY_ARR const rows = formatGridDataWithOperationValues({ operations, rows: baseRows }) ``` - Simplifies operations logic + data in react query remains as the source of truth - This also improves perceived performance, as previously we'd need to invalidate the query client if we're removing any operation. - A lot more apparent with the undo operation introduced [here](https://github.com/supabase/supabase/pull/43957) - Whereas now, we'll just revert back to whatever's in the query client (and still do the invalidation behind the scenes) so things feel faster --- apps/studio/components/grid/SupabaseGrid.tsx | 49 +-- .../components/grid/SupabaseGrid.utils.ts | 11 +- .../grid/components/grid/Grid.utils.tsx | 3 - .../grid/components/header/Header.tsx | 1 - .../grid/components/header/HeaderNew.tsx | 1 - .../grid/components/menu/RowContextMenu.tsx | 2 - .../grid/utils/queueOperationUtils.test.ts | 343 +++++++++++++----- .../grid/utils/queueOperationUtils.ts | 188 ++-------- .../SidePanelEditor/SidePanelEditor.tsx | 6 +- 9 files changed, 305 insertions(+), 299 deletions(-) diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index 04990fd8d49..eae690d9f60 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -1,4 +1,4 @@ -import { keepPreviousData, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' import { isMsSqlForeignTable } from 'data/table-editor/table-editor-types' import { useTableRowsQuery } from 'data/table-rows/table-rows-query' @@ -15,10 +15,7 @@ import { useTableEditorStateSnapshot } from 'state/table-editor' import { QueuedOperation } from 'state/table-editor-operation-queue.types' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' -import { - useIsQueueOperationsEnabled, - useIsTableFilterBarEnabled, -} from '../interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useIsTableFilterBarEnabled } from '../interfaces/App/FeaturePreview/FeaturePreviewContext' import { Shortcuts } from './components/common/Shortcuts' import { Footer } from './components/footer/Footer' import { Grid } from './components/grid/Grid' @@ -29,7 +26,7 @@ import { useTableFilter } from './hooks/useTableFilter' import { useTableSort } from './hooks/useTableSort' import { validateMsSqlSorting } from './MsSqlValidation' import { GridProps } from './types' -import { reapplyOptimisticUpdates } from './utils/queueOperationUtils' +import { formatGridDataWithOperationValues } from './utils/queueOperationUtils' export const SupabaseGrid = ({ customHeader, @@ -43,9 +40,6 @@ export const SupabaseGrid = ({ const { id: _id } = useParams() const tableId = _id ? Number(_id) : undefined - const isQueueOperationsEnabled = useIsQueueOperationsEnabled() - - const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() const tableEditorSnap = useTableEditorStateSnapshot() const snap = useTableEditorTableStateSnapshot() @@ -73,7 +67,6 @@ export const SupabaseGrid = ({ isError, isPending: isLoading, isRefetching, - dataUpdatedAt, } = useTableRowsQuery( { projectRef: project?.ref, @@ -100,39 +93,9 @@ export const SupabaseGrid = ({ if (!mounted) setMounted(true) }, []) - // Re-apply optimistic updates when table data is loaded/refetched - // This ensures pending changes remain visible when switching tabs or after data refresh - // Skip re-applying during save to avoid race condition where refetch completes before queue clears - const isSaving = tableEditorSnap.operationQueue.status === 'saving' - useEffect(() => { - if ( - isSuccess && - project?.ref && - tableId && - isQueueOperationsEnabled && - tableEditorSnap.hasPendingOperations && - !isSaving - ) { - reapplyOptimisticUpdates({ - queryClient, - projectRef: project.ref, - tableId, - operations: tableEditorSnap.operationQueue.operations as readonly QueuedOperation[], - }) - } - }, [ - isSuccess, - dataUpdatedAt, - project?.ref, - tableId, - isQueueOperationsEnabled, - tableEditorSnap.hasPendingOperations, - tableEditorSnap.operationQueue.operations, - queryClient, - isSaving, - ]) - - const rows = data?.rows ?? EMPTY_ARR + const operations = tableEditorSnap.operationQueue.operations as QueuedOperation[] + const baseRows = data?.rows ?? EMPTY_ARR + const rows = formatGridDataWithOperationValues({ operations, rows: baseRows }) const HeaderComponent = newFilterBarEnabled ? HeaderNew : Header diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 65765ff8072..37d5fe74082 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -1,18 +1,19 @@ import AwesomeDebouncePromise from 'awesome-debounce-promise' -import { compact } from 'lodash' -import { useEffect, useMemo } from 'react' -import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid' - -import type { Filter, SavedState } from 'components/grid/types' +import type { Filter, SavedState, SupaRow } from 'components/grid/types' import { Entity, isTableLike } from 'data/table-editor/table-editor-types' import { BASE_PATH } from 'lib/constants' +import { compact } from 'lodash' import { useSearchParams } from 'next/navigation' import { parseAsNativeArrayOf, parseAsString, useQueryStates } from 'nuqs' +import { useEffect, useMemo } from 'react' +import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid' import { copyToClipboard } from 'ui' + import { FilterOperatorOptions } from './components/header/filter/Filter.constants' import { STORAGE_KEY_PREFIX } from './constants' import type { Sort, SupaColumn, SupaTable } from './types' import { formatClipboardValue } from './utils/common' +import { QueuedOperation, QueuedOperationType } from '@/state/table-editor-operation-queue.types' export function formatSortURLParams(tableName: string, sort?: string[]): Sort[] { if (Array.isArray(sort)) { diff --git a/apps/studio/components/grid/components/grid/Grid.utils.tsx b/apps/studio/components/grid/components/grid/Grid.utils.tsx index 5d7affc816e..161bf2bd297 100644 --- a/apps/studio/components/grid/components/grid/Grid.utils.tsx +++ b/apps/studio/components/grid/components/grid/Grid.utils.tsx @@ -131,9 +131,7 @@ export function useOnRowsChange(rows: SupaRow[]) { if (isQueueOperationsEnabled) { queueCellEditWithOptimisticUpdate({ - queryClient, queueOperation: tableEditorSnap.queueOperation, - projectRef: project.ref, tableId: snap.table.id, table: snap.originalTable, row: previousRow, @@ -167,7 +165,6 @@ export function useOnRowsChange(rows: SupaRow[]) { snap.originalTable, snap.table.id, tableEditorSnap, - queryClient, ] ) } diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 95dce9dd577..055ac85976c 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -283,7 +283,6 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { queueRowDeletesWithOptimisticUpdate({ rows, table: snap.originalTable, - queryClient, queueOperation: tableEditorSnap.queueOperation, projectRef: project?.ref, }) diff --git a/apps/studio/components/grid/components/header/HeaderNew.tsx b/apps/studio/components/grid/components/header/HeaderNew.tsx index c675bef87f5..fb12e9fe143 100644 --- a/apps/studio/components/grid/components/header/HeaderNew.tsx +++ b/apps/studio/components/grid/components/header/HeaderNew.tsx @@ -275,7 +275,6 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { queueRowDeletesWithOptimisticUpdate({ rows, table: snap.originalTable, - queryClient, queueOperation: tableEditorSnap.queueOperation, projectRef: project?.ref, }) diff --git a/apps/studio/components/grid/components/menu/RowContextMenu.tsx b/apps/studio/components/grid/components/menu/RowContextMenu.tsx index e136f227410..1873509ca03 100644 --- a/apps/studio/components/grid/components/menu/RowContextMenu.tsx +++ b/apps/studio/components/grid/components/menu/RowContextMenu.tsx @@ -25,7 +25,6 @@ type RowContextMenuProps = { type RowContextMenuItemProps = ItemParams<{ rowIdx: number }, string> export const RowContextMenu = ({ rows }: RowContextMenuProps) => { - const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() const tableEditorSnap = useTableEditorStateSnapshot() const snap = useTableEditorTableStateSnapshot() @@ -46,7 +45,6 @@ export const RowContextMenu = ({ rows }: RowContextMenuProps) => { queueRowDeletesWithOptimisticUpdate({ rows: [row], table: snap.originalTable, - queryClient, queueOperation: tableEditorSnap.queueOperation, projectRef: project?.ref, }) diff --git a/apps/studio/components/grid/utils/queueOperationUtils.test.ts b/apps/studio/components/grid/utils/queueOperationUtils.test.ts index 3905ca2d764..46ec20de044 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.test.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.test.ts @@ -1,10 +1,17 @@ -import { describe, test, expect } from 'vitest' -import { generateTableChangeKey, rowMatchesIdentifiers, applyCellEdit } from './queueOperationUtils' +import { describe, expect, test } from 'vitest' + +import type { SupaRow } from '../types' import { - type NewEditCellContentOperation, + formatGridDataWithOperationValues, + generateTableChangeKey, + rowMatchesIdentifiers, +} from './queueOperationUtils' +import { + QueuedOperationType, type NewAddRowOperation, type NewDeleteRowOperation, - QueuedOperationType, + type NewEditCellContentOperation, + type QueuedOperation, } from '@/state/table-editor-operation-queue.types' describe('generateTableChangeKey', () => { @@ -145,101 +152,257 @@ describe('rowMatchesIdentifiers', () => { }) }) -describe('applyCellEdit', () => { - test('should apply cell edit to matching row', () => { +describe('formatGridDataWithOperationValues', () => { + const makeRow = (idx: number, data: Record = {}): SupaRow => ({ + idx, + ...data, + }) + + const makeEditOp = ( + overrides: Partial = {} + ): QueuedOperation => ({ + id: 'op-1', + tableId: 1, + timestamp: Date.now(), + type: QueuedOperationType.EDIT_CELL_CONTENT, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'old', + newValue: 'new', + table: {} as any, + }, + ...overrides, + }) + + const makeDeleteOp = ( + rowIdentifiers: Record, + originalRow: SupaRow + ): QueuedOperation => ({ + id: 'op-del', + tableId: 1, + timestamp: Date.now(), + type: QueuedOperationType.DELETE_ROW, + payload: { rowIdentifiers, originalRow, table: {} as any }, + }) + + const makeAddOp = (tempId: string, rowData: Record = {}): QueuedOperation => ({ + id: 'op-add', + tableId: 1, + timestamp: Date.now(), + type: QueuedOperationType.ADD_ROW, + payload: { + tempId, + rowData: { idx: Number(tempId), __tempId: tempId, ...rowData }, + table: {} as any, + }, + }) + + test('should return rows unchanged when there are no operations', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] + const result = formatGridDataWithOperationValues({ operations: [], rows }) + expect(result).toEqual(rows) + }) + + test('should apply EDIT_CELL_CONTENT to matching row', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] + const op = makeEditOp({ + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'Alice', + newValue: 'Updated', + table: {} as any, + }, + }) + + const result = formatGridDataWithOperationValues({ operations: [op], rows }) + expect(result[0]).toEqual({ idx: 0, id: 1, name: 'Updated' }) + expect(result[1]).toEqual(rows[1]) + }) + + test('should not modify rows when EDIT_CELL_CONTENT does not match any row', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' })] + const op = makeEditOp({ + payload: { + rowIdentifiers: { id: 999 }, + columnName: 'name', + oldValue: 'old', + newValue: 'new', + table: {} as any, + }, + }) + + const result = formatGridDataWithOperationValues({ operations: [op], rows }) + expect(result).toEqual(rows) + }) + + test('should apply multiple EDIT_CELL_CONTENT operations to different rows', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] + const op1 = makeEditOp({ + id: 'op-1', + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'Alice', + newValue: 'Updated Alice', + table: {} as any, + }, + }) + const op2 = makeEditOp({ + id: 'op-2', + payload: { + rowIdentifiers: { id: 2 }, + columnName: 'name', + oldValue: 'Bob', + newValue: 'Updated Bob', + table: {} as any, + }, + }) + + const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows }) + expect(result[0].name).toBe('Updated Alice') + expect(result[1].name).toBe('Updated Bob') + }) + + test('last edit wins when multiple operations target the same row', () => { + // Each operation spreads from the original row, so only the last op's column change is preserved + const rows = [makeRow(0, { id: 1, name: 'Alice', email: 'alice@test.com' })] + const op1 = makeEditOp({ + id: 'op-1', + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'Alice', + newValue: 'Updated', + table: {} as any, + }, + }) + const op2 = makeEditOp({ + id: 'op-2', + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'email', + oldValue: 'alice@test.com', + newValue: 'updated@test.com', + table: {} as any, + }, + }) + + const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows }) + // The second op overwrites the first since both spread from the original row + expect(result[0].name).toBe('Alice') + expect(result[0].email).toBe('updated@test.com') + }) + + test('should mark matching row as deleted for DELETE_ROW operation', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] + const op = makeDeleteOp({ id: 1 }, rows[0]) + + const result = formatGridDataWithOperationValues({ operations: [op], rows }) + expect(result[0].__isDeleted).toBe(true) + expect(result[1].__isDeleted).toBeUndefined() + }) + + test('should not modify rows when DELETE_ROW does not match any row', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' })] + const op = makeDeleteOp({ id: 999 }, makeRow(0, { id: 999 })) + + const result = formatGridDataWithOperationValues({ operations: [op], rows }) + expect(result[0].__isDeleted).toBeUndefined() + }) + + test('should not mutate the original rows array', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' })] + const op = makeEditOp({ + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'Alice', + newValue: 'Updated', + table: {} as any, + }, + }) + + const result = formatGridDataWithOperationValues({ operations: [op], rows }) + expect(rows[0].name).toBe('Alice') + expect(result[0].name).toBe('Updated') + }) + + test('should handle mixed operation types', () => { const rows = [ - { idx: 0, id: 1, name: 'old' }, - { idx: 1, id: 2, name: 'test' }, + makeRow(0, { id: 1, name: 'Alice' }), + makeRow(1, { id: 2, name: 'Bob' }), + makeRow(2, { id: 3, name: 'Charlie' }), ] - const result = applyCellEdit(rows, 'name', { id: 1 }, 'new') - expect(result).toEqual([ - { idx: 0, id: 1, name: 'new' }, - { idx: 1, id: 2, name: 'test' }, - ]) + + const editOp = makeEditOp({ + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'Alice', + newValue: 'Updated Alice', + table: {} as any, + }, + }) + const deleteOp = makeDeleteOp({ id: 2 }, rows[1]) + + const result = formatGridDataWithOperationValues({ + operations: [editOp, deleteOp], + rows, + }) + + expect(result[0].name).toBe('Updated Alice') + expect(result[1].__isDeleted).toBe(true) + expect(result[2]).toEqual(rows[2]) }) - test('should not affect non-matching rows', () => { - const rows = [ - { idx: 0, id: 1, name: 'old' }, - { idx: 1, id: 2, name: 'test' }, + test('should prepend new row for ADD_ROW operation', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' })] + const op = makeAddOp('-100', { name: 'New Row' }) + + const result = formatGridDataWithOperationValues({ operations: [op], rows }) + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ __tempId: '-100', name: 'New Row' }) + expect(result[1]).toEqual(rows[0]) + }) + + test('should update existing pending row for ADD_ROW with same tempId', () => { + const rows: SupaRow[] = [ + { idx: -100, __tempId: '-100', name: 'Original' } as any, + makeRow(1, { id: 1, name: 'Alice' }), ] - const result = applyCellEdit(rows, 'name', { id: 3 }, 'new') - expect(result).toEqual([ - { idx: 0, id: 1, name: 'old' }, - { idx: 1, id: 2, name: 'test' }, - ]) + const op = makeAddOp('-100', { name: 'Updated' }) + + const result = formatGridDataWithOperationValues({ operations: [op], rows }) + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ __tempId: '-100', name: 'Updated' }) }) - test('should create new row instances for matching row', () => { - const rows = [{ idx: 0, id: 1, name: 'old' }] - const result = applyCellEdit(rows, 'name', { id: 1 }, 'new') - expect(result[0]).not.toBe(rows[0]) - expect(result[0]).toEqual({ idx: 0, id: 1, name: 'new' }) + test('should handle multiple ADD_ROW operations', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' })] + const op1 = makeAddOp('-100', { name: 'Row 1' }) + const op2 = makeAddOp('-200', { name: 'Row 2' }) + + const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows }) + expect(result).toHaveLength(3) + expect(result[0]).toMatchObject({ __tempId: '-200', name: 'Row 2' }) + expect(result[1]).toMatchObject({ __tempId: '-100', name: 'Row 1' }) }) - test('should not modify original array', () => { - const rows = [{ idx: 0, id: 1, name: 'old' }] - const originalRows = [...rows] - applyCellEdit(rows, 'name', { id: 1 }, 'new') - expect(rows).toEqual(originalRows) - }) + test('should handle EDIT_CELL_CONTENT with composite primary keys', () => { + const rows = [makeRow(0, { tenant_id: 'a', user_id: 1, name: 'Alice' })] + const op = makeEditOp({ + payload: { + rowIdentifiers: { tenant_id: 'a', user_id: 1 }, + columnName: 'name', + oldValue: 'Alice', + newValue: 'Updated', + table: {} as any, + }, + }) - test('should handle multiple matching rows with composite keys', () => { - const rows = [ - { idx: 0, id: 1, org_id: 10, name: 'old1' }, - { idx: 1, id: 1, org_id: 20, name: 'old2' }, - { idx: 2, id: 2, org_id: 10, name: 'old3' }, - ] - const result = applyCellEdit(rows, 'name', { id: 1, org_id: 10 }, 'new') - expect(result).toEqual([ - { idx: 0, id: 1, org_id: 10, name: 'new' }, - { idx: 1, id: 1, org_id: 20, name: 'old2' }, - { idx: 2, id: 2, org_id: 10, name: 'old3' }, - ]) - }) - - test('should handle setting value to null', () => { - const rows = [{ idx: 0, id: 1, name: 'test' }] - const result = applyCellEdit(rows, 'name', { id: 1 }, null) - expect(result).toEqual([{ idx: 0, id: 1, name: null }]) - }) - - test('should handle setting value to undefined', () => { - const rows = [{ idx: 0, id: 1, name: 'test' }] - const result = applyCellEdit(rows, 'name', { id: 1 }, undefined) - expect(result).toEqual([{ idx: 0, id: 1, name: undefined }]) - }) - - test('should handle numeric values', () => { - const rows = [{ idx: 0, id: 1, count: 0 }] - const result = applyCellEdit(rows, 'count', { id: 1 }, 42) - expect(result).toEqual([{ idx: 0, id: 1, count: 42 }]) - }) - - test('should handle object values', () => { - const rows = [{ idx: 0, id: 1, data: null }] - const newValue = { nested: { value: 123 } } - const result = applyCellEdit(rows, 'data', { id: 1 }, newValue) - expect(result).toEqual([{ idx: 0, id: 1, data: newValue }]) - }) - - test('should handle empty rows array', () => { - const rows: any[] = [] - const result = applyCellEdit(rows, 'name', { id: 1 }, 'new') - expect(result).toEqual([]) - }) - - test('should update all matching rows with same identifier', () => { - const rows = [ - { idx: 0, id: 1, name: 'row1' }, - { idx: 1, id: 1, name: 'row2' }, - { idx: 2, id: 2, name: 'row3' }, - ] - const result = applyCellEdit(rows, 'name', { id: 1 }, 'updated') - expect(result).toEqual([ - { idx: 0, id: 1, name: 'updated' }, - { idx: 1, id: 1, name: 'updated' }, - { idx: 2, id: 2, name: 'row3' }, - ]) + const result = formatGridDataWithOperationValues({ operations: [op], rows }) + expect(result[0].name).toBe('Updated') }) }) diff --git a/apps/studio/components/grid/utils/queueOperationUtils.ts b/apps/studio/components/grid/utils/queueOperationUtils.ts index d9a0ffe401d..86b047369c1 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.ts @@ -1,10 +1,7 @@ -import type { QueryClient } from '@tanstack/react-query' import { isTableLike, type Entity } from 'data/table-editor/table-editor-types' -import { tableRowKeys } from 'data/table-rows/keys' -import type { TableRowsData } from 'data/table-rows/table-rows-query' import type { Dictionary } from 'types' -import { isPendingAddRow, PendingAddRow, PendingDeleteRow, SupaRow } from '../types' +import { isPendingAddRow, PendingAddRow, SupaRow } from '../types' import { EditCellContentOperation, NewQueuedOperation, @@ -63,68 +60,12 @@ export function rowMatchesIdentifiers( return identifierEntries.every(([key, value]) => row[key] === value) } -export function applyCellEdit( - rows: SupaRow[], - columnName: string, - rowIdentifiers: Dictionary, - newValue: unknown -): SupaRow[] { - return rows.map((row) => { - const rowMatches = rowMatchesIdentifiers(row, rowIdentifiers) - if (rowMatches) { - return { ...row, [columnName]: newValue } - } - return row - }) -} - -export function applyRowAdd( - rows: SupaRow[], - tempId: string, - idx: number, - rowData: Dictionary -): (PendingAddRow | SupaRow)[] { - // Check if row with this tempId already exists - const existingIndex = rows.findIndex((row) => isPendingAddRow(row) && row.__tempId === tempId) - if (existingIndex >= 0) { - // Update existing row in place - return rows.map((row, index) => { - if (index === existingIndex) { - return { ...row, ...rowData, __tempId: tempId } - } - return row - }) - } - - const newRow: PendingAddRow = { - idx, - ...rowData, - __tempId: tempId, - } - return [newRow, ...rows] -} - -export function markRowAsDeleted( - rows: SupaRow[], - rowIdentifiers: Dictionary -): (PendingDeleteRow | SupaRow)[] { - return rows.map((row): PendingDeleteRow | SupaRow => { - const rowMatches = rowMatchesIdentifiers(row, rowIdentifiers) - if (rowMatches) { - return { ...row, __isDeleted: true } - } - return row - }) -} - export function removeRow(rows: SupaRow[], rowIdentifiers: Dictionary): SupaRow[] { return rows.filter((row) => !rowMatchesIdentifiers(row, rowIdentifiers)) } interface QueueCellEditParams { - queryClient: QueryClient queueOperation: (operation: NewQueuedOperation) => void - projectRef: string tableId: number table: Entity row: SupaRow @@ -136,9 +77,7 @@ interface QueueCellEditParams { } export function queueCellEditWithOptimisticUpdate({ - queryClient, queueOperation, - projectRef, tableId, table, row, @@ -167,22 +106,10 @@ export function queueCellEditWithOptimisticUpdate({ enumArrayColumns, }, }) - - // Apply optimistic update to the UI - const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } }) - queryClient.setQueriesData({ queryKey }, (old) => { - if (!old) return old - return { - ...old, - rows: applyCellEdit(old.rows, columnName, rowIdentifiers, newValue), - } - }) } interface QueueRowAddParams { - queryClient: QueryClient queueOperation: (operation: NewQueuedOperation) => void - projectRef: string tableId: number table: Entity rowData: PendingAddRow @@ -190,9 +117,7 @@ interface QueueRowAddParams { } export function queueRowAddWithOptimisticUpdate({ - queryClient, queueOperation, - projectRef, tableId, table, rowData, @@ -213,73 +138,58 @@ export function queueRowAddWithOptimisticUpdate({ enumArrayColumns, }, }) - - // Apply optimistic update to the UI - const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } }) - queryClient.setQueriesData({ queryKey }, (old) => { - if (!old) return old - return { - ...old, - rows: applyRowAdd(old.rows, tempId, idx, rowData), - } - }) } -interface ReapplyOptimisticUpdatesParams { - queryClient: QueryClient - projectRef: string - tableId: number - operations: readonly QueuedOperation[] -} - -export function reapplyOptimisticUpdates({ - queryClient, - projectRef, - tableId, +export const formatGridDataWithOperationValues = ({ operations, -}: ReapplyOptimisticUpdatesParams) { - const tableOperations = operations.filter((op) => op.tableId === tableId) - if (tableOperations.length === 0) return + rows, +}: { + operations: QueuedOperation[] + rows: SupaRow[] +}) => { + const formattedRows = rows.slice() - const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } }) - queryClient.setQueriesData({ queryKey }, (old) => { - if (!old) return old + operations.forEach((op) => { + if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) { + const { rowIdentifiers, columnName, newValue } = op.payload + const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers)) + if (rowMatches) { + formattedRows[rowMatches.idx] = { ...rowMatches, [columnName]: newValue } + } + } else if (op.type === QueuedOperationType.ADD_ROW) { + const { tempId, rowData } = op.payload + const idx = Number(tempId) - let rows = [...old.rows] - for (const operation of tableOperations) { - switch (operation.type) { - case QueuedOperationType.EDIT_CELL_CONTENT: { - const { rowIdentifiers, columnName, newValue } = operation.payload - rows = applyCellEdit(rows, columnName, rowIdentifiers, newValue) - break - } - case QueuedOperationType.ADD_ROW: { - const { tempId, rowData } = operation.payload - // Derive idx from tempId (tempId is stringified negative timestamp) - const idx = Number(tempId) - rows = applyRowAdd(rows, tempId, idx, rowData) - break - } - case QueuedOperationType.DELETE_ROW: { - const { rowIdentifiers } = operation.payload - rows = markRowAsDeleted(rows, rowIdentifiers) - break - } - default: { - // Need to explicitly handle other operations - throw new Error(`Unknown operation type: ${(operation as never)['type']}`) + // Check if row with this tempId already exists + const existingIndex = formattedRows.findIndex( + (row) => isPendingAddRow(row) && row.__tempId === tempId + ) + if (existingIndex >= 0) { + // Update existing row in place + formattedRows[existingIndex] = { + ...formattedRows[existingIndex], + ...rowData, + __tempId: tempId, } + } else { + const newRow: PendingAddRow = { ...rowData, idx, __tempId: tempId } + formattedRows.unshift(newRow) + } + } else if (op.type === QueuedOperationType.DELETE_ROW) { + const { rowIdentifiers } = op.payload + const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers)) + if (rowMatches) { + formattedRows[rowMatches.idx] = { ...rowMatches, __isDeleted: true } } } - - return { ...old, rows } }) + + return formattedRows } interface QueueRowDeletesParams { rows: SupaRow[] table: Entity - queryClient: QueryClient queueOperation: (operation: NewQueuedOperation) => void projectRef: string | undefined } @@ -291,7 +201,6 @@ interface QueueRowDeletesParams { export function queueRowDeletesWithOptimisticUpdate({ rows, table, - queryClient, queueOperation, projectRef, }: QueueRowDeletesParams): void { @@ -327,24 +236,5 @@ export function queueRowDeletesWithOptimisticUpdate({ table, }, }) - - const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } }) - queryClient.setQueriesData({ queryKey }, (old) => { - if (!old) return old - - // For pending add rows, remove completely - if (isPendingAddRow(row)) { - return { - ...old, - rows: removeRow(old.rows, rowIdentifiers), - } - } - - // For existing rows, mark as deleted - return { - ...old, - rows: markRowAsDeleted(old.rows, rowIdentifiers), - } - }) } } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 088655e23a6..30db82f074c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -1,5 +1,5 @@ -import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' import * as Sentry from '@sentry/nextjs' +import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' import { @@ -276,9 +276,7 @@ export const SidePanelEditor = ({ // Queue the ADD_ROW operation if queue operations feature is enabled if (isQueueOperationsEnabled && selectedTable.primary_keys.length > 0) { queueRowAddWithOptimisticUpdate({ - queryClient, queueOperation: snap.queueOperation, - projectRef: project.ref, tableId: selectedTable.id, table: selectedTable as unknown as Entity, rowData: payload, @@ -330,9 +328,7 @@ export const SidePanelEditor = ({ const oldValue = row[changedColumn] queueCellEditWithOptimisticUpdate({ - queryClient, queueOperation: snap.queueOperation, - projectRef: project.ref, tableId: selectedTable.id, // Cast to Entity - the queue save mutation only uses id, name, schema table: selectedTable as unknown as Entity, From be7079a19c1bd95de90c6f735765d6c8306396f8 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 19 Mar 2026 09:49:07 -0600 Subject: [PATCH 08/56] fix: sentry should report full page crashes in third party libraries (#43927) ## 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? When a full page crash occurs due to a third party library erroring out, we should report that error rather than hiding it --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/studio/instrumentation-client.ts | 28 +++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index cb96b35ad93..eda73e22c59 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -102,13 +102,15 @@ Sentry.init({ const thirdPartyErrorFilterIntegration = (Sentry as any).thirdPartyErrorFilterIntegration if (!thirdPartyErrorFilterIntegration) return [] - // Drop errors whose stack trace only contains third-party frames (browser extensions, + // Tag errors whose stack trace only contains third-party frames (browser extensions, // injected scripts, etc.). This uses build-time code annotation via the applicationKey // in next.config.js to reliably distinguish our code from third-party code. + // We use 'apply-tag' instead of 'drop' so that beforeSend can exempt error boundary + // crashes — these may originate in third-party code but are caused by first-party bugs. return [ thirdPartyErrorFilterIntegration({ filterKeys: ['supabase-studio'], - behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', }), ] })(), @@ -146,6 +148,19 @@ Sentry.init({ return null } + const isErrorBoundaryCrash = + event.tags?.globalErrorBoundary === true || event.tags?.globalErrorBoundary === 'true' + const isThirdPartyOnly = + event.tags?.third_party_code === true || event.tags?.third_party_code === 'true' + + // Drop third-party-only errors UNLESS they crashed the page via the global error boundary. + // This preserves noise reduction for browser extensions and injected scripts, + // while ensuring page-crashing errors from third-party libs (caused by first-party bugs) + // are always reported. + if (isThirdPartyOnly && !isErrorBoundaryCrash) { + return null + } + // Downsample only known high-noise classes; keep all other errors at full rate. const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes( `Failed to construct 'URL': Invalid URL` @@ -173,9 +188,14 @@ Sentry.init({ return null } - // Drop events where every exception has no stack trace — these are not debuggable + // Drop events where every exception has no stack trace — these are not debuggable. + // Exempt error boundary crashes: even without stack frames, a page crash is always worth reporting. const exceptions = event.exception?.values ?? [] - if (exceptions.length > 0 && exceptions.every((ex) => !ex.stacktrace?.frames?.length)) { + if ( + !isErrorBoundaryCrash && + exceptions.length > 0 && + exceptions.every((ex) => !ex.stacktrace?.frames?.length) + ) { return null } From b13ee5424c283be20d80a0f6de1a268a9fcaa4c3 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 19 Mar 2026 09:59:17 -0600 Subject: [PATCH 09/56] chore: add test for undo feature for queue operations (#43984) ## 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? chore: add test for undo feature for queue operations --- .../features/queue-table-operations.spec.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/e2e/studio/features/queue-table-operations.spec.ts b/e2e/studio/features/queue-table-operations.spec.ts index abe8c4ce167..9a0fd34281a 100644 --- a/e2e/studio/features/queue-table-operations.spec.ts +++ b/e2e/studio/features/queue-table-operations.spec.ts @@ -285,6 +285,164 @@ test.describe('Queue Table Operations', () => { await expect(page.getByRole('gridcell', { name: 'shortcut test' })).toBeVisible() }) + test('undo reverts the latest operation from the queue', async ({ page, ref }) => { + const tableName = `${tableNamePrefix}_undo` + const columnName = 'name' + + await using _ = await withSetupCleanup( + async () => { + await createTable(tableName, columnName, [{ name: 'existing row' }]) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await enableQueueOperations(page) + await page.reload() + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await expect(page.getByRole('gridcell', { name: 'existing row' })).toBeVisible() + + await page.getByTestId('table-editor-insert-new-row').click() + await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click() + await page.getByTestId(`${columnName}-input`).fill('undo this row') + await page.getByTestId('action-bar-save-row').click() + + await expect(page.getByText('1 pending change')).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'undo this row' })).toBeVisible() + + await page.keyboard.press('ControlOrMeta+z') + + await expect(page.getByText('pending change')).not.toBeVisible() + await expect(page.getByRole('gridcell', { name: 'undo this row' })).not.toBeVisible() + await expect(page.getByRole('gridcell', { name: 'existing row' })).toBeVisible() + }) + + test('undo reverts operations one at a time', async ({ page, ref }) => { + const tableName = `${tableNamePrefix}_undo_multi` + const columnName = 'name' + + await using _ = await withSetupCleanup( + async () => { + await createTable(tableName, columnName) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await enableQueueOperations(page) + await page.reload() + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await page.getByTestId('table-editor-insert-new-row').click() + await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click() + await page.getByTestId(`${columnName}-input`).fill('first row') + await page.getByTestId('action-bar-save-row').click() + + await page.getByTestId('table-editor-insert-new-row').click() + await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click() + await page.getByTestId(`${columnName}-input`).fill('second row') + await page.getByTestId('action-bar-save-row').click() + + await expect(page.getByText('2 pending changes')).toBeVisible() + + await page.keyboard.press('ControlOrMeta+z') + + await expect(page.getByText('1 pending change')).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'first row' })).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'second row' })).not.toBeVisible() + + await page.keyboard.press('ControlOrMeta+z') + + await expect(page.getByText('pending change')).not.toBeVisible() + await expect(page.getByRole('gridcell', { name: 'first row' })).not.toBeVisible() + }) + + test('undo works for cell edits', async ({ page, ref }) => { + const tableName = `${tableNamePrefix}_undo_edit` + const columnName = 'name' + + await using _ = await withSetupCleanup( + async () => { + await createTable(tableName, columnName, [{ name: 'original value' }]) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await enableQueueOperations(page) + await page.reload() + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await expect(page.getByRole('gridcell', { name: 'original value' })).toBeVisible() + + const cell = page.getByRole('gridcell', { name: 'original value' }) + await cell.dblclick() + + const editor = page.getByRole('textbox', { name: /Editor content/ }) + await expect(editor).toBeVisible() + await editor.fill('edited value') + await page.keyboard.press('Enter') + + await expect(page.getByText('1 pending change')).toBeVisible() + + await page.keyboard.press('ControlOrMeta+z') + + await expect(page.getByText('pending change')).not.toBeVisible() + await expect(page.getByRole('gridcell', { name: 'original value' })).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'edited value' })).not.toBeVisible() + }) + + test('undo works for row deletes', async ({ page, ref }) => { + const tableName = `${tableNamePrefix}_undo_del` + const columnName = 'name' + + await using _ = await withSetupCleanup( + async () => { + await createTable(tableName, columnName, [{ name: 'row to keep' }]) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await enableQueueOperations(page) + await page.reload() + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await expect(page.getByRole('gridcell', { name: 'row to keep' })).toBeVisible() + + const cell = page.getByRole('gridcell', { name: 'row to keep' }) + await cell.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Delete row' }).click() + + await expect(page.getByText('1 pending change')).toBeVisible() + + await page.keyboard.press('ControlOrMeta+z') + + await expect(page.getByText('pending change')).not.toBeVisible() + await expect(page.getByRole('gridcell', { name: 'row to keep' })).toBeVisible() + }) + test('row deletes via context menu are queued', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_row_delete` const columnName = 'name' From 0533904957929f5de28d02bd2302323928489d3a Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:32:48 -0400 Subject: [PATCH 10/56] Revert "fix: Disable`/incident-banner` query" (#43933) Reverts supabase/supabase#43783 --- apps/studio/data/platform/incident-banner-query.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/studio/data/platform/incident-banner-query.ts b/apps/studio/data/platform/incident-banner-query.ts index 9b47043c184..2b9fa3f8b51 100644 --- a/apps/studio/data/platform/incident-banner-query.ts +++ b/apps/studio/data/platform/incident-banner-query.ts @@ -37,8 +37,5 @@ export const incidentBannerQueryOptions = () => // long retry delays. retryDelay: (attemptIndex) => Math.min(1000 * 4 ** attemptIndex, 1000 * 60 * 5), staleTime: 1000 * 60 * 5, - // temporarily disable incident status queries until we have the capacity to handle potential increased load - // from retries. - enabled: false, - // enabled: IS_PLATFORM, + enabled: IS_PLATFORM, }) From 8c6dbf6433dddc492dda4ef03cce649519e07304 Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Thu, 19 Mar 2026 18:42:57 +0100 Subject: [PATCH 11/56] docs: Overhaul going into prod page (#43916) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../guides/deployment/going-into-prod.mdx | 117 ++++++++---------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/apps/docs/content/guides/deployment/going-into-prod.mdx b/apps/docs/content/guides/deployment/going-into-prod.mdx index a44b3d0cc56..a66683497cc 100644 --- a/apps/docs/content/guides/deployment/going-into-prod.mdx +++ b/apps/docs/content/guides/deployment/going-into-prod.mdx @@ -6,71 +6,90 @@ description: 'Things to do before making your app publicly available' After developing your project and deciding it's production ready, you should run through this checklist to ensure that your project: -- is secure -- won't falter under the expected load -- remains available whilst in production +- [Is secure](#security) +- [Won't falter under the expected load](#performance) +- [Remains available whilst in production](#availability) ## Security -- Ensure RLS is enabled + + +Check and review issues in your database using [Security Advisor](/dashboard/project/_/database/security-advisor). + + + +- Ensure you have enabled row level security (RLS) on all tables from the [**Database > Tables**](/dashboard/project/_/database/tables) section of the Supabase Dashboard. - Tables that do not have RLS enabled with reasonable policies allow any client to access and modify their data. This is usually not what you want. - [Learn more about RLS](/docs/guides/database/postgres/row-level-security). -- Enable replication on tables containing sensitive data by enabling Row Level Security (RLS) and setting row security policies: - - Go to the Authentication > Policies page in the Supabase Dashboard to enable RLS and create security policies. - - Go to the Database > Publications page in the Supabase Dashboard to manage replication tables. -- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) (see: [dashboard](/dashboard/project/_/auth/policies)) -- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for your database (see: [dashboard](/dashboard/project/_/database/settings#network-restrictions)). -- Ensure that your Supabase Account is protected with multi-factor authentication (MFA). - - If using a GitHub sign-in, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app. +- Enable replication on tables containing sensitive data by enabling RLS and setting row security policies: + - Go to the [**Authentication > Policies**](/dashboard/project/_/auth/policies) section of the Supabase Dashboard to enable RLS and create security policies. + - Go to the [**Database > Publications**](/dashboard/project/_/database/publications) section of the Supabase Dashboard to manage replication tables. +- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) from the [**Database > Settings > SSL Configuration**](/dashboard/project/_/database/settings#ssl-configuration) section of the dashboard. +- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for the database from the [**Database > Settings > Network Restrictions**](/dashboard/project/_/database/settings#network-restrictions) section of the dashboard. +- Ensure that you protect your Supabase Account with multi-factor authentication (MFA). + - If using a GitHub sign-in, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). + - Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app. - If using email+password sign-in, set up [MFA for your Supabase account](/docs/guides/platform/multi-factor-authentication#enable-mfa). -- Enable [MFA enforcement on your organization](/docs/guides/platform/network-restrictions). This ensures all users must have a valid MFA backed session to interact with organization and project resources. -- Consider [adding multiple owners on your Supabase org](/dashboard/org/_/team). This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org. -- Ensure email confirmations are [enabled](/dashboard/project/_/auth/providers) in the `Settings > Auth` page. -- Ensure that you've [set the expiry](/dashboard/project/_/auth/providers) for one-time passwords (OTPs) to a reasonable value that you are comfortable with. We recommend setting this to 3600 seconds (1 hour) or lower. -- Increase the length of the OTP if you need a higher level of entropy. +- Consider enabling [MFA enforcement on your organization](/docs/guides/platform/mfa/org-mfa-enforcement). This ensures all users must have a valid MFA-backed session to interact with organization and project resources. +- Consider adding multiple owners on your Supabase org from [the **Organization > Team**](/dashboard/org/_/team) section of the Supabase Dashboard. This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org. +- Enable email confirmations in the [**Authentication > Providers**](/dashboard/project/_/auth/providers) section of the dashboard. +- Set the expiry in the [**Authentication > Providers**](/dashboard/project/_/auth/providers) section of the dashboard for one-time passwords (OTPs) to a reasonable value that you are comfortable with. + - We recommend setting this to 3600 seconds (1 hour) or lower. + - Increase the length of the OTP if you need a higher level of entropy. - If your application requires a higher level of security, consider setting up [multi-factor authentication](/docs/guides/auth/auth-mfa) (MFA) for your users. - Use a custom SMTP server for auth emails so that your users can see that the mails are coming from a trusted domain (preferably the same domain that your app is hosted on). Grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. - Consider how _you_ might abuse your service as an attacker, and take steps to mitigate it. - Review these [common cybersecurity threats](https://auth0.com/docs/security/prevent-threats). -- Check and review issues in your database using [Security Advisor](/dashboard/project/_/database/security-advisor). ## Performance + + +Check and review issues in your database using [Performance Advisor](/dashboard/project/_/database/performance-advisor). + + + - Ensure that you have suitable indices to cater to your common query patterns - [Learn more about indexes in Postgres](https://www.enterprisedb.com/postgres-tutorials/overview-postgresql-indexes). - `pg_stat_statements` can help you [identify hot or slow queries](https://www.virtual-dba.com/blog/postgresql-performance-identifying-hot-and-slow-queries/). - Perform load testing (preferably on a staging env) - Tools like [k6](https://k6.io/) can simulate traffic from many different users. -- Upgrade your database if you require more resources. If you need anything beyond what is listed, contact enterprise@supabase.io. +- Upgrade your database if you require more resources. If you need anything beyond what is listed in [the compute and disk table](/docs/guides/platform/compute-and-disk), contact [enterprise@supabase.io](mailto:enterprise@supabase.io). - If you are expecting a surge in traffic (for a big launch) and are on a Team or Enterprise Plan, [contact support](/dashboard/support/new) with more details about your launch and we'll help keep an eye on your project. -- If you expect your database size to be > 4 GB, [enable](/dashboard/project/_/settings/addons?panel=pitr) the Point in Time Recovery (PITR) add-on. Daily backups can take up resources from your database when the backup is in progress. PITR is more resource efficient, since only the changes to the database are backed up. -- Check and review issues in your database using [Performance Advisor](/dashboard/project/_/database/performance-advisor). +- If you expect your database size to be > 4 GB, enable the Point in Time Recovery (PITR) add-on in the [**Settings > Add-ons**](/dashboard/project/_/settings/addons?panel=pitr) section of the dashboard. ## Availability -- Use your own SMTP credentials so that you have full control over the deliverability of your transactional auth emails (see Settings > Auth) - - you can grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. You can refer to our [SMTP guide](/docs/guides/auth/auth-smtp) for more details. - - The default rate limit for auth emails when using a custom SMTP provider is 30 new users per hour, if doing a major public announcement you will likely require more than this. -- Applications on the Free Plan that exhibit extremely low activity in a 7 day period may be paused by Supabase to save on server resources. - - You can restore paused projects from the Supabase dashboard. - - Upgrade to Pro to guarantee that your project will not be paused for inactivity. +- Use your own SMTP credentials so that you have full control over the deliverability of your transactional auth emails in the [**Authentication > Emails > SMTP Settings**](/dashboard/project/_/auth/smtp) section of the dashboard. + - You can grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. Read our [SMTP guide](/docs/guides/auth/auth-smtp) for more setup details. + - The default rate limit for auth emails when using a custom SMTP provider is _30 new users per hour_. If you are doing a major public announcement, you will likely require more than this. +- We may pause applications on the Free Plan that exhibit low activity in a 7-day period to save on server resources. + - You can restore paused projects from [the Supabase dashboard](/dashboard/project/_). + - Upgrade to Pro to guarantee that we won't pause your project for inactivity. - Database backups are not available for download for Free Plan projects. - - You can set up your own backup systems using tools like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump) or [wal-g](https://github.com/wal-g/wal-g). - - Nightly backups for Pro Plan projects are available on the Supabase dashboard for up to 7 days. - - Point-in-Time Recovery (PITR) allows a project to be backed up at much shorter intervals. This provides users an option to restore to any chosen point of up to seconds in granularity. If a lower RPO is required, enable PITR. + - Read [the Database Backups guide](/docs/guides/platform/backups) for more options and retention details. + - If you need a lower recovery point objective (RPO), enable Point-in-Time Recovery (PITR). PITR allows you to back up a project at shorter intervals. This provides users an option to restore to any chosen point in time with second-level granularity. - Supabase Projects use disks that offer 99.8-99.9% durability by default. - - Use Read Replicas if you require availability resilience to a disk failure event + - Use [Read Replicas](/docs/guides/platform/read-replicas) if you require availability resilience to a disk failure event - Use PITR if you require durability resilience to a disk failure event -- Upgrading to the Supabase Pro Plan will give you [access to our support team](/dashboard/support/new). +- Upgrading to the Supabase Pro Plan gives you [access to our support team](/dashboard/support/new). ## Rate limiting, resource allocation, & abuse prevention + + +Running databases is a shared responsibility between you and Supabase. There are some things that we can take care of for you, and some things that you are responsible for. + +Read more details in our [Shared Responsibility Model guide](/docs/guides/platform/shared-responsibility-model). + + + - Supabase employs a number of safeguards against bursts of incoming traffic to prevent abuse and help maximize stability across the platform - If you're on a Team or Enterprise Plan and expect high load events, such as production launches, heavy load testing, or prolonged high resource usage, open a ticket via the [support form](https://supabase.help) for help. Provide at least 2 weeks notice. ### Auth rate limits -- The table below shows the rate limit quotas on the following authentication endpoints. You can configure the auth rate limits for your project [here](/dashboard/project/_/auth/rate-limits). +- The table below shows the rate limit quotas on the following authentication endpoints. You can configure the authentication rate limits for your project in the [**Authentication > Rate Limits**](/dashboard/project/_/auth/rate-limits) section of the dashboard. | Endpoint | Path | Limited By | Rate Limit | | ------------------------------------------------ | -------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -87,39 +106,13 @@ After developing your project and deciding it's production ready, you should run ### Realtime limits - Review the [Realtime limits](/docs/guides/realtime/limits). -- If you need limits increased you can always [contact support](/dashboard/support/new). +- If you need limits increased, [contact support](/dashboard/support/new). ### Abuse prevention -- Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Refer to [our guide](/docs/guides/auth/auth-captcha) on how to protect against abuse using this method. +- Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Read [the Auth CAPTCHA guide](/docs/guides/auth/auth-captcha) for more details on how to protect against abuse using this method. ### Email link validity -- When working with enterprise systems, email scanners may scan and make a `GET` request to the reset password link or sign up link in your email. Since links in Supabase Auth are single use, a user who opens an email post-scan to click on a link will receive an error. To get around this problem, - consider altering the email template to replace the original magic link with a link to a domain you control. The domain can present the user with a "Sign-in" button which redirect the user to the original magic link URL when clicked. - -- When using a custom SMTP service, some services might have link tracking enabled which may overwrite or disform the email confirmation links sent by Supabase Auth. To prevent this from happening, we recommend that you disable link tracking when using a custom SMTP service. - -## Subscribe to Supabase status page - -Stay informed about Supabase service status by subscribing to the [Status Page](https://status.supabase.com/). We recommend setting up Slack notifications through an RSS feed to ensure your team receives timely updates about service status changes. - -### Setting up Slack notifications - -1. Install the RSS app in Slack: - - Visit the [RSS app page](https://slack.com/marketplace/A0F81R7U7-rss) in the Slack marketplace - - Click `Add to Slack` if not already installed - - Otherwise you will get straight to next step, no need to reinstall the app - -2. Configure the Supabase status feed: - - Create a channel (e.g., `#supabase-status-alerts`) for status updates - - On the [RSS app page](https://slack.com/marketplace/A0F81R7U7-rss) go to _Add a Feed_ section and set Feed URL to `https://status.supabase.com/history.rss` - - Select your designated channel and click "Subscribe to this feed" - -Once configured, your team will receive automatic notifications in Slack whenever the Supabase Status Page is updated. - -For detailed setup instructions, see the [Add RSS feeds to Slack](https://slack.com/intl/en-nz/help/articles/218688467-Add-RSS-feeds-to-Slack). - -## Next steps - -This checklist is always growing so be sure to check back frequently, and also feel free to suggest additions and amendments by making a PR on [GitHub](https://github.com/supabase/supabase). +- When working with enterprise systems, email scanners may scan and make a `GET` request to the reset password link or sign-up link in your email. Since links in Supabase Auth are single-use, a user who opens an email post-scan to click on a link will receive an error. To get around this problem, consider altering the email template to replace the original magic link with a link to a domain you control. The domain can present the user with a "Sign-in" button, which redirects the user to the original magic link URL when clicked. +- When using a custom SMTP service, some services might have link tracking enabled which may overwrite or deform the email confirmation links sent by Supabase Auth. To prevent this from happening, we recommend that you disable link tracking when using a custom SMTP service. From 9b1377c2ba71aa82bc58199dfca46cac552f8715 Mon Sep 17 00:00:00 2001 From: Illia Basalaiev <44750366+Ellba@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:46:21 +0100 Subject: [PATCH 12/56] chore: improve storage migration script (#33927) ## 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? docs update ## What is the current behavior? Issues described in this [Slack thread](https://supabase.slack.com/archives/C02BJ2239GA/p1740408115511769) ## What is the new behavior? - Full Storage Migration: Transfers all buckets and their contents. - Bucket Configuration & Metadata Retention: Maintains settings like public access, file size limits, MIME types, and file metadata. - Conflict Resolution Options: Skip, merge (overwrite), or rename buckets in the target project. - Safety Measures: Requires user confirmation before destructive actions. - Efficient Processing: Batches files (10 per batch) and supports nested directories. - Progress & Reporting: Real-time updates, detailed migration summaries, and error tracking for failed files. --------- Co-authored-by: Illia Basalaiev --- .../backup-restore.mdx | 350 ++++++++++++++++-- 1 file changed, 314 insertions(+), 36 deletions(-) diff --git a/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx b/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx index a012aaa51aa..c4c2275fcb9 100644 --- a/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx +++ b/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx @@ -236,47 +236,325 @@ const OLD_PROJECT_SERVICE_KEY = 'old-project-service-key-xxx' const NEW_PROJECT_URL = 'https://yyy.supabase.co' const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy' -;(async () => { - const oldSupabaseRestClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY, { - db: { - schema: 'storage', - }, - }) - const oldSupabaseClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY) - const newSupabaseClient = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY) +const oldSupabase = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY) +const newSupabase = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY) - // make sure you update max_rows in postgrest settings if you have a lot of objects - // or paginate here - const { data: oldObjects, error } = await oldSupabaseRestClient.from('objects').select() - if (error) { - console.log('error getting objects from old bucket') +function createLoadingAnimation(message) { + const readline = require('readline') + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + let i = 0 + let timer + let stopped = false + + const animate = () => { + if (stopped) return + process.stdout.write(`\r${frames[i]} ${message}`) + i = (i + 1) % frames.length + timer = setTimeout(animate, 80) + } + + animate() + + return { + stop: (finalMessage = '') => { + stopped = true + clearTimeout(timer) + readline.clearLine(process.stdout, 0) + readline.cursorTo(process.stdout, 0) + process.stdout.write(`✓ ${finalMessage || message}\n`) + }, + } +} + +/** + * Lists all files in a bucket, handling nested folders recursively. + */ +async function listAllFiles(bucket, path = '') { + const loader = createLoadingAnimation(`Listing files in '${bucket}${path ? '/' + path : ''}'...`) + + try { + const { data, error } = await oldSupabase.storage.from(bucket).list(path, { limit: 1000 }) + if (error) { + loader.stop(`Error listing files in '${bucket}${path ? '/' + path : ''}'`) + throw new Error(`❌ Error listing files in bucket '${bucket}': ${error.message}`) + } + + if (!data || data.length === 0) { + loader.stop(`No files found in '${bucket}${path ? '/' + path : ''}'`) + return [] + } + + let files = [] + for (const item of data) { + if (!item.metadata) { + loader.stop(`Found folder '${item.name}' in '${bucket}${path ? '/' + path : ''}'`) + const subFiles = await listAllFiles(bucket, `${path}${item.name}/`) + files = files.concat(subFiles) + } else { + files.push({ fullPath: `${path}${item.name}`, metadata: item.metadata }) + } + } + + loader.stop(`Found ${files.length} files in '${bucket}${path ? '/' + path : ''}'`) + return files + } catch (error) { + loader.stop() throw error } +} - for (const objectData of oldObjects) { - console.log(`moving ${objectData.id}`) - try { - const { data, error: downloadObjectError } = await oldSupabaseClient.storage - .from(objectData.bucket_id) - .download(objectData.name) - if (downloadObjectError) { - throw downloadObjectError - } +/** + * Creates a bucket in the new Supabase project if it doesn't exist. + */ +async function ensureBucketExists(bucketName, options = {}) { + const { data: existingBucket, error: getBucketError } = + await newSupabase.storage.getBucket(bucketName) - const { _, error: uploadObjectError } = await newSupabaseClient.storage - .from(objectData.bucket_id) - .upload(objectData.name, data, { - upsert: true, - contentType: objectData.metadata.mimetype, - cacheControl: objectData.metadata.cacheControl, - }) - if (uploadObjectError) { - throw uploadObjectError - } - } catch (err) { - console.log('error moving ', objectData) - console.log(err) + if (getBucketError && !getBucketError.message.includes('not found')) { + throw new Error(`❌ Error checking if bucket '${bucketName}' exists: ${getBucketError.message}`) + } + + if (!existingBucket) { + console.log(`🪣 Creating bucket '${bucketName}' in new project...`) + const { error } = await newSupabase.storage.createBucket(bucketName, options) + if (error) throw new Error(`❌ Failed to create bucket '${bucketName}': ${error.message}`) + console.log(`✅ Created bucket '${bucketName}'`) + } else { + console.log(`ℹ️ Bucket '${bucketName}' already exists in new project`) + } +} + +/** + * Migrates a single file from the old project to the new one. + */ +async function migrateFile(sourceBucketName, targetBucketName, file) { + const loader = createLoadingAnimation( + `Migrating ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'...` + ) + + try { + const { data, error: downloadError } = await oldSupabase.storage + .from(sourceBucketName) + .download(file.fullPath) + if (downloadError) { + loader.stop(`Failed to migrate ${file.fullPath}: Download error`) + throw new Error(`Download failed: ${downloadError.message}`) + } + + // Preserve all available metadata from the original file + const uploadOptions = { + upsert: true, + contentType: file.metadata?.mimetype, + cacheControl: file.metadata?.cacheControl, + } + + const { error: uploadError } = await newSupabase.storage + .from(targetBucketName) + .upload(file.fullPath, data, uploadOptions) + if (uploadError) { + loader.stop(`Failed to migrate ${file.fullPath}: Upload error`) + throw new Error(`Upload failed: ${uploadError.message}`) + } + + loader.stop( + `Migrated ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'` + ) + return { success: true, path: file.fullPath } + } catch (err) { + console.error( + `❌ Error migrating ${file.fullPath} in bucket '${targetBucketName}':`, + err.message + ) + return { success: false, path: file.fullPath, error: err.message } + } +} + +function chunkArray(array, size) { + const chunks = [] + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)) + } + return chunks +} + +/** + * Migrates all buckets and files from the old Supabase project to the new one. + * Processes files in parallel within batches for efficiency. + */ +async function migrateBuckets() { + console.log('🔄 Starting Supabase Storage migration...') + console.log(`📦 Source project: ${OLD_PROJECT_URL}`) + console.log(`📦 Target project: ${NEW_PROJECT_URL}`) + + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }) + + console.log( + '\n⚠️ WARNING: This migration may overwrite files in the target project if they have the same paths.' + ) + console.log('⚠️ It is recommended to back up your target project before proceeding.') + + const answer = await new Promise((resolve) => { + readline.question('Do you want to proceed with the migration? (yes/no): ', resolve) + }) + + readline.close() + + if (answer.toLowerCase() !== 'yes') { + console.log('Migration canceled by user.') + return { canceled: true } + } + + console.log('\n📦 Fetching all buckets from old project...') + + const { data: oldBuckets, error: bucketListError } = await oldSupabase.storage.listBuckets() + + if (bucketListError) throw new Error(`❌ Error fetching buckets: ${bucketListError.message}`) + console.log(`✅ Found ${oldBuckets.length} buckets to migrate.`) + + const { data: existingBuckets, error: existingBucketsError } = + await newSupabase.storage.listBuckets() + if (existingBucketsError) + throw new Error(`❌ Error fetching existing buckets: ${existingBucketsError.message}`) + + const existingBucketNames = existingBuckets.map((b) => b.name) + const conflictingBuckets = oldBuckets.filter((b) => existingBucketNames.includes(b.name)) + + let conflictStrategy = 2 + + if (conflictingBuckets.length > 0) { + console.log('\n⚠️ The following buckets already exist in the target project:') + conflictingBuckets.forEach((b) => console.log(` - ${b.name}`)) + + const conflictAnswer = await new Promise((resolve) => { + const rl = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }) + rl.question( + '\nHow do you want to handle existing buckets?\n' + + '1. Skip existing buckets\n' + + '2. Merge files (may overwrite existing files)\n' + + '3. Rename buckets in target (add suffix "_migrated")\n' + + '4. Cancel migration\n' + + 'Enter your choice (1-4): ', + (answer) => { + rl.close() + resolve(answer) + } + ) + }) + + if (conflictAnswer === '4') { + console.log('Migration canceled by user.') + return { canceled: true } + } + + conflictStrategy = parseInt(conflictAnswer) + if (isNaN(conflictStrategy) || conflictStrategy < 1 || conflictStrategy > 3) { + console.log('Invalid choice. Migration canceled.') + return { canceled: true } } } -})() + + const migrationStats = { + totalBuckets: oldBuckets.length, + processedBuckets: 0, + skippedBuckets: 0, + totalFiles: 0, + successfulFiles: 0, + failedFiles: 0, + failedFilesList: [], + } + + for (const bucket of oldBuckets) { + const bucketName = bucket.name + console.log(`\n📁 Processing bucket: ${bucketName}`) + + let targetBucketName = bucketName + + if (existingBucketNames.includes(bucketName)) { + if (conflictStrategy === 1) { + console.log(`⏩ Skipping bucket '${bucketName}' as it already exists in target project`) + migrationStats.skippedBuckets++ + continue + } else if (conflictStrategy === 3) { + targetBucketName = `${bucketName}_migrated` + console.log(`🔄 Renaming bucket to '${targetBucketName}' in target project`) + } else { + console.log(`🔄 Merging files into existing bucket '${bucketName}' in target project`) + } + } + + // Preserve bucket configuration when creating in the new project + if (targetBucketName !== bucketName || !existingBucketNames.includes(bucketName)) { + await ensureBucketExists(targetBucketName, { + public: bucket.public, + fileSizeLimit: bucket.file_size_limit, + allowedMimeTypes: bucket.allowed_mime_types, + }) + } + + const files = await listAllFiles(bucketName) + console.log(`✅ Found ${files.length} files in bucket '${bucketName}'.`) + migrationStats.totalFiles += files.length + + const batches = chunkArray(files, 10) + + for (let i = 0; i < batches.length; i++) { + console.log(`\n🚀 Processing batch ${i + 1}/${batches.length} (${batches[i].length} files)`) + + const results = await Promise.all( + batches[i].map((file) => migrateFile(bucketName, targetBucketName, file)) + ) + + const batchSuccesses = results.filter((r) => r.success).length + const batchFailures = results.filter((r) => !r.success) + + migrationStats.successfulFiles += batchSuccesses + migrationStats.failedFiles += batchFailures.length + migrationStats.failedFilesList.push(...batchFailures.map((f) => f.path)) + + console.log( + `✅ Completed batch ${i + 1}/${batches.length}: ${batchSuccesses} succeeded, ${batchFailures.length} failed` + ) + } + + migrationStats.processedBuckets++ + console.log(`✅ Completed bucket '${bucketName}' migration`) + } + + console.log('\n📊 Migration Summary:') + console.log( + `Buckets: ${migrationStats.processedBuckets}/${migrationStats.totalBuckets} processed, ${migrationStats.skippedBuckets} skipped` + ) + console.log( + `Files: ${migrationStats.successfulFiles} succeeded, ${migrationStats.failedFiles} failed (${migrationStats.totalFiles} total)` + ) + + if (migrationStats.failedFiles > 0) { + console.log('\n⚠️ Failed files:') + migrationStats.failedFilesList.forEach((path) => console.log(` - ${path}`)) + return migrationStats + } + + return migrationStats +} + +migrateBuckets() + .then((stats) => { + if (stats.failedFiles > 0) { + console.log(`\n⚠️ Migration completed with ${stats.failedFiles} failed files.`) + process.exit(1) + } else { + console.log('\n🎉 Migration completed successfully!') + process.exit(0) + } + }) + .catch((err) => { + console.error('❌ Fatal error during migration:', err.message) + process.exit(1) + }) ``` From 1cde4de0dc894a6643669d38530a7c9ac8dcbb00 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Thu, 19 Mar 2026 19:44:31 +0100 Subject: [PATCH 13/56] =?UTF-8?q?fix(Docs):=20Expose=20errors=20on=20dynam?= =?UTF-8?q?ically=20fetch=20content=20under=20database=20=E2=80=A6=20(#439?= =?UTF-8?q?76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../database/database-advisors/page.tsx | 48 +++++++++++++------ .../extensions/wrappers/[[...slug]]/page.tsx | 20 ++++++-- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/apps/docs/app/guides/database/database-advisors/page.tsx b/apps/docs/app/guides/database/database-advisors/page.tsx index bba7532c3de..aedf3e3396b 100644 --- a/apps/docs/app/guides/database/database-advisors/page.tsx +++ b/apps/docs/app/guides/database/database-advisors/page.tsx @@ -146,18 +146,27 @@ const urlTransform: (lints: Array<{ path: string }>) => UrlTransformFunction = ( const getLints = async () => { const octokit = new Octokit({ request: { fetch: fetchRevalidatePerDay } }) - const response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { - owner: org, - repo: repo, - path: docsDir, - ref: branch, - headers: { - 'X-GitHub-Api-Version': '2022-11-28', - }, - }) + let response: Awaited> + try { + response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { + owner: org, + repo: repo, + path: docsDir, + ref: branch, + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + } catch (err) { + throw new Error( + `Failed to fetch database advisors lint list from GitHub (network error): ${err}` + ) + } if (response.status >= 400) { - throw Error(`Could not get contents of repo ${org}/${repo}`) + throw new Error( + `Failed to fetch ${org}/${repo}/contents/${docsDir} docs from GitHub: ${response.status}` + ) } if (!Array.isArray(response.data)) { @@ -170,12 +179,21 @@ const getLints = async () => { const lints = await Promise.all( lintsList.map(async ({ path }) => { - const fileResponse = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${path}` - ) + let fileResponse: Response + try { + fileResponse = await fetchRevalidatePerDay( + `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${path}` + ) + } catch (err) { + throw new Error( + `Failed to fetch database advisors lint file ${path} from GitHub (network error): ${err}` + ) + } - if (fileResponse.status >= 400) { - throw Error(`Could not get contents of file ${org}/${repo}/${path}`) + if (response.status >= 400) { + throw new Error( + `Failed to fetch ${org}/${repo}/${branch}/${path} docs from GitHub: ${response.status}` + ) } const content = await fileResponse.text() diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx index dae36f2de1e..475818f2e8e 100644 --- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx @@ -360,10 +360,22 @@ const getContent = async (params: Params) => { const repoPath = `${org}/${repo}/${tag}/${docsDir}/${remoteFile}` editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}` - const response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, { - cache: 'force-cache', - next: { tags: [REVALIDATION_TAGS.WRAPPERS] }, - }) + let response: Response + try { + response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, { + cache: 'force-cache', + next: { tags: [REVALIDATION_TAGS.WRAPPERS] }, + }) + } catch (err) { + throw new Error(`Failed to fetch wrappers docs from GitHub (network error): ${err}`) + } + + if (!response.ok) { + throw new Error( + `Failed to fetch wrappers docs from GitHub: ${response.status} ${response.statusText}` + ) + } + const rawContent = await response.text() assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/` From 04d08fbba7f0afa462de036afad33f9b10dd16cd Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Thu, 19 Mar 2026 19:45:15 +0100 Subject: [PATCH 14/56] fix(Docs): Add cache-control and default to HTML content (#43989) --- apps/docs/app/api/guides-md/[...slug]/route.ts | 5 ++++- apps/docs/components/GuidesSidebar.tsx | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/docs/app/api/guides-md/[...slug]/route.ts b/apps/docs/app/api/guides-md/[...slug]/route.ts index f09a7854f25..b942ff2f73c 100644 --- a/apps/docs/app/api/guides-md/[...slug]/route.ts +++ b/apps/docs/app/api/guides-md/[...slug]/route.ts @@ -14,7 +14,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu try { const content = await fs.readFile(filePath, 'utf-8') return new NextResponse(content, { - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600', + }, }) } catch { return new NextResponse('Not found', { status: 404 }) diff --git a/apps/docs/components/GuidesSidebar.tsx b/apps/docs/components/GuidesSidebar.tsx index 7945943892a..544efe3fe2f 100644 --- a/apps/docs/components/GuidesSidebar.tsx +++ b/apps/docs/components/GuidesSidebar.tsx @@ -30,7 +30,15 @@ function AiTools({ className }: { className?: string }) { try { const res = await fetch(mdUrl) - const text = await res.text() + let text: string + + if (res.ok) { + text = await res.text() + } else { + // Default to HTML content within the article when no .md file is available. + text = document.getElementById('sb-docs-guide-main-article')?.innerHTML ?? '' + } + await navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) From b14ef270b296a89c7d78b4c00d07f5d7afc999f9 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 19 Mar 2026 13:07:05 -0600 Subject: [PATCH 15/56] feat: add shortcuts for copy markdown, json or download CSV (#43929) ## 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? - Add copy markdown as a keyboard extension - Add copy json as keyboard extension - Add download CSV as keyboard extension All extensions can be toggled off ## Demo https://github.com/user-attachments/assets/c3390b0a-8350-4708-9aeb-7b73aa3b423c --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Account/Preferences/HotkeySettings.tsx | 160 ++++++++---------- .../Account/Preferences/HotkeyToggle.tsx | 51 ++++++ .../UtilityPanel/Results.utils.test.ts | 154 +++++++++++++++++ .../SQLEditor/UtilityPanel/Results.utils.ts | 48 ++++++ .../components/ui/DownloadResultsButton.tsx | 120 ++++++++----- apps/studio/hooks/ui/useHotKey.ts | 19 ++- packages/common/constants/local-storage.ts | 4 + 7 files changed, 410 insertions(+), 146 deletions(-) create mode 100644 apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts diff --git a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx index 669cce3c2ab..9666307ecb1 100644 --- a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx @@ -1,20 +1,9 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import * as z from 'zod' - import { LOCAL_STORAGE_KEYS } from 'common' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { - Card, - CardContent, - FormControl_Shadcn_, - FormField_Shadcn_, - Form_Shadcn_, - KeyboardShortcut, - Switch, -} from 'ui' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { useForm } from 'react-hook-form' +import { Card, Form_Shadcn_ } from 'ui' import { PageSection, PageSectionContent, @@ -23,11 +12,17 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import * as z from 'zod' + +import { HotkeyToggle } from './HotkeyToggle' const HotkeySchema = z.object({ commandMenuEnabled: z.boolean(), aiAssistantEnabled: z.boolean(), inlineEditorEnabled: z.boolean(), + copyMarkdownEnabled: z.boolean(), + copyJsonEnabled: z.boolean(), + downloadCsvEnabled: z.boolean(), }) export const HotkeySettings = () => { @@ -43,6 +38,18 @@ export const HotkeySettings = () => { LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), true ) + const [copyMarkdownEnabled, setCopyMarkdownEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.HOTKEY_COPY_MARKDOWN, + true + ) + const [copyJsonEnabled, setCopyJsonEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.HOTKEY_COPY_JSON, + true + ) + const [downloadCsvEnabled, setDownloadCsvEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.HOTKEY_DOWNLOAD_CSV, + true + ) const form = useForm>({ resolver: zodResolver(HotkeySchema), @@ -50,6 +57,9 @@ export const HotkeySettings = () => { commandMenuEnabled: commandMenuEnabled ?? true, aiAssistantEnabled: aiAssistantEnabled ?? true, inlineEditorEnabled: inlineEditorEnabled ?? true, + copyMarkdownEnabled: copyMarkdownEnabled ?? true, + copyJsonEnabled: copyJsonEnabled ?? true, + downloadCsvEnabled: downloadCsvEnabled ?? true, }, }) @@ -66,87 +76,49 @@ export const HotkeySettings = () => { - - ( - - - Command menu -
- } - > - - { - field.onChange(value) - setCommandMenuEnabled(value) - }} - /> - - - )} - /> - - - ( - - - AI Assistant Panel -
- } - > - - { - field.onChange(value) - setAiAssistantEnabled(value) - }} - /> - - - )} - /> - - - ( - - - Inline SQL Editor Panel - - } - > - - { - field.onChange(value) - setInlineEditorEnabled(value) - }} - /> - - - )} - /> - + + + + + + diff --git a/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx b/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx new file mode 100644 index 00000000000..5577ed76731 --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx @@ -0,0 +1,51 @@ +import type { FieldValues, Path, UseFormReturn } from 'react-hook-form' +import { CardContent, FormControl_Shadcn_, FormField_Shadcn_, KeyboardShortcut, Switch } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +interface HotkeyToggleProps { + form: UseFormReturn + name: Path + keys: string[] + label: string + onToggle: (value: boolean) => void + isLast?: boolean +} + +export function HotkeyToggle({ + form, + name, + keys, + label, + onToggle, + isLast, +}: HotkeyToggleProps) { + return ( + + ( + + + {label} + + } + > + + { + field.onChange(value) + onToggle(value) + }} + /> + + + )} + /> + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts new file mode 100644 index 00000000000..f1fb5d068fa --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' + +import { + convertResultsToCSV, + convertResultsToJSON, + convertResultsToMarkdown, + formatResults, + getResultsHeaders, +} from './Results.utils' + +describe('Results.utils', () => { + describe('formatResults', () => { + it('should stringify object values', () => { + const results = [{ id: 1, data: { nested: true } }] + const formatted = formatResults(results) + expect(formatted).toEqual([{ id: 1, data: '{"nested":true}' }]) + }) + + it('should stringify array values', () => { + const results = [{ id: 1, tags: ['a', 'b'] }] + const formatted = formatResults(results) + expect(formatted).toEqual([{ id: 1, tags: '["a","b"]' }]) + }) + + it('should stringify null values', () => { + const results = [{ id: 1, value: null }] + const formatted = formatResults(results) + expect(formatted).toEqual([{ id: 1, value: 'null' }]) + }) + + it('should leave primitive values unchanged', () => { + const results = [{ name: 'test', count: 42, active: true }] + const formatted = formatResults(results) + expect(formatted).toEqual([{ name: 'test', count: 42, active: true }]) + }) + + it('should return empty array for empty input', () => { + expect(formatResults([])).toEqual([]) + }) + }) + + describe('convertResultsToMarkdown', () => { + it('should return undefined for empty results', () => { + expect(convertResultsToMarkdown([])).toBeUndefined() + }) + + it('should convert results to a markdown table', () => { + const results = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ] + const md = convertResultsToMarkdown(results) + expect(md).toContain('| id | name |') + expect(md).toContain('| 1 | Alice |') + expect(md).toContain('| 2 | Bob |') + }) + + it('should stringify nested objects in markdown output', () => { + const results = [{ id: 1, meta: { role: 'admin' } }] + const md = convertResultsToMarkdown(results) + expect(md).toContain('{"role":"admin"}') + }) + }) + + describe('convertResultsToJSON', () => { + it('should return undefined for empty results', () => { + expect(convertResultsToJSON([])).toBeUndefined() + }) + + it('should return formatted JSON string', () => { + const results = [{ id: 1, name: 'Alice' }] + const json = convertResultsToJSON(results) + expect(json).toBe(JSON.stringify(results, null, 2)) + }) + + it('should preserve nested object structure', () => { + const results = [{ id: 1, meta: { role: 'admin' } }] + const json = convertResultsToJSON(results) + const parsed = JSON.parse(json!) + expect(parsed[0].meta.role).toBe('admin') + }) + }) + + describe('getResultsHeaders', () => { + it('should return undefined for empty results', () => { + expect(getResultsHeaders([])).toBeUndefined() + }) + + it('should return keys from the first row', () => { + const results = [{ id: 1, name: 'Alice' }] + expect(getResultsHeaders(results)).toEqual(['id', 'name']) + }) + + it('should preserve key order from the first row', () => { + const results = [{ z: 1, a: 2, m: 3 }] + expect(getResultsHeaders(results)).toEqual(['z', 'a', 'm']) + }) + + it('should use only the first row for headers', () => { + const results = [{ id: 1 }, { id: 2, extra: 'bonus' }] + expect(getResultsHeaders(results)).toEqual(['id']) + }) + }) + + describe('convertResultsToCSV', () => { + it('should return undefined for empty results', () => { + expect(convertResultsToCSV([])).toBeUndefined() + }) + + it('should include header row from first result keys', () => { + const results = [{ id: 1, name: 'Alice' }] + const csv = convertResultsToCSV(results) + expect(csv).toContain('id,name') + }) + + it('should include data rows', () => { + const results = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ] + const csv = convertResultsToCSV(results) + expect(csv).toContain('Alice') + expect(csv).toContain('Bob') + }) + + it('should stringify object values', () => { + const results = [{ id: 1, meta: { role: 'admin' } }] + const csv = convertResultsToCSV(results) + // CSV wraps values containing special chars in quotes and escapes inner quotes + expect(csv).toContain('"{""role"":""admin""}"') + }) + + it('should stringify array values', () => { + const results = [{ id: 1, tags: ['a', 'b'] }] + const csv = convertResultsToCSV(results) + // CSV wraps values containing special chars in quotes and escapes inner quotes + expect(csv).toContain('"[""a"",""b""]"') + }) + + it('should stringify null values', () => { + const results = [{ id: 1, value: null }] + const csv = convertResultsToCSV(results) + expect(csv).toContain('null') + }) + + it('should preserve column order from first row', () => { + const results = [{ z: 1, a: 2, m: 3 }] + const csv = convertResultsToCSV(results) + // split on CRLF line endings produced by papaparse + const headerRow = csv!.split('\r\n')[0] + expect(headerRow).toBe('z,a,m') + }) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts new file mode 100644 index 00000000000..3791f6c9c61 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts @@ -0,0 +1,48 @@ +import { markdownTable } from 'markdown-table' +import Papa from 'papaparse' + +type ResultRow = Record + +export function formatResults( + results: ResultRow[] +): Record[] { + return results.map((row) => { + const formatted: Record = {} + for (const key of Object.keys(row)) { + const value = row[key] + formatted[key] = + typeof value === 'object' ? JSON.stringify(value) : (value as string | number | boolean) + } + return formatted + }) +} + +export function convertResultsToMarkdown(results: ResultRow[]): string | undefined { + const formatted = formatResults(results) + if (formatted.length === 0) return undefined + + const columns = Object.keys(formatted[0]) + const rows = formatted.map((row) => columns.map((col) => String(row[col] ?? ''))) + const table = [columns, ...rows] + return markdownTable(table) +} + +export function convertResultsToJSON(results: ResultRow[]): string | undefined { + if (results.length === 0) return undefined + return JSON.stringify(results, null, 2) +} + +export function getResultsHeaders(results: ResultRow[]): string[] | undefined { + const firstRow = Array.from(results)[0] + if (firstRow) return Object.keys(firstRow) + return undefined +} + +export function convertResultsToCSV(results: ResultRow[]): string | undefined { + if (results.length === 0) return undefined + + const headers = getResultsHeaders(results) + const formatted = formatResults(results) + + return Papa.unparse(formatted, { columns: headers }) +} diff --git a/apps/studio/components/ui/DownloadResultsButton.tsx b/apps/studio/components/ui/DownloadResultsButton.tsx index 921e40b907e..ba1e9145e04 100644 --- a/apps/studio/components/ui/DownloadResultsButton.tsx +++ b/apps/studio/components/ui/DownloadResultsButton.tsx @@ -1,14 +1,17 @@ +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' +import { + convertResultsToCSV, + convertResultsToJSON, + convertResultsToMarkdown, +} from 'components/interfaces/SQLEditor/UtilityPanel/Results.utils' import saveAs from 'file-saver' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { useHotKey } from 'hooks/ui/useHotKey' import { ChevronDown, Copy, Download, Settings } from 'lucide-react' -import { markdownTable } from 'markdown-table' -import Papa from 'papaparse' +import Link from 'next/link' +import { usePathname } from 'next/navigation' import { useMemo } from 'react' import { toast } from 'sonner' -import Link from 'next/link' -import { useParams } from 'common' -import { usePathname } from 'next/navigation' -import { IS_PLATFORM } from 'common' - import { Button, copyToClipboard, @@ -16,6 +19,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + KeyboardShortcut, } from 'ui' interface DownloadResultsButtonProps { @@ -44,25 +48,19 @@ export const DownloadResultsButton = ({ const { ref } = useParams() const pathname = usePathname() const isLogs = pathname?.includes?.('/logs') ?? false - // [Joshen] Ensure JSON values are stringified for CSV and Markdown - const formattedResults = results.map((row) => { - const r = { ...row } - Object.keys(row).forEach((x) => { - if (typeof row[x] === 'object') r[x] = JSON.stringify(row[x]) - }) - return r - }) + const [copyMarkdownEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_MARKDOWN, true) + const [copyJsonEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_JSON, true) + const [downloadCsvEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_DOWNLOAD_CSV, true) - const headers = useMemo(() => { - if (results) { - const firstRow = Array.from(results)[0] - if (firstRow) return Object.keys(firstRow) - } - return undefined - }, [results]) + const isEmpty = useMemo(() => results.length === 0, [results]) const downloadAsCSV = () => { - const csv = Papa.unparse(formattedResults, { columns: headers }) + const csv = convertResultsToCSV(results) + if (!csv) { + toast('Results are empty') + return + } + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) saveAs(blob, `${fileName}.csv`) toast.success('Downloading results as CSV') @@ -70,35 +68,56 @@ export const DownloadResultsButton = ({ } const copyAsMarkdown = () => { - if (navigator) { - if (formattedResults.length == 0) toast('Results are empty') - - const columns = Object.keys(formattedResults[0]) - const rows = formattedResults.map((x) => { - let temp: any[] = [] - columns.forEach((col) => temp.push(x[col])) - return temp - }) - const table = [columns].concat(rows) - const markdownData = markdownTable(table) - - copyToClipboard(markdownData, () => { - toast.success('Copied results to clipboard') - onCopyAsMarkdown?.() - }) + const markdownData = convertResultsToMarkdown(results) + if (!markdownData) { + toast('Results are empty') + return } + copyToClipboard(markdownData, () => { + toast.success('Copied markdown to clipboard') + onCopyAsMarkdown?.() + }) } const copyAsJSON = () => { - if (navigator) { - if (results.length === 0) return toast('Results are empty') - copyToClipboard(JSON.stringify(results, null, 2), () => { - toast.success('Copied results to clipboard') - onCopyAsJSON?.() - }) + const jsonData = convertResultsToJSON(results) + if (!jsonData) { + toast('Results are empty') + return } + copyToClipboard(jsonData, () => { + toast.success('Copied JSON to clipboard') + onCopyAsJSON?.() + }) } + useHotKey( + (e) => { + e.preventDefault() + copyAsMarkdown() + }, + 'm', + { enabled: copyMarkdownEnabled ?? isEmpty, shift: true } + ) + + useHotKey( + (e) => { + e.preventDefault() + copyAsJSON() + }, + 'j', + { enabled: copyJsonEnabled ?? isEmpty, shift: true } + ) + + useHotKey( + (e) => { + e.preventDefault() + downloadAsCSV() + }, + 'd', + { enabled: downloadCsvEnabled ?? isEmpty, shift: true } + ) + return ( @@ -112,7 +131,7 @@ export const DownloadResultsButton = ({ {!iconOnly && text} - + {isLogs && IS_PLATFORM && ( @@ -124,14 +143,23 @@ export const DownloadResultsButton = ({

Copy as markdown

+ + +

Copy as JSON

+ + +
downloadAsCSV()}>

Download CSV

+ + +
diff --git a/apps/studio/hooks/ui/useHotKey.ts b/apps/studio/hooks/ui/useHotKey.ts index 41e470ca11c..60dbe097c6a 100644 --- a/apps/studio/hooks/ui/useHotKey.ts +++ b/apps/studio/hooks/ui/useHotKey.ts @@ -5,7 +5,7 @@ import { useLatest } from 'react-use' function useHotKey( callback: (e: KeyboardEvent) => void, key: string, - options?: { enabled?: boolean } + options?: { enabled?: boolean; shift?: boolean } ): void /** * @deprecated The `dependencies` parameter is deprecated. Use the overload without dependencies instead. @@ -14,27 +14,34 @@ function useHotKey( callback: (e: KeyboardEvent) => void, key: string, dependencies: unknown[], - options?: { enabled?: boolean } + options?: { enabled?: boolean; shift?: boolean } ): void function useHotKey( callback: (e: KeyboardEvent) => void, key: string, - dependenciesOrOptions?: unknown[] | { enabled?: boolean }, - options?: { enabled?: boolean } + dependenciesOrOptions?: unknown[] | { enabled?: boolean; shift?: boolean }, + options?: { enabled?: boolean; shift?: boolean } ): void { // Determine which overload was called const isDepsArray = Array.isArray(dependenciesOrOptions) const resolvedOptions = isDepsArray ? options : dependenciesOrOptions const enabled = resolvedOptions?.enabled ?? true + const shift = resolvedOptions?.shift ?? false const enabledRef = useLatest(enabled) const callbackRef = useLatest(callback) const keyRef = useLatest(key) + const shiftRef = useLatest(shift) useEffect(() => { function handler(e: KeyboardEvent) { if (!enabledRef.current) return - if ((e.metaKey || e.ctrlKey) && e.key === keyRef.current && !e.altKey && !e.shiftKey) { + if ( + (e.metaKey || e.ctrlKey) && + e.key.toLowerCase() === keyRef.current.toLowerCase() && + !e.altKey && + (shiftRef.current ? e.shiftKey : !e.shiftKey) + ) { callbackRef.current(e) } } @@ -43,7 +50,7 @@ function useHotKey( return () => { window.removeEventListener('keydown', handler, true) } - }, [callbackRef, enabledRef, keyRef]) + }, [callbackRef, enabledRef, keyRef, shiftRef]) } export { useHotKey } diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 633b9d524ae..c912b18d118 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -97,6 +97,10 @@ export const LOCAL_STORAGE_KEYS = { // Project sidebar hotkeys HOTKEY_SIDEBAR: (sidebarId: string) => `supabase-dashboard-hotkey-sidebar-${sidebarId}`, + HOTKEY_COPY_MARKDOWN: 'supabase-dashboard-hotkey-copy-markdown', + HOTKEY_COPY_JSON: 'supabase-dashboard-hotkey-copy-json', + HOTKEY_DOWNLOAD_CSV: 'supabase-dashboard-hotkey-download-csv', + // Index Advisor notice dismissed INDEX_ADVISOR_NOTICE_DISMISSED: (ref: string) => `index-advisor-notice-dismissed-${ref}`, From 3b94d12a2dc3eb8d1203161d345440077ef7d500 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 19 Mar 2026 13:12:51 -0600 Subject: [PATCH 16/56] fix: updated to show functions count (#43988) ## 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? chore: add functions count for easier use ## Demo image --- apps/studio/pages/project/[ref]/functions/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/studio/pages/project/[ref]/functions/index.tsx b/apps/studio/pages/project/[ref]/functions/index.tsx index d3cd3f47005..763ed65e1a1 100644 --- a/apps/studio/pages/project/[ref]/functions/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/index.tsx @@ -128,6 +128,11 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
+ + {search && filteredFunctions.length !== functions.length + ? `Viewing ${filteredFunctions.length} of ${functions.length} functions in total` + : `Viewing ${functions.length} ${functions.length === 1 ? 'function' : 'functions'} in total`} + From 1e12f93fdecd8fc3381cfdeb8bffe158e2e81287 Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:35:09 +0000 Subject: [PATCH 17/56] Updated webinar pages with YouTube recording links (#44003) ## 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? Updated webinar landing pages with recording of the event on YouTube --- ...ency-webinar-ai-prototyping-production.mdx | 20 +++++++++++++------ .../_go/webinar/agency-webinar-mar2026.tsx | 3 +-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx b/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx index a32ecdb104e..88abffa753d 100644 --- a/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx +++ b/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx @@ -13,11 +13,9 @@ duration: 45 mins categories: - webinar main_cta: - { - url: 'https://attendee.gotowebinar.com/register/5606981989518150747', - target: '_blank', - label: 'Register now', - } + url: '#recording' + target: _self + label: Watch the recording speakers: 'seth_kramer,dave_wilson,zimo_holdijk,harry_roper,victor_orozco' --- @@ -25,6 +23,16 @@ AI coding tools have made prototyping almost instant. Agencies now send working Join agency leaders and Supabase as they share how top agencies balance velocity with control. Learn when to let AI move fast, and where experienced developers still need to step in. +
+