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