@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.mjs CHANGED
@@ -203,6 +203,464 @@ var DEFAULT_BACKPRESSURE_CONFIG = {
203
203
  lowWaterMark: 0.5
204
204
  };
205
205
 
206
+ // src/connection/SingleServerProvider.ts
207
+ var DEFAULT_CONFIG = {
208
+ maxReconnectAttempts: 10,
209
+ reconnectDelayMs: 1e3,
210
+ backoffMultiplier: 2,
211
+ maxReconnectDelayMs: 3e4
212
+ };
213
+ var SingleServerProvider = class {
214
+ constructor(config) {
215
+ this.ws = null;
216
+ this.reconnectAttempts = 0;
217
+ this.reconnectTimer = null;
218
+ this.isClosing = false;
219
+ this.listeners = /* @__PURE__ */ new Map();
220
+ this.url = config.url;
221
+ this.config = {
222
+ url: config.url,
223
+ maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
224
+ reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
225
+ backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
226
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
227
+ };
228
+ }
229
+ /**
230
+ * Connect to the WebSocket server.
231
+ */
232
+ async connect() {
233
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
234
+ return;
235
+ }
236
+ this.isClosing = false;
237
+ return new Promise((resolve, reject) => {
238
+ try {
239
+ this.ws = new WebSocket(this.url);
240
+ this.ws.binaryType = "arraybuffer";
241
+ this.ws.onopen = () => {
242
+ this.reconnectAttempts = 0;
243
+ logger.info({ url: this.url }, "SingleServerProvider connected");
244
+ this.emit("connected", "default");
245
+ resolve();
246
+ };
247
+ this.ws.onerror = (error) => {
248
+ logger.error({ err: error, url: this.url }, "SingleServerProvider WebSocket error");
249
+ this.emit("error", error);
250
+ };
251
+ this.ws.onclose = (event) => {
252
+ logger.info({ url: this.url, code: event.code }, "SingleServerProvider disconnected");
253
+ this.emit("disconnected", "default");
254
+ if (!this.isClosing) {
255
+ this.scheduleReconnect();
256
+ }
257
+ };
258
+ this.ws.onmessage = (event) => {
259
+ this.emit("message", "default", event.data);
260
+ };
261
+ const timeoutId = setTimeout(() => {
262
+ if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
263
+ this.ws.close();
264
+ reject(new Error(`Connection timeout to ${this.url}`));
265
+ }
266
+ }, this.config.reconnectDelayMs * 5);
267
+ const originalOnOpen = this.ws.onopen;
268
+ const wsRef = this.ws;
269
+ this.ws.onopen = (ev) => {
270
+ clearTimeout(timeoutId);
271
+ if (originalOnOpen) {
272
+ originalOnOpen.call(wsRef, ev);
273
+ }
274
+ };
275
+ } catch (error) {
276
+ reject(error);
277
+ }
278
+ });
279
+ }
280
+ /**
281
+ * Get connection for a specific key.
282
+ * In single-server mode, key is ignored.
283
+ */
284
+ getConnection(_key) {
285
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
286
+ throw new Error("Not connected");
287
+ }
288
+ return this.ws;
289
+ }
290
+ /**
291
+ * Get any available connection.
292
+ */
293
+ getAnyConnection() {
294
+ return this.getConnection("");
295
+ }
296
+ /**
297
+ * Check if connected.
298
+ */
299
+ isConnected() {
300
+ return this.ws?.readyState === WebSocket.OPEN;
301
+ }
302
+ /**
303
+ * Get connected node IDs.
304
+ * Single-server mode returns ['default'] when connected.
305
+ */
306
+ getConnectedNodes() {
307
+ return this.isConnected() ? ["default"] : [];
308
+ }
309
+ /**
310
+ * Subscribe to connection events.
311
+ */
312
+ on(event, handler2) {
313
+ if (!this.listeners.has(event)) {
314
+ this.listeners.set(event, /* @__PURE__ */ new Set());
315
+ }
316
+ this.listeners.get(event).add(handler2);
317
+ }
318
+ /**
319
+ * Unsubscribe from connection events.
320
+ */
321
+ off(event, handler2) {
322
+ this.listeners.get(event)?.delete(handler2);
323
+ }
324
+ /**
325
+ * Send data via the WebSocket connection.
326
+ * In single-server mode, key parameter is ignored.
327
+ */
328
+ send(data, _key) {
329
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
330
+ throw new Error("Not connected");
331
+ }
332
+ this.ws.send(data);
333
+ }
334
+ /**
335
+ * Close the WebSocket connection.
336
+ */
337
+ async close() {
338
+ this.isClosing = true;
339
+ if (this.reconnectTimer) {
340
+ clearTimeout(this.reconnectTimer);
341
+ this.reconnectTimer = null;
342
+ }
343
+ if (this.ws) {
344
+ this.ws.onclose = null;
345
+ this.ws.onerror = null;
346
+ this.ws.onmessage = null;
347
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
348
+ this.ws.close();
349
+ }
350
+ this.ws = null;
351
+ }
352
+ logger.info({ url: this.url }, "SingleServerProvider closed");
353
+ }
354
+ /**
355
+ * Emit an event to all listeners.
356
+ */
357
+ emit(event, ...args) {
358
+ const handlers = this.listeners.get(event);
359
+ if (handlers) {
360
+ for (const handler2 of handlers) {
361
+ try {
362
+ handler2(...args);
363
+ } catch (err) {
364
+ logger.error({ err, event }, "Error in SingleServerProvider event handler");
365
+ }
366
+ }
367
+ }
368
+ }
369
+ /**
370
+ * Schedule a reconnection attempt with exponential backoff.
371
+ */
372
+ scheduleReconnect() {
373
+ if (this.reconnectTimer) {
374
+ clearTimeout(this.reconnectTimer);
375
+ this.reconnectTimer = null;
376
+ }
377
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
378
+ logger.error(
379
+ { attempts: this.reconnectAttempts, url: this.url },
380
+ "SingleServerProvider max reconnect attempts reached"
381
+ );
382
+ this.emit("error", new Error("Max reconnection attempts reached"));
383
+ return;
384
+ }
385
+ const delay = this.calculateBackoffDelay();
386
+ logger.info(
387
+ { delay, attempt: this.reconnectAttempts, url: this.url },
388
+ `SingleServerProvider scheduling reconnect in ${delay}ms`
389
+ );
390
+ this.reconnectTimer = setTimeout(async () => {
391
+ this.reconnectTimer = null;
392
+ this.reconnectAttempts++;
393
+ try {
394
+ await this.connect();
395
+ this.emit("reconnected", "default");
396
+ } catch (error) {
397
+ logger.error({ err: error }, "SingleServerProvider reconnection failed");
398
+ this.scheduleReconnect();
399
+ }
400
+ }, delay);
401
+ }
402
+ /**
403
+ * Calculate backoff delay with exponential increase.
404
+ */
405
+ calculateBackoffDelay() {
406
+ const { reconnectDelayMs, backoffMultiplier, maxReconnectDelayMs } = this.config;
407
+ let delay = reconnectDelayMs * Math.pow(backoffMultiplier, this.reconnectAttempts);
408
+ delay = Math.min(delay, maxReconnectDelayMs);
409
+ delay = delay * (0.5 + Math.random());
410
+ return Math.floor(delay);
411
+ }
412
+ /**
413
+ * Get the WebSocket URL this provider connects to.
414
+ */
415
+ getUrl() {
416
+ return this.url;
417
+ }
418
+ /**
419
+ * Get current reconnection attempt count.
420
+ */
421
+ getReconnectAttempts() {
422
+ return this.reconnectAttempts;
423
+ }
424
+ /**
425
+ * Reset reconnection counter.
426
+ * Called externally after successful authentication.
427
+ */
428
+ resetReconnectAttempts() {
429
+ this.reconnectAttempts = 0;
430
+ }
431
+ };
432
+
433
+ // src/ConflictResolverClient.ts
434
+ var _ConflictResolverClient = class _ConflictResolverClient {
435
+ // 10 seconds
436
+ constructor(syncEngine) {
437
+ this.rejectionListeners = /* @__PURE__ */ new Set();
438
+ this.pendingRequests = /* @__PURE__ */ new Map();
439
+ this.syncEngine = syncEngine;
440
+ }
441
+ /**
442
+ * Register a conflict resolver on the server.
443
+ *
444
+ * @param mapName The map to register the resolver for
445
+ * @param resolver The resolver definition
446
+ * @returns Promise resolving to registration result
447
+ *
448
+ * @example
449
+ * ```typescript
450
+ * // Register a first-write-wins resolver for bookings
451
+ * await client.resolvers.register('bookings', {
452
+ * name: 'first-write-wins',
453
+ * code: `
454
+ * if (context.localValue !== undefined) {
455
+ * return { action: 'reject', reason: 'Slot already booked' };
456
+ * }
457
+ * return { action: 'accept', value: context.remoteValue };
458
+ * `,
459
+ * priority: 100,
460
+ * });
461
+ * ```
462
+ */
463
+ async register(mapName, resolver) {
464
+ const requestId = crypto.randomUUID();
465
+ return new Promise((resolve, reject) => {
466
+ const timeout = setTimeout(() => {
467
+ this.pendingRequests.delete(requestId);
468
+ reject(new Error("Register resolver request timed out"));
469
+ }, _ConflictResolverClient.REQUEST_TIMEOUT);
470
+ this.pendingRequests.set(requestId, {
471
+ resolve: (result) => {
472
+ clearTimeout(timeout);
473
+ resolve(result);
474
+ },
475
+ reject,
476
+ timeout
477
+ });
478
+ try {
479
+ this.syncEngine.send({
480
+ type: "REGISTER_RESOLVER",
481
+ requestId,
482
+ mapName,
483
+ resolver: {
484
+ name: resolver.name,
485
+ code: resolver.code || "",
486
+ priority: resolver.priority,
487
+ keyPattern: resolver.keyPattern
488
+ }
489
+ });
490
+ } catch {
491
+ this.pendingRequests.delete(requestId);
492
+ clearTimeout(timeout);
493
+ resolve({ success: false, error: "Not connected to server" });
494
+ }
495
+ });
496
+ }
497
+ /**
498
+ * Unregister a conflict resolver from the server.
499
+ *
500
+ * @param mapName The map the resolver is registered for
501
+ * @param resolverName The name of the resolver to unregister
502
+ * @returns Promise resolving to unregistration result
503
+ */
504
+ async unregister(mapName, resolverName) {
505
+ const requestId = crypto.randomUUID();
506
+ return new Promise((resolve, reject) => {
507
+ const timeout = setTimeout(() => {
508
+ this.pendingRequests.delete(requestId);
509
+ reject(new Error("Unregister resolver request timed out"));
510
+ }, _ConflictResolverClient.REQUEST_TIMEOUT);
511
+ this.pendingRequests.set(requestId, {
512
+ resolve: (result) => {
513
+ clearTimeout(timeout);
514
+ resolve(result);
515
+ },
516
+ reject,
517
+ timeout
518
+ });
519
+ try {
520
+ this.syncEngine.send({
521
+ type: "UNREGISTER_RESOLVER",
522
+ requestId,
523
+ mapName,
524
+ resolverName
525
+ });
526
+ } catch {
527
+ this.pendingRequests.delete(requestId);
528
+ clearTimeout(timeout);
529
+ resolve({ success: false, error: "Not connected to server" });
530
+ }
531
+ });
532
+ }
533
+ /**
534
+ * List registered conflict resolvers on the server.
535
+ *
536
+ * @param mapName Optional - filter by map name
537
+ * @returns Promise resolving to list of resolver info
538
+ */
539
+ async list(mapName) {
540
+ const requestId = crypto.randomUUID();
541
+ return new Promise((resolve, reject) => {
542
+ const timeout = setTimeout(() => {
543
+ this.pendingRequests.delete(requestId);
544
+ reject(new Error("List resolvers request timed out"));
545
+ }, _ConflictResolverClient.REQUEST_TIMEOUT);
546
+ this.pendingRequests.set(requestId, {
547
+ resolve: (result) => {
548
+ clearTimeout(timeout);
549
+ resolve(result.resolvers);
550
+ },
551
+ reject,
552
+ timeout
553
+ });
554
+ try {
555
+ this.syncEngine.send({
556
+ type: "LIST_RESOLVERS",
557
+ requestId,
558
+ mapName
559
+ });
560
+ } catch {
561
+ this.pendingRequests.delete(requestId);
562
+ clearTimeout(timeout);
563
+ resolve([]);
564
+ }
565
+ });
566
+ }
567
+ /**
568
+ * Subscribe to merge rejection events.
569
+ *
570
+ * @param listener Callback for rejection events
571
+ * @returns Unsubscribe function
572
+ *
573
+ * @example
574
+ * ```typescript
575
+ * const unsubscribe = client.resolvers.onRejection((rejection) => {
576
+ * console.log(`Merge rejected for ${rejection.key}: ${rejection.reason}`);
577
+ * // Optionally refresh the local value
578
+ * });
579
+ *
580
+ * // Later...
581
+ * unsubscribe();
582
+ * ```
583
+ */
584
+ onRejection(listener) {
585
+ this.rejectionListeners.add(listener);
586
+ return () => this.rejectionListeners.delete(listener);
587
+ }
588
+ /**
589
+ * Handle REGISTER_RESOLVER_RESPONSE from server.
590
+ * Called by SyncEngine.
591
+ */
592
+ handleRegisterResponse(message) {
593
+ const pending = this.pendingRequests.get(message.requestId);
594
+ if (pending) {
595
+ this.pendingRequests.delete(message.requestId);
596
+ pending.resolve({ success: message.success, error: message.error });
597
+ }
598
+ }
599
+ /**
600
+ * Handle UNREGISTER_RESOLVER_RESPONSE from server.
601
+ * Called by SyncEngine.
602
+ */
603
+ handleUnregisterResponse(message) {
604
+ const pending = this.pendingRequests.get(message.requestId);
605
+ if (pending) {
606
+ this.pendingRequests.delete(message.requestId);
607
+ pending.resolve({ success: message.success, error: message.error });
608
+ }
609
+ }
610
+ /**
611
+ * Handle LIST_RESOLVERS_RESPONSE from server.
612
+ * Called by SyncEngine.
613
+ */
614
+ handleListResponse(message) {
615
+ const pending = this.pendingRequests.get(message.requestId);
616
+ if (pending) {
617
+ this.pendingRequests.delete(message.requestId);
618
+ pending.resolve({ resolvers: message.resolvers });
619
+ }
620
+ }
621
+ /**
622
+ * Handle MERGE_REJECTED from server.
623
+ * Called by SyncEngine.
624
+ */
625
+ handleMergeRejected(message) {
626
+ const rejection = {
627
+ mapName: message.mapName,
628
+ key: message.key,
629
+ attemptedValue: message.attemptedValue,
630
+ reason: message.reason,
631
+ timestamp: message.timestamp,
632
+ nodeId: ""
633
+ // Not provided by server in this message
634
+ };
635
+ logger.debug({ rejection }, "Merge rejected by server");
636
+ for (const listener of this.rejectionListeners) {
637
+ try {
638
+ listener(rejection);
639
+ } catch (e) {
640
+ logger.error({ error: e }, "Error in rejection listener");
641
+ }
642
+ }
643
+ }
644
+ /**
645
+ * Clear all pending requests (e.g., on disconnect).
646
+ */
647
+ clearPending() {
648
+ for (const [requestId, pending] of this.pendingRequests) {
649
+ clearTimeout(pending.timeout);
650
+ pending.reject(new Error("Connection lost"));
651
+ }
652
+ this.pendingRequests.clear();
653
+ }
654
+ /**
655
+ * Get the number of registered rejection listeners.
656
+ */
657
+ get rejectionListenerCount() {
658
+ return this.rejectionListeners.size;
659
+ }
660
+ };
661
+ _ConflictResolverClient.REQUEST_TIMEOUT = 1e4;
662
+ var ConflictResolverClient = _ConflictResolverClient;
663
+
206
664
  // src/SyncEngine.ts
