diff --git a/.github/workflows/studio-lint-ratchet-decrease.yml b/.github/workflows/studio-lint-ratchet-decrease.yml
new file mode 100644
index 0000000000000..25fcf61b09228
--- /dev/null
+++ b/.github/workflows/studio-lint-ratchet-decrease.yml
@@ -0,0 +1,80 @@
+name: Decrease studio lint ratchet baselines
+
+on:
+ schedule:
+ - cron: '0 0 * * SUN'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ decrease-baselines:
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ sparse-checkout: |
+ .github
+ apps/studio
+ packages
+
+ - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
+ name: Install pnpm
+ with:
+ run_install: false
+
+ - name: Use Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version-file: '.nvmrc'
+ cache: 'pnpm'
+
+ - name: Install deps
+ run: pnpm install --frozen-lockfile
+
+ - name: Decrease ESLint ratchet baselines and open PR
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -eo pipefail
+ DEFAULT_BRANCH=${DEFAULT_BRANCH:-master}
+
+ BRANCH="bot/decrease-eslint-ratchet-baselines"
+
+ git fetch origin "$DEFAULT_BRANCH" --depth=1
+ if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then
+ git fetch origin "$BRANCH":"$BRANCH" --depth=1
+ git switch "$BRANCH"
+ git reset --hard "origin/$DEFAULT_BRANCH"
+ else
+ git switch --create "$BRANCH" "origin/$DEFAULT_BRANCH"
+ fi
+
+ pnpm --filter studio run lint:ratchet --decrease-baselines
+
+ if git diff --quiet; then
+ echo "No baseline updates detected."
+ exit 0
+ fi
+
+ git config user.name 'github-actions[bot]'
+ git config user.email 'github-actions[bot]@users.noreply.github.com'
+
+ git add apps/studio/.github/eslint-rule-baselines.json
+ git commit --message "chore: decrease ESLint ratchet baselines"
+ git push --force origin "$BRANCH"
+
+ pr_url=$(gh pr list --state open --head "$BRANCH" --json url --jq '.[0].url // ""' 2>/dev/null || echo "")
+ if [ -z "$pr_url" ]; then
+ gh pr create \
+ --title "[bot] Decrease ESLint ratchet baselines" \
+ --body "Automated weekly decrease of ESLint ratchet baselines." \
+ --base "$DEFAULT_BRANCH" \
+ --head "$BRANCH"
+ else
+ gh pr comment "$pr_url" --body "Updated ESLint ratchet baselines with the latest weekly decreases."
+ fi
diff --git a/.github/workflows/studio-lint-ratchet.yml b/.github/workflows/studio-lint-ratchet.yml
new file mode 100644
index 0000000000000..b998adef3172b
--- /dev/null
+++ b/.github/workflows/studio-lint-ratchet.yml
@@ -0,0 +1,45 @@
+name: Ratchet studio lint checks
+
+on:
+ pull_request:
+ branches:
+ - master
+ paths:
+ - 'apps/studio/**'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ ratchet:
+ # Uses larger hosted runner as it significantly decreases build times
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ sparse-checkout: |
+ .github
+ apps/studio
+ packages
+
+ - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
+ name: Install pnpm
+ with:
+ run_install: false
+
+ - name: Use Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version-file: '.nvmrc'
+ cache: 'pnpm'
+
+ - name: Install deps
+ run: pnpm install --frozen-lockfile
+
+ - name: Run ratchet script
+ run: pnpm --filter studio run lint:ratchet
diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json
new file mode 100644
index 0000000000000..b5b413c54cf17
--- /dev/null
+++ b/apps/studio/.github/eslint-rule-baselines.json
@@ -0,0 +1,8 @@
+{
+ "rules": {
+ "react-hooks/exhaustive-deps": 238,
+ "import/no-anonymous-default-export": 62,
+ "@tanstack/query/exhaustive-deps": 19,
+ "@tanstack/query/no-deprecated-options": 2
+ }
+}
diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx
index ad4a8d06546cc..c960551a749c1 100644
--- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx
+++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx
@@ -1,12 +1,14 @@
+import type { PostgresTable } from '@supabase/postgres-meta'
import { ArrowRight } from 'lucide-react'
import type { PropsWithChildren } from 'react'
import type { RenderCellProps } from 'react-data-grid'
import { convertByteaToHex } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { isTableLike } from 'data/table-editor/table-editor-types'
-import { useTablesQuery } from 'data/tables/tables-query'
+import { useTablesQuery as useTableRetrieveQuery } from 'data/tables/table-retrieve-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
import type { SupaRow } from '../../types'
@@ -21,7 +23,7 @@ export const ForeignKeyFormatter = (props: Props) => {
const { tableId, row, column } = props
const { data: project } = useSelectedProjectQuery()
- const { data } = useTableEditorQuery({
+ const { data, isLoading } = useTableEditorQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
id: tableId,
@@ -35,17 +37,22 @@ export const ForeignKeyFormatter = (props: Props) => {
r.source_table_name === selectedTable?.name &&
r.source_column_name === column.name
)
- const { data: tables } = useTablesQuery({
- projectRef: project?.ref,
- includeColumns: true,
- connectionString: project?.connectionString,
- schema: relationship?.target_table_schema,
- })
- const targetTable = tables?.find(
- (table) =>
- table.schema === relationship?.target_table_schema &&
- table.name === relationship.target_table_name
- )
+
+ const { data: targetTable, isLoading: isLoadingTargetTable } =
+ useTableRetrieveQuery(
+ {
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ schema: relationship?.target_table_schema ?? '',
+ name: relationship?.target_table_name ?? '',
+ },
+ {
+ enabled:
+ !!project?.ref &&
+ !!relationship?.target_table_schema &&
+ !!relationship?.target_table_name,
+ }
+ )
const value = row[column.key]
const formattedValue =
@@ -56,25 +63,39 @@ export const ForeignKeyFormatter = (props: Props) => {
{formattedValue === null ? : formattedValue}
- {relationship !== undefined && targetTable !== undefined && formattedValue !== null && (
-
-
- }
- onClick={(e) => e.stopPropagation()}
- tooltip={{ content: { side: 'bottom', text: 'View referencing record' } }}
- />
-
-
-
-
-
+ {isLoading && formattedValue !== null && (
+
+
+
+ )}
+ {!isLoading && relationship !== undefined && formattedValue !== null && (
+ <>
+ {isLoadingTargetTable && (
+
+
+
+ )}
+ {!isLoadingTargetTable && targetTable !== undefined && (
+
+
+ }
+ onClick={(e) => e.stopPropagation()}
+ tooltip={{ content: { side: 'bottom', text: 'View referencing record' } }}
+ />
+
+
+
+
+
+ )}
+ >
)}
)
diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx
index c0d67fe41931e..516e60b211136 100644
--- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx
+++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx
@@ -45,6 +45,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
const isEmailAuth = user.email !== null
const isPhoneAuth = user.phone !== null
const isBanned = user.banned_until !== null
+ const isVerified = user.confirmed_at != null
const { authenticationSignInProviders } = useIsFeatureEnabled([
'authentication:sign_in_providers',
@@ -110,10 +111,18 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
const { mutate: sendMagicLink, isLoading: isSendingMagicLink } = useUserSendMagicLinkMutation({
onSuccess: (_, vars) => {
setSuccessAction('send_magic_link')
- toast.success(`Sent magic link to ${vars.user.email}`)
+ toast.success(
+ isVerified
+ ? `Sent magic link to ${vars.user.email}`
+ : `Sent confirmation email to ${vars.user.email}`
+ )
},
onError: (err) => {
- toast.error(`Failed to send magic link: ${err.message}`)
+ toast.error(
+ isVerified
+ ? `Failed to send magic link: ${err.message}`
+ : `Failed to send confirmation email: ${err.message}`
+ )
},
})
const { mutate: sendOTP, isLoading: isSendingOTP } = useUserSendOTPMutation({
@@ -294,11 +303,15 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
}
/>
,
- text: 'Send magic link',
+ text: isVerified ? 'Send magic link' : 'Send confirmation email',
isLoading: isSendingMagicLink,
disabled: !canSendMagicLink,
onClick: () => {
@@ -308,8 +321,10 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
success={
successAction === 'send_magic_link'
? {
- title: 'Magic link sent',
- description: `The link in the email is valid for ${formattedExpiry}`,
+ title: isVerified ? 'Magic link sent' : 'Confirmation email sent',
+ description: isVerified
+ ? `The link in the email is valid for ${formattedExpiry}`
+ : 'The confirmation email has been sent to the user',
}
: undefined
}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx
index 9d6819c1224f4..22fbdeb0b26ee 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx
@@ -47,7 +47,7 @@ const ColumnForeignKey = ({
return {
id: c.id,
name: c.name,
- format: column.format || c.format,
+ format: c.format || column.format,
isNewColumn: false,
}
})
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx
index 810f962e95399..be7eee6e8d13c 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx
@@ -1,6 +1,6 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import { sortBy } from 'lodash'
-import { ArrowRight, Database, HelpCircle, Table, X } from 'lucide-react'
+import { ArrowRight, Database, HelpCircle, Loader2, Table, X } from 'lucide-react'
import { Fragment, useEffect, useState } from 'react'
import {
AlertDescription_Shadcn_,
@@ -16,6 +16,7 @@ import InformationBox from 'components/ui/InformationBox'
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useTablesQuery } from 'data/tables/tables-query'
+import { useTablesQuery as useTableRetrieveQuery } from 'data/tables/table-retrieve-query'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
@@ -69,16 +70,27 @@ export const ForeignKeySelector = ({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
- const { data: tables } = useTablesQuery({
+ const { data: tables } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: fk.schema,
- includeColumns: true,
+ includeColumns: false,
})
- const selectedTable = (tables ?? []).find((x) => x.name === fk.table && x.schema === fk.schema)
+ const { data: selectedTable, isLoading: isLoadingSelectedTable } =
+ useTableRetrieveQuery(
+ {
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ schema: fk.schema,
+ name: fk.table,
+ },
+ {
+ enabled: !!project?.ref && !!fk.schema && !!fk.table,
+ }
+ )
- const disableApply = selectedTable === undefined || hasTypeErrors
+ const disableApply = isLoadingSelectedTable || selectedTable === undefined || hasTypeErrors
const updateSelectedSchema = (schema: string) => {
const updatedFk = { ...EMPTY_STATE, id: fk.id, schema }
@@ -258,6 +270,7 @@ export const ForeignKeySelector = ({
label="Select a table to reference to"
value={selectedTable?.id ?? 1}
onChange={(value: string) => updateSelectedTable(Number(value))}
+ disabled={isLoadingSelectedTable}
>
---
@@ -284,42 +297,87 @@ export const ForeignKeySelector = ({
{fk.schema && fk.table && (
<>
-
-
- Select columns from{' '}
-
- {fk.schema}.{fk.table}
- {' '}
- to reference to
-
-
-
- {selectedSchema}.{table.name.length > 0 ? table.name : '[unnamed table]'}
-
-
- {fk.schema}.{fk.table}
-
- {fk.columns.length === 0 && (
-
-
- There are no foreign key relations between the tables
-
-
- )}
- {fk.columns.map((_, idx) => (
-
-
-
updateSelectedColumn(idx, 'source', value)}
- >
-
- ---
-
- {(table?.columns ?? [])
- .filter((x) => x.name.length !== 0)
- .map((column) => (
+ {isLoadingSelectedTable ? (
+
+
+
Loading table columns
+
+ ) : (
+
+
+ Select columns from{' '}
+
+ {fk.schema}.{fk.table}
+ {' '}
+ to reference to
+
+
+
+ {selectedSchema}.{table.name.length > 0 ? table.name : '[unnamed table]'}
+
+
+ {fk.schema}.{fk.table}
+
+ {fk.columns.length === 0 && (
+
+
+ There are no foreign key relations between the tables
+
+
+ )}
+ {fk.columns.map((_, idx) => (
+
+
+
updateSelectedColumn(idx, 'source', value)}
+ >
+
+ ---
+
+ {(table?.columns ?? [])
+ .filter((x) => x.name.length !== 0)
+ .map((column) => (
+
+
+ {column.name}
+
+ {column.format === '' ? '-' : column.format}
+
+
+
+ ))}
+
+
+
+
+
updateSelectedColumn(idx, 'target', value)}
+ >
+
+ ---
+
+ {(selectedTable?.columns ?? []).map((column) => (
{column.name}
-
- {column.format === '' ? '-' : column.format}
-
+ {column.format}
))}
-
-
-
-
-
updateSelectedColumn(idx, 'target', value)}
- >
-
- ---
-
- {(selectedTable?.columns ?? []).map((column) => (
-
-
- {column.name}
- {column.format}
-
-
- ))}
-
-
-
- }
- disabled={fk.columns.length === 1}
- onClick={() => onRemoveColumn(idx)}
- />
-
-
- ))}
-
-
-
- Add another column
-
- {errors.columns &&
{errors.columns}
}
- {hasTypeErrors && (
-
- Column types do not match
-
- The following columns cannot be referenced as they are not of the same type:
-
-
- {(errors?.types ?? []).map((x, idx: number) => {
- if (x === undefined) return null
- return (
-
- {fk.columns[idx]?.source} (
- {x.sourceType}) and{' '}
- {fk.columns[idx]?.target}(
- {x.targetType})
-
- )
- })}
-
-
- )}
- {hasTypeNotices && (
-
- Column types will be updated
-
- The following columns will have their types updated to match their
- referenced column
-
-
- {(errors?.typeNotice ?? []).map((x, idx: number) => {
- if (x === undefined) return null
- return (
-
-
-
{fk.columns[idx]?.source}{' '}
-
{x.targetType}
-
-
- )
- })}
-
-
- )}
-
-
-
-
-
- }
- title="Which action is most appropriate?"
- description={
- <>
-
- The choice of the action depends on what kinds of objects the related tables
- represent:
-
-
-
- Cascade: if the referencing table
- represents something that is a component of what is represented by the
- referenced table and cannot exist independently
-
-
- Restrict or{' '}
- No action: if the two tables represent
- independent objects
-
-
- Set NULL or{' '}
- Set default: if a foreign-key relationship
- represents optional information
-
-
-
- Typically, restricting and cascading deletes are the most common options, but
- the default behavior is no action
-
- >
- }
- url="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK"
- urlLabel="More information"
- />
-
-
- {generateCascadeActionDescription(
- 'update',
- fk.updateAction,
- `${fk.schema}.${fk.table}`
+
+
+
+ }
+ disabled={fk.columns.length === 1}
+ onClick={() => onRemoveColumn(idx)}
+ />
+
+
+ ))}
+
+
+
+ Add another column
+
+ {errors.columns &&
{errors.columns}
}
+ {hasTypeErrors && (
+
+ Column types do not match
+
+ The following columns cannot be referenced as they are not of the same
+ type:
+
+
+ {(errors?.types ?? []).map((x, idx: number) => {
+ if (x === undefined) return null
+ return (
+
+ {fk.columns[idx]?.source} (
+ {x.sourceType}) and{' '}
+ {fk.columns[idx]?.target}(
+ {x.targetType})
+
+ )
+ })}
+
+
+ )}
+ {hasTypeNotices && (
+
+ Column types will be updated
+
+ The following columns will have their types updated to match their
+ referenced column
+
+
+ {(errors?.typeNotice ?? []).map((x, idx: number) => {
+ if (x === undefined) return null
+ return (
+
+
+
{fk.columns[idx]?.source}{' '}
+
{x.targetType}
+
+
+ )
+ })}
+
+
)}
-
- }
- onChange={(value: string) => updateCascadeAction('updateAction', value)}
- >
- {FOREIGN_KEY_CASCADE_OPTIONS.filter((option) =>
- ['no-action', 'cascade', 'restrict'].includes(option.key)
- ).map((option) => (
-
- {option.label}
-
- ))}
-
-
-
- }
- descriptionText={
- <>
-
- {generateCascadeActionDescription(
- 'delete',
- fk.deletionAction,
- `${fk.schema}.${fk.table}`
- )}
-
- >
- }
- onChange={(value: string) => updateCascadeAction('deletionAction', value)}
- >
- {FOREIGN_KEY_CASCADE_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
+
+
+ )}
+
+ {!isLoadingSelectedTable && (
+ <>
+
+
+ }
+ title="Which action is most appropriate?"
+ description={
+ <>
+
+ The choice of the action depends on what kinds of objects the related
+ tables represent:
+
+
+
+ Cascade: if the referencing table
+ represents something that is a component of what is represented by the
+ referenced table and cannot exist independently
+
+
+ Restrict or{' '}
+ No action: if the two tables represent
+ independent objects
+
+
+ Set NULL or{' '}
+ Set default: if a foreign-key
+ relationship represents optional information
+
+
+
+ Typically, restricting and cascading deletes are the most common options,
+ but the default behavior is no action
+
+ >
+ }
+ url="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK"
+ urlLabel="More information"
+ />
+
+
+ {generateCascadeActionDescription(
+ 'update',
+ fk.updateAction,
+ `${fk.schema}.${fk.table}`
+ )}
+
+ }
+ onChange={(value: string) => updateCascadeAction('updateAction', value)}
+ >
+ {FOREIGN_KEY_CASCADE_OPTIONS.filter((option) =>
+ ['no-action', 'cascade', 'restrict'].includes(option.key)
+ ).map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ }
+ descriptionText={
+ <>
+
+ {generateCascadeActionDescription(
+ 'delete',
+ fk.deletionAction,
+ `${fk.schema}.${fk.table}`
+ )}
+
+ >
+ }
+ onChange={(value: string) => updateCascadeAction('deletionAction', value)}
+ >
+ {FOREIGN_KEY_CASCADE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ >
+ )}
>
)}
diff --git a/apps/studio/package.json b/apps/studio/package.json
index 294f7837d8f11..73d5238afb170 100644
--- a/apps/studio/package.json
+++ b/apps/studio/package.json
@@ -8,6 +8,7 @@
"build": "next build && ./../../scripts/upload-static-assets.sh",
"start": "next start",
"lint": "eslint .",
+ "lint:ratchet": "tsx scripts/ratchet-eslint-rules.ts --rule react-hooks/exhaustive-deps --rule import/no-anonymous-default-export --rule @tanstack/query/exhaustive-deps --rule @tanstack/query/no-deprecated-options",
"clean": "rimraf node_modules tsconfig.tsbuildinfo .next .turbo",
"test": "vitest --run --coverage",
"test:watch": "vitest watch",
diff --git a/apps/studio/pages/project/[ref]/merge.tsx b/apps/studio/pages/project/[ref]/merge.tsx
index 19f1749675791..27f237d8bf8f9 100644
--- a/apps/studio/pages/project/[ref]/merge.tsx
+++ b/apps/studio/pages/project/[ref]/merge.tsx
@@ -313,20 +313,6 @@ const MergePage: NextPageWithLayout = () => {
})
}
- const handleReadyForReview = () => {
- if (!ref || !parentProjectRef) return
- updateBranch(
- {
- branchRef: ref,
- projectRef: parentProjectRef,
- requestReview: true,
- },
- {
- onSuccess: () => toast.success('Successfully marked as ready for review'),
- }
- )
- }
-
const breadcrumbs = useMemo(
() => [
{
@@ -334,7 +320,7 @@ const MergePage: NextPageWithLayout = () => {
href: `/project/${project?.ref}/branches/merge-requests`,
},
],
- [parentProjectRef]
+ [project?.ref]
)
const currentTab = (router.query.tab as string) || 'database'
diff --git a/apps/studio/scripts/ratchet-eslint-rules.ts b/apps/studio/scripts/ratchet-eslint-rules.ts
new file mode 100644
index 0000000000000..09613141d43b8
--- /dev/null
+++ b/apps/studio/scripts/ratchet-eslint-rules.ts
@@ -0,0 +1,314 @@
+/* eslint-disable turbo/no-undeclared-env-vars */
+/**
+ * Ratchet ESLint violations for selected rules.
+ *
+ * Examples:
+ * # Initialize baselines for two rules
+ * tsx scripts/ratchet-eslint-rules.ts --init \
+ * --rule react-hooks/exhaustive-deps --rule no-console
+ *
+ * # Compare current counts vs baselines
+ * tsx scripts/ratchet-eslint-rules.ts \
+ * --rule react-hooks/exhaustive-deps --rule no-console
+ *
+ # Decrease baselines when improvements occur
+ * tsx scripts/ratchet-eslint-rules.ts \
+ * --rule react-hooks/exhaustive-deps --rule no-console \
+ * --decrease-baselines
+ *
+ * Flags:
+ * --metadata Path to baseline file (default .github/eslint-rule-baselines.json)
+ * --init Write current counts for the provided --rule(s) into metadata and exit 0
+ * --eslint "" ESLint command to run (default "npx eslint"). Do not pass untrusted input.
+ * --eslint-args "<...>" Extra args/paths for ESLint (e.g., "."). Do not pass untrusted input.
+ * --rule [,...] Rule id(s). Repeat flag or comma-separate. REQUIRED.
+ * --decrease-baselines When improvements occur, lower stored baselines to match the new counts.
+ *
+ * Notes:
+ * - Counts occurrences regardless of severity (warn/error).
+ * - Fails if any selected rule has currentCount > baselineCount.
+ */
+
+import { spawnSync } from 'node:child_process'
+import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+
+interface Args {
+ metadata: string
+ init: boolean
+ eslint: string
+ eslintArgs: string
+ decreaseBaselines: boolean
+ rules: string[]
+}
+
+interface ESLintMessage {
+ ruleId?: string | null
+}
+
+interface ESLintResult {
+ messages?: ESLintMessage[]
+}
+
+interface ESLintExecutionResult {
+ results: ESLintResult[]
+ stderr: string
+}
+
+interface BaselineData {
+ rules: Record
+}
+
+function parseArgs(argv: string[]): Args {
+ const args: Args = {
+ metadata: '.github/eslint-rule-baselines.json',
+ init: false,
+ eslint: 'npx eslint',
+ eslintArgs: '',
+ decreaseBaselines: false,
+ rules: [],
+ }
+
+ for (let i = 2; i < argv.length; i += 1) {
+ const a = argv[i]
+ if (a === '--init') {
+ args.init = true
+ } else if (a === '--metadata') {
+ args.metadata = argv[++i]
+ } else if (a === '--eslint') {
+ args.eslint = argv[++i]
+ } else if (a === '--eslint-args') {
+ args.eslintArgs = argv[++i]
+ } else if (a === '--rule') {
+ const val = (argv[++i] ?? '').trim()
+ if (val) {
+ args.rules.push(
+ ...val
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ )
+ }
+ } else if (a === '--decrease-baselines') {
+ args.decreaseBaselines = true
+ } else {
+ console.warn(`Unknown argument: ${a}`)
+ }
+ }
+
+ if (args.rules.length === 0) {
+ console.error('Error: You must provide at least one --rule .')
+ console.error('Example: --rule exhaustive-deps --rule no-console')
+ process.exit(2)
+ }
+
+ const dedupedRules = new Set(args.rules)
+ args.rules = Array.from(dedupedRules)
+
+ return args
+}
+
+/**
+ * SECURITY:
+ * Directly spawns a command from its arguments. Should not be called with
+ * untrusted input.
+ */
+function dangerouslyRunEsLint(eslintCmd: string, eslintArgs: string): ESLintExecutionResult {
+ const fullCmd = `${eslintCmd} ${eslintArgs || ''} --format json`.trim()
+ const proc = spawnSync(fullCmd, {
+ shell: true,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: process.env,
+ maxBuffer: 32 * 1024 * 1024, // allow large ESLint JSON payloads
+ })
+
+ const stdout = typeof proc.stdout === 'string' ? proc.stdout : ''
+ const stderr = typeof proc.stderr === 'string' ? proc.stderr : ''
+
+ if (!stdout.trim()) {
+ console.error('ESLint did not produce JSON output. stderr:\n', stderr)
+ process.exit(2)
+ }
+
+ let results: ESLintResult[]
+ try {
+ results = JSON.parse(stdout) as ESLintResult[]
+ } catch (e) {
+ console.error('Failed to parse ESLint JSON output:', e)
+ console.error('Raw output (truncated to 4k):\n', stdout.slice(0, 4096))
+ process.exit(2)
+ }
+
+ return { results, stderr }
+}
+
+function countRules(results: ESLintResult[], ruleIds: string[]): Record {
+ const checkedIds = new Set(ruleIds)
+ const counts: Record = {}
+
+ for (const id of ruleIds) {
+ counts[id] = 0
+ }
+
+ for (const file of results) {
+ if (!file || !Array.isArray(file.messages)) continue
+ for (const msg of file.messages) {
+ const id = msg?.ruleId ?? ''
+ if (id && checkedIds.has(id)) {
+ counts[id] += 1
+ }
+ }
+ }
+ return counts
+}
+
+function readBaselines(fp: string): BaselineData {
+ if (!existsSync(fp)) return { rules: {} }
+ try {
+ const data = JSON.parse(readFileSync(fp, 'utf8')) as Partial
+ if (data && typeof data === 'object' && data.rules && typeof data.rules === 'object') {
+ return { rules: data.rules }
+ }
+ } catch {
+ // ignore invalid metadata files and fall back to blank baselines
+ }
+ return { rules: {} }
+}
+
+function writeBaselines(fp: string, updates: Record, merge = true): void {
+ const dir = path.dirname(fp)
+ mkdirSync(dir, { recursive: true })
+
+ let current: BaselineData = { rules: {} }
+ if (merge && existsSync(fp)) {
+ current = readBaselines(fp)
+ }
+
+ const next: BaselineData = { rules: { ...current.rules, ...updates } }
+ writeFileSync(fp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
+}
+
+function writeSummary(markdown: string): void {
+ const summaryFile = process.env.GITHUB_STEP_SUMMARY
+ if (summaryFile) {
+ try {
+ appendFileSync(summaryFile, `${markdown}\n`, 'utf8')
+ } catch {
+ // ignore summary write errors because they shouldn't block the script
+ }
+ }
+}
+
+function main(): void {
+ const args = parseArgs(process.argv)
+
+ // SECURITY:
+ // Offloaded to user. Must document that they should not pass untrusted input
+ // via --eslint or --eslint-args.
+ const { results, stderr } = dangerouslyRunEsLint(args.eslint, args.eslintArgs)
+ const currentCounts = countRules(results, args.rules)
+
+ if (args.init) {
+ writeBaselines(args.metadata, currentCounts, true)
+
+ const rows = Object.entries(currentCounts)
+ .map(([rule, count]) => `| \`${rule}\` | **${count}** |`)
+ .join('\n')
+
+ writeSummary(
+ [
+ `### ESLint rule baselines initialized`,
+ `Metadata: \`${args.metadata}\``,
+ ``,
+ `| Rule | Baseline |`,
+ `| --- | ---: |`,
+ rows,
+ ``,
+ ].join('\n')
+ )
+
+ console.log(
+ `Initialized/updated baselines for: ${args.rules.join(', ')} (saved to ${args.metadata}).`
+ )
+ process.exit(0)
+ }
+
+ const baselines = readBaselines(args.metadata).rules || {}
+
+ const missing = args.rules.filter((r) => typeof baselines[r] !== 'number')
+ if (missing.length) {
+ const msg = `Missing baselines for: ${missing.join(', ')} in ${args.metadata}. Run with --init to set them.`
+ console.error(msg)
+ writeSummary(`### ESLint rule ratchet\n${msg}`)
+ console.log(`::error title=Missing baselines::${msg}`)
+ process.exit(2)
+ }
+
+ let failed = false
+ const tableRows: string[] = []
+ const improvedRules: string[] = []
+ const decreasedBaselines: Record = {}
+ for (const rule of args.rules) {
+ const baseline = baselines[rule] ?? 0
+ const current = currentCounts[rule] ?? 0
+ const delta = current - baseline
+
+ tableRows.push(
+ `| \`${rule}\` | **${baseline}** | **${current}** | ${delta >= 0 ? '+' : '-'}${delta} |`
+ )
+
+ if (current > baseline) {
+ failed = true
+ const delta = current - baseline
+ const msg = `You added ${delta === 1 ? 'a new violation' : `${delta} new violations`} of ${rule}. Please fix it: baseline=${baseline}, current=${current}`
+ console.error(msg)
+ console.log(`::error title=New violations::${msg}`)
+ } else if (current < baseline) {
+ improvedRules.push(rule)
+ if (args.decreaseBaselines) {
+ decreasedBaselines[rule] = { from: baseline, to: current }
+ }
+ }
+ }
+
+ const summaryLines = [
+ `### ESLint rule ratchet`,
+ `Metadata: \`${args.metadata}\``,
+ ``,
+ `| Rule | Baseline | Current | Δ |`,
+ `| --- | ---: | ---: | ---: |`,
+ ...tableRows,
+ ``,
+ ]
+
+ if (args.decreaseBaselines && Object.keys(decreasedBaselines).length > 0) {
+ const updates: Record = {}
+ const details: string[] = []
+ const logParts: string[] = []
+ for (const [rule, { from, to }] of Object.entries(decreasedBaselines)) {
+ updates[rule] = to
+ details.push(`- \`${rule}\`: ${from} -> ${to}`)
+ logParts.push(`${rule}: ${from} -> ${to}`)
+ }
+ writeBaselines(args.metadata, updates, true)
+ summaryLines.push('', 'Baselines decreased for improved rules:', ...details, '')
+ console.log(`Baselines decreased for improved rules: ${logParts.join(', ')}`)
+ }
+
+ writeSummary(summaryLines.join('\n'))
+
+ if (failed) {
+ if (stderr && stderr.trim()) console.error('\nESLint stderr:\n', stderr)
+ process.exit(1)
+ } else {
+ console.log(
+ improvedRules.length > 0
+ ? 'Nice! Some rules improved.'
+ : 'Stable: No regressions for selected rules.'
+ )
+ process.exit(0)
+ }
+}
+
+main()
diff --git a/apps/www/pages/brand-assets.tsx b/apps/www/pages/brand-assets.tsx
index eeaf911afba23..dbdf9347b9599 100644
--- a/apps/www/pages/brand-assets.tsx
+++ b/apps/www/pages/brand-assets.tsx
@@ -11,6 +11,7 @@ import { Download } from 'lucide-react'
import { NextSeo } from 'next-seo'
import Image from 'next/image'
import SectionContainer from '~/components/Layouts/SectionContainer'
+import Link from 'next/link'
const Index = () => {
// base path for images
@@ -38,18 +39,18 @@ const Index = () => {
-
-
Brand assets
-
Download official Supabase logos
-
+
Brand assets
+
Download official Supabase logos
+
+
All Supabase trademarks, logos, or other brand elements can never be modified or
used for any other purpose other than to represent Supabase Inc.
-
-
-
+
+
+
{
objectFit="cover"
/>
-
-
+
+
-
Supabase logos
-
-
- Download Supabase official logos, including as SVG's, in both light and dark
- theme.
-
-
Do not use any other color for the wordmark.
+
Supabase logos
+
+ Download Supabase official logos, including as SVG's, in both light and dark
+ theme.
+
+
+ Do not use any other color for the wordmark.
-
+
-
-
-
-
+
+
{
height={31}
/>
-
-
+
+
-
Supabase Integrations
-
-
- When building a{' '}
-
- Supabase Integration
-
- , use this "Connect Supabase" button to initiate the OAuth redirect.
-
-
Do not use any other color for the wordmark.
+
Supabase Integrations
+
+ When building a{' '}
+
+ Supabase Integration
+
+ , use this "Connect Supabase" button to initiate the OAuth redirect.
+
+
+ Do not use any other color for the wordmark.
-
+
diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts
index 3578160635684..0165c3e824ff7 100644
--- a/e2e/studio/features/table-editor.spec.ts
+++ b/e2e/studio/features/table-editor.spec.ts
@@ -231,7 +231,7 @@ test.describe.serial('table editor', () => {
page,
'pg-meta',
ref,
- 'tables?include_columns=true&included_schemas=public'
+ 'tables?include_columns=false&included_schemas=public'
) // wait for table creation
await page.getByRole('button', { name: `View ${tableNameRlsDisabled}` }).click()
await expect(page.getByRole('button', { name: 'RLS disabled' })).toBeVisible()