From 8af99d68449a6960130c9e8df6ecc32a1c7d9ca4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 8 Nov 2024 10:13:59 +0100 Subject: [PATCH 1/6] it seems to work --- apps/postgres-new/app/(main)/db/[id]/page.tsx | 7 + apps/postgres-new/components/providers.tsx | 5 +- apps/postgres-new/lib/database-locks.tsx | 143 ++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 apps/postgres-new/lib/database-locks.tsx diff --git a/apps/postgres-new/app/(main)/db/[id]/page.tsx b/apps/postgres-new/app/(main)/db/[id]/page.tsx index cd13c697..c4ce243a 100644 --- a/apps/postgres-new/app/(main)/db/[id]/page.tsx +++ b/apps/postgres-new/app/(main)/db/[id]/page.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation' import { useEffect } from 'react' import { useApp } from '~/components/app-provider' import Workspace from '~/components/workspace' +import { useDatabaseLock } from '~/lib/database-locks' export default function Page({ params }: { params: { id: string } }) { const databaseId = params.id @@ -25,5 +26,11 @@ export default function Page({ params }: { params: { id: string } }) { run() }, [dbManager, databaseId, router]) + const isLocked = useDatabaseLock(databaseId) + + if (isLocked) { + return
Database is locked
+ } + return } diff --git a/apps/postgres-new/components/providers.tsx b/apps/postgres-new/components/providers.tsx index 49a19581..77a3420f 100644 --- a/apps/postgres-new/components/providers.tsx +++ b/apps/postgres-new/components/providers.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PropsWithChildren } from 'react' import AppProvider from './app-provider' import { ThemeProvider } from './theme-provider' +import { DatabaseLocksProvider } from '~/lib/database-locks' const queryClient = new QueryClient() @@ -12,7 +13,9 @@ export default function Providers({ children }: PropsWithChildren) { return ( - {children} + + {children} + ) diff --git a/apps/postgres-new/lib/database-locks.tsx b/apps/postgres-new/lib/database-locks.tsx new file mode 100644 index 00000000..692fb0d2 --- /dev/null +++ b/apps/postgres-new/lib/database-locks.tsx @@ -0,0 +1,143 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react' + +type DatabaseLocks = { + [databaseId: string]: string // databaseId -> tabId mapping +} + +function getTabId() { + const stored = sessionStorage.getItem('tabId') + if (stored) return stored + + const newId = crypto.randomUUID() + sessionStorage.setItem('tabId', newId) + return newId +} + +const DatabaseLocksContext = createContext<{ + locks: DatabaseLocks + acquireLock: (databaseId: string) => void + releaseLock: (databaseId: string) => void +}>({ + locks: {}, + acquireLock: () => {}, + releaseLock: () => {}, +}) + +export function DatabaseLocksProvider({ children }: { children: React.ReactNode }) { + const tabId = getTabId() + const lockKey = 'dbLocks' + + // Initialize with current localStorage state + const [locks, setLocks] = useState(() => + JSON.parse(localStorage.getItem(lockKey) || '{}') + ) + + const acquireLock = useCallback( + (databaseId: string) => { + const currentLocks = JSON.parse(localStorage.getItem(lockKey) || '{}') as DatabaseLocks + + if (!(databaseId in currentLocks)) { + const newLocks = { ...currentLocks, [databaseId]: tabId } + localStorage.setItem(lockKey, JSON.stringify(newLocks)) + setLocks(newLocks) + } + }, + [tabId] + ) + + const releaseLock = useCallback( + (databaseId: string) => { + const currentLocks = JSON.parse(localStorage.getItem(lockKey) || '{}') as DatabaseLocks + + if (currentLocks[databaseId] === tabId) { + const { [databaseId]: _, ...newLocks } = currentLocks + + if (Object.keys(newLocks).length === 0) { + localStorage.removeItem(lockKey) + } else { + localStorage.setItem(lockKey, JSON.stringify(newLocks)) + } + + setLocks(newLocks) + } + }, + [tabId] + ) + + useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === lockKey) { + setLocks(JSON.parse(event.newValue || '{}')) + } + } + + window.addEventListener('storage', handleStorageChange) + + const handleBeforeUnload = () => { + const currentLocks = JSON.parse(localStorage.getItem(lockKey) || '{}') as DatabaseLocks + const newLocks: DatabaseLocks = {} + + for (const [dbId, lockingTabId] of Object.entries(currentLocks)) { + if (lockingTabId !== tabId) { + newLocks[dbId] = lockingTabId + } + } + + if (Object.keys(newLocks).length === 0) { + localStorage.removeItem(lockKey) + } else { + localStorage.setItem(lockKey, JSON.stringify(newLocks)) + } + } + + window.addEventListener('beforeunload', handleBeforeUnload) + + return () => { + window.removeEventListener('storage', handleStorageChange) + window.removeEventListener('beforeunload', handleBeforeUnload) + } + }, [lockKey, tabId]) + + return ( + + {children} + + ) +} + +export function useDatabaseLock(databaseId: string) { + const context = useContext(DatabaseLocksContext) + const tabId = getTabId() + + if (!context) { + throw new Error('useDatabaseLock must be used within a DatabaseLocksProvider') + } + + const { locks, acquireLock, releaseLock } = context + const isLocked = locks[databaseId] !== undefined && locks[databaseId] !== tabId + + useEffect(() => { + acquireLock(databaseId) + + const handleStorageChange = (event: StorageEvent) => { + if (event.key === 'dbLocks') { + const newLocks = JSON.parse(event.newValue || '{}') as DatabaseLocks + const isAvailable = !(databaseId in newLocks) + + if (isAvailable) { + console.log('Database became available, acquiring lock:', databaseId) + acquireLock(databaseId) + } + } + } + + window.addEventListener('storage', handleStorageChange) + + return () => { + releaseLock(databaseId) + window.removeEventListener('storage', handleStorageChange) + } + }, [databaseId, acquireLock, releaseLock]) + + return isLocked +} From 2075f269bbb6bc48fb5c52efd5588f84a55f47e4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 8 Nov 2024 10:51:07 +0100 Subject: [PATCH 2/6] tweaks --- apps/postgres-new/app/(main)/db/[id]/page.tsx | 24 ++++++++++++++++++- apps/postgres-new/components/sidebar.tsx | 13 +++++++--- apps/postgres-new/lib/database-locks.tsx | 12 ++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/postgres-new/app/(main)/db/[id]/page.tsx b/apps/postgres-new/app/(main)/db/[id]/page.tsx index c4ce243a..46de0668 100644 --- a/apps/postgres-new/app/(main)/db/[id]/page.tsx +++ b/apps/postgres-new/app/(main)/db/[id]/page.tsx @@ -1,5 +1,7 @@ +/* eslint-disable react/no-unescaped-entities */ 'use client' +import Link from 'next/link' import { useRouter } from 'next/navigation' import { useEffect } from 'react' import { useApp } from '~/components/app-provider' @@ -29,7 +31,27 @@ export default function Page({ params }: { params: { id: string } }) { const isLocked = useDatabaseLock(databaseId) if (isLocked) { - return
Database is locked
+ 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/sidebar.tsx b/apps/postgres-new/components/sidebar.tsx index 3356eb47..6c3e604c 100644 --- a/apps/postgres-new/components/sidebar.tsx +++ b/apps/postgres-new/components/sidebar.tsx @@ -43,6 +43,7 @@ import { } from './ui/dropdown-menu' import { TooltipPortal } from '@radix-ui/react-tooltip' import { LiveShareIcon } from './live-share-icon' +import { useIsLocked } from '~/lib/database-locks' export default function Sidebar() { const { @@ -299,6 +300,7 @@ type DatabaseMenuItemProps = { function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { const router = useRouter() const { user, dbManager, liveShare } = useApp() + const isLocked = useIsLocked(database.id) const [isPopoverOpen, setIsPopoverOpen] = useState(false) const { mutateAsync: deleteDatabase } = useDatabaseDeleteMutation() const { mutateAsync: updateDatabase } = useDatabaseUpdateMutation() @@ -446,6 +448,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { ) : (
{ e.preventDefault() @@ -460,6 +463,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { Rename { e.preventDefault() @@ -493,7 +497,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { setIsDeployDialogOpen(true) setIsPopoverOpen(false) }} - disabled={user === undefined} + disabled={user === undefined || isLocked} > { e.preventDefault() @@ -539,11 +545,12 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { type ConnectMenuItemProps = { databaseId: string isActive: boolean + disabled?: boolean setIsPopoverOpen: (open: boolean) => void } function LiveShareMenuItem(props: ConnectMenuItemProps) { - const { liveShare, user } = useApp() + const { liveShare } = useApp() const router = useRouter() if (liveShare.isLiveSharing && liveShare.databaseId === props.databaseId) { @@ -564,7 +571,7 @@ function LiveShareMenuItem(props: ConnectMenuItemProps) { return ( { e.preventDefault() diff --git a/apps/postgres-new/lib/database-locks.tsx b/apps/postgres-new/lib/database-locks.tsx index 692fb0d2..cfa6c9a9 100644 --- a/apps/postgres-new/lib/database-locks.tsx +++ b/apps/postgres-new/lib/database-locks.tsx @@ -141,3 +141,15 @@ export function useDatabaseLock(databaseId: string) { return isLocked } + +export function useIsLocked(databaseId: string) { + const context = useContext(DatabaseLocksContext) + const tabId = getTabId() + + if (!context) { + throw new Error('useIsLocked must be used within a DatabaseLocksProvider') + } + + const { locks } = context + return locks[databaseId] !== undefined && locks[databaseId] !== tabId +} From 654b12bd1f653b9a604edb8aa8e4f14f51557be0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 8 Nov 2024 11:32:40 +0100 Subject: [PATCH 3/6] tweaks UI --- apps/postgres-new/app/(main)/db/[id]/page.tsx | 40 ++++++++++--------- apps/postgres-new/lib/database-locks.tsx | 1 - 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/postgres-new/app/(main)/db/[id]/page.tsx b/apps/postgres-new/app/(main)/db/[id]/page.tsx index 46de0668..7727a4f7 100644 --- a/apps/postgres-new/app/(main)/db/[id]/page.tsx +++ b/apps/postgres-new/app/(main)/db/[id]/page.tsx @@ -1,6 +1,7 @@ /* eslint-disable react/no-unescaped-entities */ 'use client' +import NewDatabasePage from '../../page' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useEffect } from 'react' @@ -32,24 +33,27 @@ export default function Page({ params }: { params: { id: string } }) { 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. -

+
+ +
+

+ 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. +

+
) } diff --git a/apps/postgres-new/lib/database-locks.tsx b/apps/postgres-new/lib/database-locks.tsx index cfa6c9a9..5640acaa 100644 --- a/apps/postgres-new/lib/database-locks.tsx +++ b/apps/postgres-new/lib/database-locks.tsx @@ -125,7 +125,6 @@ export function useDatabaseLock(databaseId: string) { const isAvailable = !(databaseId in newLocks) if (isAvailable) { - console.log('Database became available, acquiring lock:', databaseId) acquireLock(databaseId) } } From ec92234985bbf8490ba0fd747e69c63df4b0f446 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 8 Nov 2024 11:35:39 +0100 Subject: [PATCH 4/6] use dynamic to prevent ssr --- apps/postgres-new/components/providers.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/postgres-new/components/providers.tsx b/apps/postgres-new/components/providers.tsx index 77a3420f..7768672d 100644 --- a/apps/postgres-new/components/providers.tsx +++ b/apps/postgres-new/components/providers.tsx @@ -5,10 +5,18 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PropsWithChildren } from 'react' import AppProvider from './app-provider' import { ThemeProvider } from './theme-provider' -import { DatabaseLocksProvider } from '~/lib/database-locks' const queryClient = new QueryClient() +import dynamic from 'next/dynamic' + +const DatabaseLocksProvider = dynamic( + async () => (await import('~/lib/database-locks')).DatabaseLocksProvider, + { + ssr: false, + } +) + export default function Providers({ children }: PropsWithChildren) { return ( From 6bc5fe04254f53556ac7d13235fff1ad5222594e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 13 Nov 2024 18:05:55 +0100 Subject: [PATCH 5/6] basic implementation --- apps/postgres-new/lib/database-locks.tsx | 154 +++++++++-------------- 1 file changed, 57 insertions(+), 97 deletions(-) diff --git a/apps/postgres-new/lib/database-locks.tsx b/apps/postgres-new/lib/database-locks.tsx index 5640acaa..c70aa80c 100644 --- a/apps/postgres-new/lib/database-locks.tsx +++ b/apps/postgres-new/lib/database-locks.tsx @@ -1,105 +1,79 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'react' -type DatabaseLocks = { - [databaseId: string]: string // databaseId -> tabId mapping +const DATABASE_LOCK_PREFIX = 'database-build-db-lock:' + +async function getLocks() { + const result = await navigator.locks.query() + if (result.held) { + return result.held + .map((lock) => lock.name) + .filter((name): name is string => name !== null && name!.startsWith(DATABASE_LOCK_PREFIX)) + .map((name) => name.slice(DATABASE_LOCK_PREFIX.length)) + } + return [] } -function getTabId() { - const stored = sessionStorage.getItem('tabId') - if (stored) return stored - - const newId = crypto.randomUUID() - sessionStorage.setItem('tabId', newId) - return newId +type DatabaseLocksContextState = { + locks: string[] + activeLock: { databaseId: string; release: () => void } | null } const DatabaseLocksContext = createContext<{ - locks: DatabaseLocks + state: DatabaseLocksContextState acquireLock: (databaseId: string) => void releaseLock: (databaseId: string) => void }>({ - locks: {}, + state: { + locks: [], + activeLock: null, + }, acquireLock: () => {}, releaseLock: () => {}, }) export function DatabaseLocksProvider({ children }: { children: React.ReactNode }) { - const tabId = getTabId() - const lockKey = 'dbLocks' + const [state, setState] = useState({ + locks: [], + activeLock: null, + }) - // Initialize with current localStorage state - const [locks, setLocks] = useState(() => - JSON.parse(localStorage.getItem(lockKey) || '{}') - ) + // Initialize with current navigator.locks state + useEffect(() => { + async function initLocks() { + const locks = await getLocks() + setState((previousState) => ({ ...previousState, locks })) + } + initLocks() + }, []) const acquireLock = useCallback( - (databaseId: string) => { - const currentLocks = JSON.parse(localStorage.getItem(lockKey) || '{}') as DatabaseLocks - - if (!(databaseId in currentLocks)) { - const newLocks = { ...currentLocks, [databaseId]: tabId } - localStorage.setItem(lockKey, JSON.stringify(newLocks)) - setLocks(newLocks) - } + async (databaseId: string) => { + let resolve: () => void + const lockPromise = new Promise((res) => { + resolve = res + }) + navigator.locks.request(`${DATABASE_LOCK_PREFIX}${databaseId}`, () => lockPromise) + setState((previousState) => ({ + activeLock: { databaseId, release: resolve }, + locks: [...previousState.locks, databaseId], + })) }, - [tabId] + [state, setState] ) const releaseLock = useCallback( - (databaseId: string) => { - const currentLocks = JSON.parse(localStorage.getItem(lockKey) || '{}') as DatabaseLocks - - if (currentLocks[databaseId] === tabId) { - const { [databaseId]: _, ...newLocks } = currentLocks - - if (Object.keys(newLocks).length === 0) { - localStorage.removeItem(lockKey) - } else { - localStorage.setItem(lockKey, JSON.stringify(newLocks)) - } - - setLocks(newLocks) - } + async (databaseId: string) => { + state.activeLock?.release() + setState((previousState) => ({ + activeLock: null, + locks: previousState.locks.filter((id) => id !== databaseId), + })) }, - [tabId] + [state, setState] ) - useEffect(() => { - const handleStorageChange = (event: StorageEvent) => { - if (event.key === lockKey) { - setLocks(JSON.parse(event.newValue || '{}')) - } - } - - window.addEventListener('storage', handleStorageChange) - - const handleBeforeUnload = () => { - const currentLocks = JSON.parse(localStorage.getItem(lockKey) || '{}') as DatabaseLocks - const newLocks: DatabaseLocks = {} - - for (const [dbId, lockingTabId] of Object.entries(currentLocks)) { - if (lockingTabId !== tabId) { - newLocks[dbId] = lockingTabId - } - } - - if (Object.keys(newLocks).length === 0) { - localStorage.removeItem(lockKey) - } else { - localStorage.setItem(lockKey, JSON.stringify(newLocks)) - } - } - - window.addEventListener('beforeunload', handleBeforeUnload) - - return () => { - window.removeEventListener('storage', handleStorageChange) - window.removeEventListener('beforeunload', handleBeforeUnload) - } - }, [lockKey, tabId]) - return ( - + {children} ) @@ -107,48 +81,34 @@ export function DatabaseLocksProvider({ children }: { children: React.ReactNode export function useDatabaseLock(databaseId: string) { const context = useContext(DatabaseLocksContext) - const tabId = getTabId() if (!context) { throw new Error('useDatabaseLock must be used within a DatabaseLocksProvider') } - const { locks, acquireLock, releaseLock } = context - const isLocked = locks[databaseId] !== undefined && locks[databaseId] !== tabId + const { state, acquireLock, releaseLock } = context + + const isLocked = state.locks.includes(databaseId) && state.activeLock?.databaseId !== databaseId useEffect(() => { acquireLock(databaseId) - const handleStorageChange = (event: StorageEvent) => { - if (event.key === 'dbLocks') { - const newLocks = JSON.parse(event.newValue || '{}') as DatabaseLocks - const isAvailable = !(databaseId in newLocks) - - if (isAvailable) { - acquireLock(databaseId) - } - } - } - - window.addEventListener('storage', handleStorageChange) - return () => { releaseLock(databaseId) - window.removeEventListener('storage', handleStorageChange) } - }, [databaseId, acquireLock, releaseLock]) + }, [databaseId]) return isLocked } export function useIsLocked(databaseId: string) { const context = useContext(DatabaseLocksContext) - const tabId = getTabId() if (!context) { throw new Error('useIsLocked must be used within a DatabaseLocksProvider') } - const { locks } = context - return locks[databaseId] !== undefined && locks[databaseId] !== tabId + const { state } = context + + return state.locks.includes(databaseId) && state.activeLock?.databaseId !== databaseId } From 6fc4f9559afbcc45e6f93bffcbe4eb9d1d36b920 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 13 Nov 2024 18:32:31 +0100 Subject: [PATCH 6/6] logs --- apps/postgres-new/lib/database-locks.tsx | 31 +++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/postgres-new/lib/database-locks.tsx b/apps/postgres-new/lib/database-locks.tsx index c70aa80c..205954da 100644 --- a/apps/postgres-new/lib/database-locks.tsx +++ b/apps/postgres-new/lib/database-locks.tsx @@ -4,6 +4,14 @@ const DATABASE_LOCK_PREFIX = 'database-build-db-lock:' async function getLocks() { const result = await navigator.locks.query() + console.log( + 'locks held', + result.held?.filter((lock) => lock.name?.startsWith(DATABASE_LOCK_PREFIX)) + ) + console.log( + 'locks pending', + result.pending?.filter((lock) => lock.name?.startsWith(DATABASE_LOCK_PREFIX)) + ) if (result.held) { return result.held .map((lock) => lock.name) @@ -15,7 +23,7 @@ async function getLocks() { type DatabaseLocksContextState = { locks: string[] - activeLock: { databaseId: string; release: () => void } | null + activeLock: { databaseId: string; release: () => void; abortController: AbortController } | null } const DatabaseLocksContext = createContext<{ @@ -49,12 +57,28 @@ export function DatabaseLocksProvider({ children }: { children: React.ReactNode const acquireLock = useCallback( async (databaseId: string) => { let resolve: () => void + const abortController = new AbortController() const lockPromise = new Promise((res) => { resolve = res + }).then(async () => { + console.log(`${databaseId} lock released`) + const result = await navigator.locks.query() + console.log( + 'locks held', + result.held?.filter((lock) => lock.name?.startsWith(DATABASE_LOCK_PREFIX)) + ) + console.log( + 'locks pending', + result.pending?.filter((lock) => lock.name?.startsWith(DATABASE_LOCK_PREFIX)) + ) }) - navigator.locks.request(`${DATABASE_LOCK_PREFIX}${databaseId}`, () => lockPromise) + navigator.locks.request( + `${DATABASE_LOCK_PREFIX}${databaseId}`, + { signal: abortController.signal }, + () => lockPromise + ) setState((previousState) => ({ - activeLock: { databaseId, release: resolve }, + activeLock: { databaseId, release: resolve, abortController }, locks: [...previousState.locks, databaseId], })) }, @@ -64,6 +88,7 @@ export function DatabaseLocksProvider({ children }: { children: React.ReactNode const releaseLock = useCallback( async (databaseId: string) => { state.activeLock?.release() + state.activeLock?.abortController.abort('unmount') setState((previousState) => ({ activeLock: null, locks: previousState.locks.filter((id) => id !== databaseId),