@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.d.mts +414 -5
- package/dist/index.d.ts +414 -5
- package/dist/index.js +839 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +833 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
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.
|
|
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.
|
|
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.
|
|
76
|
-
this.
|
|
77
|
-
this.
|
|
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)
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|