Skip to content

synclib-io/synclib-client-js

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

synclib-client-js

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.

How it works

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     │
└───────────┘                          └──────────────────┘
  1. Client sends a sync_request with its per-table seqnums and any pending local changes
  2. Server applies changes, streams back newer rows, and returns a sync_complete with stats
  3. Optionally, periodic Merkle verification detects and repairs any drift

Quick start

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();

Channels and roles

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 server
  • PULL → server is authoritative, client overwrites local data
  • BIDIRECTIONAL → last-write-wins (LWW) using last_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
  ],
}

Sync modes

Sync on write

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();

Periodic sync

Set periodicSyncInterval to run syncUnified() on a timer:

new SyncClient({
  // ...
  periodicSyncInterval: 30_000,  // every 30 seconds
});

Manual sync

Call sync() or syncUnified() directly:

await client.sync();

// Force refresh specific tables (ignores seqnums, pulls all rows)
await client.syncUnified({ forceRefresh: ['exercises'] });

Observing state

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();

Readiness

// 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; // SyncReadyState

Dynamic channel management

Join 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[]

Schema migrations

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.

Merkle tree verification

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',
});

Custom messaging

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');

API reference

SyncClient

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

SyncClientConfig

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

Listeners

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();

Installation

npm install synclib-sync synclib phoenix

Or add to package.json:

{
  "dependencies": {
    "synclib-sync": "github:synclib-io/synclib-client-js",
    "synclib": "github:synclib-io/synclib-js",
    "phoenix": "^1.7.0"
  }
}

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors