@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.mjs CHANGED
@@ -656,9 +656,7 @@ var WebSocketManager = class {
656
656
  timeoutMs: this.config.heartbeatConfig.timeoutMs
657
657
  }, "Heartbeat timeout - triggering reconnection");
658
658
  this.stopHeartbeat();
659
- this.connectionProvider.close().catch((err) => {
660
- logger.error({ err }, "Error closing ConnectionProvider on heartbeat timeout");
661
- });
659
+ this.connectionProvider.forceReconnect();
662
660
  }
663
661
  }
664
662
  /**
@@ -1345,7 +1343,7 @@ var LockManager = class {
1345
1343
  /**
1346
1344
  * Handle lock granted message from server.
1347
1345
  */
1348
- handleLockGranted(requestId, fencingToken) {
1346
+ handleLockGranted(requestId, _name, fencingToken) {
1349
1347
  const req = this.pendingLockRequests.get(requestId);
1350
1348
  if (req) {
1351
1349
  clearTimeout(req.timer);
@@ -1356,7 +1354,7 @@ var LockManager = class {
1356
1354
  /**
1357
1355
  * Handle lock released message from server.
1358
1356
  */
1359
- handleLockReleased(requestId, success) {
1357
+ handleLockReleased(requestId, _name, success) {
1360
1358
  const req = this.pendingLockRequests.get(requestId);
1361
1359
  if (req) {
1362
1360
  clearTimeout(req.timer);
@@ -1773,6 +1771,8 @@ import { LWWMap } from "@topgunbuild/core";
1773
1771
  var MerkleSyncHandler = class {
1774
1772
  constructor(config) {
1775
1773
  this.lastSyncTimestamp = 0;
1774
+ /** Accumulated sync stats per map, flushed after a quiet period */
1775
+ this.syncStats = /* @__PURE__ */ new Map();
1776
1776
  this.config = config;
1777
1777
  }
1778
1778
  /**
@@ -1794,7 +1794,8 @@ var MerkleSyncHandler = class {
1794
1794
  * Compares root hashes and requests buckets if mismatch detected.
1795
1795
  */
1796
1796
  async handleSyncRespRoot(payload) {
1797
- const { mapName, rootHash, timestamp } = payload;
1797
+ const { mapName, timestamp } = payload;
1798
+ const rootHash = Number(payload.rootHash);
1798
1799
  const map = this.config.getMap(mapName);
1799
1800
  if (map instanceof LWWMap) {
1800
1801
  const localRootHash = map.getMerkleTree().getRootHash();
@@ -1822,9 +1823,11 @@ var MerkleSyncHandler = class {
1822
1823
  if (map instanceof LWWMap) {
1823
1824
  const tree = map.getMerkleTree();
1824
1825
  const localBuckets = tree.getBuckets(path);
1826
+ let mismatchCount = 0;
1825
1827
  for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
1826
1828
  const localHash = localBuckets[bucketKey] || 0;
1827
1829
  if (localHash !== remoteHash) {
1830
+ mismatchCount++;
1828
1831
  const newPath = path + bucketKey;
1829
1832
  this.config.sendMessage({
1830
1833
  type: "MERKLE_REQ_BUCKET",
@@ -1851,7 +1854,17 @@ var MerkleSyncHandler = class {
1851
1854
  }
1852
1855
  }
1853
1856
  if (updateCount > 0) {
1854
- logger.info({ mapName, count: updateCount }, "Synced records from server");
1857
+ const existing = this.syncStats.get(mapName);
1858
+ if (existing) {
1859
+ existing.count += updateCount;
1860
+ clearTimeout(existing.timer);
1861
+ }
1862
+ const stats = existing ?? { count: updateCount, timer: void 0 };
1863
+ if (!existing) this.syncStats.set(mapName, stats);
1864
+ stats.timer = setTimeout(() => {
1865
+ logger.info({ mapName, count: stats.count }, "Synced records from server");
1866
+ this.syncStats.delete(mapName);
1867
+ }, 100);
1855
1868
  }
1856
1869
  }
1857
1870
  }
@@ -2134,6 +2147,8 @@ function registerClientMessageHandlers(router, delegates, managers) {
2134
2147
  },
2135
2148
  // SYNC handlers
2136
2149
  "OP_ACK": (msg) => delegates.handleOpAck(msg),
2150
+ "OP_REJECTED": (msg) => delegates.handleOpRejected(msg),
2151
+ "ERROR": (msg) => delegates.handleError(msg),
2137
2152
  "SYNC_RESP_ROOT": (msg) => managers.merkleSyncHandler.handleSyncRespRoot(msg.payload),
2138
2153
  "SYNC_RESP_BUCKETS": (msg) => managers.merkleSyncHandler.handleSyncRespBuckets(msg.payload),
2139
2154
  "SYNC_RESP_LEAF": (msg) => managers.merkleSyncHandler.handleSyncRespLeaf(msg.payload),
@@ -2156,12 +2171,12 @@ function registerClientMessageHandlers(router, delegates, managers) {
2156
2171
  },
2157
2172
  // LOCK handlers
2158
2173
  "LOCK_GRANTED": (msg) => {
2159
- const { requestId, fencingToken } = msg.payload;
2160
- managers.lockManager.handleLockGranted(requestId, fencingToken);
2174
+ const { requestId, name, fencingToken } = msg.payload;
2175
+ managers.lockManager.handleLockGranted(requestId, name, fencingToken);
2161
2176
  },
2162
2177
  "LOCK_RELEASED": (msg) => {
2163
- const { requestId, success } = msg.payload;
2164
- managers.lockManager.handleLockReleased(requestId, success);
2178
+ const { requestId, name, success } = msg.payload;
2179
+ managers.lockManager.handleLockReleased(requestId, name, success);
2165
2180
  },
2166
2181
  // GC handler
2167
2182
  "GC_PRUNE": (msg) => delegates.handleGcPrune(msg),
@@ -2199,10 +2214,7 @@ function registerClientMessageHandlers(router, delegates, managers) {
2199
2214
  managers.searchClient.handleSearchResponse(msg.payload);
2200
2215
  },
2201
2216
  "SEARCH_UPDATE": () => {
2202
- },
2203
- // HYBRID handlers
2204
- "HYBRID_QUERY_RESP": (msg) => delegates.handleHybridQueryResponse(msg.payload),
2205
- "HYBRID_QUERY_DELTA": (msg) => delegates.handleHybridQueryDelta(msg.payload)
2217
+ }
2206
2218
  });
2207
2219
  }
2208
2220
 
@@ -2337,8 +2349,8 @@ var SyncEngine = class {
2337
2349
  handleServerEvent: (msg) => this.handleServerEvent(msg),
2338
2350
  handleServerBatchEvent: (msg) => this.handleServerBatchEvent(msg),
2339
2351
  handleGcPrune: (msg) => this.handleGcPrune(msg),
2340
- handleHybridQueryResponse: (payload) => this.handleHybridQueryResponse(payload),
2341
- handleHybridQueryDelta: (payload) => this.handleHybridQueryDelta(payload)
2352
+ handleOpRejected: (msg) => this.handleOpRejected(msg),
2353
+ handleError: (msg) => this.handleError(msg)
2342
2354
  },
2343
2355
  {
2344
2356
  topicManager: this.topicManager,
@@ -2491,6 +2503,15 @@ var SyncEngine = class {
2491
2503
  const pending = this.opLog.filter((op) => !op.synced);
2492
2504
  if (pending.length === 0) return;
2493
2505
  logger.info({ count: pending.length }, "Syncing pending operations");
2506
+ const connectionProvider = this.webSocketManager.getConnectionProvider();
2507
+ if (connectionProvider.sendBatch) {
2508
+ const results = connectionProvider.sendBatch(pending.map((op) => ({ key: op.key, message: op })));
2509
+ const failedKeys = [...results.entries()].filter(([, success]) => !success).map(([key]) => key);
2510
+ if (failedKeys.length > 0) {
2511
+ logger.warn({ failedKeys, count: failedKeys.length }, "Some batch operations failed to send");
2512
+ }
2513
+ return;
2514
+ }
2494
2515
  this.sendMessage({
2495
2516
  type: "OP_BATCH",
2496
2517
  payload: {
@@ -2511,7 +2532,7 @@ var SyncEngine = class {
2511
2532
  this.authToken = token;
2512
2533
  this.tokenProvider = null;
2513
2534
  const state = this.stateMachine.getState();
2514
- if (state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "CONNECTING" /* CONNECTING */) {
2535
+ if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
2515
2536
  this.sendAuth();
2516
2537
  } else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
2517
2538
  logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
@@ -2616,9 +2637,10 @@ var SyncEngine = class {
2616
2637
  return;
2617
2638
  }
2618
2639
  await this.messageRouter.route(message);
2619
- if (message.timestamp) {
2620
- this.hlc.update(message.timestamp);
2621
- this.lastSyncTimestamp = message.timestamp.millis;
2640
+ const ts = message.timestamp;
2641
+ if (ts && typeof ts === "object" && "millis" in ts && "counter" in ts && "nodeId" in ts) {
2642
+ this.hlc.update(ts);
2643
+ this.lastSyncTimestamp = Number(ts.millis);
2622
2644
  await this.saveOpLog();
2623
2645
  }
2624
2646
  }
@@ -2672,20 +2694,37 @@ var SyncEngine = class {
2672
2694
  this.writeConcernManager.resolveWriteConcernPromise(result.opId, result);
2673
2695
  }
2674
2696
  }
2697
+ const lastIdNum = parseInt(lastId, 10);
2675
2698
  let maxSyncedId = -1;
2676
2699
  let ackedCount = 0;
2677
- this.opLog.forEach((op) => {
2678
- if (op.id && op.id <= lastId) {
2700
+ if (!isNaN(lastIdNum)) {
2701
+ this.opLog.forEach((op) => {
2702
+ if (op.id) {
2703
+ const opIdNum = parseInt(op.id, 10);
2704
+ if (!isNaN(opIdNum) && opIdNum <= lastIdNum) {
2705
+ if (!op.synced) {
2706
+ ackedCount++;
2707
+ }
2708
+ op.synced = true;
2709
+ if (opIdNum > maxSyncedId) {
2710
+ maxSyncedId = opIdNum;
2711
+ }
2712
+ }
2713
+ }
2714
+ });
2715
+ } else {
2716
+ logger.warn({ lastId }, "OP_ACK has non-numeric lastId \u2014 marking all pending ops as synced");
2717
+ this.opLog.forEach((op) => {
2679
2718
  if (!op.synced) {
2680
2719
  ackedCount++;
2720
+ op.synced = true;
2721
+ const opIdNum = parseInt(op.id, 10);
2722
+ if (!isNaN(opIdNum) && opIdNum > maxSyncedId) {
2723
+ maxSyncedId = opIdNum;
2724
+ }
2681
2725
  }
2682
- op.synced = true;
2683
- const idNum = parseInt(op.id, 10);
2684
- if (!isNaN(idNum) && idNum > maxSyncedId) {
2685
- maxSyncedId = idNum;
2686
- }
2687
- }
2688
- });
2726
+ });
2727
+ }
2689
2728
  if (maxSyncedId !== -1) {
2690
2729
  this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
2691
2730
  }
@@ -2702,10 +2741,10 @@ var SyncEngine = class {
2702
2741
  }
2703
2742
  }
2704
2743
  handleQueryUpdate(message) {
2705
- const { queryId, key, value, type } = message.payload;
2744
+ const { queryId, key, value, changeType } = message.payload;
2706
2745
  const query = this.queryManager.getQueries().get(queryId);
2707
2746
  if (query) {
2708
- query.onUpdate(key, type === "REMOVE" ? null : value);
2747
+ query.onUpdate(key, changeType === "LEAVE" ? null : value);
2709
2748
  }
2710
2749
  }
2711
2750
  async handleServerEvent(message) {
@@ -3110,31 +3149,24 @@ var SyncEngine = class {
3110
3149
  return this.queryManager.runLocalHybridQuery(mapName, filter);
3111
3150
  }
3112
3151
  /**
3113
- * Handle hybrid query response from server.
3152
+ * Handle operation rejected by server (permission denied, validation failure, etc.).
3114
3153
  */
3115
- handleHybridQueryResponse(payload) {
3116
- const query = this.queryManager.getHybridQuery(payload.subscriptionId);
3117
- if (query) {
3118
- query.onResult(payload.results, "server");
3119
- query.updatePaginationInfo({
3120
- nextCursor: payload.nextCursor,
3121
- hasMore: payload.hasMore,
3122
- cursorStatus: payload.cursorStatus
3123
- });
3124
- }
3154
+ handleOpRejected(message) {
3155
+ const { opId, reason, code } = message.payload;
3156
+ logger.warn({ opId, reason, code }, "Operation rejected by server");
3157
+ this.writeConcernManager.resolveWriteConcernPromise(opId, {
3158
+ opId,
3159
+ success: false,
3160
+ achievedLevel: "FIRE_AND_FORGET",
3161
+ error: reason
3162
+ });
3125
3163
  }
3126
3164
  /**
3127
- * Handle hybrid query delta update from server.
3165
+ * Handle generic error message from server.
3128
3166
  */
3129
- handleHybridQueryDelta(payload) {
3130
- const query = this.queryManager.getHybridQuery(payload.subscriptionId);
3131
- if (query) {
3132
- if (payload.type === "LEAVE") {
3133
- query.onUpdate(payload.key, null);
3134
- } else {
3135
- query.onUpdate(payload.key, payload.value, payload.score, payload.matchedTerms);
3136
- }
3137
- }
3167
+ handleError(message) {
3168
+ const { code, message: errorMessage, details } = message.payload;
3169
+ logger.error({ code, message: errorMessage, details }, "Server error received");
3138
3170
  }
3139
3171
  };
3140
3172
 
@@ -4018,8 +4050,8 @@ var SearchHandle = class {
4018
4050
  handleSearchUpdate(message) {
4019
4051
  if (message.type !== "SEARCH_UPDATE") return;
4020
4052
  if (message.payload?.subscriptionId !== this.subscriptionId) return;
4021
- const { key, value, score, matchedTerms, type } = message.payload;
4022
- switch (type) {
4053
+ const { key, value, score, matchedTerms, changeType } = message.payload;
4054
+ switch (changeType) {
4023
4055
  case "ENTER":
4024
4056
  this.results.set(key, {
4025
4057
  key,
@@ -4343,6 +4375,30 @@ import {
4343
4375
  DEFAULT_CONNECTION_POOL_CONFIG
4344
4376
  } from "@topgunbuild/core";
4345
4377
  import { serialize as serialize2, deserialize as deserialize3 } from "@topgunbuild/core";
4378
+
4379
+ // src/connection/WebSocketConnection.ts
4380
+ var ConnectionReadyState = {
4381
+ CONNECTING: 0,
4382
+ OPEN: 1,
4383
+ CLOSING: 2,
4384
+ CLOSED: 3
4385
+ };
4386
+ var WebSocketConnection = class {
4387
+ constructor(ws) {
4388
+ this.ws = ws;
4389
+ }
4390
+ send(data) {
4391
+ this.ws.send(data);
4392
+ }
4393
+ close() {
4394
+ this.ws.close();
4395
+ }
4396
+ get readyState() {
4397
+ return this.ws.readyState;
4398
+ }
4399
+ };
4400
+
4401
+ // src/cluster/ConnectionPool.ts
4346
4402
  var ConnectionPool = class {
4347
4403
  constructor(config = {}) {
4348
4404
  this.listeners = /* @__PURE__ */ new Map();
@@ -4414,10 +4470,17 @@ var ConnectionPool = class {
4414
4470
  return;
4415
4471
  }
4416
4472
  }
4473
+ for (const [existingId, existingConn] of this.connections) {
4474
+ if (existingConn.endpoint === endpoint && existingId !== nodeId) {
4475
+ this.remapNodeId(existingId, nodeId);
4476
+ return;
4477
+ }
4478
+ }
4417
4479
  const connection = {
4418
4480
  nodeId,
4419
4481
  endpoint,
4420
4482
  socket: null,
4483
+ cachedConnection: null,
4421
4484
  state: "DISCONNECTED",
4422
4485
  lastSeen: 0,
4423
4486
  latencyMs: 0,
@@ -4452,15 +4515,34 @@ var ConnectionPool = class {
4452
4515
  }
4453
4516
  logger.info({ nodeId }, "Node removed from connection pool");
4454
4517
  }
4518
+ /**
4519
+ * Remap a node from one ID to another, preserving the existing connection.
4520
+ * Used when the server-assigned node ID differs from the temporary seed ID.
4521
+ */
4522
+ remapNodeId(oldId, newId) {
4523
+ const connection = this.connections.get(oldId);
4524
+ if (!connection) return;
4525
+ connection.nodeId = newId;
4526
+ this.connections.delete(oldId);
4527
+ this.connections.set(newId, connection);
4528
+ if (this.primaryNodeId === oldId) {
4529
+ this.primaryNodeId = newId;
4530
+ }
4531
+ logger.info({ oldId, newId }, "Node ID remapped");
4532
+ this.emit("node:remapped", oldId, newId);
4533
+ }
4455
4534
  /**
4456
4535
  * Get connection for a specific node
4457
4536
  */
4458
4537
  getConnection(nodeId) {
4459
4538
  const connection = this.connections.get(nodeId);
4460
- if (!connection || connection.state !== "AUTHENTICATED") {
4539
+ if (!connection || connection.state !== "CONNECTED" && connection.state !== "AUTHENTICATED" || !connection.socket) {
4461
4540
  return null;
4462
4541
  }
4463
- return connection.socket;
4542
+ if (!connection.cachedConnection) {
4543
+ connection.cachedConnection = new WebSocketConnection(connection.socket);
4544
+ }
4545
+ return connection.cachedConnection;
4464
4546
  }
4465
4547
  /**
4466
4548
  * Get primary connection (first/seed node)
@@ -4474,8 +4556,11 @@ var ConnectionPool = class {
4474
4556
  */
4475
4557
  getAnyHealthyConnection() {
4476
4558
  for (const [nodeId, conn] of this.connections) {
4477
- if (conn.state === "AUTHENTICATED" && conn.socket) {
4478
- return { nodeId, socket: conn.socket };
4559
+ if ((conn.state === "CONNECTED" || conn.state === "AUTHENTICATED") && conn.socket) {
4560
+ if (!conn.cachedConnection) {
4561
+ conn.cachedConnection = new WebSocketConnection(conn.socket);
4562
+ }
4563
+ return { nodeId, connection: conn.cachedConnection };
4479
4564
  }
4480
4565
  }
4481
4566
  return null;
@@ -4531,7 +4616,7 @@ var ConnectionPool = class {
4531
4616
  * Get list of connected node IDs
4532
4617
  */
4533
4618
  getConnectedNodes() {
4534
- return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
4619
+ return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "CONNECTED" || conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
4535
4620
  }
4536
4621
  /**
4537
4622
  * Get all node IDs
@@ -4540,11 +4625,11 @@ var ConnectionPool = class {
4540
4625
  return Array.from(this.connections.keys());
4541
4626
  }
4542
4627
  /**
4543
- * Check if node is connected and authenticated
4628
+ * Check if node has an open WebSocket connection
4544
4629
  */
4545
4630
  isNodeConnected(nodeId) {
4546
4631
  const conn = this.connections.get(nodeId);
4547
- return conn?.state === "AUTHENTICATED";
4632
+ return conn?.state === "CONNECTED" || conn?.state === "AUTHENTICATED";
4548
4633
  }
4549
4634
  /**
4550
4635
  * Check if connected to a specific node.
@@ -4609,25 +4694,26 @@ var ConnectionPool = class {
4609
4694
  };
4610
4695
  socket.onmessage = (event) => {
4611
4696
  connection.lastSeen = Date.now();
4612
- this.handleMessage(nodeId, event);
4697
+ this.handleMessage(connection.nodeId, event);
4613
4698
  };
4614
4699
  socket.onerror = (error) => {
4615
- logger.error({ nodeId, error }, "WebSocket error");
4616
- this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
4700
+ logger.error({ nodeId: connection.nodeId, error }, "WebSocket error");
4701
+ this.emit("error", connection.nodeId, error instanceof Error ? error : new Error("WebSocket error"));
4617
4702
  };
4618
4703
  socket.onclose = () => {
4619
4704
  const wasConnected = connection.state === "AUTHENTICATED";
4620
4705
  connection.state = "DISCONNECTED";
4621
4706
  connection.socket = null;
4707
+ connection.cachedConnection = null;
4622
4708
  if (wasConnected) {
4623
- this.emit("node:disconnected", nodeId, "Connection closed");
4709
+ this.emit("node:disconnected", connection.nodeId, "Connection closed");
4624
4710
  }
4625
- this.scheduleReconnect(nodeId);
4711
+ this.scheduleReconnect(connection.nodeId);
4626
4712
  };
4627
4713
  } catch (error) {
4628
4714
  connection.state = "FAILED";
4629
- logger.error({ nodeId, error }, "Failed to connect");
4630
- this.scheduleReconnect(nodeId);
4715
+ logger.error({ nodeId: connection.nodeId, error }, "Failed to connect");
4716
+ this.scheduleReconnect(connection.nodeId);
4631
4717
  }
4632
4718
  }
4633
4719
  sendAuth(connection) {
@@ -4656,18 +4742,15 @@ var ConnectionPool = class {
4656
4742
  logger.info({ nodeId }, "Authenticated with node");
4657
4743
  this.emit("node:healthy", nodeId);
4658
4744
  this.flushPendingMessages(connection);
4659
- return;
4660
4745
  }
4661
4746
  if (message.type === "AUTH_REQUIRED") {
4662
4747
  if (this.authToken) {
4663
4748
  this.sendAuth(connection);
4664
4749
  }
4665
- return;
4666
4750
  }
4667
4751
  if (message.type === "AUTH_FAIL") {
4668
4752
  logger.error({ nodeId, error: message.error }, "Authentication failed");
4669
4753
  connection.state = "FAILED";
4670
- return;
4671
4754
  }
4672
4755
  if (message.type === "PONG") {
4673
4756
  if (message.timestamp) {
@@ -4675,10 +4758,6 @@ var ConnectionPool = class {
4675
4758
  }
4676
4759
  return;
4677
4760
  }
4678
- if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
4679
- this.emit("message", nodeId, message);
4680
- return;
4681
- }
4682
4761
  this.emit("message", nodeId, message);
4683
4762
  }
4684
4763
  flushPendingMessages(connection) {
@@ -4849,17 +4928,17 @@ var PartitionRouter = class {
4849
4928
  }
4850
4929
  return null;
4851
4930
  }
4852
- const socket = this.connectionPool.getConnection(routing.nodeId);
4853
- if (socket) {
4854
- return { nodeId: routing.nodeId, socket };
4931
+ const connection = this.connectionPool.getConnection(routing.nodeId);
4932
+ if (connection) {
4933
+ return { nodeId: routing.nodeId, connection };
4855
4934
  }
4856
4935
  const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
4857
4936
  if (partition) {
4858
4937
  for (const backupId of partition.backupNodeIds) {
4859
- const backupSocket = this.connectionPool.getConnection(backupId);
4860
- if (backupSocket) {
4938
+ const backupConnection = this.connectionPool.getConnection(backupId);
4939
+ if (backupConnection) {
4861
4940
  logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
4862
- return { nodeId: backupId, socket: backupSocket };
4941
+ return { nodeId: backupId, connection: backupConnection };
4863
4942
  }
4864
4943
  }
4865
4944
  }
@@ -5135,7 +5214,7 @@ var PartitionRouter = class {
5135
5214
  };
5136
5215
 
5137
5216
  // src/cluster/ClusterClient.ts
5138
- var ClusterClient = class {
5217
+ var _ClusterClient = class _ClusterClient {
5139
5218
  constructor(config) {
5140
5219
  this.listeners = /* @__PURE__ */ new Map();
5141
5220
  this.initialized = false;
@@ -5148,6 +5227,8 @@ var ClusterClient = class {
5148
5227
  };
5149
5228
  // Circuit breaker state per node
5150
5229
  this.circuits = /* @__PURE__ */ new Map();
5230
+ // Debounce timer for partition map requests on reconnect
5231
+ this.partitionMapRequestTimer = null;
5151
5232
  this.config = config;
5152
5233
  this.circuitBreakerConfig = {
5153
5234
  ...DEFAULT_CIRCUIT_BREAKER_CONFIG,
@@ -5239,14 +5320,14 @@ var ClusterClient = class {
5239
5320
  this.requestPartitionMapRefresh();
5240
5321
  return this.getFallbackConnection();
5241
5322
  }
5242
- const socket = this.connectionPool.getConnection(owner);
5243
- if (!socket) {
5323
+ const connection = this.connectionPool.getConnection(owner);
5324
+ if (!connection) {
5244
5325
  this.routingMetrics.fallbackRoutes++;
5245
5326
  logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
5246
5327
  return this.getFallbackConnection();
5247
5328
  }
5248
5329
  this.routingMetrics.directRoutes++;
5249
- return socket;
5330
+ return connection;
5250
5331
  }
5251
5332
  /**
5252
5333
  * Get fallback connection when owner is unavailable.
@@ -5254,10 +5335,10 @@ var ClusterClient = class {
5254
5335
  */
5255
5336
  getFallbackConnection() {
5256
5337
  const conn = this.connectionPool.getAnyHealthyConnection();
5257
- if (!conn?.socket) {
5338
+ if (!conn?.connection) {
5258
5339
  throw new Error("No healthy connection available");
5259
5340
  }
5260
- return conn.socket;
5341
+ return conn.connection;
5261
5342
  }
5262
5343
  /**
5263
5344
  * Request a partition map refresh in the background.
@@ -5268,9 +5349,23 @@ var ClusterClient = class {
5268
5349
  logger.error({ err }, "Failed to refresh partition map");
5269
5350
  });
5270
5351
  }
5352
+ /**
5353
+ * Debounce partition map requests to prevent flooding when multiple nodes
5354
+ * reconnect simultaneously. Coalesces rapid requests into a single request
5355
+ * sent to the most recently connected node.
5356
+ */
5357
+ debouncedPartitionMapRequest(nodeId) {
5358
+ if (this.partitionMapRequestTimer) {
5359
+ clearTimeout(this.partitionMapRequestTimer);
5360
+ }
5361
+ this.partitionMapRequestTimer = setTimeout(() => {
5362
+ this.partitionMapRequestTimer = null;
5363
+ this.requestPartitionMapFromNode(nodeId);
5364
+ }, _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS);
5365
+ }
5271
5366
  /**
5272
5367
  * Request partition map from a specific node.
5273
- * Called on first node connection.
5368
+ * Called on node connection via debounced handler.
5274
5369
  */
5275
5370
  requestPartitionMapFromNode(nodeId) {
5276
5371
  const socket = this.connectionPool.getConnection(nodeId);
@@ -5439,8 +5534,8 @@ var ClusterClient = class {
5439
5534
  * Send directly to partition owner
5440
5535
  */
5441
5536
  sendDirect(key, message) {
5442
- const connection = this.partitionRouter.routeToConnection(key);
5443
- if (!connection) {
5537
+ const route = this.partitionRouter.routeToConnection(key);
5538
+ if (!route) {
5444
5539
  logger.warn({ key }, "No route available for key");
5445
5540
  return false;
5446
5541
  }
@@ -5451,7 +5546,7 @@ var ClusterClient = class {
5451
5546
  mapVersion: this.partitionRouter.getMapVersion()
5452
5547
  }
5453
5548
  };
5454
- connection.socket.send(serialize3(routedMessage));
5549
+ route.connection.send(serialize3(routedMessage));
5455
5550
  return true;
5456
5551
  }
5457
5552
  /**
@@ -5555,10 +5650,23 @@ var ClusterClient = class {
5555
5650
  async refreshPartitionMap() {
5556
5651
  await this.partitionRouter.refreshPartitionMap();
5557
5652
  }
5653
+ /**
5654
+ * Force reconnect all connections in the pool.
5655
+ */
5656
+ forceReconnect() {
5657
+ this.connectionPool.close();
5658
+ this.connect().catch((err) => {
5659
+ logger.error({ err }, "ClusterClient forceReconnect failed");
5660
+ });
5661
+ }
5558
5662
  /**
5559
5663
  * Shutdown cluster client (IConnectionProvider interface).
5560
5664
  */
5561
5665
  async close() {
5666
+ if (this.partitionMapRequestTimer) {
5667
+ clearTimeout(this.partitionMapRequestTimer);
5668
+ this.partitionMapRequestTimer = null;
5669
+ }
5562
5670
  this.partitionRouter.close();
5563
5671
  this.connectionPool.close();
5564
5672
  this.initialized = false;
@@ -5581,23 +5689,23 @@ var ClusterClient = class {
5581
5689
  return this.partitionRouter;
5582
5690
  }
5583
5691
  /**
5584
- * Get any healthy WebSocket connection (IConnectionProvider interface).
5692
+ * Get any healthy connection (IConnectionProvider interface).
5585
5693
  * @throws Error if not connected
5586
5694
  */
5587
5695
  getAnyConnection() {
5588
5696
  const conn = this.connectionPool.getAnyHealthyConnection();
5589
- if (!conn?.socket) {
5697
+ if (!conn?.connection) {
5590
5698
  throw new Error("No healthy connection available");
5591
5699
  }
5592
- return conn.socket;
5700
+ return conn.connection;
5593
5701
  }
5594
5702
  /**
5595
- * Get any healthy WebSocket connection, or null if none available.
5703
+ * Get any healthy connection, or null if none available.
5596
5704
  * Use this for optional connection checks.
5597
5705
  */
5598
5706
  getAnyConnectionOrNull() {
5599
5707
  const conn = this.connectionPool.getAnyHealthyConnection();
5600
- return conn?.socket ?? null;
5708
+ return conn?.connection ?? null;
5601
5709
  }
5602
5710
  // ============================================
5603
5711
  // Circuit Breaker Methods
@@ -5688,9 +5796,7 @@ var ClusterClient = class {
5688
5796
  setupEventHandlers() {
5689
5797
  this.connectionPool.on("node:connected", (nodeId) => {
5690
5798
  logger.debug({ nodeId }, "Node connected");
5691
- if (this.partitionRouter.getMapVersion() === 0) {
5692
- this.requestPartitionMapFromNode(nodeId);
5693
- }
5799
+ this.debouncedPartitionMapRequest(nodeId);
5694
5800
  if (this.connectionPool.getConnectedNodes().length === 1) {
5695
5801
  this.emit("connected");
5696
5802
  }
@@ -5745,6 +5851,8 @@ var ClusterClient = class {
5745
5851
  });
5746
5852
  }
5747
5853
  };
5854
+ _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS = 500;
5855
+ var ClusterClient = _ClusterClient;
5748
5856
 
5749
5857
  // src/connection/SingleServerProvider.ts
5750
5858
  var DEFAULT_CONFIG = {
@@ -5760,14 +5868,18 @@ var SingleServerProvider = class {
5760
5868
  this.reconnectTimer = null;
5761
5869
  this.isClosing = false;
5762
5870
  this.listeners = /* @__PURE__ */ new Map();
5871
+ this.onlineHandler = null;
5872
+ this.offlineHandler = null;
5763
5873
  this.url = config.url;
5764
5874
  this.config = {
5765
5875
  url: config.url,
5766
5876
  maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
5767
5877
  reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
5768
5878
  backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
5769
- maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
5879
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs,
5880
+ listenNetworkEvents: config.listenNetworkEvents ?? true
5770
5881
  };
5882
+ this.setupNetworkListeners();
5771
5883
  }
5772
5884
  /**
5773
5885
  * Connect to the WebSocket server.
@@ -5776,6 +5888,9 @@ var SingleServerProvider = class {
5776
5888
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
5777
5889
  return;
5778
5890
  }
5891
+ if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false) {
5892
+ throw new Error("Browser is offline \u2014 skipping connection attempt");
5893
+ }
5779
5894
  this.isClosing = false;
5780
5895
  return new Promise((resolve, reject) => {
5781
5896
  try {
@@ -5828,7 +5943,7 @@ var SingleServerProvider = class {
5828
5943
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
5829
5944
  throw new Error("Not connected");
5830
5945
  }
5831
- return this.ws;
5946
+ return new WebSocketConnection(this.ws);
5832
5947
  }
5833
5948
  /**
5834
5949
  * Get any available connection.
@@ -5879,6 +5994,7 @@ var SingleServerProvider = class {
5879
5994
  */
5880
5995
  async close() {
5881
5996
  this.isClosing = true;
5997
+ this.teardownNetworkListeners();
5882
5998
  if (this.reconnectTimer) {
5883
5999
  clearTimeout(this.reconnectTimer);
5884
6000
  this.reconnectTimer = null;
@@ -5917,6 +6033,10 @@ var SingleServerProvider = class {
5917
6033
  clearTimeout(this.reconnectTimer);
5918
6034
  this.reconnectTimer = null;
5919
6035
  }
6036
+ if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false && this.config.listenNetworkEvents) {
6037
+ logger.info({ url: this.url }, "Browser offline \u2014 waiting for online event instead of polling");
6038
+ return;
6039
+ }
5920
6040
  if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
5921
6041
  logger.error(
5922
6042
  { attempts: this.reconnectAttempts, url: this.url },
@@ -5952,6 +6072,37 @@ var SingleServerProvider = class {
5952
6072
  delay = delay * (0.5 + Math.random());
5953
6073
  return Math.floor(delay);
5954
6074
  }
6075
+ /**
6076
+ * Force-close the current WebSocket and immediately schedule reconnection.
6077
+ * Unlike close(), this does NOT set isClosing and preserves reconnect behavior.
6078
+ * Resets the reconnect counter so the full backoff budget is available.
6079
+ *
6080
+ * Critically, this does NOT wait for the TCP close handshake (which can
6081
+ * hang 20+ seconds on a dead network). Instead it strips all handlers from
6082
+ * the old WebSocket, fires a best-effort close(), nulls the reference, and
6083
+ * schedules reconnect right away.
6084
+ */
6085
+ forceReconnect() {
6086
+ this.reconnectAttempts = 0;
6087
+ this.isClosing = false;
6088
+ if (this.reconnectTimer) {
6089
+ clearTimeout(this.reconnectTimer);
6090
+ this.reconnectTimer = null;
6091
+ }
6092
+ if (this.ws) {
6093
+ this.ws.onopen = null;
6094
+ this.ws.onclose = null;
6095
+ this.ws.onerror = null;
6096
+ this.ws.onmessage = null;
6097
+ try {
6098
+ this.ws.close();
6099
+ } catch {
6100
+ }
6101
+ this.ws = null;
6102
+ }
6103
+ this.emit("disconnected", "default");
6104
+ this.scheduleReconnect();
6105
+ }
5955
6106
  /**
5956
6107
  * Get the WebSocket URL this provider connects to.
5957
6108
  */
@@ -5971,6 +6122,43 @@ var SingleServerProvider = class {
5971
6122
  resetReconnectAttempts() {
5972
6123
  this.reconnectAttempts = 0;
5973
6124
  }
6125
+ /**
6126
+ * Listen for browser 'online' event to trigger instant reconnect
6127
+ * when network comes back. Only active in browser environments.
6128
+ */
6129
+ setupNetworkListeners() {
6130
+ if (!this.config.listenNetworkEvents) return;
6131
+ if (typeof globalThis.addEventListener !== "function") return;
6132
+ this.onlineHandler = () => {
6133
+ if (this.isClosing) return;
6134
+ if (this.isConnected()) return;
6135
+ logger.info({ url: this.url }, "Network online detected \u2014 forcing reconnect");
6136
+ this.forceReconnect();
6137
+ };
6138
+ this.offlineHandler = () => {
6139
+ if (this.isClosing) return;
6140
+ if (!this.isConnected()) return;
6141
+ logger.info({ url: this.url }, "Network offline detected \u2014 disconnecting immediately");
6142
+ this.forceReconnect();
6143
+ };
6144
+ globalThis.addEventListener("online", this.onlineHandler);
6145
+ globalThis.addEventListener("offline", this.offlineHandler);
6146
+ }
6147
+ /**
6148
+ * Remove browser network event listeners.
6149
+ */
6150
+ teardownNetworkListeners() {
6151
+ if (typeof globalThis.removeEventListener === "function") {
6152
+ if (this.onlineHandler) {
6153
+ globalThis.removeEventListener("online", this.onlineHandler);
6154
+ this.onlineHandler = null;
6155
+ }
6156
+ if (this.offlineHandler) {
6157
+ globalThis.removeEventListener("offline", this.offlineHandler);
6158
+ this.offlineHandler = null;
6159
+ }
6160
+ }
6161
+ }
5974
6162
  };
5975
6163
 
5976
6164
  // src/TopGunClient.ts
@@ -6028,7 +6216,13 @@ var TopGunClient = class {
6028
6216
  });
6029
6217
  logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
6030
6218
  } else {
6031
- const singleServerProvider = new SingleServerProvider({ url: config.serverUrl });
6219
+ const singleServerProvider = new SingleServerProvider({
6220
+ url: config.serverUrl,
6221
+ maxReconnectAttempts: config.backoff?.maxRetries,
6222
+ reconnectDelayMs: config.backoff?.initialDelayMs,
6223
+ backoffMultiplier: config.backoff?.multiplier,
6224
+ maxReconnectDelayMs: config.backoff?.maxDelayMs
6225
+ });
6032
6226
  this.syncEngine = new SyncEngine({
6033
6227
  nodeId: this.nodeId,
6034
6228
  connectionProvider: singleServerProvider,
@@ -7075,16 +7269,512 @@ var EncryptedStorageAdapter = class {
7075
7269
 
7076
7270
  // src/index.ts
7077
7271
  import { LWWMap as LWWMap4, Predicates } from "@topgunbuild/core";
7272
+
7273
+ // src/connection/HttpSyncProvider.ts
7274
+ import { serialize as serialize5, deserialize as deserialize5 } from "@topgunbuild/core";
7275
+ var HttpConnection = class {
7276
+ constructor(provider) {
7277
+ this.provider = provider;
7278
+ }
7279
+ send(data) {
7280
+ if (typeof data === "string") {
7281
+ const encoder = new TextEncoder();
7282
+ this.provider.send(encoder.encode(data));
7283
+ } else {
7284
+ this.provider.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
7285
+ }
7286
+ }
7287
+ close() {
7288
+ }
7289
+ get readyState() {
7290
+ return this.provider.isConnected() ? ConnectionReadyState.OPEN : ConnectionReadyState.CLOSED;
7291
+ }
7292
+ };
7293
+ var HttpSyncProvider = class {
7294
+ constructor(config) {
7295
+ this.listeners = /* @__PURE__ */ new Map();
7296
+ this.pollTimer = null;
7297
+ /** Queued operations to send on next poll */
7298
+ this.pendingOperations = [];
7299
+ /** Queued one-shot queries to send on next poll */
7300
+ this.pendingQueries = [];
7301
+ /** Per-map last sync timestamps for delta tracking */
7302
+ this.lastSyncTimestamps = /* @__PURE__ */ new Map();
7303
+ /** Whether the last HTTP request succeeded */
7304
+ this.connected = false;
7305
+ /** Whether we were previously connected (for reconnected event) */
7306
+ this.wasConnected = false;
7307
+ this.url = config.url.replace(/\/$/, "");
7308
+ this.clientId = config.clientId;
7309
+ this.hlc = config.hlc;
7310
+ this.authToken = config.authToken || "";
7311
+ this.pollIntervalMs = config.pollIntervalMs ?? 5e3;
7312
+ this.requestTimeoutMs = config.requestTimeoutMs ?? 3e4;
7313
+ this.syncMaps = config.syncMaps || [];
7314
+ this.fetchImpl = config.fetchImpl || globalThis.fetch.bind(globalThis);
7315
+ }
7316
+ /**
7317
+ * Connect by sending an initial sync request to verify auth and get state.
7318
+ */
7319
+ async connect() {
7320
+ try {
7321
+ await this.doSyncRequest();
7322
+ this.connected = true;
7323
+ this.wasConnected = true;
7324
+ this.emit("connected", "http");
7325
+ this.startPolling();
7326
+ } catch (err) {
7327
+ this.connected = false;
7328
+ this.emit("error", err);
7329
+ throw err;
7330
+ }
7331
+ }
7332
+ /**
7333
+ * Get connection for a specific key.
7334
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7335
+ */
7336
+ getConnection(_key) {
7337
+ return new HttpConnection(this);
7338
+ }
7339
+ /**
7340
+ * Get any available connection.
7341
+ * Returns an HttpConnection that queues operations for the next poll cycle.
7342
+ */
7343
+ getAnyConnection() {
7344
+ return new HttpConnection(this);
7345
+ }
7346
+ /**
7347
+ * Check if connected (last HTTP request succeeded).
7348
+ */
7349
+ isConnected() {
7350
+ return this.connected;
7351
+ }
7352
+ /**
7353
+ * Get connected node IDs.
7354
+ * Returns ['http'] when connected, [] when not.
7355
+ */
7356
+ getConnectedNodes() {
7357
+ return this.connected ? ["http"] : [];
7358
+ }
7359
+ /**
7360
+ * Subscribe to connection events.
7361
+ */
7362
+ on(event, handler2) {
7363
+ if (!this.listeners.has(event)) {
7364
+ this.listeners.set(event, /* @__PURE__ */ new Set());
7365
+ }
7366
+ this.listeners.get(event).add(handler2);
7367
+ }
7368
+ /**
7369
+ * Unsubscribe from connection events.
7370
+ */
7371
+ off(event, handler2) {
7372
+ this.listeners.get(event)?.delete(handler2);
7373
+ }
7374
+ /**
7375
+ * Send data via the HTTP sync provider.
7376
+ *
7377
+ * Deserializes the msgpackr binary to extract the message type and routes:
7378
+ * - OP_BATCH / CLIENT_OP: queued as operations for next poll
7379
+ * - AUTH: silently ignored (auth via HTTP header)
7380
+ * - SYNC_INIT: silently ignored (HTTP uses timestamp-based deltas)
7381
+ * - QUERY_SUB: queued as one-shot query for next poll
7382
+ * - All other types: silently dropped with debug log
7383
+ */
7384
+ send(data, _key) {
7385
+ try {
7386
+ const message = deserialize5(
7387
+ data instanceof ArrayBuffer ? new Uint8Array(data) : data
7388
+ );
7389
+ switch (message.type) {
7390
+ case "OP_BATCH":
7391
+ if (message.payload?.ops) {
7392
+ this.pendingOperations.push(...message.payload.ops);
7393
+ }
7394
+ break;
7395
+ case "CLIENT_OP":
7396
+ if (message.payload) {
7397
+ this.pendingOperations.push(message.payload);
7398
+ }
7399
+ break;
7400
+ case "AUTH":
7401
+ break;
7402
+ case "SYNC_INIT":
7403
+ break;
7404
+ case "QUERY_SUB":
7405
+ if (message.payload) {
7406
+ this.pendingQueries.push({
7407
+ queryId: message.payload.requestId || `q-${Date.now()}`,
7408
+ mapName: message.payload.mapName || message.mapName,
7409
+ filter: message.payload.query?.where || message.payload.where,
7410
+ limit: message.payload.query?.limit || message.payload.limit
7411
+ });
7412
+ }
7413
+ break;
7414
+ default:
7415
+ logger.debug(
7416
+ { type: message.type },
7417
+ "HTTP sync provider: unsupported message type dropped"
7418
+ );
7419
+ break;
7420
+ }
7421
+ } catch (err) {
7422
+ logger.warn({ err }, "HTTP sync provider: failed to deserialize message");
7423
+ }
7424
+ }
7425
+ /**
7426
+ * Force reconnect by restarting the polling loop.
7427
+ */
7428
+ forceReconnect() {
7429
+ this.stopPolling();
7430
+ this.connected = false;
7431
+ this.emit("disconnected", "default");
7432
+ this.connect().catch((err) => {
7433
+ logger.error({ err }, "HttpSyncProvider forceReconnect failed");
7434
+ });
7435
+ }
7436
+ /**
7437
+ * Close the HTTP sync provider.
7438
+ * Stops the polling loop, clears queued operations, and sets disconnected state.
7439
+ */
7440
+ async close() {
7441
+ this.stopPolling();
7442
+ this.pendingOperations = [];
7443
+ this.pendingQueries = [];
7444
+ this.connected = false;
7445
+ logger.info({ url: this.url }, "HttpSyncProvider closed");
7446
+ }
7447
+ /**
7448
+ * Update the auth token (e.g., after token refresh).
7449
+ */
7450
+ setAuthToken(token) {
7451
+ this.authToken = token;
7452
+ }
7453
+ /**
7454
+ * Send an HTTP sync request with queued operations and receive deltas.
7455
+ */
7456
+ async doSyncRequest() {
7457
+ const syncMaps = this.syncMaps.map((mapName) => ({
7458
+ mapName,
7459
+ lastSyncTimestamp: this.lastSyncTimestamps.get(mapName) || {
7460
+ millis: 0,
7461
+ counter: 0,
7462
+ nodeId: ""
7463
+ }
7464
+ }));
7465
+ const operations = this.pendingOperations.splice(0);
7466
+ const queries = this.pendingQueries.splice(0);
7467
+ const requestBody = {
7468
+ clientId: this.clientId,
7469
+ clientHlc: this.hlc.now()
7470
+ };
7471
+ if (operations.length > 0) {
7472
+ requestBody.operations = operations;
7473
+ }
7474
+ if (syncMaps.length > 0) {
7475
+ requestBody.syncMaps = syncMaps;
7476
+ }
7477
+ if (queries.length > 0) {
7478
+ requestBody.queries = queries;
7479
+ }
7480
+ const bodyBytes = serialize5(requestBody);
7481
+ const bodyBuffer = new ArrayBuffer(bodyBytes.byteLength);
7482
+ new Uint8Array(bodyBuffer).set(bodyBytes);
7483
+ const controller = new AbortController();
7484
+ const timeoutId = setTimeout(() => controller.abort(), this.requestTimeoutMs);
7485
+ try {
7486
+ const response = await this.fetchImpl(`${this.url}/sync`, {
7487
+ method: "POST",
7488
+ headers: {
7489
+ "Content-Type": "application/x-msgpack",
7490
+ "Authorization": `Bearer ${this.authToken}`
7491
+ },
7492
+ body: bodyBuffer,
7493
+ signal: controller.signal
7494
+ });
7495
+ clearTimeout(timeoutId);
7496
+ if (!response.ok) {
7497
+ throw new Error(`HTTP sync request failed: ${response.status} ${response.statusText}`);
7498
+ }
7499
+ const responseBuffer = await response.arrayBuffer();
7500
+ const syncResponse = deserialize5(new Uint8Array(responseBuffer));
7501
+ if (syncResponse.serverHlc) {
7502
+ this.hlc.update(syncResponse.serverHlc);
7503
+ }
7504
+ if (syncResponse.ack) {
7505
+ this.emit("message", "http", serialize5({
7506
+ type: "OP_ACK",
7507
+ payload: syncResponse.ack
7508
+ }));
7509
+ }
7510
+ if (syncResponse.deltas) {
7511
+ for (const delta of syncResponse.deltas) {
7512
+ this.lastSyncTimestamps.set(delta.mapName, delta.serverSyncTimestamp);
7513
+ for (const record of delta.records) {
7514
+ this.emit("message", "http", serialize5({
7515
+ type: "SERVER_EVENT",
7516
+ payload: {
7517
+ mapName: delta.mapName,
7518
+ key: record.key,
7519
+ record: record.record,
7520
+ eventType: record.eventType
7521
+ }
7522
+ }));
7523
+ }
7524
+ }
7525
+ }
7526
+ if (syncResponse.queryResults) {
7527
+ for (const result of syncResponse.queryResults) {
7528
+ this.emit("message", "http", serialize5({
7529
+ type: "QUERY_RESP",
7530
+ payload: {
7531
+ requestId: result.queryId,
7532
+ results: result.results,
7533
+ hasMore: result.hasMore,
7534
+ nextCursor: result.nextCursor
7535
+ }
7536
+ }));
7537
+ }
7538
+ }
7539
+ if (!this.connected) {
7540
+ this.connected = true;
7541
+ if (this.wasConnected) {
7542
+ this.emit("reconnected", "http");
7543
+ } else {
7544
+ this.wasConnected = true;
7545
+ this.emit("connected", "http");
7546
+ }
7547
+ }
7548
+ } catch (err) {
7549
+ clearTimeout(timeoutId);
7550
+ if (this.connected) {
7551
+ this.connected = false;
7552
+ this.emit("disconnected", "http");
7553
+ }
7554
+ if (operations.length > 0) {
7555
+ this.pendingOperations.unshift(...operations);
7556
+ }
7557
+ if (queries.length > 0) {
7558
+ this.pendingQueries.unshift(...queries);
7559
+ }
7560
+ throw err;
7561
+ }
7562
+ }
7563
+ /**
7564
+ * Start the polling loop.
7565
+ */
7566
+ startPolling() {
7567
+ if (this.pollTimer) return;
7568
+ this.pollTimer = setInterval(async () => {
7569
+ try {
7570
+ await this.doSyncRequest();
7571
+ } catch (err) {
7572
+ logger.debug({ err }, "HTTP sync poll failed");
7573
+ }
7574
+ }, this.pollIntervalMs);
7575
+ }
7576
+ /**
7577
+ * Stop the polling loop.
7578
+ */
7579
+ stopPolling() {
7580
+ if (this.pollTimer) {
7581
+ clearInterval(this.pollTimer);
7582
+ this.pollTimer = null;
7583
+ }
7584
+ }
7585
+ /**
7586
+ * Emit an event to all listeners.
7587
+ */
7588
+ emit(event, ...args) {
7589
+ const handlers = this.listeners.get(event);
7590
+ if (handlers) {
7591
+ for (const handler2 of handlers) {
7592
+ try {
7593
+ handler2(...args);
7594
+ } catch (err) {
7595
+ logger.error({ err, event }, "Error in HttpSyncProvider event handler");
7596
+ }
7597
+ }
7598
+ }
7599
+ }
7600
+ };
7601
+
7602
+ // src/connection/AutoConnectionProvider.ts
7603
+ var AutoConnectionProvider = class {
7604
+ constructor(config) {
7605
+ /** The active underlying provider */
7606
+ this.activeProvider = null;
7607
+ this.listeners = /* @__PURE__ */ new Map();
7608
+ this.config = config;
7609
+ this.maxWsAttempts = config.maxWsAttempts ?? 3;
7610
+ this.isHttpMode = config.httpOnly ?? false;
7611
+ }
7612
+ /**
7613
+ * Connect using WebSocket first, falling back to HTTP after maxWsAttempts failures.
7614
+ * If httpOnly is true, skips WebSocket entirely.
7615
+ */
7616
+ async connect() {
7617
+ if (this.isHttpMode) {
7618
+ await this.connectHttp();
7619
+ return;
7620
+ }
7621
+ const wsUrl = this.toWsUrl(this.config.url);
7622
+ let lastError = null;
7623
+ for (let attempt = 0; attempt < this.maxWsAttempts; attempt++) {
7624
+ try {
7625
+ const wsProvider = new SingleServerProvider({
7626
+ url: wsUrl,
7627
+ maxReconnectAttempts: 1,
7628
+ reconnectDelayMs: 1e3
7629
+ });
7630
+ await wsProvider.connect();
7631
+ this.activeProvider = wsProvider;
7632
+ this.proxyEvents(wsProvider);
7633
+ logger.info({ url: wsUrl }, "AutoConnectionProvider: WebSocket connected");
7634
+ return;
7635
+ } catch (err) {
7636
+ lastError = err;
7637
+ logger.debug(
7638
+ { attempt: attempt + 1, maxAttempts: this.maxWsAttempts, err: err.message },
7639
+ "AutoConnectionProvider: WebSocket attempt failed"
7640
+ );
7641
+ }
7642
+ }
7643
+ logger.info(
7644
+ { wsAttempts: this.maxWsAttempts, url: this.config.url },
7645
+ "AutoConnectionProvider: WebSocket failed, falling back to HTTP"
7646
+ );
7647
+ this.isHttpMode = true;
7648
+ await this.connectHttp();
7649
+ }
7650
+ /**
7651
+ * Connect using HTTP sync provider.
7652
+ */
7653
+ async connectHttp() {
7654
+ const httpUrl = this.toHttpUrl(this.config.url);
7655
+ const httpProvider = new HttpSyncProvider({
7656
+ url: httpUrl,
7657
+ clientId: this.config.clientId,
7658
+ hlc: this.config.hlc,
7659
+ authToken: this.config.authToken,
7660
+ pollIntervalMs: this.config.httpPollIntervalMs,
7661
+ syncMaps: this.config.syncMaps,
7662
+ fetchImpl: this.config.fetchImpl
7663
+ });
7664
+ this.activeProvider = httpProvider;
7665
+ this.proxyEvents(httpProvider);
7666
+ await httpProvider.connect();
7667
+ logger.info({ url: httpUrl }, "AutoConnectionProvider: HTTP connected");
7668
+ }
7669
+ getConnection(key) {
7670
+ if (!this.activeProvider) {
7671
+ throw new Error("Not connected");
7672
+ }
7673
+ return this.activeProvider.getConnection(key);
7674
+ }
7675
+ getAnyConnection() {
7676
+ if (!this.activeProvider) {
7677
+ throw new Error("Not connected");
7678
+ }
7679
+ return this.activeProvider.getAnyConnection();
7680
+ }
7681
+ isConnected() {
7682
+ return this.activeProvider?.isConnected() ?? false;
7683
+ }
7684
+ getConnectedNodes() {
7685
+ return this.activeProvider?.getConnectedNodes() ?? [];
7686
+ }
7687
+ on(event, handler2) {
7688
+ if (!this.listeners.has(event)) {
7689
+ this.listeners.set(event, /* @__PURE__ */ new Set());
7690
+ }
7691
+ this.listeners.get(event).add(handler2);
7692
+ if (this.activeProvider) {
7693
+ this.activeProvider.on(event, handler2);
7694
+ }
7695
+ }
7696
+ off(event, handler2) {
7697
+ this.listeners.get(event)?.delete(handler2);
7698
+ if (this.activeProvider) {
7699
+ this.activeProvider.off(event, handler2);
7700
+ }
7701
+ }
7702
+ send(data, key) {
7703
+ if (!this.activeProvider) {
7704
+ throw new Error("Not connected");
7705
+ }
7706
+ this.activeProvider.send(data, key);
7707
+ }
7708
+ /**
7709
+ * Force reconnect by delegating to the active provider.
7710
+ */
7711
+ forceReconnect() {
7712
+ if (this.activeProvider) {
7713
+ this.activeProvider.forceReconnect();
7714
+ }
7715
+ }
7716
+ /**
7717
+ * Close the active underlying provider.
7718
+ */
7719
+ async close() {
7720
+ if (this.activeProvider) {
7721
+ await this.activeProvider.close();
7722
+ this.activeProvider = null;
7723
+ }
7724
+ }
7725
+ /**
7726
+ * Whether currently using HTTP mode.
7727
+ */
7728
+ isUsingHttp() {
7729
+ return this.isHttpMode;
7730
+ }
7731
+ /**
7732
+ * Proxy events from the underlying provider to our listeners.
7733
+ */
7734
+ proxyEvents(provider) {
7735
+ const events = [
7736
+ "connected",
7737
+ "disconnected",
7738
+ "reconnected",
7739
+ "message",
7740
+ "partitionMapUpdated",
7741
+ "error"
7742
+ ];
7743
+ for (const event of events) {
7744
+ const handlers = this.listeners.get(event);
7745
+ if (handlers) {
7746
+ for (const handler2 of handlers) {
7747
+ provider.on(event, handler2);
7748
+ }
7749
+ }
7750
+ }
7751
+ }
7752
+ /**
7753
+ * Convert a URL to WebSocket URL format.
7754
+ */
7755
+ toWsUrl(url) {
7756
+ return url.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
7757
+ }
7758
+ /**
7759
+ * Convert a URL to HTTP URL format.
7760
+ */
7761
+ toHttpUrl(url) {
7762
+ return url.replace(/^ws:\/\//, "http://").replace(/^wss:\/\//, "https://");
7763
+ }
7764
+ };
7078
7765
  export {
7766
+ AutoConnectionProvider,
7079
7767
  BackpressureError,
7080
7768
  ChangeTracker,
7081
7769
  ClusterClient,
7082
7770
  ConflictResolverClient,
7083
7771
  ConnectionPool,
7772
+ ConnectionReadyState,
7084
7773
  DEFAULT_BACKPRESSURE_CONFIG,
7085
7774
  DEFAULT_CLUSTER_CONFIG,
7086
7775
  EncryptedStorageAdapter,
7087
7776
  EventJournalReader,
7777
+ HttpSyncProvider,
7088
7778
  HybridQueryHandle,
7089
7779
  IDBAdapter,
7090
7780
  LWWMap4 as LWWMap,
@@ -7101,6 +7791,7 @@ export {
7101
7791
  TopGunClient,
7102
7792
  TopicHandle,
7103
7793
  VALID_TRANSITIONS,
7794
+ WebSocketConnection,
7104
7795
  isValidTransition,
7105
7796
  logger
7106
7797
  };