@topgunbuild/client 0.11.0 → 0.13.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
@@ -656,9 +656,7 @@ var WebSocketManager = class {
656
656
  timeoutMs: this.config.heartbeatConfig.timeoutMs
657
657
  }, "Heartbeat timeout - triggering reconnection");
658
658
  this.stopHeartbeat();
659
- this.connectionProvider.close().catch((err) => {
660
- logger.error({ err }, "Error closing ConnectionProvider on heartbeat timeout");
661
- });
659
+ this.connectionProvider.forceReconnect();
662
660
  }
663
661
  }
664
662
  /**
@@ -951,14 +949,17 @@ var QueryManager = class {
951
949
  }
952
950
  /**
953
951
  * Send query subscription message to server.
952
+ * Includes field projection when specified in the query filter.
954
953
  */
955
954
  sendQuerySubscription(query) {
955
+ const filter = query.getFilter();
956
956
  this.config.sendMessage({
957
957
  type: "QUERY_SUB",
958
958
  payload: {
959
959
  queryId: query.id,
960
960
  mapName: query.getMapName(),
961
- query: query.getFilter()
961
+ query: filter,
962
+ fields: filter.fields
962
963
  }
963
964
  });
964
965
  }
@@ -1128,6 +1129,16 @@ var QueryManager = class {
1128
1129
  logger.debug({ queryCount: this.queries.size, hybridCount: this.hybridQueries.size }, "QueryManager: resubscribing all queries");
1129
1130
  for (const query of this.queries.values()) {
1130
1131
  this.sendQuerySubscription(query);
1132
+ const filter = query.getFilter();
1133
+ if (filter.fields && filter.fields.length > 0 && query.merkleRootHash !== 0) {
1134
+ this.config.sendMessage({
1135
+ type: "QUERY_SYNC_INIT",
1136
+ payload: {
1137
+ queryId: query.id,
1138
+ rootHash: query.merkleRootHash
1139
+ }
1140
+ });
1141
+ }
1131
1142
  }
1132
1143
  for (const query of this.hybridQueries.values()) {
1133
1144
  if (query.hasFTSPredicate()) {
@@ -1345,7 +1356,7 @@ var LockManager = class {
1345
1356
  /**
1346
1357
  * Handle lock granted message from server.
1347
1358
  */
1348
- handleLockGranted(requestId, fencingToken) {
1359
+ handleLockGranted(requestId, _name, fencingToken) {
1349
1360
  const req = this.pendingLockRequests.get(requestId);
1350
1361
  if (req) {
1351
1362
  clearTimeout(req.timer);
@@ -1356,7 +1367,7 @@ var LockManager = class {
1356
1367
  /**
1357
1368
  * Handle lock released message from server.
1358
1369
  */
1359
- handleLockReleased(requestId, success) {
1370
+ handleLockReleased(requestId, _name, success) {
1360
1371
  const req = this.pendingLockRequests.get(requestId);
1361
1372
  if (req) {
1362
1373
  clearTimeout(req.timer);
@@ -1773,6 +1784,8 @@ import { LWWMap } from "@topgunbuild/core";
1773
1784
  var MerkleSyncHandler = class {
1774
1785
  constructor(config) {
1775
1786
  this.lastSyncTimestamp = 0;
1787
+ /** Accumulated sync stats per map, flushed after a quiet period */
1788
+ this.syncStats = /* @__PURE__ */ new Map();
1776
1789
  this.config = config;
1777
1790
  }
1778
1791
  /**
@@ -1794,7 +1807,8 @@ var MerkleSyncHandler = class {
1794
1807
  * Compares root hashes and requests buckets if mismatch detected.
1795
1808
  */
1796
1809
  async handleSyncRespRoot(payload) {
1797
- const { mapName, rootHash, timestamp } = payload;
1810
+ const { mapName, timestamp } = payload;
1811
+ const rootHash = Number(payload.rootHash);
1798
1812
  const map = this.config.getMap(mapName);
1799
1813
  if (map instanceof LWWMap) {
1800
1814
  const localRootHash = map.getMerkleTree().getRootHash();
@@ -1822,9 +1836,11 @@ var MerkleSyncHandler = class {
1822
1836
  if (map instanceof LWWMap) {
1823
1837
  const tree = map.getMerkleTree();
1824
1838
  const localBuckets = tree.getBuckets(path);
1839
+ let mismatchCount = 0;
1825
1840
  for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
1826
1841
  const localHash = localBuckets[bucketKey] || 0;
1827
1842
  if (localHash !== remoteHash) {
1843
+ mismatchCount++;
1828
1844
  const newPath = path + bucketKey;
1829
1845
  this.config.sendMessage({
1830
1846
  type: "MERKLE_REQ_BUCKET",
@@ -1851,7 +1867,17 @@ var MerkleSyncHandler = class {
1851
1867
  }
1852
1868
  }
1853
1869
  if (updateCount > 0) {
1854
- logger.info({ mapName, count: updateCount }, "Synced records from server");
1870
+ const existing = this.syncStats.get(mapName);
1871
+ if (existing) {
1872
+ existing.count += updateCount;
1873
+ clearTimeout(existing.timer);
1874
+ }
1875
+ const stats = existing ?? { count: updateCount, timer: void 0 };
1876
+ if (!existing) this.syncStats.set(mapName, stats);
1877
+ stats.timer = setTimeout(() => {
1878
+ logger.info({ mapName, count: stats.count }, "Synced records from server");
1879
+ this.syncStats.delete(mapName);
1880
+ }, 100);
1855
1881
  }
1856
1882
  }
1857
1883
  }
@@ -2134,6 +2160,8 @@ function registerClientMessageHandlers(router, delegates, managers) {
2134
2160
  },
2135
2161
  // SYNC handlers
2136
2162
  "OP_ACK": (msg) => delegates.handleOpAck(msg),
2163
+ "OP_REJECTED": (msg) => delegates.handleOpRejected(msg),
2164
+ "ERROR": (msg) => delegates.handleError(msg),
2137
2165
  "SYNC_RESP_ROOT": (msg) => managers.merkleSyncHandler.handleSyncRespRoot(msg.payload),
2138
2166
  "SYNC_RESP_BUCKETS": (msg) => managers.merkleSyncHandler.handleSyncRespBuckets(msg.payload),
2139
2167
  "SYNC_RESP_LEAF": (msg) => managers.merkleSyncHandler.handleSyncRespLeaf(msg.payload),
@@ -2156,12 +2184,12 @@ function registerClientMessageHandlers(router, delegates, managers) {
2156
2184
  },
2157
2185
  // LOCK handlers
2158
2186
  "LOCK_GRANTED": (msg) => {
2159
- const { requestId, fencingToken } = msg.payload;
2160
- managers.lockManager.handleLockGranted(requestId, fencingToken);
2187
+ const { requestId, name, fencingToken } = msg.payload;
2188
+ managers.lockManager.handleLockGranted(requestId, name, fencingToken);
2161
2189
  },
2162
2190
  "LOCK_RELEASED": (msg) => {
2163
- const { requestId, success } = msg.payload;
2164
- managers.lockManager.handleLockReleased(requestId, success);
2191
+ const { requestId, name, success } = msg.payload;
2192
+ managers.lockManager.handleLockReleased(requestId, name, success);
2165
2193
  },
2166
2194
  // GC handler
2167
2195
  "GC_PRUNE": (msg) => delegates.handleGcPrune(msg),
@@ -2199,10 +2227,7 @@ function registerClientMessageHandlers(router, delegates, managers) {
2199
2227
  managers.searchClient.handleSearchResponse(msg.payload);
2200
2228
  },
2201
2229
  "SEARCH_UPDATE": () => {
2202
- },
2203
- // HYBRID handlers
2204
- "HYBRID_QUERY_RESP": (msg) => delegates.handleHybridQueryResponse(msg.payload),
2205
- "HYBRID_QUERY_DELTA": (msg) => delegates.handleHybridQueryDelta(msg.payload)
2230
+ }
2206
2231
  });
2207
2232
  }
2208
2233
 
@@ -2337,8 +2362,8 @@ var SyncEngine = class {
2337
2362
  handleServerEvent: (msg) => this.handleServerEvent(msg),
2338
2363
  handleServerBatchEvent: (msg) => this.handleServerBatchEvent(msg),
2339
2364
  handleGcPrune: (msg) => this.handleGcPrune(msg),
2340
- handleHybridQueryResponse: (payload) => this.handleHybridQueryResponse(payload),
2341
- handleHybridQueryDelta: (payload) => this.handleHybridQueryDelta(payload)
2365
+ handleOpRejected: (msg) => this.handleOpRejected(msg),
2366
+ handleError: (msg) => this.handleError(msg)
2342
2367
  },
2343
2368
  {
2344
2369
  topicManager: this.topicManager,
@@ -2491,6 +2516,15 @@ var SyncEngine = class {
2491
2516
  const pending = this.opLog.filter((op) => !op.synced);
2492
2517
  if (pending.length === 0) return;
2493
2518
  logger.info({ count: pending.length }, "Syncing pending operations");
2519
+ const connectionProvider = this.webSocketManager.getConnectionProvider();
2520
+ if (connectionProvider.sendBatch) {
2521
+ const results = connectionProvider.sendBatch(pending.map((op) => ({ key: op.key, message: op })));
2522
+ const failedKeys = [...results.entries()].filter(([, success]) => !success).map(([key]) => key);
2523
+ if (failedKeys.length > 0) {
2524
+ logger.warn({ failedKeys, count: failedKeys.length }, "Some batch operations failed to send");
2525
+ }
2526
+ return;
2527
+ }
2494
2528
  this.sendMessage({
2495
2529
  type: "OP_BATCH",
2496
2530
  payload: {
@@ -2511,7 +2545,7 @@ var SyncEngine = class {
2511
2545
  this.authToken = token;
2512
2546
  this.tokenProvider = null;
2513
2547
  const state = this.stateMachine.getState();
2514
- if (state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "CONNECTING" /* CONNECTING */) {
2548
+ if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
2515
2549
  this.sendAuth();
2516
2550
  } else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
2517
2551
  logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
@@ -2616,9 +2650,10 @@ var SyncEngine = class {
2616
2650
  return;
2617
2651
  }
2618
2652
  await this.messageRouter.route(message);
2619
- if (message.timestamp) {
2620
- this.hlc.update(message.timestamp);
2621
- this.lastSyncTimestamp = message.timestamp.millis;
2653
+ const ts = message.timestamp;
2654
+ if (ts && typeof ts === "object" && "millis" in ts && "counter" in ts && "nodeId" in ts) {
2655
+ this.hlc.update(ts);
2656
+ this.lastSyncTimestamp = Number(ts.millis);
2622
2657
  await this.saveOpLog();
2623
2658
  }
2624
2659
  }
@@ -2672,20 +2707,37 @@ var SyncEngine = class {
2672
2707
  this.writeConcernManager.resolveWriteConcernPromise(result.opId, result);
2673
2708
  }
2674
2709
  }
2710
+ const lastIdNum = parseInt(lastId, 10);
2675
2711
  let maxSyncedId = -1;
2676
2712
  let ackedCount = 0;
2677
- this.opLog.forEach((op) => {
2678
- if (op.id && op.id <= lastId) {
2713
+ if (!isNaN(lastIdNum)) {
2714
+ this.opLog.forEach((op) => {
2715
+ if (op.id) {
2716
+ const opIdNum = parseInt(op.id, 10);
2717
+ if (!isNaN(opIdNum) && opIdNum <= lastIdNum) {
2718
+ if (!op.synced) {
2719
+ ackedCount++;
2720
+ }
2721
+ op.synced = true;
2722
+ if (opIdNum > maxSyncedId) {
2723
+ maxSyncedId = opIdNum;
2724
+ }
2725
+ }
2726
+ }
2727
+ });
2728
+ } else {
2729
+ logger.warn({ lastId }, "OP_ACK has non-numeric lastId \u2014 marking all pending ops as synced");
2730
+ this.opLog.forEach((op) => {
2679
2731
  if (!op.synced) {
2680
2732
  ackedCount++;
2733
+ op.synced = true;
2734
+ const opIdNum = parseInt(op.id, 10);
2735
+ if (!isNaN(opIdNum) && opIdNum > maxSyncedId) {
2736
+ maxSyncedId = opIdNum;
2737
+ }
2681
2738
  }
2682
- op.synced = true;
2683
- const idNum = parseInt(op.id, 10);
2684
- if (!isNaN(idNum) && idNum > maxSyncedId) {
2685
- maxSyncedId = idNum;
2686
- }
2687
- }
2688
- });
2739
+ });
2740
+ }
2689
2741
  if (maxSyncedId !== -1) {
2690
2742
  this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
2691
2743
  }
@@ -2694,18 +2746,18 @@ var SyncEngine = class {
2694
2746
  }
2695
2747
  }
2696
2748
  handleQueryResp(message) {
2697
- const { queryId, results, nextCursor, hasMore, cursorStatus } = message.payload;
2749
+ const { queryId, results, nextCursor, hasMore, cursorStatus, merkleRootHash } = message.payload;
2698
2750
  const query = this.queryManager.getQueries().get(queryId);
2699
2751
  if (query) {
2700
- query.onResult(results, "server");
2752
+ query.onResult(results, "server", merkleRootHash);
2701
2753
  query.updatePaginationInfo({ nextCursor, hasMore, cursorStatus });
2702
2754
  }
2703
2755
  }
2704
2756
  handleQueryUpdate(message) {
2705
- const { queryId, key, value, type } = message.payload;
2757
+ const { queryId, key, value, changeType } = message.payload;
2706
2758
  const query = this.queryManager.getQueries().get(queryId);
2707
2759
  if (query) {
2708
- query.onUpdate(key, type === "REMOVE" ? null : value);
2760
+ query.onUpdate(key, changeType === "LEAVE" ? null : value);
2709
2761
  }
2710
2762
  }
2711
2763
  async handleServerEvent(message) {
@@ -3110,31 +3162,24 @@ var SyncEngine = class {
3110
3162
  return this.queryManager.runLocalHybridQuery(mapName, filter);
3111
3163
  }
3112
3164
  /**
3113
- * Handle hybrid query response from server.
3165
+ * Handle operation rejected by server (permission denied, validation failure, etc.).
3114
3166
  */
3115
- handleHybridQueryResponse(payload) {
3116
- const query = this.queryManager.getHybridQuery(payload.subscriptionId);
3117
- if (query) {
3118
- query.onResult(payload.results, "server");
3119
- query.updatePaginationInfo({
3120
- nextCursor: payload.nextCursor,
3121
- hasMore: payload.hasMore,
3122
- cursorStatus: payload.cursorStatus
3123
- });
3124
- }
3167
+ handleOpRejected(message) {
3168
+ const { opId, reason, code } = message.payload;
3169
+ logger.warn({ opId, reason, code }, "Operation rejected by server");
3170
+ this.writeConcernManager.resolveWriteConcernPromise(opId, {
3171
+ opId,
3172
+ success: false,
3173
+ achievedLevel: "FIRE_AND_FORGET",
3174
+ error: reason
3175
+ });
3125
3176
  }
3126
3177
  /**
3127
- * Handle hybrid query delta update from server.
3178
+ * Handle generic error message from server.
3128
3179
  */
3129
- handleHybridQueryDelta(payload) {
3130
- const query = this.queryManager.getHybridQuery(payload.subscriptionId);
3131
- if (query) {
3132
- if (payload.type === "LEAVE") {
3133
- query.onUpdate(payload.key, null);
3134
- } else {
3135
- query.onUpdate(payload.key, payload.value, payload.score, payload.matchedTerms);
3136
- }
3137
- }
3180
+ handleError(message) {
3181
+ const { code, message: errorMessage, details } = message.payload;
3182
+ logger.error({ code, message: errorMessage, details }, "Server error received");
3138
3183
  }
3139
3184
  };
3140
3185
 
@@ -3241,12 +3286,15 @@ var QueryHandle = class {
3241
3286
  // Pagination info
3242
3287
  this._paginationInfo = { hasMore: false, cursorStatus: "none" };
3243
3288
  this.paginationListeners = /* @__PURE__ */ new Set();
3289
+ /** Merkle root hash from last server QUERY_RESP — used for delta reconnect */
3290
+ this.merkleRootHash = 0;
3244
3291
  // Track if we've received authoritative server response
3245
3292
  this.hasReceivedServerData = false;
3246
3293
  this.id = crypto.randomUUID();
3247
3294
  this.syncEngine = syncEngine;
3248
3295
  this.mapName = mapName;
3249
3296
  this.filter = filter;
3297
+ this.fields = filter.fields;
3250
3298
  }
3251
3299
  subscribe(callback) {
3252
3300
  this.listeners.add(callback);
@@ -3282,7 +3330,7 @@ var QueryHandle = class {
3282
3330
  * - This prevents clearing local data when server hasn't loaded from storage yet
3283
3331
  * - Works with any async storage adapter (PostgreSQL, SQLite, Redis, etc.)
3284
3332
  */
3285
- onResult(items, source = "server") {
3333
+ onResult(items, source = "server", merkleRootHash) {
3286
3334
  logger.debug({
3287
3335
  mapName: this.mapName,
3288
3336
  itemCount: items.length,
@@ -3297,6 +3345,9 @@ var QueryHandle = class {
3297
3345
  if (source === "server" && items.length > 0) {
3298
3346
  this.hasReceivedServerData = true;
3299
3347
  }
3348
+ if (merkleRootHash !== void 0) {
3349
+ this.merkleRootHash = merkleRootHash;
3350
+ }
3300
3351
  const newKeys = new Set(items.map((i) => i.key));
3301
3352
  const removedKeys = [];
3302
3353
  for (const key of this.currentResults.keys()) {
@@ -4018,8 +4069,8 @@ var SearchHandle = class {
4018
4069
  handleSearchUpdate(message) {
4019
4070
  if (message.type !== "SEARCH_UPDATE") return;
4020
4071
  if (message.payload?.subscriptionId !== this.subscriptionId) return;
4021
- const { key, value, score, matchedTerms, type } = message.payload;
4022
- switch (type) {
4072
+ const { key, value, score, matchedTerms, changeType } = message.payload;
4073
+ switch (changeType) {
4023
4074
  case "ENTER":
4024
4075
  this.results.set(key, {
4025
4076
  key,
@@ -4343,6 +4394,30 @@ import {
4343
4394
  DEFAULT_CONNECTION_POOL_CONFIG
4344
4395
  } from "@topgunbuild/core";
4345
4396
  import { serialize as serialize2, deserialize as deserialize3 } from "@topgunbuild/core";
4397
+
4398
+ // src/connection/WebSocketConnection.ts
4399
+ var ConnectionReadyState = {
4400
+ CONNECTING: 0,
4401
+ OPEN: 1,
4402
+ CLOSING: 2,
4403
+ CLOSED: 3
4404
+ };
4405
+ var WebSocketConnection = class {
4406
+ constructor(ws) {
4407
+ this.ws = ws;
4408
+ }
4409
+ send(data) {
4410
+ this.ws.send(data);
4411
+ }
4412
+ close() {
4413
+ this.ws.close();
4414
+ }
4415
+ get readyState() {
4416
+ return this.ws.readyState;
4417
+ }
4418
+ };
4419
+
4420
+ // src/cluster/ConnectionPool.ts
4346
4421
  var ConnectionPool = class {
4347
4422
  constructor(config = {}) {
4348
4423
  this.listeners = /* @__PURE__ */ new Map();
@@ -4414,10 +4489,17 @@ var ConnectionPool = class {
4414
4489
  return;
4415
4490
  }
4416
4491
  }
4492
+ for (const [existingId, existingConn] of this.connections) {
4493
+ if (existingConn.endpoint === endpoint && existingId !== nodeId) {
4494
+ this.remapNodeId(existingId, nodeId);
4495
+ return;
4496
+ }
4497
+ }
4417
4498
  const connection = {
4418
4499
  nodeId,
4419
4500
  endpoint,
4420
4501
  socket: null,
4502
+ cachedConnection: null,
4421
4503
  state: "DISCONNECTED",
4422
4504
  lastSeen: 0,
4423
4505
  latencyMs: 0,
@@ -4452,15 +4534,34 @@ var ConnectionPool = class {
4452
4534
  }
4453
4535
  logger.info({ nodeId }, "Node removed from connection pool");
4454
4536
  }
4537
+ /**
4538
+ * Remap a node from one ID to another, preserving the existing connection.
4539
+ * Used when the server-assigned node ID differs from the temporary seed ID.
4540
+ */
4541
+ remapNodeId(oldId, newId) {
4542
+ const connection = this.connections.get(oldId);
4543
+ if (!connection) return;
4544
+ connection.nodeId = newId;
4545
+ this.connections.delete(oldId);
4546
+ this.connections.set(newId, connection);
4547
+ if (this.primaryNodeId === oldId) {
4548
+ this.primaryNodeId = newId;
4549
+ }
4550
+ logger.info({ oldId, newId }, "Node ID remapped");
4551
+ this.emit("node:remapped", oldId, newId);
4552
+ }
4455
4553
  /**
4456
4554
  * Get connection for a specific node
4457
4555
  */
4458
4556
  getConnection(nodeId) {
4459
4557
  const connection = this.connections.get(nodeId);
4460
- if (!connection || connection.state !== "AUTHENTICATED") {
4558
+ if (!connection || connection.state !== "CONNECTED" && connection.state !== "AUTHENTICATED" || !connection.socket) {
4461
4559
  return null;
4462
4560
  }
4463
- return connection.socket;
4561
+ if (!connection.cachedConnection) {
4562
+ connection.cachedConnection = new WebSocketConnection(connection.socket);
4563
+ }
4564
+ return connection.cachedConnection;
4464
4565
  }
4465
4566
  /**
4466
4567
  * Get primary connection (first/seed node)
@@ -4474,8 +4575,11 @@ var ConnectionPool = class {
4474
4575
  */
4475
4576
  getAnyHealthyConnection() {
4476
4577
  for (const [nodeId, conn] of this.connections) {
4477
- if (conn.state === "AUTHENTICATED" && conn.socket) {
4478
- return { nodeId, socket: conn.socket };
4578
+ if ((conn.state === "CONNECTED" || conn.state === "AUTHENTICATED") && conn.socket) {
4579
+ if (!conn.cachedConnection) {
4580
+ conn.cachedConnection = new WebSocketConnection(conn.socket);
4581
+ }
4582
+ return { nodeId, connection: conn.cachedConnection };
4479
4583
  }
4480
4584
  }
4481
4585
  return null;
@@ -4531,7 +4635,7 @@ var ConnectionPool = class {
4531
4635
  * Get list of connected node IDs
4532
4636
  */
4533
4637
  getConnectedNodes() {
4534
- return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
4638
+ return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "CONNECTED" || conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
4535
4639
  }
4536
4640
  /**
4537
4641
  * Get all node IDs
@@ -4540,11 +4644,11 @@ var ConnectionPool = class {
4540
4644
  return Array.from(this.connections.keys());
4541
4645
  }
4542
4646
  /**
4543
- * Check if node is connected and authenticated
4647
+ * Check if node has an open WebSocket connection
4544
4648
  */
4545
4649
  isNodeConnected(nodeId) {
4546
4650
  const conn = this.connections.get(nodeId);
4547
- return conn?.state === "AUTHENTICATED";
4651
+ return conn?.state === "CONNECTED" || conn?.state === "AUTHENTICATED";
4548
4652
  }
4549
4653
  /**
4550
4654
  * Check if connected to a specific node.
@@ -4609,25 +4713,26 @@ var ConnectionPool = class {
4609
4713
  };
4610
4714
  socket.onmessage = (event) => {
4611
4715
  connection.lastSeen = Date.now();
4612
- this.handleMessage(nodeId, event);
4716
+ this.handleMessage(connection.nodeId, event);
4613
4717
  };
4614
4718
  socket.onerror = (error) => {
4615
- logger.error({ nodeId, error }, "WebSocket error");
4616
- this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
4719
+ logger.error({ nodeId: connection.nodeId, error }, "WebSocket error");
4720
+ this.emit("error", connection.nodeId, error instanceof Error ? error : new Error("WebSocket error"));
4617
4721
  };
4618
4722
  socket.onclose = () => {
4619
4723
  const wasConnected = connection.state === "AUTHENTICATED";
4620
4724
  connection.state = "DISCONNECTED";
4621
4725
  connection.socket = null;
4726
+ connection.cachedConnection = null;
4622
4727
  if (wasConnected) {
4623
- this.emit("node:disconnected", nodeId, "Connection closed");
4728
+ this.emit("node:disconnected", connection.nodeId, "Connection closed");
4624
4729
  }
4625
- this.scheduleReconnect(nodeId);
4730
+ this.scheduleReconnect(connection.nodeId);
4626
4731
  };
4627
4732
  } catch (error) {
4628
4733
  connection.state = "FAILED";
4629
- logger.error({ nodeId, error }, "Failed to connect");
4630
- this.scheduleReconnect(nodeId);
4734
+ logger.error({ nodeId: connection.nodeId, error }, "Failed to connect");
4735
+ this.scheduleReconnect(connection.nodeId);
4631
4736
  }
4632
4737
  }
4633
4738
  sendAuth(connection) {
@@ -4656,18 +4761,15 @@ var ConnectionPool = class {
4656
4761
  logger.info({ nodeId }, "Authenticated with node");
4657
4762
  this.emit("node:healthy", nodeId);
4658
4763
  this.flushPendingMessages(connection);
4659
- return;
4660
4764
  }
4661
4765
  if (message.type === "AUTH_REQUIRED") {
4662
4766
  if (this.authToken) {
4663
4767
  this.sendAuth(connection);
4664
4768
  }
4665
- return;
4666
4769
  }
4667
4770
  if (message.type === "AUTH_FAIL") {
4668
4771
  logger.error({ nodeId, error: message.error }, "Authentication failed");
4669
4772
  connection.state = "FAILED";
4670
- return;
4671
4773
  }
4672
4774
  if (message.type === "PONG") {
4673
4775
  if (message.timestamp) {
@@ -4675,10 +4777,6 @@ var ConnectionPool = class {
4675
4777
  }
4676
4778
  return;
4677
4779
  }
4678
- if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
4679
- this.emit("message", nodeId, message);
4680
- return;
4681
- }
4682
4780
  this.emit("message", nodeId, message);
4683
4781
  }
4684
4782
  flushPendingMessages(connection) {
@@ -4849,17 +4947,17 @@ var PartitionRouter = class {
4849
4947
  }
4850
4948
  return null;
4851
4949
  }
4852
- const socket = this.connectionPool.getConnection(routing.nodeId);
4853
- if (socket) {
4854
- return { nodeId: routing.nodeId, socket };
4950
+ const connection = this.connectionPool.getConnection(routing.nodeId);
4951
+ if (connection) {
4952
+ return { nodeId: routing.nodeId, connection };
4855
4953
  }
4856
4954
  const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
4857
4955
  if (partition) {
4858
4956
  for (const backupId of partition.backupNodeIds) {
4859
- const backupSocket = this.connectionPool.getConnection(backupId);
4860
- if (backupSocket) {
4957
+ const backupConnection = this.connectionPool.getConnection(backupId);
4958
+ if (backupConnection) {
4861
4959
  logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
4862
- return { nodeId: backupId, socket: backupSocket };
4960
+ return { nodeId: backupId, connection: backupConnection };
4863
4961
  }
4864
4962
  }
4865
4963
  }
@@ -5135,7 +5233,7 @@ var PartitionRouter = class {
5135
5233
  };
5136
5234
 
5137
5235
  // src/cluster/ClusterClient.ts
5138
- var ClusterClient = class {
5236
+ var _ClusterClient = class _ClusterClient {
5139
5237
  constructor(config) {
5140
5238
  this.listeners = /* @__PURE__ */ new Map();
5141
5239
  this.initialized = false;
@@ -5148,6 +5246,8 @@ var ClusterClient = class {
5148
5246
  };
5149
5247
  // Circuit breaker state per node
5150
5248
  this.circuits = /* @__PURE__ */ new Map();
5249
+ // Debounce timer for partition map requests on reconnect
5250
+ this.partitionMapRequestTimer = null;
5151
5251
  this.config = config;
5152
5252
  this.circuitBreakerConfig = {
5153
5253
  ...DEFAULT_CIRCUIT_BREAKER_CONFIG,
@@ -5239,14 +5339,14 @@ var ClusterClient = class {
5239
5339
  this.requestPartitionMapRefresh();
5240
5340
  return this.getFallbackConnection();
5241
5341
  }
5242
- const socket = this.connectionPool.getConnection(owner);
5243
- if (!socket) {
5342
+ const connection = this.connectionPool.getConnection(owner);
5343
+ if (!connection) {
5244
5344
  this.routingMetrics.fallbackRoutes++;
5245
5345
  logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
5246
5346
  return this.getFallbackConnection();
5247
5347
  }
5248
5348
  this.routingMetrics.directRoutes++;
5249
- return socket;
5349
+ return connection;
5250
5350
  }
5251
5351
  /**
5252
5352
  * Get fallback connection when owner is unavailable.
@@ -5254,10 +5354,10 @@ var ClusterClient = class {
5254
5354
  */
5255
5355
  getFallbackConnection() {
5256
5356
  const conn = this.connectionPool.getAnyHealthyConnection();
5257
- if (!conn?.socket) {
5357
+ if (!conn?.connection) {
5258
5358
  throw new Error("No healthy connection available");
5259
5359
  }
5260
- return conn.socket;
5360
+ return conn.connection;
5261
5361
  }
5262
5362
  /**
5263
5363
  * Request a partition map refresh in the background.
@@ -5268,9 +5368,23 @@ var ClusterClient = class {
5268
5368
  logger.error({ err }, "Failed to refresh partition map");
5269
5369
  });
5270
5370
  }
5371
+ /**
5372
+ * Debounce partition map requests to prevent flooding when multiple nodes
5373
+ * reconnect simultaneously. Coalesces rapid requests into a single request
5374
+ * sent to the most recently connected node.
5375
+ */
5376
+ debouncedPartitionMapRequest(nodeId) {
5377
+ if (this.partitionMapRequestTimer) {
5378
+ clearTimeout(this.partitionMapRequestTimer);
5379
+ }
5380
+ this.partitionMapRequestTimer = setTimeout(() => {
5381
+ this.partitionMapRequestTimer = null;
5382
+ this.requestPartitionMapFromNode(nodeId);
5383
+ }, _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS);
5384
+ }
5271
5385
  /**
5272
5386
  * Request partition map from a specific node.
5273
- * Called on first node connection.
5387
+ * Called on node connection via debounced handler.
5274
5388
  */
5275
5389
  requestPartitionMapFromNode(nodeId) {
5276
5390
  const socket = this.connectionPool.getConnection(nodeId);
@@ -5439,8 +5553,8 @@ var ClusterClient = class {
5439
5553
  * Send directly to partition owner
5440
5554
  */
5441
5555
  sendDirect(key, message) {
5442
- const connection = this.partitionRouter.routeToConnection(key);
5443
- if (!connection) {
5556
+ const route = this.partitionRouter.routeToConnection(key);
5557
+ if (!route) {
5444
5558
  logger.warn({ key }, "No route available for key");
5445
5559
  return false;
5446
5560
  }
@@ -5451,7 +5565,7 @@ var ClusterClient = class {
5451
5565
  mapVersion: this.partitionRouter.getMapVersion()
5452
5566
  }
5453
5567
  };
5454
- connection.socket.send(serialize3(routedMessage));
5568
+ route.connection.send(serialize3(routedMessage));
5455
5569
  return true;
5456
5570
  }
5457
5571
  /**
@@ -5555,10 +5669,23 @@ var ClusterClient = class {
5555
5669
  async refreshPartitionMap() {
5556
5670
  await this.partitionRouter.refreshPartitionMap();
5557
5671
  }
5672
+ /**
5673
+ * Force reconnect all connections in the pool.
5674
+ */
5675
+ forceReconnect() {
5676
+ this.connectionPool.close();
5677
+ this.connect().catch((err) => {
5678
+ logger.error({ err }, "ClusterClient forceReconnect failed");
5679
+ });
5680
+ }
5558
5681
  /**
5559
5682
  * Shutdown cluster client (IConnectionProvider interface).
5560
5683
  */
5561
5684
  async close() {
5685
+ if (this.partitionMapRequestTimer) {
5686
+ clearTimeout(this.partitionMapRequestTimer);
5687
+ this.partitionMapRequestTimer = null;
5688
+ }
5562
5689
  this.partitionRouter.close();
5563
5690
  this.connectionPool.close();
5564
5691
  this.initialized = false;
@@ -5581,23 +5708,23 @@ var ClusterClient = class {
5581
5708
  return this.partitionRouter;
5582
5709
  }
5583
5710
  /**
5584
- * Get any healthy WebSocket connection (IConnectionProvider interface).
5711
+ * Get any healthy connection (IConnectionProvider interface).
5585
5712
  * @throws Error if not connected
5586
5713
  */
5587
5714
  getAnyConnection() {
5588
5715
  const conn = this.connectionPool.getAnyHealthyConnection();
5589
- if (!conn?.socket) {
5716
+ if (!conn?.connection) {
5590
5717
  throw new Error("No healthy connection available");
5591
5718
  }
5592
- return conn.socket;
5719
+ return conn.connection;
5593
5720
  }
5594
5721
  /**
5595
- * Get any healthy WebSocket connection, or null if none available.
5722
+ * Get any healthy connection, or null if none available.
5596
5723
  * Use this for optional connection checks.
5597
5724
  */
5598
5725
  getAnyConnectionOrNull() {
5599
5726
  const conn = this.connectionPool.getAnyHealthyConnection();
5600
- return conn?.socket ?? null;
5727
+ return conn?.connection ?? null;
5601
5728
  }
5602
5729
  // ============================================
5603
5730
  // Circuit Breaker Methods
@@ -5688,9 +5815,7 @@ var ClusterClient = class {
5688
5815
  setupEventHandlers() {
5689
5816
  this.connectionPool.on("node:connected", (nodeId) => {
5690
5817
  logger.debug({ nodeId }, "Node connected");
5691
- if (this.partitionRouter.getMapVersion() === 0) {
5692
- this.requestPartitionMapFromNode(nodeId);
5693
- }
5818
+ this.debouncedPartitionMapRequest(nodeId);
5694
5819
  if (this.connectionPool.getConnectedNodes().length === 1) {
5695
5820
  this.emit("connected");
5696
5821
  }
@@ -5745,6 +5870,8 @@ var ClusterClient = class {
5745
5870
  });
5746
5871
  }
5747
5872
  };
5873
+ _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS = 500;
5874
+ var ClusterClient = _ClusterClient;
5748
5875
 
5749
5876
  // src/connection/SingleServerProvider.ts
5750
5877
  var DEFAULT_CONFIG = {
@@ -5760,14 +5887,18 @@ var SingleServerProvider = class {
5760
5887
  this.reconnectTimer = null;
5761
5888
  this.isClosing = false;
5762
5889
  this.listeners = /* @__PURE__ */ new Map();
5890
+ this.onlineHandler = null;
5891
+ this.offlineHandler = null;
5763
5892
  this.url = config.url;
5764
5893
  this.config = {
5765
5894
  url: config.url,
5766
5895
  maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
5767
5896
  reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
5768
5897
  backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
5769
- maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
5898
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs,
5899
+ listenNetworkEvents: config.listenNetworkEvents ?? true
5770
5900
  };
5901
+ this.setupNetworkListeners();
5771
5902
  }
5772
5903
  /**
5773
5904
  * Connect to the WebSocket server.
@@ -5776,6 +5907,9 @@ var SingleServerProvider = class {
5776
5907
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
5777
5908
  return;
5778
5909
  }
5910
+ if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false) {
5911
+ throw new Error("Browser is offline \u2014 skipping connection attempt");
5912
+ }
5779
5913
  this.isClosing = false;
5780
5914
  return new Promise((resolve, reject) => {
5781
5915
  try {
@@ -5828,7 +5962,7 @@ var SingleServerProvider = class {
5828
5962
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
5829
5963
  throw new Error("Not connected");
5830
5964
  }
5831
- return this.ws;
5965
+ return new WebSocketConnection(this.ws);
5832
5966
  }
5833
5967
  /**
5834
5968
  * Get any available connection.
@@ -5879,6 +6013,7 @@ var SingleServerProvider = class {
5879
6013
  */
5880
6014
  async close() {
5881
6015
  this.isClosing = true;
6016
+ this.teardownNetworkListeners();
5882
6017
  if (this.reconnectTimer) {
5883
6018
  clearTimeout(this.reconnectTimer);
5884
6019
  this.reconnectTimer = null;
@@ -5917,6 +6052,10 @@ var SingleServerProvider = class {
5917
6052
  clearTimeout(this.reconnectTimer);
5918
6053
  this.reconnectTimer = null;
5919
6054
  }
6055
+ if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false && this.config.listenNetworkEvents) {
6056
+ logger.info({ url: this.url }, "Browser offline \u2014 waiting for online event instead of polling");
6057
+ return;
6058
+ }
5920
6059
  if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
5921
6060
  logger.error(
5922
6061
  { attempts: this.reconnectAttempts, url: this.url },
@@ -5952,6 +6091,37 @@ var SingleServerProvider = class {
5952
6091
  delay = delay * (0.5 + Math.random());
5953
6092
  return Math.floor(delay);
5954
6093
  }
6094
+ /**
6095
+ * Force-close the current WebSocket and immediately schedule reconnection.
6096
+ * Unlike close(), this does NOT set isClosing and preserves reconnect behavior.
6097
+ * Resets the reconnect counter so the full backoff budget is available.
6098
+ *
6099
+ * Critically, this does NOT wait for the TCP close handshake (which can
6100
+ * hang 20+ seconds on a dead network). Instead it strips all handlers from
6101
+ * the old WebSocket, fires a best-effort close(), nulls the reference, and
6102
+ * schedules reconnect right away.
6103
+ */
6104
+ forceReconnect() {
6105
+ this.reconnectAttempts = 0;
6106
+ this.isClosing = false;
6107
+ if (this.reconnectTimer) {
6108
+ clearTimeout(this.reconnectTimer);
6109
+ this.reconnectTimer = null;
6110
+ }
6111
+ if (this.ws) {
6112
+ this.ws.onopen = null;
6113
+ this.ws.onclose = null;
6114
+ this.ws.onerror = null;
6115
+ this.ws.onmessage = null;
6116
+ try {
6117
+ this.ws.close();
6118
+ } catch {
6119
+ }
6120
+ this.ws = null;
6121
+ }
6122
+ this.emit("disconnected", "default");
6123
+ this.scheduleReconnect();
6124
+ }
5955
6125
  /**
5956
6126
  * Get the WebSocket URL this provider connects to.
5957
6127
  */
@@ -5971,6 +6141,43 @@ var SingleServerProvider = class {
5971
6141
  resetReconnectAttempts() {
5972
6142
  this.reconnectAttempts = 0;
5973
6143
  }
6144
+ /**
6145
+ * Listen for browser 'online' event to trigger instant reconnect
6146
+ * when network comes back. Only active in browser environments.
6147
+ */
6148
+ setupNetworkListeners() {
6149
+ if (!this.config.listenNetworkEvents) return;
6150
+ if (typeof globalThis.addEventListener !== "function") return;
6151
+ this.onlineHandler = () => {
6152
+ if (this.isClosing) return;
6153
+ if (this.isConnected()) return;
6154
+ logger.info({ url: this.url }, "Network online detected \u2014 forcing reconnect");
6155
+ this.forceReconnect();
6156
+ };
6157
+ this.offlineHandler = () => {
6158
+ if (this.isClosing) return;
6159
+ if (!this.isConnected()) return;
6160
+ logger.info({ url: this.url }, "Network offline detected \u2014 disconnecting immediately");
6161
+ this.forceReconnect();
6162
+ };
6163
+ globalThis.addEventListener("online", this.onlineHandler);
6164
+ globalThis.addEventListener("offline", this.offlineHandler);
6165
+ }
6166
+ /**
6167
+ * Remove browser network event listeners.
6168
+ */
6169
+ teardownNetworkListeners() {
6170
+ if (typeof globalThis.removeEventListener === "function") {
6171
+ if (this.onlineHandler) {
6172
+ globalThis.removeEventListener("online", this.onlineHandler);
6173
+ this.onlineHandler = null;
6174
+ }
6175
+ if (this.offlineHandler) {
6176
+ globalThis.removeEventListener("offline", this.offlineHandler);
6177
+ this.offlineHandler = null;
6178
+ }
6179
+ }
6180
+ }
5974
6181
  };
5975
6182
 
5976
6183
  // src/TopGunClient.ts
@@ -6028,7 +6235,13 @@ var TopGunClient = class {
6028
6235
  });
6029
6236
  logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
6030
6237
  } else {
6031
- const singleServerProvider = new SingleServerProvider({ url: config.serverUrl });
6238
+ const singleServerProvider = new SingleServerProvider({
6239
+ url: config.serverUrl,
6240
+ maxReconnectAttempts: config.backoff?.maxRetries,
6241
+ reconnectDelayMs: config.backoff?.initialDelayMs,
6242
+ backoffMultiplier: config.backoff?.multiplier,
6243
+ maxReconnectDelayMs: config.backoff?.maxDelayMs
6244
+ });
6032
6245
  this.syncEngine = new SyncEngine({
6033
6246
  nodeId: this.nodeId,
6034
6247
  connectionProvider: singleServerProvider,
@@ -7078,6 +7291,24 @@ import { LWWMap as LWWMap4, Predicates } from "@topgunbuild/core";
7078
7291
 
7079
7292
  // src/connection/HttpSyncProvider.ts
7080
7293
  import { serialize as serialize5, deserialize as deserialize5 } from "@topgunbuild/core";
7294
+ var HttpConnection = class {
7295
+ constructor(provider) {
7296
+ this.provider = provider;
7297
+ }
7298
+ send(data) {
7299
+ if (typeof data === "string") {
7300
+ const encoder = new TextEncoder();
7301
+ this.provider.send(encoder.encode(data));
7302
+ } else {
7303
+ this.provider.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
7304
+ }
7305
+ }
7306
+ close() {
7307
+ }
7308
+ get readyState() {
7309
+ return this.provider.isConnected() ? ConnectionReadyState.OPEN : ConnectionReadyState.CLOSED;
7310
+ }
7311
+ };
7081
7312
  var HttpSyncProvider = class {
7082
7313
  constructor(config) {
7083
7314
  this.listeners = /* @__PURE__ */ new Map();
@@ -7119,17 +7350,17 @@ var HttpSyncProvider = class {
7119
7350
  }
7120
7351
  /**
7121
7352
  * Get connection for a specific key.
7122
- * HTTP mode does not expose raw WebSocket connections.
7353
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7123
7354
  */
7124
7355
  getConnection(_key) {
7125
- throw new Error("HTTP mode does not support direct WebSocket access");
7356
+ return new HttpConnection(this);
7126
7357
  }
7127
7358
  /**
7128
7359
  * Get any available connection.
7129
- * HTTP mode does not expose raw WebSocket connections.
7360
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7130
7361
  */
7131
7362
  getAnyConnection() {
7132
- throw new Error("HTTP mode does not support direct WebSocket access");
7363
+ return new HttpConnection(this);
7133
7364
  }
7134
7365
  /**
7135
7366
  * Check if connected (last HTTP request succeeded).
@@ -7210,6 +7441,17 @@ var HttpSyncProvider = class {
7210
7441
  logger.warn({ err }, "HTTP sync provider: failed to deserialize message");
7211
7442
  }
7212
7443
  }
7444
+ /**
7445
+ * Force reconnect by restarting the polling loop.
7446
+ */
7447
+ forceReconnect() {
7448
+ this.stopPolling();
7449
+ this.connected = false;
7450
+ this.emit("disconnected", "default");
7451
+ this.connect().catch((err) => {
7452
+ logger.error({ err }, "HttpSyncProvider forceReconnect failed");
7453
+ });
7454
+ }
7213
7455
  /**
7214
7456
  * Close the HTTP sync provider.
7215
7457
  * Stops the polling loop, clears queued operations, and sets disconnected state.
@@ -7482,6 +7724,14 @@ var AutoConnectionProvider = class {
7482
7724
  }
7483
7725
  this.activeProvider.send(data, key);
7484
7726
  }
7727
+ /**
7728
+ * Force reconnect by delegating to the active provider.
7729
+ */
7730
+ forceReconnect() {
7731
+ if (this.activeProvider) {
7732
+ this.activeProvider.forceReconnect();
7733
+ }
7734
+ }
7485
7735
  /**
7486
7736
  * Close the active underlying provider.
7487
7737
  */
@@ -7538,6 +7788,7 @@ export {
7538
7788
  ClusterClient,
7539
7789
  ConflictResolverClient,
7540
7790
  ConnectionPool,
7791
+ ConnectionReadyState,
7541
7792
  DEFAULT_BACKPRESSURE_CONFIG,
7542
7793
  DEFAULT_CLUSTER_CONFIG,
7543
7794
  EncryptedStorageAdapter,
@@ -7559,6 +7810,7 @@ export {
7559
7810
  TopGunClient,
7560
7811
  TopicHandle,
7561
7812
  VALID_TRANSITIONS,
7813
+ WebSocketConnection,
7562
7814
  isValidTransition,
7563
7815
  logger
7564
7816
  };