@topgunbuild/client 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -203,6 +203,233 @@ var DEFAULT_BACKPRESSURE_CONFIG = {
203
203
  lowWaterMark: 0.5
204
204
  };
205
205
 
206
+ // src/connection/SingleServerProvider.ts
207
+ var DEFAULT_CONFIG = {
208
+ maxReconnectAttempts: 10,
209
+ reconnectDelayMs: 1e3,
210
+ backoffMultiplier: 2,
211
+ maxReconnectDelayMs: 3e4
212
+ };
213
+ var SingleServerProvider = class {
214
+ constructor(config) {
215
+ this.ws = null;
216
+ this.reconnectAttempts = 0;
217
+ this.reconnectTimer = null;
218
+ this.isClosing = false;
219
+ this.listeners = /* @__PURE__ */ new Map();
220
+ this.url = config.url;
221
+ this.config = {
222
+ url: config.url,
223
+ maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
224
+ reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
225
+ backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
226
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
227
+ };
228
+ }
229
+ /**
230
+ * Connect to the WebSocket server.
231
+ */
232
+ async connect() {
233
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
234
+ return;
235
+ }
236
+ this.isClosing = false;
237
+ return new Promise((resolve, reject) => {
238
+ try {
239
+ this.ws = new WebSocket(this.url);
240
+ this.ws.binaryType = "arraybuffer";
241
+ this.ws.onopen = () => {
242
+ this.reconnectAttempts = 0;
243
+ logger.info({ url: this.url }, "SingleServerProvider connected");
244
+ this.emit("connected", "default");
245
+ resolve();
246
+ };
247
+ this.ws.onerror = (error) => {
248
+ logger.error({ err: error, url: this.url }, "SingleServerProvider WebSocket error");
249
+ this.emit("error", error);
250
+ };
251
+ this.ws.onclose = (event) => {
252
+ logger.info({ url: this.url, code: event.code }, "SingleServerProvider disconnected");
253
+ this.emit("disconnected", "default");
254
+ if (!this.isClosing) {
255
+ this.scheduleReconnect();
256
+ }
257
+ };
258
+ this.ws.onmessage = (event) => {
259
+ this.emit("message", "default", event.data);
260
+ };
261
+ const timeoutId = setTimeout(() => {
262
+ if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
263
+ this.ws.close();
264
+ reject(new Error(`Connection timeout to ${this.url}`));
265
+ }
266
+ }, this.config.reconnectDelayMs * 5);
267
+ const originalOnOpen = this.ws.onopen;
268
+ const wsRef = this.ws;
269
+ this.ws.onopen = (ev) => {
270
+ clearTimeout(timeoutId);
271
+ if (originalOnOpen) {
272
+ originalOnOpen.call(wsRef, ev);
273
+ }
274
+ };
275
+ } catch (error) {
276
+ reject(error);
277
+ }
278
+ });
279
+ }
280
+ /**
281
+ * Get connection for a specific key.
282
+ * In single-server mode, key is ignored.
283
+ */
284
+ getConnection(_key) {
285
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
286
+ throw new Error("Not connected");
287
+ }
288
+ return this.ws;
289
+ }
290
+ /**
291
+ * Get any available connection.
292
+ */
293
+ getAnyConnection() {
294
+ return this.getConnection("");
295
+ }
296
+ /**
297
+ * Check if connected.
298
+ */
299
+ isConnected() {
300
+ return this.ws?.readyState === WebSocket.OPEN;
301
+ }
302
+ /**
303
+ * Get connected node IDs.
304
+ * Single-server mode returns ['default'] when connected.
305
+ */
306
+ getConnectedNodes() {
307
+ return this.isConnected() ? ["default"] : [];
308
+ }
309
+ /**
310
+ * Subscribe to connection events.
311
+ */
312
+ on(event, handler2) {
313
+ if (!this.listeners.has(event)) {
314
+ this.listeners.set(event, /* @__PURE__ */ new Set());
315
+ }
316
+ this.listeners.get(event).add(handler2);
317
+ }
318
+ /**
319
+ * Unsubscribe from connection events.
320
+ */
321
+ off(event, handler2) {
322
+ this.listeners.get(event)?.delete(handler2);
323
+ }
324
+ /**
325
+ * Send data via the WebSocket connection.
326
+ * In single-server mode, key parameter is ignored.
327
+ */
328
+ send(data, _key) {
329
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
330
+ throw new Error("Not connected");
331
+ }
332
+ this.ws.send(data);
333
+ }
334
+ /**
335
+ * Close the WebSocket connection.
336
+ */
337
+ async close() {
338
+ this.isClosing = true;
339
+ if (this.reconnectTimer) {
340
+ clearTimeout(this.reconnectTimer);
341
+ this.reconnectTimer = null;
342
+ }
343
+ if (this.ws) {
344
+ this.ws.onclose = null;
345
+ this.ws.onerror = null;
346
+ this.ws.onmessage = null;
347
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
348
+ this.ws.close();
349
+ }
350
+ this.ws = null;
351
+ }
352
+ logger.info({ url: this.url }, "SingleServerProvider closed");
353
+ }
354
+ /**
355
+ * Emit an event to all listeners.
356
+ */
357
+ emit(event, ...args) {
358
+ const handlers = this.listeners.get(event);
359
+ if (handlers) {
360
+ for (const handler2 of handlers) {
361
+ try {
362
+ handler2(...args);
363
+ } catch (err) {
364
+ logger.error({ err, event }, "Error in SingleServerProvider event handler");
365
+ }
366
+ }
367
+ }
368
+ }
369
+ /**
370
+ * Schedule a reconnection attempt with exponential backoff.
371
+ */
372
+ scheduleReconnect() {
373
+ if (this.reconnectTimer) {
374
+ clearTimeout(this.reconnectTimer);
375
+ this.reconnectTimer = null;
376
+ }
377
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
378
+ logger.error(
379
+ { attempts: this.reconnectAttempts, url: this.url },
380
+ "SingleServerProvider max reconnect attempts reached"
381
+ );
382
+ this.emit("error", new Error("Max reconnection attempts reached"));
383
+ return;
384
+ }
385
+ const delay = this.calculateBackoffDelay();
386
+ logger.info(
387
+ { delay, attempt: this.reconnectAttempts, url: this.url },
388
+ `SingleServerProvider scheduling reconnect in ${delay}ms`
389
+ );
390
+ this.reconnectTimer = setTimeout(async () => {
391
+ this.reconnectTimer = null;
392
+ this.reconnectAttempts++;
393
+ try {
394
+ await this.connect();
395
+ this.emit("reconnected", "default");
396
+ } catch (error) {
397
+ logger.error({ err: error }, "SingleServerProvider reconnection failed");
398
+ this.scheduleReconnect();
399
+ }
400
+ }, delay);
401
+ }
402
+ /**
403
+ * Calculate backoff delay with exponential increase.
404
+ */
405
+ calculateBackoffDelay() {
406
+ const { reconnectDelayMs, backoffMultiplier, maxReconnectDelayMs } = this.config;
407
+ let delay = reconnectDelayMs * Math.pow(backoffMultiplier, this.reconnectAttempts);
408
+ delay = Math.min(delay, maxReconnectDelayMs);
409
+ delay = delay * (0.5 + Math.random());
410
+ return Math.floor(delay);
411
+ }
412
+ /**
413
+ * Get the WebSocket URL this provider connects to.
414
+ */
415
+ getUrl() {
416
+ return this.url;
417
+ }
418
+ /**
419
+ * Get current reconnection attempt count.
420
+ */
421
+ getReconnectAttempts() {
422
+ return this.reconnectAttempts;
423
+ }
424
+ /**
425
+ * Reset reconnection counter.
426
+ * Called externally after successful authentication.
427
+ */
428
+ resetReconnectAttempts() {
429
+ this.reconnectAttempts = 0;
430
+ }
431
+ };
432
+
206
433
  // src/SyncEngine.ts
207
434
  var DEFAULT_BACKOFF_CONFIG = {
208
435
  initialDelayMs: 1e3,
@@ -232,8 +459,13 @@ var SyncEngine = class {
232
459
  this.waitingForCapacity = [];
233
460
  this.highWaterMarkEmitted = false;
234
461
  this.backpressureListeners = /* @__PURE__ */ new Map();
462
+ // Write Concern state (Phase 5.01)
463
+ this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
464
+ if (!config.serverUrl && !config.connectionProvider) {
465
+ throw new Error("SyncEngine requires either serverUrl or connectionProvider");
466
+ }
235
467
  this.nodeId = config.nodeId;
236
- this.serverUrl = config.serverUrl;
468
+ this.serverUrl = config.serverUrl || "";
237
469
  this.storageAdapter = config.storageAdapter;
238
470
  this.hlc = new HLC(this.nodeId);
239
471
  this.stateMachine = new SyncStateMachine();
@@ -250,7 +482,15 @@ var SyncEngine = class {
250
482
  ...DEFAULT_BACKPRESSURE_CONFIG,
251
483
  ...config.backpressure
252
484
  };
253
- this.initConnection();
485
+ if (config.connectionProvider) {
486
+ this.connectionProvider = config.connectionProvider;
487
+ this.useConnectionProvider = true;
488
+ this.initConnectionProvider();
489
+ } else {
490
+ this.connectionProvider = new SingleServerProvider({ url: config.serverUrl });
491
+ this.useConnectionProvider = false;
492
+ this.initConnection();
493
+ }
254
494
  this.loadOpLog();
255
495
  }
256
496
  // ============================================
@@ -301,6 +541,65 @@ var SyncEngine = class {
301
541
  // ============================================
302
542
  // Connection Management
303
543
  // ============================================
544
+ /**
545
+ * Initialize connection using IConnectionProvider (Phase 4.5 cluster mode).
546
+ * Sets up event handlers for the connection provider.
547
+ */
548
+ initConnectionProvider() {
549
+ this.stateMachine.transition("CONNECTING" /* CONNECTING */);
550
+ this.connectionProvider.on("connected", (_nodeId) => {
551
+ if (this.authToken || this.tokenProvider) {
552
+ logger.info("ConnectionProvider connected. Sending auth...");
553
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
554
+ this.sendAuth();
555
+ } else {
556
+ logger.info("ConnectionProvider connected. Waiting for auth token...");
557
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
558
+ }
559
+ });
560
+ this.connectionProvider.on("disconnected", (_nodeId) => {
561
+ logger.info("ConnectionProvider disconnected.");
562
+ this.stopHeartbeat();
563
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
564
+ });
565
+ this.connectionProvider.on("reconnected", (_nodeId) => {
566
+ logger.info("ConnectionProvider reconnected.");
567
+ this.stateMachine.transition("CONNECTING" /* CONNECTING */);
568
+ if (this.authToken || this.tokenProvider) {
569
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
570
+ this.sendAuth();
571
+ }
572
+ });
573
+ this.connectionProvider.on("message", (_nodeId, data) => {
574
+ let message;
575
+ if (data instanceof ArrayBuffer) {
576
+ message = deserialize(new Uint8Array(data));
577
+ } else if (data instanceof Uint8Array) {
578
+ message = deserialize(data);
579
+ } else {
580
+ try {
581
+ message = typeof data === "string" ? JSON.parse(data) : data;
582
+ } catch (e) {
583
+ logger.error({ err: e }, "Failed to parse message from ConnectionProvider");
584
+ return;
585
+ }
586
+ }
587
+ this.handleServerMessage(message);
588
+ });
589
+ this.connectionProvider.on("partitionMapUpdated", () => {
590
+ logger.debug("Partition map updated");
591
+ });
592
+ this.connectionProvider.on("error", (error) => {
593
+ logger.error({ err: error }, "ConnectionProvider error");
594
+ });
595
+ this.connectionProvider.connect().catch((err) => {
596
+ logger.error({ err }, "Failed to connect via ConnectionProvider");
597
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
598
+ });
599
+ }
600
+ /**
601
+ * Initialize connection using direct WebSocket (legacy single-server mode).
602
+ */
304
603
  initConnection() {
305
604
  this.stateMachine.transition("CONNECTING" /* CONNECTING */);
306
605
  this.websocket = new WebSocket(this.serverUrl);
@@ -376,6 +675,40 @@ var SyncEngine = class {
376
675
  resetBackoff() {
377
676
  this.backoffAttempt = 0;
378
677
  }
678
+ /**
679
+ * Send a message through the current connection.
680
+ * Uses connectionProvider if in cluster mode, otherwise uses direct websocket.
681
+ * @param message Message object to serialize and send
682
+ * @param key Optional key for routing (cluster mode only)
683
+ * @returns true if message was sent, false otherwise
684
+ */
685
+ sendMessage(message, key) {
686
+ const data = serialize(message);
687
+ if (this.useConnectionProvider) {
688
+ try {
689
+ this.connectionProvider.send(data, key);
690
+ return true;
691
+ } catch (err) {
692
+ logger.warn({ err }, "Failed to send via ConnectionProvider");
693
+ return false;
694
+ }
695
+ } else {
696
+ if (this.websocket?.readyState === WebSocket.OPEN) {
697
+ this.websocket.send(data);
698
+ return true;
699
+ }
700
+ return false;
701
+ }
702
+ }
703
+ /**
704
+ * Check if we can send messages (connection is ready).
705
+ */
706
+ canSend() {
707
+ if (this.useConnectionProvider) {
708
+ return this.connectionProvider.isConnected();
709
+ }
710
+ return this.websocket?.readyState === WebSocket.OPEN;
711
+ }
379
712
  async loadOpLog() {
380
713
  const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
381
714
  if (storedTimestamp) {
@@ -422,36 +755,34 @@ var SyncEngine = class {
422
755
  const pending = this.opLog.filter((op) => !op.synced);
423
756
  if (pending.length === 0) return;
424
757
  logger.info({ count: pending.length }, "Syncing pending operations");
425
- if (this.websocket?.readyState === WebSocket.OPEN) {
426
- this.websocket.send(serialize({
427
- type: "OP_BATCH",
428
- payload: {
429
- ops: pending
430
- }
431
- }));
432
- }
758
+ this.sendMessage({
759
+ type: "OP_BATCH",
760
+ payload: {
761
+ ops: pending
762
+ }
763
+ });
433
764
  }
434
765
  startMerkleSync() {
435
766
  for (const [mapName, map] of this.maps) {
436
767
  if (map instanceof LWWMap) {
437
768
  logger.info({ mapName }, "Starting Merkle sync for LWWMap");
438
- this.websocket?.send(serialize({
769
+ this.sendMessage({
439
770
  type: "SYNC_INIT",
440
771
  mapName,
441
772
  lastSyncTimestamp: this.lastSyncTimestamp
442
- }));
773
+ });
443
774
  } else if (map instanceof ORMap) {
444
775
  logger.info({ mapName }, "Starting Merkle sync for ORMap");
445
776
  const tree = map.getMerkleTree();
446
777
  const rootHash = tree.getRootHash();
447
778
  const bucketHashes = tree.getBuckets("");
448
- this.websocket?.send(serialize({
779
+ this.sendMessage({
449
780
  type: "ORMAP_SYNC_INIT",
450
781
  mapName,
451
782
  rootHash,
452
783
  bucketHashes,
453
784
  lastSyncTimestamp: this.lastSyncTimestamp
454
- }));
785
+ });
455
786
  }
456
787
  }
457
788
  }
@@ -492,10 +823,10 @@ var SyncEngine = class {
492
823
  }
493
824
  const token = this.authToken;
494
825
  if (!token) return;
495
- this.websocket?.send(serialize({
826
+ this.sendMessage({
496
827
  type: "AUTH",
497
828
  token
498
- }));
829
+ });
499
830
  }
500
831
  subscribeToQuery(query) {
501
832
  this.queries.set(query.id, query);
@@ -512,27 +843,27 @@ var SyncEngine = class {
512
843
  unsubscribeFromTopic(topic) {
513
844
  this.topics.delete(topic);
514
845
  if (this.isAuthenticated()) {
515
- this.websocket?.send(serialize({
846
+ this.sendMessage({
516
847
  type: "TOPIC_UNSUB",
517
848
  payload: { topic }
518
- }));
849
+ });
519
850
  }
520
851
  }
521
852
  publishTopic(topic, data) {
522
853
  if (this.isAuthenticated()) {
523
- this.websocket?.send(serialize({
854
+ this.sendMessage({
524
855
  type: "TOPIC_PUB",
525
856
  payload: { topic, data }
526
- }));
857
+ });
527
858
  } else {
528
859
  logger.warn({ topic }, "Dropped topic publish (offline)");
529
860
  }
530
861
  }
531
862
  sendTopicSubscription(topic) {
532
- this.websocket?.send(serialize({
863
+ this.sendMessage({
533
864
  type: "TOPIC_SUB",
534
865
  payload: { topic }
535
- }));
866
+ });
536
867
  }
537
868
  /**
538
869
  * Executes a query against local storage immediately
@@ -569,21 +900,21 @@ var SyncEngine = class {
569
900
  unsubscribeFromQuery(queryId) {
570
901
  this.queries.delete(queryId);
571
902
  if (this.isAuthenticated()) {
572
- this.websocket?.send(serialize({
903
+ this.sendMessage({
573
904
  type: "QUERY_UNSUB",
574
905
  payload: { queryId }
575
- }));
906
+ });
576
907
  }
577
908
  }
578
909
  sendQuerySubscription(query) {
579
- this.websocket?.send(serialize({
910
+ this.sendMessage({
580
911
  type: "QUERY_SUB",
581
912
  payload: {
582
913
  queryId: query.id,
583
914
  mapName: query.getMapName(),
584
915
  query: query.getFilter()
585
916
  }
586
- }));
917
+ });
587
918
  }
588
919
  requestLock(name, requestId, ttl) {
589
920
  if (!this.isAuthenticated()) {
@@ -598,10 +929,15 @@ var SyncEngine = class {
598
929
  }, 3e4);
599
930
  this.pendingLockRequests.set(requestId, { resolve, reject, timer });
600
931
  try {
601
- this.websocket?.send(serialize({
932
+ const sent = this.sendMessage({
602
933
  type: "LOCK_REQUEST",
603
934
  payload: { requestId, name, ttl }
604
- }));
935
+ });
936
+ if (!sent) {
937
+ clearTimeout(timer);
938
+ this.pendingLockRequests.delete(requestId);
939
+ reject(new Error("Failed to send lock request"));
940
+ }
605
941
  } catch (e) {
606
942
  clearTimeout(timer);
607
943
  this.pendingLockRequests.delete(requestId);
@@ -620,10 +956,15 @@ var SyncEngine = class {
620
956
  }, 5e3);
621
957
  this.pendingLockRequests.set(requestId, { resolve, reject, timer });
622
958
  try {
623
- this.websocket?.send(serialize({
959
+ const sent = this.sendMessage({
624
960
  type: "LOCK_RELEASE",
625
961
  payload: { requestId, name, fencingToken }
626
- }));
962
+ });
963
+ if (!sent) {
964
+ clearTimeout(timer);
965
+ this.pendingLockRequests.delete(requestId);
966
+ resolve(false);
967
+ }
627
968
  } catch (e) {
628
969
  clearTimeout(timer);
629
970
  this.pendingLockRequests.delete(requestId);
@@ -633,6 +974,22 @@ var SyncEngine = class {
633
974
  }
634
975
  async handleServerMessage(message) {
635
976
  switch (message.type) {
977
+ case "BATCH": {
978
+ const batchData = message.data;
979
+ const view = new DataView(batchData.buffer, batchData.byteOffset, batchData.byteLength);
980
+ let offset = 0;
981
+ const count = view.getUint32(offset, true);
982
+ offset += 4;
983
+ for (let i = 0; i < count; i++) {
984
+ const msgLen = view.getUint32(offset, true);
985
+ offset += 4;
986
+ const msgData = batchData.slice(offset, offset + msgLen);
987
+ offset += msgLen;
988
+ const innerMsg = deserialize(msgData);
989
+ await this.handleServerMessage(innerMsg);
990
+ }
991
+ break;
992
+ }
636
993
  case "AUTH_REQUIRED":
637
994
  this.sendAuth();
638
995
  break;
@@ -664,8 +1021,18 @@ var SyncEngine = class {
664
1021
  this.authToken = null;
665
1022
  break;
666
1023
  case "OP_ACK": {
667
- const { lastId } = message.payload;
668
- logger.info({ lastId }, "Received ACK for ops");
1024
+ const { lastId, achievedLevel, results } = message.payload;
1025
+ logger.info({ lastId, achievedLevel, hasResults: !!results }, "Received ACK for ops");
1026
+ if (results && Array.isArray(results)) {
1027
+ for (const result of results) {
1028
+ const op = this.opLog.find((o) => o.id === result.opId);
1029
+ if (op && !op.synced) {
1030
+ op.synced = true;
1031
+ logger.debug({ opId: result.opId, achievedLevel: result.achievedLevel, success: result.success }, "Op ACK with Write Concern");
1032
+ }
1033
+ this.resolveWriteConcernPromise(result.opId, result);
1034
+ }
1035
+ }
669
1036
  let maxSyncedId = -1;
670
1037
  let ackedCount = 0;
671
1038
  this.opLog.forEach((op) => {
@@ -726,18 +1093,20 @@ var SyncEngine = class {
726
1093
  }
727
1094
  case "SERVER_EVENT": {
728
1095
  const { mapName, eventType, key, record, orRecord, orTag } = message.payload;
729
- const localMap = this.maps.get(mapName);
730
- if (localMap) {
731
- if (localMap instanceof LWWMap && record) {
732
- localMap.merge(key, record);
733
- await this.storageAdapter.put(`${mapName}:${key}`, record);
734
- } else if (localMap instanceof ORMap) {
735
- if (eventType === "OR_ADD" && orRecord) {
736
- localMap.apply(key, orRecord);
737
- } else if (eventType === "OR_REMOVE" && orTag) {
738
- localMap.applyTombstone(orTag);
739
- }
740
- }
1096
+ await this.applyServerEvent(mapName, eventType, key, record, orRecord, orTag);
1097
+ break;
1098
+ }
1099
+ case "SERVER_BATCH_EVENT": {
1100
+ const { events } = message.payload;
1101
+ for (const event of events) {
1102
+ await this.applyServerEvent(
1103
+ event.mapName,
1104
+ event.eventType,
1105
+ event.key,
1106
+ event.record,
1107
+ event.orRecord,
1108
+ event.orTag
1109
+ );
741
1110
  }
742
1111
  break;
743
1112
  }
@@ -774,11 +1143,11 @@ var SyncEngine = class {
774
1143
  const { mapName } = message.payload;
775
1144
  logger.warn({ mapName }, "Sync Reset Required due to GC Age");
776
1145
  await this.resetMap(mapName);
777
- this.websocket?.send(serialize({
1146
+ this.sendMessage({
778
1147
  type: "SYNC_INIT",
779
1148
  mapName,
780
1149
  lastSyncTimestamp: 0
781
- }));
1150
+ });
782
1151
  break;
783
1152
  }
784
1153
  case "SYNC_RESP_ROOT": {
@@ -788,10 +1157,10 @@ var SyncEngine = class {
788
1157
  const localRootHash = map.getMerkleTree().getRootHash();
789
1158
  if (localRootHash !== rootHash) {
790
1159
  logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
791
- this.websocket?.send(serialize({
1160
+ this.sendMessage({
792
1161
  type: "MERKLE_REQ_BUCKET",
793
1162
  payload: { mapName, path: "" }
794
- }));
1163
+ });
795
1164
  } else {
796
1165
  logger.info({ mapName }, "Map is in sync");
797
1166
  }
@@ -813,10 +1182,10 @@ var SyncEngine = class {
813
1182
  const localHash = localBuckets[bucketKey] || 0;
814
1183
  if (localHash !== remoteHash) {
815
1184
  const newPath = path + bucketKey;
816
- this.websocket?.send(serialize({
1185
+ this.sendMessage({
817
1186
  type: "MERKLE_REQ_BUCKET",
818
1187
  payload: { mapName, path: newPath }
819
- }));
1188
+ });
820
1189
  }
821
1190
  }
822
1191
  }
@@ -849,10 +1218,10 @@ var SyncEngine = class {
849
1218
  const localRootHash = localTree.getRootHash();
850
1219
  if (localRootHash !== rootHash) {
851
1220
  logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
852
- this.websocket?.send(serialize({
1221
+ this.sendMessage({
853
1222
  type: "ORMAP_MERKLE_REQ_BUCKET",
854
1223
  payload: { mapName, path: "" }
855
- }));
1224
+ });
856
1225
  } else {
857
1226
  logger.info({ mapName }, "ORMap is in sync");
858
1227
  }
@@ -874,10 +1243,10 @@ var SyncEngine = class {
874
1243
  const localHash = localBuckets[bucketKey] || 0;
875
1244
  if (localHash !== remoteHash) {
876
1245
  const newPath = path + bucketKey;
877
- this.websocket?.send(serialize({
1246
+ this.sendMessage({
878
1247
  type: "ORMAP_MERKLE_REQ_BUCKET",
879
1248
  payload: { mapName, path: newPath }
880
- }));
1249
+ });
881
1250
  }
882
1251
  }
883
1252
  for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
@@ -940,6 +1309,25 @@ var SyncEngine = class {
940
1309
  getHLC() {
941
1310
  return this.hlc;
942
1311
  }
1312
+ /**
1313
+ * Helper method to apply a single server event to the local map.
1314
+ * Used by both SERVER_EVENT and SERVER_BATCH_EVENT handlers.
1315
+ */
1316
+ async applyServerEvent(mapName, eventType, key, record, orRecord, orTag) {
1317
+ const localMap = this.maps.get(mapName);
1318
+ if (localMap) {
1319
+ if (localMap instanceof LWWMap && record) {
1320
+ localMap.merge(key, record);
1321
+ await this.storageAdapter.put(`${mapName}:${key}`, record);
1322
+ } else if (localMap instanceof ORMap) {
1323
+ if (eventType === "OR_ADD" && orRecord) {
1324
+ localMap.apply(key, orRecord);
1325
+ } else if (eventType === "OR_REMOVE" && orTag) {
1326
+ localMap.applyTombstone(orTag);
1327
+ }
1328
+ }
1329
+ }
1330
+ }
943
1331
  /**
944
1332
  * Closes the WebSocket connection and cleans up resources.
945
1333
  */
@@ -949,11 +1337,16 @@ var SyncEngine = class {
949
1337
  clearTimeout(this.reconnectTimer);
950
1338
  this.reconnectTimer = null;
951
1339
  }
952
- if (this.websocket) {
1340
+ if (this.useConnectionProvider) {
1341
+ this.connectionProvider.close().catch((err) => {
1342
+ logger.error({ err }, "Error closing ConnectionProvider");
1343
+ });
1344
+ } else if (this.websocket) {
953
1345
  this.websocket.onclose = null;
954
1346
  this.websocket.close();
955
1347
  this.websocket = null;
956
1348
  }
1349
+ this.cancelAllWriteConcernPromises(new Error("SyncEngine closed"));
957
1350
  this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
958
1351
  logger.info("SyncEngine closed");
959
1352
  }
@@ -965,7 +1358,100 @@ var SyncEngine = class {
965
1358
  this.close();
966
1359
  this.stateMachine.reset();
967
1360
  this.resetBackoff();
968
- this.initConnection();
1361
+ if (this.useConnectionProvider) {
1362
+ this.initConnectionProvider();
1363
+ } else {
1364
+ this.initConnection();
1365
+ }
1366
+ }
1367
+ // ============================================
1368
+ // Failover Support Methods (Phase 4.5 Task 05)
1369
+ // ============================================
1370
+ /**
1371
+ * Wait for a partition map update from the connection provider.
1372
+ * Used when an operation fails with NOT_OWNER error and needs
1373
+ * to wait for an updated partition map before retrying.
1374
+ *
1375
+ * @param timeoutMs - Maximum time to wait (default: 5000ms)
1376
+ * @returns Promise that resolves when partition map is updated or times out
1377
+ */
1378
+ waitForPartitionMapUpdate(timeoutMs = 5e3) {
1379
+ return new Promise((resolve) => {
1380
+ const timeout = setTimeout(resolve, timeoutMs);
1381
+ const handler2 = () => {
1382
+ clearTimeout(timeout);
1383
+ this.connectionProvider.off("partitionMapUpdated", handler2);
1384
+ resolve();
1385
+ };
1386
+ this.connectionProvider.on("partitionMapUpdated", handler2);
1387
+ });
1388
+ }
1389
+ /**
1390
+ * Wait for the connection to be available.
1391
+ * Used when an operation fails due to connection issues and needs
1392
+ * to wait for reconnection before retrying.
1393
+ *
1394
+ * @param timeoutMs - Maximum time to wait (default: 10000ms)
1395
+ * @returns Promise that resolves when connected or rejects on timeout
1396
+ */
1397
+ waitForConnection(timeoutMs = 1e4) {
1398
+ return new Promise((resolve, reject) => {
1399
+ if (this.connectionProvider.isConnected()) {
1400
+ resolve();
1401
+ return;
1402
+ }
1403
+ const timeout = setTimeout(() => {
1404
+ this.connectionProvider.off("connected", handler2);
1405
+ reject(new Error("Connection timeout waiting for reconnection"));
1406
+ }, timeoutMs);
1407
+ const handler2 = () => {
1408
+ clearTimeout(timeout);
1409
+ this.connectionProvider.off("connected", handler2);
1410
+ resolve();
1411
+ };
1412
+ this.connectionProvider.on("connected", handler2);
1413
+ });
1414
+ }
1415
+ /**
1416
+ * Wait for a specific sync state.
1417
+ * Useful for waiting until fully connected and synced.
1418
+ *
1419
+ * @param targetState - The state to wait for
1420
+ * @param timeoutMs - Maximum time to wait (default: 30000ms)
1421
+ * @returns Promise that resolves when state is reached or rejects on timeout
1422
+ */
1423
+ waitForState(targetState, timeoutMs = 3e4) {
1424
+ return new Promise((resolve, reject) => {
1425
+ if (this.stateMachine.getState() === targetState) {
1426
+ resolve();
1427
+ return;
1428
+ }
1429
+ const timeout = setTimeout(() => {
1430
+ unsubscribe();
1431
+ reject(new Error(`Timeout waiting for state ${targetState}`));
1432
+ }, timeoutMs);
1433
+ const unsubscribe = this.stateMachine.onStateChange((event) => {
1434
+ if (event.to === targetState) {
1435
+ clearTimeout(timeout);
1436
+ unsubscribe();
1437
+ resolve();
1438
+ }
1439
+ });
1440
+ });
1441
+ }
1442
+ /**
1443
+ * Check if the connection provider is connected.
1444
+ * Convenience method for failover logic.
1445
+ */
1446
+ isProviderConnected() {
1447
+ return this.connectionProvider.isConnected();
1448
+ }
1449
+ /**
1450
+ * Get the connection provider for direct access.
1451
+ * Use with caution - prefer using SyncEngine methods.
1452
+ */
1453
+ getConnectionProvider() {
1454
+ return this.connectionProvider;
969
1455
  }
970
1456
  async resetMap(mapName) {
971
1457
  const map = this.maps.get(mapName);
@@ -1013,12 +1499,12 @@ var SyncEngine = class {
1013
1499
  * Sends a PING message to the server.
1014
1500
  */
1015
1501
  sendPing() {
1016
- if (this.websocket?.readyState === WebSocket.OPEN) {
1502
+ if (this.canSend()) {
1017
1503
  const pingMessage = {
1018
1504
  type: "PING",
1019
1505
  timestamp: Date.now()
1020
1506
  };
1021
- this.websocket.send(serialize(pingMessage));
1507
+ this.sendMessage(pingMessage);
1022
1508
  }
1023
1509
  }
1024
1510
  /**
@@ -1097,13 +1583,13 @@ var SyncEngine = class {
1097
1583
  }
1098
1584
  }
1099
1585
  if (entries.length > 0) {
1100
- this.websocket?.send(serialize({
1586
+ this.sendMessage({
1101
1587
  type: "ORMAP_PUSH_DIFF",
1102
1588
  payload: {
1103
1589
  mapName,
1104
1590
  entries
1105
1591
  }
1106
- }));
1592
+ });
1107
1593
  logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
1108
1594
  }
1109
1595
  }
@@ -1274,21 +1760,73 @@ var SyncEngine = class {
1274
1760
  });
1275
1761
  }
1276
1762
  }
1277
- };
1278
-
1279
- // src/TopGunClient.ts
1280
- import { LWWMap as LWWMap2, ORMap as ORMap2 } from "@topgunbuild/core";
1281
-
1282
- // src/QueryHandle.ts
1283
- var QueryHandle = class {
1284
- constructor(syncEngine, mapName, filter = {}) {
1285
- this.listeners = /* @__PURE__ */ new Set();
1286
- this.currentResults = /* @__PURE__ */ new Map();
1287
- // Track if we've received authoritative server response
1288
- this.hasReceivedServerData = false;
1289
- this.id = crypto.randomUUID();
1290
- this.syncEngine = syncEngine;
1291
- this.mapName = mapName;
1763
+ // ============================================
1764
+ // Write Concern Methods (Phase 5.01)
1765
+ // ============================================
1766
+ /**
1767
+ * Register a pending Write Concern promise for an operation.
1768
+ * The promise will be resolved when the server sends an ACK with the operation result.
1769
+ *
1770
+ * @param opId - Operation ID
1771
+ * @param timeout - Timeout in ms (default: 5000)
1772
+ * @returns Promise that resolves with the Write Concern result
1773
+ */
1774
+ registerWriteConcernPromise(opId, timeout = 5e3) {
1775
+ return new Promise((resolve, reject) => {
1776
+ const timeoutHandle = setTimeout(() => {
1777
+ this.pendingWriteConcernPromises.delete(opId);
1778
+ reject(new Error(`Write Concern timeout for operation ${opId}`));
1779
+ }, timeout);
1780
+ this.pendingWriteConcernPromises.set(opId, {
1781
+ resolve,
1782
+ reject,
1783
+ timeoutHandle
1784
+ });
1785
+ });
1786
+ }
1787
+ /**
1788
+ * Resolve a pending Write Concern promise with the server result.
1789
+ *
1790
+ * @param opId - Operation ID
1791
+ * @param result - Result from server ACK
1792
+ */
1793
+ resolveWriteConcernPromise(opId, result) {
1794
+ const pending = this.pendingWriteConcernPromises.get(opId);
1795
+ if (pending) {
1796
+ if (pending.timeoutHandle) {
1797
+ clearTimeout(pending.timeoutHandle);
1798
+ }
1799
+ pending.resolve(result);
1800
+ this.pendingWriteConcernPromises.delete(opId);
1801
+ }
1802
+ }
1803
+ /**
1804
+ * Cancel all pending Write Concern promises (e.g., on disconnect).
1805
+ */
1806
+ cancelAllWriteConcernPromises(error) {
1807
+ for (const [opId, pending] of this.pendingWriteConcernPromises.entries()) {
1808
+ if (pending.timeoutHandle) {
1809
+ clearTimeout(pending.timeoutHandle);
1810
+ }
1811
+ pending.reject(error);
1812
+ }
1813
+ this.pendingWriteConcernPromises.clear();
1814
+ }
1815
+ };
1816
+
1817
+ // src/TopGunClient.ts
1818
+ import { LWWMap as LWWMap2, ORMap as ORMap2 } from "@topgunbuild/core";
1819
+
1820
+ // src/QueryHandle.ts
1821
+ var QueryHandle = class {
1822
+ constructor(syncEngine, mapName, filter = {}) {
1823
+ this.listeners = /* @__PURE__ */ new Set();
1824
+ this.currentResults = /* @__PURE__ */ new Map();
1825
+ // Track if we've received authoritative server response
1826
+ this.hasReceivedServerData = false;
1827
+ this.id = crypto.randomUUID();
1828
+ this.syncEngine = syncEngine;
1829
+ this.mapName = mapName;
1292
1830
  this.filter = filter;
1293
1831
  }
1294
1832
  subscribe(callback) {
@@ -1347,152 +1885,1626 @@ var QueryHandle = class {
1347
1885
  this.currentResults.delete(key);
1348
1886
  }
1349
1887
  }
1350
- if (removedKeys.length > 0) {
1351
- console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
1352
- }
1353
- for (const item of items) {
1354
- this.currentResults.set(item.key, item.value);
1888
+ if (removedKeys.length > 0) {
1889
+ console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
1890
+ }
1891
+ for (const item of items) {
1892
+ this.currentResults.set(item.key, item.value);
1893
+ }
1894
+ console.log(`[QueryHandle:${this.mapName}] After merge: ${this.currentResults.size} results`);
1895
+ this.notify();
1896
+ }
1897
+ /**
1898
+ * Called by SyncEngine when server sends a live update
1899
+ */
1900
+ onUpdate(key, value) {
1901
+ if (value === null) {
1902
+ this.currentResults.delete(key);
1903
+ } else {
1904
+ this.currentResults.set(key, value);
1905
+ }
1906
+ this.notify();
1907
+ }
1908
+ notify() {
1909
+ const results = this.getSortedResults();
1910
+ for (const listener of this.listeners) {
1911
+ listener(results);
1912
+ }
1913
+ }
1914
+ getSortedResults() {
1915
+ const results = Array.from(this.currentResults.entries()).map(
1916
+ ([key, value]) => ({ ...value, _key: key })
1917
+ );
1918
+ if (this.filter.sort) {
1919
+ results.sort((a, b) => {
1920
+ for (const [field, direction] of Object.entries(this.filter.sort)) {
1921
+ const valA = a[field];
1922
+ const valB = b[field];
1923
+ if (valA < valB) return direction === "asc" ? -1 : 1;
1924
+ if (valA > valB) return direction === "asc" ? 1 : -1;
1925
+ }
1926
+ return 0;
1927
+ });
1928
+ }
1929
+ return results;
1930
+ }
1931
+ getFilter() {
1932
+ return this.filter;
1933
+ }
1934
+ getMapName() {
1935
+ return this.mapName;
1936
+ }
1937
+ };
1938
+
1939
+ // src/DistributedLock.ts
1940
+ var DistributedLock = class {
1941
+ constructor(syncEngine, name) {
1942
+ this.fencingToken = null;
1943
+ this._isLocked = false;
1944
+ this.syncEngine = syncEngine;
1945
+ this.name = name;
1946
+ }
1947
+ async lock(ttl = 1e4) {
1948
+ const requestId = crypto.randomUUID();
1949
+ try {
1950
+ const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
1951
+ this.fencingToken = result.fencingToken;
1952
+ this._isLocked = true;
1953
+ return true;
1954
+ } catch (e) {
1955
+ return false;
1956
+ }
1957
+ }
1958
+ async unlock() {
1959
+ if (!this._isLocked || this.fencingToken === null) return;
1960
+ const requestId = crypto.randomUUID();
1961
+ try {
1962
+ await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
1963
+ } finally {
1964
+ this._isLocked = false;
1965
+ this.fencingToken = null;
1966
+ }
1967
+ }
1968
+ isLocked() {
1969
+ return this._isLocked;
1970
+ }
1971
+ };
1972
+
1973
+ // src/TopicHandle.ts
1974
+ var TopicHandle = class {
1975
+ constructor(engine, topic) {
1976
+ this.listeners = /* @__PURE__ */ new Set();
1977
+ this.engine = engine;
1978
+ this.topic = topic;
1979
+ }
1980
+ get id() {
1981
+ return this.topic;
1982
+ }
1983
+ /**
1984
+ * Publish a message to the topic
1985
+ */
1986
+ publish(data) {
1987
+ this.engine.publishTopic(this.topic, data);
1988
+ }
1989
+ /**
1990
+ * Subscribe to the topic
1991
+ */
1992
+ subscribe(callback) {
1993
+ if (this.listeners.size === 0) {
1994
+ this.engine.subscribeToTopic(this.topic, this);
1995
+ }
1996
+ this.listeners.add(callback);
1997
+ return () => this.unsubscribe(callback);
1998
+ }
1999
+ unsubscribe(callback) {
2000
+ this.listeners.delete(callback);
2001
+ if (this.listeners.size === 0) {
2002
+ this.engine.unsubscribeFromTopic(this.topic);
2003
+ }
2004
+ }
2005
+ /**
2006
+ * Called by SyncEngine when a message is received
2007
+ */
2008
+ onMessage(data, context) {
2009
+ this.listeners.forEach((cb) => {
2010
+ try {
2011
+ cb(data, context);
2012
+ } catch (e) {
2013
+ console.error("Error in topic listener", e);
2014
+ }
2015
+ });
2016
+ }
2017
+ };
2018
+
2019
+ // src/cluster/ClusterClient.ts
2020
+ import {
2021
+ DEFAULT_CONNECTION_POOL_CONFIG as DEFAULT_CONNECTION_POOL_CONFIG2,
2022
+ DEFAULT_PARTITION_ROUTER_CONFIG as DEFAULT_PARTITION_ROUTER_CONFIG2,
2023
+ DEFAULT_CIRCUIT_BREAKER_CONFIG,
2024
+ serialize as serialize3
2025
+ } from "@topgunbuild/core";
2026
+
2027
+ // src/cluster/ConnectionPool.ts
2028
+ import {
2029
+ DEFAULT_CONNECTION_POOL_CONFIG
2030
+ } from "@topgunbuild/core";
2031
+ import { serialize as serialize2, deserialize as deserialize2 } from "@topgunbuild/core";
2032
+ var ConnectionPool = class {
2033
+ constructor(config = {}) {
2034
+ this.listeners = /* @__PURE__ */ new Map();
2035
+ this.connections = /* @__PURE__ */ new Map();
2036
+ this.primaryNodeId = null;
2037
+ this.healthCheckTimer = null;
2038
+ this.authToken = null;
2039
+ this.config = {
2040
+ ...DEFAULT_CONNECTION_POOL_CONFIG,
2041
+ ...config
2042
+ };
2043
+ }
2044
+ // ============================================
2045
+ // Event Emitter Methods (browser-compatible)
2046
+ // ============================================
2047
+ on(event, listener) {
2048
+ if (!this.listeners.has(event)) {
2049
+ this.listeners.set(event, /* @__PURE__ */ new Set());
2050
+ }
2051
+ this.listeners.get(event).add(listener);
2052
+ return this;
2053
+ }
2054
+ off(event, listener) {
2055
+ this.listeners.get(event)?.delete(listener);
2056
+ return this;
2057
+ }
2058
+ emit(event, ...args) {
2059
+ const eventListeners = this.listeners.get(event);
2060
+ if (!eventListeners || eventListeners.size === 0) {
2061
+ return false;
2062
+ }
2063
+ for (const listener of eventListeners) {
2064
+ try {
2065
+ listener(...args);
2066
+ } catch (err) {
2067
+ logger.error({ event, err }, "Error in event listener");
2068
+ }
2069
+ }
2070
+ return true;
2071
+ }
2072
+ removeAllListeners(event) {
2073
+ if (event) {
2074
+ this.listeners.delete(event);
2075
+ } else {
2076
+ this.listeners.clear();
2077
+ }
2078
+ return this;
2079
+ }
2080
+ /**
2081
+ * Set authentication token for all connections
2082
+ */
2083
+ setAuthToken(token) {
2084
+ this.authToken = token;
2085
+ for (const conn of this.connections.values()) {
2086
+ if (conn.state === "CONNECTED") {
2087
+ this.sendAuth(conn);
2088
+ }
2089
+ }
2090
+ }
2091
+ /**
2092
+ * Add a node to the connection pool
2093
+ */
2094
+ async addNode(nodeId, endpoint) {
2095
+ if (this.connections.has(nodeId)) {
2096
+ const existing = this.connections.get(nodeId);
2097
+ if (existing.endpoint !== endpoint) {
2098
+ await this.removeNode(nodeId);
2099
+ } else {
2100
+ return;
2101
+ }
2102
+ }
2103
+ const connection = {
2104
+ nodeId,
2105
+ endpoint,
2106
+ socket: null,
2107
+ state: "DISCONNECTED",
2108
+ lastSeen: 0,
2109
+ latencyMs: 0,
2110
+ reconnectAttempts: 0,
2111
+ reconnectTimer: null,
2112
+ pendingMessages: []
2113
+ };
2114
+ this.connections.set(nodeId, connection);
2115
+ if (!this.primaryNodeId) {
2116
+ this.primaryNodeId = nodeId;
2117
+ }
2118
+ await this.connect(nodeId);
2119
+ }
2120
+ /**
2121
+ * Remove a node from the connection pool
2122
+ */
2123
+ async removeNode(nodeId) {
2124
+ const connection = this.connections.get(nodeId);
2125
+ if (!connection) return;
2126
+ if (connection.reconnectTimer) {
2127
+ clearTimeout(connection.reconnectTimer);
2128
+ connection.reconnectTimer = null;
2129
+ }
2130
+ if (connection.socket) {
2131
+ connection.socket.onclose = null;
2132
+ connection.socket.close();
2133
+ connection.socket = null;
2134
+ }
2135
+ this.connections.delete(nodeId);
2136
+ if (this.primaryNodeId === nodeId) {
2137
+ this.primaryNodeId = this.connections.size > 0 ? this.connections.keys().next().value ?? null : null;
2138
+ }
2139
+ logger.info({ nodeId }, "Node removed from connection pool");
2140
+ }
2141
+ /**
2142
+ * Get connection for a specific node
2143
+ */
2144
+ getConnection(nodeId) {
2145
+ const connection = this.connections.get(nodeId);
2146
+ if (!connection || connection.state !== "AUTHENTICATED") {
2147
+ return null;
2148
+ }
2149
+ return connection.socket;
2150
+ }
2151
+ /**
2152
+ * Get primary connection (first/seed node)
2153
+ */
2154
+ getPrimaryConnection() {
2155
+ if (!this.primaryNodeId) return null;
2156
+ return this.getConnection(this.primaryNodeId);
2157
+ }
2158
+ /**
2159
+ * Get any healthy connection
2160
+ */
2161
+ getAnyHealthyConnection() {
2162
+ for (const [nodeId, conn] of this.connections) {
2163
+ if (conn.state === "AUTHENTICATED" && conn.socket) {
2164
+ return { nodeId, socket: conn.socket };
2165
+ }
2166
+ }
2167
+ return null;
2168
+ }
2169
+ /**
2170
+ * Send message to a specific node
2171
+ */
2172
+ send(nodeId, message) {
2173
+ const connection = this.connections.get(nodeId);
2174
+ if (!connection) {
2175
+ logger.warn({ nodeId }, "Cannot send: node not in pool");
2176
+ return false;
2177
+ }
2178
+ const data = serialize2(message);
2179
+ if (connection.state === "AUTHENTICATED" && connection.socket?.readyState === WebSocket.OPEN) {
2180
+ connection.socket.send(data);
2181
+ return true;
2182
+ }
2183
+ if (connection.pendingMessages.length < 1e3) {
2184
+ connection.pendingMessages.push(data);
2185
+ return true;
2186
+ }
2187
+ logger.warn({ nodeId }, "Message queue full, dropping message");
2188
+ return false;
2189
+ }
2190
+ /**
2191
+ * Send message to primary node
2192
+ */
2193
+ sendToPrimary(message) {
2194
+ if (!this.primaryNodeId) {
2195
+ logger.warn("No primary node available");
2196
+ return false;
2197
+ }
2198
+ return this.send(this.primaryNodeId, message);
2199
+ }
2200
+ /**
2201
+ * Get health status for all nodes
2202
+ */
2203
+ getHealthStatus() {
2204
+ const status = /* @__PURE__ */ new Map();
2205
+ for (const [nodeId, conn] of this.connections) {
2206
+ status.set(nodeId, {
2207
+ nodeId,
2208
+ state: conn.state,
2209
+ lastSeen: conn.lastSeen,
2210
+ latencyMs: conn.latencyMs,
2211
+ reconnectAttempts: conn.reconnectAttempts
2212
+ });
2213
+ }
2214
+ return status;
2215
+ }
2216
+ /**
2217
+ * Get list of connected node IDs
2218
+ */
2219
+ getConnectedNodes() {
2220
+ return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
2221
+ }
2222
+ /**
2223
+ * Get all node IDs
2224
+ */
2225
+ getAllNodes() {
2226
+ return Array.from(this.connections.keys());
2227
+ }
2228
+ /**
2229
+ * Check if node is connected and authenticated
2230
+ */
2231
+ isNodeConnected(nodeId) {
2232
+ const conn = this.connections.get(nodeId);
2233
+ return conn?.state === "AUTHENTICATED";
2234
+ }
2235
+ /**
2236
+ * Check if connected to a specific node.
2237
+ * Alias for isNodeConnected() for IConnectionProvider compatibility.
2238
+ */
2239
+ isConnected(nodeId) {
2240
+ return this.isNodeConnected(nodeId);
2241
+ }
2242
+ /**
2243
+ * Start health monitoring
2244
+ */
2245
+ startHealthCheck() {
2246
+ if (this.healthCheckTimer) return;
2247
+ this.healthCheckTimer = setInterval(() => {
2248
+ this.performHealthCheck();
2249
+ }, this.config.healthCheckIntervalMs);
2250
+ }
2251
+ /**
2252
+ * Stop health monitoring
2253
+ */
2254
+ stopHealthCheck() {
2255
+ if (this.healthCheckTimer) {
2256
+ clearInterval(this.healthCheckTimer);
2257
+ this.healthCheckTimer = null;
2258
+ }
2259
+ }
2260
+ /**
2261
+ * Close all connections and cleanup
2262
+ */
2263
+ close() {
2264
+ this.stopHealthCheck();
2265
+ for (const nodeId of this.connections.keys()) {
2266
+ this.removeNode(nodeId);
2267
+ }
2268
+ this.connections.clear();
2269
+ this.primaryNodeId = null;
2270
+ }
2271
+ // ============================================
2272
+ // Private Methods
2273
+ // ============================================
2274
+ async connect(nodeId) {
2275
+ const connection = this.connections.get(nodeId);
2276
+ if (!connection) return;
2277
+ if (connection.state === "CONNECTING" || connection.state === "CONNECTED") {
2278
+ return;
2279
+ }
2280
+ connection.state = "CONNECTING";
2281
+ logger.info({ nodeId, endpoint: connection.endpoint }, "Connecting to node");
2282
+ try {
2283
+ const socket = new WebSocket(connection.endpoint);
2284
+ socket.binaryType = "arraybuffer";
2285
+ connection.socket = socket;
2286
+ socket.onopen = () => {
2287
+ connection.state = "CONNECTED";
2288
+ connection.reconnectAttempts = 0;
2289
+ connection.lastSeen = Date.now();
2290
+ logger.info({ nodeId }, "Connected to node");
2291
+ this.emit("node:connected", nodeId);
2292
+ if (this.authToken) {
2293
+ this.sendAuth(connection);
2294
+ }
2295
+ };
2296
+ socket.onmessage = (event) => {
2297
+ connection.lastSeen = Date.now();
2298
+ this.handleMessage(nodeId, event);
2299
+ };
2300
+ socket.onerror = (error) => {
2301
+ logger.error({ nodeId, error }, "WebSocket error");
2302
+ this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
2303
+ };
2304
+ socket.onclose = () => {
2305
+ const wasConnected = connection.state === "AUTHENTICATED";
2306
+ connection.state = "DISCONNECTED";
2307
+ connection.socket = null;
2308
+ if (wasConnected) {
2309
+ this.emit("node:disconnected", nodeId, "Connection closed");
2310
+ }
2311
+ this.scheduleReconnect(nodeId);
2312
+ };
2313
+ } catch (error) {
2314
+ connection.state = "FAILED";
2315
+ logger.error({ nodeId, error }, "Failed to connect");
2316
+ this.scheduleReconnect(nodeId);
2317
+ }
2318
+ }
2319
+ sendAuth(connection) {
2320
+ if (!this.authToken || !connection.socket) return;
2321
+ connection.socket.send(serialize2({
2322
+ type: "AUTH",
2323
+ token: this.authToken
2324
+ }));
2325
+ }
2326
+ handleMessage(nodeId, event) {
2327
+ const connection = this.connections.get(nodeId);
2328
+ if (!connection) return;
2329
+ let message;
2330
+ try {
2331
+ if (event.data instanceof ArrayBuffer) {
2332
+ message = deserialize2(new Uint8Array(event.data));
2333
+ } else {
2334
+ message = JSON.parse(event.data);
2335
+ }
2336
+ } catch (e) {
2337
+ logger.error({ nodeId, error: e }, "Failed to parse message");
2338
+ return;
2339
+ }
2340
+ if (message.type === "AUTH_ACK") {
2341
+ connection.state = "AUTHENTICATED";
2342
+ logger.info({ nodeId }, "Authenticated with node");
2343
+ this.emit("node:healthy", nodeId);
2344
+ this.flushPendingMessages(connection);
2345
+ return;
2346
+ }
2347
+ if (message.type === "AUTH_REQUIRED") {
2348
+ if (this.authToken) {
2349
+ this.sendAuth(connection);
2350
+ }
2351
+ return;
2352
+ }
2353
+ if (message.type === "AUTH_FAIL") {
2354
+ logger.error({ nodeId, error: message.error }, "Authentication failed");
2355
+ connection.state = "FAILED";
2356
+ return;
2357
+ }
2358
+ if (message.type === "PONG") {
2359
+ if (message.timestamp) {
2360
+ connection.latencyMs = Date.now() - message.timestamp;
2361
+ }
2362
+ return;
2363
+ }
2364
+ if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
2365
+ this.emit("message", nodeId, message);
2366
+ return;
2367
+ }
2368
+ this.emit("message", nodeId, message);
2369
+ }
2370
+ flushPendingMessages(connection) {
2371
+ if (!connection.socket || connection.state !== "AUTHENTICATED") return;
2372
+ const pending = connection.pendingMessages;
2373
+ connection.pendingMessages = [];
2374
+ for (const data of pending) {
2375
+ if (connection.socket.readyState === WebSocket.OPEN) {
2376
+ connection.socket.send(data);
2377
+ }
2378
+ }
2379
+ if (pending.length > 0) {
2380
+ logger.debug({ nodeId: connection.nodeId, count: pending.length }, "Flushed pending messages");
2381
+ }
2382
+ }
2383
+ scheduleReconnect(nodeId) {
2384
+ const connection = this.connections.get(nodeId);
2385
+ if (!connection) return;
2386
+ if (connection.reconnectTimer) {
2387
+ clearTimeout(connection.reconnectTimer);
2388
+ connection.reconnectTimer = null;
2389
+ }
2390
+ if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
2391
+ connection.state = "FAILED";
2392
+ logger.error({ nodeId, attempts: connection.reconnectAttempts }, "Max reconnect attempts reached");
2393
+ this.emit("node:unhealthy", nodeId, "Max reconnect attempts reached");
2394
+ return;
2395
+ }
2396
+ const delay = Math.min(
2397
+ this.config.reconnectDelayMs * Math.pow(2, connection.reconnectAttempts),
2398
+ this.config.maxReconnectDelayMs
2399
+ );
2400
+ connection.state = "RECONNECTING";
2401
+ connection.reconnectAttempts++;
2402
+ logger.info({ nodeId, delay, attempt: connection.reconnectAttempts }, "Scheduling reconnect");
2403
+ connection.reconnectTimer = setTimeout(() => {
2404
+ connection.reconnectTimer = null;
2405
+ this.connect(nodeId);
2406
+ }, delay);
2407
+ }
2408
+ performHealthCheck() {
2409
+ const now = Date.now();
2410
+ for (const [nodeId, connection] of this.connections) {
2411
+ if (connection.state !== "AUTHENTICATED") continue;
2412
+ const timeSinceLastSeen = now - connection.lastSeen;
2413
+ if (timeSinceLastSeen > this.config.healthCheckIntervalMs * 3) {
2414
+ logger.warn({ nodeId, timeSinceLastSeen }, "Node appears stale, sending ping");
2415
+ }
2416
+ if (connection.socket?.readyState === WebSocket.OPEN) {
2417
+ connection.socket.send(serialize2({
2418
+ type: "PING",
2419
+ timestamp: now
2420
+ }));
2421
+ }
2422
+ }
2423
+ }
2424
+ };
2425
+
2426
+ // src/cluster/PartitionRouter.ts
2427
+ import {
2428
+ DEFAULT_PARTITION_ROUTER_CONFIG,
2429
+ PARTITION_COUNT,
2430
+ hashString
2431
+ } from "@topgunbuild/core";
2432
+ var PartitionRouter = class {
2433
+ constructor(connectionPool, config = {}) {
2434
+ this.listeners = /* @__PURE__ */ new Map();
2435
+ this.partitionMap = null;
2436
+ this.lastRefreshTime = 0;
2437
+ this.refreshTimer = null;
2438
+ this.pendingRefresh = null;
2439
+ this.connectionPool = connectionPool;
2440
+ this.config = {
2441
+ ...DEFAULT_PARTITION_ROUTER_CONFIG,
2442
+ ...config
2443
+ };
2444
+ this.connectionPool.on("message", (nodeId, message) => {
2445
+ if (message.type === "PARTITION_MAP") {
2446
+ this.handlePartitionMap(message);
2447
+ } else if (message.type === "PARTITION_MAP_DELTA") {
2448
+ this.handlePartitionMapDelta(message);
2449
+ }
2450
+ });
2451
+ }
2452
+ // ============================================
2453
+ // Event Emitter Methods (browser-compatible)
2454
+ // ============================================
2455
+ on(event, listener) {
2456
+ if (!this.listeners.has(event)) {
2457
+ this.listeners.set(event, /* @__PURE__ */ new Set());
2458
+ }
2459
+ this.listeners.get(event).add(listener);
2460
+ return this;
2461
+ }
2462
+ off(event, listener) {
2463
+ this.listeners.get(event)?.delete(listener);
2464
+ return this;
2465
+ }
2466
+ once(event, listener) {
2467
+ const wrapper = (...args) => {
2468
+ this.off(event, wrapper);
2469
+ listener(...args);
2470
+ };
2471
+ return this.on(event, wrapper);
2472
+ }
2473
+ emit(event, ...args) {
2474
+ const eventListeners = this.listeners.get(event);
2475
+ if (!eventListeners || eventListeners.size === 0) {
2476
+ return false;
2477
+ }
2478
+ for (const listener of eventListeners) {
2479
+ try {
2480
+ listener(...args);
2481
+ } catch (err) {
2482
+ logger.error({ event, err }, "Error in event listener");
2483
+ }
2484
+ }
2485
+ return true;
2486
+ }
2487
+ removeListener(event, listener) {
2488
+ return this.off(event, listener);
2489
+ }
2490
+ removeAllListeners(event) {
2491
+ if (event) {
2492
+ this.listeners.delete(event);
2493
+ } else {
2494
+ this.listeners.clear();
2495
+ }
2496
+ return this;
2497
+ }
2498
+ /**
2499
+ * Get the partition ID for a given key
2500
+ */
2501
+ getPartitionId(key) {
2502
+ return Math.abs(hashString(key)) % PARTITION_COUNT;
2503
+ }
2504
+ /**
2505
+ * Route a key to the owner node
2506
+ */
2507
+ route(key) {
2508
+ if (!this.partitionMap) {
2509
+ return null;
2510
+ }
2511
+ const partitionId = this.getPartitionId(key);
2512
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
2513
+ if (!partition) {
2514
+ logger.warn({ key, partitionId }, "Partition not found in map");
2515
+ return null;
2516
+ }
2517
+ return {
2518
+ nodeId: partition.ownerNodeId,
2519
+ partitionId,
2520
+ isOwner: true,
2521
+ isBackup: false
2522
+ };
2523
+ }
2524
+ /**
2525
+ * Route a key and get the WebSocket connection to use
2526
+ */
2527
+ routeToConnection(key) {
2528
+ const routing = this.route(key);
2529
+ if (!routing) {
2530
+ if (this.config.fallbackMode === "forward") {
2531
+ const primary = this.connectionPool.getAnyHealthyConnection();
2532
+ if (primary) {
2533
+ return primary;
2534
+ }
2535
+ }
2536
+ return null;
2537
+ }
2538
+ const socket = this.connectionPool.getConnection(routing.nodeId);
2539
+ if (socket) {
2540
+ return { nodeId: routing.nodeId, socket };
2541
+ }
2542
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
2543
+ if (partition) {
2544
+ for (const backupId of partition.backupNodeIds) {
2545
+ const backupSocket = this.connectionPool.getConnection(backupId);
2546
+ if (backupSocket) {
2547
+ logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
2548
+ return { nodeId: backupId, socket: backupSocket };
2549
+ }
2550
+ }
2551
+ }
2552
+ if (this.config.fallbackMode === "forward") {
2553
+ return this.connectionPool.getAnyHealthyConnection();
2554
+ }
2555
+ return null;
2556
+ }
2557
+ /**
2558
+ * Get routing info for multiple keys (batch routing)
2559
+ */
2560
+ routeBatch(keys) {
2561
+ const result = /* @__PURE__ */ new Map();
2562
+ for (const key of keys) {
2563
+ const routing = this.route(key);
2564
+ if (routing) {
2565
+ const nodeId = routing.nodeId;
2566
+ if (!result.has(nodeId)) {
2567
+ result.set(nodeId, []);
2568
+ }
2569
+ result.get(nodeId).push({ ...routing, key });
2570
+ }
2571
+ }
2572
+ return result;
2573
+ }
2574
+ /**
2575
+ * Get all partitions owned by a specific node
2576
+ */
2577
+ getPartitionsForNode(nodeId) {
2578
+ if (!this.partitionMap) return [];
2579
+ return this.partitionMap.partitions.filter((p) => p.ownerNodeId === nodeId).map((p) => p.partitionId);
2580
+ }
2581
+ /**
2582
+ * Get current partition map version
2583
+ */
2584
+ getMapVersion() {
2585
+ return this.partitionMap?.version ?? 0;
2586
+ }
2587
+ /**
2588
+ * Check if partition map is available
2589
+ */
2590
+ hasPartitionMap() {
2591
+ return this.partitionMap !== null;
2592
+ }
2593
+ /**
2594
+ * Get owner node for a key.
2595
+ * Returns null if partition map is not available.
2596
+ */
2597
+ getOwner(key) {
2598
+ if (!this.partitionMap) return null;
2599
+ const partitionId = this.getPartitionId(key);
2600
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
2601
+ return partition?.ownerNodeId ?? null;
2602
+ }
2603
+ /**
2604
+ * Get backup nodes for a key.
2605
+ * Returns empty array if partition map is not available.
2606
+ */
2607
+ getBackups(key) {
2608
+ if (!this.partitionMap) return [];
2609
+ const partitionId = this.getPartitionId(key);
2610
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
2611
+ return partition?.backupNodeIds ?? [];
2612
+ }
2613
+ /**
2614
+ * Get the full partition map.
2615
+ * Returns null if not available.
2616
+ */
2617
+ getMap() {
2618
+ return this.partitionMap;
2619
+ }
2620
+ /**
2621
+ * Update entire partition map.
2622
+ * Only accepts newer versions.
2623
+ */
2624
+ updateMap(map) {
2625
+ if (this.partitionMap && map.version <= this.partitionMap.version) {
2626
+ return false;
2627
+ }
2628
+ this.partitionMap = map;
2629
+ this.lastRefreshTime = Date.now();
2630
+ this.updateConnectionPool(map);
2631
+ const changesCount = map.partitions.length;
2632
+ logger.info({
2633
+ version: map.version,
2634
+ partitions: map.partitionCount,
2635
+ nodes: map.nodes.length
2636
+ }, "Partition map updated via updateMap");
2637
+ this.emit("partitionMap:updated", map.version, changesCount);
2638
+ return true;
2639
+ }
2640
+ /**
2641
+ * Update a single partition (for delta updates).
2642
+ */
2643
+ updatePartition(partitionId, owner, backups) {
2644
+ if (!this.partitionMap) return;
2645
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
2646
+ if (partition) {
2647
+ partition.ownerNodeId = owner;
2648
+ partition.backupNodeIds = backups;
2649
+ }
2650
+ }
2651
+ /**
2652
+ * Check if partition map is stale
2653
+ */
2654
+ isMapStale() {
2655
+ if (!this.partitionMap) return true;
2656
+ const now = Date.now();
2657
+ return now - this.lastRefreshTime > this.config.maxMapStalenessMs;
2658
+ }
2659
+ /**
2660
+ * Request fresh partition map from server
2661
+ */
2662
+ async refreshPartitionMap() {
2663
+ if (this.pendingRefresh) {
2664
+ return this.pendingRefresh;
2665
+ }
2666
+ this.pendingRefresh = this.doRefreshPartitionMap();
2667
+ try {
2668
+ await this.pendingRefresh;
2669
+ } finally {
2670
+ this.pendingRefresh = null;
2671
+ }
2672
+ }
2673
+ /**
2674
+ * Start periodic partition map refresh
2675
+ */
2676
+ startPeriodicRefresh() {
2677
+ if (this.refreshTimer) return;
2678
+ this.refreshTimer = setInterval(() => {
2679
+ if (this.isMapStale()) {
2680
+ this.emit("partitionMap:stale", this.getMapVersion(), this.lastRefreshTime);
2681
+ this.refreshPartitionMap().catch((err) => {
2682
+ logger.error({ error: err }, "Failed to refresh partition map");
2683
+ });
2684
+ }
2685
+ }, this.config.mapRefreshIntervalMs);
2686
+ }
2687
+ /**
2688
+ * Stop periodic refresh
2689
+ */
2690
+ stopPeriodicRefresh() {
2691
+ if (this.refreshTimer) {
2692
+ clearInterval(this.refreshTimer);
2693
+ this.refreshTimer = null;
2694
+ }
2695
+ }
2696
+ /**
2697
+ * Handle NOT_OWNER error from server
2698
+ */
2699
+ handleNotOwnerError(key, actualOwner, newMapVersion) {
2700
+ const routing = this.route(key);
2701
+ const expectedOwner = routing?.nodeId ?? "unknown";
2702
+ this.emit("routing:miss", key, expectedOwner, actualOwner);
2703
+ if (newMapVersion > this.getMapVersion()) {
2704
+ this.refreshPartitionMap().catch((err) => {
2705
+ logger.error({ error: err }, "Failed to refresh partition map after NOT_OWNER");
2706
+ });
2707
+ }
2708
+ }
2709
+ /**
2710
+ * Get statistics about routing
2711
+ */
2712
+ getStats() {
2713
+ return {
2714
+ mapVersion: this.getMapVersion(),
2715
+ partitionCount: this.partitionMap?.partitionCount ?? 0,
2716
+ nodeCount: this.partitionMap?.nodes.length ?? 0,
2717
+ lastRefresh: this.lastRefreshTime,
2718
+ isStale: this.isMapStale()
2719
+ };
2720
+ }
2721
+ /**
2722
+ * Cleanup resources
2723
+ */
2724
+ close() {
2725
+ this.stopPeriodicRefresh();
2726
+ this.partitionMap = null;
2727
+ }
2728
+ // ============================================
2729
+ // Private Methods
2730
+ // ============================================
2731
+ handlePartitionMap(message) {
2732
+ const newMap = message.payload;
2733
+ if (this.partitionMap && newMap.version <= this.partitionMap.version) {
2734
+ logger.debug({
2735
+ current: this.partitionMap.version,
2736
+ received: newMap.version
2737
+ }, "Ignoring older partition map");
2738
+ return;
2739
+ }
2740
+ this.partitionMap = newMap;
2741
+ this.lastRefreshTime = Date.now();
2742
+ this.updateConnectionPool(newMap);
2743
+ const changesCount = newMap.partitions.length;
2744
+ logger.info({
2745
+ version: newMap.version,
2746
+ partitions: newMap.partitionCount,
2747
+ nodes: newMap.nodes.length
2748
+ }, "Partition map updated");
2749
+ this.emit("partitionMap:updated", newMap.version, changesCount);
2750
+ }
2751
+ handlePartitionMapDelta(message) {
2752
+ const delta = message.payload;
2753
+ if (!this.partitionMap) {
2754
+ logger.warn("Received delta but no base map, requesting full map");
2755
+ this.refreshPartitionMap();
2756
+ return;
2757
+ }
2758
+ if (delta.previousVersion !== this.partitionMap.version) {
2759
+ logger.warn({
2760
+ expected: this.partitionMap.version,
2761
+ received: delta.previousVersion
2762
+ }, "Delta version mismatch, requesting full map");
2763
+ this.refreshPartitionMap();
2764
+ return;
2765
+ }
2766
+ for (const change of delta.changes) {
2767
+ this.applyPartitionChange(change);
2768
+ }
2769
+ this.partitionMap.version = delta.version;
2770
+ this.lastRefreshTime = Date.now();
2771
+ logger.info({
2772
+ version: delta.version,
2773
+ changes: delta.changes.length
2774
+ }, "Applied partition map delta");
2775
+ this.emit("partitionMap:updated", delta.version, delta.changes.length);
2776
+ }
2777
+ applyPartitionChange(change) {
2778
+ if (!this.partitionMap) return;
2779
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === change.partitionId);
2780
+ if (partition) {
2781
+ partition.ownerNodeId = change.newOwner;
2782
+ }
2783
+ }
2784
+ updateConnectionPool(map) {
2785
+ for (const node of map.nodes) {
2786
+ if (node.status === "ACTIVE" || node.status === "JOINING") {
2787
+ this.connectionPool.addNode(node.nodeId, node.endpoints.websocket);
2788
+ }
2789
+ }
2790
+ const currentNodeIds = new Set(map.nodes.map((n) => n.nodeId));
2791
+ for (const nodeId of this.connectionPool.getAllNodes()) {
2792
+ if (!currentNodeIds.has(nodeId)) {
2793
+ this.connectionPool.removeNode(nodeId);
2794
+ }
2795
+ }
2796
+ }
2797
+ async doRefreshPartitionMap() {
2798
+ logger.debug("Requesting partition map refresh");
2799
+ const sent = this.connectionPool.sendToPrimary({
2800
+ type: "PARTITION_MAP_REQUEST",
2801
+ payload: {
2802
+ currentVersion: this.getMapVersion()
2803
+ }
2804
+ });
2805
+ if (!sent) {
2806
+ throw new Error("No connection available to request partition map");
2807
+ }
2808
+ return new Promise((resolve, reject) => {
2809
+ const timeout = setTimeout(() => {
2810
+ this.removeListener("partitionMap:updated", onUpdate);
2811
+ reject(new Error("Partition map refresh timeout"));
2812
+ }, 5e3);
2813
+ const onUpdate = () => {
2814
+ clearTimeout(timeout);
2815
+ this.removeListener("partitionMap:updated", onUpdate);
2816
+ resolve();
2817
+ };
2818
+ this.once("partitionMap:updated", onUpdate);
2819
+ });
2820
+ }
2821
+ };
2822
+
2823
+ // src/cluster/ClusterClient.ts
2824
+ var ClusterClient = class {
2825
+ constructor(config) {
2826
+ this.listeners = /* @__PURE__ */ new Map();
2827
+ this.initialized = false;
2828
+ this.routingActive = false;
2829
+ this.routingMetrics = {
2830
+ directRoutes: 0,
2831
+ fallbackRoutes: 0,
2832
+ partitionMisses: 0,
2833
+ totalRoutes: 0
2834
+ };
2835
+ // Circuit breaker state per node
2836
+ this.circuits = /* @__PURE__ */ new Map();
2837
+ this.config = config;
2838
+ this.circuitBreakerConfig = {
2839
+ ...DEFAULT_CIRCUIT_BREAKER_CONFIG,
2840
+ ...config.circuitBreaker
2841
+ };
2842
+ const poolConfig = {
2843
+ ...DEFAULT_CONNECTION_POOL_CONFIG2,
2844
+ ...config.connectionPool
2845
+ };
2846
+ this.connectionPool = new ConnectionPool(poolConfig);
2847
+ const routerConfig = {
2848
+ ...DEFAULT_PARTITION_ROUTER_CONFIG2,
2849
+ fallbackMode: config.routingMode === "direct" ? "error" : "forward",
2850
+ ...config.routing
2851
+ };
2852
+ this.partitionRouter = new PartitionRouter(this.connectionPool, routerConfig);
2853
+ this.setupEventHandlers();
2854
+ }
2855
+ // ============================================
2856
+ // Event Emitter Methods (browser-compatible)
2857
+ // ============================================
2858
+ on(event, listener) {
2859
+ if (!this.listeners.has(event)) {
2860
+ this.listeners.set(event, /* @__PURE__ */ new Set());
2861
+ }
2862
+ this.listeners.get(event).add(listener);
2863
+ return this;
2864
+ }
2865
+ off(event, listener) {
2866
+ this.listeners.get(event)?.delete(listener);
2867
+ return this;
2868
+ }
2869
+ emit(event, ...args) {
2870
+ const eventListeners = this.listeners.get(event);
2871
+ if (!eventListeners || eventListeners.size === 0) {
2872
+ return false;
2873
+ }
2874
+ for (const listener of eventListeners) {
2875
+ try {
2876
+ listener(...args);
2877
+ } catch (err) {
2878
+ logger.error({ event, err }, "Error in event listener");
2879
+ }
2880
+ }
2881
+ return true;
2882
+ }
2883
+ removeAllListeners(event) {
2884
+ if (event) {
2885
+ this.listeners.delete(event);
2886
+ } else {
2887
+ this.listeners.clear();
2888
+ }
2889
+ return this;
2890
+ }
2891
+ // ============================================
2892
+ // IConnectionProvider Implementation
2893
+ // ============================================
2894
+ /**
2895
+ * Connect to cluster nodes (IConnectionProvider interface).
2896
+ * Alias for start() method.
2897
+ */
2898
+ async connect() {
2899
+ return this.start();
2900
+ }
2901
+ /**
2902
+ * Get connection for a specific key (IConnectionProvider interface).
2903
+ * Routes to partition owner based on key hash when smart routing is enabled.
2904
+ * @throws Error if not connected
2905
+ */
2906
+ getConnection(key) {
2907
+ if (!this.isConnected()) {
2908
+ throw new Error("ClusterClient not connected");
2909
+ }
2910
+ this.routingMetrics.totalRoutes++;
2911
+ if (this.config.routingMode !== "direct" || !this.routingActive) {
2912
+ this.routingMetrics.fallbackRoutes++;
2913
+ return this.getFallbackConnection();
2914
+ }
2915
+ const routing = this.partitionRouter.route(key);
2916
+ if (!routing) {
2917
+ this.routingMetrics.partitionMisses++;
2918
+ logger.debug({ key }, "No partition map available, using fallback");
2919
+ return this.getFallbackConnection();
2920
+ }
2921
+ const owner = routing.nodeId;
2922
+ if (!this.connectionPool.isNodeConnected(owner)) {
2923
+ this.routingMetrics.fallbackRoutes++;
2924
+ logger.debug({ key, owner }, "Partition owner not connected, using fallback");
2925
+ this.requestPartitionMapRefresh();
2926
+ return this.getFallbackConnection();
2927
+ }
2928
+ const socket = this.connectionPool.getConnection(owner);
2929
+ if (!socket) {
2930
+ this.routingMetrics.fallbackRoutes++;
2931
+ logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
2932
+ return this.getFallbackConnection();
2933
+ }
2934
+ this.routingMetrics.directRoutes++;
2935
+ return socket;
2936
+ }
2937
+ /**
2938
+ * Get fallback connection when owner is unavailable.
2939
+ * @throws Error if no connection available
2940
+ */
2941
+ getFallbackConnection() {
2942
+ const conn = this.connectionPool.getAnyHealthyConnection();
2943
+ if (!conn?.socket) {
2944
+ throw new Error("No healthy connection available");
2945
+ }
2946
+ return conn.socket;
2947
+ }
2948
+ /**
2949
+ * Request a partition map refresh in the background.
2950
+ * Called when routing to an unknown/disconnected owner.
2951
+ */
2952
+ requestPartitionMapRefresh() {
2953
+ this.partitionRouter.refreshPartitionMap().catch((err) => {
2954
+ logger.error({ err }, "Failed to refresh partition map");
2955
+ });
2956
+ }
2957
+ /**
2958
+ * Request partition map from a specific node.
2959
+ * Called on first node connection.
2960
+ */
2961
+ requestPartitionMapFromNode(nodeId) {
2962
+ const socket = this.connectionPool.getConnection(nodeId);
2963
+ if (socket) {
2964
+ logger.debug({ nodeId }, "Requesting partition map from node");
2965
+ socket.send(serialize3({
2966
+ type: "PARTITION_MAP_REQUEST",
2967
+ payload: {
2968
+ currentVersion: this.partitionRouter.getMapVersion()
2969
+ }
2970
+ }));
2971
+ }
2972
+ }
2973
+ /**
2974
+ * Check if at least one connection is active (IConnectionProvider interface).
2975
+ */
2976
+ isConnected() {
2977
+ return this.connectionPool.getConnectedNodes().length > 0;
2978
+ }
2979
+ /**
2980
+ * Send data via the appropriate connection (IConnectionProvider interface).
2981
+ * Routes based on key if provided.
2982
+ */
2983
+ send(data, key) {
2984
+ if (!this.isConnected()) {
2985
+ throw new Error("ClusterClient not connected");
2986
+ }
2987
+ const socket = key ? this.getConnection(key) : this.getAnyConnection();
2988
+ socket.send(data);
2989
+ }
2990
+ /**
2991
+ * Send data with automatic retry and rerouting on failure.
2992
+ * @param data - Data to send
2993
+ * @param key - Optional key for routing
2994
+ * @param options - Retry options
2995
+ * @throws Error after max retries exceeded
2996
+ */
2997
+ async sendWithRetry(data, key, options = {}) {
2998
+ const {
2999
+ maxRetries = 3,
3000
+ retryDelayMs = 100,
3001
+ retryOnNotOwner = true
3002
+ } = options;
3003
+ let lastError = null;
3004
+ let nodeId = null;
3005
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
3006
+ try {
3007
+ if (key && this.routingActive) {
3008
+ const routing = this.partitionRouter.route(key);
3009
+ nodeId = routing?.nodeId ?? null;
3010
+ }
3011
+ if (nodeId && !this.canUseNode(nodeId)) {
3012
+ logger.debug({ nodeId, attempt }, "Circuit open, using fallback");
3013
+ nodeId = null;
3014
+ }
3015
+ const socket = key && nodeId ? this.connectionPool.getConnection(nodeId) : this.getAnyConnection();
3016
+ if (!socket) {
3017
+ throw new Error("No connection available");
3018
+ }
3019
+ socket.send(data);
3020
+ if (nodeId) {
3021
+ this.recordSuccess(nodeId);
3022
+ }
3023
+ return;
3024
+ } catch (error) {
3025
+ lastError = error;
3026
+ if (nodeId) {
3027
+ this.recordFailure(nodeId);
3028
+ }
3029
+ const errorCode = error?.code;
3030
+ if (this.isRetryableError(error)) {
3031
+ logger.debug(
3032
+ { attempt, maxRetries, errorCode, nodeId },
3033
+ "Retryable error, will retry"
3034
+ );
3035
+ if (errorCode === "NOT_OWNER" && retryOnNotOwner) {
3036
+ await this.waitForPartitionMapUpdateInternal(2e3);
3037
+ } else if (errorCode === "CONNECTION_CLOSED" || !this.isConnected()) {
3038
+ await this.waitForConnectionInternal(5e3);
3039
+ }
3040
+ await this.delay(retryDelayMs * (attempt + 1));
3041
+ continue;
3042
+ }
3043
+ throw error;
3044
+ }
3045
+ }
3046
+ throw new Error(
3047
+ `Operation failed after ${maxRetries} retries: ${lastError?.message}`
3048
+ );
3049
+ }
3050
+ /**
3051
+ * Check if an error is retryable.
3052
+ */
3053
+ isRetryableError(error) {
3054
+ const code = error?.code;
3055
+ const message = error?.message || "";
3056
+ return code === "NOT_OWNER" || code === "CONNECTION_CLOSED" || code === "TIMEOUT" || code === "ECONNRESET" || message.includes("No active connections") || message.includes("No connection available") || message.includes("No healthy connection");
3057
+ }
3058
+ /**
3059
+ * Wait for partition map update.
3060
+ */
3061
+ waitForPartitionMapUpdateInternal(timeoutMs) {
3062
+ return new Promise((resolve) => {
3063
+ const timeout = setTimeout(resolve, timeoutMs);
3064
+ const handler2 = () => {
3065
+ clearTimeout(timeout);
3066
+ this.off("partitionMapUpdated", handler2);
3067
+ resolve();
3068
+ };
3069
+ this.on("partitionMapUpdated", handler2);
3070
+ });
3071
+ }
3072
+ /**
3073
+ * Wait for at least one connection to be available.
3074
+ */
3075
+ waitForConnectionInternal(timeoutMs) {
3076
+ return new Promise((resolve, reject) => {
3077
+ if (this.isConnected()) {
3078
+ resolve();
3079
+ return;
3080
+ }
3081
+ const timeout = setTimeout(() => {
3082
+ this.off("connected", handler2);
3083
+ reject(new Error("Connection timeout"));
3084
+ }, timeoutMs);
3085
+ const handler2 = () => {
3086
+ clearTimeout(timeout);
3087
+ this.off("connected", handler2);
3088
+ resolve();
3089
+ };
3090
+ this.on("connected", handler2);
3091
+ });
3092
+ }
3093
+ /**
3094
+ * Helper delay function.
3095
+ */
3096
+ delay(ms) {
3097
+ return new Promise((resolve) => setTimeout(resolve, ms));
3098
+ }
3099
+ // ============================================
3100
+ // Cluster-Specific Methods
3101
+ // ============================================
3102
+ /**
3103
+ * Initialize cluster connections
3104
+ */
3105
+ async start() {
3106
+ if (this.initialized) return;
3107
+ logger.info({ seedNodes: this.config.seedNodes }, "Starting cluster client");
3108
+ for (let i = 0; i < this.config.seedNodes.length; i++) {
3109
+ const endpoint = this.config.seedNodes[i];
3110
+ const nodeId = `seed-${i}`;
3111
+ await this.connectionPool.addNode(nodeId, endpoint);
1355
3112
  }
1356
- console.log(`[QueryHandle:${this.mapName}] After merge: ${this.currentResults.size} results`);
1357
- this.notify();
3113
+ this.connectionPool.startHealthCheck();
3114
+ this.partitionRouter.startPeriodicRefresh();
3115
+ this.initialized = true;
3116
+ await this.waitForPartitionMap();
1358
3117
  }
1359
3118
  /**
1360
- * Called by SyncEngine when server sends a live update
3119
+ * Set authentication token
1361
3120
  */
1362
- onUpdate(key, value) {
1363
- if (value === null) {
1364
- this.currentResults.delete(key);
1365
- } else {
1366
- this.currentResults.set(key, value);
3121
+ setAuthToken(token) {
3122
+ this.connectionPool.setAuthToken(token);
3123
+ }
3124
+ /**
3125
+ * Send operation with automatic routing (legacy API for cluster operations).
3126
+ * @deprecated Use send(data, key) for IConnectionProvider interface
3127
+ */
3128
+ sendMessage(key, message) {
3129
+ if (this.config.routingMode === "direct" && this.routingActive) {
3130
+ return this.sendDirect(key, message);
1367
3131
  }
1368
- this.notify();
3132
+ return this.sendForward(message);
1369
3133
  }
1370
- notify() {
1371
- const results = this.getSortedResults();
1372
- for (const listener of this.listeners) {
1373
- listener(results);
3134
+ /**
3135
+ * Send directly to partition owner
3136
+ */
3137
+ sendDirect(key, message) {
3138
+ const connection = this.partitionRouter.routeToConnection(key);
3139
+ if (!connection) {
3140
+ logger.warn({ key }, "No route available for key");
3141
+ return false;
1374
3142
  }
3143
+ const routedMessage = {
3144
+ ...message,
3145
+ _routing: {
3146
+ partitionId: this.partitionRouter.getPartitionId(key),
3147
+ mapVersion: this.partitionRouter.getMapVersion()
3148
+ }
3149
+ };
3150
+ connection.socket.send(serialize3(routedMessage));
3151
+ return true;
1375
3152
  }
1376
- getSortedResults() {
1377
- const results = Array.from(this.currentResults.entries()).map(
1378
- ([key, value]) => ({ ...value, _key: key })
1379
- );
1380
- if (this.filter.sort) {
1381
- results.sort((a, b) => {
1382
- for (const [field, direction] of Object.entries(this.filter.sort)) {
1383
- const valA = a[field];
1384
- const valB = b[field];
1385
- if (valA < valB) return direction === "asc" ? -1 : 1;
1386
- if (valA > valB) return direction === "asc" ? 1 : -1;
3153
+ /**
3154
+ * Send to primary node for server-side forwarding
3155
+ */
3156
+ sendForward(message) {
3157
+ return this.connectionPool.sendToPrimary(message);
3158
+ }
3159
+ /**
3160
+ * Send batch of operations with routing
3161
+ */
3162
+ sendBatch(operations) {
3163
+ const results = /* @__PURE__ */ new Map();
3164
+ if (this.config.routingMode === "direct" && this.routingActive) {
3165
+ const nodeMessages = /* @__PURE__ */ new Map();
3166
+ for (const { key, message } of operations) {
3167
+ const routing = this.partitionRouter.route(key);
3168
+ const nodeId = routing?.nodeId ?? "primary";
3169
+ if (!nodeMessages.has(nodeId)) {
3170
+ nodeMessages.set(nodeId, []);
1387
3171
  }
1388
- return 0;
3172
+ nodeMessages.get(nodeId).push({ key, message });
3173
+ }
3174
+ for (const [nodeId, messages] of nodeMessages) {
3175
+ let success;
3176
+ if (nodeId === "primary") {
3177
+ success = this.connectionPool.sendToPrimary({
3178
+ type: "OP_BATCH",
3179
+ payload: { ops: messages.map((m) => m.message) }
3180
+ });
3181
+ } else {
3182
+ success = this.connectionPool.send(nodeId, {
3183
+ type: "OP_BATCH",
3184
+ payload: { ops: messages.map((m) => m.message) }
3185
+ });
3186
+ }
3187
+ for (const { key } of messages) {
3188
+ results.set(key, success);
3189
+ }
3190
+ }
3191
+ } else {
3192
+ const success = this.connectionPool.sendToPrimary({
3193
+ type: "OP_BATCH",
3194
+ payload: { ops: operations.map((o) => o.message) }
1389
3195
  });
3196
+ for (const { key } of operations) {
3197
+ results.set(key, success);
3198
+ }
1390
3199
  }
1391
3200
  return results;
1392
3201
  }
1393
- getFilter() {
1394
- return this.filter;
3202
+ /**
3203
+ * Get connection pool health status
3204
+ */
3205
+ getHealthStatus() {
3206
+ return this.connectionPool.getHealthStatus();
1395
3207
  }
1396
- getMapName() {
1397
- return this.mapName;
3208
+ /**
3209
+ * Get partition router stats
3210
+ */
3211
+ getRouterStats() {
3212
+ return this.partitionRouter.getStats();
1398
3213
  }
1399
- };
1400
-
1401
- // src/DistributedLock.ts
1402
- var DistributedLock = class {
1403
- constructor(syncEngine, name) {
1404
- this.fencingToken = null;
1405
- this._isLocked = false;
1406
- this.syncEngine = syncEngine;
1407
- this.name = name;
3214
+ /**
3215
+ * Get routing metrics for monitoring smart routing effectiveness.
3216
+ */
3217
+ getRoutingMetrics() {
3218
+ return { ...this.routingMetrics };
1408
3219
  }
1409
- async lock(ttl = 1e4) {
1410
- const requestId = crypto.randomUUID();
1411
- try {
1412
- const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
1413
- this.fencingToken = result.fencingToken;
1414
- this._isLocked = true;
1415
- return true;
1416
- } catch (e) {
1417
- return false;
1418
- }
3220
+ /**
3221
+ * Reset routing metrics counters.
3222
+ * Useful for monitoring intervals.
3223
+ */
3224
+ resetRoutingMetrics() {
3225
+ this.routingMetrics.directRoutes = 0;
3226
+ this.routingMetrics.fallbackRoutes = 0;
3227
+ this.routingMetrics.partitionMisses = 0;
3228
+ this.routingMetrics.totalRoutes = 0;
1419
3229
  }
1420
- async unlock() {
1421
- if (!this._isLocked || this.fencingToken === null) return;
1422
- const requestId = crypto.randomUUID();
1423
- try {
1424
- await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
1425
- } finally {
1426
- this._isLocked = false;
1427
- this.fencingToken = null;
1428
- }
3230
+ /**
3231
+ * Check if cluster routing is active
3232
+ */
3233
+ isRoutingActive() {
3234
+ return this.routingActive;
1429
3235
  }
1430
- isLocked() {
1431
- return this._isLocked;
3236
+ /**
3237
+ * Get list of connected nodes
3238
+ */
3239
+ getConnectedNodes() {
3240
+ return this.connectionPool.getConnectedNodes();
1432
3241
  }
1433
- };
1434
-
1435
- // src/TopicHandle.ts
1436
- var TopicHandle = class {
1437
- constructor(engine, topic) {
1438
- this.listeners = /* @__PURE__ */ new Set();
1439
- this.engine = engine;
1440
- this.topic = topic;
3242
+ /**
3243
+ * Check if cluster client is initialized
3244
+ */
3245
+ isInitialized() {
3246
+ return this.initialized;
1441
3247
  }
1442
- get id() {
1443
- return this.topic;
3248
+ /**
3249
+ * Force refresh of partition map
3250
+ */
3251
+ async refreshPartitionMap() {
3252
+ await this.partitionRouter.refreshPartitionMap();
1444
3253
  }
1445
3254
  /**
1446
- * Publish a message to the topic
3255
+ * Shutdown cluster client (IConnectionProvider interface).
1447
3256
  */
1448
- publish(data) {
1449
- this.engine.publishTopic(this.topic, data);
3257
+ async close() {
3258
+ this.partitionRouter.close();
3259
+ this.connectionPool.close();
3260
+ this.initialized = false;
3261
+ this.routingActive = false;
3262
+ logger.info("Cluster client closed");
1450
3263
  }
3264
+ // ============================================
3265
+ // Internal Access for TopGunClient
3266
+ // ============================================
1451
3267
  /**
1452
- * Subscribe to the topic
3268
+ * Get the connection pool (for internal use)
1453
3269
  */
1454
- subscribe(callback) {
1455
- if (this.listeners.size === 0) {
1456
- this.engine.subscribeToTopic(this.topic, this);
3270
+ getConnectionPool() {
3271
+ return this.connectionPool;
3272
+ }
3273
+ /**
3274
+ * Get the partition router (for internal use)
3275
+ */
3276
+ getPartitionRouter() {
3277
+ return this.partitionRouter;
3278
+ }
3279
+ /**
3280
+ * Get any healthy WebSocket connection (IConnectionProvider interface).
3281
+ * @throws Error if not connected
3282
+ */
3283
+ getAnyConnection() {
3284
+ const conn = this.connectionPool.getAnyHealthyConnection();
3285
+ if (!conn?.socket) {
3286
+ throw new Error("No healthy connection available");
1457
3287
  }
1458
- this.listeners.add(callback);
1459
- return () => this.unsubscribe(callback);
3288
+ return conn.socket;
1460
3289
  }
1461
- unsubscribe(callback) {
1462
- this.listeners.delete(callback);
1463
- if (this.listeners.size === 0) {
1464
- this.engine.unsubscribeFromTopic(this.topic);
3290
+ /**
3291
+ * Get any healthy WebSocket connection, or null if none available.
3292
+ * Use this for optional connection checks.
3293
+ */
3294
+ getAnyConnectionOrNull() {
3295
+ const conn = this.connectionPool.getAnyHealthyConnection();
3296
+ return conn?.socket ?? null;
3297
+ }
3298
+ // ============================================
3299
+ // Circuit Breaker Methods
3300
+ // ============================================
3301
+ /**
3302
+ * Get circuit breaker state for a node.
3303
+ */
3304
+ getCircuit(nodeId) {
3305
+ let circuit = this.circuits.get(nodeId);
3306
+ if (!circuit) {
3307
+ circuit = { failures: 0, lastFailure: 0, state: "closed" };
3308
+ this.circuits.set(nodeId, circuit);
1465
3309
  }
3310
+ return circuit;
1466
3311
  }
1467
3312
  /**
1468
- * Called by SyncEngine when a message is received
3313
+ * Check if a node can be used (circuit not open).
1469
3314
  */
1470
- onMessage(data, context) {
1471
- this.listeners.forEach((cb) => {
1472
- try {
1473
- cb(data, context);
1474
- } catch (e) {
1475
- console.error("Error in topic listener", e);
3315
+ canUseNode(nodeId) {
3316
+ const circuit = this.getCircuit(nodeId);
3317
+ if (circuit.state === "closed") {
3318
+ return true;
3319
+ }
3320
+ if (circuit.state === "open") {
3321
+ if (Date.now() - circuit.lastFailure > this.circuitBreakerConfig.resetTimeoutMs) {
3322
+ circuit.state = "half-open";
3323
+ logger.debug({ nodeId }, "Circuit breaker half-open, allowing test request");
3324
+ this.emit("circuit:half-open", nodeId);
3325
+ return true;
3326
+ }
3327
+ return false;
3328
+ }
3329
+ return true;
3330
+ }
3331
+ /**
3332
+ * Record a successful operation to a node.
3333
+ * Resets circuit breaker on success.
3334
+ */
3335
+ recordSuccess(nodeId) {
3336
+ const circuit = this.getCircuit(nodeId);
3337
+ const wasOpen = circuit.state !== "closed";
3338
+ circuit.failures = 0;
3339
+ circuit.state = "closed";
3340
+ if (wasOpen) {
3341
+ logger.info({ nodeId }, "Circuit breaker closed after success");
3342
+ this.emit("circuit:closed", nodeId);
3343
+ }
3344
+ }
3345
+ /**
3346
+ * Record a failed operation to a node.
3347
+ * Opens circuit breaker after threshold failures.
3348
+ */
3349
+ recordFailure(nodeId) {
3350
+ const circuit = this.getCircuit(nodeId);
3351
+ circuit.failures++;
3352
+ circuit.lastFailure = Date.now();
3353
+ if (circuit.failures >= this.circuitBreakerConfig.failureThreshold) {
3354
+ if (circuit.state !== "open") {
3355
+ circuit.state = "open";
3356
+ logger.warn({ nodeId, failures: circuit.failures }, "Circuit breaker opened");
3357
+ this.emit("circuit:open", nodeId);
3358
+ }
3359
+ }
3360
+ }
3361
+ /**
3362
+ * Get all circuit breaker states.
3363
+ */
3364
+ getCircuitStates() {
3365
+ return new Map(this.circuits);
3366
+ }
3367
+ /**
3368
+ * Reset circuit breaker for a specific node.
3369
+ */
3370
+ resetCircuit(nodeId) {
3371
+ this.circuits.delete(nodeId);
3372
+ logger.debug({ nodeId }, "Circuit breaker reset");
3373
+ }
3374
+ /**
3375
+ * Reset all circuit breakers.
3376
+ */
3377
+ resetAllCircuits() {
3378
+ this.circuits.clear();
3379
+ logger.debug("All circuit breakers reset");
3380
+ }
3381
+ // ============================================
3382
+ // Private Methods
3383
+ // ============================================
3384
+ setupEventHandlers() {
3385
+ this.connectionPool.on("node:connected", (nodeId) => {
3386
+ logger.debug({ nodeId }, "Node connected");
3387
+ if (this.partitionRouter.getMapVersion() === 0) {
3388
+ this.requestPartitionMapFromNode(nodeId);
3389
+ }
3390
+ if (this.connectionPool.getConnectedNodes().length === 1) {
3391
+ this.emit("connected");
3392
+ }
3393
+ });
3394
+ this.connectionPool.on("node:disconnected", (nodeId, reason) => {
3395
+ logger.debug({ nodeId, reason }, "Node disconnected");
3396
+ if (this.connectionPool.getConnectedNodes().length === 0) {
3397
+ this.routingActive = false;
3398
+ this.emit("disconnected", reason);
3399
+ }
3400
+ });
3401
+ this.connectionPool.on("node:unhealthy", (nodeId, reason) => {
3402
+ logger.warn({ nodeId, reason }, "Node unhealthy");
3403
+ });
3404
+ this.connectionPool.on("error", (nodeId, error) => {
3405
+ this.emit("error", error);
3406
+ });
3407
+ this.connectionPool.on("message", (nodeId, data) => {
3408
+ this.emit("message", nodeId, data);
3409
+ });
3410
+ this.partitionRouter.on("partitionMap:updated", (version, changesCount) => {
3411
+ if (!this.routingActive && this.partitionRouter.hasPartitionMap()) {
3412
+ this.routingActive = true;
3413
+ logger.info({ version }, "Direct routing activated");
3414
+ this.emit("routing:active");
1476
3415
  }
3416
+ this.emit("partitionMap:ready", version);
3417
+ this.emit("partitionMapUpdated");
3418
+ });
3419
+ this.partitionRouter.on("routing:miss", (key, expected, actual) => {
3420
+ logger.debug({ key, expected, actual }, "Routing miss detected");
3421
+ });
3422
+ }
3423
+ async waitForPartitionMap(timeoutMs = 1e4) {
3424
+ if (this.partitionRouter.hasPartitionMap()) {
3425
+ this.routingActive = true;
3426
+ return;
3427
+ }
3428
+ return new Promise((resolve) => {
3429
+ const timeout = setTimeout(() => {
3430
+ this.partitionRouter.off("partitionMap:updated", onUpdate);
3431
+ logger.warn("Partition map not received, using fallback routing");
3432
+ resolve();
3433
+ }, timeoutMs);
3434
+ const onUpdate = () => {
3435
+ clearTimeout(timeout);
3436
+ this.partitionRouter.off("partitionMap:updated", onUpdate);
3437
+ this.routingActive = true;
3438
+ resolve();
3439
+ };
3440
+ this.partitionRouter.once("partitionMap:updated", onUpdate);
1477
3441
  });
1478
3442
  }
1479
3443
  };
1480
3444
 
1481
3445
  // src/TopGunClient.ts
3446
+ var DEFAULT_CLUSTER_CONFIG = {
3447
+ connectionsPerNode: 1,
3448
+ smartRouting: true,
3449
+ partitionMapRefreshMs: 3e4,
3450
+ connectionTimeoutMs: 5e3,
3451
+ retryAttempts: 3
3452
+ };
1482
3453
  var TopGunClient = class {
1483
3454
  constructor(config) {
1484
3455
  this.maps = /* @__PURE__ */ new Map();
1485
3456
  this.topicHandles = /* @__PURE__ */ new Map();
3457
+ if (config.serverUrl && config.cluster) {
3458
+ throw new Error("Cannot specify both serverUrl and cluster config");
3459
+ }
3460
+ if (!config.serverUrl && !config.cluster) {
3461
+ throw new Error("Must specify either serverUrl or cluster config");
3462
+ }
1486
3463
  this.nodeId = config.nodeId || crypto.randomUUID();
1487
3464
  this.storageAdapter = config.storage;
1488
- const syncEngineConfig = {
1489
- nodeId: this.nodeId,
1490
- serverUrl: config.serverUrl,
1491
- storageAdapter: this.storageAdapter,
1492
- backoff: config.backoff,
1493
- backpressure: config.backpressure
1494
- };
1495
- this.syncEngine = new SyncEngine(syncEngineConfig);
3465
+ this.isClusterMode = !!config.cluster;
3466
+ if (config.cluster) {
3467
+ if (!config.cluster.seeds || config.cluster.seeds.length === 0) {
3468
+ throw new Error("Cluster config requires at least one seed node");
3469
+ }
3470
+ this.clusterConfig = {
3471
+ seeds: config.cluster.seeds,
3472
+ connectionsPerNode: config.cluster.connectionsPerNode ?? DEFAULT_CLUSTER_CONFIG.connectionsPerNode,
3473
+ smartRouting: config.cluster.smartRouting ?? DEFAULT_CLUSTER_CONFIG.smartRouting,
3474
+ partitionMapRefreshMs: config.cluster.partitionMapRefreshMs ?? DEFAULT_CLUSTER_CONFIG.partitionMapRefreshMs,
3475
+ connectionTimeoutMs: config.cluster.connectionTimeoutMs ?? DEFAULT_CLUSTER_CONFIG.connectionTimeoutMs,
3476
+ retryAttempts: config.cluster.retryAttempts ?? DEFAULT_CLUSTER_CONFIG.retryAttempts
3477
+ };
3478
+ this.clusterClient = new ClusterClient({
3479
+ enabled: true,
3480
+ seedNodes: this.clusterConfig.seeds,
3481
+ routingMode: this.clusterConfig.smartRouting ? "direct" : "forward",
3482
+ connectionPool: {
3483
+ maxConnectionsPerNode: this.clusterConfig.connectionsPerNode,
3484
+ connectionTimeoutMs: this.clusterConfig.connectionTimeoutMs
3485
+ },
3486
+ routing: {
3487
+ mapRefreshIntervalMs: this.clusterConfig.partitionMapRefreshMs
3488
+ }
3489
+ });
3490
+ this.syncEngine = new SyncEngine({
3491
+ nodeId: this.nodeId,
3492
+ connectionProvider: this.clusterClient,
3493
+ storageAdapter: this.storageAdapter,
3494
+ backoff: config.backoff,
3495
+ backpressure: config.backpressure
3496
+ });
3497
+ logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
3498
+ } else {
3499
+ this.syncEngine = new SyncEngine({
3500
+ nodeId: this.nodeId,
3501
+ serverUrl: config.serverUrl,
3502
+ storageAdapter: this.storageAdapter,
3503
+ backoff: config.backoff,
3504
+ backpressure: config.backpressure
3505
+ });
3506
+ logger.info({ serverUrl: config.serverUrl }, "TopGunClient initialized in single-server mode");
3507
+ }
1496
3508
  }
1497
3509
  async start() {
1498
3510
  await this.storageAdapter.initialize("topgun_offline_db");
@@ -1652,9 +3664,69 @@ var TopGunClient = class {
1652
3664
  * Closes the client, disconnecting from the server and cleaning up resources.
1653
3665
  */
1654
3666
  close() {
3667
+ if (this.clusterClient) {
3668
+ this.clusterClient.close();
3669
+ }
1655
3670
  this.syncEngine.close();
1656
3671
  }
1657
3672
  // ============================================
3673
+ // Cluster Mode API
3674
+ // ============================================
3675
+ /**
3676
+ * Check if running in cluster mode
3677
+ */
3678
+ isCluster() {
3679
+ return this.isClusterMode;
3680
+ }
3681
+ /**
3682
+ * Get list of connected cluster nodes (cluster mode only)
3683
+ * @returns Array of connected node IDs, or empty array in single-server mode
3684
+ */
3685
+ getConnectedNodes() {
3686
+ if (!this.clusterClient) return [];
3687
+ return this.clusterClient.getConnectedNodes();
3688
+ }
3689
+ /**
3690
+ * Get the current partition map version (cluster mode only)
3691
+ * @returns Partition map version, or 0 in single-server mode
3692
+ */
3693
+ getPartitionMapVersion() {
3694
+ if (!this.clusterClient) return 0;
3695
+ return this.clusterClient.getRouterStats().mapVersion;
3696
+ }
3697
+ /**
3698
+ * Check if direct routing is active (cluster mode only)
3699
+ * Direct routing sends operations directly to partition owners.
3700
+ * @returns true if routing is active, false otherwise
3701
+ */
3702
+ isRoutingActive() {
3703
+ if (!this.clusterClient) return false;
3704
+ return this.clusterClient.isRoutingActive();
3705
+ }
3706
+ /**
3707
+ * Get health status for all cluster nodes (cluster mode only)
3708
+ * @returns Map of node IDs to their health status
3709
+ */
3710
+ getClusterHealth() {
3711
+ if (!this.clusterClient) return /* @__PURE__ */ new Map();
3712
+ return this.clusterClient.getHealthStatus();
3713
+ }
3714
+ /**
3715
+ * Force refresh of partition map (cluster mode only)
3716
+ * Use this after detecting routing errors.
3717
+ */
3718
+ async refreshPartitionMap() {
3719
+ if (!this.clusterClient) return;
3720
+ await this.clusterClient.refreshPartitionMap();
3721
+ }
3722
+ /**
3723
+ * Get cluster router statistics (cluster mode only)
3724
+ */
3725
+ getClusterStats() {
3726
+ if (!this.clusterClient) return null;
3727
+ return this.clusterClient.getRouterStats();
3728
+ }
3729
+ // ============================================
1658
3730
  // Connection State API
1659
3731
  // ============================================
1660
3732
  /**
@@ -2005,14 +4077,14 @@ var CollectionWrapper = class {
2005
4077
  };
2006
4078
 
2007
4079
  // src/crypto/EncryptionManager.ts
2008
- import { serialize as serialize2, deserialize as deserialize2 } from "@topgunbuild/core";
4080
+ import { serialize as serialize4, deserialize as deserialize3 } from "@topgunbuild/core";
2009
4081
  var _EncryptionManager = class _EncryptionManager {
2010
4082
  /**
2011
4083
  * Encrypts data using AES-GCM.
2012
4084
  * Serializes data to MessagePack before encryption.
2013
4085
  */
2014
4086
  static async encrypt(key, data) {
2015
- const encoded = serialize2(data);
4087
+ const encoded = serialize4(data);
2016
4088
  const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
2017
4089
  const ciphertext = await window.crypto.subtle.encrypt(
2018
4090
  {
@@ -2041,7 +4113,7 @@ var _EncryptionManager = class _EncryptionManager {
2041
4113
  key,
2042
4114
  record.data
2043
4115
  );
2044
- return deserialize2(new Uint8Array(plaintextBuffer));
4116
+ return deserialize3(new Uint8Array(plaintextBuffer));
2045
4117
  } catch (err) {
2046
4118
  console.error("Decryption failed", err);
2047
4119
  throw new Error("Failed to decrypt data: " + err);
@@ -2168,12 +4240,17 @@ var EncryptedStorageAdapter = class {
2168
4240
  import { LWWMap as LWWMap3, Predicates } from "@topgunbuild/core";
2169
4241
  export {
2170
4242
  BackpressureError,
4243
+ ClusterClient,
4244
+ ConnectionPool,
2171
4245
  DEFAULT_BACKPRESSURE_CONFIG,
4246
+ DEFAULT_CLUSTER_CONFIG,
2172
4247
  EncryptedStorageAdapter,
2173
4248
  IDBAdapter,
2174
4249
  LWWMap3 as LWWMap,
4250
+ PartitionRouter,
2175
4251
  Predicates,
2176
4252
  QueryHandle,
4253
+ SingleServerProvider,
2177
4254
  SyncEngine,
2178
4255
  SyncState,
2179
4256
  SyncStateMachine,