diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 7119fcb6d..b431f7ca2 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -57,7 +57,6 @@ jobs: working-directory: surfsense_web env: NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${{ vars.NEXT_PUBLIC_FASTAPI_BACKEND_URL }} - NEXT_PUBLIC_ELECTRIC_URL: ${{ vars.NEXT_PUBLIC_ELECTRIC_URL }} NEXT_PUBLIC_DEPLOYMENT_MODE: ${{ vars.NEXT_PUBLIC_DEPLOYMENT_MODE }} NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${{ vars.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE }} diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index a53a4b414..cedfe9d32 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -164,8 +164,6 @@ jobs: ${{ matrix.image == 'web' && 'NEXT_PUBLIC_FASTAPI_BACKEND_URL=__NEXT_PUBLIC_FASTAPI_BACKEND_URL__' || '' }} ${{ matrix.image == 'web' && 'NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__' || '' }} ${{ matrix.image == 'web' && 'NEXT_PUBLIC_ETL_SERVICE=__NEXT_PUBLIC_ETL_SERVICE__' || '' }} - ${{ matrix.image == 'web' && 'NEXT_PUBLIC_ELECTRIC_URL=__NEXT_PUBLIC_ELECTRIC_URL__' || '' }} - ${{ matrix.image == 'web' && 'NEXT_PUBLIC_ELECTRIC_AUTH_MODE=__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__' || '' }} ${{ matrix.image == 'web' && 'NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__' || '' }} - name: Export digest diff --git a/docker/.env.example b/docker/.env.example index a226c2624..e8c79425c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -35,7 +35,6 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 # BACKEND_PORT=8929 # FRONTEND_PORT=3929 -# ELECTRIC_PORT=5929 # SEARXNG_PORT=8888 # FLOWER_PORT=5555 @@ -58,7 +57,6 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 # NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL # NEXT_PUBLIC_ETL_SERVICE=DOCLING # NEXT_PUBLIC_DEPLOYMENT_MODE=self-hosted -# NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure # ------------------------------------------------------------------------------ # Custom Domain / Reverse Proxy @@ -71,7 +69,6 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 # NEXT_FRONTEND_URL=https://app.yourdomain.com # BACKEND_URL=https://api.yourdomain.com # NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.yourdomain.com -# NEXT_PUBLIC_ELECTRIC_URL=https://electric.yourdomain.com # ------------------------------------------------------------------------------ @@ -101,19 +98,6 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 # Supports TLS: rediss://:password@host:6380/0 # REDIS_URL=redis://redis:6379/0 -# ------------------------------------------------------------------------------ -# Electric SQL (real-time sync credentials) -# ------------------------------------------------------------------------------ -# These must match on the db, backend, and electric services. -# Change for security; defaults work out of the box. - -# ELECTRIC_DB_USER=electric -# ELECTRIC_DB_PASSWORD=electric_password -# Full override for the Electric → Postgres connection URL. -# Leave commented out to use the Docker-managed `db` container (default). -# Uncomment and set `db` to `host.docker.internal` when pointing Electric at a local Postgres instance (e.g. Postgres.app on macOS): -# ELECTRIC_DATABASE_URL=postgresql://electric:electric_password@db:5432/surfsense?sslmode=disable - # ------------------------------------------------------------------------------ # TTS & STT (Text-to-Speech / Speech-to-Text) # ------------------------------------------------------------------------------ diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 15531bf55..9eaaeedae 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -18,13 +18,10 @@ services: volumes: - postgres_data:/var/lib/postgresql/data - ./postgresql.conf:/etc/postgresql/postgresql.conf:ro - - ./scripts/init-electric-user.sh:/docker-entrypoint-initdb.d/init-electric-user.sh:ro environment: - POSTGRES_USER=${DB_USER:-postgres} - POSTGRES_PASSWORD=${DB_PASSWORD:-postgres} - POSTGRES_DB=${DB_NAME:-surfsense} - - ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric} - - ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password} command: postgres -c config_file=/etc/postgresql/postgresql.conf healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-surfsense}"] @@ -91,8 +88,6 @@ services: - UNSTRUCTURED_HAS_PATCHED_LOOP=1 - LANGCHAIN_TRACING_V2=false - LANGSMITH_TRACING=false - - ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric} - - ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password} - AUTH_TYPE=${AUTH_TYPE:-LOCAL} - NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000} - SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080} @@ -130,8 +125,6 @@ services: - REDIS_APP_URL=${REDIS_URL:-redis://redis:6379/0} - CELERY_TASK_DEFAULT_QUEUE=surfsense - PYTHONPATH=/app - - ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric} - - ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password} - SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080} - SERVICE_ROLE=worker depends_on: @@ -176,24 +169,6 @@ services: # - redis # - celery_worker - electric: - image: electricsql/electric:1.4.10 - ports: - - "${ELECTRIC_PORT:-5133}:3000" - depends_on: - db: - condition: service_healthy - environment: - - DATABASE_URL=${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}} - - ELECTRIC_INSECURE=true - - ELECTRIC_WRITE_TO_PG_MODE=direct - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/v1/health"] - interval: 10s - timeout: 5s - retries: 5 - frontend: build: context: ../surfsense_web @@ -201,8 +176,6 @@ services: NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:8000} NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE:-LOCAL} NEXT_PUBLIC_ETL_SERVICE: ${NEXT_PUBLIC_ETL_SERVICE:-DOCLING} - NEXT_PUBLIC_ELECTRIC_URL: ${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:5133} - NEXT_PUBLIC_ELECTRIC_AUTH_MODE: ${NEXT_PUBLIC_ELECTRIC_AUTH_MODE:-insecure} NEXT_PUBLIC_DEPLOYMENT_MODE: ${NEXT_PUBLIC_DEPLOYMENT_MODE:-self-hosted} ports: - "${FRONTEND_PORT:-3000}:3000" @@ -211,8 +184,6 @@ services: depends_on: backend: condition: service_healthy - electric: - condition: service_healthy volumes: postgres_data: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8c85248d2..12961654a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,13 +15,10 @@ services: volumes: - postgres_data:/var/lib/postgresql/data - ./postgresql.conf:/etc/postgresql/postgresql.conf:ro - - ./scripts/init-electric-user.sh:/docker-entrypoint-initdb.d/init-electric-user.sh:ro environment: POSTGRES_USER: ${DB_USER:-surfsense} POSTGRES_PASSWORD: ${DB_PASSWORD:-surfsense} POSTGRES_DB: ${DB_NAME:-surfsense} - ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric} - ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password} command: postgres -c config_file=/etc/postgresql/postgresql.conf restart: unless-stopped healthcheck: @@ -72,8 +69,6 @@ services: PYTHONPATH: /app UVICORN_LOOP: asyncio UNSTRUCTURED_HAS_PATCHED_LOOP: "1" - ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric} - ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password} NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}} SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080} # Daytona Sandbox – uncomment and set credentials to enable cloud code execution @@ -112,8 +107,6 @@ services: REDIS_APP_URL: ${REDIS_URL:-redis://redis:6379/0} CELERY_TASK_DEFAULT_QUEUE: surfsense PYTHONPATH: /app - ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric} - ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password} SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080} SERVICE_ROLE: worker depends_on: @@ -165,42 +158,20 @@ services: # - celery_worker # restart: unless-stopped - electric: - image: electricsql/electric:1.4.10 - ports: - - "${ELECTRIC_PORT:-5929}:3000" - environment: - DATABASE_URL: ${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}} - ELECTRIC_INSECURE: "true" - ELECTRIC_WRITE_TO_PG_MODE: direct - restart: unless-stopped - depends_on: - db: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/v1/health"] - interval: 10s - timeout: 5s - retries: 5 - frontend: image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest} ports: - "${FRONTEND_PORT:-3929}:3000" environment: NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8929}} - NEXT_PUBLIC_ELECTRIC_URL: ${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:${ELECTRIC_PORT:-5929}} NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL} NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING} NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted} - NEXT_PUBLIC_ELECTRIC_AUTH_MODE: ${NEXT_PUBLIC_ELECTRIC_AUTH_MODE:-insecure} labels: - "com.centurylinklabs.watchtower.enable=true" depends_on: backend: condition: service_healthy - electric: - condition: service_healthy restart: unless-stopped volumes: diff --git a/docker/scripts/init-electric-user.sh b/docker/scripts/init-electric-user.sh deleted file mode 100755 index fbd1c361a..000000000 --- a/docker/scripts/init-electric-user.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/sh -# Creates the Electric SQL replication user on first DB initialization. -# Idempotent — safe to run alongside Alembic migration 66. - -set -e - -ELECTRIC_DB_USER="${ELECTRIC_DB_USER:-electric}" -ELECTRIC_DB_PASSWORD="${ELECTRIC_DB_PASSWORD:-electric_password}" - -echo "Creating Electric SQL replication user: $ELECTRIC_DB_USER" - -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - DO \$\$ - BEGIN - IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '$ELECTRIC_DB_USER') THEN - CREATE USER $ELECTRIC_DB_USER WITH REPLICATION PASSWORD '$ELECTRIC_DB_PASSWORD'; - END IF; - END - \$\$; - - GRANT CONNECT ON DATABASE $POSTGRES_DB TO $ELECTRIC_DB_USER; - GRANT CREATE ON DATABASE $POSTGRES_DB TO $ELECTRIC_DB_USER; - GRANT USAGE ON SCHEMA public TO $ELECTRIC_DB_USER; - GRANT SELECT ON ALL TABLES IN SCHEMA public TO $ELECTRIC_DB_USER; - GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO $ELECTRIC_DB_USER; - ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO $ELECTRIC_DB_USER; - ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO $ELECTRIC_DB_USER; - - DO \$\$ - BEGIN - IF NOT EXISTS (SELECT FROM pg_publication WHERE pubname = 'electric_publication_default') THEN - CREATE PUBLICATION electric_publication_default; - END IF; - END - \$\$; -EOSQL - -echo "Electric SQL user '$ELECTRIC_DB_USER' and publication created successfully" diff --git a/docker/scripts/install.ps1 b/docker/scripts/install.ps1 index b7004bae2..0eb3886a2 100644 --- a/docker/scripts/install.ps1 +++ b/docker/scripts/install.ps1 @@ -109,7 +109,6 @@ $Files = @( @{ Src = "docker/docker-compose.yml"; Dest = "docker-compose.yml" } @{ Src = "docker/.env.example"; Dest = ".env.example" } @{ Src = "docker/postgresql.conf"; Dest = "postgresql.conf" } - @{ Src = "docker/scripts/init-electric-user.sh"; Dest = "scripts/init-electric-user.sh" } @{ Src = "docker/scripts/migrate-database.ps1"; Dest = "scripts/migrate-database.ps1" } @{ Src = "docker/searxng/settings.yml"; Dest = "searxng/settings.yml" } @{ Src = "docker/searxng/limiter.toml"; Dest = "searxng/limiter.toml" } diff --git a/docker/scripts/install.sh b/docker/scripts/install.sh index 7a68a9bd1..fcab4d55a 100644 --- a/docker/scripts/install.sh +++ b/docker/scripts/install.sh @@ -108,7 +108,6 @@ FILES=( "docker/docker-compose.yml:docker-compose.yml" "docker/.env.example:.env.example" "docker/postgresql.conf:postgresql.conf" - "docker/scripts/init-electric-user.sh:scripts/init-electric-user.sh" "docker/scripts/migrate-database.sh:scripts/migrate-database.sh" "docker/searxng/settings.yml:searxng/settings.yml" "docker/searxng/limiter.toml:searxng/limiter.toml" @@ -122,7 +121,6 @@ for entry in "${FILES[@]}"; do || error "Failed to download ${dest}. Check your internet connection and try again." done -chmod +x "${INSTALL_DIR}/scripts/init-electric-user.sh" chmod +x "${INSTALL_DIR}/scripts/migrate-database.sh" success "All files downloaded to ${INSTALL_DIR}/" diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 621c8cf99..68fada394 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -17,10 +17,6 @@ REDIS_APP_URL=redis://localhost:6379/0 # Only uncomment if running the backend outside Docker (e.g. uvicorn on host). # SEARXNG_DEFAULT_HOST=http://localhost:8888 -#Electric(for migrations only) -ELECTRIC_DB_USER=electric -ELECTRIC_DB_PASSWORD=electric_password - # Periodic task interval # # Run every minute (default) # SCHEDULE_CHECKER_INTERVAL=1m diff --git a/surfsense_backend/alembic/env.py b/surfsense_backend/alembic/env.py index fa213121c..bd8c20356 100644 --- a/surfsense_backend/alembic/env.py +++ b/surfsense_backend/alembic/env.py @@ -25,13 +25,6 @@ database_url = os.getenv("DATABASE_URL") if database_url: config.set_main_option("sqlalchemy.url", database_url) -# Electric SQL user credentials - centralized configuration for migrations -# These are used by migrations that set up Electric SQL replication -config.set_main_option("electric_db_user", os.getenv("ELECTRIC_DB_USER", "electric")) -config.set_main_option( - "electric_db_password", os.getenv("ELECTRIC_DB_PASSWORD", "electric_password") -) - # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: diff --git a/surfsense_web/.env.example b/surfsense_web/.env.example index d390a0cb7..7d0e888d1 100644 --- a/surfsense_web/.env.example +++ b/surfsense_web/.env.example @@ -2,10 +2,6 @@ NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000 NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING -# Electric SQL -NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133 -NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure - # Contact Form Vars - OPTIONAL DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres diff --git a/surfsense_web/Dockerfile b/surfsense_web/Dockerfile index 311c3c784..e1d231fac 100644 --- a/surfsense_web/Dockerfile +++ b/surfsense_web/Dockerfile @@ -35,15 +35,11 @@ RUN corepack enable pnpm ARG NEXT_PUBLIC_FASTAPI_BACKEND_URL=__NEXT_PUBLIC_FASTAPI_BACKEND_URL__ ARG NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__ ARG NEXT_PUBLIC_ETL_SERVICE=__NEXT_PUBLIC_ETL_SERVICE__ -ARG NEXT_PUBLIC_ELECTRIC_URL=__NEXT_PUBLIC_ELECTRIC_URL__ -ARG NEXT_PUBLIC_ELECTRIC_AUTH_MODE=__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__ ARG NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__ ENV NEXT_PUBLIC_FASTAPI_BACKEND_URL=$NEXT_PUBLIC_FASTAPI_BACKEND_URL ENV NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=$NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE ENV NEXT_PUBLIC_ETL_SERVICE=$NEXT_PUBLIC_ETL_SERVICE -ENV NEXT_PUBLIC_ELECTRIC_URL=$NEXT_PUBLIC_ELECTRIC_URL -ENV NEXT_PUBLIC_ELECTRIC_AUTH_MODE=$NEXT_PUBLIC_ELECTRIC_AUTH_MODE ENV NEXT_PUBLIC_DEPLOYMENT_MODE=$NEXT_PUBLIC_DEPLOYMENT_MODE COPY --from=deps /app/node_modules ./node_modules diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index b0aa58a8f..f679bd968 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -3,7 +3,6 @@ import "./globals.css"; import { RootProvider } from "fumadocs-ui/provider/next"; import { Roboto } from "next/font/google"; import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider"; -import { ElectricProvider } from "@/components/providers/ElectricProvider"; import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider"; import { I18nProvider } from "@/components/providers/I18nProvider"; import { PostHogProvider } from "@/components/providers/PostHogProvider"; @@ -140,11 +139,9 @@ export default function RootLayout({ defaultTheme="system" > - - - {children} - - + + {children} + diff --git a/surfsense_web/app/sitemap.ts b/surfsense_web/app/sitemap.ts index 5a11ef3fc..4470f53bf 100644 --- a/surfsense_web/app/sitemap.ts +++ b/surfsense_web/app/sitemap.ts @@ -212,12 +212,6 @@ export default function sitemap(): MetadataRoute.Sitemap { priority: 0.8, }, // How-to documentation - { - url: "https://www.surfsense.com/docs/how-to/electric-sql", - lastModified, - changeFrequency: "daily", - priority: 0.8, - }, { url: "https://www.surfsense.com/docs/how-to/realtime-collaboration", lastModified, diff --git a/surfsense_web/components/UserDropdown.tsx b/surfsense_web/components/UserDropdown.tsx index 5b398ae0b..b79ab6e79 100644 --- a/surfsense_web/components/UserDropdown.tsx +++ b/surfsense_web/components/UserDropdown.tsx @@ -16,7 +16,6 @@ import { } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { logout } from "@/lib/auth-utils"; -import { cleanupElectric } from "@/lib/electric/client"; import { resetUser, trackLogout } from "@/lib/posthog/events"; export function UserDropdown({ @@ -39,14 +38,6 @@ export function UserDropdown({ trackLogout(); resetUser(); - // Best-effort cleanup of Electric SQL / PGlite - // Even if this fails, login-time cleanup will handle it - try { - await cleanupElectric(); - } catch (err) { - console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err); - } - // Revoke refresh token on server and clear all tokens from localStorage await logout(); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 6ae258f99..47a2b2362 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -51,7 +51,6 @@ import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { logout } from "@/lib/auth-utils"; import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence"; -import { cleanupElectric } from "@/lib/electric/client"; import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { MorePagesDialog } from "@/components/settings/more-pages-dialog"; @@ -159,8 +158,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); - // Per-tab inbox hooks — each has independent API loading, pagination, - // and Electric live queries. The Electric sync shape is shared (client-level cache). const userId = user?.id ? String(user.id) : null; const numericSpaceId = Number(searchSpaceId) || null; @@ -607,14 +604,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid trackLogout(); resetUser(); - // Best-effort cleanup of Electric SQL / PGlite - // Even if this fails, login-time cleanup will handle it - try { - await cleanupElectric(); - } catch (err) { - console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err); - } - // Revoke refresh token on server and clear all tokens from localStorage await logout(); diff --git a/surfsense_web/components/providers/ElectricProvider.tsx b/surfsense_web/components/providers/ElectricProvider.tsx deleted file mode 100644 index aded9533a..000000000 --- a/surfsense_web/components/providers/ElectricProvider.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { usePathname } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; -import { getBearerToken } from "@/lib/auth-utils"; -import { - cleanupElectric, - type ElectricClient, - initElectric, - isElectricInitialized, -} from "@/lib/electric/client"; -import { ElectricContext } from "@/lib/electric/context"; - -interface ElectricProviderProps { - children: React.ReactNode; -} - -/** - * Initializes user-specific PGlite database with Electric SQL sync. - * Handles user isolation, cleanup, and re-initialization on user change. - */ -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 previousUserIdRef = useRef(null); - const initializingRef = useRef(false); - const pathname = usePathname(); - - useEffect(() => { - if (typeof window === "undefined") return; - - // No user logged in - cleanup if previous user existed - if (!isUserLoaded || !user?.id) { - if (previousUserIdRef.current && isElectricInitialized()) { - console.log("[ElectricProvider] User logged out, cleaning up..."); - cleanupElectric().then(() => { - previousUserIdRef.current = null; - setElectricClient(null); - }); - } - return; - } - - const userId = String(user.id); - - // Skip if already initialized for this user or currently initializing - if ((electricClient && previousUserIdRef.current === userId) || initializingRef.current) { - return; - } - - initializingRef.current = true; - let mounted = true; - - async function init() { - try { - console.log(`[ElectricProvider] Initializing for user: ${userId}`); - const client = await initElectric(userId); - - if (mounted) { - previousUserIdRef.current = userId; - setElectricClient(client); - setError(null); - console.log(`[ElectricProvider] ✅ Ready for user: ${userId}`); - } - } catch (err) { - console.error("[ElectricProvider] Failed to initialize:", err); - if (mounted) { - setError(err instanceof Error ? err : new Error("Failed to initialize Electric SQL")); - setElectricClient(null); - } - } finally { - if (mounted) { - initializingRef.current = false; - } - } - } - - init(); - return () => { - mounted = false; - }; - }, [user?.id, isUserLoaded, electricClient]); - - const hasToken = typeof window !== "undefined" && !!getBearerToken(); - - // Only block UI on dashboard routes; public pages render immediately - const requiresElectricLoading = pathname?.startsWith("/dashboard"); - const shouldShowLoading = - hasToken && isUserLoaded && !!user?.id && !electricClient && !error && requiresElectricLoading; - - useGlobalLoadingEffect(shouldShowLoading); - - // Render immediately for unauthenticated users or failed user queries - if (!hasToken || !isUserLoaded || !user?.id || isUserError) { - return {children}; - } - - // Render with null context while initializing - if (!electricClient && !error) { - return {children}; - } - - if (error) { - console.warn("[ElectricProvider] Initialization failed, sync may not work:", error.message); - } - - return {children}; -} diff --git a/surfsense_web/content/docs/docker-installation/dev-compose.mdx b/surfsense_web/content/docs/docker-installation/dev-compose.mdx index 302026c2a..5c800e450 100644 --- a/surfsense_web/content/docs/docker-installation/dev-compose.mdx +++ b/surfsense_web/content/docs/docker-installation/dev-compose.mdx @@ -25,6 +25,5 @@ The following `.env` variables are **only used by the dev compose file** (they h | `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` | Frontend build arg for auth type | `LOCAL` | | `NEXT_PUBLIC_ETL_SERVICE` | Frontend build arg for ETL service | `DOCLING` | | `NEXT_PUBLIC_DEPLOYMENT_MODE` | Frontend build arg for deployment mode | `self-hosted` | -| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend build arg for Electric auth | `insecure` | In the production compose file, the `NEXT_PUBLIC_*` frontend variables are automatically derived from `AUTH_TYPE`, `ETL_SERVICE`, and the port settings. In the dev compose file, they are passed as build args since the frontend is built from source. diff --git a/surfsense_web/content/docs/docker-installation/docker-compose.mdx b/surfsense_web/content/docs/docker-installation/docker-compose.mdx index c56f08106..bf0ca762f 100644 --- a/surfsense_web/content/docs/docker-installation/docker-compose.mdx +++ b/surfsense_web/content/docs/docker-installation/docker-compose.mdx @@ -18,8 +18,6 @@ After starting, access SurfSense at: - **Frontend**: [http://localhost:3929](http://localhost:3929) - **Backend API**: [http://localhost:8929](http://localhost:8929) - **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs) -- **Electric SQL**: [http://localhost:5929](http://localhost:5929) - --- ## Configuration @@ -50,7 +48,6 @@ All configuration lives in a single `docker/.env` file (or `surfsense/.env` if y |----------|-------------|---------| | `FRONTEND_PORT` | Frontend service port | `3929` | | `BACKEND_PORT` | Backend API service port | `8929` | -| `ELECTRIC_PORT` | Electric SQL service port | `5929` | ### Custom Domain / Reverse Proxy @@ -61,7 +58,6 @@ Only set these if serving SurfSense on a real domain via a reverse proxy (Caddy, | `NEXT_FRONTEND_URL` | Public frontend URL (e.g. `https://app.yourdomain.com`) | | `BACKEND_URL` | Public backend URL for OAuth callbacks (e.g. `https://api.yourdomain.com`) | | `NEXT_PUBLIC_FASTAPI_BACKEND_URL` | Backend URL used by the frontend (e.g. `https://api.yourdomain.com`) | -| `NEXT_PUBLIC_ELECTRIC_URL` | Electric SQL URL used by the frontend (e.g. `https://electric.yourdomain.com`) | ### Database @@ -77,14 +73,6 @@ Defaults work out of the box. Change for security in production. | `DB_SSLMODE` | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` | `disable` | | `DATABASE_URL` | Full connection URL override. Use for managed databases (RDS, Supabase, etc.) | *(built from above)* | -### Electric SQL - -| Variable | Description | Default | -|----------|-------------|---------| -| `ELECTRIC_DB_USER` | Replication user for Electric SQL | `electric` | -| `ELECTRIC_DB_PASSWORD` | Replication password for Electric SQL | `electric_password` | -| `ELECTRIC_DATABASE_URL` | Full connection URL override for Electric. Set to `host.docker.internal` when pointing at a local Postgres instance | *(built from above)* | - ### Authentication | Variable | Description | @@ -148,7 +136,6 @@ Uncomment the connectors you want to use. Redirect URIs follow the pattern `http | `backend` | FastAPI application server | | `celery_worker` | Background task processing (document indexing, etc.) | | `celery_beat` | Periodic task scheduler (connector sync) | -| `electric` | Electric SQL (real-time sync for the frontend) | | `frontend` | Next.js web application | All services start automatically with `docker compose up -d`. @@ -165,7 +152,6 @@ docker compose logs -f # View logs for a specific service docker compose logs -f backend -docker compose logs -f electric # Stop all services docker compose down @@ -183,6 +169,4 @@ docker compose down -v - **Ports already in use**: Change the relevant `*_PORT` variable in `.env` and restart. - **Permission errors on Linux**: You may need to prefix `docker` commands with `sudo`. -- **Electric SQL not connecting**: Check `docker compose logs electric`. If it shows `domain does not exist: db`, ensure `ELECTRIC_DATABASE_URL` is not set to a stale value in `.env`. -- **Real-time updates not working in browser**: Open DevTools → Console and look for `[Electric]` errors. Check that `NEXT_PUBLIC_ELECTRIC_URL` matches the running Electric SQL address. - **Line ending issues on Windows**: Run `git config --global core.autocrlf true` before cloning. diff --git a/surfsense_web/content/docs/docker-installation/install-script.mdx b/surfsense_web/content/docs/docker-installation/install-script.mdx index bbe95c230..f3f932c4c 100644 --- a/surfsense_web/content/docs/docker-installation/install-script.mdx +++ b/surfsense_web/content/docs/docker-installation/install-script.mdx @@ -38,4 +38,3 @@ After starting, access SurfSense at: - **Frontend**: [http://localhost:3929](http://localhost:3929) - **Backend API**: [http://localhost:8929](http://localhost:8929) - **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs) -- **Electric SQL**: [http://localhost:5929](http://localhost:5929) diff --git a/surfsense_web/content/docs/how-to/electric-sql.mdx b/surfsense_web/content/docs/how-to/electric-sql.mdx deleted file mode 100644 index f051a9ab5..000000000 --- a/surfsense_web/content/docs/how-to/electric-sql.mdx +++ /dev/null @@ -1,226 +0,0 @@ ---- -title: Electric SQL -description: Setting up Electric SQL for real-time data synchronization in SurfSense ---- - -[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL. - -## What does Electric SQL do? - -When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this: - -1. Backend writes data to PostgreSQL -2. Electric SQL detects changes and streams them to the frontend -3. PGlite (running in your browser) receives and stores the data locally in IndexedDB -4. Your UI updates instantly without refreshing - -This means: - -- **Inbox updates appear instantly** - No need to refresh the page -- **Document indexing progress updates live** - Watch your documents get processed -- **Connector status syncs automatically** - See when connectors finish syncing -- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible - -## Docker Setup - -- The `docker-compose.yml` includes the Electric SQL service, pre-configured to connect to the Docker-managed `db` container. -- No additional configuration is required. Electric SQL works with the Docker PostgreSQL instance out of the box. - -## Manual Setup (Development Only) - -This section is intended for local development environments. Follow the steps below based on your PostgreSQL setup. - -### Step 1: Configure Environment Variables - -Ensure your environment files are configured. If you haven't set up SurfSense yet, follow the [Manual Installation Guide](/docs/manual-installation) first. - -For Electric SQL, verify these variables are set: - -**Backend (`surfsense_backend/.env`):** - -```bash -ELECTRIC_DB_USER=electric -ELECTRIC_DB_PASSWORD=electric_password -``` - -**Frontend (`surfsense_web/.env`):** - -```bash -NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133 -NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure -``` - -Next, choose the option that matches your PostgreSQL setup: - ---- - -### Option A: Using Docker PostgreSQL - -If you're using the Docker-managed PostgreSQL instance, no extra configuration is needed. Just start the services using the development compose file (which exposes the PostgreSQL port to your host machine): - -```bash -docker compose -f docker-compose.dev.yml up -d db electric -``` - -Then run the database migration, start the backend, and launch the frontend: - -```bash -cd surfsense_backend -uv run alembic upgrade head -uv run main.py -``` - -In a separate terminal, start the frontend: - -```bash -cd surfsense_web -pnpm run dev -``` - -Electric SQL is now configured and connected to your Docker PostgreSQL database. - ---- - -### Option B: Using Local PostgreSQL - -If you're using a local PostgreSQL installation (e.g. Postgres.app on macOS), follow these steps: - -**1. Enable logical replication in PostgreSQL:** - -Open your `postgresql.conf` file: - -```bash -# Common locations: -# macOS (Postgres.app): ~/Library/Application Support/Postgres/var-17/postgresql.conf -# macOS (Homebrew): /opt/homebrew/var/postgresql@17/postgresql.conf -# Linux: /etc/postgresql/17/main/postgresql.conf - -sudo vim /path/to/postgresql.conf -``` - -Add the following settings: - -```ini -# Required for Electric SQL -wal_level = logical -max_replication_slots = 10 -max_wal_senders = 10 -``` - -After saving, restart PostgreSQL for the settings to take effect. - -**2. Create the Electric replication user:** - -Connect to your local database as a superuser and run: - -```sql -CREATE USER electric WITH REPLICATION PASSWORD 'electric_password'; -GRANT CONNECT ON DATABASE surfsense TO electric; -GRANT CREATE ON DATABASE surfsense TO electric; -GRANT USAGE ON SCHEMA public TO electric; -GRANT SELECT ON ALL TABLES IN SCHEMA public TO electric; -GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO electric; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO electric; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO electric; -CREATE PUBLICATION electric_publication_default; -``` - -**3. Set `ELECTRIC_DATABASE_URL` in `docker/.env`:** - -Uncomment and update this line to point Electric at your local Postgres via `host.docker.internal` (the hostname Docker containers use to reach the host machine): - -```bash -ELECTRIC_DATABASE_URL=postgresql://electric:electric_password@host.docker.internal:5432/surfsense?sslmode=disable -``` - -**4. Start Electric SQL only (skip the Docker `db` container):** - -```bash -docker compose -f docker-compose.dev.yml up -d --no-deps electric -``` - -The `--no-deps` flag starts only the `electric` service without starting the Docker-managed `db` container. - -**5. Run database migration and start the backend:** - -```bash -cd surfsense_backend -uv run alembic upgrade head -uv run main.py -``` - -In a separate terminal, start the frontend: - -```bash -cd surfsense_web -pnpm run dev -``` - -Electric SQL is now configured and connected to your local PostgreSQL database. - -## Environment Variables Reference - -**Required for manual setup:** - -| Variable | Location | Description | Default | -|----------|----------|-------------|---------| -| `ELECTRIC_DB_USER` | `surfsense_backend/.env` | Database user for Electric replication | `electric` | -| `ELECTRIC_DB_PASSWORD` | `surfsense_backend/.env` | Database password for Electric replication | `electric_password` | -| `NEXT_PUBLIC_ELECTRIC_URL` | `surfsense_web/.env` | Electric SQL server URL (PGlite connects to this) | `http://localhost:5133` | -| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | `surfsense_web/.env` | Authentication mode (`insecure` for dev, `secure` for production) | `insecure` | - -**Optional / Docker-only:** - -| Variable | Location | Description | Default | -|----------|----------|-------------|---------| -| `ELECTRIC_PORT` | `docker/.env` | Port to expose Electric SQL on the host | `5133` (dev), `5929` (production) | -| `ELECTRIC_DATABASE_URL` | `docker/.env` | Full connection URL override for Electric. Only needed for Option B (local Postgres via `host.docker.internal`) | *(built from above defaults)* | - -## Verify Setup - -To verify Electric SQL is running correctly: - -```bash -curl http://localhost:5133/v1/health -``` - -You should receive: - -```json -{"status":"active"} -``` - -## Troubleshooting - -### Electric SQL Server Not Starting - -**Check PostgreSQL settings:** -- Ensure `wal_level = logical` is set -- Verify the Electric user has replication permissions -- Check database connectivity from Electric container - -### Real-time Updates Not Working - -1. Open browser DevTools → Console -2. Look for errors containing `[Electric]` -3. Check Network tab for WebSocket connections to the Electric URL - -### Connection Refused Errors - -- Verify Electric SQL server is running: `docker ps | grep electric` -- Check the `NEXT_PUBLIC_ELECTRIC_URL` matches your Electric server address -- For Docker setups, ensure the frontend can reach the Electric container - -### Data Not Syncing - -- Check Electric SQL logs: `docker compose logs electric` -- Verify PostgreSQL replication is working -- Ensure the Electric user has proper table permissions - -### PGlite/IndexedDB Issues - -If data appears stale or corrupted in the browser: - -1. Open browser DevTools → Application → IndexedDB -2. Delete databases starting with `surfsense-` -3. Refresh the page - PGlite will recreate the local database and resync diff --git a/surfsense_web/content/docs/how-to/index.mdx b/surfsense_web/content/docs/how-to/index.mdx index 971df3512..a6571036d 100644 --- a/surfsense_web/content/docs/how-to/index.mdx +++ b/surfsense_web/content/docs/how-to/index.mdx @@ -8,11 +8,6 @@ import { Card, Cards } from 'fumadocs-ui/components/card'; Practical guides to help you get the most out of SurfSense. - { - // For insecure mode (development), return empty string - if (process.env.NEXT_PUBLIC_ELECTRIC_AUTH_MODE === "insecure") { - return ""; - } - - // In production, get token from your auth system - // This should match your backend auth token - if (typeof window !== "undefined") { - const token = localStorage.getItem("surfsense_bearer_token"); - return token || ""; - } - - return ""; -} diff --git a/surfsense_web/lib/electric/baseline.ts b/surfsense_web/lib/electric/baseline.ts deleted file mode 100644 index 6f819628e..000000000 --- a/surfsense_web/lib/electric/baseline.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { MutableRefObject } from "react"; - -/** - * Extract the newest `created_at` timestamp from a list of items. - * Used to establish the server-clock cutoff for the baseline timing-gap check. - * - * Uses Date parsing instead of string comparison because the API (Python - * isoformat: "+00:00" suffix) and Electric/PGlite ("Z" suffix, variable - * fractional-second precision) produce different string formats. - */ -export function getNewestTimestamp(items: T[]): string | null { - if (items.length === 0) return null; - let newest = items[0].created_at; - let newestMs = new Date(newest).getTime(); - for (let i = 1; i < items.length; i++) { - const ms = new Date(items[i].created_at).getTime(); - if (ms > newestMs) { - newest = items[i].created_at; - newestMs = ms; - } - } - return newest; -} - -/** - * Identify genuinely new items from an Electric live query callback. - * - * On Electric's first callback, ALL live IDs are snapshotted as the baseline. - * Items beyond the API's first page are in this baseline and stay hidden - * (they'll appear via scroll pagination). Items created in the timing gap - * between the API fetch and Electric's first callback are rescued via the - * `newestApiTimestamp` check — their `created_at` is newer than anything - * the API returned, so they pass through. - * - */ -export function filterNewElectricItems( - validItems: T[], - liveIds: Set, - prevIds: Set, - baselineRef: MutableRefObject | null>, - newestApiTimestamp: string | null -): T[] { - if (baselineRef.current === null) { - baselineRef.current = new Set(liveIds); - } - - const baseline = baselineRef.current; - const cutoffMs = newestApiTimestamp ? new Date(newestApiTimestamp).getTime() : null; - - const newItems = validItems.filter((item) => { - if (prevIds.has(item.id)) return false; - if (!baseline.has(item.id)) return true; - if (cutoffMs !== null && new Date(item.created_at).getTime() > cutoffMs) return true; - return false; - }); - - for (const item of newItems) { - baseline.add(item.id); - } - - return newItems; -} diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts deleted file mode 100644 index 09ef5e300..000000000 --- a/surfsense_web/lib/electric/client.ts +++ /dev/null @@ -1,848 +0,0 @@ -/** - * Electric SQL client setup for ElectricSQL 1.x with PGlite - * - * USER-SPECIFIC DATABASE ARCHITECTURE: - * - Each user gets their own IndexedDB database: idb://surfsense-{userId}-v{version} - * - On login: cleanup databases from other users, then initialize current user's DB - * - On logout: best-effort cleanup (not relied upon) - * - * This ensures: - * 1. Complete user isolation (data can never leak between users) - * 2. Self-healing on login (stale databases are cleaned up) - * 3. Works even if logout cleanup fails - */ - -import { PGlite, type Transaction } from "@electric-sql/pglite"; -import { live } from "@electric-sql/pglite/live"; -import { electricSync } from "@electric-sql/pglite-sync"; - -// Debug logging - only logs in development, silent in production -const IS_DEV = process.env.NODE_ENV === "development"; - -function debugLog(...args: unknown[]) { - if (IS_DEV) console.log(...args); -} - -function debugWarn(...args: unknown[]) { - if (IS_DEV) console.warn(...args); -} - -// Types -export interface ElectricClient { - db: PGlite; - userId: string; - syncShape: (options: SyncShapeOptions) => Promise; -} - -export interface SyncShapeOptions { - table: string; - where?: string; - columns?: string[]; - primaryKey?: string[]; -} - -export interface SyncHandle { - unsubscribe: () => void; - readonly isUpToDate: boolean; - // The stream property contains the ShapeStreamInterface from pglite-sync - stream?: unknown; - // Promise that resolves when initial sync is complete - initialSyncPromise?: Promise; -} - -// Singleton state - now tracks the user ID -let electricClient: ElectricClient | null = null; -let currentUserId: string | null = null; -let isInitializing = false; -let initPromise: Promise | null = null; - -// Cache for sync handles to prevent duplicate subscriptions (memory optimization) -const activeSyncHandles = new Map(); - -// Track pending sync operations to prevent race conditions -// If a sync is in progress, subsequent calls will wait for it instead of starting a new one -const pendingSyncs = new Map>(); - -// Version for sync state - increment this to force fresh sync when Electric config changes -// v2: user-specific database architecture -// v3: consistent cutoff date for sync+queries, visibility refresh support -// v4: heartbeat-based stale notification detection with updated_at tracking -// v5: fixed duplicate key errors, stable cutoff dates, onMustRefetch handler, -// real-time documents table with title/created_by_id/status columns, -// consolidated single documents sync, pending state for document queue visibility -// v6: added enable_summary column to search_source_connectors -// v7: fixed connector-popup using invalid category for useInbox -const SYNC_VERSION = 7; - -// Database name prefix for identifying SurfSense databases -const DB_PREFIX = "surfsense-"; - -// Get Electric URL from environment -function getElectricUrl(): string { - if (typeof window !== "undefined") { - return process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"; - } - return "http://localhost:5133"; -} - -/** - * Get the database name for a specific user - */ -function getDbName(userId: string): string { - return `idb://${DB_PREFIX}${userId}-v${SYNC_VERSION}`; -} - -/** - * Clean up databases from OTHER users AND old versions - * This is called on login to ensure clean state - */ -async function cleanupOtherUserDatabases(currentUserId: string): Promise { - if (typeof window === "undefined" || !window.indexedDB) { - return; - } - - // The exact database identifier we want to keep (current user + current version) - // Format: "surfsense-{userId}-v{version}" - const currentDbIdentifier = `${DB_PREFIX}${currentUserId}-v${SYNC_VERSION}`; - - try { - // Try to list all databases (not supported in all browsers) - if (typeof window.indexedDB.databases === "function") { - const databases = await window.indexedDB.databases(); - - for (const dbInfo of databases) { - const dbName = dbInfo.name; - if (!dbName) continue; - - // Check if this is a SurfSense database - if (dbName.includes("surfsense")) { - // Check if this is the current database - // PGlite stores with "/pglite/" prefix, so we check if the name ENDS WITH our identifier - if (dbName.endsWith(currentDbIdentifier)) { - debugLog(`[Electric] Keeping current database: ${dbName}`); - continue; - } - - // Delete ALL other databases (other users OR old versions of current user) - try { - debugLog(`[Electric] Deleting stale database: ${dbName}`); - window.indexedDB.deleteDatabase(dbName); - } catch (deleteErr) { - debugWarn(`[Electric] Failed to delete database ${dbName}:`, deleteErr); - } - } - } - } - } catch (err) { - // indexedDB.databases() not supported - that's okay, login cleanup is best-effort - debugWarn("[Electric] Could not enumerate databases for cleanup:", err); - } -} - -/** - * Initialize the Electric SQL client for a specific user - * - * KEY BEHAVIORS: - * 1. If already initialized for the SAME user, returns existing client - * 2. If initialized for a DIFFERENT user, closes old client and creates new one - * 3. On first init, cleans up databases from other users - * - * @param userId - The current user's ID (required) - */ -export async function initElectric(userId: string): Promise { - if (!userId) { - throw new Error("userId is required for Electric initialization"); - } - - // If already initialized for this user, return existing client - if (electricClient && currentUserId === userId) { - return electricClient; - } - - // If initialized for a different user, close the old client first - if (electricClient && currentUserId !== userId) { - debugLog(`[Electric] User changed from ${currentUserId} to ${userId}, reinitializing...`); - await cleanupElectric(); - } - - // If already initializing, wait for it - if (isInitializing && initPromise) { - return initPromise; - } - - isInitializing = true; - currentUserId = userId; - - initPromise = (async () => { - try { - // STEP 1: Clean up databases from other users (login-time cleanup) - debugLog("[Electric] Cleaning up databases from other users..."); - await cleanupOtherUserDatabases(userId); - - // STEP 2: Create user-specific PGlite database - const dbName = getDbName(userId); - debugLog(`[Electric] Initializing database: ${dbName}`); - - const db = await PGlite.create({ - dataDir: dbName, - relaxedDurability: true, - extensions: { - // Enable debug mode in electricSync only in development - electric: electricSync({ debug: process.env.NODE_ENV === "development" }), - live, // Enable live queries for real-time updates - }, - }); - - // STEP 3: Create the notifications table schema in PGlite - // This matches the backend schema - await db.exec(` - CREATE TABLE IF NOT EXISTS notifications ( - id INTEGER PRIMARY KEY, - user_id TEXT NOT NULL, - search_space_id INTEGER, - type TEXT NOT NULL, - title TEXT NOT NULL, - message TEXT NOT NULL, - read BOOLEAN NOT NULL DEFAULT FALSE, - metadata JSONB DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ - ); - - CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); - CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read); - `); - - // Create the search_source_connectors table schema in PGlite - // This matches the backend schema - await db.exec(` - CREATE TABLE IF NOT EXISTS search_source_connectors ( - id INTEGER PRIMARY KEY, - search_space_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - connector_type TEXT NOT NULL, - name TEXT NOT NULL, - is_indexable BOOLEAN NOT NULL DEFAULT FALSE, - last_indexed_at TIMESTAMPTZ, - config JSONB DEFAULT '{}', - periodic_indexing_enabled BOOLEAN NOT NULL DEFAULT FALSE, - indexing_frequency_minutes INTEGER, - next_scheduled_at TIMESTAMPTZ, - enable_summary BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_connectors_search_space_id ON search_source_connectors(search_space_id); - CREATE INDEX IF NOT EXISTS idx_connectors_type ON search_source_connectors(connector_type); - CREATE INDEX IF NOT EXISTS idx_connectors_user_id ON search_source_connectors(user_id); - `); - - // Create the documents table schema in PGlite - // Sync columns needed for real-time table display (lightweight - no content/metadata) - await db.exec(` - CREATE TABLE IF NOT EXISTS documents ( - id INTEGER PRIMARY KEY, - search_space_id INTEGER NOT NULL, - document_type TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', - created_by_id TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - status JSONB DEFAULT '{"state": "ready"}'::jsonb - ); - - CREATE INDEX IF NOT EXISTS idx_documents_search_space_id ON documents(search_space_id); - CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(document_type); - CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type); - CREATE INDEX IF NOT EXISTS idx_documents_status ON documents((status->>'state')); - `); - - await db.exec(` - CREATE TABLE IF NOT EXISTS chat_comment_mentions ( - id INTEGER PRIMARY KEY, - comment_id INTEGER NOT NULL, - mentioned_user_id TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_user_id ON chat_comment_mentions(mentioned_user_id); - CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id); - `); - - // Create chat_comments table for live comment sync - await db.exec(` - CREATE TABLE IF NOT EXISTS chat_comments ( - id INTEGER PRIMARY KEY, - message_id INTEGER NOT NULL, - thread_id INTEGER NOT NULL, - parent_id INTEGER, - author_id TEXT, - content TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id); - CREATE INDEX IF NOT EXISTS idx_chat_comments_message_id ON chat_comments(message_id); - CREATE INDEX IF NOT EXISTS idx_chat_comments_parent_id ON chat_comments(parent_id); - `); - - // Create new_chat_messages table for live message sync - await db.exec(` - CREATE TABLE IF NOT EXISTS new_chat_messages ( - id INTEGER PRIMARY KEY, - thread_id INTEGER NOT NULL, - role TEXT NOT NULL, - content JSONB NOT NULL, - author_id TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_new_chat_messages_thread_id ON new_chat_messages(thread_id); - CREATE INDEX IF NOT EXISTS idx_new_chat_messages_created_at ON new_chat_messages(created_at); - `); - - const electricUrl = getElectricUrl(); - - // STEP 4: Create the client wrapper - electricClient = { - db, - userId, - syncShape: async (options: SyncShapeOptions): Promise => { - const { table, where, columns, primaryKey = ["id"] } = options; - - // Create cache key for this sync shape - const cacheKey = `${table}_${where || "all"}_${columns?.join(",") || "all"}`; - - // Check if we already have an active sync for this shape (memory optimization) - const existingHandle = activeSyncHandles.get(cacheKey); - if (existingHandle) { - debugLog(`[Electric] Reusing existing sync handle for: ${cacheKey}`); - return existingHandle; - } - - // Check if there's already a pending sync for this shape (prevent race condition) - const pendingSync = pendingSyncs.get(cacheKey); - if (pendingSync) { - debugLog(`[Electric] Waiting for pending sync to complete: ${cacheKey}`); - return pendingSync; - } - - // Create and track the sync promise to prevent race conditions - const syncPromise = (async (): Promise => { - // Build params for the shape request - // Electric SQL expects params as URL query parameters - const params: Record = { table }; - - // Validate and fix WHERE clause to ensure string literals are properly quoted - let validatedWhere = where; - if (where) { - // Check if where uses positional parameters - if (where.includes("$1")) { - // Extract the value from the where clause if it's embedded - // For now, we'll use the where clause as-is and let Electric handle it - params.where = where; - validatedWhere = where; - } else { - // Validate that string literals are properly quoted - // Count single quotes - should be even (pairs) for properly quoted strings - const singleQuoteCount = (where.match(/'/g) || []).length; - - if (singleQuoteCount % 2 !== 0) { - // Odd number of quotes means unterminated string literal - debugWarn("Where clause has unmatched quotes, fixing:", where); - // Add closing quote at the end - validatedWhere = `${where}'`; - params.where = validatedWhere; - } else { - // Use the where clause directly (already formatted) - params.where = where; - validatedWhere = where; - } - } - } - - if (columns) params.columns = columns.join(","); - - debugLog("[Electric] Syncing shape with params:", params); - debugLog("[Electric] Electric URL:", `${electricUrl}/v1/shape`); - debugLog("[Electric] Where clause:", where, "Validated:", validatedWhere); - - try { - // Debug: Test Electric SQL connection directly first (DEV ONLY - skipped in production) - if (process.env.NODE_ENV === "development") { - const testUrl = `${electricUrl}/v1/shape?table=${table}&offset=-1${validatedWhere ? `&where=${encodeURIComponent(validatedWhere)}` : ""}`; - debugLog("[Electric] Testing Electric SQL directly:", testUrl); - try { - const testResponse = await fetch(testUrl); - const testHeaders = { - handle: testResponse.headers.get("electric-handle"), - offset: testResponse.headers.get("electric-offset"), - upToDate: testResponse.headers.get("electric-up-to-date"), - }; - debugLog("[Electric] Direct Electric SQL response headers:", testHeaders); - const testData = await testResponse.json(); - debugLog( - "[Electric] Direct Electric SQL data count:", - Array.isArray(testData) ? testData.length : "not array", - testData - ); - } catch (testErr) { - console.error("[Electric] Direct Electric SQL test failed:", testErr); - } - } - - // Use PGlite's electric sync plugin to sync the shape - // According to Electric SQL docs, the shape config uses params for table, where, columns - // Note: mapColumns is OPTIONAL per pglite-sync types.ts - - // Create a promise that resolves when initial sync is complete - // Using recommended approach: check isUpToDate immediately, watch stream, shorter timeout - // IMPORTANT: We don't unsubscribe from the stream - it must stay active for real-time updates - let syncResolved = false; - // Initialize with no-op functions to satisfy TypeScript - let resolveInitialSync: () => void = () => {}; - let rejectInitialSync: (error: Error) => void = () => {}; - - const initialSyncPromise = new Promise((resolve, reject) => { - resolveInitialSync = () => { - if (!syncResolved) { - syncResolved = true; - // DON'T unsubscribe from stream - it needs to stay active for real-time updates - resolve(); - } - }; - rejectInitialSync = (error: Error) => { - if (!syncResolved) { - syncResolved = true; - // DON'T unsubscribe from stream even on error - let Electric handle it - reject(error); - } - }; - - // Shorter timeout (5 seconds) as fallback - setTimeout(() => { - if (!syncResolved) { - debugWarn( - `[Electric] ⚠️ Sync timeout for ${table} - checking isUpToDate one more time...` - ); - // Check isUpToDate one more time before resolving - // This will be checked after shape is created - setTimeout(() => { - if (!syncResolved) { - debugWarn( - `[Electric] ⚠️ Sync timeout for ${table} - resolving anyway after 5s` - ); - resolveInitialSync(); - } - }, 100); - } - }, 5000); - }); - - // ROOT CAUSE FIX: The duplicate key errors were caused by unstable cutoff dates - // in use-inbox.ts generating different sync keys on each render. - // That's now fixed (rounded to midnight UTC in getSyncCutoffDate). - // We can safely use shapeKey for fast incremental sync. - - const shapeKey = `${userId}_v${SYNC_VERSION}_${table}_${where?.replace(/[^a-zA-Z0-9]/g, "_") || "all"}`; - - // Type assertion to PGlite with electric extension - const pgWithElectric = db as unknown as { - electric: { - syncShapeToTable: ( - config: Record - ) => Promise<{ unsubscribe: () => void; isUpToDate: boolean; stream: unknown }>; - }; - }; - - const shapeConfig = { - shape: { - url: `${electricUrl}/v1/shape`, - params: { - table, - ...(validatedWhere ? { where: validatedWhere } : {}), - ...(columns ? { columns: columns.join(",") } : {}), - }, - }, - table, - primaryKey, - shapeKey, // Re-enabled for fast incremental sync (root cause in use-inbox.ts is fixed) - onInitialSync: () => { - debugLog( - `[Electric] ✅ Initial sync complete for ${table} - data should now be in PGlite` - ); - resolveInitialSync(); - }, - onError: (error: Error) => { - console.error(`[Electric] ❌ Shape sync error for ${table}:`, error); - console.error( - "[Electric] Error details:", - JSON.stringify(error, Object.getOwnPropertyNames(error)) - ); - rejectInitialSync(error); - }, - // Handle must-refetch: clear table data before Electric re-inserts from scratch - // This prevents "duplicate key" errors when the shape is invalidated - onMustRefetch: async (tx: Transaction) => { - debugLog( - `[Electric] ⚠️ Must refetch triggered for ${table} - clearing existing data` - ); - try { - // Delete rows matching the shape's WHERE clause - // If no WHERE clause, delete all rows from the table - if (validatedWhere) { - // Parse the WHERE clause to build a DELETE statement - // The WHERE clause is already validated and formatted - await tx.exec(`DELETE FROM ${table} WHERE ${validatedWhere}`); - debugLog(`[Electric] 🗑️ Cleared ${table} rows matching: ${validatedWhere}`); - } else { - // No WHERE clause means we're syncing the entire table - await tx.exec(`DELETE FROM ${table}`); - debugLog(`[Electric] 🗑️ Cleared all rows from ${table}`); - } - } catch (cleanupError) { - console.error( - `[Electric] ❌ Failed to clear ${table} during must-refetch:`, - cleanupError - ); - // Re-throw to let Electric handle the error - throw cleanupError; - } - }, - }; - - debugLog("[Electric] syncShapeToTable config:", JSON.stringify(shapeConfig, null, 2)); - - let shape: { unsubscribe: () => void; isUpToDate: boolean; stream: unknown }; - try { - shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig); - } catch (syncError) { - // Handle "Already syncing" error - pglite-sync might not have fully cleaned up yet - const errorMessage = - syncError instanceof Error ? syncError.message : String(syncError); - if (errorMessage.includes("Already syncing")) { - debugWarn( - `[Electric] Already syncing ${table}, waiting for existing sync to settle...` - ); - - // Wait a short time for pglite-sync to settle - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Check if an active handle now exists (another sync might have completed) - const existingHandle = activeSyncHandles.get(cacheKey); - if (existingHandle) { - debugLog(`[Electric] Found existing handle after waiting: ${cacheKey}`); - return existingHandle; - } - - // Retry once after waiting - debugLog(`[Electric] Retrying sync for ${table}...`); - try { - shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig); - } catch (retryError) { - const retryMessage = - retryError instanceof Error ? retryError.message : String(retryError); - if (retryMessage.includes("Already syncing")) { - // Still syncing - create a placeholder handle that indicates the table is being synced - debugWarn(`[Electric] ${table} still syncing, creating placeholder handle`); - const placeholderHandle: SyncHandle = { - unsubscribe: () => { - debugLog(`[Electric] Placeholder unsubscribe for: ${cacheKey}`); - activeSyncHandles.delete(cacheKey); - }, - get isUpToDate() { - return false; // We don't know the real state - }, - stream: undefined, - initialSyncPromise: Promise.resolve(), // Already syncing means data should be coming - }; - activeSyncHandles.set(cacheKey, placeholderHandle); - return placeholderHandle; - } - throw retryError; - } - } else { - throw syncError; - } - } - - if (!shape) { - throw new Error("syncShapeToTable returned undefined"); - } - - // Log the actual shape result structure - debugLog("[Electric] Shape sync result (initial):", { - hasUnsubscribe: typeof shape?.unsubscribe === "function", - isUpToDate: shape?.isUpToDate, - hasStream: !!shape?.stream, - streamType: typeof shape?.stream, - }); - - // Recommended Approach Step 1: Check isUpToDate immediately - if (shape.isUpToDate) { - debugLog( - `[Electric] ✅ Sync already up-to-date for ${table} (resuming from previous state)` - ); - resolveInitialSync(); - } else { - // Recommended Approach Step 2: Subscribe to stream and watch for "up-to-date" message - if (shape?.stream) { - const stream = shape.stream as any; - debugLog("[Electric] Shape stream details:", { - shapeHandle: stream?.shapeHandle, - lastOffset: stream?.lastOffset, - isUpToDate: stream?.isUpToDate, - error: stream?.error, - hasSubscribe: typeof stream?.subscribe === "function", - hasUnsubscribe: typeof stream?.unsubscribe === "function", - }); - - // Subscribe to the stream to watch for "up-to-date" control message - // NOTE: We keep this subscription active - don't unsubscribe! - // The stream is what Electric SQL uses for real-time updates - if (typeof stream?.subscribe === "function") { - debugLog( - "[Electric] Subscribing to shape stream to watch for up-to-date message..." - ); - // Subscribe but don't store unsubscribe - we want it to stay active - stream.subscribe((messages: unknown[]) => { - // Continue receiving updates even after sync is resolved - if (!syncResolved) { - debugLog( - "[Electric] 🔵 Shape stream received messages:", - messages?.length || 0 - ); - } - - // Check if any message indicates sync is complete - if (messages && messages.length > 0) { - for (const message of messages) { - const msg = message as any; - // Check for "up-to-date" control message - if ( - msg?.headers?.control === "up-to-date" || - msg?.headers?.electric_up_to_date === "true" || - (typeof msg === "object" && "up-to-date" in msg) - ) { - if (!syncResolved) { - debugLog(`[Electric] ✅ Received up-to-date message for ${table}`); - resolveInitialSync(); - } - // Continue listening for real-time updates - don't return! - } - } - if (!syncResolved && messages.length > 0) { - debugLog( - "[Electric] First message:", - JSON.stringify(messages[0], null, 2) - ); - } - } - - // Also check stream's isUpToDate property after receiving messages - if (!syncResolved && stream?.isUpToDate) { - debugLog(`[Electric] ✅ Stream isUpToDate is true for ${table}`); - resolveInitialSync(); - } - }); - - // Also check stream's isUpToDate property immediately - if (stream?.isUpToDate) { - debugLog(`[Electric] ✅ Stream isUpToDate is true immediately for ${table}`); - resolveInitialSync(); - } - } - - // Also poll isUpToDate periodically as a backup (every 200ms) - const pollInterval = setInterval(() => { - if (syncResolved) { - clearInterval(pollInterval); - return; - } - - if (shape.isUpToDate || stream?.isUpToDate) { - debugLog(`[Electric] ✅ Sync completed (detected via polling) for ${table}`); - clearInterval(pollInterval); - resolveInitialSync(); - } - }, 200); - - // Clean up polling when promise resolves - initialSyncPromise.finally(() => { - clearInterval(pollInterval); - }); - } else { - debugWarn( - `[Electric] ⚠️ No stream available for ${table}, relying on callback and timeout` - ); - } - } - - // Create the sync handle with proper cleanup - const syncHandle: SyncHandle = { - unsubscribe: () => { - debugLog(`[Electric] Unsubscribing from: ${cacheKey}`); - // Remove from cache first - activeSyncHandles.delete(cacheKey); - // Then unsubscribe from the shape - if (shape && typeof shape.unsubscribe === "function") { - shape.unsubscribe(); - } - }, - // Use getter to always return current state - get isUpToDate() { - return shape?.isUpToDate ?? false; - }, - stream: shape?.stream, - initialSyncPromise, // Expose promise so callers can wait for sync - }; - - // Cache the sync handle for reuse (memory optimization) - activeSyncHandles.set(cacheKey, syncHandle); - debugLog( - `[Electric] Cached sync handle for: ${cacheKey} (total cached: ${activeSyncHandles.size})` - ); - - return syncHandle; - } catch (error) { - console.error("[Electric] Failed to sync shape:", error); - // Check if Electric SQL server is reachable - try { - const response = await fetch(`${electricUrl}/v1/shape?table=${table}&offset=-1`, { - method: "GET", - }); - debugLog( - "[Electric] Electric SQL server response:", - response.status, - response.statusText - ); - if (!response.ok) { - console.error("[Electric] Electric SQL server error:", await response.text()); - } - } catch (fetchError) { - console.error("[Electric] Cannot reach Electric SQL server:", fetchError); - console.error("[Electric] Make sure Electric SQL is running at:", electricUrl); - } - throw error; - } - })(); - - // Track the sync promise to prevent concurrent syncs for the same shape - pendingSyncs.set(cacheKey, syncPromise); - - // Clean up the pending sync when done (whether success or failure) - syncPromise.finally(() => { - pendingSyncs.delete(cacheKey); - debugLog(`[Electric] Pending sync removed for: ${cacheKey}`); - }); - - return syncPromise; - }, - }; - - debugLog(`[Electric] ✅ Initialized successfully for user: ${userId}`); - return electricClient; - } catch (error) { - console.error("[Electric] Failed to initialize:", error); - // Reset state on failure - electricClient = null; - currentUserId = null; - throw error; - } finally { - isInitializing = false; - } - })(); - - return initPromise; -} - -/** - * Cleanup Electric SQL - close database and reset singleton - * Called on logout (best-effort) and when switching users - */ -export async function cleanupElectric(): Promise { - if (!electricClient) { - return; - } - - const userIdToClean = currentUserId; - debugLog(`[Electric] Cleaning up for user: ${userIdToClean}`); - - // Unsubscribe from all active sync handles first (memory cleanup) - debugLog(`[Electric] Unsubscribing from ${activeSyncHandles.size} active sync handles`); - // Copy keys to array to avoid mutation during iteration - const handleKeys = Array.from(activeSyncHandles.keys()); - for (const key of handleKeys) { - const handle = activeSyncHandles.get(key); - if (handle) { - try { - handle.unsubscribe(); - } catch (err) { - debugWarn(`[Electric] Failed to unsubscribe from ${key}:`, err); - } - } - } - // Ensure caches are empty - activeSyncHandles.clear(); - pendingSyncs.clear(); - - try { - // Close the PGlite database connection - await electricClient.db.close(); - debugLog("[Electric] Database closed"); - } catch (error) { - console.error("[Electric] Error closing database:", error); - } - - // Reset singleton state - electricClient = null; - currentUserId = null; - isInitializing = false; - initPromise = null; - - // Delete the user's IndexedDB database (best-effort cleanup on logout) - if (typeof window !== "undefined" && window.indexedDB && userIdToClean) { - try { - const dbName = `${DB_PREFIX}${userIdToClean}-v${SYNC_VERSION}`; - window.indexedDB.deleteDatabase(dbName); - debugLog(`[Electric] Deleted database: ${dbName}`); - } catch (err) { - debugWarn("[Electric] Failed to delete database:", err); - } - } - - debugLog("[Electric] Cleanup complete"); -} - -/** - * Get the Electric client (throws if not initialized) - */ -export function getElectric(): ElectricClient { - if (!electricClient) { - throw new Error("Electric not initialized. Call initElectric(userId) first."); - } - return electricClient; -} - -/** - * Check if Electric is initialized for a specific user - */ -export function isElectricInitialized(userId?: string): boolean { - if (!electricClient) return false; - if (userId && currentUserId !== userId) return false; - return true; -} - -/** - * Get the current user ID that Electric is initialized for - */ -export function getCurrentElectricUserId(): string | null { - return currentUserId; -} - -/** - * Get the PGlite database instance - */ -export function getDb(): PGlite | null { - return electricClient?.db ?? null; -} diff --git a/surfsense_web/lib/electric/context.ts b/surfsense_web/lib/electric/context.ts deleted file mode 100644 index 777d4e12c..000000000 --- a/surfsense_web/lib/electric/context.ts +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { createContext, useContext } from "react"; -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) - * 3. Clean cleanup (ElectricProvider manages lifecycle) - */ -export const ElectricContext = createContext(null); - -/** - * Hook to get the Electric client from context - * Returns null if Electric is not initialized yet - */ -export function useElectricClient(): ElectricClient | null { - return useContext(ElectricContext); -} - -/** - * Hook to get the Electric client, throwing if not available - * Use this when you're sure Electric should be initialized - */ -export function useElectricClientOrThrow(): ElectricClient { - const client = useContext(ElectricContext); - if (!client) { - throw new Error( - "Electric client not available. Make sure you're inside ElectricProvider and user is authenticated." - ); - } - return client; -} diff --git a/surfsense_web/package.json b/surfsense_web/package.json index f2a401f07..52350325b 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -27,10 +27,6 @@ "@assistant-ui/react-ai-sdk": "^1.1.20", "@assistant-ui/react-markdown": "^0.11.9", "@babel/standalone": "^7.29.2", - "@electric-sql/client": "^1.4.0", - "@electric-sql/pglite": "^0.3.14", - "@electric-sql/pglite-sync": "^0.4.0", - "@electric-sql/react": "^1.0.26", "@hookform/resolvers": "^5.2.2", "@number-flow/react": "^0.5.10", "@platejs/autoformat": "^52.0.11",