Merge branch 'master' of github.com:supabase/supabase

This commit is contained in:
Terry Sutton committed 2024-06-26 16:24:35 -02:30
commit 65db94bb2e
47 files changed
+1265 -210

No files matched your search

@@ -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',
@@ -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.
</TabPanel>
<TabPanel id="sql" label="SQL">
```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;
@@ -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',
@@ -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
---
<StepHikeCompact>
<StepHikeCompact.Step step={1}>
<StepHikeCompact.Details title="Set up a Supabase project with sample data">
[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.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```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');
````
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={2}>
<StepHikeCompact.Details title="Create an iOS SwiftUI app with Xcode">
Open Xcode > New Project > iOS > App. You can skip this step if you already have a working app.
</StepHikeCompact.Details>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={3}>
<StepHikeCompact.Details title="Install the Supabase client library">
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.
</StepHikeCompact.Details>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Initialize the Supabase client">
Create a new `Supabase.swift` file add a new supabase instance using your project URL and public API (anon) key:
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="anonKey" />
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```swift Supabase.swift
import Supabase
let supabase = SupabaseClient(
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
supabaseKey: "YOUR_SUPABASE_ANON_KEY"
)
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Details title="Create a data model for countries">
Create a decodable struct to deserialize the data from the database.
Add the following code to a new file named `Country.swift`.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```swift Supabase.swift
struct Country: Decodable, Identifiable {
let id: Int
let name: String
}
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Details title="Query data from the app">
Use a `task` to fetch the data from the database and display it using a `List`.
Replace the default `ContentView` with the following code.
</StepHikeCompact.Details>
<StepHikeCompact.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)
}
}
}
}
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={7}>
<StepHikeCompact.Details title="Start the app">
Run the app on a simulator or a physical device by hitting `Cmd + R` on Xcode.
</StepHikeCompact.Details>
</StepHikeCompact.Step>
</StepHikeCompact>
@@ -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
}
@@ -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.
+1
View File
@@ -23,6 +23,7 @@ Deji I
Div Arora
Divit D
Egor Romanov
Etienne Stalmans
Fabrizio Fenoglio
Francesco Sansalvadore
Greg Richardson
@@ -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 }) => {
}
>
<Panel.Content>
<div className="space-y-2">
<Input
readOnly
disabled
label="Username"
layout="horizontal"
value={profile?.username ?? ''}
/>
<Input
readOnly
disabled
label="Email"
layout="horizontal"
value={profile?.primary_email ?? ''}
/>
<div className="grid gap-4">
<div className="grid grid-cols-12 gap-6">
<Label_Shadcn_ className="col-span-4 text-foreground-light">Username</Label_Shadcn_>
<Input_Shadcn_
className="col-span-8"
readOnly
disabled
placeholder="Username"
value={profile?.username ?? ''}
/>
</div>
<div className="grid grid-cols-12 gap-6">
<Label_Shadcn_ className="col-span-4 text-foreground-light">Email</Label_Shadcn_>
<div className="col-span-8">
<Input_Shadcn_
className="w-full"
readOnly
disabled
placeholder="Email"
value={profile?.primary_email ?? ''}
/>
<div className="col-span-8 mt-4">
<Collapsible_Shadcn_ className="-mt-1.5 pb-1.5">
<CollapsibleTrigger_Shadcn_ className="group font-normal p-0 [&[data-state=open]>div>svg]:!-rotate-180">
<div className="flex items-center gap-x-2 w-full">
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
How can I change my email?
</p>
<ChevronDown
className="transition-transform duration-200"
strokeWidth={1.5}
size={14}
/>
</div>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className=" mt-2">
<div className="bg-surface-200 p-4 rounded-lg grid gap-4">
{session?.user.app_metadata.provider === 'email' ? (
<div>
<ol className="text-sm ml-4 pl-2 list-decimal">
<li>Create a new account with the new email</li>
<li>Invite this email to be an owner of the Org</li>
<li>Join the Org</li>
<li>Remove the old email from the Org</li>
<li>Email Supabase Support to delete the old email (Optional) </li>
</ol>
</div>
) : (
<div>
<ol className="text-sm ml-4 pl-2 list-decimal">
<li>Log out of Supabase</li>
<li>
Change your Primary Email in{' '}
<Link href="https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/changing-your-primary-email-address">
GitHub
</Link>{' '}
(your public, not primary email)
</li>
<li>Log out of GitHub</li>
<li>
Log back into GitHub (with the new, desired email set as primary)
</li>
<li>Log back into Supabase</li>
</ol>
</div>
)}
</div>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</div>
</div>
</div>
{session?.user.app_metadata.provider === 'email' && (
<div className="text-sm grid gap-2 md:grid md:grid-cols-12 md:gap-x-4">
<div className="flex flex-col space-y-2 col-span-4 ">
@@ -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
}
@@ -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'
: ''
@@ -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({
@@ -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()
},
@@ -145,7 +145,7 @@ const Policies = ({
<section key={table.id}>
<PolicyTableRow
table={table}
isLocked={isLocked}
isLocked={schema === 'realtime' ? true : isLocked}
onSelectToggleRLS={onSelectToggleRLS}
onSelectCreatePolicy={() => onSelectCreatePolicy(table.name)}
onSelectEditPolicy={onSelectEditPolicy}
@@ -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
}
@@ -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 (
<div id={table.id.toString()} className="flex w-full items-center justify-between">
<div className="flex gap-x-4 text-left">
@@ -36,7 +40,7 @@ const PolicyTableRowHeader = ({
<h4 className="m-0">{table.name}</h4>
</Link>
<div className="flex items-center gap-x-2">
{isLocked && (
{isTableLocked && (
<Badge>
<span className="flex gap-2 items-center text-xs uppercase text-foreground-lighter">
<Lock size={12} /> Locked
@@ -48,22 +52,22 @@ const PolicyTableRowHeader = ({
</Badge>
</div>
</div>
{!isLocked && (
{!isTableLocked && (
<div className="flex-1">
<div className="flex flex-row justify-end gap-x-2">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="default"
disabled={!canToggleRLS}
onClick={() => onSelectToggleRLS(table)}
>
{table.rls_enabled ? 'Disable RLS' : 'Enable RLS'}
</Button>
</Tooltip.Trigger>
{!canToggleRLS && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
{!isRealtimeMessagesTable ? (
<Tooltip_Shadcn_ delayDuration={0}>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
disabled={!canToggleRLS}
onClick={() => onSelectToggleRLS(table)}
>
{table.rls_enabled ? 'Disable RLS' : 'Enable RLS'}
</Button>
</TooltipTrigger_Shadcn_>
{!canToggleRLS && (
<TooltipContent_Shadcn_ side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
@@ -75,10 +79,10 @@ const PolicyTableRowHeader = ({
You need additional permissions to toggle RLS
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
</TooltipContent_Shadcn_>
)}
</Tooltip_Shadcn_>
) : null}
{!isAiAssistantEnabled && (
<Button
type="default"
@@ -164,10 +164,8 @@ const Connect = () => {
<>
<Dialog>
<DialogTrigger asChild>
<Button type="primary">
<span className="flex items-center gap-2 px-3">
<Plug size={14} className="rotate-90" /> <span>Connect</span>
</span>
<Button type="primary" icon={<Plug className="rotate-90" />}>
<span>Connect</span>
</Button>
</DialogTrigger>
<DialogContent className={cn('sm:max-w-5xl p-0')}>
@@ -95,6 +95,7 @@ const TaxID = () => {
? {
type: type,
value: sanitizeTaxIdValue({ value, name: form.getValues().name }),
country: selectedTaxId?.countryIso2,
}
: null,
})
@@ -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<SetStateAction<RealtimeConfig>>
}
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<z.infer<typeof FormSchema>>({
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 ? (
<>
<Form_Shadcn_ {...form}>
<form id="realtime-channel" onSubmit={form.handleSubmit(() => onSubmit())}>
<form
id="realtime-channel"
onSubmit={form.handleSubmit(() => onSubmit())}
className="flex flex-col gap-y-4"
>
<FormField_Shadcn_
name="channel"
control={form.control}
render={({ field }) => (
<FormItem_Shadcn_>
<label className="text-foreground text-xs mb-2">Name of channel</label>
<div className="flex">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
autoComplete="off"
className="rounded-r-none text-xs px-2.5 py-1 h-auto"
placeholder="Enter a channel name"
/>
</FormControl_Shadcn_>
<Button
type="primary"
className="rounded-l-none"
disabled={form.getValues().channel.length === 0}
onClick={() => onSubmit()}
>
Listen to channel
</Button>
<FormItem_Shadcn_ className="flex flex-col gap-y-2">
<div className="flex flex-col gap-y-1">
<label className="text-foreground text-xs">Name of channel</label>
<div className="flex flex-row">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
autoComplete="off"
className="rounded-r-none text-xs px-2.5 py-1 h-auto"
placeholder="Enter a channel name"
/>
</FormControl_Shadcn_>
<Button
type="primary"
className="rounded-l-none"
disabled={form.getValues().channel.length === 0}
onClick={() => onSubmit()}
>
Listen to channel
</Button>
</div>
</div>
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter">
The channel you initialize with the Supabase Realtime client. Learn more
in{' '}
<a
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground transition"
href="https://supabase.com/docs/guides/realtime/concepts#channels"
>
our docs
</a>
</FormDescription_Shadcn_>
</FormItem_Shadcn_>
)}
/>
{authzEnabled ? (
<FormField_Shadcn_
key="isPrivate"
control={form.control}
name="isPrivate"
render={({ field }) => (
<FormItem_Shadcn_ className="">
<div className="flex flex-row items-center gap-x-2">
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={field.disabled}
/>
</FormControl_Shadcn_>
<FormLabel_Shadcn_ className="text-xs">
Is channel private?
</FormLabel_Shadcn_>
</div>
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter mt-2">
If the channel is marked as private, it will use RLS policies to filter
messages.
</FormDescription_Shadcn_>
</FormItem_Shadcn_>
)}
/>
) : null}
<Button asChild type="default" className="w-min" icon={<ExternalLink />}>
<a
target="_blank"
rel="noreferrer"
href="https://github.com/orgs/supabase/discussions/22484"
>
Documentation
</a>
</Button>
</form>
</Form_Shadcn_>
<p className="text-xs text-foreground-lighter mt-2">
The channel you initialize with the Supabase Realtime client. Learn more in{' '}
<Link
href="https://supabase.com/docs/guides/realtime/concepts#channels"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground transition"
>
our docs
</Link>
</p>
</>
) : (
<div className="space-y-2">
<div className="flex items-center gap-x-2">
<p className="text-foreground text-xs">Currently joined channel:</p>
<p className="text-foreground text-xs">
Currently joined{' '}
<span className={config.isChannelPrivate ? 'text-brand' : 'text-warning'}>
{config.isChannelPrivate ? 'private' : 'public'}
</span>{' '}
channel:
</p>
<p className="text-xs border border-scale-600 py-0.5 px-1 rounded-md bg-surface-200">
{config.channelName}
</p>
@@ -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,
@@ -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<SupabaseClient<any, 'public', any> | undefined>()
let [client, setClient] = useState<RealtimeClient>()
let [channel, setChannel] = useState<RealtimeChannel | undefined>()
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'
@@ -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<PostgresPolicy>()
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 (
<div className="flex min-h-full w-full flex-col p-4 gap-y-4">
<FormHeader
className="!mb-0"
title="Realtime policies"
description="You can use RLS policies to control access to Realtime Channels"
/>
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve tables" />}
{isSuccess && (
<div className="space-y-4">
<Policies
schema="realtime"
tables={filteredTables}
hasTables
isLocked={false}
onSelectCreatePolicy={() => {
setSelectedPolicyToEdit(undefined)
setShowPolicyEditor(true)
}}
onSelectEditPolicy={(policy) => {
setSelectedPolicyToEdit(policy)
setShowPolicyEditor(true)
}}
/>
</div>
)}
<AIPolicyEditorPanel
visible={showPolicyEditor}
searchString="messages"
schema="realtime"
selectedPolicy={selectedPolicyToEdit}
onSelectCancel={() => setShowPolicyEditor(false)}
/>
</div>
)
}
@@ -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
@@ -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 }
}
@@ -110,7 +110,7 @@ const UtilityPanel = ({
return (
<Tabs_Shadcn_ defaultValue="results" className="w-full h-full flex flex-col">
<TabsList_Shadcn_ className="flex justify-between gap-2 px-2">
<TabsList_Shadcn_ className="flex justify-between gap-2 pl-6 pr-2">
<div className="flex items-center gap-4">
<TabsTrigger_Shadcn_ className="py-3 text-xs" value="results">
<span className="translate-y-[1px]">Results</span>
@@ -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 && (
<p className="text-sm text-foreground-light">
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.
<span>
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.
</span>
</p>
)
: !subscription?.billing_via_partner && (
<p className="text-sm text-foreground-light">
Upon clicking confirm, the amount of{' '}
<span className="text-foreground">
{formatCurrency(selectedCustomDomain?.price)}
</span>{' '}
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 ? (
<span>
Upon clicking confirm, the amount of{' '}
<span className="text-foreground">
{formatCurrency(selectedCustomDomain?.price)}
</span>{' '}
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.
</span>
) : (
<span>
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.
</span>
)}
</p>
)}
{subscription?.billing_via_partner &&
subscription.scheduled_plan_change?.target_plan !== undefined && (
<Alert_Shadcn_ variant={'warning'} className="mb-2">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription_Shadcn_>
You have a scheduled subscription change that will be canceled if you change
your custom domain add on.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{
// 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 && (
<Alert_Shadcn_ variant={'warning'} className="mb-2">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription_Shadcn_>
You have a scheduled subscription change that will be canceled if you change
your custom domain add on.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
</>
)}
@@ -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 && (
<p className="text-sm text-foreground-light">
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 = () => {
</p>
{!subscription?.billing_via_partner && (
<p className="text-sm text-foreground-light">
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 ? (
<span>
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.
</span>
) : (
<span>
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.
</span>
)}
</p>
)}
{
// 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 && (
<Alert_Shadcn_ variant={'warning'} className="mb-2">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription_Shadcn_>
You have a scheduled subscription change that will be canceled if you
change your PITR add on.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
</>
)}
</>
@@ -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 && (
<p className="text-sm text-foreground-light">
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 && (
<p className="text-sm text-foreground-light">
Upon clicking confirm, the amount of{' '}
<span className="text-foreground">{formatCurrency(selectedPitr?.price)}</span>{' '}
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 ? (
<span>
Upon clicking confirm, the amount of{' '}
<span className="text-foreground">
{formatCurrency(selectedPitr?.price)}
</span>{' '}
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.
</span>
) : (
<span>
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.
</span>
)}
</p>
)}
{subscription?.billing_via_partner &&
subscription.scheduled_plan_change?.target_plan !== undefined && (
<Alert_Shadcn_ variant={'warning'} className="mb-2">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription_Shadcn_>
You have a scheduled subscription change that will be canceled if you change
your PITR add on.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{
// 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 && (
<Alert_Shadcn_ variant={'warning'} className="mb-2">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription_Shadcn_>
You have a scheduled subscription change that will be canceled if you change
your PITR add on.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
</>
)}
</div>
@@ -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
? [
@@ -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<UpdateColumnPayload> = {}
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)) {
@@ -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<RealtimeLayoutProps>) => {
const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLayoutProps>) => {
const project = useSelectedProject()
const router = useRouter()
const page = router.pathname.split('/')[4]
const authzEnabled = useFlag('authzRealtime')
return (
<ProjectLayout
isLoading={false}
title={title}
product="Realtime"
productMenu={<ProductMenu page={page} menu={generateRealtimeMenu(project)} />}
productMenu={
<ProductMenu page={page} menu={generateRealtimeMenu(project!, { authzEnabled })} />
}
>
{children}
</ProjectLayout>
@@ -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: [],
},
],
},
]
: []),
]
}
@@ -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<string>('')
const [visibleTypes, setVisibleTypes] = useState<string[]>(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 = () => {
</InnerSideBarFilterSortDropdownItem>
</InnerSideBarFilterSortDropdown>
</InnerSideBarFilterSearchInput>
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
className="h-[28px] px-1.5"
icon={<Filter />}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-56" side="bottom" align="center">
<div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
<p className="text-xs">Show entity types</p>
<div className="flex flex-col">
{Object.entries(ENTITY_TYPE).map(([key, value]) => (
<div key={key} className="group flex items-center justify-between py-0.5">
<div className="flex items-center gap-x-2">
<Checkbox_Shadcn_
id={key}
name={key}
checked={visibleTypes.includes(value)}
onCheckedChange={() => {
if (visibleTypes.includes(value)) {
setVisibleTypes(visibleTypes.filter((y) => y !== value))
} else {
setVisibleTypes(visibleTypes.concat([value]))
}
}}
/>
<Label_Shadcn_ htmlFor={key} className="capitalize text-xs">
{key.toLowerCase().replace('_', ' ')}
</Label_Shadcn_>
</div>
<Button
size="tiny"
type="default"
onClick={() => setVisibleTypes([value])}
className="transition opacity-0 group-hover:opacity-100 h-auto px-1 py-0.5"
>
Select only
</Button>
</div>
))}
</div>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</InnerSideBarFilters>
{isLoading && <InnerSideBarShimmeringLoaders />}
+1 -1
View File
@@ -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),
}
@@ -10,6 +10,7 @@ export type EntityTypesVariables = {
limit?: number
page?: number
sort?: 'alphabetical' | 'grouped-alphabetical'
filterTypes?: string[]
} & Pick<ExecuteSqlVariables, 'connectionString'>
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 = <TData = EntityTypesData>(
search,
limit = 100,
sort,
filterTypes,
}: Omit<EntityTypesVariables, 'page'>,
{
enabled = true,
@@ -120,10 +123,10 @@ export const useEntityTypesQuery = <TData = EntityTypesData>(
}: UseInfiniteQueryOptions<EntityTypesData, EntityTypesError, TData> = {}
) =>
useInfiniteQuery<EntityTypesData, EntityTypesError, TData>(
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
),
{
+1
View File
@@ -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,
}
@@ -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)
+24 -24
View File
@@ -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 = {
+3 -3
View File
@@ -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:;`,
+1
View File
@@ -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",
@@ -38,6 +38,7 @@ const handleGet = async (req: NextApiRequest, res: NextApiResponse<ResponseData>
scheduled_plan_change: null,
customer_balance: 0,
nano_enabled: false,
usage_based_billing_project_addons: false,
}
return res.status(200).json(response)
@@ -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)
@@ -16,6 +16,8 @@ export const InspectorPage: NextPageWithLayout = () => {
return <RealtimeInspector />
}
InspectorPage.getLayout = (page) => <RealtimeLayout title="Realtime">{page}</RealtimeLayout>
InspectorPage.getLayout = (page) => (
<RealtimeLayout title="Realtime Inspector">{page}</RealtimeLayout>
)
export default InspectorPage
@@ -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 <RealtimePolicies />
}
RealtimePoliciesPage.getLayout = (page) => <RealtimeLayout title="Policies">{page}</RealtimeLayout>
export default RealtimePoliciesPage
@@ -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
---
<Admonition>
[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)
</Admonition>
[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.
<Admonition>
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.
</Admonition>
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: '© <a href="https://overturemaps.org">Overture Maps Foundation</a>',
},
protomaps: {
type: 'vector',
url: 'https://api.protomaps.com/tiles/v3.json?key=your-protomaps-api-key',
attribution: 'Basemap © <a href="https://openstreetmap.org">OpenStreetMap</a>',
},
},
},
})
// ...
```
## 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 = `<pre>${JSON.stringify(data, null, 2)}</pre>`
})
}
map.on('click', 'overture-pois-text', async (e) => {
if (e.features.length > 0) {
const feature = e.features[0]
console.log(feature)
popup.setHTML(
`
<table style="font-size:12px">
<tr>
<td>id:</td>
<td>${feature.properties.id}</td>
</tr>
<tr>
<td>name:</td>
<td>${feature.properties.primary_name}</td>
</tr>
<tr>
<td>main_category:</td>
<td>${feature.properties.main_category}</td>
</tr>
<tr>
<td>details:</td>
<td>
<span onclick="loadDetails(this, '${feature.properties.id}')">
load details
</span>
</td>
</tr>
</table>
`
)
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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+13
View File
@@ -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",
+1
View File
@@ -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: {