mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-30 21:59:46 +02:00
feat: Implement notification system with real-time updates and Electric SQL integration
- Added notifications table to the database schema with replication support for Electric SQL. - Developed NotificationService to manage indexing notifications, including creation, updates, and status tracking. - Introduced NotificationButton and NotificationPopup components for displaying notifications in the UI. - Enhanced useNotifications hook for real-time notification syncing using PGlite live queries. - Updated package dependencies for Electric SQL and improved error handling in notification processes.
This commit is contained in:
parent
f441c7b0ce
commit
93d17b51f5
10 changed files with 1062 additions and 103 deletions
|
|
@ -9,11 +9,12 @@
|
|||
|
||||
import { PGlite } from '@electric-sql/pglite'
|
||||
import { electricSync } from '@electric-sql/pglite-sync'
|
||||
import { live } from '@electric-sql/pglite/live'
|
||||
|
||||
// Types
|
||||
export interface ElectricClient {
|
||||
db: PGlite
|
||||
syncShape: <T = Record<string, unknown>>(options: SyncShapeOptions) => Promise<SyncHandle<T>>
|
||||
syncShape: (options: SyncShapeOptions) => Promise<SyncHandle>
|
||||
}
|
||||
|
||||
export interface SyncShapeOptions {
|
||||
|
|
@ -23,13 +24,13 @@ export interface SyncShapeOptions {
|
|||
primaryKey?: string[]
|
||||
}
|
||||
|
||||
export interface SyncHandle<T = Record<string, unknown>> {
|
||||
export interface SyncHandle {
|
||||
unsubscribe: () => void
|
||||
isUpToDate: boolean
|
||||
shape: {
|
||||
handle?: string
|
||||
offset?: string
|
||||
}
|
||||
readonly isUpToDate: boolean
|
||||
// The stream property contains the ShapeStreamInterface from pglite-sync
|
||||
stream?: unknown
|
||||
// Promise that resolves when initial sync is complete
|
||||
initialSyncPromise?: Promise<void>
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
|
@ -37,6 +38,10 @@ let electricClient: ElectricClient | null = null
|
|||
let isInitializing = false
|
||||
let initPromise: Promise<ElectricClient> | null = null
|
||||
|
||||
// Version for sync state - increment this to force fresh sync when Electric config changes
|
||||
// Incremented to v4 to fix sync completion issues
|
||||
const SYNC_VERSION = 4
|
||||
|
||||
// Get Electric URL from environment
|
||||
function getElectricUrl(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
|
|
@ -60,11 +65,15 @@ export async function initElectric(): Promise<ElectricClient> {
|
|||
isInitializing = true
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
// Create PGlite instance with Electric sync plugin
|
||||
const db = await PGlite.create('idb://surfsense-notifications', {
|
||||
// Create PGlite instance with Electric sync plugin and live queries
|
||||
// Include version in database name to force fresh sync when Electric config changes
|
||||
const db = await PGlite.create({
|
||||
dataDir: `idb://surfsense-notifications-v${SYNC_VERSION}`,
|
||||
relaxedDurability: true,
|
||||
extensions: {
|
||||
electric: electricSync(),
|
||||
// Enable debug mode in electricSync to see detailed sync logs
|
||||
electric: electricSync({ debug: true }),
|
||||
live, // Enable live queries for real-time updates
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -93,36 +102,185 @@ export async function initElectric(): Promise<ElectricClient> {
|
|||
// Create the client wrapper
|
||||
electricClient = {
|
||||
db,
|
||||
syncShape: async <T = Record<string, unknown>>(options: SyncShapeOptions): Promise<SyncHandle<T>> => {
|
||||
syncShape: async (options: SyncShapeOptions): Promise<SyncHandle> => {
|
||||
const { table, where, columns, primaryKey = ['id'] } = options
|
||||
|
||||
// Build params for the shape request
|
||||
const params: Record<string, string> = { table }
|
||||
if (where) params.where = where
|
||||
if (columns) params.columns = columns.join(',')
|
||||
// Build params for the shape request
|
||||
// Electric SQL expects params as URL query parameters
|
||||
const params: Record<string, string> = { 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
|
||||
console.warn('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(',')
|
||||
|
||||
// Use PGlite's electric sync plugin to sync the shape
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const shape = await (db as any).electric.syncShapeToTable({
|
||||
console.log('Syncing shape with params:', params)
|
||||
console.log('Electric URL:', `${electricUrl}/v1/shape`)
|
||||
console.log('Where clause:', where, 'Validated:', validatedWhere)
|
||||
|
||||
try {
|
||||
// Debug: Test Electric SQL connection directly first
|
||||
// Use validatedWhere to ensure proper URL encoding
|
||||
const testUrl = `${electricUrl}/v1/shape?table=${table}&offset=-1${validatedWhere ? `&where=${encodeURIComponent(validatedWhere)}` : ''}`
|
||||
console.log('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'),
|
||||
}
|
||||
console.log('Direct Electric SQL response headers:', testHeaders)
|
||||
const testData = await testResponse.json()
|
||||
console.log('Direct Electric SQL data count:', Array.isArray(testData) ? testData.length : 'not array', testData)
|
||||
} catch (testErr) {
|
||||
console.error('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
|
||||
let resolveInitialSync: () => void
|
||||
let rejectInitialSync: (error: Error) => void
|
||||
const initialSyncPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveInitialSync = resolve
|
||||
rejectInitialSync = reject
|
||||
// Safety timeout - if sync doesn't complete in 30s, something is wrong
|
||||
setTimeout(() => {
|
||||
console.warn(`⚠️ Sync timeout for ${table} - sync did not complete in 30s`)
|
||||
resolve() // Resolve anyway to not block, but log warning
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
const shapeConfig = {
|
||||
shape: {
|
||||
url: `${electricUrl}/v1/shape`,
|
||||
params,
|
||||
params: {
|
||||
table,
|
||||
...(validatedWhere ? { where: validatedWhere } : {}),
|
||||
...(columns ? { columns: columns.join(',') } : {}),
|
||||
},
|
||||
},
|
||||
table,
|
||||
primaryKey,
|
||||
shapeKey: `v${SYNC_VERSION}_${table}_${where?.replace(/[^a-zA-Z0-9]/g, '_') || 'all'}`, // Versioned key to force fresh sync when needed
|
||||
onInitialSync: () => {
|
||||
console.log(`✅ Initial sync complete for ${table} - data should now be in PGlite`)
|
||||
resolveInitialSync()
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error(`❌ Shape sync error for ${table}:`, error)
|
||||
console.error('Error details:', JSON.stringify(error, Object.getOwnPropertyNames(error)))
|
||||
rejectInitialSync(error)
|
||||
},
|
||||
}
|
||||
|
||||
console.log('syncShapeToTable config:', JSON.stringify(shapeConfig, null, 2))
|
||||
|
||||
// Type assertion to PGlite with electric extension
|
||||
const pgWithElectric = db as PGlite & { electric: { syncShapeToTable: (config: typeof shapeConfig) => Promise<{ unsubscribe: () => void; isUpToDate: boolean; stream: unknown }> } }
|
||||
const shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig)
|
||||
|
||||
if (!shape) {
|
||||
throw new Error('syncShapeToTable returned undefined')
|
||||
}
|
||||
|
||||
// Log the actual shape result structure
|
||||
console.log('Shape sync result (initial):', {
|
||||
hasUnsubscribe: typeof shape?.unsubscribe === 'function',
|
||||
isUpToDate: shape?.isUpToDate,
|
||||
hasStream: !!shape?.stream,
|
||||
streamType: typeof shape?.stream,
|
||||
})
|
||||
|
||||
// Debug the stream if available
|
||||
if (shape?.stream) {
|
||||
const stream = shape.stream as any
|
||||
console.log('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',
|
||||
})
|
||||
|
||||
// Try to subscribe to the stream to see if it's receiving messages
|
||||
if (typeof stream?.subscribe === 'function') {
|
||||
console.log('Subscribing to shape stream for debugging...')
|
||||
stream.subscribe((messages: unknown[]) => {
|
||||
console.log('🔵 Shape stream received messages:', messages?.length || 0)
|
||||
if (messages && messages.length > 0) {
|
||||
console.log('First message:', JSON.stringify(messages[0], null, 2))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Wait briefly to see if sync starts
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
console.log('Shape sync result (after 100ms):', {
|
||||
isUpToDate: shape?.isUpToDate,
|
||||
})
|
||||
|
||||
// Return the shape handle - isUpToDate is a getter that reflects current state
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
console.log('unsubscribing')
|
||||
if (shape && typeof shape.unsubscribe === 'function') {
|
||||
shape.unsubscribe()
|
||||
}
|
||||
},
|
||||
isUpToDate: shape?.isUpToDate ?? false,
|
||||
shape: {
|
||||
handle: shape?.handle,
|
||||
offset: shape?.offset,
|
||||
// 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
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('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',
|
||||
})
|
||||
console.log('Electric SQL server response:', response.status, response.statusText)
|
||||
if (!response.ok) {
|
||||
console.error('Electric SQL server error:', await response.text())
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('Cannot reach Electric SQL server:', fetchError)
|
||||
console.error('Make sure Electric SQL is running at:', electricUrl)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue