@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.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,
@@ -283,8 +515,13 @@ var SyncEngine = class {
283
515
  this.waitingForCapacity = [];
284
516
  this.highWaterMarkEmitted = false;
285
517
  this.backpressureListeners = /* @__PURE__ */ new Map();
518
+ // Write Concern state (Phase 5.01)
519
+ this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
520
+ if (!config.serverUrl && !config.connectionProvider) {
521
+ throw new Error("SyncEngine requires either serverUrl or connectionProvider");
522
+ }
286
523
  this.nodeId = config.nodeId;
287
- this.serverUrl = config.serverUrl;
524
+ this.serverUrl = config.serverUrl || "";
288
525
  this.storageAdapter = config.storageAdapter;
289
526
  this.hlc = new import_core.HLC(this.nodeId);
290
527
  this.stateMachine = new SyncStateMachine();
@@ -301,7 +538,15 @@ var SyncEngine = class {
301
538
  ...DEFAULT_BACKPRESSURE_CONFIG,
302
539
  ...config.backpressure
303
540
  };
304
- 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
+ }
305
550
  this.loadOpLog();
306
551
  }
307
552
  // ============================================
@@ -352,6 +597,65 @@ var SyncEngine = class {
352
597
  // ============================================
353
598
  // Connection Management
354
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
+ */
355
659
  initConnection() {
356
660
  this.stateMachine.transition("CONNECTING" /* CONNECTING */);
357
661
  this.websocket = new WebSocket(this.serverUrl);
@@ -427,6 +731,40 @@ var SyncEngine = class {
427
731
  resetBackoff() {
428
732
  this.backoffAttempt = 0;
429
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
+ }
430
768
  async loadOpLog() {
431
769
  const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
432
770
  if (storedTimestamp) {
@@ -473,36 +811,34 @@ var SyncEngine = class {
473
811
  const pending = this.opLog.filter((op) => !op.synced);
474
812
  if (pending.length === 0) return;
475
813
  logger.info({ count: pending.length }, "Syncing pending operations");
476
- if (this.websocket?.readyState === WebSocket.OPEN) {
477
- this.websocket.send((0, import_core.serialize)({
478
- type: "OP_BATCH",
479
- payload: {
480
- ops: pending
481
- }
482
- }));
483
- }
814
+ this.sendMessage({
815
+ type: "OP_BATCH",
816
+ payload: {
817
+ ops: pending
818
+ }
819
+ });
484
820
  }
485
821
  startMerkleSync() {
486
822
  for (const [mapName, map] of this.maps) {
487
823
  if (map instanceof import_core.LWWMap) {
488
824
  logger.info({ mapName }, "Starting Merkle sync for LWWMap");
489
- this.websocket?.send((0, import_core.serialize)({
825
+ this.sendMessage({
490
826
  type: "SYNC_INIT",
491
827
  mapName,
492
828
  lastSyncTimestamp: this.lastSyncTimestamp
493
- }));
829
+ });
494
830
  } else if (map instanceof import_core.ORMap) {
495
831
  logger.info({ mapName }, "Starting Merkle sync for ORMap");
496
832
  const tree = map.getMerkleTree();
497
833
  const rootHash = tree.getRootHash();
498
834
  const bucketHashes = tree.getBuckets("");
499
- this.websocket?.send((0, import_core.serialize)({
835
+ this.sendMessage({
500
836
  type: "ORMAP_SYNC_INIT",
501
837
  mapName,
502
838
  rootHash,
503
839
  bucketHashes,
504
840
  lastSyncTimestamp: this.lastSyncTimestamp
505
- }));
841
+ });
506
842
  }
507
843
  }
508
844
  }
@@ -543,10 +879,10 @@ var SyncEngine = class {
543
879
  }
544
880
  const token = this.authToken;
545
881
  if (!token) return;
546
- this.websocket?.send((0, import_core.serialize)({
882
+ this.sendMessage({
547
883
  type: "AUTH",
548
884
  token
549
- }));
885
+ });
550
886
  }
551
887
  subscribeToQuery(query) {
552
888
  this.queries.set(query.id, query);
@@ -563,27 +899,27 @@ var SyncEngine = class {
563
899
  unsubscribeFromTopic(topic) {
564
900
  this.topics.delete(topic);
565
901
  if (this.isAuthenticated()) {
566
- this.websocket?.send((0, import_core.serialize)({
902
+ this.sendMessage({
567
903
  type: "TOPIC_UNSUB",
568
904
  payload: { topic }
569
- }));
905
+ });
570
906
  }
571
907
  }
572
908
  publishTopic(topic, data) {
573
909
  if (this.isAuthenticated()) {
574
- this.websocket?.send((0, import_core.serialize)({
910
+ this.sendMessage({
575
911
  type: "TOPIC_PUB",
576
912
  payload: { topic, data }
577
- }));
913
+ });
578
914
  } else {
579
915
  logger.warn({ topic }, "Dropped topic publish (offline)");
580
916
  }
581
917
  }
582
918
  sendTopicSubscription(topic) {
583
- this.websocket?.send((0, import_core.serialize)({
919
+ this.sendMessage({
584
920
  type: "TOPIC_SUB",
585
921
  payload: { topic }
586
- }));
922
+ });
587
923
  }
588
924
  /**
589
925
  * Executes a query against local storage immediately
@@ -620,21 +956,21 @@ var SyncEngine = class {
620
956
  unsubscribeFromQuery(queryId) {
621
957
  this.queries.delete(queryId);
622
958
  if (this.isAuthenticated()) {
623
- this.websocket?.send((0, import_core.serialize)({
959
+ this.sendMessage({
624
960
  type: "QUERY_UNSUB",
625
961
  payload: { queryId }
626
- }));
962
+ });
627
963
  }
628
964
  }
629
965
  sendQuerySubscription(query) {
630
- this.websocket?.send((0, import_core.serialize)({
966
+ this.sendMessage({
631
967
  type: "QUERY_SUB",
632
968
  payload: {
633
969
  queryId: query.id,
634
970
  mapName: query.getMapName(),
635
971
  query: query.getFilter()
636
972
  }
637
- }));
973
+ });
638
974
  }
639
975
  requestLock(name, requestId, ttl) {
640
976
  if (!this.isAuthenticated()) {
@@ -649,10 +985,15 @@ var SyncEngine = class {
649
985
  }, 3e4);
650
986
  this.pendingLockRequests.set(requestId, { resolve, reject, timer });
651
987
  try {
652
- this.websocket?.send((0, import_core.serialize)({
988
+ const sent = this.sendMessage({
653
989
  type: "LOCK_REQUEST",
654
990
  payload: { requestId, name, ttl }
655
- }));
991
+ });
992
+ if (!sent) {
993
+ clearTimeout(timer);
994
+ this.pendingLockRequests.delete(requestId);
995
+ reject(new Error("Failed to send lock request"));
996
+ }
656
997
  } catch (e) {
657
998
  clearTimeout(timer);
658
999
  this.pendingLockRequests.delete(requestId);
@@ -671,10 +1012,15 @@ var SyncEngine = class {
671
1012
  }, 5e3);
672
1013
  this.pendingLockRequests.set(requestId, { resolve, reject, timer });
673
1014
  try {
674
- this.websocket?.send((0, import_core.serialize)({
1015
+ const sent = this.sendMessage({
675
1016
  type: "LOCK_RELEASE",
676
1017
  payload: { requestId, name, fencingToken }
677
- }));
1018
+ });
1019
+ if (!sent) {
1020
+ clearTimeout(timer);
1021
+ this.pendingLockRequests.delete(requestId);
1022
+ resolve(false);
1023
+ }
678
1024
  } catch (e) {
679
1025
  clearTimeout(timer);
680
1026
  this.pendingLockRequests.delete(requestId);
@@ -684,6 +1030,22 @@ var SyncEngine = class {
684
1030
  }
685
1031
  async handleServerMessage(message) {
686
1032
  switch (message.type) {
1033
+ case "BATCH": {
1034
+ const batchData = message.data;
1035
+ const view = new DataView(batchData.buffer, batchData.byteOffset, batchData.byteLength);
1036
+ let offset = 0;
1037
+ const count = view.getUint32(offset, true);
1038
+ offset += 4;
1039
+ for (let i = 0; i < count; i++) {
1040
+ const msgLen = view.getUint32(offset, true);
1041
+ offset += 4;
1042
+ const msgData = batchData.slice(offset, offset + msgLen);
1043
+ offset += msgLen;
1044
+ const innerMsg = (0, import_core.deserialize)(msgData);
1045
+ await this.handleServerMessage(innerMsg);
1046
+ }
1047
+ break;
1048
+ }
687
1049
  case "AUTH_REQUIRED":
688
1050
  this.sendAuth();
689
1051
  break;
@@ -715,8 +1077,18 @@ var SyncEngine = class {
715
1077
  this.authToken = null;
716
1078
  break;
717
1079
  case "OP_ACK": {
718
- const { lastId } = message.payload;
719
- logger.info({ lastId }, "Received ACK for ops");
1080
+ const { lastId, achievedLevel, results } = message.payload;
1081
+ logger.info({ lastId, achievedLevel, hasResults: !!results }, "Received ACK for ops");
1082
+ if (results && Array.isArray(results)) {
1083
+ for (const result of results) {
1084
+ const op = this.opLog.find((o) => o.id === result.opId);
1085
+ if (op && !op.synced) {
1086
+ op.synced = true;
1087
+ logger.debug({ opId: result.opId, achievedLevel: result.achievedLevel, success: result.success }, "Op ACK with Write Concern");
1088
+ }
1089
+ this.resolveWriteConcernPromise(result.opId, result);
1090
+ }
1091
+ }
720
1092
  let maxSyncedId = -1;
721
1093
  let ackedCount = 0;
722
1094
  this.opLog.forEach((op) => {
@@ -777,18 +1149,20 @@ var SyncEngine = class {
777
1149
  }
778
1150
  case "SERVER_EVENT": {
779
1151
  const { mapName, eventType, key, record, orRecord, orTag } = message.payload;
780
- const localMap = this.maps.get(mapName);
781
- if (localMap) {
782
- if (localMap instanceof import_core.LWWMap && record) {
783
- localMap.merge(key, record);
784
- await this.storageAdapter.put(`${mapName}:${key}`, record);
785
- } else if (localMap instanceof import_core.ORMap) {
786
- if (eventType === "OR_ADD" && orRecord) {
787
- localMap.apply(key, orRecord);
788
- } else if (eventType === "OR_REMOVE" && orTag) {
789
- localMap.applyTombstone(orTag);
790
- }
791
- }
1152
+ await this.applyServerEvent(mapName, eventType, key, record, orRecord, orTag);
1153
+ break;
1154
+ }
1155
+ case "SERVER_BATCH_EVENT": {
1156
+ const { events } = message.payload;
1157
+ for (const event of events) {
1158
+ await this.applyServerEvent(
1159
+ event.mapName,
1160
+ event.eventType,
1161
+ event.key,
1162
+ event.record,
1163
+ event.orRecord,
1164
+ event.orTag
1165
+ );
792
1166
  }
793
1167
  break;
794
1168
  }
@@ -825,11 +1199,11 @@ var SyncEngine = class {
825
1199
  const { mapName } = message.payload;
826
1200
  logger.warn({ mapName }, "Sync Reset Required due to GC Age");
827
1201
  await this.resetMap(mapName);
828
- this.websocket?.send((0, import_core.serialize)({
1202
+ this.sendMessage({
829
1203
  type: "SYNC_INIT",
830
1204
  mapName,
831
1205
  lastSyncTimestamp: 0
832
- }));
1206
+ });
833
1207
  break;
834
1208
  }
835
1209
  case "SYNC_RESP_ROOT": {
@@ -839,10 +1213,10 @@ var SyncEngine = class {
839
1213
  const localRootHash = map.getMerkleTree().getRootHash();
840
1214
  if (localRootHash !== rootHash) {
841
1215
  logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
842
- this.websocket?.send((0, import_core.serialize)({
1216
+ this.sendMessage({
843
1217
  type: "MERKLE_REQ_BUCKET",
844
1218
  payload: { mapName, path: "" }
845
- }));
1219
+ });
846
1220
  } else {
847
1221
  logger.info({ mapName }, "Map is in sync");
848
1222
  }
@@ -864,10 +1238,10 @@ var SyncEngine = class {
864
1238
  const localHash = localBuckets[bucketKey] || 0;
865
1239
  if (localHash !== remoteHash) {
866
1240
  const newPath = path + bucketKey;
867
- this.websocket?.send((0, import_core.serialize)({
1241
+ this.sendMessage({
868
1242
  type: "MERKLE_REQ_BUCKET",
869
1243
  payload: { mapName, path: newPath }
870
- }));
1244
+ });
871
1245
  }
872
1246
  }
873
1247
  }
@@ -900,10 +1274,10 @@ var SyncEngine = class {
900
1274
  const localRootHash = localTree.getRootHash();
901
1275
  if (localRootHash !== rootHash) {
902
1276
  logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
903
- this.websocket?.send((0, import_core.serialize)({
1277
+ this.sendMessage({
904
1278
  type: "ORMAP_MERKLE_REQ_BUCKET",
905
1279
  payload: { mapName, path: "" }
906
- }));
1280
+ });
907
1281
  } else {
908
1282
  logger.info({ mapName }, "ORMap is in sync");
909
1283
  }
@@ -925,10 +1299,10 @@ var SyncEngine = class {
925
1299
  const localHash = localBuckets[bucketKey] || 0;
926
1300
  if (localHash !== remoteHash) {
927
1301
  const newPath = path + bucketKey;
928
- this.websocket?.send((0, import_core.serialize)({
1302
+ this.sendMessage({
929
1303
  type: "ORMAP_MERKLE_REQ_BUCKET",
930
1304
  payload: { mapName, path: newPath }
931
- }));
1305
+ });
932
1306
  }
933
1307
  }
934
1308
  for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
@@ -991,6 +1365,25 @@ var SyncEngine = class {
991
1365
  getHLC() {
992
1366
  return this.hlc;
993
1367
  }
1368
+ /**
1369
+ * Helper method to apply a single server event to the local map.
1370
+ * Used by both SERVER_EVENT and SERVER_BATCH_EVENT handlers.
1371
+ */
1372
+ async applyServerEvent(mapName, eventType, key, record, orRecord, orTag) {
1373
+ const localMap = this.maps.get(mapName);
1374
+ if (localMap) {
1375
+ if (localMap instanceof import_core.LWWMap && record) {
1376
+ localMap.merge(key, record);
1377
+ await this.storageAdapter.put(`${mapName}:${key}`, record);
1378
+ } else if (localMap instanceof import_core.ORMap) {
1379
+ if (eventType === "OR_ADD" && orRecord) {
1380
+ localMap.apply(key, orRecord);
1381
+ } else if (eventType === "OR_REMOVE" && orTag) {
1382
+ localMap.applyTombstone(orTag);
1383
+ }
1384
+ }
1385
+ }
1386
+ }
994
1387
  /**
995
1388
  * Closes the WebSocket connection and cleans up resources.
996
1389
  */
@@ -1000,11 +1393,16 @@ var SyncEngine = class {
1000
1393
  clearTimeout(this.reconnectTimer);
1001
1394
  this.reconnectTimer = null;
1002
1395
  }
1003
- 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) {
1004
1401
  this.websocket.onclose = null;
1005
1402
  this.websocket.close();
1006
1403
  this.websocket = null;
1007
1404
  }
1405
+ this.cancelAllWriteConcernPromises(new Error("SyncEngine closed"));
1008
1406
  this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
1009
1407
  logger.info("SyncEngine closed");
1010
1408
  }
@@ -1016,7 +1414,100 @@ var SyncEngine = class {
1016
1414
  this.close();
1017
1415
  this.stateMachine.reset();
1018
1416
  this.resetBackoff();
1019
- 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;
1020
1511
  }
1021
1512
  async resetMap(mapName) {
1022
1513
  const map = this.maps.get(mapName);
@@ -1064,12 +1555,12 @@ var SyncEngine = class {
1064
1555
  * Sends a PING message to the server.
1065
1556
  */
1066
1557
  sendPing() {
1067
- if (this.websocket?.readyState === WebSocket.OPEN) {
1558
+ if (this.canSend()) {
1068
1559
  const pingMessage = {
1069
1560
  type: "PING",
1070
1561
  timestamp: Date.now()
1071
1562
  };
1072
- this.websocket.send((0, import_core.serialize)(pingMessage));
1563
+ this.sendMessage(pingMessage);
1073
1564
  }
1074
1565
  }
1075
1566
  /**
@@ -1148,13 +1639,13 @@ var SyncEngine = class {
1148
1639
  }
1149
1640
  }
1150
1641
  if (entries.length > 0) {
1151
- this.websocket?.send((0, import_core.serialize)({
1642
+ this.sendMessage({
1152
1643
  type: "ORMAP_PUSH_DIFF",
1153
1644
  payload: {
1154
1645
  mapName,
1155
1646
  entries
1156
1647
  }
1157
- }));
1648
+ });
1158
1649
  logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
1159
1650
  }
1160
1651
  }
@@ -1325,21 +1816,73 @@ var SyncEngine = class {
1325
1816
  });
1326
1817
  }
1327
1818
  }
1328
- };
1329
-
1330
- // src/TopGunClient.ts
1331
- var import_core2 = require("@topgunbuild/core");
1332
-
1333
- // src/QueryHandle.ts
1334
- var QueryHandle = class {
1335
- constructor(syncEngine, mapName, filter = {}) {
1336
- this.listeners = /* @__PURE__ */ new Set();
1337
- this.currentResults = /* @__PURE__ */ new Map();
1338
- // Track if we've received authoritative server response
1339
- this.hasReceivedServerData = false;
1340
- this.id = crypto.randomUUID();
1341
- this.syncEngine = syncEngine;
1342
- this.mapName = mapName;
1819
+ // ============================================
1820
+ // Write Concern Methods (Phase 5.01)
1821
+ // ============================================
1822
+ /**
1823
+ * Register a pending Write Concern promise for an operation.
1824
+ * The promise will be resolved when the server sends an ACK with the operation result.
1825
+ *
1826
+ * @param opId - Operation ID
1827
+ * @param timeout - Timeout in ms (default: 5000)
1828
+ * @returns Promise that resolves with the Write Concern result
1829
+ */
1830
+ registerWriteConcernPromise(opId, timeout = 5e3) {
1831
+ return new Promise((resolve, reject) => {
1832
+ const timeoutHandle = setTimeout(() => {
1833
+ this.pendingWriteConcernPromises.delete(opId);
1834
+ reject(new Error(`Write Concern timeout for operation ${opId}`));
1835
+ }, timeout);
1836
+ this.pendingWriteConcernPromises.set(opId, {
1837
+ resolve,
1838
+ reject,
1839
+ timeoutHandle
1840
+ });
1841
+ });
1842
+ }
1843
+ /**
1844
+ * Resolve a pending Write Concern promise with the server result.
1845
+ *
1846
+ * @param opId - Operation ID
1847
+ * @param result - Result from server ACK
1848
+ */
1849
+ resolveWriteConcernPromise(opId, result) {
1850
+ const pending = this.pendingWriteConcernPromises.get(opId);
1851
+ if (pending) {
1852
+ if (pending.timeoutHandle) {
1853
+ clearTimeout(pending.timeoutHandle);
1854
+ }
1855
+ pending.resolve(result);
1856
+ this.pendingWriteConcernPromises.delete(opId);
1857
+ }
1858
+ }
1859
+ /**
1860
+ * Cancel all pending Write Concern promises (e.g., on disconnect).
1861
+ */
1862
+ cancelAllWriteConcernPromises(error) {
1863
+ for (const [opId, pending] of this.pendingWriteConcernPromises.entries()) {
1864
+ if (pending.timeoutHandle) {
1865
+ clearTimeout(pending.timeoutHandle);
1866
+ }
1867
+ pending.reject(error);
1868
+ }
1869
+ this.pendingWriteConcernPromises.clear();
1870
+ }
1871
+ };
1872
+
1873
+ // src/TopGunClient.ts
1874
+ var import_core6 = require("@topgunbuild/core");
1875
+
1876
+ // src/QueryHandle.ts
1877
+ var QueryHandle = class {
1878
+ constructor(syncEngine, mapName, filter = {}) {
1879
+ this.listeners = /* @__PURE__ */ new Set();
1880
+ this.currentResults = /* @__PURE__ */ new Map();
1881
+ // Track if we've received authoritative server response
1882
+ this.hasReceivedServerData = false;
1883
+ this.id = crypto.randomUUID();
1884
+ this.syncEngine = syncEngine;
1885
+ this.mapName = mapName;
1343
1886
  this.filter = filter;
1344
1887
  }
1345
1888
  subscribe(callback) {
@@ -1398,152 +1941,1615 @@ var QueryHandle = class {
1398
1941
  this.currentResults.delete(key);
1399
1942
  }
1400
1943
  }
1401
- if (removedKeys.length > 0) {
1402
- console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
1403
- }
1404
- for (const item of items) {
1405
- this.currentResults.set(item.key, item.value);
1944
+ if (removedKeys.length > 0) {
1945
+ console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
1946
+ }
1947
+ for (const item of items) {
1948
+ this.currentResults.set(item.key, item.value);
1949
+ }
1950
+ console.log(`[QueryHandle:${this.mapName}] After merge: ${this.currentResults.size} results`);
1951
+ this.notify();
1952
+ }
1953
+ /**
1954
+ * Called by SyncEngine when server sends a live update
1955
+ */
1956
+ onUpdate(key, value) {
1957
+ if (value === null) {
1958
+ this.currentResults.delete(key);
1959
+ } else {
1960
+ this.currentResults.set(key, value);
1961
+ }
1962
+ this.notify();
1963
+ }
1964
+ notify() {
1965
+ const results = this.getSortedResults();
1966
+ for (const listener of this.listeners) {
1967
+ listener(results);
1968
+ }
1969
+ }
1970
+ getSortedResults() {
1971
+ const results = Array.from(this.currentResults.entries()).map(
1972
+ ([key, value]) => ({ ...value, _key: key })
1973
+ );
1974
+ if (this.filter.sort) {
1975
+ results.sort((a, b) => {
1976
+ for (const [field, direction] of Object.entries(this.filter.sort)) {
1977
+ const valA = a[field];
1978
+ const valB = b[field];
1979
+ if (valA < valB) return direction === "asc" ? -1 : 1;
1980
+ if (valA > valB) return direction === "asc" ? 1 : -1;
1981
+ }
1982
+ return 0;
1983
+ });
1984
+ }
1985
+ return results;
1986
+ }
1987
+ getFilter() {
1988
+ return this.filter;
1989
+ }
1990
+ getMapName() {
1991
+ return this.mapName;
1992
+ }
1993
+ };
1994
+
1995
+ // src/DistributedLock.ts
1996
+ var DistributedLock = class {
1997
+ constructor(syncEngine, name) {
1998
+ this.fencingToken = null;
1999
+ this._isLocked = false;
2000
+ this.syncEngine = syncEngine;
2001
+ this.name = name;
2002
+ }
2003
+ async lock(ttl = 1e4) {
2004
+ const requestId = crypto.randomUUID();
2005
+ try {
2006
+ const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
2007
+ this.fencingToken = result.fencingToken;
2008
+ this._isLocked = true;
2009
+ return true;
2010
+ } catch (e) {
2011
+ return false;
2012
+ }
2013
+ }
2014
+ async unlock() {
2015
+ if (!this._isLocked || this.fencingToken === null) return;
2016
+ const requestId = crypto.randomUUID();
2017
+ try {
2018
+ await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
2019
+ } finally {
2020
+ this._isLocked = false;
2021
+ this.fencingToken = null;
2022
+ }
2023
+ }
2024
+ isLocked() {
2025
+ return this._isLocked;
2026
+ }
2027
+ };
2028
+
2029
+ // src/TopicHandle.ts
2030
+ var TopicHandle = class {
2031
+ constructor(engine, topic) {
2032
+ this.listeners = /* @__PURE__ */ new Set();
2033
+ this.engine = engine;
2034
+ this.topic = topic;
2035
+ }
2036
+ get id() {
2037
+ return this.topic;
2038
+ }
2039
+ /**
2040
+ * Publish a message to the topic
2041
+ */
2042
+ publish(data) {
2043
+ this.engine.publishTopic(this.topic, data);
2044
+ }
2045
+ /**
2046
+ * Subscribe to the topic
2047
+ */
2048
+ subscribe(callback) {
2049
+ if (this.listeners.size === 0) {
2050
+ this.engine.subscribeToTopic(this.topic, this);
2051
+ }
2052
+ this.listeners.add(callback);
2053
+ return () => this.unsubscribe(callback);
2054
+ }
2055
+ unsubscribe(callback) {
2056
+ this.listeners.delete(callback);
2057
+ if (this.listeners.size === 0) {
2058
+ this.engine.unsubscribeFromTopic(this.topic);
2059
+ }
2060
+ }
2061
+ /**
2062
+ * Called by SyncEngine when a message is received
2063
+ */
2064
+ onMessage(data, context) {
2065
+ this.listeners.forEach((cb) => {
2066
+ try {
2067
+ cb(data, context);
2068
+ } catch (e) {
2069
+ console.error("Error in topic listener", e);
2070
+ }
2071
+ });
2072
+ }
2073
+ };
2074
+
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
2091
+ };
2092
+ }
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;
2102
+ }
2103
+ off(event, listener) {
2104
+ this.listeners.get(event)?.delete(listener);
2105
+ return this;
2106
+ }
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;
2120
+ }
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);
1406
3157
  }
