A lightweight, offline-first synchronization system that enables seamless data synchronization between client-side IndexedDB and server-side PostgreSQL. Perfect for note-taking applications and other data-heavy web apps that need to work offline.
⚠️ BETA VERSION NOTICEThis project is currently in beta stage and under active development. While it's functional, you may encounter:
- Unexpected behaviors
- Breaking changes between versions
- Incomplete features
- Limited documentation
Use in production with caution. We recommend thorough testing in development/staging environments before deploying to production.
Please report any issues you encounter on our GitHub repository.
- Features
- Installation
- Quick Start
- How It Works
- Configuration
- Sync Status & Events
- Best Practices
- Schema Migrations & Conflict Resolution
- Examples
- Troubleshooting
- License
- TODO List & Roadmap
- 🔄 Real-time Sync: Instant synchronization between all connected clients
- 📱 Offline Support: Continue working without internet connection
- 🔌 Auto-Reconnect: Automatically reconnects when network is available
- 💾 Persistent Storage: IndexedDB (client) + PostgreSQL (server)
- ⚡ WebSocket Protocol: Fast, real-time updates across clients
- 🔒 Safe Transactions: Ensures data consistency
- 📊 Sync Status: Real-time sync status indicators and offline mode support
- 🗑️ Soft Delete: Recoverable deletions with automatic cleanup
# Install the package
npm install zeno-db
# Or with yarn
yarn add zeno-db
- Install dependencies:
npm install zeno-db
- Create a server.js file:
import { startSyncServer } from 'zeno-db/server';
startSyncServer({
port: 3000,
pg: {
connectionString: 'your-postgresql-connection-string',
table: 'notes' // Your table name
}
});
- Initialize the database in your app:
import { ZenoDB } from 'zeno-db';
const db = new ZenoDB({
storage: {
type: 'indexeddb',
name: 'my-app-db'
},
sync: {
type: 'websocket',
url: 'ws://localhost:3000'
},
clientId: `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
softDelete: {
enabled: true,
fieldName: 'deletedAt',
permanentDeleteAfter: 30 // Delete after 30 days
}
});
await db.init();
// Create a new note
const noteId = `note-${Date.now()}`;
await db.set(noteId, {
id: noteId,
text: 'Your note text',
createdAt: new Date().toISOString()
});
// Read a note
const note = await db.get(noteId);
// Delete a note
await db.delete(noteId);
// Subscribe to changes
db.subscribe((key, value) => {
console.log('Note changed:', key, value);
});
You can efficiently get or set multiple records at once using batch operations:
// Batch get
const notes = await db.getMany(['note-1', 'note-2', 'note-3']);
// Batch set
await db.setMany([
{ id: 'note-4', text: 'Fourth note', createdAt: new Date().toISOString() },
{ id: 'note-5', text: 'Fifth note', createdAt: new Date().toISOString() }
]);
import { useState, useEffect } from 'react';
import { ZenoDB } from 'zeno-db';
function NotesApp() {
const [db, setDb] = useState(null);
const [syncStatus, setSyncStatus] = useState({
isOnline: false,
isSyncing: false,
syncProgress: 0,
pendingChangesCount: 0
});
useEffect(() => {
const initDB = async () => {
const zenoDB = new ZenoDB({
storage: { type: 'indexeddb', name: 'notes-db' },
sync: { type: 'websocket', url: 'ws://localhost:3000' },
softDelete: {
enabled: true,
fieldName: 'deletedAt',
permanentDeleteAfter: 30 // Delete after 30 days
}
});
await zenoDB.init();
setDb(zenoDB);
// Subscribe to sync events
zenoDB.subscribe((event) => {
setSyncStatus(event.status);
});
};
initDB();
}, []);
return (
<div>
<div className={`status ${syncStatus.isOnline ? 'online' : 'offline'}`}>
{syncStatus.isOnline ? 'Online' : 'Offline'}
</div>
{syncStatus.isSyncing && (
<div className="progress">
Syncing... {syncStatus.syncProgress}%
</div>
)}
</div>
);
}
<template>
<div>
<div :class="['status', { online: syncStatus.isOnline }]">
{{ syncStatus.isOnline ? 'Online' : 'Offline' }}
</div>
<div v-if="syncStatus.isSyncing" class="progress">
Syncing... {{ syncStatus.syncProgress }}%
</div>
</div>
</template>
<script>
import { ZenoDB } from 'zeno-db';
export default {
data() {
return {
db: null,
syncStatus: {
isOnline: false,
isSyncing: false,
syncProgress: 0,
pendingChangesCount: 0
}
};
},
async mounted() {
this.db = new ZenoDB({
storage: { type: 'indexeddb', name: 'notes-db' },
sync: { type: 'websocket', url: 'ws://localhost:3000' },
softDelete: {
enabled: true,
fieldName: 'deletedAt',
permanentDeleteAfter: 30 // Delete after 30 days
}
});
await this.db.init();
this.db.subscribe((event) => {
this.syncStatus = event.status;
});
}
};
</script>
- Uses IndexedDB for local storage
- Queues changes while offline
- Auto-syncs when connection restores
- Real-time updates via WebSocket
- Sync status indicators and event handling
- PostgreSQL database for persistent storage
- WebSocket server for real-time communication
- Handles concurrent updates
- Broadcasts changes to all connected clients
interface ServerConfig {
port: number;
pg: {
connectionString: string; // PostgreSQL connection string
table: string; // Table name for your data
};
softDelete?: {
enabled: boolean; // Enable soft delete functionality
fieldName?: string; // Field to store deletion timestamp (default: 'deletedAt')
permanentDeleteAfter?: number; // Days to keep soft-deleted items
};
}
interface ClientConfig {
storage: {
type: 'indexeddb';
name: string; // IndexedDB database name
};
sync: {
type: 'websocket';
url: string; // WebSocket server URL
};
clientId: string; // Unique client identifier
softDelete?: {
enabled: boolean; // Enable soft delete functionality
fieldName?: string; // Field to store deletion timestamp (default: 'deletedAt')
permanentDeleteAfter?: number; // Days to keep soft-deleted items
};
}
// Initialize with soft delete enabled
const db = new ZenoDB({
storage: {
type: 'indexeddb',
name: 'my-app-db'
},
sync: {
type: 'websocket',
url: 'ws://localhost:3000'
},
clientId: 'client-1',
softDelete: {
enabled: true,
fieldName: 'deletedAt',
permanentDeleteAfter: 30 // Delete after 30 days
}
});
// Soft delete an item
await db.delete('note-1');
// Restore a soft-deleted item
await db.restore('note-1');
// Get all items including soft-deleted ones
const allItems = await db.getAll(true);
// Permanently delete an item
await db.delete('note-1', true);
// Purge old soft-deleted items
await db.purgeDeleted();
ZenoDB provides real-time sync status updates and event handling to help you build responsive UIs that reflect the current sync state of your application.
import { ZenoDB, SyncEvent, SyncStatus } from 'zeno-db';
// Initialize your database
const db = new ZenoDB({
storage: {
type: 'indexeddb',
name: 'my-app-db'
},
sync: {
type: 'websocket',
url: 'ws://localhost:3000'
},
softDelete: {
enabled: true,
fieldName: 'deletedAt',
permanentDeleteAfter: 30 // Delete after 30 days
}
});
// Subscribe to sync events
db.subscribe((event: SyncEvent) => {
const { type, status } = event;
// status contains:
// - isOnline: boolean (connection status)
// - isSyncing: boolean (whether sync is in progress)
// - syncProgress: number (0-100 percentage)
// - pendingChangesCount: number (changes waiting to sync)
switch(type) {
case 'connection_change':
updateConnectionUI(status.isOnline);
break;
case 'sync_started':
showSyncStarted();
break;
case 'sync_progress':
updateProgressBar(status.syncProgress);
break;
case 'sync_completed':
showSyncComplete();
break;
}
});
// Get current status at any time
const currentStatus: SyncStatus = db.getSyncStatus();
-
connection_change
: Fired when connection status changes (online/offline) -
sync_started
: Fired when sync operation begins -
sync_progress
: Fired during sync with progress updates -
sync_completed
: Fired when sync operation completes
-
isOnline
: Current connection status -
isSyncing
: Whether a sync operation is in progress -
syncProgress
: Progress percentage (0-100) -
pendingChangesCount
: Number of changes waiting to sync
- Always initialize before use:
await db.init();
- Handle offline/online transitions gracefully:
window.addEventListener('online', () => {
console.log('Back online, syncing...');
});
- Use try-catch for error handling:
try {
await db.set('key', value);
} catch (error) {
console.error('Error saving data:', error);
}
- Monitor sync status:
db.subscribe((event) => {
const { type, status } = event;
// Update UI based on sync status.
});
- Use soft delete for important data:
// Instead of permanent deletion
await db.delete('important-data');
// Later, if needed
await db.restore('important-data');
// Clean up old deleted items periodically
await db.purgeDeleted();
When evolving your data schema, you may need to migrate existing records. For example, if you add a new field to your notes, you can write a migration function that updates all records after initializing the database:
// Example: Migrating notes to add a "tags" field if missing
async function migrateNotes(db) {
const allNotes = await db.getAll();
for (const note of allNotes) {
if (!note.tags) {
note.tags = [];
await db.set(note.id, note); // Save migrated note
}
}
}
// Run migration after db.init()
await db.init();
await migrateNotes(db);
Tip: Store a schema version in each record or in IndexedDB metadata, and only run migrations when needed.
For collaborative or complex data structures, you may need to merge changes from multiple sources. Here is a simple example for merging two versions of a collaborative list:
// Example: Merging two versions of a collaborative list
function mergeLists(localList, remoteList) {
const merged = [...localList];
for (const item of remoteList) {
if (!merged.find(i => i.id === item.id)) {
merged.push(item); // Add new items from remote
}
// Optionally, resolve conflicts for items with the same id
}
return merged;
}
// Usage in sync event handler
// (Assuming your sync system emits a 'sync_conflict' event)
db.subscribe((event) => {
if (event.type === 'sync_conflict') {
const merged = mergeLists(event.local, event.remote);
db.set(event.key, merged);
}
});
Tip: For more complex data, consider using libraries like Automerge or Yjs for CRDT-based merging.
Your PostgreSQL table should have this structure:
CREATE TABLE notes (
id TEXT PRIMARY KEY,
data JSONB NOT NULL,
deleted_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Index for soft delete queries
CREATE INDEX idx_notes_deleted_at ON notes(deleted_at);
MIT License - see LICENSE file for details.
- [ ] User authentication system integration
- [ ] JWT-based authentication
- [ ] Role-based access control (RBAC)
- [ ] API rate limiting
- [ ] Data encryption at rest
- [ ] Session management
- [ ] OAuth2 provider integration (Google, GitHub)
- [ ] Data versioning
- [ ] Soft delete functionality
- [ ] Data validation middleware
- [ ] Advanced conflict resolution strategies
- [ ] Bulk import/export functionality
- [ ] Data compression
- [ ] Caching layer
- [ ] Request batching
- [ ] Lazy loading support
- [ ] Connection pooling
- [ ] Query optimization
- [ ] CLI tool for database management
- [ ] Better error handling and logging
- [ ] Development environment tooling
- [ ] TypeScript type definitions
- [ ] API documentation with Swagger/OpenAPI
- [ ] Integration tests
- [ ] E2E testing suite
- [ ] Telemetry integration
- [ ] Performance metrics dashboard
- [ ] Debug logging
- [ ] Error tracking integration
- [ ] Health check endpoints
- [ ] Audit logging
- [ ] Docker containerization
- [ ] CI/CD pipeline setup
- [ ] Automated deployment scripts
- [ ] Database migration tools
- [ ] Backup and restore functionality
- [ ] High availability setup
-
Connection Issues
// Check if WebSocket server is running const ws = new WebSocket('ws://localhost:3000'); ws.onerror = (error) => { console.error('WebSocket connection error:', error); };
-
Database Initialization Failures
try { await db.init(); } catch (error) { console.error('Database initialization failed:', error); // Check if IndexedDB is supported if (!window.indexedDB) { console.error('IndexedDB is not supported in this browser'); } }
-
Sync Status Not Updating
- Ensure you're properly subscribing to sync events
- Check network connectivity
- Verify WebSocket connection is active
-
Enable verbose logging:
const db = new ZenoDB({ // ... config debug: true });
-
Monitor sync events:
db.subscribe((event) => { console.log('Sync event:', event); });