@topgunbuild/client 0.1.0 → 0.2.0-alpha

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
@@ -20,12 +20,200 @@ var logger = pino({
20
20
  }
21
21
  });
22
22
 
23
+ // src/SyncState.ts
24
+ var SyncState = /* @__PURE__ */ ((SyncState2) => {
25
+ SyncState2["INITIAL"] = "INITIAL";
26
+ SyncState2["CONNECTING"] = "CONNECTING";
27
+ SyncState2["AUTHENTICATING"] = "AUTHENTICATING";
28
+ SyncState2["SYNCING"] = "SYNCING";
29
+ SyncState2["CONNECTED"] = "CONNECTED";
30
+ SyncState2["DISCONNECTED"] = "DISCONNECTED";
31
+ SyncState2["BACKOFF"] = "BACKOFF";
32
+ SyncState2["ERROR"] = "ERROR";
33
+ return SyncState2;
34
+ })(SyncState || {});
35
+ var VALID_TRANSITIONS = {
36
+ ["INITIAL" /* INITIAL */]: ["CONNECTING" /* CONNECTING */],
37
+ ["CONNECTING" /* CONNECTING */]: ["AUTHENTICATING" /* AUTHENTICATING */, "BACKOFF" /* BACKOFF */, "ERROR" /* ERROR */, "DISCONNECTED" /* DISCONNECTED */],
38
+ ["AUTHENTICATING" /* AUTHENTICATING */]: ["SYNCING" /* SYNCING */, "BACKOFF" /* BACKOFF */, "ERROR" /* ERROR */, "DISCONNECTED" /* DISCONNECTED */],
39
+ ["SYNCING" /* SYNCING */]: ["CONNECTED" /* CONNECTED */, "BACKOFF" /* BACKOFF */, "ERROR" /* ERROR */, "DISCONNECTED" /* DISCONNECTED */],
40
+ ["CONNECTED" /* CONNECTED */]: ["SYNCING" /* SYNCING */, "DISCONNECTED" /* DISCONNECTED */, "BACKOFF" /* BACKOFF */],
41
+ ["DISCONNECTED" /* DISCONNECTED */]: ["CONNECTING" /* CONNECTING */, "BACKOFF" /* BACKOFF */, "INITIAL" /* INITIAL */],
42
+ ["BACKOFF" /* BACKOFF */]: ["CONNECTING" /* CONNECTING */, "DISCONNECTED" /* DISCONNECTED */, "INITIAL" /* INITIAL */],
43
+ ["ERROR" /* ERROR */]: ["INITIAL" /* INITIAL */]
44
+ };
45
+ function isValidTransition(from, to) {
46
+ return VALID_TRANSITIONS[from]?.includes(to) ?? false;
47
+ }
48
+
49
+ // src/SyncStateMachine.ts
50
+ var DEFAULT_MAX_HISTORY_SIZE = 50;
51
+ var SyncStateMachine = class {
52
+ constructor(config = {}) {
53
+ this.state = "INITIAL" /* INITIAL */;
54
+ this.listeners = /* @__PURE__ */ new Set();
55
+ this.history = [];
56
+ this.maxHistorySize = config.maxHistorySize ?? DEFAULT_MAX_HISTORY_SIZE;
57
+ }
58
+ /**
59
+ * Attempt to transition to a new state.
60
+ * @param to The target state
61
+ * @returns true if the transition was valid and executed, false otherwise
62
+ */
63
+ transition(to) {
64
+ const from = this.state;
65
+ if (from === to) {
66
+ return true;
67
+ }
68
+ if (!isValidTransition(from, to)) {
69
+ logger.warn(
70
+ { from, to, currentHistory: this.getHistory(5) },
71
+ `Invalid state transition attempted: ${from} \u2192 ${to}`
72
+ );
73
+ return false;
74
+ }
75
+ this.state = to;
76
+ const event = {
77
+ from,
78
+ to,
79
+ timestamp: Date.now()
80
+ };
81
+ this.history.push(event);
82
+ if (this.history.length > this.maxHistorySize) {
83
+ this.history.shift();
84
+ }
85
+ for (const listener of this.listeners) {
86
+ try {
87
+ listener(event);
88
+ } catch (err) {
89
+ logger.error({ err, event }, "State change listener threw an error");
90
+ }
91
+ }
92
+ logger.debug({ from, to }, `State transition: ${from} \u2192 ${to}`);
93
+ return true;
94
+ }
95
+ /**
96
+ * Get the current state.
97
+ */
98
+ getState() {
99
+ return this.state;
100
+ }
101
+ /**
102
+ * Check if a transition from the current state to the target state is valid.
103
+ * @param to The target state to check
104
+ */
105
+ canTransition(to) {
106
+ return this.state === to || isValidTransition(this.state, to);
107
+ }
108
+ /**
109
+ * Subscribe to state change events.
110
+ * @param listener Callback function to be called on each state change
111
+ * @returns An unsubscribe function
112
+ */
113
+ onStateChange(listener) {
114
+ this.listeners.add(listener);
115
+ return () => {
116
+ this.listeners.delete(listener);
117
+ };
118
+ }
119
+ /**
120
+ * Get the state transition history.
121
+ * @param limit Maximum number of entries to return (default: all)
122
+ * @returns Array of state change events, oldest first
123
+ */
124
+ getHistory(limit) {
125
+ if (limit === void 0 || limit >= this.history.length) {
126
+ return [...this.history];
127
+ }
128
+ return this.history.slice(-limit);
129
+ }
130
+ /**
131
+ * Reset the state machine to INITIAL state.
132
+ * This is a forced reset that bypasses normal transition validation.
133
+ * Use for testing or hard resets after fatal errors.
134
+ * @param clearHistory If true, also clears the transition history (default: true)
135
+ */
136
+ reset(clearHistory = true) {
137
+ const from = this.state;
138
+ this.state = "INITIAL" /* INITIAL */;
139
+ if (clearHistory) {
140
+ this.history = [];
141
+ } else {
142
+ const event = {
143
+ from,
144
+ to: "INITIAL" /* INITIAL */,
145
+ timestamp: Date.now()
146
+ };
147
+ this.history.push(event);
148
+ if (this.history.length > this.maxHistorySize) {
149
+ this.history.shift();
150
+ }
151
+ for (const listener of this.listeners) {
152
+ try {
153
+ listener(event);
154
+ } catch (err) {
155
+ logger.error({ err, event }, "State change listener threw an error during reset");
156
+ }
157
+ }
158
+ }
159
+ logger.info({ from }, "State machine reset to INITIAL");
160
+ }
161
+ /**
162
+ * Check if the state machine is in a "connected" state
163
+ * (either SYNCING or CONNECTED)
164
+ */
165
+ isConnected() {
166
+ return this.state === "CONNECTED" /* CONNECTED */ || this.state === "SYNCING" /* SYNCING */;
167
+ }
168
+ /**
169
+ * Check if the state machine is in a state where operations can be sent
170
+ * (authenticated and connected)
171
+ */
172
+ isReady() {
173
+ return this.state === "CONNECTED" /* CONNECTED */;
174
+ }
175
+ /**
176
+ * Check if the state machine is currently attempting to connect
177
+ */
178
+ isConnecting() {
179
+ return this.state === "CONNECTING" /* CONNECTING */ || this.state === "AUTHENTICATING" /* AUTHENTICATING */ || this.state === "SYNCING" /* SYNCING */;
180
+ }
181
+ };
182
+
183
+ // src/errors/BackpressureError.ts
184
+ var BackpressureError = class _BackpressureError extends Error {
185
+ constructor(pendingCount, maxPending) {
186
+ super(
187
+ `Backpressure limit reached: ${pendingCount}/${maxPending} pending operations. Wait for acknowledgments or increase maxPendingOps.`
188
+ );
189
+ this.pendingCount = pendingCount;
190
+ this.maxPending = maxPending;
191
+ this.name = "BackpressureError";
192
+ if (Error.captureStackTrace) {
193
+ Error.captureStackTrace(this, _BackpressureError);
194
+ }
195
+ }
196
+ };
197
+
198
+ // src/BackpressureConfig.ts
199
+ var DEFAULT_BACKPRESSURE_CONFIG = {
200
+ maxPendingOps: 1e3,
201
+ strategy: "pause",
202
+ highWaterMark: 0.8,
203
+ lowWaterMark: 0.5
204
+ };
205
+
23
206
  // src/SyncEngine.ts