1407
- console.log(`[QueryHandle:${this.mapName}] After merge: ${this.currentResults.size} results`);
1408
- this.notify();
3158
+ this.connectionPool.startHealthCheck();
3159
+ this.partitionRouter.startPeriodicRefresh();
3160
+ this.initialized = true;
3161
+ await this.waitForPartitionMap();
1409
3162
  }
1410
3163
  /**
1411
- * Called by SyncEngine when server sends a live update
3164
+ * Set authentication token
1412
3165
  */
1413
- onUpdate(key, value) {
1414
- if (value === null) {
1415
- this.currentResults.delete(key);
1416
- } else {
1417
- this.currentResults.set(key, value);
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);
1418
3176
  }
1419
- this.notify();
3177
+ return this.sendForward(message);
1420
3178
  }
1421
- notify() {
1422
- const results = this.getSortedResults();
1423
- for (const listener of this.listeners) {
1424
- listener(results);
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;
1425
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;
1426
3197
  }
1427
- getSortedResults() {
1428
- const results = Array.from(this.currentResults.entries()).map(
1429
- ([key, value]) => ({ ...value, _key: key })
1430
- );
1431
- if (this.filter.sort) {
1432
- results.sort((a, b) => {
1433
- for (const [field, direction] of Object.entries(this.filter.sort)) {
1434
- const valA = a[field];
1435
- const valB = b[field];
1436
- if (valA < valB) return direction === "asc" ? -1 : 1;
1437
- if (valA > valB) return direction === "asc" ? 1 : -1;
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, []);
1438
3216
  }
1439
- return 0;
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) }
1440
3240
  });
