@topgunbuild/client 0.10.1 → 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
@@ -30,21 +30,24 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ AutoConnectionProvider: () => AutoConnectionProvider,
33
34
  BackpressureError: () => BackpressureError,
34
35
  ChangeTracker: () => ChangeTracker,
35
36
  ClusterClient: () => ClusterClient,
36
37
  ConflictResolverClient: () => ConflictResolverClient,
37
38
  ConnectionPool: () => ConnectionPool,
39
+ ConnectionReadyState: () => ConnectionReadyState,
38
40
  DEFAULT_BACKPRESSURE_CONFIG: () => DEFAULT_BACKPRESSURE_CONFIG,
39
41
  DEFAULT_CLUSTER_CONFIG: () => DEFAULT_CLUSTER_CONFIG,
40
42
  EncryptedStorageAdapter: () => EncryptedStorageAdapter,
41
43
  EventJournalReader: () => EventJournalReader,
44
+ HttpSyncProvider: () => HttpSyncProvider,
42
45
  HybridQueryHandle: () => HybridQueryHandle,
43
46
  IDBAdapter: () => IDBAdapter,
44
- LWWMap: () => import_core13.LWWMap,
47
+ LWWMap: () => import_core14.LWWMap,
45
48
  PNCounterHandle: () => PNCounterHandle,
46
49
  PartitionRouter: () => PartitionRouter,
47
- Predicates: () => import_core13.Predicates,
50
+ Predicates: () => import_core14.Predicates,
48
51
  QueryHandle: () => QueryHandle,
49
52
  SearchHandle: () => SearchHandle,
50
53
  SingleServerProvider: () => SingleServerProvider,
@@ -55,6 +58,7 @@ __export(index_exports, {
55
58
  TopGunClient: () => TopGunClient,
56
59
  TopicHandle: () => TopicHandle,
57
60
  VALID_TRANSITIONS: () => VALID_TRANSITIONS,
61
+ WebSocketConnection: () => WebSocketConnection,
58
62
  isValidTransition: () => isValidTransition,
59
63
  logger: () => logger
60
64
  });
@@ -718,9 +722,7 @@ var WebSocketManager = class {
718
722
  timeoutMs: this.config.heartbeatConfig.timeoutMs
719
723
  }, "Heartbeat timeout - triggering reconnection");
720
724
  this.stopHeartbeat();
721
- this.connectionProvider.close().catch((err) => {
722
- logger.error({ err }, "Error closing ConnectionProvider on heartbeat timeout");
723
- });
725
+ this.connectionProvider.forceReconnect();
724
726
  }
725
727
  }
726
728
  /**
@@ -1407,7 +1409,7 @@ var LockManager = class {
1407
1409
  /**
1408
1410
  * Handle lock granted message from server.
1409
1411
  */
