feat: Integrate Electric SQL for real-time notifications and enhance PostgreSQL configuration

- Added Electric SQL service to docker-compose for real-time data synchronization.
- Introduced PostgreSQL configuration for logical replication and performance tuning.
- Created scripts for initializing Electric SQL user and electrifying tables.
- Implemented notification model and service in the backend.
- Developed ElectricProvider and useNotifications hook in the frontend for managing notifications.
- Updated environment variables and package dependencies for Electric SQL integration.
This commit is contained in:
Anish Sarkar 2026-01-12 12:47:00 +05:30
parent 383592ce63
commit 82c6dd0221
18 changed files with 1844 additions and 6 deletions

View file

@ -1,5 +1,10 @@
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

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google";
import { ElectricProvider } from "@/components/providers/ElectricProvider";
import { I18nProvider } from "@/components/providers/I18nProvider";
import { PostHogProvider } from "@/components/providers/PostHogProvider";
import { ThemeProvider } from "@/components/theme/theme-provider";
@ -102,7 +103,9 @@ export default function RootLayout({
defaultTheme="light"
>
<RootProvider>
<ReactQueryClientProvider>{children}</ReactQueryClientProvider>
<ReactQueryClientProvider>
<ElectricProvider>{children}</ElectricProvider>
</ReactQueryClientProvider>
<Toaster />
</RootProvider>
</ThemeProvider>

View file

@ -0,0 +1,43 @@
"use client"
import { useEffect, useState } from 'react'
import { initElectric } from '@/lib/electric/client'
interface ElectricProviderProps {
children: React.ReactNode
}
export function ElectricProvider({ children }: ElectricProviderProps) {
const [initialized, setInitialized] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
async function init() {
try {
await initElectric()
setInitialized(true)
setError(null)
} catch (err) {
console.error('Failed to initialize Electric SQL:', err)
setError(err instanceof Error ? err : new Error('Failed to initialize Electric SQL'))
// Don't block rendering if Electric SQL fails - app can still work
setInitialized(true)
}
}
init()
}, [])
// Show loading state only briefly, then render children
// Electric SQL will sync in the background
if (!initialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Initializing...</div>
</div>
)
}
return <>{children}</>
}

View file

@ -0,0 +1,17 @@
import { defineConfig } from '@electric-sql/cli'
export default defineConfig({
connection: {
host: process.env.ELECTRIC_HOST || 'localhost',
port: parseInt(process.env.ELECTRIC_PORT || '5133', 10),
database: process.env.POSTGRES_DB || 'surfsense',
user: process.env.ELECTRIC_USER || 'electric',
password: process.env.ELECTRIC_PASSWORD || 'electric_password',
},
outDir: './lib/electric/generated',
service: {
host: process.env.ELECTRIC_HOST || 'localhost',
port: parseInt(process.env.ELECTRIC_PORT || '5133', 10),
},
})

View file

@ -0,0 +1,110 @@
"use client"
import { useEffect, useState, useCallback } from 'react'
import { useLiveQuery } from 'electric-sql/react'
import { initElectric, getElectric, isElectricInitialized } from '@/lib/electric/client'
export interface Notification {
id: number
user_id: string
search_space_id: number | null
type: string
title: string
message: string
read: boolean
metadata: Record<string, any>
created_at: string
updated_at: string | null
}
export function useNotifications(userId: string | null) {
const [electric, setElectric] = useState<any>(null)
const [initialized, setInitialized] = useState(false)
const [error, setError] = useState<Error | null>(null)
// Initialize Electric SQL
useEffect(() => {
if (!userId || initialized) return
async function init() {
try {
const electricClient = await initElectric()
setElectric(electricClient)
setInitialized(true)
setError(null)
} catch (err) {
console.error('Failed to initialize Electric SQL:', err)
setError(err instanceof Error ? err : new Error('Failed to initialize Electric SQL'))
}
}
init()
}, [userId, initialized])
// Use live query to get notifications
const { results: notifications } = useLiveQuery(
electric?.db.notifications?.liveMany({
where: {
user_id: userId || '',
read: false,
},
orderBy: {
created_at: 'desc',
},
})
) ?? { results: [] }
// Mark notification as read
const markAsRead = useCallback(
async (notificationId: number) => {
if (!electric || !isElectricInitialized()) {
console.warn('Electric SQL not initialized')
return false
}
try {
await electric.db.notifications.update({
data: { read: true },
where: { id: notificationId },
})
return true
} catch (err) {
console.error('Failed to mark notification as read:', err)
return false
}
},
[electric]
)
// Mark all notifications as read
const markAllAsRead = useCallback(async () => {
if (!electric || !isElectricInitialized()) {
console.warn('Electric SQL not initialized')
return false
}
try {
const unread = (notifications || []).filter((n: Notification) => !n.read)
for (const notification of unread) {
await markAsRead(notification.id)
}
return true
} catch (err) {
console.error('Failed to mark all notifications as read:', err)
return false
}
}, [electric, notifications, markAsRead])
// Get unread count
const unreadCount = (notifications || []).filter((n: Notification) => !n.read).length
return {
notifications: (notifications || []) as Notification[],
unreadCount,
markAsRead,
markAllAsRead,
loading: !initialized,
error,
}
}

