zeno-db
Version:
A lightweight, offline-first client-side database with automatic sync capabilities
460 lines (459 loc) • 20.7 kB
JavaScript
"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}`);
}