@topgunbuild/client 0.2.1 → 0.4.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 +1518 -63
- package/dist/index.d.ts +1518 -63
- package/dist/index.js +3331 -137
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3325 -129
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
- package/LICENSE +0 -97
package/dist/index.js
CHANGED
|
@@ -31,12 +31,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
BackpressureError: () => BackpressureError,
|
|
34
|
+
ChangeTracker: () => ChangeTracker,
|
|
35
|
+
ClusterClient: () => ClusterClient,
|
|
36
|
+
ConflictResolverClient: () => ConflictResolverClient,
|
|
37
|
+
ConnectionPool: () => ConnectionPool,
|
|
34
38
|
DEFAULT_BACKPRESSURE_CONFIG: () => DEFAULT_BACKPRESSURE_CONFIG,
|
|
39
|
+
DEFAULT_CLUSTER_CONFIG: () => DEFAULT_CLUSTER_CONFIG,
|
|
35
40
|
EncryptedStorageAdapter: () => EncryptedStorageAdapter,
|
|
41
|
+
EventJournalReader: () => EventJournalReader,
|
|
36
42
|
IDBAdapter: () => IDBAdapter,
|
|
37
|
-
LWWMap: () =>
|
|
38
|
-
|
|
43
|
+
LWWMap: () => import_core9.LWWMap,
|
|
44
|
+
PNCounterHandle: () => PNCounterHandle,
|
|
45
|
+
PartitionRouter: () => PartitionRouter,
|
|
46
|
+
Predicates: () => import_core9.Predicates,
|
|
39
47
|
QueryHandle: () => QueryHandle,
|
|
48
|
+
SingleServerProvider: () => SingleServerProvider,
|
|
40
49
|
SyncEngine: () => SyncEngine,
|
|
41
50
|
SyncState: () => SyncState,
|
|
42
51
|
SyncStateMachine: () => SyncStateMachine,
|
|
@@ -254,6 +263,464 @@ var DEFAULT_BACKPRESSURE_CONFIG = {
|
|
|
254
263
|
lowWaterMark: 0.5
|
|
255
264
|
};
|
|
256
265
|
|
|
266
|
+
// src/connection/SingleServerProvider.ts
|
|
267
|
+
var DEFAULT_CONFIG = {
|
|
268
|
+
maxReconnectAttempts: 10,
|
|
269
|
+
reconnectDelayMs: 1e3,
|
|
270
|
+
backoffMultiplier: 2,
|
|
271
|
+
maxReconnectDelayMs: 3e4
|
|
272
|
+
};
|
|
273
|
+
var SingleServerProvider = class {
|
|
274
|
+
constructor(config) {
|
|
275
|
+
this.ws = null;
|
|
276
|
+
this.reconnectAttempts = 0;
|
|
277
|
+
this.reconnectTimer = null;
|
|
278
|
+
this.isClosing = false;
|
|
279
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
280
|
+
this.url = config.url;
|
|
281
|
+
this.config = {
|
|
282
|
+
url: config.url,
|
|
283
|
+
maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
|
|
284
|
+
reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
|
|
285
|
+
backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
|
|
286
|
+
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Connect to the WebSocket server.
|
|
291
|
+
*/
|
|
292
|
+
async connect() {
|
|
293
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
this.isClosing = false;
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
try {
|
|
299
|
+
this.ws = new WebSocket(this.url);
|
|
300
|
+
this.ws.binaryType = "arraybuffer";
|
|
301
|
+
this.ws.onopen = () => {
|
|
302
|
+
this.reconnectAttempts = 0;
|
|
303
|
+
logger.info({ url: this.url }, "SingleServerProvider connected");
|
|
304
|
+
this.emit("connected", "default");
|
|
305
|
+
resolve();
|
|
306
|
+
};
|
|
307
|
+
this.ws.onerror = (error) => {
|
|
308
|
+
logger.error({ err: error, url: this.url }, "SingleServerProvider WebSocket error");
|
|
309
|
+
this.emit("error", error);
|
|
310
|
+
};
|
|
311
|
+
this.ws.onclose = (event) => {
|
|
312
|
+
logger.info({ url: this.url, code: event.code }, "SingleServerProvider disconnected");
|
|
313
|
+
this.emit("disconnected", "default");
|
|
314
|
+
if (!this.isClosing) {
|
|
315
|
+
this.scheduleReconnect();
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
this.ws.onmessage = (event) => {
|
|
319
|
+
this.emit("message", "default", event.data);
|
|
320
|
+
};
|
|
321
|
+
const timeoutId = setTimeout(() => {
|
|
322
|
+
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
323
|
+
this.ws.close();
|
|
324
|
+
reject(new Error(`Connection timeout to ${this.url}`));
|
|
325
|
+
}
|
|
326
|
+
}, this.config.reconnectDelayMs * 5);
|
|
327
|
+
const originalOnOpen = this.ws.onopen;
|
|
328
|
+
const wsRef = this.ws;
|
|
329
|
+
this.ws.onopen = (ev) => {
|
|
330
|
+
clearTimeout(timeoutId);
|
|
331
|
+
if (originalOnOpen) {
|
|
332
|
+
originalOnOpen.call(wsRef, ev);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
} catch (error) {
|
|
336
|
+
reject(error);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Get connection for a specific key.
|
|
342
|
+
* In single-server mode, key is ignored.
|
|
343
|
+
*/
|
|
344
|
+
getConnection(_key) {
|
|
345
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
346
|
+
throw new Error("Not connected");
|
|
347
|
+
}
|
|
348
|
+
return this.ws;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Get any available connection.
|
|
352
|
+
*/
|
|
353
|
+
getAnyConnection() {
|
|
354
|
+
return this.getConnection("");
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Check if connected.
|
|
358
|
+
*/
|
|
359
|
+
isConnected() {
|
|
360
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Get connected node IDs.
|
|
364
|
+
* Single-server mode returns ['default'] when connected.
|
|
365
|
+
*/
|
|
366
|
+
getConnectedNodes() {
|
|
367
|
+
return this.isConnected() ? ["default"] : [];
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Subscribe to connection events.
|
|
371
|
+
*/
|
|
372
|
+
on(event, handler2) {
|
|
373
|
+
if (!this.listeners.has(event)) {
|
|
374
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
375
|
+
}
|
|
376
|
+
this.listeners.get(event).add(handler2);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Unsubscribe from connection events.
|
|
380
|
+
*/
|
|
381
|
+
off(event, handler2) {
|
|
382
|
+
this.listeners.get(event)?.delete(handler2);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Send data via the WebSocket connection.
|
|
386
|
+
* In single-server mode, key parameter is ignored.
|
|
387
|
+
*/
|
|
388
|
+
send(data, _key) {
|
|
389
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
390
|
+
throw new Error("Not connected");
|
|
391
|
+
}
|
|
392
|
+
this.ws.send(data);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Close the WebSocket connection.
|
|
396
|
+
*/
|
|
397
|
+
async close() {
|
|
398
|
+
this.isClosing = true;
|
|
399
|
+
if (this.reconnectTimer) {
|
|
400
|
+
clearTimeout(this.reconnectTimer);
|
|
401
|
+
this.reconnectTimer = null;
|
|
402
|
+
}
|
|
403
|
+
if (this.ws) {
|
|
404
|
+
this.ws.onclose = null;
|
|
405
|
+
this.ws.onerror = null;
|
|
406
|
+
this.ws.onmessage = null;
|
|
407
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
408
|
+
this.ws.close();
|
|
409
|
+
}
|
|
410
|
+
this.ws = null;
|
|
411
|
+
}
|
|
412
|
+
logger.info({ url: this.url }, "SingleServerProvider closed");
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Emit an event to all listeners.
|
|
416
|
+
*/
|
|
417
|
+
emit(event, ...args) {
|
|
418
|
+
const handlers = this.listeners.get(event);
|
|
419
|
+
if (handlers) {
|
|
420
|
+
for (const handler2 of handlers) {
|
|
421
|
+
try {
|
|
422
|
+
handler2(...args);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
logger.error({ err, event }, "Error in SingleServerProvider event handler");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Schedule a reconnection attempt with exponential backoff.
|
|
431
|
+
*/
|
|
432
|
+
scheduleReconnect() {
|
|
433
|
+
if (this.reconnectTimer) {
|
|
434
|
+
clearTimeout(this.reconnectTimer);
|
|
435
|
+
this.reconnectTimer = null;
|
|
436
|
+
}
|
|
437
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
438
|
+
logger.error(
|
|
439
|
+
{ attempts: this.reconnectAttempts, url: this.url },
|
|
440
|
+
"SingleServerProvider max reconnect attempts reached"
|
|
441
|
+
);
|
|
442
|
+
this.emit("error", new Error("Max reconnection attempts reached"));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const delay = this.calculateBackoffDelay();
|
|
446
|
+
logger.info(
|
|
447
|
+
{ delay, attempt: this.reconnectAttempts, url: this.url },
|
|
448
|
+
`SingleServerProvider scheduling reconnect in ${delay}ms`
|
|
449
|
+
);
|
|
450
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
451
|
+
this.reconnectTimer = null;
|
|
452
|
+
this.reconnectAttempts++;
|
|
453
|
+
try {
|
|
454
|
+
await this.connect();
|
|
455
|
+
this.emit("reconnected", "default");
|
|
456
|
+
} catch (error) {
|
|
457
|
+
logger.error({ err: error }, "SingleServerProvider reconnection failed");
|
|
458
|
+
this.scheduleReconnect();
|
|
459
|
+
}
|
|
460
|
+
}, delay);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Calculate backoff delay with exponential increase.
|
|
464
|
+
*/
|
|
465
|
+
calculateBackoffDelay() {
|
|
466
|
+
const { reconnectDelayMs, backoffMultiplier, maxReconnectDelayMs } = this.config;
|
|
467
|
+
let delay = reconnectDelayMs * Math.pow(backoffMultiplier, this.reconnectAttempts);
|
|
468
|
+
delay = Math.min(delay, maxReconnectDelayMs);
|
|
469
|
+
delay = delay * (0.5 + Math.random());
|
|
470
|
+
return Math.floor(delay);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Get the WebSocket URL this provider connects to.
|
|
474
|
+
*/
|
|
475
|
+
getUrl() {
|
|
476
|
+
return this.url;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Get current reconnection attempt count.
|
|
480
|
+
*/
|
|
481
|
+
getReconnectAttempts() {
|
|
482
|
+
return this.reconnectAttempts;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Reset reconnection counter.
|
|
486
|
+
* Called externally after successful authentication.
|
|
487
|
+
*/
|
|
488
|
+
resetReconnectAttempts() {
|
|
489
|
+
this.reconnectAttempts = 0;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// src/ConflictResolverClient.ts
|
|
494
|
+
var _ConflictResolverClient = class _ConflictResolverClient {
|
|
495
|
+
// 10 seconds
|
|
496
|
+
constructor(syncEngine) {
|
|
497
|
+
this.rejectionListeners = /* @__PURE__ */ new Set();
|
|
498
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
499
|
+
this.syncEngine = syncEngine;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Register a conflict resolver on the server.
|
|
503
|
+
*
|
|
504
|
+
* @param mapName The map to register the resolver for
|
|
505
|
+
* @param resolver The resolver definition
|
|
506
|
+
* @returns Promise resolving to registration result
|
|
507
|
+
*
|
|
508
|
+
* @example
|
|
509
|
+
* ```typescript
|
|
510
|
+
* // Register a first-write-wins resolver for bookings
|
|
511
|
+
* await client.resolvers.register('bookings', {
|
|
512
|
+
* name: 'first-write-wins',
|
|
513
|
+
* code: `
|
|
514
|
+
* if (context.localValue !== undefined) {
|
|
515
|
+
* return { action: 'reject', reason: 'Slot already booked' };
|
|
516
|
+
* }
|
|
517
|
+
* return { action: 'accept', value: context.remoteValue };
|
|
518
|
+
* `,
|
|
519
|
+
* priority: 100,
|
|
520
|
+
* });
|
|
521
|
+
* ```
|
|
522
|
+
*/
|
|
523
|
+
async register(mapName, resolver) {
|
|
524
|
+
const requestId = crypto.randomUUID();
|
|
525
|
+
return new Promise((resolve, reject) => {
|
|
526
|
+
const timeout = setTimeout(() => {
|
|
527
|
+
this.pendingRequests.delete(requestId);
|
|
528
|
+
reject(new Error("Register resolver request timed out"));
|
|
529
|
+
}, _ConflictResolverClient.REQUEST_TIMEOUT);
|
|
530
|
+
this.pendingRequests.set(requestId, {
|
|
531
|
+
resolve: (result) => {
|
|
532
|
+
clearTimeout(timeout);
|
|
533
|
+
resolve(result);
|
|
534
|
+
},
|
|
535
|
+
reject,
|
|
536
|
+
timeout
|
|
537
|
+
});
|
|
538
|
+
try {
|
|
539
|
+
this.syncEngine.send({
|
|
540
|
+
type: "REGISTER_RESOLVER",
|
|
541
|
+
requestId,
|
|
542
|
+
mapName,
|
|
543
|
+
resolver: {
|
|
544
|
+
name: resolver.name,
|
|
545
|
+
code: resolver.code || "",
|
|
546
|
+
priority: resolver.priority,
|
|
547
|
+
keyPattern: resolver.keyPattern
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
} catch {
|
|
551
|
+
this.pendingRequests.delete(requestId);
|
|
552
|
+
clearTimeout(timeout);
|
|
553
|
+
resolve({ success: false, error: "Not connected to server" });
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Unregister a conflict resolver from the server.
|
|
559
|
+
*
|
|
560
|
+
* @param mapName The map the resolver is registered for
|
|
561
|
+
* @param resolverName The name of the resolver to unregister
|
|
562
|
+
* @returns Promise resolving to unregistration result
|
|
563
|
+
*/
|
|
564
|
+
async unregister(mapName, resolverName) {
|
|
565
|
+
const requestId = crypto.randomUUID();
|
|
566
|
+
return new Promise((resolve, reject) => {
|
|
567
|
+
const timeout = setTimeout(() => {
|
|
568
|
+
this.pendingRequests.delete(requestId);
|
|
569
|
+
reject(new Error("Unregister resolver request timed out"));
|
|
570
|
+
}, _ConflictResolverClient.REQUEST_TIMEOUT);
|
|
571
|
+
this.pendingRequests.set(requestId, {
|
|
572
|
+
resolve: (result) => {
|
|
573
|
+
clearTimeout(timeout);
|
|
574
|
+
resolve(result);
|
|
575
|
+
},
|
|
576
|
+
reject,
|
|
577
|
+
timeout
|
|
578
|
+
});
|
|
579
|
+
try {
|
|
580
|
+
this.syncEngine.send({
|
|
581
|
+
type: "UNREGISTER_RESOLVER",
|
|
582
|
+
requestId,
|
|
583
|
+
mapName,
|
|
584
|
+
resolverName
|
|
585
|
+
});
|
|
586
|
+
} catch {
|
|
587
|
+
this.pendingRequests.delete(requestId);
|
|
588
|
+
clearTimeout(timeout);
|
|
589
|
+
resolve({ success: false, error: "Not connected to server" });
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* List registered conflict resolvers on the server.
|
|
595
|
+
*
|
|
596
|
+
* @param mapName Optional - filter by map name
|
|
597
|
+
* @returns Promise resolving to list of resolver info
|
|
598
|
+
*/
|
|
599
|
+
async list(mapName) {
|
|
600
|
+
const requestId = crypto.randomUUID();
|
|
601
|
+
return new Promise((resolve, reject) => {
|
|
602
|
+
const timeout = setTimeout(() => {
|
|
603
|
+
this.pendingRequests.delete(requestId);
|
|
604
|
+
reject(new Error("List resolvers request timed out"));
|
|
605
|
+
}, _ConflictResolverClient.REQUEST_TIMEOUT);
|
|
606
|
+
this.pendingRequests.set(requestId, {
|
|
607
|
+
resolve: (result) => {
|
|
608
|
+
clearTimeout(timeout);
|
|
609
|
+
resolve(result.resolvers);
|
|
610
|
+
},
|
|
611
|
+
reject,
|
|
612
|
+
timeout
|
|
613
|
+
});
|
|
614
|
+
try {
|
|
615
|
+
this.syncEngine.send({
|
|
616
|
+
type: "LIST_RESOLVERS",
|
|
617
|
+
requestId,
|
|
618
|
+
mapName
|
|
619
|
+
});
|
|
620
|
+
} catch {
|
|
621
|
+
this.pendingRequests.delete(requestId);
|
|
622
|
+
clearTimeout(timeout);
|
|
623
|
+
resolve([]);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Subscribe to merge rejection events.
|
|
629
|
+
*
|
|
630
|
+
* @param listener Callback for rejection events
|
|
631
|
+
* @returns Unsubscribe function
|
|
632
|
+
*
|
|
633
|
+
* @example
|
|
634
|
+
* ```typescript
|
|
635
|
+
* const unsubscribe = client.resolvers.onRejection((rejection) => {
|
|
636
|
+
* console.log(`Merge rejected for ${rejection.key}: ${rejection.reason}`);
|
|
637
|
+
* // Optionally refresh the local value
|
|
638
|
+
* });
|
|
639
|
+
*
|
|
640
|
+
* // Later...
|
|
641
|
+
* unsubscribe();
|
|
642
|
+
* ```
|
|
643
|
+
*/
|
|
644
|
+
onRejection(listener) {
|
|
645
|
+
this.rejectionListeners.add(listener);
|
|
646
|
+
return () => this.rejectionListeners.delete(listener);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Handle REGISTER_RESOLVER_RESPONSE from server.
|
|
650
|
+
* Called by SyncEngine.
|
|
651
|
+
*/
|
|
652
|
+
handleRegisterResponse(message) {
|
|
653
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
654
|
+
if (pending) {
|
|
655
|
+
this.pendingRequests.delete(message.requestId);
|
|
656
|
+
pending.resolve({ success: message.success, error: message.error });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Handle UNREGISTER_RESOLVER_RESPONSE from server.
|
|
661
|
+
* Called by SyncEngine.
|
|
662
|
+
*/
|
|
663
|
+
handleUnregisterResponse(message) {
|
|
664
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
665
|
+
if (pending) {
|
|
666
|
+
this.pendingRequests.delete(message.requestId);
|
|
667
|
+
pending.resolve({ success: message.success, error: message.error });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Handle LIST_RESOLVERS_RESPONSE from server.
|
|
672
|
+
* Called by SyncEngine.
|
|
673
|
+
*/
|
|
674
|
+
handleListResponse(message) {
|
|
675
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
676
|
+
if (pending) {
|
|
677
|
+
this.pendingRequests.delete(message.requestId);
|
|
678
|
+
pending.resolve({ resolvers: message.resolvers });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Handle MERGE_REJECTED from server.
|
|
683
|
+
* Called by SyncEngine.
|
|
684
|
+
*/
|
|
685
|
+
handleMergeRejected(message) {
|
|
686
|
+
const rejection = {
|
|
687
|
+
mapName: message.mapName,
|
|
688
|
+
key: message.key,
|
|
689
|
+
attemptedValue: message.attemptedValue,
|
|
690
|
+
reason: message.reason,
|
|
691
|
+
timestamp: message.timestamp,
|
|
692
|
+
nodeId: ""
|
|
693
|
+
// Not provided by server in this message
|
|
694
|
+
};
|
|
695
|
+
logger.debug({ rejection }, "Merge rejected by server");
|
|
696
|
+
for (const listener of this.rejectionListeners) {
|
|
697
|
+
try {
|
|
698
|
+
listener(rejection);
|
|
699
|
+
} catch (e) {
|
|
700
|
+
logger.error({ error: e }, "Error in rejection listener");
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Clear all pending requests (e.g., on disconnect).
|
|
706
|
+
*/
|
|
707
|
+
clearPending() {
|
|
708
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
709
|
+
clearTimeout(pending.timeout);
|
|
710
|
+
pending.reject(new Error("Connection lost"));
|
|
711
|
+
}
|
|
712
|
+
this.pendingRequests.clear();
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Get the number of registered rejection listeners.
|
|
716
|
+
*/
|
|
717
|
+
get rejectionListenerCount() {
|
|
718
|
+
return this.rejectionListeners.size;
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
_ConflictResolverClient.REQUEST_TIMEOUT = 1e4;
|
|
722
|
+
var ConflictResolverClient = _ConflictResolverClient;
|
|
723
|
+
|
|
257
724
|
// src/SyncEngine.ts
|
|
258
725
|
var DEFAULT_BACKOFF_CONFIG = {
|
|
259
726
|
initialDelayMs: 1e3,
|
|
@@ -262,7 +729,7 @@ var DEFAULT_BACKOFF_CONFIG = {
|
|
|
262
729
|
jitter: true,
|
|
263
730
|
maxRetries: 10
|
|
264
731
|
};
|
|
265
|
-
var
|
|
732
|
+
var _SyncEngine = class _SyncEngine {
|
|
266
733
|
constructor(config) {
|
|
267
734
|
this.websocket = null;
|
|
268
735
|
this.opLog = [];
|
|
@@ -285,8 +752,28 @@ var SyncEngine = class {
|
|
|
285
752
|
this.backpressureListeners = /* @__PURE__ */ new Map();
|
|
286
753
|
// Write Concern state (Phase 5.01)
|
|
287
754
|
this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
|
|
755
|
+
// ============================================
|
|
756
|
+
// PN Counter Methods (Phase 5.2)
|
|
757
|
+
// ============================================
|
|
758
|
+
/** Counter update listeners by name */
|
|
759
|
+
this.counterUpdateListeners = /* @__PURE__ */ new Map();
|
|
760
|
+
// ============================================
|
|
761
|
+
// Entry Processor Methods (Phase 5.03)
|
|
762
|
+
// ============================================
|
|
763
|
+
/** Pending entry processor requests by requestId */
|
|
764
|
+
this.pendingProcessorRequests = /* @__PURE__ */ new Map();
|
|
765
|
+
/** Pending batch entry processor requests by requestId */
|
|
766
|
+
this.pendingBatchProcessorRequests = /* @__PURE__ */ new Map();
|
|
767
|
+
// ============================================
|
|
768
|
+
// Event Journal Methods (Phase 5.04)
|
|
769
|
+
// ============================================
|
|
770
|
+
/** Message listeners for journal and other generic messages */
|
|
771
|
+
this.messageListeners = /* @__PURE__ */ new Set();
|
|
772
|
+
if (!config.serverUrl && !config.connectionProvider) {
|
|
773
|
+
throw new Error("SyncEngine requires either serverUrl or connectionProvider");
|
|
774
|
+
}
|
|
288
775
|
this.nodeId = config.nodeId;
|
|
289
|
-
this.serverUrl = config.serverUrl;
|
|
776
|
+
this.serverUrl = config.serverUrl || "";
|
|
290
777
|
this.storageAdapter = config.storageAdapter;
|
|
291
778
|
this.hlc = new import_core.HLC(this.nodeId);
|
|
292
779
|
this.stateMachine = new SyncStateMachine();
|
|
@@ -303,7 +790,16 @@ var SyncEngine = class {
|
|
|
303
790
|
...DEFAULT_BACKPRESSURE_CONFIG,
|
|
304
791
|
...config.backpressure
|
|
305
792
|
};
|
|
306
|
-
|
|
793
|
+
if (config.connectionProvider) {
|
|
794
|
+
this.connectionProvider = config.connectionProvider;
|
|
795
|
+
this.useConnectionProvider = true;
|
|
796
|
+
this.initConnectionProvider();
|
|
797
|
+
} else {
|
|
798
|
+
this.connectionProvider = new SingleServerProvider({ url: config.serverUrl });
|
|
799
|
+
this.useConnectionProvider = false;
|
|
800
|
+
this.initConnection();
|
|
801
|
+
}
|
|
802
|
+
this.conflictResolverClient = new ConflictResolverClient(this);
|
|
307
803
|
this.loadOpLog();
|
|
308
804
|
}
|
|
309
805
|
// ============================================
|
|
@@ -354,6 +850,65 @@ var SyncEngine = class {
|
|
|
354
850
|
// ============================================
|
|
355
851
|
// Connection Management
|
|
356
852
|
// ============================================
|
|
853
|
+
/**
|
|
854
|
+
* Initialize connection using IConnectionProvider (Phase 4.5 cluster mode).
|
|
855
|
+
* Sets up event handlers for the connection provider.
|
|
856
|
+
*/
|
|
857
|
+
initConnectionProvider() {
|
|
858
|
+
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
859
|
+
this.connectionProvider.on("connected", (_nodeId) => {
|
|
860
|
+
if (this.authToken || this.tokenProvider) {
|
|
861
|
+
logger.info("ConnectionProvider connected. Sending auth...");
|
|
862
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
863
|
+
this.sendAuth();
|
|
864
|
+
} else {
|
|
865
|
+
logger.info("ConnectionProvider connected. Waiting for auth token...");
|
|
866
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
this.connectionProvider.on("disconnected", (_nodeId) => {
|
|
870
|
+
logger.info("ConnectionProvider disconnected.");
|
|
871
|
+
this.stopHeartbeat();
|
|
872
|
+
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
873
|
+
});
|
|
874
|
+
this.connectionProvider.on("reconnected", (_nodeId) => {
|
|
875
|
+
logger.info("ConnectionProvider reconnected.");
|
|
876
|
+
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
877
|
+
if (this.authToken || this.tokenProvider) {
|
|
878
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
879
|
+
this.sendAuth();
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
this.connectionProvider.on("message", (_nodeId, data) => {
|
|
883
|
+
let message;
|
|
884
|
+
if (data instanceof ArrayBuffer) {
|
|
885
|
+
message = (0, import_core.deserialize)(new Uint8Array(data));
|
|
886
|
+
} else if (data instanceof Uint8Array) {
|
|
887
|
+
message = (0, import_core.deserialize)(data);
|
|
888
|
+
} else {
|
|
889
|
+
try {
|
|
890
|
+
message = typeof data === "string" ? JSON.parse(data) : data;
|
|
891
|
+
} catch (e) {
|
|
892
|
+
logger.error({ err: e }, "Failed to parse message from ConnectionProvider");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
this.handleServerMessage(message);
|
|
897
|
+
});
|
|
898
|
+
this.connectionProvider.on("partitionMapUpdated", () => {
|
|
899
|
+
logger.debug("Partition map updated");
|
|
900
|
+
});
|
|
901
|
+
this.connectionProvider.on("error", (error) => {
|
|
902
|
+
logger.error({ err: error }, "ConnectionProvider error");
|
|
903
|
+
});
|
|
904
|
+
this.connectionProvider.connect().catch((err) => {
|
|
905
|
+
logger.error({ err }, "Failed to connect via ConnectionProvider");
|
|
906
|
+
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Initialize connection using direct WebSocket (legacy single-server mode).
|
|
911
|
+
*/
|
|
357
912
|
initConnection() {
|
|
358
913
|
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
359
914
|
this.websocket = new WebSocket(this.serverUrl);
|
|
@@ -429,6 +984,40 @@ var SyncEngine = class {
|
|
|
429
984
|
resetBackoff() {
|
|
430
985
|
this.backoffAttempt = 0;
|
|
431
986
|
}
|
|
987
|
+
/**
|
|
988
|
+
* Send a message through the current connection.
|
|
989
|
+
* Uses connectionProvider if in cluster mode, otherwise uses direct websocket.
|
|
990
|
+
* @param message Message object to serialize and send
|
|
991
|
+
* @param key Optional key for routing (cluster mode only)
|
|
992
|
+
* @returns true if message was sent, false otherwise
|
|
993
|
+
*/
|
|
994
|
+
sendMessage(message, key) {
|
|
995
|
+
const data = (0, import_core.serialize)(message);
|
|
996
|
+
if (this.useConnectionProvider) {
|
|
997
|
+
try {
|
|
998
|
+
this.connectionProvider.send(data, key);
|
|
999
|
+
return true;
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
logger.warn({ err }, "Failed to send via ConnectionProvider");
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
if (this.websocket?.readyState === WebSocket.OPEN) {
|
|
1006
|
+
this.websocket.send(data);
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Check if we can send messages (connection is ready).
|
|
1014
|
+
*/
|
|
1015
|
+
canSend() {
|
|
1016
|
+
if (this.useConnectionProvider) {
|
|
1017
|
+
return this.connectionProvider.isConnected();
|
|
1018
|
+
}
|
|
1019
|
+
return this.websocket?.readyState === WebSocket.OPEN;
|
|
1020
|
+
}
|
|
432
1021
|
async loadOpLog() {
|
|
433
1022
|
const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
|
|
434
1023
|
if (storedTimestamp) {
|
|
@@ -475,36 +1064,34 @@ var SyncEngine = class {
|
|
|
475
1064
|
const pending = this.opLog.filter((op) => !op.synced);
|
|
476
1065
|
if (pending.length === 0) return;
|
|
477
1066
|
logger.info({ count: pending.length }, "Syncing pending operations");
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}));
|
|
485
|
-
}
|
|
1067
|
+
this.sendMessage({
|
|
1068
|
+
type: "OP_BATCH",
|
|
1069
|
+
payload: {
|
|
1070
|
+
ops: pending
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
486
1073
|
}
|
|
487
1074
|
startMerkleSync() {
|
|
488
1075
|
for (const [mapName, map] of this.maps) {
|
|
489
1076
|
if (map instanceof import_core.LWWMap) {
|
|
490
1077
|
logger.info({ mapName }, "Starting Merkle sync for LWWMap");
|
|
491
|
-
this.
|
|
1078
|
+
this.sendMessage({
|
|
492
1079
|
type: "SYNC_INIT",
|
|
493
1080
|
mapName,
|
|
494
1081
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
495
|
-
})
|
|
1082
|
+
});
|
|
496
1083
|
} else if (map instanceof import_core.ORMap) {
|
|
497
1084
|
logger.info({ mapName }, "Starting Merkle sync for ORMap");
|
|
498
1085
|
const tree = map.getMerkleTree();
|
|
499
1086
|
const rootHash = tree.getRootHash();
|
|
500
1087
|
const bucketHashes = tree.getBuckets("");
|
|
501
|
-
this.
|
|
1088
|
+
this.sendMessage({
|
|
502
1089
|
type: "ORMAP_SYNC_INIT",
|
|
503
1090
|
mapName,
|
|
504
1091
|
rootHash,
|
|
505
1092
|
bucketHashes,
|
|
506
1093
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
507
|
-
})
|
|
1094
|
+
});
|
|
508
1095
|
}
|
|
509
1096
|
}
|
|
510
1097
|
}
|
|
@@ -545,10 +1132,10 @@ var SyncEngine = class {
|
|
|
545
1132
|
}
|
|
546
1133
|
const token = this.authToken;
|
|
547
1134
|
if (!token) return;
|
|
548
|
-
this.
|
|
1135
|
+
this.sendMessage({
|
|
549
1136
|
type: "AUTH",
|
|
550
1137
|
token
|
|
551
|
-
})
|
|
1138
|
+
});
|
|
552
1139
|
}
|
|
553
1140
|
subscribeToQuery(query) {
|
|
554
1141
|
this.queries.set(query.id, query);
|
|
@@ -565,27 +1152,27 @@ var SyncEngine = class {
|
|
|
565
1152
|
unsubscribeFromTopic(topic) {
|
|
566
1153
|
this.topics.delete(topic);
|
|
567
1154
|
if (this.isAuthenticated()) {
|
|
568
|
-
this.
|
|
1155
|
+
this.sendMessage({
|
|
569
1156
|
type: "TOPIC_UNSUB",
|
|
570
1157
|
payload: { topic }
|
|
571
|
-
})
|
|
1158
|
+
});
|
|
572
1159
|
}
|
|
573
1160
|
}
|
|
574
1161
|
publishTopic(topic, data) {
|
|
575
1162
|
if (this.isAuthenticated()) {
|
|
576
|
-
this.
|
|
1163
|
+
this.sendMessage({
|
|
577
1164
|
type: "TOPIC_PUB",
|
|
578
1165
|
payload: { topic, data }
|
|
579
|
-
})
|
|
1166
|
+
});
|
|
580
1167
|
} else {
|
|
581
1168
|
logger.warn({ topic }, "Dropped topic publish (offline)");
|
|
582
1169
|
}
|
|
583
1170
|
}
|
|
584
1171
|
sendTopicSubscription(topic) {
|
|
585
|
-
this.
|
|
1172
|
+
this.sendMessage({
|
|
586
1173
|
type: "TOPIC_SUB",
|
|
587
1174
|
payload: { topic }
|
|
588
|
-
})
|
|
1175
|
+
});
|
|
589
1176
|
}
|
|
590
1177
|
/**
|
|
591
1178
|
* Executes a query against local storage immediately
|
|
@@ -622,21 +1209,21 @@ var SyncEngine = class {
|
|
|
622
1209
|
unsubscribeFromQuery(queryId) {
|
|
623
1210
|
this.queries.delete(queryId);
|
|
624
1211
|
if (this.isAuthenticated()) {
|
|
625
|
-
this.
|
|
1212
|
+
this.sendMessage({
|
|
626
1213
|
type: "QUERY_UNSUB",
|
|
627
1214
|
payload: { queryId }
|
|
628
|
-
})
|
|
1215
|
+
});
|
|
629
1216
|
}
|
|
630
1217
|
}
|
|
631
1218
|
sendQuerySubscription(query) {
|
|
632
|
-
this.
|
|
1219
|
+
this.sendMessage({
|
|
633
1220
|
type: "QUERY_SUB",
|
|
634
1221
|
payload: {
|
|
635
1222
|
queryId: query.id,
|
|
636
1223
|
mapName: query.getMapName(),
|
|
637
1224
|
query: query.getFilter()
|
|
638
1225
|
}
|
|
639
|
-
})
|
|
1226
|
+
});
|
|
640
1227
|
}
|
|
641
1228
|
requestLock(name, requestId, ttl) {
|
|
642
1229
|
if (!this.isAuthenticated()) {
|
|
@@ -651,10 +1238,15 @@ var SyncEngine = class {
|
|
|
651
1238
|
}, 3e4);
|
|
652
1239
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
653
1240
|
try {
|
|
654
|
-
this.
|
|
1241
|
+
const sent = this.sendMessage({
|
|
655
1242
|
type: "LOCK_REQUEST",
|
|
656
1243
|
payload: { requestId, name, ttl }
|
|
657
|
-
})
|
|
1244
|
+
});
|
|
1245
|
+
if (!sent) {
|
|
1246
|
+
clearTimeout(timer);
|
|
1247
|
+
this.pendingLockRequests.delete(requestId);
|
|
1248
|
+
reject(new Error("Failed to send lock request"));
|
|
1249
|
+
}
|
|
658
1250
|
} catch (e) {
|
|
659
1251
|
clearTimeout(timer);
|
|
660
1252
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -673,10 +1265,15 @@ var SyncEngine = class {
|
|
|
673
1265
|
}, 5e3);
|
|
674
1266
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
675
1267
|
try {
|
|
676
|
-
this.
|
|
1268
|
+
const sent = this.sendMessage({
|
|
677
1269
|
type: "LOCK_RELEASE",
|
|
678
1270
|
payload: { requestId, name, fencingToken }
|
|
679
|
-
})
|
|
1271
|
+
});
|
|
1272
|
+
if (!sent) {
|
|
1273
|
+
clearTimeout(timer);
|
|
1274
|
+
this.pendingLockRequests.delete(requestId);
|
|
1275
|
+
resolve(false);
|
|
1276
|
+
}
|
|
680
1277
|
} catch (e) {
|
|
681
1278
|
clearTimeout(timer);
|
|
682
1279
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -685,6 +1282,7 @@ var SyncEngine = class {
|
|
|
685
1282
|
});
|
|
686
1283
|
}
|
|
687
1284
|
async handleServerMessage(message) {
|
|
1285
|
+
this.emitMessage(message);
|
|
688
1286
|
switch (message.type) {
|
|
689
1287
|
case "BATCH": {
|
|
690
1288
|
const batchData = message.data;
|
|
@@ -855,11 +1453,11 @@ var SyncEngine = class {
|
|
|
855
1453
|
const { mapName } = message.payload;
|
|
856
1454
|
logger.warn({ mapName }, "Sync Reset Required due to GC Age");
|
|
857
1455
|
await this.resetMap(mapName);
|
|
858
|
-
this.
|
|
1456
|
+
this.sendMessage({
|
|
859
1457
|
type: "SYNC_INIT",
|
|
860
1458
|
mapName,
|
|
861
1459
|
lastSyncTimestamp: 0
|
|
862
|
-
})
|
|
1460
|
+
});
|
|
863
1461
|
break;
|
|
864
1462
|
}
|
|
865
1463
|
case "SYNC_RESP_ROOT": {
|
|
@@ -869,10 +1467,10 @@ var SyncEngine = class {
|
|
|
869
1467
|
const localRootHash = map.getMerkleTree().getRootHash();
|
|
870
1468
|
if (localRootHash !== rootHash) {
|
|
871
1469
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
|
|
872
|
-
this.
|
|
1470
|
+
this.sendMessage({
|
|
873
1471
|
type: "MERKLE_REQ_BUCKET",
|
|
874
1472
|
payload: { mapName, path: "" }
|
|
875
|
-
})
|
|
1473
|
+
});
|
|
876
1474
|
} else {
|
|
877
1475
|
logger.info({ mapName }, "Map is in sync");
|
|
878
1476
|
}
|
|
@@ -894,10 +1492,10 @@ var SyncEngine = class {
|
|
|
894
1492
|
const localHash = localBuckets[bucketKey] || 0;
|
|
895
1493
|
if (localHash !== remoteHash) {
|
|
896
1494
|
const newPath = path + bucketKey;
|
|
897
|
-
this.
|
|
1495
|
+
this.sendMessage({
|
|
898
1496
|
type: "MERKLE_REQ_BUCKET",
|
|
899
1497
|
payload: { mapName, path: newPath }
|
|
900
|
-
})
|
|
1498
|
+
});
|
|
901
1499
|
}
|
|
902
1500
|
}
|
|
903
1501
|
}
|
|
@@ -930,10 +1528,10 @@ var SyncEngine = class {
|
|
|
930
1528
|
const localRootHash = localTree.getRootHash();
|
|
931
1529
|
if (localRootHash !== rootHash) {
|
|
932
1530
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
|
|
933
|
-
this.
|
|
1531
|
+
this.sendMessage({
|
|
934
1532
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
935
1533
|
payload: { mapName, path: "" }
|
|
936
|
-
})
|
|
1534
|
+
});
|
|
937
1535
|
} else {
|
|
938
1536
|
logger.info({ mapName }, "ORMap is in sync");
|
|
939
1537
|
}
|
|
@@ -955,10 +1553,10 @@ var SyncEngine = class {
|
|
|
955
1553
|
const localHash = localBuckets[bucketKey] || 0;
|
|
956
1554
|
if (localHash !== remoteHash) {
|
|
957
1555
|
const newPath = path + bucketKey;
|
|
958
|
-
this.
|
|
1556
|
+
this.sendMessage({
|
|
959
1557
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
960
1558
|
payload: { mapName, path: newPath }
|
|
961
|
-
})
|
|
1559
|
+
});
|
|
962
1560
|
}
|
|
963
1561
|
}
|
|
964
1562
|
for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
|
|
@@ -1011,6 +1609,51 @@ var SyncEngine = class {
|
|
|
1011
1609
|
}
|
|
1012
1610
|
break;
|
|
1013
1611
|
}
|
|
1612
|
+
// ============ PN Counter Message Handlers (Phase 5.2) ============
|
|
1613
|
+
case "COUNTER_UPDATE": {
|
|
1614
|
+
const { name, state } = message.payload;
|
|
1615
|
+
logger.debug({ name }, "Received COUNTER_UPDATE");
|
|
1616
|
+
this.handleCounterUpdate(name, state);
|
|
1617
|
+
break;
|
|
1618
|
+
}
|
|
1619
|
+
case "COUNTER_RESPONSE": {
|
|
1620
|
+
const { name, state } = message.payload;
|
|
1621
|
+
logger.debug({ name }, "Received COUNTER_RESPONSE");
|
|
1622
|
+
this.handleCounterUpdate(name, state);
|
|
1623
|
+
break;
|
|
1624
|
+
}
|
|
1625
|
+
// ============ Entry Processor Message Handlers (Phase 5.03) ============
|
|
1626
|
+
case "ENTRY_PROCESS_RESPONSE": {
|
|
1627
|
+
logger.debug({ requestId: message.requestId, success: message.success }, "Received ENTRY_PROCESS_RESPONSE");
|
|
1628
|
+
this.handleEntryProcessResponse(message);
|
|
1629
|
+
break;
|
|
1630
|
+
}
|
|
1631
|
+
case "ENTRY_PROCESS_BATCH_RESPONSE": {
|
|
1632
|
+
logger.debug({ requestId: message.requestId }, "Received ENTRY_PROCESS_BATCH_RESPONSE");
|
|
1633
|
+
this.handleEntryProcessBatchResponse(message);
|
|
1634
|
+
break;
|
|
1635
|
+
}
|
|
1636
|
+
// ============ Conflict Resolver Message Handlers (Phase 5.05) ============
|
|
1637
|
+
case "REGISTER_RESOLVER_RESPONSE": {
|
|
1638
|
+
logger.debug({ requestId: message.requestId, success: message.success }, "Received REGISTER_RESOLVER_RESPONSE");
|
|
1639
|
+
this.conflictResolverClient.handleRegisterResponse(message);
|
|
1640
|
+
break;
|
|
1641
|
+
}
|
|
1642
|
+
case "UNREGISTER_RESOLVER_RESPONSE": {
|
|
1643
|
+
logger.debug({ requestId: message.requestId, success: message.success }, "Received UNREGISTER_RESOLVER_RESPONSE");
|
|
1644
|
+
this.conflictResolverClient.handleUnregisterResponse(message);
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
case "LIST_RESOLVERS_RESPONSE": {
|
|
1648
|
+
logger.debug({ requestId: message.requestId }, "Received LIST_RESOLVERS_RESPONSE");
|
|
1649
|
+
this.conflictResolverClient.handleListResponse(message);
|
|
1650
|
+
break;
|
|
1651
|
+
}
|
|
1652
|
+
case "MERGE_REJECTED": {
|
|
1653
|
+
logger.debug({ mapName: message.mapName, key: message.key, reason: message.reason }, "Received MERGE_REJECTED");
|
|
1654
|
+
this.conflictResolverClient.handleMergeRejected(message);
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1014
1657
|
}
|
|
1015
1658
|
if (message.timestamp) {
|
|
1016
1659
|
this.hlc.update(message.timestamp);
|
|
@@ -1049,7 +1692,11 @@ var SyncEngine = class {
|
|
|
1049
1692
|
clearTimeout(this.reconnectTimer);
|
|
1050
1693
|
this.reconnectTimer = null;
|
|
1051
1694
|
}
|
|
1052
|
-
if (this.
|
|
1695
|
+
if (this.useConnectionProvider) {
|
|
1696
|
+
this.connectionProvider.close().catch((err) => {
|
|
1697
|
+
logger.error({ err }, "Error closing ConnectionProvider");
|
|
1698
|
+
});
|
|
1699
|
+
} else if (this.websocket) {
|
|
1053
1700
|
this.websocket.onclose = null;
|
|
1054
1701
|
this.websocket.close();
|
|
1055
1702
|
this.websocket = null;
|
|
@@ -1066,7 +1713,100 @@ var SyncEngine = class {
|
|
|
1066
1713
|
this.close();
|
|
1067
1714
|
this.stateMachine.reset();
|
|
1068
1715
|
this.resetBackoff();
|
|
1069
|
-
this.
|
|
1716
|
+
if (this.useConnectionProvider) {
|
|
1717
|
+
this.initConnectionProvider();
|
|
1718
|
+
} else {
|
|
1719
|
+
this.initConnection();
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
// ============================================
|
|
1723
|
+
// Failover Support Methods (Phase 4.5 Task 05)
|
|
1724
|
+
// ============================================
|
|
1725
|
+
/**
|
|
1726
|
+
* Wait for a partition map update from the connection provider.
|
|
1727
|
+
* Used when an operation fails with NOT_OWNER error and needs
|
|
1728
|
+
* to wait for an updated partition map before retrying.
|
|
1729
|
+
*
|
|
1730
|
+
* @param timeoutMs - Maximum time to wait (default: 5000ms)
|
|
1731
|
+
* @returns Promise that resolves when partition map is updated or times out
|
|
1732
|
+
*/
|
|
1733
|
+
waitForPartitionMapUpdate(timeoutMs = 5e3) {
|
|
1734
|
+
return new Promise((resolve) => {
|
|
1735
|
+
const timeout = setTimeout(resolve, timeoutMs);
|
|
1736
|
+
const handler2 = () => {
|
|
1737
|
+
clearTimeout(timeout);
|
|
1738
|
+
this.connectionProvider.off("partitionMapUpdated", handler2);
|
|
1739
|
+
resolve();
|
|
1740
|
+
};
|
|
1741
|
+
this.connectionProvider.on("partitionMapUpdated", handler2);
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Wait for the connection to be available.
|
|
1746
|
+
* Used when an operation fails due to connection issues and needs
|
|
1747
|
+
* to wait for reconnection before retrying.
|
|
1748
|
+
*
|
|
1749
|
+
* @param timeoutMs - Maximum time to wait (default: 10000ms)
|
|
1750
|
+
* @returns Promise that resolves when connected or rejects on timeout
|
|
1751
|
+
*/
|
|
1752
|
+
waitForConnection(timeoutMs = 1e4) {
|
|
1753
|
+
return new Promise((resolve, reject) => {
|
|
1754
|
+
if (this.connectionProvider.isConnected()) {
|
|
1755
|
+
resolve();
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const timeout = setTimeout(() => {
|
|
1759
|
+
this.connectionProvider.off("connected", handler2);
|
|
1760
|
+
reject(new Error("Connection timeout waiting for reconnection"));
|
|
1761
|
+
}, timeoutMs);
|
|
1762
|
+
const handler2 = () => {
|
|
1763
|
+
clearTimeout(timeout);
|
|
1764
|
+
this.connectionProvider.off("connected", handler2);
|
|
1765
|
+
resolve();
|
|
1766
|
+
};
|
|
1767
|
+
this.connectionProvider.on("connected", handler2);
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Wait for a specific sync state.
|
|
1772
|
+
* Useful for waiting until fully connected and synced.
|
|
1773
|
+
*
|
|
1774
|
+
* @param targetState - The state to wait for
|
|
1775
|
+
* @param timeoutMs - Maximum time to wait (default: 30000ms)
|
|
1776
|
+
* @returns Promise that resolves when state is reached or rejects on timeout
|
|
1777
|
+
*/
|
|
1778
|
+
waitForState(targetState, timeoutMs = 3e4) {
|
|
1779
|
+
return new Promise((resolve, reject) => {
|
|
1780
|
+
if (this.stateMachine.getState() === targetState) {
|
|
1781
|
+
resolve();
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
const timeout = setTimeout(() => {
|
|
1785
|
+
unsubscribe();
|
|
1786
|
+
reject(new Error(`Timeout waiting for state ${targetState}`));
|
|
1787
|
+
}, timeoutMs);
|
|
1788
|
+
const unsubscribe = this.stateMachine.onStateChange((event) => {
|
|
1789
|
+
if (event.to === targetState) {
|
|
1790
|
+
clearTimeout(timeout);
|
|
1791
|
+
unsubscribe();
|
|
1792
|
+
resolve();
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Check if the connection provider is connected.
|
|
1799
|
+
* Convenience method for failover logic.
|
|
1800
|
+
*/
|
|
1801
|
+
isProviderConnected() {
|
|
1802
|
+
return this.connectionProvider.isConnected();
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Get the connection provider for direct access.
|
|
1806
|
+
* Use with caution - prefer using SyncEngine methods.
|
|
1807
|
+
*/
|
|
1808
|
+
getConnectionProvider() {
|
|
1809
|
+
return this.connectionProvider;
|
|
1070
1810
|
}
|
|
1071
1811
|
async resetMap(mapName) {
|
|
1072
1812
|
const map = this.maps.get(mapName);
|
|
@@ -1114,12 +1854,12 @@ var SyncEngine = class {
|
|
|
1114
1854
|
* Sends a PING message to the server.
|
|
1115
1855
|
*/
|
|
1116
1856
|
sendPing() {
|
|
1117
|
-
if (this.
|
|
1857
|
+
if (this.canSend()) {
|
|
1118
1858
|
const pingMessage = {
|
|
1119
1859
|
type: "PING",
|
|
1120
1860
|
timestamp: Date.now()
|
|
1121
1861
|
};
|
|
1122
|
-
this.
|
|
1862
|
+
this.sendMessage(pingMessage);
|
|
1123
1863
|
}
|
|
1124
1864
|
}
|
|
1125
1865
|
/**
|
|
@@ -1198,13 +1938,13 @@ var SyncEngine = class {
|
|
|
1198
1938
|
}
|
|
1199
1939
|
}
|
|
1200
1940
|
if (entries.length > 0) {
|
|
1201
|
-
this.
|
|
1941
|
+
this.sendMessage({
|
|
1202
1942
|
type: "ORMAP_PUSH_DIFF",
|
|
1203
1943
|
payload: {
|
|
1204
1944
|
mapName,
|
|
1205
1945
|
entries
|
|
1206
1946
|
}
|
|
1207
|
-
})
|
|
1947
|
+
});
|
|
1208
1948
|
logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
|
|
1209
1949
|
}
|
|
1210
1950
|
}
|
|
@@ -1427,16 +2167,371 @@ var SyncEngine = class {
|
|
|
1427
2167
|
}
|
|
1428
2168
|
this.pendingWriteConcernPromises.clear();
|
|
1429
2169
|
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Subscribe to counter updates from server.
|
|
2172
|
+
* @param name Counter name
|
|
2173
|
+
* @param listener Callback when counter state is updated
|
|
2174
|
+
* @returns Unsubscribe function
|
|
2175
|
+
*/
|
|
2176
|
+
onCounterUpdate(name, listener) {
|
|
2177
|
+
if (!this.counterUpdateListeners.has(name)) {
|
|
2178
|
+
this.counterUpdateListeners.set(name, /* @__PURE__ */ new Set());
|
|
2179
|
+
}
|
|
2180
|
+
this.counterUpdateListeners.get(name).add(listener);
|
|
2181
|
+
return () => {
|
|
2182
|
+
this.counterUpdateListeners.get(name)?.delete(listener);
|
|
2183
|
+
if (this.counterUpdateListeners.get(name)?.size === 0) {
|
|
2184
|
+
this.counterUpdateListeners.delete(name);
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Request initial counter state from server.
|
|
2190
|
+
* @param name Counter name
|
|
2191
|
+
*/
|
|
2192
|
+
requestCounter(name) {
|
|
2193
|
+
if (this.isAuthenticated()) {
|
|
2194
|
+
this.sendMessage({
|
|
2195
|
+
type: "COUNTER_REQUEST",
|
|
2196
|
+
payload: { name }
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Sync local counter state to server.
|
|
2202
|
+
* @param name Counter name
|
|
2203
|
+
* @param state Counter state to sync
|
|
2204
|
+
*/
|
|
2205
|
+
syncCounter(name, state) {
|
|
2206
|
+
if (this.isAuthenticated()) {
|
|
2207
|
+
const stateObj = {
|
|
2208
|
+
positive: Object.fromEntries(state.positive),
|
|
2209
|
+
negative: Object.fromEntries(state.negative)
|
|
2210
|
+
};
|
|
2211
|
+
this.sendMessage({
|
|
2212
|
+
type: "COUNTER_SYNC",
|
|
2213
|
+
payload: {
|
|
2214
|
+
name,
|
|
2215
|
+
state: stateObj
|
|
2216
|
+
}
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* Handle incoming counter update from server.
|
|
2222
|
+
* Called by handleServerMessage for COUNTER_UPDATE messages.
|
|
2223
|
+
*/
|
|
2224
|
+
handleCounterUpdate(name, stateObj) {
|
|
2225
|
+
const state = {
|
|
2226
|
+
positive: new Map(Object.entries(stateObj.positive)),
|
|
2227
|
+
negative: new Map(Object.entries(stateObj.negative))
|
|
2228
|
+
};
|
|
2229
|
+
const listeners = this.counterUpdateListeners.get(name);
|
|
2230
|
+
if (listeners) {
|
|
2231
|
+
for (const listener of listeners) {
|
|
2232
|
+
try {
|
|
2233
|
+
listener(state);
|
|
2234
|
+
} catch (e) {
|
|
2235
|
+
logger.error({ err: e, counterName: name }, "Counter update listener error");
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Execute an entry processor on a single key atomically.
|
|
2242
|
+
*
|
|
2243
|
+
* @param mapName Name of the map
|
|
2244
|
+
* @param key Key to process
|
|
2245
|
+
* @param processor Processor definition
|
|
2246
|
+
* @returns Promise resolving to the processor result
|
|
2247
|
+
*/
|
|
2248
|
+
async executeOnKey(mapName, key, processor) {
|
|
2249
|
+
if (!this.isAuthenticated()) {
|
|
2250
|
+
return {
|
|
2251
|
+
success: false,
|
|
2252
|
+
error: "Not connected to server"
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
const requestId = crypto.randomUUID();
|
|
2256
|
+
return new Promise((resolve, reject) => {
|
|
2257
|
+
const timeout = setTimeout(() => {
|
|
2258
|
+
this.pendingProcessorRequests.delete(requestId);
|
|
2259
|
+
reject(new Error("Entry processor request timed out"));
|
|
2260
|
+
}, _SyncEngine.PROCESSOR_TIMEOUT);
|
|
2261
|
+
this.pendingProcessorRequests.set(requestId, {
|
|
2262
|
+
resolve: (result) => {
|
|
2263
|
+
clearTimeout(timeout);
|
|
2264
|
+
resolve(result);
|
|
2265
|
+
},
|
|
2266
|
+
reject,
|
|
2267
|
+
timeout
|
|
2268
|
+
});
|
|
2269
|
+
const sent = this.sendMessage({
|
|
2270
|
+
type: "ENTRY_PROCESS",
|
|
2271
|
+
requestId,
|
|
2272
|
+
mapName,
|
|
2273
|
+
key,
|
|
2274
|
+
processor: {
|
|
2275
|
+
name: processor.name,
|
|
2276
|
+
code: processor.code,
|
|
2277
|
+
args: processor.args
|
|
2278
|
+
}
|
|
2279
|
+
}, key);
|
|
2280
|
+
if (!sent) {
|
|
2281
|
+
this.pendingProcessorRequests.delete(requestId);
|
|
2282
|
+
clearTimeout(timeout);
|
|
2283
|
+
reject(new Error("Failed to send entry processor request"));
|
|
2284
|
+
}
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* Execute an entry processor on multiple keys.
|
|
2289
|
+
*
|
|
2290
|
+
* @param mapName Name of the map
|
|
2291
|
+
* @param keys Keys to process
|
|
2292
|
+
* @param processor Processor definition
|
|
2293
|
+
* @returns Promise resolving to a map of key -> result
|
|
2294
|
+
*/
|
|
2295
|
+
async executeOnKeys(mapName, keys, processor) {
|
|
2296
|
+
if (!this.isAuthenticated()) {
|
|
2297
|
+
const results = /* @__PURE__ */ new Map();
|
|
2298
|
+
const error = {
|
|
2299
|
+
success: false,
|
|
2300
|
+
error: "Not connected to server"
|
|
2301
|
+
};
|
|
2302
|
+
for (const key of keys) {
|
|
2303
|
+
results.set(key, error);
|
|
2304
|
+
}
|
|
2305
|
+
return results;
|
|
2306
|
+
}
|
|
2307
|
+
const requestId = crypto.randomUUID();
|
|
2308
|
+
return new Promise((resolve, reject) => {
|
|
2309
|
+
const timeout = setTimeout(() => {
|
|
2310
|
+
this.pendingBatchProcessorRequests.delete(requestId);
|
|
2311
|
+
reject(new Error("Entry processor batch request timed out"));
|
|
2312
|
+
}, _SyncEngine.PROCESSOR_TIMEOUT);
|
|
2313
|
+
this.pendingBatchProcessorRequests.set(requestId, {
|
|
2314
|
+
resolve: (results) => {
|
|
2315
|
+
clearTimeout(timeout);
|
|
2316
|
+
resolve(results);
|
|
2317
|
+
},
|
|
2318
|
+
reject,
|
|
2319
|
+
timeout
|
|
2320
|
+
});
|
|
2321
|
+
const sent = this.sendMessage({
|
|
2322
|
+
type: "ENTRY_PROCESS_BATCH",
|
|
2323
|
+
requestId,
|
|
2324
|
+
mapName,
|
|
2325
|
+
keys,
|
|
2326
|
+
processor: {
|
|
2327
|
+
name: processor.name,
|
|
2328
|
+
code: processor.code,
|
|
2329
|
+
args: processor.args
|
|
2330
|
+
}
|
|
2331
|
+
});
|
|
2332
|
+
if (!sent) {
|
|
2333
|
+
this.pendingBatchProcessorRequests.delete(requestId);
|
|
2334
|
+
clearTimeout(timeout);
|
|
2335
|
+
reject(new Error("Failed to send entry processor batch request"));
|
|
2336
|
+
}
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
/**
|
|
2340
|
+
* Handle entry processor response from server.
|
|
2341
|
+
* Called by handleServerMessage for ENTRY_PROCESS_RESPONSE messages.
|
|
2342
|
+
*/
|
|
2343
|
+
handleEntryProcessResponse(message) {
|
|
2344
|
+
const pending = this.pendingProcessorRequests.get(message.requestId);
|
|
2345
|
+
if (pending) {
|
|
2346
|
+
this.pendingProcessorRequests.delete(message.requestId);
|
|
2347
|
+
pending.resolve({
|
|
2348
|
+
success: message.success,
|
|
2349
|
+
result: message.result,
|
|
2350
|
+
newValue: message.newValue,
|
|
2351
|
+
error: message.error
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Handle entry processor batch response from server.
|
|
2357
|
+
* Called by handleServerMessage for ENTRY_PROCESS_BATCH_RESPONSE messages.
|
|
2358
|
+
*/
|
|
2359
|
+
handleEntryProcessBatchResponse(message) {
|
|
2360
|
+
const pending = this.pendingBatchProcessorRequests.get(message.requestId);
|
|
2361
|
+
if (pending) {
|
|
2362
|
+
this.pendingBatchProcessorRequests.delete(message.requestId);
|
|
2363
|
+
const resultsMap = /* @__PURE__ */ new Map();
|
|
2364
|
+
for (const [key, result] of Object.entries(message.results)) {
|
|
2365
|
+
resultsMap.set(key, {
|
|
2366
|
+
success: result.success,
|
|
2367
|
+
result: result.result,
|
|
2368
|
+
newValue: result.newValue,
|
|
2369
|
+
error: result.error
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
pending.resolve(resultsMap);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* Subscribe to all incoming messages.
|
|
2377
|
+
* Used by EventJournalReader to receive journal events.
|
|
2378
|
+
*
|
|
2379
|
+
* @param event Event type (currently only 'message')
|
|
2380
|
+
* @param handler Message handler
|
|
2381
|
+
*/
|
|
2382
|
+
on(event, handler2) {
|
|
2383
|
+
if (event === "message") {
|
|
2384
|
+
this.messageListeners.add(handler2);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Unsubscribe from incoming messages.
|
|
2389
|
+
*
|
|
2390
|
+
* @param event Event type (currently only 'message')
|
|
2391
|
+
* @param handler Message handler to remove
|
|
2392
|
+
*/
|
|
2393
|
+
off(event, handler2) {
|
|
2394
|
+
if (event === "message") {
|
|
2395
|
+
this.messageListeners.delete(handler2);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Send a message to the server.
|
|
2400
|
+
* Public method for EventJournalReader and other components.
|
|
2401
|
+
*
|
|
2402
|
+
* @param message Message object to send
|
|
2403
|
+
*/
|
|
2404
|
+
send(message) {
|
|
2405
|
+
this.sendMessage(message);
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* Emit message to all listeners.
|
|
2409
|
+
* Called internally when a message is received.
|
|
2410
|
+
*/
|
|
2411
|
+
emitMessage(message) {
|
|
2412
|
+
for (const listener of this.messageListeners) {
|
|
2413
|
+
try {
|
|
2414
|
+
listener(message);
|
|
2415
|
+
} catch (e) {
|
|
2416
|
+
logger.error({ err: e }, "Message listener error");
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
// ============================================
|
|
2421
|
+
// Conflict Resolver Client (Phase 5.05)
|
|
2422
|
+
// ============================================
|
|
2423
|
+
/**
|
|
2424
|
+
* Get the conflict resolver client for registering custom resolvers
|
|
2425
|
+
* and subscribing to merge rejection events.
|
|
2426
|
+
*/
|
|
2427
|
+
getConflictResolverClient() {
|
|
2428
|
+
return this.conflictResolverClient;
|
|
2429
|
+
}
|
|
1430
2430
|
};
|
|
2431
|
+
/** Default timeout for entry processor requests (ms) */
|
|
2432
|
+
_SyncEngine.PROCESSOR_TIMEOUT = 3e4;
|
|
2433
|
+
var SyncEngine = _SyncEngine;
|
|
1431
2434
|
|
|
1432
2435
|
// src/TopGunClient.ts
|
|
1433
|
-
var
|
|
2436
|
+
var import_core7 = require("@topgunbuild/core");
|
|
2437
|
+
|
|
2438
|
+
// src/utils/deepEqual.ts
|
|
2439
|
+
function deepEqual(a, b) {
|
|
2440
|
+
if (a === b) return true;
|
|
2441
|
+
if (a == null || b == null) return a === b;
|
|
2442
|
+
if (typeof a !== typeof b) return false;
|
|
2443
|
+
if (typeof a !== "object") return a === b;
|
|
2444
|
+
if (Array.isArray(a)) {
|
|
2445
|
+
if (!Array.isArray(b)) return false;
|
|
2446
|
+
if (a.length !== b.length) return false;
|
|
2447
|
+
for (let i = 0; i < a.length; i++) {
|
|
2448
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
2449
|
+
}
|
|
2450
|
+
return true;
|
|
2451
|
+
}
|
|
2452
|
+
if (Array.isArray(b)) return false;
|
|
2453
|
+
const objA = a;
|
|
2454
|
+
const objB = b;
|
|
2455
|
+
const keysA = Object.keys(objA);
|
|
2456
|
+
const keysB = Object.keys(objB);
|
|
2457
|
+
if (keysA.length !== keysB.length) return false;
|
|
2458
|
+
for (const key of keysA) {
|
|
2459
|
+
if (!Object.prototype.hasOwnProperty.call(objB, key)) return false;
|
|
2460
|
+
if (!deepEqual(objA[key], objB[key])) return false;
|
|
2461
|
+
}
|
|
2462
|
+
return true;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// src/ChangeTracker.ts
|
|
2466
|
+
var ChangeTracker = class {
|
|
2467
|
+
constructor() {
|
|
2468
|
+
this.previousSnapshot = /* @__PURE__ */ new Map();
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Computes changes between previous and current state.
|
|
2472
|
+
* Updates internal snapshot after computation.
|
|
2473
|
+
*
|
|
2474
|
+
* @param current - Current state as a Map
|
|
2475
|
+
* @param timestamp - HLC timestamp for the changes
|
|
2476
|
+
* @returns Array of change events (may be empty if no changes)
|
|
2477
|
+
*/
|
|
2478
|
+
computeChanges(current, timestamp) {
|
|
2479
|
+
const changes = [];
|
|
2480
|
+
for (const [key, value] of current) {
|
|
2481
|
+
const previous = this.previousSnapshot.get(key);
|
|
2482
|
+
if (previous === void 0) {
|
|
2483
|
+
changes.push({ type: "add", key, value, timestamp });
|
|
2484
|
+
} else if (!deepEqual(previous, value)) {
|
|
2485
|
+
changes.push({
|
|
2486
|
+
type: "update",
|
|
2487
|
+
key,
|
|
2488
|
+
value,
|
|
2489
|
+
previousValue: previous,
|
|
2490
|
+
timestamp
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
for (const [key, value] of this.previousSnapshot) {
|
|
2495
|
+
if (!current.has(key)) {
|
|
2496
|
+
changes.push({
|
|
2497
|
+
type: "remove",
|
|
2498
|
+
key,
|
|
2499
|
+
previousValue: value,
|
|
2500
|
+
timestamp
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
this.previousSnapshot = new Map(
|
|
2505
|
+
Array.from(current.entries()).map(([k, v]) => [
|
|
2506
|
+
k,
|
|
2507
|
+
typeof v === "object" && v !== null ? { ...v } : v
|
|
2508
|
+
])
|
|
2509
|
+
);
|
|
2510
|
+
return changes;
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Reset tracker (e.g., on query change or reconnect)
|
|
2514
|
+
*/
|
|
2515
|
+
reset() {
|
|
2516
|
+
this.previousSnapshot.clear();
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Get current snapshot size for debugging/metrics
|
|
2520
|
+
*/
|
|
2521
|
+
get size() {
|
|
2522
|
+
return this.previousSnapshot.size;
|
|
2523
|
+
}
|
|
2524
|
+
};
|
|
1434
2525
|
|
|
1435
2526
|
// src/QueryHandle.ts
|
|
1436
2527
|
var QueryHandle = class {
|
|
1437
2528
|
constructor(syncEngine, mapName, filter = {}) {
|
|
1438
2529
|
this.listeners = /* @__PURE__ */ new Set();
|
|
1439
2530
|
this.currentResults = /* @__PURE__ */ new Map();
|
|
2531
|
+
// Change tracking (Phase 5.1)
|
|
2532
|
+
this.changeTracker = new ChangeTracker();
|
|
2533
|
+
this.pendingChanges = [];
|
|
2534
|
+
this.changeListeners = /* @__PURE__ */ new Set();
|
|
1440
2535
|
// Track if we've received authoritative server response
|
|
1441
2536
|
this.hasReceivedServerData = false;
|
|
1442
2537
|
this.id = crypto.randomUUID();
|
|
@@ -1479,14 +2574,15 @@ var QueryHandle = class {
|
|
|
1479
2574
|
* - Works with any async storage adapter (PostgreSQL, SQLite, Redis, etc.)
|
|
1480
2575
|
*/
|
|
1481
2576
|
onResult(items, source = "server") {
|
|
1482
|
-
|
|
2577
|
+
logger.debug({
|
|
2578
|
+
mapName: this.mapName,
|
|
2579
|
+
itemCount: items.length,
|
|
1483
2580
|
source,
|
|
1484
2581
|
currentResultsCount: this.currentResults.size,
|
|
1485
|
-
newItemKeys: items.map((i) => i.key),
|
|
1486
2582
|
hasReceivedServerData: this.hasReceivedServerData
|
|
1487
|
-
});
|
|
2583
|
+
}, "QueryHandle onResult");
|
|
1488
2584
|
if (source === "server" && items.length === 0 && !this.hasReceivedServerData) {
|
|
1489
|
-
|
|
2585
|
+
logger.debug({ mapName: this.mapName }, "QueryHandle ignoring empty server response - waiting for authoritative data");
|
|
1490
2586
|
return;
|
|
1491
2587
|
}
|
|
1492
2588
|
if (source === "server" && items.length > 0) {
|
|
@@ -1501,12 +2597,20 @@ var QueryHandle = class {
|
|
|
1501
2597
|
}
|
|
1502
2598
|
}
|
|
1503
2599
|
if (removedKeys.length > 0) {
|
|
1504
|
-
|
|
2600
|
+
logger.debug({
|
|
2601
|
+
mapName: this.mapName,
|
|
2602
|
+
removedCount: removedKeys.length,
|
|
2603
|
+
removedKeys
|
|
2604
|
+
}, "QueryHandle removed keys");
|
|
1505
2605
|
}
|
|
1506
2606
|
for (const item of items) {
|
|
1507
2607
|
this.currentResults.set(item.key, item.value);
|
|
1508
2608
|
}
|
|
1509
|
-
|
|
2609
|
+
logger.debug({
|
|
2610
|
+
mapName: this.mapName,
|
|
2611
|
+
resultCount: this.currentResults.size
|
|
2612
|
+
}, "QueryHandle after merge");
|
|
2613
|
+
this.computeAndNotifyChanges(Date.now());
|
|
1510
2614
|
this.notify();
|
|
1511
2615
|
}
|
|
1512
2616
|
/**
|
|
@@ -1518,8 +2622,80 @@ var QueryHandle = class {
|
|
|
1518
2622
|
} else {
|
|
1519
2623
|
this.currentResults.set(key, value);
|
|
1520
2624
|
}
|
|
2625
|
+
this.computeAndNotifyChanges(Date.now());
|
|
1521
2626
|
this.notify();
|
|
1522
2627
|
}
|
|
2628
|
+
/**
|
|
2629
|
+
* Subscribe to change events (Phase 5.1).
|
|
2630
|
+
* Returns an unsubscribe function.
|
|
2631
|
+
*
|
|
2632
|
+
* @example
|
|
2633
|
+
* ```typescript
|
|
2634
|
+
* const unsubscribe = handle.onChanges((changes) => {
|
|
2635
|
+
* for (const change of changes) {
|
|
2636
|
+
* if (change.type === 'add') {
|
|
2637
|
+
* console.log('Added:', change.key, change.value);
|
|
2638
|
+
* }
|
|
2639
|
+
* }
|
|
2640
|
+
* });
|
|
2641
|
+
* ```
|
|
2642
|
+
*/
|
|
2643
|
+
onChanges(listener) {
|
|
2644
|
+
this.changeListeners.add(listener);
|
|
2645
|
+
return () => this.changeListeners.delete(listener);
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* Get and clear pending changes (Phase 5.1).
|
|
2649
|
+
* Call this to retrieve all changes since the last consume.
|
|
2650
|
+
*/
|
|
2651
|
+
consumeChanges() {
|
|
2652
|
+
const changes = [...this.pendingChanges];
|
|
2653
|
+
this.pendingChanges = [];
|
|
2654
|
+
return changes;
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
2657
|
+
* Get last change without consuming (Phase 5.1).
|
|
2658
|
+
* Returns null if no pending changes.
|
|
2659
|
+
*/
|
|
2660
|
+
getLastChange() {
|
|
2661
|
+
return this.pendingChanges.length > 0 ? this.pendingChanges[this.pendingChanges.length - 1] : null;
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* Get all pending changes without consuming (Phase 5.1).
|
|
2665
|
+
*/
|
|
2666
|
+
getPendingChanges() {
|
|
2667
|
+
return [...this.pendingChanges];
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Clear all pending changes (Phase 5.1).
|
|
2671
|
+
*/
|
|
2672
|
+
clearChanges() {
|
|
2673
|
+
this.pendingChanges = [];
|
|
2674
|
+
}
|
|
2675
|
+
/**
|
|
2676
|
+
* Reset change tracker (Phase 5.1).
|
|
2677
|
+
* Use when query filter changes or on reconnect.
|
|
2678
|
+
*/
|
|
2679
|
+
resetChangeTracker() {
|
|
2680
|
+
this.changeTracker.reset();
|
|
2681
|
+
this.pendingChanges = [];
|
|
2682
|
+
}
|
|
2683
|
+
computeAndNotifyChanges(timestamp) {
|
|
2684
|
+
const changes = this.changeTracker.computeChanges(this.currentResults, timestamp);
|
|
2685
|
+
if (changes.length > 0) {
|
|
2686
|
+
this.pendingChanges.push(...changes);
|
|
2687
|
+
this.notifyChangeListeners(changes);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
notifyChangeListeners(changes) {
|
|
2691
|
+
for (const listener of this.changeListeners) {
|
|
2692
|
+
try {
|
|
2693
|
+
listener(changes);
|
|
2694
|
+
} catch (e) {
|
|
2695
|
+
logger.error({ err: e }, "QueryHandle change listener error");
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
1523
2699
|
notify() {
|
|
1524
2700
|
const results = this.getSortedResults();
|
|
1525
2701
|
for (const listener of this.listeners) {
|
|
@@ -1538,114 +2714,1866 @@ var QueryHandle = class {
|
|
|
1538
2714
|
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
1539
2715
|
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
1540
2716
|
}
|
|
1541
|
-
return 0;
|
|
2717
|
+
return 0;
|
|
2718
|
+
});
|
|
2719
|
+
}
|
|
2720
|
+
return results;
|
|
2721
|
+
}
|
|
2722
|
+
getFilter() {
|
|
2723
|
+
return this.filter;
|
|
2724
|
+
}
|
|
2725
|
+
getMapName() {
|
|
2726
|
+
return this.mapName;
|
|
2727
|
+
}
|
|
2728
|
+
};
|
|
2729
|
+
|
|
2730
|
+
// src/DistributedLock.ts
|
|
2731
|
+
var DistributedLock = class {
|
|
2732
|
+
constructor(syncEngine, name) {
|
|
2733
|
+
this.fencingToken = null;
|
|
2734
|
+
this._isLocked = false;
|
|
2735
|
+
this.syncEngine = syncEngine;
|
|
2736
|
+
this.name = name;
|
|
2737
|
+
}
|
|
2738
|
+
async lock(ttl = 1e4) {
|
|
2739
|
+
const requestId = crypto.randomUUID();
|
|
2740
|
+
try {
|
|
2741
|
+
const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
|
|
2742
|
+
this.fencingToken = result.fencingToken;
|
|
2743
|
+
this._isLocked = true;
|
|
2744
|
+
return true;
|
|
2745
|
+
} catch (e) {
|
|
2746
|
+
return false;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
async unlock() {
|
|
2750
|
+
if (!this._isLocked || this.fencingToken === null) return;
|
|
2751
|
+
const requestId = crypto.randomUUID();
|
|
2752
|
+
try {
|
|
2753
|
+
await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
|
|
2754
|
+
} finally {
|
|
2755
|
+
this._isLocked = false;
|
|
2756
|
+
this.fencingToken = null;
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
isLocked() {
|
|
2760
|
+
return this._isLocked;
|
|
2761
|
+
}
|
|
2762
|
+
};
|
|
2763
|
+
|
|
2764
|
+
// src/TopicHandle.ts
|
|
2765
|
+
var TopicHandle = class {
|
|
2766
|
+
constructor(engine, topic) {
|
|
2767
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
2768
|
+
this.engine = engine;
|
|
2769
|
+
this.topic = topic;
|
|
2770
|
+
}
|
|
2771
|
+
get id() {
|
|
2772
|
+
return this.topic;
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Publish a message to the topic
|
|
2776
|
+
*/
|
|
2777
|
+
publish(data) {
|
|
2778
|
+
this.engine.publishTopic(this.topic, data);
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Subscribe to the topic
|
|
2782
|
+
*/
|
|
2783
|
+
subscribe(callback) {
|
|
2784
|
+
if (this.listeners.size === 0) {
|
|
2785
|
+
this.engine.subscribeToTopic(this.topic, this);
|
|
2786
|
+
}
|
|
2787
|
+
this.listeners.add(callback);
|
|
2788
|
+
return () => this.unsubscribe(callback);
|
|
2789
|
+
}
|
|
2790
|
+
unsubscribe(callback) {
|
|
2791
|
+
this.listeners.delete(callback);
|
|
2792
|
+
if (this.listeners.size === 0) {
|
|
2793
|
+
this.engine.unsubscribeFromTopic(this.topic);
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
/**
|
|
2797
|
+
* Called by SyncEngine when a message is received
|
|
2798
|
+
*/
|
|
2799
|
+
onMessage(data, context) {
|
|
2800
|
+
this.listeners.forEach((cb) => {
|
|
2801
|
+
try {
|
|
2802
|
+
cb(data, context);
|
|
2803
|
+
} catch (e) {
|
|
2804
|
+
console.error("Error in topic listener", e);
|
|
2805
|
+
}
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
};
|
|
2809
|
+
|
|
2810
|
+
// src/PNCounterHandle.ts
|
|
2811
|
+
var import_core2 = require("@topgunbuild/core");
|
|
2812
|
+
var COUNTER_STORAGE_PREFIX = "__counter__:";
|
|
2813
|
+
var PNCounterHandle = class {
|
|
2814
|
+
constructor(name, nodeId, syncEngine, storageAdapter) {
|
|
2815
|
+
this.syncScheduled = false;
|
|
2816
|
+
this.persistScheduled = false;
|
|
2817
|
+
this.name = name;
|
|
2818
|
+
this.syncEngine = syncEngine;
|
|
2819
|
+
this.storageAdapter = storageAdapter;
|
|
2820
|
+
this.counter = new import_core2.PNCounterImpl({ nodeId });
|
|
2821
|
+
this.restoreFromStorage();
|
|
2822
|
+
this.unsubscribeFromUpdates = this.syncEngine.onCounterUpdate(name, (state) => {
|
|
2823
|
+
this.counter.merge(state);
|
|
2824
|
+
this.schedulePersist();
|
|
2825
|
+
});
|
|
2826
|
+
this.syncEngine.requestCounter(name);
|
|
2827
|
+
logger.debug({ name, nodeId }, "PNCounterHandle created");
|
|
2828
|
+
}
|
|
2829
|
+
/**
|
|
2830
|
+
* Restore counter state from local storage.
|
|
2831
|
+
* Called during construction to recover offline state.
|
|
2832
|
+
*/
|
|
2833
|
+
async restoreFromStorage() {
|
|
2834
|
+
if (!this.storageAdapter) {
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
try {
|
|
2838
|
+
const storageKey = COUNTER_STORAGE_PREFIX + this.name;
|
|
2839
|
+
const stored = await this.storageAdapter.getMeta(storageKey);
|
|
2840
|
+
if (stored && typeof stored === "object" && "p" in stored && "n" in stored) {
|
|
2841
|
+
const state = import_core2.PNCounterImpl.objectToState(stored);
|
|
2842
|
+
this.counter.merge(state);
|
|
2843
|
+
logger.debug({ name: this.name, value: this.counter.get() }, "PNCounter restored from storage");
|
|
2844
|
+
}
|
|
2845
|
+
} catch (err) {
|
|
2846
|
+
logger.error({ err, name: this.name }, "Failed to restore PNCounter from storage");
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
/**
|
|
2850
|
+
* Persist counter state to local storage.
|
|
2851
|
+
* Debounced to avoid excessive writes during rapid operations.
|
|
2852
|
+
*/
|
|
2853
|
+
schedulePersist() {
|
|
2854
|
+
if (!this.storageAdapter || this.persistScheduled) return;
|
|
2855
|
+
this.persistScheduled = true;
|
|
2856
|
+
setTimeout(() => {
|
|
2857
|
+
this.persistScheduled = false;
|
|
2858
|
+
this.persistToStorage();
|
|
2859
|
+
}, 100);
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* Actually persist state to storage.
|
|
2863
|
+
*/
|
|
2864
|
+
async persistToStorage() {
|
|
2865
|
+
if (!this.storageAdapter) return;
|
|
2866
|
+
try {
|
|
2867
|
+
const storageKey = COUNTER_STORAGE_PREFIX + this.name;
|
|
2868
|
+
const stateObj = import_core2.PNCounterImpl.stateToObject(this.counter.getState());
|
|
2869
|
+
await this.storageAdapter.setMeta(storageKey, stateObj);
|
|
2870
|
+
logger.debug({ name: this.name, value: this.counter.get() }, "PNCounter persisted to storage");
|
|
2871
|
+
} catch (err) {
|
|
2872
|
+
logger.error({ err, name: this.name }, "Failed to persist PNCounter to storage");
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* Get current counter value.
|
|
2877
|
+
*/
|
|
2878
|
+
get() {
|
|
2879
|
+
return this.counter.get();
|
|
2880
|
+
}
|
|
2881
|
+
/**
|
|
2882
|
+
* Increment by 1 and return new value.
|
|
2883
|
+
*/
|
|
2884
|
+
increment() {
|
|
2885
|
+
const value = this.counter.increment();
|
|
2886
|
+
this.scheduleSync();
|
|
2887
|
+
this.schedulePersist();
|
|
2888
|
+
return value;
|
|
2889
|
+
}
|
|
2890
|
+
/**
|
|
2891
|
+
* Decrement by 1 and return new value.
|
|
2892
|
+
*/
|
|
2893
|
+
decrement() {
|
|
2894
|
+
const value = this.counter.decrement();
|
|
2895
|
+
this.scheduleSync();
|
|
2896
|
+
this.schedulePersist();
|
|
2897
|
+
return value;
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* Add delta (positive or negative) and return new value.
|
|
2901
|
+
*/
|
|
2902
|
+
addAndGet(delta) {
|
|
2903
|
+
const value = this.counter.addAndGet(delta);
|
|
2904
|
+
if (delta !== 0) {
|
|
2905
|
+
this.scheduleSync();
|
|
2906
|
+
this.schedulePersist();
|
|
2907
|
+
}
|
|
2908
|
+
return value;
|
|
2909
|
+
}
|
|
2910
|
+
/**
|
|
2911
|
+
* Get state for sync.
|
|
2912
|
+
*/
|
|
2913
|
+
getState() {
|
|
2914
|
+
return this.counter.getState();
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Merge remote state.
|
|
2918
|
+
*/
|
|
2919
|
+
merge(remote) {
|
|
2920
|
+
this.counter.merge(remote);
|
|
2921
|
+
}
|
|
2922
|
+
/**
|
|
2923
|
+
* Subscribe to value changes.
|
|
2924
|
+
*/
|
|
2925
|
+
subscribe(listener) {
|
|
2926
|
+
return this.counter.subscribe(listener);
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* Get the counter name.
|
|
2930
|
+
*/
|
|
2931
|
+
getName() {
|
|
2932
|
+
return this.name;
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Cleanup resources.
|
|
2936
|
+
*/
|
|
2937
|
+
dispose() {
|
|
2938
|
+
if (this.unsubscribeFromUpdates) {
|
|
2939
|
+
this.unsubscribeFromUpdates();
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
/**
|
|
2943
|
+
* Schedule sync to server with debouncing.
|
|
2944
|
+
* Batches rapid increments to avoid network spam.
|
|
2945
|
+
*/
|
|
2946
|
+
scheduleSync() {
|
|
2947
|
+
if (this.syncScheduled) return;
|
|
2948
|
+
this.syncScheduled = true;
|
|
2949
|
+
setTimeout(() => {
|
|
2950
|
+
this.syncScheduled = false;
|
|
2951
|
+
this.syncEngine.syncCounter(this.name, this.counter.getState());
|
|
2952
|
+
}, 50);
|
|
2953
|
+
}
|
|
2954
|
+
};
|
|
2955
|
+
|
|
2956
|
+
// src/EventJournalReader.ts
|
|
2957
|
+
var EventJournalReader = class {
|
|
2958
|
+
constructor(syncEngine) {
|
|
2959
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
2960
|
+
this.subscriptionCounter = 0;
|
|
2961
|
+
this.syncEngine = syncEngine;
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* Read events from sequence with optional limit.
|
|
2965
|
+
*
|
|
2966
|
+
* @param sequence Starting sequence (inclusive)
|
|
2967
|
+
* @param limit Maximum events to return (default: 100)
|
|
2968
|
+
* @returns Promise resolving to array of events
|
|
2969
|
+
*/
|
|
2970
|
+
async readFrom(sequence, limit = 100) {
|
|
2971
|
+
const requestId = this.generateRequestId();
|
|
2972
|
+
return new Promise((resolve, reject) => {
|
|
2973
|
+
const timeout = setTimeout(() => {
|
|
2974
|
+
reject(new Error("Journal read timeout"));
|
|
2975
|
+
}, 1e4);
|
|
2976
|
+
const handleResponse = (message) => {
|
|
2977
|
+
if (message.type === "JOURNAL_READ_RESPONSE" && message.requestId === requestId) {
|
|
2978
|
+
clearTimeout(timeout);
|
|
2979
|
+
this.syncEngine.off("message", handleResponse);
|
|
2980
|
+
const events = message.events.map((e) => this.parseEvent(e));
|
|
2981
|
+
resolve(events);
|
|
2982
|
+
}
|
|
2983
|
+
};
|
|
2984
|
+
this.syncEngine.on("message", handleResponse);
|
|
2985
|
+
this.syncEngine.send({
|
|
2986
|
+
type: "JOURNAL_READ",
|
|
2987
|
+
requestId,
|
|
2988
|
+
fromSequence: sequence.toString(),
|
|
2989
|
+
limit
|
|
2990
|
+
});
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
/**
|
|
2994
|
+
* Read events for a specific map.
|
|
2995
|
+
*
|
|
2996
|
+
* @param mapName Map name to filter
|
|
2997
|
+
* @param sequence Starting sequence (default: 0n)
|
|
2998
|
+
* @param limit Maximum events to return (default: 100)
|
|
2999
|
+
*/
|
|
3000
|
+
async readMapEvents(mapName, sequence = 0n, limit = 100) {
|
|
3001
|
+
const requestId = this.generateRequestId();
|
|
3002
|
+
return new Promise((resolve, reject) => {
|
|
3003
|
+
const timeout = setTimeout(() => {
|
|
3004
|
+
reject(new Error("Journal read timeout"));
|
|
3005
|
+
}, 1e4);
|
|
3006
|
+
const handleResponse = (message) => {
|
|
3007
|
+
if (message.type === "JOURNAL_READ_RESPONSE" && message.requestId === requestId) {
|
|
3008
|
+
clearTimeout(timeout);
|
|
3009
|
+
this.syncEngine.off("message", handleResponse);
|
|
3010
|
+
const events = message.events.map((e) => this.parseEvent(e));
|
|
3011
|
+
resolve(events);
|
|
3012
|
+
}
|
|
3013
|
+
};
|
|
3014
|
+
this.syncEngine.on("message", handleResponse);
|
|
3015
|
+
this.syncEngine.send({
|
|
3016
|
+
type: "JOURNAL_READ",
|
|
3017
|
+
requestId,
|
|
3018
|
+
fromSequence: sequence.toString(),
|
|
3019
|
+
limit,
|
|
3020
|
+
mapName
|
|
3021
|
+
});
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Subscribe to new journal events.
|
|
3026
|
+
*
|
|
3027
|
+
* @param listener Callback for each event
|
|
3028
|
+
* @param options Subscription options
|
|
3029
|
+
* @returns Unsubscribe function
|
|
3030
|
+
*/
|
|
3031
|
+
subscribe(listener, options = {}) {
|
|
3032
|
+
const subscriptionId = this.generateRequestId();
|
|
3033
|
+
this.listeners.set(subscriptionId, listener);
|
|
3034
|
+
const handleEvent = (message) => {
|
|
3035
|
+
if (message.type === "JOURNAL_EVENT") {
|
|
3036
|
+
const event = this.parseEvent(message.event);
|
|
3037
|
+
if (options.mapName && event.mapName !== options.mapName) return;
|
|
3038
|
+
if (options.types && !options.types.includes(event.type)) return;
|
|
3039
|
+
const listenerFn = this.listeners.get(subscriptionId);
|
|
3040
|
+
if (listenerFn) {
|
|
3041
|
+
try {
|
|
3042
|
+
listenerFn(event);
|
|
3043
|
+
} catch (e) {
|
|
3044
|
+
logger.error({ err: e }, "Journal listener error");
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
};
|
|
3049
|
+
this.syncEngine.on("message", handleEvent);
|
|
3050
|
+
this.syncEngine.send({
|
|
3051
|
+
type: "JOURNAL_SUBSCRIBE",
|
|
3052
|
+
requestId: subscriptionId,
|
|
3053
|
+
fromSequence: options.fromSequence?.toString(),
|
|
3054
|
+
mapName: options.mapName,
|
|
3055
|
+
types: options.types
|
|
3056
|
+
});
|
|
3057
|
+
return () => {
|
|
3058
|
+
this.listeners.delete(subscriptionId);
|
|
3059
|
+
this.syncEngine.off("message", handleEvent);
|
|
3060
|
+
this.syncEngine.send({
|
|
3061
|
+
type: "JOURNAL_UNSUBSCRIBE",
|
|
3062
|
+
subscriptionId
|
|
3063
|
+
});
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
/**
|
|
3067
|
+
* Get the latest sequence number from server.
|
|
3068
|
+
*/
|
|
3069
|
+
async getLatestSequence() {
|
|
3070
|
+
const events = await this.readFrom(0n, 1);
|
|
3071
|
+
if (events.length === 0) return 0n;
|
|
3072
|
+
return events[events.length - 1].sequence;
|
|
3073
|
+
}
|
|
3074
|
+
/**
|
|
3075
|
+
* Parse network event data to JournalEvent.
|
|
3076
|
+
*/
|
|
3077
|
+
parseEvent(raw) {
|
|
3078
|
+
return {
|
|
3079
|
+
sequence: BigInt(raw.sequence),
|
|
3080
|
+
type: raw.type,
|
|
3081
|
+
mapName: raw.mapName,
|
|
3082
|
+
key: raw.key,
|
|
3083
|
+
value: raw.value,
|
|
3084
|
+
previousValue: raw.previousValue,
|
|
3085
|
+
timestamp: raw.timestamp,
|
|
3086
|
+
nodeId: raw.nodeId,
|
|
3087
|
+
metadata: raw.metadata
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* Generate unique request ID.
|
|
3092
|
+
*/
|
|
3093
|
+
generateRequestId() {
|
|
3094
|
+
return `journal_${Date.now()}_${++this.subscriptionCounter}`;
|
|
3095
|
+
}
|
|
3096
|
+
};
|
|
3097
|
+
|
|
3098
|
+
// src/cluster/ClusterClient.ts
|
|
3099
|
+
var import_core6 = require("@topgunbuild/core");
|
|
3100
|
+
|
|
3101
|
+
// src/cluster/ConnectionPool.ts
|
|
3102
|
+
var import_core3 = require("@topgunbuild/core");
|
|
3103
|
+
var import_core4 = require("@topgunbuild/core");
|
|
3104
|
+
var ConnectionPool = class {
|
|
3105
|
+
constructor(config = {}) {
|
|
3106
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
3107
|
+
this.connections = /* @__PURE__ */ new Map();
|
|
3108
|
+
this.primaryNodeId = null;
|
|
3109
|
+
this.healthCheckTimer = null;
|
|
3110
|
+
this.authToken = null;
|
|
3111
|
+
this.config = {
|
|
3112
|
+
...import_core3.DEFAULT_CONNECTION_POOL_CONFIG,
|
|
3113
|
+
...config
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
// ============================================
|
|
3117
|
+
// Event Emitter Methods (browser-compatible)
|
|
3118
|
+
// ============================================
|
|
3119
|
+
on(event, listener) {
|
|
3120
|
+
if (!this.listeners.has(event)) {
|
|
3121
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
3122
|
+
}
|
|
3123
|
+
this.listeners.get(event).add(listener);
|
|
3124
|
+
return this;
|
|
3125
|
+
}
|
|
3126
|
+
off(event, listener) {
|
|
3127
|
+
this.listeners.get(event)?.delete(listener);
|
|
3128
|
+
return this;
|
|
3129
|
+
}
|
|
3130
|
+
emit(event, ...args) {
|
|
3131
|
+
const eventListeners = this.listeners.get(event);
|
|
3132
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
3133
|
+
return false;
|
|
3134
|
+
}
|
|
3135
|
+
for (const listener of eventListeners) {
|
|
3136
|
+
try {
|
|
3137
|
+
listener(...args);
|
|
3138
|
+
} catch (err) {
|
|
3139
|
+
logger.error({ event, err }, "Error in event listener");
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
return true;
|
|
3143
|
+
}
|
|
3144
|
+
removeAllListeners(event) {
|
|
3145
|
+
if (event) {
|
|
3146
|
+
this.listeners.delete(event);
|
|
3147
|
+
} else {
|
|
3148
|
+
this.listeners.clear();
|
|
3149
|
+
}
|
|
3150
|
+
return this;
|
|
3151
|
+
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Set authentication token for all connections
|
|
3154
|
+
*/
|
|
3155
|
+
setAuthToken(token) {
|
|
3156
|
+
this.authToken = token;
|
|
3157
|
+
for (const conn of this.connections.values()) {
|
|
3158
|
+
if (conn.state === "CONNECTED") {
|
|
3159
|
+
this.sendAuth(conn);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
/**
|
|
3164
|
+
* Add a node to the connection pool
|
|
3165
|
+
*/
|
|
3166
|
+
async addNode(nodeId, endpoint) {
|
|
3167
|
+
if (this.connections.has(nodeId)) {
|
|
3168
|
+
const existing = this.connections.get(nodeId);
|
|
3169
|
+
if (existing.endpoint !== endpoint) {
|
|
3170
|
+
await this.removeNode(nodeId);
|
|
3171
|
+
} else {
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
const connection = {
|
|
3176
|
+
nodeId,
|
|
3177
|
+
endpoint,
|
|
3178
|
+
socket: null,
|
|
3179
|
+
state: "DISCONNECTED",
|
|
3180
|
+
lastSeen: 0,
|
|
3181
|
+
latencyMs: 0,
|
|
3182
|
+
reconnectAttempts: 0,
|
|
3183
|
+
reconnectTimer: null,
|
|
3184
|
+
pendingMessages: []
|
|
3185
|
+
};
|
|
3186
|
+
this.connections.set(nodeId, connection);
|
|
3187
|
+
if (!this.primaryNodeId) {
|
|
3188
|
+
this.primaryNodeId = nodeId;
|
|
3189
|
+
}
|
|
3190
|
+
await this.connect(nodeId);
|
|
3191
|
+
}
|
|
3192
|
+
/**
|
|
3193
|
+
* Remove a node from the connection pool
|
|
3194
|
+
*/
|
|
3195
|
+
async removeNode(nodeId) {
|
|
3196
|
+
const connection = this.connections.get(nodeId);
|
|
3197
|
+
if (!connection) return;
|
|
3198
|
+
if (connection.reconnectTimer) {
|
|
3199
|
+
clearTimeout(connection.reconnectTimer);
|
|
3200
|
+
connection.reconnectTimer = null;
|
|
3201
|
+
}
|
|
3202
|
+
if (connection.socket) {
|
|
3203
|
+
connection.socket.onclose = null;
|
|
3204
|
+
connection.socket.close();
|
|
3205
|
+
connection.socket = null;
|
|
3206
|
+
}
|
|
3207
|
+
this.connections.delete(nodeId);
|
|
3208
|
+
if (this.primaryNodeId === nodeId) {
|
|
3209
|
+
this.primaryNodeId = this.connections.size > 0 ? this.connections.keys().next().value ?? null : null;
|
|
3210
|
+
}
|
|
3211
|
+
logger.info({ nodeId }, "Node removed from connection pool");
|
|
3212
|
+
}
|
|
3213
|
+
/**
|
|
3214
|
+
* Get connection for a specific node
|
|
3215
|
+
*/
|
|
3216
|
+
getConnection(nodeId) {
|
|
3217
|
+
const connection = this.connections.get(nodeId);
|
|
3218
|
+
if (!connection || connection.state !== "AUTHENTICATED") {
|
|
3219
|
+
return null;
|
|
3220
|
+
}
|
|
3221
|
+
return connection.socket;
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* Get primary connection (first/seed node)
|
|
3225
|
+
*/
|
|
3226
|
+
getPrimaryConnection() {
|
|
3227
|
+
if (!this.primaryNodeId) return null;
|
|
3228
|
+
return this.getConnection(this.primaryNodeId);
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* Get any healthy connection
|
|
3232
|
+
*/
|
|
3233
|
+
getAnyHealthyConnection() {
|
|
3234
|
+
for (const [nodeId, conn] of this.connections) {
|
|
3235
|
+
if (conn.state === "AUTHENTICATED" && conn.socket) {
|
|
3236
|
+
return { nodeId, socket: conn.socket };
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
return null;
|
|
3240
|
+
}
|
|
3241
|
+
/**
|
|
3242
|
+
* Send message to a specific node
|
|
3243
|
+
*/
|
|
3244
|
+
send(nodeId, message) {
|
|
3245
|
+
const connection = this.connections.get(nodeId);
|
|
3246
|
+
if (!connection) {
|
|
3247
|
+
logger.warn({ nodeId }, "Cannot send: node not in pool");
|
|
3248
|
+
return false;
|
|
3249
|
+
}
|
|
3250
|
+
const data = (0, import_core4.serialize)(message);
|
|
3251
|
+
if (connection.state === "AUTHENTICATED" && connection.socket?.readyState === WebSocket.OPEN) {
|
|
3252
|
+
connection.socket.send(data);
|
|
3253
|
+
return true;
|
|
3254
|
+
}
|
|
3255
|
+
if (connection.pendingMessages.length < 1e3) {
|
|
3256
|
+
connection.pendingMessages.push(data);
|
|
3257
|
+
return true;
|
|
3258
|
+
}
|
|
3259
|
+
logger.warn({ nodeId }, "Message queue full, dropping message");
|
|
3260
|
+
return false;
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Send message to primary node
|
|
3264
|
+
*/
|
|
3265
|
+
sendToPrimary(message) {
|
|
3266
|
+
if (!this.primaryNodeId) {
|
|
3267
|
+
logger.warn("No primary node available");
|
|
3268
|
+
return false;
|
|
3269
|
+
}
|
|
3270
|
+
return this.send(this.primaryNodeId, message);
|
|
3271
|
+
}
|
|
3272
|
+
/**
|
|
3273
|
+
* Get health status for all nodes
|
|
3274
|
+
*/
|
|
3275
|
+
getHealthStatus() {
|
|
3276
|
+
const status = /* @__PURE__ */ new Map();
|
|
3277
|
+
for (const [nodeId, conn] of this.connections) {
|
|
3278
|
+
status.set(nodeId, {
|
|
3279
|
+
nodeId,
|
|
3280
|
+
state: conn.state,
|
|
3281
|
+
lastSeen: conn.lastSeen,
|
|
3282
|
+
latencyMs: conn.latencyMs,
|
|
3283
|
+
reconnectAttempts: conn.reconnectAttempts
|
|
3284
|
+
});
|
|
3285
|
+
}
|
|
3286
|
+
return status;
|
|
3287
|
+
}
|
|
3288
|
+
/**
|
|
3289
|
+
* Get list of connected node IDs
|
|
3290
|
+
*/
|
|
3291
|
+
getConnectedNodes() {
|
|
3292
|
+
return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
|
|
3293
|
+
}
|
|
3294
|
+
/**
|
|
3295
|
+
* Get all node IDs
|
|
3296
|
+
*/
|
|
3297
|
+
getAllNodes() {
|
|
3298
|
+
return Array.from(this.connections.keys());
|
|
3299
|
+
}
|
|
3300
|
+
/**
|
|
3301
|
+
* Check if node is connected and authenticated
|
|
3302
|
+
*/
|
|
3303
|
+
isNodeConnected(nodeId) {
|
|
3304
|
+
const conn = this.connections.get(nodeId);
|
|
3305
|
+
return conn?.state === "AUTHENTICATED";
|
|
3306
|
+
}
|
|
3307
|
+
/**
|
|
3308
|
+
* Check if connected to a specific node.
|
|
3309
|
+
* Alias for isNodeConnected() for IConnectionProvider compatibility.
|
|
3310
|
+
*/
|
|
3311
|
+
isConnected(nodeId) {
|
|
3312
|
+
return this.isNodeConnected(nodeId);
|
|
3313
|
+
}
|
|
3314
|
+
/**
|
|
3315
|
+
* Start health monitoring
|
|
3316
|
+
*/
|
|
3317
|
+
startHealthCheck() {
|
|
3318
|
+
if (this.healthCheckTimer) return;
|
|
3319
|
+
this.healthCheckTimer = setInterval(() => {
|
|
3320
|
+
this.performHealthCheck();
|
|
3321
|
+
}, this.config.healthCheckIntervalMs);
|
|
3322
|
+
}
|
|
3323
|
+
/**
|
|
3324
|
+
* Stop health monitoring
|
|
3325
|
+
*/
|
|
3326
|
+
stopHealthCheck() {
|
|
3327
|
+
if (this.healthCheckTimer) {
|
|
3328
|
+
clearInterval(this.healthCheckTimer);
|
|
3329
|
+
this.healthCheckTimer = null;
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
/**
|
|
3333
|
+
* Close all connections and cleanup
|
|
3334
|
+
*/
|
|
3335
|
+
close() {
|
|
3336
|
+
this.stopHealthCheck();
|
|
3337
|
+
for (const nodeId of this.connections.keys()) {
|
|
3338
|
+
this.removeNode(nodeId);
|
|
3339
|
+
}
|
|
3340
|
+
this.connections.clear();
|
|
3341
|
+
this.primaryNodeId = null;
|
|
3342
|
+
}
|
|
3343
|
+
// ============================================
|
|
3344
|
+
// Private Methods
|
|
3345
|
+
// ============================================
|
|
3346
|
+
async connect(nodeId) {
|
|
3347
|
+
const connection = this.connections.get(nodeId);
|
|
3348
|
+
if (!connection) return;
|
|
3349
|
+
if (connection.state === "CONNECTING" || connection.state === "CONNECTED") {
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3352
|
+
connection.state = "CONNECTING";
|
|
3353
|
+
logger.info({ nodeId, endpoint: connection.endpoint }, "Connecting to node");
|
|
3354
|
+
try {
|
|
3355
|
+
const socket = new WebSocket(connection.endpoint);
|
|
3356
|
+
socket.binaryType = "arraybuffer";
|
|
3357
|
+
connection.socket = socket;
|
|
3358
|
+
socket.onopen = () => {
|
|
3359
|
+
connection.state = "CONNECTED";
|
|
3360
|
+
connection.reconnectAttempts = 0;
|
|
3361
|
+
connection.lastSeen = Date.now();
|
|
3362
|
+
logger.info({ nodeId }, "Connected to node");
|
|
3363
|
+
this.emit("node:connected", nodeId);
|
|
3364
|
+
if (this.authToken) {
|
|
3365
|
+
this.sendAuth(connection);
|
|
3366
|
+
}
|
|
3367
|
+
};
|
|
3368
|
+
socket.onmessage = (event) => {
|
|
3369
|
+
connection.lastSeen = Date.now();
|
|
3370
|
+
this.handleMessage(nodeId, event);
|
|
3371
|
+
};
|
|
3372
|
+
socket.onerror = (error) => {
|
|
3373
|
+
logger.error({ nodeId, error }, "WebSocket error");
|
|
3374
|
+
this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
|
|
3375
|
+
};
|
|
3376
|
+
socket.onclose = () => {
|
|
3377
|
+
const wasConnected = connection.state === "AUTHENTICATED";
|
|
3378
|
+
connection.state = "DISCONNECTED";
|
|
3379
|
+
connection.socket = null;
|
|
3380
|
+
if (wasConnected) {
|
|
3381
|
+
this.emit("node:disconnected", nodeId, "Connection closed");
|
|
3382
|
+
}
|
|
3383
|
+
this.scheduleReconnect(nodeId);
|
|
3384
|
+
};
|
|
3385
|
+
} catch (error) {
|
|
3386
|
+
connection.state = "FAILED";
|
|
3387
|
+
logger.error({ nodeId, error }, "Failed to connect");
|
|
3388
|
+
this.scheduleReconnect(nodeId);
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
sendAuth(connection) {
|
|
3392
|
+
if (!this.authToken || !connection.socket) return;
|
|
3393
|
+
connection.socket.send((0, import_core4.serialize)({
|
|
3394
|
+
type: "AUTH",
|
|
3395
|
+
token: this.authToken
|
|
3396
|
+
}));
|
|
3397
|
+
}
|
|
3398
|
+
handleMessage(nodeId, event) {
|
|
3399
|
+
const connection = this.connections.get(nodeId);
|
|
3400
|
+
if (!connection) return;
|
|
3401
|
+
let message;
|
|
3402
|
+
try {
|
|
3403
|
+
if (event.data instanceof ArrayBuffer) {
|
|
3404
|
+
message = (0, import_core4.deserialize)(new Uint8Array(event.data));
|
|
3405
|
+
} else {
|
|
3406
|
+
message = JSON.parse(event.data);
|
|
3407
|
+
}
|
|
3408
|
+
} catch (e) {
|
|
3409
|
+
logger.error({ nodeId, error: e }, "Failed to parse message");
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
if (message.type === "AUTH_ACK") {
|
|
3413
|
+
connection.state = "AUTHENTICATED";
|
|
3414
|
+
logger.info({ nodeId }, "Authenticated with node");
|
|
3415
|
+
this.emit("node:healthy", nodeId);
|
|
3416
|
+
this.flushPendingMessages(connection);
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
if (message.type === "AUTH_REQUIRED") {
|
|
3420
|
+
if (this.authToken) {
|
|
3421
|
+
this.sendAuth(connection);
|
|
3422
|
+
}
|
|
3423
|
+
return;
|
|
3424
|
+
}
|
|
3425
|
+
if (message.type === "AUTH_FAIL") {
|
|
3426
|
+
logger.error({ nodeId, error: message.error }, "Authentication failed");
|
|
3427
|
+
connection.state = "FAILED";
|
|
3428
|
+
return;
|
|
3429
|
+
}
|
|
3430
|
+
if (message.type === "PONG") {
|
|
3431
|
+
if (message.timestamp) {
|
|
3432
|
+
connection.latencyMs = Date.now() - message.timestamp;
|
|
3433
|
+
}
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
|
|
3437
|
+
this.emit("message", nodeId, message);
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
this.emit("message", nodeId, message);
|
|
3441
|
+
}
|
|
3442
|
+
flushPendingMessages(connection) {
|
|
3443
|
+
if (!connection.socket || connection.state !== "AUTHENTICATED") return;
|
|
3444
|
+
const pending = connection.pendingMessages;
|
|
3445
|
+
connection.pendingMessages = [];
|
|
3446
|
+
for (const data of pending) {
|
|
3447
|
+
if (connection.socket.readyState === WebSocket.OPEN) {
|
|
3448
|
+
connection.socket.send(data);
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
if (pending.length > 0) {
|
|
3452
|
+
logger.debug({ nodeId: connection.nodeId, count: pending.length }, "Flushed pending messages");
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
scheduleReconnect(nodeId) {
|
|
3456
|
+
const connection = this.connections.get(nodeId);
|
|
3457
|
+
if (!connection) return;
|
|
3458
|
+
if (connection.reconnectTimer) {
|
|
3459
|
+
clearTimeout(connection.reconnectTimer);
|
|
3460
|
+
connection.reconnectTimer = null;
|
|
3461
|
+
}
|
|
3462
|
+
if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
3463
|
+
connection.state = "FAILED";
|
|
3464
|
+
logger.error({ nodeId, attempts: connection.reconnectAttempts }, "Max reconnect attempts reached");
|
|
3465
|
+
this.emit("node:unhealthy", nodeId, "Max reconnect attempts reached");
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
const delay = Math.min(
|
|
3469
|
+
this.config.reconnectDelayMs * Math.pow(2, connection.reconnectAttempts),
|
|
3470
|
+
this.config.maxReconnectDelayMs
|
|
3471
|
+
);
|
|
3472
|
+
connection.state = "RECONNECTING";
|
|
3473
|
+
connection.reconnectAttempts++;
|
|
3474
|
+
logger.info({ nodeId, delay, attempt: connection.reconnectAttempts }, "Scheduling reconnect");
|
|
3475
|
+
connection.reconnectTimer = setTimeout(() => {
|
|
3476
|
+
connection.reconnectTimer = null;
|
|
3477
|
+
this.connect(nodeId);
|
|
3478
|
+
}, delay);
|
|
3479
|
+
}
|
|
3480
|
+
performHealthCheck() {
|
|
3481
|
+
const now = Date.now();
|
|
3482
|
+
for (const [nodeId, connection] of this.connections) {
|
|
3483
|
+
if (connection.state !== "AUTHENTICATED") continue;
|
|
3484
|
+
const timeSinceLastSeen = now - connection.lastSeen;
|
|
3485
|
+
if (timeSinceLastSeen > this.config.healthCheckIntervalMs * 3) {
|
|
3486
|
+
logger.warn({ nodeId, timeSinceLastSeen }, "Node appears stale, sending ping");
|
|
3487
|
+
}
|
|
3488
|
+
if (connection.socket?.readyState === WebSocket.OPEN) {
|
|
3489
|
+
connection.socket.send((0, import_core4.serialize)({
|
|
3490
|
+
type: "PING",
|
|
3491
|
+
timestamp: now
|
|
3492
|
+
}));
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
};
|
|
3497
|
+
|
|
3498
|
+
// src/cluster/PartitionRouter.ts
|
|
3499
|
+
var import_core5 = require("@topgunbuild/core");
|
|
3500
|
+
var PartitionRouter = class {
|
|
3501
|
+
constructor(connectionPool, config = {}) {
|
|
3502
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
3503
|
+
this.partitionMap = null;
|
|
3504
|
+
this.lastRefreshTime = 0;
|
|
3505
|
+
this.refreshTimer = null;
|
|
3506
|
+
this.pendingRefresh = null;
|
|
3507
|
+
this.connectionPool = connectionPool;
|
|
3508
|
+
this.config = {
|
|
3509
|
+
...import_core5.DEFAULT_PARTITION_ROUTER_CONFIG,
|
|
3510
|
+
...config
|
|
3511
|
+
};
|
|
3512
|
+
this.connectionPool.on("message", (nodeId, message) => {
|
|
3513
|
+
if (message.type === "PARTITION_MAP") {
|
|
3514
|
+
this.handlePartitionMap(message);
|
|
3515
|
+
} else if (message.type === "PARTITION_MAP_DELTA") {
|
|
3516
|
+
this.handlePartitionMapDelta(message);
|
|
3517
|
+
}
|
|
3518
|
+
});
|
|
3519
|
+
}
|
|
3520
|
+
// ============================================
|
|
3521
|
+
// Event Emitter Methods (browser-compatible)
|
|
3522
|
+
// ============================================
|
|
3523
|
+
on(event, listener) {
|
|
3524
|
+
if (!this.listeners.has(event)) {
|
|
3525
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
3526
|
+
}
|
|
3527
|
+
this.listeners.get(event).add(listener);
|
|
3528
|
+
return this;
|
|
3529
|
+
}
|
|
3530
|
+
off(event, listener) {
|
|
3531
|
+
this.listeners.get(event)?.delete(listener);
|
|
3532
|
+
return this;
|
|
3533
|
+
}
|
|
3534
|
+
once(event, listener) {
|
|
3535
|
+
const wrapper = (...args) => {
|
|
3536
|
+
this.off(event, wrapper);
|
|
3537
|
+
listener(...args);
|
|
3538
|
+
};
|
|
3539
|
+
return this.on(event, wrapper);
|
|
3540
|
+
}
|
|
3541
|
+
emit(event, ...args) {
|
|
3542
|
+
const eventListeners = this.listeners.get(event);
|
|
3543
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
3544
|
+
return false;
|
|
3545
|
+
}
|
|
3546
|
+
for (const listener of eventListeners) {
|
|
3547
|
+
try {
|
|
3548
|
+
listener(...args);
|
|
3549
|
+
} catch (err) {
|
|
3550
|
+
logger.error({ event, err }, "Error in event listener");
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
return true;
|
|
3554
|
+
}
|
|
3555
|
+
removeListener(event, listener) {
|
|
3556
|
+
return this.off(event, listener);
|
|
3557
|
+
}
|
|
3558
|
+
removeAllListeners(event) {
|
|
3559
|
+
if (event) {
|
|
3560
|
+
this.listeners.delete(event);
|
|
3561
|
+
} else {
|
|
3562
|
+
this.listeners.clear();
|
|
3563
|
+
}
|
|
3564
|
+
return this;
|
|
3565
|
+
}
|
|
3566
|
+
/**
|
|
3567
|
+
* Get the partition ID for a given key
|
|
3568
|
+
*/
|
|
3569
|
+
getPartitionId(key) {
|
|
3570
|
+
return Math.abs((0, import_core5.hashString)(key)) % import_core5.PARTITION_COUNT;
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* Route a key to the owner node
|
|
3574
|
+
*/
|
|
3575
|
+
route(key) {
|
|
3576
|
+
if (!this.partitionMap) {
|
|
3577
|
+
return null;
|
|
3578
|
+
}
|
|
3579
|
+
const partitionId = this.getPartitionId(key);
|
|
3580
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
3581
|
+
if (!partition) {
|
|
3582
|
+
logger.warn({ key, partitionId }, "Partition not found in map");
|
|
3583
|
+
return null;
|
|
3584
|
+
}
|
|
3585
|
+
return {
|
|
3586
|
+
nodeId: partition.ownerNodeId,
|
|
3587
|
+
partitionId,
|
|
3588
|
+
isOwner: true,
|
|
3589
|
+
isBackup: false
|
|
3590
|
+
};
|
|
3591
|
+
}
|
|
3592
|
+
/**
|
|
3593
|
+
* Route a key and get the WebSocket connection to use
|
|
3594
|
+
*/
|
|
3595
|
+
routeToConnection(key) {
|
|
3596
|
+
const routing = this.route(key);
|
|
3597
|
+
if (!routing) {
|
|
3598
|
+
if (this.config.fallbackMode === "forward") {
|
|
3599
|
+
const primary = this.connectionPool.getAnyHealthyConnection();
|
|
3600
|
+
if (primary) {
|
|
3601
|
+
return primary;
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
return null;
|
|
3605
|
+
}
|
|
3606
|
+
const socket = this.connectionPool.getConnection(routing.nodeId);
|
|
3607
|
+
if (socket) {
|
|
3608
|
+
return { nodeId: routing.nodeId, socket };
|
|
3609
|
+
}
|
|
3610
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
|
|
3611
|
+
if (partition) {
|
|
3612
|
+
for (const backupId of partition.backupNodeIds) {
|
|
3613
|
+
const backupSocket = this.connectionPool.getConnection(backupId);
|
|
3614
|
+
if (backupSocket) {
|
|
3615
|
+
logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
|
|
3616
|
+
return { nodeId: backupId, socket: backupSocket };
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
if (this.config.fallbackMode === "forward") {
|
|
3621
|
+
return this.connectionPool.getAnyHealthyConnection();
|
|
3622
|
+
}
|
|
3623
|
+
return null;
|
|
3624
|
+
}
|
|
3625
|
+
/**
|
|
3626
|
+
* Get routing info for multiple keys (batch routing)
|
|
3627
|
+
*/
|
|
3628
|
+
routeBatch(keys) {
|
|
3629
|
+
const result = /* @__PURE__ */ new Map();
|
|
3630
|
+
for (const key of keys) {
|
|
3631
|
+
const routing = this.route(key);
|
|
3632
|
+
if (routing) {
|
|
3633
|
+
const nodeId = routing.nodeId;
|
|
3634
|
+
if (!result.has(nodeId)) {
|
|
3635
|
+
result.set(nodeId, []);
|
|
3636
|
+
}
|
|
3637
|
+
result.get(nodeId).push({ ...routing, key });
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
return result;
|
|
3641
|
+
}
|
|
3642
|
+
/**
|
|
3643
|
+
* Get all partitions owned by a specific node
|
|
3644
|
+
*/
|
|
3645
|
+
getPartitionsForNode(nodeId) {
|
|
3646
|
+
if (!this.partitionMap) return [];
|
|
3647
|
+
return this.partitionMap.partitions.filter((p) => p.ownerNodeId === nodeId).map((p) => p.partitionId);
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* Get current partition map version
|
|
3651
|
+
*/
|
|
3652
|
+
getMapVersion() {
|
|
3653
|
+
return this.partitionMap?.version ?? 0;
|
|
3654
|
+
}
|
|
3655
|
+
/**
|
|
3656
|
+
* Check if partition map is available
|
|
3657
|
+
*/
|
|
3658
|
+
hasPartitionMap() {
|
|
3659
|
+
return this.partitionMap !== null;
|
|
3660
|
+
}
|
|
3661
|
+
/**
|
|
3662
|
+
* Get owner node for a key.
|
|
3663
|
+
* Returns null if partition map is not available.
|
|
3664
|
+
*/
|
|
3665
|
+
getOwner(key) {
|
|
3666
|
+
if (!this.partitionMap) return null;
|
|
3667
|
+
const partitionId = this.getPartitionId(key);
|
|
3668
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
3669
|
+
return partition?.ownerNodeId ?? null;
|
|
3670
|
+
}
|
|
3671
|
+
/**
|
|
3672
|
+
* Get backup nodes for a key.
|
|
3673
|
+
* Returns empty array if partition map is not available.
|
|
3674
|
+
*/
|
|
3675
|
+
getBackups(key) {
|
|
3676
|
+
if (!this.partitionMap) return [];
|
|
3677
|
+
const partitionId = this.getPartitionId(key);
|
|
3678
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
3679
|
+
return partition?.backupNodeIds ?? [];
|
|
3680
|
+
}
|
|
3681
|
+
/**
|
|
3682
|
+
* Get the full partition map.
|
|
3683
|
+
* Returns null if not available.
|
|
3684
|
+
*/
|
|
3685
|
+
getMap() {
|
|
3686
|
+
return this.partitionMap;
|
|
3687
|
+
}
|
|
3688
|
+
/**
|
|
3689
|
+
* Update entire partition map.
|
|
3690
|
+
* Only accepts newer versions.
|
|
3691
|
+
*/
|
|
3692
|
+
updateMap(map) {
|
|
3693
|
+
if (this.partitionMap && map.version <= this.partitionMap.version) {
|
|
3694
|
+
return false;
|
|
3695
|
+
}
|
|
3696
|
+
this.partitionMap = map;
|
|
3697
|
+
this.lastRefreshTime = Date.now();
|
|
3698
|
+
this.updateConnectionPool(map);
|
|
3699
|
+
const changesCount = map.partitions.length;
|
|
3700
|
+
logger.info({
|
|
3701
|
+
version: map.version,
|
|
3702
|
+
partitions: map.partitionCount,
|
|
3703
|
+
nodes: map.nodes.length
|
|
3704
|
+
}, "Partition map updated via updateMap");
|
|
3705
|
+
this.emit("partitionMap:updated", map.version, changesCount);
|
|
3706
|
+
return true;
|
|
3707
|
+
}
|
|
3708
|
+
/**
|
|
3709
|
+
* Update a single partition (for delta updates).
|
|
3710
|
+
*/
|
|
3711
|
+
updatePartition(partitionId, owner, backups) {
|
|
3712
|
+
if (!this.partitionMap) return;
|
|
3713
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
3714
|
+
if (partition) {
|
|
3715
|
+
partition.ownerNodeId = owner;
|
|
3716
|
+
partition.backupNodeIds = backups;
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
/**
|
|
3720
|
+
* Check if partition map is stale
|
|
3721
|
+
*/
|
|
3722
|
+
isMapStale() {
|
|
3723
|
+
if (!this.partitionMap) return true;
|
|
3724
|
+
const now = Date.now();
|
|
3725
|
+
return now - this.lastRefreshTime > this.config.maxMapStalenessMs;
|
|
3726
|
+
}
|
|
3727
|
+
/**
|
|
3728
|
+
* Request fresh partition map from server
|
|
3729
|
+
*/
|
|
3730
|
+
async refreshPartitionMap() {
|
|
3731
|
+
if (this.pendingRefresh) {
|
|
3732
|
+
return this.pendingRefresh;
|
|
3733
|
+
}
|
|
3734
|
+
this.pendingRefresh = this.doRefreshPartitionMap();
|
|
3735
|
+
try {
|
|
3736
|
+
await this.pendingRefresh;
|
|
3737
|
+
} finally {
|
|
3738
|
+
this.pendingRefresh = null;
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
/**
|
|
3742
|
+
* Start periodic partition map refresh
|
|
3743
|
+
*/
|
|
3744
|
+
startPeriodicRefresh() {
|
|
3745
|
+
if (this.refreshTimer) return;
|
|
3746
|
+
this.refreshTimer = setInterval(() => {
|
|
3747
|
+
if (this.isMapStale()) {
|
|
3748
|
+
this.emit("partitionMap:stale", this.getMapVersion(), this.lastRefreshTime);
|
|
3749
|
+
this.refreshPartitionMap().catch((err) => {
|
|
3750
|
+
logger.error({ error: err }, "Failed to refresh partition map");
|
|
3751
|
+
});
|
|
3752
|
+
}
|
|
3753
|
+
}, this.config.mapRefreshIntervalMs);
|
|
3754
|
+
}
|
|
3755
|
+
/**
|
|
3756
|
+
* Stop periodic refresh
|
|
3757
|
+
*/
|
|
3758
|
+
stopPeriodicRefresh() {
|
|
3759
|
+
if (this.refreshTimer) {
|
|
3760
|
+
clearInterval(this.refreshTimer);
|
|
3761
|
+
this.refreshTimer = null;
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
/**
|
|
3765
|
+
* Handle NOT_OWNER error from server
|
|
3766
|
+
*/
|
|
3767
|
+
handleNotOwnerError(key, actualOwner, newMapVersion) {
|
|
3768
|
+
const routing = this.route(key);
|
|
3769
|
+
const expectedOwner = routing?.nodeId ?? "unknown";
|
|
3770
|
+
this.emit("routing:miss", key, expectedOwner, actualOwner);
|
|
3771
|
+
if (newMapVersion > this.getMapVersion()) {
|
|
3772
|
+
this.refreshPartitionMap().catch((err) => {
|
|
3773
|
+
logger.error({ error: err }, "Failed to refresh partition map after NOT_OWNER");
|
|
3774
|
+
});
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* Get statistics about routing
|
|
3779
|
+
*/
|
|
3780
|
+
getStats() {
|
|
3781
|
+
return {
|
|
3782
|
+
mapVersion: this.getMapVersion(),
|
|
3783
|
+
partitionCount: this.partitionMap?.partitionCount ?? 0,
|
|
3784
|
+
nodeCount: this.partitionMap?.nodes.length ?? 0,
|
|
3785
|
+
lastRefresh: this.lastRefreshTime,
|
|
3786
|
+
isStale: this.isMapStale()
|
|
3787
|
+
};
|
|
3788
|
+
}
|
|
3789
|
+
/**
|
|
3790
|
+
* Cleanup resources
|
|
3791
|
+
*/
|
|
3792
|
+
close() {
|
|
3793
|
+
this.stopPeriodicRefresh();
|
|
3794
|
+
this.partitionMap = null;
|
|
3795
|
+
}
|
|
3796
|
+
// ============================================
|
|
3797
|
+
// Private Methods
|
|
3798
|
+
// ============================================
|
|
3799
|
+
handlePartitionMap(message) {
|
|
3800
|
+
const newMap = message.payload;
|
|
3801
|
+
if (this.partitionMap && newMap.version <= this.partitionMap.version) {
|
|
3802
|
+
logger.debug({
|
|
3803
|
+
current: this.partitionMap.version,
|
|
3804
|
+
received: newMap.version
|
|
3805
|
+
}, "Ignoring older partition map");
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
this.partitionMap = newMap;
|
|
3809
|
+
this.lastRefreshTime = Date.now();
|
|
3810
|
+
this.updateConnectionPool(newMap);
|
|
3811
|
+
const changesCount = newMap.partitions.length;
|
|
3812
|
+
logger.info({
|
|
3813
|
+
version: newMap.version,
|
|
3814
|
+
partitions: newMap.partitionCount,
|
|
3815
|
+
nodes: newMap.nodes.length
|
|
3816
|
+
}, "Partition map updated");
|
|
3817
|
+
this.emit("partitionMap:updated", newMap.version, changesCount);
|
|
3818
|
+
}
|
|
3819
|
+
handlePartitionMapDelta(message) {
|
|
3820
|
+
const delta = message.payload;
|
|
3821
|
+
if (!this.partitionMap) {
|
|
3822
|
+
logger.warn("Received delta but no base map, requesting full map");
|
|
3823
|
+
this.refreshPartitionMap();
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
3826
|
+
if (delta.previousVersion !== this.partitionMap.version) {
|
|
3827
|
+
logger.warn({
|
|
3828
|
+
expected: this.partitionMap.version,
|
|
3829
|
+
received: delta.previousVersion
|
|
3830
|
+
}, "Delta version mismatch, requesting full map");
|
|
3831
|
+
this.refreshPartitionMap();
|
|
3832
|
+
return;
|
|
3833
|
+
}
|
|
3834
|
+
for (const change of delta.changes) {
|
|
3835
|
+
this.applyPartitionChange(change);
|
|
3836
|
+
}
|
|
3837
|
+
this.partitionMap.version = delta.version;
|
|
3838
|
+
this.lastRefreshTime = Date.now();
|
|
3839
|
+
logger.info({
|
|
3840
|
+
version: delta.version,
|
|
3841
|
+
changes: delta.changes.length
|
|
3842
|
+
}, "Applied partition map delta");
|
|
3843
|
+
this.emit("partitionMap:updated", delta.version, delta.changes.length);
|
|
3844
|
+
}
|
|
3845
|
+
applyPartitionChange(change) {
|
|
3846
|
+
if (!this.partitionMap) return;
|
|
3847
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === change.partitionId);
|
|
3848
|
+
if (partition) {
|
|
3849
|
+
partition.ownerNodeId = change.newOwner;
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
updateConnectionPool(map) {
|
|
3853
|
+
for (const node of map.nodes) {
|
|
3854
|
+
if (node.status === "ACTIVE" || node.status === "JOINING") {
|
|
3855
|
+
this.connectionPool.addNode(node.nodeId, node.endpoints.websocket);
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
const currentNodeIds = new Set(map.nodes.map((n) => n.nodeId));
|
|
3859
|
+
for (const nodeId of this.connectionPool.getAllNodes()) {
|
|
3860
|
+
if (!currentNodeIds.has(nodeId)) {
|
|
3861
|
+
this.connectionPool.removeNode(nodeId);
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
async doRefreshPartitionMap() {
|
|
3866
|
+
logger.debug("Requesting partition map refresh");
|
|
3867
|
+
const sent = this.connectionPool.sendToPrimary({
|
|
3868
|
+
type: "PARTITION_MAP_REQUEST",
|
|
3869
|
+
payload: {
|
|
3870
|
+
currentVersion: this.getMapVersion()
|
|
3871
|
+
}
|
|
3872
|
+
});
|
|
3873
|
+
if (!sent) {
|
|
3874
|
+
throw new Error("No connection available to request partition map");
|
|
3875
|
+
}
|
|
3876
|
+
return new Promise((resolve, reject) => {
|
|
3877
|
+
const timeout = setTimeout(() => {
|
|
3878
|
+
this.removeListener("partitionMap:updated", onUpdate);
|
|
3879
|
+
reject(new Error("Partition map refresh timeout"));
|
|
3880
|
+
}, 5e3);
|
|
3881
|
+
const onUpdate = () => {
|
|
3882
|
+
clearTimeout(timeout);
|
|
3883
|
+
this.removeListener("partitionMap:updated", onUpdate);
|
|
3884
|
+
resolve();
|
|
3885
|
+
};
|
|
3886
|
+
this.once("partitionMap:updated", onUpdate);
|
|
3887
|
+
});
|
|
3888
|
+
}
|
|
3889
|
+
};
|
|
3890
|
+
|
|
3891
|
+
// src/cluster/ClusterClient.ts
|
|
3892
|
+
var ClusterClient = class {
|
|
3893
|
+
constructor(config) {
|
|
3894
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
3895
|
+
this.initialized = false;
|
|
3896
|
+
this.routingActive = false;
|
|
3897
|
+
this.routingMetrics = {
|
|
3898
|
+
directRoutes: 0,
|
|
3899
|
+
fallbackRoutes: 0,
|
|
3900
|
+
partitionMisses: 0,
|
|
3901
|
+
totalRoutes: 0
|
|
3902
|
+
};
|
|
3903
|
+
// Circuit breaker state per node
|
|
3904
|
+
this.circuits = /* @__PURE__ */ new Map();
|
|
3905
|
+
this.config = config;
|
|
3906
|
+
this.circuitBreakerConfig = {
|
|
3907
|
+
...import_core6.DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
3908
|
+
...config.circuitBreaker
|
|
3909
|
+
};
|
|
3910
|
+
const poolConfig = {
|
|
3911
|
+
...import_core6.DEFAULT_CONNECTION_POOL_CONFIG,
|
|
3912
|
+
...config.connectionPool
|
|
3913
|
+
};
|
|
3914
|
+
this.connectionPool = new ConnectionPool(poolConfig);
|
|
3915
|
+
const routerConfig = {
|
|
3916
|
+
...import_core6.DEFAULT_PARTITION_ROUTER_CONFIG,
|
|
3917
|
+
fallbackMode: config.routingMode === "direct" ? "error" : "forward",
|
|
3918
|
+
...config.routing
|
|
3919
|
+
};
|
|
3920
|
+
this.partitionRouter = new PartitionRouter(this.connectionPool, routerConfig);
|
|
3921
|
+
this.setupEventHandlers();
|
|
3922
|
+
}
|
|
3923
|
+
// ============================================
|
|
3924
|
+
// Event Emitter Methods (browser-compatible)
|
|
3925
|
+
// ============================================
|
|
3926
|
+
on(event, listener) {
|
|
3927
|
+
if (!this.listeners.has(event)) {
|
|
3928
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
3929
|
+
}
|
|
3930
|
+
this.listeners.get(event).add(listener);
|
|
3931
|
+
return this;
|
|
3932
|
+
}
|
|
3933
|
+
off(event, listener) {
|
|
3934
|
+
this.listeners.get(event)?.delete(listener);
|
|
3935
|
+
return this;
|
|
3936
|
+
}
|
|
3937
|
+
emit(event, ...args) {
|
|
3938
|
+
const eventListeners = this.listeners.get(event);
|
|
3939
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
3940
|
+
return false;
|
|
3941
|
+
}
|
|
3942
|
+
for (const listener of eventListeners) {
|
|
3943
|
+
try {
|
|
3944
|
+
listener(...args);
|
|
3945
|
+
} catch (err) {
|
|
3946
|
+
logger.error({ event, err }, "Error in event listener");
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
return true;
|
|
3950
|
+
}
|
|
3951
|
+
removeAllListeners(event) {
|
|
3952
|
+
if (event) {
|
|
3953
|
+
this.listeners.delete(event);
|
|
3954
|
+
} else {
|
|
3955
|
+
this.listeners.clear();
|
|
3956
|
+
}
|
|
3957
|
+
return this;
|
|
3958
|
+
}
|
|
3959
|
+
// ============================================
|
|
3960
|
+
// IConnectionProvider Implementation
|
|
3961
|
+
// ============================================
|
|
3962
|
+
/**
|
|
3963
|
+
* Connect to cluster nodes (IConnectionProvider interface).
|
|
3964
|
+
* Alias for start() method.
|
|
3965
|
+
*/
|
|
3966
|
+
async connect() {
|
|
3967
|
+
return this.start();
|
|
3968
|
+
}
|
|
3969
|
+
/**
|
|
3970
|
+
* Get connection for a specific key (IConnectionProvider interface).
|
|
3971
|
+
* Routes to partition owner based on key hash when smart routing is enabled.
|
|
3972
|
+
* @throws Error if not connected
|
|
3973
|
+
*/
|
|
3974
|
+
getConnection(key) {
|
|
3975
|
+
if (!this.isConnected()) {
|
|
3976
|
+
throw new Error("ClusterClient not connected");
|
|
3977
|
+
}
|
|
3978
|
+
this.routingMetrics.totalRoutes++;
|
|
3979
|
+
if (this.config.routingMode !== "direct" || !this.routingActive) {
|
|
3980
|
+
this.routingMetrics.fallbackRoutes++;
|
|
3981
|
+
return this.getFallbackConnection();
|
|
3982
|
+
}
|
|
3983
|
+
const routing = this.partitionRouter.route(key);
|
|
3984
|
+
if (!routing) {
|
|
3985
|
+
this.routingMetrics.partitionMisses++;
|
|
3986
|
+
logger.debug({ key }, "No partition map available, using fallback");
|
|
3987
|
+
return this.getFallbackConnection();
|
|
3988
|
+
}
|
|
3989
|
+
const owner = routing.nodeId;
|
|
3990
|
+
if (!this.connectionPool.isNodeConnected(owner)) {
|
|
3991
|
+
this.routingMetrics.fallbackRoutes++;
|
|
3992
|
+
logger.debug({ key, owner }, "Partition owner not connected, using fallback");
|
|
3993
|
+
this.requestPartitionMapRefresh();
|
|
3994
|
+
return this.getFallbackConnection();
|
|
3995
|
+
}
|
|
3996
|
+
const socket = this.connectionPool.getConnection(owner);
|
|
3997
|
+
if (!socket) {
|
|
3998
|
+
this.routingMetrics.fallbackRoutes++;
|
|
3999
|
+
logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
|
|
4000
|
+
return this.getFallbackConnection();
|
|
4001
|
+
}
|
|
4002
|
+
this.routingMetrics.directRoutes++;
|
|
4003
|
+
return socket;
|
|
4004
|
+
}
|
|
4005
|
+
/**
|
|
4006
|
+
* Get fallback connection when owner is unavailable.
|
|
4007
|
+
* @throws Error if no connection available
|
|
4008
|
+
*/
|
|
4009
|
+
getFallbackConnection() {
|
|
4010
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
4011
|
+
if (!conn?.socket) {
|
|
4012
|
+
throw new Error("No healthy connection available");
|
|
4013
|
+
}
|
|
4014
|
+
return conn.socket;
|
|
4015
|
+
}
|
|
4016
|
+
/**
|
|
4017
|
+
* Request a partition map refresh in the background.
|
|
4018
|
+
* Called when routing to an unknown/disconnected owner.
|
|
4019
|
+
*/
|
|
4020
|
+
requestPartitionMapRefresh() {
|
|
4021
|
+
this.partitionRouter.refreshPartitionMap().catch((err) => {
|
|
4022
|
+
logger.error({ err }, "Failed to refresh partition map");
|
|
4023
|
+
});
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Request partition map from a specific node.
|
|
4027
|
+
* Called on first node connection.
|
|
4028
|
+
*/
|
|
4029
|
+
requestPartitionMapFromNode(nodeId) {
|
|
4030
|
+
const socket = this.connectionPool.getConnection(nodeId);
|
|
4031
|
+
if (socket) {
|
|
4032
|
+
logger.debug({ nodeId }, "Requesting partition map from node");
|
|
4033
|
+
socket.send((0, import_core6.serialize)({
|
|
4034
|
+
type: "PARTITION_MAP_REQUEST",
|
|
4035
|
+
payload: {
|
|
4036
|
+
currentVersion: this.partitionRouter.getMapVersion()
|
|
4037
|
+
}
|
|
4038
|
+
}));
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Check if at least one connection is active (IConnectionProvider interface).
|
|
4043
|
+
*/
|
|
4044
|
+
isConnected() {
|
|
4045
|
+
return this.connectionPool.getConnectedNodes().length > 0;
|
|
4046
|
+
}
|
|
4047
|
+
/**
|
|
4048
|
+
* Send data via the appropriate connection (IConnectionProvider interface).
|
|
4049
|
+
* Routes based on key if provided.
|
|
4050
|
+
*/
|
|
4051
|
+
send(data, key) {
|
|
4052
|
+
if (!this.isConnected()) {
|
|
4053
|
+
throw new Error("ClusterClient not connected");
|
|
4054
|
+
}
|
|
4055
|
+
const socket = key ? this.getConnection(key) : this.getAnyConnection();
|
|
4056
|
+
socket.send(data);
|
|
4057
|
+
}
|
|
4058
|
+
/**
|
|
4059
|
+
* Send data with automatic retry and rerouting on failure.
|
|
4060
|
+
* @param data - Data to send
|
|
4061
|
+
* @param key - Optional key for routing
|
|
4062
|
+
* @param options - Retry options
|
|
4063
|
+
* @throws Error after max retries exceeded
|
|
4064
|
+
*/
|
|
4065
|
+
async sendWithRetry(data, key, options = {}) {
|
|
4066
|
+
const {
|
|
4067
|
+
maxRetries = 3,
|
|
4068
|
+
retryDelayMs = 100,
|
|
4069
|
+
retryOnNotOwner = true
|
|
4070
|
+
} = options;
|
|
4071
|
+
let lastError = null;
|
|
4072
|
+
let nodeId = null;
|
|
4073
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
4074
|
+
try {
|
|
4075
|
+
if (key && this.routingActive) {
|
|
4076
|
+
const routing = this.partitionRouter.route(key);
|
|
4077
|
+
nodeId = routing?.nodeId ?? null;
|
|
4078
|
+
}
|
|
4079
|
+
if (nodeId && !this.canUseNode(nodeId)) {
|
|
4080
|
+
logger.debug({ nodeId, attempt }, "Circuit open, using fallback");
|
|
4081
|
+
nodeId = null;
|
|
4082
|
+
}
|
|
4083
|
+
const socket = key && nodeId ? this.connectionPool.getConnection(nodeId) : this.getAnyConnection();
|
|
4084
|
+
if (!socket) {
|
|
4085
|
+
throw new Error("No connection available");
|
|
4086
|
+
}
|
|
4087
|
+
socket.send(data);
|
|
4088
|
+
if (nodeId) {
|
|
4089
|
+
this.recordSuccess(nodeId);
|
|
4090
|
+
}
|
|
4091
|
+
return;
|
|
4092
|
+
} catch (error) {
|
|
4093
|
+
lastError = error;
|
|
4094
|
+
if (nodeId) {
|
|
4095
|
+
this.recordFailure(nodeId);
|
|
4096
|
+
}
|
|
4097
|
+
const errorCode = error?.code;
|
|
4098
|
+
if (this.isRetryableError(error)) {
|
|
4099
|
+
logger.debug(
|
|
4100
|
+
{ attempt, maxRetries, errorCode, nodeId },
|
|
4101
|
+
"Retryable error, will retry"
|
|
4102
|
+
);
|
|
4103
|
+
if (errorCode === "NOT_OWNER" && retryOnNotOwner) {
|
|
4104
|
+
await this.waitForPartitionMapUpdateInternal(2e3);
|
|
4105
|
+
} else if (errorCode === "CONNECTION_CLOSED" || !this.isConnected()) {
|
|
4106
|
+
await this.waitForConnectionInternal(5e3);
|
|
4107
|
+
}
|
|
4108
|
+
await this.delay(retryDelayMs * (attempt + 1));
|
|
4109
|
+
continue;
|
|
4110
|
+
}
|
|
4111
|
+
throw error;
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
throw new Error(
|
|
4115
|
+
`Operation failed after ${maxRetries} retries: ${lastError?.message}`
|
|
4116
|
+
);
|
|
4117
|
+
}
|
|
4118
|
+
/**
|
|
4119
|
+
* Check if an error is retryable.
|
|
4120
|
+
*/
|
|
4121
|
+
isRetryableError(error) {
|
|
4122
|
+
const code = error?.code;
|
|
4123
|
+
const message = error?.message || "";
|
|
4124
|
+
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");
|
|
4125
|
+
}
|
|
4126
|
+
/**
|
|
4127
|
+
* Wait for partition map update.
|
|
4128
|
+
*/
|
|
4129
|
+
waitForPartitionMapUpdateInternal(timeoutMs) {
|
|
4130
|
+
return new Promise((resolve) => {
|
|
4131
|
+
const timeout = setTimeout(resolve, timeoutMs);
|
|
4132
|
+
const handler2 = () => {
|
|
4133
|
+
clearTimeout(timeout);
|
|
4134
|
+
this.off("partitionMapUpdated", handler2);
|
|
4135
|
+
resolve();
|
|
4136
|
+
};
|
|
4137
|
+
this.on("partitionMapUpdated", handler2);
|
|
4138
|
+
});
|
|
4139
|
+
}
|
|
4140
|
+
/**
|
|
4141
|
+
* Wait for at least one connection to be available.
|
|
4142
|
+
*/
|
|
4143
|
+
waitForConnectionInternal(timeoutMs) {
|
|
4144
|
+
return new Promise((resolve, reject) => {
|
|
4145
|
+
if (this.isConnected()) {
|
|
4146
|
+
resolve();
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
const timeout = setTimeout(() => {
|
|
4150
|
+
this.off("connected", handler2);
|
|
4151
|
+
reject(new Error("Connection timeout"));
|
|
4152
|
+
}, timeoutMs);
|
|
4153
|
+
const handler2 = () => {
|
|
4154
|
+
clearTimeout(timeout);
|
|
4155
|
+
this.off("connected", handler2);
|
|
4156
|
+
resolve();
|
|
4157
|
+
};
|
|
4158
|
+
this.on("connected", handler2);
|
|
4159
|
+
});
|
|
4160
|
+
}
|
|
4161
|
+
/**
|
|
4162
|
+
* Helper delay function.
|
|
4163
|
+
*/
|
|
4164
|
+
delay(ms) {
|
|
4165
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4166
|
+
}
|
|
4167
|
+
// ============================================
|
|
4168
|
+
// Cluster-Specific Methods
|
|
4169
|
+
// ============================================
|
|
4170
|
+
/**
|
|
4171
|
+
* Initialize cluster connections
|
|
4172
|
+
*/
|
|
4173
|
+
async start() {
|
|
4174
|
+
if (this.initialized) return;
|
|
4175
|
+
logger.info({ seedNodes: this.config.seedNodes }, "Starting cluster client");
|
|
4176
|
+
for (let i = 0; i < this.config.seedNodes.length; i++) {
|
|
4177
|
+
const endpoint = this.config.seedNodes[i];
|
|
4178
|
+
const nodeId = `seed-${i}`;
|
|
4179
|
+
await this.connectionPool.addNode(nodeId, endpoint);
|
|
4180
|
+
}
|
|
4181
|
+
this.connectionPool.startHealthCheck();
|
|
4182
|
+
this.partitionRouter.startPeriodicRefresh();
|
|
4183
|
+
this.initialized = true;
|
|
4184
|
+
await this.waitForPartitionMap();
|
|
4185
|
+
}
|
|
4186
|
+
/**
|
|
4187
|
+
* Set authentication token
|
|
4188
|
+
*/
|
|
4189
|
+
setAuthToken(token) {
|
|
4190
|
+
this.connectionPool.setAuthToken(token);
|
|
4191
|
+
}
|
|
4192
|
+
/**
|
|
4193
|
+
* Send operation with automatic routing (legacy API for cluster operations).
|
|
4194
|
+
* @deprecated Use send(data, key) for IConnectionProvider interface
|
|
4195
|
+
*/
|
|
4196
|
+
sendMessage(key, message) {
|
|
4197
|
+
if (this.config.routingMode === "direct" && this.routingActive) {
|
|
4198
|
+
return this.sendDirect(key, message);
|
|
4199
|
+
}
|
|
4200
|
+
return this.sendForward(message);
|
|
4201
|
+
}
|
|
4202
|
+
/**
|
|
4203
|
+
* Send directly to partition owner
|
|
4204
|
+
*/
|
|
4205
|
+
sendDirect(key, message) {
|
|
4206
|
+
const connection = this.partitionRouter.routeToConnection(key);
|
|
4207
|
+
if (!connection) {
|
|
4208
|
+
logger.warn({ key }, "No route available for key");
|
|
4209
|
+
return false;
|
|
4210
|
+
}
|
|
4211
|
+
const routedMessage = {
|
|
4212
|
+
...message,
|
|
4213
|
+
_routing: {
|
|
4214
|
+
partitionId: this.partitionRouter.getPartitionId(key),
|
|
4215
|
+
mapVersion: this.partitionRouter.getMapVersion()
|
|
4216
|
+
}
|
|
4217
|
+
};
|
|
4218
|
+
connection.socket.send((0, import_core6.serialize)(routedMessage));
|
|
4219
|
+
return true;
|
|
4220
|
+
}
|
|
4221
|
+
/**
|
|
4222
|
+
* Send to primary node for server-side forwarding
|
|
4223
|
+
*/
|
|
4224
|
+
sendForward(message) {
|
|
4225
|
+
return this.connectionPool.sendToPrimary(message);
|
|
4226
|
+
}
|
|
4227
|
+
/**
|
|
4228
|
+
* Send batch of operations with routing
|
|
4229
|
+
*/
|
|
4230
|
+
sendBatch(operations) {
|
|
4231
|
+
const results = /* @__PURE__ */ new Map();
|
|
4232
|
+
if (this.config.routingMode === "direct" && this.routingActive) {
|
|
4233
|
+
const nodeMessages = /* @__PURE__ */ new Map();
|
|
4234
|
+
for (const { key, message } of operations) {
|
|
4235
|
+
const routing = this.partitionRouter.route(key);
|
|
4236
|
+
const nodeId = routing?.nodeId ?? "primary";
|
|
4237
|
+
if (!nodeMessages.has(nodeId)) {
|
|
4238
|
+
nodeMessages.set(nodeId, []);
|
|
4239
|
+
}
|
|
4240
|
+
nodeMessages.get(nodeId).push({ key, message });
|
|
4241
|
+
}
|
|
4242
|
+
for (const [nodeId, messages] of nodeMessages) {
|
|
4243
|
+
let success;
|
|
4244
|
+
if (nodeId === "primary") {
|
|
4245
|
+
success = this.connectionPool.sendToPrimary({
|
|
4246
|
+
type: "OP_BATCH",
|
|
4247
|
+
payload: { ops: messages.map((m) => m.message) }
|
|
4248
|
+
});
|
|
4249
|
+
} else {
|
|
4250
|
+
success = this.connectionPool.send(nodeId, {
|
|
4251
|
+
type: "OP_BATCH",
|
|
4252
|
+
payload: { ops: messages.map((m) => m.message) }
|
|
4253
|
+
});
|
|
4254
|
+
}
|
|
4255
|
+
for (const { key } of messages) {
|
|
4256
|
+
results.set(key, success);
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
} else {
|
|
4260
|
+
const success = this.connectionPool.sendToPrimary({
|
|
4261
|
+
type: "OP_BATCH",
|
|
4262
|
+
payload: { ops: operations.map((o) => o.message) }
|
|
1542
4263
|
});
|
|
4264
|
+
for (const { key } of operations) {
|
|
4265
|
+
results.set(key, success);
|
|
4266
|
+
}
|
|
1543
4267
|
}
|
|
1544
4268
|
return results;
|
|
1545
4269
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
4270
|
+
/**
|
|
4271
|
+
* Get connection pool health status
|
|
4272
|
+
*/
|
|
4273
|
+
getHealthStatus() {
|
|
4274
|
+
return this.connectionPool.getHealthStatus();
|
|
1548
4275
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
4276
|
+
/**
|
|
4277
|
+
* Get partition router stats
|
|
4278
|
+
*/
|
|
4279
|
+
getRouterStats() {
|
|
4280
|
+
return this.partitionRouter.getStats();
|
|
1551
4281
|
}
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
this.fencingToken = null;
|
|
1558
|
-
this._isLocked = false;
|
|
1559
|
-
this.syncEngine = syncEngine;
|
|
1560
|
-
this.name = name;
|
|
4282
|
+
/**
|
|
4283
|
+
* Get routing metrics for monitoring smart routing effectiveness.
|
|
4284
|
+
*/
|
|
4285
|
+
getRoutingMetrics() {
|
|
4286
|
+
return { ...this.routingMetrics };
|
|
1561
4287
|
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
}
|
|
4288
|
+
/**
|
|
4289
|
+
* Reset routing metrics counters.
|
|
4290
|
+
* Useful for monitoring intervals.
|
|
4291
|
+
*/
|
|
4292
|
+
resetRoutingMetrics() {
|
|
4293
|
+
this.routingMetrics.directRoutes = 0;
|
|
4294
|
+
this.routingMetrics.fallbackRoutes = 0;
|
|
4295
|
+
this.routingMetrics.partitionMisses = 0;
|
|
4296
|
+
this.routingMetrics.totalRoutes = 0;
|
|
1572
4297
|
}
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
} finally {
|
|
1579
|
-
this._isLocked = false;
|
|
1580
|
-
this.fencingToken = null;
|
|
1581
|
-
}
|
|
4298
|
+
/**
|
|
4299
|
+
* Check if cluster routing is active
|
|
4300
|
+
*/
|
|
4301
|
+
isRoutingActive() {
|
|
4302
|
+
return this.routingActive;
|
|
1582
4303
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
4304
|
+
/**
|
|
4305
|
+
* Get list of connected nodes
|
|
4306
|
+
*/
|
|
4307
|
+
getConnectedNodes() {
|
|
4308
|
+
return this.connectionPool.getConnectedNodes();
|
|
1585
4309
|
}
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
this.listeners = /* @__PURE__ */ new Set();
|
|
1592
|
-
this.engine = engine;
|
|
1593
|
-
this.topic = topic;
|
|
4310
|
+
/**
|
|
4311
|
+
* Check if cluster client is initialized
|
|
4312
|
+
*/
|
|
4313
|
+
isInitialized() {
|
|
4314
|
+
return this.initialized;
|
|
1594
4315
|
}
|
|
1595
|
-
|
|
1596
|
-
|
|
4316
|
+
/**
|
|
4317
|
+
* Force refresh of partition map
|
|
4318
|
+
*/
|
|
4319
|
+
async refreshPartitionMap() {
|
|
4320
|
+
await this.partitionRouter.refreshPartitionMap();
|
|
1597
4321
|
}
|
|
1598
4322
|
/**
|
|
1599
|
-
*
|
|
4323
|
+
* Shutdown cluster client (IConnectionProvider interface).
|
|
1600
4324
|
*/
|
|
1601
|
-
|
|
1602
|
-
this.
|
|
4325
|
+
async close() {
|
|
4326
|
+
this.partitionRouter.close();
|
|
4327
|
+
this.connectionPool.close();
|
|
4328
|
+
this.initialized = false;
|
|
4329
|
+
this.routingActive = false;
|
|
4330
|
+
logger.info("Cluster client closed");
|
|
4331
|
+
}
|
|
4332
|
+
// ============================================
|
|
4333
|
+
// Internal Access for TopGunClient
|
|
4334
|
+
// ============================================
|
|
4335
|
+
/**
|
|
4336
|
+
* Get the connection pool (for internal use)
|
|
4337
|
+
*/
|
|
4338
|
+
getConnectionPool() {
|
|
4339
|
+
return this.connectionPool;
|
|
1603
4340
|
}
|
|
1604
4341
|
/**
|
|
1605
|
-
*
|
|
4342
|
+
* Get the partition router (for internal use)
|
|
1606
4343
|
*/
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
4344
|
+
getPartitionRouter() {
|
|
4345
|
+
return this.partitionRouter;
|
|
4346
|
+
}
|
|
4347
|
+
/**
|
|
4348
|
+
* Get any healthy WebSocket connection (IConnectionProvider interface).
|
|
4349
|
+
* @throws Error if not connected
|
|
4350
|
+
*/
|
|
4351
|
+
getAnyConnection() {
|
|
4352
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
4353
|
+
if (!conn?.socket) {
|
|
4354
|
+
throw new Error("No healthy connection available");
|
|
1610
4355
|
}
|
|
1611
|
-
|
|
1612
|
-
return () => this.unsubscribe(callback);
|
|
4356
|
+
return conn.socket;
|
|
1613
4357
|
}
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
4358
|
+
/**
|
|
4359
|
+
* Get any healthy WebSocket connection, or null if none available.
|
|
4360
|
+
* Use this for optional connection checks.
|
|
4361
|
+
*/
|
|
4362
|
+
getAnyConnectionOrNull() {
|
|
4363
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
4364
|
+
return conn?.socket ?? null;
|
|
4365
|
+
}
|
|
4366
|
+
// ============================================
|
|
4367
|
+
// Circuit Breaker Methods
|
|
4368
|
+
// ============================================
|
|
4369
|
+
/**
|
|
4370
|
+
* Get circuit breaker state for a node.
|
|
4371
|
+
*/
|
|
4372
|
+
getCircuit(nodeId) {
|
|
4373
|
+
let circuit = this.circuits.get(nodeId);
|
|
4374
|
+
if (!circuit) {
|
|
4375
|
+
circuit = { failures: 0, lastFailure: 0, state: "closed" };
|
|
4376
|
+
this.circuits.set(nodeId, circuit);
|
|
1618
4377
|
}
|
|
4378
|
+
return circuit;
|
|
1619
4379
|
}
|
|
1620
4380
|
/**
|
|
1621
|
-
*
|
|
4381
|
+
* Check if a node can be used (circuit not open).
|
|
1622
4382
|
*/
|
|
1623
|
-
|
|
1624
|
-
this.
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
4383
|
+
canUseNode(nodeId) {
|
|
4384
|
+
const circuit = this.getCircuit(nodeId);
|
|
4385
|
+
if (circuit.state === "closed") {
|
|
4386
|
+
return true;
|
|
4387
|
+
}
|
|
4388
|
+
if (circuit.state === "open") {
|
|
4389
|
+
if (Date.now() - circuit.lastFailure > this.circuitBreakerConfig.resetTimeoutMs) {
|
|
4390
|
+
circuit.state = "half-open";
|
|
4391
|
+
logger.debug({ nodeId }, "Circuit breaker half-open, allowing test request");
|
|
4392
|
+
this.emit("circuit:half-open", nodeId);
|
|
4393
|
+
return true;
|
|
4394
|
+
}
|
|
4395
|
+
return false;
|
|
4396
|
+
}
|
|
4397
|
+
return true;
|
|
4398
|
+
}
|
|
4399
|
+
/**
|
|
4400
|
+
* Record a successful operation to a node.
|
|
4401
|
+
* Resets circuit breaker on success.
|
|
4402
|
+
*/
|
|
4403
|
+
recordSuccess(nodeId) {
|
|
4404
|
+
const circuit = this.getCircuit(nodeId);
|
|
4405
|
+
const wasOpen = circuit.state !== "closed";
|
|
4406
|
+
circuit.failures = 0;
|
|
4407
|
+
circuit.state = "closed";
|
|
4408
|
+
if (wasOpen) {
|
|
4409
|
+
logger.info({ nodeId }, "Circuit breaker closed after success");
|
|
4410
|
+
this.emit("circuit:closed", nodeId);
|
|
4411
|
+
}
|
|
4412
|
+
}
|
|
4413
|
+
/**
|
|
4414
|
+
* Record a failed operation to a node.
|
|
4415
|
+
* Opens circuit breaker after threshold failures.
|
|
4416
|
+
*/
|
|
4417
|
+
recordFailure(nodeId) {
|
|
4418
|
+
const circuit = this.getCircuit(nodeId);
|
|
4419
|
+
circuit.failures++;
|
|
4420
|
+
circuit.lastFailure = Date.now();
|
|
4421
|
+
if (circuit.failures >= this.circuitBreakerConfig.failureThreshold) {
|
|
4422
|
+
if (circuit.state !== "open") {
|
|
4423
|
+
circuit.state = "open";
|
|
4424
|
+
logger.warn({ nodeId, failures: circuit.failures }, "Circuit breaker opened");
|
|
4425
|
+
this.emit("circuit:open", nodeId);
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
}
|
|
4429
|
+
/**
|
|
4430
|
+
* Get all circuit breaker states.
|
|
4431
|
+
*/
|
|
4432
|
+
getCircuitStates() {
|
|
4433
|
+
return new Map(this.circuits);
|
|
4434
|
+
}
|
|
4435
|
+
/**
|
|
4436
|
+
* Reset circuit breaker for a specific node.
|
|
4437
|
+
*/
|
|
4438
|
+
resetCircuit(nodeId) {
|
|
4439
|
+
this.circuits.delete(nodeId);
|
|
4440
|
+
logger.debug({ nodeId }, "Circuit breaker reset");
|
|
4441
|
+
}
|
|
4442
|
+
/**
|
|
4443
|
+
* Reset all circuit breakers.
|
|
4444
|
+
*/
|
|
4445
|
+
resetAllCircuits() {
|
|
4446
|
+
this.circuits.clear();
|
|
4447
|
+
logger.debug("All circuit breakers reset");
|
|
4448
|
+
}
|
|
4449
|
+
// ============================================
|
|
4450
|
+
// Private Methods
|
|
4451
|
+
// ============================================
|
|
4452
|
+
setupEventHandlers() {
|
|
4453
|
+
this.connectionPool.on("node:connected", (nodeId) => {
|
|
4454
|
+
logger.debug({ nodeId }, "Node connected");
|
|
4455
|
+
if (this.partitionRouter.getMapVersion() === 0) {
|
|
4456
|
+
this.requestPartitionMapFromNode(nodeId);
|
|
4457
|
+
}
|
|
4458
|
+
if (this.connectionPool.getConnectedNodes().length === 1) {
|
|
4459
|
+
this.emit("connected");
|
|
4460
|
+
}
|
|
4461
|
+
});
|
|
4462
|
+
this.connectionPool.on("node:disconnected", (nodeId, reason) => {
|
|
4463
|
+
logger.debug({ nodeId, reason }, "Node disconnected");
|
|
4464
|
+
if (this.connectionPool.getConnectedNodes().length === 0) {
|
|
4465
|
+
this.routingActive = false;
|
|
4466
|
+
this.emit("disconnected", reason);
|
|
4467
|
+
}
|
|
4468
|
+
});
|
|
4469
|
+
this.connectionPool.on("node:unhealthy", (nodeId, reason) => {
|
|
4470
|
+
logger.warn({ nodeId, reason }, "Node unhealthy");
|
|
4471
|
+
});
|
|
4472
|
+
this.connectionPool.on("error", (nodeId, error) => {
|
|
4473
|
+
this.emit("error", error);
|
|
4474
|
+
});
|
|
4475
|
+
this.connectionPool.on("message", (nodeId, data) => {
|
|
4476
|
+
this.emit("message", nodeId, data);
|
|
4477
|
+
});
|
|
4478
|
+
this.partitionRouter.on("partitionMap:updated", (version, changesCount) => {
|
|
4479
|
+
if (!this.routingActive && this.partitionRouter.hasPartitionMap()) {
|
|
4480
|
+
this.routingActive = true;
|
|
4481
|
+
logger.info({ version }, "Direct routing activated");
|
|
4482
|
+
this.emit("routing:active");
|
|
1629
4483
|
}
|
|
4484
|
+
this.emit("partitionMap:ready", version);
|
|
4485
|
+
this.emit("partitionMapUpdated");
|
|
4486
|
+
});
|
|
4487
|
+
this.partitionRouter.on("routing:miss", (key, expected, actual) => {
|
|
4488
|
+
logger.debug({ key, expected, actual }, "Routing miss detected");
|
|
4489
|
+
});
|
|
4490
|
+
}
|
|
4491
|
+
async waitForPartitionMap(timeoutMs = 1e4) {
|
|
4492
|
+
if (this.partitionRouter.hasPartitionMap()) {
|
|
4493
|
+
this.routingActive = true;
|
|
4494
|
+
return;
|
|
4495
|
+
}
|
|
4496
|
+
return new Promise((resolve) => {
|
|
4497
|
+
const timeout = setTimeout(() => {
|
|
4498
|
+
this.partitionRouter.off("partitionMap:updated", onUpdate);
|
|
4499
|
+
logger.warn("Partition map not received, using fallback routing");
|
|
4500
|
+
resolve();
|
|
4501
|
+
}, timeoutMs);
|
|
4502
|
+
const onUpdate = () => {
|
|
4503
|
+
clearTimeout(timeout);
|
|
4504
|
+
this.partitionRouter.off("partitionMap:updated", onUpdate);
|
|
4505
|
+
this.routingActive = true;
|
|
4506
|
+
resolve();
|
|
4507
|
+
};
|
|
4508
|
+
this.partitionRouter.once("partitionMap:updated", onUpdate);
|
|
1630
4509
|
});
|
|
1631
4510
|
}
|
|
1632
4511
|
};
|
|
1633
4512
|
|
|
1634
4513
|
// src/TopGunClient.ts
|
|
4514
|
+
var DEFAULT_CLUSTER_CONFIG = {
|
|
4515
|
+
connectionsPerNode: 1,
|
|
4516
|
+
smartRouting: true,
|
|
4517
|
+
partitionMapRefreshMs: 3e4,
|
|
4518
|
+
connectionTimeoutMs: 5e3,
|
|
4519
|
+
retryAttempts: 3
|
|
4520
|
+
};
|
|
1635
4521
|
var TopGunClient = class {
|
|
1636
4522
|
constructor(config) {
|
|
1637
4523
|
this.maps = /* @__PURE__ */ new Map();
|
|
1638
4524
|
this.topicHandles = /* @__PURE__ */ new Map();
|
|
4525
|
+
this.counters = /* @__PURE__ */ new Map();
|
|
4526
|
+
if (config.serverUrl && config.cluster) {
|
|
4527
|
+
throw new Error("Cannot specify both serverUrl and cluster config");
|
|
4528
|
+
}
|
|
4529
|
+
if (!config.serverUrl && !config.cluster) {
|
|
4530
|
+
throw new Error("Must specify either serverUrl or cluster config");
|
|
4531
|
+
}
|
|
1639
4532
|
this.nodeId = config.nodeId || crypto.randomUUID();
|
|
1640
4533
|
this.storageAdapter = config.storage;
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
4534
|
+
this.isClusterMode = !!config.cluster;
|
|
4535
|
+
if (config.cluster) {
|
|
4536
|
+
if (!config.cluster.seeds || config.cluster.seeds.length === 0) {
|
|
4537
|
+
throw new Error("Cluster config requires at least one seed node");
|
|
4538
|
+
}
|
|
4539
|
+
this.clusterConfig = {
|
|
4540
|
+
seeds: config.cluster.seeds,
|
|
4541
|
+
connectionsPerNode: config.cluster.connectionsPerNode ?? DEFAULT_CLUSTER_CONFIG.connectionsPerNode,
|
|
4542
|
+
smartRouting: config.cluster.smartRouting ?? DEFAULT_CLUSTER_CONFIG.smartRouting,
|
|
4543
|
+
partitionMapRefreshMs: config.cluster.partitionMapRefreshMs ?? DEFAULT_CLUSTER_CONFIG.partitionMapRefreshMs,
|
|
4544
|
+
connectionTimeoutMs: config.cluster.connectionTimeoutMs ?? DEFAULT_CLUSTER_CONFIG.connectionTimeoutMs,
|
|
4545
|
+
retryAttempts: config.cluster.retryAttempts ?? DEFAULT_CLUSTER_CONFIG.retryAttempts
|
|
4546
|
+
};
|
|
4547
|
+
this.clusterClient = new ClusterClient({
|
|
4548
|
+
enabled: true,
|
|
4549
|
+
seedNodes: this.clusterConfig.seeds,
|
|
4550
|
+
routingMode: this.clusterConfig.smartRouting ? "direct" : "forward",
|
|
4551
|
+
connectionPool: {
|
|
4552
|
+
maxConnectionsPerNode: this.clusterConfig.connectionsPerNode,
|
|
4553
|
+
connectionTimeoutMs: this.clusterConfig.connectionTimeoutMs
|
|
4554
|
+
},
|
|
4555
|
+
routing: {
|
|
4556
|
+
mapRefreshIntervalMs: this.clusterConfig.partitionMapRefreshMs
|
|
4557
|
+
}
|
|
4558
|
+
});
|
|
4559
|
+
this.syncEngine = new SyncEngine({
|
|
4560
|
+
nodeId: this.nodeId,
|
|
4561
|
+
connectionProvider: this.clusterClient,
|
|
4562
|
+
storageAdapter: this.storageAdapter,
|
|
4563
|
+
backoff: config.backoff,
|
|
4564
|
+
backpressure: config.backpressure
|
|
4565
|
+
});
|
|
4566
|
+
logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
|
|
4567
|
+
} else {
|
|
4568
|
+
this.syncEngine = new SyncEngine({
|
|
4569
|
+
nodeId: this.nodeId,
|
|
4570
|
+
serverUrl: config.serverUrl,
|
|
4571
|
+
storageAdapter: this.storageAdapter,
|
|
4572
|
+
backoff: config.backoff,
|
|
4573
|
+
backpressure: config.backpressure
|
|
4574
|
+
});
|
|
4575
|
+
logger.info({ serverUrl: config.serverUrl }, "TopGunClient initialized in single-server mode");
|
|
4576
|
+
}
|
|
1649
4577
|
}
|
|
1650
4578
|
async start() {
|
|
1651
4579
|
await this.storageAdapter.initialize("topgun_offline_db");
|
|
@@ -1679,6 +4607,34 @@ var TopGunClient = class {
|
|
|
1679
4607
|
}
|
|
1680
4608
|
return this.topicHandles.get(name);
|
|
1681
4609
|
}
|
|
4610
|
+
/**
|
|
4611
|
+
* Retrieves a PN Counter instance. If the counter doesn't exist locally, it's created.
|
|
4612
|
+
* PN Counters support increment and decrement operations that work offline
|
|
4613
|
+
* and sync to server when connected.
|
|
4614
|
+
*
|
|
4615
|
+
* @param name The name of the counter (e.g., 'likes:post-123')
|
|
4616
|
+
* @returns A PNCounterHandle instance
|
|
4617
|
+
*
|
|
4618
|
+
* @example
|
|
4619
|
+
* ```typescript
|
|
4620
|
+
* const likes = client.getPNCounter('likes:post-123');
|
|
4621
|
+
* likes.increment(); // +1
|
|
4622
|
+
* likes.decrement(); // -1
|
|
4623
|
+
* likes.addAndGet(10); // +10
|
|
4624
|
+
*
|
|
4625
|
+
* likes.subscribe((value) => {
|
|
4626
|
+
* console.log('Current likes:', value);
|
|
4627
|
+
* });
|
|
4628
|
+
* ```
|
|
4629
|
+
*/
|
|
4630
|
+
getPNCounter(name) {
|
|
4631
|
+
let counter = this.counters.get(name);
|
|
4632
|
+
if (!counter) {
|
|
4633
|
+
counter = new PNCounterHandle(name, this.nodeId, this.syncEngine, this.storageAdapter);
|
|
4634
|
+
this.counters.set(name, counter);
|
|
4635
|
+
}
|
|
4636
|
+
return counter;
|
|
4637
|
+
}
|
|
1682
4638
|
/**
|
|
1683
4639
|
* Retrieves an LWWMap instance. If the map doesn't exist locally, it's created.
|
|
1684
4640
|
* @param name The name of the map.
|
|
@@ -1687,12 +4643,12 @@ var TopGunClient = class {
|
|
|
1687
4643
|
getMap(name) {
|
|
1688
4644
|
if (this.maps.has(name)) {
|
|
1689
4645
|
const map = this.maps.get(name);
|
|
1690
|
-
if (map instanceof
|
|
4646
|
+
if (map instanceof import_core7.LWWMap) {
|
|
1691
4647
|
return map;
|
|
1692
4648
|
}
|
|
1693
4649
|
throw new Error(`Map ${name} exists but is not an LWWMap`);
|
|
1694
4650
|
}
|
|
1695
|
-
const lwwMap = new
|
|
4651
|
+
const lwwMap = new import_core7.LWWMap(this.syncEngine.getHLC());
|
|
1696
4652
|
this.maps.set(name, lwwMap);
|
|
1697
4653
|
this.syncEngine.registerMap(name, lwwMap);
|
|
1698
4654
|
this.storageAdapter.getAllKeys().then(async (keys) => {
|
|
@@ -1731,12 +4687,12 @@ var TopGunClient = class {
|
|
|
1731
4687
|
getORMap(name) {
|
|
1732
4688
|
if (this.maps.has(name)) {
|
|
1733
4689
|
const map = this.maps.get(name);
|
|
1734
|
-
if (map instanceof
|
|
4690
|
+
if (map instanceof import_core7.ORMap) {
|
|
1735
4691
|
return map;
|
|
1736
4692
|
}
|
|
1737
4693
|
throw new Error(`Map ${name} exists but is not an ORMap`);
|
|
1738
4694
|
}
|
|
1739
|
-
const orMap = new
|
|
4695
|
+
const orMap = new import_core7.ORMap(this.syncEngine.getHLC());
|
|
1740
4696
|
this.maps.set(name, orMap);
|
|
1741
4697
|
this.syncEngine.registerMap(name, orMap);
|
|
1742
4698
|
this.restoreORMap(name, orMap);
|
|
@@ -1805,9 +4761,69 @@ var TopGunClient = class {
|
|
|
1805
4761
|
* Closes the client, disconnecting from the server and cleaning up resources.
|
|
1806
4762
|
*/
|
|
1807
4763
|
close() {
|
|
4764
|
+
if (this.clusterClient) {
|
|
4765
|
+
this.clusterClient.close();
|
|
4766
|
+
}
|
|
1808
4767
|
this.syncEngine.close();
|
|
1809
4768
|
}
|
|
1810
4769
|
// ============================================
|
|
4770
|
+
// Cluster Mode API
|
|
4771
|
+
// ============================================
|
|
4772
|
+
/**
|
|
4773
|
+
* Check if running in cluster mode
|
|
4774
|
+
*/
|
|
4775
|
+
isCluster() {
|
|
4776
|
+
return this.isClusterMode;
|
|
4777
|
+
}
|
|
4778
|
+
/**
|
|
4779
|
+
* Get list of connected cluster nodes (cluster mode only)
|
|
4780
|
+
* @returns Array of connected node IDs, or empty array in single-server mode
|
|
4781
|
+
*/
|
|
4782
|
+
getConnectedNodes() {
|
|
4783
|
+
if (!this.clusterClient) return [];
|
|
4784
|
+
return this.clusterClient.getConnectedNodes();
|
|
4785
|
+
}
|
|
4786
|
+
/**
|
|
4787
|
+
* Get the current partition map version (cluster mode only)
|
|
4788
|
+
* @returns Partition map version, or 0 in single-server mode
|
|
4789
|
+
*/
|
|
4790
|
+
getPartitionMapVersion() {
|
|
4791
|
+
if (!this.clusterClient) return 0;
|
|
4792
|
+
return this.clusterClient.getRouterStats().mapVersion;
|
|
4793
|
+
}
|
|
4794
|
+
/**
|
|
4795
|
+
* Check if direct routing is active (cluster mode only)
|
|
4796
|
+
* Direct routing sends operations directly to partition owners.
|
|
4797
|
+
* @returns true if routing is active, false otherwise
|
|
4798
|
+
*/
|
|
4799
|
+
isRoutingActive() {
|
|
4800
|
+
if (!this.clusterClient) return false;
|
|
4801
|
+
return this.clusterClient.isRoutingActive();
|
|
4802
|
+
}
|
|
4803
|
+
/**
|
|
4804
|
+
* Get health status for all cluster nodes (cluster mode only)
|
|
4805
|
+
* @returns Map of node IDs to their health status
|
|
4806
|
+
*/
|
|
4807
|
+
getClusterHealth() {
|
|
4808
|
+
if (!this.clusterClient) return /* @__PURE__ */ new Map();
|
|
4809
|
+
return this.clusterClient.getHealthStatus();
|
|
4810
|
+
}
|
|
4811
|
+
/**
|
|
4812
|
+
* Force refresh of partition map (cluster mode only)
|
|
4813
|
+
* Use this after detecting routing errors.
|
|
4814
|
+
*/
|
|
4815
|
+
async refreshPartitionMap() {
|
|
4816
|
+
if (!this.clusterClient) return;
|
|
4817
|
+
await this.clusterClient.refreshPartitionMap();
|
|
4818
|
+
}
|
|
4819
|
+
/**
|
|
4820
|
+
* Get cluster router statistics (cluster mode only)
|
|
4821
|
+
*/
|
|
4822
|
+
getClusterStats() {
|
|
4823
|
+
if (!this.clusterClient) return null;
|
|
4824
|
+
return this.clusterClient.getRouterStats();
|
|
4825
|
+
}
|
|
4826
|
+
// ============================================
|
|
1811
4827
|
// Connection State API
|
|
1812
4828
|
// ============================================
|
|
1813
4829
|
/**
|
|
@@ -1891,6 +4907,175 @@ var TopGunClient = class {
|
|
|
1891
4907
|
onBackpressure(event, listener) {
|
|
1892
4908
|
return this.syncEngine.onBackpressure(event, listener);
|
|
1893
4909
|
}
|
|
4910
|
+
// ============================================
|
|
4911
|
+
// Entry Processor API (Phase 5.03)
|
|
4912
|
+
// ============================================
|
|
4913
|
+
/**
|
|
4914
|
+
* Execute an entry processor on a single key atomically.
|
|
4915
|
+
*
|
|
4916
|
+
* Entry processors solve the read-modify-write race condition by executing
|
|
4917
|
+
* user-defined logic atomically on the server where the data lives.
|
|
4918
|
+
*
|
|
4919
|
+
* @param mapName Name of the map
|
|
4920
|
+
* @param key Key to process
|
|
4921
|
+
* @param processor Processor definition with name, code, and optional args
|
|
4922
|
+
* @returns Promise resolving to the processor result
|
|
4923
|
+
*
|
|
4924
|
+
* @example
|
|
4925
|
+
* ```typescript
|
|
4926
|
+
* // Increment a counter atomically
|
|
4927
|
+
* const result = await client.executeOnKey('stats', 'pageViews', {
|
|
4928
|
+
* name: 'increment',
|
|
4929
|
+
* code: `
|
|
4930
|
+
* const current = value ?? 0;
|
|
4931
|
+
* return { value: current + 1, result: current + 1 };
|
|
4932
|
+
* `,
|
|
4933
|
+
* });
|
|
4934
|
+
*
|
|
4935
|
+
* // Using built-in processor
|
|
4936
|
+
* import { BuiltInProcessors } from '@topgunbuild/core';
|
|
4937
|
+
* const result = await client.executeOnKey(
|
|
4938
|
+
* 'stats',
|
|
4939
|
+
* 'pageViews',
|
|
4940
|
+
* BuiltInProcessors.INCREMENT(1)
|
|
4941
|
+
* );
|
|
4942
|
+
* ```
|
|
4943
|
+
*/
|
|
4944
|
+
async executeOnKey(mapName, key, processor) {
|
|
4945
|
+
const result = await this.syncEngine.executeOnKey(mapName, key, processor);
|
|
4946
|
+
if (result.success && result.newValue !== void 0) {
|
|
4947
|
+
const map = this.maps.get(mapName);
|
|
4948
|
+
if (map instanceof import_core7.LWWMap) {
|
|
4949
|
+
map.set(key, result.newValue);
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4952
|
+
return result;
|
|
4953
|
+
}
|
|
4954
|
+
/**
|
|
4955
|
+
* Execute an entry processor on multiple keys.
|
|
4956
|
+
*
|
|
4957
|
+
* Each key is processed atomically. The operation returns when all keys
|
|
4958
|
+
* have been processed.
|
|
4959
|
+
*
|
|
4960
|
+
* @param mapName Name of the map
|
|
4961
|
+
* @param keys Keys to process
|
|
4962
|
+
* @param processor Processor definition
|
|
4963
|
+
* @returns Promise resolving to a map of key -> result
|
|
4964
|
+
*
|
|
4965
|
+
* @example
|
|
4966
|
+
* ```typescript
|
|
4967
|
+
* // Reset multiple counters
|
|
4968
|
+
* const results = await client.executeOnKeys(
|
|
4969
|
+
* 'stats',
|
|
4970
|
+
* ['pageViews', 'uniqueVisitors', 'bounceRate'],
|
|
4971
|
+
* {
|
|
4972
|
+
* name: 'reset',
|
|
4973
|
+
* code: `return { value: 0, result: value };`, // Returns old value
|
|
4974
|
+
* }
|
|
4975
|
+
* );
|
|
4976
|
+
*
|
|
4977
|
+
* for (const [key, result] of results) {
|
|
4978
|
+
* console.log(`${key}: was ${result.result}, now 0`);
|
|
4979
|
+
* }
|
|
4980
|
+
* ```
|
|
4981
|
+
*/
|
|
4982
|
+
async executeOnKeys(mapName, keys, processor) {
|
|
4983
|
+
const results = await this.syncEngine.executeOnKeys(mapName, keys, processor);
|
|
4984
|
+
const map = this.maps.get(mapName);
|
|
4985
|
+
if (map instanceof import_core7.LWWMap) {
|
|
4986
|
+
for (const [key, result] of results) {
|
|
4987
|
+
if (result.success && result.newValue !== void 0) {
|
|
4988
|
+
map.set(key, result.newValue);
|
|
4989
|
+
}
|
|
4990
|
+
}
|
|
4991
|
+
}
|
|
4992
|
+
return results;
|
|
4993
|
+
}
|
|
4994
|
+
/**
|
|
4995
|
+
* Get the Event Journal reader for subscribing to and reading
|
|
4996
|
+
* map change events.
|
|
4997
|
+
*
|
|
4998
|
+
* The Event Journal provides:
|
|
4999
|
+
* - Append-only log of all map changes (PUT, UPDATE, DELETE)
|
|
5000
|
+
* - Subscription to real-time events
|
|
5001
|
+
* - Historical event replay
|
|
5002
|
+
* - Audit trail for compliance
|
|
5003
|
+
*
|
|
5004
|
+
* @returns EventJournalReader instance
|
|
5005
|
+
*
|
|
5006
|
+
* @example
|
|
5007
|
+
* ```typescript
|
|
5008
|
+
* const journal = client.getEventJournal();
|
|
5009
|
+
*
|
|
5010
|
+
* // Subscribe to all events
|
|
5011
|
+
* const unsubscribe = journal.subscribe((event) => {
|
|
5012
|
+
* console.log(`${event.type} on ${event.mapName}:${event.key}`);
|
|
5013
|
+
* });
|
|
5014
|
+
*
|
|
5015
|
+
* // Subscribe to specific map
|
|
5016
|
+
* journal.subscribe(
|
|
5017
|
+
* (event) => console.log('User changed:', event.key),
|
|
5018
|
+
* { mapName: 'users' }
|
|
5019
|
+
* );
|
|
5020
|
+
*
|
|
5021
|
+
* // Read historical events
|
|
5022
|
+
* const events = await journal.readFrom(0n, 100);
|
|
5023
|
+
* ```
|
|
5024
|
+
*/
|
|
5025
|
+
getEventJournal() {
|
|
5026
|
+
if (!this.journalReader) {
|
|
5027
|
+
this.journalReader = new EventJournalReader(this.syncEngine);
|
|
5028
|
+
}
|
|
5029
|
+
return this.journalReader;
|
|
5030
|
+
}
|
|
5031
|
+
// ============================================
|
|
5032
|
+
// Conflict Resolver API (Phase 5.05)
|
|
5033
|
+
// ============================================
|
|
5034
|
+
/**
|
|
5035
|
+
* Get the conflict resolver client for registering custom merge resolvers.
|
|
5036
|
+
*
|
|
5037
|
+
* Conflict resolvers allow you to customize how merge conflicts are handled
|
|
5038
|
+
* on the server. You can implement business logic like:
|
|
5039
|
+
* - First-write-wins for booking systems
|
|
5040
|
+
* - Numeric constraints (non-negative, min/max)
|
|
5041
|
+
* - Owner-only modifications
|
|
5042
|
+
* - Custom merge strategies
|
|
5043
|
+
*
|
|
5044
|
+
* @returns ConflictResolverClient instance
|
|
5045
|
+
*
|
|
5046
|
+
* @example
|
|
5047
|
+
* ```typescript
|
|
5048
|
+
* const resolvers = client.getConflictResolvers();
|
|
5049
|
+
*
|
|
5050
|
+
* // Register a first-write-wins resolver
|
|
5051
|
+
* await resolvers.register('bookings', {
|
|
5052
|
+
* name: 'first-write-wins',
|
|
5053
|
+
* code: `
|
|
5054
|
+
* if (context.localValue !== undefined) {
|
|
5055
|
+
* return { action: 'reject', reason: 'Slot already booked' };
|
|
5056
|
+
* }
|
|
5057
|
+
* return { action: 'accept', value: context.remoteValue };
|
|
5058
|
+
* `,
|
|
5059
|
+
* priority: 100,
|
|
5060
|
+
* });
|
|
5061
|
+
*
|
|
5062
|
+
* // Subscribe to merge rejections
|
|
5063
|
+
* resolvers.onRejection((rejection) => {
|
|
5064
|
+
* console.log(`Merge rejected: ${rejection.reason}`);
|
|
5065
|
+
* // Optionally refresh local state
|
|
5066
|
+
* });
|
|
5067
|
+
*
|
|
5068
|
+
* // List registered resolvers
|
|
5069
|
+
* const registered = await resolvers.list('bookings');
|
|
5070
|
+
* console.log('Active resolvers:', registered);
|
|
5071
|
+
*
|
|
5072
|
+
* // Unregister when done
|
|
5073
|
+
* await resolvers.unregister('bookings', 'first-write-wins');
|
|
5074
|
+
* ```
|
|
5075
|
+
*/
|
|
5076
|
+
getConflictResolvers() {
|
|
5077
|
+
return this.syncEngine.getConflictResolverClient();
|
|
5078
|
+
}
|
|
1894
5079
|
};
|
|
1895
5080
|
|
|
1896
5081
|
// src/adapters/IDBAdapter.ts
|
|
@@ -2158,14 +5343,14 @@ var CollectionWrapper = class {
|
|
|
2158
5343
|
};
|
|
2159
5344
|
|
|
2160
5345
|
// src/crypto/EncryptionManager.ts
|
|
2161
|
-
var
|
|
5346
|
+
var import_core8 = require("@topgunbuild/core");
|
|
2162
5347
|
var _EncryptionManager = class _EncryptionManager {
|
|
2163
5348
|
/**
|
|
2164
5349
|
* Encrypts data using AES-GCM.
|
|
2165
5350
|
* Serializes data to MessagePack before encryption.
|
|
2166
5351
|
*/
|
|
2167
5352
|
static async encrypt(key, data) {
|
|
2168
|
-
const encoded = (0,
|
|
5353
|
+
const encoded = (0, import_core8.serialize)(data);
|
|
2169
5354
|
const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
|
|
2170
5355
|
const ciphertext = await window.crypto.subtle.encrypt(
|
|
2171
5356
|
{
|
|
@@ -2194,7 +5379,7 @@ var _EncryptionManager = class _EncryptionManager {
|
|
|
2194
5379
|
key,
|
|
2195
5380
|
record.data
|
|
2196
5381
|
);
|
|
2197
|
-
return (0,
|
|
5382
|
+
return (0, import_core8.deserialize)(new Uint8Array(plaintextBuffer));
|
|
2198
5383
|
} catch (err) {
|
|
2199
5384
|
console.error("Decryption failed", err);
|
|
2200
5385
|
throw new Error("Failed to decrypt data: " + err);
|
|
@@ -2318,16 +5503,25 @@ var EncryptedStorageAdapter = class {
|
|
|
2318
5503
|
};
|
|
2319
5504
|
|
|
2320
5505
|
// src/index.ts
|
|
2321
|
-
var
|
|
5506
|
+
var import_core9 = require("@topgunbuild/core");
|
|
2322
5507
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2323
5508
|
0 && (module.exports = {
|
|
2324
5509
|
BackpressureError,
|
|
5510
|
+
ChangeTracker,
|
|
5511
|
+
ClusterClient,
|
|
5512
|
+
ConflictResolverClient,
|
|
5513
|
+
ConnectionPool,
|
|
2325
5514
|
DEFAULT_BACKPRESSURE_CONFIG,
|
|
5515
|
+
DEFAULT_CLUSTER_CONFIG,
|
|
2326
5516
|
EncryptedStorageAdapter,
|
|
5517
|
+
EventJournalReader,
|
|
2327
5518
|
IDBAdapter,
|
|
2328
5519
|
LWWMap,
|
|
5520
|
+
PNCounterHandle,
|
|
5521
|
+
PartitionRouter,
|
|
2329
5522
|
Predicates,
|
|
2330
5523
|
QueryHandle,
|
|
5524
|
+
SingleServerProvider,
|
|
2331
5525
|
SyncEngine,
|
|
2332
5526
|
SyncState,
|
|
2333
5527
|
SyncStateMachine,
|