Skip to content

Commit

Permalink
Merge pull request supabase-community#135 from supabase-community/fea…
Browse files Browse the repository at this point in the history
…t/navigator-locks

feat: lock databases usage in other tabs or windows
  • Loading branch information
gregnr authored Nov 14, 2024
2 parents 4900c04 + 0f0b4a8 commit a8628ce
Show file tree
Hide file tree
Showing 4 changed files with 279 additions and 5 deletions.
33 changes: 33 additions & 0 deletions apps/postgres-new/app/(main)/db/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
'use client'

import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { useApp } from '~/components/app-provider'
import { useAcquireLock, useIsLocked } from '~/components/lock-provider'
import Workspace from '~/components/workspace'
import NewDatabasePage from '../../page'

export default function Page({ params }: { params: { id: string } }) {
const databaseId = params.id
const router = useRouter()
const { dbManager } = useApp()
useAcquireLock(databaseId)
const isLocked = useIsLocked(databaseId, true)

useEffect(() => {
async function run() {
Expand All @@ -25,5 +30,33 @@ export default function Page({ params }: { params: { id: string } }) {
run()
}, [dbManager, databaseId, router])

if (isLocked) {
return (
<div className="relative h-full w-full">
<NewDatabasePage />
<div className="absolute inset-0 bg-background/70 backdrop-blur-sm flex items-center justify-center">
<p>
This database is already open in another tab or window.
<br />
<br />
Due to{' '}
<Link
target="_blank"
rel="noopener noreferrer"
className="underline"
href="https://github.com/electric-sql/pglite?tab=readme-ov-file#how-it-works"
>
PGlite&apos;s single-user mode limitation
</Link>
, only one connection is allowed at a time.
<br />
<br />
Please close the database in the other location to access it here.
</p>
</div>
</div>
)
}

return <Workspace databaseId={databaseId} visibility="local" />
}
230 changes: 230 additions & 0 deletions apps/postgres-new/components/lock-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import {
createContext,
Dispatch,
PropsWithChildren,
SetStateAction,
useContext,
useEffect,
useMemo,
useState,
} from 'react'

type RequireProp<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>

export type LockProviderProps = PropsWithChildren<{
/**
* The namespace for the locks. Used in both the
* `BroadcastChannel` and the lock names.
*/
namespace: string
}>

/**
* A provider that manages locks across multiple tabs.
*/
export function LockProvider({ namespace, children }: LockProviderProps) {
// Receive messages from other tabs
const broadcastChannel = useMemo(() => new BroadcastChannel(namespace), [namespace])

// Receive messages from self
const selfChannel = useMemo(() => new MessageChannel(), [])
const messagePort = selfChannel.port1

// Track locks across all tabs
const [locks, setLocks] = useState(new Set<string>())

// Track locks acquired by this tab
const [selfLocks, setSelfLocks] = useState(new Set<string>())

const lockPrefix = `${namespace}:`

useEffect(() => {
async function updateLocks() {
const locks = await navigator.locks.query()
const held = locks.held
?.filter(
(lock): lock is RequireProp<LockInfo, 'name'> =>
lock.name !== undefined && lock.name.startsWith(lockPrefix)
)
.map((lock) => lock.name.slice(lockPrefix.length))

if (!held) {
return
}

setLocks(new Set(held))
}

updateLocks()
messagePort.start()

broadcastChannel.addEventListener('message', updateLocks)
messagePort.addEventListener('message', updateLocks)

return () => {
broadcastChannel.removeEventListener('message', updateLocks)
messagePort.removeEventListener('message', updateLocks)
}
}, [lockPrefix, broadcastChannel, messagePort])

return (
<LockContext.Provider
value={{
namespace,
broadcastChannel,
messagePort: selfChannel.port2,
locks,
selfLocks,
setSelfLocks,
}}
>
{children}
</LockContext.Provider>
)
}

export type LockContextValues = {
/**
* The namespace for the locks. Used in both the
* `BroadcastChannel` and the lock names.
*/
namespace: string

/**
* The `BroadcastChannel` used to notify other tabs
* of lock changes.
*/
broadcastChannel: BroadcastChannel

/**
* The `MessagePort` used to notify this tab of
* lock changes.
*/
messagePort: MessagePort

/**
* The set of keys locked across all tabs.
*/
locks: Set<string>

/**
* The set of keys locked by this tab.
*/
selfLocks: Set<string>

/**
* Set the locks acquired by this tab.
*/
setSelfLocks: Dispatch<SetStateAction<Set<string>>>
}

export const LockContext = createContext<LockContextValues | undefined>(undefined)

/**
* Hook to access the locks acquired by all tabs.
* Can optionally exclude keys acquired by current tab.
*/
export function useLocks(excludeSelf = false) {
const context = useContext(LockContext)

if (!context) {
throw new Error('LockContext missing. Are you accessing useLocks() outside of an LockProvider?')
}

let set = context.locks

if (excludeSelf) {
set = set.difference(context.selfLocks)
}

return set
}

/**
* Hook to check if a key is locked by any tab.
* Can optionally exclude keys acquired by current tab.
*/
export function useIsLocked(key: string, excludeSelf = false) {
const context = useContext(LockContext)

if (!context) {
throw new Error(
'LockContext missing. Are you accessing useIsLocked() outside of an LockProvider?'
)
}

let set = context.locks

if (excludeSelf) {
set = set.difference(context.selfLocks)
}

return set.has(key)
}

/**
* Hook to acquire a lock for a key across all tabs.
*/
export function useAcquireLock(key: string) {
const context = useContext(LockContext)

if (!context) {
throw new Error(
'LockContext missing. Are you accessing useAcquireLock() outside of an LockProvider?'
)
}

const { namespace, broadcastChannel, messagePort, setSelfLocks } = context

const lockPrefix = `${namespace}:`
const lockName = `${namespace}:${key}`

useEffect(() => {
const abortController = new AbortController()
let releaseLock: () => void

// Request the lock and notify listeners
navigator.locks
.request(lockName, { signal: abortController.signal }, () => {
const key = lockName.startsWith(lockPrefix) ? lockName.slice(lockPrefix.length) : undefined

if (!key) {
return
}

broadcastChannel.postMessage({ type: 'acquire', key })
messagePort.postMessage({ type: 'acquire', key })
setSelfLocks((locks) => locks.union(new Set([key])))

return new Promise<void>((resolve) => {
releaseLock = resolve
})
})
.then(async () => {
const key = lockName.startsWith(lockPrefix) ? lockName.slice(lockPrefix.length) : undefined

if (!key) {
return
}

broadcastChannel.postMessage({ type: 'release', key })
messagePort.postMessage({ type: 'release', key })
setSelfLocks((locks) => locks.difference(new Set([key])))
})
.catch(() => {})

// Release the lock when the component is unmounted
function unload() {
abortController.abort('unmount')
releaseLock?.()
}

// Release the lock when the tab is closed
window.addEventListener('beforeunload', unload)

return () => {
unload()
window.removeEventListener('beforeunload', unload)
}
}, [lockName, lockPrefix, broadcastChannel, messagePort, setSelfLocks])
}
5 changes: 4 additions & 1 deletion apps/postgres-new/components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

import { PropsWithChildren } from 'react'
import AppProvider from './app-provider'
import { LockProvider } from './lock-provider'
import { ThemeProvider } from './theme-provider'

const queryClient = new QueryClient()
Expand All @@ -12,7 +13,9 @@ export default function Providers({ children }: PropsWithChildren) {
return (
<ThemeProvider attribute="class" defaultTheme="system" disableTransitionOnChange>
<QueryClientProvider client={queryClient}>
<AppProvider>{children}</AppProvider>
<LockProvider namespace="database-build-lock">
<AppProvider>{children}</AppProvider>
</LockProvider>
</QueryClientProvider>
</ThemeProvider>
)
Expand Down
16 changes: 12 additions & 4 deletions apps/postgres-new/components/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { TooltipPortal } from '@radix-ui/react-tooltip'
import { AnimatePresence, m } from 'framer-motion'
import {
ArrowLeftToLine,
Expand Down Expand Up @@ -32,6 +33,8 @@ import { downloadFile, titleToKebabCase } from '~/lib/util'
import { cn } from '~/lib/utils'
import { useApp } from './app-provider'
import { CodeBlock } from './code-block'
import { LiveShareIcon } from './live-share-icon'
import { useIsLocked } from './lock-provider'
import SignInButton from './sign-in-button'
import ThemeDropdown from './theme-dropdown'
import {
Expand All @@ -41,8 +44,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu'
import { TooltipPortal } from '@radix-ui/react-tooltip'
import { LiveShareIcon } from './live-share-icon'

export default function Sidebar() {
const {
Expand Down Expand Up @@ -309,6 +310,8 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) {
const { data: isOnDeployWaitlist } = useIsOnDeployWaitlistQuery()
const { mutateAsync: joinDeployWaitlist } = useDeployWaitlistCreateMutation()

const isLocked = useIsLocked(database.id, true)

return (
<>
<Dialog
Expand Down Expand Up @@ -446,6 +449,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) {
) : (
<div className="flex flex-col items-stretch w-32">
<DropdownMenuItem
disabled={isLocked}
className="gap-3"
onSelect={async (e) => {
e.preventDefault()
Expand All @@ -460,6 +464,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) {
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={isLocked}
className="gap-3"
onSelect={async (e) => {
e.preventDefault()
Expand Down Expand Up @@ -493,7 +498,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) {
setIsDeployDialogOpen(true)
setIsPopoverOpen(false)
}}
disabled={user === undefined}
disabled={user === undefined || isLocked}
>
<Upload
size={16}
Expand All @@ -506,9 +511,11 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) {
databaseId={database.id}
isActive={isActive}
setIsPopoverOpen={setIsPopoverOpen}
disabled={user === undefined || isLocked}
/>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isLocked}
className="gap-3"
onSelect={async (e) => {
e.preventDefault()
Expand Down Expand Up @@ -539,6 +546,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) {
type ConnectMenuItemProps = {
databaseId: string
isActive: boolean
disabled?: boolean
setIsPopoverOpen: (open: boolean) => void
}

Expand All @@ -564,7 +572,7 @@ function LiveShareMenuItem(props: ConnectMenuItemProps) {

return (
<DropdownMenuItem
disabled={!user}
disabled={props.disabled}
className="bg-inherit justify-start hover:bg-neutral-200 flex gap-3"
onClick={async (e) => {
e.preventDefault()
Expand Down

0 comments on commit a8628ce

Please sign in to comment.