@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.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: () => import_core4.LWWMap,
38
- Predicates: () => import_core4.Predicates,
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 SyncEngine = class {
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
- this.initConnection();
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
- if (this.websocket?.readyState === WebSocket.OPEN) {
479
- this.websocket.send((0, import_core.serialize)({
480
- type: "OP_BATCH",
481
- payload: {
482
- ops: pending
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
1212
+ this.sendMessage({
626
1213
  type: "QUERY_UNSUB",
627
1214
  payload: { queryId }
628
- }));
1215
+ });
629
1216
  }
630
1217
  }
631
1218
  sendQuerySubscription(query) {
632
- this.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket?.send((0, import_core.serialize)({
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.websocket) {
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.initConnection();
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.websocket?.readyState === WebSocket.OPEN) {
1857
+ if (this.canSend()) {
1118
1858
  const pingMessage = {
1119
1859
  type: "PING",
1120
1860
  timestamp: Date.now()
1121
1861
  };
1122
- this.websocket.send((0, import_core.serialize)(pingMessage));
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.websocket?.send((0, import_core.serialize)({
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 import_core2 = require("@topgunbuild/core");
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
- console.log(`[QueryHandle:${this.mapName}] onResult called with ${items.length} items`, {
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
- console.log(`[QueryHandle:${this.mapName}] Ignoring empty server response - waiting for authoritative data`);
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
- console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
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
- console.log(`[QueryHandle:${this.mapName}] After merge: ${this.currentResults.size} results`);
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
- getFilter() {
1547
- return this.filter;
4270
+ /**
4271
+ * Get connection pool health status
4272
+ */
4273
+ getHealthStatus() {
4274
+ return this.connectionPool.getHealthStatus();
1548
4275
  }
1549
- getMapName() {
1550
- return this.mapName;
4276
+ /**
4277
+ * Get partition router stats
4278
+ */
4279
+ getRouterStats() {
4280
+ return this.partitionRouter.getStats();
1551
4281
  }
1552
- };
1553
-
1554
- // src/DistributedLock.ts
1555
- var DistributedLock = class {
1556
- constructor(syncEngine, name) {
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
- async lock(ttl = 1e4) {
1563
- const requestId = crypto.randomUUID();
1564
- try {
1565
- const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
1566
- this.fencingToken = result.fencingToken;
1567
- this._isLocked = true;
1568
- return true;
1569
- } catch (e) {
1570
- return false;
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
- async unlock() {
1574
- if (!this._isLocked || this.fencingToken === null) return;
1575
- const requestId = crypto.randomUUID();
1576
- try {
1577
- await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
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
- isLocked() {
1584
- return this._isLocked;
4304
+ /**
4305
+ * Get list of connected nodes
4306
+ */
4307
+ getConnectedNodes() {
4308
+ return this.connectionPool.getConnectedNodes();
1585
4309
  }
1586
- };
1587
-
1588
- // src/TopicHandle.ts
1589
- var TopicHandle = class {
1590
- constructor(engine, topic) {
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
- get id() {
1596
- return this.topic;
4316
+ /**
4317
+ * Force refresh of partition map
4318
+ */
4319
+ async refreshPartitionMap() {
4320
+ await this.partitionRouter.refreshPartitionMap();
1597
4321
  }
1598
4322
  /**
1599
- * Publish a message to the topic
4323
+ * Shutdown cluster client (IConnectionProvider interface).
1600
4324
  */
1601
- publish(data) {
1602
- this.engine.publishTopic(this.topic, data);
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
- * Subscribe to the topic
4342
+ * Get the partition router (for internal use)
1606
4343
  */
1607
- subscribe(callback) {
1608
- if (this.listeners.size === 0) {
1609
- this.engine.subscribeToTopic(this.topic, this);
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
- this.listeners.add(callback);
1612
- return () => this.unsubscribe(callback);
4356
+ return conn.socket;
1613
4357
  }
1614
- unsubscribe(callback) {
1615
- this.listeners.delete(callback);
1616
- if (this.listeners.size === 0) {
1617
- this.engine.unsubscribeFromTopic(this.topic);
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
- * Called by SyncEngine when a message is received
4381
+ * Check if a node can be used (circuit not open).
1622
4382
  */
1623
- onMessage(data, context) {
1624
- this.listeners.forEach((cb) => {
1625
- try {
1626
- cb(data, context);
1627
- } catch (e) {
1628
- console.error("Error in topic listener", e);
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
- const syncEngineConfig = {
1642
- nodeId: this.nodeId,
1643
- serverUrl: config.serverUrl,
1644
- storageAdapter: this.storageAdapter,
1645
- backoff: config.backoff,
1646
- backpressure: config.backpressure
1647
- };
1648
- this.syncEngine = new SyncEngine(syncEngineConfig);
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 import_core2.LWWMap) {
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 import_core2.LWWMap(this.syncEngine.getHLC());
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 import_core2.ORMap) {
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 import_core2.ORMap(this.syncEngine.getHLC());
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 import_core3 = require("@topgunbuild/core");
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, import_core3.serialize)(data);
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, import_core3.deserialize)(new Uint8Array(plaintextBuffer));
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 import_core4 = require("@topgunbuild/core");
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,