@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.mjs CHANGED
@@ -430,6 +430,237 @@ var SingleServerProvider = class {
430
430
  }
431
431
  };
432
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
+
433
664
  // src/SyncEngine.ts
434
665
  var DEFAULT_BACKOFF_CONFIG = {
435
666
  initialDelayMs: 1e3,
@@ -438,7 +669,7 @@ var DEFAULT_BACKOFF_CONFIG = {
438
669
  jitter: true,
439
670
  maxRetries: 10
440
671
  };
441
- var SyncEngine = class {
672
+ var _SyncEngine = class _SyncEngine {
442
673
  constructor(config) {
443
674
  this.websocket = null;
444
675
  this.opLog = [];
@@ -461,6 +692,23 @@ var SyncEngine = class {
461
692
  this.backpressureListeners = /* @__PURE__ */ new Map();
462
693
  // Write Concern state (Phase 5.01)
463
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();
464
712
  if (!config.serverUrl && !config.connectionProvider) {
465
713
  throw new Error("SyncEngine requires either serverUrl or connectionProvider");
466
714
  }
@@ -491,6 +739,7 @@ var SyncEngine = class {
491
739
  this.useConnectionProvider = false;
492
740
  this.initConnection();
493
741
  }
742
+ this.conflictResolverClient = new ConflictResolverClient(this);
494
743
  this.loadOpLog();
495
744
  }
496
745
  // ============================================
@@ -973,6 +1222,7 @@ var SyncEngine = class {
973
1222
  });
974
1223
  }
975
1224
  async handleServerMessage(message) {
1225
+ this.emitMessage(message);
976
1226
  switch (message.type) {
977
1227
  case "BATCH": {
978
1228
  const batchData = message.data;
@@ -1299,6 +1549,51 @@ var SyncEngine = class {
1299
1549
  }
1300
1550
  break;
1301
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
+ }
1302
1597
  }
1303
1598
  if (message.timestamp) {
1304
1599
  this.hlc.update(message.timestamp);
@@ -1812,16 +2107,371 @@ var SyncEngine = class {
1812
2107
  }
1813
2108
  this.pendingWriteConcernPromises.clear();
1814
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
+ }
1815
2370
  };
2371
+ /** Default timeout for entry processor requests (ms) */
2372
+ _SyncEngine.PROCESSOR_TIMEOUT = 3e4;
2373
+ var SyncEngine = _SyncEngine;
1816
2374
 
1817
2375
  // src/TopGunClient.ts
1818
2376
  import { LWWMap as LWWMap2, ORMap as ORMap2 } from "@topgunbuild/core";
1819
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
+
1820
2466
  // src/QueryHandle.ts
1821
2467
  var QueryHandle = class {
1822
2468
  constructor(syncEngine, mapName, filter = {}) {
1823
2469
  this.listeners = /* @__PURE__ */ new Set();
1824
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();
1825
2475
  // Track if we've received authoritative server response
1826
2476
  this.hasReceivedServerData = false;
1827
2477
  this.id = crypto.randomUUID();
@@ -1864,14 +2514,15 @@ var QueryHandle = class {
1864
2514
  * - Works with any async storage adapter (PostgreSQL, SQLite, Redis, etc.)
1865
2515
  */
1866
2516
  onResult(items, source = "server") {
1867
- console.log(`[QueryHandle:${this.mapName}] onResult called with ${items.length} items`, {
2517
+ logger.debug({
2518
+ mapName: this.mapName,
2519
+ itemCount: items.length,
1868
2520
  source,
1869
2521
  currentResultsCount: this.currentResults.size,
1870
- newItemKeys: items.map((i) => i.key),
1871
2522
  hasReceivedServerData: this.hasReceivedServerData
1872
- });
2523
+ }, "QueryHandle onResult");
1873
2524
  if (source === "server" && items.length === 0 && !this.hasReceivedServerData) {
1874
- 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");
1875
2526
  return;
1876
2527
  }
1877
2528
  if (source === "server" && items.length > 0) {
@@ -1886,12 +2537,20 @@ var QueryHandle = class {
1886
2537
  }
1887
2538
  }
1888
2539
  if (removedKeys.length > 0) {
1889
- 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");
1890
2545
  }
1891
2546
  for (const item of items) {
1892
2547
  this.currentResults.set(item.key, item.value);
1893
2548
  }
1894
- 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());
1895
2554
  this.notify();
1896
2555
  }
1897
2556
  /**
@@ -1903,8 +2562,80 @@ var QueryHandle = class {
1903
2562
  } else {
1904
2563
  this.currentResults.set(key, value);
1905
2564
  }
2565
+ this.computeAndNotifyChanges(Date.now());
1906
2566
  this.notify();
1907
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
+ }
1908
2639
  notify() {
1909
2640
  const results = this.getSortedResults();
1910
2641
  for (const listener of this.listeners) {
@@ -2016,6 +2747,294 @@ var TopicHandle = class {
2016
2747
  }
2017
2748
  };
2018
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
+
2019
3038
  // src/cluster/ClusterClient.ts
2020
3039
  import {
2021
3040
  DEFAULT_CONNECTION_POOL_CONFIG as DEFAULT_CONNECTION_POOL_CONFIG2,
@@ -3454,6 +4473,7 @@ var TopGunClient = class {
3454
4473
  constructor(config) {
3455
4474
  this.maps = /* @__PURE__ */ new Map();
3456
4475
  this.topicHandles = /* @__PURE__ */ new Map();
4476
+ this.counters = /* @__PURE__ */ new Map();
3457
4477
  if (config.serverUrl && config.cluster) {
3458
4478
  throw new Error("Cannot specify both serverUrl and cluster config");
3459
4479
  }
@@ -3538,6 +4558,34 @@ var TopGunClient = class {
3538
4558
  }
3539
4559
  return this.topicHandles.get(name);
3540
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
+ }
3541
4589
  /**
3542
4590
  * Retrieves an LWWMap instance. If the map doesn't exist locally, it's created.
3543
4591
  * @param name The name of the map.
@@ -3810,6 +4858,175 @@ var TopGunClient = class {
3810
4858
  onBackpressure(event, listener) {
3811
4859
  return this.syncEngine.onBackpressure(event, listener);
3812
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
+ }
3813
5030
  };
3814
5031
 
3815
5032
  // src/adapters/IDBAdapter.ts
@@ -4240,13 +5457,17 @@ var EncryptedStorageAdapter = class {
4240
5457
  import { LWWMap as LWWMap3, Predicates } from "@topgunbuild/core";
4241
5458
  export {
4242
5459
  BackpressureError,
5460
+ ChangeTracker,
4243
5461
  ClusterClient,
5462
+ ConflictResolverClient,
4244
5463
  ConnectionPool,
4245
5464
  DEFAULT_BACKPRESSURE_CONFIG,
4246
5465
  DEFAULT_CLUSTER_CONFIG,
4247
5466
  EncryptedStorageAdapter,
5467
+ EventJournalReader,
4248
5468
  IDBAdapter,
4249
5469
  LWWMap3 as LWWMap,
5470
+ PNCounterHandle,
4250
5471
  PartitionRouter,
4251
5472
  Predicates,
4252
5473
  QueryHandle,