@topgunbuild/client 0.11.0 → 0.12.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
@@ -36,6 +36,7 @@ __export(index_exports, {
36
36
  ClusterClient: () => ClusterClient,
37
37
  ConflictResolverClient: () => ConflictResolverClient,
38
38
  ConnectionPool: () => ConnectionPool,
39
+ ConnectionReadyState: () => ConnectionReadyState,
39
40
  DEFAULT_BACKPRESSURE_CONFIG: () => DEFAULT_BACKPRESSURE_CONFIG,
40
41
  DEFAULT_CLUSTER_CONFIG: () => DEFAULT_CLUSTER_CONFIG,
41
42
  EncryptedStorageAdapter: () => EncryptedStorageAdapter,
@@ -57,6 +58,7 @@ __export(index_exports, {
57
58
  TopGunClient: () => TopGunClient,
58
59
  TopicHandle: () => TopicHandle,
59
60
  VALID_TRANSITIONS: () => VALID_TRANSITIONS,
61
+ WebSocketConnection: () => WebSocketConnection,
60
62
  isValidTransition: () => isValidTransition,
61
63
  logger: () => logger
62
64
  });
@@ -720,9 +722,7 @@ var WebSocketManager = class {
720
722
  timeoutMs: this.config.heartbeatConfig.timeoutMs
721
723
  }, "Heartbeat timeout - triggering reconnection");
722
724
  this.stopHeartbeat();
723
- this.connectionProvider.close().catch((err) => {
724
- logger.error({ err }, "Error closing ConnectionProvider on heartbeat timeout");
725
- });
725
+ this.connectionProvider.forceReconnect();
726
726
  }
727
727
  }
728
728
  /**
@@ -1409,7 +1409,7 @@ var LockManager = class {
1409
1409
  /**
1410
1410
  * Handle lock granted message from server.
1411
1411
  */
