@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.d.mts +653 -2
- package/dist/index.d.ts +653 -2
- package/dist/index.js +1258 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1228 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
- package/LICENSE +0 -97
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|