@synclib-io/sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,578 @@
1
+ # synclib-sync
2
+
3
+ TypeScript/JavaScript sync client for coordinating database changes with Elixir Phoenix server. Works in tandem with [synclib](../synclib_js) to provide real-time bidirectional sync between your local SQLite database and a Phoenix server.
4
+
5
+ ## Features
6
+
7
+ - 🔄 **Bidirectional Sync** - Push local changes and receive remote updates
8
+ - 🔌 **Phoenix Channels** - Built on Phoenix WebSocket channels for real-time communication
9
+ - 🔁 **Automatic Reconnection** - Handles disconnections and reconnects automatically
10
+ - 📦 **Batch Operations** - Efficient batching of changes for better performance
11
+ - 🔀 **Conflict Resolution** - Pluggable conflict resolution strategies
12
+ - 📊 **Schema Migrations** - Automatic schema version management and migrations
13
+ - 📸 **Snapshot Streaming** - Efficient initial data loading with incremental updates
14
+ - 💬 **Real-time Events** - Support for livestream, conversation, and job update events
15
+ - 🎯 **TypeScript** - Full type safety and IntelliSense support
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install synclib-sync synclib
21
+ # or
22
+ pnpm add synclib-sync synclib
23
+ # or
24
+ yarn add synclib-sync synclib
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```typescript
30
+ import { SynclibDatabase } from 'synclib';
31
+ import { SyncClient } from 'synclib-sync';
32
+
33
+ // 1. Open your local database
34
+ const db = await SynclibDatabase.open({
35
+ databaseName: 'myapp.db'
36
+ });
37
+
38
+ // 2. Create tables
39
+ db.exec(`
40
+ CREATE TABLE IF NOT EXISTS users (
41
+ id TEXT PRIMARY KEY,
42
+ name TEXT NOT NULL,
43
+ email TEXT NOT NULL
44
+ )
45
+ `);
46
+
47
+ // 3. Create sync client
48
+ const syncClient = new SyncClient({
49
+ db,
50
+ serverUrl: 'wss://api.example.com/socket',
51
+ clientId: 'user-123',
52
+ initialChannels: [
53
+ {
54
+ channelName: 'user',
55
+ channelId: 'user-123',
56
+ },
57
+ ],
58
+ pushInterval: 5000, // Push changes every 5 seconds
59
+ });
60
+
61
+ // 4. Initialize and connect
62
+ await syncClient.initialize();
63
+ await syncClient.connect('your-auth-token');
64
+
65
+ // 5. Stream initial data
66
+ await syncClient.streamSnapshot(['users']);
67
+
68
+ // 6. Listen for remote changes
69
+ syncClient.onRemoteChange((change) => {
70
+ console.log('Remote change applied:', change);
71
+ // Refresh your UI
72
+ });
73
+
74
+ // 7. Make local changes (they'll be synced automatically)
75
+ await db.write({
76
+ tableName: 'users',
77
+ rowId: '456',
78
+ operation: SynclibOperation.INSERT,
79
+ sql: `INSERT INTO users (id, name, email) VALUES (?, ?, ?)`,
80
+ params: ['456', 'Jane Doe', 'jane@example.com'],
81
+ data: JSON.stringify({ id: '456', name: 'Jane Doe', email: 'jane@example.com' })
82
+ });
83
+ ```
84
+
85
+ ## Usage with Svelte
86
+
87
+ Here's a complete example of integrating synclib-sync in a Svelte application:
88
+
89
+ ```typescript
90
+ // src/lib/stores/sync.ts
91
+ import { writable } from 'svelte/store';
92
+ import { SynclibDatabase } from 'synclib';
93
+ import { SyncClient, type SyncClientConfig } from 'synclib-sync';
94
+
95
+ export const db = writable<SynclibDatabase | null>(null);
96
+ export const syncClient = writable<SyncClient | null>(null);
97
+ export const isConnected = writable(false);
98
+ export const isSyncing = writable(false);
99
+
100
+ export async function initializeSync(userId: string, token: string) {
101
+ // Open database
102
+ const database = await SynclibDatabase.open({
103
+ databaseName: 'myapp.db'
104
+ });
105
+
106
+ // Run migrations
107
+ const currentVersion = database.getSchemaVersion();
108
+ if (currentVersion < 1) {
109
+ database.exec(`
110
+ CREATE TABLE IF NOT EXISTS todos (
111
+ id TEXT PRIMARY KEY,
112
+ title TEXT NOT NULL,
113
+ completed INTEGER DEFAULT 0
114
+ )
115
+ `);
116
+ await database.setSchemaVersion(1);
117
+ }
118
+
119
+ // Create sync client
120
+ const client = new SyncClient({
121
+ db: database,
122
+ serverUrl: 'wss://api.example.com/socket',
123
+ clientId: userId,
124
+ initialChannels: [
125
+ {
126
+ channelName: 'user',
127
+ channelId: userId,
128
+ },
129
+ ],
130
+ pushInterval: 5000,
131
+ });
132
+
133
+ // Listen for connection state changes
134
+ client.onStateChange((state) => {
135
+ isConnected.set(state === 'connected');
136
+ });
137
+
138
+ // Listen for remote changes
139
+ client.onRemoteChange((change) => {
140
+ console.log('Remote change:', change);
141
+ // Trigger UI refresh
142
+ });
143
+
144
+ // Initialize and connect
145
+ await client.initialize();
146
+ await client.connect(token);
147
+
148
+ // Stream initial data
149
+ isSyncing.set(true);
150
+ await client.streamSnapshot(['todos']);
151
+
152
+ // Wait for snapshot to complete
153
+ await new Promise<void>((resolve) => {
154
+ const unsubscribe = client.onSnapshotComplete(() => {
155
+ unsubscribe();
156
+ resolve();
157
+ });
158
+ });
159
+ isSyncing.set(false);
160
+
161
+ db.set(database);
162
+ syncClient.set(client);
163
+
164
+ return { database, client };
165
+ }
166
+ ```
167
+
168
+ ```svelte
169
+ <!-- src/routes/+layout.svelte -->
170
+ <script lang="ts">
171
+ import { onMount } from 'svelte';
172
+ import { initializeSync, isConnected, isSyncing } from '$lib/stores/sync';
173
+
174
+ let userId = 'user-123';
175
+ let token = 'your-auth-token';
176
+
177
+ onMount(async () => {
178
+ await initializeSync(userId, token);
179
+ });
180
+ </script>
181
+
182
+ {#if $isSyncing}
183
+ <div class="loading">Syncing data...</div>
184
+ {/if}
185
+
186
+ {#if !$isConnected}
187
+ <div class="offline">Offline - changes will sync when reconnected</div>
188
+ {/if}
189
+
190
+ <slot />
191
+ ```
192
+
193
+ ```svelte
194
+ <!-- src/routes/+page.svelte -->
195
+ <script lang="ts">
196
+ import { db } from '$lib/stores/sync';
197
+ import { SynclibOperation } from 'synclib';
198
+
199
+ let todos: Array<{ id: string; title: string; completed: number }> = [];
200
+ let newTodoTitle = '';
201
+
202
+ $: if ($db) {
203
+ loadTodos();
204
+ }
205
+
206
+ function loadTodos() {
207
+ if (!$db) return;
208
+ todos = $db.read('SELECT * FROM todos ORDER BY id');
209
+ }
210
+
211
+ async function addTodo() {
212
+ if (!$db || !newTodoTitle.trim()) return;
213
+
214
+ const id = crypto.randomUUID();
215
+
216
+ await $db.write({
217
+ tableName: 'todos',
218
+ rowId: id,
219
+ operation: SynclibOperation.INSERT,
220
+ sql: `INSERT INTO todos (id, title, completed) VALUES (?, ?, ?)`,
221
+ params: [id, newTodoTitle, 0],
222
+ data: JSON.stringify({ id, title: newTodoTitle, completed: 0 })
223
+ });
224
+
225
+ newTodoTitle = '';
226
+ loadTodos();
227
+ }
228
+ </script>
229
+
230
+ <div>
231
+ <h1>Todos</h1>
232
+
233
+ <input
234
+ bind:value={newTodoTitle}
235
+ on:keypress={(e) => e.key === 'Enter' && addTodo()}
236
+ placeholder="Add a todo"
237
+ />
238
+ <button on:click={addTodo}>Add</button>
239
+
240
+ {#each todos as todo}
241
+ <div>
242
+ <input
243
+ type="checkbox"
244
+ checked={todo.completed === 1}
245
+ on:change={() => toggleTodo(todo.id, todo.completed)}
246
+ />
247
+ <span>{todo.title}</span>
248
+ </div>
249
+ {/each}
250
+ </div>
251
+ ```
252
+
253
+ ## API Reference
254
+
255
+ ### SyncClient
256
+
257
+ Main class for managing sync with the server.
258
+
259
+ #### Constructor
260
+
261
+ ```typescript
262
+ new SyncClient(config: SyncClientConfig)
263
+ ```
264
+
265
+ **Config Options:**
266
+
267
+ ```typescript
268
+ interface SyncClientConfig {
269
+ // Required
270
+ db: SynclibDatabase; // Synclib database instance
271
+ serverUrl: string; // WebSocket server URL
272
+ clientId: string; // Unique client identifier
273
+ initialChannels: SyncClientChannel[]; // Channels to join on connect
274
+
275
+ // Optional
276
+ pushInterval?: number; // How often to push changes (ms), null = manual
277
+ pushBatchSize?: number; // Max changes per push (default: 100)
278
+ pullInterval?: number | null; // How often to pull changes (ms), null = reactive
279
+ metadata?: Record<string, any>; // Custom metadata for hello message
280
+ }
281
+
282
+ interface SyncClientChannel {
283
+ channelName: string; // Channel type (e.g., 'user', 'tribe', 'guild')
284
+ channelId: string; // Specific channel ID
285
+ params?: Record<string, string>; // Additional channel params
286
+ }
287
+ ```
288
+
289
+ #### Methods
290
+
291
+ **`async initialize(): Promise<void>`**
292
+
293
+ Initialize the sync client. Must be called before connect().
294
+
295
+ **`async connect(token: string): Promise<void>`**
296
+
297
+ Connect to the sync server with authentication token.
298
+
299
+ **`async disconnect(): Promise<void>`**
300
+
301
+ Disconnect from the sync server.
302
+
303
+ **`async sync(): Promise<void>`**
304
+
305
+ Manually trigger a sync cycle (push local changes, pull remote changes).
306
+
307
+ **`async joinChannel(channel: SyncClientChannel): Promise<void>`**
308
+
309
+ Join an additional channel after initial connection.
310
+
311
+ **`async leaveChannel(channel: SyncClientChannel): Promise<void>`**
312
+
313
+ Leave a channel.
314
+
315
+ **`isChannelJoined(channel: SyncClientChannel): boolean`**
316
+
317
+ Check if a channel is currently joined.
318
+
319
+ **`getJoinedChannels(): string[]`**
320
+
321
+ Get all currently joined channel topics.
322
+
323
+ **`async streamSnapshot(tables: string[], options?: { incremental?: boolean; channelTopic?: string }): Promise<void>`**
324
+
325
+ Stream a snapshot of tables from the server.
326
+
327
+ - `tables`: Array of table names to stream
328
+ - `options.incremental`: If true, only stream changes since last sync
329
+ - `options.channelTopic`: Specific channel to use for the request
330
+
331
+ **`async fetchRow(table: string, rowId: string): Promise<Record<string, any>>`**
332
+
333
+ Fetch a single row from the server (including JSONB fields).
334
+
335
+ **`async sendMessage(event: string, payload: Record<string, any>, channelTopic?: string): Promise<Record<string, any>>`**
336
+
337
+ Send a custom message to the server and wait for reply.
338
+
339
+ **`async sendConversationPresence(options): Promise<void>`**
340
+
341
+ Send a conversation presence event (user_joined or user_left).
342
+
343
+ ```typescript
344
+ await syncClient.sendConversationPresence({
345
+ conversationId: 'tribe_123',
346
+ userId: 'user_456',
347
+ event: 'conversation:user_joined',
348
+ });
349
+ ```
350
+
351
+ **`setConflictResolver(resolver: ConflictResolver): void`**
352
+
353
+ Set a custom conflict resolution function.
354
+
355
+ ```typescript
356
+ syncClient.setConflictResolver(async (local, remote) => {
357
+ // Return the change to apply, or null to skip
358
+ // Example: last-write-wins
359
+ return remote.timestamp! > local.timestamp! ? remote : local;
360
+ });
361
+ ```
362
+
363
+ #### Event Listeners
364
+
365
+ All event listeners return an unsubscribe function.
366
+
367
+ **`onRemoteChange(listener: (change: ChangeMessage) => void): () => void`**
368
+
369
+ Called when a remote change is applied to the local database.
370
+
371
+ **`onSnapshotComplete(listener: (streamId: string) => void): () => void`**
372
+
373
+ Called when a snapshot stream completes.
374
+
375
+ **`onJobUpdate(listener: (update: JobUpdateMessage) => void): () => void`**
376
+
377
+ Called when a job update is received (e.g., from ECS tasks).
378
+
379
+ **`onLivestream(listener: (message: LivestreamMessage) => void): () => void`**
380
+
381
+ Called when a livestream event is received (started/stopped).
382
+
383
+ **`onConversation(listener: (message: ConversationMessage) => void): () => void`**
384
+
385
+ Called when a conversation event is received (user presence, messages).
386
+
387
+ **`onSyncReadyStateChange(listener: (state: SyncReadyState) => void): () => void`**
388
+
389
+ Called when the sync ready state changes.
390
+
391
+ States:
392
+ - `WAITING_FOR_HELLO`: Waiting for initial hello reply
393
+ - `APPLYING_MIGRATIONS`: Applying schema migrations
394
+ - `READY`: Ready to stream snapshots and sync data
395
+
396
+ **`onStateChange(listener: (state: ConnectionState) => void): () => void`**
397
+
398
+ Called when the WebSocket connection state changes.
399
+
400
+ States:
401
+ - `DISCONNECTED`: Not connected
402
+ - `CONNECTING`: Connecting to server
403
+ - `CONNECTED`: Connected and ready
404
+ - `RECONNECTING`: Attempting to reconnect
405
+ - `FAILED`: Connection failed
406
+
407
+ #### Properties
408
+
409
+ **`connectionState: ConnectionState`** (readonly)
410
+
411
+ Current connection state.
412
+
413
+ **`isReady: boolean`** (readonly)
414
+
415
+ Whether the client is ready to stream snapshots (true when ready state is READY).
416
+
417
+ **`currentReadyState: SyncReadyState`** (readonly)
418
+
419
+ Current sync ready state.
420
+
421
+ #### Disposal
422
+
423
+ **`async dispose(): Promise<void>`**
424
+
425
+ Clean up resources and disconnect.
426
+
427
+ ## Advanced Usage
428
+
429
+ ### Conflict Resolution
430
+
431
+ Handle conflicts when local and remote changes affect the same row:
432
+
433
+ ```typescript
434
+ syncClient.setConflictResolver(async (local, remote) => {
435
+ // Last-write-wins strategy
436
+ if (remote.timestamp! > local.timestamp!) {
437
+ return remote;
438
+ }
439
+ return local;
440
+
441
+ // Or custom merge logic
442
+ // return {
443
+ // ...remote,
444
+ // data: {
445
+ // ...local.data,
446
+ // ...remote.data,
447
+ // }
448
+ // };
449
+
450
+ // Or skip the change
451
+ // return null;
452
+ });
453
+ ```
454
+
455
+ ### Incremental Snapshots
456
+
457
+ For large datasets, use incremental snapshots to only fetch new data:
458
+
459
+ ```typescript
460
+ // First sync - get all data
461
+ await syncClient.streamSnapshot(['users', 'posts']);
462
+
463
+ // Later syncs - only get changes
464
+ await syncClient.streamSnapshot(['users', 'posts'], {
465
+ incremental: true
466
+ });
467
+ ```
468
+
469
+ ### Custom Channel Messages
470
+
471
+ Send custom messages to your Phoenix channels:
472
+
473
+ ```typescript
474
+ const response = await syncClient.sendMessage('custom_event', {
475
+ key: 'value',
476
+ data: { foo: 'bar' }
477
+ });
478
+
479
+ console.log('Server response:', response);
480
+ ```
481
+
482
+ ### Multiple Channels
483
+
484
+ Join multiple channels for different data scopes:
485
+
486
+ ```typescript
487
+ const syncClient = new SyncClient({
488
+ // ...
489
+ initialChannels: [
490
+ { channelName: 'user', channelId: userId },
491
+ { channelName: 'tribe', channelId: tribeId },
492
+ { channelName: 'guild', channelId: guildId },
493
+ ],
494
+ });
495
+
496
+ // Or join dynamically
497
+ await syncClient.joinChannel({
498
+ channelName: 'party',
499
+ channelId: partyId,
500
+ });
501
+ ```
502
+
503
+ ## Server-Side Integration
504
+
505
+ This library is designed to work with an Elixir Phoenix server using Phoenix Channels. Your server should implement:
506
+
507
+ 1. **Channel authentication** - Verify tokens on channel join
508
+ 2. **Change broadcasting** - Broadcast changes to relevant channels
509
+ 3. **Snapshot streaming** - Stream table data in batches
510
+ 4. **Schema migrations** - Manage and distribute schema versions
511
+
512
+ Example Phoenix channel (Elixir):
513
+
514
+ ```elixir
515
+ defmodule MyAppWeb.SyncChannel do
516
+ use Phoenix.Channel
517
+
518
+ def join("sync:user:" <> user_id, _params, socket) do
519
+ # Authenticate and authorize
520
+ {:ok, socket}
521
+ end
522
+
523
+ def handle_in("hello", %{"schema_version" => version}, socket) do
524
+ # Check schema version and send migrations if needed
525
+ {:reply, {:ok, %{status: "up_to_date"}}, socket}
526
+ end
527
+
528
+ def handle_in("changes_batch", %{"changes" => changes}, socket) do
529
+ # Process and broadcast changes
530
+ Enum.each(changes, fn change ->
531
+ broadcast!(socket, "change", change)
532
+ end)
533
+ {:reply, {:ok, %{}}, socket}
534
+ end
535
+
536
+ def handle_in("stream_snapshot", %{"tables" => tables}, socket) do
537
+ # Stream table data in batches
538
+ Task.start(fn ->
539
+ Enum.each(tables, fn table ->
540
+ stream_table_data(socket, table)
541
+ end)
542
+ end)
543
+ {:reply, {:ok, %{}}, socket}
544
+ end
545
+ end
546
+ ```
547
+
548
+ ## Troubleshooting
549
+
550
+ ### Connection Issues
551
+
552
+ If you're having trouble connecting:
553
+
554
+ 1. Check WebSocket URL is correct (should start with `wss://` or `ws://`)
555
+ 2. Verify authentication token is valid
556
+ 3. Check server logs for connection errors
557
+ 4. Ensure CORS is properly configured on your server
558
+
559
+ ### Changes Not Syncing
560
+
561
+ If changes aren't syncing:
562
+
563
+ 1. Check connection state: `syncClient.connectionState`
564
+ 2. Verify channels are joined: `syncClient.getJoinedChannels()`
565
+ 3. Check for errors in browser console
566
+ 4. Verify `pushInterval` is set (or call `sync()` manually)
567
+
568
+ ### Schema Mismatch
569
+
570
+ If you get schema version errors:
571
+
572
+ 1. The client will automatically apply migrations from the server
573
+ 2. Listen to `onSyncReadyStateChange` to track migration progress
574
+ 3. Ensure your server is sending proper migration SQL
575
+
576
+ ## License
577
+
578
+ MIT