TypeScript/JavaScript sync client for bidirectional database synchronization over Phoenix channels.
Connects a local SQLite database (via synclib-js) to a synclib server, keeping data in sync using seqnum-based incremental pull, batched push, and Merkle tree integrity verification.
Local SQLite (WASM) synclib server (Postgres)
┌───────────┐ WebSocket / Phoenix ┌──────────────────┐
│ your app │◄═══════════════════════►│ sync channel │
│ │ │ │
│ push ─────►│── pending changes ────► │ apply + broadcast │
│ │ │ │
│ ◄── pull ──│◄── newer rows ──────── │ seqnum triggers │
│ │ │ │
│ verify ───►│◄── merkle compare ───► │ hash triggers │
└───────────┘ └──────────────────┘
- Client sends a
sync_requestwith its per-table seqnums and any pending local changes - Server applies changes, streams back newer rows, and returns a
sync_completewith stats - Optionally, periodic Merkle verification detects and repairs any drift
import { SyncClient, ChannelRole } from 'synclib-sync';
import { SynclibDatabase } from 'synclib';
// 1. Open the local database
const db = await SynclibDatabase.open('local.db');
// 2. Create the sync client
const client = new SyncClient({
db,
serverUrl: 'wss://your-server.com/socket',
clientId: 'device-abc',
channels: [
{
topic: 'sync:user:user-123',
role: ChannelRole.PUSH,
tables: [
{ name: 'journal_entries' },
{ name: 'measurements' },
],
},
{
topic: 'sync:tribe:tribe-456',
role: ChannelRole.PULL,
tables: [
{ name: 'exercises' },
{ name: 'workouts' },
],
},
],
periodicSyncInterval: 30_000, // background sync every 30s
merkleVerifyInterval: 3_600_000, // integrity check every hour
});
// 3. Initialize and connect
await client.initialize();
await client.connect(jwtToken);
// 4. Sync is automatic via periodic timer — or call manually:
await client.sync();Every sync relationship is modeled as a channel with a role:
| Role | Direction | Use case |
|---|---|---|
ChannelRole.PUSH |
Client → Server | User-owned data (journal entries, settings) |
ChannelRole.PULL |
Server → Client | Shared/read-only data (exercises, content) |
ChannelRole.BIDIRECTIONAL |
Both ways | Collaborative data (chat, shared docs) |
The role determines the default repair direction when Merkle verification finds mismatches:
PUSH→ client is authoritative, sends its rows to serverPULL→ server is authoritative, client overwrites local dataBIDIRECTIONAL→ last-write-wins (LWW) usinglast_modified_ms
Individual tables can override the channel default:
import { ChannelRole, RepairDirection } from 'synclib-sync';
{
topic: 'sync:user:user-123',
role: ChannelRole.PUSH,
tables: [
{ name: 'journal_entries' }, // inherits PUSH
{ name: 'notifications', direction: RepairDirection.PULL }, // override: server-authoritative
{ name: 'shared_prefs', direction: RepairDirection.LWW }, // override: last-write-wins
],
}Call notifyLocalChange() after database writes. The client debounces rapid calls (default 100ms) and batches them into a single sync:
await db.write({ tableName: 'todos', rowId: '123', operation: 'INSERT', sql: '...' });
client.notifyLocalChange();Set periodicSyncInterval to run syncUnified() on a timer:
new SyncClient({
// ...
periodicSyncInterval: 30_000, // every 30 seconds
});Call sync() or syncUnified() directly:
await client.sync();
// Force refresh specific tables (ignores seqnums, pulls all rows)
await client.syncUnified({ forceRefresh: ['exercises'] });The client exposes callback-based listeners. Each returns an unsubscribe function:
// Connection state (DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING, FAILED, AUTH_FAILED)
const unsub = client.onStateChange((state) => {
console.log('Connection:', state);
});
// Sync state (DISCONNECTED, CONNECTING, SYNCING, READY, ERROR)
client.onSyncStateChange((state) => {
console.log('Sync:', state);
});
// Sync progress (phase: 'pushing' | 'pulling' | 'migrating' | 'complete')
client.onSyncProgress((progress) => {
console.log(`${progress.phase}: ${progress.table}`);
});
// Sync complete with stats
client.onSyncComplete((event) => {
console.log(`Pulled ${event.totalRowsPulled} rows`);
console.log(`Pushed ${event.totalChangesPushed} changes`);
if (event.schemaUpgraded) {
console.log(`Schema upgraded to v${event.schemaVersion}`);
}
});
// Remote changes as they arrive
client.onRemoteChange((change) => {
console.log(`${change.operation} on ${change.table}: ${change.row_id}`);
});
// Merkle verification results
client.onMerkleVerification((event) => {
if (event.hadMismatches) {
console.log('Repaired:', event.repairedTables);
// Invalidate caches, refresh UI
}
});
// Custom/unrecognized message types (extensibility)
client.onCustomMessage((message) => {
console.log(`Custom: ${message.type}`, message.payload);
});
// Unsubscribe when done
unsub();// Check if client is fully ready (connected + all channels synced)
if (client.isReady) {
// Safe to read from local database
}
// Current state getters
client.connectionState; // ConnectionState
client.currentSyncState; // SyncState
client.currentReadyState; // SyncReadyStateJoin or leave channels at runtime:
// Join a new channel after initial connect
await client.joinChannel({
topic: 'sync:tribe:new-tribe',
role: ChannelRole.PULL,
tables: [{ name: 'tribe_data' }],
});
// Leave a channel
await client.leaveChannelById('sync:tribe:new-tribe');
// Check which channels are joined
const joined = client.getJoinedChannels(); // string[]The server pushes SQLite DDL migrations when the client's schema version is behind. The client applies them automatically during syncUnified() and reports the result in SyncCompleteEvent.schemaUpgraded.
No client-side migration code is needed — the server is the single source of truth for schema.
When merkleVerifyInterval is set, the client periodically builds SHA256-based Merkle trees from stored row_hash values and compares them with the server. If blocks differ, only the mismatched rows are fetched and repaired according to each table's repair direction.
The server is the single source of truth for row_hash — computed by a Postgres trigger at write time. Clients receive and store these values during sync, snapshots, ACK responses, and merkle repair. No local hash computation is needed for sync to work.
For optional client-side data integrity (e.g., detecting local corruption), the synclib_hash library is available via WASM but is not required.
new SyncClient({
// ...
merkleVerifyInterval: 3_600_000, // every hour
channels: [
{
topic: 'sync:tribe:tribe-456',
role: ChannelRole.PULL,
tables: [
{ name: 'exercises', hashColumns: ['last_modified_ms'] },
],
},
],
});hashColumns lets you hash only specific columns (e.g., a timestamp updated on every write) instead of the full row, which is faster for tables with large document blobs.
Manual verification is also available:
const repairedTables = await client.verifyIntegrity({
tables: ['exercises', 'workouts'],
channelTopic: 'sync:tribe:tribe-456',
});Send arbitrary messages through the Phoenix channel:
// Send a custom event and wait for reply
const response = await client.sendMessage('fetch_row', {
table: 'users',
row_id: 'user-123',
});
// Convenience method for single-row fetch
const row = await client.fetchRow('users', 'user-123');| Method | Description |
|---|---|
initialize() |
Set up WebSocket listeners (idempotent) |
connect(token) |
Connect to server with JWT auth and join channels |
disconnect() |
Close the WebSocket connection |
sync() |
Alias for syncUnified() |
syncUnified(options?) |
Run a full push + pull sync cycle |
notifyLocalChange() |
Trigger debounced sync after a local write |
streamSnapshot(tables, options?) |
Stream table data from server |
fetchRow(table, rowId) |
Fetch a single row from server |
sendMessage(event, payload) |
Send a custom message to server |
joinChannel(channel) |
Join an additional channel at runtime |
leaveChannel(channel) |
Leave a channel by config |
leaveChannelById(topic) |
Leave a channel by topic string |
getJoinedChannels() |
List joined channel topics |
isChannelJoined(channel) |
Check if a channel is joined |
verifyIntegrity(options?) |
Run Merkle verification manually |
setConflictResolver(fn) |
Set custom conflict resolution callback |
dispose() |
Clean up all resources |
| Parameter | Type | Default | Description |
|---|---|---|---|
db |
SynclibDatabase |
required | Local SQLite database instance |
serverUrl |
string |
required | WebSocket server URL |
clientId |
string |
required | Unique client identifier |
channels |
SyncChannel[] |
required | Channels to sync on |
pushBatchSize |
number |
100 |
Max changes per push batch |
metadata |
Record<string, any> |
— | Extra data sent in hello |
syncOnWriteDebounce |
number |
100 |
Debounce ms for notifyLocalChange() |
connectionTimeout |
number |
30000 |
Timeout ms for connection wait |
syncTimeout |
number |
300000 |
Timeout ms for sync operations |
periodicSyncInterval |
number |
— | Background sync interval ms |
merkleVerifyInterval |
number |
— | Merkle verification interval ms |
additionalEventTypes |
string[] |
— | Extra Phoenix event types to listen for |
| Method | Event type |
|---|---|
onStateChange(fn) |
ConnectionState |
onSyncStateChange(fn) |
SyncState |
onSyncReadyStateChange(fn) |
SyncReadyState |
onSyncProgress(fn) |
SyncProgress |
onSyncComplete(fn) |
SyncCompleteEvent |
onRemoteChange(fn) |
ChangeMessage |
onSnapshotComplete(fn) |
(streamId, channelId) |
onMerkleVerification(fn) |
MerkleVerificationEvent |
onCustomMessage(fn) |
UnknownMessage |
All listeners return an unsubscribe function: const unsub = client.onX(fn); unsub();
npm install synclib-sync synclib phoenixOr add to package.json:
{
"dependencies": {
"synclib-sync": "github:synclib-io/synclib-client-js",
"synclib": "github:synclib-io/synclib-js",
"phoenix": "^1.7.0"
}
}MIT