207
665
  var DEFAULT_BACKOFF_CONFIG = {
208
666
  initialDelayMs: 1e3,
@@ -211,7 +669,7 @@ var DEFAULT_BACKOFF_CONFIG = {
211
669
  jitter: true,
212
670
  maxRetries: 10
213
671
  };
214
- var SyncEngine = class {
672
+ var _SyncEngine = class _SyncEngine {
215
673
  constructor(config) {
216
674
  this.websocket = null;
217
675
  this.opLog = [];
@@ -234,8 +692,28 @@ var SyncEngine = class {
234
692
  this.backpressureListeners = /* @__PURE__ */ new Map();
235
693
  // Write Concern state (Phase 5.01)
236
694
  this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
695
+ // ============================================
696
+ // PN Counter Methods (Phase 5.2)
697
+ // ============================================
698
+ /** Counter update listeners by name */
699
+ this.counterUpdateListeners = /* @__PURE__ */ new Map();
700
+ // ============================================
701
+ // Entry Processor Methods (Phase 5.03)
702
+ // ============================================
703
+ /** Pending entry processor requests by requestId */
704
+ this.pendingProcessorRequests = /* @__PURE__ */ new Map();
705
+ /** Pending batch entry processor requests by requestId */
706
+ this.pendingBatchProcessorRequests = /* @__PURE__ */ new Map();
707
+ // ============================================
708
+ // Event Journal Methods (Phase 5.04)
709
+ // ============================================
710
+ /** Message listeners for journal and other generic messages */
711
+ this.messageListeners = /* @__PURE__ */ new Set();
712
+ if (!config.serverUrl && !config.connectionProvider) {
713
+ throw new Error("SyncEngine requires either serverUrl or connectionProvider");
714
+ }
237
715
  this.nodeId = config.nodeId;
238
- this.serverUrl = config.serverUrl;
716
+ this.serverUrl = config.serverUrl || "";
239
717
  this.storageAdapter = config.storageAdapter;
240
718
  this.hlc = new HLC(this.nodeId);
241
719
  this.stateMachine = new SyncStateMachine();
@@ -252,7 +730,16 @@ var SyncEngine = class {
252
730
  ...DEFAULT_BACKPRESSURE_CONFIG,
253
731
  ...config.backpressure
254
732
  };
255
- this.initConnection();
733
+ if (config.connectionProvider) {
734
+ this.connectionProvider = config.connectionProvider;
735
+ this.useConnectionProvider = true;
736
+ this.initConnectionProvider();
737
+ } else {
738
+ this.connectionProvider = new SingleServerProvider({ url: config.serverUrl });
739
+ this.useConnectionProvider = false;
740
+ this.initConnection();
741
+ }
742
+ this.conflictResolverClient = new ConflictResolverClient(this);
256
743
  this.loadOpLog();
257
744
  }
258
745
  // ============================================
@@ -303,6 +790,65 @@ var SyncEngine = class {
303
790
  // ============================================
304
791
  // Connection Management
305
792
  // ============================================
793
+ /**
794
+ * Initialize connection using IConnectionProvider (Phase 4.5 cluster mode).
795
+ * Sets up event handlers for the connection provider.
796
+ */
797
+ initConnectionProvider() {
798
+ this.stateMachine.transition("CONNECTING" /* CONNECTING */);
799
+ this.connectionProvider.on("connected", (_nodeId) => {
800
+ if (this.authToken || this.tokenProvider) {
801
+ logger.info("ConnectionProvider connected. Sending auth...");
802
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
803
+ this.sendAuth();
804
+ } else {
805
+ logger.info("ConnectionProvider connected. Waiting for auth token...");
806
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
807
+ }
808
+ });
809
+ this.connectionProvider.on("disconnected", (_nodeId) => {
810
+ logger.info("ConnectionProvider disconnected.");
811
+ this.stopHeartbeat();
812
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
813
+ });
814
+ this.connectionProvider.on("reconnected", (_nodeId) => {
815
+ logger.info("ConnectionProvider reconnected.");
816
+ this.stateMachine.transition("CONNECTING" /* CONNECTING */);
817
+ if (this.authToken || this.tokenProvider) {
818
+ this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
819
+ this.sendAuth();
820
+ }
821
+ });
822
+ this.connectionProvider.on("message", (_nodeId, data) => {
823
+ let message;
824
+ if (data instanceof ArrayBuffer) {
825
+ message = deserialize(new Uint8Array(data));
826
+ } else if (data instanceof Uint8Array) {
827
+ message = deserialize(data);
828
+ } else {
829
+ try {
830
+ message = typeof data === "string" ? JSON.parse(data) : data;
831
+ } catch (e) {
832
+ logger.error({ err: e }, "Failed to parse message from ConnectionProvider");
833
+ return;
834
+ }
835
+ }
836
+ this.handleServerMessage(message);
837
+ });
838
+ this.connectionProvider.on("partitionMapUpdated", () => {
839
+ logger.debug("Partition map updated");
840
+ });
841
+ this.connectionProvider.on("error", (error) => {
842
+ logger.error({ err: error }, "ConnectionProvider error");
843
+ });
844
+ this.connectionProvider.connect().catch((err) => {
845
+ logger.error({ err }, "Failed to connect via ConnectionProvider");
846
+ this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
847
+ });
848
+ }
849
+ /**
850
+ * Initialize connection using direct WebSocket (legacy single-server mode).
851
+ */
306
852
  initConnection() {
307
853
  this.stateMachine.transition("CONNECTING" /* CONNECTING */);
308
854
  this.websocket = new WebSocket(this.serverUrl);
@@ -378,6 +924,40 @@ var SyncEngine = class {
378
924
  resetBackoff() {
379
925
  this.backoffAttempt = 0;
380
926
  }
927
+ /**
928
+ * Send a message through the current connection.
929
+ * Uses connectionProvider if in cluster mode, otherwise uses direct websocket.
930
+ * @param message Message object to serialize and send
931
+ * @param key Optional key for routing (cluster mode only)
932
+ * @returns true if message was sent, false otherwise
933
+ */
934
+ sendMessage(message, key) {
935
+ const data = serialize(message);
936
+ if (this.useConnectionProvider) {
937
+ try {
938
+ this.connectionProvider.send(data, key);
939
+ return true;
940
+ } catch (err) {
941
+ logger.warn({ err }, "Failed to send via ConnectionProvider");
942
+ return false;
943
+ }
944
+ } else {
945
+ if (this.websocket?.readyState === WebSocket.OPEN) {
946
+ this.websocket.send(data);
947
+ return true;
948
+ }
949
+ return false;
950
+ }
951
+ }
952
+ /**
953
+ * Check if we can send messages (connection is ready).
954
+ */
955
+ canSend() {
956
+ if (this.useConnectionProvider) {
957
+ return this.connectionProvider.isConnected();
958
+ }
959
+ return this.websocket?.readyState === WebSocket.OPEN;
960
+ }
381
961
  async loadOpLog() {
382
962
  const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
383
963
  if (storedTimestamp) {
@@ -424,36 +1004,34 @@ var SyncEngine = class {
424
1004
  const pending = this.opLog.filter((op) => !op.synced);
425
1005
  if (pending.length === 0) return;
426
1006
  logger.info({ count: pending.length }, "Syncing pending operations");
427
- if (this.websocket?.readyState === WebSocket.OPEN) {
428
- this.websocket.send(serialize({
429
- type: "OP_BATCH",
430
- payload: {
431
- ops: pending
432
- }
433
- }));
434
- }
1007
+ this.sendMessage({
1008
+ type: "OP_BATCH",
1009
+ payload: {
1010
+ ops: pending
1011
+ }
1012
+ });
435
1013
  }
436
1014
  startMerkleSync() {
437
1015
  for (const [mapName, map] of this.maps) {
438
1016
  if (map instanceof LWWMap) {
439
1017
  logger.info({ mapName }, "Starting Merkle sync for LWWMap");
440
- this.websocket?.send(serialize({
1018
+ this.sendMessage({
441
1019
  type: "SYNC_INIT",
442
1020
  mapName,
443
1021
  lastSyncTimestamp: this.lastSyncTimestamp
444
- }));
1022
+ });
445
1023
  } else if (map instanceof ORMap) {
446
1024
  logger.info({ mapName }, "Starting Merkle sync for ORMap");
447
1025
  const tree = map.getMerkleTree();
448
1026
  const rootHash = tree.getRootHash();
449
1027
  const bucketHashes = tree.getBuckets("");
450
- this.websocket?.send(serialize({
1028
+ this.sendMessage({
451
1029
  type: "ORMAP_SYNC_INIT",
452
1030
  mapName,
453
1031
  rootHash,
454
1032
  bucketHashes,
455
1033
  lastSyncTimestamp: this.lastSyncTimestamp
456
- }));
1034
+ });
457
1035
  }
458
1036
  }
459
1037
  }
@@ -494,10 +1072,10 @@ var SyncEngine = class {
494
1072
  }
495
1073
  const token = this.authToken;
496
1074
  if (!token) return;
497
- this.websocket?.send(serialize({
1075
+ this.sendMessage({
498
1076
  type: "AUTH",
499
1077
  token
500
- }));
1078
+ });
501
1079
  }
502
1080
  subscribeToQuery(query) {
503
1081
  this.queries.set(query.id, query);
@@ -514,27 +1092,27 @@ var SyncEngine = class {
514
1092
  unsubscribeFromTopic(topic) {
515
1093
  this.topics.delete(topic);
516
1094
  if (this.isAuthenticated()) {
517
- this.websocket?.send(serialize({
1095
+ this.sendMessage({
518
1096
  type: "TOPIC_UNSUB",
519
1097
  payload: { topic }
520
- }));
1098
+ });
521
1099
  }
522
1100
  }
523
1101
  publishTopic(topic, data) {
524
1102
  if (this.isAuthenticated()) {
525
- this.websocket?.send(serialize({
1103
+ this.sendMessage({
526
1104
  type: "TOPIC_PUB",
527
1105
  payload: { topic, data }
528
- }));
1106
+ });
529
1107
  } else {
530
1108
  logger.warn({ topic }, "Dropped topic publish (offline)");
531
1109
  }
532
1110
  }
533
1111
  sendTopicSubscription(topic) {
534
- this.websocket?.send(serialize({
1112
+ this.sendMessage({
535
1113
  type: "TOPIC_SUB",
536
1114
  payload: { topic }
537
- }));
1115
+ });
538
1116
  }
539
1117
  /**
540
1118
  * Executes a query against local storage immediately
@@ -571,21 +1149,21 @@ var SyncEngine = class {
571
1149
  unsubscribeFromQuery(queryId) {
572
1150
  this.queries.delete(queryId);
573
1151
  if (this.isAuthenticated()) {
574
- this.websocket?.send(serialize({
1152
+ this.sendMessage({
575
1153
  type: "QUERY_UNSUB",
576
1154
  payload: { queryId }
577
- }));
1155
+ });
578
1156
  }
579
1157
  }
580
1158
  sendQuerySubscription(query) {
581
- this.websocket?.send(serialize({
1159
+ this.sendMessage({
582
1160
  type: "QUERY_SUB",
583
1161
  payload: {
584
1162
  queryId: query.id,
585
1163
  mapName: query.getMapName(),
586
1164
  query: query.getFilter()
587
1165
  }
588
- }));
1166
+ });
589
1167
  }
590
1168
  requestLock(name, requestId, ttl) {
591
1169
  if (!this.isAuthenticated()) {
@@ -600,10 +1178,15 @@ var SyncEngine = class {
600
1178
  }, 3e4);
601
1179
  this.pendingLockRequests.set(requestId, { resolve, reject, timer });
602
1180
  try {
603
- this.websocket?.send(serialize({
1181
+ const sent = this.sendMessage({
604
1182
  type: "LOCK_REQUEST",
605
1183
  payload: { requestId, name, ttl }
606
- }));
1184
+ });
1185
+ if (!sent) {
1186
+ clearTimeout(timer);
1187
+ this.pendingLockRequests.delete(requestId);
1188
+ reject(new Error("Failed to send lock request"));
1189
+ }
607
1190
  } catch (e) {
608
1191
  clearTimeout(timer);
609
1192
  this.pendingLockRequests.delete(requestId);
@@ -622,10 +1205,15 @@ var SyncEngine = class {
622
1205
  }, 5e3);
623
1206
  this.pendingLockRequests.set(requestId, { resolve, reject, timer });
624
1207
  try {
625
- this.websocket?.send(serialize({
1208
+ const sent = this.sendMessage({
626
1209
  type: "LOCK_RELEASE",
627
1210
  payload: { requestId, name, fencingToken }
628
- }));
1211
+ });
1212
+ if (!sent) {
1213
+ clearTimeout(timer);
1214
+ this.pendingLockRequests.delete(requestId);
1215
+ resolve(false);
1216
+ }
629
1217
  } catch (e) {
630
1218
  clearTimeout(timer);
631
1219
  this.pendingLockRequests.delete(requestId);
@@ -634,6 +1222,7 @@ var SyncEngine = class {
634
1222
  });
635
1223
  }
636
1224
  async handleServerMessage(message) {
1225
+ this.emitMessage(message);
637
1226
  switch (message.type) {
638
1227
  case "BATCH": {
639
1228
  const batchData = message.data;
@@ -804,11 +1393,11 @@ var SyncEngine = class {
804
1393
  const { mapName } = message.payload;
805
1394
  logger.warn({ mapName }, "Sync Reset Required due to GC Age");
806
1395
  await this.resetMap(mapName);
807
- this.websocket?.send(serialize({
1396
+ this.sendMessage({
808
1397
  type: "SYNC_INIT",
809
1398
  mapName,
810
1399
  lastSyncTimestamp: 0
811
- }));
1400
+ });
812
1401
  break;
813
1402
  }
814
1403
  case "SYNC_RESP_ROOT": {
@@ -818,10 +1407,10 @@ var SyncEngine = class {
818
1407
  const localRootHash = map.getMerkleTree().getRootHash();
819
1408
  if (localRootHash !== rootHash) {
820
1409
  logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
821
- this.websocket?.send(serialize({
1410
+ this.sendMessage({
822
1411
  type: "MERKLE_REQ_BUCKET",
823
1412
  payload: { mapName, path: "" }
824
- }));
1413
+ });
825
1414
  } else {
826
1415
  logger.info({ mapName }, "Map is in sync");
827
1416
  }
@@ -843,10 +1432,10 @@ var SyncEngine = class {
843
1432
  const localHash = localBuckets[bucketKey] || 0;
844
1433
  if (localHash !== remoteHash) {
845
1434
  const newPath = path + bucketKey;
846
- this.websocket?.send(serialize({
1435
+ this.sendMessage({
847
1436
  type: "MERKLE_REQ_BUCKET",
848
1437
  payload: { mapName, path: newPath }
849
- }));
1438
+ });
850
1439
  }
851
1440
  }
852
1441
  }
@@ -879,10 +1468,10 @@ var SyncEngine = class {
879
1468
  const localRootHash = localTree.getRootHash();
880
1469
  if (localRootHash !== rootHash) {
881
1470
  logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
882
- this.websocket?.send(serialize({
1471
+ this.sendMessage({
883
1472
  type: "ORMAP_MERKLE_REQ_BUCKET",
884
1473
  payload: { mapName, path: "" }
885
- }));
1474
+ });
886
1475
  } else {
887
1476
  logger.info({ mapName }, "ORMap is in sync");
888
1477
  }
@@ -904,10 +1493,10 @@ var SyncEngine = class {
904
1493
  const localHash = localBuckets[bucketKey] || 0;
905
1494
  if (localHash !== remoteHash) {
906
1495
  const newPath = path + bucketKey;
907
- this.websocket?.send(serialize({
1496
+ this.sendMessage({
908
1497
  type: "ORMAP_MERKLE_REQ_BUCKET",
909
1498
  payload: { mapName, path: newPath }
910
- }));
1499
+ });
911
1500
  }
912
1501
  }
913
1502
  for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
@@ -960,6 +1549,51 @@ var SyncEngine = class {
960
1549
  }
961
1550
  break;
962
1551
  }
1552
+ // ============ PN Counter Message Handlers (Phase 5.2) ============
1553
+ case "COUNTER_UPDATE": {
1554
+ const { name, state } = message.payload;
1555
+ logger.debug({ name }, "Received COUNTER_UPDATE");
1556
+ this.handleCounterUpdate(name, state);
1557
+ break;
1558
+ }
1559
+ case "COUNTER_RESPONSE": {
1560
+ const { name, state } = message.payload;
1561
+ logger.debug({ name }, "Received COUNTER_RESPONSE");
1562
+ this.handleCounterUpdate(name, state);
1563
+ break;
1564
+ }
1565
+ // ============ Entry Processor Message Handlers (Phase 5.03) ============
1566
+ case "ENTRY_PROCESS_RESPONSE": {
1567
+ logger.debug({ requestId: message.requestId, success: message.success }, "Received ENTRY_PROCESS_RESPONSE");
1568
+ this.handleEntryProcessResponse(message);
1569
+ break;
1570
+ }
1571
+ case "ENTRY_PROCESS_BATCH_RESPONSE": {
1572
+ logger.debug({ requestId: message.requestId }, "Received ENTRY_PROCESS_BATCH_RESPONSE");
1573
+ this.handleEntryProcessBatchResponse(message);
1574
+ break;
1575
+ }
1576
+ // ============ Conflict Resolver Message Handlers (Phase 5.05) ============
1577
+ case "REGISTER_RESOLVER_RESPONSE": {
1578
+ logger.debug({ requestId: message.requestId, success: message.success }, "Received REGISTER_RESOLVER_RESPONSE");
1579
+ this.conflictResolverClient.handleRegisterResponse(message);
1580
+ break;
1581
+ }
1582
+ case "UNREGISTER_RESOLVER_RESPONSE": {
1583
+ logger.debug({ requestId: message.requestId, success: message.success }, "Received UNREGISTER_RESOLVER_RESPONSE");
1584
+ this.conflictResolverClient.handleUnregisterResponse(message);
1585
+ break;
1586
+ }
1587
+ case "LIST_RESOLVERS_RESPONSE": {
1588
+ logger.debug({ requestId: message.requestId }, "Received LIST_RESOLVERS_RESPONSE");
1589
+ this.conflictResolverClient.handleListResponse(message);
1590
+ break;
1591
+ }
1592
+ case "MERGE_REJECTED": {
1593
+ logger.debug({ mapName: message.mapName, key: message.key, reason: message.reason }, "Received MERGE_REJECTED");
1594
+ this.conflictResolverClient.handleMergeRejected(message);
1595
+ break;
1596
+ }
963
1597
  }
964
1598
  if (message.timestamp) {
965
1599
  this.hlc.update(message.timestamp);
@@ -998,7 +1632,11 @@ var SyncEngine = class {
998
1632
  clearTimeout(this.reconnectTimer);
999
1633
  this.reconnectTimer = null;
1000
1634
  }
1001
- if (this.websocket) {
1635
+ if (this.useConnectionProvider) {
1636
+ this.connectionProvider.close().catch((err) => {
1637
+ logger.error({ err }, "Error closing ConnectionProvider");
1638
+ });
1639
+ } else if (this.websocket) {
1002
1640
  this.websocket.onclose = null;
1003
1641
  this.websocket.close();
1004
1642
  this.websocket = null;
@@ -1015,7 +1653,100 @@ var SyncEngine = class {
1015
1653
  this.close();
1016
1654
  this.stateMachine.reset();
1017
1655
  this.resetBackoff();
1018
- this.initConnection();
1656
+ if (this.useConnectionProvider) {
1657
+ this.initConnectionProvider();
1658
+ } else {
1659
+ this.initConnection();
1660
+ }
1661
+ }
1662
+ // ============================================
1663
+ // Failover Support Methods (Phase 4.5 Task 05)
1664
+ // ============================================
1665
+ /**
1666
+ * Wait for a partition map update from the connection provider.
1667
+ * Used when an operation fails with NOT_OWNER error and needs
1668
+ * to wait for an updated partition map before retrying.
1669
+ *
1670
+ * @param timeoutMs - Maximum time to wait (default: 5000ms)
1671
+ * @returns Promise that resolves when partition map is updated or times out
1672
+ */
1673
+ waitForPartitionMapUpdate(timeoutMs = 5e3) {
1674
+ return new Promise((resolve) => {
1675
+ const timeout = setTimeout(resolve, timeoutMs);
1676
+ const handler2 = () => {
1677
+ clearTimeout(timeout);
1678
+ this.connectionProvider.off("partitionMapUpdated", handler2);
1679
+ resolve();
1680
+ };
1681
+ this.connectionProvider.on("partitionMapUpdated", handler2);
1682
+ });
1683
+ }
1684
+ /**
1685
+ * Wait for the connection to be available.
1686
+ * Used when an operation fails due to connection issues and needs
1687
+ * to wait for reconnection before retrying.
1688
+ *
1689
+ * @param timeoutMs - Maximum time to wait (default: 10000ms)
1690
+ * @returns Promise that resolves when connected or rejects on timeout
1691
+ */
1692
+ waitForConnection(timeoutMs = 1e4) {
1693
+ return new Promise((resolve, reject) => {
1694
+ if (this.connectionProvider.isConnected()) {
1695
+ resolve();
1696
+ return;
1697
+ }
1698
+ const timeout = setTimeout(() => {
1699
+ this.connectionProvider.off("connected", handler2);
1700
+ reject(new Error("Connection timeout waiting for reconnection"));
1701
+ }, timeoutMs);
1702
+ const handler2 = () => {
1703
+ clearTimeout(timeout);
1704
+ this.connectionProvider.off("connected", handler2);
1705
+ resolve();
1706
+ };
1707
+ this.connectionProvider.on("connected", handler2);
1708
+ });
1709
+ }
1710
+ /**
1711
+ * Wait for a specific sync state.
1712
+ * Useful for waiting until fully connected and synced.
1713
+ *
1714
+ * @param targetState - The state to wait for
1715
+ * @param timeoutMs - Maximum time to wait (default: 30000ms)
1716
+ * @returns Promise that resolves when state is reached or rejects on timeout
1717
+ */
1718
+ waitForState(targetState, timeoutMs = 3e4) {
1719
+ return new Promise((resolve, reject) => {
1720
+ if (this.stateMachine.getState() === targetState) {
1721
+ resolve();
1722
+ return;
1723
+ }
1724
+ const timeout = setTimeout(() => {
1725
+ unsubscribe();
1726
+ reject(new Error(`Timeout waiting for state ${targetState}`));
1727
+ }, timeoutMs);
1728
+ const unsubscribe = this.stateMachine.onStateChange((event) => {
1729
+ if (event.to === targetState) {
1730
+ clearTimeout(timeout);
1731
+ unsubscribe();
1732
+ resolve();
1733
+ }
1734
+ });
1735
+ });
1736
+ }
1737
+ /**
1738
+ * Check if the connection provider is connected.
1739
+ * Convenience method for failover logic.
1740
+ */
1741
+ isProviderConnected() {
1742
+ return this.connectionProvider.isConnected();
1743
+ }
1744
+ /**
1745
+ * Get the connection provider for direct access.
1746
+ * Use with caution - prefer using SyncEngine methods.
1747
+ */
1748
+ getConnectionProvider() {
1749
+ return this.connectionProvider;
1019
1750
  }
1020
1751
  async resetMap(mapName) {
1021
1752
  const map = this.maps.get(mapName);
@@ -1063,12 +1794,12 @@ var SyncEngine = class {
1063
1794
  * Sends a PING message to the server.
1064
1795
  */
1065
1796
  sendPing() {
1066
- if (this.websocket?.readyState === WebSocket.OPEN) {
1797
+ if (this.canSend()) {
1067
1798
  const pingMessage = {
1068
1799
  type: "PING",
1069
1800
  timestamp: Date.now()
1070
1801
  };
1071
- this.websocket.send(serialize(pingMessage));
1802
+ this.sendMessage(pingMessage);
1072
1803
  }
1073
1804
  }
1074
1805
  /**
@@ -1147,13 +1878,13 @@ var SyncEngine = class {
1147
1878
  }
1148
1879
  }
1149
1880
  if (entries.length > 0) {
1150
- this.websocket?.send(serialize({
1881
+ this.sendMessage({
1151
1882
  type: "ORMAP_PUSH_DIFF",
1152
1883
  payload: {
1153
1884
  mapName,
1154
1885
  entries
1155
1886
  }
1156
- }));
1887
+ });
1157
1888
  logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
1158
1889
  }
1159
1890
  }
@@ -1376,16 +2107,371 @@ var SyncEngine = class {
1376
2107
  }
1377
2108
  this.pendingWriteConcernPromises.clear();
1378
2109
  }
2110
+ /**
2111
+ * Subscribe to counter updates from server.
2112
+ * @param name Counter name
2113
+ * @param listener Callback when counter state is updated
2114
+ * @returns Unsubscribe function
2115
+ */
2116
+ onCounterUpdate(name, listener) {
2117
+ if (!this.counterUpdateListeners.has(name)) {
2118
+ this.counterUpdateListeners.set(name, /* @__PURE__ */ new Set());
2119
+ }
2120
+ this.counterUpdateListeners.get(name).add(listener);
2121
+ return () => {
2122
+ this.counterUpdateListeners.get(name)?.delete(listener);
2123
+ if (this.counterUpdateListeners.get(name)?.size === 0) {
2124
+ this.counterUpdateListeners.delete(name);
2125
+ }
2126
+ };
2127
+ }
2128
+ /**
2129
+ * Request initial counter state from server.
2130
+ * @param name Counter name
2131
+ */
2132
+ requestCounter(name) {
2133
+ if (this.isAuthenticated()) {
2134
+ this.sendMessage({
2135
+ type: "COUNTER_REQUEST",
2136
+ payload: { name }
2137
+ });
2138
+ }
2139
+ }
2140
+ /**
2141
+ * Sync local counter state to server.
2142
+ * @param name Counter name
2143
+ * @param state Counter state to sync
2144
+ */
2145
+ syncCounter(name, state) {
2146
+ if (this.isAuthenticated()) {
2147
+ const stateObj = {
2148
+ positive: Object.fromEntries(state.positive),
2149
+ negative: Object.fromEntries(state.negative)
2150
+ };
2151
+ this.sendMessage({
2152
+ type: "COUNTER_SYNC",
2153
+ payload: {
2154
+ name,
2155
+ state: stateObj
2156
+ }
2157
+ });
2158
+ }
2159
+ }
2160
+ /**
2161
+ * Handle incoming counter update from server.
2162
+ * Called by handleServerMessage for COUNTER_UPDATE messages.
2163
+ */
2164
+ handleCounterUpdate(name, stateObj) {
2165
+ const state = {
2166
+ positive: new Map(Object.entries(stateObj.positive)),
2167
+ negative: new Map(Object.entries(stateObj.negative))
2168
+ };
2169
+ const listeners = this.counterUpdateListeners.get(name);
2170
+ if (listeners) {
2171
+ for (const listener of listeners) {
2172
+ try {
2173
+ listener(state);
2174
+ } catch (e) {
2175
+ logger.error({ err: e, counterName: name }, "Counter update listener error");
2176
+ }
2177
+ }
2178
+ }
2179
+ }
2180
+ /**
2181
+ * Execute an entry processor on a single key atomically.
2182
+ *
2183
+ * @param mapName Name of the map
2184
+ * @param key Key to process
2185
+ * @param processor Processor definition
2186
+ * @returns Promise resolving to the processor result
2187
+ */
2188
+ async executeOnKey(mapName, key, processor) {
2189
+ if (!this.isAuthenticated()) {
2190
+ return {
2191
+ success: false,
2192
+ error: "Not connected to server"
2193
+ };
2194
+ }
2195
+ const requestId = crypto.randomUUID();
2196
+ return new Promise((resolve, reject) => {
2197
+ const timeout = setTimeout(() => {
2198
+ this.pendingProcessorRequests.delete(requestId);
2199
+ reject(new Error("Entry processor request timed out"));
2200
+ }, _SyncEngine.PROCESSOR_TIMEOUT);
2201
+ this.pendingProcessorRequests.set(requestId, {
2202
+ resolve: (result) => {
2203
+ clearTimeout(timeout);
2204
+ resolve(result);
2205
+ },
2206
+ reject,
2207
+ timeout
2208
+ });
2209
+ const sent = this.sendMessage({
2210
+ type: "ENTRY_PROCESS",
2211
+ requestId,
2212
+ mapName,
2213
+ key,
2214
+ processor: {
2215
+ name: processor.name,
2216
+ code: processor.code,
2217
+ args: processor.args
2218
+ }
2219
+ }, key);
2220
+ if (!sent) {
2221
+ this.pendingProcessorRequests.delete(requestId);
2222
+ clearTimeout(timeout);
2223
+ reject(new Error("Failed to send entry processor request"));
2224
+ }
2225
+ });
2226
+ }
2227
+ /**
2228
+ * Execute an entry processor on multiple keys.
2229
+ *
2230
+ * @param mapName Name of the map
2231
+ * @param keys Keys to process
2232
+ * @param processor Processor definition
2233
+ * @returns Promise resolving to a map of key -> result
2234
+ */
2235
+ async executeOnKeys(mapName, keys, processor) {
2236
+ if (!this.isAuthenticated()) {
2237
+ const results = /* @__PURE__ */ new Map();
2238
+ const error = {
2239
+ success: false,
2240
+ error: "Not connected to server"
2241
+ };
2242
+ for (const key of keys) {
2243
+ results.set(key, error);
2244
+ }
2245
+ return results;
2246
+ }
2247
+ const requestId = crypto.randomUUID();
2248
+ return new Promise((resolve, reject) => {
2249
+ const timeout = setTimeout(() => {
2250
+ this.pendingBatchProcessorRequests.delete(requestId);
2251
+ reject(new Error("Entry processor batch request timed out"));
2252
+ }, _SyncEngine.PROCESSOR_TIMEOUT);
2253
+ this.pendingBatchProcessorRequests.set(requestId, {
2254
+ resolve: (results) => {
2255
+ clearTimeout(timeout);
2256
+ resolve(results);
2257
+ },
2258
+ reject,
2259
+ timeout
2260
+ });
2261
+ const sent = this.sendMessage({
2262
+ type: "ENTRY_PROCESS_BATCH",
2263
+ requestId,
2264
+ mapName,
2265
+ keys,
2266
+ processor: {
2267
+ name: processor.name,
2268
+ code: processor.code,
2269
+ args: processor.args
2270
+ }
2271
+ });
2272
+ if (!sent) {
2273
+ this.pendingBatchProcessorRequests.delete(requestId);
2274
+ clearTimeout(timeout);
2275
+ reject(new Error("Failed to send entry processor batch request"));
2276
+ }
2277
+ });
2278
+ }
2279
+ /**
2280
+ * Handle entry processor response from server.
2281
+ * Called by handleServerMessage for ENTRY_PROCESS_RESPONSE messages.
2282
+ */
2283
+ handleEntryProcessResponse(message) {
2284
+ const pending = this.pendingProcessorRequests.get(message.requestId);
2285
+ if (pending) {
2286
+ this.pendingProcessorRequests.delete(message.requestId);
2287
+ pending.resolve({
2288
+ success: message.success,
2289
+ result: message.result,
2290
+ newValue: message.newValue,
2291
+ error: message.error
2292
+ });
2293
+ }
2294
+ }
2295
+ /**
2296
+ * Handle entry processor batch response from server.
2297
+ * Called by handleServerMessage for ENTRY_PROCESS_BATCH_RESPONSE messages.
2298
+ */
2299
+ handleEntryProcessBatchResponse(message) {
2300
+ const pending = this.pendingBatchProcessorRequests.get(message.requestId);
2301
+ if (pending) {
2302
+ this.pendingBatchProcessorRequests.delete(message.requestId);
2303
+ const resultsMap = /* @__PURE__ */ new Map();
2304
+ for (const [key, result] of Object.entries(message.results)) {
2305
+ resultsMap.set(key, {
2306
+ success: result.success,
2307
+ result: result.result,
2308
+ newValue: result.newValue,
2309
+ error: result.error
2310
+ });
2311
+ }
2312
+ pending.resolve(resultsMap);
2313
+ }
2314
+ }
2315
+ /**
2316
+ * Subscribe to all incoming messages.
2317
+ * Used by EventJournalReader to receive journal events.
2318
+ *
2319
+ * @param event Event type (currently only 'message')
2320
+ * @param handler Message handler
2321
+ */
2322
+ on(event, handler2) {
2323
+ if (event === "message") {
2324
+ this.messageListeners.add(handler2);
2325
+ }
2326
+ }
2327
+ /**
2328
+ * Unsubscribe from incoming messages.
2329
+ *
2330
+ * @param event Event type (currently only 'message')
2331
+ * @param handler Message handler to remove
2332
+ */
2333
+ off(event, handler2) {
2334
+ if (event === "message") {
2335
+ this.messageListeners.delete(handler2);
2336
+ }
2337
+ }
2338
+ /**
2339
+ * Send a message to the server.
2340
+ * Public method for EventJournalReader and other components.
2341
+ *
2342
+ * @param message Message object to send
2343
+ */
2344
+ send(message) {
2345
+ this.sendMessage(message);
2346
+ }
2347
+ /**
2348
+ * Emit message to all listeners.
2349
+ * Called internally when a message is received.
2350
+ */
2351
+ emitMessage(message) {
2352
+ for (const listener of this.messageListeners) {
2353
+ try {
2354
+ listener(message);
2355
+ } catch (e) {
2356
+ logger.error({ err: e }, "Message listener error");
2357
+ }
2358
+ }
2359
+ }
2360
+ // ============================================
2361
+ // Conflict Resolver Client (Phase 5.05)
2362
+ // ============================================
2363
+ /**
2364
+ * Get the conflict resolver client for registering custom resolvers
2365
+ * and subscribing to merge rejection events.
2366
+ */
2367
+ getConflictResolverClient() {
2368
+ return this.conflictResolverClient;
2369
+ }
1379
2370
  };
2371
+ /** Default timeout for entry processor requests (ms) */
2372
+ _SyncEngine.PROCESSOR_TIMEOUT = 3e4;
2373
+ var SyncEngine = _SyncEngine;
1380
2374
 
1381
2375
  // src/TopGunClient.ts
1382
2376
  import { LWWMap as LWWMap2, ORMap as ORMap2 } from "@topgunbuild/core";
1383
2377
 
2378
+ // src/utils/deepEqual.ts
2379
+ function deepEqual(a, b) {
2380
+ if (a === b) return true;
2381
+ if (a == null || b == null) return a === b;
2382
+ if (typeof a !== typeof b) return false;
2383
+ if (typeof a !== "object") return a === b;
2384
+ if (Array.isArray(a)) {
2385
+ if (!Array.isArray(b)) return false;
2386
+ if (a.length !== b.length) return false;
2387
+ for (let i = 0; i < a.length; i++) {
2388
+ if (!deepEqual(a[i], b[i])) return false;
2389
+ }
2390
+ return true;
2391
+ }
2392
+ if (Array.isArray(b)) return false;
2393
+ const objA = a;
2394
+ const objB = b;
2395
+ const keysA = Object.keys(objA);
2396
+ const keysB = Object.keys(objB);
2397
+ if (keysA.length !== keysB.length) return false;
2398
+ for (const key of keysA) {
2399
+ if (!Object.prototype.hasOwnProperty.call(objB, key)) return false;
2400
+ if (!deepEqual(objA[key], objB[key])) return false;
2401
+ }
2402
+ return true;
2403
+ }
2404
+
2405
+ // src/ChangeTracker.ts
2406
+ var ChangeTracker = class {
2407
+ constructor() {
2408
+ this.previousSnapshot = /* @__PURE__ */ new Map();
2409
+ }
2410
+ /**
2411
+ * Computes changes between previous and current state.
2412
+ * Updates internal snapshot after computation.
2413
+ *
2414
+ * @param current - Current state as a Map
2415
+ * @param timestamp - HLC timestamp for the changes
2416
+ * @returns Array of change events (may be empty if no changes)
2417
+ */
2418
+ computeChanges(current, timestamp) {
2419
+ const changes = [];
2420
+ for (const [key, value] of current) {
2421
+ const previous = this.previousSnapshot.get(key);
2422
+ if (previous === void 0) {
2423
+ changes.push({ type: "add", key, value, timestamp });
2424
+ } else if (!deepEqual(previous, value)) {
2425
+ changes.push({
2426
+ type: "update",
2427
+ key,
2428
+ value,
2429
+ previousValue: previous,
2430
+ timestamp
2431
+ });
2432
+ }
2433
+ }
2434
+ for (const [key, value] of this.previousSnapshot) {
2435
+ if (!current.has(key)) {
2436
+ changes.push({
2437
+ type: "remove",
2438
+ key,
2439
+ previousValue: value,
2440
+ timestamp
2441
+ });
2442
+ }
2443
+ }
2444
+ this.previousSnapshot = new Map(
2445
+ Array.from(current.entries()).map(([k, v]) => [
2446
+ k,
2447
+ typeof v === "object" && v !== null ? { ...v } : v
2448
+ ])
2449
+ );
2450
+ return changes;
2451
+ }
2452
+ /**
2453
+ * Reset tracker (e.g., on query change or reconnect)
2454
+ */
2455
+ reset() {
2456
+ this.previousSnapshot.clear();
2457
+ }
2458
+ /**
2459
+ * Get current snapshot size for debugging/metrics
2460
+ */
2461
+ get size() {
2462
+ return this.previousSnapshot.size;
2463
+ }
2464
+ };
2465
+
1384
2466
  // src/QueryHandle.ts
1385
2467
  var QueryHandle = class {
1386
2468
  constructor(syncEngine, mapName, filter = {}) {
1387
2469
  this.listeners = /* @__PURE__ */ new Set();
1388
2470
  this.currentResults = /* @__PURE__ */ new Map();
2471
+ // Change tracking (Phase 5.1)
2472
+ this.changeTracker = new ChangeTracker();
2473
+ this.pendingChanges = [];
2474
+ this.changeListeners = /* @__PURE__ */ new Set();
1389
2475
  // Track if we've received authoritative server response
1390
2476
  this.hasReceivedServerData = false;
1391
2477
  this.id = crypto.randomUUID();
@@ -1428,14 +2514,15 @@ var QueryHandle = class {
1428
2514
  * - Works with any async storage adapter (PostgreSQL, SQLite, Redis, etc.)
1429
2515
  */
1430
2516
  onResult(items, source = "server") {
1431
- console.log(`[QueryHandle:${this.mapName}] onResult called with ${items.length} items`, {
2517
+ logger.debug({
2518
+ mapName: this.mapName,
2519
+ itemCount: items.length,
1432
2520
  source,
1433
2521
  currentResultsCount: this.currentResults.size,
1434
- newItemKeys: items.map((i) => i.key),
1435
2522
  hasReceivedServerData: this.hasReceivedServerData
1436
- });
2523
+ }, "QueryHandle onResult");
1437
2524
  if (source === "server" && items.length === 0 && !this.hasReceivedServerData) {
1438
- console.log(`[QueryHandle:${this.mapName}] Ignoring empty server response - waiting for authoritative data`);
2525
+ logger.debug({ mapName: this.mapName }, "QueryHandle ignoring empty server response - waiting for authoritative data");
1439
2526
  return;
1440
2527
  }
1441
2528
  if (source === "server" && items.length > 0) {
@@ -1450,12 +2537,20 @@ var QueryHandle = class {
1450
2537
  }
1451
2538
  }
1452
2539
  if (removedKeys.length > 0) {
1453
- console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
2540
+ logger.debug({
2541
+ mapName: this.mapName,
2542
+ removedCount: removedKeys.length,
2543
+ removedKeys
2544
+ }, "QueryHandle removed keys");
1454
2545
  }
1455
2546
  for (const item of items) {
1456
2547
  this.currentResults.set(item.key, item.value);
1457
2548
  }
1458
- console.log(`[QueryHandle:${this.mapName}] After merge: ${this.currentResults.size} results`);
2549
+ logger.debug({
2550
+ mapName: this.mapName,
2551
+ resultCount: this.currentResults.size
2552
+ }, "QueryHandle after merge");
2553
+ this.computeAndNotifyChanges(Date.now());
1459
2554
  this.notify();
1460
2555
  }
1461
2556
  /**
@@ -1467,8 +2562,80 @@ var QueryHandle = class {
1467
2562
  } else {
1468
2563
  this.currentResults.set(key, value);
1469
2564
  }
2565
+ this.computeAndNotifyChanges(Date.now());
1470
2566
  this.notify();
1471
2567
  }
2568
+ /**
2569
+ * Subscribe to change events (Phase 5.1).
2570
+ * Returns an unsubscribe function.
2571
+ *
2572
+ * @example
2573
+ * ```typescript
2574
+ * const unsubscribe = handle.onChanges((changes) => {
2575
+ * for (const change of changes) {
2576
+ * if (change.type === 'add') {
2577
+ * console.log('Added:', change.key, change.value);
2578
+ * }
2579
+ * }
2580
+ * });
2581
+ * ```
2582
+ */
2583
+ onChanges(listener) {
2584
+ this.changeListeners.add(listener);
2585
+ return () => this.changeListeners.delete(listener);
2586
+ }
2587
+ /**
2588
+ * Get and clear pending changes (Phase 5.1).
2589
+ * Call this to retrieve all changes since the last consume.
2590
+ */
2591
+ consumeChanges() {
2592
+ const changes = [...this.pendingChanges];
2593
+ this.pendingChanges = [];
2594
+ return changes;
2595
+ }
2596
+ /**
2597
+ * Get last change without consuming (Phase 5.1).
2598
+ * Returns null if no pending changes.
2599
+ */
2600
+ getLastChange() {
2601
+ return this.pendingChanges.length > 0 ? this.pendingChanges[this.pendingChanges.length - 1] : null;
2602
+ }
2603
+ /**
2604
+ * Get all pending changes without consuming (Phase 5.1).
2605
+ */
2606
+ getPendingChanges() {
2607
+ return [...this.pendingChanges];
2608
+ }
2609
+ /**
2610
+ * Clear all pending changes (Phase 5.1).
2611
+ */
2612
+ clearChanges() {
2613
+ this.pendingChanges = [];
2614
+ }
2615
+ /**
2616
+ * Reset change tracker (Phase 5.1).
2617
+ * Use when query filter changes or on reconnect.
2618
+ */
2619
+ resetChangeTracker() {
2620
+ this.changeTracker.reset();
2621
+ this.pendingChanges = [];
2622
+ }
2623
+ computeAndNotifyChanges(timestamp) {
2624
+ const changes = this.changeTracker.computeChanges(this.currentResults, timestamp);
2625
+ if (changes.length > 0) {
2626
+ this.pendingChanges.push(...changes);
2627
+ this.notifyChangeListeners(changes);
2628
+ }
2629
+ }
2630
+ notifyChangeListeners(changes) {
2631
+ for (const listener of this.changeListeners) {
2632
+ try {
2633
+ listener(changes);
2634
+ } catch (e) {
2635
+ logger.error({ err: e }, "QueryHandle change listener error");
2636
+ }
2637
+ }
2638
+ }
1472
2639
  notify() {
1473
2640
  const results = this.getSortedResults();
1474
2641
  for (const listener of this.listeners) {
@@ -1487,114 +2654,1877 @@ var QueryHandle = class {
1487
2654
  if (valA < valB) return direction === "asc" ? -1 : 1;
1488
2655
  if (valA > valB) return direction === "asc" ? 1 : -1;
1489
2656
  }
1490
- return 0;
2657
+ return 0;
2658
+ });
2659
+ }
2660
+ return results;
2661
+ }
2662
+ getFilter() {
2663
+ return this.filter;
2664
+ }
2665
+ getMapName() {
2666
+ return this.mapName;
2667
+ }
2668
+ };
2669
+
2670
+ // src/DistributedLock.ts
2671
+ var DistributedLock = class {
2672
+ constructor(syncEngine, name) {
2673
+ this.fencingToken = null;
2674
+ this._isLocked = false;
2675
+ this.syncEngine = syncEngine;
2676
+ this.name = name;
2677
+ }
2678
+ async lock(ttl = 1e4) {
2679
+ const requestId = crypto.randomUUID();
2680
+ try {
2681
+ const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
2682
+ this.fencingToken = result.fencingToken;
2683
+ this._isLocked = true;
2684
+ return true;
2685
+ } catch (e) {
2686
+ return false;
2687
+ }
2688
+ }
2689
+ async unlock() {
2690
+ if (!this._isLocked || this.fencingToken === null) return;
2691
+ const requestId = crypto.randomUUID();
2692
+ try {
2693
+ await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
2694
+ } finally {
2695
+ this._isLocked = false;
2696
+ this.fencingToken = null;
2697
+ }
2698
+ }
2699
+ isLocked() {
2700
+ return this._isLocked;
2701
+ }
2702
+ };
2703
+
2704
+ // src/TopicHandle.ts
2705
+ var TopicHandle = class {
2706
+ constructor(engine, topic) {
2707
+ this.listeners = /* @__PURE__ */ new Set();
2708
+ this.engine = engine;
2709
+ this.topic = topic;
2710
+ }
2711
+ get id() {
2712
+ return this.topic;
2713
+ }
2714
+ /**
2715
+ * Publish a message to the topic
2716
+ */
2717
+ publish(data) {
2718
+ this.engine.publishTopic(this.topic, data);
2719
+ }
2720
+ /**
2721
+ * Subscribe to the topic
2722
+ */
2723
+ subscribe(callback) {
2724
+ if (this.listeners.size === 0) {
2725
+ this.engine.subscribeToTopic(this.topic, this);
2726
+ }
2727
+ this.listeners.add(callback);
2728
+ return () => this.unsubscribe(callback);
2729
+ }
2730
+ unsubscribe(callback) {
2731
+ this.listeners.delete(callback);
2732
+ if (this.listeners.size === 0) {
2733
+ this.engine.unsubscribeFromTopic(this.topic);
2734
+ }
2735
+ }
2736
+ /**
2737
+ * Called by SyncEngine when a message is received
2738
+ */
2739
+ onMessage(data, context) {
2740
+ this.listeners.forEach((cb) => {
2741
+ try {
2742
+ cb(data, context);
2743
+ } catch (e) {
2744
+ console.error("Error in topic listener", e);
2745
+ }
2746
+ });
2747
+ }
2748
+ };
2749
+
2750
+ // src/PNCounterHandle.ts
2751
+ import { PNCounterImpl } from "@topgunbuild/core";
2752
+ var COUNTER_STORAGE_PREFIX = "__counter__:";
2753
+ var PNCounterHandle = class {
2754
+ constructor(name, nodeId, syncEngine, storageAdapter) {
2755
+ this.syncScheduled = false;
2756
+ this.persistScheduled = false;
2757
+ this.name = name;
2758
+ this.syncEngine = syncEngine;
2759
+ this.storageAdapter = storageAdapter;
2760
+ this.counter = new PNCounterImpl({ nodeId });
2761
+ this.restoreFromStorage();
2762
+ this.unsubscribeFromUpdates = this.syncEngine.onCounterUpdate(name, (state) => {
2763
+ this.counter.merge(state);
2764
+ this.schedulePersist();
2765
+ });
2766
+ this.syncEngine.requestCounter(name);
2767
+ logger.debug({ name, nodeId }, "PNCounterHandle created");
2768
+ }
2769
+ /**
2770
+ * Restore counter state from local storage.
2771
+ * Called during construction to recover offline state.
2772
+ */
2773
+ async restoreFromStorage() {
2774
+ if (!this.storageAdapter) {
2775
+ return;
2776
+ }
2777
+ try {
2778
+ const storageKey = COUNTER_STORAGE_PREFIX + this.name;
2779
+ const stored = await this.storageAdapter.getMeta(storageKey);
2780
+ if (stored && typeof stored === "object" && "p" in stored && "n" in stored) {
2781
+ const state = PNCounterImpl.objectToState(stored);
2782
+ this.counter.merge(state);
2783
+ logger.debug({ name: this.name, value: this.counter.get() }, "PNCounter restored from storage");
2784
+ }
2785
+ } catch (err) {
2786
+ logger.error({ err, name: this.name }, "Failed to restore PNCounter from storage");
2787
+ }
2788
+ }
2789
+ /**
2790
+ * Persist counter state to local storage.
2791
+ * Debounced to avoid excessive writes during rapid operations.
2792
+ */
2793
+ schedulePersist() {
2794
+ if (!this.storageAdapter || this.persistScheduled) return;
2795
+ this.persistScheduled = true;
2796
+ setTimeout(() => {
2797
+ this.persistScheduled = false;
2798
+ this.persistToStorage();
2799
+ }, 100);
2800
+ }
2801
+ /**
2802
+ * Actually persist state to storage.
2803
+ */
2804
+ async persistToStorage() {
2805
+ if (!this.storageAdapter) return;
2806
+ try {
2807
+ const storageKey = COUNTER_STORAGE_PREFIX + this.name;
2808
+ const stateObj = PNCounterImpl.stateToObject(this.counter.getState());
2809
+ await this.storageAdapter.setMeta(storageKey, stateObj);
2810
+ logger.debug({ name: this.name, value: this.counter.get() }, "PNCounter persisted to storage");
2811
+ } catch (err) {
2812
+ logger.error({ err, name: this.name }, "Failed to persist PNCounter to storage");
2813
+ }
2814
+ }
2815
+ /**
2816
+ * Get current counter value.
2817
+ */
2818
+ get() {
2819
+ return this.counter.get();
2820
+ }
2821
+ /**
2822
+ * Increment by 1 and return new value.
2823
+ */
2824
+ increment() {
2825
+ const value = this.counter.increment();
2826
+ this.scheduleSync();
2827
+ this.schedulePersist();
2828
+ return value;
2829
+ }
2830
+ /**
2831
+ * Decrement by 1 and return new value.
2832
+ */
2833
+ decrement() {
2834
+ const value = this.counter.decrement();
2835
+ this.scheduleSync();
2836
+ this.schedulePersist();
2837
+ return value;
2838
+ }
2839
+ /**
2840
+ * Add delta (positive or negative) and return new value.
2841
+ */
2842
+ addAndGet(delta) {
2843
+ const value = this.counter.addAndGet(delta);
2844
+ if (delta !== 0) {
2845
+ this.scheduleSync();
2846
+ this.schedulePersist();
2847
+ }
2848
+ return value;
2849
+ }
2850
+ /**
2851
+ * Get state for sync.
2852
+ */
2853
+ getState() {
2854
+ return this.counter.getState();
2855
+ }
2856
+ /**
2857
+ * Merge remote state.
2858
+ */
2859
+ merge(remote) {
2860
+ this.counter.merge(remote);
2861
+ }
2862
+ /**
2863
+ * Subscribe to value changes.
2864
+ */
2865
+ subscribe(listener) {
2866
+ return this.counter.subscribe(listener);
2867
+ }
2868
+ /**
2869
+ * Get the counter name.
2870
+ */
2871
+ getName() {
2872
+ return this.name;
2873
+ }
2874
+ /**
2875
+ * Cleanup resources.
2876
+ */
2877
+ dispose() {
2878
+ if (this.unsubscribeFromUpdates) {
2879
+ this.unsubscribeFromUpdates();
2880
+ }
2881
+ }
2882
+ /**
2883
+ * Schedule sync to server with debouncing.
2884
+ * Batches rapid increments to avoid network spam.
2885
+ */
2886
+ scheduleSync() {
2887
+ if (this.syncScheduled) return;
2888
+ this.syncScheduled = true;
2889
+ setTimeout(() => {
2890
+ this.syncScheduled = false;
2891
+ this.syncEngine.syncCounter(this.name, this.counter.getState());
2892
+ }, 50);
2893
+ }
2894
+ };
2895
+
2896
+ // src/EventJournalReader.ts
2897
+ var EventJournalReader = class {
2898
+ constructor(syncEngine) {
2899
+ this.listeners = /* @__PURE__ */ new Map();
2900
+ this.subscriptionCounter = 0;
2901
+ this.syncEngine = syncEngine;
2902
+ }
2903
+ /**
2904
+ * Read events from sequence with optional limit.
2905
+ *
2906
+ * @param sequence Starting sequence (inclusive)
2907
+ * @param limit Maximum events to return (default: 100)
2908
+ * @returns Promise resolving to array of events
2909
+ */
2910
+ async readFrom(sequence, limit = 100) {
2911
+ const requestId = this.generateRequestId();
2912
+ return new Promise((resolve, reject) => {
2913
+ const timeout = setTimeout(() => {
2914
+ reject(new Error("Journal read timeout"));
2915
+ }, 1e4);
2916
+ const handleResponse = (message) => {
2917
+ if (message.type === "JOURNAL_READ_RESPONSE" && message.requestId === requestId) {
2918
+ clearTimeout(timeout);
2919
+ this.syncEngine.off("message", handleResponse);
2920
+ const events = message.events.map((e) => this.parseEvent(e));
2921
+ resolve(events);
2922
+ }
2923
+ };
2924
+ this.syncEngine.on("message", handleResponse);
2925
+ this.syncEngine.send({
2926
+ type: "JOURNAL_READ",
2927
+ requestId,
2928
+ fromSequence: sequence.toString(),
2929
+ limit
2930
+ });
2931
+ });
2932
+ }
2933
+ /**
2934
+ * Read events for a specific map.
2935
+ *
2936
+ * @param mapName Map name to filter
2937
+ * @param sequence Starting sequence (default: 0n)
2938
+ * @param limit Maximum events to return (default: 100)
2939
+ */
2940
+ async readMapEvents(mapName, sequence = 0n, limit = 100) {
2941
+ const requestId = this.generateRequestId();
2942
+ return new Promise((resolve, reject) => {
2943
+ const timeout = setTimeout(() => {
2944
+ reject(new Error("Journal read timeout"));
2945
+ }, 1e4);
2946
+ const handleResponse = (message) => {
2947
+ if (message.type === "JOURNAL_READ_RESPONSE" && message.requestId === requestId) {
2948
+ clearTimeout(timeout);
2949
+ this.syncEngine.off("message", handleResponse);
2950
+ const events = message.events.map((e) => this.parseEvent(e));
2951
+ resolve(events);
2952
+ }
2953
+ };
2954
+ this.syncEngine.on("message", handleResponse);
2955
+ this.syncEngine.send({
2956
+ type: "JOURNAL_READ",
2957
+ requestId,
2958
+ fromSequence: sequence.toString(),
2959
+ limit,
2960
+ mapName
2961
+ });
2962
+ });
2963
+ }
2964
+ /**
2965
+ * Subscribe to new journal events.
2966
+ *
2967
+ * @param listener Callback for each event
2968
+ * @param options Subscription options
2969
+ * @returns Unsubscribe function
2970
+ */
2971
+ subscribe(listener, options = {}) {
2972
+ const subscriptionId = this.generateRequestId();
2973
+ this.listeners.set(subscriptionId, listener);
2974
+ const handleEvent = (message) => {
2975
+ if (message.type === "JOURNAL_EVENT") {
2976
+ const event = this.parseEvent(message.event);
2977
+ if (options.mapName && event.mapName !== options.mapName) return;
2978
+ if (options.types && !options.types.includes(event.type)) return;
2979
+ const listenerFn = this.listeners.get(subscriptionId);
2980
+ if (listenerFn) {
2981
+ try {
2982
+ listenerFn(event);
2983
+ } catch (e) {
2984
+ logger.error({ err: e }, "Journal listener error");
2985
+ }
2986
+ }
2987
+ }
2988
+ };
2989
+ this.syncEngine.on("message", handleEvent);
2990
+ this.syncEngine.send({
2991
+ type: "JOURNAL_SUBSCRIBE",
2992
+ requestId: subscriptionId,
2993
+ fromSequence: options.fromSequence?.toString(),
2994
+ mapName: options.mapName,
2995
+ types: options.types
2996
+ });
2997
+ return () => {
2998
+ this.listeners.delete(subscriptionId);
2999
+ this.syncEngine.off("message", handleEvent);
3000
+ this.syncEngine.send({
3001
+ type: "JOURNAL_UNSUBSCRIBE",
3002
+ subscriptionId
3003
+ });
3004
+ };
3005
+ }
3006
+ /**
3007
+ * Get the latest sequence number from server.
3008
+ */
3009
+ async getLatestSequence() {
3010
+ const events = await this.readFrom(0n, 1);
3011
+ if (events.length === 0) return 0n;
3012
+ return events[events.length - 1].sequence;
3013
+ }
3014
+ /**
3015
+ * Parse network event data to JournalEvent.
3016
+ */
3017
+ parseEvent(raw) {
3018
+ return {
3019
+ sequence: BigInt(raw.sequence),
3020
+ type: raw.type,
3021
+ mapName: raw.mapName,
3022
+ key: raw.key,
3023
+ value: raw.value,
3024
+ previousValue: raw.previousValue,
3025
+ timestamp: raw.timestamp,
3026
+ nodeId: raw.nodeId,
3027
+ metadata: raw.metadata
3028
+ };
3029
+ }
3030
+ /**
3031
+ * Generate unique request ID.
3032
+ */
3033
+ generateRequestId() {
3034
+ return `journal_${Date.now()}_${++this.subscriptionCounter}`;
3035
+ }
3036
+ };
3037
+
3038
+ // src/cluster/ClusterClient.ts
3039
+ import {
3040
+ DEFAULT_CONNECTION_POOL_CONFIG as DEFAULT_CONNECTION_POOL_CONFIG2,
3041
+ DEFAULT_PARTITION_ROUTER_CONFIG as DEFAULT_PARTITION_ROUTER_CONFIG2,
3042
+ DEFAULT_CIRCUIT_BREAKER_CONFIG,
3043
+ serialize as serialize3
3044
+ } from "@topgunbuild/core";
3045
+
3046
+ // src/cluster/ConnectionPool.ts
3047
+ import {
3048
+ DEFAULT_CONNECTION_POOL_CONFIG
3049
+ } from "@topgunbuild/core";
3050
+ import { serialize as serialize2, deserialize as deserialize2 } from "@topgunbuild/core";
3051
+ var ConnectionPool = class {
3052
+ constructor(config = {}) {
3053
+ this.listeners = /* @__PURE__ */ new Map();
3054
+ this.connections = /* @__PURE__ */ new Map();
3055
+ this.primaryNodeId = null;
3056
+ this.healthCheckTimer = null;
3057
+ this.authToken = null;
3058
+ this.config = {
3059
+ ...DEFAULT_CONNECTION_POOL_CONFIG,
3060
+ ...config
3061
+ };
3062
+ }
3063
+ // ============================================
3064
+ // Event Emitter Methods (browser-compatible)
3065
+ // ============================================
3066
+ on(event, listener) {
3067
+ if (!this.listeners.has(event)) {
3068
+ this.listeners.set(event, /* @__PURE__ */ new Set());
3069
+ }
3070
+ this.listeners.get(event).add(listener);
3071
+ return this;
3072
+ }
3073
+ off(event, listener) {
3074
+ this.listeners.get(event)?.delete(listener);
3075
+ return this;
3076
+ }
3077
+ emit(event, ...args) {
3078
+ const eventListeners = this.listeners.get(event);
3079
+ if (!eventListeners || eventListeners.size === 0) {
3080
+ return false;
3081
+ }
3082
+ for (const listener of eventListeners) {
3083
+ try {
3084
+ listener(...args);
3085
+ } catch (err) {
3086
+ logger.error({ event, err }, "Error in event listener");
3087
+ }
3088
+ }
3089
+ return true;
3090
+ }
3091
+ removeAllListeners(event) {
3092
+ if (event) {
3093
+ this.listeners.delete(event);
3094
+ } else {
3095
+ this.listeners.clear();
3096
+ }
3097
+ return this;
3098
+ }
3099
+ /**
3100
+ * Set authentication token for all connections
3101
+ */
3102
+ setAuthToken(token) {
3103
+ this.authToken = token;
3104
+ for (const conn of this.connections.values()) {
3105
+ if (conn.state === "CONNECTED") {
3106
+ this.sendAuth(conn);
3107
+ }
3108
+ }
3109
+ }
3110
+ /**
3111
+ * Add a node to the connection pool
3112
+ */
3113
+ async addNode(nodeId, endpoint) {
3114
+ if (this.connections.has(nodeId)) {
3115
+ const existing = this.connections.get(nodeId);
3116
+ if (existing.endpoint !== endpoint) {
3117
+ await this.removeNode(nodeId);
3118
+ } else {
3119
+ return;
3120
+ }
3121
+ }
3122
+ const connection = {
3123
+ nodeId,
3124
+ endpoint,
3125
+ socket: null,
3126
+ state: "DISCONNECTED",
3127
+ lastSeen: 0,
3128
+ latencyMs: 0,
3129
+ reconnectAttempts: 0,
3130
+ reconnectTimer: null,
3131
+ pendingMessages: []
3132
+ };
3133
+ this.connections.set(nodeId, connection);
3134
+ if (!this.primaryNodeId) {
3135
+ this.primaryNodeId = nodeId;
3136
+ }
3137
+ await this.connect(nodeId);
3138
+ }
3139
+ /**
3140
+ * Remove a node from the connection pool
3141
+ */
3142
+ async removeNode(nodeId) {
3143
+ const connection = this.connections.get(nodeId);
3144
+ if (!connection) return;
3145
+ if (connection.reconnectTimer) {
3146
+ clearTimeout(connection.reconnectTimer);
3147
+ connection.reconnectTimer = null;
3148
+ }
3149
+ if (connection.socket) {
3150
+ connection.socket.onclose = null;
3151
+ connection.socket.close();
3152
+ connection.socket = null;
3153
+ }
3154
+ this.connections.delete(nodeId);
3155
+ if (this.primaryNodeId === nodeId) {
3156
+ this.primaryNodeId = this.connections.size > 0 ? this.connections.keys().next().value ?? null : null;
3157
+ }
3158
+ logger.info({ nodeId }, "Node removed from connection pool");
3159
+ }
3160
+ /**
3161
+ * Get connection for a specific node
3162
+ */
3163
+ getConnection(nodeId) {
3164
+ const connection = this.connections.get(nodeId);
3165
+ if (!connection || connection.state !== "AUTHENTICATED") {
3166
+ return null;
3167
+ }
3168
+ return connection.socket;
3169
+ }
3170
+ /**
3171
+ * Get primary connection (first/seed node)
3172
+ */
3173
+ getPrimaryConnection() {
3174
+ if (!this.primaryNodeId) return null;
3175
+ return this.getConnection(this.primaryNodeId);
3176
+ }
3177
+ /**
3178
+ * Get any healthy connection
3179
+ */
3180
+ getAnyHealthyConnection() {
3181
+ for (const [nodeId, conn] of this.connections) {
3182
+ if (conn.state === "AUTHENTICATED" && conn.socket) {
3183
+ return { nodeId, socket: conn.socket };
3184
+ }
3185
+ }
3186
+ return null;
3187
+ }
3188
+ /**
3189
+ * Send message to a specific node
3190
+ */
3191
+ send(nodeId, message) {
3192
+ const connection = this.connections.get(nodeId);
3193
+ if (!connection) {
3194
+ logger.warn({ nodeId }, "Cannot send: node not in pool");
3195
+ return false;
3196
+ }
3197
+ const data = serialize2(message);
3198
+ if (connection.state === "AUTHENTICATED" && connection.socket?.readyState === WebSocket.OPEN) {
3199
+ connection.socket.send(data);
3200
+ return true;
3201
+ }
3202
+ if (connection.pendingMessages.length < 1e3) {
3203
+ connection.pendingMessages.push(data);
3204
+ return true;
3205
+ }
3206
+ logger.warn({ nodeId }, "Message queue full, dropping message");
3207
+ return false;
3208
+ }
3209
+ /**
3210
+ * Send message to primary node
3211
+ */
3212
+ sendToPrimary(message) {
3213
+ if (!this.primaryNodeId) {
3214
+ logger.warn("No primary node available");
3215
+ return false;
3216
+ }
3217
+ return this.send(this.primaryNodeId, message);
3218
+ }
3219
+ /**
3220
+ * Get health status for all nodes
3221
+ */
3222
+ getHealthStatus() {
3223
+ const status = /* @__PURE__ */ new Map();
3224
+ for (const [nodeId, conn] of this.connections) {
3225
+ status.set(nodeId, {
3226
+ nodeId,
3227
+ state: conn.state,
3228
+ lastSeen: conn.lastSeen,
3229
+ latencyMs: conn.latencyMs,
3230
+ reconnectAttempts: conn.reconnectAttempts
3231
+ });
3232
+ }
3233
+ return status;
3234
+ }
3235
+ /**
3236
+ * Get list of connected node IDs
3237
+ */
3238
+ getConnectedNodes() {
3239
+ return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
3240
+ }
3241
+ /**
3242
+ * Get all node IDs
3243
+ */
3244
+ getAllNodes() {
3245
+ return Array.from(this.connections.keys());
3246
+ }
3247
+ /**
3248
+ * Check if node is connected and authenticated
3249
+ */
3250
+ isNodeConnected(nodeId) {
3251
+ const conn = this.connections.get(nodeId);
3252
+ return conn?.state === "AUTHENTICATED";
3253
+ }
3254
+ /**
3255
+ * Check if connected to a specific node.
3256
+ * Alias for isNodeConnected() for IConnectionProvider compatibility.
3257
+ */
3258
+ isConnected(nodeId) {
3259
+ return this.isNodeConnected(nodeId);
3260
+ }
3261
+ /**
3262
+ * Start health monitoring
3263
+ */
3264
+ startHealthCheck() {
3265
+ if (this.healthCheckTimer) return;
3266
+ this.healthCheckTimer = setInterval(() => {
3267
+ this.performHealthCheck();
3268
+ }, this.config.healthCheckIntervalMs);
3269
+ }
3270
+ /**
3271
+ * Stop health monitoring
3272
+ */
3273
+ stopHealthCheck() {
3274
+ if (this.healthCheckTimer) {
3275
+ clearInterval(this.healthCheckTimer);
3276
+ this.healthCheckTimer = null;
3277
+ }
3278
+ }
3279
+ /**
3280
+ * Close all connections and cleanup
3281
+ */
3282
+ close() {
3283
+ this.stopHealthCheck();
3284
+ for (const nodeId of this.connections.keys()) {
3285
+ this.removeNode(nodeId);
3286
+ }
3287
+ this.connections.clear();
3288
+ this.primaryNodeId = null;
3289
+ }
3290
+ // ============================================
3291
+ // Private Methods
3292
+ // ============================================
3293
+ async connect(nodeId) {
3294
+ const connection = this.connections.get(nodeId);
3295
+ if (!connection) return;
3296
+ if (connection.state === "CONNECTING" || connection.state === "CONNECTED") {
3297
+ return;
3298
+ }
3299
+ connection.state = "CONNECTING";
3300
+ logger.info({ nodeId, endpoint: connection.endpoint }, "Connecting to node");
3301
+ try {
3302
+ const socket = new WebSocket(connection.endpoint);
3303
+ socket.binaryType = "arraybuffer";
3304
+ connection.socket = socket;
3305
+ socket.onopen = () => {
3306
+ connection.state = "CONNECTED";
3307
+ connection.reconnectAttempts = 0;
3308
+ connection.lastSeen = Date.now();
3309
+ logger.info({ nodeId }, "Connected to node");
3310
+ this.emit("node:connected", nodeId);
3311
+ if (this.authToken) {
3312
+ this.sendAuth(connection);
3313
+ }
3314
+ };
3315
+ socket.onmessage = (event) => {
3316
+ connection.lastSeen = Date.now();
3317
+ this.handleMessage(nodeId, event);
3318
+ };
3319
+ socket.onerror = (error) => {
3320
+ logger.error({ nodeId, error }, "WebSocket error");
3321
+ this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
3322
+ };
3323
+ socket.onclose = () => {
3324
+ const wasConnected = connection.state === "AUTHENTICATED";
3325
+ connection.state = "DISCONNECTED";
3326
+ connection.socket = null;
3327
+ if (wasConnected) {
3328
+ this.emit("node:disconnected", nodeId, "Connection closed");
3329
+ }
3330
+ this.scheduleReconnect(nodeId);
3331
+ };
3332
+ } catch (error) {
3333
+ connection.state = "FAILED";
3334
+ logger.error({ nodeId, error }, "Failed to connect");
3335
+ this.scheduleReconnect(nodeId);
3336
+ }
3337
+ }
3338
+ sendAuth(connection) {
3339
+ if (!this.authToken || !connection.socket) return;
3340
+ connection.socket.send(serialize2({
3341
+ type: "AUTH",
3342
+ token: this.authToken
3343
+ }));
3344
+ }
3345
+ handleMessage(nodeId, event) {
3346
+ const connection = this.connections.get(nodeId);
3347
+ if (!connection) return;
3348
+ let message;
3349
+ try {
3350
+ if (event.data instanceof ArrayBuffer) {
3351
+ message = deserialize2(new Uint8Array(event.data));
3352
+ } else {
3353
+ message = JSON.parse(event.data);
3354
+ }
3355
+ } catch (e) {
3356
+ logger.error({ nodeId, error: e }, "Failed to parse message");
3357
+ return;
3358
+ }
3359
+ if (message.type === "AUTH_ACK") {
3360
+ connection.state = "AUTHENTICATED";
3361
+ logger.info({ nodeId }, "Authenticated with node");
3362
+ this.emit("node:healthy", nodeId);
3363
+ this.flushPendingMessages(connection);
3364
+ return;
3365
+ }
3366
+ if (message.type === "AUTH_REQUIRED") {
3367
+ if (this.authToken) {
3368
+ this.sendAuth(connection);
3369
+ }
3370
+ return;
3371
+ }
3372
+ if (message.type === "AUTH_FAIL") {
3373
+ logger.error({ nodeId, error: message.error }, "Authentication failed");
3374
+ connection.state = "FAILED";
3375
+ return;
3376
+ }
3377
+ if (message.type === "PONG") {
3378
+ if (message.timestamp) {
3379
+ connection.latencyMs = Date.now() - message.timestamp;
3380
+ }
3381
+ return;
3382
+ }
3383
+ if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
3384
+ this.emit("message", nodeId, message);
3385
+ return;
3386
+ }
3387
+ this.emit("message", nodeId, message);
3388
+ }
3389
+ flushPendingMessages(connection) {
3390
+ if (!connection.socket || connection.state !== "AUTHENTICATED") return;
3391
+ const pending = connection.pendingMessages;
3392
+ connection.pendingMessages = [];
3393
+ for (const data of pending) {
3394
+ if (connection.socket.readyState === WebSocket.OPEN) {
3395
+ connection.socket.send(data);
3396
+ }
3397
+ }
3398
+ if (pending.length > 0) {
3399
+ logger.debug({ nodeId: connection.nodeId, count: pending.length }, "Flushed pending messages");
3400
+ }
3401
+ }
3402
+ scheduleReconnect(nodeId) {
3403
+ const connection = this.connections.get(nodeId);
3404
+ if (!connection) return;
3405
+ if (connection.reconnectTimer) {
3406
+ clearTimeout(connection.reconnectTimer);
3407
+ connection.reconnectTimer = null;
3408
+ }
3409
+ if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
3410
+ connection.state = "FAILED";
3411
+ logger.error({ nodeId, attempts: connection.reconnectAttempts }, "Max reconnect attempts reached");
3412
+ this.emit("node:unhealthy", nodeId, "Max reconnect attempts reached");
3413
+ return;
3414
+ }
3415
+ const delay = Math.min(
3416
+ this.config.reconnectDelayMs * Math.pow(2, connection.reconnectAttempts),
3417
+ this.config.maxReconnectDelayMs
3418
+ );
3419
+ connection.state = "RECONNECTING";
3420
+ connection.reconnectAttempts++;
3421
+ logger.info({ nodeId, delay, attempt: connection.reconnectAttempts }, "Scheduling reconnect");
3422
+ connection.reconnectTimer = setTimeout(() => {
3423
+ connection.reconnectTimer = null;
3424
+ this.connect(nodeId);
3425
+ }, delay);
3426
+ }
3427
+ performHealthCheck() {
3428
+ const now = Date.now();
3429
+ for (const [nodeId, connection] of this.connections) {
3430
+ if (connection.state !== "AUTHENTICATED") continue;
3431
+ const timeSinceLastSeen = now - connection.lastSeen;
3432
+ if (timeSinceLastSeen > this.config.healthCheckIntervalMs * 3) {
3433
+ logger.warn({ nodeId, timeSinceLastSeen }, "Node appears stale, sending ping");
3434
+ }
3435
+ if (connection.socket?.readyState === WebSocket.OPEN) {
3436
+ connection.socket.send(serialize2({
3437
+ type: "PING",
3438
+ timestamp: now
3439
+ }));
3440
+ }
3441
+ }
3442
+ }
3443
+ };
3444
+
3445
+ // src/cluster/PartitionRouter.ts
3446
+ import {
3447
+ DEFAULT_PARTITION_ROUTER_CONFIG,
3448
+ PARTITION_COUNT,
3449
+ hashString
3450
+ } from "@topgunbuild/core";
3451
+ var PartitionRouter = class {
3452
+ constructor(connectionPool, config = {}) {
3453
+ this.listeners = /* @__PURE__ */ new Map();
3454
+ this.partitionMap = null;
3455
+ this.lastRefreshTime = 0;
3456
+ this.refreshTimer = null;
3457
+ this.pendingRefresh = null;
3458
+ this.connectionPool = connectionPool;
3459
+ this.config = {
3460
+ ...DEFAULT_PARTITION_ROUTER_CONFIG,
3461
+ ...config
3462
+ };
3463
+ this.connectionPool.on("message", (nodeId, message) => {
3464
+ if (message.type === "PARTITION_MAP") {
3465
+ this.handlePartitionMap(message);
3466
+ } else if (message.type === "PARTITION_MAP_DELTA") {
3467
+ this.handlePartitionMapDelta(message);
3468
+ }
3469
+ });
3470
+ }
3471
+ // ============================================
3472
+ // Event Emitter Methods (browser-compatible)
3473
+ // ============================================
3474
+ on(event, listener) {
3475
+ if (!this.listeners.has(event)) {
3476
+ this.listeners.set(event, /* @__PURE__ */ new Set());
3477
+ }
3478
+ this.listeners.get(event).add(listener);
3479
+ return this;
3480
+ }
3481
+ off(event, listener) {
3482
+ this.listeners.get(event)?.delete(listener);
3483
+ return this;
3484
+ }
3485
+ once(event, listener) {
3486
+ const wrapper = (...args) => {
3487
+ this.off(event, wrapper);
3488
+ listener(...args);
3489
+ };
3490
+ return this.on(event, wrapper);
3491
+ }
3492
+ emit(event, ...args) {
3493
+ const eventListeners = this.listeners.get(event);
3494
+ if (!eventListeners || eventListeners.size === 0) {
3495
+ return false;
3496
+ }
3497
+ for (const listener of eventListeners) {
3498
+ try {
3499
+ listener(...args);
3500
+ } catch (err) {
3501
+ logger.error({ event, err }, "Error in event listener");
3502
+ }
3503
+ }
3504
+ return true;
3505
+ }
3506
+ removeListener(event, listener) {
3507
+ return this.off(event, listener);
3508
+ }
3509
+ removeAllListeners(event) {
3510
+ if (event) {
3511
+ this.listeners.delete(event);
3512
+ } else {
3513
+ this.listeners.clear();
3514
+ }
3515
+ return this;
3516
+ }
3517
+ /**
3518
+ * Get the partition ID for a given key
3519
+ */
3520
+ getPartitionId(key) {
3521
+ return Math.abs(hashString(key)) % PARTITION_COUNT;
3522
+ }
3523
+ /**
3524
+ * Route a key to the owner node
3525
+ */
3526
+ route(key) {
3527
+ if (!this.partitionMap) {
3528
+ return null;
3529
+ }
3530
+ const partitionId = this.getPartitionId(key);
3531
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
3532
+ if (!partition) {
3533
+ logger.warn({ key, partitionId }, "Partition not found in map");
3534
+ return null;
3535
+ }
3536
+ return {
3537
+ nodeId: partition.ownerNodeId,
3538
+ partitionId,
3539
+ isOwner: true,
3540
+ isBackup: false
3541
+ };
3542
+ }
3543
+ /**
3544
+ * Route a key and get the WebSocket connection to use
3545
+ */
3546
+ routeToConnection(key) {
3547
+ const routing = this.route(key);
3548
+ if (!routing) {
3549
+ if (this.config.fallbackMode === "forward") {
3550
+ const primary = this.connectionPool.getAnyHealthyConnection();
3551
+ if (primary) {
3552
+ return primary;
3553
+ }
3554
+ }
3555
+ return null;
3556
+ }
3557
+ const socket = this.connectionPool.getConnection(routing.nodeId);
3558
+ if (socket) {
3559
+ return { nodeId: routing.nodeId, socket };
3560
+ }
3561
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
3562
+ if (partition) {
3563
+ for (const backupId of partition.backupNodeIds) {
3564
+ const backupSocket = this.connectionPool.getConnection(backupId);
3565
+ if (backupSocket) {
3566
+ logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
3567
+ return { nodeId: backupId, socket: backupSocket };
3568
+ }
3569
+ }
3570
+ }
3571
+ if (this.config.fallbackMode === "forward") {
3572
+ return this.connectionPool.getAnyHealthyConnection();
3573
+ }
3574
+ return null;
3575
+ }
3576
+ /**
3577
+ * Get routing info for multiple keys (batch routing)
3578
+ */
3579
+ routeBatch(keys) {
3580
+ const result = /* @__PURE__ */ new Map();
3581
+ for (const key of keys) {
3582
+ const routing = this.route(key);
3583
+ if (routing) {
3584
+ const nodeId = routing.nodeId;
3585
+ if (!result.has(nodeId)) {
3586
+ result.set(nodeId, []);
3587
+ }
3588
+ result.get(nodeId).push({ ...routing, key });
3589
+ }
3590
+ }
3591
+ return result;
3592
+ }
3593
+ /**
3594
+ * Get all partitions owned by a specific node
3595
+ */
3596
+ getPartitionsForNode(nodeId) {
3597
+ if (!this.partitionMap) return [];
3598
+ return this.partitionMap.partitions.filter((p) => p.ownerNodeId === nodeId).map((p) => p.partitionId);
3599
+ }
3600
+ /**
3601
+ * Get current partition map version
3602
+ */
3603
+ getMapVersion() {
3604
+ return this.partitionMap?.version ?? 0;
3605
+ }
3606
+ /**
3607
+ * Check if partition map is available
3608
+ */
3609
+ hasPartitionMap() {
3610
+ return this.partitionMap !== null;
3611
+ }
3612
+ /**
3613
+ * Get owner node for a key.
3614
+ * Returns null if partition map is not available.
3615
+ */
3616
+ getOwner(key) {
3617
+ if (!this.partitionMap) return null;
3618
+ const partitionId = this.getPartitionId(key);
3619
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
3620
+ return partition?.ownerNodeId ?? null;
3621
+ }
3622
+ /**
3623
+ * Get backup nodes for a key.
3624
+ * Returns empty array if partition map is not available.
3625
+ */
3626
+ getBackups(key) {
3627
+ if (!this.partitionMap) return [];
3628
+ const partitionId = this.getPartitionId(key);
3629
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
3630
+ return partition?.backupNodeIds ?? [];
3631
+ }
3632
+ /**
3633
+ * Get the full partition map.
3634
+ * Returns null if not available.
3635
+ */
3636
+ getMap() {
3637
+ return this.partitionMap;
3638
+ }
3639
+ /**
3640
+ * Update entire partition map.
3641
+ * Only accepts newer versions.
3642
+ */
3643
+ updateMap(map) {
3644
+ if (this.partitionMap && map.version <= this.partitionMap.version) {
3645
+ return false;
3646
+ }
3647
+ this.partitionMap = map;
3648
+ this.lastRefreshTime = Date.now();
3649
+ this.updateConnectionPool(map);
3650
+ const changesCount = map.partitions.length;
3651
+ logger.info({
3652
+ version: map.version,
3653
+ partitions: map.partitionCount,
3654
+ nodes: map.nodes.length
3655
+ }, "Partition map updated via updateMap");
3656
+ this.emit("partitionMap:updated", map.version, changesCount);
3657
+ return true;
3658
+ }
3659
+ /**
3660
+ * Update a single partition (for delta updates).
3661
+ */
3662
+ updatePartition(partitionId, owner, backups) {
3663
+ if (!this.partitionMap) return;
3664
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
3665
+ if (partition) {
3666
+ partition.ownerNodeId = owner;
3667
+ partition.backupNodeIds = backups;
3668
+ }
3669
+ }
3670
+ /**
3671
+ * Check if partition map is stale
3672
+ */
3673
+ isMapStale() {
3674
+ if (!this.partitionMap) return true;
3675
+ const now = Date.now();
3676
+ return now - this.lastRefreshTime > this.config.maxMapStalenessMs;
3677
+ }
3678
+ /**
3679
+ * Request fresh partition map from server
3680
+ */
3681
+ async refreshPartitionMap() {
3682
+ if (this.pendingRefresh) {
3683
+ return this.pendingRefresh;
3684
+ }
3685
+ this.pendingRefresh = this.doRefreshPartitionMap();
3686
+ try {
3687
+ await this.pendingRefresh;
3688
+ } finally {
3689
+ this.pendingRefresh = null;
3690
+ }
3691
+ }
3692
+ /**
3693
+ * Start periodic partition map refresh
3694
+ */
3695
+ startPeriodicRefresh() {
3696
+ if (this.refreshTimer) return;
3697
+ this.refreshTimer = setInterval(() => {
3698
+ if (this.isMapStale()) {
3699
+ this.emit("partitionMap:stale", this.getMapVersion(), this.lastRefreshTime);
3700
+ this.refreshPartitionMap().catch((err) => {
3701
+ logger.error({ error: err }, "Failed to refresh partition map");
3702
+ });
3703
+ }
3704
+ }, this.config.mapRefreshIntervalMs);
3705
+ }
3706
+ /**
3707
+ * Stop periodic refresh
3708
+ */
3709
+ stopPeriodicRefresh() {
3710
+ if (this.refreshTimer) {
3711
+ clearInterval(this.refreshTimer);
3712
+ this.refreshTimer = null;
3713
+ }
3714
+ }
3715
+ /**
3716
+ * Handle NOT_OWNER error from server
3717
+ */
3718
+ handleNotOwnerError(key, actualOwner, newMapVersion) {
3719
+ const routing = this.route(key);
3720
+ const expectedOwner = routing?.nodeId ?? "unknown";
3721
+ this.emit("routing:miss", key, expectedOwner, actualOwner);
3722
+ if (newMapVersion > this.getMapVersion()) {
3723
+ this.refreshPartitionMap().catch((err) => {
3724
+ logger.error({ error: err }, "Failed to refresh partition map after NOT_OWNER");
3725
+ });
3726
+ }
3727
+ }
3728
+ /**
3729
+ * Get statistics about routing
3730
+ */
3731
+ getStats() {
3732
+ return {
3733
+ mapVersion: this.getMapVersion(),
3734
+ partitionCount: this.partitionMap?.partitionCount ?? 0,
3735
+ nodeCount: this.partitionMap?.nodes.length ?? 0,
3736
+ lastRefresh: this.lastRefreshTime,
3737
+ isStale: this.isMapStale()
3738
+ };
3739
+ }
3740
+ /**
3741
+ * Cleanup resources
3742
+ */
3743
+ close() {
3744
+ this.stopPeriodicRefresh();
3745
+ this.partitionMap = null;
3746
+ }
3747
+ // ============================================
3748
+ // Private Methods
3749
+ // ============================================
3750
+ handlePartitionMap(message) {
3751
+ const newMap = message.payload;
3752
+ if (this.partitionMap && newMap.version <= this.partitionMap.version) {
3753
+ logger.debug({
3754
+ current: this.partitionMap.version,
3755
+ received: newMap.version
3756
+ }, "Ignoring older partition map");
3757
+ return;
3758
+ }
3759
+ this.partitionMap = newMap;
3760
+ this.lastRefreshTime = Date.now();
3761
+ this.updateConnectionPool(newMap);
3762
+ const changesCount = newMap.partitions.length;
3763
+ logger.info({
3764
+ version: newMap.version,
3765
+ partitions: newMap.partitionCount,
3766
+ nodes: newMap.nodes.length
3767
+ }, "Partition map updated");
3768
+ this.emit("partitionMap:updated", newMap.version, changesCount);
3769
+ }
3770
+ handlePartitionMapDelta(message) {
3771
+ const delta = message.payload;
3772
+ if (!this.partitionMap) {
3773
+ logger.warn("Received delta but no base map, requesting full map");
3774
+ this.refreshPartitionMap();
3775
+ return;
3776
+ }
3777
+ if (delta.previousVersion !== this.partitionMap.version) {
3778
+ logger.warn({
3779
+ expected: this.partitionMap.version,
3780
+ received: delta.previousVersion
3781
+ }, "Delta version mismatch, requesting full map");
3782
+ this.refreshPartitionMap();
3783
+ return;
3784
+ }
3785
+ for (const change of delta.changes) {
3786
+ this.applyPartitionChange(change);
3787
+ }
3788
+ this.partitionMap.version = delta.version;
3789
+ this.lastRefreshTime = Date.now();
3790
+ logger.info({
3791
+ version: delta.version,
3792
+ changes: delta.changes.length
3793
+ }, "Applied partition map delta");
3794
+ this.emit("partitionMap:updated", delta.version, delta.changes.length);
3795
+ }
3796
+ applyPartitionChange(change) {
3797
+ if (!this.partitionMap) return;
3798
+ const partition = this.partitionMap.partitions.find((p) => p.partitionId === change.partitionId);
3799
+ if (partition) {
3800
+ partition.ownerNodeId = change.newOwner;
3801
+ }
3802
+ }
3803
+ updateConnectionPool(map) {
3804
+ for (const node of map.nodes) {
3805
+ if (node.status === "ACTIVE" || node.status === "JOINING") {
3806
+ this.connectionPool.addNode(node.nodeId, node.endpoints.websocket);
3807
+ }
3808
+ }
3809
+ const currentNodeIds = new Set(map.nodes.map((n) => n.nodeId));
3810
+ for (const nodeId of this.connectionPool.getAllNodes()) {
3811
+ if (!currentNodeIds.has(nodeId)) {
3812
+ this.connectionPool.removeNode(nodeId);
3813
+ }
3814
+ }
3815
+ }
3816
+ async doRefreshPartitionMap() {
3817
+ logger.debug("Requesting partition map refresh");
3818
+ const sent = this.connectionPool.sendToPrimary({
3819
+ type: "PARTITION_MAP_REQUEST",
3820
+ payload: {
3821
+ currentVersion: this.getMapVersion()
3822
+ }
3823
+ });
3824
+ if (!sent) {
3825
+ throw new Error("No connection available to request partition map");
3826
+ }
3827
+ return new Promise((resolve, reject) => {
3828
+ const timeout = setTimeout(() => {
3829
+ this.removeListener("partitionMap:updated", onUpdate);
3830
+ reject(new Error("Partition map refresh timeout"));
3831
+ }, 5e3);
3832
+ const onUpdate = () => {
3833
+ clearTimeout(timeout);
3834
+ this.removeListener("partitionMap:updated", onUpdate);
3835
+ resolve();
3836
+ };
3837
+ this.once("partitionMap:updated", onUpdate);
3838
+ });
3839
+ }
3840
+ };
3841
+
3842
+ // src/cluster/ClusterClient.ts
3843
+ var ClusterClient = class {
3844
+ constructor(config) {
3845
+ this.listeners = /* @__PURE__ */ new Map();
3846
+ this.initialized = false;
3847
+ this.routingActive = false;
3848
+ this.routingMetrics = {
3849
+ directRoutes: 0,
3850
+ fallbackRoutes: 0,
3851
+ partitionMisses: 0,
3852
+ totalRoutes: 0
3853
+ };
3854
+ // Circuit breaker state per node
3855
+ this.circuits = /* @__PURE__ */ new Map();
3856
+ this.config = config;
3857
+ this.circuitBreakerConfig = {
3858
+ ...DEFAULT_CIRCUIT_BREAKER_CONFIG,
3859
+ ...config.circuitBreaker
3860
+ };
3861
+ const poolConfig = {
3862
+ ...DEFAULT_CONNECTION_POOL_CONFIG2,
3863
+ ...config.connectionPool
3864
+ };
3865
+ this.connectionPool = new ConnectionPool(poolConfig);
3866
+ const routerConfig = {
3867
+ ...DEFAULT_PARTITION_ROUTER_CONFIG2,
3868
+ fallbackMode: config.routingMode === "direct" ? "error" : "forward",
3869
+ ...config.routing
3870
+ };
3871
+ this.partitionRouter = new PartitionRouter(this.connectionPool, routerConfig);
3872
+ this.setupEventHandlers();
3873
+ }
3874
+ // ============================================
3875
+ // Event Emitter Methods (browser-compatible)
3876
+ // ============================================
3877
+ on(event, listener) {
3878
+ if (!this.listeners.has(event)) {
3879
+ this.listeners.set(event, /* @__PURE__ */ new Set());
3880
+ }
3881
+ this.listeners.get(event).add(listener);
3882
+ return this;
3883
+ }
3884
+ off(event, listener) {
3885
+ this.listeners.get(event)?.delete(listener);
3886
+ return this;
3887
+ }
3888
+ emit(event, ...args) {
3889
+ const eventListeners = this.listeners.get(event);
3890
+ if (!eventListeners || eventListeners.size === 0) {
3891
+ return false;
3892
+ }
3893
+ for (const listener of eventListeners) {
3894
+ try {
3895
+ listener(...args);
3896
+ } catch (err) {
3897
+ logger.error({ event, err }, "Error in event listener");
3898
+ }
3899
+ }
3900
+ return true;
3901
+ }
3902
+ removeAllListeners(event) {
3903
+ if (event) {
3904
+ this.listeners.delete(event);
3905
+ } else {
3906
+ this.listeners.clear();
3907
+ }
3908
+ return this;
3909
+ }
3910
+ // ============================================
3911
+ // IConnectionProvider Implementation
3912
+ // ============================================
3913
+ /**
3914
+ * Connect to cluster nodes (IConnectionProvider interface).
3915
+ * Alias for start() method.
3916
+ */
3917
+ async connect() {
3918
+ return this.start();
3919
+ }
3920
+ /**
3921
+ * Get connection for a specific key (IConnectionProvider interface).
3922
+ * Routes to partition owner based on key hash when smart routing is enabled.
3923
+ * @throws Error if not connected
3924
+ */
3925
+ getConnection(key) {
3926
+ if (!this.isConnected()) {
3927
+ throw new Error("ClusterClient not connected");
3928
+ }
3929
+ this.routingMetrics.totalRoutes++;
3930
+ if (this.config.routingMode !== "direct" || !this.routingActive) {
3931
+ this.routingMetrics.fallbackRoutes++;
3932
+ return this.getFallbackConnection();
3933
+ }
3934
+ const routing = this.partitionRouter.route(key);
3935
+ if (!routing) {
3936
+ this.routingMetrics.partitionMisses++;
3937
+ logger.debug({ key }, "No partition map available, using fallback");
3938
+ return this.getFallbackConnection();
3939
+ }
3940
+ const owner = routing.nodeId;
3941
+ if (!this.connectionPool.isNodeConnected(owner)) {
3942
+ this.routingMetrics.fallbackRoutes++;
3943
+ logger.debug({ key, owner }, "Partition owner not connected, using fallback");
3944
+ this.requestPartitionMapRefresh();
3945
+ return this.getFallbackConnection();
3946
+ }
3947
+ const socket = this.connectionPool.getConnection(owner);
3948
+ if (!socket) {
3949
+ this.routingMetrics.fallbackRoutes++;
3950
+ logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
3951
+ return this.getFallbackConnection();
3952
+ }
3953
+ this.routingMetrics.directRoutes++;
3954
+ return socket;
3955
+ }
3956
+ /**
3957
+ * Get fallback connection when owner is unavailable.
3958
+ * @throws Error if no connection available
3959
+ */
3960
+ getFallbackConnection() {
3961
+ const conn = this.connectionPool.getAnyHealthyConnection();
3962
+ if (!conn?.socket) {
3963
+ throw new Error("No healthy connection available");
3964
+ }
3965
+ return conn.socket;
3966
+ }
3967
+ /**
3968
+ * Request a partition map refresh in the background.
3969
+ * Called when routing to an unknown/disconnected owner.
3970
+ */
3971
+ requestPartitionMapRefresh() {
3972
+ this.partitionRouter.refreshPartitionMap().catch((err) => {
3973
+ logger.error({ err }, "Failed to refresh partition map");
3974
+ });
3975
+ }
3976
+ /**
3977
+ * Request partition map from a specific node.
3978
+ * Called on first node connection.
3979
+ */
3980
+ requestPartitionMapFromNode(nodeId) {
3981
+ const socket = this.connectionPool.getConnection(nodeId);
3982
+ if (socket) {
3983
+ logger.debug({ nodeId }, "Requesting partition map from node");
3984
+ socket.send(serialize3({
3985
+ type: "PARTITION_MAP_REQUEST",
3986
+ payload: {
3987
+ currentVersion: this.partitionRouter.getMapVersion()
3988
+ }
3989
+ }));
3990
+ }
3991
+ }
3992
+ /**
3993
+ * Check if at least one connection is active (IConnectionProvider interface).
3994
+ */
3995
+ isConnected() {
3996
+ return this.connectionPool.getConnectedNodes().length > 0;
3997
+ }
3998
+ /**
3999
+ * Send data via the appropriate connection (IConnectionProvider interface).
4000
+ * Routes based on key if provided.
4001
+ */
4002
+ send(data, key) {
4003
+ if (!this.isConnected()) {
4004
+ throw new Error("ClusterClient not connected");
4005
+ }
4006
+ const socket = key ? this.getConnection(key) : this.getAnyConnection();
4007
+ socket.send(data);
4008
+ }
4009
+ /**
4010
+ * Send data with automatic retry and rerouting on failure.
4011
+ * @param data - Data to send
4012
+ * @param key - Optional key for routing
4013
+ * @param options - Retry options
4014
+ * @throws Error after max retries exceeded
4015
+ */
4016
+ async sendWithRetry(data, key, options = {}) {
4017
+ const {
4018
+ maxRetries = 3,
4019
+ retryDelayMs = 100,
4020
+ retryOnNotOwner = true
4021
+ } = options;
4022
+ let lastError = null;
4023
+ let nodeId = null;
4024
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
4025
+ try {
4026
+ if (key && this.routingActive) {
4027
+ const routing = this.partitionRouter.route(key);
4028
+ nodeId = routing?.nodeId ?? null;
4029
+ }
4030
+ if (nodeId && !this.canUseNode(nodeId)) {
4031
+ logger.debug({ nodeId, attempt }, "Circuit open, using fallback");
4032
+ nodeId = null;
4033
+ }
4034
+ const socket = key && nodeId ? this.connectionPool.getConnection(nodeId) : this.getAnyConnection();
4035
+ if (!socket) {
4036
+ throw new Error("No connection available");
4037
+ }
4038
+ socket.send(data);
4039
+ if (nodeId) {
4040
+ this.recordSuccess(nodeId);
4041
+ }
4042
+ return;
4043
+ } catch (error) {
4044
+ lastError = error;
4045
+ if (nodeId) {
4046
+ this.recordFailure(nodeId);
4047
+ }
4048
+ const errorCode = error?.code;
4049
+ if (this.isRetryableError(error)) {
4050
+ logger.debug(
4051
+ { attempt, maxRetries, errorCode, nodeId },
4052
+ "Retryable error, will retry"
4053
+ );
4054
+ if (errorCode === "NOT_OWNER" && retryOnNotOwner) {
4055
+ await this.waitForPartitionMapUpdateInternal(2e3);
4056
+ } else if (errorCode === "CONNECTION_CLOSED" || !this.isConnected()) {
4057
+ await this.waitForConnectionInternal(5e3);
4058
+ }
4059
+ await this.delay(retryDelayMs * (attempt + 1));
4060
+ continue;
4061
+ }
4062
+ throw error;
4063
+ }
4064
+ }
4065
+ throw new Error(
4066
+ `Operation failed after ${maxRetries} retries: ${lastError?.message}`
4067
+ );
4068
+ }
4069
+ /**
4070
+ * Check if an error is retryable.
4071
+ */
4072
+ isRetryableError(error) {
4073
+ const code = error?.code;
4074
+ const message = error?.message || "";
4075
+ 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");
4076
+ }
4077
+ /**
4078
+ * Wait for partition map update.
4079
+ */
4080
+ waitForPartitionMapUpdateInternal(timeoutMs) {
4081
+ return new Promise((resolve) => {
4082
+ const timeout = setTimeout(resolve, timeoutMs);
4083
+ const handler2 = () => {
4084
+ clearTimeout(timeout);
4085
+ this.off("partitionMapUpdated", handler2);
4086
+ resolve();
4087
+ };
4088
+ this.on("partitionMapUpdated", handler2);
4089
+ });
4090
+ }
4091
+ /**
4092
+ * Wait for at least one connection to be available.
4093
+ */
4094
+ waitForConnectionInternal(timeoutMs) {
4095
+ return new Promise((resolve, reject) => {
4096
+ if (this.isConnected()) {
4097
+ resolve();
4098
+ return;
4099
+ }
4100
+ const timeout = setTimeout(() => {
4101
+ this.off("connected", handler2);
4102
+ reject(new Error("Connection timeout"));
4103
+ }, timeoutMs);
4104
+ const handler2 = () => {
4105
+ clearTimeout(timeout);
4106
+ this.off("connected", handler2);
4107
+ resolve();
4108
+ };
4109
+ this.on("connected", handler2);
4110
+ });
4111
+ }
4112
+ /**
4113
+ * Helper delay function.
4114
+ */
4115
+ delay(ms) {
4116
+ return new Promise((resolve) => setTimeout(resolve, ms));
4117
+ }
4118
+ // ============================================
4119
+ // Cluster-Specific Methods
4120
+ // ============================================
4121
+ /**
4122
+ * Initialize cluster connections
4123
+ */
4124
+ async start() {
4125
+ if (this.initialized) return;
4126
+ logger.info({ seedNodes: this.config.seedNodes }, "Starting cluster client");
4127
+ for (let i = 0; i < this.config.seedNodes.length; i++) {
4128
+ const endpoint = this.config.seedNodes[i];
4129
+ const nodeId = `seed-${i}`;
4130
+ await this.connectionPool.addNode(nodeId, endpoint);
4131
+ }
4132
+ this.connectionPool.startHealthCheck();
4133
+ this.partitionRouter.startPeriodicRefresh();
4134
+ this.initialized = true;
4135
+ await this.waitForPartitionMap();
4136
+ }
4137
+ /**
4138
+ * Set authentication token
4139
+ */
4140
+ setAuthToken(token) {
4141
+ this.connectionPool.setAuthToken(token);
4142
+ }
4143
+ /**
4144
+ * Send operation with automatic routing (legacy API for cluster operations).
4145
+ * @deprecated Use send(data, key) for IConnectionProvider interface
4146
+ */
4147
+ sendMessage(key, message) {
4148
+ if (this.config.routingMode === "direct" && this.routingActive) {
4149
+ return this.sendDirect(key, message);
4150
+ }
4151
+ return this.sendForward(message);
4152
+ }
4153
+ /**
4154
+ * Send directly to partition owner
4155
+ */
4156
+ sendDirect(key, message) {
4157
+ const connection = this.partitionRouter.routeToConnection(key);
4158
+ if (!connection) {
4159
+ logger.warn({ key }, "No route available for key");
4160
+ return false;
4161
+ }
4162
+ const routedMessage = {
4163
+ ...message,
4164
+ _routing: {
4165
+ partitionId: this.partitionRouter.getPartitionId(key),
4166
+ mapVersion: this.partitionRouter.getMapVersion()
4167
+ }
4168
+ };
4169
+ connection.socket.send(serialize3(routedMessage));
4170
+ return true;
4171
+ }
4172
+ /**
4173
+ * Send to primary node for server-side forwarding
4174
+ */
4175
+ sendForward(message) {
4176
+ return this.connectionPool.sendToPrimary(message);
4177
+ }
4178
+ /**
4179
+ * Send batch of operations with routing
4180
+ */
4181
+ sendBatch(operations) {
4182
+ const results = /* @__PURE__ */ new Map();
4183
+ if (this.config.routingMode === "direct" && this.routingActive) {
4184
+ const nodeMessages = /* @__PURE__ */ new Map();
4185
+ for (const { key, message } of operations) {
4186
+ const routing = this.partitionRouter.route(key);
4187
+ const nodeId = routing?.nodeId ?? "primary";
4188
+ if (!nodeMessages.has(nodeId)) {
4189
+ nodeMessages.set(nodeId, []);
4190
+ }
4191
+ nodeMessages.get(nodeId).push({ key, message });
4192
+ }
4193
+ for (const [nodeId, messages] of nodeMessages) {
4194
+ let success;
4195
+ if (nodeId === "primary") {
4196
+ success = this.connectionPool.sendToPrimary({
4197
+ type: "OP_BATCH",
4198
+ payload: { ops: messages.map((m) => m.message) }
4199
+ });
4200
+ } else {
4201
+ success = this.connectionPool.send(nodeId, {
4202
+ type: "OP_BATCH",
4203
+ payload: { ops: messages.map((m) => m.message) }
4204
+ });
4205
+ }
4206
+ for (const { key } of messages) {
4207
+ results.set(key, success);
4208
+ }
4209
+ }
4210
+ } else {
4211
+ const success = this.connectionPool.sendToPrimary({
4212
+ type: "OP_BATCH",
4213
+ payload: { ops: operations.map((o) => o.message) }
1491
4214
  });
4215
+ for (const { key } of operations) {
4216
+ results.set(key, success);
4217
+ }
1492
4218
  }
1493
4219
  return results;
1494
4220
  }
1495
- getFilter() {
1496
- return this.filter;
4221
+ /**
4222
+ * Get connection pool health status
4223
+ */
4224
+ getHealthStatus() {
4225
+ return this.connectionPool.getHealthStatus();
1497
4226
  }
1498
- getMapName() {
1499
- return this.mapName;
4227
+ /**
4228
+ * Get partition router stats
4229
+ */
4230
+ getRouterStats() {
4231
+ return this.partitionRouter.getStats();
1500
4232
  }
1501
- };
1502
-
1503
- // src/DistributedLock.ts
1504
- var DistributedLock = class {
1505
- constructor(syncEngine, name) {
1506
- this.fencingToken = null;
1507
- this._isLocked = false;
1508
- this.syncEngine = syncEngine;
1509
- this.name = name;
4233
+ /**
4234
+ * Get routing metrics for monitoring smart routing effectiveness.
4235
+ */
4236
+ getRoutingMetrics() {
4237
+ return { ...this.routingMetrics };
1510
4238
  }
1511
- async lock(ttl = 1e4) {
1512
- const requestId = crypto.randomUUID();
1513
- try {
1514
- const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
1515
- this.fencingToken = result.fencingToken;
1516
- this._isLocked = true;
1517
- return true;
1518
- } catch (e) {
1519
- return false;
1520
- }
4239
+ /**
4240
+ * Reset routing metrics counters.
4241
+ * Useful for monitoring intervals.
4242
+ */
4243
+ resetRoutingMetrics() {
4244
+ this.routingMetrics.directRoutes = 0;
4245
+ this.routingMetrics.fallbackRoutes = 0;
4246
+ this.routingMetrics.partitionMisses = 0;
4247
+ this.routingMetrics.totalRoutes = 0;
1521
4248
  }
1522
- async unlock() {
1523
- if (!this._isLocked || this.fencingToken === null) return;
1524
- const requestId = crypto.randomUUID();
1525
- try {
1526
- await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
1527
- } finally {
1528
- this._isLocked = false;
1529
- this.fencingToken = null;
1530
- }
4249
+ /**
4250
+ * Check if cluster routing is active
4251
+ */
4252
+ isRoutingActive() {
4253
+ return this.routingActive;
1531
4254
  }
1532
- isLocked() {
1533
- return this._isLocked;
4255
+ /**
4256
+ * Get list of connected nodes
4257
+ */
4258
+ getConnectedNodes() {
4259
+ return this.connectionPool.getConnectedNodes();
1534
4260
  }
1535
- };
1536
-
1537
- // src/TopicHandle.ts
1538
- var TopicHandle = class {
1539
- constructor(engine, topic) {
1540
- this.listeners = /* @__PURE__ */ new Set();
1541
- this.engine = engine;
1542
- this.topic = topic;
4261
+ /**
4262
+ * Check if cluster client is initialized
4263
+ */
4264
+ isInitialized() {
4265
+ return this.initialized;
1543
4266
  }
1544
- get id() {
1545
- return this.topic;
4267
+ /**
4268
+ * Force refresh of partition map
4269
+ */
4270
+ async refreshPartitionMap() {
4271
+ await this.partitionRouter.refreshPartitionMap();
1546
4272
  }
1547
4273
  /**
1548
- * Publish a message to the topic
4274
+ * Shutdown cluster client (IConnectionProvider interface).
1549
4275
  */
1550
- publish(data) {
1551
- this.engine.publishTopic(this.topic, data);
4276
+ async close() {
4277
+ this.partitionRouter.close();
4278
+ this.connectionPool.close();
4279
+ this.initialized = false;
4280
+ this.routingActive = false;
4281
+ logger.info("Cluster client closed");
4282
+ }
4283
+ // ============================================
4284
+ // Internal Access for TopGunClient
4285
+ // ============================================
4286
+ /**
4287
+ * Get the connection pool (for internal use)
4288
+ */
4289
+ getConnectionPool() {
4290
+ return this.connectionPool;
1552
4291
  }
1553
4292
  /**
1554
- * Subscribe to the topic
4293
+ * Get the partition router (for internal use)
1555
4294
  */
1556
- subscribe(callback) {
1557
- if (this.listeners.size === 0) {
1558
- this.engine.subscribeToTopic(this.topic, this);
4295
+ getPartitionRouter() {
4296
+ return this.partitionRouter;
4297
+ }
4298
+ /**
4299
+ * Get any healthy WebSocket connection (IConnectionProvider interface).
4300
+ * @throws Error if not connected
4301
+ */
4302
+ getAnyConnection() {
4303
+ const conn = this.connectionPool.getAnyHealthyConnection();
4304
+ if (!conn?.socket) {
4305
+ throw new Error("No healthy connection available");
1559
4306
  }
1560
- this.listeners.add(callback);
1561
- return () => this.unsubscribe(callback);
4307
+ return conn.socket;
1562
4308
  }
1563
- unsubscribe(callback) {
1564
- this.listeners.delete(callback);
1565
- if (this.listeners.size === 0) {
1566
- this.engine.unsubscribeFromTopic(this.topic);
4309
+ /**
4310
+ * Get any healthy WebSocket connection, or null if none available.
4311
+ * Use this for optional connection checks.
4312
+ */
4313
+ getAnyConnectionOrNull() {
4314
+ const conn = this.connectionPool.getAnyHealthyConnection();
4315
+ return conn?.socket ?? null;
4316
+ }
4317
+ // ============================================
4318
+ // Circuit Breaker Methods
4319
+ // ============================================
4320
+ /**
4321
+ * Get circuit breaker state for a node.
4322
+ */
4323
+ getCircuit(nodeId) {
4324
+ let circuit = this.circuits.get(nodeId);
4325
+ if (!circuit) {
4326
+ circuit = { failures: 0, lastFailure: 0, state: "closed" };
4327
+ this.circuits.set(nodeId, circuit);
1567
4328
  }
4329
+ return circuit;
1568
4330
  }
1569
4331
  /**
1570
- * Called by SyncEngine when a message is received
4332
+ * Check if a node can be used (circuit not open).
1571
4333
  */
1572
- onMessage(data, context) {
1573
- this.listeners.forEach((cb) => {
1574
- try {
1575
- cb(data, context);
1576
- } catch (e) {
1577
- console.error("Error in topic listener", e);
4334
+ canUseNode(nodeId) {
4335
+ const circuit = this.getCircuit(nodeId);
4336
+ if (circuit.state === "closed") {
4337
+ return true;
4338
+ }
4339
+ if (circuit.state === "open") {
4340
+ if (Date.now() - circuit.lastFailure > this.circuitBreakerConfig.resetTimeoutMs) {
4341
+ circuit.state = "half-open";
4342
+ logger.debug({ nodeId }, "Circuit breaker half-open, allowing test request");
4343
+ this.emit("circuit:half-open", nodeId);
4344
+ return true;
4345
+ }
4346
+ return false;
4347
+ }
4348
+ return true;
4349
+ }
4350
+ /**
4351
+ * Record a successful operation to a node.
4352
+ * Resets circuit breaker on success.
4353
+ */
4354
+ recordSuccess(nodeId) {
4355
+ const circuit = this.getCircuit(nodeId);
4356
+ const wasOpen = circuit.state !== "closed";
4357
+ circuit.failures = 0;
4358
+ circuit.state = "closed";
4359
+ if (wasOpen) {
4360
+ logger.info({ nodeId }, "Circuit breaker closed after success");
4361
+ this.emit("circuit:closed", nodeId);
4362
+ }
4363
+ }
4364
+ /**
4365
+ * Record a failed operation to a node.
4366
+ * Opens circuit breaker after threshold failures.
4367
+ */
4368
+ recordFailure(nodeId) {
4369
+ const circuit = this.getCircuit(nodeId);
4370
+ circuit.failures++;
4371
+ circuit.lastFailure = Date.now();
4372
+ if (circuit.failures >= this.circuitBreakerConfig.failureThreshold) {
4373
+ if (circuit.state !== "open") {
4374
+ circuit.state = "open";
4375
+ logger.warn({ nodeId, failures: circuit.failures }, "Circuit breaker opened");
4376
+ this.emit("circuit:open", nodeId);
4377
+ }
4378
+ }
4379
+ }
4380
+ /**
4381
+ * Get all circuit breaker states.
4382
+ */
4383
+ getCircuitStates() {
4384
+ return new Map(this.circuits);
4385
+ }
4386
+ /**
4387
+ * Reset circuit breaker for a specific node.
4388
+ */
4389
+ resetCircuit(nodeId) {
4390
+ this.circuits.delete(nodeId);
4391
+ logger.debug({ nodeId }, "Circuit breaker reset");
4392
+ }
4393
+ /**
4394
+ * Reset all circuit breakers.
4395
+ */
4396
+ resetAllCircuits() {
4397
+ this.circuits.clear();
4398
+ logger.debug("All circuit breakers reset");
4399
+ }
4400
+ // ============================================
4401
+ // Private Methods
4402
+ // ============================================
4403
+ setupEventHandlers() {
4404
+ this.connectionPool.on("node:connected", (nodeId) => {
4405
+ logger.debug({ nodeId }, "Node connected");
4406
+ if (this.partitionRouter.getMapVersion() === 0) {
4407
+ this.requestPartitionMapFromNode(nodeId);
4408
+ }
4409
+ if (this.connectionPool.getConnectedNodes().length === 1) {
4410
+ this.emit("connected");
4411
+ }
4412
+ });
4413
+ this.connectionPool.on("node:disconnected", (nodeId, reason) => {
4414
+ logger.debug({ nodeId, reason }, "Node disconnected");
4415
+ if (this.connectionPool.getConnectedNodes().length === 0) {
4416
+ this.routingActive = false;
4417
+ this.emit("disconnected", reason);
4418
+ }
4419
+ });
4420
+ this.connectionPool.on("node:unhealthy", (nodeId, reason) => {
4421
+ logger.warn({ nodeId, reason }, "Node unhealthy");
4422
+ });
4423
+ this.connectionPool.on("error", (nodeId, error) => {
4424
+ this.emit("error", error);
4425
+ });
4426
+ this.connectionPool.on("message", (nodeId, data) => {
4427
+ this.emit("message", nodeId, data);
4428
+ });
4429
+ this.partitionRouter.on("partitionMap:updated", (version, changesCount) => {
4430
+ if (!this.routingActive && this.partitionRouter.hasPartitionMap()) {
4431
+ this.routingActive = true;
4432
+ logger.info({ version }, "Direct routing activated");
4433
+ this.emit("routing:active");
1578
4434
  }
4435
+ this.emit("partitionMap:ready", version);
4436
+ this.emit("partitionMapUpdated");
4437
+ });
4438
+ this.partitionRouter.on("routing:miss", (key, expected, actual) => {
4439
+ logger.debug({ key, expected, actual }, "Routing miss detected");
4440
+ });
4441
+ }
4442
+ async waitForPartitionMap(timeoutMs = 1e4) {
4443
+ if (this.partitionRouter.hasPartitionMap()) {
4444
+ this.routingActive = true;
4445
+ return;
4446
+ }
4447
+ return new Promise((resolve) => {
4448
+ const timeout = setTimeout(() => {
4449
+ this.partitionRouter.off("partitionMap:updated", onUpdate);
4450
+ logger.warn("Partition map not received, using fallback routing");
4451
+ resolve();
4452
+ }, timeoutMs);
4453
+ const onUpdate = () => {
4454
+ clearTimeout(timeout);
4455
+ this.partitionRouter.off("partitionMap:updated", onUpdate);
4456
+ this.routingActive = true;
4457
+ resolve();
4458
+ };
4459
+ this.partitionRouter.once("partitionMap:updated", onUpdate);
1579
4460
  });
1580
4461
  }
1581
4462
  };
1582
4463
 
1583
4464
  // src/TopGunClient.ts
4465
+ var DEFAULT_CLUSTER_CONFIG = {
4466
+ connectionsPerNode: 1,
4467
+ smartRouting: true,
4468
+ partitionMapRefreshMs: 3e4,
4469
+ connectionTimeoutMs: 5e3,
4470
+ retryAttempts: 3
4471
+ };
1584
4472
  var TopGunClient = class {
1585
4473
  constructor(config) {
1586
4474
  this.maps = /* @__PURE__ */ new Map();
1587
4475
  this.topicHandles = /* @__PURE__ */ new Map();
4476
+ this.counters = /* @__PURE__ */ new Map();
4477
+ if (config.serverUrl && config.cluster) {
4478
+ throw new Error("Cannot specify both serverUrl and cluster config");
4479
+ }
4480
+ if (!config.serverUrl && !config.cluster) {
4481
+ throw new Error("Must specify either serverUrl or cluster config");
4482
+ }
1588
4483
  this.nodeId = config.nodeId || crypto.randomUUID();
1589
4484
  this.storageAdapter = config.storage;
1590
- const syncEngineConfig = {
1591
- nodeId: this.nodeId,
1592
- serverUrl: config.serverUrl,
1593
- storageAdapter: this.storageAdapter,
1594
- backoff: config.backoff,
1595
- backpressure: config.backpressure
1596
- };
1597
- this.syncEngine = new SyncEngine(syncEngineConfig);
4485
+ this.isClusterMode = !!config.cluster;
4486
+ if (config.cluster) {
4487
+ if (!config.cluster.seeds || config.cluster.seeds.length === 0) {
4488
+ throw new Error("Cluster config requires at least one seed node");
4489
+ }
4490
+ this.clusterConfig = {
4491
+ seeds: config.cluster.seeds,
4492
+ connectionsPerNode: config.cluster.connectionsPerNode ?? DEFAULT_CLUSTER_CONFIG.connectionsPerNode,
4493
+ smartRouting: config.cluster.smartRouting ?? DEFAULT_CLUSTER_CONFIG.smartRouting,
4494
+ partitionMapRefreshMs: config.cluster.partitionMapRefreshMs ?? DEFAULT_CLUSTER_CONFIG.partitionMapRefreshMs,
4495
+ connectionTimeoutMs: config.cluster.connectionTimeoutMs ?? DEFAULT_CLUSTER_CONFIG.connectionTimeoutMs,
4496
+ retryAttempts: config.cluster.retryAttempts ?? DEFAULT_CLUSTER_CONFIG.retryAttempts
4497
+ };
4498
+ this.clusterClient = new ClusterClient({
4499
+ enabled: true,
4500
+ seedNodes: this.clusterConfig.seeds,
4501
+ routingMode: this.clusterConfig.smartRouting ? "direct" : "forward",
4502
+ connectionPool: {
4503
+ maxConnectionsPerNode: this.clusterConfig.connectionsPerNode,
4504
+ connectionTimeoutMs: this.clusterConfig.connectionTimeoutMs
4505
+ },
4506
+ routing: {
4507
+ mapRefreshIntervalMs: this.clusterConfig.partitionMapRefreshMs
4508
+ }
4509
+ });
4510
+ this.syncEngine = new SyncEngine({
4511
+ nodeId: this.nodeId,
4512
+ connectionProvider: this.clusterClient,
4513
+ storageAdapter: this.storageAdapter,
4514
+ backoff: config.backoff,
4515
+ backpressure: config.backpressure
4516
+ });
4517
+ logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
4518
+ } else {
4519
+ this.syncEngine = new SyncEngine({
4520
+ nodeId: this.nodeId,
4521
+ serverUrl: config.serverUrl,
4522
+ storageAdapter: this.storageAdapter,
4523
+ backoff: config.backoff,
4524
+ backpressure: config.backpressure
4525
+ });
4526
+ logger.info({ serverUrl: config.serverUrl }, "TopGunClient initialized in single-server mode");
4527
+ }
1598
4528
  }
1599
4529
  async start() {
1600
4530
  await this.storageAdapter.initialize("topgun_offline_db");
@@ -1628,6 +4558,34 @@ var TopGunClient = class {
1628
4558
  }
1629
4559
  return this.topicHandles.get(name);
1630
4560
  }
4561
+ /**
4562
+ * Retrieves a PN Counter instance. If the counter doesn't exist locally, it's created.
4563
+ * PN Counters support increment and decrement operations that work offline
4564
+ * and sync to server when connected.
4565
+ *
4566
+ * @param name The name of the counter (e.g., 'likes:post-123')
4567
+ * @returns A PNCounterHandle instance
4568
+ *
4569
+ * @example
4570
+ * ```typescript
4571
+ * const likes = client.getPNCounter('likes:post-123');
4572
+ * likes.increment(); // +1
4573
+ * likes.decrement(); // -1
4574
+ * likes.addAndGet(10); // +10
4575
+ *
4576
+ * likes.subscribe((value) => {
4577
+ * console.log('Current likes:', value);
4578
+ * });
4579
+ * ```
4580
+ */
4581
+ getPNCounter(name) {
4582
+ let counter = this.counters.get(name);
4583
+ if (!counter) {
4584
+ counter = new PNCounterHandle(name, this.nodeId, this.syncEngine, this.storageAdapter);
4585
+ this.counters.set(name, counter);
4586
+ }
4587
+ return counter;
4588
+ }
1631
4589
  /**
1632
4590
  * Retrieves an LWWMap instance. If the map doesn't exist locally, it's created.
1633
4591
  * @param name The name of the map.
@@ -1754,9 +4712,69 @@ var TopGunClient = class {
1754
4712
  * Closes the client, disconnecting from the server and cleaning up resources.
1755
4713
  */
1756
4714
  close() {
4715
+ if (this.clusterClient) {
4716
+ this.clusterClient.close();
4717
+ }
1757
4718
  this.syncEngine.close();
1758
4719
  }
1759
4720
  // ============================================
4721
+ // Cluster Mode API
4722
+ // ============================================
4723
+ /**
4724
+ * Check if running in cluster mode
4725
+ */
4726
+ isCluster() {
4727
+ return this.isClusterMode;
4728
+ }
4729
+ /**
4730
+ * Get list of connected cluster nodes (cluster mode only)
4731
+ * @returns Array of connected node IDs, or empty array in single-server mode
4732
+ */
4733
+ getConnectedNodes() {
4734
+ if (!this.clusterClient) return [];
4735
+ return this.clusterClient.getConnectedNodes();
4736
+ }
4737
+ /**
4738
+ * Get the current partition map version (cluster mode only)
4739
+ * @returns Partition map version, or 0 in single-server mode
4740
+ */
4741
+ getPartitionMapVersion() {
4742
+ if (!this.clusterClient) return 0;
4743
+ return this.clusterClient.getRouterStats().mapVersion;
4744
+ }
4745
+ /**
4746
+ * Check if direct routing is active (cluster mode only)
4747
+ * Direct routing sends operations directly to partition owners.
4748
+ * @returns true if routing is active, false otherwise
4749
+ */
4750
+ isRoutingActive() {
4751
+ if (!this.clusterClient) return false;
4752
+ return this.clusterClient.isRoutingActive();
4753
+ }
4754
+ /**
4755
+ * Get health status for all cluster nodes (cluster mode only)
4756
+ * @returns Map of node IDs to their health status
4757
+ */
4758
+ getClusterHealth() {
4759
+ if (!this.clusterClient) return /* @__PURE__ */ new Map();
4760
+ return this.clusterClient.getHealthStatus();
4761
+ }
4762
+ /**
4763
+ * Force refresh of partition map (cluster mode only)
4764
+ * Use this after detecting routing errors.
4765
+ */
4766
+ async refreshPartitionMap() {
4767
+ if (!this.clusterClient) return;
4768
+ await this.clusterClient.refreshPartitionMap();
4769
+ }
4770
+ /**
4771
+ * Get cluster router statistics (cluster mode only)
4772
+ */
4773
+ getClusterStats() {
4774
+ if (!this.clusterClient) return null;
4775
+ return this.clusterClient.getRouterStats();
4776
+ }
4777
+ // ============================================
1760
4778
  // Connection State API
1761
4779
  // ============================================
1762
4780
  /**
@@ -1840,6 +4858,175 @@ var TopGunClient = class {
1840
4858
  onBackpressure(event, listener) {
1841
4859
  return this.syncEngine.onBackpressure(event, listener);
1842
4860
  }
4861
+ // ============================================
4862
+ // Entry Processor API (Phase 5.03)
4863
+ // ============================================
4864
+ /**
4865
+ * Execute an entry processor on a single key atomically.
4866
+ *
4867
+ * Entry processors solve the read-modify-write race condition by executing
4868
+ * user-defined logic atomically on the server where the data lives.
4869
+ *
4870
+ * @param mapName Name of the map
4871
+ * @param key Key to process
4872
+ * @param processor Processor definition with name, code, and optional args
4873
+ * @returns Promise resolving to the processor result
4874
+ *
4875
+ * @example
4876
+ * ```typescript
4877
+ * // Increment a counter atomically
4878
+ * const result = await client.executeOnKey('stats', 'pageViews', {
4879
+ * name: 'increment',
4880
+ * code: `
4881
+ * const current = value ?? 0;
4882
+ * return { value: current + 1, result: current + 1 };
4883
+ * `,
4884
+ * });
4885
+ *
4886
+ * // Using built-in processor
4887
+ * import { BuiltInProcessors } from '@topgunbuild/core';
4888
+ * const result = await client.executeOnKey(
4889
+ * 'stats',
4890
+ * 'pageViews',
4891
+ * BuiltInProcessors.INCREMENT(1)
4892
+ * );
4893
+ * ```
4894
+ */
4895
+ async executeOnKey(mapName, key, processor) {
4896
+ const result = await this.syncEngine.executeOnKey(mapName, key, processor);
4897
+ if (result.success && result.newValue !== void 0) {
4898
+ const map = this.maps.get(mapName);
4899
+ if (map instanceof LWWMap2) {
4900
+ map.set(key, result.newValue);
4901
+ }
4902
+ }
4903
+ return result;
4904
+ }
4905
+ /**
4906
+ * Execute an entry processor on multiple keys.
4907
+ *
4908
+ * Each key is processed atomically. The operation returns when all keys
4909
+ * have been processed.
4910
+ *
4911
+ * @param mapName Name of the map
4912
+ * @param keys Keys to process
4913
+ * @param processor Processor definition
4914
+ * @returns Promise resolving to a map of key -> result
4915
+ *
4916
+ * @example
4917
+ * ```typescript
4918
+ * // Reset multiple counters
4919
+ * const results = await client.executeOnKeys(
4920
+ * 'stats',
4921
+ * ['pageViews', 'uniqueVisitors', 'bounceRate'],
4922
+ * {
4923
+ * name: 'reset',
4924
+ * code: `return { value: 0, result: value };`, // Returns old value
4925
+ * }
4926
+ * );
4927
+ *
4928
+ * for (const [key, result] of results) {
4929
+ * console.log(`${key}: was ${result.result}, now 0`);
4930
+ * }
4931
+ * ```
4932
+ */
4933
+ async executeOnKeys(mapName, keys, processor) {
4934
+ const results = await this.syncEngine.executeOnKeys(mapName, keys, processor);
4935
+ const map = this.maps.get(mapName);
4936
+ if (map instanceof LWWMap2) {
4937
+ for (const [key, result] of results) {
4938
+ if (result.success && result.newValue !== void 0) {
4939
+ map.set(key, result.newValue);
4940
+ }
4941
+ }
4942
+ }
4943
+ return results;
4944
+ }
4945
+ /**
4946
+ * Get the Event Journal reader for subscribing to and reading
4947
+ * map change events.
4948
+ *
4949
+ * The Event Journal provides:
4950
+ * - Append-only log of all map changes (PUT, UPDATE, DELETE)
4951
+ * - Subscription to real-time events
4952
+ * - Historical event replay
4953
+ * - Audit trail for compliance
4954
+ *
4955
+ * @returns EventJournalReader instance
4956
+ *
4957
+ * @example
4958
+ * ```typescript
4959
+ * const journal = client.getEventJournal();
4960
+ *
4961
+ * // Subscribe to all events
4962
+ * const unsubscribe = journal.subscribe((event) => {
4963
+ * console.log(`${event.type} on ${event.mapName}:${event.key}`);
4964
+ * });
4965
+ *
4966
+ * // Subscribe to specific map
4967
+ * journal.subscribe(
4968
+ * (event) => console.log('User changed:', event.key),
4969
+ * { mapName: 'users' }
4970
+ * );
4971
+ *
4972
+ * // Read historical events
4973
+ * const events = await journal.readFrom(0n, 100);
4974
+ * ```
4975
+ */
4976
+ getEventJournal() {
4977
+ if (!this.journalReader) {
4978
+ this.journalReader = new EventJournalReader(this.syncEngine);
4979
+ }
4980
+ return this.journalReader;
4981
+ }
4982
+ // ============================================
4983
+ // Conflict Resolver API (Phase 5.05)
4984
+ // ============================================
4985
+ /**
4986
+ * Get the conflict resolver client for registering custom merge resolvers.
4987
+ *
4988
+ * Conflict resolvers allow you to customize how merge conflicts are handled
4989
+ * on the server. You can implement business logic like:
4990
+ * - First-write-wins for booking systems
4991
+ * - Numeric constraints (non-negative, min/max)
4992
+ * - Owner-only modifications
4993
+ * - Custom merge strategies
4994
+ *
4995
+ * @returns ConflictResolverClient instance
4996
+ *
4997
+ * @example
4998
+ * ```typescript
4999
+ * const resolvers = client.getConflictResolvers();
5000
+ *
5001
+ * // Register a first-write-wins resolver
5002
+ * await resolvers.register('bookings', {
5003
+ * name: 'first-write-wins',
5004
+ * code: `
5005
+ * if (context.localValue !== undefined) {
5006
+ * return { action: 'reject', reason: 'Slot already booked' };
5007
+ * }
5008
+ * return { action: 'accept', value: context.remoteValue };
5009
+ * `,
5010
+ * priority: 100,
5011
+ * });
5012
+ *
5013
+ * // Subscribe to merge rejections
5014
+ * resolvers.onRejection((rejection) => {
5015
+ * console.log(`Merge rejected: ${rejection.reason}`);
5016
+ * // Optionally refresh local state
5017
+ * });
5018
+ *
5019
+ * // List registered resolvers
5020
+ * const registered = await resolvers.list('bookings');
5021
+ * console.log('Active resolvers:', registered);
5022
+ *
5023
+ * // Unregister when done
5024
+ * await resolvers.unregister('bookings', 'first-write-wins');
5025
+ * ```
5026
+ */
5027
+ getConflictResolvers() {
5028
+ return this.syncEngine.getConflictResolverClient();
5029
+ }
1843
5030
  };
1844
5031
 
1845
5032
  // src/adapters/IDBAdapter.ts
@@ -2107,14 +5294,14 @@ var CollectionWrapper = class {
2107
5294
  };
2108
5295
 
2109
5296
  // src/crypto/EncryptionManager.ts
2110
- import { serialize as serialize2, deserialize as deserialize2 } from "@topgunbuild/core";
5297
+ import { serialize as serialize4, deserialize as deserialize3 } from "@topgunbuild/core";
2111
5298
  var _EncryptionManager = class _EncryptionManager {
2112
5299
  /**
2113
5300
  * Encrypts data using AES-GCM.
2114
5301
  * Serializes data to MessagePack before encryption.
2115
5302
  */
2116
5303
  static async encrypt(key, data) {
2117
- const encoded = serialize2(data);
5304
+ const encoded = serialize4(data);
2118
5305
  const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
2119
5306
  const ciphertext = await window.crypto.subtle.encrypt(
2120
5307
  {
@@ -2143,7 +5330,7 @@ var _EncryptionManager = class _EncryptionManager {
2143
5330
  key,
2144
5331
  record.data
2145
5332
  );
2146
- return deserialize2(new Uint8Array(plaintextBuffer));
5333
+ return deserialize3(new Uint8Array(plaintextBuffer));
2147
5334
  } catch (err) {
2148
5335
  console.error("Decryption failed", err);
2149
5336
  throw new Error("Failed to decrypt data: " + err);
@@ -2270,12 +5457,21 @@ var EncryptedStorageAdapter = class {
2270
5457
  import { LWWMap as LWWMap3, Predicates } from "@topgunbuild/core";
2271
5458
  export {
2272
5459
  BackpressureError,
5460
+ ChangeTracker,
5461
+ ClusterClient,
5462
+ ConflictResolverClient,
5463
+ ConnectionPool,
2273
5464
  DEFAULT_BACKPRESSURE_CONFIG,
5465
+ DEFAULT_CLUSTER_CONFIG,
2274
5466
  EncryptedStorageAdapter,
5467
+ EventJournalReader,
2275
5468
  IDBAdapter,
2276
5469
  LWWMap3 as LWWMap,
5470
+ PNCounterHandle,
5471
+ PartitionRouter,
2277
5472
  Predicates,
2278
5473
  QueryHandle,
5474
+ SingleServerProvider,
2279
5475
  SyncEngine,
2280
5476
  SyncState,
2281
5477
  SyncStateMachine,