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