@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.js CHANGED
@@ -30,15 +30,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ BackpressureError: () => BackpressureError,
34
+ DEFAULT_BACKPRESSURE_CONFIG: () => DEFAULT_BACKPRESSURE_CONFIG,
33
35
  EncryptedStorageAdapter: () => EncryptedStorageAdapter,
34
36
  IDBAdapter: () => IDBAdapter,
35
37
  LWWMap: () => import_core4.LWWMap,
36
38
  Predicates: () => import_core4.Predicates,
37
39
  QueryHandle: () => QueryHandle,
38
40
  SyncEngine: () => SyncEngine,
41
+ SyncState: () => SyncState,
42
+ SyncStateMachine: () => SyncStateMachine,
39
43
  TopGun: () => TopGun,
40
44
  TopGunClient: () => TopGunClient,
41
45
  TopicHandle: () => TopicHandle,
46
+ VALID_TRANSITIONS: () => VALID_TRANSITIONS,
47
+ isValidTransition: () => isValidTransition,
42
48
  logger: () => logger
43
49
  });
44
50
  module.exports = __toCommonJS(index_exports);
@@ -65,12 +71,200 @@ var logger = (0, import_pino.default)({
65
71
  }
66
72
  });
67
73
 
74
+ // src/SyncState.ts
75
+ var SyncState = /* @__PURE__ */ ((SyncState2) => {
76
+ SyncState2["INITIAL"] = "INITIAL";
77
+ SyncState2["CONNECTING"] = "CONNECTING";
78
+ SyncState2["AUTHENTICATING"] = "AUTHENTICATING";
79
+ SyncState2["SYNCING"] = "SYNCING";
80
+ SyncState2["CONNECTED"] = "CONNECTED";
81
+ SyncState2["DISCONNECTED"] = "DISCONNECTED";
82
+ SyncState2["BACKOFF"] = "BACKOFF";
83
+ SyncState2["ERROR"] = "ERROR";
84
+ return SyncState2;
85
+ })(SyncState || {});
86
+ var VALID_TRANSITIONS = {
87
+ ["INITIAL" /* INITIAL */]: ["CONNECTING" /* CONNECTING */],
88
+ ["CONNECTING" /* CONNECTING */]: ["AUTHENTICATING" /* AUTHENTICATING */, "BACKOFF" /* BACKOFF */, "ERROR" /* ERROR */, "DISCONNECTED" /* DISCONNECTED */],
89
+ ["AUTHENTICATING" /* AUTHENTICATING */]: ["SYNCING" /* SYNCING */, "BACKOFF" /* BACKOFF */, "ERROR" /* ERROR */, "DISCONNECTED" /* DISCONNECTED */],
90
+ ["SYNCING" /* SYNCING */]: ["CONNECTED" /* CONNECTED */, "BACKOFF" /* BACKOFF */, "ERROR" /* ERROR */, "DISCONNECTED" /* DISCONNECTED */],
91
+ ["CONNECTED" /* CONNECTED */]: ["SYNCING" /* SYNCING */, "DISCONNECTED" /* DISCONNECTED */, "BACKOFF" /* BACKOFF */],
92
+ ["DISCONNECTED" /* DISCONNECTED */]: ["CONNECTING" /* CONNECTING */, "BACKOFF" /* BACKOFF */, "INITIAL" /* INITIAL */],
93
+ ["BACKOFF" /* BACKOFF */]: ["CONNECTING" /* CONNECTING */, "DISCONNECTED" /* DISCONNECTED */, "INITIAL" /* INITIAL */],
94
+ ["ERROR" /* ERROR */]: ["INITIAL" /* INITIAL */]
95
+ };
96
+ function isValidTransition(from, to) {
97
+ return VALID_TRANSITIONS[from]?.includes(to) ?? false;
98
+ }
99
+
100
+ // src/SyncStateMachine.ts
101
+ var DEFAULT_MAX_HISTORY_SIZE = 50;
102
+ var SyncStateMachine = class {
103
+ constructor(config = {}) {
104
+ this.state = "INITIAL" /* INITIAL */;
105
+ this.listeners = /* @__PURE__ */ new Set();
106
+ this.history = [];
107
+ this.maxHistorySize = config.maxHistorySize ?? DEFAULT_MAX_HISTORY_SIZE;
108
+ }
109
+ /**
110
+ * Attempt to transition to a new state.
111
+ * @param to The target state
112
+ * @returns true if the transition was valid and executed, false otherwise
113
+ */
114
+ transition(to) {
115
+ const from = this.state;
116
+ if (from === to) {
117
+ return true;
118
+ }
119
+ if (!isValidTransition(from, to)) {
120
+ logger.warn(
121
+ { from, to, currentHistory: this.getHistory(5) },
122
+ `Invalid state transition attempted: ${from} \u2192 ${to}`
123
+ );
124
+ return false;
125
+ }
126
+ this.state = to;
127
+ const event = {
128
+ from,
129
+ to,
130
+ timestamp: Date.now()
131
+ };
132
+ this.history.push(event);
133
+ if (this.history.length > this.maxHistorySize) {
134
+ this.history.shift();
135
+ }
136
+ for (const listener of this.listeners) {
137
+ try {
138
+ listener(event);
139
+ } catch (err) {
140
+ logger.error({ err, event }, "State change listener threw an error");
141
+ }
142
+ }
143
+ logger.debug({ from, to }, `State transition: ${from} \u2192 ${to}`);
144
+ return true;
145
+ }
146
+ /**
147
+ * Get the current state.
148
+ */
149
+ getState() {
150
+ return this.state;
151
+ }
152
+ /**
153
+ * Check if a transition from the current state to the target state is valid.
154
+ * @param to The target state to check
155
+ */
156
+ canTransition(to) {
157
+ return this.state === to || isValidTransition(this.state, to);
158
+ }
159
+ /**
160
+ * Subscribe to state change events.
161
+ * @param listener Callback function to be called on each state change
162
+ * @returns An unsubscribe function
163
+ */
164
+ onStateChange(listener) {
165
+ this.listeners.add(listener);
166
+ return () => {
167
+ this.listeners.delete(listener);
168
+ };
169
+ }
170
+ /**
171
+ * Get the state transition history.
172
+ * @param limit Maximum number of entries to return (default: all)
173
+ * @returns Array of state change events, oldest first
174
+ */
175
+ getHistory(limit) {
176
+ if (limit === void 0 || limit >= this.history.length) {
177
+ return [...this.history];
178
+ }
179
+ return this.history.slice(-limit);
180
+ }
181
+ /**
182
+ * Reset the state machine to INITIAL state.
183
+ * This is a forced reset that bypasses normal transition validation.
184
+ * Use for testing or hard resets after fatal errors.
185
+ * @param clearHistory If true, also clears the transition history (default: true)
186
+ */
187
+ reset(clearHistory = true) {
188
+ const from = this.state;
189
+ this.state = "INITIAL" /* INITIAL */;
190
+ if (clearHistory) {
191
+ this.history = [];
192
+ } else {
193
+ const event = {
194
+ from,
195
+ to: "INITIAL" /* INITIAL */,
196
+ timestamp: Date.now()
197
+ };
198
+ this.history.push(event);
199
+ if (this.history.length > this.maxHistorySize) {
200
+ this.history.shift();
201
+ }
202
+ for (const listener of this.listeners) {
203
+ try {
204
+ listener(event);
205
+ } catch (err) {
206
+ logger.error({ err, event }, "State change listener threw an error during reset");
207
+ }
208
+ }
209
+ }
210
+ logger.info({ from }, "State machine reset to INITIAL");
211
+ }
212
+ /**
213
+ * Check if the state machine is in a "connected" state
214
+ * (either SYNCING or CONNECTED)
215
+ */
216
+ isConnected() {
217
+ return this.state === "CONNECTED" /* CONNECTED */ || this.state === "SYNCING" /* SYNCING */;
218
+ }
219
+ /**
220
+ * Check if the state machine is in a state where operations can be sent
221
+ * (authenticated and connected)
222
+ */
223
+ isReady() {
224
+ return this.state === "CONNECTED" /* CONNECTED */;
225
+ }
226
+ /**
227
+ * Check if the state machine is currently attempting to connect
228
+ */
229
+ isConnecting() {
230
+ return this.state === "CONNECTING" /* CONNECTING */ || this.state === "AUTHENTICATING" /* AUTHENTICATING */ || this.state === "SYNCING" /* SYNCING */;
231
+ }
232
+ };
233
+
234
+ // src/errors/BackpressureError.ts
235
+ var BackpressureError = class _BackpressureError extends Error {
236
+ constructor(pendingCount, maxPending) {
237
+ super(
238
+ `Backpressure limit reached: ${pendingCount}/${maxPending} pending operations. Wait for acknowledgments or increase maxPendingOps.`
239
+ );
240
+ this.pendingCount = pendingCount;
241
+ this.maxPending = maxPending;
242
+ this.name = "BackpressureError";
243
+ if (Error.captureStackTrace) {
244
+ Error.captureStackTrace(this, _BackpressureError);
245
+ }
246
+ }
247
+ };
248
+
249
+ // src/BackpressureConfig.ts
250
+ var DEFAULT_BACKPRESSURE_CONFIG = {
251
+ maxPendingOps: 1e3,
252
+ strategy: "pause",
253
+ highWaterMark: 0.8,
254
+ lowWaterMark: 0.5
255
+ };
256
+
68
257
  // src/SyncEngine.ts
