diff --git a/apps/postgres-new/app/(main)/db/[id]/page.tsx b/apps/postgres-new/app/(main)/db/[id]/page.tsx index cd13c697..eed16979 100644 --- a/apps/postgres-new/app/(main)/db/[id]/page.tsx +++ b/apps/postgres-new/app/(main)/db/[id]/page.tsx @@ -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() { @@ -25,5 +30,33 @@ export default function Page({ params }: { params: { id: string } }) { run() }, [dbManager, databaseId, router]) + if (isLocked) { + return ( +
+ +
+

+ This database is already open in another tab or window. +
+
+ Due to{' '} + + PGlite's single-user mode limitation + + , only one connection is allowed at a time. +
+
+ Please close the database in the other location to access it here. +

+
+
+ ) + } + return } diff --git a/apps/postgres-new/components/lock-provider.tsx b/apps/postgres-new/components/lock-provider.tsx new file mode 100644 index 00000000..b94769c0 --- /dev/null +++ b/apps/postgres-new/components/lock-provider.tsx @@ -0,0 +1,230 @@ +import { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useContext, + useEffect, + useMemo, + useState, +} from 'react' + +type RequireProp = Omit & Required> + +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()) + + // Track locks acquired by this tab + const [selfLocks, setSelfLocks] = useState(new Set()) + + const lockPrefix = `${namespace}:` + + useEffect(() => { + async function updateLocks() { + const locks = await navigator.locks.query() + const held = locks.held + ?.filter( + (lock): lock is RequireProp => + 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 ( + + {children} + + ) +} + +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 + + /** + * The set of keys locked by this tab. + */ + selfLocks: Set + + /** + * Set the locks acquired by this tab. + */ + setSelfLocks: Dispatch>> +} + +export const LockContext = createContext(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((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]) +} diff --git a/apps/postgres-new/components/providers.tsx b/apps/postgres-new/components/providers.tsx index 49a19581..ace9377d 100644 --- a/apps/postgres-new/components/providers.tsx +++ b/apps/postgres-new/components/providers.tsx @@ -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() @@ -12,7 +13,9 @@ export default function Providers({ children }: PropsWithChildren) { return ( - {children} + + {children} + ) diff --git a/apps/postgres-new/components/sidebar.tsx b/apps/postgres-new/components/sidebar.tsx index 3356eb47..9db7d255 100644 --- a/apps/postgres-new/components/sidebar.tsx +++ b/apps/postgres-new/components/sidebar.tsx @@ -1,5 +1,6 @@ 'use client' +import { TooltipPortal } from '@radix-ui/react-tooltip' import { AnimatePresence, m } from 'framer-motion' import { ArrowLeftToLine, @@ -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 { @@ -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 { @@ -309,6 +310,8 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { const { data: isOnDeployWaitlist } = useIsOnDeployWaitlistQuery() const { mutateAsync: joinDeployWaitlist } = useDeployWaitlistCreateMutation() + const isLocked = useIsLocked(database.id, true) + return ( <> { e.preventDefault() @@ -460,6 +464,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { Rename { e.preventDefault() @@ -493,7 +498,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { setIsDeployDialogOpen(true) setIsPopoverOpen(false) }} - disabled={user === undefined} + disabled={user === undefined || isLocked} > { e.preventDefault() @@ -539,6 +546,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { type ConnectMenuItemProps = { databaseId: string isActive: boolean + disabled?: boolean setIsPopoverOpen: (open: boolean) => void } @@ -564,7 +572,7 @@ function LiveShareMenuItem(props: ConnectMenuItemProps) { return ( { e.preventDefault()