mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-28 21:49:40 +02:00
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:
parent
383592ce63
commit
82c6dd0221
18 changed files with 1844 additions and 6 deletions
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
43
surfsense_web/components/providers/ElectricProvider.tsx
Normal file
43
surfsense_web/components/providers/ElectricProvider.tsx
Normal 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}</>
|
||||
}
|
||||
|
||||
17
surfsense_web/electric.config.ts
Normal file
17
surfsense_web/electric.config.ts
Normal 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),
|
||||
},
|
||||
})
|
||||
|
||||
110
surfsense_web/hooks/use-notifications.ts
Normal file
110
surfsense_web/hooks/use-notifications.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
21
surfsense_web/lib/electric/auth.ts
Normal file
21
surfsense_web/lib/electric/auth.ts
Normal 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 ''
|
||||
}
|
||||
|
||||
86
surfsense_web/lib/electric/client.ts
Normal file
86
surfsense_web/lib/electric/client.ts
Normal 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
|
||||
}
|
||||
|
||||
19
surfsense_web/lib/electric/config.ts
Normal file
19
surfsense_web/lib/electric/config.ts
Normal 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),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
1207
surfsense_web/pnpm-lock.yaml
generated
1207
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue