@topgunbuild/client 0.2.1 → 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.js CHANGED
@@ -31,12 +31,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  BackpressureError: () => BackpressureError,
34
+ ClusterClient: () => ClusterClient,
35
+ ConnectionPool: () => ConnectionPool,
34
36
  DEFAULT_BACKPRESSURE_CONFIG: () => DEFAULT_BACKPRESSURE_CONFIG,
37
+ DEFAULT_CLUSTER_CONFIG: () => DEFAULT_CLUSTER_CONFIG,
35
38
  EncryptedStorageAdapter: () => EncryptedStorageAdapter,
36
39
  IDBAdapter: () => IDBAdapter,
37
- LWWMap: () => import_core4.LWWMap,
38
- Predicates: () => import_core4.Predicates,
40
+ LWWMap: () => import_core8.LWWMap,
41
+ PartitionRouter: () => PartitionRouter,
42
+ Predicates: () => import_core8.Predicates,
39
43
  QueryHandle: () => QueryHandle,
44
+ SingleServerProvider: () => SingleServerProvider,
40
45
  SyncEngine: () => SyncEngine,
41
46
  SyncState: () => SyncState,
42
47
  SyncStateMachine: () => SyncStateMachine,
@@ -254,6 +259,233 @@ var DEFAULT_BACKPRESSURE_CONFIG = {
254
259
  lowWaterMark: 0.5
255
260
  };
256
261
 
262
+ // src/connection/SingleServerProvider.ts
263
+ var DEFAULT_CONFIG = {
264
+ maxReconnectAttempts: 10,
265
+ reconnectDelayMs: 1e3,
266
+ backoffMultiplier: 2,
267
+ maxReconnectDelayMs: 3e4
268
+ };
269
+ var SingleServerProvider = class {
270
+ constructor(config) {
271
+ this.ws = null;
272
+ this.reconnectAttempts = 0;
273
+ this.reconnectTimer = null;
274
+ this.isClosing = false;
275
+ this.listeners = /* @__PURE__ */ new Map();
276
+ this.url = config.url;
277
+ this.config = {
278
+ url: config.url,
279
+ maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
280
+ reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
281
+ backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
282
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
283
+ };
284
+ }
285
+ /**
286
+ * Connect to the WebSocket server.
287
+ */
288
+ async connect() {
289
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
290
+ return;
291
+ }
292
+ this.isClosing = false;
293
+ return new Promise((resolve, reject) => {
294
+ try {
295
+ this.ws = new WebSocket(this.url);
296
+ this.ws.binaryType = "arraybuffer";
297
+ this.ws.onopen = () => {
298
+ this.reconnectAttempts = 0;
299
+ logger.info({ url: this.url }, "SingleServerProvider connected");
300
+ this.emit("connected", "default");
301
+ resolve();
302
+ };
303
+ this.ws.onerror = (error) => {
304
+ logger.error({ err: error, url: this.url }, "SingleServerProvider WebSocket error");
305
+ this.emit("error", error);
306
+ };
307
+ this.ws.onclose = (event) => {
308
+ logger.info({ url: this.url, code: event.code }, "SingleServerProvider disconnected");
309
+ this.emit("disconnected", "default");
310
+ if (!this.isClosing) {
311
+ this.scheduleReconnect();
312
+ }
313
+ };
314
+ this.ws.onmessage = (event) => {
315
+ this.emit("message", "default", event.data);
316
+ };
317
+ const timeoutId = setTimeout(() => {
318
+ if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
319
+ this.ws.close();
320
+ reject(new Error(`Connection timeout to ${this.url}`));
321
+ }
322
+ }, this.config.reconnectDelayMs * 5);
323
+ const originalOnOpen = this.ws.onopen;
324
+ const wsRef = this.ws;
325
+ this.ws.onopen = (ev) => {
326
+ clearTimeout(timeoutId);
327
+ if (originalOnOpen) {
328
+ originalOnOpen.call(wsRef, ev);
329
+ }
330
+ };
331
+ } catch (error) {
332
+ reject(error);
333
+ }
334
+ });
335
+ }
336
+ /**
337
+ * Get connection for a specific key.
338
+ * In single-server mode, key is ignored.
339
+ */
340
+ getConnection(_key) {
341
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
342
+ throw new Error("Not connected");
343
+ }
344
+ return this.ws;
345
+ }
346
+ /**
347
+ * Get any available connection.
348
+ */
349
+ getAnyConnection() {
350
+ return this.getConnection("");
351
+ }
352
+ /**
353
+ * Check if connected.
354
+ */
355
+ isConnected() {
356
+ return this.ws?.readyState === WebSocket.OPEN;
357
+ }
358
+ /**
359
+ * Get connected node IDs.
360
+ * Single-server mode returns ['default'] when connected.
361
+ */
362
+ getConnectedNodes() {
363
+ return this.isConnected() ? ["default"] : [];
364
+ }
365
+ /**
366
+ * Subscribe to connection events.
367
+ */
368
+ on(event, handler2) {
369
+ if (!this.listeners.has(event)) {
370
+ this.listeners.set(event, /* @__PURE__ */ new Set());
371
+ }
372
+ this.listeners.get(event).add(handler2);
373
+ }
374
+ /**
375
+ * Unsubscribe from connection events.
376
+ */
377
+ off(event, handler2) {
378
+ this.listeners.get(event)?.delete(handler2);
379
+ }
380
+ /**
381
+ * Send data via the WebSocket connection.
382
+ * In single-server mode, key parameter is ignored.
383
+ */
384
+ send(data, _key) {
385
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
386
+ throw new Error("Not connected");
387
+ }
388
+ this.ws.send(data);
389
+ }
390
+ /**
391
+ * Close the WebSocket connection.
392
+ */
393
+ async close() {
394
+ this.isClosing = true;
395
+ if (this.reconnectTimer) {
396
+ clearTimeout(this.reconnectTimer);
397
+ this.reconnectTimer = null;
398
+ }
399
+ if (this.ws) {
400
+ this.ws.onclose = null;
401
+ this.ws.onerror = null;
402
+ this.ws.onmessage = null;
403
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
404
+ this.ws.close();
405
+ }
406
+ this.ws = null;
407
+ }
408
+ logger.info({ url: this.url }, "SingleServerProvider closed");
409
+ }
410
+ /**
411
+ * Emit an event to all listeners.
412
+ */
413
+ emit(event, ...args) {
414
+ const handlers = this.listeners.get(event);
415
+ if (handlers) {
416
+ for (const handler2 of handlers) {
417
+ try {
418
+ handler2(...args);
419
+ } catch (err) {
420
+ logger.error({ err, event }, "Error in SingleServerProvider event handler");
421
+ }
422
+ }
423
+ }
424
+ }
425
+ /**
426
+ * Schedule a reconnection attempt with exponential backoff.
427
+ */
428
+ scheduleReconnect() {
429
+ if (this.reconnectTimer) {
430
+ clearTimeout(this.reconnectTimer);
431
+ this.reconnectTimer = null;
432
+ }
433
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
434
+ logger.error(
435
+ { attempts: this.reconnectAttempts, url: this.url },
436
+ "SingleServerProvider max reconnect attempts reached"
437
+ );
438
+ this.emit("error", new Error("Max reconnection attempts reached"));
439
+ return;
440
+ }
441
+ const delay = this.calculateBackoffDelay();
442
+ logger.info(
443
+ { delay, attempt: this.reconnectAttempts, url: this.url },
444
+ `SingleServerProvider scheduling reconnect in ${delay}ms`
445
+ );
446
+ this.reconnectTimer = setTimeout(async () => {
447
+ this.reconnectTimer = null;
448
+ this.reconnectAttempts++;
449
+ try {
450
+ await this.connect();
451
+ this.emit("reconnected", "default");
452
+ } catch (error) {
453
+ logger.error({ err: error }, "SingleServerProvider reconnection failed");
454
+ this.scheduleReconnect();
455
+ }
456
+ }, delay);
457
+ }
458
+ /**
459
+ * Calculate backoff delay with exponential increase.
460
+ */
461
+ calculateBackoffDelay() {
462
+ const { reconnectDelayMs, backoffMultiplier, maxReconnectDelayMs } = this.config;
463
+ let delay = reconnectDelayMs * Math.pow(backoffMultiplier, this.reconnectAttempts);
464
+ delay = Math.min(delay, maxReconnectDelayMs);
465
+ delay = delay * (0.5 + Math.random());
466
+ return Math.floor(delay);
467
+ }
468
+ /**
469
+ * Get the WebSocket URL this provider connects to.
470
+ */
471
+ getUrl() {
472
+ return this.url;
473
+ }
474
+ /**
475
+ * Get current reconnection attempt count.
476
+ */
477
+ getReconnectAttempts() {
478
+ return this.reconnectAttempts;
479
+ }
480
+ /**
481
+ * Reset reconnection counter.
482
+ * Called externally after successful authentication.
483
+ */
484
+ resetReconnectAttempts() {
485
+ this.reconnectAttempts = 0;
486
+ }
487
+ };
488
+
257
489
  // src/SyncEngine.ts
258
490
  var DEFAULT_BACKOFF_CONFIG = {
259
491
  initialDelayMs: 1e3,
@@ -285,8 +517,11 @@ var SyncEngine = class {
285
517
  this.backpressureListeners = /* @__PURE__ */ new Map();
286
518
  // Write Concern state (Phase 5.01)
287
519
  this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
520
+ if (!config.serverUrl && !config.connectionProvider) {
521
+ throw new Error("SyncEngine requires either serverUrl or connectionProvider");
522
+ }
288
523
  this.nodeId = config.nodeId;
289
- this.serverUrl = config.serverUrl;
524
+ this.serverUrl = config.serverUrl || "";
290
525
  this.storageAdapter = config.storageAdapter;
291
526
  this.hlc = new import_core.HLC(this.nodeId);
292
527
  this.stateMachine = new SyncStateMachine();
@@ -303,7 +538,15 @@ var SyncEngine = class {
303
538
  ...DEFAULT_BACKPRESSURE_CONFIG,
304
539
  ...config.backpressure
305
540
  };
306
- this.initConnection();
541
+ if (config.connectionProvider) {
542
+ this.connectionProvider = config.connectionProvider;
543
+ this.useConnectionProvider = true;
544
+ this.initConnectionProvider();
545
+ } else {
546
+ this.connectionProvider = new SingleServerProvider({ url: config.serverUrl });
547
+ this.useConnectionProvider = false;
548
+ this.initConnection();
549
+ }
307
550
  this.loadOpLog();
308
551
  }
309
552
  // ============================================
@@ -354,6 +597,65 @@ var SyncEngine = class {
354
597
  // ============================================
355
598
  // Connection Management
356
599
  // ============================================
600
+ /**
601
+ * Initialize connection using IConnectionProvider (Phase 4.5 cluster mode).
602
+ * Sets up event handlers for the connection provider.
603
+ */
604
+ initConnectionProvider() {
605
+ this.stateMachine.transition("CONNECTING" /* CONNECTING */);
606
+ this.connectionProvider.on("connected", (_nodeId) => {
607
+ if (this.authToken || this.tokenProvider) {
608
+ logger.info("ConnectionProvider connected. Sending auth...");
609
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
610
+ this.sendAuth();
611
+ } else {
612
+ logger.info("ConnectionProvider connected. Waiting for auth token...");
613
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
614
+ }
615
+ });
616
+ this.connectionProvider.on("disconnected", (_nodeId) => {
617
+ logger.info("ConnectionProvider disconnected.");
618
+ this.stopHeartbeat();
619
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
620
+ });
621
+ this.connectionProvider.on("reconnected", (_nodeId) => {
622
+ logger.info("ConnectionProvider reconnected.");
623
+ this.stateMachine.transition("CONNECTING" /* CONNECTING */);
624
+ if (this.authToken || this.tokenProvider) {
625
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
626
+ this.sendAuth();
627
+ }
628
+ });
629
+ this.connectionProvider.on("message", (_nodeId, data) => {
630
+ let message;
631
+ if (data instanceof ArrayBuffer) {
632
+ message = (0, import_core.deserialize)(new Uint8Array(data));
633
+ } else if (data instanceof Uint8Array) {
634
+ message = (0, import_core.deserialize)(data);
635
+ } else {
636
+ try {
637
+ message = typeof data === "string" ? JSON.parse(data) : data;
638
+ } catch (e) {
639
+ logger.error({ err: e }, "Failed to parse message from ConnectionProvider");
640
+ return;
641
+ }
642
+ }
643
+ this.handleServerMessage(message);
644
+ });
645
+ this.connectionProvider.on("partitionMapUpdated", () => {
646
+ logger.debug("Partition map updated");
647
+ });
648
+ this.connectionProvider.on("error", (error) => {
649
+ logger.error({ err: error }, "ConnectionProvider error");
650
+ });
651
+ this.connectionProvider.connect().catch((err) => {
652
+ logger.error({ err }, "Failed to connect via ConnectionProvider");
653
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
654
+ });
655
+ }
656
+ /**
657
+ * Initialize connection using direct WebSocket (legacy single-server mode).
658
+ */
357
659
  initConnection() {
358
660
  this.stateMachine.transition("CONNECTING" /* CONNECTING */);
359
661
  this.websocket = new WebSocket(this.serverUrl);
@@ -429,6 +731,40 @@ var SyncEngine = class {
429
731
  resetBackoff() {
430
732
  this.backoffAttempt = 0;
431
733
  }
734
+ /**
735
+ * Send a message through the current connection.
736
+ * Uses connectionProvider if in cluster mode, otherwise uses direct websocket.
737
+ * @param message Message object to serialize and send
738
+ * @param key Optional key for routing (cluster mode only)
739
+ * @returns true if message was sent, false otherwise
740
+ */
741
+ sendMessage(message, key) {
742
+ const data = (0, import_core.serialize)(message);
743
+ if (this.useConnectionProvider) {
744
+ try {
745
+ this.connectionProvider.send(data, key);
746
+ return true;
747
+ } catch (err) {
748
+ logger.warn({ err }, "Failed to send via ConnectionProvider");
749
+ return false;
750
+ }
751
+ } else {
752
+ if (this.websocket?.readyState === WebSocket.OPEN) {
753
+ this.websocket.send(data);
754
+ return true;
755
+ }
756
+ return false;
757
+ }
758
+ }
759
+ /**
760
+ * Check if we can send messages (connection is ready).
761
+ */
762
+ canSend() {
763
+ if (this.useConnectionProvider) {
764
+ return this.connectionProvider.isConnected();
765
+ }
766
+ return this.websocket?.readyState === WebSocket.OPEN;
767
+ }
432
768
  async loadOpLog() {
433
769
  const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
434
770
  if (storedTimestamp) {
@@ -475,36 +811,34 @@ var SyncEngine = class {
475
811
  const pending = this.opLog.filter((op) => !op.synced);
476
812
  if (pending.length === 0) return;
477
813
  logger.info({ count: pending.length }, "Syncing pending operations");
478
- if (this.websocket?.readyState === WebSocket.OPEN) {
479
- this.websocket.send((0, import_core.serialize)({
480
- type: "OP_BATCH",
481
- payload: {
482
- ops: pending
483
- }
484
- }));
485
- }
814
+ this.sendMessage({
815
+ type: "OP_BATCH",
816
+ payload: {
817
+ ops: pending
818
+ }
819
+ });
486
820
  }
487
821
  startMerkleSync() {
488
822
  for (const [mapName, map] of this.maps) {
489
823
  if (map instanceof import_core.LWWMap) {
490
824
  logger.info({ mapName }, "Starting Merkle sync for LWWMap");
491
- this.websocket?.send((0, import_core.serialize)({
825
+ this.sendMessage({
492
826
  type: "SYNC_INIT",
493
827
  mapName,
494
828
  lastSyncTimestamp: this.lastSyncTimestamp
495
- }));
829
+ });
496
830
  } else if (map instanceof import_core.ORMap) {
497
831
  logger.info({ mapName }, "Starting Merkle sync for ORMap");
498
832
  const tree = map.getMerkleTree();
499
833
  const rootHash = tree.getRootHash();
500
834
  const bucketHashes = tree.getBuckets("");
501
- this.websocket?.send((0, import_core.serialize)({
835
+ this.sendMessage({
502
836
  type: "ORMAP_SYNC_INIT",
503
837
  mapName,
504
838
  rootHash,
505
839
  bucketHashes,
506
840
  lastSyncTimestamp: this.lastSyncTimestamp
507
- }));
841
+ });
508
842
  }
509
843
  }
510
844
  }
@@ -545,10 +879,10 @@ var SyncEngine = class {
545
879
  }
546
880
  const token = this.authToken;
547
881
  if (!token) return;
548
- this.websocket?.send((0, import_core.serialize)({
882
+ this.sendMessage({
549
883
  type: "AUTH",
550
884
  token
551
- }));
885
+ });
552
886
  }
553
887
  subscribeToQuery(query) {
554
888
  this.queries.set(query.id, query);
@@ -565,27 +899,27 @@ var SyncEngine = class {
565
899
  unsubscribeFromTopic(topic) {
566
900
  this.topics.delete(topic);
567
901
  if (this.isAuthenticated()) {
568
- this.websocket?.send((0, import_core.serialize)({
902
+ this.sendMessage({
569
903
  type: "TOPIC_UNSUB",
570
904
  payload: { topic }
571
- }));
905
+ });
572
906
  }
573
907
  }
574
908
  publishTopic(topic, data) {
575
909
  if (this.isAuthenticated()) {
576
- this.websocket?.send((0, import_core.serialize)({
910
+ this.sendMessage({
577
911
  type: "TOPIC_PUB",
578
912
  payload: { topic, data }
579
- }));
913
+ });
580
914
  } else {
581
915
  logger.warn({ topic }, "Dropped topic publish (offline)");
582
916
  }
583
917
  }
584
918
  sendTopicSubscription(topic) {
585
- this.websocket?.send((0, import_core.serialize)({
919
+ this.sendMessage({
586
920
  type: "TOPIC_SUB",
587
921
  payload: { topic }
588
- }));
922
+ });
589
923
  }
590
924
  /**
591
925
  * Executes a query against local storage immediately
@@ -622,21 +956,21 @@ var SyncEngine = class {
622
956
  unsubscribeFromQuery(queryId) {
623
957
  this.queries.delete(queryId);
624
958
  if (this.isAuthenticated()) {
625
- this.websocket?.send((0, import_core.serialize)({
959
+ this.sendMessage({
626
960
  type: "QUERY_UNSUB",
627
961
  payload: { queryId }
628
- }));
962
+ });
629
963
  }
630
964
  }
631
965
  sendQuerySubscription(query) {
632
- this.websocket?.send((0, import_core.serialize)({
966
+ this.sendMessage({
633
967
  type: "QUERY_SUB",
634
968
  payload: {
635
969
  queryId: query.id,
636
970
  mapName: query.getMapName(),
637
971
  query: query.getFilter()
638
972
  }
639
- }));
973
+ });
640
974
  }
641
975
  requestLock(name, requestId, ttl) {
642
976
  if (!this.isAuthenticated()) {
@@ -651,10 +985,15 @@ var SyncEngine = class {
651
985
  }, 3e4);
652
986
  this.pendingLockRequests.set(requestId, { resolve, reject, timer });
653
987
  try {
654
- this.websocket?.send((0, import_core.serialize)({
988
+ const sent = this.sendMessage({
655
989
  type: "LOCK_REQUEST",
656
990
  payload: { requestId, name, ttl }
657
- }));
991
+ });
992
+ if (!sent) {
993
+ clearTimeout(timer);
994
+ this.pendingLockRequests.delete(requestId);
995
+ reject(new Error("Failed to send lock request"));
996
+ }
658
997
  } catch (e) {
659
998
  clearTimeout(timer);
660
999
  this.pendingLockRequests.delete(requestId);
@@ -673,10 +1012,15 @@ var SyncEngine = class {
673
1012
  }, 5e3);
674
1013
  this.pendingLockRequests.set(requestId, { resolve, reject, timer });
675
1014
  try {
676
- this.websocket?.send((0, import_core.serialize)({
1015
+ const sent = this.sendMessage({
677
1016
  type: "LOCK_RELEASE",
678
1017
  payload: { requestId, name, fencingToken }
679
- }));
1018
+ });
1019
+ if (!sent) {
1020
+ clearTimeout(timer);
1021
+ this.pendingLockRequests.delete(requestId);
1022
+ resolve(false);
1023
+ }
680
1024
  } catch (e) {
681
1025
  clearTimeout(timer);
682
1026
  this.pendingLockRequests.delete(requestId);
@@ -855,11 +1199,11 @@ var SyncEngine = class {
855
1199
  const { mapName } = message.payload;
856
1200
  logger.warn({ mapName }, "Sync Reset Required due to GC Age");
857
1201
  await this.resetMap(mapName);
858
- this.websocket?.send((0, import_core.serialize)({
1202
+ this.sendMessage({
859
1203
  type: "SYNC_INIT",
860
1204
  mapName,
861
1205
  lastSyncTimestamp: 0
862
- }));
1206
+ });
863
1207
  break;
864
1208
  }
865
1209
  case "SYNC_RESP_ROOT": {
@@ -869,10 +1213,10 @@ var SyncEngine = class {
869
1213
  const localRootHash = map.getMerkleTree().getRootHash();
870
1214
  if (localRootHash !== rootHash) {
871
1215
  logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
872
- this.websocket?.send((0, import_core.serialize)({
1216
+ this.sendMessage({
873
1217
  type: "MERKLE_REQ_BUCKET",
874
1218
  payload: { mapName, path: "" }
875
- }));
1219
+ });
876
1220
  } else {
877
1221
  logger.info({ mapName }, "Map is in sync");
878
1222
  }
@@ -894,10 +1238,10 @@ var SyncEngine = class {
894
1238
  const localHash = localBuckets[bucketKey] || 0;
895
1239
  if (localHash !== remoteHash) {
896
1240
  const newPath = path + bucketKey;
897
- this.websocket?.send((0, import_core.serialize)({
1241
+ this.sendMessage({
898
1242
  type: "MERKLE_REQ_BUCKET",
899
1243
  payload: { mapName, path: newPath }
900
- }));
1244
+ });
901
1245
  }
902
1246
  }
903
1247
  }
@@ -930,10 +1274,10 @@ var SyncEngine = class {
930
1274
  const localRootHash = localTree.getRootHash();
931
1275
  if (localRootHash !== rootHash) {
932
1276
  logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
933
- this.websocket?.send((0, import_core.serialize)({
1277
+ this.sendMessage({
934
1278
  type: "ORMAP_MERKLE_REQ_BUCKET",
935
1279
  payload: { mapName, path: "" }
936
- }));
1280
+ });
937
1281
  } else {
938
1282
  logger.info({ mapName }, "ORMap is in sync");
939
1283
  }
@@ -955,10 +1299,10 @@ var SyncEngine = class {
955
1299
  const localHash = localBuckets[bucketKey] || 0;
956
1300
  if (localHash !== remoteHash) {
957
1301
  const newPath = path + bucketKey;
958
- this.websocket?.send((0, import_core.serialize)({
1302
+ this.sendMessage({
959
1303
  type: "ORMAP_MERKLE_REQ_BUCKET",
960
1304
  payload: { mapName, path: newPath }
961
- }));
1305
+ });
962
1306
  }
963
1307
  }
964
1308
  for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
@@ -1049,7 +1393,11 @@ var SyncEngine = class {
1049
1393
  clearTimeout(this.reconnectTimer);
1050
1394
  this.reconnectTimer = null;
1051
1395
  }
1052
- if (this.websocket) {
1396
+ if (this.useConnectionProvider) {
1397
+ this.connectionProvider.close().catch((err) => {
1398
+ logger.error({ err }, "Error closing ConnectionProvider");
1399
+ });
1400
+ } else if (this.websocket) {
1053
1401
  this.websocket.onclose = null;
1054
1402
  this.websocket.close();
1055
1403
  this.websocket = null;
@@ -1066,7 +1414,100 @@ var SyncEngine = class {
1066
1414
  this.close();
1067
1415
  this.stateMachine.reset();
1068
1416
  this.resetBackoff();
1069
- this.initConnection();
1417
+ if (this.useConnectionProvider) {
1418
+ this.initConnectionProvider();
1419
+ } else {
1420
+ this.initConnection();
1421
+ }
1422
+ }
1423
+ // ============================================
1424
+ // Failover Support Methods (Phase 4.5 Task 05)
1425
+ // ============================================
1426
+ /**
1427
+ * Wait for a partition map update from the connection provider.
1428
+ * Used when an operation fails with NOT_OWNER error and needs
1429
+ * to wait for an updated partition map before retrying.
1430
+ *
1431
+ * @param timeoutMs - Maximum time to wait (default: 5000ms)
1432
+ * @returns Promise that resolves when partition map is updated or times out
1433
+ */
1434
+ waitForPartitionMapUpdate(timeoutMs = 5e3) {
1435
+ return new Promise((resolve) => {
1436
+ const timeout = setTimeout(resolve, timeoutMs);
1437
+ const handler2 = () => {
1438
+ clearTimeout(timeout);
1439
+ this.connectionProvider.off("partitionMapUpdated", handler2);
1440
+ resolve();
1441
+ };
1442
+ this.connectionProvider.on("partitionMapUpdated", handler2);
1443
+ });
1444
+ }
1445
+ /**
1446
+ * Wait for the connection to be available.
1447
+ * Used when an operation fails due to connection issues and needs
1448
+ * to wait for reconnection before retrying.
1449
+ *
1450
+ * @param timeoutMs - Maximum time to wait (default: 10000ms)
1451
+ * @returns Promise that resolves when connected or rejects on timeout
1452
+ */
1453
+ waitForConnection(timeoutMs = 1e4) {
1454
+ return new Promise((resolve, reject) => {
1455
+ if (this.connectionProvider.isConnected()) {
1456
+ resolve();
1457
+ return;
1458
+ }
1459
+ const timeout = setTimeout(() => {
1460
+ this.connectionProvider.off("connected", handler2);
1461
+ reject(new Error("Connection timeout waiting for reconnection"));
1462
+ }, timeoutMs);
1463
+ const handler2 = () => {
1464
+ clearTimeout(timeout);
1465
+ this.connectionProvider.off("connected", handler2);
1466
+ resolve();
1467
+ };
1468
+ this.connectionProvider.on("connected", handler2);
1469
+ });
1470
+ }
1471
+ /**
1472
+ * Wait for a specific sync state.
1473
+ * Useful for waiting until fully connected and synced.
1474
+ *
1475
+ * @param targetState - The state to wait for
1476
+ * @param timeoutMs - Maximum time to wait (default: 30000ms)
1477
+ * @returns Promise that resolves when state is reached or rejects on timeout
1478
+ */
1479
+ waitForState(targetState, timeoutMs = 3e4) {
1480
+ return new Promise((resolve, reject) => {
1481
+ if (this.stateMachine.getState() === targetState) {
1482
+ resolve();
1483
+ return;
1484
+ }
1485
+ const timeout = setTimeout(() => {
1486
+ unsubscribe();
1487
+ reject(new Error(`Timeout waiting for state ${targetState}`));
1488
+ }, timeoutMs);
1489
+ const unsubscribe = this.stateMachine.onStateChange((event) => {
1490
+ if (event.to === targetState) {
1491
+ clearTimeout(timeout);
1492
+ unsubscribe();
1493
+ resolve();
1494
+ }
1495
+ });
1496
+ });
1497
+ }
1498
+ /**
1499
+ * Check if the connection provider is connected.
1500
+ * Convenience method for failover logic.
1501
+ */
1502
+ isProviderConnected() {
1503
+ return this.connectionProvider.isConnected();
1504
+ }
1505
+ /**
1506
+ * Get the connection provider for direct access.
1507
+ * Use with caution - prefer using SyncEngine methods.
1508
+ */
1509
+ getConnectionProvider() {
1510
+ return this.connectionProvider;
1070
1511
  }
1071
1512
  async resetMap(mapName) {
1072
1513
  const map = this.maps.get(mapName);
@@ -1114,12 +1555,12 @@ var SyncEngine = class {
1114
1555
  * Sends a PING message to the server.
1115
1556
  */
1116
1557
  sendPing() {
1117
- if (this.websocket?.readyState === WebSocket.OPEN) {
1558
+ if (this.canSend()) {
1118
1559
  const pingMessage = {
1119
1560
  type: "PING",
1120
1561
  timestamp: Date.now()
1121
1562
  };
1122
- this.websocket.send((0, import_core.serialize)(pingMessage));
1563
+ this.sendMessage(pingMessage);
1123
1564
  }
1124
1565
  }
1125
1566
  /**
@@ -1198,13 +1639,13 @@ var SyncEngine = class {
1198
1639
  }
1199
1640
  }
1200
1641
  if (entries.length > 0) {
1201
- this.websocket?.send((0, import_core.serialize)({
1642
+ this.sendMessage({
1202
1643
  type: "ORMAP_PUSH_DIFF",
1203
1644
  payload: {
1204
1645
  mapName,
1205
1646
  entries
1206
1647
  }
1207
- }));
1648
+ });
1208
1649
  logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
1209
1650
  }
1210
1651
  }
@@ -1430,7 +1871,7 @@ var SyncEngine = class {
1430
1871
  };
1431
1872
 
1432
1873
  // src/TopGunClient.ts
1433
- var import_core2 = require("@topgunbuild/core");
1874
+ var import_core6 = require("@topgunbuild/core");
1434
1875
 
1435
1876
  // src/QueryHandle.ts
1436
1877
  var QueryHandle = class {
@@ -1631,33 +2072,1496 @@ var TopicHandle = class {
1631
2072
  }
1632
2073
  };
1633
2074
 
1634
- // src/TopGunClient.ts
1635
- var TopGunClient = class {
1636
- constructor(config) {
1637
- this.maps = /* @__PURE__ */ new Map();
1638
- this.topicHandles = /* @__PURE__ */ new Map();
1639
- this.nodeId = config.nodeId || crypto.randomUUID();
1640
- this.storageAdapter = config.storage;
1641
- const syncEngineConfig = {
1642
- nodeId: this.nodeId,
1643
- serverUrl: config.serverUrl,
1644
- storageAdapter: this.storageAdapter,
1645
- backoff: config.backoff,
1646
- backpressure: config.backpressure
2075
+ // src/cluster/ClusterClient.ts
2076
+ var import_core5 = require("@topgunbuild/core");
2077
+
2078
+ // src/cluster/ConnectionPool.ts
2079
+ var import_core2 = require("@topgunbuild/core");
2080
+ var import_core3 = require("@topgunbuild/core");
2081
+ var ConnectionPool = class {
2082
+ constructor(config = {}) {
2083
+ this.listeners = /* @__PURE__ */ new Map();
2084
+ this.connections = /* @__PURE__ */ new Map();
2085
+ this.primaryNodeId = null;
2086
+ this.healthCheckTimer = null;
2087
+ this.authToken = null;
2088
+ this.config = {
2089
+ ...import_core2.DEFAULT_CONNECTION_POOL_CONFIG,
2090
+ ...config
1647
2091
  };
1648
- this.syncEngine = new SyncEngine(syncEngineConfig);
1649
2092
  }
1650
- async start() {
1651
- await this.storageAdapter.initialize("topgun_offline_db");
2093
+ // ============================================
2094
+ // Event Emitter Methods (browser-compatible)
2095
+ // ============================================
2096
+ on(event, listener) {
2097
+ if (!this.listeners.has(event)) {
2098
+ this.listeners.set(event, /* @__PURE__ */ new Set());
2099
+ }
2100
+ this.listeners.get(event).add(listener);
2101
+ return this;
1652
2102
  }
1653
- setAuthToken(token) {
1654
- this.syncEngine.setAuthToken(token);
2103
+ off(event, listener) {
2104
+ this.listeners.get(event)?.delete(listener);
2105
+ return this;
1655
2106
  }
1656
- setAuthTokenProvider(provider) {
1657
- this.syncEngine.setTokenProvider(provider);
2107
+ emit(event, ...args) {
2108
+ const eventListeners = this.listeners.get(event);
2109
+ if (!eventListeners || eventListeners.size === 0) {
2110
+ return false;
2111
+ }
2112
+ for (const listener of eventListeners) {
2113
+ try {
2114
+ listener(...args);
2115
+ } catch (err) {
2116
+ logger.error({ event, err }, "Error in event listener");
2117
+ }
2118
+ }
2119
+ return true;
1658
2120
  }
1659
- /**
1660
- * Creates a live query subscription for a map.
2121
+ removeAllListeners(event) {
2122
+ if (event) {
2123
+ this.listeners.delete(event);
2124
+ } else {
2125
+ this.listeners.clear();
2126
+ }
2127
+ return this;
2128
+ }
2129
+ /**
2130
+ * Set authentication token for all connections
2131
+ */
2132
+ setAuthToken(token) {
2133
+ this.authToken = token;
2134
+ for (const conn of this.connections.values()) {
2135
+ if (conn.state === "CONNECTED") {
2136
+ this.sendAuth(conn);
2137
+ }
2138
+ }
2139
+ }
2140
+ /**
2141
+ * Add a node to the connection pool
2142
+ */
2143
+ async addNode(nodeId, endpoint) {
2144
+ if (this.connections.has(nodeId)) {
2145
+ const existing = this.connections.get(nodeId);
2146
+ if (existing.endpoint !== endpoint) {
2147
+ await this.removeNode(nodeId);
2148
+ } else {
2149
+ return;
2150
+ }
2151
+ }
2152
+ const connection = {
2153
+ nodeId,
2154
+ endpoint,
2155
+ socket: null,
2156
+ state: "DISCONNECTED",
2157
+ lastSeen: 0,
2158
+ latencyMs: 0,
2159
+ reconnectAttempts: 0,
2160
+ reconnectTimer: null,
2161
+ pendingMessages: []
2162
+ };
2163
+ this.connections.set(nodeId, connection);
2164
+ if (!this.primaryNodeId) {
2165
+ this.primaryNodeId = nodeId;
2166
+ }
2167
+ await this.connect(nodeId);
2168
+ }
2169
+ /**
2170
+ * Remove a node from the connection pool
2171
+ */
2172
+ async removeNode(nodeId) {
2173
+ const connection = this.connections.get(nodeId);
2174
+ if (!connection) return;
2175
+ if (connection.reconnectTimer) {
2176
+ clearTimeout(connection.reconnectTimer);
2177
+ connection.reconnectTimer = null;
2178
+ }
2179
+ if (connection.socket) {
2180
+ connection.socket.onclose = null;
2181
+ connection.socket.close();
2182
+ connection.socket = null;
2183
+ }
2184
+ this.connections.delete(nodeId);
2185
+ if (this.primaryNodeId === nodeId) {
2186
+ this.primaryNodeId = this.connections.size > 0 ? this.connections.keys().next().value ?? null : null;
2187
+ }
2188
+ logger.info({ nodeId }, "Node removed from connection pool");
2189
+ }
2190
+ /**
2191
+ * Get connection for a specific node
2192
+ */
2193
+ getConnection(nodeId) {
2194
+ const connection = this.connections.get(nodeId);
2195
+ if (!connection || connection.state !== "AUTHENTICATED") {
2196
+ return null;
2197
+ }
2198
+ return connection.socket;
2199
+ }
2200
+ /**
2201
+ * Get primary connection (first/seed node)
2202
+ */
2203
+ getPrimaryConnection() {
2204
+ if (!this.primaryNodeId) return null;
2205
+ return this.getConnection(this.primaryNodeId);
2206
+ }
2207
+ /**
2208
+ * Get any healthy connection
2209
+ */
2210
+ getAnyHealthyConnection() {
2211
+ for (const [nodeId, conn] of this.connections) {
2212
+ if (conn.state === "AUTHENTICATED" && conn.socket) {
2213
+ return { nodeId, socket: conn.socket };
2214
+ }
2215
+ }
2216
+ return null;
2217
+ }
2218
+ /**
2219
+ * Send message to a specific node
2220
+ */
2221
+ send(nodeId, message) {
2222
+ const connection = this.connections.get(nodeId);
2223
+ if (!connection) {
2224
+ logger.warn({ nodeId }, "Cannot send: node not in pool");
2225
+ return false;
2226
+ }
2227
+ const data = (0, import_core3.serialize)(message);
2228
+ if (connection.state === "AUTHENTICATED" && connection.socket?.readyState === WebSocket.OPEN) {
2229
+ connection.socket.send(data);
2230
+ return true;
2231
+ }
2232
+ if (connection.pendingMessages.length < 1e3) {
2233
+ connection.pendingMessages.push(data);
2234
+ return true;
2235
+ }
2236
+ logger.warn({ nodeId }, "Message queue full, dropping message");
2237
+ return false;
2238
+ }
2239
+ /**
2240
+ * Send message to primary node
2241
+ */
2242
+ sendToPrimary(message) {
2243
+ if (!this.primaryNodeId) {
2244
+ logger.warn("No primary node available");
2245
+ return false;
2246
+ }
2247
+ return this.send(this.primaryNodeId, message);
2248
+ }
2249
+ /**
2250
+ * Get health status for all nodes
2251
+ */
2252
+ getHealthStatus() {
2253
+ const status = /* @__PURE__ */ new Map();
2254
+ for (const [nodeId, conn] of this.connections) {
2255
+ status.set(nodeId, {
2256
+ nodeId,
2257
+ state: conn.state,
2258
+ lastSeen: conn.lastSeen,
2259
+ latencyMs: conn.latencyMs,
2260
+ reconnectAttempts: conn.reconnectAttempts
2261
+ });
2262
+ }
2263
+ return status;
2264
+ }
2265
+ /**
2266
+ * Get list of connected node IDs
2267
+ */
2268
+ getConnectedNodes() {
2269
+ return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
2270
+ }
2271
+ /**
2272
+ * Get all node IDs
2273
+ */
2274
+ getAllNodes() {
2275
+ return Array.from(this.connections.keys());
2276
+ }
2277
+ /**
2278
+ * Check if node is connected and authenticated
2279
+ */
2280
+ isNodeConnected(nodeId) {
2281
+ const conn = this.connections.get(nodeId);
2282
+ return conn?.state === "AUTHENTICATED";
2283
+ }
2284
+ /**
2285
+ * Check if connected to a specific node.
2286
+ * Alias for isNodeConnected() for IConnectionProvider compatibility.
2287
+ */
2288
+ isConnected(nodeId) {
2289
+ return this.isNodeConnected(nodeId);
2290
+ }
2291
+ /**
2292
+ * Start health monitoring
2293
+ */
2294
+ startHealthCheck() {
2295
+ if (this.healthCheckTimer) return;
2296
+ this.healthCheckTimer = setInterval(() => {
2297
+ this.performHealthCheck();
2298
+ }, this.config.healthCheckIntervalMs);
2299
+ }
2300
+ /**
2301
+ * Stop health monitoring
2302
+ */
2303
+ stopHealthCheck() {
2304
+ if (this.healthCheckTimer) {
2305
+ clearInterval(this.healthCheckTimer);
2306
+ this.healthCheckTimer = null;
2307
+ }
2308
+ }
2309
+ /**
2310
+ * Close all connections and cleanup
2311
+ */
2312
+ close() {
2313
+ this.stopHealthCheck();
2314
+ for (const nodeId of this.connections.keys()) {
2315
+ this.removeNode(nodeId);
2316
+ }
2317
+ this.connections.clear();
2318
+ this.primaryNodeId = null;
2319
+ }
2320
+ // ============================================
2321
+ // Private Methods
2322
+ // ============================================
2323
+ async connect(nodeId) {
2324
+ const connection = this.connections.get(nodeId);
2325
+ if (!connection) return;
2326
+ if (connection.state === "CONNECTING" || connection.state === "CONNECTED") {
2327
+ return;
2328
+ }
2329
+ connection.state = "CONNECTING";
2330
+ logger.info({ nodeId, endpoint: connection.endpoint }, "Connecting to node");
2331
+ try {
2332
+ const socket = new WebSocket(connection.endpoint);
2333
+ socket.binaryType = "arraybuffer";
2334
+ connection.socket = socket;
2335
+ socket.onopen = () => {
2336
+ connection.state = "CONNECTED";
2337
+ connection.reconnectAttempts = 0;
2338
+ connection.lastSeen = Date.now();
2339
+ logger.info({ nodeId }, "Connected to node");
2340
+ this.emit("node:connected", nodeId);
2341
+ if (this.authToken) {
2342
+ this.sendAuth(connection);
2343
+ }
2344
+ };
2345
+ socket.onmessage = (event) => {
2346
+ connection.lastSeen = Date.now();
2347
+ this.handleMessage(nodeId, event);
2348
+ };
2349
+ socket.onerror = (error) => {
2350
+ logger.error({ nodeId, error }, "WebSocket error");
2351
+ this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
2352
+ };
2353
+ socket.onclose = () => {
2354
+ const wasConnected = connection.state === "AUTHENTICATED";
2355
+ connection.state = "DISCONNECTED";
2356
+ connection.socket = null;
2357
+ if (wasConnected) {
2358
+ this.emit("node:disconnected", nodeId, "Connection closed");
2359
+ }
2360
+ this.scheduleReconnect(nodeId);
2361
+ };
2362
+ } catch (error) {
2363
+ connection.state = "FAILED";
2364
+ logger.error({ nodeId, error }, "Failed to connect");
2365
+ this.scheduleReconnect(nodeId);
2366
+ }
2367
+ }
2368
+ sendAuth(connection) {
2369
+ if (!this.authToken || !connection.socket) return;
2370
+ connection.socket.send((0, import_core3.serialize)({
2371
+ type: "AUTH",
2372
+ token: this.authToken
2373
+ }));
2374
+ }
2375
+ handleMessage(nodeId, event) {
2376
+ const connection = this.connections.get(nodeId);
2377
+ if (!connection) return;
2378
+ let message;
2379
+ try {
2380
+ if (event.data instanceof ArrayBuffer) {
2381
+ message = (0, import_core3.deserialize)(new Uint8Array(event.data));
2382
+ } else {
2383
+ message = JSON.parse(event.data);
2384
+ }
2385
+ } catch (e) {
2386
+ logger.error({ nodeId, error: e }, "Failed to parse message");
2387
+ return;
2388
+ }
2389
+ if (message.type === "AUTH_ACK") {
2390
+ connection.state = "AUTHENTICATED";
2391
+ logger.info({ nodeId }, "Authenticated with node");
2392
+ this.emit("node:healthy", nodeId);
2393
+ this.flushPendingMessages(connection);
2394
+ return;
2395
+ }
2396
+ if (message.type === "AUTH_REQUIRED") {
2397
+ if (this.authToken) {
2398
+ this.sendAuth(connection);
2399
+ }
2400
+ return;
2401
+ }
2402
+ if (message.type === "AUTH_FAIL") {
2403
+ logger.error({ nodeId, error: message.error }, "Authentication failed");
2404
+ connection.state = "FAILED";
2405
+ return;
2406
+ }
2407
+ if (message.type === "PONG") {
2408
+ if (message.timestamp) {
2409
+ connection.latencyMs = Date.now() - message.timestamp;
2410
+ }
2411
+ return;
2412
+ }
2413
+ if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
2414
+ this.emit("message", nodeId, message);
2415
+ return;
2416
+ }
2417
+ this.emit("message", nodeId, message);
2418
+ }
2419
+ flushPendingMessages(connection) {
2420
+ if (!connection.socket || connection.state !== "AUTHENTICATED") return;
2421
+ const pending = connection.pendingMessages;
2422
+ connection.pendingMessages = [];
2423
+ for (const data of pending) {
2424
+ if (connection.socket.readyState === WebSocket.OPEN) {
2425
+ connection.socket.send(data);
2426
+ }
2427
+ }
2428
+ if (pending.length > 0) {
2429
+ logger.debug({ nodeId: connection.nodeId, count: pending.length }, "Flushed pending messages");
2430
+ }
2431
+ }
2432
+ scheduleReconnect(nodeId) {
2433
+ const connection = this.connections.get(nodeId);
2434
+ if (!connection) return;
2435
+ if (connection.reconnectTimer) {
2436
+ clearTimeout(connection.reconnectTimer);
2437
+ connection.reconnectTimer = null;
2438
+ }
2439
+ if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
2440
+ connection.state = "FAILED";
2441
+ logger.error({ nodeId, attempts: connection.reconnectAttempts }, "Max reconnect attempts reached");
2442
+ this.emit("node:unhealthy", nodeId, "Max reconnect attempts reached");
2443
+ return;
2444
+ }
2445
+ const delay = Math.min(
2446
+ this.config.reconnectDelayMs * Math.pow(2, connection.reconnectAttempts),
2447
+ this.config.maxReconnectDelayMs
2448
+ );
2449
+ connection.state = "RECONNECTING";
2450
+ connection.reconnectAttempts++;
2451
+ logger.info({ nodeId, delay, attempt: connection.reconnectAttempts }, "Scheduling reconnect");
2452
+ connection.reconnectTimer = setTimeout(() => {
2453
+ connection.reconnectTimer = null;
2454
+ this.connect(nodeId);
2455
+ }, delay);
2456
+ }
2457
+ performHealthCheck() {
2458
+ const now = Date.now();
2459
+ for (const [nodeId, connection] of this.connections) {
2460
+ if (connection.state !== "AUTHENTICATED") continue;
2461
+ const timeSinceLastSeen = now - connection.lastSeen;
2462
+ if (timeSinceLastSeen > this.config.healthCheckIntervalMs * 3) {
2463
+ logger.warn({ nodeId, timeSinceLastSeen }, "Node appears stale, sending ping");
2464
+ }
2465
+ if (connection.socket?.readyState === WebSocket.OPEN) {
2466
+ connection.socket.send((0, import_core3.serialize)({
2467
+ type: "PING",
2468
+ timestamp: now
2469
+ }));
2470
+ }
2471
+ }
2472
+ }
2473
+ };
2474
+
2475
+ // src/cluster/PartitionRouter.ts
2476
+ var import_core4 = require("@topgunbuild/core");
2477
+ var PartitionRouter = class {
2478
+ constructor(connectionPool, config = {}) {
2479
+ this.listeners = /* @__PURE__ */ new Map();
2480
+ this.partitionMap = null;
2481
+ this.lastRefreshTime = 0;
2482
+ this.refreshTimer = null;
2483
+ this.pendingRefresh = null;
2484
+ this.connectionPool = connectionPool;
2485
+ this.config = {
2486
+ ...import_core4.DEFAULT_PARTITION_ROUTER_CONFIG,
2487
+ ...config
2488
+ };
2489
+ this.connectionPool.on("message", (nodeId, message) => {
2490
+ if (message.type === "PARTITION_MAP") {
2491
+ this.handlePartitionMap(message);
2492
+ } else if (message.type === "PARTITION_MAP_DELTA") {
2493
+ this.handlePartitionMapDelta(message);
2494
+ }
2495
+ });
2496
+ }
2497
+ // ============================================
2498
+ // Event Emitter Methods (browser-compatible)
2499
+ // ============================================
2500
+ on(event, listener) {
2501
+ if (!this.listeners.has(event)) {
2502
+ this.listeners.set(event, /* @__PURE__ */ new Set());
2503
+ }
2504
+ this.listeners.get(event).add(listener);
2505
+ return this;
2506
+ }
2507
+ off(event, listener) {
2508
+ this.listeners.get(event)?.delete(listener);
2509
+ return this;
2510
+ }
2511
+ once(event, listener) {
2512
+ const wrapper = (...args) => {
2513
+ this.off(event, wrapper);
2514
+ listener(...args);
2515
+ };
2516
+ return this.on(event, wrapper);
2517
+ }
2518
+ emit(event, ...args) {
2519
+ const eventListeners = this.listeners.get(event);
2520
+ if (!eventListeners || eventListeners.size === 0) {
2521
+ return false;
2522
+ }
2523
+ for (const listener of eventListeners) {
2524
+ try {
2525
+ listener(...args);
2526
+ } catch (err) {
2527
+ logger.error({ event, err }, "Error in event listener");
2528
+ }
2529
+ }
2530
+ return true;
2531
+ }
2532
+ removeListener(event, listener) {
2533
+ return this.off(event, listener);
2534
+ }
2535
+ removeAllListeners(event) {
2536
+ if (event) {
2537
+ this.listeners.delete(event);
2538
+ } else {
2539
+ this.listeners.clear();
2540
+ }
2541
+ return this;
2542
+ }
2543
+ /**
2544
+ * Get the partition ID for a given key
2545
+ */
2546
+ getPartitionId(key) {
2547
+ return Math.abs((0, import_core4.hashString)(key)) % import_core4.PARTITION_COUNT;
2548
+ }
2549
+ /**
2550
+ * Route a key to the owner node
2551
+ */
2552
+ route(key) {
2553
+ if (!this.partitionMap) {
2554
+ return null;
2555
+ }
2556
+ const partitionId = this.getPartitionId(key);
2557
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
2558
+ if (!partition) {
2559
+ logger.warn({ key, partitionId }, "Partition not found in map");
2560
+ return null;
2561
+ }
2562
+ return {
2563
+ nodeId: partition.ownerNodeId,
2564
+ partitionId,
2565
+ isOwner: true,
2566
+ isBackup: false
2567
+ };
2568
+ }
2569
+ /**
2570
+ * Route a key and get the WebSocket connection to use
2571
+ */
2572
+ routeToConnection(key) {
2573
+ const routing = this.route(key);
2574
+ if (!routing) {
2575
+ if (this.config.fallbackMode === "forward") {
2576
+ const primary = this.connectionPool.getAnyHealthyConnection();
2577
+ if (primary) {
2578
+ return primary;
2579
+ }
2580
+ }
2581
+ return null;
2582
+ }
2583
+ const socket = this.connectionPool.getConnection(routing.nodeId);
2584
+ if (socket) {
2585
+ return { nodeId: routing.nodeId, socket };
2586
+ }
2587
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
2588
+ if (partition) {
2589
+ for (const backupId of partition.backupNodeIds) {
2590
+ const backupSocket = this.connectionPool.getConnection(backupId);
2591
+ if (backupSocket) {
2592
+ logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
2593
+ return { nodeId: backupId, socket: backupSocket };
2594
+ }
2595
+ }
2596
+ }
2597
+ if (this.config.fallbackMode === "forward") {
2598
+ return this.connectionPool.getAnyHealthyConnection();
2599
+ }
2600
+ return null;
2601
+ }
2602
+ /**
2603
+ * Get routing info for multiple keys (batch routing)
2604
+ */
2605
+ routeBatch(keys) {
2606
+ const result = /* @__PURE__ */ new Map();
2607
+ for (const key of keys) {
2608
+ const routing = this.route(key);
2609
+ if (routing) {
2610
+ const nodeId = routing.nodeId;
2611
+ if (!result.has(nodeId)) {
2612
+ result.set(nodeId, []);
2613
+ }
2614
+ result.get(nodeId).push({ ...routing, key });
2615
+ }
2616
+ }
2617
+ return result;
2618
+ }
2619
+ /**
2620
+ * Get all partitions owned by a specific node
2621
+ */
2622
+ getPartitionsForNode(nodeId) {
2623
+ if (!this.partitionMap) return [];
2624
+ return this.partitionMap.partitions.filter((p) => p.ownerNodeId === nodeId).map((p) => p.partitionId);
2625
+ }
2626
+ /**
2627
+ * Get current partition map version
2628
+ */
2629
+ getMapVersion() {
2630
+ return this.partitionMap?.version ?? 0;
2631
+ }
2632
+ /**
2633
+ * Check if partition map is available
2634
+ */
2635
+ hasPartitionMap() {
2636
+ return this.partitionMap !== null;
2637
+ }
2638
+ /**
2639
+ * Get owner node for a key.
2640
+ * Returns null if partition map is not available.
2641
+ */
2642
+ getOwner(key) {
2643
+ if (!this.partitionMap) return null;
2644
+ const partitionId = this.getPartitionId(key);
2645
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
2646
+ return partition?.ownerNodeId ?? null;
2647
+ }
2648
+ /**
2649
+ * Get backup nodes for a key.
2650
+ * Returns empty array if partition map is not available.
2651
+ */
2652
+ getBackups(key) {
2653
+ if (!this.partitionMap) return [];
2654
+ const partitionId = this.getPartitionId(key);
2655
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
2656
+ return partition?.backupNodeIds ?? [];
2657
+ }
2658
+ /**
2659
+ * Get the full partition map.
2660
+ * Returns null if not available.
2661
+ */
2662
+ getMap() {
2663
+ return this.partitionMap;
2664
+ }
2665
+ /**
2666
+ * Update entire partition map.
2667
+ * Only accepts newer versions.
2668
+ */
2669
+ updateMap(map) {
2670
+ if (this.partitionMap && map.version <= this.partitionMap.version) {
2671
+ return false;
2672
+ }
2673
+ this.partitionMap = map;
2674
+ this.lastRefreshTime = Date.now();
2675
+ this.updateConnectionPool(map);
2676
+ const changesCount = map.partitions.length;
2677
+ logger.info({
2678
+ version: map.version,
2679
+ partitions: map.partitionCount,
2680
+ nodes: map.nodes.length
2681
+ }, "Partition map updated via updateMap");
2682
+ this.emit("partitionMap:updated", map.version, changesCount);
2683
+ return true;
2684
+ }
2685
+ /**
2686
+ * Update a single partition (for delta updates).
2687
+ */
2688
+ updatePartition(partitionId, owner, backups) {
2689
+ if (!this.partitionMap) return;
2690
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
2691
+ if (partition) {
2692
+ partition.ownerNodeId = owner;
2693
+ partition.backupNodeIds = backups;
2694
+ }
2695
+ }
2696
+ /**
2697
+ * Check if partition map is stale
2698
+ */
2699
+ isMapStale() {
2700
+ if (!this.partitionMap) return true;
2701
+ const now = Date.now();
2702
+ return now - this.lastRefreshTime > this.config.maxMapStalenessMs;
2703
+ }
2704
+ /**
2705
+ * Request fresh partition map from server
2706
+ */
2707
+ async refreshPartitionMap() {
2708
+ if (this.pendingRefresh) {
2709
+ return this.pendingRefresh;
2710
+ }
2711
+ this.pendingRefresh = this.doRefreshPartitionMap();
2712
+ try {
2713
+ await this.pendingRefresh;
2714
+ } finally {
2715
+ this.pendingRefresh = null;
2716
+ }
2717
+ }
2718
+ /**
2719
+ * Start periodic partition map refresh
2720
+ */
2721
+ startPeriodicRefresh() {
2722
+ if (this.refreshTimer) return;
2723
+ this.refreshTimer = setInterval(() => {
2724
+ if (this.isMapStale()) {
2725
+ this.emit("partitionMap:stale", this.getMapVersion(), this.lastRefreshTime);
2726
+ this.refreshPartitionMap().catch((err) => {
2727
+ logger.error({ error: err }, "Failed to refresh partition map");
2728
+ });
2729
+ }
2730
+ }, this.config.mapRefreshIntervalMs);
2731
+ }
2732
+ /**
2733
+ * Stop periodic refresh
2734
+ */
2735
+ stopPeriodicRefresh() {
2736
+ if (this.refreshTimer) {
2737
+ clearInterval(this.refreshTimer);
2738
+ this.refreshTimer = null;
2739
+ }
2740
+ }
2741
+ /**
2742
+ * Handle NOT_OWNER error from server
2743
+ */
2744
+ handleNotOwnerError(key, actualOwner, newMapVersion) {
2745
+ const routing = this.route(key);
2746
+ const expectedOwner = routing?.nodeId ?? "unknown";
2747
+ this.emit("routing:miss", key, expectedOwner, actualOwner);
2748
+ if (newMapVersion > this.getMapVersion()) {
2749
+ this.refreshPartitionMap().catch((err) => {
2750
+ logger.error({ error: err }, "Failed to refresh partition map after NOT_OWNER");
2751
+ });
2752
+ }
2753
+ }
2754
+ /**
2755
+ * Get statistics about routing
2756
+ */
2757
+ getStats() {
2758
+ return {
2759
+ mapVersion: this.getMapVersion(),
2760
+ partitionCount: this.partitionMap?.partitionCount ?? 0,
2761
+ nodeCount: this.partitionMap?.nodes.length ?? 0,
2762
+ lastRefresh: this.lastRefreshTime,
2763
+ isStale: this.isMapStale()
2764
+ };
2765
+ }
2766
+ /**
2767
+ * Cleanup resources
2768
+ */
2769
+ close() {
2770
+ this.stopPeriodicRefresh();
2771
+ this.partitionMap = null;
2772
+ }
2773
+ // ============================================
2774
+ // Private Methods
2775
+ // ============================================
2776
+ handlePartitionMap(message) {
2777
+ const newMap = message.payload;
2778
+ if (this.partitionMap && newMap.version <= this.partitionMap.version) {
2779
+ logger.debug({
2780
+ current: this.partitionMap.version,
2781
+ received: newMap.version
2782
+ }, "Ignoring older partition map");
2783
+ return;
2784
+ }
2785
+ this.partitionMap = newMap;
2786
+ this.lastRefreshTime = Date.now();
2787
+ this.updateConnectionPool(newMap);
2788
+ const changesCount = newMap.partitions.length;
2789
+ logger.info({
2790
+ version: newMap.version,
2791
+ partitions: newMap.partitionCount,
2792
+ nodes: newMap.nodes.length
2793
+ }, "Partition map updated");
2794
+ this.emit("partitionMap:updated", newMap.version, changesCount);
2795
+ }
2796
+ handlePartitionMapDelta(message) {
2797
+ const delta = message.payload;
2798
+ if (!this.partitionMap) {
2799
+ logger.warn("Received delta but no base map, requesting full map");
2800
+ this.refreshPartitionMap();
2801
+ return;
2802
+ }
2803
+ if (delta.previousVersion !== this.partitionMap.version) {
2804
+ logger.warn({
2805
+ expected: this.partitionMap.version,
2806
+ received: delta.previousVersion
2807
+ }, "Delta version mismatch, requesting full map");
2808
+ this.refreshPartitionMap();
2809
+ return;
2810
+ }
2811
+ for (const change of delta.changes) {
2812
+ this.applyPartitionChange(change);
2813
+ }
2814
+ this.partitionMap.version = delta.version;
2815
+ this.lastRefreshTime = Date.now();
2816
+ logger.info({
2817
+ version: delta.version,
2818
+ changes: delta.changes.length
2819
+ }, "Applied partition map delta");
2820
+ this.emit("partitionMap:updated", delta.version, delta.changes.length);
2821
+ }
2822
+ applyPartitionChange(change) {
2823
+ if (!this.partitionMap) return;
2824
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === change.partitionId);
2825
+ if (partition) {
2826
+ partition.ownerNodeId = change.newOwner;
2827
+ }
2828
+ }
2829
+ updateConnectionPool(map) {
2830
+ for (const node of map.nodes) {
2831
+ if (node.status === "ACTIVE" || node.status === "JOINING") {
2832
+ this.connectionPool.addNode(node.nodeId, node.endpoints.websocket);
2833
+ }
2834
+ }
2835
+ const currentNodeIds = new Set(map.nodes.map((n) => n.nodeId));
2836
+ for (const nodeId of this.connectionPool.getAllNodes()) {
2837
+ if (!currentNodeIds.has(nodeId)) {
2838
+ this.connectionPool.removeNode(nodeId);
2839
+ }
2840
+ }
2841
+ }
2842
+ async doRefreshPartitionMap() {
2843
+ logger.debug("Requesting partition map refresh");
2844
+ const sent = this.connectionPool.sendToPrimary({
2845
+ type: "PARTITION_MAP_REQUEST",
2846
+ payload: {
2847
+ currentVersion: this.getMapVersion()
2848
+ }
2849
+ });
2850
+ if (!sent) {
2851
+ throw new Error("No connection available to request partition map");
2852
+ }
2853
+ return new Promise((resolve, reject) => {
2854
+ const timeout = setTimeout(() => {
2855
+ this.removeListener("partitionMap:updated", onUpdate);
2856
+ reject(new Error("Partition map refresh timeout"));
2857
+ }, 5e3);
2858
+ const onUpdate = () => {
2859
+ clearTimeout(timeout);
2860
+ this.removeListener("partitionMap:updated", onUpdate);
2861
+ resolve();
2862
+ };
2863
+ this.once("partitionMap:updated", onUpdate);
2864
+ });
2865
+ }
2866
+ };
2867
+
2868
+ // src/cluster/ClusterClient.ts
2869
+ var ClusterClient = class {
2870
+ constructor(config) {
2871
+ this.listeners = /* @__PURE__ */ new Map();
2872
+ this.initialized = false;
2873
+ this.routingActive = false;
2874
+ this.routingMetrics = {
2875
+ directRoutes: 0,
2876
+ fallbackRoutes: 0,
2877
+ partitionMisses: 0,
2878
+ totalRoutes: 0
2879
+ };
2880
+ // Circuit breaker state per node
2881
+ this.circuits = /* @__PURE__ */ new Map();
2882
+ this.config = config;
2883
+ this.circuitBreakerConfig = {
2884
+ ...import_core5.DEFAULT_CIRCUIT_BREAKER_CONFIG,
2885
+ ...config.circuitBreaker
2886
+ };
2887
+ const poolConfig = {
2888
+ ...import_core5.DEFAULT_CONNECTION_POOL_CONFIG,
2889
+ ...config.connectionPool
2890
+ };
2891
+ this.connectionPool = new ConnectionPool(poolConfig);
2892
+ const routerConfig = {
2893
+ ...import_core5.DEFAULT_PARTITION_ROUTER_CONFIG,
2894
+ fallbackMode: config.routingMode === "direct" ? "error" : "forward",
2895
+ ...config.routing
2896
+ };
2897
+ this.partitionRouter = new PartitionRouter(this.connectionPool, routerConfig);
2898
+ this.setupEventHandlers();
2899
+ }
2900
+ // ============================================
2901
+ // Event Emitter Methods (browser-compatible)
2902
+ // ============================================
2903
+ on(event, listener) {
2904
+ if (!this.listeners.has(event)) {
2905
+ this.listeners.set(event, /* @__PURE__ */ new Set());
2906
+ }
2907
+ this.listeners.get(event).add(listener);
2908
+ return this;
2909
+ }
2910
+ off(event, listener) {
2911
+ this.listeners.get(event)?.delete(listener);
2912
+ return this;
2913
+ }
2914
+ emit(event, ...args) {
2915
+ const eventListeners = this.listeners.get(event);
2916
+ if (!eventListeners || eventListeners.size === 0) {
2917
+ return false;
2918
+ }
2919
+ for (const listener of eventListeners) {
2920
+ try {
2921
+ listener(...args);
2922
+ } catch (err) {
2923
+ logger.error({ event, err }, "Error in event listener");
2924
+ }
2925
+ }
2926
+ return true;
2927
+ }
2928
+ removeAllListeners(event) {
2929
+ if (event) {
2930
+ this.listeners.delete(event);
2931
+ } else {
2932
+ this.listeners.clear();
2933
+ }
2934
+ return this;
2935
+ }
2936
+ // ============================================
2937
+ // IConnectionProvider Implementation
2938
+ // ============================================
2939
+ /**
2940
+ * Connect to cluster nodes (IConnectionProvider interface).
2941
+ * Alias for start() method.
2942
+ */
2943
+ async connect() {
2944
+ return this.start();
2945
+ }
2946
+ /**
2947
+ * Get connection for a specific key (IConnectionProvider interface).
2948
+ * Routes to partition owner based on key hash when smart routing is enabled.
2949
+ * @throws Error if not connected
2950
+ */
2951
+ getConnection(key) {
2952
+ if (!this.isConnected()) {
2953
+ throw new Error("ClusterClient not connected");
2954
+ }
2955
+ this.routingMetrics.totalRoutes++;
2956
+ if (this.config.routingMode !== "direct" || !this.routingActive) {
2957
+ this.routingMetrics.fallbackRoutes++;
2958
+ return this.getFallbackConnection();
2959
+ }
2960
+ const routing = this.partitionRouter.route(key);
2961
+ if (!routing) {
2962
+ this.routingMetrics.partitionMisses++;
2963
+ logger.debug({ key }, "No partition map available, using fallback");
2964
+ return this.getFallbackConnection();
2965
+ }
2966
+ const owner = routing.nodeId;
2967
+ if (!this.connectionPool.isNodeConnected(owner)) {
2968
+ this.routingMetrics.fallbackRoutes++;
2969
+ logger.debug({ key, owner }, "Partition owner not connected, using fallback");
2970
+ this.requestPartitionMapRefresh();
2971
+ return this.getFallbackConnection();
2972
+ }
2973
+ const socket = this.connectionPool.getConnection(owner);
2974
+ if (!socket) {
2975
+ this.routingMetrics.fallbackRoutes++;
2976
+ logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
2977
+ return this.getFallbackConnection();
2978
+ }
2979
+ this.routingMetrics.directRoutes++;
2980
+ return socket;
2981
+ }
2982
+ /**
2983
+ * Get fallback connection when owner is unavailable.
2984
+ * @throws Error if no connection available
2985
+ */
2986
+ getFallbackConnection() {
2987
+ const conn = this.connectionPool.getAnyHealthyConnection();
2988
+ if (!conn?.socket) {
2989
+ throw new Error("No healthy connection available");
2990
+ }
2991
+ return conn.socket;
2992
+ }
2993
+ /**
2994
+ * Request a partition map refresh in the background.
2995
+ * Called when routing to an unknown/disconnected owner.
2996
+ */
2997
+ requestPartitionMapRefresh() {
2998
+ this.partitionRouter.refreshPartitionMap().catch((err) => {
2999
+ logger.error({ err }, "Failed to refresh partition map");
3000
+ });
3001
+ }
3002
+ /**
3003
+ * Request partition map from a specific node.
3004
+ * Called on first node connection.
3005
+ */
3006
+ requestPartitionMapFromNode(nodeId) {
3007
+ const socket = this.connectionPool.getConnection(nodeId);
3008
+ if (socket) {
3009
+ logger.debug({ nodeId }, "Requesting partition map from node");
3010
+ socket.send((0, import_core5.serialize)({
3011
+ type: "PARTITION_MAP_REQUEST",
3012
+ payload: {
3013
+ currentVersion: this.partitionRouter.getMapVersion()
3014
+ }
3015
+ }));
3016
+ }
3017
+ }
3018
+ /**
3019
+ * Check if at least one connection is active (IConnectionProvider interface).
3020
+ */
3021
+ isConnected() {
3022
+ return this.connectionPool.getConnectedNodes().length > 0;
3023
+ }
3024
+ /**
3025
+ * Send data via the appropriate connection (IConnectionProvider interface).
3026
+ * Routes based on key if provided.
3027
+ */
3028
+ send(data, key) {
3029
+ if (!this.isConnected()) {
3030
+ throw new Error("ClusterClient not connected");
3031
+ }
3032
+ const socket = key ? this.getConnection(key) : this.getAnyConnection();
3033
+ socket.send(data);
3034
+ }
3035
+ /**
3036
+ * Send data with automatic retry and rerouting on failure.
3037
+ * @param data - Data to send
3038
+ * @param key - Optional key for routing
3039
+ * @param options - Retry options
3040
+ * @throws Error after max retries exceeded
3041
+ */
3042
+ async sendWithRetry(data, key, options = {}) {
3043
+ const {
3044
+ maxRetries = 3,
3045
+ retryDelayMs = 100,
3046
+ retryOnNotOwner = true
3047
+ } = options;
3048
+ let lastError = null;
3049
+ let nodeId = null;
3050
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
3051
+ try {
3052
+ if (key && this.routingActive) {
3053
+ const routing = this.partitionRouter.route(key);
3054
+ nodeId = routing?.nodeId ?? null;
3055
+ }
3056
+ if (nodeId && !this.canUseNode(nodeId)) {
3057
+ logger.debug({ nodeId, attempt }, "Circuit open, using fallback");
3058
+ nodeId = null;
3059
+ }
3060
+ const socket = key && nodeId ? this.connectionPool.getConnection(nodeId) : this.getAnyConnection();
3061
+ if (!socket) {
3062
+ throw new Error("No connection available");
3063
+ }
3064
+ socket.send(data);
3065
+ if (nodeId) {
3066
+ this.recordSuccess(nodeId);
3067
+ }
3068
+ return;
3069
+ } catch (error) {
3070
+ lastError = error;
3071
+ if (nodeId) {
3072
+ this.recordFailure(nodeId);
3073
+ }
3074
+ const errorCode = error?.code;
3075
+ if (this.isRetryableError(error)) {
3076
+ logger.debug(
3077
+ { attempt, maxRetries, errorCode, nodeId },
3078
+ "Retryable error, will retry"
3079
+ );
3080
+ if (errorCode === "NOT_OWNER" && retryOnNotOwner) {
3081
+ await this.waitForPartitionMapUpdateInternal(2e3);
3082
+ } else if (errorCode === "CONNECTION_CLOSED" || !this.isConnected()) {
3083
+ await this.waitForConnectionInternal(5e3);
3084
+ }
3085
+ await this.delay(retryDelayMs * (attempt + 1));
3086
+ continue;
3087
+ }
3088
+ throw error;
3089
+ }
3090
+ }
3091
+ throw new Error(
3092
+ `Operation failed after ${maxRetries} retries: ${lastError?.message}`
3093
+ );
3094
+ }
3095
+ /**
3096
+ * Check if an error is retryable.
3097
+ */
3098
+ isRetryableError(error) {
3099
+ const code = error?.code;
3100
+ const message = error?.message || "";
3101
+ 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");
3102
+ }
3103
+ /**
3104
+ * Wait for partition map update.
3105
+ */
3106
+ waitForPartitionMapUpdateInternal(timeoutMs) {
3107
+ return new Promise((resolve) => {
3108
+ const timeout = setTimeout(resolve, timeoutMs);
3109
+ const handler2 = () => {
3110
+ clearTimeout(timeout);
3111
+ this.off("partitionMapUpdated", handler2);
3112
+ resolve();
3113
+ };
3114
+ this.on("partitionMapUpdated", handler2);
3115
+ });
3116
+ }
3117
+ /**
3118
+ * Wait for at least one connection to be available.
3119
+ */
3120
+ waitForConnectionInternal(timeoutMs) {
3121
+ return new Promise((resolve, reject) => {
3122
+ if (this.isConnected()) {
3123
+ resolve();
3124
+ return;
3125
+ }
3126
+ const timeout = setTimeout(() => {
3127
+ this.off("connected", handler2);
3128
+ reject(new Error("Connection timeout"));
3129
+ }, timeoutMs);
3130
+ const handler2 = () => {
3131
+ clearTimeout(timeout);
3132
+ this.off("connected", handler2);
3133
+ resolve();
3134
+ };
3135
+ this.on("connected", handler2);
3136
+ });
3137
+ }
3138
+ /**
3139
+ * Helper delay function.
3140
+ */
3141
+ delay(ms) {
3142
+ return new Promise((resolve) => setTimeout(resolve, ms));
3143
+ }
3144
+ // ============================================
3145
+ // Cluster-Specific Methods
3146
+ // ============================================
3147
+ /**
3148
+ * Initialize cluster connections
3149
+ */
3150
+ async start() {
3151
+ if (this.initialized) return;
3152
+ logger.info({ seedNodes: this.config.seedNodes }, "Starting cluster client");
3153
+ for (let i = 0; i < this.config.seedNodes.length; i++) {
3154
+ const endpoint = this.config.seedNodes[i];
3155
+ const nodeId = `seed-${i}`;
3156
+ await this.connectionPool.addNode(nodeId, endpoint);
3157
+ }
3158
+ this.connectionPool.startHealthCheck();
3159
+ this.partitionRouter.startPeriodicRefresh();
3160
+ this.initialized = true;
3161
+ await this.waitForPartitionMap();
3162
+ }
3163
+ /**
3164
+ * Set authentication token
3165
+ */
3166
+ setAuthToken(token) {
3167
+ this.connectionPool.setAuthToken(token);
3168
+ }
3169
+ /**
3170
+ * Send operation with automatic routing (legacy API for cluster operations).
3171
+ * @deprecated Use send(data, key) for IConnectionProvider interface
3172
+ */
3173
+ sendMessage(key, message) {
3174
+ if (this.config.routingMode === "direct" && this.routingActive) {
3175
+ return this.sendDirect(key, message);
3176
+ }
3177
+ return this.sendForward(message);
3178
+ }
3179
+ /**
3180
+ * Send directly to partition owner
3181
+ */
3182
+ sendDirect(key, message) {
3183
+ const connection = this.partitionRouter.routeToConnection(key);
3184
+ if (!connection) {
3185
+ logger.warn({ key }, "No route available for key");
3186
+ return false;
3187
+ }
3188
+ const routedMessage = {
3189
+ ...message,
3190
+ _routing: {
3191
+ partitionId: this.partitionRouter.getPartitionId(key),
3192
+ mapVersion: this.partitionRouter.getMapVersion()
3193
+ }
3194
+ };
3195
+ connection.socket.send((0, import_core5.serialize)(routedMessage));
3196
+ return true;
3197
+ }
3198
+ /**
3199
+ * Send to primary node for server-side forwarding
3200
+ */
3201
+ sendForward(message) {
3202
+ return this.connectionPool.sendToPrimary(message);
3203
+ }
3204
+ /**
3205
+ * Send batch of operations with routing
3206
+ */
3207
+ sendBatch(operations) {
3208
+ const results = /* @__PURE__ */ new Map();
3209
+ if (this.config.routingMode === "direct" && this.routingActive) {
3210
+ const nodeMessages = /* @__PURE__ */ new Map();
3211
+ for (const { key, message } of operations) {
3212
+ const routing = this.partitionRouter.route(key);
3213
+ const nodeId = routing?.nodeId ?? "primary";
3214
+ if (!nodeMessages.has(nodeId)) {
3215
+ nodeMessages.set(nodeId, []);
3216
+ }
3217
+ nodeMessages.get(nodeId).push({ key, message });
3218
+ }
3219
+ for (const [nodeId, messages] of nodeMessages) {
3220
+ let success;
3221
+ if (nodeId === "primary") {
3222
+ success = this.connectionPool.sendToPrimary({
3223
+ type: "OP_BATCH",
3224
+ payload: { ops: messages.map((m) => m.message) }
3225
+ });
3226
+ } else {
3227
+ success = this.connectionPool.send(nodeId, {
3228
+ type: "OP_BATCH",
3229
+ payload: { ops: messages.map((m) => m.message) }
3230
+ });
3231
+ }
3232
+ for (const { key } of messages) {
3233
+ results.set(key, success);
3234
+ }
3235
+ }
3236
+ } else {
3237
+ const success = this.connectionPool.sendToPrimary({
3238
+ type: "OP_BATCH",
3239
+ payload: { ops: operations.map((o) => o.message) }
3240
+ });
3241
+ for (const { key } of operations) {
3242
+ results.set(key, success);
3243
+ }
3244
+ }
3245
+ return results;
3246
+ }
3247
+ /**
3248
+ * Get connection pool health status
3249
+ */
3250
+ getHealthStatus() {
3251
+ return this.connectionPool.getHealthStatus();
3252
+ }
3253
+ /**
3254
+ * Get partition router stats
3255
+ */
3256
+ getRouterStats() {
3257
+ return this.partitionRouter.getStats();
3258
+ }
3259
+ /**
3260
+ * Get routing metrics for monitoring smart routing effectiveness.
3261
+ */
3262
+ getRoutingMetrics() {
3263
+ return { ...this.routingMetrics };
3264
+ }
3265
+ /**
3266
+ * Reset routing metrics counters.
3267
+ * Useful for monitoring intervals.
3268
+ */
3269
+ resetRoutingMetrics() {
3270
+ this.routingMetrics.directRoutes = 0;
3271
+ this.routingMetrics.fallbackRoutes = 0;
3272
+ this.routingMetrics.partitionMisses = 0;
3273
+ this.routingMetrics.totalRoutes = 0;
3274
+ }
3275
+ /**
3276
+ * Check if cluster routing is active
3277
+ */
3278
+ isRoutingActive() {
3279
+ return this.routingActive;
3280
+ }
3281
+ /**
3282
+ * Get list of connected nodes
3283
+ */
3284
+ getConnectedNodes() {
3285
+ return this.connectionPool.getConnectedNodes();
3286
+ }
3287
+ /**
3288
+ * Check if cluster client is initialized
3289
+ */
3290
+ isInitialized() {
3291
+ return this.initialized;
3292
+ }
3293
+ /**
3294
+ * Force refresh of partition map
3295
+ */
3296
+ async refreshPartitionMap() {
3297
+ await this.partitionRouter.refreshPartitionMap();
3298
+ }
3299
+ /**
3300
+ * Shutdown cluster client (IConnectionProvider interface).
3301
+ */
3302
+ async close() {
3303
+ this.partitionRouter.close();
3304
+ this.connectionPool.close();
3305
+ this.initialized = false;
3306
+ this.routingActive = false;
3307
+ logger.info("Cluster client closed");
3308
+ }
3309
+ // ============================================
3310
+ // Internal Access for TopGunClient
3311
+ // ============================================
3312
+ /**
3313
+ * Get the connection pool (for internal use)
3314
+ */
3315
+ getConnectionPool() {
3316
+ return this.connectionPool;
3317
+ }
3318
+ /**
3319
+ * Get the partition router (for internal use)
3320
+ */
3321
+ getPartitionRouter() {
3322
+ return this.partitionRouter;
3323
+ }
3324
+ /**
3325
+ * Get any healthy WebSocket connection (IConnectionProvider interface).
3326
+ * @throws Error if not connected
3327
+ */
3328
+ getAnyConnection() {
3329
+ const conn = this.connectionPool.getAnyHealthyConnection();
3330
+ if (!conn?.socket) {
3331
+ throw new Error("No healthy connection available");
3332
+ }
3333
+ return conn.socket;
3334
+ }
3335
+ /**
3336
+ * Get any healthy WebSocket connection, or null if none available.
3337
+ * Use this for optional connection checks.
3338
+ */
3339
+ getAnyConnectionOrNull() {
3340
+ const conn = this.connectionPool.getAnyHealthyConnection();
3341
+ return conn?.socket ?? null;
3342
+ }
3343
+ // ============================================
3344
+ // Circuit Breaker Methods
3345
+ // ============================================
3346
+ /**
3347
+ * Get circuit breaker state for a node.
3348
+ */
3349
+ getCircuit(nodeId) {
3350
+ let circuit = this.circuits.get(nodeId);
3351
+ if (!circuit) {
3352
+ circuit = { failures: 0, lastFailure: 0, state: "closed" };
3353
+ this.circuits.set(nodeId, circuit);
3354
+ }
3355
+ return circuit;
3356
+ }
3357
+ /**
3358
+ * Check if a node can be used (circuit not open).
3359
+ */
3360
+ canUseNode(nodeId) {
3361
+ const circuit = this.getCircuit(nodeId);
3362
+ if (circuit.state === "closed") {
3363
+ return true;
3364
+ }
3365
+ if (circuit.state === "open") {
3366
+ if (Date.now() - circuit.lastFailure > this.circuitBreakerConfig.resetTimeoutMs) {
3367
+ circuit.state = "half-open";
3368
+ logger.debug({ nodeId }, "Circuit breaker half-open, allowing test request");
3369
+ this.emit("circuit:half-open", nodeId);
3370
+ return true;
3371
+ }
3372
+ return false;
3373
+ }
3374
+ return true;
3375
+ }
3376
+ /**
3377
+ * Record a successful operation to a node.
3378
+ * Resets circuit breaker on success.
3379
+ */
3380
+ recordSuccess(nodeId) {
3381
+ const circuit = this.getCircuit(nodeId);
3382
+ const wasOpen = circuit.state !== "closed";
3383
+ circuit.failures = 0;
3384
+ circuit.state = "closed";
3385
+ if (wasOpen) {
3386
+ logger.info({ nodeId }, "Circuit breaker closed after success");
3387
+ this.emit("circuit:closed", nodeId);
3388
+ }
3389
+ }
3390
+ /**
3391
+ * Record a failed operation to a node.
3392
+ * Opens circuit breaker after threshold failures.
3393
+ */
3394
+ recordFailure(nodeId) {
3395
+ const circuit = this.getCircuit(nodeId);
3396
+ circuit.failures++;
3397
+ circuit.lastFailure = Date.now();
3398
+ if (circuit.failures >= this.circuitBreakerConfig.failureThreshold) {
3399
+ if (circuit.state !== "open") {
3400
+ circuit.state = "open";
3401
+ logger.warn({ nodeId, failures: circuit.failures }, "Circuit breaker opened");
3402
+ this.emit("circuit:open", nodeId);
3403
+ }
3404
+ }
3405
+ }
3406
+ /**
3407
+ * Get all circuit breaker states.
3408
+ */
3409
+ getCircuitStates() {
3410
+ return new Map(this.circuits);
3411
+ }
3412
+ /**
3413
+ * Reset circuit breaker for a specific node.
3414
+ */
3415
+ resetCircuit(nodeId) {
3416
+ this.circuits.delete(nodeId);
3417
+ logger.debug({ nodeId }, "Circuit breaker reset");
3418
+ }
3419
+ /**
3420
+ * Reset all circuit breakers.
3421
+ */
3422
+ resetAllCircuits() {
3423
+ this.circuits.clear();
3424
+ logger.debug("All circuit breakers reset");
3425
+ }
3426
+ // ============================================
3427
+ // Private Methods
3428
+ // ============================================
3429
+ setupEventHandlers() {
3430
+ this.connectionPool.on("node:connected", (nodeId) => {
3431
+ logger.debug({ nodeId }, "Node connected");
3432
+ if (this.partitionRouter.getMapVersion() === 0) {
3433
+ this.requestPartitionMapFromNode(nodeId);
3434
+ }
3435
+ if (this.connectionPool.getConnectedNodes().length === 1) {
3436
+ this.emit("connected");
3437
+ }
3438
+ });
3439
+ this.connectionPool.on("node:disconnected", (nodeId, reason) => {
3440
+ logger.debug({ nodeId, reason }, "Node disconnected");
3441
+ if (this.connectionPool.getConnectedNodes().length === 0) {
3442
+ this.routingActive = false;
3443
+ this.emit("disconnected", reason);
3444
+ }
3445
+ });
3446
+ this.connectionPool.on("node:unhealthy", (nodeId, reason) => {
3447
+ logger.warn({ nodeId, reason }, "Node unhealthy");
3448
+ });
3449
+ this.connectionPool.on("error", (nodeId, error) => {
3450
+ this.emit("error", error);
3451
+ });
3452
+ this.connectionPool.on("message", (nodeId, data) => {
3453
+ this.emit("message", nodeId, data);
3454
+ });
3455
+ this.partitionRouter.on("partitionMap:updated", (version, changesCount) => {
3456
+ if (!this.routingActive && this.partitionRouter.hasPartitionMap()) {
3457
+ this.routingActive = true;
3458
+ logger.info({ version }, "Direct routing activated");
3459
+ this.emit("routing:active");
3460
+ }
3461
+ this.emit("partitionMap:ready", version);
3462
+ this.emit("partitionMapUpdated");
3463
+ });
3464
+ this.partitionRouter.on("routing:miss", (key, expected, actual) => {
3465
+ logger.debug({ key, expected, actual }, "Routing miss detected");
3466
+ });
3467
+ }
3468
+ async waitForPartitionMap(timeoutMs = 1e4) {
3469
+ if (this.partitionRouter.hasPartitionMap()) {
3470
+ this.routingActive = true;
3471
+ return;
3472
+ }
3473
+ return new Promise((resolve) => {
3474
+ const timeout = setTimeout(() => {
3475
+ this.partitionRouter.off("partitionMap:updated", onUpdate);
3476
+ logger.warn("Partition map not received, using fallback routing");
3477
+ resolve();
3478
+ }, timeoutMs);
3479
+ const onUpdate = () => {
3480
+ clearTimeout(timeout);
3481
+ this.partitionRouter.off("partitionMap:updated", onUpdate);
3482
+ this.routingActive = true;
3483
+ resolve();
3484
+ };
3485
+ this.partitionRouter.once("partitionMap:updated", onUpdate);
3486
+ });
3487
+ }
3488
+ };
3489
+
3490
+ // src/TopGunClient.ts
3491
+ var DEFAULT_CLUSTER_CONFIG = {
3492
+ connectionsPerNode: 1,
3493
+ smartRouting: true,
3494
+ partitionMapRefreshMs: 3e4,
3495
+ connectionTimeoutMs: 5e3,
3496
+ retryAttempts: 3
3497
+ };
3498
+ var TopGunClient = class {
3499
+ constructor(config) {
3500
+ this.maps = /* @__PURE__ */ new Map();
3501
+ this.topicHandles = /* @__PURE__ */ new Map();
3502
+ if (config.serverUrl && config.cluster) {
3503
+ throw new Error("Cannot specify both serverUrl and cluster config");
3504
+ }
3505
+ if (!config.serverUrl && !config.cluster) {
3506
+ throw new Error("Must specify either serverUrl or cluster config");
3507
+ }
3508
+ this.nodeId = config.nodeId || crypto.randomUUID();
3509
+ this.storageAdapter = config.storage;
3510
+ this.isClusterMode = !!config.cluster;
3511
+ if (config.cluster) {
3512
+ if (!config.cluster.seeds || config.cluster.seeds.length === 0) {
3513
+ throw new Error("Cluster config requires at least one seed node");
3514
+ }
3515
+ this.clusterConfig = {
3516
+ seeds: config.cluster.seeds,
3517
+ connectionsPerNode: config.cluster.connectionsPerNode ?? DEFAULT_CLUSTER_CONFIG.connectionsPerNode,
3518
+ smartRouting: config.cluster.smartRouting ?? DEFAULT_CLUSTER_CONFIG.smartRouting,
3519
+ partitionMapRefreshMs: config.cluster.partitionMapRefreshMs ?? DEFAULT_CLUSTER_CONFIG.partitionMapRefreshMs,
3520
+ connectionTimeoutMs: config.cluster.connectionTimeoutMs ?? DEFAULT_CLUSTER_CONFIG.connectionTimeoutMs,
3521
+ retryAttempts: config.cluster.retryAttempts ?? DEFAULT_CLUSTER_CONFIG.retryAttempts
3522
+ };
3523
+ this.clusterClient = new ClusterClient({
3524
+ enabled: true,
3525
+ seedNodes: this.clusterConfig.seeds,
3526
+ routingMode: this.clusterConfig.smartRouting ? "direct" : "forward",
3527
+ connectionPool: {
3528
+ maxConnectionsPerNode: this.clusterConfig.connectionsPerNode,
3529
+ connectionTimeoutMs: this.clusterConfig.connectionTimeoutMs
3530
+ },
3531
+ routing: {
3532
+ mapRefreshIntervalMs: this.clusterConfig.partitionMapRefreshMs
3533
+ }
3534
+ });
3535
+ this.syncEngine = new SyncEngine({
3536
+ nodeId: this.nodeId,
3537
+ connectionProvider: this.clusterClient,
3538
+ storageAdapter: this.storageAdapter,
3539
+ backoff: config.backoff,
3540
+ backpressure: config.backpressure
3541
+ });
3542
+ logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
3543
+ } else {
3544
+ this.syncEngine = new SyncEngine({
3545
+ nodeId: this.nodeId,
3546
+ serverUrl: config.serverUrl,
3547
+ storageAdapter: this.storageAdapter,
3548
+ backoff: config.backoff,
3549
+ backpressure: config.backpressure
3550
+ });
3551
+ logger.info({ serverUrl: config.serverUrl }, "TopGunClient initialized in single-server mode");
3552
+ }
3553
+ }
3554
+ async start() {
3555
+ await this.storageAdapter.initialize("topgun_offline_db");
3556
+ }
3557
+ setAuthToken(token) {
3558
+ this.syncEngine.setAuthToken(token);
3559
+ }
3560
+ setAuthTokenProvider(provider) {
3561
+ this.syncEngine.setTokenProvider(provider);
3562
+ }
3563
+ /**
3564
+ * Creates a live query subscription for a map.
1661
3565
  */
1662
3566
  query(mapName, filter) {
1663
3567
  return new QueryHandle(this.syncEngine, mapName, filter);
@@ -1687,12 +3591,12 @@ var TopGunClient = class {
1687
3591
  getMap(name) {
1688
3592
  if (this.maps.has(name)) {
1689
3593
  const map = this.maps.get(name);
1690
- if (map instanceof import_core2.LWWMap) {
3594
+ if (map instanceof import_core6.LWWMap) {
1691
3595
  return map;
1692
3596
  }
1693
3597
  throw new Error(`Map ${name} exists but is not an LWWMap`);
1694
3598
  }
1695
- const lwwMap = new import_core2.LWWMap(this.syncEngine.getHLC());
3599
+ const lwwMap = new import_core6.LWWMap(this.syncEngine.getHLC());
1696
3600
  this.maps.set(name, lwwMap);
1697
3601
  this.syncEngine.registerMap(name, lwwMap);
1698
3602
  this.storageAdapter.getAllKeys().then(async (keys) => {
@@ -1731,12 +3635,12 @@ var TopGunClient = class {
1731
3635
  getORMap(name) {
1732
3636
  if (this.maps.has(name)) {
1733
3637
  const map = this.maps.get(name);
1734
- if (map instanceof import_core2.ORMap) {
3638
+ if (map instanceof import_core6.ORMap) {
1735
3639
  return map;
1736
3640
  }
1737
3641
  throw new Error(`Map ${name} exists but is not an ORMap`);
1738
3642
  }
1739
- const orMap = new import_core2.ORMap(this.syncEngine.getHLC());
3643
+ const orMap = new import_core6.ORMap(this.syncEngine.getHLC());
1740
3644
  this.maps.set(name, orMap);
1741
3645
  this.syncEngine.registerMap(name, orMap);
1742
3646
  this.restoreORMap(name, orMap);
@@ -1805,9 +3709,69 @@ var TopGunClient = class {
1805
3709
  * Closes the client, disconnecting from the server and cleaning up resources.
1806
3710
  */
1807
3711
  close() {
3712
+ if (this.clusterClient) {
3713
+ this.clusterClient.close();
3714
+ }
1808
3715
  this.syncEngine.close();
1809
3716
  }
1810
3717
  // ============================================
3718
+ // Cluster Mode API
3719
+ // ============================================
3720
+ /**
3721
+ * Check if running in cluster mode
3722
+ */
3723
+ isCluster() {
3724
+ return this.isClusterMode;
3725
+ }
3726
+ /**
3727
+ * Get list of connected cluster nodes (cluster mode only)
3728
+ * @returns Array of connected node IDs, or empty array in single-server mode
3729
+ */
3730
+ getConnectedNodes() {
3731
+ if (!this.clusterClient) return [];
3732
+ return this.clusterClient.getConnectedNodes();
3733
+ }
3734
+ /**
3735
+ * Get the current partition map version (cluster mode only)
3736
+ * @returns Partition map version, or 0 in single-server mode
3737
+ */
3738
+ getPartitionMapVersion() {
3739
+ if (!this.clusterClient) return 0;
3740
+ return this.clusterClient.getRouterStats().mapVersion;
3741
+ }
3742
+ /**
3743
+ * Check if direct routing is active (cluster mode only)
3744
+ * Direct routing sends operations directly to partition owners.
3745
+ * @returns true if routing is active, false otherwise
3746
+ */
3747
+ isRoutingActive() {
3748
+ if (!this.clusterClient) return false;
3749
+ return this.clusterClient.isRoutingActive();
3750
+ }
3751
+ /**
3752
+ * Get health status for all cluster nodes (cluster mode only)
3753
+ * @returns Map of node IDs to their health status
3754
+ */
3755
+ getClusterHealth() {
3756
+ if (!this.clusterClient) return /* @__PURE__ */ new Map();
3757
+ return this.clusterClient.getHealthStatus();
3758
+ }
3759
+ /**
3760
+ * Force refresh of partition map (cluster mode only)
3761
+ * Use this after detecting routing errors.
3762
+ */
3763
+ async refreshPartitionMap() {
3764
+ if (!this.clusterClient) return;
3765
+ await this.clusterClient.refreshPartitionMap();
3766
+ }
3767
+ /**
3768
+ * Get cluster router statistics (cluster mode only)
3769
+ */
3770
+ getClusterStats() {
3771
+ if (!this.clusterClient) return null;
3772
+ return this.clusterClient.getRouterStats();
3773
+ }
3774
+ // ============================================
1811
3775
  // Connection State API
1812
3776
  // ============================================
1813
3777
  /**
@@ -2158,14 +4122,14 @@ var CollectionWrapper = class {
2158
4122
  };
2159
4123
 
2160
4124
  // src/crypto/EncryptionManager.ts
2161
- var import_core3 = require("@topgunbuild/core");
4125
+ var import_core7 = require("@topgunbuild/core");
2162
4126
  var _EncryptionManager = class _EncryptionManager {
2163
4127
  /**
2164
4128
  * Encrypts data using AES-GCM.
2165
4129
  * Serializes data to MessagePack before encryption.
2166
4130
  */
2167
4131
  static async encrypt(key, data) {
2168
- const encoded = (0, import_core3.serialize)(data);
4132
+ const encoded = (0, import_core7.serialize)(data);
2169
4133
  const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
2170
4134
  const ciphertext = await window.crypto.subtle.encrypt(
2171
4135
  {
@@ -2194,7 +4158,7 @@ var _EncryptionManager = class _EncryptionManager {
2194
4158
  key,
2195
4159
  record.data
2196
4160
  );
2197
- return (0, import_core3.deserialize)(new Uint8Array(plaintextBuffer));
4161
+ return (0, import_core7.deserialize)(new Uint8Array(plaintextBuffer));
2198
4162
  } catch (err) {
2199
4163
  console.error("Decryption failed", err);
2200
4164
  throw new Error("Failed to decrypt data: " + err);
@@ -2318,16 +4282,21 @@ var EncryptedStorageAdapter = class {
2318
4282
  };
2319
4283
 
2320
4284
  // src/index.ts
2321
- var import_core4 = require("@topgunbuild/core");
4285
+ var import_core8 = require("@topgunbuild/core");
2322
4286
  // Annotate the CommonJS export names for ESM import in node:
2323
4287
  0 && (module.exports = {
2324
4288
  BackpressureError,
4289
+ ClusterClient,
4290
+ ConnectionPool,
2325
4291
  DEFAULT_BACKPRESSURE_CONFIG,
4292
+ DEFAULT_CLUSTER_CONFIG,
2326
4293
  EncryptedStorageAdapter,
2327
4294
  IDBAdapter,
2328
4295
  LWWMap,
4296
+ PartitionRouter,
2329
4297
  Predicates,
2330
4298
  QueryHandle,
4299
+ SingleServerProvider,
2331
4300
  SyncEngine,
2332
4301
  SyncState,
2333
4302
  SyncStateMachine,