3241
+ for (const { key } of operations) {
3242
+ results.set(key, success);
3243
+ }
1441
3244
  }
1442
3245
  return results;
1443
3246
  }
1444
- getFilter() {
1445
- return this.filter;
3247
+ /**
3248
+ * Get connection pool health status
3249
+ */
3250
+ getHealthStatus() {
3251
+ return this.connectionPool.getHealthStatus();
1446
3252
  }
1447
- getMapName() {
1448
- return this.mapName;
3253
+ /**
3254
+ * Get partition router stats
3255
+ */
3256
+ getRouterStats() {
3257
+ return this.partitionRouter.getStats();
1449
3258
  }
1450
- };
1451
-
1452
- // src/DistributedLock.ts
1453
- var DistributedLock = class {
1454
- constructor(syncEngine, name) {
1455
- this.fencingToken = null;
1456
- this._isLocked = false;
1457
- this.syncEngine = syncEngine;
1458
- this.name = name;
3259
+ /**
3260
+ * Get routing metrics for monitoring smart routing effectiveness.
3261
+ */
3262
+ getRoutingMetrics() {
3263
+ return { ...this.routingMetrics };
1459
3264
  }
1460
- async lock(ttl = 1e4) {
1461
- const requestId = crypto.randomUUID();
1462
- try {
1463
- const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
1464
- this.fencingToken = result.fencingToken;
1465
- this._isLocked = true;
1466
- return true;
1467
- } catch (e) {
1468
- return false;
1469
- }
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;
1470
3274
  }
1471
- async unlock() {
1472
- if (!this._isLocked || this.fencingToken === null) return;
1473
- const requestId = crypto.randomUUID();
1474
- try {
1475
- await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
1476
- } finally {
1477
- this._isLocked = false;
1478
- this.fencingToken = null;
1479
- }
3275
+ /**
3276
+ * Check if cluster routing is active
3277
+ */
3278
+ isRoutingActive() {
3279
+ return this.routingActive;
1480
3280
  }
1481
- isLocked() {
1482
- return this._isLocked;
3281
+ /**
3282
+ * Get list of connected nodes
3283
+ */
3284
+ getConnectedNodes() {
3285
+ return this.connectionPool.getConnectedNodes();
1483
3286
  }
1484
- };
1485
-
1486
- // src/TopicHandle.ts
1487
- var TopicHandle = class {
1488
- constructor(engine, topic) {
1489
- this.listeners = /* @__PURE__ */ new Set();
1490
- this.engine = engine;
1491
- this.topic = topic;
3287
+ /**
3288
+ * Check if cluster client is initialized
3289
+ */
3290
+ isInitialized() {
3291
+ return this.initialized;
1492
3292
  }
1493
- get id() {
1494
- return this.topic;
3293
+ /**
3294
+ * Force refresh of partition map
3295
+ */
3296
+ async refreshPartitionMap() {
3297
+ await this.partitionRouter.refreshPartitionMap();
1495
3298
  }
1496
3299
  /**
1497
- * Publish a message to the topic
3300
+ * Shutdown cluster client (IConnectionProvider interface).
1498
3301
  */
1499
- publish(data) {
1500
- this.engine.publishTopic(this.topic, data);
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;
1501
3317
  }
1502
3318
  /**
1503
- * Subscribe to the topic
3319
+ * Get the partition router (for internal use)
1504
3320
  */
1505
- subscribe(callback) {
1506
- if (this.listeners.size === 0) {
1507
- this.engine.subscribeToTopic(this.topic, this);
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");
1508
3332
  }
1509
- this.listeners.add(callback);
1510
- return () => this.unsubscribe(callback);
3333
+ return conn.socket;
1511
3334
  }
1512
- unsubscribe(callback) {
1513
- this.listeners.delete(callback);
1514
- if (this.listeners.size === 0) {
1515
- this.engine.unsubscribeFromTopic(this.topic);
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);
1516
3354
  }
3355
+ return circuit;
1517
3356
  }
1518
3357
  /**
1519
- * Called by SyncEngine when a message is received
3358
+ * Check if a node can be used (circuit not open).
1520
3359
  */
1521
- onMessage(data, context) {
1522
- this.listeners.forEach((cb) => {
1523
- try {
1524
- cb(data, context);
1525
- } catch (e) {
1526
- console.error("Error in topic listener", e);
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");
1527
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);
1528
3486
  });
1529
3487
  }
1530
3488
  };
1531
3489
 
1532
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
+ };
1533
3498
  var TopGunClient = class {
1534
3499
  constructor(config) {
1535
3500
  this.maps = /* @__PURE__ */ new Map();
1536
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
+ }
1537
3508
  this.nodeId = config.nodeId || crypto.randomUUID();
1538
3509
  this.storageAdapter = config.storage;
1539
- const syncEngineConfig = {
1540
- nodeId: this.nodeId,
1541
- serverUrl: config.serverUrl,
1542
- storageAdapter: this.storageAdapter,
1543
- backoff: config.backoff,
1544
- backpressure: config.backpressure
1545
- };
1546
- this.syncEngine = new SyncEngine(syncEngineConfig);
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
+ }
1547
3553
  }
1548
3554
  async start() {
1549
3555
  await this.storageAdapter.initialize("topgun_offline_db");
@@ -1585,12 +3591,12 @@ var TopGunClient = class {
1585
3591
  getMap(name) {
1586
3592
  if (this.maps.has(name)) {
1587
3593
  const map = this.maps.get(name);
1588
- if (map instanceof import_core2.LWWMap) {
3594
+ if (map instanceof import_core6.LWWMap) {
1589
3595
  return map;
1590
3596
  }
1591
3597
  throw new Error(`Map ${name} exists but is not an LWWMap`);
1592
3598
  }
1593
- const lwwMap = new import_core2.LWWMap(this.syncEngine.getHLC());
3599
+ const lwwMap = new import_core6.LWWMap(this.syncEngine.getHLC());
1594
3600
  this.maps.set(name, lwwMap);
1595
3601
  this.syncEngine.registerMap(name, lwwMap);
1596
3602
  this.storageAdapter.getAllKeys().then(async (keys) => {
@@ -1629,12 +3635,12 @@ var TopGunClient = class {
1629
3635
  getORMap(name) {
1630
3636
  if (this.maps.has(name)) {
1631
3637
  const map = this.maps.get(name);
1632
- if (map instanceof import_core2.ORMap) {
3638
+ if (map instanceof import_core6.ORMap) {
1633
3639
  return map;
1634
3640
  }
1635
3641
  throw new Error(`Map ${name} exists but is not an ORMap`);
1636
3642
  }
1637
- const orMap = new import_core2.ORMap(this.syncEngine.getHLC());
3643
+ const orMap = new import_core6.ORMap(this.syncEngine.getHLC());
1638
3644
  this.maps.set(name, orMap);
1639
3645
  this.syncEngine.registerMap(name, orMap);
1640
3646
  this.restoreORMap(name, orMap);
@@ -1703,9 +3709,69 @@ var TopGunClient = class {
1703
3709
  * Closes the client, disconnecting from the server and cleaning up resources.
1704
3710
  */
1705
3711
  close() {
3712
+ if (this.clusterClient) {
3713
+ this.clusterClient.close();
3714
+ }
1706
3715
  this.syncEngine.close();
1707
3716
  }
1708
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
+ // ============================================
1709
3775
  // Connection State API
1710
3776
  // ============================================
1711
3777
  /**
@@ -2056,14 +4122,14 @@ var CollectionWrapper = class {
2056
4122
  };
2057
4123
 
2058
4124
  // src/crypto/EncryptionManager.ts
2059
- var import_core3 = require("@topgunbuild/core");
4125
+ var import_core7 = require("@topgunbuild/core");
2060
4126
  var _EncryptionManager = class _EncryptionManager {
2061
4127
  /**
2062
4128
  * Encrypts data using AES-GCM.
2063
4129
  * Serializes data to MessagePack before encryption.
2064
4130
  */
2065
4131
  static async encrypt(key, data) {
2066
- const encoded = (0, import_core3.serialize)(data);
4132
+ const encoded = (0, import_core7.serialize)(data);
2067
4133
  const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
2068
4134
  const ciphertext = await window.crypto.subtle.encrypt(
2069
4135
  {
@@ -2092,7 +4158,7 @@ var _EncryptionManager = class _EncryptionManager {
2092
4158
  key,
2093
4159
  record.data
2094
4160
  );
2095
- return (0, import_core3.deserialize)(new Uint8Array(plaintextBuffer));
4161
+ return (0, import_core7.deserialize)(new Uint8Array(plaintextBuffer));
2096
4162
  } catch (err) {
2097
4163
  console.error("Decryption failed", err);
2098
4164
  throw new Error("Failed to decrypt data: " + err);
@@ -2216,16 +4282,21 @@ var EncryptedStorageAdapter = class {
2216
4282
  };
2217
4283
 
2218
4284
  // src/index.ts
2219
- var import_core4 = require("@topgunbuild/core");
4285
+ var import_core8 = require("@topgunbuild/core");
2220
4286
  // Annotate the CommonJS export names for ESM import in node:
2221
4287
  0 && (module.exports = {
2222
4288
  BackpressureError,
4289
+ ClusterClient,
4290
+ ConnectionPool,
2223
4291
  DEFAULT_BACKPRESSURE_CONFIG,
4292
+ DEFAULT_CLUSTER_CONFIG,
2224
4293
  EncryptedStorageAdapter,
2225
4294
  IDBAdapter,
2226
4295
  LWWMap,
4296
+ PartitionRouter,
2227
4297
  Predicates,
2228
4298
  QueryHandle,
4299
+ SingleServerProvider,
2229
4300
  SyncEngine,
2230
4301
  SyncState,
2231
4302
  SyncStateMachine,