1410
- handleLockGranted(requestId, fencingToken) {
1412
+ handleLockGranted(requestId, _name, fencingToken) {
1411
1413
  const req = this.pendingLockRequests.get(requestId);
1412
1414
  if (req) {
1413
1415
  clearTimeout(req.timer);
@@ -1418,7 +1420,7 @@ var LockManager = class {
1418
1420
  /**
1419
1421
  * Handle lock released message from server.
1420
1422
  */
1421
- handleLockReleased(requestId, success) {
1423
+ handleLockReleased(requestId, _name, success) {
1422
1424
  const req = this.pendingLockRequests.get(requestId);
1423
1425
  if (req) {
1424
1426
  clearTimeout(req.timer);
@@ -1835,6 +1837,8 @@ var import_core3 = require("@topgunbuild/core");
1835
1837
  var MerkleSyncHandler = class {
1836
1838
  constructor(config) {
1837
1839
  this.lastSyncTimestamp = 0;
1840
+ /** Accumulated sync stats per map, flushed after a quiet period */
1841
+ this.syncStats = /* @__PURE__ */ new Map();
1838
1842
  this.config = config;
1839
1843
  }
1840
1844
  /**
@@ -1856,7 +1860,8 @@ var MerkleSyncHandler = class {
1856
1860
  * Compares root hashes and requests buckets if mismatch detected.
1857
1861
  */
1858
1862
  async handleSyncRespRoot(payload) {
1859
- const { mapName, rootHash, timestamp } = payload;
1863
+ const { mapName, timestamp } = payload;
1864
+ const rootHash = Number(payload.rootHash);
1860
1865
  const map = this.config.getMap(mapName);
1861
1866
  if (map instanceof import_core3.LWWMap) {
1862
1867
  const localRootHash = map.getMerkleTree().getRootHash();
@@ -1884,9 +1889,11 @@ var MerkleSyncHandler = class {
1884
1889
  if (map instanceof import_core3.LWWMap) {
1885
1890
  const tree = map.getMerkleTree();
1886
1891
  const localBuckets = tree.getBuckets(path);
1892
+ let mismatchCount = 0;
1887
1893
  for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
1888
1894
  const localHash = localBuckets[bucketKey] || 0;
1889
1895
  if (localHash !== remoteHash) {
1896
+ mismatchCount++;
1890
1897
  const newPath = path + bucketKey;
1891
1898
  this.config.sendMessage({
1892
1899
  type: "MERKLE_REQ_BUCKET",
@@ -1913,7 +1920,17 @@ var MerkleSyncHandler = class {
1913
1920
  }
1914
1921
  }
1915
1922
  if (updateCount > 0) {
1916
- 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);
1917
1934
  }
1918
1935
  }
1919
1936
  }
@@ -2196,6 +2213,8 @@ function registerClientMessageHandlers(router, delegates, managers) {
2196
2213
  },
2197
2214
  // SYNC handlers
2198
2215
  "OP_ACK": (msg) => delegates.handleOpAck(msg),
2216
+ "OP_REJECTED": (msg) => delegates.handleOpRejected(msg),
2217
+ "ERROR": (msg) => delegates.handleError(msg),
2199
2218
  "SYNC_RESP_ROOT": (msg) => managers.merkleSyncHandler.handleSyncRespRoot(msg.payload),
2200
2219
  "SYNC_RESP_BUCKETS": (msg) => managers.merkleSyncHandler.handleSyncRespBuckets(msg.payload),
2201
2220
  "SYNC_RESP_LEAF": (msg) => managers.merkleSyncHandler.handleSyncRespLeaf(msg.payload),
@@ -2218,12 +2237,12 @@ function registerClientMessageHandlers(router, delegates, managers) {
2218
2237
  },
2219
2238
  // LOCK handlers
2220
2239
  "LOCK_GRANTED": (msg) => {
2221
- const { requestId, fencingToken } = msg.payload;
2222
- managers.lockManager.handleLockGranted(requestId, fencingToken);
2240
+ const { requestId, name, fencingToken } = msg.payload;
2241
+ managers.lockManager.handleLockGranted(requestId, name, fencingToken);
2223
2242
  },
2224
2243
  "LOCK_RELEASED": (msg) => {
2225
- const { requestId, success } = msg.payload;
2226
- managers.lockManager.handleLockReleased(requestId, success);
2244
+ const { requestId, name, success } = msg.payload;
2245
+ managers.lockManager.handleLockReleased(requestId, name, success);
2227
2246
  },
2228
2247
  // GC handler
2229
2248
  "GC_PRUNE": (msg) => delegates.handleGcPrune(msg),
@@ -2261,10 +2280,7 @@ function registerClientMessageHandlers(router, delegates, managers) {
2261
2280
  managers.searchClient.handleSearchResponse(msg.payload);
2262
2281
  },
2263
2282
  "SEARCH_UPDATE": () => {
2264
- },
2265
- // HYBRID handlers
2266
- "HYBRID_QUERY_RESP": (msg) => delegates.handleHybridQueryResponse(msg.payload),
2267
- "HYBRID_QUERY_DELTA": (msg) => delegates.handleHybridQueryDelta(msg.payload)
2283
+ }
2268
2284
  });
2269
2285
  }
2270
2286
 
@@ -2399,8 +2415,8 @@ var SyncEngine = class {
2399
2415
  handleServerEvent: (msg) => this.handleServerEvent(msg),
2400
2416
  handleServerBatchEvent: (msg) => this.handleServerBatchEvent(msg),
2401
2417
  handleGcPrune: (msg) => this.handleGcPrune(msg),
2402
- handleHybridQueryResponse: (payload) => this.handleHybridQueryResponse(payload),
2403
- handleHybridQueryDelta: (payload) => this.handleHybridQueryDelta(payload)
2418
+ handleOpRejected: (msg) => this.handleOpRejected(msg),
2419
+ handleError: (msg) => this.handleError(msg)
2404
2420
  },
2405
2421
  {
2406
2422
  topicManager: this.topicManager,
@@ -2553,6 +2569,15 @@ var SyncEngine = class {
2553
2569
  const pending = this.opLog.filter((op) => !op.synced);
2554
2570
  if (pending.length === 0) return;
2555
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
+ }
2556
2581
  this.sendMessage({
2557
2582
  type: "OP_BATCH",
2558
2583
  payload: {
@@ -2573,7 +2598,7 @@ var SyncEngine = class {
2573
2598
  this.authToken = token;
2574
2599
  this.tokenProvider = null;
2575
2600
  const state = this.stateMachine.getState();
2576
- if (state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "CONNECTING" /* CONNECTING */) {
2601
+ if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
2577
2602
  this.sendAuth();
2578
2603
  } else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
2579
2604
  logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
@@ -2678,9 +2703,10 @@ var SyncEngine = class {
2678
2703
  return;
2679
2704
  }
2680
2705
  await this.messageRouter.route(message);
2681
- if (message.timestamp) {
2682
- this.hlc.update(message.timestamp);
2683
- 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);
2684
2710
  await this.saveOpLog();
2685
2711
  }
2686
2712
  }
@@ -2734,20 +2760,37 @@ var SyncEngine = class {
2734
2760
  this.writeConcernManager.resolveWriteConcernPromise(result.opId, result);
2735
2761
  }
2736
2762
  }
2763
+ const lastIdNum = parseInt(lastId, 10);
2737
2764
  let maxSyncedId = -1;
2738
2765
  let ackedCount = 0;
2739
- this.opLog.forEach((op) => {
2740
- 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) => {
2741
2784
  if (!op.synced) {
2742
2785
  ackedCount++;
2786
+ op.synced = true;
2787
+ const opIdNum = parseInt(op.id, 10);
2788
+ if (!isNaN(opIdNum) && opIdNum > maxSyncedId) {
2789
+ maxSyncedId = opIdNum;
2790
+ }
2743
2791
  }
2744
- op.synced = true;
2745
- const idNum = parseInt(op.id, 10);
2746
- if (!isNaN(idNum) && idNum > maxSyncedId) {
2747
- maxSyncedId = idNum;
2748
- }
2749
- }
2750
- });
2792
+ });
2793
+ }
2751
2794
  if (maxSyncedId !== -1) {
2752
2795
  this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
2753
2796
  }
@@ -2764,10 +2807,10 @@ var SyncEngine = class {
2764
2807
  }
2765
2808
  }
2766
2809
  handleQueryUpdate(message) {
2767
- const { queryId, key, value, type } = message.payload;
2810
+ const { queryId, key, value, changeType } = message.payload;
2768
2811
  const query = this.queryManager.getQueries().get(queryId);
2769
2812
  if (query) {
2770
- query.onUpdate(key, type === "REMOVE" ? null : value);
2813
+ query.onUpdate(key, changeType === "LEAVE" ? null : value);
2771
2814
  }
2772
2815
  }
2773
2816
  async handleServerEvent(message) {
@@ -3172,31 +3215,24 @@ var SyncEngine = class {
3172
3215
  return this.queryManager.runLocalHybridQuery(mapName, filter);
3173
3216
  }
3174
3217
  /**
3175
- * Handle hybrid query response from server.
3218
+ * Handle operation rejected by server (permission denied, validation failure, etc.).
3176
3219
  */
3177
- handleHybridQueryResponse(payload) {
3178
- const query = this.queryManager.getHybridQuery(payload.subscriptionId);
3179
- if (query) {
3180
- query.onResult(payload.results, "server");
3181
- query.updatePaginationInfo({
3182
- nextCursor: payload.nextCursor,
3183
- hasMore: payload.hasMore,
3184
- cursorStatus: payload.cursorStatus
3185
- });
3186
- }
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
+ });
3187
3229
  }
3188
3230
  /**
3189
- * Handle hybrid query delta update from server.
3231
+ * Handle generic error message from server.
3190
3232
  */
3191
- handleHybridQueryDelta(payload) {
3192
- const query = this.queryManager.getHybridQuery(payload.subscriptionId);
3193
- if (query) {
3194
- if (payload.type === "LEAVE") {
3195
- query.onUpdate(payload.key, null);
3196
- } else {
3197
- query.onUpdate(payload.key, payload.value, payload.score, payload.matchedTerms);
3198
- }
3199
- }
3233
+ handleError(message) {
3234
+ const { code, message: errorMessage, details } = message.payload;
3235
+ logger.error({ code, message: errorMessage, details }, "Server error received");
3200
3236
  }
3201
3237
  };
3202
3238
 
@@ -4080,8 +4116,8 @@ var SearchHandle = class {
4080
4116
  handleSearchUpdate(message) {
4081
4117
  if (message.type !== "SEARCH_UPDATE") return;
4082
4118
  if (message.payload?.subscriptionId !== this.subscriptionId) return;
4083
- const { key, value, score, matchedTerms, type } = message.payload;
4084
- switch (type) {
4119
+ const { key, value, score, matchedTerms, changeType } = message.payload;
4120
+ switch (changeType) {
4085
4121
  case "ENTER":
4086
4122
  this.results.set(key, {
4087
4123
  key,
@@ -4398,6 +4434,30 @@ var import_core10 = require("@topgunbuild/core");
4398
4434
  // src/cluster/ConnectionPool.ts
4399
4435
  var import_core7 = require("@topgunbuild/core");
4400
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
4401
4461
  var ConnectionPool = class {
4402
4462
  constructor(config = {}) {
4403
4463
  this.listeners = /* @__PURE__ */ new Map();
@@ -4469,10 +4529,17 @@ var ConnectionPool = class {
4469
4529
  return;
4470
4530
  }
4471
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
+ }
4472
4538
  const connection = {
4473
4539
  nodeId,
4474
4540
  endpoint,
4475
4541
  socket: null,
4542
+ cachedConnection: null,
4476
4543
  state: "DISCONNECTED",
4477
4544
  lastSeen: 0,
4478
4545
  latencyMs: 0,
@@ -4507,15 +4574,34 @@ var ConnectionPool = class {
4507
4574
  }
4508
4575
  logger.info({ nodeId }, "Node removed from connection pool");
4509
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
+ }
4510
4593
  /**
4511
4594
  * Get connection for a specific node
4512
4595
  */
4513
4596
  getConnection(nodeId) {
4514
4597
  const connection = this.connections.get(nodeId);
4515
- if (!connection || connection.state !== "AUTHENTICATED") {
4598
+ if (!connection || connection.state !== "CONNECTED" && connection.state !== "AUTHENTICATED" || !connection.socket) {
4516
4599
  return null;
4517
4600
  }
4518
- return connection.socket;
4601
+ if (!connection.cachedConnection) {
4602
+ connection.cachedConnection = new WebSocketConnection(connection.socket);
4603
+ }
4604
+ return connection.cachedConnection;
4519
4605
  }
4520
4606
  /**
4521
4607
  * Get primary connection (first/seed node)
@@ -4529,8 +4615,11 @@ var ConnectionPool = class {
4529
4615
  */
4530
4616
  getAnyHealthyConnection() {
4531
4617
  for (const [nodeId, conn] of this.connections) {
4532
- if (conn.state === "AUTHENTICATED" && conn.socket) {
4533
- 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 };
4534
4623
  }
4535
4624
  }
4536
4625
  return null;
@@ -4586,7 +4675,7 @@ var ConnectionPool = class {
4586
4675
  * Get list of connected node IDs
4587
4676
  */
4588
4677
  getConnectedNodes() {
4589
- 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);
4590
4679
  }
4591
4680
  /**
4592
4681
  * Get all node IDs
@@ -4595,11 +4684,11 @@ var ConnectionPool = class {
4595
4684
  return Array.from(this.connections.keys());
4596
4685
  }
4597
4686
  /**
4598
- * Check if node is connected and authenticated
4687
+ * Check if node has an open WebSocket connection
4599
4688
  */
4600
4689
  isNodeConnected(nodeId) {
4601
4690
  const conn = this.connections.get(nodeId);
4602
- return conn?.state === "AUTHENTICATED";
4691
+ return conn?.state === "CONNECTED" || conn?.state === "AUTHENTICATED";
4603
4692
  }
4604
4693
  /**
4605
4694
  * Check if connected to a specific node.
@@ -4664,25 +4753,26 @@ var ConnectionPool = class {
4664
4753
  };
4665
4754
  socket.onmessage = (event) => {
4666
4755
  connection.lastSeen = Date.now();
4667
- this.handleMessage(nodeId, event);
4756
+ this.handleMessage(connection.nodeId, event);
4668
4757
  };
4669
4758
  socket.onerror = (error) => {
4670
- logger.error({ nodeId, error }, "WebSocket error");
4671
- 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"));
4672
4761
  };
4673
4762
  socket.onclose = () => {
4674
4763
  const wasConnected = connection.state === "AUTHENTICATED";
4675
4764
  connection.state = "DISCONNECTED";
4676
4765
  connection.socket = null;
4766
+ connection.cachedConnection = null;
4677
4767
  if (wasConnected) {
4678
- this.emit("node:disconnected", nodeId, "Connection closed");
4768
+ this.emit("node:disconnected", connection.nodeId, "Connection closed");
4679
4769
  }
4680
- this.scheduleReconnect(nodeId);
4770
+ this.scheduleReconnect(connection.nodeId);
4681
4771
  };
4682
4772
  } catch (error) {
4683
4773
  connection.state = "FAILED";
4684
- logger.error({ nodeId, error }, "Failed to connect");
4685
- this.scheduleReconnect(nodeId);
4774
+ logger.error({ nodeId: connection.nodeId, error }, "Failed to connect");
4775
+ this.scheduleReconnect(connection.nodeId);
4686
4776
  }
4687
4777
  }
4688
4778
  sendAuth(connection) {
@@ -4711,18 +4801,15 @@ var ConnectionPool = class {
4711
4801
  logger.info({ nodeId }, "Authenticated with node");
4712
4802
  this.emit("node:healthy", nodeId);
4713
4803
  this.flushPendingMessages(connection);
4714
- return;
4715
4804
  }
4716
4805
  if (message.type === "AUTH_REQUIRED") {
4717
4806
  if (this.authToken) {
4718
4807
  this.sendAuth(connection);
4719
4808
  }
4720
- return;
4721
4809
  }
4722
4810
  if (message.type === "AUTH_FAIL") {
4723
4811
  logger.error({ nodeId, error: message.error }, "Authentication failed");
4724
4812
  connection.state = "FAILED";
4725
- return;
4726
4813
  }
4727
4814
  if (message.type === "PONG") {
4728
4815
  if (message.timestamp) {
@@ -4730,10 +4817,6 @@ var ConnectionPool = class {
4730
4817
  }
4731
4818
  return;
4732
4819
  }
4733
- if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
4734
- this.emit("message", nodeId, message);
4735
- return;
4736
- }
4737
4820
  this.emit("message", nodeId, message);
4738
4821
  }
4739
4822
  flushPendingMessages(connection) {
@@ -4900,17 +4983,17 @@ var PartitionRouter = class {
4900
4983
  }
4901
4984
  return null;
4902
4985
  }
4903
- const socket = this.connectionPool.getConnection(routing.nodeId);
4904
- if (socket) {
4905
- return { nodeId: routing.nodeId, socket };
4986
+ const connection = this.connectionPool.getConnection(routing.nodeId);
4987
+ if (connection) {
4988
+ return { nodeId: routing.nodeId, connection };
4906
4989
  }
4907
4990
  const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
4908
4991
  if (partition) {
4909
4992
  for (const backupId of partition.backupNodeIds) {
4910
- const backupSocket = this.connectionPool.getConnection(backupId);
4911
- if (backupSocket) {
4993
+ const backupConnection = this.connectionPool.getConnection(backupId);
4994
+ if (backupConnection) {
4912
4995
  logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
4913
- return { nodeId: backupId, socket: backupSocket };
4996
+ return { nodeId: backupId, connection: backupConnection };
4914
4997
  }
4915
4998
  }
4916
4999
  }
@@ -5186,7 +5269,7 @@ var PartitionRouter = class {
5186
5269
  };
5187
5270
 
5188
5271
  // src/cluster/ClusterClient.ts
5189
- var ClusterClient = class {
5272
+ var _ClusterClient = class _ClusterClient {
5190
5273
  constructor(config) {
5191
5274
  this.listeners = /* @__PURE__ */ new Map();
5192
5275
  this.initialized = false;
@@ -5199,6 +5282,8 @@ var ClusterClient = class {
5199
5282
  };
5200
5283
  // Circuit breaker state per node
5201
5284
  this.circuits = /* @__PURE__ */ new Map();
5285
+ // Debounce timer for partition map requests on reconnect
5286
+ this.partitionMapRequestTimer = null;
5202
5287
  this.config = config;
5203
5288
  this.circuitBreakerConfig = {
5204
5289
  ...import_core10.DEFAULT_CIRCUIT_BREAKER_CONFIG,
@@ -5290,14 +5375,14 @@ var ClusterClient = class {
5290
5375
  this.requestPartitionMapRefresh();
5291
5376
  return this.getFallbackConnection();
5292
5377
  }
5293
- const socket = this.connectionPool.getConnection(owner);
5294
- if (!socket) {
5378
+ const connection = this.connectionPool.getConnection(owner);
5379
+ if (!connection) {
5295
5380
  this.routingMetrics.fallbackRoutes++;
5296
5381
  logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
5297
5382
  return this.getFallbackConnection();
5298
5383
  }
5299
5384
  this.routingMetrics.directRoutes++;
5300
- return socket;
5385
+ return connection;
5301
5386
  }
5302
5387
  /**
5303
5388
  * Get fallback connection when owner is unavailable.
@@ -5305,10 +5390,10 @@ var ClusterClient = class {
5305
5390
  */
5306
5391
  getFallbackConnection() {
5307
5392
  const conn = this.connectionPool.getAnyHealthyConnection();
5308
- if (!conn?.socket) {
5393
+ if (!conn?.connection) {
5309
5394
  throw new Error("No healthy connection available");
5310
5395
  }
5311
- return conn.socket;
5396
+ return conn.connection;
5312
5397
  }
5313
5398
  /**
5314
5399
  * Request a partition map refresh in the background.
@@ -5319,9 +5404,23 @@ var ClusterClient = class {
5319
5404
  logger.error({ err }, "Failed to refresh partition map");
5320
5405
  });
5321
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
+ }
5322
5421
  /**
5323
5422
  * Request partition map from a specific node.
5324
- * Called on first node connection.
5423
+ * Called on node connection via debounced handler.
5325
5424
  */
5326
5425
  requestPartitionMapFromNode(nodeId) {
5327
5426
  const socket = this.connectionPool.getConnection(nodeId);
@@ -5490,8 +5589,8 @@ var ClusterClient = class {
5490
5589
  * Send directly to partition owner
5491
5590
  */
5492
5591
  sendDirect(key, message) {
5493
- const connection = this.partitionRouter.routeToConnection(key);
5494
- if (!connection) {
5592
+ const route = this.partitionRouter.routeToConnection(key);
5593
+ if (!route) {
5495
5594
  logger.warn({ key }, "No route available for key");
5496
5595
  return false;
5497
5596
  }
@@ -5502,7 +5601,7 @@ var ClusterClient = class {
5502
5601
  mapVersion: this.partitionRouter.getMapVersion()
5503
5602
  }
5504
5603
  };
5505
- connection.socket.send((0, import_core10.serialize)(routedMessage));
5604
+ route.connection.send((0, import_core10.serialize)(routedMessage));
5506
5605
  return true;
5507
5606
  }
5508
5607
  /**
@@ -5606,10 +5705,23 @@ var ClusterClient = class {
5606
5705
  async refreshPartitionMap() {
5607
5706
  await this.partitionRouter.refreshPartitionMap();
5608
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
+ }
5609
5717
  /**
5610
5718
  * Shutdown cluster client (IConnectionProvider interface).
5611
5719
  */
5612
5720
  async close() {
5721
+ if (this.partitionMapRequestTimer) {
5722
+ clearTimeout(this.partitionMapRequestTimer);
5723
+ this.partitionMapRequestTimer = null;
5724
+ }
5613
5725
  this.partitionRouter.close();
5614
5726
  this.connectionPool.close();
5615
5727
  this.initialized = false;
@@ -5632,23 +5744,23 @@ var ClusterClient = class {
5632
5744
  return this.partitionRouter;
5633
5745
  }
5634
5746
  /**
5635
- * Get any healthy WebSocket connection (IConnectionProvider interface).
5747
+ * Get any healthy connection (IConnectionProvider interface).
5636
5748
  * @throws Error if not connected
5637
5749
  */
5638
5750
  getAnyConnection() {
5639
5751
  const conn = this.connectionPool.getAnyHealthyConnection();
5640
- if (!conn?.socket) {
5752
+ if (!conn?.connection) {
5641
5753
  throw new Error("No healthy connection available");
5642
5754
  }
5643
- return conn.socket;
5755
+ return conn.connection;
5644
5756
  }
5645
5757
  /**
5646
- * Get any healthy WebSocket connection, or null if none available.
5758
+ * Get any healthy connection, or null if none available.
5647
5759
  * Use this for optional connection checks.
5648
5760
  */
5649
5761
  getAnyConnectionOrNull() {
5650
5762
  const conn = this.connectionPool.getAnyHealthyConnection();
5651
- return conn?.socket ?? null;
5763
+ return conn?.connection ?? null;
5652
5764
  }
5653
5765
  // ============================================
5654
5766
  // Circuit Breaker Methods
@@ -5739,9 +5851,7 @@ var ClusterClient = class {
5739
5851
  setupEventHandlers() {
5740
5852
  this.connectionPool.on("node:connected", (nodeId) => {
5741
5853
  logger.debug({ nodeId }, "Node connected");
5742
- if (this.partitionRouter.getMapVersion() === 0) {
5743
- this.requestPartitionMapFromNode(nodeId);
5744
- }
5854
+ this.debouncedPartitionMapRequest(nodeId);
5745
5855
  if (this.connectionPool.getConnectedNodes().length === 1) {
5746
5856
  this.emit("connected");
5747
5857
  }
@@ -5796,6 +5906,8 @@ var ClusterClient = class {
5796
5906
  });
5797
5907
  }
5798
5908
  };
5909
+ _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS = 500;
5910
+ var ClusterClient = _ClusterClient;
5799
5911
 
5800
5912
  // src/connection/SingleServerProvider.ts
5801
5913
  var DEFAULT_CONFIG = {
@@ -5811,14 +5923,18 @@ var SingleServerProvider = class {
5811
5923
  this.reconnectTimer = null;
5812
5924
  this.isClosing = false;
5813
5925
  this.listeners = /* @__PURE__ */ new Map();
5926
+ this.onlineHandler = null;
5927
+ this.offlineHandler = null;
5814
5928
  this.url = config.url;
5815
5929
  this.config = {
5816
5930
  url: config.url,
5817
5931
  maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
5818
5932
  reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
5819
5933
  backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
5820
- maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
5934
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs,
5935
+ listenNetworkEvents: config.listenNetworkEvents ?? true
5821
5936
  };
5937
+ this.setupNetworkListeners();
5822
5938
  }
5823
5939
  /**
5824
5940
  * Connect to the WebSocket server.
@@ -5827,6 +5943,9 @@ var SingleServerProvider = class {
5827
5943
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
5828
5944
  return;
5829
5945
  }
5946
+ if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false) {
5947
+ throw new Error("Browser is offline \u2014 skipping connection attempt");
5948
+ }
5830
5949
  this.isClosing = false;
5831
5950
  return new Promise((resolve, reject) => {
5832
5951
  try {
@@ -5879,7 +5998,7 @@ var SingleServerProvider = class {
5879
5998
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
5880
5999
  throw new Error("Not connected");
5881
6000
  }
5882
- return this.ws;
6001
+ return new WebSocketConnection(this.ws);
5883
6002
  }
5884
6003
  /**
5885
6004
  * Get any available connection.
@@ -5930,6 +6049,7 @@ var SingleServerProvider = class {
5930
6049
  */
5931
6050
  async close() {
5932
6051
  this.isClosing = true;
6052
+ this.teardownNetworkListeners();
5933
6053
  if (this.reconnectTimer) {
5934
6054
  clearTimeout(this.reconnectTimer);
5935
6055
  this.reconnectTimer = null;
@@ -5968,6 +6088,10 @@ var SingleServerProvider = class {
5968
6088
  clearTimeout(this.reconnectTimer);
5969
6089
  this.reconnectTimer = null;
5970
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
+ }
5971
6095
  if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
5972
6096
  logger.error(
5973
6097
  { attempts: this.reconnectAttempts, url: this.url },
@@ -6003,6 +6127,37 @@ var SingleServerProvider = class {
6003
6127
  delay = delay * (0.5 + Math.random());
6004
6128
  return Math.floor(delay);
6005
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
+ }
6006
6161
  /**
6007
6162
  * Get the WebSocket URL this provider connects to.
6008
6163
  */
@@ -6022,6 +6177,43 @@ var SingleServerProvider = class {
6022
6177
  resetReconnectAttempts() {
6023
6178
  this.reconnectAttempts = 0;
6024
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
+ }
6025
6217
  };
6026
6218
 
6027
6219
  // src/TopGunClient.ts
@@ -6079,7 +6271,13 @@ var TopGunClient = class {
6079
6271
  });
6080
6272
  logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
6081
6273
  } else {
6082
- 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
+ });
6083
6281
  this.syncEngine = new SyncEngine({
6084
6282
  nodeId: this.nodeId,
6085
6283
  connectionProvider: singleServerProvider,
@@ -7125,18 +7323,514 @@ var EncryptedStorageAdapter = class {
7125
7323
  };
7126
7324
 
7127
7325
  // src/index.ts
7326
+ var import_core14 = require("@topgunbuild/core");
7327
+
7328
+ // src/connection/HttpSyncProvider.ts
7128
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
+ };
7348
+ var HttpSyncProvider = class {
7349
+ constructor(config) {
7350
+ this.listeners = /* @__PURE__ */ new Map();
7351
+ this.pollTimer = null;
7352
+ /** Queued operations to send on next poll */
7353
+ this.pendingOperations = [];
7354
+ /** Queued one-shot queries to send on next poll */
7355
+ this.pendingQueries = [];
7356
+ /** Per-map last sync timestamps for delta tracking */
7357
+ this.lastSyncTimestamps = /* @__PURE__ */ new Map();
7358
+ /** Whether the last HTTP request succeeded */
7359
+ this.connected = false;
7360
+ /** Whether we were previously connected (for reconnected event) */
7361
+ this.wasConnected = false;
7362
+ this.url = config.url.replace(/\/$/, "");
7363
+ this.clientId = config.clientId;
7364
+ this.hlc = config.hlc;
7365
+ this.authToken = config.authToken || "";
7366
+ this.pollIntervalMs = config.pollIntervalMs ?? 5e3;
7367
+ this.requestTimeoutMs = config.requestTimeoutMs ?? 3e4;
7368
+ this.syncMaps = config.syncMaps || [];
7369
+ this.fetchImpl = config.fetchImpl || globalThis.fetch.bind(globalThis);
7370
+ }
7371
+ /**
7372
+ * Connect by sending an initial sync request to verify auth and get state.
7373
+ */
7374
+ async connect() {
7375
+ try {
7376
+ await this.doSyncRequest();
7377
+ this.connected = true;
7378
+ this.wasConnected = true;
7379
+ this.emit("connected", "http");
7380
+ this.startPolling();
7381
+ } catch (err) {
7382
+ this.connected = false;
7383
+ this.emit("error", err);
7384
+ throw err;
7385
+ }
7386
+ }
7387
+ /**
7388
+ * Get connection for a specific key.
7389
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7390
+ */
7391
+ getConnection(_key) {
7392
+ return new HttpConnection(this);
7393
+ }
7394
+ /**
7395
+ * Get any available connection.
7396
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7397
+ */
7398
+ getAnyConnection() {
7399
+ return new HttpConnection(this);
7400
+ }
7401
+ /**
7402
+ * Check if connected (last HTTP request succeeded).
7403
+ */
7404
+ isConnected() {
7405
+ return this.connected;
7406
+ }
7407
+ /**
7408
+ * Get connected node IDs.
7409
+ * Returns ['http'] when connected, [] when not.
7410
+ */
7411
+ getConnectedNodes() {
7412
+ return this.connected ? ["http"] : [];
7413
+ }
7414
+ /**
7415
+ * Subscribe to connection events.
7416
+ */
7417
+ on(event, handler2) {
7418
+ if (!this.listeners.has(event)) {
7419
+ this.listeners.set(event, /* @__PURE__ */ new Set());
7420
+ }
7421
+ this.listeners.get(event).add(handler2);
7422
+ }
7423
+ /**
7424
+ * Unsubscribe from connection events.
7425
+ */
7426
+ off(event, handler2) {
7427
+ this.listeners.get(event)?.delete(handler2);
7428
+ }
7429
+ /**
7430
+ * Send data via the HTTP sync provider.
7431
+ *
7432
+ * Deserializes the msgpackr binary to extract the message type and routes:
7433
+ * - OP_BATCH / CLIENT_OP: queued as operations for next poll
7434
+ * - AUTH: silently ignored (auth via HTTP header)
7435
+ * - SYNC_INIT: silently ignored (HTTP uses timestamp-based deltas)
7436
+ * - QUERY_SUB: queued as one-shot query for next poll
7437
+ * - All other types: silently dropped with debug log
7438
+ */
7439
+ send(data, _key) {
7440
+ try {
7441
+ const message = (0, import_core13.deserialize)(
7442
+ data instanceof ArrayBuffer ? new Uint8Array(data) : data
7443
+ );
7444
+ switch (message.type) {
7445
+ case "OP_BATCH":
7446
+ if (message.payload?.ops) {
7447
+ this.pendingOperations.push(...message.payload.ops);
7448
+ }
7449
+ break;
7450
+ case "CLIENT_OP":
7451
+ if (message.payload) {
7452
+ this.pendingOperations.push(message.payload);
7453
+ }
7454
+ break;
7455
+ case "AUTH":
7456
+ break;
7457
+ case "SYNC_INIT":
7458
+ break;
7459
+ case "QUERY_SUB":
7460
+ if (message.payload) {
7461
+ this.pendingQueries.push({
7462
+ queryId: message.payload.requestId || `q-${Date.now()}`,
7463
+ mapName: message.payload.mapName || message.mapName,
7464
+ filter: message.payload.query?.where || message.payload.where,
7465
+ limit: message.payload.query?.limit || message.payload.limit
7466
+ });
7467
+ }
7468
+ break;
7469
+ default:
7470
+ logger.debug(
7471
+ { type: message.type },
7472
+ "HTTP sync provider: unsupported message type dropped"
7473
+ );
7474
+ break;
7475
+ }
7476
+ } catch (err) {
7477
+ logger.warn({ err }, "HTTP sync provider: failed to deserialize message");
7478
+ }
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
+ }
7491
+ /**
7492
+ * Close the HTTP sync provider.
7493
+ * Stops the polling loop, clears queued operations, and sets disconnected state.
7494
+ */
7495
+ async close() {
7496
+ this.stopPolling();
7497
+ this.pendingOperations = [];
7498
+ this.pendingQueries = [];
7499
+ this.connected = false;
7500
+ logger.info({ url: this.url }, "HttpSyncProvider closed");
7501
+ }
7502
+ /**
7503
+ * Update the auth token (e.g., after token refresh).
7504
+ */
7505
+ setAuthToken(token) {
7506
+ this.authToken = token;
7507
+ }
7508
+ /**
7509
+ * Send an HTTP sync request with queued operations and receive deltas.
7510
+ */
7511
+ async doSyncRequest() {
7512
+ const syncMaps = this.syncMaps.map((mapName) => ({
7513
+ mapName,
7514
+ lastSyncTimestamp: this.lastSyncTimestamps.get(mapName) || {
7515
+ millis: 0,
7516
+ counter: 0,
7517
+ nodeId: ""
7518
+ }
7519
+ }));
7520
+ const operations = this.pendingOperations.splice(0);
7521
+ const queries = this.pendingQueries.splice(0);
7522
+ const requestBody = {
7523
+ clientId: this.clientId,
7524
+ clientHlc: this.hlc.now()
7525
+ };
7526
+ if (operations.length > 0) {
7527
+ requestBody.operations = operations;
7528
+ }
7529
+ if (syncMaps.length > 0) {
7530
+ requestBody.syncMaps = syncMaps;
7531
+ }
7532
+ if (queries.length > 0) {
7533
+ requestBody.queries = queries;
7534
+ }
7535
+ const bodyBytes = (0, import_core13.serialize)(requestBody);
7536
+ const bodyBuffer = new ArrayBuffer(bodyBytes.byteLength);
7537
+ new Uint8Array(bodyBuffer).set(bodyBytes);
7538
+ const controller = new AbortController();
7539
+ const timeoutId = setTimeout(() => controller.abort(), this.requestTimeoutMs);
7540
+ try {
7541
+ const response = await this.fetchImpl(`${this.url}/sync`, {
7542
+ method: "POST",
7543
+ headers: {
7544
+ "Content-Type": "application/x-msgpack",
7545
+ "Authorization": `Bearer ${this.authToken}`
7546
+ },
7547
+ body: bodyBuffer,
7548
+ signal: controller.signal
7549
+ });
7550
+ clearTimeout(timeoutId);
7551
+ if (!response.ok) {
7552
+ throw new Error(`HTTP sync request failed: ${response.status} ${response.statusText}`);
7553
+ }
7554
+ const responseBuffer = await response.arrayBuffer();
7555
+ const syncResponse = (0, import_core13.deserialize)(new Uint8Array(responseBuffer));
7556
+ if (syncResponse.serverHlc) {
7557
+ this.hlc.update(syncResponse.serverHlc);
7558
+ }
7559
+ if (syncResponse.ack) {
7560
+ this.emit("message", "http", (0, import_core13.serialize)({
7561
+ type: "OP_ACK",
7562
+ payload: syncResponse.ack
7563
+ }));
7564
+ }
7565
+ if (syncResponse.deltas) {
7566
+ for (const delta of syncResponse.deltas) {
7567
+ this.lastSyncTimestamps.set(delta.mapName, delta.serverSyncTimestamp);
7568
+ for (const record of delta.records) {
7569
+ this.emit("message", "http", (0, import_core13.serialize)({
7570
+ type: "SERVER_EVENT",
7571
+ payload: {
7572
+ mapName: delta.mapName,
7573
+ key: record.key,
7574
+ record: record.record,
7575
+ eventType: record.eventType
7576
+ }
7577
+ }));
7578
+ }
7579
+ }
7580
+ }
7581
+ if (syncResponse.queryResults) {
7582
+ for (const result of syncResponse.queryResults) {
7583
+ this.emit("message", "http", (0, import_core13.serialize)({
7584
+ type: "QUERY_RESP",
7585
+ payload: {
7586
+ requestId: result.queryId,
7587
+ results: result.results,
7588
+ hasMore: result.hasMore,
7589
+ nextCursor: result.nextCursor
7590
+ }
7591
+ }));
7592
+ }
7593
+ }
7594
+ if (!this.connected) {
7595
+ this.connected = true;
7596
+ if (this.wasConnected) {
7597
+ this.emit("reconnected", "http");
7598
+ } else {
7599
+ this.wasConnected = true;
7600
+ this.emit("connected", "http");
7601
+ }
7602
+ }
7603
+ } catch (err) {
7604
+ clearTimeout(timeoutId);
7605
+ if (this.connected) {
7606
+ this.connected = false;
7607
+ this.emit("disconnected", "http");
7608
+ }
7609
+ if (operations.length > 0) {
7610
+ this.pendingOperations.unshift(...operations);
7611
+ }
7612
+ if (queries.length > 0) {
7613
+ this.pendingQueries.unshift(...queries);
7614
+ }
7615
+ throw err;
7616
+ }
7617
+ }
7618
+ /**
7619
+ * Start the polling loop.
7620
+ */
7621
+ startPolling() {
7622
+ if (this.pollTimer) return;
7623
+ this.pollTimer = setInterval(async () => {
7624
+ try {
7625
+ await this.doSyncRequest();
7626
+ } catch (err) {
7627
+ logger.debug({ err }, "HTTP sync poll failed");
7628
+ }
7629
+ }, this.pollIntervalMs);
7630
+ }
7631
+ /**
7632
+ * Stop the polling loop.
7633
+ */
7634
+ stopPolling() {
7635
+ if (this.pollTimer) {
7636
+ clearInterval(this.pollTimer);
7637
+ this.pollTimer = null;
7638
+ }
7639
+ }
7640
+ /**
7641
+ * Emit an event to all listeners.
7642
+ */
7643
+ emit(event, ...args) {
7644
+ const handlers = this.listeners.get(event);
7645
+ if (handlers) {
7646
+ for (const handler2 of handlers) {
7647
+ try {
7648
+ handler2(...args);
7649
+ } catch (err) {
7650
+ logger.error({ err, event }, "Error in HttpSyncProvider event handler");
7651
+ }
7652
+ }
7653
+ }
7654
+ }
7655
+ };
7656
+
7657
+ // src/connection/AutoConnectionProvider.ts
7658
+ var AutoConnectionProvider = class {
7659
+ constructor(config) {
7660
+ /** The active underlying provider */
7661
+ this.activeProvider = null;
7662
+ this.listeners = /* @__PURE__ */ new Map();
7663
+ this.config = config;
7664
+ this.maxWsAttempts = config.maxWsAttempts ?? 3;
7665
+ this.isHttpMode = config.httpOnly ?? false;
7666
+ }
7667
+ /**
7668
+ * Connect using WebSocket first, falling back to HTTP after maxWsAttempts failures.
7669
+ * If httpOnly is true, skips WebSocket entirely.
7670
+ */
7671
+ async connect() {
7672
+ if (this.isHttpMode) {
7673
+ await this.connectHttp();
7674
+ return;
7675
+ }
7676
+ const wsUrl = this.toWsUrl(this.config.url);
7677
+ let lastError = null;
7678
+ for (let attempt = 0; attempt < this.maxWsAttempts; attempt++) {
7679
+ try {
7680
+ const wsProvider = new SingleServerProvider({
7681
+ url: wsUrl,
7682
+ maxReconnectAttempts: 1,
7683
+ reconnectDelayMs: 1e3
7684
+ });
7685
+ await wsProvider.connect();
7686
+ this.activeProvider = wsProvider;
7687
+ this.proxyEvents(wsProvider);
7688
+ logger.info({ url: wsUrl }, "AutoConnectionProvider: WebSocket connected");
7689
+ return;
7690
+ } catch (err) {
7691
+ lastError = err;
7692
+ logger.debug(
7693
+ { attempt: attempt + 1, maxAttempts: this.maxWsAttempts, err: err.message },
7694
+ "AutoConnectionProvider: WebSocket attempt failed"
7695
+ );
7696
+ }
7697
+ }
7698
+ logger.info(
7699
+ { wsAttempts: this.maxWsAttempts, url: this.config.url },
7700
+ "AutoConnectionProvider: WebSocket failed, falling back to HTTP"
7701
+ );
7702
+ this.isHttpMode = true;
7703
+ await this.connectHttp();
7704
+ }
7705
+ /**
7706
+ * Connect using HTTP sync provider.
7707
+ */
7708
+ async connectHttp() {
7709
+ const httpUrl = this.toHttpUrl(this.config.url);
7710
+ const httpProvider = new HttpSyncProvider({
7711
+ url: httpUrl,
7712
+ clientId: this.config.clientId,
7713
+ hlc: this.config.hlc,
7714
+ authToken: this.config.authToken,
7715
+ pollIntervalMs: this.config.httpPollIntervalMs,
7716
+ syncMaps: this.config.syncMaps,
7717
+ fetchImpl: this.config.fetchImpl
7718
+ });
7719
+ this.activeProvider = httpProvider;
7720
+ this.proxyEvents(httpProvider);
7721
+ await httpProvider.connect();
7722
+ logger.info({ url: httpUrl }, "AutoConnectionProvider: HTTP connected");
7723
+ }
7724
+ getConnection(key) {
7725
+ if (!this.activeProvider) {
7726
+ throw new Error("Not connected");
7727
+ }
7728
+ return this.activeProvider.getConnection(key);
7729
+ }
7730
+ getAnyConnection() {
7731
+ if (!this.activeProvider) {
7732
+ throw new Error("Not connected");
7733
+ }
7734
+ return this.activeProvider.getAnyConnection();
7735
+ }
7736
+ isConnected() {
7737
+ return this.activeProvider?.isConnected() ?? false;
7738
+ }
7739
+ getConnectedNodes() {
7740
+ return this.activeProvider?.getConnectedNodes() ?? [];
7741
+ }
7742
+ on(event, handler2) {
7743
+ if (!this.listeners.has(event)) {
7744
+ this.listeners.set(event, /* @__PURE__ */ new Set());
7745
+ }
7746
+ this.listeners.get(event).add(handler2);
7747
+ if (this.activeProvider) {
7748
+ this.activeProvider.on(event, handler2);
7749
+ }
7750
+ }
7751
+ off(event, handler2) {
7752
+ this.listeners.get(event)?.delete(handler2);
7753
+ if (this.activeProvider) {
7754
+ this.activeProvider.off(event, handler2);
7755
+ }
7756
+ }
7757
+ send(data, key) {
7758
+ if (!this.activeProvider) {
7759
+ throw new Error("Not connected");
7760
+ }
7761
+ this.activeProvider.send(data, key);
7762
+ }
7763
+ /**
7764
+ * Force reconnect by delegating to the active provider.
7765
+ */
7766
+ forceReconnect() {
7767
+ if (this.activeProvider) {
7768
+ this.activeProvider.forceReconnect();
7769
+ }
7770
+ }
7771
+ /**
7772
+ * Close the active underlying provider.
7773
+ */
7774
+ async close() {
7775
+ if (this.activeProvider) {
7776
+ await this.activeProvider.close();
7777
+ this.activeProvider = null;
7778
+ }
7779
+ }
7780
+ /**
7781
+ * Whether currently using HTTP mode.
7782
+ */
7783
+ isUsingHttp() {
7784
+ return this.isHttpMode;
7785
+ }
7786
+ /**
7787
+ * Proxy events from the underlying provider to our listeners.
7788
+ */
7789
+ proxyEvents(provider) {
7790
+ const events = [
7791
+ "connected",
7792
+ "disconnected",
7793
+ "reconnected",
7794
+ "message",
7795
+ "partitionMapUpdated",
7796
+ "error"
7797
+ ];
7798
+ for (const event of events) {
7799
+ const handlers = this.listeners.get(event);
7800
+ if (handlers) {
7801
+ for (const handler2 of handlers) {
7802
+ provider.on(event, handler2);
7803
+ }
7804
+ }
7805
+ }
7806
+ }
7807
+ /**
7808
+ * Convert a URL to WebSocket URL format.
7809
+ */
7810
+ toWsUrl(url) {
7811
+ return url.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
7812
+ }
7813
+ /**
7814
+ * Convert a URL to HTTP URL format.
7815
+ */
7816
+ toHttpUrl(url) {
7817
+ return url.replace(/^ws:\/\//, "http://").replace(/^wss:\/\//, "https://");
7818
+ }
7819
+ };
7129
7820
  // Annotate the CommonJS export names for ESM import in node:
7130
7821
  0 && (module.exports = {
7822
+ AutoConnectionProvider,
7131
7823
  BackpressureError,
7132
7824
  ChangeTracker,
7133
7825
  ClusterClient,
7134
7826
  ConflictResolverClient,
7135
7827
  ConnectionPool,
7828
+ ConnectionReadyState,
7136
7829
  DEFAULT_BACKPRESSURE_CONFIG,
7137
7830
  DEFAULT_CLUSTER_CONFIG,
7138
7831
  EncryptedStorageAdapter,
7139
7832
  EventJournalReader,
7833
+ HttpSyncProvider,
7140
7834
  HybridQueryHandle,
7141
7835
  IDBAdapter,
7142
7836
  LWWMap,
@@ -7153,6 +7847,7 @@ var import_core13 = require("@topgunbuild/core");
7153
7847
  TopGunClient,
7154
7848
  TopicHandle,
7155
7849
  VALID_TRANSITIONS,
7850
+ WebSocketConnection,
7156
7851
  isValidTransition,
7157
7852
  logger
7158
7853
  });