207
+ var DEFAULT_BACKOFF_CONFIG = {
208
+ initialDelayMs: 1e3,
209
+ maxDelayMs: 3e4,
210
+ multiplier: 2,
211
+ jitter: true,
212
+ maxRetries: 10
213
+ };
24
214
  var SyncEngine = class {
25
215
  constructor(config) {
26
216
  this.websocket = null;
27
- this.isOnline = false;
28
- this.isAuthenticated = false;
29
217
  this.opLog = [];
30
218
  this.maps = /* @__PURE__ */ new Map();
31
219
  this.queries = /* @__PURE__ */ new Map();
@@ -36,25 +224,95 @@ var SyncEngine = class {
36
224
  // NodeJS.Timeout
37
225
  this.authToken = null;
38
226
  this.tokenProvider = null;
227
+ this.backoffAttempt = 0;
228
+ this.heartbeatInterval = null;
229
+ this.lastPongReceived = Date.now();
230
+ this.lastRoundTripTime = null;
231
+ this.backpressurePaused = false;
232
+ this.waitingForCapacity = [];
233
+ this.highWaterMarkEmitted = false;
234
+ this.backpressureListeners = /* @__PURE__ */ new Map();
39
235
  this.nodeId = config.nodeId;
40
236
  this.serverUrl = config.serverUrl;
41
237
  this.storageAdapter = config.storageAdapter;
42
- this.reconnectInterval = config.reconnectInterval || 5e3;
43
238
  this.hlc = new HLC(this.nodeId);
239
+ this.stateMachine = new SyncStateMachine();
240
+ this.heartbeatConfig = {
241
+ intervalMs: config.heartbeat?.intervalMs ?? 5e3,
242
+ timeoutMs: config.heartbeat?.timeoutMs ?? 15e3,
243
+ enabled: config.heartbeat?.enabled ?? true
244
+ };
245
+ this.backoffConfig = {
246
+ ...DEFAULT_BACKOFF_CONFIG,
247
+ ...config.backoff
248
+ };
249
+ this.backpressureConfig = {
250
+ ...DEFAULT_BACKPRESSURE_CONFIG,
251
+ ...config.backpressure
252
+ };
44
253
  this.initConnection();
45
254
  this.loadOpLog();
46
255
  }
256
+ // ============================================
257
+ // State Machine Public API
258
+ // ============================================
259
+ /**
260
+ * Get the current connection state
261
+ */
262
+ getConnectionState() {
263
+ return this.stateMachine.getState();
264
+ }
265
+ /**
266
+ * Subscribe to connection state changes
267
+ * @returns Unsubscribe function
268
+ */
269
+ onConnectionStateChange(listener) {
270
+ return this.stateMachine.onStateChange(listener);
271
+ }
272
+ /**
273
+ * Get state machine history for debugging
274
+ */
275
+ getStateHistory(limit) {
276
+ return this.stateMachine.getHistory(limit);
277
+ }
278
+ // ============================================
279
+ // Internal State Helpers (replace boolean flags)
280
+ // ============================================
281
+ /**
282
+ * Check if WebSocket is connected (but may not be authenticated yet)
283
+ */
284
+ isOnline() {
285
+ const state = this.stateMachine.getState();
286
+ return state === "CONNECTING" /* CONNECTING */ || state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
287
+ }
288
+ /**
289
+ * Check if fully authenticated and ready for operations
290
+ */
291
+ isAuthenticated() {
292
+ const state = this.stateMachine.getState();
293
+ return state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
294
+ }
295
+ /**
296
+ * Check if fully connected and synced
297
+ */
298
+ isConnected() {
299
+ return this.stateMachine.getState() === "CONNECTED" /* CONNECTED */;
300
+ }
301
+ // ============================================
302
+ // Connection Management
303
+ // ============================================
47
304
  initConnection() {
305
+ this.stateMachine.transition("CONNECTING" /* CONNECTING */);
48
306
  this.websocket = new WebSocket(this.serverUrl);
49
307
  this.websocket.binaryType = "arraybuffer";
50
308
  this.websocket.onopen = () => {
51
309
  if (this.authToken || this.tokenProvider) {
52
310
  logger.info("WebSocket connected. Sending auth...");
53
- this.isOnline = true;
311
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
54
312
  this.sendAuth();
55
313
  } else {
56
314
  logger.info("WebSocket connected. Waiting for auth token...");
57
- this.isOnline = true;
315
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
58
316
  }
59
317
  };
60
318
  this.websocket.onmessage = (event) => {
@@ -72,9 +330,9 @@ var SyncEngine = class {
72
330
  this.handleServerMessage(message);
73
331
  };
74
332
  this.websocket.onclose = () => {
75
- logger.info("WebSocket disconnected. Retrying...");
76
- this.isOnline = false;
77
- this.isAuthenticated = false;
333
+ logger.info("WebSocket disconnected.");
334
+ this.stopHeartbeat();
335
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
78
336
  this.scheduleReconnect();
79
337
  };
80
338
  this.websocket.onerror = (error) => {
@@ -82,10 +340,41 @@ var SyncEngine = class {
82
340
  };
83
341
  }
84
342
  scheduleReconnect() {
85
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
343
+ if (this.reconnectTimer) {
344
+ clearTimeout(this.reconnectTimer);
345
+ this.reconnectTimer = null;
346
+ }
347
+ if (this.backoffAttempt >= this.backoffConfig.maxRetries) {
348
+ logger.error(
349
+ { attempts: this.backoffAttempt },
350
+ "Max reconnection attempts reached. Entering ERROR state."
351
+ );
352
+ this.stateMachine.transition("ERROR" /* ERROR */);
353
+ return;
354
+ }
355
+ this.stateMachine.transition("BACKOFF" /* BACKOFF */);
356
+ const delay = this.calculateBackoffDelay();
357
+ logger.info({ delay, attempt: this.backoffAttempt }, `Backing off for ${delay}ms`);
86
358
  this.reconnectTimer = setTimeout(() => {
359
+ this.reconnectTimer = null;
360
+ this.backoffAttempt++;
87
361
  this.initConnection();
88
- }, this.reconnectInterval);
362
+ }, delay);
363
+ }
364
+ calculateBackoffDelay() {
365
+ const { initialDelayMs, maxDelayMs, multiplier, jitter } = this.backoffConfig;
366
+ let delay = initialDelayMs * Math.pow(multiplier, this.backoffAttempt);
367
+ delay = Math.min(delay, maxDelayMs);
368
+ if (jitter) {
369
+ delay = delay * (0.5 + Math.random());
370
+ }
371
+ return Math.floor(delay);
372
+ }
373
+ /**
374
+ * Reset backoff counter (called on successful connection)
375
+ */
376
+ resetBackoff() {
377
+ this.backoffAttempt = 0;
89
378
  }
90
379
  async loadOpLog() {
91
380
  const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
@@ -109,6 +398,7 @@ var SyncEngine = class {
109
398
  this.maps.set(mapName, map);
110
399
  }
111
400
  async recordOperation(mapName, opType, key, data) {
401
+ await this.checkBackpressure();
112
402
  const opLogEntry = {
113
403
  mapName,
114
404
  opType,
@@ -122,9 +412,11 @@ var SyncEngine = class {
122
412
  const id = await this.storageAdapter.appendOpLog(opLogEntry);
123
413
  opLogEntry.id = String(id);
124
414
  this.opLog.push(opLogEntry);
125
- if (this.isOnline && this.isAuthenticated) {
415
+ this.checkHighWaterMark();
416
+ if (this.isAuthenticated()) {
126
417
  this.syncPendingOperations();
127
418
  }
419
+ return opLogEntry.id;
128
420
  }
129
421
  syncPendingOperations() {
130
422
  const pending = this.opLog.filter((op) => !op.synced);
@@ -142,32 +434,47 @@ var SyncEngine = class {
142
434
  startMerkleSync() {
143
435
  for (const [mapName, map] of this.maps) {
144
436
  if (map instanceof LWWMap) {
145
- logger.info({ mapName }, "Starting Merkle sync for map");
437
+ logger.info({ mapName }, "Starting Merkle sync for LWWMap");
146
438
  this.websocket?.send(serialize({
147
439
  type: "SYNC_INIT",
148
440
  mapName,
149
441
  lastSyncTimestamp: this.lastSyncTimestamp
150
442
  }));
443
+ } else if (map instanceof ORMap) {
444
+ logger.info({ mapName }, "Starting Merkle sync for ORMap");
445
+ const tree = map.getMerkleTree();
446
+ const rootHash = tree.getRootHash();
447
+ const bucketHashes = tree.getBuckets("");
448
+ this.websocket?.send(serialize({
449
+ type: "ORMAP_SYNC_INIT",
450
+ mapName,
451
+ rootHash,
452
+ bucketHashes,
453
+ lastSyncTimestamp: this.lastSyncTimestamp
454
+ }));
151
455
  }
152
456
  }
153
457
  }
154
458
  setAuthToken(token) {
155
459
  this.authToken = token;
156
460
  this.tokenProvider = null;
157
- if (this.isOnline) {
461
+ const state = this.stateMachine.getState();
462
+ if (state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "CONNECTING" /* CONNECTING */) {
158
463
  this.sendAuth();
159
- } else {
464
+ } else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
465
+ logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
160
466
  if (this.reconnectTimer) {
161
- logger.info("Auth token set during backoff. Reconnecting immediately.");
162
467
  clearTimeout(this.reconnectTimer);
163
468
  this.reconnectTimer = null;
164
- this.initConnection();
165
469
  }
470
+ this.resetBackoff();
471
+ this.initConnection();
166
472
  }
167
473
  }
168
474
  setTokenProvider(provider) {
169
475
  this.tokenProvider = provider;
170
- if (this.isOnline && !this.isAuthenticated) {
476
+ const state = this.stateMachine.getState();
477
+ if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
171
478
  this.sendAuth();
172
479
  }
173
480
  }
@@ -192,19 +499,19 @@ var SyncEngine = class {
192
499
  }
193
500
  subscribeToQuery(query) {
194
501
  this.queries.set(query.id, query);
195
- if (this.isOnline && this.isAuthenticated) {
502
+ if (this.isAuthenticated()) {
196
503
  this.sendQuerySubscription(query);
197
504
  }
198
505
  }
199
506
  subscribeToTopic(topic, handle) {
200
507
  this.topics.set(topic, handle);
201
- if (this.isOnline && this.isAuthenticated) {
508
+ if (this.isAuthenticated()) {
202
509
  this.sendTopicSubscription(topic);
203
510
  }
204
511
  }
205
512
  unsubscribeFromTopic(topic) {
206
513
  this.topics.delete(topic);
207
- if (this.isOnline && this.isAuthenticated) {
514
+ if (this.isAuthenticated()) {
208
515
  this.websocket?.send(serialize({
209
516
  type: "TOPIC_UNSUB",
210
517
  payload: { topic }
@@ -212,7 +519,7 @@ var SyncEngine = class {
212
519
  }
213
520
  }
214
521
  publishTopic(topic, data) {
215
- if (this.isOnline && this.isAuthenticated) {
522
+ if (this.isAuthenticated()) {
216
523
  this.websocket?.send(serialize({
217
524
  type: "TOPIC_PUB",
218
525
  payload: { topic, data }
@@ -261,7 +568,7 @@ var SyncEngine = class {
261
568
  }
262
569
  unsubscribeFromQuery(queryId) {
263
570
  this.queries.delete(queryId);
264
- if (this.isOnline && this.isAuthenticated) {
571
+ if (this.isAuthenticated()) {
265
572
  this.websocket?.send(serialize({
266
573
  type: "QUERY_UNSUB",
267
574
  payload: { queryId }
@@ -279,7 +586,7 @@ var SyncEngine = class {
279
586
  }));
280
587
  }
281
588
  requestLock(name, requestId, ttl) {
282
- if (!this.isOnline || !this.isAuthenticated) {
589
+ if (!this.isAuthenticated()) {
283
590
  return Promise.reject(new Error("Not connected or authenticated"));
284
591
  }
285
592
  return new Promise((resolve, reject) => {
@@ -303,7 +610,7 @@ var SyncEngine = class {
303
610
  });
304
611
  }
305
612
  releaseLock(name, requestId, fencingToken) {
306
- if (!this.isOnline) return Promise.resolve(false);
613
+ if (!this.isOnline()) return Promise.resolve(false);
307
614
  return new Promise((resolve, reject) => {
308
615
  const timer = setTimeout(() => {
309
616
  if (this.pendingLockRequests.has(requestId)) {
@@ -331,10 +638,12 @@ var SyncEngine = class {
331
638
  break;
332
639
  case "AUTH_ACK": {
333
640
  logger.info("Authenticated successfully");
334
- const wasAuthenticated = this.isAuthenticated;
335
- this.isAuthenticated = true;
641
+ const wasAuthenticated = this.isAuthenticated();
642
+ this.stateMachine.transition("SYNCING" /* SYNCING */);
643
+ this.resetBackoff();
336
644
  this.syncPendingOperations();
337
645
  if (!wasAuthenticated) {
646
+ this.startHeartbeat();
338
647
  this.startMerkleSync();
339
648
  for (const query of this.queries.values()) {
340
649
  this.sendQuerySubscription(query);
@@ -343,19 +652,27 @@ var SyncEngine = class {
343
652
  this.sendTopicSubscription(topic);
344
653
  }
345
654
  }
655
+ this.stateMachine.transition("CONNECTED" /* CONNECTED */);
656
+ break;
657
+ }
658
+ case "PONG": {
659
+ this.handlePong(message);
346
660
  break;
347
661
  }
348
662
  case "AUTH_FAIL":
349
663
  logger.error({ error: message.error }, "Authentication failed");
350
- this.isAuthenticated = false;
351
664
  this.authToken = null;
352
665
  break;
353
666
  case "OP_ACK": {
354
667
  const { lastId } = message.payload;
355
668
  logger.info({ lastId }, "Received ACK for ops");
356
669
  let maxSyncedId = -1;
670
+ let ackedCount = 0;
357
671
  this.opLog.forEach((op) => {
358
672
  if (op.id && op.id <= lastId) {
673
+ if (!op.synced) {
674
+ ackedCount++;
675
+ }
359
676
  op.synced = true;
360
677
  const idNum = parseInt(op.id, 10);
361
678
  if (!isNaN(idNum) && idNum > maxSyncedId) {
@@ -366,6 +683,9 @@ var SyncEngine = class {
366
683
  if (maxSyncedId !== -1) {
367
684
  this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
368
685
  }
686
+ if (ackedCount > 0) {
687
+ this.checkLowWaterMark();
688
+ }
369
689
  break;
370
690
  }
371
691
  case "LOCK_GRANTED": {
@@ -520,6 +840,96 @@ var SyncEngine = class {
520
840
  }
521
841
  break;
522
842
  }
843
+ // ============ ORMap Sync Message Handlers ============
844
+ case "ORMAP_SYNC_RESP_ROOT": {
845
+ const { mapName, rootHash, timestamp } = message.payload;
846
+ const map = this.maps.get(mapName);
847
+ if (map instanceof ORMap) {
848
+ const localTree = map.getMerkleTree();
849
+ const localRootHash = localTree.getRootHash();
850
+ if (localRootHash !== rootHash) {
851
+ logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
852
+ this.websocket?.send(serialize({
853
+ type: "ORMAP_MERKLE_REQ_BUCKET",
854
+ payload: { mapName, path: "" }
855
+ }));
856
+ } else {
857
+ logger.info({ mapName }, "ORMap is in sync");
858
+ }
859
+ }
860
+ if (timestamp) {
861
+ this.hlc.update(timestamp);
862
+ this.lastSyncTimestamp = timestamp.millis;
863
+ await this.saveOpLog();
864
+ }
865
+ break;
866
+ }
867
+ case "ORMAP_SYNC_RESP_BUCKETS": {
868
+ const { mapName, path, buckets } = message.payload;
869
+ const map = this.maps.get(mapName);
870
+ if (map instanceof ORMap) {
871
+ const tree = map.getMerkleTree();
872
+ const localBuckets = tree.getBuckets(path);
873
+ for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
874
+ const localHash = localBuckets[bucketKey] || 0;
875
+ if (localHash !== remoteHash) {
876
+ const newPath = path + bucketKey;
877
+ this.websocket?.send(serialize({
878
+ type: "ORMAP_MERKLE_REQ_BUCKET",
879
+ payload: { mapName, path: newPath }
880
+ }));
881
+ }
882
+ }
883
+ for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
884
+ if (!(bucketKey in buckets) && localHash !== 0) {
885
+ const newPath = path + bucketKey;
886
+ const keys = tree.getKeysInBucket(newPath);
887
+ if (keys.length > 0) {
888
+ this.pushORMapDiff(mapName, keys, map);
889
+ }
890
+ }
891
+ }
892
+ }
893
+ break;
894
+ }
895
+ case "ORMAP_SYNC_RESP_LEAF": {
896
+ const { mapName, entries } = message.payload;
897
+ const map = this.maps.get(mapName);
898
+ if (map instanceof ORMap) {
899
+ let totalAdded = 0;
900
+ let totalUpdated = 0;
901
+ for (const entry of entries) {
902
+ const { key, records, tombstones } = entry;
903
+ const result = map.mergeKey(key, records, tombstones);
904
+ totalAdded += result.added;
905
+ totalUpdated += result.updated;
906
+ }
907
+ if (totalAdded > 0 || totalUpdated > 0) {
908
+ logger.info({ mapName, added: totalAdded, updated: totalUpdated }, "Synced ORMap records from server");
909
+ }
910
+ const keysToCheck = entries.map((e) => e.key);
911
+ await this.pushORMapDiff(mapName, keysToCheck, map);
912
+ }
913
+ break;
914
+ }
915
+ case "ORMAP_DIFF_RESPONSE": {
916
+ const { mapName, entries } = message.payload;
917
+ const map = this.maps.get(mapName);
918
+ if (map instanceof ORMap) {
919
+ let totalAdded = 0;
920
+ let totalUpdated = 0;
921
+ for (const entry of entries) {
922
+ const { key, records, tombstones } = entry;
923
+ const result = map.mergeKey(key, records, tombstones);
924
+ totalAdded += result.added;
925
+ totalUpdated += result.updated;
926
+ }
927
+ if (totalAdded > 0 || totalUpdated > 0) {
928
+ logger.info({ mapName, added: totalAdded, updated: totalUpdated }, "Merged ORMap diff from server");
929
+ }
930
+ }
931
+ break;
932
+ }
523
933
  }
524
934
  if (message.timestamp) {
525
935
  this.hlc.update(message.timestamp);
@@ -534,6 +944,7 @@ var SyncEngine = class {
534
944
  * Closes the WebSocket connection and cleans up resources.
535
945
  */
536
946
  close() {
947
+ this.stopHeartbeat();
537
948
  if (this.reconnectTimer) {
538
949
  clearTimeout(this.reconnectTimer);
539
950
  this.reconnectTimer = null;
@@ -543,10 +954,19 @@ var SyncEngine = class {
543
954
  this.websocket.close();
544
955
  this.websocket = null;
545
956
  }
546
- this.isOnline = false;
547
- this.isAuthenticated = false;
957
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
548
958
  logger.info("SyncEngine closed");
549
959
  }
960
+ /**
961
+ * Reset the state machine and connection.
962
+ * Use after fatal errors to start fresh.
963
+ */
964
+ resetConnection() {
965
+ this.close();
966
+ this.stateMachine.reset();
967
+ this.resetBackoff();
968
+ this.initConnection();
969
+ }
550
970
  async resetMap(mapName) {
551
971
  const map = this.maps.get(mapName);
552
972
  if (map) {
@@ -563,6 +983,297 @@ var SyncEngine = class {
563
983
  }
564
984
  logger.info({ mapName, removedStorageCount: mapKeys.length }, "Reset map: Cleared memory and storage");
565
985
  }
986
+ // ============ Heartbeat Methods ============
987
+ /**
988
+ * Starts the heartbeat mechanism after successful connection.
989
+ */
990
+ startHeartbeat() {
991
+ if (!this.heartbeatConfig.enabled) {
992
+ return;
993
+ }
994
+ this.stopHeartbeat();
995
+ this.lastPongReceived = Date.now();
996
+ this.heartbeatInterval = setInterval(() => {
997
+ this.sendPing();
998
+ this.checkHeartbeatTimeout();
999
+ }, this.heartbeatConfig.intervalMs);
1000
+ logger.info({ intervalMs: this.heartbeatConfig.intervalMs }, "Heartbeat started");
1001
+ }
1002
+ /**
1003
+ * Stops the heartbeat mechanism.
1004
+ */
1005
+ stopHeartbeat() {
1006
+ if (this.heartbeatInterval) {
1007
+ clearInterval(this.heartbeatInterval);
1008
+ this.heartbeatInterval = null;
1009
+ logger.info("Heartbeat stopped");
1010
+ }
1011
+ }
1012
+ /**
1013
+ * Sends a PING message to the server.
1014
+ */
1015
+ sendPing() {
1016
+ if (this.websocket?.readyState === WebSocket.OPEN) {
1017
+ const pingMessage = {
1018
+ type: "PING",
1019
+ timestamp: Date.now()
1020
+ };
1021
+ this.websocket.send(serialize(pingMessage));
1022
+ }
1023
+ }
1024
+ /**
1025
+ * Handles incoming PONG message from server.
1026
+ */
1027
+ handlePong(msg) {
1028
+ const now = Date.now();
1029
+ this.lastPongReceived = now;
1030
+ this.lastRoundTripTime = now - msg.timestamp;
1031
+ logger.debug({
1032
+ rtt: this.lastRoundTripTime,
1033
+ serverTime: msg.serverTime,
1034
+ clockSkew: msg.serverTime - (msg.timestamp + this.lastRoundTripTime / 2)
1035
+ }, "Received PONG");
1036
+ }
1037
+ /**
1038
+ * Checks if heartbeat has timed out and triggers reconnection if needed.
1039
+ */
1040
+ checkHeartbeatTimeout() {
1041
+ const now = Date.now();
1042
+ const timeSinceLastPong = now - this.lastPongReceived;
1043
+ if (timeSinceLastPong > this.heartbeatConfig.timeoutMs) {
1044
+ logger.warn({
1045
+ timeSinceLastPong,
1046
+ timeoutMs: this.heartbeatConfig.timeoutMs
1047
+ }, "Heartbeat timeout - triggering reconnection");
1048
+ this.stopHeartbeat();
1049
+ if (this.websocket) {
1050
+ this.websocket.close();
1051
+ }
1052
+ }
1053
+ }
1054
+ /**
1055
+ * Returns the last measured round-trip time in milliseconds.
1056
+ * Returns null if no PONG has been received yet.
1057
+ */
1058
+ getLastRoundTripTime() {
1059
+ return this.lastRoundTripTime;
1060
+ }
1061
+ /**
1062
+ * Returns true if the connection is considered healthy based on heartbeat.
1063
+ * A connection is healthy if it's online, authenticated, and has received
1064
+ * a PONG within the timeout window.
1065
+ */
1066
+ isConnectionHealthy() {
1067
+ if (!this.isOnline() || !this.isAuthenticated()) {
1068
+ return false;
1069
+ }
1070
+ if (!this.heartbeatConfig.enabled) {
1071
+ return true;
1072
+ }
1073
+ const timeSinceLastPong = Date.now() - this.lastPongReceived;
1074
+ return timeSinceLastPong < this.heartbeatConfig.timeoutMs;
1075
+ }
1076
+ // ============ ORMap Sync Methods ============
1077
+ /**
1078
+ * Push local ORMap diff to server for the given keys.
1079
+ * Sends local records and tombstones that the server might not have.
1080
+ */
1081
+ async pushORMapDiff(mapName, keys, map) {
1082
+ const entries = [];
1083
+ const snapshot = map.getSnapshot();
1084
+ for (const key of keys) {
1085
+ const recordsMap = map.getRecordsMap(key);
1086
+ if (recordsMap && recordsMap.size > 0) {
1087
+ const records = Array.from(recordsMap.values());
1088
+ const tombstones = [];
1089
+ for (const tag of snapshot.tombstones) {
1090
+ tombstones.push(tag);
1091
+ }
1092
+ entries.push({
1093
+ key,
1094
+ records,
1095
+ tombstones
1096
+ });
1097
+ }
1098
+ }
1099
+ if (entries.length > 0) {
1100
+ this.websocket?.send(serialize({
1101
+ type: "ORMAP_PUSH_DIFF",
1102
+ payload: {
1103
+ mapName,
1104
+ entries
1105
+ }
1106
+ }));
1107
+ logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
1108
+ }
1109
+ }
1110
+ // ============ Backpressure Methods ============
1111
+ /**
1112
+ * Get the current number of pending (unsynced) operations.
1113
+ */
1114
+ getPendingOpsCount() {
1115
+ return this.opLog.filter((op) => !op.synced).length;
1116
+ }
1117
+ /**
1118
+ * Get the current backpressure status.
1119
+ */
1120
+ getBackpressureStatus() {
1121
+ const pending = this.getPendingOpsCount();
1122
+ const max = this.backpressureConfig.maxPendingOps;
1123
+ return {
1124
+ pending,
1125
+ max,
1126
+ percentage: max > 0 ? pending / max : 0,
1127
+ isPaused: this.backpressurePaused,
1128
+ strategy: this.backpressureConfig.strategy
1129
+ };
1130
+ }
1131
+ /**
1132
+ * Returns true if writes are currently paused due to backpressure.
1133
+ */
1134
+ isBackpressurePaused() {
1135
+ return this.backpressurePaused;
1136
+ }
1137
+ /**
1138
+ * Subscribe to backpressure events.
1139
+ * @param event Event name: 'backpressure:high', 'backpressure:low', 'backpressure:paused', 'backpressure:resumed', 'operation:dropped'
1140
+ * @param listener Callback function
1141
+ * @returns Unsubscribe function
1142
+ */
1143
+ onBackpressure(event, listener) {
1144
+ if (!this.backpressureListeners.has(event)) {
1145
+ this.backpressureListeners.set(event, /* @__PURE__ */ new Set());
1146
+ }
1147
+ this.backpressureListeners.get(event).add(listener);
1148
+ return () => {
1149
+ this.backpressureListeners.get(event)?.delete(listener);
1150
+ };
1151
+ }
1152
+ /**
1153
+ * Emit a backpressure event to all listeners.
1154
+ */
1155
+ emitBackpressureEvent(event, data) {
1156
+ const listeners = this.backpressureListeners.get(event);
1157
+ if (listeners) {
1158
+ for (const listener of listeners) {
1159
+ try {
1160
+ listener(data);
1161
+ } catch (err) {
1162
+ logger.error({ err, event }, "Error in backpressure event listener");
1163
+ }
1164
+ }
1165
+ }
1166
+ }
1167
+ /**
1168
+ * Check backpressure before adding a new operation.
1169
+ * May pause, throw, or drop depending on strategy.
1170
+ */
1171
+ async checkBackpressure() {
1172
+ const pendingCount = this.getPendingOpsCount();
1173
+ if (pendingCount < this.backpressureConfig.maxPendingOps) {
1174
+ return;
1175
+ }
1176
+ switch (this.backpressureConfig.strategy) {
1177
+ case "pause":
1178
+ await this.waitForCapacity();
1179
+ break;
1180
+ case "throw":
1181
+ throw new BackpressureError(
1182
+ pendingCount,
1183
+ this.backpressureConfig.maxPendingOps
1184
+ );
1185
+ case "drop-oldest":
1186
+ this.dropOldestOp();
1187
+ break;
1188
+ }
1189
+ }
1190
+ /**
1191
+ * Check high water mark and emit event if threshold reached.
1192
+ */
1193
+ checkHighWaterMark() {
1194
+ const pendingCount = this.getPendingOpsCount();
1195
+ const threshold = Math.floor(
1196
+ this.backpressureConfig.maxPendingOps * this.backpressureConfig.highWaterMark
1197
+ );
1198
+ if (pendingCount >= threshold && !this.highWaterMarkEmitted) {
1199
+ this.highWaterMarkEmitted = true;
1200
+ logger.warn(
1201
+ { pending: pendingCount, max: this.backpressureConfig.maxPendingOps },
1202
+ "Backpressure high water mark reached"
1203
+ );
1204
+ this.emitBackpressureEvent("backpressure:high", {
1205
+ pending: pendingCount,
1206
+ max: this.backpressureConfig.maxPendingOps
1207
+ });
1208
+ }
1209
+ }
1210
+ /**
1211
+ * Check low water mark and resume paused writes if threshold reached.
1212
+ */
1213
+ checkLowWaterMark() {
1214
+ const pendingCount = this.getPendingOpsCount();
1215
+ const lowThreshold = Math.floor(
1216
+ this.backpressureConfig.maxPendingOps * this.backpressureConfig.lowWaterMark
1217
+ );
1218
+ const highThreshold = Math.floor(
1219
+ this.backpressureConfig.maxPendingOps * this.backpressureConfig.highWaterMark
1220
+ );
1221
+ if (pendingCount < highThreshold && this.highWaterMarkEmitted) {
1222
+ this.highWaterMarkEmitted = false;
1223
+ }
1224
+ if (pendingCount <= lowThreshold) {
1225
+ if (this.backpressurePaused) {
1226
+ this.backpressurePaused = false;
1227
+ logger.info(
1228
+ { pending: pendingCount, max: this.backpressureConfig.maxPendingOps },
1229
+ "Backpressure low water mark reached, resuming writes"
1230
+ );
1231
+ this.emitBackpressureEvent("backpressure:low", {
1232
+ pending: pendingCount,
1233
+ max: this.backpressureConfig.maxPendingOps
1234
+ });
1235
+ this.emitBackpressureEvent("backpressure:resumed");
1236
+ const waiting = this.waitingForCapacity;
1237
+ this.waitingForCapacity = [];
1238
+ for (const resolve of waiting) {
1239
+ resolve();
1240
+ }
1241
+ }
1242
+ }
1243
+ }
1244
+ /**
1245
+ * Wait for capacity to become available (used by 'pause' strategy).
1246
+ */
1247
+ async waitForCapacity() {
1248
+ if (!this.backpressurePaused) {
1249
+ this.backpressurePaused = true;
1250
+ logger.warn("Backpressure paused - waiting for capacity");
1251
+ this.emitBackpressureEvent("backpressure:paused");
1252
+ }
1253
+ return new Promise((resolve) => {
1254
+ this.waitingForCapacity.push(resolve);
1255
+ });
1256
+ }
1257
+ /**
1258
+ * Drop the oldest pending operation (used by 'drop-oldest' strategy).
1259
+ */
1260
+ dropOldestOp() {
1261
+ const oldestIndex = this.opLog.findIndex((op) => !op.synced);
1262
+ if (oldestIndex !== -1) {
1263
+ const dropped = this.opLog[oldestIndex];
1264
+ this.opLog.splice(oldestIndex, 1);
1265
+ logger.warn(
1266
+ { opId: dropped.id, mapName: dropped.mapName, key: dropped.key },
1267
+ "Dropped oldest pending operation due to backpressure"
1268
+ );
1269
+ this.emitBackpressureEvent("operation:dropped", {
1270
+ opId: dropped.id,
1271
+ mapName: dropped.mapName,
1272
+ opType: dropped.opType,
1273
+ key: dropped.key
1274
+ });
1275
+ }
1276
+ }
566
1277
  };
567
1278
 
568
1279
  // src/TopGunClient.ts
@@ -777,7 +1488,9 @@ var TopGunClient = class {
777
1488
  const syncEngineConfig = {
778
1489
  nodeId: this.nodeId,
779
1490
  serverUrl: config.serverUrl,
780
- storageAdapter: this.storageAdapter
1491
+ storageAdapter: this.storageAdapter,
1492
+ backoff: config.backoff,
1493
+ backpressure: config.backpressure
781
1494
  };
782
1495
  this.syncEngine = new SyncEngine(syncEngineConfig);
783
1496
  }
@@ -941,6 +1654,90 @@ var TopGunClient = class {
941
1654
  close() {
942
1655
  this.syncEngine.close();
943
1656
  }
1657
+ // ============================================
1658
+ // Connection State API
1659
+ // ============================================
1660
+ /**
1661
+ * Get the current connection state
1662
+ */
1663
+ getConnectionState() {
1664
+ return this.syncEngine.getConnectionState();
1665
+ }
1666
+ /**
1667
+ * Subscribe to connection state changes
1668
+ * @param listener Callback function called on each state change
1669
+ * @returns Unsubscribe function
1670
+ */
1671
+ onConnectionStateChange(listener) {
1672
+ return this.syncEngine.onConnectionStateChange(listener);
1673
+ }
1674
+ /**
1675
+ * Get state machine history for debugging
1676
+ * @param limit Maximum number of entries to return
1677
+ */
1678
+ getStateHistory(limit) {
1679
+ return this.syncEngine.getStateHistory(limit);
1680
+ }
1681
+ /**
1682
+ * Reset the connection and state machine.
1683
+ * Use after fatal errors to start fresh.
1684
+ */
1685
+ resetConnection() {
1686
+ this.syncEngine.resetConnection();
1687
+ }
1688
+ // ============================================
1689
+ // Backpressure API
1690
+ // ============================================
1691
+ /**
1692
+ * Get the current number of pending (unacknowledged) operations.
1693
+ */
1694
+ getPendingOpsCount() {
1695
+ return this.syncEngine.getPendingOpsCount();
1696
+ }
1697
+ /**
1698
+ * Get the current backpressure status.
1699
+ */
1700
+ getBackpressureStatus() {
1701
+ return this.syncEngine.getBackpressureStatus();
1702
+ }
1703
+ /**
1704
+ * Returns true if writes are currently paused due to backpressure.
1705
+ */
1706
+ isBackpressurePaused() {
1707
+ return this.syncEngine.isBackpressurePaused();
1708
+ }
1709
+ /**
1710
+ * Subscribe to backpressure events.
1711
+ *
1712
+ * Available events:
1713
+ * - 'backpressure:high': Emitted when pending ops reach high water mark
1714
+ * - 'backpressure:low': Emitted when pending ops drop below low water mark
1715
+ * - 'backpressure:paused': Emitted when writes are paused (pause strategy)
1716
+ * - 'backpressure:resumed': Emitted when writes resume after being paused
1717
+ * - 'operation:dropped': Emitted when an operation is dropped (drop-oldest strategy)
1718
+ *
1719
+ * @param event Event name
1720
+ * @param listener Callback function
1721
+ * @returns Unsubscribe function
1722
+ *
1723
+ * @example
1724
+ * ```typescript
1725
+ * client.onBackpressure('backpressure:high', ({ pending, max }) => {
1726
+ * console.warn(`Warning: ${pending}/${max} pending ops`);
1727
+ * });
1728
+ *
1729
+ * client.onBackpressure('backpressure:paused', () => {
1730
+ * showLoadingSpinner();
1731
+ * });
1732
+ *
1733
+ * client.onBackpressure('backpressure:resumed', () => {
1734
+ * hideLoadingSpinner();
1735
+ * });
1736
+ * ```
1737
+ */
1738
+ onBackpressure(event, listener) {
1739
+ return this.syncEngine.onBackpressure(event, listener);
1740
+ }
944
1741
  };
945
1742
 
946
1743
  // src/adapters/IDBAdapter.ts
@@ -1370,15 +2167,21 @@ var EncryptedStorageAdapter = class {
1370
2167
  // src/index.ts
1371
2168
  import { LWWMap as LWWMap3, Predicates } from "@topgunbuild/core";
1372
2169
  export {
2170
+ BackpressureError,
2171
+ DEFAULT_BACKPRESSURE_CONFIG,
1373
2172
  EncryptedStorageAdapter,
1374
2173
  IDBAdapter,
1375
2174
  LWWMap3 as LWWMap,
1376
2175
  Predicates,
1377
2176
  QueryHandle,
1378
2177
  SyncEngine,
2178
+ SyncState,
2179
+ SyncStateMachine,
1379
2180
  TopGun,
1380
2181
  TopGunClient,
1381
2182
  TopicHandle,
2183
+ VALID_TRANSITIONS,
2184
+ isValidTransition,
1382
2185
  logger
1383
2186
  };
1384
2187
  //# sourceMappingURL=index.mjs.map