@topgunbuild/client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -31,15 +31,19 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  BackpressureError: () => BackpressureError,
34
+ ChangeTracker: () => ChangeTracker,
34
35
  ClusterClient: () => ClusterClient,
36
+ ConflictResolverClient: () => ConflictResolverClient,
35
37
  ConnectionPool: () => ConnectionPool,
36
38
  DEFAULT_BACKPRESSURE_CONFIG: () => DEFAULT_BACKPRESSURE_CONFIG,
37
39
  DEFAULT_CLUSTER_CONFIG: () => DEFAULT_CLUSTER_CONFIG,
38
40
  EncryptedStorageAdapter: () => EncryptedStorageAdapter,
41
+ EventJournalReader: () => EventJournalReader,
39
42
  IDBAdapter: () => IDBAdapter,
40
- LWWMap: () => import_core8.LWWMap,
43
+ LWWMap: () => import_core9.LWWMap,
44
+ PNCounterHandle: () => PNCounterHandle,
41
45
  PartitionRouter: () => PartitionRouter,
42
- Predicates: () => import_core8.Predicates,
46
+ Predicates: () => import_core9.Predicates,
43
47
  QueryHandle: () => QueryHandle,
44
48
  SingleServerProvider: () => SingleServerProvider,
45
49
  SyncEngine: () => SyncEngine,
@@ -486,6 +490,237 @@ var SingleServerProvider = class {
486
490
  }
487
491
  };
488
492
 
493
+ // src/ConflictResolverClient.ts
494
+ var _ConflictResolverClient = class _ConflictResolverClient {
495
+ // 10 seconds
496
+ constructor(syncEngine) {
497
+ this.rejectionListeners = /* @__PURE__ */ new Set();
498
+ this.pendingRequests = /* @__PURE__ */ new Map();
499
+ this.syncEngine = syncEngine;
500
+ }
501
+ /**
502
+ * Register a conflict resolver on the server.
503
+ *
504
+ * @param mapName The map to register the resolver for
505
+ * @param resolver The resolver definition
506
+ * @returns Promise resolving to registration result
507
+ *
508
+ * @example
509
+ * ```typescript
510
+ * // Register a first-write-wins resolver for bookings
511
+ * await client.resolvers.register('bookings', {
512
+ * name: 'first-write-wins',
513
+ * code: `
514
+ * if (context.localValue !== undefined) {
515
+ * return { action: 'reject', reason: 'Slot already booked' };
516
+ * }
517
+ * return { action: 'accept', value: context.remoteValue };
518
+ * `,
519
+ * priority: 100,
520
+ * });
521
+ * ```
522
+ */
523
+ async register(mapName, resolver) {
524
+ const requestId = crypto.randomUUID();
525
+ return new Promise((resolve, reject) => {
526
+ const timeout = setTimeout(() => {
527
+ this.pendingRequests.delete(requestId);
528
+ reject(new Error("Register resolver request timed out"));
529
+ }, _ConflictResolverClient.REQUEST_TIMEOUT);
530
+ this.pendingRequests.set(requestId, {
531
+ resolve: (result) => {
532
+ clearTimeout(timeout);
533
+ resolve(result);
534
+ },
535
+ reject,
536
+ timeout
537
+ });
538
+ try {
539
+ this.syncEngine.send({
540
+ type: "REGISTER_RESOLVER",
541
+ requestId,
542
+ mapName,
543
+ resolver: {
544
+ name: resolver.name,
545
+ code: resolver.code || "",
546
+ priority: resolver.priority,
547
+ keyPattern: resolver.keyPattern
548
+ }
549
+ });
550
+ } catch {
551
+ this.pendingRequests.delete(requestId);
552
+ clearTimeout(timeout);
553
+ resolve({ success: false, error: "Not connected to server" });
554
+ }
555
+ });
556
+ }
557
+ /**
558
+ * Unregister a conflict resolver from the server.
559
+ *
560
+ * @param mapName The map the resolver is registered for
561
+ * @param resolverName The name of the resolver to unregister
562
+ * @returns Promise resolving to unregistration result
563
+ */
564
+ async unregister(mapName, resolverName) {
565
+ const requestId = crypto.randomUUID();
566
+ return new Promise((resolve, reject) => {
567
+ const timeout = setTimeout(() => {
568
+ this.pendingRequests.delete(requestId);
569
+ reject(new Error("Unregister resolver request timed out"));
570
+ }, _ConflictResolverClient.REQUEST_TIMEOUT);
571
+ this.pendingRequests.set(requestId, {
572
+ resolve: (result) => {
573
+ clearTimeout(timeout);
574
+ resolve(result);
575
+ },
576
+ reject,
577
+ timeout
578
+ });
579
+ try {
580
+ this.syncEngine.send({
581
+ type: "UNREGISTER_RESOLVER",
582
+ requestId,
583
+ mapName,
584
+ resolverName
585
+ });
586
+ } catch {
587
+ this.pendingRequests.delete(requestId);
588
+ clearTimeout(timeout);
589
+ resolve({ success: false, error: "Not connected to server" });
590
+ }
591
+ });
592
+ }
593
+ /**
594
+ * List registered conflict resolvers on the server.
595
+ *
596
+ * @param mapName Optional - filter by map name
597
+ * @returns Promise resolving to list of resolver info
598
+ */
599
+ async list(mapName) {
600
+ const requestId = crypto.randomUUID();
601
+ return new Promise((resolve, reject) => {
602
+ const timeout = setTimeout(() => {
603
+ this.pendingRequests.delete(requestId);
604
+ reject(new Error("List resolvers request timed out"));
605
+ }, _ConflictResolverClient.REQUEST_TIMEOUT);
606
+ this.pendingRequests.set(requestId, {
607
+ resolve: (result) => {
608
+ clearTimeout(timeout);
609
+ resolve(result.resolvers);
610
+ },
611
+ reject,
612
+ timeout
613
+ });
614
+ try {
615
+ this.syncEngine.send({
616
+ type: "LIST_RESOLVERS",
617
+ requestId,
618
+ mapName
619
+ });
620
+ } catch {
621
+ this.pendingRequests.delete(requestId);
622
+ clearTimeout(timeout);
623
+ resolve([]);
624
+ }
625
+ });
626
+ }
627
+ /**
628
+ * Subscribe to merge rejection events.
629
+ *
630
+ * @param listener Callback for rejection events
631
+ * @returns Unsubscribe function
632
+ *
633
+ * @example
634
+ * ```typescript
635
+ * const unsubscribe = client.resolvers.onRejection((rejection) => {
636
+ * console.log(`Merge rejected for ${rejection.key}: ${rejection.reason}`);
637
+ * // Optionally refresh the local value
638
+ * });
639
+ *
640
+ * // Later...
641
+ * unsubscribe();
642
+ * ```
643
+ */
644
+ onRejection(listener) {
645
+ this.rejectionListeners.add(listener);
646
+ return () => this.rejectionListeners.delete(listener);
647
+ }
648
+ /**
649
+ * Handle REGISTER_RESOLVER_RESPONSE from server.
650
+ * Called by SyncEngine.
651
+ */
652
+ handleRegisterResponse(message) {
653
+ const pending = this.pendingRequests.get(message.requestId);
654
+ if (pending) {
655
+ this.pendingRequests.delete(message.requestId);
656
+ pending.resolve({ success: message.success, error: message.error });
657
+ }
658
+ }
659
+ /**
660
+ * Handle UNREGISTER_RESOLVER_RESPONSE from server.
661
+ * Called by SyncEngine.
662
+ */
663
+ handleUnregisterResponse(message) {
664
+ const pending = this.pendingRequests.get(message.requestId);
665
+ if (pending) {
666
+ this.pendingRequests.delete(message.requestId);
667
+ pending.resolve({ success: message.success, error: message.error });
668
+ }
669
+ }
670
+ /**
671
+ * Handle LIST_RESOLVERS_RESPONSE from server.
672
+ * Called by SyncEngine.
673
+ */
674
+ handleListResponse(message) {
675
+ const pending = this.pendingRequests.get(message.requestId);
676
+ if (pending) {
677
+ this.pendingRequests.delete(message.requestId);
678
+ pending.resolve({ resolvers: message.resolvers });
679
+ }
680
+ }
681
+ /**
682
+ * Handle MERGE_REJECTED from server.
683
+ * Called by SyncEngine.
684
+ */
685
+ handleMergeRejected(message) {
686
+ const rejection = {
687
+ mapName: message.mapName,
688
+ key: message.key,
689
+ attemptedValue: message.attemptedValue,
690
+ reason: message.reason,
691
+ timestamp: message.timestamp,
692
+ nodeId: ""
693
+ // Not provided by server in this message
694
+ };
695
+ logger.debug({ rejection }, "Merge rejected by server");
696
+ for (const listener of this.rejectionListeners) {
697
+ try {
698
+ listener(rejection);
699
+ } catch (e) {
700
+ logger.error({ error: e }, "Error in rejection listener");
701
+ }
702
+ }
703
+ }
704
+ /**
705
+ * Clear all pending requests (e.g., on disconnect).
706
+ */
707
+ clearPending() {
708
+ for (const [requestId, pending] of this.pendingRequests) {
709
+ clearTimeout(pending.timeout);
710
+ pending.reject(new Error("Connection lost"));
711
+ }
712
+ this.pendingRequests.clear();
713
+ }
714
+ /**
715
+ * Get the number of registered rejection listeners.
716
+ */
717
+ get rejectionListenerCount() {
718
+ return this.rejectionListeners.size;
719
+ }
720
+ };
721
+ _ConflictResolverClient.REQUEST_TIMEOUT = 1e4;
722
+ var ConflictResolverClient = _ConflictResolverClient;
723
+
489
724
  // src/SyncEngine.ts
