UNPKG

zeno-db

Version:

A lightweight, offline-first client-side database with automatic sync capabilities

460 lines (459 loc) 20.7 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.startSyncServer = startSyncServer; const ws_1 = require("ws"); const pg_1 = require("pg"); class DatabaseConnectionManager { constructor(config, maxRetries = 5, retryInterval = 5000) { this.config = config; this.maxRetries = maxRetries; this.retryInterval = retryInterval; this.pool = null; this.isConnected = false; this.isConnecting = false; this.retryCount = 0; this.reconnectTimer = null; this.healthCheckInterval = null; this.offlineSince = null; this.queuedChangesCount = 0; this.onConnectedCallback = null; } testConnection() { return __awaiter(this, void 0, void 0, function* () { if (!this.pool) return false; try { yield this.pool.query('SELECT 1'); return true; } catch (err) { console.error('[DatabaseManager] Connection test failed:', err); return false; } }); } connect() { return __awaiter(this, void 0, void 0, function* () { if (this.isConnecting) { console.log('[DatabaseManager] Connection attempt already in progress'); return null; } this.isConnecting = true; console.log('[DatabaseManager] 🔄 Attempting database connection...'); try { // Clean up existing pool if (this.pool) { this.pool.removeAllListeners(); yield this.pool.end().catch(err => console.error('[DatabaseManager] Error ending pool:', err)); this.pool = null; } // Create new pool this.pool = new pg_1.Pool({ connectionString: this.config.pg.connectionString, max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, allowExitOnIdle: true }); // Test connection immediately const isConnected = yield this.testConnection(); if (!isConnected) { throw new Error('Initial connection test failed'); } // Set up error handler this.pool.on('error', (err) => __awaiter(this, void 0, void 0, function* () { console.error('[DatabaseManager] Pool error:', err); this.isConnected = false; if (!this.offlineSince) { this.offlineSince = new Date(); console.log(`[DatabaseManager] ⚠️ System went offline at ${this.offlineSince.toLocaleString()}`); } yield this.handleConnectionError(); })); // Create table if not exists yield this.pool.query(` CREATE TABLE IF NOT EXISTS ${this.config.pg.table} ( id TEXT PRIMARY KEY, data JSONB, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ) `); this.isConnected = true; this.retryCount = 0; if (this.offlineSince) { const offlineDuration = Math.round((Date.now() - this.offlineSince.getTime()) / 1000); console.log(`[DatabaseManager] 🔄 System back online after ${offlineDuration} seconds offline`); this.offlineSince = null; } console.log('[DatabaseManager] ✅ Successfully connected to database'); // Start health check this.startHealthCheck(); // Trigger callback if exists if (this.onConnectedCallback) { try { yield this.onConnectedCallback(); } catch (err) { console.error('[DatabaseManager] Error in connection callback:', err); } } return this.pool; } catch (err) { console.error('[DatabaseManager] Connection error:', err); this.isConnected = false; if (!this.offlineSince) { this.offlineSince = new Date(); console.log(`[DatabaseManager] ⚠️ System went offline at ${this.offlineSince.toLocaleString()}`); } return this.handleConnectionError(); } finally { this.isConnecting = false; } }); } handleConnectionError() { return __awaiter(this, void 0, void 0, function* () { this.isConnected = false; if (this.retryCount < this.maxRetries) { this.retryCount++; const delay = this.retryInterval * Math.pow(2, this.retryCount - 1); console.log(`[DatabaseManager] 🔄 Will retry connection in ${delay / 1000} seconds (attempt ${this.retryCount}/${this.maxRetries})`); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } return new Promise((resolve) => { this.reconnectTimer = setTimeout(() => __awaiter(this, void 0, void 0, function* () { resolve(yield this.connect()); }), delay); }); } else { console.log(`[DatabaseManager] ⚠️ Max retries (${this.maxRetries}) reached, will try again in background`); this.startHealthCheck(); return null; } }); } startHealthCheck() { if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); } this.healthCheckInterval = setInterval(() => __awaiter(this, void 0, void 0, function* () { if (!this.isConnected && !this.isConnecting) { const isConnected = yield this.testConnection(); if (!isConnected) { console.log('[DatabaseManager] 🔍 Health check: Database still offline'); this.retryCount = 0; yield this.connect(); } else { console.log('[DatabaseManager] ✅ Health check: Database connection restored'); this.isConnected = true; } } }), 5000); // Check every 5 seconds } getPool() { return this.pool; } isPoolConnected() { return this.isConnected && this.pool !== null; } updateQueuedChangesCount(count) { this.queuedChangesCount = count; if (count > 0) { console.log(`[DatabaseManager] 📦 ${count} changes queued${this.isConnected ? ' (attempting to process)' : ' (waiting for connection)'}`); } } onConnected(callback) { this.onConnectedCallback = callback; if (this.isConnected && this.pool) { callback(); } } shutdown() { return __awaiter(this, void 0, void 0, function* () { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); } if (this.pool) { yield this.pool.end(); } }); } } function startSyncServer(config) { const wss = new ws_1.WebSocketServer({ port: config.port }); const clients = new Set(); const dbManager = new DatabaseConnectionManager(config); const pendingChanges = []; function processChange(change_1) { return __awaiter(this, arguments, void 0, function* (change, isRetry = false) { // Skip if we've already processed this change if (change.processed) { console.log(`[ZenoSyncServer] ⏭️ Skipping already processed change:`, change.key); return true; } console.log(`[ZenoSyncServer] 🔄 Processing change:`, change); // Check database connection if (!dbManager.isPoolConnected()) { console.log('[ZenoSyncServer] 📥 Database offline, queueing change'); if (!isRetry && !pendingChanges.some(pc => pc.key === change.key && pc.timestamp === change.timestamp)) { pendingChanges.push(change); dbManager.updateQueuedChangesCount(pendingChanges.length); } return false; } const pool = dbManager.getPool(); if (!pool) { console.error('[ZenoSyncServer] Pool is null despite connection check'); return false; } let client; try { client = yield pool.connect(); console.log('[ZenoSyncServer] 🔌 Connected to PostgreSQL'); yield client.query('BEGIN'); console.log('[ZenoSyncServer] 📝 Started transaction'); if (change.operation === 'set') { const query = ` INSERT INTO ${config.pg.table} (id, data, updated_at) VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP RETURNING id, data`; const result = yield client.query(query, [change.key, change.value]); console.log(`[ZenoSyncServer] ✅ Saved to PostgreSQL:`, result.rows[0]); } else if (change.operation === 'delete') { const result = yield client.query(`DELETE FROM ${config.pg.table} WHERE id = $1 RETURNING id`, [change.key]); console.log(`[ZenoSyncServer] ✅ Deleted from PostgreSQL:`, result.rows[0]); } yield client.query('COMMIT'); console.log('[ZenoSyncServer] ✅ Transaction committed'); // Mark the change as processed change.processed = true; // Broadcast the change broadcastChange(change); return true; } catch (err) { console.error('[ZenoSyncServer] ❌ Error processing change:', err); if (client) { try { yield client.query('ROLLBACK'); console.log('[ZenoSyncServer] 🔄 Transaction rolled back'); } catch (rollbackErr) { console.error('[ZenoSyncServer] ❌ Error during rollback:', rollbackErr); } } // Queue the change if it wasn't already queued if (!isRetry && !pendingChanges.some(pc => pc.key === change.key && pc.timestamp === change.timestamp)) { console.log(`[ZenoSyncServer] ⚠️ Failed to process change, queueing for retry`); pendingChanges.push(change); dbManager.updateQueuedChangesCount(pendingChanges.length); } // Check if it's a connection error if (err && typeof err === 'object' && 'code' in err && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'EADDRNOTAVAIL')) { console.log('[ZenoSyncServer] 🔄 Database connection lost, attempting to reconnect...'); yield dbManager.connect(); } return false; } finally { if (client) { client.release(true); console.log('[ZenoSyncServer] 🔌 Released PostgreSQL connection'); } } }); } function broadcastChange(change) { const message = JSON.stringify({ operation: change.operation, key: change.key, value: change.value, timestamp: Date.now() }); let broadcastCount = 0; for (const client of clients) { if (client.readyState === ws_1.WebSocket.OPEN) { client.send(message); broadcastCount++; } } console.log(`[ZenoSyncServer] 📢 Change broadcasted to ${broadcastCount} clients`); } function processPendingChanges() { return __awaiter(this, void 0, void 0, function* () { const count = pendingChanges.length; if (count === 0) return; console.log(`[ZenoSyncServer] 🔄 Processing ${count} pending changes...`); const failedChanges = []; let processedCount = 0; const maxRetries = 3; // Maximum number of retries per change // Process changes in batches to prevent overwhelming the database const batchSize = 10; const batches = Math.ceil(pendingChanges.length / batchSize); for (let i = 0; i < batches; i++) { const batch = pendingChanges.splice(0, batchSize); console.log(`[ZenoSyncServer] 📦 Processing batch ${i + 1}/${batches} (${batch.length} changes)`); for (const change of batch) { if (!dbManager.isPoolConnected()) { console.log('[ZenoSyncServer] 📥 Database offline, stopping batch processing'); failedChanges.push(change); continue; } try { const success = yield processChange(change, true); if (success) { processedCount++; console.log(`[ZenoSyncServer] ✅ Successfully processed change ${processedCount}/${count}`); } else { // Only requeue if we haven't exceeded max retries if (change.retryCount === undefined) { change.retryCount = 1; } else { change.retryCount++; } if (change.retryCount <= maxRetries) { failedChanges.push(change); console.log(`[ZenoSyncServer] ⚠️ Failed to process change (attempt ${change.retryCount}/${maxRetries}), will retry later`); } else { console.log(`[ZenoSyncServer] ❌ Change failed after ${maxRetries} attempts, giving up`); } } } catch (err) { console.error(`[ZenoSyncServer] ❌ Error processing change:`, err); if (change.retryCount === undefined) { change.retryCount = 1; } else { change.retryCount++; } if (change.retryCount <= maxRetries) { failedChanges.push(change); } } } // Add a small delay between batches if (i < batches - 1) { yield new Promise(resolve => setTimeout(resolve, 1000)); } } // Re-queue failed changes with a delay if (failedChanges.length > 0) { console.log(`[ZenoSyncServer] ⚠️ Re-queueing ${failedChanges.length} failed changes with delay`); // Add a delay before retrying failed changes setTimeout(() => { pendingChanges.push(...failedChanges); dbManager.updateQueuedChangesCount(pendingChanges.length); }, 5000); // 5 second delay between retries } console.log(`[ZenoSyncServer] 📊 Sync summary: ${processedCount} processed, ${failedChanges.length} failed`); }); } function getInitialState() { return __awaiter(this, void 0, void 0, function* () { const pool = dbManager.getPool(); if (!pool || !dbManager.isPoolConnected()) { return {}; } try { const res = yield pool.query(`SELECT id, data FROM ${config.pg.table}`); const state = {}; for (const row of res.rows) { state[row.id] = row.data; } return state; } catch (err) { console.error('[ZenoSyncServer] Error getting initial state:', err); return {}; } }); } // Handle WebSocket messages wss.on('connection', (ws) => __awaiter(this, void 0, void 0, function* () { console.log('[ZenoSyncServer] 🔌 New client connected'); const extWs = ws; clients.add(extWs); extWs.isAlive = true; extWs.on('pong', () => { extWs.isAlive = true; }); try { // Send initial state const state = yield getInitialState(); if (extWs.readyState === ws_1.WebSocket.OPEN) { extWs.send(JSON.stringify({ operation: 'set', key: 'tasks', value: state })); console.log('[ZenoSyncServer] ✅ Sent initial state to client'); } // Process any pending changes if (pendingChanges.length > 0) { console.log(`[ZenoSyncServer] 🔄 Processing ${pendingChanges.length} pending changes on connection`); yield processPendingChanges(); } } catch (err) { console.error('[ZenoSyncServer] ❌ Error during client initialization:', err); } extWs.on('message', (msg) => __awaiter(this, void 0, void 0, function* () { try { const change = JSON.parse(msg.toString()); console.log('[ZenoSyncServer] 📥 Received change:', change); yield processChange(change); } catch (err) { console.error('[ZenoSyncServer] ❌ Error handling message:', err); } })); extWs.on('close', () => { console.log('[ZenoSyncServer] 🔌 Client disconnected'); clients.delete(extWs); }); extWs.on('error', (err) => { console.error('[ZenoSyncServer] ❌ WebSocket error:', err); clients.delete(extWs); }); })); // Register connection callback dbManager.onConnected(() => __awaiter(this, void 0, void 0, function* () { console.log('[ZenoSyncServer] 🔌 Database connection restored'); if (pendingChanges.length > 0) { console.log(`[ZenoSyncServer] 🔄 Processing ${pendingChanges.length} pending changes after reconnection`); yield processPendingChanges(); } })); // Initial database connection dbManager.connect().then(() => { if (dbManager.isPoolConnected()) { console.log('[ZenoSyncServer] 🚀 Server initialized and ready'); } else { console.log('[ZenoSyncServer] ⚠️ Server started but database connection failed'); } }); console.log(`[ZenoSyncServer] 🎯 Server listening on ws://localhost:${config.port}`); }