@topgunbuild/client 0.2.1 → 0.3.0

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