@topgunbuild/client 0.10.0 → 0.10.1
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 +134 -204
- package/dist/index.d.ts +134 -204
- package/dist/index.js +2638 -1889
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2607 -1858
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/SyncEngine.ts
|
|
2
|
-
import { HLC, LWWMap, ORMap
|
|
2
|
+
import { HLC, LWWMap as LWWMap2, ORMap as ORMap2, deserialize as deserialize2 } from "@topgunbuild/core";
|
|
3
3
|
|
|
4
4
|
// src/utils/logger.ts
|
|
5
5
|
import pino from "pino";
|
|
@@ -180,21 +180,6 @@ var SyncStateMachine = class {
|
|
|
180
180
|
}
|
|
181
181
|
};
|
|
182
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
183
|
// src/BackpressureConfig.ts
|
|
199
184
|
var DEFAULT_BACKPRESSURE_CONFIG = {
|
|
200
185
|
maxPendingOps: 1e3,
|
|
@@ -203,233 +188,6 @@ var DEFAULT_BACKPRESSURE_CONFIG = {
|
|
|
203
188
|
lowWaterMark: 0.5
|
|
204
189
|
};
|
|
205
190
|
|
|
206
|
-
// src/connection/SingleServerProvider.ts
|
|
207
|
-
var DEFAULT_CONFIG = {
|
|
208
|
-
maxReconnectAttempts: 10,
|
|
209
|
-
reconnectDelayMs: 1e3,
|
|
210
|
-
backoffMultiplier: 2,
|
|
211
|
-
maxReconnectDelayMs: 3e4
|
|
212
|
-
};
|
|
213
|
-
var SingleServerProvider = class {
|
|
214
|
-
constructor(config) {
|
|
215
|
-
this.ws = null;
|
|
216
|
-
this.reconnectAttempts = 0;
|
|
217
|
-
this.reconnectTimer = null;
|
|
218
|
-
this.isClosing = false;
|
|
219
|
-
this.listeners = /* @__PURE__ */ new Map();
|
|
220
|
-
this.url = config.url;
|
|
221
|
-
this.config = {
|
|
222
|
-
url: config.url,
|
|
223
|
-
maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
|
|
224
|
-
reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
|
|
225
|
-
backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
|
|
226
|
-
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Connect to the WebSocket server.
|
|
231
|
-
*/
|
|
232
|
-
async connect() {
|
|
233
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
this.isClosing = false;
|
|
237
|
-
return new Promise((resolve, reject) => {
|
|
238
|
-
try {
|
|
239
|
-
this.ws = new WebSocket(this.url);
|
|
240
|
-
this.ws.binaryType = "arraybuffer";
|
|
241
|
-
this.ws.onopen = () => {
|
|
242
|
-
this.reconnectAttempts = 0;
|
|
243
|
-
logger.info({ url: this.url }, "SingleServerProvider connected");
|
|
244
|
-
this.emit("connected", "default");
|
|
245
|
-
resolve();
|
|
246
|
-
};
|
|
247
|
-
this.ws.onerror = (error) => {
|
|
248
|
-
logger.error({ err: error, url: this.url }, "SingleServerProvider WebSocket error");
|
|
249
|
-
this.emit("error", error);
|
|
250
|
-
};
|
|
251
|
-
this.ws.onclose = (event) => {
|
|
252
|
-
logger.info({ url: this.url, code: event.code }, "SingleServerProvider disconnected");
|
|
253
|
-
this.emit("disconnected", "default");
|
|
254
|
-
if (!this.isClosing) {
|
|
255
|
-
this.scheduleReconnect();
|
|
256
|
-
}
|
|
257
|
-
};
|
|
258
|
-
this.ws.onmessage = (event) => {
|
|
259
|
-
this.emit("message", "default", event.data);
|
|
260
|
-
};
|
|
261
|
-
const timeoutId = setTimeout(() => {
|
|
262
|
-
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
263
|
-
this.ws.close();
|
|
264
|
-
reject(new Error(`Connection timeout to ${this.url}`));
|
|
265
|
-
}
|
|
266
|
-
}, this.config.reconnectDelayMs * 5);
|
|
267
|
-
const originalOnOpen = this.ws.onopen;
|
|
268
|
-
const wsRef = this.ws;
|
|
269
|
-
this.ws.onopen = (ev) => {
|
|
270
|
-
clearTimeout(timeoutId);
|
|
271
|
-
if (originalOnOpen) {
|
|
272
|
-
originalOnOpen.call(wsRef, ev);
|
|
273
|
-
}
|
|
274
|
-
};
|
|
275
|
-
} catch (error) {
|
|
276
|
-
reject(error);
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Get connection for a specific key.
|
|
282
|
-
* In single-server mode, key is ignored.
|
|
283
|
-
*/
|
|
284
|
-
getConnection(_key) {
|
|
285
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
286
|
-
throw new Error("Not connected");
|
|
287
|
-
}
|
|
288
|
-
return this.ws;
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* Get any available connection.
|
|
292
|
-
*/
|
|
293
|
-
getAnyConnection() {
|
|
294
|
-
return this.getConnection("");
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Check if connected.
|
|
298
|
-
*/
|
|
299
|
-
isConnected() {
|
|
300
|
-
return this.ws?.readyState === WebSocket.OPEN;
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Get connected node IDs.
|
|
304
|
-
* Single-server mode returns ['default'] when connected.
|
|
305
|
-
*/
|
|
306
|
-
getConnectedNodes() {
|
|
307
|
-
return this.isConnected() ? ["default"] : [];
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Subscribe to connection events.
|
|
311
|
-
*/
|
|
312
|
-
on(event, handler2) {
|
|
313
|
-
if (!this.listeners.has(event)) {
|
|
314
|
-
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
315
|
-
}
|
|
316
|
-
this.listeners.get(event).add(handler2);
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* Unsubscribe from connection events.
|
|
320
|
-
*/
|
|
321
|
-
off(event, handler2) {
|
|
322
|
-
this.listeners.get(event)?.delete(handler2);
|
|
323
|
-
}
|
|
324
|
-
/**
|
|
325
|
-
* Send data via the WebSocket connection.
|
|
326
|
-
* In single-server mode, key parameter is ignored.
|
|
327
|
-
*/
|
|
328
|
-
send(data, _key) {
|
|
329
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
330
|
-
throw new Error("Not connected");
|
|
331
|
-
}
|
|
332
|
-
this.ws.send(data);
|
|
333
|
-
}
|
|
334
|
-
/**
|
|
335
|
-
* Close the WebSocket connection.
|
|
336
|
-
*/
|
|
337
|
-
async close() {
|
|
338
|
-
this.isClosing = true;
|
|
339
|
-
if (this.reconnectTimer) {
|
|
340
|
-
clearTimeout(this.reconnectTimer);
|
|
341
|
-
this.reconnectTimer = null;
|
|
342
|
-
}
|
|
343
|
-
if (this.ws) {
|
|
344
|
-
this.ws.onclose = null;
|
|
345
|
-
this.ws.onerror = null;
|
|
346
|
-
this.ws.onmessage = null;
|
|
347
|
-
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
348
|
-
this.ws.close();
|
|
349
|
-
}
|
|
350
|
-
this.ws = null;
|
|
351
|
-
}
|
|
352
|
-
logger.info({ url: this.url }, "SingleServerProvider closed");
|
|
353
|
-
}
|
|
354
|
-
/**
|
|
355
|
-
* Emit an event to all listeners.
|
|
356
|
-
*/
|
|
357
|
-
emit(event, ...args) {
|
|
358
|
-
const handlers = this.listeners.get(event);
|
|
359
|
-
if (handlers) {
|
|
360
|
-
for (const handler2 of handlers) {
|
|
361
|
-
try {
|
|
362
|
-
handler2(...args);
|
|
363
|
-
} catch (err) {
|
|
364
|
-
logger.error({ err, event }, "Error in SingleServerProvider event handler");
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
/**
|
|
370
|
-
* Schedule a reconnection attempt with exponential backoff.
|
|
371
|
-
*/
|
|
372
|
-
scheduleReconnect() {
|
|
373
|
-
if (this.reconnectTimer) {
|
|
374
|
-
clearTimeout(this.reconnectTimer);
|
|
375
|
-
this.reconnectTimer = null;
|
|
376
|
-
}
|
|
377
|
-
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
378
|
-
logger.error(
|
|
379
|
-
{ attempts: this.reconnectAttempts, url: this.url },
|
|
380
|
-
"SingleServerProvider max reconnect attempts reached"
|
|
381
|
-
);
|
|
382
|
-
this.emit("error", new Error("Max reconnection attempts reached"));
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
const delay = this.calculateBackoffDelay();
|
|
386
|
-
logger.info(
|
|
387
|
-
{ delay, attempt: this.reconnectAttempts, url: this.url },
|
|
388
|
-
`SingleServerProvider scheduling reconnect in ${delay}ms`
|
|
389
|
-
);
|
|
390
|
-
this.reconnectTimer = setTimeout(async () => {
|
|
391
|
-
this.reconnectTimer = null;
|
|
392
|
-
this.reconnectAttempts++;
|
|
393
|
-
try {
|
|
394
|
-
await this.connect();
|
|
395
|
-
this.emit("reconnected", "default");
|
|
396
|
-
} catch (error) {
|
|
397
|
-
logger.error({ err: error }, "SingleServerProvider reconnection failed");
|
|
398
|
-
this.scheduleReconnect();
|
|
399
|
-
}
|
|
400
|
-
}, delay);
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* Calculate backoff delay with exponential increase.
|
|
404
|
-
*/
|
|
405
|
-
calculateBackoffDelay() {
|
|
406
|
-
const { reconnectDelayMs, backoffMultiplier, maxReconnectDelayMs } = this.config;
|
|
407
|
-
let delay = reconnectDelayMs * Math.pow(backoffMultiplier, this.reconnectAttempts);
|
|
408
|
-
delay = Math.min(delay, maxReconnectDelayMs);
|
|
409
|
-
delay = delay * (0.5 + Math.random());
|
|
410
|
-
return Math.floor(delay);
|
|
411
|
-
}
|
|
412
|
-
/**
|
|
413
|
-
* Get the WebSocket URL this provider connects to.
|
|
414
|
-
*/
|
|
415
|
-
getUrl() {
|
|
416
|
-
return this.url;
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Get current reconnection attempt count.
|
|
420
|
-
*/
|
|
421
|
-
getReconnectAttempts() {
|
|
422
|
-
return this.reconnectAttempts;
|
|
423
|
-
}
|
|
424
|
-
/**
|
|
425
|
-
* Reset reconnection counter.
|
|
426
|
-
* Called externally after successful authentication.
|
|
427
|
-
*/
|
|
428
|
-
resetReconnectAttempts() {
|
|
429
|
-
this.reconnectAttempts = 0;
|
|
430
|
-
}
|
|
431
|
-
};
|
|
432
|
-
|
|
433
191
|
// src/ConflictResolverClient.ts
|
|
434
192
|
var _ConflictResolverClient = class _ConflictResolverClient {
|
|
435
193
|
// 10 seconds
|
|
@@ -661,189 +419,52 @@ var _ConflictResolverClient = class _ConflictResolverClient {
|
|
|
661
419
|
_ConflictResolverClient.REQUEST_TIMEOUT = 1e4;
|
|
662
420
|
var ConflictResolverClient = _ConflictResolverClient;
|
|
663
421
|
|
|
664
|
-
// src/
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
maxDelayMs: 3e4,
|
|
668
|
-
multiplier: 2,
|
|
669
|
-
jitter: true,
|
|
670
|
-
maxRetries: 10
|
|
671
|
-
};
|
|
672
|
-
var _SyncEngine = class _SyncEngine {
|
|
422
|
+
// src/sync/WebSocketManager.ts
|
|
423
|
+
import { serialize, deserialize } from "@topgunbuild/core";
|
|
424
|
+
var WebSocketManager = class {
|
|
673
425
|
constructor(config) {
|
|
674
|
-
|
|
675
|
-
this.opLog = [];
|
|
676
|
-
this.maps = /* @__PURE__ */ new Map();
|
|
677
|
-
this.queries = /* @__PURE__ */ new Map();
|
|
678
|
-
this.topics = /* @__PURE__ */ new Map();
|
|
679
|
-
this.pendingLockRequests = /* @__PURE__ */ new Map();
|
|
680
|
-
this.lastSyncTimestamp = 0;
|
|
426
|
+
// Reconnection state
|
|
681
427
|
this.reconnectTimer = null;
|
|
682
|
-
// NodeJS.Timeout
|
|
683
|
-
this.authToken = null;
|
|
684
|
-
this.tokenProvider = null;
|
|
685
428
|
this.backoffAttempt = 0;
|
|
429
|
+
// Heartbeat state
|
|
686
430
|
this.heartbeatInterval = null;
|
|
687
431
|
this.lastPongReceived = Date.now();
|
|
688
432
|
this.lastRoundTripTime = null;
|
|
689
|
-
this.
|
|
690
|
-
this.
|
|
691
|
-
this.highWaterMarkEmitted = false;
|
|
692
|
-
this.backpressureListeners = /* @__PURE__ */ new Map();
|
|
693
|
-
// Write Concern state (Phase 5.01)
|
|
694
|
-
this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
|
|
695
|
-
// ============================================
|
|
696
|
-
// PN Counter Methods (Phase 5.2)
|
|
697
|
-
// ============================================
|
|
698
|
-
/** Counter update listeners by name */
|
|
699
|
-
this.counterUpdateListeners = /* @__PURE__ */ new Map();
|
|
700
|
-
// ============================================
|
|
701
|
-
// Entry Processor Methods (Phase 5.03)
|
|
702
|
-
// ============================================
|
|
703
|
-
/** Pending entry processor requests by requestId */
|
|
704
|
-
this.pendingProcessorRequests = /* @__PURE__ */ new Map();
|
|
705
|
-
/** Pending batch entry processor requests by requestId */
|
|
706
|
-
this.pendingBatchProcessorRequests = /* @__PURE__ */ new Map();
|
|
707
|
-
// ============================================
|
|
708
|
-
// Event Journal Methods (Phase 5.04)
|
|
709
|
-
// ============================================
|
|
710
|
-
/** Message listeners for journal and other generic messages */
|
|
711
|
-
this.messageListeners = /* @__PURE__ */ new Set();
|
|
712
|
-
// ============================================
|
|
713
|
-
// Full-Text Search Methods (Phase 11.1a)
|
|
714
|
-
// ============================================
|
|
715
|
-
/** Pending search requests by requestId */
|
|
716
|
-
this.pendingSearchRequests = /* @__PURE__ */ new Map();
|
|
717
|
-
// ============================================
|
|
718
|
-
// Hybrid Query Support (Phase 12)
|
|
719
|
-
// ============================================
|
|
720
|
-
/** Active hybrid query subscriptions */
|
|
721
|
-
this.hybridQueries = /* @__PURE__ */ new Map();
|
|
722
|
-
if (!config.serverUrl && !config.connectionProvider) {
|
|
723
|
-
throw new Error("SyncEngine requires either serverUrl or connectionProvider");
|
|
724
|
-
}
|
|
725
|
-
this.nodeId = config.nodeId;
|
|
726
|
-
this.serverUrl = config.serverUrl || "";
|
|
727
|
-
this.storageAdapter = config.storageAdapter;
|
|
728
|
-
this.hlc = new HLC(this.nodeId);
|
|
729
|
-
this.stateMachine = new SyncStateMachine();
|
|
730
|
-
this.heartbeatConfig = {
|
|
731
|
-
intervalMs: config.heartbeat?.intervalMs ?? 5e3,
|
|
732
|
-
timeoutMs: config.heartbeat?.timeoutMs ?? 15e3,
|
|
733
|
-
enabled: config.heartbeat?.enabled ?? true
|
|
734
|
-
};
|
|
735
|
-
this.backoffConfig = {
|
|
736
|
-
...DEFAULT_BACKOFF_CONFIG,
|
|
737
|
-
...config.backoff
|
|
738
|
-
};
|
|
739
|
-
this.backpressureConfig = {
|
|
740
|
-
...DEFAULT_BACKPRESSURE_CONFIG,
|
|
741
|
-
...config.backpressure
|
|
742
|
-
};
|
|
743
|
-
if (config.connectionProvider) {
|
|
744
|
-
this.connectionProvider = config.connectionProvider;
|
|
745
|
-
this.useConnectionProvider = true;
|
|
746
|
-
this.initConnectionProvider();
|
|
747
|
-
} else {
|
|
748
|
-
this.connectionProvider = new SingleServerProvider({ url: config.serverUrl });
|
|
749
|
-
this.useConnectionProvider = false;
|
|
750
|
-
this.initConnection();
|
|
751
|
-
}
|
|
752
|
-
this.conflictResolverClient = new ConflictResolverClient(this);
|
|
753
|
-
this.loadOpLog();
|
|
754
|
-
}
|
|
755
|
-
// ============================================
|
|
756
|
-
// State Machine Public API
|
|
757
|
-
// ============================================
|
|
758
|
-
/**
|
|
759
|
-
* Get the current connection state
|
|
760
|
-
*/
|
|
761
|
-
getConnectionState() {
|
|
762
|
-
return this.stateMachine.getState();
|
|
763
|
-
}
|
|
764
|
-
/**
|
|
765
|
-
* Subscribe to connection state changes
|
|
766
|
-
* @returns Unsubscribe function
|
|
767
|
-
*/
|
|
768
|
-
onConnectionStateChange(listener) {
|
|
769
|
-
return this.stateMachine.onStateChange(listener);
|
|
770
|
-
}
|
|
771
|
-
/**
|
|
772
|
-
* Get state machine history for debugging
|
|
773
|
-
*/
|
|
774
|
-
getStateHistory(limit) {
|
|
775
|
-
return this.stateMachine.getHistory(limit);
|
|
776
|
-
}
|
|
777
|
-
// ============================================
|
|
778
|
-
// Internal State Helpers (replace boolean flags)
|
|
779
|
-
// ============================================
|
|
780
|
-
/**
|
|
781
|
-
* Check if WebSocket is connected (but may not be authenticated yet)
|
|
782
|
-
*/
|
|
783
|
-
isOnline() {
|
|
784
|
-
const state = this.stateMachine.getState();
|
|
785
|
-
return state === "CONNECTING" /* CONNECTING */ || state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
|
|
786
|
-
}
|
|
787
|
-
/**
|
|
788
|
-
* Check if fully authenticated and ready for operations
|
|
789
|
-
*/
|
|
790
|
-
isAuthenticated() {
|
|
791
|
-
const state = this.stateMachine.getState();
|
|
792
|
-
return state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
|
|
433
|
+
this.config = config;
|
|
434
|
+
this.connectionProvider = config.connectionProvider;
|
|
793
435
|
}
|
|
794
436
|
/**
|
|
795
|
-
*
|
|
437
|
+
* Initialize the connection.
|
|
438
|
+
* Sets up event handlers and starts the connection process.
|
|
796
439
|
*/
|
|
797
|
-
|
|
798
|
-
|
|
440
|
+
connect() {
|
|
441
|
+
this.initConnectionProvider();
|
|
799
442
|
}
|
|
800
|
-
// ============================================
|
|
801
|
-
// Connection Management
|
|
802
|
-
// ============================================
|
|
803
443
|
/**
|
|
804
|
-
* Initialize connection using IConnectionProvider
|
|
805
|
-
* Sets up event handlers for the connection provider.
|
|
444
|
+
* Initialize connection using IConnectionProvider.
|
|
806
445
|
*/
|
|
807
446
|
initConnectionProvider() {
|
|
808
|
-
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
447
|
+
this.config.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
809
448
|
this.connectionProvider.on("connected", (_nodeId) => {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
813
|
-
this.sendAuth();
|
|
814
|
-
} else {
|
|
815
|
-
logger.info("ConnectionProvider connected. Waiting for auth token...");
|
|
816
|
-
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
817
|
-
}
|
|
449
|
+
logger.info("ConnectionProvider connected.");
|
|
450
|
+
this.config.onConnected?.();
|
|
818
451
|
});
|
|
819
452
|
this.connectionProvider.on("disconnected", (_nodeId) => {
|
|
820
453
|
logger.info("ConnectionProvider disconnected.");
|
|
821
454
|
this.stopHeartbeat();
|
|
822
|
-
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
455
|
+
this.config.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
456
|
+
this.config.onDisconnected?.();
|
|
823
457
|
});
|
|
824
458
|
this.connectionProvider.on("reconnected", (_nodeId) => {
|
|
825
459
|
logger.info("ConnectionProvider reconnected.");
|
|
826
|
-
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
827
|
-
|
|
828
|
-
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
829
|
-
this.sendAuth();
|
|
830
|
-
}
|
|
460
|
+
this.config.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
461
|
+
this.config.onReconnected?.();
|
|
831
462
|
});
|
|
832
463
|
this.connectionProvider.on("message", (_nodeId, data) => {
|
|
833
|
-
|
|
834
|
-
if (
|
|
835
|
-
message
|
|
836
|
-
} else if (data instanceof Uint8Array) {
|
|
837
|
-
message = deserialize(data);
|
|
838
|
-
} else {
|
|
839
|
-
try {
|
|
840
|
-
message = typeof data === "string" ? JSON.parse(data) : data;
|
|
841
|
-
} catch (e) {
|
|
842
|
-
logger.error({ err: e }, "Failed to parse message from ConnectionProvider");
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
464
|
+
const message = this.deserializeMessage(data);
|
|
465
|
+
if (message) {
|
|
466
|
+
this.handleMessage(message);
|
|
845
467
|
}
|
|
846
|
-
this.handleServerMessage(message);
|
|
847
468
|
});
|
|
848
469
|
this.connectionProvider.on("partitionMapUpdated", () => {
|
|
849
470
|
logger.debug("Partition map updated");
|
|
@@ -853,109 +474,48 @@ var _SyncEngine = class _SyncEngine {
|
|
|
853
474
|
});
|
|
854
475
|
this.connectionProvider.connect().catch((err) => {
|
|
855
476
|
logger.error({ err }, "Failed to connect via ConnectionProvider");
|
|
856
|
-
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
477
|
+
this.config.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
857
478
|
});
|
|
858
479
|
}
|
|
859
480
|
/**
|
|
860
|
-
*
|
|
481
|
+
* Deserialize incoming message data.
|
|
861
482
|
*/
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
this.sendAuth();
|
|
871
|
-
} else {
|
|
872
|
-
logger.info("WebSocket connected. Waiting for auth token...");
|
|
873
|
-
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
874
|
-
}
|
|
875
|
-
};
|
|
876
|
-
this.websocket.onmessage = (event) => {
|
|
877
|
-
let message;
|
|
878
|
-
if (event.data instanceof ArrayBuffer) {
|
|
879
|
-
message = deserialize(new Uint8Array(event.data));
|
|
483
|
+
deserializeMessage(data) {
|
|
484
|
+
try {
|
|
485
|
+
if (data instanceof ArrayBuffer) {
|
|
486
|
+
return deserialize(new Uint8Array(data));
|
|
487
|
+
} else if (data instanceof Uint8Array) {
|
|
488
|
+
return deserialize(data);
|
|
489
|
+
} else if (typeof data === "string") {
|
|
490
|
+
return JSON.parse(data);
|
|
880
491
|
} else {
|
|
881
|
-
|
|
882
|
-
message = JSON.parse(event.data);
|
|
883
|
-
} catch (e) {
|
|
884
|
-
logger.error({ err: e }, "Failed to parse message");
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
492
|
+
return data;
|
|
887
493
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
logger.info("WebSocket disconnected.");
|
|
892
|
-
this.stopHeartbeat();
|
|
893
|
-
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
894
|
-
this.scheduleReconnect();
|
|
895
|
-
};
|
|
896
|
-
this.websocket.onerror = (error) => {
|
|
897
|
-
logger.error({ err: error }, "WebSocket error");
|
|
898
|
-
};
|
|
899
|
-
}
|
|
900
|
-
scheduleReconnect() {
|
|
901
|
-
if (this.reconnectTimer) {
|
|
902
|
-
clearTimeout(this.reconnectTimer);
|
|
903
|
-
this.reconnectTimer = null;
|
|
904
|
-
}
|
|
905
|
-
if (this.backoffAttempt >= this.backoffConfig.maxRetries) {
|
|
906
|
-
logger.error(
|
|
907
|
-
{ attempts: this.backoffAttempt },
|
|
908
|
-
"Max reconnection attempts reached. Entering ERROR state."
|
|
909
|
-
);
|
|
910
|
-
this.stateMachine.transition("ERROR" /* ERROR */);
|
|
911
|
-
return;
|
|
912
|
-
}
|
|
913
|
-
this.stateMachine.transition("BACKOFF" /* BACKOFF */);
|
|
914
|
-
const delay = this.calculateBackoffDelay();
|
|
915
|
-
logger.info({ delay, attempt: this.backoffAttempt }, `Backing off for ${delay}ms`);
|
|
916
|
-
this.reconnectTimer = setTimeout(() => {
|
|
917
|
-
this.reconnectTimer = null;
|
|
918
|
-
this.backoffAttempt++;
|
|
919
|
-
this.initConnection();
|
|
920
|
-
}, delay);
|
|
921
|
-
}
|
|
922
|
-
calculateBackoffDelay() {
|
|
923
|
-
const { initialDelayMs, maxDelayMs, multiplier, jitter } = this.backoffConfig;
|
|
924
|
-
let delay = initialDelayMs * Math.pow(multiplier, this.backoffAttempt);
|
|
925
|
-
delay = Math.min(delay, maxDelayMs);
|
|
926
|
-
if (jitter) {
|
|
927
|
-
delay = delay * (0.5 + Math.random());
|
|
494
|
+
} catch (e) {
|
|
495
|
+
logger.error({ err: e }, "Failed to parse message");
|
|
496
|
+
return null;
|
|
928
497
|
}
|
|
929
|
-
return Math.floor(delay);
|
|
930
498
|
}
|
|
931
499
|
/**
|
|
932
|
-
*
|
|
500
|
+
* Handle incoming message.
|
|
501
|
+
* Routes PONG to internal handler, all others to SyncEngine.
|
|
933
502
|
*/
|
|
934
|
-
|
|
935
|
-
|
|
503
|
+
handleMessage(message) {
|
|
504
|
+
if (message.type === "PONG") {
|
|
505
|
+
this.handlePong(message);
|
|
506
|
+
}
|
|
507
|
+
this.config.onMessage(message);
|
|
936
508
|
}
|
|
937
509
|
/**
|
|
938
510
|
* Send a message through the current connection.
|
|
939
|
-
* Uses connectionProvider if in cluster mode, otherwise uses direct websocket.
|
|
940
|
-
* @param message Message object to serialize and send
|
|
941
|
-
* @param key Optional key for routing (cluster mode only)
|
|
942
|
-
* @returns true if message was sent, false otherwise
|
|
943
511
|
*/
|
|
944
512
|
sendMessage(message, key) {
|
|
945
513
|
const data = serialize(message);
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
logger.warn({ err }, "Failed to send via ConnectionProvider");
|
|
952
|
-
return false;
|
|
953
|
-
}
|
|
954
|
-
} else {
|
|
955
|
-
if (this.websocket?.readyState === WebSocket.OPEN) {
|
|
956
|
-
this.websocket.send(data);
|
|
957
|
-
return true;
|
|
958
|
-
}
|
|
514
|
+
try {
|
|
515
|
+
this.connectionProvider.send(data, key);
|
|
516
|
+
return true;
|
|
517
|
+
} catch (err) {
|
|
518
|
+
logger.warn({ err }, "Failed to send via ConnectionProvider");
|
|
959
519
|
return false;
|
|
960
520
|
}
|
|
961
521
|
}
|
|
@@ -963,110 +523,2009 @@ var _SyncEngine = class _SyncEngine {
|
|
|
963
523
|
* Check if we can send messages (connection is ready).
|
|
964
524
|
*/
|
|
965
525
|
canSend() {
|
|
966
|
-
|
|
967
|
-
return this.connectionProvider.isConnected();
|
|
968
|
-
}
|
|
969
|
-
return this.websocket?.readyState === WebSocket.OPEN;
|
|
526
|
+
return this.connectionProvider.isConnected();
|
|
970
527
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Check if connected to the server.
|
|
530
|
+
*/
|
|
531
|
+
isOnline() {
|
|
532
|
+
const state = this.config.stateMachine.getState();
|
|
533
|
+
return state === "CONNECTING" /* CONNECTING */ || state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Get the connection provider.
|
|
537
|
+
*/
|
|
538
|
+
getConnectionProvider() {
|
|
539
|
+
return this.connectionProvider;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Close the connection and clean up resources.
|
|
543
|
+
*/
|
|
544
|
+
close() {
|
|
545
|
+
this.stopHeartbeat();
|
|
546
|
+
if (this.reconnectTimer) {
|
|
547
|
+
clearTimeout(this.reconnectTimer);
|
|
548
|
+
this.reconnectTimer = null;
|
|
984
549
|
}
|
|
550
|
+
this.connectionProvider.close().catch((err) => {
|
|
551
|
+
logger.error({ err }, "Error closing ConnectionProvider");
|
|
552
|
+
});
|
|
985
553
|
}
|
|
986
|
-
|
|
987
|
-
|
|
554
|
+
/**
|
|
555
|
+
* Reset connection state for a fresh reconnection.
|
|
556
|
+
*/
|
|
557
|
+
reset() {
|
|
558
|
+
this.close();
|
|
559
|
+
this.resetBackoff();
|
|
988
560
|
}
|
|
989
|
-
|
|
990
|
-
|
|
561
|
+
/**
|
|
562
|
+
* Subscribe to connection events.
|
|
563
|
+
*/
|
|
564
|
+
on(event, handler2) {
|
|
565
|
+
this.connectionProvider.on(event, handler2);
|
|
991
566
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
key,
|
|
998
|
-
record: data.record,
|
|
999
|
-
orRecord: data.orRecord,
|
|
1000
|
-
orTag: data.orTag,
|
|
1001
|
-
timestamp: data.timestamp,
|
|
1002
|
-
synced: false
|
|
1003
|
-
};
|
|
1004
|
-
const id = await this.storageAdapter.appendOpLog(opLogEntry);
|
|
1005
|
-
opLogEntry.id = String(id);
|
|
1006
|
-
this.opLog.push(opLogEntry);
|
|
1007
|
-
this.checkHighWaterMark();
|
|
1008
|
-
if (this.isAuthenticated()) {
|
|
1009
|
-
this.syncPendingOperations();
|
|
1010
|
-
}
|
|
1011
|
-
return opLogEntry.id;
|
|
567
|
+
/**
|
|
568
|
+
* Unsubscribe from connection events.
|
|
569
|
+
*/
|
|
570
|
+
off(event, handler2) {
|
|
571
|
+
this.connectionProvider.off(event, handler2);
|
|
1012
572
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
this.
|
|
1018
|
-
type: "OP_BATCH",
|
|
1019
|
-
payload: {
|
|
1020
|
-
ops: pending
|
|
1021
|
-
}
|
|
1022
|
-
});
|
|
573
|
+
/**
|
|
574
|
+
* Reset backoff counter.
|
|
575
|
+
*/
|
|
576
|
+
resetBackoff() {
|
|
577
|
+
this.backoffAttempt = 0;
|
|
1023
578
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
type: "SYNC_INIT",
|
|
1030
|
-
mapName,
|
|
1031
|
-
lastSyncTimestamp: this.lastSyncTimestamp
|
|
1032
|
-
});
|
|
1033
|
-
} else if (map instanceof ORMap) {
|
|
1034
|
-
logger.info({ mapName }, "Starting Merkle sync for ORMap");
|
|
1035
|
-
const tree = map.getMerkleTree();
|
|
1036
|
-
const rootHash = tree.getRootHash();
|
|
1037
|
-
const bucketHashes = tree.getBuckets("");
|
|
1038
|
-
this.sendMessage({
|
|
1039
|
-
type: "ORMAP_SYNC_INIT",
|
|
1040
|
-
mapName,
|
|
1041
|
-
rootHash,
|
|
1042
|
-
bucketHashes,
|
|
1043
|
-
lastSyncTimestamp: this.lastSyncTimestamp
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
579
|
+
/**
|
|
580
|
+
* Get current backoff attempt count.
|
|
581
|
+
*/
|
|
582
|
+
getBackoffAttempt() {
|
|
583
|
+
return this.backoffAttempt;
|
|
1047
584
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
if (
|
|
1053
|
-
this.
|
|
1054
|
-
|
|
1055
|
-
logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
|
|
1056
|
-
if (this.reconnectTimer) {
|
|
1057
|
-
clearTimeout(this.reconnectTimer);
|
|
1058
|
-
this.reconnectTimer = null;
|
|
1059
|
-
}
|
|
1060
|
-
this.resetBackoff();
|
|
1061
|
-
this.initConnection();
|
|
585
|
+
/**
|
|
586
|
+
* Clear reconnect timer (for external control, e.g., when new auth token provided).
|
|
587
|
+
*/
|
|
588
|
+
clearReconnectTimer() {
|
|
589
|
+
if (this.reconnectTimer) {
|
|
590
|
+
clearTimeout(this.reconnectTimer);
|
|
591
|
+
this.reconnectTimer = null;
|
|
1062
592
|
}
|
|
1063
593
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
594
|
+
// ============================================
|
|
595
|
+
// Heartbeat Mechanism
|
|
596
|
+
// ============================================
|
|
597
|
+
/**
|
|
598
|
+
* Starts the heartbeat mechanism after successful connection.
|
|
599
|
+
*/
|
|
600
|
+
startHeartbeat() {
|
|
601
|
+
if (!this.config.heartbeatConfig.enabled) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
this.stopHeartbeat();
|
|
605
|
+
this.lastPongReceived = Date.now();
|
|
606
|
+
this.heartbeatInterval = setInterval(() => {
|
|
607
|
+
this.sendPing();
|
|
608
|
+
this.checkHeartbeatTimeout();
|
|
609
|
+
}, this.config.heartbeatConfig.intervalMs);
|
|
610
|
+
logger.info({ intervalMs: this.config.heartbeatConfig.intervalMs }, "Heartbeat started");
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Stops the heartbeat mechanism.
|
|
614
|
+
*/
|
|
615
|
+
stopHeartbeat() {
|
|
616
|
+
if (this.heartbeatInterval) {
|
|
617
|
+
clearInterval(this.heartbeatInterval);
|
|
618
|
+
this.heartbeatInterval = null;
|
|
619
|
+
logger.info("Heartbeat stopped");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Sends a PING message to the server.
|
|
624
|
+
*/
|
|
625
|
+
sendPing() {
|
|
626
|
+
if (this.canSend()) {
|
|
627
|
+
const pingMessage = {
|
|
628
|
+
type: "PING",
|
|
629
|
+
timestamp: Date.now()
|
|
630
|
+
};
|
|
631
|
+
this.sendMessage(pingMessage);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Handles incoming PONG message from server.
|
|
636
|
+
*/
|
|
637
|
+
handlePong(msg) {
|
|
638
|
+
const now = Date.now();
|
|
639
|
+
this.lastPongReceived = now;
|
|
640
|
+
this.lastRoundTripTime = now - msg.timestamp;
|
|
641
|
+
logger.debug({
|
|
642
|
+
rtt: this.lastRoundTripTime,
|
|
643
|
+
serverTime: msg.serverTime,
|
|
644
|
+
clockSkew: msg.serverTime - (msg.timestamp + this.lastRoundTripTime / 2)
|
|
645
|
+
}, "Received PONG");
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Checks if heartbeat has timed out and triggers reconnection if needed.
|
|
649
|
+
*/
|
|
650
|
+
checkHeartbeatTimeout() {
|
|
651
|
+
const now = Date.now();
|
|
652
|
+
const timeSinceLastPong = now - this.lastPongReceived;
|
|
653
|
+
if (timeSinceLastPong > this.config.heartbeatConfig.timeoutMs) {
|
|
654
|
+
logger.warn({
|
|
655
|
+
timeSinceLastPong,
|
|
656
|
+
timeoutMs: this.config.heartbeatConfig.timeoutMs
|
|
657
|
+
}, "Heartbeat timeout - triggering reconnection");
|
|
658
|
+
this.stopHeartbeat();
|
|
659
|
+
this.connectionProvider.close().catch((err) => {
|
|
660
|
+
logger.error({ err }, "Error closing ConnectionProvider on heartbeat timeout");
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Returns the last measured round-trip time in milliseconds.
|
|
666
|
+
*/
|
|
667
|
+
getLastRoundTripTime() {
|
|
668
|
+
return this.lastRoundTripTime;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Returns true if the connection is considered healthy based on heartbeat.
|
|
672
|
+
*/
|
|
673
|
+
isConnectionHealthy() {
|
|
674
|
+
const state = this.config.stateMachine.getState();
|
|
675
|
+
const isOnline = state === "CONNECTING" /* CONNECTING */ || state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
|
|
676
|
+
const isAuthenticated = state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
|
|
677
|
+
if (!isOnline || !isAuthenticated) {
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
if (!this.config.heartbeatConfig.enabled) {
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
const timeSinceLastPong = Date.now() - this.lastPongReceived;
|
|
684
|
+
return timeSinceLastPong < this.config.heartbeatConfig.timeoutMs;
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// src/errors/BackpressureError.ts
|
|
689
|
+
var BackpressureError = class _BackpressureError extends Error {
|
|
690
|
+
constructor(pendingCount, maxPending) {
|
|
691
|
+
super(
|
|
692
|
+
`Backpressure limit reached: ${pendingCount}/${maxPending} pending operations. Wait for acknowledgments or increase maxPendingOps.`
|
|
693
|
+
);
|
|
694
|
+
this.pendingCount = pendingCount;
|
|
695
|
+
this.maxPending = maxPending;
|
|
696
|
+
this.name = "BackpressureError";
|
|
697
|
+
if (Error.captureStackTrace) {
|
|
698
|
+
Error.captureStackTrace(this, _BackpressureError);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
// src/sync/BackpressureController.ts
|
|
704
|
+
var BackpressureController = class {
|
|
705
|
+
constructor(controllerConfig) {
|
|
706
|
+
// Internal state
|
|
707
|
+
this.backpressurePaused = false;
|
|
708
|
+
this.waitingForCapacity = [];
|
|
709
|
+
this.highWaterMarkEmitted = false;
|
|
710
|
+
this.backpressureListeners = /* @__PURE__ */ new Map();
|
|
711
|
+
this.config = controllerConfig.config;
|
|
712
|
+
this.opLog = controllerConfig.opLog;
|
|
713
|
+
}
|
|
714
|
+
// ============================================
|
|
715
|
+
// Status Methods
|
|
716
|
+
// ============================================
|
|
717
|
+
/**
|
|
718
|
+
* Get the current number of pending (unsynced) operations.
|
|
719
|
+
*/
|
|
720
|
+
getPendingOpsCount() {
|
|
721
|
+
return this.opLog.filter((op) => !op.synced).length;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Get the current backpressure status.
|
|
725
|
+
*/
|
|
726
|
+
getBackpressureStatus() {
|
|
727
|
+
const pending = this.getPendingOpsCount();
|
|
728
|
+
const max = this.config.maxPendingOps;
|
|
729
|
+
return {
|
|
730
|
+
pending,
|
|
731
|
+
max,
|
|
732
|
+
percentage: max > 0 ? pending / max : 0,
|
|
733
|
+
isPaused: this.backpressurePaused,
|
|
734
|
+
strategy: this.config.strategy
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Returns true if writes are currently paused due to backpressure.
|
|
739
|
+
*/
|
|
740
|
+
isBackpressurePaused() {
|
|
741
|
+
return this.backpressurePaused;
|
|
742
|
+
}
|
|
743
|
+
// ============================================
|
|
744
|
+
// Check Methods
|
|
745
|
+
// ============================================
|
|
746
|
+
/**
|
|
747
|
+
* Check backpressure before adding a new operation.
|
|
748
|
+
* May pause, throw, or drop depending on strategy.
|
|
749
|
+
*/
|
|
750
|
+
async checkBackpressure() {
|
|
751
|
+
const pendingCount = this.getPendingOpsCount();
|
|
752
|
+
if (pendingCount < this.config.maxPendingOps) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
switch (this.config.strategy) {
|
|
756
|
+
case "pause":
|
|
757
|
+
await this.waitForCapacity();
|
|
758
|
+
break;
|
|
759
|
+
case "throw":
|
|
760
|
+
throw new BackpressureError(
|
|
761
|
+
pendingCount,
|
|
762
|
+
this.config.maxPendingOps
|
|
763
|
+
);
|
|
764
|
+
case "drop-oldest":
|
|
765
|
+
this.dropOldestOp();
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Check high water mark and emit event if threshold reached.
|
|
771
|
+
*/
|
|
772
|
+
checkHighWaterMark() {
|
|
773
|
+
const pendingCount = this.getPendingOpsCount();
|
|
774
|
+
const threshold = Math.floor(
|
|
775
|
+
this.config.maxPendingOps * this.config.highWaterMark
|
|
776
|
+
);
|
|
777
|
+
if (pendingCount >= threshold && !this.highWaterMarkEmitted) {
|
|
778
|
+
this.highWaterMarkEmitted = true;
|
|
779
|
+
logger.warn(
|
|
780
|
+
{ pending: pendingCount, max: this.config.maxPendingOps },
|
|
781
|
+
"Backpressure high water mark reached"
|
|
782
|
+
);
|
|
783
|
+
this.emitBackpressureEvent("backpressure:high", {
|
|
784
|
+
pending: pendingCount,
|
|
785
|
+
max: this.config.maxPendingOps
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Check low water mark and resume paused writes if threshold reached.
|
|
791
|
+
*/
|
|
792
|
+
checkLowWaterMark() {
|
|
793
|
+
const pendingCount = this.getPendingOpsCount();
|
|
794
|
+
const lowThreshold = Math.floor(
|
|
795
|
+
this.config.maxPendingOps * this.config.lowWaterMark
|
|
796
|
+
);
|
|
797
|
+
const highThreshold = Math.floor(
|
|
798
|
+
this.config.maxPendingOps * this.config.highWaterMark
|
|
799
|
+
);
|
|
800
|
+
if (pendingCount < highThreshold && this.highWaterMarkEmitted) {
|
|
801
|
+
this.highWaterMarkEmitted = false;
|
|
802
|
+
}
|
|
803
|
+
if (pendingCount <= lowThreshold) {
|
|
804
|
+
if (this.backpressurePaused) {
|
|
805
|
+
this.backpressurePaused = false;
|
|
806
|
+
logger.info(
|
|
807
|
+
{ pending: pendingCount, max: this.config.maxPendingOps },
|
|
808
|
+
"Backpressure low water mark reached, resuming writes"
|
|
809
|
+
);
|
|
810
|
+
this.emitBackpressureEvent("backpressure:low", {
|
|
811
|
+
pending: pendingCount,
|
|
812
|
+
max: this.config.maxPendingOps
|
|
813
|
+
});
|
|
814
|
+
this.emitBackpressureEvent("backpressure:resumed");
|
|
815
|
+
const waiting = this.waitingForCapacity;
|
|
816
|
+
this.waitingForCapacity = [];
|
|
817
|
+
for (const resolve of waiting) {
|
|
818
|
+
resolve();
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
// ============================================
|
|
824
|
+
// Event Methods
|
|
825
|
+
// ============================================
|
|
826
|
+
/**
|
|
827
|
+
* Subscribe to backpressure events.
|
|
828
|
+
* @param event Event name: 'backpressure:high', 'backpressure:low', 'backpressure:paused', 'backpressure:resumed', 'operation:dropped'
|
|
829
|
+
* @param listener Callback function
|
|
830
|
+
* @returns Unsubscribe function
|
|
831
|
+
*/
|
|
832
|
+
onBackpressure(event, listener) {
|
|
833
|
+
if (!this.backpressureListeners.has(event)) {
|
|
834
|
+
this.backpressureListeners.set(event, /* @__PURE__ */ new Set());
|
|
835
|
+
}
|
|
836
|
+
this.backpressureListeners.get(event).add(listener);
|
|
837
|
+
return () => {
|
|
838
|
+
this.backpressureListeners.get(event)?.delete(listener);
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
// ============================================
|
|
842
|
+
// Private Methods
|
|
843
|
+
// ============================================
|
|
844
|
+
/**
|
|
845
|
+
* Emit a backpressure event to all listeners.
|
|
846
|
+
*/
|
|
847
|
+
emitBackpressureEvent(event, data) {
|
|
848
|
+
const listeners = this.backpressureListeners.get(event);
|
|
849
|
+
if (listeners) {
|
|
850
|
+
for (const listener of listeners) {
|
|
851
|
+
try {
|
|
852
|
+
listener(data);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
logger.error({ err, event }, "Error in backpressure event listener");
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Wait for capacity to become available (used by 'pause' strategy).
|
|
861
|
+
*/
|
|
862
|
+
async waitForCapacity() {
|
|
863
|
+
if (!this.backpressurePaused) {
|
|
864
|
+
this.backpressurePaused = true;
|
|
865
|
+
logger.warn("Backpressure paused - waiting for capacity");
|
|
866
|
+
this.emitBackpressureEvent("backpressure:paused");
|
|
867
|
+
}
|
|
868
|
+
return new Promise((resolve) => {
|
|
869
|
+
this.waitingForCapacity.push(resolve);
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Drop the oldest pending operation (used by 'drop-oldest' strategy).
|
|
874
|
+
* Modifies opLog via shared reference.
|
|
875
|
+
*/
|
|
876
|
+
dropOldestOp() {
|
|
877
|
+
const oldestIndex = this.opLog.findIndex((op) => !op.synced);
|
|
878
|
+
if (oldestIndex !== -1) {
|
|
879
|
+
const dropped = this.opLog[oldestIndex];
|
|
880
|
+
this.opLog.splice(oldestIndex, 1);
|
|
881
|
+
logger.warn(
|
|
882
|
+
{ opId: dropped.id, mapName: dropped.mapName, key: dropped.key },
|
|
883
|
+
"Dropped oldest pending operation due to backpressure"
|
|
884
|
+
);
|
|
885
|
+
this.emitBackpressureEvent("operation:dropped", {
|
|
886
|
+
opId: dropped.id,
|
|
887
|
+
mapName: dropped.mapName,
|
|
888
|
+
opType: dropped.opType,
|
|
889
|
+
key: dropped.key
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// src/sync/QueryManager.ts
|
|
896
|
+
import { evaluatePredicate } from "@topgunbuild/core";
|
|
897
|
+
var QueryManager = class {
|
|
898
|
+
constructor(config) {
|
|
899
|
+
/** Standard queries (single source of truth) */
|
|
900
|
+
this.queries = /* @__PURE__ */ new Map();
|
|
901
|
+
/** Hybrid queries with FTS support (single source of truth) */
|
|
902
|
+
this.hybridQueries = /* @__PURE__ */ new Map();
|
|
903
|
+
this.config = config;
|
|
904
|
+
}
|
|
905
|
+
// ============================================
|
|
906
|
+
// Query Access Methods
|
|
907
|
+
// ============================================
|
|
908
|
+
/**
|
|
909
|
+
* Get all queries (read-only access).
|
|
910
|
+
*/
|
|
911
|
+
getQueries() {
|
|
912
|
+
return this.queries;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Get all hybrid queries.
|
|
916
|
+
*/
|
|
917
|
+
getHybridQueries() {
|
|
918
|
+
return this.hybridQueries;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Get a hybrid query by ID.
|
|
922
|
+
*/
|
|
923
|
+
getHybridQuery(queryId) {
|
|
924
|
+
return this.hybridQueries.get(queryId);
|
|
925
|
+
}
|
|
926
|
+
// ============================================
|
|
927
|
+
// Standard Query Methods
|
|
928
|
+
// ============================================
|
|
929
|
+
/**
|
|
930
|
+
* Subscribe to a standard query.
|
|
931
|
+
* Adds to queries Map and sends subscription to server if authenticated.
|
|
932
|
+
*/
|
|
933
|
+
subscribeToQuery(query) {
|
|
934
|
+
this.queries.set(query.id, query);
|
|
935
|
+
if (this.config.isAuthenticated()) {
|
|
936
|
+
this.sendQuerySubscription(query);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Unsubscribe from a query.
|
|
941
|
+
* Removes from Map and sends unsubscription to server if authenticated.
|
|
942
|
+
*/
|
|
943
|
+
unsubscribeFromQuery(queryId) {
|
|
944
|
+
this.queries.delete(queryId);
|
|
945
|
+
if (this.config.isAuthenticated()) {
|
|
946
|
+
this.config.sendMessage({
|
|
947
|
+
type: "QUERY_UNSUB",
|
|
948
|
+
payload: { queryId }
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Send query subscription message to server.
|
|
954
|
+
*/
|
|
955
|
+
sendQuerySubscription(query) {
|
|
956
|
+
this.config.sendMessage({
|
|
957
|
+
type: "QUERY_SUB",
|
|
958
|
+
payload: {
|
|
959
|
+
queryId: query.id,
|
|
960
|
+
mapName: query.getMapName(),
|
|
961
|
+
query: query.getFilter()
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
// ============================================
|
|
966
|
+
// Hybrid Query Methods
|
|
967
|
+
// ============================================
|
|
968
|
+
/**
|
|
969
|
+
* Subscribe to a hybrid query (FTS + filter combination).
|
|
970
|
+
*/
|
|
971
|
+
subscribeToHybridQuery(query) {
|
|
972
|
+
this.hybridQueries.set(query.id, query);
|
|
973
|
+
const filter = query.getFilter();
|
|
974
|
+
const mapName = query.getMapName();
|
|
975
|
+
if (query.hasFTSPredicate() && this.config.isAuthenticated()) {
|
|
976
|
+
this.sendHybridQuerySubscription(query.id, mapName, filter);
|
|
977
|
+
}
|
|
978
|
+
this.runLocalHybridQuery(mapName, filter).then((results) => {
|
|
979
|
+
query.onResult(results, "local");
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Unsubscribe from a hybrid query.
|
|
984
|
+
*/
|
|
985
|
+
unsubscribeFromHybridQuery(queryId) {
|
|
986
|
+
const query = this.hybridQueries.get(queryId);
|
|
987
|
+
if (query) {
|
|
988
|
+
this.hybridQueries.delete(queryId);
|
|
989
|
+
if (this.config.isAuthenticated() && query.hasFTSPredicate()) {
|
|
990
|
+
this.config.sendMessage({
|
|
991
|
+
type: "HYBRID_QUERY_UNSUBSCRIBE",
|
|
992
|
+
payload: { subscriptionId: queryId }
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Send hybrid query subscription message to server.
|
|
999
|
+
*/
|
|
1000
|
+
sendHybridQuerySubscription(queryId, mapName, filter) {
|
|
1001
|
+
this.config.sendMessage({
|
|
1002
|
+
type: "HYBRID_QUERY_SUBSCRIBE",
|
|
1003
|
+
payload: {
|
|
1004
|
+
subscriptionId: queryId,
|
|
1005
|
+
mapName,
|
|
1006
|
+
predicate: filter.predicate,
|
|
1007
|
+
where: filter.where,
|
|
1008
|
+
sort: filter.sort,
|
|
1009
|
+
limit: filter.limit,
|
|
1010
|
+
cursor: filter.cursor
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
// ============================================
|
|
1015
|
+
// Local Query Execution
|
|
1016
|
+
// ============================================
|
|
1017
|
+
/**
|
|
1018
|
+
* Executes a query against local storage immediately.
|
|
1019
|
+
*/
|
|
1020
|
+
async runLocalQuery(mapName, filter) {
|
|
1021
|
+
const keys = await this.config.storageAdapter.getAllKeys();
|
|
1022
|
+
const mapKeys = keys.filter((k) => k.startsWith(mapName + ":"));
|
|
1023
|
+
const results = [];
|
|
1024
|
+
for (const fullKey of mapKeys) {
|
|
1025
|
+
const record = await this.config.storageAdapter.get(fullKey);
|
|
1026
|
+
if (record && record.value) {
|
|
1027
|
+
const actualKey = fullKey.slice(mapName.length + 1);
|
|
1028
|
+
let matches = true;
|
|
1029
|
+
if (filter.where) {
|
|
1030
|
+
for (const [k, v] of Object.entries(filter.where)) {
|
|
1031
|
+
if (record.value[k] !== v) {
|
|
1032
|
+
matches = false;
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (matches && filter.predicate) {
|
|
1038
|
+
if (!evaluatePredicate(filter.predicate, record.value)) {
|
|
1039
|
+
matches = false;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (matches) {
|
|
1043
|
+
results.push({ key: actualKey, value: record.value });
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return results;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Run a local hybrid query (FTS + filter combination).
|
|
1051
|
+
* For FTS predicates, returns results with score = 0 (local-only mode).
|
|
1052
|
+
* Server provides actual FTS scoring.
|
|
1053
|
+
*/
|
|
1054
|
+
async runLocalHybridQuery(mapName, filter) {
|
|
1055
|
+
const results = [];
|
|
1056
|
+
const allKeys = await this.config.storageAdapter.getAllKeys();
|
|
1057
|
+
const mapPrefix = `${mapName}:`;
|
|
1058
|
+
const entries = [];
|
|
1059
|
+
for (const fullKey of allKeys) {
|
|
1060
|
+
if (fullKey.startsWith(mapPrefix)) {
|
|
1061
|
+
const key = fullKey.substring(mapPrefix.length);
|
|
1062
|
+
const record = await this.config.storageAdapter.get(fullKey);
|
|
1063
|
+
if (record) {
|
|
1064
|
+
entries.push([key, record]);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
for (const [key, record] of entries) {
|
|
1069
|
+
if (record === null || record.value === null) continue;
|
|
1070
|
+
const value = record.value;
|
|
1071
|
+
if (filter.predicate) {
|
|
1072
|
+
const matches = evaluatePredicate(filter.predicate, value);
|
|
1073
|
+
if (!matches) continue;
|
|
1074
|
+
}
|
|
1075
|
+
if (filter.where) {
|
|
1076
|
+
let whereMatches = true;
|
|
1077
|
+
for (const [field, expected] of Object.entries(filter.where)) {
|
|
1078
|
+
if (value[field] !== expected) {
|
|
1079
|
+
whereMatches = false;
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (!whereMatches) continue;
|
|
1084
|
+
}
|
|
1085
|
+
results.push({
|
|
1086
|
+
key,
|
|
1087
|
+
value,
|
|
1088
|
+
score: 0,
|
|
1089
|
+
// Local doesn't have FTS scoring
|
|
1090
|
+
matchedTerms: []
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
if (filter.sort) {
|
|
1094
|
+
results.sort((a, b) => {
|
|
1095
|
+
for (const [field, direction] of Object.entries(filter.sort)) {
|
|
1096
|
+
let valA;
|
|
1097
|
+
let valB;
|
|
1098
|
+
if (field === "_score") {
|
|
1099
|
+
valA = a.score ?? 0;
|
|
1100
|
+
valB = b.score ?? 0;
|
|
1101
|
+
} else if (field === "_key") {
|
|
1102
|
+
valA = a.key;
|
|
1103
|
+
valB = b.key;
|
|
1104
|
+
} else {
|
|
1105
|
+
valA = a.value[field];
|
|
1106
|
+
valB = b.value[field];
|
|
1107
|
+
}
|
|
1108
|
+
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
1109
|
+
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
1110
|
+
}
|
|
1111
|
+
return 0;
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
let sliced = results;
|
|
1115
|
+
if (filter.limit) {
|
|
1116
|
+
sliced = sliced.slice(0, filter.limit);
|
|
1117
|
+
}
|
|
1118
|
+
return sliced;
|
|
1119
|
+
}
|
|
1120
|
+
// ============================================
|
|
1121
|
+
// Re-subscription (after auth)
|
|
1122
|
+
// ============================================
|
|
1123
|
+
/**
|
|
1124
|
+
* Re-subscribe all queries after authentication.
|
|
1125
|
+
* Called by SyncEngine after AUTH_ACK.
|
|
1126
|
+
*/
|
|
1127
|
+
resubscribeAll() {
|
|
1128
|
+
logger.debug({ queryCount: this.queries.size, hybridCount: this.hybridQueries.size }, "QueryManager: resubscribing all queries");
|
|
1129
|
+
for (const query of this.queries.values()) {
|
|
1130
|
+
this.sendQuerySubscription(query);
|
|
1131
|
+
}
|
|
1132
|
+
for (const query of this.hybridQueries.values()) {
|
|
1133
|
+
if (query.hasFTSPredicate()) {
|
|
1134
|
+
this.sendHybridQuerySubscription(query.id, query.getMapName(), query.getFilter());
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
// src/sync/TopicManager.ts
|
|
1141
|
+
var TopicManager = class {
|
|
1142
|
+
constructor(config) {
|
|
1143
|
+
// Topic subscriptions (single source of truth)
|
|
1144
|
+
this.topics = /* @__PURE__ */ new Map();
|
|
1145
|
+
// Offline message queue
|
|
1146
|
+
this.topicQueue = [];
|
|
1147
|
+
this.config = config;
|
|
1148
|
+
}
|
|
1149
|
+
// ============================================
|
|
1150
|
+
// Public API
|
|
1151
|
+
// ============================================
|
|
1152
|
+
/**
|
|
1153
|
+
* Subscribe to a topic.
|
|
1154
|
+
* Adds to topics Map and sends subscription to server if authenticated.
|
|
1155
|
+
*/
|
|
1156
|
+
subscribeToTopic(topic, handle) {
|
|
1157
|
+
this.topics.set(topic, handle);
|
|
1158
|
+
if (this.config.isAuthenticated()) {
|
|
1159
|
+
this.sendTopicSubscription(topic);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Unsubscribe from a topic.
|
|
1164
|
+
* Removes from Map and sends unsubscription to server if authenticated.
|
|
1165
|
+
*/
|
|
1166
|
+
unsubscribeFromTopic(topic) {
|
|
1167
|
+
this.topics.delete(topic);
|
|
1168
|
+
if (this.config.isAuthenticated()) {
|
|
1169
|
+
this.config.sendMessage({
|
|
1170
|
+
type: "TOPIC_UNSUB",
|
|
1171
|
+
payload: { topic }
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Publish a message to a topic.
|
|
1177
|
+
* Sends immediately if authenticated, otherwise queues for later.
|
|
1178
|
+
*/
|
|
1179
|
+
publishTopic(topic, data) {
|
|
1180
|
+
if (this.config.isAuthenticated()) {
|
|
1181
|
+
this.config.sendMessage({
|
|
1182
|
+
type: "TOPIC_PUB",
|
|
1183
|
+
payload: { topic, data }
|
|
1184
|
+
});
|
|
1185
|
+
} else {
|
|
1186
|
+
this.queueTopicMessage(topic, data);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Flush all queued topic messages.
|
|
1191
|
+
* Called by SyncEngine after authentication.
|
|
1192
|
+
*/
|
|
1193
|
+
flushTopicQueue() {
|
|
1194
|
+
if (this.topicQueue.length === 0) return;
|
|
1195
|
+
logger.info({ count: this.topicQueue.length }, "Flushing queued topic messages");
|
|
1196
|
+
for (const msg of this.topicQueue) {
|
|
1197
|
+
this.config.sendMessage({
|
|
1198
|
+
type: "TOPIC_PUB",
|
|
1199
|
+
payload: { topic: msg.topic, data: msg.data }
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
this.topicQueue = [];
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Get topic queue status.
|
|
1206
|
+
*/
|
|
1207
|
+
getTopicQueueStatus() {
|
|
1208
|
+
return {
|
|
1209
|
+
size: this.topicQueue.length,
|
|
1210
|
+
maxSize: this.config.topicQueueConfig.maxSize
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Get all subscribed topics.
|
|
1215
|
+
* Used for resubscription after authentication.
|
|
1216
|
+
*/
|
|
1217
|
+
getTopics() {
|
|
1218
|
+
return this.topics.keys();
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Re-subscribe all topics after authentication.
|
|
1222
|
+
* Called by SyncEngine after AUTH_ACK.
|
|
1223
|
+
*/
|
|
1224
|
+
resubscribeAll() {
|
|
1225
|
+
for (const topic of this.topics.keys()) {
|
|
1226
|
+
this.sendTopicSubscription(topic);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Handle incoming topic message from server.
|
|
1231
|
+
*/
|
|
1232
|
+
handleTopicMessage(topic, data, publisherId, timestamp) {
|
|
1233
|
+
const handle = this.topics.get(topic);
|
|
1234
|
+
if (handle) {
|
|
1235
|
+
handle.onMessage(data, { publisherId, timestamp });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
// ============================================
|
|
1239
|
+
// Private Methods
|
|
1240
|
+
// ============================================
|
|
1241
|
+
/**
|
|
1242
|
+
* Queue a topic message for offline publishing.
|
|
1243
|
+
*/
|
|
1244
|
+
queueTopicMessage(topic, data) {
|
|
1245
|
+
const message = {
|
|
1246
|
+
topic,
|
|
1247
|
+
data,
|
|
1248
|
+
timestamp: Date.now()
|
|
1249
|
+
};
|
|
1250
|
+
if (this.topicQueue.length >= this.config.topicQueueConfig.maxSize) {
|
|
1251
|
+
if (this.config.topicQueueConfig.strategy === "drop-oldest") {
|
|
1252
|
+
const dropped = this.topicQueue.shift();
|
|
1253
|
+
logger.warn({ topic: dropped?.topic }, "Dropped oldest queued topic message (queue full)");
|
|
1254
|
+
} else {
|
|
1255
|
+
logger.warn({ topic }, "Dropped newest topic message (queue full)");
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
this.topicQueue.push(message);
|
|
1260
|
+
logger.debug({ topic, queueSize: this.topicQueue.length }, "Queued topic message for offline");
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Send topic subscription message to server.
|
|
1264
|
+
*/
|
|
1265
|
+
sendTopicSubscription(topic) {
|
|
1266
|
+
this.config.sendMessage({
|
|
1267
|
+
type: "TOPIC_SUB",
|
|
1268
|
+
payload: { topic }
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
// src/sync/LockManager.ts
|
|
1274
|
+
var LockManager = class {
|
|
1275
|
+
constructor(config) {
|
|
1276
|
+
// Pending lock requests (single source of truth)
|
|
1277
|
+
this.pendingLockRequests = /* @__PURE__ */ new Map();
|
|
1278
|
+
this.config = config;
|
|
1279
|
+
}
|
|
1280
|
+
// ============================================
|
|
1281
|
+
// Public API
|
|
1282
|
+
// ============================================
|
|
1283
|
+
/**
|
|
1284
|
+
* Request a distributed lock.
|
|
1285
|
+
*/
|
|
1286
|
+
requestLock(name, requestId, ttl) {
|
|
1287
|
+
if (!this.config.isAuthenticated()) {
|
|
1288
|
+
return Promise.reject(new Error("Not connected or authenticated"));
|
|
1289
|
+
}
|
|
1290
|
+
return new Promise((resolve, reject) => {
|
|
1291
|
+
const timer = setTimeout(() => {
|
|
1292
|
+
if (this.pendingLockRequests.has(requestId)) {
|
|
1293
|
+
this.pendingLockRequests.delete(requestId);
|
|
1294
|
+
reject(new Error("Lock request timed out waiting for server response"));
|
|
1295
|
+
}
|
|
1296
|
+
}, 3e4);
|
|
1297
|
+
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
1298
|
+
try {
|
|
1299
|
+
const sent = this.config.sendMessage({
|
|
1300
|
+
type: "LOCK_REQUEST",
|
|
1301
|
+
payload: { requestId, name, ttl }
|
|
1302
|
+
});
|
|
1303
|
+
if (!sent) {
|
|
1304
|
+
clearTimeout(timer);
|
|
1305
|
+
this.pendingLockRequests.delete(requestId);
|
|
1306
|
+
reject(new Error("Failed to send lock request"));
|
|
1307
|
+
}
|
|
1308
|
+
} catch (e) {
|
|
1309
|
+
clearTimeout(timer);
|
|
1310
|
+
this.pendingLockRequests.delete(requestId);
|
|
1311
|
+
reject(e);
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Release a distributed lock.
|
|
1317
|
+
*/
|
|
1318
|
+
releaseLock(name, requestId, fencingToken) {
|
|
1319
|
+
if (!this.config.isOnline()) return Promise.resolve(false);
|
|
1320
|
+
return new Promise((resolve, reject) => {
|
|
1321
|
+
const timer = setTimeout(() => {
|
|
1322
|
+
if (this.pendingLockRequests.has(requestId)) {
|
|
1323
|
+
this.pendingLockRequests.delete(requestId);
|
|
1324
|
+
resolve(false);
|
|
1325
|
+
}
|
|
1326
|
+
}, 5e3);
|
|
1327
|
+
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
1328
|
+
try {
|
|
1329
|
+
const sent = this.config.sendMessage({
|
|
1330
|
+
type: "LOCK_RELEASE",
|
|
1331
|
+
payload: { requestId, name, fencingToken }
|
|
1332
|
+
});
|
|
1333
|
+
if (!sent) {
|
|
1334
|
+
clearTimeout(timer);
|
|
1335
|
+
this.pendingLockRequests.delete(requestId);
|
|
1336
|
+
resolve(false);
|
|
1337
|
+
}
|
|
1338
|
+
} catch (e) {
|
|
1339
|
+
clearTimeout(timer);
|
|
1340
|
+
this.pendingLockRequests.delete(requestId);
|
|
1341
|
+
resolve(false);
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Handle lock granted message from server.
|
|
1347
|
+
*/
|
|
1348
|
+
handleLockGranted(requestId, fencingToken) {
|
|
1349
|
+
const req = this.pendingLockRequests.get(requestId);
|
|
1350
|
+
if (req) {
|
|
1351
|
+
clearTimeout(req.timer);
|
|
1352
|
+
this.pendingLockRequests.delete(requestId);
|
|
1353
|
+
req.resolve({ fencingToken });
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Handle lock released message from server.
|
|
1358
|
+
*/
|
|
1359
|
+
handleLockReleased(requestId, success) {
|
|
1360
|
+
const req = this.pendingLockRequests.get(requestId);
|
|
1361
|
+
if (req) {
|
|
1362
|
+
clearTimeout(req.timer);
|
|
1363
|
+
this.pendingLockRequests.delete(requestId);
|
|
1364
|
+
req.resolve(success);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
// src/sync/WriteConcernManager.ts
|
|
1370
|
+
var WriteConcernManager = class {
|
|
1371
|
+
constructor(config) {
|
|
1372
|
+
// Pending write concern promises (single source of truth)
|
|
1373
|
+
this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
|
|
1374
|
+
this.config = config;
|
|
1375
|
+
}
|
|
1376
|
+
// ============================================
|
|
1377
|
+
// Public API
|
|
1378
|
+
// ============================================
|
|
1379
|
+
/**
|
|
1380
|
+
* Register a pending Write Concern promise for an operation.
|
|
1381
|
+
* The promise will be resolved when the server sends an ACK with the operation result.
|
|
1382
|
+
*
|
|
1383
|
+
* @param opId - Operation ID
|
|
1384
|
+
* @param timeout - Timeout in ms (default: 5000)
|
|
1385
|
+
* @returns Promise that resolves with the Write Concern result
|
|
1386
|
+
*/
|
|
1387
|
+
registerWriteConcernPromise(opId, timeout = 5e3) {
|
|
1388
|
+
const actualTimeout = timeout ?? this.config.defaultTimeout ?? 5e3;
|
|
1389
|
+
return new Promise((resolve, reject) => {
|
|
1390
|
+
const timeoutHandle = setTimeout(() => {
|
|
1391
|
+
this.pendingWriteConcernPromises.delete(opId);
|
|
1392
|
+
reject(new Error(`Write Concern timeout for operation ${opId}`));
|
|
1393
|
+
}, actualTimeout);
|
|
1394
|
+
this.pendingWriteConcernPromises.set(opId, {
|
|
1395
|
+
resolve,
|
|
1396
|
+
reject,
|
|
1397
|
+
timeoutHandle
|
|
1398
|
+
});
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Resolve a pending Write Concern promise with the server result.
|
|
1403
|
+
*
|
|
1404
|
+
* @param opId - Operation ID
|
|
1405
|
+
* @param result - Result from server ACK
|
|
1406
|
+
*/
|
|
1407
|
+
resolveWriteConcernPromise(opId, result) {
|
|
1408
|
+
const pending = this.pendingWriteConcernPromises.get(opId);
|
|
1409
|
+
if (pending) {
|
|
1410
|
+
if (pending.timeoutHandle) {
|
|
1411
|
+
clearTimeout(pending.timeoutHandle);
|
|
1412
|
+
}
|
|
1413
|
+
pending.resolve(result);
|
|
1414
|
+
this.pendingWriteConcernPromises.delete(opId);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Cancel all pending Write Concern promises (e.g., on disconnect).
|
|
1419
|
+
*/
|
|
1420
|
+
cancelAllWriteConcernPromises(error) {
|
|
1421
|
+
for (const [opId, pending] of this.pendingWriteConcernPromises.entries()) {
|
|
1422
|
+
if (pending.timeoutHandle) {
|
|
1423
|
+
clearTimeout(pending.timeoutHandle);
|
|
1424
|
+
}
|
|
1425
|
+
pending.reject(error);
|
|
1426
|
+
}
|
|
1427
|
+
this.pendingWriteConcernPromises.clear();
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
// src/sync/CounterManager.ts
|
|
1432
|
+
var CounterManager = class {
|
|
1433
|
+
constructor(config) {
|
|
1434
|
+
// Counter update listeners by name
|
|
1435
|
+
this.counterUpdateListeners = /* @__PURE__ */ new Map();
|
|
1436
|
+
this.config = config;
|
|
1437
|
+
}
|
|
1438
|
+
// ============================================
|
|
1439
|
+
// Public API
|
|
1440
|
+
// ============================================
|
|
1441
|
+
/**
|
|
1442
|
+
* Subscribe to counter updates from server.
|
|
1443
|
+
* @param name Counter name
|
|
1444
|
+
* @param listener Callback when counter state is updated
|
|
1445
|
+
* @returns Unsubscribe function
|
|
1446
|
+
*/
|
|
1447
|
+
onCounterUpdate(name, listener) {
|
|
1448
|
+
if (!this.counterUpdateListeners.has(name)) {
|
|
1449
|
+
this.counterUpdateListeners.set(name, /* @__PURE__ */ new Set());
|
|
1450
|
+
}
|
|
1451
|
+
this.counterUpdateListeners.get(name).add(listener);
|
|
1452
|
+
return () => {
|
|
1453
|
+
this.counterUpdateListeners.get(name)?.delete(listener);
|
|
1454
|
+
if (this.counterUpdateListeners.get(name)?.size === 0) {
|
|
1455
|
+
this.counterUpdateListeners.delete(name);
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Request initial counter state from server.
|
|
1461
|
+
* @param name Counter name
|
|
1462
|
+
*/
|
|
1463
|
+
requestCounter(name) {
|
|
1464
|
+
if (this.config.isAuthenticated()) {
|
|
1465
|
+
this.config.sendMessage({
|
|
1466
|
+
type: "COUNTER_REQUEST",
|
|
1467
|
+
payload: { name }
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Sync local counter state to server.
|
|
1473
|
+
* @param name Counter name
|
|
1474
|
+
* @param state Counter state to sync
|
|
1475
|
+
*/
|
|
1476
|
+
syncCounter(name, state) {
|
|
1477
|
+
if (this.config.isAuthenticated()) {
|
|
1478
|
+
const stateObj = {
|
|
1479
|
+
positive: Object.fromEntries(state.positive),
|
|
1480
|
+
negative: Object.fromEntries(state.negative)
|
|
1481
|
+
};
|
|
1482
|
+
this.config.sendMessage({
|
|
1483
|
+
type: "COUNTER_SYNC",
|
|
1484
|
+
payload: {
|
|
1485
|
+
name,
|
|
1486
|
+
state: stateObj
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Handle incoming counter update from server.
|
|
1493
|
+
* Called by SyncEngine for COUNTER_UPDATE and COUNTER_RESPONSE messages.
|
|
1494
|
+
*/
|
|
1495
|
+
handleCounterUpdate(name, stateObj) {
|
|
1496
|
+
const state = {
|
|
1497
|
+
positive: new Map(Object.entries(stateObj.positive)),
|
|
1498
|
+
negative: new Map(Object.entries(stateObj.negative))
|
|
1499
|
+
};
|
|
1500
|
+
const listeners = this.counterUpdateListeners.get(name);
|
|
1501
|
+
if (listeners) {
|
|
1502
|
+
for (const listener of listeners) {
|
|
1503
|
+
try {
|
|
1504
|
+
listener(state);
|
|
1505
|
+
} catch (e) {
|
|
1506
|
+
logger.error({ err: e, counterName: name }, "Counter update listener error");
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Clean up resources.
|
|
1513
|
+
* Clears all counter update listeners.
|
|
1514
|
+
*/
|
|
1515
|
+
close() {
|
|
1516
|
+
this.counterUpdateListeners.clear();
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
// src/sync/EntryProcessorClient.ts
|
|
1521
|
+
var DEFAULT_PROCESSOR_TIMEOUT = 3e4;
|
|
1522
|
+
var EntryProcessorClient = class {
|
|
1523
|
+
constructor(config) {
|
|
1524
|
+
// Pending entry processor requests by requestId
|
|
1525
|
+
this.pendingProcessorRequests = /* @__PURE__ */ new Map();
|
|
1526
|
+
// Pending batch entry processor requests by requestId
|
|
1527
|
+
this.pendingBatchProcessorRequests = /* @__PURE__ */ new Map();
|
|
1528
|
+
this.config = config;
|
|
1529
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_PROCESSOR_TIMEOUT;
|
|
1530
|
+
}
|
|
1531
|
+
// ============================================
|
|
1532
|
+
// Public API
|
|
1533
|
+
// ============================================
|
|
1534
|
+
/**
|
|
1535
|
+
* Execute an entry processor on a single key atomically.
|
|
1536
|
+
*
|
|
1537
|
+
* @param mapName Name of the map
|
|
1538
|
+
* @param key Key to process
|
|
1539
|
+
* @param processor Processor definition
|
|
1540
|
+
* @returns Promise resolving to the processor result
|
|
1541
|
+
*/
|
|
1542
|
+
async executeOnKey(mapName, key, processor) {
|
|
1543
|
+
if (!this.config.isAuthenticated()) {
|
|
1544
|
+
return {
|
|
1545
|
+
success: false,
|
|
1546
|
+
error: "Not connected to server"
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
const requestId = crypto.randomUUID();
|
|
1550
|
+
return new Promise((resolve, reject) => {
|
|
1551
|
+
const timeout = setTimeout(() => {
|
|
1552
|
+
this.pendingProcessorRequests.delete(requestId);
|
|
1553
|
+
reject(new Error("Entry processor request timed out"));
|
|
1554
|
+
}, this.timeoutMs);
|
|
1555
|
+
this.pendingProcessorRequests.set(requestId, {
|
|
1556
|
+
resolve: (result) => {
|
|
1557
|
+
clearTimeout(timeout);
|
|
1558
|
+
resolve(result);
|
|
1559
|
+
},
|
|
1560
|
+
reject,
|
|
1561
|
+
timeout
|
|
1562
|
+
});
|
|
1563
|
+
const sent = this.config.sendMessage({
|
|
1564
|
+
type: "ENTRY_PROCESS",
|
|
1565
|
+
requestId,
|
|
1566
|
+
mapName,
|
|
1567
|
+
key,
|
|
1568
|
+
processor: {
|
|
1569
|
+
name: processor.name,
|
|
1570
|
+
code: processor.code,
|
|
1571
|
+
args: processor.args
|
|
1572
|
+
}
|
|
1573
|
+
}, key);
|
|
1574
|
+
if (!sent) {
|
|
1575
|
+
this.pendingProcessorRequests.delete(requestId);
|
|
1576
|
+
clearTimeout(timeout);
|
|
1577
|
+
reject(new Error("Failed to send entry processor request"));
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Execute an entry processor on multiple keys.
|
|
1583
|
+
*
|
|
1584
|
+
* @param mapName Name of the map
|
|
1585
|
+
* @param keys Keys to process
|
|
1586
|
+
* @param processor Processor definition
|
|
1587
|
+
* @returns Promise resolving to a map of key -> result
|
|
1588
|
+
*/
|
|
1589
|
+
async executeOnKeys(mapName, keys, processor) {
|
|
1590
|
+
if (!this.config.isAuthenticated()) {
|
|
1591
|
+
const results = /* @__PURE__ */ new Map();
|
|
1592
|
+
const error = {
|
|
1593
|
+
success: false,
|
|
1594
|
+
error: "Not connected to server"
|
|
1595
|
+
};
|
|
1596
|
+
for (const key of keys) {
|
|
1597
|
+
results.set(key, error);
|
|
1598
|
+
}
|
|
1599
|
+
return results;
|
|
1600
|
+
}
|
|
1601
|
+
const requestId = crypto.randomUUID();
|
|
1602
|
+
return new Promise((resolve, reject) => {
|
|
1603
|
+
const timeout = setTimeout(() => {
|
|
1604
|
+
this.pendingBatchProcessorRequests.delete(requestId);
|
|
1605
|
+
reject(new Error("Entry processor batch request timed out"));
|
|
1606
|
+
}, this.timeoutMs);
|
|
1607
|
+
this.pendingBatchProcessorRequests.set(requestId, {
|
|
1608
|
+
resolve: (results) => {
|
|
1609
|
+
clearTimeout(timeout);
|
|
1610
|
+
resolve(results);
|
|
1611
|
+
},
|
|
1612
|
+
reject,
|
|
1613
|
+
timeout
|
|
1614
|
+
});
|
|
1615
|
+
const sent = this.config.sendMessage({
|
|
1616
|
+
type: "ENTRY_PROCESS_BATCH",
|
|
1617
|
+
requestId,
|
|
1618
|
+
mapName,
|
|
1619
|
+
keys,
|
|
1620
|
+
processor: {
|
|
1621
|
+
name: processor.name,
|
|
1622
|
+
code: processor.code,
|
|
1623
|
+
args: processor.args
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
if (!sent) {
|
|
1627
|
+
this.pendingBatchProcessorRequests.delete(requestId);
|
|
1628
|
+
clearTimeout(timeout);
|
|
1629
|
+
reject(new Error("Failed to send entry processor batch request"));
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Handle entry processor response from server.
|
|
1635
|
+
* Called by SyncEngine for ENTRY_PROCESS_RESPONSE messages.
|
|
1636
|
+
*/
|
|
1637
|
+
handleEntryProcessResponse(message) {
|
|
1638
|
+
const pending = this.pendingProcessorRequests.get(message.requestId);
|
|
1639
|
+
if (pending) {
|
|
1640
|
+
this.pendingProcessorRequests.delete(message.requestId);
|
|
1641
|
+
pending.resolve({
|
|
1642
|
+
success: message.success,
|
|
1643
|
+
result: message.result,
|
|
1644
|
+
newValue: message.newValue,
|
|
1645
|
+
error: message.error
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Handle entry processor batch response from server.
|
|
1651
|
+
* Called by SyncEngine for ENTRY_PROCESS_BATCH_RESPONSE messages.
|
|
1652
|
+
*/
|
|
1653
|
+
handleEntryProcessBatchResponse(message) {
|
|
1654
|
+
const pending = this.pendingBatchProcessorRequests.get(message.requestId);
|
|
1655
|
+
if (pending) {
|
|
1656
|
+
this.pendingBatchProcessorRequests.delete(message.requestId);
|
|
1657
|
+
const resultsMap = /* @__PURE__ */ new Map();
|
|
1658
|
+
for (const [key, result] of Object.entries(message.results)) {
|
|
1659
|
+
resultsMap.set(key, {
|
|
1660
|
+
success: result.success,
|
|
1661
|
+
result: result.result,
|
|
1662
|
+
newValue: result.newValue,
|
|
1663
|
+
error: result.error
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
pending.resolve(resultsMap);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Clean up resources.
|
|
1671
|
+
* Clears pending timeouts without rejecting promises to match original SyncEngine behavior.
|
|
1672
|
+
* Note: This may leave promises hanging, but maintains backward compatibility with tests.
|
|
1673
|
+
*/
|
|
1674
|
+
close(error) {
|
|
1675
|
+
for (const [requestId, pending] of this.pendingProcessorRequests.entries()) {
|
|
1676
|
+
clearTimeout(pending.timeout);
|
|
1677
|
+
}
|
|
1678
|
+
this.pendingProcessorRequests.clear();
|
|
1679
|
+
for (const [requestId, pending] of this.pendingBatchProcessorRequests.entries()) {
|
|
1680
|
+
clearTimeout(pending.timeout);
|
|
1681
|
+
}
|
|
1682
|
+
this.pendingBatchProcessorRequests.clear();
|
|
1683
|
+
}
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
// src/sync/SearchClient.ts
|
|
1687
|
+
var DEFAULT_SEARCH_TIMEOUT = 3e4;
|
|
1688
|
+
var SearchClient = class {
|
|
1689
|
+
constructor(config) {
|
|
1690
|
+
// Pending search requests by requestId
|
|
1691
|
+
this.pendingSearchRequests = /* @__PURE__ */ new Map();
|
|
1692
|
+
this.config = config;
|
|
1693
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_SEARCH_TIMEOUT;
|
|
1694
|
+
}
|
|
1695
|
+
// ============================================
|
|
1696
|
+
// Public API
|
|
1697
|
+
// ============================================
|
|
1698
|
+
/**
|
|
1699
|
+
* Perform a one-shot BM25 search on the server.
|
|
1700
|
+
*
|
|
1701
|
+
* @param mapName Name of the map to search
|
|
1702
|
+
* @param query Search query text
|
|
1703
|
+
* @param options Search options (limit, minScore, boost)
|
|
1704
|
+
* @returns Promise resolving to search results
|
|
1705
|
+
*/
|
|
1706
|
+
async search(mapName, query, options) {
|
|
1707
|
+
if (!this.config.isAuthenticated()) {
|
|
1708
|
+
throw new Error("Not connected to server");
|
|
1709
|
+
}
|
|
1710
|
+
const requestId = crypto.randomUUID();
|
|
1711
|
+
return new Promise((resolve, reject) => {
|
|
1712
|
+
const timeout = setTimeout(() => {
|
|
1713
|
+
this.pendingSearchRequests.delete(requestId);
|
|
1714
|
+
reject(new Error("Search request timed out"));
|
|
1715
|
+
}, this.timeoutMs);
|
|
1716
|
+
this.pendingSearchRequests.set(requestId, {
|
|
1717
|
+
resolve: (results) => {
|
|
1718
|
+
clearTimeout(timeout);
|
|
1719
|
+
resolve(results);
|
|
1720
|
+
},
|
|
1721
|
+
reject: (error) => {
|
|
1722
|
+
clearTimeout(timeout);
|
|
1723
|
+
reject(error);
|
|
1724
|
+
},
|
|
1725
|
+
timeout
|
|
1726
|
+
});
|
|
1727
|
+
const sent = this.config.sendMessage({
|
|
1728
|
+
type: "SEARCH",
|
|
1729
|
+
payload: {
|
|
1730
|
+
requestId,
|
|
1731
|
+
mapName,
|
|
1732
|
+
query,
|
|
1733
|
+
options
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
if (!sent) {
|
|
1737
|
+
this.pendingSearchRequests.delete(requestId);
|
|
1738
|
+
clearTimeout(timeout);
|
|
1739
|
+
reject(new Error("Failed to send search request"));
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Handle search response from server.
|
|
1745
|
+
* Called by SyncEngine for SEARCH_RESP messages.
|
|
1746
|
+
*/
|
|
1747
|
+
handleSearchResponse(payload) {
|
|
1748
|
+
const pending = this.pendingSearchRequests.get(payload.requestId);
|
|
1749
|
+
if (pending) {
|
|
1750
|
+
this.pendingSearchRequests.delete(payload.requestId);
|
|
1751
|
+
if (payload.error) {
|
|
1752
|
+
pending.reject(new Error(payload.error));
|
|
1753
|
+
} else {
|
|
1754
|
+
pending.resolve(payload.results);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Clean up resources.
|
|
1760
|
+
* Clears pending timeouts without rejecting promises to match original SyncEngine behavior.
|
|
1761
|
+
* Note: This may leave promises hanging, but maintains backward compatibility with tests.
|
|
1762
|
+
*/
|
|
1763
|
+
close(error) {
|
|
1764
|
+
for (const [requestId, pending] of this.pendingSearchRequests.entries()) {
|
|
1765
|
+
clearTimeout(pending.timeout);
|
|
1766
|
+
}
|
|
1767
|
+
this.pendingSearchRequests.clear();
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
|
|
1771
|
+
// src/sync/MerkleSyncHandler.ts
|
|
1772
|
+
import { LWWMap } from "@topgunbuild/core";
|
|
1773
|
+
var MerkleSyncHandler = class {
|
|
1774
|
+
constructor(config) {
|
|
1775
|
+
this.lastSyncTimestamp = 0;
|
|
1776
|
+
this.config = config;
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Handle SYNC_RESET_REQUIRED message from server.
|
|
1780
|
+
* Resets the map and triggers a fresh sync.
|
|
1781
|
+
*/
|
|
1782
|
+
async handleSyncResetRequired(payload) {
|
|
1783
|
+
const { mapName } = payload;
|
|
1784
|
+
logger.warn({ mapName }, "Sync Reset Required due to GC Age");
|
|
1785
|
+
await this.config.resetMap(mapName);
|
|
1786
|
+
this.config.sendMessage({
|
|
1787
|
+
type: "SYNC_INIT",
|
|
1788
|
+
mapName,
|
|
1789
|
+
lastSyncTimestamp: 0
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Handle SYNC_RESP_ROOT message from server.
|
|
1794
|
+
* Compares root hashes and requests buckets if mismatch detected.
|
|
1795
|
+
*/
|
|
1796
|
+
async handleSyncRespRoot(payload) {
|
|
1797
|
+
const { mapName, rootHash, timestamp } = payload;
|
|
1798
|
+
const map = this.config.getMap(mapName);
|
|
1799
|
+
if (map instanceof LWWMap) {
|
|
1800
|
+
const localRootHash = map.getMerkleTree().getRootHash();
|
|
1801
|
+
if (localRootHash !== rootHash) {
|
|
1802
|
+
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
|
|
1803
|
+
this.config.sendMessage({
|
|
1804
|
+
type: "MERKLE_REQ_BUCKET",
|
|
1805
|
+
payload: { mapName, path: "" }
|
|
1806
|
+
});
|
|
1807
|
+
} else {
|
|
1808
|
+
logger.info({ mapName }, "Map is in sync");
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
if (timestamp) {
|
|
1812
|
+
await this.config.onTimestampUpdate(timestamp);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Handle SYNC_RESP_BUCKETS message from server.
|
|
1817
|
+
* Compares bucket hashes and requests mismatched buckets.
|
|
1818
|
+
*/
|
|
1819
|
+
handleSyncRespBuckets(payload) {
|
|
1820
|
+
const { mapName, path, buckets } = payload;
|
|
1821
|
+
const map = this.config.getMap(mapName);
|
|
1822
|
+
if (map instanceof LWWMap) {
|
|
1823
|
+
const tree = map.getMerkleTree();
|
|
1824
|
+
const localBuckets = tree.getBuckets(path);
|
|
1825
|
+
for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
|
|
1826
|
+
const localHash = localBuckets[bucketKey] || 0;
|
|
1827
|
+
if (localHash !== remoteHash) {
|
|
1828
|
+
const newPath = path + bucketKey;
|
|
1829
|
+
this.config.sendMessage({
|
|
1830
|
+
type: "MERKLE_REQ_BUCKET",
|
|
1831
|
+
payload: { mapName, path: newPath }
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Handle SYNC_RESP_LEAF message from server.
|
|
1839
|
+
* Merges leaf records into local map and persists to storage.
|
|
1840
|
+
*/
|
|
1841
|
+
async handleSyncRespLeaf(payload) {
|
|
1842
|
+
const { mapName, records } = payload;
|
|
1843
|
+
const map = this.config.getMap(mapName);
|
|
1844
|
+
if (map instanceof LWWMap) {
|
|
1845
|
+
let updateCount = 0;
|
|
1846
|
+
for (const { key, record } of records) {
|
|
1847
|
+
const updated = map.merge(key, record);
|
|
1848
|
+
if (updated) {
|
|
1849
|
+
updateCount++;
|
|
1850
|
+
await this.config.storageAdapter.put(`${mapName}:${key}`, record);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
if (updateCount > 0) {
|
|
1854
|
+
logger.info({ mapName, count: updateCount }, "Synced records from server");
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* Send SYNC_INIT message to server to start sync.
|
|
1860
|
+
* Encapsulates sync init message construction.
|
|
1861
|
+
*/
|
|
1862
|
+
sendSyncInit(mapName, lastSyncTimestamp) {
|
|
1863
|
+
this.lastSyncTimestamp = lastSyncTimestamp;
|
|
1864
|
+
logger.info({ mapName }, "Starting Merkle sync for LWWMap");
|
|
1865
|
+
this.config.sendMessage({
|
|
1866
|
+
type: "SYNC_INIT",
|
|
1867
|
+
mapName,
|
|
1868
|
+
lastSyncTimestamp
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Get the last sync timestamp for debugging/testing.
|
|
1873
|
+
*/
|
|
1874
|
+
getLastSyncTimestamp() {
|
|
1875
|
+
return this.lastSyncTimestamp;
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
// src/sync/ORMapSyncHandler.ts
|
|
1880
|
+
import { ORMap } from "@topgunbuild/core";
|
|
1881
|
+
var ORMapSyncHandler = class {
|
|
1882
|
+
constructor(config) {
|
|
1883
|
+
this.lastSyncTimestamp = 0;
|
|
1884
|
+
this.config = config;
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Handle ORMAP_SYNC_RESP_ROOT message from server.
|
|
1888
|
+
* Compares root hashes and requests buckets if mismatch detected.
|
|
1889
|
+
*/
|
|
1890
|
+
async handleORMapSyncRespRoot(payload) {
|
|
1891
|
+
const { mapName, rootHash, timestamp } = payload;
|
|
1892
|
+
const map = this.config.getMap(mapName);
|
|
1893
|
+
if (map instanceof ORMap) {
|
|
1894
|
+
const localTree = map.getMerkleTree();
|
|
1895
|
+
const localRootHash = localTree.getRootHash();
|
|
1896
|
+
if (localRootHash !== rootHash) {
|
|
1897
|
+
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
|
|
1898
|
+
this.config.sendMessage({
|
|
1899
|
+
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
1900
|
+
payload: { mapName, path: "" }
|
|
1901
|
+
});
|
|
1902
|
+
} else {
|
|
1903
|
+
logger.info({ mapName }, "ORMap is in sync");
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
if (timestamp) {
|
|
1907
|
+
await this.config.onTimestampUpdate(timestamp);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Handle ORMAP_SYNC_RESP_BUCKETS message from server.
|
|
1912
|
+
* Compares bucket hashes and requests mismatched buckets.
|
|
1913
|
+
* Also pushes local data that server doesn't have.
|
|
1914
|
+
*/
|
|
1915
|
+
async handleORMapSyncRespBuckets(payload) {
|
|
1916
|
+
const { mapName, path, buckets } = payload;
|
|
1917
|
+
const map = this.config.getMap(mapName);
|
|
1918
|
+
if (map instanceof ORMap) {
|
|
1919
|
+
const tree = map.getMerkleTree();
|
|
1920
|
+
const localBuckets = tree.getBuckets(path);
|
|
1921
|
+
for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
|
|
1922
|
+
const localHash = localBuckets[bucketKey] || 0;
|
|
1923
|
+
if (localHash !== remoteHash) {
|
|
1924
|
+
const newPath = path + bucketKey;
|
|
1925
|
+
this.config.sendMessage({
|
|
1926
|
+
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
1927
|
+
payload: { mapName, path: newPath }
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
|
|
1932
|
+
if (!(bucketKey in buckets) && localHash !== 0) {
|
|
1933
|
+
const newPath = path + bucketKey;
|
|
1934
|
+
const keys = tree.getKeysInBucket(newPath);
|
|
1935
|
+
if (keys.length > 0) {
|
|
1936
|
+
await this.pushORMapDiff(mapName, keys, map);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Handle ORMAP_SYNC_RESP_LEAF message from server.
|
|
1944
|
+
* Merges leaf entries into local map and pushes local diff back.
|
|
1945
|
+
*/
|
|
1946
|
+
async handleORMapSyncRespLeaf(payload) {
|
|
1947
|
+
const { mapName, entries } = payload;
|
|
1948
|
+
const map = this.config.getMap(mapName);
|
|
1949
|
+
if (map instanceof ORMap) {
|
|
1950
|
+
let totalAdded = 0;
|
|
1951
|
+
let totalUpdated = 0;
|
|
1952
|
+
for (const entry of entries) {
|
|
1953
|
+
const { key, records, tombstones } = entry;
|
|
1954
|
+
const result = map.mergeKey(key, records, tombstones);
|
|
1955
|
+
totalAdded += result.added;
|
|
1956
|
+
totalUpdated += result.updated;
|
|
1957
|
+
}
|
|
1958
|
+
if (totalAdded > 0 || totalUpdated > 0) {
|
|
1959
|
+
logger.info({ mapName, added: totalAdded, updated: totalUpdated }, "Synced ORMap records from server");
|
|
1960
|
+
}
|
|
1961
|
+
const keysToCheck = entries.map((e) => e.key);
|
|
1962
|
+
await this.pushORMapDiff(mapName, keysToCheck, map);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Handle ORMAP_DIFF_RESPONSE message from server.
|
|
1967
|
+
* Merges diff entries into local map.
|
|
1968
|
+
*/
|
|
1969
|
+
async handleORMapDiffResponse(payload) {
|
|
1970
|
+
const { mapName, entries } = payload;
|
|
1971
|
+
const map = this.config.getMap(mapName);
|
|
1972
|
+
if (map instanceof ORMap) {
|
|
1973
|
+
let totalAdded = 0;
|
|
1974
|
+
let totalUpdated = 0;
|
|
1975
|
+
for (const entry of entries) {
|
|
1976
|
+
const { key, records, tombstones } = entry;
|
|
1977
|
+
const result = map.mergeKey(key, records, tombstones);
|
|
1978
|
+
totalAdded += result.added;
|
|
1979
|
+
totalUpdated += result.updated;
|
|
1980
|
+
}
|
|
1981
|
+
if (totalAdded > 0 || totalUpdated > 0) {
|
|
1982
|
+
logger.info({ mapName, added: totalAdded, updated: totalUpdated }, "Merged ORMap diff from server");
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Push local ORMap diff to server for the given keys.
|
|
1988
|
+
* Sends local records and tombstones that the server might not have.
|
|
1989
|
+
*/
|
|
1990
|
+
async pushORMapDiff(mapName, keys, map) {
|
|
1991
|
+
const entries = [];
|
|
1992
|
+
const snapshot = map.getSnapshot();
|
|
1993
|
+
for (const key of keys) {
|
|
1994
|
+
const recordsMap = map.getRecordsMap(key);
|
|
1995
|
+
if (recordsMap && recordsMap.size > 0) {
|
|
1996
|
+
const records = Array.from(recordsMap.values());
|
|
1997
|
+
const tombstones = [];
|
|
1998
|
+
for (const tag of snapshot.tombstones) {
|
|
1999
|
+
tombstones.push(tag);
|
|
2000
|
+
}
|
|
2001
|
+
entries.push({
|
|
2002
|
+
key,
|
|
2003
|
+
records,
|
|
2004
|
+
tombstones
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
if (entries.length > 0) {
|
|
2009
|
+
this.config.sendMessage({
|
|
2010
|
+
type: "ORMAP_PUSH_DIFF",
|
|
2011
|
+
payload: {
|
|
2012
|
+
mapName,
|
|
2013
|
+
entries
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* Send ORMAP_SYNC_INIT message to server to start sync.
|
|
2021
|
+
* Encapsulates sync init message construction.
|
|
2022
|
+
*/
|
|
2023
|
+
sendSyncInit(mapName, lastSyncTimestamp) {
|
|
2024
|
+
this.lastSyncTimestamp = lastSyncTimestamp;
|
|
2025
|
+
const map = this.config.getMap(mapName);
|
|
2026
|
+
if (map instanceof ORMap) {
|
|
2027
|
+
logger.info({ mapName }, "Starting Merkle sync for ORMap");
|
|
2028
|
+
const tree = map.getMerkleTree();
|
|
2029
|
+
const rootHash = tree.getRootHash();
|
|
2030
|
+
const bucketHashes = tree.getBuckets("");
|
|
2031
|
+
this.config.sendMessage({
|
|
2032
|
+
type: "ORMAP_SYNC_INIT",
|
|
2033
|
+
mapName,
|
|
2034
|
+
rootHash,
|
|
2035
|
+
bucketHashes,
|
|
2036
|
+
lastSyncTimestamp
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Get the last sync timestamp for debugging/testing.
|
|
2042
|
+
*/
|
|
2043
|
+
getLastSyncTimestamp() {
|
|
2044
|
+
return this.lastSyncTimestamp;
|
|
2045
|
+
}
|
|
2046
|
+
};
|
|
2047
|
+
|
|
2048
|
+
// src/sync/MessageRouter.ts
|
|
2049
|
+
var MessageRouter = class {
|
|
2050
|
+
constructor(config = {}) {
|
|
2051
|
+
this.handlers = config.handlers ? new Map(config.handlers) : /* @__PURE__ */ new Map();
|
|
2052
|
+
this.onUnhandled = config.onUnhandled;
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Register a handler for a message type.
|
|
2056
|
+
* @param type - Message type to handle
|
|
2057
|
+
* @param handler - Handler function
|
|
2058
|
+
*/
|
|
2059
|
+
registerHandler(type, handler2) {
|
|
2060
|
+
if (this.handlers.has(type)) {
|
|
2061
|
+
logger.warn({ type }, "Overwriting existing handler for message type");
|
|
2062
|
+
}
|
|
2063
|
+
this.handlers.set(type, handler2);
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Register multiple handlers at once.
|
|
2067
|
+
* @param handlers - Record of type -> handler
|
|
2068
|
+
*/
|
|
2069
|
+
registerHandlers(handlers) {
|
|
2070
|
+
for (const [type, handler2] of Object.entries(handlers)) {
|
|
2071
|
+
this.registerHandler(type, handler2);
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Route a message to its registered handler.
|
|
2076
|
+
* Returns true if handled, false if no handler found.
|
|
2077
|
+
* @param message - Message to route
|
|
2078
|
+
* @returns Promise resolving to true if handled
|
|
2079
|
+
*/
|
|
2080
|
+
async route(message) {
|
|
2081
|
+
const type = message?.type;
|
|
2082
|
+
if (!type) {
|
|
2083
|
+
logger.warn({ message }, "Cannot route message without type");
|
|
2084
|
+
return false;
|
|
2085
|
+
}
|
|
2086
|
+
const handler2 = this.handlers.get(type);
|
|
2087
|
+
if (!handler2) {
|
|
2088
|
+
if (this.onUnhandled) {
|
|
2089
|
+
this.onUnhandled(message);
|
|
2090
|
+
}
|
|
2091
|
+
return false;
|
|
2092
|
+
}
|
|
2093
|
+
try {
|
|
2094
|
+
await handler2(message);
|
|
2095
|
+
return true;
|
|
2096
|
+
} catch (error) {
|
|
2097
|
+
logger.error({ type, error }, "Error in message handler");
|
|
2098
|
+
return true;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Check if a handler is registered for a message type.
|
|
2103
|
+
* @param type - Message type to check
|
|
2104
|
+
* @returns true if handler exists
|
|
2105
|
+
*/
|
|
2106
|
+
hasHandler(type) {
|
|
2107
|
+
return this.handlers.has(type);
|
|
2108
|
+
}
|
|
2109
|
+
/**
|
|
2110
|
+
* Get the count of registered handlers.
|
|
2111
|
+
* Useful for debugging/testing.
|
|
2112
|
+
*/
|
|
2113
|
+
get handlerCount() {
|
|
2114
|
+
return this.handlers.size;
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Get all registered message types.
|
|
2118
|
+
* Useful for debugging/testing.
|
|
2119
|
+
*/
|
|
2120
|
+
getRegisteredTypes() {
|
|
2121
|
+
return Array.from(this.handlers.keys());
|
|
2122
|
+
}
|
|
2123
|
+
};
|
|
2124
|
+
|
|
2125
|
+
// src/sync/ClientMessageHandlers.ts
|
|
2126
|
+
function registerClientMessageHandlers(router, delegates, managers) {
|
|
2127
|
+
router.registerHandlers({
|
|
2128
|
+
// AUTH handlers
|
|
2129
|
+
"AUTH_REQUIRED": () => delegates.sendAuth(),
|
|
2130
|
+
"AUTH_ACK": () => delegates.handleAuthAck(),
|
|
2131
|
+
"AUTH_FAIL": (msg) => delegates.handleAuthFail(msg),
|
|
2132
|
+
// HEARTBEAT - handled by WebSocketManager, no-op here
|
|
2133
|
+
"PONG": () => {
|
|
2134
|
+
},
|
|
2135
|
+
// SYNC handlers
|
|
2136
|
+
"OP_ACK": (msg) => delegates.handleOpAck(msg),
|
|
2137
|
+
"SYNC_RESP_ROOT": (msg) => managers.merkleSyncHandler.handleSyncRespRoot(msg.payload),
|
|
2138
|
+
"SYNC_RESP_BUCKETS": (msg) => managers.merkleSyncHandler.handleSyncRespBuckets(msg.payload),
|
|
2139
|
+
"SYNC_RESP_LEAF": (msg) => managers.merkleSyncHandler.handleSyncRespLeaf(msg.payload),
|
|
2140
|
+
"SYNC_RESET_REQUIRED": (msg) => managers.merkleSyncHandler.handleSyncResetRequired(msg.payload),
|
|
2141
|
+
// ORMAP SYNC handlers
|
|
2142
|
+
"ORMAP_SYNC_RESP_ROOT": (msg) => managers.orMapSyncHandler.handleORMapSyncRespRoot(msg.payload),
|
|
2143
|
+
"ORMAP_SYNC_RESP_BUCKETS": (msg) => managers.orMapSyncHandler.handleORMapSyncRespBuckets(msg.payload),
|
|
2144
|
+
"ORMAP_SYNC_RESP_LEAF": (msg) => managers.orMapSyncHandler.handleORMapSyncRespLeaf(msg.payload),
|
|
2145
|
+
"ORMAP_DIFF_RESPONSE": (msg) => managers.orMapSyncHandler.handleORMapDiffResponse(msg.payload),
|
|
2146
|
+
// QUERY handlers
|
|
2147
|
+
"QUERY_RESP": (msg) => delegates.handleQueryResp(msg),
|
|
2148
|
+
"QUERY_UPDATE": (msg) => delegates.handleQueryUpdate(msg),
|
|
2149
|
+
// EVENT handlers
|
|
2150
|
+
"SERVER_EVENT": (msg) => delegates.handleServerEvent(msg),
|
|
2151
|
+
"SERVER_BATCH_EVENT": (msg) => delegates.handleServerBatchEvent(msg),
|
|
2152
|
+
// TOPIC handlers
|
|
2153
|
+
"TOPIC_MESSAGE": (msg) => {
|
|
2154
|
+
const { topic, data, publisherId, timestamp } = msg.payload;
|
|
2155
|
+
managers.topicManager.handleTopicMessage(topic, data, publisherId, timestamp);
|
|
2156
|
+
},
|
|
2157
|
+
// LOCK handlers
|
|
2158
|
+
"LOCK_GRANTED": (msg) => {
|
|
2159
|
+
const { requestId, fencingToken } = msg.payload;
|
|
2160
|
+
managers.lockManager.handleLockGranted(requestId, fencingToken);
|
|
2161
|
+
},
|
|
2162
|
+
"LOCK_RELEASED": (msg) => {
|
|
2163
|
+
const { requestId, success } = msg.payload;
|
|
2164
|
+
managers.lockManager.handleLockReleased(requestId, success);
|
|
2165
|
+
},
|
|
2166
|
+
// GC handler
|
|
2167
|
+
"GC_PRUNE": (msg) => delegates.handleGcPrune(msg),
|
|
2168
|
+
// COUNTER handlers
|
|
2169
|
+
"COUNTER_UPDATE": (msg) => {
|
|
2170
|
+
const { name, state } = msg.payload;
|
|
2171
|
+
managers.counterManager.handleCounterUpdate(name, state);
|
|
2172
|
+
},
|
|
2173
|
+
"COUNTER_RESPONSE": (msg) => {
|
|
2174
|
+
const { name, state } = msg.payload;
|
|
2175
|
+
managers.counterManager.handleCounterUpdate(name, state);
|
|
2176
|
+
},
|
|
2177
|
+
// PROCESSOR handlers
|
|
2178
|
+
"ENTRY_PROCESS_RESPONSE": (msg) => {
|
|
2179
|
+
managers.entryProcessorClient.handleEntryProcessResponse(msg);
|
|
2180
|
+
},
|
|
2181
|
+
"ENTRY_PROCESS_BATCH_RESPONSE": (msg) => {
|
|
2182
|
+
managers.entryProcessorClient.handleEntryProcessBatchResponse(msg);
|
|
2183
|
+
},
|
|
2184
|
+
// RESOLVER handlers
|
|
2185
|
+
"REGISTER_RESOLVER_RESPONSE": (msg) => {
|
|
2186
|
+
managers.conflictResolverClient.handleRegisterResponse(msg);
|
|
2187
|
+
},
|
|
2188
|
+
"UNREGISTER_RESOLVER_RESPONSE": (msg) => {
|
|
2189
|
+
managers.conflictResolverClient.handleUnregisterResponse(msg);
|
|
2190
|
+
},
|
|
2191
|
+
"LIST_RESOLVERS_RESPONSE": (msg) => {
|
|
2192
|
+
managers.conflictResolverClient.handleListResponse(msg);
|
|
2193
|
+
},
|
|
2194
|
+
"MERGE_REJECTED": (msg) => {
|
|
2195
|
+
managers.conflictResolverClient.handleMergeRejected(msg);
|
|
2196
|
+
},
|
|
2197
|
+
// SEARCH handlers
|
|
2198
|
+
"SEARCH_RESP": (msg) => {
|
|
2199
|
+
managers.searchClient.handleSearchResponse(msg.payload);
|
|
2200
|
+
},
|
|
2201
|
+
"SEARCH_UPDATE": () => {
|
|
2202
|
+
},
|
|
2203
|
+
// HYBRID handlers
|
|
2204
|
+
"HYBRID_QUERY_RESP": (msg) => delegates.handleHybridQueryResponse(msg.payload),
|
|
2205
|
+
"HYBRID_QUERY_DELTA": (msg) => delegates.handleHybridQueryDelta(msg.payload)
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// src/SyncEngine.ts
|
|
2210
|
+
var DEFAULT_TOPIC_QUEUE_CONFIG = {
|
|
2211
|
+
maxSize: 100,
|
|
2212
|
+
strategy: "drop-oldest"
|
|
2213
|
+
};
|
|
2214
|
+
var DEFAULT_BACKOFF_CONFIG = {
|
|
2215
|
+
initialDelayMs: 1e3,
|
|
2216
|
+
maxDelayMs: 3e4,
|
|
2217
|
+
multiplier: 2,
|
|
2218
|
+
jitter: true,
|
|
2219
|
+
maxRetries: 10
|
|
2220
|
+
};
|
|
2221
|
+
var SyncEngine = class {
|
|
2222
|
+
constructor(config) {
|
|
2223
|
+
this.opLog = [];
|
|
2224
|
+
this.maps = /* @__PURE__ */ new Map();
|
|
2225
|
+
this.lastSyncTimestamp = 0;
|
|
2226
|
+
this.authToken = null;
|
|
2227
|
+
this.tokenProvider = null;
|
|
2228
|
+
// ============================================
|
|
2229
|
+
// Event Journal Methods
|
|
2230
|
+
// ============================================
|
|
2231
|
+
/** Message listeners for journal and other generic messages */
|
|
2232
|
+
this.messageListeners = /* @__PURE__ */ new Set();
|
|
2233
|
+
if (!config.connectionProvider) {
|
|
2234
|
+
throw new Error("SyncEngine requires connectionProvider");
|
|
2235
|
+
}
|
|
2236
|
+
this.nodeId = config.nodeId;
|
|
2237
|
+
this.storageAdapter = config.storageAdapter;
|
|
2238
|
+
this.hlc = new HLC(this.nodeId);
|
|
2239
|
+
this.stateMachine = new SyncStateMachine();
|
|
2240
|
+
this.heartbeatConfig = {
|
|
2241
|
+
intervalMs: config.heartbeat?.intervalMs ?? 5e3,
|
|
2242
|
+
timeoutMs: config.heartbeat?.timeoutMs ?? 15e3,
|
|
2243
|
+
enabled: config.heartbeat?.enabled ?? true
|
|
2244
|
+
};
|
|
2245
|
+
this.backoffConfig = {
|
|
2246
|
+
...DEFAULT_BACKOFF_CONFIG,
|
|
2247
|
+
...config.backoff
|
|
2248
|
+
};
|
|
2249
|
+
this.backpressureConfig = {
|
|
2250
|
+
...DEFAULT_BACKPRESSURE_CONFIG,
|
|
2251
|
+
...config.backpressure
|
|
2252
|
+
};
|
|
2253
|
+
this.backpressureController = new BackpressureController({
|
|
2254
|
+
config: this.backpressureConfig,
|
|
2255
|
+
opLog: this.opLog
|
|
2256
|
+
// Pass reference, not copy
|
|
2257
|
+
});
|
|
2258
|
+
const topicQueueConfig = {
|
|
2259
|
+
...DEFAULT_TOPIC_QUEUE_CONFIG,
|
|
2260
|
+
...config.topicQueue
|
|
2261
|
+
};
|
|
2262
|
+
this.webSocketManager = new WebSocketManager({
|
|
2263
|
+
connectionProvider: config.connectionProvider,
|
|
2264
|
+
stateMachine: this.stateMachine,
|
|
2265
|
+
backoffConfig: this.backoffConfig,
|
|
2266
|
+
heartbeatConfig: this.heartbeatConfig,
|
|
2267
|
+
onMessage: (msg) => this.handleServerMessage(msg),
|
|
2268
|
+
onConnected: () => this.handleConnectionEstablished(),
|
|
2269
|
+
onDisconnected: () => this.handleConnectionLost(),
|
|
2270
|
+
onReconnected: () => this.handleReconnection()
|
|
2271
|
+
});
|
|
2272
|
+
this.queryManager = new QueryManager({
|
|
2273
|
+
storageAdapter: this.storageAdapter,
|
|
2274
|
+
sendMessage: (msg, key) => this.webSocketManager.sendMessage(msg, key),
|
|
2275
|
+
isAuthenticated: () => this.isAuthenticated()
|
|
2276
|
+
});
|
|
2277
|
+
this.topicManager = new TopicManager({
|
|
2278
|
+
topicQueueConfig,
|
|
2279
|
+
sendMessage: (msg, key) => this.webSocketManager.sendMessage(msg, key),
|
|
2280
|
+
isAuthenticated: () => this.isAuthenticated()
|
|
2281
|
+
});
|
|
2282
|
+
this.lockManager = new LockManager({
|
|
2283
|
+
sendMessage: (msg, key) => this.webSocketManager.sendMessage(msg, key),
|
|
2284
|
+
isAuthenticated: () => this.isAuthenticated(),
|
|
2285
|
+
isOnline: () => this.isOnline()
|
|
2286
|
+
});
|
|
2287
|
+
this.writeConcernManager = new WriteConcernManager({
|
|
2288
|
+
defaultTimeout: 5e3
|
|
2289
|
+
});
|
|
2290
|
+
this.counterManager = new CounterManager({
|
|
2291
|
+
sendMessage: (msg) => this.sendMessage(msg),
|
|
2292
|
+
isAuthenticated: () => this.isAuthenticated()
|
|
2293
|
+
});
|
|
2294
|
+
this.entryProcessorClient = new EntryProcessorClient({
|
|
2295
|
+
sendMessage: (msg, key) => key !== void 0 ? this.sendMessage(msg, key) : this.sendMessage(msg),
|
|
2296
|
+
isAuthenticated: () => this.isAuthenticated()
|
|
2297
|
+
});
|
|
2298
|
+
this.searchClient = new SearchClient({
|
|
2299
|
+
sendMessage: (msg) => this.sendMessage(msg),
|
|
2300
|
+
isAuthenticated: () => this.isAuthenticated()
|
|
2301
|
+
});
|
|
2302
|
+
this.merkleSyncHandler = new MerkleSyncHandler({
|
|
2303
|
+
getMap: (name) => this.maps.get(name),
|
|
2304
|
+
sendMessage: (msg, key) => this.webSocketManager.sendMessage(msg, key),
|
|
2305
|
+
storageAdapter: this.storageAdapter,
|
|
2306
|
+
hlc: this.hlc,
|
|
2307
|
+
onTimestampUpdate: async (ts) => {
|
|
2308
|
+
this.hlc.update(ts);
|
|
2309
|
+
this.lastSyncTimestamp = ts.millis;
|
|
2310
|
+
await this.saveOpLog();
|
|
2311
|
+
},
|
|
2312
|
+
resetMap: (name) => this.resetMap(name)
|
|
2313
|
+
});
|
|
2314
|
+
this.orMapSyncHandler = new ORMapSyncHandler({
|
|
2315
|
+
getMap: (name) => this.maps.get(name),
|
|
2316
|
+
sendMessage: (msg, key) => this.webSocketManager.sendMessage(msg, key),
|
|
2317
|
+
hlc: this.hlc,
|
|
2318
|
+
onTimestampUpdate: async (ts) => {
|
|
2319
|
+
this.hlc.update(ts);
|
|
2320
|
+
this.lastSyncTimestamp = ts.millis;
|
|
2321
|
+
await this.saveOpLog();
|
|
2322
|
+
}
|
|
2323
|
+
});
|
|
2324
|
+
this.conflictResolverClient = new ConflictResolverClient(this);
|
|
2325
|
+
this.messageRouter = new MessageRouter({
|
|
2326
|
+
onUnhandled: (msg) => logger.warn({ type: msg?.type }, "Unhandled message type")
|
|
2327
|
+
});
|
|
2328
|
+
registerClientMessageHandlers(
|
|
2329
|
+
this.messageRouter,
|
|
2330
|
+
{
|
|
2331
|
+
sendAuth: () => this.sendAuth(),
|
|
2332
|
+
handleAuthAck: () => this.handleAuthAck(),
|
|
2333
|
+
handleAuthFail: (msg) => this.handleAuthFail(msg),
|
|
2334
|
+
handleOpAck: (msg) => this.handleOpAck(msg),
|
|
2335
|
+
handleQueryResp: (msg) => this.handleQueryResp(msg),
|
|
2336
|
+
handleQueryUpdate: (msg) => this.handleQueryUpdate(msg),
|
|
2337
|
+
handleServerEvent: (msg) => this.handleServerEvent(msg),
|
|
2338
|
+
handleServerBatchEvent: (msg) => this.handleServerBatchEvent(msg),
|
|
2339
|
+
handleGcPrune: (msg) => this.handleGcPrune(msg),
|
|
2340
|
+
handleHybridQueryResponse: (payload) => this.handleHybridQueryResponse(payload),
|
|
2341
|
+
handleHybridQueryDelta: (payload) => this.handleHybridQueryDelta(payload)
|
|
2342
|
+
},
|
|
2343
|
+
{
|
|
2344
|
+
topicManager: this.topicManager,
|
|
2345
|
+
lockManager: this.lockManager,
|
|
2346
|
+
counterManager: this.counterManager,
|
|
2347
|
+
entryProcessorClient: this.entryProcessorClient,
|
|
2348
|
+
conflictResolverClient: this.conflictResolverClient,
|
|
2349
|
+
searchClient: this.searchClient,
|
|
2350
|
+
merkleSyncHandler: this.merkleSyncHandler,
|
|
2351
|
+
orMapSyncHandler: this.orMapSyncHandler
|
|
2352
|
+
}
|
|
2353
|
+
);
|
|
2354
|
+
this.webSocketManager.connect();
|
|
2355
|
+
this.loadOpLog();
|
|
2356
|
+
}
|
|
2357
|
+
// ============================================
|
|
2358
|
+
// Connection Callbacks (from WebSocketManager)
|
|
2359
|
+
// ============================================
|
|
2360
|
+
/**
|
|
2361
|
+
* Called when connection is established (initial or reconnect).
|
|
2362
|
+
*/
|
|
2363
|
+
handleConnectionEstablished() {
|
|
2364
|
+
if (this.authToken || this.tokenProvider) {
|
|
2365
|
+
logger.info("Connection established. Sending auth...");
|
|
2366
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
2367
|
+
this.sendAuth();
|
|
2368
|
+
} else {
|
|
2369
|
+
logger.info("Connection established. Waiting for auth token...");
|
|
2370
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* Called when connection is lost.
|
|
2375
|
+
*/
|
|
2376
|
+
handleConnectionLost() {
|
|
2377
|
+
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Called when reconnection succeeds.
|
|
2380
|
+
*/
|
|
2381
|
+
handleReconnection() {
|
|
2382
|
+
if (this.authToken || this.tokenProvider) {
|
|
2383
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
2384
|
+
this.sendAuth();
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
// ============================================
|
|
2388
|
+
// State Machine Public API
|
|
2389
|
+
// ============================================
|
|
2390
|
+
/**
|
|
2391
|
+
* Get the current connection state
|
|
2392
|
+
*/
|
|
2393
|
+
getConnectionState() {
|
|
2394
|
+
return this.stateMachine.getState();
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* Subscribe to connection state changes
|
|
2398
|
+
* @returns Unsubscribe function
|
|
2399
|
+
*/
|
|
2400
|
+
onConnectionStateChange(listener) {
|
|
2401
|
+
return this.stateMachine.onStateChange(listener);
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Get state machine history for debugging
|
|
2405
|
+
*/
|
|
2406
|
+
getStateHistory(limit) {
|
|
2407
|
+
return this.stateMachine.getHistory(limit);
|
|
2408
|
+
}
|
|
2409
|
+
// ============================================
|
|
2410
|
+
// Internal State Helpers (replace boolean flags)
|
|
2411
|
+
// ============================================
|
|
2412
|
+
/**
|
|
2413
|
+
* Check if WebSocket is connected (but may not be authenticated yet)
|
|
2414
|
+
*/
|
|
2415
|
+
isOnline() {
|
|
2416
|
+
const state = this.stateMachine.getState();
|
|
2417
|
+
return state === "CONNECTING" /* CONNECTING */ || state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Check if fully authenticated and ready for operations
|
|
2421
|
+
*/
|
|
2422
|
+
isAuthenticated() {
|
|
2423
|
+
const state = this.stateMachine.getState();
|
|
2424
|
+
return state === "SYNCING" /* SYNCING */ || state === "CONNECTED" /* CONNECTED */;
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Check if fully connected and synced
|
|
2428
|
+
*/
|
|
2429
|
+
isConnected() {
|
|
2430
|
+
return this.stateMachine.getState() === "CONNECTED" /* CONNECTED */;
|
|
2431
|
+
}
|
|
2432
|
+
// ============================================
|
|
2433
|
+
// Message Sending (delegates to WebSocketManager)
|
|
2434
|
+
// ============================================
|
|
2435
|
+
/**
|
|
2436
|
+
* Send a message through the current connection.
|
|
2437
|
+
* Delegates to WebSocketManager.
|
|
2438
|
+
*/
|
|
2439
|
+
sendMessage(message, key) {
|
|
2440
|
+
return this.webSocketManager.sendMessage(message, key);
|
|
2441
|
+
}
|
|
2442
|
+
// ============================================
|
|
2443
|
+
// Op Log Management
|
|
2444
|
+
// ============================================
|
|
2445
|
+
async loadOpLog() {
|
|
2446
|
+
const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
|
|
2447
|
+
if (storedTimestamp) {
|
|
2448
|
+
this.lastSyncTimestamp = storedTimestamp;
|
|
2449
|
+
}
|
|
2450
|
+
const pendingOps = await this.storageAdapter.getPendingOps();
|
|
2451
|
+
this.opLog.length = 0;
|
|
2452
|
+
for (const op of pendingOps) {
|
|
2453
|
+
this.opLog.push({
|
|
2454
|
+
...op,
|
|
2455
|
+
id: String(op.id),
|
|
2456
|
+
synced: false
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
if (this.opLog.length > 0) {
|
|
2460
|
+
logger.info({ count: this.opLog.length }, "Loaded pending operations from local storage");
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
async saveOpLog() {
|
|
2464
|
+
await this.storageAdapter.setMeta("lastSyncTimestamp", this.lastSyncTimestamp);
|
|
2465
|
+
}
|
|
2466
|
+
registerMap(mapName, map) {
|
|
2467
|
+
this.maps.set(mapName, map);
|
|
2468
|
+
}
|
|
2469
|
+
async recordOperation(mapName, opType, key, data) {
|
|
2470
|
+
await this.backpressureController.checkBackpressure();
|
|
2471
|
+
const opLogEntry = {
|
|
2472
|
+
mapName,
|
|
2473
|
+
opType,
|
|
2474
|
+
key,
|
|
2475
|
+
record: data.record,
|
|
2476
|
+
orRecord: data.orRecord,
|
|
2477
|
+
orTag: data.orTag,
|
|
2478
|
+
timestamp: data.timestamp,
|
|
2479
|
+
synced: false
|
|
2480
|
+
};
|
|
2481
|
+
const id = await this.storageAdapter.appendOpLog(opLogEntry);
|
|
2482
|
+
opLogEntry.id = String(id);
|
|
2483
|
+
this.opLog.push(opLogEntry);
|
|
2484
|
+
this.backpressureController.checkHighWaterMark();
|
|
2485
|
+
if (this.isAuthenticated()) {
|
|
2486
|
+
this.syncPendingOperations();
|
|
2487
|
+
}
|
|
2488
|
+
return opLogEntry.id;
|
|
2489
|
+
}
|
|
2490
|
+
syncPendingOperations() {
|
|
2491
|
+
const pending = this.opLog.filter((op) => !op.synced);
|
|
2492
|
+
if (pending.length === 0) return;
|
|
2493
|
+
logger.info({ count: pending.length }, "Syncing pending operations");
|
|
2494
|
+
this.sendMessage({
|
|
2495
|
+
type: "OP_BATCH",
|
|
2496
|
+
payload: {
|
|
2497
|
+
ops: pending
|
|
2498
|
+
}
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
startMerkleSync() {
|
|
2502
|
+
for (const [mapName, map] of this.maps) {
|
|
2503
|
+
if (map instanceof LWWMap2) {
|
|
2504
|
+
this.merkleSyncHandler.sendSyncInit(mapName, this.lastSyncTimestamp);
|
|
2505
|
+
} else if (map instanceof ORMap2) {
|
|
2506
|
+
this.orMapSyncHandler.sendSyncInit(mapName, this.lastSyncTimestamp);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
setAuthToken(token) {
|
|
2511
|
+
this.authToken = token;
|
|
2512
|
+
this.tokenProvider = null;
|
|
2513
|
+
const state = this.stateMachine.getState();
|
|
2514
|
+
if (state === "AUTHENTICATING" /* AUTHENTICATING */ || state === "CONNECTING" /* CONNECTING */) {
|
|
2515
|
+
this.sendAuth();
|
|
2516
|
+
} else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
|
|
2517
|
+
logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
|
|
2518
|
+
this.webSocketManager.clearReconnectTimer();
|
|
2519
|
+
this.webSocketManager.resetBackoff();
|
|
2520
|
+
this.webSocketManager.connect();
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
setTokenProvider(provider) {
|
|
2524
|
+
this.tokenProvider = provider;
|
|
2525
|
+
const state = this.stateMachine.getState();
|
|
2526
|
+
if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
|
|
2527
|
+
this.sendAuth();
|
|
2528
|
+
}
|
|
1070
2529
|
}
|
|
1071
2530
|
async sendAuth() {
|
|
1072
2531
|
if (this.tokenProvider) {
|
|
@@ -1087,545 +2546,204 @@ var _SyncEngine = class _SyncEngine {
|
|
|
1087
2546
|
token
|
|
1088
2547
|
});
|
|
1089
2548
|
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Subscribe to a standard query.
|
|
2551
|
+
* Delegates to QueryManager.
|
|
2552
|
+
*/
|
|
1090
2553
|
subscribeToQuery(query) {
|
|
1091
|
-
this.
|
|
1092
|
-
if (this.isAuthenticated()) {
|
|
1093
|
-
this.sendQuerySubscription(query);
|
|
1094
|
-
}
|
|
2554
|
+
this.queryManager.subscribeToQuery(query);
|
|
1095
2555
|
}
|
|
2556
|
+
/**
|
|
2557
|
+
* Subscribe to a topic.
|
|
2558
|
+
* Delegates to TopicManager.
|
|
2559
|
+
*/
|
|
1096
2560
|
subscribeToTopic(topic, handle) {
|
|
1097
|
-
this.
|
|
1098
|
-
if (this.isAuthenticated()) {
|
|
1099
|
-
this.sendTopicSubscription(topic);
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
unsubscribeFromTopic(topic) {
|
|
1103
|
-
this.topics.delete(topic);
|
|
1104
|
-
if (this.isAuthenticated()) {
|
|
1105
|
-
this.sendMessage({
|
|
1106
|
-
type: "TOPIC_UNSUB",
|
|
1107
|
-
payload: { topic }
|
|
1108
|
-
});
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
publishTopic(topic, data) {
|
|
1112
|
-
if (this.isAuthenticated()) {
|
|
1113
|
-
this.sendMessage({
|
|
1114
|
-
type: "TOPIC_PUB",
|
|
1115
|
-
payload: { topic, data }
|
|
1116
|
-
});
|
|
1117
|
-
} else {
|
|
1118
|
-
logger.warn({ topic }, "Dropped topic publish (offline)");
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
sendTopicSubscription(topic) {
|
|
1122
|
-
this.sendMessage({
|
|
1123
|
-
type: "TOPIC_SUB",
|
|
1124
|
-
payload: { topic }
|
|
1125
|
-
});
|
|
2561
|
+
this.topicManager.subscribeToTopic(topic, handle);
|
|
1126
2562
|
}
|
|
1127
2563
|
/**
|
|
1128
|
-
*
|
|
2564
|
+
* Unsubscribe from a topic.
|
|
2565
|
+
* Delegates to TopicManager.
|
|
1129
2566
|
*/
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
const mapKeys = keys.filter((k) => k.startsWith(mapName + ":"));
|
|
1133
|
-
const results = [];
|
|
1134
|
-
for (const fullKey of mapKeys) {
|
|
1135
|
-
const record = await this.storageAdapter.get(fullKey);
|
|
1136
|
-
if (record && record.value) {
|
|
1137
|
-
const actualKey = fullKey.slice(mapName.length + 1);
|
|
1138
|
-
let matches = true;
|
|
1139
|
-
if (filter.where) {
|
|
1140
|
-
for (const [k, v] of Object.entries(filter.where)) {
|
|
1141
|
-
if (record.value[k] !== v) {
|
|
1142
|
-
matches = false;
|
|
1143
|
-
break;
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
if (matches && filter.predicate) {
|
|
1148
|
-
if (!evaluatePredicate(filter.predicate, record.value)) {
|
|
1149
|
-
matches = false;
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
if (matches) {
|
|
1153
|
-
results.push({ key: actualKey, value: record.value });
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
return results;
|
|
1158
|
-
}
|
|
1159
|
-
unsubscribeFromQuery(queryId) {
|
|
1160
|
-
this.queries.delete(queryId);
|
|
1161
|
-
if (this.isAuthenticated()) {
|
|
1162
|
-
this.sendMessage({
|
|
1163
|
-
type: "QUERY_UNSUB",
|
|
1164
|
-
payload: { queryId }
|
|
1165
|
-
});
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
sendQuerySubscription(query) {
|
|
1169
|
-
this.sendMessage({
|
|
1170
|
-
type: "QUERY_SUB",
|
|
1171
|
-
payload: {
|
|
1172
|
-
queryId: query.id,
|
|
1173
|
-
mapName: query.getMapName(),
|
|
1174
|
-
query: query.getFilter()
|
|
1175
|
-
}
|
|
1176
|
-
});
|
|
1177
|
-
}
|
|
1178
|
-
requestLock(name, requestId, ttl) {
|
|
1179
|
-
if (!this.isAuthenticated()) {
|
|
1180
|
-
return Promise.reject(new Error("Not connected or authenticated"));
|
|
1181
|
-
}
|
|
1182
|
-
return new Promise((resolve, reject) => {
|
|
1183
|
-
const timer = setTimeout(() => {
|
|
1184
|
-
if (this.pendingLockRequests.has(requestId)) {
|
|
1185
|
-
this.pendingLockRequests.delete(requestId);
|
|
1186
|
-
reject(new Error("Lock request timed out waiting for server response"));
|
|
1187
|
-
}
|
|
1188
|
-
}, 3e4);
|
|
1189
|
-
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
1190
|
-
try {
|
|
1191
|
-
const sent = this.sendMessage({
|
|
1192
|
-
type: "LOCK_REQUEST",
|
|
1193
|
-
payload: { requestId, name, ttl }
|
|
1194
|
-
});
|
|
1195
|
-
if (!sent) {
|
|
1196
|
-
clearTimeout(timer);
|
|
1197
|
-
this.pendingLockRequests.delete(requestId);
|
|
1198
|
-
reject(new Error("Failed to send lock request"));
|
|
1199
|
-
}
|
|
1200
|
-
} catch (e) {
|
|
1201
|
-
clearTimeout(timer);
|
|
1202
|
-
this.pendingLockRequests.delete(requestId);
|
|
1203
|
-
reject(e);
|
|
1204
|
-
}
|
|
1205
|
-
});
|
|
1206
|
-
}
|
|
1207
|
-
releaseLock(name, requestId, fencingToken) {
|
|
1208
|
-
if (!this.isOnline()) return Promise.resolve(false);
|
|
1209
|
-
return new Promise((resolve, reject) => {
|
|
1210
|
-
const timer = setTimeout(() => {
|
|
1211
|
-
if (this.pendingLockRequests.has(requestId)) {
|
|
1212
|
-
this.pendingLockRequests.delete(requestId);
|
|
1213
|
-
resolve(false);
|
|
1214
|
-
}
|
|
1215
|
-
}, 5e3);
|
|
1216
|
-
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
1217
|
-
try {
|
|
1218
|
-
const sent = this.sendMessage({
|
|
1219
|
-
type: "LOCK_RELEASE",
|
|
1220
|
-
payload: { requestId, name, fencingToken }
|
|
1221
|
-
});
|
|
1222
|
-
if (!sent) {
|
|
1223
|
-
clearTimeout(timer);
|
|
1224
|
-
this.pendingLockRequests.delete(requestId);
|
|
1225
|
-
resolve(false);
|
|
1226
|
-
}
|
|
1227
|
-
} catch (e) {
|
|
1228
|
-
clearTimeout(timer);
|
|
1229
|
-
this.pendingLockRequests.delete(requestId);
|
|
1230
|
-
resolve(false);
|
|
1231
|
-
}
|
|
1232
|
-
});
|
|
2567
|
+
unsubscribeFromTopic(topic) {
|
|
2568
|
+
this.topicManager.unsubscribeFromTopic(topic);
|
|
1233
2569
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
}
|
|
1336
|
-
break;
|
|
1337
|
-
}
|
|
1338
|
-
case "QUERY_RESP": {
|
|
1339
|
-
const { queryId, results, nextCursor, hasMore, cursorStatus } = message.payload;
|
|
1340
|
-
const query = this.queries.get(queryId);
|
|
1341
|
-
if (query) {
|
|
1342
|
-
query.onResult(results, "server");
|
|
1343
|
-
query.updatePaginationInfo({ nextCursor, hasMore, cursorStatus });
|
|
1344
|
-
}
|
|
1345
|
-
break;
|
|
1346
|
-
}
|
|
1347
|
-
case "QUERY_UPDATE": {
|
|
1348
|
-
const { queryId, key, value, type } = message.payload;
|
|
1349
|
-
const query = this.queries.get(queryId);
|
|
1350
|
-
if (query) {
|
|
1351
|
-
query.onUpdate(key, type === "REMOVE" ? null : value);
|
|
1352
|
-
}
|
|
1353
|
-
break;
|
|
1354
|
-
}
|
|
1355
|
-
case "SERVER_EVENT": {
|
|
1356
|
-
const { mapName, eventType, key, record, orRecord, orTag } = message.payload;
|
|
1357
|
-
await this.applyServerEvent(mapName, eventType, key, record, orRecord, orTag);
|
|
1358
|
-
break;
|
|
1359
|
-
}
|
|
1360
|
-
case "SERVER_BATCH_EVENT": {
|
|
1361
|
-
const { events } = message.payload;
|
|
1362
|
-
for (const event of events) {
|
|
1363
|
-
await this.applyServerEvent(
|
|
1364
|
-
event.mapName,
|
|
1365
|
-
event.eventType,
|
|
1366
|
-
event.key,
|
|
1367
|
-
event.record,
|
|
1368
|
-
event.orRecord,
|
|
1369
|
-
event.orTag
|
|
1370
|
-
);
|
|
1371
|
-
}
|
|
1372
|
-
break;
|
|
1373
|
-
}
|
|
1374
|
-
case "TOPIC_MESSAGE": {
|
|
1375
|
-
const { topic, data, publisherId, timestamp } = message.payload;
|
|
1376
|
-
const handle = this.topics.get(topic);
|
|
1377
|
-
if (handle) {
|
|
1378
|
-
handle.onMessage(data, { publisherId, timestamp });
|
|
1379
|
-
}
|
|
1380
|
-
break;
|
|
1381
|
-
}
|
|
1382
|
-
case "GC_PRUNE": {
|
|
1383
|
-
const { olderThan } = message.payload;
|
|
1384
|
-
logger.info({ olderThan: olderThan.millis }, "Received GC_PRUNE request");
|
|
1385
|
-
for (const [name, map] of this.maps) {
|
|
1386
|
-
if (map instanceof LWWMap) {
|
|
1387
|
-
const removedKeys = map.prune(olderThan);
|
|
1388
|
-
for (const key of removedKeys) {
|
|
1389
|
-
await this.storageAdapter.remove(`${name}:${key}`);
|
|
1390
|
-
}
|
|
1391
|
-
if (removedKeys.length > 0) {
|
|
1392
|
-
logger.info({ mapName: name, count: removedKeys.length }, "Pruned tombstones from LWWMap");
|
|
1393
|
-
}
|
|
1394
|
-
} else if (map instanceof ORMap) {
|
|
1395
|
-
const removedTags = map.prune(olderThan);
|
|
1396
|
-
if (removedTags.length > 0) {
|
|
1397
|
-
logger.info({ mapName: name, count: removedTags.length }, "Pruned tombstones from ORMap");
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
break;
|
|
1402
|
-
}
|
|
1403
|
-
case "SYNC_RESET_REQUIRED": {
|
|
1404
|
-
const { mapName } = message.payload;
|
|
1405
|
-
logger.warn({ mapName }, "Sync Reset Required due to GC Age");
|
|
1406
|
-
await this.resetMap(mapName);
|
|
1407
|
-
this.sendMessage({
|
|
1408
|
-
type: "SYNC_INIT",
|
|
1409
|
-
mapName,
|
|
1410
|
-
lastSyncTimestamp: 0
|
|
1411
|
-
});
|
|
1412
|
-
break;
|
|
1413
|
-
}
|
|
1414
|
-
case "SYNC_RESP_ROOT": {
|
|
1415
|
-
const { mapName, rootHash, timestamp } = message.payload;
|
|
1416
|
-
const map = this.maps.get(mapName);
|
|
1417
|
-
if (map instanceof LWWMap) {
|
|
1418
|
-
const localRootHash = map.getMerkleTree().getRootHash();
|
|
1419
|
-
if (localRootHash !== rootHash) {
|
|
1420
|
-
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
|
|
1421
|
-
this.sendMessage({
|
|
1422
|
-
type: "MERKLE_REQ_BUCKET",
|
|
1423
|
-
payload: { mapName, path: "" }
|
|
1424
|
-
});
|
|
1425
|
-
} else {
|
|
1426
|
-
logger.info({ mapName }, "Map is in sync");
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
if (timestamp) {
|
|
1430
|
-
this.hlc.update(timestamp);
|
|
1431
|
-
this.lastSyncTimestamp = timestamp.millis;
|
|
1432
|
-
await this.saveOpLog();
|
|
1433
|
-
}
|
|
1434
|
-
break;
|
|
1435
|
-
}
|
|
1436
|
-
case "SYNC_RESP_BUCKETS": {
|
|
1437
|
-
const { mapName, path, buckets } = message.payload;
|
|
1438
|
-
const map = this.maps.get(mapName);
|
|
1439
|
-
if (map instanceof LWWMap) {
|
|
1440
|
-
const tree = map.getMerkleTree();
|
|
1441
|
-
const localBuckets = tree.getBuckets(path);
|
|
1442
|
-
for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
|
|
1443
|
-
const localHash = localBuckets[bucketKey] || 0;
|
|
1444
|
-
if (localHash !== remoteHash) {
|
|
1445
|
-
const newPath = path + bucketKey;
|
|
1446
|
-
this.sendMessage({
|
|
1447
|
-
type: "MERKLE_REQ_BUCKET",
|
|
1448
|
-
payload: { mapName, path: newPath }
|
|
1449
|
-
});
|
|
1450
|
-
}
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
break;
|
|
1454
|
-
}
|
|
1455
|
-
case "SYNC_RESP_LEAF": {
|
|
1456
|
-
const { mapName, records } = message.payload;
|
|
1457
|
-
const map = this.maps.get(mapName);
|
|
1458
|
-
if (map instanceof LWWMap) {
|
|
1459
|
-
let updateCount = 0;
|
|
1460
|
-
for (const { key, record } of records) {
|
|
1461
|
-
const updated = map.merge(key, record);
|
|
1462
|
-
if (updated) {
|
|
1463
|
-
updateCount++;
|
|
1464
|
-
await this.storageAdapter.put(`${mapName}:${key}`, record);
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
if (updateCount > 0) {
|
|
1468
|
-
logger.info({ mapName, count: updateCount }, "Synced records from server");
|
|
1469
|
-
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Publish a message to a topic.
|
|
2572
|
+
* Delegates to TopicManager.
|
|
2573
|
+
*/
|
|
2574
|
+
publishTopic(topic, data) {
|
|
2575
|
+
this.topicManager.publishTopic(topic, data);
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Get topic queue status.
|
|
2579
|
+
* Delegates to TopicManager.
|
|
2580
|
+
*/
|
|
2581
|
+
getTopicQueueStatus() {
|
|
2582
|
+
return this.topicManager.getTopicQueueStatus();
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* Executes a query against local storage immediately.
|
|
2586
|
+
* Delegates to QueryManager.
|
|
2587
|
+
*/
|
|
2588
|
+
async runLocalQuery(mapName, filter) {
|
|
2589
|
+
return this.queryManager.runLocalQuery(mapName, filter);
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* Unsubscribe from a query.
|
|
2593
|
+
* Delegates to QueryManager.
|
|
2594
|
+
*/
|
|
2595
|
+
unsubscribeFromQuery(queryId) {
|
|
2596
|
+
this.queryManager.unsubscribeFromQuery(queryId);
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Request a distributed lock.
|
|
2600
|
+
* Delegates to LockManager.
|
|
2601
|
+
*/
|
|
2602
|
+
requestLock(name, requestId, ttl) {
|
|
2603
|
+
return this.lockManager.requestLock(name, requestId, ttl);
|
|
2604
|
+
}
|
|
2605
|
+
/**
|
|
2606
|
+
* Release a distributed lock.
|
|
2607
|
+
* Delegates to LockManager.
|
|
2608
|
+
*/
|
|
2609
|
+
releaseLock(name, requestId, fencingToken) {
|
|
2610
|
+
return this.lockManager.releaseLock(name, requestId, fencingToken);
|
|
2611
|
+
}
|
|
2612
|
+
async handleServerMessage(message) {
|
|
2613
|
+
this.emitMessage(message);
|
|
2614
|
+
if (message.type === "BATCH") {
|
|
2615
|
+
await this.handleBatch(message);
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
await this.messageRouter.route(message);
|
|
2619
|
+
if (message.timestamp) {
|
|
2620
|
+
this.hlc.update(message.timestamp);
|
|
2621
|
+
this.lastSyncTimestamp = message.timestamp.millis;
|
|
2622
|
+
await this.saveOpLog();
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
// ============================================
|
|
2626
|
+
// Message Handler Helpers (extracted from switch)
|
|
2627
|
+
// ============================================
|
|
2628
|
+
async handleBatch(message) {
|
|
2629
|
+
const batchData = message.data;
|
|
2630
|
+
const view = new DataView(batchData.buffer, batchData.byteOffset, batchData.byteLength);
|
|
2631
|
+
let offset = 0;
|
|
2632
|
+
const count = view.getUint32(offset, true);
|
|
2633
|
+
offset += 4;
|
|
2634
|
+
for (let i = 0; i < count; i++) {
|
|
2635
|
+
const msgLen = view.getUint32(offset, true);
|
|
2636
|
+
offset += 4;
|
|
2637
|
+
const msgData = batchData.slice(offset, offset + msgLen);
|
|
2638
|
+
offset += msgLen;
|
|
2639
|
+
const innerMsg = deserialize2(msgData);
|
|
2640
|
+
await this.handleServerMessage(innerMsg);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
handleAuthAck() {
|
|
2644
|
+
logger.info("Authenticated successfully");
|
|
2645
|
+
const wasAuthenticated = this.isAuthenticated();
|
|
2646
|
+
this.stateMachine.transition("SYNCING" /* SYNCING */);
|
|
2647
|
+
this.webSocketManager.resetBackoff();
|
|
2648
|
+
this.syncPendingOperations();
|
|
2649
|
+
this.topicManager.flushTopicQueue();
|
|
2650
|
+
if (!wasAuthenticated) {
|
|
2651
|
+
this.webSocketManager.startHeartbeat();
|
|
2652
|
+
this.startMerkleSync();
|
|
2653
|
+
this.queryManager.resubscribeAll();
|
|
2654
|
+
this.topicManager.resubscribeAll();
|
|
2655
|
+
}
|
|
2656
|
+
this.stateMachine.transition("CONNECTED" /* CONNECTED */);
|
|
2657
|
+
}
|
|
2658
|
+
handleAuthFail(message) {
|
|
2659
|
+
logger.error({ error: message.error }, "Authentication failed");
|
|
2660
|
+
this.authToken = null;
|
|
2661
|
+
}
|
|
2662
|
+
handleOpAck(message) {
|
|
2663
|
+
const { lastId, achievedLevel, results } = message.payload;
|
|
2664
|
+
logger.info({ lastId, achievedLevel, hasResults: !!results }, "Received ACK for ops");
|
|
2665
|
+
if (results && Array.isArray(results)) {
|
|
2666
|
+
for (const result of results) {
|
|
2667
|
+
const op = this.opLog.find((o) => o.id === result.opId);
|
|
2668
|
+
if (op && !op.synced) {
|
|
2669
|
+
op.synced = true;
|
|
2670
|
+
logger.debug({ opId: result.opId, achievedLevel: result.achievedLevel, success: result.success }, "Op ACK with Write Concern");
|
|
1470
2671
|
}
|
|
1471
|
-
|
|
2672
|
+
this.writeConcernManager.resolveWriteConcernPromise(result.opId, result);
|
|
1472
2673
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
if (localRootHash !== rootHash) {
|
|
1481
|
-
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
|
|
1482
|
-
this.sendMessage({
|
|
1483
|
-
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
1484
|
-
payload: { mapName, path: "" }
|
|
1485
|
-
});
|
|
1486
|
-
} else {
|
|
1487
|
-
logger.info({ mapName }, "ORMap is in sync");
|
|
1488
|
-
}
|
|
2674
|
+
}
|
|
2675
|
+
let maxSyncedId = -1;
|
|
2676
|
+
let ackedCount = 0;
|
|
2677
|
+
this.opLog.forEach((op) => {
|
|
2678
|
+
if (op.id && op.id <= lastId) {
|
|
2679
|
+
if (!op.synced) {
|
|
2680
|
+
ackedCount++;
|
|
1489
2681
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
2682
|
+
op.synced = true;
|
|
2683
|
+
const idNum = parseInt(op.id, 10);
|
|
2684
|
+
if (!isNaN(idNum) && idNum > maxSyncedId) {
|
|
2685
|
+
maxSyncedId = idNum;
|
|
1494
2686
|
}
|
|
1495
|
-
break;
|
|
1496
2687
|
}
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
2688
|
+
});
|
|
2689
|
+
if (maxSyncedId !== -1) {
|
|
2690
|
+
this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
|
|
2691
|
+
}
|
|
2692
|
+
if (ackedCount > 0) {
|
|
2693
|
+
this.backpressureController.checkLowWaterMark();
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
handleQueryResp(message) {
|
|
2697
|
+
const { queryId, results, nextCursor, hasMore, cursorStatus } = message.payload;
|
|
2698
|
+
const query = this.queryManager.getQueries().get(queryId);
|
|
2699
|
+
if (query) {
|
|
2700
|
+
query.onResult(results, "server");
|
|
2701
|
+
query.updatePaginationInfo({ nextCursor, hasMore, cursorStatus });
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
handleQueryUpdate(message) {
|
|
2705
|
+
const { queryId, key, value, type } = message.payload;
|
|
2706
|
+
const query = this.queryManager.getQueries().get(queryId);
|
|
2707
|
+
if (query) {
|
|
2708
|
+
query.onUpdate(key, type === "REMOVE" ? null : value);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
async handleServerEvent(message) {
|
|
2712
|
+
const { mapName, eventType, key, record, orRecord, orTag } = message.payload;
|
|
2713
|
+
await this.applyServerEvent(mapName, eventType, key, record, orRecord, orTag);
|
|
2714
|
+
}
|
|
2715
|
+
async handleServerBatchEvent(message) {
|
|
2716
|
+
const { events } = message.payload;
|
|
2717
|
+
for (const event of events) {
|
|
2718
|
+
await this.applyServerEvent(
|
|
2719
|
+
event.mapName,
|
|
2720
|
+
event.eventType,
|
|
2721
|
+
event.key,
|
|
2722
|
+
event.record,
|
|
2723
|
+
event.orRecord,
|
|
2724
|
+
event.orTag
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
async handleGcPrune(message) {
|
|
2729
|
+
const { olderThan } = message.payload;
|
|
2730
|
+
logger.info({ olderThan: olderThan.millis }, "Received GC_PRUNE request");
|
|
2731
|
+
for (const [name, map] of this.maps) {
|
|
2732
|
+
if (map instanceof LWWMap2) {
|
|
2733
|
+
const removedKeys = map.prune(olderThan);
|
|
2734
|
+
for (const key of removedKeys) {
|
|
2735
|
+
await this.storageAdapter.remove(`${name}:${key}`);
|
|
1522
2736
|
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
case "ORMAP_SYNC_RESP_LEAF": {
|
|
1526
|
-
const { mapName, entries } = message.payload;
|
|
1527
|
-
const map = this.maps.get(mapName);
|
|
1528
|
-
if (map instanceof ORMap) {
|
|
1529
|
-
let totalAdded = 0;
|
|
1530
|
-
let totalUpdated = 0;
|
|
1531
|
-
for (const entry of entries) {
|
|
1532
|
-
const { key, records, tombstones } = entry;
|
|
1533
|
-
const result = map.mergeKey(key, records, tombstones);
|
|
1534
|
-
totalAdded += result.added;
|
|
1535
|
-
totalUpdated += result.updated;
|
|
1536
|
-
}
|
|
1537
|
-
if (totalAdded > 0 || totalUpdated > 0) {
|
|
1538
|
-
logger.info({ mapName, added: totalAdded, updated: totalUpdated }, "Synced ORMap records from server");
|
|
1539
|
-
}
|
|
1540
|
-
const keysToCheck = entries.map((e) => e.key);
|
|
1541
|
-
await this.pushORMapDiff(mapName, keysToCheck, map);
|
|
2737
|
+
if (removedKeys.length > 0) {
|
|
2738
|
+
logger.info({ mapName: name, count: removedKeys.length }, "Pruned tombstones from LWWMap");
|
|
1542
2739
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
const map = this.maps.get(mapName);
|
|
1548
|
-
if (map instanceof ORMap) {
|
|
1549
|
-
let totalAdded = 0;
|
|
1550
|
-
let totalUpdated = 0;
|
|
1551
|
-
for (const entry of entries) {
|
|
1552
|
-
const { key, records, tombstones } = entry;
|
|
1553
|
-
const result = map.mergeKey(key, records, tombstones);
|
|
1554
|
-
totalAdded += result.added;
|
|
1555
|
-
totalUpdated += result.updated;
|
|
1556
|
-
}
|
|
1557
|
-
if (totalAdded > 0 || totalUpdated > 0) {
|
|
1558
|
-
logger.info({ mapName, added: totalAdded, updated: totalUpdated }, "Merged ORMap diff from server");
|
|
1559
|
-
}
|
|
2740
|
+
} else if (map instanceof ORMap2) {
|
|
2741
|
+
const removedTags = map.prune(olderThan);
|
|
2742
|
+
if (removedTags.length > 0) {
|
|
2743
|
+
logger.info({ mapName: name, count: removedTags.length }, "Pruned tombstones from ORMap");
|
|
1560
2744
|
}
|
|
1561
|
-
break;
|
|
1562
|
-
}
|
|
1563
|
-
// ============ PN Counter Message Handlers (Phase 5.2) ============
|
|
1564
|
-
case "COUNTER_UPDATE": {
|
|
1565
|
-
const { name, state } = message.payload;
|
|
1566
|
-
logger.debug({ name }, "Received COUNTER_UPDATE");
|
|
1567
|
-
this.handleCounterUpdate(name, state);
|
|
1568
|
-
break;
|
|
1569
|
-
}
|
|
1570
|
-
case "COUNTER_RESPONSE": {
|
|
1571
|
-
const { name, state } = message.payload;
|
|
1572
|
-
logger.debug({ name }, "Received COUNTER_RESPONSE");
|
|
1573
|
-
this.handleCounterUpdate(name, state);
|
|
1574
|
-
break;
|
|
1575
|
-
}
|
|
1576
|
-
// ============ Entry Processor Message Handlers (Phase 5.03) ============
|
|
1577
|
-
case "ENTRY_PROCESS_RESPONSE": {
|
|
1578
|
-
logger.debug({ requestId: message.requestId, success: message.success }, "Received ENTRY_PROCESS_RESPONSE");
|
|
1579
|
-
this.handleEntryProcessResponse(message);
|
|
1580
|
-
break;
|
|
1581
|
-
}
|
|
1582
|
-
case "ENTRY_PROCESS_BATCH_RESPONSE": {
|
|
1583
|
-
logger.debug({ requestId: message.requestId }, "Received ENTRY_PROCESS_BATCH_RESPONSE");
|
|
1584
|
-
this.handleEntryProcessBatchResponse(message);
|
|
1585
|
-
break;
|
|
1586
|
-
}
|
|
1587
|
-
// ============ Conflict Resolver Message Handlers (Phase 5.05) ============
|
|
1588
|
-
case "REGISTER_RESOLVER_RESPONSE": {
|
|
1589
|
-
logger.debug({ requestId: message.requestId, success: message.success }, "Received REGISTER_RESOLVER_RESPONSE");
|
|
1590
|
-
this.conflictResolverClient.handleRegisterResponse(message);
|
|
1591
|
-
break;
|
|
1592
|
-
}
|
|
1593
|
-
case "UNREGISTER_RESOLVER_RESPONSE": {
|
|
1594
|
-
logger.debug({ requestId: message.requestId, success: message.success }, "Received UNREGISTER_RESOLVER_RESPONSE");
|
|
1595
|
-
this.conflictResolverClient.handleUnregisterResponse(message);
|
|
1596
|
-
break;
|
|
1597
|
-
}
|
|
1598
|
-
case "LIST_RESOLVERS_RESPONSE": {
|
|
1599
|
-
logger.debug({ requestId: message.requestId }, "Received LIST_RESOLVERS_RESPONSE");
|
|
1600
|
-
this.conflictResolverClient.handleListResponse(message);
|
|
1601
|
-
break;
|
|
1602
|
-
}
|
|
1603
|
-
case "MERGE_REJECTED": {
|
|
1604
|
-
logger.debug({ mapName: message.mapName, key: message.key, reason: message.reason }, "Received MERGE_REJECTED");
|
|
1605
|
-
this.conflictResolverClient.handleMergeRejected(message);
|
|
1606
|
-
break;
|
|
1607
|
-
}
|
|
1608
|
-
// ============ Full-Text Search Message Handlers (Phase 11.1a) ============
|
|
1609
|
-
case "SEARCH_RESP": {
|
|
1610
|
-
logger.debug({ requestId: message.payload?.requestId, resultCount: message.payload?.results?.length }, "Received SEARCH_RESP");
|
|
1611
|
-
this.handleSearchResponse(message.payload);
|
|
1612
|
-
break;
|
|
1613
|
-
}
|
|
1614
|
-
// ============ Live Search Message Handlers (Phase 11.1b) ============
|
|
1615
|
-
case "SEARCH_UPDATE": {
|
|
1616
|
-
logger.debug({
|
|
1617
|
-
subscriptionId: message.payload?.subscriptionId,
|
|
1618
|
-
key: message.payload?.key,
|
|
1619
|
-
type: message.payload?.type
|
|
1620
|
-
}, "Received SEARCH_UPDATE");
|
|
1621
|
-
break;
|
|
1622
2745
|
}
|
|
1623
2746
|
}
|
|
1624
|
-
if (message.timestamp) {
|
|
1625
|
-
this.hlc.update(message.timestamp);
|
|
1626
|
-
this.lastSyncTimestamp = message.timestamp.millis;
|
|
1627
|
-
await this.saveOpLog();
|
|
1628
|
-
}
|
|
1629
2747
|
}
|
|
1630
2748
|
getHLC() {
|
|
1631
2749
|
return this.hlc;
|
|
@@ -1637,10 +2755,10 @@ var _SyncEngine = class _SyncEngine {
|
|
|
1637
2755
|
async applyServerEvent(mapName, eventType, key, record, orRecord, orTag) {
|
|
1638
2756
|
const localMap = this.maps.get(mapName);
|
|
1639
2757
|
if (localMap) {
|
|
1640
|
-
if (localMap instanceof
|
|
2758
|
+
if (localMap instanceof LWWMap2 && record) {
|
|
1641
2759
|
localMap.merge(key, record);
|
|
1642
2760
|
await this.storageAdapter.put(`${mapName}:${key}`, record);
|
|
1643
|
-
} else if (localMap instanceof
|
|
2761
|
+
} else if (localMap instanceof ORMap2) {
|
|
1644
2762
|
if (eventType === "OR_ADD" && orRecord) {
|
|
1645
2763
|
localMap.apply(key, orRecord);
|
|
1646
2764
|
} else if (eventType === "OR_REMOVE" && orTag) {
|
|
@@ -1653,21 +2771,11 @@ var _SyncEngine = class _SyncEngine {
|
|
|
1653
2771
|
* Closes the WebSocket connection and cleans up resources.
|
|
1654
2772
|
*/
|
|
1655
2773
|
close() {
|
|
1656
|
-
this.
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
if (this.useConnectionProvider) {
|
|
1662
|
-
this.connectionProvider.close().catch((err) => {
|
|
1663
|
-
logger.error({ err }, "Error closing ConnectionProvider");
|
|
1664
|
-
});
|
|
1665
|
-
} else if (this.websocket) {
|
|
1666
|
-
this.websocket.onclose = null;
|
|
1667
|
-
this.websocket.close();
|
|
1668
|
-
this.websocket = null;
|
|
1669
|
-
}
|
|
1670
|
-
this.cancelAllWriteConcernPromises(new Error("SyncEngine closed"));
|
|
2774
|
+
this.webSocketManager.close();
|
|
2775
|
+
this.writeConcernManager.cancelAllWriteConcernPromises(new Error("SyncEngine closed"));
|
|
2776
|
+
this.counterManager.close();
|
|
2777
|
+
this.entryProcessorClient.close(new Error("SyncEngine closed"));
|
|
2778
|
+
this.searchClient.close(new Error("SyncEngine closed"));
|
|
1671
2779
|
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
1672
2780
|
logger.info("SyncEngine closed");
|
|
1673
2781
|
}
|
|
@@ -1678,15 +2786,11 @@ var _SyncEngine = class _SyncEngine {
|
|
|
1678
2786
|
resetConnection() {
|
|
1679
2787
|
this.close();
|
|
1680
2788
|
this.stateMachine.reset();
|
|
1681
|
-
this.
|
|
1682
|
-
|
|
1683
|
-
this.initConnectionProvider();
|
|
1684
|
-
} else {
|
|
1685
|
-
this.initConnection();
|
|
1686
|
-
}
|
|
2789
|
+
this.webSocketManager.reset();
|
|
2790
|
+
this.webSocketManager.connect();
|
|
1687
2791
|
}
|
|
1688
2792
|
// ============================================
|
|
1689
|
-
// Failover Support Methods
|
|
2793
|
+
// Failover Support Methods
|
|
1690
2794
|
// ============================================
|
|
1691
2795
|
/**
|
|
1692
2796
|
* Wait for a partition map update from the connection provider.
|
|
@@ -1699,12 +2803,13 @@ var _SyncEngine = class _SyncEngine {
|
|
|
1699
2803
|
waitForPartitionMapUpdate(timeoutMs = 5e3) {
|
|
1700
2804
|
return new Promise((resolve) => {
|
|
1701
2805
|
const timeout = setTimeout(resolve, timeoutMs);
|
|
2806
|
+
const connectionProvider = this.webSocketManager.getConnectionProvider();
|
|
1702
2807
|
const handler2 = () => {
|
|
1703
2808
|
clearTimeout(timeout);
|
|
1704
|
-
|
|
2809
|
+
connectionProvider.off("partitionMapUpdated", handler2);
|
|
1705
2810
|
resolve();
|
|
1706
2811
|
};
|
|
1707
|
-
|
|
2812
|
+
connectionProvider.on("partitionMapUpdated", handler2);
|
|
1708
2813
|
});
|
|
1709
2814
|
}
|
|
1710
2815
|
/**
|
|
@@ -1717,20 +2822,21 @@ var _SyncEngine = class _SyncEngine {
|
|
|
1717
2822
|
*/
|
|
1718
2823
|
waitForConnection(timeoutMs = 1e4) {
|
|
1719
2824
|
return new Promise((resolve, reject) => {
|
|
1720
|
-
|
|
2825
|
+
const connectionProvider = this.webSocketManager.getConnectionProvider();
|
|
2826
|
+
if (connectionProvider.isConnected()) {
|
|
1721
2827
|
resolve();
|
|
1722
2828
|
return;
|
|
1723
2829
|
}
|
|
1724
2830
|
const timeout = setTimeout(() => {
|
|
1725
|
-
|
|
2831
|
+
connectionProvider.off("connected", handler2);
|
|
1726
2832
|
reject(new Error("Connection timeout waiting for reconnection"));
|
|
1727
2833
|
}, timeoutMs);
|
|
1728
2834
|
const handler2 = () => {
|
|
1729
2835
|
clearTimeout(timeout);
|
|
1730
|
-
|
|
2836
|
+
connectionProvider.off("connected", handler2);
|
|
1731
2837
|
resolve();
|
|
1732
2838
|
};
|
|
1733
|
-
|
|
2839
|
+
connectionProvider.on("connected", handler2);
|
|
1734
2840
|
});
|
|
1735
2841
|
}
|
|
1736
2842
|
/**
|
|
@@ -1765,21 +2871,21 @@ var _SyncEngine = class _SyncEngine {
|
|
|
1765
2871
|
* Convenience method for failover logic.
|
|
1766
2872
|
*/
|
|
1767
2873
|
isProviderConnected() {
|
|
1768
|
-
return this.
|
|
2874
|
+
return this.webSocketManager.getConnectionProvider().isConnected();
|
|
1769
2875
|
}
|
|
1770
2876
|
/**
|
|
1771
2877
|
* Get the connection provider for direct access.
|
|
1772
2878
|
* Use with caution - prefer using SyncEngine methods.
|
|
1773
2879
|
*/
|
|
1774
2880
|
getConnectionProvider() {
|
|
1775
|
-
return this.
|
|
2881
|
+
return this.webSocketManager.getConnectionProvider();
|
|
1776
2882
|
}
|
|
1777
2883
|
async resetMap(mapName) {
|
|
1778
2884
|
const map = this.maps.get(mapName);
|
|
1779
2885
|
if (map) {
|
|
1780
|
-
if (map instanceof
|
|
2886
|
+
if (map instanceof LWWMap2) {
|
|
1781
2887
|
map.clear();
|
|
1782
|
-
} else if (map instanceof
|
|
2888
|
+
} else if (map instanceof ORMap2) {
|
|
1783
2889
|
map.clear();
|
|
1784
2890
|
}
|
|
1785
2891
|
}
|
|
@@ -1790,468 +2896,116 @@ var _SyncEngine = class _SyncEngine {
|
|
|
1790
2896
|
}
|
|
1791
2897
|
logger.info({ mapName, removedStorageCount: mapKeys.length }, "Reset map: Cleared memory and storage");
|
|
1792
2898
|
}
|
|
1793
|
-
// ============ Heartbeat Methods ============
|
|
1794
|
-
/**
|
|
1795
|
-
* Starts the heartbeat mechanism after successful connection.
|
|
1796
|
-
*/
|
|
1797
|
-
startHeartbeat() {
|
|
1798
|
-
if (!this.heartbeatConfig.enabled) {
|
|
1799
|
-
return;
|
|
1800
|
-
}
|
|
1801
|
-
this.stopHeartbeat();
|
|
1802
|
-
this.lastPongReceived = Date.now();
|
|
1803
|
-
this.heartbeatInterval = setInterval(() => {
|
|
1804
|
-
this.sendPing();
|
|
1805
|
-
this.checkHeartbeatTimeout();
|
|
1806
|
-
}, this.heartbeatConfig.intervalMs);
|
|
1807
|
-
logger.info({ intervalMs: this.heartbeatConfig.intervalMs }, "Heartbeat started");
|
|
1808
|
-
}
|
|
1809
|
-
/**
|
|
1810
|
-
* Stops the heartbeat mechanism.
|
|
1811
|
-
*/
|
|
1812
|
-
stopHeartbeat() {
|
|
1813
|
-
if (this.heartbeatInterval) {
|
|
1814
|
-
clearInterval(this.heartbeatInterval);
|
|
1815
|
-
this.heartbeatInterval = null;
|
|
1816
|
-
logger.info("Heartbeat stopped");
|
|
1817
|
-
}
|
|
1818
|
-
}
|
|
1819
|
-
/**
|
|
1820
|
-
* Sends a PING message to the server.
|
|
1821
|
-
*/
|
|
1822
|
-
sendPing() {
|
|
1823
|
-
if (this.canSend()) {
|
|
1824
|
-
const pingMessage = {
|
|
1825
|
-
type: "PING",
|
|
1826
|
-
timestamp: Date.now()
|
|
1827
|
-
};
|
|
1828
|
-
this.sendMessage(pingMessage);
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
/**
|
|
1832
|
-
* Handles incoming PONG message from server.
|
|
1833
|
-
*/
|
|
1834
|
-
handlePong(msg) {
|
|
1835
|
-
const now = Date.now();
|
|
1836
|
-
this.lastPongReceived = now;
|
|
1837
|
-
this.lastRoundTripTime = now - msg.timestamp;
|
|
1838
|
-
logger.debug({
|
|
1839
|
-
rtt: this.lastRoundTripTime,
|
|
1840
|
-
serverTime: msg.serverTime,
|
|
1841
|
-
clockSkew: msg.serverTime - (msg.timestamp + this.lastRoundTripTime / 2)
|
|
1842
|
-
}, "Received PONG");
|
|
1843
|
-
}
|
|
1844
|
-
/**
|
|
1845
|
-
* Checks if heartbeat has timed out and triggers reconnection if needed.
|
|
1846
|
-
*/
|
|
1847
|
-
checkHeartbeatTimeout() {
|
|
1848
|
-
const now = Date.now();
|
|
1849
|
-
const timeSinceLastPong = now - this.lastPongReceived;
|
|
1850
|
-
if (timeSinceLastPong > this.heartbeatConfig.timeoutMs) {
|
|
1851
|
-
logger.warn({
|
|
1852
|
-
timeSinceLastPong,
|
|
1853
|
-
timeoutMs: this.heartbeatConfig.timeoutMs
|
|
1854
|
-
}, "Heartbeat timeout - triggering reconnection");
|
|
1855
|
-
this.stopHeartbeat();
|
|
1856
|
-
if (this.websocket) {
|
|
1857
|
-
this.websocket.close();
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1861
|
-
/**
|
|
1862
|
-
* Returns the last measured round-trip time in milliseconds.
|
|
1863
|
-
* Returns null if no PONG has been received yet.
|
|
1864
|
-
*/
|
|
1865
|
-
getLastRoundTripTime() {
|
|
1866
|
-
return this.lastRoundTripTime;
|
|
1867
|
-
}
|
|
1868
|
-
/**
|
|
1869
|
-
* Returns true if the connection is considered healthy based on heartbeat.
|
|
1870
|
-
* A connection is healthy if it's online, authenticated, and has received
|
|
1871
|
-
* a PONG within the timeout window.
|
|
1872
|
-
*/
|
|
1873
|
-
isConnectionHealthy() {
|
|
1874
|
-
if (!this.isOnline() || !this.isAuthenticated()) {
|
|
1875
|
-
return false;
|
|
1876
|
-
}
|
|
1877
|
-
if (!this.heartbeatConfig.enabled) {
|
|
1878
|
-
return true;
|
|
1879
|
-
}
|
|
1880
|
-
const timeSinceLastPong = Date.now() - this.lastPongReceived;
|
|
1881
|
-
return timeSinceLastPong < this.heartbeatConfig.timeoutMs;
|
|
1882
|
-
}
|
|
1883
|
-
// ============ ORMap Sync Methods ============
|
|
1884
|
-
/**
|
|
1885
|
-
* Push local ORMap diff to server for the given keys.
|
|
1886
|
-
* Sends local records and tombstones that the server might not have.
|
|
1887
|
-
*/
|
|
1888
|
-
async pushORMapDiff(mapName, keys, map) {
|
|
1889
|
-
const entries = [];
|
|
1890
|
-
const snapshot = map.getSnapshot();
|
|
1891
|
-
for (const key of keys) {
|
|
1892
|
-
const recordsMap = map.getRecordsMap(key);
|
|
1893
|
-
if (recordsMap && recordsMap.size > 0) {
|
|
1894
|
-
const records = Array.from(recordsMap.values());
|
|
1895
|
-
const tombstones = [];
|
|
1896
|
-
for (const tag of snapshot.tombstones) {
|
|
1897
|
-
tombstones.push(tag);
|
|
1898
|
-
}
|
|
1899
|
-
entries.push({
|
|
1900
|
-
key,
|
|
1901
|
-
records,
|
|
1902
|
-
tombstones
|
|
1903
|
-
});
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
if (entries.length > 0) {
|
|
1907
|
-
this.sendMessage({
|
|
1908
|
-
type: "ORMAP_PUSH_DIFF",
|
|
1909
|
-
payload: {
|
|
1910
|
-
mapName,
|
|
1911
|
-
entries
|
|
1912
|
-
}
|
|
1913
|
-
});
|
|
1914
|
-
logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
// ============ Backpressure Methods ============
|
|
1918
|
-
/**
|
|
1919
|
-
* Get the current number of pending (unsynced) operations.
|
|
1920
|
-
*/
|
|
1921
|
-
getPendingOpsCount() {
|
|
1922
|
-
return this.opLog.filter((op) => !op.synced).length;
|
|
1923
|
-
}
|
|
1924
|
-
/**
|
|
1925
|
-
* Get the current backpressure status.
|
|
1926
|
-
*/
|
|
1927
|
-
getBackpressureStatus() {
|
|
1928
|
-
const pending = this.getPendingOpsCount();
|
|
1929
|
-
const max = this.backpressureConfig.maxPendingOps;
|
|
1930
|
-
return {
|
|
1931
|
-
pending,
|
|
1932
|
-
max,
|
|
1933
|
-
percentage: max > 0 ? pending / max : 0,
|
|
1934
|
-
isPaused: this.backpressurePaused,
|
|
1935
|
-
strategy: this.backpressureConfig.strategy
|
|
1936
|
-
};
|
|
1937
|
-
}
|
|
1938
|
-
/**
|
|
1939
|
-
* Returns true if writes are currently paused due to backpressure.
|
|
1940
|
-
*/
|
|
1941
|
-
isBackpressurePaused() {
|
|
1942
|
-
return this.backpressurePaused;
|
|
1943
|
-
}
|
|
1944
|
-
/**
|
|
1945
|
-
* Subscribe to backpressure events.
|
|
1946
|
-
* @param event Event name: 'backpressure:high', 'backpressure:low', 'backpressure:paused', 'backpressure:resumed', 'operation:dropped'
|
|
1947
|
-
* @param listener Callback function
|
|
1948
|
-
* @returns Unsubscribe function
|
|
1949
|
-
*/
|
|
1950
|
-
onBackpressure(event, listener) {
|
|
1951
|
-
if (!this.backpressureListeners.has(event)) {
|
|
1952
|
-
this.backpressureListeners.set(event, /* @__PURE__ */ new Set());
|
|
1953
|
-
}
|
|
1954
|
-
this.backpressureListeners.get(event).add(listener);
|
|
1955
|
-
return () => {
|
|
1956
|
-
this.backpressureListeners.get(event)?.delete(listener);
|
|
1957
|
-
};
|
|
1958
|
-
}
|
|
2899
|
+
// ============ Heartbeat Methods (delegate to WebSocketManager) ============
|
|
1959
2900
|
/**
|
|
1960
|
-
*
|
|
2901
|
+
* Returns the last measured round-trip time in milliseconds.
|
|
2902
|
+
* Returns null if no PONG has been received yet.
|
|
1961
2903
|
*/
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
if (listeners) {
|
|
1965
|
-
for (const listener of listeners) {
|
|
1966
|
-
try {
|
|
1967
|
-
listener(data);
|
|
1968
|
-
} catch (err) {
|
|
1969
|
-
logger.error({ err, event }, "Error in backpressure event listener");
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
2904
|
+
getLastRoundTripTime() {
|
|
2905
|
+
return this.webSocketManager.getLastRoundTripTime();
|
|
1973
2906
|
}
|
|
1974
2907
|
/**
|
|
1975
|
-
*
|
|
1976
|
-
*
|
|
2908
|
+
* Returns true if the connection is considered healthy based on heartbeat.
|
|
2909
|
+
* A connection is healthy if it's online, authenticated, and has received
|
|
2910
|
+
* a PONG within the timeout window.
|
|
1977
2911
|
*/
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
if (pendingCount < this.backpressureConfig.maxPendingOps) {
|
|
1981
|
-
return;
|
|
1982
|
-
}
|
|
1983
|
-
switch (this.backpressureConfig.strategy) {
|
|
1984
|
-
case "pause":
|
|
1985
|
-
await this.waitForCapacity();
|
|
1986
|
-
break;
|
|
1987
|
-
case "throw":
|
|
1988
|
-
throw new BackpressureError(
|
|
1989
|
-
pendingCount,
|
|
1990
|
-
this.backpressureConfig.maxPendingOps
|
|
1991
|
-
);
|
|
1992
|
-
case "drop-oldest":
|
|
1993
|
-
this.dropOldestOp();
|
|
1994
|
-
break;
|
|
1995
|
-
}
|
|
2912
|
+
isConnectionHealthy() {
|
|
2913
|
+
return this.webSocketManager.isConnectionHealthy();
|
|
1996
2914
|
}
|
|
2915
|
+
// ============ Backpressure Methods (delegated to BackpressureController) ============
|
|
1997
2916
|
/**
|
|
1998
|
-
*
|
|
2917
|
+
* Get the current number of pending (unsynced) operations.
|
|
2918
|
+
* Delegates to BackpressureController.
|
|
1999
2919
|
*/
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
const threshold = Math.floor(
|
|
2003
|
-
this.backpressureConfig.maxPendingOps * this.backpressureConfig.highWaterMark
|
|
2004
|
-
);
|
|
2005
|
-
if (pendingCount >= threshold && !this.highWaterMarkEmitted) {
|
|
2006
|
-
this.highWaterMarkEmitted = true;
|
|
2007
|
-
logger.warn(
|
|
2008
|
-
{ pending: pendingCount, max: this.backpressureConfig.maxPendingOps },
|
|
2009
|
-
"Backpressure high water mark reached"
|
|
2010
|
-
);
|
|
2011
|
-
this.emitBackpressureEvent("backpressure:high", {
|
|
2012
|
-
pending: pendingCount,
|
|
2013
|
-
max: this.backpressureConfig.maxPendingOps
|
|
2014
|
-
});
|
|
2015
|
-
}
|
|
2920
|
+
getPendingOpsCount() {
|
|
2921
|
+
return this.backpressureController.getPendingOpsCount();
|
|
2016
2922
|
}
|
|
2017
2923
|
/**
|
|
2018
|
-
*
|
|
2924
|
+
* Get the current backpressure status.
|
|
2925
|
+
* Delegates to BackpressureController.
|
|
2019
2926
|
*/
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
const lowThreshold = Math.floor(
|
|
2023
|
-
this.backpressureConfig.maxPendingOps * this.backpressureConfig.lowWaterMark
|
|
2024
|
-
);
|
|
2025
|
-
const highThreshold = Math.floor(
|
|
2026
|
-
this.backpressureConfig.maxPendingOps * this.backpressureConfig.highWaterMark
|
|
2027
|
-
);
|
|
2028
|
-
if (pendingCount < highThreshold && this.highWaterMarkEmitted) {
|
|
2029
|
-
this.highWaterMarkEmitted = false;
|
|
2030
|
-
}
|
|
2031
|
-
if (pendingCount <= lowThreshold) {
|
|
2032
|
-
if (this.backpressurePaused) {
|
|
2033
|
-
this.backpressurePaused = false;
|
|
2034
|
-
logger.info(
|
|
2035
|
-
{ pending: pendingCount, max: this.backpressureConfig.maxPendingOps },
|
|
2036
|
-
"Backpressure low water mark reached, resuming writes"
|
|
2037
|
-
);
|
|
2038
|
-
this.emitBackpressureEvent("backpressure:low", {
|
|
2039
|
-
pending: pendingCount,
|
|
2040
|
-
max: this.backpressureConfig.maxPendingOps
|
|
2041
|
-
});
|
|
2042
|
-
this.emitBackpressureEvent("backpressure:resumed");
|
|
2043
|
-
const waiting = this.waitingForCapacity;
|
|
2044
|
-
this.waitingForCapacity = [];
|
|
2045
|
-
for (const resolve of waiting) {
|
|
2046
|
-
resolve();
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2927
|
+
getBackpressureStatus() {
|
|
2928
|
+
return this.backpressureController.getBackpressureStatus();
|
|
2050
2929
|
}
|
|
2051
2930
|
/**
|
|
2052
|
-
*
|
|
2931
|
+
* Returns true if writes are currently paused due to backpressure.
|
|
2932
|
+
* Delegates to BackpressureController.
|
|
2053
2933
|
*/
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
this.backpressurePaused = true;
|
|
2057
|
-
logger.warn("Backpressure paused - waiting for capacity");
|
|
2058
|
-
this.emitBackpressureEvent("backpressure:paused");
|
|
2059
|
-
}
|
|
2060
|
-
return new Promise((resolve) => {
|
|
2061
|
-
this.waitingForCapacity.push(resolve);
|
|
2062
|
-
});
|
|
2934
|
+
isBackpressurePaused() {
|
|
2935
|
+
return this.backpressureController.isBackpressurePaused();
|
|
2063
2936
|
}
|
|
2064
2937
|
/**
|
|
2065
|
-
*
|
|
2938
|
+
* Subscribe to backpressure events.
|
|
2939
|
+
* Delegates to BackpressureController.
|
|
2940
|
+
* @param event Event name: 'backpressure:high', 'backpressure:low', 'backpressure:paused', 'backpressure:resumed', 'operation:dropped'
|
|
2941
|
+
* @param listener Callback function
|
|
2942
|
+
* @returns Unsubscribe function
|
|
2066
2943
|
*/
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
if (oldestIndex !== -1) {
|
|
2070
|
-
const dropped = this.opLog[oldestIndex];
|
|
2071
|
-
this.opLog.splice(oldestIndex, 1);
|
|
2072
|
-
logger.warn(
|
|
2073
|
-
{ opId: dropped.id, mapName: dropped.mapName, key: dropped.key },
|
|
2074
|
-
"Dropped oldest pending operation due to backpressure"
|
|
2075
|
-
);
|
|
2076
|
-
this.emitBackpressureEvent("operation:dropped", {
|
|
2077
|
-
opId: dropped.id,
|
|
2078
|
-
mapName: dropped.mapName,
|
|
2079
|
-
opType: dropped.opType,
|
|
2080
|
-
key: dropped.key
|
|
2081
|
-
});
|
|
2082
|
-
}
|
|
2944
|
+
onBackpressure(event, listener) {
|
|
2945
|
+
return this.backpressureController.onBackpressure(event, listener);
|
|
2083
2946
|
}
|
|
2084
2947
|
// ============================================
|
|
2085
|
-
// Write Concern Methods
|
|
2948
|
+
// Write Concern Methods
|
|
2086
2949
|
// ============================================
|
|
2087
2950
|
/**
|
|
2088
2951
|
* Register a pending Write Concern promise for an operation.
|
|
2089
|
-
*
|
|
2952
|
+
* Delegates to WriteConcernManager.
|
|
2090
2953
|
*
|
|
2091
2954
|
* @param opId - Operation ID
|
|
2092
2955
|
* @param timeout - Timeout in ms (default: 5000)
|
|
2093
2956
|
* @returns Promise that resolves with the Write Concern result
|
|
2094
2957
|
*/
|
|
2095
2958
|
registerWriteConcernPromise(opId, timeout = 5e3) {
|
|
2096
|
-
return
|
|
2097
|
-
const timeoutHandle = setTimeout(() => {
|
|
2098
|
-
this.pendingWriteConcernPromises.delete(opId);
|
|
2099
|
-
reject(new Error(`Write Concern timeout for operation ${opId}`));
|
|
2100
|
-
}, timeout);
|
|
2101
|
-
this.pendingWriteConcernPromises.set(opId, {
|
|
2102
|
-
resolve,
|
|
2103
|
-
reject,
|
|
2104
|
-
timeoutHandle
|
|
2105
|
-
});
|
|
2106
|
-
});
|
|
2107
|
-
}
|
|
2108
|
-
/**
|
|
2109
|
-
* Resolve a pending Write Concern promise with the server result.
|
|
2110
|
-
*
|
|
2111
|
-
* @param opId - Operation ID
|
|
2112
|
-
* @param result - Result from server ACK
|
|
2113
|
-
*/
|
|
2114
|
-
resolveWriteConcernPromise(opId, result) {
|
|
2115
|
-
const pending = this.pendingWriteConcernPromises.get(opId);
|
|
2116
|
-
if (pending) {
|
|
2117
|
-
if (pending.timeoutHandle) {
|
|
2118
|
-
clearTimeout(pending.timeoutHandle);
|
|
2119
|
-
}
|
|
2120
|
-
pending.resolve(result);
|
|
2121
|
-
this.pendingWriteConcernPromises.delete(opId);
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
/**
|
|
2125
|
-
* Cancel all pending Write Concern promises (e.g., on disconnect).
|
|
2126
|
-
*/
|
|
2127
|
-
cancelAllWriteConcernPromises(error) {
|
|
2128
|
-
for (const [opId, pending] of this.pendingWriteConcernPromises.entries()) {
|
|
2129
|
-
if (pending.timeoutHandle) {
|
|
2130
|
-
clearTimeout(pending.timeoutHandle);
|
|
2131
|
-
}
|
|
2132
|
-
pending.reject(error);
|
|
2133
|
-
}
|
|
2134
|
-
this.pendingWriteConcernPromises.clear();
|
|
2959
|
+
return this.writeConcernManager.registerWriteConcernPromise(opId, timeout);
|
|
2135
2960
|
}
|
|
2961
|
+
// ============================================
|
|
2962
|
+
// PN Counter Methods - Delegates to CounterManager
|
|
2963
|
+
// ============================================
|
|
2136
2964
|
/**
|
|
2137
2965
|
* Subscribe to counter updates from server.
|
|
2966
|
+
* Delegates to CounterManager.
|
|
2138
2967
|
* @param name Counter name
|
|
2139
2968
|
* @param listener Callback when counter state is updated
|
|
2140
2969
|
* @returns Unsubscribe function
|
|
2141
2970
|
*/
|
|
2142
2971
|
onCounterUpdate(name, listener) {
|
|
2143
|
-
|
|
2144
|
-
this.counterUpdateListeners.set(name, /* @__PURE__ */ new Set());
|
|
2145
|
-
}
|
|
2146
|
-
this.counterUpdateListeners.get(name).add(listener);
|
|
2147
|
-
return () => {
|
|
2148
|
-
this.counterUpdateListeners.get(name)?.delete(listener);
|
|
2149
|
-
if (this.counterUpdateListeners.get(name)?.size === 0) {
|
|
2150
|
-
this.counterUpdateListeners.delete(name);
|
|
2151
|
-
}
|
|
2152
|
-
};
|
|
2972
|
+
return this.counterManager.onCounterUpdate(name, listener);
|
|
2153
2973
|
}
|
|
2154
2974
|
/**
|
|
2155
2975
|
* Request initial counter state from server.
|
|
2156
|
-
*
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
}
|
|
2186
|
-
/**
|
|
2187
|
-
* Handle incoming counter update from server.
|
|
2188
|
-
* Called by handleServerMessage for COUNTER_UPDATE messages.
|
|
2189
|
-
*/
|
|
2190
|
-
handleCounterUpdate(name, stateObj) {
|
|
2191
|
-
const state = {
|
|
2192
|
-
positive: new Map(Object.entries(stateObj.positive)),
|
|
2193
|
-
negative: new Map(Object.entries(stateObj.negative))
|
|
2194
|
-
};
|
|
2195
|
-
const listeners = this.counterUpdateListeners.get(name);
|
|
2196
|
-
if (listeners) {
|
|
2197
|
-
for (const listener of listeners) {
|
|
2198
|
-
try {
|
|
2199
|
-
listener(state);
|
|
2200
|
-
} catch (e) {
|
|
2201
|
-
logger.error({ err: e, counterName: name }, "Counter update listener error");
|
|
2202
|
-
}
|
|
2203
|
-
}
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
/**
|
|
2207
|
-
* Execute an entry processor on a single key atomically.
|
|
2208
|
-
*
|
|
2209
|
-
* @param mapName Name of the map
|
|
2210
|
-
* @param key Key to process
|
|
2211
|
-
* @param processor Processor definition
|
|
2212
|
-
* @returns Promise resolving to the processor result
|
|
2213
|
-
*/
|
|
2214
|
-
async executeOnKey(mapName, key, processor) {
|
|
2215
|
-
if (!this.isAuthenticated()) {
|
|
2216
|
-
return {
|
|
2217
|
-
success: false,
|
|
2218
|
-
error: "Not connected to server"
|
|
2219
|
-
};
|
|
2220
|
-
}
|
|
2221
|
-
const requestId = crypto.randomUUID();
|
|
2222
|
-
return new Promise((resolve, reject) => {
|
|
2223
|
-
const timeout = setTimeout(() => {
|
|
2224
|
-
this.pendingProcessorRequests.delete(requestId);
|
|
2225
|
-
reject(new Error("Entry processor request timed out"));
|
|
2226
|
-
}, _SyncEngine.PROCESSOR_TIMEOUT);
|
|
2227
|
-
this.pendingProcessorRequests.set(requestId, {
|
|
2228
|
-
resolve: (result) => {
|
|
2229
|
-
clearTimeout(timeout);
|
|
2230
|
-
resolve(result);
|
|
2231
|
-
},
|
|
2232
|
-
reject,
|
|
2233
|
-
timeout
|
|
2234
|
-
});
|
|
2235
|
-
const sent = this.sendMessage({
|
|
2236
|
-
type: "ENTRY_PROCESS",
|
|
2237
|
-
requestId,
|
|
2238
|
-
mapName,
|
|
2239
|
-
key,
|
|
2240
|
-
processor: {
|
|
2241
|
-
name: processor.name,
|
|
2242
|
-
code: processor.code,
|
|
2243
|
-
args: processor.args
|
|
2244
|
-
}
|
|
2245
|
-
}, key);
|
|
2246
|
-
if (!sent) {
|
|
2247
|
-
this.pendingProcessorRequests.delete(requestId);
|
|
2248
|
-
clearTimeout(timeout);
|
|
2249
|
-
reject(new Error("Failed to send entry processor request"));
|
|
2250
|
-
}
|
|
2251
|
-
});
|
|
2976
|
+
* Delegates to CounterManager.
|
|
2977
|
+
* @param name Counter name
|
|
2978
|
+
*/
|
|
2979
|
+
requestCounter(name) {
|
|
2980
|
+
this.counterManager.requestCounter(name);
|
|
2981
|
+
}
|
|
2982
|
+
/**
|
|
2983
|
+
* Sync local counter state to server.
|
|
2984
|
+
* Delegates to CounterManager.
|
|
2985
|
+
* @param name Counter name
|
|
2986
|
+
* @param state Counter state to sync
|
|
2987
|
+
*/
|
|
2988
|
+
syncCounter(name, state) {
|
|
2989
|
+
this.counterManager.syncCounter(name, state);
|
|
2990
|
+
}
|
|
2991
|
+
// ============================================
|
|
2992
|
+
// Entry Processor Methods - Delegates to EntryProcessorClient
|
|
2993
|
+
// ============================================
|
|
2994
|
+
/**
|
|
2995
|
+
* Execute an entry processor on a single key atomically.
|
|
2996
|
+
* Delegates to EntryProcessorClient.
|
|
2997
|
+
*
|
|
2998
|
+
* @param mapName Name of the map
|
|
2999
|
+
* @param key Key to process
|
|
3000
|
+
* @param processor Processor definition
|
|
3001
|
+
* @returns Promise resolving to the processor result
|
|
3002
|
+
*/
|
|
3003
|
+
async executeOnKey(mapName, key, processor) {
|
|
3004
|
+
return this.entryProcessorClient.executeOnKey(mapName, key, processor);
|
|
2252
3005
|
}
|
|
2253
3006
|
/**
|
|
2254
3007
|
* Execute an entry processor on multiple keys.
|
|
3008
|
+
* Delegates to EntryProcessorClient.
|
|
2255
3009
|
*
|
|
2256
3010
|
* @param mapName Name of the map
|
|
2257
3011
|
* @param keys Keys to process
|
|
@@ -2259,84 +3013,7 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2259
3013
|
* @returns Promise resolving to a map of key -> result
|
|
2260
3014
|
*/
|
|
2261
3015
|
async executeOnKeys(mapName, keys, processor) {
|
|
2262
|
-
|
|
2263
|
-
const results = /* @__PURE__ */ new Map();
|
|
2264
|
-
const error = {
|
|
2265
|
-
success: false,
|
|
2266
|
-
error: "Not connected to server"
|
|
2267
|
-
};
|
|
2268
|
-
for (const key of keys) {
|
|
2269
|
-
results.set(key, error);
|
|
2270
|
-
}
|
|
2271
|
-
return results;
|
|
2272
|
-
}
|
|
2273
|
-
const requestId = crypto.randomUUID();
|
|
2274
|
-
return new Promise((resolve, reject) => {
|
|
2275
|
-
const timeout = setTimeout(() => {
|
|
2276
|
-
this.pendingBatchProcessorRequests.delete(requestId);
|
|
2277
|
-
reject(new Error("Entry processor batch request timed out"));
|
|
2278
|
-
}, _SyncEngine.PROCESSOR_TIMEOUT);
|
|
2279
|
-
this.pendingBatchProcessorRequests.set(requestId, {
|
|
2280
|
-
resolve: (results) => {
|
|
2281
|
-
clearTimeout(timeout);
|
|
2282
|
-
resolve(results);
|
|
2283
|
-
},
|
|
2284
|
-
reject,
|
|
2285
|
-
timeout
|
|
2286
|
-
});
|
|
2287
|
-
const sent = this.sendMessage({
|
|
2288
|
-
type: "ENTRY_PROCESS_BATCH",
|
|
2289
|
-
requestId,
|
|
2290
|
-
mapName,
|
|
2291
|
-
keys,
|
|
2292
|
-
processor: {
|
|
2293
|
-
name: processor.name,
|
|
2294
|
-
code: processor.code,
|
|
2295
|
-
args: processor.args
|
|
2296
|
-
}
|
|
2297
|
-
});
|
|
2298
|
-
if (!sent) {
|
|
2299
|
-
this.pendingBatchProcessorRequests.delete(requestId);
|
|
2300
|
-
clearTimeout(timeout);
|
|
2301
|
-
reject(new Error("Failed to send entry processor batch request"));
|
|
2302
|
-
}
|
|
2303
|
-
});
|
|
2304
|
-
}
|
|
2305
|
-
/**
|
|
2306
|
-
* Handle entry processor response from server.
|
|
2307
|
-
* Called by handleServerMessage for ENTRY_PROCESS_RESPONSE messages.
|
|
2308
|
-
*/
|
|
2309
|
-
handleEntryProcessResponse(message) {
|
|
2310
|
-
const pending = this.pendingProcessorRequests.get(message.requestId);
|
|
2311
|
-
if (pending) {
|
|
2312
|
-
this.pendingProcessorRequests.delete(message.requestId);
|
|
2313
|
-
pending.resolve({
|
|
2314
|
-
success: message.success,
|
|
2315
|
-
result: message.result,
|
|
2316
|
-
newValue: message.newValue,
|
|
2317
|
-
error: message.error
|
|
2318
|
-
});
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
/**
|
|
2322
|
-
* Handle entry processor batch response from server.
|
|
2323
|
-
* Called by handleServerMessage for ENTRY_PROCESS_BATCH_RESPONSE messages.
|
|
2324
|
-
*/
|
|
2325
|
-
handleEntryProcessBatchResponse(message) {
|
|
2326
|
-
const pending = this.pendingBatchProcessorRequests.get(message.requestId);
|
|
2327
|
-
if (pending) {
|
|
2328
|
-
this.pendingBatchProcessorRequests.delete(message.requestId);
|
|
2329
|
-
const resultsMap = /* @__PURE__ */ new Map();
|
|
2330
|
-
for (const [key, result] of Object.entries(message.results)) {
|
|
2331
|
-
resultsMap.set(key, {
|
|
2332
|
-
success: result.success,
|
|
2333
|
-
result: result.result,
|
|
2334
|
-
newValue: result.newValue,
|
|
2335
|
-
error: result.error
|
|
2336
|
-
});
|
|
2337
|
-
}
|
|
2338
|
-
pending.resolve(resultsMap);
|
|
2339
|
-
}
|
|
3016
|
+
return this.entryProcessorClient.executeOnKeys(mapName, keys, processor);
|
|
2340
3017
|
}
|
|
2341
3018
|
/**
|
|
2342
3019
|
* Subscribe to all incoming messages.
|
|
@@ -2383,8 +3060,12 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2383
3060
|
}
|
|
2384
3061
|
}
|
|
2385
3062
|
}
|
|
3063
|
+
// ============================================
|
|
3064
|
+
// Full-Text Search Methods - Delegates to SearchClient
|
|
3065
|
+
// ============================================
|
|
2386
3066
|
/**
|
|
2387
3067
|
* Perform a one-shot BM25 search on the server.
|
|
3068
|
+
* Delegates to SearchClient.
|
|
2388
3069
|
*
|
|
2389
3070
|
* @param mapName Name of the map to search
|
|
2390
3071
|
* @param query Search query text
|
|
@@ -2392,58 +3073,10 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2392
3073
|
* @returns Promise resolving to search results
|
|
2393
3074
|
*/
|
|
2394
3075
|
async search(mapName, query, options) {
|
|
2395
|
-
|
|
2396
|
-
throw new Error("Not connected to server");
|
|
2397
|
-
}
|
|
2398
|
-
const requestId = crypto.randomUUID();
|
|
2399
|
-
return new Promise((resolve, reject) => {
|
|
2400
|
-
const timeout = setTimeout(() => {
|
|
2401
|
-
this.pendingSearchRequests.delete(requestId);
|
|
2402
|
-
reject(new Error("Search request timed out"));
|
|
2403
|
-
}, _SyncEngine.SEARCH_TIMEOUT);
|
|
2404
|
-
this.pendingSearchRequests.set(requestId, {
|
|
2405
|
-
resolve: (results) => {
|
|
2406
|
-
clearTimeout(timeout);
|
|
2407
|
-
resolve(results);
|
|
2408
|
-
},
|
|
2409
|
-
reject: (error) => {
|
|
2410
|
-
clearTimeout(timeout);
|
|
2411
|
-
reject(error);
|
|
2412
|
-
},
|
|
2413
|
-
timeout
|
|
2414
|
-
});
|
|
2415
|
-
const sent = this.sendMessage({
|
|
2416
|
-
type: "SEARCH",
|
|
2417
|
-
payload: {
|
|
2418
|
-
requestId,
|
|
2419
|
-
mapName,
|
|
2420
|
-
query,
|
|
2421
|
-
options
|
|
2422
|
-
}
|
|
2423
|
-
});
|
|
2424
|
-
if (!sent) {
|
|
2425
|
-
this.pendingSearchRequests.delete(requestId);
|
|
2426
|
-
clearTimeout(timeout);
|
|
2427
|
-
reject(new Error("Failed to send search request"));
|
|
2428
|
-
}
|
|
2429
|
-
});
|
|
2430
|
-
}
|
|
2431
|
-
/**
|
|
2432
|
-
* Handle search response from server.
|
|
2433
|
-
*/
|
|
2434
|
-
handleSearchResponse(payload) {
|
|
2435
|
-
const pending = this.pendingSearchRequests.get(payload.requestId);
|
|
2436
|
-
if (pending) {
|
|
2437
|
-
this.pendingSearchRequests.delete(payload.requestId);
|
|
2438
|
-
if (payload.error) {
|
|
2439
|
-
pending.reject(new Error(payload.error));
|
|
2440
|
-
} else {
|
|
2441
|
-
pending.resolve(payload.results);
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
3076
|
+
return this.searchClient.search(mapName, query, options);
|
|
2444
3077
|
}
|
|
2445
3078
|
// ============================================
|
|
2446
|
-
// Conflict Resolver Client
|
|
3079
|
+
// Conflict Resolver Client
|
|
2447
3080
|
// ============================================
|
|
2448
3081
|
/**
|
|
2449
3082
|
* Get the conflict resolver client for registering custom resolvers
|
|
@@ -2452,132 +3085,35 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2452
3085
|
getConflictResolverClient() {
|
|
2453
3086
|
return this.conflictResolverClient;
|
|
2454
3087
|
}
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
type: "HYBRID_QUERY_UNSUBSCRIBE",
|
|
2479
|
-
payload: { subscriptionId: queryId }
|
|
2480
|
-
});
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
/**
|
|
2485
|
-
* Send hybrid query subscription to server.
|
|
2486
|
-
*/
|
|
2487
|
-
sendHybridQuerySubscription(queryId, mapName, filter) {
|
|
2488
|
-
this.sendMessage({
|
|
2489
|
-
type: "HYBRID_QUERY_SUBSCRIBE",
|
|
2490
|
-
payload: {
|
|
2491
|
-
subscriptionId: queryId,
|
|
2492
|
-
mapName,
|
|
2493
|
-
predicate: filter.predicate,
|
|
2494
|
-
where: filter.where,
|
|
2495
|
-
sort: filter.sort,
|
|
2496
|
-
limit: filter.limit,
|
|
2497
|
-
cursor: filter.cursor
|
|
2498
|
-
// Phase 14.1: replaces offset
|
|
2499
|
-
}
|
|
2500
|
-
});
|
|
2501
|
-
}
|
|
2502
|
-
/**
|
|
2503
|
-
* Run a local hybrid query (FTS + filter combination).
|
|
2504
|
-
* For FTS predicates, returns results with score = 0 (local-only mode).
|
|
2505
|
-
* Server provides actual FTS scoring.
|
|
2506
|
-
*/
|
|
2507
|
-
async runLocalHybridQuery(mapName, filter) {
|
|
2508
|
-
if (!this.storageAdapter) {
|
|
2509
|
-
return [];
|
|
2510
|
-
}
|
|
2511
|
-
const results = [];
|
|
2512
|
-
const allKeys = await this.storageAdapter.getAllKeys();
|
|
2513
|
-
const mapPrefix = `${mapName}:`;
|
|
2514
|
-
const entries = [];
|
|
2515
|
-
for (const fullKey of allKeys) {
|
|
2516
|
-
if (fullKey.startsWith(mapPrefix)) {
|
|
2517
|
-
const key = fullKey.substring(mapPrefix.length);
|
|
2518
|
-
const record = await this.storageAdapter.get(fullKey);
|
|
2519
|
-
if (record) {
|
|
2520
|
-
entries.push([key, record]);
|
|
2521
|
-
}
|
|
2522
|
-
}
|
|
2523
|
-
}
|
|
2524
|
-
for (const [key, record] of entries) {
|
|
2525
|
-
if (record === null || record.value === null) continue;
|
|
2526
|
-
const value = record.value;
|
|
2527
|
-
if (filter.predicate) {
|
|
2528
|
-
const matches = evaluatePredicate(filter.predicate, value);
|
|
2529
|
-
if (!matches) continue;
|
|
2530
|
-
}
|
|
2531
|
-
if (filter.where) {
|
|
2532
|
-
let whereMatches = true;
|
|
2533
|
-
for (const [field, expected] of Object.entries(filter.where)) {
|
|
2534
|
-
if (value[field] !== expected) {
|
|
2535
|
-
whereMatches = false;
|
|
2536
|
-
break;
|
|
2537
|
-
}
|
|
2538
|
-
}
|
|
2539
|
-
if (!whereMatches) continue;
|
|
2540
|
-
}
|
|
2541
|
-
results.push({
|
|
2542
|
-
key,
|
|
2543
|
-
value,
|
|
2544
|
-
score: 0,
|
|
2545
|
-
// Local doesn't have FTS scoring
|
|
2546
|
-
matchedTerms: []
|
|
2547
|
-
});
|
|
2548
|
-
}
|
|
2549
|
-
if (filter.sort) {
|
|
2550
|
-
results.sort((a, b) => {
|
|
2551
|
-
for (const [field, direction] of Object.entries(filter.sort)) {
|
|
2552
|
-
let valA;
|
|
2553
|
-
let valB;
|
|
2554
|
-
if (field === "_score") {
|
|
2555
|
-
valA = a.score ?? 0;
|
|
2556
|
-
valB = b.score ?? 0;
|
|
2557
|
-
} else if (field === "_key") {
|
|
2558
|
-
valA = a.key;
|
|
2559
|
-
valB = b.key;
|
|
2560
|
-
} else {
|
|
2561
|
-
valA = a.value[field];
|
|
2562
|
-
valB = b.value[field];
|
|
2563
|
-
}
|
|
2564
|
-
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
2565
|
-
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
2566
|
-
}
|
|
2567
|
-
return 0;
|
|
2568
|
-
});
|
|
2569
|
-
}
|
|
2570
|
-
let sliced = results;
|
|
2571
|
-
if (filter.limit) {
|
|
2572
|
-
sliced = sliced.slice(0, filter.limit);
|
|
2573
|
-
}
|
|
2574
|
-
return sliced;
|
|
3088
|
+
// ============================================
|
|
3089
|
+
// Hybrid Query Support - Delegates to QueryManager
|
|
3090
|
+
// ============================================
|
|
3091
|
+
/**
|
|
3092
|
+
* Subscribe to a hybrid query (FTS + filter combination).
|
|
3093
|
+
* Delegates to QueryManager.
|
|
3094
|
+
*/
|
|
3095
|
+
subscribeToHybridQuery(query) {
|
|
3096
|
+
this.queryManager.subscribeToHybridQuery(query);
|
|
3097
|
+
}
|
|
3098
|
+
/**
|
|
3099
|
+
* Unsubscribe from a hybrid query.
|
|
3100
|
+
* Delegates to QueryManager.
|
|
3101
|
+
*/
|
|
3102
|
+
unsubscribeFromHybridQuery(queryId) {
|
|
3103
|
+
this.queryManager.unsubscribeFromHybridQuery(queryId);
|
|
3104
|
+
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Run a local hybrid query (FTS + filter combination).
|
|
3107
|
+
* Delegates to QueryManager.
|
|
3108
|
+
*/
|
|
3109
|
+
async runLocalHybridQuery(mapName, filter) {
|
|
3110
|
+
return this.queryManager.runLocalHybridQuery(mapName, filter);
|
|
2575
3111
|
}
|
|
2576
3112
|
/**
|
|
2577
3113
|
* Handle hybrid query response from server.
|
|
2578
3114
|
*/
|
|
2579
3115
|
handleHybridQueryResponse(payload) {
|
|
2580
|
-
const query = this.
|
|
3116
|
+
const query = this.queryManager.getHybridQuery(payload.subscriptionId);
|
|
2581
3117
|
if (query) {
|
|
2582
3118
|
query.onResult(payload.results, "server");
|
|
2583
3119
|
query.updatePaginationInfo({
|
|
@@ -2591,7 +3127,7 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2591
3127
|
* Handle hybrid query delta update from server.
|
|
2592
3128
|
*/
|
|
2593
3129
|
handleHybridQueryDelta(payload) {
|
|
2594
|
-
const query = this.
|
|
3130
|
+
const query = this.queryManager.getHybridQuery(payload.subscriptionId);
|
|
2595
3131
|
if (query) {
|
|
2596
3132
|
if (payload.type === "LEAVE") {
|
|
2597
3133
|
query.onUpdate(payload.key, null);
|
|
@@ -2601,14 +3137,9 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2601
3137
|
}
|
|
2602
3138
|
}
|
|
2603
3139
|
};
|
|
2604
|
-
/** Default timeout for entry processor requests (ms) */
|
|
2605
|
-
_SyncEngine.PROCESSOR_TIMEOUT = 3e4;
|
|
2606
|
-
/** Default timeout for search requests (ms) */
|
|
2607
|
-
_SyncEngine.SEARCH_TIMEOUT = 3e4;
|
|
2608
|
-
var SyncEngine = _SyncEngine;
|
|
2609
3140
|
|
|
2610
3141
|
// src/TopGunClient.ts
|
|
2611
|
-
import { LWWMap as
|
|
3142
|
+
import { LWWMap as LWWMap3, ORMap as ORMap3 } from "@topgunbuild/core";
|
|
2612
3143
|
|
|
2613
3144
|
// src/utils/deepEqual.ts
|
|
2614
3145
|
function deepEqual(a, b) {
|
|
@@ -2703,11 +3234,11 @@ var QueryHandle = class {
|
|
|
2703
3234
|
constructor(syncEngine, mapName, filter = {}) {
|
|
2704
3235
|
this.listeners = /* @__PURE__ */ new Set();
|
|
2705
3236
|
this.currentResults = /* @__PURE__ */ new Map();
|
|
2706
|
-
// Change tracking
|
|
3237
|
+
// Change tracking for delta notifications
|
|
2707
3238
|
this.changeTracker = new ChangeTracker();
|
|
2708
3239
|
this.pendingChanges = [];
|
|
2709
3240
|
this.changeListeners = /* @__PURE__ */ new Set();
|
|
2710
|
-
// Pagination info
|
|
3241
|
+
// Pagination info
|
|
2711
3242
|
this._paginationInfo = { hasMore: false, cursorStatus: "none" };
|
|
2712
3243
|
this.paginationListeners = /* @__PURE__ */ new Set();
|
|
2713
3244
|
// Track if we've received authoritative server response
|
|
@@ -2804,7 +3335,7 @@ var QueryHandle = class {
|
|
|
2804
3335
|
this.notify();
|
|
2805
3336
|
}
|
|
2806
3337
|
/**
|
|
2807
|
-
* Subscribe to change events
|
|
3338
|
+
* Subscribe to change events.
|
|
2808
3339
|
* Returns an unsubscribe function.
|
|
2809
3340
|
*
|
|
2810
3341
|
* @example
|
|
@@ -2823,7 +3354,7 @@ var QueryHandle = class {
|
|
|
2823
3354
|
return () => this.changeListeners.delete(listener);
|
|
2824
3355
|
}
|
|
2825
3356
|
/**
|
|
2826
|
-
* Get and clear pending changes
|
|
3357
|
+
* Get and clear pending changes.
|
|
2827
3358
|
* Call this to retrieve all changes since the last consume.
|
|
2828
3359
|
*/
|
|
2829
3360
|
consumeChanges() {
|
|
@@ -2832,26 +3363,26 @@ var QueryHandle = class {
|
|
|
2832
3363
|
return changes;
|
|
2833
3364
|
}
|
|
2834
3365
|
/**
|
|
2835
|
-
* Get last change without consuming
|
|
3366
|
+
* Get last change without consuming.
|
|
2836
3367
|
* Returns null if no pending changes.
|
|
2837
3368
|
*/
|
|
2838
3369
|
getLastChange() {
|
|
2839
3370
|
return this.pendingChanges.length > 0 ? this.pendingChanges[this.pendingChanges.length - 1] : null;
|
|
2840
3371
|
}
|
|
2841
3372
|
/**
|
|
2842
|
-
* Get all pending changes without consuming
|
|
3373
|
+
* Get all pending changes without consuming.
|
|
2843
3374
|
*/
|
|
2844
3375
|
getPendingChanges() {
|
|
2845
3376
|
return [...this.pendingChanges];
|
|
2846
3377
|
}
|
|
2847
3378
|
/**
|
|
2848
|
-
* Clear all pending changes
|
|
3379
|
+
* Clear all pending changes.
|
|
2849
3380
|
*/
|
|
2850
3381
|
clearChanges() {
|
|
2851
3382
|
this.pendingChanges = [];
|
|
2852
3383
|
}
|
|
2853
3384
|
/**
|
|
2854
|
-
* Reset change tracker
|
|
3385
|
+
* Reset change tracker.
|
|
2855
3386
|
* Use when query filter changes or on reconnect.
|
|
2856
3387
|
*/
|
|
2857
3388
|
resetChangeTracker() {
|
|
@@ -2903,7 +3434,7 @@ var QueryHandle = class {
|
|
|
2903
3434
|
getMapName() {
|
|
2904
3435
|
return this.mapName;
|
|
2905
3436
|
}
|
|
2906
|
-
// ============== Pagination Methods
|
|
3437
|
+
// ============== Pagination Methods ==============
|
|
2907
3438
|
/**
|
|
2908
3439
|
* Get current pagination info.
|
|
2909
3440
|
* Returns nextCursor, hasMore, and cursorStatus.
|
|
@@ -3022,7 +3553,7 @@ var TopicHandle = class {
|
|
|
3022
3553
|
try {
|
|
3023
3554
|
cb(data, context);
|
|
3024
3555
|
} catch (e) {
|
|
3025
|
-
|
|
3556
|
+
logger.error({ err: e, topic: this.topic, context: "listener" }, "Error in topic listener");
|
|
3026
3557
|
}
|
|
3027
3558
|
});
|
|
3028
3559
|
}
|
|
@@ -3520,7 +4051,7 @@ var SearchHandle = class {
|
|
|
3520
4051
|
try {
|
|
3521
4052
|
listener(results);
|
|
3522
4053
|
} catch (err) {
|
|
3523
|
-
|
|
4054
|
+
logger.error({ err, mapName: this.mapName, context: "listener" }, "SearchHandle listener error");
|
|
3524
4055
|
}
|
|
3525
4056
|
}
|
|
3526
4057
|
}
|
|
@@ -3537,7 +4068,7 @@ var HybridQueryHandle = class {
|
|
|
3537
4068
|
this.changeListeners = /* @__PURE__ */ new Set();
|
|
3538
4069
|
// Track server data reception
|
|
3539
4070
|
this.hasReceivedServerData = false;
|
|
3540
|
-
// Pagination info
|
|
4071
|
+
// Pagination info
|
|
3541
4072
|
this._paginationInfo = { hasMore: false, cursorStatus: "none" };
|
|
3542
4073
|
this.paginationListeners = /* @__PURE__ */ new Set();
|
|
3543
4074
|
this.id = crypto.randomUUID();
|
|
@@ -3754,7 +4285,7 @@ var HybridQueryHandle = class {
|
|
|
3754
4285
|
}
|
|
3755
4286
|
return false;
|
|
3756
4287
|
}
|
|
3757
|
-
// ============== Pagination Methods
|
|
4288
|
+
// ============== Pagination Methods ==============
|
|
3758
4289
|
/**
|
|
3759
4290
|
* Get current pagination info.
|
|
3760
4291
|
* Returns nextCursor, hasMore, and cursorStatus.
|
|
@@ -3811,7 +4342,7 @@ import {
|
|
|
3811
4342
|
import {
|
|
3812
4343
|
DEFAULT_CONNECTION_POOL_CONFIG
|
|
3813
4344
|
} from "@topgunbuild/core";
|
|
3814
|
-
import { serialize as serialize2, deserialize as
|
|
4345
|
+
import { serialize as serialize2, deserialize as deserialize3 } from "@topgunbuild/core";
|
|
3815
4346
|
var ConnectionPool = class {
|
|
3816
4347
|
constructor(config = {}) {
|
|
3817
4348
|
this.listeners = /* @__PURE__ */ new Map();
|
|
@@ -4112,7 +4643,7 @@ var ConnectionPool = class {
|
|
|
4112
4643
|
let message;
|
|
4113
4644
|
try {
|
|
4114
4645
|
if (event.data instanceof ArrayBuffer) {
|
|
4115
|
-
message =
|
|
4646
|
+
message = deserialize3(new Uint8Array(event.data));
|
|
4116
4647
|
} else {
|
|
4117
4648
|
message = JSON.parse(event.data);
|
|
4118
4649
|
}
|
|
@@ -4904,16 +5435,6 @@ var ClusterClient = class {
|
|
|
4904
5435
|
setAuthToken(token) {
|
|
4905
5436
|
this.connectionPool.setAuthToken(token);
|
|
4906
5437
|
}
|
|
4907
|
-
/**
|
|
4908
|
-
* Send operation with automatic routing (legacy API for cluster operations).
|
|
4909
|
-
* @deprecated Use send(data, key) for IConnectionProvider interface
|
|
4910
|
-
*/
|
|
4911
|
-
sendMessage(key, message) {
|
|
4912
|
-
if (this.config.routingMode === "direct" && this.routingActive) {
|
|
4913
|
-
return this.sendDirect(key, message);
|
|
4914
|
-
}
|
|
4915
|
-
return this.sendForward(message);
|
|
4916
|
-
}
|
|
4917
5438
|
/**
|
|
4918
5439
|
* Send directly to partition owner
|
|
4919
5440
|
*/
|
|
@@ -5225,6 +5746,233 @@ var ClusterClient = class {
|
|
|
5225
5746
|
}
|
|
5226
5747
|
};
|
|
5227
5748
|
|
|
5749
|
+
// src/connection/SingleServerProvider.ts
|
|
5750
|
+
var DEFAULT_CONFIG = {
|
|
5751
|
+
maxReconnectAttempts: 10,
|
|
5752
|
+
reconnectDelayMs: 1e3,
|
|
5753
|
+
backoffMultiplier: 2,
|
|
5754
|
+
maxReconnectDelayMs: 3e4
|
|
5755
|
+
};
|
|
5756
|
+
var SingleServerProvider = class {
|
|
5757
|
+
constructor(config) {
|
|
5758
|
+
this.ws = null;
|
|
5759
|
+
this.reconnectAttempts = 0;
|
|
5760
|
+
this.reconnectTimer = null;
|
|
5761
|
+
this.isClosing = false;
|
|
5762
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
5763
|
+
this.url = config.url;
|
|
5764
|
+
this.config = {
|
|
5765
|
+
url: config.url,
|
|
5766
|
+
maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
|
|
5767
|
+
reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
|
|
5768
|
+
backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
|
|
5769
|
+
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
|
|
5770
|
+
};
|
|
5771
|
+
}
|
|
5772
|
+
/**
|
|
5773
|
+
* Connect to the WebSocket server.
|
|
5774
|
+
*/
|
|
5775
|
+
async connect() {
|
|
5776
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
5777
|
+
return;
|
|
5778
|
+
}
|
|
5779
|
+
this.isClosing = false;
|
|
5780
|
+
return new Promise((resolve, reject) => {
|
|
5781
|
+
try {
|
|
5782
|
+
this.ws = new WebSocket(this.url);
|
|
5783
|
+
this.ws.binaryType = "arraybuffer";
|
|
5784
|
+
this.ws.onopen = () => {
|
|
5785
|
+
this.reconnectAttempts = 0;
|
|
5786
|
+
logger.info({ url: this.url }, "SingleServerProvider connected");
|
|
5787
|
+
this.emit("connected", "default");
|
|
5788
|
+
resolve();
|
|
5789
|
+
};
|
|
5790
|
+
this.ws.onerror = (error) => {
|
|
5791
|
+
logger.error({ err: error, url: this.url }, "SingleServerProvider WebSocket error");
|
|
5792
|
+
this.emit("error", error);
|
|
5793
|
+
};
|
|
5794
|
+
this.ws.onclose = (event) => {
|
|
5795
|
+
logger.info({ url: this.url, code: event.code }, "SingleServerProvider disconnected");
|
|
5796
|
+
this.emit("disconnected", "default");
|
|
5797
|
+
if (!this.isClosing) {
|
|
5798
|
+
this.scheduleReconnect();
|
|
5799
|
+
}
|
|
5800
|
+
};
|
|
5801
|
+
this.ws.onmessage = (event) => {
|
|
5802
|
+
this.emit("message", "default", event.data);
|
|
5803
|
+
};
|
|
5804
|
+
const timeoutId = setTimeout(() => {
|
|
5805
|
+
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
5806
|
+
this.ws.close();
|
|
5807
|
+
reject(new Error(`Connection timeout to ${this.url}`));
|
|
5808
|
+
}
|
|
5809
|
+
}, this.config.reconnectDelayMs * 5);
|
|
5810
|
+
const originalOnOpen = this.ws.onopen;
|
|
5811
|
+
const wsRef = this.ws;
|
|
5812
|
+
this.ws.onopen = (ev) => {
|
|
5813
|
+
clearTimeout(timeoutId);
|
|
5814
|
+
if (originalOnOpen) {
|
|
5815
|
+
originalOnOpen.call(wsRef, ev);
|
|
5816
|
+
}
|
|
5817
|
+
};
|
|
5818
|
+
} catch (error) {
|
|
5819
|
+
reject(error);
|
|
5820
|
+
}
|
|
5821
|
+
});
|
|
5822
|
+
}
|
|
5823
|
+
/**
|
|
5824
|
+
* Get connection for a specific key.
|
|
5825
|
+
* In single-server mode, key is ignored.
|
|
5826
|
+
*/
|
|
5827
|
+
getConnection(_key) {
|
|
5828
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
5829
|
+
throw new Error("Not connected");
|
|
5830
|
+
}
|
|
5831
|
+
return this.ws;
|
|
5832
|
+
}
|
|
5833
|
+
/**
|
|
5834
|
+
* Get any available connection.
|
|
5835
|
+
*/
|
|
5836
|
+
getAnyConnection() {
|
|
5837
|
+
return this.getConnection("");
|
|
5838
|
+
}
|
|
5839
|
+
/**
|
|
5840
|
+
* Check if connected.
|
|
5841
|
+
*/
|
|
5842
|
+
isConnected() {
|
|
5843
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
5844
|
+
}
|
|
5845
|
+
/**
|
|
5846
|
+
* Get connected node IDs.
|
|
5847
|
+
* Single-server mode returns ['default'] when connected.
|
|
5848
|
+
*/
|
|
5849
|
+
getConnectedNodes() {
|
|
5850
|
+
return this.isConnected() ? ["default"] : [];
|
|
5851
|
+
}
|
|
5852
|
+
/**
|
|
5853
|
+
* Subscribe to connection events.
|
|
5854
|
+
*/
|
|
5855
|
+
on(event, handler2) {
|
|
5856
|
+
if (!this.listeners.has(event)) {
|
|
5857
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
5858
|
+
}
|
|
5859
|
+
this.listeners.get(event).add(handler2);
|
|
5860
|
+
}
|
|
5861
|
+
/**
|
|
5862
|
+
* Unsubscribe from connection events.
|
|
5863
|
+
*/
|
|
5864
|
+
off(event, handler2) {
|
|
5865
|
+
this.listeners.get(event)?.delete(handler2);
|
|
5866
|
+
}
|
|
5867
|
+
/**
|
|
5868
|
+
* Send data via the WebSocket connection.
|
|
5869
|
+
* In single-server mode, key parameter is ignored.
|
|
5870
|
+
*/
|
|
5871
|
+
send(data, _key) {
|
|
5872
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
5873
|
+
throw new Error("Not connected");
|
|
5874
|
+
}
|
|
5875
|
+
this.ws.send(data);
|
|
5876
|
+
}
|
|
5877
|
+
/**
|
|
5878
|
+
* Close the WebSocket connection.
|
|
5879
|
+
*/
|
|
5880
|
+
async close() {
|
|
5881
|
+
this.isClosing = true;
|
|
5882
|
+
if (this.reconnectTimer) {
|
|
5883
|
+
clearTimeout(this.reconnectTimer);
|
|
5884
|
+
this.reconnectTimer = null;
|
|
5885
|
+
}
|
|
5886
|
+
if (this.ws) {
|
|
5887
|
+
this.ws.onclose = null;
|
|
5888
|
+
this.ws.onerror = null;
|
|
5889
|
+
this.ws.onmessage = null;
|
|
5890
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
5891
|
+
this.ws.close();
|
|
5892
|
+
}
|
|
5893
|
+
this.ws = null;
|
|
5894
|
+
}
|
|
5895
|
+
logger.info({ url: this.url }, "SingleServerProvider closed");
|
|
5896
|
+
}
|
|
5897
|
+
/**
|
|
5898
|
+
* Emit an event to all listeners.
|
|
5899
|
+
*/
|
|
5900
|
+
emit(event, ...args) {
|
|
5901
|
+
const handlers = this.listeners.get(event);
|
|
5902
|
+
if (handlers) {
|
|
5903
|
+
for (const handler2 of handlers) {
|
|
5904
|
+
try {
|
|
5905
|
+
handler2(...args);
|
|
5906
|
+
} catch (err) {
|
|
5907
|
+
logger.error({ err, event }, "Error in SingleServerProvider event handler");
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
}
|
|
5911
|
+
}
|
|
5912
|
+
/**
|
|
5913
|
+
* Schedule a reconnection attempt with exponential backoff.
|
|
5914
|
+
*/
|
|
5915
|
+
scheduleReconnect() {
|
|
5916
|
+
if (this.reconnectTimer) {
|
|
5917
|
+
clearTimeout(this.reconnectTimer);
|
|
5918
|
+
this.reconnectTimer = null;
|
|
5919
|
+
}
|
|
5920
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
5921
|
+
logger.error(
|
|
5922
|
+
{ attempts: this.reconnectAttempts, url: this.url },
|
|
5923
|
+
"SingleServerProvider max reconnect attempts reached"
|
|
5924
|
+
);
|
|
5925
|
+
this.emit("error", new Error("Max reconnection attempts reached"));
|
|
5926
|
+
return;
|
|
5927
|
+
}
|
|
5928
|
+
const delay = this.calculateBackoffDelay();
|
|
5929
|
+
logger.info(
|
|
5930
|
+
{ delay, attempt: this.reconnectAttempts, url: this.url },
|
|
5931
|
+
`SingleServerProvider scheduling reconnect in ${delay}ms`
|
|
5932
|
+
);
|
|
5933
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
5934
|
+
this.reconnectTimer = null;
|
|
5935
|
+
this.reconnectAttempts++;
|
|
5936
|
+
try {
|
|
5937
|
+
await this.connect();
|
|
5938
|
+
this.emit("reconnected", "default");
|
|
5939
|
+
} catch (error) {
|
|
5940
|
+
logger.error({ err: error }, "SingleServerProvider reconnection failed");
|
|
5941
|
+
this.scheduleReconnect();
|
|
5942
|
+
}
|
|
5943
|
+
}, delay);
|
|
5944
|
+
}
|
|
5945
|
+
/**
|
|
5946
|
+
* Calculate backoff delay with exponential increase.
|
|
5947
|
+
*/
|
|
5948
|
+
calculateBackoffDelay() {
|
|
5949
|
+
const { reconnectDelayMs, backoffMultiplier, maxReconnectDelayMs } = this.config;
|
|
5950
|
+
let delay = reconnectDelayMs * Math.pow(backoffMultiplier, this.reconnectAttempts);
|
|
5951
|
+
delay = Math.min(delay, maxReconnectDelayMs);
|
|
5952
|
+
delay = delay * (0.5 + Math.random());
|
|
5953
|
+
return Math.floor(delay);
|
|
5954
|
+
}
|
|
5955
|
+
/**
|
|
5956
|
+
* Get the WebSocket URL this provider connects to.
|
|
5957
|
+
*/
|
|
5958
|
+
getUrl() {
|
|
5959
|
+
return this.url;
|
|
5960
|
+
}
|
|
5961
|
+
/**
|
|
5962
|
+
* Get current reconnection attempt count.
|
|
5963
|
+
*/
|
|
5964
|
+
getReconnectAttempts() {
|
|
5965
|
+
return this.reconnectAttempts;
|
|
5966
|
+
}
|
|
5967
|
+
/**
|
|
5968
|
+
* Reset reconnection counter.
|
|
5969
|
+
* Called externally after successful authentication.
|
|
5970
|
+
*/
|
|
5971
|
+
resetReconnectAttempts() {
|
|
5972
|
+
this.reconnectAttempts = 0;
|
|
5973
|
+
}
|
|
5974
|
+
};
|
|
5975
|
+
|
|
5228
5976
|
// src/TopGunClient.ts
|
|
5229
5977
|
var DEFAULT_CLUSTER_CONFIG = {
|
|
5230
5978
|
connectionsPerNode: 1,
|
|
@@ -5280,9 +6028,10 @@ var TopGunClient = class {
|
|
|
5280
6028
|
});
|
|
5281
6029
|
logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
|
|
5282
6030
|
} else {
|
|
6031
|
+
const singleServerProvider = new SingleServerProvider({ url: config.serverUrl });
|
|
5283
6032
|
this.syncEngine = new SyncEngine({
|
|
5284
6033
|
nodeId: this.nodeId,
|
|
5285
|
-
|
|
6034
|
+
connectionProvider: singleServerProvider,
|
|
5286
6035
|
storageAdapter: this.storageAdapter,
|
|
5287
6036
|
backoff: config.backoff,
|
|
5288
6037
|
backpressure: config.backpressure
|
|
@@ -5358,12 +6107,12 @@ var TopGunClient = class {
|
|
|
5358
6107
|
getMap(name) {
|
|
5359
6108
|
if (this.maps.has(name)) {
|
|
5360
6109
|
const map = this.maps.get(name);
|
|
5361
|
-
if (map instanceof
|
|
6110
|
+
if (map instanceof LWWMap3) {
|
|
5362
6111
|
return map;
|
|
5363
6112
|
}
|
|
5364
6113
|
throw new Error(`Map ${name} exists but is not an LWWMap`);
|
|
5365
6114
|
}
|
|
5366
|
-
const lwwMap = new
|
|
6115
|
+
const lwwMap = new LWWMap3(this.syncEngine.getHLC());
|
|
5367
6116
|
this.maps.set(name, lwwMap);
|
|
5368
6117
|
this.syncEngine.registerMap(name, lwwMap);
|
|
5369
6118
|
this.storageAdapter.getAllKeys().then(async (keys) => {
|
|
@@ -5402,12 +6151,12 @@ var TopGunClient = class {
|
|
|
5402
6151
|
getORMap(name) {
|
|
5403
6152
|
if (this.maps.has(name)) {
|
|
5404
6153
|
const map = this.maps.get(name);
|
|
5405
|
-
if (map instanceof
|
|
6154
|
+
if (map instanceof ORMap3) {
|
|
5406
6155
|
return map;
|
|
5407
6156
|
}
|
|
5408
6157
|
throw new Error(`Map ${name} exists but is not an ORMap`);
|
|
5409
6158
|
}
|
|
5410
|
-
const orMap = new
|
|
6159
|
+
const orMap = new ORMap3(this.syncEngine.getHLC());
|
|
5411
6160
|
this.maps.set(name, orMap);
|
|
5412
6161
|
this.syncEngine.registerMap(name, orMap);
|
|
5413
6162
|
this.restoreORMap(name, orMap);
|
|
@@ -5623,7 +6372,7 @@ var TopGunClient = class {
|
|
|
5623
6372
|
return this.syncEngine.onBackpressure(event, listener);
|
|
5624
6373
|
}
|
|
5625
6374
|
// ============================================
|
|
5626
|
-
// Full-Text Search API
|
|
6375
|
+
// Full-Text Search API
|
|
5627
6376
|
// ============================================
|
|
5628
6377
|
/**
|
|
5629
6378
|
* Perform a one-shot BM25 search on the server.
|
|
@@ -5653,7 +6402,7 @@ var TopGunClient = class {
|
|
|
5653
6402
|
return this.syncEngine.search(mapName, query, options);
|
|
5654
6403
|
}
|
|
5655
6404
|
// ============================================
|
|
5656
|
-
// Live Search API
|
|
6405
|
+
// Live Search API
|
|
5657
6406
|
// ============================================
|
|
5658
6407
|
/**
|
|
5659
6408
|
* Subscribe to live search results with real-time updates.
|
|
@@ -5693,7 +6442,7 @@ var TopGunClient = class {
|
|
|
5693
6442
|
return new SearchHandle(this.syncEngine, mapName, query, options);
|
|
5694
6443
|
}
|
|
5695
6444
|
// ============================================
|
|
5696
|
-
// Hybrid Query API
|
|
6445
|
+
// Hybrid Query API
|
|
5697
6446
|
// ============================================
|
|
5698
6447
|
/**
|
|
5699
6448
|
* Create a hybrid query combining FTS with traditional filters.
|
|
@@ -5730,7 +6479,7 @@ var TopGunClient = class {
|
|
|
5730
6479
|
return new HybridQueryHandle(this.syncEngine, mapName, filter);
|
|
5731
6480
|
}
|
|
5732
6481
|
// ============================================
|
|
5733
|
-
// Entry Processor API
|
|
6482
|
+
// Entry Processor API
|
|
5734
6483
|
// ============================================
|
|
5735
6484
|
/**
|
|
5736
6485
|
* Execute an entry processor on a single key atomically.
|
|
@@ -5767,7 +6516,7 @@ var TopGunClient = class {
|
|
|
5767
6516
|
const result = await this.syncEngine.executeOnKey(mapName, key, processor);
|
|
5768
6517
|
if (result.success && result.newValue !== void 0) {
|
|
5769
6518
|
const map = this.maps.get(mapName);
|
|
5770
|
-
if (map instanceof
|
|
6519
|
+
if (map instanceof LWWMap3) {
|
|
5771
6520
|
map.set(key, result.newValue);
|
|
5772
6521
|
}
|
|
5773
6522
|
}
|
|
@@ -5804,7 +6553,7 @@ var TopGunClient = class {
|
|
|
5804
6553
|
async executeOnKeys(mapName, keys, processor) {
|
|
5805
6554
|
const results = await this.syncEngine.executeOnKeys(mapName, keys, processor);
|
|
5806
6555
|
const map = this.maps.get(mapName);
|
|
5807
|
-
if (map instanceof
|
|
6556
|
+
if (map instanceof LWWMap3) {
|
|
5808
6557
|
for (const [key, result] of results) {
|
|
5809
6558
|
if (result.success && result.newValue !== void 0) {
|
|
5810
6559
|
map.set(key, result.newValue);
|
|
@@ -5851,7 +6600,7 @@ var TopGunClient = class {
|
|
|
5851
6600
|
return this.journalReader;
|
|
5852
6601
|
}
|
|
5853
6602
|
// ============================================
|
|
5854
|
-
// Conflict Resolver API
|
|
6603
|
+
// Conflict Resolver API
|
|
5855
6604
|
// ============================================
|
|
5856
6605
|
/**
|
|
5857
6606
|
* Get the conflict resolver client for registering custom merge resolvers.
|
|
@@ -6110,7 +6859,7 @@ var TopGun = class {
|
|
|
6110
6859
|
nodeId: config.nodeId
|
|
6111
6860
|
});
|
|
6112
6861
|
this.initPromise = this.client.start().catch((err) => {
|
|
6113
|
-
|
|
6862
|
+
logger.error({ err, context: "client_start" }, "Failed to start TopGun client");
|
|
6114
6863
|
throw err;
|
|
6115
6864
|
});
|
|
6116
6865
|
return new Proxy(this, handler);
|
|
@@ -6165,7 +6914,7 @@ var CollectionWrapper = class {
|
|
|
6165
6914
|
};
|
|
6166
6915
|
|
|
6167
6916
|
// src/crypto/EncryptionManager.ts
|
|
6168
|
-
import { serialize as serialize4, deserialize as
|
|
6917
|
+
import { serialize as serialize4, deserialize as deserialize4 } from "@topgunbuild/core";
|
|
6169
6918
|
var _EncryptionManager = class _EncryptionManager {
|
|
6170
6919
|
/**
|
|
6171
6920
|
* Encrypts data using AES-GCM.
|
|
@@ -6201,9 +6950,9 @@ var _EncryptionManager = class _EncryptionManager {
|
|
|
6201
6950
|
key,
|
|
6202
6951
|
record.data
|
|
6203
6952
|
);
|
|
6204
|
-
return
|
|
6953
|
+
return deserialize4(new Uint8Array(plaintextBuffer));
|
|
6205
6954
|
} catch (err) {
|
|
6206
|
-
|
|
6955
|
+
logger.error({ err, context: "decryption" }, "Decryption failed");
|
|
6207
6956
|
throw new Error("Failed to decrypt data: " + err);
|
|
6208
6957
|
}
|
|
6209
6958
|
}
|
|
@@ -6325,7 +7074,7 @@ var EncryptedStorageAdapter = class {
|
|
|
6325
7074
|
};
|
|
6326
7075
|
|
|
6327
7076
|
// src/index.ts
|
|
6328
|
-
import { LWWMap as
|
|
7077
|
+
import { LWWMap as LWWMap4, Predicates } from "@topgunbuild/core";
|
|
6329
7078
|
export {
|
|
6330
7079
|
BackpressureError,
|
|
6331
7080
|
ChangeTracker,
|
|
@@ -6338,7 +7087,7 @@ export {
|
|
|
6338
7087
|
EventJournalReader,
|
|
6339
7088
|
HybridQueryHandle,
|
|
6340
7089
|
IDBAdapter,
|
|
6341
|
-
|
|
7090
|
+
LWWMap4 as LWWMap,
|
|
6342
7091
|
PNCounterHandle,
|
|
6343
7092
|
PartitionRouter,
|
|
6344
7093
|
Predicates,
|