@topgunbuild/client 0.2.0 → 0.3.0
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 +840 -10
- package/dist/index.d.ts +840 -10
- package/dist/index.js +2258 -187
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2257 -180
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -31,12 +31,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
BackpressureError: () => BackpressureError,
|
|
34
|
+
ClusterClient: () => ClusterClient,
|
|
35
|
+
ConnectionPool: () => ConnectionPool,
|
|
34
36
|
DEFAULT_BACKPRESSURE_CONFIG: () => DEFAULT_BACKPRESSURE_CONFIG,
|
|
37
|
+
DEFAULT_CLUSTER_CONFIG: () => DEFAULT_CLUSTER_CONFIG,
|
|
35
38
|
EncryptedStorageAdapter: () => EncryptedStorageAdapter,
|
|
36
39
|
IDBAdapter: () => IDBAdapter,
|
|
37
|
-
LWWMap: () =>
|
|
38
|
-
|
|
40
|
+
LWWMap: () => import_core8.LWWMap,
|
|
41
|
+
PartitionRouter: () => PartitionRouter,
|
|
42
|
+
Predicates: () => import_core8.Predicates,
|
|
39
43
|
QueryHandle: () => QueryHandle,
|
|
44
|
+
SingleServerProvider: () => SingleServerProvider,
|
|
40
45
|
SyncEngine: () => SyncEngine,
|
|
41
46
|
SyncState: () => SyncState,
|
|
42
47
|
SyncStateMachine: () => SyncStateMachine,
|
|
@@ -254,6 +259,233 @@ var DEFAULT_BACKPRESSURE_CONFIG = {
|
|
|
254
259
|
lowWaterMark: 0.5
|
|
255
260
|
};
|
|
256
261
|
|
|
262
|
+
// src/connection/SingleServerProvider.ts
|
|
263
|
+
var DEFAULT_CONFIG = {
|
|
264
|
+
maxReconnectAttempts: 10,
|
|
265
|
+
reconnectDelayMs: 1e3,
|
|
266
|
+
backoffMultiplier: 2,
|
|
267
|
+
maxReconnectDelayMs: 3e4
|
|
268
|
+
};
|
|
269
|
+
var SingleServerProvider = class {
|
|
270
|
+
constructor(config) {
|
|
271
|
+
this.ws = null;
|
|
272
|
+
this.reconnectAttempts = 0;
|
|
273
|
+
this.reconnectTimer = null;
|
|
274
|
+
this.isClosing = false;
|
|
275
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
276
|
+
this.url = config.url;
|
|
277
|
+
this.config = {
|
|
278
|
+
url: config.url,
|
|
279
|
+
maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
|
|
280
|
+
reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
|
|
281
|
+
backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
|
|
282
|
+
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Connect to the WebSocket server.
|
|
287
|
+
*/
|
|
288
|
+
async connect() {
|
|
289
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
this.isClosing = false;
|
|
293
|
+
return new Promise((resolve, reject) => {
|
|
294
|
+
try {
|
|
295
|
+
this.ws = new WebSocket(this.url);
|
|
296
|
+
this.ws.binaryType = "arraybuffer";
|
|
297
|
+
this.ws.onopen = () => {
|
|
298
|
+
this.reconnectAttempts = 0;
|
|
299
|
+
logger.info({ url: this.url }, "SingleServerProvider connected");
|
|
300
|
+
this.emit("connected", "default");
|
|
301
|
+
resolve();
|
|
302
|
+
};
|
|
303
|
+
this.ws.onerror = (error) => {
|
|
304
|
+
logger.error({ err: error, url: this.url }, "SingleServerProvider WebSocket error");
|
|
305
|
+
this.emit("error", error);
|
|
306
|
+
};
|
|
307
|
+
this.ws.onclose = (event) => {
|
|
308
|
+
logger.info({ url: this.url, code: event.code }, "SingleServerProvider disconnected");
|
|
309
|
+
this.emit("disconnected", "default");
|
|
310
|
+
if (!this.isClosing) {
|
|
311
|
+
this.scheduleReconnect();
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
this.ws.onmessage = (event) => {
|
|
315
|
+
this.emit("message", "default", event.data);
|
|
316
|
+
};
|
|
317
|
+
const timeoutId = setTimeout(() => {
|
|
318
|
+
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
319
|
+
this.ws.close();
|
|
320
|
+
reject(new Error(`Connection timeout to ${this.url}`));
|
|
321
|
+
}
|
|
322
|
+
}, this.config.reconnectDelayMs * 5);
|
|
323
|
+
const originalOnOpen = this.ws.onopen;
|
|
324
|
+
const wsRef = this.ws;
|
|
325
|
+
this.ws.onopen = (ev) => {
|
|
326
|
+
clearTimeout(timeoutId);
|
|
327
|
+
if (originalOnOpen) {
|
|
328
|
+
originalOnOpen.call(wsRef, ev);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
} catch (error) {
|
|
332
|
+
reject(error);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Get connection for a specific key.
|
|
338
|
+
* In single-server mode, key is ignored.
|
|
339
|
+
*/
|
|
340
|
+
getConnection(_key) {
|
|
341
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
342
|
+
throw new Error("Not connected");
|
|
343
|
+
}
|
|
344
|
+
return this.ws;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Get any available connection.
|
|
348
|
+
*/
|
|
349
|
+
getAnyConnection() {
|
|
350
|
+
return this.getConnection("");
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Check if connected.
|
|
354
|
+
*/
|
|
355
|
+
isConnected() {
|
|
356
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Get connected node IDs.
|
|
360
|
+
* Single-server mode returns ['default'] when connected.
|
|
361
|
+
*/
|
|
362
|
+
getConnectedNodes() {
|
|
363
|
+
return this.isConnected() ? ["default"] : [];
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Subscribe to connection events.
|
|
367
|
+
*/
|
|
368
|
+
on(event, handler2) {
|
|
369
|
+
if (!this.listeners.has(event)) {
|
|
370
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
371
|
+
}
|
|
372
|
+
this.listeners.get(event).add(handler2);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Unsubscribe from connection events.
|
|
376
|
+
*/
|
|
377
|
+
off(event, handler2) {
|
|
378
|
+
this.listeners.get(event)?.delete(handler2);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Send data via the WebSocket connection.
|
|
382
|
+
* In single-server mode, key parameter is ignored.
|
|
383
|
+
*/
|
|
384
|
+
send(data, _key) {
|
|
385
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
386
|
+
throw new Error("Not connected");
|
|
387
|
+
}
|
|
388
|
+
this.ws.send(data);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Close the WebSocket connection.
|
|
392
|
+
*/
|
|
393
|
+
async close() {
|
|
394
|
+
this.isClosing = true;
|
|
395
|
+
if (this.reconnectTimer) {
|
|
396
|
+
clearTimeout(this.reconnectTimer);
|
|
397
|
+
this.reconnectTimer = null;
|
|
398
|
+
}
|
|
399
|
+
if (this.ws) {
|
|
400
|
+
this.ws.onclose = null;
|
|
401
|
+
this.ws.onerror = null;
|
|
402
|
+
this.ws.onmessage = null;
|
|
403
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
404
|
+
this.ws.close();
|
|
405
|
+
}
|
|
406
|
+
this.ws = null;
|
|
407
|
+
}
|
|
408
|
+
logger.info({ url: this.url }, "SingleServerProvider closed");
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Emit an event to all listeners.
|
|
412
|
+
*/
|
|
413
|
+
emit(event, ...args) {
|
|
414
|
+
const handlers = this.listeners.get(event);
|
|
415
|
+
if (handlers) {
|
|
416
|
+
for (const handler2 of handlers) {
|
|
417
|
+
try {
|
|
418
|
+
handler2(...args);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
logger.error({ err, event }, "Error in SingleServerProvider event handler");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Schedule a reconnection attempt with exponential backoff.
|
|
427
|
+
*/
|
|
428
|
+
scheduleReconnect() {
|
|
429
|
+
if (this.reconnectTimer) {
|
|
430
|
+
clearTimeout(this.reconnectTimer);
|
|
431
|
+
this.reconnectTimer = null;
|
|
432
|
+
}
|
|
433
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
434
|
+
logger.error(
|
|
435
|
+
{ attempts: this.reconnectAttempts, url: this.url },
|
|
436
|
+
"SingleServerProvider max reconnect attempts reached"
|
|
437
|
+
);
|
|
438
|
+
this.emit("error", new Error("Max reconnection attempts reached"));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const delay = this.calculateBackoffDelay();
|
|
442
|
+
logger.info(
|
|
443
|
+
{ delay, attempt: this.reconnectAttempts, url: this.url },
|
|
444
|
+
`SingleServerProvider scheduling reconnect in ${delay}ms`
|
|
445
|
+
);
|
|
446
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
447
|
+
this.reconnectTimer = null;
|
|
448
|
+
this.reconnectAttempts++;
|
|
449
|
+
try {
|
|
450
|
+
await this.connect();
|
|
451
|
+
this.emit("reconnected", "default");
|
|
452
|
+
} catch (error) {
|
|
453
|
+
logger.error({ err: error }, "SingleServerProvider reconnection failed");
|
|
454
|
+
this.scheduleReconnect();
|
|
455
|
+
}
|
|
456
|
+
}, delay);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Calculate backoff delay with exponential increase.
|
|
460
|
+
*/
|
|
461
|
+
calculateBackoffDelay() {
|
|
462
|
+
const { reconnectDelayMs, backoffMultiplier, maxReconnectDelayMs } = this.config;
|
|
463
|
+
let delay = reconnectDelayMs * Math.pow(backoffMultiplier, this.reconnectAttempts);
|
|
464
|
+
delay = Math.min(delay, maxReconnectDelayMs);
|
|
465
|
+
delay = delay * (0.5 + Math.random());
|
|
466
|
+
return Math.floor(delay);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Get the WebSocket URL this provider connects to.
|
|
470
|
+
*/
|
|
471
|
+
getUrl() {
|
|
472
|
+
return this.url;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Get current reconnection attempt count.
|
|
476
|
+
*/
|
|
477
|
+
getReconnectAttempts() {
|
|
478
|
+
return this.reconnectAttempts;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Reset reconnection counter.
|
|
482
|
+
* Called externally after successful authentication.
|
|
483
|
+
*/
|
|
484
|
+
resetReconnectAttempts() {
|
|
485
|
+
this.reconnectAttempts = 0;
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
257
489
|
// src/SyncEngine.ts
|
|
258
490
|
var DEFAULT_BACKOFF_CONFIG = {
|
|
259
491
|
initialDelayMs: 1e3,
|
|
@@ -283,8 +515,13 @@ var SyncEngine = class {
|
|
|
283
515
|
this.waitingForCapacity = [];
|
|
284
516
|
this.highWaterMarkEmitted = false;
|
|
285
517
|
this.backpressureListeners = /* @__PURE__ */ new Map();
|
|
518
|
+
// Write Concern state (Phase 5.01)
|
|
519
|
+
this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
|
|
520
|
+
if (!config.serverUrl && !config.connectionProvider) {
|
|
521
|
+
throw new Error("SyncEngine requires either serverUrl or connectionProvider");
|
|
522
|
+
}
|
|
286
523
|
this.nodeId = config.nodeId;
|
|
287
|
-
this.serverUrl = config.serverUrl;
|
|
524
|
+
this.serverUrl = config.serverUrl || "";
|
|
288
525
|
this.storageAdapter = config.storageAdapter;
|
|
289
526
|
this.hlc = new import_core.HLC(this.nodeId);
|
|
290
527
|
this.stateMachine = new SyncStateMachine();
|
|
@@ -301,7 +538,15 @@ var SyncEngine = class {
|
|
|
301
538
|
...DEFAULT_BACKPRESSURE_CONFIG,
|
|
302
539
|
...config.backpressure
|
|
303
540
|
};
|
|
304
|
-
|
|
541
|
+
if (config.connectionProvider) {
|
|
542
|
+
this.connectionProvider = config.connectionProvider;
|
|
543
|
+
this.useConnectionProvider = true;
|
|
544
|
+
this.initConnectionProvider();
|
|
545
|
+
} else {
|
|
546
|
+
this.connectionProvider = new SingleServerProvider({ url: config.serverUrl });
|
|
547
|
+
this.useConnectionProvider = false;
|
|
548
|
+
this.initConnection();
|
|
549
|
+
}
|
|
305
550
|
this.loadOpLog();
|
|
306
551
|
}
|
|
307
552
|
// ============================================
|
|
@@ -352,6 +597,65 @@ var SyncEngine = class {
|
|
|
352
597
|
// ============================================
|
|
353
598
|
// Connection Management
|
|
354
599
|
// ============================================
|
|
600
|
+
/**
|
|
601
|
+
* Initialize connection using IConnectionProvider (Phase 4.5 cluster mode).
|
|
602
|
+
* Sets up event handlers for the connection provider.
|
|
603
|
+
*/
|
|
604
|
+
initConnectionProvider() {
|
|
605
|
+
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
606
|
+
this.connectionProvider.on("connected", (_nodeId) => {
|
|
607
|
+
if (this.authToken || this.tokenProvider) {
|
|
608
|
+
logger.info("ConnectionProvider connected. Sending auth...");
|
|
609
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
610
|
+
this.sendAuth();
|
|
611
|
+
} else {
|
|
612
|
+
logger.info("ConnectionProvider connected. Waiting for auth token...");
|
|
613
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
this.connectionProvider.on("disconnected", (_nodeId) => {
|
|
617
|
+
logger.info("ConnectionProvider disconnected.");
|
|
618
|
+
this.stopHeartbeat();
|
|
619
|
+
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
620
|
+
});
|
|
621
|
+
this.connectionProvider.on("reconnected", (_nodeId) => {
|
|
622
|
+
logger.info("ConnectionProvider reconnected.");
|
|
623
|
+
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
624
|
+
if (this.authToken || this.tokenProvider) {
|
|
625
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
626
|
+
this.sendAuth();
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
this.connectionProvider.on("message", (_nodeId, data) => {
|
|
630
|
+
let message;
|
|
631
|
+
if (data instanceof ArrayBuffer) {
|
|
632
|
+
message = (0, import_core.deserialize)(new Uint8Array(data));
|
|
633
|
+
} else if (data instanceof Uint8Array) {
|
|
634
|
+
message = (0, import_core.deserialize)(data);
|
|
635
|
+
} else {
|
|
636
|
+
try {
|
|
637
|
+
message = typeof data === "string" ? JSON.parse(data) : data;
|
|
638
|
+
} catch (e) {
|
|
639
|
+
logger.error({ err: e }, "Failed to parse message from ConnectionProvider");
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
this.handleServerMessage(message);
|
|
644
|
+
});
|
|
645
|
+
this.connectionProvider.on("partitionMapUpdated", () => {
|
|
646
|
+
logger.debug("Partition map updated");
|
|
647
|
+
});
|
|
648
|
+
this.connectionProvider.on("error", (error) => {
|
|
649
|
+
logger.error({ err: error }, "ConnectionProvider error");
|
|
650
|
+
});
|
|
651
|
+
this.connectionProvider.connect().catch((err) => {
|
|
652
|
+
logger.error({ err }, "Failed to connect via ConnectionProvider");
|
|
653
|
+
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Initialize connection using direct WebSocket (legacy single-server mode).
|
|
658
|
+
*/
|
|
355
659
|
initConnection() {
|
|
356
660
|
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
357
661
|
this.websocket = new WebSocket(this.serverUrl);
|
|
@@ -427,6 +731,40 @@ var SyncEngine = class {
|
|
|
427
731
|
resetBackoff() {
|
|
428
732
|
this.backoffAttempt = 0;
|
|
429
733
|
}
|
|
734
|
+
/**
|
|
735
|
+
* Send a message through the current connection.
|
|
736
|
+
* Uses connectionProvider if in cluster mode, otherwise uses direct websocket.
|
|
737
|
+
* @param message Message object to serialize and send
|
|
738
|
+
* @param key Optional key for routing (cluster mode only)
|
|
739
|
+
* @returns true if message was sent, false otherwise
|
|
740
|
+
*/
|
|
741
|
+
sendMessage(message, key) {
|
|
742
|
+
const data = (0, import_core.serialize)(message);
|
|
743
|
+
if (this.useConnectionProvider) {
|
|
744
|
+
try {
|
|
745
|
+
this.connectionProvider.send(data, key);
|
|
746
|
+
return true;
|
|
747
|
+
} catch (err) {
|
|
748
|
+
logger.warn({ err }, "Failed to send via ConnectionProvider");
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
} else {
|
|
752
|
+
if (this.websocket?.readyState === WebSocket.OPEN) {
|
|
753
|
+
this.websocket.send(data);
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Check if we can send messages (connection is ready).
|
|
761
|
+
*/
|
|
762
|
+
canSend() {
|
|
763
|
+
if (this.useConnectionProvider) {
|
|
764
|
+
return this.connectionProvider.isConnected();
|
|
765
|
+
}
|
|
766
|
+
return this.websocket?.readyState === WebSocket.OPEN;
|
|
767
|
+
}
|
|
430
768
|
async loadOpLog() {
|
|
431
769
|
const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
|
|
432
770
|
if (storedTimestamp) {
|
|
@@ -473,36 +811,34 @@ var SyncEngine = class {
|
|
|
473
811
|
const pending = this.opLog.filter((op) => !op.synced);
|
|
474
812
|
if (pending.length === 0) return;
|
|
475
813
|
logger.info({ count: pending.length }, "Syncing pending operations");
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}));
|
|
483
|
-
}
|
|
814
|
+
this.sendMessage({
|
|
815
|
+
type: "OP_BATCH",
|
|
816
|
+
payload: {
|
|
817
|
+
ops: pending
|
|
818
|
+
}
|
|
819
|
+
});
|
|
484
820
|
}
|
|
485
821
|
startMerkleSync() {
|
|
486
822
|
for (const [mapName, map] of this.maps) {
|
|
487
823
|
if (map instanceof import_core.LWWMap) {
|
|
488
824
|
logger.info({ mapName }, "Starting Merkle sync for LWWMap");
|
|
489
|
-
this.
|
|
825
|
+
this.sendMessage({
|
|
490
826
|
type: "SYNC_INIT",
|
|
491
827
|
mapName,
|
|
492
828
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
493
|
-
})
|
|
829
|
+
});
|
|
494
830
|
} else if (map instanceof import_core.ORMap) {
|
|
495
831
|
logger.info({ mapName }, "Starting Merkle sync for ORMap");
|
|
496
832
|
const tree = map.getMerkleTree();
|
|
497
833
|
const rootHash = tree.getRootHash();
|
|
498
834
|
const bucketHashes = tree.getBuckets("");
|
|
499
|
-
this.
|
|
835
|
+
this.sendMessage({
|
|
500
836
|
type: "ORMAP_SYNC_INIT",
|
|
501
837
|
mapName,
|
|
502
838
|
rootHash,
|
|
503
839
|
bucketHashes,
|
|
504
840
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
505
|
-
})
|
|
841
|
+
});
|
|
506
842
|
}
|
|
507
843
|
}
|
|
508
844
|
}
|
|
@@ -543,10 +879,10 @@ var SyncEngine = class {
|
|
|
543
879
|
}
|
|
544
880
|
const token = this.authToken;
|
|
545
881
|
if (!token) return;
|
|
546
|
-
this.
|
|
882
|
+
this.sendMessage({
|
|
547
883
|
type: "AUTH",
|
|
548
884
|
token
|
|
549
|
-
})
|
|
885
|
+
});
|
|
550
886
|
}
|
|
551
887
|
subscribeToQuery(query) {
|
|
552
888
|
this.queries.set(query.id, query);
|
|
@@ -563,27 +899,27 @@ var SyncEngine = class {
|
|
|
563
899
|
unsubscribeFromTopic(topic) {
|
|
564
900
|
this.topics.delete(topic);
|
|
565
901
|
if (this.isAuthenticated()) {
|
|
566
|
-
this.
|
|
902
|
+
this.sendMessage({
|
|
567
903
|
type: "TOPIC_UNSUB",
|
|
568
904
|
payload: { topic }
|
|
569
|
-
})
|
|
905
|
+
});
|
|
570
906
|
}
|
|
571
907
|
}
|
|
572
908
|
publishTopic(topic, data) {
|
|
573
909
|
if (this.isAuthenticated()) {
|
|
574
|
-
this.
|
|
910
|
+
this.sendMessage({
|
|
575
911
|
type: "TOPIC_PUB",
|
|
576
912
|
payload: { topic, data }
|
|
577
|
-
})
|
|
913
|
+
});
|
|
578
914
|
} else {
|
|
579
915
|
logger.warn({ topic }, "Dropped topic publish (offline)");
|
|
580
916
|
}
|
|
581
917
|
}
|
|
582
918
|
sendTopicSubscription(topic) {
|
|
583
|
-
this.
|
|
919
|
+
this.sendMessage({
|
|
584
920
|
type: "TOPIC_SUB",
|
|
585
921
|
payload: { topic }
|
|
586
|
-
})
|
|
922
|
+
});
|
|
587
923
|
}
|
|
588
924
|
/**
|
|
589
925
|
* Executes a query against local storage immediately
|
|
@@ -620,21 +956,21 @@ var SyncEngine = class {
|
|
|
620
956
|
unsubscribeFromQuery(queryId) {
|
|
621
957
|
this.queries.delete(queryId);
|
|
622
958
|
if (this.isAuthenticated()) {
|
|
623
|
-
this.
|
|
959
|
+
this.sendMessage({
|
|
624
960
|
type: "QUERY_UNSUB",
|
|
625
961
|
payload: { queryId }
|
|
626
|
-
})
|
|
962
|
+
});
|
|
627
963
|
}
|
|
628
964
|
}
|
|
629
965
|
sendQuerySubscription(query) {
|
|
630
|
-
this.
|
|
966
|
+
this.sendMessage({
|
|
631
967
|
type: "QUERY_SUB",
|
|
632
968
|
payload: {
|
|
633
969
|
queryId: query.id,
|
|
634
970
|
mapName: query.getMapName(),
|
|
635
971
|
query: query.getFilter()
|
|
636
972
|
}
|
|
637
|
-
})
|
|
973
|
+
});
|
|
638
974
|
}
|
|
639
975
|
requestLock(name, requestId, ttl) {
|
|
640
976
|
if (!this.isAuthenticated()) {
|
|
@@ -649,10 +985,15 @@ var SyncEngine = class {
|
|
|
649
985
|
}, 3e4);
|
|
650
986
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
651
987
|
try {
|
|
652
|
-
this.
|
|
988
|
+
const sent = this.sendMessage({
|
|
653
989
|
type: "LOCK_REQUEST",
|
|
654
990
|
payload: { requestId, name, ttl }
|
|
655
|
-
})
|
|
991
|
+
});
|
|
992
|
+
if (!sent) {
|
|
993
|
+
clearTimeout(timer);
|
|
994
|
+
this.pendingLockRequests.delete(requestId);
|
|
995
|
+
reject(new Error("Failed to send lock request"));
|
|
996
|
+
}
|
|
656
997
|
} catch (e) {
|
|
657
998
|
clearTimeout(timer);
|
|
658
999
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -671,10 +1012,15 @@ var SyncEngine = class {
|
|
|
671
1012
|
}, 5e3);
|
|
672
1013
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
673
1014
|
try {
|
|
674
|
-
this.
|
|
1015
|
+
const sent = this.sendMessage({
|
|
675
1016
|
type: "LOCK_RELEASE",
|
|
676
1017
|
payload: { requestId, name, fencingToken }
|
|
677
|
-
})
|
|
1018
|
+
});
|
|
1019
|
+
if (!sent) {
|
|
1020
|
+
clearTimeout(timer);
|
|
1021
|
+
this.pendingLockRequests.delete(requestId);
|
|
1022
|
+
resolve(false);
|
|
1023
|
+
}
|
|
678
1024
|
} catch (e) {
|
|
679
1025
|
clearTimeout(timer);
|
|
680
1026
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -684,6 +1030,22 @@ var SyncEngine = class {
|
|
|
684
1030
|
}
|
|
685
1031
|
async handleServerMessage(message) {
|
|
686
1032
|
switch (message.type) {
|
|
1033
|
+
case "BATCH": {
|
|
1034
|
+
const batchData = message.data;
|
|
1035
|
+
const view = new DataView(batchData.buffer, batchData.byteOffset, batchData.byteLength);
|
|
1036
|
+
let offset = 0;
|
|
1037
|
+
const count = view.getUint32(offset, true);
|
|
1038
|
+
offset += 4;
|
|
1039
|
+
for (let i = 0; i < count; i++) {
|
|
1040
|
+
const msgLen = view.getUint32(offset, true);
|
|
1041
|
+
offset += 4;
|
|
1042
|
+
const msgData = batchData.slice(offset, offset + msgLen);
|
|
1043
|
+
offset += msgLen;
|
|
1044
|
+
const innerMsg = (0, import_core.deserialize)(msgData);
|
|
1045
|
+
await this.handleServerMessage(innerMsg);
|
|
1046
|
+
}
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
687
1049
|
case "AUTH_REQUIRED":
|
|
688
1050
|
this.sendAuth();
|
|
689
1051
|
break;
|
|
@@ -715,8 +1077,18 @@ var SyncEngine = class {
|
|
|
715
1077
|
this.authToken = null;
|
|
716
1078
|
break;
|
|
717
1079
|
case "OP_ACK": {
|
|
718
|
-
const { lastId } = message.payload;
|
|
719
|
-
logger.info({ lastId }, "Received ACK for ops");
|
|
1080
|
+
const { lastId, achievedLevel, results } = message.payload;
|
|
1081
|
+
logger.info({ lastId, achievedLevel, hasResults: !!results }, "Received ACK for ops");
|
|
1082
|
+
if (results && Array.isArray(results)) {
|
|
1083
|
+
for (const result of results) {
|
|
1084
|
+
const op = this.opLog.find((o) => o.id === result.opId);
|
|
1085
|
+
if (op && !op.synced) {
|
|
1086
|
+
op.synced = true;
|
|
1087
|
+
logger.debug({ opId: result.opId, achievedLevel: result.achievedLevel, success: result.success }, "Op ACK with Write Concern");
|
|
1088
|
+
}
|
|
1089
|
+
this.resolveWriteConcernPromise(result.opId, result);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
720
1092
|
let maxSyncedId = -1;
|
|
721
1093
|
let ackedCount = 0;
|
|
722
1094
|
this.opLog.forEach((op) => {
|
|
@@ -777,18 +1149,20 @@ var SyncEngine = class {
|
|
|
777
1149
|
}
|
|
778
1150
|
case "SERVER_EVENT": {
|
|
779
1151
|
const { mapName, eventType, key, record, orRecord, orTag } = message.payload;
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1152
|
+
await this.applyServerEvent(mapName, eventType, key, record, orRecord, orTag);
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
case "SERVER_BATCH_EVENT": {
|
|
1156
|
+
const { events } = message.payload;
|
|
1157
|
+
for (const event of events) {
|
|
1158
|
+
await this.applyServerEvent(
|
|
1159
|
+
event.mapName,
|
|
1160
|
+
event.eventType,
|
|
1161
|
+
event.key,
|
|
1162
|
+
event.record,
|
|
1163
|
+
event.orRecord,
|
|
1164
|
+
event.orTag
|
|
1165
|
+
);
|
|
792
1166
|
}
|
|
793
1167
|
break;
|
|
794
1168
|
}
|
|
@@ -825,11 +1199,11 @@ var SyncEngine = class {
|
|
|
825
1199
|
const { mapName } = message.payload;
|
|
826
1200
|
logger.warn({ mapName }, "Sync Reset Required due to GC Age");
|
|
827
1201
|
await this.resetMap(mapName);
|
|
828
|
-
this.
|
|
1202
|
+
this.sendMessage({
|
|
829
1203
|
type: "SYNC_INIT",
|
|
830
1204
|
mapName,
|
|
831
1205
|
lastSyncTimestamp: 0
|
|
832
|
-
})
|
|
1206
|
+
});
|
|
833
1207
|
break;
|
|
834
1208
|
}
|
|
835
1209
|
case "SYNC_RESP_ROOT": {
|
|
@@ -839,10 +1213,10 @@ var SyncEngine = class {
|
|
|
839
1213
|
const localRootHash = map.getMerkleTree().getRootHash();
|
|
840
1214
|
if (localRootHash !== rootHash) {
|
|
841
1215
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
|
|
842
|
-
this.
|
|
1216
|
+
this.sendMessage({
|
|
843
1217
|
type: "MERKLE_REQ_BUCKET",
|
|
844
1218
|
payload: { mapName, path: "" }
|
|
845
|
-
})
|
|
1219
|
+
});
|
|
846
1220
|
} else {
|
|
847
1221
|
logger.info({ mapName }, "Map is in sync");
|
|
848
1222
|
}
|
|
@@ -864,10 +1238,10 @@ var SyncEngine = class {
|
|
|
864
1238
|
const localHash = localBuckets[bucketKey] || 0;
|
|
865
1239
|
if (localHash !== remoteHash) {
|
|
866
1240
|
const newPath = path + bucketKey;
|
|
867
|
-
this.
|
|
1241
|
+
this.sendMessage({
|
|
868
1242
|
type: "MERKLE_REQ_BUCKET",
|
|
869
1243
|
payload: { mapName, path: newPath }
|
|
870
|
-
})
|
|
1244
|
+
});
|
|
871
1245
|
}
|
|
872
1246
|
}
|
|
873
1247
|
}
|
|
@@ -900,10 +1274,10 @@ var SyncEngine = class {
|
|
|
900
1274
|
const localRootHash = localTree.getRootHash();
|
|
901
1275
|
if (localRootHash !== rootHash) {
|
|
902
1276
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
|
|
903
|
-
this.
|
|
1277
|
+
this.sendMessage({
|
|
904
1278
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
905
1279
|
payload: { mapName, path: "" }
|
|
906
|
-
})
|
|
1280
|
+
});
|
|
907
1281
|
} else {
|
|
908
1282
|
logger.info({ mapName }, "ORMap is in sync");
|
|
909
1283
|
}
|
|
@@ -925,10 +1299,10 @@ var SyncEngine = class {
|
|
|
925
1299
|
const localHash = localBuckets[bucketKey] || 0;
|
|
926
1300
|
if (localHash !== remoteHash) {
|
|
927
1301
|
const newPath = path + bucketKey;
|
|
928
|
-
this.
|
|
1302
|
+
this.sendMessage({
|
|
929
1303
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
930
1304
|
payload: { mapName, path: newPath }
|
|
931
|
-
})
|
|
1305
|
+
});
|
|
932
1306
|
}
|
|
933
1307
|
}
|
|
934
1308
|
for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
|
|
@@ -991,6 +1365,25 @@ var SyncEngine = class {
|
|
|
991
1365
|
getHLC() {
|
|
992
1366
|
return this.hlc;
|
|
993
1367
|
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Helper method to apply a single server event to the local map.
|
|
1370
|
+
* Used by both SERVER_EVENT and SERVER_BATCH_EVENT handlers.
|
|
1371
|
+
*/
|
|
1372
|
+
async applyServerEvent(mapName, eventType, key, record, orRecord, orTag) {
|
|
1373
|
+
const localMap = this.maps.get(mapName);
|
|
1374
|
+
if (localMap) {
|
|
1375
|
+
if (localMap instanceof import_core.LWWMap && record) {
|
|
1376
|
+
localMap.merge(key, record);
|
|
1377
|
+
await this.storageAdapter.put(`${mapName}:${key}`, record);
|
|
1378
|
+
} else if (localMap instanceof import_core.ORMap) {
|
|
1379
|
+
if (eventType === "OR_ADD" && orRecord) {
|
|
1380
|
+
localMap.apply(key, orRecord);
|
|
1381
|
+
} else if (eventType === "OR_REMOVE" && orTag) {
|
|
1382
|
+
localMap.applyTombstone(orTag);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
994
1387
|
/**
|
|
995
1388
|
* Closes the WebSocket connection and cleans up resources.
|
|
996
1389
|
*/
|
|
@@ -1000,11 +1393,16 @@ var SyncEngine = class {
|
|
|
1000
1393
|
clearTimeout(this.reconnectTimer);
|
|
1001
1394
|
this.reconnectTimer = null;
|
|
1002
1395
|
}
|
|
1003
|
-
if (this.
|
|
1396
|
+
if (this.useConnectionProvider) {
|
|
1397
|
+
this.connectionProvider.close().catch((err) => {
|
|
1398
|
+
logger.error({ err }, "Error closing ConnectionProvider");
|
|
1399
|
+
});
|
|
1400
|
+
} else if (this.websocket) {
|
|
1004
1401
|
this.websocket.onclose = null;
|
|
1005
1402
|
this.websocket.close();
|
|
1006
1403
|
this.websocket = null;
|
|
1007
1404
|
}
|
|
1405
|
+
this.cancelAllWriteConcernPromises(new Error("SyncEngine closed"));
|
|
1008
1406
|
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
1009
1407
|
logger.info("SyncEngine closed");
|
|
1010
1408
|
}
|
|
@@ -1016,7 +1414,100 @@ var SyncEngine = class {
|
|
|
1016
1414
|
this.close();
|
|
1017
1415
|
this.stateMachine.reset();
|
|
1018
1416
|
this.resetBackoff();
|
|
1019
|
-
this.
|
|
1417
|
+
if (this.useConnectionProvider) {
|
|
1418
|
+
this.initConnectionProvider();
|
|
1419
|
+
} else {
|
|
1420
|
+
this.initConnection();
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
// ============================================
|
|
1424
|
+
// Failover Support Methods (Phase 4.5 Task 05)
|
|
1425
|
+
// ============================================
|
|
1426
|
+
/**
|
|
1427
|
+
* Wait for a partition map update from the connection provider.
|
|
1428
|
+
* Used when an operation fails with NOT_OWNER error and needs
|
|
1429
|
+
* to wait for an updated partition map before retrying.
|
|
1430
|
+
*
|
|
1431
|
+
* @param timeoutMs - Maximum time to wait (default: 5000ms)
|
|
1432
|
+
* @returns Promise that resolves when partition map is updated or times out
|
|
1433
|
+
*/
|
|
1434
|
+
waitForPartitionMapUpdate(timeoutMs = 5e3) {
|
|
1435
|
+
return new Promise((resolve) => {
|
|
1436
|
+
const timeout = setTimeout(resolve, timeoutMs);
|
|
1437
|
+
const handler2 = () => {
|
|
1438
|
+
clearTimeout(timeout);
|
|
1439
|
+
this.connectionProvider.off("partitionMapUpdated", handler2);
|
|
1440
|
+
resolve();
|
|
1441
|
+
};
|
|
1442
|
+
this.connectionProvider.on("partitionMapUpdated", handler2);
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Wait for the connection to be available.
|
|
1447
|
+
* Used when an operation fails due to connection issues and needs
|
|
1448
|
+
* to wait for reconnection before retrying.
|
|
1449
|
+
*
|
|
1450
|
+
* @param timeoutMs - Maximum time to wait (default: 10000ms)
|
|
1451
|
+
* @returns Promise that resolves when connected or rejects on timeout
|
|
1452
|
+
*/
|
|
1453
|
+
waitForConnection(timeoutMs = 1e4) {
|
|
1454
|
+
return new Promise((resolve, reject) => {
|
|
1455
|
+
if (this.connectionProvider.isConnected()) {
|
|
1456
|
+
resolve();
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
const timeout = setTimeout(() => {
|
|
1460
|
+
this.connectionProvider.off("connected", handler2);
|
|
1461
|
+
reject(new Error("Connection timeout waiting for reconnection"));
|
|
1462
|
+
}, timeoutMs);
|
|
1463
|
+
const handler2 = () => {
|
|
1464
|
+
clearTimeout(timeout);
|
|
1465
|
+
this.connectionProvider.off("connected", handler2);
|
|
1466
|
+
resolve();
|
|
1467
|
+
};
|
|
1468
|
+
this.connectionProvider.on("connected", handler2);
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Wait for a specific sync state.
|
|
1473
|
+
* Useful for waiting until fully connected and synced.
|
|
1474
|
+
*
|
|
1475
|
+
* @param targetState - The state to wait for
|
|
1476
|
+
* @param timeoutMs - Maximum time to wait (default: 30000ms)
|
|
1477
|
+
* @returns Promise that resolves when state is reached or rejects on timeout
|
|
1478
|
+
*/
|
|
1479
|
+
waitForState(targetState, timeoutMs = 3e4) {
|
|
1480
|
+
return new Promise((resolve, reject) => {
|
|
1481
|
+
if (this.stateMachine.getState() === targetState) {
|
|
1482
|
+
resolve();
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const timeout = setTimeout(() => {
|
|
1486
|
+
unsubscribe();
|
|
1487
|
+
reject(new Error(`Timeout waiting for state ${targetState}`));
|
|
1488
|
+
}, timeoutMs);
|
|
1489
|
+
const unsubscribe = this.stateMachine.onStateChange((event) => {
|
|
1490
|
+
if (event.to === targetState) {
|
|
1491
|
+
clearTimeout(timeout);
|
|
1492
|
+
unsubscribe();
|
|
1493
|
+
resolve();
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Check if the connection provider is connected.
|
|
1500
|
+
* Convenience method for failover logic.
|
|
1501
|
+
*/
|
|
1502
|
+
isProviderConnected() {
|
|
1503
|
+
return this.connectionProvider.isConnected();
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Get the connection provider for direct access.
|
|
1507
|
+
* Use with caution - prefer using SyncEngine methods.
|
|
1508
|
+
*/
|
|
1509
|
+
getConnectionProvider() {
|
|
1510
|
+
return this.connectionProvider;
|
|
1020
1511
|
}
|
|
1021
1512
|
async resetMap(mapName) {
|
|
1022
1513
|
const map = this.maps.get(mapName);
|
|
@@ -1064,12 +1555,12 @@ var SyncEngine = class {
|
|
|
1064
1555
|
* Sends a PING message to the server.
|
|
1065
1556
|
*/
|
|
1066
1557
|
sendPing() {
|
|
1067
|
-
if (this.
|
|
1558
|
+
if (this.canSend()) {
|
|
1068
1559
|
const pingMessage = {
|
|
1069
1560
|
type: "PING",
|
|
1070
1561
|
timestamp: Date.now()
|
|
1071
1562
|
};
|
|
1072
|
-
this.
|
|
1563
|
+
this.sendMessage(pingMessage);
|
|
1073
1564
|
}
|
|
1074
1565
|
}
|
|
1075
1566
|
/**
|
|
@@ -1148,13 +1639,13 @@ var SyncEngine = class {
|
|
|
1148
1639
|
}
|
|
1149
1640
|
}
|
|
1150
1641
|
if (entries.length > 0) {
|
|
1151
|
-
this.
|
|
1642
|
+
this.sendMessage({
|
|
1152
1643
|
type: "ORMAP_PUSH_DIFF",
|
|
1153
1644
|
payload: {
|
|
1154
1645
|
mapName,
|
|
1155
1646
|
entries
|
|
1156
1647
|
}
|
|
1157
|
-
})
|
|
1648
|
+
});
|
|
1158
1649
|
logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
|
|
1159
1650
|
}
|
|
1160
1651
|
}
|
|
@@ -1325,21 +1816,73 @@ var SyncEngine = class {
|
|
|
1325
1816
|
});
|
|
1326
1817
|
}
|
|
1327
1818
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
//
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1819
|
+
// ============================================
|
|
1820
|
+
// Write Concern Methods (Phase 5.01)
|
|
1821
|
+
// ============================================
|
|
1822
|
+
/**
|
|
1823
|
+
* Register a pending Write Concern promise for an operation.
|
|
1824
|
+
* The promise will be resolved when the server sends an ACK with the operation result.
|
|
1825
|
+
*
|
|
1826
|
+
* @param opId - Operation ID
|
|
1827
|
+
* @param timeout - Timeout in ms (default: 5000)
|
|
1828
|
+
* @returns Promise that resolves with the Write Concern result
|
|
1829
|
+
*/
|
|
1830
|
+
registerWriteConcernPromise(opId, timeout = 5e3) {
|
|
1831
|
+
return new Promise((resolve, reject) => {
|
|
1832
|
+
const timeoutHandle = setTimeout(() => {
|
|
1833
|
+
this.pendingWriteConcernPromises.delete(opId);
|
|
1834
|
+
reject(new Error(`Write Concern timeout for operation ${opId}`));
|
|
1835
|
+
}, timeout);
|
|
1836
|
+
this.pendingWriteConcernPromises.set(opId, {
|
|
1837
|
+
resolve,
|
|
1838
|
+
reject,
|
|
1839
|
+
timeoutHandle
|
|
1840
|
+
});
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Resolve a pending Write Concern promise with the server result.
|
|
1845
|
+
*
|
|
1846
|
+
* @param opId - Operation ID
|
|
1847
|
+
* @param result - Result from server ACK
|
|
1848
|
+
*/
|
|
1849
|
+
resolveWriteConcernPromise(opId, result) {
|
|
1850
|
+
const pending = this.pendingWriteConcernPromises.get(opId);
|
|
1851
|
+
if (pending) {
|
|
1852
|
+
if (pending.timeoutHandle) {
|
|
1853
|
+
clearTimeout(pending.timeoutHandle);
|
|
1854
|
+
}
|
|
1855
|
+
pending.resolve(result);
|
|
1856
|
+
this.pendingWriteConcernPromises.delete(opId);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Cancel all pending Write Concern promises (e.g., on disconnect).
|
|
1861
|
+
*/
|
|
1862
|
+
cancelAllWriteConcernPromises(error) {
|
|
1863
|
+
for (const [opId, pending] of this.pendingWriteConcernPromises.entries()) {
|
|
1864
|
+
if (pending.timeoutHandle) {
|
|
1865
|
+
clearTimeout(pending.timeoutHandle);
|
|
1866
|
+
}
|
|
1867
|
+
pending.reject(error);
|
|
1868
|
+
}
|
|
1869
|
+
this.pendingWriteConcernPromises.clear();
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
// src/TopGunClient.ts
|
|
1874
|
+
var import_core6 = require("@topgunbuild/core");
|
|
1875
|
+
|
|
1876
|
+
// src/QueryHandle.ts
|
|
1877
|
+
var QueryHandle = class {
|
|
1878
|
+
constructor(syncEngine, mapName, filter = {}) {
|
|
1879
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1880
|
+
this.currentResults = /* @__PURE__ */ new Map();
|
|
1881
|
+
// Track if we've received authoritative server response
|
|
1882
|
+
this.hasReceivedServerData = false;
|
|
1883
|
+
this.id = crypto.randomUUID();
|
|
1884
|
+
this.syncEngine = syncEngine;
|
|
1885
|
+
this.mapName = mapName;
|
|
1343
1886
|
this.filter = filter;
|
|
1344
1887
|
}
|
|
1345
1888
|
subscribe(callback) {
|
|
@@ -1398,152 +1941,1615 @@ var QueryHandle = class {
|
|
|
1398
1941
|
this.currentResults.delete(key);
|
|
1399
1942
|
}
|
|
1400
1943
|
}
|
|
1401
|
-
if (removedKeys.length > 0) {
|
|
1402
|
-
console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
|
|
1403
|
-
}
|
|
1404
|
-
for (const item of items) {
|
|
1405
|
-
this.currentResults.set(item.key, item.value);
|
|
1944
|
+
if (removedKeys.length > 0) {
|
|
1945
|
+
console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
|
|
1946
|
+
}
|
|
1947
|
+
for (const item of items) {
|
|
1948
|
+
this.currentResults.set(item.key, item.value);
|
|
1949
|
+
}
|
|
1950
|
+
console.log(`[QueryHandle:${this.mapName}] After merge: ${this.currentResults.size} results`);
|
|
1951
|
+
this.notify();
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Called by SyncEngine when server sends a live update
|
|
1955
|
+
*/
|
|
1956
|
+
onUpdate(key, value) {
|
|
1957
|
+
if (value === null) {
|
|
1958
|
+
this.currentResults.delete(key);
|
|
1959
|
+
} else {
|
|
1960
|
+
this.currentResults.set(key, value);
|
|
1961
|
+
}
|
|
1962
|
+
this.notify();
|
|
1963
|
+
}
|
|
1964
|
+
notify() {
|
|
1965
|
+
const results = this.getSortedResults();
|
|
1966
|
+
for (const listener of this.listeners) {
|
|
1967
|
+
listener(results);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
getSortedResults() {
|
|
1971
|
+
const results = Array.from(this.currentResults.entries()).map(
|
|
1972
|
+
([key, value]) => ({ ...value, _key: key })
|
|
1973
|
+
);
|
|
1974
|
+
if (this.filter.sort) {
|
|
1975
|
+
results.sort((a, b) => {
|
|
1976
|
+
for (const [field, direction] of Object.entries(this.filter.sort)) {
|
|
1977
|
+
const valA = a[field];
|
|
1978
|
+
const valB = b[field];
|
|
1979
|
+
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
1980
|
+
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
1981
|
+
}
|
|
1982
|
+
return 0;
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
return results;
|
|
1986
|
+
}
|
|
1987
|
+
getFilter() {
|
|
1988
|
+
return this.filter;
|
|
1989
|
+
}
|
|
1990
|
+
getMapName() {
|
|
1991
|
+
return this.mapName;
|
|
1992
|
+
}
|
|
1993
|
+
};
|
|
1994
|
+
|
|
1995
|
+
// src/DistributedLock.ts
|
|
1996
|
+
var DistributedLock = class {
|
|
1997
|
+
constructor(syncEngine, name) {
|
|
1998
|
+
this.fencingToken = null;
|
|
1999
|
+
this._isLocked = false;
|
|
2000
|
+
this.syncEngine = syncEngine;
|
|
2001
|
+
this.name = name;
|
|
2002
|
+
}
|
|
2003
|
+
async lock(ttl = 1e4) {
|
|
2004
|
+
const requestId = crypto.randomUUID();
|
|
2005
|
+
try {
|
|
2006
|
+
const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
|
|
2007
|
+
this.fencingToken = result.fencingToken;
|
|
2008
|
+
this._isLocked = true;
|
|
2009
|
+
return true;
|
|
2010
|
+
} catch (e) {
|
|
2011
|
+
return false;
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
async unlock() {
|
|
2015
|
+
if (!this._isLocked || this.fencingToken === null) return;
|
|
2016
|
+
const requestId = crypto.randomUUID();
|
|
2017
|
+
try {
|
|
2018
|
+
await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
|
|
2019
|
+
} finally {
|
|
2020
|
+
this._isLocked = false;
|
|
2021
|
+
this.fencingToken = null;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
isLocked() {
|
|
2025
|
+
return this._isLocked;
|
|
2026
|
+
}
|
|
2027
|
+
};
|
|
2028
|
+
|
|
2029
|
+
// src/TopicHandle.ts
|
|
2030
|
+
var TopicHandle = class {
|
|
2031
|
+
constructor(engine, topic) {
|
|
2032
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
2033
|
+
this.engine = engine;
|
|
2034
|
+
this.topic = topic;
|
|
2035
|
+
}
|
|
2036
|
+
get id() {
|
|
2037
|
+
return this.topic;
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Publish a message to the topic
|
|
2041
|
+
*/
|
|
2042
|
+
publish(data) {
|
|
2043
|
+
this.engine.publishTopic(this.topic, data);
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Subscribe to the topic
|
|
2047
|
+
*/
|
|
2048
|
+
subscribe(callback) {
|
|
2049
|
+
if (this.listeners.size === 0) {
|
|
2050
|
+
this.engine.subscribeToTopic(this.topic, this);
|
|
2051
|
+
}
|
|
2052
|
+
this.listeners.add(callback);
|
|
2053
|
+
return () => this.unsubscribe(callback);
|
|
2054
|
+
}
|
|
2055
|
+
unsubscribe(callback) {
|
|
2056
|
+
this.listeners.delete(callback);
|
|
2057
|
+
if (this.listeners.size === 0) {
|
|
2058
|
+
this.engine.unsubscribeFromTopic(this.topic);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Called by SyncEngine when a message is received
|
|
2063
|
+
*/
|
|
2064
|
+
onMessage(data, context) {
|
|
2065
|
+
this.listeners.forEach((cb) => {
|
|
2066
|
+
try {
|
|
2067
|
+
cb(data, context);
|
|
2068
|
+
} catch (e) {
|
|
2069
|
+
console.error("Error in topic listener", e);
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2074
|
+
|
|
2075
|
+
// src/cluster/ClusterClient.ts
|
|
2076
|
+
var import_core5 = require("@topgunbuild/core");
|
|
2077
|
+
|
|
2078
|
+
// src/cluster/ConnectionPool.ts
|
|
2079
|
+
var import_core2 = require("@topgunbuild/core");
|
|
2080
|
+
var import_core3 = require("@topgunbuild/core");
|
|
2081
|
+
var ConnectionPool = class {
|
|
2082
|
+
constructor(config = {}) {
|
|
2083
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
2084
|
+
this.connections = /* @__PURE__ */ new Map();
|
|
2085
|
+
this.primaryNodeId = null;
|
|
2086
|
+
this.healthCheckTimer = null;
|
|
2087
|
+
this.authToken = null;
|
|
2088
|
+
this.config = {
|
|
2089
|
+
...import_core2.DEFAULT_CONNECTION_POOL_CONFIG,
|
|
2090
|
+
...config
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
// ============================================
|
|
2094
|
+
// Event Emitter Methods (browser-compatible)
|
|
2095
|
+
// ============================================
|
|
2096
|
+
on(event, listener) {
|
|
2097
|
+
if (!this.listeners.has(event)) {
|
|
2098
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2099
|
+
}
|
|
2100
|
+
this.listeners.get(event).add(listener);
|
|
2101
|
+
return this;
|
|
2102
|
+
}
|
|
2103
|
+
off(event, listener) {
|
|
2104
|
+
this.listeners.get(event)?.delete(listener);
|
|
2105
|
+
return this;
|
|
2106
|
+
}
|
|
2107
|
+
emit(event, ...args) {
|
|
2108
|
+
const eventListeners = this.listeners.get(event);
|
|
2109
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
2110
|
+
return false;
|
|
2111
|
+
}
|
|
2112
|
+
for (const listener of eventListeners) {
|
|
2113
|
+
try {
|
|
2114
|
+
listener(...args);
|
|
2115
|
+
} catch (err) {
|
|
2116
|
+
logger.error({ event, err }, "Error in event listener");
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
return true;
|
|
2120
|
+
}
|
|
2121
|
+
removeAllListeners(event) {
|
|
2122
|
+
if (event) {
|
|
2123
|
+
this.listeners.delete(event);
|
|
2124
|
+
} else {
|
|
2125
|
+
this.listeners.clear();
|
|
2126
|
+
}
|
|
2127
|
+
return this;
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Set authentication token for all connections
|
|
2131
|
+
*/
|
|
2132
|
+
setAuthToken(token) {
|
|
2133
|
+
this.authToken = token;
|
|
2134
|
+
for (const conn of this.connections.values()) {
|
|
2135
|
+
if (conn.state === "CONNECTED") {
|
|
2136
|
+
this.sendAuth(conn);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Add a node to the connection pool
|
|
2142
|
+
*/
|
|
2143
|
+
async addNode(nodeId, endpoint) {
|
|
2144
|
+
if (this.connections.has(nodeId)) {
|
|
2145
|
+
const existing = this.connections.get(nodeId);
|
|
2146
|
+
if (existing.endpoint !== endpoint) {
|
|
2147
|
+
await this.removeNode(nodeId);
|
|
2148
|
+
} else {
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
const connection = {
|
|
2153
|
+
nodeId,
|
|
2154
|
+
endpoint,
|
|
2155
|
+
socket: null,
|
|
2156
|
+
state: "DISCONNECTED",
|
|
2157
|
+
lastSeen: 0,
|
|
2158
|
+
latencyMs: 0,
|
|
2159
|
+
reconnectAttempts: 0,
|
|
2160
|
+
reconnectTimer: null,
|
|
2161
|
+
pendingMessages: []
|
|
2162
|
+
};
|
|
2163
|
+
this.connections.set(nodeId, connection);
|
|
2164
|
+
if (!this.primaryNodeId) {
|
|
2165
|
+
this.primaryNodeId = nodeId;
|
|
2166
|
+
}
|
|
2167
|
+
await this.connect(nodeId);
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Remove a node from the connection pool
|
|
2171
|
+
*/
|
|
2172
|
+
async removeNode(nodeId) {
|
|
2173
|
+
const connection = this.connections.get(nodeId);
|
|
2174
|
+
if (!connection) return;
|
|
2175
|
+
if (connection.reconnectTimer) {
|
|
2176
|
+
clearTimeout(connection.reconnectTimer);
|
|
2177
|
+
connection.reconnectTimer = null;
|
|
2178
|
+
}
|
|
2179
|
+
if (connection.socket) {
|
|
2180
|
+
connection.socket.onclose = null;
|
|
2181
|
+
connection.socket.close();
|
|
2182
|
+
connection.socket = null;
|
|
2183
|
+
}
|
|
2184
|
+
this.connections.delete(nodeId);
|
|
2185
|
+
if (this.primaryNodeId === nodeId) {
|
|
2186
|
+
this.primaryNodeId = this.connections.size > 0 ? this.connections.keys().next().value ?? null : null;
|
|
2187
|
+
}
|
|
2188
|
+
logger.info({ nodeId }, "Node removed from connection pool");
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Get connection for a specific node
|
|
2192
|
+
*/
|
|
2193
|
+
getConnection(nodeId) {
|
|
2194
|
+
const connection = this.connections.get(nodeId);
|
|
2195
|
+
if (!connection || connection.state !== "AUTHENTICATED") {
|
|
2196
|
+
return null;
|
|
2197
|
+
}
|
|
2198
|
+
return connection.socket;
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Get primary connection (first/seed node)
|
|
2202
|
+
*/
|
|
2203
|
+
getPrimaryConnection() {
|
|
2204
|
+
if (!this.primaryNodeId) return null;
|
|
2205
|
+
return this.getConnection(this.primaryNodeId);
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Get any healthy connection
|
|
2209
|
+
*/
|
|
2210
|
+
getAnyHealthyConnection() {
|
|
2211
|
+
for (const [nodeId, conn] of this.connections) {
|
|
2212
|
+
if (conn.state === "AUTHENTICATED" && conn.socket) {
|
|
2213
|
+
return { nodeId, socket: conn.socket };
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
return null;
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Send message to a specific node
|
|
2220
|
+
*/
|
|
2221
|
+
send(nodeId, message) {
|
|
2222
|
+
const connection = this.connections.get(nodeId);
|
|
2223
|
+
if (!connection) {
|
|
2224
|
+
logger.warn({ nodeId }, "Cannot send: node not in pool");
|
|
2225
|
+
return false;
|
|
2226
|
+
}
|
|
2227
|
+
const data = (0, import_core3.serialize)(message);
|
|
2228
|
+
if (connection.state === "AUTHENTICATED" && connection.socket?.readyState === WebSocket.OPEN) {
|
|
2229
|
+
connection.socket.send(data);
|
|
2230
|
+
return true;
|
|
2231
|
+
}
|
|
2232
|
+
if (connection.pendingMessages.length < 1e3) {
|
|
2233
|
+
connection.pendingMessages.push(data);
|
|
2234
|
+
return true;
|
|
2235
|
+
}
|
|
2236
|
+
logger.warn({ nodeId }, "Message queue full, dropping message");
|
|
2237
|
+
return false;
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Send message to primary node
|
|
2241
|
+
*/
|
|
2242
|
+
sendToPrimary(message) {
|
|
2243
|
+
if (!this.primaryNodeId) {
|
|
2244
|
+
logger.warn("No primary node available");
|
|
2245
|
+
return false;
|
|
2246
|
+
}
|
|
2247
|
+
return this.send(this.primaryNodeId, message);
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* Get health status for all nodes
|
|
2251
|
+
*/
|
|
2252
|
+
getHealthStatus() {
|
|
2253
|
+
const status = /* @__PURE__ */ new Map();
|
|
2254
|
+
for (const [nodeId, conn] of this.connections) {
|
|
2255
|
+
status.set(nodeId, {
|
|
2256
|
+
nodeId,
|
|
2257
|
+
state: conn.state,
|
|
2258
|
+
lastSeen: conn.lastSeen,
|
|
2259
|
+
latencyMs: conn.latencyMs,
|
|
2260
|
+
reconnectAttempts: conn.reconnectAttempts
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
return status;
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Get list of connected node IDs
|
|
2267
|
+
*/
|
|
2268
|
+
getConnectedNodes() {
|
|
2269
|
+
return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Get all node IDs
|
|
2273
|
+
*/
|
|
2274
|
+
getAllNodes() {
|
|
2275
|
+
return Array.from(this.connections.keys());
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* Check if node is connected and authenticated
|
|
2279
|
+
*/
|
|
2280
|
+
isNodeConnected(nodeId) {
|
|
2281
|
+
const conn = this.connections.get(nodeId);
|
|
2282
|
+
return conn?.state === "AUTHENTICATED";
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Check if connected to a specific node.
|
|
2286
|
+
* Alias for isNodeConnected() for IConnectionProvider compatibility.
|
|
2287
|
+
*/
|
|
2288
|
+
isConnected(nodeId) {
|
|
2289
|
+
return this.isNodeConnected(nodeId);
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Start health monitoring
|
|
2293
|
+
*/
|
|
2294
|
+
startHealthCheck() {
|
|
2295
|
+
if (this.healthCheckTimer) return;
|
|
2296
|
+
this.healthCheckTimer = setInterval(() => {
|
|
2297
|
+
this.performHealthCheck();
|
|
2298
|
+
}, this.config.healthCheckIntervalMs);
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Stop health monitoring
|
|
2302
|
+
*/
|
|
2303
|
+
stopHealthCheck() {
|
|
2304
|
+
if (this.healthCheckTimer) {
|
|
2305
|
+
clearInterval(this.healthCheckTimer);
|
|
2306
|
+
this.healthCheckTimer = null;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Close all connections and cleanup
|
|
2311
|
+
*/
|
|
2312
|
+
close() {
|
|
2313
|
+
this.stopHealthCheck();
|
|
2314
|
+
for (const nodeId of this.connections.keys()) {
|
|
2315
|
+
this.removeNode(nodeId);
|
|
2316
|
+
}
|
|
2317
|
+
this.connections.clear();
|
|
2318
|
+
this.primaryNodeId = null;
|
|
2319
|
+
}
|
|
2320
|
+
// ============================================
|
|
2321
|
+
// Private Methods
|
|
2322
|
+
// ============================================
|
|
2323
|
+
async connect(nodeId) {
|
|
2324
|
+
const connection = this.connections.get(nodeId);
|
|
2325
|
+
if (!connection) return;
|
|
2326
|
+
if (connection.state === "CONNECTING" || connection.state === "CONNECTED") {
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
connection.state = "CONNECTING";
|
|
2330
|
+
logger.info({ nodeId, endpoint: connection.endpoint }, "Connecting to node");
|
|
2331
|
+
try {
|
|
2332
|
+
const socket = new WebSocket(connection.endpoint);
|
|
2333
|
+
socket.binaryType = "arraybuffer";
|
|
2334
|
+
connection.socket = socket;
|
|
2335
|
+
socket.onopen = () => {
|
|
2336
|
+
connection.state = "CONNECTED";
|
|
2337
|
+
connection.reconnectAttempts = 0;
|
|
2338
|
+
connection.lastSeen = Date.now();
|
|
2339
|
+
logger.info({ nodeId }, "Connected to node");
|
|
2340
|
+
this.emit("node:connected", nodeId);
|
|
2341
|
+
if (this.authToken) {
|
|
2342
|
+
this.sendAuth(connection);
|
|
2343
|
+
}
|
|
2344
|
+
};
|
|
2345
|
+
socket.onmessage = (event) => {
|
|
2346
|
+
connection.lastSeen = Date.now();
|
|
2347
|
+
this.handleMessage(nodeId, event);
|
|
2348
|
+
};
|
|
2349
|
+
socket.onerror = (error) => {
|
|
2350
|
+
logger.error({ nodeId, error }, "WebSocket error");
|
|
2351
|
+
this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
|
|
2352
|
+
};
|
|
2353
|
+
socket.onclose = () => {
|
|
2354
|
+
const wasConnected = connection.state === "AUTHENTICATED";
|
|
2355
|
+
connection.state = "DISCONNECTED";
|
|
2356
|
+
connection.socket = null;
|
|
2357
|
+
if (wasConnected) {
|
|
2358
|
+
this.emit("node:disconnected", nodeId, "Connection closed");
|
|
2359
|
+
}
|
|
2360
|
+
this.scheduleReconnect(nodeId);
|
|
2361
|
+
};
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
connection.state = "FAILED";
|
|
2364
|
+
logger.error({ nodeId, error }, "Failed to connect");
|
|
2365
|
+
this.scheduleReconnect(nodeId);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
sendAuth(connection) {
|
|
2369
|
+
if (!this.authToken || !connection.socket) return;
|
|
2370
|
+
connection.socket.send((0, import_core3.serialize)({
|
|
2371
|
+
type: "AUTH",
|
|
2372
|
+
token: this.authToken
|
|
2373
|
+
}));
|
|
2374
|
+
}
|
|
2375
|
+
handleMessage(nodeId, event) {
|
|
2376
|
+
const connection = this.connections.get(nodeId);
|
|
2377
|
+
if (!connection) return;
|
|
2378
|
+
let message;
|
|
2379
|
+
try {
|
|
2380
|
+
if (event.data instanceof ArrayBuffer) {
|
|
2381
|
+
message = (0, import_core3.deserialize)(new Uint8Array(event.data));
|
|
2382
|
+
} else {
|
|
2383
|
+
message = JSON.parse(event.data);
|
|
2384
|
+
}
|
|
2385
|
+
} catch (e) {
|
|
2386
|
+
logger.error({ nodeId, error: e }, "Failed to parse message");
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
if (message.type === "AUTH_ACK") {
|
|
2390
|
+
connection.state = "AUTHENTICATED";
|
|
2391
|
+
logger.info({ nodeId }, "Authenticated with node");
|
|
2392
|
+
this.emit("node:healthy", nodeId);
|
|
2393
|
+
this.flushPendingMessages(connection);
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
if (message.type === "AUTH_REQUIRED") {
|
|
2397
|
+
if (this.authToken) {
|
|
2398
|
+
this.sendAuth(connection);
|
|
2399
|
+
}
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
if (message.type === "AUTH_FAIL") {
|
|
2403
|
+
logger.error({ nodeId, error: message.error }, "Authentication failed");
|
|
2404
|
+
connection.state = "FAILED";
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
if (message.type === "PONG") {
|
|
2408
|
+
if (message.timestamp) {
|
|
2409
|
+
connection.latencyMs = Date.now() - message.timestamp;
|
|
2410
|
+
}
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
|
|
2414
|
+
this.emit("message", nodeId, message);
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
this.emit("message", nodeId, message);
|
|
2418
|
+
}
|
|
2419
|
+
flushPendingMessages(connection) {
|
|
2420
|
+
if (!connection.socket || connection.state !== "AUTHENTICATED") return;
|
|
2421
|
+
const pending = connection.pendingMessages;
|
|
2422
|
+
connection.pendingMessages = [];
|
|
2423
|
+
for (const data of pending) {
|
|
2424
|
+
if (connection.socket.readyState === WebSocket.OPEN) {
|
|
2425
|
+
connection.socket.send(data);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
if (pending.length > 0) {
|
|
2429
|
+
logger.debug({ nodeId: connection.nodeId, count: pending.length }, "Flushed pending messages");
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
scheduleReconnect(nodeId) {
|
|
2433
|
+
const connection = this.connections.get(nodeId);
|
|
2434
|
+
if (!connection) return;
|
|
2435
|
+
if (connection.reconnectTimer) {
|
|
2436
|
+
clearTimeout(connection.reconnectTimer);
|
|
2437
|
+
connection.reconnectTimer = null;
|
|
2438
|
+
}
|
|
2439
|
+
if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
2440
|
+
connection.state = "FAILED";
|
|
2441
|
+
logger.error({ nodeId, attempts: connection.reconnectAttempts }, "Max reconnect attempts reached");
|
|
2442
|
+
this.emit("node:unhealthy", nodeId, "Max reconnect attempts reached");
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
const delay = Math.min(
|
|
2446
|
+
this.config.reconnectDelayMs * Math.pow(2, connection.reconnectAttempts),
|
|
2447
|
+
this.config.maxReconnectDelayMs
|
|
2448
|
+
);
|
|
2449
|
+
connection.state = "RECONNECTING";
|
|
2450
|
+
connection.reconnectAttempts++;
|
|
2451
|
+
logger.info({ nodeId, delay, attempt: connection.reconnectAttempts }, "Scheduling reconnect");
|
|
2452
|
+
connection.reconnectTimer = setTimeout(() => {
|
|
2453
|
+
connection.reconnectTimer = null;
|
|
2454
|
+
this.connect(nodeId);
|
|
2455
|
+
}, delay);
|
|
2456
|
+
}
|
|
2457
|
+
performHealthCheck() {
|
|
2458
|
+
const now = Date.now();
|
|
2459
|
+
for (const [nodeId, connection] of this.connections) {
|
|
2460
|
+
if (connection.state !== "AUTHENTICATED") continue;
|
|
2461
|
+
const timeSinceLastSeen = now - connection.lastSeen;
|
|
2462
|
+
if (timeSinceLastSeen > this.config.healthCheckIntervalMs * 3) {
|
|
2463
|
+
logger.warn({ nodeId, timeSinceLastSeen }, "Node appears stale, sending ping");
|
|
2464
|
+
}
|
|
2465
|
+
if (connection.socket?.readyState === WebSocket.OPEN) {
|
|
2466
|
+
connection.socket.send((0, import_core3.serialize)({
|
|
2467
|
+
type: "PING",
|
|
2468
|
+
timestamp: now
|
|
2469
|
+
}));
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
};
|
|
2474
|
+
|
|
2475
|
+
// src/cluster/PartitionRouter.ts
|
|
2476
|
+
var import_core4 = require("@topgunbuild/core");
|
|
2477
|
+
var PartitionRouter = class {
|
|
2478
|
+
constructor(connectionPool, config = {}) {
|
|
2479
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
2480
|
+
this.partitionMap = null;
|
|
2481
|
+
this.lastRefreshTime = 0;
|
|
2482
|
+
this.refreshTimer = null;
|
|
2483
|
+
this.pendingRefresh = null;
|
|
2484
|
+
this.connectionPool = connectionPool;
|
|
2485
|
+
this.config = {
|
|
2486
|
+
...import_core4.DEFAULT_PARTITION_ROUTER_CONFIG,
|
|
2487
|
+
...config
|
|
2488
|
+
};
|
|
2489
|
+
this.connectionPool.on("message", (nodeId, message) => {
|
|
2490
|
+
if (message.type === "PARTITION_MAP") {
|
|
2491
|
+
this.handlePartitionMap(message);
|
|
2492
|
+
} else if (message.type === "PARTITION_MAP_DELTA") {
|
|
2493
|
+
this.handlePartitionMapDelta(message);
|
|
2494
|
+
}
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
// ============================================
|
|
2498
|
+
// Event Emitter Methods (browser-compatible)
|
|
2499
|
+
// ============================================
|
|
2500
|
+
on(event, listener) {
|
|
2501
|
+
if (!this.listeners.has(event)) {
|
|
2502
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2503
|
+
}
|
|
2504
|
+
this.listeners.get(event).add(listener);
|
|
2505
|
+
return this;
|
|
2506
|
+
}
|
|
2507
|
+
off(event, listener) {
|
|
2508
|
+
this.listeners.get(event)?.delete(listener);
|
|
2509
|
+
return this;
|
|
2510
|
+
}
|
|
2511
|
+
once(event, listener) {
|
|
2512
|
+
const wrapper = (...args) => {
|
|
2513
|
+
this.off(event, wrapper);
|
|
2514
|
+
listener(...args);
|
|
2515
|
+
};
|
|
2516
|
+
return this.on(event, wrapper);
|
|
2517
|
+
}
|
|
2518
|
+
emit(event, ...args) {
|
|
2519
|
+
const eventListeners = this.listeners.get(event);
|
|
2520
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
2521
|
+
return false;
|
|
2522
|
+
}
|
|
2523
|
+
for (const listener of eventListeners) {
|
|
2524
|
+
try {
|
|
2525
|
+
listener(...args);
|
|
2526
|
+
} catch (err) {
|
|
2527
|
+
logger.error({ event, err }, "Error in event listener");
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
return true;
|
|
2531
|
+
}
|
|
2532
|
+
removeListener(event, listener) {
|
|
2533
|
+
return this.off(event, listener);
|
|
2534
|
+
}
|
|
2535
|
+
removeAllListeners(event) {
|
|
2536
|
+
if (event) {
|
|
2537
|
+
this.listeners.delete(event);
|
|
2538
|
+
} else {
|
|
2539
|
+
this.listeners.clear();
|
|
2540
|
+
}
|
|
2541
|
+
return this;
|
|
2542
|
+
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Get the partition ID for a given key
|
|
2545
|
+
*/
|
|
2546
|
+
getPartitionId(key) {
|
|
2547
|
+
return Math.abs((0, import_core4.hashString)(key)) % import_core4.PARTITION_COUNT;
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Route a key to the owner node
|
|
2551
|
+
*/
|
|
2552
|
+
route(key) {
|
|
2553
|
+
if (!this.partitionMap) {
|
|
2554
|
+
return null;
|
|
2555
|
+
}
|
|
2556
|
+
const partitionId = this.getPartitionId(key);
|
|
2557
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
2558
|
+
if (!partition) {
|
|
2559
|
+
logger.warn({ key, partitionId }, "Partition not found in map");
|
|
2560
|
+
return null;
|
|
2561
|
+
}
|
|
2562
|
+
return {
|
|
2563
|
+
nodeId: partition.ownerNodeId,
|
|
2564
|
+
partitionId,
|
|
2565
|
+
isOwner: true,
|
|
2566
|
+
isBackup: false
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* Route a key and get the WebSocket connection to use
|
|
2571
|
+
*/
|
|
2572
|
+
routeToConnection(key) {
|
|
2573
|
+
const routing = this.route(key);
|
|
2574
|
+
if (!routing) {
|
|
2575
|
+
if (this.config.fallbackMode === "forward") {
|
|
2576
|
+
const primary = this.connectionPool.getAnyHealthyConnection();
|
|
2577
|
+
if (primary) {
|
|
2578
|
+
return primary;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
return null;
|
|
2582
|
+
}
|
|
2583
|
+
const socket = this.connectionPool.getConnection(routing.nodeId);
|
|
2584
|
+
if (socket) {
|
|
2585
|
+
return { nodeId: routing.nodeId, socket };
|
|
2586
|
+
}
|
|
2587
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
|
|
2588
|
+
if (partition) {
|
|
2589
|
+
for (const backupId of partition.backupNodeIds) {
|
|
2590
|
+
const backupSocket = this.connectionPool.getConnection(backupId);
|
|
2591
|
+
if (backupSocket) {
|
|
2592
|
+
logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
|
|
2593
|
+
return { nodeId: backupId, socket: backupSocket };
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
if (this.config.fallbackMode === "forward") {
|
|
2598
|
+
return this.connectionPool.getAnyHealthyConnection();
|
|
2599
|
+
}
|
|
2600
|
+
return null;
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Get routing info for multiple keys (batch routing)
|
|
2604
|
+
*/
|
|
2605
|
+
routeBatch(keys) {
|
|
2606
|
+
const result = /* @__PURE__ */ new Map();
|
|
2607
|
+
for (const key of keys) {
|
|
2608
|
+
const routing = this.route(key);
|
|
2609
|
+
if (routing) {
|
|
2610
|
+
const nodeId = routing.nodeId;
|
|
2611
|
+
if (!result.has(nodeId)) {
|
|
2612
|
+
result.set(nodeId, []);
|
|
2613
|
+
}
|
|
2614
|
+
result.get(nodeId).push({ ...routing, key });
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
return result;
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Get all partitions owned by a specific node
|
|
2621
|
+
*/
|
|
2622
|
+
getPartitionsForNode(nodeId) {
|
|
2623
|
+
if (!this.partitionMap) return [];
|
|
2624
|
+
return this.partitionMap.partitions.filter((p) => p.ownerNodeId === nodeId).map((p) => p.partitionId);
|
|
2625
|
+
}
|
|
2626
|
+
/**
|
|
2627
|
+
* Get current partition map version
|
|
2628
|
+
*/
|
|
2629
|
+
getMapVersion() {
|
|
2630
|
+
return this.partitionMap?.version ?? 0;
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Check if partition map is available
|
|
2634
|
+
*/
|
|
2635
|
+
hasPartitionMap() {
|
|
2636
|
+
return this.partitionMap !== null;
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Get owner node for a key.
|
|
2640
|
+
* Returns null if partition map is not available.
|
|
2641
|
+
*/
|
|
2642
|
+
getOwner(key) {
|
|
2643
|
+
if (!this.partitionMap) return null;
|
|
2644
|
+
const partitionId = this.getPartitionId(key);
|
|
2645
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
2646
|
+
return partition?.ownerNodeId ?? null;
|
|
2647
|
+
}
|
|
2648
|
+
/**
|
|
2649
|
+
* Get backup nodes for a key.
|
|
2650
|
+
* Returns empty array if partition map is not available.
|
|
2651
|
+
*/
|
|
2652
|
+
getBackups(key) {
|
|
2653
|
+
if (!this.partitionMap) return [];
|
|
2654
|
+
const partitionId = this.getPartitionId(key);
|
|
2655
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
2656
|
+
return partition?.backupNodeIds ?? [];
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Get the full partition map.
|
|
2660
|
+
* Returns null if not available.
|
|
2661
|
+
*/
|
|
2662
|
+
getMap() {
|
|
2663
|
+
return this.partitionMap;
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* Update entire partition map.
|
|
2667
|
+
* Only accepts newer versions.
|
|
2668
|
+
*/
|
|
2669
|
+
updateMap(map) {
|
|
2670
|
+
if (this.partitionMap && map.version <= this.partitionMap.version) {
|
|
2671
|
+
return false;
|
|
2672
|
+
}
|
|
2673
|
+
this.partitionMap = map;
|
|
2674
|
+
this.lastRefreshTime = Date.now();
|
|
2675
|
+
this.updateConnectionPool(map);
|
|
2676
|
+
const changesCount = map.partitions.length;
|
|
2677
|
+
logger.info({
|
|
2678
|
+
version: map.version,
|
|
2679
|
+
partitions: map.partitionCount,
|
|
2680
|
+
nodes: map.nodes.length
|
|
2681
|
+
}, "Partition map updated via updateMap");
|
|
2682
|
+
this.emit("partitionMap:updated", map.version, changesCount);
|
|
2683
|
+
return true;
|
|
2684
|
+
}
|
|
2685
|
+
/**
|
|
2686
|
+
* Update a single partition (for delta updates).
|
|
2687
|
+
*/
|
|
2688
|
+
updatePartition(partitionId, owner, backups) {
|
|
2689
|
+
if (!this.partitionMap) return;
|
|
2690
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
2691
|
+
if (partition) {
|
|
2692
|
+
partition.ownerNodeId = owner;
|
|
2693
|
+
partition.backupNodeIds = backups;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* Check if partition map is stale
|
|
2698
|
+
*/
|
|
2699
|
+
isMapStale() {
|
|
2700
|
+
if (!this.partitionMap) return true;
|
|
2701
|
+
const now = Date.now();
|
|
2702
|
+
return now - this.lastRefreshTime > this.config.maxMapStalenessMs;
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Request fresh partition map from server
|
|
2706
|
+
*/
|
|
2707
|
+
async refreshPartitionMap() {
|
|
2708
|
+
if (this.pendingRefresh) {
|
|
2709
|
+
return this.pendingRefresh;
|
|
2710
|
+
}
|
|
2711
|
+
this.pendingRefresh = this.doRefreshPartitionMap();
|
|
2712
|
+
try {
|
|
2713
|
+
await this.pendingRefresh;
|
|
2714
|
+
} finally {
|
|
2715
|
+
this.pendingRefresh = null;
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Start periodic partition map refresh
|
|
2720
|
+
*/
|
|
2721
|
+
startPeriodicRefresh() {
|
|
2722
|
+
if (this.refreshTimer) return;
|
|
2723
|
+
this.refreshTimer = setInterval(() => {
|
|
2724
|
+
if (this.isMapStale()) {
|
|
2725
|
+
this.emit("partitionMap:stale", this.getMapVersion(), this.lastRefreshTime);
|
|
2726
|
+
this.refreshPartitionMap().catch((err) => {
|
|
2727
|
+
logger.error({ error: err }, "Failed to refresh partition map");
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
}, this.config.mapRefreshIntervalMs);
|
|
2731
|
+
}
|
|
2732
|
+
/**
|
|
2733
|
+
* Stop periodic refresh
|
|
2734
|
+
*/
|
|
2735
|
+
stopPeriodicRefresh() {
|
|
2736
|
+
if (this.refreshTimer) {
|
|
2737
|
+
clearInterval(this.refreshTimer);
|
|
2738
|
+
this.refreshTimer = null;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
/**
|
|
2742
|
+
* Handle NOT_OWNER error from server
|
|
2743
|
+
*/
|
|
2744
|
+
handleNotOwnerError(key, actualOwner, newMapVersion) {
|
|
2745
|
+
const routing = this.route(key);
|
|
2746
|
+
const expectedOwner = routing?.nodeId ?? "unknown";
|
|
2747
|
+
this.emit("routing:miss", key, expectedOwner, actualOwner);
|
|
2748
|
+
if (newMapVersion > this.getMapVersion()) {
|
|
2749
|
+
this.refreshPartitionMap().catch((err) => {
|
|
2750
|
+
logger.error({ error: err }, "Failed to refresh partition map after NOT_OWNER");
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
/**
|
|
2755
|
+
* Get statistics about routing
|
|
2756
|
+
*/
|
|
2757
|
+
getStats() {
|
|
2758
|
+
return {
|
|
2759
|
+
mapVersion: this.getMapVersion(),
|
|
2760
|
+
partitionCount: this.partitionMap?.partitionCount ?? 0,
|
|
2761
|
+
nodeCount: this.partitionMap?.nodes.length ?? 0,
|
|
2762
|
+
lastRefresh: this.lastRefreshTime,
|
|
2763
|
+
isStale: this.isMapStale()
|
|
2764
|
+
};
|
|
2765
|
+
}
|
|
2766
|
+
/**
|
|
2767
|
+
* Cleanup resources
|
|
2768
|
+
*/
|
|
2769
|
+
close() {
|
|
2770
|
+
this.stopPeriodicRefresh();
|
|
2771
|
+
this.partitionMap = null;
|
|
2772
|
+
}
|
|
2773
|
+
// ============================================
|
|
2774
|
+
// Private Methods
|
|
2775
|
+
// ============================================
|
|
2776
|
+
handlePartitionMap(message) {
|
|
2777
|
+
const newMap = message.payload;
|
|
2778
|
+
if (this.partitionMap && newMap.version <= this.partitionMap.version) {
|
|
2779
|
+
logger.debug({
|
|
2780
|
+
current: this.partitionMap.version,
|
|
2781
|
+
received: newMap.version
|
|
2782
|
+
}, "Ignoring older partition map");
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
this.partitionMap = newMap;
|
|
2786
|
+
this.lastRefreshTime = Date.now();
|
|
2787
|
+
this.updateConnectionPool(newMap);
|
|
2788
|
+
const changesCount = newMap.partitions.length;
|
|
2789
|
+
logger.info({
|
|
2790
|
+
version: newMap.version,
|
|
2791
|
+
partitions: newMap.partitionCount,
|
|
2792
|
+
nodes: newMap.nodes.length
|
|
2793
|
+
}, "Partition map updated");
|
|
2794
|
+
this.emit("partitionMap:updated", newMap.version, changesCount);
|
|
2795
|
+
}
|
|
2796
|
+
handlePartitionMapDelta(message) {
|
|
2797
|
+
const delta = message.payload;
|
|
2798
|
+
if (!this.partitionMap) {
|
|
2799
|
+
logger.warn("Received delta but no base map, requesting full map");
|
|
2800
|
+
this.refreshPartitionMap();
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
if (delta.previousVersion !== this.partitionMap.version) {
|
|
2804
|
+
logger.warn({
|
|
2805
|
+
expected: this.partitionMap.version,
|
|
2806
|
+
received: delta.previousVersion
|
|
2807
|
+
}, "Delta version mismatch, requesting full map");
|
|
2808
|
+
this.refreshPartitionMap();
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
for (const change of delta.changes) {
|
|
2812
|
+
this.applyPartitionChange(change);
|
|
2813
|
+
}
|
|
2814
|
+
this.partitionMap.version = delta.version;
|
|
2815
|
+
this.lastRefreshTime = Date.now();
|
|
2816
|
+
logger.info({
|
|
2817
|
+
version: delta.version,
|
|
2818
|
+
changes: delta.changes.length
|
|
2819
|
+
}, "Applied partition map delta");
|
|
2820
|
+
this.emit("partitionMap:updated", delta.version, delta.changes.length);
|
|
2821
|
+
}
|
|
2822
|
+
applyPartitionChange(change) {
|
|
2823
|
+
if (!this.partitionMap) return;
|
|
2824
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === change.partitionId);
|
|
2825
|
+
if (partition) {
|
|
2826
|
+
partition.ownerNodeId = change.newOwner;
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
updateConnectionPool(map) {
|
|
2830
|
+
for (const node of map.nodes) {
|
|
2831
|
+
if (node.status === "ACTIVE" || node.status === "JOINING") {
|
|
2832
|
+
this.connectionPool.addNode(node.nodeId, node.endpoints.websocket);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
const currentNodeIds = new Set(map.nodes.map((n) => n.nodeId));
|
|
2836
|
+
for (const nodeId of this.connectionPool.getAllNodes()) {
|
|
2837
|
+
if (!currentNodeIds.has(nodeId)) {
|
|
2838
|
+
this.connectionPool.removeNode(nodeId);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
async doRefreshPartitionMap() {
|
|
2843
|
+
logger.debug("Requesting partition map refresh");
|
|
2844
|
+
const sent = this.connectionPool.sendToPrimary({
|
|
2845
|
+
type: "PARTITION_MAP_REQUEST",
|
|
2846
|
+
payload: {
|
|
2847
|
+
currentVersion: this.getMapVersion()
|
|
2848
|
+
}
|
|
2849
|
+
});
|
|
2850
|
+
if (!sent) {
|
|
2851
|
+
throw new Error("No connection available to request partition map");
|
|
2852
|
+
}
|
|
2853
|
+
return new Promise((resolve, reject) => {
|
|
2854
|
+
const timeout = setTimeout(() => {
|
|
2855
|
+
this.removeListener("partitionMap:updated", onUpdate);
|
|
2856
|
+
reject(new Error("Partition map refresh timeout"));
|
|
2857
|
+
}, 5e3);
|
|
2858
|
+
const onUpdate = () => {
|
|
2859
|
+
clearTimeout(timeout);
|
|
2860
|
+
this.removeListener("partitionMap:updated", onUpdate);
|
|
2861
|
+
resolve();
|
|
2862
|
+
};
|
|
2863
|
+
this.once("partitionMap:updated", onUpdate);
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
};
|
|
2867
|
+
|
|
2868
|
+
// src/cluster/ClusterClient.ts
|
|
2869
|
+
var ClusterClient = class {
|
|
2870
|
+
constructor(config) {
|
|
2871
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
2872
|
+
this.initialized = false;
|
|
2873
|
+
this.routingActive = false;
|
|
2874
|
+
this.routingMetrics = {
|
|
2875
|
+
directRoutes: 0,
|
|
2876
|
+
fallbackRoutes: 0,
|
|
2877
|
+
partitionMisses: 0,
|
|
2878
|
+
totalRoutes: 0
|
|
2879
|
+
};
|
|
2880
|
+
// Circuit breaker state per node
|
|
2881
|
+
this.circuits = /* @__PURE__ */ new Map();
|
|
2882
|
+
this.config = config;
|
|
2883
|
+
this.circuitBreakerConfig = {
|
|
2884
|
+
...import_core5.DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
2885
|
+
...config.circuitBreaker
|
|
2886
|
+
};
|
|
2887
|
+
const poolConfig = {
|
|
2888
|
+
...import_core5.DEFAULT_CONNECTION_POOL_CONFIG,
|
|
2889
|
+
...config.connectionPool
|
|
2890
|
+
};
|
|
2891
|
+
this.connectionPool = new ConnectionPool(poolConfig);
|
|
2892
|
+
const routerConfig = {
|
|
2893
|
+
...import_core5.DEFAULT_PARTITION_ROUTER_CONFIG,
|
|
2894
|
+
fallbackMode: config.routingMode === "direct" ? "error" : "forward",
|
|
2895
|
+
...config.routing
|
|
2896
|
+
};
|
|
2897
|
+
this.partitionRouter = new PartitionRouter(this.connectionPool, routerConfig);
|
|
2898
|
+
this.setupEventHandlers();
|
|
2899
|
+
}
|
|
2900
|
+
// ============================================
|
|
2901
|
+
// Event Emitter Methods (browser-compatible)
|
|
2902
|
+
// ============================================
|
|
2903
|
+
on(event, listener) {
|
|
2904
|
+
if (!this.listeners.has(event)) {
|
|
2905
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2906
|
+
}
|
|
2907
|
+
this.listeners.get(event).add(listener);
|
|
2908
|
+
return this;
|
|
2909
|
+
}
|
|
2910
|
+
off(event, listener) {
|
|
2911
|
+
this.listeners.get(event)?.delete(listener);
|
|
2912
|
+
return this;
|
|
2913
|
+
}
|
|
2914
|
+
emit(event, ...args) {
|
|
2915
|
+
const eventListeners = this.listeners.get(event);
|
|
2916
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
2917
|
+
return false;
|
|
2918
|
+
}
|
|
2919
|
+
for (const listener of eventListeners) {
|
|
2920
|
+
try {
|
|
2921
|
+
listener(...args);
|
|
2922
|
+
} catch (err) {
|
|
2923
|
+
logger.error({ event, err }, "Error in event listener");
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
return true;
|
|
2927
|
+
}
|
|
2928
|
+
removeAllListeners(event) {
|
|
2929
|
+
if (event) {
|
|
2930
|
+
this.listeners.delete(event);
|
|
2931
|
+
} else {
|
|
2932
|
+
this.listeners.clear();
|
|
2933
|
+
}
|
|
2934
|
+
return this;
|
|
2935
|
+
}
|
|
2936
|
+
// ============================================
|
|
2937
|
+
// IConnectionProvider Implementation
|
|
2938
|
+
// ============================================
|
|
2939
|
+
/**
|
|
2940
|
+
* Connect to cluster nodes (IConnectionProvider interface).
|
|
2941
|
+
* Alias for start() method.
|
|
2942
|
+
*/
|
|
2943
|
+
async connect() {
|
|
2944
|
+
return this.start();
|
|
2945
|
+
}
|
|
2946
|
+
/**
|
|
2947
|
+
* Get connection for a specific key (IConnectionProvider interface).
|
|
2948
|
+
* Routes to partition owner based on key hash when smart routing is enabled.
|
|
2949
|
+
* @throws Error if not connected
|
|
2950
|
+
*/
|
|
2951
|
+
getConnection(key) {
|
|
2952
|
+
if (!this.isConnected()) {
|
|
2953
|
+
throw new Error("ClusterClient not connected");
|
|
2954
|
+
}
|
|
2955
|
+
this.routingMetrics.totalRoutes++;
|
|
2956
|
+
if (this.config.routingMode !== "direct" || !this.routingActive) {
|
|
2957
|
+
this.routingMetrics.fallbackRoutes++;
|
|
2958
|
+
return this.getFallbackConnection();
|
|
2959
|
+
}
|
|
2960
|
+
const routing = this.partitionRouter.route(key);
|
|
2961
|
+
if (!routing) {
|
|
2962
|
+
this.routingMetrics.partitionMisses++;
|
|
2963
|
+
logger.debug({ key }, "No partition map available, using fallback");
|
|
2964
|
+
return this.getFallbackConnection();
|
|
2965
|
+
}
|
|
2966
|
+
const owner = routing.nodeId;
|
|
2967
|
+
if (!this.connectionPool.isNodeConnected(owner)) {
|
|
2968
|
+
this.routingMetrics.fallbackRoutes++;
|
|
2969
|
+
logger.debug({ key, owner }, "Partition owner not connected, using fallback");
|
|
2970
|
+
this.requestPartitionMapRefresh();
|
|
2971
|
+
return this.getFallbackConnection();
|
|
2972
|
+
}
|
|
2973
|
+
const socket = this.connectionPool.getConnection(owner);
|
|
2974
|
+
if (!socket) {
|
|
2975
|
+
this.routingMetrics.fallbackRoutes++;
|
|
2976
|
+
logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
|
|
2977
|
+
return this.getFallbackConnection();
|
|
2978
|
+
}
|
|
2979
|
+
this.routingMetrics.directRoutes++;
|
|
2980
|
+
return socket;
|
|
2981
|
+
}
|
|
2982
|
+
/**
|
|
2983
|
+
* Get fallback connection when owner is unavailable.
|
|
2984
|
+
* @throws Error if no connection available
|
|
2985
|
+
*/
|
|
2986
|
+
getFallbackConnection() {
|
|
2987
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
2988
|
+
if (!conn?.socket) {
|
|
2989
|
+
throw new Error("No healthy connection available");
|
|
2990
|
+
}
|
|
2991
|
+
return conn.socket;
|
|
2992
|
+
}
|
|
2993
|
+
/**
|
|
2994
|
+
* Request a partition map refresh in the background.
|
|
2995
|
+
* Called when routing to an unknown/disconnected owner.
|
|
2996
|
+
*/
|
|
2997
|
+
requestPartitionMapRefresh() {
|
|
2998
|
+
this.partitionRouter.refreshPartitionMap().catch((err) => {
|
|
2999
|
+
logger.error({ err }, "Failed to refresh partition map");
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
/**
|
|
3003
|
+
* Request partition map from a specific node.
|
|
3004
|
+
* Called on first node connection.
|
|
3005
|
+
*/
|
|
3006
|
+
requestPartitionMapFromNode(nodeId) {
|
|
3007
|
+
const socket = this.connectionPool.getConnection(nodeId);
|
|
3008
|
+
if (socket) {
|
|
3009
|
+
logger.debug({ nodeId }, "Requesting partition map from node");
|
|
3010
|
+
socket.send((0, import_core5.serialize)({
|
|
3011
|
+
type: "PARTITION_MAP_REQUEST",
|
|
3012
|
+
payload: {
|
|
3013
|
+
currentVersion: this.partitionRouter.getMapVersion()
|
|
3014
|
+
}
|
|
3015
|
+
}));
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
/**
|
|
3019
|
+
* Check if at least one connection is active (IConnectionProvider interface).
|
|
3020
|
+
*/
|
|
3021
|
+
isConnected() {
|
|
3022
|
+
return this.connectionPool.getConnectedNodes().length > 0;
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Send data via the appropriate connection (IConnectionProvider interface).
|
|
3026
|
+
* Routes based on key if provided.
|
|
3027
|
+
*/
|
|
3028
|
+
send(data, key) {
|
|
3029
|
+
if (!this.isConnected()) {
|
|
3030
|
+
throw new Error("ClusterClient not connected");
|
|
3031
|
+
}
|
|
3032
|
+
const socket = key ? this.getConnection(key) : this.getAnyConnection();
|
|
3033
|
+
socket.send(data);
|
|
3034
|
+
}
|
|
3035
|
+
/**
|
|
3036
|
+
* Send data with automatic retry and rerouting on failure.
|
|
3037
|
+
* @param data - Data to send
|
|
3038
|
+
* @param key - Optional key for routing
|
|
3039
|
+
* @param options - Retry options
|
|
3040
|
+
* @throws Error after max retries exceeded
|
|
3041
|
+
*/
|
|
3042
|
+
async sendWithRetry(data, key, options = {}) {
|
|
3043
|
+
const {
|
|
3044
|
+
maxRetries = 3,
|
|
3045
|
+
retryDelayMs = 100,
|
|
3046
|
+
retryOnNotOwner = true
|
|
3047
|
+
} = options;
|
|
3048
|
+
let lastError = null;
|
|
3049
|
+
let nodeId = null;
|
|
3050
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
3051
|
+
try {
|
|
3052
|
+
if (key && this.routingActive) {
|
|
3053
|
+
const routing = this.partitionRouter.route(key);
|
|
3054
|
+
nodeId = routing?.nodeId ?? null;
|
|
3055
|
+
}
|
|
3056
|
+
if (nodeId && !this.canUseNode(nodeId)) {
|
|
3057
|
+
logger.debug({ nodeId, attempt }, "Circuit open, using fallback");
|
|
3058
|
+
nodeId = null;
|
|
3059
|
+
}
|
|
3060
|
+
const socket = key && nodeId ? this.connectionPool.getConnection(nodeId) : this.getAnyConnection();
|
|
3061
|
+
if (!socket) {
|
|
3062
|
+
throw new Error("No connection available");
|
|
3063
|
+
}
|
|
3064
|
+
socket.send(data);
|
|
3065
|
+
if (nodeId) {
|
|
3066
|
+
this.recordSuccess(nodeId);
|
|
3067
|
+
}
|
|
3068
|
+
return;
|
|
3069
|
+
} catch (error) {
|
|
3070
|
+
lastError = error;
|
|
3071
|
+
if (nodeId) {
|
|
3072
|
+
this.recordFailure(nodeId);
|
|
3073
|
+
}
|
|
3074
|
+
const errorCode = error?.code;
|
|
3075
|
+
if (this.isRetryableError(error)) {
|
|
3076
|
+
logger.debug(
|
|
3077
|
+
{ attempt, maxRetries, errorCode, nodeId },
|
|
3078
|
+
"Retryable error, will retry"
|
|
3079
|
+
);
|
|
3080
|
+
if (errorCode === "NOT_OWNER" && retryOnNotOwner) {
|
|
3081
|
+
await this.waitForPartitionMapUpdateInternal(2e3);
|
|
3082
|
+
} else if (errorCode === "CONNECTION_CLOSED" || !this.isConnected()) {
|
|
3083
|
+
await this.waitForConnectionInternal(5e3);
|
|
3084
|
+
}
|
|
3085
|
+
await this.delay(retryDelayMs * (attempt + 1));
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
throw error;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
throw new Error(
|
|
3092
|
+
`Operation failed after ${maxRetries} retries: ${lastError?.message}`
|
|
3093
|
+
);
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
3096
|
+
* Check if an error is retryable.
|
|
3097
|
+
*/
|
|
3098
|
+
isRetryableError(error) {
|
|
3099
|
+
const code = error?.code;
|
|
3100
|
+
const message = error?.message || "";
|
|
3101
|
+
return code === "NOT_OWNER" || code === "CONNECTION_CLOSED" || code === "TIMEOUT" || code === "ECONNRESET" || message.includes("No active connections") || message.includes("No connection available") || message.includes("No healthy connection");
|
|
3102
|
+
}
|
|
3103
|
+
/**
|
|
3104
|
+
* Wait for partition map update.
|
|
3105
|
+
*/
|
|
3106
|
+
waitForPartitionMapUpdateInternal(timeoutMs) {
|
|
3107
|
+
return new Promise((resolve) => {
|
|
3108
|
+
const timeout = setTimeout(resolve, timeoutMs);
|
|
3109
|
+
const handler2 = () => {
|
|
3110
|
+
clearTimeout(timeout);
|
|
3111
|
+
this.off("partitionMapUpdated", handler2);
|
|
3112
|
+
resolve();
|
|
3113
|
+
};
|
|
3114
|
+
this.on("partitionMapUpdated", handler2);
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
/**
|
|
3118
|
+
* Wait for at least one connection to be available.
|
|
3119
|
+
*/
|
|
3120
|
+
waitForConnectionInternal(timeoutMs) {
|
|
3121
|
+
return new Promise((resolve, reject) => {
|
|
3122
|
+
if (this.isConnected()) {
|
|
3123
|
+
resolve();
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
const timeout = setTimeout(() => {
|
|
3127
|
+
this.off("connected", handler2);
|
|
3128
|
+
reject(new Error("Connection timeout"));
|
|
3129
|
+
}, timeoutMs);
|
|
3130
|
+
const handler2 = () => {
|
|
3131
|
+
clearTimeout(timeout);
|
|
3132
|
+
this.off("connected", handler2);
|
|
3133
|
+
resolve();
|
|
3134
|
+
};
|
|
3135
|
+
this.on("connected", handler2);
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
/**
|
|
3139
|
+
* Helper delay function.
|
|
3140
|
+
*/
|
|
3141
|
+
delay(ms) {
|
|
3142
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3143
|
+
}
|
|
3144
|
+
// ============================================
|
|
3145
|
+
// Cluster-Specific Methods
|
|
3146
|
+
// ============================================
|
|
3147
|
+
/**
|
|
3148
|
+
* Initialize cluster connections
|
|
3149
|
+
*/
|
|
3150
|
+
async start() {
|
|
3151
|
+
if (this.initialized) return;
|
|
3152
|
+
logger.info({ seedNodes: this.config.seedNodes }, "Starting cluster client");
|
|
3153
|
+
for (let i = 0; i < this.config.seedNodes.length; i++) {
|
|
3154
|
+
const endpoint = this.config.seedNodes[i];
|
|
3155
|
+
const nodeId = `seed-${i}`;
|
|
3156
|
+
await this.connectionPool.addNode(nodeId, endpoint);
|
|
1406
3157
|
}
|
|
1407
|
-
|
|
1408
|
-
this.
|
|
3158
|
+
this.connectionPool.startHealthCheck();
|
|
3159
|
+
this.partitionRouter.startPeriodicRefresh();
|
|
3160
|
+
this.initialized = true;
|
|
3161
|
+
await this.waitForPartitionMap();
|
|
1409
3162
|
}
|
|
1410
3163
|
/**
|
|
1411
|
-
*
|
|
3164
|
+
* Set authentication token
|
|
1412
3165
|
*/
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
3166
|
+
setAuthToken(token) {
|
|
3167
|
+
this.connectionPool.setAuthToken(token);
|
|
3168
|
+
}
|
|
3169
|
+
/**
|
|
3170
|
+
* Send operation with automatic routing (legacy API for cluster operations).
|
|
3171
|
+
* @deprecated Use send(data, key) for IConnectionProvider interface
|
|
3172
|
+
*/
|
|
3173
|
+
sendMessage(key, message) {
|
|
3174
|
+
if (this.config.routingMode === "direct" && this.routingActive) {
|
|
3175
|
+
return this.sendDirect(key, message);
|
|
1418
3176
|
}
|
|
1419
|
-
this.
|
|
3177
|
+
return this.sendForward(message);
|
|
1420
3178
|
}
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
3179
|
+
/**
|
|
3180
|
+
* Send directly to partition owner
|
|
3181
|
+
*/
|
|
3182
|
+
sendDirect(key, message) {
|
|
3183
|
+
const connection = this.partitionRouter.routeToConnection(key);
|
|
3184
|
+
if (!connection) {
|
|
3185
|
+
logger.warn({ key }, "No route available for key");
|
|
3186
|
+
return false;
|
|
1425
3187
|
}
|
|
3188
|
+
const routedMessage = {
|
|
3189
|
+
...message,
|
|
3190
|
+
_routing: {
|
|
3191
|
+
partitionId: this.partitionRouter.getPartitionId(key),
|
|
3192
|
+
mapVersion: this.partitionRouter.getMapVersion()
|
|
3193
|
+
}
|
|
3194
|
+
};
|
|
3195
|
+
connection.socket.send((0, import_core5.serialize)(routedMessage));
|
|
3196
|
+
return true;
|
|
1426
3197
|
}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
3198
|
+
/**
|
|
3199
|
+
* Send to primary node for server-side forwarding
|
|
3200
|
+
*/
|
|
3201
|
+
sendForward(message) {
|
|
3202
|
+
return this.connectionPool.sendToPrimary(message);
|
|
3203
|
+
}
|
|
3204
|
+
/**
|
|
3205
|
+
* Send batch of operations with routing
|
|
3206
|
+
*/
|
|
3207
|
+
sendBatch(operations) {
|
|
3208
|
+
const results = /* @__PURE__ */ new Map();
|
|
3209
|
+
if (this.config.routingMode === "direct" && this.routingActive) {
|
|
3210
|
+
const nodeMessages = /* @__PURE__ */ new Map();
|
|
3211
|
+
for (const { key, message } of operations) {
|
|
3212
|
+
const routing = this.partitionRouter.route(key);
|
|
3213
|
+
const nodeId = routing?.nodeId ?? "primary";
|
|
3214
|
+
if (!nodeMessages.has(nodeId)) {
|
|
3215
|
+
nodeMessages.set(nodeId, []);
|
|
1438
3216
|
}
|
|
1439
|
-
|
|
3217
|
+
nodeMessages.get(nodeId).push({ key, message });
|
|
3218
|
+
}
|
|
3219
|
+
for (const [nodeId, messages] of nodeMessages) {
|
|
3220
|
+
let success;
|
|
3221
|
+
if (nodeId === "primary") {
|
|
3222
|
+
success = this.connectionPool.sendToPrimary({
|
|
3223
|
+
type: "OP_BATCH",
|
|
3224
|
+
payload: { ops: messages.map((m) => m.message) }
|
|
3225
|
+
});
|
|
3226
|
+
} else {
|
|
3227
|
+
success = this.connectionPool.send(nodeId, {
|
|
3228
|
+
type: "OP_BATCH",
|
|
3229
|
+
payload: { ops: messages.map((m) => m.message) }
|
|
3230
|
+
});
|
|
3231
|
+
}
|
|
3232
|
+
for (const { key } of messages) {
|
|
3233
|
+
results.set(key, success);
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
} else {
|
|
3237
|
+
const success = this.connectionPool.sendToPrimary({
|
|
3238
|
+
type: "OP_BATCH",
|
|
3239
|
+
payload: { ops: operations.map((o) => o.message) }
|
|
1440
3240
|
});
|
|
3241
|
+
for (const { key } of operations) {
|
|
3242
|
+
results.set(key, success);
|
|
3243
|
+
}
|
|
1441
3244
|
}
|
|
1442
3245
|
return results;
|
|
1443
3246
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
3247
|
+
/**
|
|
3248
|
+
* Get connection pool health status
|
|
3249
|
+
*/
|
|
3250
|
+
getHealthStatus() {
|
|
3251
|
+
return this.connectionPool.getHealthStatus();
|
|
1446
3252
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
3253
|
+
/**
|
|
3254
|
+
* Get partition router stats
|
|
3255
|
+
*/
|
|
3256
|
+
getRouterStats() {
|
|
3257
|
+
return this.partitionRouter.getStats();
|
|
1449
3258
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
this.fencingToken = null;
|
|
1456
|
-
this._isLocked = false;
|
|
1457
|
-
this.syncEngine = syncEngine;
|
|
1458
|
-
this.name = name;
|
|
3259
|
+
/**
|
|
3260
|
+
* Get routing metrics for monitoring smart routing effectiveness.
|
|
3261
|
+
*/
|
|
3262
|
+
getRoutingMetrics() {
|
|
3263
|
+
return { ...this.routingMetrics };
|
|
1459
3264
|
}
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
}
|
|
3265
|
+
/**
|
|
3266
|
+
* Reset routing metrics counters.
|
|
3267
|
+
* Useful for monitoring intervals.
|
|
3268
|
+
*/
|
|
3269
|
+
resetRoutingMetrics() {
|
|
3270
|
+
this.routingMetrics.directRoutes = 0;
|
|
3271
|
+
this.routingMetrics.fallbackRoutes = 0;
|
|
3272
|
+
this.routingMetrics.partitionMisses = 0;
|
|
3273
|
+
this.routingMetrics.totalRoutes = 0;
|
|
1470
3274
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
} finally {
|
|
1477
|
-
this._isLocked = false;
|
|
1478
|
-
this.fencingToken = null;
|
|
1479
|
-
}
|
|
3275
|
+
/**
|
|
3276
|
+
* Check if cluster routing is active
|
|
3277
|
+
*/
|
|
3278
|
+
isRoutingActive() {
|
|
3279
|
+
return this.routingActive;
|
|
1480
3280
|
}
|
|
1481
|
-
|
|
1482
|
-
|
|
3281
|
+
/**
|
|
3282
|
+
* Get list of connected nodes
|
|
3283
|
+
*/
|
|
3284
|
+
getConnectedNodes() {
|
|
3285
|
+
return this.connectionPool.getConnectedNodes();
|
|
1483
3286
|
}
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
this.listeners = /* @__PURE__ */ new Set();
|
|
1490
|
-
this.engine = engine;
|
|
1491
|
-
this.topic = topic;
|
|
3287
|
+
/**
|
|
3288
|
+
* Check if cluster client is initialized
|
|
3289
|
+
*/
|
|
3290
|
+
isInitialized() {
|
|
3291
|
+
return this.initialized;
|
|
1492
3292
|
}
|
|
1493
|
-
|
|
1494
|
-
|
|
3293
|
+
/**
|
|
3294
|
+
* Force refresh of partition map
|
|
3295
|
+
*/
|
|
3296
|
+
async refreshPartitionMap() {
|
|
3297
|
+
await this.partitionRouter.refreshPartitionMap();
|
|
1495
3298
|
}
|
|
1496
3299
|
/**
|
|
1497
|
-
*
|
|
3300
|
+
* Shutdown cluster client (IConnectionProvider interface).
|
|
1498
3301
|
*/
|
|
1499
|
-
|
|
1500
|
-
this.
|
|
3302
|
+
async close() {
|
|
3303
|
+
this.partitionRouter.close();
|
|
3304
|
+
this.connectionPool.close();
|
|
3305
|
+
this.initialized = false;
|
|
3306
|
+
this.routingActive = false;
|
|
3307
|
+
logger.info("Cluster client closed");
|
|
3308
|
+
}
|
|
3309
|
+
// ============================================
|
|
3310
|
+
// Internal Access for TopGunClient
|
|
3311
|
+
// ============================================
|
|
3312
|
+
/**
|
|
3313
|
+
* Get the connection pool (for internal use)
|
|
3314
|
+
*/
|
|
3315
|
+
getConnectionPool() {
|
|
3316
|
+
return this.connectionPool;
|
|
1501
3317
|
}
|
|
1502
3318
|
/**
|
|
1503
|
-
*
|
|
3319
|
+
* Get the partition router (for internal use)
|
|
1504
3320
|
*/
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
3321
|
+
getPartitionRouter() {
|
|
3322
|
+
return this.partitionRouter;
|
|
3323
|
+
}
|
|
3324
|
+
/**
|
|
3325
|
+
* Get any healthy WebSocket connection (IConnectionProvider interface).
|
|
3326
|
+
* @throws Error if not connected
|
|
3327
|
+
*/
|
|
3328
|
+
getAnyConnection() {
|
|
3329
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
3330
|
+
if (!conn?.socket) {
|
|
3331
|
+
throw new Error("No healthy connection available");
|
|
1508
3332
|
}
|
|
1509
|
-
|
|
1510
|
-
return () => this.unsubscribe(callback);
|
|
3333
|
+
return conn.socket;
|
|
1511
3334
|
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
3335
|
+
/**
|
|
3336
|
+
* Get any healthy WebSocket connection, or null if none available.
|
|
3337
|
+
* Use this for optional connection checks.
|
|
3338
|
+
*/
|
|
3339
|
+
getAnyConnectionOrNull() {
|
|
3340
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
3341
|
+
return conn?.socket ?? null;
|
|
3342
|
+
}
|
|
3343
|
+
// ============================================
|
|
3344
|
+
// Circuit Breaker Methods
|
|
3345
|
+
// ============================================
|
|
3346
|
+
/**
|
|
3347
|
+
* Get circuit breaker state for a node.
|
|
3348
|
+
*/
|
|
3349
|
+
getCircuit(nodeId) {
|
|
3350
|
+
let circuit = this.circuits.get(nodeId);
|
|
3351
|
+
if (!circuit) {
|
|
3352
|
+
circuit = { failures: 0, lastFailure: 0, state: "closed" };
|
|
3353
|
+
this.circuits.set(nodeId, circuit);
|
|
1516
3354
|
}
|
|
3355
|
+
return circuit;
|
|
1517
3356
|
}
|
|
1518
3357
|
/**
|
|
1519
|
-
*
|
|
3358
|
+
* Check if a node can be used (circuit not open).
|
|
1520
3359
|
*/
|
|
1521
|
-
|
|
1522
|
-
this.
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
3360
|
+
canUseNode(nodeId) {
|
|
3361
|
+
const circuit = this.getCircuit(nodeId);
|
|
3362
|
+
if (circuit.state === "closed") {
|
|
3363
|
+
return true;
|
|
3364
|
+
}
|
|
3365
|
+
if (circuit.state === "open") {
|
|
3366
|
+
if (Date.now() - circuit.lastFailure > this.circuitBreakerConfig.resetTimeoutMs) {
|
|
3367
|
+
circuit.state = "half-open";
|
|
3368
|
+
logger.debug({ nodeId }, "Circuit breaker half-open, allowing test request");
|
|
3369
|
+
this.emit("circuit:half-open", nodeId);
|
|
3370
|
+
return true;
|
|
3371
|
+
}
|
|
3372
|
+
return false;
|
|
3373
|
+
}
|
|
3374
|
+
return true;
|
|
3375
|
+
}
|
|
3376
|
+
/**
|
|
3377
|
+
* Record a successful operation to a node.
|
|
3378
|
+
* Resets circuit breaker on success.
|
|
3379
|
+
*/
|
|
3380
|
+
recordSuccess(nodeId) {
|
|
3381
|
+
const circuit = this.getCircuit(nodeId);
|
|
3382
|
+
const wasOpen = circuit.state !== "closed";
|
|
3383
|
+
circuit.failures = 0;
|
|
3384
|
+
circuit.state = "closed";
|
|
3385
|
+
if (wasOpen) {
|
|
3386
|
+
logger.info({ nodeId }, "Circuit breaker closed after success");
|
|
3387
|
+
this.emit("circuit:closed", nodeId);
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* Record a failed operation to a node.
|
|
3392
|
+
* Opens circuit breaker after threshold failures.
|
|
3393
|
+
*/
|
|
3394
|
+
recordFailure(nodeId) {
|
|
3395
|
+
const circuit = this.getCircuit(nodeId);
|
|
3396
|
+
circuit.failures++;
|
|
3397
|
+
circuit.lastFailure = Date.now();
|
|
3398
|
+
if (circuit.failures >= this.circuitBreakerConfig.failureThreshold) {
|
|
3399
|
+
if (circuit.state !== "open") {
|
|
3400
|
+
circuit.state = "open";
|
|
3401
|
+
logger.warn({ nodeId, failures: circuit.failures }, "Circuit breaker opened");
|
|
3402
|
+
this.emit("circuit:open", nodeId);
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
/**
|
|
3407
|
+
* Get all circuit breaker states.
|
|
3408
|
+
*/
|
|
3409
|
+
getCircuitStates() {
|
|
3410
|
+
return new Map(this.circuits);
|
|
3411
|
+
}
|
|
3412
|
+
/**
|
|
3413
|
+
* Reset circuit breaker for a specific node.
|
|
3414
|
+
*/
|
|
3415
|
+
resetCircuit(nodeId) {
|
|
3416
|
+
this.circuits.delete(nodeId);
|
|
3417
|
+
logger.debug({ nodeId }, "Circuit breaker reset");
|
|
3418
|
+
}
|
|
3419
|
+
/**
|
|
3420
|
+
* Reset all circuit breakers.
|
|
3421
|
+
*/
|
|
3422
|
+
resetAllCircuits() {
|
|
3423
|
+
this.circuits.clear();
|
|
3424
|
+
logger.debug("All circuit breakers reset");
|
|
3425
|
+
}
|
|
3426
|
+
// ============================================
|
|
3427
|
+
// Private Methods
|
|
3428
|
+
// ============================================
|
|
3429
|
+
setupEventHandlers() {
|
|
3430
|
+
this.connectionPool.on("node:connected", (nodeId) => {
|
|
3431
|
+
logger.debug({ nodeId }, "Node connected");
|
|
3432
|
+
if (this.partitionRouter.getMapVersion() === 0) {
|
|
3433
|
+
this.requestPartitionMapFromNode(nodeId);
|
|
3434
|
+
}
|
|
3435
|
+
if (this.connectionPool.getConnectedNodes().length === 1) {
|
|
3436
|
+
this.emit("connected");
|
|
3437
|
+
}
|
|
3438
|
+
});
|
|
3439
|
+
this.connectionPool.on("node:disconnected", (nodeId, reason) => {
|
|
3440
|
+
logger.debug({ nodeId, reason }, "Node disconnected");
|
|
3441
|
+
if (this.connectionPool.getConnectedNodes().length === 0) {
|
|
3442
|
+
this.routingActive = false;
|
|
3443
|
+
this.emit("disconnected", reason);
|
|
3444
|
+
}
|
|
3445
|
+
});
|
|
3446
|
+
this.connectionPool.on("node:unhealthy", (nodeId, reason) => {
|
|
3447
|
+
logger.warn({ nodeId, reason }, "Node unhealthy");
|
|
3448
|
+
});
|
|
3449
|
+
this.connectionPool.on("error", (nodeId, error) => {
|
|
3450
|
+
this.emit("error", error);
|
|
3451
|
+
});
|
|
3452
|
+
this.connectionPool.on("message", (nodeId, data) => {
|
|
3453
|
+
this.emit("message", nodeId, data);
|
|
3454
|
+
});
|
|
3455
|
+
this.partitionRouter.on("partitionMap:updated", (version, changesCount) => {
|
|
3456
|
+
if (!this.routingActive && this.partitionRouter.hasPartitionMap()) {
|
|
3457
|
+
this.routingActive = true;
|
|
3458
|
+
logger.info({ version }, "Direct routing activated");
|
|
3459
|
+
this.emit("routing:active");
|
|
1527
3460
|
}
|
|
3461
|
+
this.emit("partitionMap:ready", version);
|
|
3462
|
+
this.emit("partitionMapUpdated");
|
|
3463
|
+
});
|
|
3464
|
+
this.partitionRouter.on("routing:miss", (key, expected, actual) => {
|
|
3465
|
+
logger.debug({ key, expected, actual }, "Routing miss detected");
|
|
3466
|
+
});
|
|
3467
|
+
}
|
|
3468
|
+
async waitForPartitionMap(timeoutMs = 1e4) {
|
|
3469
|
+
if (this.partitionRouter.hasPartitionMap()) {
|
|
3470
|
+
this.routingActive = true;
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
return new Promise((resolve) => {
|
|
3474
|
+
const timeout = setTimeout(() => {
|
|
3475
|
+
this.partitionRouter.off("partitionMap:updated", onUpdate);
|
|
3476
|
+
logger.warn("Partition map not received, using fallback routing");
|
|
3477
|
+
resolve();
|
|
3478
|
+
}, timeoutMs);
|
|
3479
|
+
const onUpdate = () => {
|
|
3480
|
+
clearTimeout(timeout);
|
|
3481
|
+
this.partitionRouter.off("partitionMap:updated", onUpdate);
|
|
3482
|
+
this.routingActive = true;
|
|
3483
|
+
resolve();
|
|
3484
|
+
};
|
|
3485
|
+
this.partitionRouter.once("partitionMap:updated", onUpdate);
|
|
1528
3486
|
});
|
|
1529
3487
|
}
|
|
1530
3488
|
};
|
|
1531
3489
|
|
|
1532
3490
|
// src/TopGunClient.ts
|
|
3491
|
+
var DEFAULT_CLUSTER_CONFIG = {
|
|
3492
|
+
connectionsPerNode: 1,
|
|
3493
|
+
smartRouting: true,
|
|
3494
|
+
partitionMapRefreshMs: 3e4,
|
|
3495
|
+
connectionTimeoutMs: 5e3,
|
|
3496
|
+
retryAttempts: 3
|
|
3497
|
+
};
|
|
1533
3498
|
var TopGunClient = class {
|
|
1534
3499
|
constructor(config) {
|
|
1535
3500
|
this.maps = /* @__PURE__ */ new Map();
|
|
1536
3501
|
this.topicHandles = /* @__PURE__ */ new Map();
|
|
3502
|
+
if (config.serverUrl && config.cluster) {
|
|
3503
|
+
throw new Error("Cannot specify both serverUrl and cluster config");
|
|
3504
|
+
}
|
|
3505
|
+
if (!config.serverUrl && !config.cluster) {
|
|
3506
|
+
throw new Error("Must specify either serverUrl or cluster config");
|
|
3507
|
+
}
|
|
1537
3508
|
this.nodeId = config.nodeId || crypto.randomUUID();
|
|
1538
3509
|
this.storageAdapter = config.storage;
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
3510
|
+
this.isClusterMode = !!config.cluster;
|
|
3511
|
+
if (config.cluster) {
|
|
3512
|
+
if (!config.cluster.seeds || config.cluster.seeds.length === 0) {
|
|
3513
|
+
throw new Error("Cluster config requires at least one seed node");
|
|
3514
|
+
}
|
|
3515
|
+
this.clusterConfig = {
|
|
3516
|
+
seeds: config.cluster.seeds,
|
|
3517
|
+
connectionsPerNode: config.cluster.connectionsPerNode ?? DEFAULT_CLUSTER_CONFIG.connectionsPerNode,
|
|
3518
|
+
smartRouting: config.cluster.smartRouting ?? DEFAULT_CLUSTER_CONFIG.smartRouting,
|
|
3519
|
+
partitionMapRefreshMs: config.cluster.partitionMapRefreshMs ?? DEFAULT_CLUSTER_CONFIG.partitionMapRefreshMs,
|
|
3520
|
+
connectionTimeoutMs: config.cluster.connectionTimeoutMs ?? DEFAULT_CLUSTER_CONFIG.connectionTimeoutMs,
|
|
3521
|
+
retryAttempts: config.cluster.retryAttempts ?? DEFAULT_CLUSTER_CONFIG.retryAttempts
|
|
3522
|
+
};
|
|
3523
|
+
this.clusterClient = new ClusterClient({
|
|
3524
|
+
enabled: true,
|
|
3525
|
+
seedNodes: this.clusterConfig.seeds,
|
|
3526
|
+
routingMode: this.clusterConfig.smartRouting ? "direct" : "forward",
|
|
3527
|
+
connectionPool: {
|
|
3528
|
+
maxConnectionsPerNode: this.clusterConfig.connectionsPerNode,
|
|
3529
|
+
connectionTimeoutMs: this.clusterConfig.connectionTimeoutMs
|
|
3530
|
+
},
|
|
3531
|
+
routing: {
|
|
3532
|
+
mapRefreshIntervalMs: this.clusterConfig.partitionMapRefreshMs
|
|
3533
|
+
}
|
|
3534
|
+
});
|
|
3535
|
+
this.syncEngine = new SyncEngine({
|
|
3536
|
+
nodeId: this.nodeId,
|
|
3537
|
+
connectionProvider: this.clusterClient,
|
|
3538
|
+
storageAdapter: this.storageAdapter,
|
|
3539
|
+
backoff: config.backoff,
|
|
3540
|
+
backpressure: config.backpressure
|
|
3541
|
+
});
|
|
3542
|
+
logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
|
|
3543
|
+
} else {
|
|
3544
|
+
this.syncEngine = new SyncEngine({
|
|
3545
|
+
nodeId: this.nodeId,
|
|
3546
|
+
serverUrl: config.serverUrl,
|
|
3547
|
+
storageAdapter: this.storageAdapter,
|
|
3548
|
+
backoff: config.backoff,
|
|
3549
|
+
backpressure: config.backpressure
|
|
3550
|
+
});
|
|
3551
|
+
logger.info({ serverUrl: config.serverUrl }, "TopGunClient initialized in single-server mode");
|
|
3552
|
+
}
|
|
1547
3553
|
}
|
|
1548
3554
|
async start() {
|
|
1549
3555
|
await this.storageAdapter.initialize("topgun_offline_db");
|
|
@@ -1585,12 +3591,12 @@ var TopGunClient = class {
|
|
|
1585
3591
|
getMap(name) {
|
|
1586
3592
|
if (this.maps.has(name)) {
|
|
1587
3593
|
const map = this.maps.get(name);
|
|
1588
|
-
if (map instanceof
|
|
3594
|
+
if (map instanceof import_core6.LWWMap) {
|
|
1589
3595
|
return map;
|
|
1590
3596
|
}
|
|
1591
3597
|
throw new Error(`Map ${name} exists but is not an LWWMap`);
|
|
1592
3598
|
}
|
|
1593
|
-
const lwwMap = new
|
|
3599
|
+
const lwwMap = new import_core6.LWWMap(this.syncEngine.getHLC());
|
|
1594
3600
|
this.maps.set(name, lwwMap);
|
|
1595
3601
|
this.syncEngine.registerMap(name, lwwMap);
|
|
1596
3602
|
this.storageAdapter.getAllKeys().then(async (keys) => {
|
|
@@ -1629,12 +3635,12 @@ var TopGunClient = class {
|
|
|
1629
3635
|
getORMap(name) {
|
|
1630
3636
|
if (this.maps.has(name)) {
|
|
1631
3637
|
const map = this.maps.get(name);
|
|
1632
|
-
if (map instanceof
|
|
3638
|
+
if (map instanceof import_core6.ORMap) {
|
|
1633
3639
|
return map;
|
|
1634
3640
|
}
|
|
1635
3641
|
throw new Error(`Map ${name} exists but is not an ORMap`);
|
|
1636
3642
|
}
|
|
1637
|
-
const orMap = new
|
|
3643
|
+
const orMap = new import_core6.ORMap(this.syncEngine.getHLC());
|
|
1638
3644
|
this.maps.set(name, orMap);
|
|
1639
3645
|
this.syncEngine.registerMap(name, orMap);
|
|
1640
3646
|
this.restoreORMap(name, orMap);
|
|
@@ -1703,9 +3709,69 @@ var TopGunClient = class {
|
|
|
1703
3709
|
* Closes the client, disconnecting from the server and cleaning up resources.
|
|
1704
3710
|
*/
|
|
1705
3711
|
close() {
|
|
3712
|
+
if (this.clusterClient) {
|
|
3713
|
+
this.clusterClient.close();
|
|
3714
|
+
}
|
|
1706
3715
|
this.syncEngine.close();
|
|
1707
3716
|
}
|
|
1708
3717
|
// ============================================
|
|
3718
|
+
// Cluster Mode API
|
|
3719
|
+
// ============================================
|
|
3720
|
+
/**
|
|
3721
|
+
* Check if running in cluster mode
|
|
3722
|
+
*/
|
|
3723
|
+
isCluster() {
|
|
3724
|
+
return this.isClusterMode;
|
|
3725
|
+
}
|
|
3726
|
+
/**
|
|
3727
|
+
* Get list of connected cluster nodes (cluster mode only)
|
|
3728
|
+
* @returns Array of connected node IDs, or empty array in single-server mode
|
|
3729
|
+
*/
|
|
3730
|
+
getConnectedNodes() {
|
|
3731
|
+
if (!this.clusterClient) return [];
|
|
3732
|
+
return this.clusterClient.getConnectedNodes();
|
|
3733
|
+
}
|
|
3734
|
+
/**
|
|
3735
|
+
* Get the current partition map version (cluster mode only)
|
|
3736
|
+
* @returns Partition map version, or 0 in single-server mode
|
|
3737
|
+
*/
|
|
3738
|
+
getPartitionMapVersion() {
|
|
3739
|
+
if (!this.clusterClient) return 0;
|
|
3740
|
+
return this.clusterClient.getRouterStats().mapVersion;
|
|
3741
|
+
}
|
|
3742
|
+
/**
|
|
3743
|
+
* Check if direct routing is active (cluster mode only)
|
|
3744
|
+
* Direct routing sends operations directly to partition owners.
|
|
3745
|
+
* @returns true if routing is active, false otherwise
|
|
3746
|
+
*/
|
|
3747
|
+
isRoutingActive() {
|
|
3748
|
+
if (!this.clusterClient) return false;
|
|
3749
|
+
return this.clusterClient.isRoutingActive();
|
|
3750
|
+
}
|
|
3751
|
+
/**
|
|
3752
|
+
* Get health status for all cluster nodes (cluster mode only)
|
|
3753
|
+
* @returns Map of node IDs to their health status
|
|
3754
|
+
*/
|
|
3755
|
+
getClusterHealth() {
|
|
3756
|
+
if (!this.clusterClient) return /* @__PURE__ */ new Map();
|
|
3757
|
+
return this.clusterClient.getHealthStatus();
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* Force refresh of partition map (cluster mode only)
|
|
3761
|
+
* Use this after detecting routing errors.
|
|
3762
|
+
*/
|
|
3763
|
+
async refreshPartitionMap() {
|
|
3764
|
+
if (!this.clusterClient) return;
|
|
3765
|
+
await this.clusterClient.refreshPartitionMap();
|
|
3766
|
+
}
|
|
3767
|
+
/**
|
|
3768
|
+
* Get cluster router statistics (cluster mode only)
|
|
3769
|
+
*/
|
|
3770
|
+
getClusterStats() {
|
|
3771
|
+
if (!this.clusterClient) return null;
|
|
3772
|
+
return this.clusterClient.getRouterStats();
|
|
3773
|
+
}
|
|
3774
|
+
// ============================================
|
|
1709
3775
|
// Connection State API
|
|
1710
3776
|
// ============================================
|
|
1711
3777
|
/**
|
|
@@ -2056,14 +4122,14 @@ var CollectionWrapper = class {
|
|
|
2056
4122
|
};
|
|
2057
4123
|
|
|
2058
4124
|
// src/crypto/EncryptionManager.ts
|
|
2059
|
-
var
|
|
4125
|
+
var import_core7 = require("@topgunbuild/core");
|
|
2060
4126
|
var _EncryptionManager = class _EncryptionManager {
|
|
2061
4127
|
/**
|
|
2062
4128
|
* Encrypts data using AES-GCM.
|
|
2063
4129
|
* Serializes data to MessagePack before encryption.
|
|
2064
4130
|
*/
|
|
2065
4131
|
static async encrypt(key, data) {
|
|
2066
|
-
const encoded = (0,
|
|
4132
|
+
const encoded = (0, import_core7.serialize)(data);
|
|
2067
4133
|
const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
|
|
2068
4134
|
const ciphertext = await window.crypto.subtle.encrypt(
|
|
2069
4135
|
{
|
|
@@ -2092,7 +4158,7 @@ var _EncryptionManager = class _EncryptionManager {
|
|
|
2092
4158
|
key,
|
|
2093
4159
|
record.data
|
|
2094
4160
|
);
|
|
2095
|
-
return (0,
|
|
4161
|
+
return (0, import_core7.deserialize)(new Uint8Array(plaintextBuffer));
|
|
2096
4162
|
} catch (err) {
|
|
2097
4163
|
console.error("Decryption failed", err);
|
|
2098
4164
|
throw new Error("Failed to decrypt data: " + err);
|
|
@@ -2216,16 +4282,21 @@ var EncryptedStorageAdapter = class {
|
|
|
2216
4282
|
};
|
|
2217
4283
|
|
|
2218
4284
|
// src/index.ts
|
|
2219
|
-
var
|
|
4285
|
+
var import_core8 = require("@topgunbuild/core");
|
|
2220
4286
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2221
4287
|
0 && (module.exports = {
|
|
2222
4288
|
BackpressureError,
|
|
4289
|
+
ClusterClient,
|
|
4290
|
+
ConnectionPool,
|
|
2223
4291
|
DEFAULT_BACKPRESSURE_CONFIG,
|
|
4292
|
+
DEFAULT_CLUSTER_CONFIG,
|
|
2224
4293
|
EncryptedStorageAdapter,
|
|
2225
4294
|
IDBAdapter,
|
|
2226
4295
|
LWWMap,
|
|
4296
|
+
PartitionRouter,
|
|
2227
4297
|
Predicates,
|
|
2228
4298
|
QueryHandle,
|
|
4299
|
+
SingleServerProvider,
|
|
2229
4300
|
SyncEngine,
|
|
2230
4301
|
SyncState,
|
|
2231
4302
|
SyncStateMachine,
|