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 && ( <> -
- -
-
- {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

+
+ ) : ( +
+ +
+
+ {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} -
-
- ))} -
-
-
-
-
- ))} -
-
- - {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}` + +
+
+
+
+ ))} +
+
+ + {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.

- -
-
+ +
+
Supabase logo Preview { 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.

-
- -
+
+ +
- - -
-
+
+
Connect Supabase Button { 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()