490
725
  var DEFAULT_BACKOFF_CONFIG = {
491
726
  initialDelayMs: 1e3,
@@ -494,7 +729,7 @@ var DEFAULT_BACKOFF_CONFIG = {
494
729
  jitter: true,
495
730
  maxRetries: 10
496
731
  };
497
- var SyncEngine = class {
732
+ var _SyncEngine = class _SyncEngine {
498
733
  constructor(config) {
499
734
  this.websocket = null;
500
735
  this.opLog = [];
@@ -517,6 +752,23 @@ var SyncEngine = class {
517
752
  this.backpressureListeners = /* @__PURE__ */ new Map();
518
753
  // Write Concern state (Phase 5.01)
519
754
  this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
755
+ // ============================================
756
+ // PN Counter Methods (Phase 5.2)
757
+ // ============================================
758
+ /** Counter update listeners by name */
759
+ this.counterUpdateListeners = /* @__PURE__ */ new Map();
760
+ // ============================================
761
+ // Entry Processor Methods (Phase 5.03)
762
+ // ============================================
763
+ /** Pending entry processor requests by requestId */
764
+ this.pendingProcessorRequests = /* @__PURE__ */ new Map();
765
+ /** Pending batch entry processor requests by requestId */
766
+ this.pendingBatchProcessorRequests = /* @__PURE__ */ new Map();
767
+ // ============================================
768
+ // Event Journal Methods (Phase 5.04)
769
+ // ============================================
770
+ /** Message listeners for journal and other generic messages */
771
+ this.messageListeners = /* @__PURE__ */ new Set();
520
772
  if (!config.serverUrl && !config.connectionProvider) {
521
773
  throw new Error("SyncEngine requires either serverUrl or connectionProvider");
522
774
  }
@@ -547,6 +799,7 @@ var SyncEngine = class {
547
799
  this.useConnectionProvider = false;
548
800
  this.initConnection();
549
801
  }
802
+ this.conflictResolverClient = new ConflictResolverClient(this);
550
803
  this.loadOpLog();
551
804
  }
552
805
  // ============================================
@@ -1029,6 +1282,7 @@ var SyncEngine = class {
1029
1282
  });
1030
1283
  }
1031
1284
  async handleServerMessage(message) {
1285
+ this.emitMessage(message);
1032
1286
  switch (message.type) {
1033
1287
  case "BATCH": {
1034
1288
  const batchData = message.data;
@@ -1355,6 +1609,51 @@ var SyncEngine = class {
1355
1609
  }
1356
1610
  break;
1357
1611
  }
1612
+ // ============ PN Counter Message Handlers (Phase 5.2) ============
1613
+ case "COUNTER_UPDATE": {
1614
+ const { name, state } = message.payload;
1615
+ logger.debug({ name }, "Received COUNTER_UPDATE");
1616
+ this.handleCounterUpdate(name, state);
1617
+ break;
1618
+ }
1619
+ case "COUNTER_RESPONSE": {
1620
+ const { name, state } = message.payload;
1621
+ logger.debug({ name }, "Received COUNTER_RESPONSE");
1622
+ this.handleCounterUpdate(name, state);
1623
+ break;
1624
+ }
1625
+ // ============ Entry Processor Message Handlers (Phase 5.03) ============
1626
+ case "ENTRY_PROCESS_RESPONSE": {
1627
+ logger.debug({ requestId: message.requestId, success: message.success }, "Received ENTRY_PROCESS_RESPONSE");
1628
+ this.handleEntryProcessResponse(message);
1629
+ break;
1630
+ }
1631
+ case "ENTRY_PROCESS_BATCH_RESPONSE": {
1632
+ logger.debug({ requestId: message.requestId }, "Received ENTRY_PROCESS_BATCH_RESPONSE");
1633
+ this.handleEntryProcessBatchResponse(message);
1634
+ break;
1635
+ }
1636
+ // ============ Conflict Resolver Message Handlers (Phase 5.05) ============
1637
+ case "REGISTER_RESOLVER_RESPONSE": {
1638
+ logger.debug({ requestId: message.requestId, success: message.success }, "Received REGISTER_RESOLVER_RESPONSE");
1639
+ this.conflictResolverClient.handleRegisterResponse(message);
1640
+ break;
1641
+ }
1642
+ case "UNREGISTER_RESOLVER_RESPONSE": {
1643
+ logger.debug({ requestId: message.requestId, success: message.success }, "Received UNREGISTER_RESOLVER_RESPONSE");
1644
+ this.conflictResolverClient.handleUnregisterResponse(message);
1645
+ break;
1646
+ }
1647
+ case "LIST_RESOLVERS_RESPONSE": {
1648
+ logger.debug({ requestId: message.requestId }, "Received LIST_RESOLVERS_RESPONSE");
1649
+ this.conflictResolverClient.handleListResponse(message);
1650
+ break;
1651
+ }
1652
+ case "MERGE_REJECTED": {
1653
+ logger.debug({ mapName: message.mapName, key: message.key, reason: message.reason }, "Received MERGE_REJECTED");
1654
+ this.conflictResolverClient.handleMergeRejected(message);
1655
+ break;
1656
+ }
1358
1657
  }
1359
1658
  if (message.timestamp) {
1360
1659
  this.hlc.update(message.timestamp);
@@ -1868,16 +2167,371 @@ var SyncEngine = class {
1868
2167
  }
1869
2168
  this.pendingWriteConcernPromises.clear();
1870
2169
  }
2170
+ /**
2171
+ * Subscribe to counter updates from server.
2172
+ * @param name Counter name
2173
+ * @param listener Callback when counter state is updated
2174
+ * @returns Unsubscribe function
2175
+ */
2176
+ onCounterUpdate(name, listener) {
2177
+ if (!this.counterUpdateListeners.has(name)) {
2178
+ this.counterUpdateListeners.set(name, /* @__PURE__ */ new Set());
2179
+ }
2180
+ this.counterUpdateListeners.get(name).add(listener);
2181
+ return () => {
2182
+ this.counterUpdateListeners.get(name)?.delete(listener);
2183
+ if (this.counterUpdateListeners.get(name)?.size === 0) {
2184
+ this.counterUpdateListeners.delete(name);
2185
+ }
2186
+ };
2187
+ }
2188
+ /**
2189
+ * Request initial counter state from server.
2190
+ * @param name Counter name
2191
+ */
2192
+ requestCounter(name) {
2193
+ if (this.isAuthenticated()) {
2194
+ this.sendMessage({
2195
+ type: "COUNTER_REQUEST",
2196
+ payload: { name }
2197
+ });
2198
+ }
2199
+ }
2200
+ /**
2201
+ * Sync local counter state to server.
2202
+ * @param name Counter name
2203
+ * @param state Counter state to sync
2204
+ */
2205
+ syncCounter(name, state) {
2206
+ if (this.isAuthenticated()) {
2207
+ const stateObj = {
2208
+ positive: Object.fromEntries(state.positive),
2209
+ negative: Object.fromEntries(state.negative)
2210
+ };
2211
+ this.sendMessage({
2212
+ type: "COUNTER_SYNC",
2213
+ payload: {
2214
+ name,
2215
+ state: stateObj
2216
+ }
2217
+ });
2218
+ }
2219
+ }
2220
+ /**
2221
+ * Handle incoming counter update from server.
2222
+ * Called by handleServerMessage for COUNTER_UPDATE messages.
2223
+ */
2224
+ handleCounterUpdate(name, stateObj) {
2225
+ const state = {
2226
+ positive: new Map(Object.entries(stateObj.positive)),
2227
+ negative: new Map(Object.entries(stateObj.negative))
2228
+ };
2229
+ const listeners = this.counterUpdateListeners.get(name);
2230
+ if (listeners) {
2231
+ for (const listener of listeners) {
2232
+ try {
2233
+ listener(state);
2234
+ } catch (e) {
2235
+ logger.error({ err: e, counterName: name }, "Counter update listener error");
2236
+ }
2237
+ }
2238
+ }
2239
+ }
2240
+ /**
2241
+ * Execute an entry processor on a single key atomically.
2242
+ *
2243
+ * @param mapName Name of the map
2244
+ * @param key Key to process
2245
+ * @param processor Processor definition
2246
+ * @returns Promise resolving to the processor result
2247
+ */
2248
+ async executeOnKey(mapName, key, processor) {
2249
+ if (!this.isAuthenticated()) {
2250
+ return {
2251
+ success: false,
2252
+ error: "Not connected to server"
2253
+ };
2254
+ }
2255
+ const requestId = crypto.randomUUID();
2256
+ return new Promise((resolve, reject) => {
2257
+ const timeout = setTimeout(() => {
2258
+ this.pendingProcessorRequests.delete(requestId);
2259
+ reject(new Error("Entry processor request timed out"));
2260
+ }, _SyncEngine.PROCESSOR_TIMEOUT);
2261
+ this.pendingProcessorRequests.set(requestId, {
2262
+ resolve: (result) => {
2263
+ clearTimeout(timeout);
2264
+ resolve(result);
2265
+ },
2266
+ reject,
2267
+ timeout
2268
+ });
2269
+ const sent = this.sendMessage({
2270
+ type: "ENTRY_PROCESS",
2271
+ requestId,
2272
+ mapName,
2273
+ key,
2274
+ processor: {
2275
+ name: processor.name,
2276
+ code: processor.code,
2277
+ args: processor.args
2278
+ }
2279
+ }, key);
2280
+ if (!sent) {
2281
+ this.pendingProcessorRequests.delete(requestId);
2282
+ clearTimeout(timeout);
2283
+ reject(new Error("Failed to send entry processor request"));
2284
+ }
2285
+ });
2286
+ }
2287
+ /**
2288
+ * Execute an entry processor on multiple keys.
2289
+ *
2290
+ * @param mapName Name of the map
2291
+ * @param keys Keys to process
2292
+ * @param processor Processor definition
2293
+ * @returns Promise resolving to a map of key -> result
2294
+ */
2295
+ async executeOnKeys(mapName, keys, processor) {
2296
+ if (!this.isAuthenticated()) {
2297
+ const results = /* @__PURE__ */ new Map();
2298
+ const error = {
2299
+ success: false,
2300
+ error: "Not connected to server"
2301
+ };
2302
+ for (const key of keys) {
2303
+ results.set(key, error);
2304
+ }
2305
+ return results;
2306
+ }
2307
+ const requestId = crypto.randomUUID();
2308
+ return new Promise((resolve, reject) => {
2309
+ const timeout = setTimeout(() => {
2310
+ this.pendingBatchProcessorRequests.delete(requestId);
2311
+ reject(new Error("Entry processor batch request timed out"));
2312
+ }, _SyncEngine.PROCESSOR_TIMEOUT);
2313
+ this.pendingBatchProcessorRequests.set(requestId, {
2314
+ resolve: (results) => {
2315
+ clearTimeout(timeout);
2316
+ resolve(results);
2317
+ },
2318
+ reject,
2319
+ timeout
2320
+ });
2321
+ const sent = this.sendMessage({
2322
+ type: "ENTRY_PROCESS_BATCH",
2323
+ requestId,
2324
+ mapName,
2325
+ keys,
2326
+ processor: {
2327
+ name: processor.name,
2328
+ code: processor.code,
2329
+ args: processor.args
2330
+ }
2331
+ });
2332
+ if (!sent) {
2333
+ this.pendingBatchProcessorRequests.delete(requestId);
2334
+ clearTimeout(timeout);
2335
+ reject(new Error("Failed to send entry processor batch request"));
2336
+ }
2337
+ });
2338
+ }
2339
+ /**
2340
+ * Handle entry processor response from server.
2341
+ * Called by handleServerMessage for ENTRY_PROCESS_RESPONSE messages.
2342
+ */
2343
+ handleEntryProcessResponse(message) {
2344
+ const pending = this.pendingProcessorRequests.get(message.requestId);
2345
+ if (pending) {
2346
+ this.pendingProcessorRequests.delete(message.requestId);
2347
+ pending.resolve({
2348
+ success: message.success,
2349
+ result: message.result,
2350
+ newValue: message.newValue,
2351
+ error: message.error
2352
+ });
2353
+ }
2354
+ }
2355
+ /**
2356
+ * Handle entry processor batch response from server.
2357
+ * Called by handleServerMessage for ENTRY_PROCESS_BATCH_RESPONSE messages.
2358
+ */
2359
+ handleEntryProcessBatchResponse(message) {
2360
+ const pending = this.pendingBatchProcessorRequests.get(message.requestId);
2361
+ if (pending) {
2362
+ this.pendingBatchProcessorRequests.delete(message.requestId);
2363
+ const resultsMap = /* @__PURE__ */ new Map();
2364
+ for (const [key, result] of Object.entries(message.results)) {
2365
+ resultsMap.set(key, {
2366
+ success: result.success,
2367
+ result: result.result,
2368
+ newValue: result.newValue,
2369
+ error: result.error
2370
+ });
2371
+ }
2372
+ pending.resolve(resultsMap);
2373
+ }
2374
+ }
2375
+ /**
2376
+ * Subscribe to all incoming messages.
2377
+ * Used by EventJournalReader to receive journal events.
2378
+ *
2379
+ * @param event Event type (currently only 'message')
2380
+ * @param handler Message handler
2381
+ */
2382
+ on(event, handler2) {
2383
+ if (event === "message") {
2384
+ this.messageListeners.add(handler2);
2385
+ }
2386
+ }
2387
+ /**
2388
+ * Unsubscribe from incoming messages.
2389
+ *
2390
+ * @param event Event type (currently only 'message')
2391
+ * @param handler Message handler to remove
2392
+ */
2393
+ off(event, handler2) {
2394
+ if (event === "message") {
2395
+ this.messageListeners.delete(handler2);
2396
+ }
2397
+ }
2398
+ /**
2399
+ * Send a message to the server.
2400
+ * Public method for EventJournalReader and other components.
2401
+ *
2402
+ * @param message Message object to send
2403
+ */
2404
+ send(message) {
2405
+ this.sendMessage(message);
2406
+ }
2407
+ /**
2408
+ * Emit message to all listeners.
2409
+ * Called internally when a message is received.
2410
+ */
2411
+ emitMessage(message) {
2412
+ for (const listener of this.messageListeners) {
2413
+ try {
2414
+ listener(message);
2415
+ } catch (e) {
2416
+ logger.error({ err: e }, "Message listener error");
2417
+ }
2418
+ }
2419
+ }
2420
+ // ============================================
2421
+ // Conflict Resolver Client (Phase 5.05)
2422
+ // ============================================
2423
+ /**
2424
+ * Get the conflict resolver client for registering custom resolvers
2425
+ * and subscribing to merge rejection events.
2426
+ */
2427
+ getConflictResolverClient() {
2428
+ return this.conflictResolverClient;
2429
+ }
1871
2430
  };
2431
+ /** Default timeout for entry processor requests (ms) */
2432
+ _SyncEngine.PROCESSOR_TIMEOUT = 3e4;
2433
+ var SyncEngine = _SyncEngine;
1872
2434
 
1873
2435
  // src/TopGunClient.ts
1874
- var import_core6 = require("@topgunbuild/core");
2436
+ var import_core7 = require("@topgunbuild/core");
2437
+
2438
+ // src/utils/deepEqual.ts
2439
+ function deepEqual(a, b) {
2440
+ if (a === b) return true;
2441
+ if (a == null || b == null) return a === b;
2442
+ if (typeof a !== typeof b) return false;
2443
+ if (typeof a !== "object") return a === b;
2444
+ if (Array.isArray(a)) {
2445
+ if (!Array.isArray(b)) return false;
2446
+ if (a.length !== b.length) return false;
2447
+ for (let i = 0; i < a.length; i++) {
2448
+ if (!deepEqual(a[i], b[i])) return false;
2449
+ }
2450
+ return true;
2451
+ }
2452
+ if (Array.isArray(b)) return false;
2453
+ const objA = a;
2454
+ const objB = b;
2455
+ const keysA = Object.keys(objA);
2456
+ const keysB = Object.keys(objB);
2457
+ if (keysA.length !== keysB.length) return false;
2458
+ for (const key of keysA) {
2459
+ if (!Object.prototype.hasOwnProperty.call(objB, key)) return false;
2460
+ if (!deepEqual(objA[key], objB[key])) return false;
2461
+ }
2462
+ return true;
2463
+ }
2464
+
2465
+ // src/ChangeTracker.ts
2466
+ var ChangeTracker = class {
2467
+ constructor() {
2468
+ this.previousSnapshot = /* @__PURE__ */ new Map();
2469
+ }
2470
+ /**
2471
+ * Computes changes between previous and current state.
2472
+ * Updates internal snapshot after computation.
2473
+ *
2474
+ * @param current - Current state as a Map
2475
+ * @param timestamp - HLC timestamp for the changes
2476
+ * @returns Array of change events (may be empty if no changes)
2477
+ */
2478
+ computeChanges(current, timestamp) {
2479
+ const changes = [];
2480
+ for (const [key, value] of current) {
2481
+ const previous = this.previousSnapshot.get(key);
2482
+ if (previous === void 0) {
2483
+ changes.push({ type: "add", key, value, timestamp });
2484
+ } else if (!deepEqual(previous, value)) {
2485
+ changes.push({
2486
+ type: "update",
2487
+ key,
2488
+ value,
2489
+ previousValue: previous,
2490
+ timestamp
2491
+ });
2492
+ }
2493
+ }
2494
+ for (const [key, value] of this.previousSnapshot) {
2495
+ if (!current.has(key)) {
2496
+ changes.push({
2497
+ type: "remove",
2498
+ key,
2499
+ previousValue: value,
2500
+ timestamp
2501
+ });
2502
+ }
2503
+ }
2504
+ this.previousSnapshot = new Map(
2505
+ Array.from(current.entries()).map(([k, v]) => [
2506
+ k,
2507
+ typeof v === "object" && v !== null ? { ...v } : v
2508
+ ])
2509
+ );
2510
+ return changes;
2511
+ }
2512
+ /**
2513
+ * Reset tracker (e.g., on query change or reconnect)
2514
+ */
2515
+ reset() {
2516
+ this.previousSnapshot.clear();
2517
+ }
2518
+ /**
2519
+ * Get current snapshot size for debugging/metrics
2520
+ */
2521
+ get size() {
2522
+ return this.previousSnapshot.size;
2523
+ }
2524
+ };
1875
2525
 
1876
2526
  // src/QueryHandle.ts
1877
2527
  var QueryHandle = class {
1878
2528
  constructor(syncEngine, mapName, filter = {}) {
1879
2529
  this.listeners = /* @__PURE__ */ new Set();
1880
2530
  this.currentResults = /* @__PURE__ */ new Map();
2531
+ // Change tracking (Phase 5.1)
2532
+ this.changeTracker = new ChangeTracker();
2533
+ this.pendingChanges = [];
2534
+ this.changeListeners = /* @__PURE__ */ new Set();
1881
2535
  // Track if we've received authoritative server response
1882
2536
  this.hasReceivedServerData = false;
1883
2537
  this.id = crypto.randomUUID();
@@ -1920,14 +2574,15 @@ var QueryHandle = class {
1920
2574
  * - Works with any async storage adapter (PostgreSQL, SQLite, Redis, etc.)
1921
2575
  */
1922
2576
  onResult(items, source = "server") {
1923
- console.log(`[QueryHandle:${this.mapName}] onResult called with ${items.length} items`, {
2577
+ logger.debug({
2578
+ mapName: this.mapName,
2579
+ itemCount: items.length,
1924
2580
  source,
1925
2581
  currentResultsCount: this.currentResults.size,
1926
- newItemKeys: items.map((i) => i.key),
1927
2582
  hasReceivedServerData: this.hasReceivedServerData
1928
- });
2583
+ }, "QueryHandle onResult");
1929
2584
  if (source === "server" && items.length === 0 && !this.hasReceivedServerData) {
1930
- console.log(`[QueryHandle:${this.mapName}] Ignoring empty server response - waiting for authoritative data`);
2585
+ logger.debug({ mapName: this.mapName }, "QueryHandle ignoring empty server response - waiting for authoritative data");
1931
2586
  return;
1932
2587
  }
1933
2588
  if (source === "server" && items.length > 0) {
@@ -1942,12 +2597,20 @@ var QueryHandle = class {
1942
2597
  }
1943
2598
  }
1944
2599
  if (removedKeys.length > 0) {
1945
- console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
2600
+ logger.debug({
2601
+ mapName: this.mapName,
2602
+ removedCount: removedKeys.length,
2603
+ removedKeys
2604
+ }, "QueryHandle removed keys");
1946
2605
  }
1947
2606
  for (const item of items) {
1948
2607
  this.currentResults.set(item.key, item.value);
1949
2608
  }
1950
- console.log(`[QueryHandle:${this.mapName}] After merge: ${this.currentResults.size} results`);
2609
+ logger.debug({
2610
+ mapName: this.mapName,
2611
+ resultCount: this.currentResults.size
2612
+ }, "QueryHandle after merge");
2613
+ this.computeAndNotifyChanges(Date.now());
1951
2614
  this.notify();
1952
2615
  }
1953
2616
  /**
@@ -1959,8 +2622,80 @@ var QueryHandle = class {
1959
2622
  } else {
1960
2623
  this.currentResults.set(key, value);
1961
2624
  }
2625
+ this.computeAndNotifyChanges(Date.now());
1962
2626
  this.notify();
1963
2627
  }
2628
+ /**
2629
+ * Subscribe to change events (Phase 5.1).
2630
+ * Returns an unsubscribe function.
2631
+ *
2632
+ * @example
2633
+ * ```typescript
2634
+ * const unsubscribe = handle.onChanges((changes) => {
2635
+ * for (const change of changes) {
2636
+ * if (change.type === 'add') {
2637
+ * console.log('Added:', change.key, change.value);
2638
+ * }
2639
+ * }
2640
+ * });
2641
+ * ```
2642
+ */
2643
+ onChanges(listener) {
2644
+ this.changeListeners.add(listener);
2645
+ return () => this.changeListeners.delete(listener);
2646
+ }
2647
+ /**
2648
+ * Get and clear pending changes (Phase 5.1).
2649
+ * Call this to retrieve all changes since the last consume.
2650
+ */
2651
+ consumeChanges() {
2652
+ const changes = [...this.pendingChanges];
2653
+ this.pendingChanges = [];
2654
+ return changes;
2655
+ }
2656
+ /**
2657
+ * Get last change without consuming (Phase 5.1).
2658
+ * Returns null if no pending changes.
2659
+ */
2660
+ getLastChange() {
2661
+ return this.pendingChanges.length > 0 ? this.pendingChanges[this.pendingChanges.length - 1] : null;
2662
+ }
2663
+ /**
2664
+ * Get all pending changes without consuming (Phase 5.1).
2665
+ */
2666
+ getPendingChanges() {
2667
+ return [...this.pendingChanges];
2668
+ }
2669
+ /**
2670
+ * Clear all pending changes (Phase 5.1).
2671
+ */
2672
+ clearChanges() {
2673
+ this.pendingChanges = [];
2674
+ }
2675
+ /**
2676
+ * Reset change tracker (Phase 5.1).
2677
+ * Use when query filter changes or on reconnect.
2678
+ */
2679
+ resetChangeTracker() {
2680
+ this.changeTracker.reset();
2681
+ this.pendingChanges = [];
2682
+ }
2683
+ computeAndNotifyChanges(timestamp) {
2684
+ const changes = this.changeTracker.computeChanges(this.currentResults, timestamp);
2685
+ if (changes.length > 0) {
2686
+ this.pendingChanges.push(...changes);
2687
+ this.notifyChangeListeners(changes);
2688
+ }
2689
+ }
2690
+ notifyChangeListeners(changes) {
2691
+ for (const listener of this.changeListeners) {
2692
+ try {
2693
+ listener(changes);
2694
+ } catch (e) {
2695
+ logger.error({ err: e }, "QueryHandle change listener error");
2696
+ }
2697
+ }
2698
+ }
1964
2699
  notify() {
1965
2700
  const results = this.getSortedResults();
1966
2701
  for (const listener of this.listeners) {
@@ -2072,12 +2807,300 @@ var TopicHandle = class {
2072
2807
  }
2073
2808
  };
2074
2809
 
2810
+ // src/PNCounterHandle.ts
2811
+ var import_core2 = require("@topgunbuild/core");
2812
+ var COUNTER_STORAGE_PREFIX = "__counter__:";
2813
+ var PNCounterHandle = class {
2814
+ constructor(name, nodeId, syncEngine, storageAdapter) {
2815
+ this.syncScheduled = false;
2816
+ this.persistScheduled = false;
2817
+ this.name = name;
2818
+ this.syncEngine = syncEngine;
2819
+ this.storageAdapter = storageAdapter;
2820
+ this.counter = new import_core2.PNCounterImpl({ nodeId });
2821
+ this.restoreFromStorage();
2822
+ this.unsubscribeFromUpdates = this.syncEngine.onCounterUpdate(name, (state) => {
2823
+ this.counter.merge(state);
2824
+ this.schedulePersist();
2825
+ });
2826
+ this.syncEngine.requestCounter(name);
2827
+ logger.debug({ name, nodeId }, "PNCounterHandle created");
2828
+ }
2829
+ /**
2830
+ * Restore counter state from local storage.
2831
+ * Called during construction to recover offline state.
2832
+ */
2833
+ async restoreFromStorage() {
2834
+ if (!this.storageAdapter) {
2835
+ return;
2836
+ }
2837
+ try {
2838
+ const storageKey = COUNTER_STORAGE_PREFIX + this.name;
2839
+ const stored = await this.storageAdapter.getMeta(storageKey);
2840
+ if (stored && typeof stored === "object" && "p" in stored && "n" in stored) {
2841
+ const state = import_core2.PNCounterImpl.objectToState(stored);
2842
+ this.counter.merge(state);
2843
+ logger.debug({ name: this.name, value: this.counter.get() }, "PNCounter restored from storage");
2844
+ }
2845
+ } catch (err) {
2846
+ logger.error({ err, name: this.name }, "Failed to restore PNCounter from storage");
2847
+ }
2848
+ }
2849
+ /**
2850
+ * Persist counter state to local storage.
2851
+ * Debounced to avoid excessive writes during rapid operations.
2852
+ */
2853
+ schedulePersist() {
2854
+ if (!this.storageAdapter || this.persistScheduled) return;
2855
+ this.persistScheduled = true;
2856
+ setTimeout(() => {
2857
+ this.persistScheduled = false;
2858
+ this.persistToStorage();
2859
+ }, 100);
2860
+ }
2861
+ /**
2862
+ * Actually persist state to storage.
2863
+ */
2864
+ async persistToStorage() {
2865
+ if (!this.storageAdapter) return;
2866
+ try {
2867
+ const storageKey = COUNTER_STORAGE_PREFIX + this.name;
2868
+ const stateObj = import_core2.PNCounterImpl.stateToObject(this.counter.getState());
2869
+ await this.storageAdapter.setMeta(storageKey, stateObj);
2870
+ logger.debug({ name: this.name, value: this.counter.get() }, "PNCounter persisted to storage");
2871
+ } catch (err) {
2872
+ logger.error({ err, name: this.name }, "Failed to persist PNCounter to storage");
2873
+ }
2874
+ }
2875
+ /**
2876
+ * Get current counter value.
2877
+ */
2878
+ get() {
2879
+ return this.counter.get();
2880
+ }
2881
+ /**
2882
+ * Increment by 1 and return new value.
2883
+ */
2884
+ increment() {
2885
+ const value = this.counter.increment();
2886
+ this.scheduleSync();
2887
+ this.schedulePersist();
2888
+ return value;
2889
+ }
2890
+ /**
2891
+ * Decrement by 1 and return new value.
2892
+ */
2893
+ decrement() {
2894
+ const value = this.counter.decrement();
2895
+ this.scheduleSync();
2896
+ this.schedulePersist();
2897
+ return value;
2898
+ }
2899
+ /**
2900
+ * Add delta (positive or negative) and return new value.
2901
+ */
2902
+ addAndGet(delta) {
2903
+ const value = this.counter.addAndGet(delta);
2904
+ if (delta !== 0) {
2905
+ this.scheduleSync();
2906
+ this.schedulePersist();
2907
+ }
2908
+ return value;
2909
+ }
2910
+ /**
2911
+ * Get state for sync.
2912
+ */
2913
+ getState() {
2914
+ return this.counter.getState();
2915
+ }
2916
+ /**
2917
+ * Merge remote state.
2918
+ */
2919
+ merge(remote) {
2920
+ this.counter.merge(remote);
2921
+ }
2922
+ /**
2923
+ * Subscribe to value changes.
2924
+ */
2925
+ subscribe(listener) {
2926
+ return this.counter.subscribe(listener);
2927
+ }
2928
+ /**
2929
+ * Get the counter name.
2930
+ */
2931
+ getName() {
2932
+ return this.name;
2933
+ }
2934
+ /**
2935
+ * Cleanup resources.
2936
+ */
2937
+ dispose() {
2938
+ if (this.unsubscribeFromUpdates) {
2939
+ this.unsubscribeFromUpdates();
2940
+ }
2941
+ }
2942
+ /**
2943
+ * Schedule sync to server with debouncing.
2944
+ * Batches rapid increments to avoid network spam.
2945
+ */
2946
+ scheduleSync() {
2947
+ if (this.syncScheduled) return;
2948
+ this.syncScheduled = true;
2949
+ setTimeout(() => {
2950
+ this.syncScheduled = false;
2951
+ this.syncEngine.syncCounter(this.name, this.counter.getState());
2952
+ }, 50);
2953
+ }
2954
+ };
2955
+
2956
+ // src/EventJournalReader.ts
2957
+ var EventJournalReader = class {
2958
+ constructor(syncEngine) {
2959
+ this.listeners = /* @__PURE__ */ new Map();
2960
+ this.subscriptionCounter = 0;
2961
+ this.syncEngine = syncEngine;
2962
+ }
2963
+ /**
2964
+ * Read events from sequence with optional limit.
2965
+ *
2966
+ * @param sequence Starting sequence (inclusive)
2967
+ * @param limit Maximum events to return (default: 100)
2968
+ * @returns Promise resolving to array of events
2969
+ */
2970
+ async readFrom(sequence, limit = 100) {
2971
+ const requestId = this.generateRequestId();
2972
+ return new Promise((resolve, reject) => {
2973
+ const timeout = setTimeout(() => {
2974
+ reject(new Error("Journal read timeout"));
2975
+ }, 1e4);
2976
+ const handleResponse = (message) => {
2977
+ if (message.type === "JOURNAL_READ_RESPONSE" && message.requestId === requestId) {
2978
+ clearTimeout(timeout);
2979
+ this.syncEngine.off("message", handleResponse);
2980
+ const events = message.events.map((e) => this.parseEvent(e));
2981
+ resolve(events);
2982
+ }
2983
+ };
2984
+ this.syncEngine.on("message", handleResponse);
2985
+ this.syncEngine.send({
2986
+ type: "JOURNAL_READ",
2987
+ requestId,
2988
+ fromSequence: sequence.toString(),
2989
+ limit
2990
+ });
2991
+ });
2992
+ }
2993
+ /**
2994
+ * Read events for a specific map.
2995
+ *
2996
+ * @param mapName Map name to filter
2997
+ * @param sequence Starting sequence (default: 0n)
2998
+ * @param limit Maximum events to return (default: 100)
2999
+ */
3000
+ async readMapEvents(mapName, sequence = 0n, limit = 100) {
3001
+ const requestId = this.generateRequestId();
3002
+ return new Promise((resolve, reject) => {
3003
+ const timeout = setTimeout(() => {
3004
+ reject(new Error("Journal read timeout"));
3005
+ }, 1e4);
3006
+ const handleResponse = (message) => {
3007
+ if (message.type === "JOURNAL_READ_RESPONSE" && message.requestId === requestId) {
3008
+ clearTimeout(timeout);
3009
+ this.syncEngine.off("message", handleResponse);
3010
+ const events = message.events.map((e) => this.parseEvent(e));
3011
+ resolve(events);
3012
+ }
3013
+ };
3014
+ this.syncEngine.on("message", handleResponse);
3015
+ this.syncEngine.send({
3016
+ type: "JOURNAL_READ",
3017
+ requestId,
3018
+ fromSequence: sequence.toString(),
3019
+ limit,
3020
+ mapName
3021
+ });
3022
+ });
3023
+ }
3024
+ /**
3025
+ * Subscribe to new journal events.
3026
+ *
3027
+ * @param listener Callback for each event
3028
+ * @param options Subscription options
3029
+ * @returns Unsubscribe function
3030
+ */
3031
+ subscribe(listener, options = {}) {
3032
+ const subscriptionId = this.generateRequestId();
3033
+ this.listeners.set(subscriptionId, listener);
3034
+ const handleEvent = (message) => {
3035
+ if (message.type === "JOURNAL_EVENT") {
3036
+ const event = this.parseEvent(message.event);
3037
+ if (options.mapName && event.mapName !== options.mapName) return;
3038
+ if (options.types && !options.types.includes(event.type)) return;
3039
+ const listenerFn = this.listeners.get(subscriptionId);
3040
+ if (listenerFn) {
3041
+ try {
3042
+ listenerFn(event);
3043
+ } catch (e) {
3044
+ logger.error({ err: e }, "Journal listener error");
3045
+ }
3046
+ }
3047
+ }
3048
+ };
3049
+ this.syncEngine.on("message", handleEvent);
3050
+ this.syncEngine.send({
3051
+ type: "JOURNAL_SUBSCRIBE",
3052
+ requestId: subscriptionId,
3053
+ fromSequence: options.fromSequence?.toString(),
3054
+ mapName: options.mapName,
3055
+ types: options.types
3056
+ });
3057
+ return () => {
3058
+ this.listeners.delete(subscriptionId);
3059
+ this.syncEngine.off("message", handleEvent);
3060
+ this.syncEngine.send({
3061
+ type: "JOURNAL_UNSUBSCRIBE",
3062
+ subscriptionId
3063
+ });
3064
+ };
3065
+ }
3066
+ /**
3067
+ * Get the latest sequence number from server.
3068
+ */
3069
+ async getLatestSequence() {
3070
+ const events = await this.readFrom(0n, 1);
3071
+ if (events.length === 0) return 0n;
3072
+ return events[events.length - 1].sequence;
3073
+ }
3074
+ /**
3075
+ * Parse network event data to JournalEvent.
3076
+ */
3077
+ parseEvent(raw) {
3078
+ return {
3079
+ sequence: BigInt(raw.sequence),
3080
+ type: raw.type,
3081
+ mapName: raw.mapName,
3082
+ key: raw.key,
3083
+ value: raw.value,
3084
+ previousValue: raw.previousValue,
3085
+ timestamp: raw.timestamp,
3086
+ nodeId: raw.nodeId,
3087
+ metadata: raw.metadata
3088
+ };
3089
+ }
3090
+ /**
3091
+ * Generate unique request ID.
3092
+ */
3093
+ generateRequestId() {
3094
+ return `journal_${Date.now()}_${++this.subscriptionCounter}`;
3095
+ }
3096
+ };
3097
+
2075
3098
  // src/cluster/ClusterClient.ts
2076
- var import_core5 = require("@topgunbuild/core");
3099
+ var import_core6 = require("@topgunbuild/core");
2077
3100
 
2078
3101
  // src/cluster/ConnectionPool.ts
2079
- var import_core2 = require("@topgunbuild/core");
2080
3102
  var import_core3 = require("@topgunbuild/core");
3103
+ var import_core4 = require("@topgunbuild/core");
2081
3104
  var ConnectionPool = class {
2082
3105
  constructor(config = {}) {
2083
3106
  this.listeners = /* @__PURE__ */ new Map();
@@ -2086,7 +3109,7 @@ var ConnectionPool = class {
2086
3109
  this.healthCheckTimer = null;
2087
3110
  this.authToken = null;
2088
3111
  this.config = {
2089
- ...import_core2.DEFAULT_CONNECTION_POOL_CONFIG,
3112
+ ...import_core3.DEFAULT_CONNECTION_POOL_CONFIG,
2090
3113
  ...config
2091
3114
  };
2092
3115
  }
@@ -2224,7 +3247,7 @@ var ConnectionPool = class {
2224
3247
  logger.warn({ nodeId }, "Cannot send: node not in pool");
2225
3248
  return false;
2226
3249
  }
2227
- const data = (0, import_core3.serialize)(message);
3250
+ const data = (0, import_core4.serialize)(message);
2228
3251
  if (connection.state === "AUTHENTICATED" && connection.socket?.readyState === WebSocket.OPEN) {
2229
3252
  connection.socket.send(data);
2230
3253
  return true;
@@ -2367,7 +3390,7 @@ var ConnectionPool = class {
2367
3390
  }
2368
3391
  sendAuth(connection) {
2369
3392
  if (!this.authToken || !connection.socket) return;
2370
- connection.socket.send((0, import_core3.serialize)({
3393
+ connection.socket.send((0, import_core4.serialize)({
2371
3394
  type: "AUTH",
2372
3395
  token: this.authToken
2373
3396
  }));
@@ -2378,7 +3401,7 @@ var ConnectionPool = class {
2378
3401
  let message;
2379
3402
  try {
2380
3403
  if (event.data instanceof ArrayBuffer) {
2381
- message = (0, import_core3.deserialize)(new Uint8Array(event.data));
3404
+ message = (0, import_core4.deserialize)(new Uint8Array(event.data));
2382
3405
  } else {
2383
3406
  message = JSON.parse(event.data);
2384
3407
  }
@@ -2463,7 +3486,7 @@ var ConnectionPool = class {
2463
3486
  logger.warn({ nodeId, timeSinceLastSeen }, "Node appears stale, sending ping");
2464
3487
  }
2465
3488
  if (connection.socket?.readyState === WebSocket.OPEN) {
2466
- connection.socket.send((0, import_core3.serialize)({
3489
+ connection.socket.send((0, import_core4.serialize)({
2467
3490
  type: "PING",
2468
3491
  timestamp: now
2469
3492
  }));
@@ -2473,7 +3496,7 @@ var ConnectionPool = class {
2473
3496
  };
2474
3497
 
2475
3498
  // src/cluster/PartitionRouter.ts
2476
- var import_core4 = require("@topgunbuild/core");
3499
+ var import_core5 = require("@topgunbuild/core");
2477
3500
  var PartitionRouter = class {
2478
3501
  constructor(connectionPool, config = {}) {
2479
3502
  this.listeners = /* @__PURE__ */ new Map();
@@ -2483,7 +3506,7 @@ var PartitionRouter = class {
2483
3506
  this.pendingRefresh = null;
2484
3507
  this.connectionPool = connectionPool;
2485
3508
  this.config = {
2486
- ...import_core4.DEFAULT_PARTITION_ROUTER_CONFIG,
3509
+ ...import_core5.DEFAULT_PARTITION_ROUTER_CONFIG,
2487
3510
  ...config
2488
3511
  };
2489
3512
  this.connectionPool.on("message", (nodeId, message) => {
@@ -2544,7 +3567,7 @@ var PartitionRouter = class {
2544
3567
  * Get the partition ID for a given key
2545
3568
  */
2546
3569
  getPartitionId(key) {
2547
- return Math.abs((0, import_core4.hashString)(key)) % import_core4.PARTITION_COUNT;
3570
+ return Math.abs((0, import_core5.hashString)(key)) % import_core5.PARTITION_COUNT;
2548
3571
  }
2549
3572
  /**
2550
3573
  * Route a key to the owner node
@@ -2881,16 +3904,16 @@ var ClusterClient = class {
2881
3904
  this.circuits = /* @__PURE__ */ new Map();
2882
3905
  this.config = config;
2883
3906
  this.circuitBreakerConfig = {
2884
- ...import_core5.DEFAULT_CIRCUIT_BREAKER_CONFIG,
3907
+ ...import_core6.DEFAULT_CIRCUIT_BREAKER_CONFIG,
2885
3908
  ...config.circuitBreaker
2886
3909
  };
2887
3910
  const poolConfig = {
2888
- ...import_core5.DEFAULT_CONNECTION_POOL_CONFIG,
3911
+ ...import_core6.DEFAULT_CONNECTION_POOL_CONFIG,
2889
3912
  ...config.connectionPool
2890
3913
  };
2891
3914
  this.connectionPool = new ConnectionPool(poolConfig);
2892
3915
  const routerConfig = {
2893
- ...import_core5.DEFAULT_PARTITION_ROUTER_CONFIG,
3916
+ ...import_core6.DEFAULT_PARTITION_ROUTER_CONFIG,
2894
3917
  fallbackMode: config.routingMode === "direct" ? "error" : "forward",
2895
3918
  ...config.routing
2896
3919
  };
@@ -3007,7 +4030,7 @@ var ClusterClient = class {
3007
4030
  const socket = this.connectionPool.getConnection(nodeId);
3008
4031
  if (socket) {
3009
4032
  logger.debug({ nodeId }, "Requesting partition map from node");
3010
- socket.send((0, import_core5.serialize)({
4033
+ socket.send((0, import_core6.serialize)({
3011
4034
  type: "PARTITION_MAP_REQUEST",
3012
4035
  payload: {
3013
4036
  currentVersion: this.partitionRouter.getMapVersion()
@@ -3192,7 +4215,7 @@ var ClusterClient = class {
3192
4215
  mapVersion: this.partitionRouter.getMapVersion()
3193
4216
  }
3194
4217
  };
3195
- connection.socket.send((0, import_core5.serialize)(routedMessage));
4218
+ connection.socket.send((0, import_core6.serialize)(routedMessage));
3196
4219
  return true;
3197
4220
  }
3198
4221
  /**
@@ -3499,6 +4522,7 @@ var TopGunClient = class {
3499
4522
  constructor(config) {
3500
4523
  this.maps = /* @__PURE__ */ new Map();
3501
4524
  this.topicHandles = /* @__PURE__ */ new Map();
4525
+ this.counters = /* @__PURE__ */ new Map();
3502
4526
  if (config.serverUrl && config.cluster) {
3503
4527
  throw new Error("Cannot specify both serverUrl and cluster config");
3504
4528
  }
@@ -3583,6 +4607,34 @@ var TopGunClient = class {
3583
4607
  }
3584
4608
  return this.topicHandles.get(name);
3585
4609
  }
4610
+ /**
4611
+ * Retrieves a PN Counter instance. If the counter doesn't exist locally, it's created.
4612
+ * PN Counters support increment and decrement operations that work offline
4613
+ * and sync to server when connected.
4614
+ *
4615
+ * @param name The name of the counter (e.g., 'likes:post-123')
4616
+ * @returns A PNCounterHandle instance
4617
+ *
4618
+ * @example
4619
+ * ```typescript
4620
+ * const likes = client.getPNCounter('likes:post-123');
4621
+ * likes.increment(); // +1
4622
+ * likes.decrement(); // -1
4623
+ * likes.addAndGet(10); // +10
4624
+ *
4625
+ * likes.subscribe((value) => {
4626
+ * console.log('Current likes:', value);
4627
+ * });
4628
+ * ```
4629
+ */
4630
+ getPNCounter(name) {
4631
+ let counter = this.counters.get(name);
4632
+ if (!counter) {
4633
+ counter = new PNCounterHandle(name, this.nodeId, this.syncEngine, this.storageAdapter);
4634
+ this.counters.set(name, counter);
4635
+ }
4636
+ return counter;
4637
+ }
3586
4638
  /**
3587
4639
  * Retrieves an LWWMap instance. If the map doesn't exist locally, it's created.
3588
4640
  * @param name The name of the map.
@@ -3591,12 +4643,12 @@ var TopGunClient = class {
3591
4643
  getMap(name) {
3592
4644
  if (this.maps.has(name)) {
3593
4645
  const map = this.maps.get(name);
3594
- if (map instanceof import_core6.LWWMap) {
4646
+ if (map instanceof import_core7.LWWMap) {
3595
4647
  return map;
3596
4648
  }
3597
4649
  throw new Error(`Map ${name} exists but is not an LWWMap`);
3598
4650
  }
3599
- const lwwMap = new import_core6.LWWMap(this.syncEngine.getHLC());
4651
+ const lwwMap = new import_core7.LWWMap(this.syncEngine.getHLC());
3600
4652
  this.maps.set(name, lwwMap);
3601
4653
  this.syncEngine.registerMap(name, lwwMap);
3602
4654
  this.storageAdapter.getAllKeys().then(async (keys) => {
@@ -3635,12 +4687,12 @@ var TopGunClient = class {
3635
4687
  getORMap(name) {
3636
4688
  if (this.maps.has(name)) {
3637
4689
  const map = this.maps.get(name);
3638
- if (map instanceof import_core6.ORMap) {
4690
+ if (map instanceof import_core7.ORMap) {
3639
4691
  return map;
3640
4692
  }
3641
4693
  throw new Error(`Map ${name} exists but is not an ORMap`);
3642
4694
  }
3643
- const orMap = new import_core6.ORMap(this.syncEngine.getHLC());
4695
+ const orMap = new import_core7.ORMap(this.syncEngine.getHLC());
3644
4696
  this.maps.set(name, orMap);
3645
4697
  this.syncEngine.registerMap(name, orMap);
3646
4698
  this.restoreORMap(name, orMap);
@@ -3855,6 +4907,175 @@ var TopGunClient = class {
3855
4907
  onBackpressure(event, listener) {
3856
4908
  return this.syncEngine.onBackpressure(event, listener);
3857
4909
  }
4910
+ // ============================================
4911
+ // Entry Processor API (Phase 5.03)
4912
+ // ============================================
4913
+ /**
4914
+ * Execute an entry processor on a single key atomically.
4915
+ *
4916
+ * Entry processors solve the read-modify-write race condition by executing
4917
+ * user-defined logic atomically on the server where the data lives.
4918
+ *
4919
+ * @param mapName Name of the map
4920
+ * @param key Key to process
4921
+ * @param processor Processor definition with name, code, and optional args
4922
+ * @returns Promise resolving to the processor result
4923
+ *
4924
+ * @example
4925
+ * ```typescript
4926
+ * // Increment a counter atomically
4927
+ * const result = await client.executeOnKey('stats', 'pageViews', {
4928
+ * name: 'increment',
4929
+ * code: `
4930
+ * const current = value ?? 0;
4931
+ * return { value: current + 1, result: current + 1 };
4932
+ * `,
4933
+ * });
4934
+ *
4935
+ * // Using built-in processor
4936
+ * import { BuiltInProcessors } from '@topgunbuild/core';
4937
+ * const result = await client.executeOnKey(
4938
+ * 'stats',
4939
+ * 'pageViews',
4940
+ * BuiltInProcessors.INCREMENT(1)
4941
+ * );
4942
+ * ```
4943
+ */
4944
+ async executeOnKey(mapName, key, processor) {
4945
+ const result = await this.syncEngine.executeOnKey(mapName, key, processor);
4946
+ if (result.success && result.newValue !== void 0) {
4947
+ const map = this.maps.get(mapName);
4948
+ if (map instanceof import_core7.LWWMap) {
4949
+ map.set(key, result.newValue);
4950
+ }
4951
+ }
4952
+ return result;
4953
+ }
4954
+ /**
4955
+ * Execute an entry processor on multiple keys.
4956
+ *
4957
+ * Each key is processed atomically. The operation returns when all keys
4958
+ * have been processed.
4959
+ *
4960
+ * @param mapName Name of the map
4961
+ * @param keys Keys to process
4962
+ * @param processor Processor definition
4963
+ * @returns Promise resolving to a map of key -> result
4964
+ *
4965
+ * @example
4966
+ * ```typescript
4967
+ * // Reset multiple counters
4968
+ * const results = await client.executeOnKeys(
4969
+ * 'stats',
4970
+ * ['pageViews', 'uniqueVisitors', 'bounceRate'],
4971
+ * {
4972
+ * name: 'reset',
4973
+ * code: `return { value: 0, result: value };`, // Returns old value
4974
+ * }
4975
+ * );
4976
+ *
4977
+ * for (const [key, result] of results) {
4978
+ * console.log(`${key}: was ${result.result}, now 0`);
4979
+ * }
4980
+ * ```
4981
+ */
4982
+ async executeOnKeys(mapName, keys, processor) {
4983
+ const results = await this.syncEngine.executeOnKeys(mapName, keys, processor);
4984
+ const map = this.maps.get(mapName);
4985
+ if (map instanceof import_core7.LWWMap) {
4986
+ for (const [key, result] of results) {
4987
+ if (result.success && result.newValue !== void 0) {
4988
+ map.set(key, result.newValue);
4989
+ }
4990
+ }
4991
+ }
4992
+ return results;
4993
+ }
4994
+ /**
4995
+ * Get the Event Journal reader for subscribing to and reading
4996
+ * map change events.
4997
+ *
4998
+ * The Event Journal provides:
4999
+ * - Append-only log of all map changes (PUT, UPDATE, DELETE)
5000
+ * - Subscription to real-time events
5001
+ * - Historical event replay
5002
+ * - Audit trail for compliance
5003
+ *
5004
+ * @returns EventJournalReader instance
5005
+ *
5006
+ * @example
5007
+ * ```typescript
5008
+ * const journal = client.getEventJournal();
5009
+ *
5010
+ * // Subscribe to all events
5011
+ * const unsubscribe = journal.subscribe((event) => {
5012
+ * console.log(`${event.type} on ${event.mapName}:${event.key}`);
5013
+ * });
5014
+ *
5015
+ * // Subscribe to specific map
5016
+ * journal.subscribe(
5017
+ * (event) => console.log('User changed:', event.key),
5018
+ * { mapName: 'users' }
5019
+ * );
5020
+ *
5021
+ * // Read historical events
5022
+ * const events = await journal.readFrom(0n, 100);
5023
+ * ```
5024
+ */
5025
+ getEventJournal() {
5026
+ if (!this.journalReader) {
5027
+ this.journalReader = new EventJournalReader(this.syncEngine);
5028
+ }
5029
+ return this.journalReader;
5030
+ }
5031
+ // ============================================
5032
+ // Conflict Resolver API (Phase 5.05)
5033
+ // ============================================
5034
+ /**
5035
+ * Get the conflict resolver client for registering custom merge resolvers.
5036
+ *
5037
+ * Conflict resolvers allow you to customize how merge conflicts are handled
5038
+ * on the server. You can implement business logic like:
5039
+ * - First-write-wins for booking systems
5040
+ * - Numeric constraints (non-negative, min/max)
5041
+ * - Owner-only modifications
5042
+ * - Custom merge strategies
5043
+ *
5044
+ * @returns ConflictResolverClient instance
5045
+ *
5046
+ * @example
5047
+ * ```typescript
5048
+ * const resolvers = client.getConflictResolvers();
5049
+ *
5050
+ * // Register a first-write-wins resolver
5051
+ * await resolvers.register('bookings', {
5052
+ * name: 'first-write-wins',
5053
+ * code: `
5054
+ * if (context.localValue !== undefined) {
5055
+ * return { action: 'reject', reason: 'Slot already booked' };
5056
+ * }
5057
+ * return { action: 'accept', value: context.remoteValue };
5058
+ * `,
5059
+ * priority: 100,
5060
+ * });
5061
+ *
5062
+ * // Subscribe to merge rejections
5063
+ * resolvers.onRejection((rejection) => {
5064
+ * console.log(`Merge rejected: ${rejection.reason}`);
5065
+ * // Optionally refresh local state
5066
+ * });
5067
+ *
5068
+ * // List registered resolvers
5069
+ * const registered = await resolvers.list('bookings');
5070
+ * console.log('Active resolvers:', registered);
5071
+ *
5072
+ * // Unregister when done
5073
+ * await resolvers.unregister('bookings', 'first-write-wins');
5074
+ * ```
5075
+ */
5076
+ getConflictResolvers() {
5077
+ return this.syncEngine.getConflictResolverClient();
5078
+ }
3858
5079
  };
3859
5080
 
3860
5081
  // src/adapters/IDBAdapter.ts
@@ -4122,14 +5343,14 @@ var CollectionWrapper = class {
4122
5343
  };
4123
5344
 
4124
5345
  // src/crypto/EncryptionManager.ts
4125
- var import_core7 = require("@topgunbuild/core");
5346
+ var import_core8 = require("@topgunbuild/core");
4126
5347
  var _EncryptionManager = class _EncryptionManager {
4127
5348
  /**
4128
5349
  * Encrypts data using AES-GCM.
4129
5350
  * Serializes data to MessagePack before encryption.
4130
5351
  */
4131
5352
  static async encrypt(key, data) {
4132
- const encoded = (0, import_core7.serialize)(data);
5353
+ const encoded = (0, import_core8.serialize)(data);
4133
5354
  const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
4134
5355
  const ciphertext = await window.crypto.subtle.encrypt(
4135
5356
  {
@@ -4158,7 +5379,7 @@ var _EncryptionManager = class _EncryptionManager {
4158
5379
  key,
4159
5380
  record.data
4160
5381
  );
4161
- return (0, import_core7.deserialize)(new Uint8Array(plaintextBuffer));
5382
+ return (0, import_core8.deserialize)(new Uint8Array(plaintextBuffer));
4162
5383
  } catch (err) {
4163
5384
  console.error("Decryption failed", err);
4164
5385
  throw new Error("Failed to decrypt data: " + err);
@@ -4282,17 +5503,21 @@ var EncryptedStorageAdapter = class {
4282
5503
  };
4283
5504
 
4284
5505
  // src/index.ts
4285
- var import_core8 = require("@topgunbuild/core");
5506
+ var import_core9 = require("@topgunbuild/core");
4286
5507
  // Annotate the CommonJS export names for ESM import in node:
4287
5508
  0 && (module.exports = {
4288
5509
  BackpressureError,
5510
+ ChangeTracker,
4289
5511
  ClusterClient,
5512
+ ConflictResolverClient,
4290
5513
  ConnectionPool,
4291
5514
  DEFAULT_BACKPRESSURE_CONFIG,
4292
5515
  DEFAULT_CLUSTER_CONFIG,
4293
5516
  EncryptedStorageAdapter,
5517
+ EventJournalReader,
4294
5518
  IDBAdapter,
4295
5519
  LWWMap,
5520
+ PNCounterHandle,
4296
5521
  PartitionRouter,
4297
5522
  Predicates,
4298
5523
  QueryHandle,