@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.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
  /**
@@ -1015,14 +1015,17 @@ var QueryManager = class {
1015
1015
  }
1016
1016
  /**
1017
1017
  * Send query subscription message to server.
1018
+ * Includes field projection when specified in the query filter.
1018
1019
  */
1019
1020
  sendQuerySubscription(query) {
1021
+ const filter = query.getFilter();
1020
1022
  this.config.sendMessage({
1021
1023
  type: "QUERY_SUB",
1022
1024
  payload: {
1023
1025
  queryId: query.id,
1024
1026
  mapName: query.getMapName(),
1025
- query: query.getFilter()
1027
+ query: filter,
1028
+ fields: filter.fields
1026
1029
  }
1027
1030
  });
1028
1031
  }
@@ -1192,6 +1195,16 @@ var QueryManager = class {
1192
1195
  logger.debug({ queryCount: this.queries.size, hybridCount: this.hybridQueries.size }, "QueryManager: resubscribing all queries");
1193
1196
  for (const query of this.queries.values()) {
1194
1197
  this.sendQuerySubscription(query);
1198
+ const filter = query.getFilter();
1199
+ if (filter.fields && filter.fields.length > 0 && query.merkleRootHash !== 0) {
1200
+ this.config.sendMessage({
1201
+ type: "QUERY_SYNC_INIT",
1202
+ payload: {
1203
+ queryId: query.id,
1204
+ rootHash: query.merkleRootHash
1205
+ }
1206
+ });
1207
+ }
1195
1208
  }
1196
1209
  for (const query of this.hybridQueries.values()) {
1197
1210
  if (query.hasFTSPredicate()) {
@@ -1409,7 +1422,7 @@ var LockManager = class {
1409
1422
  /**
1410
1423
  * Handle lock granted message from server.
1411
1424
  */
1412
- handleLockGranted(requestId, fencingToken) {
1425
+ handleLockGranted(requestId, _name, fencingToken) {
1413
1426
  const req = this.pendingLockRequests.get(requestId);
1414
1427
  if (req) {
1415
1428
  clearTimeout(req.timer);
@@ -1420,7 +1433,7 @@ var LockManager = class {
1420
1433
  /**
1421
1434
  * Handle lock released message from server.
1422
1435
  */
1423
- handleLockReleased(requestId, success) {
1436
+ handleLockReleased(requestId, _name, success) {
1424
1437
  const req = this.pendingLockRequests.get(requestId);
1425
1438
  if (req) {
1426
1439
  clearTimeout(req.timer);
@@ -1837,6 +1850,8 @@ var import_core3 = require("@topgunbuild/core");
1837
1850
  var MerkleSyncHandler = class {
1838
1851
  constructor(config) {
1839
1852
  this.lastSyncTimestamp = 0;
1853
+ /** Accumulated sync stats per map, flushed after a quiet period */
1854
+ this.syncStats = /* @__PURE__ */ new Map();
1840
1855
  this.config = config;
1841
1856
  }
1842
1857
  /**
@@ -1858,7 +1873,8 @@ var MerkleSyncHandler = class {
1858
1873
  * Compares root hashes and requests buckets if mismatch detected.
1859
1874
  */
1860
1875
  async handleSyncRespRoot(payload) {
1861
- const { mapName, rootHash, timestamp } = payload;
1876
+ const { mapName, timestamp } = payload;
1877
+ const rootHash = Number(payload.rootHash);
1862
1878
  const map = this.config.getMap(mapName);
1863
1879
  if (map instanceof import_core3.LWWMap) {
1864
1880
  const localRootHash = map.getMerkleTree().getRootHash();
@@ -1886,9 +1902,11 @@ var MerkleSyncHandler = class {
1886
1902
  if (map instanceof import_core3.LWWMap) {
1887
1903
  const tree = map.getMerkleTree();
1888
1904
  const localBuckets = tree.getBuckets(path);
1905
+ let mismatchCount = 0;
1889
1906
  for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
1890
1907
  const localHash = localBuckets[bucketKey] || 0;
1891
1908
  if (localHash !== remoteHash) {
1909
+ mismatchCount++;
1892
1910
  const newPath = path + bucketKey;
1893
1911
  this.config.sendMessage({
1894
1912
  type: "MERKLE_REQ_BUCKET",
@@ -1915,7 +1933,17 @@ var MerkleSyncHandler = class {
1915
1933
  }
1916
1934
  }
1917
1935
  if (updateCount > 0) {
1918
- logger.info({ mapName, count: updateCount }, "Synced records from server");
1936
+ const existing = this.syncStats.get(mapName);
1937
+ if (existing) {
1938
+ existing.count += updateCount;
1939
+ clearTimeout(existing.timer);
1940
+ }
1941
+ const stats = existing ?? { count: updateCount, timer: void 0 };
1942
+ if (!existing) this.syncStats.set(mapName, stats);
1943
+ stats.timer = setTimeout(() => {
1944
+ logger.info({ mapName, count: stats.count }, "Synced records from server");
1945
+ this.syncStats.delete(mapName);
1946
+ }, 100);
1919
1947
  }
1920
1948
  }
1921
1949
  }
@@ -2198,6 +2226,8 @@ function registerClientMessageHandlers(router, delegates, managers) {
2198
2226
  },
2199
2227
  // SYNC handlers
2200
2228
  "OP_ACK": (msg) => delegates.handleOpAck(msg),
2229
+ "OP_REJECTED": (msg) => delegates.handleOpRejected(msg),
2230
+ "ERROR": (msg) => delegates.handleError(msg),
2201
2231
  "SYNC_RESP_ROOT": (msg) => managers.merkleSyncHandler.handleSyncRespRoot(msg.payload),
2202
2232
  "SYNC_RESP_BUCKETS": (msg) => managers.merkleSyncHandler.handleSyncRespBuckets(msg.payload),
2203
2233
  "SYNC_RESP_LEAF": (msg) => managers.merkleSyncHandler.handleSyncRespLeaf(msg.payload),
@@ -2220,12 +2250,12 @@ function registerClientMessageHandlers(router, delegates, managers) {
2220
2250
  },
2221
2251
  // LOCK handlers
2222
2252
  "LOCK_GRANTED": (msg) => {
2223
- const { requestId, fencingToken } = msg.payload;
2224
- managers.lockManager.handleLockGranted(requestId, fencingToken);
2253
+ const { requestId, name, fencingToken } = msg.payload;
2254
+ managers.lockManager.handleLockGranted(requestId, name, fencingToken);
2225
2255
  },
2226
2256
  "LOCK_RELEASED": (msg) => {
2227
- const { requestId, success } = msg.payload;
2228
- managers.lockManager.handleLockReleased(requestId, success);
2257
+ const { requestId, name, success } = msg.payload;
2258
+ managers.lockManager.handleLockReleased(requestId, name, success);
2229
2259
  },
2230
2260
  // GC handler
2231
2261
  "GC_PRUNE": (msg) => delegates.handleGcPrune(msg),
@@ -2263,10 +2293,7 @@ function registerClientMessageHandlers(router, delegates, managers) {
2263
2293
  managers.searchClient.handleSearchResponse(msg.payload);
2264
2294
  },
2265
2295
  "SEARCH_UPDATE": () => {
2266
- },
2267
- // HYBRID handlers
2268
- "HYBRID_QUERY_RESP": (msg) => delegates.handleHybridQueryResponse(msg.payload),
2269
- "HYBRID_QUERY_DELTA": (msg) => delegates.handleHybridQueryDelta(msg.payload)
2296
+ }
2270
2297
  });
2271
2298
  }
2272
2299
 
@@ -2401,8 +2428,8 @@ var SyncEngine = class {
2401
2428
  handleServerEvent: (msg) => this.handleServerEvent(msg),
2402
2429
  handleServerBatchEvent: (msg) => this.handleServerBatchEvent(msg),
2403
2430
  handleGcPrune: (msg) => this.handleGcPrune(msg),
2404
- handleHybridQueryResponse: (payload) => this.handleHybridQueryResponse(payload),
2405
- handleHybridQueryDelta: (payload) => this.handleHybridQueryDelta(payload)
2431
+ handleOpRejected: (msg) => this.handleOpRejected(msg),
2432
+ handleError: (msg) => this.handleError(msg)
2406
2433
  },
2407
2434
  {
2408
2435
  topicManager: this.topicManager,
@@ -2555,6 +2582,15 @@ var SyncEngine = class {
2555
2582
  const pending = this.opLog.filter((op) => !op.synced);
2556
2583
  if (pending.length === 0) return;
2557
2584
  logger.info({ count: pending.length }, "Syncing pending operations");
2585
+ const connectionProvider = this.webSocketManager.getConnectionProvider();
2586
+ if (connectionProvider.sendBatch) {
2587
+ const results = connectionProvider.sendBatch(pending.map((op) => ({ key: op.key, message: op })));
2588
+ const failedKeys = [...results.entries()].filter(([, success]) => !success).map(([key]) => key);
2589
+ if (failedKeys.length > 0) {
2590
+ logger.warn({ failedKeys, count: failedKeys.length }, "Some batch operations failed to send");
2591
+ }
2592
+ return;
2593
+ }
2558
2594
  this.sendMessage({
2559
2595
  type: "OP_BATCH",
2560
2596
  payload: {
@@ -2575,7 +2611,7 @@ var SyncEngine = class {
2575
2611
  this.authToken = token;
2576
2612
  this.tokenProvider = null;
2577
2613
  const state = this.stateMachine.getState();
2578
- if (state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "CONNECTING" /* CONNECTING */) {
2614
+ if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
2579
2615
  this.sendAuth();
2580
2616
  } else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
2581
2617
  logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
@@ -2680,9 +2716,10 @@ var SyncEngine = class {
2680
2716
  return;
2681
2717
  }
2682
2718
  await this.messageRouter.route(message);
2683
- if (message.timestamp) {
2684
- this.hlc.update(message.timestamp);
2685
- this.lastSyncTimestamp = message.timestamp.millis;
2719
+ const ts = message.timestamp;
2720
+ if (ts && typeof ts === "object" && "millis" in ts && "counter" in ts && "nodeId" in ts) {
2721
+ this.hlc.update(ts);
2722
+ this.lastSyncTimestamp = Number(ts.millis);
2686
2723
  await this.saveOpLog();
2687
2724
  }
2688
2725
  }
@@ -2736,20 +2773,37 @@ var SyncEngine = class {
2736
2773
  this.writeConcernManager.resolveWriteConcernPromise(result.opId, result);
2737
2774
  }
2738
2775
  }
2776
+ const lastIdNum = parseInt(lastId, 10);
2739
2777
  let maxSyncedId = -1;
2740
2778
  let ackedCount = 0;
2741
- this.opLog.forEach((op) => {
2742
- if (op.id && op.id <= lastId) {
2779
+ if (!isNaN(lastIdNum)) {
2780
+ this.opLog.forEach((op) => {
2781
+ if (op.id) {
2782
+ const opIdNum = parseInt(op.id, 10);
2783
+ if (!isNaN(opIdNum) && opIdNum <= lastIdNum) {
2784
+ if (!op.synced) {
2785
+ ackedCount++;
2786
+ }
2787
+ op.synced = true;
2788
+ if (opIdNum > maxSyncedId) {
2789
+ maxSyncedId = opIdNum;
2790
+ }
2791
+ }
2792
+ }
2793
+ });
2794
+ } else {
2795
+ logger.warn({ lastId }, "OP_ACK has non-numeric lastId \u2014 marking all pending ops as synced");
2796
+ this.opLog.forEach((op) => {
2743
2797
  if (!op.synced) {
2744
2798
  ackedCount++;
2799
+ op.synced = true;
2800
+ const opIdNum = parseInt(op.id, 10);
2801
+ if (!isNaN(opIdNum) && opIdNum > maxSyncedId) {
2802
+ maxSyncedId = opIdNum;
2803
+ }
2745
2804
  }
2746
- op.synced = true;
2747
- const idNum = parseInt(op.id, 10);
2748
- if (!isNaN(idNum) && idNum > maxSyncedId) {
2749
- maxSyncedId = idNum;
2750
- }
2751
- }
2752
- });
2805
+ });
2806
+ }
2753
2807
  if (maxSyncedId !== -1) {
2754
2808
  this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
2755
2809
  }
@@ -2758,18 +2812,18 @@ var SyncEngine = class {
2758
2812
  }
2759
2813
  }
2760
2814
  handleQueryResp(message) {
2761
- const { queryId, results, nextCursor, hasMore, cursorStatus } = message.payload;
2815
+ const { queryId, results, nextCursor, hasMore, cursorStatus, merkleRootHash } = message.payload;
2762
2816
  const query = this.queryManager.getQueries().get(queryId);
2763
2817
  if (query) {
2764
- query.onResult(results, "server");
2818
+ query.onResult(results, "server", merkleRootHash);
2765
2819
  query.updatePaginationInfo({ nextCursor, hasMore, cursorStatus });
2766
2820
  }
2767
2821
  }
2768
2822
  handleQueryUpdate(message) {
2769
- const { queryId, key, value, type } = message.payload;
2823
+ const { queryId, key, value, changeType } = message.payload;
2770
2824
  const query = this.queryManager.getQueries().get(queryId);
2771
2825
  if (query) {
2772
- query.onUpdate(key, type === "REMOVE" ? null : value);
2826
+ query.onUpdate(key, changeType === "LEAVE" ? null : value);
2773
2827
  }
2774
2828
  }
2775
2829
  async handleServerEvent(message) {
@@ -3174,31 +3228,24 @@ var SyncEngine = class {
3174
3228
  return this.queryManager.runLocalHybridQuery(mapName, filter);
3175
3229
  }
3176
3230
  /**
3177
- * Handle hybrid query response from server.
3231
+ * Handle operation rejected by server (permission denied, validation failure, etc.).
3178
3232
  */
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
- }
3233
+ handleOpRejected(message) {
3234
+ const { opId, reason, code } = message.payload;
3235
+ logger.warn({ opId, reason, code }, "Operation rejected by server");
3236
+ this.writeConcernManager.resolveWriteConcernPromise(opId, {
3237
+ opId,
3238
+ success: false,
3239
+ achievedLevel: "FIRE_AND_FORGET",
3240
+ error: reason
3241
+ });
3189
3242
  }
3190
3243
  /**
3191
- * Handle hybrid query delta update from server.
3244
+ * Handle generic error message from server.
3192
3245
  */
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
- }
3246
+ handleError(message) {
3247
+ const { code, message: errorMessage, details } = message.payload;
3248
+ logger.error({ code, message: errorMessage, details }, "Server error received");
3202
3249
  }
3203
3250
  };
3204
3251
 
@@ -3305,12 +3352,15 @@ var QueryHandle = class {
3305
3352
  // Pagination info
3306
3353
  this._paginationInfo = { hasMore: false, cursorStatus: "none" };
3307
3354
  this.paginationListeners = /* @__PURE__ */ new Set();
3355
+ /** Merkle root hash from last server QUERY_RESP — used for delta reconnect */
3356
+ this.merkleRootHash = 0;
3308
3357
  // Track if we've received authoritative server response
3309
3358
  this.hasReceivedServerData = false;
3310
3359
  this.id = crypto.randomUUID();
3311
3360
  this.syncEngine = syncEngine;
3312
3361
  this.mapName = mapName;
3313
3362
  this.filter = filter;
3363
+ this.fields = filter.fields;
3314
3364
  }
3315
3365
  subscribe(callback) {
3316
3366
  this.listeners.add(callback);
@@ -3346,7 +3396,7 @@ var QueryHandle = class {
3346
3396
  * - This prevents clearing local data when server hasn't loaded from storage yet
3347
3397
  * - Works with any async storage adapter (PostgreSQL, SQLite, Redis, etc.)
3348
3398
  */
3349
- onResult(items, source = "server") {
3399
+ onResult(items, source = "server", merkleRootHash) {
3350
3400
  logger.debug({
3351
3401
  mapName: this.mapName,
3352
3402
  itemCount: items.length,
@@ -3361,6 +3411,9 @@ var QueryHandle = class {
3361
3411
  if (source === "server" && items.length > 0) {
3362
3412
  this.hasReceivedServerData = true;
3363
3413
  }
3414
+ if (merkleRootHash !== void 0) {
3415
+ this.merkleRootHash = merkleRootHash;
3416
+ }
3364
3417
  const newKeys = new Set(items.map((i) => i.key));
3365
3418
  const removedKeys = [];
3366
3419
  for (const key of this.currentResults.keys()) {
@@ -4082,8 +4135,8 @@ var SearchHandle = class {
4082
4135
  handleSearchUpdate(message) {
4083
4136
  if (message.type !== "SEARCH_UPDATE") return;
4084
4137
  if (message.payload?.subscriptionId !== this.subscriptionId) return;
4085
- const { key, value, score, matchedTerms, type } = message.payload;
4086
- switch (type) {
4138
+ const { key, value, score, matchedTerms, changeType } = message.payload;
4139
+ switch (changeType) {
4087
4140
  case "ENTER":
4088
4141
  this.results.set(key, {
4089
4142
  key,
@@ -4400,6 +4453,30 @@ var import_core10 = require("@topgunbuild/core");
4400
4453
  // src/cluster/ConnectionPool.ts
4401
4454
  var import_core7 = require("@topgunbuild/core");
4402
4455
  var import_core8 = require("@topgunbuild/core");
4456
+
4457
+ // src/connection/WebSocketConnection.ts
4458
+ var ConnectionReadyState = {
4459
+ CONNECTING: 0,
4460
+ OPEN: 1,
4461
+ CLOSING: 2,
4462
+ CLOSED: 3
4463
+ };
4464
+ var WebSocketConnection = class {
4465
+ constructor(ws) {
4466
+ this.ws = ws;
4467
+ }
4468
+ send(data) {
4469
+ this.ws.send(data);
4470
+ }
4471
+ close() {
4472
+ this.ws.close();
4473
+ }
4474
+ get readyState() {
4475
+ return this.ws.readyState;
4476
+ }
4477
+ };
4478
+
4479
+ // src/cluster/ConnectionPool.ts
4403
4480
  var ConnectionPool = class {
4404
4481
  constructor(config = {}) {
4405
4482
  this.listeners = /* @__PURE__ */ new Map();
@@ -4471,10 +4548,17 @@ var ConnectionPool = class {
4471
4548
  return;
4472
4549
  }
4473
4550
  }
4551
+ for (const [existingId, existingConn] of this.connections) {
4552
+ if (existingConn.endpoint === endpoint && existingId !== nodeId) {
4553
+ this.remapNodeId(existingId, nodeId);
4554
+ return;
4555
+ }
4556
+ }
4474
4557
  const connection = {
4475
4558
  nodeId,
4476
4559
  endpoint,
4477
4560
  socket: null,
4561
+ cachedConnection: null,
4478
4562
  state: "DISCONNECTED",
4479
4563
  lastSeen: 0,
4480
4564
  latencyMs: 0,
@@ -4509,15 +4593,34 @@ var ConnectionPool = class {
4509
4593
  }
4510
4594
  logger.info({ nodeId }, "Node removed from connection pool");
4511
4595
  }
4596
+ /**
4597
+ * Remap a node from one ID to another, preserving the existing connection.
4598
+ * Used when the server-assigned node ID differs from the temporary seed ID.
4599
+ */
4600
+ remapNodeId(oldId, newId) {
4601
+ const connection = this.connections.get(oldId);
4602
+ if (!connection) return;
4603
+ connection.nodeId = newId;
4604
+ this.connections.delete(oldId);
4605
+ this.connections.set(newId, connection);
4606
+ if (this.primaryNodeId === oldId) {
4607
+ this.primaryNodeId = newId;
4608
+ }
4609
+ logger.info({ oldId, newId }, "Node ID remapped");
4610
+ this.emit("node:remapped", oldId, newId);
4611
+ }
4512
4612
  /**
4513
4613
  * Get connection for a specific node
4514
4614
  */
4515
4615
  getConnection(nodeId) {
4516
4616
  const connection = this.connections.get(nodeId);
4517
- if (!connection || connection.state !== "AUTHENTICATED") {
4617
+ if (!connection || connection.state !== "CONNECTED" && connection.state !== "AUTHENTICATED" || !connection.socket) {
4518
4618
  return null;
4519
4619
  }
4520
- return connection.socket;
4620
+ if (!connection.cachedConnection) {
4621
+ connection.cachedConnection = new WebSocketConnection(connection.socket);
4622
+ }
4623
+ return connection.cachedConnection;
4521
4624
  }
4522
4625
  /**
4523
4626
  * Get primary connection (first/seed node)
@@ -4531,8 +4634,11 @@ var ConnectionPool = class {
4531
4634
  */
4532
4635
  getAnyHealthyConnection() {
4533
4636
  for (const [nodeId, conn] of this.connections) {
4534
- if (conn.state === "AUTHENTICATED" && conn.socket) {
4535
- return { nodeId, socket: conn.socket };
4637
+ if ((conn.state === "CONNECTED" || conn.state === "AUTHENTICATED") && conn.socket) {
4638
+ if (!conn.cachedConnection) {
4639
+ conn.cachedConnection = new WebSocketConnection(conn.socket);
4640
+ }
4641
+ return { nodeId, connection: conn.cachedConnection };
4536
4642
  }
4537
4643
  }
4538
4644
  return null;
@@ -4588,7 +4694,7 @@ var ConnectionPool = class {
4588
4694
  * Get list of connected node IDs
4589
4695
  */
4590
4696
  getConnectedNodes() {
4591
- return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
4697
+ return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "CONNECTED" || conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
4592
4698
  }
4593
4699
  /**
4594
4700
  * Get all node IDs
@@ -4597,11 +4703,11 @@ var ConnectionPool = class {
4597
4703
  return Array.from(this.connections.keys());
4598
4704
  }
4599
4705
  /**
4600
- * Check if node is connected and authenticated
4706
+ * Check if node has an open WebSocket connection
4601
4707
  */
4602
4708
  isNodeConnected(nodeId) {
4603
4709
  const conn = this.connections.get(nodeId);
4604
- return conn?.state === "AUTHENTICATED";
4710
+ return conn?.state === "CONNECTED" || conn?.state === "AUTHENTICATED";
4605
4711
  }
4606
4712
  /**
4607
4713
  * Check if connected to a specific node.
@@ -4666,25 +4772,26 @@ var ConnectionPool = class {
4666
4772
  };
4667
4773
  socket.onmessage = (event) => {
4668
4774
  connection.lastSeen = Date.now();
4669
- this.handleMessage(nodeId, event);
4775
+ this.handleMessage(connection.nodeId, event);
4670
4776
  };
4671
4777
  socket.onerror = (error) => {
4672
- logger.error({ nodeId, error }, "WebSocket error");
4673
- this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
4778
+ logger.error({ nodeId: connection.nodeId, error }, "WebSocket error");
4779
+ this.emit("error", connection.nodeId, error instanceof Error ? error : new Error("WebSocket error"));
4674
4780
  };
4675
4781
  socket.onclose = () => {
4676
4782
  const wasConnected = connection.state === "AUTHENTICATED";
4677
4783
  connection.state = "DISCONNECTED";
4678
4784
  connection.socket = null;
4785
+ connection.cachedConnection = null;
4679
4786
  if (wasConnected) {
4680
- this.emit("node:disconnected", nodeId, "Connection closed");
4787
+ this.emit("node:disconnected", connection.nodeId, "Connection closed");
4681
4788
  }
4682
- this.scheduleReconnect(nodeId);
4789
+ this.scheduleReconnect(connection.nodeId);
4683
4790
  };
4684
4791
  } catch (error) {
4685
4792
  connection.state = "FAILED";
4686
- logger.error({ nodeId, error }, "Failed to connect");
4687
- this.scheduleReconnect(nodeId);
4793
+ logger.error({ nodeId: connection.nodeId, error }, "Failed to connect");
4794
+ this.scheduleReconnect(connection.nodeId);
4688
4795
  }
4689
4796
  }
4690
4797
  sendAuth(connection) {
@@ -4713,18 +4820,15 @@ var ConnectionPool = class {
4713
4820
  logger.info({ nodeId }, "Authenticated with node");
4714
4821
  this.emit("node:healthy", nodeId);
4715
4822
  this.flushPendingMessages(connection);
4716
- return;
4717
4823
  }
4718
4824
  if (message.type === "AUTH_REQUIRED") {
4719
4825
  if (this.authToken) {
4720
4826
  this.sendAuth(connection);
4721
4827
  }
4722
- return;
4723
4828
  }
4724
4829
  if (message.type === "AUTH_FAIL") {
4725
4830
  logger.error({ nodeId, error: message.error }, "Authentication failed");
4726
4831
  connection.state = "FAILED";
4727
- return;
4728
4832
  }
4729
4833
  if (message.type === "PONG") {
4730
4834
  if (message.timestamp) {
@@ -4732,10 +4836,6 @@ var ConnectionPool = class {
4732
4836
  }
4733
4837
  return;
4734
4838
  }
4735
- if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
4736
- this.emit("message", nodeId, message);
4737
- return;
4738
- }
4739
4839
  this.emit("message", nodeId, message);
4740
4840
  }
4741
4841
  flushPendingMessages(connection) {
@@ -4902,17 +5002,17 @@ var PartitionRouter = class {
4902
5002
  }
4903
5003
  return null;
4904
5004
  }
4905
- const socket = this.connectionPool.getConnection(routing.nodeId);
4906
- if (socket) {
4907
- return { nodeId: routing.nodeId, socket };
5005
+ const connection = this.connectionPool.getConnection(routing.nodeId);
5006
+ if (connection) {
5007
+ return { nodeId: routing.nodeId, connection };
4908
5008
  }
4909
5009
  const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
4910
5010
  if (partition) {
4911
5011
  for (const backupId of partition.backupNodeIds) {
4912
- const backupSocket = this.connectionPool.getConnection(backupId);
4913
- if (backupSocket) {
5012
+ const backupConnection = this.connectionPool.getConnection(backupId);
5013
+ if (backupConnection) {
4914
5014
  logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
4915
- return { nodeId: backupId, socket: backupSocket };
5015
+ return { nodeId: backupId, connection: backupConnection };
4916
5016
  }
4917
5017
  }
4918
5018
  }
@@ -5188,7 +5288,7 @@ var PartitionRouter = class {
5188
5288
  };
5189
5289
 
5190
5290
  // src/cluster/ClusterClient.ts
5191
- var ClusterClient = class {
5291
+ var _ClusterClient = class _ClusterClient {
5192
5292
  constructor(config) {
5193
5293
  this.listeners = /* @__PURE__ */ new Map();
5194
5294
  this.initialized = false;
@@ -5201,6 +5301,8 @@ var ClusterClient = class {
5201
5301
  };
5202
5302
  // Circuit breaker state per node
5203
5303
  this.circuits = /* @__PURE__ */ new Map();
5304
+ // Debounce timer for partition map requests on reconnect
5305
+ this.partitionMapRequestTimer = null;
5204
5306
  this.config = config;
5205
5307
  this.circuitBreakerConfig = {
5206
5308
  ...import_core10.DEFAULT_CIRCUIT_BREAKER_CONFIG,
@@ -5292,14 +5394,14 @@ var ClusterClient = class {
5292
5394
  this.requestPartitionMapRefresh();
5293
5395
  return this.getFallbackConnection();
5294
5396
  }
5295
- const socket = this.connectionPool.getConnection(owner);
5296
- if (!socket) {
5397
+ const connection = this.connectionPool.getConnection(owner);
5398
+ if (!connection) {
5297
5399
  this.routingMetrics.fallbackRoutes++;
5298
5400
  logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
5299
5401
  return this.getFallbackConnection();
5300
5402
  }
5301
5403
  this.routingMetrics.directRoutes++;
5302
- return socket;
5404
+ return connection;
5303
5405
  }
5304
5406
  /**
5305
5407
  * Get fallback connection when owner is unavailable.
@@ -5307,10 +5409,10 @@ var ClusterClient = class {
5307
5409
  */
5308
5410
  getFallbackConnection() {
5309
5411
  const conn = this.connectionPool.getAnyHealthyConnection();
5310
- if (!conn?.socket) {
5412
+ if (!conn?.connection) {
5311
5413
  throw new Error("No healthy connection available");
5312
5414
  }
5313
- return conn.socket;
5415
+ return conn.connection;
5314
5416
  }
5315
5417
  /**
5316
5418
  * Request a partition map refresh in the background.
@@ -5321,9 +5423,23 @@ var ClusterClient = class {
5321
5423
  logger.error({ err }, "Failed to refresh partition map");
5322
5424
  });
5323
5425
  }
5426
+ /**
5427
+ * Debounce partition map requests to prevent flooding when multiple nodes
5428
+ * reconnect simultaneously. Coalesces rapid requests into a single request
5429
+ * sent to the most recently connected node.
5430
+ */
5431
+ debouncedPartitionMapRequest(nodeId) {
5432
+ if (this.partitionMapRequestTimer) {
5433
+ clearTimeout(this.partitionMapRequestTimer);
5434
+ }
5435
+ this.partitionMapRequestTimer = setTimeout(() => {
5436
+ this.partitionMapRequestTimer = null;
5437
+ this.requestPartitionMapFromNode(nodeId);
5438
+ }, _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS);
5439
+ }
5324
5440
  /**
5325
5441
  * Request partition map from a specific node.
5326
- * Called on first node connection.
5442
+ * Called on node connection via debounced handler.
5327
5443
  */
5328
5444
  requestPartitionMapFromNode(nodeId) {
5329
5445
  const socket = this.connectionPool.getConnection(nodeId);
@@ -5492,8 +5608,8 @@ var ClusterClient = class {
5492
5608
  * Send directly to partition owner
5493
5609
  */
5494
5610
  sendDirect(key, message) {
5495
- const connection = this.partitionRouter.routeToConnection(key);
5496
- if (!connection) {
5611
+ const route = this.partitionRouter.routeToConnection(key);
5612
+ if (!route) {
5497
5613
  logger.warn({ key }, "No route available for key");
5498
5614
  return false;
5499
5615
  }
@@ -5504,7 +5620,7 @@ var ClusterClient = class {
5504
5620
  mapVersion: this.partitionRouter.getMapVersion()
5505
5621
  }
5506
5622
  };
5507
- connection.socket.send((0, import_core10.serialize)(routedMessage));
5623
+ route.connection.send((0, import_core10.serialize)(routedMessage));
5508
5624
  return true;
5509
5625
  }
5510
5626
  /**
@@ -5608,10 +5724,23 @@ var ClusterClient = class {
5608
5724
  async refreshPartitionMap() {
5609
5725
  await this.partitionRouter.refreshPartitionMap();
5610
5726
  }
5727
+ /**
5728
+ * Force reconnect all connections in the pool.
5729
+ */
5730
+ forceReconnect() {
5731
+ this.connectionPool.close();
5732
+ this.connect().catch((err) => {
5733
+ logger.error({ err }, "ClusterClient forceReconnect failed");
5734
+ });
5735
+ }
5611
5736
  /**
5612
5737
  * Shutdown cluster client (IConnectionProvider interface).
5613
5738
  */
5614
5739
  async close() {
5740
+ if (this.partitionMapRequestTimer) {
5741
+ clearTimeout(this.partitionMapRequestTimer);
5742
+ this.partitionMapRequestTimer = null;
5743
+ }
5615
5744
  this.partitionRouter.close();
5616
5745
  this.connectionPool.close();
5617
5746
  this.initialized = false;
@@ -5634,23 +5763,23 @@ var ClusterClient = class {
5634
5763
  return this.partitionRouter;
5635
5764
  }
5636
5765
  /**
5637
- * Get any healthy WebSocket connection (IConnectionProvider interface).
5766
+ * Get any healthy connection (IConnectionProvider interface).
5638
5767
  * @throws Error if not connected
5639
5768
  */
5640
5769
  getAnyConnection() {
5641
5770
  const conn = this.connectionPool.getAnyHealthyConnection();
5642
- if (!conn?.socket) {
5771
+ if (!conn?.connection) {
5643
5772
  throw new Error("No healthy connection available");
5644
5773
  }
5645
- return conn.socket;
5774
+ return conn.connection;
5646
5775
  }
5647
5776
  /**
5648
- * Get any healthy WebSocket connection, or null if none available.
5777
+ * Get any healthy connection, or null if none available.
5649
5778
  * Use this for optional connection checks.
5650
5779
  */
5651
5780
  getAnyConnectionOrNull() {
5652
5781
  const conn = this.connectionPool.getAnyHealthyConnection();
5653
- return conn?.socket ?? null;
5782
+ return conn?.connection ?? null;
5654
5783
  }
5655
5784
  // ============================================
5656
5785
  // Circuit Breaker Methods
@@ -5741,9 +5870,7 @@ var ClusterClient = class {
5741
5870
  setupEventHandlers() {
5742
5871
  this.connectionPool.on("node:connected", (nodeId) => {
5743
5872
  logger.debug({ nodeId }, "Node connected");
5744
- if (this.partitionRouter.getMapVersion() === 0) {
5745
- this.requestPartitionMapFromNode(nodeId);
5746
- }
5873
+ this.debouncedPartitionMapRequest(nodeId);
5747
5874
  if (this.connectionPool.getConnectedNodes().length === 1) {
5748
5875
  this.emit("connected");
5749
5876
  }
@@ -5798,6 +5925,8 @@ var ClusterClient = class {
5798
5925
  });
5799
5926
  }
5800
5927
  };
5928
+ _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS = 500;
5929
+ var ClusterClient = _ClusterClient;
5801
5930
 
5802
5931
  // src/connection/SingleServerProvider.ts
5803
5932
  var DEFAULT_CONFIG = {
@@ -5813,14 +5942,18 @@ var SingleServerProvider = class {
5813
5942
  this.reconnectTimer = null;
5814
5943
  this.isClosing = false;
5815
5944
  this.listeners = /* @__PURE__ */ new Map();
5945
+ this.onlineHandler = null;
5946
+ this.offlineHandler = null;
5816
5947
  this.url = config.url;
5817
5948
  this.config = {
5818
5949
  url: config.url,
5819
5950
  maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
5820
5951
  reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
5821
5952
  backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
5822
- maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
5953
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs,
5954
+ listenNetworkEvents: config.listenNetworkEvents ?? true
5823
5955
  };
5956
+ this.setupNetworkListeners();
5824
5957
  }
5825
5958
  /**
5826
5959
  * Connect to the WebSocket server.
@@ -5829,6 +5962,9 @@ var SingleServerProvider = class {
5829
5962
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
5830
5963
  return;
5831
5964
  }
5965
+ if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false) {
5966
+ throw new Error("Browser is offline \u2014 skipping connection attempt");
5967
+ }
5832
5968
  this.isClosing = false;
5833
5969
  return new Promise((resolve, reject) => {
5834
5970
  try {
@@ -5881,7 +6017,7 @@ var SingleServerProvider = class {
5881
6017
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
5882
6018
  throw new Error("Not connected");
5883
6019
  }
5884
- return this.ws;
6020
+ return new WebSocketConnection(this.ws);
5885
6021
  }
5886
6022
  /**
5887
6023
  * Get any available connection.
@@ -5932,6 +6068,7 @@ var SingleServerProvider = class {
5932
6068
  */
5933
6069
  async close() {
5934
6070
  this.isClosing = true;
6071
+ this.teardownNetworkListeners();
5935
6072
  if (this.reconnectTimer) {
5936
6073
  clearTimeout(this.reconnectTimer);
5937
6074
  this.reconnectTimer = null;
@@ -5970,6 +6107,10 @@ var SingleServerProvider = class {
5970
6107
  clearTimeout(this.reconnectTimer);
5971
6108
  this.reconnectTimer = null;
5972
6109
  }
6110
+ if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false && this.config.listenNetworkEvents) {
6111
+ logger.info({ url: this.url }, "Browser offline \u2014 waiting for online event instead of polling");
6112
+ return;
6113
+ }
5973
6114
  if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
5974
6115
  logger.error(
5975
6116
  { attempts: this.reconnectAttempts, url: this.url },
@@ -6005,6 +6146,37 @@ var SingleServerProvider = class {
6005
6146
  delay = delay * (0.5 + Math.random());
6006
6147
  return Math.floor(delay);
6007
6148
  }
6149
+ /**
6150
+ * Force-close the current WebSocket and immediately schedule reconnection.
6151
+ * Unlike close(), this does NOT set isClosing and preserves reconnect behavior.
6152
+ * Resets the reconnect counter so the full backoff budget is available.
6153
+ *
6154
+ * Critically, this does NOT wait for the TCP close handshake (which can
6155
+ * hang 20+ seconds on a dead network). Instead it strips all handlers from
6156
+ * the old WebSocket, fires a best-effort close(), nulls the reference, and
6157
+ * schedules reconnect right away.
6158
+ */
6159
+ forceReconnect() {
6160
+ this.reconnectAttempts = 0;
6161
+ this.isClosing = false;
6162
+ if (this.reconnectTimer) {
6163
+ clearTimeout(this.reconnectTimer);
6164
+ this.reconnectTimer = null;
6165
+ }
6166
+ if (this.ws) {
6167
+ this.ws.onopen = null;
6168
+ this.ws.onclose = null;
6169
+ this.ws.onerror = null;
6170
+ this.ws.onmessage = null;
6171
+ try {
6172
+ this.ws.close();
6173
+ } catch {
6174
+ }
6175
+ this.ws = null;
6176
+ }
6177
+ this.emit("disconnected", "default");
6178
+ this.scheduleReconnect();
6179
+ }
6008
6180
  /**
6009
6181
  * Get the WebSocket URL this provider connects to.
6010
6182
  */
@@ -6024,6 +6196,43 @@ var SingleServerProvider = class {
6024
6196
  resetReconnectAttempts() {
6025
6197
  this.reconnectAttempts = 0;
6026
6198
  }
6199
+ /**
6200
+ * Listen for browser 'online' event to trigger instant reconnect
6201
+ * when network comes back. Only active in browser environments.
6202
+ */
6203
+ setupNetworkListeners() {
6204
+ if (!this.config.listenNetworkEvents) return;
6205
+ if (typeof globalThis.addEventListener !== "function") return;
6206
+ this.onlineHandler = () => {
6207
+ if (this.isClosing) return;
6208
+ if (this.isConnected()) return;
6209
+ logger.info({ url: this.url }, "Network online detected \u2014 forcing reconnect");
6210
+ this.forceReconnect();
6211
+ };
6212
+ this.offlineHandler = () => {
6213
+ if (this.isClosing) return;
6214
+ if (!this.isConnected()) return;
6215
+ logger.info({ url: this.url }, "Network offline detected \u2014 disconnecting immediately");
6216
+ this.forceReconnect();
6217
+ };
6218
+ globalThis.addEventListener("online", this.onlineHandler);
6219
+ globalThis.addEventListener("offline", this.offlineHandler);
6220
+ }
6221
+ /**
6222
+ * Remove browser network event listeners.
6223
+ */
6224
+ teardownNetworkListeners() {
6225
+ if (typeof globalThis.removeEventListener === "function") {
6226
+ if (this.onlineHandler) {
6227
+ globalThis.removeEventListener("online", this.onlineHandler);
6228
+ this.onlineHandler = null;
6229
+ }
6230
+ if (this.offlineHandler) {
6231
+ globalThis.removeEventListener("offline", this.offlineHandler);
6232
+ this.offlineHandler = null;
6233
+ }
6234
+ }
6235
+ }
6027
6236
  };
6028
6237
 
6029
6238
  // src/TopGunClient.ts
@@ -6081,7 +6290,13 @@ var TopGunClient = class {
6081
6290
  });
6082
6291
  logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
6083
6292
  } else {
6084
- const singleServerProvider = new SingleServerProvider({ url: config.serverUrl });
6293
+ const singleServerProvider = new SingleServerProvider({
6294
+ url: config.serverUrl,
6295
+ maxReconnectAttempts: config.backoff?.maxRetries,
6296
+ reconnectDelayMs: config.backoff?.initialDelayMs,
6297
+ backoffMultiplier: config.backoff?.multiplier,
6298
+ maxReconnectDelayMs: config.backoff?.maxDelayMs
6299
+ });
6085
6300
  this.syncEngine = new SyncEngine({
6086
6301
  nodeId: this.nodeId,
6087
6302
  connectionProvider: singleServerProvider,
@@ -7131,6 +7346,24 @@ var import_core14 = require("@topgunbuild/core");
7131
7346
 
7132
7347
  // src/connection/HttpSyncProvider.ts
7133
7348
  var import_core13 = require("@topgunbuild/core");
7349
+ var HttpConnection = class {
7350
+ constructor(provider) {
7351
+ this.provider = provider;
7352
+ }
7353
+ send(data) {
7354
+ if (typeof data === "string") {
7355
+ const encoder = new TextEncoder();
7356
+ this.provider.send(encoder.encode(data));
7357
+ } else {
7358
+ this.provider.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
7359
+ }
7360
+ }
7361
+ close() {
7362
+ }
7363
+ get readyState() {
7364
+ return this.provider.isConnected() ? ConnectionReadyState.OPEN : ConnectionReadyState.CLOSED;
7365
+ }
7366
+ };
7134
7367
  var HttpSyncProvider = class {
7135
7368
  constructor(config) {
7136
7369
  this.listeners = /* @__PURE__ */ new Map();
@@ -7172,17 +7405,17 @@ var HttpSyncProvider = class {
7172
7405
  }
7173
7406
  /**
7174
7407
  * Get connection for a specific key.
7175
- * HTTP mode does not expose raw WebSocket connections.
7408
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7176
7409
  */
7177
7410
  getConnection(_key) {
7178
- throw new Error("HTTP mode does not support direct WebSocket access");
7411
+ return new HttpConnection(this);
7179
7412
  }
7180
7413
  /**
7181
7414
  * Get any available connection.
7182
- * HTTP mode does not expose raw WebSocket connections.
7415
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7183
7416
  */
7184
7417
  getAnyConnection() {
7185
- throw new Error("HTTP mode does not support direct WebSocket access");
7418
+ return new HttpConnection(this);
7186
7419
  }
7187
7420
  /**
7188
7421
  * Check if connected (last HTTP request succeeded).
@@ -7263,6 +7496,17 @@ var HttpSyncProvider = class {
7263
7496
  logger.warn({ err }, "HTTP sync provider: failed to deserialize message");
7264
7497
  }
7265
7498
  }
7499
+ /**
7500
+ * Force reconnect by restarting the polling loop.
7501
+ */
7502
+ forceReconnect() {
7503
+ this.stopPolling();
7504
+ this.connected = false;
7505
+ this.emit("disconnected", "default");
7506
+ this.connect().catch((err) => {
7507
+ logger.error({ err }, "HttpSyncProvider forceReconnect failed");
7508
+ });
7509
+ }
7266
7510
  /**
7267
7511
  * Close the HTTP sync provider.
7268
7512
  * Stops the polling loop, clears queued operations, and sets disconnected state.
@@ -7535,6 +7779,14 @@ var AutoConnectionProvider = class {
7535
7779
  }
7536
7780
  this.activeProvider.send(data, key);
7537
7781
  }
7782
+ /**
7783
+ * Force reconnect by delegating to the active provider.
7784
+ */
7785
+ forceReconnect() {
7786
+ if (this.activeProvider) {
7787
+ this.activeProvider.forceReconnect();
7788
+ }
7789
+ }
7538
7790
  /**
7539
7791
  * Close the active underlying provider.
7540
7792
  */
@@ -7592,6 +7844,7 @@ var AutoConnectionProvider = class {
7592
7844
  ClusterClient,
7593
7845
  ConflictResolverClient,
7594
7846
  ConnectionPool,
7847
+ ConnectionReadyState,
7595
7848
  DEFAULT_BACKPRESSURE_CONFIG,
7596
7849
  DEFAULT_CLUSTER_CONFIG,
7597
7850
  EncryptedStorageAdapter,
@@ -7613,6 +7866,7 @@ var AutoConnectionProvider = class {
7613
7866
  TopGunClient,
7614
7867
  TopicHandle,
7615
7868
  VALID_TRANSITIONS,
7869
+ WebSocketConnection,
7616
7870
  isValidTransition,
7617
7871
  logger
7618
7872
  });