diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index b11aab55b23..fc1d9593c16 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -238,6 +238,7 @@ export const gettingstarted: NavMenuConstant = { { name: 'NuxtJS', url: '/guides/getting-started/quickstarts/nuxtjs' }, { name: 'Vue', url: '/guides/getting-started/quickstarts/vue' }, { name: 'Flutter', url: '/guides/getting-started/quickstarts/flutter' }, + { name: 'iOS SwiftUI', url: '/guides/getting-started/quickstarts/ios-swiftui' }, { name: 'Android Kotlin', url: '/guides/getting-started/quickstarts/kotlin', diff --git a/apps/docs/content/guides/database/extensions/postgis.mdx b/apps/docs/content/guides/database/extensions/postgis.mdx index 83e4057a62f..059060efa40 100644 --- a/apps/docs/content/guides/database/extensions/postgis.mdx +++ b/apps/docs/content/guides/database/extensions/postgis.mdx @@ -29,13 +29,17 @@ You can get started with PostGIS by enabling the PostGIS extension in your Supab 1. Go to the [Database](https://supabase.com/dashboard/project/_/database/tables) page in the Dashboard. 2. Click on **Extensions** in the sidebar. 3. Search for "postgis" and enable the extension. +4. In the confirmation prompt select "Create a new schema" and name it `gis` for example. ```sql +-- Create a dedicated separate schema +create schema if not exists "gis"; + -- Example: enable the "postgis" extension -create extension postgis with schema extensions; +create extension postgis with schema "gis"; -- Example: disable the "postgis" extension drop extension if exists postgis; diff --git a/apps/docs/content/guides/getting-started.mdx b/apps/docs/content/guides/getting-started.mdx index 5d59dbce971..bc458a709d7 100644 --- a/apps/docs/content/guides/getting-started.mdx +++ b/apps/docs/content/guides/getting-started.mdx @@ -131,6 +131,13 @@ hideToc: true 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Flutter app.', icon: '/docs/img/icons/flutter-icon', }, + { + title: 'iOS SwiftUI', + href: '/guides/getting-started/quickstarts/ios-swiftui', + description: + 'Learn how to create a Supabase project, add some sample data to your database, and query the data from an iOS app.', + icon: '/docs/img/icons/swift-icon', + }, { title: 'Android Kotlin', href: '/guides/getting-started/quickstarts/kotlin', diff --git a/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx b/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx new file mode 100644 index 00000000000..7a7199d3f78 --- /dev/null +++ b/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx @@ -0,0 +1,158 @@ +--- +title: 'Use Supabase with iOS and SwiftUI' +subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from an iOS app.' +breadcrumb: 'Framework Quickstarts' +hideToc: true +--- + + + + + + + [Create a new project](https://supabase.com/dashboard) in the Supabase Dashboard. + + After your project is ready, create a table in your Supabase database using the [SQL Editor](https://supabase.com/dashboard/project/_/sql) in the Dashboard. Use the following SQL statement to create a `countries` table with some sample data. + + + + + + ```sql SQL_EDITOR + -- Create the table + CREATE TABLE countries ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL + ); + -- Insert some sample data into the table + INSERT INTO countries (name) VALUES ('United States'); + INSERT INTO countries (name) VALUES ('Canada'); + INSERT INTO countries (name) VALUES ('Mexico'); + ```` + + + + + + + + + + Open Xcode > New Project > iOS > App. You can skip this step if you already have a working app. + + + + + + + + + + Install Supabase package dependency using Xcode by following Apple's [tutorial](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app). + + Make sure to add `Supabase` product package as dependency to the application. + + + + + + + + + + Create a new `Supabase.swift` file add a new supabase instance using your project URL and public API (anon) key: + + + + + + + + + ```swift Supabase.swift + import Supabase + + let supabase = SupabaseClient( + supabaseURL: URL(string: "YOUR_SUPABASE_URL")!, + supabaseKey: "YOUR_SUPABASE_ANON_KEY" + ) + ``` + + + + + + + + + + Create a decodable struct to deserialize the data from the database. + + Add the following code to a new file named `Country.swift`. + + + + + + ```swift Supabase.swift + struct Country: Decodable, Identifiable { + let id: Int + let name: String + } + ``` + + + + + + + + + + Use a `task` to fetch the data from the database and display it using a `List`. + + Replace the default `ContentView` with the following code. + + + + + + ```swift ContentView.swift + struct ContentView: View { + + @State var countries: [Country] = [] + + var body: some View { + List(countries) { country in + Text(country.name) + } + .overlay { + if countries.isEmpty { + ProgressView() + } + } + .task { + do { + countries = try await supabase.from("countries").select().execute().value + } catch { + dump(error) + } + } + } + } + ``` + + + + + + + + + Run the app on a simulator or a physical device by hitting `Cmd + R` on Xcode. + + + + + + diff --git a/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx b/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx index e26045321ce..7a71bc87124 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx @@ -272,7 +272,7 @@ struct AppView: View { } } .task { - for await state in await supabase.auth.authStateChanges { + for await state in supabase.auth.authStateChanges { if [.initialSession, .signedIn, .signedOut].contains(state.event) { isAuthenticated = state.session != nil } diff --git a/apps/docs/content/guides/platform/migrating-and-upgrading-projects.mdx b/apps/docs/content/guides/platform/migrating-and-upgrading-projects.mdx index 429a1f43dad..1e050755784 100644 --- a/apps/docs/content/guides/platform/migrating-and-upgrading-projects.mdx +++ b/apps/docs/content/guides/platform/migrating-and-upgrading-projects.mdx @@ -65,7 +65,7 @@ If you are upgrading from a significantly older version, you will need to consid #### Time Limits -Starting from 2024-06-13, when a project is paused, users then have a 90-day window to restore the project on the platform from within Supabase Studio. +Starting from 2024-06-24, when a project is paused, users then have a 90-day window to restore the project on the platform from within Supabase Studio. The 90-day window allows Supabase to introduce platform changes that may not be backwards compatible with older backups. Unlike active projects, static backups can't be updated to accommodate such changes. diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 3813ca9595e..f251af7c9c7 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -23,6 +23,7 @@ Deji I Div Arora Divit D Egor Romanov +Etienne Stalmans Fabrizio Fenoglio Francesco Sansalvadore Greg Richardson diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountInformation.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountInformation.tsx index c6c940aa975..8bc199d8904 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountInformation.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountInformation.tsx @@ -1,9 +1,19 @@ -import { Button, Input } from 'ui' +import { + Button, + Input, + Input_Shadcn_, + Label_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + Collapsible_Shadcn_, + IconChevronDown, +} from 'ui' import Link from 'next/link' import Panel from 'components/ui/Panel' import { useSession } from 'lib/auth' import type { Profile } from 'data/profile/types' +import { ChevronDown } from 'lucide-react' const AccountInformation = ({ profile }: { profile?: Profile }) => { const session = useSession() @@ -17,21 +27,79 @@ const AccountInformation = ({ profile }: { profile?: Profile }) => { } > -
- - +
+
+ Username + +
+
+ Email + +
+ +
+ + +
+

+ How can I change my email? +

+ +
+
+ +
+ {session?.user.app_metadata.provider === 'email' ? ( +
+
    +
  1. Create a new account with the new email
  2. +
  3. Invite this email to be an owner of the Org
  4. +
  5. Join the Org
  6. +
  7. Remove the old email from the Org
  8. +
  9. Email Supabase Support to delete the old email (Optional)
  10. +
+
+ ) : ( +
+
    +
  1. Log out of Supabase
  2. +
  3. + Change your Primary Email in{' '} + + GitHub + {' '} + (your public, not primary email) +
  4. +
  5. Log out of GitHub
  6. +
  7. + Log back into GitHub (with the new, desired email set as primary) +
  8. +
  9. Log back into Supabase
  10. +
+
+ )} +
+
+
+
+
+
{session?.user.app_metadata.provider === 'email' && (
diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx index fa81a9fc59a..14a131c1aa5 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx @@ -1,9 +1,10 @@ +import { Check, ChevronsUpDown } from 'lucide-react' import { useEffect, useState } from 'react' +import { UseFormReturn } from 'react-hook-form' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' import { useTablesQuery } from 'data/tables/tables-query' -import { Check, ChevronsUpDown } from 'lucide-react' import { Button, CommandEmpty_Shadcn_, @@ -37,7 +38,13 @@ interface PolicyDetailsV2Props { searchString?: string selectedTable?: string isEditing: boolean - form: any + form: UseFormReturn<{ + name: string + table: string + behavior: string + command: string + roles: string + }> onUpdateCommand: (command: string) => void } diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx index 73fefa60219..31830fc8537 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx @@ -8,7 +8,10 @@ import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' import CardButton from 'components/ui/CardButton' import CopyButton from 'components/ui/CopyButton' import NoSearchResults from 'components/ui/NoSearchResults' -import { getGeneralPolicyTemplates } from '../PolicyEditorModal/PolicyEditorModal.constants' +import { + getGeneralPolicyTemplates, + getRealtimePolicyTemplates, +} from '../PolicyEditorModal/PolicyEditorModal.constants' interface PolicyTemplatesProps { schema: string @@ -26,7 +29,11 @@ export const PolicyTemplates = ({ onSelectTemplate, }: PolicyTemplatesProps) => { const [search, setSearch] = useState('') - const templates = getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name') + + const templates = + schema === 'realtime' + ? getRealtimePolicyTemplates() + : getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name') const baseTemplates = selectedPolicy !== undefined @@ -68,7 +75,7 @@ export const PolicyTemplates = ({ title={template.name} titleClass="text-sm" className={cn( - 'transition', + 'transition w-full', template.id === selectedTemplate ? '!border-stronger bg-surface-200 hover:!border-stronger' : '' diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx index e51b2284519..6b33be84e2c 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx @@ -4,9 +4,9 @@ import { MutableRefObject, useEffect, useRef } from 'react' import { cn } from 'ui' import { Markdown } from 'components/interfaces/Markdown' -import { noop } from 'lodash' -import { formatQuery } from 'data/sql/format-sql-query' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { formatQuery } from 'data/sql/format-sql-query' +import { noop } from 'lodash' // [Joshen] Is there a way we can just have one single MonacoEditor component that's shared across the dashboard? // Feels like we're creating multiple copies of Editor. I'm keen to make this one the defacto as well so lets make sure @@ -119,6 +119,12 @@ const RLSCodeEditor = ({ onChange() } + // when the value has changed, trigger the onChange callback so that the height of the container can be adjusted. + // Happens when the value wordwraps and is updated via a template. + useEffect(() => { + onChange() + }, [value]) + async function formatPgsql(value: any) { try { const formatted = await formatQuery({ diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx index 6c905ed4de4..39edae2948f 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx @@ -195,9 +195,9 @@ export const AIPolicyEditorPanel = memo(function ({ }, [chatMessages, debugThread]) const { mutate: executeMutation, isLoading: isExecuting } = useExecuteSqlMutation({ - onSuccess: () => { + onSuccess: async () => { // refresh all policies - queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) + await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) toast.success('Successfully created new policy') onSelectCancel() }, diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx index a299ea11ac1..24606d04e9a 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx @@ -145,7 +145,7 @@ const Policies = ({
onSelectCreatePolicy(table.name)} onSelectEditPolicy={onSelectEditPolicy} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts index b71239c9bdb..ddcecc68903 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts @@ -173,3 +173,141 @@ for select using ( roles: [], }, ] + +export const getRealtimePolicyTemplates = (): PolicyTemplate[] => { + const results = [ + { + id: 'policy-broadcast-1', + preview: false, + templateName: 'Allow listening for broadcasts for authenticated users only', + description: 'This policy allows listening for broadcasts for authenticated users only.', + statement: ` +create policy "Allow listening for broadcasts for authenticated users only" +on realtime.messages for select +to authenticated +using ( realtime.messages.extension = 'broadcast' );`.trim(), + name: 'Allow listening for broadcasts for authenticated users only', + definition: "realtime.messages.extension = 'broadcast'", + check: '', + command: 'SELECT', + roles: ['authenticated'], + }, + { + id: 'policy-broadcast-2', + preview: false, + templateName: 'Allow pushing broadcasts for authenticated users only', + description: 'This policy allows pushing broadcasts for authenticated users only.', + statement: ` +create policy "Allow pushing broadcasts for authenticated users only" +ON realtime.messages for update +TO authenticated +using ( realtime.messages.extension = 'broadcast' ) +with check ( realtime.messages.extension = 'broadcast' );`.trim(), + name: 'Allow pushing broadcasts for authenticated users only', + definition: "realtime.messages.extension = 'broadcast'", + check: "realtime.messages.extension = 'broadcast'", + command: 'UPDATE', + roles: ['authenticated'], + }, + { + id: 'policy-broadcast-3', + preview: false, + templateName: 'Allow listening for broadcasts from a specific channel', + description: 'This policy allows listening for broadcasts from a specific channel.', + statement: ` +create policy "Allow listening for broadcasts from a specific channel" +on realtime.messages for select +using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(), + name: 'Allow listening for broadcasts from a specific channel', + definition: `realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`, + check: '', + command: 'SELECT', + roles: [], + }, + { + id: 'policy-broadcast-4', + preview: false, + templateName: 'Allow pushing broadcasts to specific channel', + description: 'This policy allow pushing broadcasts to specific channel.', + statement: ` +create policy "Allow pushing broadcasts to specific channel" +ON realtime.messages for update +using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' ) +with check ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(), + name: 'Allow pushing broadcasts to specific channel', + definition: `realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`, + check: `realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`, + command: 'UPDATE', + roles: [], + }, + { + id: 'policy-presences-1', + preview: false, + templateName: 'Allow listening for presences on all channels for authenticated users only', + description: + 'This policy enables listening for presences on all channels for all authenticated users only.', + statement: ` +create policy "Allow listening for presences on all channels for authenticated users only" +on realtime.messages for select +to authenticated +using ( realtime.messages.extension = 'presence' );`.trim(), + name: 'Allow listening for presences on all channels for authenticated users only', + definition: "realtime.messages.extension = 'presence'", + check: '', + command: 'SELECT', + roles: ['authenticated'], + }, + { + id: 'policy-presences-2', + preview: false, + templateName: 'Allow broadcasting presences on all channels for authenticated users only', + description: + 'This policy enables broadcasting presences on all channels for all authenticated users only.', + statement: ` +create policy "Allow broadcasting presences on all channels for authenticated users only" +ON realtime.messages for update +TO authenticated +using ( realtime.messages.extension = 'presence' ) +with check ( realtime.messages.extension = 'presence' ); + ;`.trim(), + name: 'Allow broadcasting presences on all channels for authenticated users only', + definition: "realtime.messages.extension = 'presence'", + check: "realtime.messages.extension = 'presence'", + command: 'UPDATE', + roles: ['authenticated'], + }, + { + id: 'policy-presences-3', + preview: false, + templateName: 'Allow listening for presences from a specific channel', + description: 'This policy enables listening for presences from a specific channel.', + statement: ` +create policy "Allow listening for presences from a specific channel" +on realtime.messages for select +using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );`.trim(), + name: 'Allow listening for presences from a specific channel', + definition: `realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`, + check: '', + command: 'SELECT', + roles: [], + }, + { + id: 'policy-presences-4', + preview: false, + templateName: 'Publish presence to a specific channel', + description: 'This policy allows publishing presence to a specific channel.', + statement: ` +create policy "Publish presence to a specific channel" +ON realtime.messages for update +using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' ) +with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' ); + ;`.trim(), + name: 'Publish presence to a specific channel', + definition: `realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`, + check: `realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`, + command: 'UPDATE', + roles: [], + }, + ] as PolicyTemplate[] + return results +} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index cfee481bc00..530a2aa9f22 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -8,7 +8,7 @@ import { useRouter } from 'next/router' import { useIsRLSAIAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useCheckPermissions } from 'hooks' -import { Badge, Button } from 'ui' +import { Badge, Button, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui' interface PolicyTableRowHeaderProps { table: PostgresTable @@ -29,6 +29,10 @@ const PolicyTableRowHeader = ({ const isAiAssistantEnabled = useIsRLSAIAssistantEnabled() const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const isRealtimeSchema = table.schema === 'realtime' + const isRealtimeMessagesTable = isRealtimeSchema && table.name === 'messages' + const isTableLocked = isRealtimeSchema ? !isRealtimeMessagesTable : isLocked + return (
@@ -36,7 +40,7 @@ const PolicyTableRowHeader = ({

{table.name}

- {isLocked && ( + {isTableLocked && ( Locked @@ -48,22 +52,22 @@ const PolicyTableRowHeader = ({
- {!isLocked && ( + {!isTableLocked && (
- - - - - {!canToggleRLS && ( - - + {!isRealtimeMessagesTable ? ( + + + + + {!canToggleRLS && ( +
-
-
- )} -
+ + )} + + ) : null} {!isAiAssistantEnabled && ( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/TaxID/TaxID.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/TaxID/TaxID.tsx index a50b80737e2..629c92353dd 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/TaxID/TaxID.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/TaxID/TaxID.tsx @@ -95,6 +95,7 @@ const TaxID = () => { ? { type: type, value: sanitizeTaxIdValue({ value, name: form.getValues().name }), + country: selectedTaxId?.countryIso2, } : null, }) diff --git a/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx index 9719b931570..75507a329ee 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx @@ -1,24 +1,28 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useTelemetryProps } from 'common' -import Link from 'next/link' import { useRouter } from 'next/router' import { Dispatch, SetStateAction, useState } from 'react' import { useForm } from 'react-hook-form' import { Button, FormControl_Shadcn_, + FormDescription_Shadcn_, FormField_Shadcn_, FormItem_Shadcn_, + FormLabel_Shadcn_, Form_Shadcn_, IconChevronDown, Input_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, + Switch, } from 'ui' import * as z from 'zod' +import { useFlag } from 'hooks' import Telemetry from 'lib/telemetry' +import { ExternalLink } from 'lucide-react' import { RealtimeConfig } from '../useRealtimeMessages' interface ChooseChannelPopoverProps { @@ -26,17 +30,19 @@ interface ChooseChannelPopoverProps { onChangeConfig: Dispatch> } +const FormSchema = z.object({ channel: z.string(), isPrivate: z.boolean() }) + export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPopoverProps) => { const [open, setOpen] = useState(false) const telemetryProps = useTelemetryProps() const router = useRouter() + const authzEnabled = useFlag('authzRealtime') - const FormSchema = z.object({ channel: z.string() }) const form = useForm>({ mode: 'onBlur', reValidateMode: 'onBlur', resolver: zodResolver(FormSchema), - defaultValues: { channel: '' }, + defaultValues: { channel: '', isPrivate: false }, }) const onOpen = (v: boolean) => { @@ -58,7 +64,12 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo telemetryProps, router ) - onChangeConfig({ ...config, channelName: form.getValues('channel'), enabled: true }) + onChangeConfig({ + ...config, + channelName: form.getValues('channel'), + isChannelPrivate: form.getValues('isPrivate'), + enabled: true, + }) } return ( @@ -83,52 +94,104 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo {config.channelName.length === 0 ? ( <> -
onSubmit())}> + onSubmit())} + className="flex flex-col gap-y-4" + > ( - - -
- - - - + +
+ +
+ + + + + +
+ + The channel you initialize with the Supabase Realtime client. Learn more + in{' '} + + our docs + +
)} /> + + {authzEnabled ? ( + ( + +
+ + + + + Is channel private? + +
+ + If the channel is marked as private, it will use RLS policies to filter + messages. + +
+ )} + /> + ) : null} + + -

- The channel you initialize with the Supabase Realtime client. Learn more in{' '} - - our docs - -

) : (
-

Currently joined channel:

+

+ Currently joined{' '} + + {config.isChannelPrivate ? 'private' : 'public'} + {' '} + channel: +

{config.channelName}

diff --git a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx index 718ae8ec315..6a0bedafd1b 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx @@ -25,6 +25,7 @@ export const RealtimeInspector = () => { token: '', // will be filled out by RealtimeTokensPopover schema: 'public', table: '*', + isChannelPrivate: false, filter: undefined, bearer: null, enablePresence: true, diff --git a/apps/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts b/apps/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts index 46ceb7e3ad1..d70fb65d52a 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts +++ b/apps/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts @@ -1,5 +1,9 @@ -import { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js' -import { sortBy, take } from 'lodash' +import { RealtimeChannel, RealtimeClient } from '@supabase/realtime-js-next' +import { + DEFAULT_GLOBAL_OPTIONS, + DEFAULT_REALTIME_OPTIONS, +} from '@supabase/supabase-js/dist/main/lib/constants' +import { merge, sortBy, take } from 'lodash' import { Dispatch, SetStateAction, useCallback, useEffect, useReducer, useState } from 'react' import toast from 'react-hot-toast' @@ -43,6 +47,7 @@ export interface RealtimeConfig { token: string schema: string table: string + isChannelPrivate: boolean filter: string | undefined bearer: string | null enableBroadcast: boolean @@ -62,6 +67,7 @@ export const useRealtimeMessages = ( token, schema, table, + isChannelPrivate, filter, bearer, enablePresence, @@ -76,40 +82,43 @@ export const useRealtimeMessages = ( ? `${data.autoApiService.protocol}://${data.autoApiService.endpoint}` : `https://${projectRef}.supabase.co` + const realtimeUrl = `${host}/realtime/v1`.replace(/^http/i, 'ws') + const [logData, dispatch] = useReducer(reducer, [] as LogData[]) const pushMessage = (messageType: string, metadata: any) => { dispatch({ type: 'add', payload: { messageType, metadata } }) } // Instantiate our client with the Realtime server and params to connect with - let [client, setClient] = useState | undefined>() + let [client, setClient] = useState() let [channel, setChannel] = useState() useEffect(() => { if (!enabled) { return } - const opts = { - global: { - headers: { - 'User-Agent': `supabase-api/${process.env.VERCEL_GIT_COMMIT_SHA || 'unknown-sha'}`, - }, - }, - realtime: { - params: { - log_level: logLevel, - }, + + const globalOptions = merge(DEFAULT_GLOBAL_OPTIONS, { + headers: { + 'User-Agent': `supabase-api/${process.env.VERCEL_GIT_COMMIT_SHA || 'unknown-sha'}`, }, + }) + const realtimeOptions = merge(DEFAULT_REALTIME_OPTIONS, { params: { log_level: logLevel } }) + + const options = { + headers: globalOptions.headers, + ...realtimeOptions, + params: { apikey: token, ...realtimeOptions.params }, } - const newClient = new SupabaseClient(host, token, opts) + const realtimeClient = new RealtimeClient(realtimeUrl, options) if (bearer) { - newClient.realtime.setAuth(bearer) + realtimeClient.setAuth(bearer) } - setClient(newClient) + setClient(realtimeClient) return () => { - client?.realtime.disconnect() + realtimeClient.disconnect() setClient(undefined) } }, [enabled, bearer, host, logLevel, token]) @@ -120,7 +129,7 @@ export const useRealtimeMessages = ( } dispatch({ type: 'clear' }) const newChannel = client?.channel(channelName, { - config: { broadcast: { self: true } }, + config: { broadcast: { self: true }, private: isChannelPrivate }, }) // Hack to confirm Postgres is subscribed // Need to add 'extension' key in the 'payload' diff --git a/apps/studio/components/interfaces/Realtime/Policies.tsx b/apps/studio/components/interfaces/Realtime/Policies.tsx new file mode 100644 index 00000000000..53d4085c1a2 --- /dev/null +++ b/apps/studio/components/interfaces/Realtime/Policies.tsx @@ -0,0 +1,72 @@ +import { PostgresPolicy } from '@supabase/postgres-meta' +import { useState } from 'react' + +import { Policies } from 'components/interfaces/Auth/Policies' +import { AIPolicyEditorPanel } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import AlertError from 'components/ui/AlertError' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useTablesQuery } from 'data/tables/tables-query' +import { FormHeader } from 'components/ui/Forms' + +export const RealtimePolicies = () => { + const { project } = useProjectContext() + + const [showPolicyEditor, setShowPolicyEditor] = useState(false) + const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState() + + const { + data: tables, + isLoading, + isSuccess, + isError, + error, + } = useTablesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: 'realtime', + }) + + const filteredTables = (tables ?? []).filter((table) => table.name === 'messages') + + return ( +
+ + + {isLoading && } + + {isError && } + + {isSuccess && ( +
+ { + setSelectedPolicyToEdit(undefined) + setShowPolicyEditor(true) + }} + onSelectEditPolicy={(policy) => { + setSelectedPolicyToEdit(policy) + setShowPolicyEditor(true) + }} + /> +
+ )} + + setShowPolicyEditor(false)} + /> +
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index a8a3a4bafda..0f966c253de 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -19,6 +19,24 @@ describe('SQLEditor.utils.ts:checkIfAppendLimitRequired', () => { const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) expect(appendAutoLimit).toBe(false) }) + test('Should return false if query already has a limit (check for case-insensitiveness)', () => { + const sql = 'SELECT * FROM countries LIMIT 10;' + const limit = 100 + const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query already has a limit and offset', () => { + const sql = 'select * from countries limit 10 offset 0;' + const limit = 100 + const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => { + const sql = 'select * from countries offset 0 limit 1;' + const limit = 100 + const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) + expect(appendAutoLimit).toBe(false) + }) test('Should return false if query already has a limit, even if no value provided for limit', () => { const sql = 'select * from countries limit' const limit = 100 diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index c9c8b4a2a89..8dc6bf5a84c 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -134,10 +134,11 @@ export const checkIfAppendLimitRequired = (sql: string, limit: number = 0) => { !hasComments && !hasMultipleQueries && cleanedSql.toLowerCase().startsWith('select') && - !cleanedSql.toLowerCase().match(/fetch\s+first/) && - !cleanedSql.endsWith('limit') && - !cleanedSql.endsWith('limit;') && - !cleanedSql.match('limit [0-9]*[;]?$') + !cleanedSql.toLowerCase().match(/fetch\s+first/i) && + !cleanedSql.match(/limit$/i) && + !cleanedSql.match(/limit;$/i) && + !cleanedSql.match(/limit [0-9]* offset [0-9]*[;]?$/i) && + !cleanedSql.match(/limit [0-9]*[;]?$/i) return { cleanedSql, appendAutoLimit } } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index 7c044980ec6..b8f444dc39a 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -110,7 +110,7 @@ const UtilityPanel = ({ return ( - +
Results diff --git a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx index c76c0e1d33c..6982dc025b4 100644 --- a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx @@ -256,36 +256,55 @@ const CustomDomainSidePanel = () => { <> {selectedOption === 'cd_none' || (selectedCustomDomain?.price ?? 0) < (subscriptionCDOption?.variant.price ?? 0) - ? subscription?.billing_via_partner === false && ( + ? subscription?.billing_via_partner === false && + // Old addon billing with upfront payment + subscription.usage_based_billing_project_addons === false && (

- Upon clicking confirm, the add-on is removed immediately and any unused time - in the current billing cycle is added as prorated credits to your organization - and used in subsequent billing cycles. + + Upon clicking confirm, the add-on is removed immediately and any unused time + in the current billing cycle is added as prorated credits to your + organization and used in subsequent billing cycles. +

) : !subscription?.billing_via_partner && (

- Upon clicking confirm, the amount of{' '} - - {formatCurrency(selectedCustomDomain?.price)} - {' '} - will be added to your monthly invoice. The addon is prepaid per month and in - case of a downgrade, you get credits for the remaining time. For the current - billing cycle you're immediately charged a prorated amount for the remaining - days. + {subscription?.usage_based_billing_project_addons === false ? ( + + Upon clicking confirm, the amount of{' '} + + {formatCurrency(selectedCustomDomain?.price)} + {' '} + will be added to your monthly invoice. The addon is prepaid per month and + in case of a downgrade, you get credits for the remaining time. For the + current billing cycle you're immediately charged a prorated amount for the + remaining days. + + ) : ( + + There are no immediate charges. The addon is billed at the end of your + billing cycle based on your usage and prorated to the hour. + + )}

)} - {subscription?.billing_via_partner && - subscription.scheduled_plan_change?.target_plan !== undefined && ( - - - - You have a scheduled subscription change that will be canceled if you change - your custom domain add on. - - - )} + { + // Billed via partner + subscription?.billing_via_partner && + // Project addons are still billed the old way (upfront payment) + subscription?.usage_based_billing_project_addons === false && + // Scheduled billing plan change + subscription.scheduled_plan_change?.target_plan !== undefined && ( + + + + You have a scheduled subscription change that will be canceled if you change + your custom domain add on. + + + ) + } )} diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index a23bf816212..0e2c1b2c943 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -14,7 +14,17 @@ import { useCheckPermissions, useSelectedOrganization } from 'hooks' import { formatCurrency } from 'lib/helpers' import Telemetry from 'lib/telemetry' import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' -import { Alert, Button, cn, IconExternalLink, Radio, SidePanel } from 'ui' +import { + Alert, + Alert_Shadcn_, + AlertDescription_Shadcn_, + Button, + cn, + IconAlertTriangle, + IconExternalLink, + Radio, + SidePanel, +} from 'ui' const IPv4SidePanel = () => { const router = useRouter() @@ -224,7 +234,9 @@ const IPv4SidePanel = () => { <> {selectedOption === 'ipv4_none' || (selectedIPv4?.price ?? 0) < (subscriptionIpV4Option?.variant.price ?? 0) ? ( - subscription?.billing_via_partner === false && ( + subscription?.billing_via_partner === false && + // Old addon billing with upfront payment + subscription.usage_based_billing_project_addons === false && (

Upon clicking confirm, the add-on is removed immediately and any unused time in the current billing cycle is added as prorated credits to your organization and @@ -253,12 +265,38 @@ const IPv4SidePanel = () => {

{!subscription?.billing_via_partner && (

- Upon clicking confirm, the respective amount will be added to your monthly - invoice. The addon is prepaid per month and in case of a downgrade, you get - credits for the remaining time. For the current billing cycle you're - immediately charged a prorated amount for the remaining days. + {subscription?.usage_based_billing_project_addons === false ? ( + + Upon clicking confirm, the respective amount will be added to your monthly + invoice. The addon is prepaid per month and in case of a downgrade, you + get credits for the remaining time. For the current billing cycle you're + immediately charged a prorated amount for the remaining days. + + ) : ( + + There are no immediate charges. The addon is billed at the end of your + billing cycle based on your usage and prorated to the hour. + + )}

)} + + { + // Billed via partner + subscription?.billing_via_partner && + // Project addons are still billed the old way (upfront payment) + subscription?.usage_based_billing_project_addons === false && + // Scheduled billing plan change + subscription.scheduled_plan_change?.target_plan !== undefined && ( + + + + You have a scheduled subscription change that will be canceled if you + change your PITR add on. + + + ) + } )} diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 6e9d6037d86..86883dbef20 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -383,7 +383,9 @@ const PITRSidePanel = () => { <> {selectedOption === 'pitr_0' || (selectedPitr?.price ?? 0) < (subscriptionPitr?.variant.price ?? 0) - ? subscription?.billing_via_partner === false && ( + ? subscription?.billing_via_partner === false && + // Old addon billing with upfront payment + subscription.usage_based_billing_project_addons === false && (

Upon clicking confirm, the add-on is removed immediately and any unused time in the current billing cycle is added as prorated credits to your organization @@ -392,25 +394,42 @@ const PITRSidePanel = () => { ) : !subscription?.billing_via_partner && (

- Upon clicking confirm, the amount of{' '} - {formatCurrency(selectedPitr?.price)}{' '} - will be added to your monthly invoice. The addon is prepaid per month and in - case of a downgrade, you get credits for the remaining time. For the current - billing cycle you're immediately charged a prorated amount for the remaining - days. + {subscription?.usage_based_billing_project_addons === false ? ( + + Upon clicking confirm, the amount of{' '} + + {formatCurrency(selectedPitr?.price)} + {' '} + will be added to your monthly invoice. The addon is prepaid per month and + in case of a downgrade, you get credits for the remaining time. For the + current billing cycle you're immediately charged a prorated amount for the + remaining days. + + ) : ( + + There are no immediate charges. The addon is billed at the end of your + billing cycle based on your usage and prorated to the hour. + + )}

)} - {subscription?.billing_via_partner && - subscription.scheduled_plan_change?.target_plan !== undefined && ( - - - - You have a scheduled subscription change that will be canceled if you change - your PITR add on. - - - )} + { + // Billed via partner + subscription?.billing_via_partner && + // Project addons are still billed the old way (upfront payment) + subscription?.usage_based_billing_project_addons === false && + // Scheduled billing plan change + subscription.scheduled_plan_change?.target_plan !== undefined && ( + + + + You have a scheduled subscription change that will be canceled if you change + your PITR add on. + + + ) + } )}
diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.utils.ts index 31da04db4ad..cb257a61176 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.utils.ts +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.utils.ts @@ -90,7 +90,7 @@ export const constructConnStringSyntax = ( if (selectedTab === 'uri' || selectedTab === 'nodejs') { if (isMd5) { return [ - { value: 'postgres://', tooltip: undefined }, + { value: 'postgresql://', tooltip: undefined }, { value: '[user]', tooltip: DB_USER_DESC }, { value: ':', tooltip: undefined }, { value: '[password]', tooltip: DB_PASS_DESC }, @@ -109,7 +109,7 @@ export const constructConnStringSyntax = ( ] } else { return [ - { value: 'postgres://', tooltip: undefined }, + { value: 'postgresql://', tooltip: undefined }, { value: '[user]', tooltip: DB_USER_DESC }, ...(usePoolerConnection ? [ diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts index f9ce1f04b0a..43b2511f260 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts @@ -123,18 +123,22 @@ export const generateUpdateColumnPayload = ( const isOriginallyPrimaryKey = primaryKeyColumns.includes(originalColumn.name) // Only append the properties which are getting updated + const name = field.name.trim() const type = field.isArray ? `${field.format}[]` : field.format - const comment = (field.comment?.length ?? '') === 0 ? null : field.comment + const comment = ((field.comment?.length ?? '') === 0 ? null : field.comment)?.trim() + const check = field.check?.trim() const payload: Partial = {} - if (!isEqual(originalColumn.name, field.name)) { - payload.name = field.name + // [Joshen] Trimming on the original name as well so we don't rename columns that already + // contain whitespaces (and accidentally bringing user apps down) + if (!isEqual(originalColumn.name.trim(), name)) { + payload.name = name } - if (!isEqual(originalColumn.comment, comment)) { + if (!isEqual(originalColumn.comment?.trim(), comment)) { payload.comment = comment as string | undefined } - if (!isEqual(originalColumn.check?.trim(), field.check?.trim())) { - payload.check = field.check?.trim() + if (!isEqual(originalColumn.check?.trim(), check)) { + payload.check = check } if (!isEqual(originalColumn.format, type)) { diff --git a/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx b/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx index db0102aef20..4f63c634bdd 100644 --- a/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx +++ b/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx @@ -2,25 +2,29 @@ import { useRouter } from 'next/router' import { PropsWithChildren } from 'react' import { ProductMenu } from 'components/ui/ProductMenu' -import { useSelectedProject, withAuth } from 'hooks' +import { useFlag, useSelectedProject, withAuth } from 'hooks' import { ProjectLayout } from '../' import { generateRealtimeMenu } from './RealtimeMenu.utils' export interface RealtimeLayoutProps { - title?: string + title: string } -const RealtimeLayout = ({ children }: PropsWithChildren) => { +const RealtimeLayout = ({ title, children }: PropsWithChildren) => { const project = useSelectedProject() const router = useRouter() const page = router.pathname.split('/')[4] + const authzEnabled = useFlag('authzRealtime') + return ( } + productMenu={ + + } > {children} diff --git a/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts b/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts index c8d612494a7..cad5482e8f2 100644 --- a/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts +++ b/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts @@ -1,12 +1,15 @@ import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' import type { Project } from 'data/projects/project-detail-query' -export const generateRealtimeMenu = (project?: Project): ProductMenuGroup[] => { +export const generateRealtimeMenu = ( + project: Project, + { authzEnabled }: { authzEnabled: boolean } +): ProductMenuGroup[] => { const ref = project?.ref ?? 'default' return [ { - title: 'Realtime', + title: 'Tools', items: [ { name: 'Inspector', @@ -16,5 +19,20 @@ export const generateRealtimeMenu = (project?: Project): ProductMenuGroup[] => { }, ], }, + ...(authzEnabled + ? [ + { + title: 'Configuration', + items: [ + { + name: 'Policies', + key: 'policies', + url: `/project/${ref}/realtime/policies`, + items: [], + }, + ], + }, + ] + : []), ] } diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 061d1306273..24ebd09141b 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -1,7 +1,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { partition } from 'lodash' -import { Plus } from 'lucide-react' +import { Filter, Plus } from 'lucide-react' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' @@ -15,7 +15,17 @@ import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-que import { useCheckPermissions, useLocalStorage } from 'hooks' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + Checkbox_Shadcn_, + Label_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, +} from 'ui' import { InnerSideBarEmptyPanel, InnerSideBarFilterSearchInput, @@ -26,6 +36,7 @@ import { } from 'ui-patterns/InnerSideMenu' import { useProjectContext } from '../ProjectLayout/ProjectContext' import EntityListItem from './EntityListItem' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' const TableEditorMenu = () => { const router = useRouter() @@ -34,6 +45,7 @@ const TableEditorMenu = () => { const [showModal, setShowModal] = useState(false) const [searchText, setSearchText] = useState('') + const [visibleTypes, setVisibleTypes] = useState(Object.values(ENTITY_TYPE)) const [sort, setSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>( 'table-editor-sort', 'alphabetical' @@ -57,6 +69,7 @@ const TableEditorMenu = () => { schema: snap.selectedSchemaName, search: searchText || undefined, sort, + filterTypes: visibleTypes, }, { keepPreviousData: Boolean(searchText), @@ -188,6 +201,51 @@ const TableEditorMenu = () => { + + + +
+ ))} +
+
+ + {isLoading && } diff --git a/apps/studio/data/database-policies/keys.ts b/apps/studio/data/database-policies/keys.ts index e2589c4245c..6fa6dc3718c 100644 --- a/apps/studio/data/database-policies/keys.ts +++ b/apps/studio/data/database-policies/keys.ts @@ -1,4 +1,4 @@ export const databasePoliciesKeys = { list: (projectRef: string | undefined, schema?: string | undefined) => - ['projects', projectRef, 'database-policies', schema] as const, + ['projects', projectRef, 'database-policies', schema].filter(Boolean), } 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 f24f0bf96d1..470bd56933b 100644 --- a/apps/studio/data/entity-types/entity-types-infinite-query.ts +++ b/apps/studio/data/entity-types/entity-types-infinite-query.ts @@ -10,6 +10,7 @@ export type EntityTypesVariables = { limit?: number page?: number sort?: 'alphabetical' | 'grouped-alphabetical' + filterTypes?: string[] } & Pick export type EntityTypesResponse = { @@ -28,6 +29,7 @@ export async function getEntityTypes( limit = 100, page = 0, sort = 'alphabetical', + filterTypes, }: EntityTypesVariables, signal?: AbortSignal ) { @@ -55,7 +57,7 @@ export async function getEntityTypes( pg_namespace nc join pg_class c on nc.oid = c.relnamespace where - c.relkind in ('r', 'v', 'm', 'f', 'p') + c.relkind in (${filterTypes === undefined ? `'r', 'v', 'm', 'f', 'p'` : filterTypes.map((x) => `'${x}'`).join(', ')}) and not pg_is_other_temp_schema(nc.oid) and ( pg_has_role(c.relowner, 'USAGE') @@ -113,6 +115,7 @@ export const useEntityTypesQuery = ( search, limit = 100, sort, + filterTypes, }: Omit, { enabled = true, @@ -120,10 +123,10 @@ export const useEntityTypesQuery = ( }: UseInfiniteQueryOptions = {} ) => useInfiniteQuery( - entityTypeKeys.list(projectRef, { schema, search, sort, limit }), + entityTypeKeys.list(projectRef, { schema, search, sort, limit, filterTypes }), ({ signal, pageParam }) => getEntityTypes( - { projectRef, connectionString, schema, search, limit, page: pageParam, sort }, + { projectRef, connectionString, schema, search, limit, page: pageParam, sort, filterTypes }, signal ), { diff --git a/apps/studio/data/entity-types/keys.ts b/apps/studio/data/entity-types/keys.ts index d56b82cb4ba..c04fc7dbd12 100644 --- a/apps/studio/data/entity-types/keys.ts +++ b/apps/studio/data/entity-types/keys.ts @@ -6,6 +6,7 @@ export const entityTypeKeys = { search?: string sort?: 'alphabetical' | 'grouped-alphabetical' limit?: number + filterTypes?: string[] } ) => ['projects', projectRef, 'entity-types', ...(params ? [params] : [])] as const, } diff --git a/apps/studio/data/organizations/organization-tax-id-update-mutation.ts b/apps/studio/data/organizations/organization-tax-id-update-mutation.ts index e0763e33220..cef8a6b336c 100644 --- a/apps/studio/data/organizations/organization-tax-id-update-mutation.ts +++ b/apps/studio/data/organizations/organization-tax-id-update-mutation.ts @@ -7,7 +7,7 @@ import { put, del, handleError } from 'data/fetchers' export type OrganizationTaxIdUpdateVariables = { slug: string - taxId: { type: string; value: string } | null + taxId: { type: string; value: string; country?: string } | null } export async function updateOrganizationTaxId({ slug, taxId }: OrganizationTaxIdUpdateVariables) { @@ -22,7 +22,7 @@ export async function updateOrganizationTaxId({ slug, taxId }: OrganizationTaxId slug, }, }, - body: { type: taxId.type, value: taxId.value }, + body: { type: taxId.type, value: taxId.value, country: taxId.country }, }) if (error) handleError(error) diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts index 5d0e3e56c54..5ce5bf5d7cc 100644 --- a/apps/studio/data/table-rows/table-rows-query.ts +++ b/apps/studio/data/table-rows/table-rows-query.ts @@ -125,28 +125,15 @@ export const getTableRowsSqlQuery = ({ if (!table) return `` - // [Joshen] Only truncate text/json based columns as their length could go really big - // Note: Risk of payload being too large if the user has many many text/json based columns - // although possibly negligible risk. - const truncatedColumns = table.columns - .filter((column) => { - return ( - ((column?.enum ?? []).length > 0 && column.dataType.toLowerCase() === 'array') || - TEXT_TYPES.includes(column.format) || - JSON_TYPES.includes(column.format) - ) - }) - .map((column) => { - if ((column?.enum ?? []).length > 0 && column.dataType.toLowerCase() === 'array') { - return `"${column.name}"::text[]` - } else { - return `case when length("${column.name}"::text) > ${MAX_CHARACTERS} then concat(left("${column.name}"::text, ${MAX_CHARACTERS}), '...') else "${column.name}"::text end "${column.name}"` - } - }) + const arrayBasedColumns = table.columns + .filter( + (column) => (column?.enum ?? []).length > 0 && column.dataType.toLowerCase() === 'array' + ) + .map((column) => `"${column.name}"::text[]`) let queryChains = query .from(table.name, table.schema ?? undefined) - .select(truncatedColumns.length > 0 ? `*,${truncatedColumns.join(',')}` : '*') + .select(arrayBasedColumns.length > 0 ? `*,${arrayBasedColumns.join(',')}` : '*') filters .filter((x) => x.value && x.value != '') @@ -157,10 +144,10 @@ export const getTableRowsSqlQuery = ({ // If sorts is empty, use the primary key as the default sort if (sorts.length === 0) { - const primaryKey = getDefaultOrderByColumn(table) + const defaultOrderByColumn = getDefaultOrderByColumn(table) - if (primaryKey) { - queryChains = queryChains.order(table.name, primaryKey, true, true) + if (defaultOrderByColumn) { + queryChains = queryChains.order(table.name, defaultOrderByColumn, true, true) } } else { sorts.forEach((x) => { @@ -170,9 +157,22 @@ export const getTableRowsSqlQuery = ({ // getPagination is expecting to start from 0 const { from, to } = getPagination((page ?? 1) - 1, limit) - const sql = queryChains.range(from, to).toSql() + const baseSql = queryChains.range(from, to).toSql() - return sql + // [Joshen] Only truncate text/json based columns as their length could go really big + // Note: Risk of payload being too large if the user has many many text/json based columns + // although possibly negligible risk. + const truncatedColumns = table.columns + .filter((column) => TEXT_TYPES.includes(column.format) || JSON_TYPES.includes(column.format)) + .map((column) => { + return `case when length("${column.name}"::text) > ${MAX_CHARACTERS} then concat(left("${column.name}"::text, ${MAX_CHARACTERS}), '...') else "${column.name}"::text end "${column.name}"` + }) + const outputSql = + truncatedColumns.length > 0 + ? `with _temp as (${baseSql.slice(0, -1)}) select *, ${truncatedColumns.join(',')} from _temp` + : baseSql + + return outputSql } export type TableRows = { diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 3c71c8fbc5f..e748fbeba14 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -52,10 +52,10 @@ const VERCEL_LIVE_URL = 'https://vercel.live' const PUSHER_URL = 'https://*.pusher.com' const PUSHER_URL_WS = 'wss://*.pusher.com' -const DEFAULT_SRC_URLS = `${API_URL} ${SUPABASE_URL} ${GOTRUE_URL} ${SUPABASE_LOCAL_PROJECTS_URL_WS} ${SUPABASE_PROJECTS_URL} ${SUPABASE_PROJECTS_URL_WS} ${HCAPTCHA_SUBDOMAINS_URL} ${CONFIGCAT_URL} ${STRIPE_SUBDOMAINS_URL} ${STRIPE_NETWORK_URL} ${CLOUDFLARE_URL} ${ONE_ONE_ONE_ONE_URL} ${VERCEL_INSIGHTS_URL} ${GITHUB_API_URL} ${GITHUB_USER_CONTENT_URL} ${GITHUB_USER_AVATAR_URL}` +const DEFAULT_SRC_URLS = `${API_URL} ${SUPABASE_URL} ${GOTRUE_URL} ${SUPABASE_LOCAL_PROJECTS_URL_WS} ${SUPABASE_PROJECTS_URL} ${SUPABASE_PROJECTS_URL_WS} ${HCAPTCHA_SUBDOMAINS_URL} ${CONFIGCAT_URL} ${STRIPE_SUBDOMAINS_URL} ${STRIPE_NETWORK_URL} ${CLOUDFLARE_URL} ${ONE_ONE_ONE_ONE_URL} ${VERCEL_INSIGHTS_URL} ${GITHUB_API_URL} ${GITHUB_USER_CONTENT_URL}` const SCRIPT_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${HCAPTCHA_JS_URL} ${STRIPE_JS_URL}` const FRAME_SRC_URLS = `${HCAPTCHA_ASSET_URL} ${STRIPE_JS_URL}` -const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL}` +const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL} ${GITHUB_USER_AVATAR_URL}` const STYLE_SRC_URLS = `${CLOUDFLARE_CDN_URL}` const FONT_SRC_URLS = `${CLOUDFLARE_CDN_URL}` @@ -76,7 +76,7 @@ const csp = [ `default-src 'self' ${DEFAULT_SRC_URLS};`, `script-src 'self' 'unsafe-eval' 'unsafe-inline' ${SCRIPT_SRC_URLS};`, `frame-src 'self' ${FRAME_SRC_URLS};`, - `img-src 'self' blob: data: ${IMG_SRC_URLS};`, + `img-src 'self' blob: data: ${IMG_SRC_URLS} ;`, `style-src 'self' 'unsafe-inline' ${STYLE_SRC_URLS};`, `font-src 'self' ${FONT_SRC_URLS};`, `worker-src 'self' blob: data:;`, diff --git a/apps/studio/package.json b/apps/studio/package.json index 7577acb527e..100ed2e156d 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -38,6 +38,7 @@ "@stripe/stripe-js": "^3.0.5", "@supabase/auth-helpers-react": "^0.4.2", "@supabase/pg-meta": "*", + "@supabase/realtime-js-next": "npm:@supabase/realtime-js@2.10.0-next.8", "@supabase/shared-types": "0.1.65", "@supabase/supabase-js": "^2.41.1", "@tanstack/react-query": "4.35.7", diff --git a/apps/studio/pages/api/organizations/[slug]/billing/subscription.ts b/apps/studio/pages/api/organizations/[slug]/billing/subscription.ts index 5baa6aa7966..ed1da8d5d54 100644 --- a/apps/studio/pages/api/organizations/[slug]/billing/subscription.ts +++ b/apps/studio/pages/api/organizations/[slug]/billing/subscription.ts @@ -38,6 +38,7 @@ const handleGet = async (req: NextApiRequest, res: NextApiResponse scheduled_plan_change: null, customer_balance: 0, nano_enabled: false, + usage_based_billing_project_addons: false, } return res.status(200).json(response) diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index 71729cc5b64..703b9e73b91 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -73,8 +73,9 @@ const AuthPoliciesPage: NextPageWithLayout = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) - const [protectedSchemas] = partition(schemas, (schema) => - EXCLUDED_SCHEMAS.includes(schema?.name ?? '') + const [protectedSchemas] = partition( + schemas, + (schema) => schema?.name !== 'realtime' && EXCLUDED_SCHEMAS.includes(schema?.name ?? '') ) const selectedSchema = schemas?.find((s) => s.name === schema) const isLocked = protectedSchemas.some((s) => s.id === selectedSchema?.id) diff --git a/apps/studio/pages/project/[ref]/realtime/inspector.tsx b/apps/studio/pages/project/[ref]/realtime/inspector.tsx index 3d3c3efe2f2..ca584b60a42 100644 --- a/apps/studio/pages/project/[ref]/realtime/inspector.tsx +++ b/apps/studio/pages/project/[ref]/realtime/inspector.tsx @@ -16,6 +16,8 @@ export const InspectorPage: NextPageWithLayout = () => { return } -InspectorPage.getLayout = (page) => {page} +InspectorPage.getLayout = (page) => ( + {page} +) export default InspectorPage diff --git a/apps/studio/pages/project/[ref]/realtime/policies.tsx b/apps/studio/pages/project/[ref]/realtime/policies.tsx new file mode 100644 index 00000000000..9f80d443f91 --- /dev/null +++ b/apps/studio/pages/project/[ref]/realtime/policies.tsx @@ -0,0 +1,12 @@ +import { RealtimePolicies } from 'components/interfaces/Realtime/Policies' +import type { NextPageWithLayout } from 'types' + +import RealtimeLayout from 'components/layouts/RealtimeLayout/RealtimeLayout' + +const RealtimePoliciesPage: NextPageWithLayout = () => { + return +} + +RealtimePoliciesPage.getLayout = (page) => {page} + +export default RealtimePoliciesPage diff --git a/apps/www/_blog/2024-06-26-postgis-generate-vector-tiles.mdx b/apps/www/_blog/2024-06-26-postgis-generate-vector-tiles.mdx new file mode 100644 index 00000000000..ae29e4ef30b --- /dev/null +++ b/apps/www/_blog/2024-06-26-postgis-generate-vector-tiles.mdx @@ -0,0 +1,296 @@ +--- +title: 'Generate Vector Tiles with PostGIS' +description: 'Use PostGIS to programmatically generate Mapbox Vector Tiles and render them with MapLibre GL.' +author: bdon,thor_schaeff +image: postgis_vector_tiles/overture_postgis_mvt.png +thumb: postgis_vector_tiles/overture_postgis_mvt.png +categories: + - developers +tags: + - postgres + - postgis + - maps +date: '2024-06-26' +toc_depth: 3 +--- + + + +[Do you prefer audio-visual learning? Watch the video guide!](https://supabase.link/supa-gis-yt-docs) + +[Or jump straight into the code](https://github.com/bdon/supabase-vector-tile) + + + +[Overture Maps Foundation](https://overturemaps.org/) is a [Joint Development Foundation Project](https://jointdevelopment.org/) initiated by Amazon, Meta, Microsoft, and tomtom, aiming to create reliable, easy-to-use, and interoperable open map data. + +Overture Maps allows us to download open map data, like places of interest, as [GeoJSON](https://geojson.org/) which we can transform into SQL and ingest into our Postgres database on Supabase. + +Using PostGIS we can then programmatically generate vector tiles and serve them to our MapLibre GL client using supabase-js. + + + +Vector tiles are packets of geographic data, packaged into pre-defined roughly-square shaped "tiles" for transfer over the web. Map data is requested by a client as a set of "tiles" corresponding to square areas of land of a pre-defined size and location. + +Especially for large datasets, this has the benefit that the data transfer is greatly reduced because because only data within the current viewport, and at the current zoom level needs to be transferred. + + + +In this tutorial, you will learn to + +- Use Overture Maps to download open map places data in GeoJSON format. +- Use GDAL ogr2ogr to transform GeoJSON into SQL statements. +- Import location data and JSON metadata into your Supabase Postgres database using psql. +- Use PostGIS' `ST_AsMVT` to aggregate a set of rows corresponding to a tile layer into a binary vector tile representation. +- Use MapLibre's `addProtocol` to visualize large PostGIS tables by making remote procedure calls with supabase-js. +- Use supabase-js to fetch additional JSON metadata on demand + +## Download open map data with Overture Maps + +Overture Maps provides a [python command-line tool](https://docs.overturemaps.org/getting-data/overturemaps-py/) to download data within a region of interest and converts it to several common geospatial file formats. + +We can download places in Singapore into a GeoJSON file with this command: + +```bash +overturemaps download --bbox=103.570233,1.125077,104.115855,1.490957 -f geojson --type=place -o places.geojson +``` + +Depending on the size of the bounding box this can take quite some time! + +## Transform GeoJSON into SQL + +In the next step, we can use [GDAL ogr2ogr](https://gdal.org/programs/ogr2ogr.html) to transform the GeoJSON file into a PostGIS compatible SQL file. + +You can install `GDAL` via `homebrew brew install gdal` or follow the [download instructions](https://gdal.org/download.html). + +```bash +PG_USE_COPY=true ogr2ogr -f pgdump places.sql places.geojson +``` + +## Import location data into Supabase + +Enable the PostGIS extension on your Supabase Database on a dedicated separate `gis` schema. To do so you can navigate to the [SQL Editor](/dashboard/project/_/sql/new) and run the following SQL, or you can enable the extension from the [Database Extensions Settings](/dashboard/project/_/database/extensions). + +As PostGIS can be quite compute heavy, we recommend enabling it on a decidacted separate schema, for example names `gis`! + +```sql +CREATE SCHEMA IF NOT EXISTS "gis"; +CREATE EXTENSION IF NOT EXISTS "postgis" WITH SCHEMA "gis"; +``` + +Import the open map data into a `places` table in Supabase: + +```bash +psql -h aws-0-us-west-1.pooler.supabase.com -p 5432 -d postgres -U postgres.project-ref < places.sql +``` + +You can find the credentials in the [Database settings](/dashboard/project/_/settings/database) of your Supabase Dashboard. + +### Enable RLS and create a public read policy + +We want the places data to be available publicly, so we can create a row level security policy that enables public read access. + +In your Supabase Dashboard, navigate to the [SQL Editor](/dashboard/project/_/sql/new) and run the following: + +```sql +ALTER TABLE "public"."places" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Enable read access for all users" ON "public"."places" FOR SELECT USING (true); +``` + +## Generate vector tiles with PostGIS + +To programmatically generate vector tiles on client-side request, we need to create a Postgres function that we can invoke via a [remote procedure call](/docs/reference/javascript/rpc). In your SQL Editor, run: + +```sql +CREATE OR REPLACE FUNCTION mvt(z integer, x integer, y integer) +RETURNS text +LANGUAGE plpgsql +AS $$ +DECLARE + mvt_output text; +BEGIN + WITH + -- Define the bounds of the tile using the provided Z, X, Y coordinates + bounds AS ( + SELECT ST_TileEnvelope(z, x, y) AS geom + ), + -- Transform the geometries from EPSG:4326 to EPSG:3857 and clip them to the tile bounds + mvtgeom AS ( + SELECT + -- include the name and id only at zoom 13 to make low-zoom tiles smaller + CASE + WHEN z > 13 THEN id + ELSE NULL + END AS id, + CASE + WHEN z > 13 THEN names::json->>'primary' + ELSE NULL + END AS primary_name, + categories::json->>'main' as main_category, + ST_AsMVTGeom( + ST_Transform(wkb_geometry, 3857), -- Transform the geometry to Web Mercator + bounds.geom, + 4096, -- The extent of the tile in pixels (commonly 256 or 4096) + 0, -- Buffer around the tile in pixels + true -- Clip geometries to the tile extent + ) AS geom + FROM + places, bounds + WHERE + ST_Intersects(ST_Transform(wkb_geometry, 3857), bounds.geom) + ) + -- Generate the MVT from the clipped geometries + SELECT INTO mvt_output encode(ST_AsMVT(mvtgeom, 'places', 4096, 'geom'),'base64') + FROM mvtgeom; + + RETURN mvt_output; +END; +$$; +``` + +To limit the amount of data send over the wire, we limit the amount of metadata to include in the vector tile. For example we add a condition for the zoom level, and only return the place name when the user has zoomed in beyond level 13. + +## Use supabase-js to fetch vector tiles from MapLibre GL client + +You can find the full `index.html` code on [GitHub](https://github.com/bdon/supabase-vector-tile/blob/main/index.html). Here we'll highlight how to add a new protocol to MapLibreGL to fetch the bas64 encoded binary vector tile data via supabase-js so that MapLibre GL can fetch and render the data as your users interact with the map: + +```js index.html +const client = supabase.createClient('you-supabase-api-url', 'your-supabase-anon-key') + +function base64ToArrayBuffer(base64) { + var binaryString = atob(base64) + var bytes = new Uint8Array(binaryString.length) + for (var i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes +} + +maplibregl.addProtocol('supabase', async (params, abortController) => { + const re = new RegExp(/supabase:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/) + const result = params.url.match(re) + const { data, error } = await client.rpc('mvt', { + z: result[2], + x: result[3], + y: result[4], + }) + const encoded = base64ToArrayBuffer(data) + if (!error) { + return { data: encoded } + } else { + throw new Error(`Tile fetch error:`) + } +}) +``` + +With the supabase protocol registered, we can now add it to our MapLibre GL sources on top of a basemap like [Protomaps](https://protomaps.com/) for example: + +```js index.html +// ... +const map = new maplibregl.Map({ + hash: true, + container: 'map', + style: { + version: 8, + glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf', + sources: { + supabase: { + type: 'vector', + tiles: ['supabase://boston/{z}/{x}/{y}'], + attribution: '© Overture Maps Foundation', + }, + protomaps: { + type: 'vector', + url: 'https://api.protomaps.com/tiles/v3.json?key=your-protomaps-api-key', + attribution: 'Basemap © OpenStreetMap', + }, + }, + }, +}) +// ... +``` + +## On demand fetch additional JSON metadata + +To limit the amount of data send over the wire, we don't encode all the metadata in the vector tile itself, but rather set up an onclick handler to fetch the additional metadata on demand within the MapLibre GL popup: + +```js index.html +// .. +const popup = new maplibregl.Popup({ + closeButton: true, + closeOnClick: false, + maxWidth: 'none', +}) + +function loadDetails(element, id) { + element.innerHTML = 'loading...' + client + .from('places') + .select( + ` + websites, + socials, + phones, + addresses, + source: sources->0->dataset + ` + ) + .eq('id', id) + .single() + .then(({ data, error }) => { + if (error) return console.error(error) + element.parentElement.innerHTML = `
${JSON.stringify(data, null, 2)}
` + }) +} + +map.on('click', 'overture-pois-text', async (e) => { + if (e.features.length > 0) { + const feature = e.features[0] + console.log(feature) + popup.setHTML( + ` + + + + + + + + + + + + + + + + + +
id:${feature.properties.id}
name:${feature.properties.primary_name}
main_category:${feature.properties.main_category}
details: + + load details + +
+ ` + ) + popup.setLngLat(e.lngLat) + popup.addTo(map) + } +}) +// ... +``` + +## Conclusion + +PostGIS is incredibly powerful, allowing you to programmatically generate vector tiles from table rows stored in Postgres. Paired with Supabase's auto generated REST API and supabase-js client library you're able to build interactive geospatial applications with ease! + +Want to learn more about Maps and PostGIS? Make sure to follow our [Twitter](https://x.com/supabase) and [YouTube](https://www.youtube.com/@Supabase) channels to not miss out! See you then! + +## More Supabase + +- [Watch the video guide](https://supabase.link/supa-gis-yt-docs) +- [Find the code](https://github.com/bdon/supabase-vector-tile) +- [Self-host Maps on Supabase Storage with Protomaps](https://supabase.link/protomaps-storage-yt) +- [Getting started with PostGIS](https://supabase.link/postgis-quickstart-yt) +- [PostGIS docs guide](/docs/guides/database/extensions/postgis) diff --git a/apps/www/public/images/blog/postgis_vector_tiles/overture_postgis_mvt.png b/apps/www/public/images/blog/postgis_vector_tiles/overture_postgis_mvt.png new file mode 100644 index 00000000000..3072365f503 Binary files /dev/null and b/apps/www/public/images/blog/postgis_vector_tiles/overture_postgis_mvt.png differ diff --git a/package-lock.json b/package-lock.json index e17df090917..676b2fb6eda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1237,6 +1237,7 @@ "@stripe/stripe-js": "^3.0.5", "@supabase/auth-helpers-react": "^0.4.2", "@supabase/pg-meta": "*", + "@supabase/realtime-js-next": "npm:@supabase/realtime-js@2.10.0-next.8", "@supabase/shared-types": "0.1.65", "@supabase/supabase-js": "^2.41.1", "@tanstack/react-query": "4.35.7", @@ -13675,6 +13676,18 @@ "ws": "^8.14.2" } }, + "node_modules/@supabase/realtime-js-next": { + "name": "@supabase/realtime-js", + "version": "2.10.0-next.8", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.0-next.8.tgz", + "integrity": "sha512-mwyvgG6pBqpThgGwZxDQNG7HIwEQgto6ImaLPkGclxyCucTkapCvZiYXQOWrVQKNqe5JiPksNbX+3Ssv7k4xUg==", + "dependencies": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.14.2" + } + }, "node_modules/@supabase/shared-types": { "version": "0.1.65", "resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.65.tgz", diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index b47000ecc80..29b53f47670 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -3063,6 +3063,7 @@ export interface components { plan: components['schemas']['BillingSubscriptionPlan'] project_addons: components['schemas']['BillingProjectAddonResponse'][] scheduled_plan_change: components['schemas']['ScheduledPlanChange'] | null + usage_based_billing_project_addons: boolean usage_billing_enabled: boolean } GetUserContentByIdResponse: {