diff --git a/surfsense_backend/alembic/versions/66_add_notifications_table_and_electric_replication.py b/surfsense_backend/alembic/versions/66_add_notifications_table_and_electric_replication.py index e07cf6de4..dc25a1edd 100644 --- a/surfsense_backend/alembic/versions/66_add_notifications_table_and_electric_replication.py +++ b/surfsense_backend/alembic/versions/66_add_notifications_table_and_electric_replication.py @@ -19,7 +19,7 @@ from alembic import context, op # Get Electric SQL user credentials from env.py configuration _config = context.config -ELECTRIC_DB_USER =_config.get_main_option("electric_db_user", "electric") +ELECTRIC_DB_USER = _config.get_main_option("electric_db_user", "electric") ELECTRIC_DB_PASSWORD = _config.get_main_option( "electric_db_password", "electric_password" ) @@ -52,10 +52,18 @@ def upgrade() -> None: ) # Create indexes (using IF NOT EXISTS for idempotency) - op.execute("CREATE INDEX IF NOT EXISTS ix_notifications_user_id ON notifications (user_id);") - op.execute("CREATE INDEX IF NOT EXISTS ix_notifications_read ON notifications (read);") - op.execute("CREATE INDEX IF NOT EXISTS ix_notifications_created_at ON notifications (created_at);") - op.execute("CREATE INDEX IF NOT EXISTS ix_notifications_user_read ON notifications (user_id, read);") + op.execute( + "CREATE INDEX IF NOT EXISTS ix_notifications_user_id ON notifications (user_id);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_notifications_read ON notifications (read);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_notifications_created_at ON notifications (created_at);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_notifications_user_read ON notifications (user_id, read);" + ) # ===================================================== # Electric SQL Setup - User and Publication diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 049183cf3..2320b3b9a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -438,9 +438,7 @@ export default function EditorPage() { {saving ? ( <> - - {isNewNote ? "Creating" : "Saving"} - + {isNewNote ? "Creating" : "Saving"} ) : ( <> diff --git a/surfsense_web/components/notifications/NotificationButton.tsx b/surfsense_web/components/notifications/NotificationButton.tsx index b1edd2254..26b4713b5 100644 --- a/surfsense_web/components/notifications/NotificationButton.tsx +++ b/surfsense_web/components/notifications/NotificationButton.tsx @@ -14,15 +14,15 @@ import { useParams } from "next/navigation"; export function NotificationButton() { const { data: user } = useAtomValue(currentUserAtom); const params = useParams(); - + const userId = user?.id ? String(user.id) : null; // Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/ - const searchSpaceId = params?.search_space_id - ? Number(params.search_space_id) - : null; - - const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = - useNotifications(userId, searchSpaceId); + const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; + + const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications( + userId, + searchSpaceId + ); return ( diff --git a/surfsense_web/components/providers/ElectricProvider.tsx b/surfsense_web/components/providers/ElectricProvider.tsx index 9a296f314..af3046a64 100644 --- a/surfsense_web/components/providers/ElectricProvider.tsx +++ b/surfsense_web/components/providers/ElectricProvider.tsx @@ -29,7 +29,11 @@ interface ElectricProviderProps { export function ElectricProvider({ children }: ElectricProviderProps) { const [electricClient, setElectricClient] = useState(null); const [error, setError] = useState(null); - const { data: user, isSuccess: isUserLoaded, isError: isUserError } = useAtomValue(currentUserAtom); + const { + data: user, + isSuccess: isUserLoaded, + isError: isUserError, + } = useAtomValue(currentUserAtom); const previousUserIdRef = useRef(null); const initializingRef = useRef(false); @@ -104,11 +108,7 @@ export function ElectricProvider({ children }: ElectricProviderProps) { // For non-authenticated pages (like landing page), render immediately with null context // Also render immediately if user query failed (e.g., token expired) if (!isUserLoaded || !user?.id || isUserError) { - return ( - - {children} - - ); + return {children}; } // Show loading state while initializing for authenticated users @@ -128,9 +128,5 @@ export function ElectricProvider({ children }: ElectricProviderProps) { } // Provide the Electric client to children - return ( - - {children} - - ); + return {children}; } diff --git a/surfsense_web/hooks/use-connectors-electric.ts b/surfsense_web/hooks/use-connectors-electric.ts index 77c18f0a5..ccda052b1 100644 --- a/surfsense_web/hooks/use-connectors-electric.ts +++ b/surfsense_web/hooks/use-connectors-electric.ts @@ -7,14 +7,14 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types"; /** * Hook for managing connectors with Electric SQL real-time sync - * + * * Uses the Electric client from context (provided by ElectricProvider) * instead of initializing its own - prevents race conditions and memory leaks */ export function useConnectorsElectric(searchSpaceId: number | string | null) { // Get Electric client from context - ElectricProvider handles initialization const electricClient = useElectricClient(); - + const [connectors, setConnectors] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -173,7 +173,7 @@ export function useConnectorsElectric(searchSpaceId: number | string | null) { return () => { mounted = false; syncKeyRef.current = null; - + if (syncHandleRef.current) { syncHandleRef.current.unsubscribe(); syncHandleRef.current = null; diff --git a/surfsense_web/hooks/use-documents-electric.ts b/surfsense_web/hooks/use-documents-electric.ts index 6e50c775c..74d9e91e7 100644 --- a/surfsense_web/hooks/use-documents-electric.ts +++ b/surfsense_web/hooks/use-documents-electric.ts @@ -13,14 +13,14 @@ interface Document { /** * Hook for managing documents with Electric SQL real-time sync - * + * * Uses the Electric client from context (provided by ElectricProvider) * instead of initializing its own - prevents race conditions and memory leaks */ export function useDocumentsElectric(searchSpaceId: number | string | null) { // Get Electric client from context - ElectricProvider handles initialization const electricClient = useElectricClient(); - + const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -169,7 +169,7 @@ export function useDocumentsElectric(searchSpaceId: number | string | null) { return () => { mounted = false; syncKeyRef.current = null; - + if (syncHandleRef.current) { syncHandleRef.current.unsubscribe(); syncHandleRef.current = null; diff --git a/surfsense_web/hooks/use-notifications.ts b/surfsense_web/hooks/use-notifications.ts index 62617ede2..7a3b49861 100644 --- a/surfsense_web/hooks/use-notifications.ts +++ b/surfsense_web/hooks/use-notifications.ts @@ -10,29 +10,29 @@ export type { Notification } from "@/contracts/types/notification.types"; /** * Hook for managing notifications with Electric SQL real-time sync - * + * * Uses the Electric client from context (provided by ElectricProvider) * instead of initializing its own - prevents race conditions and memory leaks - * + * * Architecture: * - User-level sync: Syncs ALL notifications for a user (runs once per user) * - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change) - * + * * This separation ensures smooth transitions when switching search spaces (no flash). - * + * * @param userId - The user ID to fetch notifications for * @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only) */ export function useNotifications(userId: string | null, searchSpaceId: number | null) { // Get Electric client from context - ElectricProvider handles initialization const electricClient = useElectricClient(); - + const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const syncHandleRef = useRef(null); const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); - + // Track user-level sync key to prevent duplicate sync subscriptions const userSyncKeyRef = useRef(null); @@ -100,7 +100,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number | return () => { mounted = false; userSyncKeyRef.current = null; - + if (syncHandleRef.current) { syncHandleRef.current.unsubscribe(); syncHandleRef.current = null; @@ -135,7 +135,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number | ORDER BY created_at DESC`, [userId, searchSpaceId] ); - + if (mounted) { setNotifications(result.rows || []); } diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 801b7e963..473e06461 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -381,7 +381,10 @@ export async function initElectric(userId: string): Promise { }, }; - console.log("[Electric] syncShapeToTable config:", JSON.stringify(shapeConfig, null, 2)); + console.log( + "[Electric] syncShapeToTable config:", + JSON.stringify(shapeConfig, null, 2) + ); // Type assertion to PGlite with electric extension const pgWithElectric = db as PGlite & { @@ -452,9 +455,7 @@ export async function initElectric(userId: string): Promise { (typeof msg === "object" && "up-to-date" in msg) ) { if (!syncResolved) { - console.log( - `[Electric] ✅ Received up-to-date message for ${table}` - ); + console.log(`[Electric] ✅ Received up-to-date message for ${table}`); resolveInitialSync(); } // Continue listening for real-time updates - don't return! @@ -477,9 +478,7 @@ export async function initElectric(userId: string): Promise { // Also check stream's isUpToDate property immediately if (stream?.isUpToDate) { - console.log( - `[Electric] ✅ Stream isUpToDate is true immediately for ${table}` - ); + console.log(`[Electric] ✅ Stream isUpToDate is true immediately for ${table}`); resolveInitialSync(); } } @@ -530,7 +529,9 @@ export async function initElectric(userId: string): Promise { // Cache the sync handle for reuse (memory optimization) activeSyncHandles.set(cacheKey, syncHandle); - console.log(`[Electric] Cached sync handle for: ${cacheKey} (total cached: ${activeSyncHandles.size})`); + console.log( + `[Electric] Cached sync handle for: ${cacheKey} (total cached: ${activeSyncHandles.size})` + ); return syncHandle; } catch (error) { diff --git a/surfsense_web/lib/electric/context.ts b/surfsense_web/lib/electric/context.ts index 010959ed2..777d4e12c 100644 --- a/surfsense_web/lib/electric/context.ts +++ b/surfsense_web/lib/electric/context.ts @@ -5,7 +5,7 @@ import type { ElectricClient } from "./client"; /** * Context for sharing the Electric SQL client across the app - * + * * This ensures: * 1. Single initialization point (ElectricProvider only) * 2. No race conditions (hooks wait for context) @@ -34,4 +34,3 @@ export function useElectricClientOrThrow(): ElectricClient { } return client; } -