@topgunbuild/client 0.2.1 → 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 +814 -10
- package/dist/index.d.ts +814 -10
- package/dist/index.js +2048 -79
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2045 -70
- 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,
|
|
@@ -285,8 +517,11 @@ var SyncEngine = class {
|
|
|
285
517
|
this.backpressureListeners = /* @__PURE__ */ new Map();
|
|
286
518
|
// Write Concern state (Phase 5.01)
|
|
287
519
|
this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
|
|
520
|
+
if (!config.serverUrl && !config.connectionProvider) {
|
|
521
|
+
throw new Error("SyncEngine requires either serverUrl or connectionProvider");
|
|
522
|
+
}
|
|
288
523
|
this.nodeId = config.nodeId;
|
|
289
|
-
this.serverUrl = config.serverUrl;
|
|
524
|
+
this.serverUrl = config.serverUrl || "";
|
|
290
525
|
this.storageAdapter = config.storageAdapter;
|
|
291
526
|
this.hlc = new import_core.HLC(this.nodeId);
|
|
292
527
|
this.stateMachine = new SyncStateMachine();
|
|
@@ -303,7 +538,15 @@ var SyncEngine = class {
|
|
|
303
538
|
...DEFAULT_BACKPRESSURE_CONFIG,
|
|
304
539
|
...config.backpressure
|
|
305
540
|
};
|
|
306
|
-
|
|
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
|
+
}
|
|
307
550
|
this.loadOpLog();
|
|
308
551
|
}
|
|
309
552
|
// ============================================
|
|
@@ -354,6 +597,65 @@ var SyncEngine = class {
|
|
|
354
597
|
// ============================================
|
|
355
598
|
// Connection Management
|
|
356
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
|
+
*/
|
|
357
659
|
initConnection() {
|
|
358
660
|
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
359
661
|
this.websocket = new WebSocket(this.serverUrl);
|
|
@@ -429,6 +731,40 @@ var SyncEngine = class {
|
|
|
429
731
|
resetBackoff() {
|
|
430
732
|
this.backoffAttempt = 0;
|
|
431
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
|
+
}
|
|
432
768
|
async loadOpLog() {
|
|
433
769
|
const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
|
|
434
770
|
if (storedTimestamp) {
|
|
@@ -475,36 +811,34 @@ var SyncEngine = class {
|
|
|
475
811
|
const pending = this.opLog.filter((op) => !op.synced);
|
|
476
812
|
if (pending.length === 0) return;
|
|
477
813
|
logger.info({ count: pending.length }, "Syncing pending operations");
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}));
|
|
485
|
-
}
|
|
814
|
+
this.sendMessage({
|
|
815
|
+
type: "OP_BATCH",
|
|
816
|
+
payload: {
|
|
817
|
+
ops: pending
|
|
818
|
+
}
|
|
819
|
+
});
|
|
486
820
|
}
|
|
487
821
|
startMerkleSync() {
|
|
488
822
|
for (const [mapName, map] of this.maps) {
|
|
489
823
|
if (map instanceof import_core.LWWMap) {
|
|
490
824
|
logger.info({ mapName }, "Starting Merkle sync for LWWMap");
|
|
491
|
-
this.
|
|
825
|
+
this.sendMessage({
|
|
492
826
|
type: "SYNC_INIT",
|
|
493
827
|
mapName,
|
|
494
828
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
495
|
-
})
|
|
829
|
+
});
|
|
496
830
|
} else if (map instanceof import_core.ORMap) {
|
|
497
831
|
logger.info({ mapName }, "Starting Merkle sync for ORMap");
|
|
498
832
|
const tree = map.getMerkleTree();
|
|
499
833
|
const rootHash = tree.getRootHash();
|
|
500
834
|
const bucketHashes = tree.getBuckets("");
|
|
501
|
-
this.
|
|
835
|
+
this.sendMessage({
|
|
502
836
|
type: "ORMAP_SYNC_INIT",
|
|
503
837
|
mapName,
|
|
504
838
|
rootHash,
|
|
505
839
|
bucketHashes,
|
|
506
840
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
507
|
-
})
|
|
841
|
+
});
|
|
508
842
|
}
|
|
509
843
|
}
|
|
510
844
|
}
|
|
@@ -545,10 +879,10 @@ var SyncEngine = class {
|
|
|
545
879
|
}
|
|
546
880
|
const token = this.authToken;
|
|
547
881
|
if (!token) return;
|
|
548
|
-
this.
|
|
882
|
+
this.sendMessage({
|
|
549
883
|
type: "AUTH",
|
|
550
884
|
token
|
|
551
|
-
})
|
|
885
|
+
});
|
|
552
886
|
}
|
|
553
887
|
subscribeToQuery(query) {
|
|
554
888
|
this.queries.set(query.id, query);
|
|
@@ -565,27 +899,27 @@ var SyncEngine = class {
|
|
|
565
899
|
unsubscribeFromTopic(topic) {
|
|
566
900
|
this.topics.delete(topic);
|
|
567
901
|
if (this.isAuthenticated()) {
|
|
568
|
-
this.
|
|
902
|
+
this.sendMessage({
|
|
569
903
|
type: "TOPIC_UNSUB",
|
|
570
904
|
payload: { topic }
|
|
571
|
-
})
|
|
905
|
+
});
|
|
572
906
|
}
|
|
573
907
|
}
|
|
574
908
|
publishTopic(topic, data) {
|
|
575
909
|
if (this.isAuthenticated()) {
|
|
576
|
-
this.
|
|
910
|
+
this.sendMessage({
|
|
577
911
|
type: "TOPIC_PUB",
|
|
578
912
|
payload: { topic, data }
|
|
579
|
-
})
|
|
913
|
+
});
|
|
580
914
|
} else {
|
|
581
915
|
logger.warn({ topic }, "Dropped topic publish (offline)");
|
|
582
916
|
}
|
|
583
917
|
}
|
|
584
918
|
sendTopicSubscription(topic) {
|
|
585
|
-
this.
|
|
919
|
+
this.sendMessage({
|
|
586
920
|
type: "TOPIC_SUB",
|
|
587
921
|
payload: { topic }
|
|
588
|
-
})
|
|
922
|
+
});
|
|
589
923
|
}
|
|
590
924
|
/**
|
|
591
925
|
* Executes a query against local storage immediately
|
|
@@ -622,21 +956,21 @@ var SyncEngine = class {
|
|
|
622
956
|
unsubscribeFromQuery(queryId) {
|
|
623
957
|
this.queries.delete(queryId);
|
|
624
958
|
if (this.isAuthenticated()) {
|
|
625
|
-
this.
|
|
959
|
+
this.sendMessage({
|
|
626
960
|
type: "QUERY_UNSUB",
|
|
627
961
|
payload: { queryId }
|
|
628
|
-
})
|
|
962
|
+
});
|
|
629
963
|
}
|
|
630
964
|
}
|
|
631
965
|
sendQuerySubscription(query) {
|
|
632
|
-
this.
|
|
966
|
+
this.sendMessage({
|
|
633
967
|
type: "QUERY_SUB",
|
|
634
968
|
payload: {
|
|
635
969
|
queryId: query.id,
|
|
636
970
|
mapName: query.getMapName(),
|
|
637
971
|
query: query.getFilter()
|
|
638
972
|
}
|
|
639
|
-
})
|
|
973
|
+
});
|
|
640
974
|
}
|
|
641
975
|
requestLock(name, requestId, ttl) {
|
|
642
976
|
if (!this.isAuthenticated()) {
|
|
@@ -651,10 +985,15 @@ var SyncEngine = class {
|
|
|
651
985
|
}, 3e4);
|
|
652
986
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
653
987
|
try {
|
|
654
|
-
this.
|
|
988
|
+
const sent = this.sendMessage({
|
|
655
989
|
type: "LOCK_REQUEST",
|
|
656
990
|
payload: { requestId, name, ttl }
|
|
657
|
-
})
|
|
991
|
+
});
|
|
992
|
+
if (!sent) {
|
|
993
|
+
clearTimeout(timer);
|
|
994
|
+
this.pendingLockRequests.delete(requestId);
|
|
995
|
+
reject(new Error("Failed to send lock request"));
|
|
996
|
+
}
|
|
658
997
|
} catch (e) {
|
|
659
998
|
clearTimeout(timer);
|
|
660
999
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -673,10 +1012,15 @@ var SyncEngine = class {
|
|
|
673
1012
|
}, 5e3);
|
|
674
1013
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
675
1014
|
try {
|
|
676
|
-
this.
|
|
1015
|
+
const sent = this.sendMessage({
|
|
677
1016
|
type: "LOCK_RELEASE",
|
|
678
1017
|
payload: { requestId, name, fencingToken }
|
|
679
|
-
})
|
|
1018
|
+
});
|
|
1019
|
+
if (!sent) {
|
|
1020
|
+
clearTimeout(timer);
|
|
1021
|
+
this.pendingLockRequests.delete(requestId);
|
|
1022
|
+
resolve(false);
|
|
1023
|
+
}
|
|
680
1024
|
} catch (e) {
|
|
681
1025
|
clearTimeout(timer);
|
|
682
1026
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -855,11 +1199,11 @@ var SyncEngine = class {
|
|
|
855
1199
|
const { mapName } = message.payload;
|
|
856
1200
|
logger.warn({ mapName }, "Sync Reset Required due to GC Age");
|
|
857
1201
|
await this.resetMap(mapName);
|
|
858
|
-
this.
|
|
1202
|
+
this.sendMessage({
|
|
859
1203
|
type: "SYNC_INIT",
|
|
860
1204
|
mapName,
|
|
861
1205
|
lastSyncTimestamp: 0
|
|
862
|
-
})
|
|
1206
|
+
});
|
|
863
1207
|
break;
|
|
864
1208
|
}
|
|
865
1209
|
case "SYNC_RESP_ROOT": {
|
|
@@ -869,10 +1213,10 @@ var SyncEngine = class {
|
|
|
869
1213
|
const localRootHash = map.getMerkleTree().getRootHash();
|
|
870
1214
|
if (localRootHash !== rootHash) {
|
|
871
1215
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
|
|
872
|
-
this.
|
|
1216
|
+
this.sendMessage({
|
|
873
1217
|
type: "MERKLE_REQ_BUCKET",
|
|
874
1218
|
payload: { mapName, path: "" }
|
|
875
|
-
})
|
|
1219
|
+
});
|
|
876
1220
|
} else {
|
|
877
1221
|
logger.info({ mapName }, "Map is in sync");
|
|
878
1222
|
}
|
|
@@ -894,10 +1238,10 @@ var SyncEngine = class {
|
|
|
894
1238
|
const localHash = localBuckets[bucketKey] || 0;
|
|
895
1239
|
if (localHash !== remoteHash) {
|
|
896
1240
|
const newPath = path + bucketKey;
|
|
897
|
-
this.
|
|
1241
|
+
this.sendMessage({
|
|
898
1242
|
type: "MERKLE_REQ_BUCKET",
|
|
899
1243
|
payload: { mapName, path: newPath }
|
|
900
|
-
})
|
|
1244
|
+
});
|
|
901
1245
|
}
|
|
902
1246
|
}
|
|
903
1247
|
}
|
|
@@ -930,10 +1274,10 @@ var SyncEngine = class {
|
|
|
930
1274
|
const localRootHash = localTree.getRootHash();
|
|
931
1275
|
if (localRootHash !== rootHash) {
|
|
932
1276
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
|
|
933
|
-
this.
|
|
1277
|
+
this.sendMessage({
|
|
934
1278
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
935
1279
|
payload: { mapName, path: "" }
|
|
936
|
-
})
|
|
1280
|
+
});
|
|
937
1281
|
} else {
|
|
938
1282
|
logger.info({ mapName }, "ORMap is in sync");
|
|
939
1283
|
}
|
|
@@ -955,10 +1299,10 @@ var SyncEngine = class {
|
|
|
955
1299
|
const localHash = localBuckets[bucketKey] || 0;
|
|
956
1300
|
if (localHash !== remoteHash) {
|
|
957
1301
|
const newPath = path + bucketKey;
|
|
958
|
-
this.
|
|
1302
|
+
this.sendMessage({
|
|
959
1303
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
960
1304
|
payload: { mapName, path: newPath }
|
|
961
|
-
})
|
|
1305
|
+
});
|
|
962
1306
|
}
|
|
963
1307
|
}
|
|
964
1308
|
for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
|
|
@@ -1049,7 +1393,11 @@ var SyncEngine = class {
|
|
|
1049
1393
|
clearTimeout(this.reconnectTimer);
|
|
1050
1394
|
this.reconnectTimer = null;
|
|
1051
1395
|
}
|
|
1052
|
-
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) {
|
|
1053
1401
|
this.websocket.onclose = null;
|
|
1054
1402
|
this.websocket.close();
|
|
1055
1403
|
this.websocket = null;
|
|
@@ -1066,7 +1414,100 @@ var SyncEngine = class {
|
|
|
1066
1414
|
this.close();
|
|
1067
1415
|
this.stateMachine.reset();
|
|
1068
1416
|
this.resetBackoff();
|
|
1069
|
-
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;
|
|
1070
1511
|
}
|
|
1071
1512
|
async resetMap(mapName) {
|
|
1072
1513
|
const map = this.maps.get(mapName);
|
|
@@ -1114,12 +1555,12 @@ var SyncEngine = class {
|
|
|
1114
1555
|
* Sends a PING message to the server.
|
|
1115
1556
|
*/
|
|
1116
1557
|
sendPing() {
|
|
1117
|
-
if (this.
|
|
1558
|
+
if (this.canSend()) {
|
|
1118
1559
|
const pingMessage = {
|
|
1119
1560
|
type: "PING",
|
|
1120
1561
|
timestamp: Date.now()
|
|
1121
1562
|
};
|
|
1122
|
-
this.
|
|
1563
|
+
this.sendMessage(pingMessage);
|
|
1123
1564
|
}
|
|
1124
1565
|
}
|
|
1125
1566
|
/**
|
|
@@ -1198,13 +1639,13 @@ var SyncEngine = class {
|
|
|
1198
1639
|
}
|
|
1199
1640
|
}
|
|
1200
1641
|
if (entries.length > 0) {
|
|
1201
|
-
this.
|
|
1642
|
+
this.sendMessage({
|
|
1202
1643
|
type: "ORMAP_PUSH_DIFF",
|
|
1203
1644
|
payload: {
|
|
1204
1645
|
mapName,
|
|
1205
1646
|
entries
|
|
1206
1647
|
}
|
|
1207
|
-
})
|
|
1648
|
+
});
|
|
1208
1649
|
logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
|
|
1209
1650
|
}
|
|
1210
1651
|
}
|
|
@@ -1430,7 +1871,7 @@ var SyncEngine = class {
|
|
|
1430
1871
|
};
|
|
1431
1872
|
|
|
1432
1873
|
// src/TopGunClient.ts
|
|
1433
|
-
var
|
|
1874
|
+
var import_core6 = require("@topgunbuild/core");
|
|
1434
1875
|
|
|
1435
1876
|
// src/QueryHandle.ts
|
|
1436
1877
|
var QueryHandle = class {
|
|
@@ -1631,33 +2072,1496 @@ var TopicHandle = class {
|
|
|
1631
2072
|
}
|
|
1632
2073
|
};
|
|
1633
2074
|
|
|
1634
|
-
// src/
|
|
1635
|
-
var
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
|
1647
2091
|
};
|
|
1648
|
-
this.syncEngine = new SyncEngine(syncEngineConfig);
|
|
1649
2092
|
}
|
|
1650
|
-
|
|
1651
|
-
|
|
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;
|
|
1652
2102
|
}
|
|
1653
|
-
|
|
1654
|
-
this.
|
|
2103
|
+
off(event, listener) {
|
|
2104
|
+
this.listeners.get(event)?.delete(listener);
|
|
2105
|
+
return this;
|
|
1655
2106
|
}
|
|
1656
|
-
|
|
1657
|
-
this.
|
|
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;
|
|
1658
2120
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
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);
|
|
3157
|
+
}
|
|
3158
|
+
this.connectionPool.startHealthCheck();
|
|
3159
|
+
this.partitionRouter.startPeriodicRefresh();
|
|
3160
|
+
this.initialized = true;
|
|
3161
|
+
await this.waitForPartitionMap();
|
|
3162
|
+
}
|
|
3163
|
+
/**
|
|
3164
|
+
* Set authentication token
|
|
3165
|
+
*/
|
|
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);
|
|
3176
|
+
}
|
|
3177
|
+
return this.sendForward(message);
|
|
3178
|
+
}
|
|
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;
|
|
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;
|
|
3197
|
+
}
|
|
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, []);
|
|
3216
|
+
}
|
|
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) }
|
|
3240
|
+
});
|
|
3241
|
+
for (const { key } of operations) {
|
|
3242
|
+
results.set(key, success);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
return results;
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* Get connection pool health status
|
|
3249
|
+
*/
|
|
3250
|
+
getHealthStatus() {
|
|
3251
|
+
return this.connectionPool.getHealthStatus();
|
|
3252
|
+
}
|
|
3253
|
+
/**
|
|
3254
|
+
* Get partition router stats
|
|
3255
|
+
*/
|
|
3256
|
+
getRouterStats() {
|
|
3257
|
+
return this.partitionRouter.getStats();
|
|
3258
|
+
}
|
|
3259
|
+
/**
|
|
3260
|
+
* Get routing metrics for monitoring smart routing effectiveness.
|
|
3261
|
+
*/
|
|
3262
|
+
getRoutingMetrics() {
|
|
3263
|
+
return { ...this.routingMetrics };
|
|
3264
|
+
}
|
|
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;
|
|
3274
|
+
}
|
|
3275
|
+
/**
|
|
3276
|
+
* Check if cluster routing is active
|
|
3277
|
+
*/
|
|
3278
|
+
isRoutingActive() {
|
|
3279
|
+
return this.routingActive;
|
|
3280
|
+
}
|
|
3281
|
+
/**
|
|
3282
|
+
* Get list of connected nodes
|
|
3283
|
+
*/
|
|
3284
|
+
getConnectedNodes() {
|
|
3285
|
+
return this.connectionPool.getConnectedNodes();
|
|
3286
|
+
}
|
|
3287
|
+
/**
|
|
3288
|
+
* Check if cluster client is initialized
|
|
3289
|
+
*/
|
|
3290
|
+
isInitialized() {
|
|
3291
|
+
return this.initialized;
|
|
3292
|
+
}
|
|
3293
|
+
/**
|
|
3294
|
+
* Force refresh of partition map
|
|
3295
|
+
*/
|
|
3296
|
+
async refreshPartitionMap() {
|
|
3297
|
+
await this.partitionRouter.refreshPartitionMap();
|
|
3298
|
+
}
|
|
3299
|
+
/**
|
|
3300
|
+
* Shutdown cluster client (IConnectionProvider interface).
|
|
3301
|
+
*/
|
|
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;
|
|
3317
|
+
}
|
|
3318
|
+
/**
|
|
3319
|
+
* Get the partition router (for internal use)
|
|
3320
|
+
*/
|
|
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");
|
|
3332
|
+
}
|
|
3333
|
+
return conn.socket;
|
|
3334
|
+
}
|
|
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);
|
|
3354
|
+
}
|
|
3355
|
+
return circuit;
|
|
3356
|
+
}
|
|
3357
|
+
/**
|
|
3358
|
+
* Check if a node can be used (circuit not open).
|
|
3359
|
+
*/
|
|
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");
|
|
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);
|
|
3486
|
+
});
|
|
3487
|
+
}
|
|
3488
|
+
};
|
|
3489
|
+
|
|
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
|
+
};
|
|
3498
|
+
var TopGunClient = class {
|
|
3499
|
+
constructor(config) {
|
|
3500
|
+
this.maps = /* @__PURE__ */ new Map();
|
|
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
|
+
}
|
|
3508
|
+
this.nodeId = config.nodeId || crypto.randomUUID();
|
|
3509
|
+
this.storageAdapter = config.storage;
|
|
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
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
async start() {
|
|
3555
|
+
await this.storageAdapter.initialize("topgun_offline_db");
|
|
3556
|
+
}
|
|
3557
|
+
setAuthToken(token) {
|
|
3558
|
+
this.syncEngine.setAuthToken(token);
|
|
3559
|
+
}
|
|
3560
|
+
setAuthTokenProvider(provider) {
|
|
3561
|
+
this.syncEngine.setTokenProvider(provider);
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3564
|
+
* Creates a live query subscription for a map.
|
|
1661
3565
|
*/
|
|
1662
3566
|
query(mapName, filter) {
|
|
1663
3567
|
return new QueryHandle(this.syncEngine, mapName, filter);
|
|
@@ -1687,12 +3591,12 @@ var TopGunClient = class {
|
|
|
1687
3591
|
getMap(name) {
|
|
1688
3592
|
if (this.maps.has(name)) {
|
|
1689
3593
|
const map = this.maps.get(name);
|
|
1690
|
-
if (map instanceof
|
|
3594
|
+
if (map instanceof import_core6.LWWMap) {
|
|
1691
3595
|
return map;
|
|
1692
3596
|
}
|
|
1693
3597
|
throw new Error(`Map ${name} exists but is not an LWWMap`);
|
|
1694
3598
|
}
|
|
1695
|
-
const lwwMap = new
|
|
3599
|
+
const lwwMap = new import_core6.LWWMap(this.syncEngine.getHLC());
|
|
1696
3600
|
this.maps.set(name, lwwMap);
|
|
1697
3601
|
this.syncEngine.registerMap(name, lwwMap);
|
|
1698
3602
|
this.storageAdapter.getAllKeys().then(async (keys) => {
|
|
@@ -1731,12 +3635,12 @@ var TopGunClient = class {
|
|
|
1731
3635
|
getORMap(name) {
|
|
1732
3636
|
if (this.maps.has(name)) {
|
|
1733
3637
|
const map = this.maps.get(name);
|
|
1734
|
-
if (map instanceof
|
|
3638
|
+
if (map instanceof import_core6.ORMap) {
|
|
1735
3639
|
return map;
|
|
1736
3640
|
}
|
|
1737
3641
|
throw new Error(`Map ${name} exists but is not an ORMap`);
|
|
1738
3642
|
}
|
|
1739
|
-
const orMap = new
|
|
3643
|
+
const orMap = new import_core6.ORMap(this.syncEngine.getHLC());
|
|
1740
3644
|
this.maps.set(name, orMap);
|
|
1741
3645
|
this.syncEngine.registerMap(name, orMap);
|
|
1742
3646
|
this.restoreORMap(name, orMap);
|
|
@@ -1805,9 +3709,69 @@ var TopGunClient = class {
|
|
|
1805
3709
|
* Closes the client, disconnecting from the server and cleaning up resources.
|
|
1806
3710
|
*/
|
|
1807
3711
|
close() {
|
|
3712
|
+
if (this.clusterClient) {
|
|
3713
|
+
this.clusterClient.close();
|
|
3714
|
+
}
|
|
1808
3715
|
this.syncEngine.close();
|
|
1809
3716
|
}
|
|
1810
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
|
+
// ============================================
|
|
1811
3775
|
// Connection State API
|
|
1812
3776
|
// ============================================
|
|
1813
3777
|
/**
|
|
@@ -2158,14 +4122,14 @@ var CollectionWrapper = class {
|
|
|
2158
4122
|
};
|
|
2159
4123
|
|
|
2160
4124
|
// src/crypto/EncryptionManager.ts
|
|
2161
|
-
var
|
|
4125
|
+
var import_core7 = require("@topgunbuild/core");
|
|
2162
4126
|
var _EncryptionManager = class _EncryptionManager {
|
|
2163
4127
|
/**
|
|
2164
4128
|
* Encrypts data using AES-GCM.
|
|
2165
4129
|
* Serializes data to MessagePack before encryption.
|
|
2166
4130
|
*/
|
|
2167
4131
|
static async encrypt(key, data) {
|
|
2168
|
-
const encoded = (0,
|
|
4132
|
+
const encoded = (0, import_core7.serialize)(data);
|
|
2169
4133
|
const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
|
|
2170
4134
|
const ciphertext = await window.crypto.subtle.encrypt(
|
|
2171
4135
|
{
|
|
@@ -2194,7 +4158,7 @@ var _EncryptionManager = class _EncryptionManager {
|
|
|
2194
4158
|
key,
|
|
2195
4159
|
record.data
|
|
2196
4160
|
);
|
|
2197
|
-
return (0,
|
|
4161
|
+
return (0, import_core7.deserialize)(new Uint8Array(plaintextBuffer));
|
|
2198
4162
|
} catch (err) {
|
|
2199
4163
|
console.error("Decryption failed", err);
|
|
2200
4164
|
throw new Error("Failed to decrypt data: " + err);
|
|
@@ -2318,16 +4282,21 @@ var EncryptedStorageAdapter = class {
|
|
|
2318
4282
|
};
|
|
2319
4283
|
|
|
2320
4284
|
// src/index.ts
|
|
2321
|
-
var
|
|
4285
|
+
var import_core8 = require("@topgunbuild/core");
|
|
2322
4286
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2323
4287
|
0 && (module.exports = {
|
|
2324
4288
|
BackpressureError,
|
|
4289
|
+
ClusterClient,
|
|
4290
|
+
ConnectionPool,
|
|
2325
4291
|
DEFAULT_BACKPRESSURE_CONFIG,
|
|
4292
|
+
DEFAULT_CLUSTER_CONFIG,
|
|
2326
4293
|
EncryptedStorageAdapter,
|
|
2327
4294
|
IDBAdapter,
|
|
2328
4295
|
LWWMap,
|
|
4296
|
+
PartitionRouter,
|
|
2329
4297
|
Predicates,
|
|
2330
4298
|
QueryHandle,
|
|
4299
|
+
SingleServerProvider,
|
|
2331
4300
|
SyncEngine,
|
|
2332
4301
|
SyncState,
|
|
2333
4302
|
SyncStateMachine,
|