View file

@ -0,0 +1,21 @@
/**
* Get auth token for Electric SQL
* In production, this should get the token from your auth system
*/
export async function getElectricAuthToken(): Promise<string> {
// 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 ''
}

View file

@ -0,0 +1,86 @@
/**
* Electric SQL client setup
* This initializes the Electric SQL client with local PGlite database (PostgreSQL in browser)
*/
import { PGlite } from '@electric-sql/pglite'
import { electrify } from 'electric-sql/pglite'
import { getElectricAuthToken } from './auth'
// We'll generate the schema after running electric:generate
// For now, we'll use a placeholder type
type Electric = any
type Schema = any
let electric: Electric | null = null
let isInitializing = false
let initPromise: Promise<Electric> | null = null
export async function initElectric(): Promise<Electric> {
if (electric) {
return electric
}
if (isInitializing && initPromise) {
return initPromise
}
isInitializing = true
initPromise = (async () => {
try {
const config = {
auth: {
token: await getElectricAuthToken(),
},
url: process.env.NEXT_PUBLIC_ELECTRIC_URL || 'http://localhost:5133',
}
// Initialize PGlite database (PostgreSQL in browser)
// Use idb:// prefix for IndexedDB storage in browser
// relaxedDurability improves responsiveness by scheduling flush after query returns
const conn = new PGlite('idb://surfsense.db', {
relaxedDurability: true,
})
// Import schema (will be generated by electric:generate)
// For now, we'll use a dynamic import that will work after schema generation
let schema: Schema
try {
const schemaModule = await import('./generated/schema')
schema = schemaModule.schema
} catch (error) {
console.warn(
'Electric SQL schema not found. Run "pnpm electric:generate" to generate it.',
error
)
// Return a mock electric client for now
return null as any
}
// Electrify the PGlite database connection
electric = await electrify(conn, schema, config)
console.log('Electric SQL initialized successfully with PGlite')
return electric
} catch (error) {
console.error('Failed to initialize Electric SQL:', error)
throw error
} finally {
isInitializing = false
}
})()
return initPromise
}
export function getElectric(): Electric {
if (!electric) {
throw new Error('Electric not initialized. Call initElectric() first.')
}
return electric
}
export function isElectricInitialized(): boolean {
return electric !== null
}

View file

@ -0,0 +1,19 @@
/**
* Electric SQL configuration
* This file will be used by @electric-sql/cli to generate the schema
*/
export const electricConfig = {
connection: {
host: process.env.ELECTRIC_HOST || 'localhost',
port: parseInt(process.env.ELECTRIC_PORT || '5133', 10),
database: process.env.POSTGRES_DB || 'surfsense',
user: process.env.ELECTRIC_USER || 'electric',
password: process.env.ELECTRIC_PASSWORD || 'electric_password',
},
service: {
host: process.env.ELECTRIC_HOST || 'localhost',
port: parseInt(process.env.ELECTRIC_PORT || '5133', 10),
},
}

View file

@ -18,7 +18,9 @@
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"format:fix": "npx @biomejs/biome check --fix"
"format:fix": "npx @biomejs/biome check --fix",
"electric:generate": "electric generate",
"electric:watch": "electric watch"
},
"dependencies": {
"@ai-sdk/react": "^1.2.12",
@ -29,6 +31,8 @@
"@blocknote/mantine": "^0.45.0",
"@blocknote/react": "^0.45.0",
"@blocknote/server-util": "^0.45.0",
"@electric-sql/client": "^1.4.0",
"@electric-sql/pglite": "^0.2.17",
"@hookform/resolvers": "^5.2.2",
"@number-flow/react": "^0.5.10",
"@posthog/react": "^1.5.2",
@ -67,6 +71,7 @@
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.5",
"electric-sql": "^0.12.1",
"emblor": "^1.4.8",
"fumadocs-core": "^16.3.1",
"fumadocs-mdx": "^14.2.1",
@ -105,6 +110,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.1.2",
"@electric-sql/cli": "0.11.4-canary.cb19c58",
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",

File diff suppressed because it is too large Load diff