258
+ var DEFAULT_BACKOFF_CONFIG = {
259
+ initialDelayMs: 1e3,
260
+ maxDelayMs: 3e4,
261
+ multiplier: 2,
262
+ jitter: true,
263
+ maxRetries: 10
264
+ };
69
265
  var SyncEngine = class {
70
266
  constructor(config) {
71
267
  this.websocket = null;
72
- this.isOnline = false;
73
- this.isAuthenticated = false;
74
268
  this.opLog = [];
75
269
  this.maps = /* @__PURE__ */ new Map();
76
270
  this.queries = /* @__PURE__ */ new Map();
@@ -81,25 +275,95 @@ var SyncEngine = class {
81
275
  // NodeJS.Timeout
82
276
  this.authToken = null;
83
277
  this.tokenProvider = null;
278
+ this.backoffAttempt = 0;
279
+ this.heartbeatInterval = null;
280
+ this.lastPongReceived = Date.now();
281
+ this.lastRoundTripTime = null;
282
+ this.backpressurePaused = false;
283
+ this.waitingForCapacity = [];
284
+ this.highWaterMarkEmitted = false;
285
+ this.backpressureListeners = /* @__PURE__ */ new Map();
84
286
  this.nodeId = config.nodeId;
85
287
  this.serverUrl = config.serverUrl;
86
288
  this.storageAdapter = config.storageAdapter;
87
- this.reconnectInterval = config.reconnectInterval || 5e3;
88
289
  this.hlc = new import_core.HLC(this.nodeId);
290
+ this.stateMachine = new SyncStateMachine();
291
+ this.heartbeatConfig = {
292
+ intervalMs: config.heartbeat?.intervalMs ?? 5e3,
293
+ timeoutMs: config.heartbeat?.timeoutMs ?? 15e3,
294
+ enabled: config.heartbeat?.enabled ?? true
295
+ };
296
+ this.backoffConfig = {
297
+ ...DEFAULT_BACKOFF_CONFIG,
298
+ ...config.backoff
299
+ };
300
+ this.backpressureConfig = {
301
+ ...DEFAULT_BACKPRESSURE_CONFIG,
302
+ ...config.backpressure
303
+ };
89
304
  this.initConnection();
90
305
  this.loadOpLog();
91
306
  }
307
+ // ============================================
308
+ // State Machine Public API
309
+ // ============================================
310
+ /**
311
+ * Get the current connection state
312
+ */
313
+ getConnectionState() {
314
+ return this.stateMachine.getState();
315
+ }
316
+ /**
317
+ * Subscribe to connection state changes
318
+ * @returns Unsubscribe function
319
+ */
320
+ onConnectionStateChange(listener) {
321
+ return this.stateMachine.onStateChange(listener);
322
+ }
323
+ /**
324
+ * Get state machine history for debugging
325
+ */
326
+ getStateHistory(limit) {
327
+ return this.stateMachine.getHistory(limit);
328
+ }
329
+ // ============================================
330
+ // Internal State Helpers (replace boolean flags)
331
+ // ============================================
332
+ /**
333
+ * Check if WebSocket is connected (but may not be authenticated yet)
334
+ */
335
+ isOnline() {
336
+ const state = this.stateMachine.getState();
337
+ return state === "CONNECTING" /* CONNECTING */ || state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
338
+ }
339
+ /**
340
+ * Check if fully authenticated and ready for operations
341
+ */
342
+ isAuthenticated() {
343
+ const state = this.stateMachine.getState();
344
+ return state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
345
+ }
346
+ /**
347
+ * Check if fully connected and synced
348
+ */
349
+ isConnected() {
350
+ return this.stateMachine.getState() === "CONNECTED" /* CONNECTED */;
351
+ }
352
+ // ============================================
353
+ // Connection Management
354
+ // ============================================
92
355
  initConnection() {
356
+ this.stateMachine.transition("CONNECTING" /* CONNECTING */);
93
357
  this.websocket = new WebSocket(this.serverUrl);
94
358
  this.websocket.binaryType = "arraybuffer";
95
359
  this.websocket.onopen = () => {
96
360
  if (this.authToken || this.tokenProvider) {
97
361
  logger.info("WebSocket connected. Sending auth...");
98
- this.isOnline = true;
362
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
99
363
  this.sendAuth();
100
364
  } else {
101
365
  logger.info("WebSocket connected. Waiting for auth token...");
102
- this.isOnline = true;
366
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
103
367
  }
104
368
  };
105
369
  this.websocket.onmessage = (event) => {
@@ -117,9 +381,9 @@ var SyncEngine = class {
117
381
  this.handleServerMessage(message);
118
382
  };
119
383
  this.websocket.onclose = () => {
120
- logger.info("WebSocket disconnected. Retrying...");
121
- this.isOnline = false;
122
- this.isAuthenticated = false;
384
+ logger.info("WebSocket disconnected.");
385
+ this.stopHeartbeat();
386
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
123
387
  this.scheduleReconnect();
124
388
  };
125
389
  this.websocket.onerror = (error) => {
@@ -127,10 +391,41 @@ var SyncEngine = class {
127
391
  };
128
392
  }
129
393
  scheduleReconnect() {
130
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
394
+ if (this.reconnectTimer) {
395
+ clearTimeout(this.reconnectTimer);
396
+ this.reconnectTimer = null;
397
+ }
398
+ if (this.backoffAttempt >= this.backoffConfig.maxRetries) {
399
+ logger.error(
400
+ { attempts: this.backoffAttempt },
401
+ "Max reconnection attempts reached. Entering ERROR state."
402
+ );
403
+ this.stateMachine.transition("ERROR" /* ERROR */);
404
+ return;
405
+ }
406
+ this.stateMachine.transition("BACKOFF" /* BACKOFF */);
407
+ const delay = this.calculateBackoffDelay();
408
+ logger.info({ delay, attempt: this.backoffAttempt }, `Backing off for ${delay}ms`);
131
409
  this.reconnectTimer = setTimeout(() => {
410
+ this.reconnectTimer = null;
411
+ this.backoffAttempt++;
132
412
  this.initConnection();
133
- }, this.reconnectInterval);
413
+ }, delay);
414
+ }
415
+ calculateBackoffDelay() {
416
+ const { initialDelayMs, maxDelayMs, multiplier, jitter } = this.backoffConfig;
417
+ let delay = initialDelayMs * Math.pow(multiplier, this.backoffAttempt);
418
+ delay = Math.min(delay, maxDelayMs);
419
+ if (jitter) {
420
+ delay = delay * (0.5 + Math.random());
421
+ }
422
+ return Math.floor(delay);
423
+ }
424
+ /**
425
+ * Reset backoff counter (called on successful connection)
426
+ */
427
+ resetBackoff() {
428
+ this.backoffAttempt = 0;
134
429
  }
135
430
  async loadOpLog() {
136
431
  const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
@@ -154,6 +449,7 @@ var SyncEngine = class {
154
449
  this.maps.set(mapName, map);
155
450
  }
156
451
  async recordOperation(mapName, opType, key, data) {
452
+ await this.checkBackpressure();
157
453
  const opLogEntry = {
158
454
  mapName,
159
455
  opType,
@@ -167,9 +463,11 @@ var SyncEngine = class {
167
463
  const id = await this.storageAdapter.appendOpLog(opLogEntry);
168
464
  opLogEntry.id = String(id);
169
465
  this.opLog.push(opLogEntry);
170
- if (this.isOnline && this.isAuthenticated) {
466
+ this.checkHighWaterMark();
467
+ if (this.isAuthenticated()) {
171
468
  this.syncPendingOperations();
172
469
  }
470
+ return opLogEntry.id;
173
471
  }
174
472
  syncPendingOperations() {
175
473
  const pending = this.opLog.filter((op) => !op.synced);
@@ -187,32 +485,47 @@ var SyncEngine = class {
187
485
  startMerkleSync() {
188
486
  for (const [mapName, map] of this.maps) {
189
487
  if (map instanceof import_core.LWWMap) {
190
- logger.info({ mapName }, "Starting Merkle sync for map");
488
+ logger.info({ mapName }, "Starting Merkle sync for LWWMap");
191
489
  this.websocket?.send((0, import_core.serialize)({
192
490
  type: "SYNC_INIT",
193
491
  mapName,
194
492
  lastSyncTimestamp: this.lastSyncTimestamp
195
493
  }));
494
+ } else if (map instanceof import_core.ORMap) {
495
+ logger.info({ mapName }, "Starting Merkle sync for ORMap");
496
+ const tree = map.getMerkleTree();
497
+ const rootHash = tree.getRootHash();
498
+ const bucketHashes = tree.getBuckets("");
499
+ this.websocket?.send((0, import_core.serialize)({
500
+ type: "ORMAP_SYNC_INIT",
501
+ mapName,
502
+ rootHash,
503
+ bucketHashes,
504
+ lastSyncTimestamp: this.lastSyncTimestamp
505
+ }));
196
506
  }
197
507
  }
198
508
  }
199
509
  setAuthToken(token) {
200
510
  this.authToken = token;
201
511
  this.tokenProvider = null;
202
- if (this.isOnline) {
512
+ const state = this.stateMachine.getState();
513
+ if (state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "CONNECTING" /* CONNECTING */) {
203
514
  this.sendAuth();
204
- } else {
515
+ } else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
516
+ logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
205
517
  if (this.reconnectTimer) {
206
- logger.info("Auth token set during backoff. Reconnecting immediately.");
207
518
  clearTimeout(this.reconnectTimer);
208
519
  this.reconnectTimer = null;
209
- this.initConnection();
210
520
  }
521
+ this.resetBackoff();
522
+ this.initConnection();
211
523
  }
212
524
  }
213
525
  setTokenProvider(provider) {
214
526
  this.tokenProvider = provider;
215
- if (this.isOnline && !this.isAuthenticated) {
527
+ const state = this.stateMachine.getState();
528
+ if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
216
529
  this.sendAuth();
217
530
  }
218
531
  }
@@ -237,19 +550,19 @@ var SyncEngine = class {
237
550
  }
238
551
  subscribeToQuery(query) {
239
552
  this.queries.set(query.id, query);
240
- if (this.isOnline && this.isAuthenticated) {
553
+ if (this.isAuthenticated()) {
241
554
  this.sendQuerySubscription(query);
242
555
  }
243
556
  }
244
557
  subscribeToTopic(topic, handle) {
245
558
  this.topics.set(topic, handle);
246
- if (this.isOnline && this.isAuthenticated) {
559
+ if (this.isAuthenticated()) {
247
560
  this.sendTopicSubscription(topic);
248
561
  }
249
562
  }
250
563
  unsubscribeFromTopic(topic) {
251
564
  this.topics.delete(topic);
252
- if (this.isOnline && this.isAuthenticated) {
565
+ if (this.isAuthenticated()) {
253
566
  this.websocket?.send((0, import_core.serialize)({
254
567
  type: "TOPIC_UNSUB",
255
568
  payload: { topic }
@@ -257,7 +570,7 @@ var SyncEngine = class {
257
570
  }
258
571
  }
259
572
  publishTopic(topic, data) {
260
- if (this.isOnline && this.isAuthenticated) {
573
+ if (this.isAuthenticated()) {
261
574
  this.websocket?.send((0, import_core.serialize)({
262
575
  type: "TOPIC_PUB",
263
576
  payload: { topic, data }
@@ -306,7 +619,7 @@ var SyncEngine = class {
306
619
  }
307
620
  unsubscribeFromQuery(queryId) {
308
621
  this.queries.delete(queryId);
309
- if (this.isOnline && this.isAuthenticated) {
622
+ if (this.isAuthenticated()) {
310
623
  this.websocket?.send((0, import_core.serialize)({
311
624
  type: "QUERY_UNSUB",
312
625
  payload: { queryId }
@@ -324,7 +637,7 @@ var SyncEngine = class {
324
637
  }));
325
638
  }
326
639
  requestLock(name, requestId, ttl) {
327
- if (!this.isOnline || !this.isAuthenticated) {
640
+ if (!this.isAuthenticated()) {
328
641
  return Promise.reject(new Error("Not connected or authenticated"));
329
642
  }
330
643
  return new Promise((resolve, reject) => {
@@ -348,7 +661,7 @@ var SyncEngine = class {
348
661
  });
349
662
  }
350
663
  releaseLock(name, requestId, fencingToken) {
351
- if (!this.isOnline) return Promise.resolve(false);
664
+ if (!this.isOnline()) return Promise.resolve(false);
352
665
  return new Promise((resolve, reject) => {
353
666
  const timer = setTimeout(() => {
354
667
  if (this.pendingLockRequests.has(requestId)) {
@@ -376,10 +689,12 @@ var SyncEngine = class {
376
689
  break;
377
690
  case "AUTH_ACK": {
378
691
  logger.info("Authenticated successfully");
379
- const wasAuthenticated = this.isAuthenticated;
380
- this.isAuthenticated = true;
692
+ const wasAuthenticated = this.isAuthenticated();
693
+ this.stateMachine.transition("SYNCING" /* SYNCING */);
694
+ this.resetBackoff();
381
695
  this.syncPendingOperations();
382
696
  if (!wasAuthenticated) {
697
+ this.startHeartbeat();
383
698
  this.startMerkleSync();
384
699
  for (const query of this.queries.values()) {
385
700
  this.sendQuerySubscription(query);
@@ -388,19 +703,27 @@ var SyncEngine = class {
388
703
  this.sendTopicSubscription(topic);
389
704
  }
390
705
  }
706
+ this.stateMachine.transition("CONNECTED" /* CONNECTED */);
707
+ break;
708
+ }
709
+ case "PONG": {
710
+ this.handlePong(message);
391
711
  break;
392
712
  }
393
713
  case "AUTH_FAIL":
394
714
  logger.error({ error: message.error }, "Authentication failed");
395
- this.isAuthenticated = false;
396
715
  this.authToken = null;
397
716
  break;
398
717
  case "OP_ACK": {
399
718
  const { lastId } = message.payload;
400
719
  logger.info({ lastId }, "Received ACK for ops");
401
720
  let maxSyncedId = -1;
721
+ let ackedCount = 0;
402
722
  this.opLog.forEach((op) => {
403
723
  if (op.id && op.id <= lastId) {
724
+ if (!op.synced) {
725
+ ackedCount++;
726
+ }
404
727
  op.synced = true;
405
728
  const idNum = parseInt(op.id, 10);
406
729
  if (!isNaN(idNum) && idNum > maxSyncedId) {
@@ -411,6 +734,9 @@ var SyncEngine = class {
411
734
  if (maxSyncedId !== -1) {
412
735
  this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
413
736
  }
737
+ if (ackedCount > 0) {
738
+ this.checkLowWaterMark();
739
+ }
414
740
  break;
415
741
  }
416
742
  case "LOCK_GRANTED": {
@@ -565,6 +891,96 @@ var SyncEngine = class {
565
891
  }
566
892
  break;
567
893
  }
894
+ // ============ ORMap Sync Message Handlers ============
895
+ case "ORMAP_SYNC_RESP_ROOT": {
896
+ const { mapName, rootHash, timestamp } = message.payload;
897
+ const map = this.maps.get(mapName);
898
+ if (map instanceof import_core.ORMap) {
899
+ const localTree = map.getMerkleTree();
900
+ const localRootHash = localTree.getRootHash();
901
+ if (localRootHash !== rootHash) {
902
+ logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
903
+ this.websocket?.send((0, import_core.serialize)({
904
+ type: "ORMAP_MERKLE_REQ_BUCKET",
905
+ payload: { mapName, path: "" }
906
+ }));
907
+ } else {
908
+ logger.info({ mapName }, "ORMap is in sync");
909
+ }
910
+ }
911
+ if (timestamp) {
912
+ this.hlc.update(timestamp);
913
+ this.lastSyncTimestamp = timestamp.millis;
914
+ await this.saveOpLog();
915
+ }
916
+ break;
917
+ }
918
+ case "ORMAP_SYNC_RESP_BUCKETS": {
919
+ const { mapName, path, buckets } = message.payload;
920
+ const map = this.maps.get(mapName);
921
+ if (map instanceof import_core.ORMap) {
922
+ const tree = map.getMerkleTree();
923
+ const localBuckets = tree.getBuckets(path);
924
+ for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
925
+ const localHash = localBuckets[bucketKey] || 0;
926
+ if (localHash !== remoteHash) {
927
+ const newPath = path + bucketKey;
928
+ this.websocket?.send((0, import_core.serialize)({
929
+ type: "ORMAP_MERKLE_REQ_BUCKET",
930
+ payload: { mapName, path: newPath }
931
+ }));
932
+ }
933
+ }
934
+ for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
935
+ if (!(bucketKey in buckets) && localHash !== 0) {
936
+ const newPath = path + bucketKey;
937
+ const keys = tree.getKeysInBucket(newPath);
938
+ if (keys.length > 0) {
939
+ this.pushORMapDiff(mapName, keys, map);
940
+ }
941
+ }
942
+ }
943
+ }
944
+ break;
945
+ }
946
+ case "ORMAP_SYNC_RESP_LEAF": {
947
+ const { mapName, entries } = message.payload;
948
+ const map = this.maps.get(mapName);
949
+ if (map instanceof import_core.ORMap) {
950
+ let totalAdded = 0;
951
+ let totalUpdated = 0;
952
+ for (const entry of entries) {
953
+ const { key, records, tombstones } = entry;
954
+ const result = map.mergeKey(key, records, tombstones);
955
+ totalAdded += result.added;
956
+ totalUpdated += result.updated;
957
+ }
958
+ if (totalAdded > 0 || totalUpdated > 0) {
959
+ logger.info({ mapName, added: totalAdded, updated: totalUpdated }, "Synced ORMap records from server");
960
+ }
961
+ const keysToCheck = entries.map((e) => e.key);
962
+ await this.pushORMapDiff(mapName, keysToCheck, map);
963
+ }
964
+ break;
965
+ }
966
+ case "ORMAP_DIFF_RESPONSE": {
967
+ const { mapName, entries } = message.payload;
968
+ const map = this.maps.get(mapName);
969
+ if (map instanceof import_core.ORMap) {
970
+ let totalAdded = 0;
971
+ let totalUpdated = 0;
972
+ for (const entry of entries) {
973
+ const { key, records, tombstones } = entry;
974
+ const result = map.mergeKey(key, records, tombstones);
975
+ totalAdded += result.added;
976
+ totalUpdated += result.updated;
977
+ }
978
+ if (totalAdded > 0 || totalUpdated > 0) {
979
+ logger.info({ mapName, added: totalAdded, updated: totalUpdated }, "Merged ORMap diff from server");
980
+ }
981
+ }
982
+ break;
983
+ }
568
984
  }
569
985
  if (message.timestamp) {
570
986
  this.hlc.update(message.timestamp);
@@ -579,6 +995,7 @@ var SyncEngine = class {
579
995
  * Closes the WebSocket connection and cleans up resources.
580
996
  */
581
997
  close() {
998
+ this.stopHeartbeat();
582
999
  if (this.reconnectTimer) {
583
1000
  clearTimeout(this.reconnectTimer);
584
1001
  this.reconnectTimer = null;
@@ -588,10 +1005,19 @@ var SyncEngine = class {
588
1005
  this.websocket.close();
589
1006
  this.websocket = null;
590
1007
  }
591
- this.isOnline = false;
592
- this.isAuthenticated = false;
1008
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
593
1009
  logger.info("SyncEngine closed");
594
1010
  }
1011
+ /**
1012
+ * Reset the state machine and connection.
1013
+ * Use after fatal errors to start fresh.
1014
+ */
1015
+ resetConnection() {
1016
+ this.close();
1017
+ this.stateMachine.reset();
1018
+ this.resetBackoff();
1019
+ this.initConnection();
1020
+ }
595
1021
  async resetMap(mapName) {
596
1022
  const map = this.maps.get(mapName);
597
1023
  if (map) {
@@ -608,6 +1034,297 @@ var SyncEngine = class {
608
1034
  }
609
1035
  logger.info({ mapName, removedStorageCount: mapKeys.length }, "Reset map: Cleared memory and storage");
610
1036
  }
1037
+ // ============ Heartbeat Methods ============
1038
+ /**
1039
+ * Starts the heartbeat mechanism after successful connection.
1040
+ */
1041
+ startHeartbeat() {
1042
+ if (!this.heartbeatConfig.enabled) {
1043
+ return;
1044
+ }
1045
+ this.stopHeartbeat();
1046
+ this.lastPongReceived = Date.now();
1047
+ this.heartbeatInterval = setInterval(() => {
1048
+ this.sendPing();
1049
+ this.checkHeartbeatTimeout();
1050
+ }, this.heartbeatConfig.intervalMs);
1051
+ logger.info({ intervalMs: this.heartbeatConfig.intervalMs }, "Heartbeat started");
1052
+ }
1053
+ /**
1054
+ * Stops the heartbeat mechanism.
1055
+ */
1056
+ stopHeartbeat() {
1057
+ if (this.heartbeatInterval) {
1058
+ clearInterval(this.heartbeatInterval);
1059
+ this.heartbeatInterval = null;
1060
+ logger.info("Heartbeat stopped");
1061
+ }
1062
+ }
1063
+ /**
1064
+ * Sends a PING message to the server.
1065
+ */
1066
+ sendPing() {
1067
+ if (this.websocket?.readyState === WebSocket.OPEN) {
1068
+ const pingMessage = {
1069
+ type: "PING",
1070
+ timestamp: Date.now()
1071
+ };
1072
+ this.websocket.send((0, import_core.serialize)(pingMessage));
1073
+ }
1074
+ }
1075
+ /**
1076
+ * Handles incoming PONG message from server.
1077
+ */
1078
+ handlePong(msg) {
1079
+ const now = Date.now();
1080
+ this.lastPongReceived = now;
1081
+ this.lastRoundTripTime = now - msg.timestamp;
1082
+ logger.debug({
1083
+ rtt: this.lastRoundTripTime,
1084
+ serverTime: msg.serverTime,
1085
+ clockSkew: msg.serverTime - (msg.timestamp + this.lastRoundTripTime / 2)
1086
+ }, "Received PONG");
1087
+ }
1088
+ /**
1089
+ * Checks if heartbeat has timed out and triggers reconnection if needed.
1090
+ */
1091
+ checkHeartbeatTimeout() {
1092
+ const now = Date.now();
1093
+ const timeSinceLastPong = now - this.lastPongReceived;
1094
+ if (timeSinceLastPong > this.heartbeatConfig.timeoutMs) {
1095
+ logger.warn({
1096
+ timeSinceLastPong,
1097
+ timeoutMs: this.heartbeatConfig.timeoutMs
1098
+ }, "Heartbeat timeout - triggering reconnection");
1099
+ this.stopHeartbeat();
1100
+ if (this.websocket) {
1101
+ this.websocket.close();
1102
+ }
1103
+ }
1104
+ }
1105
+ /**
1106
+ * Returns the last measured round-trip time in milliseconds.
1107
+ * Returns null if no PONG has been received yet.
1108
+ */
1109
+ getLastRoundTripTime() {
1110
+ return this.lastRoundTripTime;
1111
+ }
1112
+ /**
1113
+ * Returns true if the connection is considered healthy based on heartbeat.
1114
+ * A connection is healthy if it's online, authenticated, and has received
1115
+ * a PONG within the timeout window.
1116
+ */
1117
+ isConnectionHealthy() {
1118
+ if (!this.isOnline() || !this.isAuthenticated()) {
1119
+ return false;
1120
+ }
1121
+ if (!this.heartbeatConfig.enabled) {
1122
+ return true;
1123
+ }
1124
+ const timeSinceLastPong = Date.now() - this.lastPongReceived;
1125
+ return timeSinceLastPong < this.heartbeatConfig.timeoutMs;
1126
+ }
1127
+ // ============ ORMap Sync Methods ============
1128
+ /**
1129
+ * Push local ORMap diff to server for the given keys.
1130
+ * Sends local records and tombstones that the server might not have.
1131
+ */
1132
+ async pushORMapDiff(mapName, keys, map) {
1133
+ const entries = [];
1134
+ const snapshot = map.getSnapshot();
1135
+ for (const key of keys) {
1136
+ const recordsMap = map.getRecordsMap(key);
1137
+ if (recordsMap && recordsMap.size > 0) {
1138
+ const records = Array.from(recordsMap.values());
1139
+ const tombstones = [];
1140
+ for (const tag of snapshot.tombstones) {
1141
+ tombstones.push(tag);
1142
+ }
1143
+ entries.push({
1144
+ key,
1145
+ records,
1146
+ tombstones
1147
+ });
1148
+ }
1149
+ }
1150
+ if (entries.length > 0) {
1151
+ this.websocket?.send((0, import_core.serialize)({
1152
+ type: "ORMAP_PUSH_DIFF",
1153
+ payload: {
1154
+ mapName,
1155
+ entries
1156
+ }
1157
+ }));
1158
+ logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
1159
+ }
1160
+ }
1161
+ // ============ Backpressure Methods ============
1162
+ /**
1163
+ * Get the current number of pending (unsynced) operations.
1164
+ */
1165
+ getPendingOpsCount() {
1166
+ return this.opLog.filter((op) => !op.synced).length;
1167
+ }
1168
+ /**
1169
+ * Get the current backpressure status.
1170
+ */
1171
+ getBackpressureStatus() {
1172
+ const pending = this.getPendingOpsCount();
1173
+ const max = this.backpressureConfig.maxPendingOps;
1174
+ return {
1175
+ pending,
1176
+ max,
1177
+ percentage: max > 0 ? pending / max : 0,
1178
+ isPaused: this.backpressurePaused,
1179
+ strategy: this.backpressureConfig.strategy
1180
+ };
1181
+ }
1182
+ /**
1183
+ * Returns true if writes are currently paused due to backpressure.
1184
+ */
1185
+ isBackpressurePaused() {
1186
+ return this.backpressurePaused;
1187
+ }
1188
+ /**
1189
+ * Subscribe to backpressure events.
1190
+ * @param event Event name: 'backpressure:high', 'backpressure:low', 'backpressure:paused', 'backpressure:resumed', 'operation:dropped'
1191
+ * @param listener Callback function
1192
+ * @returns Unsubscribe function
1193
+ */
1194
+ onBackpressure(event, listener) {
1195
+ if (!this.backpressureListeners.has(event)) {
1196
+ this.backpressureListeners.set(event, /* @__PURE__ */ new Set());
1197
+ }
1198
+ this.backpressureListeners.get(event).add(listener);
1199
+ return () => {
1200
+ this.backpressureListeners.get(event)?.delete(listener);
1201
+ };
1202
+ }
1203
+ /**
1204
+ * Emit a backpressure event to all listeners.
1205
+ */
1206
+ emitBackpressureEvent(event, data) {
1207
+ const listeners = this.backpressureListeners.get(event);
1208
+ if (listeners) {
1209
+ for (const listener of listeners) {
1210
+ try {
1211
+ listener(data);
1212
+ } catch (err) {
1213
+ logger.error({ err, event }, "Error in backpressure event listener");
1214
+ }
1215
+ }
1216
+ }
1217
+ }
1218
+ /**
1219
+ * Check backpressure before adding a new operation.
1220
+ * May pause, throw, or drop depending on strategy.
1221
+ */
1222
+ async checkBackpressure() {
1223
+ const pendingCount = this.getPendingOpsCount();
1224
+ if (pendingCount < this.backpressureConfig.maxPendingOps) {
1225
+ return;
1226
+ }
1227
+ switch (this.backpressureConfig.strategy) {
1228
+ case "pause":
1229
+ await this.waitForCapacity();
1230
+ break;
1231
+ case "throw":
1232
+ throw new BackpressureError(
1233
+ pendingCount,
1234
+ this.backpressureConfig.maxPendingOps
1235
+ );
1236
+ case "drop-oldest":
1237
+ this.dropOldestOp();
1238
+ break;
1239
+ }
1240
+ }
1241
+ /**
1242
+ * Check high water mark and emit event if threshold reached.
1243
+ */
1244
+ checkHighWaterMark() {
1245
+ const pendingCount = this.getPendingOpsCount();
1246
+ const threshold = Math.floor(
1247
+ this.backpressureConfig.maxPendingOps * this.backpressureConfig.highWaterMark
1248
+ );
1249
+ if (pendingCount >= threshold && !this.highWaterMarkEmitted) {
1250
+ this.highWaterMarkEmitted = true;
1251
+ logger.warn(
1252
+ { pending: pendingCount, max: this.backpressureConfig.maxPendingOps },
1253
+ "Backpressure high water mark reached"
1254
+ );
1255
+ this.emitBackpressureEvent("backpressure:high", {
1256
+ pending: pendingCount,
1257
+ max: this.backpressureConfig.maxPendingOps
1258
+ });
1259
+ }
1260
+ }
1261
+ /**
1262
+ * Check low water mark and resume paused writes if threshold reached.
1263
+ */
1264
+ checkLowWaterMark() {
1265
+ const pendingCount = this.getPendingOpsCount();
1266
+ const lowThreshold = Math.floor(
1267
+ this.backpressureConfig.maxPendingOps * this.backpressureConfig.lowWaterMark
1268
+ );
1269
+ const highThreshold = Math.floor(
1270
+ this.backpressureConfig.maxPendingOps * this.backpressureConfig.highWaterMark
1271
+ );
1272
+ if (pendingCount < highThreshold && this.highWaterMarkEmitted) {
1273
+ this.highWaterMarkEmitted = false;
1274
+ }
1275
+ if (pendingCount <= lowThreshold) {
1276
+ if (this.backpressurePaused) {
1277
+ this.backpressurePaused = false;
1278
+ logger.info(
1279
+ { pending: pendingCount, max: this.backpressureConfig.maxPendingOps },
1280
+ "Backpressure low water mark reached, resuming writes"
1281
+ );
1282
+ this.emitBackpressureEvent("backpressure:low", {
1283
+ pending: pendingCount,
1284
+ max: this.backpressureConfig.maxPendingOps
1285
+ });
1286
+ this.emitBackpressureEvent("backpressure:resumed");
1287
+ const waiting = this.waitingForCapacity;
1288
+ this.waitingForCapacity = [];
1289
+ for (const resolve of waiting) {
1290
+ resolve();
1291
+ }
1292
+ }
1293
+ }
1294
+ }
1295
+ /**
1296
+ * Wait for capacity to become available (used by 'pause' strategy).
1297
+ */
1298
+ async waitForCapacity() {
1299
+ if (!this.backpressurePaused) {
1300
+ this.backpressurePaused = true;
1301
+ logger.warn("Backpressure paused - waiting for capacity");
1302
+ this.emitBackpressureEvent("backpressure:paused");
1303
+ }
1304
+ return new Promise((resolve) => {
1305
+ this.waitingForCapacity.push(resolve);
1306
+ });
1307
+ }
1308
+ /**
1309
+ * Drop the oldest pending operation (used by 'drop-oldest' strategy).
1310
+ */
1311
+ dropOldestOp() {
1312
+ const oldestIndex = this.opLog.findIndex((op) => !op.synced);
1313
+ if (oldestIndex !== -1) {
1314
+ const dropped = this.opLog[oldestIndex];
1315
+ this.opLog.splice(oldestIndex, 1);
1316
+ logger.warn(
1317
+ { opId: dropped.id, mapName: dropped.mapName, key: dropped.key },
1318
+ "Dropped oldest pending operation due to backpressure"
1319
+ );
1320
+ this.emitBackpressureEvent("operation:dropped", {
1321
+ opId: dropped.id,
1322
+ mapName: dropped.mapName,
1323
+ opType: dropped.opType,
1324
+ key: dropped.key
1325
+ });
1326
+ }
1327
+ }
611
1328
  };
612
1329
 
613
1330
  // src/TopGunClient.ts
@@ -822,7 +1539,9 @@ var TopGunClient = class {
822
1539
  const syncEngineConfig = {
823
1540
  nodeId: this.nodeId,
824
1541
  serverUrl: config.serverUrl,
825
- storageAdapter: this.storageAdapter
1542
+ storageAdapter: this.storageAdapter,
1543
+ backoff: config.backoff,
1544
+ backpressure: config.backpressure
826
1545
  };
827
1546
  this.syncEngine = new SyncEngine(syncEngineConfig);
828
1547
  }
@@ -986,6 +1705,90 @@ var TopGunClient = class {
986
1705
  close() {
987
1706
  this.syncEngine.close();
988
1707
  }
1708
+ // ============================================
1709
+ // Connection State API
1710
+ // ============================================
1711
+ /**
1712
+ * Get the current connection state
1713
+ */
1714
+ getConnectionState() {
1715
+ return this.syncEngine.getConnectionState();
1716
+ }
1717
+ /**
1718
+ * Subscribe to connection state changes
1719
+ * @param listener Callback function called on each state change
1720
+ * @returns Unsubscribe function
1721
+ */
1722
+ onConnectionStateChange(listener) {
1723
+ return this.syncEngine.onConnectionStateChange(listener);
1724
+ }
1725
+ /**
1726
+ * Get state machine history for debugging
1727
+ * @param limit Maximum number of entries to return
1728
+ */
1729
+ getStateHistory(limit) {
1730
+ return this.syncEngine.getStateHistory(limit);
1731
+ }
1732
+ /**
1733
+ * Reset the connection and state machine.
1734
+ * Use after fatal errors to start fresh.
1735
+ */
1736
+ resetConnection() {
1737
+ this.syncEngine.resetConnection();
1738
+ }
1739
+ // ============================================
1740
+ // Backpressure API
1741
+ // ============================================
1742
+ /**
1743
+ * Get the current number of pending (unacknowledged) operations.
1744
+ */
1745
+ getPendingOpsCount() {
1746
+ return this.syncEngine.getPendingOpsCount();
1747
+ }
1748
+ /**
1749
+ * Get the current backpressure status.
1750
+ */
1751
+ getBackpressureStatus() {
1752
+ return this.syncEngine.getBackpressureStatus();
1753
+ }
1754
+ /**
1755
+ * Returns true if writes are currently paused due to backpressure.
1756
+ */
1757
+ isBackpressurePaused() {
1758
+ return this.syncEngine.isBackpressurePaused();
1759
+ }
1760
+ /**
1761
+ * Subscribe to backpressure events.
1762
+ *
1763
+ * Available events:
1764
+ * - 'backpressure:high': Emitted when pending ops reach high water mark
1765
+ * - 'backpressure:low': Emitted when pending ops drop below low water mark
1766
+ * - 'backpressure:paused': Emitted when writes are paused (pause strategy)
1767
+ * - 'backpressure:resumed': Emitted when writes resume after being paused
1768
+ * - 'operation:dropped': Emitted when an operation is dropped (drop-oldest strategy)
1769
+ *
1770
+ * @param event Event name
1771
+ * @param listener Callback function
1772
+ * @returns Unsubscribe function
1773
+ *
1774
+ * @example
1775
+ * ```typescript
1776
+ * client.onBackpressure('backpressure:high', ({ pending, max }) => {
1777
+ * console.warn(`Warning: ${pending}/${max} pending ops`);
1778
+ * });
1779
+ *
1780
+ * client.onBackpressure('backpressure:paused', () => {
1781
+ * showLoadingSpinner();
1782
+ * });
1783
+ *
1784
+ * client.onBackpressure('backpressure:resumed', () => {
1785
+ * hideLoadingSpinner();
1786
+ * });
1787
+ * ```
1788
+ */
1789
+ onBackpressure(event, listener) {
1790
+ return this.syncEngine.onBackpressure(event, listener);
1791
+ }
989
1792
  };
990
1793
 
991
1794
  // src/adapters/IDBAdapter.ts
@@ -1416,15 +2219,21 @@ var EncryptedStorageAdapter = class {
1416
2219
  var import_core4 = require("@topgunbuild/core");
1417
2220
  // Annotate the CommonJS export names for ESM import in node:
1418
2221
  0 && (module.exports = {
2222
+ BackpressureError,
2223
+ DEFAULT_BACKPRESSURE_CONFIG,
1419
2224
  EncryptedStorageAdapter,
1420
2225
  IDBAdapter,
1421
2226
  LWWMap,
1422
2227
  Predicates,
1423
2228
  QueryHandle,
1424
2229
  SyncEngine,
2230
+ SyncState,
2231
+ SyncStateMachine,
1425
2232
  TopGun,
1426
2233
  TopGunClient,
1427
2234
  TopicHandle,
2235
+ VALID_TRANSITIONS,
2236
+ isValidTransition,
1428
2237
  logger
1429
2238
  });
1430
2239
  //# sourceMappingURL=index.js.map