@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.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.
|
|
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.
|
|
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.
|
|
121
|
-
this.
|
|
122
|
-
this.
|
|
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)
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|