1412
- handleLockGranted(requestId, fencingToken) {
1412
+ handleLockGranted(requestId, _name, fencingToken) {
1413
1413
  const req = this.pendingLockRequests.get(requestId);
1414
1414
  if (req) {
1415
1415
  clearTimeout(req.timer);
@@ -1420,7 +1420,7 @@ var LockManager = class {
1420
1420
  /**
1421
1421
  * Handle lock released message from server.
1422
1422
  */
1423
- handleLockReleased(requestId, success) {
1423
+ handleLockReleased(requestId, _name, success) {
1424
1424
  const req = this.pendingLockRequests.get(requestId);
1425
1425
  if (req) {
1426
1426
  clearTimeout(req.timer);
@@ -1837,6 +1837,8 @@ var import_core3 = require("@topgunbuild/core");
1837
1837
  var MerkleSyncHandler = class {
1838
1838
  constructor(config) {
1839
1839
  this.lastSyncTimestamp = 0;
1840
+ /** Accumulated sync stats per map, flushed after a quiet period */
1841
+ this.syncStats = /* @__PURE__ */ new Map();
1840
1842
  this.config = config;
1841
1843
  }
1842
1844
  /**
@@ -1858,7 +1860,8 @@ var MerkleSyncHandler = class {
1858
1860
  * Compares root hashes and requests buckets if mismatch detected.
1859
1861
  */
1860
1862
  async handleSyncRespRoot(payload) {
1861
- const { mapName, rootHash, timestamp } = payload;
1863
+ const { mapName, timestamp } = payload;
1864
+ const rootHash = Number(payload.rootHash);
1862
1865
  const map = this.config.getMap(mapName);
1863
1866
  if (map instanceof import_core3.LWWMap) {
1864
1867
  const localRootHash = map.getMerkleTree().getRootHash();
@@ -1886,9 +1889,11 @@ var MerkleSyncHandler = class {
1886
1889
  if (map instanceof import_core3.LWWMap) {
1887
1890
  const tree = map.getMerkleTree();
1888
1891
  const localBuckets = tree.getBuckets(path);
1892
+ let mismatchCount = 0;
1889
1893
  for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
1890
1894
  const localHash = localBuckets[bucketKey] || 0;
1891
1895
  if (localHash !== remoteHash) {
1896
+ mismatchCount++;
1892
1897
  const newPath = path + bucketKey;
1893
1898
  this.config.sendMessage({
1894
1899
  type: "MERKLE_REQ_BUCKET",
@@ -1915,7 +1920,17 @@ var MerkleSyncHandler = class {
1915
1920
  }
1916
1921
  }
1917
1922
  if (updateCount > 0) {
1918
- logger.info({ mapName, count: updateCount }, "Synced records from server");
1923
+ const existing = this.syncStats.get(mapName);
1924
+ if (existing) {
1925
+ existing.count += updateCount;
1926
+ clearTimeout(existing.timer);
1927
+ }
1928
+ const stats = existing ?? { count: updateCount, timer: void 0 };
1929
+ if (!existing) this.syncStats.set(mapName, stats);
1930
+ stats.timer = setTimeout(() => {
1931
+ logger.info({ mapName, count: stats.count }, "Synced records from server");
1932
+ this.syncStats.delete(mapName);
1933
+ }, 100);
1919
1934
  }
1920
1935
  }
1921
1936
  }
@@ -2198,6 +2213,8 @@ function registerClientMessageHandlers(router, delegates, managers) {
2198
2213
  },
2199
2214
  // SYNC handlers
2200
2215
  "OP_ACK": (msg) => delegates.handleOpAck(msg),
2216
+ "OP_REJECTED": (msg) => delegates.handleOpRejected(msg),
2217
+ "ERROR": (msg) => delegates.handleError(msg),
2201
2218
  "SYNC_RESP_ROOT": (msg) => managers.merkleSyncHandler.handleSyncRespRoot(msg.payload),
2202
2219
  "SYNC_RESP_BUCKETS": (msg) => managers.merkleSyncHandler.handleSyncRespBuckets(msg.payload),
2203
2220
  "SYNC_RESP_LEAF": (msg) => managers.merkleSyncHandler.handleSyncRespLeaf(msg.payload),
@@ -2220,12 +2237,12 @@ function registerClientMessageHandlers(router, delegates, managers) {
2220
2237
  },
2221
2238
  // LOCK handlers
2222
2239
  "LOCK_GRANTED": (msg) => {
2223
- const { requestId, fencingToken } = msg.payload;
2224
- managers.lockManager.handleLockGranted(requestId, fencingToken);
2240
+ const { requestId, name, fencingToken } = msg.payload;
2241
+ managers.lockManager.handleLockGranted(requestId, name, fencingToken);
2225
2242
  },
2226
2243
  "LOCK_RELEASED": (msg) => {
2227
- const { requestId, success } = msg.payload;
2228
- managers.lockManager.handleLockReleased(requestId, success);
2244
+ const { requestId, name, success } = msg.payload;
2245
+ managers.lockManager.handleLockReleased(requestId, name, success);
2229
2246
  },
2230
2247
  // GC handler
2231
2248
  "GC_PRUNE": (msg) => delegates.handleGcPrune(msg),
@@ -2263,10 +2280,7 @@ function registerClientMessageHandlers(router, delegates, managers) {
2263
2280
  managers.searchClient.handleSearchResponse(msg.payload);
2264
2281
  },
2265
2282
  "SEARCH_UPDATE": () => {
2266
- },
2267
- // HYBRID handlers
2268
- "HYBRID_QUERY_RESP": (msg) => delegates.handleHybridQueryResponse(msg.payload),
2269
- "HYBRID_QUERY_DELTA": (msg) => delegates.handleHybridQueryDelta(msg.payload)
2283
+ }
2270
2284
  });
2271
2285
  }
2272
2286
 
@@ -2401,8 +2415,8 @@ var SyncEngine = class {
2401
2415
  handleServerEvent: (msg) => this.handleServerEvent(msg),
2402
2416
  handleServerBatchEvent: (msg) => this.handleServerBatchEvent(msg),
2403
2417
  handleGcPrune: (msg) => this.handleGcPrune(msg),
2404
- handleHybridQueryResponse: (payload) => this.handleHybridQueryResponse(payload),
2405
- handleHybridQueryDelta: (payload) => this.handleHybridQueryDelta(payload)
2418
+ handleOpRejected: (msg) => this.handleOpRejected(msg),
2419
+ handleError: (msg) => this.handleError(msg)
2406
2420
  },
2407
2421
  {
2408
2422
  topicManager: this.topicManager,
@@ -2555,6 +2569,15 @@ var SyncEngine = class {
2555
2569
  const pending = this.opLog.filter((op) => !op.synced);
2556
2570
  if (pending.length === 0) return;
2557
2571
  logger.info({ count: pending.length }, "Syncing pending operations");
2572
+ const connectionProvider = this.webSocketManager.getConnectionProvider();
2573
+ if (connectionProvider.sendBatch) {
2574
+ const results = connectionProvider.sendBatch(pending.map((op) => ({ key: op.key, message: op })));
2575
+ const failedKeys = [...results.entries()].filter(([, success]) => !success).map(([key]) => key);
2576
+ if (failedKeys.length > 0) {
2577
+ logger.warn({ failedKeys, count: failedKeys.length }, "Some batch operations failed to send");
2578
+ }
2579
+ return;
2580
+ }
2558
2581
  this.sendMessage({
2559
2582
  type: "OP_BATCH",
2560
2583
  payload: {
@@ -2575,7 +2598,7 @@ var SyncEngine = class {
2575
2598
  this.authToken = token;
2576
2599
  this.tokenProvider = null;
2577
2600
  const state = this.stateMachine.getState();
2578
- if (state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "CONNECTING" /* CONNECTING */) {
2601
+ if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
2579
2602
  this.sendAuth();
2580
2603
  } else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
2581
2604
  logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
@@ -2680,9 +2703,10 @@ var SyncEngine = class {
2680
2703
  return;
2681
2704
  }
2682
2705
  await this.messageRouter.route(message);
2683
- if (message.timestamp) {
2684
- this.hlc.update(message.timestamp);
2685
- this.lastSyncTimestamp = message.timestamp.millis;
2706
+ const ts = message.timestamp;
2707
+ if (ts && typeof ts === "object" && "millis" in ts && "counter" in ts && "nodeId" in ts) {
2708
+ this.hlc.update(ts);
2709
+ this.lastSyncTimestamp = Number(ts.millis);
2686
2710
  await this.saveOpLog();
2687
2711
  }
2688
2712
  }
@@ -2736,20 +2760,37 @@ var SyncEngine = class {
2736
2760
  this.writeConcernManager.resolveWriteConcernPromise(result.opId, result);
2737
2761
  }
2738
2762
  }
2763
+ const lastIdNum = parseInt(lastId, 10);
2739
2764
  let maxSyncedId = -1;
2740
2765
  let ackedCount = 0;
2741
- this.opLog.forEach((op) => {
2742
- if (op.id && op.id <= lastId) {
2766
+ if (!isNaN(lastIdNum)) {
2767
+ this.opLog.forEach((op) => {
2768
+ if (op.id) {
2769
+ const opIdNum = parseInt(op.id, 10);
2770
+ if (!isNaN(opIdNum) && opIdNum <= lastIdNum) {
2771
+ if (!op.synced) {
2772
+ ackedCount++;
2773
+ }
2774
+ op.synced = true;
2775
+ if (opIdNum > maxSyncedId) {
2776
+ maxSyncedId = opIdNum;
2777
+ }
2778
+ }
2779
+ }
2780
+ });
2781
+ } else {
2782
+ logger.warn({ lastId }, "OP_ACK has non-numeric lastId \u2014 marking all pending ops as synced");
2783
+ this.opLog.forEach((op) => {
2743
2784
  if (!op.synced) {
2744
2785
  ackedCount++;
2786
+ op.synced = true;
2787
+ const opIdNum = parseInt(op.id, 10);
2788
+ if (!isNaN(opIdNum) && opIdNum > maxSyncedId) {
2789
+ maxSyncedId = opIdNum;
2790
+ }
2745
2791
  }
2746
- op.synced = true;
2747
- const idNum = parseInt(op.id, 10);
2748
- if (!isNaN(idNum) && idNum > maxSyncedId) {
2749
- maxSyncedId = idNum;
2750
- }
2751
- }
2752
- });
2792
+ });
2793
+ }
2753
2794
  if (maxSyncedId !== -1) {
2754
2795
  this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
2755
2796
  }
@@ -2766,10 +2807,10 @@ var SyncEngine = class {
2766
2807
  }
2767
2808
  }
2768
2809
  handleQueryUpdate(message) {
2769
- const { queryId, key, value, type } = message.payload;
2810
+ const { queryId, key, value, changeType } = message.payload;
2770
2811
  const query = this.queryManager.getQueries().get(queryId);
2771
2812
  if (query) {
2772
- query.onUpdate(key, type === "REMOVE" ? null : value);
2813
+ query.onUpdate(key, changeType === "LEAVE" ? null : value);
2773
2814
  }
2774
2815
  }
2775
2816
  async handleServerEvent(message) {
@@ -3174,31 +3215,24 @@ var SyncEngine = class {
3174
3215
  return this.queryManager.runLocalHybridQuery(mapName, filter);
3175
3216
  }
3176
3217
  /**
3177
- * Handle hybrid query response from server.
3218
+ * Handle operation rejected by server (permission denied, validation failure, etc.).
3178
3219
  */
3179
- handleHybridQueryResponse(payload) {
3180
- const query = this.queryManager.getHybridQuery(payload.subscriptionId);
3181
- if (query) {
3182
- query.onResult(payload.results, "server");
3183
- query.updatePaginationInfo({
3184
- nextCursor: payload.nextCursor,
3185
- hasMore: payload.hasMore,
3186
- cursorStatus: payload.cursorStatus
3187
- });
3188
- }
3220
+ handleOpRejected(message) {
3221
+ const { opId, reason, code } = message.payload;
3222
+ logger.warn({ opId, reason, code }, "Operation rejected by server");
3223
+ this.writeConcernManager.resolveWriteConcernPromise(opId, {
3224
+ opId,
3225
+ success: false,
3226
+ achievedLevel: "FIRE_AND_FORGET",
3227
+ error: reason
3228
+ });
3189
3229
  }
3190
3230
  /**
3191
- * Handle hybrid query delta update from server.
3231
+ * Handle generic error message from server.
3192
3232
  */
3193
- handleHybridQueryDelta(payload) {
3194
- const query = this.queryManager.getHybridQuery(payload.subscriptionId);
3195
- if (query) {
3196
- if (payload.type === "LEAVE") {
3197
- query.onUpdate(payload.key, null);
3198
- } else {
3199
- query.onUpdate(payload.key, payload.value, payload.score, payload.matchedTerms);
3200
- }
3201
- }
3233
+ handleError(message) {
3234
+ const { code, message: errorMessage, details } = message.payload;
3235
+ logger.error({ code, message: errorMessage, details }, "Server error received");
3202
3236
  }
3203
3237
  };
3204
3238
 
@@ -4082,8 +4116,8 @@ var SearchHandle = class {
4082
4116
  handleSearchUpdate(message) {
4083
4117
  if (message.type !== "SEARCH_UPDATE") return;
4084
4118
  if (message.payload?.subscriptionId !== this.subscriptionId) return;
4085
- const { key, value, score, matchedTerms, type } = message.payload;
4086
- switch (type) {
4119
+ const { key, value, score, matchedTerms, changeType } = message.payload;
4120
+ switch (changeType) {
4087
4121
  case "ENTER":
4088
4122
  this.results.set(key, {
4089
4123
  key,
@@ -4400,6 +4434,30 @@ var import_core10 = require("@topgunbuild/core");
4400
4434
  // src/cluster/ConnectionPool.ts
4401
4435
  var import_core7 = require("@topgunbuild/core");
4402
4436
  var import_core8 = require("@topgunbuild/core");
4437
+
4438
+ // src/connection/WebSocketConnection.ts
4439
+ var ConnectionReadyState = {
4440
+ CONNECTING: 0,
4441
+ OPEN: 1,
4442
+ CLOSING: 2,
4443
+ CLOSED: 3
4444
+ };
4445
+ var WebSocketConnection = class {
4446
+ constructor(ws) {
4447
+ this.ws = ws;
4448
+ }
4449
+ send(data) {
4450
+ this.ws.send(data);
4451
+ }
4452
+ close() {
4453
+ this.ws.close();
4454
+ }
4455
+ get readyState() {
4456
+ return this.ws.readyState;
4457
+ }
4458
+ };
4459
+
4460
+ // src/cluster/ConnectionPool.ts
4403
4461
  var ConnectionPool = class {
4404
4462
  constructor(config = {}) {
4405
4463
  this.listeners = /* @__PURE__ */ new Map();
@@ -4471,10 +4529,17 @@ var ConnectionPool = class {
4471
4529
  return;
4472
4530
  }
4473
4531
  }
4532
+ for (const [existingId, existingConn] of this.connections) {
4533
+ if (existingConn.endpoint === endpoint && existingId !== nodeId) {
4534
+ this.remapNodeId(existingId, nodeId);
4535
+ return;
4536
+ }
4537
+ }
4474
4538
  const connection = {
4475
4539
  nodeId,
4476
4540
  endpoint,
4477
4541
  socket: null,
4542
+ cachedConnection: null,
4478
4543
  state: "DISCONNECTED",
4479
4544
  lastSeen: 0,
4480
4545
  latencyMs: 0,
@@ -4509,15 +4574,34 @@ var ConnectionPool = class {
4509
4574
  }
4510
4575
  logger.info({ nodeId }, "Node removed from connection pool");
4511
4576
  }
4577
+ /**
4578
+ * Remap a node from one ID to another, preserving the existing connection.
4579
+ * Used when the server-assigned node ID differs from the temporary seed ID.
4580
+ */
4581
+ remapNodeId(oldId, newId) {
4582
+ const connection = this.connections.get(oldId);
4583
+ if (!connection) return;
4584
+ connection.nodeId = newId;
4585
+ this.connections.delete(oldId);
4586
+ this.connections.set(newId, connection);
4587
+ if (this.primaryNodeId === oldId) {
4588
+ this.primaryNodeId = newId;
4589
+ }
4590
+ logger.info({ oldId, newId }, "Node ID remapped");
4591
+ this.emit("node:remapped", oldId, newId);
4592
+ }
4512
4593
  /**
4513
4594
  * Get connection for a specific node
4514
4595
  */
4515
4596
  getConnection(nodeId) {
4516
4597
  const connection = this.connections.get(nodeId);
4517
- if (!connection || connection.state !== "AUTHENTICATED") {
4598
+ if (!connection || connection.state !== "CONNECTED" && connection.state !== "AUTHENTICATED" || !connection.socket) {
4518
4599
  return null;
4519
4600
  }
4520
- return connection.socket;
4601
+ if (!connection.cachedConnection) {
4602
+ connection.cachedConnection = new WebSocketConnection(connection.socket);
4603
+ }
4604
+ return connection.cachedConnection;
4521
4605
  }
4522
4606
  /**
4523
4607
  * Get primary connection (first/seed node)
@@ -4531,8 +4615,11 @@ var ConnectionPool = class {
4531
4615
  */
4532
4616
  getAnyHealthyConnection() {
4533
4617
  for (const [nodeId, conn] of this.connections) {
4534
- if (conn.state === "AUTHENTICATED" && conn.socket) {
4535
- return { nodeId, socket: conn.socket };
4618
+ if ((conn.state === "CONNECTED" || conn.state === "AUTHENTICATED") && conn.socket) {
4619
+ if (!conn.cachedConnection) {
4620
+ conn.cachedConnection = new WebSocketConnection(conn.socket);
4621
+ }
4622
+ return { nodeId, connection: conn.cachedConnection };
4536
4623
  }
4537
4624
  }
4538
4625
  return null;
@@ -4588,7 +4675,7 @@ var ConnectionPool = class {
4588
4675
  * Get list of connected node IDs
4589
4676
  */
4590
4677
  getConnectedNodes() {
4591
- return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
4678
+ return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "CONNECTED" || conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
4592
4679
  }
4593
4680
  /**
4594
4681
  * Get all node IDs
@@ -4597,11 +4684,11 @@ var ConnectionPool = class {
4597
4684
  return Array.from(this.connections.keys());
4598
4685
  }
4599
4686
  /**
4600
- * Check if node is connected and authenticated
4687
+ * Check if node has an open WebSocket connection
4601
4688
  */
4602
4689
  isNodeConnected(nodeId) {
4603
4690
  const conn = this.connections.get(nodeId);
4604
- return conn?.state === "AUTHENTICATED";
4691
+ return conn?.state === "CONNECTED" || conn?.state === "AUTHENTICATED";
4605
4692
  }
4606
4693
  /**
4607
4694
  * Check if connected to a specific node.
@@ -4666,25 +4753,26 @@ var ConnectionPool = class {
4666
4753
  };
4667
4754
  socket.onmessage = (event) => {
4668
4755
  connection.lastSeen = Date.now();
4669
- this.handleMessage(nodeId, event);
4756
+ this.handleMessage(connection.nodeId, event);
4670
4757
  };
4671
4758
  socket.onerror = (error) => {
4672
- logger.error({ nodeId, error }, "WebSocket error");
4673
- this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
4759
+ logger.error({ nodeId: connection.nodeId, error }, "WebSocket error");
4760
+ this.emit("error", connection.nodeId, error instanceof Error ? error : new Error("WebSocket error"));
4674
4761
  };
4675
4762
  socket.onclose = () => {
4676
4763
  const wasConnected = connection.state === "AUTHENTICATED";
4677
4764
  connection.state = "DISCONNECTED";
4678
4765
  connection.socket = null;
4766
+ connection.cachedConnection = null;
4679
4767
  if (wasConnected) {
4680
- this.emit("node:disconnected", nodeId, "Connection closed");
4768
+ this.emit("node:disconnected", connection.nodeId, "Connection closed");
4681
4769
  }
4682
- this.scheduleReconnect(nodeId);
4770
+ this.scheduleReconnect(connection.nodeId);
4683
4771
  };
4684
4772
  } catch (error) {
4685
4773
  connection.state = "FAILED";
4686
- logger.error({ nodeId, error }, "Failed to connect");
4687
- this.scheduleReconnect(nodeId);
4774
+ logger.error({ nodeId: connection.nodeId, error }, "Failed to connect");
4775
+ this.scheduleReconnect(connection.nodeId);
4688
4776
  }
4689
4777
  }
4690
4778
  sendAuth(connection) {
@@ -4713,18 +4801,15 @@ var ConnectionPool = class {
4713
4801
  logger.info({ nodeId }, "Authenticated with node");
4714
4802
  this.emit("node:healthy", nodeId);
4715
4803
  this.flushPendingMessages(connection);
4716
- return;
4717
4804
  }
4718
4805
  if (message.type === "AUTH_REQUIRED") {
4719
4806
  if (this.authToken) {
4720
4807
  this.sendAuth(connection);
4721
4808
  }
4722
- return;
4723
4809
  }
4724
4810
  if (message.type === "AUTH_FAIL") {
4725
4811
  logger.error({ nodeId, error: message.error }, "Authentication failed");
4726
4812
  connection.state = "FAILED";
4727
- return;
4728
4813
  }
4729
4814
  if (message.type === "PONG") {
4730
4815
  if (message.timestamp) {
@@ -4732,10 +4817,6 @@ var ConnectionPool = class {
4732
4817
  }
4733
4818
  return;
4734
4819
  }
4735
- if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
4736
- this.emit("message", nodeId, message);
4737
- return;
4738
- }
4739
4820
  this.emit("message", nodeId, message);
4740
4821
  }
4741
4822
  flushPendingMessages(connection) {
@@ -4902,17 +4983,17 @@ var PartitionRouter = class {
4902
4983
  }
4903
4984
  return null;
4904
4985
  }
4905
- const socket = this.connectionPool.getConnection(routing.nodeId);
4906
- if (socket) {
4907
- return { nodeId: routing.nodeId, socket };
4986
+ const connection = this.connectionPool.getConnection(routing.nodeId);
4987
+ if (connection) {
4988
+ return { nodeId: routing.nodeId, connection };
4908
4989
  }
4909
4990
  const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
4910
4991
  if (partition) {
4911
4992
  for (const backupId of partition.backupNodeIds) {
4912
- const backupSocket = this.connectionPool.getConnection(backupId);
4913
- if (backupSocket) {
4993
+ const backupConnection = this.connectionPool.getConnection(backupId);
4994
+ if (backupConnection) {
4914
4995
  logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
4915
- return { nodeId: backupId, socket: backupSocket };
4996
+ return { nodeId: backupId, connection: backupConnection };
4916
4997
  }
4917
4998
  }
4918
4999
  }
@@ -5188,7 +5269,7 @@ var PartitionRouter = class {
5188
5269
  };
5189
5270
 
5190
5271
  // src/cluster/ClusterClient.ts
5191
- var ClusterClient = class {
5272
+ var _ClusterClient = class _ClusterClient {
5192
5273
  constructor(config) {
5193
5274
  this.listeners = /* @__PURE__ */ new Map();
5194
5275
  this.initialized = false;
@@ -5201,6 +5282,8 @@ var ClusterClient = class {
5201
5282
  };
5202
5283
  // Circuit breaker state per node
5203
5284
  this.circuits = /* @__PURE__ */ new Map();
5285
+ // Debounce timer for partition map requests on reconnect
5286
+ this.partitionMapRequestTimer = null;
5204
5287
  this.config = config;
5205
5288
  this.circuitBreakerConfig = {
5206
5289
  ...import_core10.DEFAULT_CIRCUIT_BREAKER_CONFIG,
@@ -5292,14 +5375,14 @@ var ClusterClient = class {
5292
5375
  this.requestPartitionMapRefresh();
5293
5376
  return this.getFallbackConnection();
5294
5377
  }
5295
- const socket = this.connectionPool.getConnection(owner);
5296
- if (!socket) {
5378
+ const connection = this.connectionPool.getConnection(owner);
5379
+ if (!connection) {
5297
5380
  this.routingMetrics.fallbackRoutes++;
5298
5381
  logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
5299
5382
  return this.getFallbackConnection();
5300
5383
  }
5301
5384
  this.routingMetrics.directRoutes++;
5302
- return socket;
5385
+ return connection;
5303
5386
  }
5304
5387
  /**
5305
5388
  * Get fallback connection when owner is unavailable.
@@ -5307,10 +5390,10 @@ var ClusterClient = class {
5307
5390
  */
5308
5391
  getFallbackConnection() {
5309
5392
  const conn = this.connectionPool.getAnyHealthyConnection();
5310
- if (!conn?.socket) {
5393
+ if (!conn?.connection) {
5311
5394
  throw new Error("No healthy connection available");
5312
5395
  }
5313
- return conn.socket;
5396
+ return conn.connection;
5314
5397
  }
5315
5398
  /**
5316
5399
  * Request a partition map refresh in the background.
@@ -5321,9 +5404,23 @@ var ClusterClient = class {
5321
5404
  logger.error({ err }, "Failed to refresh partition map");
5322
5405
  });
5323
5406
  }
5407
+ /**
5408
+ * Debounce partition map requests to prevent flooding when multiple nodes
5409
+ * reconnect simultaneously. Coalesces rapid requests into a single request
5410
+ * sent to the most recently connected node.
5411
+ */
5412
+ debouncedPartitionMapRequest(nodeId) {
5413
+ if (this.partitionMapRequestTimer) {
5414
+ clearTimeout(this.partitionMapRequestTimer);
5415
+ }
5416
+ this.partitionMapRequestTimer = setTimeout(() => {
5417
+ this.partitionMapRequestTimer = null;
5418
+ this.requestPartitionMapFromNode(nodeId);
5419
+ }, _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS);
5420
+ }
5324
5421
  /**
5325
5422
  * Request partition map from a specific node.
5326
- * Called on first node connection.
5423
+ * Called on node connection via debounced handler.
5327
5424
  */
5328
5425
  requestPartitionMapFromNode(nodeId) {
5329
5426
  const socket = this.connectionPool.getConnection(nodeId);
@@ -5492,8 +5589,8 @@ var ClusterClient = class {
5492
5589
  * Send directly to partition owner
5493
5590
  */
5494
5591
  sendDirect(key, message) {
5495
- const connection = this.partitionRouter.routeToConnection(key);
5496
- if (!connection) {
5592
+ const route = this.partitionRouter.routeToConnection(key);
5593
+ if (!route) {
5497
5594
  logger.warn({ key }, "No route available for key");
5498
5595
  return false;
5499
5596
  }
@@ -5504,7 +5601,7 @@ var ClusterClient = class {
5504
5601
  mapVersion: this.partitionRouter.getMapVersion()
5505
5602
  }
5506
5603
  };
5507
- connection.socket.send((0, import_core10.serialize)(routedMessage));
5604
+ route.connection.send((0, import_core10.serialize)(routedMessage));
5508
5605
  return true;
5509
5606
  }
5510
5607
  /**
@@ -5608,10 +5705,23 @@ var ClusterClient = class {
5608
5705
  async refreshPartitionMap() {
5609
5706
  await this.partitionRouter.refreshPartitionMap();
5610
5707
  }
5708
+ /**
5709
+ * Force reconnect all connections in the pool.
5710
+ */
5711
+ forceReconnect() {
5712
+ this.connectionPool.close();
5713
+ this.connect().catch((err) => {
5714
+ logger.error({ err }, "ClusterClient forceReconnect failed");
5715
+ });
5716
+ }
5611
5717
  /**
5612
5718
  * Shutdown cluster client (IConnectionProvider interface).
5613
5719
  */
5614
5720
  async close() {
5721
+ if (this.partitionMapRequestTimer) {
5722
+ clearTimeout(this.partitionMapRequestTimer);
5723
+ this.partitionMapRequestTimer = null;
5724
+ }
5615
5725
  this.partitionRouter.close();
5616
5726
  this.connectionPool.close();
5617
5727
  this.initialized = false;
@@ -5634,23 +5744,23 @@ var ClusterClient = class {
5634
5744
  return this.partitionRouter;
5635
5745
  }
5636
5746
  /**
5637
- * Get any healthy WebSocket connection (IConnectionProvider interface).
5747
+ * Get any healthy connection (IConnectionProvider interface).
5638
5748
  * @throws Error if not connected
5639
5749
  */
5640
5750
  getAnyConnection() {
5641
5751
  const conn = this.connectionPool.getAnyHealthyConnection();
5642
- if (!conn?.socket) {
5752
+ if (!conn?.connection) {
5643
5753
  throw new Error("No healthy connection available");
5644
5754
  }
5645
- return conn.socket;
5755
+ return conn.connection;
5646
5756
  }
5647
5757
  /**
5648
- * Get any healthy WebSocket connection, or null if none available.
5758
+ * Get any healthy connection, or null if none available.
5649
5759
  * Use this for optional connection checks.
5650
5760
  */
5651
5761
  getAnyConnectionOrNull() {
5652
5762
  const conn = this.connectionPool.getAnyHealthyConnection();
5653
- return conn?.socket ?? null;
5763
+ return conn?.connection ?? null;
5654
5764
  }
5655
5765
  // ============================================
5656
5766
  // Circuit Breaker Methods
@@ -5741,9 +5851,7 @@ var ClusterClient = class {
5741
5851
  setupEventHandlers() {
5742
5852
  this.connectionPool.on("node:connected", (nodeId) => {
5743
5853
  logger.debug({ nodeId }, "Node connected");
5744
- if (this.partitionRouter.getMapVersion() === 0) {
5745
- this.requestPartitionMapFromNode(nodeId);
5746
- }
5854
+ this.debouncedPartitionMapRequest(nodeId);
5747
5855
  if (this.connectionPool.getConnectedNodes().length === 1) {
5748
5856
  this.emit("connected");
5749
5857
  }
@@ -5798,6 +5906,8 @@ var ClusterClient = class {
5798
5906
  });
5799
5907
  }
5800
5908
  };
5909
+ _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS = 500;
5910
+ var ClusterClient = _ClusterClient;
5801
5911
 
5802
5912
  // src/connection/SingleServerProvider.ts
5803
5913
  var DEFAULT_CONFIG = {
@@ -5813,14 +5923,18 @@ var SingleServerProvider = class {
5813
5923
  this.reconnectTimer = null;
5814
5924
  this.isClosing = false;
5815
5925
  this.listeners = /* @__PURE__ */ new Map();
5926
+ this.onlineHandler = null;
5927
+ this.offlineHandler = null;
5816
5928
  this.url = config.url;
5817
5929
  this.config = {
5818
5930
  url: config.url,
5819
5931
  maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
5820
5932
  reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
5821
5933
  backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
5822
- maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
5934
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs,
5935
+ listenNetworkEvents: config.listenNetworkEvents ?? true
5823
5936
  };
5937
+ this.setupNetworkListeners();
5824
5938
  }
5825
5939
  /**
5826
5940
  * Connect to the WebSocket server.
@@ -5829,6 +5943,9 @@ var SingleServerProvider = class {
5829
5943
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
5830
5944
  return;
5831
5945
  }
5946
+ if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false) {
5947
+ throw new Error("Browser is offline \u2014 skipping connection attempt");
5948
+ }
5832
5949
  this.isClosing = false;
5833
5950
  return new Promise((resolve, reject) => {
5834
5951
  try {
@@ -5881,7 +5998,7 @@ var SingleServerProvider = class {
5881
5998
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
5882
5999
  throw new Error("Not connected");
5883
6000
  }
5884
- return this.ws;
6001
+ return new WebSocketConnection(this.ws);
5885
6002
  }
5886
6003
  /**
5887
6004
  * Get any available connection.
@@ -5932,6 +6049,7 @@ var SingleServerProvider = class {
5932
6049
  */
5933
6050
  async close() {
5934
6051
  this.isClosing = true;
6052
+ this.teardownNetworkListeners();
5935
6053
  if (this.reconnectTimer) {
5936
6054
  clearTimeout(this.reconnectTimer);
5937
6055
  this.reconnectTimer = null;
@@ -5970,6 +6088,10 @@ var SingleServerProvider = class {
5970
6088
  clearTimeout(this.reconnectTimer);
5971
6089
  this.reconnectTimer = null;
5972
6090
  }
6091
+ if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false && this.config.listenNetworkEvents) {
6092
+ logger.info({ url: this.url }, "Browser offline \u2014 waiting for online event instead of polling");
6093
+ return;
6094
+ }
5973
6095
  if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
5974
6096
  logger.error(
5975
6097
  { attempts: this.reconnectAttempts, url: this.url },
@@ -6005,6 +6127,37 @@ var SingleServerProvider = class {
6005
6127
  delay = delay * (0.5 + Math.random());
6006
6128
  return Math.floor(delay);
6007
6129
  }
6130
+ /**
6131
+ * Force-close the current WebSocket and immediately schedule reconnection.
6132
+ * Unlike close(), this does NOT set isClosing and preserves reconnect behavior.
6133
+ * Resets the reconnect counter so the full backoff budget is available.
6134
+ *
6135
+ * Critically, this does NOT wait for the TCP close handshake (which can
6136
+ * hang 20+ seconds on a dead network). Instead it strips all handlers from
6137
+ * the old WebSocket, fires a best-effort close(), nulls the reference, and
6138
+ * schedules reconnect right away.
6139
+ */
6140
+ forceReconnect() {
6141
+ this.reconnectAttempts = 0;
6142
+ this.isClosing = false;
6143
+ if (this.reconnectTimer) {
6144
+ clearTimeout(this.reconnectTimer);
6145
+ this.reconnectTimer = null;
6146
+ }
6147
+ if (this.ws) {
6148
+ this.ws.onopen = null;
6149
+ this.ws.onclose = null;
6150
+ this.ws.onerror = null;
6151
+ this.ws.onmessage = null;
6152
+ try {
6153
+ this.ws.close();
6154
+ } catch {
6155
+ }
6156
+ this.ws = null;
6157
+ }
6158
+ this.emit("disconnected", "default");
6159
+ this.scheduleReconnect();
6160
+ }
6008
6161
  /**
6009
6162
  * Get the WebSocket URL this provider connects to.
6010
6163
  */
@@ -6024,6 +6177,43 @@ var SingleServerProvider = class {
6024
6177
  resetReconnectAttempts() {
6025
6178
  this.reconnectAttempts = 0;
6026
6179
  }
6180
+ /**
6181
+ * Listen for browser 'online' event to trigger instant reconnect
6182
+ * when network comes back. Only active in browser environments.
6183
+ */
6184
+ setupNetworkListeners() {
6185
+ if (!this.config.listenNetworkEvents) return;
6186
+ if (typeof globalThis.addEventListener !== "function") return;
6187
+ this.onlineHandler = () => {
6188
+ if (this.isClosing) return;
6189
+ if (this.isConnected()) return;
6190
+ logger.info({ url: this.url }, "Network online detected \u2014 forcing reconnect");
6191
+ this.forceReconnect();
6192
+ };
6193
+ this.offlineHandler = () => {
6194
+ if (this.isClosing) return;
6195
+ if (!this.isConnected()) return;
6196
+ logger.info({ url: this.url }, "Network offline detected \u2014 disconnecting immediately");
6197
+ this.forceReconnect();
6198
+ };
6199
+ globalThis.addEventListener("online", this.onlineHandler);
6200
+ globalThis.addEventListener("offline", this.offlineHandler);
6201
+ }
6202
+ /**
6203
+ * Remove browser network event listeners.
6204
+ */
6205
+ teardownNetworkListeners() {
6206
+ if (typeof globalThis.removeEventListener === "function") {
6207
+ if (this.onlineHandler) {
6208
+ globalThis.removeEventListener("online", this.onlineHandler);
6209
+ this.onlineHandler = null;
6210
+ }
6211
+ if (this.offlineHandler) {
6212
+ globalThis.removeEventListener("offline", this.offlineHandler);
6213
+ this.offlineHandler = null;
6214
+ }
6215
+ }
6216
+ }
6027
6217
  };
6028
6218
 
6029
6219
  // src/TopGunClient.ts
@@ -6081,7 +6271,13 @@ var TopGunClient = class {
6081
6271
  });
6082
6272
  logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
6083
6273
  } else {
6084
- const singleServerProvider = new SingleServerProvider({ url: config.serverUrl });
6274
+ const singleServerProvider = new SingleServerProvider({
6275
+ url: config.serverUrl,
6276
+ maxReconnectAttempts: config.backoff?.maxRetries,
6277
+ reconnectDelayMs: config.backoff?.initialDelayMs,
6278
+ backoffMultiplier: config.backoff?.multiplier,
6279
+ maxReconnectDelayMs: config.backoff?.maxDelayMs
6280
+ });
6085
6281
  this.syncEngine = new SyncEngine({
6086
6282
  nodeId: this.nodeId,
6087
6283
  connectionProvider: singleServerProvider,
@@ -7131,6 +7327,24 @@ var import_core14 = require("@topgunbuild/core");
7131
7327
 
7132
7328
  // src/connection/HttpSyncProvider.ts
7133
7329
  var import_core13 = require("@topgunbuild/core");
7330
+ var HttpConnection = class {
7331
+ constructor(provider) {
7332
+ this.provider = provider;
7333
+ }
7334
+ send(data) {
7335
+ if (typeof data === "string") {
7336
+ const encoder = new TextEncoder();
7337
+ this.provider.send(encoder.encode(data));
7338
+ } else {
7339
+ this.provider.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
7340
+ }
7341
+ }
7342
+ close() {
7343
+ }
7344
+ get readyState() {
7345
+ return this.provider.isConnected() ? ConnectionReadyState.OPEN : ConnectionReadyState.CLOSED;
7346
+ }
7347
+ };
7134
7348
  var HttpSyncProvider = class {
7135
7349
  constructor(config) {
7136
7350
  this.listeners = /* @__PURE__ */ new Map();
@@ -7172,17 +7386,17 @@ var HttpSyncProvider = class {
7172
7386
  }
7173
7387
  /**
7174
7388
  * Get connection for a specific key.
7175
- * HTTP mode does not expose raw WebSocket connections.
7389
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7176
7390
  */
7177
7391
  getConnection(_key) {
7178
- throw new Error("HTTP mode does not support direct WebSocket access");
7392
+ return new HttpConnection(this);
7179
7393
  }
7180
7394
  /**
7181
7395
  * Get any available connection.
7182
- * HTTP mode does not expose raw WebSocket connections.
7396
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7183
7397
  */
7184
7398
  getAnyConnection() {
7185
- throw new Error("HTTP mode does not support direct WebSocket access");
7399
+ return new HttpConnection(this);
7186
7400
  }
7187
7401
  /**
7188
7402
  * Check if connected (last HTTP request succeeded).
@@ -7263,6 +7477,17 @@ var HttpSyncProvider = class {
7263
7477
  logger.warn({ err }, "HTTP sync provider: failed to deserialize message");
7264
7478
  }
7265
7479
  }
7480
+ /**
7481
+ * Force reconnect by restarting the polling loop.
7482
+ */
7483
+ forceReconnect() {
7484
+ this.stopPolling();
7485
+ this.connected = false;
7486
+ this.emit("disconnected", "default");
7487
+ this.connect().catch((err) => {
7488
+ logger.error({ err }, "HttpSyncProvider forceReconnect failed");
7489
+ });
7490
+ }
7266
7491
  /**
7267
7492
  * Close the HTTP sync provider.
7268
7493
  * Stops the polling loop, clears queued operations, and sets disconnected state.
@@ -7535,6 +7760,14 @@ var AutoConnectionProvider = class {
7535
7760
  }
7536
7761
  this.activeProvider.send(data, key);
7537
7762
  }
7763
+ /**
7764
+ * Force reconnect by delegating to the active provider.
7765
+ */
7766
+ forceReconnect() {
7767
+ if (this.activeProvider) {
7768
+ this.activeProvider.forceReconnect();
7769
+ }
7770
+ }
7538
7771
  /**
7539
7772
  * Close the active underlying provider.
7540
7773
  */
@@ -7592,6 +7825,7 @@ var AutoConnectionProvider = class {
7592
7825
  ClusterClient,
7593
7826
  ConflictResolverClient,
7594
7827
  ConnectionPool,
7828
+ ConnectionReadyState,
7595
7829
  DEFAULT_BACKPRESSURE_CONFIG,
7596
7830
  DEFAULT_CLUSTER_CONFIG,
7597
7831
  EncryptedStorageAdapter,
@@ -7613,6 +7847,7 @@ var AutoConnectionProvider = class {
7613
7847
  TopGunClient,
7614
7848
  TopicHandle,
7615
7849
  VALID_TRANSITIONS,
7850
+ WebSocketConnection,
7616
7851
  isValidTransition,
7617
7852
  logger
7618
7853
  });