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