@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/dist/index.mjs ADDED
@@ -0,0 +1,2809 @@
1
+ // src/sync-client.ts
2
+ import { SynclibOperation as SynclibOp } from "@synclib-io/core";
3
+
4
+ // src/connection/websocket-manager.ts
5
+ import { Socket } from "phoenix";
6
+
7
+ // src/protocol/message.ts
8
+ var MessageFactory = {
9
+ /**
10
+ * Create a HelloMessage
11
+ */
12
+ hello(clientId, schemaVersion = 0, lastSeqnum, metadata) {
13
+ return {
14
+ type: "hello",
15
+ client_id: clientId,
16
+ schema_version: schemaVersion,
17
+ last_seqnum: lastSeqnum,
18
+ metadata,
19
+ toMap() {
20
+ return {
21
+ type: "hello",
22
+ client_id: this.client_id,
23
+ schema_version: this.schema_version,
24
+ ...this.last_seqnum !== void 0 && { last_seqnum: this.last_seqnum },
25
+ ...this.metadata && { metadata: this.metadata }
26
+ };
27
+ }
28
+ };
29
+ },
30
+ /**
31
+ * Create a ChangeMessage from a synclib Change object
32
+ */
33
+ fromChange(change) {
34
+ return {
35
+ type: "change",
36
+ table: change.tableName,
37
+ operation: change.operation,
38
+ row_id: change.rowId,
39
+ data: change.data ? JSON.parse(change.data) : void 0,
40
+ seqnum: change.seqnum,
41
+ timestamp: /* @__PURE__ */ new Date(),
42
+ toMap() {
43
+ return {
44
+ type: "change",
45
+ table: this.table,
46
+ operation: this.operation,
47
+ row_id: this.row_id,
48
+ ...this.data && { data: this.data },
49
+ ...this.seqnum !== void 0 && { seqnum: this.seqnum },
50
+ ...this.timestamp && { timestamp: this.timestamp.toISOString() }
51
+ };
52
+ }
53
+ };
54
+ },
55
+ /**
56
+ * Create a ChangesBatchMessage
57
+ */
58
+ changesBatch(changes, fromSeqnum, toSeqnum) {
59
+ return {
60
+ type: "changes_batch",
61
+ changes,
62
+ from_seqnum: fromSeqnum,
63
+ to_seqnum: toSeqnum,
64
+ toMap() {
65
+ return {
66
+ type: "changes_batch",
67
+ changes: this.changes.map((c) => c.toMap()),
68
+ ...this.from_seqnum !== void 0 && { from_seqnum: this.from_seqnum },
69
+ ...this.to_seqnum !== void 0 && { to_seqnum: this.to_seqnum }
70
+ };
71
+ }
72
+ };
73
+ },
74
+ /**
75
+ * Create a RequestChangesMessage
76
+ */
77
+ requestChanges(sinceSeqnum, limit, table) {
78
+ return {
79
+ type: "request_changes",
80
+ since_seqnum: sinceSeqnum,
81
+ limit,
82
+ table,
83
+ toMap() {
84
+ return {
85
+ type: "request_changes",
86
+ since_seqnum: this.since_seqnum ?? 0,
87
+ ...this.limit !== void 0 && { limit: this.limit },
88
+ ...this.table && { table: this.table }
89
+ };
90
+ }
91
+ };
92
+ },
93
+ /**
94
+ * Create a SchemaConfirmMessage
95
+ */
96
+ schemaConfirm(version) {
97
+ return {
98
+ type: "schema_migrated",
99
+ version,
100
+ toMap() {
101
+ return {
102
+ type: "schema_migrated",
103
+ version: this.version
104
+ };
105
+ }
106
+ };
107
+ },
108
+ /**
109
+ * Parse a message from a map
110
+ */
111
+ fromMap(map) {
112
+ const type = map.type;
113
+ switch (type) {
114
+ case "change":
115
+ return {
116
+ type: "change",
117
+ table: map.table,
118
+ operation: map.operation,
119
+ row_id: map.row_id,
120
+ data: map.data,
121
+ seqnum: map.seqnum,
122
+ timestamp: map.timestamp ? typeof map.timestamp === "string" ? new Date(map.timestamp) : new Date(map.timestamp * 1e3) : void 0,
123
+ toMap() {
124
+ return map;
125
+ }
126
+ };
127
+ case "changes_batch":
128
+ return {
129
+ type: "changes_batch",
130
+ changes: map.changes.map((c) => MessageFactory.fromMap(c)),
131
+ from_seqnum: map.from_seqnum,
132
+ to_seqnum: map.to_seqnum,
133
+ toMap() {
134
+ return map;
135
+ }
136
+ };
137
+ case "ack":
138
+ return {
139
+ type: "ack",
140
+ seqnum: map.seqnum,
141
+ success: map.success,
142
+ error: map.error,
143
+ server_seqnum: map.server_seqnum,
144
+ row_hash: map.row_hash,
145
+ toMap() {
146
+ return map;
147
+ }
148
+ };
149
+ case "error":
150
+ return {
151
+ type: "error",
152
+ code: map.code,
153
+ message: map.message,
154
+ details: map.details,
155
+ toMap() {
156
+ return map;
157
+ }
158
+ };
159
+ case "phx_reply":
160
+ return {
161
+ type: "phx_reply",
162
+ status: map.status || "ok",
163
+ response: map,
164
+ toMap() {
165
+ return map;
166
+ }
167
+ };
168
+ case "snapshot_batch":
169
+ return {
170
+ type: "snapshot_batch",
171
+ stream_id: map.stream_id,
172
+ table: map.table,
173
+ rows: map.rows || [],
174
+ toMap() {
175
+ return map;
176
+ }
177
+ };
178
+ case "snapshot_complete":
179
+ return {
180
+ type: "snapshot_complete",
181
+ stream_id: map.stream_id,
182
+ channel_id: map.channel_id,
183
+ toMap() {
184
+ return map;
185
+ }
186
+ };
187
+ case "job_update":
188
+ return {
189
+ type: "job_update",
190
+ current_step: map.current_step,
191
+ total_steps: map.total_steps,
192
+ step_type: map.step_type,
193
+ job_id: map.job_id,
194
+ user_id: map.user_id,
195
+ phoenix_channel_id: map.phoenix_channel_id,
196
+ filename: map.filename,
197
+ toMap() {
198
+ return map;
199
+ }
200
+ };
201
+ case "schema_update":
202
+ return {
203
+ type: "schema_update",
204
+ new_version: map.new_version,
205
+ migrations: map.migrations,
206
+ timestamp: map.timestamp,
207
+ toMap() {
208
+ return map;
209
+ }
210
+ };
211
+ case "livestream:started":
212
+ case "livestream:stopped":
213
+ return {
214
+ type: map.type || map.event,
215
+ stream_id: map.stream_id,
216
+ tribe_id: map.tribe_id,
217
+ user_id: map.user_id,
218
+ hls_url: map.hls_url,
219
+ timestamp: map.timestamp || Math.floor(Date.now() / 1e3),
220
+ toMap() {
221
+ return map;
222
+ }
223
+ };
224
+ case "conversation:user_joined":
225
+ case "conversation:user_left":
226
+ case "conversation:message_sent":
227
+ case "conversation:online_count":
228
+ return {
229
+ type: map.type || map.event,
230
+ conversation_id: map.conversation_id,
231
+ user_id: map.user_id,
232
+ message_id: map.message_id,
233
+ online_count: map.online_count,
234
+ metadata: map.metadata,
235
+ timestamp: map.timestamp || Math.floor(Date.now() / 1e3),
236
+ toMap() {
237
+ return map;
238
+ }
239
+ };
240
+ // Unified sync protocol messages
241
+ case "change_acks":
242
+ return {
243
+ type: "change_acks",
244
+ stream_id: map.stream_id,
245
+ acks: map.acks || [],
246
+ toMap() {
247
+ return map;
248
+ }
249
+ };
250
+ case "sync_data_batch":
251
+ case "sync_batch":
252
+ return {
253
+ type: map.type,
254
+ stream_id: map.stream_id,
255
+ table: map.table,
256
+ rows: map.rows || [],
257
+ toMap() {
258
+ return map;
259
+ }
260
+ };
261
+ case "sync_complete":
262
+ return {
263
+ type: "sync_complete",
264
+ stream_id: map.stream_id,
265
+ schema_version: map.schema_version,
266
+ table_seqnums: map.table_seqnums || {},
267
+ stats: map.stats,
268
+ toMap() {
269
+ return map;
270
+ }
271
+ };
272
+ // Merkle tree integrity verification messages
273
+ case "merkle_verify":
274
+ return {
275
+ type: "merkle_verify",
276
+ table_hashes: map.table_hashes || {},
277
+ block_size: map.block_size,
278
+ toMap() {
279
+ return map;
280
+ }
281
+ };
282
+ case "merkle_verify_response":
283
+ return {
284
+ type: "merkle_verify_response",
285
+ status: map.status,
286
+ verified_tables: map.verified_tables,
287
+ mismatches: map.mismatches,
288
+ toMap() {
289
+ return map;
290
+ }
291
+ };
292
+ case "merkle_block_hashes":
293
+ return {
294
+ type: "merkle_block_hashes",
295
+ table: map.table,
296
+ block_hashes: map.block_hashes || [],
297
+ block_size: map.block_size,
298
+ toMap() {
299
+ return map;
300
+ }
301
+ };
302
+ case "merkle_block_hashes_response":
303
+ return {
304
+ type: "merkle_block_hashes_response",
305
+ table: map.table,
306
+ differing_blocks: map.differing_blocks || [],
307
+ toMap() {
308
+ return map;
309
+ }
310
+ };
311
+ case "merkle_fetch_blocks":
312
+ return {
313
+ type: "merkle_fetch_blocks",
314
+ table: map.table,
315
+ blocks: map.blocks || [],
316
+ block_size: map.block_size,
317
+ toMap() {
318
+ return map;
319
+ }
320
+ };
321
+ case "merkle_fetch_blocks_response":
322
+ return {
323
+ type: "merkle_fetch_blocks_response",
324
+ table: map.table,
325
+ block: map.block,
326
+ rows: map.rows || [],
327
+ toMap() {
328
+ return map;
329
+ }
330
+ };
331
+ default:
332
+ throw new Error(`Unknown message type: ${type}`);
333
+ }
334
+ }
335
+ };
336
+
337
+ // src/connection/websocket-manager.ts
338
+ var ConnectionState = /* @__PURE__ */ ((ConnectionState2) => {
339
+ ConnectionState2["DISCONNECTED"] = "disconnected";
340
+ ConnectionState2["CONNECTING"] = "connecting";
341
+ ConnectionState2["CONNECTED"] = "connected";
342
+ ConnectionState2["RECONNECTING"] = "reconnecting";
343
+ ConnectionState2["FAILED"] = "failed";
344
+ ConnectionState2["AUTH_FAILED"] = "auth_failed";
345
+ return ConnectionState2;
346
+ })(ConnectionState || {});
347
+ var _WebSocketManager = class _WebSocketManager {
348
+ constructor(url, reconnectDelay = 2e3, maxReconnectAttempts = 5) {
349
+ this.url = url;
350
+ this.reconnectDelay = reconnectDelay;
351
+ this.maxReconnectAttempts = maxReconnectAttempts;
352
+ this.socket = null;
353
+ this.channels = /* @__PURE__ */ new Map();
354
+ this.state = "disconnected" /* DISCONNECTED */;
355
+ this.messageListeners = /* @__PURE__ */ new Set();
356
+ this.stateListeners = /* @__PURE__ */ new Set();
357
+ this.reconnectAttempts = 0;
358
+ this.connectStartTime = 0;
359
+ this.intentionalDisconnect = false;
360
+ this.quickFailureCount = 0;
361
+ this.authFailureFired = false;
362
+ }
363
+ /**
364
+ * Register a callback fired when the connection is rejected due to auth
365
+ * (e.g. expired JWT). The callback can refresh the token via setToken() and
366
+ * then call connect() again to retry.
367
+ */
368
+ setOnAuthFailure(callback) {
369
+ this.onAuthFailureCallback = callback;
370
+ }
371
+ /**
372
+ * Update the JWT (or other auth param) used for subsequent connect/reconnect
373
+ * attempts. The Socket reads its `params` via a thunk so the next reconnect
374
+ * picks up the new value without recreating the socket.
375
+ *
376
+ * If the connection is currently in AUTH_FAILED (reconnects were halted), a
377
+ * fresh token implies the caller wants to retry — we tear down the failed
378
+ * socket, reset state, and schedule a new connect.
379
+ */
380
+ setToken(token) {
381
+ if (!this.connectionParams) this.connectionParams = {};
382
+ this.connectionParams.token = token;
383
+ this.authFailureFired = false;
384
+ this.quickFailureCount = 0;
385
+ if (this.state === "auth_failed" /* AUTH_FAILED */) {
386
+ try {
387
+ this.socket?.disconnect();
388
+ } catch (_) {
389
+ }
390
+ this.socket = null;
391
+ this.reconnectAttempts = 0;
392
+ this.updateState("disconnected" /* DISCONNECTED */);
393
+ this.connect().catch((e) => {
394
+ console.warn("Reconnect after setToken failed:", e);
395
+ });
396
+ }
397
+ }
398
+ /**
399
+ * Build the string params map from current connectionParams. Called on every
400
+ * Phoenix reconnect attempt so token refreshes (via setToken) take effect
401
+ * without tearing down the socket.
402
+ */
403
+ buildStringParams() {
404
+ const out = {};
405
+ if (this.connectionParams) {
406
+ for (const [key, value] of Object.entries(this.connectionParams)) {
407
+ out[key] = String(value);
408
+ }
409
+ }
410
+ return out;
411
+ }
412
+ /**
413
+ * Connect to WebSocket server
414
+ */
415
+ async connect(params) {
416
+ if (this.state === "connected" /* CONNECTED */ || this.state === "connecting" /* CONNECTING */) {
417
+ console.log("Already connected or connecting");
418
+ return;
419
+ }
420
+ this.intentionalDisconnect = false;
421
+ if (params) {
422
+ this.connectionParams = params;
423
+ }
424
+ this.updateState("connecting" /* CONNECTING */);
425
+ this.connectStartTime = Date.now();
426
+ console.log(`Connecting to ${this.url}`);
427
+ try {
428
+ this.socket = new Socket(this.url, {
429
+ timeout: 1e4,
430
+ params: () => this.buildStringParams()
431
+ });
432
+ this.socket.onOpen(() => {
433
+ console.log("Socket opened");
434
+ this.updateState("connected" /* CONNECTED */);
435
+ this.reconnectAttempts = 0;
436
+ this.quickFailureCount = 0;
437
+ });
438
+ this.socket.onError((error) => {
439
+ console.error("Socket error:", error);
440
+ this.onError(error);
441
+ });
442
+ this.socket.onClose(() => {
443
+ console.warn("Socket closed");
444
+ this.onClose();
445
+ });
446
+ this.socket.connect();
447
+ await new Promise((resolve, reject) => {
448
+ const timeout = setTimeout(() => {
449
+ reject(new Error("Connection timeout"));
450
+ }, 1e4);
451
+ const checkConnection = () => {
452
+ if (this.state === "connected" /* CONNECTED */) {
453
+ clearTimeout(timeout);
454
+ resolve();
455
+ } else if (this.state === "failed" /* FAILED */) {
456
+ clearTimeout(timeout);
457
+ reject(new Error("Connection failed"));
458
+ } else {
459
+ setTimeout(checkConnection, 100);
460
+ }
461
+ };
462
+ checkConnection();
463
+ });
464
+ console.log("Connect call completed");
465
+ } catch (e) {
466
+ console.error("Connection failed:", e);
467
+ this.updateState("failed" /* FAILED */);
468
+ this.scheduleReconnect();
469
+ throw e;
470
+ }
471
+ }
472
+ /**
473
+ * Join a Phoenix channel
474
+ */
475
+ async joinChannel(topic, params = {}) {
476
+ if (!this.isConnected()) {
477
+ throw new Error("Not connected to server");
478
+ }
479
+ try {
480
+ if (this.channels.has(topic)) {
481
+ console.log(`Channel ${topic} already exists, leaving old one`);
482
+ const oldChannel = this.channels.get(topic);
483
+ oldChannel?.leave();
484
+ this.channels.delete(topic);
485
+ }
486
+ const channel = this.socket.channel(topic, params);
487
+ const eventTypes = [
488
+ "snapshot_batch",
489
+ "snapshot_complete",
490
+ "change",
491
+ "changes_batch",
492
+ "ack",
493
+ "error",
494
+ "schema_update",
495
+ "job_update",
496
+ // Unified sync events
497
+ "sync_complete",
498
+ "sync_data_batch",
499
+ "sync_batch",
500
+ "change_acks",
501
+ // Livestream events
502
+ "livestream:started",
503
+ "livestream:stopped",
504
+ // Conversation events
505
+ "conversation:user_joined",
506
+ "conversation:user_left",
507
+ "conversation:message_sent",
508
+ "conversation:online_count"
509
+ ];
510
+ for (const eventType of eventTypes) {
511
+ channel.on(eventType, (payload) => {
512
+ this.handleIncomingMessage(eventType, payload);
513
+ });
514
+ }
515
+ const joinPush = channel.join();
516
+ const joinResponse = await new Promise((resolve, reject) => {
517
+ joinPush.receive("ok", (response) => {
518
+ this.channels.set(topic, channel);
519
+ console.log(`Joined channel: ${topic}`, response);
520
+ resolve(response ?? {});
521
+ }).receive("error", (error) => {
522
+ console.error(`Failed to join channel: ${topic}`, error);
523
+ reject(new Error(`Failed to join channel: ${JSON.stringify(error)}`));
524
+ }).receive("timeout", () => {
525
+ console.error(`Timeout joining channel: ${topic}`);
526
+ reject(new Error("Channel join timeout"));
527
+ });
528
+ });
529
+ return joinResponse;
530
+ } catch (e) {
531
+ console.error("Failed to join channel:", e);
532
+ throw e;
533
+ }
534
+ }
535
+ /**
536
+ * Check if a channel is currently joined
537
+ */
538
+ isChannelJoined(topic) {
539
+ return this.channels.has(topic);
540
+ }
541
+ /**
542
+ * Get all currently joined channel topics
543
+ */
544
+ getJoinedChannels() {
545
+ return Array.from(this.channels.keys());
546
+ }
547
+ /**
548
+ * Leave a specific channel by topic
549
+ */
550
+ async leaveChannel(topic) {
551
+ const channel = this.channels.get(topic);
552
+ if (!channel) {
553
+ console.warn(`Channel ${topic} not found`);
554
+ return;
555
+ }
556
+ console.log(`Leaving channel: ${topic}`);
557
+ channel.leave();
558
+ this.channels.delete(topic);
559
+ console.log(`Left channel: ${topic}`);
560
+ }
561
+ /**
562
+ * Disconnect from WebSocket server
563
+ */
564
+ async disconnect() {
565
+ console.log("Disconnecting");
566
+ this.intentionalDisconnect = true;
567
+ for (const channel of this.channels.values()) {
568
+ channel.leave();
569
+ }
570
+ this.channels.clear();
571
+ this.socket?.disconnect();
572
+ this.socket = null;
573
+ this.updateState("disconnected" /* DISCONNECTED */);
574
+ }
575
+ /**
576
+ * Send a message to the server
577
+ */
578
+ async send(message, channelTopic) {
579
+ if (!this.isConnected() || this.channels.size === 0) {
580
+ throw new Error("Not connected to channel");
581
+ }
582
+ try {
583
+ const map = message.toMap();
584
+ const event = map.type;
585
+ delete map.type;
586
+ console.log(`Sending event: ${event} with payload:`, map);
587
+ let channel;
588
+ if (channelTopic) {
589
+ for (const [topic, ch] of this.channels.entries()) {
590
+ if (topic === channelTopic) {
591
+ channel = ch;
592
+ break;
593
+ }
594
+ }
595
+ }
596
+ if (!channel) {
597
+ channel = Array.from(this.channels.values())[0];
598
+ }
599
+ if (!channel) {
600
+ throw new Error("No channel available");
601
+ }
602
+ console.log(`[WS DEBUG] Creating push for event: ${event}`);
603
+ const push = channel.push(event, map);
604
+ console.log(`[WS DEBUG] Push created, registering callbacks for: ${event}`);
605
+ await new Promise((resolve, reject) => {
606
+ push.receive("ok", (response) => {
607
+ console.log(`[WS DEBUG] Received 'ok' for ${event}:`, response);
608
+ if (response && typeof response === "object") {
609
+ this.handleChannelReply({ response });
610
+ }
611
+ resolve();
612
+ }).receive("error", (error) => {
613
+ console.error(`[WS DEBUG] Received 'error' for ${event}:`, error);
614
+ reject(new Error(`Failed to send: ${JSON.stringify(error)}`));
615
+ }).receive("timeout", () => {
616
+ console.error(`[WS DEBUG] Received 'timeout' for ${event}`);
617
+ reject(new Error("Send timeout"));
618
+ });
619
+ console.log(`[WS DEBUG] Callbacks registered for: ${event}`);
620
+ });
621
+ } catch (e) {
622
+ console.error("Failed to send message:", e);
623
+ throw e;
624
+ }
625
+ }
626
+ /**
627
+ * Send a raw message with custom event and payload, returns server response
628
+ */
629
+ async sendRaw(event, payload, channelTopic) {
630
+ if (!this.isConnected() || this.channels.size === 0) {
631
+ throw new Error("Not connected to channel");
632
+ }
633
+ try {
634
+ console.log(`Sending raw event: ${event} with payload:`, payload);
635
+ let channel;
636
+ if (channelTopic) {
637
+ for (const [topic, ch] of this.channels.entries()) {
638
+ if (topic === channelTopic) {
639
+ channel = ch;
640
+ break;
641
+ }
642
+ }
643
+ }
644
+ if (!channel) {
645
+ channel = Array.from(this.channels.values())[0];
646
+ }
647
+ if (!channel) {
648
+ throw new Error("No channel available");
649
+ }
650
+ const response = await new Promise((resolve, reject) => {
651
+ channel.push(event, payload).receive("ok", (resp) => {
652
+ console.log(
653
+ `Sent on channel ${channelTopic || "default"} raw message: ${event}, received response`
654
+ );
655
+ resolve(resp);
656
+ }).receive("error", (error) => {
657
+ console.error(`Failed to send raw message: ${event}`, error);
658
+ reject(new Error(`Server error: ${JSON.stringify(error)}`));
659
+ }).receive("timeout", () => {
660
+ console.error(`Timeout sending raw message: ${event}`);
661
+ reject(new Error("Request timeout"));
662
+ });
663
+ });
664
+ return response;
665
+ } catch (e) {
666
+ console.error("Failed to send raw message:", e);
667
+ throw e;
668
+ }
669
+ }
670
+ /**
671
+ * Handle incoming message
672
+ */
673
+ handleIncomingMessage(event, payload) {
674
+ try {
675
+ console.log(`Received Phoenix message: event=${event}, payload:`, payload);
676
+ if (event.startsWith("phx_") && event !== "phx_reply") {
677
+ console.log(`Skipping Phoenix system event: ${event}`);
678
+ return;
679
+ }
680
+ if (event.startsWith("chan_reply_")) {
681
+ this.handleChannelReply(payload);
682
+ return;
683
+ }
684
+ const messagePayload = { ...payload, type: event };
685
+ const syncMessage = MessageFactory.fromMap(messagePayload);
686
+ for (const listener of this.messageListeners) {
687
+ listener(syncMessage);
688
+ }
689
+ console.log(`Successfully decoded ${event} message and notified listeners`);
690
+ } catch (e) {
691
+ console.error("Failed to process message:", e);
692
+ }
693
+ }
694
+ /**
695
+ * Handle channel reply messages
696
+ */
697
+ handleChannelReply(payload) {
698
+ try {
699
+ const response = payload.response || {};
700
+ console.log("[WS DEBUG] handleChannelReply called with response:", response);
701
+ const replyPayload = { ...response, type: "phx_reply" };
702
+ console.log("[WS DEBUG] Creating phx_reply message from:", replyPayload);
703
+ const syncMessage = MessageFactory.fromMap(replyPayload);
704
+ console.log("[WS DEBUG] Created syncMessage:", syncMessage);
705
+ console.log(`[WS DEBUG] Notifying ${this.messageListeners.size} listeners`);
706
+ for (const listener of this.messageListeners) {
707
+ console.log("[WS DEBUG] Calling listener with syncMessage");
708
+ listener(syncMessage);
709
+ }
710
+ console.log("[WS DEBUG] Successfully processed channel reply");
711
+ } catch (e) {
712
+ console.error("[WS DEBUG] Failed to process channel reply:", e);
713
+ }
714
+ }
715
+ /**
716
+ * Handle WebSocket error
717
+ */
718
+ onError(error) {
719
+ console.error("WebSocket error:", error);
720
+ const timeSinceConnect = Date.now() - this.connectStartTime;
721
+ if (timeSinceConnect < 2e3 && this.state === "connecting" /* CONNECTING */) {
722
+ console.warn("Connection rejected quickly - likely auth failure (token expired?)");
723
+ this.updateState("auth_failed" /* AUTH_FAILED */);
724
+ } else {
725
+ this.updateState("failed" /* FAILED */);
726
+ }
727
+ }
728
+ /**
729
+ * Handle WebSocket close
730
+ */
731
+ onClose() {
732
+ console.warn("WebSocket connection closed");
733
+ if (this.state === "auth_failed" /* AUTH_FAILED */) {
734
+ console.warn("Not reconnecting due to auth failure - token refresh required");
735
+ return;
736
+ }
737
+ const timeSinceConnect = Date.now() - this.connectStartTime;
738
+ if (timeSinceConnect < 2e3) {
739
+ this.quickFailureCount++;
740
+ console.warn(`Quick connection failure detected (${this.quickFailureCount}/${_WebSocketManager.QUICK_FAILURE_THRESHOLD})`);
741
+ if (this.quickFailureCount >= _WebSocketManager.QUICK_FAILURE_THRESHOLD) {
742
+ console.warn("Multiple quick failures - likely auth failure (token expired?)");
743
+ this.updateState("auth_failed" /* AUTH_FAILED */);
744
+ return;
745
+ }
746
+ } else {
747
+ this.quickFailureCount = 0;
748
+ }
749
+ this.updateState("disconnected" /* DISCONNECTED */);
750
+ this.scheduleReconnect();
751
+ }
752
+ /**
753
+ * Schedule automatic reconnection
754
+ */
755
+ scheduleReconnect() {
756
+ if (this.intentionalDisconnect) {
757
+ console.log("Skipping reconnect - disconnect was intentional");
758
+ return;
759
+ }
760
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
761
+ console.error("Max reconnect attempts reached");
762
+ this.updateState("failed" /* FAILED */);
763
+ return;
764
+ }
765
+ this.reconnectAttempts++;
766
+ this.updateState("reconnecting" /* RECONNECTING */);
767
+ const delay = this.reconnectDelay * this.reconnectAttempts;
768
+ console.log(
769
+ `Reconnecting in ${delay / 1e3}s (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`
770
+ );
771
+ setTimeout(() => {
772
+ this.connect();
773
+ }, delay);
774
+ }
775
+ /**
776
+ * Update connection state and notify listeners
777
+ */
778
+ updateState(newState) {
779
+ if (this.state === newState) return;
780
+ this.state = newState;
781
+ for (const listener of this.stateListeners) {
782
+ listener(newState);
783
+ }
784
+ if (newState === "auth_failed" /* AUTH_FAILED */ && !this.authFailureFired) {
785
+ this.authFailureFired = true;
786
+ try {
787
+ this.onAuthFailureCallback?.();
788
+ } catch (e) {
789
+ console.error("onAuthFailure callback threw:", e);
790
+ }
791
+ }
792
+ }
793
+ /**
794
+ * Check if connected
795
+ */
796
+ isConnected() {
797
+ return this.state === "connected" /* CONNECTED */;
798
+ }
799
+ /**
800
+ * Get current connection state
801
+ */
802
+ getState() {
803
+ return this.state;
804
+ }
805
+ /**
806
+ * Subscribe to messages
807
+ */
808
+ onMessage(listener) {
809
+ this.messageListeners.add(listener);
810
+ return () => {
811
+ this.messageListeners.delete(listener);
812
+ };
813
+ }
814
+ /**
815
+ * Subscribe to state changes
816
+ */
817
+ onStateChange(listener) {
818
+ this.stateListeners.add(listener);
819
+ return () => {
820
+ this.stateListeners.delete(listener);
821
+ };
822
+ }
823
+ /**
824
+ * Dispose resources
825
+ */
826
+ async dispose() {
827
+ await this.disconnect();
828
+ this.messageListeners.clear();
829
+ this.stateListeners.clear();
830
+ }
831
+ };
832
+ // Track consecutive quick failures for auth detection
833
+ _WebSocketManager.QUICK_FAILURE_THRESHOLD = 1;
834
+ var WebSocketManager = _WebSocketManager;
835
+
836
+ // src/sync-client.ts
837
+ import { MerkleComputer, DEFAULT_BLOCK_SIZE } from "@synclib-io/core";
838
+ var SyncReadyState = /* @__PURE__ */ ((SyncReadyState2) => {
839
+ SyncReadyState2["WAITING_FOR_HELLO"] = "waiting_for_hello";
840
+ SyncReadyState2["APPLYING_MIGRATIONS"] = "applying_migrations";
841
+ SyncReadyState2["READY"] = "ready";
842
+ return SyncReadyState2;
843
+ })(SyncReadyState || {});
844
+ var DEFAULT_MERKLE_BLOCK_SIZE = 100;
845
+ var SyncState = /* @__PURE__ */ ((SyncState2) => {
846
+ SyncState2["DISCONNECTED"] = "disconnected";
847
+ SyncState2["CONNECTING"] = "connecting";
848
+ SyncState2["SYNCING"] = "syncing";
849
+ SyncState2["READY"] = "ready";
850
+ SyncState2["ERROR"] = "error";
851
+ return SyncState2;
852
+ })(SyncState || {});
853
+ var RepairDirection = /* @__PURE__ */ ((RepairDirection2) => {
854
+ RepairDirection2["PULL"] = "pull";
855
+ RepairDirection2["PUSH"] = "push";
856
+ RepairDirection2["LWW"] = "lww";
857
+ return RepairDirection2;
858
+ })(RepairDirection || {});
859
+ var ChannelRole = /* @__PURE__ */ ((ChannelRole2) => {
860
+ ChannelRole2["PUSH"] = "push";
861
+ ChannelRole2["PULL"] = "pull";
862
+ ChannelRole2["BIDIRECTIONAL"] = "bidirectional";
863
+ return ChannelRole2;
864
+ })(ChannelRole || {});
865
+ function defaultDirectionForRole(role) {
866
+ switch (role) {
867
+ case "push" /* PUSH */:
868
+ return "push" /* PUSH */;
869
+ case "pull" /* PULL */:
870
+ return "pull" /* PULL */;
871
+ case "bidirectional" /* BIDIRECTIONAL */:
872
+ return "lww" /* LWW */;
873
+ }
874
+ }
875
+ function getErrorMessage(err) {
876
+ if (err instanceof Error) return err.message;
877
+ if (typeof err === "string") return err;
878
+ try {
879
+ return JSON.stringify(err);
880
+ } catch {
881
+ return String(err);
882
+ }
883
+ }
884
+ function isBenignMigrationError(err) {
885
+ const msg = getErrorMessage(err).toLowerCase();
886
+ return msg.includes("duplicate column") || msg.includes("already exists") || msg.includes("no such table");
887
+ }
888
+ var SyncClient = class {
889
+ constructor(config) {
890
+ this.isInitialized = false;
891
+ this.hasConnectedOnce = false;
892
+ this.pendingAcks = /* @__PURE__ */ new Set();
893
+ // Track which table/rowId corresponds to each pending change seqnum
894
+ this.pendingChangeInfo = /* @__PURE__ */ new Map();
895
+ // Event listeners
896
+ this.remoteChangeListeners = /* @__PURE__ */ new Set();
897
+ this.snapshotCompleteListeners = /* @__PURE__ */ new Set();
898
+ this.jobUpdateListeners = /* @__PURE__ */ new Set();
899
+ this.livestreamListeners = /* @__PURE__ */ new Set();
900
+ this.conversationListeners = /* @__PURE__ */ new Set();
901
+ this.syncReadyStateListeners = /* @__PURE__ */ new Set();
902
+ // State
903
+ this.readyState = "waiting_for_hello" /* WAITING_FOR_HELLO */;
904
+ this.batchQueue = [];
905
+ this.batchLock = Promise.resolve();
906
+ // Message queue for serialized processing
907
+ this.messageQueue = [];
908
+ this.processingMessages = false;
909
+ // Unified sync state
910
+ this.syncState = "disconnected" /* DISCONNECTED */;
911
+ this.isSyncing = false;
912
+ this.tableSeqnums = /* @__PURE__ */ new Map();
913
+ this.activeSyncCompleters = /* @__PURE__ */ new Map();
914
+ // Unified sync event listeners
915
+ this.syncStateListeners = /* @__PURE__ */ new Set();
916
+ this.syncProgressListeners = /* @__PURE__ */ new Set();
917
+ this.syncCompleteListeners = /* @__PURE__ */ new Set();
918
+ this.merkleVerificationListeners = /* @__PURE__ */ new Set();
919
+ this.config = {
920
+ pushBatchSize: 100,
921
+ syncOnWriteDebounce: 100,
922
+ connectionTimeout: 3e4,
923
+ syncTimeout: 3e5,
924
+ ...config
925
+ };
926
+ this.db = config.db;
927
+ this.ws = new WebSocketManager(config.serverUrl);
928
+ this.ws.onMessage(this.enqueueMessage.bind(this));
929
+ this.ws.onStateChange(this.handleStateChange.bind(this));
930
+ }
931
+ /** Get channels that push (push or bidirectional role). */
932
+ get pushChannels() {
933
+ return this.config.channels.filter(
934
+ (c) => c.role === "push" /* PUSH */ || c.role === "bidirectional" /* BIDIRECTIONAL */
935
+ );
936
+ }
937
+ /** Get channels that pull (pull or bidirectional role). */
938
+ get pullChannels() {
939
+ return this.config.channels.filter(
940
+ (c) => c.role === "pull" /* PULL */ || c.role === "bidirectional" /* BIDIRECTIONAL */
941
+ );
942
+ }
943
+ /** Get all sync table names from channels config. */
944
+ get allSyncTables() {
945
+ const tables = /* @__PURE__ */ new Set();
946
+ for (const ch of this.config.channels) {
947
+ for (const t of ch.tables ?? []) {
948
+ tables.add(t.name);
949
+ }
950
+ }
951
+ return Array.from(tables);
952
+ }
953
+ /**
954
+ * One-time migration: switch to server-authoritative row_hash.
955
+ * Sets all local row_hash values to '' (sentinel) so merkle comparison
956
+ * detects mismatch and triggers repair from server.
957
+ */
958
+ migrateToServerAuthoritativeRowHash() {
959
+ try {
960
+ this.db.exec(`
961
+ CREATE TABLE IF NOT EXISTS _synclib_metadata (
962
+ key TEXT PRIMARY KEY,
963
+ value TEXT NOT NULL
964
+ )
965
+ `);
966
+ const results = this.db.read(
967
+ `SELECT value FROM _synclib_metadata WHERE key = 'server_authoritative_row_hash'`
968
+ );
969
+ if (results.length > 0) return;
970
+ console.log("[SyncClient] Migrating to server-authoritative row_hash");
971
+ for (const tableName of this.allSyncTables) {
972
+ try {
973
+ this.db.exec(`UPDATE "${tableName}" SET row_hash = ''`);
974
+ console.log(`[SyncClient] Reset row_hash to sentinel for ${tableName}`);
975
+ } catch (e) {
976
+ console.log(`[SyncClient] Could not reset row_hash for ${tableName}:`, e);
977
+ }
978
+ }
979
+ this.db.exec(
980
+ `INSERT OR REPLACE INTO _synclib_metadata (key, value) VALUES ('server_authoritative_row_hash', '1')`
981
+ );
982
+ console.log("[SyncClient] Server-authoritative row_hash migration complete");
983
+ } catch (e) {
984
+ console.warn("[SyncClient] Failed to migrate row_hash:", e);
985
+ }
986
+ }
987
+ /**
988
+ * Initialize the sync client
989
+ */
990
+ async initialize() {
991
+ if (this.isInitialized) {
992
+ console.warn("Already initialized");
993
+ return;
994
+ }
995
+ console.log("Initializing sync client");
996
+ this.migrateToServerAuthoritativeRowHash();
997
+ this.isInitialized = true;
998
+ }
999
+ /**
1000
+ * Connect to sync server.
1001
+ * `extra` is forwarded as additional Phoenix socket connect params —
1002
+ * used for `slug` and `env` so the server can route the per-app
1003
+ * Postgres schema ({slug}_dev vs {slug}_prod).
1004
+ */
1005
+ async connect(token, extra) {
1006
+ if (!this.isInitialized) {
1007
+ throw new Error("Not initialized. Call initialize() first.");
1008
+ }
1009
+ await this.ws.connect({
1010
+ token,
1011
+ client_id: this.config.clientId,
1012
+ ...extra || {}
1013
+ });
1014
+ await this.joinChannels();
1015
+ }
1016
+ /**
1017
+ * Update the auth token used on subsequent (re)connects without tearing
1018
+ * down the socket. Use this when the host app refreshes a JWT — typically
1019
+ * either proactively (on a timer) or reactively (in response to
1020
+ * setOnAuthFailure).
1021
+ */
1022
+ setToken(token) {
1023
+ this.ws.setToken(token);
1024
+ }
1025
+ /**
1026
+ * Register a callback fired when the WebSocket is rejected due to bad/expired
1027
+ * auth. The host app should refresh the token, call setToken(newToken), then
1028
+ * call connect() again (or rely on the next scheduled reconnect, after we
1029
+ * clear AUTH_FAILED state).
1030
+ */
1031
+ setOnAuthFailure(callback) {
1032
+ this.ws.setOnAuthFailure(callback);
1033
+ }
1034
+ /**
1035
+ * Join all configured channels
1036
+ */
1037
+ async joinChannels() {
1038
+ for (const channel of this.config.channels) {
1039
+ const topic = channel.topic;
1040
+ console.log(`Joining channel: ${topic}`);
1041
+ const response = await this.ws.joinChannel(topic, {
1042
+ client_id: this.config.clientId,
1043
+ ...channel.params
1044
+ });
1045
+ this.extractServerHashColumns(response);
1046
+ }
1047
+ console.log("All channels joined successfully");
1048
+ this.hasConnectedOnce = true;
1049
+ await this.sendHello();
1050
+ this.startPeriodicSync();
1051
+ }
1052
+ /**
1053
+ * Join an additional channel after initial connection
1054
+ */
1055
+ async joinChannel(channel) {
1056
+ if (!this.ws.isConnected()) {
1057
+ throw new Error("Not connected. Call connect() first.");
1058
+ }
1059
+ const topic = channel.topic;
1060
+ console.log(`Joining additional channel: ${topic}`);
1061
+ const response = await this.ws.joinChannel(topic, {
1062
+ client_id: this.config.clientId,
1063
+ ...channel.params
1064
+ });
1065
+ this.extractServerHashColumns(response);
1066
+ console.log(`Successfully joined channel: ${topic}`);
1067
+ }
1068
+ /**
1069
+ * Check if a channel is currently joined
1070
+ */
1071
+ isChannelJoined(channel) {
1072
+ return this.ws.isChannelJoined(channel.topic);
1073
+ }
1074
+ /**
1075
+ * Get all currently joined channel topics
1076
+ */
1077
+ getJoinedChannels() {
1078
+ return this.ws.getJoinedChannels();
1079
+ }
1080
+ /**
1081
+ * Update the channel configuration dynamically.
1082
+ * Useful when new tables are discovered (e.g. after schema_update migration).
1083
+ * The allSyncTables getter automatically uses config.channels.
1084
+ */
1085
+ updateChannels(channels) {
1086
+ this.config = { ...this.config, channels };
1087
+ }
1088
+ /**
1089
+ * Leave a channel
1090
+ */
1091
+ async leaveChannel(channel) {
1092
+ console.log(`Leaving channel: ${channel.topic}`);
1093
+ await this.ws.leaveChannel(channel.topic);
1094
+ console.log(`Left channel: ${channel.topic}`);
1095
+ }
1096
+ /**
1097
+ * Leave a channel by its topic ID (e.g., "sync:user:123")
1098
+ */
1099
+ async leaveChannelById(channelId) {
1100
+ console.log(`Leaving channel: ${channelId}`);
1101
+ await this.ws.leaveChannel(channelId);
1102
+ console.log(`Left channel: ${channelId}`);
1103
+ }
1104
+ /**
1105
+ * Disconnect from sync server
1106
+ */
1107
+ async disconnect() {
1108
+ this.stopPeriodicSync();
1109
+ await this.ws.disconnect();
1110
+ }
1111
+ /**
1112
+ * Manually trigger a sync cycle.
1113
+ * Calls syncUnified() which handles push, pull, schema, and stripped content.
1114
+ */
1115
+ async sync() {
1116
+ await this.syncUnified();
1117
+ }
1118
+ /**
1119
+ * Unified sync - single operation that handles push, pull, and schema in one request.
1120
+ *
1121
+ * This is the recommended sync method that matches the Flutter implementation.
1122
+ * It prevents concurrent syncs and waits for connection if disconnected.
1123
+ */
1124
+ async syncUnified(options) {
1125
+ await this.waitForConnection();
1126
+ if (this.isSyncing) {
1127
+ console.log("[SyncClient] Already syncing, skipping");
1128
+ return;
1129
+ }
1130
+ try {
1131
+ this.isSyncing = true;
1132
+ this.updateSyncState("syncing" /* SYNCING */);
1133
+ const schemaVersion = this.db.getSchemaVersion();
1134
+ const pendingChanges = this.db.getPendingChanges(this.config.pushBatchSize);
1135
+ console.log(`[SyncClient] syncUnified: ${pendingChanges.length} pending changes`);
1136
+ if (pendingChanges.length > 0) {
1137
+ this.emitSyncProgress({
1138
+ phase: "pushing",
1139
+ changesPushed: pendingChanges.length
1140
+ });
1141
+ }
1142
+ const tablesToSync = this.allSyncTables;
1143
+ const tableSeqnums = await this.getPerTableSeqnums(tablesToSync);
1144
+ const payload = {
1145
+ client_id: this.config.clientId,
1146
+ schema_version: schemaVersion,
1147
+ table_seqnums: tableSeqnums,
1148
+ tables: tablesToSync
1149
+ };
1150
+ if (options?.forceRefresh) {
1151
+ payload.force_refresh_tables = options.forceRefresh;
1152
+ }
1153
+ if (pendingChanges.length > 0) {
1154
+ payload.pending_changes = pendingChanges.map((c) => ({
1155
+ local_seqnum: c.seqnum,
1156
+ table: c.tableName,
1157
+ row_id: c.rowId,
1158
+ operation: c.operation,
1159
+ data: c.data ? JSON.parse(c.data) : void 0
1160
+ }));
1161
+ for (const change of pendingChanges) {
1162
+ this.pendingAcks.add(change.seqnum);
1163
+ this.pendingChangeInfo.set(change.seqnum, {
1164
+ table: change.tableName,
1165
+ rowId: change.rowId
1166
+ });
1167
+ }
1168
+ }
1169
+ const channelTopic = this.config.channels[0]?.topic;
1170
+ const response = await this.ws.sendRaw("sync", payload, channelTopic);
1171
+ const streamId = response.stream_id;
1172
+ console.log(`[SyncClient] Sent sync request, server stream_id: ${streamId}`);
1173
+ const completePromise = this.waitForSyncComplete(
1174
+ streamId,
1175
+ this.config.syncTimeout
1176
+ );
1177
+ this.emitSyncProgress({ phase: "pulling" });
1178
+ await completePromise;
1179
+ await this.checkMerkleVerification();
1180
+ if (this.syncOnWriteDebounceTimer) {
1181
+ clearTimeout(this.syncOnWriteDebounceTimer);
1182
+ this.syncOnWriteDebounceTimer = void 0;
1183
+ }
1184
+ this.updateSyncState("ready" /* READY */);
1185
+ console.log("[SyncClient] syncUnified complete");
1186
+ } catch (error) {
1187
+ console.error("[SyncClient] syncUnified error:", error);
1188
+ this.updateSyncState("error" /* ERROR */);
1189
+ throw error;
1190
+ } finally {
1191
+ this.isSyncing = false;
1192
+ }
1193
+ }
1194
+ /**
1195
+ * Wait for WebSocket connection with timeout
1196
+ */
1197
+ async waitForConnection(timeout) {
1198
+ const timeoutMs = timeout ?? this.config.connectionTimeout ?? 3e4;
1199
+ if (this.ws.isConnected()) {
1200
+ return;
1201
+ }
1202
+ return new Promise((resolve, reject) => {
1203
+ const timeoutId = setTimeout(() => {
1204
+ unsubscribe();
1205
+ reject(new Error("Connection timeout"));
1206
+ }, timeoutMs);
1207
+ const unsubscribe = this.ws.onStateChange((state) => {
1208
+ if (state === "connected" /* CONNECTED */) {
1209
+ clearTimeout(timeoutId);
1210
+ unsubscribe();
1211
+ resolve();
1212
+ } else if (state === "failed" /* FAILED */ || state === "auth_failed" /* AUTH_FAILED */) {
1213
+ clearTimeout(timeoutId);
1214
+ unsubscribe();
1215
+ reject(new Error(`Connection failed: ${state}`));
1216
+ }
1217
+ });
1218
+ });
1219
+ }
1220
+ /**
1221
+ * Get per-table seqnums for incremental sync
1222
+ */
1223
+ async getPerTableSeqnums(tables) {
1224
+ const seqnums = {};
1225
+ for (const [table, seqnum] of this.tableSeqnums) {
1226
+ if (!tables || tables.includes(table)) {
1227
+ seqnums[table] = seqnum;
1228
+ }
1229
+ }
1230
+ if (tables) {
1231
+ for (const table of tables) {
1232
+ if (seqnums[table] === void 0) {
1233
+ try {
1234
+ const result = this.db.read(`SELECT MAX("seqnum") as max_seqnum FROM ${this.quoteId(table)}`);
1235
+ if (result.length > 0 && result[0].max_seqnum != null) {
1236
+ seqnums[table] = typeof result[0].max_seqnum === "number" ? result[0].max_seqnum : parseInt(String(result[0].max_seqnum), 10);
1237
+ }
1238
+ } catch {
1239
+ }
1240
+ }
1241
+ }
1242
+ }
1243
+ return seqnums;
1244
+ }
1245
+ /**
1246
+ * Wait for sync complete message
1247
+ */
1248
+ waitForSyncComplete(streamId, timeout) {
1249
+ const timeoutMs = timeout ?? this.config.syncTimeout ?? 3e5;
1250
+ return new Promise((resolve, reject) => {
1251
+ const timeoutId = setTimeout(() => {
1252
+ this.activeSyncCompleters.delete(streamId);
1253
+ reject(new Error("Sync timeout"));
1254
+ }, timeoutMs);
1255
+ this.activeSyncCompleters.set(streamId, {
1256
+ resolve: () => {
1257
+ clearTimeout(timeoutId);
1258
+ resolve();
1259
+ },
1260
+ reject: (e) => {
1261
+ clearTimeout(timeoutId);
1262
+ reject(e);
1263
+ }
1264
+ });
1265
+ });
1266
+ }
1267
+ /**
1268
+ * Update sync state and notify listeners
1269
+ */
1270
+ updateSyncState(newState) {
1271
+ if (this.syncState !== newState) {
1272
+ this.syncState = newState;
1273
+ for (const listener of this.syncStateListeners) {
1274
+ listener(newState);
1275
+ }
1276
+ console.log(`[SyncClient] Sync state changed to: ${newState}`);
1277
+ }
1278
+ }
1279
+ /**
1280
+ * Emit sync progress event
1281
+ */
1282
+ emitSyncProgress(progress) {
1283
+ for (const listener of this.syncProgressListeners) {
1284
+ listener(progress);
1285
+ }
1286
+ }
1287
+ /**
1288
+ * Extract server-driven hash_columns from a channel join response.
1289
+ */
1290
+ extractServerHashColumns(response) {
1291
+ const hashCols = response?.hash_columns;
1292
+ if (Array.isArray(hashCols) && hashCols.length > 0) {
1293
+ this.serverHashColumns = hashCols;
1294
+ console.log(`Server hash_columns: ${this.serverHashColumns.join(", ")}`);
1295
+ }
1296
+ }
1297
+ /**
1298
+ * Send hello message to server
1299
+ */
1300
+ async sendHello() {
1301
+ const schemaVersion = this.db.getSchemaVersion();
1302
+ const hello = MessageFactory.hello(
1303
+ this.config.clientId,
1304
+ schemaVersion,
1305
+ 0,
1306
+ this.config.metadata
1307
+ );
1308
+ const handshakeComplete = new Promise((resolve) => {
1309
+ this.helloHandshakeResolve = resolve;
1310
+ });
1311
+ await this.ws.send(hello, this.config.channels[0]?.topic);
1312
+ console.log(`Sent hello message with schema version ${schemaVersion}`);
1313
+ await handshakeComplete;
1314
+ }
1315
+ /**
1316
+ * Enqueue a message for serialized processing.
1317
+ * Prevents interleaving when multiple async messages arrive rapidly.
1318
+ */
1319
+ enqueueMessage(message) {
1320
+ this.messageQueue.push(message);
1321
+ if (!this.processingMessages) {
1322
+ this.processMessageQueue();
1323
+ }
1324
+ }
1325
+ /**
1326
+ * Process queued messages one at a time.
1327
+ */
1328
+ async processMessageQueue() {
1329
+ this.processingMessages = true;
1330
+ while (this.messageQueue.length > 0) {
1331
+ const msg = this.messageQueue.shift();
1332
+ await this.handleMessage(msg);
1333
+ }
1334
+ this.processingMessages = false;
1335
+ }
1336
+ /**
1337
+ * Handle incoming message from server
1338
+ */
1339
+ async handleMessage(message) {
1340
+ try {
1341
+ if (message.type === "change") {
1342
+ await this.applyRemoteChange(message);
1343
+ } else if (message.type === "changes_batch") {
1344
+ await this.applyRemoteChanges(message.changes);
1345
+ } else if (message.type === "ack") {
1346
+ await this.handleAck(message);
1347
+ } else if (message.type === "phx_reply") {
1348
+ await this.handlePhoenixReply(message);
1349
+ } else if (message.type === "error") {
1350
+ this.handleError(message);
1351
+ } else if (message.type === "snapshot_batch") {
1352
+ await this.queueSnapshotBatch(message);
1353
+ } else if (message.type === "snapshot_complete") {
1354
+ this.handleSnapshotComplete(message);
1355
+ } else if (message.type === "job_update") {
1356
+ this.handleJobUpdate(message);
1357
+ } else if (message.type === "livestream:started" || message.type === "livestream:stopped") {
1358
+ this.handleLivestream(message);
1359
+ } else if (message.type.startsWith("conversation:")) {
1360
+ this.handleConversation(message);
1361
+ } else if (message.type === "schema_update") {
1362
+ await this.handleSchemaUpdate(message);
1363
+ } else if (message.type === "change_acks") {
1364
+ await this.handleChangeAcks(message);
1365
+ } else if (message.type === "sync_data_batch" || message.type === "sync_batch") {
1366
+ await this.handleSyncDataBatch(message);
1367
+ } else if (message.type === "sync_complete") {
1368
+ this.handleSyncComplete(message);
1369
+ } else {
1370
+ console.warn("Unhandled message type:", message.type);
1371
+ }
1372
+ } catch (e) {
1373
+ console.error("Error handling message:", e);
1374
+ }
1375
+ }
1376
+ /**
1377
+ * Apply a single remote change to local database
1378
+ */
1379
+ async applyRemoteChange(change) {
1380
+ try {
1381
+ if (change.data && change.data["deleted_at"] != null) {
1382
+ const deleteSql = `DELETE FROM ${this.quoteId(change.table)} WHERE "id" = '${this.escapeSql(change.row_id)}'`;
1383
+ await this.db.applyRemote({
1384
+ tableName: change.table,
1385
+ rowId: change.row_id,
1386
+ operation: SynclibOp.DELETE,
1387
+ sql: deleteSql,
1388
+ data: void 0
1389
+ });
1390
+ console.log(`Soft-deleted row locally: ${change.table}:${change.row_id}`);
1391
+ for (const listener of this.remoteChangeListeners) {
1392
+ listener(change);
1393
+ }
1394
+ return;
1395
+ }
1396
+ if (this.conflictResolver) {
1397
+ const localChanges = this.db.getPendingChanges();
1398
+ const conflict = localChanges.find(
1399
+ (c) => c.tableName === change.table && c.rowId === change.row_id
1400
+ );
1401
+ if (conflict) {
1402
+ console.log(`Conflict detected for ${change.table}:${change.row_id}`);
1403
+ const localChange = MessageFactory.fromChange(conflict);
1404
+ const resolved = await this.conflictResolver(localChange, change);
1405
+ if (!resolved) {
1406
+ console.log(`Conflict skipped for ${change.table}:${change.row_id}`);
1407
+ return;
1408
+ }
1409
+ change = resolved;
1410
+ }
1411
+ }
1412
+ const sql = this.generateSql(change);
1413
+ await this.db.applyRemote({
1414
+ tableName: change.table,
1415
+ rowId: change.row_id,
1416
+ operation: this.toSynclibOperation(change.operation),
1417
+ sql,
1418
+ data: change.data ? JSON.stringify(change.data) : void 0
1419
+ });
1420
+ console.log(`Applied remote change: ${change.operation} on ${change.table}`);
1421
+ for (const listener of this.remoteChangeListeners) {
1422
+ listener(change);
1423
+ }
1424
+ } catch (e) {
1425
+ console.error("Failed to apply remote change:", e);
1426
+ }
1427
+ }
1428
+ /**
1429
+ * Apply multiple remote changes
1430
+ */
1431
+ async applyRemoteChanges(changes) {
1432
+ console.log(`Applying ${changes.length} remote changes`);
1433
+ await this.withBatchLock(async () => {
1434
+ this.db.beginBulkRemote();
1435
+ let deletedCount = 0;
1436
+ let skippedCount = 0;
1437
+ try {
1438
+ for (const change of changes) {
1439
+ try {
1440
+ if (change.data && change.data["deleted_at"] != null) {
1441
+ const deleteSql = `DELETE FROM ${this.quoteId(change.table)} WHERE "id" = '${this.escapeSql(change.row_id)}'`;
1442
+ this.db.execBulkRemote(deleteSql);
1443
+ deletedCount++;
1444
+ } else {
1445
+ const sql = this.generateSql(change);
1446
+ this.db.execBulkRemote(sql);
1447
+ }
1448
+ } catch (rowErr) {
1449
+ skippedCount++;
1450
+ console.error(`Skipping bad row in ${change.table} (id=${change.row_id}):`, rowErr);
1451
+ }
1452
+ }
1453
+ await this.db.endBulkRemote();
1454
+ if (deletedCount > 0) {
1455
+ console.log(`Soft-deleted ${deletedCount} rows locally`);
1456
+ }
1457
+ if (skippedCount > 0) {
1458
+ console.warn(`Skipped ${skippedCount} bad rows in changes batch`);
1459
+ }
1460
+ console.log(`Successfully applied ${changes.length} changes`);
1461
+ } catch (e) {
1462
+ console.error("Failed to apply changes batch:", e);
1463
+ await this.db.endBulkRemote(true);
1464
+ }
1465
+ });
1466
+ }
1467
+ /**
1468
+ * Handle acknowledgment from server
1469
+ */
1470
+ async handleAck(ack) {
1471
+ console.log(`Received ack for seqnum ${ack.seqnum}: ${ack.success}, server_seqnum: ${ack.server_seqnum}`);
1472
+ if (ack.success) {
1473
+ this.pendingAcks.delete(ack.seqnum);
1474
+ const changeInfo = this.pendingChangeInfo.get(ack.seqnum);
1475
+ this.pendingChangeInfo.delete(ack.seqnum);
1476
+ await this.db.deleteChange(ack.seqnum);
1477
+ if (ack.server_seqnum !== void 0 && changeInfo) {
1478
+ await this.updateLocalSeqnum(changeInfo.table, changeInfo.rowId, ack.server_seqnum);
1479
+ }
1480
+ if (ack.row_hash !== void 0 && changeInfo) {
1481
+ this.updateLocalRowHash(changeInfo.table, changeInfo.rowId, ack.row_hash);
1482
+ }
1483
+ } else {
1484
+ console.warn(`Change ${ack.seqnum} failed: ${ack.error}`);
1485
+ this.pendingChangeInfo.delete(ack.seqnum);
1486
+ await this.db.deleteChange(ack.seqnum);
1487
+ }
1488
+ }
1489
+ /**
1490
+ * Update the seqnum column on a local row after server assigns it
1491
+ */
1492
+ async updateLocalSeqnum(table, rowId, serverSeqnum) {
1493
+ try {
1494
+ const escapedRowId = rowId.replace(/'/g, "''");
1495
+ const sql = `UPDATE ${this.quoteId(table)} SET "seqnum" = ${serverSeqnum} WHERE "id" = '${escapedRowId}'`;
1496
+ this.db.exec(sql);
1497
+ console.log(`Updated local seqnum for ${table}:${rowId} to ${serverSeqnum}`);
1498
+ } catch (e) {
1499
+ console.log(`Could not update seqnum for ${table}:${rowId} (table may not have seqnum column):`, e);
1500
+ }
1501
+ }
1502
+ /**
1503
+ * Update the row_hash column on a local row with the server-computed value
1504
+ */
1505
+ updateLocalRowHash(table, rowId, rowHash) {
1506
+ try {
1507
+ const escapedRowId = rowId.replace(/'/g, "''");
1508
+ const escapedHash = rowHash.replace(/'/g, "''");
1509
+ const sql = `UPDATE ${this.quoteId(table)} SET "row_hash" = '${escapedHash}' WHERE "id" = '${escapedRowId}'`;
1510
+ this.db.exec(sql);
1511
+ } catch (e) {
1512
+ console.log(`Could not update row_hash for ${table}:${rowId}:`, e);
1513
+ }
1514
+ }
1515
+ /**
1516
+ * Handle error message from server
1517
+ */
1518
+ handleError(error) {
1519
+ console.error(`Server error: ${error.code} - ${error.message}`);
1520
+ }
1521
+ /**
1522
+ * Queue snapshot batch for processing
1523
+ */
1524
+ async queueSnapshotBatch(batch) {
1525
+ this.batchQueue.push(batch);
1526
+ console.log(
1527
+ `Queued snapshot batch for ${batch.table}: ${batch.rows.length} rows (queue size: ${this.batchQueue.length})`
1528
+ );
1529
+ await this.processBatchQueue();
1530
+ }
1531
+ /**
1532
+ * Process all batches in the queue serially
1533
+ */
1534
+ async processBatchQueue() {
1535
+ await this.withBatchLock(async () => {
1536
+ while (this.batchQueue.length > 0) {
1537
+ const batch = this.batchQueue.shift();
1538
+ console.log(
1539
+ `Processing batch for ${batch.table} (${this.batchQueue.length} remaining in queue)`
1540
+ );
1541
+ await this.handleSnapshotBatch(batch);
1542
+ }
1543
+ });
1544
+ }
1545
+ /**
1546
+ * Handle snapshot batch message
1547
+ */
1548
+ async handleSnapshotBatch(batch) {
1549
+ const startTime = Date.now();
1550
+ console.log(`Received snapshot batch for ${batch.table}: ${batch.rows.length} rows`);
1551
+ try {
1552
+ const tableCheck = this.db.read(
1553
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='${batch.table}'`
1554
+ );
1555
+ if (tableCheck.length === 0) {
1556
+ console.warn(`Table ${batch.table} does not exist, skipping batch`);
1557
+ return;
1558
+ }
1559
+ } catch (e) {
1560
+ console.error(`Error checking if table ${batch.table} exists:`, e);
1561
+ return;
1562
+ }
1563
+ this.db.beginBulkRemote();
1564
+ let processedCount = 0;
1565
+ let deletedCount = 0;
1566
+ try {
1567
+ for (const row of batch.rows) {
1568
+ const deletedAt = row["deleted_at"];
1569
+ if (deletedAt != null) {
1570
+ const rowId = row.id;
1571
+ const deleteSql = `DELETE FROM ${this.quoteId(batch.table)} WHERE "id" = '${this.escapeSql(rowId)}'`;
1572
+ this.db.execBulkRemote(deleteSql);
1573
+ deletedCount++;
1574
+ } else {
1575
+ const change = {
1576
+ type: "change",
1577
+ table: batch.table,
1578
+ operation: "insert",
1579
+ row_id: row.id,
1580
+ data: row,
1581
+ toMap() {
1582
+ const { toMap, ...rest } = this;
1583
+ return rest;
1584
+ }
1585
+ };
1586
+ const sql = this.generateSql(change);
1587
+ this.db.execBulkRemote(sql);
1588
+ }
1589
+ processedCount++;
1590
+ }
1591
+ await this.db.endBulkRemote();
1592
+ if (deletedCount > 0) {
1593
+ console.log(`Soft-deleted ${deletedCount} rows from ${batch.table}`);
1594
+ }
1595
+ const elapsed = Date.now() - startTime;
1596
+ console.log(
1597
+ `Successfully applied snapshot batch for ${batch.table}: processed ${processedCount}/${batch.rows.length} rows in ${elapsed}ms`
1598
+ );
1599
+ } catch (e) {
1600
+ const elapsed = Date.now() - startTime;
1601
+ console.error(
1602
+ `Failed to apply snapshot batch for ${batch.table} after processing ${processedCount}/${batch.rows.length} rows (${elapsed}ms):`,
1603
+ e
1604
+ );
1605
+ await this.db.endBulkRemote(true);
1606
+ throw e;
1607
+ }
1608
+ }
1609
+ /**
1610
+ * Handle snapshot complete message
1611
+ */
1612
+ handleSnapshotComplete(message) {
1613
+ console.log(`Snapshot complete for stream ${message.stream_id} on channel ${message.channel_id}`);
1614
+ for (const listener of this.snapshotCompleteListeners) {
1615
+ listener(message.stream_id, message.channel_id);
1616
+ }
1617
+ }
1618
+ /**
1619
+ * Handle job update message
1620
+ */
1621
+ handleJobUpdate(message) {
1622
+ console.log(
1623
+ `Job update: ${message.step_type} - step ${message.current_step}/${message.total_steps} for job ${message.job_id}`
1624
+ );
1625
+ for (const listener of this.jobUpdateListeners) {
1626
+ listener(message);
1627
+ }
1628
+ }
1629
+ /**
1630
+ * Handle livestream message
1631
+ */
1632
+ handleLivestream(message) {
1633
+ console.log(
1634
+ `Livestream event: ${message.type} - stream ${message.stream_id} by user ${message.user_id}`
1635
+ );
1636
+ for (const listener of this.livestreamListeners) {
1637
+ listener(message);
1638
+ }
1639
+ }
1640
+ /**
1641
+ * Handle conversation message
1642
+ */
1643
+ handleConversation(message) {
1644
+ console.log(
1645
+ `Conversation event: ${message.type} - conversation ${message.conversation_id} by user ${message.user_id}`
1646
+ );
1647
+ for (const listener of this.conversationListeners) {
1648
+ listener(message);
1649
+ }
1650
+ }
1651
+ /**
1652
+ * Handle schema update notification
1653
+ */
1654
+ async handleSchemaUpdate(message) {
1655
+ console.warn(`Schema update notification: server has new version ${message.new_version}`);
1656
+ const currentVersion = this.db.getSchemaVersion();
1657
+ if (message.new_version > currentVersion) {
1658
+ console.log(`Client schema v${currentVersion} is behind server v${message.new_version}`);
1659
+ if (message.migrations && message.migrations.length > 0) {
1660
+ console.log(
1661
+ `Applying ${message.migrations.length} migrations from schema_update notification`
1662
+ );
1663
+ this.updateReadyState("applying_migrations" /* APPLYING_MIGRATIONS */);
1664
+ await this.applyMigrations({
1665
+ migrations: message.migrations,
1666
+ current_version: message.new_version
1667
+ });
1668
+ this.updateReadyState("ready" /* READY */);
1669
+ } else {
1670
+ console.warn(
1671
+ "Schema update detected but no migrations provided - may need to reconnect"
1672
+ );
1673
+ }
1674
+ } else {
1675
+ console.log(`Client schema is already up to date (v${currentVersion})`);
1676
+ }
1677
+ }
1678
+ /**
1679
+ * Handle batch of change acknowledgments (unified sync protocol)
1680
+ */
1681
+ async handleChangeAcks(message) {
1682
+ console.log(`[SyncClient] Received ${message.acks.length} change acks`);
1683
+ for (const ack of message.acks) {
1684
+ if (ack.success) {
1685
+ this.pendingAcks.delete(ack.local_seqnum);
1686
+ const changeInfo = this.pendingChangeInfo.get(ack.local_seqnum);
1687
+ this.pendingChangeInfo.delete(ack.local_seqnum);
1688
+ await this.db.deleteChange(ack.local_seqnum);
1689
+ if (ack.server_seqnum !== void 0 && changeInfo) {
1690
+ await this.updateLocalSeqnum(changeInfo.table, changeInfo.rowId, ack.server_seqnum);
1691
+ }
1692
+ if (ack.row_hash !== void 0 && changeInfo) {
1693
+ this.updateLocalRowHash(changeInfo.table, changeInfo.rowId, ack.row_hash);
1694
+ }
1695
+ } else {
1696
+ console.warn(`Change ${ack.local_seqnum} failed: ${ack.error}`);
1697
+ this.pendingAcks.delete(ack.local_seqnum);
1698
+ this.pendingChangeInfo.delete(ack.local_seqnum);
1699
+ await this.db.deleteChange(ack.local_seqnum);
1700
+ }
1701
+ }
1702
+ }
1703
+ /**
1704
+ * Handle sync data batch (unified sync protocol)
1705
+ */
1706
+ async handleSyncDataBatch(message) {
1707
+ console.log(
1708
+ `[SyncClient] Received sync data batch for ${message.table}: ${message.rows.length} rows`
1709
+ );
1710
+ this.emitSyncProgress({
1711
+ phase: "pulling",
1712
+ table: message.table,
1713
+ rowCount: message.rows.length
1714
+ });
1715
+ await this.withBatchLock(async () => {
1716
+ try {
1717
+ const tableCheck = this.db.read(
1718
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='${message.table}'`
1719
+ );
1720
+ if (tableCheck.length === 0) {
1721
+ console.warn(`Table ${message.table} does not exist, skipping batch`);
1722
+ return;
1723
+ }
1724
+ } catch (e) {
1725
+ console.error(`Error checking if table ${message.table} exists:`, e);
1726
+ return;
1727
+ }
1728
+ this.db.beginBulkRemote();
1729
+ let deletedCount = 0;
1730
+ let maxSeqnum = 0;
1731
+ let skippedCount = 0;
1732
+ try {
1733
+ for (const row of message.rows) {
1734
+ try {
1735
+ const rowSeqnum = row.seqnum;
1736
+ if (rowSeqnum && rowSeqnum > maxSeqnum) {
1737
+ maxSeqnum = rowSeqnum;
1738
+ }
1739
+ if (row["deleted_at"] != null) {
1740
+ const rowId = row.id;
1741
+ const deleteSql = `DELETE FROM ${this.quoteId(message.table)} WHERE "id" = '${this.escapeSql(rowId)}'`;
1742
+ this.db.execBulkRemote(deleteSql);
1743
+ deletedCount++;
1744
+ } else {
1745
+ const change = {
1746
+ type: "change",
1747
+ table: message.table,
1748
+ operation: "insert",
1749
+ row_id: row.id,
1750
+ data: row,
1751
+ toMap() {
1752
+ const { toMap, ...rest } = this;
1753
+ return rest;
1754
+ }
1755
+ };
1756
+ const sql = this.generateSql(change);
1757
+ this.db.execBulkRemote(sql);
1758
+ }
1759
+ } catch (rowErr) {
1760
+ skippedCount++;
1761
+ console.error(`[SyncClient] Skipping bad row in ${message.table} (id=${row.id}):`, rowErr);
1762
+ }
1763
+ }
1764
+ await this.db.endBulkRemote();
1765
+ if (maxSeqnum > 0) {
1766
+ const currentSeqnum = this.tableSeqnums.get(message.table) ?? 0;
1767
+ if (maxSeqnum > currentSeqnum) {
1768
+ this.tableSeqnums.set(message.table, maxSeqnum);
1769
+ }
1770
+ }
1771
+ if (deletedCount > 0) {
1772
+ console.log(`[SyncClient] Soft-deleted ${deletedCount} rows from ${message.table}`);
1773
+ }
1774
+ if (skippedCount > 0) {
1775
+ console.warn(`[SyncClient] Skipped ${skippedCount} bad rows in ${message.table}`);
1776
+ }
1777
+ console.log(
1778
+ `[SyncClient] Applied sync batch for ${message.table}: ${message.rows.length} rows`
1779
+ );
1780
+ } catch (e) {
1781
+ console.error(`Failed to apply sync data batch for ${message.table}:`, e);
1782
+ await this.db.endBulkRemote(true);
1783
+ throw e;
1784
+ }
1785
+ });
1786
+ }
1787
+ /**
1788
+ * Handle sync complete message (unified sync protocol)
1789
+ */
1790
+ handleSyncComplete(message) {
1791
+ console.log(`[SyncClient] Sync complete, stream_id: ${message.stream_id}`);
1792
+ if (message.table_seqnums) {
1793
+ for (const [table, seqnum] of Object.entries(message.table_seqnums)) {
1794
+ this.tableSeqnums.set(table, seqnum);
1795
+ }
1796
+ }
1797
+ const event = {
1798
+ streamId: message.stream_id,
1799
+ schemaVersion: message.schema_version,
1800
+ tableSeqnums: message.table_seqnums ?? {},
1801
+ schemaUpgraded: message.stats?.schema_upgraded ?? false,
1802
+ migrationsApplied: message.stats?.migrations_applied ?? 0,
1803
+ totalRowsPulled: message.stats?.pull_total ?? 0,
1804
+ totalChangesPushed: message.stats?.push_total ?? 0,
1805
+ totalChangesSucceeded: message.stats?.push_success ?? 0,
1806
+ totalChangesFailed: message.stats?.push_failed ?? 0,
1807
+ pullStats: {},
1808
+ pushStats: {}
1809
+ };
1810
+ this.emitSyncProgress({ phase: "complete" });
1811
+ for (const listener of this.syncCompleteListeners) {
1812
+ listener(event);
1813
+ }
1814
+ const streamId = message.stream_id ?? "";
1815
+ const completer = this.activeSyncCompleters.get(streamId);
1816
+ if (completer) {
1817
+ completer.resolve();
1818
+ this.activeSyncCompleters.delete(streamId);
1819
+ }
1820
+ }
1821
+ /**
1822
+ * Handle Phoenix reply messages
1823
+ */
1824
+ async handlePhoenixReply(reply) {
1825
+ console.log("[SYNC DEBUG] handlePhoenixReply called with:", reply);
1826
+ const response = reply.response;
1827
+ console.log("[SYNC DEBUG] Response object:", response);
1828
+ console.log("[SYNC DEBUG] Response status:", response?.status);
1829
+ if (response.merkle_block_size != null) {
1830
+ this.serverMerkleBlockSize = response.merkle_block_size;
1831
+ console.log(`[SyncClient] Server merkle_block_size: ${this.serverMerkleBlockSize}`);
1832
+ }
1833
+ if (response.status === "upgrade_needed") {
1834
+ console.log("[SYNC DEBUG] Schema upgrade needed - applying migrations");
1835
+ this.updateReadyState("applying_migrations" /* APPLYING_MIGRATIONS */);
1836
+ await this.applyMigrations(response);
1837
+ this.updateReadyState("ready" /* READY */);
1838
+ } else if (response.status === "up_to_date") {
1839
+ console.log("[SYNC DEBUG] Schema is up to date");
1840
+ this.updateReadyState("ready" /* READY */);
1841
+ } else if (response.status === "ok") {
1842
+ console.log("[SYNC DEBUG] Received OK response, current readyState:", this.readyState);
1843
+ if (this.readyState === "waiting_for_hello" /* WAITING_FOR_HELLO */) {
1844
+ console.log("[SYNC DEBUG] Transitioning from WAITING_FOR_HELLO to READY");
1845
+ this.updateReadyState("ready" /* READY */);
1846
+ }
1847
+ } else {
1848
+ console.log("[SYNC DEBUG] Unknown status:", response.status);
1849
+ }
1850
+ if (this.helloHandshakeResolve) {
1851
+ this.helloHandshakeResolve();
1852
+ this.helloHandshakeResolve = void 0;
1853
+ }
1854
+ }
1855
+ /**
1856
+ * Apply schema migrations from server
1857
+ */
1858
+ async applyMigrations(response) {
1859
+ const currentVersion = response.current_version;
1860
+ const migrations = response.migrations;
1861
+ if (!migrations || migrations.length === 0) {
1862
+ console.warn("No migrations provided");
1863
+ return;
1864
+ }
1865
+ console.log(`Applying ${migrations.length} migration(s) to reach version ${currentVersion}`);
1866
+ for (const migration of migrations) {
1867
+ console.log(`Applying migration v${migration.version}: ${migration.description}`);
1868
+ try {
1869
+ for (const sql of migration.sql) {
1870
+ console.log(`Executing: ${sql}`);
1871
+ try {
1872
+ this.db.exec(sql);
1873
+ } catch (stmtErr) {
1874
+ if (isBenignMigrationError(stmtErr)) {
1875
+ console.warn(
1876
+ `Skipping idempotent migration statement (already applied): ${sql} \u2014 ${getErrorMessage(stmtErr)}`
1877
+ );
1878
+ continue;
1879
+ }
1880
+ throw stmtErr;
1881
+ }
1882
+ }
1883
+ await this.db.setSchemaVersion(migration.version);
1884
+ console.log(`Successfully applied migration v${migration.version}`);
1885
+ } catch (e) {
1886
+ console.error(`Failed to apply migration v${migration.version}:`, e);
1887
+ throw e;
1888
+ }
1889
+ }
1890
+ const confirm = MessageFactory.schemaConfirm(currentVersion);
1891
+ await this.ws.send(confirm, this.config.channels[0]?.topic);
1892
+ console.log("Confirmed schema migration to server");
1893
+ }
1894
+ /**
1895
+ * Update sync ready state
1896
+ */
1897
+ updateReadyState(newState) {
1898
+ if (this.readyState !== newState) {
1899
+ this.readyState = newState;
1900
+ for (const listener of this.syncReadyStateListeners) {
1901
+ listener(newState);
1902
+ }
1903
+ console.log(`Sync ready state changed to: ${newState}`);
1904
+ }
1905
+ }
1906
+ /**
1907
+ * Handle connection state changes
1908
+ */
1909
+ handleStateChange(state) {
1910
+ console.log(`Connection state: ${state}`);
1911
+ switch (state) {
1912
+ case "connected" /* CONNECTED */:
1913
+ if (!this.isSyncing) {
1914
+ this.updateSyncState("ready" /* READY */);
1915
+ }
1916
+ break;
1917
+ case "connecting" /* CONNECTING */:
1918
+ case "reconnecting" /* RECONNECTING */:
1919
+ this.updateSyncState("connecting" /* CONNECTING */);
1920
+ break;
1921
+ case "disconnected" /* DISCONNECTED */:
1922
+ case "failed" /* FAILED */:
1923
+ this.stopPeriodicSync();
1924
+ this.updateSyncState("disconnected" /* DISCONNECTED */);
1925
+ break;
1926
+ case "auth_failed" /* AUTH_FAILED */:
1927
+ this.updateSyncState("error" /* ERROR */);
1928
+ break;
1929
+ }
1930
+ if (state === "connected" /* CONNECTED */ && this.hasConnectedOnce) {
1931
+ console.log("Reconnected - rejoining channels");
1932
+ this.joinChannels().catch((e) => {
1933
+ console.error("Failed to rejoin channels after reconnect:", e);
1934
+ });
1935
+ } else if (state === "disconnected" /* DISCONNECTED */ || state === "failed" /* FAILED */) {
1936
+ }
1937
+ }
1938
+ /**
1939
+ * Generate SQL statement from change message
1940
+ */
1941
+ generateSql(change) {
1942
+ const filteredData = {};
1943
+ if (change.data) {
1944
+ for (const [key, value] of Object.entries(change.data)) {
1945
+ if (key === "inserted_at" || key === "id") continue;
1946
+ const normalizedKey = key.toLowerCase();
1947
+ filteredData[normalizedKey] = value;
1948
+ }
1949
+ }
1950
+ const qt = this.quoteId(change.table);
1951
+ switch (change.operation) {
1952
+ case "insert": {
1953
+ const columns = ["id", ...Object.keys(filteredData)].map((c) => this.quoteId(c)).join(", ");
1954
+ const values = [
1955
+ `'${this.escapeSql(change.row_id)}'`,
1956
+ ...Object.values(filteredData).map((v) => this.formatSqlValue(v))
1957
+ ].join(", ");
1958
+ return `INSERT OR REPLACE INTO ${qt} (${columns}) VALUES (${values})`;
1959
+ }
1960
+ case "update": {
1961
+ const sets = Object.entries(filteredData).map(([k, v]) => `${this.quoteId(k)} = ${this.formatSqlValue(v)}`).join(", ");
1962
+ return `UPDATE ${qt} SET ${sets} WHERE "id" = '${this.escapeSql(change.row_id)}'`;
1963
+ }
1964
+ case "delete":
1965
+ return `DELETE FROM ${qt} WHERE "id" = '${this.escapeSql(change.row_id)}'`;
1966
+ default:
1967
+ throw new Error(`Unknown operation: ${change.operation}`);
1968
+ }
1969
+ }
1970
+ /**
1971
+ * Format a value for SQL
1972
+ */
1973
+ formatSqlValue(value) {
1974
+ if (value === null || value === void 0) {
1975
+ return "null";
1976
+ } else if (typeof value === "string") {
1977
+ return `'${this.escapeSql(value)}'`;
1978
+ } else if (typeof value === "boolean") {
1979
+ return value ? "1" : "0";
1980
+ } else if (typeof value === "number") {
1981
+ return value.toString();
1982
+ } else if (typeof value === "object") {
1983
+ const jsonString = JSON.stringify(value);
1984
+ return `'${this.escapeSql(jsonString)}'`;
1985
+ } else {
1986
+ return String(value);
1987
+ }
1988
+ }
1989
+ /**
1990
+ * Escape single quotes in SQL strings
1991
+ */
1992
+ escapeSql(value) {
1993
+ return value.replace(/'/g, "''");
1994
+ }
1995
+ /**
1996
+ * Quote a SQL identifier (table or column name) to prevent SQL injection.
1997
+ */
1998
+ quoteId(name) {
1999
+ return '"' + name.replace(/"/g, '""') + '"';
2000
+ }
2001
+ /**
2002
+ * Convert operation string to SynclibOperation
2003
+ */
2004
+ toSynclibOperation(operation) {
2005
+ switch (operation) {
2006
+ case "insert":
2007
+ return SynclibOp.INSERT;
2008
+ case "update":
2009
+ return SynclibOp.UPDATE;
2010
+ case "delete":
2011
+ return SynclibOp.DELETE;
2012
+ default:
2013
+ throw new Error(`Unknown operation: ${operation}`);
2014
+ }
2015
+ }
2016
+ /**
2017
+ * Execute function with batch lock (serial processing)
2018
+ */
2019
+ async withBatchLock(fn) {
2020
+ const previous = this.batchLock;
2021
+ let resolve;
2022
+ this.batchLock = new Promise((r) => resolve = r);
2023
+ try {
2024
+ await previous;
2025
+ return await fn();
2026
+ } finally {
2027
+ resolve();
2028
+ }
2029
+ }
2030
+ /**
2031
+ * Stream snapshot of tables from server
2032
+ */
2033
+ async streamSnapshot(tables, options = {}) {
2034
+ if (!this.ws.isConnected()) {
2035
+ throw new Error("Not connected to server");
2036
+ }
2037
+ let sinceSeqnum;
2038
+ if (options.incremental) {
2039
+ let maxSeqnum = 0;
2040
+ for (const table of tables) {
2041
+ try {
2042
+ const result = this.db.read(`SELECT MAX("seqnum") as max_seqnum FROM ${this.quoteId(table)}`);
2043
+ if (result.length > 0 && result[0].max_seqnum) {
2044
+ const seqnum = typeof result[0].max_seqnum === "number" ? result[0].max_seqnum : parseInt(String(result[0].max_seqnum), 10);
2045
+ if (seqnum > maxSeqnum) {
2046
+ maxSeqnum = seqnum;
2047
+ }
2048
+ }
2049
+ } catch (e) {
2050
+ console.warn(`Failed to get max seqnum for table ${table}:`, e);
2051
+ }
2052
+ }
2053
+ sinceSeqnum = maxSeqnum;
2054
+ console.log(`Requesting incremental snapshot since seqnum: ${sinceSeqnum}`);
2055
+ } else {
2056
+ console.log("Requesting full snapshot");
2057
+ }
2058
+ const payload = { tables };
2059
+ if (sinceSeqnum !== void 0) {
2060
+ payload.since_seqnum = sinceSeqnum;
2061
+ }
2062
+ await this.ws.sendRaw("stream_snapshot", payload, options.channelTopic);
2063
+ }
2064
+ /**
2065
+ * Fetch a full row from the server
2066
+ */
2067
+ async fetchRow(table, rowId) {
2068
+ const response = await this.sendMessage("fetch_row", {
2069
+ table,
2070
+ row_id: rowId
2071
+ });
2072
+ return response.row;
2073
+ }
2074
+ /**
2075
+ * Send a custom message to the server and wait for reply
2076
+ */
2077
+ async sendMessage(event, payload, channelTopic) {
2078
+ if (!this.ws.isConnected()) {
2079
+ throw new Error("Not connected to server");
2080
+ }
2081
+ try {
2082
+ const response = await this.ws.sendRaw(event, payload, channelTopic);
2083
+ return response;
2084
+ } catch (e) {
2085
+ console.error("Failed to send message:", e);
2086
+ throw e;
2087
+ }
2088
+ }
2089
+ /**
2090
+ * Send a conversation presence event
2091
+ */
2092
+ async sendConversationPresence(options) {
2093
+ if (!this.ws.isConnected()) {
2094
+ throw new Error("Not connected to server");
2095
+ }
2096
+ try {
2097
+ const payload = {
2098
+ conversation_id: options.conversationId,
2099
+ user_id: options.userId,
2100
+ timestamp: Math.floor(Date.now() / 1e3)
2101
+ };
2102
+ await this.ws.sendRaw(options.event, payload, options.channelTopic);
2103
+ console.log(
2104
+ `Sent conversation presence event: ${options.event} for conversation ${options.conversationId}`
2105
+ );
2106
+ } catch (e) {
2107
+ console.error("Failed to send conversation presence:", e);
2108
+ throw e;
2109
+ }
2110
+ }
2111
+ /**
2112
+ * Set conflict resolver
2113
+ */
2114
+ setConflictResolver(resolver) {
2115
+ this.conflictResolver = resolver;
2116
+ }
2117
+ /**
2118
+ * Notify the sync client that a local change occurred.
2119
+ *
2120
+ * Call this after writing to the database to trigger an immediate push.
2121
+ * Uses debouncing to batch rapid writes together (configurable via syncOnWriteDebounce).
2122
+ *
2123
+ * Example:
2124
+ * ```typescript
2125
+ * // After writing to the database
2126
+ * await db.write({ tableName: 'users', rowId: '123', ... });
2127
+ * syncClient.notifyLocalChange();
2128
+ * ```
2129
+ */
2130
+ notifyLocalChange() {
2131
+ if (!this.ws.isConnected()) return;
2132
+ if (this.syncOnWriteDebounceTimer) {
2133
+ clearTimeout(this.syncOnWriteDebounceTimer);
2134
+ }
2135
+ this.syncOnWriteDebounceTimer = setTimeout(() => {
2136
+ console.log("syncOnWrite: syncing after debounce");
2137
+ this.syncUnified();
2138
+ }, this.config.syncOnWriteDebounce);
2139
+ }
2140
+ /** Start periodic background sync timer if configured. */
2141
+ startPeriodicSync() {
2142
+ this.stopPeriodicSync();
2143
+ const interval = this.config.periodicSyncInterval;
2144
+ if (interval == null) return;
2145
+ console.log(`Starting periodic sync every ${interval}ms`);
2146
+ this.periodicSyncTimer = setInterval(() => {
2147
+ this.syncUnified().catch((e) => {
2148
+ console.warn("Periodic sync failed:", e);
2149
+ });
2150
+ }, interval);
2151
+ }
2152
+ /** Stop periodic background sync timer. */
2153
+ stopPeriodicSync() {
2154
+ if (this.periodicSyncTimer) {
2155
+ clearInterval(this.periodicSyncTimer);
2156
+ this.periodicSyncTimer = void 0;
2157
+ }
2158
+ }
2159
+ /**
2160
+ * Event listeners
2161
+ */
2162
+ onRemoteChange(listener) {
2163
+ this.remoteChangeListeners.add(listener);
2164
+ return () => this.remoteChangeListeners.delete(listener);
2165
+ }
2166
+ onSnapshotComplete(listener) {
2167
+ this.snapshotCompleteListeners.add(listener);
2168
+ return () => this.snapshotCompleteListeners.delete(listener);
2169
+ }
2170
+ onJobUpdate(listener) {
2171
+ this.jobUpdateListeners.add(listener);
2172
+ return () => this.jobUpdateListeners.delete(listener);
2173
+ }
2174
+ onLivestream(listener) {
2175
+ this.livestreamListeners.add(listener);
2176
+ return () => this.livestreamListeners.delete(listener);
2177
+ }
2178
+ onConversation(listener) {
2179
+ this.conversationListeners.add(listener);
2180
+ return () => this.conversationListeners.delete(listener);
2181
+ }
2182
+ onSyncReadyStateChange(listener) {
2183
+ this.syncReadyStateListeners.add(listener);
2184
+ return () => this.syncReadyStateListeners.delete(listener);
2185
+ }
2186
+ onStateChange(listener) {
2187
+ return this.ws.onStateChange(listener);
2188
+ }
2189
+ /**
2190
+ * Listen to unified sync state changes
2191
+ */
2192
+ onSyncStateChange(listener) {
2193
+ this.syncStateListeners.add(listener);
2194
+ return () => this.syncStateListeners.delete(listener);
2195
+ }
2196
+ /**
2197
+ * Listen to sync progress events
2198
+ */
2199
+ onSyncProgress(listener) {
2200
+ this.syncProgressListeners.add(listener);
2201
+ return () => this.syncProgressListeners.delete(listener);
2202
+ }
2203
+ /**
2204
+ * Listen to sync complete events
2205
+ */
2206
+ onSyncComplete(listener) {
2207
+ this.syncCompleteListeners.add(listener);
2208
+ return () => this.syncCompleteListeners.delete(listener);
2209
+ }
2210
+ /**
2211
+ * Listen to Merkle verification events
2212
+ * Fires after background verification completes, especially useful when repairs occurred.
2213
+ * Subscribe to invalidate caches/refs when data was repaired.
2214
+ */
2215
+ onMerkleVerification(listener) {
2216
+ this.merkleVerificationListeners.add(listener);
2217
+ return () => this.merkleVerificationListeners.delete(listener);
2218
+ }
2219
+ /**
2220
+ * Getters
2221
+ */
2222
+ get connectionState() {
2223
+ return this.ws.getState();
2224
+ }
2225
+ get isReady() {
2226
+ return this.readyState === "ready" /* READY */;
2227
+ }
2228
+ get currentReadyState() {
2229
+ return this.readyState;
2230
+ }
2231
+ /**
2232
+ * Get current unified sync state
2233
+ */
2234
+ get currentSyncState() {
2235
+ return this.syncState;
2236
+ }
2237
+ // ==========================================================================
2238
+ // MERKLE TREE INTEGRITY VERIFICATION
2239
+ // ==========================================================================
2240
+ /**
2241
+ * Check if Merkle verification is needed based on staleness.
2242
+ * Called at the end of syncUnified to run verification if interval has elapsed.
2243
+ */
2244
+ async checkMerkleVerification() {
2245
+ const hasChannels = this.config.channels.some((c) => (c.tables?.length ?? 0) > 0);
2246
+ if (!this.config.merkleVerifyInterval || !hasChannels) {
2247
+ return;
2248
+ }
2249
+ try {
2250
+ const now = Date.now();
2251
+ const results = this.db.read(
2252
+ `SELECT value FROM _synclib_metadata WHERE key = 'merkle_last_verified'`
2253
+ );
2254
+ const lastVerified = results.length > 0 ? parseInt(results[0].value, 10) : 0;
2255
+ if (lastVerified === 0) {
2256
+ console.log("[SyncClient] Merkle verification: first run - skipping, setting initial timestamp");
2257
+ this.db.exec(
2258
+ `INSERT OR REPLACE INTO _synclib_metadata (key, value) VALUES ('merkle_last_verified', '${now}')`
2259
+ );
2260
+ return;
2261
+ }
2262
+ if (now - lastVerified < this.config.merkleVerifyInterval) {
2263
+ console.log(
2264
+ `[SyncClient] Merkle verification not needed (last: ${new Date(lastVerified).toISOString()})`
2265
+ );
2266
+ return;
2267
+ }
2268
+ console.log(
2269
+ `[SyncClient] Running Merkle verification (last: ${new Date(lastVerified).toISOString()})`
2270
+ );
2271
+ const blockSize = this.serverMerkleBlockSize ?? DEFAULT_MERKLE_BLOCK_SIZE;
2272
+ const allRepairedTables = [];
2273
+ await this.merkleVerifyFromChannels(this.config.channels, blockSize, allRepairedTables);
2274
+ this.db.exec(
2275
+ `INSERT OR REPLACE INTO _synclib_metadata (key, value) VALUES ('merkle_last_verified', '${now}')`
2276
+ );
2277
+ if (allRepairedTables.length > 0) {
2278
+ console.log(`[SyncClient] Merkle verification repaired tables: ${allRepairedTables.join(", ")}`);
2279
+ } else {
2280
+ console.log("[SyncClient] Merkle verification: all tables match");
2281
+ }
2282
+ const event = {
2283
+ repairedTables: allRepairedTables,
2284
+ hadMismatches: allRepairedTables.length > 0
2285
+ };
2286
+ for (const listener of this.merkleVerificationListeners) {
2287
+ listener(event);
2288
+ }
2289
+ } catch (e) {
2290
+ console.warn("[SyncClient] Merkle verification failed:", e);
2291
+ }
2292
+ }
2293
+ /**
2294
+ * Merkle verification using the new channels config.
2295
+ * Verifies all tables per channel, then dispatches repair by direction.
2296
+ */
2297
+ async merkleVerifyFromChannels(channels, blockSize, allRepairedTables) {
2298
+ for (const channel of channels) {
2299
+ if (!channel.tables?.length) continue;
2300
+ const allTables = channel.tables.map((t) => t.name);
2301
+ console.log(`[SyncClient] Merkle verification on ${channel.topic}: ${allTables.join(", ")}`);
2302
+ try {
2303
+ const mismatches = await this.verifyRoots(allTables, blockSize, channel.topic, this.serverHashColumns);
2304
+ for (const mismatch of mismatches) {
2305
+ const syncTable = channel.tables.find((t) => t.name === mismatch.table);
2306
+ const direction = syncTable?.direction ?? defaultDirectionForRole(channel.role);
2307
+ console.log(
2308
+ `[SyncClient] Merkle repair: ${mismatch.table} on ${channel.topic} direction=${direction}`
2309
+ );
2310
+ switch (direction) {
2311
+ case "pull" /* PULL */:
2312
+ await this.repairTablePull(mismatch.table, blockSize, channel.topic, this.serverHashColumns, mismatch.row_ids);
2313
+ break;
2314
+ case "push" /* PUSH */:
2315
+ await this.repairTablePush(mismatch.table, blockSize, channel.topic, this.serverHashColumns, mismatch.row_ids);
2316
+ break;
2317
+ case "lww" /* LWW */:
2318
+ await this.repairTableLww(mismatch.table, blockSize, channel.topic, this.serverHashColumns, mismatch.row_ids);
2319
+ break;
2320
+ }
2321
+ allRepairedTables.push(mismatch.table);
2322
+ }
2323
+ } catch (e) {
2324
+ console.warn(`[SyncClient] Merkle verification failed on ${channel.topic}:`, e);
2325
+ }
2326
+ }
2327
+ }
2328
+ /**
2329
+ * Legacy merkle verification using old config fields (backward compat).
2330
+ */
2331
+ /**
2332
+ * Compare local Merkle roots against server and return mismatches.
2333
+ * Shared root-verification step used by the channels config path.
2334
+ */
2335
+ async verifyRoots(tables, blockSize, channelTopic, hashColumns) {
2336
+ if (!this.ws.isConnected()) {
2337
+ throw new Error("Not connected to server");
2338
+ }
2339
+ const merkleComputer = new MerkleComputer(this.db);
2340
+ const tableHashes = {};
2341
+ for (const table of tables) {
2342
+ try {
2343
+ const merkleInfo = await merkleComputer.merkleRoot(table, blockSize, "id", hashColumns);
2344
+ tableHashes[table] = {
2345
+ root_hash: merkleInfo.rootHash,
2346
+ block_count: merkleInfo.blockCount,
2347
+ row_count: merkleInfo.rowCount
2348
+ };
2349
+ } catch (e) {
2350
+ console.warn(`[SyncClient] verifyRoots: Failed to compute Merkle root for ${table}:`, e);
2351
+ }
2352
+ }
2353
+ if (Object.keys(tableHashes).length === 0) return [];
2354
+ const payload = {
2355
+ table_hashes: tableHashes,
2356
+ block_size: blockSize
2357
+ };
2358
+ const responseMap = await this.ws.sendRaw("merkle_verify", payload, channelTopic);
2359
+ if (responseMap.status === "ok") return [];
2360
+ const mismatches = (responseMap.mismatches ?? []).map((m) => ({
2361
+ table: m.table,
2362
+ server_root_hash: m.server_root_hash,
2363
+ server_block_count: m.server_block_count,
2364
+ server_row_count: m.server_row_count,
2365
+ row_ids: m.row_ids ?? void 0
2366
+ }));
2367
+ const realMismatches = [];
2368
+ const merkle = new MerkleComputer(this.db);
2369
+ for (const mismatch of mismatches) {
2370
+ if (mismatch.row_ids && mismatch.row_ids.length > 0) {
2371
+ try {
2372
+ const scopedInfo = await merkle.merkleRoot(mismatch.table, blockSize, "id", hashColumns, mismatch.row_ids);
2373
+ if (scopedInfo.rootHash === mismatch.server_root_hash) {
2374
+ console.log(
2375
+ `[SyncClient] verifyRoots: ${mismatch.table} matches after scoping (${scopedInfo.rowCount} scoped rows vs ${tableHashes[mismatch.table]?.row_count} total)`
2376
+ );
2377
+ continue;
2378
+ }
2379
+ } catch (e) {
2380
+ console.warn(`[SyncClient] verifyRoots: scoped recheck failed for ${mismatch.table}:`, e);
2381
+ }
2382
+ }
2383
+ realMismatches.push(mismatch);
2384
+ }
2385
+ return realMismatches;
2386
+ }
2387
+ /**
2388
+ * Verify data integrity using Merkle trees.
2389
+ *
2390
+ * Compares local Merkle roots against server and repairs any mismatched blocks.
2391
+ * This is a consistency audit that catches:
2392
+ * - Data corruption during development
2393
+ * - Seqnum drift from manual database edits
2394
+ * - Missed changes from network issues
2395
+ * - Any state where seqnums match but data differs
2396
+ *
2397
+ * @param options - Verification options
2398
+ * @returns List of tables that were repaired. Empty array means all matched.
2399
+ *
2400
+ * @example
2401
+ * ```typescript
2402
+ * const repairedTables = await syncClient.verifyIntegrity({
2403
+ * tables: ['users', 'workouts'],
2404
+ * blockSize: 100,
2405
+ * });
2406
+ * if (repairedTables.length > 0) {
2407
+ * console.log('Repaired tables:', repairedTables);
2408
+ * }
2409
+ * ```
2410
+ */
2411
+ async verifyIntegrity(options) {
2412
+ if (!this.ws.isConnected()) {
2413
+ console.warn("[SyncClient] verifyIntegrity: Not connected");
2414
+ throw new Error("Not connected to server");
2415
+ }
2416
+ const blockSize = options?.blockSize ?? DEFAULT_BLOCK_SIZE;
2417
+ const tablesToVerify = options?.tables ?? [];
2418
+ if (tablesToVerify.length === 0) {
2419
+ console.warn("[SyncClient] verifyIntegrity: No tables specified");
2420
+ return [];
2421
+ }
2422
+ console.log(
2423
+ `[SyncClient] verifyIntegrity: Starting integrity check for ${tablesToVerify.length} tables`
2424
+ );
2425
+ const merkleComputer = new MerkleComputer(this.db);
2426
+ const tableHashes = {};
2427
+ for (const table of tablesToVerify) {
2428
+ try {
2429
+ const merkleInfo = await merkleComputer.merkleRoot(table, blockSize);
2430
+ tableHashes[table] = {
2431
+ root_hash: merkleInfo.rootHash,
2432
+ block_count: merkleInfo.blockCount,
2433
+ row_count: merkleInfo.rowCount
2434
+ };
2435
+ console.log(
2436
+ `[SyncClient] verifyIntegrity: ${table} - root=${merkleInfo.rootHash.substring(0, 16)}..., blocks=${merkleInfo.blockCount}, rows=${merkleInfo.rowCount}`
2437
+ );
2438
+ } catch (e) {
2439
+ console.warn(
2440
+ `[SyncClient] verifyIntegrity: Failed to compute Merkle root for ${table}:`,
2441
+ e
2442
+ );
2443
+ }
2444
+ }
2445
+ if (Object.keys(tableHashes).length === 0) {
2446
+ console.warn("[SyncClient] verifyIntegrity: No valid tables to verify");
2447
+ return [];
2448
+ }
2449
+ const channel = options?.channelTopic;
2450
+ const payload = {
2451
+ table_hashes: tableHashes,
2452
+ block_size: blockSize
2453
+ };
2454
+ const responseMap = await this.ws.sendRaw("merkle_verify", payload, channel);
2455
+ const response = {
2456
+ type: "merkle_verify_response",
2457
+ status: responseMap.status,
2458
+ mismatches: responseMap.mismatches?.map((m) => ({
2459
+ table: m.table,
2460
+ server_root_hash: m.server_root_hash,
2461
+ server_block_count: m.server_block_count,
2462
+ server_row_count: m.server_row_count
2463
+ })),
2464
+ toMap() {
2465
+ return this;
2466
+ }
2467
+ };
2468
+ if (response.status === "ok") {
2469
+ console.log("[SyncClient] verifyIntegrity: All tables verified OK");
2470
+ return [];
2471
+ }
2472
+ const repairedTables = [];
2473
+ for (const mismatch of response.mismatches ?? []) {
2474
+ console.log(
2475
+ `[SyncClient] verifyIntegrity: Mismatch detected for ${mismatch.table} - local: ${tableHashes[mismatch.table]?.root_hash.substring(0, 16)}..., server: ${mismatch.server_root_hash.substring(0, 16)}...`
2476
+ );
2477
+ await this.repairTablePull(mismatch.table, blockSize, channel);
2478
+ repairedTables.push(mismatch.table);
2479
+ }
2480
+ console.log(
2481
+ `[SyncClient] verifyIntegrity: Repaired ${repairedTables.length} tables`
2482
+ );
2483
+ return repairedTables;
2484
+ }
2485
+ /**
2486
+ * Repair a table by fetching differing blocks from server (pull: server → client)
2487
+ */
2488
+ async repairTablePull(table, blockSize, channelTopic, hashColumns, scopedRowIds) {
2489
+ const merkleComputer = new MerkleComputer(this.db);
2490
+ const blockHashes = await merkleComputer.merkleBlockHashes(table, blockSize, "id", hashColumns, scopedRowIds);
2491
+ console.log(
2492
+ `[SyncClient] verifyIntegrity: ${table} has ${blockHashes.length} blocks`
2493
+ );
2494
+ const payload = {
2495
+ table,
2496
+ block_hashes: blockHashes,
2497
+ block_size: blockSize
2498
+ };
2499
+ const responseMap = await this.ws.sendRaw("merkle_block_hashes", payload, channelTopic);
2500
+ const response = {
2501
+ type: "merkle_block_hashes_response",
2502
+ table: responseMap.table,
2503
+ differing_blocks: responseMap.differing_blocks,
2504
+ toMap() {
2505
+ return this;
2506
+ }
2507
+ };
2508
+ if (response.differing_blocks.length === 0 && scopedRowIds == null) {
2509
+ console.log(
2510
+ `[SyncClient] verifyIntegrity: ${table} - no differing blocks (hash collision resolved)`
2511
+ );
2512
+ return;
2513
+ }
2514
+ console.log(
2515
+ `[SyncClient] verifyIntegrity: ${table} - ${response.differing_blocks.length} differing blocks: ${response.differing_blocks}`
2516
+ );
2517
+ for (const blockIndex of response.differing_blocks) {
2518
+ await this.fetchAndApplyBlock(table, blockIndex, blockSize, channelTopic);
2519
+ }
2520
+ if (scopedRowIds != null) {
2521
+ const allLocalIds = merkleComputer.getAllRowIds(table);
2522
+ const scopedSet = new Set(scopedRowIds);
2523
+ const outOfScope = allLocalIds.filter((id) => !scopedSet.has(id));
2524
+ if (outOfScope.length > 0) {
2525
+ console.log(
2526
+ `[SyncClient] verifyIntegrity: ${table} - deleting ${outOfScope.length} out-of-scope rows`
2527
+ );
2528
+ this.db.beginBulkRemote();
2529
+ try {
2530
+ for (const rowId of outOfScope) {
2531
+ const change = {
2532
+ type: "change",
2533
+ table,
2534
+ operation: "delete",
2535
+ row_id: rowId,
2536
+ toMap() {
2537
+ return this;
2538
+ }
2539
+ };
2540
+ const sql = this.generateSql(change);
2541
+ this.db.execBulkRemote(sql);
2542
+ }
2543
+ await this.db.endBulkRemote();
2544
+ } catch (e) {
2545
+ await this.db.endBulkRemote(true);
2546
+ throw e;
2547
+ }
2548
+ }
2549
+ }
2550
+ console.log(`[SyncClient] verifyIntegrity: ${table} - repair complete`);
2551
+ }
2552
+ /**
2553
+ * Fetch a block from server and apply to local database
2554
+ */
2555
+ async fetchAndApplyBlock(table, blockIndex, blockSize, channelTopic) {
2556
+ const payload = {
2557
+ table,
2558
+ blocks: [blockIndex],
2559
+ block_size: blockSize
2560
+ };
2561
+ const responseMap = await this.ws.sendRaw("merkle_fetch_blocks", payload, channelTopic);
2562
+ const response = {
2563
+ type: "merkle_fetch_blocks_response",
2564
+ table: responseMap.table,
2565
+ block: responseMap.block,
2566
+ rows: responseMap.rows,
2567
+ toMap() {
2568
+ return this;
2569
+ }
2570
+ };
2571
+ console.log(
2572
+ `[SyncClient] verifyIntegrity: ${table} block ${blockIndex} - received ${response.rows.length} rows`
2573
+ );
2574
+ const merkleComputer = new MerkleComputer(this.db);
2575
+ const existingRowIds = merkleComputer.getBlockRowIds(table, blockIndex, blockSize);
2576
+ const serverRowIds = new Set(response.rows.map((r) => String(r.id ?? "")));
2577
+ for (const row of response.rows) {
2578
+ const rowId = String(row.id ?? "");
2579
+ if (!rowId) continue;
2580
+ const isUpdate = existingRowIds.includes(rowId);
2581
+ await this.applyRemoteChange({
2582
+ type: "change",
2583
+ table,
2584
+ operation: isUpdate ? "update" : "insert",
2585
+ row_id: rowId,
2586
+ data: row,
2587
+ toMap() {
2588
+ return this;
2589
+ }
2590
+ });
2591
+ }
2592
+ for (const localRowId of existingRowIds) {
2593
+ if (!serverRowIds.has(localRowId)) {
2594
+ await this.applyRemoteChange({
2595
+ type: "change",
2596
+ table,
2597
+ operation: "delete",
2598
+ row_id: localRowId,
2599
+ toMap() {
2600
+ return this;
2601
+ }
2602
+ });
2603
+ console.log(
2604
+ `[SyncClient] verifyIntegrity: ${table} - deleted orphan row ${localRowId}`
2605
+ );
2606
+ }
2607
+ }
2608
+ }
2609
+ /**
2610
+ * Repair a table by pushing local rows to server (push: client → server)
2611
+ */
2612
+ async repairTablePush(table, blockSize, channelTopic, hashColumns, scopedRowIds) {
2613
+ const merkleComputer = new MerkleComputer(this.db);
2614
+ const effectiveScope = scopedRowIds != null && scopedRowIds.length === 0 ? void 0 : scopedRowIds;
2615
+ const blockHashes = await merkleComputer.merkleBlockHashes(table, blockSize, "id", hashColumns, effectiveScope);
2616
+ console.log(`[SyncClient] repairPush: ${table} has ${blockHashes.length} blocks`);
2617
+ const payload = {
2618
+ table,
2619
+ block_hashes: blockHashes,
2620
+ block_size: blockSize
2621
+ };
2622
+ const responseMap = await this.ws.sendRaw("merkle_block_hashes", payload, channelTopic);
2623
+ const differingBlocks = responseMap.differing_blocks;
2624
+ if (differingBlocks.length === 0) {
2625
+ console.log(`[SyncClient] repairPush: ${table} - no differing blocks`);
2626
+ return;
2627
+ }
2628
+ console.log(
2629
+ `[SyncClient] repairPush: ${table} - ${differingBlocks.length} differing blocks: ${differingBlocks}`
2630
+ );
2631
+ for (const blockIndex of differingBlocks) {
2632
+ await this.pushBlockToServer(table, blockIndex, blockSize, channelTopic);
2633
+ }
2634
+ console.log(`[SyncClient] repairPush: ${table} - push repair complete`);
2635
+ }
2636
+ /**
2637
+ * Read local rows for a block and push them to the server.
2638
+ */
2639
+ async pushBlockToServer(table, blockIndex, blockSize, channelTopic) {
2640
+ const merkleComputer = new MerkleComputer(this.db);
2641
+ const rowIds = merkleComputer.getBlockRowIds(table, blockIndex, blockSize);
2642
+ const rows = [];
2643
+ for (const rowId of rowIds) {
2644
+ const rowData = this.db.read(
2645
+ `SELECT * FROM ${this.quoteId(table)} WHERE "id" = '${this.escapeSql(rowId)}'`
2646
+ );
2647
+ if (rowData.length > 0) {
2648
+ const { row_hash, ...row } = rowData[0];
2649
+ rows.push(row);
2650
+ }
2651
+ }
2652
+ const payload = {
2653
+ table,
2654
+ block_index: blockIndex,
2655
+ block_size: blockSize,
2656
+ rows,
2657
+ client_row_ids: rowIds
2658
+ };
2659
+ const responseMap = await this.ws.sendRaw("merkle_push_blocks", payload, channelTopic);
2660
+ const response = {
2661
+ type: "merkle_push_blocks_response",
2662
+ table: responseMap.table,
2663
+ block_index: responseMap.block_index,
2664
+ applied: responseMap.applied ?? 0,
2665
+ rejected: responseMap.rejected ?? 0,
2666
+ deleted: responseMap.deleted ?? 0,
2667
+ errors: responseMap.errors ?? [],
2668
+ toMap() {
2669
+ return this;
2670
+ }
2671
+ };
2672
+ console.log(
2673
+ `[SyncClient] repairPush: ${table} block ${blockIndex} - applied: ${response.applied}, rejected: ${response.rejected}, deleted: ${response.deleted}`
2674
+ );
2675
+ if (response.errors.length > 0) {
2676
+ for (const error of response.errors) {
2677
+ console.warn(`[SyncClient] repairPush: ${table} block ${blockIndex} - error:`, error);
2678
+ }
2679
+ }
2680
+ }
2681
+ /**
2682
+ * Repair a table using last-write-wins (LWW) resolution.
2683
+ * Sends local rows to server which compares last_modified_ms timestamps.
2684
+ */
2685
+ async repairTableLww(table, blockSize, channelTopic, hashColumns, scopedRowIds) {
2686
+ const merkleComputer = new MerkleComputer(this.db);
2687
+ const effectiveScope = scopedRowIds != null && scopedRowIds.length === 0 ? void 0 : scopedRowIds;
2688
+ const blockHashes = await merkleComputer.merkleBlockHashes(table, blockSize, "id", hashColumns, effectiveScope);
2689
+ console.log(`[SyncClient] repairLww: ${table} has ${blockHashes.length} blocks`);
2690
+ const payload = {
2691
+ table,
2692
+ block_hashes: blockHashes,
2693
+ block_size: blockSize
2694
+ };
2695
+ const responseMap = await this.ws.sendRaw("merkle_block_hashes", payload, channelTopic);
2696
+ const differingBlocks = responseMap.differing_blocks;
2697
+ if (differingBlocks.length === 0) {
2698
+ console.log(`[SyncClient] repairLww: ${table} - no differing blocks`);
2699
+ return;
2700
+ }
2701
+ console.log(
2702
+ `[SyncClient] repairLww: ${table} - ${differingBlocks.length} differing blocks: ${differingBlocks}`
2703
+ );
2704
+ for (const blockIndex of differingBlocks) {
2705
+ await this.lwwResolveBlock(table, blockIndex, blockSize, channelTopic);
2706
+ }
2707
+ console.log(`[SyncClient] repairLww: ${table} - lww repair complete`);
2708
+ }
2709
+ /**
2710
+ * Resolve a single block using last-write-wins.
2711
+ */
2712
+ async lwwResolveBlock(table, blockIndex, blockSize, channelTopic) {
2713
+ const merkleComputer = new MerkleComputer(this.db);
2714
+ const rowIds = merkleComputer.getBlockRowIds(table, blockIndex, blockSize);
2715
+ const localRows = [];
2716
+ for (const rowId of rowIds) {
2717
+ const rowData = this.db.read(
2718
+ `SELECT * FROM ${this.quoteId(table)} WHERE "id" = '${this.escapeSql(rowId)}'`
2719
+ );
2720
+ if (rowData.length > 0) {
2721
+ const { row_hash, ...row } = rowData[0];
2722
+ localRows.push(row);
2723
+ }
2724
+ }
2725
+ const payload = {
2726
+ table,
2727
+ block_index: blockIndex,
2728
+ block_size: blockSize,
2729
+ rows: localRows,
2730
+ client_row_ids: rowIds
2731
+ };
2732
+ const responseMap = await this.ws.sendRaw("merkle_lww_blocks", payload, channelTopic);
2733
+ const response = {
2734
+ type: "merkle_lww_blocks_response",
2735
+ table: responseMap.table,
2736
+ block_index: responseMap.block_index,
2737
+ client_wins: responseMap.client_wins ?? [],
2738
+ server_wins: responseMap.server_wins ?? [],
2739
+ applied_from_client: responseMap.applied_from_client ?? 0,
2740
+ sent_to_client: responseMap.sent_to_client ?? 0,
2741
+ toMap() {
2742
+ return this;
2743
+ }
2744
+ };
2745
+ if (response.server_wins.length > 0) {
2746
+ this.db.beginBulkRemote();
2747
+ try {
2748
+ for (const row of response.server_wins) {
2749
+ const rowId = String(row.id ?? "");
2750
+ if (!rowId) continue;
2751
+ const isUpdate = rowIds.includes(rowId);
2752
+ const change = {
2753
+ type: "change",
2754
+ table,
2755
+ operation: isUpdate ? "update" : "insert",
2756
+ row_id: rowId,
2757
+ data: row,
2758
+ toMap() {
2759
+ return this;
2760
+ }
2761
+ };
2762
+ const sql = this.generateSql(change);
2763
+ this.db.execBulkRemote(sql);
2764
+ }
2765
+ await this.db.endBulkRemote();
2766
+ } catch (e) {
2767
+ await this.db.endBulkRemote(true);
2768
+ throw e;
2769
+ }
2770
+ }
2771
+ console.log(
2772
+ `[SyncClient] repairLww: ${table} block ${blockIndex} - client_wins: ${response.applied_from_client}, server_wins: ${response.server_wins.length}`
2773
+ );
2774
+ }
2775
+ // ==========================================================================
2776
+ // END MERKLE TREE INTEGRITY VERIFICATION
2777
+ // ==========================================================================
2778
+ /**
2779
+ * Dispose resources
2780
+ */
2781
+ async dispose() {
2782
+ this.stopPeriodicSync();
2783
+ if (this.syncOnWriteDebounceTimer) {
2784
+ clearTimeout(this.syncOnWriteDebounceTimer);
2785
+ }
2786
+ this.remoteChangeListeners.clear();
2787
+ this.snapshotCompleteListeners.clear();
2788
+ this.jobUpdateListeners.clear();
2789
+ this.livestreamListeners.clear();
2790
+ this.conversationListeners.clear();
2791
+ this.syncReadyStateListeners.clear();
2792
+ this.syncStateListeners.clear();
2793
+ this.syncProgressListeners.clear();
2794
+ this.syncCompleteListeners.clear();
2795
+ this.activeSyncCompleters.clear();
2796
+ this.merkleVerificationListeners.clear();
2797
+ await this.ws.dispose();
2798
+ }
2799
+ };
2800
+ export {
2801
+ ChannelRole,
2802
+ ConnectionState,
2803
+ MessageFactory,
2804
+ RepairDirection,
2805
+ SyncClient,
2806
+ SyncReadyState,
2807
+ SyncState,
2808
+ WebSocketManager
2809
+ };